From 8391897f54134fb0529d50425a2b4ea38d20bb48 Mon Sep 17 00:00:00 2001 From: Markury Date: Wed, 1 Oct 2025 20:02:57 -0400 Subject: [PATCH] feat(library): add sqlite-backed library sync and stats BREAKING CHANGE: Library data is now stored in a database and will require an initial sync. Existing in-memory library data is no longer used. --- bun.lock | 2 +- package.json | 2 +- src-tauri/Cargo.lock | 15 ++ src-tauri/Cargo.toml | 2 +- src-tauri/capabilities/default.json | 3 +- src-tauri/src/lib.rs | 47 ++++- src/lib/library/database.ts | 229 ++++++++++++++++++++++ src/lib/library/sync.ts | 284 ++++++++++++++++++++++++++++ src/routes/library/+page.svelte | 178 +++++++++++++++-- src/routes/settings/+page.svelte | 40 +++- 10 files changed, 783 insertions(+), 19 deletions(-) create mode 100644 src/lib/library/database.ts create mode 100644 src/lib/library/sync.ts diff --git a/bun.lock b/bun.lock index 941b391..0951ea6 100644 --- a/bun.lock +++ b/bun.lock @@ -10,7 +10,7 @@ "@tauri-apps/plugin-fs": "^2.4.2", "@tauri-apps/plugin-http": "~2", "@tauri-apps/plugin-opener": "^2", - "@tauri-apps/plugin-sql": "~2", + "@tauri-apps/plugin-sql": "^2.3.0", "@tauri-apps/plugin-store": "~2", "blowfish-node": "^1.1.4", "browser-id3-writer": "^6.3.1", diff --git a/package.json b/package.json index f44fbea..d8cc4d6 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "@tauri-apps/plugin-fs": "^2.4.2", "@tauri-apps/plugin-http": "~2", "@tauri-apps/plugin-opener": "^2", - "@tauri-apps/plugin-sql": "~2", + "@tauri-apps/plugin-sql": "^2.3.0", "@tauri-apps/plugin-store": "~2", "blowfish-node": "^1.1.4", "browser-id3-writer": "^6.3.1", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index c764b2a..9d36c3e 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2247,6 +2247,7 @@ version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" dependencies = [ + "cc", "pkg-config", "vcpkg", ] @@ -4153,6 +4154,8 @@ dependencies = [ "smallvec", "thiserror 2.0.17", "time", + "tokio", + "tokio-stream", "tracing", "url", ] @@ -4191,6 +4194,7 @@ dependencies = [ "sqlx-postgres", "sqlx-sqlite", "syn 2.0.106", + "tokio", "url", ] @@ -5019,6 +5023,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.16" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 04737f2..951edcc 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -26,5 +26,5 @@ tauri-plugin-fs = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" tauri-plugin-http = { version = "2", features = ["unsafe-headers"] } -tauri-plugin-sql = "2" +tauri-plugin-sql = { version = "2", features = ["sqlite"] } diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index d6f12ff..65c1d7b 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -55,6 +55,7 @@ } ] }, - "sql:default" + "sql:default", + "sql:allow-execute" ] } \ No newline at end of file diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8aab33e..c6eabc8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,3 +1,5 @@ +use tauri_plugin_sql::{Migration, MigrationKind}; + // Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ #[tauri::command] fn greet(name: &str) -> String { @@ -6,8 +8,51 @@ fn greet(name: &str) -> String { #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { + let migrations = vec![ + Migration { + version: 1, + description: "create_library_tables", + sql: " + CREATE TABLE IF NOT EXISTS artists ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + path TEXT NOT NULL UNIQUE, + album_count INTEGER DEFAULT 0, + track_count INTEGER DEFAULT 0, + primary_cover_path TEXT, + last_scanned INTEGER, + created_at INTEGER DEFAULT (strftime('%s', 'now')) + ); + + CREATE TABLE IF NOT EXISTS albums ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + artist_id INTEGER NOT NULL, + artist_name TEXT NOT NULL, + title TEXT NOT NULL, + path TEXT NOT NULL UNIQUE, + cover_path TEXT, + track_count INTEGER DEFAULT 0, + year INTEGER, + last_scanned INTEGER, + created_at INTEGER DEFAULT (strftime('%s', 'now')), + FOREIGN KEY (artist_id) REFERENCES artists(id) ON DELETE CASCADE + ); + + 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); + ", + kind: MigrationKind::Up, + } + ]; + tauri::Builder::default() - .plugin(tauri_plugin_sql::Builder::new().build()) + .plugin( + tauri_plugin_sql::Builder::new() + .add_migrations("sqlite:library.db", migrations) + .build() + ) .plugin(tauri_plugin_http::init()) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_store::Builder::new().build()) diff --git a/src/lib/library/database.ts b/src/lib/library/database.ts new file mode 100644 index 0000000..be8a136 --- /dev/null +++ b/src/lib/library/database.ts @@ -0,0 +1,229 @@ +import Database from '@tauri-apps/plugin-sql'; + +export interface DbArtist { + id: number; + name: string; + path: string; + album_count: number; + track_count: number; + primary_cover_path: string | null; + last_scanned: number | null; + created_at: number; +} + +export interface DbAlbum { + id: number; + artist_id: number; + artist_name: string; + title: string; + path: string; + cover_path: string | null; + track_count: number; + year: number | null; + last_scanned: number | null; + created_at: number; +} + +let db: Database | null = null; + +/** + * Initialize database connection + */ +export async function initDatabase(): Promise { + if (!db) { + db = await Database.load('sqlite:library.db'); + } + return db; +} + +/** + * Get all artists with their albums + */ +export async function getArtistsWithAlbums(): Promise { + const database = await initDatabase(); + const artists = await database.select( + 'SELECT * FROM artists ORDER BY name COLLATE NOCASE' + ); + return artists; +} + +/** + * Get all albums for a specific artist + */ +export async function getAlbumsByArtist(artistId: number): Promise { + const database = await initDatabase(); + const albums = await database.select( + 'SELECT * FROM albums WHERE artist_id = $1 ORDER BY title COLLATE NOCASE', + [artistId] + ); + return albums; +} + +/** + * Get all albums across all artists + */ +export async function getAllAlbums(): Promise { + const database = await initDatabase(); + const albums = await database.select( + 'SELECT * FROM albums ORDER BY artist_name COLLATE NOCASE, title COLLATE NOCASE' + ); + return albums; +} + +/** + * Get a specific album by path + */ +export async function getAlbumByPath(path: string): Promise { + const database = await initDatabase(); + const albums = await database.select( + 'SELECT * FROM albums WHERE path = $1', + [path] + ); + return albums[0] || null; +} + +/** + * Upsert an artist (insert or update) + */ +export async function upsertArtist(artist: { + name: string; + path: string; + album_count: number; + track_count: number; + primary_cover_path?: string | null; +}): Promise { + const database = await initDatabase(); + const now = Math.floor(Date.now() / 1000); + + const result = await database.execute( + `INSERT INTO artists (name, path, album_count, track_count, primary_cover_path, last_scanned) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT(path) DO UPDATE SET + name = $1, + album_count = $3, + track_count = $4, + primary_cover_path = $5, + last_scanned = $6`, + [ + artist.name, + artist.path, + artist.album_count, + artist.track_count, + artist.primary_cover_path || null, + now + ] + ); + + // Get the artist ID + const artists = await database.select( + 'SELECT id FROM artists WHERE path = $1', + [artist.path] + ); + + return artists[0]?.id || result.lastInsertId; +} + +/** + * Upsert an album (insert or update) + */ +export async function upsertAlbum(album: { + artist_id: number; + artist_name: string; + title: string; + path: string; + cover_path?: string | null; + track_count: number; + year?: number | null; +}): Promise { + const database = await initDatabase(); + const now = Math.floor(Date.now() / 1000); + + await database.execute( + `INSERT INTO albums (artist_id, artist_name, title, path, cover_path, track_count, year, last_scanned) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ON CONFLICT(path) DO UPDATE SET + artist_id = $1, + artist_name = $2, + title = $3, + cover_path = $5, + track_count = $6, + year = $7, + last_scanned = $8`, + [ + album.artist_id, + album.artist_name, + album.title, + album.path, + album.cover_path || null, + album.track_count, + album.year || null, + now + ] + ); +} + +/** + * Delete albums that no longer exist on filesystem + */ +export async function deleteAlbumsNotInPaths(paths: string[]): Promise { + if (paths.length === 0) { + // Clear all albums + const database = await initDatabase(); + await database.execute('DELETE FROM albums'); + return; + } + + const database = await initDatabase(); + const placeholders = paths.map((_, i) => `$${i + 1}`).join(','); + await database.execute( + `DELETE FROM albums WHERE path NOT IN (${placeholders})`, + paths + ); +} + +/** + * Delete artists that have no albums + */ +export async function deleteOrphanedArtists(): Promise { + const database = await initDatabase(); + await database.execute( + 'DELETE FROM artists WHERE id NOT IN (SELECT DISTINCT artist_id FROM albums)' + ); +} + +/** + * Clear all library data + */ +export async function clearLibrary(): Promise { + const database = await initDatabase(); + await database.execute('DELETE FROM albums'); + await database.execute('DELETE FROM artists'); + await database.execute('VACUUM'); // Reclaim disk space +} + +/** + * Get library statistics + */ +export async function getLibraryStats(): Promise<{ + artistCount: number; + albumCount: number; + trackCount: number; +}> { + const database = await initDatabase(); + + const artistResult = await database.select<{ count: number }[]>( + 'SELECT COUNT(*) as count FROM artists' + ); + const albumResult = await database.select<{ count: number }[]>( + 'SELECT COUNT(*) as count FROM albums' + ); + const trackResult = await database.select<{ total: number }[]>( + 'SELECT SUM(track_count) as total FROM albums' + ); + + return { + artistCount: artistResult[0]?.count || 0, + albumCount: albumResult[0]?.count || 0, + trackCount: trackResult[0]?.total || 0 + }; +} diff --git a/src/lib/library/sync.ts b/src/lib/library/sync.ts new file mode 100644 index 0000000..22317bf --- /dev/null +++ b/src/lib/library/sync.ts @@ -0,0 +1,284 @@ +import { readDir, readFile } from '@tauri-apps/plugin-fs'; +import { parseBuffer } from 'music-metadata'; +import type { AudioFormat } from '$lib/types/track'; +import { + upsertArtist, + upsertAlbum, + deleteAlbumsNotInPaths, + deleteOrphanedArtists, + initDatabase +} from '$lib/library/database'; + +interface ScanProgress { + status: 'scanning' | 'syncing' | 'complete' | 'error'; + current: number; + total: number; + message?: string; +} + +export type ProgressCallback = (progress: ScanProgress) => void; + +/** + * Get audio format from file extension + */ +function getAudioFormat(filename: string): AudioFormat { + const ext = filename.toLowerCase().split('.').pop(); + switch (ext) { + case 'flac': + return 'flac'; + case 'mp3': + return 'mp3'; + case 'opus': + return 'opus'; + case 'ogg': + return 'ogg'; + case 'm4a': + return 'm4a'; + case 'wav': + return 'wav'; + default: + return 'unknown'; + } +} + +/** + * Find cover art image in album directory + */ +async function findAlbumArt(albumPath: string): Promise { + try { + const entries = await readDir(albumPath); + const imageExtensions = ['.jpg', '.jpeg', '.png', '.JPG', '.JPEG', '.PNG']; + + for (const entry of entries) { + if (!entry.isDirectory) { + const hasImageExt = imageExtensions.some(ext => entry.name.endsWith(ext)); + if (hasImageExt) { + return `${albumPath}/${entry.name}`; + } + } + } + return undefined; + } catch (error) { + console.error('Error finding album art:', error); + return undefined; + } +} + +/** + * Read year from first audio file in album directory + */ +async function getAlbumYear(albumPath: string): Promise { + try { + const entries = await readDir(albumPath); + const audioExtensions = ['.flac', '.mp3', '.opus', '.ogg', '.m4a', '.wav', '.FLAC', '.MP3']; + + // Find first audio file + for (const entry of entries) { + if (!entry.isDirectory) { + const hasAudioExt = audioExtensions.some(ext => entry.name.endsWith(ext)); + if (hasAudioExt) { + const filePath = `${albumPath}/${entry.name}`; + const format = getAudioFormat(entry.name); + + // Read file and parse metadata + const fileData = await readFile(filePath); + const mimeMap: Record = { + 'flac': 'audio/flac', + 'mp3': 'audio/mpeg', + 'opus': 'audio/opus', + 'ogg': 'audio/ogg', + 'm4a': 'audio/mp4', + 'wav': 'audio/wav', + 'unknown': 'audio/mpeg' + }; + + const metadata = await parseBuffer(fileData, mimeMap[format]); + return metadata.common.year; + } + } + } + return undefined; + } catch (error) { + // Don't log errors for year reading (non-critical) + return undefined; + } +} + +/** + * Count audio files in album directory + */ +async function countTracks(albumPath: string): Promise { + try { + const entries = await readDir(albumPath); + const audioExtensions = ['.flac', '.mp3', '.opus', '.ogg', '.m4a', '.wav', '.FLAC', '.MP3']; + + let count = 0; + for (const entry of entries) { + if (!entry.isDirectory) { + const hasAudioExt = audioExtensions.some(ext => entry.name.endsWith(ext)); + if (hasAudioExt) { + count++; + } + } + } + return count; + } catch (error) { + console.error('Error counting tracks:', error); + return 0; + } +} + +/** + * Scan and sync music library to database + * This is an incremental sync that only updates changed folders + */ +export async function syncLibraryToDatabase( + musicFolderPath: string, + onProgress?: ProgressCallback +): Promise { + try { + // Initialize database + await initDatabase(); + + onProgress?.({ + status: 'scanning', + current: 0, + total: 0, + message: 'Scanning music folder...' + }); + + // Get all artist folders + const artistEntries = await readDir(musicFolderPath); + const artistFolders = artistEntries.filter( + e => e.isDirectory && e.name !== '_temp' + ); + + // Count total albums for progress tracking + let totalAlbums = 0; + const artistAlbumCounts = new Map(); + + for (const artistEntry of artistFolders) { + const artistPath = `${musicFolderPath}/${artistEntry.name}`; + try { + const albumEntries = await readDir(artistPath); + const albumCount = albumEntries.filter(e => e.isDirectory).length; + artistAlbumCounts.set(artistEntry.name, albumCount); + totalAlbums += albumCount; + } catch (error) { + console.error(`Error reading artist folder ${artistPath}:`, error); + } + } + + onProgress?.({ + status: 'syncing', + current: 0, + total: totalAlbums, + message: 'Syncing library...' + }); + + let processedAlbums = 0; + const allAlbumPaths: string[] = []; + + // Process each artist + for (const artistEntry of artistFolders) { + const artistPath = `${musicFolderPath}/${artistEntry.name}`; + const albumEntries = await readDir(artistPath); + const albumFolders = albumEntries.filter(e => e.isDirectory); + + if (albumFolders.length === 0) { + continue; + } + + // Process albums for this artist + const artistAlbums: Array<{ + path: string; + title: string; + trackCount: number; + coverPath?: string; + year?: number; + }> = []; + + for (const albumEntry of albumFolders) { + const albumPath = `${artistPath}/${albumEntry.name}`; + allAlbumPaths.push(albumPath); + + try { + const trackCount = await countTracks(albumPath); + + if (trackCount > 0) { + // Use Promise.all for parallel operations + const [coverArtPath, year] = await Promise.all([ + findAlbumArt(albumPath), + getAlbumYear(albumPath) + ]); + + artistAlbums.push({ + path: albumPath, + title: albumEntry.name, + trackCount, + coverPath: coverArtPath, + year + }); + } + } catch (error) { + console.error(`Error processing album ${albumPath}:`, error); + } + + processedAlbums++; + onProgress?.({ + status: 'syncing', + current: processedAlbums, + total: totalAlbums, + message: `Syncing ${artistEntry.name} - ${albumEntry.name}` + }); + } + + // Upsert artist + if (artistAlbums.length > 0) { + const totalTracks = artistAlbums.reduce((sum, a) => sum + a.trackCount, 0); + const primaryCover = artistAlbums[0]?.coverPath; + + const artistId = await upsertArtist({ + name: artistEntry.name, + path: artistPath, + album_count: artistAlbums.length, + track_count: totalTracks, + primary_cover_path: primaryCover + }); + + // Upsert all albums for this artist + for (const album of artistAlbums) { + await upsertAlbum({ + artist_id: artistId, + artist_name: artistEntry.name, + title: album.title, + path: album.path, + cover_path: album.coverPath, + track_count: album.trackCount, + year: album.year + }); + } + } + } + + // Clean up deleted albums and orphaned artists + await deleteAlbumsNotInPaths(allAlbumPaths); + await deleteOrphanedArtists(); + + onProgress?.({ + status: 'complete', + current: totalAlbums, + total: totalAlbums, + message: 'Library synced successfully' + }); + } catch (error) { + console.error('Error syncing library:', error); + onProgress?.({ + status: 'error', + current: 0, + total: 0, + message: `Error: ${(error as Error).message}` + }); + throw error; + } +} diff --git a/src/routes/library/+page.svelte b/src/routes/library/+page.svelte index ee114e7..88375b7 100644 --- a/src/routes/library/+page.svelte +++ b/src/routes/library/+page.svelte @@ -3,15 +3,19 @@ import { goto } from '$app/navigation'; import { convertFileSrc } from '@tauri-apps/api/core'; import { settings, loadSettings } from '$lib/stores/settings'; - import { scanArtistsWithAlbums, scanAlbums } from '$lib/library/scanner'; + import { getArtistsWithAlbums, getAllAlbums, getAlbumsByArtist, getLibraryStats } from '$lib/library/database'; + import { syncLibraryToDatabase } from '$lib/library/sync'; import type { ArtistWithAlbums, Album } from '$lib/types/track'; + import type { DbArtist, DbAlbum } from '$lib/library/database'; - type ViewMode = 'artists' | 'albums'; + type ViewMode = 'artists' | 'albums' | 'stats'; let viewMode = $state('artists'); let artists = $state([]); let albums = $state([]); let loading = $state(true); + let syncing = $state(false); + let syncProgress = $state<{ current: number; total: number; message: string } | null>(null); let error = $state(null); let selectedArtistIndex = $state(null); let selectedAlbumIndex = $state(null); @@ -32,13 +36,37 @@ } try { - const [artistsData, albumsData] = await Promise.all([ - scanArtistsWithAlbums($settings.musicFolder), - scanAlbums($settings.musicFolder) + // Check if database has any data + const stats = await getLibraryStats(); + + if (stats.albumCount === 0) { + // Database is empty, automatically sync + await handleSync(); + return; + } + + // Load from database + const [dbArtists, dbAlbums] = await Promise.all([ + getArtistsWithAlbums(), + getAllAlbums() ]); - artists = artistsData; - albums = albumsData; + // Convert DbArtist to ArtistWithAlbums + artists = await Promise.all( + dbArtists.map(async (dbArtist) => { + const artistAlbums = await getAlbumsByArtist(dbArtist.id); + return { + name: dbArtist.name, + path: dbArtist.path, + albums: artistAlbums.map(convertDbAlbumToAlbum), + primaryCoverArt: dbArtist.primary_cover_path || undefined + }; + }) + ); + + // Convert DbAlbum to Album + albums = dbAlbums.map(convertDbAlbumToAlbum); + loading = false; } catch (e) { error = 'Error loading library: ' + (e as Error).message; @@ -46,6 +74,45 @@ } } + function convertDbAlbumToAlbum(dbAlbum: DbAlbum): Album { + return { + artist: dbAlbum.artist_name, + title: dbAlbum.title, + path: dbAlbum.path, + coverArtPath: dbAlbum.cover_path || undefined, + trackCount: dbAlbum.track_count, + year: dbAlbum.year || undefined + }; + } + + async function handleSync() { + if (!$settings.musicFolder || syncing) { + return; + } + + syncing = true; + error = null; + syncProgress = { current: 0, total: 0, message: 'Starting sync...' }; + + try { + await syncLibraryToDatabase($settings.musicFolder, (progress) => { + syncProgress = { + current: progress.current, + total: progress.total, + message: progress.message || '' + }; + }); + + // Reload library from database + await loadLibrary(); + } catch (e) { + error = 'Error syncing library: ' + (e as Error).message; + } finally { + syncing = false; + syncProgress = null; + } + } + function getThumbnailUrl(coverArtPath?: string): string { if (!coverArtPath) { return ''; // Will use CSS background for placeholder @@ -70,9 +137,22 @@
-

Library

+

Library

- {#if loading} + {#if syncing} +
+

{syncProgress?.message || 'Syncing...'}

+ {#if syncProgress && syncProgress.total > 0} +
+
+
+

{syncProgress.current} / {syncProgress.total} albums

+ {/if} +
+ {:else if loading}

Loading library...

{:else if error}

{error}

@@ -96,6 +176,9 @@
  • +
  • + +
  • @@ -138,7 +221,7 @@
    - {:else} + {:else if viewMode === 'albums'}
    @@ -177,6 +260,33 @@
    + {:else if viewMode === 'stats'} + +
    +
    + Library Statistics +
    + Artists: + {artists.length} +
    +
    + Albums: + {albums.length} +
    +
    + Tracks: + {albums.reduce((sum, a) => sum + a.trackCount, 0)} +
    +
    + +
    + Library Maintenance + +

    Scan your music folder and update the library database

    +
    +
    {/if} @@ -192,8 +302,54 @@ } h2 { + margin: 0; + } + + .sync-status { + padding: 16px 8px; + } + + .sync-status p { margin: 0 0 8px 0; - flex-shrink: 0; + } + + .progress-bar { + width: 100%; + height: 20px; + background: #c0c0c0; + border: 2px inset #808080; + margin-bottom: 4px; + } + + .progress-fill { + height: 100%; + background: linear-gradient(90deg, #000080, #0000ff); + transition: width 0.2s ease; + } + + .progress-text { + font-size: 12px; + color: #808080; + } + + .stats-container { + padding: 16px; + } + + .stats-container .field-row { + display: flex; + justify-content: space-between; + padding: 4px 0; + } + + .stat-label { + font-weight: bold; + } + + .help-text { + margin: 8px 0 0 0; + font-size: 11px; + color: #808080; } .library-content { diff --git a/src/routes/settings/+page.svelte b/src/routes/settings/+page.svelte index 3ea27f5..e2525ce 100644 --- a/src/routes/settings/+page.svelte +++ b/src/routes/settings/+page.svelte @@ -9,7 +9,8 @@ setDeezerOverwrite, loadSettings } from '$lib/stores/settings'; - import { open } from '@tauri-apps/plugin-dialog'; + import { clearLibrary as clearLibraryDb } from '$lib/library/database'; + import { open, confirm, message } from '@tauri-apps/plugin-dialog'; let currentMusicFolder = $state(null); let currentPlaylistsFolder = $state(null); @@ -63,11 +64,34 @@ await setMusicFolder(null); await setPlaylistsFolder(null); } + + async function clearLibraryDatabase() { + const confirmed = await confirm( + 'This will clear all library data from the database. The next time you visit the Library page, it will automatically rescan. Continue?', + { title: 'Clear Library Database', kind: 'warning' } + ); + + if (confirmed) { + try { + await clearLibraryDb(); + await message('Library database cleared successfully.', { title: 'Success', kind: 'info' }); + } catch (error) { + await message('Error clearing library database: ' + (error as Error).message, { title: 'Error', kind: 'error' }); + } + } + }

    Settings

    - +
  • { e.preventDefault(); activeTab = 'library'; }}>Library @@ -178,10 +202,16 @@

    Advanced Settings

    - +
    Clear All Paths
    This will reset your music and playlists folder paths. You'll need to set them up again.
    + +
    +
    Clear Library Database
    + This will delete all cached library data from the database. Your music files will not be affected. + +
    {/if}
  • @@ -271,4 +301,8 @@ font-weight: bold; text-align: center; } + + .setting-heading { + font-weight: bold; + }