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,
|
RawAddToPlaylistResponse,
|
||||||
RemoveFromPlaylistArgs,
|
RemoveFromPlaylistArgs,
|
||||||
RawRemoveFromPlaylistResponse,
|
RawRemoveFromPlaylistResponse,
|
||||||
|
ScrobbleArgs,
|
||||||
|
RawScrobbleResponse,
|
||||||
} from '/@/renderer/api/types';
|
} from '/@/renderer/api/types';
|
||||||
import { subsonicApi } from '/@/renderer/api/subsonic.api';
|
import { subsonicApi } from '/@/renderer/api/subsonic.api';
|
||||||
import { jellyfinApi } from '/@/renderer/api/jellyfin.api';
|
import { jellyfinApi } from '/@/renderer/api/jellyfin.api';
|
||||||
|
import { ServerListItem } from '/@/renderer/types';
|
||||||
|
|
||||||
export type ControllerEndpoint = Partial<{
|
export type ControllerEndpoint = Partial<{
|
||||||
addToPlaylist: (args: AddToPlaylistArgs) => Promise<RawAddToPlaylistResponse>;
|
addToPlaylist: (args: AddToPlaylistArgs) => Promise<RawAddToPlaylistResponse>;
|
||||||
|
@ -75,6 +78,7 @@ export type ControllerEndpoint = Partial<{
|
||||||
getTopSongs: (args: TopSongListArgs) => Promise<RawTopSongListResponse>;
|
getTopSongs: (args: TopSongListArgs) => Promise<RawTopSongListResponse>;
|
||||||
getUserList: (args: UserListArgs) => Promise<RawUserListResponse>;
|
getUserList: (args: UserListArgs) => Promise<RawUserListResponse>;
|
||||||
removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise<RawRemoveFromPlaylistResponse>;
|
removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise<RawRemoveFromPlaylistResponse>;
|
||||||
|
scrobble: (args: ScrobbleArgs) => Promise<RawScrobbleResponse>;
|
||||||
updatePlaylist: (args: UpdatePlaylistArgs) => Promise<RawUpdatePlaylistResponse>;
|
updatePlaylist: (args: UpdatePlaylistArgs) => Promise<RawUpdatePlaylistResponse>;
|
||||||
updateRating: (args: RatingArgs) => Promise<RawRatingResponse>;
|
updateRating: (args: RatingArgs) => Promise<RawRatingResponse>;
|
||||||
}>;
|
}>;
|
||||||
|
@ -114,6 +118,7 @@ const endpoints: ApiController = {
|
||||||
getTopSongs: undefined,
|
getTopSongs: undefined,
|
||||||
getUserList: undefined,
|
getUserList: undefined,
|
||||||
removeFromPlaylist: jellyfinApi.removeFromPlaylist,
|
removeFromPlaylist: jellyfinApi.removeFromPlaylist,
|
||||||
|
scrobble: jellyfinApi.scrobble,
|
||||||
updatePlaylist: jellyfinApi.updatePlaylist,
|
updatePlaylist: jellyfinApi.updatePlaylist,
|
||||||
updateRating: undefined,
|
updateRating: undefined,
|
||||||
},
|
},
|
||||||
|
@ -145,6 +150,7 @@ const endpoints: ApiController = {
|
||||||
getTopSongs: subsonicApi.getTopSongList,
|
getTopSongs: subsonicApi.getTopSongList,
|
||||||
getUserList: navidromeApi.getUserList,
|
getUserList: navidromeApi.getUserList,
|
||||||
removeFromPlaylist: navidromeApi.removeFromPlaylist,
|
removeFromPlaylist: navidromeApi.removeFromPlaylist,
|
||||||
|
scrobble: subsonicApi.scrobble,
|
||||||
updatePlaylist: navidromeApi.updatePlaylist,
|
updatePlaylist: navidromeApi.updatePlaylist,
|
||||||
updateRating: subsonicApi.updateRating,
|
updateRating: subsonicApi.updateRating,
|
||||||
},
|
},
|
||||||
|
@ -173,13 +179,14 @@ const endpoints: ApiController = {
|
||||||
getSongList: undefined,
|
getSongList: undefined,
|
||||||
getTopSongs: subsonicApi.getTopSongList,
|
getTopSongs: subsonicApi.getTopSongList,
|
||||||
getUserList: undefined,
|
getUserList: undefined,
|
||||||
|
scrobble: subsonicApi.scrobble,
|
||||||
updatePlaylist: undefined,
|
updatePlaylist: undefined,
|
||||||
updateRating: undefined,
|
updateRating: undefined,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const apiController = (endpoint: keyof ControllerEndpoint) => {
|
const apiController = (endpoint: keyof ControllerEndpoint, server?: ServerListItem | null) => {
|
||||||
const serverType = useAuthStore.getState().currentServer?.type;
|
const serverType = server?.type || useAuthStore.getState().currentServer?.type;
|
||||||
|
|
||||||
if (!serverType) {
|
if (!serverType) {
|
||||||
toast.error({ message: 'No server selected', title: 'Unable to route request' });
|
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);
|
return (apiController('getTopSongs') as ControllerEndpoint['getTopSongs'])?.(args);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const scrobble = async (args: ScrobbleArgs) => {
|
||||||
|
return (apiController('scrobble', args.server) as ControllerEndpoint['scrobble'])?.(args);
|
||||||
|
};
|
||||||
|
|
||||||
export const controller = {
|
export const controller = {
|
||||||
addToPlaylist,
|
addToPlaylist,
|
||||||
createFavorite,
|
createFavorite,
|
||||||
|
@ -307,6 +318,7 @@ export const controller = {
|
||||||
getTopSongList,
|
getTopSongList,
|
||||||
getUserList,
|
getUserList,
|
||||||
removeFromPlaylist,
|
removeFromPlaylist,
|
||||||
|
scrobble,
|
||||||
updatePlaylist,
|
updatePlaylist,
|
||||||
updateRating,
|
updateRating,
|
||||||
};
|
};
|
||||||
|
|
|
@ -70,10 +70,13 @@ import {
|
||||||
LibraryItem,
|
LibraryItem,
|
||||||
RemoveFromPlaylistArgs,
|
RemoveFromPlaylistArgs,
|
||||||
AddToPlaylistArgs,
|
AddToPlaylistArgs,
|
||||||
|
ScrobbleArgs,
|
||||||
|
RawScrobbleResponse,
|
||||||
} from '/@/renderer/api/types';
|
} from '/@/renderer/api/types';
|
||||||
import { useAuthStore } from '/@/renderer/store';
|
import { useAuthStore } from '/@/renderer/store';
|
||||||
import { ServerListItem, ServerType } from '/@/renderer/types';
|
import { ServerListItem, ServerType } from '/@/renderer/types';
|
||||||
import { parseSearchParams } from '/@/renderer/utils';
|
import { parseSearchParams } from '/@/renderer/utils';
|
||||||
|
import packageJson from '../../../package.json';
|
||||||
|
|
||||||
const getCommaDelimitedString = (value: string[]) => {
|
const getCommaDelimitedString = (value: string[]) => {
|
||||||
return value.join(',');
|
return value.join(',');
|
||||||
|
@ -93,8 +96,7 @@ const authenticate = async (
|
||||||
const data = await ky
|
const data = await ky
|
||||||
.post(`${cleanServerUrl}/users/authenticatebyname`, {
|
.post(`${cleanServerUrl}/users/authenticatebyname`, {
|
||||||
headers: {
|
headers: {
|
||||||
'X-Emby-Authorization':
|
'X-Emby-Authorization': `MediaBrowser Client="Feishin", Device="PC", DeviceId="Feishin", Version="${packageJson.version}"`,
|
||||||
'MediaBrowser Client="Feishin", Device="PC", DeviceId="Feishin", Version="0.0.1"',
|
|
||||||
},
|
},
|
||||||
json: {
|
json: {
|
||||||
pw: body.password,
|
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: {
|
const getStreamUrl = (args: {
|
||||||
container?: string;
|
container?: string;
|
||||||
deviceId: string;
|
deviceId: string;
|
||||||
|
@ -927,6 +1004,7 @@ export const jellyfinApi = {
|
||||||
getPlaylistSongList,
|
getPlaylistSongList,
|
||||||
getSongList,
|
getSongList,
|
||||||
removeFromPlaylist,
|
removeFromPlaylist,
|
||||||
|
scrobble,
|
||||||
updatePlaylist,
|
updatePlaylist,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -26,6 +26,7 @@ import type {
|
||||||
SSArtistInfo,
|
SSArtistInfo,
|
||||||
SSSong,
|
SSSong,
|
||||||
SSTopSongList,
|
SSTopSongList,
|
||||||
|
SSScrobbleParams,
|
||||||
} from '/@/renderer/api/subsonic.types';
|
} from '/@/renderer/api/subsonic.types';
|
||||||
import {
|
import {
|
||||||
AlbumArtistDetailArgs,
|
AlbumArtistDetailArgs,
|
||||||
|
@ -42,6 +43,8 @@ import {
|
||||||
QueueSong,
|
QueueSong,
|
||||||
RatingArgs,
|
RatingArgs,
|
||||||
RatingResponse,
|
RatingResponse,
|
||||||
|
RawScrobbleResponse,
|
||||||
|
ScrobbleArgs,
|
||||||
ServerListItem,
|
ServerListItem,
|
||||||
ServerType,
|
ServerType,
|
||||||
TopSongListArgs,
|
TopSongListArgs,
|
||||||
|
@ -386,6 +389,25 @@ const getArtistInfo = async (args: ArtistInfoArgs): Promise<SSArtistInfo> => {
|
||||||
return data.artistInfo2;
|
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 normalizeSong = (item: SSSong, server: ServerListItem, deviceId: string): QueueSong => {
|
||||||
const imageUrl =
|
const imageUrl =
|
||||||
getCoverArtUrl({
|
getCoverArtUrl({
|
||||||
|
@ -465,6 +487,7 @@ export const subsonicApi = {
|
||||||
getGenreList,
|
getGenreList,
|
||||||
getMusicFolderList,
|
getMusicFolderList,
|
||||||
getTopSongList,
|
getTopSongList,
|
||||||
|
scrobble,
|
||||||
updateRating,
|
updateRating,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -216,3 +216,9 @@ export type SSTopSongList = {
|
||||||
startIndex: number;
|
startIndex: number;
|
||||||
totalRecordCount: number | null;
|
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;
|
export type ListSortOrder = NDOrder | JFSortOrder;
|
||||||
|
|
||||||
type BaseEndpointArgs = {
|
type BaseEndpointArgs = {
|
||||||
|
_serverId?: string;
|
||||||
server: ServerListItem | null;
|
server: ServerListItem | null;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
};
|
};
|
||||||
|
@ -1014,3 +1015,17 @@ export type ArtistInfoQuery = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ArtistInfoArgs = { query: ArtistInfoQuery } & BaseEndpointArgs;
|
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);
|
background-color: var(--slider-track-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
& .mantine-Slider-bar {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
& .mantine-Slider-thumb {
|
& .mantine-Slider-thumb {
|
||||||
width: 1rem;
|
width: 1rem;
|
||||||
height: 1rem;
|
height: 1rem;
|
||||||
|
|
|
@ -181,6 +181,7 @@ export const LeftControls = () => {
|
||||||
<MetadataStack layout="position">
|
<MetadataStack layout="position">
|
||||||
<LineItem>
|
<LineItem>
|
||||||
<Group
|
<Group
|
||||||
|
noWrap
|
||||||
align="flex-start"
|
align="flex-start"
|
||||||
spacing="xs"
|
spacing="xs"
|
||||||
>
|
>
|
||||||
|
|
|
@ -12,6 +12,7 @@ import {
|
||||||
useShuffleStatus,
|
useShuffleStatus,
|
||||||
} from '/@/renderer/store';
|
} from '/@/renderer/store';
|
||||||
import { usePlayerType, useSettingsStore } from '/@/renderer/store/settings.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 mpvPlayer = isElectron() ? window.electron.mpvPlayer : null;
|
||||||
const mpvPlayerListener = isElectron() ? window.electron.mpvPlayerListener : 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 currentPlayerRef = currentPlayer === 1 ? player1Ref : player2Ref;
|
||||||
const nextPlayerRef = currentPlayer === 1 ? player2Ref : player1Ref;
|
const nextPlayerRef = currentPlayer === 1 ? player2Ref : player1Ref;
|
||||||
|
|
||||||
|
const { handleScrobbleFromSongRestart, handleScrobbleFromSeek } = useScrobble();
|
||||||
|
|
||||||
const resetPlayers = useCallback(() => {
|
const resetPlayers = useCallback(() => {
|
||||||
if (player1Ref.getInternalPlayer()) {
|
if (player1Ref.getInternalPlayer()) {
|
||||||
player1Ref.getInternalPlayer().currentTime = 0;
|
player1Ref.getInternalPlayer().currentTime = 0;
|
||||||
|
@ -289,6 +292,7 @@ export const useCenterControls = (args: { playersRef: any }) => {
|
||||||
// Reset the current track more than 10 seconds have elapsed
|
// Reset the current track more than 10 seconds have elapsed
|
||||||
if (currentTime >= 10) {
|
if (currentTime >= 10) {
|
||||||
setCurrentTime(0);
|
setCurrentTime(0);
|
||||||
|
handleScrobbleFromSongRestart(currentTime);
|
||||||
|
|
||||||
if (isMpvPlayer) {
|
if (isMpvPlayer) {
|
||||||
return mpvPlayer.seekTo(0);
|
return mpvPlayer.seekTo(0);
|
||||||
|
@ -373,6 +377,7 @@ export const useCenterControls = (args: { playersRef: any }) => {
|
||||||
}, [
|
}, [
|
||||||
checkIsFirstTrack,
|
checkIsFirstTrack,
|
||||||
currentPlayerRef,
|
currentPlayerRef,
|
||||||
|
handleScrobbleFromSongRestart,
|
||||||
isMpvPlayer,
|
isMpvPlayer,
|
||||||
pause,
|
pause,
|
||||||
playerType,
|
playerType,
|
||||||
|
@ -438,13 +443,15 @@ export const useCenterControls = (args: { playersRef: any }) => {
|
||||||
(e: number | any) => {
|
(e: number | any) => {
|
||||||
setCurrentTime(e);
|
setCurrentTime(e);
|
||||||
|
|
||||||
|
handleScrobbleFromSeek(e);
|
||||||
|
|
||||||
if (isMpvPlayer) {
|
if (isMpvPlayer) {
|
||||||
mpvPlayer.seekTo(e);
|
mpvPlayer.seekTo(e);
|
||||||
} else {
|
} else {
|
||||||
currentPlayerRef.seekTo(e);
|
currentPlayerRef.seekTo(e);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[currentPlayerRef, isMpvPlayer, setCurrentTime],
|
[currentPlayerRef, handleScrobbleFromSeek, isMpvPlayer, setCurrentTime],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleQuit = useCallback(() => {
|
const handleQuit = useCallback(() => {
|
||||||
|
|
|
@ -1,23 +1,319 @@
|
||||||
import { useState } from 'react';
|
import { useEffect, useCallback, useState, useRef } from 'react';
|
||||||
import { usePlayerStore } from '/@/renderer/store';
|
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 = () => {
|
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');
|
sendScrobble.mutate({
|
||||||
const handleScrobble = () => {
|
_serverId: currentSong?.serverId,
|
||||||
console.log('scrobble complete');
|
query: {
|
||||||
};
|
event: 'timeupdate',
|
||||||
|
id: currentSong.id,
|
||||||
|
position,
|
||||||
|
submission: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[isScrobbleEnabled, sendScrobble],
|
||||||
|
);
|
||||||
|
|
||||||
return { handleScrobble, isScrobbled, setIsScrobbled };
|
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 { 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 = [
|
const otherOptions = [
|
||||||
{
|
{
|
||||||
control: (
|
control: (
|
||||||
|
@ -370,6 +448,22 @@ export const PlaybackTab = () => {
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<Divider />
|
<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
|
{otherOptions
|
||||||
.filter((o) => !o.isHidden)
|
.filter((o) => !o.isHidden)
|
||||||
.map((option) => (
|
.map((option) => (
|
||||||
|
|
|
@ -20,6 +20,7 @@ export interface AuthSlice extends AuthState {
|
||||||
actions: {
|
actions: {
|
||||||
addServer: (args: ServerListItem) => void;
|
addServer: (args: ServerListItem) => void;
|
||||||
deleteServer: (id: string) => void;
|
deleteServer: (id: string) => void;
|
||||||
|
getServer: (id?: string) => ServerListItem | undefined;
|
||||||
setCurrentServer: (server: ServerListItem | null) => void;
|
setCurrentServer: (server: ServerListItem | null) => void;
|
||||||
updateServer: (id: string, args: Partial<ServerListItem>) => void;
|
updateServer: (id: string, args: Partial<ServerListItem>) => void;
|
||||||
};
|
};
|
||||||
|
@ -28,7 +29,7 @@ export interface AuthSlice extends AuthState {
|
||||||
export const useAuthStore = create<AuthSlice>()(
|
export const useAuthStore = create<AuthSlice>()(
|
||||||
persist(
|
persist(
|
||||||
devtools(
|
devtools(
|
||||||
immer((set) => ({
|
immer((set, get) => ({
|
||||||
actions: {
|
actions: {
|
||||||
addServer: (args) => {
|
addServer: (args) => {
|
||||||
set((state) => {
|
set((state) => {
|
||||||
|
@ -43,6 +44,9 @@ export const useAuthStore = create<AuthSlice>()(
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
getServer: (id) => {
|
||||||
|
return get().serverList.find((server) => server.id === id);
|
||||||
|
},
|
||||||
setCurrentServer: (server) => {
|
setCurrentServer: (server) => {
|
||||||
set((state) => {
|
set((state) => {
|
||||||
state.currentServer = server;
|
state.currentServer = server;
|
||||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -47,6 +47,7 @@ export interface SettingsState {
|
||||||
playButtonBehavior: Play;
|
playButtonBehavior: Play;
|
||||||
scrobble: {
|
scrobble: {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
scrobbleAtDuration: number;
|
||||||
scrobbleAtPercentage: number;
|
scrobbleAtPercentage: number;
|
||||||
};
|
};
|
||||||
skipButtons: {
|
skipButtons: {
|
||||||
|
@ -98,7 +99,8 @@ export const useSettingsStore = create<SettingsSlice>()(
|
||||||
muted: false,
|
muted: false,
|
||||||
playButtonBehavior: Play.NOW,
|
playButtonBehavior: Play.NOW,
|
||||||
scrobble: {
|
scrobble: {
|
||||||
enabled: false,
|
enabled: true,
|
||||||
|
scrobbleAtDuration: 240,
|
||||||
scrobbleAtPercentage: 75,
|
scrobbleAtPercentage: 75,
|
||||||
},
|
},
|
||||||
skipButtons: {
|
skipButtons: {
|
||||||
|
@ -220,7 +222,7 @@ export const useSettingsStore = create<SettingsSlice>()(
|
||||||
return merge(currentState, persistedState);
|
return merge(currentState, persistedState);
|
||||||
},
|
},
|
||||||
name: 'store_settings',
|
name: 'store_settings',
|
||||||
version: 1,
|
version: 2,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
Reference in a new issue