* 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>
347 lines
10 KiB
Go
347 lines
10 KiB
Go
package userdb
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
)
|
|
|
|
const schemaVersion = 12
|
|
|
|
func runMigrations(db *sql.DB) error {
|
|
version, err := userVersion(db)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if version > schemaVersion {
|
|
return fmt.Errorf("unsupported sqlite schema version %d", version)
|
|
}
|
|
if version == 0 {
|
|
return setUserVersion(db, schemaVersion)
|
|
}
|
|
if version == schemaVersion {
|
|
return nil
|
|
}
|
|
|
|
tx, err := db.Begin()
|
|
if err != nil {
|
|
return fmt.Errorf("beginning sqlite migration transaction: %w", err)
|
|
}
|
|
defer tx.Rollback() //nolint:errcheck
|
|
|
|
if version < 2 {
|
|
if err := migrateToV2(tx); err != nil {
|
|
return err
|
|
}
|
|
if _, err := tx.Exec("PRAGMA user_version = 2"); err != nil {
|
|
return fmt.Errorf("setting sqlite user_version 2: %w", err)
|
|
}
|
|
}
|
|
|
|
if version < 3 {
|
|
if err := migrateToV3(tx); err != nil {
|
|
return err
|
|
}
|
|
if _, err := tx.Exec("PRAGMA user_version = 3"); err != nil {
|
|
return fmt.Errorf("setting sqlite user_version 3: %w", err)
|
|
}
|
|
}
|
|
|
|
if version < 4 {
|
|
if err := migrateToV4(tx); err != nil {
|
|
return err
|
|
}
|
|
if _, err := tx.Exec("PRAGMA user_version = 4"); err != nil {
|
|
return fmt.Errorf("setting sqlite user_version 4: %w", err)
|
|
}
|
|
}
|
|
|
|
if version < 5 {
|
|
if err := migrateToV5(tx); err != nil {
|
|
return err
|
|
}
|
|
if _, err := tx.Exec("PRAGMA user_version = 5"); err != nil {
|
|
return fmt.Errorf("setting sqlite user_version 5: %w", err)
|
|
}
|
|
}
|
|
|
|
if version < 6 {
|
|
if err := migrateToV6(tx); err != nil {
|
|
return err
|
|
}
|
|
if _, err := tx.Exec("PRAGMA user_version = 6"); err != nil {
|
|
return fmt.Errorf("setting sqlite user_version 6: %w", err)
|
|
}
|
|
}
|
|
|
|
if version < 7 {
|
|
if err := migrateToV7(tx); err != nil {
|
|
return err
|
|
}
|
|
if _, err := tx.Exec("PRAGMA user_version = 7"); err != nil {
|
|
return fmt.Errorf("setting sqlite user_version 7: %w", err)
|
|
}
|
|
}
|
|
|
|
if version < 8 {
|
|
if err := migrateToV8(tx); err != nil {
|
|
return err
|
|
}
|
|
if _, err := tx.Exec("PRAGMA user_version = 8"); err != nil {
|
|
return fmt.Errorf("setting sqlite user_version 8: %w", err)
|
|
}
|
|
}
|
|
|
|
if version < 9 {
|
|
if _, err := tx.Exec("PRAGMA user_version = 9"); err != nil {
|
|
return fmt.Errorf("setting sqlite user_version 9: %w", err)
|
|
}
|
|
}
|
|
|
|
if version < 10 {
|
|
if err := migrateToV10(tx); err != nil {
|
|
return err
|
|
}
|
|
if _, err := tx.Exec("PRAGMA user_version = 10"); err != nil {
|
|
return fmt.Errorf("setting sqlite user_version 10: %w", err)
|
|
}
|
|
}
|
|
|
|
if version < 11 {
|
|
if err := migrateToV11(tx); err != nil {
|
|
return err
|
|
}
|
|
if _, err := tx.Exec("PRAGMA user_version = 11"); err != nil {
|
|
return fmt.Errorf("setting sqlite user_version 11: %w", err)
|
|
}
|
|
}
|
|
|
|
if version < 12 {
|
|
if err := migrateToV12(tx); err != nil {
|
|
return err
|
|
}
|
|
if _, err := tx.Exec("PRAGMA user_version = 12"); err != nil {
|
|
return fmt.Errorf("setting sqlite user_version 12: %w", err)
|
|
}
|
|
}
|
|
|
|
return tx.Commit()
|
|
}
|
|
|
|
// migrateToV12 adds the nullable watchlist.sort_index column used to mirror a
|
|
// provider's watchlist order. NULL means "use added_at ordering".
|
|
func migrateToV12(tx *sql.Tx) error {
|
|
if columnExists(tx, "watchlist", "sort_index") {
|
|
return nil
|
|
}
|
|
if _, err := tx.Exec("ALTER TABLE watchlist ADD COLUMN sort_index INTEGER"); err != nil {
|
|
return fmt.Errorf("adding watchlist.sort_index: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// migrateToV11 resets legacy completed watch_progress rows to
|
|
// position_seconds = 0. The watch-progress model keeps `completed` as a
|
|
// one-way watched latch with no resume point; the Continue Watching
|
|
// predicate is position_seconds > 0, so legacy rows (position pinned to the
|
|
// duration) would otherwise surface as phantom resume entries. Running this
|
|
// as a versioned migration (rather than a predicate-only data fix) matters:
|
|
// re-running the UPDATE on every boot would wipe the resume point of any
|
|
// rewatch in flight.
|
|
func migrateToV11(tx *sql.Tx) error {
|
|
if _, err := tx.Exec(`
|
|
UPDATE watch_progress
|
|
SET position_seconds = 0
|
|
WHERE completed = 1
|
|
AND position_seconds <> 0`); err != nil {
|
|
return fmt.Errorf("resetting completed watch_progress positions: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func migrateToV10(tx *sql.Tx) error {
|
|
if columnExists(tx, "watch_history", "watch_identity") {
|
|
return nil
|
|
}
|
|
if _, err := tx.Exec("ALTER TABLE watch_history ADD COLUMN watch_identity TEXT NOT NULL DEFAULT '{}'"); err != nil {
|
|
return fmt.Errorf("adding watch_history.watch_identity: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func userVersion(db *sql.DB) (int, error) {
|
|
var version int
|
|
if err := db.QueryRow("PRAGMA user_version").Scan(&version); err != nil {
|
|
return 0, fmt.Errorf("querying sqlite user_version: %w", err)
|
|
}
|
|
return version, nil
|
|
}
|
|
|
|
func setUserVersion(db *sql.DB, version int) error {
|
|
if _, err := db.Exec(fmt.Sprintf("PRAGMA user_version = %d", version)); err != nil {
|
|
return fmt.Errorf("setting sqlite user_version %d: %w", version, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func migrateToV3(tx *sql.Tx) error {
|
|
cols := []struct{ name, ddl string }{
|
|
{"last_file_id", "ALTER TABLE watch_progress ADD COLUMN last_file_id INTEGER"},
|
|
{"last_resolution", "ALTER TABLE watch_progress ADD COLUMN last_resolution TEXT"},
|
|
{"last_hdr", "ALTER TABLE watch_progress ADD COLUMN last_hdr BOOLEAN"},
|
|
{"last_codec_video", "ALTER TABLE watch_progress ADD COLUMN last_codec_video TEXT"},
|
|
}
|
|
for _, c := range cols {
|
|
if columnExists(tx, "watch_progress", c.name) {
|
|
continue
|
|
}
|
|
if _, err := tx.Exec(c.ddl); err != nil {
|
|
return fmt.Errorf("adding watch_progress.%s: %w", c.name, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func migrateToV8(tx *sql.Tx) error {
|
|
if columnExists(tx, "watch_progress", "last_edition_key") {
|
|
return nil
|
|
}
|
|
if _, err := tx.Exec("ALTER TABLE watch_progress ADD COLUMN last_edition_key TEXT"); err != nil {
|
|
return fmt.Errorf("adding watch_progress.last_edition_key: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func columnExists(tx *sql.Tx, table, column string) bool {
|
|
var count int
|
|
err := tx.QueryRow("SELECT COUNT(*) FROM pragma_table_info(?) WHERE name = ?", table, column).Scan(&count)
|
|
return err == nil && count > 0
|
|
}
|
|
|
|
func migrateToV2(tx *sql.Tx) error {
|
|
if _, err := tx.Exec("ALTER TABLE profiles ADD COLUMN library_restrictions_enabled BOOLEAN DEFAULT false"); err != nil {
|
|
return fmt.Errorf("adding profiles.library_restrictions_enabled: %w", err)
|
|
}
|
|
if _, err := tx.Exec("ALTER TABLE profiles ADD COLUMN max_playback_quality TEXT DEFAULT ''"); err != nil {
|
|
return fmt.Errorf("adding profiles.max_playback_quality: %w", err)
|
|
}
|
|
if _, err := tx.Exec(`
|
|
CREATE TABLE IF NOT EXISTS profile_allowed_libraries (
|
|
profile_id TEXT NOT NULL,
|
|
library_id INTEGER NOT NULL,
|
|
PRIMARY KEY (profile_id, library_id)
|
|
)`); err != nil {
|
|
return fmt.Errorf("creating profile_allowed_libraries: %w", err)
|
|
}
|
|
if _, err := tx.Exec(`
|
|
CREATE INDEX IF NOT EXISTS idx_profile_allowed_libraries_lookup
|
|
ON profile_allowed_libraries(profile_id)`); err != nil {
|
|
return fmt.Errorf("creating idx_profile_allowed_libraries_lookup: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func migrateToV4(tx *sql.Tx) error {
|
|
cols := []struct{ name, ddl string }{
|
|
{"creator_profile_id", "ALTER TABLE personal_collections ADD COLUMN creator_profile_id TEXT NOT NULL DEFAULT ''"},
|
|
{"collection_type", "ALTER TABLE personal_collections ADD COLUMN collection_type TEXT NOT NULL DEFAULT 'manual'"},
|
|
{"is_shared", "ALTER TABLE personal_collections ADD COLUMN is_shared BOOLEAN DEFAULT false"},
|
|
{"query_definition", "ALTER TABLE personal_collections ADD COLUMN query_definition TEXT NOT NULL DEFAULT '{}'"},
|
|
{"sort_config", "ALTER TABLE personal_collections ADD COLUMN sort_config TEXT NOT NULL DEFAULT '{}'"},
|
|
}
|
|
for _, c := range cols {
|
|
if columnExists(tx, "personal_collections", c.name) {
|
|
continue
|
|
}
|
|
if _, err := tx.Exec(c.ddl); err != nil {
|
|
return fmt.Errorf("adding personal_collections.%s: %w", c.name, err)
|
|
}
|
|
}
|
|
|
|
if _, err := tx.Exec(`
|
|
UPDATE personal_collections
|
|
SET creator_profile_id = profile_id
|
|
WHERE creator_profile_id = ''
|
|
`); err != nil {
|
|
return fmt.Errorf("backfilling creator_profile_id: %w", err)
|
|
}
|
|
|
|
if _, err := tx.Exec(`
|
|
CREATE TABLE IF NOT EXISTS personal_collection_profiles (
|
|
collection_id TEXT NOT NULL,
|
|
profile_id TEXT NOT NULL,
|
|
PRIMARY KEY (collection_id, profile_id)
|
|
)`); err != nil {
|
|
return fmt.Errorf("creating personal_collection_profiles: %w", err)
|
|
}
|
|
if _, err := tx.Exec(`
|
|
INSERT OR IGNORE INTO personal_collection_profiles (collection_id, profile_id)
|
|
SELECT id, profile_id
|
|
FROM personal_collections
|
|
`); err != nil {
|
|
return fmt.Errorf("backfilling personal_collection_profiles: %w", err)
|
|
}
|
|
if _, err := tx.Exec(`
|
|
CREATE INDEX IF NOT EXISTS idx_personal_collection_profiles_lookup
|
|
ON personal_collection_profiles(profile_id, collection_id)`); err != nil {
|
|
return fmt.Errorf("creating idx_personal_collection_profiles_lookup: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func migrateToV5(tx *sql.Tx) error {
|
|
// Rename collection_mode → collection_type. SQLite ALTER TABLE RENAME COLUMN
|
|
// is supported in SQLite ≥ 3.25.0.
|
|
if columnExists(tx, "personal_collections", "collection_mode") {
|
|
if _, err := tx.Exec("ALTER TABLE personal_collections RENAME COLUMN collection_mode TO collection_type"); err != nil {
|
|
return fmt.Errorf("renaming personal_collections.collection_mode → collection_type: %w", err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func migrateToV6(tx *sql.Tx) error {
|
|
if _, err := tx.Exec(`
|
|
CREATE TABLE IF NOT EXISTS series_playback_preferences (
|
|
profile_id TEXT NOT NULL,
|
|
series_id TEXT NOT NULL,
|
|
resolution TEXT,
|
|
hdr BOOLEAN NOT NULL DEFAULT false,
|
|
codec_video TEXT,
|
|
updated_at TEXT NOT NULL,
|
|
PRIMARY KEY (profile_id, series_id)
|
|
)`); err != nil {
|
|
return fmt.Errorf("creating series_playback_preferences: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func migrateToV7(tx *sql.Tx) error {
|
|
cols := []struct {
|
|
table string
|
|
name string
|
|
ddl string
|
|
}{
|
|
{
|
|
table: "audio_preferences",
|
|
name: "audio_track_signature",
|
|
ddl: "ALTER TABLE audio_preferences ADD COLUMN audio_track_signature TEXT NOT NULL DEFAULT '{}'",
|
|
},
|
|
{
|
|
table: "subtitle_preferences",
|
|
name: "subtitle_track_signature",
|
|
ddl: "ALTER TABLE subtitle_preferences ADD COLUMN subtitle_track_signature TEXT NOT NULL DEFAULT '{}'",
|
|
},
|
|
}
|
|
|
|
for _, c := range cols {
|
|
if columnExists(tx, c.table, c.name) {
|
|
continue
|
|
}
|
|
if _, err := tx.Exec(c.ddl); err != nil {
|
|
return fmt.Errorf("adding %s.%s: %w", c.table, c.name, err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|