28617df5bd
Adds cover_art_source to AlbumRef, two admin API helpers (refetchAlbumCover, refetchMissingCovers), a per-album retry button gated on is_admin in the album detail page, and a bulk-refetch section in the admin overview. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
185 lines
6.5 KiB
Svelte
185 lines
6.5 KiB
Svelte
<script lang="ts">
|
|
import { Play, Shuffle } from 'lucide-svelte';
|
|
import { page } from '$app/state';
|
|
import { createAlbumQuery } from '$lib/api/queries';
|
|
import { qk } from '$lib/api/queries';
|
|
import { pageTitle } from '$lib/branding';
|
|
import TrackRow from '$lib/components/TrackRow.svelte';
|
|
import LibrarySkeleton from '$lib/components/LibrarySkeleton.svelte';
|
|
import ApiErrorBanner from '$lib/components/ApiErrorBanner.svelte';
|
|
import LikeButton from '$lib/components/LikeButton.svelte';
|
|
import { useDelayed } from '$lib/utils/useDelayed.svelte';
|
|
import { formatDuration } from '$lib/media/duration';
|
|
import { FALLBACK_COVER } from '$lib/media/covers';
|
|
import type { ApiError } from '$lib/api/client';
|
|
import { playQueue } from '$lib/player/store.svelte';
|
|
import { user } from '$lib/auth/store.svelte';
|
|
import { refetchAlbumCover } from '$lib/api/admin';
|
|
import { useQueryClient } from '@tanstack/svelte-query';
|
|
|
|
const id = $derived(page.params.id ?? '');
|
|
const queryStore = $derived(createAlbumQuery(id));
|
|
const query = $derived($queryStore);
|
|
const showSkeleton = useDelayed(() => query.isPending);
|
|
|
|
const notFound = $derived(
|
|
query.isError && (query.error as unknown as ApiError | undefined)?.code === 'not_found'
|
|
);
|
|
|
|
function onCoverError(e: Event) {
|
|
(e.currentTarget as HTMLImageElement).src = FALLBACK_COVER;
|
|
}
|
|
|
|
let isStartingPlay = $state(false);
|
|
|
|
function shuffleArr<T>(items: T[]): T[] {
|
|
const arr = items.slice();
|
|
for (let i = arr.length - 1; i > 0; i--) {
|
|
const j = Math.floor(Math.random() * (i + 1));
|
|
[arr[i], arr[j]] = [arr[j], arr[i]];
|
|
}
|
|
return arr;
|
|
}
|
|
|
|
function onPlay() {
|
|
const data = query.data;
|
|
if (isStartingPlay || !data || data.tracks.length === 0) return;
|
|
isStartingPlay = true;
|
|
try {
|
|
playQueue(data.tracks, 0);
|
|
} finally {
|
|
isStartingPlay = false;
|
|
}
|
|
}
|
|
|
|
function onShuffle() {
|
|
const data = query.data;
|
|
if (isStartingPlay || !data || data.tracks.length === 0) return;
|
|
isStartingPlay = true;
|
|
try {
|
|
playQueue(shuffleArr(data.tracks), 0);
|
|
} finally {
|
|
isStartingPlay = false;
|
|
}
|
|
}
|
|
|
|
const queryClient = useQueryClient();
|
|
let refetching = $state(false);
|
|
let refetchError = $state<string | null>(null);
|
|
|
|
const isAdmin = $derived(user.value?.is_admin === true);
|
|
|
|
async function onRefetchCover() {
|
|
const data = query.data;
|
|
if (!data || refetching) return;
|
|
refetching = true;
|
|
refetchError = null;
|
|
try {
|
|
await refetchAlbumCover(data.id);
|
|
await queryClient.invalidateQueries({ queryKey: qk.album(data.id) });
|
|
} catch (e) {
|
|
refetchError = (e as { code?: string })?.code ?? 'refetch failed';
|
|
} finally {
|
|
refetching = false;
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<svelte:head>
|
|
<title>{pageTitle(query.data?.title ? `Album · ${query.data.title}` : 'Album')}</title>
|
|
</svelte:head>
|
|
|
|
<div class="space-y-6">
|
|
{#if notFound}
|
|
<div role="alert" class="rounded border border-border bg-surface p-4">
|
|
<p>Album not found.</p>
|
|
<a href="/" class="mt-2 inline-block text-sm text-accent">Back to Library</a>
|
|
</div>
|
|
{:else if query.isError}
|
|
<ApiErrorBanner error={query.error} onRetry={query.refetch} />
|
|
{:else if showSkeleton.value && !query.data}
|
|
<LibrarySkeleton variant="album" />
|
|
{:else if query.data}
|
|
{@const album = query.data}
|
|
<a
|
|
href={`/artists/${album.artist_id}`}
|
|
class="text-sm text-text-secondary hover:text-text-primary"
|
|
>
|
|
← {album.artist_name}
|
|
</a>
|
|
|
|
<header class="flex flex-col gap-4 md:flex-row md:items-end">
|
|
<img
|
|
src={album.cover_url}
|
|
alt={album.title}
|
|
class="h-60 w-60 rounded object-cover"
|
|
loading="lazy"
|
|
onerror={onCoverError}
|
|
/>
|
|
<div class="flex-1 space-y-2">
|
|
<div class="flex items-center gap-3">
|
|
<h1 class="font-display text-2xl font-medium text-text-primary">{album.title}</h1>
|
|
<LikeButton entityType="album" entityId={album.id} size="lg" />
|
|
</div>
|
|
<p>
|
|
<a href={`/artists/${album.artist_id}`} class="hover:underline">{album.artist_name}</a>
|
|
</p>
|
|
{#if album.year}
|
|
<p class="text-sm text-text-secondary">{album.year}</p>
|
|
{/if}
|
|
<p class="text-sm text-text-secondary">
|
|
{album.track_count} {album.track_count === 1 ? 'track' : 'tracks'}
|
|
· {formatDuration(album.duration_sec)}
|
|
</p>
|
|
<div class="flex items-center gap-2 pt-2">
|
|
<button
|
|
type="button"
|
|
aria-label={`Play ${album.title}`}
|
|
onclick={onPlay}
|
|
disabled={isStartingPlay || album.tracks.length === 0}
|
|
class="flex items-center gap-2 rounded-md bg-accent px-4 py-2 text-sm font-medium text-text-primary hover:opacity-90 focus-visible:ring-2 focus-visible:ring-accent disabled:opacity-50"
|
|
>
|
|
<Play size={16} strokeWidth={1.5} fill="currentColor" />
|
|
Play
|
|
</button>
|
|
<button
|
|
type="button"
|
|
aria-label={`Shuffle ${album.title}`}
|
|
onclick={onShuffle}
|
|
disabled={isStartingPlay || album.tracks.length === 0}
|
|
class="flex items-center gap-2 rounded-md border border-border bg-surface px-4 py-2 text-sm font-medium text-text-primary hover:border-accent focus-visible:ring-2 focus-visible:ring-accent disabled:opacity-50"
|
|
>
|
|
<Shuffle size={16} strokeWidth={1.5} />
|
|
Shuffle
|
|
</button>
|
|
{#if isAdmin}
|
|
<button
|
|
type="button"
|
|
onclick={onRefetchCover}
|
|
disabled={refetching}
|
|
aria-label={`Refetch cover for ${album.title}`}
|
|
title={album.cover_art_source === 'none' ? 'No cover found previously — try again' : 'Refetch from MusicBrainz'}
|
|
class="flex items-center gap-2 rounded-md border border-border bg-surface px-3 py-2 text-sm font-medium text-text-secondary hover:border-accent disabled:opacity-50"
|
|
>
|
|
Refetch cover
|
|
</button>
|
|
{/if}
|
|
{#if refetchError}
|
|
<span class="text-sm text-oxblood">{refetchError}</span>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
{#if album.tracks.length === 0}
|
|
<p class="text-text-secondary">This album has no tracks.</p>
|
|
{:else}
|
|
<div class="overflow-hidden rounded border border-border">
|
|
{#each album.tracks as track, i (track.id)}
|
|
<TrackRow tracks={album.tracks} index={i} />
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
</div>
|