fix: a group of one recording in several slots is settled by getting the album again, not merged (#6184)
release / govulncheck (push) Successful in 36s
release / web (push) Successful in 1m39s
release / go (push) Successful in 1m45s
release / android (push) Successful in 5m9s
release / integration (push) Successful in 5m11s
release / Build signed APK (releases and dev) (push) Successful in 5m21s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m23s
release / Verify release artifacts (tag releases only) (push) Skipped

The duplicates review offered only Merge for a wrong-slot group. A merge keeps
one copy's title whether or not it is the audio's, and neither sets the release
aside nor wants the missing song back.

For class mismatch the page now asks which song the recording is. "Get it
again" (the operator can't tell) removes every copy; "Keep <title>" keeps that
copy and removes the ones under other titles. Either way it runs the resolver's
own path: the release the files came from is set aside, the removed copies'
tracks stay marked missing and emptied, and the album is wanted again.

- library.SettleWrongSlot, POST /api/admin/library/duplicates/{id}/refetch
- ListDuplicateGroupsForResolve takes an optional group_id
- removeWrongSlots takes the acting operator (manual: not automatic, actor in
  the audit row) and removes files through removeTrackFileOnDisk, so a
  read-only library answers library_not_writable

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-21 13:47:43 -04:00
co-authored by Claude Opus 5.5
parent 5bcbc80555
commit b9684e6d12
11 changed files with 601 additions and 50 deletions
+13
View File
@@ -9,6 +9,7 @@ import type {
AdminDuplicatesResponse,
DuplicatesView,
MergeDuplicateResult,
RefetchDuplicateResult,
ResolvedDuplicatesResponse,
AdminPlaybackError,
AdminQuarantineRow,
@@ -893,6 +894,18 @@ export async function mergeDuplicateGroup(
);
}
// One recording in several of an album's slots (#6184): keep the copy whose
// title it is, or none (empty keep_track_id), and fetch the album again.
export async function refetchDuplicateGroup(
id: string,
body: { keep_track_id: string }
): Promise<RefetchDuplicateResult> {
return api.post<RefetchDuplicateResult>(
`/api/admin/library/duplicates/${encodeURIComponent(id)}/refetch`,
body
);
}
export async function dismissDuplicateGroup(id: string): Promise<void> {
await api.post(`/api/admin/library/duplicates/${encodeURIComponent(id)}/dismiss`, {});
}
+9
View File
@@ -485,6 +485,15 @@ export type MergeDuplicateResult = {
removed_paths: string[];
};
// What settling one recording in several slots did (#6184). kept_track_id is
// null when every copy went; blocked names the releases set aside, empty when
// the album came from before Minstrel downloaded anything.
export type RefetchDuplicateResult = {
kept_track_id: string | null;
removed_paths: string[];
blocked: string[];
};
export type AdminDuplicatesResponse = {
sweep: AdminDuplicateSweep;
fingerprints: {
+165 -29
View File
@@ -9,7 +9,8 @@
createResolvedDuplicatesQuery,
runDuplicateSweep,
dismissDuplicateGroup,
mergeDuplicateGroup
mergeDuplicateGroup,
refetchDuplicateGroup
} from '#lib/api/admin.js';
import { errMessage } from '#lib/api/errors.js';
import { pushToast } from '#lib/stores/toast.svelte.js';
@@ -38,6 +39,14 @@
// 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.
//
// Except for one recording filling several of an album's slots (class
// mismatch, #6184), where a merge is the wrong answer: it keeps one copy's
// title whether or not it is the audio's, and fetches nothing. There the
// operator says which song the recording is, or that they can't tell, and the
// group settles as the resolver would have: copies in other songs' slots are
// removed, the release they came from is set aside, and the album is fetched
// again. Operator, 2026-10-21: "for some of these I don't have any idea".
type Tab = DuplicatesView | 'resolved';
const fromUrl = page.url.searchParams.get('view');
@@ -53,6 +62,11 @@
let keepChoice = $state<Record<string, string>>({});
let confirmingMerge = $state<string | null>(null);
let merging = $state<string | null>(null);
// Per wrong-slot group: the copy whose title the recording is, absent when
// the operator can't tell, and the same two-click confirm.
let slotPick = $state<Record<string, string>>({});
let confirmingRefetch = $state<string | null>(null);
let refetching = $state<string | null>(null);
const queryStore = $derived(createDuplicatesQuery(groupView));
const query = $derived($queryStore);
@@ -126,6 +140,51 @@
}
}
function wrongSlot(g: AdminDuplicateGroup): boolean {
return g.class === 'mismatch';
}
function pickedOf(g: AdminDuplicateGroup): AdminDuplicateMember | undefined {
return g.members.find((m) => m.track_id === slotPick[g.id]);
}
// Close to the server's MatchTitleKey: case, "&" and punctuation aside.
function songKey(title: string): string {
return title.toLowerCase().replace(/&/g, 'and').replace(/[^\p{L}\p{N}]/gu, '');
}
// The copies that go: every one when the operator can't tell, else those
// under another title. A copy under the picked title is the song held twice,
// and the next sweep merges it.
function refetchRemoves(g: AdminDuplicateGroup): AdminDuplicateMember[] {
const picked = pickedOf(g);
if (!picked) return g.members;
return g.members.filter((m) => songKey(m.title) !== songKey(picked.title));
}
async function onRefetch(group: AdminDuplicateGroup) {
if (confirmingRefetch !== group.id) {
confirmingRefetch = group.id;
return;
}
confirmingRefetch = null;
refetching = group.id;
try {
const result = await refetchDuplicateGroup(group.id, { keep_track_id: slotPick[group.id] ?? '' });
const removed = `Removed ${plural(result.removed_paths.length, 'file', 'files')}`;
pushToast(
result.blocked.length > 0
? `${removed} and set aside the release they came from. The album will be fetched again.`
: `${removed}. The album will be fetched again, but Minstrel didn't download these files, so the same ones may come back.`
);
query.refetch();
} catch (e: unknown) {
pushToast(errMessage(e), 'error');
} finally {
refetching = 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.
@@ -377,27 +436,49 @@
<button
type="button"
onclick={() => onDismiss(group)}
disabled={dismissing === group.id || merging === group.id}
disabled={dismissing === group.id || merging === group.id || refetching === 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"
>
{tab === 'cross_release' ? 'Not the same song' : '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 {plural(group.members.length - 1, 'file', 'files')} and merge
{:else}
Merge…
{/if}
</button>
{#if wrongSlot(group)}
{@const picked = pickedOf(group)}
<button
type="button"
onclick={() => onRefetch(group)}
disabled={refetching === group.id}
class="rounded-md px-3 py-1.5 text-sm disabled:opacity-50 {confirmingRefetch === group.id
? 'bg-action-destructive text-action-fg hover:opacity-90'
: 'border border-border text-text-primary hover:bg-surface-hover'}"
>
{#if refetching === group.id}
Removing…
{:else if confirmingRefetch === group.id}
Remove {plural(refetchRemoves(group).length, 'file', 'files')} and get it again
{:else if picked}
Keep “{picked.title}”…
{:else}
Get it again…
{/if}
</button>
{:else}
<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 {plural(group.members.length - 1, 'file', 'files')} and merge
{:else}
Merge…
{/if}
</button>
{/if}
</div>
</div>
@@ -424,12 +505,40 @@
<span>
{group.tier === 'exact' ? 'The audio is identical' : 'The music is the same'}, but the
copies are titled {quotedList(titles)}. If those name different recordings, one file is
mislabelled and the other recording isn't in your library. Play each and keep the copy
whose title matches what you hear.
mislabelled and the other recording isn't in your library.
{#if wrongSlot(group)}
Play them. If you know which song this is, pick the copy with that title. If you can't
tell, get the album again: every copy goes, and it's fetched from another release.
{:else}
Play each and keep the copy whose title matches what you hear.
{/if}
</span>
</p>
{/if}
{#if confirmingRefetch === group.id}
{@const picked = pickedOf(group)}
<div class="space-y-2 border-b border-border bg-surface-hover px-4 py-3 text-sm" data-testid="refetch-confirm">
<p class="text-text-primary">
{#if picked}
The copy titled “{picked.title}” stays, and {plural(refetchRemoves(group).length, 'copy', 'copies')}
under other titles will be deleted from disk.
{:else}
All {plural(group.members.length, 'copy', 'copies')} will be deleted from disk.
{/if}
Their tracks stay, marked missing, with their likes and plays. The release the files came
from is set aside, and the album is fetched again from another one.
</p>
<button
type="button"
class="text-xs text-text-secondary underline hover:text-text-primary"
onclick={() => (confirmingRefetch = null)}
>
Cancel
</button>
</div>
{/if}
{#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">
@@ -449,20 +558,38 @@
<ul class="divide-y divide-border">
{#each group.members as m (m.track_id)}
{@const keep = keeperOf(group) === m.track_id}
{@const keep = !wrongSlot(group) && keeperOf(group) === m.track_id}
{@const proposed = 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}"
/>
{#if wrongSlot(group)}
<!-- No copy is proposed: which song the recording is, is the
question, and nothing here can answer it. -->
<input
type="radio"
name="song-{group.id}"
class="mt-1"
checked={slotPick[group.id] === m.track_id}
onchange={() => (slotPick[group.id] = m.track_id)}
aria-label="This is {m.title}, {m.file_path}"
/>
{:else}
<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}"
/>
{/if}
<div class="min-w-0 flex-1 space-y-0.5">
<div class="flex flex-wrap items-center gap-2">
<span class="truncate text-sm text-text-primary">{m.title}</span>
{#if wrongSlot(group) && slotPick[group.id] === m.track_id}
<span class="shrink-0 rounded-full bg-accent-tint px-2 py-0.5 text-xs text-accent-fg" data-testid="song-badge">
The recording is this song
</span>
{/if}
{#if keep}
<!-- The copy the merge will keep: the proposed one with the
rule that chose it, or the one the operator picked. The
@@ -499,6 +626,15 @@
</li>
{/each}
</ul>
{#if wrongSlot(group) && pickedOf(group)}
<button
type="button"
class="w-full border-t border-border px-4 py-2 text-left text-xs text-text-secondary underline hover:text-text-primary"
onclick={() => delete slotPick[group.id]}
>
I can't tell which song it is
</button>
{/if}
</li>
{/each}
</ul>
@@ -15,6 +15,11 @@ vi.mock('#lib/api/admin.js', () => ({
mergeDuplicateGroup: vi.fn().mockResolvedValue({
survivor_track_id: 'www-01',
removed_paths: ['/music/Moe Shop/WWW (2020)/www-02.mp3']
}),
refetchDuplicateGroup: vi.fn().mockResolvedValue({
kept_track_id: null,
removed_paths: ['/music/Moe Shop/WWW (2020)/www-01.mp3', '/music/Moe Shop/WWW (2020)/www-02.mp3'],
blocked: ['Moe Shop - WWW [WEB]']
})
}));
vi.mock('#lib/player/store.svelte.js', () => ({ playQueue: vi.fn() }));
@@ -26,6 +31,7 @@ import {
createResolvedDuplicatesQuery,
dismissDuplicateGroup,
mergeDuplicateGroup,
refetchDuplicateGroup,
runDuplicateSweep
} from '#lib/api/admin.js';
@@ -256,7 +262,64 @@ describe('admin duplicates', () => {
renderWith(r);
const notice = text(screen.getByTestId('title-mismatch'));
expect(notice).toContain('The music is the same, but the copies are titled “WWW” and “WWW (instrumental)”');
expect(notice).toContain('Play each');
expect(notice).toContain("If you can't tell, get the album again");
});
});
// One recording in several of an album's slots (#6184). A merge would keep
// one title whether or not it is the audio's and fetch nothing, so it is not
// offered: the operator says which song it is, or that they can't tell.
describe('one recording in several slots', () => {
function slots() {
const r = response();
r.groups[0] = {
...r.groups[0],
tier: 'acoustic',
worst_bit_error_rate: 0.04,
class: 'mismatch',
members: [member('www-01'), member('www-02', { title: 'WWW (instrumental)' })]
};
return r;
}
test('offers Get it again instead of Merge, with no copy proposed', () => {
renderWith(slots());
expect(screen.queryByRole('button', { name: 'Merge…' })).toBeNull();
expect(screen.getByRole('button', { name: 'Get it again…' })).toBeTruthy();
expect(screen.getAllByRole('radio').some((r) => (r as HTMLInputElement).checked)).toBe(false);
expect(screen.queryByTestId('survivor-badge')).toBeNull();
});
test("can't tell: every copy goes, after a second click", async () => {
renderWith(slots());
await fireEvent.click(screen.getByRole('button', { name: 'Get it again…' }));
expect(refetchDuplicateGroup).not.toHaveBeenCalled();
const confirm = text(screen.getByTestId('refetch-confirm'));
expect(confirm).toContain('All 2 copies will be deleted from disk');
expect(confirm).toContain('the album is fetched again from another one');
await fireEvent.click(screen.getByRole('button', { name: 'Remove 2 files and get it again' }));
expect(refetchDuplicateGroup).toHaveBeenCalledWith('g-1', { keep_track_id: '' });
});
test('naming the song keeps that copy and removes the others', async () => {
renderWith(slots());
await fireEvent.click(screen.getByRole('radio', { name: /This is WWW \(instrumental\)/ }));
expect(screen.getByTestId('song-badge')).toBeTruthy();
await fireEvent.click(screen.getByRole('button', { name: 'Keep “WWW (instrumental)”…' }));
expect(text(screen.getByTestId('refetch-confirm'))).toContain(
'The copy titled “WWW (instrumental)” stays, and 1 copy under other titles will be deleted from disk'
);
await fireEvent.click(screen.getByRole('button', { name: 'Remove 1 file and get it again' }));
expect(refetchDuplicateGroup).toHaveBeenCalledWith('g-1', { keep_track_id: 'www-02' });
});
test("I can't tell takes the pick back", async () => {
renderWith(slots());
await fireEvent.click(screen.getAllByRole('radio')[0]);
await fireEvent.click(screen.getByRole('button', { name: "I can't tell which song it is" }));
expect(screen.getByRole('button', { name: 'Get it again…' })).toBeTruthy();
expect(screen.queryByTestId('song-badge')).toBeNull();
});
});