Files
minstrel/internal/lidarrquarantine/service.go
T
bvandeusenandClaude Opus 5 d7a8e5f300
test-go / test (push) Failing after 55s
test-web / test (push) Successful in 56s
test-go / integration (push) Failing after 4m50s
android / Build + lint + test (push) Successful in 5m52s
release / Build signed APK (releases and dev) (push) Successful in 6m5s
release / Build + push container image (push) Successful in 1m14s
release / Verify release artifacts (tag releases only) (push) Skipped
fix(library): a track delete that cannot remove its file deletes nothing — #3918
Two delete paths had opposite failure policies. tracks.RemoveTrack
logged a failed os.Remove and deleted the row anyway, which CASCADEs
likes, plays, playlist memberships and tags, while the file survived
for the next scan to re-import as a stranger. library.DeleteTrackFile
stopped correctly but reported it as a bare 500 nobody could read.

One path now: library.DeleteTrackFile removes the file first and, on
anything but ErrNotExist, returns *FileRemoveError with nothing
deleted. Only then does it delete the row and tidy an emptied album
and artist in one transaction, log the sync change and clear orphaned
artist art. RemoveTrack calls it, which also fixes RemoveTrack never
logging a sync change. Quarantine Delete file now tidies emptied
albums and artists too.

Both endpoints answer an unwritable library (EROFS, EACCES, EPERM) with
409 library_not_writable. The message names the directory (removal
writes to the parent), the uid:gid the server runs as, and that
nothing was deleted. Other remove errors are 500 file_delete_failed
with the path.

The reachable surface is quarantine Delete file, which failed
silently: no copy for the code on either client, and Android swallowed
the exception so the row just reappeared. Web and Android now have
copy for both codes and append the server message for exactly those
two. Android's quarantine screen shows it in a snackbar.

DELETE /api/admin/tracks/{id} has had no client since f7278f24, which
kept it on purpose for a safer admin surface, so its history loss was
latent. Fixed rather than removed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
2026-09-11 14:23:01 -04:00

361 lines
14 KiB
Go

// Package lidarrquarantine owns the per-user track quarantine workflow.
// Users flag a track as broken (Flag/Unflag), the SPA hides the track
// from their views, and admins resolve the resulting reports via the
// Service's admin actions (Resolve / DeleteFile / DeleteViaLidarr).
package lidarrquarantine
import (
"context"
"errors"
"fmt"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
)
// Public errors. Handlers map these to API codes.
var (
ErrBadReason = errors.New("lidarrquarantine: invalid reason")
ErrTrackNotFound = errors.New("lidarrquarantine: track not found")
ErrQuarantineNotFound = errors.New("lidarrquarantine: quarantine row not found")
ErrAlbumMBIDMissing = errors.New("lidarrquarantine: track has no parent album mbid")
ErrLidarrAlbumNotFound = errors.New("lidarrquarantine: lidarr has no album for that mbid")
ErrLidarrDisabled = errors.New("lidarrquarantine: lidarr is not configured")
)
// Service owns the request lifecycle. clientFn is a per-call factory so
// config changes in lidarrconfig take effect immediately. clientFn returns
// nil when Lidarr is disabled.
type Service struct {
// dataDir lets a Delete file that empties an artist clear that artist's
// cached art, the same as the admin remove-track path.
dataDir string
pool *pgxpool.Pool
lidarrCfg *lidarrconfig.Service
clientFn func() *lidarr.Client
}
// NewService constructs a Service. Pass nil for clientFn to disable the
// Lidarr-using methods (DeleteViaLidarr will return ErrLidarrDisabled).
func NewService(pool *pgxpool.Pool, cfg *lidarrconfig.Service, clientFn func() *lidarr.Client, dataDir string) *Service {
if clientFn == nil {
clientFn = func() *lidarr.Client { return nil }
}
return &Service{pool: pool, lidarrCfg: cfg, clientFn: clientFn, dataDir: dataDir}
}
// Flag inserts or updates a quarantine row for the caller. Re-flagging
// the same (user, track) overwrites reason+notes (and bumps created_at).
func (s *Service) Flag(ctx context.Context, userID, trackID pgtype.UUID, reason string, notes string) (dbq.LidarrQuarantine, error) {
if !validReason(reason) {
return dbq.LidarrQuarantine{}, ErrBadReason
}
var notesPtr *string
if notes != "" {
notesPtr = &notes
}
row, err := dbq.New(s.pool).UpsertQuarantine(ctx, dbq.UpsertQuarantineParams{
UserID: userID,
TrackID: trackID,
Reason: dbq.LidarrQuarantineReason(reason),
Notes: notesPtr,
})
if err != nil {
// FK violation on track_id (no such track) → ErrTrackNotFound so the
// handler can return 404 instead of a generic 500. Code 23503 is the
// Postgres SQLSTATE for foreign_key_violation; the constraint check
// guards against future FKs (e.g. user_id) firing the same code.
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23503" &&
(pgErr.ConstraintName == "" || strings.Contains(pgErr.ConstraintName, "track")) {
return dbq.LidarrQuarantine{}, ErrTrackNotFound
}
return dbq.LidarrQuarantine{}, fmt.Errorf("upsert: %w", err)
}
return row, nil
}
// Unflag removes the caller's row. Returns ErrQuarantineNotFound if no
// row exists for that (user, track).
func (s *Service) Unflag(ctx context.Context, userID, trackID pgtype.UUID) error {
_, err := dbq.New(s.pool).DeleteQuarantine(ctx, dbq.DeleteQuarantineParams{
UserID: userID, TrackID: trackID,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrQuarantineNotFound
}
return fmt.Errorf("delete: %w", err)
}
return nil
}
// ListMine returns the caller's quarantines with track + album + artist
// detail. Drives /library/hidden. Ordered newest-first.
func (s *Service) ListMine(ctx context.Context, userID pgtype.UUID) ([]dbq.ListQuarantineForUserRow, error) {
return dbq.New(s.pool).ListQuarantineForUser(ctx, userID)
}
// AdminQueueRow is the assembled aggregated row served by the admin
// queue endpoint. The handler post-processes the SQL result + a per-track
// ListQuarantineReports call to materialize reason_counts and the
// per-user reports list.
type AdminQueueRow struct {
TrackID pgtype.UUID
TrackTitle string
ArtistName string
AlbumTitle string
AlbumID pgtype.UUID
LidarrAlbumMBID *string
ReportCount int32
LatestAt pgtype.Timestamptz
ReasonCounts map[string]int
Reports []UserReport
}
// UserReport is the per-user detail under an aggregated admin row.
type UserReport struct {
UserID pgtype.UUID
Username string
Reason string
Notes *string
CreatedAt pgtype.Timestamptz
}
// ListAdminQueue returns the aggregated admin queue. One row per track.
// Performs an N+1 (one ListQuarantineReportsForTrack per aggregated row)
// because materializing reason_counts in pure SQL is awkward; the queue
// is small in practice (≲100 tracks at household scale). Revisit with
// json_object_agg if the queue grows.
func (s *Service) ListAdminQueue(ctx context.Context) ([]AdminQueueRow, error) {
q := dbq.New(s.pool)
aggregated, err := q.ListAdminQuarantineQueue(ctx)
if err != nil {
return nil, fmt.Errorf("aggregate: %w", err)
}
out := make([]AdminQueueRow, 0, len(aggregated))
for _, r := range aggregated {
reports, err := q.ListQuarantineReportsForTrack(ctx, r.TrackID)
if err != nil {
return nil, fmt.Errorf("reports for track %v: %w", r.TrackID, err)
}
rc := make(map[string]int, len(reports))
userReports := make([]UserReport, 0, len(reports))
for _, rep := range reports {
rc[string(rep.Reason)]++
userReports = append(userReports, UserReport{
UserID: rep.UserID,
Username: rep.Username,
Reason: string(rep.Reason),
Notes: rep.Notes,
CreatedAt: rep.CreatedAt,
})
}
out = append(out, AdminQueueRow{
TrackID: r.TrackID,
TrackTitle: r.TrackTitle,
ArtistName: r.ArtistName,
AlbumTitle: r.AlbumTitle,
AlbumID: r.AlbumID,
LidarrAlbumMBID: r.LidarrAlbumMbid,
ReportCount: r.ReportCount,
LatestAt: r.LatestAt,
ReasonCounts: rc,
Reports: userReports,
})
}
return out, nil
}
func validReason(r string) bool {
switch r {
case "bad_rip", "wrong_file", "wrong_tags", "duplicate", "other":
return true
}
return false
}
// Resolve clears all per-user quarantine rows for a track and writes an
// audit log row. Idempotent — a track with no rows still writes an audit
// entry with affected_users=0 (so admin can see "I clicked resolve on a
// track that already had no reports").
func (s *Service) Resolve(ctx context.Context, trackID, adminID pgtype.UUID) (dbq.LidarrQuarantineAction, error) {
q := dbq.New(s.pool)
track, err := q.GetTrackByID(ctx, trackID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return dbq.LidarrQuarantineAction{}, ErrTrackNotFound
}
return dbq.LidarrQuarantineAction{}, fmt.Errorf("get track: %w", err)
}
snap, err := s.snapshot(ctx, q, track)
if err != nil {
return dbq.LidarrQuarantineAction{}, err
}
affected, err := q.CountQuarantineForTrack(ctx, trackID)
if err != nil {
return dbq.LidarrQuarantineAction{}, fmt.Errorf("count: %w", err)
}
if err := q.DeleteQuarantineForTrack(ctx, trackID); err != nil {
return dbq.LidarrQuarantineAction{}, fmt.Errorf("delete rows: %w", err)
}
// LidarrAlbumMbid stays nil here: Lidarr was not contacted. The audit
// log uses the field as a "this row triggered Lidarr" marker, so only
// DeleteViaLidarr sets it.
return q.WriteQuarantineAction(ctx, dbq.WriteQuarantineActionParams{
TrackID: trackID, TrackTitle: snap.TrackTitle, ArtistName: snap.ArtistName,
AlbumTitle: snap.AlbumTitle, Action: dbq.LidarrQuarantineActionKindResolved,
AdminID: adminID, LidarrAlbumMbid: nil, AffectedUsers: affected,
})
}
// quarantineSnapshot is the audit-row-ready snapshot of track + album +
// artist text columns. Used by the three admin actions.
type quarantineSnapshot struct {
TrackTitle string
ArtistName string
AlbumTitle *string
LidarrAlbumMBID *string
}
// snapshot pulls the audit-row text columns from track / album / artist.
// AlbumTitle is materialized as *string because WriteQuarantineActionParams
// expects nullable; we coerce the schema's NOT NULL value into a pointer.
func (s *Service) snapshot(ctx context.Context, q *dbq.Queries, track dbq.Track) (quarantineSnapshot, error) {
album, err := q.GetAlbumByID(ctx, track.AlbumID)
if err != nil {
return quarantineSnapshot{}, fmt.Errorf("get album: %w", err)
}
artist, err := q.GetArtistByID(ctx, track.ArtistID)
if err != nil {
return quarantineSnapshot{}, fmt.Errorf("get artist: %w", err)
}
titlePtr := album.Title
return quarantineSnapshot{
TrackTitle: track.Title,
ArtistName: artist.Name,
AlbumTitle: &titlePtr,
LidarrAlbumMBID: album.Mbid,
}, nil
}
// DeleteFile removes the track file from disk and the tracks row (tidying away
// an album or artist that leaves empty), then — via FK ON DELETE CASCADE —
// clears every per-user quarantine row for that track and writes an audit row.
//
// If the file cannot be removed, nothing is deleted and the per-user rows stay
// so the admin can retry: the error wraps a *library.FileRemoveError, which the
// handler turns into an answer naming the cause (#3918). No partial state.
func (s *Service) DeleteFile(ctx context.Context, trackID, adminID pgtype.UUID) (dbq.LidarrQuarantineAction, error) {
q := dbq.New(s.pool)
track, err := q.GetTrackByID(ctx, trackID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return dbq.LidarrQuarantineAction{}, ErrTrackNotFound
}
return dbq.LidarrQuarantineAction{}, fmt.Errorf("get track: %w", err)
}
snap, err := s.snapshot(ctx, q, track)
if err != nil {
return dbq.LidarrQuarantineAction{}, err
}
affected, err := q.CountQuarantineForTrack(ctx, trackID)
if err != nil {
return dbq.LidarrQuarantineAction{}, fmt.Errorf("count: %w", err)
}
if _, err := library.DeleteTrackFile(ctx, s.pool, nil, s.dataDir, trackID); err != nil {
return dbq.LidarrQuarantineAction{}, fmt.Errorf("delete file: %w", err)
}
// tracks row is gone; ON DELETE CASCADE on lidarr_quarantine.track_id
// already cleared the per-user rows. We don't call
// DeleteQuarantineForTrack separately. LidarrAlbumMbid stays nil —
// Lidarr was not contacted.
return q.WriteQuarantineAction(ctx, dbq.WriteQuarantineActionParams{
TrackID: trackID, TrackTitle: snap.TrackTitle, ArtistName: snap.ArtistName,
AlbumTitle: snap.AlbumTitle, Action: dbq.LidarrQuarantineActionKindDeletedFile,
AdminID: adminID, LidarrAlbumMbid: nil, AffectedUsers: affected,
})
}
// DeleteViaLidarr is the destructive admin path: tells Lidarr to remove
// the parent album with deleteFiles=true + addImportListExclusion=true,
// then removes Minstrel rows for all tracks of that album. The cascade
// on lidarr_quarantine.track_id clears per-user rows automatically.
//
// On Lidarr failure (unreachable, auth-failed, lookup-empty), nothing
// changes locally. Admin retries.
//
// Returns the audit row + the count of tracks deleted from Minstrel.
func (s *Service) DeleteViaLidarr(ctx context.Context, trackID, adminID pgtype.UUID) (dbq.LidarrQuarantineAction, int, error) {
cfg, err := s.lidarrCfg.Get(ctx)
if err != nil {
return dbq.LidarrQuarantineAction{}, 0, fmt.Errorf("load config: %w", err)
}
client := s.clientFn()
if !cfg.Enabled || client == nil {
return dbq.LidarrQuarantineAction{}, 0, ErrLidarrDisabled
}
q := dbq.New(s.pool)
track, err := q.GetTrackByID(ctx, trackID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return dbq.LidarrQuarantineAction{}, 0, ErrTrackNotFound
}
return dbq.LidarrQuarantineAction{}, 0, fmt.Errorf("get track: %w", err)
}
snap, err := s.snapshot(ctx, q, track)
if err != nil {
return dbq.LidarrQuarantineAction{}, 0, err
}
if snap.LidarrAlbumMBID == nil || *snap.LidarrAlbumMBID == "" {
return dbq.LidarrQuarantineAction{}, 0, ErrAlbumMBIDMissing
}
affected, err := q.CountQuarantineForTrack(ctx, trackID)
if err != nil {
return dbq.LidarrQuarantineAction{}, 0, fmt.Errorf("count: %w", err)
}
// Lidarr lookup: MBID -> internal album ID.
album, err := client.LookupAlbumByMBID(ctx, *snap.LidarrAlbumMBID)
if err != nil {
if errors.Is(err, lidarr.ErrNotFound) {
return dbq.LidarrQuarantineAction{}, 0, ErrLidarrAlbumNotFound
}
return dbq.LidarrQuarantineAction{}, 0, fmt.Errorf("lidarr lookup: %w", err)
}
// Lidarr DELETE — both flags true.
if err := client.DeleteAlbum(ctx, album.ID, true, true); err != nil {
return dbq.LidarrQuarantineAction{}, 0, fmt.Errorf("lidarr delete: %w", err)
}
// Now remove the local rows. CASCADE on lidarr_quarantine.track_id
// handles per-user rows.
res, err := s.pool.Exec(ctx, "DELETE FROM tracks WHERE album_id = $1", track.AlbumID)
if err != nil {
// Lidarr is already done; we couldn't update locally. Operator-
// recoverable: re-running picks up where we left off.
return dbq.LidarrQuarantineAction{}, 0, fmt.Errorf("delete tracks: %w", err)
}
deletedCount := int(res.RowsAffected())
action, err := q.WriteQuarantineAction(ctx, dbq.WriteQuarantineActionParams{
TrackID: trackID, TrackTitle: snap.TrackTitle, ArtistName: snap.ArtistName,
AlbumTitle: snap.AlbumTitle, Action: dbq.LidarrQuarantineActionKindDeletedViaLidarr,
AdminID: adminID, LidarrAlbumMbid: snap.LidarrAlbumMBID, AffectedUsers: affected,
})
return action, deletedCount, err
}