Add scrobble functionality (#19)
* Fix slider bar background to use theme * Add "scrobbleAtDuration" to settings store * Add subscribeWithSelector and playCount incrementor * Add scrobbling API and mutation * Add scrobble settings * Begin support for multi-server queue handling * Dynamically set version on auth header * Add scrobbling functionality for navidrome/jellyfin
This commit is contained in:
parent
85bf910d65
commit
484c96187c
14 changed files with 1253 additions and 653 deletions
|
@ -43,9 +43,12 @@ import type {
|
|||
RawAddToPlaylistResponse,
|
||||
RemoveFromPlaylistArgs,
|
||||
RawRemoveFromPlaylistResponse,
|
||||
ScrobbleArgs,
|
||||
RawScrobbleResponse,
|
||||
} from '/@/renderer/api/types';
|
||||
import { subsonicApi } from '/@/renderer/api/subsonic.api';
|
||||
import { jellyfinApi } from '/@/renderer/api/jellyfin.api';
|
||||
import { ServerListItem } from '/@/renderer/types';
|
||||
|
||||
export type ControllerEndpoint = Partial<{
|
||||
addToPlaylist: (args: AddToPlaylistArgs) => Promise<RawAddToPlaylistResponse>;
|
||||
|
@ -75,6 +78,7 @@ export type ControllerEndpoint = Partial<{
|
|||
getTopSongs: (args: TopSongListArgs) => Promise<RawTopSongListResponse>;
|
||||
getUserList: (args: UserListArgs) => Promise<RawUserListResponse>;
|
||||
removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise<RawRemoveFromPlaylistResponse>;
|
||||
scrobble: (args: ScrobbleArgs) => Promise<RawScrobbleResponse>;
|
||||
updatePlaylist: (args: UpdatePlaylistArgs) => Promise<RawUpdatePlaylistResponse>;
|
||||
updateRating: (args: RatingArgs) => Promise<RawRatingResponse>;
|
||||
}>;
|
||||
|
@ -114,6 +118,7 @@ const endpoints: ApiController = {
|
|||
getTopSongs: undefined,
|
||||
getUserList: undefined,
|
||||
removeFromPlaylist: jellyfinApi.removeFromPlaylist,
|
||||
scrobble: jellyfinApi.scrobble,
|
||||
updatePlaylist: jellyfinApi.updatePlaylist,
|
||||
updateRating: undefined,
|
||||
},
|
||||
|
@ -145,6 +150,7 @@ const endpoints: ApiController = {
|
|||
getTopSongs: subsonicApi.getTopSongList,
|
||||
getUserList: navidromeApi.getUserList,
|
||||
removeFromPlaylist: navidromeApi.removeFromPlaylist,
|
||||
scrobble: subsonicApi.scrobble,
|
||||
updatePlaylist: navidromeApi.updatePlaylist,
|
||||
updateRating: subsonicApi.updateRating,
|
||||
},
|
||||
|
@ -173,13 +179,14 @@ const endpoints: ApiController = {
|
|||
getSongList: undefined,
|
||||
getTopSongs: subsonicApi.getTopSongList,
|
||||
getUserList: undefined,
|
||||
scrobble: subsonicApi.scrobble,
|
||||
updatePlaylist: undefined,
|
||||
updateRating: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
const apiController = (endpoint: keyof ControllerEndpoint) => {
|
||||
const serverType = useAuthStore.getState().currentServer?.type;
|
||||
const apiController = (endpoint: keyof ControllerEndpoint, server?: ServerListItem | null) => {
|
||||
const serverType = server?.type || useAuthStore.getState().currentServer?.type;
|
||||
|
||||
if (!serverType) {
|
||||
toast.error({ message: 'No server selected', title: 'Unable to route request' });
|
||||
|
@ -287,6 +294,10 @@ const getTopSongList = async (args: TopSongListArgs) => {
|
|||
return (apiController('getTopSongs') as ControllerEndpoint['getTopSongs'])?.(args);
|
||||
};
|
||||
|
||||
const scrobble = async (args: ScrobbleArgs) => {
|
||||
return (apiController('scrobble', args.server) as ControllerEndpoint['scrobble'])?.(args);
|
||||
};
|
||||
|
||||
export const controller = {
|
||||
addToPlaylist,
|
||||
createFavorite,
|
||||
|
@ -307,6 +318,7 @@ export const controller = {
|
|||
getTopSongList,
|
||||
getUserList,
|
||||
removeFromPlaylist,
|
||||
scrobble,
|
||||
updatePlaylist,
|
||||
updateRating,
|
||||
};
|
||||
|
|
|
@ -70,10 +70,13 @@ import {
|
|||
LibraryItem,
|
||||
RemoveFromPlaylistArgs,
|
||||
AddToPlaylistArgs,
|
||||
ScrobbleArgs,
|
||||
RawScrobbleResponse,
|
||||
} from '/@/renderer/api/types';
|
||||
import { useAuthStore } from '/@/renderer/store';
|
||||
import { ServerListItem, ServerType } from '/@/renderer/types';
|
||||
import { parseSearchParams } from '/@/renderer/utils';
|
||||
import packageJson from '../../../package.json';
|
||||
|
||||
const getCommaDelimitedString = (value: string[]) => {
|
||||
return value.join(',');
|
||||
|
@ -93,8 +96,7 @@ const authenticate = async (
|
|||
const data = await ky
|
||||
.post(`${cleanServerUrl}/users/authenticatebyname`, {
|
||||
headers: {
|
||||
'X-Emby-Authorization':
|
||||
'MediaBrowser Client="Feishin", Device="PC", DeviceId="Feishin", Version="0.0.1"',
|
||||
'X-Emby-Authorization': `MediaBrowser Client="Feishin", Device="PC", DeviceId="Feishin", Version="${packageJson.version}"`,
|
||||
},
|
||||
json: {
|
||||
pw: body.password,
|
||||
|
@ -581,6 +583,81 @@ const deleteFavorite = async (args: FavoriteArgs): Promise<FavoriteResponse> =>
|
|||
};
|
||||
};
|
||||
|
||||
const scrobble = async (args: ScrobbleArgs): Promise<RawScrobbleResponse> => {
|
||||
const { query, server } = args;
|
||||
|
||||
const position = query.position && Math.round(query.position);
|
||||
|
||||
if (query.submission) {
|
||||
// Checked by jellyfin-plugin-lastfm for whether or not to send the "finished" scrobble (uses PositionTicks)
|
||||
api.post(`sessions/playing/stopped`, {
|
||||
headers: { 'X-MediaBrowser-Token': server?.credential },
|
||||
json: {
|
||||
IsPaused: true,
|
||||
ItemId: query.id,
|
||||
PositionTicks: position,
|
||||
},
|
||||
prefixUrl: server?.url,
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (query.event === 'start') {
|
||||
await api.post(`sessions/playing`, {
|
||||
headers: { 'X-MediaBrowser-Token': server?.credential },
|
||||
json: {
|
||||
ItemId: query.id,
|
||||
PositionTicks: position,
|
||||
},
|
||||
prefixUrl: server?.url,
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (query.event === 'pause') {
|
||||
await api.post(`sessions/playing/progress`, {
|
||||
headers: { 'X-MediaBrowser-Token': server?.credential },
|
||||
json: {
|
||||
EventName: query.event,
|
||||
IsPaused: true,
|
||||
ItemId: query.id,
|
||||
PositionTicks: position,
|
||||
},
|
||||
prefixUrl: server?.url,
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (query.event === 'unpause') {
|
||||
await api.post(`sessions/playing/progress`, {
|
||||
headers: { 'X-MediaBrowser-Token': server?.credential },
|
||||
json: {
|
||||
EventName: query.event,
|
||||
IsPaused: false,
|
||||
ItemId: query.id,
|
||||
PositionTicks: position,
|
||||
},
|
||||
prefixUrl: server?.url,
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
await api.post(`sessions/playing/progress`, {
|
||||
headers: { 'X-MediaBrowser-Token': server?.credential },
|
||||
json: {
|
||||
ItemId: query.id,
|
||||
PositionTicks: position,
|
||||
},
|
||||
prefixUrl: server?.url,
|
||||
});
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const getStreamUrl = (args: {
|
||||
container?: string;
|
||||
deviceId: string;
|
||||
|
@ -927,6 +1004,7 @@ export const jellyfinApi = {
|
|||
getPlaylistSongList,
|
||||
getSongList,
|
||||
removeFromPlaylist,
|
||||
scrobble,
|
||||
updatePlaylist,
|
||||
};
|
||||
|
||||
|
|
|
@ -26,6 +26,7 @@ import type {
|
|||
SSArtistInfo,
|
||||
SSSong,
|
||||
SSTopSongList,
|
||||
SSScrobbleParams,
|
||||
} from '/@/renderer/api/subsonic.types';
|
||||
import {
|
||||
AlbumArtistDetailArgs,
|
||||
|
@ -42,6 +43,8 @@ import {
|
|||
QueueSong,
|
||||
RatingArgs,
|
||||
RatingResponse,
|
||||
RawScrobbleResponse,
|
||||
ScrobbleArgs,
|
||||
ServerListItem,
|
||||
ServerType,
|
||||
TopSongListArgs,
|
||||
|
@ -386,6 +389,25 @@ const getArtistInfo = async (args: ArtistInfoArgs): Promise<SSArtistInfo> => {
|
|||
return data.artistInfo2;
|
||||
};
|
||||
|
||||
const scrobble = async (args: ScrobbleArgs): Promise<RawScrobbleResponse> => {
|
||||
const { signal, server, query } = args;
|
||||
const defaultParams = getDefaultParams(server);
|
||||
|
||||
const searchParams: SSScrobbleParams = {
|
||||
id: query.id,
|
||||
submission: query.submission,
|
||||
...defaultParams,
|
||||
};
|
||||
|
||||
await api.get('rest/scrobble.view', {
|
||||
prefixUrl: server?.url,
|
||||
searchParams,
|
||||
signal,
|
||||
});
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const normalizeSong = (item: SSSong, server: ServerListItem, deviceId: string): QueueSong => {
|
||||
const imageUrl =
|
||||
getCoverArtUrl({
|
||||
|
@ -465,6 +487,7 @@ export const subsonicApi = {
|
|||
getGenreList,
|
||||
getMusicFolderList,
|
||||
getTopSongList,
|
||||
scrobble,
|
||||
updateRating,
|
||||
};
|
||||
|
||||
|
|
|
@ -216,3 +216,9 @@ export type SSTopSongList = {
|
|||
startIndex: number;
|
||||
totalRecordCount: number | null;
|
||||
};
|
||||
|
||||
export type SSScrobbleParams = {
|
||||
id: string;
|
||||
submission?: boolean;
|
||||
time?: number;
|
||||
};
|
||||
|
|
|
@ -295,6 +295,7 @@ export type MusicFoldersResponse = MusicFolder[];
|
|||
export type ListSortOrder = NDOrder | JFSortOrder;
|
||||
|
||||
type BaseEndpointArgs = {
|
||||
_serverId?: string;
|
||||
server: ServerListItem | null;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
@ -1014,3 +1015,17 @@ export type ArtistInfoQuery = {
|
|||
};
|
||||
|
||||
export type ArtistInfoArgs = { query: ArtistInfoQuery } & BaseEndpointArgs;
|
||||
|
||||
// Scrobble
|
||||
export type RawScrobbleResponse = null | undefined;
|
||||
|
||||
export type ScrobbleArgs = {
|
||||
query: ScrobbleQuery;
|
||||
} & BaseEndpointArgs;
|
||||
|
||||
export type ScrobbleQuery = {
|
||||
event?: 'pause' | 'unpause' | 'timeupdate' | 'start';
|
||||
id: string;
|
||||
position?: number;
|
||||
submission: boolean;
|
||||
};
|
||||
|
|
|
@ -10,6 +10,10 @@ const StyledSlider = styled(MantineSlider)`
|
|||
background-color: var(--slider-track-bg);
|
||||
}
|
||||
|
||||
& .mantine-Slider-bar {
|
||||
background-color: var(--primary-color);
|
||||
}
|
||||
|
||||
& .mantine-Slider-thumb {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
|
|
|
@ -181,6 +181,7 @@ export const LeftControls = () => {
|
|||
<MetadataStack layout="position">
|
||||
<LineItem>
|
||||
<Group
|
||||
noWrap
|
||||
align="flex-start"
|
||||
spacing="xs"
|
||||
>
|
||||
|
|
|
@ -12,6 +12,7 @@ import {
|
|||
useShuffleStatus,
|
||||
} from '/@/renderer/store';
|
||||
import { usePlayerType, useSettingsStore } from '/@/renderer/store/settings.store';
|
||||
import { useScrobble } from '/@/renderer/features/player/hooks/use-scrobble';
|
||||
|
||||
const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null;
|
||||
const mpvPlayerListener = isElectron() ? window.electron.mpvPlayerListener : null;
|
||||
|
@ -35,6 +36,8 @@ export const useCenterControls = (args: { playersRef: any }) => {
|
|||
const currentPlayerRef = currentPlayer === 1 ? player1Ref : player2Ref;
|
||||
const nextPlayerRef = currentPlayer === 1 ? player2Ref : player1Ref;
|
||||
|
||||
const { handleScrobbleFromSongRestart, handleScrobbleFromSeek } = useScrobble();
|
||||
|
||||
const resetPlayers = useCallback(() => {
|
||||
if (player1Ref.getInternalPlayer()) {
|
||||
player1Ref.getInternalPlayer().currentTime = 0;
|
||||
|
@ -289,6 +292,7 @@ export const useCenterControls = (args: { playersRef: any }) => {
|
|||
// Reset the current track more than 10 seconds have elapsed
|
||||
if (currentTime >= 10) {
|
||||
setCurrentTime(0);
|
||||
handleScrobbleFromSongRestart(currentTime);
|
||||
|
||||
if (isMpvPlayer) {
|
||||
return mpvPlayer.seekTo(0);
|
||||
|
@ -373,6 +377,7 @@ export const useCenterControls = (args: { playersRef: any }) => {
|
|||
}, [
|
||||
checkIsFirstTrack,
|
||||
currentPlayerRef,
|
||||
handleScrobbleFromSongRestart,
|
||||
isMpvPlayer,
|
||||
pause,
|
||||
playerType,
|
||||
|
@ -438,13 +443,15 @@ export const useCenterControls = (args: { playersRef: any }) => {
|
|||
(e: number | any) => {
|
||||
setCurrentTime(e);
|
||||
|
||||
handleScrobbleFromSeek(e);
|
||||
|
||||
if (isMpvPlayer) {
|
||||
mpvPlayer.seekTo(e);
|
||||
} else {
|
||||
currentPlayerRef.seekTo(e);
|
||||
}
|
||||
},
|
||||
[currentPlayerRef, isMpvPlayer, setCurrentTime],
|
||||
[currentPlayerRef, handleScrobbleFromSeek, isMpvPlayer, setCurrentTime],
|
||||
);
|
||||
|
||||
const handleQuit = useCallback(() => {
|
||||
|
|
|
@ -1,23 +1,319 @@
|
|||
import { useState } from 'react';
|
||||
import { usePlayerStore } from '/@/renderer/store';
|
||||
import { useEffect, useCallback, useState, useRef } from 'react';
|
||||
import { QueueSong, ServerType } from '/@/renderer/api/types';
|
||||
import { useSendScrobble } from '/@/renderer/features/player/mutations/scrobble-mutation';
|
||||
import { useCurrentStatus, usePlayerStore } from '/@/renderer/store';
|
||||
import { usePlayerSettings } from '/@/renderer/store/settings.store';
|
||||
import { PlayerStatus } from '/@/renderer/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
|
||||
*/
|
||||
|
||||
const checkScrobbleConditions = (args: {
|
||||
scrobbleAtDuration: number;
|
||||
scrobbleAtPercentage: number;
|
||||
songCompletedDuration: number;
|
||||
songDuration: number;
|
||||
}) => {
|
||||
const { scrobbleAtDuration, scrobbleAtPercentage, songCompletedDuration, songDuration } = args;
|
||||
const percentageOfSongCompleted = songDuration ? (songCompletedDuration / songDuration) * 100 : 0;
|
||||
|
||||
return (
|
||||
percentageOfSongCompleted >= scrobbleAtPercentage || songCompletedDuration >= scrobbleAtDuration
|
||||
);
|
||||
};
|
||||
|
||||
export const useScrobble = () => {
|
||||
const [isScrobbled, setIsScrobbled] = useState(false);
|
||||
const status = useCurrentStatus();
|
||||
const scrobbleSettings = usePlayerSettings().scrobble;
|
||||
const isScrobbleEnabled = scrobbleSettings?.enabled;
|
||||
const sendScrobble = useSendScrobble();
|
||||
|
||||
const currentSongDuration = usePlayerStore((state) => state.current.song?.duration);
|
||||
const [isCurrentSongScrobbled, setIsCurrentSongScrobbled] = useState(false);
|
||||
|
||||
const scrobbleAtPercentage = usePlayerStore((state) => state.settings.scrobbleAtPercentage);
|
||||
const handleScrobbleFromSeek = useCallback(
|
||||
(currentTime: number) => {
|
||||
if (!isScrobbleEnabled) return;
|
||||
|
||||
console.log('currentSongDuration', currentSongDuration);
|
||||
const currentSong = usePlayerStore.getState().current.song;
|
||||
|
||||
const scrobbleAtTime = (currentSongDuration * scrobbleAtPercentage) / 100;
|
||||
if (!currentSong?.id || currentSong?.serverType !== ServerType.JELLYFIN) return;
|
||||
|
||||
console.log('scrobbleAtTime', scrobbleAtTime);
|
||||
const position =
|
||||
currentSong?.serverType === ServerType.JELLYFIN ? currentTime * 1e7 : undefined;
|
||||
|
||||
console.log('render');
|
||||
const handleScrobble = () => {
|
||||
console.log('scrobble complete');
|
||||
sendScrobble.mutate({
|
||||
_serverId: currentSong?.serverId,
|
||||
query: {
|
||||
event: 'timeupdate',
|
||||
id: currentSong.id,
|
||||
position,
|
||||
submission: false,
|
||||
},
|
||||
});
|
||||
},
|
||||
[isScrobbleEnabled, sendScrobble],
|
||||
);
|
||||
|
||||
const progressIntervalId = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const songChangeTimeoutId = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const handleScrobbleFromSongChange = useCallback(
|
||||
(current: (QueueSong | number | undefined)[], previous: (QueueSong | number | undefined)[]) => {
|
||||
if (!isScrobbleEnabled) return;
|
||||
|
||||
if (progressIntervalId.current) {
|
||||
clearInterval(progressIntervalId.current);
|
||||
}
|
||||
|
||||
// const currentSong = current[0] as QueueSong | undefined;
|
||||
const previousSong = previous[0] as QueueSong;
|
||||
const previousSongTime = previous[1] as number;
|
||||
|
||||
// Send completion scrobble when song changes and a previous song exists
|
||||
if (previousSong?.id) {
|
||||
const shouldSubmitScrobble = checkScrobbleConditions({
|
||||
scrobbleAtDuration: scrobbleSettings?.scrobbleAtDuration,
|
||||
scrobbleAtPercentage: scrobbleSettings?.scrobbleAtPercentage,
|
||||
songCompletedDuration: previousSongTime,
|
||||
songDuration: previousSong.duration,
|
||||
});
|
||||
|
||||
if (
|
||||
(!isCurrentSongScrobbled && shouldSubmitScrobble) ||
|
||||
previousSong?.serverType === ServerType.JELLYFIN
|
||||
) {
|
||||
const position =
|
||||
previousSong?.serverType === ServerType.JELLYFIN ? previousSongTime * 1e7 : undefined;
|
||||
|
||||
sendScrobble.mutate({
|
||||
_serverId: previousSong?.serverId,
|
||||
query: {
|
||||
id: previousSong.id,
|
||||
position,
|
||||
submission: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setIsCurrentSongScrobbled(false);
|
||||
|
||||
// Use a timeout to prevent spamming the server with scrobble events when switching through songs quickly
|
||||
clearTimeout(songChangeTimeoutId.current as ReturnType<typeof setTimeout>);
|
||||
songChangeTimeoutId.current = setTimeout(() => {
|
||||
const currentSong = current[0] as QueueSong | undefined;
|
||||
|
||||
// Send start scrobble when song changes and the new song is playing
|
||||
if (status === PlayerStatus.PLAYING && currentSong?.id) {
|
||||
sendScrobble.mutate({
|
||||
_serverId: currentSong?.serverId,
|
||||
query: {
|
||||
event: 'start',
|
||||
id: currentSong.id,
|
||||
position: 0,
|
||||
submission: false,
|
||||
},
|
||||
});
|
||||
|
||||
if (currentSong?.serverType === ServerType.JELLYFIN) {
|
||||
progressIntervalId.current = setInterval(() => {
|
||||
const currentTime = usePlayerStore.getState().current.time;
|
||||
handleScrobbleFromSeek(currentTime);
|
||||
}, 10000);
|
||||
}
|
||||
}
|
||||
}, 2000);
|
||||
},
|
||||
[
|
||||
isScrobbleEnabled,
|
||||
scrobbleSettings?.scrobbleAtDuration,
|
||||
scrobbleSettings?.scrobbleAtPercentage,
|
||||
isCurrentSongScrobbled,
|
||||
sendScrobble,
|
||||
status,
|
||||
handleScrobbleFromSeek,
|
||||
],
|
||||
);
|
||||
|
||||
const handleScrobbleFromStatusChange = useCallback(
|
||||
(status: PlayerStatus | undefined) => {
|
||||
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;
|
||||
|
||||
// Whenever the player is restarted, send a 'start' scrobble
|
||||
if (status === PlayerStatus.PLAYING) {
|
||||
sendScrobble.mutate({
|
||||
_serverId: currentSong?.serverId,
|
||||
query: {
|
||||
event: 'unpause',
|
||||
id: currentSong.id,
|
||||
position,
|
||||
submission: false,
|
||||
},
|
||||
});
|
||||
|
||||
if (currentSong?.serverType === ServerType.JELLYFIN) {
|
||||
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) {
|
||||
sendScrobble.mutate({
|
||||
_serverId: currentSong?.serverId,
|
||||
query: {
|
||||
event: 'pause',
|
||||
id: currentSong.id,
|
||||
position,
|
||||
submission: false,
|
||||
},
|
||||
});
|
||||
|
||||
if (progressIntervalId.current) {
|
||||
clearInterval(progressIntervalId.current as ReturnType<typeof setInterval>);
|
||||
}
|
||||
} else {
|
||||
// If not already scrobbled, send a 'submission' scrobble if conditions are met
|
||||
const shouldSubmitScrobble = checkScrobbleConditions({
|
||||
scrobbleAtDuration: scrobbleSettings?.scrobbleAtDuration,
|
||||
scrobbleAtPercentage: scrobbleSettings?.scrobbleAtPercentage,
|
||||
songCompletedDuration: usePlayerStore.getState().current.time,
|
||||
songDuration: currentSong.duration,
|
||||
});
|
||||
|
||||
if (!isCurrentSongScrobbled && shouldSubmitScrobble) {
|
||||
sendScrobble.mutate({
|
||||
_serverId: currentSong?.serverId,
|
||||
query: {
|
||||
id: currentSong.id,
|
||||
submission: true,
|
||||
},
|
||||
});
|
||||
|
||||
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({
|
||||
scrobbleAtDuration: scrobbleSettings?.scrobbleAtDuration,
|
||||
scrobbleAtPercentage: scrobbleSettings?.scrobbleAtPercentage,
|
||||
songCompletedDuration: currentTime,
|
||||
songDuration: currentSong.duration,
|
||||
});
|
||||
|
||||
if (!isCurrentSongScrobbled && shouldSubmitScrobble) {
|
||||
sendScrobble.mutate({
|
||||
_serverId: currentSong?.serverId,
|
||||
query: {
|
||||
id: currentSong.id,
|
||||
position,
|
||||
submission: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (currentSong?.serverType === ServerType.JELLYFIN) {
|
||||
sendScrobble.mutate({
|
||||
_serverId: currentSong?.serverId,
|
||||
query: {
|
||||
event: 'start',
|
||||
id: currentSong.id,
|
||||
position: 0,
|
||||
submission: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
setIsCurrentSongScrobbled(false);
|
||||
},
|
||||
[
|
||||
isScrobbleEnabled,
|
||||
scrobbleSettings?.scrobbleAtDuration,
|
||||
scrobbleSettings?.scrobbleAtPercentage,
|
||||
isCurrentSongScrobbled,
|
||||
sendScrobble,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubSongChange = usePlayerStore.subscribe(
|
||||
(state) => [state.current.song, state.current.time],
|
||||
handleScrobbleFromSongChange,
|
||||
{
|
||||
// We need the current time to check the scrobble condition, but we only want to
|
||||
// trigger the callback when the song changes
|
||||
equalityFn: (a, b) => (a[0] as QueueSong)?.id === (b[0] as QueueSong)?.id,
|
||||
},
|
||||
);
|
||||
|
||||
const unsubStatusChange = usePlayerStore.subscribe(
|
||||
(state) => state.current.status,
|
||||
handleScrobbleFromStatusChange,
|
||||
);
|
||||
|
||||
return () => {
|
||||
unsubSongChange();
|
||||
unsubStatusChange();
|
||||
};
|
||||
}, [handleScrobbleFromSongChange, handleScrobbleFromStatusChange]);
|
||||
|
||||
return { handleScrobble, isScrobbled, setIsScrobbled };
|
||||
return { handleScrobbleFromSeek, handleScrobbleFromSongRestart };
|
||||
};
|
||||
|
|
25
src/renderer/features/player/mutations/scrobble-mutation.ts
Normal file
25
src/renderer/features/player/mutations/scrobble-mutation.ts
Normal file
|
@ -0,0 +1,25 @@
|
|||
import { useMutation } from '@tanstack/react-query';
|
||||
import { HTTPError } from 'ky';
|
||||
import { api } from '/@/renderer/api';
|
||||
import { RawScrobbleResponse, ScrobbleArgs } from '/@/renderer/api/types';
|
||||
import { MutationOptions } from '/@/renderer/lib/react-query';
|
||||
import { useAuthStore, useCurrentServer, useIncrementQueuePlayCount } from '/@/renderer/store';
|
||||
|
||||
export const useSendScrobble = (options?: MutationOptions) => {
|
||||
const currentServer = useCurrentServer();
|
||||
const incrementPlayCount = useIncrementQueuePlayCount();
|
||||
|
||||
return useMutation<RawScrobbleResponse, HTTPError, Omit<ScrobbleArgs, 'server'>, null>({
|
||||
mutationFn: (args) => {
|
||||
const server = useAuthStore.getState().actions.getServer(args._serverId) || currentServer;
|
||||
return api.controller.scrobble({ ...args, server });
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
// Manually increment the play count for the song in the queue if scrobble was submitted
|
||||
if (variables.query.submission) {
|
||||
incrementPlayCount([variables.query.id]);
|
||||
}
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
|
@ -263,6 +263,84 @@ export const PlaybackTab = () => {
|
|||
},
|
||||
];
|
||||
|
||||
const scrobbleOptions = [
|
||||
{
|
||||
control: (
|
||||
<Switch
|
||||
aria-label="Toggle scrobble"
|
||||
defaultChecked={settings.scrobble.enabled}
|
||||
onChange={(e) => {
|
||||
setSettings({
|
||||
player: {
|
||||
...settings,
|
||||
scrobble: {
|
||||
...settings.scrobble,
|
||||
enabled: e.currentTarget.checked,
|
||||
},
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
),
|
||||
description: 'Enable or disable scrobbling to your media server',
|
||||
isHidden: !isElectron(),
|
||||
title: 'Scrobble',
|
||||
},
|
||||
{
|
||||
control: (
|
||||
<Slider
|
||||
aria-label="Scrobble percentage"
|
||||
defaultValue={settings.scrobble.scrobbleAtPercentage}
|
||||
label={`${settings.scrobble.scrobbleAtPercentage}%`}
|
||||
max={90}
|
||||
min={25}
|
||||
w={100}
|
||||
onChange={(e) => {
|
||||
setSettings({
|
||||
player: {
|
||||
...settings,
|
||||
scrobble: {
|
||||
...settings.scrobble,
|
||||
scrobbleAtPercentage: e,
|
||||
},
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
),
|
||||
description: 'The percentage of the song that must be played before submitting a scrobble',
|
||||
isHidden: !isElectron(),
|
||||
title: 'Minimum scrobble percentage*',
|
||||
},
|
||||
{
|
||||
control: (
|
||||
<NumberInput
|
||||
aria-label="Scrobble duration in seconds"
|
||||
defaultValue={settings.scrobble.scrobbleAtDuration}
|
||||
max={1200}
|
||||
min={0}
|
||||
width={75}
|
||||
onChange={(e) => {
|
||||
if (e === '') return;
|
||||
setSettings({
|
||||
player: {
|
||||
...settings,
|
||||
scrobble: {
|
||||
...settings.scrobble,
|
||||
scrobbleAtDuration: e,
|
||||
},
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
),
|
||||
description:
|
||||
'The duration in seconds of a song that must be played before submitting a scrobble',
|
||||
isHidden: !isElectron(),
|
||||
title: 'Minimum scrobble duration (seconds)*',
|
||||
},
|
||||
];
|
||||
|
||||
const otherOptions = [
|
||||
{
|
||||
control: (
|
||||
|
@ -370,6 +448,22 @@ export const PlaybackTab = () => {
|
|||
/>
|
||||
))}
|
||||
<Divider />
|
||||
|
||||
{scrobbleOptions
|
||||
.filter((o) => !o.isHidden)
|
||||
.map((option) => (
|
||||
<SettingsOptions
|
||||
key={`'scrobble-${option.title}`}
|
||||
{...option}
|
||||
/>
|
||||
))}
|
||||
<Text
|
||||
$secondary
|
||||
size="xs"
|
||||
>
|
||||
*The scrobble will be submitted if one or more of the above conditions is met
|
||||
</Text>
|
||||
<Divider />
|
||||
{otherOptions
|
||||
.filter((o) => !o.isHidden)
|
||||
.map((option) => (
|
||||
|
|
|
@ -20,6 +20,7 @@ export interface AuthSlice extends AuthState {
|
|||
actions: {
|
||||
addServer: (args: ServerListItem) => void;
|
||||
deleteServer: (id: string) => void;
|
||||
getServer: (id?: string) => ServerListItem | undefined;
|
||||
setCurrentServer: (server: ServerListItem | null) => void;
|
||||
updateServer: (id: string, args: Partial<ServerListItem>) => void;
|
||||
};
|
||||
|
@ -28,7 +29,7 @@ export interface AuthSlice extends AuthState {
|
|||
export const useAuthStore = create<AuthSlice>()(
|
||||
persist(
|
||||
devtools(
|
||||
immer((set) => ({
|
||||
immer((set, get) => ({
|
||||
actions: {
|
||||
addServer: (args) => {
|
||||
set((state) => {
|
||||
|
@ -43,6 +44,9 @@ export const useAuthStore = create<AuthSlice>()(
|
|||
}
|
||||
});
|
||||
},
|
||||
getServer: (id) => {
|
||||
return get().serverList.find((server) => server.id === id);
|
||||
},
|
||||
setCurrentServer: (server) => {
|
||||
set((state) => {
|
||||
state.currentServer = server;
|
||||
|
|
|
@ -3,7 +3,7 @@ import merge from 'lodash/merge';
|
|||
import shuffle from 'lodash/shuffle';
|
||||
import { nanoid } from 'nanoid/non-secure';
|
||||
import create from 'zustand';
|
||||
import { devtools, persist } from 'zustand/middleware';
|
||||
import { devtools, persist, subscribeWithSelector } from 'zustand/middleware';
|
||||
import { immer } from 'zustand/middleware/immer';
|
||||
import shallow from 'zustand/shallow';
|
||||
import { QueueSong, Song } from '/@/renderer/api/types';
|
||||
|
@ -62,6 +62,7 @@ export interface PlayerSlice extends PlayerState {
|
|||
clearQueue: () => PlayerData;
|
||||
getPlayerData: () => PlayerData;
|
||||
getQueueData: () => QueueData;
|
||||
incrementPlayCount: (ids: string[]) => string[];
|
||||
moveToBottomOfQueue: (uniqueIds: string[]) => PlayerData;
|
||||
moveToTopOfQueue: (uniqueIds: string[]) => PlayerData;
|
||||
next: () => PlayerData;
|
||||
|
@ -88,6 +89,7 @@ export interface PlayerSlice extends PlayerState {
|
|||
}
|
||||
|
||||
export const usePlayerStore = create<PlayerSlice>()(
|
||||
subscribeWithSelector(
|
||||
persist(
|
||||
devtools(
|
||||
immer((set, get) => ({
|
||||
|
@ -388,6 +390,31 @@ export const usePlayerStore = create<PlayerSlice>()(
|
|||
previous: queue[get().current.index - 1],
|
||||
};
|
||||
},
|
||||
incrementPlayCount: (ids) => {
|
||||
const { default: queue } = get().queue;
|
||||
const foundUniqueIds = [];
|
||||
|
||||
for (const id of ids) {
|
||||
const foundIndex = queue.findIndex((song) => song.id === id);
|
||||
if (foundIndex !== -1) {
|
||||
foundUniqueIds.push(queue[foundIndex].uniqueId);
|
||||
set((state) => {
|
||||
state.queue.default[foundIndex].playCount += 1;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const currentSongId = get().current.song?.id;
|
||||
if (currentSongId && ids.includes(currentSongId)) {
|
||||
set((state) => {
|
||||
if (state.current.song) {
|
||||
state.current.song.playCount += 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return foundUniqueIds;
|
||||
},
|
||||
moveToBottomOfQueue: (uniqueIds) => {
|
||||
const queue = get().queue.default;
|
||||
|
||||
|
@ -514,7 +541,9 @@ export const usePlayerStore = create<PlayerSlice>()(
|
|||
} else {
|
||||
let prevIndex: number;
|
||||
if (repeat === PlayerRepeat.ALL) {
|
||||
prevIndex = isFirstTrack ? get().queue.default.length - 1 : get().current.index - 1;
|
||||
prevIndex = isFirstTrack
|
||||
? get().queue.default.length - 1
|
||||
: get().current.index - 1;
|
||||
} else {
|
||||
prevIndex = isFirstTrack ? 0 : get().current.index - 1;
|
||||
}
|
||||
|
@ -798,6 +827,7 @@ export const usePlayerStore = create<PlayerSlice>()(
|
|||
version: 1,
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
export const usePlayerStoreActions = () => usePlayerStore((state) => state.actions);
|
||||
|
@ -872,3 +902,6 @@ export const useMuted = () => usePlayerStore((state) => state.muted);
|
|||
export const useSetQueueFavorite = () => usePlayerStore((state) => state.actions.setFavorite);
|
||||
|
||||
export const useSetQueueRating = () => usePlayerStore((state) => state.actions.setRating);
|
||||
|
||||
export const useIncrementQueuePlayCount = () =>
|
||||
usePlayerStore((state) => state.actions.incrementPlayCount);
|
||||
|
|
|
@ -47,6 +47,7 @@ export interface SettingsState {
|
|||
playButtonBehavior: Play;
|
||||
scrobble: {
|
||||
enabled: boolean;
|
||||
scrobbleAtDuration: number;
|
||||
scrobbleAtPercentage: number;
|
||||
};
|
||||
skipButtons: {
|
||||
|
@ -98,7 +99,8 @@ export const useSettingsStore = create<SettingsSlice>()(
|
|||
muted: false,
|
||||
playButtonBehavior: Play.NOW,
|
||||
scrobble: {
|
||||
enabled: false,
|
||||
enabled: true,
|
||||
scrobbleAtDuration: 240,
|
||||
scrobbleAtPercentage: 75,
|
||||
},
|
||||
skipButtons: {
|
||||
|
@ -220,7 +222,7 @@ export const useSettingsStore = create<SettingsSlice>()(
|
|||
return merge(currentState, persistedState);
|
||||
},
|
||||
name: 'store_settings',
|
||||
version: 1,
|
||||
version: 2,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
Reference in a new issue