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
@@ -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' });
});
});
});