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

- /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:
2026-10-11 10:53:58 -04:00
co-authored by Claude Opus 5.5
parent 864eb45580
commit 89a2653995
17 changed files with 765 additions and 960 deletions
+1 -2
View File
@@ -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' }
+63
View File
@@ -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']);
});
});
+131
View File
@@ -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());
}
+2 -2
View File
@@ -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;