import { useCallback, useEffect, useRef, useState } from 'react'; import { useItemImageUrl } from '/@/renderer/components/item-image/item-image'; import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events'; import { useSendScrobble } from '/@/renderer/features/player/mutations/scrobble-mutation'; import { useAppStore, usePlaybackSettings, usePlayerSong, usePlayerStore, useTimestampStoreBase, } from '/@/renderer/store'; import { LogCategory, logFn } from '/@/renderer/utils/logger'; import { logMsg } from '/@/renderer/utils/logger-message'; import { LibraryItem, QueueSong, ServerType } from '/@/shared/types/domain-types'; import { PlayerStatus } from '/@/shared/types/types'; /* Scrobble Conditions (match any): - If the song has been played for the required percentage - If the song has been played for the required duration Scrobble Events: - On song timestamp update: - If the song has been played for the required percentage - If the song has been played for the required duration - When the song changes (or is completed): - Current song: Sends the 'playing' scrobble event - Resets the 'isCurrentSongScrobbled' state to false - When the song is restarted: - Sends the 'submission' scrobble event if conditions are met AND the 'isCurrentSongScrobbled' state is false - Resets the 'isCurrentSongScrobbled' state to false - When the song is seeked: - Sends the 'timeupdate' scrobble event (Jellyfin only) Progress Events: - When the song is playing (Jellyfin only): - Sends the 'progress' scrobble event on an interval */ const checkScrobbleConditions = (args: { scrobbleAtDurationMs: number; scrobbleAtPercentage: number; songCompletedDurationMs: number; songDurationMs: number; }) => { const { scrobbleAtDurationMs, scrobbleAtPercentage, songCompletedDurationMs, songDurationMs } = args; const percentageOfSongCompleted = songDurationMs ? (songCompletedDurationMs / songDurationMs) * 100 : 0; const shouldScrobbleBasedOnPercetange = percentageOfSongCompleted >= scrobbleAtPercentage; const shouldScrobbleBasedOnDuration = songCompletedDurationMs >= scrobbleAtDurationMs; return shouldScrobbleBasedOnPercetange || shouldScrobbleBasedOnDuration; }; export const useScrobble = () => { const scrobbleSettings = usePlaybackSettings().scrobble; const isScrobbleEnabled = scrobbleSettings?.enabled; const isPrivateModeEnabled = useAppStore((state) => state.privateMode); const sendScrobble = useSendScrobble(); const currentSong = usePlayerSong(); const imageUrl = useItemImageUrl({ id: currentSong?.id, imageUrl: currentSong?.imageUrl, itemType: LibraryItem.SONG, type: 'itemCard', }); const imageUrlRef = useRef(imageUrl); const [isCurrentSongScrobbled, setIsCurrentSongScrobbled] = useState(false); const previousSongRef = useRef(undefined); const previousTimestampRef = useRef(0); const lastProgressEventRef = useRef(0); const lastSeekEventRef = useRef(0); const songChangeTimeoutRef = useRef | undefined>(undefined); const notifyTimeoutRef = useRef | undefined>(undefined); useEffect(() => { imageUrlRef.current = imageUrl; }, [imageUrl]); const handleScrobbleFromProgress = useCallback( (properties: { timestamp: number }, prev: { timestamp: number }) => { if (!isScrobbleEnabled || isPrivateModeEnabled) return; const currentSong = usePlayerStore.getState().getCurrentSong(); const currentStatus = usePlayerStore.getState().player.status; if (!currentSong?.id || currentStatus !== PlayerStatus.PLAYING) return; const currentTime = properties.timestamp; const previousTime = prev.timestamp; // Detect song restart: when timestamp resets to near 0 and was playing for at least 10 seconds if ( currentTime < previousTime && currentTime < 5 && // Reset to near 0 previousTime >= 10 // Was playing for at least 10 seconds ) { setIsCurrentSongScrobbled(false); lastProgressEventRef.current = 0; previousTimestampRef.current = 0; return; } // Send Jellyfin progress events every 10 seconds if (currentSong._serverType === ServerType.JELLYFIN) { const timeSinceLastProgress = currentTime - lastProgressEventRef.current; if (timeSinceLastProgress >= 10) { const position = currentTime * 1e7; sendScrobble.mutate( { apiClientProps: { serverId: currentSong._serverId || '' }, query: { event: 'timeupdate', id: currentSong.id, position, submission: false, }, }, { onSuccess: () => { logFn.debug(logMsg[LogCategory.SCROBBLE].scrobbledTimeupdate, { category: LogCategory.SCROBBLE, meta: { id: currentSong.id, }, }); }, }, ); lastProgressEventRef.current = currentTime; } } // Check if we should submit scrobble based on conditions if (!isCurrentSongScrobbled) { const shouldSubmitScrobble = checkScrobbleConditions({ scrobbleAtDurationMs: (scrobbleSettings?.scrobbleAtDuration ?? 0) * 1000, scrobbleAtPercentage: scrobbleSettings?.scrobbleAtPercentage, songCompletedDurationMs: currentTime * 1000, songDurationMs: currentSong.duration, }); 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( { apiClientProps: { serverId: currentSong._serverId || '' }, query: { id: currentSong.id, position, submission: true, }, }, { onSuccess: () => { logFn.debug(logMsg[LogCategory.SCROBBLE].scrobbledSubmission, { category: LogCategory.SCROBBLE, meta: { id: currentSong.id, reason: 'from song progress', }, }); }, }, ); setIsCurrentSongScrobbled(true); } } }, [ isScrobbleEnabled, isPrivateModeEnabled, scrobbleSettings?.scrobbleAtDuration, scrobbleSettings?.scrobbleAtPercentage, isCurrentSongScrobbled, sendScrobble, ], ); const handleScrobbleFromSongChange = useCallback( ( properties: { index: number; song: QueueSong | undefined }, prev: { index: number; song: QueueSong | undefined }, ) => { const currentSong = properties.song; const previousSong = previousSongRef.current; // Handle notifications if (scrobbleSettings?.notify && currentSong?.id) { clearTimeout(notifyTimeoutRef.current); notifyTimeoutRef.current = setTimeout(() => { if ( currentSong._uniqueId !== previousSong?._uniqueId || properties.index !== prev.index ) { const artists = currentSong.artists?.length > 0 ? currentSong.artists.map((artist) => artist.name).join(' ยท ') : currentSong.artistName; new Notification(`${currentSong.name}`, { body: `${artists}\n${currentSong.album}`, icon: imageUrlRef.current || undefined, silent: true, }); } }, 1000); } if (!isScrobbleEnabled || isPrivateModeEnabled) { previousSongRef.current = currentSong; previousTimestampRef.current = 0; return; } setIsCurrentSongScrobbled(false); lastProgressEventRef.current = 0; // Use a timeout to prevent spamming the server when switching songs quickly clearTimeout(songChangeTimeoutRef.current); songChangeTimeoutRef.current = setTimeout(() => { const currentStatus = usePlayerStore.getState().player.status; // Send start scrobble when song changes and the new song is playing if (currentStatus === PlayerStatus.PLAYING && currentSong?.id) { sendScrobble.mutate( { apiClientProps: { serverId: currentSong._serverId || '' }, query: { event: 'start', id: currentSong.id, position: 0, submission: false, }, }, { onSuccess: () => { logFn.debug(logMsg[LogCategory.SCROBBLE].scrobbledStart, { category: LogCategory.SCROBBLE, meta: { id: currentSong.id, }, }); }, }, ); } }, 2000); previousSongRef.current = currentSong; previousTimestampRef.current = 0; }, [scrobbleSettings?.notify, isScrobbleEnabled, isPrivateModeEnabled, sendScrobble], ); const handleScrobbleFromSeek = useCallback( // eslint-disable-next-line @typescript-eslint/no-unused-vars (properties: { timestamp: number }, _prev: { timestamp: number }) => { if (!isScrobbleEnabled || isPrivateModeEnabled) { return; } const currentSong = usePlayerStore.getState().getCurrentSong(); if (!currentSong?.id) { return; } // Position scrobbles are only relevant for Jellyfin if (currentSong._serverType !== ServerType.JELLYFIN) { return; } const now = Date.now(); const timeSinceLastSeek = now - lastSeekEventRef.current; // Only allow seek scrobble once per second if (timeSinceLastSeek < 1000) { return; } const position = properties.timestamp * 1e7; lastProgressEventRef.current = properties.timestamp; lastSeekEventRef.current = now; sendScrobble.mutate( { apiClientProps: { serverId: currentSong._serverId || '' }, query: { event: 'timeupdate', id: currentSong.id, position, submission: false, }, }, { onSuccess: () => { logFn.debug(logMsg[LogCategory.SCROBBLE].scrobbledTimeupdate, { category: LogCategory.SCROBBLE, meta: { id: currentSong.id, }, }); }, }, ); }, [isScrobbleEnabled, isPrivateModeEnabled, sendScrobble], ); const handleScrobbleFromStatus = useCallback( (properties: { status: PlayerStatus }, prev: { status: PlayerStatus }) => { if (!isScrobbleEnabled || isPrivateModeEnabled) { return; } const currentSong = usePlayerStore.getState().getCurrentSong(); if (!currentSong?.id) { return; } // Only apply to Jellyfin controller scrobble if (currentSong._serverType !== ServerType.JELLYFIN) { return; } const currentTimestamp = useTimestampStoreBase.getState().timestamp; const position = currentTimestamp * 1e7; // Send pause event when status changes to paused if (properties.status === PlayerStatus.PAUSED && prev.status === PlayerStatus.PLAYING) { sendScrobble.mutate( { apiClientProps: { serverId: currentSong._serverId || '' }, query: { event: 'pause', id: currentSong.id, position, submission: false, }, }, { onSuccess: () => { logFn.debug(logMsg[LogCategory.SCROBBLE].scrobbledPause, { category: LogCategory.SCROBBLE, meta: { id: currentSong.id, }, }); }, }, ); } // Send unpause event when status changes to playing (from paused) if (properties.status === PlayerStatus.PLAYING && prev.status === PlayerStatus.PAUSED) { sendScrobble.mutate( { apiClientProps: { serverId: currentSong._serverId || '' }, query: { event: 'unpause', id: currentSong.id, position, submission: false, }, }, { onSuccess: () => { logFn.debug(logMsg[LogCategory.SCROBBLE].scrobbledUnpause, { category: LogCategory.SCROBBLE, meta: { id: currentSong.id, }, }); }, }, ); } }, [isScrobbleEnabled, isPrivateModeEnabled, sendScrobble], ); // Update previous timestamp on progress for use in status change handler const handleProgressUpdate = useCallback( (properties: { timestamp: number }, prev: { timestamp: number }) => { previousTimestampRef.current = properties.timestamp; handleScrobbleFromProgress(properties, prev); }, [handleScrobbleFromProgress], ); usePlayerEvents( { onCurrentSongChange: handleScrobbleFromSongChange, onPlayerProgress: handleProgressUpdate, onPlayerSeekToTimestamp: handleScrobbleFromSeek, onPlayerStatus: handleScrobbleFromStatus, }, [handleScrobbleFromSongChange, handleProgressUpdate, handleScrobbleFromSeek], ); };