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
+49 -94
View File
@@ -10,22 +10,20 @@ 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/lidarrquarantine"
"git.fabledsword.com/bvandeusen/minstrel/internal/quarantine"
)
// adminQueueRowView is a single aggregated row in the admin queue response.
type adminQueueRowView struct {
TrackID string `json:"track_id"`
TrackTitle string `json:"track_title"`
ArtistName string `json:"artist_name"`
AlbumTitle string `json:"album_title"`
AlbumID string `json:"album_id"`
LidarrAlbumMBID *string `json:"lidarr_album_mbid,omitempty"`
ReportCount int32 `json:"report_count"`
LatestAt string `json:"latest_at"`
ReasonCounts map[string]int `json:"reason_counts"`
Reports []adminQueueReportView `json:"reports"`
TrackID string `json:"track_id"`
TrackTitle string `json:"track_title"`
ArtistName string `json:"artist_name"`
AlbumTitle string `json:"album_title"`
AlbumID string `json:"album_id"`
ReportCount int32 `json:"report_count"`
LatestAt string `json:"latest_at"`
ReasonCounts map[string]int `json:"reason_counts"`
Reports []adminQueueReportView `json:"reports"`
}
// adminQueueReportView is one user's report under an aggregated admin row.
@@ -47,7 +45,7 @@ func formatTimestamp(ts pgtype.Timestamptz) string {
// handleListAdminQuarantine implements GET /api/admin/quarantine.
func (h *handlers) handleListAdminQuarantine(w http.ResponseWriter, r *http.Request) {
rows, err := h.lidarrQuarantine.ListAdminQueue(r.Context())
rows, err := h.quarantine.ListAdminQueue(r.Context())
if err != nil {
h.logger.Error("admin: list quarantine", "err", err)
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
@@ -66,27 +64,25 @@ func (h *handlers) handleListAdminQuarantine(w http.ResponseWriter, r *http.Requ
})
}
out = append(out, adminQueueRowView{
TrackID: uuidToString(row.TrackID),
TrackTitle: row.TrackTitle,
ArtistName: row.ArtistName,
AlbumTitle: row.AlbumTitle,
AlbumID: uuidToString(row.AlbumID),
LidarrAlbumMBID: row.LidarrAlbumMBID,
ReportCount: row.ReportCount,
LatestAt: formatTimestamp(row.LatestAt),
ReasonCounts: row.ReasonCounts,
Reports: reports,
TrackID: uuidToString(row.TrackID),
TrackTitle: row.TrackTitle,
ArtistName: row.ArtistName,
AlbumTitle: row.AlbumTitle,
AlbumID: uuidToString(row.AlbumID),
ReportCount: row.ReportCount,
LatestAt: formatTimestamp(row.LatestAt),
ReasonCounts: row.ReasonCounts,
Reports: reports,
})
}
writeJSON(w, http.StatusOK, out)
}
// actionResultView is the response shape for the three admin destructive
// actions (resolve, delete-file, delete-via-lidarr).
// actionResultView is the response shape for the admin actions (resolve,
// delete-file).
type actionResultView struct {
ActionID string `json:"action_id"`
AffectedUsers int32 `json:"affected_users"`
DeletedTrackCount *int `json:"deleted_track_count,omitempty"`
ActionID string `json:"action_id"`
AffectedUsers int32 `json:"affected_users"`
}
// handleResolveQuarantine implements POST /api/admin/quarantine/{track_id}/resolve.
@@ -101,9 +97,9 @@ func (h *handlers) handleResolveQuarantine(w http.ResponseWriter, r *http.Reques
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_id")
return
}
action, err := h.lidarrQuarantine.Resolve(r.Context(), id, admin.ID)
action, err := h.quarantine.Resolve(r.Context(), id, admin.ID)
if err != nil {
if errors.Is(err, lidarrquarantine.ErrTrackNotFound) {
if errors.Is(err, quarantine.ErrTrackNotFound) {
writeAdminJSONErr(w, http.StatusNotFound, "track_not_found")
return
}
@@ -131,7 +127,7 @@ func (h *handlers) handleDeleteQuarantineFile(w http.ResponseWriter, r *http.Req
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_id")
return
}
action, err := h.lidarrQuarantine.DeleteFile(r.Context(), id, admin.ID)
action, err := h.quarantine.DeleteFile(r.Context(), id, admin.ID)
if err != nil {
// Written in the enveloped shape, not writeAdminJSONErr's bare code: the
// message is the part that tells the operator which directory and uid.
@@ -141,7 +137,7 @@ func (h *handlers) handleDeleteQuarantineFile(w http.ResponseWriter, r *http.Req
return
}
switch {
case errors.Is(err, lidarrquarantine.ErrTrackNotFound):
case errors.Is(err, quarantine.ErrTrackNotFound):
writeAdminJSONErr(w, http.StatusNotFound, "track_not_found")
default:
h.logger.Error("admin: delete file", "err", err)
@@ -158,60 +154,19 @@ func (h *handlers) handleDeleteQuarantineFile(w http.ResponseWriter, r *http.Req
})
}
// handleDeleteQuarantineViaLidarr implements POST /api/admin/quarantine/{track_id}/delete-via-lidarr.
func (h *handlers) handleDeleteQuarantineViaLidarr(w http.ResponseWriter, r *http.Request) {
admin, ok := auth.UserFromContext(r.Context())
if !ok {
writeAdminJSONErr(w, http.StatusUnauthorized, "unauthorized")
return
}
id, ok := parseUUID(chi.URLParam(r, "track_id"))
if !ok {
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_id")
return
}
action, deleted, err := h.lidarrQuarantine.DeleteViaLidarr(r.Context(), id, admin.ID)
if err != nil {
switch {
case errors.Is(err, lidarrquarantine.ErrLidarrDisabled):
writeAdminJSONErr(w, http.StatusServiceUnavailable, "lidarr_disabled")
case errors.Is(err, lidarrquarantine.ErrTrackNotFound):
writeAdminJSONErr(w, http.StatusNotFound, "track_not_found")
case errors.Is(err, lidarrquarantine.ErrAlbumMBIDMissing):
writeAdminJSONErr(w, http.StatusNotFound, "album_mbid_missing")
case errors.Is(err, lidarrquarantine.ErrLidarrAlbumNotFound):
writeAdminJSONErr(w, http.StatusBadGateway, "lidarr_album_lookup_failed")
case errors.Is(err, lidarr.ErrUnreachable):
writeAdminJSONErr(w, http.StatusServiceUnavailable, "lidarr_unreachable")
case errors.Is(err, lidarr.ErrAuthFailed):
writeAdminJSONErr(w, http.StatusServiceUnavailable, "lidarr_auth_failed")
default:
h.logger.Error("admin: delete via lidarr", "err", err)
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
}
return
}
// Broadcast: same rationale as delete-file — track row gone everywhere.
h.publishQuarantineEvent("quarantine.deleted_via_lidarr", pgtype.UUID{}, id, true)
writeJSON(w, http.StatusOK, actionResultView{
ActionID: uuidToString(action.ID),
AffectedUsers: action.AffectedUsers,
DeletedTrackCount: &deleted,
})
}
// actionLogView is the row shape returned by GET /api/admin/quarantine/actions.
type actionLogView struct {
ID string `json:"id"`
TrackID string `json:"track_id"`
TrackTitle string `json:"track_title"`
ArtistName string `json:"artist_name"`
AlbumTitle *string `json:"album_title,omitempty"`
Action string `json:"action"`
AdminID *string `json:"admin_id,omitempty"`
LidarrAlbumMBID *string `json:"lidarr_album_mbid,omitempty"`
AffectedUsers int32 `json:"affected_users"`
CreatedAt string `json:"created_at"`
ID string `json:"id"`
TrackID string `json:"track_id"`
TrackTitle string `json:"track_title"`
ArtistName string `json:"artist_name"`
AlbumTitle *string `json:"album_title,omitempty"`
Action string `json:"action"`
AdminID *string `json:"admin_id,omitempty"`
// AlbumMBID is set on the older deleted_album rows: the album removed.
AlbumMBID *string `json:"album_mbid,omitempty"`
AffectedUsers int32 `json:"affected_users"`
CreatedAt string `json:"created_at"`
}
// handleListQuarantineActions implements GET /api/admin/quarantine/actions.
@@ -241,16 +196,16 @@ func (h *handlers) handleListQuarantineActions(w http.ResponseWriter, r *http.Re
adminID = &s
}
out = append(out, actionLogView{
ID: uuidToString(row.ID),
TrackID: uuidToString(row.TrackID),
TrackTitle: row.TrackTitle,
ArtistName: row.ArtistName,
AlbumTitle: row.AlbumTitle,
Action: string(row.Action),
AdminID: adminID,
LidarrAlbumMBID: row.LidarrAlbumMbid,
AffectedUsers: row.AffectedUsers,
CreatedAt: formatTimestamp(row.CreatedAt),
ID: uuidToString(row.ID),
TrackID: uuidToString(row.TrackID),
TrackTitle: row.TrackTitle,
ArtistName: row.ArtistName,
AlbumTitle: row.AlbumTitle,
Action: string(row.Action),
AdminID: adminID,
AlbumMBID: row.AlbumMbid,
AffectedUsers: row.AffectedUsers,
CreatedAt: formatTimestamp(row.CreatedAt),
})
}
writeJSON(w, http.StatusOK, out)