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; + }); +});