diff --git a/src/renderer/api/controller.ts b/src/renderer/api/controller.ts index 196a848c..73d0039d 100644 --- a/src/renderer/api/controller.ts +++ b/src/renderer/api/controller.ts @@ -48,6 +48,8 @@ import type { SearchResponse, LyricsArgs, LyricsResponse, + ServerInfo, + ServerInfoArgs, } from '/@/renderer/api/types'; import { ServerType } from '/@/renderer/types'; import { DeletePlaylistResponse, RandomSongListArgs } from './types'; @@ -85,6 +87,7 @@ export type ControllerEndpoint = Partial<{ getPlaylistList: (args: PlaylistListArgs) => Promise; getPlaylistSongList: (args: PlaylistSongListArgs) => Promise; getRandomSongList: (args: RandomSongListArgs) => Promise; + getServerInfo: (args: ServerInfoArgs) => Promise; getSongDetail: (args: SongDetailArgs) => Promise; getSongList: (args: SongListArgs) => Promise; getTopSongs: (args: TopSongListArgs) => Promise; @@ -129,6 +132,7 @@ const endpoints: ApiController = { getPlaylistList: jfController.getPlaylistList, getPlaylistSongList: jfController.getPlaylistSongList, getRandomSongList: jfController.getRandomSongList, + getServerInfo: jfController.getServerInfo, getSongDetail: jfController.getSongDetail, getSongList: jfController.getSongList, getTopSongs: jfController.getTopSongList, @@ -165,6 +169,7 @@ const endpoints: ApiController = { getPlaylistList: ndController.getPlaylistList, getPlaylistSongList: ndController.getPlaylistSongList, getRandomSongList: ssController.getRandomSongList, + getServerInfo: ssController.getServerInfo, getSongDetail: ndController.getSongDetail, getSongList: ndController.getSongList, getTopSongs: ssController.getTopSongList, @@ -198,6 +203,7 @@ const endpoints: ApiController = { getMusicFolderList: ssController.getMusicFolderList, getPlaylistDetail: undefined, getPlaylistList: undefined, + getServerInfo: ssController.getServerInfo, getSongDetail: undefined, getSongList: undefined, getTopSongs: ssController.getTopSongList, @@ -481,6 +487,15 @@ const getLyrics = async (args: LyricsArgs) => { )?.(args); }; +const getServerInfo = async (args: ServerInfoArgs) => { + return ( + apiController( + 'getServerInfo', + args.apiClientProps.server?.type, + ) as ControllerEndpoint['getServerInfo'] + )?.(args); +}; + export const controller = { addToPlaylist, authenticate, @@ -500,6 +515,7 @@ export const controller = { getPlaylistList, getPlaylistSongList, getRandomSongList, + getServerInfo, getSongDetail, getSongList, getTopSongList, diff --git a/src/renderer/api/jellyfin/jellyfin-api.ts b/src/renderer/api/jellyfin/jellyfin-api.ts index 3303c742..d9b3040d 100644 --- a/src/renderer/api/jellyfin/jellyfin-api.ts +++ b/src/renderer/api/jellyfin/jellyfin-api.ts @@ -150,6 +150,14 @@ export const contract = c.router({ 400: jfType._response.error, }, }, + getServerInfo: { + method: 'GET', + path: 'system/info', + responses: { + 200: jfType._response.serverInfo, + 400: jfType._response.error, + }, + }, getSimilarArtistList: { method: 'GET', path: 'artists/:id/similar', diff --git a/src/renderer/api/jellyfin/jellyfin-controller.ts b/src/renderer/api/jellyfin/jellyfin-controller.ts index d1c9faff..5a1369a2 100644 --- a/src/renderer/api/jellyfin/jellyfin-controller.ts +++ b/src/renderer/api/jellyfin/jellyfin-controller.ts @@ -49,6 +49,8 @@ import { genreListSortMap, SongDetailArgs, SongDetailResponse, + ServerInfo, + ServerInfoArgs, } from '/@/renderer/api/types'; import { jfApiClient } from '/@/renderer/api/jellyfin/jellyfin-api'; import { jfNormalize } from './jellyfin-normalize'; @@ -946,6 +948,18 @@ const getSongDetail = async (args: SongDetailArgs): Promise return jfNormalize.song(res.body, apiClientProps.server, ''); }; +const getServerInfo = async (args: ServerInfoArgs): Promise => { + const { apiClientProps } = args; + + const res = await jfApiClient(apiClientProps).getServerInfo(); + + if (res.status !== 200) { + throw new Error('Failed to get song detail'); + } + + return { id: apiClientProps.server?.id, version: res.body.Version }; +}; + export const jfController = { addToPlaylist, authenticate, @@ -965,6 +979,7 @@ export const jfController = { getPlaylistList, getPlaylistSongList, getRandomSongList, + getServerInfo, getSongDetail, getSongList, getTopSongList, diff --git a/src/renderer/api/jellyfin/jellyfin-types.ts b/src/renderer/api/jellyfin/jellyfin-types.ts index 9721ae8a..b789e406 100644 --- a/src/renderer/api/jellyfin/jellyfin-types.ts +++ b/src/renderer/api/jellyfin/jellyfin-types.ts @@ -654,6 +654,10 @@ const lyrics = z.object({ Lyrics: z.array(lyricText), }); +const serverInfo = z.object({ + Version: z.string(), +}); + export const jfType = { _enum: { albumArtistList: albumArtistListSort, @@ -707,6 +711,7 @@ export const jfType = { removeFromPlaylist, scrobble, search, + serverInfo, song, songList, topSongsList, diff --git a/src/renderer/api/subsonic/subsonic-api.ts b/src/renderer/api/subsonic/subsonic-api.ts index 5a620f19..d3beac24 100644 --- a/src/renderer/api/subsonic/subsonic-api.ts +++ b/src/renderer/api/subsonic/subsonic-api.ts @@ -50,6 +50,13 @@ export const contract = c.router({ 200: ssType._response.randomSongList, }, }, + getServerInfo: { + method: 'GET', + path: 'getOpenSubsonicExtensions.view', + responses: { + 200: ssType._response.serverInfo, + }, + }, getTopSongsList: { method: 'GET', path: 'getTopSongs.view', @@ -58,6 +65,13 @@ export const contract = c.router({ 200: ssType._response.topSongsList, }, }, + ping: { + method: 'GET', + path: 'ping.view', + responses: { + 200: ssType._response.ping, + }, + }, removeFavorite: { method: 'GET', path: 'unstar.view', diff --git a/src/renderer/api/subsonic/subsonic-controller.ts b/src/renderer/api/subsonic/subsonic-controller.ts index 32c0de17..352b5d9a 100644 --- a/src/renderer/api/subsonic/subsonic-controller.ts +++ b/src/renderer/api/subsonic/subsonic-controller.ts @@ -21,6 +21,8 @@ import { SearchResponse, RandomSongListResponse, RandomSongListArgs, + ServerInfo, + ServerInfoArgs, } from '/@/renderer/api/types'; import { randomString } from '/@/renderer/utils'; @@ -368,12 +370,40 @@ const getRandomSongList = async (args: RandomSongListArgs): Promise => { + const { apiClientProps } = args; + + const ping = await ssApiClient(apiClientProps).ping(); + + if (ping.status !== 200) { + throw new Error('Failed to ping server'); + } + + if (!ping.body.openSubsonic || !ping.body.serverVersion) { + return { version: ping.body.version }; + } + + const res = await ssApiClient(apiClientProps).getServerInfo(); + + if (res.status !== 200) { + throw new Error('Failed to get server extensions'); + } + + const features: Record = {}; + for (const extension of res.body.openSubsonicExtensions) { + features[extension.name] = extension.versions; + } + + return { features, id: apiClientProps.server?.id, version: ping.body.serverVersion }; +}; + export const ssController = { authenticate, createFavorite, getArtistInfo, getMusicFolderList, getRandomSongList, + getServerInfo, getTopSongList, removeFavorite, scrobble, diff --git a/src/renderer/api/subsonic/subsonic-types.ts b/src/renderer/api/subsonic/subsonic-types.ts index 3360081b..5999d986 100644 --- a/src/renderer/api/subsonic/subsonic-types.ts +++ b/src/renderer/api/subsonic/subsonic-types.ts @@ -206,6 +206,21 @@ const randomSongList = z.object({ }), }); +const ping = z.object({ + openSubsonic: z.boolean().optional(), + serverVersion: z.string().optional(), + version: z.string(), +}); + +const extension = z.object({ + name: z.string(), + versions: z.number().array(), +}); + +const serverInfo = z.object({ + openSubsonicExtensions: z.array(extension), +}); + export const ssType = { _parameters: { albumList: albumListParameters, @@ -229,10 +244,12 @@ export const ssType = { baseResponse, createFavorite, musicFolderList, + ping, randomSongList, removeFavorite, scrobble, search3, + serverInfo, setRating, song, topSongsList, diff --git a/src/renderer/api/types.ts b/src/renderer/api/types.ts index 5165c7fb..ec8ce685 100644 --- a/src/renderer/api/types.ts +++ b/src/renderer/api/types.ts @@ -1139,3 +1139,17 @@ export type FontData = { postscriptName: string; style: string; }; + +export type ServerInfoArgs = BaseEndpointArgs; + +export enum SubsonicExtensions { + FORM_POST = 'formPost', + SONG_LYRICS = 'songLyrics', + TRANSCODE_OFFSET = 'transcodeOffset', +} + +export type ServerInfo = { + features?: Record; + id?: string; + version: string; +}; diff --git a/src/renderer/app.tsx b/src/renderer/app.tsx index d64b2149..b834c3a5 100644 --- a/src/renderer/app.tsx +++ b/src/renderer/app.tsx @@ -27,6 +27,7 @@ import { FontType, PlaybackType, PlayerStatus } from '/@/renderer/types'; import '@ag-grid-community/styles/ag-grid.css'; import { useDiscordRpc } from '/@/renderer/features/discord-rpc/use-discord-rpc'; import i18n from '/@/i18n/i18n'; +import { useServerVersion } from '/@/renderer/hooks/use-server-version'; ModuleRegistry.registerModules([ClientSideRowModelModule, InfiniteRowModelModule]); @@ -49,6 +50,7 @@ export const App = () => { const remoteSettings = useRemoteSettings(); const textStyleRef = useRef(); useDiscordRpc(); + useServerVersion(); useEffect(() => { if (type === FontType.SYSTEM && system) { diff --git a/src/renderer/features/lyrics/queries/lyric-query.ts b/src/renderer/features/lyrics/queries/lyric-query.ts index 599c5857..249fb118 100644 --- a/src/renderer/features/lyrics/queries/lyric-query.ts +++ b/src/renderer/features/lyrics/queries/lyric-query.ts @@ -6,6 +6,7 @@ import { InternetProviderLyricResponse, FullLyricsMetadata, LyricGetQuery, + SubsonicExtensions, } from '/@/renderer/api/types'; import { QueryHookArgs } from '/@/renderer/lib/react-query'; import { getServerById, useLyricsSettings } from '/@/renderer/store'; @@ -93,16 +94,6 @@ export const useSongLyricsBySong = ( if (!server) throw new Error('Server not found'); if (!song) return null; - if (song.lyrics) { - return { - artist: song.artists?.[0]?.name, - lyrics: formatLyrics(song.lyrics), - name: song.name, - remote: false, - source: server?.name ?? 'music server', - }; - } - if (server.type === ServerType.JELLYFIN) { const jfLyrics = await api.controller .getLyrics({ @@ -120,6 +111,16 @@ export const useSongLyricsBySong = ( source: server?.name ?? 'music server', }; } + } else if (server.features && SubsonicExtensions.SONG_LYRICS in server.features) { + console.log(1234); + } else if (song.lyrics) { + return { + artist: song.artists?.[0]?.name, + lyrics: formatLyrics(song.lyrics), + name: song.name, + remote: false, + source: server?.name ?? 'music server', + }; } if (fetch) { diff --git a/src/renderer/features/servers/components/edit-server-form.tsx b/src/renderer/features/servers/components/edit-server-form.tsx index 776257e1..47fb110c 100644 --- a/src/renderer/features/servers/components/edit-server-form.tsx +++ b/src/renderer/features/servers/components/edit-server-form.tsx @@ -12,6 +12,8 @@ import { useAuthStoreActions } from '/@/renderer/store'; import { ServerListItem, ServerType } from '/@/renderer/types'; import { api } from '/@/renderer/api'; import i18n from '/@/i18n/i18n'; +import { queryClient } from '/@/renderer/lib/react-query'; +import { queryKeys } from '/@/renderer/api/query-keys'; const localSettings = isElectron() ? window.electron.localSettings : null; @@ -111,6 +113,8 @@ export const EditServerForm = ({ isUpdate, password, server, onCancel }: EditSer localSettings.passwordRemove(server.id); } } + + queryClient.invalidateQueries({ queryKey: queryKeys.server.root(server.id) }); } catch (err: any) { setIsLoading(false); return toast.error({ message: err?.message }); diff --git a/src/renderer/hooks/use-server-version.ts b/src/renderer/hooks/use-server-version.ts new file mode 100644 index 00000000..6ca1327d --- /dev/null +++ b/src/renderer/hooks/use-server-version.ts @@ -0,0 +1,35 @@ +import { useEffect } from 'react'; +import { useAuthStoreActions, useCurrentServer } from '/@/renderer/store'; +import { useQuery } from '@tanstack/react-query'; +import { queryKeys } from '/@/renderer/api/query-keys'; +import { controller } from '/@/renderer/api/controller'; + +export const useServerVersion = () => { + const { updateServer } = useAuthStoreActions(); + const server = useCurrentServer(); + + const serverInfo = useQuery({ + enabled: !!server, + queryFn: async ({ signal }) => { + return controller.getServerInfo({ + apiClientProps: { + server, + signal, + }, + }); + }, + queryKey: queryKeys.server.root(server?.id), + }); + + useEffect(() => { + if (server && server.id === serverInfo.data?.id) { + const { version, features } = serverInfo.data; + if (version !== server.version) { + updateServer(server.id, { + features, + version, + }); + } + } + }, [server, serverInfo.data, updateServer]); +}; diff --git a/src/renderer/types.ts b/src/renderer/types.ts index 1ede3dea..5df65673 100644 --- a/src/renderer/types.ts +++ b/src/renderer/types.ts @@ -62,6 +62,7 @@ export enum ServerType { export type ServerListItem = { credential: string; + features?: Record; id: string; name: string; ndCredential?: string; @@ -70,6 +71,7 @@ export type ServerListItem = { url: string; userId: string | null; username: string; + version?: string; }; export enum PlayerStatus {