* 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>
234 lines
7.8 KiB
Go
234 lines
7.8 KiB
Go
package watchlist
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/models"
|
|
"github.com/Silo-Server/silo-server/internal/userstore"
|
|
"github.com/Silo-Server/silo-server/internal/watchsync"
|
|
)
|
|
|
|
var errNotFound = errors.New("not found")
|
|
|
|
type fakeStore struct {
|
|
removeWatched bool
|
|
watchlist map[string]bool
|
|
progress map[string]userstore.WatchProgress
|
|
removed []string
|
|
}
|
|
|
|
func (f *fakeStore) RemoveWatchedFromWatchlist(context.Context, string) (bool, error) {
|
|
return f.removeWatched, nil
|
|
}
|
|
|
|
func (f *fakeStore) InWatchlist(_ context.Context, _ string, mediaItemID string) (bool, error) {
|
|
return f.watchlist[mediaItemID], nil
|
|
}
|
|
|
|
func (f *fakeStore) RemoveFromWatchlist(_ context.Context, _ string, mediaItemID string) error {
|
|
delete(f.watchlist, mediaItemID)
|
|
f.removed = append(f.removed, mediaItemID)
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeStore) ListProgressByMediaItems(_ context.Context, _ string, ids []string) (map[string]userstore.WatchProgress, error) {
|
|
out := make(map[string]userstore.WatchProgress, len(ids))
|
|
for _, id := range ids {
|
|
if p, ok := f.progress[id]; ok {
|
|
out[id] = p
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
type fakeItems map[string]*models.MediaItem
|
|
|
|
func (f fakeItems) GetByID(_ context.Context, id string) (*models.MediaItem, error) {
|
|
if item, ok := f[id]; ok {
|
|
return item, nil
|
|
}
|
|
return nil, errNotFound
|
|
}
|
|
|
|
type fakeEpisodes struct {
|
|
byID map[string]*models.Episode
|
|
bySeries map[string][]*models.Episode
|
|
}
|
|
|
|
func (f fakeEpisodes) GetByID(_ context.Context, id string) (*models.Episode, error) {
|
|
if ep, ok := f.byID[id]; ok {
|
|
return ep, nil
|
|
}
|
|
return nil, errNotFound
|
|
}
|
|
|
|
func (f fakeEpisodes) ListBySeries(_ context.Context, seriesID string) ([]*models.Episode, error) {
|
|
return f.bySeries[seriesID], nil
|
|
}
|
|
|
|
type fakeDispatcher struct {
|
|
events []watchsync.LocalListEvent
|
|
}
|
|
|
|
func (f *fakeDispatcher) HandleLocalListEvent(_ context.Context, event watchsync.LocalListEvent) error {
|
|
f.events = append(f.events, event)
|
|
return nil
|
|
}
|
|
|
|
func newMaintainer(store *fakeStore, items fakeItems, episodes fakeEpisodes, dispatcher *fakeDispatcher) *Maintainer {
|
|
return &Maintainer{
|
|
storeFor: func(context.Context, int) (maintainerStore, error) { return store, nil },
|
|
items: items,
|
|
episodes: episodes,
|
|
dispatcher: dispatcher,
|
|
}
|
|
}
|
|
|
|
func completed() userstore.WatchProgress { return userstore.WatchProgress{Completed: true} }
|
|
|
|
func TestMaintainerRemovesWatchedMovie(t *testing.T) {
|
|
store := &fakeStore{removeWatched: true, watchlist: map[string]bool{"movie-1": true}}
|
|
items := fakeItems{"movie-1": {ContentID: "movie-1", Type: "movie", Title: "M", ImdbID: "tt1"}}
|
|
dispatcher := &fakeDispatcher{}
|
|
m := newMaintainer(store, items, fakeEpisodes{}, dispatcher)
|
|
|
|
if err := m.process(context.Background(), 7, "profile-1", []string{"movie-1"}); err != nil {
|
|
t.Fatalf("process: %v", err)
|
|
}
|
|
if store.watchlist["movie-1"] {
|
|
t.Fatal("watched movie should be removed from the watchlist")
|
|
}
|
|
if len(dispatcher.events) != 1 || dispatcher.events[0].List != watchsync.ListKindWatchlist ||
|
|
dispatcher.events[0].Change != watchsync.ListChangeRemoved {
|
|
t.Fatalf("expected one watchlist-removed event, got %+v", dispatcher.events)
|
|
}
|
|
}
|
|
|
|
func TestMaintainerSkipsMovieNotOnWatchlist(t *testing.T) {
|
|
store := &fakeStore{removeWatched: true, watchlist: map[string]bool{}}
|
|
items := fakeItems{"movie-1": {ContentID: "movie-1", Type: "movie"}}
|
|
dispatcher := &fakeDispatcher{}
|
|
m := newMaintainer(store, items, fakeEpisodes{}, dispatcher)
|
|
|
|
if err := m.process(context.Background(), 7, "profile-1", []string{"movie-1"}); err != nil {
|
|
t.Fatalf("process: %v", err)
|
|
}
|
|
if len(store.removed) != 0 || len(dispatcher.events) != 0 {
|
|
t.Fatalf("nothing should happen for a movie not on the watchlist: removed=%v events=%v", store.removed, dispatcher.events)
|
|
}
|
|
}
|
|
|
|
func TestMaintainerRemovesSeriesOnlyWhenFullyWatched(t *testing.T) {
|
|
episodes := fakeEpisodes{
|
|
byID: map[string]*models.Episode{
|
|
"ep-1": {ContentID: "ep-1", SeriesID: "series-1"},
|
|
"ep-2": {ContentID: "ep-2", SeriesID: "series-1"},
|
|
},
|
|
bySeries: map[string][]*models.Episode{
|
|
"series-1": {{ContentID: "ep-1"}, {ContentID: "ep-2"}},
|
|
},
|
|
}
|
|
items := fakeItems{"series-1": {ContentID: "series-1", Type: "series", TvdbID: "99"}}
|
|
|
|
// Only ep-1 watched: series stays on the watchlist.
|
|
partial := &fakeStore{
|
|
removeWatched: true,
|
|
watchlist: map[string]bool{"series-1": true},
|
|
progress: map[string]userstore.WatchProgress{"ep-1": completed()},
|
|
}
|
|
m := newMaintainer(partial, items, episodes, &fakeDispatcher{})
|
|
if err := m.process(context.Background(), 7, "profile-1", []string{"ep-1"}); err != nil {
|
|
t.Fatalf("process: %v", err)
|
|
}
|
|
if !partial.watchlist["series-1"] {
|
|
t.Fatal("a partially-watched series must stay on the watchlist")
|
|
}
|
|
|
|
// Both episodes watched: series is removed.
|
|
full := &fakeStore{
|
|
removeWatched: true,
|
|
watchlist: map[string]bool{"series-1": true},
|
|
progress: map[string]userstore.WatchProgress{"ep-1": completed(), "ep-2": completed()},
|
|
}
|
|
dispatcher := &fakeDispatcher{}
|
|
m = newMaintainer(full, items, episodes, dispatcher)
|
|
if err := m.process(context.Background(), 7, "profile-1", []string{"ep-2"}); err != nil {
|
|
t.Fatalf("process: %v", err)
|
|
}
|
|
if full.watchlist["series-1"] {
|
|
t.Fatal("a fully-watched series must leave the watchlist")
|
|
}
|
|
if len(dispatcher.events) != 1 {
|
|
t.Fatalf("expected one removal event for the series, got %+v", dispatcher.events)
|
|
}
|
|
}
|
|
|
|
func TestMaintainerRespectsPreferenceOff(t *testing.T) {
|
|
store := &fakeStore{removeWatched: false, watchlist: map[string]bool{"movie-1": true}}
|
|
items := fakeItems{"movie-1": {ContentID: "movie-1", Type: "movie"}}
|
|
dispatcher := &fakeDispatcher{}
|
|
m := newMaintainer(store, items, fakeEpisodes{}, dispatcher)
|
|
|
|
if err := m.process(context.Background(), 7, "profile-1", []string{"movie-1"}); err != nil {
|
|
t.Fatalf("process: %v", err)
|
|
}
|
|
if !store.watchlist["movie-1"] || len(store.removed) != 0 {
|
|
t.Fatal("preference off must leave the watchlist untouched")
|
|
}
|
|
}
|
|
|
|
func TestMaintainerDedupesEpisodesOfSameSeries(t *testing.T) {
|
|
episodes := fakeEpisodes{
|
|
byID: map[string]*models.Episode{
|
|
"ep-1": {ContentID: "ep-1", SeriesID: "series-1"},
|
|
"ep-2": {ContentID: "ep-2", SeriesID: "series-1"},
|
|
},
|
|
bySeries: map[string][]*models.Episode{
|
|
"series-1": {{ContentID: "ep-1"}, {ContentID: "ep-2"}},
|
|
},
|
|
}
|
|
items := fakeItems{"series-1": {ContentID: "series-1", Type: "series"}}
|
|
store := &fakeStore{
|
|
removeWatched: true,
|
|
watchlist: map[string]bool{"series-1": true},
|
|
progress: map[string]userstore.WatchProgress{"ep-1": completed(), "ep-2": completed()},
|
|
}
|
|
m := newMaintainer(store, items, episodes, &fakeDispatcher{})
|
|
|
|
// Both episodes complete in one batch; the series should be removed once.
|
|
if err := m.process(context.Background(), 7, "profile-1", []string{"ep-1", "ep-2"}); err != nil {
|
|
t.Fatalf("process: %v", err)
|
|
}
|
|
if got := len(store.removed); got != 1 {
|
|
t.Fatalf("series should be removed exactly once, got %d removals (%v)", got, store.removed)
|
|
}
|
|
}
|
|
|
|
type erroringItems struct{ err error }
|
|
|
|
func (e erroringItems) GetByID(context.Context, string) (*models.MediaItem, error) {
|
|
return nil, e.err
|
|
}
|
|
|
|
type emptyEpisodes struct{}
|
|
|
|
func (emptyEpisodes) GetByID(context.Context, string) (*models.Episode, error) { return nil, nil }
|
|
func (emptyEpisodes) ListBySeries(context.Context, string) ([]*models.Episode, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func TestMaintainerPropagatesItemLookupError(t *testing.T) {
|
|
boom := errors.New("db down")
|
|
store := &fakeStore{removeWatched: true, watchlist: map[string]bool{}}
|
|
m := &Maintainer{
|
|
storeFor: func(context.Context, int) (maintainerStore, error) { return store, nil },
|
|
items: erroringItems{err: boom},
|
|
episodes: emptyEpisodes{},
|
|
}
|
|
if err := m.process(context.Background(), 7, "profile-1", []string{"x"}); err == nil {
|
|
t.Fatal("a transient catalog lookup error must propagate, not be swallowed")
|
|
}
|
|
}
|