Files
minstrel/internal/api/playback_errors.go
T
bvandeusenandClaude Opus 5.5 1e9408c835
release / govulncheck (push) Successful in 18s
release / web (push) Successful in 1m22s
release / go (push) Successful in 1m43s
release / integration (push) Successful in 4m56s
release / android (push) Successful in 5m16s
release / Build signed APK (releases and dev) (push) Successful in 5m27s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 15s
release / Verify release artifacts (tag releases only) (push) Skipped
feat(notifications): library health reaches admins, coalesced (#5341)
- A failed scan run sends scan_failed. Each failure adds to the count and
  the notice shows the latest error. A scan cut short by shutdown says
  nothing.
- Marking tracks missing sends tracks_missing with a running count.
- A duplicate sweep that proposes a group it had not proposed before
  sends duplicates_found, counting everything awaiting review. A sweep
  that only re-finds known groups stays quiet, so a read notice isn't
  repeated every sweep (CountDuplicateGroupsDetectedSince).
- A playback-error report sends playback_errors, counting the unresolved
  errors (CountUnresolvedPlaybackErrors).

The library package gets its notifier as a package-level SetNotifier
beside SetEventBus, for the same reason the bus is package-level.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 07:20:33 -04:00

244 lines
7.8 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
}
h.notifyPlaybackErrors(r.Context())
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
}