From 28617df5bdb7eae88b395a553d51d0ea4938593e Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 4 May 2026 15:15:26 -0400 Subject: [PATCH] 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 --- web/src/lib/api/admin.covers.test.ts | 33 ++++++++++++++++++++ web/src/lib/api/admin.ts | 20 +++++++++++++ web/src/lib/api/types.ts | 1 + web/src/routes/admin/+page.svelte | 40 ++++++++++++++++++++++++- web/src/routes/albums/[id]/+page.svelte | 40 +++++++++++++++++++++++++ 5 files changed, 133 insertions(+), 1 deletion(-) create mode 100644 web/src/lib/api/admin.covers.test.ts diff --git a/web/src/lib/api/admin.covers.test.ts b/web/src/lib/api/admin.covers.test.ts new file mode 100644 index 00000000..5bc89734 --- /dev/null +++ b/web/src/lib/api/admin.covers.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('./client', () => ({ + api: { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + del: vi.fn() + } +})); + +import { refetchAlbumCover, refetchMissingCovers } from './admin'; +import { api } from './client'; + +describe('admin covers API', () => { + beforeEach(() => vi.clearAllMocks()); + + it('refetchAlbumCover POSTs to the correct path', async () => { + (api.post as unknown as ReturnType).mockResolvedValueOnce({ + album_id: 'a1', cover_art_path: '/x.jpg', cover_art_source: 'mbcaa' + }); + const got = await refetchAlbumCover('a1'); + expect(api.post).toHaveBeenCalledWith('/api/admin/albums/a1/cover/refetch', {}); + expect(got.cover_art_source).toBe('mbcaa'); + }); + + it('refetchMissingCovers POSTs to the bulk endpoint', async () => { + (api.post as unknown as ReturnType).mockResolvedValueOnce({ queued: 7 }); + const got = await refetchMissingCovers(); + expect(api.post).toHaveBeenCalledWith('/api/admin/covers/refetch-missing', {}); + expect(got.queued).toBe(7); + }); +}); diff --git a/web/src/lib/api/admin.ts b/web/src/lib/api/admin.ts index 0c14d01f..f63dc685 100644 --- a/web/src/lib/api/admin.ts +++ b/web/src/lib/api/admin.ts @@ -161,3 +161,23 @@ export function createQuarantineActionsQuery(limit: number = 50) { staleTime: 60_000 }); } + +// Admin cover art --------------------------------------------------------- + +export type RefetchAlbumCoverResponse = { + album_id: string; + cover_art_path: string | null; + cover_art_source: string | null; +}; + +export async function refetchAlbumCover(albumId: string): Promise { + return api.post(`/api/admin/albums/${albumId}/cover/refetch`, {}); +} + +export type RefetchMissingResponse = { + queued: number; +}; + +export async function refetchMissingCovers(): Promise { + return api.post('/api/admin/covers/refetch-missing', {}); +} diff --git a/web/src/lib/api/types.ts b/web/src/lib/api/types.ts index 2434bdf2..ecdd6340 100644 --- a/web/src/lib/api/types.ts +++ b/web/src/lib/api/types.ts @@ -27,6 +27,7 @@ export type AlbumRef = { track_count: number; duration_sec: number; cover_url: string; + cover_art_source: string | null; }; export type TrackRef = { diff --git a/web/src/routes/admin/+page.svelte b/web/src/routes/admin/+page.svelte index 3f84e227..2fd185bc 100644 --- a/web/src/routes/admin/+page.svelte +++ b/web/src/routes/admin/+page.svelte @@ -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(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; + } + } {pageTitle('Admin · Overview')} @@ -257,6 +276,25 @@ {/if} + +
+

Cover art

+

+ Refetch cover art for albums that are missing one or where the previous attempt failed. +

+ + {#if bulkResult} +

{bulkResult}

+ {/if} +
+
diff --git a/web/src/routes/albums/[id]/+page.svelte b/web/src/routes/albums/[id]/+page.svelte index d2a05c22..edf88d4b 100644 --- a/web/src/routes/albums/[id]/+page.svelte +++ b/web/src/routes/albums/[id]/+page.svelte @@ -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(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; + } + } @@ -127,6 +152,21 @@ Shuffle + {#if isAdmin} + + {/if} + {#if refetchError} + {refetchError} + {/if}