feat(admin): the duplicates report — review proposed duplicate groups (M400 #3912)
test-web / test (push) Successful in 52s
test-go / test (push) Successful in 1m9s
test-go / integration (push) Successful in 3m31s
release / Build signed APK (releases and dev) (push) Successful in 4m32s
release / Build + push container image (push) Successful in 24s
release / Verify release artifacts (tag releases only) (push) Skipped
test-web / test (push) Successful in 52s
test-go / test (push) Successful in 1m9s
test-go / integration (push) Successful in 3m31s
release / Build signed APK (releases and dev) (push) Successful in 4m32s
release / Build + push container image (push) Successful in 24s
release / Verify release artifacts (tag releases only) (push) Skipped
A new admin tab, Duplicates, beside Missing files: the proposals from the duplicate sweep, with a Sweep now trigger and a Not duplicates dismissal. Nothing on it merges or deletes; the merge is #3911. Each group shows: - whether it is identical audio or the same recording, with a match percentage from the weakest link between members - every copy's format, size, duration, path, and the likes and plays it carries (every user's; this is admin-only, and it is what decides which copy to keep) - the copy proposed to keep, and the rule that chose it The survivor rule is library.ProposeSurvivor, a pure function the merge will reuse: lossless over lossy, then the larger file, then the copy in the library longest, then lowest id. Bitrate is not in it because the scanner never fills tracks.bitrate, and for one recording at one duration a larger file is the higher bitrate. m4a is not counted as lossless: it may be AAC. The reason names the rule that separated first place from second, not every rule the winner passed. An empty report has three causes, and the page says which: still fingerprinting, the sweep has never run, or it ran and found nothing. The sweep's state and the backfill's progress come back with the groups for that reason. Groups left with fewer than two members since the sweep are not shown. GET /api/admin/library/duplicates, POST .../sweep (202, or 409 sweep_in_progress), POST .../{id}/dismiss (404 duplicate_group_not_pending when already resolved). Migration 0060 indexes play_events by track_id. Its only indexes led with user_id, so each copy's play count, and the merge's repointing of play history, would scan the whole table. Web only, like Missing files: Android has no library-health admin screens. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
This commit is contained in:
@@ -0,0 +1,251 @@
|
||||
<script lang="ts">
|
||||
import { pageTitle } from '$lib/branding';
|
||||
import { Copy } from 'lucide-svelte';
|
||||
import {
|
||||
createDuplicatesQuery,
|
||||
runDuplicateSweep,
|
||||
dismissDuplicateGroup
|
||||
} from '$lib/api/admin';
|
||||
import { errMessage } from '$lib/api/errors';
|
||||
import { pushToast } from '$lib/stores/toast.svelte';
|
||||
import { relativeTime } from '$lib/utils/relativeTime';
|
||||
import type { AdminDuplicateGroup, AdminDuplicateMember } from '$lib/api/types';
|
||||
|
||||
// Tracks the duplicate sweep believes hold one recording (#3912). A group is a
|
||||
// proposal: nothing here deletes or merges. Dismissing one says "these are not
|
||||
// duplicates", and the sweep will not propose that set again.
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
let offset = $state(0);
|
||||
let sweeping = $state(false);
|
||||
let dismissing = $state<string | null>(null);
|
||||
|
||||
const queryStore = $derived(createDuplicatesQuery(offset, PAGE_SIZE));
|
||||
const query = $derived($queryStore);
|
||||
const data = $derived(query.data);
|
||||
const groups = $derived((data?.groups ?? []) as AdminDuplicateGroup[]);
|
||||
const total = $derived(data?.total ?? 0);
|
||||
const hasMore = $derived(offset + groups.length < total);
|
||||
const sweep = $derived(data?.sweep);
|
||||
const prints = $derived(data?.fingerprints);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// "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}`;
|
||||
}
|
||||
|
||||
// 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')}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{pageTitle('Admin · Duplicates')}</title></svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<header class="space-y-1">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<h2 class="font-display text-2xl font-medium text-text-primary">Duplicates</h2>
|
||||
{#if total > 0}
|
||||
<span
|
||||
class="inline-flex items-center rounded-full bg-accent-tint px-2 py-0.5 text-xs text-accent"
|
||||
data-testid="duplicates-count-pill"
|
||||
>
|
||||
{total}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<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. Review each group; nothing is
|
||||
merged or removed from here.
|
||||
</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-oxblood">It stopped early: {sweep.error_message}</span>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if prints && prints.pending > 0}
|
||||
{prints.pending.toLocaleString()} tracks are still waiting for a fingerprint and join
|
||||
the comparison once they have one.
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if query.isPending}
|
||||
<p class="text-text-secondary">Loading duplicates…</p>
|
||||
{:else if query.isError}
|
||||
<p class="text-error">Couldn't load the duplicates report.</p>
|
||||
{:else if groups.length === 0}
|
||||
<!-- Three 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">
|
||||
The library is still being fingerprinted — {prints.pending.toLocaleString()} tracks to go.
|
||||
Duplicates appear here as the sweep finds them.
|
||||
</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}
|
||||
<p class="mt-3 text-text-primary">No duplicates found.</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. Groups you dismiss don't come back.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<ul class="space-y-4">
|
||||
{#each groups as group (group.id)}
|
||||
<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>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onDismiss(group)}
|
||||
disabled={dismissing === group.id}
|
||||
class="shrink-0 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"
|
||||
>
|
||||
Not duplicates
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ul class="divide-y divide-border">
|
||||
{#each group.members as m (m.track_id)}
|
||||
{@const keep = m.track_id === group.survivor_track_id}
|
||||
<li class="flex items-start gap-3 px-4 py-3" data-testid="duplicate-member">
|
||||
<div class="min-w-0 flex-1 space-y-0.5">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="truncate text-sm text-text-primary">{m.title}</span>
|
||||
{#if keep}
|
||||
<!-- The proposed copy to keep, with the rule that chose it.
|
||||
A default the merge will let the operator override. -->
|
||||
<span
|
||||
class="shrink-0 rounded-full bg-accent-tint px-2 py-0.5 text-xs text-accent"
|
||||
data-testid="survivor-badge"
|
||||
title="Proposed to keep: {group.survivor_reason}"
|
||||
>
|
||||
Keep · {group.survivor_reason}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="truncate text-xs text-text-secondary">
|
||||
{m.artist_name} · {m.album_title}
|
||||
</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>
|
||||
|
||||
{#if hasMore || offset > 0}
|
||||
<nav class="flex items-center justify-between" aria-label="Duplicates pages">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-border px-3 py-1.5 text-sm text-text-primary hover:bg-surface-hover disabled:cursor-not-allowed disabled:opacity-40"
|
||||
disabled={offset === 0}
|
||||
onclick={() => (offset = Math.max(0, offset - PAGE_SIZE))}
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span class="text-xs text-text-secondary">
|
||||
{offset + 1}–{offset + groups.length} of {total}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-border px-3 py-1.5 text-sm text-text-primary hover:bg-surface-hover disabled:cursor-not-allowed disabled:opacity-40"
|
||||
disabled={!hasMore}
|
||||
onclick={() => (offset += PAGE_SIZE)}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</nav>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
Reference in New Issue
Block a user