feat: Minstrel no longer talks to Lidarr: its connection, Delete via Lidarr and the unmonitor options are gone, and the track reports are plain quarantine (M502 #5525)
release / govulncheck (push) Successful in 15s
release / web (push) Failing after 17s
release / go (push) Successful in 1m13s
release / integration (push) Successful in 4m56s
release / android (push) Successful in 5m9s
release / Build signed APK (releases and dev) (push) Successful in 5m19s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped

Migration 0091 drops lidarr_config and renames the quarantine tables, types,
indexes and the history column off the Lidarr name. The Integrations Lidarr
card, the Reports "Delete via Lidarr" action, the merge and remove-track
unmonitor options, the Lidarr audit actions and error codes go, on the
server, web and Android. The README describes acquisition as it is now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-11 13:48:20 -04:00
co-authored by Claude Opus 5.5
parent c54792c51e
commit 3a16732883
170 changed files with 994 additions and 6242 deletions
+6 -194
View File
@@ -15,9 +15,6 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine"
)
// newAdminQuarantineRouter builds a test chi router with the five admin
@@ -30,7 +27,6 @@ func newAdminQuarantineRouter(h *handlers) chi.Router {
admin.Get("/quarantine", h.handleListAdminQuarantine)
admin.Post("/quarantine/{track_id}/resolve", h.handleResolveQuarantine)
admin.Post("/quarantine/{track_id}/delete-file", h.handleDeleteQuarantineFile)
admin.Post("/quarantine/{track_id}/delete-via-lidarr", h.handleDeleteQuarantineViaLidarr)
admin.Get("/quarantine/actions", h.handleListQuarantineActions)
})
return r
@@ -56,27 +52,11 @@ func doAdminQuarantineReq(t *testing.T, h *handlers, method, path string, body [
return w
}
// installQuarantineClientFn rewires h.lidarrQuarantine to a fresh Service
// with a real clientFn pointing at the (current) saved lidarr_config. This
// mirrors testHandlersWithClientFn for the requests test file.
func installQuarantineClientFn(t *testing.T, h *handlers) {
t.Helper()
cfg := lidarrconfig.New(h.pool)
clientFn := func() *lidarr.Client {
c, err := cfg.Get(context.Background())
if err != nil || !c.Enabled || c.BaseURL == "" {
return nil
}
return lidarr.NewClient(c.BaseURL, c.APIKey)
}
h.lidarrQuarantine = lidarrquarantine.NewService(h.pool, cfg, clientFn, h.dataDir)
}
// flagDirect bypasses the HTTP handler to seed a quarantine row via the
// Service for setup. Used to construct admin queue scenarios.
func flagDirect(t *testing.T, h *handlers, userID, trackID pgtype.UUID, reason string) {
t.Helper()
if _, err := h.lidarrQuarantine.Flag(context.Background(), userID, trackID, reason, ""); err != nil {
if _, err := h.quarantine.Flag(context.Background(), userID, trackID, reason, ""); err != nil {
t.Fatalf("flagDirect: %v", err)
}
}
@@ -110,49 +90,13 @@ func seedTrackOnDisk(t *testing.T, h *handlers, unique string) (dbq.Track, strin
return tr, path
}
// seedTrackWithAlbumMBID seeds an artist+album-with-mbid+track. The mbid is
// required for delete-via-lidarr scenarios.
func seedTrackWithAlbumMBID(t *testing.T, h *handlers, unique, albumMBID string) (dbq.Track, string) {
t.Helper()
artist := seedArtist(t, h.pool, "DvL Artist "+unique)
dir := t.TempDir()
path := filepath.Join(dir, "track-"+unique+".mp3")
if err := os.WriteFile(path, []byte("audio"), 0o644); err != nil {
t.Fatalf("write file: %v", err)
}
mbid := albumMBID
album, err := dbq.New(h.pool).UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{
Title: "DvL Album " + unique, SortTitle: "DvL Album " + unique,
ArtistID: artist.ID, Mbid: &mbid,
})
if err != nil {
t.Fatalf("UpsertAlbum: %v", err)
}
one := int32(1)
tr, err := dbq.New(h.pool).UpsertTrack(context.Background(), dbq.UpsertTrackParams{
Title: "DvL Track " + unique,
AlbumID: album.ID,
ArtistID: artist.ID,
TrackNumber: &one,
DurationMs: 30000,
FilePath: path,
FileSize: 5,
FileFormat: "mp3",
})
if err != nil {
t.Fatalf("UpsertTrack: %v", err)
}
return tr, path
}
// TestListAdminQuarantine_AggregatedShape seeds 3 users flagging the same
// track with mixed reasons. Verifies one aggregated row with report_count=3,
// reason_counts populated, and a Reports list of length 3.
func TestListAdminQuarantine_AggregatedShape(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
resetLidarrState(t, h)
installQuarantineClientFn(t, h)
resetRequests(t, h)
alice := seedUser(t, pool, "alice", "pw", false)
bob := seedUser(t, pool, "bob", "pw", false)
@@ -192,8 +136,7 @@ func TestListAdminQuarantine_AggregatedShape(t *testing.T) {
func TestResolveQuarantine_HappyPath(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
resetLidarrState(t, h)
installQuarantineClientFn(t, h)
resetRequests(t, h)
alice := seedUser(t, pool, "alice", "pw", false)
bob := seedUser(t, pool, "bob", "pw", false)
@@ -227,7 +170,7 @@ func TestResolveQuarantine_HappyPath(t *testing.T) {
if len(actions) != 1 {
t.Fatalf("audit rows = %d, want 1", len(actions))
}
if actions[0].Action != dbq.LidarrQuarantineActionKindResolved {
if actions[0].Action != dbq.QuarantineActionKindResolved {
t.Errorf("audit action = %v, want resolved", actions[0].Action)
}
}
@@ -237,8 +180,7 @@ func TestResolveQuarantine_HappyPath(t *testing.T) {
func TestDeleteQuarantineFile_HappyPath(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
resetLidarrState(t, h)
installQuarantineClientFn(t, h)
resetRequests(t, h)
alice := seedUser(t, pool, "alice", "pw", false)
admin := seedUser(t, pool, "admin", "pw", true)
@@ -268,141 +210,12 @@ func TestDeleteQuarantineFile_HappyPath(t *testing.T) {
}
}
// TestDeleteQuarantineViaLidarr_HappyPath uses a stub Lidarr that returns
// a single album for the lookup and 200 for the delete. Verifies the
// audit row is written and the local track row is deleted.
func TestDeleteQuarantineViaLidarr_HappyPath(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
resetLidarrState(t, h)
installQuarantineClientFn(t, h)
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.URL.Path == "/api/v1/album" && r.Method == http.MethodGet {
_, _ = w.Write([]byte(`[{"id":42,"foreignAlbumId":"al-mbid-dvl","title":"Al","artistId":7}]`))
return
}
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(stub.Close)
saveLidarrConfig(t, h, stub.URL, true)
installQuarantineClientFn(t, h) // re-install after config save so clientFn picks it up
alice := seedUser(t, pool, "alice", "pw", false)
admin := seedUser(t, pool, "admin", "pw", true)
track, _ := seedTrackWithAlbumMBID(t, h, "dvl-happy", "al-mbid-dvl")
flagDirect(t, h, alice.ID, track.ID, "bad_rip")
w := doAdminQuarantineReq(t, h, http.MethodPost,
"/api/admin/quarantine/"+uuidToString(track.ID)+"/delete-via-lidarr", nil, admin)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
}
var got actionResultView
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
}
if got.AffectedUsers != 1 {
t.Errorf("affected_users = %d, want 1", got.AffectedUsers)
}
if got.DeletedTrackCount == nil || *got.DeletedTrackCount != 1 {
t.Errorf("deleted_track_count = %v, want 1", got.DeletedTrackCount)
}
// Track row should be gone.
if _, err := dbq.New(pool).GetTrackByID(context.Background(), track.ID); err == nil {
t.Error("track row still exists after delete-via-lidarr")
}
// Audit row written with the lidarr_album_mbid.
actions, err := dbq.New(pool).ListQuarantineActions(context.Background(), 50)
if err != nil {
t.Fatalf("list actions: %v", err)
}
if len(actions) != 1 {
t.Fatalf("audit rows = %d, want 1", len(actions))
}
if actions[0].Action != dbq.LidarrQuarantineActionKindDeletedViaLidarr {
t.Errorf("audit action = %v, want deleted_via_lidarr", actions[0].Action)
}
if actions[0].LidarrAlbumMbid == nil || *actions[0].LidarrAlbumMbid != "al-mbid-dvl" {
t.Errorf("audit lidarr_album_mbid = %v", actions[0].LidarrAlbumMbid)
}
}
// TestDeleteQuarantineViaLidarr_LidarrDisabled verifies 503 when Lidarr is
// not configured.
func TestDeleteQuarantineViaLidarr_LidarrDisabled(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
resetLidarrState(t, h)
installQuarantineClientFn(t, h)
alice := seedUser(t, pool, "alice", "pw", false)
admin := seedUser(t, pool, "admin", "pw", true)
track, _ := seedTrackWithAlbumMBID(t, h, "dvl-disabled", "al-mbid-disabled")
flagDirect(t, h, alice.ID, track.ID, "bad_rip")
// Lidarr config is reset (disabled).
w := doAdminQuarantineReq(t, h, http.MethodPost,
"/api/admin/quarantine/"+uuidToString(track.ID)+"/delete-via-lidarr", nil, admin)
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want 503; body = %s", w.Code, w.Body.String())
}
var resp map[string]string
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
}
if resp["error"] != "lidarr_disabled" {
t.Errorf("error = %q, want lidarr_disabled", resp["error"])
}
}
// TestDeleteQuarantineViaLidarr_AlbumMBIDMissing verifies 404 album_mbid_missing
// when the parent album has no mbid.
func TestDeleteQuarantineViaLidarr_AlbumMBIDMissing(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
resetLidarrState(t, h)
installQuarantineClientFn(t, h)
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(stub.Close)
saveLidarrConfig(t, h, stub.URL, true)
installQuarantineClientFn(t, h)
alice := seedUser(t, pool, "alice", "pw", false)
admin := seedUser(t, pool, "admin", "pw", true)
// Track whose album has NO mbid.
track, _ := seedTrackOnDisk(t, h, "no-mbid")
flagDirect(t, h, alice.ID, track.ID, "bad_rip")
w := doAdminQuarantineReq(t, h, http.MethodPost,
"/api/admin/quarantine/"+uuidToString(track.ID)+"/delete-via-lidarr", nil, admin)
if w.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404; body = %s", w.Code, w.Body.String())
}
var resp map[string]string
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
}
if resp["error"] != "album_mbid_missing" {
t.Errorf("error = %q, want album_mbid_missing", resp["error"])
}
}
// TestQuarantineAdminEndpointsRequire403ForNonAdmin verifies all five admin
// endpoints reject non-admin callers with 403.
func TestQuarantineAdminEndpointsRequire403ForNonAdmin(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
resetLidarrState(t, h)
installQuarantineClientFn(t, h)
resetRequests(t, h)
nonAdmin := seedUser(t, pool, "regular", "pw", false)
fakeID := "00000000-0000-0000-0000-000000000001"
@@ -414,7 +227,6 @@ func TestQuarantineAdminEndpointsRequire403ForNonAdmin(t *testing.T) {
{http.MethodGet, "/api/admin/quarantine"},
{http.MethodPost, "/api/admin/quarantine/" + fakeID + "/resolve"},
{http.MethodPost, "/api/admin/quarantine/" + fakeID + "/delete-file"},
{http.MethodPost, "/api/admin/quarantine/" + fakeID + "/delete-via-lidarr"},
{http.MethodGet, "/api/admin/quarantine/actions"},
}
for _, tc := range cases {