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:
Jeff 2023-01-30 20:01:57 -08:00 committed by GitHub
parent 85bf910d65
commit 484c96187c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 1253 additions and 653 deletions

View file

@ -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,
};

View file

@ -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,
};

View file

@ -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,
};

View file

@ -216,3 +216,9 @@ export type SSTopSongList = {
startIndex: number;
totalRecordCount: number | null;
};
export type SSScrobbleParams = {
id: string;
submission?: boolean;
time?: number;
};

View file

@ -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;
};