diff --git a/internal/db/dbq/lidarr_quarantine.sql.go b/internal/db/dbq/lidarr_quarantine.sql.go new file mode 100644 index 00000000..5942194f --- /dev/null +++ b/internal/db/dbq/lidarr_quarantine.sql.go @@ -0,0 +1,372 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: lidarr_quarantine.sql + +package dbq + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const countQuarantineForTrack = `-- name: CountQuarantineForTrack :one +SELECT count(*)::int FROM lidarr_quarantine WHERE track_id = $1 +` + +// Reads affected_users for the audit row before the delete fires. +func (q *Queries) CountQuarantineForTrack(ctx context.Context, trackID pgtype.UUID) (int32, error) { + row := q.db.QueryRow(ctx, countQuarantineForTrack, trackID) + var column_1 int32 + err := row.Scan(&column_1) + return column_1, err +} + +const deleteQuarantine = `-- name: DeleteQuarantine :one +DELETE FROM lidarr_quarantine + WHERE user_id = $1 AND track_id = $2 + RETURNING user_id, track_id, reason, notes, created_at +` + +type DeleteQuarantineParams struct { + UserID pgtype.UUID + TrackID pgtype.UUID +} + +// Removes the caller's row. Returns the deleted row so the handler can +// distinguish "no row existed" (zero rows -> ErrNoRows) from success. +func (q *Queries) DeleteQuarantine(ctx context.Context, arg DeleteQuarantineParams) (LidarrQuarantine, error) { + row := q.db.QueryRow(ctx, deleteQuarantine, arg.UserID, arg.TrackID) + var i LidarrQuarantine + err := row.Scan( + &i.UserID, + &i.TrackID, + &i.Reason, + &i.Notes, + &i.CreatedAt, + ) + return i, err +} + +const deleteQuarantineForTrack = `-- name: DeleteQuarantineForTrack :exec +DELETE FROM lidarr_quarantine WHERE track_id = $1 +` + +// Clears all per-user rows for a given track. Used by Resolve and the +// two delete actions. Caller writes the audit row separately before +// this fires (so we can capture the affected_users count). +func (q *Queries) DeleteQuarantineForTrack(ctx context.Context, trackID pgtype.UUID) error { + _, err := q.db.Exec(ctx, deleteQuarantineForTrack, trackID) + return err +} + +const listAdminQuarantineQueue = `-- name: ListAdminQuarantineQueue :many +SELECT + t.id AS track_id, + t.title AS track_title, + ar.name AS artist_name, + al.title AS album_title, + al.id AS album_id, + al.mbid AS lidarr_album_mbid, + count(q.user_id)::int AS report_count, + max(q.created_at) AS latest_at +FROM lidarr_quarantine q +JOIN tracks t ON t.id = q.track_id +JOIN albums al ON al.id = t.album_id +JOIN artists ar ON ar.id = t.artist_id +GROUP BY t.id, ar.name, al.title, al.id, al.mbid +ORDER BY max(q.created_at) DESC +` + +type ListAdminQuarantineQueueRow struct { + TrackID pgtype.UUID + TrackTitle string + ArtistName string + AlbumTitle string + AlbumID pgtype.UUID + LidarrAlbumMbid *string + ReportCount int32 + LatestAt interface{} +} + +// Aggregated admin queue. One row per track. The handler post-processes +// the rows it gets from this query plus a per-track ListQuarantineReports +// call to materialize reason_counts and the per-user reports list. +func (q *Queries) ListAdminQuarantineQueue(ctx context.Context) ([]ListAdminQuarantineQueueRow, error) { + rows, err := q.db.Query(ctx, listAdminQuarantineQueue) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListAdminQuarantineQueueRow + for rows.Next() { + var i ListAdminQuarantineQueueRow + if err := rows.Scan( + &i.TrackID, + &i.TrackTitle, + &i.ArtistName, + &i.AlbumTitle, + &i.AlbumID, + &i.LidarrAlbumMbid, + &i.ReportCount, + &i.LatestAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listQuarantineActions = `-- name: ListQuarantineActions :many +SELECT id, track_id, track_title, artist_name, album_title, action, admin_id, lidarr_album_mbid, affected_users, created_at FROM lidarr_quarantine_actions +ORDER BY created_at DESC +LIMIT $1 +` + +func (q *Queries) ListQuarantineActions(ctx context.Context, limit int32) ([]LidarrQuarantineAction, error) { + rows, err := q.db.Query(ctx, listQuarantineActions, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []LidarrQuarantineAction + for rows.Next() { + var i LidarrQuarantineAction + if err := rows.Scan( + &i.ID, + &i.TrackID, + &i.TrackTitle, + &i.ArtistName, + &i.AlbumTitle, + &i.Action, + &i.AdminID, + &i.LidarrAlbumMbid, + &i.AffectedUsers, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listQuarantineForUser = `-- name: ListQuarantineForUser :many +SELECT + q.user_id, q.track_id, q.reason, q.notes, q.created_at, + t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, + al.id, al.title, al.sort_title, al.artist_id, al.release_date, al.mbid, al.cover_art_path, al.created_at, al.updated_at, + ar.id, ar.name, ar.sort_name, ar.mbid, ar.created_at, ar.updated_at +FROM lidarr_quarantine q +JOIN tracks t ON t.id = q.track_id +JOIN albums al ON al.id = t.album_id +JOIN artists ar ON ar.id = t.artist_id +WHERE q.user_id = $1 +ORDER BY q.created_at DESC +` + +type ListQuarantineForUserRow struct { + LidarrQuarantine LidarrQuarantine + Track Track + Album Album + Artist Artist +} + +// Caller's own quarantines joined with track + album + artist for full +// detail. Drives /library/hidden. +func (q *Queries) ListQuarantineForUser(ctx context.Context, userID pgtype.UUID) ([]ListQuarantineForUserRow, error) { + rows, err := q.db.Query(ctx, listQuarantineForUser, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListQuarantineForUserRow + for rows.Next() { + var i ListQuarantineForUserRow + if err := rows.Scan( + &i.LidarrQuarantine.UserID, + &i.LidarrQuarantine.TrackID, + &i.LidarrQuarantine.Reason, + &i.LidarrQuarantine.Notes, + &i.LidarrQuarantine.CreatedAt, + &i.Track.ID, + &i.Track.Title, + &i.Track.AlbumID, + &i.Track.ArtistID, + &i.Track.TrackNumber, + &i.Track.DiscNumber, + &i.Track.DurationMs, + &i.Track.FilePath, + &i.Track.FileSize, + &i.Track.FileFormat, + &i.Track.Bitrate, + &i.Track.Mbid, + &i.Track.Genre, + &i.Track.AddedAt, + &i.Track.UpdatedAt, + &i.Album.ID, + &i.Album.Title, + &i.Album.SortTitle, + &i.Album.ArtistID, + &i.Album.ReleaseDate, + &i.Album.Mbid, + &i.Album.CoverArtPath, + &i.Album.CreatedAt, + &i.Album.UpdatedAt, + &i.Artist.ID, + &i.Artist.Name, + &i.Artist.SortName, + &i.Artist.Mbid, + &i.Artist.CreatedAt, + &i.Artist.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listQuarantineReportsForTrack = `-- name: ListQuarantineReportsForTrack :many +SELECT + q.user_id, + u.username, + q.reason, + q.notes, + q.created_at +FROM lidarr_quarantine q +JOIN users u ON u.id = q.user_id +WHERE q.track_id = $1 +ORDER BY q.created_at DESC +` + +type ListQuarantineReportsForTrackRow struct { + UserID pgtype.UUID + Username string + Reason LidarrQuarantineReason + Notes *string + CreatedAt pgtype.Timestamptz +} + +// Per-user reports for a single track. Returned by ListAdminQuarantineQueue +// post-processing and exposed expandable in the SPA admin queue rows. +func (q *Queries) ListQuarantineReportsForTrack(ctx context.Context, trackID pgtype.UUID) ([]ListQuarantineReportsForTrackRow, error) { + rows, err := q.db.Query(ctx, listQuarantineReportsForTrack, trackID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListQuarantineReportsForTrackRow + for rows.Next() { + var i ListQuarantineReportsForTrackRow + if err := rows.Scan( + &i.UserID, + &i.Username, + &i.Reason, + &i.Notes, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const upsertQuarantine = `-- name: UpsertQuarantine :one +INSERT INTO lidarr_quarantine (user_id, track_id, reason, notes) +VALUES ($1, $2, $3, $4) +ON CONFLICT (user_id, track_id) DO UPDATE SET + reason = EXCLUDED.reason, + notes = EXCLUDED.notes, + created_at = now() +RETURNING user_id, track_id, reason, notes, created_at +` + +type UpsertQuarantineParams struct { + UserID pgtype.UUID + TrackID pgtype.UUID + Reason LidarrQuarantineReason + Notes *string +} + +// Insert a new quarantine row, or update reason/notes if the user has +// already flagged this track. +func (q *Queries) UpsertQuarantine(ctx context.Context, arg UpsertQuarantineParams) (LidarrQuarantine, error) { + row := q.db.QueryRow(ctx, upsertQuarantine, + arg.UserID, + arg.TrackID, + arg.Reason, + arg.Notes, + ) + var i LidarrQuarantine + err := row.Scan( + &i.UserID, + &i.TrackID, + &i.Reason, + &i.Notes, + &i.CreatedAt, + ) + return i, err +} + +const writeQuarantineAction = `-- name: WriteQuarantineAction :one +INSERT INTO lidarr_quarantine_actions ( + track_id, track_title, artist_name, album_title, + action, admin_id, lidarr_album_mbid, affected_users +) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) +RETURNING id, track_id, track_title, artist_name, album_title, action, admin_id, lidarr_album_mbid, affected_users, created_at +` + +type WriteQuarantineActionParams struct { + TrackID pgtype.UUID + TrackTitle string + ArtistName string + AlbumTitle *string + Action LidarrQuarantineActionKind + AdminID pgtype.UUID + LidarrAlbumMbid *string + AffectedUsers int32 +} + +// Audit row for an admin destructive action. +func (q *Queries) WriteQuarantineAction(ctx context.Context, arg WriteQuarantineActionParams) (LidarrQuarantineAction, error) { + row := q.db.QueryRow(ctx, writeQuarantineAction, + arg.TrackID, + arg.TrackTitle, + arg.ArtistName, + arg.AlbumTitle, + arg.Action, + arg.AdminID, + arg.LidarrAlbumMbid, + arg.AffectedUsers, + ) + var i LidarrQuarantineAction + err := row.Scan( + &i.ID, + &i.TrackID, + &i.TrackTitle, + &i.ArtistName, + &i.AlbumTitle, + &i.Action, + &i.AdminID, + &i.LidarrAlbumMbid, + &i.AffectedUsers, + &i.CreatedAt, + ) + return i, err +} diff --git a/internal/db/dbq/models.go b/internal/db/dbq/models.go index d0cbf1f2..052bf95f 100644 --- a/internal/db/dbq/models.go +++ b/internal/db/dbq/models.go @@ -11,6 +11,94 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +type LidarrQuarantineActionKind string + +const ( + LidarrQuarantineActionKindResolved LidarrQuarantineActionKind = "resolved" + LidarrQuarantineActionKindDeletedFile LidarrQuarantineActionKind = "deleted_file" + LidarrQuarantineActionKindDeletedViaLidarr LidarrQuarantineActionKind = "deleted_via_lidarr" +) + +func (e *LidarrQuarantineActionKind) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = LidarrQuarantineActionKind(s) + case string: + *e = LidarrQuarantineActionKind(s) + default: + return fmt.Errorf("unsupported scan type for LidarrQuarantineActionKind: %T", src) + } + return nil +} + +type NullLidarrQuarantineActionKind struct { + LidarrQuarantineActionKind LidarrQuarantineActionKind + Valid bool // Valid is true if LidarrQuarantineActionKind is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullLidarrQuarantineActionKind) Scan(value interface{}) error { + if value == nil { + ns.LidarrQuarantineActionKind, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.LidarrQuarantineActionKind.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullLidarrQuarantineActionKind) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.LidarrQuarantineActionKind), nil +} + +type LidarrQuarantineReason string + +const ( + LidarrQuarantineReasonBadRip LidarrQuarantineReason = "bad_rip" + LidarrQuarantineReasonWrongFile LidarrQuarantineReason = "wrong_file" + LidarrQuarantineReasonWrongTags LidarrQuarantineReason = "wrong_tags" + LidarrQuarantineReasonDuplicate LidarrQuarantineReason = "duplicate" + LidarrQuarantineReasonOther LidarrQuarantineReason = "other" +) + +func (e *LidarrQuarantineReason) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = LidarrQuarantineReason(s) + case string: + *e = LidarrQuarantineReason(s) + default: + return fmt.Errorf("unsupported scan type for LidarrQuarantineReason: %T", src) + } + return nil +} + +type NullLidarrQuarantineReason struct { + LidarrQuarantineReason LidarrQuarantineReason + Valid bool // Valid is true if LidarrQuarantineReason is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullLidarrQuarantineReason) Scan(value interface{}) error { + if value == nil { + ns.LidarrQuarantineReason, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.LidarrQuarantineReason.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullLidarrQuarantineReason) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.LidarrQuarantineReason), nil +} + type LidarrRequestKind string const ( @@ -167,6 +255,27 @@ type LidarrConfig struct { UpdatedAt pgtype.Timestamptz } +type LidarrQuarantine struct { + UserID pgtype.UUID + TrackID pgtype.UUID + Reason LidarrQuarantineReason + Notes *string + CreatedAt pgtype.Timestamptz +} + +type LidarrQuarantineAction struct { + ID pgtype.UUID + TrackID pgtype.UUID + TrackTitle string + ArtistName string + AlbumTitle *string + Action LidarrQuarantineActionKind + AdminID pgtype.UUID + LidarrAlbumMbid *string + AffectedUsers int32 + CreatedAt pgtype.Timestamptz +} + type LidarrRequest struct { ID pgtype.UUID UserID pgtype.UUID diff --git a/internal/db/migrations/0011_lidarr_quarantine.down.sql b/internal/db/migrations/0011_lidarr_quarantine.down.sql new file mode 100644 index 00000000..beaf999f --- /dev/null +++ b/internal/db/migrations/0011_lidarr_quarantine.down.sql @@ -0,0 +1,8 @@ +DROP INDEX IF EXISTS lidarr_quarantine_actions_created_idx; +DROP INDEX IF EXISTS lidarr_quarantine_actions_track_idx; +DROP TABLE IF EXISTS lidarr_quarantine_actions; +DROP TYPE IF EXISTS lidarr_quarantine_action_kind; +DROP INDEX IF EXISTS lidarr_quarantine_user_idx; +DROP INDEX IF EXISTS lidarr_quarantine_track_idx; +DROP TABLE IF EXISTS lidarr_quarantine; +DROP TYPE IF EXISTS lidarr_quarantine_reason; diff --git a/internal/db/migrations/0011_lidarr_quarantine.up.sql b/internal/db/migrations/0011_lidarr_quarantine.up.sql new file mode 100644 index 00000000..ff4c177e --- /dev/null +++ b/internal/db/migrations/0011_lidarr_quarantine.up.sql @@ -0,0 +1,45 @@ +-- M5b: per-user track quarantines + admin action audit log. +-- +-- lidarr_quarantine — one row per (user, track) complaint. PK matches +-- the general_likes pattern. Re-flagging the same track upserts. Deleted +-- on user resolution (un-hide), admin Resolve, or any of the deletes. +-- +-- lidarr_quarantine_actions — audit log of admin destructive actions. +-- Snapshot text columns let the log stay readable after the underlying +-- track/album rows are gone. + +CREATE TYPE lidarr_quarantine_reason AS ENUM ( + 'bad_rip', 'wrong_file', 'wrong_tags', 'duplicate', 'other' +); + +CREATE TABLE lidarr_quarantine ( + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + track_id uuid NOT NULL REFERENCES tracks(id) ON DELETE CASCADE, + reason lidarr_quarantine_reason NOT NULL, + notes text, + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, track_id) +); + +CREATE INDEX lidarr_quarantine_track_idx ON lidarr_quarantine (track_id); +CREATE INDEX lidarr_quarantine_user_idx ON lidarr_quarantine (user_id, created_at DESC); + +CREATE TYPE lidarr_quarantine_action_kind AS ENUM ( + 'resolved', 'deleted_file', 'deleted_via_lidarr' +); + +CREATE TABLE lidarr_quarantine_actions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + track_id uuid NOT NULL, + track_title text NOT NULL, + artist_name text NOT NULL, + album_title text, + action lidarr_quarantine_action_kind NOT NULL, + admin_id uuid REFERENCES users(id) ON DELETE SET NULL, + lidarr_album_mbid text, + affected_users int NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX lidarr_quarantine_actions_track_idx ON lidarr_quarantine_actions (track_id); +CREATE INDEX lidarr_quarantine_actions_created_idx ON lidarr_quarantine_actions (created_at DESC); diff --git a/internal/db/queries/lidarr_quarantine.sql b/internal/db/queries/lidarr_quarantine.sql new file mode 100644 index 00000000..7e4c54ed --- /dev/null +++ b/internal/db/queries/lidarr_quarantine.sql @@ -0,0 +1,89 @@ +-- name: UpsertQuarantine :one +-- Insert a new quarantine row, or update reason/notes if the user has +-- already flagged this track. +INSERT INTO lidarr_quarantine (user_id, track_id, reason, notes) +VALUES ($1, $2, $3, $4) +ON CONFLICT (user_id, track_id) DO UPDATE SET + reason = EXCLUDED.reason, + notes = EXCLUDED.notes, + created_at = now() +RETURNING user_id, track_id, reason, notes, created_at; + +-- name: DeleteQuarantine :one +-- Removes the caller's row. Returns the deleted row so the handler can +-- distinguish "no row existed" (zero rows -> ErrNoRows) from success. +DELETE FROM lidarr_quarantine + WHERE user_id = $1 AND track_id = $2 + RETURNING user_id, track_id, reason, notes, created_at; + +-- name: ListQuarantineForUser :many +-- Caller's own quarantines joined with track + album + artist for full +-- detail. Drives /library/hidden. +SELECT + sqlc.embed(q), + sqlc.embed(t), + sqlc.embed(al), + sqlc.embed(ar) +FROM lidarr_quarantine q +JOIN tracks t ON t.id = q.track_id +JOIN albums al ON al.id = t.album_id +JOIN artists ar ON ar.id = t.artist_id +WHERE q.user_id = $1 +ORDER BY q.created_at DESC; + +-- name: ListAdminQuarantineQueue :many +-- Aggregated admin queue. One row per track. The handler post-processes +-- the rows it gets from this query plus a per-track ListQuarantineReports +-- call to materialize reason_counts and the per-user reports list. +SELECT + t.id AS track_id, + t.title AS track_title, + ar.name AS artist_name, + al.title AS album_title, + al.id AS album_id, + al.mbid AS lidarr_album_mbid, + count(q.user_id)::int AS report_count, + max(q.created_at) AS latest_at +FROM lidarr_quarantine q +JOIN tracks t ON t.id = q.track_id +JOIN albums al ON al.id = t.album_id +JOIN artists ar ON ar.id = t.artist_id +GROUP BY t.id, ar.name, al.title, al.id, al.mbid +ORDER BY max(q.created_at) DESC; + +-- name: ListQuarantineReportsForTrack :many +-- Per-user reports for a single track. Returned by ListAdminQuarantineQueue +-- post-processing and exposed expandable in the SPA admin queue rows. +SELECT + q.user_id, + u.username, + q.reason, + q.notes, + q.created_at +FROM lidarr_quarantine q +JOIN users u ON u.id = q.user_id +WHERE q.track_id = $1 +ORDER BY q.created_at DESC; + +-- name: DeleteQuarantineForTrack :exec +-- Clears all per-user rows for a given track. Used by Resolve and the +-- two delete actions. Caller writes the audit row separately before +-- this fires (so we can capture the affected_users count). +DELETE FROM lidarr_quarantine WHERE track_id = $1; + +-- name: CountQuarantineForTrack :one +-- Reads affected_users for the audit row before the delete fires. +SELECT count(*)::int FROM lidarr_quarantine WHERE track_id = $1; + +-- name: WriteQuarantineAction :one +-- Audit row for an admin destructive action. +INSERT INTO lidarr_quarantine_actions ( + track_id, track_title, artist_name, album_title, + action, admin_id, lidarr_album_mbid, affected_users +) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) +RETURNING *; + +-- name: ListQuarantineActions :many +SELECT * FROM lidarr_quarantine_actions +ORDER BY created_at DESC +LIMIT $1;