diff --git a/src/renderer/api/controller.ts b/src/renderer/api/controller.ts index 196a848c1..6cd4d6235 100644 --- a/src/renderer/api/controller.ts +++ b/src/renderer/api/controller.ts @@ -48,6 +48,10 @@ import type { SearchResponse, LyricsArgs, LyricsResponse, + ServerInfo, + ServerInfoArgs, + StructuredLyricsArgs, + StructuredLyric, } from '/@/renderer/api/types'; import { ServerType } from '/@/renderer/types'; import { DeletePlaylistResponse, RandomSongListArgs } from './types'; @@ -85,8 +89,10 @@ 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; + getStructuredLyrics: (args: StructuredLyricsArgs) => Promise; getTopSongs: (args: TopSongListArgs) => Promise; getUserList: (args: UserListArgs) => Promise; removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise; @@ -129,8 +135,10 @@ const endpoints: ApiController = { getPlaylistList: jfController.getPlaylistList, getPlaylistSongList: jfController.getPlaylistSongList, getRandomSongList: jfController.getRandomSongList, + getServerInfo: jfController.getServerInfo, getSongDetail: jfController.getSongDetail, getSongList: jfController.getSongList, + getStructuredLyrics: undefined, getTopSongs: jfController.getTopSongList, getUserList: undefined, removeFromPlaylist: jfController.removeFromPlaylist, @@ -165,8 +173,10 @@ const endpoints: ApiController = { getPlaylistList: ndController.getPlaylistList, getPlaylistSongList: ndController.getPlaylistSongList, getRandomSongList: ssController.getRandomSongList, + getServerInfo: ssController.getServerInfo, getSongDetail: ndController.getSongDetail, getSongList: ndController.getSongList, + getStructuredLyrics: ssController.getStructuredLyrics, getTopSongs: ssController.getTopSongList, getUserList: ndController.getUserList, removeFromPlaylist: ndController.removeFromPlaylist, @@ -198,8 +208,10 @@ const endpoints: ApiController = { getMusicFolderList: ssController.getMusicFolderList, getPlaylistDetail: undefined, getPlaylistList: undefined, + getServerInfo: ssController.getServerInfo, getSongDetail: undefined, getSongList: undefined, + getStructuredLyrics: ssController.getStructuredLyrics, getTopSongs: ssController.getTopSongList, getUserList: undefined, scrobble: ssController.scrobble, @@ -481,6 +493,24 @@ const getLyrics = async (args: LyricsArgs) => { )?.(args); }; +const getServerInfo = async (args: ServerInfoArgs) => { + return ( + apiController( + 'getServerInfo', + args.apiClientProps.server?.type, + ) as ControllerEndpoint['getServerInfo'] + )?.(args); +}; + +const getStructuredLyrics = async (args: StructuredLyricsArgs) => { + return ( + apiController( + 'getStructuredLyrics', + args.apiClientProps.server?.type, + ) as ControllerEndpoint['getStructuredLyrics'] + )?.(args); +}; + export const controller = { addToPlaylist, authenticate, @@ -500,8 +530,10 @@ export const controller = { getPlaylistList, getPlaylistSongList, getRandomSongList, + getServerInfo, getSongDetail, getSongList, + getStructuredLyrics, getTopSongList, getUserList, removeFromPlaylist, diff --git a/src/renderer/api/jellyfin/jellyfin-api.ts b/src/renderer/api/jellyfin/jellyfin-api.ts index 3303c7429..d9b3040dd 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 d1c9faff4..52cf298b9 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 server info'); + } + + 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 9721ae8a4..b789e406b 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 5a620f19a..757575171 100644 --- a/src/renderer/api/subsonic/subsonic-api.ts +++ b/src/renderer/api/subsonic/subsonic-api.ts @@ -50,6 +50,21 @@ export const contract = c.router({ 200: ssType._response.randomSongList, }, }, + getServerInfo: { + method: 'GET', + path: 'getOpenSubsonicExtensions.view', + responses: { + 200: ssType._response.serverInfo, + }, + }, + getStructuredLyrics: { + method: 'GET', + path: 'getLyricsBySongId.view', + query: ssType._parameters.structuredLyrics, + responses: { + 200: ssType._response.structuredLyrics, + }, + }, getTopSongsList: { method: 'GET', path: 'getTopSongs.view', @@ -58,6 +73,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 32c0de170..b67200e8b 100644 --- a/src/renderer/api/subsonic/subsonic-controller.ts +++ b/src/renderer/api/subsonic/subsonic-controller.ts @@ -21,6 +21,10 @@ import { SearchResponse, RandomSongListResponse, RandomSongListArgs, + ServerInfo, + ServerInfoArgs, + StructuredLyricsArgs, + StructuredLyric, } from '/@/renderer/api/types'; import { randomString } from '/@/renderer/utils'; @@ -368,12 +372,86 @@ 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 getStructuredLyrics = async ( + args: StructuredLyricsArgs, +): Promise => { + const { query, apiClientProps } = args; + + const res = await ssApiClient(apiClientProps).getStructuredLyrics({ + query: { + id: query.songId, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get structured lyrics'); + } + + const lyrics = res.body.lyricsList?.structuredLyrics; + + if (!lyrics) { + return []; + } + + return lyrics.map((lyric) => { + const baseLyric = { + artist: lyric.displayArtist || '', + lang: lyric.lang, + name: lyric.displayTitle || '', + remote: false, + source: apiClientProps.server?.name || 'music server', + }; + + if (lyric.synced) { + return { + ...baseLyric, + lyrics: lyric.line.map((line) => [line.start!, line.value]), + synced: true, + }; + } + return { + ...baseLyric, + lyrics: lyric.line.map((line) => [line.value]).join('\n'), + synced: false, + }; + }); +}; + export const ssController = { authenticate, createFavorite, getArtistInfo, getMusicFolderList, getRandomSongList, + getServerInfo, + getStructuredLyrics, getTopSongList, removeFavorite, scrobble, diff --git a/src/renderer/api/subsonic/subsonic-types.ts b/src/renderer/api/subsonic/subsonic-types.ts index 3360081b6..9005fe8c5 100644 --- a/src/renderer/api/subsonic/subsonic-types.ts +++ b/src/renderer/api/subsonic/subsonic-types.ts @@ -206,6 +206,47 @@ 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), +}); + +const structuredLyricsParameters = z.object({ + id: z.string(), +}); + +const lyricLine = z.object({ + start: z.number().optional(), + value: z.string(), +}); + +const structuredLyric = z.object({ + displayArtist: z.string().optional(), + displayTitle: z.string().optional(), + lang: z.string(), + line: z.array(lyricLine), + offset: z.number().optional(), + synced: z.boolean(), +}); + +const structuredLyrics = z.object({ + lyricsList: z + .object({ + structuredLyrics: z.array(structuredLyric).optional(), + }) + .optional(), +}); + export const ssType = { _parameters: { albumList: albumListParameters, @@ -217,6 +258,7 @@ export const ssType = { scrobble: scrobbleParameters, search3: search3Parameters, setRating: setRatingParameters, + structuredLyrics: structuredLyricsParameters, topSongsList: topSongsListParameters, }, _response: { @@ -229,12 +271,15 @@ export const ssType = { baseResponse, createFavorite, musicFolderList, + ping, randomSongList, removeFavorite, scrobble, search3, + serverInfo, setRating, song, + structuredLyrics, topSongsList, }, }; diff --git a/src/renderer/api/types.ts b/src/renderer/api/types.ts index 5165c7fb1..7241d82ec 100644 --- a/src/renderer/api/types.ts +++ b/src/renderer/api/types.ts @@ -1092,17 +1092,11 @@ export type InternetProviderLyricSearchResponse = { source: LyricSource; }; -export type SynchronizedLyricMetadata = { - lyrics: SynchronizedLyricsArray; +export type FullLyricsMetadata = { + lyrics: LyricsResponse; remote: boolean; -} & Omit; - -export type UnsynchronizedLyricMetadata = { - lyrics: string; - remote: boolean; -} & Omit; - -export type FullLyricsMetadata = SynchronizedLyricMetadata | UnsynchronizedLyricMetadata; + source: string; +} & Omit; export type LyricOverride = Omit; @@ -1139,3 +1133,35 @@ 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; +}; + +export type StructuredLyricsArgs = { + query: LyricsQuery; +} & BaseEndpointArgs; + +export type StructuredUnsyncedLyric = { + lyrics: string; + synced: false; +} & Omit; + +export type StructuredSyncedLyric = { + lyrics: SynchronizedLyricsArray; + synced: true; +} & Omit; + +export type StructuredLyric = { + lang: string; +} & (StructuredUnsyncedLyric | StructuredSyncedLyric); diff --git a/src/renderer/app.tsx b/src/renderer/app.tsx index d64b21498..b834c3a59 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/lyrics-actions.tsx b/src/renderer/features/lyrics/lyrics-actions.tsx index 3ce52b13e..b9edc7088 100644 --- a/src/renderer/features/lyrics/lyrics-actions.tsx +++ b/src/renderer/features/lyrics/lyrics-actions.tsx @@ -1,4 +1,4 @@ -import { Box, Group } from '@mantine/core'; +import { Box, Center, Group, Select, SelectItem } from '@mantine/core'; import isElectron from 'is-electron'; import { useTranslation } from 'react-i18next'; import { RiAddFill, RiSubtractFill } from 'react-icons/ri'; @@ -13,15 +13,22 @@ import { } from '/@/renderer/store'; interface LyricsActionsProps { + index: number; + languages: SelectItem[]; + onRemoveLyric: () => void; onResetLyric: () => void; onSearchOverride: (params: LyricsOverride) => void; + setIndex: (idx: number) => void; } export const LyricsActions = ({ + index, + languages, onRemoveLyric, onResetLyric, onSearchOverride, + setIndex, }: LyricsActionsProps) => { const { t } = useTranslation(); const currentSong = useCurrentSong(); @@ -42,6 +49,18 @@ export const LyricsActions = ({ return ( + {languages.length > 1 && ( +
+