180 lines
6.0 KiB
Go
180 lines
6.0 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 —
|
|
// 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
|
|
}
|