DLNA renderers, app updates told like Librarian, acquisition without Lidarr, Collection and admin rework (M502–M505, M542) #156
@@ -19,12 +19,11 @@ type adminQueueView struct {
|
||||
// (#5605): every queue an admin acts on, in one response, so each admin page
|
||||
// pays one cheap read for its rail.
|
||||
type adminSummaryView struct {
|
||||
Requests adminQueueView `json:"requests"`
|
||||
Quarantined adminQueueView `json:"quarantined"`
|
||||
PlaybackErrors adminQueueView `json:"playback_errors"`
|
||||
Duplicates adminQueueView `json:"duplicates"`
|
||||
Suspect adminQueueView `json:"suspect"`
|
||||
Missing adminQueueView `json:"missing"`
|
||||
Requests adminQueueView `json:"requests"`
|
||||
Reports adminQueueView `json:"reports"`
|
||||
Duplicates adminQueueView `json:"duplicates"`
|
||||
Suspect adminQueueView `json:"suspect"`
|
||||
Missing adminQueueView `json:"missing"`
|
||||
}
|
||||
|
||||
func queueView(count int64, since pgtype.Timestamptz) adminQueueView {
|
||||
@@ -38,12 +37,11 @@ func queueView(count int64, since pgtype.Timestamptz) adminQueueView {
|
||||
|
||||
func adminSummaryFrom(row dbq.AdminSummaryRow) adminSummaryView {
|
||||
return adminSummaryView{
|
||||
Requests: queueView(row.Requests, row.RequestsSince),
|
||||
Quarantined: queueView(row.Quarantined, row.QuarantinedSince),
|
||||
PlaybackErrors: queueView(row.PlaybackErrors, row.PlaybackErrorsSince),
|
||||
Duplicates: queueView(row.Duplicates, row.DuplicatesSince),
|
||||
Suspect: queueView(row.Suspect, row.SuspectSince),
|
||||
Missing: queueView(row.Missing, row.MissingSince),
|
||||
Requests: queueView(row.Requests, row.RequestsSince),
|
||||
Reports: queueView(row.Reports, row.ReportsSince),
|
||||
Duplicates: queueView(row.Duplicates, row.DuplicatesSince),
|
||||
Suspect: queueView(row.Suspect, row.SuspectSince),
|
||||
Missing: queueView(row.Missing, row.MissingSince),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,10 +15,17 @@ const adminSummary = `-- name: AdminSummary :one
|
||||
SELECT
|
||||
(SELECT count(*) FROM requests WHERE status = 'pending')::bigint AS requests,
|
||||
(SELECT min(requested_at) FROM requests WHERE status = 'pending')::timestamptz AS requests_since,
|
||||
(SELECT count(DISTINCT track_id) FROM lidarr_quarantine)::bigint AS quarantined,
|
||||
(SELECT min(created_at) FROM lidarr_quarantine)::timestamptz AS quarantined_since,
|
||||
(SELECT count(*) FROM playback_errors WHERE resolved_at IS NULL)::bigint AS playback_errors,
|
||||
(SELECT min(occurred_at) FROM playback_errors WHERE resolved_at IS NULL)::timestamptz AS playback_errors_since,
|
||||
-- A broken track is one report however it was found: a listener flagged
|
||||
-- it, or it failed to play (#5606). Counted by track, as the Reports
|
||||
-- page lists them.
|
||||
(SELECT count(*) FROM (
|
||||
SELECT track_id FROM lidarr_quarantine
|
||||
UNION
|
||||
SELECT track_id FROM playback_errors WHERE resolved_at IS NULL) r)::bigint AS reports,
|
||||
(SELECT min(at) FROM (
|
||||
SELECT created_at AS at FROM lidarr_quarantine
|
||||
UNION ALL
|
||||
SELECT occurred_at FROM playback_errors WHERE resolved_at IS NULL) r)::timestamptz AS reports_since,
|
||||
(SELECT count(*) FROM duplicate_groups g
|
||||
WHERE g.status = 'pending'
|
||||
AND (SELECT count(*) FROM duplicate_group_members m WHERE m.group_id = g.id) >= 2)::bigint AS duplicates,
|
||||
@@ -32,18 +39,16 @@ SELECT
|
||||
`
|
||||
|
||||
type AdminSummaryRow struct {
|
||||
Requests int64
|
||||
RequestsSince pgtype.Timestamptz
|
||||
Quarantined int64
|
||||
QuarantinedSince pgtype.Timestamptz
|
||||
PlaybackErrors int64
|
||||
PlaybackErrorsSince pgtype.Timestamptz
|
||||
Duplicates int64
|
||||
DuplicatesSince pgtype.Timestamptz
|
||||
Suspect int64
|
||||
SuspectSince pgtype.Timestamptz
|
||||
Missing int64
|
||||
MissingSince pgtype.Timestamptz
|
||||
Requests int64
|
||||
RequestsSince pgtype.Timestamptz
|
||||
Reports int64
|
||||
ReportsSince pgtype.Timestamptz
|
||||
Duplicates int64
|
||||
DuplicatesSince pgtype.Timestamptz
|
||||
Suspect int64
|
||||
SuspectSince pgtype.Timestamptz
|
||||
Missing int64
|
||||
MissingSince pgtype.Timestamptz
|
||||
}
|
||||
|
||||
// What the admin rail and the Overview's verdict read (#5605): each queue
|
||||
@@ -57,10 +62,8 @@ func (q *Queries) AdminSummary(ctx context.Context) (AdminSummaryRow, error) {
|
||||
err := row.Scan(
|
||||
&i.Requests,
|
||||
&i.RequestsSince,
|
||||
&i.Quarantined,
|
||||
&i.QuarantinedSince,
|
||||
&i.PlaybackErrors,
|
||||
&i.PlaybackErrorsSince,
|
||||
&i.Reports,
|
||||
&i.ReportsSince,
|
||||
&i.Duplicates,
|
||||
&i.DuplicatesSince,
|
||||
&i.Suspect,
|
||||
|
||||
@@ -7,10 +7,17 @@
|
||||
SELECT
|
||||
(SELECT count(*) FROM requests WHERE status = 'pending')::bigint AS requests,
|
||||
(SELECT min(requested_at) FROM requests WHERE status = 'pending')::timestamptz AS requests_since,
|
||||
(SELECT count(DISTINCT track_id) FROM lidarr_quarantine)::bigint AS quarantined,
|
||||
(SELECT min(created_at) FROM lidarr_quarantine)::timestamptz AS quarantined_since,
|
||||
(SELECT count(*) FROM playback_errors WHERE resolved_at IS NULL)::bigint AS playback_errors,
|
||||
(SELECT min(occurred_at) FROM playback_errors WHERE resolved_at IS NULL)::timestamptz AS playback_errors_since,
|
||||
-- A broken track is one report however it was found: a listener flagged
|
||||
-- it, or it failed to play (#5606). Counted by track, as the Reports
|
||||
-- page lists them.
|
||||
(SELECT count(*) FROM (
|
||||
SELECT track_id FROM lidarr_quarantine
|
||||
UNION
|
||||
SELECT track_id FROM playback_errors WHERE resolved_at IS NULL) r)::bigint AS reports,
|
||||
(SELECT min(at) FROM (
|
||||
SELECT created_at AS at FROM lidarr_quarantine
|
||||
UNION ALL
|
||||
SELECT occurred_at FROM playback_errors WHERE resolved_at IS NULL) r)::timestamptz AS reports_since,
|
||||
(SELECT count(*) FROM duplicate_groups g
|
||||
WHERE g.status = 'pending'
|
||||
AND (SELECT count(*) FROM duplicate_group_members m WHERE m.group_id = g.id) >= 2)::bigint AS duplicates,
|
||||
|
||||
@@ -17,8 +17,7 @@ export type QueueDef = {
|
||||
|
||||
export const QUEUES: QueueDef[] = [
|
||||
{ key: 'requests', href: '/admin/requests', one: 'request to decide', many: 'requests to decide', none: 'requests' },
|
||||
{ key: 'quarantined', href: '/admin/quarantine', one: 'reported track', many: 'reported tracks', none: 'reports' },
|
||||
{ key: 'playback_errors', href: '/admin/playback-errors', one: 'playback error', many: 'playback errors', none: 'playback errors' },
|
||||
{ key: 'reports', href: '/admin/reports', one: 'broken track reported', many: 'broken tracks reported', none: 'reports' },
|
||||
{ key: 'duplicates', href: '/admin/duplicates', one: 'set of duplicates to review', many: 'sets of duplicates to review', none: 'duplicates' },
|
||||
{ key: 'suspect', href: '/admin/suspect-sources', one: 'track from a suspect source', many: 'tracks from suspect sources', none: 'suspect sources' },
|
||||
{ key: 'missing', href: '/admin/missing-files', one: 'missing file', many: 'missing files', none: 'missing files' }
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { errorCounts, mergeHandled, mergeReports } from './reports';
|
||||
import type { AdminPlaybackError, AdminQuarantineRow, LidarrQuarantineActionRow } from '#lib/api/types.js';
|
||||
|
||||
const flagged = (track: string, at: string): AdminQuarantineRow => ({
|
||||
track_id: track,
|
||||
track_title: `T${track}`,
|
||||
artist_name: 'A',
|
||||
album_title: 'Al',
|
||||
album_id: 'al',
|
||||
lidarr_album_mbid: 'rg',
|
||||
report_count: 1,
|
||||
latest_at: at,
|
||||
reason_counts: { bad_rip: 1 },
|
||||
reports: [{ user_id: 'u', username: 'alice', reason: 'bad_rip', created_at: at }]
|
||||
});
|
||||
|
||||
const failed = (id: string, track: string, at: string, kind: AdminPlaybackError['kind'] = 'load_failed'): AdminPlaybackError => ({
|
||||
id,
|
||||
track_id: track,
|
||||
user_id: 'u',
|
||||
username: 'bob',
|
||||
client_id: 'android',
|
||||
kind,
|
||||
occurred_at: at,
|
||||
track_title: `T${track}`,
|
||||
track_file_path: `/music/${track}.flac`,
|
||||
artist_name: 'A',
|
||||
album_title: 'Al',
|
||||
album_id: 'al'
|
||||
});
|
||||
|
||||
describe('mergeReports', () => {
|
||||
test('a track both flagged and failing to play is one row carrying both, newest first', () => {
|
||||
const rows = mergeReports(
|
||||
[flagged('1', '2026-10-01T00:00:00Z'), flagged('2', '2026-10-05T00:00:00Z')],
|
||||
[failed('e1', '1', '2026-10-09T00:00:00Z'), failed('e2', '3', '2026-10-03T00:00:00Z', 'stalled')]
|
||||
);
|
||||
expect(rows.map((r) => r.track_id)).toEqual(['1', '2', '3']);
|
||||
expect(rows[0].reports).toHaveLength(1);
|
||||
expect(rows[0].errors.map((e) => e.id)).toEqual(['e1']);
|
||||
expect(rows[0].latest).toBe('2026-10-09T00:00:00Z');
|
||||
expect(rows[0].lidarr_album_mbid).toBe('rg');
|
||||
expect(rows[2].reports).toHaveLength(0);
|
||||
expect(rows[2].file_path).toBe('/music/3.flac');
|
||||
});
|
||||
|
||||
test('errors are counted by kind', () => {
|
||||
const [row] = mergeReports([], [failed('a', '1', '2026-10-01T00:00:00Z'), failed('b', '1', '2026-10-02T00:00:00Z'), failed('c', '1', '2026-10-03T00:00:00Z', 'stalled')]);
|
||||
expect(errorCounts(row)).toEqual([['load_failed', 2], ['stalled', 1]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeHandled', () => {
|
||||
test('cleared reports, deletions and resolved errors read as one history, newest first', () => {
|
||||
const action: LidarrQuarantineActionRow = {
|
||||
id: 'x', track_id: '1', track_title: 'T1', artist_name: 'A', action: 'deleted_file',
|
||||
affected_users: 1, created_at: '2026-10-02T00:00:00Z'
|
||||
};
|
||||
const fixed = { ...failed('e', '2', '2026-10-01T00:00:00Z'), resolved_at: '2026-10-04T00:00:00Z', resolution: 'fixed' as const };
|
||||
expect(mergeHandled([action], [fixed]).map((r) => r.what)).toEqual(['Playback error marked fixed', 'File deleted']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import type {
|
||||
AdminPlaybackError,
|
||||
AdminQuarantineReport,
|
||||
AdminQuarantineRow,
|
||||
LidarrQuarantineActionRow,
|
||||
LidarrQuarantineReason,
|
||||
PlaybackErrorKind
|
||||
} from '#lib/api/types.js';
|
||||
|
||||
// Admin › Reports (#5606): a broken track is one thing to decide about,
|
||||
// whether a listener flagged it or it failed to play, so the two sources
|
||||
// fold into one row per track. The rail's count (the summary's `reports`)
|
||||
// counts the same way, by track.
|
||||
|
||||
export type ReportRow = {
|
||||
track_id: string;
|
||||
title: string;
|
||||
artist: string;
|
||||
album: string;
|
||||
album_id: string;
|
||||
file_path: string | null;
|
||||
lidarr_album_mbid: string | null;
|
||||
reports: AdminQuarantineReport[];
|
||||
reason_counts: Partial<Record<LidarrQuarantineReason, number>>;
|
||||
errors: AdminPlaybackError[];
|
||||
latest: string;
|
||||
};
|
||||
|
||||
export const REASON_LABELS: Record<LidarrQuarantineReason, string> = {
|
||||
bad_rip: 'Bad rip',
|
||||
wrong_file: 'Wrong file',
|
||||
wrong_tags: 'Wrong tags',
|
||||
duplicate: 'Duplicate',
|
||||
other: 'Other'
|
||||
};
|
||||
|
||||
export const ERROR_LABELS: Record<PlaybackErrorKind, string> = {
|
||||
zero_duration: 'Zero duration',
|
||||
load_failed: "Wouldn't load",
|
||||
stalled: 'Stalled'
|
||||
};
|
||||
|
||||
const later = (a: string, b: string) => (new Date(a) > new Date(b) ? a : b);
|
||||
|
||||
export function mergeReports(flagged: AdminQuarantineRow[], errors: AdminPlaybackError[]): ReportRow[] {
|
||||
const byTrack = new Map<string, ReportRow>();
|
||||
for (const q of flagged) {
|
||||
byTrack.set(q.track_id, {
|
||||
track_id: q.track_id,
|
||||
title: q.track_title,
|
||||
artist: q.artist_name,
|
||||
album: q.album_title,
|
||||
album_id: q.album_id,
|
||||
file_path: null,
|
||||
lidarr_album_mbid: q.lidarr_album_mbid ?? null,
|
||||
reports: q.reports,
|
||||
reason_counts: q.reason_counts,
|
||||
errors: [],
|
||||
latest: q.latest_at
|
||||
});
|
||||
}
|
||||
for (const e of errors) {
|
||||
let row = byTrack.get(e.track_id);
|
||||
if (!row) {
|
||||
row = {
|
||||
track_id: e.track_id,
|
||||
title: e.track_title,
|
||||
artist: e.artist_name,
|
||||
album: e.album_title,
|
||||
album_id: e.album_id,
|
||||
file_path: null,
|
||||
lidarr_album_mbid: null,
|
||||
reports: [],
|
||||
reason_counts: {},
|
||||
errors: [],
|
||||
latest: e.occurred_at
|
||||
};
|
||||
byTrack.set(e.track_id, row);
|
||||
}
|
||||
row.errors.push(e);
|
||||
row.file_path = e.track_file_path;
|
||||
row.latest = later(row.latest, e.occurred_at);
|
||||
}
|
||||
return [...byTrack.values()].sort((a, b) => new Date(b.latest).getTime() - new Date(a.latest).getTime());
|
||||
}
|
||||
|
||||
// How many of each playback-error kind, for the row's pills.
|
||||
export function errorCounts(row: ReportRow): [PlaybackErrorKind, number][] {
|
||||
const counts = new Map<PlaybackErrorKind, number>();
|
||||
for (const e of row.errors) counts.set(e.kind, (counts.get(e.kind) ?? 0) + 1);
|
||||
return [...counts.entries()];
|
||||
}
|
||||
|
||||
// The Handled view: what was done about past reports, newest first.
|
||||
export type HandledRow = { key: string; title: string; artist: string; what: string; at: string };
|
||||
|
||||
const ACTION_LABELS: Record<LidarrQuarantineActionRow['action'], string> = {
|
||||
resolved: 'Reports cleared',
|
||||
deleted_file: 'File deleted',
|
||||
deleted_via_lidarr: 'Deleted through Lidarr'
|
||||
};
|
||||
|
||||
const RESOLUTION_LABELS: Record<string, string> = {
|
||||
fixed: 'Playback error marked fixed',
|
||||
ignored: 'Playback error ignored',
|
||||
hidden: 'Hidden after a playback error',
|
||||
deleted: 'File deleted after a playback error',
|
||||
requested: 'Re-requested after a playback error'
|
||||
};
|
||||
|
||||
export function mergeHandled(actions: LidarrQuarantineActionRow[], resolved: AdminPlaybackError[]): HandledRow[] {
|
||||
const rows: HandledRow[] = [
|
||||
...actions.map((a) => ({
|
||||
key: `a:${a.id}`,
|
||||
title: a.track_title,
|
||||
artist: a.artist_name,
|
||||
what: ACTION_LABELS[a.action] ?? a.action,
|
||||
at: a.created_at
|
||||
})),
|
||||
...resolved
|
||||
.filter((e) => e.resolved_at)
|
||||
.map((e) => ({
|
||||
key: `e:${e.id}`,
|
||||
title: e.track_title,
|
||||
artist: e.artist_name,
|
||||
what: RESOLUTION_LABELS[e.resolution ?? ''] ?? 'Playback error resolved',
|
||||
at: e.resolved_at as string
|
||||
}))
|
||||
];
|
||||
return rows.sort((a, b) => new Date(b.at).getTime() - new Date(a.at).getTime());
|
||||
}
|
||||
@@ -906,8 +906,8 @@ export type ReleaseProfilesResponse = {
|
||||
export type AdminQueue = { count: number; since: string | null };
|
||||
export type AdminSummary = {
|
||||
requests: AdminQueue;
|
||||
quarantined: AdminQueue;
|
||||
playback_errors: AdminQueue;
|
||||
// Broken tracks: flagged by a listener or failed to play, counted by track.
|
||||
reports: AdminQueue;
|
||||
duplicates: AdminQueue;
|
||||
suspect: AdminQueue;
|
||||
missing: AdminQueue;
|
||||
|
||||
@@ -28,8 +28,7 @@
|
||||
const items: RailItem[] = $derived([
|
||||
{ href: '/admin', label: 'Overview', exact: true },
|
||||
{ href: '/admin/requests', label: 'Requests', attention: countOf(summary, 'requests') },
|
||||
{ href: '/admin/quarantine', label: 'Quarantine', attention: countOf(summary, 'quarantined') },
|
||||
{ href: '/admin/playback-errors', label: 'Playback errors', attention: countOf(summary, 'playback_errors') },
|
||||
{ href: '/admin/reports', label: 'Reports', attention: countOf(summary, 'reports') },
|
||||
{ href: '/admin/duplicates', label: 'Duplicates', attention: countOf(summary, 'duplicates') },
|
||||
{ href: '/admin/suspect-sources', label: 'Suspect sources', attention: countOf(summary, 'suspect') },
|
||||
{ href: '/admin/missing-files', label: 'Missing files', attention: countOf(summary, 'missing') },
|
||||
|
||||
@@ -105,8 +105,7 @@ const empty = { count: 0, since: null };
|
||||
function summary(over: Partial<AdminSummary> = {}): AdminSummary {
|
||||
return {
|
||||
requests: empty,
|
||||
quarantined: empty,
|
||||
playback_errors: empty,
|
||||
reports: empty,
|
||||
duplicates: empty,
|
||||
suspect: empty,
|
||||
missing: empty,
|
||||
@@ -128,7 +127,7 @@ describe('admin Overview page', () => {
|
||||
render(OverviewPage);
|
||||
expect(screen.getByTestId('verdict').textContent).toMatch(/nothing needs you/i);
|
||||
expect(screen.queryAllByTestId('needs-you')).toHaveLength(0);
|
||||
expect(screen.getByTestId('all-clear').textContent).toMatch(/no requests, reports, playback errors, duplicates, suspect sources, missing files/i);
|
||||
expect(screen.getByTestId('all-clear').textContent).toMatch(/no requests, reports, duplicates, suspect sources, missing files/i);
|
||||
});
|
||||
|
||||
test('each queue that waits on you is listed with how long its oldest has waited, and opens its page', () => {
|
||||
@@ -213,8 +212,7 @@ describe('admin layout', () => {
|
||||
expect(links).toEqual([
|
||||
['/admin', 'Overview'],
|
||||
['/admin/requests', 'Requests 2'],
|
||||
['/admin/quarantine', 'Quarantine'],
|
||||
['/admin/playback-errors', 'Playback errors'],
|
||||
['/admin/reports', 'Reports'],
|
||||
['/admin/duplicates', 'Duplicates 5'],
|
||||
['/admin/suspect-sources', 'Suspect sources'],
|
||||
['/admin/missing-files', 'Missing files'],
|
||||
|
||||
@@ -1,298 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { pageTitle } from '#lib/branding.js';
|
||||
import { Copy, Trash2, CheckCircle2 } from 'lucide-svelte';
|
||||
import { useQueryClient } from '@tanstack/svelte-query';
|
||||
import RowActionsMenu, { type RowAction } from '#lib/components/RowActionsMenu.svelte';
|
||||
import {
|
||||
createAdminPlaybackErrorsQuery,
|
||||
resolvePlaybackError,
|
||||
deleteQuarantineFile
|
||||
} from '#lib/api/admin.js';
|
||||
import { qk } from '#lib/api/queries.js';
|
||||
import { errMessage } from '#lib/api/errors.js';
|
||||
import { pushToast } from '#lib/stores/toast.svelte.js';
|
||||
import Modal from '#lib/components/Modal.svelte';
|
||||
import TabStrip from '#lib/components/TabStrip.svelte';
|
||||
import type { AdminPlaybackError, PlaybackErrorResolution } from '#lib/api/types.js';
|
||||
import { relativeTime } from '#lib/utils/relativeTime.js';
|
||||
|
||||
// Client-reported playback errors inbox. Two tabs — Unresolved
|
||||
// (default) / Resolved. Per-row: copy details to clipboard, delete
|
||||
// the source file (auto-resolves as 'deleted'), or mark resolved
|
||||
// manually with a fixed/ignored reason. Other resolutions (hidden,
|
||||
// requested) get stamped by their respective workflows in follow-up
|
||||
// slices.
|
||||
|
||||
const client = useQueryClient();
|
||||
|
||||
// Tab state — observed by the query factory so swapping tabs
|
||||
// re-fetches the corresponding list.
|
||||
let resolved = $state(false);
|
||||
|
||||
const queryStore = $derived(createAdminPlaybackErrorsQuery(resolved));
|
||||
const query = $derived($queryStore);
|
||||
const rows = $derived((query.data ?? []) as AdminPlaybackError[]);
|
||||
|
||||
// Maps the kind enum to a short readable badge label.
|
||||
function kindLabel(kind: string): string {
|
||||
switch (kind) {
|
||||
case 'zero_duration': return 'Zero duration';
|
||||
case 'load_failed': return 'Load failed';
|
||||
case 'stalled': return 'Stalled';
|
||||
default: return kind;
|
||||
}
|
||||
}
|
||||
|
||||
// Maps the resolution enum to a short label for the Resolved tab.
|
||||
function resolutionLabel(r: string | null | undefined): string {
|
||||
if (!r) return '';
|
||||
switch (r) {
|
||||
case 'hidden': return 'Hidden';
|
||||
case 'deleted': return 'Deleted';
|
||||
case 'requested': return 'Re-requested';
|
||||
case 'fixed': return 'Fixed';
|
||||
case 'ignored': return 'Ignored';
|
||||
default: return r;
|
||||
}
|
||||
}
|
||||
|
||||
async function invalidate() {
|
||||
await client.invalidateQueries({ queryKey: qk.adminPlaybackErrors(resolved) });
|
||||
}
|
||||
|
||||
// Copy a single-row JSON payload to the clipboard. Includes the
|
||||
// server-side file_path so the operator can grep their library
|
||||
// mount without round-tripping back through the UI.
|
||||
async function onCopy(r: AdminPlaybackError) {
|
||||
const payload = {
|
||||
id: r.id,
|
||||
track_id: r.track_id,
|
||||
track_title: r.track_title,
|
||||
artist_name: r.artist_name,
|
||||
album_title: r.album_title,
|
||||
file_path: r.track_file_path,
|
||||
kind: r.kind,
|
||||
detail: r.detail,
|
||||
reported_by: r.username,
|
||||
client_id: r.client_id,
|
||||
occurred_at: r.occurred_at
|
||||
};
|
||||
try {
|
||||
await navigator.clipboard.writeText(JSON.stringify(payload, null, 2));
|
||||
pushToast('Copied error details to clipboard');
|
||||
} catch (e: unknown) {
|
||||
pushToast(`Copy failed: ${errMessage(e)}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the source file via the quarantine admin endpoint. That
|
||||
// removes the track row, and the track's error reports go with it
|
||||
// (ON DELETE CASCADE, migration 0032), so there is no row left to
|
||||
// stamp 'deleted': resolving it afterwards 404'd and the page said
|
||||
// "Delete failed" for a file already gone (#5604). The deletion is
|
||||
// recorded in the quarantine action log instead.
|
||||
let deleteFileOpen = $state<AdminPlaybackError | null>(null);
|
||||
|
||||
async function confirmDeleteFile() {
|
||||
const row = deleteFileOpen;
|
||||
if (!row) return;
|
||||
deleteFileOpen = null;
|
||||
try {
|
||||
await deleteQuarantineFile(row.track_id);
|
||||
} catch (e: unknown) {
|
||||
pushToast(`Delete failed: ${errMessage(e)}`, 'error');
|
||||
return;
|
||||
}
|
||||
pushToast(`Deleted "${row.track_title}"`);
|
||||
await client.invalidateQueries({ queryKey: ['adminPlaybackErrors'] });
|
||||
}
|
||||
|
||||
// Manual mark-resolved modal. Resolution dropdown limited to the two
|
||||
// "no further action taken" cases (fixed / ignored). Delete already
|
||||
// stamps 'deleted'; hide/request land in follow-up slices.
|
||||
let resolveOpen = $state<AdminPlaybackError | null>(null);
|
||||
let resolveChoice = $state<PlaybackErrorResolution>('fixed');
|
||||
|
||||
function openResolve(row: AdminPlaybackError) {
|
||||
resolveOpen = row;
|
||||
resolveChoice = 'fixed';
|
||||
}
|
||||
|
||||
async function confirmResolve() {
|
||||
const row = resolveOpen;
|
||||
if (!row) return;
|
||||
resolveOpen = null;
|
||||
try {
|
||||
await resolvePlaybackError(row.id, resolveChoice);
|
||||
pushToast(`Marked "${row.track_title}" ${resolutionLabel(resolveChoice).toLowerCase()}`);
|
||||
await invalidate();
|
||||
} catch (e: unknown) {
|
||||
pushToast(`Resolve failed: ${errMessage(e)}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function actionsFor(r: AdminPlaybackError): { primary: RowAction; secondary: RowAction[] } {
|
||||
return {
|
||||
primary: {
|
||||
icon: CheckCircle2,
|
||||
label: 'Resolve',
|
||||
ariaLabel: `Resolve ${r.track_title}`,
|
||||
onclick: () => openResolve(r)
|
||||
},
|
||||
secondary: [
|
||||
{
|
||||
icon: Copy,
|
||||
label: 'Copy',
|
||||
ariaLabel: `Copy details for ${r.track_title}`,
|
||||
onclick: () => onCopy(r)
|
||||
},
|
||||
{
|
||||
icon: Trash2,
|
||||
label: 'Delete file',
|
||||
ariaLabel: `Delete file for ${r.track_title}`,
|
||||
danger: true,
|
||||
onclick: () => { deleteFileOpen = r; }
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{pageTitle('Admin · Playback errors')}</title></svelte:head>
|
||||
|
||||
<div class="space-y-4">
|
||||
<header class="flex items-end justify-between gap-3">
|
||||
<div>
|
||||
<h1 class="font-display text-2xl font-medium text-text-primary">Playback errors</h1>
|
||||
<p class="text-sm text-text-secondary">
|
||||
Tracks that failed to play on a client. Reported automatically by
|
||||
the Android player when a track loads with zero duration or
|
||||
decode-fails — the player skips fast and logs here for triage.
|
||||
</p>
|
||||
</div>
|
||||
{#if !query.isPending && !query.isError && !resolved}
|
||||
<span class="rounded-full bg-accent/15 px-3 py-1 text-sm text-accent-fg">
|
||||
{rows.length} unresolved
|
||||
</span>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<!-- Tab toggle. Resolved / unresolved are two separate query keys so
|
||||
the cache holds both lists without re-fetching when you flip. -->
|
||||
<TabStrip
|
||||
ariaLabel="Resolution status"
|
||||
bind:value={() => (resolved ? 'resolved' : 'unresolved'), (v) => (resolved = v === 'resolved')}
|
||||
items={[
|
||||
{ id: 'unresolved', label: 'Unresolved' },
|
||||
{ id: 'resolved', label: 'Resolved' }
|
||||
]}
|
||||
/>
|
||||
|
||||
{#if query.isError}
|
||||
<p class="text-error-fg">Couldn't load: {errMessage(query.error)}</p>
|
||||
{:else if query.isPending}
|
||||
<p class="text-text-secondary">Loading…</p>
|
||||
{:else if rows.length === 0}
|
||||
<p class="text-text-secondary">
|
||||
{resolved ? 'No resolved errors yet.' : 'No unresolved errors.'}
|
||||
</p>
|
||||
{:else}
|
||||
<ul class="divide-y divide-border rounded-md border border-border">
|
||||
{#each rows as r (r.id)}
|
||||
<li class="flex items-start gap-3 px-3 py-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||
<span class="truncate text-sm font-medium text-text-primary">
|
||||
{r.track_title}
|
||||
</span>
|
||||
<span class="truncate text-xs text-text-secondary">
|
||||
{r.artist_name} · {r.album_title}
|
||||
</span>
|
||||
<span class="rounded bg-surface-hover px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-text-secondary">
|
||||
{kindLabel(r.kind)}
|
||||
</span>
|
||||
{#if resolved && r.resolution}
|
||||
<span class="rounded bg-accent/15 px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-accent-fg">
|
||||
{resolutionLabel(r.resolution)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="mt-0.5 text-xs text-text-muted">
|
||||
by {r.username} · {relativeTime(r.occurred_at)}
|
||||
{#if r.detail}<span class="text-text-secondary"> · {r.detail}</span>{/if}
|
||||
</div>
|
||||
<div class="mt-1 truncate text-[11px] font-mono text-text-muted" title={r.track_file_path}>
|
||||
{r.track_file_path}
|
||||
</div>
|
||||
</div>
|
||||
{#if !resolved}
|
||||
{@const a = actionsFor(r)}
|
||||
<RowActionsMenu primary={a.primary} secondary={a.secondary} />
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
title="Delete file?"
|
||||
open={deleteFileOpen !== null}
|
||||
onClose={() => { deleteFileOpen = null; }}
|
||||
>
|
||||
{#if deleteFileOpen}
|
||||
<p class="text-sm text-text-secondary">
|
||||
This removes <span class="font-medium text-text-primary">{deleteFileOpen.track_title}</span>
|
||||
from the library and deletes the underlying file. Its error reports
|
||||
go with it.
|
||||
</p>
|
||||
<p class="mt-2 text-xs text-text-muted font-mono">{deleteFileOpen.track_file_path}</p>
|
||||
<div class="mt-4 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { deleteFileOpen = null; }}
|
||||
class="rounded px-3 py-2 text-sm text-text-secondary hover:text-text-primary"
|
||||
>Cancel</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={confirmDeleteFile}
|
||||
class="rounded bg-action-destructive px-3 py-2 text-sm text-action-fg hover:opacity-90"
|
||||
>Delete file</button>
|
||||
</div>
|
||||
{/if}
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="Mark resolved"
|
||||
open={resolveOpen !== null}
|
||||
onClose={() => { resolveOpen = null; }}
|
||||
>
|
||||
{#if resolveOpen}
|
||||
<p class="text-sm text-text-secondary">
|
||||
Marking <span class="font-medium text-text-primary">{resolveOpen.track_title}</span> resolved.
|
||||
Pick how you handled it:
|
||||
</p>
|
||||
<label class="mt-3 block text-xs text-text-secondary">
|
||||
Resolution
|
||||
<select
|
||||
bind:value={resolveChoice}
|
||||
class="mt-1 block w-full rounded border border-border bg-surface px-2 py-1.5 text-sm text-text-primary"
|
||||
>
|
||||
<option value="fixed">Fixed — track now plays</option>
|
||||
<option value="ignored">Ignored — no action needed</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="mt-4 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { resolveOpen = null; }}
|
||||
class="rounded px-3 py-2 text-sm text-text-secondary hover:text-text-primary"
|
||||
>Cancel</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={confirmResolve}
|
||||
class="rounded bg-action-primary px-3 py-2 text-sm text-action-fg hover:opacity-90"
|
||||
>Mark resolved</button>
|
||||
</div>
|
||||
{/if}
|
||||
</Modal>
|
||||
@@ -0,0 +1,9 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
// Quarantine and Playback errors are one page now, Admin › Reports (#5606).
|
||||
// The old address stays because notifications and the Android app link to
|
||||
// it, and a link already sent should still land somewhere useful. A
|
||||
// universal load, as the app is adapter-static with ssr off.
|
||||
export const load = () => {
|
||||
throw redirect(308, '/admin/reports');
|
||||
};
|
||||
@@ -1,48 +0,0 @@
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/svelte';
|
||||
import { mockQuery } from '../../../test-utils/query';
|
||||
import type { AdminPlaybackError } from '#lib/api/types.js';
|
||||
|
||||
vi.mock('#lib/api/admin.js', () => ({
|
||||
createAdminPlaybackErrorsQuery: vi.fn(),
|
||||
resolvePlaybackError: vi.fn(),
|
||||
deleteQuarantineFile: vi.fn()
|
||||
}));
|
||||
|
||||
import Page from './+page.svelte';
|
||||
import { createAdminPlaybackErrorsQuery, deleteQuarantineFile, resolvePlaybackError } from '#lib/api/admin.js';
|
||||
|
||||
const row = {
|
||||
id: 'pe-1',
|
||||
track_id: 't-1',
|
||||
track_title: 'Roygbiv',
|
||||
artist_name: 'Boards of Canada',
|
||||
album_title: 'Geogaddi',
|
||||
track_file_path: '/music/BoC/Geogaddi/05 Roygbiv.flac',
|
||||
kind: 'load_failed',
|
||||
detail: null,
|
||||
username: 'alice',
|
||||
client_id: 'android-1',
|
||||
occurred_at: new Date().toISOString(),
|
||||
resolution: null
|
||||
} as unknown as AdminPlaybackError;
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('/admin/playback-errors', () => {
|
||||
// Deleting the track takes its error rows with it (ON DELETE CASCADE), so
|
||||
// the page must not then try to resolve the row: that 404'd and said
|
||||
// "Delete failed" for a file already gone (#5604).
|
||||
test('Delete file deletes the track and says so, without resolving the row it took with it', async () => {
|
||||
vi.mocked(createAdminPlaybackErrorsQuery).mockReturnValue(
|
||||
mockQuery({ data: [row] }) as unknown as ReturnType<typeof createAdminPlaybackErrorsQuery>
|
||||
);
|
||||
vi.mocked(deleteQuarantineFile).mockResolvedValue({} as never);
|
||||
render(Page);
|
||||
await fireEvent.click(screen.getByRole('button', { name: /delete file for roygbiv/i }));
|
||||
await fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: /^delete file$/i }));
|
||||
await waitFor(() => expect(deleteQuarantineFile).toHaveBeenCalledWith('t-1'));
|
||||
expect(resolvePlaybackError).not.toHaveBeenCalled();
|
||||
await waitFor(() => expect(screen.getByTestId('toast').textContent).toMatch(/deleted "roygbiv"/i));
|
||||
});
|
||||
});
|
||||
@@ -1,385 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { pageTitle } from '#lib/branding.js';
|
||||
import { Music2, RotateCcw, Trash2, Cloud, Play, ChevronRight } from 'lucide-svelte';
|
||||
import { useQueryClient } from '@tanstack/svelte-query';
|
||||
import RowActionsMenu, { type RowAction } from '#lib/components/RowActionsMenu.svelte';
|
||||
import {
|
||||
createAdminQuarantineQuery,
|
||||
resolveQuarantine,
|
||||
deleteQuarantineFile,
|
||||
deleteQuarantineViaLidarr
|
||||
} from '#lib/api/admin.js';
|
||||
import { qk } from '#lib/api/queries.js';
|
||||
import { errMessage } from '#lib/api/errors.js';
|
||||
import { pushToast } from '#lib/stores/toast.svelte.js';
|
||||
import { playRadio } from '#lib/player/store.svelte.js';
|
||||
import { coverUrl } from '#lib/media/covers.js';
|
||||
import Modal from '#lib/components/Modal.svelte';
|
||||
import type { AdminQuarantineRow, LidarrQuarantineReason } from '#lib/api/types.js';
|
||||
import { relativeTime } from '#lib/utils/relativeTime.js';
|
||||
|
||||
// Aggregated triage queue. One row per track, with per-row resolution
|
||||
// actions: Resolve (clears reports), Delete file (Bronze; modal-confirm),
|
||||
// Delete via Lidarr (Oxblood; typed-confirm "DELETE"). Mirrors
|
||||
// /admin/requests for shape — toast helper, copyForCode(), invalidateQueries.
|
||||
|
||||
const client = useQueryClient();
|
||||
|
||||
const queryStore = createAdminQuarantineQuery();
|
||||
const query = $derived($queryStore);
|
||||
const rows = $derived((query.data ?? []) as AdminQuarantineRow[]);
|
||||
|
||||
// Sum of reports across all rows; rendered as the accent-tint pill in the
|
||||
// header. Count of *reports*, not unique tracks — that's the spec.
|
||||
const totalReports = $derived(
|
||||
rows.reduce((sum, r) => sum + r.report_count, 0)
|
||||
);
|
||||
|
||||
const REASON_LABELS: Record<LidarrQuarantineReason, string> = {
|
||||
bad_rip: 'Bad rip',
|
||||
wrong_file: 'Wrong file',
|
||||
wrong_tags: 'Wrong tags',
|
||||
duplicate: 'Duplicate',
|
||||
other: 'Other'
|
||||
};
|
||||
|
||||
// Row-level expand state: tracks which rows have their per-user report
|
||||
// details revealed. Keyed by track_id.
|
||||
let expanded = $state<Record<string, boolean>>({});
|
||||
|
||||
function toggleExpanded(trackID: string) {
|
||||
expanded[trackID] = !expanded[trackID];
|
||||
}
|
||||
|
||||
// Modal state. The modal-confirm (delete file) and the typed-confirm (delete
|
||||
// via Lidarr) both store the active row's track_id; null when closed.
|
||||
let deleteFileOpen = $state<string | null>(null);
|
||||
|
||||
let deleteLidarrOpen = $state<string | null>(null);
|
||||
let deleteLidarrInput = $state<string>('');
|
||||
// Inline error inside the typed-confirm modal — the modal stays open with
|
||||
// feedback if Lidarr can't be reached or the album lookup fails.
|
||||
let deleteLidarrError = $state<string | null>(null);
|
||||
|
||||
async function invalidate() {
|
||||
await client.invalidateQueries({ queryKey: qk.adminQuarantine() });
|
||||
}
|
||||
|
||||
async function onResolve(r: AdminQuarantineRow) {
|
||||
try {
|
||||
await resolveQuarantine(r.track_id);
|
||||
await invalidate();
|
||||
} catch (e) {
|
||||
pushToast(errMessage(e), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function openDeleteFile(r: AdminQuarantineRow) {
|
||||
deleteFileOpen = r.track_id;
|
||||
}
|
||||
|
||||
function cancelDeleteFile() {
|
||||
deleteFileOpen = null;
|
||||
}
|
||||
|
||||
async function confirmDeleteFile(r: AdminQuarantineRow) {
|
||||
deleteFileOpen = null;
|
||||
try {
|
||||
await deleteQuarantineFile(r.track_id);
|
||||
await invalidate();
|
||||
} catch (e) {
|
||||
pushToast(errMessage(e), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function openDeleteLidarr(r: AdminQuarantineRow) {
|
||||
deleteLidarrOpen = r.track_id;
|
||||
deleteLidarrInput = '';
|
||||
deleteLidarrError = null;
|
||||
}
|
||||
|
||||
function cancelDeleteLidarr() {
|
||||
deleteLidarrOpen = null;
|
||||
deleteLidarrInput = '';
|
||||
deleteLidarrError = null;
|
||||
}
|
||||
|
||||
async function confirmDeleteLidarr(r: AdminQuarantineRow) {
|
||||
// Trimmed equality matches the M5a Disconnect typed-confirm pattern.
|
||||
if (deleteLidarrInput.trim() !== 'DELETE') return;
|
||||
try {
|
||||
await deleteQuarantineViaLidarr(r.track_id);
|
||||
deleteLidarrOpen = null;
|
||||
deleteLidarrInput = '';
|
||||
deleteLidarrError = null;
|
||||
await invalidate();
|
||||
} catch (e) {
|
||||
const msg = errMessage(e);
|
||||
// Inline error keeps the modal open so the operator sees the failure
|
||||
// alongside the album they were about to remove.
|
||||
deleteLidarrError = msg;
|
||||
// Also surface as toast so it's visible after dismissing the modal.
|
||||
pushToast(msg, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function onPlay(r: AdminQuarantineRow) {
|
||||
try {
|
||||
await playRadio(r.track_id);
|
||||
} catch {
|
||||
// Silent on failure — the play button is a convenience, not a primary
|
||||
// action. Toast surface is reserved for the destructive action errors.
|
||||
}
|
||||
}
|
||||
|
||||
// Modal lookups guard against the row disappearing if the query refetches
|
||||
// while the modal is open.
|
||||
const deleteFileRow = $derived(
|
||||
deleteFileOpen ? rows.find((r) => r.track_id === deleteFileOpen) ?? null : null
|
||||
);
|
||||
const deleteLidarrRow = $derived(
|
||||
deleteLidarrOpen ? rows.find((r) => r.track_id === deleteLidarrOpen) ?? null : null
|
||||
);
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{pageTitle('Admin · Quarantine')}</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">Quarantine</h2>
|
||||
{#if totalReports > 0}
|
||||
<span
|
||||
class="inline-flex items-center rounded-full bg-accent-tint px-2 py-0.5 text-xs text-accent-fg"
|
||||
data-testid="report-count-pill"
|
||||
>
|
||||
{totalReports}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-text-secondary">User-reported broken tracks waiting for triage.</p>
|
||||
</header>
|
||||
|
||||
{#if query.isPending}
|
||||
<p class="text-text-secondary">Reading the queue…</p>
|
||||
{:else if query.isError}
|
||||
<p class="text-error-fg">Couldn't load the quarantine queue.</p>
|
||||
{:else if rows.length === 0}
|
||||
<p class="text-text-secondary">Nothing to triage right now.</p>
|
||||
{:else}
|
||||
<ul class="divide-y divide-border rounded-lg border border-border bg-surface">
|
||||
{#each rows as r (r.track_id)}
|
||||
{@const isExpanded = !!expanded[r.track_id]}
|
||||
{@const primary: RowAction = {
|
||||
icon: RotateCcw,
|
||||
label: 'Resolve',
|
||||
ariaLabel: `Resolve ${r.track_title}`,
|
||||
onclick: () => onResolve(r)
|
||||
}}
|
||||
{@const secondary: RowAction[] = [
|
||||
{
|
||||
icon: Play,
|
||||
label: 'Play',
|
||||
ariaLabel: `Play ${r.track_title}`,
|
||||
onclick: () => onPlay(r)
|
||||
},
|
||||
{
|
||||
icon: Trash2,
|
||||
label: 'Delete file',
|
||||
ariaLabel: `Delete file for ${r.track_title}`,
|
||||
onclick: () => openDeleteFile(r),
|
||||
danger: true
|
||||
},
|
||||
// In the row's actions, so a phone reaches it from the menu too
|
||||
// (#5604). A local-only track has no Lidarr album to remove, so
|
||||
// it isn't offered.
|
||||
...(r.lidarr_album_mbid
|
||||
? [{
|
||||
icon: Cloud,
|
||||
label: 'Delete via Lidarr',
|
||||
ariaLabel: `Delete via Lidarr for ${r.track_title}`,
|
||||
onclick: () => openDeleteLidarr(r),
|
||||
danger: true
|
||||
}]
|
||||
: [])
|
||||
]}
|
||||
<li
|
||||
class="flex items-start gap-4 p-3"
|
||||
data-testid="admin-quarantine-row"
|
||||
data-track-id={r.track_id}
|
||||
>
|
||||
<!-- 56px album art with Slate fallback. -->
|
||||
<div
|
||||
class="flex h-14 w-14 shrink-0 items-center justify-center rounded-md bg-surface-hover"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{#if r.album_id}
|
||||
<img
|
||||
src={coverUrl(r.album_id)}
|
||||
alt=""
|
||||
class="h-full w-full rounded-md object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
{:else}
|
||||
<Music2 size={24} strokeWidth={1} class="text-text-muted" />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1 space-y-1">
|
||||
<div class="truncate text-base font-medium text-text-primary">
|
||||
{r.track_title}
|
||||
</div>
|
||||
<div class="truncate text-sm text-text-secondary">
|
||||
{r.artist_name} · {r.album_title} · {r.report_count} {r.report_count === 1 ? 'report' : 'reports'} — latest {relativeTime(r.latest_at)}
|
||||
</div>
|
||||
|
||||
<!-- Reason distribution pills -->
|
||||
<div class="flex flex-wrap gap-1.5 pt-1">
|
||||
{#each Object.entries(r.reason_counts) as [reason, count] (reason)}
|
||||
<span
|
||||
class="reason-pill"
|
||||
data-testid="reason-pill"
|
||||
>
|
||||
{count}× {REASON_LABELS[reason as LidarrQuarantineReason] ?? reason}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Reports details disclosure -->
|
||||
<button
|
||||
type="button"
|
||||
class="mt-1 inline-flex items-center gap-1 text-xs text-text-secondary hover:text-text-primary"
|
||||
aria-expanded={isExpanded}
|
||||
aria-label={`${isExpanded ? 'Hide' : 'Show'} reports for ${r.track_title}`}
|
||||
onclick={() => toggleExpanded(r.track_id)}
|
||||
>
|
||||
<ChevronRight
|
||||
size={12}
|
||||
strokeWidth={1.5}
|
||||
class="transition-transform {isExpanded ? 'rotate-90' : ''}"
|
||||
/>
|
||||
{isExpanded ? 'Hide reports' : `Show ${r.reports.length} ${r.reports.length === 1 ? 'report' : 'reports'}`}
|
||||
</button>
|
||||
|
||||
{#if isExpanded}
|
||||
<ul
|
||||
class="mt-2 space-y-1 rounded-md border border-border bg-background p-2 text-xs text-text-secondary"
|
||||
data-testid="reports-details"
|
||||
>
|
||||
{#each r.reports as rep, idx (rep.user_id + ':' + rep.created_at + ':' + idx)}
|
||||
<li>
|
||||
<span class="text-text-primary">{rep.username}</span>
|
||||
· {REASON_LABELS[rep.reason] ?? rep.reason}
|
||||
{#if rep.notes}
|
||||
· <span class="italic">{rep.notes}</span>
|
||||
{/if}
|
||||
· {relativeTime(rep.created_at)}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Action cluster -->
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<RowActionsMenu {primary} {secondary} />
|
||||
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
title="Delete file?"
|
||||
open={deleteFileRow !== null}
|
||||
onClose={cancelDeleteFile}
|
||||
>
|
||||
{#if deleteFileRow}
|
||||
<p class="-mt-2 text-sm text-text-secondary">
|
||||
Remove <em class="font-medium text-text-primary">{deleteFileRow.track_title}</em>
|
||||
from disk and clear {deleteFileRow.report_count} {deleteFileRow.report_count === 1 ? 'report' : 'reports'}? Lidarr may auto-redownload depending on its monitor settings.
|
||||
</p>
|
||||
<div class="mt-5 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-border px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary"
|
||||
onclick={cancelDeleteFile}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Confirm delete file"
|
||||
class="inline-flex items-center gap-1 rounded-md bg-action-secondary px-3 py-1.5 text-sm text-action-fg"
|
||||
onclick={() => confirmDeleteFile(deleteFileRow!)}
|
||||
>
|
||||
<Trash2 size={14} strokeWidth={2} />
|
||||
Delete file
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="Delete via Lidarr?"
|
||||
open={deleteLidarrRow !== null}
|
||||
onClose={cancelDeleteLidarr}
|
||||
>
|
||||
{#if deleteLidarrRow}
|
||||
<p class="-mt-2 text-sm text-text-secondary">
|
||||
This will tell Lidarr to remove <em class="font-medium text-text-primary">{deleteLidarrRow.album_title}</em>
|
||||
(artist <em class="font-medium text-text-primary">{deleteLidarrRow.artist_name}</em>)
|
||||
and add it to the import-list exclusion. Affects all tracks on the album. Type <strong>DELETE</strong> to confirm.
|
||||
</p>
|
||||
<label class="mt-3 block">
|
||||
<span class="block text-sm text-text-secondary">Type DELETE to confirm</span>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={deleteLidarrInput}
|
||||
placeholder="DELETE"
|
||||
class="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 font-mono text-sm text-text-primary placeholder:text-text-muted focus:outline-hidden focus:ring-2 focus:ring-accent"
|
||||
aria-label="Type DELETE to confirm"
|
||||
/>
|
||||
</label>
|
||||
{#if deleteLidarrError}
|
||||
<p class="mt-3 text-sm text-error-fg" data-testid="delete-lidarr-error">
|
||||
{deleteLidarrError}
|
||||
</p>
|
||||
{/if}
|
||||
<div class="mt-5 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-border px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary"
|
||||
onclick={cancelDeleteLidarr}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Confirm delete via Lidarr"
|
||||
disabled={deleteLidarrInput.trim() !== 'DELETE'}
|
||||
class="inline-flex items-center gap-1 rounded-md bg-action-destructive px-3 py-1.5 text-sm text-action-fg disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onclick={() => confirmDeleteLidarr(deleteLidarrRow!)}
|
||||
>
|
||||
<Trash2 size={14} strokeWidth={2} />
|
||||
<Cloud size={14} strokeWidth={2} />
|
||||
Delete via Lidarr
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
.reason-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
line-height: 14px;
|
||||
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
|
||||
color: var(--fs-accent-fg);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,9 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
// Quarantine and Playback errors are one page now, Admin › Reports (#5606).
|
||||
// The old address stays because notifications and the Android app link to
|
||||
// it, and a link already sent should still land somewhere useful. A
|
||||
// universal load, as the app is adapter-static with ssr off.
|
||||
export const load = () => {
|
||||
throw redirect(308, '/admin/reports');
|
||||
};
|
||||
@@ -1,182 +0,0 @@
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor, within } from '@testing-library/svelte';
|
||||
import { mockQuery } from '../../../test-utils/query';
|
||||
import type { AdminQuarantineRow } from '#lib/api/types.js';
|
||||
|
||||
// Wrap useQueryClient so the page can call invalidateQueries() without a real
|
||||
// QueryClient context. Everything else from svelte-query passes through.
|
||||
vi.mock('#lib/api/admin.js', () => ({
|
||||
createAdminQuarantineQuery: vi.fn(),
|
||||
resolveQuarantine: vi.fn(),
|
||||
deleteQuarantineFile: vi.fn(),
|
||||
deleteQuarantineViaLidarr: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('#lib/player/store.svelte.js', () => ({
|
||||
playRadio: vi.fn()
|
||||
}));
|
||||
|
||||
import AdminQuarantinePage from './+page.svelte';
|
||||
import {
|
||||
createAdminQuarantineQuery,
|
||||
resolveQuarantine,
|
||||
deleteQuarantineFile,
|
||||
deleteQuarantineViaLidarr
|
||||
} from '#lib/api/admin.js';
|
||||
import { playRadio } from '#lib/player/store.svelte.js';
|
||||
|
||||
const baseRow: AdminQuarantineRow = {
|
||||
track_id: 't-001',
|
||||
track_title: 'Roygbiv',
|
||||
artist_name: 'Boards of Canada',
|
||||
album_title: 'Geogaddi',
|
||||
album_id: 'al-001',
|
||||
lidarr_album_mbid: 'al-mbid-1',
|
||||
report_count: 3,
|
||||
latest_at: new Date(Date.now() - 2 * 3_600_000).toISOString(),
|
||||
reason_counts: { bad_rip: 2, wrong_tags: 1 },
|
||||
reports: [
|
||||
{
|
||||
user_id: 'u1',
|
||||
username: 'alice',
|
||||
reason: 'bad_rip',
|
||||
notes: 'crackles at 1:24',
|
||||
created_at: new Date(Date.now() - 2 * 3_600_000).toISOString()
|
||||
},
|
||||
{
|
||||
user_id: 'u2',
|
||||
username: 'bob',
|
||||
reason: 'bad_rip',
|
||||
notes: null,
|
||||
created_at: new Date(Date.now() - 5 * 3_600_000).toISOString()
|
||||
},
|
||||
{
|
||||
user_id: 'u3',
|
||||
username: 'carol',
|
||||
reason: 'wrong_tags',
|
||||
notes: 'wrong year',
|
||||
created_at: new Date(Date.now() - 7 * 3_600_000).toISOString()
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
function setup(rows: AdminQuarantineRow[] = [baseRow]) {
|
||||
(createAdminQuarantineQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||
mockQuery({ data: rows })
|
||||
);
|
||||
return render(AdminQuarantinePage);
|
||||
}
|
||||
|
||||
describe('/admin/quarantine', () => {
|
||||
test('Empty state shows the spec copy', () => {
|
||||
setup([]);
|
||||
expect(screen.getByText('Nothing to triage right now.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Aggregated row renders with reason distribution + report count', () => {
|
||||
setup();
|
||||
expect(screen.getByText('Roygbiv')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/Boards of Canada · Geogaddi · 3 reports — latest/i)
|
||||
).toBeInTheDocument();
|
||||
const pills = screen.getAllByTestId('reason-pill').map((el) => el.textContent?.trim());
|
||||
expect(pills).toContain('2× Bad rip');
|
||||
expect(pills).toContain('1× Wrong tags');
|
||||
expect(screen.getByTestId('report-count-pill')).toHaveTextContent('3');
|
||||
});
|
||||
|
||||
test('Resolve fires resolveQuarantine and invalidates the query', async () => {
|
||||
setup();
|
||||
(resolveQuarantine as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
action_id: 'a1',
|
||||
affected_users: 3
|
||||
});
|
||||
await fireEvent.click(screen.getByRole('button', { name: /resolve roygbiv/i }));
|
||||
expect(resolveQuarantine).toHaveBeenCalledWith('t-001');
|
||||
});
|
||||
|
||||
test('Delete file → modal-confirm → fires deleteQuarantineFile', async () => {
|
||||
setup();
|
||||
(deleteQuarantineFile as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
action_id: 'a2',
|
||||
affected_users: 3
|
||||
});
|
||||
await fireEvent.click(
|
||||
screen.getByRole('button', { name: /delete file for roygbiv/i })
|
||||
);
|
||||
const dialog = await screen.findByRole('dialog');
|
||||
expect(dialog).toHaveTextContent(/Remove/i);
|
||||
expect(dialog).toHaveTextContent(/clear 3 reports/i);
|
||||
await fireEvent.click(
|
||||
screen.getByRole('button', { name: /confirm delete file/i })
|
||||
);
|
||||
expect(deleteQuarantineFile).toHaveBeenCalledWith('t-001');
|
||||
});
|
||||
|
||||
test('Delete via Lidarr → typed-confirm "DELETE" → fires deleteQuarantineViaLidarr', async () => {
|
||||
setup();
|
||||
(deleteQuarantineViaLidarr as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
action_id: 'a3',
|
||||
affected_users: 3,
|
||||
deleted_track_count: 12
|
||||
});
|
||||
await fireEvent.click(
|
||||
screen.getByRole('button', { name: /delete via lidarr for roygbiv/i })
|
||||
);
|
||||
await screen.findByRole('dialog');
|
||||
const confirmBtn = screen.getByRole('button', {
|
||||
name: /confirm delete via lidarr/i
|
||||
}) as HTMLButtonElement;
|
||||
expect(confirmBtn).toBeDisabled();
|
||||
const input = screen.getByLabelText(/type delete to confirm/i) as HTMLInputElement;
|
||||
await fireEvent.input(input, { target: { value: 'DELETE' } });
|
||||
expect(confirmBtn).not.toBeDisabled();
|
||||
await fireEvent.click(confirmBtn);
|
||||
expect(deleteQuarantineViaLidarr).toHaveBeenCalledWith('t-001');
|
||||
});
|
||||
|
||||
test('Lidarr-unreachable error shows toast with spec copy', async () => {
|
||||
setup();
|
||||
(resolveQuarantine as ReturnType<typeof vi.fn>).mockRejectedValueOnce({
|
||||
code: 'lidarr_unreachable',
|
||||
message: 'unreachable',
|
||||
status: 503
|
||||
});
|
||||
await fireEvent.click(screen.getByRole('button', { name: /resolve roygbiv/i }));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Lidarr is unreachable right now. Try again, or check Admin → Integrations.'
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
);
|
||||
});
|
||||
|
||||
test('a local-only track (no Lidarr album) offers no Delete via Lidarr', () => {
|
||||
setup([{ ...baseRow, lidarr_album_mbid: null }]);
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /delete via lidarr for roygbiv/i })
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('on a phone, Delete via Lidarr is in the row menu', async () => {
|
||||
setup();
|
||||
await fireEvent.click(screen.getByRole('button', { name: /more actions/i }));
|
||||
expect(within(screen.getByRole('menu')).getByText('Delete via Lidarr')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('one report reads "1 report"', () => {
|
||||
setup([{ ...baseRow, report_count: 1, reports: [baseRow.reports[0]] }]);
|
||||
const row = screen.getByTestId('admin-quarantine-row');
|
||||
expect(row.textContent).toMatch(/· 1 report — latest/);
|
||||
expect(row.textContent).toMatch(/Show 1 report\b/);
|
||||
});
|
||||
|
||||
test('Inline play button calls playRadio with the track id', async () => {
|
||||
setup();
|
||||
await fireEvent.click(screen.getByRole('button', { name: /play roygbiv/i }));
|
||||
expect(playRadio).toHaveBeenCalledWith('t-001');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,374 @@
|
||||
<script lang="ts">
|
||||
import { pageTitle } from '#lib/branding.js';
|
||||
import { Music2, CheckCircle2, Trash2, Cloud, Play, Copy, ChevronRight } from 'lucide-svelte';
|
||||
import { useQueryClient } from '@tanstack/svelte-query';
|
||||
import RowActionsMenu, { type RowAction } from '#lib/components/RowActionsMenu.svelte';
|
||||
import TabStrip from '#lib/components/TabStrip.svelte';
|
||||
import Modal from '#lib/components/Modal.svelte';
|
||||
import {
|
||||
createAdminQuarantineQuery,
|
||||
createAdminPlaybackErrorsQuery,
|
||||
createQuarantineActionsQuery,
|
||||
resolveQuarantine,
|
||||
resolvePlaybackError,
|
||||
deleteQuarantineFile,
|
||||
deleteQuarantineViaLidarr
|
||||
} from '#lib/api/admin.js';
|
||||
import { qk } from '#lib/api/queries.js';
|
||||
import { errMessage } from '#lib/api/errors.js';
|
||||
import { pushToast } from '#lib/stores/toast.svelte.js';
|
||||
import { playRadio } from '#lib/player/store.svelte.js';
|
||||
import { coverUrl } from '#lib/media/covers.js';
|
||||
import { relativeTime } from '#lib/utils/relativeTime.js';
|
||||
import {
|
||||
ERROR_LABELS,
|
||||
REASON_LABELS,
|
||||
errorCounts,
|
||||
mergeHandled,
|
||||
mergeReports,
|
||||
type ReportRow
|
||||
} from '#lib/admin/reports.js';
|
||||
import type { LidarrQuarantineReason } from '#lib/api/types.js';
|
||||
|
||||
// Admin › Reports (#5606): every broken track in one queue, whether a
|
||||
// listener flagged it or it failed to play on a client, one row per track
|
||||
// with one set of actions. Before, the two were separate pages with their
|
||||
// own delete paths and counts that disagreed with the rail.
|
||||
|
||||
const client = useQueryClient();
|
||||
|
||||
let view = $state<'open' | 'handled'>('open');
|
||||
|
||||
const flaggedStore = createAdminQuarantineQuery();
|
||||
const flagged = $derived($flaggedStore);
|
||||
const errorsStore = createAdminPlaybackErrorsQuery(false);
|
||||
const errors = $derived($errorsStore);
|
||||
const rows = $derived(mergeReports(flagged.data ?? [], errors.data ?? []));
|
||||
const loading = $derived(flagged.isPending || errors.isPending);
|
||||
const failed = $derived(flagged.isError || errors.isError);
|
||||
|
||||
// The Handled view loads only when it is opened.
|
||||
const actionsStore = $derived(view === 'handled' ? createQuarantineActionsQuery(50) : null);
|
||||
const resolvedStore = $derived(view === 'handled' ? createAdminPlaybackErrorsQuery(true) : null);
|
||||
const actions = $derived(actionsStore ? $actionsStore : null);
|
||||
const resolved = $derived(resolvedStore ? $resolvedStore : null);
|
||||
const handled = $derived(mergeHandled(actions?.data ?? [], resolved?.data ?? []));
|
||||
|
||||
let expanded = $state<Record<string, boolean>>({});
|
||||
|
||||
async function refresh() {
|
||||
await Promise.all([
|
||||
client.invalidateQueries({ queryKey: qk.adminQuarantine() }),
|
||||
client.invalidateQueries({ queryKey: ['adminPlaybackErrors'] }),
|
||||
client.invalidateQueries({ queryKey: ['adminQuarantineActions'] }),
|
||||
client.invalidateQueries({ queryKey: qk.adminSummary() })
|
||||
]);
|
||||
}
|
||||
|
||||
// Resolve clears the listeners' reports and marks the playback errors
|
||||
// fixed: the track was looked at and is fine.
|
||||
async function onResolve(r: ReportRow) {
|
||||
try {
|
||||
if (r.reports.length > 0) await resolveQuarantine(r.track_id);
|
||||
for (const e of r.errors) await resolvePlaybackError(e.id, 'fixed');
|
||||
pushToast(`Resolved "${r.title}"`);
|
||||
} catch (e) {
|
||||
pushToast(`Couldn't resolve "${r.title}": ${errMessage(e)}`, 'error');
|
||||
}
|
||||
await refresh();
|
||||
}
|
||||
|
||||
// Copies what an operator needs to find the file and the failure.
|
||||
async function onCopy(r: ReportRow) {
|
||||
const payload = {
|
||||
track_id: r.track_id,
|
||||
title: r.title,
|
||||
artist: r.artist,
|
||||
album: r.album,
|
||||
file_path: r.file_path,
|
||||
reports: r.reports.map((x) => ({ by: x.username, reason: x.reason, notes: x.notes, at: x.created_at })),
|
||||
playback_errors: r.errors.map((x) => ({
|
||||
by: x.username,
|
||||
kind: x.kind,
|
||||
detail: x.detail,
|
||||
client: x.client_id,
|
||||
at: x.occurred_at
|
||||
}))
|
||||
};
|
||||
try {
|
||||
await navigator.clipboard.writeText(JSON.stringify(payload, null, 2));
|
||||
pushToast('Copied the details');
|
||||
} catch (e) {
|
||||
pushToast(`Copy failed: ${errMessage(e)}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function onPlay(r: ReportRow) {
|
||||
try {
|
||||
await playRadio(r.track_id);
|
||||
} catch {
|
||||
// Play is a convenience for listening before deciding; a failure here
|
||||
// is what the report is about already.
|
||||
}
|
||||
}
|
||||
|
||||
// One delete path for every report: the track and its file go, and its
|
||||
// reports and playback errors go with the track row (ON DELETE CASCADE).
|
||||
let deleteFileId = $state<string | null>(null);
|
||||
const deleteFileRow = $derived(deleteFileId ? (rows.find((r) => r.track_id === deleteFileId) ?? null) : null);
|
||||
|
||||
async function confirmDeleteFile(r: ReportRow) {
|
||||
deleteFileId = null;
|
||||
try {
|
||||
await deleteQuarantineFile(r.track_id);
|
||||
pushToast(`Deleted "${r.title}"`);
|
||||
} catch (e) {
|
||||
pushToast(`Delete failed: ${errMessage(e)}`, 'error');
|
||||
}
|
||||
await refresh();
|
||||
}
|
||||
|
||||
let deleteLidarrId = $state<string | null>(null);
|
||||
let deleteLidarrInput = $state('');
|
||||
let deleteLidarrError = $state<string | null>(null);
|
||||
const deleteLidarrRow = $derived(deleteLidarrId ? (rows.find((r) => r.track_id === deleteLidarrId) ?? null) : null);
|
||||
|
||||
function openDeleteLidarr(r: ReportRow) {
|
||||
deleteLidarrId = r.track_id;
|
||||
deleteLidarrInput = '';
|
||||
deleteLidarrError = null;
|
||||
}
|
||||
|
||||
async function confirmDeleteLidarr(r: ReportRow) {
|
||||
if (deleteLidarrInput.trim() !== 'DELETE') return;
|
||||
try {
|
||||
await deleteQuarantineViaLidarr(r.track_id);
|
||||
deleteLidarrId = null;
|
||||
pushToast(`Asked Lidarr to remove "${r.album}"`);
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
// The modal stays open with the failure beside the album named.
|
||||
deleteLidarrError = errMessage(e);
|
||||
}
|
||||
}
|
||||
|
||||
function actionsFor(r: ReportRow): { primary: RowAction; secondary: RowAction[] } {
|
||||
return {
|
||||
primary: { icon: CheckCircle2, label: 'Resolve', ariaLabel: `Resolve ${r.title}`, onclick: () => onResolve(r) },
|
||||
secondary: [
|
||||
{ icon: Play, label: 'Play', ariaLabel: `Play ${r.title}`, onclick: () => onPlay(r) },
|
||||
{ icon: Copy, label: 'Copy details', ariaLabel: `Copy details for ${r.title}`, onclick: () => onCopy(r) },
|
||||
{
|
||||
icon: Trash2,
|
||||
label: 'Delete file',
|
||||
ariaLabel: `Delete file for ${r.title}`,
|
||||
danger: true,
|
||||
onclick: () => (deleteFileId = r.track_id)
|
||||
},
|
||||
// A local-only track has no Lidarr album to remove.
|
||||
...(r.lidarr_album_mbid
|
||||
? [
|
||||
{
|
||||
icon: Cloud,
|
||||
label: 'Delete via Lidarr',
|
||||
ariaLabel: `Delete via Lidarr for ${r.title}`,
|
||||
danger: true,
|
||||
onclick: () => openDeleteLidarr(r)
|
||||
}
|
||||
]
|
||||
: [])
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`;
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{pageTitle('Admin · Reports')}</title></svelte:head>
|
||||
|
||||
<div class="space-y-4">
|
||||
<header class="space-y-1">
|
||||
<h2 class="font-display text-xl font-medium text-text-primary">Reports</h2>
|
||||
<p class="text-sm text-text-secondary">Tracks a listener flagged, or that failed to play.</p>
|
||||
</header>
|
||||
|
||||
<TabStrip
|
||||
ariaLabel="Reports view"
|
||||
bind:value={view}
|
||||
items={[
|
||||
{ id: 'open', label: 'Open' },
|
||||
{ id: 'handled', label: 'Handled' }
|
||||
]}
|
||||
/>
|
||||
|
||||
{#if view === 'open'}
|
||||
{#if loading}
|
||||
<p class="text-sm text-text-secondary">Reading the reports…</p>
|
||||
{:else if failed}
|
||||
<p class="text-sm text-error-fg" data-testid="reports-error">
|
||||
Couldn't load the reports.
|
||||
<button type="button" class="underline hover:no-underline" onclick={() => { flagged.refetch(); errors.refetch(); }}>Try again</button>
|
||||
</p>
|
||||
{:else if rows.length === 0}
|
||||
<p class="text-sm text-text-secondary" data-testid="reports-empty">No reports. Nothing is waiting on you here.</p>
|
||||
{:else}
|
||||
<p class="text-sm text-text-primary" data-testid="reports-verdict">
|
||||
{plural(rows.length, 'track needs', 'tracks need')} a look.
|
||||
</p>
|
||||
<ul class="divide-y divide-border rounded-lg border border-border bg-surface">
|
||||
{#each rows as r (r.track_id)}
|
||||
{@const a = actionsFor(r)}
|
||||
{@const open = !!expanded[r.track_id]}
|
||||
<li class="flex items-start gap-4 p-3" data-testid="report-row">
|
||||
<div class="flex h-12 w-12 shrink-0 items-center justify-center rounded-md bg-surface-hover" aria-hidden="true">
|
||||
{#if r.album_id}
|
||||
<img src={coverUrl(r.album_id)} alt="" class="h-full w-full rounded-md object-cover" loading="lazy" />
|
||||
{:else}
|
||||
<Music2 size={20} strokeWidth={1} class="text-text-muted" />
|
||||
{/if}
|
||||
</div>
|
||||
<div class="min-w-0 flex-1 space-y-1">
|
||||
<div class="truncate text-sm font-medium text-text-primary">{r.title}</div>
|
||||
<div class="truncate text-xs text-text-secondary">
|
||||
{r.artist} · {r.album} · latest {relativeTime(r.latest)}
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1.5" data-testid="report-kinds">
|
||||
{#each Object.entries(r.reason_counts) as [reason, n] (reason)}
|
||||
<span class="pill">{n}× {REASON_LABELS[reason as LidarrQuarantineReason] ?? reason}</span>
|
||||
{/each}
|
||||
{#each errorCounts(r) as [kind, n] (kind)}
|
||||
<span class="pill pill-error">{n}× {ERROR_LABELS[kind] ?? kind}</span>
|
||||
{/each}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 text-xs text-text-secondary hover:text-text-primary"
|
||||
aria-expanded={open}
|
||||
onclick={() => (expanded[r.track_id] = !open)}
|
||||
>
|
||||
<ChevronRight size={12} strokeWidth={1.5} class="transition-transform {open ? 'rotate-90' : ''}" />
|
||||
{open ? 'Hide details' : 'Details'}
|
||||
</button>
|
||||
{#if open}
|
||||
<ul class="space-y-1 rounded-md border border-border bg-background p-2 text-xs text-text-secondary" data-testid="report-details">
|
||||
{#each r.reports as rep, i (rep.user_id + i)}
|
||||
<li>
|
||||
<span class="text-text-primary">{rep.username}</span> flagged it ·
|
||||
{REASON_LABELS[rep.reason] ?? rep.reason}{#if rep.notes} · <span class="italic">{rep.notes}</span>{/if}
|
||||
· {relativeTime(rep.created_at)}
|
||||
</li>
|
||||
{/each}
|
||||
{#each r.errors as e (e.id)}
|
||||
<li>
|
||||
Failed to play for <span class="text-text-primary">{e.username}</span> ·
|
||||
{ERROR_LABELS[e.kind] ?? e.kind}{#if e.detail} · {e.detail}{/if} · {relativeTime(e.occurred_at)}
|
||||
</li>
|
||||
{/each}
|
||||
{#if r.file_path}
|
||||
<li class="truncate font-mono text-text-muted" title={r.file_path}>{r.file_path}</li>
|
||||
{/if}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
<RowActionsMenu primary={a.primary} secondary={a.secondary} />
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{:else if actions?.isPending || resolved?.isPending}
|
||||
<p class="text-sm text-text-secondary">Reading what was done…</p>
|
||||
{:else if actions?.isError || resolved?.isError}
|
||||
<p class="text-sm text-error-fg">
|
||||
Couldn't load what was done.
|
||||
<button type="button" class="underline hover:no-underline" onclick={() => { actions?.refetch(); resolved?.refetch(); }}>Try again</button>
|
||||
</p>
|
||||
{:else if handled.length === 0}
|
||||
<p class="text-sm text-text-secondary">Nothing has been handled yet.</p>
|
||||
{:else}
|
||||
<ul class="divide-y divide-border rounded-lg border border-border bg-surface" data-testid="handled">
|
||||
{#each handled as h (h.key)}
|
||||
<li class="flex flex-wrap items-baseline gap-x-3 gap-y-0.5 px-4 py-2.5 text-sm">
|
||||
<span class="text-text-primary">{h.title}</span>
|
||||
<span class="text-xs text-text-secondary">{h.artist}</span>
|
||||
<span class="ml-auto text-xs text-text-secondary">{h.what} · {relativeTime(h.at)}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Modal title="Delete file?" open={deleteFileRow !== null} onClose={() => (deleteFileId = null)}>
|
||||
{#if deleteFileRow}
|
||||
<p class="-mt-2 text-sm text-text-secondary">
|
||||
Remove <em class="font-medium text-text-primary">{deleteFileRow.title}</em> from the library and delete its
|
||||
file. Its reports and playback errors go with it.
|
||||
</p>
|
||||
<div class="mt-5 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-border px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary"
|
||||
onclick={() => (deleteFileId = null)}>Cancel</button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Confirm delete file"
|
||||
class="inline-flex items-center gap-1 rounded-md bg-action-secondary px-3 py-1.5 text-sm text-action-fg"
|
||||
onclick={() => confirmDeleteFile(deleteFileRow!)}
|
||||
>
|
||||
<Trash2 size={14} strokeWidth={2} /> Delete file
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</Modal>
|
||||
|
||||
<Modal title="Delete via Lidarr?" open={deleteLidarrRow !== null} onClose={() => (deleteLidarrId = null)}>
|
||||
{#if deleteLidarrRow}
|
||||
<p class="-mt-2 text-sm text-text-secondary">
|
||||
Lidarr removes the whole album <em class="font-medium text-text-primary">{deleteLidarrRow.album}</em> by
|
||||
{deleteLidarrRow.artist}, files included, and won't import it again. Type <strong>DELETE</strong> to confirm.
|
||||
</p>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={deleteLidarrInput}
|
||||
placeholder="DELETE"
|
||||
aria-label="Type DELETE to confirm"
|
||||
class="mt-3 w-full rounded-md border border-border bg-background px-3 py-2 font-mono text-sm text-text-primary placeholder:text-text-muted focus:outline-hidden focus:ring-2 focus:ring-accent"
|
||||
/>
|
||||
{#if deleteLidarrError}
|
||||
<p class="mt-3 text-sm text-error-fg" data-testid="delete-lidarr-error">{deleteLidarrError}</p>
|
||||
{/if}
|
||||
<div class="mt-5 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-border px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary"
|
||||
onclick={() => (deleteLidarrId = null)}>Cancel</button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Confirm delete via Lidarr"
|
||||
disabled={deleteLidarrInput.trim() !== 'DELETE'}
|
||||
class="inline-flex items-center gap-1 rounded-md bg-action-destructive px-3 py-1.5 text-sm text-action-fg disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onclick={() => confirmDeleteLidarr(deleteLidarrRow!)}
|
||||
>
|
||||
<Cloud size={14} strokeWidth={2} /> Delete via Lidarr
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
line-height: 14px;
|
||||
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
|
||||
color: var(--fs-accent-fg);
|
||||
}
|
||||
.pill-error {
|
||||
background: color-mix(in srgb, var(--fs-error) 15%, transparent);
|
||||
color: var(--fs-error-fg);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,128 @@
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/svelte';
|
||||
import { mockQuery } from '../../../test-utils/query';
|
||||
import type { AdminPlaybackError, AdminQuarantineRow } from '#lib/api/types.js';
|
||||
|
||||
vi.mock('#lib/api/admin.js', () => ({
|
||||
createAdminQuarantineQuery: vi.fn(),
|
||||
createAdminPlaybackErrorsQuery: vi.fn(),
|
||||
createQuarantineActionsQuery: vi.fn(),
|
||||
resolveQuarantine: vi.fn().mockResolvedValue({}),
|
||||
resolvePlaybackError: vi.fn().mockResolvedValue({}),
|
||||
deleteQuarantineFile: vi.fn().mockResolvedValue({}),
|
||||
deleteQuarantineViaLidarr: vi.fn().mockResolvedValue({})
|
||||
}));
|
||||
vi.mock('#lib/player/store.svelte.js', () => ({ playRadio: vi.fn() }));
|
||||
|
||||
import Page from './+page.svelte';
|
||||
import {
|
||||
createAdminPlaybackErrorsQuery,
|
||||
createAdminQuarantineQuery,
|
||||
createQuarantineActionsQuery,
|
||||
deleteQuarantineFile,
|
||||
resolvePlaybackError,
|
||||
resolveQuarantine
|
||||
} from '#lib/api/admin.js';
|
||||
|
||||
const hoursAgo = (h: number) => new Date(Date.now() - h * 3_600_000).toISOString();
|
||||
|
||||
const flagged: AdminQuarantineRow = {
|
||||
track_id: 't1',
|
||||
track_title: 'Roygbiv',
|
||||
artist_name: 'Boards of Canada',
|
||||
album_title: 'Geogaddi',
|
||||
album_id: 'al1',
|
||||
lidarr_album_mbid: 'rg1',
|
||||
report_count: 1,
|
||||
latest_at: hoursAgo(5),
|
||||
reason_counts: { bad_rip: 1 },
|
||||
reports: [{ user_id: 'u1', username: 'alice', reason: 'bad_rip', notes: 'crackles', created_at: hoursAgo(5) }]
|
||||
};
|
||||
|
||||
const failure = (id: string, track: string, title: string, h: number): AdminPlaybackError => ({
|
||||
id,
|
||||
track_id: track,
|
||||
user_id: 'u2',
|
||||
username: 'bob',
|
||||
client_id: 'android',
|
||||
kind: 'load_failed',
|
||||
occurred_at: hoursAgo(h),
|
||||
track_title: title,
|
||||
track_file_path: `/music/${title}.flac`,
|
||||
artist_name: 'Annadale',
|
||||
album_title: 'Dial Tone',
|
||||
album_id: 'al2'
|
||||
});
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
function setup(q: AdminQuarantineRow[], e: AdminPlaybackError[], opts: { error?: boolean } = {}) {
|
||||
vi.mocked(createAdminQuarantineQuery).mockReturnValue(
|
||||
(opts.error ? mockQuery({ isError: true, error: new Error('x') }) : mockQuery({ data: q })) as never
|
||||
);
|
||||
vi.mocked(createAdminPlaybackErrorsQuery).mockReturnValue(mockQuery({ data: e }) as never);
|
||||
vi.mocked(createQuarantineActionsQuery).mockReturnValue(mockQuery({ data: [] }) as never);
|
||||
return render(Page);
|
||||
}
|
||||
|
||||
describe('/admin/reports', () => {
|
||||
test('a flagged track and a track that failed to play are one list, one row per track, newest first', () => {
|
||||
setup([flagged], [failure('e1', 't1', 'Roygbiv', 1), failure('e2', 't2', 'Intro', 9)]);
|
||||
const rows = screen.getAllByTestId('report-row');
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(screen.getByTestId('reports-verdict').textContent).toMatch(/2 tracks need a look/);
|
||||
const first = within(rows[0]).getByTestId('report-kinds').textContent?.replace(/\s+/g, ' ');
|
||||
expect(first).toMatch(/1× Bad rip/);
|
||||
expect(first).toMatch(/1× Wouldn't load/);
|
||||
expect(rows[1].textContent).toMatch(/Intro/);
|
||||
});
|
||||
|
||||
test('Resolve clears the listeners\' reports and marks the playback errors fixed', async () => {
|
||||
setup([flagged], [failure('e1', 't1', 'Roygbiv', 1)]);
|
||||
await fireEvent.click(screen.getByRole('button', { name: /resolve roygbiv/i }));
|
||||
await waitFor(() => expect(resolveQuarantine).toHaveBeenCalledWith('t1'));
|
||||
await waitFor(() => expect(resolvePlaybackError).toHaveBeenCalledWith('e1', 'fixed'));
|
||||
});
|
||||
|
||||
test('a track only failing to play is resolved without touching listener reports', async () => {
|
||||
setup([], [failure('e2', 't2', 'Intro', 2)]);
|
||||
await fireEvent.click(screen.getByRole('button', { name: /resolve intro/i }));
|
||||
await waitFor(() => expect(resolvePlaybackError).toHaveBeenCalledWith('e2', 'fixed'));
|
||||
expect(resolveQuarantine).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('Delete file is one path, whatever the report came from', async () => {
|
||||
setup([], [failure('e2', 't2', 'Intro', 2)]);
|
||||
await fireEvent.click(screen.getByRole('button', { name: /delete file for intro/i }));
|
||||
await fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: /confirm delete file/i }));
|
||||
await waitFor(() => expect(deleteQuarantineFile).toHaveBeenCalledWith('t2'));
|
||||
expect(resolvePlaybackError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('Delete via Lidarr is offered only for an album Lidarr holds, and on a phone it is in the row menu', async () => {
|
||||
setup([flagged], [failure('e2', 't2', 'Intro', 2)]);
|
||||
expect(screen.getByRole('button', { name: /delete via lidarr for roygbiv/i })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /delete via lidarr for intro/i })).toBeNull();
|
||||
await fireEvent.click(screen.getAllByRole('button', { name: /more actions/i })[0]);
|
||||
expect(within(screen.getByRole('menu')).getByText('Delete via Lidarr')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('details show who flagged it, who it failed for, and the file', async () => {
|
||||
setup([flagged], [failure('e1', 't1', 'Roygbiv', 1)]);
|
||||
await fireEvent.click(screen.getByRole('button', { name: /^details$/i }));
|
||||
const details = screen.getByTestId('report-details').textContent?.replace(/\s+/g, ' ');
|
||||
expect(details).toMatch(/alice flagged it · Bad rip · crackles/);
|
||||
expect(details).toMatch(/Failed to play for bob · Wouldn't load/);
|
||||
expect(details).toMatch(/\/music\/Roygbiv\.flac/);
|
||||
});
|
||||
|
||||
test('a load failure says so with Try again, and an empty queue says nothing waits', () => {
|
||||
setup([], [], { error: true });
|
||||
expect(screen.getByTestId('reports-error').textContent).toMatch(/couldn't load the reports/i);
|
||||
});
|
||||
|
||||
test('with nothing reported it says so', () => {
|
||||
setup([], []);
|
||||
expect(screen.getByTestId('reports-empty').textContent).toMatch(/no reports/i);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user