feat: duplicates settle themselves: retitled copies across releases link, alike versions on one album are dismissed, and a file in another song's slot is removed so the album is fetched again (M498 #6159-#6162)
release / govulncheck (push) Successful in 14s
release / web (push) Failing after 1m26s
release / go (push) Successful in 1m35s
release / integration (push) Successful in 5m8s
release / android (push) Successful in 5m12s
release / Build signed APK (releases and dev) (push) Successful in 5m26s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
release / govulncheck (push) Successful in 14s
release / web (push) Failing after 1m26s
release / go (push) Successful in 1m35s
release / integration (push) Successful in 5m8s
release / android (push) Successful in 5m12s
release / Build signed APK (releases and dev) (push) Successful in 5m26s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
The operator's library had 536 groups waiting for review, 2026-10-19. A sample
showed most were one recording under two titles on two releases.
- The headline and the Duplicates count leave out cross-release groups, which
the resolver keeps and links: the headline read 3,479 when 536 needed review.
- Any group spanning albums is cross_release and is linked as one song,
whatever its titles say. Nothing is deleted.
- One album, two versions named as such ("Lovesick (Instrumental)") and not
byte-identical: a new class, distinct, dismissed by the resolver.
- One album, one recording under different song titles (mismatch): the
album's cached MusicBrainz track list decides. When exactly one slot's song
is as long as the file and every other slot's song is clearly not, the other
copies are removed and their tracks marked missing, so the reacquisition
sweeper wants the album back. Otherwise the group stays for review.
- The duplicates_found notice moves from the sweep to the resolver: it names
only groups a pass first left for review.
- Resolved automatically lists dismissals and wrong-slot removals beside
merges.
Migration 0094 adds 'distinct' to the class CHECK (rule 36).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -448,7 +448,7 @@ export type AdminDuplicateMember = {
|
||||
};
|
||||
|
||||
// The resolver's verdict on a group (M498). null until its first pass.
|
||||
export type DuplicateClass = 'same_release' | 'cross_release' | 'mismatch' | 'review';
|
||||
export type DuplicateClass = 'same_release' | 'cross_release' | 'distinct' | 'mismatch' | 'review';
|
||||
|
||||
// exact: identical encoded audio. acoustic: the same recording, differently
|
||||
// encoded; worst_bit_error_rate is the weakest link between any two members.
|
||||
@@ -504,14 +504,20 @@ export type AdminDuplicatesResponse = {
|
||||
export type DuplicatesView = 'review' | 'cross_release';
|
||||
|
||||
// One merge the resolver made on its own (M498), from the audit log.
|
||||
// What the resolver did by itself: merged a group, dismissed two versions of a
|
||||
// song as not duplicates, or removed copies that filled another song's track
|
||||
// slot (their tracks stay, marked missing, and the album is fetched again).
|
||||
export type ResolvedDuplicateActivity = {
|
||||
id: string;
|
||||
action: 'duplicate_merge';
|
||||
action: 'duplicate_merge' | 'duplicate_dismiss' | 'duplicate_wrong_file';
|
||||
created_at: string;
|
||||
details: {
|
||||
survivor_path?: string;
|
||||
survivor_reason?: string;
|
||||
removed?: { track_id: string; file_path: string }[];
|
||||
removed?: { track_id: string; file_path: string; title?: string }[];
|
||||
artist?: string;
|
||||
titles?: string[];
|
||||
kept_title?: string;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -188,8 +188,10 @@
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-text-secondary">
|
||||
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.
|
||||
Tracks that hold the same recording more than once. Most settle on their own: a song held twice
|
||||
on one album is merged, copies on different releases count as one song, two versions of a song
|
||||
are left alone, and a file filling another song's track slot is removed so the album is fetched
|
||||
again. What's left here needs you.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
@@ -233,7 +235,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 made on their own are listed here.
|
||||
What Minstrel settled on its own is listed here.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
@@ -241,16 +243,31 @@
|
||||
{#each activity as item (item.id)}
|
||||
{@const removed = item.details.removed ?? []}
|
||||
<li class="space-y-1 px-4 py-3 text-sm" data-testid="resolved-item">
|
||||
<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 {plural(removed.length, 'file', 'files')}.
|
||||
</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}
|
||||
{#if item.action === 'duplicate_dismiss'}
|
||||
<p class="text-text-primary">
|
||||
{item.details.artist}: {(item.details.titles ?? []).join(' and ')} are different versions,
|
||||
not duplicates. Both kept.
|
||||
</p>
|
||||
{:else if item.action === 'duplicate_wrong_file'}
|
||||
<p class="text-text-primary">
|
||||
{item.details.artist}: the recording of {item.details.kept_title} also filled the
|
||||
{plural(removed.length, 'slot', 'slots')} of {removed.map((r) => r.title).join(', ')}.
|
||||
Removed; the album will be fetched again.
|
||||
</p>
|
||||
{:else}
|
||||
<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 {plural(removed.length, 'file', 'files')}.
|
||||
</p>
|
||||
{/if}
|
||||
{#if item.action !== 'duplicate_dismiss'}
|
||||
{#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}
|
||||
{/if}
|
||||
<p class="text-xs text-text-muted">{relativeTime(item.created_at)}</p>
|
||||
</li>
|
||||
{/each}
|
||||
|
||||
@@ -354,6 +354,34 @@ describe('admin duplicates', () => {
|
||||
expect(items[0]).toContain('Kept 03 - Saturnz Barz.mp3 (the copy that is not a video rip) and removed 1 file.');
|
||||
expect(screen.queryByTestId('duplicate-group')).toBeNull();
|
||||
});
|
||||
|
||||
test('Resolved automatically says what was dismissed and which slots were emptied', async () => {
|
||||
renderWith(response(), [
|
||||
{
|
||||
id: 'a-2',
|
||||
action: 'duplicate_dismiss',
|
||||
created_at: ago(HOUR),
|
||||
details: { artist: 'Moe Shop', titles: ['Lovesick', 'Lovesick (Instrumental)'] }
|
||||
},
|
||||
{
|
||||
id: 'a-3',
|
||||
action: 'duplicate_wrong_file',
|
||||
created_at: ago(HOUR),
|
||||
details: {
|
||||
artist: 'Savant',
|
||||
kept_title: 'Void',
|
||||
removed: [{ track_id: 't-9', file_path: '/data/Music/Savant/Void/17 - Reach.m4a', title: 'Reach' }]
|
||||
}
|
||||
}
|
||||
]);
|
||||
await fireEvent.click(screen.getByRole('tab', { name: /resolved automatically/i }));
|
||||
const items = screen.getAllByTestId('resolved-item').map(text);
|
||||
expect(items[0]).toContain('Moe Shop: Lovesick and Lovesick (Instrumental) are different versions, not duplicates. Both kept.');
|
||||
expect(items[1]).toContain(
|
||||
'Savant: the recording of Void also filled the slot of Reach. Removed; the album will be fetched again.'
|
||||
);
|
||||
expect(items[1]).toContain('/data/Music/Savant/Void/17 - Reach.m4a');
|
||||
});
|
||||
});
|
||||
|
||||
describe("the resolver's note", () => {
|
||||
|
||||
Reference in New Issue
Block a user