Files
minstrel/internal/api/admin_suspect_sources.go
T
bvandeusenandClaude Opus 5.5 08a2c06b85 docs: api comments say what the endpoints do, not how they came to be (#6277)
Rewrites the internal/api comment blocks to rule 32: task, milestone and
rule ids gone, the stories behind them restated as the constraint they
left, stale references corrected (the stream secret is wired from config,
the timezone handler already refreshes the scheduler, the Android app is
the native client, the design docs cited are not in the repo). Also
rewraps comment lines over 80 columns left by the earlier batches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:23:58 -04:00

171 lines
6.0 KiB
Go

package api
import (
"encoding/json"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
)
// suspectTrackView is one flagged track. FilePath is the evidence: the
// markers are read from its basename, and an admin needs to see it to
// judge whether the flag is right.
type suspectTrackView struct {
TrackID string `json:"track_id"`
Title string `json:"title"`
ArtistID string `json:"artist_id"`
ArtistName string `json:"artist_name"`
AlbumID string `json:"album_id"`
AlbumTitle string `json:"album_title"`
FilePath string `json:"file_path"`
DurationSec int32 `json:"duration_sec"`
DiscNumber *int32 `json:"disc_number"`
TrackNumber *int32 `json:"track_number"`
Markers []string `json:"markers"`
// Verdict is "suspect" while the track is held back from radio and the
// mixes, "fine" once an admin let it back, null before the resolver's
// first pass. VerdictAt is when it was last set.
Verdict *string `json:"verdict"`
VerdictAt *string `json:"verdict_at"`
// Replacing: the album is on the wanted list for a clean copy;
// SearchedAt is when one was last searched for, null before the first.
Replacing bool `json:"replacing"`
SearchedAt *string `json:"searched_at"`
}
// suspectGroupView is a folder's worth of flagged tracks. As on the
// missing-files report, the folder is the unit of decision: Humanz was 62 rows
// in one folder, which is one problem to look at, not 62.
type suspectGroupView struct {
Directory string `json:"directory"`
Tracks []suspectTrackView `json:"tracks"`
}
// adminSuspectSourcesResponse is the paged envelope. Total counts tracks.
type adminSuspectSourcesResponse struct {
Total int64 `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
Groups []suspectGroupView `json:"groups"`
}
// handleListSuspectSources implements GET /api/admin/library/suspect-sources.
//
// A marker is a reason to look, not proof: a band can title a song "Music
// Video". The duplicate resolver holds a flagged track back from radio and the
// mixes and merges it away where a clean copy exists; this report
// is where an admin sees that and can say a track is fine.
func (h *handlers) handleListSuspectSources(w http.ResponseWriter, r *http.Request) {
limit, offset, err := parsePaging(r.URL.Query())
if err != nil {
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_paging")
return
}
q := dbq.New(h.pool)
total, err := q.CountSuspectSourceTracks(r.Context(), library.SuspectSourcePattern)
if err != nil {
h.logger.Error("admin: count suspect-source tracks", "err", err)
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
return
}
rows, err := q.ListSuspectSourceTracks(r.Context(), dbq.ListSuspectSourceTracksParams{
Pattern: library.SuspectSourcePattern,
PageLimit: int32(limit),
PageOffset: int32(offset),
})
if err != nil {
h.logger.Error("admin: list suspect-source tracks", "err", err)
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
return
}
writeJSON(w, http.StatusOK, adminSuspectSourcesResponse{
Total: total,
Limit: limit,
Offset: offset,
Groups: groupSuspectByDirectory(rows),
})
}
// groupSuspectByDirectory folds the directory-ordered rows into per-folder
// groups, the same run-length fold as groupMissingByDirectory. A page boundary
// can split a folder; the web page joins the halves.
func groupSuspectByDirectory(rows []dbq.ListSuspectSourceTracksRow) []suspectGroupView {
groups := make([]suspectGroupView, 0, 8)
for _, row := range rows {
t := suspectTrackView{
TrackID: uuidToString(row.ID),
Title: row.Title,
ArtistID: uuidToString(row.ArtistID),
ArtistName: row.ArtistName,
AlbumID: uuidToString(row.AlbumID),
AlbumTitle: row.AlbumTitle,
FilePath: row.FilePath,
DurationSec: row.DurationMs / 1000,
DiscNumber: row.DiscNumber,
TrackNumber: row.TrackNumber,
Markers: library.SourceMarkersFor(row.FilePath),
Verdict: row.SourceVerdict,
}
if row.SourceVerdictAt.Valid {
at := row.SourceVerdictAt.Time.UTC().Format(time.RFC3339)
t.VerdictAt = &at
}
t.Replacing = row.WantUpgrade == "rip"
if t.Replacing && row.SearchedAt.Valid {
at := row.SearchedAt.Time.UTC().Format(time.RFC3339)
t.SearchedAt = &at
}
if n := len(groups); n > 0 && groups[n-1].Directory == row.Directory {
groups[n-1].Tracks = append(groups[n-1].Tracks, t)
continue
}
groups = append(groups, suspectGroupView{
Directory: row.Directory,
Tracks: []suspectTrackView{t},
})
}
return groups
}
type suspectVerdictRequest struct {
Verdict string `json:"verdict"`
}
// handleSetSuspectSourceVerdict implements PUT
// /api/admin/library/suspect-sources/{id}: {"verdict": "fine"} lets a flagged
// track back into radio and the mixes for good, {"verdict": "suspect"} holds
// it back again. 404 suspect_track_not_found when the track was never flagged.
func (h *handlers) handleSetSuspectSourceVerdict(w http.ResponseWriter, r *http.Request) {
id, ok := parseUUID(chi.URLParam(r, "id"))
if !ok {
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_id")
return
}
var req suspectVerdictRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_body")
return
}
if req.Verdict != "fine" && req.Verdict != "suspect" {
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_verdict")
return
}
n, err := dbq.New(h.pool).SetTrackSourceVerdict(r.Context(), dbq.SetTrackSourceVerdictParams{Verdict: req.Verdict, ID: id})
if err != nil {
h.logger.Error("admin: set suspect-source verdict", "err", err)
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
return
}
if n == 0 {
writeAdminJSONErr(w, http.StatusNotFound, "suspect_track_not_found")
return
}
writeJSON(w, http.StatusOK, map[string]string{"verdict": req.Verdict})
}