[READ-ONLY] Mirror of https://github.com/FoxxMD/multi-scrobbler. Scrobble plays from multiple sources to multiple clients docs.multi-scrobbler.app
deezer docker jellyfin koito lastfm listenbrainz maloja mopidy mpris music music-assistant plex scrobble self-hosted spotify subsonic tautulli youtube-music
0

Configure Feed

Select the types of activity you want to include in your feed.

feat(tealfm): Add now playing support with fm.teal.alpha.actor.status

Jax (May 26, 2026, 5:20 AM EDT) 925f3b91 a00e5d0d

+75 -8
+9
src/backend/common/infrastructure/config/client/tealfm.ts
··· 79 79 [x: string]: unknown 80 80 } 81 81 82 + export interface StatusRecord { 83 + $type: "fm.teal.alpha.actor.status", 84 + /** item is just ScrobbleRecord, but without $type */ 85 + item: Omit<ScrobbleRecord, '$type'>, 86 + time: string, 87 + expiry: string, 88 + [x: string]: unknown 89 + } 90 + 82 91 export interface ListRecord<T> { 83 92 uri: string; 84 93 cid: string;
+31 -3
src/backend/common/vendor/bluesky/AbstractBlueSkyApiClient.ts
··· 1 1 import { getRoot } from "../../../ioc.js"; 2 2 import { AbstractApiOptions, PagelessListensTimeRangeOptions, PagelessTimeRangeListens, PagelessTimeRangeListensResult } from "../../infrastructure/Atomic.js"; 3 - import { ListRecord, ScrobbleRecord, TealClientData } from "../../infrastructure/config/client/tealfm.js"; 3 + import { ListRecord, ScrobbleRecord, StatusRecord, TealClientData } from "../../infrastructure/config/client/tealfm.js"; 4 4 import AbstractApiClient from "../AbstractApiClient.js"; 5 - import { Agent, ComAtprotoRepoCreateRecord, ComAtprotoRepoListRecords } from "@atproto/api"; 5 + import { Agent, ComAtprotoRepoCreateRecord, ComAtprotoRepoListRecords, ComAtprotoRepoPutRecord } from "@atproto/api"; 6 6 import { MSCache } from "../../Cache.js"; 7 - import { BrainzMeta, PlayObject, PlayObjectLifecycleless, ScrobbleActionResult, UnixTimestamp } from "../../../../core/Atomic.js"; 7 + import { BrainzMeta, PlayObject, SourcePlayerObj, PlayObjectLifecycleless, ScrobbleActionResult, UnixTimestamp } from "../../../../core/Atomic.js"; 8 8 import { musicServiceToCononical } from '../listenbrainz/lzUtils.js'; 9 9 import { parseRegexSingle } from "@foxxmd/regex-buddy-core"; 10 10 import { RecordOptions } from "../../infrastructure/config/client/tealfm.js"; ··· 48 48 } 49 49 } 50 50 51 + async updateStatusRecord(record: StatusRecord): Promise<ScrobbleActionResult> { 52 + const input: ComAtprotoRepoPutRecord.InputSchema = { 53 + repo: this.agent.sessionManager.did, 54 + collection: "fm.teal.alpha.actor.status", 55 + rkey: "self", 56 + record 57 + }; 58 + try { 59 + const resp = await this.agent.com.atproto.repo.putRecord(input); 60 + return {payload: input, response: resp.data}; 61 + } catch (e) { 62 + throw new ScrobbleSubmitError(`Failed to update status record for scrobble`, { cause: e, payload: input, response: 'response' in e ? e.response : undefined }); 63 + } 64 + } 65 + 51 66 async listScrobbleRecord(options: {limit?: number, cursor?: string} = {}): Promise<ComAtprotoRepoListRecords.Response> { 52 67 const {limit = 20, cursor} = options; 53 68 try { ··· 106 121 }; 107 122 108 123 return record; 124 + } 125 + 126 + export const playToStatusRecord = (play: PlayObject, notPlaying: boolean, position?: number): StatusRecord => { 127 + const { $type, ...item } = playToRecord(play); 128 + return { 129 + $type: "fm.teal.alpha.actor.status", 130 + time: dayjs().toISOString(), 131 + // expiry is 1min ago if paused, (now + duration - position) if position is available, or fallback to (now + 10mins) 132 + expiry: notPlaying ? dayjs().subtract(1, 'minute').toISOString() 133 + : position !== undefined ? dayjs().add(play.data.duration - position, 'second').toISOString() 134 + : dayjs().add(10, 'minute').toISOString(), 135 + item 136 + }; 109 137 } 110 138 111 139 export const listRecordToPlay = (listRecord: ListRecord<ScrobbleRecord>): PlayObject => {
+35 -5
src/backend/scrobblers/TealfmScrobbler.ts
··· 1 - import { Logger } from "@foxxmd/logging"; 1 + import { Logger, LogLevel } from "@foxxmd/logging"; 2 2 import EventEmitter from "events"; 3 - import { PlayObject } from "../../core/Atomic.js"; 3 + import { PlayObject, SourcePlayerObj } from "../../core/Atomic.js"; 4 4 import { buildTrackString, capitalize } from "../../core/StringUtils.js"; 5 5 import { isNodeNetworkException } from "../common/errors/NodeErrors.js"; 6 - import { FormatPlayObjectOptions } from "../common/infrastructure/Atomic.js"; 6 + import { FormatPlayObjectOptions, CALCULATED_PLAYER_STATUSES, ReportedPlayerStatus } from "../common/infrastructure/Atomic.js"; 7 7 import { playToListenPayload } from '../common/vendor/listenbrainz/lzUtils.js'; 8 8 import { Notifiers } from "../notifier/Notifiers.js"; 9 9 ··· 11 11 import { TealClientConfig } from "../common/infrastructure/config/client/tealfm.js"; 12 12 import { BlueSkyAppApiClient } from "../common/vendor/bluesky/BlueSkyAppApiClient.js"; 13 13 import { BlueSkyOauthApiClient } from "../common/vendor/bluesky/BlueSkyOauthApiClient.js"; 14 - import { AbstractBlueSkyApiClient, listRecordToPlay, playToRecord, recordToPlay } from "../common/vendor/bluesky/AbstractBlueSkyApiClient.js"; 14 + import { AbstractBlueSkyApiClient, listRecordToPlay, playToRecord, playToStatusRecord, recordToPlay } from "../common/vendor/bluesky/AbstractBlueSkyApiClient.js"; 15 15 16 16 export default class TealScrobbler extends AbstractScrobbleClient { 17 17 18 18 requiresAuth = true; 19 19 requiresAuthInteraction = false; 20 + clearedStatus = false; // tracks if a user's status has been cleared on their repo 20 21 21 22 declare config: TealClientConfig; 22 23 ··· 26 27 super('tealfm', name, config, notifier, emitter, logger); 27 28 this.MAX_INITIAL_SCROBBLES_FETCH = 20; 28 29 this.scrobbleDelay = 1500; 29 - this.supportsNowPlaying = false; 30 + this.supportsNowPlaying = true; 30 31 if(config.data.appPassword !== undefined) { 31 32 this.client = new BlueSkyAppApiClient(name, config.data, {...options, logger}); 32 33 this.requiresAuthInteraction = false; ··· 119 120 } catch (e) { 120 121 await this.notifier.notify({title: `Client - ${capitalize(this.type)} - ${this.name} - Scrobble Error`, message: `Failed to scrobble => ${buildTrackString(playObj)} | Error: ${e.message}`, priority: 'error'}); 121 122 throw e; 123 + } 124 + } 125 + 126 + doPlayingNow = async (data: SourcePlayerObj) => { 127 + const notPlaying = [CALCULATED_PLAYER_STATUSES.stopped, CALCULATED_PLAYER_STATUSES.paused].includes(data.status.calculated as ReportedPlayerStatus); 128 + try { 129 + await this.client.updateStatusRecord(playToStatusRecord(data.play, notPlaying, data.position)); 130 + this.clearedStatus = notPlaying; 131 + } catch (e) { 132 + throw e; 133 + } 134 + } 135 + 136 + shouldUpdatePlayingNowPlatformSpecific = async (data: SourcePlayerObj): Promise<[boolean, string?, LogLevel?]> => { 137 + if ([CALCULATED_PLAYER_STATUSES.stopped, CALCULATED_PLAYER_STATUSES.paused].includes(data.status.calculated as ReportedPlayerStatus) && !this.clearedStatus 138 + || [CALCULATED_PLAYER_STATUSES.playing].includes(data.status.calculated as ReportedPlayerStatus) 139 + || (data.nowPlayingMode && !CALCULATED_PLAYER_STATUSES.stopped)) { 140 + return [true]; 141 + } else { 142 + if(!data.nowPlayingMode && ![CALCULATED_PLAYER_STATUSES.stopped, CALCULATED_PLAYER_STATUSES.paused, CALCULATED_PLAYER_STATUSES.playing].includes(data.status.calculated as ReportedPlayerStatus)) { 143 + return [false,`player is not in state: stopped | paused | playing => Found '${data.status.calculated }'`]; 144 + } else if (this.clearedStatus) { 145 + return [false, 'teal.fm status has already been set to expired']; 146 + } else if (data.nowPlayingMode && CALCULATED_PLAYER_STATUSES.stopped) { 147 + this.npLogger.trace(`Will not update because now playing player is stopped => Found ${data.status.calculated}`); 148 + return [false,`playing player is stopped => Found ${data.status.calculated}` ] 149 + } else { 150 + return [false, 'player is in an unexpected state for teal.fm usage'] 151 + } 122 152 } 123 153 } 124 154 }