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 {
|
type duplicateMemberView struct {
|
||||||
TrackID string `json:"track_id"`
|
TrackID string `json:"track_id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
|
ArtistID string `json:"artist_id"`
|
||||||
ArtistName string `json:"artist_name"`
|
ArtistName string `json:"artist_name"`
|
||||||
AlbumID string `json:"album_id"`
|
AlbumID string `json:"album_id"`
|
||||||
AlbumTitle string `json:"album_title"`
|
AlbumTitle string `json:"album_title"`
|
||||||
@@ -34,6 +35,9 @@ type duplicateMemberView struct {
|
|||||||
PlayCount int64 `json:"play_count"`
|
PlayCount int64 `json:"play_count"`
|
||||||
DiscNumber *int32 `json:"disc_number"`
|
DiscNumber *int32 `json:"disc_number"`
|
||||||
TrackNumber *int32 `json:"track_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
|
// 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{
|
groups[n].Members = append(groups[n].Members, duplicateMemberView{
|
||||||
TrackID: trackID,
|
TrackID: trackID,
|
||||||
Title: row.Title,
|
Title: row.Title,
|
||||||
|
ArtistID: uuidToString(row.ArtistID),
|
||||||
ArtistName: row.ArtistName,
|
ArtistName: row.ArtistName,
|
||||||
AlbumID: uuidToString(row.AlbumID),
|
AlbumID: uuidToString(row.AlbumID),
|
||||||
AlbumTitle: row.AlbumTitle,
|
AlbumTitle: row.AlbumTitle,
|
||||||
@@ -289,6 +294,7 @@ func foldDuplicateGroups(rows []dbq.ListPendingDuplicateGroupMembersRow) []dupli
|
|||||||
PlayCount: row.PlayCount,
|
PlayCount: row.PlayCount,
|
||||||
DiscNumber: row.DiscNumber,
|
DiscNumber: row.DiscNumber,
|
||||||
TrackNumber: row.TrackNumber,
|
TrackNumber: row.TrackNumber,
|
||||||
|
StreamURL: streamURL(row.TrackID),
|
||||||
})
|
})
|
||||||
candidates[n] = append(candidates[n], library.SurvivorCandidate{
|
candidates[n] = append(candidates[n], library.SurvivorCandidate{
|
||||||
TrackID: trackID, FileFormat: row.FileFormat, FileSize: row.FileSize, AddedAt: row.AddedAt.Time,
|
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,
|
AND o.track_number = t.track_number)::boolean AS position_clash,
|
||||||
t.id AS track_id,
|
t.id AS track_id,
|
||||||
t.title,
|
t.title,
|
||||||
|
artists.id AS artist_id,
|
||||||
artists.name AS artist_name,
|
artists.name AS artist_name,
|
||||||
albums.id AS album_id,
|
albums.id AS album_id,
|
||||||
albums.title AS album_title,
|
albums.title AS album_title,
|
||||||
@@ -501,6 +502,7 @@ type ListPendingDuplicateGroupMembersRow struct {
|
|||||||
PositionClash bool
|
PositionClash bool
|
||||||
TrackID pgtype.UUID
|
TrackID pgtype.UUID
|
||||||
Title string
|
Title string
|
||||||
|
ArtistID pgtype.UUID
|
||||||
ArtistName string
|
ArtistName string
|
||||||
AlbumID pgtype.UUID
|
AlbumID pgtype.UUID
|
||||||
AlbumTitle string
|
AlbumTitle string
|
||||||
@@ -540,6 +542,7 @@ func (q *Queries) ListPendingDuplicateGroupMembers(ctx context.Context, arg List
|
|||||||
&i.PositionClash,
|
&i.PositionClash,
|
||||||
&i.TrackID,
|
&i.TrackID,
|
||||||
&i.Title,
|
&i.Title,
|
||||||
|
&i.ArtistID,
|
||||||
&i.ArtistName,
|
&i.ArtistName,
|
||||||
&i.AlbumID,
|
&i.AlbumID,
|
||||||
&i.AlbumTitle,
|
&i.AlbumTitle,
|
||||||
|
|||||||
@@ -160,6 +160,7 @@ SELECT p.id AS group_id,
|
|||||||
AND o.track_number = t.track_number)::boolean AS position_clash,
|
AND o.track_number = t.track_number)::boolean AS position_clash,
|
||||||
t.id AS track_id,
|
t.id AS track_id,
|
||||||
t.title,
|
t.title,
|
||||||
|
artists.id AS artist_id,
|
||||||
artists.name AS artist_name,
|
artists.name AS artist_name,
|
||||||
albums.id AS album_id,
|
albums.id AS album_id,
|
||||||
albums.title AS album_title,
|
albums.title AS album_title,
|
||||||
|
|||||||
@@ -433,6 +433,7 @@ export type AdminSuspectResponse = {
|
|||||||
export type AdminDuplicateMember = {
|
export type AdminDuplicateMember = {
|
||||||
track_id: string;
|
track_id: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
artist_id: string;
|
||||||
artist_name: string;
|
artist_name: string;
|
||||||
album_id: string;
|
album_id: string;
|
||||||
album_title: string;
|
album_title: string;
|
||||||
@@ -445,6 +446,8 @@ export type AdminDuplicateMember = {
|
|||||||
play_count: number;
|
play_count: number;
|
||||||
disc_number: number | null;
|
disc_number: number | null;
|
||||||
track_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.
|
// 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 { plural } from '#lib/utils/plural.js';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { pageTitle } from '#lib/branding.js';
|
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 {
|
import {
|
||||||
createDuplicatesQuery,
|
createDuplicatesQuery,
|
||||||
createResolvedDuplicatesQuery,
|
createResolvedDuplicatesQuery,
|
||||||
@@ -19,6 +20,7 @@
|
|||||||
AdminDuplicateGroup,
|
AdminDuplicateGroup,
|
||||||
AdminDuplicateMember,
|
AdminDuplicateMember,
|
||||||
DuplicatesView,
|
DuplicatesView,
|
||||||
|
TrackRef,
|
||||||
ResolvedDuplicateActivity
|
ResolvedDuplicateActivity
|
||||||
} from '#lib/api/types.js';
|
} from '#lib/api/types.js';
|
||||||
import ListContinuation from '#lib/components/ListContinuation.svelte';
|
import ListContinuation from '#lib/components/ListContinuation.svelte';
|
||||||
@@ -127,19 +129,35 @@
|
|||||||
// "Identical audio" and "same recording" are different claims, and an
|
// "Identical audio" and "same recording" are different claims, and an
|
||||||
// operator deciding whether to merge needs to know which one they are
|
// operator deciding whether to merge needs to know which one they are
|
||||||
// looking at before anything else.
|
// looking at before anything else.
|
||||||
function tierLabel(g: AdminDuplicateGroup): string {
|
function matchPercent(g: AdminDuplicateGroup): number {
|
||||||
if (g.tier === 'exact') return 'Identical audio';
|
return Math.round((1 - (g.worst_bit_error_rate ?? 0)) * 100);
|
||||||
const match = Math.round((1 - (g.worst_bit_error_rate ?? 0)) * 100);
|
|
||||||
return `Same recording · ${match}% match`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
// 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
|
// 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
|
// missing with nothing to say so. Savant's Mortals held one recording as 16
|
||||||
// encodings, where a "Remastered" suffix is routine rather than a lie.
|
// 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[] {
|
function differingTitles(g: AdminDuplicateGroup): string[] {
|
||||||
if (g.tier !== 'exact') return [];
|
if (g.tier !== 'exact' && g.class !== 'mismatch') return [];
|
||||||
const byKey = new Map<string, string>();
|
const byKey = new Map<string, string>();
|
||||||
for (const m of g.members) {
|
for (const m of g.members) {
|
||||||
const title = m.title.trim().replace(/\s+/g, ' ');
|
const title = m.title.trim().replace(/\s+/g, ' ');
|
||||||
@@ -170,6 +188,21 @@
|
|||||||
function basename(path: string): string {
|
function basename(path: string): string {
|
||||||
return path.slice(path.lastIndexOf('/') + 1);
|
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>
|
</script>
|
||||||
|
|
||||||
<svelte:head><title>{pageTitle('Collection · Duplicates')}</title></svelte:head>
|
<svelte:head><title>{pageTitle('Collection · Duplicates')}</title></svelte:head>
|
||||||
@@ -378,6 +411,10 @@
|
|||||||
</p>
|
</p>
|
||||||
{/if}
|
{/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}
|
{#if titles.length > 0}
|
||||||
<p
|
<p
|
||||||
class="flex items-start gap-2 border-b border-border bg-surface-hover px-4 py-3 text-xs text-text-secondary"
|
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" />
|
<TriangleAlert size={14} class="mt-0.5 shrink-0 text-warning-fg" aria-hidden="true" />
|
||||||
<span>
|
<span>
|
||||||
The audio is identical, but the copies are titled {quotedList(titles)}. If those
|
{group.tier === 'exact' ? 'The audio is identical' : 'The music is the same'}, but the
|
||||||
name different recordings, one file is mislabelled and the other recording isn't in
|
copies are titled {quotedList(titles)}. If those name different recordings, one file is
|
||||||
your library. Keep the copy whose title matches what you hear.
|
mislabelled and the other recording isn't in your library. Play each and keep the copy
|
||||||
|
whose title matches what you hear.
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -445,6 +483,15 @@
|
|||||||
{m.file_path}
|
{m.file_path}
|
||||||
</div>
|
</div>
|
||||||
</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 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-file">{m.file_format.toUpperCase()} · {formatBytes(m.file_size)} · {formatDuration(m.duration_sec)}</div>
|
||||||
<div data-testid="member-history">{historyLabel(m)}</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']
|
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 AdminDuplicatesPage from './+page.svelte';
|
||||||
|
import { playQueue } from '#lib/player/store.svelte.js';
|
||||||
import {
|
import {
|
||||||
createDuplicatesQuery,
|
createDuplicatesQuery,
|
||||||
createResolvedDuplicatesQuery,
|
createResolvedDuplicatesQuery,
|
||||||
@@ -45,6 +47,7 @@ function member(id: string, extra: Partial<AdminDuplicatesResponse['groups'][num
|
|||||||
return {
|
return {
|
||||||
track_id: id,
|
track_id: id,
|
||||||
title: 'WWW',
|
title: 'WWW',
|
||||||
|
artist_id: 'ar-1',
|
||||||
artist_name: 'Moe Shop',
|
artist_name: 'Moe Shop',
|
||||||
album_id: 'al-1',
|
album_id: 'al-1',
|
||||||
album_title: 'WWW',
|
album_title: 'WWW',
|
||||||
@@ -57,6 +60,7 @@ function member(id: string, extra: Partial<AdminDuplicatesResponse['groups'][num
|
|||||||
play_count: 0,
|
play_count: 0,
|
||||||
disc_number: null,
|
disc_number: null,
|
||||||
track_number: null,
|
track_number: null,
|
||||||
|
stream_url: `/api/tracks/${id}/stream`,
|
||||||
...extra
|
...extra
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -164,7 +168,30 @@ describe('admin duplicates', () => {
|
|||||||
const r = response();
|
const r = response();
|
||||||
r.groups[0] = { ...r.groups[0], tier: 'acoustic', worst_bit_error_rate: 0.04 };
|
r.groups[0] = { ...r.groups[0], tier: 'acoustic', worst_bit_error_rate: 0.04 };
|
||||||
renderWith(r);
|
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
|
// #3885: identical audio titled "WWW" and "WWW (instrumental)". One file
|
||||||
@@ -220,6 +247,17 @@ describe('admin duplicates', () => {
|
|||||||
renderWith(r);
|
renderWith(r);
|
||||||
expect(screen.queryByTestId('title-mismatch')).toBeNull();
|
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.
|
// Three states all show zero groups. Each must say which it is.
|
||||||
|
|||||||
Reference in New Issue
Block a user