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
+ {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.
{/if} @@ -445,6 +483,15 @@ {m.file_path} +