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 }