Files
minstrel/internal/api/requests.go
T

280 lines
9.7 KiB
Go

package api
import (
"context"
"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/apierror"
"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"`
// 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 := auth.UserFromContext(r.Context())
if !ok {
writeErr(w, apierror.ErrUnauthorized)
return
}
var body createRequestBody
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeErr(w, apierror.BadRequest("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, 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)
}
}
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, apierror.ErrUnauthorized)
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 := auth.UserFromContext(r.Context())
if !ok {
writeErr(w, apierror.ErrUnauthorized)
return
}
id, ok := parseUUID(chi.URLParam(r, "id"))
if !ok {
writeErr(w, apierror.BadRequest("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, apierror.NotFound("request"))
return
}
h.logger.Error("api: get request", "err", err)
writeErr(w, apierror.InternalMsg("get failed", err))
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 := auth.UserFromContext(r.Context())
if !ok {
writeErr(w, apierror.ErrUnauthorized)
return
}
id, ok := parseUUID(chi.URLParam(r, "id"))
if !ok {
writeErr(w, apierror.BadRequest("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, 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
}
writeJSON(w, http.StatusOK, requestViewFrom(row))
}