feat(library): add album and artist scanning with cover art

This commit is contained in:
2025-10-01 11:32:40 -04:00
parent de04dbc323
commit 515a744734
11 changed files with 534 additions and 39 deletions

7
src-tauri/Cargo.lock generated
View File

@@ -1600,6 +1600,12 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
] ]
[[package]]
name = "http-range"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573"
[[package]] [[package]]
name = "httparse" name = "httparse"
version = "1.10.1" version = "1.10.1"
@@ -3987,6 +3993,7 @@ dependencies = [
"gtk", "gtk",
"heck 0.5.0", "heck 0.5.0",
"http", "http",
"http-range",
"jni", "jni",
"libc", "libc",
"log", "log",

View File

@@ -18,7 +18,7 @@ crate-type = ["staticlib", "cdylib", "rlib"]
tauri-build = { version = "2", features = [] } tauri-build = { version = "2", features = [] }
[dependencies] [dependencies]
tauri = { version = "2", features = [] } tauri = { version = "2", features = ["protocol-asset"] }
tauri-plugin-opener = "2" tauri-plugin-opener = "2"
tauri-plugin-store = "2" tauri-plugin-store = "2"
tauri-plugin-dialog = "2" tauri-plugin-dialog = "2"

View File

@@ -19,7 +19,11 @@
} }
], ],
"security": { "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": { "bundle": {

View File

@@ -0,0 +1,80 @@
<script lang="ts">
import type { Track } from '$lib/types/track';
interface Props {
tracks: Track[];
onTrackSelect?: (track: Track) => void;
}
let { tracks, onTrackSelect }: Props = $props();
let selectedIndex = $state<number | null>(null);
function handleRowClick(index: number) {
selectedIndex = index;
if (onTrackSelect) {
onTrackSelect(tracks[index]);
}
}
function formatDuration(seconds?: number): string {
if (!seconds) return '--:--';
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
</script>
<div class="sunken-panel track-list-container">
<table class="interactive">
<thead>
<tr>
<th>#</th>
<th>Title</th>
<th>Artist</th>
<th>Album</th>
<th>Duration</th>
</tr>
</thead>
<tbody>
{#each tracks as track, i}
<tr
class:highlighted={selectedIndex === i}
onclick={() => handleRowClick(i)}
>
<td>{track.metadata.trackNumber ?? i + 1}</td>
<td>{track.metadata.title ?? track.filename}</td>
<td>{track.metadata.artist ?? 'Unknown Artist'}</td>
<td>{track.metadata.album ?? 'Unknown Album'}</td>
<td>{formatDuration(track.metadata.duration)}</td>
</tr>
{/each}
</tbody>
</table>
</div>
<style>
.track-list-container {
height: 400px;
overflow: auto;
}
table {
width: 100%;
}
th {
text-align: left;
}
td:first-child {
width: 40px;
text-align: right;
}
td:last-child {
width: 60px;
text-align: right;
font-family: monospace;
}
</style>

View File

@@ -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 { export interface Artist {
name: string; name: string;
@@ -38,6 +40,236 @@ export async function scanArtists(musicFolderPath: string): Promise<Artist[]> {
} }
} }
/**
* 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;
}
}
/**
* 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<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) {
console.error('Error reading album year:', error);
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 all albums in music folder
*/
export async function scanAlbums(musicFolderPath: string): Promise<Album[]> {
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<ArtistWithAlbums[]> {
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 * Scan playlists folder for m3u/m3u8 files
*/ */

View File

@@ -29,6 +29,28 @@ export interface Track {
metadata: TrackMetadata; 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 * Playlist with tracks
*/ */

View File

@@ -180,7 +180,7 @@
flex: 1; flex: 1;
min-width: 0; min-width: 0;
overflow-y: auto; overflow-y: auto;
padding: 8px; padding: 0;
font-family: "Pixelated MS Sans Serif", Arial; font-family: "Pixelated MS Sans Serif", Arial;
background: #121212; background: #121212;
} }

View File

@@ -2,7 +2,7 @@
import { settings } from '$lib/stores/settings'; import { settings } from '$lib/stores/settings';
</script> </script>
<div> <div style="padding: 8px;">
<img src="/Square150x150Logo.png" alt="cat" style="width: 128px; height: 128px;" /> <img src="/Square150x150Logo.png" alt="cat" style="width: 128px; height: 128px;" />
<h2>Welcome to Shark!</h2> <h2>Welcome to Shark!</h2>
<p>Your music library manager and player.</p> <p>Your music library manager and player.</p>

View File

@@ -108,9 +108,9 @@
<style> <style>
.downloads-page { .downloads-page {
height: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 8px;
} }
.header { .header {

View File

@@ -1,18 +1,26 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { convertFileSrc } from '@tauri-apps/api/core';
import { settings, loadSettings } from '$lib/stores/settings'; import { settings, loadSettings } from '$lib/stores/settings';
import { scanArtists, type Artist } from '$lib/library/scanner'; import { scanArtistsWithAlbums, scanAlbums } from '$lib/library/scanner';
import type { ArtistWithAlbums, Album } from '$lib/types/track';
let artists = $state<Artist[]>([]); type ViewMode = 'artists' | 'albums';
let viewMode = $state<ViewMode>('artists');
let artists = $state<ArtistWithAlbums[]>([]);
let albums = $state<Album[]>([]);
let loading = $state(true); let loading = $state(true);
let error = $state<string | null>(null); let error = $state<string | null>(null);
let selectedArtistIndex = $state<number | null>(null);
let selectedAlbumIndex = $state<number | null>(null);
onMount(async () => { onMount(async () => {
await loadSettings(); await loadSettings();
await loadArtists(); await loadLibrary();
}); });
async function loadArtists() { async function loadLibrary() {
loading = true; loading = true;
error = null; error = null;
@@ -23,66 +31,208 @@
} }
try { try {
artists = await scanArtists($settings.musicFolder); const [artistsData, albumsData] = await Promise.all([
scanArtistsWithAlbums($settings.musicFolder),
scanAlbums($settings.musicFolder)
]);
artists = artistsData;
albums = albumsData;
loading = false; loading = false;
} catch (e) { } catch (e) {
error = 'Error loading artists: ' + (e as Error).message; error = 'Error loading library: ' + (e as Error).message;
loading = false; loading = false;
} }
} }
function getThumbnailUrl(coverArtPath?: string): string {
if (!coverArtPath) {
return ''; // Will use CSS background for placeholder
}
return convertFileSrc(coverArtPath);
}
function handleArtistClick(index: number) {
selectedArtistIndex = index;
}
function handleAlbumClick(index: number) {
selectedAlbumIndex = index;
}
function getTotalTracks(artist: ArtistWithAlbums): number {
return artist.albums.reduce((sum, album) => sum + album.trackCount, 0);
}
</script> </script>
<div> <div>
<h2>Library</h2> <h2 style="padding: 8px 8px 0 8px;">Library</h2>
{#if loading} {#if loading}
<p>Loading artists...</p> <p>Loading library...</p>
{:else if error} {:else if error}
<p class="error">{error}</p> <p class="error">{error}</p>
{:else if artists.length === 0} {:else if artists.length === 0 && albums.length === 0}
<p>No artists found in your music folder.</p> <p>No music found in your music folder.</p>
{:else} {:else}
<section class="library-content"> <section class="library-content">
<table> <!-- Tabs -->
<thead> <menu role="tablist">
<tr> <li role="tab" aria-selected={viewMode === 'artists'}>
<th>Artist</th> <button onclick={() => viewMode = 'artists'}>Artists</button>
<th>Path</th> </li>
</tr> <li role="tab" aria-selected={viewMode === 'albums'}>
</thead> <button onclick={() => viewMode = 'albums'}>Albums</button>
<tbody> </li>
{#each artists as artist} </menu>
<tr>
<td>{artist.name}</td> <!-- Tab Content -->
<td class="path">{artist.path}</td> <div class="window tab-content" role="tabpanel">
</tr> <div class="window-body">
{/each} {#if viewMode === 'artists'}
</tbody> <!-- Artists View -->
</table> <div class="sunken-panel table-container">
<table class="interactive">
<thead>
<tr>
<th class="cover-column"></th>
<th>Artist</th>
<th>Albums</th>
<th>Tracks</th>
</tr>
</thead>
<tbody>
{#each artists as artist, i}
<tr
class:highlighted={selectedArtistIndex === i}
onclick={() => handleArtistClick(i)}
>
<td class="cover-cell">
{#if artist.primaryCoverArt}
<img
src={getThumbnailUrl(artist.primaryCoverArt)}
alt="{artist.name} cover"
class="thumbnail"
/>
{:else}
<div class="thumbnail-placeholder"></div>
{/if}
</td>
<td>{artist.name}</td>
<td>{artist.albums.length}</td>
<td>{getTotalTracks(artist)}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{:else}
<!-- Albums View -->
<div class="sunken-panel table-container">
<table class="interactive">
<thead>
<tr>
<th class="cover-column"></th>
<th>Album</th>
<th>Artist</th>
<th>Year</th>
<th>Tracks</th>
</tr>
</thead>
<tbody>
{#each albums as album, i}
<tr
class:highlighted={selectedAlbumIndex === i}
onclick={() => handleAlbumClick(i)}
>
<td class="cover-cell">
{#if album.coverArtPath}
<img
src={getThumbnailUrl(album.coverArtPath)}
alt="{album.title} cover"
class="thumbnail"
/>
{:else}
<div class="thumbnail-placeholder"></div>
{/if}
</td>
<td>{album.title}</td>
<td>{album.artist}</td>
<td>{album.year ?? '—'}</td>
<td>{album.trackCount}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
</div>
</section> </section>
{/if} {/if}
</div> </div>
<style> <style>
h2 { h2 {
margin-top: 0; margin: 0 0 8px 0;
} }
.library-content { .library-content {
margin-top: 20px; margin: 0;
} }
.error { .error {
color: #ff6b6b; color: #ff6b6b;
} }
.tab-content {
margin-top: -2px;
}
.window-body {
padding: 0;
}
.table-container {
overflow-y: auto;
}
table { table {
width: 100%; width: 100%;
} }
.path { th {
font-family: monospace; text-align: left;
font-size: 0.9em; }
opacity: 0.7;
.cover-column {
width: 60px;
}
.cover-cell {
padding: 4px;
}
.thumbnail {
width: 48px;
height: 48px;
object-fit: cover;
display: block;
image-rendering: auto;
}
.thumbnail-placeholder {
width: 48px;
height: 48px;
background: linear-gradient(135deg, #c0c0c0 25%, #808080 25%, #808080 50%, #c0c0c0 50%, #c0c0c0 75%, #808080 75%);
background-size: 8px 8px;
display: block;
}
td:nth-child(3),
td:nth-child(4),
td:nth-child(5) {
width: 80px;
text-align: right;
} }
</style> </style>

View File

@@ -65,7 +65,7 @@
} }
</script> </script>
<div> <div style="padding: 8px;">
<h2>Settings</h2> <h2>Settings</h2>
<menu role="tablist"> <menu role="tablist">