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

fix: Improve timing and update frequency to match scrobbler behavior

* Aggressively start Now Playing update (based on reported) but aggressibely end, based on stale/orphaned, so updates are early but not often
* Refactor update thresholds to use scrobble client behavior
* LFM and LZ use track duration as "time visible" for Now Playing, don't re-update during that time period
* Add thresholds to logging for easier troubleshooting

#395

FoxxMD (Nov 11, 2025, 7:49 PM UTC) de1cbb2a a094a1aa

+35 -19
+12 -9
src/backend/scrobblers/AbstractScrobbleClient.ts
··· 93 93 supportsNowPlaying: boolean = false; 94 94 nowPlayingEnabled: boolean; 95 95 nowPlayingFilter: (queue: NowPlayingQueue) => PlayObject | undefined; 96 - nowPlayingThresholds: [number,number] = [10,30]; 96 + nowPlayingMinThreshold: (play?: PlayObject) => number = (_) => 10; 97 + nowPlayingMaxThreshold: (play?: PlayObject) => number = (_) => 30; 97 98 nowPlayingLastUpdated?: Dayjs; 98 99 nowPlayingLastPlay?: PlayObject; 99 100 nowPlayingQueue: NowPlayingQueue = new Map(); ··· 978 979 } 979 980 this.nowPlayingLastPlay = play; 980 981 this.nowPlayingLastUpdated = dayjs(); 981 - // only clear queue after we have updated Now Playing, this way we always have the latest and "most complete" 982 - // set of Source Player updates available for filtering/sorting 983 - this.nowPlayingQueue = new Map(); 984 982 } 983 + this.nowPlayingQueue = new Map(); 985 984 } 986 985 } 987 986 ··· 997 996 998 997 // update if play *has* changed and time since last update is greater than min interval 999 998 // this prevents spamming scrobbler API with updates if user is skipping tracks and source updates frequently 1000 - if(!playObjDataMatch(data, this.nowPlayingLastPlay) && this.nowPlayingThresholds[0] < lastUpdateDiff) { 999 + if(!playObjDataMatch(data, this.nowPlayingLastPlay) && this.nowPlayingMinThreshold(data) < lastUpdateDiff) { 1001 1000 if(isDebugMode()) { 1002 - this.npLogger.debug(`New Play differs from previous Now Playing and time since update > ${lastUpdateDiff}s, should update`); 1001 + this.npLogger.debug(`New Play differs from previous Now Playing and time since update ${lastUpdateDiff}s, greater than threshold ${this.nowPlayingMinThreshold(data)}. Should update`); 1003 1002 } 1004 1003 return true; 1005 1004 } 1006 1005 // update if play *has not* changed but last update is greater than max interval 1007 1006 // this keeps scrobbler Now Playing fresh ("active" indicator) in the event play is long 1008 - if(playObjDataMatch(data, this.nowPlayingLastPlay) && this.nowPlayingThresholds[1] < lastUpdateDiff) { 1007 + if(playObjDataMatch(data, this.nowPlayingLastPlay) && this.nowPlayingMaxThreshold(data) < lastUpdateDiff) { 1009 1008 if(isDebugMode()) { 1010 - this.npLogger.debug(`Now Playing has not been updated in > ${lastUpdateDiff}s, should update`); 1009 + this.npLogger.debug(`Now Playing last updated ${lastUpdateDiff}s ago, greater than threshold ${this.nowPlayingMaxThreshold(data)}s. Should update`); 1011 1010 } 1012 1011 return true; 1013 1012 } 1014 1013 1015 1014 if(isDebugMode()) { 1016 - this.npLogger.debug(`Updated Now Playing ${playObjDataMatch(data, this.nowPlayingLastPlay) ? 'matches' : 'does not match'} and was last updated ${lastUpdateDiff}s ago, not updating`); 1015 + this.npLogger.debug(`Now Playing ${playObjDataMatch(data, this.nowPlayingLastPlay) ? 'matches' : 'does not match'} and was last updated ${lastUpdateDiff}s ago (threshold ${this.nowPlayingMaxThreshold(data)}s), not updating`); 1017 1016 } 1018 1017 return false; 1019 1018 } ··· 1042 1041 .catch((e) => this.logger.warn(new Error('Error while updating queued scrobble cache', {cause: e}))); 1043 1042 } 1044 1043 } 1044 + 1045 + export const nowPlayingUpdateByPlayDuration = (play: PlayObject) => { 1046 + return (play.data.duration ?? 30) + 1; 1047 + }
+5 -2
src/backend/scrobblers/LastfmScrobbler.ts
··· 9 9 import { LastfmClientConfig } from "../common/infrastructure/config/client/lastfm.js"; 10 10 import LastfmApiClient, { playToClientPayload } from "../common/vendor/LastfmApiClient.js"; 11 11 import { Notifiers } from "../notifier/Notifiers.js"; 12 - import AbstractScrobbleClient from "./AbstractScrobbleClient.js"; 12 + import AbstractScrobbleClient, { nowPlayingUpdateByPlayDuration } from "./AbstractScrobbleClient.js"; 13 13 14 14 export default class LastfmScrobbler extends AbstractScrobbleClient { 15 15 ··· 24 24 // @ts-expect-error sloppy data structure assign 25 25 this.api = new LastfmApiClient(name, config.data, {...options, logger}) 26 26 // https://www.last.fm/api/show/user.getRecentTracks 27 - this.MAX_INITIAL_SCROBBLES_FETCH = 200; 27 + this.MAX_INITIAL_SCROBBLES_FETCH = 100; 28 28 this.supportsNowPlaying = true; 29 + // last.fm shows Now Playing for the same time as the duration of the track being submitted 30 + this.nowPlayingMaxThreshold = nowPlayingUpdateByPlayDuration; 29 31 } 30 32 31 33 formatPlayObj = (obj: any, options: FormatPlayObjectOptions = {}) => LastfmApiClient.formatPlayObj(obj, options); ··· 177 179 } 178 180 179 181 doPlayingNow = async (data: PlayObject) => { 182 + // last.fm shows Now Playing for the same time as the duration of the track being submitted 180 183 try { 181 184 const {timestamp, mbid, ...rest} = playToClientPayload(data); 182 185 const response = await this.api.callApi<NowPlayingResponse>((client: any) => client.trackUpdateNowPlaying(rest));
+4 -1
src/backend/scrobblers/ListenbrainzScrobbler.ts
··· 10 10 import { ListenPayload } from '../common/vendor/listenbrainz/interfaces.js'; 11 11 import { Notifiers } from "../notifier/Notifiers.js"; 12 12 13 - import AbstractScrobbleClient from "./AbstractScrobbleClient.js"; 13 + import AbstractScrobbleClient, { nowPlayingUpdateByPlayDuration } from "./AbstractScrobbleClient.js"; 14 14 import { isDebugMode } from "../utils.js"; 15 15 16 16 export default class ListenbrainzScrobbler extends AbstractScrobbleClient { ··· 29 29 // 1000 is way too high. maxing at 100 30 30 this.MAX_INITIAL_SCROBBLES_FETCH = 100; 31 31 this.supportsNowPlaying = true; 32 + // listenbrainz shows Now Playing for the same time as the duration of the track being submitted 33 + this.nowPlayingMaxThreshold = nowPlayingUpdateByPlayDuration; 32 34 } 33 35 34 36 formatPlayObj = (obj: any, options: FormatPlayObjectOptions = {}) => ListenbrainzApiClient.formatPlayObj(obj, options); ··· 97 99 } 98 100 99 101 doPlayingNow = async (data: PlayObject) => { 102 + // listenbrainz shows Now Playing for the same time as the duration of the track being submitted 100 103 try { 101 104 await this.api.submitListen(data, { listenType: 'playing_now'}); 102 105 } catch (e) {
+3 -1
src/backend/scrobblers/ScrobbleClients.ts
··· 50 50 this.logger = childLogger(parentLogger, 'Scrobblers'); // winston.loggers.get('app').child({labels: ['Scrobblers']}, mergeArr); 51 51 52 52 this.sourceEmitter.on('playerUpdate', async (payload: { data: SourcePlayerObj & { options: { scrobbleTo: string[] } }} & SourceIdentifier) => { 53 - if(payload.data.status.reported === REPORTED_PLAYER_STATUSES.playing) { 53 + // agressively update Now Playing so scrobblers that display based on duration are mostly synced 54 + // but aggressively *stop* updating if state becomes stale/orphaned 55 + if(payload.data.status.reported === REPORTED_PLAYER_STATUSES.playing && (!payload.data.status.stale && !payload.data.status.orphaned)) { 54 56 this.playingNow(payload.data.play, {...payload.data.options, scrobbleFrom: { type: payload.type, name: payload.name}}); 55 57 } 56 58 });
+6 -1
src/backend/sources/PlayerState/NowPlayingPlayerState.ts
··· 19 19 20 20 protected getOrphanedInterval() { 21 21 if(this.currentPlay !== undefined && this.currentPlay.data.duration !== undefined) { 22 - return this.currentPlay.data.duration; 22 + // want this player to clear Now Playing as soon as track is finished 23 + return (this.currentPlay.data.duration / 2) + 1; 23 24 } 24 25 return super.getOrphanedInterval(); 26 + } 27 + 28 + public isDead() { 29 + return this.isOrphaned(); 25 30 } 26 31 27 32 public getApiState(): SourcePlayerObj {
+5 -5
src/backend/tests/scrobbler/scrobblers.test.ts
··· 895 895 await npScrobbler.initialize(); 896 896 897 897 const lastUpdate = generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())}); 898 - npScrobbler.nowPlayingLastUpdated = dayjs().subtract(npScrobbler.nowPlayingThresholds[1] + 1, 's'); 898 + npScrobbler.nowPlayingLastUpdated = dayjs().subtract(npScrobbler.nowPlayingMaxThreshold(lastUpdate) + 1, 's'); 899 899 npScrobbler.nowPlayingLastPlay = lastUpdate; 900 900 901 901 const res = npScrobbler.shouldUpdatePlayingNow(lastUpdate); ··· 908 908 await npScrobbler.initialize(); 909 909 910 910 const lastUpdate = generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())}); 911 - npScrobbler.nowPlayingLastUpdated = dayjs().subtract(npScrobbler.nowPlayingThresholds[1] - 1, 's'); 911 + npScrobbler.nowPlayingLastUpdated = dayjs().subtract(npScrobbler.nowPlayingMaxThreshold(lastUpdate) - 1, 's'); 912 912 npScrobbler.nowPlayingLastPlay = lastUpdate; 913 913 914 914 const res = npScrobbler.shouldUpdatePlayingNow(lastUpdate); ··· 921 921 await npScrobbler.initialize(); 922 922 923 923 const lastUpdate = generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())}); 924 - npScrobbler.nowPlayingLastUpdated = dayjs().subtract(npScrobbler.nowPlayingThresholds[0] + 1, 's'); 924 + npScrobbler.nowPlayingLastUpdated = dayjs().subtract(npScrobbler.nowPlayingMinThreshold(lastUpdate) + 1, 's'); 925 925 npScrobbler.nowPlayingLastPlay = lastUpdate; 926 926 927 927 const res = npScrobbler.shouldUpdatePlayingNow(generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())})); ··· 934 934 await npScrobbler.initialize(); 935 935 936 936 const lastUpdate = generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())}); 937 - npScrobbler.nowPlayingLastUpdated = dayjs().subtract(npScrobbler.nowPlayingThresholds[0] - 1, 's'); 937 + npScrobbler.nowPlayingLastUpdated = dayjs().subtract(npScrobbler.nowPlayingMinThreshold(lastUpdate) - 1, 's'); 938 938 npScrobbler.nowPlayingLastPlay = lastUpdate; 939 939 940 940 const res = npScrobbler.shouldUpdatePlayingNow(generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())})); ··· 978 978 979 979 expect(res).is.not.undefined; 980 980 981 - MockDate.set(now.add(npScrobbler.nowPlayingThresholds[0] + 3, 's').toDate()); 981 + MockDate.set(now.add(npScrobbler.nowPlayingMinThreshold() + 3, 's').toDate()); 982 982 983 983 npScrobbler.queuePlayingNow(generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())}), {type: 'jellyfin', name: 'test'}); 984 984