release / govulncheck (push) Successful in 25s
release / web (push) Failing after 26s
release / go (push) Failing after 58s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / integration (push) Canceled after 3m25s
release / Build signed APK (releases and dev) (push) Canceled after 3m26s
release / android (push) Canceled after 3m28s
- The resolver also merges cross-release and mismatch groups where Lidarr
maps exactly one copy: the others fulfil nothing, so removing them opens no
hole (D-a rule 1). That covers a rip beside the clean copy on another
release and a wrong-file import Lidarr holds unmapped. With two or more
mapped copies each fulfils its own release and nothing is removed.
- A track whose file name carries a video-rip marker is held back from radio
and the system mixes (tracks.source_verdict, migration 0077). It still plays
when chosen. A renamed file is released; the operator's "fine" sticks.
- Suspect sources shows what was done to each track, with "This one is fine"
and "Hold back again" (PUT /api/admin/library/suspect-sources/{id}).
- The Liked list prefers a copy that is not held back.
Replacing a rip that has no clean copy is left for the operator to decide.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
162 lines
5.6 KiB
Go
162 lines
5.6 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"`
|
|
}
|
|
|
|
// 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})
|
|
}
|