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
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:
@@ -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>
|
||||
|
||||
@@ -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,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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user