Files
silo-server/internal/historyimport/repo.go
T

952 lines
30 KiB
Go

package historyimport
import (
"context"
"encoding/json"
"errors"
"fmt"
"sort"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var (
ErrSourceNotFound = errors.New("history import source not found")
ErrRunNotFound = errors.New("history import run not found")
ErrProfileNotFound = errors.New("profile not found")
ErrConnectSessionNotFound = errors.New("history import connect session not found")
ErrConnectSessionExpired = errors.New("history import connect session expired")
ErrConnectSessionUsed = errors.New("history import connect session already used")
ErrPlexSessionNotFound = errors.New("plex session not found")
ErrPlexSessionExpired = errors.New("plex session expired")
ErrPlexSessionUsed = errors.New("plex session already used")
)
type Repository struct {
pool *pgxpool.Pool
}
func NewRepository(pool *pgxpool.Pool) *Repository {
return &Repository{pool: pool}
}
func (r *Repository) ListEnabledSources(ctx context.Context) ([]Source, error) {
rows, err := r.pool.Query(ctx, `
SELECT id, name, source_type, '' AS base_url, COALESCE(system_id, ''), enabled, sort_order,
(admin_token IS NOT NULL) AS has_admin_token,
created_at, updated_at
FROM history_import_sources
WHERE enabled = TRUE
ORDER BY sort_order ASC, name ASC, id ASC`)
if err != nil {
return nil, fmt.Errorf("listing enabled history import sources: %w", err)
}
defer rows.Close()
return scanSources(rows)
}
func (r *Repository) ListAdminSources(ctx context.Context) ([]Source, error) {
rows, err := r.pool.Query(ctx, `
SELECT id, name, source_type, base_url, COALESCE(system_id, ''), enabled, sort_order,
(admin_token IS NOT NULL) AS has_admin_token,
created_at, updated_at
FROM history_import_sources
ORDER BY sort_order ASC, name ASC, id ASC`)
if err != nil {
return nil, fmt.Errorf("listing admin history import sources: %w", err)
}
defer rows.Close()
return scanSources(rows)
}
func (r *Repository) GetSourceByID(ctx context.Context, id int) (*Source, error) {
row := r.pool.QueryRow(ctx, `
SELECT id, name, source_type, base_url, COALESCE(system_id, ''), enabled, sort_order,
(admin_token IS NOT NULL) AS has_admin_token,
created_at, updated_at
FROM history_import_sources
WHERE id = $1`, id)
source, err := scanSource(row)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrSourceNotFound
}
if err != nil {
return nil, fmt.Errorf("getting history import source %d: %w", id, err)
}
return source, nil
}
func (r *Repository) CreateSource(ctx context.Context, input CreateSourceInput) (*Source, error) {
row := r.pool.QueryRow(ctx, `
INSERT INTO history_import_sources (name, source_type, base_url, system_id, enabled, sort_order,
admin_token)
VALUES ($1, $2, $3, NULLIF($4, ''), $5, $6, NULLIF($7, ''))
RETURNING id, name, source_type, base_url, COALESCE(system_id, ''), enabled, sort_order,
(admin_token IS NOT NULL) AS has_admin_token,
created_at, updated_at`,
input.Name, input.SourceType, input.BaseURL, input.SystemID, input.Enabled, input.SortOrder,
input.AdminToken,
)
source, err := scanSource(row)
if err != nil {
return nil, fmt.Errorf("creating history import source: %w", err)
}
return source, nil
}
func (r *Repository) UpdateSource(ctx context.Context, id int, input UpdateSourceInput) (*Source, error) {
result, err := r.pool.Exec(ctx, `
UPDATE history_import_sources
SET
name = COALESCE($2::text, name),
base_url = COALESCE($3::text, base_url),
system_id = CASE
WHEN $4::text IS NULL THEN system_id
WHEN $4::text = '' THEN NULL
ELSE $4::text
END,
enabled = COALESCE($5::boolean, enabled),
sort_order = COALESCE($6::integer, sort_order),
updated_at = NOW()
WHERE id = $1`,
id, input.Name, input.BaseURL, input.SystemID, input.Enabled, input.SortOrder,
)
if err != nil {
return nil, fmt.Errorf("updating history import source %d: %w", id, err)
}
if result.RowsAffected() == 0 {
return nil, ErrSourceNotFound
}
return r.GetSourceByID(ctx, id)
}
func (r *Repository) DeleteSource(ctx context.Context, id int) error {
result, err := r.pool.Exec(ctx, `DELETE FROM history_import_sources WHERE id = $1`, id)
if err != nil {
return fmt.Errorf("deleting history import source %d: %w", id, err)
}
if result.RowsAffected() == 0 {
return ErrSourceNotFound
}
return nil
}
func (r *Repository) CreateConnectSession(ctx context.Context, session ConnectSession) (*ConnectSession, error) {
serversJSON, err := json.Marshal(session.Servers)
if err != nil {
return nil, fmt.Errorf("marshaling connect servers: %w", err)
}
row := r.pool.QueryRow(ctx, `
INSERT INTO history_import_connect_sessions (
id, user_id, connect_user_id, connect_access_token, servers_json, expires_at
) VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, user_id, connect_user_id, connect_access_token, servers_json, expires_at, consumed_at, created_at, updated_at`,
session.ID, session.UserID, session.ConnectUserID, session.ConnectAccessToken, serversJSON, session.ExpiresAt,
)
stored, err := scanConnectSession(row)
if err != nil {
return nil, fmt.Errorf("creating connect session: %w", err)
}
return stored, nil
}
func (r *Repository) GetConnectSession(ctx context.Context, userID int, sessionID string) (*ConnectSession, error) {
row := r.pool.QueryRow(ctx, `
SELECT id, user_id, connect_user_id, connect_access_token, servers_json, expires_at, consumed_at, created_at, updated_at
FROM history_import_connect_sessions
WHERE id = $1 AND user_id = $2`, sessionID, userID)
session, err := scanConnectSession(row)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrConnectSessionNotFound
}
if err != nil {
return nil, fmt.Errorf("getting connect session %s: %w", sessionID, err)
}
if session.ExpiresAt.Before(time.Now().UTC()) {
return nil, ErrConnectSessionExpired
}
if session.ConsumedAt != nil {
return nil, ErrConnectSessionUsed
}
return session, nil
}
func (r *Repository) ConsumeConnectSession(ctx context.Context, sessionID string) error {
result, err := r.pool.Exec(ctx, `
UPDATE history_import_connect_sessions
SET consumed_at = NOW(), updated_at = NOW()
WHERE id = $1 AND consumed_at IS NULL`, sessionID)
if err != nil {
return fmt.Errorf("consuming connect session %s: %w", sessionID, err)
}
if result.RowsAffected() == 0 {
return ErrConnectSessionNotFound
}
return nil
}
func (r *Repository) DeleteExpiredConnectSessions(ctx context.Context) error {
_, err := r.pool.Exec(ctx, `
DELETE FROM history_import_connect_sessions
WHERE expires_at < NOW() OR consumed_at IS NOT NULL`)
if err != nil {
return fmt.Errorf("deleting expired connect sessions: %w", err)
}
return nil
}
func (r *Repository) CreatePlexSession(ctx context.Context, session PlexSession) (*PlexSession, error) {
serversJSON, err := json.Marshal(session.Servers)
if err != nil {
return nil, fmt.Errorf("marshaling plex servers: %w", err)
}
row := r.pool.QueryRow(ctx, `
INSERT INTO history_import_plex_sessions (
id, user_id, pin_id, pin_code, auth_token, servers_json, expires_at
) VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, user_id, pin_id, pin_code, auth_token, servers_json, expires_at, consumed_at, created_at, updated_at`,
session.ID, session.UserID, session.PinID, session.PinCode, nilIfEmpty(session.AuthToken), serversJSON, session.ExpiresAt,
)
stored, err := scanPlexSession(row)
if err != nil {
return nil, fmt.Errorf("creating plex session: %w", err)
}
return stored, nil
}
func (r *Repository) GetPlexSession(ctx context.Context, userID int, sessionID string) (*PlexSession, error) {
row := r.pool.QueryRow(ctx, `
SELECT id, user_id, pin_id, pin_code, COALESCE(auth_token, ''), servers_json, expires_at, consumed_at, created_at, updated_at
FROM history_import_plex_sessions
WHERE id = $1 AND user_id = $2`, sessionID, userID)
session, err := scanPlexSession(row)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrPlexSessionNotFound
}
if err != nil {
return nil, fmt.Errorf("getting plex session %s: %w", sessionID, err)
}
if session.ExpiresAt.Before(time.Now().UTC()) {
return nil, ErrPlexSessionExpired
}
if session.ConsumedAt != nil {
return nil, ErrPlexSessionUsed
}
return session, nil
}
func (r *Repository) UpdatePlexSessionAuth(ctx context.Context, sessionID, authToken string, servers []PlexServer) error {
serversJSON, err := json.Marshal(servers)
if err != nil {
return fmt.Errorf("marshaling plex servers: %w", err)
}
result, err := r.pool.Exec(ctx, `
UPDATE history_import_plex_sessions
SET auth_token = $2, servers_json = $3, updated_at = NOW()
WHERE id = $1`, sessionID, authToken, serversJSON)
if err != nil {
return fmt.Errorf("updating plex session auth %s: %w", sessionID, err)
}
if result.RowsAffected() == 0 {
return ErrPlexSessionNotFound
}
return nil
}
func (r *Repository) ConsumePlexSession(ctx context.Context, sessionID string) error {
result, err := r.pool.Exec(ctx, `
UPDATE history_import_plex_sessions
SET consumed_at = NOW(), updated_at = NOW()
WHERE id = $1 AND consumed_at IS NULL`, sessionID)
if err != nil {
return fmt.Errorf("consuming plex session %s: %w", sessionID, err)
}
if result.RowsAffected() == 0 {
return ErrPlexSessionNotFound
}
return nil
}
func (r *Repository) DeleteExpiredPlexSessions(ctx context.Context) error {
_, err := r.pool.Exec(ctx, `
DELETE FROM history_import_plex_sessions
WHERE expires_at < NOW() OR consumed_at IS NOT NULL`)
if err != nil {
return fmt.Errorf("deleting expired plex sessions: %w", err)
}
return nil
}
func (r *Repository) ProfileExistsForUser(ctx context.Context, userID int, profileID string) (bool, error) {
var exists bool
if err := r.pool.QueryRow(ctx,
`SELECT EXISTS(SELECT 1 FROM user_profiles WHERE user_id = $1 AND id = $2)`,
userID, profileID,
).Scan(&exists); err != nil {
return false, fmt.Errorf("checking profile ownership: %w", err)
}
return exists, nil
}
func (r *Repository) CreateRun(ctx context.Context, run Run) (*Run, error) {
warningsJSON, err := json.Marshal(nonNilStrings(run.Warnings))
if err != nil {
return nil, fmt.Errorf("marshaling run warnings: %w", err)
}
unmatchedJSON, err := json.Marshal(nonNilUnmatchedSamples(run.UnmatchedSamples))
if err != nil {
return nil, fmt.Errorf("marshaling run unmatched samples: %w", err)
}
row := r.pool.QueryRow(ctx, `
INSERT INTO history_import_runs (
id, user_id, profile_id, source_type, connection_mode, status,
mapping_id,
fetched, matched, unmatched, progress_updated, history_created, skipped,
warnings, unmatched_samples, error_message
) VALUES (
$1, $2, $3, $4, $5, $6,
$7,
$8, $9, $10, $11, $12, $13,
$14, $15, NULLIF($16, '')
)
RETURNING id, user_id, profile_id, source_type, connection_mode, status,
mapping_id,
fetched, matched, unmatched, progress_updated, history_created, skipped,
warnings, unmatched_samples, COALESCE(error_message, ''), created_at, started_at, completed_at`,
run.ID, run.UserID, run.ProfileID, run.SourceType, run.ConnectionMode, run.Status,
run.MappingID,
run.Fetched, run.Matched, run.Unmatched, run.ProgressUpdated, run.HistoryCreated, run.Skipped,
warningsJSON, unmatchedJSON, run.ErrorMessage,
)
created, err := scanRunWithMappingID(row)
if err != nil {
return nil, fmt.Errorf("creating history import run: %w", err)
}
return created, nil
}
func (r *Repository) MarkRunStarted(ctx context.Context, runID string) error {
result, err := r.pool.Exec(ctx, `
UPDATE history_import_runs
SET status = $2, started_at = NOW(), last_heartbeat_at = NOW()
WHERE id = $1`, runID, RunStatusRunning)
if err != nil {
return fmt.Errorf("marking run %s started: %w", runID, err)
}
if result.RowsAffected() == 0 {
return ErrRunNotFound
}
return nil
}
func (r *Repository) TouchRunHeartbeat(ctx context.Context, runID string) error {
result, err := r.pool.Exec(ctx, `
UPDATE history_import_runs
SET last_heartbeat_at = NOW()
WHERE id = $1 AND status = $2`, runID, RunStatusRunning)
if err != nil {
return fmt.Errorf("touching run %s heartbeat: %w", runID, err)
}
if result.RowsAffected() == 0 {
return ErrRunNotFound
}
return nil
}
func (r *Repository) CompleteRun(ctx context.Context, runID string, summary ExecutionSummary) error {
warningsJSON, err := json.Marshal(trimWarnings(persistedWarnings(summary)))
if err != nil {
return fmt.Errorf("marshaling run warnings: %w", err)
}
unmatchedJSON, err := json.Marshal(trimUnmatchedSamples(summary.UnmatchedSamples))
if err != nil {
return fmt.Errorf("marshaling unmatched samples: %w", err)
}
result, err := r.pool.Exec(ctx, `
UPDATE history_import_runs
SET
status = $2,
fetched = $3,
matched = $4,
unmatched = $5,
progress_updated = $6,
history_created = $7,
skipped = $8,
warnings = $9,
unmatched_samples = $10,
error_message = NULL,
completed_at = NOW(),
last_heartbeat_at = NOW()
WHERE id = $1`,
runID, RunStatusCompleted, summary.Fetched, summary.Matched, summary.Unmatched,
summary.ProgressUpdated, summary.HistoryCreated, summary.Skipped, warningsJSON, unmatchedJSON,
)
if err != nil {
return fmt.Errorf("completing run %s: %w", runID, err)
}
if result.RowsAffected() == 0 {
return ErrRunNotFound
}
return nil
}
func (r *Repository) UpdateRunProgress(ctx context.Context, runID string, summary ExecutionSummary) error {
warningsJSON, err := json.Marshal(trimWarnings(persistedWarnings(summary)))
if err != nil {
return fmt.Errorf("marshaling run warnings: %w", err)
}
unmatchedJSON, err := json.Marshal(trimUnmatchedSamples(summary.UnmatchedSamples))
if err != nil {
return fmt.Errorf("marshaling unmatched samples: %w", err)
}
result, err := r.pool.Exec(ctx, `
UPDATE history_import_runs
SET
fetched = $2,
matched = $3,
unmatched = $4,
progress_updated = $5,
history_created = $6,
skipped = $7,
warnings = $8,
unmatched_samples = $9,
last_heartbeat_at = NOW()
WHERE id = $1 AND status = $10`,
runID,
summary.Fetched,
summary.Matched,
summary.Unmatched,
summary.ProgressUpdated,
summary.HistoryCreated,
summary.Skipped,
warningsJSON,
unmatchedJSON,
RunStatusRunning,
)
if err != nil {
return fmt.Errorf("updating run %s progress: %w", runID, err)
}
if result.RowsAffected() == 0 {
return ErrRunNotFound
}
return nil
}
func (r *Repository) FailStaleRuns(ctx context.Context, staleBefore time.Time, errorMessage string) (int64, error) {
result, err := r.pool.Exec(ctx, `
UPDATE history_import_runs
SET
status = $1,
error_message = NULLIF($2, ''),
completed_at = NOW()
WHERE status = $3
AND completed_at IS NULL
AND COALESCE(last_heartbeat_at, started_at, created_at) < $4`,
RunStatusFailed,
errorMessage,
RunStatusRunning,
staleBefore,
)
if err != nil {
return 0, fmt.Errorf("failing stale history import runs: %w", err)
}
return result.RowsAffected(), nil
}
func (r *Repository) FailRun(ctx context.Context, runID string, summary ExecutionSummary, errorMessage string) error {
warningsJSON, err := json.Marshal(trimWarnings(persistedWarnings(summary)))
if err != nil {
return fmt.Errorf("marshaling run warnings: %w", err)
}
unmatchedJSON, err := json.Marshal(trimUnmatchedSamples(summary.UnmatchedSamples))
if err != nil {
return fmt.Errorf("marshaling unmatched samples: %w", err)
}
result, err := r.pool.Exec(ctx, `
UPDATE history_import_runs
SET
status = $2,
fetched = $3,
matched = $4,
unmatched = $5,
progress_updated = $6,
history_created = $7,
skipped = $8,
warnings = $9,
unmatched_samples = $10,
error_message = NULLIF($11, ''),
completed_at = NOW(),
last_heartbeat_at = NOW()
WHERE id = $1`,
runID, RunStatusFailed, summary.Fetched, summary.Matched, summary.Unmatched,
summary.ProgressUpdated, summary.HistoryCreated, summary.Skipped, warningsJSON, unmatchedJSON, errorMessage,
)
if err != nil {
return fmt.Errorf("failing run %s: %w", runID, err)
}
if result.RowsAffected() == 0 {
return ErrRunNotFound
}
return nil
}
func (r *Repository) ListRunsForUser(ctx context.Context, userID, limit int) ([]Run, error) {
rows, err := r.pool.Query(ctx, `
SELECT id, user_id, profile_id, source_type, connection_mode, status,
mapping_id,
fetched, matched, unmatched, progress_updated, history_created, skipped,
warnings, unmatched_samples, COALESCE(error_message, ''), created_at, started_at, completed_at
FROM history_import_runs
WHERE user_id = $1
ORDER BY created_at DESC
LIMIT $2`, userID, limit)
if err != nil {
return nil, fmt.Errorf("listing history import runs: %w", err)
}
defer rows.Close()
return scanRunsWithMappingID(rows)
}
func (r *Repository) GetRunForUser(ctx context.Context, userID int, runID string) (*Run, error) {
row := r.pool.QueryRow(ctx, `
SELECT id, user_id, profile_id, source_type, connection_mode, status,
mapping_id,
fetched, matched, unmatched, progress_updated, history_created, skipped,
warnings, unmatched_samples, COALESCE(error_message, ''), created_at, started_at, completed_at
FROM history_import_runs
WHERE id = $1 AND user_id = $2`, runID, userID)
run, err := scanRunWithMappingID(row)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrRunNotFound
}
if err != nil {
return nil, fmt.Errorf("getting run %s: %w", runID, err)
}
return run, nil
}
func (r *Repository) ListActiveRunsForUser(ctx context.Context, userID int) ([]Run, error) {
rows, err := r.pool.Query(ctx, `
SELECT id, user_id, profile_id, source_type, connection_mode, status,
mapping_id,
fetched, matched, unmatched, progress_updated, history_created, skipped,
warnings, unmatched_samples, COALESCE(error_message, ''), created_at, started_at, completed_at
FROM history_import_runs
WHERE user_id = $1
AND status IN ($2, $3)
ORDER BY created_at DESC`, userID, RunStatusQueued, RunStatusRunning)
if err != nil {
return nil, fmt.Errorf("listing active history import runs: %w", err)
}
defer rows.Close()
return scanRunsWithMappingID(rows)
}
func (r *Repository) GetProgress(ctx context.Context, userID int, profileID, mediaItemID string) (*localProgressRow, error) {
var row localProgressRow
err := r.pool.QueryRow(ctx, `
SELECT updated_at
FROM user_watch_progress
WHERE user_id = $1 AND profile_id = $2 AND media_item_id = $3`,
userID, profileID, mediaItemID,
).Scan(&row.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("getting watch progress: %w", err)
}
return &row, nil
}
func (r *Repository) UpsertImportedProgress(
ctx context.Context,
userID int,
profileID, mediaItemID string,
positionSeconds, durationSeconds float64,
completed bool,
updatedAt time.Time,
) error {
if durationSeconds < 0 {
durationSeconds = 0
}
if positionSeconds < 0 {
positionSeconds = 0
}
if completed && durationSeconds > 0 {
positionSeconds = durationSeconds
}
_, err := r.pool.Exec(ctx, `
INSERT INTO user_watch_progress (
user_id, profile_id, media_item_id, position_seconds, duration_seconds, completed, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (user_id, profile_id, media_item_id) DO UPDATE SET
position_seconds = excluded.position_seconds,
duration_seconds = excluded.duration_seconds,
completed = excluded.completed,
updated_at = excluded.updated_at`,
userID, profileID, mediaItemID, positionSeconds, durationSeconds, completed, updatedAt,
)
if err != nil {
return fmt.Errorf("upserting imported progress: %w", err)
}
return nil
}
func (r *Repository) InsertHistoryIfMissing(
ctx context.Context,
userID int,
profileID, mediaItemID string,
watchedAt time.Time,
durationSeconds float64,
completed bool,
) (bool, error) {
if watchedAt.IsZero() {
return false, nil
}
var exists bool
if err := r.pool.QueryRow(ctx, `
SELECT EXISTS(
SELECT 1
FROM user_watch_history
WHERE user_id = $1 AND profile_id = $2 AND media_item_id = $3 AND watched_at = $4
)`,
userID, profileID, mediaItemID, watchedAt,
).Scan(&exists); err != nil {
return false, fmt.Errorf("checking history row existence: %w", err)
}
if exists {
return false, nil
}
_, err := r.pool.Exec(ctx, `
INSERT INTO user_watch_history (
id, user_id, profile_id, media_item_id, watched_at, duration_seconds, completed
) VALUES ($1, $2, $3, $4, $5, $6, $7)`,
uuid.NewString(), userID, profileID, mediaItemID, watchedAt, durationSeconds, completed,
)
if err != nil {
return false, fmt.Errorf("inserting history row: %w", err)
}
return true, nil
}
type mediaLookupRow struct {
ContentID string
Title string
Year int
}
func (r *Repository) MatchMediaByExternalID(ctx context.Context, kind, column, value string) ([]mediaLookupRow, error) {
if value == "" {
return nil, nil
}
rows, err := r.pool.Query(ctx, `
SELECT content_id, title, COALESCE(year, 0)
FROM media_items
WHERE type = $1 AND status = 'matched' AND `+column+` = $2
ORDER BY content_id ASC`,
kind, value,
)
if err != nil {
return nil, fmt.Errorf("matching media by %s: %w", column, err)
}
defer rows.Close()
var matches []mediaLookupRow
for rows.Next() {
var row mediaLookupRow
if err := rows.Scan(&row.ContentID, &row.Title, &row.Year); err != nil {
return nil, fmt.Errorf("scanning media lookup row: %w", err)
}
matches = append(matches, row)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterating media lookup rows: %w", err)
}
return matches, nil
}
func (r *Repository) MatchMediaByTitleYear(ctx context.Context, kind, title string, year int) ([]mediaLookupRow, error) {
if title == "" {
return nil, nil
}
rows, err := r.pool.Query(ctx, `
SELECT content_id, title, COALESCE(year, 0)
FROM media_items
WHERE type = $1 AND status = 'matched' AND title = $2 AND ($3 = 0 OR COALESCE(year, 0) = $3)
ORDER BY content_id ASC`,
kind, title, year,
)
if err != nil {
return nil, fmt.Errorf("matching media by title/year: %w", err)
}
defer rows.Close()
var matches []mediaLookupRow
for rows.Next() {
var row mediaLookupRow
if err := rows.Scan(&row.ContentID, &row.Title, &row.Year); err != nil {
return nil, fmt.Errorf("scanning title/year match: %w", err)
}
matches = append(matches, row)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterating title/year matches: %w", err)
}
return matches, nil
}
func (r *Repository) MatchEpisodeByExternalID(ctx context.Context, column, value string) ([]mediaLookupRow, error) {
if value == "" {
return nil, nil
}
rows, err := r.pool.Query(ctx, `
SELECT e.content_id, COALESCE(e.title, ''), COALESCE(series.year, 0)
FROM episodes e
LEFT JOIN media_items series ON series.content_id = e.series_id
WHERE e.`+column+` = $1
ORDER BY e.content_id ASC`,
value,
)
if err != nil {
return nil, fmt.Errorf("matching episode by %s: %w", column, err)
}
defer rows.Close()
var matches []mediaLookupRow
for rows.Next() {
var row mediaLookupRow
if err := rows.Scan(&row.ContentID, &row.Title, &row.Year); err != nil {
return nil, fmt.Errorf("scanning episode lookup row: %w", err)
}
matches = append(matches, row)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterating episode lookup rows: %w", err)
}
return matches, nil
}
func (r *Repository) MatchEpisodeBySeries(ctx context.Context, seriesID string, seasonNumber, episodeNumber int) (*Match, error) {
row := r.pool.QueryRow(ctx, `
SELECT e.content_id, COALESCE(e.title, ''), COALESCE(series.year, 0)
FROM episodes e
LEFT JOIN media_items series ON series.content_id = e.series_id
WHERE e.series_id = $1 AND e.season_number = $2 AND e.episode_number = $3`,
seriesID, seasonNumber, episodeNumber,
)
var match Match
if err := row.Scan(&match.MediaItemID, &match.Title, &match.Year); errors.Is(err, pgx.ErrNoRows) {
return nil, nil
} else if err != nil {
return nil, fmt.Errorf("matching episode: %w", err)
}
match.Kind = KindEpisode
return &match, nil
}
func scanSource(scanner interface{ Scan(dest ...any) error }) (*Source, error) {
var source Source
if err := scanner.Scan(
&source.ID, &source.Name, &source.SourceType, &source.BaseURL, &source.SystemID,
&source.Enabled, &source.SortOrder, &source.HasAdminToken,
&source.CreatedAt, &source.UpdatedAt,
); err != nil {
return nil, err
}
return &source, nil
}
func scanSources(rows pgx.Rows) ([]Source, error) {
var sources []Source
for rows.Next() {
source, err := scanSource(rows)
if err != nil {
return nil, err
}
sources = append(sources, *source)
}
return sources, rows.Err()
}
func scanConnectSession(scanner interface{ Scan(dest ...any) error }) (*ConnectSession, error) {
var session ConnectSession
var serversJSON []byte
if err := scanner.Scan(
&session.ID, &session.UserID, &session.ConnectUserID, &session.ConnectAccessToken,
&serversJSON, &session.ExpiresAt, &session.ConsumedAt, &session.CreatedAt, &session.UpdatedAt,
); err != nil {
return nil, err
}
if len(serversJSON) > 0 {
if err := json.Unmarshal(serversJSON, &session.Servers); err != nil {
return nil, fmt.Errorf("unmarshaling connect session servers: %w", err)
}
}
return &session, nil
}
// scanRun is the legacy scanner for queries that do NOT include mapping_id.
// Kept for backward compatibility with UpdateRunProgress/CompleteRun/FailRun paths
// that use Exec and do not scan rows.
func scanRun(scanner interface{ Scan(dest ...any) error }) (*Run, error) {
var run Run
var warningsJSON []byte
var unmatchedJSON []byte
if err := scanner.Scan(
&run.ID, &run.UserID, &run.ProfileID, &run.SourceType, &run.ConnectionMode, &run.Status,
&run.Fetched, &run.Matched, &run.Unmatched, &run.ProgressUpdated, &run.HistoryCreated, &run.Skipped,
&warningsJSON, &unmatchedJSON, &run.ErrorMessage, &run.CreatedAt, &run.StartedAt, &run.CompletedAt,
); err != nil {
return nil, err
}
return finalizeRunScan(&run, warningsJSON, unmatchedJSON)
}
// scanRunWithMappingID scans a run row that includes the mapping_id column.
func scanRunWithMappingID(scanner interface{ Scan(dest ...any) error }) (*Run, error) {
var run Run
var warningsJSON []byte
var unmatchedJSON []byte
if err := scanner.Scan(
&run.ID, &run.UserID, &run.ProfileID, &run.SourceType, &run.ConnectionMode, &run.Status,
&run.MappingID,
&run.Fetched, &run.Matched, &run.Unmatched, &run.ProgressUpdated, &run.HistoryCreated, &run.Skipped,
&warningsJSON, &unmatchedJSON, &run.ErrorMessage, &run.CreatedAt, &run.StartedAt, &run.CompletedAt,
); err != nil {
return nil, err
}
return finalizeRunScan(&run, warningsJSON, unmatchedJSON)
}
func finalizeRunScan(run *Run, warningsJSON, unmatchedJSON []byte) (*Run, error) {
if len(warningsJSON) > 0 {
if err := json.Unmarshal(warningsJSON, &run.Warnings); err != nil {
return nil, fmt.Errorf("unmarshaling run warnings: %w", err)
}
}
if len(unmatchedJSON) > 0 {
if err := json.Unmarshal(unmatchedJSON, &run.UnmatchedSamples); err != nil {
return nil, fmt.Errorf("unmarshaling unmatched samples: %w", err)
}
}
if run.Warnings == nil {
run.Warnings = []string{}
}
if run.UnmatchedSamples == nil {
run.UnmatchedSamples = []UnmatchedSample{}
}
return run, nil
}
func scanRuns(rows pgx.Rows) ([]Run, error) {
var runs []Run
for rows.Next() {
run, err := scanRun(rows)
if err != nil {
return nil, err
}
runs = append(runs, *run)
}
return runs, rows.Err()
}
func scanRunsWithMappingID(rows pgx.Rows) ([]Run, error) {
var runs []Run
for rows.Next() {
run, err := scanRunWithMappingID(rows)
if err != nil {
return nil, err
}
runs = append(runs, *run)
}
return runs, rows.Err()
}
func trimWarnings(warnings []string) []string {
if len(warnings) <= maxStoredWarnings {
return nonNilStrings(warnings)
}
return nonNilStrings(warnings[:maxStoredWarnings])
}
func persistedWarnings(summary ExecutionSummary) []string {
warnings := append([]string{}, nonNilStrings(summary.Warnings)...)
if len(summary.UnmatchedReasonCounts) == 0 {
return warnings
}
type reasonCount struct {
reason string
count int
}
reasons := make([]reasonCount, 0, len(summary.UnmatchedReasonCounts))
for reason, count := range summary.UnmatchedReasonCounts {
if count <= 0 || reason == "" {
continue
}
reasons = append(reasons, reasonCount{reason: reason, count: count})
}
sort.Slice(reasons, func(i, j int) bool {
if reasons[i].count != reasons[j].count {
return reasons[i].count > reasons[j].count
}
return reasons[i].reason < reasons[j].reason
})
for _, reason := range reasons {
warnings = append(warnings, fmt.Sprintf("unmatched items (%d): %s", reason.count, reason.reason))
}
return warnings
}
func trimUnmatchedSamples(samples []UnmatchedSample) []UnmatchedSample {
if len(samples) <= maxUnmatchedSamples {
return nonNilUnmatchedSamples(samples)
}
return nonNilUnmatchedSamples(samples[:maxUnmatchedSamples])
}
func nonNilStrings(values []string) []string {
if values == nil {
return []string{}
}
return values
}
func nonNilUnmatchedSamples(values []UnmatchedSample) []UnmatchedSample {
if values == nil {
return []UnmatchedSample{}
}
return values
}
func scanPlexSession(scanner interface{ Scan(dest ...any) error }) (*PlexSession, error) {
var session PlexSession
var authToken *string
var serversJSON []byte
if err := scanner.Scan(
&session.ID, &session.UserID, &session.PinID, &session.PinCode,
&authToken, &serversJSON, &session.ExpiresAt, &session.ConsumedAt,
&session.CreatedAt, &session.UpdatedAt,
); err != nil {
return nil, err
}
if authToken != nil {
session.AuthToken = *authToken
}
if len(serversJSON) > 0 {
if err := json.Unmarshal(serversJSON, &session.Servers); err != nil {
return nil, fmt.Errorf("unmarshaling plex session servers: %w", err)
}
}
return &session, nil
}
func nilIfEmpty(s string) *string {
if s == "" {
return nil
}
return &s
}