From 515a7447340c94cdcf19b1695033d63db1a88173 Mon Sep 17 00:00:00 2001 From: Markury Date: Wed, 1 Oct 2025 11:32:40 -0400 Subject: [PATCH] feat(library): add album and artist scanning with cover art --- src-tauri/Cargo.lock | 7 + src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 6 +- src/lib/components/TrackList.svelte | 80 ++++++++++ src/lib/library/scanner.ts | 234 +++++++++++++++++++++++++++- src/lib/types/track.ts | 22 +++ src/routes/+layout.svelte | 2 +- src/routes/+page.svelte | 2 +- src/routes/downloads/+page.svelte | 2 +- src/routes/library/+page.svelte | 214 +++++++++++++++++++++---- src/routes/settings/+page.svelte | 2 +- 11 files changed, 534 insertions(+), 39 deletions(-) create mode 100644 src/lib/components/TrackList.svelte diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index fe09204..311417d 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1600,6 +1600,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573" + [[package]] name = "httparse" version = "1.10.1" @@ -3987,6 +3993,7 @@ dependencies = [ "gtk", "heck 0.5.0", "http", + "http-range", "jni", "libc", "log", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 9ddbec8..ecc5d79 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -18,7 +18,7 @@ crate-type = ["staticlib", "cdylib", "rlib"] tauri-build = { version = "2", features = [] } [dependencies] -tauri = { version = "2", features = [] } +tauri = { version = "2", features = ["protocol-asset"] } tauri-plugin-opener = "2" tauri-plugin-store = "2" tauri-plugin-dialog = "2" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index e008cea..f481176 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -19,7 +19,11 @@ } ], "security": { - "csp": null + "csp": "default-src 'self' ipc: http://ipc.localhost; img-src 'self' asset: http://asset.localhost; style-src 'self' 'unsafe-inline'", + "assetProtocol": { + "enable": true, + "scope": ["**"] + } } }, "bundle": { diff --git a/src/lib/components/TrackList.svelte b/src/lib/components/TrackList.svelte new file mode 100644 index 0000000..d7a3331 --- /dev/null +++ b/src/lib/components/TrackList.svelte @@ -0,0 +1,80 @@ + + +
+ + + + + + + + + + + + {#each tracks as track, i} + handleRowClick(i)} + > + + + + + + + {/each} + +
#TitleArtistAlbumDuration
{track.metadata.trackNumber ?? i + 1}{track.metadata.title ?? track.filename}{track.metadata.artist ?? 'Unknown Artist'}{track.metadata.album ?? 'Unknown Album'}{formatDuration(track.metadata.duration)}
+
+ + diff --git a/src/lib/library/scanner.ts b/src/lib/library/scanner.ts index 0522de3..461fc06 100644 --- a/src/lib/library/scanner.ts +++ b/src/lib/library/scanner.ts @@ -1,4 +1,6 @@ -import { readDir } from '@tauri-apps/plugin-fs'; +import { readDir, readFile } from '@tauri-apps/plugin-fs'; +import { parseBuffer } from 'music-metadata'; +import type { Album, ArtistWithAlbums, AudioFormat } from '$lib/types/track'; export interface Artist { name: string; @@ -38,6 +40,236 @@ export async function scanArtists(musicFolderPath: string): Promise { } } +/** + * Find cover art image in album directory + */ +async function findAlbumArt(albumPath: string): Promise { + 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; + } +} + +/** + * 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'; + } +} + +/** + * Read year from first audio file in album directory + */ +async function getAlbumYear(albumPath: string): Promise { + 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 = { + '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) { + console.error('Error reading album year:', error); + return undefined; + } +} + +/** + * Count audio files in album directory + */ +async function countTracks(albumPath: string): Promise { + 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 all albums in music folder + */ +export async function scanAlbums(musicFolderPath: string): Promise { + try { + const artistEntries = await readDir(musicFolderPath); + const albums: Album[] = []; + + for (const artistEntry of artistEntries) { + // Skip _temp folder and non-directories + if (!artistEntry.isDirectory || artistEntry.name === '_temp') { + continue; + } + + const artistPath = `${musicFolderPath}/${artistEntry.name}`; + const albumEntries = await readDir(artistPath); + + for (const albumEntry of albumEntries) { + if (albumEntry.isDirectory) { + const albumPath = `${artistPath}/${albumEntry.name}`; + const trackCount = await countTracks(albumPath); + + // Only include if there are tracks + if (trackCount > 0) { + const [coverArtPath, year] = await Promise.all([ + findAlbumArt(albumPath), + getAlbumYear(albumPath) + ]); + + albums.push({ + artist: artistEntry.name, + title: albumEntry.name, + path: albumPath, + coverArtPath, + trackCount, + year + }); + } + } + } + } + + // Sort by artist then album title + albums.sort((a, b) => { + const artistCompare = a.artist.localeCompare(b.artist); + return artistCompare !== 0 ? artistCompare : a.title.localeCompare(b.title); + }); + + return albums; + } catch (error) { + console.error('Error scanning albums:', error); + return []; + } +} + +/** + * Scan artists with their albums + */ +export async function scanArtistsWithAlbums(musicFolderPath: string): Promise { + try { + const artistEntries = await readDir(musicFolderPath); + const artists: ArtistWithAlbums[] = []; + + for (const artistEntry of artistEntries) { + // Skip _temp folder and non-directories + if (!artistEntry.isDirectory || artistEntry.name === '_temp') { + continue; + } + + const artistPath = `${musicFolderPath}/${artistEntry.name}`; + const albumEntries = await readDir(artistPath); + const albums: Album[] = []; + + for (const albumEntry of albumEntries) { + if (albumEntry.isDirectory) { + const albumPath = `${artistPath}/${albumEntry.name}`; + const trackCount = await countTracks(albumPath); + + // Only include if there are tracks + if (trackCount > 0) { + const [coverArtPath, year] = await Promise.all([ + findAlbumArt(albumPath), + getAlbumYear(albumPath) + ]); + + albums.push({ + artist: artistEntry.name, + title: albumEntry.name, + path: albumPath, + coverArtPath, + trackCount, + year + }); + } + } + } + + // Only add artist if they have albums + if (albums.length > 0) { + albums.sort((a, b) => a.title.localeCompare(b.title)); + + artists.push({ + name: artistEntry.name, + path: artistPath, + albums, + primaryCoverArt: albums[0]?.coverArtPath + }); + } + } + + // Sort alphabetically by artist name + artists.sort((a, b) => a.name.localeCompare(b.name)); + + return artists; + } catch (error) { + console.error('Error scanning artists with albums:', error); + return []; + } +} + /** * Scan playlists folder for m3u/m3u8 files */ diff --git a/src/lib/types/track.ts b/src/lib/types/track.ts index f362452..0038b29 100644 --- a/src/lib/types/track.ts +++ b/src/lib/types/track.ts @@ -29,6 +29,28 @@ export interface Track { metadata: TrackMetadata; } +/** + * Album information + */ +export interface Album { + artist: string; // Album artist + title: string; // Album title + year?: number; + path: string; // Full path to album directory + coverArtPath?: string; // Path to cover art image if found + trackCount: number; +} + +/** + * Artist with their albums + */ +export interface ArtistWithAlbums { + name: string; + path: string; + albums: Album[]; + primaryCoverArt?: string; // Cover art from first album for thumbnail +} + /** * Playlist with tracks */ diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 2342d1c..390e38b 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -180,7 +180,7 @@ flex: 1; min-width: 0; overflow-y: auto; - padding: 8px; + padding: 0; font-family: "Pixelated MS Sans Serif", Arial; background: #121212; } diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 3d8cd30..e5dca62 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -2,7 +2,7 @@ import { settings } from '$lib/stores/settings'; -
+
cat

Welcome to Shark!

Your music library manager and player.

diff --git a/src/routes/downloads/+page.svelte b/src/routes/downloads/+page.svelte index 4733799..296c661 100644 --- a/src/routes/downloads/+page.svelte +++ b/src/routes/downloads/+page.svelte @@ -108,9 +108,9 @@ diff --git a/src/routes/settings/+page.svelte b/src/routes/settings/+page.svelte index 0a8122d..3ea27f5 100644 --- a/src/routes/settings/+page.svelte +++ b/src/routes/settings/+page.svelte @@ -65,7 +65,7 @@ } -
+

Settings