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

229
src/lib/library/database.ts Normal file
View 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
};
}