// 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 — // the admin action methods are added in a follow-up task). package lidarrquarantine import ( "context" "errors" "fmt" "github.com/jackc/pgx/v5" "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/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 surfaces here as a postgres error; // callers can treat any error as "track doesn't exist or DB issue." // Distinguishing FK violation specifically isn't worth the extra // dependency on pgconn just for one branch. 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, }) } var albumTitle *string if r.AlbumTitle != "" { t := r.AlbumTitle albumTitle = &t } out = append(out, AdminQueueRow{ TrackID: r.TrackID, TrackTitle: r.TrackTitle, ArtistName: r.ArtistName, AlbumTitle: 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 }