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.
This commit is contained in:
2025-10-01 20:02:57 -04:00
parent bdfd245b4e
commit 8391897f54
10 changed files with 783 additions and 19 deletions

View File

@@ -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<ViewMode>('artists');
let artists = $state<ArtistWithAlbums[]>([]);
let albums = $state<Album[]>([]);
let loading = $state(true);
let syncing = $state(false);
let syncProgress = $state<{ current: number; total: number; message: string } | null>(null);
let error = $state<string | null>(null);
let selectedArtistIndex = $state<number | null>(null);
let selectedAlbumIndex = $state<number | null>(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 @@
</script>
<div class="library-wrapper">
<h2 style="padding: 8px 8px 0 8px;">Library</h2>
<h2 style="padding: 8px">Library</h2>
{#if loading}
{#if syncing}
<div class="sync-status">
<p>{syncProgress?.message || 'Syncing...'}</p>
{#if syncProgress && syncProgress.total > 0}
<div class="progress-bar">
<div
class="progress-fill"
style="width: {(syncProgress.current / syncProgress.total) * 100}%"
></div>
</div>
<p class="progress-text">{syncProgress.current} / {syncProgress.total} albums</p>
{/if}
</div>
{:else if loading}
<p style="padding: 8px;">Loading library...</p>
{:else if error}
<p class="error" style="padding: 8px;">{error}</p>
@@ -96,6 +176,9 @@
<li role="tab" aria-selected={viewMode === 'albums'}>
<button onclick={() => viewMode = 'albums'}>Albums</button>
</li>
<li role="tab" aria-selected={viewMode === 'stats'}>
<button onclick={() => viewMode = 'stats'}>Stats</button>
</li>
</menu>
<!-- Tab Content -->
@@ -138,7 +221,7 @@
</tbody>
</table>
</div>
{:else}
{:else if viewMode === 'albums'}
<!-- Albums View -->
<div class="sunken-panel table-container">
<table class="interactive">
@@ -177,6 +260,33 @@
</tbody>
</table>
</div>
{:else if viewMode === 'stats'}
<!-- Stats View -->
<div class="stats-container">
<fieldset>
<legend>Library Statistics</legend>
<div class="field-row">
<span class="stat-label">Artists:</span>
<span>{artists.length}</span>
</div>
<div class="field-row">
<span class="stat-label">Albums:</span>
<span>{albums.length}</span>
</div>
<div class="field-row">
<span class="stat-label">Tracks:</span>
<span>{albums.reduce((sum, a) => sum + a.trackCount, 0)}</span>
</div>
</fieldset>
<fieldset style="margin-top: 16px;">
<legend>Library Maintenance</legend>
<button onclick={handleSync} disabled={syncing || !$settings.musicFolder}>
{syncing ? 'Syncing...' : 'Refresh Library'}
</button>
<p class="help-text">Scan your music folder and update the library database</p>
</fieldset>
</div>
{/if}
</div>
</div>
@@ -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 {

View File

@@ -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<string | null>(null);
let currentPlaylistsFolder = $state<string | null>(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' });
}
}
}
</script>
<div style="padding: 8px;">
<h2>Settings</h2>
<!--
svelte-ignore a11y_no_noninteractive_element_to_interactive_role
Reason: 98.css library requires <menu role="tablist"> for proper tab styling.
The role="tablist" selector is used by 98.css CSS rules (menu[role="tablist"]).
The <menu> element IS interactive (contains clickable <button> elements) and the
role="tablist" properly describes the semantic purpose to assistive technology.
This is the documented pattern from 98.css and matches WAI-ARIA tab widget patterns.
-->
<menu role="tablist">
<li role="tab" aria-selected={activeTab === 'library'}>
<a href="#library" onclick={(e) => { e.preventDefault(); activeTab = 'library'; }}>Library</a>
@@ -178,10 +202,16 @@
<h3>Advanced Settings</h3>
<div class="field-row-stacked">
<label>Clear All Paths</label>
<div class="setting-heading">Clear All Paths</div>
<small class="help-text">This will reset your music and playlists folder paths. You'll need to set them up again.</small>
<button onclick={clearAllPaths}>Clear All Paths</button>
</div>
<div class="field-row-stacked">
<div class="setting-heading">Clear Library Database</div>
<small class="help-text">This will delete all cached library data from the database. Your music files will not be affected.</small>
<button onclick={clearLibraryDatabase}>Clear Library Database</button>
</div>
</section>
{/if}
</div>
@@ -271,4 +301,8 @@
font-weight: bold;
text-align: center;
}
.setting-heading {
font-weight: bold;
}
</style>