15b59a214d
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.
243 lines
7.7 KiB
Go
243 lines
7.7 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/jackc/pgx/v5"
|
|
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
|
)
|
|
|
|
// Valid `kind` values. Mirrors the CHECK constraint in migration 0032
|
|
// — keep both lists in sync if you add a kind here, otherwise inserts
|
|
// pass the handler whitelist and trip the DB constraint instead.
|
|
var validPlaybackErrorKinds = map[string]struct{}{
|
|
"zero_duration": {},
|
|
"load_failed": {},
|
|
"stalled": {},
|
|
}
|
|
|
|
// Valid `resolution` values. Hidden / deleted / requested are stamped
|
|
// automatically when the admin clicks the matching action from the
|
|
// inbox; fixed / ignored are operator-driven (no action taken on the
|
|
// track itself). Mirrors the CHECK constraint in migration 0032.
|
|
var validPlaybackErrorResolutions = map[string]struct{}{
|
|
"hidden": {},
|
|
"deleted": {},
|
|
"requested": {},
|
|
"fixed": {},
|
|
"ignored": {},
|
|
}
|
|
|
|
// reportPlaybackErrorRequest is the body of POST /api/playback-errors.
|
|
// TrackID is a UUID string; Kind is one of [validPlaybackErrorKinds];
|
|
// Detail is optional free-text from the client (e.g. ExoPlayer error
|
|
// message); ClientID identifies the device/browser so support can
|
|
// correlate reports across surfaces.
|
|
type reportPlaybackErrorRequest struct {
|
|
TrackID string `json:"track_id"`
|
|
Kind string `json:"kind"`
|
|
Detail *string `json:"detail,omitempty"`
|
|
ClientID string `json:"client_id"`
|
|
}
|
|
|
|
// adminPlaybackErrorView is one row in the admin inbox response. The
|
|
// track / album / artist fields are joined so the SPA can render
|
|
// without a second round-trip per row.
|
|
type adminPlaybackErrorView struct {
|
|
ID string `json:"id"`
|
|
TrackID string `json:"track_id"`
|
|
UserID string `json:"user_id"`
|
|
Username string `json:"username"`
|
|
ClientID string `json:"client_id"`
|
|
Kind string `json:"kind"`
|
|
Detail *string `json:"detail,omitempty"`
|
|
OccurredAt string `json:"occurred_at"`
|
|
ResolvedAt *string `json:"resolved_at,omitempty"`
|
|
Resolution *string `json:"resolution,omitempty"`
|
|
TrackTitle string `json:"track_title"`
|
|
TrackFilePath string `json:"track_file_path"`
|
|
ArtistName string `json:"artist_name"`
|
|
AlbumTitle string `json:"album_title"`
|
|
AlbumID string `json:"album_id"`
|
|
}
|
|
|
|
// resolvePlaybackErrorRequest is the body of POST
|
|
// /api/admin/playback-errors/{id}/resolve. The resolution string is
|
|
// validated against [validPlaybackErrorResolutions].
|
|
type resolvePlaybackErrorRequest struct {
|
|
Resolution string `json:"resolution"`
|
|
}
|
|
|
|
// Pagination defaults for the admin list endpoint. 50 fits the
|
|
// admin inbox table on a laptop without scrolling; max 200 prevents
|
|
// a misbehaving client from asking for the whole table at once.
|
|
const (
|
|
defaultPlaybackErrorPageSize = 50
|
|
maxPlaybackErrorPageSize = 200
|
|
)
|
|
|
|
// handleReportPlaybackError implements POST /api/playback-errors.
|
|
// Any signed-in user can report; the handler validates kind + track
|
|
// existence and inserts a row for admin review.
|
|
func (h *handlers) handleReportPlaybackError(w http.ResponseWriter, r *http.Request) {
|
|
user, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
var req reportPlaybackErrorRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeErr(w, apierror.BadRequest("bad_request", "invalid JSON body"))
|
|
return
|
|
}
|
|
trackID, ok := parseUUID(req.TrackID)
|
|
if !ok {
|
|
writeErr(w, apierror.BadRequest("bad_request", "invalid track_id"))
|
|
return
|
|
}
|
|
if _, ok := validPlaybackErrorKinds[req.Kind]; !ok {
|
|
writeErr(w, apierror.BadRequest("bad_request", "invalid kind"))
|
|
return
|
|
}
|
|
if req.ClientID == "" {
|
|
writeErr(w, apierror.BadRequest("bad_request", "client_id required"))
|
|
return
|
|
}
|
|
if _, err := dbq.New(h.pool).GetTrackByID(r.Context(), trackID); err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
writeErr(w, &apierror.Error{Status: 404, Code: "not_found", Message: "track not found"})
|
|
return
|
|
}
|
|
h.logger.Error("api: playback_error: lookup track", "err", err)
|
|
writeErr(w, apierror.Internal(err))
|
|
return
|
|
}
|
|
row, err := dbq.New(h.pool).InsertPlaybackError(r.Context(), dbq.InsertPlaybackErrorParams{
|
|
TrackID: trackID,
|
|
UserID: user.ID,
|
|
ClientID: req.ClientID,
|
|
Kind: req.Kind,
|
|
Detail: req.Detail,
|
|
})
|
|
if err != nil {
|
|
h.logger.Error("api: playback_error: insert", "err", err)
|
|
writeErr(w, apierror.Internal(err))
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, map[string]string{"id": uuidToString(row.ID)})
|
|
}
|
|
|
|
// handleListAdminPlaybackErrors implements GET
|
|
// /api/admin/playback-errors?resolved=false&offset=0&limit=50.
|
|
func (h *handlers) handleListAdminPlaybackErrors(w http.ResponseWriter, r *http.Request) {
|
|
resolved := r.URL.Query().Get("resolved") == "true"
|
|
limit := parsePlaybackErrorLimit(r.URL.Query().Get("limit"))
|
|
offset := parsePlaybackErrorOffset(r.URL.Query().Get("offset"))
|
|
rows, err := dbq.New(h.pool).ListAdminPlaybackErrors(r.Context(), dbq.ListAdminPlaybackErrorsParams{
|
|
ResolvedFilter: resolved,
|
|
Off: int32(offset),
|
|
Lim: int32(limit),
|
|
})
|
|
if err != nil {
|
|
h.logger.Error("admin: list playback_errors", "err", err)
|
|
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
|
|
return
|
|
}
|
|
out := make([]adminPlaybackErrorView, 0, len(rows))
|
|
for _, row := range rows {
|
|
view := adminPlaybackErrorView{
|
|
ID: uuidToString(row.ID),
|
|
TrackID: uuidToString(row.TrackID),
|
|
UserID: uuidToString(row.UserID),
|
|
Username: row.Username,
|
|
ClientID: row.ClientID,
|
|
Kind: row.Kind,
|
|
Detail: row.Detail,
|
|
OccurredAt: formatTimestamp(row.OccurredAt),
|
|
Resolution: row.Resolution,
|
|
TrackTitle: row.TrackTitle,
|
|
TrackFilePath: row.TrackFilePath,
|
|
ArtistName: row.ArtistName,
|
|
AlbumTitle: row.AlbumTitle,
|
|
AlbumID: uuidToString(row.AlbumID),
|
|
}
|
|
if row.ResolvedAt.Valid {
|
|
ts := formatTimestamp(row.ResolvedAt)
|
|
view.ResolvedAt = &ts
|
|
}
|
|
out = append(out, view)
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
}
|
|
|
|
// handleResolvePlaybackError implements POST
|
|
// /api/admin/playback-errors/{id}/resolve.
|
|
func (h *handlers) handleResolvePlaybackError(w http.ResponseWriter, r *http.Request) {
|
|
admin, ok := auth.UserFromContext(r.Context())
|
|
if !ok {
|
|
writeAdminJSONErr(w, http.StatusUnauthorized, "unauthorized")
|
|
return
|
|
}
|
|
id, ok := parseUUID(chi.URLParam(r, "id"))
|
|
if !ok {
|
|
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_id")
|
|
return
|
|
}
|
|
var req resolvePlaybackErrorRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeAdminJSONErr(w, http.StatusBadRequest, "bad_request")
|
|
return
|
|
}
|
|
if _, ok := validPlaybackErrorResolutions[req.Resolution]; !ok {
|
|
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_resolution")
|
|
return
|
|
}
|
|
resolution := req.Resolution
|
|
row, err := dbq.New(h.pool).ResolvePlaybackError(r.Context(), dbq.ResolvePlaybackErrorParams{
|
|
ID: id,
|
|
ResolvedBy: admin.ID,
|
|
Resolution: &resolution,
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
writeAdminJSONErr(w, http.StatusNotFound, "not_found")
|
|
return
|
|
}
|
|
h.logger.Error("admin: resolve playback_error", "err", err)
|
|
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]string{"id": uuidToString(row.ID)})
|
|
}
|
|
|
|
func parsePlaybackErrorLimit(raw string) int {
|
|
if raw == "" {
|
|
return defaultPlaybackErrorPageSize
|
|
}
|
|
n, err := strconv.Atoi(raw)
|
|
if err != nil || n <= 0 {
|
|
return defaultPlaybackErrorPageSize
|
|
}
|
|
if n > maxPlaybackErrorPageSize {
|
|
return maxPlaybackErrorPageSize
|
|
}
|
|
return n
|
|
}
|
|
|
|
func parsePlaybackErrorOffset(raw string) int {
|
|
if raw == "" {
|
|
return 0
|
|
}
|
|
n, err := strconv.Atoi(raw)
|
|
if err != nil || n < 0 {
|
|
return 0
|
|
}
|
|
return n
|
|
}
|