{"subject":"fix(requests): clear prior failed rows on re-request","body":""}
756 lines
21 KiB
Go
756 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
|
|
}
|
|
|
|
func (r *Repository) DeleteFailedByTMDB(ctx context.Context, mediaType MediaType, tmdbID int) (int, error) {
|
|
if tmdbID <= 0 {
|
|
return 0, nil
|
|
}
|
|
tag, err := r.pool.Exec(ctx, `
|
|
DELETE FROM media_requests
|
|
WHERE media_type = $1
|
|
AND provider = 'tmdb'
|
|
AND tmdb_id = $2
|
|
AND outcome = 'failed'
|
|
`, mediaType, tmdbID)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("delete failed requests by tmdb: %w", err)
|
|
}
|
|
return int(tag.RowsAffected()), 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
|
|
}
|