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
@@ -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();
});
});