package api import ( "context" "errors" "net/http" "github.com/jackc/pgx/v5/pgtype" "git.fabledsword.com/bvandeusen/minstrel/internal/apierror" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests" ) // requestView is the JSON shape returned by all /api/requests handlers. // Field names follow the lowercase snake_case convention for /api/* responses. type requestView struct { ID pgtype.UUID `json:"id"` UserID pgtype.UUID `json:"user_id"` Status string `json:"status"` Kind string `json:"kind"` LidarrArtistMBID string `json:"lidarr_artist_mbid"` LidarrAlbumMBID *string `json:"lidarr_album_mbid,omitempty"` LidarrTrackMBID *string `json:"lidarr_track_mbid,omitempty"` ArtistName string `json:"artist_name"` AlbumTitle *string `json:"album_title,omitempty"` TrackTitle *string `json:"track_title,omitempty"` QualityProfileID *int32 `json:"quality_profile_id,omitempty"` RootFolderPath *string `json:"root_folder_path,omitempty"` DecidedAt pgtype.Timestamptz `json:"decided_at,omitempty"` DecidedBy pgtype.UUID `json:"decided_by,omitempty"` Notes *string `json:"notes,omitempty"` CompletedAt pgtype.Timestamptz `json:"completed_at,omitempty"` MatchedTrackID pgtype.UUID `json:"matched_track_id,omitempty"` MatchedAlbumID pgtype.UUID `json:"matched_album_id,omitempty"` MatchedArtistID pgtype.UUID `json:"matched_artist_id,omitempty"` RequestedAt pgtype.Timestamptz `json:"requested_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` // Progress counters, computed at response time from the matched // entity's children. 0 when nothing has been ingested yet (or when // the request has no matched entity yet). For artist requests both // counters fill; for album/track requests only ImportedTrackCount. ImportedAlbumCount int `json:"imported_album_count"` ImportedTrackCount int `json:"imported_track_count"` } func requestViewFrom(row dbq.LidarrRequest) requestView { return requestView{ ID: row.ID, UserID: row.UserID, Status: string(row.Status), Kind: string(row.Kind), LidarrArtistMBID: row.LidarrArtistMbid, LidarrAlbumMBID: row.LidarrAlbumMbid, LidarrTrackMBID: row.LidarrTrackMbid, ArtistName: row.ArtistName, AlbumTitle: row.AlbumTitle, TrackTitle: row.TrackTitle, QualityProfileID: row.QualityProfileID, RootFolderPath: row.RootFolderPath, DecidedAt: row.DecidedAt, DecidedBy: row.DecidedBy, Notes: row.Notes, CompletedAt: row.CompletedAt, MatchedTrackID: row.MatchedTrackID, MatchedAlbumID: row.MatchedAlbumID, MatchedArtistID: row.MatchedArtistID, RequestedAt: row.RequestedAt, UpdatedAt: row.UpdatedAt, } } // fillProgress fills ImportedAlbumCount + ImportedTrackCount on a view by // querying the count of children for the request's matched entity. // // kind=artist → counts all albums + tracks under the matched artist. // kind=album → counts tracks under the matched album. // kind=track → ImportedTrackCount = 1 once matched_track_id is set. // // N+1 against the request list: acceptable at admin scale (dozens of // in-flight requests at most). Errors are swallowed — progress is a // reporting nicety, not a correctness gate. func fillProgress(ctx context.Context, q *dbq.Queries, row dbq.LidarrRequest, view *requestView) { switch row.Kind { case dbq.LidarrRequestKindArtist: if !row.MatchedArtistID.Valid { return } if n, err := q.CountAlbumsByArtist(ctx, row.MatchedArtistID); err == nil { view.ImportedAlbumCount = int(n) } if n, err := q.CountTracksByArtist(ctx, row.MatchedArtistID); err == nil { view.ImportedTrackCount = int(n) } case dbq.LidarrRequestKindAlbum: if !row.MatchedAlbumID.Valid { return } view.ImportedAlbumCount = 1 if n, err := q.CountTracksByAlbum(ctx, row.MatchedAlbumID); err == nil { view.ImportedTrackCount = int(n) } case dbq.LidarrRequestKindTrack: if row.MatchedTrackID.Valid { view.ImportedTrackCount = 1 } } } // createRequestBody is the decoded JSON for POST /api/requests. type createRequestBody struct { Kind string `json:"kind"` LidarrArtistMBID string `json:"lidarr_artist_mbid"` LidarrAlbumMBID string `json:"lidarr_album_mbid"` LidarrTrackMBID string `json:"lidarr_track_mbid"` ArtistName string `json:"artist_name"` AlbumTitle string `json:"album_title"` TrackTitle string `json:"track_title"` } // handleCreateRequest implements POST /api/requests. func (h *handlers) handleCreateRequest(w http.ResponseWriter, r *http.Request) { user, ok := requireUser(w, r) if !ok { return } var body createRequestBody if !decodeBody(w, r, &body) { return } row, err := h.lidarrRequests.Create(r.Context(), user.ID, lidarrrequests.CreateParams{ Kind: body.Kind, LidarrArtistMBID: body.LidarrArtistMBID, LidarrAlbumMBID: body.LidarrAlbumMBID, LidarrTrackMBID: body.LidarrTrackMBID, ArtistName: body.ArtistName, AlbumTitle: body.AlbumTitle, TrackTitle: body.TrackTitle, }) if err != nil { if errors.Is(err, lidarrrequests.ErrInvalidKindFields) { writeErr(w, apierror.BadRequest("mbid_required", err.Error())) return } h.logger.Error("api: create request", "err", err) writeErr(w, apierror.InternalMsg("create failed", err)) return } // Auto-approve flow (#355 / #376 U2.5): when the user has the // auto_approve_requests flag set by an admin, transition the // just-created pending row through Approve so it dispatches to // Lidarr immediately. Failures here (Lidarr disabled, defaults // missing, network error) leave the row pending — same fallback // as a manual admin approve that hits the same error path. The // user gets a 201 with whatever status Approve produced; admin // can still resolve the row in /admin/requests if it stayed // pending. // // The "actor" id passed to Approve is the user themselves — the // auto-approval is the user acting under the privilege the admin // granted them via the toggle. The trail of "admin set the flag" // already lives in audit_log (ActionAutoApproveToggle from U2). if user.AutoApproveRequests { approved, aerr := h.lidarrRequests.Approve(r.Context(), row.ID, user.ID, lidarrrequests.ApproveOverrides{}) if aerr == nil { row = approved } else { h.logger.Warn("api: auto-approve failed; request stays pending", "user_id", uuidToString(user.ID), "request_id", uuidToString(row.ID), "err", aerr) } } h.publishRequestStatusChanged(row) writeJSON(w, http.StatusCreated, requestViewFrom(row)) } // handleListRequests implements GET /api/requests — returns caller's own requests. func (h *handlers) handleListRequests(w http.ResponseWriter, r *http.Request) { user, ok := requireUser(w, r) if !ok { return } rows, err := h.lidarrRequests.ListForUser(r.Context(), user.ID, 100) if err != nil { h.logger.Error("api: list requests", "err", err) writeErr(w, apierror.InternalMsg("list failed", err)) return } q := dbq.New(h.pool) out := make([]requestView, 0, len(rows)) for _, row := range rows { v := requestViewFrom(row) fillProgress(r.Context(), q, row, &v) out = append(out, v) } writeJSON(w, http.StatusOK, out) } // handleGetRequest implements GET /api/requests/:id. // The caller must own the request, or be an admin. func (h *handlers) handleGetRequest(w http.ResponseWriter, r *http.Request) { user, ok := requireUser(w, r) if !ok { return } row, apiErr := resolveByID(r, "id", dbq.New(h.pool).GetLidarrRequestByID, "request") if apiErr != nil { writeErr(w, apiErr) return } // Allow if caller owns the request or is an admin. if row.UserID != user.ID && !user.IsAdmin { writeErr(w, apierror.NotFound("request")) return } v := requestViewFrom(row) fillProgress(r.Context(), dbq.New(h.pool), row, &v) writeJSON(w, http.StatusOK, v) } // handleCancelRequest implements DELETE /api/requests/:id. // Cancels the caller's own pending request. func (h *handlers) handleCancelRequest(w http.ResponseWriter, r *http.Request) { user, ok := requireUser(w, r) if !ok { return } id, ok := requireURLUUID(w, r, "id") if !ok { return } row, err := h.lidarrRequests.Cancel(r.Context(), id, user.ID) if err != nil { switch { case errors.Is(err, lidarrrequests.ErrNotPending): writeErr(w, apierror.Conflict("request_not_pending", "request is not pending")) case errors.Is(err, lidarrrequests.ErrNotFound): writeErr(w, apierror.NotFound("request")) default: h.logger.Error("api: cancel request", "err", err) writeErr(w, apierror.InternalMsg("cancel failed", err)) } return } h.publishRequestStatusChanged(row) writeJSON(w, http.StatusOK, requestViewFrom(row)) }