Files
minstrel/internal/api/admin_suspect_sources.go
T
bvandeusenandClaude Opus 5.5 c54792c51e
release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m23s
release / go (push) Successful in 1m41s
release / integration (push) Successful in 5m20s
release / android (push) Successful in 5m28s
release / Build signed APK (releases and dev) (push) Successful in 5m30s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 27s
release / Verify release artifacts (tag releases only) (push) Skipped
feat: a held album below the quality you settle for, or holding a video rip, stays wanted for a better copy, and the import replaces the old tracks with their likes and plays carried over (M502 #5524)
- acquisition_settings.upgrade_until (default High quality lossy, Lidarr's
  cutoff, note #5527; "" is off), an "Upgrade until" row under Searching.
- The wanted worker judges held albums hourly: tier per format from size per
  second (the scanner records no bitrate), the album's worst. Below the
  cutoff it is wanted for quality; a video rip among its tracks always.
- The schedule searches upgrades weekly, after open wants; a release no
  better than the copy held is refused ("not_better").
- The import matches each file to the track it would replace (recording,
  then disc/track) and replaces it when it is a rip or the file is a better
  tier: over the same path in one rename, or filed beside it and the old
  track folded in (library.ReplaceTrack: file first, then foldTrackInto).
  Nothing better: the release is set aside.
- Collection › Upgrades (Lidarr's Cutoff Unmet); Suspect sources says when a
  clean copy was looked for instead of Lidarr's search.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:24:59 -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 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"`
// Replacing: the album is on the wanted list for a clean copy (#5524);
// 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 (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
}
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})
}