Files
silo-server/internal/watchsync/repository.go
T
02e62767a1 feat(watchsync): sync watchlists with Trakt/Simkl/MDBList (#227)
* feat(watchsync): sync watchlists with Trakt/Simkl/MDBList

Extend the watch-providers feature to sync a user's watchlist, generalizing
the existing favorites pipeline rather than duplicating it.

What changed
- Generalize the favorites sync into one ListKind-parameterized pipeline
  (internal/watchsync/lists.go) driving both favorites and watchlist; the
  per-favorites service methods are replaced by kind-generic ones. The shadow
  table watch_provider_favorite_items becomes watch_provider_list_items with a
  list_kind discriminator.
- Providers: Trakt gains watchlist sync (/sync/watchlist, distinct from
  favorites); Simkl gains plan-to-watch sync; MDBList is re-mapped from
  favorites to watchlist (its only list is a watchlist) — its capabilities now
  report import_favorites=false / import_watchlist=true, and the migration
  re-binds existing MDBList connections.
- Auto-remove watched items from the watchlist: a standalone, default-on
  profile preference (user_profiles.remove_watched_from_watchlist) removes a
  movie when watched and a series once every episode is watched. Implemented as
  watchstate.CompletionObserver (internal/watchlist.Maintainer), wired into the
  manual mark-watched, playback-stop, and jellycompat mark-played paths.
- Optional MDBList sort-order mirroring: an opt-in, capability-gated toggle
  mirrors MDBList's watchlist order into Silo via user_watchlist.sort_index;
  ListWatchlist orders by sort_index then added_at, so both /api/v1/watchlist
  and the catalog watchlist view inherit it.
- Real-time + scheduled: local add/remove pushes to connected providers
  immediately (removals gated by the opt-in removals toggle); the hourly job is
  the inbound/import + retry/reconcile path.
- Web: watch-provider settings gain watchlist import/export/removals and
  "mirror watchlist order" toggles plus watchlist sync stats.

Why
- The favorites and watchlist pipelines are ~90% identical; generalizing keeps
  one code path (per CLAUDE.md's anti-duplication guidance) instead of cloning.

API/compat
- All new fields on ConnectionStatus/Capabilities/ConnectionUpdate/SyncRun and
  the web types are additive (Silo v1 additive-only rule). No existing field is
  renamed, removed, or retyped.

Risks / follow-up
- MDBList capability flip is intentional and client-visible: silo-android /
  silo-apple may need to surface MDBList under the watchlist (not favorites) UI.
- MDBList existing users: their MDBList list previously mirrored Silo favorites
  and now mirrors Silo watchlist; the first post-migration sync is a union
  (removals default off), so nothing is destructively purged.
- Order mirroring reflects the order MDBList returns from /watchlist/items
  (couldn't confirm against their docs — Cloudflare-blocked); if it ever
  diverges from the UI sort, a sort param is the small follow-up.

Tests: new maintainer (auto-remove) and watchlist-order unit tests; provider +
service tests updated. go build, go test (affected pkgs), migrate-validate,
verify-local-paths, web prettier/eslint/tsc all pass.

AI-use disclosure: implemented with Claude Code (Claude Opus 4.8).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(watchsync): update list shadow table references

* fix(watchsync): address review — retry/progress + error propagation

Addresses CodeRabbit review on #227:
- maintainer: propagate transient catalog lookup errors instead of silently
  treating every items.GetByID failure as "maybe an episode".
- exportList: mark every queued item not confirmed sent (not_found, failed, or
  omitted) so the pending loop always advances; the next run's upsert clears the
  error and re-attempts, so transient failures still retry.
- removePendingListItems + realtime removal: treat Sent and NotFound as
  reconciled; leave true failures pending (no last_error, which would strand
  them from the removal query) so the scheduled run retries, using in-memory
  dedupe to terminate the loop.
- exportLocalListItems: send the normalized items (with computed
  ProviderItemKey), not the original event slice.
- UpdateConnection: clear mirrored watchlist order before persisting the disable
  and propagate failures, so a failed clear can't report "disabled" while
  sort_index ordering is still active.
- web: include favorite + watchlist removal counts in the exported "sent" total.
- test: align serviceFakeRepo list-state with Postgres (clear last_error on
  successful transitions); add maintainer error-propagation test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 16:05:53 -04:00

1169 lines
41 KiB
Go

package watchsync
import (
"context"
"encoding/json"
"errors"
"fmt"
"strconv"
"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)
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
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
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)
}
// 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, 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,
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 (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) {
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,
session.DeviceCode,
session.UserCode,
session.VerificationURL,
session.IntervalSeconds,
session.ExpiresAt,
session.CompletedAt,
)
saved, err := 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 := 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)
}
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, 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, 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::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,
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,
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,
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,
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,
_ time.Time,
) ([]Connection, error) {
rows, err := r.pool.Query(ctx, `
SELECT `+connectionColumns+`
FROM watch_provider_connections
WHERE provider <> ''
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
`)
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
}
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+`
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
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') 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
`, id, status, lastError)
if err != nil {
return fmt.Errorf("mark history export status: %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
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) 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
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 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
}
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
err := row.Scan(
&conn.ID,
&conn.Provider,
&conn.UserID,
&conn.ProfileID,
&conn.ProviderAccountID,
&conn.ProviderUsername,
&conn.AccessToken,
&conn.RefreshToken,
&conn.TokenExpiresAt,
&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,
&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)
}
conn.SyncCursors = decodeSyncCursors(rawSyncCursors)
return conn, nil
}
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
}