mirror of
https://github.com/markuryy/shark.git
synced 2025-12-12 19:51:01 +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:
2
bun.lock
2
bun.lock
@@ -10,7 +10,7 @@
|
|||||||
"@tauri-apps/plugin-fs": "^2.4.2",
|
"@tauri-apps/plugin-fs": "^2.4.2",
|
||||||
"@tauri-apps/plugin-http": "~2",
|
"@tauri-apps/plugin-http": "~2",
|
||||||
"@tauri-apps/plugin-opener": "^2",
|
"@tauri-apps/plugin-opener": "^2",
|
||||||
"@tauri-apps/plugin-sql": "~2",
|
"@tauri-apps/plugin-sql": "^2.3.0",
|
||||||
"@tauri-apps/plugin-store": "~2",
|
"@tauri-apps/plugin-store": "~2",
|
||||||
"blowfish-node": "^1.1.4",
|
"blowfish-node": "^1.1.4",
|
||||||
"browser-id3-writer": "^6.3.1",
|
"browser-id3-writer": "^6.3.1",
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
"@tauri-apps/plugin-fs": "^2.4.2",
|
"@tauri-apps/plugin-fs": "^2.4.2",
|
||||||
"@tauri-apps/plugin-http": "~2",
|
"@tauri-apps/plugin-http": "~2",
|
||||||
"@tauri-apps/plugin-opener": "^2",
|
"@tauri-apps/plugin-opener": "^2",
|
||||||
"@tauri-apps/plugin-sql": "~2",
|
"@tauri-apps/plugin-sql": "^2.3.0",
|
||||||
"@tauri-apps/plugin-store": "~2",
|
"@tauri-apps/plugin-store": "~2",
|
||||||
"blowfish-node": "^1.1.4",
|
"blowfish-node": "^1.1.4",
|
||||||
"browser-id3-writer": "^6.3.1",
|
"browser-id3-writer": "^6.3.1",
|
||||||
|
|||||||
15
src-tauri/Cargo.lock
generated
15
src-tauri/Cargo.lock
generated
@@ -2247,6 +2247,7 @@ version = "0.30.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
|
checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"cc",
|
||||||
"pkg-config",
|
"pkg-config",
|
||||||
"vcpkg",
|
"vcpkg",
|
||||||
]
|
]
|
||||||
@@ -4153,6 +4154,8 @@ dependencies = [
|
|||||||
"smallvec",
|
"smallvec",
|
||||||
"thiserror 2.0.17",
|
"thiserror 2.0.17",
|
||||||
"time",
|
"time",
|
||||||
|
"tokio",
|
||||||
|
"tokio-stream",
|
||||||
"tracing",
|
"tracing",
|
||||||
"url",
|
"url",
|
||||||
]
|
]
|
||||||
@@ -4191,6 +4194,7 @@ dependencies = [
|
|||||||
"sqlx-postgres",
|
"sqlx-postgres",
|
||||||
"sqlx-sqlite",
|
"sqlx-sqlite",
|
||||||
"syn 2.0.106",
|
"syn 2.0.106",
|
||||||
|
"tokio",
|
||||||
"url",
|
"url",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -5019,6 +5023,17 @@ dependencies = [
|
|||||||
"tokio",
|
"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]]
|
[[package]]
|
||||||
name = "tokio-util"
|
name = "tokio-util"
|
||||||
version = "0.7.16"
|
version = "0.7.16"
|
||||||
|
|||||||
@@ -26,5 +26,5 @@ tauri-plugin-fs = "2"
|
|||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
tauri-plugin-http = { version = "2", features = ["unsafe-headers"] }
|
tauri-plugin-http = { version = "2", features = ["unsafe-headers"] }
|
||||||
tauri-plugin-sql = "2"
|
tauri-plugin-sql = { version = "2", features = ["sqlite"] }
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,7 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"sql:default"
|
"sql:default",
|
||||||
|
"sql:allow-execute"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
use tauri_plugin_sql::{Migration, MigrationKind};
|
||||||
|
|
||||||
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
|
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn greet(name: &str) -> String {
|
fn greet(name: &str) -> String {
|
||||||
@@ -6,8 +8,51 @@ fn greet(name: &str) -> String {
|
|||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
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()
|
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_http::init())
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
.plugin(tauri_plugin_store::Builder::new().build())
|
.plugin(tauri_plugin_store::Builder::new().build())
|
||||||
|
|||||||
229
src/lib/library/database.ts
Normal file
229
src/lib/library/database.ts
Normal file
@@ -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<Database> {
|
||||||
|
if (!db) {
|
||||||
|
db = await Database.load('sqlite:library.db');
|
||||||
|
}
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all artists with their albums
|
||||||
|
*/
|
||||||
|
export async function getArtistsWithAlbums(): Promise<DbArtist[]> {
|
||||||
|
const database = await initDatabase();
|
||||||
|
const artists = await database.select<DbArtist[]>(
|
||||||
|
'SELECT * FROM artists ORDER BY name COLLATE NOCASE'
|
||||||
|
);
|
||||||
|
return artists;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all albums for a specific artist
|
||||||
|
*/
|
||||||
|
export async function getAlbumsByArtist(artistId: number): Promise<DbAlbum[]> {
|
||||||
|
const database = await initDatabase();
|
||||||
|
const albums = await database.select<DbAlbum[]>(
|
||||||
|
'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<DbAlbum[]> {
|
||||||
|
const database = await initDatabase();
|
||||||
|
const albums = await database.select<DbAlbum[]>(
|
||||||
|
'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<DbAlbum | null> {
|
||||||
|
const database = await initDatabase();
|
||||||
|
const albums = await database.select<DbAlbum[]>(
|
||||||
|
'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<number> {
|
||||||
|
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<DbArtist[]>(
|
||||||
|
'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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
284
src/lib/library/sync.ts
Normal file
284
src/lib/library/sync.ts
Normal file
@@ -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<string | undefined> {
|
||||||
|
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<number | undefined> {
|
||||||
|
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<AudioFormat, string> = {
|
||||||
|
'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<number> {
|
||||||
|
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<void> {
|
||||||
|
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<string, number>();
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,15 +3,19 @@
|
|||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { convertFileSrc } from '@tauri-apps/api/core';
|
import { convertFileSrc } from '@tauri-apps/api/core';
|
||||||
import { settings, loadSettings } from '$lib/stores/settings';
|
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 { 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 viewMode = $state<ViewMode>('artists');
|
||||||
let artists = $state<ArtistWithAlbums[]>([]);
|
let artists = $state<ArtistWithAlbums[]>([]);
|
||||||
let albums = $state<Album[]>([]);
|
let albums = $state<Album[]>([]);
|
||||||
let loading = $state(true);
|
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 error = $state<string | null>(null);
|
||||||
let selectedArtistIndex = $state<number | null>(null);
|
let selectedArtistIndex = $state<number | null>(null);
|
||||||
let selectedAlbumIndex = $state<number | null>(null);
|
let selectedAlbumIndex = $state<number | null>(null);
|
||||||
@@ -32,13 +36,37 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [artistsData, albumsData] = await Promise.all([
|
// Check if database has any data
|
||||||
scanArtistsWithAlbums($settings.musicFolder),
|
const stats = await getLibraryStats();
|
||||||
scanAlbums($settings.musicFolder)
|
|
||||||
|
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;
|
// Convert DbArtist to ArtistWithAlbums
|
||||||
albums = albumsData;
|
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;
|
loading = false;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = 'Error loading library: ' + (e as Error).message;
|
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 {
|
function getThumbnailUrl(coverArtPath?: string): string {
|
||||||
if (!coverArtPath) {
|
if (!coverArtPath) {
|
||||||
return ''; // Will use CSS background for placeholder
|
return ''; // Will use CSS background for placeholder
|
||||||
@@ -70,9 +137,22 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="library-wrapper">
|
<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>
|
<p style="padding: 8px;">Loading library...</p>
|
||||||
{:else if error}
|
{:else if error}
|
||||||
<p class="error" style="padding: 8px;">{error}</p>
|
<p class="error" style="padding: 8px;">{error}</p>
|
||||||
@@ -96,6 +176,9 @@
|
|||||||
<li role="tab" aria-selected={viewMode === 'albums'}>
|
<li role="tab" aria-selected={viewMode === 'albums'}>
|
||||||
<button onclick={() => viewMode = 'albums'}>Albums</button>
|
<button onclick={() => viewMode = 'albums'}>Albums</button>
|
||||||
</li>
|
</li>
|
||||||
|
<li role="tab" aria-selected={viewMode === 'stats'}>
|
||||||
|
<button onclick={() => viewMode = 'stats'}>Stats</button>
|
||||||
|
</li>
|
||||||
</menu>
|
</menu>
|
||||||
|
|
||||||
<!-- Tab Content -->
|
<!-- Tab Content -->
|
||||||
@@ -138,7 +221,7 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else if viewMode === 'albums'}
|
||||||
<!-- Albums View -->
|
<!-- Albums View -->
|
||||||
<div class="sunken-panel table-container">
|
<div class="sunken-panel table-container">
|
||||||
<table class="interactive">
|
<table class="interactive">
|
||||||
@@ -177,6 +260,33 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -192,8 +302,54 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-status {
|
||||||
|
padding: 16px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-status p {
|
||||||
margin: 0 0 8px 0;
|
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 {
|
.library-content {
|
||||||
|
|||||||
@@ -9,7 +9,8 @@
|
|||||||
setDeezerOverwrite,
|
setDeezerOverwrite,
|
||||||
loadSettings
|
loadSettings
|
||||||
} from '$lib/stores/settings';
|
} 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 currentMusicFolder = $state<string | null>(null);
|
||||||
let currentPlaylistsFolder = $state<string | null>(null);
|
let currentPlaylistsFolder = $state<string | null>(null);
|
||||||
@@ -63,11 +64,34 @@
|
|||||||
await setMusicFolder(null);
|
await setMusicFolder(null);
|
||||||
await setPlaylistsFolder(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>
|
</script>
|
||||||
|
|
||||||
<div style="padding: 8px;">
|
<div style="padding: 8px;">
|
||||||
<h2>Settings</h2>
|
<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">
|
<menu role="tablist">
|
||||||
<li role="tab" aria-selected={activeTab === 'library'}>
|
<li role="tab" aria-selected={activeTab === 'library'}>
|
||||||
<a href="#library" onclick={(e) => { e.preventDefault(); activeTab = 'library'; }}>Library</a>
|
<a href="#library" onclick={(e) => { e.preventDefault(); activeTab = 'library'; }}>Library</a>
|
||||||
@@ -178,10 +202,16 @@
|
|||||||
<h3>Advanced Settings</h3>
|
<h3>Advanced Settings</h3>
|
||||||
|
|
||||||
<div class="field-row-stacked">
|
<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>
|
<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>
|
<button onclick={clearAllPaths}>Clear All Paths</button>
|
||||||
</div>
|
</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>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -271,4 +301,8 @@
|
|||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.setting-heading {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user