reimplement player scrobble

This commit is contained in:
jeffvli
2025-11-23 15:06:37 -08:00
parent c23e459b89
commit af7e52295a
4 changed files with 238 additions and 202 deletions
@@ -6,7 +6,6 @@ import styles from './synchronized-lyrics.module.css';
import { LyricLine } from '/@/renderer/features/lyrics/lyric-line';
import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events';
import { useScrobble } from '/@/renderer/features/player/hooks/use-scrobble';
import { useLyricsSettings, usePlaybackType, usePlayerActions } from '/@/renderer/store';
import { FullLyricsMetadata, SynchronizedLyricsArray } from '/@/shared/types/domain-types';
import { PlayerStatus, PlayerType } from '/@/shared/types/types';
@@ -31,7 +30,6 @@ export const SynchronizedLyrics = ({
const playbackType = usePlaybackType();
const settings = useLyricsSettings();
const { mediaSeekToTimestamp } = usePlayerActions();
const { handleScrobbleFromSeek } = useScrobble();
// State for player status and timestamp from events
const [status, setStatus] = useState<PlayerStatus>(PlayerStatus.PAUSED);
@@ -42,12 +40,11 @@ export const SynchronizedLyrics = ({
if (playbackType === PlayerType.LOCAL && mpvPlayer) {
mpvPlayer.seekTo(time);
} else {
handleScrobbleFromSeek(time);
mpris?.updateSeek(time);
mediaSeekToTimestamp(time);
}
},
[handleScrobbleFromSeek, mediaSeekToTimestamp, playbackType],
[mediaSeekToTimestamp, playbackType],
);
// const seeked = useSeeked();
@@ -4,6 +4,7 @@ import { eventEmitter } from '/@/renderer/events/event-emitter';
import { UserFavoriteEventPayload, UserRatingEventPayload } from '/@/renderer/events/events';
import { MpvPlayer } from '/@/renderer/features/player/audio-player/mpv-player';
import { WebPlayer } from '/@/renderer/features/player/audio-player/web-player';
import { useScrobble } from '/@/renderer/features/player/hooks/use-scrobble';
import {
updateQueueFavorites,
updateQueueRatings,
@@ -17,6 +18,8 @@ export const AudioPlayers = () => {
const playbackType = usePlaybackType();
const serverId = useCurrentServerId();
useScrobble();
// Listen to favorite and rating events to update queue songs
useEffect(() => {
const handleFavorite = (payload: UserFavoriteEventPayload) => {
+219 -198
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useRef, useState } from 'react';
import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events';
import { useSendScrobble } from '/@/renderer/features/player/mutations/scrobble-mutation';
import { useAppStore, usePlaybackSettings, usePlayerStore } from '/@/renderer/store';
import { QueueSong, ServerType } from '/@/shared/types/domain-types';
@@ -11,6 +12,10 @@ import { PlayerStatus } from '/@/shared/types/types';
- 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
- Previous song (if exists): Sends the 'submission' scrobble event if conditions are met AND the 'isCurrentSongScrobbled' state is false
@@ -28,15 +33,12 @@ Scrobble Events:
- Sends the 'timeupdate' scrobble event (Jellyfin only)
Progress Events (Jellyfin only):
- When the song is playing:
Progress Events:
- When the song is playing (Jellyfin only):
- Sends the 'progress' scrobble event on an interval
*/
type PlayerEvent = [PlayerStatus, number];
type SongEvent = [QueueSong | undefined, number, 1 | 2];
const checkScrobbleConditions = (args: {
scrobbleAtDurationMs: number;
scrobbleAtPercentage: number;
@@ -62,49 +64,149 @@ export const useScrobble = () => {
const sendScrobble = useSendScrobble();
const [isCurrentSongScrobbled, setIsCurrentSongScrobbled] = useState(false);
const previousSongRef = useRef<QueueSong | undefined>(undefined);
const previousTimestampRef = useRef<number>(0);
const lastProgressEventRef = useRef<number>(0);
const songChangeTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const notifyTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const handleScrobbleFromSeek = useCallback(
(currentTime: number) => {
const handleScrobbleFromProgress = useCallback(
(properties: { timestamp: number }, prev: { timestamp: number }) => {
if (!isScrobbleEnabled || isPrivateModeEnabled) return;
const currentSong = usePlayerStore.getState().current.song;
console.log('handleScrobbleFromProgress', properties, prev);
if (!currentSong?.id || currentSong?.serverType !== ServerType.JELLYFIN) return;
const currentSong = usePlayerStore.getState().getCurrentSong();
const currentStatus = usePlayerStore.getState().player.status;
const position =
currentSong?.serverType === ServerType.JELLYFIN ? currentTime * 1e7 : undefined;
if (!currentSong?.id || currentStatus !== PlayerStatus.PLAYING) return;
sendScrobble.mutate({
apiClientProps: { serverId: currentSong?.serverId || '' },
query: {
event: 'timeupdate',
id: currentSong.id,
position,
submission: false,
},
});
const currentTime = properties.timestamp;
const previousTime = prev.timestamp;
// Detect song restart: timestamp resets to 0 (or goes backwards significantly) while song stays the same
// This happens when pressing "Previous Track" and the song restarts (if >= 10 seconds)
if (
currentTime < previousTime &&
currentTime < 5 && // Reset to near 0
previousTime >= 10 && // Was playing for at least 10 seconds
currentSong._uniqueId === previousSongRef.current?._uniqueId
) {
// Handle song restart scrobble
const shouldSubmitScrobble = checkScrobbleConditions({
scrobbleAtDurationMs: (scrobbleSettings?.scrobbleAtDuration ?? 0) * 1000,
scrobbleAtPercentage: scrobbleSettings?.scrobbleAtPercentage,
songCompletedDurationMs: previousTime * 1000,
songDurationMs: currentSong.duration,
});
if (!isCurrentSongScrobbled && shouldSubmitScrobble) {
const position =
currentSong._serverType === ServerType.JELLYFIN
? previousTime * 1e7
: undefined;
sendScrobble.mutate({
apiClientProps: { serverId: currentSong._serverId || '' },
query: {
id: currentSong.id,
position,
submission: true,
},
});
}
// Send start event for Jellyfin on restart
if (currentSong._serverType === ServerType.JELLYFIN) {
sendScrobble.mutate({
apiClientProps: { serverId: currentSong._serverId || '' },
query: {
event: 'start',
id: currentSong.id,
position: 0,
submission: false,
},
});
}
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,
},
});
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) {
const position =
currentSong._serverType === ServerType.JELLYFIN
? currentTime * 1e7
: undefined;
sendScrobble.mutate({
apiClientProps: { serverId: currentSong._serverId || '' },
query: {
id: currentSong.id,
position,
submission: true,
},
});
setIsCurrentSongScrobbled(true);
}
}
},
[isScrobbleEnabled, isPrivateModeEnabled, sendScrobble],
[
isScrobbleEnabled,
isPrivateModeEnabled,
scrobbleSettings?.scrobbleAtDuration,
scrobbleSettings?.scrobbleAtPercentage,
isCurrentSongScrobbled,
sendScrobble,
],
);
const progressIntervalId = useRef<null | ReturnType<typeof setInterval>>(null);
const songChangeTimeoutId = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const notifyTimeoutId = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const handleScrobbleFromSongChange = useCallback(
(current: SongEvent, previous: SongEvent) => {
if (scrobbleSettings?.notify && current[0]?.id) {
clearTimeout(notifyTimeoutId.current);
const currentSong = current[0];
(
properties: { index: number; song: QueueSong | undefined },
prev: { index: number; song: QueueSong | undefined },
) => {
const currentSong = properties.song;
const previousSong = previousSongRef.current;
const previousTimestamp = previousTimestampRef.current;
// Set a delay so that quickly (within a second) switching songs doesn't trigger multiple
// notifications
notifyTimeoutId.current = setTimeout(() => {
// Only trigger if the song changed, or the player changed. This should be the case
// anyways, but who knows
// Handle notifications
if (scrobbleSettings?.notify && currentSong?.id) {
clearTimeout(notifyTimeoutRef.current);
notifyTimeoutRef.current = setTimeout(() => {
if (
currentSong._uniqueId !== previous[0]?._uniqueId ||
current[2] !== previous[2]
currentSong._uniqueId !== previousSong?._uniqueId ||
properties.index !== prev.index
) {
const artists =
currentSong.artists?.length > 0
@@ -120,36 +222,32 @@ export const useScrobble = () => {
}, 1000);
}
if (!isScrobbleEnabled || isPrivateModeEnabled) return;
if (progressIntervalId.current) {
clearInterval(progressIntervalId.current);
progressIntervalId.current = null;
if (!isScrobbleEnabled || isPrivateModeEnabled) {
previousSongRef.current = currentSong;
previousTimestampRef.current = 0;
return;
}
const previousSong = previous[0];
const previousSongTimeSec = previous[1];
// Send completion scrobble when song changes and a previous song exists
// Send completion scrobble for previous song
if (previousSong?.id) {
const shouldSubmitScrobble = checkScrobbleConditions({
scrobbleAtDurationMs: (scrobbleSettings?.scrobbleAtDuration ?? 0) * 1000,
scrobbleAtPercentage: scrobbleSettings?.scrobbleAtPercentage,
songCompletedDurationMs: previousSongTimeSec * 1000,
songCompletedDurationMs: previousTimestamp * 1000,
songDurationMs: previousSong.duration,
});
if (
(!isCurrentSongScrobbled && shouldSubmitScrobble) ||
previousSong?._serverType === ServerType.JELLYFIN
previousSong._serverType === ServerType.JELLYFIN
) {
const position =
previousSong?._serverType === ServerType.JELLYFIN
? previousSongTimeSec * 1e7
previousSong._serverType === ServerType.JELLYFIN
? previousTimestamp * 1e7
: undefined;
sendScrobble.mutate({
apiClientProps: { serverId: previousSong?._serverId || '' },
apiClientProps: { serverId: previousSong._serverId || '' },
query: {
id: previousSong.id,
position,
@@ -160,20 +258,17 @@ export const useScrobble = () => {
}
setIsCurrentSongScrobbled(false);
lastProgressEventRef.current = 0;
// Use a timeout to prevent spamming the server with scrobble events when switching through songs quickly
clearTimeout(songChangeTimeoutId.current);
songChangeTimeoutId.current = setTimeout(() => {
const currentSong = current[0];
// Get the current status from the state, not variable. This is because
// of a timing issue where, when playing the first track, the first
// event is song, and then the event is play
const currentStatus = usePlayerStore.getState().current.status;
// 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 || '' },
apiClientProps: { serverId: currentSong._serverId || '' },
query: {
event: 'start',
id: currentSong.id,
@@ -181,21 +276,11 @@ export const useScrobble = () => {
submission: false,
},
});
if (currentSong?._serverType === ServerType.JELLYFIN) {
// It is possible that another function sets an interval.
// We only want one running, so clear the existing interval
if (progressIntervalId.current) {
clearInterval(progressIntervalId.current);
}
progressIntervalId.current = setInterval(() => {
const currentTime = usePlayerStore.getState().current.time;
handleScrobbleFromSeek(currentTime);
}, 10000);
}
}
}, 2000);
previousSongRef.current = currentSong;
previousTimestampRef.current = 0;
},
[
scrobbleSettings?.notify,
@@ -205,30 +290,31 @@ export const useScrobble = () => {
isPrivateModeEnabled,
isCurrentSongScrobbled,
sendScrobble,
handleScrobbleFromSeek,
],
);
const handleScrobbleFromStatusChange = useCallback(
(current: PlayerEvent, previous: PlayerEvent) => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
(properties: { status: PlayerStatus }, _prev: { status: PlayerStatus }) => {
if (!isScrobbleEnabled || isPrivateModeEnabled) return;
const currentSong = usePlayerStore.getState().current.song;
const currentSong = usePlayerStore.getState().getCurrentSong();
const currentTimestamp =
usePlayerStore.getState().player.index >= 0 ? previousTimestampRef.current : 0;
if (!currentSong?.id) return;
const position =
currentSong?.serverType === ServerType.JELLYFIN
? usePlayerStore.getState().current.time * 1e7
currentSong._serverType === ServerType.JELLYFIN
? currentTimestamp * 1e7
: undefined;
const currentStatus = current[0];
const currentTimeSec = current[1];
const currentStatus = properties.status;
// Whenever the player is restarted, send a 'start' scrobble
if (currentStatus === PlayerStatus.PLAYING) {
// Send unpause event
sendScrobble.mutate({
apiClientProps: { serverId: currentSong?.serverId || '' },
apiClientProps: { serverId: currentSong._serverId || '' },
query: {
event: 'unpause',
id: currentSong.id,
@@ -236,24 +322,10 @@ export const useScrobble = () => {
submission: false,
},
});
if (currentSong?.serverType === ServerType.JELLYFIN) {
// It is possible that another function sets an interval.
// We only want one running, so clear the existing interval
if (progressIntervalId.current) {
clearInterval(progressIntervalId.current);
}
progressIntervalId.current = setInterval(() => {
const currentTime = usePlayerStore.getState().current.time;
handleScrobbleFromSeek(currentTime);
}, 10000);
}
// Jellyfin is the only one that needs to send a 'pause' event to the server
} else if (currentSong?.serverType === ServerType.JELLYFIN) {
} else if (currentSong._serverType === ServerType.JELLYFIN) {
// Send pause event for Jellyfin
sendScrobble.mutate({
apiClientProps: { serverId: currentSong?.serverId || '' },
apiClientProps: { serverId: currentSong._serverId || '' },
query: {
event: 'pause',
id: currentSong.id,
@@ -261,33 +333,27 @@ export const useScrobble = () => {
submission: false,
},
});
if (progressIntervalId.current) {
clearInterval(progressIntervalId.current);
progressIntervalId.current = null;
}
} else {
const isLastTrackInQueue = usePlayerStore.getState().actions.checkIsLastTrack();
const isFisrtTrackInQueue = usePlayerStore.getState().actions.checkIsFirstTrack();
const previousTimeSec = previous[1];
// For non-Jellyfin servers, check scrobble conditions on pause
const isLastTrackInQueue = usePlayerStore.getState().isLastTrackInQueue();
const isFirstTrackInQueue = usePlayerStore.getState().isFirstTrackInQueue();
const previousTimestamp = previousTimestampRef.current;
// If not already scrobbled, send a 'submission' scrobble if conditions are met
const shouldSubmitScrobble = checkScrobbleConditions({
scrobbleAtDurationMs: (scrobbleSettings?.scrobbleAtDuration ?? 0) * 1000,
scrobbleAtPercentage: scrobbleSettings?.scrobbleAtPercentage,
// If scrobbling the last song in the queue, use the previous time instead of the current time since otherwise time value will be 0
// Note that if the queue has one item (both first and last), use the elapsed time, as this will otherwise result
// in duplicate scrobbles in tandem with handleScrobbleFromSongChange
// If scrobbling the last song in the queue, use the previous time
// Note: if queue has one item (both first and last), use current time
songCompletedDurationMs:
(isLastTrackInQueue && !isFisrtTrackInQueue
? previousTimeSec
: currentTimeSec) * 1000,
(isLastTrackInQueue && !isFirstTrackInQueue
? previousTimestamp
: currentTimestamp) * 1000,
songDurationMs: currentSong.duration,
});
if (!isCurrentSongScrobbled && shouldSubmitScrobble) {
sendScrobble.mutate({
apiClientProps: { serverId: currentSong?.serverId || '' },
apiClientProps: { serverId: currentSong._serverId || '' },
query: {
id: currentSong.id,
submission: true,
@@ -301,103 +367,58 @@ export const useScrobble = () => {
[
isScrobbleEnabled,
isPrivateModeEnabled,
sendScrobble,
handleScrobbleFromSeek,
scrobbleSettings?.scrobbleAtDuration,
scrobbleSettings?.scrobbleAtPercentage,
isCurrentSongScrobbled,
sendScrobble,
],
);
// When pressing the "Previous Track" button, the player will restart the current song if the
// currentTime is >= 10 seconds. Since the song / status change events are not triggered, we will
// need to perform another check to see if the scrobble conditions are met
const handleScrobbleFromSongRestart = useCallback(
(currentTime: number) => {
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().current.song;
const currentSong = usePlayerStore.getState().getCurrentSong();
if (!currentSong?.id) return;
if (!currentSong?.id || currentSong._serverType !== ServerType.JELLYFIN) return;
const position =
currentSong?.serverType === ServerType.JELLYFIN ? currentTime * 1e7 : undefined;
const position = properties.timestamp * 1e7;
const shouldSubmitScrobble = checkScrobbleConditions({
scrobbleAtDurationMs: (scrobbleSettings?.scrobbleAtDuration ?? 0) * 1000,
scrobbleAtPercentage: scrobbleSettings?.scrobbleAtPercentage,
songCompletedDurationMs: currentTime,
songDurationMs: currentSong.duration,
sendScrobble.mutate({
apiClientProps: { serverId: currentSong._serverId || '' },
query: {
event: 'timeupdate',
id: currentSong.id,
position,
submission: false,
},
});
if (!isCurrentSongScrobbled && shouldSubmitScrobble) {
sendScrobble.mutate({
apiClientProps: { serverId: currentSong?.serverId || '' },
query: {
id: currentSong.id,
position,
submission: true,
},
});
}
if (currentSong?.serverType === ServerType.JELLYFIN) {
sendScrobble.mutate({
apiClientProps: { serverId: currentSong?.serverId || '' },
query: {
event: 'start',
id: currentSong.id,
position: 0,
submission: false,
},
});
}
setIsCurrentSongScrobbled(false);
},
[
isScrobbleEnabled,
isPrivateModeEnabled,
scrobbleSettings?.scrobbleAtDuration,
scrobbleSettings?.scrobbleAtPercentage,
isCurrentSongScrobbled,
sendScrobble,
],
[isScrobbleEnabled, isPrivateModeEnabled, sendScrobble],
);
useEffect(() => {
const unsubSongChange = usePlayerStore.subscribe(
(state): SongEvent => [state.current.song, state.current.time, state.current.player],
// 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: handleScrobbleFromStatusChange,
},
[
handleScrobbleFromSongChange,
{
// We need the current time to check the scrobble condition, but we only want to
// trigger the callback when the song changes
// There are two conditions where this should trigger:
// 1. The song actually changes (the common case)
// 2. The song does not change, but the player dows. This would either be
// a single track on repeat one, or one track added to the queue
// multiple times in a row and playback goes normally (no next/previous)
equalityFn: (a, b) =>
// compute whether the song changed
a[0]?._uniqueId === b[0]?._uniqueId &&
// compute whether the same player: relevant for repeat one and repeat all (one track)
a[2] === b[2],
},
);
const unsubStatusChange = usePlayerStore.subscribe(
(state): PlayerEvent => [state.current.status, state.current.time],
handleProgressUpdate,
handleScrobbleFromSeek,
handleScrobbleFromStatusChange,
{
equalityFn: (a, b) => a[0] === b[0],
},
);
return () => {
unsubSongChange();
unsubStatusChange();
};
}, [handleScrobbleFromSongChange, handleScrobbleFromStatusChange]);
return { handleScrobbleFromSeek, handleScrobbleFromSongRestart };
],
);
};
+15
View File
@@ -41,6 +41,8 @@ interface Actions {
items: QueueSong[];
};
increaseVolume: (value: number) => void;
isFirstTrackInQueue: () => boolean;
isLastTrackInQueue: () => boolean;
mediaAutoNext: () => PlayerData;
mediaNext: () => void;
mediaPause: () => void;
@@ -606,6 +608,17 @@ export const usePlayerStoreBase = create<PlayerState>()(
state.player.volume = Math.min(100, state.player.volume + value);
});
},
isFirstTrackInQueue: () => {
const state = get();
const currentIndex = state.player.index;
return currentIndex === 0;
},
isLastTrackInQueue: () => {
const state = get();
const queue = state.getQueueOrder();
const currentIndex = state.player.index;
return currentIndex === queue.items.length - 1;
},
mediaAutoNext: () => {
const currentIndex = get().player.index;
const player = get().player;
@@ -1278,6 +1291,8 @@ export const usePlayerActions = () => {
decreaseVolume: state.decreaseVolume,
getQueue: state.getQueue,
increaseVolume: state.increaseVolume,
isFirstTrackInQueue: state.isFirstTrackInQueue,
isLastTrackInQueue: state.isLastTrackInQueue,
mediaAutoNext: state.mediaAutoNext,
mediaNext: state.mediaNext,
mediaPause: state.mediaPause,