diff --git a/src/backend/sources/SubsonicSource.ts b/src/backend/sources/SubsonicSource.ts index 66b0bb5f..a43ebddb 100644 --- a/src/backend/sources/SubsonicSource.ts +++ b/src/backend/sources/SubsonicSource.ts @@ -1,5 +1,5 @@ import * as crypto from 'crypto'; -import dayjs from "dayjs"; +import dayjs, { type Dayjs } from "dayjs"; import isSameOrAfter from "dayjs/plugin/isSameOrAfter.js"; import type EventEmitter from "events"; import type { Request } from 'superagent'; @@ -21,6 +21,7 @@ import type {Logger} from '@foxxmd/logging'; import { baseFormatPlayObj } from '../utils/PlayTransformUtils.ts'; import { noRetryOnUpstreamError, tryApiCall } from '../utils/RequestUtils.ts'; import { artistNameToCredit } from '../../core/StringUtils.ts'; +import { timeToHumanTimestamp, todayAwareFormat } from '../../core/TimeUtils.ts'; dayjs.extend(isSameOrAfter); @@ -278,6 +279,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; + }); + } + doAuthentication = async () => { const {url} = this.config.data; try { @@ -298,8 +316,10 @@ export class SubsonicSource extends MemorySource { entry = [] } = {} } = 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); // 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(entry.map(x => SubsonicSource.formatPlayObj(x, {sourceData: this.sourceData}))); + 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())); return await this.processRecentPlays(userFiltered); } @@ -366,3 +386,20 @@ export const identifiersFromResponse = (data: SubsonicResponseCommon) => { } return identifiers.join(' | '); } + +export const isSubsonicNowPlayingExpired = (play: PlayObject, now: Dayjs = dayjs()): boolean => { + const {duration, playDate} = play.data; + if (duration === undefined || duration <= 0 || playDate === undefined) { + return false; + } + const tolerance = getSubsonicNowPlayingTolerance(duration); + return now.isAfter(playDate.add(duration + tolerance, 'second')); +} + +/** + * Subsonic only reports the track start in whole minutes. Allow for that lost precision before treating a lingering now-playing row as stale. + */ +const getSubsonicNowPlayingTolerance = (duration: number): number => { + const nowPlayingMinToleranceTimeSeconds = 60; + return nowPlayingMinToleranceTimeSeconds + (duration * 0.05) +}; diff --git a/src/backend/tests/subsonic/subsonic.test.ts b/src/backend/tests/subsonic/subsonic.test.ts new file mode 100644 index 00000000..da063048 --- /dev/null +++ b/src/backend/tests/subsonic/subsonic.test.ts @@ -0,0 +1,76 @@ +import {expect} from 'chai'; +import {afterEach, describe, it} from 'mocha'; +import MockDate from 'mockdate'; +import dayjs from 'dayjs'; +import {isSubsonicNowPlayingExpired, SubsonicSource} from '../../sources/SubsonicSource.ts'; + +const entry = (minutesAgo: number, duration = 180) => ({ + id: 'track-id', + title: 'Track', + album: 'Album', + artist: 'Artist', + duration, + minutesAgo, + playerId: 'player-id', + username: 'user' +}); + +describe('Subsonic now-playing expiration', () => { + afterEach(() => MockDate.reset()); + + it('derives the play start from minutesAgo with minute precision', () => { + MockDate.set('2026-01-01T12:05:30Z'); + + const play = SubsonicSource.formatPlayObj(entry(3)); + + expect(play.data.playDate.isSame(dayjs('2026-01-01T12:02:00Z'))).to.be.true; + }); + + it('keeps a now-playing row within the track duration and tolerance', () => { + MockDate.set('2026-01-01T12:05:30Z'); + + const play = SubsonicSource.formatPlayObj(entry(3)); + + expect(isSubsonicNowPlayingExpired(play)).to.be.false; + }); + + it('expires a lingering now-playing row older than duration plus tolerance', () => { + MockDate.set('2026-01-01T12:05:30Z'); + + const play = SubsonicSource.formatPlayObj(entry(4)); + + expect(isSubsonicNowPlayingExpired(play)).to.be.true; + }); + + it('expires only after the duration plus minute precision and playback tolerance', () => { + const play = SubsonicSource.formatPlayObj(entry(0)); + const expiresAt = play.data.playDate!.add(249, 'second'); + + expect(isSubsonicNowPlayingExpired(play, expiresAt)).to.be.false; + expect(isSubsonicNowPlayingExpired(play, expiresAt.add(1, 'second'))).to.be.true; + }); + + it('adds five percent to the minute precision tolerance for long tracks', () => { + const play = SubsonicSource.formatPlayObj(entry(0, 1800)); + const expiresAt = play.data.playDate!.add(1950, 'second'); + + expect(isSubsonicNowPlayingExpired(play, expiresAt)).to.be.false; + expect(isSubsonicNowPlayingExpired(play, expiresAt.add(1, 'second'))).to.be.true; + }); + + it('does not expire a track which started late in the reported minute', () => { + MockDate.set('2026-01-01T12:04:57Z'); + + const play = SubsonicSource.formatPlayObj(entry(3, 184)); + + expect(play.data.playDate.isSame(dayjs('2026-01-01T12:01:00Z'))).to.be.true; + expect(isSubsonicNowPlayingExpired(play)).to.be.false; + }); + + it('accepts a reset minutesAgo value for a repeated track', () => { + MockDate.set('2026-01-01T12:10:30Z'); + + expect(isSubsonicNowPlayingExpired(SubsonicSource.formatPlayObj(entry(8)))).to.be.true; + expect(isSubsonicNowPlayingExpired(SubsonicSource.formatPlayObj(entry(0)))).to.be.false; + }); +}); -- 2.51.2 From a79b697496e832200b5445d0213bd0edf00f198b Mon Sep 17 00:00:00 2001 From: Jannis Pohle Date: Fri, 24 Jul 2026 14:18:06 +0000 Subject: [PATCH 2/4] fix: add configuration key for disabling auto-detection of stale songs from subsonic servers via the minutesAgo field --- config/subsonic.json.example | 3 +- .../docs/configuration/sources/subsonic.mdx | 8 ++- .../infrastructure/config/source/subsonic.ts | 9 ++++ src/backend/sources/SubsonicSource.ts | 33 ++++++------ src/backend/tests/subsonic/subsonic.test.ts | 52 +++++++++++++++++++ 5 files changed, 88 insertions(+), 17 deletions(-) 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); + }); }); -- 2.51.2 From ea77ea435abb8ec70b5d74dd9cce466844fabd2f Mon Sep 17 00:00:00 2001 From: Jannis Pohle Date: Sun, 26 Jul 2026 06:24:59 +0000 Subject: [PATCH 3/4] feat: add support for the playbackReport extension for OpenSubsonic If the extension is provided by the server and playback data is reported by the client, make use of the new position and state fields when polling the current play entry from subsonic. If the new fields are not provided, fallback to the old timestamp based tracking listen progress. --- .../docs/configuration/sources/subsonic.mdx | 11 +- .../infrastructure/config/source/subsonic.ts | 2 +- .../common/vendor/subsonic/interfaces.ts | 24 +- .../PlayerState/AbstractPlayerState.ts | 2 +- .../PlayerState/SubsonicPlayerState.ts | 72 +++- src/backend/sources/SubsonicSource.ts | 92 +++++- src/backend/tests/subsonic/subsonic.test.ts | 307 +++++++++++++++++- 7 files changed, 470 insertions(+), 40 deletions(-) diff --git a/docsite/docs/configuration/sources/subsonic.mdx b/docsite/docs/configuration/sources/subsonic.mdx index aadbd082..148a28ea 100644 --- a/docsite/docs/configuration/sources/subsonic.mdx +++ b/docsite/docs/configuration/sources/subsonic.mdx @@ -28,11 +28,16 @@ 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 +## Playback Report Support -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. +During source initialization, multi-scrobbler probes the OpenSubsonic [`getOpenSubsonicExtensions`](https://opensubsonic.netlify.app/docs/endpoints/getopensubsonicextensions/) endpoint for [Playback Report](https://opensubsonic.netlify.app/docs/extensions/playbackreport/) support. Playback Report is available only when both the server supports the extension and the active playback client reports it. -If the server properly reports no playing song when playback has stopped, this behavior can be disabled by setting `detectStaleNowPlayingFromMinutesAgo` to `false`. +When the active client reports Playback Report data, multi-scrobbler uses the `state` and `positionMs` fields returned by [`getNowPlaying`](https://opensubsonic.netlify.app/docs/endpoints/getnowplaying/). This provides more accurate playback tracking and stale now-playing handling. + +An extension probe failure does not prevent the source from initializing. multi-scrobbler continues with the standard `getNowPlaying` behavior, and also uses Playback Report fields if they are present in a response. + +For classic Subsonic clients and entries without `state` or `positionMs`, multi-scrobbler uses the `minutesAgo` and duration values returned by `getNowPlaying` 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 detection can be disabled by setting `detectStaleNowPlayingFromMinutesAgo` to `false`. ## Configuration diff --git a/src/backend/common/infrastructure/config/source/subsonic.ts b/src/backend/common/infrastructure/config/source/subsonic.ts index 412207eb..56abaeb6 100644 --- a/src/backend/common/infrastructure/config/source/subsonic.ts +++ b/src/backend/common/infrastructure/config/source/subsonic.ts @@ -66,7 +66,7 @@ export interface SubsonicData extends CommonSourceData, PollingOptions { /** * 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. + * This fallback is used only when the active client does not report OpenSubsonic Playback Report state or position. It 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 * */ diff --git a/src/backend/common/vendor/subsonic/interfaces.ts b/src/backend/common/vendor/subsonic/interfaces.ts index 361d5f88..29d1fa92 100644 --- a/src/backend/common/vendor/subsonic/interfaces.ts +++ b/src/backend/common/vendor/subsonic/interfaces.ts @@ -27,7 +27,29 @@ export interface SubsonicNowPlayingResponse extends SubsonicResponseCommon { } } -export interface EntryData { +export interface OpenSubsonicExtension { + name: string, + versions: number[] +} + +export interface OpenSubsonicExtensionsResponse extends SubsonicResponseCommon { + openSubsonicExtensions: OpenSubsonicExtension[] +} + +export type SubsonicPlaybackState = 'playing' | 'paused' | 'stopped' | 'starting' | string; + +export interface PlaybackReportData { + /** + * State is only provided by the server if the playback report extension is implemented by the server and used by the active client (see https://opensubsonic.netlify.app/docs/extensions/playbackreport/) + * */ + state?: SubsonicPlaybackState, + /** + * PositionMs is only provided by the server if the playback report extension is implemented by the server and used by the active client (see https://opensubsonic.netlify.app/docs/extensions/playbackreport/) + * */ + positionMs?: number +} + +export interface EntryData extends PlaybackReportData { id: string, title: string, album?: string, diff --git a/src/backend/sources/PlayerState/AbstractPlayerState.ts b/src/backend/sources/PlayerState/AbstractPlayerState.ts index 9c1ce4b3..973fb866 100644 --- a/src/backend/sources/PlayerState/AbstractPlayerState.ts +++ b/src/backend/sources/PlayerState/AbstractPlayerState.ts @@ -254,7 +254,7 @@ export abstract class AbstractPlayerState { this.logger.debug(`Last Play ${shortDiff ? 'was' : 'was not'} within 20s of new Player session and ${lastPlayMatch ? 'does' : 'does not'} match new Play -- ${this.isRepeatPlay ? 'is' : 'is not'} a repeat Play`); } - this.setCurrentPlay(state); + this.setCurrentPlay(state, {reportedTS}); this.calculatedStatus = CALCULATED_PLAYER_STATUSES.unknown; } diff --git a/src/backend/sources/PlayerState/SubsonicPlayerState.ts b/src/backend/sources/PlayerState/SubsonicPlayerState.ts index ee21a37a..42f58363 100644 --- a/src/backend/sources/PlayerState/SubsonicPlayerState.ts +++ b/src/backend/sources/PlayerState/SubsonicPlayerState.ts @@ -1,11 +1,60 @@ -import { GenericPlayerState } from "./GenericPlayerState.ts"; import type {Dayjs} from "dayjs"; +import { type AbstractPlayerState } from "./AbstractPlayerState.ts"; +import type {PlayerStateDataMaybePlay} from "../../common/infrastructure/Atomic.ts"; +import { PositionalPlayerState } from "./PositionalPlayerState.ts"; +import { type ListenRange, ListenRangeTS } from "./ListenRange.ts"; +import { ListenProgressTS } from "./ListenProgress.ts"; +import { CALCULATED_PLAYER_STATUSES, type Second } from "../../../core/Atomic.ts"; -export class SubsonicPlayerState extends GenericPlayerState { +export class SubsonicPlayerState extends PositionalPlayerState { + + update(state: PlayerStateDataMaybePlay, reportedTS?: Dayjs) { + const range = this.activeRange; + const usesPosition = state.position !== undefined; + if (range !== undefined && range.isPositional() !== usesPosition) { + // Timestamp and position ranges measure different coordinates and cannot be combined -> Finalize the current listen session and start a new one afterwards. + this.currentListenSessionEnd(); + } + return super.update(state, reportedTS); + } + + protected currentListenSessionContinue(position?: number, timestamp?: Dayjs) { + if (position !== undefined) { + return super.currentListenSessionContinue(position, timestamp); + } + + if (this.activeRange === undefined) { + this.logger.debug('Started new Player listen range.'); + this.activeRange = new ListenRangeTS(new ListenProgressTS({timestamp})); + } else { + this.calculatedStatus = CALCULATED_PLAYER_STATUSES.playing; + this.activeRange.setRangeEnd(new ListenProgressTS({timestamp})); + } + } + + protected currentListenSessionEnd() { + const range = this.activeRange; + if (range?.isPositional()) { + return super.currentListenSessionEnd(); + } + if (range !== undefined && range.getDuration() !== 0) { + this.logger.debug('Ended current Player listen range.'); + range.finalize(); + this.basePlayer.listenRanges.push(range); + } + this.activeRange = undefined; + } + + public getPosition(): Second | undefined { + if (!this.activeRange?.isPositional()) { + return this.activeRange?.getPosition(); + } + return super.getPosition(); + } protected isSessionRepeat(position?: number, reportedTS?: Dayjs) { - if(super.isSessionRepeat()) { - return true; + if (this.activeRange?.isPositional()) { + return super.isSessionRepeat(position, reportedTS); } // if track has a duration and the listened duration for this session is greater than 100% + 5% (for buffer) // then assume track is on repeat @@ -15,4 +64,17 @@ export class SubsonicPlayerState extends GenericPlayerState { } return false; } -} \ No newline at end of file + + // Use base player listen range, to be able to set a timestamp-based listen range. + private get basePlayer(): AbstractPlayerState { + return this as AbstractPlayerState; + } + + private get activeRange(): ListenRange | undefined { + return this.basePlayer.currentListenRange; + } + + private set activeRange(range: ListenRange | undefined) { + this.basePlayer.currentListenRange = range; + } +} diff --git a/src/backend/sources/SubsonicSource.ts b/src/backend/sources/SubsonicSource.ts index cb5b6fb3..9ae5cb86 100644 --- a/src/backend/sources/SubsonicSource.ts +++ b/src/backend/sources/SubsonicSource.ts @@ -4,17 +4,17 @@ import isSameOrAfter from "dayjs/plugin/isSameOrAfter.js"; import type EventEmitter from "events"; import type { Request } from 'superagent'; import request from 'superagent'; -import type {PlayObject, PlayObjectMinimal} from "../../core/Atomic.ts"; +import { REPORTED_PLAYER_STATUSES, type PlayObject, type PlayObjectMinimal } from "../../core/Atomic.ts"; import { isNodeNetworkException } from "../common/errors/NodeErrors.ts"; import { UpstreamError } from "../common/errors/UpstreamError.ts"; -import { DEFAULT_RETRY_MULTIPLIER, type FormatPlayObjectOptions, type InternalConfig } from "../common/infrastructure/Atomic.ts"; +import { DEFAULT_RETRY_MULTIPLIER, type FormatPlayObjectOptions, type InternalConfig, type PlayerStateDataMaybePlay } from "../common/infrastructure/Atomic.ts"; import type {PlayPlatformId} from '../../core/Atomic.ts'; import type {SubSonicSourceConfig} from "../common/infrastructure/config/source/subsonic.ts"; -import { getSubsonicResponse, type SubsonicResponse, type SubsonicResponseCommon } from "../common/vendor/subsonic/interfaces.ts"; +import { getSubsonicResponse, type EntryData, type OpenSubsonicExtensionsResponse, type SubsonicNowPlayingResponse, type SubsonicResponse, type SubsonicResponseCommon } from "../common/vendor/subsonic/interfaces.ts"; import { removeDuplicates } from "../utils.ts"; import { findCauseByFunc } from "../utils/ErrorUtils.ts"; import type {RecentlyPlayedOptions} from "./AbstractSource.ts"; -import MemorySource from "./MemorySource.ts"; +import { MemoryPositionalSource } from "./MemoryPositionalSource.ts"; import { SubsonicPlayerState } from './PlayerState/SubsonicPlayerState.ts'; import type {PlayerStateOptions} from './PlayerState/AbstractPlayerState.ts'; import type {Logger} from '@foxxmd/logging'; @@ -35,7 +35,7 @@ interface SourceIdentifierData { openSubsonic?: boolean } -export class SubsonicSource extends MemorySource { +export class SubsonicSource extends MemoryPositionalSource { requiresAuth = true; @@ -47,6 +47,8 @@ export class SubsonicSource extends MemorySource { sourceData: SourceIdentifierData = {}; + playbackReportSupported = false; + constructor(name: any, config: SubSonicSourceConfig, internal: InternalConfig, emitter: EventEmitter) { const { data: { @@ -59,7 +61,7 @@ export class SubsonicSource extends MemorySource { this.canPoll = true; } - static formatPlayObj(obj: any, options: FormatPlayObjectOptions & { sourceData?: SourceIdentifierData } = {}): PlayObject { + static formatPlayObj(obj: EntryData, options: FormatPlayObjectOptions & { sourceData?: SourceIdentifierData } = {}): PlayerStateDataMaybePlay { const { newFromSource = false, sourceData: { @@ -78,8 +80,12 @@ export class SubsonicSource extends MemorySource { minutesAgo, playerId, username, + state, + positionMs } = obj; + const position = positionMs !== undefined ? positionMs / 1000 : undefined; + const play: PlayObjectMinimal = { data: { artists: [artistNameToCredit(artist)], @@ -90,6 +96,7 @@ export class SubsonicSource extends MemorySource { // so we need to force the time to be 0 seconds always so that when we compare against scrobbles from client the time isn't off playDate: minutesAgo === 0 ? dayjs().startOf('minute') : dayjs().startOf('minute').subtract(minutesAgo, 'minute'), }, + meta: { source: 'Subsonic', trackId: id, @@ -97,10 +104,18 @@ export class SubsonicSource extends MemorySource { user: username, deviceId: playerId, mediaPlayerName: type ?? `${openSubsonic ? 'Open ' : ''}Subsonic`, - mediaPlayerVersion: type !== undefined && serverVersion !== undefined ? serverVersion : version + mediaPlayerVersion: type !== undefined && serverVersion !== undefined ? serverVersion : version, + ...(position === undefined ? {} : {trackProgressPosition: position}) } } - return baseFormatPlayObj(obj, play); + const status = subsonicPlaybackStateToReportedStatus(state); + + return { + platformId: [playerId, username], + play: baseFormatPlayObj(obj, play), + ...(status === undefined ? {} : {status}), + ...(position === undefined ? {} : {position}) + }; } doCallApi = async (req: Request, retries = 0): Promise => { @@ -209,7 +224,7 @@ export class SubsonicSource extends MemorySource { callApi = async (reqFunc: () => Request): Promise => { try { return await tryApiCall(() => this.doCallApi(reqFunc()), { - ...this.config, + ...this.config.options, logger: this.logger, shouldRetry: noRetryOnUpstreamError }) as T; @@ -256,6 +271,7 @@ export class SubsonicSource extends MemorySource { const resp = await this.callApi(() => request.get(`${url}/rest/ping`)); this.sourceData = resp as SourceIdentifierData; this.logger.info(`Subsonic Server reachable: ${identifiersFromResponse(resp)}`); + await this.discoverPlaybackReportSupport(); return true; } catch (e) { @@ -264,6 +280,7 @@ export class SubsonicSource extends MemorySource { const resp = getSubsonicResponse(subResponseError.response) this.logger.info(`Subsonic Server reachable: ${identifiersFromResponse(resp)}`); this.sourceData = resp as SourceIdentifierData; + await this.discoverPlaybackReportSupport(); return true; } @@ -279,12 +296,29 @@ export class SubsonicSource extends MemorySource { } } - protected filterExpiredNowPlaying(plays: PlayObject[]): PlayObject[]{ + private async discoverPlaybackReportSupport() { + const {url} = this.config.data; + this.playbackReportSupported = false; + try { + const {openSubsonicExtensions} = await this.callApi(() => request.get(`${url}/rest/getOpenSubsonicExtensions`)); + this.playbackReportSupported = openSubsonicExtensions.some(({name}) => name === 'playbackReport'); + this.logger.info(`OpenSubsonic Playback Report support: ${this.playbackReportSupported ? 'available' : 'unavailable'}`); + } catch (e) { + this.logger.info({error: e}, 'Could not determine OpenSubsonic Playback Report support'); + } + } + + protected filterExpiredNowPlaying(states: PlayerStateDataMaybePlay[]): PlayerStateDataMaybePlay[]{ if(this.config.data.detectStaleNowPlayingFromMinutesAgo === false){ - return plays; + return states; } - return plays.filter(play => { + return states.filter(state => { + // Playback reports are more accurate than the minute-granularity fallback. + if (state.position !== undefined || state.status !== undefined || state.play === undefined) { + return true; + } + const {play} = state; const {artists = [], duration, playDate, track} = play.data; if (duration === undefined || playDate === undefined) { return true; @@ -313,23 +347,49 @@ export class SubsonicSource extends MemorySource { getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}) => { const {formatted = false} = options; const {url} = this.config.data; - const resp = await this.callApi(() => request.get(`${url}/rest/getNowPlaying`)); + const resp = await this.callApi(() => request.get(`${url}/rest/getNowPlaying`)); const { nowPlaying: { entry = [] } = {} } = resp; + const states = entry.map(x => { + if (this.playbackReportSupported && x.state === undefined && x.positionMs === undefined) { + this.logger.debug({entry: x}, 'Playback Report support was advertised by the server but a now-playing entry contained no playback report fields. This is likely caused by the client used for playback not reporting playback information to the server.'); + } + return SubsonicSource.formatPlayObj(x, {sourceData: this.sourceData}); + }); // 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.map(x => SubsonicSource.formatPlayObj(x, {sourceData: this.sourceData}))); + const active = this.filterExpiredNowPlaying(states); // 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())); + const dedupedPlays = removeDuplicates(active.flatMap(({play}) => play === undefined ? [] : [play])); + const deduped = active.filter(({play}) => play === undefined || dedupedPlays.includes(play)); + const allowedUsers = this.usersAllow.map(x => x.toLocaleLowerCase()); + const userFiltered = allowedUsers.length === 0 ? deduped : deduped.filter(state => { + const user = state.play?.meta.user; + return user === undefined || allowedUsers.includes(user.toLocaleLowerCase()); + }); return await this.processRecentPlays(userFiltered); } getNewPlayer = (logger: Logger, id: PlayPlatformId, opts: PlayerStateOptions) => new SubsonicPlayerState(logger, id, opts); } +const subsonicPlaybackStateToReportedStatus = (state: string | undefined) => { + switch (state) { + case 'playing': + return REPORTED_PLAYER_STATUSES.playing; + case 'paused': + return REPORTED_PLAYER_STATUSES.paused; + case 'stopped': + return REPORTED_PLAYER_STATUSES.stopped; + case undefined: + return undefined; + default: + return REPORTED_PLAYER_STATUSES.unknown; + } +}; + export const getSubsonicResponseFromError = (error: unknown): UpstreamError => findCauseByFunc(error, (err) => { if(err instanceof UpstreamError && err.response !== undefined) { return getSubsonicResponse(err.response) !== undefined; diff --git a/src/backend/tests/subsonic/subsonic.test.ts b/src/backend/tests/subsonic/subsonic.test.ts index 716fa3ef..94313dbb 100644 --- a/src/backend/tests/subsonic/subsonic.test.ts +++ b/src/backend/tests/subsonic/subsonic.test.ts @@ -4,18 +4,35 @@ 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 { http, HttpResponse } from 'msw'; +import { REPORTED_PLAYER_STATUSES } from '../../../core/Atomic.ts'; +import type {PlayerStateDataMaybePlay} from '../../common/infrastructure/Atomic.ts'; import type {SubSonicSourceConfig} from '../../common/infrastructure/config/source/subsonic.ts'; +import { UpstreamError } from '../../common/errors/UpstreamError.ts'; import {isSubsonicNowPlayingExpired, SubsonicSource} from '../../sources/SubsonicSource.ts'; +import { SubsonicPlayerState } from '../../sources/PlayerState/SubsonicPlayerState.ts'; +import { withRequestInterception } from '../utils/networking.ts'; class TestSubsonicSource extends SubsonicSource { + + // Prevent mocked timestamps during tests to be interpreted as seek, by increasing the allowed drift range + getNewPlayer = (...[logger, id, opts]: Parameters) => new SubsonicPlayerState(logger, id, { + ...opts, + allowedDrift: 30_000 + }); + + // make protected method available for tests + doCheckConnection(): Promise { + return super.doCheckConnection(); + } + // make protected method available for tests - filterNowPlaying(entries: PlayObject[]): PlayObject[] { + filterNowPlaying(entries: PlayerStateDataMaybePlay[]): PlayerStateDataMaybePlay[] { return this.filterExpiredNowPlaying(entries); } } -const entry = (minutesAgo: number, duration = 180) => ({ +const entry = (minutesAgo: number, duration = 180, playback: Record = {}) => ({ id: 'track-id', title: 'Track', album: 'Album', @@ -23,9 +40,12 @@ const entry = (minutesAgo: number, duration = 180) => ({ duration, minutesAgo, playerId: 'player-id', - username: 'user' + username: 'user', + ...playback }); +const formatPlay = (...args: Parameters) => SubsonicSource.formatPlayObj(...args).play!; + const createSource = (detectStaleNowPlayingFromMinutesAgo?: boolean) => { const config: SubSonicSourceConfig = { data: { @@ -34,7 +54,8 @@ const createSource = (detectStaleNowPlayingFromMinutesAgo?: boolean) => { password: 'password', detectStaleNowPlayingFromMinutesAgo }, - options: {} + // Network-error tests opt out of backoff unless they explicitly test retries. + options: {maxRequestRetries: 0} }; const source = new TestSubsonicSource('test', config, { localUrl: new URL('https://example.com'), @@ -52,7 +73,7 @@ describe('Subsonic now-playing expiration', () => { it('derives the play start from minutesAgo with minute precision', () => { MockDate.set('2026-01-01T12:05:30Z'); - const play = SubsonicSource.formatPlayObj(entry(3)); + const play = formatPlay(entry(3)); expect(play.data.playDate.isSame(dayjs('2026-01-01T12:02:00Z'))).to.be.true; }); @@ -60,7 +81,7 @@ describe('Subsonic now-playing expiration', () => { it('keeps a now-playing row within the track duration and tolerance', () => { MockDate.set('2026-01-01T12:05:30Z'); - const play = SubsonicSource.formatPlayObj(entry(3)); + const play = formatPlay(entry(3)); expect(isSubsonicNowPlayingExpired(play)).to.be.false; }); @@ -68,13 +89,13 @@ describe('Subsonic now-playing expiration', () => { it('expires a lingering now-playing row older than duration plus tolerance', () => { MockDate.set('2026-01-01T12:05:30Z'); - const play = SubsonicSource.formatPlayObj(entry(4)); + const play = formatPlay(entry(4)); expect(isSubsonicNowPlayingExpired(play)).to.be.true; }); it('expires only after the duration plus minute precision and playback tolerance', () => { - const play = SubsonicSource.formatPlayObj(entry(0)); + const play = formatPlay(entry(0)); const expiresAt = play.data.playDate!.add(249, 'second'); expect(isSubsonicNowPlayingExpired(play, expiresAt)).to.be.false; @@ -82,7 +103,7 @@ describe('Subsonic now-playing expiration', () => { }); it('adds five percent to the minute precision tolerance for long tracks', () => { - const play = SubsonicSource.formatPlayObj(entry(0, 1800)); + const play = formatPlay(entry(0, 1800)); const expiresAt = play.data.playDate!.add(1950, 'second'); expect(isSubsonicNowPlayingExpired(play, expiresAt)).to.be.false; @@ -92,7 +113,7 @@ describe('Subsonic now-playing expiration', () => { it('does not expire a track which started late in the reported minute', () => { MockDate.set('2026-01-01T12:04:57Z'); - const play = SubsonicSource.formatPlayObj(entry(3, 184)); + const play = formatPlay(entry(3, 184)); expect(play.data.playDate.isSame(dayjs('2026-01-01T12:01:00Z'))).to.be.true; expect(isSubsonicNowPlayingExpired(play)).to.be.false; @@ -101,8 +122,8 @@ describe('Subsonic now-playing expiration', () => { it('accepts a reset minutesAgo value for a repeated track', () => { MockDate.set('2026-01-01T12:10:30Z'); - expect(isSubsonicNowPlayingExpired(SubsonicSource.formatPlayObj(entry(8)))).to.be.true; - expect(isSubsonicNowPlayingExpired(SubsonicSource.formatPlayObj(entry(0)))).to.be.false; + expect(isSubsonicNowPlayingExpired(formatPlay(entry(8)))).to.be.true; + expect(isSubsonicNowPlayingExpired(formatPlay(entry(0)))).to.be.false; }); it('filters expired now-playing rows by default', () => { @@ -125,4 +146,264 @@ describe('Subsonic now-playing expiration', () => { expect(source.filterNowPlaying([SubsonicSource.formatPlayObj(entry(4))])).to.have.length(1); }); + + it('uses minutesAgo expiration only when no playback report fields are present', () => { + MockDate.set('2026-01-01T12:05:30Z'); + const source = createSource(); + const stale = entry(4); + + expect(source.filterNowPlaying([ + SubsonicSource.formatPlayObj(stale), + SubsonicSource.formatPlayObj({...stale, state: 'playing'}), + SubsonicSource.formatPlayObj({...stale, positionMs: 0}) + ])).to.have.length(2); + }); + + it('retains a state without a play safely', () => { + const source = createSource(); + const state: PlayerStateDataMaybePlay = {platformId: ['player-id', 'user']}; + + expect(source.filterNowPlaying([state])).to.deep.equal([state]); + }); +}); + +describe('Subsonic playback reports', () => { + it('formats playback reports as player state data', () => { + const state = SubsonicSource.formatPlayObj(entry(0, 180, { + state: 'playing', + positionMs: 12345 + })); + + expect(state.platformId).to.deep.equal(['player-id', 'user']); + expect(state.play!.data.track).to.equal('Track'); + expect(state.status).to.equal(REPORTED_PLAYER_STATUSES.playing); + expect(state.position).to.equal(12.345); + expect(state.play.meta.trackProgressPosition).to.equal(12.345); + }); + + for (const [reportedState, expectedStatus] of [ + ['playing', REPORTED_PLAYER_STATUSES.playing], + ['paused', REPORTED_PLAYER_STATUSES.paused], + ['stopped', REPORTED_PLAYER_STATUSES.stopped], + ['starting', REPORTED_PLAYER_STATUSES.unknown], + ['unrecognized', REPORTED_PLAYER_STATUSES.unknown] + ] as const) { + it(`normalizes ${reportedState} playback report status`, () => { + const state = SubsonicSource.formatPlayObj(entry(0, 180, {state: reportedState})); + + expect(state.status).to.equal(expectedStatus); + }); + } + + it('omits unavailable playback report fields', () => { + const state = SubsonicSource.formatPlayObj(entry(0)); + + expect(state).not.to.have.property('status'); + expect(state).not.to.have.property('position'); + }); +}); + +describe('Subsonic player tracking', () => { + const at = (seconds: number) => dayjs('2026-01-01T12:00:00Z').add(seconds, 'second'); + const state = (seconds: number, playback: Record = {}) => ({ + ...SubsonicSource.formatPlayObj(entry(0, 60, playback)), + stateUpdatedAt: at(seconds) + }); + const player = (source: TestSubsonicSource) => Array.from(source.players.values())[0]; + + it('tracks positionless legacy playback using timestamps', async () => { + const source = createSource(); + + await source.processRecentPlays([state(0)], at(0)); + await source.processRecentPlays([state(10)], at(10)); + + expect(player(source).getPlayedObject()!.data.listenedFor).to.equal(10); + }); + + it('tracks reported playback using position deltas', async () => { + const source = createSource(); + + await source.processRecentPlays([state(0, {state: 'playing', positionMs: 0})], at(0)); + await source.processRecentPlays([state(10, {state: 'playing', positionMs: 10000})], at(10)); + await source.processRecentPlays([state(20, {state: 'playing', positionMs: 10000})], at(20)); + + expect(player(source).getPlayedObject()!.data.listenedFor).to.equal(10); + }); + + it('ends a timestamp range before switching to reported positions', async () => { + const source = createSource(); + + await source.processRecentPlays([state(0)], at(0)); + await source.processRecentPlays([state(10)], at(10)); + await source.processRecentPlays([state(20, {state: 'playing', positionMs: 100000})], at(20)); + await source.processRecentPlays([state(30, {state: 'playing', positionMs: 110000})], at(30)); + + expect(player(source).getPlayedObject()!.data.listenedFor).to.equal(20); + }); + + it('ends a reported-position range before switching to timestamps', async () => { + const source = createSource(); + + await source.processRecentPlays([state(0, {state: 'playing', positionMs: 0})], at(0)); + await source.processRecentPlays([state(10, {state: 'playing', positionMs: 10000})], at(10)); + await source.processRecentPlays([state(20)], at(20)); + await source.processRecentPlays([state(30)], at(30)); + + expect(player(source).getPlayedObject()!.data.listenedFor).to.equal(20); + }); + + it('does not apply the legacy repeat fallback to positioned playback', async () => { + const source = createSource(); + + await source.processRecentPlays([state(0, {state: 'playing', positionMs: 0})], at(0)); + await source.processRecentPlays([state(64, {state: 'playing', positionMs: 64000})], at(64)); + const plays = await source.processRecentPlays([state(65, {state: 'playing', positionMs: 65000})], at(65)); + + expect(plays).to.be.empty; + expect(player(source).getPlayedObject()!.data.repeat).to.be.false; + }); + + it('supports timestamp ranges when realtime position tracking is enabled', () => { + const playerState = new SubsonicPlayerState(loggerTest, ['player-id', 'user'], {rtTruth: true}); + + playerState.update(state(0), at(0)); + + expect(() => playerState.getPosition()).not.to.throw(); + expect(playerState.getPosition()).to.be.undefined; + }); +}); + +describe('Subsonic playback report capability discovery', () => { + const pingResponse = { + 'subsonic-response': { + status: 'ok', + version: '1.16.1', + type: 'OpenSubsonic', + serverVersion: '1.0.0', + openSubsonic: true + } + }; + const ping = () => http.get('https://example.com/rest/ping', () => HttpResponse.json(pingResponse)); + const extensions = (openSubsonicExtensions: {name: string, versions: number[]}[]) => http.get('https://example.com/rest/getOpenSubsonicExtensions', () => HttpResponse.json({ + 'subsonic-response': { + ...pingResponse['subsonic-response'], + openSubsonicExtensions + } + })); + + it('discovers playback report version 1 support', withRequestInterception([ + ping(), + extensions([{name: 'playbackReport', versions: [1]}]) + ], async () => { + const source = createSource(); + + expect(await source.doCheckConnection()).to.be.true; + expect(source.playbackReportSupported).to.be.true; + })); + + it(`does not enable playback report support when it is not advertised by the server`, withRequestInterception([ + ping(), + extensions([]) + ], async () => { + const source = createSource(); + + expect(await source.doCheckConnection()).to.be.true; + expect(source.playbackReportSupported).to.be.false; + })); + + it('retries a transient extension probe failure', function() { + this.timeout(3000); + let attempts = 0; + return withRequestInterception([ + ping(), + http.get('https://example.com/rest/getOpenSubsonicExtensions', () => { + attempts += 1; + if (attempts === 1) { + return new HttpResponse(null, {status: 500}); + } + return HttpResponse.json({ + 'subsonic-response': { + ...pingResponse['subsonic-response'], + openSubsonicExtensions: [{name: 'playbackReport', versions: [1]}] + } + }); + }) + ], async () => { + const source = createSource(); + source.config.options.maxRequestRetries = 1; + + expect(await source.doCheckConnection()).to.be.true; + expect(attempts).to.equal(2); + expect(source.playbackReportSupported).to.be.true; + })(); + }); + + for (const [description, extensionResponse] of [ + ['returns HTTP 404', () => new HttpResponse(null, {status: 404})], + ['returns a failed Subsonic envelope', () => HttpResponse.json({'subsonic-response': {...pingResponse['subsonic-response'], status: 'failed', error: {code: 70, message: 'unsupported'}}})], + ['returns malformed non-JSON content', () => HttpResponse.text('not json')], + ['returns HTTP 500', () => new HttpResponse(null, {status: 500})] + ] as const) { + it(`continues when the extension probe ${description}`, withRequestInterception([ + ping(), + http.get('https://example.com/rest/getOpenSubsonicExtensions', extensionResponse) + ], async () => { + const source = createSource(); + + expect(await source.doCheckConnection()).to.be.true; + expect(source.playbackReportSupported).to.be.false; + })); + } + + it('does not probe extensions after a bare HTTP 500 ping failure', withRequestInterception([ + http.get('https://example.com/rest/ping', () => new HttpResponse(null, {status: 500})), + http.get('https://example.com/rest/getOpenSubsonicExtensions', () => { + throw new Error('Extensions must not be probed after a bare HTTP ping failure'); + }) + ], async () => { + const source = createSource(); + source.config.options.maxRequestRetries = 0; + let error: unknown; + + try { + await source.doCheckConnection(); + } catch (e) { + error = e; + } + + expect(error).to.be.instanceOf(UpstreamError); + expect(source.playbackReportSupported).to.be.false; + })); + + it('treats a failed Subsonic ping envelope as reachable and probes extensions', withRequestInterception([ + http.get('https://example.com/rest/ping', () => HttpResponse.json({ + 'subsonic-response': { + ...pingResponse['subsonic-response'], + status: 'failed', + error: {code: 40, message: 'Invalid credentials'} + } + })), + extensions([{name: 'playbackReport', versions: [1]}]) + ], async () => { + const source = createSource(); + + expect(await source.doCheckConnection()).to.be.true; + expect(source.playbackReportSupported).to.be.true; + })); + + it('continues after a failed Subsonic ping envelope when the extension probe returns HTTP 404', withRequestInterception([ + http.get('https://example.com/rest/ping', () => HttpResponse.json({ + 'subsonic-response': { + ...pingResponse['subsonic-response'], + status: 'failed', + error: {code: 40, message: 'Invalid credentials'} + } + })), + http.get('https://example.com/rest/getOpenSubsonicExtensions', () => new HttpResponse(null, {status: 404})) + ], async () => { + const source = createSource(); + + expect(await source.doCheckConnection()).to.be.true; + expect(source.playbackReportSupported).to.be.false; + })); }); -- 2.51.2 From b983de1a58fb5f7578dc9802fc753865dc01ac3c Mon Sep 17 00:00:00 2001 From: Jannis Pohle Date: Sun, 26 Jul 2026 06:40:43 +0000 Subject: [PATCH 4/4] docs: update subsonic source docs --- docsite/docs/configuration/sources/subsonic.mdx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/docsite/docs/configuration/sources/subsonic.mdx b/docsite/docs/configuration/sources/subsonic.mdx index 148a28ea..affeaab3 100644 --- a/docsite/docs/configuration/sources/subsonic.mdx +++ b/docsite/docs/configuration/sources/subsonic.mdx @@ -30,11 +30,7 @@ Use the optional `usersAllow` property with **File** or **AIO** configuration to ## Playback Report Support -During source initialization, multi-scrobbler probes the OpenSubsonic [`getOpenSubsonicExtensions`](https://opensubsonic.netlify.app/docs/endpoints/getopensubsonicextensions/) endpoint for [Playback Report](https://opensubsonic.netlify.app/docs/extensions/playbackreport/) support. Playback Report is available only when both the server supports the extension and the active playback client reports it. - -When the active client reports Playback Report data, multi-scrobbler uses the `state` and `positionMs` fields returned by [`getNowPlaying`](https://opensubsonic.netlify.app/docs/endpoints/getnowplaying/). This provides more accurate playback tracking and stale now-playing handling. - -An extension probe failure does not prevent the source from initializing. multi-scrobbler continues with the standard `getNowPlaying` behavior, and also uses Playback Report fields if they are present in a response. +During source initialization, multi-scrobbler probes the OpenSubsonic [`getOpenSubsonicExtensions`](https://opensubsonic.netlify.app/docs/endpoints/getopensubsonicextensions/) endpoint for [Playback Report](https://opensubsonic.netlify.app/docs/extensions/playbackreport/) support. Playback Report is available only when both the server supports the extension and the active playback client reports it. This extension is optional and not strictly required for multi-scrobbler to work with Subsonic servers, but will lead to more accurate playback tracking and stale now-playing handling. For classic Subsonic clients and entries without `state` or `positionMs`, multi-scrobbler uses the `minutesAgo` and duration values returned by `getNowPlaying` 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 detection can be disabled by setting `detectStaleNowPlayingFromMinutesAgo` to `false`.