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:
@@ -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<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValueOnce({ queued: 7 });
|
||||||
|
const got = await refetchMissingCovers();
|
||||||
|
expect(api.post).toHaveBeenCalledWith('/api/admin/covers/refetch-missing', {});
|
||||||
|
expect(got.queued).toBe(7);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -161,3 +161,23 @@ export function createQuarantineActionsQuery(limit: number = 50) {
|
|||||||
staleTime: 60_000
|
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<RefetchAlbumCoverResponse> {
|
||||||
|
return api.post<RefetchAlbumCoverResponse>(`/api/admin/albums/${albumId}/cover/refetch`, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RefetchMissingResponse = {
|
||||||
|
queued: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function refetchMissingCovers(): Promise<RefetchMissingResponse> {
|
||||||
|
return api.post<RefetchMissingResponse>('/api/admin/covers/refetch-missing', {});
|
||||||
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export type AlbumRef = {
|
|||||||
track_count: number;
|
track_count: number;
|
||||||
duration_sec: number;
|
duration_sec: number;
|
||||||
cover_url: string;
|
cover_url: string;
|
||||||
|
cover_art_source: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TrackRef = {
|
export type TrackRef = {
|
||||||
|
|||||||
@@ -10,7 +10,8 @@
|
|||||||
rejectRequest,
|
rejectRequest,
|
||||||
resolveQuarantine,
|
resolveQuarantine,
|
||||||
deleteQuarantineFile,
|
deleteQuarantineFile,
|
||||||
deleteQuarantineViaLidarr
|
deleteQuarantineViaLidarr,
|
||||||
|
refetchMissingCovers
|
||||||
} from '$lib/api/admin';
|
} from '$lib/api/admin';
|
||||||
import { qk } from '$lib/api/queries';
|
import { qk } from '$lib/api/queries';
|
||||||
import { copyForCode } from '$lib/api/error-copy';
|
import { copyForCode } from '$lib/api/error-copy';
|
||||||
@@ -176,6 +177,24 @@
|
|||||||
showToast(copyForCode((e as { code?: string }).code));
|
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>
|
</script>
|
||||||
|
|
||||||
<svelte:head><title>{pageTitle('Admin · Overview')}</title></svelte:head>
|
<svelte:head><title>{pageTitle('Admin · Overview')}</title></svelte:head>
|
||||||
@@ -257,6 +276,25 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</section>
|
</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 -->
|
<!-- Quarantine preview -->
|
||||||
<section class="space-y-3">
|
<section class="space-y-3">
|
||||||
<header class="flex items-baseline justify-between">
|
<header class="flex items-baseline justify-between">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { Play, Shuffle } from 'lucide-svelte';
|
import { Play, Shuffle } from 'lucide-svelte';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { createAlbumQuery } from '$lib/api/queries';
|
import { createAlbumQuery } from '$lib/api/queries';
|
||||||
|
import { qk } from '$lib/api/queries';
|
||||||
import { pageTitle } from '$lib/branding';
|
import { pageTitle } from '$lib/branding';
|
||||||
import TrackRow from '$lib/components/TrackRow.svelte';
|
import TrackRow from '$lib/components/TrackRow.svelte';
|
||||||
import LibrarySkeleton from '$lib/components/LibrarySkeleton.svelte';
|
import LibrarySkeleton from '$lib/components/LibrarySkeleton.svelte';
|
||||||
@@ -12,6 +13,9 @@
|
|||||||
import { FALLBACK_COVER } from '$lib/media/covers';
|
import { FALLBACK_COVER } from '$lib/media/covers';
|
||||||
import type { ApiError } from '$lib/api/client';
|
import type { ApiError } from '$lib/api/client';
|
||||||
import { playQueue } from '$lib/player/store.svelte';
|
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 id = $derived(page.params.id ?? '');
|
||||||
const queryStore = $derived(createAlbumQuery(id));
|
const queryStore = $derived(createAlbumQuery(id));
|
||||||
@@ -58,6 +62,27 @@
|
|||||||
isStartingPlay = false;
|
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>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -127,6 +152,21 @@
|
|||||||
<Shuffle size={16} strokeWidth={1.5} />
|
<Shuffle size={16} strokeWidth={1.5} />
|
||||||
Shuffle
|
Shuffle
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
Reference in New Issue
Block a user