feat: video rips and stray copies handle themselves (M498 #5439)
release / govulncheck (push) Successful in 25s
release / web (push) Failing after 26s
release / go (push) Failing after 58s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / integration (push) Canceled after 3m25s
release / Build signed APK (releases and dev) (push) Canceled after 3m26s
release / android (push) Canceled after 3m28s

- The resolver also merges cross-release and mismatch groups where Lidarr
  maps exactly one copy: the others fulfil nothing, so removing them opens no
  hole (D-a rule 1). That covers a rip beside the clean copy on another
  release and a wrong-file import Lidarr holds unmapped. With two or more
  mapped copies each fulfils its own release and nothing is removed.
- A track whose file name carries a video-rip marker is held back from radio
  and the system mixes (tracks.source_verdict, migration 0077). It still plays
  when chosen. A renamed file is released; the operator's "fine" sticks.
- Suspect sources shows what was done to each track, with "This one is fine"
  and "Hold back again" (PUT /api/admin/library/suspect-sources/{id}).
- The Liked list prefers a copy that is not held back.

Replacing a rip that has no clean copy is left for the operator to decide.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 22:13:37 -04:00
co-authored by Claude Opus 5.5
parent 5b372f61d7
commit 53eb954c86
26 changed files with 638 additions and 63 deletions
+7
View File
@@ -5,6 +5,7 @@ import type {
ActionResult,
AdminMissingResponse,
AdminSuspectResponse,
SuspectVerdict,
AdminDuplicatesResponse,
DuplicatesView,
MergeDuplicateResult,
@@ -961,6 +962,12 @@ export function createSuspectSourcesQuery() {
});
}
// 'fine' lets a flagged track back into radio and the mixes; 'suspect' holds
// it back again (M498 #5439).
export async function setSuspectVerdict(trackId: string, verdict: SuspectVerdict): Promise<void> {
await api.put(`/api/admin/library/suspect-sources/${encodeURIComponent(trackId)}`, { verdict });
}
// Missing-file re-acquisition (#2527 / milestone #290) ----------------------
export type ReacquisitionSettings = {
+6
View File
@@ -439,8 +439,14 @@ export type AdminSuspectTrack = {
disc_number: number | null;
track_number: number | null;
markers: string[];
// 'suspect': held back from radio and the mixes. 'fine': the operator let it
// back. null: the resolver hasn't looked yet (M498 #5439).
verdict: SuspectVerdict | null;
verdict_at: string | null;
};
export type SuspectVerdict = 'suspect' | 'fine';
// A folder's worth of flagged tracks; the server orders and groups by folder.
export type AdminSuspectGroup = {
directory: string;
@@ -121,8 +121,9 @@
<span class="text-sm text-text-primary">Resolve duplicates automatically</span>
<span class="block text-xs text-text-secondary">
Merges copies Lidarr doesn't use into the one it does, and moves a Lidarr album off a
release that lists songs twice. A copy Lidarr uses is never removed. Off, every group
waits here for you.
release that lists songs twice. A song kept on several releases counts as one song, and
video rips are kept out of radio and the mixes. A copy Lidarr uses is never removed.
Off, every group waits here for you.
</span>
</span>
</label>
+2
View File
@@ -48,6 +48,8 @@
"survivor_not_in_group": "That copy isn't part of this group any more.",
"copy_tracked_by_lidarr": "Lidarr uses a copy this would remove, and would download it again. Keep that copy instead.",
"lidarr_unavailable": "Lidarr didn't answer, so it isn't safe to remove a copy yet. Try again shortly.",
"suspect_track_not_found": "That track isn't flagged any more. Refresh the list.",
"invalid_verdict": "That isn't a choice for a flagged track.",
"invalid_setting": "That setting is out of range.",
"invalid_public_url": "That address isn't valid.",
"album_not_found": "That album no longer exists.",
@@ -1,17 +1,23 @@
<script lang="ts">
import { pageTitle } from '#lib/branding.js';
import { FileCheck, Music2 } from 'lucide-svelte';
import { createSuspectSourcesQuery } from '#lib/api/admin.js';
import { createSuspectSourcesQuery, setSuspectVerdict } from '#lib/api/admin.js';
import { errMessage } from '#lib/api/errors.js';
import { pushToast } from '#lib/stores/toast.svelte.js';
import { relativeTime } from '#lib/utils/relativeTime.js';
import { coverUrl } from '#lib/media/covers.js';
import { formatDuration } from '#lib/media/duration.js';
import ListContinuation from '#lib/components/ListContinuation.svelte';
import type { AdminSuspectGroup, AdminSuspectTrack } from '#lib/api/types.js';
import type { AdminSuspectGroup, AdminSuspectTrack, SuspectVerdict } from '#lib/api/types.js';
// Files whose names say they were ripped from a video (#5410): "(Official
// Video)", "[Audio]", "Visualizer", a reaction. Humanz was 62 of them in one
// folder, with junk disc numbers and a reaction video filed as a song.
// Read-only: a marker is a reason to look, not proof, and the fix lives
// elsewhere — Duplicates, Quarantine, or a better release in Lidarr.
//
// The duplicate resolver holds each one back from radio and the mixes, and
// merges it away where Lidarr keeps a clean copy (M498 #5439). A marker is a
// reason to look, not proof, so each row says what was done and offers the
// undo: "This one is fine" lets the track back for good.
const queryStore = createSuspectSourcesQuery();
const query = $derived($queryStore);
@@ -34,6 +40,28 @@
return out;
});
// The verdict the operator just set, shown at once while the list refetches.
let changed = $state<Record<string, SuspectVerdict>>({});
let saving = $state<string | null>(null);
function verdictOf(t: AdminSuspectTrack): SuspectVerdict | null {
return changed[t.track_id] ?? t.verdict;
}
async function setVerdict(t: AdminSuspectTrack, verdict: SuspectVerdict) {
saving = t.track_id;
try {
await setSuspectVerdict(t.track_id, verdict);
changed[t.track_id] = verdict;
pushToast(verdict === 'fine' ? `${t.title} is back in radio and mixes.` : `${t.title} is held back again.`);
query.refetch();
} catch (e: unknown) {
pushToast(errMessage(e), 'error');
} finally {
saving = null;
}
}
function trackCountLabel(n: number): string {
return n === 1 ? '1 track' : `${n} tracks`;
}
@@ -71,7 +99,9 @@
Tracks whose file name reads like a video title rather than an album track,
such as "(Official Video)", "[Audio]" or "Visualizer". These usually came from
a video site, and can be the wrong audio: a live take, a music-video edit, or
something else entirely. Nothing here is changed automatically.
something else entirely. Each one is kept out of radio and the mixes, though it still
plays when you choose it, and it is removed where Lidarr keeps a clean copy of the same
song. If one is fine, say so and it goes back in.
</p>
</header>
@@ -143,6 +173,33 @@
{/each}
</div>
<span class="shrink-0 text-xs text-text-muted">{formatDuration(t.duration_sec)}</span>
{#if verdictOf(t) === 'suspect'}
<div class="flex shrink-0 flex-col items-end gap-1">
<span class="text-xs text-text-secondary" data-testid="suspect-verdict">
Held back{#if t.verdict_at && !changed[t.track_id]}&nbsp;{relativeTime(t.verdict_at)}{/if}
</span>
<button
type="button"
onclick={() => setVerdict(t, 'fine')}
disabled={saving === t.track_id}
class="rounded-md border border-border px-2.5 py-1 text-xs text-text-secondary hover:bg-surface-hover hover:text-text-primary disabled:opacity-50"
>
This one is fine
</button>
</div>
{:else if verdictOf(t) === 'fine'}
<div class="flex shrink-0 flex-col items-end gap-1">
<span class="text-xs text-text-secondary" data-testid="suspect-verdict">Marked fine</span>
<button
type="button"
onclick={() => setVerdict(t, 'suspect')}
disabled={saving === t.track_id}
class="rounded-md px-2.5 py-1 text-xs text-text-muted hover:bg-surface-hover hover:text-text-primary disabled:opacity-50"
>
Hold back again
</button>
</div>
{/if}
</li>
{/each}
</ul>
@@ -1,14 +1,15 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import { fireEvent, render, screen } from '@testing-library/svelte';
import { readable } from 'svelte/store';
import type { AdminSuspectResponse, AdminSuspectTrack } from '#lib/api/types.js';
vi.mock('#lib/api/admin.js', () => ({
createSuspectSourcesQuery: vi.fn()
createSuspectSourcesQuery: vi.fn(),
setSuspectVerdict: vi.fn(async () => {})
}));
import SuspectSourcesPage from './+page.svelte';
import { createSuspectSourcesQuery } from '#lib/api/admin.js';
import { createSuspectSourcesQuery, setSuspectVerdict } from '#lib/api/admin.js';
const mocked = createSuspectSourcesQuery as ReturnType<typeof vi.fn>;
@@ -26,7 +27,9 @@ function track(title: string, file: string, markers: string[], disc: number | nu
duration_sec: 180,
disc_number: disc,
track_number: 4,
markers
markers,
verdict: 'suspect',
verdict_at: new Date(Date.now() - 3_600_000).toISOString()
};
}
@@ -42,7 +45,8 @@ function infinite(pages: AdminSuspectResponse[], opts: { hasNextPage?: boolean;
isError: false,
hasNextPage: opts.hasNextPage ?? false,
isFetchingNextPage: false,
fetchNextPage: () => {}
fetchNextPage: () => {},
refetch: () => {}
});
}
@@ -110,4 +114,26 @@ describe('Suspect sources page', () => {
expect(container.querySelector('div[aria-hidden="true"].h-px')).not.toBeNull();
expect(screen.queryByRole('button', { name: /more/i })).not.toBeInTheDocument();
});
// M498 #5439: a flagged track is held back from radio and the mixes, and the
// operator can let it back.
test('This one is fine lets a held-back track back in', async () => {
mocked.mockReturnValue(
infinite([
page(0, 1, [
{
directory: HUMANZ,
tracks: [track('Strobelite', 'Gorillaz - Strobelite (Official Video).mp3', ['music video'])]
}
])
])
);
render(SuspectSourcesPage);
expect(screen.getByTestId('suspect-verdict')).toHaveTextContent(/Held back/);
await fireEvent.click(screen.getByRole('button', { name: 'This one is fine' }));
expect(setSuspectVerdict).toHaveBeenCalledWith('t-Strobelite', 'fine');
expect(await screen.findByText('Marked fine')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Hold back again' })).toBeInTheDocument();
});
});