feat(library): merge duplicates without losing history (M400 #3911)
test-web / test (push) Successful in 57s
test-go / test (push) Successful in 1m16s
test-go / integration (push) Successful in 3m39s
release / Build signed APK (releases and dev) (push) Successful in 4m46s
release / Build + push container image (push) Successful in 26s
release / Verify release artifacts (tag releases only) (push) Skipped

Merge keeps one copy of a duplicate group and removes the rest. Every
table that references tracks does so ON DELETE CASCADE, so deleting a
duplicate's row outright would silently destroy its likes, plays,
playlist entries and tags. The merge moves all of that onto the kept
copy first, then deletes the empty row.

In one transaction, holding a lock on the group:
- repoints play_events, skip_events, contextual_likes, playback_errors,
  lidarr_requests.matched_track_id and playlist_tracks. The last is
  keyed by position, so every entry stays where it was.
- merges general_likes one per user, dated to the earlier like
- takes the union of track_tags, keeping the kept copy's own weight on
  a shared tag
- rewrites track_similarity onto the kept copy, dropping edges that
  would point a track at itself and keeping the kept copy's existing
  edge on a collision
- lets the kept copy take a recording MBID only the removed copy had
- deletes the removed copies' rows, tidies emptied albums and artists,
  marks the group merged
- logs sync changes: track deletes, and like and playlist-track
  delete/upsert pairs

The removed copies' files are deleted first, before any row changes,
through the same helper as DeleteTrackFile (now shared, along with the
album tidy-up). A merge that left the file behind would be undone by
the next scan re-importing it. An unwritable library answers 409
library_not_writable and nothing changes.

tracks.Service.MergeDuplicates wraps it with the opt-in Lidarr unmonitor
from RemoveTrack, skipped when the removed copy is a second file of the
kept copy's own album track: unmonitoring that would stop Lidarr
managing the kept file. It writes a duplicate_merge audit row after
commit, per the audit package's best-effort contract, naming both
paths.

POST /api/admin/library/duplicates/{id}/merge takes an optional
survivor_track_id (the report's proposal otherwise) and unmonitor.

On the report page:
- each copy gets a Keep choice, defaulting to the proposed one
- Merge needs a second click, on a button that says how many files it
  removes, with the consequence stated beside an opt-in Lidarr checkbox

Integration tests cover:
- every piece of history landing on the kept copy exactly: likes
  deduped at the earlier time, plays and skips counted, playlist
  position unchanged, tags unioned, similarity rewritten with no
  duplicate or self-edge, MBID inherited
- the removed file gone, and a second merge refused
- an unwritable file leaving likes, plays, row and group untouched
- a survivor outside the group refused

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
This commit is contained in:
2026-09-11 17:25:06 -04:00
co-authored by Claude Opus 5
parent ff493a8c7d
commit 11ef044ef6
17 changed files with 1462 additions and 39 deletions
+10 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { dismissDuplicateGroup, listDuplicates, runDuplicateSweep } from './admin';
import { dismissDuplicateGroup, listDuplicates, mergeDuplicateGroup, runDuplicateSweep } from './admin';
vi.mock('./client', () => ({
api: { get: vi.fn(), post: vi.fn() }
@@ -27,4 +27,13 @@ describe('admin duplicates API', () => {
await dismissDuplicateGroup('g/1');
expect(api.post).toHaveBeenCalledWith('/api/admin/library/duplicates/g%2F1/dismiss', {});
});
it('mergeDuplicateGroup POSTs the chosen survivor', async () => {
(api.post as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ removed_paths: [] });
await mergeDuplicateGroup('g-1', { survivor_track_id: 't-1', unmonitor: true });
expect(api.post).toHaveBeenCalledWith('/api/admin/library/duplicates/g-1/merge', {
survivor_track_id: 't-1',
unmonitor: true
});
});
});
+14
View File
@@ -5,6 +5,7 @@ import type {
ActionResult,
AdminMissingResponse,
AdminDuplicatesResponse,
MergeDuplicateResult,
AdminPlaybackError,
AdminQuarantineRow,
LidarrConfig,
@@ -723,6 +724,19 @@ export async function runDuplicateSweep(): Promise<{ started: boolean }> {
return api.post<{ started: boolean }>('/api/admin/library/duplicates/sweep', {});
}
// Merges a group into the copy chosen to keep, removing the other copies' files.
// survivor_track_id is the operator's choice; the server checks it belongs to
// the group.
export async function mergeDuplicateGroup(
id: string,
body: { survivor_track_id: string; unmonitor: boolean }
): Promise<MergeDuplicateResult> {
return api.post<MergeDuplicateResult>(
`/api/admin/library/duplicates/${encodeURIComponent(id)}/merge`,
body
);
}
export async function dismissDuplicateGroup(id: string): Promise<void> {
await api.post(`/api/admin/library/duplicates/${encodeURIComponent(id)}/dismiss`, {});
}
+9
View File
@@ -460,6 +460,15 @@ export type AdminDuplicateSweep = {
error_message: string | null;
};
// What a merge did (#3911). removed_paths are the files that were deleted from
// disk; lidarr_unmonitor_failed appears only when unmonitoring was asked for and
// failed.
export type MergeDuplicateResult = {
survivor_track_id: string;
removed_paths: string[];
lidarr_unmonitor_failed?: boolean;
};
export type AdminDuplicatesResponse = {
sweep: AdminDuplicateSweep;
fingerprints: { total: number; fingerprinted: number; rejected: number; pending: number };
+1
View File
@@ -44,6 +44,7 @@
"file_delete_failed": "The file couldn't be deleted.",
"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.",
"album_not_found": "That album no longer exists.",
"artist_not_found": "That artist no longer exists.",
"playlist_not_found": "That playlist no longer exists.",
+105 -12
View File
@@ -4,22 +4,30 @@
import {
createDuplicatesQuery,
runDuplicateSweep,
dismissDuplicateGroup
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';
// 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.
// 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);
@@ -56,6 +64,40 @@
}
}
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.
@@ -177,20 +219,71 @@
<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 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>
@@ -6,11 +6,20 @@ import type { AdminDuplicatesResponse } from '$lib/api/types';
vi.mock('$lib/api/admin', () => ({
createDuplicatesQuery: vi.fn(),
runDuplicateSweep: vi.fn().mockResolvedValue({ started: true }),
dismissDuplicateGroup: vi.fn().mockResolvedValue(undefined)
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, dismissDuplicateGroup, runDuplicateSweep } from '$lib/api/admin';
import {
createDuplicatesQuery,
dismissDuplicateGroup,
mergeDuplicateGroup,
runDuplicateSweep
} from '$lib/api/admin';
const HOUR = 3_600_000;
const ago = (ms: number) => new Date(Date.now() - ms).toISOString();
@@ -144,4 +153,40 @@ describe('admin duplicates', () => {
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',
unmonitor: false
});
});
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',
unmonitor: false
});
});
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();
});
});