feat: admin Suspect sources report, for files named like video rips (#5410)
release / govulncheck (push) Successful in 37s
release / go (push) Failing after 1m10s
release / web (push) Failing after 1m24s
release / integration (push) Successful in 4m38s
release / android (push) Successful in 5m5s
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 / Build signed APK (releases and dev) (push) Canceled after 5m18s

Humanz turned out to be YouTube rips: "(Official Video)", "Visualizer", a
reaction video filed as a song, junk disc numbers (#5401). The library holds
about 200 more files named the same way. This report lists them, grouped by
folder like Missing files, with the markers each file name carries.

- GET /api/admin/library/suspect-sources: one marker list in Go builds both
  the Postgres ~* filter and each row's labels, so they cannot drift.
  Basename only; missing files are left out.
- Markers calibrated on the live library: "live in/at" dropped (real live
  albums), "reaction" narrowed (it caught "Chain Reaction").
- Admin tab "Suspect sources", read-only, loads as you scroll; a folder split
  across pages is joined back into one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 15:51:01 -04:00
co-authored by Claude Opus 5.5
parent 918db27fec
commit 3f0540cb7a
12 changed files with 775 additions and 2 deletions
+173
View File
@@ -0,0 +1,173 @@
package api
import (
"net/http"
"path"
"regexp"
"strings"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// sourceMarker is one sign that a file was ripped from a video rather than
// taken from a release: words a video title carries and an album track does
// not (#5410).
//
// Each pattern is used twice, so it is written in the subset that Postgres's
// regex flavour and Go's RE2 read the same way: no \b (Postgres spells word
// boundaries \y), no lookaround, and brackets written as (\(|\[) rather than
// as a bracket expression. The SQL side matches it case-insensitively with ~*,
// the Go side with (?i); [^a-zA-Z] is spelled out so neither has to fold case
// inside a negated class.
type sourceMarker struct {
label string
pattern string
}
// suspectSourceMarkers was calibrated against the operator's library on
// 2026-10-08. Two candidates were left out on purpose:
// - "live in"/"live at": 229 files, nearly all from real live albums.
// - a bare "reaction": it caught Beck's "Chain Reaction". The marker below
// wants the phrases a reaction video actually uses.
var suspectSourceMarkers = []sourceMarker{
{"music video", `official[^a-zA-Z]*(music[^a-zA-Z]*)?video|music[^a-zA-Z]*video`},
{"official audio", `official[^a-zA-Z]*audio`},
{"lyric video", `lyrics?[^a-zA-Z]*video`},
{"visualiser", `visuali[sz]er`},
{"reaction", `reaction[^a-zA-Z]*(video|mashup)|reacts?[^a-zA-Z]+to[^a-zA-Z]|first[^a-zA-Z]*time[^a-zA-Z]*(hearing|listening)`},
{"MV", `(^|[^a-zA-Z])(mv|m/v)([^a-zA-Z]|$)`},
{"[Audio]", `(\(|\[)audio(\)|\])`},
{"[HD]", `(\(|\[)(hd|hq|4k)(\)|\])`},
}
// suspectSourcePattern is every marker as one alternation, for the SQL filter.
var suspectSourcePattern = func() string {
parts := make([]string, len(suspectSourceMarkers))
for i, m := range suspectSourceMarkers {
parts[i] = "(" + m.pattern + ")"
}
return strings.Join(parts, "|")
}()
var suspectSourceRegexps = func() []*regexp.Regexp {
out := make([]*regexp.Regexp, len(suspectSourceMarkers))
for i, m := range suspectSourceMarkers {
out[i] = regexp.MustCompile("(?i)" + m.pattern)
}
return out
}()
// sourceMarkersFor returns the labels of every marker the file's basename
// carries, in list order. Empty for a file the SQL filter would not return.
func sourceMarkersFor(filePath string) []string {
base := path.Base(filePath)
labels := make([]string, 0, 2)
for i, re := range suspectSourceRegexps {
if re.MatchString(base) {
labels = append(labels, suspectSourceMarkers[i].label)
}
}
return labels
}
// 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(), 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: 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: 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
}
+113
View File
@@ -0,0 +1,113 @@
package api
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"slices"
"testing"
)
// Real basenames from the operator's library (2026-10-08), each with the
// labels it should carry. The negatives are the near misses that shaped the
// list: a song called "Chain Reaction", a live album, an ordinary track.
func TestSourceMarkersFor(t *testing.T) {
cases := []struct {
base string
want []string
}{
{"Daft Punk - Random Access Memories - 06 - Daft Punk - Doin' It Right (Music Video) ft. Panda Bear.mp3", []string{"music video"}},
{"Gorillaz - Humanz - 03 - Gorillaz - Saturnz Barz (Official Video).mp3", []string{"music video"}},
{"Gorillaz - Humanz - 05 - Gorillaz - Andromeda (Official Audio).mp3", []string{"official audio"}},
{"Gorillaz - Gorillaz - 06 - Gorillaz - P45 (Visualizer).mp3", []string{"visualiser"}},
{"Gorillaz - Demon Days - 16 - Gorillaz - Don Quixote's Christmas Bonanza (Visualiser).mp3", []string{"visualiser"}},
{"Artist - Album - 01 - Artist - Song (Lyric Video).mp3", []string{"lyric video"}},
{"Gorillaz - Humanz - 02 - FIRST TIME HEARING Gorillaz - Ascension REACTION.mp3", []string{"reaction"}},
{"Watsky - INTENTION - 05 - MANIAC Reacts to Watsky - AWW SHiT.mp3", []string{"reaction"}},
{"米津玄師 - diorama - 09 - 【MV】米津玄師 - 恋と病熱.mp3", []string{"MV"}},
{"Andora - Ego - 01 - Andora - Ego (feat. Will Stetson) MV.mp3", []string{"MV"}},
{"Jimmy Eat World - Something(s) Loud - 05 - Jimmy Eat World - Call to Love (Audio).mp3", []string{"[Audio]"}},
{"Record Heat - World War IV - 03 - Record Heat - Front Seat Feelin' [Audio].mp3", []string{"[Audio]"}},
{"Aphex Twin - Come To Daddy - 03 - Aphex Twin - Bucephalus Bouncing Ball (HQ).mp3", []string{"[HD]"}},
{"Artist - Album - 01 - Artist - Song (Official Lyric Video) [4K].mp3", []string{"lyric video", "[HD]"}},
{"Beck - Guero - 15 - Beck - Chain Reaction.mp3", nil},
{"Nirvana - MTV Unplugged in New York - 01 - About a Girl (live in New York).flac", nil},
{"Boards of Canada - Music Has the Right to Children - 05 - Roygbiv.flac", nil},
{"Artist - Album - 01 - Mvula.flac", nil},
}
for _, c := range cases {
got := sourceMarkersFor("/music/x/" + c.base)
if !slices.Equal(got, c.want) && !(len(got) == 0 && len(c.want) == 0) {
t.Errorf("%s:\n got %v\n want %v", c.base, got, c.want)
}
}
}
// The folder is not evidence: a marker word in a directory name must not
// flag the files inside it.
func TestSourceMarkersFor_ReadsOnlyTheBasename(t *testing.T) {
if got := sourceMarkersFor("/music/Official Video Collection/01 - Song.flac"); len(got) != 0 {
t.Errorf("directory name flagged the file: %v", got)
}
}
// The integration test runs the same alternation through Postgres's ~*, so the
// one pattern is exercised in both regex dialects it has to work in.
func TestHandleListSuspectSources_Integration(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
admin := seedUser(t, pool, "suspect-admin", "pw", true)
artist := seedArtist(t, pool, "Gorillaz")
humanz := seedAlbum(t, pool, artist.ID, "Humanz", 2017)
seedTrack(t, pool, humanz.ID, artist.ID, "Saturnz Barz (Official Video)", 1, 180_000)
seedTrack(t, pool, humanz.ID, artist.ID, "Momentz (Visualizer)", 2, 200_000)
seedTrack(t, pool, humanz.ID, artist.ID, "Andromeda [Audio]", 3, 190_000)
seedTrack(t, pool, humanz.ID, artist.ID, "Chain Reaction", 4, 190_000)
seedTrack(t, pool, humanz.ID, artist.ID, "Busted and Blue", 5, 190_000)
gone := seedTrack(t, pool, humanz.ID, artist.ID, "Ascension (Lyric Video)", 6, 156_000)
if _, err := pool.Exec(context.Background(),
`UPDATE tracks SET missing_since = now() WHERE id = $1`, gone.ID); err != nil {
t.Fatalf("mark missing: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/api/admin/library/suspect-sources?limit=50", nil)
req = withUser(req, admin)
w := httptest.NewRecorder()
h.handleListSuspectSources(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status %d: %s", w.Code, w.Body.String())
}
var resp adminSuspectSourcesResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
// Three present files carry a marker. "Chain Reaction" and "Busted and
// Blue" carry none, and the lyric video's file is missing.
if resp.Total != 3 {
t.Errorf("total = %d, want 3", resp.Total)
}
if len(resp.Groups) != 1 {
t.Fatalf("groups = %d, want 1 (one folder)", len(resp.Groups))
}
got := map[string][]string{}
for _, tr := range resp.Groups[0].Tracks {
got[tr.Title] = tr.Markers
}
want := map[string][]string{
"Saturnz Barz (Official Video)": {"music video"},
"Momentz (Visualizer)": {"visualiser"},
"Andromeda [Audio]": {"[Audio]"},
}
if len(got) != len(want) {
t.Fatalf("tracks = %v, want %v", got, want)
}
for title, labels := range want {
if !slices.Equal(got[title], labels) {
t.Errorf("%s: markers %v, want %v", title, got[title], labels)
}
}
}
+1
View File
@@ -252,6 +252,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
// and because the destructive /tracks/{id} route above must
// not be mistaken for it (#2527).
admin.Get("/library/missing", h.handleListMissingTracks)
admin.Get("/library/suspect-sources", h.handleListSuspectSources)
admin.Get("/library/coverage", h.handleGetLibraryCoverage)
admin.Get("/library/fingerprints", h.handleGetFingerprintCoverage)