* 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>
92 lines
2.8 KiB
Go
92 lines
2.8 KiB
Go
package userdb
|
|
|
|
import (
|
|
"database/sql"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func watchlistIDs(t *testing.T, db *sql.DB, profileID string) []string {
|
|
t.Helper()
|
|
entries, err := ListWatchlist(db, profileID, 100, 0)
|
|
if err != nil {
|
|
t.Fatalf("ListWatchlist: %v", err)
|
|
}
|
|
ids := make([]string, 0, len(entries))
|
|
for _, e := range entries {
|
|
ids = append(ids, e.MediaItemID)
|
|
}
|
|
return ids
|
|
}
|
|
|
|
func TestWatchlistOrderMirrorsProviderSequence(t *testing.T) {
|
|
db, err := sql.Open("sqlite3", ":memory:")
|
|
if err != nil {
|
|
t.Fatalf("open sqlite: %v", err)
|
|
}
|
|
defer db.Close()
|
|
if err := InitSchema(db); err != nil {
|
|
t.Fatalf("InitSchema: %v", err)
|
|
}
|
|
|
|
const profile = "profile-1"
|
|
// Add in one order (added_at ascending a, b, c).
|
|
base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
for i, id := range []string{"a", "b", "c"} {
|
|
if err := AddToWatchlistAt(db, profile, id, base.Add(time.Duration(i)*time.Hour)); err != nil {
|
|
t.Fatalf("AddToWatchlistAt %s: %v", id, err)
|
|
}
|
|
}
|
|
|
|
// Without a synced order, newest-added comes first.
|
|
if got := watchlistIDs(t, db, profile); !equalStrings(got, []string{"c", "b", "a"}) {
|
|
t.Fatalf("default order = %v, want [c b a]", got)
|
|
}
|
|
|
|
// Mirror a provider order of c, a, b.
|
|
if err := ReplaceWatchlistOrder(db, profile, []string{"c", "a", "b"}); err != nil {
|
|
t.Fatalf("ReplaceWatchlistOrder: %v", err)
|
|
}
|
|
if got := watchlistIDs(t, db, profile); !equalStrings(got, []string{"c", "a", "b"}) {
|
|
t.Fatalf("synced order = %v, want [c a b]", got)
|
|
}
|
|
|
|
// A locally-added item (no synced index) sorts after the ordered ones.
|
|
if err := AddToWatchlistAt(db, profile, "d", base.Add(10*time.Hour)); err != nil {
|
|
t.Fatalf("AddToWatchlistAt d: %v", err)
|
|
}
|
|
if got := watchlistIDs(t, db, profile); !equalStrings(got, []string{"c", "a", "b", "d"}) {
|
|
t.Fatalf("order with local add = %v, want [c a b d]", got)
|
|
}
|
|
|
|
// Re-syncing a shorter provider list drops removed items' index and keeps
|
|
// the new order; items no longer present fall back to added_at.
|
|
if err := ReplaceWatchlistOrder(db, profile, []string{"b", "a"}); err != nil {
|
|
t.Fatalf("ReplaceWatchlistOrder re-sync: %v", err)
|
|
}
|
|
// b, a are positioned; c and d are unpositioned → after, newest-first (d then c).
|
|
if got := watchlistIDs(t, db, profile); !equalStrings(got, []string{"b", "a", "d", "c"}) {
|
|
t.Fatalf("re-synced order = %v, want [b a d c]", got)
|
|
}
|
|
|
|
// Clearing the order reverts everything to added_at DESC.
|
|
if err := ReplaceWatchlistOrder(db, profile, nil); err != nil {
|
|
t.Fatalf("ReplaceWatchlistOrder clear: %v", err)
|
|
}
|
|
if got := watchlistIDs(t, db, profile); !equalStrings(got, []string{"d", "c", "b", "a"}) {
|
|
t.Fatalf("cleared order = %v, want [d c b a]", got)
|
|
}
|
|
}
|
|
|
|
func equalStrings(a, b []string) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
for i := range a {
|
|
if a[i] != b[i] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|