Notify the requesting profile once its media request is actually present in the catalog (roadmap 06, item 2). Completion transitions stay notification-agnostic; a presence-gated pass at the end of each reconcile run fires the notice, so it means "watchable in Silo", not "download finished". - New System.DispatchOperational: delivery insert + webhook/web-push outbox enqueue in one transaction, post-commit multi-dispatch. The webhook auto-disable notice now rides the same path (replacing its hand-rolled hub publish and the now-removed InsertOperational), which also delivers auto-disable notices over web push. - At-most-once delivery: partial unique index on (profile_id, reason_flags->>'request_id') plus a fulfilled_notified_at marker on media_requests, backfilled for pre-existing completed requests so deploys never flood. - Per-webhook notify_requests toggle (default on) through repo, service, API, and settings UI; gated independently of the episode reason flags. - request.fulfilled rendering in web inbox, realtime toast, web push payload, and Discord/generic webhook payloads, deep-linking to the matched catalog item. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
856 lines
26 KiB
Go
856 lines
26 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"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/secret"
|
|
)
|
|
|
|
type Repository struct {
|
|
pool *pgxpool.Pool
|
|
cipher *secret.Cipher
|
|
}
|
|
|
|
func NewRepository(pool *pgxpool.Pool, cipher *secret.Cipher) *Repository {
|
|
return &Repository{pool: pool, cipher: cipher}
|
|
}
|
|
|
|
func apiKeyAAD(id string) string {
|
|
return secret.RowAAD("request_integrations", "api_key_ref", id)
|
|
}
|
|
|
|
func (r *Repository) encryptAPIKey(id, apiKey string) (string, error) {
|
|
apiKey = strings.TrimSpace(apiKey)
|
|
if apiKey == "" {
|
|
return "", nil
|
|
}
|
|
return r.cipher.Encrypt(apiKey, apiKeyAAD(id))
|
|
}
|
|
|
|
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, force_dual_quality, updated_at
|
|
FROM request_settings
|
|
WHERE id = true
|
|
`).Scan(&s.RequestsEnabled, &s.GlobalMaxRequests, &s.GlobalWindowDays, &s.GlobalAutoApprovalEnabled, &s.ForceDualQuality, &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, force_dual_quality, updated_at
|
|
)
|
|
VALUES (true, $1, $2, $3, $4, $5, 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,
|
|
force_dual_quality = EXCLUDED.force_dual_quality,
|
|
updated_at = now()
|
|
RETURNING requests_enabled, global_max_requests, global_window_days,
|
|
global_auto_approval_enabled, force_dual_quality, updated_at
|
|
`, settings.RequestsEnabled, settings.GlobalMaxRequests, settings.GlobalWindowDays, settings.GlobalAutoApprovalEnabled, settings.ForceDualQuality).
|
|
Scan(&s.RequestsEnabled, &s.GlobalMaxRequests, &s.GlobalWindowDays, &s.GlobalAutoApprovalEnabled, &s.ForceDualQuality, &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
|
|
Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
|
|
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, is_anime, created_at, updated_at, approved_at
|
|
)
|
|
VALUES (
|
|
$1, 'tmdb', $2, $3, $4, $5, $6, $7,
|
|
$8, $9, $10, $11, $12,
|
|
$13, $14, $15, $16, $16, $17
|
|
)
|
|
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, input.IsAnime, 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
|
|
}
|
|
|
|
// ListFulfilledUnnotified returns completed requests whose fulfillment
|
|
// notification has not fired, oldest first. The horizon bounds how long a
|
|
// completed request keeps being presence-polled when its media never appears
|
|
// in the catalog (requests completed before the feature shipped are stamped
|
|
// by the migration backfill, so the horizon is defense in depth).
|
|
func (r *Repository) ListFulfilledUnnotified(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 = 'completed'
|
|
AND fulfilled_notified_at IS NULL
|
|
AND completed_at > now() - interval '30 days'
|
|
ORDER BY completed_at ASC
|
|
LIMIT $1
|
|
`, limit)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list fulfilled unnotified 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 fulfilled unnotified requests: %w", err)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// MarkFulfilledNotified stamps the fulfillment-notification marker. Idempotent.
|
|
func (r *Repository) MarkFulfilledNotified(ctx context.Context, id string) error {
|
|
_, err := r.pool.Exec(ctx, `
|
|
UPDATE media_requests SET fulfilled_notified_at = now()
|
|
WHERE id = $1 AND fulfilled_notified_at IS NULL`, id)
|
|
if err != nil {
|
|
return fmt.Errorf("mark request fulfill-notified: %w", err)
|
|
}
|
|
return 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) 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
|
|
}
|
|
|
|
const integrationColumns = `id, name, enabled, base_url, api_key_ref,
|
|
last_check_at, last_check_status, last_check_error, updated_at,
|
|
capability_id, installation_id, supported_media_types, plugin_config`
|
|
|
|
func (r *Repository) ListIntegrations(ctx context.Context) ([]Integration, error) {
|
|
rows, err := r.pool.Query(ctx, `SELECT `+integrationColumns+` FROM request_integrations ORDER BY name`)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list request integrations: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []Integration
|
|
for rows.Next() {
|
|
integration, err := r.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) GetIntegration(ctx context.Context, id string) (*Integration, error) {
|
|
row := r.pool.QueryRow(ctx, `SELECT `+integrationColumns+
|
|
` FROM request_integrations WHERE id = $1`, id)
|
|
i, err := r.scanIntegration(row)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, ErrNotFound
|
|
}
|
|
return nil, fmt.Errorf("get request integration: %w", err)
|
|
}
|
|
return &i, nil
|
|
}
|
|
|
|
func (r *Repository) CreateIntegration(ctx context.Context, i Integration) (*Integration, error) {
|
|
return r.insertIntegration(ctx, r.pool, i)
|
|
}
|
|
|
|
func (r *Repository) UpdateIntegration(ctx context.Context, i Integration) (*Integration, error) {
|
|
return r.updateIntegration(ctx, r.pool, i)
|
|
}
|
|
|
|
// insertIntegration runs the integration INSERT against any executor (pool or
|
|
// tx) so the same SQL is reused by the plain create path and the transactional
|
|
// SaveIntegrationWithDefaults path.
|
|
func (r *Repository) insertIntegration(ctx context.Context, exec requestExecutor, i Integration) (*Integration, error) {
|
|
if i.PluginConfig == nil {
|
|
i.PluginConfig = map[string]any{}
|
|
}
|
|
pluginConfig, err := json.Marshal(i.PluginConfig)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshal plugin config: %w", err)
|
|
}
|
|
// capability_id is the capability sub-id ("arr"/"seerr"), validated non-empty
|
|
// upstream in validateInstance; persist it verbatim (never default it to the
|
|
// capability type, which the plugin runtime can't resolve).
|
|
capabilityID := strings.TrimSpace(i.CapabilityID)
|
|
supportedMediaTypes := i.SupportedMediaTypes
|
|
if supportedMediaTypes == nil {
|
|
supportedMediaTypes = []string{}
|
|
}
|
|
apiKeyRef, err := r.encryptAPIKey(i.ID, i.APIKeyRef)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("encrypt api key: %w", err)
|
|
}
|
|
row := exec.QueryRow(ctx, `
|
|
INSERT INTO request_integrations (
|
|
id, name, enabled, base_url, api_key_ref,
|
|
capability_id, installation_id, supported_media_types,
|
|
plugin_config, updated_at)
|
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9, now())
|
|
RETURNING `+integrationColumns,
|
|
i.ID, strings.TrimSpace(i.Name), i.Enabled, strings.TrimSpace(i.BaseURL),
|
|
apiKeyRef, capabilityID, i.InstallationID, supportedMediaTypes, pluginConfig)
|
|
out, err := r.scanIntegration(row)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create request integration: %w", err)
|
|
}
|
|
return &out, nil
|
|
}
|
|
|
|
// updateIntegration runs the integration UPDATE against any executor (pool or
|
|
// tx) so the plain update path and SaveIntegrationWithDefaults share the SQL.
|
|
func (r *Repository) updateIntegration(ctx context.Context, exec requestExecutor, i Integration) (*Integration, error) {
|
|
if i.PluginConfig == nil {
|
|
i.PluginConfig = map[string]any{}
|
|
}
|
|
pluginConfig, err := json.Marshal(i.PluginConfig)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshal plugin config: %w", err)
|
|
}
|
|
// capability_id is the capability sub-id ("arr"/"seerr"), validated non-empty
|
|
// upstream in validateInstance; persist it verbatim (never default it to the
|
|
// capability type, which the plugin runtime can't resolve).
|
|
capabilityID := strings.TrimSpace(i.CapabilityID)
|
|
supportedMediaTypes := i.SupportedMediaTypes
|
|
if supportedMediaTypes == nil {
|
|
supportedMediaTypes = []string{}
|
|
}
|
|
// Encrypt the incoming key; an empty result preserves the keep-existing CASE
|
|
// (a blank edit leaves the stored key untouched).
|
|
apiKeyRef, err := r.encryptAPIKey(i.ID, i.APIKeyRef)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("encrypt api key: %w", err)
|
|
}
|
|
row := exec.QueryRow(ctx, `
|
|
UPDATE request_integrations SET
|
|
name=$2, enabled=$3, base_url=$4,
|
|
api_key_ref = CASE WHEN $5 = '' THEN api_key_ref ELSE $5 END,
|
|
capability_id=$6, installation_id=$7,
|
|
supported_media_types=$8, plugin_config=$9, updated_at=now()
|
|
WHERE id=$1
|
|
RETURNING `+integrationColumns,
|
|
i.ID, strings.TrimSpace(i.Name), i.Enabled, strings.TrimSpace(i.BaseURL),
|
|
apiKeyRef, capabilityID, i.InstallationID, supportedMediaTypes, pluginConfig)
|
|
out, err := r.scanIntegration(row)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, ErrNotFound
|
|
}
|
|
return nil, fmt.Errorf("update request integration: %w", err)
|
|
}
|
|
return &out, nil
|
|
}
|
|
|
|
// SaveIntegrationWithDefaults creates/updates the instance in a single
|
|
// transaction. The host no longer enforces a single HD/4K default per kind;
|
|
// the request_router plugin's RouteTargets picks the first is_default
|
|
// connection from plugin_config, so the save path is a plain insert-or-update.
|
|
func (r *Repository) SaveIntegrationWithDefaults(ctx context.Context, in Integration, isCreate bool) (*Integration, error) {
|
|
tx, err := r.pool.Begin(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("begin save integration: %w", err)
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
var out *Integration
|
|
if isCreate {
|
|
out, err = r.insertIntegration(ctx, tx, in)
|
|
} else {
|
|
out, err = r.updateIntegration(ctx, tx, in)
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return nil, fmt.Errorf("commit save integration: %w", err)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (r *Repository) DeleteIntegration(ctx context.Context, id string) error {
|
|
tx, err := r.pool.Begin(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("begin delete integration: %w", err)
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
var lockedID string
|
|
if err := tx.QueryRow(ctx, `
|
|
SELECT id FROM request_integrations WHERE id = $1 FOR UPDATE
|
|
`, id).Scan(&lockedID); err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return ErrNotFound
|
|
}
|
|
return fmt.Errorf("lock request integration: %w", err)
|
|
}
|
|
|
|
var hasLiveTargets bool
|
|
if err := tx.QueryRow(ctx, `
|
|
SELECT EXISTS (
|
|
SELECT 1 FROM media_request_targets
|
|
WHERE integration_id = $1 AND status IN ('queued', 'downloading')
|
|
)
|
|
`, id).Scan(&hasLiveTargets); err != nil {
|
|
return fmt.Errorf("check integration targets: %w", err)
|
|
}
|
|
if hasLiveTargets {
|
|
return ErrInvalidState
|
|
}
|
|
|
|
if _, err := tx.Exec(ctx, `DELETE FROM request_integrations WHERE id = $1`, id); err != nil {
|
|
return fmt.Errorf("delete request integration: %w", err)
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return fmt.Errorf("commit delete integration: %w", err)
|
|
}
|
|
return 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, is_anime,
|
|
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.IsAnime,
|
|
&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 (r *Repository) scanIntegration(row integrationScanner) (Integration, error) {
|
|
var i Integration
|
|
var installationID sql.NullInt64
|
|
var pluginConfigRaw []byte
|
|
var lastCheckAt sql.NullTime
|
|
if err := row.Scan(
|
|
&i.ID, &i.Name, &i.Enabled, &i.BaseURL, &i.APIKeyRef,
|
|
&lastCheckAt, &i.LastCheckStatus, &i.LastCheckError, &i.UpdatedAt,
|
|
&i.CapabilityID, &installationID, &i.SupportedMediaTypes, &pluginConfigRaw,
|
|
); err != nil {
|
|
return Integration{}, err
|
|
}
|
|
// Decrypt the stored api key (read-path contract: legacy plaintext passes
|
|
// through, enc:v1: values decrypt, corrupt ciphertext errors). Callers
|
|
// receive the literal key — there is no longer a ref/literal ambiguity.
|
|
apiKey, err := r.cipher.DecryptIfEncrypted(i.APIKeyRef, apiKeyAAD(i.ID))
|
|
if err != nil {
|
|
return Integration{}, fmt.Errorf("decrypt request integration %s api key: %w", i.ID, err)
|
|
}
|
|
i.APIKeyRef = apiKey
|
|
if installationID.Valid {
|
|
v := int(installationID.Int64)
|
|
i.InstallationID = &v
|
|
}
|
|
if len(pluginConfigRaw) > 0 {
|
|
if err := json.Unmarshal(pluginConfigRaw, &i.PluginConfig); err != nil {
|
|
return Integration{}, fmt.Errorf("unmarshal request integration plugin config for %s: %w", i.ID, err)
|
|
}
|
|
}
|
|
if i.PluginConfig == nil {
|
|
i.PluginConfig = map[string]any{}
|
|
}
|
|
if lastCheckAt.Valid {
|
|
i.LastCheckAt = &lastCheckAt.Time
|
|
}
|
|
return i, nil
|
|
}
|