diff --git a/src/renderer/api/jellyfin/jellyfin-controller.ts b/src/renderer/api/jellyfin/jellyfin-controller.ts index e0e89bba7..1a77a7f7d 100644 --- a/src/renderer/api/jellyfin/jellyfin-controller.ts +++ b/src/renderer/api/jellyfin/jellyfin-controller.ts @@ -1793,6 +1793,17 @@ export const JellyfinController: InternalControllerEndpoint = { return null; } + if (query.event === 'stop') { + jfApiClient(apiClientProps).scrobbleStopped({ + body: { + ItemId: query.id, + PositionTicks: position, + }, + }); + + return null; + } + jfApiClient(apiClientProps).scrobbleProgress({ body: { ItemId: query.id, diff --git a/src/renderer/api/subsonic/subsonic-controller.ts b/src/renderer/api/subsonic/subsonic-controller.ts index b46419b52..f468e439e 100644 --- a/src/renderer/api/subsonic/subsonic-controller.ts +++ b/src/renderer/api/subsonic/subsonic-controller.ts @@ -2326,35 +2326,49 @@ export const SubsonicController: InternalControllerEndpoint = { } } - let state: 'paused' | 'playing' | 'starting' | 'stopped' = 'playing'; + const defaultParams = { + ignoreScrobble: true, + mediaId: query.id, + mediaType: query.mediaType, + playbackRate: query.playbackRate, + positionMs: query.position ?? 0, + }; + + const reportPlayback = (state: 'paused' | 'playing' | 'starting' | 'stopped') => { + return ssApiClient(apiClientProps).reportPlayback({ + query: { + ...defaultParams, + state, + }, + }); + }; + + const promises: Promise[] = []; switch (query.event) { case 'pause': - state = 'paused'; + promises.push(reportPlayback('paused')); break; case 'start': - state = 'starting'; + promises.push(reportPlayback('starting')); + promises.push(reportPlayback('playing')); + break; + case 'stop': + promises.push(reportPlayback('stopped')); break; case 'unpause': - state = 'playing'; + promises.push(reportPlayback('playing')); break; default: - state = 'playing'; + break; } - const res = await ssApiClient(apiClientProps).reportPlayback({ - query: { - ignoreScrobble: true, - mediaId: query.id, - mediaType: query.mediaType, - playbackRate: query.playbackRate, - positionMs: query.position ?? 0, - state, - }, - }); + for (const promise of promises) { + const res = await promise; - if (res.status !== 200) { - throw new Error('Failed to report playback'); + if (res.status !== 200) { + throw new Error('Failed to report playback'); + } } return null; diff --git a/src/renderer/features/player/hooks/use-scrobble.ts b/src/renderer/features/player/hooks/use-scrobble.ts index 98b4af6b7..29e42578b 100644 --- a/src/renderer/features/player/hooks/use-scrobble.ts +++ b/src/renderer/features/player/hooks/use-scrobble.ts @@ -67,8 +67,9 @@ Jellyfin progress APIs still use playback position (ticks), not listen time: - pause / unpause Other events: - - When the song changes: sends 'start' when the new track is playing; - clears submission flag and listen accumulator for the new track. + - When the song changes: sends 'stop' for the previous track; sends 'start' + when the new track is playing; clears submission flag and listen accumulator + for the new track. - When the song is restarted (near 0 after 10s+): clears submission flag and listen accumulator. @@ -129,6 +130,7 @@ export const useScrobble = () => { const previousSongRef = useRef(undefined); const previousTimestampRef = useRef(0); + const stopPositionRef = useRef(0); const lastProgressEventRef = useRef(0); const lastSeekEventRef = useRef(0); const songChangeTimeoutRef = useRef | undefined>(undefined); @@ -316,7 +318,10 @@ export const useScrobble = () => { ) => { const currentSong = properties.song; const previousSong = previousSongRef.current; + const previousPositionSec = stopPositionRef.current; const mediaType = currentSong?._itemType.includes('song') ? 'song' : 'podcast'; + const previousMediaType = previousSong?._itemType.includes('song') ? 'song' : 'podcast'; + const useTicksForPrevious = previousSong?._serverType === ServerType.JELLYFIN; // Handle notifications if (scrobbleSettings?.notify && currentSong?.id) { @@ -352,6 +357,7 @@ export const useScrobble = () => { if (!isScrobbleEnabled || isPrivateModeEnabled) { previousSongRef.current = currentSong; previousTimestampRef.current = 0; + stopPositionRef.current = 0; listenedMsRef.current = 0; lastListenSampleTimeRef.current = null; flushScrobbleDebug(); @@ -395,10 +401,42 @@ export const useScrobble = () => { }, ); } + + // Send stop scrobble for the track that was playing before the change + if (previousSong?.id) { + sendScrobble.mutate( + { + apiClientProps: { serverId: previousSong._serverId || '' }, + query: { + albumId: previousSong.albumId, + event: 'stop', + id: previousSong.id, + mediaType: previousMediaType, + playbackRate: playbackRate, + position: getPositionValue( + previousPositionSec, + useTicksForPrevious, + ), + submission: false, + }, + }, + { + onSuccess: () => { + logFn.debug(logMsg[LogCategory.SCROBBLE].scrobbledStop, { + category: LogCategory.SCROBBLE, + meta: { + id: previousSong.id, + }, + }); + }, + }, + ); + } }, 2000); previousSongRef.current = currentSong; previousTimestampRef.current = 0; + stopPositionRef.current = 0; flushScrobbleDebug(); }, [ @@ -591,6 +629,7 @@ export const useScrobble = () => { isCurrentSongScrobbledRef.current = false; lastProgressEventRef.current = 0; previousTimestampRef.current = 0; + stopPositionRef.current = 0; listenedMsRef.current = 0; lastListenSampleTimeRef.current = null; @@ -625,6 +664,17 @@ export const useScrobble = () => { // Update previous timestamp on progress for use in status change handler const handleProgressUpdate = useCallback( (properties: { timestamp: number }, prev: { timestamp: number }) => { + // Preserve last playback position when the playhead resets to the start + // (song change can fire after progress already reports 0 for the new track). + if ( + properties.timestamp < SCROBBLE_TRACK_BEGIN_SEC && + prev.timestamp >= SCROBBLE_TRACK_BEGIN_SEC + ) { + stopPositionRef.current = prev.timestamp; + } else { + stopPositionRef.current = properties.timestamp; + } + previousTimestampRef.current = properties.timestamp; handleScrobbleFromProgress(properties, prev); flushScrobbleDebug(); diff --git a/src/renderer/utils/logger-message.ts b/src/renderer/utils/logger-message.ts index b3373b84a..137c28cc5 100644 --- a/src/renderer/utils/logger-message.ts +++ b/src/renderer/utils/logger-message.ts @@ -107,6 +107,7 @@ export const logMsg = { [LogCategory.SCROBBLE]: { scrobbledPause: 'Scrobbled a pause event', scrobbledStart: 'Scrobbled a start event', + scrobbledStop: 'Scrobbled a stop event', scrobbledSubmission: 'Scrobbled a submission event', scrobbledTimeupdate: 'Scrobbled a timeupdate event', scrobbledUnpause: 'Scrobbled an unpause event', diff --git a/src/shared/types/domain-types.ts b/src/shared/types/domain-types.ts index 24e3ef858..2aeaaf579 100644 --- a/src/shared/types/domain-types.ts +++ b/src/shared/types/domain-types.ts @@ -1363,7 +1363,7 @@ export type ScrobbleArgs = BaseEndpointArgs & { export type ScrobbleQuery = { albumId?: string; - event?: 'pause' | 'start' | 'unpause'; + event?: 'pause' | 'start' | 'stop' | 'unpause'; id: string; mediaType: 'podcast' | 'song'; playbackRate: number;