mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +00:00
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:
committed by
GitHub
parent
a76616b342
commit
88cd09c0be
+29
-2
@@ -8,6 +8,7 @@ import { fetchLyricsPlus, hasWordSync } from '../api/lyricsplus';
|
|||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
import { useOfflineStore } from '../store/offlineStore';
|
import { useOfflineStore } from '../store/offlineStore';
|
||||||
import { useHotCacheStore } from '../store/hotCacheStore';
|
import { useHotCacheStore } from '../store/hotCacheStore';
|
||||||
|
import { getCachedLyrics, putCachedLyrics, lyricsCacheKey } from '../utils/lyricsPersistentCache';
|
||||||
import type { Track } from '../store/playerStore';
|
import type { Track } from '../store/playerStore';
|
||||||
|
|
||||||
export type LyricsSource = 'server' | 'lrclib' | 'netease' | 'embedded' | 'lyricsplus';
|
export type LyricsSource = 'server' | 'lrclib' | 'netease' | 'embedded' | 'lyricsplus';
|
||||||
@@ -38,7 +39,9 @@ export interface CachedLyrics {
|
|||||||
notFound: boolean;
|
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>();
|
export const lyricsCache = new Map<string, CachedLyrics>();
|
||||||
|
|
||||||
/** Convert structured Subsonic lyrics (ms timestamps) into LrcLine[] or plain text. */
|
/** Convert structured Subsonic lyrics (ms timestamps) into LrcLine[] or plain text. */
|
||||||
@@ -103,7 +106,7 @@ export function useLyrics(currentTrack: Track | null): UseLyricsResult {
|
|||||||
setNotFound(false);
|
setNotFound(false);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
const store = (entry: CachedLyrics) => {
|
const applyEntry = (entry: CachedLyrics) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
lyricsCache.set(currentTrack.id, entry);
|
lyricsCache.set(currentTrack.id, entry);
|
||||||
setSyncedLines(entry.syncedLines);
|
setSyncedLines(entry.syncedLines);
|
||||||
@@ -114,6 +117,14 @@ export function useLyrics(currentTrack: Track | null): UseLyricsResult {
|
|||||||
setLoading(false);
|
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 /
|
// For offline / hot-cached tracks we have the file locally — read SYLT /
|
||||||
// SYNCEDLYRICS directly via Rust instead of relying on Navidrome's parsing.
|
// SYNCEDLYRICS directly via Rust instead of relying on Navidrome's parsing.
|
||||||
// Fast path: both store lookups are synchronous; returns false immediately
|
// 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 (cancelled) return;
|
||||||
if (await fetchEmbedded()) 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.
|
// YouLyPlus mode: try lyricsplus first, silent fallback to standard.
|
||||||
if (lyricsMode === 'lyricsplus') {
|
if (lyricsMode === 'lyricsplus') {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
|||||||
@@ -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<IDBDatabase | null> | null = null;
|
||||||
|
|
||||||
|
function openDB(): Promise<IDBDatabase | null> {
|
||||||
|
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<CachedLyrics | null> {
|
||||||
|
try {
|
||||||
|
const database = await openDB();
|
||||||
|
if (!database) return null;
|
||||||
|
return await new Promise<CachedLyrics | null>(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<void> {
|
||||||
|
try {
|
||||||
|
const database = await openDB();
|
||||||
|
if (!database) return;
|
||||||
|
await new Promise<void>(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<void> {
|
||||||
|
try {
|
||||||
|
const database = await openDB();
|
||||||
|
if (!database) return;
|
||||||
|
await new Promise<void>(resolve => {
|
||||||
|
const tx = database.transaction(STORE_NAME, 'readwrite');
|
||||||
|
tx.objectStore(STORE_NAME).clear();
|
||||||
|
tx.oncomplete = () => resolve();
|
||||||
|
tx.onerror = () => resolve();
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user