* 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>
172 lines
5.4 KiB
Go
172 lines
5.4 KiB
Go
package jellycompat
|
|
|
|
import (
|
|
"context"
|
|
"io/fs"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/auth"
|
|
"github.com/Silo-Server/silo-server/internal/catalog"
|
|
"github.com/Silo-Server/silo-server/internal/clientip"
|
|
"github.com/Silo-Server/silo-server/internal/config"
|
|
"github.com/Silo-Server/silo-server/internal/nodepool"
|
|
"github.com/Silo-Server/silo-server/internal/recommendations"
|
|
"github.com/Silo-Server/silo-server/internal/scantrigger"
|
|
"github.com/Silo-Server/silo-server/internal/secret"
|
|
"github.com/Silo-Server/silo-server/internal/subtitles"
|
|
"github.com/Silo-Server/silo-server/internal/userstore"
|
|
"github.com/Silo-Server/silo-server/internal/watchstate"
|
|
)
|
|
|
|
// Dependencies holds the pluggable pieces used by the compat server.
|
|
type Dependencies struct {
|
|
Config *config.Config
|
|
// LiveConfig returns the current hot-reloaded config. May be nil (tests,
|
|
// worker modes); read through CurrentConfig(), which falls back to Config.
|
|
LiveConfig func() *config.Config
|
|
DB *pgxpool.Pool
|
|
SecretCipher *secret.Cipher // at-rest credential cipher (required when DB is set)
|
|
ClientIPResolver *clientip.Resolver
|
|
Now func() time.Time
|
|
TokenGenerator func() string
|
|
SessionStore *SessionStore
|
|
IDCodec *ResourceIDCodec
|
|
ImageCache *ImageCache
|
|
DeviceProfiles *DeviceProfileStore
|
|
PlaybackStore *PlaybackSessionStore
|
|
LoginResolver loginResolver
|
|
Authenticator *Authenticator
|
|
WebFS fs.FS
|
|
// FrontendFS is the embedded Silo frontend asset filesystem (web/dist),
|
|
// used to serve app-relative artwork such as bundled collection-template
|
|
// posters that have no remote origin. Optional.
|
|
FrontendFS fs.FS
|
|
HTTPClient *http.Client
|
|
|
|
// Direct service dependencies (replaces Client)
|
|
ContentService ContentService
|
|
UserDataService UserDataService
|
|
AuthService *auth.Service
|
|
|
|
// WatchCompletionObserver is notified when a Jellyfin-compat mark-played
|
|
// completes a watch, so fully-watched items leave the watchlist. Optional.
|
|
WatchCompletionObserver watchstate.CompletionObserver
|
|
|
|
// Autoscan / admin compatibility support.
|
|
APIKeyValidator apiKeyValidator
|
|
APIKeyUserLoader apiKeyUserLoader
|
|
ScanQueue scantrigger.Queuer
|
|
|
|
// Catalog repos (for ContentService construction)
|
|
BrowseRepo *catalog.BrowseRepository
|
|
ItemRepo *catalog.ItemRepository
|
|
SeasonRepo *catalog.SeasonRepository
|
|
EpisodeRepo *catalog.EpisodeRepository
|
|
ProviderIDRepo *catalog.ProviderIDRepository
|
|
DetailSvc *catalog.DetailService
|
|
FolderRepo *catalog.FolderRepository
|
|
|
|
// Person repository
|
|
PersonRepo *catalog.PersonRepository
|
|
|
|
// Library poster presigning
|
|
PosterPresigner LibraryPosterPresigner
|
|
PresignTTL time.Duration
|
|
|
|
// Playback
|
|
SessionMgr SessionManagerInterface
|
|
FileResolver FilePathResolver
|
|
UserStoreProvider userstore.UserStoreProvider
|
|
AccessFilterFn AccessFilterResolver
|
|
NodePlanner nodepool.SessionPlanner
|
|
JWTSecret string
|
|
Recommender recommendations.Recommender
|
|
RecWorker *recommendations.Worker
|
|
|
|
// Settings (optional; reads server_settings for watched threshold, etc.)
|
|
SettingsRepo SettingsReader
|
|
|
|
// Subtitle support (optional)
|
|
SubtitleRepo subtitles.Repository // optional; downloaded subtitle support
|
|
S3Client subtitles.S3Client // optional
|
|
S3Bucket string // optional
|
|
}
|
|
|
|
// CurrentConfig returns the live config when hot reload is wired, falling
|
|
// back to the startup snapshot otherwise.
|
|
func (d *Dependencies) CurrentConfig() *config.Config {
|
|
if d.LiveConfig != nil {
|
|
if cfg := d.LiveConfig(); cfg != nil {
|
|
return cfg
|
|
}
|
|
}
|
|
return d.Config
|
|
}
|
|
|
|
// Server wraps the compat HTTP handler.
|
|
type Server struct {
|
|
cfg *config.Config
|
|
handler http.Handler
|
|
deps Dependencies
|
|
}
|
|
|
|
// NewServer creates a new Jellyfin-compatibility server.
|
|
func NewServer(cfg *config.Config) *Server {
|
|
return NewServerWithDependencies(NewDependencies(cfg))
|
|
}
|
|
|
|
// NewServerWithDependencies creates a new Jellyfin-compatibility server using explicit dependencies.
|
|
func NewServerWithDependencies(deps Dependencies) *Server {
|
|
deps = withDefaults(deps)
|
|
return &Server{
|
|
cfg: deps.Config,
|
|
handler: NewRouter(deps),
|
|
deps: deps,
|
|
}
|
|
}
|
|
|
|
// Handler returns the compat HTTP handler.
|
|
func (s *Server) Handler() http.Handler {
|
|
return s.handler
|
|
}
|
|
|
|
// HTTPServer builds an http.Server using the compat listen address.
|
|
func (s *Server) HTTPServer() *http.Server {
|
|
return &http.Server{
|
|
Addr: s.cfg.JellyfinCompat.Listen,
|
|
Handler: s.handler,
|
|
}
|
|
}
|
|
|
|
// Dependencies returns the resolved dependency set.
|
|
func (s *Server) Dependencies() Dependencies {
|
|
return s.deps
|
|
}
|
|
|
|
// SessionStore returns the compat session store for external revocation hooks.
|
|
func (s *Server) SessionStore() *SessionStore {
|
|
return s.deps.SessionStore
|
|
}
|
|
|
|
// StartBackgroundTasks starts background goroutines tied to the server lifecycle.
|
|
// Call this once after constructing the server; goroutines stop when ctx is cancelled.
|
|
func (s *Server) StartBackgroundTasks(ctx context.Context) {
|
|
if s.deps.DB != nil {
|
|
repo := NewSessionRepository(s.deps.DB, s.deps.SecretCipher)
|
|
StartSessionCleanup(ctx, repo, 1*time.Hour)
|
|
}
|
|
}
|
|
|
|
// NewDependencies fills in sensible defaults for optional compat dependencies.
|
|
func NewDependencies(cfg *config.Config) Dependencies {
|
|
return Dependencies{
|
|
Config: cfg,
|
|
Now: time.Now,
|
|
TokenGenerator: uuid.NewString,
|
|
}
|
|
}
|