feat: Admin › Reports is one queue of broken tracks, whether a listener flagged it or it failed to play, one row per track with one set of actions (M503 #5606)
release / govulncheck (push) Successful in 16s
release / web (push) Failing after 1m18s
release / go (push) Successful in 1m36s
release / integration (push) Successful in 5m27s
release / android (push) Successful in 5m28s
release / Build signed APK (releases and dev) (push) Successful in 5m38s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
release / govulncheck (push) Successful in 16s
release / web (push) Failing after 1m18s
release / go (push) Successful in 1m36s
release / integration (push) Successful in 5m27s
release / android (push) Successful in 5m28s
release / Build signed APK (releases and dev) (push) Successful in 5m38s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
- /admin/reports folds the quarantine queue and the unresolved playback errors into one row per track, newest first: who flagged it and why, what failed and for whom, the file, with Resolve (clears the reports and marks the errors fixed), Play, Copy details, Delete file and, for an album Lidarr holds, Delete via Lidarr. - One delete path: Delete file removes the track, and its reports and errors go with the row. - A Handled tab shows what was done: reports cleared, files deleted, errors marked fixed or ignored. - The summary counts reports by track across both sources, so the rail, the Overview and the page agree. - /admin/quarantine and /admin/playback-errors redirect to Reports: the notifications and the Android app link to them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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