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
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:
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user