mirror of
https://github.com/jeffvli/feishin.git
synced 2026-06-10 22:32:17 +02:00
422 lines
16 KiB
TypeScript
422 lines
16 KiB
TypeScript
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<null | string | undefined>(imageUrl);
|
|
const [isCurrentSongScrobbled, setIsCurrentSongScrobbled] = useState(false);
|
|
const previousSongRef = useRef<QueueSong | undefined>(undefined);
|
|
const previousTimestampRef = useRef<number>(0);
|
|
const lastProgressEventRef = useRef<number>(0);
|
|
const lastSeekEventRef = useRef<number>(0);
|
|
const songChangeTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
|
const notifyTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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],
|
|
);
|
|
};
|