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 the operator 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 the operator let it back, null before the resolver's // first pass (M498 #5439). VerdictAt is when it was last set. Verdict *string `json:"verdict"` VerdictAt *string `json:"verdict_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 (M498 #5439); this report // is where the operator 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 } 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}) }