[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.

Merge pull request #603 from HiiJax/tealfmStatus

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

authored by

Matt Foxx and committed by
GitHub
(May 27, 2026, 8:43 PM EDT) 3975edea 6905853a

+90 -9
+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;
+43 -4
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"; 11 - import dayjs, { ManipulateType } from "dayjs"; 11 + import dayjs, { Dayjs, ManipulateType } from "dayjs"; 12 12 import { getScrobbleTsSOCDateWithContext, usecToUnix } from "../../../utils/TimeUtils.js"; 13 13 import { removeUndefinedKeys } from "../../../utils.js"; 14 14 import { baseFormatPlayObj } from "../../../utils/PlayTransformUtils.js"; ··· 45 45 return {payload: input, response: resp.data}; 46 46 } catch (e) { 47 47 throw new ScrobbleSubmitError(`Failed to create record for scrobble`, { cause: e, payload: input, response: 'response' in e ? e.response : undefined }); 48 + } 49 + } 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 }); 48 63 } 49 64 } 50 65 ··· 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 } = notPlaying 128 + ? { trackName: "", artists: [] } 129 + : playToRecord(play); 130 + 131 + // default "fallback" value 132 + let expiry: Dayjs = dayjs().add(10, 'minute'); 133 + // 1min ago if paused -> try now + (duration - position) -> try now + duration -> fallback 134 + if(notPlaying) { 135 + expiry = dayjs().subtract(1, 'minute'); 136 + } else if(position !== undefined && play.data.duration !== undefined) { 137 + expiry = dayjs().add(play.data.duration - position, 'second'); 138 + } else if(play.data.duration !== undefined) { 139 + expiry = dayjs().add(play.data.duration, 'second'); 140 + } 141 + 142 + return { 143 + $type: "fm.teal.alpha.actor.status", 144 + time: dayjs().toISOString(), 145 + expiry: expiry.toISOString(), 146 + item 147 + }; 109 148 } 110 149 111 150 export const listRecordToPlay = (listRecord: ListRecord<ScrobbleRecord>): PlayObject => {
+38 -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 ··· 26 26 super('tealfm', name, config, notifier, emitter, logger); 27 27 this.MAX_INITIAL_SCROBBLES_FETCH = 20; 28 28 this.scrobbleDelay = 1500; 29 - this.supportsNowPlaying = false; 29 + this.supportsNowPlaying = true; 30 30 if(config.data.appPassword !== undefined) { 31 31 this.client = new BlueSkyAppApiClient(name, config.data, {...options, logger}); 32 32 this.requiresAuthInteraction = false; ··· 119 119 } catch (e) { 120 120 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 121 throw e; 122 + } 123 + } 124 + 125 + doPlayingNow = async (data: SourcePlayerObj) => { 126 + const notPlaying = [CALCULATED_PLAYER_STATUSES.stopped, CALCULATED_PLAYER_STATUSES.paused].includes(data.status.calculated as ReportedPlayerStatus); 127 + try { 128 + await this.client.updateStatusRecord(playToStatusRecord(data.play, notPlaying, data.position)); 129 + } catch (e) { 130 + throw e; 131 + } 132 + } 133 + 134 + wasLastStatusCleared = () => { 135 + return this.nowPlayingLastPlay !== undefined 136 + && [CALCULATED_PLAYER_STATUSES.stopped, CALCULATED_PLAYER_STATUSES.paused].includes(this.nowPlayingLastPlay.status.calculated as ReportedPlayerStatus) 137 + } 138 + 139 + shouldUpdatePlayingNowPlatformSpecific = async (data: SourcePlayerObj): Promise<[boolean, string?, LogLevel?]> => { 140 + if ([CALCULATED_PLAYER_STATUSES.stopped, CALCULATED_PLAYER_STATUSES.paused].includes(data.status.calculated as ReportedPlayerStatus) && !this.wasLastStatusCleared() 141 + || [CALCULATED_PLAYER_STATUSES.playing].includes(data.status.calculated as ReportedPlayerStatus) 142 + || (data.nowPlayingMode && !CALCULATED_PLAYER_STATUSES.stopped)) { 143 + return [true]; 144 + } else { 145 + if(!data.nowPlayingMode && ![CALCULATED_PLAYER_STATUSES.stopped, CALCULATED_PLAYER_STATUSES.paused, CALCULATED_PLAYER_STATUSES.playing].includes(data.status.calculated as ReportedPlayerStatus)) { 146 + return [false,`player is not in state: stopped | paused | playing => Found '${data.status.calculated }'`]; 147 + } else if (this.wasLastStatusCleared()) { 148 + return [false, 'teal.fm status has already been set to expired']; 149 + } else if (data.nowPlayingMode && CALCULATED_PLAYER_STATUSES.stopped) { 150 + this.npLogger.trace(`Will not update because now playing player is stopped => Found ${data.status.calculated}`); 151 + return [false,`playing player is stopped => Found ${data.status.calculated}` ] 152 + } else { 153 + return [false, 'player is in an unexpected state for teal.fm usage'] 154 + } 122 155 } 123 156 } 124 157 }