feat: the duplicates review says what each match rests on and plays every copy (#6168)
release / govulncheck (push) Successful in 16s
release / web (push) Successful in 1m25s
release / go (push) Successful in 1m34s
release / android (push) Successful in 4m58s
release / integration (push) Successful in 5m4s
release / Build signed APK (releases and dev) (push) Successful in 5m9s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 25s
release / Verify release artifacts (tag releases only) (push) Skipped
release / govulncheck (push) Successful in 16s
release / web (push) Successful in 1m25s
release / go (push) Successful in 1m34s
release / android (push) Successful in 4m58s
release / integration (push) Successful in 5m4s
release / Build signed APK (releases and dev) (push) Successful in 5m9s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 25s
release / Verify release artifacts (tag releases only) (push) Skipped
Each group now states its evidence: identical audio data, or the share of audio fingerprints that agree. Copies titled as different songs in one acoustic group say so and ask the reviewer to listen, and each copy has a play button so they can. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -433,6 +433,7 @@ export type AdminSuspectResponse = {
|
||||
export type AdminDuplicateMember = {
|
||||
track_id: string;
|
||||
title: string;
|
||||
artist_id: string;
|
||||
artist_name: string;
|
||||
album_id: string;
|
||||
album_title: string;
|
||||
@@ -445,6 +446,8 @@ export type AdminDuplicateMember = {
|
||||
play_count: number;
|
||||
disc_number: number | null;
|
||||
track_number: number | null;
|
||||
// To play each copy from the review (#6168).
|
||||
stream_url: string;
|
||||
};
|
||||
|
||||
// The resolver's verdict on a group (M498). null until its first pass.
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
import { plural } from '#lib/utils/plural.js';
|
||||
import { page } from '$app/state';
|
||||
import { pageTitle } from '#lib/branding.js';
|
||||
import { Copy, Info, TriangleAlert } from 'lucide-svelte';
|
||||
import { Copy, Info, Play, TriangleAlert } from 'lucide-svelte';
|
||||
import { playQueue } from '#lib/player/store.svelte.js';
|
||||
import {
|
||||
createDuplicatesQuery,
|
||||
createResolvedDuplicatesQuery,
|
||||
@@ -19,6 +20,7 @@
|
||||
AdminDuplicateGroup,
|
||||
AdminDuplicateMember,
|
||||
DuplicatesView,
|
||||
TrackRef,
|
||||
ResolvedDuplicateActivity
|
||||
} from '#lib/api/types.js';
|
||||
import ListContinuation from '#lib/components/ListContinuation.svelte';
|
||||
@@ -127,19 +129,35 @@
|
||||
// "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.
|
||||
function tierLabel(g: AdminDuplicateGroup): string {
|
||||
if (g.tier === 'exact') return 'Identical audio';
|
||||
const match = Math.round((1 - (g.worst_bit_error_rate ?? 0)) * 100);
|
||||
return `Same recording · ${match}% match`;
|
||||
function matchPercent(g: AdminDuplicateGroup): number {
|
||||
return Math.round((1 - (g.worst_bit_error_rate ?? 0)) * 100);
|
||||
}
|
||||
|
||||
// Identical audio under different titles means at least one file's tags are
|
||||
function tierLabel(g: AdminDuplicateGroup): string {
|
||||
if (g.tier === 'exact') return 'Identical audio';
|
||||
return `Same music · ${matchPercent(g)}% match`;
|
||||
}
|
||||
|
||||
// What the match rests on, said outright: the copies' sizes and lengths sit
|
||||
// beside them, and an operator reading only those took the match for a guess
|
||||
// made from them (2026-10-20, #6168).
|
||||
function evidence(g: AdminDuplicateGroup): string {
|
||||
if (g.tier === 'exact') {
|
||||
return 'Matched on the audio itself: these files hold the same audio data, byte for byte, once their tags are set aside.';
|
||||
}
|
||||
return `Matched by listening, not by size or length: ${matchPercent(g)}% of their audio fingerprints agree, so these files play the same music.`;
|
||||
}
|
||||
|
||||
// The same audio under different titles means at least one file's tags are
|
||||
// wrong, and the recording the other title names may not be in the library at
|
||||
// all. #3885: "WWW" was a second copy of the instrumental, and the vocal was
|
||||
// missing with nothing to say so. Exact tier only: an acoustic match spans
|
||||
// encodings, where a "Remastered" suffix is routine rather than a lie.
|
||||
// missing with nothing to say so. Savant's Mortals held one recording as 16
|
||||
// of its tracks, each cut to its own song's length (#6169). An acoustic match
|
||||
// spans encodings, where a "Remastered" suffix is routine rather than a lie,
|
||||
// so there it is raised only when the resolver found different songs on one
|
||||
// album (class mismatch).
|
||||
function differingTitles(g: AdminDuplicateGroup): string[] {
|
||||
if (g.tier !== 'exact') return [];
|
||||
if (g.tier !== 'exact' && g.class !== 'mismatch') return [];
|
||||
const byKey = new Map<string, string>();
|
||||
for (const m of g.members) {
|
||||
const title = m.title.trim().replace(/\s+/g, ' ');
|
||||
@@ -170,6 +188,21 @@
|
||||
function basename(path: string): string {
|
||||
return path.slice(path.lastIndexOf('/') + 1);
|
||||
}
|
||||
|
||||
// Hearing the copies settles what the fingerprints claim (#6168).
|
||||
function play(m: AdminDuplicateMember) {
|
||||
const track: TrackRef = {
|
||||
id: m.track_id,
|
||||
title: m.title,
|
||||
album_id: m.album_id,
|
||||
album_title: m.album_title,
|
||||
artist_id: m.artist_id,
|
||||
artist_name: m.artist_name,
|
||||
duration_sec: m.duration_sec,
|
||||
stream_url: m.stream_url
|
||||
};
|
||||
playQueue([track], 0);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{pageTitle('Collection · Duplicates')}</title></svelte:head>
|
||||
@@ -378,6 +411,10 @@
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<p class="border-b border-border px-4 py-3 text-xs text-text-secondary" data-testid="match-evidence">
|
||||
{evidence(group)}
|
||||
</p>
|
||||
|
||||
{#if titles.length > 0}
|
||||
<p
|
||||
class="flex items-start gap-2 border-b border-border bg-surface-hover px-4 py-3 text-xs text-text-secondary"
|
||||
@@ -385,9 +422,10 @@
|
||||
>
|
||||
<TriangleAlert size={14} class="mt-0.5 shrink-0 text-warning-fg" aria-hidden="true" />
|
||||
<span>
|
||||
The audio is identical, 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. Keep the copy whose title matches what you hear.
|
||||
{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.
|
||||
</span>
|
||||
</p>
|
||||
{/if}
|
||||
@@ -445,6 +483,15 @@
|
||||
{m.file_path}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => play(m)}
|
||||
class="mt-0.5 shrink-0 rounded-md p-1.5 text-text-secondary hover:bg-surface-hover hover:text-text-primary"
|
||||
aria-label="Play {m.title}"
|
||||
data-testid="member-play"
|
||||
>
|
||||
<Play size={16} aria-hidden="true" />
|
||||
</button>
|
||||
<div class="shrink-0 space-y-0.5 text-right text-xs text-text-muted">
|
||||
<div data-testid="member-file">{m.file_format.toUpperCase()} · {formatBytes(m.file_size)} · {formatDuration(m.duration_sec)}</div>
|
||||
<div data-testid="member-history">{historyLabel(m)}</div>
|
||||
|
||||
@@ -17,8 +17,10 @@ vi.mock('#lib/api/admin.js', () => ({
|
||||
removed_paths: ['/music/Moe Shop/WWW (2020)/www-02.mp3']
|
||||
})
|
||||
}));
|
||||
vi.mock('#lib/player/store.svelte.js', () => ({ playQueue: vi.fn() }));
|
||||
|
||||
import AdminDuplicatesPage from './+page.svelte';
|
||||
import { playQueue } from '#lib/player/store.svelte.js';
|
||||
import {
|
||||
createDuplicatesQuery,
|
||||
createResolvedDuplicatesQuery,
|
||||
@@ -45,6 +47,7 @@ function member(id: string, extra: Partial<AdminDuplicatesResponse['groups'][num
|
||||
return {
|
||||
track_id: id,
|
||||
title: 'WWW',
|
||||
artist_id: 'ar-1',
|
||||
artist_name: 'Moe Shop',
|
||||
album_id: 'al-1',
|
||||
album_title: 'WWW',
|
||||
@@ -57,6 +60,7 @@ function member(id: string, extra: Partial<AdminDuplicatesResponse['groups'][num
|
||||
play_count: 0,
|
||||
disc_number: null,
|
||||
track_number: null,
|
||||
stream_url: `/api/tracks/${id}/stream`,
|
||||
...extra
|
||||
};
|
||||
}
|
||||
@@ -164,7 +168,30 @@ describe('admin duplicates', () => {
|
||||
const r = response();
|
||||
r.groups[0] = { ...r.groups[0], tier: 'acoustic', worst_bit_error_rate: 0.04 };
|
||||
renderWith(r);
|
||||
expect(text(screen.getByTestId('duplicate-tier'))).toBe('Same recording · 96% match');
|
||||
expect(text(screen.getByTestId('duplicate-tier'))).toBe('Same music · 96% match');
|
||||
});
|
||||
|
||||
// The operator took the match for a guess from size and length (#6168).
|
||||
test('each group says what the match rests on', () => {
|
||||
const r = response();
|
||||
r.groups.push({ ...r.groups[0], id: 'g-2', tier: 'acoustic', worst_bit_error_rate: 0.04 });
|
||||
renderWith(r);
|
||||
const lines = screen.getAllByTestId('match-evidence').map(text);
|
||||
expect(lines[0]).toContain('the same audio data, byte for byte');
|
||||
expect(lines[1]).toBe(
|
||||
'Matched by listening, not by size or length: 96% of their audio fingerprints agree, so these files play the same music.'
|
||||
);
|
||||
});
|
||||
|
||||
test('each copy can be played', async () => {
|
||||
renderWith(response());
|
||||
const plays = screen.getAllByTestId('member-play');
|
||||
expect(plays).toHaveLength(2);
|
||||
await fireEvent.click(plays[1]);
|
||||
expect(playQueue).toHaveBeenLastCalledWith(
|
||||
[expect.objectContaining({ id: 'www-02', artist_id: 'ar-1', stream_url: '/api/tracks/www-02/stream' })],
|
||||
0
|
||||
);
|
||||
});
|
||||
|
||||
// #3885: identical audio titled "WWW" and "WWW (instrumental)". One file
|
||||
@@ -220,6 +247,17 @@ describe('admin duplicates', () => {
|
||||
renderWith(r);
|
||||
expect(screen.queryByTestId('title-mismatch')).toBeNull();
|
||||
});
|
||||
|
||||
// Unless the resolver found different songs on one album: Savant's Mortals
|
||||
// held one recording as 16 tracks, each cut to its own song's length (#6169).
|
||||
test('an acoustic group the resolver found to be different songs says so', () => {
|
||||
const r = www();
|
||||
r.groups[0] = { ...r.groups[0], tier: 'acoustic', worst_bit_error_rate: 0, class: 'mismatch' };
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
// Three states all show zero groups. Each must say which it is.
|
||||
|
||||
Reference in New Issue
Block a user