Files
shark/src/lib/library/database.ts

326 lines
7.9 KiB
TypeScript

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;
}
export interface DbTrack {
id: number;
path: string;
title: string;
artist: string;
album: string;
duration: number;
format: string;
has_lyrics: number;
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]
);
const artistId = artists[0]?.id ?? result.lastInsertId;
if (artistId == null) {
throw new Error('Failed to get artist ID from upsert operation');
}
return artistId;
}
/**
* 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
};
}
/**
* Get all tracks without lyrics (has_lyrics = 0)
*/
export async function getTracksWithoutLyrics(): Promise<DbTrack[]> {
const database = await initDatabase();
const tracks = await database.select<DbTrack[]>(
'SELECT * FROM tracks WHERE has_lyrics = 0 ORDER BY artist COLLATE NOCASE, album COLLATE NOCASE, title COLLATE NOCASE'
);
return tracks;
}
/**
* Upsert a track (insert or update)
*/
export async function upsertTrack(track: {
path: string;
title: string;
artist: string;
album: string;
duration: number;
format: string;
has_lyrics: boolean;
}): Promise<void> {
const database = await initDatabase();
const now = Math.floor(Date.now() / 1000);
await database.execute(
`INSERT INTO tracks (path, title, artist, album, duration, format, has_lyrics, last_scanned)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT(path) DO UPDATE SET
title = $2,
artist = $3,
album = $4,
duration = $5,
format = $6,
has_lyrics = $7,
last_scanned = $8`,
[
track.path,
track.title,
track.artist,
track.album,
track.duration,
track.format,
track.has_lyrics ? 1 : 0,
now
]
);
}
/**
* Get the last scan timestamp for lyrics
*/
export async function getLyricsScanTimestamp(): Promise<number | null> {
const database = await initDatabase();
const result = await database.select<{ last_scanned: number | null }[]>(
'SELECT MAX(last_scanned) as last_scanned FROM tracks'
);
return result[0]?.last_scanned || null;
}
/**
* Delete tracks that are no longer in the provided paths
*/
export async function deleteTracksNotInPaths(paths: string[]): Promise<void> {
if (paths.length === 0) {
const database = await initDatabase();
await database.execute('DELETE FROM tracks');
return;
}
const database = await initDatabase();
const placeholders = paths.map((_, i) => `$${i + 1}`).join(',');
await database.execute(
`DELETE FROM tracks WHERE path NOT IN (${placeholders})`,
paths
);
}