feat(server): playback_errors table + admin inbox endpoints
test-go / test (push) Successful in 29s
test-go / integration (push) Successful in 11m25s

New client-reported playback-error log. Surfaces zero-duration
tracks (and future load_failed / stalled kinds) into an admin
inbox so the operator can hide / delete / re-request the
offending track.

Schema (migration 0032):
- playback_errors table with CHECK constraints on the kind +
  resolution enums (per the standing rule that new enum values
  need a migration to add)
- Partial index on unresolved rows for fast inbox lookup
- ON DELETE CASCADE from tracks + users so cleanup is automatic

Endpoints:
- POST /api/playback-errors: any signed-in user reports. Body
  validates track existence + kind whitelist; client_id required
  so support can correlate reports from the same device.
- GET /api/admin/playback-errors?resolved=false&offset=&limit=:
  admin list with join to track/album/artist for table render
  without per-row round-trips. Pagination capped at 200/page.
- POST /api/admin/playback-errors/{id}/resolve: admin marks
  resolved with a resolution enum string.

Auto-resolve on Hide/Delete/Re-request from the inbox row is
driven from the web client (two sequential calls) — keeps the
existing track-action endpoints unchanged.
This commit is contained in:
2026-06-02 11:25:50 -04:00
parent 6a54d074fd
commit 15b59a214d
7 changed files with 577 additions and 0 deletions
+13
View File
@@ -383,6 +383,19 @@ type PlaySession struct {
ClientID *string
}
type PlaybackError struct {
ID pgtype.UUID
TrackID pgtype.UUID
UserID pgtype.UUID
ClientID string
Kind string
Detail *string
OccurredAt pgtype.Timestamptz
ResolvedAt pgtype.Timestamptz
ResolvedBy pgtype.UUID
Resolution *string
}
type Playlist struct {
ID pgtype.UUID
UserID pgtype.UUID
+210
View File
@@ -0,0 +1,210 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: playback_errors.sql
package dbq
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const insertPlaybackError = `-- name: InsertPlaybackError :one
INSERT INTO playback_errors (track_id, user_id, client_id, kind, detail)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, track_id, user_id, client_id, kind, detail, occurred_at,
resolved_at, resolved_by, resolution
`
type InsertPlaybackErrorParams struct {
TrackID pgtype.UUID
UserID pgtype.UUID
ClientID string
Kind string
Detail *string
}
// Records a client-reported playback failure. The handler validates
// the kind enum before this runs; the CHECK constraint is a
// belt-and-braces guard.
func (q *Queries) InsertPlaybackError(ctx context.Context, arg InsertPlaybackErrorParams) (PlaybackError, error) {
row := q.db.QueryRow(ctx, insertPlaybackError,
arg.TrackID,
arg.UserID,
arg.ClientID,
arg.Kind,
arg.Detail,
)
var i PlaybackError
err := row.Scan(
&i.ID,
&i.TrackID,
&i.UserID,
&i.ClientID,
&i.Kind,
&i.Detail,
&i.OccurredAt,
&i.ResolvedAt,
&i.ResolvedBy,
&i.Resolution,
)
return i, err
}
const listAdminPlaybackErrors = `-- name: ListAdminPlaybackErrors :many
SELECT
pe.id AS id,
pe.track_id AS track_id,
pe.user_id AS user_id,
u.username AS username,
pe.client_id AS client_id,
pe.kind AS kind,
pe.detail AS detail,
pe.occurred_at AS occurred_at,
pe.resolved_at AS resolved_at,
pe.resolution AS resolution,
t.title AS track_title,
t.file_path AS track_file_path,
ar.name AS artist_name,
al.title AS album_title,
al.id AS album_id
FROM playback_errors pe
JOIN users u ON u.id = pe.user_id
JOIN tracks t ON t.id = pe.track_id
JOIN albums al ON al.id = t.album_id
JOIN artists ar ON ar.id = t.artist_id
WHERE ($1::bool AND pe.resolved_at IS NOT NULL)
OR (NOT $1::bool AND pe.resolved_at IS NULL)
ORDER BY pe.occurred_at DESC
LIMIT $3::int OFFSET $2::int
`
type ListAdminPlaybackErrorsParams struct {
ResolvedFilter bool
Off int32
Lim int32
}
type ListAdminPlaybackErrorsRow struct {
ID pgtype.UUID
TrackID pgtype.UUID
UserID pgtype.UUID
Username string
ClientID string
Kind string
Detail *string
OccurredAt pgtype.Timestamptz
ResolvedAt pgtype.Timestamptz
Resolution *string
TrackTitle string
TrackFilePath string
ArtistName string
AlbumTitle string
AlbumID pgtype.UUID
}
// Admin inbox query. Returns one row per playback_errors row joined
// with track / album / artist metadata so the SPA can render without
// a second round-trip. Filter by resolved status via the sqlc.arg —
// pass true for the Resolved tab, false for Unresolved (the default).
func (q *Queries) ListAdminPlaybackErrors(ctx context.Context, arg ListAdminPlaybackErrorsParams) ([]ListAdminPlaybackErrorsRow, error) {
rows, err := q.db.Query(ctx, listAdminPlaybackErrors, arg.ResolvedFilter, arg.Off, arg.Lim)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListAdminPlaybackErrorsRow
for rows.Next() {
var i ListAdminPlaybackErrorsRow
if err := rows.Scan(
&i.ID,
&i.TrackID,
&i.UserID,
&i.Username,
&i.ClientID,
&i.Kind,
&i.Detail,
&i.OccurredAt,
&i.ResolvedAt,
&i.Resolution,
&i.TrackTitle,
&i.TrackFilePath,
&i.ArtistName,
&i.AlbumTitle,
&i.AlbumID,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const resolveAllPlaybackErrorsForTrack = `-- name: ResolveAllPlaybackErrorsForTrack :execrows
UPDATE playback_errors
SET resolved_at = now(),
resolved_by = $2,
resolution = $3
WHERE track_id = $1 AND resolved_at IS NULL
`
type ResolveAllPlaybackErrorsForTrackParams struct {
TrackID pgtype.UUID
ResolvedBy pgtype.UUID
Resolution *string
}
// Bulk-resolve every unresolved error for a track when an admin acts
// on it from a non-inbox surface (existing quarantine flow, etc.).
// Returns the affected row count so the caller can surface "N reports
// auto-resolved" in the response if useful.
func (q *Queries) ResolveAllPlaybackErrorsForTrack(ctx context.Context, arg ResolveAllPlaybackErrorsForTrackParams) (int64, error) {
result, err := q.db.Exec(ctx, resolveAllPlaybackErrorsForTrack, arg.TrackID, arg.ResolvedBy, arg.Resolution)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const resolvePlaybackError = `-- name: ResolvePlaybackError :one
UPDATE playback_errors
SET resolved_at = now(),
resolved_by = $2,
resolution = $3
WHERE id = $1
RETURNING id, track_id, user_id, client_id, kind, detail, occurred_at,
resolved_at, resolved_by, resolution
`
type ResolvePlaybackErrorParams struct {
ID pgtype.UUID
ResolvedBy pgtype.UUID
Resolution *string
}
// Marks a single error resolved. Idempotent over (id, resolution) —
// a second resolve with the same resolution is a no-op rewrite. Returns
// the row so the handler can echo it. The CHECK constraint enforces
// the resolution enum.
func (q *Queries) ResolvePlaybackError(ctx context.Context, arg ResolvePlaybackErrorParams) (PlaybackError, error) {
row := q.db.QueryRow(ctx, resolvePlaybackError, arg.ID, arg.ResolvedBy, arg.Resolution)
var i PlaybackError
err := row.Scan(
&i.ID,
&i.TrackID,
&i.UserID,
&i.ClientID,
&i.Kind,
&i.Detail,
&i.OccurredAt,
&i.ResolvedAt,
&i.ResolvedBy,
&i.Resolution,
)
return i, err
}
@@ -0,0 +1 @@
DROP TABLE IF EXISTS playback_errors;
@@ -0,0 +1,41 @@
-- Client-reported playback errors. Populated by clients when a track
-- fails to play (zero duration, decode error, etc.); surfaced in the
-- admin /admin/playback-errors inbox so the operator can hide / delete
-- / re-request the offending track.
--
-- resolved_at + resolved_by + resolution are NULL until an admin acts
-- on the row. Auto-resolved by the client when the admin clicks Hide /
-- Delete / Re-request from the inbox row, with the resolution string
-- recording which path was taken. The partial index keeps the
-- unresolved-queue lookup fast even as resolved history accumulates.
--
-- CHECK constraints on kind/resolution gate the enum values so client
-- typos can't pollute the data (per the standing rule about
-- enum-CHECK whitelists needing migrations).
CREATE TABLE playback_errors (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
track_id uuid NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
client_id text NOT NULL,
kind text NOT NULL,
detail text,
occurred_at timestamptz NOT NULL DEFAULT now(),
resolved_at timestamptz,
resolved_by uuid REFERENCES users(id),
resolution text,
CONSTRAINT playback_errors_kind_check
CHECK (kind IN ('zero_duration', 'load_failed', 'stalled')),
CONSTRAINT playback_errors_resolution_check
CHECK (resolution IS NULL OR resolution IN
('hidden', 'deleted', 'requested', 'fixed', 'ignored')),
CONSTRAINT playback_errors_resolved_consistency
CHECK ((resolved_at IS NULL) = (resolution IS NULL))
);
CREATE INDEX idx_playback_errors_unresolved
ON playback_errors (occurred_at DESC)
WHERE resolved_at IS NULL;
CREATE INDEX idx_playback_errors_track
ON playback_errors (track_id);
+63
View File
@@ -0,0 +1,63 @@
-- name: InsertPlaybackError :one
-- Records a client-reported playback failure. The handler validates
-- the kind enum before this runs; the CHECK constraint is a
-- belt-and-braces guard.
INSERT INTO playback_errors (track_id, user_id, client_id, kind, detail)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, track_id, user_id, client_id, kind, detail, occurred_at,
resolved_at, resolved_by, resolution;
-- name: ListAdminPlaybackErrors :many
-- Admin inbox query. Returns one row per playback_errors row joined
-- with track / album / artist metadata so the SPA can render without
-- a second round-trip. Filter by resolved status via the sqlc.arg —
-- pass true for the Resolved tab, false for Unresolved (the default).
SELECT
pe.id AS id,
pe.track_id AS track_id,
pe.user_id AS user_id,
u.username AS username,
pe.client_id AS client_id,
pe.kind AS kind,
pe.detail AS detail,
pe.occurred_at AS occurred_at,
pe.resolved_at AS resolved_at,
pe.resolution AS resolution,
t.title AS track_title,
t.file_path AS track_file_path,
ar.name AS artist_name,
al.title AS album_title,
al.id AS album_id
FROM playback_errors pe
JOIN users u ON u.id = pe.user_id
JOIN tracks t ON t.id = pe.track_id
JOIN albums al ON al.id = t.album_id
JOIN artists ar ON ar.id = t.artist_id
WHERE (sqlc.arg(resolved_filter)::bool AND pe.resolved_at IS NOT NULL)
OR (NOT sqlc.arg(resolved_filter)::bool AND pe.resolved_at IS NULL)
ORDER BY pe.occurred_at DESC
LIMIT sqlc.arg(lim)::int OFFSET sqlc.arg(off)::int;
-- name: ResolvePlaybackError :one
-- Marks a single error resolved. Idempotent over (id, resolution) —
-- a second resolve with the same resolution is a no-op rewrite. Returns
-- the row so the handler can echo it. The CHECK constraint enforces
-- the resolution enum.
UPDATE playback_errors
SET resolved_at = now(),
resolved_by = $2,
resolution = $3
WHERE id = $1
RETURNING id, track_id, user_id, client_id, kind, detail, occurred_at,
resolved_at, resolved_by, resolution;
-- name: ResolveAllPlaybackErrorsForTrack :execrows
-- Bulk-resolve every unresolved error for a track when an admin acts
-- on it from a non-inbox surface (existing quarantine flow, etc.).
-- Returns the affected row count so the caller can surface "N reports
-- auto-resolved" in the response if useful.
UPDATE playback_errors
SET resolved_at = now(),
resolved_by = $2,
resolution = $3
WHERE track_id = $1 AND resolved_at IS NULL;