* 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>
185 lines
6.9 KiB
Go
185 lines
6.9 KiB
Go
package metadata
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestBetterCanonicalCandidatePrefersActiveFilesThenLibrariesThenAgeThenID(t *testing.T) {
|
|
now := time.Now()
|
|
base := canonicalCandidate{ContentID: "b", CreatedAt: now, ActiveFiles: 0, LibraryCount: 2}
|
|
|
|
if !betterCanonicalCandidate(canonicalCandidate{ContentID: "a", CreatedAt: now, ActiveFiles: 1, LibraryCount: 0}, base) {
|
|
t.Fatal("expected active files to win over library count")
|
|
}
|
|
if !betterCanonicalCandidate(canonicalCandidate{ContentID: "a", CreatedAt: now, ActiveFiles: 0, LibraryCount: 3}, base) {
|
|
t.Fatal("expected larger library count to win")
|
|
}
|
|
if !betterCanonicalCandidate(canonicalCandidate{ContentID: "a", CreatedAt: now.Add(-time.Hour), ActiveFiles: 0, LibraryCount: 2}, base) {
|
|
t.Fatal("expected older created_at to win")
|
|
}
|
|
if !betterCanonicalCandidate(canonicalCandidate{ContentID: "a", CreatedAt: now, ActiveFiles: 0, LibraryCount: 2}, base) {
|
|
t.Fatal("expected lexicographically smaller content_id to win")
|
|
}
|
|
}
|
|
|
|
func TestProviderEntriesFromDriftUsesDurableProvidersOnly(t *testing.T) {
|
|
entries := providerEntriesFromDrift(providerIDDriftRow{
|
|
ContentID: "item-1",
|
|
ItemType: "movie",
|
|
TMDBID: " 603 ",
|
|
TVDBID: "",
|
|
IMDbID: "tt0083658",
|
|
})
|
|
if len(entries) != 2 {
|
|
t.Fatalf("len(entries) = %d, want 2", len(entries))
|
|
}
|
|
if entries[0] != (providerIDEntry{Provider: "tmdb", ProviderID: "603"}) {
|
|
t.Fatalf("entries[0] = %#v", entries[0])
|
|
}
|
|
if entries[1] != (providerIDEntry{Provider: "imdb", ProviderID: "tt0083658"}) {
|
|
t.Fatalf("entries[1] = %#v", entries[1])
|
|
}
|
|
}
|
|
|
|
func TestMaxPlaceholder(t *testing.T) {
|
|
// maxPlaceholder must read the FULL placeholder number, not a substring:
|
|
// "$20" is placeholder 20, never "$2". This is the property the old
|
|
// strings.Contains(sql, "$2") heuristic got wrong.
|
|
cases := []struct {
|
|
sql string
|
|
want int
|
|
}{
|
|
{`DELETE FROM media_item_provider_ids WHERE content_id = $1`, 1},
|
|
{`UPDATE media_files SET content_id = $2 WHERE content_id = $1`, 2},
|
|
{`INSERT INTO t SELECT $2 FROM s WHERE content_id = $1`, 2},
|
|
{`SELECT $1, $2, $1`, 2}, // repeated placeholders count once
|
|
{`SELECT $20 FROM t`, 20},
|
|
{`SELECT $12 FROM t`, 12},
|
|
{`DELETE FROM t WHERE x = 'literal'`, 0},
|
|
}
|
|
for _, tc := range cases {
|
|
if got := maxPlaceholder(tc.sql); got != tc.want {
|
|
t.Errorf("maxPlaceholder(%q) = %d, want %d", tc.sql, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMergeStepArgsMatchesPlaceholderArity(t *testing.T) {
|
|
const sourceID = "src"
|
|
const canonicalID = "canon"
|
|
|
|
// A step that only references $1 must receive exactly one argument.
|
|
// Passing canonicalID as an unused $2 makes pgx reject the Exec with
|
|
// "mismatched param and argument count" under the default
|
|
// QueryExecModeCacheStatement, aborting the whole merge transaction.
|
|
onlySource := mergeStepArgs(`DELETE FROM media_item_provider_ids WHERE content_id = $1`, sourceID, canonicalID)
|
|
if len(onlySource) != 1 || onlySource[0] != sourceID {
|
|
t.Fatalf("mergeStepArgs($1-only) = %#v, want [%q]", onlySource, sourceID)
|
|
}
|
|
|
|
// A step that references $2 must receive both arguments in order.
|
|
both := mergeStepArgs(`UPDATE media_files SET content_id = $2 WHERE content_id = $1`, sourceID, canonicalID)
|
|
if len(both) != 2 || both[0] != sourceID || both[1] != canonicalID {
|
|
t.Fatalf("mergeStepArgs($1+$2) = %#v, want [%q %q]", both, sourceID, canonicalID)
|
|
}
|
|
|
|
// $2 may appear before $1 (e.g. INSERT ... SELECT $2 ...). Args are still
|
|
// positional [source, canonical] regardless of textual order.
|
|
reversed := mergeStepArgs(`INSERT INTO t SELECT $2 FROM s WHERE content_id = $1`, sourceID, canonicalID)
|
|
if len(reversed) != 2 || reversed[0] != sourceID || reversed[1] != canonicalID {
|
|
t.Fatalf("mergeStepArgs(reversed) = %#v, want [%q %q]", reversed, sourceID, canonicalID)
|
|
}
|
|
}
|
|
|
|
func TestMergeStepPlaceholdersAreBounded(t *testing.T) {
|
|
// Every merge step must bind $1 (the source) and may bind $2 (the
|
|
// canonical). A step binding $3+ (or none) cannot be satisfied by the two
|
|
// IDs the loop passes and would abort the merge at runtime; this guard —
|
|
// checking the actual placeholder bound rather than re-deriving the arg
|
|
// count from the same predicate the helper uses — catches such a step.
|
|
for _, step := range mediaItemMergeSteps {
|
|
n := maxPlaceholder(step.sql)
|
|
if n < 1 || n > 2 {
|
|
t.Errorf("merge step %q binds %d placeholders; merge supports only $1 and $2", step.name, n)
|
|
}
|
|
if !strings.Contains(step.sql, "$1") {
|
|
t.Errorf("merge step %q does not bind $1 (source)", step.name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMergeStepsUseCurrentWatchProviderListItemsTable(t *testing.T) {
|
|
for _, step := range mediaItemMergeSteps {
|
|
if strings.Contains(step.sql, "watch_provider_favorite_items") {
|
|
t.Fatalf("merge step %q still references renamed watch provider table", step.name)
|
|
}
|
|
}
|
|
|
|
for _, want := range []string{
|
|
"merge watch provider list items by provider key",
|
|
"merge watch provider list items by connection",
|
|
"delete duplicate watch provider list items",
|
|
} {
|
|
var stepSQL string
|
|
for _, step := range mediaItemMergeSteps {
|
|
if step.name == want {
|
|
stepSQL = normalizeMergeStepSQL(step.sql)
|
|
break
|
|
}
|
|
}
|
|
if stepSQL == "" {
|
|
t.Fatalf("missing merge step %q", want)
|
|
}
|
|
if !strings.Contains(stepSQL, "watch_provider_list_items") {
|
|
t.Fatalf("merge step %q does not reference watch_provider_list_items", want)
|
|
}
|
|
if !strings.Contains(stepSQL, "src.list_kind = dest.list_kind") {
|
|
t.Fatalf("merge step %q must preserve per-list shadow state", want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMergeStepsPreserveEbookReaderProgress(t *testing.T) {
|
|
var hasMerge bool
|
|
var hasDelete bool
|
|
for _, step := range mediaItemMergeSteps {
|
|
if strings.Contains(step.sql, "ebook_reader_progress") && strings.Contains(step.sql, "INSERT INTO") {
|
|
hasMerge = true
|
|
}
|
|
if strings.Contains(step.sql, "ebook_reader_progress") && strings.Contains(step.sql, "DELETE FROM") {
|
|
hasDelete = true
|
|
}
|
|
}
|
|
if !hasMerge {
|
|
t.Fatal("media item merge steps should merge ebook_reader_progress rows")
|
|
}
|
|
if !hasDelete {
|
|
t.Fatal("media item merge steps should delete source ebook_reader_progress rows")
|
|
}
|
|
}
|
|
|
|
func normalizeMergeStepSQL(s string) string {
|
|
return strings.Join(strings.Fields(s), " ")
|
|
}
|
|
|
|
func TestBothCandidatesHaveContentDetectsRealUserData(t *testing.T) {
|
|
withFiles := canonicalCandidate{ContentID: "a", ActiveFiles: 2}
|
|
withLibraries := canonicalCandidate{ContentID: "b", LibraryCount: 1}
|
|
skeleton := canonicalCandidate{ContentID: "c"}
|
|
|
|
if !bothCandidatesHaveContent(withFiles, withLibraries) {
|
|
t.Fatal("expected both-content when one has files and the other has library memberships")
|
|
}
|
|
if !bothCandidatesHaveContent(withFiles, withFiles) {
|
|
t.Fatal("expected both-content when both sides have files")
|
|
}
|
|
if bothCandidatesHaveContent(withFiles, skeleton) {
|
|
t.Fatal("did not expect both-content when one side is a skeleton")
|
|
}
|
|
if bothCandidatesHaveContent(skeleton, skeleton) {
|
|
t.Fatal("did not expect both-content when both sides are skeletons")
|
|
}
|
|
}
|