// Package quarantine 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). package quarantine 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" ) // Public errors. Handlers map these to API codes. var ( ErrBadReason = errors.New("quarantine: invalid reason") ErrTrackNotFound = errors.New("quarantine: track not found") ErrQuarantineNotFound = errors.New("quarantine: quarantine row not found") ) // Service owns the quarantine lifecycle. 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 } // NewService constructs a Service. func NewService(pool *pgxpool.Pool, dataDir string) *Service { return &Service{pool: pool, 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.Quarantine, error) { if !validReason(reason) { return dbq.Quarantine{}, ErrBadReason } var notesPtr *string if notes != "" { notesPtr = ¬es } row, err := dbq.New(s.pool).UpsertQuarantine(ctx, dbq.UpsertQuarantineParams{ UserID: userID, TrackID: trackID, Reason: dbq.QuarantineReason(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.Quarantine{}, ErrTrackNotFound } return dbq.Quarantine{}, 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 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, 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.QuarantineAction, error) { q := dbq.New(s.pool) track, err := q.GetTrackByID(ctx, trackID) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return dbq.QuarantineAction{}, ErrTrackNotFound } return dbq.QuarantineAction{}, fmt.Errorf("get track: %w", err) } snap, err := s.snapshot(ctx, q, track) if err != nil { return dbq.QuarantineAction{}, err } affected, err := q.CountQuarantineForTrack(ctx, trackID) if err != nil { return dbq.QuarantineAction{}, fmt.Errorf("count: %w", err) } if err := q.DeleteQuarantineForTrack(ctx, trackID); err != nil { return dbq.QuarantineAction{}, fmt.Errorf("delete rows: %w", err) } return q.WriteQuarantineAction(ctx, dbq.WriteQuarantineActionParams{ TrackID: trackID, TrackTitle: snap.TrackTitle, ArtistName: snap.ArtistName, AlbumTitle: snap.AlbumTitle, Action: dbq.QuarantineActionKindResolved, AdminID: adminID, AffectedUsers: affected, }) } // quarantineSnapshot is the audit-row-ready snapshot of track + album + // artist text columns. Used by the admin actions. type quarantineSnapshot struct { TrackTitle string ArtistName string AlbumTitle *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, }, 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.QuarantineAction, error) { q := dbq.New(s.pool) track, err := q.GetTrackByID(ctx, trackID) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return dbq.QuarantineAction{}, ErrTrackNotFound } return dbq.QuarantineAction{}, fmt.Errorf("get track: %w", err) } snap, err := s.snapshot(ctx, q, track) if err != nil { return dbq.QuarantineAction{}, err } affected, err := q.CountQuarantineForTrack(ctx, trackID) if err != nil { return dbq.QuarantineAction{}, fmt.Errorf("count: %w", err) } if _, err := library.DeleteTrackFile(ctx, s.pool, nil, s.dataDir, trackID); err != nil { return dbq.QuarantineAction{}, fmt.Errorf("delete file: %w", err) } // tracks row is gone; ON DELETE CASCADE on quarantine.track_id already // cleared the per-user rows. We don't call DeleteQuarantineForTrack // separately. return q.WriteQuarantineAction(ctx, dbq.WriteQuarantineActionParams{ TrackID: trackID, TrackTitle: snap.TrackTitle, ArtistName: snap.ArtistName, AlbumTitle: snap.AlbumTitle, Action: dbq.QuarantineActionKindDeletedFile, AdminID: adminID, AffectedUsers: affected, }) }