diff --git a/config/subsonic.json.example b/config/subsonic.json.example index 9d77b2ae..985af665 100644 --- a/config/subsonic.json.example +++ b/config/subsonic.json.example @@ -7,7 +7,8 @@ "url": "http://localhost:4040/airsonic", "user": "yourUser", "password": "yourPassword", - "usersAllow": ["yourUser"] + "usersAllow": ["yourUser"], + "detectStaleNowPlayingFromMinutesAgo": true } } ] diff --git a/docsite/docs/configuration/sources/subsonic.mdx b/docsite/docs/configuration/sources/subsonic.mdx index 74709764..aadbd082 100644 --- a/docsite/docs/configuration/sources/subsonic.mdx +++ b/docsite/docs/configuration/sources/subsonic.mdx @@ -28,6 +28,12 @@ Service-specific scrobble implementations tend to be more accurate and provide m Use the optional `usersAllow` property with **File** or **AIO** configuration to restrict scrobbling to a list of defined users. +## Stale Now-Playing Detection + +By default, multi-scrobbler uses the `minutesAgo` and duration values returned by `getNowPlaying` of the subsonic server to ignore entries that have likely finished playing. This prevents repeated scrobbles from Subsonic-compatible servers that continue returning a track after playback stops. + +If the server properly reports no playing song when playback has stopped, this behavior can be disabled by setting `detectStaleNowPlayingFromMinutesAgo` to `false`. + ## Configuration @@ -38,4 +44,4 @@ Use the optional `usersAllow` property with **File** or **AIO** configuration to | `SUBSONIC_PASSWORD` | Yes | | | | `SUBSONIC_URL` | Yes | | Base url of your subsonic-api server | | `SUBSONIC_NAME` | No | | A vanity name different than ID | - \ No newline at end of file + diff --git a/src/backend/common/infrastructure/config/source/subsonic.ts b/src/backend/common/infrastructure/config/source/subsonic.ts index aa917e03..412207eb 100644 --- a/src/backend/common/infrastructure/config/source/subsonic.ts +++ b/src/backend/common/infrastructure/config/source/subsonic.ts @@ -62,6 +62,15 @@ export interface SubsonicData extends CommonSourceData, PollingOptions { * If undefined or an empty string/list MS will scrobble activity from all users * */ usersAllow?: string | string[] + + /** + * Ignore `getNowPlaying` entries whose `minutesAgo`-derived start time is older than their reported duration. + * + * This prevents servers that retain stale now-playing entries after playback stops from repeatedly scrobbling the same track. Can be disabled if the server properly reports no playing songs when playback is stopped. + * + * @default true + * */ + detectStaleNowPlayingFromMinutesAgo?: boolean } export interface SubSonicSourceConfig extends CommonSourceConfig { data: SubsonicData diff --git a/src/backend/sources/SubsonicSource.ts b/src/backend/sources/SubsonicSource.ts index a43ebddb..cb5b6fb3 100644 --- a/src/backend/sources/SubsonicSource.ts +++ b/src/backend/sources/SubsonicSource.ts @@ -280,20 +280,23 @@ export class SubsonicSource extends MemorySource { } protected filterExpiredNowPlaying(plays: PlayObject[]): PlayObject[]{ - return plays.map(x => SubsonicSource.formatPlayObj(x, {sourceData: this.sourceData})) - .filter(play => { - const {artists = [], duration, playDate, track} = play.data; - if (duration === undefined || playDate === undefined) { - return true; - } - if (!isSubsonicNowPlayingExpired(play)) { - return true; - } - const tolerance = getSubsonicNowPlayingTolerance(duration); - const expiresAt = playDate.add(duration + tolerance, 'second'); - this.logger.trace(`Ignoring Subsonic now-playing entry as inactive: '${artists.map(x => x.name).join(', ')} - ${track}'. Estimated start: ${todayAwareFormat(playDate)}; track duration: ${timeToHumanTimestamp(duration * 1000)}. The entry expired at ${todayAwareFormat(expiresAt)}.`); - return false; - }); + if(this.config.data.detectStaleNowPlayingFromMinutesAgo === false){ + return plays; + } + + return plays.filter(play => { + const {artists = [], duration, playDate, track} = play.data; + if (duration === undefined || playDate === undefined) { + return true; + } + if (!isSubsonicNowPlayingExpired(play)) { + return true; + } + const tolerance = getSubsonicNowPlayingTolerance(duration); + const expiresAt = playDate.add(duration + tolerance, 'second'); + this.logger.trace(`Ignoring Subsonic now-playing entry as inactive: '${artists.map(x => x.name).join(', ')} - ${track}'. Estimated start: ${todayAwareFormat(playDate)}; track duration: ${timeToHumanTimestamp(duration * 1000)}. The entry expired at ${todayAwareFormat(expiresAt)}.`); + return false; + }); } doAuthentication = async () => { @@ -317,7 +320,7 @@ export class SubsonicSource extends MemorySource { } = {} } = resp; // Some servers continue reporting the same song as playing after playback stops. Ignore it so it cannot be treated as a new repeat session. - const active = this.filterExpiredNowPlaying(entry); + const active = this.filterExpiredNowPlaying(entry.map(x => SubsonicSource.formatPlayObj(x, {sourceData: this.sourceData}))); // sometimes subsonic sources will return the same track as being played twice on the same player, need to remove this so we don't duplicate plays const deduped = removeDuplicates(active); const userFiltered = this.usersAllow.length == 0 ? deduped : deduped.filter(x => x.meta.user === undefined || this.usersAllow.map(x => x.toLocaleLowerCase()).includes(x.meta.user.toLocaleLowerCase())); diff --git a/src/backend/tests/subsonic/subsonic.test.ts b/src/backend/tests/subsonic/subsonic.test.ts index da063048..716fa3ef 100644 --- a/src/backend/tests/subsonic/subsonic.test.ts +++ b/src/backend/tests/subsonic/subsonic.test.ts @@ -2,8 +2,19 @@ import {expect} from 'chai'; import {afterEach, describe, it} from 'mocha'; import MockDate from 'mockdate'; import dayjs from 'dayjs'; +import { loggerTest } from '@foxxmd/logging'; +import EventEmitter from 'events'; +import type {PlayObject} from '../../../core/Atomic.ts'; +import type {SubSonicSourceConfig} from '../../common/infrastructure/config/source/subsonic.ts'; import {isSubsonicNowPlayingExpired, SubsonicSource} from '../../sources/SubsonicSource.ts'; +class TestSubsonicSource extends SubsonicSource { + // make protected method available for tests + filterNowPlaying(entries: PlayObject[]): PlayObject[] { + return this.filterExpiredNowPlaying(entries); + } +} + const entry = (minutesAgo: number, duration = 180) => ({ id: 'track-id', title: 'Track', @@ -15,6 +26,26 @@ const entry = (minutesAgo: number, duration = 180) => ({ username: 'user' }); +const createSource = (detectStaleNowPlayingFromMinutesAgo?: boolean) => { + const config: SubSonicSourceConfig = { + data: { + url: 'https://example.com', + user: 'user', + password: 'password', + detectStaleNowPlayingFromMinutesAgo + }, + options: {} + }; + const source = new TestSubsonicSource('test', config, { + localUrl: new URL('https://example.com'), + configDir: 'test', + logger: loggerTest, + version: 'test' + }, new EventEmitter()); + source.scheduler.stop(); + return source; +}; + describe('Subsonic now-playing expiration', () => { afterEach(() => MockDate.reset()); @@ -73,4 +104,25 @@ describe('Subsonic now-playing expiration', () => { expect(isSubsonicNowPlayingExpired(SubsonicSource.formatPlayObj(entry(8)))).to.be.true; expect(isSubsonicNowPlayingExpired(SubsonicSource.formatPlayObj(entry(0)))).to.be.false; }); + + it('filters expired now-playing rows by default', () => { + MockDate.set('2026-01-01T12:05:30Z'); + const source = createSource(); + + expect(source.filterNowPlaying([SubsonicSource.formatPlayObj(entry(4))])).to.be.empty; + }); + + it('filters expired now-playing rows when detecting stale entries is enabled by configuration', () => { + MockDate.set('2026-01-01T12:05:30Z'); + const source = createSource(true); + + expect(source.filterNowPlaying([SubsonicSource.formatPlayObj(entry(4))])).to.be.empty; + }); + + it('keeps expired now-playing rows when minutesAgo detection is disabled', () => { + MockDate.set('2026-01-01T12:05:30Z'); + const source = createSource(false); + + expect(source.filterNowPlaying([SubsonicSource.formatPlayObj(entry(4))])).to.have.length(1); + }); });