Files
silo-server/internal/watchsync/repository.go
40a9de7f26 feat(watchsync): add plugin-backed providers (#475)
* feat(watchsync): add plugin-backed providers

* fix(watchsync): address plugin review findings

* fix(watchsync): harden plugin provider failures

* feat(watchsync): complete plugin provider contract

* fix(watchsync): address provider review feedback

* fix(watchsync): keep device state host-private

* fix(watchsync): build reconciliation index concurrently

* fix(watchsync): preserve empty device state updates

* chore(deps): use released watch-sync SDK

---------

Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
2026-08-06 10:30:49 -04:00

1504 lines
55 KiB
Go

package watchsync
import (
"context"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/Silo-Server/silo-server/internal/secret"
)
type Repository interface {
GetServerSetting(ctx context.Context, key string) (string, error)
UpsertAuthSession(ctx context.Context, session DeviceAuthSession) (DeviceAuthSession, error)
GetAuthSession(ctx context.Context, id string) (DeviceAuthSession, error)
UpsertConnection(ctx context.Context, conn Connection) (Connection, error)
GetConnection(ctx context.Context, provider string, userID int, profileID string) (Connection, bool, error)
GetConnectionByID(ctx context.Context, id string) (Connection, bool, error)
DeleteConnection(ctx context.Context, provider string, userID int, profileID string) error
ListConnectionsDueForSync(ctx context.Context, now time.Time) ([]Connection, error)
DeferConnectionsForAccount(ctx context.Context, provider, providerAccountID string, until time.Time, lastError string) (int, error)
CreateSyncRun(ctx context.Context, run SyncRun) (SyncRun, error)
CompleteSyncRun(ctx context.Context, run SyncRun) (SyncRun, error)
GetLatestSyncRun(ctx context.Context, connectionID string) (SyncRun, bool, error)
GetActiveSyncRun(ctx context.Context, connectionID string) (SyncRun, bool, error)
ListSyncRuns(ctx context.Context, connectionID string, limit int) ([]SyncRun, error)
ListLocalWatchEventConnections(ctx context.Context, userID int, profileID string, kind LocalWatchEventKind) ([]Connection, error)
ListListEventConnections(ctx context.Context, userID int, profileID string, list ListKind) ([]Connection, error)
UpsertHistoryExports(ctx context.Context, exports []HistoryExport) error
ListPendingHistoryExports(ctx context.Context, connectionID string, limit int) ([]HistoryExport, error)
MarkHistoryExportStatus(ctx context.Context, id string, status string, lastError string) error
MarkHistoryExportSatisfiedByScrobble(ctx context.Context, connectionID string, historyID string) error
UpsertListItemStates(ctx context.Context, states []ListItemState) error
ListListItemStates(ctx context.Context, connectionID string, kind ListKind) ([]ListItemState, error)
ListPendingListItemExports(ctx context.Context, connectionID string, kind ListKind, limit int) ([]ListItemState, error)
ListPendingListItemRemovals(ctx context.Context, connectionID string, kind ListKind, limit int) ([]ListItemState, error)
MarkListItemExported(ctx context.Context, connectionID string, kind ListKind, mediaItemID string, exportedAt time.Time) error
MarkListItemRemoteRemoved(ctx context.Context, connectionID string, kind ListKind, mediaItemID string, removedAt time.Time) error
MarkListItemLocalRemoved(ctx context.Context, connectionID string, kind ListKind, mediaItemID string, removedAt time.Time) error
MarkListItemError(ctx context.Context, connectionID string, kind ListKind, mediaItemID, lastError string) error
ListScrobbleConnections(ctx context.Context, userID int, profileID string) ([]Connection, error)
UpsertScrobbleSession(ctx context.Context, event ScrobbleEvent, connectionID string, action string) error
PrepareConfirmedScrobbleStop(ctx context.Context, event ScrobbleEvent, connectionID string, staleBefore time.Time) (confirmedStopPreparation, time.Time, error)
CompleteConfirmedScrobbleStop(ctx context.Context, playbackSessionID string, connectionID string, progress float64, historyID string, claimVersion time.Time, stopSentAt time.Time) error
FailConfirmedScrobbleStop(ctx context.Context, playbackSessionID string, connectionID string, progress float64, historyID string, claimVersion time.Time, lastError string) error
UpdateScrobbleSession(ctx context.Context, playbackSessionID string, connectionID string, action string, progress float64, historyID string, lastError string, stopSentAt *time.Time) error
ListOpenScrobbleSessions(ctx context.Context) ([]ScrobbleSession, error)
ListPendingScrobbleReconciliations(ctx context.Context) ([]ScrobbleSession, error)
MarkScrobbleHistoryReconciled(ctx context.Context, playbackSessionID, connectionID string, reconciledAt time.Time) error
}
type confirmedStopPreparation uint8
const (
confirmedStopPrepared confirmedStopPreparation = iota
confirmedStopAlreadySent
confirmedStopInProgress
)
var errConfirmedStopClaimLost = errors.New("confirmed scrobble stop claim lost")
// connectionColumns is the canonical select/returning column list for
// watch_provider_connections, in the exact order scanConnection reads. Sharing
// it across every read query keeps the column set and scan order in lockstep.
const connectionColumns = `
id::text, provider, user_id, profile_id, provider_account_id, provider_username,
access_token, refresh_token, token_expires_at, plugin_credentials, import_watched_enabled,
import_progress_enabled, export_watched_enabled, export_unwatched_enabled,
import_favorites_enabled, export_favorites_enabled, sync_favorite_removals_enabled,
import_watchlist_enabled, export_watchlist_enabled, sync_watchlist_removals_enabled,
sync_watchlist_order_enabled, scrobble_enabled, last_inbound_sync_at,
last_progress_sync_at, last_outbound_sync_at, last_favorites_sync_at,
last_watchlist_sync_at, last_scrobble_error_at, last_error,
rate_limited_until, sync_cursors, created_at, updated_at`
// syncRunColumns is the canonical select/returning column list for
// watch_provider_sync_runs, in the exact order scanSyncRun reads.
const syncRunColumns = `
id::text, connection_id::text, trigger, status, provider,
inbound_watched_found, inbound_watched_imported,
inbound_progress_found, inbound_progress_imported,
outbound_found, outbound_sent, inbound_favorites_found,
inbound_favorites_imported, outbound_favorites_found,
outbound_favorites_sent, favorite_removals_sent,
inbound_watchlist_found, inbound_watchlist_imported,
outbound_watchlist_found, outbound_watchlist_sent, watchlist_removals_sent,
warning, error, started_at, completed_at, created_at`
// listItemStateColumns is the canonical select column list for
// watch_provider_list_items, in the exact order scanListItemStates reads.
const listItemStateColumns = `
id::text, connection_id::text, list_kind, media_item_id, provider_item_key, kind, title, year,
remote_present, local_present, last_seen_remote_at, last_seen_local_at,
last_exported_at, last_removed_remote_at, last_removed_local_at, last_error, created_at, updated_at`
type PostgresRepository struct {
pool *pgxpool.Pool
cipher *secret.Cipher
}
func NewPostgresRepository(pool *pgxpool.Pool, cipher *secret.Cipher) *PostgresRepository {
return &PostgresRepository{pool: pool, cipher: cipher}
}
// TokenAAD binds an access/refresh token ciphertext to its connection. It uses
// the stable UNIQUE business key (provider, user_id, profile_id) rather than the
// surrogate id, because UpsertConnection lets Postgres assign/keep the id (ON
// CONFLICT), so the id is not known before the write — the tuple is, and it
// identifies the row just as uniquely. Exported so the (raw-SQL) Trakt
// collection-token resolver binds tokens identically.
func TokenAAD(column, provider string, userID int, profileID string) string {
return secret.RowAAD("watch_provider_connections", column, provider+":"+strconv.Itoa(userID)+":"+profileID)
}
func authStateAAD(provider string, userID int, profileID string) string {
return secret.RowAAD("watch_provider_auth_sessions", "device_code", provider+":"+strconv.Itoa(userID)+":"+profileID)
}
func (r *PostgresRepository) GetServerSetting(ctx context.Context, key string) (string, error) {
var value string
err := r.pool.QueryRow(ctx, `SELECT value FROM server_settings WHERE key = $1`, key).Scan(&value)
if errors.Is(err, pgx.ErrNoRows) {
return "", nil
}
if err != nil {
return "", fmt.Errorf("server_settings get %q: %w", key, err)
}
// This repo reads watchsync.<provider>.client_id/client_secret (sensitive
// settings) directly, bypassing the settings decorator, so apply the same
// read-path decryption here.
out, err := r.cipher.DecryptIfEncrypted(value, secret.SettingsAAD(key))
if err != nil {
return "", fmt.Errorf("decrypt server_settings %q: %w", key, err)
}
return out, nil
}
func (r *PostgresRepository) UpsertAuthSession(
ctx context.Context,
session DeviceAuthSession,
) (DeviceAuthSession, error) {
deviceCode, err := r.cipher.Encrypt(session.DeviceCode, authStateAAD(session.Provider, session.UserID, session.ProfileID))
if err != nil {
return DeviceAuthSession{}, fmt.Errorf("encrypt watch provider authorization state: %w", err)
}
row := r.pool.QueryRow(ctx, `
INSERT INTO watch_provider_auth_sessions (
id, provider, user_id, profile_id, device_code, user_code,
verification_url, interval_seconds, expires_at, completed_at
)
VALUES (
COALESCE(NULLIF($1, '')::uuid, gen_random_uuid()),
$2, $3, $4, $5, $6, $7, $8, $9, $10
)
ON CONFLICT (id) DO UPDATE SET
provider = EXCLUDED.provider,
user_id = EXCLUDED.user_id,
profile_id = EXCLUDED.profile_id,
device_code = EXCLUDED.device_code,
user_code = EXCLUDED.user_code,
verification_url = EXCLUDED.verification_url,
interval_seconds = EXCLUDED.interval_seconds,
expires_at = EXCLUDED.expires_at,
completed_at = EXCLUDED.completed_at,
updated_at = now()
RETURNING
id::text, provider, user_id, profile_id, device_code, user_code,
verification_url, interval_seconds, expires_at, completed_at
`,
session.ID,
session.Provider,
session.UserID,
session.ProfileID,
deviceCode,
session.UserCode,
session.VerificationURL,
session.IntervalSeconds,
session.ExpiresAt,
session.CompletedAt,
)
saved, err := r.scanDeviceAuthSession(row)
if err != nil {
return DeviceAuthSession{}, fmt.Errorf("upsert watch provider auth session: %w", err)
}
return saved, nil
}
func (r *PostgresRepository) GetAuthSession(ctx context.Context, id string) (DeviceAuthSession, error) {
row := r.pool.QueryRow(ctx, `
SELECT
id::text, provider, user_id, profile_id, device_code, user_code,
verification_url, interval_seconds, expires_at, completed_at
FROM watch_provider_auth_sessions
WHERE id = $1::uuid
`, id)
session, err := r.scanDeviceAuthSession(row)
if err != nil {
return DeviceAuthSession{}, fmt.Errorf("get watch provider auth session %q: %w", id, err)
}
return session, nil
}
func (r *PostgresRepository) UpsertConnection(ctx context.Context, conn Connection) (Connection, error) {
accessToken, err := r.cipher.Encrypt(conn.AccessToken, TokenAAD("access_token", conn.Provider, conn.UserID, conn.ProfileID))
if err != nil {
return Connection{}, fmt.Errorf("encrypt watch access token: %w", err)
}
refreshToken, err := r.cipher.Encrypt(conn.RefreshToken, TokenAAD("refresh_token", conn.Provider, conn.UserID, conn.ProfileID))
if err != nil {
return Connection{}, fmt.Errorf("encrypt watch refresh token: %w", err)
}
pluginCredentials, err := r.pluginCredentialsForConnection(conn)
if err != nil {
return Connection{}, err
}
row := r.pool.QueryRow(ctx, `
INSERT INTO watch_provider_connections (
id, provider, user_id, profile_id, provider_account_id, provider_username,
access_token, refresh_token, token_expires_at, plugin_credentials, import_watched_enabled,
import_progress_enabled, export_watched_enabled, export_unwatched_enabled,
import_favorites_enabled, export_favorites_enabled, sync_favorite_removals_enabled,
import_watchlist_enabled, export_watchlist_enabled, sync_watchlist_removals_enabled,
sync_watchlist_order_enabled, scrobble_enabled, last_inbound_sync_at, last_progress_sync_at,
last_outbound_sync_at, last_favorites_sync_at, last_watchlist_sync_at, last_scrobble_error_at,
last_error, rate_limited_until, sync_cursors
)
VALUES (
COALESCE(NULLIF($1, '')::uuid, gen_random_uuid()),
$2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14,
$15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31::jsonb
)
ON CONFLICT (provider, user_id, profile_id) DO UPDATE SET
provider_account_id = EXCLUDED.provider_account_id,
provider_username = EXCLUDED.provider_username,
access_token = EXCLUDED.access_token,
refresh_token = EXCLUDED.refresh_token,
token_expires_at = EXCLUDED.token_expires_at,
plugin_credentials = EXCLUDED.plugin_credentials,
import_watched_enabled = EXCLUDED.import_watched_enabled,
import_progress_enabled = EXCLUDED.import_progress_enabled,
export_watched_enabled = EXCLUDED.export_watched_enabled,
export_unwatched_enabled = EXCLUDED.export_unwatched_enabled,
import_favorites_enabled = EXCLUDED.import_favorites_enabled,
export_favorites_enabled = EXCLUDED.export_favorites_enabled,
sync_favorite_removals_enabled = EXCLUDED.sync_favorite_removals_enabled,
import_watchlist_enabled = EXCLUDED.import_watchlist_enabled,
export_watchlist_enabled = EXCLUDED.export_watchlist_enabled,
sync_watchlist_removals_enabled = EXCLUDED.sync_watchlist_removals_enabled,
sync_watchlist_order_enabled = EXCLUDED.sync_watchlist_order_enabled,
scrobble_enabled = EXCLUDED.scrobble_enabled,
last_inbound_sync_at = EXCLUDED.last_inbound_sync_at,
last_progress_sync_at = EXCLUDED.last_progress_sync_at,
last_outbound_sync_at = EXCLUDED.last_outbound_sync_at,
last_favorites_sync_at = EXCLUDED.last_favorites_sync_at,
last_watchlist_sync_at = EXCLUDED.last_watchlist_sync_at,
last_scrobble_error_at = EXCLUDED.last_scrobble_error_at,
last_error = EXCLUDED.last_error,
rate_limited_until = EXCLUDED.rate_limited_until,
sync_cursors = EXCLUDED.sync_cursors,
updated_at = now()
RETURNING `+connectionColumns+`
`,
conn.ID,
conn.Provider,
conn.UserID,
conn.ProfileID,
conn.ProviderAccountID,
conn.ProviderUsername,
accessToken,
refreshToken,
conn.TokenExpiresAt,
pluginCredentials,
conn.ImportWatchedEnabled,
conn.ImportProgressEnabled,
conn.ExportWatchedEnabled,
conn.ExportUnwatchedEnabled,
conn.ImportFavoritesEnabled,
conn.ExportFavoritesEnabled,
conn.SyncFavoriteRemovalsEnabled,
conn.ImportWatchlistEnabled,
conn.ExportWatchlistEnabled,
conn.SyncWatchlistRemovalsEnabled,
conn.SyncWatchlistOrderEnabled,
conn.ScrobbleEnabled,
conn.LastInboundSyncAt,
conn.LastProgressSyncAt,
conn.LastOutboundSyncAt,
conn.LastFavoritesSyncAt,
conn.LastWatchlistSyncAt,
conn.LastScrobbleErrorAt,
conn.LastError,
conn.RateLimitedUntil,
encodeSyncCursors(conn.SyncCursors),
)
saved, err := r.scanConnection(row)
if err != nil {
return Connection{}, fmt.Errorf("upsert watch provider connection: %w", err)
}
return saved, nil
}
func (r *PostgresRepository) GetConnection(
ctx context.Context,
provider string,
userID int,
profileID string,
) (Connection, bool, error) {
row := r.pool.QueryRow(ctx, `
SELECT `+connectionColumns+`
FROM watch_provider_connections
WHERE provider = $1 AND user_id = $2 AND profile_id = $3
`, provider, userID, profileID)
conn, err := r.scanConnection(row)
if errors.Is(err, pgx.ErrNoRows) {
return Connection{}, false, nil
}
if err != nil {
return Connection{}, false, fmt.Errorf("get watch provider connection: %w", err)
}
return conn, true, nil
}
func (r *PostgresRepository) GetConnectionByID(ctx context.Context, id string) (Connection, bool, error) {
row := r.pool.QueryRow(ctx, `
SELECT `+connectionColumns+`
FROM watch_provider_connections
WHERE id = $1::uuid
`, id)
conn, err := r.scanConnection(row)
if errors.Is(err, pgx.ErrNoRows) {
return Connection{}, false, nil
}
if err != nil {
return Connection{}, false, fmt.Errorf("get watch provider connection by id: %w", err)
}
return conn, true, nil
}
func (r *PostgresRepository) DeleteConnection(
ctx context.Context,
provider string,
userID int,
profileID string,
) error {
_, err := r.pool.Exec(ctx, `
DELETE FROM watch_provider_connections
WHERE provider = $1 AND user_id = $2 AND profile_id = $3
`, provider, userID, profileID)
if err != nil {
return fmt.Errorf("delete watch provider connection: %w", err)
}
return nil
}
func (r *PostgresRepository) ListConnectionsDueForSync(
ctx context.Context,
now time.Time,
) ([]Connection, error) {
rows, err := r.pool.Query(ctx, `
SELECT `+connectionColumns+`
FROM watch_provider_connections
WHERE provider <> ''
AND (rate_limited_until IS NULL OR rate_limited_until <= $1)
AND (
import_watched_enabled
OR import_progress_enabled
OR export_watched_enabled
OR export_unwatched_enabled
OR import_favorites_enabled
OR export_favorites_enabled
OR sync_favorite_removals_enabled
OR import_watchlist_enabled
OR export_watchlist_enabled
OR sync_watchlist_removals_enabled
OR scrobble_enabled
)
ORDER BY provider, user_id, profile_id
`, now)
if err != nil {
return nil, fmt.Errorf("list due watch provider connections: %w", err)
}
defer rows.Close()
var conns []Connection
for rows.Next() {
conn, scanErr := r.scanConnection(rows)
if scanErr != nil {
return nil, fmt.Errorf("scan due watch provider connection: %w", scanErr)
}
conns = append(conns, conn)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate due watch provider connections: %w", err)
}
return conns, nil
}
// DeferConnectionsForAccount stamps rate_limited_until on every connection
// bound to the same provider account. Provider rate limits apply to the API
// key/account, not the Silo profile, so all sibling connections must sit out
// the same window.
func (r *PostgresRepository) DeferConnectionsForAccount(
ctx context.Context,
provider string,
providerAccountID string,
until time.Time,
lastError string,
) (int, error) {
if strings.TrimSpace(providerAccountID) == "" {
return 0, nil
}
tag, err := r.pool.Exec(ctx, `
UPDATE watch_provider_connections
SET rate_limited_until = $1, last_error = $2, updated_at = now()
WHERE provider = $3 AND provider_account_id = $4
`, until, lastError, provider, providerAccountID)
if err != nil {
return 0, fmt.Errorf("defer watch provider connections for account: %w", err)
}
return int(tag.RowsAffected()), nil
}
func (r *PostgresRepository) CreateSyncRun(ctx context.Context, run SyncRun) (SyncRun, error) {
if run.Status == "" {
run.Status = string(SyncRunStatusRunning)
}
if run.StartedAt.IsZero() {
run.StartedAt = time.Now().UTC()
}
row := r.pool.QueryRow(ctx, `
INSERT INTO watch_provider_sync_runs (
connection_id, trigger, status, provider,
inbound_watched_found, inbound_watched_imported,
inbound_progress_found, inbound_progress_imported,
outbound_found, outbound_sent, inbound_favorites_found,
inbound_favorites_imported, outbound_favorites_found,
outbound_favorites_sent, favorite_removals_sent,
inbound_watchlist_found, inbound_watchlist_imported,
outbound_watchlist_found, outbound_watchlist_sent, watchlist_removals_sent,
warning, error, started_at, completed_at
)
VALUES (
$1::uuid, $2, $3, $4,
$5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15,
$16, $17, $18, $19, $20, $21, $22, $23, $24
)
RETURNING `+syncRunColumns+`
`, run.ConnectionID, run.Trigger, run.Status, run.Provider,
run.InboundWatchedFound, run.InboundWatchedImported,
run.InboundProgressFound, run.InboundProgressImported,
run.OutboundFound, run.OutboundSent, run.InboundFavoritesFound,
run.InboundFavoritesImported, run.OutboundFavoritesFound,
run.OutboundFavoritesSent, run.FavoriteRemovalsSent,
run.InboundWatchlistFound, run.InboundWatchlistImported,
run.OutboundWatchlistFound, run.OutboundWatchlistSent, run.WatchlistRemovalsSent,
run.Warning, run.Error, run.StartedAt, run.CompletedAt)
created, err := scanSyncRun(row)
if err != nil {
return SyncRun{}, fmt.Errorf("scan created watch provider sync run: %w", err)
}
return created, nil
}
func (r *PostgresRepository) CompleteSyncRun(ctx context.Context, run SyncRun) (SyncRun, error) {
row := r.pool.QueryRow(ctx, `
UPDATE watch_provider_sync_runs
SET status = $2,
inbound_watched_found = $3,
inbound_watched_imported = $4,
inbound_progress_found = $5,
inbound_progress_imported = $6,
outbound_found = $7,
outbound_sent = $8,
inbound_favorites_found = $9,
inbound_favorites_imported = $10,
outbound_favorites_found = $11,
outbound_favorites_sent = $12,
favorite_removals_sent = $13,
inbound_watchlist_found = $14,
inbound_watchlist_imported = $15,
outbound_watchlist_found = $16,
outbound_watchlist_sent = $17,
watchlist_removals_sent = $18,
warning = $19,
error = $20,
completed_at = $21
WHERE id = $1::uuid
RETURNING `+syncRunColumns+`
`, run.ID, run.Status, run.InboundWatchedFound, run.InboundWatchedImported,
run.InboundProgressFound, run.InboundProgressImported, run.OutboundFound, run.OutboundSent,
run.InboundFavoritesFound, run.InboundFavoritesImported, run.OutboundFavoritesFound,
run.OutboundFavoritesSent, run.FavoriteRemovalsSent,
run.InboundWatchlistFound, run.InboundWatchlistImported, run.OutboundWatchlistFound,
run.OutboundWatchlistSent, run.WatchlistRemovalsSent,
run.Warning, run.Error, run.CompletedAt)
completed, err := scanSyncRun(row)
if err != nil {
return SyncRun{}, fmt.Errorf("complete watch provider sync run: %w", err)
}
return completed, nil
}
func (r *PostgresRepository) GetLatestSyncRun(ctx context.Context, connectionID string) (SyncRun, bool, error) {
row := r.pool.QueryRow(ctx, `
SELECT `+syncRunColumns+`
FROM watch_provider_sync_runs
WHERE connection_id = $1::uuid
ORDER BY started_at DESC, created_at DESC
LIMIT 1
`, connectionID)
run, err := scanSyncRun(row)
if errors.Is(err, pgx.ErrNoRows) {
return SyncRun{}, false, nil
}
if err != nil {
return SyncRun{}, false, fmt.Errorf("get latest watch provider sync run: %w", err)
}
return run, true, nil
}
func (r *PostgresRepository) GetActiveSyncRun(ctx context.Context, connectionID string) (SyncRun, bool, error) {
row := r.pool.QueryRow(ctx, `
SELECT `+syncRunColumns+`
FROM watch_provider_sync_runs
WHERE connection_id = $1::uuid
AND status IN ('queued', 'running')
ORDER BY started_at DESC, created_at DESC
LIMIT 1
`, connectionID)
run, err := scanSyncRun(row)
if errors.Is(err, pgx.ErrNoRows) {
return SyncRun{}, false, nil
}
if err != nil {
return SyncRun{}, false, fmt.Errorf("get active watch provider sync run: %w", err)
}
return run, true, nil
}
func (r *PostgresRepository) ListSyncRuns(ctx context.Context, connectionID string, limit int) ([]SyncRun, error) {
if limit <= 0 || limit > 50 {
limit = 10
}
rows, err := r.pool.Query(ctx, `
SELECT `+syncRunColumns+`
FROM watch_provider_sync_runs
WHERE connection_id = $1::uuid
ORDER BY started_at DESC, created_at DESC
LIMIT $2
`, connectionID, limit)
if err != nil {
return nil, fmt.Errorf("list watch provider sync runs: %w", err)
}
defer rows.Close()
var runs []SyncRun
for rows.Next() {
run, err := scanSyncRun(rows)
if err != nil {
return nil, fmt.Errorf("scan watch provider sync run: %w", err)
}
runs = append(runs, run)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate watch provider sync runs: %w", err)
}
return runs, nil
}
func (r *PostgresRepository) ListLocalWatchEventConnections(
ctx context.Context,
userID int,
profileID string,
kind LocalWatchEventKind,
) ([]Connection, error) {
var predicate string
switch kind {
case LocalWatchEventMarkedWatched:
predicate = "export_watched_enabled = true"
case LocalWatchEventMarkedUnwatched:
predicate = "export_unwatched_enabled = true"
default:
return nil, nil
}
rows, err := r.pool.Query(ctx, `
SELECT `+connectionColumns+`
FROM watch_provider_connections
WHERE user_id = $1 AND profile_id = $2 AND `+predicate+`
AND (rate_limited_until IS NULL OR rate_limited_until <= now())
ORDER BY provider
`, userID, profileID)
if err != nil {
return nil, fmt.Errorf("list local watch event connections: %w", err)
}
defer rows.Close()
var conns []Connection
for rows.Next() {
conn, scanErr := r.scanConnection(rows)
if scanErr != nil {
return nil, fmt.Errorf("scan local watch event connection: %w", scanErr)
}
conns = append(conns, conn)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate local watch event connections: %w", err)
}
return conns, nil
}
// ListListEventConnections returns connections that export the given list kind,
// i.e. should mirror a local add/remove on that list to the provider.
func (r *PostgresRepository) ListListEventConnections(
ctx context.Context,
userID int,
profileID string,
list ListKind,
) ([]Connection, error) {
column := "export_favorites_enabled"
if list == ListKindWatchlist {
column = "export_watchlist_enabled"
}
rows, err := r.pool.Query(ctx, `
SELECT `+connectionColumns+`
FROM watch_provider_connections
WHERE user_id = $1 AND profile_id = $2 AND `+column+` = true
AND (rate_limited_until IS NULL OR rate_limited_until <= now())
ORDER BY provider
`, userID, profileID)
if err != nil {
return nil, fmt.Errorf("list %s event connections: %w", list, err)
}
defer rows.Close()
var conns []Connection
for rows.Next() {
conn, scanErr := r.scanConnection(rows)
if scanErr != nil {
return nil, fmt.Errorf("scan %s event connection: %w", list, scanErr)
}
conns = append(conns, conn)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate %s event connections: %w", list, err)
}
return conns, nil
}
func (r *PostgresRepository) GetMediaDuration(ctx context.Context, mediaItemID string) (float64, error) {
var duration float64
err := r.pool.QueryRow(ctx, mediaDurationQuery, mediaItemID).Scan(&duration)
if err != nil {
return 0, fmt.Errorf("get media duration: %w", err)
}
return duration, nil
}
func (r *PostgresRepository) GetListMediaItems(ctx context.Context, mediaItemIDs []string) (map[string]LocalFavorite, error) {
result := make(map[string]LocalFavorite, len(mediaItemIDs))
if len(mediaItemIDs) == 0 {
return result, nil
}
rows, err := r.pool.Query(ctx, `
SELECT content_id, type, title, COALESCE(year, 0), COALESCE(imdb_id, ''), COALESCE(tmdb_id, ''), COALESCE(tvdb_id, '')
FROM media_items
WHERE content_id = ANY($1)
`, mediaItemIDs)
if err != nil {
return nil, fmt.Errorf("get list media items: %w", err)
}
defer rows.Close()
for rows.Next() {
var fav LocalFavorite
if err := rows.Scan(&fav.MediaItemID, &fav.Kind, &fav.Title, &fav.Year, &fav.IMDbID, &fav.TMDBID, &fav.TVDBID); err != nil {
return nil, fmt.Errorf("scan list media item: %w", err)
}
fav.ProviderItemKey = providerItemKeyForLocalFavorite(fav)
result[fav.MediaItemID] = fav
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate list media items: %w", err)
}
return result, nil
}
const mediaDurationQuery = `
SELECT COALESCE(MAX(duration), 0)
FROM media_files
WHERE content_id = $1 AND missing_since IS NULL
`
func (r *PostgresRepository) UpsertHistoryExports(ctx context.Context, exports []HistoryExport) error {
for _, export := range exports {
_, err := r.pool.Exec(ctx, `
INSERT INTO watch_provider_history_exports (
connection_id, history_id, media_item_id, watched_at, provider_item_key, status,
attempt_count, last_attempt_at, last_error
)
VALUES ($1::uuid, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (connection_id, history_id) DO UPDATE SET
provider_item_key = EXCLUDED.provider_item_key,
status = CASE
WHEN watch_provider_history_exports.status IN ('sent', 'satisfied_by_scrobble', 'not_found')
OR watch_provider_history_exports.attempt_count >= 5
THEN watch_provider_history_exports.status
ELSE EXCLUDED.status
END,
updated_at = now()
`, export.ConnectionID, export.HistoryID, export.MediaItemID, export.WatchedAt, export.ProviderItemKey,
export.Status, export.AttemptCount, export.LastAttemptAt, export.LastError)
if err != nil {
return fmt.Errorf("upsert history export: %w", err)
}
}
return nil
}
func (r *PostgresRepository) ListPendingHistoryExports(ctx context.Context, connectionID string, limit int) ([]HistoryExport, error) {
if limit <= 0 || limit > 100 {
limit = 100
}
rows, err := r.pool.Query(ctx, `
SELECT id::text, connection_id::text, history_id, media_item_id, watched_at,
provider_item_key, status, attempt_count, last_attempt_at, last_error, created_at, updated_at
FROM watch_provider_history_exports
WHERE connection_id = $1::uuid
AND status IN ('pending', 'failed')
AND attempt_count < 5
ORDER BY watched_at ASC
LIMIT $2
`, connectionID, limit)
if err != nil {
return nil, fmt.Errorf("list pending history exports: %w", err)
}
defer rows.Close()
var exports []HistoryExport
for rows.Next() {
var export HistoryExport
if err := rows.Scan(
&export.ID,
&export.ConnectionID,
&export.HistoryID,
&export.MediaItemID,
&export.WatchedAt,
&export.ProviderItemKey,
&export.Status,
&export.AttemptCount,
&export.LastAttemptAt,
&export.LastError,
&export.CreatedAt,
&export.UpdatedAt,
); err != nil {
return nil, fmt.Errorf("scan pending history export: %w", err)
}
exports = append(exports, export)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate pending history exports: %w", err)
}
return exports, nil
}
func (r *PostgresRepository) MarkHistoryExportStatus(ctx context.Context, id string, status string, lastError string) error {
_, err := r.pool.Exec(ctx, `
UPDATE watch_provider_history_exports
SET status = $2,
attempt_count = attempt_count + 1,
last_attempt_at = now(),
last_error = $3,
updated_at = now()
WHERE id = $1::uuid
AND status NOT IN ('sent', 'satisfied_by_scrobble', 'not_found')
`, id, status, lastError)
if err != nil {
return fmt.Errorf("mark history export status: %w", err)
}
return nil
}
func (r *PostgresRepository) MarkHistoryExportSatisfiedByScrobble(ctx context.Context, connectionID string, historyID string) error {
_, err := r.pool.Exec(ctx, `
UPDATE watch_provider_history_exports
SET status = 'satisfied_by_scrobble',
last_attempt_at = now(),
last_error = '',
updated_at = now()
WHERE connection_id = $1::uuid AND history_id = $2
AND status NOT IN ('sent', 'not_found')
`, connectionID, historyID)
if err != nil {
return fmt.Errorf("mark history export satisfied by scrobble: %w", err)
}
return nil
}
func (r *PostgresRepository) UpsertListItemStates(ctx context.Context, states []ListItemState) error {
for _, state := range states {
kind := state.ListKind
if kind == "" {
kind = ListKindFavorites
}
_, err := r.pool.Exec(ctx, `
INSERT INTO watch_provider_list_items (
connection_id, list_kind, media_item_id, provider_item_key, kind, title, year,
remote_present, local_present, last_seen_remote_at, last_seen_local_at,
last_exported_at, last_removed_remote_at, last_removed_local_at, last_error
)
VALUES ($1::uuid, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
ON CONFLICT (connection_id, list_kind, media_item_id) DO UPDATE SET
provider_item_key = CASE
WHEN EXCLUDED.provider_item_key <> '' THEN EXCLUDED.provider_item_key
ELSE watch_provider_list_items.provider_item_key
END,
kind = CASE WHEN EXCLUDED.kind <> '' THEN EXCLUDED.kind ELSE watch_provider_list_items.kind END,
title = CASE WHEN EXCLUDED.title <> '' THEN EXCLUDED.title ELSE watch_provider_list_items.title END,
year = CASE WHEN EXCLUDED.year <> 0 THEN EXCLUDED.year ELSE watch_provider_list_items.year END,
remote_present = watch_provider_list_items.remote_present OR EXCLUDED.remote_present,
local_present = EXCLUDED.local_present,
last_seen_remote_at = COALESCE(EXCLUDED.last_seen_remote_at, watch_provider_list_items.last_seen_remote_at),
last_seen_local_at = COALESCE(EXCLUDED.last_seen_local_at, watch_provider_list_items.last_seen_local_at),
last_exported_at = COALESCE(EXCLUDED.last_exported_at, watch_provider_list_items.last_exported_at),
last_removed_remote_at = COALESCE(EXCLUDED.last_removed_remote_at, watch_provider_list_items.last_removed_remote_at),
last_removed_local_at = COALESCE(EXCLUDED.last_removed_local_at, watch_provider_list_items.last_removed_local_at),
last_error = EXCLUDED.last_error,
updated_at = now()
`, state.ConnectionID, string(kind), state.MediaItemID, state.ProviderItemKey, state.Kind, state.Title, state.Year,
state.RemotePresent, state.LocalPresent, state.LastSeenRemoteAt, state.LastSeenLocalAt,
state.LastExportedAt, state.LastRemovedRemoteAt, state.LastRemovedLocalAt, state.LastError)
if err != nil {
return fmt.Errorf("upsert list item state: %w", err)
}
}
return nil
}
func (r *PostgresRepository) ListListItemStates(ctx context.Context, connectionID string, kind ListKind) ([]ListItemState, error) {
rows, err := r.pool.Query(ctx, `
SELECT `+listItemStateColumns+`
FROM watch_provider_list_items
WHERE connection_id = $1::uuid AND list_kind = $2
`, connectionID, string(kind))
if err != nil {
return nil, fmt.Errorf("list list item states: %w", err)
}
defer rows.Close()
return scanListItemStates(rows)
}
func (r *PostgresRepository) ListPendingListItemExports(ctx context.Context, connectionID string, kind ListKind, limit int) ([]ListItemState, error) {
if limit <= 0 || limit > 100 {
limit = 100
}
rows, err := r.pool.Query(ctx, `
SELECT `+listItemStateColumns+`
FROM watch_provider_list_items
WHERE connection_id = $1::uuid
AND list_kind = $2
AND local_present = true
AND remote_present = false
AND last_error = ''
ORDER BY last_seen_local_at ASC NULLS LAST, created_at ASC
LIMIT $3
`, connectionID, string(kind), limit)
if err != nil {
return nil, fmt.Errorf("list pending list item exports: %w", err)
}
defer rows.Close()
return scanListItemStates(rows)
}
func (r *PostgresRepository) ListPendingListItemRemovals(ctx context.Context, connectionID string, kind ListKind, limit int) ([]ListItemState, error) {
if limit <= 0 || limit > 100 {
limit = 100
}
rows, err := r.pool.Query(ctx, `
SELECT `+listItemStateColumns+`
FROM watch_provider_list_items
WHERE connection_id = $1::uuid
AND list_kind = $2
AND local_present = false
AND remote_present = true
AND last_error = ''
ORDER BY last_removed_local_at ASC NULLS LAST, updated_at ASC
LIMIT $3
`, connectionID, string(kind), limit)
if err != nil {
return nil, fmt.Errorf("list pending list item removals: %w", err)
}
defer rows.Close()
return scanListItemStates(rows)
}
func (r *PostgresRepository) MarkListItemExported(ctx context.Context, connectionID string, kind ListKind, mediaItemID string, exportedAt time.Time) error {
return r.updateListItemState(ctx, connectionID, kind, mediaItemID, `
remote_present = true,
local_present = true,
last_exported_at = $4,
last_seen_remote_at = COALESCE(last_seen_remote_at, $4),
last_error = ''
`, exportedAt)
}
func (r *PostgresRepository) MarkListItemRemoteRemoved(ctx context.Context, connectionID string, kind ListKind, mediaItemID string, removedAt time.Time) error {
return r.updateListItemState(ctx, connectionID, kind, mediaItemID, `
remote_present = false,
last_removed_remote_at = $4,
last_error = ''
`, removedAt)
}
func (r *PostgresRepository) MarkListItemLocalRemoved(ctx context.Context, connectionID string, kind ListKind, mediaItemID string, removedAt time.Time) error {
return r.updateListItemState(ctx, connectionID, kind, mediaItemID, `
local_present = false,
last_removed_local_at = $4,
last_error = ''
`, removedAt)
}
func (r *PostgresRepository) MarkListItemError(ctx context.Context, connectionID string, kind ListKind, mediaItemID, lastError string) error {
_, err := r.pool.Exec(ctx, `
UPDATE watch_provider_list_items
SET last_error = $4, updated_at = now()
WHERE connection_id = $1::uuid AND list_kind = $2 AND media_item_id = $3
`, connectionID, string(kind), mediaItemID, lastError)
if err != nil {
return fmt.Errorf("mark list item error: %w", err)
}
return nil
}
func (r *PostgresRepository) updateListItemState(ctx context.Context, connectionID string, kind ListKind, mediaItemID, setClause string, at time.Time) error {
_, err := r.pool.Exec(ctx, `
UPDATE watch_provider_list_items
SET `+setClause+`,
updated_at = now()
WHERE connection_id = $1::uuid AND list_kind = $2 AND media_item_id = $3
`, connectionID, string(kind), mediaItemID, at)
if err != nil {
return fmt.Errorf("update list item state: %w", err)
}
return nil
}
func (r *PostgresRepository) ListScrobbleConnections(ctx context.Context, userID int, profileID string) ([]Connection, error) {
rows, err := r.pool.Query(ctx, `
SELECT `+connectionColumns+`
FROM watch_provider_connections
WHERE user_id = $1 AND profile_id = $2 AND scrobble_enabled = true
AND (rate_limited_until IS NULL OR rate_limited_until <= now())
ORDER BY provider
`, userID, profileID)
if err != nil {
return nil, fmt.Errorf("list scrobble connections: %w", err)
}
defer rows.Close()
var conns []Connection
for rows.Next() {
conn, err := r.scanConnection(rows)
if err != nil {
return nil, fmt.Errorf("scan scrobble connection: %w", err)
}
conns = append(conns, conn)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate scrobble connections: %w", err)
}
return conns, nil
}
func (r *PostgresRepository) UpsertScrobbleSession(ctx context.Context, event ScrobbleEvent, connectionID string, action string) error {
_, err := r.pool.Exec(ctx, `
INSERT INTO watch_provider_scrobble_sessions (
playback_session_id, connection_id, media_item_id, provider_item_key, kind,
imdb_id, tmdb_id, tvdb_id, series_imdb_id, series_tmdb_id, series_tvdb_id,
season_number, episode_number, history_id, started_at, last_progress,
duration_seconds, completed, last_action, last_error
)
VALUES (
$1, $2::uuid, $3, $4, $5, $6, $7, $8, $9, $10,
$11, $12, $13, $14, $15, $16, $17, $18, $19, ''
)
ON CONFLICT (playback_session_id, connection_id) DO UPDATE SET
media_item_id = EXCLUDED.media_item_id,
provider_item_key = EXCLUDED.provider_item_key,
kind = EXCLUDED.kind,
imdb_id = EXCLUDED.imdb_id,
tmdb_id = EXCLUDED.tmdb_id,
tvdb_id = EXCLUDED.tvdb_id,
series_imdb_id = EXCLUDED.series_imdb_id,
series_tmdb_id = EXCLUDED.series_tmdb_id,
series_tvdb_id = EXCLUDED.series_tvdb_id,
season_number = EXCLUDED.season_number,
episode_number = EXCLUDED.episode_number,
history_id = COALESCE(NULLIF(EXCLUDED.history_id, ''), watch_provider_scrobble_sessions.history_id),
last_progress = EXCLUDED.last_progress,
duration_seconds = EXCLUDED.duration_seconds,
completed = EXCLUDED.completed,
last_action = EXCLUDED.last_action,
last_error = '',
updated_at = now()
`, event.PlaybackSessionID, connectionID, event.MediaItemID, event.ProviderItemKey, event.Kind,
event.IMDbID, event.TMDBID, event.TVDBID, event.SeriesIMDbID, event.SeriesTMDBID,
event.SeriesTVDBID, event.SeasonNumber, event.EpisodeNumber, event.HistoryID,
event.OccurredAt, event.PositionSeconds, event.DurationSeconds, event.Completed, action)
if err != nil {
return fmt.Errorf("upsert scrobble session: %w", err)
}
return nil
}
func (r *PostgresRepository) UpdateScrobbleSession(ctx context.Context, playbackSessionID string, connectionID string, action string, progress float64, historyID string, lastError string, stopSentAt *time.Time) error {
_, err := r.pool.Exec(ctx, `
UPDATE watch_provider_scrobble_sessions
SET last_action = $3,
last_progress = $4,
history_id = COALESCE(NULLIF($5, ''), history_id),
last_error = $6,
stop_sent_at = COALESCE($7, stop_sent_at),
updated_at = now()
WHERE playback_session_id = $1 AND connection_id = $2::uuid
`, playbackSessionID, connectionID, action, progress, historyID, lastError, stopSentAt)
if err != nil {
return fmt.Errorf("update scrobble session: %w", err)
}
return nil
}
func (r *PostgresRepository) PrepareConfirmedScrobbleStop(ctx context.Context, event ScrobbleEvent, connectionID string, staleBefore time.Time) (confirmedStopPreparation, time.Time, error) {
var claimVersion time.Time
err := r.pool.QueryRow(ctx, `
INSERT INTO watch_provider_scrobble_sessions (
playback_session_id, connection_id, media_item_id, provider_item_key, kind,
imdb_id, tmdb_id, tvdb_id, series_imdb_id, series_tmdb_id, series_tvdb_id,
season_number, episode_number, history_id, started_at, last_progress,
duration_seconds, completed, last_action, last_error, stop_sent_at
)
VALUES (
$1, $2::uuid, $3, $4, $5, $6, $7, $8, $9, $10,
$11, $12, $13, $14, $15, $16, $17, $18, 'stop_confirming', '', NULL
)
ON CONFLICT (playback_session_id, connection_id) DO NOTHING
RETURNING updated_at
`, event.PlaybackSessionID, connectionID, event.MediaItemID, event.ProviderItemKey, event.Kind,
event.IMDbID, event.TMDBID, event.TVDBID, event.SeriesIMDbID, event.SeriesTMDBID,
event.SeriesTVDBID, event.SeasonNumber, event.EpisodeNumber, event.HistoryID,
event.OccurredAt, event.PositionSeconds, event.DurationSeconds, event.Completed).Scan(&claimVersion)
if err == nil {
return confirmedStopPrepared, claimVersion, nil
}
if !errors.Is(err, pgx.ErrNoRows) {
return confirmedStopInProgress, time.Time{}, fmt.Errorf("insert confirmed scrobble stop: %w", err)
}
err = r.pool.QueryRow(ctx, `
UPDATE watch_provider_scrobble_sessions
SET last_action = 'stop_confirming',
last_progress = $3,
history_id = COALESCE(NULLIF($4, ''), history_id),
duration_seconds = $5,
completed = $6,
last_error = '',
stop_sent_at = NULL,
updated_at = now()
WHERE playback_session_id = $1 AND connection_id = $2::uuid
-- A non-null stop_sent_at can be the provisional ActiveEncodings
-- fallback. Confirmed delivery must replace it with the later
-- authoritative Stopped position, so last_action owns deduplication.
AND last_action IS DISTINCT FROM 'stop_confirmed'
AND (last_action IS DISTINCT FROM 'stop_confirming' OR updated_at <= $7)
RETURNING updated_at
`, event.PlaybackSessionID, connectionID, event.PositionSeconds, event.HistoryID,
event.DurationSeconds, event.Completed, staleBefore).Scan(&claimVersion)
if err == nil {
return confirmedStopPrepared, claimVersion, nil
}
if !errors.Is(err, pgx.ErrNoRows) {
return confirmedStopInProgress, time.Time{}, fmt.Errorf("prepare confirmed scrobble stop: %w", err)
}
var lastAction string
if err := r.pool.QueryRow(ctx, `
SELECT last_action
FROM watch_provider_scrobble_sessions
WHERE playback_session_id = $1 AND connection_id = $2::uuid
`, event.PlaybackSessionID, connectionID).Scan(&lastAction); err != nil {
return confirmedStopInProgress, time.Time{}, fmt.Errorf("read confirmed scrobble stop state: %w", err)
}
if lastAction == "stop_confirmed" {
return confirmedStopAlreadySent, time.Time{}, nil
}
return confirmedStopInProgress, time.Time{}, nil
}
func (r *PostgresRepository) CompleteConfirmedScrobbleStop(ctx context.Context, playbackSessionID string, connectionID string, progress float64, historyID string, claimVersion time.Time, stopSentAt time.Time) error {
tag, err := r.pool.Exec(ctx, `
UPDATE watch_provider_scrobble_sessions
SET last_action = 'stop_confirmed',
last_progress = $3,
history_id = COALESCE(NULLIF($4, ''), history_id),
last_error = '',
stop_sent_at = $5,
updated_at = now()
WHERE playback_session_id = $1 AND connection_id = $2::uuid
AND last_action = 'stop_confirming' AND updated_at = $6
`, playbackSessionID, connectionID, progress, historyID, stopSentAt, claimVersion)
if err != nil {
return fmt.Errorf("complete confirmed scrobble stop: %w", err)
}
if tag.RowsAffected() == 0 {
return errConfirmedStopClaimLost
}
return nil
}
func (r *PostgresRepository) FailConfirmedScrobbleStop(ctx context.Context, playbackSessionID string, connectionID string, progress float64, historyID string, claimVersion time.Time, lastError string) error {
tag, err := r.pool.Exec(ctx, `
UPDATE watch_provider_scrobble_sessions
SET last_action = 'stop_retry',
last_progress = $3,
history_id = COALESCE(NULLIF($4, ''), history_id),
last_error = $6,
updated_at = now()
WHERE playback_session_id = $1 AND connection_id = $2::uuid
AND last_action = 'stop_confirming' AND updated_at = $5
`, playbackSessionID, connectionID, progress, historyID, claimVersion, lastError)
if err != nil {
return fmt.Errorf("fail confirmed scrobble stop: %w", err)
}
if tag.RowsAffected() == 0 {
return errConfirmedStopClaimLost
}
return nil
}
func (r *PostgresRepository) ListOpenScrobbleSessions(ctx context.Context) ([]ScrobbleSession, error) {
rows, err := r.pool.Query(ctx, `
SELECT playback_session_id, connection_id::text, media_item_id, provider_item_key, kind,
imdb_id, tmdb_id, tvdb_id, series_imdb_id, series_tmdb_id, series_tvdb_id,
season_number, episode_number, history_id, started_at, last_progress,
duration_seconds, completed, last_action, stop_sent_at, last_error
FROM watch_provider_scrobble_sessions
WHERE stop_sent_at IS NULL
AND last_action NOT IN ('stop_confirming', 'stop_retry')
ORDER BY started_at ASC
`)
if err != nil {
return nil, fmt.Errorf("list open scrobble sessions: %w", err)
}
defer rows.Close()
var sessions []ScrobbleSession
for rows.Next() {
var session ScrobbleSession
if err := rows.Scan(
&session.PlaybackSessionID,
&session.ConnectionID,
&session.MediaItemID,
&session.ProviderItemKey,
&session.Kind,
&session.IMDbID,
&session.TMDBID,
&session.TVDBID,
&session.SeriesIMDbID,
&session.SeriesTMDBID,
&session.SeriesTVDBID,
&session.SeasonNumber,
&session.EpisodeNumber,
&session.HistoryID,
&session.StartedAt,
&session.LastProgress,
&session.DurationSeconds,
&session.Completed,
&session.LastAction,
&session.StopSentAt,
&session.LastError,
); err != nil {
return nil, fmt.Errorf("scan open scrobble session: %w", err)
}
sessions = append(sessions, session)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate open scrobble sessions: %w", err)
}
return sessions, nil
}
func (r *PostgresRepository) ListPendingScrobbleReconciliations(ctx context.Context) ([]ScrobbleSession, error) {
rows, err := r.pool.Query(ctx, `
SELECT playback_session_id, connection_id::text, media_item_id, provider_item_key, kind,
imdb_id, tmdb_id, tvdb_id, series_imdb_id, series_tmdb_id, series_tvdb_id,
season_number, episode_number, history_id, started_at, last_progress,
duration_seconds, completed, last_action, stop_sent_at, history_reconciled_at, last_error
FROM watch_provider_scrobble_sessions
WHERE stop_sent_at IS NOT NULL AND completed = true AND history_id <> ''
AND history_reconciled_at IS NULL
ORDER BY stop_sent_at ASC
LIMIT 100
`)
if err != nil {
return nil, fmt.Errorf("list pending scrobble reconciliations: %w", err)
}
defer rows.Close()
var sessions []ScrobbleSession
for rows.Next() {
var session ScrobbleSession
if err := rows.Scan(
&session.PlaybackSessionID, &session.ConnectionID, &session.MediaItemID,
&session.ProviderItemKey, &session.Kind, &session.IMDbID, &session.TMDBID,
&session.TVDBID, &session.SeriesIMDbID, &session.SeriesTMDBID,
&session.SeriesTVDBID, &session.SeasonNumber, &session.EpisodeNumber,
&session.HistoryID, &session.StartedAt, &session.LastProgress,
&session.DurationSeconds, &session.Completed, &session.LastAction,
&session.StopSentAt, &session.HistoryReconciledAt, &session.LastError,
); err != nil {
return nil, fmt.Errorf("scan pending scrobble reconciliation: %w", err)
}
sessions = append(sessions, session)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate pending scrobble reconciliations: %w", err)
}
return sessions, nil
}
func (r *PostgresRepository) MarkScrobbleHistoryReconciled(
ctx context.Context,
playbackSessionID string,
connectionID string,
reconciledAt time.Time,
) error {
_, err := r.pool.Exec(ctx, `
UPDATE watch_provider_scrobble_sessions
SET history_reconciled_at = $3, last_error = '', updated_at = now()
WHERE playback_session_id = $1 AND connection_id = $2::uuid
`, playbackSessionID, connectionID, reconciledAt)
if err != nil {
return fmt.Errorf("mark scrobble history reconciled: %w", err)
}
return nil
}
func (r *PostgresRepository) scanDeviceAuthSession(row pgx.Row) (DeviceAuthSession, error) {
var session DeviceAuthSession
err := row.Scan(
&session.ID,
&session.Provider,
&session.UserID,
&session.ProfileID,
&session.DeviceCode,
&session.UserCode,
&session.VerificationURL,
&session.IntervalSeconds,
&session.ExpiresAt,
&session.CompletedAt,
)
if err != nil {
return DeviceAuthSession{}, err
}
decrypted, err := r.cipher.DecryptIfEncrypted(
session.DeviceCode,
authStateAAD(session.Provider, session.UserID, session.ProfileID),
)
if err != nil {
return DeviceAuthSession{}, fmt.Errorf("decrypt watch provider authorization state: %w", err)
}
session.DeviceCode = decrypted
return session, nil
}
func scanSyncRun(row pgx.Row) (SyncRun, error) {
var run SyncRun
err := row.Scan(
&run.ID,
&run.ConnectionID,
&run.Trigger,
&run.Status,
&run.Provider,
&run.InboundWatchedFound,
&run.InboundWatchedImported,
&run.InboundProgressFound,
&run.InboundProgressImported,
&run.OutboundFound,
&run.OutboundSent,
&run.InboundFavoritesFound,
&run.InboundFavoritesImported,
&run.OutboundFavoritesFound,
&run.OutboundFavoritesSent,
&run.FavoriteRemovalsSent,
&run.InboundWatchlistFound,
&run.InboundWatchlistImported,
&run.OutboundWatchlistFound,
&run.OutboundWatchlistSent,
&run.WatchlistRemovalsSent,
&run.Warning,
&run.Error,
&run.StartedAt,
&run.CompletedAt,
&run.CreatedAt,
)
if err != nil {
return SyncRun{}, err
}
return run, nil
}
func (r *PostgresRepository) scanConnection(row pgx.Row) (Connection, error) {
var conn Connection
var rawSyncCursors []byte
var rawPluginCredentials string
err := row.Scan(
&conn.ID,
&conn.Provider,
&conn.UserID,
&conn.ProfileID,
&conn.ProviderAccountID,
&conn.ProviderUsername,
&conn.AccessToken,
&conn.RefreshToken,
&conn.TokenExpiresAt,
&rawPluginCredentials,
&conn.ImportWatchedEnabled,
&conn.ImportProgressEnabled,
&conn.ExportWatchedEnabled,
&conn.ExportUnwatchedEnabled,
&conn.ImportFavoritesEnabled,
&conn.ExportFavoritesEnabled,
&conn.SyncFavoriteRemovalsEnabled,
&conn.ImportWatchlistEnabled,
&conn.ExportWatchlistEnabled,
&conn.SyncWatchlistRemovalsEnabled,
&conn.SyncWatchlistOrderEnabled,
&conn.ScrobbleEnabled,
&conn.LastInboundSyncAt,
&conn.LastProgressSyncAt,
&conn.LastOutboundSyncAt,
&conn.LastFavoritesSyncAt,
&conn.LastWatchlistSyncAt,
&conn.LastScrobbleErrorAt,
&conn.LastError,
&conn.RateLimitedUntil,
&rawSyncCursors,
&conn.CreatedAt,
&conn.UpdatedAt,
)
if err != nil {
return Connection{}, err
}
// Decrypt the tokens (read-path contract), bound to the connection's stable
// business key — matching TokenAAD on the write path.
if conn.AccessToken, err = r.cipher.DecryptIfEncrypted(conn.AccessToken, TokenAAD("access_token", conn.Provider, conn.UserID, conn.ProfileID)); err != nil {
return Connection{}, fmt.Errorf("decrypt watch access token: %w", err)
}
if conn.RefreshToken, err = r.cipher.DecryptIfEncrypted(conn.RefreshToken, TokenAAD("refresh_token", conn.Provider, conn.UserID, conn.ProfileID)); err != nil {
return Connection{}, fmt.Errorf("decrypt watch refresh token: %w", err)
}
if err := r.decodePluginCredentials(&conn, rawPluginCredentials); err != nil {
return Connection{}, err
}
conn.SyncCursors = decodeSyncCursors(rawSyncCursors)
return conn, nil
}
type storedPluginCredentials struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token,omitempty"`
TokenExpiresAt *time.Time `json:"expires_at,omitempty"`
TokenType string `json:"token_type,omitempty"`
Scopes []string `json:"scopes,omitempty"`
SecretAttributes map[string]string `json:"secret_attributes,omitempty"`
}
func (r *PostgresRepository) pluginCredentialsForConnection(conn Connection) (string, error) {
if !strings.HasPrefix(conn.Provider, providerSourcePlugin+":") {
// Built-in providers have legacy token writers that update the dedicated
// token columns directly. Keeping a second authoritative bundle for them
// would let that bundle become stale and overwrite freshly rotated tokens.
return "", nil
}
return r.encodePluginCredentials(conn)
}
func (r *PostgresRepository) encodePluginCredentials(conn Connection) (string, error) {
payload, err := json.Marshal(storedPluginCredentials{
AccessToken: conn.AccessToken,
RefreshToken: conn.RefreshToken,
TokenExpiresAt: conn.TokenExpiresAt,
TokenType: conn.TokenType,
Scopes: conn.Scopes,
SecretAttributes: conn.SecretAttributes,
})
if err != nil {
return "", fmt.Errorf("encode watch provider credentials: %w", err)
}
encoded, err := r.cipher.Encrypt(
string(payload),
TokenAAD("plugin_credentials", conn.Provider, conn.UserID, conn.ProfileID),
)
if err != nil {
return "", fmt.Errorf("encrypt watch provider credentials: %w", err)
}
return encoded, nil
}
func (r *PostgresRepository) decodePluginCredentials(conn *Connection, encoded string) error {
if conn == nil || strings.TrimSpace(encoded) == "" {
return nil
}
plaintext, err := r.cipher.DecryptIfEncrypted(
encoded,
TokenAAD("plugin_credentials", conn.Provider, conn.UserID, conn.ProfileID),
)
if err != nil {
return fmt.Errorf("decrypt watch provider credentials: %w", err)
}
var credentials storedPluginCredentials
if err := json.Unmarshal([]byte(plaintext), &credentials); err != nil {
return fmt.Errorf("decode watch provider credentials: %w", err)
}
conn.AccessToken = credentials.AccessToken
conn.RefreshToken = credentials.RefreshToken
conn.TokenExpiresAt = credentials.TokenExpiresAt
conn.TokenType = credentials.TokenType
conn.Scopes = append([]string(nil), credentials.Scopes...)
conn.SecretAttributes = cloneStringMap(credentials.SecretAttributes)
return nil
}
func cloneStringMap(values map[string]string) map[string]string {
if values == nil {
return nil
}
result := make(map[string]string, len(values))
for key, value := range values {
result[key] = value
}
return result
}
type listItemStateRows interface {
Next() bool
Scan(dest ...any) error
Err() error
}
func scanListItemStates(rows listItemStateRows) ([]ListItemState, error) {
var states []ListItemState
for rows.Next() {
var state ListItemState
if err := rows.Scan(
&state.ID,
&state.ConnectionID,
&state.ListKind,
&state.MediaItemID,
&state.ProviderItemKey,
&state.Kind,
&state.Title,
&state.Year,
&state.RemotePresent,
&state.LocalPresent,
&state.LastSeenRemoteAt,
&state.LastSeenLocalAt,
&state.LastExportedAt,
&state.LastRemovedRemoteAt,
&state.LastRemovedLocalAt,
&state.LastError,
&state.CreatedAt,
&state.UpdatedAt,
); err != nil {
return nil, fmt.Errorf("scan list item state: %w", err)
}
states = append(states, state)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate list item states: %w", err)
}
return states, nil
}
func encodeSyncCursors(cursors map[string]string) []byte {
if len(cursors) == 0 {
return []byte(`{}`)
}
data, err := json.Marshal(cursors)
if err != nil {
return []byte(`{}`)
}
return data
}
func decodeSyncCursors(data []byte) map[string]string {
if len(data) == 0 {
return map[string]string{}
}
var cursors map[string]string
if err := json.Unmarshal(data, &cursors); err != nil || cursors == nil {
return map[string]string{}
}
return cursors
}