test-go / test (push) Failing after 55s
test-web / test (push) Successful in 56s
test-go / integration (push) Failing after 4m50s
android / Build + lint + test (push) Successful in 5m52s
release / Build signed APK (releases and dev) (push) Successful in 6m5s
release / Build + push container image (push) Successful in 1m14s
release / Verify release artifacts (tag releases only) (push) Skipped
Two delete paths had opposite failure policies. tracks.RemoveTrack
logged a failed os.Remove and deleted the row anyway, which CASCADEs
likes, plays, playlist memberships and tags, while the file survived
for the next scan to re-import as a stranger. library.DeleteTrackFile
stopped correctly but reported it as a bare 500 nobody could read.
One path now: library.DeleteTrackFile removes the file first and, on
anything but ErrNotExist, returns *FileRemoveError with nothing
deleted. Only then does it delete the row and tidy an emptied album
and artist in one transaction, log the sync change and clear orphaned
artist art. RemoveTrack calls it, which also fixes RemoveTrack never
logging a sync change. Quarantine Delete file now tidies emptied
albums and artists too.
Both endpoints answer an unwritable library (EROFS, EACCES, EPERM) with
409 library_not_writable. The message names the directory (removal
writes to the parent), the uid:gid the server runs as, and that
nothing was deleted. Other remove errors are 500 file_delete_failed
with the path.
The reachable surface is quarantine Delete file, which failed
silently: no copy for the code on either client, and Android swallowed
the exception so the row just reappeared. Web and Android now have
copy for both codes and append the server message for exactly those
two. Android's quarantine screen shows it in a snackbar.
DELETE /api/admin/tracks/{id} has had no client since f7278f24, which
kept it on purpose for a safer admin surface, so its history loss was
latent. Fixed rather than removed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
258 lines
9.2 KiB
Go
258 lines
9.2 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 {
|
|
// Written in the enveloped shape, not writeAdminJSONErr's bare code: the
|
|
// message is the part that tells the operator which directory and uid.
|
|
if apiErr, ok := fileRemoveAPIError(err); ok {
|
|
logFileRemoveFailure(h.logger, apiErr, "track_id", uuidToString(id))
|
|
writeErr(w, apiErr)
|
|
return
|
|
}
|
|
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)
|
|
}
|