feat: duplicates resolve themselves without Lidarr (M502 #5517)
release / govulncheck (push) Successful in 37s
release / web (push) Successful in 1m13s
release / go (push) Successful in 1m29s
release / integration (push) Successful in 4m46s
release / android (push) Successful in 5m36s
release / Build signed APK (releases and dev) (push) Successful in 5m54s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 6m48s
release / Verify release artifacts (tag releases only) (push) Skipped

Lidarr is off, and with it the resolver had stopped merging: it only
merged when Lidarr had said which copies it mapped. Minstrel is now the
only program writing the library, so the rule is about the copies:

- A song held twice on one album: the extra copies merge into the one
  whose tags fit best (then lossless, larger, older).
- Across releases or under another title: when every copy but one is a
  video rip (held back, or named like one and not marked fine), the rips
  merge into the one that is not.
- Clean copies on different releases are linked as one song, as before.
- Anything else waits for the operator.

Gone with Lidarr: release switches and their settle window, the
unmapped-file read, the rip search, the merge guard and its 409/503,
lidarr.Client's release methods, and duplicate_group_members.lidarr_state
(migration 0078). The duplicates page drops the per-copy Lidarr badge
and the blocked-merge notice; past release changes stay in its history.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 20:12:49 -04:00
co-authored by Claude Opus 5.5
parent 8bd48b4302
commit 5306a261c1
27 changed files with 301 additions and 1743 deletions
-4
View File
@@ -479,10 +479,6 @@ 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;
};
@@ -120,10 +120,9 @@
<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 song kept on several releases counts as one song, and
video rips are kept out of radio and the mixes. A copy Lidarr uses is never removed.
Off, every group waits here for you.
Merges a song held twice on one album, and a video rip with a clean copy elsewhere, into
the copy that fits best. A song kept on several releases counts as one song, and video
rips are kept out of radio and the mixes. Off, every group waits here for you.
</span>
</span>
</label>
-2
View File
@@ -46,8 +46,6 @@
"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.",
"suspect_track_not_found": "That track isn't flagged any more. Refresh the list.",
"invalid_verdict": "That isn't a choice for a flagged track.",
"invalid_setting": "That setting is out of range.",
+23 -56
View File
@@ -23,17 +23,17 @@
import TabStrip from '#lib/components/TabStrip.svelte';
// 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.
// 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. The server refuses to remove a copy Lidarr uses.
// asks twice.
type Tab = DuplicatesView | 'resolved';
const fromUrl = page.url.searchParams.get('view');
@@ -108,13 +108,6 @@
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`;
}
@@ -223,8 +216,8 @@
</button>
</div>
<p class="text-text-secondary">
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.
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>
@@ -265,7 +258,7 @@
<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.
Merges made on their own are listed here.
</p>
</div>
{:else}
@@ -279,7 +272,7 @@
{#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.
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>
@@ -354,7 +347,6 @@
<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">
@@ -373,7 +365,7 @@
<button
type="button"
onclick={() => onMerge(group)}
disabled={merging === group.id || (confirmingMerge === group.id && blocked.length > 0)}
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'}"
@@ -416,26 +408,18 @@
{#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">
{#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}
<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"
@@ -472,23 +456,6 @@
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}{position(m) ? ` · ${position(m)}` : ''}
@@ -66,7 +66,6 @@ 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
@@ -332,7 +331,9 @@ describe('admin duplicates', () => {
expect(dismissDuplicateGroup).toHaveBeenCalledWith('g-1');
});
test('Resolved automatically lists merges and release changes', async () => {
// Release changes stopped with Lidarr (M502), but the ones made before stay
// in the history.
test('Resolved automatically lists merges and past release changes', async () => {
renderWith(response(), [
{
id: 'a-1',
@@ -340,7 +341,7 @@ describe('admin duplicates', () => {
created_at: ago(HOUR),
details: {
survivor_path: '/music/Gorillaz/Humanz (2017)/03 - Saturnz Barz.mp3',
survivor_reason: 'the copy Lidarr tracks',
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' }]
}
},
@@ -358,47 +359,26 @@ describe('admin duplicates', () => {
]);
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[0]).toContain('Kept 03 - Saturnz Barz.mp3 (the copy that is not a video rip) 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() {
describe("the resolver's note", () => {
test('shows above the group it explains', () => {
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.');
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.');
});
// 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());
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…' }));
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 });
expect(mergeDuplicateGroup).toHaveBeenCalledWith('g-1', { survivor_track_id: 'www-02', unmonitor: false });
});
});
});