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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-11 14:35:59 -04:00
co-authored by Claude Opus 5.5
parent 12725e71b0
commit 38b2325d2d
17 changed files with 114 additions and 53 deletions
+21 -1
View File
@@ -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">
+42 -3
View File
@@ -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]}&nbsp;{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');
});
});