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

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:
2026-10-20 10:28:29 -04:00
co-authored by Claude Opus 5.5
parent 460e78c955
commit e3eddab80f
6 changed files with 111 additions and 13 deletions
+6
View File
@@ -22,6 +22,7 @@ import (
type duplicateMemberView struct { type duplicateMemberView struct {
TrackID string `json:"track_id"` TrackID string `json:"track_id"`
Title string `json:"title"` Title string `json:"title"`
ArtistID string `json:"artist_id"`
ArtistName string `json:"artist_name"` ArtistName string `json:"artist_name"`
AlbumID string `json:"album_id"` AlbumID string `json:"album_id"`
AlbumTitle string `json:"album_title"` AlbumTitle string `json:"album_title"`
@@ -34,6 +35,9 @@ type duplicateMemberView struct {
PlayCount int64 `json:"play_count"` PlayCount int64 `json:"play_count"`
DiscNumber *int32 `json:"disc_number"` DiscNumber *int32 `json:"disc_number"`
TrackNumber *int32 `json:"track_number"` TrackNumber *int32 `json:"track_number"`
// StreamURL lets the review play each copy, to hear whether they are the
// same song (#6168).
StreamURL string `json:"stream_url"`
} }
// duplicateGroupView is one proposal. SurvivorTrackID and SurvivorReason are // duplicateGroupView is one proposal. SurvivorTrackID and SurvivorReason are
@@ -277,6 +281,7 @@ func foldDuplicateGroups(rows []dbq.ListPendingDuplicateGroupMembersRow) []dupli
groups[n].Members = append(groups[n].Members, duplicateMemberView{ groups[n].Members = append(groups[n].Members, duplicateMemberView{
TrackID: trackID, TrackID: trackID,
Title: row.Title, Title: row.Title,
ArtistID: uuidToString(row.ArtistID),
ArtistName: row.ArtistName, ArtistName: row.ArtistName,
AlbumID: uuidToString(row.AlbumID), AlbumID: uuidToString(row.AlbumID),
AlbumTitle: row.AlbumTitle, AlbumTitle: row.AlbumTitle,
@@ -289,6 +294,7 @@ func foldDuplicateGroups(rows []dbq.ListPendingDuplicateGroupMembersRow) []dupli
PlayCount: row.PlayCount, PlayCount: row.PlayCount,
DiscNumber: row.DiscNumber, DiscNumber: row.DiscNumber,
TrackNumber: row.TrackNumber, TrackNumber: row.TrackNumber,
StreamURL: streamURL(row.TrackID),
}) })
candidates[n] = append(candidates[n], library.SurvivorCandidate{ candidates[n] = append(candidates[n], library.SurvivorCandidate{
TrackID: trackID, FileFormat: row.FileFormat, FileSize: row.FileSize, AddedAt: row.AddedAt.Time, TrackID: trackID, FileFormat: row.FileFormat, FileSize: row.FileSize, AddedAt: row.AddedAt.Time,
+3
View File
@@ -464,6 +464,7 @@ SELECT p.id AS group_id,
AND o.track_number = t.track_number)::boolean AS position_clash, AND o.track_number = t.track_number)::boolean AS position_clash,
t.id AS track_id, t.id AS track_id,
t.title, t.title,
artists.id AS artist_id,
artists.name AS artist_name, artists.name AS artist_name,
albums.id AS album_id, albums.id AS album_id,
albums.title AS album_title, albums.title AS album_title,
@@ -501,6 +502,7 @@ type ListPendingDuplicateGroupMembersRow struct {
PositionClash bool PositionClash bool
TrackID pgtype.UUID TrackID pgtype.UUID
Title string Title string
ArtistID pgtype.UUID
ArtistName string ArtistName string
AlbumID pgtype.UUID AlbumID pgtype.UUID
AlbumTitle string AlbumTitle string
@@ -540,6 +542,7 @@ func (q *Queries) ListPendingDuplicateGroupMembers(ctx context.Context, arg List
&i.PositionClash, &i.PositionClash,
&i.TrackID, &i.TrackID,
&i.Title, &i.Title,
&i.ArtistID,
&i.ArtistName, &i.ArtistName,
&i.AlbumID, &i.AlbumID,
&i.AlbumTitle, &i.AlbumTitle,
+1
View File
@@ -160,6 +160,7 @@ SELECT p.id AS group_id,
AND o.track_number = t.track_number)::boolean AS position_clash, AND o.track_number = t.track_number)::boolean AS position_clash,
t.id AS track_id, t.id AS track_id,
t.title, t.title,
artists.id AS artist_id,
artists.name AS artist_name, artists.name AS artist_name,
albums.id AS album_id, albums.id AS album_id,
albums.title AS album_title, albums.title AS album_title,
+3
View File
@@ -433,6 +433,7 @@ export type AdminSuspectResponse = {
export type AdminDuplicateMember = { export type AdminDuplicateMember = {
track_id: string; track_id: string;
title: string; title: string;
artist_id: string;
artist_name: string; artist_name: string;
album_id: string; album_id: string;
album_title: string; album_title: string;
@@ -445,6 +446,8 @@ export type AdminDuplicateMember = {
play_count: number; play_count: number;
disc_number: number | null; disc_number: number | null;
track_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. // 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 { plural } from '#lib/utils/plural.js';
import { page } from '$app/state'; import { page } from '$app/state';
import { pageTitle } from '#lib/branding.js'; 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 { import {
createDuplicatesQuery, createDuplicatesQuery,
createResolvedDuplicatesQuery, createResolvedDuplicatesQuery,
@@ -19,6 +20,7 @@
AdminDuplicateGroup, AdminDuplicateGroup,
AdminDuplicateMember, AdminDuplicateMember,
DuplicatesView, DuplicatesView,
TrackRef,
ResolvedDuplicateActivity ResolvedDuplicateActivity
} from '#lib/api/types.js'; } from '#lib/api/types.js';
import ListContinuation from '#lib/components/ListContinuation.svelte'; import ListContinuation from '#lib/components/ListContinuation.svelte';
@@ -127,19 +129,35 @@
// "Identical audio" and "same recording" are different claims, and an // "Identical audio" and "same recording" are different claims, and an
// operator deciding whether to merge needs to know which one they are // operator deciding whether to merge needs to know which one they are
// looking at before anything else. // looking at before anything else.
function tierLabel(g: AdminDuplicateGroup): string { function matchPercent(g: AdminDuplicateGroup): number {
if (g.tier === 'exact') return 'Identical audio'; return Math.round((1 - (g.worst_bit_error_rate ?? 0)) * 100);
const match = Math.round((1 - (g.worst_bit_error_rate ?? 0)) * 100);
return `Same recording · ${match}% match`;
} }
// 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 // 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 // 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 // missing with nothing to say so. Savant's Mortals held one recording as 16
// encodings, where a "Remastered" suffix is routine rather than a lie. // 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[] { function differingTitles(g: AdminDuplicateGroup): string[] {
if (g.tier !== 'exact') return []; if (g.tier !== 'exact' && g.class !== 'mismatch') return [];
const byKey = new Map<string, string>(); const byKey = new Map<string, string>();
for (const m of g.members) { for (const m of g.members) {
const title = m.title.trim().replace(/\s+/g, ' '); const title = m.title.trim().replace(/\s+/g, ' ');
@@ -170,6 +188,21 @@
function basename(path: string): string { function basename(path: string): string {
return path.slice(path.lastIndexOf('/') + 1); 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> </script>
<svelte:head><title>{pageTitle('Collection · Duplicates')}</title></svelte:head> <svelte:head><title>{pageTitle('Collection · Duplicates')}</title></svelte:head>
@@ -378,6 +411,10 @@
</p> </p>
{/if} {/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} {#if titles.length > 0}
<p <p
class="flex items-start gap-2 border-b border-border bg-surface-hover px-4 py-3 text-xs text-text-secondary" 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" /> <TriangleAlert size={14} class="mt-0.5 shrink-0 text-warning-fg" aria-hidden="true" />
<span> <span>
The audio is identical, but the copies are titled {quotedList(titles)}. If those {group.tier === 'exact' ? 'The audio is identical' : 'The music is the same'}, but the
name different recordings, one file is mislabelled and the other recording isn't in copies are titled {quotedList(titles)}. If those name different recordings, one file is
your library. Keep the copy whose title matches what you hear. mislabelled and the other recording isn't in your library. Play each and keep the copy
whose title matches what you hear.
</span> </span>
</p> </p>
{/if} {/if}
@@ -445,6 +483,15 @@
{m.file_path} {m.file_path}
</div> </div>
</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 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-file">{m.file_format.toUpperCase()} · {formatBytes(m.file_size)} · {formatDuration(m.duration_sec)}</div>
<div data-testid="member-history">{historyLabel(m)}</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'] 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 AdminDuplicatesPage from './+page.svelte';
import { playQueue } from '#lib/player/store.svelte.js';
import { import {
createDuplicatesQuery, createDuplicatesQuery,
createResolvedDuplicatesQuery, createResolvedDuplicatesQuery,
@@ -45,6 +47,7 @@ function member(id: string, extra: Partial<AdminDuplicatesResponse['groups'][num
return { return {
track_id: id, track_id: id,
title: 'WWW', title: 'WWW',
artist_id: 'ar-1',
artist_name: 'Moe Shop', artist_name: 'Moe Shop',
album_id: 'al-1', album_id: 'al-1',
album_title: 'WWW', album_title: 'WWW',
@@ -57,6 +60,7 @@ function member(id: string, extra: Partial<AdminDuplicatesResponse['groups'][num
play_count: 0, play_count: 0,
disc_number: null, disc_number: null,
track_number: null, track_number: null,
stream_url: `/api/tracks/${id}/stream`,
...extra ...extra
}; };
} }
@@ -164,7 +168,30 @@ describe('admin duplicates', () => {
const r = response(); const r = response();
r.groups[0] = { ...r.groups[0], tier: 'acoustic', worst_bit_error_rate: 0.04 }; r.groups[0] = { ...r.groups[0], tier: 'acoustic', worst_bit_error_rate: 0.04 };
renderWith(r); 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 // #3885: identical audio titled "WWW" and "WWW (instrumental)". One file
@@ -220,6 +247,17 @@ describe('admin duplicates', () => {
renderWith(r); renderWith(r);
expect(screen.queryByTestId('title-mismatch')).toBeNull(); 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. // Three states all show zero groups. Each must say which it is.