DLNA renderers, app updates told like Librarian, acquisition without Lidarr, Collection and admin rework (M502–M505, M542) #156
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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