ee7f0cdb42
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>
251 lines
8.9 KiB
Go
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)
|
|
}
|