mirror of
https://github.com/markuryy/shark.git
synced 2025-12-15 12:41:02 +00:00
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:
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user