diff --git a/src/hooks/useLyrics.ts b/src/hooks/useLyrics.ts index 3a6fa48b..22fdfc7d 100644 --- a/src/hooks/useLyrics.ts +++ b/src/hooks/useLyrics.ts @@ -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(); /** 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; diff --git a/src/utils/lyricsPersistentCache.ts b/src/utils/lyricsPersistentCache.ts new file mode 100644 index 00000000..14de2e52 --- /dev/null +++ b/src/utils/lyricsPersistentCache.ts @@ -0,0 +1,108 @@ +/** + * IndexedDB layer for lyrics caching. The RAM-level cache lives in + * `useLyrics.ts` as module state — this file only persists across app + * restarts. Reads are best-effort and fall back to the network fetch + * chain if anything goes wrong. + * + * Key format: `${serverId}:${songId}` so the same song id on two + * different Subsonic servers can't collide. + * + * TTL: + * - Found lyrics: 90 days. Lyrics rarely change once shipped. + * - notFound entries: 7 days. Lets the user / server admin add lyrics + * later without an indefinite negative cache. + */ +import type { CachedLyrics } from '../hooks/useLyrics'; + +const DB_NAME = 'psysonic-lyrics-cache'; +const STORE_NAME = 'lyrics'; +const TTL_FOUND_MS = 90 * 24 * 60 * 60 * 1000; +const TTL_NOT_FOUND_MS = 7 * 24 * 60 * 60 * 1000; + +interface StoredEntry { + key: string; + payload: CachedLyrics; + timestamp: number; +} + +let db: IDBDatabase | null = null; +let dbPromise: Promise | null = null; + +function openDB(): Promise { + if (db) return Promise.resolve(db); + if (dbPromise) return dbPromise; + dbPromise = new Promise(resolve => { + try { + const req = indexedDB.open(DB_NAME, 1); + req.onupgradeneeded = e => { + const database = (e.target as IDBOpenDBRequest).result; + if (!database.objectStoreNames.contains(STORE_NAME)) { + database.createObjectStore(STORE_NAME, { keyPath: 'key' }); + } + }; + req.onsuccess = e => { + db = (e.target as IDBOpenDBRequest).result; + resolve(db); + }; + req.onerror = () => resolve(null); + } catch { + resolve(null); + } + }); + return dbPromise; +} + +export function lyricsCacheKey(serverId: string, songId: string): string { + return `${serverId}:${songId}`; +} + +export async function getCachedLyrics(key: string): Promise { + try { + const database = await openDB(); + if (!database) return null; + return await new Promise(resolve => { + const req = database.transaction(STORE_NAME, 'readonly').objectStore(STORE_NAME).get(key); + req.onsuccess = () => { + const entry = req.result as StoredEntry | undefined; + if (!entry) { resolve(null); return; } + const ttl = entry.payload?.notFound ? TTL_NOT_FOUND_MS : TTL_FOUND_MS; + if (Date.now() - entry.timestamp > ttl) { resolve(null); return; } + resolve(entry.payload); + }; + req.onerror = () => resolve(null); + }); + } catch { + return null; + } +} + +export async function putCachedLyrics(key: string, payload: CachedLyrics): Promise { + try { + const database = await openDB(); + if (!database) return; + await new Promise(resolve => { + const tx = database.transaction(STORE_NAME, 'readwrite'); + tx.objectStore(STORE_NAME).put({ key, payload, timestamp: Date.now() } satisfies StoredEntry); + tx.oncomplete = () => resolve(); + tx.onerror = () => resolve(); + }); + } catch { + // Ignore — fall back to RAM-only behaviour. + } +} + +/** Wipes all entries — exposed for a future "clear cache" Settings action. */ +export async function clearLyricsCache(): Promise { + try { + const database = await openDB(); + if (!database) return; + await new Promise(resolve => { + const tx = database.transaction(STORE_NAME, 'readwrite'); + tx.objectStore(STORE_NAME).clear(); + tx.oncomplete = () => resolve(); + tx.onerror = () => resolve(); + }); + } catch { + // Ignore + } +}