[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: Change stale/orphan interval to match current play duration for Players that only track Now Playing data

#338

FoxxMD (Aug 26, 2025, 7:40 PM UTC) bd44fa56 c81bdf60

+63 -7
+6
src/backend/sources/EndpointLastfmSource.ts
··· 7 7 InternalConfig, 8 8 NO_USER, 9 9 PlayerStateData, 10 + PlayPlatformId, 10 11 REPORTED_PLAYER_STATUSES, 11 12 ReportedPlayerStatus 12 13 } from "../common/infrastructure/Atomic.js"; ··· 15 16 import { LastFMEndpointSourceConfig } from "../common/infrastructure/config/source/endpointlfm.js"; 16 17 import { LastfmTrackUpdateRequest, NowPlayingPayload, TrackScrobblePayload } from "lastfm-node-client"; 17 18 import { scrobblePayloadToPlay } from "../common/vendor/LastfmApiClient.js"; 19 + import { Logger } from "@foxxmd/logging"; 20 + import { PlayerStateOptions } from "./PlayerState/AbstractPlayerState.js"; 21 + import { NowPlayingPlayerState } from "./PlayerState/NowPlayingPlayerState.js"; 18 22 19 23 const noSlugMatch = new RegExp(/(?:\/api\/lastfm\/?)$|(?:\/1\/?|\/2.0\/?)$/i); 20 24 const slugMatch = new RegExp(/\/api\/lastfm\/([^\/]+)$/i); ··· 77 81 } 78 82 } 79 83 } 84 + 85 + getNewPlayer = (logger: Logger, id: PlayPlatformId, opts: PlayerStateOptions) => new NowPlayingPlayerState(logger, id, opts); 80 86 } 81 87 82 88 export const playStateFromRequest = (obj: LastfmTrackUpdateRequest): PlayerStateData => {
+6
src/backend/sources/EndpointListenbrainzSource.ts
··· 7 7 InternalConfig, 8 8 NO_USER, 9 9 PlayerStateData, 10 + PlayPlatformId, 10 11 REPORTED_PLAYER_STATUSES, 11 12 ReportedPlayerStatus 12 13 } from "../common/infrastructure/Atomic.js"; ··· 16 17 import { ListenPayload } from '../common/vendor/listenbrainz/interfaces.js'; 17 18 import { parseRegexSingleOrFail } from "../utils.js"; 18 19 import MemorySource from "./MemorySource.js"; 20 + import { NowPlayingPlayerState } from "./PlayerState/NowPlayingPlayerState.js"; 21 + import { Logger } from "@foxxmd/logging"; 22 + import { PlayerStateOptions } from "./PlayerState/AbstractPlayerState.js"; 19 23 20 24 const noSlugMatch = new RegExp(/(?:\/api\/listenbrainz\/?)$|(?:\/1\/?|\/1\/submit-listens\/?)$/i); 21 25 const slugMatch = new RegExp(/\/api\/listenbrainz\/([^\/]+)$/i); ··· 94 98 } 95 99 } 96 100 } 101 + 102 + getNewPlayer = (logger: Logger, id: PlayPlatformId, opts: PlayerStateOptions) => new NowPlayingPlayerState(logger, id, opts); 97 103 } 98 104 99 105 export const playStateFromRequest = (obj: SubmitPayload): PlayerStateData => {
+6 -1
src/backend/sources/LastfmSource.ts
··· 4 4 import request from "superagent"; 5 5 import { PlayObject, SOURCE_SOT } from "../../core/Atomic.js"; 6 6 import { isNodeNetworkException } from "../common/errors/NodeErrors.js"; 7 - import { FormatPlayObjectOptions, InternalConfig } from "../common/infrastructure/Atomic.js"; 7 + import { FormatPlayObjectOptions, InternalConfig, PlayPlatformId } from "../common/infrastructure/Atomic.js"; 8 8 import { LastfmSourceConfig } from "../common/infrastructure/config/source/lastfm.js"; 9 9 import LastfmApiClient from "../common/vendor/LastfmApiClient.js"; 10 10 import { sortByOldestPlayDate } from "../utils.js"; 11 11 import { RecentlyPlayedOptions } from "./AbstractSource.js"; 12 12 import MemorySource from "./MemorySource.js"; 13 + import { Logger } from "@foxxmd/logging"; 14 + import { PlayerStateOptions } from "./PlayerState/AbstractPlayerState.js"; 15 + import { NowPlayingPlayerState } from "./PlayerState/NowPlayingPlayerState.js"; 13 16 14 17 export default class LastfmSource extends MemorySource { 15 18 ··· 149 152 } 150 153 151 154 protected getBackloggedPlays = async (options: RecentlyPlayedOptions = {}) => await this.getRecentlyPlayed({formatted: true, ...options}) 155 + 156 + getNewPlayer = (logger: Logger, id: PlayPlatformId, opts: PlayerStateOptions) => new NowPlayingPlayerState(logger, id, opts); 152 157 }
+6 -1
src/backend/sources/ListenbrainzSource.ts
··· 2 2 import request from "superagent"; 3 3 import { PlayObject, SOURCE_SOT } from "../../core/Atomic.js"; 4 4 import { isNodeNetworkException } from "../common/errors/NodeErrors.js"; 5 - import { FormatPlayObjectOptions, InternalConfig } from "../common/infrastructure/Atomic.js"; 5 + import { FormatPlayObjectOptions, InternalConfig, PlayPlatformId } from "../common/infrastructure/Atomic.js"; 6 6 import { ListenBrainzSourceConfig } from "../common/infrastructure/config/source/listenbrainz.js"; 7 7 import { ListenbrainzApiClient } from "../common/vendor/ListenbrainzApiClient.js"; 8 8 import { RecentlyPlayedOptions } from "./AbstractSource.js"; 9 9 import MemorySource from "./MemorySource.js"; 10 10 import { isPortReachableConnect } from "../utils/NetworkUtils.js"; 11 + import { Logger } from "@foxxmd/logging"; 12 + import { PlayerStateOptions } from "./PlayerState/AbstractPlayerState.js"; 13 + import { NowPlayingPlayerState } from "./PlayerState/NowPlayingPlayerState.js"; 11 14 12 15 export default class ListenbrainzSource extends MemorySource { 13 16 ··· 89 92 } 90 93 91 94 protected getBackloggedPlays = async (options: RecentlyPlayedOptions = {}) => await this.getRecentlyPlayed({formatted: true, ...options}) 95 + 96 + getNewPlayer = (logger: Logger, id: PlayPlatformId, opts: PlayerStateOptions) => new NowPlayingPlayerState(logger, id, opts); 92 97 }
+13 -5
src/backend/sources/PlayerState/AbstractPlayerState.ts
··· 98 98 protected abstract newListenProgress(data?: Partial<PlayProgress>): ListenProgress; 99 99 protected abstract newListenRange(start?: ListenProgress, end?: ListenProgress, options?: object): ListenRange; 100 100 101 + protected getStaleInterval(): number { 102 + return this.stateIntervalOptions.staleInterval; 103 + } 104 + 105 + protected getOrphanedInterval(): number { 106 + return this.stateIntervalOptions.orphanedInterval; 107 + } 108 + 101 109 get platformIdStr() { 102 110 return genGroupIdStr(this.platformId); 103 111 } ··· 108 116 109 117 isUpdateStale(reportedTS?: Dayjs) { 110 118 if (this.currentPlay !== undefined) { 111 - return Math.abs((reportedTS ?? dayjs()).diff(this.playLastUpdatedAt, 'seconds')) > this.stateIntervalOptions.staleInterval; 119 + return Math.abs((reportedTS ?? dayjs()).diff(this.playLastUpdatedAt, 'seconds')) > this.getStaleInterval(); 112 120 } 113 121 return false; 114 122 } ··· 117 125 const isStale = this.isUpdateStale(reportedTS); 118 126 if (isStale && ![CALCULATED_PLAYER_STATUSES.stale, CALCULATED_PLAYER_STATUSES.orphaned].includes(this.calculatedStatus)) { 119 127 this.calculatedStatus = CALCULATED_PLAYER_STATUSES.stale; 120 - this.logger.debug(`Stale after no Play updates for ${timeToHumanTimestamp(Math.abs((reportedTS ?? dayjs()).diff(this.playLastUpdatedAt, 'ms')))} (staleAfter ${this.stateIntervalOptions.staleInterval}s)`); 128 + this.logger.debug(`Stale after no Play updates for ${timeToHumanTimestamp(Math.abs((reportedTS ?? dayjs()).diff(this.playLastUpdatedAt, 'ms')))} (staleAfter ${this.getStaleInterval()}s)`); 121 129 // end current listening sessions 122 130 this.currentListenSessionEnd(); 123 131 } ··· 125 133 } 126 134 127 135 isOrphaned() { 128 - return dayjs().diff(this.stateLastUpdatedAt, 'seconds') >= this.stateIntervalOptions.orphanedInterval; 136 + return dayjs().diff(this.stateLastUpdatedAt, 'seconds') >= this.getOrphanedInterval(); 129 137 } 130 138 131 139 isDead() { 132 - return dayjs().diff(this.stateLastUpdatedAt, 'seconds') >= this.stateIntervalOptions.orphanedInterval * 2; 140 + return dayjs().diff(this.stateLastUpdatedAt, 'seconds') >= this.getOrphanedInterval()* 2; 133 141 } 134 142 135 143 checkOrphaned() { 136 144 const isOrphaned = this.isOrphaned(); 137 145 if (isOrphaned && this.calculatedStatus !== CALCULATED_PLAYER_STATUSES.orphaned) { 138 146 this.calculatedStatus = CALCULATED_PLAYER_STATUSES.orphaned; 139 - this.logger.debug(`Orphaned after no Player updates for ${timeToHumanTimestamp(Math.abs(dayjs().diff(this.stateLastUpdatedAt, 'ms')))} ${Math.abs(dayjs().diff(this.stateLastUpdatedAt, 'minutes'))} (orhanedAfter ${this.stateIntervalOptions.orphanedInterval}s)`); 147 + this.logger.debug(`Orphaned after no Player updates for ${timeToHumanTimestamp(Math.abs(dayjs().diff(this.stateLastUpdatedAt, 'ms')))} ${Math.abs(dayjs().diff(this.stateLastUpdatedAt, 'minutes'))} (orhanedAfter ${this.getOrphanedInterval()}s)`); 140 148 } 141 149 return isOrphaned; 142 150 }
+26
src/backend/sources/PlayerState/NowPlayingPlayerState.ts
··· 1 + import { GenericPlayerState } from "./GenericPlayerState.js"; 2 + 3 + /** 4 + * This Player is only used for displaying data reported to EndpointListenbrainzSource, and parsed from ListenbrainzSource, as playing_now 5 + * and the behvior for Listenbrainz server is to display playing now info with a timeout equal to duraion of the submitted track 6 + * https://github.com/FoxxMD/multi-scrobbler/discussions/338 7 + * 8 + * We'll use duration as a generic timeout for any Source that *only* parses Now Playing data for Player 9 + */ 10 + export class NowPlayingPlayerState extends GenericPlayerState { 11 + 12 + protected getStaleInterval() { 13 + if(this.currentPlay !== undefined && this.currentPlay.data.duration !== undefined) { 14 + return this.currentPlay.data.duration; 15 + } 16 + return super.getStaleInterval(); 17 + } 18 + 19 + protected getOrphanedInterval() { 20 + if(this.currentPlay !== undefined && this.currentPlay.data.duration !== undefined) { 21 + return this.currentPlay.data.duration; 22 + } 23 + return super.getOrphanedInterval(); 24 + } 25 + 26 + }