package api import ( "errors" "net/http" "strconv" "github.com/go-chi/chi/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/lidarr" "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine" ) // adminQueueRowView is a single aggregated row in the admin queue response. type adminQueueRowView struct { TrackID string `json:"track_id"` TrackTitle string `json:"track_title"` ArtistName string `json:"artist_name"` AlbumTitle string `json:"album_title"` AlbumID string `json:"album_id"` LidarrAlbumMBID *string `json:"lidarr_album_mbid,omitempty"` ReportCount int32 `json:"report_count"` LatestAt string `json:"latest_at"` ReasonCounts map[string]int `json:"reason_counts"` Reports []adminQueueReportView `json:"reports"` } // adminQueueReportView is one user's report under an aggregated admin row. type adminQueueReportView struct { UserID string `json:"user_id"` Username string `json:"username"` Reason string `json:"reason"` Notes *string `json:"notes,omitempty"` CreatedAt string `json:"created_at"` } // formatTimestamp renders a pgtype.Timestamptz as RFC3339 or empty string. func formatTimestamp(ts pgtype.Timestamptz) string { if !ts.Valid { return "" } return ts.Time.Format("2006-01-02T15:04:05Z07:00") } // handleListAdminQuarantine implements GET /api/admin/quarantine. func (h *handlers) handleListAdminQuarantine(w http.ResponseWriter, r *http.Request) { rows, err := h.lidarrQuarantine.ListAdminQueue(r.Context()) if err != nil { h.logger.Error("admin: list quarantine", "err", err) writeAdminJSONErr(w, http.StatusInternalServerError, "server_error") return } out := make([]adminQueueRowView, 0, len(rows)) for _, row := range rows { reports := make([]adminQueueReportView, 0, len(row.Reports)) for _, rep := range row.Reports { reports = append(reports, adminQueueReportView{ UserID: uuidToString(rep.UserID), Username: rep.Username, Reason: rep.Reason, Notes: rep.Notes, CreatedAt: formatTimestamp(rep.CreatedAt), }) } out = append(out, adminQueueRowView{ TrackID: uuidToString(row.TrackID), TrackTitle: row.TrackTitle, ArtistName: row.ArtistName, AlbumTitle: row.AlbumTitle, AlbumID: uuidToString(row.AlbumID), LidarrAlbumMBID: row.LidarrAlbumMBID, ReportCount: row.ReportCount, LatestAt: formatTimestamp(row.LatestAt), ReasonCounts: row.ReasonCounts, Reports: reports, }) } writeJSON(w, http.StatusOK, out) } // actionResultView is the response shape for the three admin destructive // actions (resolve, delete-file, delete-via-lidarr). type actionResultView struct { ActionID string `json:"action_id"` AffectedUsers int32 `json:"affected_users"` DeletedTrackCount *int `json:"deleted_track_count,omitempty"` } // handleResolveQuarantine implements POST /api/admin/quarantine/{track_id}/resolve. func (h *handlers) handleResolveQuarantine(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, "track_id")) if !ok { writeAdminJSONErr(w, http.StatusBadRequest, "invalid_id") return } action, err := h.lidarrQuarantine.Resolve(r.Context(), id, admin.ID) if err != nil { if errors.Is(err, lidarrquarantine.ErrTrackNotFound) { writeAdminJSONErr(w, http.StatusNotFound, "track_not_found") return } h.logger.Error("admin: resolve quarantine", "err", err) writeAdminJSONErr(w, http.StatusInternalServerError, "server_error") return } writeJSON(w, http.StatusOK, actionResultView{ ActionID: uuidToString(action.ID), AffectedUsers: action.AffectedUsers, }) } // handleDeleteQuarantineFile implements POST /api/admin/quarantine/{track_id}/delete-file. func (h *handlers) handleDeleteQuarantineFile(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, "track_id")) if !ok { writeAdminJSONErr(w, http.StatusBadRequest, "invalid_id") return } action, err := h.lidarrQuarantine.DeleteFile(r.Context(), id, admin.ID) if err != nil { switch { case errors.Is(err, lidarrquarantine.ErrTrackNotFound): writeAdminJSONErr(w, http.StatusNotFound, "track_not_found") default: h.logger.Error("admin: delete file", "err", err) writeAdminJSONErr(w, http.StatusInternalServerError, "file_delete_failed") } return } writeJSON(w, http.StatusOK, actionResultView{ ActionID: uuidToString(action.ID), AffectedUsers: action.AffectedUsers, }) } // handleDeleteQuarantineViaLidarr implements POST /api/admin/quarantine/{track_id}/delete-via-lidarr. func (h *handlers) handleDeleteQuarantineViaLidarr(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, "track_id")) if !ok { writeAdminJSONErr(w, http.StatusBadRequest, "invalid_id") return } action, deleted, err := h.lidarrQuarantine.DeleteViaLidarr(r.Context(), id, admin.ID) if err != nil { switch { case errors.Is(err, lidarrquarantine.ErrLidarrDisabled): writeAdminJSONErr(w, http.StatusServiceUnavailable, "lidarr_disabled") case errors.Is(err, lidarrquarantine.ErrTrackNotFound): writeAdminJSONErr(w, http.StatusNotFound, "track_not_found") case errors.Is(err, lidarrquarantine.ErrAlbumMBIDMissing): writeAdminJSONErr(w, http.StatusNotFound, "album_mbid_missing") case errors.Is(err, lidarrquarantine.ErrLidarrAlbumNotFound): writeAdminJSONErr(w, http.StatusBadGateway, "lidarr_album_lookup_failed") case errors.Is(err, lidarr.ErrUnreachable): writeAdminJSONErr(w, http.StatusServiceUnavailable, "lidarr_unreachable") case errors.Is(err, lidarr.ErrAuthFailed): writeAdminJSONErr(w, http.StatusServiceUnavailable, "lidarr_auth_failed") default: h.logger.Error("admin: delete via lidarr", "err", err) writeAdminJSONErr(w, http.StatusInternalServerError, "server_error") } return } writeJSON(w, http.StatusOK, actionResultView{ ActionID: uuidToString(action.ID), AffectedUsers: action.AffectedUsers, DeletedTrackCount: &deleted, }) } // actionLogView is the row shape returned by GET /api/admin/quarantine/actions. type actionLogView struct { ID string `json:"id"` TrackID string `json:"track_id"` TrackTitle string `json:"track_title"` ArtistName string `json:"artist_name"` AlbumTitle *string `json:"album_title,omitempty"` Action string `json:"action"` AdminID *string `json:"admin_id,omitempty"` LidarrAlbumMBID *string `json:"lidarr_album_mbid,omitempty"` AffectedUsers int32 `json:"affected_users"` CreatedAt string `json:"created_at"` } // handleListQuarantineActions implements GET /api/admin/quarantine/actions. // ?limit= defaults to 50, capped at 200. func (h *handlers) handleListQuarantineActions(w http.ResponseWriter, r *http.Request) { limitStr := r.URL.Query().Get("limit") limit := int32(50) if limitStr != "" { if v, err := strconv.Atoi(limitStr); err == nil && v > 0 { if v > 200 { v = 200 } limit = int32(v) } } rows, err := dbq.New(h.pool).ListQuarantineActions(r.Context(), limit) if err != nil { h.logger.Error("admin: list actions", "err", err) writeAdminJSONErr(w, http.StatusInternalServerError, "server_error") return } out := make([]actionLogView, 0, len(rows)) for _, row := range rows { var adminID *string if row.AdminID.Valid { s := uuidToString(row.AdminID) adminID = &s } out = append(out, actionLogView{ ID: uuidToString(row.ID), TrackID: uuidToString(row.TrackID), TrackTitle: row.TrackTitle, ArtistName: row.ArtistName, AlbumTitle: row.AlbumTitle, Action: string(row.Action), AdminID: adminID, LidarrAlbumMBID: row.LidarrAlbumMbid, AffectedUsers: row.AffectedUsers, CreatedAt: formatTimestamp(row.CreatedAt), }) } writeJSON(w, http.StatusOK, out) }