feat(web/m7-353): admin cover-refetch UI (per-album + bulk)

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>
This commit is contained in:
2026-05-04 15:15:26 -04:00
parent d4a837b904
commit 28617df5bd
5 changed files with 133 additions and 1 deletions
+39 -1
View File
@@ -10,7 +10,8 @@
rejectRequest,
resolveQuarantine,
deleteQuarantineFile,
deleteQuarantineViaLidarr
deleteQuarantineViaLidarr,
refetchMissingCovers
} from '$lib/api/admin';
import { qk } from '$lib/api/queries';
import { copyForCode } from '$lib/api/error-copy';
@@ -176,6 +177,24 @@
showToast(copyForCode((e as { code?: string }).code));
}
}
// ---- Cover art bulk refetch ----
let bulkBusy = $state(false);
let bulkResult = $state<string | null>(null);
async function onBulkRefetch() {
if (bulkBusy) return;
bulkBusy = true;
bulkResult = null;
try {
const { queued } = await refetchMissingCovers();
bulkResult = `Queued ${queued} albums for cover refetch.`;
} catch (e) {
bulkResult = `Failed: ${(e as { code?: string })?.code ?? 'unknown'}`;
} finally {
bulkBusy = false;
}
}
</script>
<svelte:head><title>{pageTitle('Admin · Overview')}</title></svelte:head>
@@ -257,6 +276,25 @@
{/if}
</section>
<!-- Cover art bulk refetch -->
<section class="rounded border border-border bg-surface p-4">
<h2 class="font-display text-lg font-medium">Cover art</h2>
<p class="mt-1 text-sm text-text-secondary">
Refetch cover art for albums that are missing one or where the previous attempt failed.
</p>
<button
type="button"
onclick={onBulkRefetch}
disabled={bulkBusy}
class="mt-3 rounded-md bg-accent px-4 py-2 text-sm font-medium text-text-primary disabled:opacity-50"
>
{bulkBusy ? 'Queueing…' : 'Refetch missing covers'}
</button>
{#if bulkResult}
<p class="mt-2 text-sm">{bulkResult}</p>
{/if}
</section>
<!-- Quarantine preview -->
<section class="space-y-3">
<header class="flex items-baseline justify-between">
+40
View File
@@ -2,6 +2,7 @@
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';
@@ -12,6 +13,9 @@
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));
@@ -58,6 +62,27 @@
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>
@@ -127,6 +152,21 @@
<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>