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}

{/if} @@ -445,6 +483,15 @@ {m.file_path} +
{m.file_format.toUpperCase()} · {formatBytes(m.file_size)} · {formatDuration(m.duration_sec)}
{historyLabel(m)}
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.