// 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 { 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) *Service { if clientFn == nil { clientFn = func() *lidarr.Client { return nil } } return &Service{pool: pool, lidarrCfg: cfg, clientFn: clientFn} } // 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 = ¬es } 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, then // (via FK ON DELETE CASCADE) clears all per-user quarantine rows for that // track and writes an audit row. If the file deletion fails, the per-user // rows stay so admin can retry. 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, 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 }