feishin/src/renderer/features/player/hooks/use-scrobble.ts

395 lines
16 KiB
TypeScript
Raw Normal View History

import { useCallback, useEffect, useRef, useState } from 'react';
import { useSendScrobble } from '/@/renderer/features/player/mutations/scrobble-mutation';
2024-04-01 22:13:06 -07:00
import { usePlayerStore } from '/@/renderer/store';
2023-03-30 06:44:33 -07:00
import { usePlaybackSettings } from '/@/renderer/store/settings.store';
2025-05-20 19:23:36 -07:00
import { 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:
- 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
- Resets the 'isCurrentSongScrobbled' state to false
- When the song is paused:
- Sends the 'submission' scrobble event if conditions are met AND the 'isCurrentSongScrobbled' state is false
- Sends the 'pause' scrobble event (Jellyfin only)
- 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 (Jellyfin only):
- When the song is playing:
- Sends the 'progress' scrobble event on an interval
*/
type PlayerEvent = [PlayerStatus, number];
type SongEvent = [QueueSong | undefined, number, 1 | 2];
const checkScrobbleConditions = (args: {
scrobbleAtDurationMs: number;
2023-07-01 19:10:05 -07:00
scrobbleAtPercentage: number;
songCompletedDurationMs: number;
songDurationMs: number;
}) => {
const { scrobbleAtDurationMs, scrobbleAtPercentage, songCompletedDurationMs, songDurationMs } =
args;
const percentageOfSongCompleted = songDurationMs
? (songCompletedDurationMs / songDurationMs) * 100
2023-07-01 19:10:05 -07:00
: 0;
const shouldScrobbleBasedOnPercetange = percentageOfSongCompleted >= scrobbleAtPercentage;
const shouldScrobbleBasedOnDuration = songCompletedDurationMs >= scrobbleAtDurationMs;
return shouldScrobbleBasedOnPercetange || shouldScrobbleBasedOnDuration;
};
2022-12-19 15:59:14 -08:00
export const useScrobble = () => {
2023-07-01 19:10:05 -07:00
const scrobbleSettings = usePlaybackSettings().scrobble;
const isScrobbleEnabled = scrobbleSettings?.enabled;
const sendScrobble = useSendScrobble();
2023-07-01 19:10:05 -07:00
const [isCurrentSongScrobbled, setIsCurrentSongScrobbled] = useState(false);
2023-07-01 19:10:05 -07:00
const handleScrobbleFromSeek = useCallback(
(currentTime: number) => {
if (!isScrobbleEnabled) return;
2023-07-01 19:10:05 -07:00
const currentSong = usePlayerStore.getState().current.song;
2023-07-01 19:10:05 -07:00
if (!currentSong?.id || currentSong?.serverType !== ServerType.JELLYFIN) return;
2023-07-01 19:10:05 -07:00
const position =
currentSong?.serverType === ServerType.JELLYFIN ? currentTime * 1e7 : undefined;
2023-07-01 19:10:05 -07:00
sendScrobble.mutate({
query: {
event: 'timeupdate',
id: currentSong.id,
position,
submission: false,
},
serverId: currentSong?.serverId,
});
},
2023-07-01 19:10:05 -07:00
[isScrobbleEnabled, sendScrobble],
);
2022-12-19 15:59:14 -08:00
const progressIntervalId = useRef<null | ReturnType<typeof setInterval>>(null);
const songChangeTimeoutId = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const notifyTimeoutId = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
2023-07-01 19:10:05 -07:00
const handleScrobbleFromSongChange = useCallback(
(current: SongEvent, previous: SongEvent) => {
if (scrobbleSettings?.notify && current[0]?.id) {
clearTimeout(notifyTimeoutId.current);
const currentSong = current[0];
// 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
if (
currentSong.uniqueId !== previous[0]?.uniqueId ||
current[2] !== previous[2]
) {
const artists =
currentSong.artists?.length > 0
? currentSong.artists.map((artist) => artist.name).join(', ')
: currentSong.artistName;
new Notification(`Now playing ${currentSong.name}`, {
body: `by ${artists} on ${currentSong.album}`,
icon: currentSong.imageUrl || undefined,
});
}
}, 1000);
}
2023-07-01 19:10:05 -07:00
if (!isScrobbleEnabled) return;
if (progressIntervalId.current) {
clearInterval(progressIntervalId.current);
2024-04-01 22:13:06 -07:00
progressIntervalId.current = null;
2023-07-01 19:10:05 -07:00
}
const previousSong = previous[0];
const previousSongTimeSec = previous[1];
2023-07-01 19:10:05 -07:00
// Send completion scrobble when song changes and a previous song exists
if (previousSong?.id) {
const shouldSubmitScrobble = checkScrobbleConditions({
scrobbleAtDurationMs: (scrobbleSettings?.scrobbleAtDuration ?? 0) * 1000,
2023-07-01 19:10:05 -07:00
scrobbleAtPercentage: scrobbleSettings?.scrobbleAtPercentage,
songCompletedDurationMs: previousSongTimeSec * 1000,
songDurationMs: previousSong.duration,
2023-07-01 19:10:05 -07:00
});
if (
(!isCurrentSongScrobbled && shouldSubmitScrobble) ||
previousSong?.serverType === ServerType.JELLYFIN
) {
const position =
previousSong?.serverType === ServerType.JELLYFIN
? previousSongTimeSec * 1e7
2023-07-01 19:10:05 -07:00
: undefined;
sendScrobble.mutate({
query: {
id: previousSong.id,
position,
submission: true,
},
serverId: previousSong?.serverId,
});
}
}
setIsCurrentSongScrobbled(false);
// Use a timeout to prevent spamming the server with scrobble events when switching through songs quickly
clearTimeout(songChangeTimeoutId.current);
2023-07-01 19:10:05 -07:00
songChangeTimeoutId.current = setTimeout(() => {
const currentSong = current[0];
2024-04-01 22:13:06 -07:00
// 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;
2023-07-01 19:10:05 -07:00
// Send start scrobble when song changes and the new song is playing
2024-04-01 22:13:06 -07:00
if (currentStatus === PlayerStatus.PLAYING && currentSong?.id) {
2023-07-01 19:10:05 -07:00
sendScrobble.mutate({
query: {
event: 'start',
id: currentSong.id,
position: 0,
submission: false,
},
serverId: currentSong?.serverId,
});
if (currentSong?.serverType === ServerType.JELLYFIN) {
2024-04-01 22:13:06 -07:00
// 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);
}
2023-07-01 19:10:05 -07:00
progressIntervalId.current = setInterval(() => {
const currentTime = usePlayerStore.getState().current.time;
handleScrobbleFromSeek(currentTime);
}, 10000);
}
}
}, 2000);
},
[
scrobbleSettings?.notify,
2023-07-01 19:10:05 -07:00
scrobbleSettings?.scrobbleAtDuration,
scrobbleSettings?.scrobbleAtPercentage,
isScrobbleEnabled,
2023-07-01 19:10:05 -07:00
isCurrentSongScrobbled,
sendScrobble,
handleScrobbleFromSeek,
],
);
2022-12-19 15:59:14 -08:00
2023-07-01 19:10:05 -07:00
const handleScrobbleFromStatusChange = useCallback(
(current: PlayerEvent, previous: PlayerEvent) => {
2023-07-01 19:10:05 -07:00
if (!isScrobbleEnabled) return;
const currentSong = usePlayerStore.getState().current.song;
if (!currentSong?.id) return;
const position =
currentSong?.serverType === ServerType.JELLYFIN
? usePlayerStore.getState().current.time * 1e7
: undefined;
const currentStatus = current[0];
const currentTimeSec = current[1];
2023-07-01 19:10:05 -07:00
// Whenever the player is restarted, send a 'start' scrobble
if (currentStatus === PlayerStatus.PLAYING) {
2023-07-01 19:10:05 -07:00
sendScrobble.mutate({
query: {
event: 'unpause',
id: currentSong.id,
position,
submission: false,
},
serverId: currentSong?.serverId,
});
if (currentSong?.serverType === ServerType.JELLYFIN) {
2024-04-01 22:13:06 -07:00
// 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);
}
2023-07-01 19:10:05 -07:00
progressIntervalId.current = setInterval(() => {
2024-04-01 22:13:06 -07:00
const currentTime = usePlayerStore.getState().current.time;
2023-07-01 19:10:05 -07:00
handleScrobbleFromSeek(currentTime);
}, 10000);
}
// Jellyfin is the only one that needs to send a 'pause' event to the server
} else if (currentSong?.serverType === ServerType.JELLYFIN) {
sendScrobble.mutate({
query: {
event: 'pause',
id: currentSong.id,
position,
submission: false,
},
serverId: currentSong?.serverId,
});
if (progressIntervalId.current) {
clearInterval(progressIntervalId.current);
2024-04-01 22:13:06 -07:00
progressIntervalId.current = null;
2023-07-01 19:10:05 -07:00
}
} else {
const isLastTrackInQueue = usePlayerStore.getState().actions.checkIsLastTrack();
const previousTimeSec = previous[1];
2023-07-01 19:10:05 -07:00
// If not already scrobbled, send a 'submission' scrobble if conditions are met
const shouldSubmitScrobble = checkScrobbleConditions({
scrobbleAtDurationMs: (scrobbleSettings?.scrobbleAtDuration ?? 0) * 1000,
2023-07-01 19:10:05 -07:00
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
songCompletedDurationMs:
(isLastTrackInQueue ? previousTimeSec : currentTimeSec) * 1000,
songDurationMs: currentSong.duration,
2023-07-01 19:10:05 -07:00
});
if (!isCurrentSongScrobbled && shouldSubmitScrobble) {
sendScrobble.mutate({
query: {
id: currentSong.id,
submission: true,
},
serverId: currentSong?.serverId,
});
setIsCurrentSongScrobbled(true);
}
}
},
[
isScrobbleEnabled,
sendScrobble,
handleScrobbleFromSeek,
scrobbleSettings?.scrobbleAtDuration,
scrobbleSettings?.scrobbleAtPercentage,
isCurrentSongScrobbled,
],
);
// 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) => {
if (!isScrobbleEnabled) return;
const currentSong = usePlayerStore.getState().current.song;
if (!currentSong?.id) return;
const position =
currentSong?.serverType === ServerType.JELLYFIN ? currentTime * 1e7 : undefined;
const shouldSubmitScrobble = checkScrobbleConditions({
scrobbleAtDurationMs: (scrobbleSettings?.scrobbleAtDuration ?? 0) * 1000,
2023-07-01 19:10:05 -07:00
scrobbleAtPercentage: scrobbleSettings?.scrobbleAtPercentage,
songCompletedDurationMs: currentTime,
songDurationMs: currentSong.duration,
2023-07-01 19:10:05 -07:00
});
if (!isCurrentSongScrobbled && shouldSubmitScrobble) {
sendScrobble.mutate({
query: {
id: currentSong.id,
position,
submission: true,
},
serverId: currentSong?.serverId,
});
}
if (currentSong?.serverType === ServerType.JELLYFIN) {
sendScrobble.mutate({
query: {
event: 'start',
id: currentSong.id,
position: 0,
submission: false,
},
serverId: currentSong?.serverId,
});
}
setIsCurrentSongScrobbled(false);
},
[
isScrobbleEnabled,
scrobbleSettings?.scrobbleAtDuration,
scrobbleSettings?.scrobbleAtPercentage,
isCurrentSongScrobbled,
sendScrobble,
],
);
useEffect(() => {
const unsubSongChange = usePlayerStore.subscribe(
(state): SongEvent => [state.current.song, state.current.time, state.current.player],
2023-07-01 19:10:05 -07:00
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) =>
2025-04-20 10:54:44 -07:00
// compute whether the song changed
a[0]?.uniqueId === b[0]?.uniqueId &&
2025-04-20 10:54:44 -07:00
// compute whether the same player: relevant for repeat one and repeat all (one track)
a[2] === b[2],
2023-07-01 19:10:05 -07:00
},
);
const unsubStatusChange = usePlayerStore.subscribe(
(state): PlayerEvent => [state.current.status, state.current.time],
2023-07-01 19:10:05 -07:00
handleScrobbleFromStatusChange,
{
equalityFn: (a, b) => a[0] === b[0],
},
2023-07-01 19:10:05 -07:00
);
return () => {
unsubSongChange();
unsubStatusChange();
};
}, [handleScrobbleFromSongChange, handleScrobbleFromStatusChange]);
2022-12-19 15:59:14 -08:00
2023-07-01 19:10:05 -07:00
return { handleScrobbleFromSeek, handleScrobbleFromSongRestart };
2022-12-19 15:59:14 -08:00
};