add jellyfin, improvements

This commit is contained in:
Kendall Garner 2023-06-02 23:54:34 -07:00 committed by Jeff
parent 85d2576bdc
commit 58f38b2655
11 changed files with 168 additions and 17 deletions

View file

@ -46,6 +46,8 @@ import type {
AuthenticationResponse, AuthenticationResponse,
SearchArgs, SearchArgs,
SearchResponse, SearchResponse,
LyricsArgs,
SynchronizedLyricsArray,
} from '/@/renderer/api/types'; } from '/@/renderer/api/types';
import { ServerType } from '/@/renderer/types'; import { ServerType } from '/@/renderer/types';
import { DeletePlaylistResponse, RandomSongListArgs } from './types'; import { DeletePlaylistResponse, RandomSongListArgs } from './types';
@ -76,6 +78,7 @@ export type ControllerEndpoint = Partial<{
getFolderList: () => void; getFolderList: () => void;
getFolderSongs: () => void; getFolderSongs: () => void;
getGenreList: (args: GenreListArgs) => Promise<GenreListResponse>; getGenreList: (args: GenreListArgs) => Promise<GenreListResponse>;
getLyrics: (args: LyricsArgs) => Promise<SynchronizedLyricsArray>;
getMusicFolderList: (args: MusicFolderListArgs) => Promise<MusicFolderListResponse>; getMusicFolderList: (args: MusicFolderListArgs) => Promise<MusicFolderListResponse>;
getPlaylistDetail: (args: PlaylistDetailArgs) => Promise<PlaylistDetailResponse>; getPlaylistDetail: (args: PlaylistDetailArgs) => Promise<PlaylistDetailResponse>;
getPlaylistList: (args: PlaylistListArgs) => Promise<PlaylistListResponse>; getPlaylistList: (args: PlaylistListArgs) => Promise<PlaylistListResponse>;
@ -119,6 +122,7 @@ const endpoints: ApiController = {
getFolderList: undefined, getFolderList: undefined,
getFolderSongs: undefined, getFolderSongs: undefined,
getGenreList: jfController.getGenreList, getGenreList: jfController.getGenreList,
getLyrics: jfController.getLyrics,
getMusicFolderList: jfController.getMusicFolderList, getMusicFolderList: jfController.getMusicFolderList,
getPlaylistDetail: jfController.getPlaylistDetail, getPlaylistDetail: jfController.getPlaylistDetail,
getPlaylistList: jfController.getPlaylistList, getPlaylistList: jfController.getPlaylistList,
@ -154,6 +158,7 @@ const endpoints: ApiController = {
getFolderList: undefined, getFolderList: undefined,
getFolderSongs: undefined, getFolderSongs: undefined,
getGenreList: ndController.getGenreList, getGenreList: ndController.getGenreList,
getLyrics: undefined,
getMusicFolderList: ssController.getMusicFolderList, getMusicFolderList: ssController.getMusicFolderList,
getPlaylistDetail: ndController.getPlaylistDetail, getPlaylistDetail: ndController.getPlaylistDetail,
getPlaylistList: ndController.getPlaylistList, getPlaylistList: ndController.getPlaylistList,
@ -188,6 +193,7 @@ const endpoints: ApiController = {
getFolderList: undefined, getFolderList: undefined,
getFolderSongs: undefined, getFolderSongs: undefined,
getGenreList: undefined, getGenreList: undefined,
getLyrics: undefined,
getMusicFolderList: ssController.getMusicFolderList, getMusicFolderList: ssController.getMusicFolderList,
getPlaylistDetail: undefined, getPlaylistDetail: undefined,
getPlaylistList: undefined, getPlaylistList: undefined,
@ -448,6 +454,12 @@ const getRandomSongList = async (args: RandomSongListArgs) => {
)?.(args); )?.(args);
}; };
const getLyrics = async (args: LyricsArgs) => {
return (
apiController('getLyrics', args.apiClientProps.server?.type) as ControllerEndpoint['getLyrics']
)?.(args);
};
export const controller = { export const controller = {
addToPlaylist, addToPlaylist,
authenticate, authenticate,
@ -461,6 +473,7 @@ export const controller = {
getAlbumList, getAlbumList,
getArtistList, getArtistList,
getGenreList, getGenreList,
getLyrics,
getMusicFolderList, getMusicFolderList,
getPlaylistDetail, getPlaylistDetail,
getPlaylistList, getPlaylistList,

View file

@ -174,6 +174,14 @@ export const contract = c.router({
400: jfType._response.error, 400: jfType._response.error,
}, },
}, },
getSongLyrics: {
method: 'GET',
path: 'users/:userId/Items/:id/Lyrics',
responses: {
200: jfType._response.lyrics,
404: jfType._response.error,
},
},
getTopSongsList: { getTopSongsList: {
method: 'GET', method: 'GET',
path: 'users/:userId/items', path: 'users/:userId/items',

View file

@ -44,6 +44,8 @@ import {
SearchResponse, SearchResponse,
RandomSongListResponse, RandomSongListResponse,
RandomSongListArgs, RandomSongListArgs,
LyricsArgs,
SynchronizedLyricsArray,
} from '/@/renderer/api/types'; } from '/@/renderer/api/types';
import { jfApiClient } from '/@/renderer/api/jellyfin/jellyfin-api'; import { jfApiClient } from '/@/renderer/api/jellyfin/jellyfin-api';
import { jfNormalize } from './jellyfin-normalize'; import { jfNormalize } from './jellyfin-normalize';
@ -846,6 +848,28 @@ const getRandomSongList = async (args: RandomSongListArgs): Promise<RandomSongLi
totalRecordCount: res.body.Items.length || 0, totalRecordCount: res.body.Items.length || 0,
}; };
}; };
const getLyrics = async (args: LyricsArgs): Promise<SynchronizedLyricsArray> => {
const { query, apiClientProps } = args;
if (!apiClientProps.server?.userId) {
throw new Error('No userId found');
}
const res = await jfApiClient(apiClientProps).getSongLyrics({
params: {
id: query.songId,
userId: apiClientProps.server?.userId,
},
});
if (res.status !== 200) {
throw new Error('Failed to get lyrics');
}
return res.body.Lyrics.map((lyric) => [lyric.Start / 1e4, lyric.Text]);
};
export const jfController = { export const jfController = {
addToPlaylist, addToPlaylist,
authenticate, authenticate,
@ -859,6 +883,7 @@ export const jfController = {
getAlbumList, getAlbumList,
getArtistList, getArtistList,
getGenreList, getGenreList,
getLyrics,
getMusicFolderList, getMusicFolderList,
getPlaylistDetail, getPlaylistDetail,
getPlaylistList, getPlaylistList,

View file

@ -631,6 +631,15 @@ const searchParameters = paginationParameters.merge(baseParameters);
const search = z.any(); const search = z.any();
const lyricText = z.object({
Start: z.number(),
Text: z.string(),
});
const lyrics = z.object({
Lyrics: z.array(lyricText),
});
export const jfType = { export const jfType = {
_enum: { _enum: {
collection: jfCollection, collection: jfCollection,
@ -670,6 +679,7 @@ export const jfType = {
favorite, favorite,
genre, genre,
genreList, genreList,
lyrics,
musicFolderList, musicFolderList,
playlist, playlist,
playlistList, playlistList,

View file

@ -14,6 +14,7 @@ import type {
SearchQuery, SearchQuery,
SongDetailQuery, SongDetailQuery,
RandomSongListQuery, RandomSongListQuery,
LyricsQuery,
} from './types'; } from './types';
export const queryKeys: Record< export const queryKeys: Record<
@ -102,6 +103,10 @@ export const queryKeys: Record<
if (query) return [serverId, 'songs', 'list', query] as const; if (query) return [serverId, 'songs', 'list', query] as const;
return [serverId, 'songs', 'list'] as const; return [serverId, 'songs', 'list'] as const;
}, },
lyrics: (serverId: string, query?: LyricsQuery) => {
if (query) return [serverId, 'song', 'lyrics', query] as const;
return [serverId, 'song', 'lyrics'] as const;
},
randomSongList: (serverId: string, query?: RandomSongListQuery) => { randomSongList: (serverId: string, query?: RandomSongListQuery) => {
if (query) return [serverId, 'songs', 'randomSongList', query] as const; if (query) return [serverId, 'songs', 'randomSongList', query] as const;
return [serverId, 'songs', 'randomSongList'] as const; return [serverId, 'songs', 'randomSongList'] as const;

View file

@ -1017,6 +1017,16 @@ export type RandomSongListArgs = {
export type RandomSongListResponse = SongListResponse; export type RandomSongListResponse = SongListResponse;
export type LyricsQuery = {
songId: string;
};
export type LyricsArgs = {
query: LyricsQuery;
} & BaseEndpointArgs;
export type SynchronizedLyricsArray = Array<[number, string]>;
export const instanceOfCancellationError = (error: any) => { export const instanceOfCancellationError = (error: any) => {
return 'revert' in error; return 'revert' in error;
}; };

View file

@ -3,9 +3,14 @@ import isElectron from 'is-electron';
import { ErrorBoundary } from 'react-error-boundary'; import { ErrorBoundary } from 'react-error-boundary';
import { ErrorFallback } from '/@/renderer/features/action-required'; import { ErrorFallback } from '/@/renderer/features/action-required';
import { useCurrentServer, useCurrentSong } from '/@/renderer/store'; import { useCurrentServer, useCurrentSong } from '/@/renderer/store';
import { SynchronizedLyricsArray, SynchronizedLyrics } from './synchronized-lyrics'; import { SynchronizedLyrics } from './synchronized-lyrics';
import { UnsynchronizedLyrics } from '/@/renderer/features/lyrics/unsynchronized-lyrics'; import { UnsynchronizedLyrics } from '/@/renderer/features/lyrics/unsynchronized-lyrics';
import { LyricLine } from '/@/renderer/features/lyrics/lyric-line'; import { LyricLine } from '/@/renderer/features/lyrics/lyric-line';
import { Center, Group } from '@mantine/core';
import { RiInformationFill } from 'react-icons/ri';
import { TextTitle } from '/@/renderer/components';
import { SynchronizedLyricsArray } from '/@/renderer/api/types';
import { useSongLyrics } from '/@/renderer/features/lyrics/queries/lyric-query';
const lyrics = isElectron() ? window.electron.lyrics : null; const lyrics = isElectron() ? window.electron.lyrics : null;
@ -22,6 +27,11 @@ export const Lyrics = () => {
const [source, setSource] = useState<string | null>(null); const [source, setSource] = useState<string | null>(null);
const [songLyrics, setSongLyrics] = useState<SynchronizedLyricsArray | string | null>(null); const [songLyrics, setSongLyrics] = useState<SynchronizedLyricsArray | string | null>(null);
const remoteLyrics = useSongLyrics({
query: { songId: currentSong?.id ?? '' },
serverId: currentServer?.id,
});
const songRef = useRef<string | null>(null); const songRef = useRef<string | null>(null);
useEffect(() => { useEffect(() => {
@ -38,7 +48,7 @@ export const Lyrics = () => {
}, []); }, []);
useEffect(() => { useEffect(() => {
if (currentSong && !currentSong.lyrics) { if (currentSong && !currentSong.lyrics && !remoteLyrics.isLoading && !remoteLyrics.isSuccess) {
lyrics?.fetchLyrics(currentSong); lyrics?.fetchLyrics(currentSong);
} }
@ -46,7 +56,7 @@ export const Lyrics = () => {
setOverride(null); setOverride(null);
setSource(null); setSource(null);
}, [currentSong]); }, [currentSong, remoteLyrics.isLoading, remoteLyrics.isSuccess]);
useEffect(() => { useEffect(() => {
let lyrics: string | null = null; let lyrics: string | null = null;
@ -57,6 +67,10 @@ export const Lyrics = () => {
setSource(currentServer?.name ?? 'music server'); setSource(currentServer?.name ?? 'music server');
} else if (override) { } else if (override) {
lyrics = override; lyrics = override;
} else if (remoteLyrics.isSuccess) {
setSource(currentServer?.name ?? 'music server');
setSongLyrics(remoteLyrics.data!);
return;
} }
if (lyrics) { if (lyrics) {
@ -82,16 +96,23 @@ export const Lyrics = () => {
} else { } else {
setSongLyrics(null); setSongLyrics(null);
} }
}, [currentServer?.name, currentSong, override]); }, [currentServer?.name, currentSong, override, remoteLyrics.data, remoteLyrics.isSuccess]);
return ( return (
<ErrorBoundary FallbackComponent={ErrorFallback}> <ErrorBoundary FallbackComponent={ErrorFallback}>
{songLyrics && {!songLyrics && (
(Array.isArray(songLyrics) ? ( <Center>
<SynchronizedLyrics lyrics={songLyrics} /> <Group>
) : ( <RiInformationFill size="2rem" />
<UnsynchronizedLyrics lyrics={songLyrics} /> <TextTitle
))} order={3}
weight={700}
>
No lyrics found
</TextTitle>
</Group>
</Center>
)}
{source && ( {source && (
<LyricLine <LyricLine
key="provided-by" key="provided-by"
@ -99,6 +120,12 @@ export const Lyrics = () => {
text={`Provided by: ${source}`} text={`Provided by: ${source}`}
/> />
)} )}
{songLyrics &&
(Array.isArray(songLyrics) ? (
<SynchronizedLyrics lyrics={songLyrics} />
) : (
<UnsynchronizedLyrics lyrics={songLyrics} />
))}
</ErrorBoundary> </ErrorBoundary>
); );
}; };

View file

@ -0,0 +1,25 @@
import { useQuery } from '@tanstack/react-query';
import { LyricsQuery } from '/@/renderer/api/types';
import { QueryHookArgs } from '/@/renderer/lib/react-query';
import { getServerById } from '/@/renderer/store';
import { controller } from '/@/renderer/api/controller';
import { queryKeys } from '/@/renderer/api/query-keys';
import { ServerType } from '/@/renderer/types';
export const useSongLyrics = (args: QueryHookArgs<LyricsQuery>) => {
const { query, serverId } = args;
const server = getServerById(serverId);
return useQuery({
// Note: This currently fetches for every song, even if it shouldn't have
// lyrics, because for some reason HasLyrics is not exposed. Thus, ignore the error
onError: () => {},
queryFn: ({ signal }) => {
if (!server) throw new Error('Server not found');
// This should only be called for Jellyfin. Return null to ignore errors
if (server.type !== ServerType.JELLYFIN) return null;
return controller.getLyrics({ apiClientProps: { server, signal }, query });
},
queryKey: queryKeys.songs.lyrics(server?.id || '', query),
});
};

View file

@ -10,11 +10,10 @@ import { PlaybackType, PlayerStatus } from '/@/renderer/types';
import { LyricLine } from '/@/renderer/features/lyrics/lyric-line'; import { LyricLine } from '/@/renderer/features/lyrics/lyric-line';
import isElectron from 'is-electron'; import isElectron from 'is-electron';
import { PlayersRef } from '/@/renderer/features/player/ref/players-ref'; import { PlayersRef } from '/@/renderer/features/player/ref/players-ref';
import { SynchronizedLyricsArray } from '/@/renderer/api/types';
const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null; const mpvPlayer = isElectron() ? window.electron.mpvPlayer : null;
export type SynchronizedLyricsArray = Array<[number, string]>;
interface SynchronizedLyricsProps { interface SynchronizedLyricsProps {
lyrics: SynchronizedLyricsArray; lyrics: SynchronizedLyricsArray;
} }
@ -40,7 +39,12 @@ export const SynchronizedLyrics = ({ lyrics }: SynchronizedLyricsProps) => {
// whether to proceed or stop // whether to proceed or stop
const timerEpoch = useRef(0); const timerEpoch = useRef(0);
const followRef = useRef<boolean>(settings.follow); const delayMsRef = useRef(settings.delayMs);
const followRef = useRef(settings.follow);
useEffect(() => {
delayMsRef.current = settings.delayMs;
}, [settings.delayMs]);
useEffect(() => { useEffect(() => {
// Copy the follow settings into a ref that can be accessed in the timeout // Copy the follow settings into a ref that can be accessed in the timeout
@ -127,7 +131,7 @@ export const SynchronizedLyrics = ({ lyrics }: SynchronizedLyricsProps) => {
} }
if (index !== lyricRef.current!.length - 1) { if (index !== lyricRef.current!.length - 1) {
const [nextTime] = lyricRef.current![index + 1]; const nextTime = lyricRef.current![index + 1][0];
const elapsed = performance.now() - start; const elapsed = performance.now() - start;
@ -149,7 +153,7 @@ export const SynchronizedLyrics = ({ lyrics }: SynchronizedLyricsProps) => {
return false; return false;
} }
setCurrentLyric(timeInSec * 1000); setCurrentLyric(timeInSec * 1000 + delayMsRef.current);
return true; return true;
}) })
@ -185,7 +189,7 @@ export const SynchronizedLyrics = ({ lyrics }: SynchronizedLyricsProps) => {
clearTimeout(lyricTimer.current); clearTimeout(lyricTimer.current);
} }
setCurrentLyric(now * 1000); setCurrentLyric(now * 1000 + delayMsRef.current);
}, [now, seeked, setCurrentLyric, status]); }, [now, seeked, setCurrentLyric, status]);
useEffect(() => { useEffect(() => {

View file

@ -1,4 +1,4 @@
import { Switch } from '@mantine/core'; import { NumberInput, Switch } from '@mantine/core';
import { import {
SettingOption, SettingOption,
SettingsSection, SettingsSection,
@ -82,6 +82,28 @@ export const LyricSettings = () => {
isHidden: !isElectron(), isHidden: !isElectron(),
title: 'Providers to fetch music', title: 'Providers to fetch music',
}, },
{
control: (
<NumberInput
defaultValue={settings.delayMs}
step={10}
width={100}
onBlur={(e) => {
const value = Number(e.currentTarget.value);
setSettings({
lyrics: {
...settings,
delayMs: value,
},
});
}}
/>
),
description:
'Lyric offset (in milliseconds). Positive values mean that lyrics are shown later, and negative mean that lyrics are shown earlier',
isHidden: !isElectron(),
title: 'Lyric offset',
},
]; ];
return <SettingsSection options={lyricOptions} />; return <SettingsSection options={lyricOptions} />;

View file

@ -123,6 +123,7 @@ export interface SettingsState {
globalMediaHotkeys: boolean; globalMediaHotkeys: boolean;
}; };
lyrics: { lyrics: {
delayMs: number;
fetch: boolean; fetch: boolean;
follow: boolean; follow: boolean;
sources: LyricSource[]; sources: LyricSource[];
@ -209,6 +210,7 @@ const initialState: SettingsState = {
globalMediaHotkeys: true, globalMediaHotkeys: true,
}, },
lyrics: { lyrics: {
delayMs: 0,
fetch: false, fetch: false,
follow: true, follow: true,
sources: [], sources: [],