handle positional scrobbles for OS

This commit is contained in:
jeffvli
2026-05-25 12:23:08 -07:00
parent 54f181c542
commit d11c3fa58c
@@ -4,17 +4,21 @@ import { useItemImageUrl } from '/@/renderer/components/item-image/item-image';
import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events'; import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events';
import { useSendScrobble } from '/@/renderer/features/player/mutations/scrobble-mutation'; import { useSendScrobble } from '/@/renderer/features/player/mutations/scrobble-mutation';
import { import {
getServerById,
publishScrobbleDebug, publishScrobbleDebug,
useAppStore, useAppStore,
usePlaybackSettings, usePlaybackSettings,
usePlayerSong, usePlayerSong,
usePlayerSpeed,
usePlayerStore, usePlayerStore,
useSettingsStore, useSettingsStore,
useTimestampStoreBase, useTimestampStoreBase,
} from '/@/renderer/store'; } from '/@/renderer/store';
import { LogCategory, logFn } from '/@/renderer/utils/logger'; import { LogCategory, logFn } from '/@/renderer/utils/logger';
import { logMsg } from '/@/renderer/utils/logger-message'; import { logMsg } from '/@/renderer/utils/logger-message';
import { hasFeature } from '/@/shared/api/utils';
import { LibraryItem, QueueSong, ServerType } from '/@/shared/types/domain-types'; import { LibraryItem, QueueSong, ServerType } from '/@/shared/types/domain-types';
import { ServerFeature } from '/@/shared/types/features-types';
import { PlayerStatus } from '/@/shared/types/types'; import { PlayerStatus } from '/@/shared/types/types';
type ScrobbleManualHandlers = { type ScrobbleManualHandlers = {
@@ -36,6 +40,14 @@ export const invokeScrobbleResetListenedState = () => {
scrobbleManualHandlers?.resetListenedState(); scrobbleManualHandlers?.resetListenedState();
}; };
const getPositionValue = (seconds: number, useTicks: boolean) => {
if (useTicks) {
return Math.round(seconds * 1e7);
}
return seconds;
};
/* /*
Submission (Last.fm / etc.) eligibility uses accumulated listen time: Submission (Last.fm / etc.) eligibility uses accumulated listen time:
- If listened time meets the required percentage of track duration - If listened time meets the required percentage of track duration
@@ -99,6 +111,7 @@ export const useScrobble = () => {
const isPrivateModeEnabled = useAppStore((state) => state.privateMode); const isPrivateModeEnabled = useAppStore((state) => state.privateMode);
const sendScrobble = useSendScrobble(); const sendScrobble = useSendScrobble();
const currentSong = usePlayerSong(); const currentSong = usePlayerSong();
const playbackRate = usePlayerSpeed();
const imageUrl = useItemImageUrl({ const imageUrl = useItemImageUrl({
id: currentSong?.imageId || undefined, id: currentSong?.imageId || undefined,
@@ -166,6 +179,11 @@ export const useScrobble = () => {
if (!isScrobbleEnabled || isPrivateModeEnabled) return; if (!isScrobbleEnabled || isPrivateModeEnabled) return;
const currentSong = usePlayerStore.getState().getCurrentSong(); const currentSong = usePlayerStore.getState().getCurrentSong();
const mediaType = currentSong?._itemType.includes('song') ? 'song' : 'podcast';
const serverId = currentSong?._serverId;
const server = getServerById(serverId);
const hasPlaybackReport = hasFeature(server, ServerFeature.REPORT_PLAYBACK);
const useTicks = currentSong?._serverType === ServerType.JELLYFIN;
const currentStatus = usePlayerStore.getState().player.status; const currentStatus = usePlayerStore.getState().player.status;
const currentTime = properties.timestamp; const currentTime = properties.timestamp;
const previousTime = prev.timestamp; const previousTime = prev.timestamp;
@@ -220,19 +238,20 @@ export const useScrobble = () => {
} }
} }
// Send Jellyfin progress events every 10 seconds // Send progress events every 10 seconds
if (currentSong._serverType === ServerType.JELLYFIN) { if (hasPlaybackReport) {
const timeSinceLastProgress = currentTime - lastProgressEventRef.current; const timeSinceLastProgress = currentTime - lastProgressEventRef.current;
if (timeSinceLastProgress >= 10) { if (timeSinceLastProgress >= 10) {
const position = currentTime * 1e7;
sendScrobble.mutate( sendScrobble.mutate(
{ {
apiClientProps: { serverId: currentSong._serverId || '' }, apiClientProps: { serverId: serverId || '' },
query: { query: {
albumId: currentSong.albumId, albumId: currentSong.albumId,
event: 'timeupdate', event: 'timeupdate',
id: currentSong.id, id: currentSong.id,
position, mediaType: mediaType,
playbackRate,
position: getPositionValue(currentTime, useTicks),
submission: false, submission: false,
}, },
}, },
@@ -261,20 +280,15 @@ export const useScrobble = () => {
}); });
if (shouldSubmitScrobble) { if (shouldSubmitScrobble) {
// Since jellyfin-plugin-lastfm uses the submission Position to determine if the song should actually scrobble
// we just send the full duration of the song when it matches the local scrobble conditions
const position =
currentSong._serverType === ServerType.JELLYFIN
? currentSong.duration * 1e7
: undefined;
sendScrobble.mutate( sendScrobble.mutate(
{ {
apiClientProps: { serverId: currentSong._serverId || '' }, apiClientProps: { serverId: currentSong._serverId || '' },
query: { query: {
albumId: currentSong.albumId, albumId: currentSong.albumId,
id: currentSong.id, id: currentSong.id,
position, mediaType: mediaType,
playbackRate: playbackRate,
position: getPositionValue(currentSong.duration ?? 0, useTicks),
submission: true, submission: true,
}, },
}, },
@@ -295,7 +309,7 @@ export const useScrobble = () => {
} }
} }
}, },
[isScrobbleEnabled, isPrivateModeEnabled, sendScrobble], [isScrobbleEnabled, isPrivateModeEnabled, sendScrobble, playbackRate],
); );
const handleScrobbleFromSongChange = useCallback( const handleScrobbleFromSongChange = useCallback(
@@ -305,6 +319,7 @@ export const useScrobble = () => {
) => { ) => {
const currentSong = properties.song; const currentSong = properties.song;
const previousSong = previousSongRef.current; const previousSong = previousSongRef.current;
const mediaType = currentSong?._itemType.includes('song') ? 'song' : 'podcast';
// Handle notifications // Handle notifications
if (scrobbleSettings?.notify && currentSong?.id) { if (scrobbleSettings?.notify && currentSong?.id) {
@@ -365,6 +380,8 @@ export const useScrobble = () => {
albumId: currentSong.albumId, albumId: currentSong.albumId,
event: 'start', event: 'start',
id: currentSong.id, id: currentSong.id,
mediaType: mediaType,
playbackRate: playbackRate,
position: 0, position: 0,
submission: false, submission: false,
}, },
@@ -388,11 +405,12 @@ export const useScrobble = () => {
flushScrobbleDebug(); flushScrobbleDebug();
}, },
[ [
flushScrobbleDebug,
scrobbleSettings?.notify, scrobbleSettings?.notify,
isScrobbleEnabled, isScrobbleEnabled,
isPrivateModeEnabled, isPrivateModeEnabled,
flushScrobbleDebug,
sendScrobble, sendScrobble,
playbackRate,
], ],
); );
@@ -404,6 +422,11 @@ export const useScrobble = () => {
} }
const currentSong = usePlayerStore.getState().getCurrentSong(); const currentSong = usePlayerStore.getState().getCurrentSong();
const mediaType = currentSong?._itemType.includes('song') ? 'song' : 'podcast';
const serverId = currentSong?._serverId;
const server = getServerById(serverId);
const hasPlaybackReport = hasFeature(server, ServerFeature.REPORT_PLAYBACK);
const useTicks = currentSong?._serverType === ServerType.JELLYFIN;
if (!currentSong?.id) { if (!currentSong?.id) {
return; return;
@@ -422,7 +445,7 @@ export const useScrobble = () => {
} }
// Position scrobbles are only relevant for Jellyfin // Position scrobbles are only relevant for Jellyfin
if (currentSong._serverType !== ServerType.JELLYFIN) { if (!hasPlaybackReport) {
flushScrobbleDebug(); flushScrobbleDebug();
return; return;
} }
@@ -436,8 +459,6 @@ export const useScrobble = () => {
return; return;
} }
const position = properties.timestamp * 1e7;
lastProgressEventRef.current = properties.timestamp; lastProgressEventRef.current = properties.timestamp;
lastSeekEventRef.current = now; lastSeekEventRef.current = now;
@@ -448,7 +469,9 @@ export const useScrobble = () => {
albumId: currentSong.albumId, albumId: currentSong.albumId,
event: 'timeupdate', event: 'timeupdate',
id: currentSong.id, id: currentSong.id,
position, mediaType: mediaType,
playbackRate: playbackRate,
position: getPositionValue(properties.timestamp, useTicks),
submission: false, submission: false,
}, },
}, },
@@ -465,7 +488,7 @@ export const useScrobble = () => {
); );
flushScrobbleDebug(); flushScrobbleDebug();
}, },
[flushScrobbleDebug, isScrobbleEnabled, isPrivateModeEnabled, sendScrobble], [isScrobbleEnabled, isPrivateModeEnabled, sendScrobble, playbackRate, flushScrobbleDebug],
); );
const handleScrobbleFromStatus = useCallback( const handleScrobbleFromStatus = useCallback(
@@ -475,18 +498,22 @@ export const useScrobble = () => {
} }
const currentSong = usePlayerStore.getState().getCurrentSong(); const currentSong = usePlayerStore.getState().getCurrentSong();
const mediaType = currentSong?._itemType.includes('song') ? 'song' : 'podcast';
const serverId = currentSong?._serverId;
const server = getServerById(serverId);
const hasPlaybackReport = hasFeature(server, ServerFeature.REPORT_PLAYBACK);
const useTicks = currentSong?._serverType === ServerType.JELLYFIN;
if (!currentSong?.id) { if (!currentSong?.id) {
return; return;
} }
// Only apply to Jellyfin controller scrobble // Only apply to Jellyfin controller scrobble
if (currentSong._serverType !== ServerType.JELLYFIN) { if (!hasPlaybackReport) {
return; return;
} }
const currentTimestamp = useTimestampStoreBase.getState().timestamp; const currentTimestamp = useTimestampStoreBase.getState().timestamp;
const position = currentTimestamp * 1e7;
// Send pause event when status changes to paused // Send pause event when status changes to paused
if (properties.status === PlayerStatus.PAUSED && prev.status === PlayerStatus.PLAYING) { if (properties.status === PlayerStatus.PAUSED && prev.status === PlayerStatus.PLAYING) {
@@ -497,7 +524,9 @@ export const useScrobble = () => {
albumId: currentSong.albumId, albumId: currentSong.albumId,
event: 'pause', event: 'pause',
id: currentSong.id, id: currentSong.id,
position, mediaType: mediaType,
playbackRate: playbackRate,
position: getPositionValue(currentTimestamp, useTicks),
submission: false, submission: false,
}, },
}, },
@@ -523,7 +552,9 @@ export const useScrobble = () => {
albumId: currentSong.albumId, albumId: currentSong.albumId,
event: 'unpause', event: 'unpause',
id: currentSong.id, id: currentSong.id,
position, mediaType: mediaType,
playbackRate: playbackRate,
position: getPositionValue(currentTimestamp, useTicks),
submission: false, submission: false,
}, },
}, },
@@ -542,7 +573,7 @@ export const useScrobble = () => {
flushScrobbleDebug(); flushScrobbleDebug();
}, },
[flushScrobbleDebug, isScrobbleEnabled, isPrivateModeEnabled, sendScrobble], [isScrobbleEnabled, isPrivateModeEnabled, flushScrobbleDebug, sendScrobble, playbackRate],
); );
const handleScrobbleFromRepeat = useCallback(() => { const handleScrobbleFromRepeat = useCallback(() => {
@@ -552,6 +583,7 @@ export const useScrobble = () => {
const currentSong = usePlayerStore.getState().getCurrentSong(); const currentSong = usePlayerStore.getState().getCurrentSong();
const currentStatus = usePlayerStore.getState().player.status; const currentStatus = usePlayerStore.getState().player.status;
const mediaType = currentSong?._itemType.includes('song') ? 'song' : 'podcast';
if (currentStatus !== PlayerStatus.PLAYING || !currentSong?.id) { if (currentStatus !== PlayerStatus.PLAYING || !currentSong?.id) {
return; return;
@@ -570,6 +602,8 @@ export const useScrobble = () => {
albumId: currentSong.albumId, albumId: currentSong.albumId,
event: 'start', event: 'start',
id: currentSong.id, id: currentSong.id,
mediaType: mediaType,
playbackRate: playbackRate,
position: 0, position: 0,
submission: false, submission: false,
}, },
@@ -587,7 +621,7 @@ export const useScrobble = () => {
}, },
); );
flushScrobbleDebug(); flushScrobbleDebug();
}, [flushScrobbleDebug, isScrobbleEnabled, isPrivateModeEnabled, sendScrobble]); }, [isScrobbleEnabled, isPrivateModeEnabled, sendScrobble, playbackRate, flushScrobbleDebug]);
// Update previous timestamp on progress for use in status change handler // Update previous timestamp on progress for use in status change handler
const handleProgressUpdate = useCallback( const handleProgressUpdate = useCallback(
@@ -607,20 +641,22 @@ export const useScrobble = () => {
} }
const song = usePlayerStore.getState().getCurrentSong(); const song = usePlayerStore.getState().getCurrentSong();
const mediaType = song?._itemType.includes('song') ? 'song' : 'podcast';
const useTicks = song?._serverType === ServerType.JELLYFIN;
if (!song?.id) { if (!song?.id) {
return; return;
} }
const position =
song._serverType === ServerType.JELLYFIN ? song.duration * 1e7 : undefined;
sendScrobble.mutate( sendScrobble.mutate(
{ {
apiClientProps: { serverId: song._serverId || '' }, apiClientProps: { serverId: song._serverId || '' },
query: { query: {
albumId: song.albumId, albumId: song.albumId,
id: song.id, id: song.id,
position, mediaType: mediaType,
playbackRate: playbackRate,
position: getPositionValue(song.duration ?? 0, useTicks),
submission: true, submission: true,
}, },
}, },
@@ -659,7 +695,7 @@ export const useScrobble = () => {
}); });
return () => registerScrobbleManualHandlers(null); return () => registerScrobbleManualHandlers(null);
}, [flushScrobbleDebug, isPrivateModeEnabled, isScrobbleEnabled, sendScrobble]); }, [flushScrobbleDebug, isPrivateModeEnabled, isScrobbleEnabled, playbackRate, sendScrobble]);
usePlayerEvents( usePlayerEvents(
{ {