diff --git a/src/backend/common/infrastructure/config/client/tealfm.ts b/src/backend/common/infrastructure/config/client/tealfm.ts index ce75ae45..5089e70e 100644 --- a/src/backend/common/infrastructure/config/client/tealfm.ts +++ b/src/backend/common/infrastructure/config/client/tealfm.ts @@ -79,6 +79,15 @@ export interface ScrobbleRecord { [x: string]: unknown } +export interface StatusRecord { + $type: "fm.teal.alpha.actor.status", + /** item is just ScrobbleRecord, but without $type */ + item: Omit, + time: string, + expiry: string, + [x: string]: unknown +} + export interface ListRecord { uri: string; cid: string; diff --git a/src/backend/common/vendor/bluesky/AbstractBlueSkyApiClient.ts b/src/backend/common/vendor/bluesky/AbstractBlueSkyApiClient.ts index 61881ce7..e8b902a8 100644 --- a/src/backend/common/vendor/bluesky/AbstractBlueSkyApiClient.ts +++ b/src/backend/common/vendor/bluesky/AbstractBlueSkyApiClient.ts @@ -1,10 +1,10 @@ import { getRoot } from "../../../ioc.js"; import { AbstractApiOptions, PagelessListensTimeRangeOptions, PagelessTimeRangeListens, PagelessTimeRangeListensResult } from "../../infrastructure/Atomic.js"; -import { ListRecord, ScrobbleRecord, TealClientData } from "../../infrastructure/config/client/tealfm.js"; +import { ListRecord, ScrobbleRecord, StatusRecord, TealClientData } from "../../infrastructure/config/client/tealfm.js"; import AbstractApiClient from "../AbstractApiClient.js"; -import { Agent, ComAtprotoRepoCreateRecord, ComAtprotoRepoListRecords } from "@atproto/api"; +import { Agent, ComAtprotoRepoCreateRecord, ComAtprotoRepoListRecords, ComAtprotoRepoPutRecord } from "@atproto/api"; import { MSCache } from "../../Cache.js"; -import { BrainzMeta, PlayObject, PlayObjectLifecycleless, ScrobbleActionResult, UnixTimestamp } from "../../../../core/Atomic.js"; +import { BrainzMeta, PlayObject, SourcePlayerObj, PlayObjectLifecycleless, ScrobbleActionResult, UnixTimestamp } from "../../../../core/Atomic.js"; import { musicServiceToCononical } from '../listenbrainz/lzUtils.js'; import { parseRegexSingle } from "@foxxmd/regex-buddy-core"; import { RecordOptions } from "../../infrastructure/config/client/tealfm.js"; @@ -48,6 +48,21 @@ export abstract class AbstractBlueSkyApiClient extends AbstractApiClient impleme } } + async updateStatusRecord(record: StatusRecord): Promise { + const input: ComAtprotoRepoPutRecord.InputSchema = { + repo: this.agent.sessionManager.did, + collection: "fm.teal.alpha.actor.status", + rkey: "self", + record + }; + try { + const resp = await this.agent.com.atproto.repo.putRecord(input); + return {payload: input, response: resp.data}; + } catch (e) { + throw new ScrobbleSubmitError(`Failed to update status record for scrobble`, { cause: e, payload: input, response: 'response' in e ? e.response : undefined }); + } + } + async listScrobbleRecord(options: {limit?: number, cursor?: string} = {}): Promise { const {limit = 20, cursor} = options; try { @@ -108,6 +123,19 @@ export const playToRecord = (play: PlayObject): ScrobbleRecord => { return record; } +export const playToStatusRecord = (play: PlayObject, notPlaying: boolean, position?: number): StatusRecord => { + const { $type, ...item } = playToRecord(play); + return { + $type: "fm.teal.alpha.actor.status", + time: dayjs().toISOString(), + // expiry is 1min ago if paused, (now + duration - position) if position is available, or fallback to (now + 10mins) + expiry: notPlaying ? dayjs().subtract(1, 'minute').toISOString() + : position !== undefined ? dayjs().add(play.data.duration - position, 'second').toISOString() + : dayjs().add(10, 'minute').toISOString(), + item + }; +} + export const listRecordToPlay = (listRecord: ListRecord): PlayObject => { const opts: RecordOptions = {}; const uriRes = parseRegexSingle(ATPROTO_URI_REGEX, listRecord.uri); diff --git a/src/backend/scrobblers/TealfmScrobbler.ts b/src/backend/scrobblers/TealfmScrobbler.ts index c456f9d3..1050f37b 100644 --- a/src/backend/scrobblers/TealfmScrobbler.ts +++ b/src/backend/scrobblers/TealfmScrobbler.ts @@ -1,9 +1,9 @@ -import { Logger } from "@foxxmd/logging"; +import { Logger, LogLevel } from "@foxxmd/logging"; import EventEmitter from "events"; -import { PlayObject } from "../../core/Atomic.js"; +import { PlayObject, SourcePlayerObj } from "../../core/Atomic.js"; import { buildTrackString, capitalize } from "../../core/StringUtils.js"; import { isNodeNetworkException } from "../common/errors/NodeErrors.js"; -import { FormatPlayObjectOptions } from "../common/infrastructure/Atomic.js"; +import { FormatPlayObjectOptions, CALCULATED_PLAYER_STATUSES, ReportedPlayerStatus } from "../common/infrastructure/Atomic.js"; import { playToListenPayload } from '../common/vendor/listenbrainz/lzUtils.js'; import { Notifiers } from "../notifier/Notifiers.js"; @@ -11,12 +11,13 @@ import AbstractScrobbleClient from "./AbstractScrobbleClient.js"; import { TealClientConfig } from "../common/infrastructure/config/client/tealfm.js"; import { BlueSkyAppApiClient } from "../common/vendor/bluesky/BlueSkyAppApiClient.js"; import { BlueSkyOauthApiClient } from "../common/vendor/bluesky/BlueSkyOauthApiClient.js"; -import { AbstractBlueSkyApiClient, listRecordToPlay, playToRecord, recordToPlay } from "../common/vendor/bluesky/AbstractBlueSkyApiClient.js"; +import { AbstractBlueSkyApiClient, listRecordToPlay, playToRecord, playToStatusRecord, recordToPlay } from "../common/vendor/bluesky/AbstractBlueSkyApiClient.js"; export default class TealScrobbler extends AbstractScrobbleClient { requiresAuth = true; requiresAuthInteraction = false; + clearedStatus = false; // tracks if a user's status has been cleared on their repo declare config: TealClientConfig; @@ -26,7 +27,7 @@ export default class TealScrobbler extends AbstractScrobbleClient { super('tealfm', name, config, notifier, emitter, logger); this.MAX_INITIAL_SCROBBLES_FETCH = 20; this.scrobbleDelay = 1500; - this.supportsNowPlaying = false; + this.supportsNowPlaying = true; if(config.data.appPassword !== undefined) { this.client = new BlueSkyAppApiClient(name, config.data, {...options, logger}); this.requiresAuthInteraction = false; @@ -121,5 +122,34 @@ export default class TealScrobbler extends AbstractScrobbleClient { throw e; } } + + doPlayingNow = async (data: SourcePlayerObj) => { + const notPlaying = [CALCULATED_PLAYER_STATUSES.stopped, CALCULATED_PLAYER_STATUSES.paused].includes(data.status.calculated as ReportedPlayerStatus); + try { + await this.client.updateStatusRecord(playToStatusRecord(data.play, notPlaying, data.position)); + this.clearedStatus = notPlaying; + } catch (e) { + throw e; + } + } + + shouldUpdatePlayingNowPlatformSpecific = async (data: SourcePlayerObj): Promise<[boolean, string?, LogLevel?]> => { + if ([CALCULATED_PLAYER_STATUSES.stopped, CALCULATED_PLAYER_STATUSES.paused].includes(data.status.calculated as ReportedPlayerStatus) && !this.clearedStatus + || [CALCULATED_PLAYER_STATUSES.playing].includes(data.status.calculated as ReportedPlayerStatus) + || (data.nowPlayingMode && !CALCULATED_PLAYER_STATUSES.stopped)) { + return [true]; + } else { + if(!data.nowPlayingMode && ![CALCULATED_PLAYER_STATUSES.stopped, CALCULATED_PLAYER_STATUSES.paused, CALCULATED_PLAYER_STATUSES.playing].includes(data.status.calculated as ReportedPlayerStatus)) { + return [false,`player is not in state: stopped | paused | playing => Found '${data.status.calculated }'`]; + } else if (this.clearedStatus) { + return [false, 'teal.fm status has already been set to expired']; + } else if (data.nowPlayingMode && CALCULATED_PLAYER_STATUSES.stopped) { + this.npLogger.trace(`Will not update because now playing player is stopped => Found ${data.status.calculated}`); + return [false,`playing player is stopped => Found ${data.status.calculated}` ] + } else { + return [false, 'player is in an unexpected state for teal.fm usage'] + } + } + } }