From 8fb27b1acddce7730e27f1e378ed99877b580907 Mon Sep 17 00:00:00 2001 From: Markury Date: Sun, 5 Oct 2025 00:17:19 -0400 Subject: [PATCH] feat(db): add tracks table and lyric scan caching --- src-tauri/src/lib.rs | 15 ++++ src/lib/library/database.ts | 92 +++++++++++++++++++++++++ src/lib/library/lyricScanner.ts | 36 ++++++++++ src/routes/services/lrclib/+page.svelte | 44 +++++++++++- 4 files changed, 185 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a8a20be..0538722 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -203,10 +203,25 @@ pub fn run() { FOREIGN KEY (artist_id) REFERENCES artists(id) ON DELETE CASCADE ); + CREATE TABLE IF NOT EXISTS tracks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + path TEXT NOT NULL UNIQUE, + title TEXT NOT NULL, + artist TEXT NOT NULL, + album TEXT NOT NULL, + duration INTEGER NOT NULL, + format TEXT NOT NULL, + has_lyrics INTEGER DEFAULT 0, + last_scanned INTEGER, + created_at INTEGER DEFAULT (strftime('%s', 'now')) + ); + CREATE INDEX IF NOT EXISTS idx_artists_name ON artists(name); CREATE INDEX IF NOT EXISTS idx_albums_artist_id ON albums(artist_id); CREATE INDEX IF NOT EXISTS idx_albums_year ON albums(year); CREATE INDEX IF NOT EXISTS idx_albums_artist_title ON albums(artist_name, title); + CREATE INDEX IF NOT EXISTS idx_tracks_has_lyrics ON tracks(has_lyrics); + CREATE INDEX IF NOT EXISTS idx_tracks_path ON tracks(path); ", kind: MigrationKind::Up, }]; diff --git a/src/lib/library/database.ts b/src/lib/library/database.ts index a3dada2..0b8a4bb 100644 --- a/src/lib/library/database.ts +++ b/src/lib/library/database.ts @@ -24,6 +24,19 @@ export interface DbAlbum { created_at: number; } +export interface DbTrack { + id: number; + path: string; + title: string; + artist: string; + album: string; + duration: number; + format: string; + has_lyrics: number; + last_scanned: number | null; + created_at: number; +} + let db: Database | null = null; /** @@ -231,3 +244,82 @@ export async function getLibraryStats(): Promise<{ trackCount: trackResult[0]?.total || 0 }; } + +/** + * Get all tracks without lyrics (has_lyrics = 0) + */ +export async function getTracksWithoutLyrics(): Promise { + const database = await initDatabase(); + const tracks = await database.select( + 'SELECT * FROM tracks WHERE has_lyrics = 0 ORDER BY artist COLLATE NOCASE, album COLLATE NOCASE, title COLLATE NOCASE' + ); + return tracks; +} + +/** + * Upsert a track (insert or update) + */ +export async function upsertTrack(track: { + path: string; + title: string; + artist: string; + album: string; + duration: number; + format: string; + has_lyrics: boolean; +}): Promise { + const database = await initDatabase(); + const now = Math.floor(Date.now() / 1000); + + await database.execute( + `INSERT INTO tracks (path, title, artist, album, duration, format, has_lyrics, last_scanned) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ON CONFLICT(path) DO UPDATE SET + title = $2, + artist = $3, + album = $4, + duration = $5, + format = $6, + has_lyrics = $7, + last_scanned = $8`, + [ + track.path, + track.title, + track.artist, + track.album, + track.duration, + track.format, + track.has_lyrics ? 1 : 0, + now + ] + ); +} + +/** + * Get the last scan timestamp for lyrics + */ +export async function getLyricsScanTimestamp(): Promise { + const database = await initDatabase(); + const result = await database.select<{ last_scanned: number | null }[]>( + 'SELECT MAX(last_scanned) as last_scanned FROM tracks' + ); + return result[0]?.last_scanned || null; +} + +/** + * Delete tracks that are no longer in the provided paths + */ +export async function deleteTracksNotInPaths(paths: string[]): Promise { + if (paths.length === 0) { + const database = await initDatabase(); + await database.execute('DELETE FROM tracks'); + return; + } + + const database = await initDatabase(); + const placeholders = paths.map((_, i) => `$${i + 1}`).join(','); + await database.execute( + `DELETE FROM tracks WHERE path NOT IN (${placeholders})`, + paths + ); +} diff --git a/src/lib/library/lyricScanner.ts b/src/lib/library/lyricScanner.ts index 9efbc1d..32009fe 100644 --- a/src/lib/library/lyricScanner.ts +++ b/src/lib/library/lyricScanner.ts @@ -5,6 +5,7 @@ import { readDir, exists, readFile } from '@tauri-apps/plugin-fs'; import { parseBuffer } from 'music-metadata'; import type { AudioFormat } from '$lib/types/track'; +import { upsertTrack, getTracksWithoutLyrics, type DbTrack } from '$lib/library/database'; export interface TrackWithoutLyrics { path: string; @@ -116,6 +117,7 @@ async function scanDirectoryForMissingLyrics( /** * Scan the music library for tracks without .lrc files + * Results are cached in the database */ export async function scanForTracksWithoutLyrics( musicFolderPath: string, @@ -129,9 +131,43 @@ export async function scanForTracksWithoutLyrics( await scanDirectoryForMissingLyrics(musicFolderPath, results); + // Save results to database + if (onProgress) { + onProgress(results.length, results.length, 'Caching results...'); + } + + for (const track of results) { + await upsertTrack({ + path: track.path, + title: track.title, + artist: track.artist, + album: track.album, + duration: Math.round(track.duration), + format: track.format, + has_lyrics: false + }); + } + if (onProgress) { onProgress(results.length, results.length, `Found ${results.length} tracks without lyrics`); } return results; } + +/** + * Load cached tracks without lyrics from database + */ +export async function loadCachedTracksWithoutLyrics(): Promise { + const dbTracks = await getTracksWithoutLyrics(); + + return dbTracks.map((track: DbTrack) => ({ + path: track.path, + filename: track.path.split('/').pop() || track.path, + title: track.title, + artist: track.artist, + album: track.album, + duration: track.duration, + format: track.format as AudioFormat + })); +} diff --git a/src/routes/services/lrclib/+page.svelte b/src/routes/services/lrclib/+page.svelte index e8e7bed..b1c2216 100644 --- a/src/routes/services/lrclib/+page.svelte +++ b/src/routes/services/lrclib/+page.svelte @@ -3,7 +3,8 @@ import { settings } from '$lib/stores/settings'; import { setSuccess, setWarning, setError, setInfo } from '$lib/stores/status'; import { checkApiStatus, fetchAndSaveLyrics } from '$lib/services/lrclib'; - import { scanForTracksWithoutLyrics, type TrackWithoutLyrics } from '$lib/library/lyricScanner'; + import { scanForTracksWithoutLyrics, loadCachedTracksWithoutLyrics, type TrackWithoutLyrics } from '$lib/library/lyricScanner'; + import { getLyricsScanTimestamp, upsertTrack } from '$lib/library/database'; import ContextMenu, { type MenuItem } from '$lib/components/ContextMenu.svelte'; type ViewMode = 'tracks' | 'info'; @@ -16,11 +17,22 @@ let tracks = $state([]); let selectedTrackIndex = $state(null); let contextMenu = $state<{ x: number; y: number; trackIndex: number } | null>(null); + let lastScanned = $state(null); onMount(async () => { await checkApi(); + await loadCachedResults(); }); + async function loadCachedResults() { + try { + tracks = await loadCachedTracksWithoutLyrics(); + lastScanned = await getLyricsScanTimestamp(); + } catch (error) { + console.error('[LRCLIB] Error loading cached results:', error); + } + } + async function checkApi() { checkingApi = true; apiAvailable = await checkApiStatus(); @@ -45,6 +57,7 @@ ); tracks = foundTracks; + lastScanned = await getLyricsScanTimestamp(); if (tracks.length === 0) { setInfo('All tracks have lyrics!'); @@ -72,6 +85,17 @@ }); if (result.success) { + // Update database to mark track as having lyrics + await upsertTrack({ + path: track.path, + title: track.title, + artist: track.artist, + album: track.album, + duration: Math.round(track.duration), + format: track.format, + has_lyrics: true + }); + if (result.instrumental) { setInfo(`Track marked as instrumental: ${track.title}`); } else if (result.hasLyrics) { @@ -194,7 +218,12 @@ {#if viewMode === 'tracks'}
- {tracks.length} track{tracks.length !== 1 ? 's' : ''} found +
+ {tracks.length} track{tracks.length !== 1 ? 's' : ''} found + {#if lastScanned} + Last scanned: {new Date(lastScanned * 1000).toLocaleString()} + {/if} +