From e3eddab80f8ef2eed143ee9e15a359ab8e36498b Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Tue, 20 Oct 2026 10:28:29 -0400
Subject: [PATCH] feat: the duplicates review says what each match rests on and
plays every copy (#6168)
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
---
internal/api/admin_duplicates.go | 6 ++
internal/db/dbq/duplicates.sql.go | 3 +
internal/db/queries/duplicates.sql | 1 +
web/src/lib/api/types.ts | 3 +
.../routes/collection/duplicates/+page.svelte | 71 +++++++++++++++----
.../collection/duplicates/duplicates.test.ts | 40 ++++++++++-
6 files changed, 111 insertions(+), 13 deletions(-)
diff --git a/internal/api/admin_duplicates.go b/internal/api/admin_duplicates.go
index 755799c6..de8fe2e1 100644
--- a/internal/api/admin_duplicates.go
+++ b/internal/api/admin_duplicates.go
@@ -22,6 +22,7 @@ import (
type duplicateMemberView struct {
TrackID string `json:"track_id"`
Title string `json:"title"`
+ ArtistID string `json:"artist_id"`
ArtistName string `json:"artist_name"`
AlbumID string `json:"album_id"`
AlbumTitle string `json:"album_title"`
@@ -34,6 +35,9 @@ type duplicateMemberView struct {
PlayCount int64 `json:"play_count"`
DiscNumber *int32 `json:"disc_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
@@ -277,6 +281,7 @@ func foldDuplicateGroups(rows []dbq.ListPendingDuplicateGroupMembersRow) []dupli
groups[n].Members = append(groups[n].Members, duplicateMemberView{
TrackID: trackID,
Title: row.Title,
+ ArtistID: uuidToString(row.ArtistID),
ArtistName: row.ArtistName,
AlbumID: uuidToString(row.AlbumID),
AlbumTitle: row.AlbumTitle,
@@ -289,6 +294,7 @@ func foldDuplicateGroups(rows []dbq.ListPendingDuplicateGroupMembersRow) []dupli
PlayCount: row.PlayCount,
DiscNumber: row.DiscNumber,
TrackNumber: row.TrackNumber,
+ StreamURL: streamURL(row.TrackID),
})
candidates[n] = append(candidates[n], library.SurvivorCandidate{
TrackID: trackID, FileFormat: row.FileFormat, FileSize: row.FileSize, AddedAt: row.AddedAt.Time,
diff --git a/internal/db/dbq/duplicates.sql.go b/internal/db/dbq/duplicates.sql.go
index f6bf98d3..800a28f8 100644
--- a/internal/db/dbq/duplicates.sql.go
+++ b/internal/db/dbq/duplicates.sql.go
@@ -464,6 +464,7 @@ SELECT p.id AS group_id,
AND o.track_number = t.track_number)::boolean AS position_clash,
t.id AS track_id,
t.title,
+ artists.id AS artist_id,
artists.name AS artist_name,
albums.id AS album_id,
albums.title AS album_title,
@@ -501,6 +502,7 @@ type ListPendingDuplicateGroupMembersRow struct {
PositionClash bool
TrackID pgtype.UUID
Title string
+ ArtistID pgtype.UUID
ArtistName string
AlbumID pgtype.UUID
AlbumTitle string
@@ -540,6 +542,7 @@ func (q *Queries) ListPendingDuplicateGroupMembers(ctx context.Context, arg List
&i.PositionClash,
&i.TrackID,
&i.Title,
+ &i.ArtistID,
&i.ArtistName,
&i.AlbumID,
&i.AlbumTitle,
diff --git a/internal/db/queries/duplicates.sql b/internal/db/queries/duplicates.sql
index 9a5d17c0..113e354c 100644
--- a/internal/db/queries/duplicates.sql
+++ b/internal/db/queries/duplicates.sql
@@ -160,6 +160,7 @@ SELECT p.id AS group_id,
AND o.track_number = t.track_number)::boolean AS position_clash,
t.id AS track_id,
t.title,
+ artists.id AS artist_id,
artists.name AS artist_name,
albums.id AS album_id,
albums.title AS album_title,
diff --git a/web/src/lib/api/types.ts b/web/src/lib/api/types.ts
index b81363ab..1bd81638 100644
--- a/web/src/lib/api/types.ts
+++ b/web/src/lib/api/types.ts
@@ -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.
diff --git a/web/src/routes/collection/duplicates/+page.svelte b/web/src/routes/collection/duplicates/+page.svelte
index 6667dbf7..63ffd1c3 100644
--- a/web/src/routes/collection/duplicates/+page.svelte
+++ b/web/src/routes/collection/duplicates/+page.svelte
@@ -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();
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);
+ }
{pageTitle('Collection · Duplicates')}
@@ -378,6 +411,10 @@
{/if}
+
+ {evidence(group)}
+
+
{#if titles.length > 0}
- 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.
diff --git a/web/src/routes/collection/duplicates/duplicates.test.ts b/web/src/routes/collection/duplicates/duplicates.test.ts
index 9007accc..ac4a4bfb 100644
--- a/web/src/routes/collection/duplicates/duplicates.test.ts
+++ b/web/src/routes/collection/duplicates/duplicates.test.ts
@@ -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 {
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.