[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(subsonic): Add basic repeat detection based on listened duration

#375

FoxxMD (Nov 4, 2025, 4:31 PM UTC) c017dd73 ad478ade

+26 -2
+1 -1
src/backend/sources/PlayerState/AbstractPlayerState.ts
··· 318 318 * * Listened duraton is more than 2 minutes/50% of Play OR... 319 319 * * Previous Position was close to end of Play 320 320 */ 321 - protected isSessionRepeat(position?: number, reportedTS?: Dayjs) { 321 + protected isSessionRepeat(position?: number, reportedTS?: Dayjs): boolean { 322 322 if(this.currentListenRange === undefined) { 323 323 return false; 324 324 }
+19
src/backend/sources/PlayerState/SubsonicPlayerState.ts
··· 1 + import { SourcePlayerObj } from "../../../core/Atomic.js"; 2 + import { GenericPlayerState } from "./GenericPlayerState.js"; 3 + import { Dayjs } from "dayjs"; 4 + 5 + export class SubsonicPlayerState extends GenericPlayerState { 6 + 7 + protected isSessionRepeat(position?: number, reportedTS?: Dayjs) { 8 + if(super.isSessionRepeat()) { 9 + return true; 10 + } 11 + // if track has a duration and the listened duration for this session is greater than 100% + 5% (for buffer) 12 + // then assume track is on repeat 13 + if(this.currentPlay.data.duration !== undefined && this.getListenDuration() > (this.currentPlay.data.duration + (0.05 * this.currentPlay.data.duration))) { 14 + this.logger.debug('Listened duration for this session is over 105%, triggering as a repeat'); 15 + return true; 16 + } 17 + return false; 18 + } 19 + }
+6 -1
src/backend/sources/SubsonicSource.ts
··· 6 6 import { PlayObject } from "../../core/Atomic.js"; 7 7 import { isNodeNetworkException } from "../common/errors/NodeErrors.js"; 8 8 import { UpstreamError } from "../common/errors/UpstreamError.js"; 9 - import { DEFAULT_RETRY_MULTIPLIER, FormatPlayObjectOptions, InternalConfig } from "../common/infrastructure/Atomic.js"; 9 + import { DEFAULT_RETRY_MULTIPLIER, FormatPlayObjectOptions, InternalConfig, PlayPlatformId } from "../common/infrastructure/Atomic.js"; 10 10 import { SubSonicSourceConfig } from "../common/infrastructure/config/source/subsonic.js"; 11 11 import { getSubsonicResponse, SubsonicResponse, SubsonicResponseCommon } from "../common/vendor/subsonic/interfaces.js"; 12 12 import { parseRetryAfterSecsFromObj, removeDuplicates, sleep } from "../utils.js"; 13 13 import { findCauseByFunc } from "../utils/ErrorUtils.js"; 14 14 import { RecentlyPlayedOptions } from "./AbstractSource.js"; 15 15 import MemorySource from "./MemorySource.js"; 16 + import { SubsonicPlayerState } from './PlayerState/SubsonicPlayerState.js'; 17 + import { PlayerStateOptions } from './PlayerState/AbstractPlayerState.js'; 18 + import { Logger } from '@foxxmd/logging'; 16 19 17 20 dayjs.extend(isSameOrAfter); 18 21 ··· 291 294 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())); 292 295 return this.processRecentPlays(userFiltered); 293 296 } 297 + 298 + getNewPlayer = (logger: Logger, id: PlayPlatformId, opts: PlayerStateOptions) => new SubsonicPlayerState(logger, id, opts); 294 299 } 295 300 296 301 export const getSubsonicResponseFromError = (error: unknown): UpstreamError => findCauseByFunc(error, (err) => {