feat(mukke): display album cover art in library, playlists, and song lists

Add batch cover-url endpoint (POST /library/cover-urls) to efficiently
resolve multiple cover art presigned URLs in a single request. Integrate
cover art display across all UI surfaces: album grid, album detail header,
song list thumbnails, playlist grid, and playlist detail song list.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-19 20:59:45 +01:00
parent 789ce0a435
commit e848fa5342
81 changed files with 376 additions and 58 deletions

View file

@ -1,4 +1,4 @@
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
import { Controller, Get, Post, Param, Body, UseGuards } from '@nestjs/common';
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
import { LibraryService } from './library.service';
@ -7,6 +7,12 @@ import { LibraryService } from './library.service';
export class LibraryController {
constructor(private readonly libraryService: LibraryService) {}
@Post('cover-urls')
async getCoverUrls(@Body() body: { paths: string[] }) {
const urls = await this.libraryService.getCoverUrls(body.paths ?? []);
return { urls };
}
@Get('albums')
async getAlbums(@CurrentUser() user: CurrentUserData) {
const albums = await this.libraryService.getAlbums(user.userId);

View file

@ -3,10 +3,15 @@ import { eq, and, asc, sql } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../db/database.module';
import { Database } from '../db/connection';
import { songs } from '../db/schema';
import { createMukkeStorage, type StorageClient } from '@manacore/shared-storage';
@Injectable()
export class LibraryService {
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
private storage: StorageClient;
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {
this.storage = createMukkeStorage();
}
async getAlbums(userId: string) {
const result = await this.db.execute<{
@ -93,4 +98,26 @@ export class LibraryService {
.where(and(eq(songs.userId, userId), eq(songs.genre, genreName)))
.orderBy(asc(songs.title));
}
async getCoverUrls(paths: string[]): Promise<Record<string, string>> {
const uniquePaths = [...new Set(paths.filter(Boolean))];
if (uniquePaths.length === 0) return {};
const entries = await Promise.all(
uniquePaths.map(async (path) => {
try {
const url = await this.storage.getDownloadUrl(path, { expiresIn: 3600 });
return [path, url] as const;
} catch {
return null;
}
})
);
const result: Record<string, string> = {};
for (const entry of entries) {
if (entry) result[entry[0]] = entry[1];
}
return result;
}
}

View file

@ -15,6 +15,7 @@ interface LibraryState {
artists: Artist[];
genres: Genre[];
stats: LibraryStats | null;
coverUrls: Record<string, string>;
activeTab: 'songs' | 'albums' | 'artists' | 'genres';
sortField: SortField;
sortDirection: SortDirection;
@ -40,6 +41,7 @@ function createLibraryStore() {
artists: [],
genres: [],
stats: null,
coverUrls: {},
activeTab: 'songs',
sortField: 'addedAt' as SortField,
sortDirection: 'desc' as SortDirection,
@ -94,10 +96,27 @@ function createLibraryStore() {
get isLoading() {
return state.isLoading;
},
get coverUrls() {
return state.coverUrls;
},
get error() {
return state.error;
},
async loadCoverUrls(paths: string[]) {
const uncached = paths.filter((p) => p && !state.coverUrls[p]);
if (uncached.length === 0) return;
try {
const data = await fetchApi<{ urls: Record<string, string> }>('/library/cover-urls', {
method: 'POST',
body: JSON.stringify({ paths: uncached }),
});
state.coverUrls = { ...state.coverUrls, ...data.urls };
} catch {
// Cover URLs are non-critical, don't set error
}
},
async loadSongs() {
state.isLoading = true;
state.error = null;
@ -106,6 +125,8 @@ function createLibraryStore() {
`/songs?sort=${state.sortField}&direction=${state.sortDirection}`
);
state.songs = data.songs;
const coverPaths = data.songs.map((s) => s.coverArtPath).filter((p): p is string => !!p);
if (coverPaths.length > 0) this.loadCoverUrls(coverPaths);
} catch (e) {
state.error = e instanceof Error ? e.message : 'Failed to load songs';
}
@ -118,6 +139,8 @@ function createLibraryStore() {
try {
const data = await fetchApi<{ albums: Album[] }>('/library/albums');
state.albums = data.albums;
const coverPaths = data.albums.map((a) => a.coverArtPath).filter((p): p is string => !!p);
if (coverPaths.length > 0) this.loadCoverUrls(coverPaths);
} catch (e) {
state.error = e instanceof Error ? e.message : 'Failed to load albums';
}

View file

@ -4,6 +4,7 @@ import { authStore } from './auth.svelte';
interface PlaylistState {
playlists: Playlist[];
currentPlaylist: PlaylistWithSongs | null;
coverUrls: Record<string, string>;
isLoading: boolean;
error: string | null;
}
@ -23,6 +24,7 @@ function createPlaylistStore() {
let state = $state<PlaylistState>({
playlists: [],
currentPlaylist: null,
coverUrls: {},
isLoading: false,
error: null,
});
@ -56,16 +58,37 @@ function createPlaylistStore() {
get isLoading() {
return state.isLoading;
},
get coverUrls() {
return state.coverUrls;
},
get error() {
return state.error;
},
async loadCoverUrls(paths: string[]) {
const uncached = paths.filter((p) => p && !state.coverUrls[p]);
if (uncached.length === 0) return;
try {
const data = await fetchApi<{ urls: Record<string, string> }>('/library/cover-urls', {
method: 'POST',
body: JSON.stringify({ paths: uncached }),
});
state.coverUrls = { ...state.coverUrls, ...data.urls };
} catch {
// Cover URLs are non-critical
}
},
async loadPlaylists() {
state.isLoading = true;
state.error = null;
try {
const data = await fetchApi<{ playlists: Playlist[] }>('/playlists');
state.playlists = data.playlists;
const coverPaths = data.playlists
.map((p) => p.coverArtPath)
.filter((p): p is string => !!p);
if (coverPaths.length > 0) this.loadCoverUrls(coverPaths);
} catch (e) {
state.error = e instanceof Error ? e.message : 'Failed to load playlists';
}
@ -78,6 +101,10 @@ function createPlaylistStore() {
try {
const data = await fetchApi<{ playlist: PlaylistWithSongs }>(`/playlists/${id}`);
state.currentPlaylist = data.playlist;
const coverPaths = data.playlist.songs
.map((s) => s.coverArtPath)
.filter((p): p is string => !!p);
if (coverPaths.length > 0) this.loadCoverUrls(coverPaths);
} catch (e) {
state.error = e instanceof Error ? e.message : 'Failed to load playlist';
}

View file

@ -0,0 +1 @@
export const APP_VERSION = '0.2.0';

View file

@ -136,8 +136,9 @@
<div class="bg-surface rounded-lg overflow-hidden">
<!-- Header -->
<div
class="grid grid-cols-[1fr_1fr_1fr_80px_40px_40px_40px] gap-4 px-4 py-3 text-xs font-medium text-foreground-secondary uppercase tracking-wide border-b border-border"
class="grid grid-cols-[40px_1fr_1fr_1fr_80px_40px_40px_40px] gap-4 px-4 py-3 text-xs font-medium text-foreground-secondary uppercase tracking-wide border-b border-border"
>
<span></span>
<span>Title</span>
<span>Artist</span>
<span>Album</span>
@ -149,8 +150,33 @@
<!-- Song rows -->
{#each libraryStore.songs as song}
<div
class="grid grid-cols-[1fr_1fr_1fr_80px_40px_40px_40px] gap-4 px-4 py-3 hover:bg-background transition-colors items-center"
class="grid grid-cols-[40px_1fr_1fr_1fr_80px_40px_40px_40px] gap-4 px-4 py-3 hover:bg-background transition-colors items-center"
>
<div
class="w-10 h-10 rounded bg-background flex items-center justify-center overflow-hidden flex-shrink-0"
>
{#if song.coverArtPath && libraryStore.coverUrls[song.coverArtPath]}
<img
src={libraryStore.coverUrls[song.coverArtPath]}
alt=""
class="w-full h-full object-cover"
/>
{:else}
<svg
class="w-5 h-5 text-foreground-secondary"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"
/>
</svg>
{/if}
</div>
<span class="truncate font-medium">{song.title}</span>
<span class="truncate text-foreground-secondary">{song.artist ?? 'Unknown'}</span>
<span class="truncate text-foreground-secondary">{song.album ?? 'Unknown'}</span>
@ -226,21 +252,29 @@
class="bg-surface rounded-lg p-4 hover:bg-surface-hover transition-colors group"
>
<div
class="aspect-square bg-background rounded-lg mb-3 flex items-center justify-center"
class="aspect-square bg-background rounded-lg mb-3 flex items-center justify-center overflow-hidden"
>
<svg
class="w-12 h-12 text-foreground-secondary"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"
{#if album.coverArtPath && libraryStore.coverUrls[album.coverArtPath]}
<img
src={libraryStore.coverUrls[album.coverArtPath]}
alt={album.album}
class="w-full h-full object-cover"
/>
</svg>
{:else}
<svg
class="w-12 h-12 text-foreground-secondary"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"
/>
</svg>
{/if}
</div>
<h3 class="font-medium truncate group-hover:text-primary transition-colors">
{album.album}

View file

@ -29,6 +29,7 @@
let songs = $state<Song[]>([]);
let isLoading = $state(true);
let error = $state<string | null>(null);
let coverUrl = $state<string | null>(null);
let albumName = $derived(decodeURIComponent($page.params.name ?? ''));
let albumArtist = $derived(
@ -42,10 +43,17 @@
isLoading = true;
error = null;
coverUrl = null;
fetchApi<{ songs: Song[] }>(`/library/albums/${encodeURIComponent(decodeURIComponent(name))}`)
.then((data) => {
songs = data.songs;
const songWithCover = data.songs.find((s) => s.coverArtPath);
if (songWithCover) {
fetchApi<{ url: string | null }>(`/songs/${songWithCover.id}/cover-url`).then((res) => {
coverUrl = res.url;
});
}
})
.catch((e) => {
error = e instanceof Error ? e.message : 'Failed to load album';
@ -100,20 +108,26 @@
{:else}
<!-- Album header -->
<div class="flex items-end gap-6 mb-8">
<div class="w-48 h-48 bg-surface rounded-lg flex items-center justify-center flex-shrink-0">
<svg
class="w-16 h-16 text-foreground-secondary"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"
/>
</svg>
<div
class="w-48 h-48 bg-surface rounded-lg flex items-center justify-center flex-shrink-0 overflow-hidden"
>
{#if coverUrl}
<img src={coverUrl} alt={albumName} class="w-full h-full object-cover" />
{:else}
<svg
class="w-16 h-16 text-foreground-secondary"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"
/>
</svg>
{/if}
</div>
<div>
<h1 class="text-3xl font-bold mb-1">{albumName}</h1>

View file

@ -99,20 +99,30 @@
href="/playlists/{playlist.id}"
class="bg-surface rounded-lg p-4 hover:bg-surface-hover transition-colors group relative"
>
<div class="aspect-square bg-background rounded-lg mb-3 flex items-center justify-center">
<svg
class="w-12 h-12 text-foreground-secondary"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"
<div
class="aspect-square bg-background rounded-lg mb-3 flex items-center justify-center overflow-hidden"
>
{#if playlist.coverArtPath && playlistStore.coverUrls[playlist.coverArtPath]}
<img
src={playlistStore.coverUrls[playlist.coverArtPath]}
alt={playlist.name}
class="w-full h-full object-cover"
/>
</svg>
{:else}
<svg
class="w-12 h-12 text-foreground-secondary"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"
/>
</svg>
{/if}
</div>
<h3 class="font-medium truncate group-hover:text-primary transition-colors">
{playlist.name}

View file

@ -205,8 +205,9 @@
<div class="bg-surface rounded-lg overflow-hidden">
<!-- Header -->
<div
class="grid grid-cols-[1fr_1fr_1fr_80px_40px] gap-4 px-4 py-3 text-xs font-medium text-foreground-secondary uppercase tracking-wide border-b border-border"
class="grid grid-cols-[40px_1fr_1fr_1fr_80px_40px] gap-4 px-4 py-3 text-xs font-medium text-foreground-secondary uppercase tracking-wide border-b border-border"
>
<span></span>
<span>Title</span>
<span>Artist</span>
<span>Album</span>
@ -216,7 +217,7 @@
<!-- Song rows -->
{#each playlistStore.currentPlaylist.songs as song, index}
<div
class="grid grid-cols-[1fr_1fr_1fr_80px_40px] gap-4 px-4 py-3 hover:bg-background transition-colors items-center cursor-pointer"
class="grid grid-cols-[40px_1fr_1fr_1fr_80px_40px] gap-4 px-4 py-3 hover:bg-background transition-colors items-center cursor-pointer"
onclick={() => handlePlaySong(song, index)}
role="button"
tabindex="0"
@ -224,6 +225,31 @@
if (e.key === 'Enter') handlePlaySong(song, index);
}}
>
<div
class="w-10 h-10 rounded bg-background flex items-center justify-center overflow-hidden flex-shrink-0"
>
{#if song.coverArtPath && playlistStore.coverUrls[song.coverArtPath]}
<img
src={playlistStore.coverUrls[song.coverArtPath]}
alt=""
class="w-full h-full object-cover"
/>
{:else}
<svg
class="w-5 h-5 text-foreground-secondary"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"
/>
</svg>
{/if}
</div>
<span class="truncate font-medium">{song.title}</span>
<span class="truncate text-foreground-secondary">{song.artist ?? 'Unknown'}</span>
<span class="truncate text-foreground-secondary">{song.album ?? 'Unknown'}</span>

View file

@ -1,6 +1,7 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { authStore } from '$lib/stores/auth.svelte';
import { APP_VERSION } from '$lib/version';
import {
SettingsPage,
SettingsSection,
@ -60,4 +61,6 @@
onclick={handleLogout}
/>
</SettingsDangerZone>
<p class="mt-8 pb-4 text-center text-xs text-gray-400 dark:text-gray-600">v{APP_VERSION}</p>
</SettingsPage>

View file

@ -6,6 +6,7 @@
import { getLoginTranslations } from '@manacore/shared-i18n';
import { MukkeLogo } from '@manacore/shared-branding';
import { authStore } from '$lib/stores/auth.svelte';
import { APP_VERSION } from '$lib/version';
// Get redirect URL from query params or sessionStorage
const redirectTo = $derived.by(() => {
@ -60,4 +61,5 @@
{translations}
{verified}
{initialEmail}
version={APP_VERSION}
/>