* 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>
1504 lines
55 KiB
Go
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
|
|
}
|