package api import ( "net/http" "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"` } // 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. // // Read-only. A marker is a reason to look, not proof: a band can title a song // "Music Video". What to do about a flagged file — merge it in Duplicates, // quarantine it, replace it in Lidarr — stays the operator's call. 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), } 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 }