CreateRequest previously read the user's request count outside the insert transaction, so two concurrent submissions at MaxRequests-1 could both pass the quota gate and end up at MaxRequests+1. Move the count inside the same transaction as the insert and acquire a per-user advisory lock so concurrent inserts serialize. The store reports ErrQuotaExceeded when the racing path catches the user at the limit and the service maps it back to QuotaError. normalizeListFilter previously reset limit to 50 when callers asked for more than 100, which is surprising. Clamp to the cap instead so a request for 150 returns 100 and a request for 1_000_000 still cannot hit the database with an unbounded scan. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
739 lines
21 KiB
Go
739 lines
21 KiB
Go
package requests
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgconn"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
type Repository struct {
|
|
pool *pgxpool.Pool
|
|
}
|
|
|
|
func NewRepository(pool *pgxpool.Pool) *Repository {
|
|
return &Repository{pool: pool}
|
|
}
|
|
|
|
func (r *Repository) GetSettings(ctx context.Context) (Settings, error) {
|
|
var s Settings
|
|
err := r.pool.QueryRow(ctx, `
|
|
SELECT requests_enabled, global_max_requests, global_window_days,
|
|
global_auto_approval_enabled, updated_at
|
|
FROM request_settings
|
|
WHERE id = true
|
|
`).Scan(&s.RequestsEnabled, &s.GlobalMaxRequests, &s.GlobalWindowDays, &s.GlobalAutoApprovalEnabled, &s.UpdatedAt)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return Settings{
|
|
RequestsEnabled: false,
|
|
GlobalMaxRequests: 5,
|
|
GlobalWindowDays: 7,
|
|
GlobalAutoApprovalEnabled: false,
|
|
}, nil
|
|
}
|
|
return Settings{}, fmt.Errorf("get request settings: %w", err)
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
func (r *Repository) UpdateSettings(ctx context.Context, settings Settings) (Settings, error) {
|
|
if settings.GlobalWindowDays <= 0 {
|
|
settings.GlobalWindowDays = 7
|
|
}
|
|
if settings.GlobalMaxRequests < 0 {
|
|
settings.GlobalMaxRequests = 0
|
|
}
|
|
|
|
var s Settings
|
|
err := r.pool.QueryRow(ctx, `
|
|
INSERT INTO request_settings (
|
|
id, requests_enabled, global_max_requests, global_window_days,
|
|
global_auto_approval_enabled, updated_at
|
|
)
|
|
VALUES (true, $1, $2, $3, $4, now())
|
|
ON CONFLICT (id) DO UPDATE SET
|
|
requests_enabled = EXCLUDED.requests_enabled,
|
|
global_max_requests = EXCLUDED.global_max_requests,
|
|
global_window_days = EXCLUDED.global_window_days,
|
|
global_auto_approval_enabled = EXCLUDED.global_auto_approval_enabled,
|
|
updated_at = now()
|
|
RETURNING requests_enabled, global_max_requests, global_window_days,
|
|
global_auto_approval_enabled, updated_at
|
|
`, settings.RequestsEnabled, settings.GlobalMaxRequests, settings.GlobalWindowDays, settings.GlobalAutoApprovalEnabled).
|
|
Scan(&s.RequestsEnabled, &s.GlobalMaxRequests, &s.GlobalWindowDays, &s.GlobalAutoApprovalEnabled, &s.UpdatedAt)
|
|
if err != nil {
|
|
return Settings{}, fmt.Errorf("update request settings: %w", err)
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
func (r *Repository) GetUserLimit(ctx context.Context, userID int) (*UserLimit, error) {
|
|
var row UserLimit
|
|
var max, window sql.NullInt64
|
|
err := r.pool.QueryRow(ctx, `
|
|
SELECT user_id, limit_mode, max_requests, window_days, approval_mode, updated_at
|
|
FROM request_user_limits
|
|
WHERE user_id = $1
|
|
`, userID).Scan(&row.UserID, &row.LimitMode, &max, &window, &row.ApprovalMode, &row.UpdatedAt)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, nil
|
|
}
|
|
return nil, fmt.Errorf("get request user limit: %w", err)
|
|
}
|
|
if max.Valid {
|
|
v := int(max.Int64)
|
|
row.MaxRequests = &v
|
|
}
|
|
if window.Valid {
|
|
v := int(window.Int64)
|
|
row.WindowDays = &v
|
|
}
|
|
return &row, nil
|
|
}
|
|
|
|
func (r *Repository) UpsertUserLimit(ctx context.Context, limit UserLimit) (*UserLimit, error) {
|
|
var max, window any
|
|
if limit.MaxRequests != nil {
|
|
max = *limit.MaxRequests
|
|
}
|
|
if limit.WindowDays != nil {
|
|
window = *limit.WindowDays
|
|
}
|
|
var row UserLimit
|
|
var scannedMax, scannedWindow sql.NullInt64
|
|
err := r.pool.QueryRow(ctx, `
|
|
INSERT INTO request_user_limits (
|
|
user_id, limit_mode, max_requests, window_days, approval_mode, updated_at
|
|
)
|
|
VALUES ($1, $2, $3, $4, $5, now())
|
|
ON CONFLICT (user_id) DO UPDATE SET
|
|
limit_mode = EXCLUDED.limit_mode,
|
|
max_requests = EXCLUDED.max_requests,
|
|
window_days = EXCLUDED.window_days,
|
|
approval_mode = EXCLUDED.approval_mode,
|
|
updated_at = now()
|
|
RETURNING user_id, limit_mode, max_requests, window_days, approval_mode, updated_at
|
|
`, limit.UserID, limit.LimitMode, max, window, limit.ApprovalMode).
|
|
Scan(&row.UserID, &row.LimitMode, &scannedMax, &scannedWindow, &row.ApprovalMode, &row.UpdatedAt)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("upsert request user limit: %w", err)
|
|
}
|
|
if scannedMax.Valid {
|
|
v := int(scannedMax.Int64)
|
|
row.MaxRequests = &v
|
|
}
|
|
if scannedWindow.Valid {
|
|
v := int(scannedWindow.Int64)
|
|
row.WindowDays = &v
|
|
}
|
|
return &row, nil
|
|
}
|
|
|
|
func (r *Repository) CountUserRequestsSince(ctx context.Context, userID int, since time.Time) (int, error) {
|
|
var count int
|
|
if err := r.pool.QueryRow(ctx, `
|
|
SELECT COUNT(*)
|
|
FROM media_requests
|
|
WHERE requested_by_user_id = $1
|
|
AND created_at >= $2
|
|
`, userID, since).Scan(&count); err != nil {
|
|
return 0, fmt.Errorf("count user requests: %w", err)
|
|
}
|
|
return count, nil
|
|
}
|
|
|
|
func (r *Repository) ListActiveByTMDB(ctx context.Context, mediaType MediaType, tmdbIDs []int) (map[int]*Request, error) {
|
|
if len(tmdbIDs) == 0 {
|
|
return map[int]*Request{}, nil
|
|
}
|
|
rows, err := r.pool.Query(ctx, requestSelectSQL()+`
|
|
WHERE media_type = $1
|
|
AND provider = 'tmdb'
|
|
AND tmdb_id = ANY($2)
|
|
AND outcome = 'active'
|
|
AND status <> 'completed'
|
|
`, mediaType, tmdbIDs)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list active requests by tmdb: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
out := map[int]*Request{}
|
|
for rows.Next() {
|
|
req, err := scanRequest(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out[req.TMDBID] = req
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("iterate active requests by tmdb: %w", err)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// quotaLockNamespace partitions advisory locks so request-quota locks do not
|
|
// collide with advisory locks held elsewhere in the database. The value is
|
|
// arbitrary; what matters is that it is stable.
|
|
const quotaLockNamespace = 139
|
|
|
|
func (r *Repository) CreateRequest(ctx context.Context, input CreateRequestRecord) (*Request, error) {
|
|
tx, err := r.pool.Begin(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("begin create request transaction: %w", err)
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
if input.Quota != nil {
|
|
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock($1::int4, $2::int4)`,
|
|
quotaLockNamespace, input.Quota.UserID); err != nil {
|
|
return nil, fmt.Errorf("acquire request quota lock: %w", err)
|
|
}
|
|
var count int
|
|
if err := tx.QueryRow(ctx, `
|
|
SELECT COUNT(*)
|
|
FROM media_requests
|
|
WHERE requested_by_user_id = $1
|
|
AND created_at >= $2
|
|
`, input.Quota.UserID, input.Quota.WindowStart).Scan(&count); err != nil {
|
|
return nil, fmt.Errorf("count requests for quota: %w", err)
|
|
}
|
|
if count >= input.Quota.MaxRequests {
|
|
return nil, ErrQuotaExceeded
|
|
}
|
|
}
|
|
|
|
now := input.Now
|
|
if now.IsZero() {
|
|
now = time.Now().UTC()
|
|
}
|
|
status := input.Status
|
|
if status == "" {
|
|
status = StatusPending
|
|
}
|
|
outcome := input.Outcome
|
|
if outcome == "" {
|
|
outcome = OutcomeActive
|
|
}
|
|
|
|
var approvedAt any
|
|
if status != StatusPending {
|
|
approvedAt = now
|
|
}
|
|
|
|
req, err := r.insertRequest(ctx, tx, input, status, outcome, now, approvedAt)
|
|
if err != nil {
|
|
var pgErr *pgconn.PgError
|
|
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
|
return nil, ErrAlreadyRequested
|
|
}
|
|
return nil, err
|
|
}
|
|
if err := r.recordEvent(ctx, tx, req.ID, "created", input.Requester, ""); err != nil {
|
|
return nil, err
|
|
}
|
|
if status == StatusApproved {
|
|
if err := r.recordEvent(ctx, tx, req.ID, "approved", input.Requester, "auto approved"); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return nil, fmt.Errorf("commit create request transaction: %w", err)
|
|
}
|
|
return req, nil
|
|
}
|
|
|
|
type requestExecutor interface {
|
|
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
|
|
Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
|
|
}
|
|
|
|
func (r *Repository) insertRequest(
|
|
ctx context.Context,
|
|
exec requestExecutor,
|
|
input CreateRequestRecord,
|
|
status Status,
|
|
outcome Outcome,
|
|
now time.Time,
|
|
approvedAt any,
|
|
) (*Request, error) {
|
|
var tvdbID any
|
|
if input.Input.TVDBID != nil {
|
|
tvdbID = *input.Input.TVDBID
|
|
}
|
|
var year any
|
|
if input.Input.Year != nil {
|
|
year = *input.Input.Year
|
|
}
|
|
row := exec.QueryRow(ctx, `
|
|
INSERT INTO media_requests (
|
|
id, provider, media_type, tmdb_id, tvdb_id, imdb_id, title, year,
|
|
overview, poster_path, backdrop_path, status, outcome,
|
|
requested_by_user_id, requested_by_profile_id, created_at, updated_at, approved_at
|
|
)
|
|
VALUES (
|
|
$1, 'tmdb', $2, $3, $4, $5, $6, $7,
|
|
$8, $9, $10, $11, $12,
|
|
$13, $14, $15, $15, $16
|
|
)
|
|
RETURNING `+requestColumns(), input.ID, input.Input.MediaType, input.Input.TMDBID, tvdbID,
|
|
strings.TrimSpace(input.Input.IMDbID), strings.TrimSpace(input.Input.Title), year,
|
|
strings.TrimSpace(input.Input.Overview), strings.TrimSpace(input.Input.PosterPath),
|
|
strings.TrimSpace(input.Input.BackdropPath), status, outcome,
|
|
input.Requester.UserID, input.Requester.ProfileID, now, approvedAt)
|
|
req, err := scanRequest(row)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("insert request: %w", err)
|
|
}
|
|
return req, nil
|
|
}
|
|
|
|
func (r *Repository) GetRequest(ctx context.Context, id string) (*Request, error) {
|
|
req, err := scanRequest(r.pool.QueryRow(ctx, requestSelectSQL()+`
|
|
WHERE id = $1
|
|
`, id))
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, ErrNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
return req, nil
|
|
}
|
|
|
|
func (r *Repository) ListReconciliationCandidates(ctx context.Context, limit int) ([]*Request, error) {
|
|
if limit <= 0 || limit > 500 {
|
|
limit = 100
|
|
}
|
|
rows, err := r.pool.Query(ctx, requestSelectSQL()+`
|
|
WHERE outcome = 'active'
|
|
AND status IN ('approved', 'queued', 'downloading')
|
|
ORDER BY updated_at ASC
|
|
LIMIT $1
|
|
`, limit)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list request reconciliation candidates: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []*Request
|
|
for rows.Next() {
|
|
req, err := scanRequest(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, req)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("iterate request reconciliation candidates: %w", err)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (r *Repository) ListMine(ctx context.Context, userID int, filter ListFilter) ([]*Request, error) {
|
|
sqlText, args := buildRequestListSQL("requested_by_user_id = $1", []any{userID}, filter)
|
|
return r.listRequests(ctx, sqlText, args)
|
|
}
|
|
|
|
func (r *Repository) ListAdmin(ctx context.Context, filter ListFilter) ([]*Request, error) {
|
|
sqlText, args := buildRequestListSQL("true", nil, filter)
|
|
return r.listRequests(ctx, sqlText, args)
|
|
}
|
|
|
|
func (r *Repository) listRequests(ctx context.Context, sqlText string, args []any) ([]*Request, error) {
|
|
rows, err := r.pool.Query(ctx, sqlText, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list requests: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
var out []*Request
|
|
for rows.Next() {
|
|
req, err := scanRequest(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, req)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("iterate requests: %w", err)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (r *Repository) SetStatus(ctx context.Context, id string, status Status, actor Viewer) (*Request, error) {
|
|
tx, err := r.pool.Begin(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("begin request status transaction: %w", err)
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
req, err := scanRequest(tx.QueryRow(ctx, `
|
|
UPDATE media_requests
|
|
SET status = $2,
|
|
updated_at = now(),
|
|
approved_at = CASE WHEN $2 = 'approved' AND approved_at IS NULL THEN now() ELSE approved_at END,
|
|
completed_at = CASE WHEN $2 = 'completed' AND completed_at IS NULL THEN now() ELSE completed_at END
|
|
WHERE id = $1
|
|
RETURNING `+requestColumns(), id, status))
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, ErrNotFound
|
|
}
|
|
return nil, fmt.Errorf("set request status: %w", err)
|
|
}
|
|
if err := r.recordEvent(ctx, tx, id, "status_"+string(status), actor, ""); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return nil, fmt.Errorf("commit request status transaction: %w", err)
|
|
}
|
|
return req, nil
|
|
}
|
|
|
|
func (r *Repository) MarkQueued(ctx context.Context, id string, update QueueUpdate, actor Viewer) (*Request, error) {
|
|
tx, err := r.pool.Begin(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("begin request queue transaction: %w", err)
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
externalStatus := strings.TrimSpace(update.ExternalStatus)
|
|
if externalStatus == "" {
|
|
externalStatus = "queued"
|
|
}
|
|
req, err := scanRequest(tx.QueryRow(ctx, `
|
|
UPDATE media_requests
|
|
SET status = 'queued',
|
|
outcome = 'active',
|
|
integration_kind = $2,
|
|
external_id = $3,
|
|
external_status = $4,
|
|
last_error = '',
|
|
updated_at = now(),
|
|
approved_at = CASE WHEN approved_at IS NULL THEN now() ELSE approved_at END
|
|
WHERE id = $1
|
|
RETURNING `+requestColumns(), id,
|
|
strings.TrimSpace(update.IntegrationKind),
|
|
strings.TrimSpace(update.ExternalID),
|
|
externalStatus,
|
|
))
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, ErrNotFound
|
|
}
|
|
return nil, fmt.Errorf("mark request queued: %w", err)
|
|
}
|
|
if err := r.recordEvent(ctx, tx, id, "status_queued", actor, externalStatus); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return nil, fmt.Errorf("commit request queue transaction: %w", err)
|
|
}
|
|
return req, nil
|
|
}
|
|
|
|
func (r *Repository) SetOutcome(ctx context.Context, id string, outcome Outcome, actor Viewer, message string) (*Request, error) {
|
|
tx, err := r.pool.Begin(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("begin request outcome transaction: %w", err)
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
req, err := scanRequest(tx.QueryRow(ctx, `
|
|
UPDATE media_requests
|
|
SET outcome = $2,
|
|
last_error = CASE
|
|
WHEN $2 = 'failed' THEN $3
|
|
WHEN $2 = 'active' THEN ''
|
|
ELSE last_error
|
|
END,
|
|
updated_at = now()
|
|
WHERE id = $1
|
|
RETURNING `+requestColumns(), id, outcome, strings.TrimSpace(message)))
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, ErrNotFound
|
|
}
|
|
return nil, fmt.Errorf("set request outcome: %w", err)
|
|
}
|
|
if err := r.recordEvent(ctx, tx, id, "outcome_"+string(outcome), actor, message); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return nil, fmt.Errorf("commit request outcome transaction: %w", err)
|
|
}
|
|
return req, nil
|
|
}
|
|
|
|
func (r *Repository) ListIntegrations(ctx context.Context) ([]Integration, error) {
|
|
rows, err := r.pool.Query(ctx, `
|
|
SELECT kind, enabled, base_url, api_key_ref, root_folder, quality_profile_id,
|
|
tags, options, last_check_at, last_check_status, last_check_error, updated_at
|
|
FROM request_integrations
|
|
ORDER BY kind
|
|
`)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list request integrations: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []Integration
|
|
for rows.Next() {
|
|
integration, err := scanIntegration(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, integration)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("iterate request integrations: %w", err)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (r *Repository) UpsertIntegration(ctx context.Context, integration Integration) (*Integration, error) {
|
|
out, err := r.upsertIntegration(ctx, r.pool, integration)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("upsert request integration: %w", err)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (r *Repository) UpsertIntegrations(ctx context.Context, integrations []Integration) ([]Integration, error) {
|
|
tx, err := r.pool.Begin(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("begin request integrations transaction: %w", err)
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
out := make([]Integration, 0, len(integrations))
|
|
for _, integration := range integrations {
|
|
updated, err := r.upsertIntegration(ctx, tx, integration)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, *updated)
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return nil, fmt.Errorf("commit request integrations transaction: %w", err)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (r *Repository) upsertIntegration(ctx context.Context, exec requestExecutor, integration Integration) (*Integration, error) {
|
|
if integration.Options == nil {
|
|
integration.Options = map[string]any{}
|
|
}
|
|
options, err := json.Marshal(integration.Options)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshal request integration options: %w", err)
|
|
}
|
|
tags := int32Slice(integration.Tags)
|
|
row := exec.QueryRow(ctx, `
|
|
INSERT INTO request_integrations (
|
|
kind, enabled, base_url, api_key_ref, root_folder, quality_profile_id,
|
|
tags, options, updated_at
|
|
)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, now())
|
|
ON CONFLICT (kind) DO UPDATE SET
|
|
enabled = EXCLUDED.enabled,
|
|
base_url = EXCLUDED.base_url,
|
|
api_key_ref = CASE
|
|
WHEN EXCLUDED.api_key_ref = '' THEN request_integrations.api_key_ref
|
|
ELSE EXCLUDED.api_key_ref
|
|
END,
|
|
root_folder = EXCLUDED.root_folder,
|
|
quality_profile_id = EXCLUDED.quality_profile_id,
|
|
tags = EXCLUDED.tags,
|
|
options = EXCLUDED.options,
|
|
updated_at = now()
|
|
RETURNING kind, enabled, base_url, api_key_ref, root_folder, quality_profile_id,
|
|
tags, options, last_check_at, last_check_status, last_check_error, updated_at
|
|
`, integration.Kind, integration.Enabled, strings.TrimSpace(integration.BaseURL),
|
|
strings.TrimSpace(integration.APIKeyRef), strings.TrimSpace(integration.RootFolder),
|
|
integration.QualityProfileID, tags, options)
|
|
out, err := scanIntegration(row)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &out, nil
|
|
}
|
|
|
|
func (r *Repository) recordEvent(ctx context.Context, exec requestExecutor, requestID, eventType string, actor Viewer, message string) error {
|
|
var actorUserID any
|
|
if actor.UserID > 0 {
|
|
actorUserID = actor.UserID
|
|
}
|
|
_, err := exec.Exec(ctx, `
|
|
INSERT INTO media_request_events (
|
|
request_id, event_type, actor_user_id, actor_profile_id, message
|
|
)
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
`, requestID, eventType, actorUserID, actor.ProfileID, strings.TrimSpace(message))
|
|
if err != nil {
|
|
return fmt.Errorf("record request event: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func buildRequestListSQL(baseCondition string, baseArgs []any, filter ListFilter) (string, []any) {
|
|
args := append([]any(nil), baseArgs...)
|
|
conditions := []string{baseCondition}
|
|
if filter.Status != "" {
|
|
args = append(args, filter.Status)
|
|
conditions = append(conditions, "status = $"+strconv.Itoa(len(args)))
|
|
}
|
|
if filter.Outcome != "" {
|
|
args = append(args, filter.Outcome)
|
|
conditions = append(conditions, "outcome = $"+strconv.Itoa(len(args)))
|
|
}
|
|
limit := filter.Limit
|
|
if limit <= 0 || limit > 100 {
|
|
limit = 50
|
|
}
|
|
offset := filter.Offset
|
|
if offset < 0 {
|
|
offset = 0
|
|
}
|
|
args = append(args, limit, offset)
|
|
return requestSelectSQL() + `
|
|
WHERE ` + strings.Join(conditions, " AND ") + `
|
|
ORDER BY created_at DESC
|
|
LIMIT $` + strconv.Itoa(len(args)-1) + ` OFFSET $` + strconv.Itoa(len(args)), args
|
|
}
|
|
|
|
func requestSelectSQL() string {
|
|
return "SELECT " + requestColumns() + " FROM media_requests "
|
|
}
|
|
|
|
func requestColumns() string {
|
|
return `id, provider, media_type, tmdb_id, tvdb_id, imdb_id, title, year,
|
|
overview, poster_path, backdrop_path, status, outcome,
|
|
requested_by_user_id, requested_by_profile_id, integration_kind,
|
|
external_id, external_status, last_error, created_at, updated_at,
|
|
approved_at, completed_at`
|
|
}
|
|
|
|
type requestScanner interface {
|
|
Scan(dest ...any) error
|
|
}
|
|
|
|
func scanRequest(row requestScanner) (*Request, error) {
|
|
var req Request
|
|
var tvdbID, year sql.NullInt64
|
|
var approvedAt, completedAt sql.NullTime
|
|
if err := row.Scan(
|
|
&req.ID,
|
|
&req.Provider,
|
|
&req.MediaType,
|
|
&req.TMDBID,
|
|
&tvdbID,
|
|
&req.IMDbID,
|
|
&req.Title,
|
|
&year,
|
|
&req.Overview,
|
|
&req.PosterPath,
|
|
&req.BackdropPath,
|
|
&req.Status,
|
|
&req.Outcome,
|
|
&req.RequestedByUserID,
|
|
&req.RequestedByProfileID,
|
|
&req.IntegrationKind,
|
|
&req.ExternalID,
|
|
&req.ExternalStatus,
|
|
&req.LastError,
|
|
&req.CreatedAt,
|
|
&req.UpdatedAt,
|
|
&approvedAt,
|
|
&completedAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
if tvdbID.Valid {
|
|
v := int(tvdbID.Int64)
|
|
req.TVDBID = &v
|
|
}
|
|
if year.Valid {
|
|
v := int(year.Int64)
|
|
req.Year = &v
|
|
}
|
|
if approvedAt.Valid {
|
|
req.ApprovedAt = &approvedAt.Time
|
|
}
|
|
if completedAt.Valid {
|
|
req.CompletedAt = &completedAt.Time
|
|
}
|
|
return &req, nil
|
|
}
|
|
|
|
type integrationScanner interface {
|
|
Scan(dest ...any) error
|
|
}
|
|
|
|
func scanIntegration(row integrationScanner) (Integration, error) {
|
|
var integration Integration
|
|
var quality sql.NullInt64
|
|
var tags []int32
|
|
var optionsRaw []byte
|
|
var lastCheckAt sql.NullTime
|
|
if err := row.Scan(
|
|
&integration.Kind,
|
|
&integration.Enabled,
|
|
&integration.BaseURL,
|
|
&integration.APIKeyRef,
|
|
&integration.RootFolder,
|
|
&quality,
|
|
&tags,
|
|
&optionsRaw,
|
|
&lastCheckAt,
|
|
&integration.LastCheckStatus,
|
|
&integration.LastCheckError,
|
|
&integration.UpdatedAt,
|
|
); err != nil {
|
|
return Integration{}, err
|
|
}
|
|
if quality.Valid {
|
|
v := int(quality.Int64)
|
|
integration.QualityProfileID = &v
|
|
}
|
|
integration.Tags = intsFromInt32(tags)
|
|
if len(optionsRaw) > 0 {
|
|
if err := json.Unmarshal(optionsRaw, &integration.Options); err != nil {
|
|
return Integration{}, fmt.Errorf("unmarshal request integration options for %s: %w", integration.Kind, err)
|
|
}
|
|
}
|
|
if integration.Options == nil {
|
|
integration.Options = map[string]any{}
|
|
}
|
|
if lastCheckAt.Valid {
|
|
integration.LastCheckAt = &lastCheckAt.Time
|
|
}
|
|
return integration, nil
|
|
}
|
|
|
|
func int32Slice(values []int) []int32 {
|
|
out := make([]int32, 0, len(values))
|
|
for _, value := range values {
|
|
out = append(out, int32(value))
|
|
}
|
|
return out
|
|
}
|
|
|
|
func intsFromInt32(values []int32) []int {
|
|
out := make([]int, 0, len(values))
|
|
for _, value := range values {
|
|
out = append(out, int(value))
|
|
}
|
|
return out
|
|
}
|