Files
silo-server/internal/watchsync/repository.go
T

1134 lines
41 KiB
Go

package watchsync
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
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)
ListFavoriteEventConnections(ctx context.Context, userID int, profileID string, kind LocalFavoriteEventKind) ([]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
UpsertFavoriteStates(ctx context.Context, states []FavoriteState) error
ListFavoriteStates(ctx context.Context, connectionID string) ([]FavoriteState, error)
ListPendingFavoriteExports(ctx context.Context, connectionID string, limit int) ([]FavoriteState, error)
ListPendingFavoriteRemovals(ctx context.Context, connectionID string, limit int) ([]FavoriteState, error)
MarkFavoriteExported(ctx context.Context, connectionID, mediaItemID string, exportedAt time.Time) error
MarkFavoriteRemoteRemoved(ctx context.Context, connectionID, mediaItemID string, removedAt time.Time) error
MarkFavoriteLocalRemoved(ctx context.Context, connectionID, mediaItemID string, removedAt time.Time) error
MarkFavoriteError(ctx context.Context, connectionID, 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)
}
type PostgresRepository struct {
pool *pgxpool.Pool
}
func NewPostgresRepository(pool *pgxpool.Pool) *PostgresRepository {
return &PostgresRepository{pool: pool}
}
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)
}
return value, 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) {
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,
scrobble_enabled, last_inbound_sync_at, last_progress_sync_at, last_outbound_sync_at,
last_favorites_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::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,
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_scrobble_error_at = EXCLUDED.last_scrobble_error_at,
last_error = EXCLUDED.last_error,
sync_cursors = EXCLUDED.sync_cursors,
updated_at = now()
RETURNING
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,
scrobble_enabled, last_inbound_sync_at, last_progress_sync_at, last_outbound_sync_at,
last_favorites_sync_at, last_scrobble_error_at, last_error, sync_cursors, created_at, updated_at
`,
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.ScrobbleEnabled,
conn.LastInboundSyncAt,
conn.LastProgressSyncAt,
conn.LastOutboundSyncAt,
conn.LastFavoritesSyncAt,
conn.LastScrobbleErrorAt,
conn.LastError,
encodeSyncCursors(conn.SyncCursors),
)
saved, err := 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
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,
scrobble_enabled, last_inbound_sync_at, last_progress_sync_at, last_outbound_sync_at,
last_favorites_sync_at, last_scrobble_error_at, last_error, sync_cursors, created_at, updated_at
FROM watch_provider_connections
WHERE provider = $1 AND user_id = $2 AND profile_id = $3
`, provider, userID, profileID)
conn, err := 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
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,
scrobble_enabled, last_inbound_sync_at, last_progress_sync_at, last_outbound_sync_at,
last_favorites_sync_at, last_scrobble_error_at, last_error, sync_cursors, created_at, updated_at
FROM watch_provider_connections
WHERE id = $1::uuid
`, id)
conn, err := 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
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,
scrobble_enabled, last_inbound_sync_at, last_progress_sync_at, last_outbound_sync_at,
last_favorites_sync_at, last_scrobble_error_at, last_error, sync_cursors, created_at, updated_at
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 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 := 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,
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
)
RETURNING 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,
warning, error, started_at, completed_at, created_at
`, 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.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,
warning = $14,
error = $15,
completed_at = $16
WHERE id = $1::uuid
RETURNING 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,
warning, error, started_at, completed_at, created_at
`, 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.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 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,
warning, error, started_at, completed_at, created_at
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 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,
warning, error, started_at, completed_at, created_at
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 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,
warning, error, started_at, completed_at, created_at
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
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,
scrobble_enabled, last_inbound_sync_at, last_progress_sync_at, last_outbound_sync_at,
last_favorites_sync_at, last_scrobble_error_at, last_error, sync_cursors, created_at, updated_at
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 := 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
}
func (r *PostgresRepository) ListFavoriteEventConnections(
ctx context.Context,
userID int,
profileID string,
kind LocalFavoriteEventKind,
) ([]Connection, error) {
var predicate string
switch kind {
case LocalFavoriteEventAdded:
predicate = "export_favorites_enabled = true"
case LocalFavoriteEventRemoved:
predicate = "export_favorites_enabled = true"
default:
return nil, nil
}
rows, err := r.pool.Query(ctx, `
SELECT
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,
scrobble_enabled, last_inbound_sync_at, last_progress_sync_at, last_outbound_sync_at,
last_favorites_sync_at, last_scrobble_error_at, last_error, sync_cursors, created_at, updated_at
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 favorite event connections: %w", err)
}
defer rows.Close()
var conns []Connection
for rows.Next() {
conn, scanErr := scanConnection(rows)
if scanErr != nil {
return nil, fmt.Errorf("scan favorite event connection: %w", scanErr)
}
conns = append(conns, conn)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate favorite event connections: %w", 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) GetFavoriteMediaItems(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 favorite 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 favorite media item: %w", err)
}
fav.ProviderItemKey = providerItemKeyForLocalFavorite(fav)
result[fav.MediaItemID] = fav
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate favorite 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) UpsertFavoriteStates(ctx context.Context, states []FavoriteState) error {
for _, state := range states {
_, err := r.pool.Exec(ctx, `
INSERT INTO watch_provider_favorite_items (
connection_id, 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)
ON CONFLICT (connection_id, media_item_id) DO UPDATE SET
provider_item_key = CASE
WHEN EXCLUDED.provider_item_key <> '' THEN EXCLUDED.provider_item_key
ELSE watch_provider_favorite_items.provider_item_key
END,
kind = CASE WHEN EXCLUDED.kind <> '' THEN EXCLUDED.kind ELSE watch_provider_favorite_items.kind END,
title = CASE WHEN EXCLUDED.title <> '' THEN EXCLUDED.title ELSE watch_provider_favorite_items.title END,
year = CASE WHEN EXCLUDED.year <> 0 THEN EXCLUDED.year ELSE watch_provider_favorite_items.year END,
remote_present = watch_provider_favorite_items.remote_present OR EXCLUDED.remote_present,
local_present = EXCLUDED.local_present,
last_seen_remote_at = COALESCE(EXCLUDED.last_seen_remote_at, watch_provider_favorite_items.last_seen_remote_at),
last_seen_local_at = COALESCE(EXCLUDED.last_seen_local_at, watch_provider_favorite_items.last_seen_local_at),
last_exported_at = COALESCE(EXCLUDED.last_exported_at, watch_provider_favorite_items.last_exported_at),
last_removed_remote_at = COALESCE(EXCLUDED.last_removed_remote_at, watch_provider_favorite_items.last_removed_remote_at),
last_removed_local_at = COALESCE(EXCLUDED.last_removed_local_at, watch_provider_favorite_items.last_removed_local_at),
last_error = EXCLUDED.last_error,
updated_at = now()
`, state.ConnectionID, 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 favorite state: %w", err)
}
}
return nil
}
func (r *PostgresRepository) ListFavoriteStates(ctx context.Context, connectionID string) ([]FavoriteState, error) {
rows, err := r.pool.Query(ctx, `
SELECT id::text, connection_id::text, 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
FROM watch_provider_favorite_items
WHERE connection_id = $1::uuid
`, connectionID)
if err != nil {
return nil, fmt.Errorf("list favorite states: %w", err)
}
defer rows.Close()
return scanFavoriteStates(rows)
}
func (r *PostgresRepository) ListPendingFavoriteExports(ctx context.Context, connectionID string, limit int) ([]FavoriteState, error) {
if limit <= 0 || limit > 100 {
limit = 100
}
rows, err := r.pool.Query(ctx, `
SELECT id::text, connection_id::text, 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
FROM watch_provider_favorite_items
WHERE connection_id = $1::uuid
AND local_present = true
AND remote_present = false
AND last_error = ''
ORDER BY last_seen_local_at ASC NULLS LAST, created_at ASC
LIMIT $2
`, connectionID, limit)
if err != nil {
return nil, fmt.Errorf("list pending favorite exports: %w", err)
}
defer rows.Close()
return scanFavoriteStates(rows)
}
func (r *PostgresRepository) ListPendingFavoriteRemovals(ctx context.Context, connectionID string, limit int) ([]FavoriteState, error) {
if limit <= 0 || limit > 100 {
limit = 100
}
rows, err := r.pool.Query(ctx, `
SELECT id::text, connection_id::text, 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
FROM watch_provider_favorite_items
WHERE connection_id = $1::uuid
AND local_present = false
AND remote_present = true
AND last_error = ''
ORDER BY last_removed_local_at ASC NULLS LAST, updated_at ASC
LIMIT $2
`, connectionID, limit)
if err != nil {
return nil, fmt.Errorf("list pending favorite removals: %w", err)
}
defer rows.Close()
return scanFavoriteStates(rows)
}
func (r *PostgresRepository) MarkFavoriteExported(ctx context.Context, connectionID, mediaItemID string, exportedAt time.Time) error {
return r.updateFavoriteState(ctx, connectionID, mediaItemID, `
remote_present = true,
local_present = true,
last_exported_at = $3,
last_seen_remote_at = COALESCE(last_seen_remote_at, $3),
last_error = ''
`, exportedAt)
}
func (r *PostgresRepository) MarkFavoriteRemoteRemoved(ctx context.Context, connectionID, mediaItemID string, removedAt time.Time) error {
return r.updateFavoriteState(ctx, connectionID, mediaItemID, `
remote_present = false,
last_removed_remote_at = $3,
last_error = ''
`, removedAt)
}
func (r *PostgresRepository) MarkFavoriteLocalRemoved(ctx context.Context, connectionID, mediaItemID string, removedAt time.Time) error {
return r.updateFavoriteState(ctx, connectionID, mediaItemID, `
local_present = false,
last_removed_local_at = $3,
last_error = ''
`, removedAt)
}
func (r *PostgresRepository) MarkFavoriteError(ctx context.Context, connectionID, mediaItemID, lastError string) error {
_, err := r.pool.Exec(ctx, `
UPDATE watch_provider_favorite_items
SET last_error = $3, updated_at = now()
WHERE connection_id = $1::uuid AND media_item_id = $2
`, connectionID, mediaItemID, lastError)
if err != nil {
return fmt.Errorf("mark favorite error: %w", err)
}
return nil
}
func (r *PostgresRepository) updateFavoriteState(ctx context.Context, connectionID, mediaItemID, setClause string, at time.Time) error {
_, err := r.pool.Exec(ctx, `
UPDATE watch_provider_favorite_items
SET `+setClause+`,
updated_at = now()
WHERE connection_id = $1::uuid AND media_item_id = $2
`, connectionID, mediaItemID, at)
if err != nil {
return fmt.Errorf("update favorite 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
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,
scrobble_enabled, last_inbound_sync_at, last_progress_sync_at, last_outbound_sync_at,
last_favorites_sync_at, last_scrobble_error_at, last_error, sync_cursors, created_at, updated_at
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 := 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.Warning,
&run.Error,
&run.StartedAt,
&run.CompletedAt,
&run.CreatedAt,
)
if err != nil {
return SyncRun{}, err
}
return run, nil
}
func 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.ScrobbleEnabled,
&conn.LastInboundSyncAt,
&conn.LastProgressSyncAt,
&conn.LastOutboundSyncAt,
&conn.LastFavoritesSyncAt,
&conn.LastScrobbleErrorAt,
&conn.LastError,
&rawSyncCursors,
&conn.CreatedAt,
&conn.UpdatedAt,
)
if err != nil {
return Connection{}, err
}
conn.SyncCursors = decodeSyncCursors(rawSyncCursors)
return conn, nil
}
type favoriteStateRows interface {
Next() bool
Scan(dest ...any) error
Err() error
}
func scanFavoriteStates(rows favoriteStateRows) ([]FavoriteState, error) {
var states []FavoriteState
for rows.Next() {
var state FavoriteState
if err := rows.Scan(
&state.ID,
&state.ConnectionID,
&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 favorite state: %w", err)
}
states = append(states, state)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate favorite 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
}