feat: Reports, Duplicates and Suspect sources live under Collection, with their counts on its rail and a "To look at" line naming the ones waiting; notices link there (M504 #5625)
release / govulncheck (push) Successful in 21s
release / web (push) Successful in 1m21s
release / go (push) Successful in 1m39s
release / integration (push) Successful in 5m31s
release / android (push) Successful in 6m5s
release / Build signed APK (releases and dev) (push) Successful in 6m21s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 32s
release / Verify release artifacts (tag releases only) (push) Skipped
release / govulncheck (push) Successful in 21s
release / web (push) Successful in 1m21s
release / go (push) Successful in 1m39s
release / integration (push) Successful in 5m31s
release / android (push) Successful in 6m5s
release / Build signed APK (releases and dev) (push) Successful in 6m21s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 32s
release / Verify release artifacts (tag releases only) (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { createWantArtistsQuery } from '#lib/api/wanted.js';
|
||||
import { createAdminSummaryQuery } from '#lib/api/admin.js';
|
||||
import { QUEUES, countOf, queueLabel } from '#lib/admin/queues.js';
|
||||
import CollectionRail from '#lib/components/wanted/CollectionRail.svelte';
|
||||
|
||||
let { children } = $props<{ children: import('svelte').Snippet }>();
|
||||
@@ -28,6 +30,15 @@
|
||||
return parts.join(', ') + '. Nothing needs you.';
|
||||
});
|
||||
const needsYou = $derived(!!counts?.attention || !!counts?.unmatched);
|
||||
|
||||
// The library's health queues (M504) count from the admin summary, the
|
||||
// same read Admin's Overview makes. The line under the verdict names the
|
||||
// ones with something in them; each links to its page.
|
||||
const summaryStore = createAdminSummaryQuery();
|
||||
const summary = $derived($summaryStore.data);
|
||||
const health = $derived(
|
||||
QUEUES.filter((d) => d.href.startsWith('/collection/') && countOf(summary, d.key) > 0)
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="space-y-4">
|
||||
@@ -50,12 +61,21 @@
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
{#if health.length > 0}
|
||||
<p class="text-sm text-text-primary" data-testid="collection-health">
|
||||
To look at:
|
||||
{#each health as d, i (d.key)}<a href={d.href} class="text-accent-fg hover:underline"
|
||||
>{queueLabel(d, countOf(summary, d.key))}</a
|
||||
>{i < health.length - 1 ? ', ' : '.'}
|
||||
{/each}
|
||||
</p>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<div class="md:grid md:grid-cols-[11rem_minmax(0,1fr)] md:gap-6">
|
||||
<div class="mb-4 md:mb-0">
|
||||
<div class="md:sticky md:top-4">
|
||||
<CollectionRail {counts} />
|
||||
<CollectionRail {counts} {summary} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
|
||||
@@ -3,22 +3,31 @@ import { render, screen } from '@testing-library/svelte';
|
||||
import { createRawSnippet } from 'svelte';
|
||||
import { mockInfiniteQuery } from '../../test-utils/query';
|
||||
import { pageUrlModule } from '#test-utils/mocks/appState.js';
|
||||
import type { WantArtistsPage, WantCounts } from '#lib/api/types.js';
|
||||
import { mockQuery } from '../../test-utils/query';
|
||||
import type { AdminSummary, WantArtistsPage, WantCounts } from '#lib/api/types.js';
|
||||
|
||||
const state = vi.hoisted(() => ({ pageUrl: new URL('http://localhost/collection') }));
|
||||
vi.mock('$app/state', () => pageUrlModule(state));
|
||||
vi.mock('#lib/api/wanted.js', () => ({ createWantArtistsQuery: vi.fn() }));
|
||||
vi.mock('#lib/api/admin.js', () => ({ createAdminSummaryQuery: vi.fn() }));
|
||||
|
||||
import WantedLayout from './+layout.svelte';
|
||||
import { createWantArtistsQuery } from '#lib/api/wanted.js';
|
||||
import { createAdminSummaryQuery } from '#lib/api/admin.js';
|
||||
|
||||
const counts: WantCounts = { artists: 7, wanted: 5, missing: 1, held: 9, upgrades: 6, following: 2, activity: 3, attention: 0, blocked: 4, unmatched: 3 };
|
||||
const children = createRawSnippet(() => ({ render: () => `<p data-testid="tab-body">body</p>` }));
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
function setup(c: WantCounts = counts, path = '/collection') {
|
||||
const none = { count: 0, since: null };
|
||||
const clear: AdminSummary = { requests: none, reports: none, duplicates: none, suspect: none, missing: none };
|
||||
|
||||
function setup(c: WantCounts = counts, path = '/collection', summary: AdminSummary = clear) {
|
||||
state.pageUrl = new URL(`http://localhost${path}`);
|
||||
vi.mocked(createAdminSummaryQuery).mockReturnValue(
|
||||
mockQuery({ data: summary }) as unknown as ReturnType<typeof createAdminSummaryQuery>
|
||||
);
|
||||
const page: WantArtistsPage = {
|
||||
items: [],
|
||||
total: 0,
|
||||
@@ -47,7 +56,10 @@ describe('/collection layout', () => {
|
||||
['/collection/activity', 'Activity 3'],
|
||||
['/collection/following', 'Following 2'],
|
||||
['/collection/blocked', 'Blocked 4'],
|
||||
['/collection/unmatched', 'Unmatched 3']
|
||||
['/collection/unmatched', 'Unmatched 3'],
|
||||
['/collection/reports', 'Reports'],
|
||||
['/collection/duplicates', 'Duplicates'],
|
||||
['/collection/suspect-sources', 'Suspect sources']
|
||||
]);
|
||||
expect(screen.getByRole('link', { name: /activity/i })).toHaveAttribute('aria-current', 'page');
|
||||
expect(screen.getByRole('link', { name: /^catalogue/i })).not.toHaveAttribute('aria-current');
|
||||
@@ -76,6 +88,33 @@ describe('/collection layout', () => {
|
||||
expect(screen.getByRole('link', { name: /see unmatched/i }).getAttribute('href')).toBe('/collection/unmatched');
|
||||
});
|
||||
|
||||
// The library's health queues moved here from Admin (M504): their counts
|
||||
// come from the admin summary, accented, and a line names the ones waiting.
|
||||
test('reports, duplicates and suspect sources carry what waits on you, and a line links to each', () => {
|
||||
setup(counts, '/collection', {
|
||||
...clear,
|
||||
reports: { count: 2, since: null },
|
||||
duplicates: { count: 5, since: null },
|
||||
missing: { count: 9, since: null }
|
||||
});
|
||||
const nav = screen.getByRole('navigation', { name: /collection sections/i });
|
||||
const dup = Array.from(nav.querySelectorAll('a')).find((a) => a.getAttribute('href') === '/collection/duplicates')!;
|
||||
expect(dup.textContent?.replace(/\s+/g, ' ').trim()).toBe('Duplicates 5');
|
||||
const health = screen.getByTestId('collection-health');
|
||||
expect(health.textContent?.replace(/\s+/g, ' ').trim()).toBe(
|
||||
'To look at: 2 broken tracks reported, 5 sets of duplicates to review.'
|
||||
);
|
||||
expect(Array.from(health.querySelectorAll('a')).map((a) => a.getAttribute('href'))).toEqual([
|
||||
'/collection/reports',
|
||||
'/collection/duplicates'
|
||||
]);
|
||||
});
|
||||
|
||||
test('with nothing waiting there is no health line', () => {
|
||||
setup();
|
||||
expect(screen.queryByTestId('collection-health')).toBeNull();
|
||||
});
|
||||
|
||||
test("an artist's page sits under Catalogue", () => {
|
||||
setup(counts, '/collection/artist/blur');
|
||||
expect(screen.getByRole('link', { name: /^catalogue/i })).toHaveAttribute('aria-current', 'page');
|
||||
|
||||
@@ -0,0 +1,467 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { pageTitle } from '#lib/branding.js';
|
||||
import { Copy, Info, TriangleAlert } from 'lucide-svelte';
|
||||
import {
|
||||
createDuplicatesQuery,
|
||||
createResolvedDuplicatesQuery,
|
||||
runDuplicateSweep,
|
||||
dismissDuplicateGroup,
|
||||
mergeDuplicateGroup
|
||||
} from '#lib/api/admin.js';
|
||||
import { errMessage } from '#lib/api/errors.js';
|
||||
import { pushToast } from '#lib/stores/toast.svelte.js';
|
||||
import { relativeTime } from '#lib/utils/relativeTime.js';
|
||||
import type {
|
||||
AdminDuplicateGroup,
|
||||
AdminDuplicateMember,
|
||||
DuplicatesView,
|
||||
ResolvedDuplicateActivity
|
||||
} from '#lib/api/types.js';
|
||||
import ListContinuation from '#lib/components/ListContinuation.svelte';
|
||||
import TabStrip from '#lib/components/TabStrip.svelte';
|
||||
|
||||
// Tracks the duplicate sweep believes hold one recording (#3912). The
|
||||
// resolver (M498, M502) settles what is safe on its own: a song held twice on
|
||||
// one album, and a video rip with a clean copy elsewhere. What is left comes
|
||||
// in two tabs: groups that need the operator, and the same song on different
|
||||
// releases, which is kept on purpose. A third tab lists what the resolver
|
||||
// did.
|
||||
//
|
||||
// Dismissing a group says "these are not duplicates", and the sweep will not
|
||||
// propose that set again. On the cross-release tab it reads "Not the same
|
||||
// song" and also undoes the link that made the copies one song (#5438). Merging (#3911) keeps one copy, moves the others'
|
||||
// likes, plays and playlist entries onto it, and deletes their files, so it
|
||||
// asks twice.
|
||||
|
||||
type Tab = DuplicatesView | 'resolved';
|
||||
const fromUrl = page.url.searchParams.get('view');
|
||||
let tab = $state<Tab>(fromUrl === 'cross_release' || fromUrl === 'resolved' ? fromUrl : 'review');
|
||||
// The resolved tab still reads the review query: it carries the sweep state
|
||||
// and every tab's count.
|
||||
const groupView = $derived<DuplicatesView>(tab === 'cross_release' ? 'cross_release' : 'review');
|
||||
|
||||
let sweeping = $state(false);
|
||||
let dismissing = $state<string | null>(null);
|
||||
// Per group: which copy to keep (defaults to the proposed survivor) and the
|
||||
// two-click confirm.
|
||||
let keepChoice = $state<Record<string, string>>({});
|
||||
let confirmingMerge = $state<string | null>(null);
|
||||
let merging = $state<string | null>(null);
|
||||
|
||||
const queryStore = $derived(createDuplicatesQuery(groupView));
|
||||
const query = $derived($queryStore);
|
||||
const first = $derived(query.data?.pages?.[0]);
|
||||
const groups = $derived(
|
||||
(query.data?.pages ?? []).flatMap((p) => p.groups) as AdminDuplicateGroup[]
|
||||
);
|
||||
const counts = $derived(first?.counts);
|
||||
const sweep = $derived(first?.sweep);
|
||||
const prints = $derived(first?.fingerprints);
|
||||
|
||||
const resolvedStore = createResolvedDuplicatesQuery();
|
||||
const resolved = $derived($resolvedStore);
|
||||
const activity = $derived(
|
||||
(resolved.data?.pages ?? []).flatMap((p) => p.items) as ResolvedDuplicateActivity[]
|
||||
);
|
||||
|
||||
const tabs = $derived([
|
||||
{ id: 'review' as Tab, label: 'Needs review', count: counts?.review },
|
||||
{ id: 'cross_release' as Tab, label: 'Across releases', count: counts?.cross_release },
|
||||
{ id: 'resolved' as Tab, label: 'Resolved automatically', count: counts?.resolved }
|
||||
]);
|
||||
|
||||
async function onRunSweep() {
|
||||
sweeping = true;
|
||||
try {
|
||||
await runDuplicateSweep();
|
||||
pushToast('Duplicate sweep started.');
|
||||
query.refetch();
|
||||
} catch (e: unknown) {
|
||||
pushToast(errMessage(e), 'error');
|
||||
} finally {
|
||||
sweeping = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onDismiss(group: AdminDuplicateGroup) {
|
||||
dismissing = group.id;
|
||||
try {
|
||||
await dismissDuplicateGroup(group.id);
|
||||
pushToast('Marked as not duplicates.');
|
||||
query.refetch();
|
||||
} catch (e: unknown) {
|
||||
pushToast(errMessage(e), 'error');
|
||||
} finally {
|
||||
dismissing = null;
|
||||
}
|
||||
}
|
||||
|
||||
function keeperOf(group: AdminDuplicateGroup): string {
|
||||
return keepChoice[group.id] ?? group.survivor_track_id;
|
||||
}
|
||||
|
||||
function fileCountLabel(n: number): string {
|
||||
return n === 1 ? '1 file' : `${n} files`;
|
||||
}
|
||||
|
||||
async function onMerge(group: AdminDuplicateGroup) {
|
||||
// First click arms; the second, on the button that now names how many files
|
||||
// go, does it. A merge deletes files, and nothing brings them back.
|
||||
if (confirmingMerge !== group.id) {
|
||||
confirmingMerge = group.id;
|
||||
return;
|
||||
}
|
||||
confirmingMerge = null;
|
||||
merging = group.id;
|
||||
try {
|
||||
const result = await mergeDuplicateGroup(group.id, { survivor_track_id: keeperOf(group) });
|
||||
pushToast(`Merged. Removed ${fileCountLabel(result.removed_paths.length)}.`);
|
||||
query.refetch();
|
||||
} catch (e: unknown) {
|
||||
pushToast(errMessage(e), 'error');
|
||||
} finally {
|
||||
merging = null;
|
||||
}
|
||||
}
|
||||
|
||||
// "Identical audio" and "same recording" are different claims, and an
|
||||
// operator deciding whether to merge needs to know which one they are
|
||||
// looking at before anything else.
|
||||
function tierLabel(g: AdminDuplicateGroup): string {
|
||||
if (g.tier === 'exact') return 'Identical audio';
|
||||
const match = Math.round((1 - (g.worst_bit_error_rate ?? 0)) * 100);
|
||||
return `Same recording · ${match}% match`;
|
||||
}
|
||||
|
||||
function sizeLabel(bytes: number): string {
|
||||
return `${(bytes / 1_000_000).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function durationLabel(sec: number): string {
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = sec % 60;
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function countLabel(n: number, one: string, many: string): string {
|
||||
return n === 1 ? `1 ${one}` : `${n} ${many}`;
|
||||
}
|
||||
|
||||
// Identical audio under different titles means at least one file's tags are
|
||||
// wrong, and the recording the other title names may not be in the library at
|
||||
// all. #3885: "WWW" was a second copy of the instrumental, and the vocal was
|
||||
// missing with nothing to say so. Exact tier only: an acoustic match spans
|
||||
// encodings, where a "Remastered" suffix is routine rather than a lie.
|
||||
function differingTitles(g: AdminDuplicateGroup): string[] {
|
||||
if (g.tier !== 'exact') return [];
|
||||
const byKey = new Map<string, string>();
|
||||
for (const m of g.members) {
|
||||
const title = m.title.trim().replace(/\s+/g, ' ');
|
||||
const key = title.toLowerCase();
|
||||
if (!byKey.has(key)) byKey.set(key, title);
|
||||
}
|
||||
return byKey.size > 1 ? [...byKey.values()] : [];
|
||||
}
|
||||
|
||||
function quotedList(titles: string[]): string {
|
||||
const q = titles.map((t) => `“${t}”`);
|
||||
return q.length <= 2 ? q.join(' and ') : `${q.slice(0, -1).join(', ')} and ${q[q.length - 1]}`;
|
||||
}
|
||||
|
||||
// What the copy carries is the fact that decides which to keep.
|
||||
function historyLabel(m: AdminDuplicateMember): string {
|
||||
if (m.like_count === 0 && m.play_count === 0) return 'no likes or plays';
|
||||
return `${countLabel(m.like_count, 'like', 'likes')} · ${countLabel(m.play_count, 'play', 'plays')}`;
|
||||
}
|
||||
|
||||
function position(m: AdminDuplicateMember): string {
|
||||
const parts: string[] = [];
|
||||
if (m.disc_number != null) parts.push(`disc ${m.disc_number}`);
|
||||
if (m.track_number != null) parts.push(`track ${m.track_number}`);
|
||||
return parts.join(', ');
|
||||
}
|
||||
|
||||
function basename(path: string): string {
|
||||
return path.slice(path.lastIndexOf('/') + 1);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{pageTitle('Collection · Duplicates')}</title></svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<header class="space-y-1">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<h2 class="font-display text-2xl font-medium text-text-primary">Duplicates</h2>
|
||||
<button
|
||||
type="button"
|
||||
onclick={onRunSweep}
|
||||
disabled={sweeping || sweep?.state === 'running'}
|
||||
class="flex h-8 items-center gap-1 rounded-md bg-action-primary px-4 text-sm text-action-fg hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
{sweeping ? 'Starting…' : 'Sweep now'}
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-text-secondary">
|
||||
Tracks that hold the same recording more than once. A song held twice on one album is merged on
|
||||
its own, and so is a video rip with a clean copy elsewhere. What's left here needs you.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{#if sweep}
|
||||
<p class="text-sm text-text-secondary" data-testid="sweep-status">
|
||||
{#if sweep.state === 'never'}
|
||||
The duplicate sweep hasn't run yet.
|
||||
{:else if sweep.state === 'running'}
|
||||
Sweeping now — started {relativeTime(sweep.started_at ?? '')}.
|
||||
{:else}
|
||||
Last swept {relativeTime(sweep.finished_at ?? '')}, comparing
|
||||
{(sweep.candidates ?? 0).toLocaleString()} tracks.
|
||||
{#if sweep.error_message}
|
||||
<span class="text-error-fg">It stopped early: {sweep.error_message}</span>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if prints && prints.pending > 0}
|
||||
{#if prints.enabled === false}
|
||||
Fingerprinting is off, so {prints.pending.toLocaleString()} tracks without a current
|
||||
fingerprint aren't compared.
|
||||
{:else}
|
||||
{prints.pending.toLocaleString()} tracks are still waiting for a fingerprint and join
|
||||
the comparison once they have one.
|
||||
{/if}
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<TabStrip items={tabs} bind:value={tab} ariaLabel="Duplicates" />
|
||||
|
||||
{#if tab === 'resolved'}
|
||||
{#if resolved.isPending}
|
||||
<p class="text-text-secondary">Loading…</p>
|
||||
{:else if resolved.isError && !resolved.isFetchNextPageError}
|
||||
<p class="text-sm text-error-fg">
|
||||
Couldn't load what was resolved.
|
||||
<button type="button" class="underline hover:no-underline" onclick={() => resolved.refetch()}>Try again</button>
|
||||
</p>
|
||||
{:else if activity.length === 0}
|
||||
<div class="rounded-lg border border-border bg-surface p-6 text-center" data-testid="resolved-empty">
|
||||
<Copy size={28} strokeWidth={1} class="mx-auto text-text-muted" />
|
||||
<p class="mt-3 text-text-primary">Nothing resolved automatically yet.</p>
|
||||
<p class="mt-1 text-sm text-text-secondary">
|
||||
Merges made on their own are listed here.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<ul class="divide-y divide-border rounded-lg border border-border bg-surface">
|
||||
{#each activity as item (item.id)}
|
||||
{@const removed = item.details.removed ?? []}
|
||||
<li class="space-y-1 px-4 py-3 text-sm" data-testid="resolved-item">
|
||||
<p class="text-text-primary">
|
||||
Kept <span class="font-mono text-xs">{basename(item.details.survivor_path ?? '')}</span>
|
||||
{#if item.details.survivor_reason}
|
||||
<span class="text-text-secondary">({item.details.survivor_reason})</span>
|
||||
{/if}
|
||||
and removed {fileCountLabel(removed.length)}.
|
||||
</p>
|
||||
{#each removed as r (r.track_id)}
|
||||
<p class="truncate font-mono text-xs text-text-muted" title={r.file_path}>{r.file_path}</p>
|
||||
{/each}
|
||||
<p class="text-xs text-text-muted">{relativeTime(item.created_at)}</p>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<ListContinuation
|
||||
hasMore={resolved.hasNextPage}
|
||||
loading={resolved.isFetchingNextPage}
|
||||
failed={resolved.isFetchNextPageError}
|
||||
onMore={() => resolved.fetchNextPage()}
|
||||
/>
|
||||
{/if}
|
||||
{:else if query.isPending}
|
||||
<p class="text-text-secondary">Loading duplicates…</p>
|
||||
{:else if query.isError && !query.isFetchNextPageError}
|
||||
<p class="text-sm text-error-fg">
|
||||
Couldn't load the duplicates report.
|
||||
<button type="button" class="underline hover:no-underline" onclick={() => query.refetch()}>Try again</button>
|
||||
</p>
|
||||
{:else if groups.length === 0}
|
||||
<!-- Several states all show zero groups and mean different things. Telling
|
||||
them apart is what stops an empty page reading as a broken feature. -->
|
||||
<div class="rounded-lg border border-border bg-surface p-6 text-center" data-testid="empty-state">
|
||||
<Copy size={28} strokeWidth={1} class="mx-auto text-text-muted" />
|
||||
{#if prints && prints.total > 0 && prints.fingerprinted === 0}
|
||||
<p class="mt-3 text-text-primary">Nothing to compare yet.</p>
|
||||
<p class="mt-1 text-sm text-text-secondary">
|
||||
{#if prints.enabled === false}
|
||||
Fingerprinting is off, so no track has a fingerprint to compare. Turn it on under
|
||||
<a href="/admin/library#fingerprints" class="text-accent-fg hover:underline">Library</a>.
|
||||
{:else}
|
||||
The library is still being fingerprinted — {prints.pending.toLocaleString()} tracks to go.
|
||||
Duplicates appear here as the sweep finds them.
|
||||
{/if}
|
||||
</p>
|
||||
{:else if sweep?.state === 'never'}
|
||||
<p class="mt-3 text-text-primary">The sweep hasn't run yet.</p>
|
||||
<p class="mt-1 text-sm text-text-secondary">
|
||||
It runs on its own whenever new fingerprints arrive, or now if you start it.
|
||||
</p>
|
||||
{:else if sweep?.state === 'running'}
|
||||
<p class="mt-3 text-text-primary">Sweeping…</p>
|
||||
<p class="mt-1 text-sm text-text-secondary">Anything it finds will appear here.</p>
|
||||
{:else if tab === 'cross_release'}
|
||||
<p class="mt-3 text-text-primary">No songs repeated across releases.</p>
|
||||
<p class="mt-1 text-sm text-text-secondary">
|
||||
A single and the album it's on, or two editions of one album, show here. Each copy
|
||||
belongs to its own release, so none is removed. They count as one song: a like on one
|
||||
is a like on all, and a mix plays it once.
|
||||
</p>
|
||||
{:else}
|
||||
<p class="mt-3 text-text-primary">Nothing needs review.</p>
|
||||
<p class="mt-1 text-sm text-text-secondary">
|
||||
A group appears when two tracks hold identical audio, or the same recording in a
|
||||
different encoding, and it can't be settled on its own. Groups you dismiss don't come
|
||||
back.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<ul class="space-y-4">
|
||||
{#each groups as group (group.id)}
|
||||
{@const titles = differingTitles(group)}
|
||||
<li class="overflow-hidden rounded-lg border border-border bg-surface" data-testid="duplicate-group">
|
||||
<div class="flex items-center justify-between gap-4 border-b border-border px-4 py-3">
|
||||
<div class="min-w-0">
|
||||
<h3 class="text-sm text-text-primary" data-testid="duplicate-tier">{tierLabel(group)}</h3>
|
||||
<p class="text-xs text-text-secondary">Found {relativeTime(group.detected_at)}</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onDismiss(group)}
|
||||
disabled={dismissing === group.id || merging === group.id}
|
||||
class="rounded-md border border-border px-3 py-1.5 text-sm text-text-secondary hover:bg-surface-hover hover:text-text-primary disabled:opacity-50"
|
||||
>
|
||||
{tab === 'cross_release' ? 'Not the same song' : 'Not duplicates'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onMerge(group)}
|
||||
disabled={merging === group.id}
|
||||
class="rounded-md px-3 py-1.5 text-sm disabled:opacity-50 {confirmingMerge === group.id
|
||||
? 'bg-action-destructive text-action-fg hover:opacity-90'
|
||||
: 'border border-border text-text-primary hover:bg-surface-hover'}"
|
||||
>
|
||||
{#if merging === group.id}
|
||||
Merging…
|
||||
{:else if confirmingMerge === group.id}
|
||||
Remove {fileCountLabel(group.members.length - 1)} and merge
|
||||
{:else}
|
||||
Merge…
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if group.resolve_note}
|
||||
<p
|
||||
class="flex items-start gap-2 border-b border-border px-4 py-3 text-xs text-text-secondary"
|
||||
data-testid="resolve-note"
|
||||
>
|
||||
<Info size={14} class="mt-0.5 shrink-0 text-text-muted" aria-hidden="true" />
|
||||
<span>{group.resolve_note}</span>
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if titles.length > 0}
|
||||
<p
|
||||
class="flex items-start gap-2 border-b border-border bg-surface-hover px-4 py-3 text-xs text-text-secondary"
|
||||
data-testid="title-mismatch"
|
||||
>
|
||||
<TriangleAlert size={14} class="mt-0.5 shrink-0 text-warning-fg" aria-hidden="true" />
|
||||
<span>
|
||||
The audio is identical, but the copies are titled {quotedList(titles)}. If those
|
||||
name different recordings, one file is mislabelled and the other recording isn't in
|
||||
your library. Keep the copy whose title matches what you hear.
|
||||
</span>
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if confirmingMerge === group.id}
|
||||
<!-- What the second click will do, said plainly before it is done. -->
|
||||
<div class="space-y-2 border-b border-border bg-surface-hover px-4 py-3 text-sm" data-testid="merge-confirm">
|
||||
<p class="text-text-primary">
|
||||
The copy marked Keep stays. The other {fileCountLabel(group.members.length - 1)} will be
|
||||
deleted from disk, and their likes, plays and playlist entries move to the copy kept.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs text-text-secondary underline hover:text-text-primary"
|
||||
onclick={() => (confirmingMerge = null)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<ul class="divide-y divide-border">
|
||||
{#each group.members as m (m.track_id)}
|
||||
{@const keep = keeperOf(group) === m.track_id}
|
||||
{@const proposed = m.track_id === group.survivor_track_id}
|
||||
<li class="flex items-start gap-3 px-4 py-3" data-testid="duplicate-member">
|
||||
<input
|
||||
type="radio"
|
||||
name="keep-{group.id}"
|
||||
class="mt-1"
|
||||
checked={keeperOf(group) === m.track_id}
|
||||
onchange={() => (keepChoice[group.id] = m.track_id)}
|
||||
aria-label="Keep {m.title}, {m.file_format.toUpperCase()}, {m.file_path}"
|
||||
/>
|
||||
<div class="min-w-0 flex-1 space-y-0.5">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="truncate text-sm text-text-primary">{m.title}</span>
|
||||
{#if keep}
|
||||
<!-- The copy the merge will keep: the proposed one with the
|
||||
rule that chose it, or the one the operator picked. The
|
||||
badge follows the pick, as the merge does (#5604). -->
|
||||
<span
|
||||
class="shrink-0 rounded-full bg-accent-tint px-2 py-0.5 text-xs text-accent-fg"
|
||||
data-testid="survivor-badge"
|
||||
title={proposed ? `Proposed to keep: ${group.survivor_reason}` : 'Your pick'}
|
||||
>
|
||||
Keep · {proposed ? group.survivor_reason : 'your pick'}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="truncate text-xs text-text-secondary">
|
||||
{m.artist_name} · {m.album_title}{position(m) ? ` · ${position(m)}` : ''}
|
||||
</div>
|
||||
<div class="truncate font-mono text-xs text-text-muted" title={m.file_path}>
|
||||
{m.file_path}
|
||||
</div>
|
||||
</div>
|
||||
<div class="shrink-0 space-y-0.5 text-right text-xs text-text-muted">
|
||||
<div>{m.file_format.toUpperCase()} · {sizeLabel(m.file_size)} · {durationLabel(m.duration_sec)}</div>
|
||||
<div data-testid="member-history">{historyLabel(m)}</div>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<ListContinuation
|
||||
hasMore={query.hasNextPage}
|
||||
loading={query.isFetchingNextPage}
|
||||
failed={query.isFetchNextPageError}
|
||||
onMore={() => query.fetchNextPage()}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Fingerprinting is set under Library (#5607); the line says where,
|
||||
since turning it back on is how an empty report gets out of that state. -->
|
||||
<p class="text-xs text-text-secondary" data-testid="fingerprint-settings-link">
|
||||
Fingerprinting is {prints?.enabled === false ? 'off' : 'on'}. It's set under
|
||||
<a href="/admin/library#fingerprints" class="text-accent-fg hover:underline">Library</a>.
|
||||
</p>
|
||||
</div>
|
||||
@@ -0,0 +1,364 @@
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { fireEvent, render, screen } from '@testing-library/svelte';
|
||||
import { readable } from 'svelte/store';
|
||||
import type {
|
||||
AdminDuplicatesResponse,
|
||||
ResolvedDuplicateActivity,
|
||||
ResolvedDuplicatesResponse
|
||||
} from '#lib/api/types.js';
|
||||
|
||||
vi.mock('#lib/api/admin.js', () => ({
|
||||
createDuplicatesQuery: vi.fn(),
|
||||
createResolvedDuplicatesQuery: vi.fn(),
|
||||
runDuplicateSweep: vi.fn().mockResolvedValue({ started: true }),
|
||||
dismissDuplicateGroup: vi.fn().mockResolvedValue(undefined),
|
||||
mergeDuplicateGroup: vi.fn().mockResolvedValue({
|
||||
survivor_track_id: 'www-01',
|
||||
removed_paths: ['/music/Moe Shop/WWW (2020)/www-02.mp3']
|
||||
})
|
||||
}));
|
||||
|
||||
import AdminDuplicatesPage from './+page.svelte';
|
||||
import {
|
||||
createDuplicatesQuery,
|
||||
createResolvedDuplicatesQuery,
|
||||
dismissDuplicateGroup,
|
||||
mergeDuplicateGroup,
|
||||
runDuplicateSweep
|
||||
} from '#lib/api/admin.js';
|
||||
|
||||
const HOUR = 3_600_000;
|
||||
const ago = (ms: number) => new Date(Date.now() - ms).toISOString();
|
||||
|
||||
const finishedSweep = {
|
||||
state: 'finished' as const,
|
||||
started_at: ago(2 * HOUR),
|
||||
finished_at: ago(HOUR),
|
||||
candidates: 1200,
|
||||
groups_found: 1,
|
||||
oversize_clusters: 0,
|
||||
error_message: null
|
||||
};
|
||||
const allFingerprinted = { total: 1200, fingerprinted: 1200, rejected: 0, pending: 0, enabled: true };
|
||||
|
||||
function member(id: string, extra: Partial<AdminDuplicatesResponse['groups'][number]['members'][number]> = {}) {
|
||||
return {
|
||||
track_id: id,
|
||||
title: 'WWW',
|
||||
artist_name: 'Moe Shop',
|
||||
album_id: 'al-1',
|
||||
album_title: 'WWW',
|
||||
file_path: `/music/Moe Shop/WWW (2020)/${id}.mp3`,
|
||||
file_format: 'mp3',
|
||||
file_size: 6_900_000,
|
||||
duration_sec: 215,
|
||||
added_at: ago(500 * HOUR),
|
||||
like_count: 0,
|
||||
play_count: 0,
|
||||
disc_number: null,
|
||||
track_number: null,
|
||||
...extra
|
||||
};
|
||||
}
|
||||
|
||||
function response(over: Partial<AdminDuplicatesResponse> = {}): AdminDuplicatesResponse {
|
||||
return {
|
||||
sweep: finishedSweep,
|
||||
fingerprints: allFingerprinted,
|
||||
view: 'review',
|
||||
counts: { review: 1, cross_release: 4, resolved: 2 },
|
||||
total: 1,
|
||||
limit: 25,
|
||||
offset: 0,
|
||||
groups: [
|
||||
{
|
||||
id: 'g-1',
|
||||
tier: 'exact',
|
||||
worst_bit_error_rate: null,
|
||||
detected_at: ago(HOUR),
|
||||
survivor_track_id: 'www-01',
|
||||
survivor_reason: 'in the library longest',
|
||||
class: 'review',
|
||||
resolve_note: null,
|
||||
members: [member('www-01', { like_count: 2, play_count: 14 }), member('www-02')]
|
||||
}
|
||||
],
|
||||
...over
|
||||
};
|
||||
}
|
||||
|
||||
// The page reads only these fields of an infinite query.
|
||||
function infinite<T>(pages: T[]) {
|
||||
return readable({
|
||||
data: { pages },
|
||||
isPending: false,
|
||||
isError: false,
|
||||
hasNextPage: false,
|
||||
isFetchingNextPage: false,
|
||||
isFetchNextPageError: false,
|
||||
fetchNextPage: () => {},
|
||||
refetch: () => {}
|
||||
});
|
||||
}
|
||||
|
||||
function resolvedPage(items: ResolvedDuplicateActivity[]): ResolvedDuplicatesResponse {
|
||||
return { total: items.length, limit: 25, offset: 0, items };
|
||||
}
|
||||
|
||||
function renderWith(data: AdminDuplicatesResponse, resolved: ResolvedDuplicateActivity[] = []) {
|
||||
vi.mocked(createDuplicatesQuery).mockReturnValue(
|
||||
infinite([data]) as unknown as ReturnType<typeof createDuplicatesQuery>
|
||||
);
|
||||
vi.mocked(createResolvedDuplicatesQuery).mockReturnValue(
|
||||
infinite([resolvedPage(resolved)]) as unknown as ReturnType<typeof createResolvedDuplicatesQuery>
|
||||
);
|
||||
return render(AdminDuplicatesPage);
|
||||
}
|
||||
|
||||
function text(el: HTMLElement): string {
|
||||
return (el.textContent ?? '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('admin duplicates', () => {
|
||||
test('shows each group with its members, what they carry, and the copy to keep', () => {
|
||||
renderWith(response());
|
||||
expect(screen.getAllByTestId('duplicate-group')).toHaveLength(1);
|
||||
expect(screen.getAllByTestId('duplicate-member')).toHaveLength(2);
|
||||
expect(text(screen.getByTestId('duplicate-tier'))).toBe('Identical audio');
|
||||
expect(text(screen.getByTestId('survivor-badge'))).toBe('Keep · in the library longest');
|
||||
const history = screen.getAllByTestId('member-history').map(text);
|
||||
expect(history).toEqual(['2 likes · 14 plays', 'no likes or plays']);
|
||||
});
|
||||
|
||||
// The badge must sit on the proposed survivor, not merely appear somewhere.
|
||||
test('the keep badge is on the survivor row', () => {
|
||||
renderWith(response());
|
||||
const rows = screen.getAllByTestId('duplicate-member');
|
||||
expect(rows[0].querySelector('[data-testid="survivor-badge"]')).not.toBeNull();
|
||||
expect(rows[1].querySelector('[data-testid="survivor-badge"]')).toBeNull();
|
||||
});
|
||||
|
||||
test('picking another copy moves the keep badge to it', async () => {
|
||||
renderWith(response());
|
||||
const rows = screen.getAllByTestId('duplicate-member');
|
||||
await fireEvent.click(rows[1].querySelector('input[type="radio"]') as HTMLInputElement);
|
||||
const after = screen.getAllByTestId('duplicate-member');
|
||||
expect(after[0].querySelector('[data-testid="survivor-badge"]')).toBeNull();
|
||||
expect(text(after[1].querySelector('[data-testid="survivor-badge"]') as HTMLElement)).toBe('Keep · your pick');
|
||||
});
|
||||
|
||||
test('an acoustic group reads as a match percentage', () => {
|
||||
const r = response();
|
||||
r.groups[0] = { ...r.groups[0], tier: 'acoustic', worst_bit_error_rate: 0.04 };
|
||||
renderWith(r);
|
||||
expect(text(screen.getByTestId('duplicate-tier'))).toBe('Same recording · 96% match');
|
||||
});
|
||||
|
||||
// #3885: identical audio titled "WWW" and "WWW (instrumental)". One file
|
||||
// claimed to be the vocal and wasn't, and nothing said the vocal was missing.
|
||||
describe('identical audio under different titles', () => {
|
||||
function www() {
|
||||
const r = response();
|
||||
r.groups[0] = {
|
||||
...r.groups[0],
|
||||
members: [member('www-01'), member('www-02', { title: 'WWW (instrumental)' })]
|
||||
};
|
||||
return r;
|
||||
}
|
||||
|
||||
test('says the copies are titled differently and names both titles', () => {
|
||||
renderWith(www());
|
||||
const notice = text(screen.getByTestId('title-mismatch'));
|
||||
expect(notice).toContain('titled “WWW” and “WWW (instrumental)”');
|
||||
expect(notice).toContain("isn't in your library");
|
||||
});
|
||||
|
||||
test('three different titles are all named', () => {
|
||||
const r = www();
|
||||
r.groups[0].members.push(member('www-03', { title: 'WWW (Radio Edit)' }));
|
||||
renderWith(r);
|
||||
expect(text(screen.getByTestId('title-mismatch'))).toContain(
|
||||
'“WWW”, “WWW (instrumental)” and “WWW (Radio Edit)”'
|
||||
);
|
||||
});
|
||||
|
||||
test('titles differing only in case or spacing are the same title', () => {
|
||||
const r = response();
|
||||
r.groups[0] = {
|
||||
...r.groups[0],
|
||||
members: [
|
||||
member('www-01', { title: 'WWW (Instrumental)' }),
|
||||
member('www-02', { title: ' www (instrumental) ' })
|
||||
]
|
||||
};
|
||||
renderWith(r);
|
||||
expect(screen.queryByTestId('title-mismatch')).toBeNull();
|
||||
});
|
||||
|
||||
test('matching titles raise nothing', () => {
|
||||
renderWith(response());
|
||||
expect(screen.queryByTestId('title-mismatch')).toBeNull();
|
||||
});
|
||||
|
||||
// Across encodings a "Remastered" suffix is routine, not a mislabel.
|
||||
test('an acoustic group with different titles raises nothing', () => {
|
||||
const r = www();
|
||||
r.groups[0] = { ...r.groups[0], tier: 'acoustic', worst_bit_error_rate: 0.04 };
|
||||
renderWith(r);
|
||||
expect(screen.queryByTestId('title-mismatch')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// Three states all show zero groups. Each must say which it is.
|
||||
test('empty while still fingerprinting says so', () => {
|
||||
renderWith(
|
||||
response({
|
||||
groups: [],
|
||||
total: 0,
|
||||
fingerprints: { total: 1200, fingerprinted: 0, rejected: 0, pending: 1200, enabled: true }
|
||||
})
|
||||
);
|
||||
expect(text(screen.getByTestId('empty-state'))).toContain('still being fingerprinted');
|
||||
});
|
||||
|
||||
// Switched off, the backlog never shrinks. "Still being fingerprinted" would
|
||||
// promise work nothing is doing (#3913).
|
||||
test('with fingerprinting off the page says so instead of promising progress', () => {
|
||||
renderWith(
|
||||
response({
|
||||
groups: [],
|
||||
total: 0,
|
||||
fingerprints: { total: 1200, fingerprinted: 0, rejected: 0, pending: 1200, enabled: false }
|
||||
})
|
||||
);
|
||||
const empty = text(screen.getByTestId('empty-state'));
|
||||
expect(empty).toContain('Fingerprinting is off');
|
||||
expect(empty).not.toContain('still being fingerprinted');
|
||||
expect(text(screen.getByTestId('sweep-status'))).toContain('Fingerprinting is off');
|
||||
});
|
||||
|
||||
test('the fingerprinting settings are a link to Library, saying whether it is on', () => {
|
||||
renderWith(response());
|
||||
const line = screen.getByTestId('fingerprint-settings-link');
|
||||
expect(text(line)).toContain('Fingerprinting is on');
|
||||
expect(line.querySelector('a')?.getAttribute('href')).toBe('/admin/library#fingerprints');
|
||||
});
|
||||
|
||||
test('empty before any sweep says the sweep has not run', () => {
|
||||
renderWith(
|
||||
response({
|
||||
groups: [],
|
||||
total: 0,
|
||||
sweep: { ...finishedSweep, state: 'never', started_at: null, finished_at: null, candidates: null }
|
||||
})
|
||||
);
|
||||
expect(text(screen.getByTestId('empty-state'))).toContain("hasn't run yet");
|
||||
});
|
||||
|
||||
test('empty after a sweep says nothing needs review', () => {
|
||||
renderWith(response({ groups: [], total: 0 }));
|
||||
expect(text(screen.getByTestId('empty-state'))).toContain('Nothing needs review');
|
||||
});
|
||||
|
||||
test('Not duplicates dismisses that group', async () => {
|
||||
renderWith(response());
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Not duplicates' }));
|
||||
expect(dismissDuplicateGroup).toHaveBeenCalledWith('g-1');
|
||||
});
|
||||
|
||||
test('Sweep now starts a sweep', async () => {
|
||||
renderWith(response());
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Sweep now' }));
|
||||
expect(runDuplicateSweep).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// A merge deletes files. The first click must only arm it.
|
||||
test('Merge needs a second click, and keeps the proposed copy by default', async () => {
|
||||
renderWith(response());
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Merge…' }));
|
||||
expect(mergeDuplicateGroup).not.toHaveBeenCalled();
|
||||
expect(text(screen.getByTestId('merge-confirm'))).toContain('The other 1 file will be deleted from disk');
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Remove 1 file and merge' }));
|
||||
expect(mergeDuplicateGroup).toHaveBeenCalledWith('g-1', { survivor_track_id: 'www-01' });
|
||||
});
|
||||
|
||||
test('choosing another copy to keep sends that copy', async () => {
|
||||
renderWith(response());
|
||||
const radios = screen.getAllByRole('radio');
|
||||
expect((radios[0] as HTMLInputElement).checked).toBe(true);
|
||||
await fireEvent.click(radios[1]);
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Merge…' }));
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Remove 1 file and merge' }));
|
||||
expect(mergeDuplicateGroup).toHaveBeenCalledWith('g-1', { survivor_track_id: 'www-02' });
|
||||
});
|
||||
|
||||
test('Cancel disarms the merge', async () => {
|
||||
renderWith(response());
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Merge…' }));
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
expect(screen.queryByTestId('merge-confirm')).toBeNull();
|
||||
expect(screen.getByRole('button', { name: 'Merge…' })).toBeTruthy();
|
||||
});
|
||||
|
||||
// M498: the resolver's three outcomes are three tabs, each with its size.
|
||||
describe('tabs', () => {
|
||||
test('each tab shows its count, and Across releases asks for that view', async () => {
|
||||
renderWith(response());
|
||||
expect(screen.getByRole('tab', { name: /needs review/i })).toHaveAttribute('aria-selected', 'true');
|
||||
expect(screen.getAllByTestId('tab-count').map(text)).toEqual(['1', '4', '2']);
|
||||
expect(createDuplicatesQuery).toHaveBeenLastCalledWith('review');
|
||||
|
||||
await fireEvent.click(screen.getByRole('tab', { name: /across releases/i }));
|
||||
expect(createDuplicatesQuery).toHaveBeenLastCalledWith('cross_release');
|
||||
});
|
||||
|
||||
// Copies across releases are linked as one song; dismissing one says they
|
||||
// are not, which is what the button names.
|
||||
test('Across releases dismisses as Not the same song', async () => {
|
||||
renderWith(response());
|
||||
await fireEvent.click(screen.getByRole('tab', { name: /across releases/i }));
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Not the same song' }));
|
||||
expect(dismissDuplicateGroup).toHaveBeenCalledWith('g-1');
|
||||
});
|
||||
|
||||
test('Resolved automatically lists the merges the resolver made', async () => {
|
||||
renderWith(response(), [
|
||||
{
|
||||
id: 'a-1',
|
||||
action: 'duplicate_merge',
|
||||
created_at: ago(HOUR),
|
||||
details: {
|
||||
survivor_path: '/music/Gorillaz/Humanz (2017)/03 - Saturnz Barz.mp3',
|
||||
survivor_reason: 'the copy that is not a video rip',
|
||||
removed: [{ track_id: 't-2', file_path: '/music/Gorillaz/Humanz (2017)/03 - Saturnz Barz (Official Video).mp3' }]
|
||||
}
|
||||
}
|
||||
]);
|
||||
await fireEvent.click(screen.getByRole('tab', { name: /resolved automatically/i }));
|
||||
const items = screen.getAllByTestId('resolved-item').map(text);
|
||||
expect(items[0]).toContain('Kept 03 - Saturnz Barz.mp3 (the copy that is not a video rip) and removed 1 file.');
|
||||
expect(screen.queryByTestId('duplicate-group')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("the resolver's note", () => {
|
||||
test('shows above the group it explains', () => {
|
||||
const r = response();
|
||||
r.groups[0] = { ...r.groups[0], resolve_note: 'Identical audio filed under different titles.' };
|
||||
renderWith(r);
|
||||
expect(text(screen.getByTestId('resolve-note'))).toBe('Identical audio filed under different titles.');
|
||||
});
|
||||
|
||||
test('a merge confirms and sends the copy chosen to keep', async () => {
|
||||
renderWith(response());
|
||||
await fireEvent.click(screen.getAllByRole('radio')[1]);
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Merge…' }));
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Remove 1 file and merge' }));
|
||||
expect(mergeDuplicateGroup).toHaveBeenCalledWith('g-1', { survivor_track_id: 'www-02' });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,302 @@
|
||||
<script lang="ts">
|
||||
import { pageTitle } from '#lib/branding.js';
|
||||
import { Music2, CheckCircle2, Trash2, Play, Copy, ChevronRight } from 'lucide-svelte';
|
||||
import { useQueryClient } from '@tanstack/svelte-query';
|
||||
import RowActionsMenu, { type RowAction } from '#lib/components/RowActionsMenu.svelte';
|
||||
import TabStrip from '#lib/components/TabStrip.svelte';
|
||||
import Modal from '#lib/components/Modal.svelte';
|
||||
import {
|
||||
createAdminQuarantineQuery,
|
||||
createAdminPlaybackErrorsQuery,
|
||||
createQuarantineActionsQuery,
|
||||
resolveQuarantine,
|
||||
resolvePlaybackError,
|
||||
deleteQuarantineFile
|
||||
} from '#lib/api/admin.js';
|
||||
import { qk } from '#lib/api/queries.js';
|
||||
import { errMessage } from '#lib/api/errors.js';
|
||||
import { pushToast } from '#lib/stores/toast.svelte.js';
|
||||
import { playRadio } from '#lib/player/store.svelte.js';
|
||||
import { coverUrl } from '#lib/media/covers.js';
|
||||
import { relativeTime } from '#lib/utils/relativeTime.js';
|
||||
import {
|
||||
ERROR_LABELS,
|
||||
REASON_LABELS,
|
||||
errorCounts,
|
||||
mergeHandled,
|
||||
mergeReports,
|
||||
type ReportRow
|
||||
} from '#lib/admin/reports.js';
|
||||
import type { QuarantineReason } from '#lib/api/types.js';
|
||||
|
||||
// Admin › Reports (#5606): every broken track in one queue, whether a
|
||||
// listener flagged it or it failed to play on a client, one row per track
|
||||
// with one set of actions. Before, the two were separate pages with their
|
||||
// own delete paths and counts that disagreed with the rail.
|
||||
|
||||
const client = useQueryClient();
|
||||
|
||||
let view = $state<'open' | 'handled'>('open');
|
||||
|
||||
const flaggedStore = createAdminQuarantineQuery();
|
||||
const flagged = $derived($flaggedStore);
|
||||
const errorsStore = createAdminPlaybackErrorsQuery(false);
|
||||
const errors = $derived($errorsStore);
|
||||
const rows = $derived(mergeReports(flagged.data ?? [], errors.data ?? []));
|
||||
const loading = $derived(flagged.isPending || errors.isPending);
|
||||
const failed = $derived(flagged.isError || errors.isError);
|
||||
|
||||
// The Handled view loads only when it is opened.
|
||||
const actionsStore = $derived(view === 'handled' ? createQuarantineActionsQuery(50) : null);
|
||||
const resolvedStore = $derived(view === 'handled' ? createAdminPlaybackErrorsQuery(true) : null);
|
||||
const actions = $derived(actionsStore ? $actionsStore : null);
|
||||
const resolved = $derived(resolvedStore ? $resolvedStore : null);
|
||||
const handled = $derived(mergeHandled(actions?.data ?? [], resolved?.data ?? []));
|
||||
|
||||
let expanded = $state<Record<string, boolean>>({});
|
||||
|
||||
async function refresh() {
|
||||
await Promise.all([
|
||||
client.invalidateQueries({ queryKey: qk.adminQuarantine() }),
|
||||
client.invalidateQueries({ queryKey: ['adminPlaybackErrors'] }),
|
||||
client.invalidateQueries({ queryKey: ['adminQuarantineActions'] }),
|
||||
client.invalidateQueries({ queryKey: qk.adminSummary() })
|
||||
]);
|
||||
}
|
||||
|
||||
// Resolve clears the listeners' reports and marks the playback errors
|
||||
// fixed: the track was looked at and is fine.
|
||||
async function onResolve(r: ReportRow) {
|
||||
try {
|
||||
if (r.reports.length > 0) await resolveQuarantine(r.track_id);
|
||||
for (const e of r.errors) await resolvePlaybackError(e.id, 'fixed');
|
||||
pushToast(`Resolved "${r.title}"`);
|
||||
} catch (e) {
|
||||
pushToast(`Couldn't resolve "${r.title}": ${errMessage(e)}`, 'error');
|
||||
}
|
||||
await refresh();
|
||||
}
|
||||
|
||||
// Copies what an operator needs to find the file and the failure.
|
||||
async function onCopy(r: ReportRow) {
|
||||
const payload = {
|
||||
track_id: r.track_id,
|
||||
title: r.title,
|
||||
artist: r.artist,
|
||||
album: r.album,
|
||||
file_path: r.file_path,
|
||||
reports: r.reports.map((x) => ({ by: x.username, reason: x.reason, notes: x.notes, at: x.created_at })),
|
||||
playback_errors: r.errors.map((x) => ({
|
||||
by: x.username,
|
||||
kind: x.kind,
|
||||
detail: x.detail,
|
||||
client: x.client_id,
|
||||
at: x.occurred_at
|
||||
}))
|
||||
};
|
||||
try {
|
||||
await navigator.clipboard.writeText(JSON.stringify(payload, null, 2));
|
||||
pushToast('Copied the details');
|
||||
} catch (e) {
|
||||
pushToast(`Copy failed: ${errMessage(e)}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function onPlay(r: ReportRow) {
|
||||
try {
|
||||
await playRadio(r.track_id);
|
||||
} catch {
|
||||
// Play is a convenience for listening before deciding; a failure here
|
||||
// is what the report is about already.
|
||||
}
|
||||
}
|
||||
|
||||
// One delete path for every report: the track and its file go, and its
|
||||
// reports and playback errors go with the track row (ON DELETE CASCADE).
|
||||
let deleteFileId = $state<string | null>(null);
|
||||
const deleteFileRow = $derived(deleteFileId ? (rows.find((r) => r.track_id === deleteFileId) ?? null) : null);
|
||||
|
||||
async function confirmDeleteFile(r: ReportRow) {
|
||||
deleteFileId = null;
|
||||
try {
|
||||
await deleteQuarantineFile(r.track_id);
|
||||
pushToast(`Deleted "${r.title}"`);
|
||||
} catch (e) {
|
||||
pushToast(`Delete failed: ${errMessage(e)}`, 'error');
|
||||
}
|
||||
await refresh();
|
||||
}
|
||||
|
||||
function actionsFor(r: ReportRow): { primary: RowAction; secondary: RowAction[] } {
|
||||
return {
|
||||
primary: { icon: CheckCircle2, label: 'Resolve', ariaLabel: `Resolve ${r.title}`, onclick: () => onResolve(r) },
|
||||
secondary: [
|
||||
{ icon: Play, label: 'Play', ariaLabel: `Play ${r.title}`, onclick: () => onPlay(r) },
|
||||
{ icon: Copy, label: 'Copy details', ariaLabel: `Copy details for ${r.title}`, onclick: () => onCopy(r) },
|
||||
{
|
||||
icon: Trash2,
|
||||
label: 'Delete file',
|
||||
ariaLabel: `Delete file for ${r.title}`,
|
||||
danger: true,
|
||||
onclick: () => (deleteFileId = r.track_id)
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`;
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{pageTitle('Collection · Reports')}</title></svelte:head>
|
||||
|
||||
<div class="space-y-4">
|
||||
<header class="space-y-1">
|
||||
<h2 class="font-display text-xl font-medium text-text-primary">Reports</h2>
|
||||
<p class="text-sm text-text-secondary">Tracks a listener flagged, or that failed to play.</p>
|
||||
</header>
|
||||
|
||||
<TabStrip
|
||||
ariaLabel="Reports view"
|
||||
bind:value={view}
|
||||
items={[
|
||||
{ id: 'open', label: 'Open' },
|
||||
{ id: 'handled', label: 'Handled' }
|
||||
]}
|
||||
/>
|
||||
|
||||
{#if view === 'open'}
|
||||
{#if loading}
|
||||
<p class="text-sm text-text-secondary">Reading the reports…</p>
|
||||
{:else if failed}
|
||||
<p class="text-sm text-error-fg" data-testid="reports-error">
|
||||
Couldn't load the reports.
|
||||
<button type="button" class="underline hover:no-underline" onclick={() => { flagged.refetch(); errors.refetch(); }}>Try again</button>
|
||||
</p>
|
||||
{:else if rows.length === 0}
|
||||
<p class="text-sm text-text-secondary" data-testid="reports-empty">No reports. Nothing is waiting on you here.</p>
|
||||
{:else}
|
||||
<p class="text-sm text-text-primary" data-testid="reports-verdict">
|
||||
{plural(rows.length, 'track needs', 'tracks need')} a look.
|
||||
</p>
|
||||
<ul class="divide-y divide-border rounded-lg border border-border bg-surface">
|
||||
{#each rows as r (r.track_id)}
|
||||
{@const a = actionsFor(r)}
|
||||
{@const open = !!expanded[r.track_id]}
|
||||
<li class="flex items-start gap-4 p-3" data-testid="report-row">
|
||||
<div class="flex h-12 w-12 shrink-0 items-center justify-center rounded-md bg-surface-hover" aria-hidden="true">
|
||||
{#if r.album_id}
|
||||
<img src={coverUrl(r.album_id)} alt="" class="h-full w-full rounded-md object-cover" loading="lazy" />
|
||||
{:else}
|
||||
<Music2 size={20} strokeWidth={1} class="text-text-muted" />
|
||||
{/if}
|
||||
</div>
|
||||
<div class="min-w-0 flex-1 space-y-1">
|
||||
<div class="truncate text-sm font-medium text-text-primary">{r.title}</div>
|
||||
<div class="truncate text-xs text-text-secondary">
|
||||
{r.artist} · {r.album} · latest {relativeTime(r.latest)}
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1.5" data-testid="report-kinds">
|
||||
{#each Object.entries(r.reason_counts) as [reason, n] (reason)}
|
||||
<span class="pill">{n}× {REASON_LABELS[reason as QuarantineReason] ?? reason}</span>
|
||||
{/each}
|
||||
{#each errorCounts(r) as [kind, n] (kind)}
|
||||
<span class="pill pill-error">{n}× {ERROR_LABELS[kind] ?? kind}</span>
|
||||
{/each}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 text-xs text-text-secondary hover:text-text-primary"
|
||||
aria-expanded={open}
|
||||
onclick={() => (expanded[r.track_id] = !open)}
|
||||
>
|
||||
<ChevronRight size={12} strokeWidth={1.5} class="transition-transform {open ? 'rotate-90' : ''}" />
|
||||
{open ? 'Hide details' : 'Details'}
|
||||
</button>
|
||||
{#if open}
|
||||
<ul class="space-y-1 rounded-md border border-border bg-background p-2 text-xs text-text-secondary" data-testid="report-details">
|
||||
{#each r.reports as rep, i (rep.user_id + i)}
|
||||
<li>
|
||||
<span class="text-text-primary">{rep.username}</span> flagged it ·
|
||||
{REASON_LABELS[rep.reason] ?? rep.reason}{#if rep.notes}{' · '}<span class="italic">{rep.notes}</span>{/if}
|
||||
· {relativeTime(rep.created_at)}
|
||||
</li>
|
||||
{/each}
|
||||
{#each r.errors as e (e.id)}
|
||||
<li>
|
||||
Failed to play for <span class="text-text-primary">{e.username}</span> ·
|
||||
{ERROR_LABELS[e.kind] ?? e.kind}{#if e.detail}{` · ${e.detail}`}{/if} · {relativeTime(e.occurred_at)}
|
||||
</li>
|
||||
{/each}
|
||||
{#if r.file_path}
|
||||
<li class="truncate font-mono text-text-muted" title={r.file_path}>{r.file_path}</li>
|
||||
{/if}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
<RowActionsMenu primary={a.primary} secondary={a.secondary} />
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{:else if actions?.isPending || resolved?.isPending}
|
||||
<p class="text-sm text-text-secondary">Reading what was done…</p>
|
||||
{:else if actions?.isError || resolved?.isError}
|
||||
<p class="text-sm text-error-fg">
|
||||
Couldn't load what was done.
|
||||
<button type="button" class="underline hover:no-underline" onclick={() => { actions?.refetch(); resolved?.refetch(); }}>Try again</button>
|
||||
</p>
|
||||
{:else if handled.length === 0}
|
||||
<p class="text-sm text-text-secondary">Nothing has been handled yet.</p>
|
||||
{:else}
|
||||
<ul class="divide-y divide-border rounded-lg border border-border bg-surface" data-testid="handled">
|
||||
{#each handled as h (h.key)}
|
||||
<li class="flex flex-wrap items-baseline gap-x-3 gap-y-0.5 px-4 py-2.5 text-sm">
|
||||
<span class="text-text-primary">{h.title}</span>
|
||||
<span class="text-xs text-text-secondary">{h.artist}</span>
|
||||
<span class="ml-auto text-xs text-text-secondary">{h.what} · {relativeTime(h.at)}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Modal title="Delete file?" open={deleteFileRow !== null} onClose={() => (deleteFileId = null)}>
|
||||
{#if deleteFileRow}
|
||||
<p class="-mt-2 text-sm text-text-secondary">
|
||||
Remove <em class="font-medium text-text-primary">{deleteFileRow.title}</em> from the library and delete its
|
||||
file. Its reports and playback errors go with it.
|
||||
</p>
|
||||
<div class="mt-5 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-border px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary"
|
||||
onclick={() => (deleteFileId = null)}>Cancel</button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Confirm delete file"
|
||||
class="inline-flex items-center gap-1 rounded-md bg-action-secondary px-3 py-1.5 text-sm text-action-fg"
|
||||
onclick={() => confirmDeleteFile(deleteFileRow!)}
|
||||
>
|
||||
<Trash2 size={14} strokeWidth={2} /> Delete file
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
line-height: 14px;
|
||||
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
|
||||
color: var(--fs-accent-fg);
|
||||
}
|
||||
.pill-error {
|
||||
background: color-mix(in srgb, var(--fs-error) 15%, transparent);
|
||||
color: var(--fs-error-fg);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,125 @@
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/svelte';
|
||||
import { mockQuery } from '../../../test-utils/query';
|
||||
import type { AdminPlaybackError, AdminQuarantineRow } from '#lib/api/types.js';
|
||||
|
||||
vi.mock('#lib/api/admin.js', () => ({
|
||||
createAdminQuarantineQuery: vi.fn(),
|
||||
createAdminPlaybackErrorsQuery: vi.fn(),
|
||||
createQuarantineActionsQuery: vi.fn(),
|
||||
resolveQuarantine: vi.fn().mockResolvedValue({}),
|
||||
resolvePlaybackError: vi.fn().mockResolvedValue({}),
|
||||
deleteQuarantineFile: vi.fn().mockResolvedValue({})
|
||||
}));
|
||||
vi.mock('#lib/player/store.svelte.js', () => ({ playRadio: vi.fn() }));
|
||||
|
||||
import Page from './+page.svelte';
|
||||
import {
|
||||
createAdminPlaybackErrorsQuery,
|
||||
createAdminQuarantineQuery,
|
||||
createQuarantineActionsQuery,
|
||||
deleteQuarantineFile,
|
||||
resolvePlaybackError,
|
||||
resolveQuarantine
|
||||
} from '#lib/api/admin.js';
|
||||
|
||||
const hoursAgo = (h: number) => new Date(Date.now() - h * 3_600_000).toISOString();
|
||||
|
||||
const flagged: AdminQuarantineRow = {
|
||||
track_id: 't1',
|
||||
track_title: 'Roygbiv',
|
||||
artist_name: 'Boards of Canada',
|
||||
album_title: 'Geogaddi',
|
||||
album_id: 'al1',
|
||||
report_count: 1,
|
||||
latest_at: hoursAgo(5),
|
||||
reason_counts: { bad_rip: 1 },
|
||||
reports: [{ user_id: 'u1', username: 'alice', reason: 'bad_rip', notes: 'crackles', created_at: hoursAgo(5) }]
|
||||
};
|
||||
|
||||
const failure = (id: string, track: string, title: string, h: number): AdminPlaybackError => ({
|
||||
id,
|
||||
track_id: track,
|
||||
user_id: 'u2',
|
||||
username: 'bob',
|
||||
client_id: 'android',
|
||||
kind: 'load_failed',
|
||||
occurred_at: hoursAgo(h),
|
||||
track_title: title,
|
||||
track_file_path: `/music/${title}.flac`,
|
||||
artist_name: 'Annadale',
|
||||
album_title: 'Dial Tone',
|
||||
album_id: 'al2'
|
||||
});
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
function setup(q: AdminQuarantineRow[], e: AdminPlaybackError[], opts: { error?: boolean } = {}) {
|
||||
vi.mocked(createAdminQuarantineQuery).mockReturnValue(
|
||||
(opts.error ? mockQuery({ isError: true, error: new Error('x') }) : mockQuery({ data: q })) as never
|
||||
);
|
||||
vi.mocked(createAdminPlaybackErrorsQuery).mockReturnValue(mockQuery({ data: e }) as never);
|
||||
vi.mocked(createQuarantineActionsQuery).mockReturnValue(mockQuery({ data: [] }) as never);
|
||||
return render(Page);
|
||||
}
|
||||
|
||||
describe('/collection/reports', () => {
|
||||
test('a flagged track and a track that failed to play are one list, one row per track, newest first', () => {
|
||||
setup([flagged], [failure('e1', 't1', 'Roygbiv', 1), failure('e2', 't2', 'Intro', 9)]);
|
||||
const rows = screen.getAllByTestId('report-row');
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(screen.getByTestId('reports-verdict').textContent).toMatch(/2 tracks need a look/);
|
||||
const first = within(rows[0]).getByTestId('report-kinds').textContent?.replace(/\s+/g, ' ');
|
||||
expect(first).toMatch(/1× Bad rip/);
|
||||
expect(first).toMatch(/1× Wouldn't load/);
|
||||
expect(rows[1].textContent).toMatch(/Intro/);
|
||||
});
|
||||
|
||||
test('Resolve clears the listeners\' reports and marks the playback errors fixed', async () => {
|
||||
setup([flagged], [failure('e1', 't1', 'Roygbiv', 1)]);
|
||||
await fireEvent.click(screen.getByRole('button', { name: /resolve roygbiv/i }));
|
||||
await waitFor(() => expect(resolveQuarantine).toHaveBeenCalledWith('t1'));
|
||||
await waitFor(() => expect(resolvePlaybackError).toHaveBeenCalledWith('e1', 'fixed'));
|
||||
});
|
||||
|
||||
test('a track only failing to play is resolved without touching listener reports', async () => {
|
||||
setup([], [failure('e2', 't2', 'Intro', 2)]);
|
||||
await fireEvent.click(screen.getByRole('button', { name: /resolve intro/i }));
|
||||
await waitFor(() => expect(resolvePlaybackError).toHaveBeenCalledWith('e2', 'fixed'));
|
||||
expect(resolveQuarantine).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('Delete file is one path, whatever the report came from', async () => {
|
||||
setup([], [failure('e2', 't2', 'Intro', 2)]);
|
||||
await fireEvent.click(screen.getByRole('button', { name: /delete file for intro/i }));
|
||||
await fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: /confirm delete file/i }));
|
||||
await waitFor(() => expect(deleteQuarantineFile).toHaveBeenCalledWith('t2'));
|
||||
expect(resolvePlaybackError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("on a phone the row's other actions are in its menu", async () => {
|
||||
setup([flagged], [failure('e2', 't2', 'Intro', 2)]);
|
||||
const roygbiv = screen.getAllByTestId('report-row').find((r) => r.textContent?.includes('Roygbiv'))!;
|
||||
await fireEvent.click(within(roygbiv).getByRole('button', { name: /more actions/i }));
|
||||
expect(within(screen.getByRole('menu')).getByText('Delete file')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('details show who flagged it, who it failed for, and the file', async () => {
|
||||
setup([flagged], [failure('e1', 't1', 'Roygbiv', 1)]);
|
||||
await fireEvent.click(screen.getByRole('button', { name: /^details$/i }));
|
||||
const details = screen.getByTestId('report-details').textContent?.replace(/\s+/g, ' ');
|
||||
expect(details).toMatch(/alice flagged it · Bad rip · crackles/);
|
||||
expect(details).toMatch(/Failed to play for bob · Wouldn't load/);
|
||||
expect(details).toMatch(/\/music\/Roygbiv\.flac/);
|
||||
});
|
||||
|
||||
test('a load failure says so with Try again, and an empty queue says nothing waits', () => {
|
||||
setup([], [], { error: true });
|
||||
expect(screen.getByTestId('reports-error').textContent).toMatch(/couldn't load the reports/i);
|
||||
});
|
||||
|
||||
test('with nothing reported it says so', () => {
|
||||
setup([], []);
|
||||
expect(screen.getByTestId('reports-empty').textContent).toMatch(/no reports/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
<script lang="ts">
|
||||
import { pageTitle } from '#lib/branding.js';
|
||||
import { FileCheck, Music2 } from 'lucide-svelte';
|
||||
import { createSuspectSourcesQuery, setSuspectVerdict } from '#lib/api/admin.js';
|
||||
import { errMessage } from '#lib/api/errors.js';
|
||||
import { pushToast } from '#lib/stores/toast.svelte.js';
|
||||
import { relativeTime } from '#lib/utils/relativeTime.js';
|
||||
import { coverUrl } from '#lib/media/covers.js';
|
||||
import { formatDuration } from '#lib/media/duration.js';
|
||||
import ListContinuation from '#lib/components/ListContinuation.svelte';
|
||||
import type { AdminSuspectGroup, AdminSuspectTrack, SuspectVerdict } from '#lib/api/types.js';
|
||||
|
||||
// Files whose names say they were ripped from a video (#5410): "(Official
|
||||
// Video)", "[Audio]", "Visualizer", a reaction. Humanz was 62 of them in one
|
||||
// folder, with junk disc numbers and a reaction video filed as a song.
|
||||
//
|
||||
// The duplicate resolver holds each one back from radio and the mixes, and
|
||||
// merges it away where the library has a clean copy (M498 #5439). A marker
|
||||
// is a reason to look, not proof, so each row says what was done and offers
|
||||
// the undo: "This one is fine" lets the track back for good. A rip's album
|
||||
// stays wanted for a clean copy, searched for weekly and filed over the rip
|
||||
// when it comes (#5524).
|
||||
|
||||
const queryStore = createSuspectSourcesQuery();
|
||||
const query = $derived($queryStore);
|
||||
const total = $derived(query.data?.pages?.[0]?.total ?? 0);
|
||||
|
||||
// The server groups each page by folder, so a folder that straddles a page
|
||||
// boundary arrives as two groups. Join them back into one.
|
||||
const groups = $derived.by(() => {
|
||||
const out: AdminSuspectGroup[] = [];
|
||||
for (const page of query.data?.pages ?? []) {
|
||||
for (const g of page.groups) {
|
||||
const last = out.at(-1);
|
||||
if (last && last.directory === g.directory) {
|
||||
out[out.length - 1] = { ...last, tracks: [...last.tracks, ...g.tracks] };
|
||||
} else {
|
||||
out.push(g);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
// The verdict the operator just set, shown at once while the list refetches.
|
||||
let changed = $state<Record<string, SuspectVerdict>>({});
|
||||
let saving = $state<string | null>(null);
|
||||
|
||||
function verdictOf(t: AdminSuspectTrack): SuspectVerdict | null {
|
||||
return changed[t.track_id] ?? t.verdict;
|
||||
}
|
||||
|
||||
async function setVerdict(t: AdminSuspectTrack, verdict: SuspectVerdict) {
|
||||
saving = t.track_id;
|
||||
try {
|
||||
await setSuspectVerdict(t.track_id, verdict);
|
||||
changed[t.track_id] = verdict;
|
||||
pushToast(verdict === 'fine' ? `${t.title} is back in radio and mixes.` : `${t.title} is held back again.`);
|
||||
query.refetch();
|
||||
} catch (e: unknown) {
|
||||
pushToast(errMessage(e), 'error');
|
||||
} finally {
|
||||
saving = null;
|
||||
}
|
||||
}
|
||||
|
||||
function trackCountLabel(n: number): string {
|
||||
return n === 1 ? '1 track' : `${n} tracks`;
|
||||
}
|
||||
|
||||
function basename(path: string): string {
|
||||
return path.slice(path.lastIndexOf('/') + 1);
|
||||
}
|
||||
|
||||
// Disc and track are shown because junk numbering is the other half of
|
||||
// the symptom: Humanz's rips were spread over discs 1 to 15.
|
||||
function position(t: AdminSuspectTrack): string {
|
||||
const parts: string[] = [];
|
||||
if (t.disc_number != null) parts.push(`disc ${t.disc_number}`);
|
||||
if (t.track_number != null) parts.push(`track ${t.track_number}`);
|
||||
return parts.join(', ');
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{pageTitle('Collection · Suspect sources')}</title></svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<header class="space-y-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<h2 class="font-display text-2xl font-medium text-text-primary">Suspect sources</h2>
|
||||
{#if total > 0}
|
||||
<span
|
||||
class="inline-flex items-center rounded-full bg-accent-tint px-2 py-0.5 text-xs text-accent-fg"
|
||||
data-testid="suspect-count-pill"
|
||||
>
|
||||
{total}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-text-secondary">
|
||||
Tracks whose file name reads like a video title rather than an album track,
|
||||
such as "(Official Video)", "[Audio]" or "Visualizer". These usually came from
|
||||
a video site, and can be the wrong audio: a live take, a music-video edit, or
|
||||
something else entirely. Each one is kept out of radio and the mixes, though it still
|
||||
plays when you choose it, and it is merged away where the library has a clean copy of the
|
||||
same song. Where it is the only copy, its album is searched for a clean copy once a week,
|
||||
under <a href="/collection/upgrades" class="text-accent-fg hover:underline">Upgrades</a>. If one
|
||||
is fine, say so and it goes back in.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{#if query.isPending}
|
||||
<p class="text-text-secondary">Reading file names…</p>
|
||||
{:else if query.isError && !query.isFetchNextPageError}
|
||||
<p class="text-sm text-error-fg">
|
||||
Couldn't load the suspect sources.
|
||||
<button type="button" class="underline hover:no-underline" onclick={() => query.refetch()}>Try again</button>
|
||||
</p>
|
||||
{:else if groups.length === 0}
|
||||
<div class="rounded-lg border border-border bg-surface p-6 text-center">
|
||||
<FileCheck size={28} strokeWidth={1} class="mx-auto text-text-muted" />
|
||||
<p class="mt-3 text-text-primary">No file names look like video rips.</p>
|
||||
<p class="mt-1 text-sm text-text-secondary">
|
||||
A track lands here when its file name carries a video-title marker, such as
|
||||
"Official Video", "Lyric Video", "[Audio]" or "MV".
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<ul class="space-y-4">
|
||||
{#each groups as group (group.directory)}
|
||||
<li class="overflow-hidden rounded-lg border border-border bg-surface">
|
||||
<div class="flex items-baseline justify-between gap-4 border-b border-border px-4 py-3">
|
||||
<h3 class="truncate font-mono text-sm text-text-primary" title={group.directory}>
|
||||
{group.directory}
|
||||
</h3>
|
||||
<span class="shrink-0 text-xs text-text-secondary">
|
||||
{trackCountLabel(group.tracks.length)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ul class="divide-y divide-border">
|
||||
{#each group.tracks as t (t.track_id)}
|
||||
<li class="flex items-center gap-3 px-4 py-2" data-testid="suspect-track-row">
|
||||
<div
|
||||
class="flex h-10 w-10 shrink-0 items-center justify-center rounded bg-surface-hover"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{#if t.album_id}
|
||||
<img
|
||||
src={coverUrl(t.album_id)}
|
||||
alt=""
|
||||
class="h-full w-full rounded object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
{:else}
|
||||
<Music2 size={18} strokeWidth={1} class="text-text-muted" />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1 space-y-0.5">
|
||||
<div class="truncate text-sm text-text-primary">{t.title}</div>
|
||||
<div class="truncate text-xs text-text-secondary">
|
||||
{t.artist_name} ·
|
||||
<a href="/albums/{t.album_id}" class="hover:text-text-primary hover:underline"
|
||||
>{t.album_title}</a
|
||||
>{#if position(t)}<span class="text-text-muted"> · {position(t)}</span>{/if}
|
||||
</div>
|
||||
<!-- The file name is the evidence; the markers are read from it. -->
|
||||
<div class="truncate font-mono text-xs text-text-muted" title={t.file_path}>
|
||||
{basename(t.file_path)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hidden shrink-0 flex-wrap justify-end gap-1 sm:flex">
|
||||
{#each t.markers as m (m)}
|
||||
<span
|
||||
class="rounded-full border border-border px-2 py-0.5 text-xs text-text-secondary"
|
||||
data-testid="suspect-marker">{m}</span
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
<span class="shrink-0 text-xs text-text-muted">{formatDuration(t.duration_sec)}</span>
|
||||
{#if verdictOf(t) === 'suspect'}
|
||||
<div class="flex shrink-0 flex-col items-end gap-1">
|
||||
<span class="text-xs text-text-secondary" data-testid="suspect-verdict">
|
||||
Held back{#if t.verdict_at && !changed[t.track_id]} {relativeTime(t.verdict_at)}{/if}
|
||||
</span>
|
||||
{#if t.replacing}
|
||||
<a href="/collection/upgrades" class="text-xs text-text-muted hover:underline" data-testid="suspect-searched">
|
||||
{t.searched_at
|
||||
? `Looked for a clean copy ${relativeTime(t.searched_at)}`
|
||||
: 'A clean copy is wanted'}
|
||||
</a>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => setVerdict(t, 'fine')}
|
||||
disabled={saving === t.track_id}
|
||||
class="rounded-md border border-border px-2.5 py-1 text-xs text-text-secondary hover:bg-surface-hover hover:text-text-primary disabled:opacity-50"
|
||||
>
|
||||
This one is fine
|
||||
</button>
|
||||
</div>
|
||||
{:else if verdictOf(t) === 'fine'}
|
||||
<div class="flex shrink-0 flex-col items-end gap-1">
|
||||
<span class="text-xs text-text-secondary" data-testid="suspect-verdict">Marked fine</span>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => setVerdict(t, 'suspect')}
|
||||
disabled={saving === t.track_id}
|
||||
class="rounded-md px-2.5 py-1 text-xs text-text-muted hover:bg-surface-hover hover:text-text-primary disabled:opacity-50"
|
||||
>
|
||||
Hold back again
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<ListContinuation
|
||||
hasMore={!!query.hasNextPage}
|
||||
loading={query.isFetchingNextPage}
|
||||
failed={query.isFetchNextPageError}
|
||||
onMore={() => query.fetchNextPage()}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,154 @@
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { fireEvent, render, screen } from '@testing-library/svelte';
|
||||
import { readable } from 'svelte/store';
|
||||
import type { AdminSuspectResponse, AdminSuspectTrack } from '#lib/api/types.js';
|
||||
|
||||
vi.mock('#lib/api/admin.js', () => ({
|
||||
createSuspectSourcesQuery: vi.fn(),
|
||||
setSuspectVerdict: vi.fn(async () => {})
|
||||
}));
|
||||
|
||||
import SuspectSourcesPage from './+page.svelte';
|
||||
import { createSuspectSourcesQuery, setSuspectVerdict } from '#lib/api/admin.js';
|
||||
|
||||
const mocked = createSuspectSourcesQuery as ReturnType<typeof vi.fn>;
|
||||
|
||||
const HUMANZ = '/music/Gorillaz/Humanz (2017)';
|
||||
|
||||
function track(title: string, file: string, markers: string[], disc: number | null = 15): AdminSuspectTrack {
|
||||
return {
|
||||
track_id: `t-${title}`,
|
||||
title,
|
||||
artist_id: 'ar-1',
|
||||
artist_name: 'Gorillaz',
|
||||
album_id: 'al-1',
|
||||
album_title: 'Humanz',
|
||||
file_path: `${HUMANZ}/${file}`,
|
||||
duration_sec: 180,
|
||||
disc_number: disc,
|
||||
track_number: 4,
|
||||
markers,
|
||||
verdict: 'suspect',
|
||||
verdict_at: new Date(Date.now() - 3_600_000).toISOString(),
|
||||
replacing: false,
|
||||
searched_at: null
|
||||
};
|
||||
}
|
||||
|
||||
function page(offset: number, total: number, groups: AdminSuspectResponse['groups']): AdminSuspectResponse {
|
||||
return { total, limit: 50, offset, groups };
|
||||
}
|
||||
|
||||
// The page reads only these fields of the infinite query.
|
||||
function infinite(pages: AdminSuspectResponse[], opts: { hasNextPage?: boolean; isPending?: boolean } = {}) {
|
||||
return readable({
|
||||
data: { pages },
|
||||
isPending: opts.isPending ?? false,
|
||||
isError: false,
|
||||
hasNextPage: opts.hasNextPage ?? false,
|
||||
isFetchingNextPage: false,
|
||||
fetchNextPage: () => {},
|
||||
refetch: () => {}
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('Suspect sources page', () => {
|
||||
test('shows each flagged track with its file name and markers', () => {
|
||||
mocked.mockReturnValue(
|
||||
infinite([
|
||||
page(0, 2, [
|
||||
{
|
||||
directory: HUMANZ,
|
||||
tracks: [
|
||||
track('Strobelite', 'Gorillaz - Strobelite (Official Video).mp3', ['music video']),
|
||||
track('Andromeda', 'Gorillaz - Andromeda [Audio] [HQ].mp3', ['[Audio]', '[HD]'])
|
||||
]
|
||||
}
|
||||
])
|
||||
])
|
||||
);
|
||||
render(SuspectSourcesPage);
|
||||
|
||||
expect(screen.getByTestId('suspect-count-pill')).toHaveTextContent('2');
|
||||
expect(screen.getByText(HUMANZ)).toBeInTheDocument();
|
||||
expect(screen.getByText('Gorillaz - Strobelite (Official Video).mp3')).toBeInTheDocument();
|
||||
expect(screen.getAllByTestId('suspect-marker').map((m) => m.textContent)).toEqual([
|
||||
'music video',
|
||||
'[Audio]',
|
||||
'[HD]'
|
||||
]);
|
||||
expect(screen.getAllByText(/disc 15, track 4/)).toHaveLength(2);
|
||||
});
|
||||
|
||||
// A folder split across two pages is still one folder on screen.
|
||||
test('joins a folder that straddles a page boundary', () => {
|
||||
mocked.mockReturnValue(
|
||||
infinite([
|
||||
page(0, 3, [{ directory: HUMANZ, tracks: [track('Strobelite', 'a (Official Video).mp3', ['music video'])] }]),
|
||||
page(1, 3, [
|
||||
{ directory: HUMANZ, tracks: [track('Momentz', 'b (Visualizer).mp3', ['visualiser'])] },
|
||||
{ directory: '/music/Daft Punk/RAM', tracks: [track('Doin It Right', 'c (Music Video).mp3', ['music video'])] }
|
||||
])
|
||||
])
|
||||
);
|
||||
render(SuspectSourcesPage);
|
||||
|
||||
expect(screen.getAllByText(HUMANZ)).toHaveLength(1);
|
||||
expect(screen.getAllByText('2 tracks')).toHaveLength(1);
|
||||
expect(screen.getAllByTestId('suspect-track-row')).toHaveLength(3);
|
||||
});
|
||||
|
||||
test('empty library explains what would land here', () => {
|
||||
mocked.mockReturnValue(infinite([page(0, 0, [])]));
|
||||
render(SuspectSourcesPage);
|
||||
expect(screen.getByText(/no file names look like video rips/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('loads more as you scroll, with no button', () => {
|
||||
mocked.mockReturnValue(
|
||||
infinite([page(0, 80, [{ directory: HUMANZ, tracks: [track('Strobelite', 'a (Official Video).mp3', ['music video'])] }])], {
|
||||
hasNextPage: true
|
||||
})
|
||||
);
|
||||
const { container } = render(SuspectSourcesPage);
|
||||
expect(container.querySelector('div[aria-hidden="true"].h-px')).not.toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /more/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// M498 #5439: a flagged track is held back from radio and the mixes, and the
|
||||
// operator can let it back.
|
||||
test('This one is fine lets a held-back track back in', async () => {
|
||||
mocked.mockReturnValue(
|
||||
infinite([
|
||||
page(0, 1, [
|
||||
{
|
||||
directory: HUMANZ,
|
||||
tracks: [track('Strobelite', 'Gorillaz - Strobelite (Official Video).mp3', ['music video'])]
|
||||
}
|
||||
])
|
||||
])
|
||||
);
|
||||
render(SuspectSourcesPage);
|
||||
expect(screen.getByTestId('suspect-verdict')).toHaveTextContent(/Held back/);
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'This one is fine' }));
|
||||
expect(setSuspectVerdict).toHaveBeenCalledWith('t-Strobelite', 'fine');
|
||||
expect(await screen.findByText('Marked fine')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Hold back again' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// #5524: a rip's album is wanted for a clean copy, and the row says when
|
||||
// one was last looked for.
|
||||
test('shows that a clean copy is wanted, and when it was looked for', () => {
|
||||
const t = track('Strobelite', 'Gorillaz - Strobelite (Official Video).mp3', ['music video']);
|
||||
t.replacing = true;
|
||||
t.searched_at = new Date(Date.now() - 7_200_000).toISOString();
|
||||
mocked.mockReturnValue(infinite([page(0, 1, [{ directory: HUMANZ, tracks: [t] }])]));
|
||||
render(SuspectSourcesPage);
|
||||
const line = screen.getByTestId('suspect-searched');
|
||||
expect(line).toHaveTextContent(/Looked for a clean copy/);
|
||||
expect(line).toHaveAttribute('href', '/collection/upgrades');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user