package api import ( "encoding/json" "errors" "net/http" "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" "git.fabledsword.com/bvandeusen/minstrel/internal/auth" "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"` } 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, } } // 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 := auth.UserFromContext(r.Context()) if !ok { writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required") return } var body createRequestBody if err := json.NewDecoder(r.Body).Decode(&body); err != nil { writeErr(w, http.StatusBadRequest, "bad_request", "invalid JSON 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, http.StatusBadRequest, "mbid_required", err.Error()) return } h.logger.Error("api: create request", "err", err) writeErr(w, http.StatusInternalServerError, "server_error", "create failed") return } 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 := auth.UserFromContext(r.Context()) if !ok { writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required") return } rows, err := h.lidarrRequests.ListForUser(r.Context(), user.ID, 100) if err != nil { h.logger.Error("api: list requests", "err", err) writeErr(w, http.StatusInternalServerError, "server_error", "list failed") return } out := make([]requestView, 0, len(rows)) for _, row := range rows { out = append(out, requestViewFrom(row)) } 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 := auth.UserFromContext(r.Context()) if !ok { writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required") return } id, ok := parseUUID(chi.URLParam(r, "id")) if !ok { writeErr(w, http.StatusBadRequest, "bad_request", "invalid request id") return } row, err := dbq.New(h.pool).GetLidarrRequestByID(r.Context(), id) if err != nil { if errors.Is(err, pgx.ErrNoRows) { writeErr(w, http.StatusNotFound, "request_not_found", "request not found") return } h.logger.Error("api: get request", "err", err) writeErr(w, http.StatusInternalServerError, "server_error", "get failed") return } // Allow if caller owns the request or is an admin. if row.UserID != user.ID && !user.IsAdmin { writeErr(w, http.StatusNotFound, "request_not_found", "request not found") return } writeJSON(w, http.StatusOK, requestViewFrom(row)) } // 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 := auth.UserFromContext(r.Context()) if !ok { writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required") return } id, ok := parseUUID(chi.URLParam(r, "id")) if !ok { writeErr(w, http.StatusBadRequest, "bad_request", "invalid request id") return } row, err := h.lidarrRequests.Cancel(r.Context(), id, user.ID) if err != nil { switch { case errors.Is(err, lidarrrequests.ErrNotPending): writeErr(w, http.StatusConflict, "request_not_pending", "request is not pending") case errors.Is(err, lidarrrequests.ErrNotFound): writeErr(w, http.StatusNotFound, "request_not_found", "request not found") default: h.logger.Error("api: cancel request", "err", err) writeErr(w, http.StatusInternalServerError, "server_error", "cancel failed") } return } writeJSON(w, http.StatusOK, requestViewFrom(row)) }