test-go / test (push) Failing after 44s
test-web / test (push) Successful in 49s
test-go / integration (push) Failing after 2m42s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / Build signed APK (releases and dev) (push) Canceled after 4m8s
Rule 25: the fingerprinting knobs move out of source into a DB-backed singleton (migration 0061), edited from a card on the Duplicates page and shared live with the scanner, the backfill and the duplicate sweep through one service instance, so a save needs no restart. The length is the knob that can silently break the library: prints taken at two lengths never match. Each track_fingerprints row now records the length it was taken at, and every reader filters on the current one — the backfill treats another length as stale, the gauge counts it pending, the sweep never streams it. Equivalent to a version bump, except that setting the length back makes rows not yet redone current again. The card warns before a length change re-fingerprints the library. Off stops every decode: the scan takes only the stream hash (a demux, and what recognises a moved file) and stores nothing, dropping a changed file's stale row; the backfill idles. A save also makes a sweep due, since a new threshold or length changes what the same prints group into, and the sweep interval gains slack so an hourly interval on an hourly tick doesn't skip every other tick. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
360 lines
15 KiB
Svelte
360 lines
15 KiB
Svelte
<script lang="ts">
|
||
import { pageTitle } from '$lib/branding';
|
||
import { Copy } from 'lucide-svelte';
|
||
import {
|
||
createDuplicatesQuery,
|
||
runDuplicateSweep,
|
||
dismissDuplicateGroup,
|
||
mergeDuplicateGroup
|
||
} 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';
|
||
import FingerprintSettingsCard from '$lib/components/FingerprintSettingsCard.svelte';
|
||
|
||
// Tracks the duplicate sweep believes hold one recording (#3912). Dismissing a
|
||
// group says "these are not duplicates", and the sweep will not propose that
|
||
// set again. Merging (#3911) keeps one copy, moves the others' likes, plays and
|
||
// playlist entries onto it, and deletes their files — so it asks twice.
|
||
|
||
const PAGE_SIZE = 25;
|
||
|
||
let offset = $state(0);
|
||
let sweeping = $state(false);
|
||
let dismissing = $state<string | null>(null);
|
||
// Per group: which copy to keep (defaults to the proposed survivor), whether to
|
||
// unmonitor the removed copies in Lidarr, and the two-click confirm.
|
||
let keepChoice = $state<Record<string, string>>({});
|
||
let unmonitorChoice = $state<Record<string, boolean>>({});
|
||
let confirmingMerge = $state<string | null>(null);
|
||
let merging = $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;
|
||
}
|
||
}
|
||
|
||
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),
|
||
unmonitor: unmonitorChoice[group.id] ?? false
|
||
});
|
||
pushToast(`Merged. Removed ${fileCountLabel(result.removed_paths.length)}.`);
|
||
if (result.lidarr_unmonitor_failed) {
|
||
pushToast("Merged, but Lidarr couldn't be told to stop monitoring the removed copies.", 'error');
|
||
}
|
||
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}`;
|
||
}
|
||
|
||
// 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}
|
||
{#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}
|
||
|
||
{#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">
|
||
{#if prints.enabled === false}
|
||
Fingerprinting is off, so no track has a fingerprint to compare. Turn it on in the
|
||
settings below.
|
||
{: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}
|
||
<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>
|
||
<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"
|
||
>
|
||
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 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>
|
||
<label class="flex items-center gap-2 text-text-secondary">
|
||
<input
|
||
type="checkbox"
|
||
checked={unmonitorChoice[group.id] ?? false}
|
||
onchange={(e) => (unmonitorChoice[group.id] = e.currentTarget.checked)}
|
||
/>
|
||
Tell Lidarr to stop monitoring the removed copies, so it doesn't download them again
|
||
</label>
|
||
<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 = 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 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}
|
||
|
||
<!-- Settings sit under the report whatever state it is in: turning
|
||
fingerprinting back on is how an empty report gets out of that state. -->
|
||
<FingerprintSettingsCard libraryTotal={prints?.total ?? 0} onSaved={() => query.refetch()} />
|
||
</div>
|