feat: duplicates resolve themselves where Lidarr says it is safe (M498)
release / govulncheck (push) Successful in 45s
release / web (push) Successful in 1m23s
release / go (push) Successful in 1m39s
release / integration (push) Successful in 4m25s
release / android (push) Successful in 6m17s
release / Build signed APK (releases and dev) (push) Successful in 5m57s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m54s
release / Verify release artifacts (tag releases only) (push) Skipped

The duplicate sweep proposed 4,197 groups and every one waited for the
operator. Most are safe to settle, and Lidarr defines what safe means: it
maps one file to each track of the release it monitors and downloads any
mapped file that disappears. Deleting a mapped copy opens exactly the hole
the operator saw Lidarr fill.

Classify (#5435)
- Migration 0075: duplicate_groups.class (same_release, cross_release,
  mismatch, review), resolve_note, resolved_automatically;
  duplicate_group_members.lidarr_state (tracked, unmapped);
  fingerprint_settings.auto_resolve; notification kind
  duplicates_resolved with both kind CHECKs swapped (rule 36).
- library.ClassifyDuplicateGroup, with MatchTitleKey dropping featuring
  credits, remaster notes and video-rip markers, and keeping live, demo,
  remix and instrumental. The rip markers move from api to library.

Choose the copy to keep (#5436)
- ProposeSurvivor ranks the copy Lidarr maps first, then tag fit (a
  clash-free track number, no rip marker in the name, an MBID), then the
  quality rules. File size picked the wrong Humanz copy in 6 of 21 groups.

Act (#5437)
- An hourly resolver pass reads Lidarr's unmapped files, matched by the
  last three path components, and records each copy's state.
- Same album, with at most one copy mapped: merged into the mapped copy.
  The merge is guarded, so a mapped copy can never be removed
  (MergeDuplicateGroupGuarded, ErrCopyTrackedByLidarr).
- Same album, every copy mapped: the monitored release lists the song
  twice (Humanz's 14x12" box set). The pass moves Lidarr to the release
  that lists each song once and best covers what is on disk. It never
  picks one covering less, and is capped at 10 albums per pass.
  - Fixed point (lesson #4183): the chosen release no longer repeats.
  - The album is left alone for 24h while Lidarr rescans, so "every copy
    unmapped" mid-rescan is never read as licence to merge.
- Both actions are audited with no actor and summarised to admins. The
  operator can switch them off in the Fingerprinting card (rule 25).
- Manual merges use the same guard: 409 copy_tracked_by_lidarr, or 503
  lidarr_unavailable when Lidarr cannot say.

Web
- Duplicates gets tabs: Needs review, Across releases, Resolved
  automatically. Each loads as you scroll (rule 172), replacing the
  pager.
- Each copy says whether Lidarr uses it.
- The resolver's note shows on each group.
- The merge confirm blocks, before sending, a merge that would remove
  the copy Lidarr uses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 21:49:37 -04:00
co-authored by Claude Opus 5.5
parent 01e2294471
commit 4ecff52f19
48 changed files with 3068 additions and 254 deletions
+29 -4
View File
@@ -1,5 +1,13 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { dismissDuplicateGroup, listDuplicates, mergeDuplicateGroup, runDuplicateSweep } from './admin';
import {
dismissDuplicateGroup,
duplicatesNextOffset,
listDuplicates,
listResolvedDuplicates,
mergeDuplicateGroup,
runDuplicateSweep
} from './admin';
import type { AdminDuplicatesResponse } from './types';
vi.mock('./client', () => ({
api: { get: vi.fn(), post: vi.fn() }
@@ -10,10 +18,27 @@ import { api } from './client';
describe('admin duplicates API', () => {
beforeEach(() => vi.clearAllMocks());
it('listDuplicates GETs the paged report', async () => {
it('listDuplicates GETs one tab of the paged report', async () => {
(api.get as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ groups: [] });
await listDuplicates(25, 25);
expect(api.get).toHaveBeenCalledWith('/api/admin/library/duplicates?limit=25&offset=25');
await listDuplicates('cross_release', 25);
expect(api.get).toHaveBeenCalledWith(
'/api/admin/library/duplicates?view=cross_release&limit=25&offset=25'
);
});
it('listResolvedDuplicates GETs what the resolver did', async () => {
(api.get as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ items: [] });
await listResolvedDuplicates(50);
expect(api.get).toHaveBeenCalledWith('/api/admin/library/duplicates/resolved?limit=25&offset=50');
});
// The next offset counts groups, and stops at the total or on an empty page.
it('duplicatesNextOffset pages by groups', () => {
const page = (offset: number, n: number, total: number) =>
({ offset, total, groups: Array.from({ length: n }, () => ({})) }) as unknown as AdminDuplicatesResponse;
expect(duplicatesNextOffset(page(0, 25, 60))).toBe(25);
expect(duplicatesNextOffset(page(50, 10, 60))).toBeUndefined();
expect(duplicatesNextOffset(page(25, 0, 60))).toBeUndefined();
});
it('runDuplicateSweep POSTs the trigger', async () => {
+2 -1
View File
@@ -49,7 +49,8 @@ describe('admin fingerprint coverage API', () => {
chromaprint_length_sec: 120,
acoustic_max_bit_error_rate: 0.15,
backfill_concurrency: 2,
sweep_interval_hours: 1
sweep_interval_hours: 1,
auto_resolve: true
};
(api.get as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(settings);
(api.put as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(settings);
+46 -10
View File
@@ -6,7 +6,9 @@ import type {
AdminMissingResponse,
AdminSuspectResponse,
AdminDuplicatesResponse,
DuplicatesView,
MergeDuplicateResult,
ResolvedDuplicatesResponse,
AdminPlaybackError,
AdminQuarantineRow,
LidarrConfig,
@@ -353,6 +355,9 @@ export type FingerprintSettings = {
acoustic_max_bit_error_rate: number;
backfill_concurrency: number;
sweep_interval_hours: number;
// Lets the duplicate resolver act on its own (M498): merge copies Lidarr
// doesn't need and change a Lidarr release that lists songs twice.
auto_resolve: boolean;
};
export async function getFingerprintSettings(): Promise<FingerprintSettings> {
@@ -829,24 +834,55 @@ export async function updatePublicUrl(publicUrl: string): Promise<NetworkSetting
// Duplicates report (#3912) -------------------------------------------------
export const DUPLICATES_PAGE_SIZE = 25;
export async function listDuplicates(
view: DuplicatesView = 'review',
offset: number = 0,
limit: number = 25
limit: number = DUPLICATES_PAGE_SIZE
): Promise<AdminDuplicatesResponse> {
return api.get<AdminDuplicatesResponse>(
`/api/admin/library/duplicates?limit=${limit}&offset=${offset}`
`/api/admin/library/duplicates?view=${view}&limit=${limit}&offset=${offset}`
);
}
// Takes a plain offset, like createMissingFilesQuery, so a $derived caller
// re-creates the query on paging. Polls while a sweep might be running: the
// page is where the operator waits for one to finish.
export function createDuplicatesQuery(offset: number = 0, limit: number = 25) {
return createQuery({
queryKey: qk.adminDuplicates(offset),
queryFn: () => listDuplicates(offset, limit),
export function duplicatesNextOffset(last: AdminDuplicatesResponse): number | undefined {
const loaded = last.offset + last.groups.length;
return last.groups.length === 0 || loaded >= last.total ? undefined : loaded;
}
// Loads as the page scrolls (rule 172), one tab at a time. Polls so a sweep
// or resolver pass finishing shows up while the operator is on the page.
export function createDuplicatesQuery(view: DuplicatesView = 'review') {
return createInfiniteQuery({
queryKey: qk.adminDuplicates(view),
queryFn: ({ pageParam }) => listDuplicates(view, pageParam as number),
initialPageParam: 0,
getNextPageParam: duplicatesNextOffset,
staleTime: 30_000,
refetchInterval: 15_000
refetchInterval: 30_000
});
}
export async function listResolvedDuplicates(offset: number = 0): Promise<ResolvedDuplicatesResponse> {
return api.get<ResolvedDuplicatesResponse>(
`/api/admin/library/duplicates/resolved?limit=${DUPLICATES_PAGE_SIZE}&offset=${offset}`
);
}
export function resolvedDuplicatesNextOffset(last: ResolvedDuplicatesResponse): number | undefined {
const loaded = last.offset + last.items.length;
return last.items.length === 0 || loaded >= last.total ? undefined : loaded;
}
// What the resolver did by itself (M498), newest first.
export function createResolvedDuplicatesQuery() {
return createInfiniteQuery({
queryKey: qk.adminResolvedDuplicates(),
queryFn: ({ pageParam }) => listResolvedDuplicates(pageParam as number),
initialPageParam: 0,
getNextPageParam: resolvedDuplicatesNextOffset,
staleTime: 60_000
});
}
+1
View File
@@ -122,5 +122,6 @@ export const NOTIFICATION_KIND_LABELS: Record<string, string> = {
scan_failed: 'Library scan failed',
tracks_missing: 'Tracks gone missing',
duplicates_found: 'Duplicates to review',
duplicates_resolved: 'Duplicates resolved',
playback_errors: 'Playback errors'
};
+3 -2
View File
@@ -56,8 +56,9 @@ export const qk = {
['adminDiagnostics', f] as const,
adminMissingFiles: (offset?: number) =>
['adminMissingFiles', { offset: offset ?? 0 }] as const,
adminDuplicates: (offset?: number) =>
['adminDuplicates', { offset: offset ?? 0 }] as const,
adminDuplicates: (view: string = 'review') =>
['adminDuplicates', { view }] as const,
adminResolvedDuplicates: () => ['adminResolvedDuplicates'] as const,
adminSuspectSources: () => ['adminSuspectSources'] as const,
adminDiagnosticDevices: (userId?: string) =>
['adminDiagnosticDevices', { userId: userId ?? 'all' }] as const,
+44
View File
@@ -471,8 +471,17 @@ export type AdminDuplicateMember = {
added_at: string;
like_count: number;
play_count: number;
// Lidarr's view of the file as of the resolver's last pass (M498): tracked
// means Lidarr maps it to a track, so removing it would make Lidarr download
// it again. null when Lidarr wasn't asked.
lidarr_state: 'tracked' | 'unmapped' | null;
disc_number: number | null;
track_number: number | null;
};
// The resolver's verdict on a group (M498). null until its first pass.
export type DuplicateClass = 'same_release' | 'cross_release' | 'mismatch' | 'review';
// exact: identical encoded audio. acoustic: the same recording, differently
// encoded; worst_bit_error_rate is the weakest link between any two members.
export type AdminDuplicateGroup = {
@@ -482,6 +491,9 @@ export type AdminDuplicateGroup = {
detected_at: string;
survivor_track_id: string;
survivor_reason: string;
class: DuplicateClass | null;
// Why the resolver left the group for the operator, when it had something to say.
resolve_note: string | null;
members: AdminDuplicateMember[];
};
@@ -513,8 +525,40 @@ export type AdminDuplicatesResponse = {
pending: number;
enabled: boolean;
};
view: DuplicatesView;
// Each tab's size, for its pill.
counts: { review: number; cross_release: number; resolved: number };
total: number;
limit: number;
offset: number;
groups: AdminDuplicateGroup[];
};
// The report's group tabs. 'resolved' is the third tab, served separately.
export type DuplicatesView = 'review' | 'cross_release';
// One thing the resolver did on its own (M498), from the audit log.
export type ResolvedDuplicateActivity =
| {
id: string;
action: 'duplicate_merge';
created_at: string;
details: {
survivor_path?: string;
survivor_reason?: string;
removed?: { track_id: string; file_path: string }[];
};
}
| {
id: string;
action: 'lidarr_release_change';
created_at: string;
details: { album_title?: string; artist_name?: string; from_release?: string; to_release?: string };
};
export type ResolvedDuplicatesResponse = {
total: number;
limit: number;
offset: number;
items: ResolvedDuplicateActivity[];
};
@@ -115,6 +115,18 @@
</span>
</label>
<label class="flex items-start gap-3">
<input type="checkbox" bind:checked={form.auto_resolve} class="mt-1" />
<span>
<span class="text-sm text-text-primary">Resolve duplicates automatically</span>
<span class="block text-xs text-text-secondary">
Merges copies Lidarr doesn't use into the one it does, and moves a Lidarr album off a
release that lists songs twice. A copy Lidarr uses is never removed. Off, every group
waits here for you.
</span>
</span>
</label>
<div class="grid gap-4 sm:grid-cols-2">
<label class="block">
<span class="text-sm text-text-primary">Seconds of audio to fingerprint</span>
@@ -19,7 +19,8 @@ const base: FingerprintSettings = {
chromaprint_length_sec: 120,
acoustic_max_bit_error_rate: 0.15,
backfill_concurrency: 2,
sweep_interval_hours: 1
sweep_interval_hours: 1,
auto_resolve: true
};
afterEach(() => vi.clearAllMocks());
@@ -116,6 +117,20 @@ describe('FingerprintSettingsCard', () => {
);
});
// M498, rule 25: the resolver acting on its own is the operator's to turn off.
test('auto-resolve can be turned off and is saved with the rest', async () => {
vi.mocked(updateFingerprintSettings).mockResolvedValue({ ...base, auto_resolve: false });
await renderCard();
const toggle = screen.getByRole('checkbox', { name: /resolve duplicates automatically/i }) as HTMLInputElement;
expect(toggle.checked).toBe(true);
await fireEvent.click(toggle);
await fireEvent.click(saveButton());
await waitFor(() =>
expect(updateFingerprintSettings).toHaveBeenCalledWith({ ...base, auto_resolve: false })
);
});
// Switching fingerprinting off changes what the page's counts mean.
test('a save tells the page, so its counts refresh', async () => {
const onSaved = vi.fn();
+2
View File
@@ -46,6 +46,8 @@
"sweep_in_progress": "A duplicate sweep is already running.",
"duplicate_group_not_pending": "That group has already been resolved.",
"survivor_not_in_group": "That copy isn't part of this group any more.",
"copy_tracked_by_lidarr": "Lidarr uses a copy this would remove, and would download it again. Keep that copy instead.",
"lidarr_unavailable": "Lidarr didn't answer, so it isn't safe to remove a copy yet. Try again shortly.",
"invalid_setting": "That setting is out of range.",
"invalid_public_url": "That address isn't valid.",
"album_not_found": "That album no longer exists.",
+197 -72
View File
@@ -1,8 +1,10 @@
<script lang="ts">
import { page } from '$app/state';
import { pageTitle } from '#lib/branding.js';
import { Copy, TriangleAlert } from 'lucide-svelte';
import { Copy, Info, TriangleAlert } from 'lucide-svelte';
import {
createDuplicatesQuery,
createResolvedDuplicatesQuery,
runDuplicateSweep,
dismissDuplicateGroup,
mergeDuplicateGroup
@@ -10,17 +12,35 @@
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 } from '#lib/api/types.js';
import type {
AdminDuplicateGroup,
AdminDuplicateMember,
DuplicatesView,
ResolvedDuplicateActivity
} from '#lib/api/types.js';
import FingerprintSettingsCard from '#lib/components/FingerprintSettingsCard.svelte';
import ListContinuation from '#lib/components/ListContinuation.svelte';
import TabStrip from '#lib/components/TabStrip.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.
// Tracks the duplicate sweep believes hold one recording (#3912). The
// resolver (M498) settles what is safe on its own: copies Lidarr doesn't use
// are merged into the one it does, and a Lidarr release that lists songs
// twice is changed. 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. Merging (#3911) keeps one copy, moves the others'
// likes, plays and playlist entries onto it, and deletes their files, so it
// asks twice. The server refuses to remove a copy Lidarr uses.
const PAGE_SIZE = 25;
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 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
@@ -30,14 +50,32 @@
let confirmingMerge = $state<string | null>(null);
let merging = $state<string | null>(null);
const queryStore = $derived(createDuplicatesQuery(offset, PAGE_SIZE));
const queryStore = $derived(createDuplicatesQuery(groupView));
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);
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 }
]);
function refetchAll() {
query.refetch();
resolved.refetch();
}
async function onRunSweep() {
sweeping = true;
@@ -69,6 +107,13 @@
return keepChoice[group.id] ?? group.survivor_track_id;
}
// Copies the merge would remove that Lidarr uses. The server refuses these,
// so the confirm says so before the click rather than after.
function trackedLosers(group: AdminDuplicateGroup): AdminDuplicateMember[] {
const keep = keeperOf(group);
return group.members.filter((m) => m.track_id !== keep && m.lidarr_state === 'tracked');
}
function fileCountLabel(n: number): string {
return n === 1 ? '1 file' : `${n} files`;
}
@@ -148,6 +193,17 @@
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('Admin · Duplicates')}</title></svelte:head>
@@ -155,17 +211,7 @@
<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-fg"
data-testid="duplicates-count-pill"
>
{total}
</span>
{/if}
</div>
<h2 class="font-display text-2xl font-medium text-text-primary">Duplicates</h2>
<button
type="button"
onclick={onRunSweep}
@@ -176,8 +222,8 @@
</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.
Tracks that hold the same recording more than once. Copies Lidarr doesn't use are merged on
their own, and a copy Lidarr uses is never removed. What's left here needs you.
</p>
</header>
@@ -206,12 +252,65 @@
</p>
{/if}
{#if query.isPending}
<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-error-fg">Couldn't load what was resolved.</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 and Lidarr release changes 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)}
<li class="space-y-1 px-4 py-3 text-sm" data-testid="resolved-item">
{#if item.action === 'duplicate_merge'}
{@const removed = item.details.removed ?? []}
<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)} Lidarr doesn't use.
</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}
{:else}
<p class="text-text-primary">
Lidarr now monitors {item.details.to_release ?? 'another release'} of
{item.details.album_title ?? 'an album'}{item.details.artist_name
? ` by ${item.details.artist_name}`
: ''}, which lists each song once.
</p>
{#if item.details.from_release}
<p class="text-xs text-text-secondary">Was {item.details.from_release}.</p>
{/if}
{/if}
<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}
{:else if query.isError && !query.isFetchNextPageError}
<p class="text-error-fg">Couldn't load the duplicates report.</p>
{:else if groups.length === 0}
<!-- Three states all show zero groups and mean different things. Telling
<!-- 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" />
@@ -234,11 +333,18 @@
{: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.
</p>
{:else}
<p class="mt-3 text-text-primary">No duplicates found.</p>
<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. Groups you dismiss don't come back.
different encoding, and it can't be settled on its own. Groups you dismiss don't come
back.
</p>
{/if}
</div>
@@ -246,6 +352,7 @@
<ul class="space-y-4">
{#each groups as group (group.id)}
{@const titles = differingTitles(group)}
{@const blocked = trackedLosers(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">
@@ -264,7 +371,7 @@
<button
type="button"
onclick={() => onMerge(group)}
disabled={merging === group.id}
disabled={merging === group.id || (confirmingMerge === group.id && blocked.length > 0)}
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'}"
@@ -280,6 +387,16 @@
</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"
@@ -297,18 +414,26 @@
{#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>
{#if blocked.length > 0}
<p class="text-error-fg" data-testid="merge-blocked">
Lidarr uses {blocked.length === 1 ? 'a copy' : 'copies'} this would remove, and would
download {blocked.length === 1 ? 'it' : 'them'} again. Keep the copy Lidarr uses, or
leave this group as it is.
</p>
{:else}
<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>
{/if}
<button
type="button"
class="text-xs text-text-secondary underline hover:text-text-primary"
@@ -332,7 +457,7 @@
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">
<div class="flex flex-wrap 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.
@@ -345,9 +470,26 @@
Keep · {group.survivor_reason}
</span>
{/if}
{#if m.lidarr_state === 'tracked'}
<span
class="shrink-0 rounded-full border border-border px-2 py-0.5 text-xs text-text-secondary"
data-testid="lidarr-state"
title="Lidarr maps this file to a track. Removing it would make Lidarr download it again."
>
Lidarr uses this
</span>
{:else if m.lidarr_state === 'unmapped'}
<span
class="shrink-0 rounded-full border border-border px-2 py-0.5 text-xs text-text-muted"
data-testid="lidarr-state"
title="Lidarr holds this file but maps it to no track. Removing it opens no gap."
>
Lidarr doesn't use this
</span>
{/if}
</div>
<div class="truncate text-xs text-text-secondary">
{m.artist_name} · {m.album_title}
{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}
@@ -364,32 +506,15 @@
{/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}
<ListContinuation
hasMore={query.hasNextPage}
loading={query.isFetchingNextPage}
failed={query.isFetchNextPageError}
onMore={() => query.fetchNextPage()}
/>
{/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()} />
<FingerprintSettingsCard libraryTotal={prints?.total ?? 0} onSaved={refetchAll} />
</div>
@@ -1,10 +1,15 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/svelte';
import { mockQuery } from '../../../test-utils/query';
import type { AdminDuplicatesResponse } from '#lib/api/types.js';
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({
@@ -18,7 +23,8 @@ vi.mock('#lib/api/admin.js', () => ({
chromaprint_length_sec: 120,
acoustic_max_bit_error_rate: 0.15,
backfill_concurrency: 2,
sweep_interval_hours: 1
sweep_interval_hours: 1,
auto_resolve: true
}),
updateFingerprintSettings: vi.fn()
}));
@@ -26,6 +32,7 @@ vi.mock('#lib/api/admin.js', () => ({
import AdminDuplicatesPage from './+page.svelte';
import {
createDuplicatesQuery,
createResolvedDuplicatesQuery,
dismissDuplicateGroup,
mergeDuplicateGroup,
runDuplicateSweep
@@ -59,6 +66,9 @@ function member(id: string, extra: Partial<AdminDuplicatesResponse['groups'][num
added_at: ago(500 * HOUR),
like_count: 0,
play_count: 0,
lidarr_state: null,
disc_number: null,
track_number: null,
...extra
};
}
@@ -67,6 +77,8 @@ function response(over: Partial<AdminDuplicatesResponse> = {}): AdminDuplicatesR
return {
sweep: finishedSweep,
fingerprints: allFingerprinted,
view: 'review',
counts: { review: 1, cross_release: 4, resolved: 2 },
total: 1,
limit: 25,
offset: 0,
@@ -78,6 +90,8 @@ function response(over: Partial<AdminDuplicatesResponse> = {}): AdminDuplicatesR
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')]
}
],
@@ -85,9 +99,30 @@ function response(over: Partial<AdminDuplicatesResponse> = {}): AdminDuplicatesR
};
}
function renderWith(data: AdminDuplicatesResponse | undefined) {
// 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(
mockQuery({ data }) as ReturnType<typeof createDuplicatesQuery>
infinite([data]) as unknown as ReturnType<typeof createDuplicatesQuery>
);
vi.mocked(createResolvedDuplicatesQuery).mockReturnValue(
infinite([resolvedPage(resolved)]) as unknown as ReturnType<typeof createResolvedDuplicatesQuery>
);
return render(AdminDuplicatesPage);
}
@@ -223,9 +258,9 @@ describe('admin duplicates', () => {
expect(text(screen.getByTestId('empty-state'))).toContain("hasn't run yet");
});
test('empty after a sweep says no duplicates were found', () => {
test('empty after a sweep says nothing needs review', () => {
renderWith(response({ groups: [], total: 0 }));
expect(text(screen.getByTestId('empty-state'))).toContain('No duplicates found');
expect(text(screen.getByTestId('empty-state'))).toContain('Nothing needs review');
});
test('Not duplicates dismisses that group', async () => {
@@ -275,4 +310,86 @@ describe('admin duplicates', () => {
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');
});
test('Resolved automatically lists merges and release changes', 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 Lidarr tracks',
removed: [{ track_id: 't-2', file_path: '/music/Gorillaz/Humanz (2017)/03 - Saturnz Barz (Official Video).mp3' }]
}
},
{
id: 'a-2',
action: 'lidarr_release_change',
created_at: ago(2 * HOUR),
details: {
album_title: 'Humanz',
artist_name: 'Gorillaz',
from_release: '14x12" Vinyl, Digital Media (68 tracks)',
to_release: 'Digital Media (40 tracks)'
}
}
]);
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 Lidarr tracks) and removed 1 file');
expect(items[1]).toContain('Lidarr now monitors Digital Media (40 tracks) of Humanz by Gorillaz');
expect(screen.queryByTestId('duplicate-group')).toBeNull();
});
});
describe("Lidarr's view of each copy", () => {
function tracked() {
const r = response();
r.groups[0] = {
...r.groups[0],
resolve_note: 'Lidarr tracks every copy as its own track.',
members: [member('www-01', { lidarr_state: 'tracked' }), member('www-02', { lidarr_state: 'unmapped' })]
};
return r;
}
test('each copy says whether Lidarr uses it, and the resolver note shows', () => {
renderWith(tracked());
expect(screen.getAllByTestId('lidarr-state').map(text)).toEqual([
'Lidarr uses this',
"Lidarr doesn't use this"
]);
expect(text(screen.getByTestId('resolve-note'))).toBe('Lidarr tracks every copy as its own track.');
});
// Keeping the unmapped copy would remove the one Lidarr uses. The server
// refuses that; the confirm says so first and offers no button to press.
test('a merge that would remove the copy Lidarr uses is blocked before it is sent', async () => {
renderWith(tracked());
await fireEvent.click(screen.getAllByRole('radio')[1]);
await fireEvent.click(screen.getByRole('button', { name: 'Merge…' }));
expect(text(screen.getByTestId('merge-blocked'))).toContain('Lidarr uses a copy this would remove');
const confirm = screen.getByRole('button', { name: 'Remove 1 file and merge' }) as HTMLButtonElement;
expect(confirm.disabled).toBe(true);
// Choosing the copy Lidarr uses clears it.
await fireEvent.click(screen.getAllByRole('radio')[0]);
expect(screen.queryByTestId('merge-blocked')).toBeNull();
await fireEvent.click(screen.getByRole('button', { name: 'Remove 1 file and merge' }));
expect(mergeDuplicateGroup).toHaveBeenCalledWith('g-1', { survivor_track_id: 'www-01', unmonitor: false });
});
});
});