feat: admin Suspect sources report, for files named like video rips (#5410)
release / govulncheck (push) Successful in 37s
release / go (push) Failing after 1m10s
release / web (push) Failing after 1m24s
release / integration (push) Successful in 4m38s
release / android (push) Successful in 5m5s
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 / Build signed APK (releases and dev) (push) Canceled after 5m18s

Humanz turned out to be YouTube rips: "(Official Video)", "Visualizer", a
reaction video filed as a song, junk disc numbers (#5401). The library holds
about 200 more files named the same way. This report lists them, grouped by
folder like Missing files, with the markers each file name carries.

- GET /api/admin/library/suspect-sources: one marker list in Go builds both
  the Postgres ~* filter and each row's labels, so they cannot drift.
  Basename only; missing files are left out.
- Markers calibrated on the live library: "live in/at" dropped (real live
  albums), "reaction" narrowed (it caught "Chain Reaction").
- Admin tab "Suspect sources", read-only, loads as you scroll; a folder split
  across pages is joined back into one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 15:51:01 -04:00
co-authored by Claude Opus 5.5
parent 918db27fec
commit 3f0540cb7a
12 changed files with 775 additions and 2 deletions
+30 -1
View File
@@ -1,9 +1,10 @@
import { createQuery } from '@tanstack/svelte-query';
import { createInfiniteQuery, createQuery } from '@tanstack/svelte-query';
import { api } from './client';
import { qk } from './queries';
import type {
ActionResult,
AdminMissingResponse,
AdminSuspectResponse,
AdminDuplicatesResponse,
MergeDuplicateResult,
AdminPlaybackError,
@@ -896,6 +897,34 @@ export function createMissingFilesQuery(offset: number = 0, limit: number = 50)
});
}
// Suspect sources (#5410) --------------------------------------------------
export const SUSPECT_SOURCES_PAGE_SIZE = 50;
export async function listSuspectSources(offset: number = 0): Promise<AdminSuspectResponse> {
return api.get<AdminSuspectResponse>(
`/api/admin/library/suspect-sources?limit=${SUSPECT_SOURCES_PAGE_SIZE}&offset=${offset}`
);
}
// The next offset counts tracks, not groups: limit and total are in tracks.
export function suspectSourcesNextOffset(last: AdminSuspectResponse): number | undefined {
const loaded = last.offset + last.groups.reduce((n, g) => n + g.tracks.length, 0);
return loaded >= last.total ? undefined : loaded;
}
// Loads as the page scrolls (rule 172). Changes only when files are added or
// removed, so it is not re-fetched on every focus.
export function createSuspectSourcesQuery() {
return createInfiniteQuery({
queryKey: qk.adminSuspectSources(),
queryFn: ({ pageParam }) => listSuspectSources(pageParam as number),
initialPageParam: 0,
getNextPageParam: suspectSourcesNextOffset,
staleTime: 120_000
});
}
// Missing-file re-acquisition (#2527 / milestone #290) ----------------------
export type ReacquisitionSettings = {
+1
View File
@@ -58,6 +58,7 @@ export const qk = {
['adminMissingFiles', { offset: offset ?? 0 }] as const,
adminDuplicates: (offset?: number) =>
['adminDuplicates', { offset: offset ?? 0 }] as const,
adminSuspectSources: () => ['adminSuspectSources'] as const,
adminDiagnosticDevices: (userId?: string) =>
['adminDiagnosticDevices', { userId: userId ?? 'all' }] as const,
smtpConfig: () => ['smtpConfig'] as const,
+31
View File
@@ -419,6 +419,37 @@ export type AdminMissingResponse = {
groups: AdminMissingGroup[];
};
// Suspect sources (#5410) --------------------------------------------------
// A present track whose filename carries a video-rip marker. markers are the
// reasons, read from the file's basename: "music video", "[Audio]", "MV"...
export type AdminSuspectTrack = {
track_id: string;
title: string;
artist_id: string;
artist_name: string;
album_id: string;
album_title: string;
file_path: string;
duration_sec: number;
disc_number: number | null;
track_number: number | null;
markers: string[];
};
// A folder's worth of flagged tracks; the server orders and groups by folder.
export type AdminSuspectGroup = {
directory: string;
tracks: AdminSuspectTrack[];
};
export type AdminSuspectResponse = {
total: number;
limit: number;
offset: number;
groups: AdminSuspectGroup[];
};
// Duplicates report (#3912) -------------------------------------------------
// One copy in a proposed duplicate group. like_count and play_count cover every
+1
View File
@@ -10,6 +10,7 @@
{ href: '/admin/quarantine', label: 'Quarantine' },
{ href: '/admin/missing-files', label: 'Missing files' },
{ href: '/admin/duplicates', label: 'Duplicates' },
{ href: '/admin/suspect-sources', label: 'Suspect sources' },
{ href: '/admin/playback-errors', label: 'Playback errors' },
{ href: '/admin/diagnostics', label: 'Diagnostics' },
{ href: '/admin/tuning', label: 'Tuning' },
+4 -1
View File
@@ -52,7 +52,7 @@ describe('AdminTabs', () => {
);
});
test('renders all ten tabs in order', () => {
test('renders all eleven tabs in order', () => {
state.pageUrl = new URL('http://localhost/admin');
render(AdminTabs);
const links = screen.getAllByRole('link');
@@ -67,6 +67,9 @@ describe('AdminTabs', () => {
// Duplicates follows Missing files: both are library-health reports on
// what the library holds, rather than a queue of user reports.
'Duplicates',
// Suspect sources is the third library-health report: files whose
// names say they were ripped from a video.
'Suspect sources',
'Playback errors',
'Diagnostics',
'Tuning',
@@ -0,0 +1,163 @@
<script lang="ts">
import { pageTitle } from '#lib/branding.js';
import { FileCheck, Music2 } from 'lucide-svelte';
import { createSuspectSourcesQuery } from '#lib/api/admin.js';
import { coverUrl } from '#lib/media/covers.js';
import { formatDuration } from '#lib/media/duration.js';
import InfiniteScrollSentinel from '#lib/components/InfiniteScrollSentinel.svelte';
import type { AdminSuspectGroup, AdminSuspectTrack } 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.
const queryStore = createSuspectSourcesQuery();
const query = $derived($queryStore);
const total = $derived(query.data?.pages?.[0]?.total ?? 0);
// The server groups each page by folder, so a folder that straddles a page
// boundary arrives as two groups. Join them back into one.
const groups = $derived.by(() => {
const out: AdminSuspectGroup[] = [];
for (const page of query.data?.pages ?? []) {
for (const g of page.groups) {
const last = out.at(-1);
if (last && last.directory === g.directory) {
out[out.length - 1] = { ...last, tracks: [...last.tracks, ...g.tracks] };
} else {
out.push(g);
}
}
}
return out;
});
function trackCountLabel(n: number): string {
return n === 1 ? '1 track' : `${n} tracks`;
}
function basename(path: string): string {
return path.slice(path.lastIndexOf('/') + 1);
}
// Disc and track are shown because junk numbering is the other half of
// the symptom: Humanz's rips were spread over discs 1 to 15.
function position(t: AdminSuspectTrack): string {
const parts: string[] = [];
if (t.disc_number != null) parts.push(`disc ${t.disc_number}`);
if (t.track_number != null) parts.push(`track ${t.track_number}`);
return parts.join(', ');
}
</script>
<svelte:head><title>{pageTitle('Admin · Suspect sources')}</title></svelte:head>
<div class="space-y-6">
<header class="space-y-1">
<div class="flex items-center gap-2">
<h2 class="font-display text-2xl font-medium text-text-primary">Suspect sources</h2>
{#if total > 0}
<span
class="inline-flex items-center rounded-full bg-accent-tint px-2 py-0.5 text-xs text-accent-fg"
data-testid="suspect-count-pill"
>
{total}
</span>
{/if}
</div>
<p class="text-text-secondary">
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.
</p>
</header>
{#if query.isPending}
<p class="text-text-secondary">Reading file names…</p>
{:else if query.isError}
<p class="text-error-fg">Couldn't load the suspect-sources list.</p>
{:else if groups.length === 0}
<div class="rounded-lg border border-border bg-surface p-6 text-center">
<FileCheck size={28} strokeWidth={1} class="mx-auto text-text-muted" />
<p class="mt-3 text-text-primary">No file names look like video rips.</p>
<p class="mt-1 text-sm text-text-secondary">
A track lands here when its file name carries a video-title marker, such as
"Official Video", "Lyric Video", "[Audio]" or "MV".
</p>
</div>
{:else}
<ul class="space-y-4">
{#each groups as group (group.directory)}
<li class="overflow-hidden rounded-lg border border-border bg-surface">
<div class="flex items-baseline justify-between gap-4 border-b border-border px-4 py-3">
<h3 class="truncate font-mono text-sm text-text-primary" title={group.directory}>
{group.directory}
</h3>
<span class="shrink-0 text-xs text-text-secondary">
{trackCountLabel(group.tracks.length)}
</span>
</div>
<ul class="divide-y divide-border">
{#each group.tracks as t (t.track_id)}
<li class="flex items-center gap-3 px-4 py-2" data-testid="suspect-track-row">
<div
class="flex h-10 w-10 shrink-0 items-center justify-center rounded bg-surface-hover"
aria-hidden="true"
>
{#if t.album_id}
<img
src={coverUrl(t.album_id)}
alt=""
class="h-full w-full rounded object-cover"
loading="lazy"
/>
{:else}
<Music2 size={18} strokeWidth={1} class="text-text-muted" />
{/if}
</div>
<div class="min-w-0 flex-1 space-y-0.5">
<div class="truncate text-sm text-text-primary">{t.title}</div>
<div class="truncate text-xs text-text-secondary">
{t.artist_name} ·
<a href="/albums/{t.album_id}" class="hover:text-text-primary hover:underline"
>{t.album_title}</a
>{#if position(t)}<span class="text-text-muted"> · {position(t)}</span>{/if}
</div>
<!-- The file name is the evidence; the markers are read from it. -->
<div class="truncate font-mono text-xs text-text-muted" title={t.file_path}>
{basename(t.file_path)}
</div>
</div>
<div class="hidden shrink-0 flex-wrap justify-end gap-1 sm:flex">
{#each t.markers as m (m)}
<span
class="rounded-full border border-border px-2 py-0.5 text-xs text-text-secondary"
data-testid="suspect-marker">{m}</span
>
{/each}
</div>
<span class="shrink-0 text-xs text-text-muted">{formatDuration(t.duration_sec)}</span>
</li>
{/each}
</ul>
</li>
{/each}
</ul>
{#if query.hasNextPage}
<InfiniteScrollSentinel
enabled={!query.isFetchingNextPage}
onIntersect={() => query.fetchNextPage()}
/>
{#if query.isFetchingNextPage}
<p class="py-2 text-center text-sm text-text-secondary">Loading more…</p>
{/if}
{/if}
{/if}
</div>
@@ -0,0 +1,125 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { 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()
}));
import SuspectSourcesPage from './+page.svelte';
import { createSuspectSourcesQuery } from '#lib/api/admin.js';
import { suspectSourcesNextOffset } from '#lib/api/admin.js';
const mocked = createSuspectSourcesQuery as ReturnType<typeof vi.fn>;
const HUMANZ = '/music/Gorillaz/Humanz (2017)';
function track(title: string, file: string, markers: string[], disc: number | null = 15): AdminSuspectTrack {
return {
track_id: `t-${title}`,
title,
artist_id: 'ar-1',
artist_name: 'Gorillaz',
album_id: 'al-1',
album_title: 'Humanz',
file_path: `${HUMANZ}/${file}`,
duration_sec: 180,
disc_number: disc,
track_number: 4,
markers
};
}
function page(offset: number, total: number, groups: AdminSuspectResponse['groups']): AdminSuspectResponse {
return { total, limit: 50, offset, groups };
}
// The page reads only these fields of the infinite query.
function infinite(pages: AdminSuspectResponse[], opts: { hasNextPage?: boolean; isPending?: boolean } = {}) {
return readable({
data: { pages },
isPending: opts.isPending ?? false,
isError: false,
hasNextPage: opts.hasNextPage ?? false,
isFetchingNextPage: false,
fetchNextPage: () => {}
});
}
afterEach(() => vi.clearAllMocks());
describe('Suspect sources page', () => {
test('shows each flagged track with its file name and markers', () => {
mocked.mockReturnValue(
infinite([
page(0, 2, [
{
directory: HUMANZ,
tracks: [
track('Strobelite', 'Gorillaz - Strobelite (Official Video).mp3', ['music video']),
track('Andromeda', 'Gorillaz - Andromeda [Audio] [HQ].mp3', ['[Audio]', '[HD]'])
]
}
])
])
);
render(SuspectSourcesPage);
expect(screen.getByTestId('suspect-count-pill')).toHaveTextContent('2');
expect(screen.getByText(HUMANZ)).toBeInTheDocument();
expect(screen.getByText('Gorillaz - Strobelite (Official Video).mp3')).toBeInTheDocument();
expect(screen.getAllByTestId('suspect-marker').map((m) => m.textContent)).toEqual([
'music video',
'[Audio]',
'[HD]'
]);
expect(screen.getAllByText(/disc 15, track 4/)).toHaveLength(2);
});
// A folder split across two pages is still one folder on screen.
test('joins a folder that straddles a page boundary', () => {
mocked.mockReturnValue(
infinite([
page(0, 3, [{ directory: HUMANZ, tracks: [track('Strobelite', 'a (Official Video).mp3', ['music video'])] }]),
page(1, 3, [
{ directory: HUMANZ, tracks: [track('Momentz', 'b (Visualizer).mp3', ['visualiser'])] },
{ directory: '/music/Daft Punk/RAM', tracks: [track('Doin It Right', 'c (Music Video).mp3', ['music video'])] }
])
])
);
render(SuspectSourcesPage);
expect(screen.getAllByText(HUMANZ)).toHaveLength(1);
expect(screen.getAllByText('2 tracks')).toHaveLength(1);
expect(screen.getAllByTestId('suspect-track-row')).toHaveLength(3);
});
test('empty library explains what would land here', () => {
mocked.mockReturnValue(infinite([page(0, 0, [])]));
render(SuspectSourcesPage);
expect(screen.getByText(/no file names look like video rips/i)).toBeInTheDocument();
});
test('loads more as you scroll, with no button', () => {
mocked.mockReturnValue(
infinite([page(0, 80, [{ directory: HUMANZ, tracks: [track('Strobelite', 'a (Official Video).mp3', ['music video'])] }])], {
hasNextPage: true
})
);
const { container } = render(SuspectSourcesPage);
expect(container.querySelector('div[aria-hidden="true"].h-px')).not.toBeNull();
expect(screen.queryByRole('button', { name: /more/i })).not.toBeInTheDocument();
});
});
describe('suspectSourcesNextOffset', () => {
test('counts tracks, not groups, and stops at the total', () => {
const groups = [
{ directory: 'a', tracks: [track('1', '1.mp3', []), track('2', '2.mp3', [])] },
{ directory: 'b', tracks: [track('3', '3.mp3', [])] }
];
expect(suspectSourcesNextOffset(page(0, 10, groups))).toBe(3);
expect(suspectSourcesNextOffset(page(7, 10, groups))).toBeUndefined();
});
});