mirror of
https://github.com/antebudimir/feishin.git
synced 2026-01-01 02:13:33 +00:00
Merge pull request #484 from kgarner7/fix-structured-lyrics
[bugfix/enhancement]: Support Navidrome structured lyrics
This commit is contained in:
commit
83d5fee442
17 changed files with 366 additions and 55 deletions
|
|
@ -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<PlaylistListResponse>;
|
||||
getPlaylistSongList: (args: PlaylistSongListArgs) => Promise<SongListResponse>;
|
||||
getRandomSongList: (args: RandomSongListArgs) => Promise<SongListResponse>;
|
||||
getServerInfo: (args: ServerInfoArgs) => Promise<ServerInfo>;
|
||||
getSongDetail: (args: SongDetailArgs) => Promise<SongDetailResponse>;
|
||||
getSongList: (args: SongListArgs) => Promise<SongListResponse>;
|
||||
getStructuredLyrics: (args: StructuredLyricsArgs) => Promise<StructuredLyric[]>;
|
||||
getTopSongs: (args: TopSongListArgs) => Promise<TopSongListResponse>;
|
||||
getUserList: (args: UserListArgs) => Promise<UserListResponse>;
|
||||
removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise<RemoveFromPlaylistResponse>;
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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<SongDetailResponse>
|
|||
return jfNormalize.song(res.body, apiClientProps.server, '');
|
||||
};
|
||||
|
||||
const getServerInfo = async (args: ServerInfoArgs): Promise<ServerInfo> => {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -661,6 +661,10 @@ const lyrics = z.object({
|
|||
Lyrics: z.array(lyricText),
|
||||
});
|
||||
|
||||
const serverInfo = z.object({
|
||||
Version: z.string(),
|
||||
});
|
||||
|
||||
export const jfType = {
|
||||
_enum: {
|
||||
albumArtistList: albumArtistListSort,
|
||||
|
|
@ -714,6 +718,7 @@ export const jfType = {
|
|||
removeFromPlaylist,
|
||||
scrobble,
|
||||
search,
|
||||
serverInfo,
|
||||
song,
|
||||
songList,
|
||||
topSongsList,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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<RandomSongLi
|
|||
};
|
||||
};
|
||||
|
||||
const getServerInfo = async (args: ServerInfoArgs): Promise<ServerInfo> => {
|
||||
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<string, number[]> = {};
|
||||
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<StructuredLyric[]> => {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1097,17 +1097,11 @@ export type InternetProviderLyricSearchResponse = {
|
|||
source: LyricSource;
|
||||
};
|
||||
|
||||
export type SynchronizedLyricMetadata = {
|
||||
lyrics: SynchronizedLyricsArray;
|
||||
export type FullLyricsMetadata = {
|
||||
lyrics: LyricsResponse;
|
||||
remote: boolean;
|
||||
} & Omit<InternetProviderLyricResponse, 'lyrics'>;
|
||||
|
||||
export type UnsynchronizedLyricMetadata = {
|
||||
lyrics: string;
|
||||
remote: boolean;
|
||||
} & Omit<InternetProviderLyricResponse, 'lyrics'>;
|
||||
|
||||
export type FullLyricsMetadata = SynchronizedLyricMetadata | UnsynchronizedLyricMetadata;
|
||||
source: string;
|
||||
} & Omit<InternetProviderLyricResponse, 'id' | 'lyrics' | 'source'>;
|
||||
|
||||
export type LyricOverride = Omit<InternetProviderLyricResponse, 'lyrics'>;
|
||||
|
||||
|
|
@ -1144,3 +1138,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<string, number[]>;
|
||||
id?: string;
|
||||
version: string;
|
||||
};
|
||||
|
||||
export type StructuredLyricsArgs = {
|
||||
query: LyricsQuery;
|
||||
} & BaseEndpointArgs;
|
||||
|
||||
export type StructuredUnsyncedLyric = {
|
||||
lyrics: string;
|
||||
synced: false;
|
||||
} & Omit<FullLyricsMetadata, 'lyrics'>;
|
||||
|
||||
export type StructuredSyncedLyric = {
|
||||
lyrics: SynchronizedLyricsArray;
|
||||
synced: true;
|
||||
} & Omit<FullLyricsMetadata, 'lyrics'>;
|
||||
|
||||
export type StructuredLyric = {
|
||||
lang: string;
|
||||
} & (StructuredUnsyncedLyric | StructuredSyncedLyric);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue