mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-16 13:40:24 +02:00
simplify lyrics implementation
- removes complex lyrics fetch and override logic, and instead uses a single query as a source of truth for the lyrics - properly handles loading state, invalidation, and refetch
This commit is contained in:
@@ -12,16 +12,31 @@ import {
|
||||
InternetProviderLyricSearchResponse,
|
||||
LyricGetQuery,
|
||||
LyricSearchQuery,
|
||||
LyricsOverride,
|
||||
LyricsQuery,
|
||||
QueueSong,
|
||||
Song,
|
||||
StructuredLyric,
|
||||
SynchronizedLyricsArray,
|
||||
} from '/@/shared/types/domain-types';
|
||||
import { LyricSource } from '/@/shared/types/domain-types';
|
||||
import { LyricsResponse } from '/@/shared/types/domain-types';
|
||||
import { ServerFeature } from '/@/shared/types/features-types';
|
||||
|
||||
const lyricsIpc = isElectron() ? window.api.lyrics : null;
|
||||
|
||||
export type LyricsQueryResult = {
|
||||
local: FullLyricsMetadata | null | StructuredLyric[];
|
||||
overrideData: LyricsResponse | null;
|
||||
overrideSelection: LyricsOverride | null;
|
||||
remoteAuto: FullLyricsMetadata | null;
|
||||
selected: FullLyricsMetadata | null | StructuredLyric;
|
||||
selectedOffsetMs: number;
|
||||
selectedStructuredIndex: number;
|
||||
selectedSynced: boolean;
|
||||
suppressRemoteAuto: boolean;
|
||||
};
|
||||
|
||||
// Match LRC lyrics format by https://github.com/ustbhuangyi/lyric-parser
|
||||
// [mm:ss.SSS] text
|
||||
const timeExp = /\[(\d{2,}):(\d{2})(?:\.(\d{2,3}))?]([^\n]+)(\n|$)/g;
|
||||
@@ -63,35 +78,177 @@ const formatLyrics = (lyrics: string) => {
|
||||
return lyrics;
|
||||
};
|
||||
|
||||
export const formatLyricsForDisplay = formatLyrics;
|
||||
|
||||
export function computeSelectedFromResult(
|
||||
result: Pick<
|
||||
LyricsQueryResult,
|
||||
'local' | 'overrideData' | 'overrideSelection' | 'remoteAuto' | 'selectedOffsetMs'
|
||||
>,
|
||||
preferLocalLyrics: boolean,
|
||||
selectedStructuredIndex: number,
|
||||
): {
|
||||
selected: FullLyricsMetadata | null | StructuredLyric;
|
||||
selectedSynced: boolean;
|
||||
} {
|
||||
const { local, overrideData, overrideSelection, remoteAuto, selectedOffsetMs } = result;
|
||||
|
||||
// Override takes precedence over local and remote lyrics in all scenarios if available
|
||||
if (overrideSelection && overrideData) {
|
||||
const overrideLyrics: FullLyricsMetadata = {
|
||||
artist: overrideSelection.artist,
|
||||
lyrics: overrideData,
|
||||
name: overrideSelection.name,
|
||||
offsetMs: selectedOffsetMs,
|
||||
remote: overrideSelection.remote ?? true,
|
||||
source: overrideSelection.source,
|
||||
};
|
||||
return {
|
||||
selected: overrideLyrics,
|
||||
selectedSynced: Array.isArray(overrideData),
|
||||
};
|
||||
}
|
||||
|
||||
const hasLocalLocal =
|
||||
(Array.isArray(local) && local.length > 0) ||
|
||||
(local != null && !Array.isArray(local) && 'lyrics' in local && Boolean(local.lyrics));
|
||||
|
||||
// If setting is set to prefer local lyrics, return the local lyrics if available
|
||||
if (preferLocalLyrics && hasLocalLocal) {
|
||||
if (Array.isArray(local) && local.length > 0) {
|
||||
const item = local[Math.min(selectedStructuredIndex, local.length - 1)];
|
||||
return { selected: item, selectedSynced: item.synced };
|
||||
}
|
||||
|
||||
if (local != null && !Array.isArray(local) && 'lyrics' in local && local.lyrics) {
|
||||
return { selected: local, selectedSynced: Array.isArray(local.lyrics) };
|
||||
}
|
||||
}
|
||||
|
||||
// If remote lyrics are automatically fetched and available, return the remote auto lyrics
|
||||
if (remoteAuto) {
|
||||
return {
|
||||
selected: remoteAuto,
|
||||
selectedSynced: Array.isArray(remoteAuto.lyrics),
|
||||
};
|
||||
}
|
||||
|
||||
// Otherwise, we just return the local lyrics if available, using structured lyrics if available
|
||||
if (Array.isArray(local) && local.length > 0) {
|
||||
const item = local[Math.min(selectedStructuredIndex, local.length - 1)];
|
||||
return { selected: item, selectedSynced: item.synced };
|
||||
}
|
||||
|
||||
if (local != null && !Array.isArray(local) && 'lyrics' in local && local.lyrics) {
|
||||
return { selected: local, selectedSynced: Array.isArray(local.lyrics) };
|
||||
}
|
||||
|
||||
// If no lyrics are available, return null
|
||||
return { selected: null, selectedSynced: false };
|
||||
}
|
||||
|
||||
export async function fetchLocalLyrics(params: {
|
||||
serverId: string;
|
||||
signal?: AbortSignal;
|
||||
song: QueueSong;
|
||||
}): Promise<FullLyricsMetadata | null | StructuredLyric[]> {
|
||||
const { serverId, signal, song } = params;
|
||||
const server = getServerById(serverId);
|
||||
if (!server) throw new Error('Server not found');
|
||||
|
||||
if (hasFeature(server, ServerFeature.LYRICS_MULTIPLE_STRUCTURED)) {
|
||||
const subsonicLyrics = await api.controller
|
||||
.getStructuredLyrics({
|
||||
apiClientProps: { serverId, signal },
|
||||
query: { songId: song.id },
|
||||
})
|
||||
.catch(console.error);
|
||||
if (subsonicLyrics?.length) return subsonicLyrics;
|
||||
} else if (hasFeature(server, ServerFeature.LYRICS_SINGLE_STRUCTURED)) {
|
||||
const jfLyrics = await api.controller
|
||||
.getLyrics({
|
||||
apiClientProps: { serverId, signal },
|
||||
query: { songId: song.id },
|
||||
})
|
||||
.catch((err) => console.error(err));
|
||||
if (jfLyrics) {
|
||||
return {
|
||||
artist: song.artists?.[0]?.name,
|
||||
lyrics: jfLyrics,
|
||||
name: song.name,
|
||||
remote: false,
|
||||
source: server?.name ?? 'music server',
|
||||
};
|
||||
}
|
||||
} else if (song.lyrics) {
|
||||
return {
|
||||
artist: song.artists?.[0]?.name,
|
||||
lyrics: formatLyrics(song.lyrics),
|
||||
name: song.name,
|
||||
remote: false,
|
||||
source: server?.name ?? 'music server',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function fetchRemoteLyricsAuto(song: QueueSong): Promise<FullLyricsMetadata | null> {
|
||||
const { fetch } = useSettingsStore.getState().lyrics;
|
||||
if (!fetch) return null;
|
||||
const remoteLyricsResult: InternetProviderLyricResponse | null =
|
||||
await lyricsIpc?.getRemoteLyricsBySong(song);
|
||||
|
||||
if (remoteLyricsResult) {
|
||||
return {
|
||||
...remoteLyricsResult,
|
||||
lyrics: formatLyrics(remoteLyricsResult.lyrics),
|
||||
remote: true,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function fetchRemoteLyricsById(params: {
|
||||
remoteSongId: string;
|
||||
remoteSource: LyricSource;
|
||||
song?: QueueSong | Song;
|
||||
}): Promise<LyricsResponse | null> {
|
||||
const result = await lyricsIpc?.getRemoteLyricsByRemoteId(params as LyricGetQuery);
|
||||
if (result) return formatLyrics(result);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getDisplayOffset(
|
||||
selected: FullLyricsMetadata | null | StructuredLyric,
|
||||
storedOffsetMs: number,
|
||||
selectedStructuredIndex: number,
|
||||
local: FullLyricsMetadata | null | StructuredLyric[],
|
||||
): number {
|
||||
if (selected && 'offsetMs' in selected && selected.offsetMs !== undefined) {
|
||||
return selected.offsetMs;
|
||||
}
|
||||
|
||||
if (Array.isArray(local) && local.length > 0) {
|
||||
const item = local[Math.min(selectedStructuredIndex, local.length - 1)];
|
||||
return item.offsetMs ?? storedOffsetMs;
|
||||
}
|
||||
|
||||
return storedOffsetMs;
|
||||
}
|
||||
|
||||
const emptyResult = (): LyricsQueryResult => ({
|
||||
local: null,
|
||||
overrideData: null,
|
||||
overrideSelection: null,
|
||||
remoteAuto: null,
|
||||
selected: null,
|
||||
selectedOffsetMs: 0,
|
||||
selectedStructuredIndex: 0,
|
||||
selectedSynced: false,
|
||||
suppressRemoteAuto: false,
|
||||
});
|
||||
|
||||
export const lyricsQueries = {
|
||||
remoteLyrics: (args: QueryHookArgs<LyricsQuery>, song: QueueSong | undefined) => {
|
||||
return queryOptions({
|
||||
gcTime: Infinity,
|
||||
queryFn: async (): Promise<FullLyricsMetadata | null> => {
|
||||
if (!song) return null;
|
||||
|
||||
const { fetch } = useSettingsStore.getState().lyrics;
|
||||
|
||||
if (!fetch) return null;
|
||||
|
||||
const remoteLyricsResult: InternetProviderLyricResponse | null =
|
||||
await lyricsIpc?.getRemoteLyricsBySong(song);
|
||||
|
||||
if (remoteLyricsResult) {
|
||||
return {
|
||||
...remoteLyricsResult,
|
||||
lyrics: formatLyrics(remoteLyricsResult.lyrics),
|
||||
remote: true,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
queryKey: queryKeys.songs.remoteLyrics(args.serverId, args.query),
|
||||
staleTime: Infinity,
|
||||
...args.options,
|
||||
});
|
||||
},
|
||||
search: (args: Omit<QueryHookArgs<LyricSearchQuery>, 'serverId'>) => {
|
||||
return queryOptions({
|
||||
gcTime: 1000 * 60 * 1,
|
||||
@@ -106,83 +263,82 @@ export const lyricsQueries = {
|
||||
...args.options,
|
||||
});
|
||||
},
|
||||
serverLyrics: (args: QueryHookArgs<LyricsQuery>, song: QueueSong | undefined) => {
|
||||
return queryOptions({
|
||||
gcTime: Infinity,
|
||||
queryFn: async ({ signal }): Promise<FullLyricsMetadata | null | StructuredLyric[]> => {
|
||||
const server = getServerById(args.serverId);
|
||||
if (!server) throw new Error('Server not found');
|
||||
if (!song) return null;
|
||||
|
||||
let localLyrics: FullLyricsMetadata | null | StructuredLyric[] = null;
|
||||
|
||||
if (hasFeature(server, ServerFeature.LYRICS_MULTIPLE_STRUCTURED)) {
|
||||
const subsonicLyrics = await api.controller
|
||||
.getStructuredLyrics({
|
||||
apiClientProps: { serverId: args.serverId, signal },
|
||||
query: { songId: song.id },
|
||||
})
|
||||
.catch(console.error);
|
||||
|
||||
if (subsonicLyrics?.length) {
|
||||
localLyrics = subsonicLyrics;
|
||||
}
|
||||
} else if (hasFeature(server, ServerFeature.LYRICS_SINGLE_STRUCTURED)) {
|
||||
const jfLyrics = await api.controller
|
||||
.getLyrics({
|
||||
apiClientProps: { serverId: args.serverId, signal },
|
||||
query: { songId: song.id },
|
||||
})
|
||||
.catch((err) => console.error(err));
|
||||
|
||||
if (jfLyrics) {
|
||||
localLyrics = {
|
||||
artist: song.artists?.[0]?.name,
|
||||
lyrics: jfLyrics,
|
||||
name: song.name,
|
||||
remote: false,
|
||||
source: server?.name ?? 'music server',
|
||||
};
|
||||
}
|
||||
} else if (song.lyrics) {
|
||||
localLyrics = {
|
||||
artist: song.artists?.[0]?.name,
|
||||
lyrics: formatLyrics(song.lyrics),
|
||||
name: song.name,
|
||||
remote: false,
|
||||
source: server?.name ?? 'music server',
|
||||
};
|
||||
}
|
||||
|
||||
return localLyrics;
|
||||
},
|
||||
queryKey: queryKeys.songs.serverLyrics(args.serverId, args.query),
|
||||
staleTime: Infinity,
|
||||
...args.options,
|
||||
});
|
||||
},
|
||||
songLyrics: (args: QueryHookArgs<LyricsQuery>, song: QueueSong | undefined) => {
|
||||
const lyricsKey = queryKeys.songs.lyrics(args.serverId, args.query);
|
||||
return queryOptions({
|
||||
gcTime: Infinity,
|
||||
queryFn: async ({ signal }): Promise<FullLyricsMetadata | null | StructuredLyric[]> => {
|
||||
if (!song) return null;
|
||||
queryFn: async ({ signal }): Promise<LyricsQueryResult> => {
|
||||
if (!song) return emptyResult();
|
||||
|
||||
const serverLyricsQuery = lyricsQueries.serverLyrics(args, song);
|
||||
const serverLyrics = await queryClient.fetchQuery({
|
||||
...serverLyricsQuery,
|
||||
queryFn: async (context) => {
|
||||
return (
|
||||
serverLyricsQuery.queryFn?.({
|
||||
...context,
|
||||
signal,
|
||||
}) ?? null
|
||||
);
|
||||
},
|
||||
});
|
||||
const prev = queryClient.getQueryData<LyricsQueryResult>(lyricsKey);
|
||||
const overrideSelection = prev?.overrideSelection ?? null;
|
||||
const suppressRemoteAuto = prev?.suppressRemoteAuto ?? false;
|
||||
const selectedStructuredIndex = prev?.selectedStructuredIndex ?? 0;
|
||||
const selectedOffsetMs = prev?.selectedOffsetMs ?? 0;
|
||||
const preferLocalLyrics = useSettingsStore.getState().lyrics.preferLocalLyrics;
|
||||
|
||||
return serverLyrics;
|
||||
// Fetch local lyrics
|
||||
const localPromise = fetchLocalLyrics({ serverId: args.serverId, signal, song });
|
||||
|
||||
// Fetch remote auto lyrics
|
||||
const remoteAutoPromise =
|
||||
suppressRemoteAuto || !useSettingsStore.getState().lyrics.fetch
|
||||
? null
|
||||
: fetchRemoteLyricsAuto(song);
|
||||
|
||||
// Fetch override data
|
||||
const overrideDataPromise = overrideSelection
|
||||
? fetchRemoteLyricsById({
|
||||
remoteSongId: overrideSelection.id,
|
||||
remoteSource: overrideSelection.source as LyricSource,
|
||||
song,
|
||||
})
|
||||
: null;
|
||||
|
||||
const [local, remoteAuto, overrideData] = await Promise.all([
|
||||
localPromise,
|
||||
remoteAutoPromise,
|
||||
overrideDataPromise,
|
||||
]);
|
||||
|
||||
const partial: Pick<
|
||||
LyricsQueryResult,
|
||||
| 'local'
|
||||
| 'overrideData'
|
||||
| 'overrideSelection'
|
||||
| 'remoteAuto'
|
||||
| 'selectedOffsetMs'
|
||||
> = {
|
||||
local,
|
||||
overrideData,
|
||||
overrideSelection,
|
||||
remoteAuto,
|
||||
selectedOffsetMs,
|
||||
};
|
||||
const { selected, selectedSynced } = computeSelectedFromResult(
|
||||
partial,
|
||||
preferLocalLyrics,
|
||||
selectedStructuredIndex,
|
||||
);
|
||||
const displayOffset = getDisplayOffset(
|
||||
selected,
|
||||
selectedOffsetMs,
|
||||
selectedStructuredIndex,
|
||||
local,
|
||||
);
|
||||
const resultSelectedOffsetMs = displayOffset;
|
||||
|
||||
return {
|
||||
...emptyResult(),
|
||||
...partial,
|
||||
selected,
|
||||
selectedOffsetMs: resultSelectedOffsetMs,
|
||||
selectedStructuredIndex,
|
||||
selectedSynced,
|
||||
suppressRemoteAuto,
|
||||
};
|
||||
},
|
||||
queryKey: queryKeys.songs.lyrics(args.serverId, args.query),
|
||||
queryKey: lyricsKey,
|
||||
staleTime: Infinity,
|
||||
...args.options,
|
||||
});
|
||||
@@ -191,15 +347,13 @@ export const lyricsQueries = {
|
||||
return queryOptions({
|
||||
gcTime: Infinity,
|
||||
queryFn: async () => {
|
||||
const remoteLyricsResult = await lyricsIpc?.getRemoteLyricsByRemoteId(
|
||||
args.query as any,
|
||||
);
|
||||
|
||||
if (remoteLyricsResult) {
|
||||
return formatLyrics(remoteLyricsResult);
|
||||
}
|
||||
|
||||
return null;
|
||||
const q = args.query;
|
||||
if (!q?.remoteSongId || !q?.remoteSource) return null;
|
||||
return fetchRemoteLyricsById({
|
||||
remoteSongId: q.remoteSongId,
|
||||
remoteSource: q.remoteSource as LyricSource,
|
||||
song: q.song as QueueSong | Song | undefined,
|
||||
});
|
||||
},
|
||||
queryKey: queryKeys.songs.lyricsByRemoteId(args.query),
|
||||
staleTime: Infinity,
|
||||
|
||||
Reference in New Issue
Block a user