feat(lidarrquarantine): Service Flag/Unflag/ListMine/ListAdminQueue

This commit is contained in:
2026-04-30 17:36:27 -04:00
parent e6e3f297d6
commit b4fe224a67
2 changed files with 390 additions and 0 deletions
+179
View File
@@ -0,0 +1,179 @@
// 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 = &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 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
}
+211
View File
@@ -0,0 +1,211 @@
package lidarrquarantine
import (
"context"
"errors"
"io"
"log/slog"
"os"
"path/filepath"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
)
func newPool(t *testing.T) *pgxpool.Pool {
t.Helper()
if testing.Short() {
t.Skip("skipping integration test in -short mode")
}
dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil {
t.Fatalf("migrate: %v", err)
}
pool, err := pgxpool.New(context.Background(), dsn)
if err != nil {
t.Fatalf("pool: %v", err)
}
t.Cleanup(pool.Close)
dbtest.ResetDB(t, pool)
return pool
}
func seedUser(t *testing.T, pool *pgxpool.Pool, name string) dbq.User {
t.Helper()
u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{
Username: dbtest.TestUserPrefix + name, PasswordHash: "x",
ApiToken: name + "-token", IsAdmin: false,
})
if err != nil {
t.Fatalf("seed user %s: %v", name, err)
}
return u
}
func seedTrack(t *testing.T, pool *pgxpool.Pool, title, mbid string) (dbq.Track, dbq.Album, dbq.Artist) {
t.Helper()
q := dbq.New(pool)
artist, err := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{
Name: "Test Artist", SortName: "Test Artist",
})
if err != nil {
t.Fatalf("artist: %v", err)
}
albumMBID := mbid + "-album"
album, err := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{
Title: "Test Album", SortTitle: "Test Album",
ArtistID: artist.ID, Mbid: &albumMBID,
})
if err != nil {
t.Fatalf("album: %v", err)
}
track, err := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{
Title: title, AlbumID: album.ID, ArtistID: artist.ID,
DurationMs: 1000, FilePath: filepath.Join(t.TempDir(), title+".mp3"),
FileSize: 100, FileFormat: "mp3",
})
if err != nil {
t.Fatalf("track: %v", err)
}
return track, album, artist
}
func TestFlag_HappyPath(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool, "alice")
track, _, _ := seedTrack(t, pool, "Bad Track", "abc")
svc := NewService(pool, lidarrconfig.New(pool), nil)
row, err := svc.Flag(context.Background(), user.ID, track.ID, "bad_rip", "crackly")
if err != nil {
t.Fatalf("Flag: %v", err)
}
if string(row.Reason) != "bad_rip" {
t.Errorf("reason = %v", row.Reason)
}
if row.Notes == nil || *row.Notes != "crackly" {
t.Errorf("notes = %v", row.Notes)
}
}
func TestFlag_UpsertOnSecondFlag(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool, "alice")
track, _, _ := seedTrack(t, pool, "T", "x")
svc := NewService(pool, lidarrconfig.New(pool), nil)
if _, err := svc.Flag(context.Background(), user.ID, track.ID, "bad_rip", "first"); err != nil {
t.Fatalf("first flag: %v", err)
}
row, err := svc.Flag(context.Background(), user.ID, track.ID, "wrong_tags", "")
if err != nil {
t.Fatalf("second flag: %v", err)
}
if string(row.Reason) != "wrong_tags" {
t.Errorf("reason = %v", row.Reason)
}
if row.Notes != nil {
t.Errorf("notes = %v, want nil after empty notes upsert", row.Notes)
}
}
func TestFlag_BadReasonRejected(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool, "alice")
track, _, _ := seedTrack(t, pool, "T", "x")
svc := NewService(pool, lidarrconfig.New(pool), nil)
_, err := svc.Flag(context.Background(), user.ID, track.ID, "garbage", "")
if !errors.Is(err, ErrBadReason) {
t.Errorf("err = %v, want ErrBadReason", err)
}
}
func TestUnflag_DeletesRow(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool, "alice")
track, _, _ := seedTrack(t, pool, "T", "x")
svc := NewService(pool, lidarrconfig.New(pool), nil)
if _, err := svc.Flag(context.Background(), user.ID, track.ID, "bad_rip", ""); err != nil {
t.Fatalf("Flag: %v", err)
}
if err := svc.Unflag(context.Background(), user.ID, track.ID); err != nil {
t.Fatalf("Unflag: %v", err)
}
if err := svc.Unflag(context.Background(), user.ID, track.ID); !errors.Is(err, ErrQuarantineNotFound) {
t.Errorf("second Unflag err = %v, want ErrQuarantineNotFound", err)
}
}
func TestListMine_OrderedNewestFirst(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool, "alice")
t1, _, _ := seedTrack(t, pool, "T1", "x")
t2, _, _ := seedTrack(t, pool, "T2", "y")
svc := NewService(pool, lidarrconfig.New(pool), nil)
if _, err := svc.Flag(context.Background(), user.ID, t1.ID, "bad_rip", ""); err != nil {
t.Fatalf("Flag t1: %v", err)
}
if _, err := svc.Flag(context.Background(), user.ID, t2.ID, "duplicate", ""); err != nil {
t.Fatalf("Flag t2: %v", err)
}
rows, err := svc.ListMine(context.Background(), user.ID)
if err != nil {
t.Fatalf("ListMine: %v", err)
}
if len(rows) != 2 {
t.Fatalf("len = %d, want 2", len(rows))
}
// T2 was flagged second — newest first.
if rows[0].LidarrQuarantine.TrackID != t2.ID {
t.Errorf("first row track = %v, want T2 (%v)", rows[0].LidarrQuarantine.TrackID, t2.ID)
}
}
func TestListAdminQueue_AggregatesByTrackWithReasonCounts(t *testing.T) {
pool := newPool(t)
alice := seedUser(t, pool, "alice")
bob := seedUser(t, pool, "bob")
carol := seedUser(t, pool, "carol")
track, _, _ := seedTrack(t, pool, "Hot Mess", "abc")
svc := NewService(pool, lidarrconfig.New(pool), nil)
if _, err := svc.Flag(context.Background(), alice.ID, track.ID, "bad_rip", ""); err != nil {
t.Fatalf("alice flag: %v", err)
}
if _, err := svc.Flag(context.Background(), bob.ID, track.ID, "bad_rip", ""); err != nil {
t.Fatalf("bob flag: %v", err)
}
if _, err := svc.Flag(context.Background(), carol.ID, track.ID, "wrong_tags", ""); err != nil {
t.Fatalf("carol flag: %v", err)
}
rows, err := svc.ListAdminQueue(context.Background())
if err != nil {
t.Fatalf("ListAdminQueue: %v", err)
}
if len(rows) != 1 {
t.Fatalf("len = %d, want 1 aggregated row", len(rows))
}
r := rows[0]
if r.ReportCount != 3 {
t.Errorf("report_count = %d, want 3", r.ReportCount)
}
if r.ReasonCounts["bad_rip"] != 2 || r.ReasonCounts["wrong_tags"] != 1 {
t.Errorf("reason_counts = %+v, want bad_rip=2 wrong_tags=1", r.ReasonCounts)
}
if len(r.Reports) != 3 {
t.Errorf("reports len = %d, want 3", len(r.Reports))
}
}