perf(lyrics): persist resolved lyrics to IndexedDB across app restarts (#248)

The existing module-level Map<songId, CachedLyrics> only survived
within a session. After every app restart the lyrics fetch chain
(server → lrclib → netease) ran from scratch for every track the user
played, even ones whose lyrics were already resolved 5 minutes ago
before the previous session ended.

Add an IndexedDB layer that's only consulted on RAM miss:

- L1 (RAM): unchanged, sync, hit on tab switch / track repeat
- L2 (IndexedDB, async): hit after restart, hydrates RAM
- Network: unchanged fallback chain

Key format `${serverId}:${songId}` so the same songId on two different
Subsonic servers cannot collide.

TTLs: 90 days for resolved lyrics (they rarely change once shipped),
7 days for `notFound` entries (gives the user / admin a chance to add
lyrics later without an indefinite negative cache).

YouLyPlus mode skips L2 — a persisted entry from the standard pipeline
wouldn't have word-level sync, which is the whole point of lyricsplus.

Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Frank Stellmacher
2026-04-21 21:38:02 +02:00
committed by GitHub
parent a76616b342
commit 88cd09c0be
2 changed files with 137 additions and 2 deletions
+29 -2
View File
@@ -8,6 +8,7 @@ import { fetchLyricsPlus, hasWordSync } from '../api/lyricsplus';
import { useAuthStore } from '../store/authStore';
import { useOfflineStore } from '../store/offlineStore';
import { useHotCacheStore } from '../store/hotCacheStore';
import { getCachedLyrics, putCachedLyrics, lyricsCacheKey } from '../utils/lyricsPersistentCache';
import type { Track } from '../store/playerStore';
export type LyricsSource = 'server' | 'lrclib' | 'netease' | 'embedded' | 'lyricsplus';
@@ -38,7 +39,9 @@ export interface CachedLyrics {
notFound: boolean;
}
// Session-level cache — survives tab switches and component unmount/remount.
// L1 cache: RAM, survives tab switches and component remount within a session.
// L2 (IndexedDB) lives in `utils/lyricsPersistentCache.ts` — only touched on
// L1 miss so the common case (jumping back to a recent track) stays fully sync.
export const lyricsCache = new Map<string, CachedLyrics>();
/** Convert structured Subsonic lyrics (ms timestamps) into LrcLine[] or plain text. */
@@ -103,7 +106,7 @@ export function useLyrics(currentTrack: Track | null): UseLyricsResult {
setNotFound(false);
setLoading(true);
const store = (entry: CachedLyrics) => {
const applyEntry = (entry: CachedLyrics) => {
if (cancelled) return;
lyricsCache.set(currentTrack.id, entry);
setSyncedLines(entry.syncedLines);
@@ -114,6 +117,14 @@ export function useLyrics(currentTrack: Track | null): UseLyricsResult {
setLoading(false);
};
const store = (entry: CachedLyrics) => {
if (cancelled) return;
applyEntry(entry);
// Persist for the next session (fire-and-forget — failures are silent).
const serverId = useAuthStore.getState().activeServerId ?? '';
putCachedLyrics(lyricsCacheKey(serverId, currentTrack.id), entry);
};
// For offline / hot-cached tracks we have the file locally — read SYLT /
// SYNCEDLYRICS directly via Rust instead of relying on Navidrome's parsing.
// Fast path: both store lookups are synchronous; returns false immediately
@@ -243,6 +254,22 @@ export function useLyrics(currentTrack: Track | null): UseLyricsResult {
if (cancelled) return;
if (await fetchEmbedded()) return;
// L2: IndexedDB — re-hydrates RAM cache without a network roundtrip.
// Skip for 'lyricsplus' mode since the persisted entry might be from
// the standard pipeline (no word-level sync) and the user explicitly
// wants a fresh lyricsplus attempt.
if (lyricsMode !== 'lyricsplus') {
const serverId = useAuthStore.getState().activeServerId ?? '';
const persisted = await getCachedLyrics(lyricsCacheKey(serverId, currentTrack.id));
if (cancelled) return;
if (persisted) {
// Don't re-write to L2 (it's already there); just hydrate RAM + UI.
lyricsCache.set(currentTrack.id, persisted);
applyEntry(persisted);
return;
}
}
// YouLyPlus mode: try lyricsplus first, silent fallback to standard.
if (lyricsMode === 'lyricsplus') {
if (cancelled) return;