fix lyrics fetch and clear (#1342)

- split server and remote lyrics into separate queries
- lyrics cache now always contain server lyrics, override will use separate remote query
- clear button is reverted to only clear the override query, and back to server
This commit is contained in:
jeffvli
2025-12-31 01:07:56 -08:00
parent 8eab4933ae
commit 255b9a9c2d
4 changed files with 207 additions and 92 deletions
+8
View File
@@ -383,6 +383,14 @@ export const queryKeys: Record<
lyricsByRemoteId: (searchQuery: { remoteSongId: string; remoteSource: LyricSource }) => { lyricsByRemoteId: (searchQuery: { remoteSongId: string; remoteSource: LyricSource }) => {
return ['song', 'lyrics', 'remote', searchQuery] as const; return ['song', 'lyrics', 'remote', searchQuery] as const;
}, },
remoteLyrics: (serverId: string, query?: LyricsQuery) => {
if (query) return [serverId, 'song', 'lyrics', 'remote', query] as const;
return [serverId, 'song', 'lyrics', 'remote'] as const;
},
serverLyrics: (serverId: string, query?: LyricsQuery) => {
if (query) return [serverId, 'song', 'lyrics', 'server', query] as const;
return [serverId, 'song', 'lyrics', 'server'] as const;
},
lyricsSearch: (query?: LyricSearchQuery) => { lyricsSearch: (query?: LyricSearchQuery) => {
if (query) return ['lyrics', 'search', query] as const; if (query) return ['lyrics', 'search', query] as const;
return ['lyrics', 'search'] as const; return ['lyrics', 'search'] as const;
+57 -48
View File
@@ -3,7 +3,7 @@ import isElectron from 'is-electron';
import { api } from '/@/renderer/api'; import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys'; import { queryKeys } from '/@/renderer/api/query-keys';
import { QueryHookArgs } from '/@/renderer/lib/react-query'; import { queryClient, QueryHookArgs } from '/@/renderer/lib/react-query';
import { getServerById, useSettingsStore } from '/@/renderer/store'; import { getServerById, useSettingsStore } from '/@/renderer/store';
import { hasFeature } from '/@/shared/api/utils'; import { hasFeature } from '/@/shared/api/utils';
import { import {
@@ -14,7 +14,6 @@ import {
LyricSearchQuery, LyricSearchQuery,
LyricsQuery, LyricsQuery,
QueueSong, QueueSong,
ServerType,
StructuredLyric, StructuredLyric,
SynchronizedLyricsArray, SynchronizedLyricsArray,
} from '/@/shared/types/domain-types'; } from '/@/shared/types/domain-types';
@@ -65,6 +64,34 @@ const formatLyrics = (lyrics: string) => {
}; };
export const lyricsQueries = { 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'>) => { search: (args: Omit<QueryHookArgs<LyricSearchQuery>, 'serverId'>) => {
return queryOptions({ return queryOptions({
gcTime: 1000 * 60 * 1, gcTime: 1000 * 60 * 1,
@@ -79,34 +106,15 @@ export const lyricsQueries = {
...args.options, ...args.options,
}); });
}, },
serverLyrics: (args: QueryHookArgs<LyricsQuery>) => { serverLyrics: (args: QueryHookArgs<LyricsQuery>, song: QueueSong | undefined) => {
return queryOptions({
queryFn: ({ signal }) => {
const server = getServerById(args.serverId);
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 api.controller.getLyrics({
apiClientProps: { serverId: args.serverId, signal },
query: args.query,
});
},
queryKey: queryKeys.songs.lyrics(args.serverId, args.query),
...args.options,
});
},
songLyrics: (args: QueryHookArgs<LyricsQuery>, song: QueueSong | undefined) => {
return queryOptions({ return queryOptions({
gcTime: Infinity, gcTime: Infinity,
queryFn: async ({ signal }): Promise<FullLyricsMetadata | null | StructuredLyric[]> => { queryFn: async ({ signal }): Promise<FullLyricsMetadata | null | StructuredLyric[]> => {
const server = getServerById(song?._serverId); const server = getServerById(args.serverId);
if (!server) throw new Error('Server not found'); if (!server) throw new Error('Server not found');
if (!song) return null; if (!song) return null;
const { preferLocalLyrics } = useSettingsStore.getState().lyrics;
let localLyrics: FullLyricsMetadata | null | StructuredLyric[] = null; let localLyrics: FullLyricsMetadata | null | StructuredLyric[] = null;
let remoteLyrics: FullLyricsMetadata | null | StructuredLyric[] = null;
if (hasFeature(server, ServerFeature.LYRICS_MULTIPLE_STRUCTURED)) { if (hasFeature(server, ServerFeature.LYRICS_MULTIPLE_STRUCTURED)) {
const subsonicLyrics = await api.controller const subsonicLyrics = await api.controller
@@ -146,34 +154,33 @@ export const lyricsQueries = {
}; };
} }
if (preferLocalLyrics && localLyrics) {
return localLyrics; return localLyrics;
} },
queryKey: queryKeys.songs.serverLyrics(args.serverId, args.query),
staleTime: Infinity,
...args.options,
});
},
songLyrics: (args: QueryHookArgs<LyricsQuery>, song: QueueSong | undefined) => {
return queryOptions({
gcTime: Infinity,
queryFn: async ({ signal }): Promise<FullLyricsMetadata | null | StructuredLyric[]> => {
if (!song) return null;
const { fetch } = useSettingsStore.getState().lyrics; const serverLyricsQuery = lyricsQueries.serverLyrics(args, song);
const serverLyrics = await queryClient.fetchQuery({
...serverLyricsQuery,
queryFn: async (context) => {
return (
serverLyricsQuery.queryFn?.({
...context,
signal,
}) ?? null
);
},
});
if (fetch) { return serverLyrics;
const remoteLyricsResult: InternetProviderLyricResponse | null =
await lyricsIpc?.getRemoteLyricsBySong(song);
if (remoteLyricsResult) {
remoteLyrics = {
...remoteLyricsResult,
lyrics: formatLyrics(remoteLyricsResult.lyrics),
remote: true,
};
}
}
if (remoteLyrics) {
return remoteLyrics;
}
if (localLyrics) {
return localLyrics;
}
return null;
}, },
queryKey: queryKeys.songs.lyrics(args.serverId, args.query), queryKey: queryKeys.songs.lyrics(args.serverId, args.query),
staleTime: Infinity, staleTime: Infinity,
@@ -182,6 +189,7 @@ export const lyricsQueries = {
}, },
songLyricsByRemoteId: (args: QueryHookArgs<Partial<LyricGetQuery>>) => { songLyricsByRemoteId: (args: QueryHookArgs<Partial<LyricGetQuery>>) => {
return queryOptions({ return queryOptions({
gcTime: Infinity,
queryFn: async () => { queryFn: async () => {
const remoteLyricsResult = await lyricsIpc?.getRemoteLyricsByRemoteId( const remoteLyricsResult = await lyricsIpc?.getRemoteLyricsByRemoteId(
args.query as any, args.query as any,
@@ -194,6 +202,7 @@ export const lyricsQueries = {
return null; return null;
}, },
queryKey: queryKeys.songs.lyricsByRemoteId(args.query), queryKey: queryKeys.songs.lyricsByRemoteId(args.query),
staleTime: Infinity,
...args.options, ...args.options,
}); });
}, },
@@ -62,7 +62,12 @@ export const LyricsActions = ({
value={index.toString()} value={index.toString()}
/> />
)} )}
<Button onClick={onExportLyrics} uppercase variant="subtle"> <Button
onClick={onExportLyrics}
size="compact-sm"
uppercase
variant="subtle"
>
{t('form.lyricsExport.export', { postProcess: 'sentenceCase ' })} {t('form.lyricsExport.export', { postProcess: 'sentenceCase ' })}
</Button> </Button>
</Center> </Center>
+135 -42
View File
@@ -22,7 +22,7 @@ import { openLyricsSettingsModal } from '/@/renderer/features/lyrics/utils/open-
import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events'; import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events';
import { ComponentErrorBoundary } from '/@/renderer/features/shared/components/component-error-boundary'; import { ComponentErrorBoundary } from '/@/renderer/features/shared/components/component-error-boundary';
import { queryClient } from '/@/renderer/lib/react-query'; import { queryClient } from '/@/renderer/lib/react-query';
import { useLyricsSettings, usePlayerSong } from '/@/renderer/store'; import { useLyricsSettings, usePlayerSong, useSettingsStore } from '/@/renderer/store';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Center } from '/@/shared/components/center/center'; import { Center } from '/@/shared/components/center/center';
import { Group } from '/@/shared/components/group/group'; import { Group } from '/@/shared/components/group/group';
@@ -44,6 +44,7 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default'
const currentSong = usePlayerSong(); const currentSong = usePlayerSong();
const { const {
enableAutoTranslation, enableAutoTranslation,
preferLocalLyrics,
translationApiKey, translationApiKey,
translationApiProvider, translationApiProvider,
translationTargetLanguage, translationTargetLanguage,
@@ -98,11 +99,68 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default'
); );
const [override, setOverride] = useState<LyricsOverride | undefined>(undefined); const [override, setOverride] = useState<LyricsOverride | undefined>(undefined);
const [autoRemoteLyrics, setAutoRemoteLyrics] = useState<FullLyricsMetadata | null>(null);
const clearedOverrideRef = useRef<string | undefined>(undefined);
// Fetch remote lyrics automatically (but not if we've explicitly cleared the override)
const { data: remoteLyricsData } = useQuery(
lyricsQueries.remoteLyrics(
{
options: {
enabled:
!!pendingSongId &&
pendingSongId === currentSong?.id &&
!override &&
clearedOverrideRef.current !== currentSong?.id &&
useSettingsStore.getState().lyrics.fetch,
},
query: { songId: currentSong?.id || '' },
serverId: currentSong?._serverId || '',
},
currentSong,
),
);
// Automatically set remote lyrics as override when fetched
// Only auto-apply if preferLocalLyrics is disabled OR if local lyrics are not available
useEffect(() => {
// Don't auto-set if we've explicitly cleared the override for this song
if (
remoteLyricsData &&
!override &&
currentSong &&
clearedOverrideRef.current !== currentSong.id
) {
// If preferLocalLyrics is enabled, wait for local lyrics to finish loading
if (preferLocalLyrics && isInitialLoading) {
return;
}
// Check if local lyrics are available
const hasLocalLyrics =
(Array.isArray(data) && data.length > 0) ||
(data && !Array.isArray(data) && data.lyrics);
// Only auto-apply remote lyrics if:
// 1. preferLocalLyrics is disabled, OR
// 2. preferLocalLyrics is enabled but no local lyrics are available
if (!preferLocalLyrics || !hasLocalLyrics) {
// Store the remote lyrics data directly (it already contains the lyrics)
setAutoRemoteLyrics(remoteLyricsData);
} else {
// Clear auto remote lyrics if local lyrics are preferred and available
setAutoRemoteLyrics(null);
}
} else if (!remoteLyricsData || override) {
// Clear auto remote lyrics if override is set or remote lyrics are cleared
setAutoRemoteLyrics(null);
}
}, [remoteLyricsData, override, currentSong, preferLocalLyrics, data, isInitialLoading]);
const { data: overrideData, isInitialLoading: isOverrideLoading } = useQuery( const { data: overrideData, isInitialLoading: isOverrideLoading } = useQuery(
lyricsQueries.songLyricsByRemoteId({ lyricsQueries.songLyricsByRemoteId({
options: { options: {
enabled: !!override, enabled: !!override && !!override.id,
}, },
query: { query: {
remoteSongId: override?.id, remoteSongId: override?.id,
@@ -127,8 +185,8 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default'
}, [data, index]); }, [data, index]);
const [lyrics, synced] = useMemo(() => { const [lyrics, synced] = useMemo(() => {
// If override data is available, use it // If override data is available, use it (manual override always takes priority)
if (override && overrideData) { if (override && overrideData && override.id) {
const overrideLyrics: FullLyricsMetadata = { const overrideLyrics: FullLyricsMetadata = {
artist: override.artist, artist: override.artist,
lyrics: overrideData, lyrics: overrideData,
@@ -137,21 +195,42 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default'
remote: override.remote ?? true, remote: override.remote ?? true,
source: override.source, source: override.source,
}; };
return [overrideLyrics, Array.isArray(overrideData)]; return [overrideLyrics, Array.isArray(overrideData), 'override'] as const;
} }
// Otherwise, use the regular data // Check if local lyrics are available
const hasLocalLyrics =
(Array.isArray(data) && data.length > 0) ||
(data && !Array.isArray(data) && data.lyrics);
// If preferLocalLyrics is enabled and local lyrics are available, prioritize them
if (preferLocalLyrics && hasLocalLyrics) {
if (Array.isArray(data)) {
const selectedLyric = data[Math.min(index, data.length - 1)];
return [selectedLyric, selectedLyric.synced, 'server'] as const;
} else if (data?.lyrics) {
return [data, Array.isArray(data.lyrics), 'server'] as const;
}
}
// If auto-fetched remote lyrics are available, use them
// (This will only be set if preferLocalLyrics is disabled OR no local lyrics exist)
if (autoRemoteLyrics) {
return [autoRemoteLyrics, Array.isArray(autoRemoteLyrics.lyrics), 'override'] as const;
}
// Otherwise, use the server-side lyrics data
if (Array.isArray(data)) { if (Array.isArray(data)) {
if (data.length > 0) { if (data.length > 0) {
const selectedLyric = data[Math.min(index, data.length - 1)]; const selectedLyric = data[Math.min(index, data.length - 1)];
return [selectedLyric, selectedLyric.synced]; return [selectedLyric, selectedLyric.synced, 'server'] as const;
} }
} else if (data?.lyrics) { } else if (data?.lyrics) {
return [data, Array.isArray(data.lyrics)]; return [data, Array.isArray(data.lyrics), 'server'] as const;
} }
return [undefined, false]; return [undefined, false, 'server'] as const;
}, [data, index, override, overrideData, currentOffsetMs]); }, [data, index, override, overrideData, autoRemoteLyrics, currentOffsetMs, preferLocalLyrics]);
const handleOnSearchOverride = useCallback((params: LyricsOverride) => { const handleOnSearchOverride = useCallback((params: LyricsOverride) => {
setOverride(params); setOverride(params);
@@ -159,7 +238,7 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default'
// Persist override lyrics to cache with current offset // Persist override lyrics to cache with current offset
useEffect(() => { useEffect(() => {
if (override && overrideData && currentSong) { if (override && overrideData && currentSong && override.id) {
const persistedLyrics: FullLyricsMetadata = { const persistedLyrics: FullLyricsMetadata = {
artist: override.artist, artist: override.artist,
lyrics: overrideData, lyrics: overrideData,
@@ -211,46 +290,58 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default'
[currentSong, index], [currentSong, index],
); );
// const handleOnResetLyric = useCallback(() => { const handleOnRemoveLyric = useCallback(async () => {
// setOverride(undefined); if (!currentSong) return;
// queryClient.invalidateQueries({
// exact: true,
// queryKey: queryKeys.songs.lyrics(currentSong?._serverId, { songId: currentSong?.id }),
// });
// }, [currentSong?.id, currentSong?._serverId]);
const handleOnRemoveLyric = useCallback(() => { const currentOverride = override;
clearedOverrideRef.current = currentSong.id;
// Clear the override state and auto remote lyrics
setOverride(undefined); setOverride(undefined);
setAutoRemoteLyrics(null);
// Clear the main lyrics query cache
queryClient.setQueryData(
queryKeys.songs.lyrics(currentSong?._serverId, { songId: currentSong?.id }),
(prev: FullLyricsMetadata | StructuredLyric[] | undefined) => {
if (!prev) {
return undefined;
}
if (Array.isArray(prev)) {
return undefined;
}
return {
...prev,
lyrics: '',
};
},
);
// Clear the override query cache if it exists // Clear the override query cache if it exists
if (override) { if (currentOverride?.id) {
queryClient.removeQueries({ queryClient.removeQueries({
queryKey: queryKeys.songs.lyricsByRemoteId({ queryKey: queryKeys.songs.lyricsByRemoteId({
remoteSongId: override.id, remoteSongId: currentOverride.id,
remoteSource: override.source, remoteSource: currentOverride.source,
}), }),
}); });
} }
}, [currentSong?.id, currentSong?._serverId, override]);
// Clear the remote lyrics cache
queryClient.removeQueries({
queryKey: queryKeys.songs.remoteLyrics(currentSong._serverId, {
songId: currentSong.id,
}),
});
// Clear the server lyrics cache so it refetches from server
const serverLyricsQueryKey = queryKeys.songs.serverLyrics(currentSong._serverId, {
songId: currentSong.id,
});
queryClient.removeQueries({
queryKey: serverLyricsQueryKey,
});
// Remove the main lyrics query cache
const lyricsQueryKey = queryKeys.songs.lyrics(currentSong._serverId, {
songId: currentSong.id,
});
queryClient.removeQueries({
exact: true,
queryKey: lyricsQueryKey,
});
// Refetch server lyrics first, then song lyrics to ensure fresh data from server
await queryClient.refetchQueries({
queryKey: serverLyricsQueryKey,
});
await queryClient.refetchQueries({
queryKey: lyricsQueryKey,
});
}, [currentSong, override]);
const fetchTranslation = useCallback(async () => { const fetchTranslation = useCallback(async () => {
if (!lyrics) return; if (!lyrics) return;
@@ -279,6 +370,8 @@ export const Lyrics = ({ fadeOutNoLyricsMessage = true, settingsKey = 'default'
{ {
onCurrentSongChange: () => { onCurrentSongChange: () => {
setOverride(undefined); setOverride(undefined);
setAutoRemoteLyrics(null);
clearedOverrideRef.current = undefined;
setIndex(0); setIndex(0);
setShowTranslation(false); setShowTranslation(false);
setTranslatedLyrics(null); setTranslatedLyrics(null);