Files
minstrel/internal/api/admin_quarantine.go
bvandeusen ee7f0cdb42 feat(#392): publish quarantine + playlist mutation events
Slice 3a — extends the producer set onto the SSE bus.

quarantine events (5 producer sites):
- quarantine.flagged (user-side flag): broadcast so the flagger's other
  clients invalidate their Hidden tab and admins' clients invalidate
  their queue.
- quarantine.unflagged (user-side unflag): scoped to the user.
- quarantine.resolved / .file_deleted / .deleted_via_lidarr (admin
  actions): broadcast because a single admin action can affect every
  user who'd flagged that track.

playlist events (6 producer sites):
- playlist.created / .updated / .deleted: owner-scoped.
- playlist.tracks_changed: emitted for AppendTracks / RemoveTrack /
  Reorder. Owner-scoped. Single kind for all three mutations because
  the client invalidation logic is identical (refetch the detail).

Public-playlist subscribers (other users viewing someone else's public
playlist) intentionally left out — needs a separate broadcast kind, not
exercised yet at single-household scale.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 21:28:05 -04:00

251 lines
8.9 KiB
Go

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
}
// Broadcast: affects every user who'd flagged this track.
h.publishQuarantineEvent("quarantine.resolved", pgtype.UUID{}, id, true)
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
}
// Broadcast: the track row is gone; every client's library/likes/queue
// invalidates so stale references stop showing.
h.publishQuarantineEvent("quarantine.file_deleted", pgtype.UUID{}, id, true)
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
}
// Broadcast: same rationale as delete-file — track row gone everywhere.
h.publishQuarantineEvent("quarantine.deleted_via_lidarr", pgtype.UUID{}, id, true)
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)
}