mirror of
https://github.com/markuryy/shark.git
synced 2025-12-12 19:51:01 +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
|
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_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_artist_id ON albums(artist_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_albums_year ON albums(year);
|
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_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,
|
kind: MigrationKind::Up,
|
||||||
}];
|
}];
|
||||||
|
|||||||
@@ -24,6 +24,19 @@ export interface DbAlbum {
|
|||||||
created_at: number;
|
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;
|
let db: Database | null = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -231,3 +244,82 @@ export async function getLibraryStats(): Promise<{
|
|||||||
trackCount: trackResult[0]?.total || 0
|
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 { readDir, exists, readFile } from '@tauri-apps/plugin-fs';
|
||||||
import { parseBuffer } from 'music-metadata';
|
import { parseBuffer } from 'music-metadata';
|
||||||
import type { AudioFormat } from '$lib/types/track';
|
import type { AudioFormat } from '$lib/types/track';
|
||||||
|
import { upsertTrack, getTracksWithoutLyrics, type DbTrack } from '$lib/library/database';
|
||||||
|
|
||||||
export interface TrackWithoutLyrics {
|
export interface TrackWithoutLyrics {
|
||||||
path: string;
|
path: string;
|
||||||
@@ -116,6 +117,7 @@ async function scanDirectoryForMissingLyrics(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Scan the music library for tracks without .lrc files
|
* Scan the music library for tracks without .lrc files
|
||||||
|
* Results are cached in the database
|
||||||
*/
|
*/
|
||||||
export async function scanForTracksWithoutLyrics(
|
export async function scanForTracksWithoutLyrics(
|
||||||
musicFolderPath: string,
|
musicFolderPath: string,
|
||||||
@@ -129,9 +131,43 @@ export async function scanForTracksWithoutLyrics(
|
|||||||
|
|
||||||
await scanDirectoryForMissingLyrics(musicFolderPath, results);
|
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) {
|
if (onProgress) {
|
||||||
onProgress(results.length, results.length, `Found ${results.length} tracks without lyrics`);
|
onProgress(results.length, results.length, `Found ${results.length} tracks without lyrics`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return results;
|
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 { settings } from '$lib/stores/settings';
|
||||||
import { setSuccess, setWarning, setError, setInfo } from '$lib/stores/status';
|
import { setSuccess, setWarning, setError, setInfo } from '$lib/stores/status';
|
||||||
import { checkApiStatus, fetchAndSaveLyrics } from '$lib/services/lrclib';
|
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';
|
import ContextMenu, { type MenuItem } from '$lib/components/ContextMenu.svelte';
|
||||||
|
|
||||||
type ViewMode = 'tracks' | 'info';
|
type ViewMode = 'tracks' | 'info';
|
||||||
@@ -16,11 +17,22 @@
|
|||||||
let tracks = $state<TrackWithoutLyrics[]>([]);
|
let tracks = $state<TrackWithoutLyrics[]>([]);
|
||||||
let selectedTrackIndex = $state<number | null>(null);
|
let selectedTrackIndex = $state<number | null>(null);
|
||||||
let contextMenu = $state<{ x: number; y: number; trackIndex: number } | null>(null);
|
let contextMenu = $state<{ x: number; y: number; trackIndex: number } | null>(null);
|
||||||
|
let lastScanned = $state<number | null>(null);
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
await checkApi();
|
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() {
|
async function checkApi() {
|
||||||
checkingApi = true;
|
checkingApi = true;
|
||||||
apiAvailable = await checkApiStatus();
|
apiAvailable = await checkApiStatus();
|
||||||
@@ -45,6 +57,7 @@
|
|||||||
);
|
);
|
||||||
|
|
||||||
tracks = foundTracks;
|
tracks = foundTracks;
|
||||||
|
lastScanned = await getLyricsScanTimestamp();
|
||||||
|
|
||||||
if (tracks.length === 0) {
|
if (tracks.length === 0) {
|
||||||
setInfo('All tracks have lyrics!');
|
setInfo('All tracks have lyrics!');
|
||||||
@@ -72,6 +85,17 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (result.success) {
|
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) {
|
if (result.instrumental) {
|
||||||
setInfo(`Track marked as instrumental: ${track.title}`);
|
setInfo(`Track marked as instrumental: ${track.title}`);
|
||||||
} else if (result.hasLyrics) {
|
} else if (result.hasLyrics) {
|
||||||
@@ -194,7 +218,12 @@
|
|||||||
{#if viewMode === 'tracks'}
|
{#if viewMode === 'tracks'}
|
||||||
<!-- Tracks View -->
|
<!-- Tracks View -->
|
||||||
<div class="tab-header">
|
<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">
|
<div class="actions-row">
|
||||||
<button onclick={handleScan} disabled={scanning || !$settings.musicFolder}>
|
<button onclick={handleScan} disabled={scanning || !$settings.musicFolder}>
|
||||||
{scanning ? 'Scanning...' : 'Scan Library'}
|
{scanning ? 'Scanning...' : 'Scan Library'}
|
||||||
@@ -337,6 +366,17 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.last-scanned {
|
||||||
|
font-size: 10px;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
.status-row {
|
.status-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
Reference in New Issue
Block a user