feat(db): add tracks table and lyric scan caching

This commit is contained in:
2025-10-05 00:17:19 -04:00
parent 25ce2d676e
commit 8fb27b1acd
4 changed files with 185 additions and 2 deletions

View File

@@ -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,
}];

View File

@@ -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<DbTrack[]> {
const database = await initDatabase();
const tracks = await database.select<DbTrack[]>(
'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<void> {
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<number | null> {
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<void> {
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
);
}

View File

@@ -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<TrackWithoutLyrics[]> {
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
}));
}

View File

@@ -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<TrackWithoutLyrics[]>([]);
let selectedTrackIndex = $state<number | null>(null);
let contextMenu = $state<{ x: number; y: number; trackIndex: number } | null>(null);
let lastScanned = $state<number | null>(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 View -->
<div class="tab-header">
<span>{tracks.length} track{tracks.length !== 1 ? 's' : ''} found</span>
<div class="header-left">
<span>{tracks.length} track{tracks.length !== 1 ? 's' : ''} found</span>
{#if lastScanned}
<span class="last-scanned">Last scanned: {new Date(lastScanned * 1000).toLocaleString()}</span>
{/if}
</div>
<div class="actions-row">
<button onclick={handleScan} disabled={scanning || !$settings.musicFolder}>
{scanning ? 'Scanning...' : 'Scan Library'}
@@ -337,6 +366,17 @@
flex-shrink: 0;
}
.header-left {
display: flex;
flex-direction: column;
gap: 4px;
}
.last-scanned {
font-size: 10px;
opacity: 0.6;
}
.status-row {
display: flex;
align-items: center;