DLNA renderers, app updates told like Librarian, acquisition without Lidarr, Collection and admin rework (M502–M505, M542) #156

Merged
bvandeusen merged 117 commits from dev into main 2026-10-26 12:32:33 -04:00
6 changed files with 111 additions and 13 deletions
Showing only changes of commit e3eddab80f - Show all commits
+6
View File
@@ -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,
+3
View File
@@ -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,
+1
View File
@@ -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,
+3
View File
@@ -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.