mirror of
https://github.com/markuryy/shark.git
synced 2025-12-12 11:41:02 +00:00
feat(db): add tracks table and lyric scan caching
This commit is contained in:
@@ -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,
|
||||
}];
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
<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;
|
||||
|
||||
Reference in New Issue
Block a user