// Package api provides the HTTP router and middleware setup for Silo. package api import ( "context" "errors" "io/fs" "log/slog" "net/http" "strconv" "strings" "time" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "github.com/redis/go-redis/v9" "github.com/Silo-Server/silo-server/internal/access" "github.com/Silo-Server/silo-server/internal/activitylog" "github.com/Silo-Server/silo-server/internal/adminjob" "github.com/Silo-Server/silo-server/internal/ai/jobrunner" "github.com/Silo-Server/silo-server/internal/ai/llm" "github.com/Silo-Server/silo-server/internal/api/handlers" apimw "github.com/Silo-Server/silo-server/internal/api/middleware" "github.com/Silo-Server/silo-server/internal/auth" "github.com/Silo-Server/silo-server/internal/autoscan" "github.com/Silo-Server/silo-server/internal/branding" "github.com/Silo-Server/silo-server/internal/cache" "github.com/Silo-Server/silo-server/internal/catalog" "github.com/Silo-Server/silo-server/internal/catalogseed" "github.com/Silo-Server/silo-server/internal/clientip" "github.com/Silo-Server/silo-server/internal/config" "github.com/Silo-Server/silo-server/internal/download" evt "github.com/Silo-Server/silo-server/internal/events" "github.com/Silo-Server/silo-server/internal/historyimport" "github.com/Silo-Server/silo-server/internal/intromarkers" "github.com/Silo-Server/silo-server/internal/libraryingest" "github.com/Silo-Server/silo-server/internal/logstream" "github.com/Silo-Server/silo-server/internal/mail" "github.com/Silo-Server/silo-server/internal/markers" "github.com/Silo-Server/silo-server/internal/mdblist" "github.com/Silo-Server/silo-server/internal/metadata" "github.com/Silo-Server/silo-server/internal/metadata/tmdb" metatrakt "github.com/Silo-Server/silo-server/internal/metadata/trakt" metadatatranslation "github.com/Silo-Server/silo-server/internal/metadata/translation" "github.com/Silo-Server/silo-server/internal/nodepool" "github.com/Silo-Server/silo-server/internal/notifications" "github.com/Silo-Server/silo-server/internal/opslog" "github.com/Silo-Server/silo-server/internal/playback" "github.com/Silo-Server/silo-server/internal/plugins" "github.com/Silo-Server/silo-server/internal/ratelimit" "github.com/Silo-Server/silo-server/internal/recommendations" mediarequests "github.com/Silo-Server/silo-server/internal/requests" "github.com/Silo-Server/silo-server/internal/s3client" "github.com/Silo-Server/silo-server/internal/scanner" "github.com/Silo-Server/silo-server/internal/scanqueue" "github.com/Silo-Server/silo-server/internal/secret" "github.com/Silo-Server/silo-server/internal/sections" "github.com/Silo-Server/silo-server/internal/subtitles" subtitleai "github.com/Silo-Server/silo-server/internal/subtitles/ai" "github.com/Silo-Server/silo-server/internal/subtitles/opensubtitles" "github.com/Silo-Server/silo-server/internal/subtitles/subdl" "github.com/Silo-Server/silo-server/internal/subtitles/subsource" "github.com/Silo-Server/silo-server/internal/taskmanager" "github.com/Silo-Server/silo-server/internal/taskmanager/repository" "github.com/Silo-Server/silo-server/internal/usercollections" "github.com/Silo-Server/silo-server/internal/userstore" "github.com/Silo-Server/silo-server/internal/watchstate" watchtrakt "github.com/Silo-Server/silo-server/internal/watchsync/providers/trakt" "github.com/Silo-Server/silo-server/internal/watchtogether" "github.com/Silo-Server/silo-server/internal/webhooksync" ) // Dependencies holds all shared dependencies that handlers need. 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 // OnConfigChange registers a callback fired after a live config reload // actually changes the config. May be nil when hot reload is not wired. OnConfigChange func(fn func(old, updated *config.Config)) BootstrapSensitiveConfigured map[string]bool BootstrapSensitiveValues map[string]string AppContext context.Context DB *pgxpool.Pool SecretCipher *secret.Cipher // at-rest credential cipher (required when DB is set) FrontendFS fs.FS S3Public *s3client.Client // public assets bucket client (may be nil) S3Private *s3client.Client // private internal bucket client (may be nil) S3UserDB *s3client.Client // user-db bucket client (may be nil) BrandingService *branding.Service // white-label branding (nil when DB unavailable) FolderRepo *catalog.FolderRepository // media folder repository (may be nil) FileRepo *scanner.FileRepository // media file repository (may be nil) Scanner *scanner.Scanner // scanner instance (may be nil) LibraryIngester *libraryingest.Executor // shared library ingest executor (may be nil) ProbeEnsurer handlers.PlaybackProbeEnsurer // on-demand probe repair for playback/detail (may be nil) UserStoreProvider userstore.UserStoreProvider // user store provider (may be nil) SessionMgr *playback.SessionManager // playback session manager (may be nil) SkippedRootRepo *metadata.SkippedRootRepository // skipped root repository (may be nil) StaleIDRepo *metadata.StaleMediaIDRepository // stale media ID repository (may be nil) MovieMatchQueueRepo *metadata.MovieMatchQueueRepository SeriesRootMatchQueueRepo *metadata.SeriesRootMatchQueueRepository Refresher handlers.AdminMetadataRefresher // metadata refresher (may be nil) NodeRepo *nodepool.Repository // stream node repository (may be nil) ProxyPool *nodepool.ProxyPool // proxy node pool (may be nil) TranscodePool *nodepool.TranscodePool // transcode node pool (may be nil) NodePlanner *nodepool.Planner // group/cap-aware node selection (may be nil) SessionSyncer handlers.PlaybackSessionSyncer // optional; immediate playback session sync trigger EventBus cache.EventBus AdminStatsProvider handlers.AdminStatsSource Recommender recommendations.Recommender // nil when disabled RecWorker *recommendations.Worker // nil when disabled RatingsRepo *catalog.RatingsRepo PersonRepo *catalog.PersonRepository PersonRefreshQueue handlers.PersonRefreshQueue PersonRefresher handlers.PersonRefresher RateLimitMW *ratelimit.Middleware ClientIPResolver *clientip.Resolver NodeID string LogStreamHub *logstream.Hub RealtimeHub *notifications.Hub Notifications *notifications.System // user-facing release notifications (may be nil) EventsHub *evt.Hub ScanRegistry *evt.ScanRegistry LibraryScanQueue *scanqueue.Service ActivityLogWriter activitylog.Writer ActivityLogRepo *activitylog.Repo OpsLogRepo *opslog.Repo FFmpegLogSink playback.FFmpegLogSink RedisClient *redis.Client // for session listing (may be nil) TaskManager *taskmanager.TaskManager // task manager (may be nil) AdminJobCancelRegistry *adminjob.CancelRegistry IntroRepository *intromarkers.Repository IntroAnalyzer *intromarkers.Analyzer MarkerRegistry *markers.Registry MarkerResolver markers.ExternalIDResolver MarkerProviderConfig *markers.ProviderConfigStore MarkerContributionStore *markers.ContributionStore MarkerContributionService *markers.ContributionService WatchProviderService handlers.WatchProviderService PluginService *plugins.Service PluginHTTPProxy *plugins.HTTPProxy PluginUserConfig *plugins.UserConfigStore AuthProviders []auth.RegisteredProvider // PublicURL is the externally-reachable origin (scheme + host) for this // silo instance. Used to build redirect_uri values handed to OAuth // IdPs. Empty disables the /oauth/{install_id}/{init,callback} routes. PublicURL string ImageResolver catalog.ImageResolver // plugin-based image URL resolver (may be nil) PluginImageResolver *metadata.PluginImageResolver // concrete resolver for runtime source registration (may be nil) MetadataService handlers.MatchMetadataService // metadata search+process (may be nil) CollectionService *catalog.LibraryCollectionService // collection service (may be nil) ChapterThumbnailQueuer catalog.ChapterThumbnailQueuer PlaybackRealtimeHub *playback.RealtimeHub OnUserSessionsRevoked func(ctx context.Context, userID int) OnServerSettingUpdated func(ctx context.Context, key, value string) RequestServerRestart func(ctx context.Context) error ServerRestartStatus *handlers.ServerRestartStatusTracker // UserCollectionSync handles per-profile imported collections (TMDB / // Trakt / MDBList) — the user-facing analogue of CollectionService. UserCollectionSync *usercollections.Service UserCollectionScheduler *usercollections.Scheduler // TrendingRefresher refreshes the persisted trending_discover snapshots. // Built in main.go with TMDB wired; its Trakt fetcher is propagated here in // router.go once the Trakt adapter exists (mirrors UserCollectionSync). TrendingRefresher *sections.TrendingRefresher // MDBListClient is used by user-facing list discovery endpoints // (search/top). May be nil; the handlers report "not configured" in // that case rather than failing. MDBListClient *mdblist.Client // ABSHandler is the Audiobookshelf-compatible HTTP handler. When non-nil // it is mounted at the root router level (not under /api/v1/) so that ABS // clients hitting /login, /api/*, /abs/api/*, and /abs/socket.io/* all // resolve correctly. May be nil; no ABS routes are registered in that case. ABSHandler absHandler } // absHandler is the narrow interface the router needs from the ABS handler. // Using an interface avoids a direct import of the abs sub-package from router.go. type absHandler interface { Mount(r chi.Router) } // 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 } // NewRouter creates a chi.Router with all middleware and routes mounted // under /api/v1/. ABS-compat routes (/abs/*, /login, /socket.io/*) are // mounted at the root level when deps.ABSHandler is non-nil. func NewRouter(deps Dependencies) chi.Router { r := chi.NewRouter() // Standard middleware. r.Use(middleware.RequestID) // Client IP resolution must run before request logging. if deps.ClientIPResolver != nil { r.Use(clientip.Middleware(deps.ClientIPResolver)) } r.Use(apimw.RequestLogger(deps.NodeID)) r.Use(middleware.Recoverer) r.Use(apimw.Metrics) // Compress text-like responses (JSON, SVG, …); media content types are // not in the middleware's allowlist and stream through untouched. r.Use(middleware.Compress(5)) // Activity logging (before auth — captures all requests including failed auth). if deps.ActivityLogWriter != nil { r.Use(activitylog.NewMiddleware(deps.ActivityLogWriter, deps.NodeID)) } // Build the readiness handler with optional S3 check. var s3Checker handlers.S3HealthChecker if deps.S3Public != nil { s3Checker = deps.S3Public } else if deps.S3Private != nil { s3Checker = deps.S3Private } // PG pinger: use the pool if available. var pgPinger handlers.PGPinger if deps.DB != nil { pgPinger = deps.DB } readyHandler := handlers.NewReadyHandler(pgPinger, s3Checker) // Resolves whether a declared profile belongs to the user and is the // household primary profile. Nil (no user store) disables the // acting-admin profile policy, degrading admin routes to the plain // role check. var checkPrimaryProfile apimw.PrimaryProfileChecker if deps.UserStoreProvider != nil { userStores := deps.UserStoreProvider checkPrimaryProfile = func(ctx context.Context, userID int, profileID string) (bool, bool, error) { store, err := userStores.ForUser(ctx, userID) if err != nil { return false, false, err } profile, err := store.GetProfile(ctx, profileID) if err != nil { return false, false, err } if profile == nil { return false, false, nil } return profile.IsPrimary, true, nil } } // Admin authorization for routes: admin role, exercised through the // account's primary household profile (see apimw.RequireActingAdmin). requireActingAdmin := apimw.RequireActingAdmin(checkPrimaryProfile) // Health handler advertises the server's identity so multi-server // clients can display a friendly name. Falls back to empty strings // if config is absent (tests, minimal fixtures); JSON omits empties. var healthServerName, healthServerID string if deps.Config != nil { healthServerName = deps.Config.JellyfinCompat.ServerName healthServerID = deps.Config.JellyfinCompat.ServerID } healthHandler := handlers.NewHealthHandler(healthServerName, healthServerID) // Build server settings repo if DB is available (needed by auth and admin). // Wrap it in the encrypting decorator so sensitive keys rest as ciphertext // and every consumer transparently reads plaintext. var settingsRepo catalog.SettingsStore if deps.DB != nil { settingsRepo = catalog.NewEncryptedSettingsRepo(catalog.NewServerSettingsRepo(deps.DB), deps.SecretCipher) } // Build auth handler and auth middleware if DB and config are available. var userRepo *auth.UserRepository var inviteCodeRepo *auth.InviteCodeRepository var apiKeyRepo *auth.APIKeyRepository var authService *auth.Service var authHandler *handlers.AuthHandler var authMiddleware *apimw.AuthMiddleware var viewerAccessMiddleware *apimw.ViewerAccessMiddleware var permissionMiddleware *apimw.PermissionMiddleware var viewerResolver *access.Resolver var profileTokenService *access.ProfileTokenService var jwtService *auth.JWTService var sessionRepo *auth.SessionRepository var deviceLoginService *auth.DeviceLoginService if deps.DB != nil && deps.Config != nil { userRepo = auth.NewUserRepository(deps.DB) sessionRepo = auth.NewSessionRepository(deps.DB) inviteCodeRepo = auth.NewInviteCodeRepository(deps.DB) apiKeyRepo = auth.NewAPIKeyRepository(deps.DB) jwtService = auth.NewJWTService( deps.Config.Auth.JWTSecret, deps.Config.Auth.AccessTokenExpiry, deps.Config.Auth.RefreshTokenExpiry, ) if deps.OnConfigChange != nil { jwtForReload := jwtService deps.OnConfigChange(func(_, updated *config.Config) { jwtForReload.SetExpiries(updated.Auth.AccessTokenExpiry, updated.Auth.RefreshTokenExpiry) }) } provider := auth.NewLocalProvider(userRepo, sessionRepo) authService = auth.NewService( provider, jwtService, sessionRepo, userRepo, inviteCodeRepo, settingsRepo, deps.UserStoreProvider, ) for _, registration := range deps.AuthProviders { authService.RegisterProvider(registration.Info, registration.Provider) } deviceLoginService = auth.NewDeviceLoginService(deps.DB, userRepo, jwtService, sessionRepo) authHandler = handlers.NewAuthHandler(authService, jwtService, deviceLoginService) authMiddleware = apimw.NewAuthMiddleware(jwtService, sessionRepo, apiKeyRepo, userRepo) profileTokenService = access.NewProfileTokenService(deps.Config.Auth.JWTSecret, 0) if deps.UserStoreProvider != nil { viewerResolver = access.NewResolver(userRepo, deps.UserStoreProvider, profileTokenService) viewerAccessMiddleware = apimw.NewViewerAccessMiddleware(viewerResolver) } if deps.DB != nil { permissionMiddleware = apimw.NewPermissionMiddleware( userRepo, apimw.NewPGMetadataTargetLibraryResolver(deps.DB), checkPrimaryProfile, ) } } if deps.SessionMgr != nil && userRepo != nil { deps.SessionMgr.SetLimitProvider(func(ctx context.Context, userID int) (playback.SessionLimits, error) { user, err := userRepo.GetByID(ctx, userID) if err != nil { return playback.SessionLimits{}, err } return playback.SessionLimits{ MaxStreams: user.MaxStreams, MaxTranscodes: user.MaxTranscodes, }, nil }) } // Build demo guard middleware if server settings are available. var demoGuard *apimw.DemoGuard if settingsRepo != nil { demoGuard = apimw.NewDemoGuard(settingsRepo) } // Build library handler if folder repo is available. var libraryHandler *handlers.LibraryHandler if deps.FolderRepo != nil { libraryHandler = handlers.NewLibraryHandler(deps.FolderRepo, deps.LibraryIngester, userRepo, deps.DB, deps.Refresher, deps.AppContext) libraryHandler.EventBus = deps.EventBus libraryHandler.EventsHub = deps.EventsHub libraryHandler.ScanRegistry = deps.ScanRegistry libraryHandler.ScanQueue = deps.LibraryScanQueue libraryHandler.MovieMatchQueueRepo = deps.MovieMatchQueueRepo libraryHandler.SeriesMatchQueueRepo = deps.SeriesRootMatchQueueRepo libraryHandler.RawMatchBacklogRepo = deps.FileRepo if deps.Config != nil { libraryHandler.TVSeriesRootQueue = deps.Config.Matcher.TVSeriesRootQueueEnabled() } if deps.DB != nil { libraryHandler.JobRepo = adminjob.NewRepository(deps.DB) } // Library poster uploads are writable client-facing assets, so they // belong in the public assets bucket. if deps.S3Public != nil { libraryHandler.S3Meta = deps.S3Public } // Wire provider chain repos for per-library provider priority management. if deps.DB != nil && deps.PluginService != nil { libraryHandler.ChainRepo = metadata.NewChainRepository(deps.DB) libraryHandler.PluginInstallations = plugins.NewInstallationStore(deps.DB) } if invalidator, ok := deps.MetadataService.(interface{ InvalidateChainCache() }); ok { libraryHandler.SetChainCacheInvalidator(invalidator) } if deps.SkippedRootRepo != nil { libraryHandler.SkippedRootRepo = deps.SkippedRootRepo } if deps.StaleIDRepo != nil { libraryHandler.StaleIDRepo = deps.StaleIDRepo } if deps.DB != nil { libraryHandler.SectionRepo = sections.NewRepository(deps.DB) } if deps.UserStoreProvider != nil { libraryHandler.StoreProvider = deps.UserStoreProvider } } // Build ratings repo if DB is available. Use dep-injected repo when provided // (e.g. already constructed in main.go for the recommendations engine). var ratingsRepo *catalog.RatingsRepo if deps.RatingsRepo != nil { ratingsRepo = deps.RatingsRepo } else if deps.DB != nil { ratingsRepo = catalog.NewRatingsRepo(deps.DB) } // Build browse/search/items handlers if DB is available. var itemsHandler *handlers.ItemsHandler var catalogResourceHandler *handlers.CatalogResourceHandler var catalogHandler *handlers.CatalogHandler var peopleHandler *handlers.PeopleHandler var itemRepo *catalog.ItemRepository var episodeRepo *catalog.EpisodeRepository var providerIDRepo *catalog.ProviderIDRepository var seasonRepo *catalog.SeasonRepository var detailSvc *catalog.DetailService var calendarRepo *catalog.CalendarRepository var webhookSyncHandler *handlers.WebhookSyncHandler var requestHandler *handlers.RequestsHandler var autoscanHandler *handlers.AutoscanHandler var ebookReaderHandler *handlers.EbookReaderHandler var ebookProgressStore *handlers.PGEbookReaderProgressStore var ebookConfigStore *handlers.PGEbookReaderConfigStore var ebookAnnotationStore *handlers.PGEbookReaderAnnotationStore if deps.DB != nil { ebookProgressStore = handlers.NewPGEbookReaderProgressStore(deps.DB) ebookConfigStore = handlers.NewPGEbookReaderConfigStore(deps.DB) ebookAnnotationStore = handlers.NewPGEbookReaderAnnotationStore(deps.DB) browseRepo := catalog.NewBrowseRepository(deps.DB) itemRepo = catalog.NewItemRepository(deps.DB) episodeRepo = catalog.NewEpisodeRepository(deps.DB) providerIDRepo = catalog.NewProviderIDRepository(deps.DB) calendarRepo = catalog.NewCalendarRepository(deps.DB) var fileFetcher catalog.FileVersionFetcher if deps.FileRepo != nil { fileFetcher = deps.FileRepo } seasonRepo = catalog.NewSeasonRepository(deps.DB) folderRepo := catalog.NewFolderRepository(deps.DB) var episodeFileProvider handlers.EpisodeFileProvider if deps.FileRepo != nil { episodeFileProvider = deps.FileRepo } rootClaimRepo := catalog.NewRootClaimRepository(deps.DB) groupClaimRepo := catalog.NewGroupClaimRepository(deps.DB) detailSvc = catalog.NewDetailService(itemRepo, episodeRepo, seasonRepo, deps.PersonRepo, fileFetcher) detailSvc.SetFolderRepository(folderRepo) detailSvc.SetRootClaimRepository(rootClaimRepo) detailSvc.SetGroupClaimRepository(groupClaimRepo) detailSvc.SetProbeEnsurer(deps.ProbeEnsurer) detailSvc.SetChapterThumbnailQueuer(deps.ChapterThumbnailQueuer) if deps.ImageResolver != nil { detailSvc.SetImageResolver(deps.ImageResolver) } detailSvc.SetUserStoreProvider(deps.UserStoreProvider) itemsHandler = handlers.NewItemsHandler( browseRepo, itemRepo, episodeRepo, seasonRepo, ratingsRepo, episodeFileProvider, deps.UserStoreProvider, detailSvc, providerIDRepo, ) itemsHandler.EventsHub = deps.EventsHub itemsHandler.UserRepo = userRepo if requester, ok := deps.MetadataService.(handlers.MetadataRefreshRequester); ok { itemsHandler.SetMetadataRefreshRequester(requester) } if dispatcher, ok := deps.WatchProviderService.(handlers.LocalWatchEventDispatcher); ok { itemsHandler.SetLocalWatchEventDispatcher(dispatcher) } if ebookProgressStore != nil { itemsHandler.SetEbookReaderProgressStore(ebookProgressStore) } if deps.FileRepo != nil { ebookReaderHandler = handlers.NewEbookReaderHandler(&handlers.MediaFileAuthorizer{ FileResolver: deps.FileRepo, ItemAccess: itemRepo, EpisodeLookup: episodeRepo, }) if ebookProgressStore != nil { ebookReaderHandler.ProgressStore = ebookProgressStore } if ebookConfigStore != nil { ebookReaderHandler.ConfigStore = ebookConfigStore } if ebookAnnotationStore != nil { ebookReaderHandler.AnnotationStore = ebookAnnotationStore } } catalogResourceHandler = handlers.NewCatalogResourceHandler(itemsHandler) catalogHandler = handlers.NewCatalogHandler( catalog.NewCatalogResolver(browseRepo, itemRepo). WithEpisodeRepository(episodeRepo). WithUserStoreProvider(deps.UserStoreProvider), itemsHandler, ) tmdbAPIKey := "" if deps.Config != nil { tmdbAPIKey = deps.Config.TMDBAPIKey } requestsRepo := mediarequests.NewRepository(deps.DB, deps.SecretCipher) requestSvc := mediarequests.NewService( requestsRepo, tmdb.NewClient(tmdbAPIKey, 40), mediarequests.NewCatalogPresence(itemRepo, providerIDRepo), ) AttachRequestRouter(requestSvc, deps.PluginService) requestSvc.SetRequesterIdentityResolver(plugins.RequesterIdentityFromLookup(plugins.NewPgUserIdentityLookup(deps.DB))) if viewerResolver != nil { requestSvc.SetEntitlementResolver(mediarequests.NewAccessEntitlements(viewerResolver)) } // Request lifecycle notifications (submitted / approved / declined): // server-channel broadcasts plus personal deliveries to the requester // on approve/decline. Fulfilled rides the reconcile service's // fulfillment notifier instead. if lifecycle := notifications.NewRequestLifecycleNotifier(deps.Notifications); lifecycle != nil { requestSvc.SetLifecycleNotifier(lifecycle) } requestHandler = handlers.NewRequestsHandler(requestSvc) autoscanRepo := autoscan.NewRepository(deps.DB, deps.SecretCipher) if deps.FolderRepo != nil && deps.LibraryScanQueue != nil && deps.PluginService != nil { autoscanSvc := BuildAutoscanService( autoscanRepo, deps.PluginService, plugins.NewInstallationStore(deps.DB), requestsRepo, deps.FolderRepo, deps.LibraryScanQueue, deps.RedisClient, ) autoscanHandler = handlers.NewAutoscanHandler(autoscanRepo, autoscanSvc) // Wire the optional poll-task rescheduler so a settings change // re-applies the poll interval without a restart. if deps.TaskManager != nil { autoscanHandler.SetTriggerUpdater(deps.TaskManager) } } if deps.PersonRepo != nil { peopleHandler = handlers.NewPeopleHandler(deps.PersonRepo, browseRepo, itemRepo, detailSvc) peopleHandler.SetItemsHandler(itemsHandler) peopleHandler.SetRefreshQueue(deps.PersonRefreshQueue) peopleHandler.SetRefreshService(deps.PersonRefresher) } } // Build profile/personal data handlers if UserStoreProvider is available. var profileHandler *handlers.ProfileHandler var personalDataHandler *handlers.PersonalDataHandler var progressHandler *handlers.ProgressHandler var collectionHandler *handlers.CollectionHandler var settingsHandler *handlers.SettingsHandler var homeDismissalHandler *handlers.HomeDismissalHandler var subtitlePrefHandler *handlers.SubtitlePrefHandler var audioPrefHandler *handlers.AudioPrefHandler var libraryPlaybackPrefHandler *handlers.LibraryPlaybackPrefHandler var watchProviderHandler *handlers.WatchProviderHandler var playbackSessionsLoader *handlers.PlaybackSessionsLoader if deps.DB != nil { playbackSessionsLoader = handlers.NewPlaybackSessionsLoader(deps.DB, deps.UserStoreProvider, detailSvc) } if deps.UserStoreProvider != nil { profileHandler = handlers.NewProfileHandler(deps.UserStoreProvider) profileHandler.UserRepo = userRepo profileHandler.ProfileTokens = profileTokenService profileHandler.AvatarStore = deps.S3Private profileHandler.SessionsReader = playbackSessionsLoader personalDataHandler = handlers.NewPersonalDataHandler(deps.UserStoreProvider, itemRepo) if detailSvc != nil { personalDataHandler.SetDetailService(detailSvc) } if ebookProgressStore != nil { personalDataHandler.SetEbookReaderProgressStore(ebookProgressStore) } personalDataHandler.SetEpisodeRepo(episodeRepo) personalDataHandler.SetSeasonRepo(seasonRepo) personalDataHandler.EventsHub = deps.EventsHub if dispatcher, ok := deps.WatchProviderService.(handlers.LocalFavoriteEventDispatcher); ok { personalDataHandler.SetLocalFavoriteEventDispatcher(dispatcher) } progressHandler = handlers.NewProgressHandler(deps.UserStoreProvider) progressHandler.EventsHub = deps.EventsHub if settingsRepo != nil { progressHandler.SettingsRepo = settingsRepo } if deps.DB != nil { progressHandler.LibraryLookup = catalog.NewLibraryItemRepository(deps.DB) } collectionHandler = handlers.NewCollectionHandler(deps.UserStoreProvider) if deps.DB != nil { collectionHandler.Executor = &catalog.QueryExecutor{Pool: deps.DB} } if deps.S3Public != nil { collectionHandler.S3GP = deps.S3Public collectionHandler.PresignTTL = 4 * time.Hour } settingsHandler = handlers.NewSettingsHandler(deps.UserStoreProvider) if settingsRepo != nil { settingsHandler.SetServerSettings(settingsRepo) } homeDismissalHandler = handlers.NewHomeDismissalHandler(deps.UserStoreProvider) homeDismissalHandler.EventsHub = deps.EventsHub subtitlePrefHandler = handlers.NewSubtitlePrefHandler(deps.UserStoreProvider) audioPrefHandler = handlers.NewAudioPrefHandler(deps.UserStoreProvider) libraryPlaybackPrefHandler = handlers.NewLibraryPlaybackPrefHandler(deps.UserStoreProvider) if deps.FolderRepo != nil { libraryPlaybackPrefHandler.SetLibraryLookup(deps.FolderRepo) } else if deps.DB != nil { libraryPlaybackPrefHandler.SetLibraryLookup(catalog.NewFolderRepository(deps.DB)) } } if deps.WatchProviderService != nil { watchProviderHandler = handlers.NewWatchProviderHandler(deps.WatchProviderService) } // Build ratings handler if both repo and itemRepo are available. var ratingsHandler *handlers.RatingsHandler var recsRepoForStale *recommendations.Repo if ratingsRepo != nil && itemRepo != nil { ratingsHandler = handlers.NewRatingsHandler(ratingsRepo, itemRepo) if deps.DB != nil { recsRepoForStale = recommendations.NewRepo(deps.DB) ratingsHandler.SetProfileStaler(recsRepoForStale) ratingsHandler.SetProfileRefreshRequester(deps.RecWorker) if personalDataHandler != nil { personalDataHandler.SetProfileStaler(recsRepoForStale) personalDataHandler.SetProfileRefreshRequester(deps.RecWorker) } if progressHandler != nil { progressHandler.SetProfileStaler(recsRepoForStale) progressHandler.SetProfileRefreshRequester(deps.RecWorker) } if itemsHandler != nil { itemsHandler.SetProfileStaler(recsRepoForStale) itemsHandler.SetProfileRefreshRequester(deps.RecWorker) } } } // Create subtitleRepo early — only needs DB, shared with playback handler and subtitle search handler. var subtitleRepo *subtitles.PgRepository if deps.DB != nil { subtitleRepo = subtitles.NewPgRepository(deps.DB, deps.SecretCipher) } // Notifier that pushes "subtitle ready" events to active sessions when an AI // translation completes. Assigned inside the playback handler block where the // realtime hub and session manager are in scope; nil when playback is off. var subtitleAINotifier *playback.SubtitleReadyNotifier // Build playback handler if session manager is available. var playbackHandler *handlers.PlaybackHandler var adminPlaybackControlHandler *handlers.AdminPlaybackControlHandler var playbackCommandDispatcher *playback.CommandDispatcher var streamHandler *handlers.StreamHandler var watchTogetherHandler *handlers.WatchTogetherHandler if deps.SessionMgr != nil { var playbackAdminStore handlers.PlaybackAdminStore if deps.DB != nil { playbackAdminStore = handlers.NewPGPlaybackAdminStore(deps.DB, deps.EventsHub) } if deps.FileRepo != nil { playbackHandler = handlers.NewPlaybackHandler(deps.SessionMgr, deps.FileRepo) streamHandler = handlers.NewStreamHandler(deps.SessionMgr, deps.FileRepo) } else { playbackHandler = handlers.NewPlaybackHandler(deps.SessionMgr) } // Wire UserStoreProvider for progress/history persistence. if deps.UserStoreProvider != nil { playbackHandler.StoreProvider = deps.UserStoreProvider } playbackHandler.StableIdentityResolver = watchstate.NewStableIdentityResolver(itemRepo, episodeRepo, providerIDRepo) if scrobbler, ok := deps.WatchProviderService.(handlers.PlaybackWatchScrobbler); ok { playbackHandler.WatchScrobbler = scrobbler } playbackHandler.AdminStore = playbackAdminStore playbackHandler.EventsHub = deps.EventsHub if deps.FileRepo != nil { playbackHandler.MissingMarker = deps.FileRepo } if deps.SessionSyncer != nil { playbackHandler.SessionSyncer = deps.SessionSyncer } if streamHandler != nil { streamHandler.AdminStore = playbackAdminStore streamHandler.EventsHub = deps.EventsHub streamHandler.SessionSyncer = deps.SessionSyncer if deps.FileRepo != nil { streamHandler.MissingMarker = deps.FileRepo } } // Wire the optional node planner and JWT secret for node-aware stream URLs. if deps.NodePlanner != nil { playbackHandler.NodePlanner = deps.NodePlanner } if deps.Config != nil && deps.Config.Auth.JWTSecret != "" { playbackHandler.JWTSecret = deps.Config.Auth.JWTSecret } if deps.Config != nil { playbackHandler.PlaybackConfig = func() config.PlaybackConfig { return deps.CurrentConfig().Playback } if cleaned, err := playbackHandler.CleanupOrphanedTranscodes(); err != nil { slog.Warn("playback transcode cleanup failed", "dir", deps.Config.Playback.TranscodeDir, "error", err) } else if cleaned > 0 { slog.Info("playback transcode cleanup removed orphaned dirs", "dir", deps.Config.Playback.TranscodeDir, "count", cleaned) } } playbackHandler.ProbeEnsurer = deps.ProbeEnsurer playbackHandler.ChapterThumbnailQueuer = deps.ChapterThumbnailQueuer if settingsRepo != nil { playbackHandler.SettingsRepo = settingsRepo } if deps.FileRepo != nil { playbackHandler.FileVersionFetcher = deps.FileRepo } if subtitleRepo != nil { playbackHandler.SubtitleRepo = subtitleRepo } if recsRepoForStale != nil { playbackHandler.SetProfileStaler(recsRepoForStale) playbackHandler.SetProfileRefreshRequester(deps.RecWorker) } realtimeHub := deps.PlaybackRealtimeHub if realtimeHub == nil { realtimeHub = playback.NewRealtimeHub() } commandTracker := playback.NewCommandTracker() playbackHandler.RealtimeHub = realtimeHub playbackHandler.CommandTracker = commandTracker playbackHandler.CommandDispatcher = playback.NewCommandDispatcher(deps.SessionMgr, realtimeHub, commandTracker) playbackCommandDispatcher = playbackHandler.CommandDispatcher playbackHandler.IntroAnalyzer = deps.IntroAnalyzer playbackHandler.IntroRepository = deps.IntroRepository playbackHandler.MarkerRegistry = deps.MarkerRegistry playbackHandler.MarkerResolver = deps.MarkerResolver if deps.FileRepo != nil { playbackHandler.MarkerUpserter = deps.FileRepo } playbackHandler.MarkerUpdateNotifier = playback.NewMarkerUpdateNotifier(deps.SessionMgr, realtimeHub) subtitleAINotifier = playback.NewSubtitleReadyNotifier(deps.SessionMgr, realtimeHub) adminPlaybackControlHandler = handlers.NewAdminPlaybackControlHandler(playbackHandler) if deps.DB != nil && deps.FileRepo != nil && viewerResolver != nil && deps.Config != nil && detailSvc != nil { roomTokenService := watchtogether.NewRoomTokenService(deps.Config.Auth.JWTSecret, 24*time.Hour) watchTogetherHandler = handlers.NewWatchTogetherHandler( watchtogether.NewService( watchtogether.NewRepository(deps.DB), deps.SessionMgr, deps.FileRepo, playbackHandler.CommandDispatcher, watchtogether.NewCatalogSelectionResolver(detailSvc), watchtogether.NewSuggestionRepository(deps.DB), ), viewerResolver, roomTokenService, ) } } // Wire subtitle repo and S3 client onto streamHandler for S3-stored subtitle serving. if streamHandler != nil && subtitleRepo != nil && deps.S3Public != nil { streamHandler.SubtitleRepo = subtitleRepo streamHandler.S3Client = deps.S3Public streamHandler.S3Bucket = deps.S3Public.Bucket() } if streamHandler != nil && deps.Config != nil { streamHandler.PlaybackConfig = func() config.PlaybackConfig { return deps.CurrentConfig().Playback } } restartStatus := deps.ServerRestartStatus if restartStatus == nil { restartStatus = handlers.NewServerRestartStatusTracker() } serverControlHandler := handlers.NewServerControlHandler(deps.RequestServerRestart, playbackCommandDispatcher, restartStatus) // Build admin handler if we have a user repo. var adminHandler *handlers.AdminHandler var catalogSeedHandler *handlers.CatalogSeedHandler var adminJobsHandler *handlers.AdminJobsHandler if userRepo != nil { adminHandler = handlers.NewAdminHandler(userRepo, deps.DB, deps.UserStoreProvider) adminHandler.SessionsLoader = playbackSessionsLoader adminHandler.DetailSvc = detailSvc adminHandler.EventBus = deps.EventBus adminHandler.EventsHub = deps.EventsHub adminHandler.ImpersonationService = authService adminHandler.StatsSource = deps.AdminStatsProvider adminHandler.RealtimeHub = deps.RealtimeHub adminHandler.BootstrapSensitiveConfigured = deps.BootstrapSensitiveConfigured adminHandler.BootstrapSensitiveValues = deps.BootstrapSensitiveValues adminHandler.RestartStatus = restartStatus if settingsRepo != nil { adminHandler.SettingsRepo = settingsRepo } adminHandler.Config = deps.Config if deps.OnUserSessionsRevoked != nil { adminHandler.OnUserSessionsRevoked = deps.OnUserSessionsRevoked } if deps.OnServerSettingUpdated != nil { adminHandler.OnServerSettingUpdated = deps.OnServerSettingUpdated } } if deps.DB != nil { jobRepo := adminjob.NewRepository(deps.DB) // Avoid wrapping a nil *s3client.Client in a non-nil interface; // handlers rely on interface-nil checks to gate S3 features. var privateStore handlers.CatalogSeedArtifactStore if deps.S3Private != nil { privateStore = deps.S3Private } catalogSeedHandler = handlers.NewCatalogSeedHandler(catalogseed.NewService(deps.DB, deps.PersonRepo, recommendations.NewRepo(deps.DB)), jobRepo, privateStore) catalogSeedHandler.RealtimeHub = deps.RealtimeHub adminJobsHandler = handlers.NewAdminJobsHandler(jobRepo, privateStore) adminJobsHandler.CancelRegistry = deps.AdminJobCancelRegistry adminJobsHandler.RealtimeHub = deps.RealtimeHub if adminHandler != nil && deps.FolderRepo != nil && deps.FileRepo != nil && itemRepo != nil && episodeRepo != nil { adminHandler.JobRepo = jobRepo adminHandler.ItemRefreshResolver = adminjob.NewItemRefreshResolver( itemRepo, catalog.NewSeasonRepository(deps.DB), episodeRepo, deps.FolderRepo, deps.FileRepo, ) } } // Build admin match handler if metadata service and item repo are available. var adminMatchHandler *handlers.AdminMatchHandler if deps.MetadataService != nil && itemRepo != nil && deps.DB != nil { adminMatchHandler = handlers.NewAdminMatchHandler( itemRepo, &handlers.PoolFolderLookup{Pool: deps.DB}, deps.MetadataService, ) } // Build admin image handler for poster/backdrop/logo selection. var adminImageHandler *handlers.AdminImageHandler if imageSvc, ok := deps.MetadataService.(handlers.ImageService); ok && itemRepo != nil && seasonRepo != nil && episodeRepo != nil && deps.DB != nil && detailSvc != nil { adminImageHandler = handlers.NewAdminImageHandler( itemRepo, seasonRepo, episodeRepo, &handlers.PoolFolderLookup{Pool: deps.DB}, imageSvc, deps.PluginImageResolver, detailSvc, ) } var adminIntroHandler *handlers.AdminIntroHandler if deps.IntroAnalyzer != nil && deps.IntroRepository != nil { adminIntroHandler = handlers.NewAdminIntroHandler( deps.IntroAnalyzer, deps.IntroRepository, deps.AppContext, slog.Default(), ) adminIntroHandler.Settings = settingsRepo adminIntroHandler.FileResolver = deps.FileRepo if playbackHandler != nil { adminIntroHandler.MarkerUpdateNotifier = playbackHandler.MarkerUpdateNotifier } } var markersHandler *handlers.MarkersHandler if deps.FileRepo != nil { var notifier handlers.PlaybackMarkerUpdateNotifier if playbackHandler != nil { notifier = playbackHandler.MarkerUpdateNotifier } var contributor handlers.MarkerContributor if deps.MarkerContributionService != nil { contributor = deps.MarkerContributionService } var contributions handlers.MarkerContributionLister if deps.MarkerContributionStore != nil { contributions = deps.MarkerContributionStore } markersHandler = handlers.NewMarkersHandler( deps.FileRepo, deps.FileRepo, contributor, contributions, notifier, slog.Default(), ) markersHandler.BaseContext = deps.AppContext markersHandler.AuditHistory = deps.FileRepo markersHandler.Users = userRepo if itemRepo != nil { markersHandler.Authorizer = &handlers.MediaFileAuthorizer{ FileResolver: deps.FileRepo, ItemAccess: itemRepo, EpisodeLookup: episodeRepo, } } } var adminMarkerProvidersHandler *handlers.AdminMarkerProvidersHandler if deps.MarkerRegistry != nil && deps.MarkerProviderConfig != nil { adminMarkerProvidersHandler = handlers.NewAdminMarkerProvidersHandler( deps.MarkerRegistry, deps.MarkerProviderConfig, deps.EventBus, slog.Default(), ) } // Admin subtitle config handler only needs the DB repo — no S3 required. var adminSubtitleHandler *handlers.AdminSubtitleHandler var subtitleManager *subtitles.Manager if subtitleRepo != nil { adminSubtitleHandler = handlers.NewAdminSubtitleHandler(subtitleRepo) } // Build subtitle search handler if we have DB and S3. var subtitleSearchHandler *handlers.SubtitleSearchHandler if deps.DB != nil && deps.S3Public != nil && subtitleRepo != nil { subtitleManager = subtitles.NewManager(subtitleRepo, deps.S3Public, deps.S3Public.Bucket()) // Load provider configs from DB and register enabled providers. providerConfigs, _ := subtitleRepo.ListProviderConfigs(deps.AppContext) for _, cfg := range providerConfigs { if !cfg.Enabled { continue } switch cfg.ProviderName { case "opensubtitles": if cfg.Username == "" || cfg.Password == "" { continue } subtitleManager.RegisterProvider(opensubtitles.New(opensubtitles.Config{ Username: cfg.Username, Password: cfg.Password, })) case "subdl": if cfg.APIKey == "" { continue } subtitleManager.RegisterProvider(subdl.New(subdl.Config{APIKey: cfg.APIKey})) case "subsource": if cfg.APIKey == "" { continue } subtitleManager.RegisterProvider(subsource.New(subsource.Config{APIKey: cfg.APIKey})) } } mediaResolver := &pgSubtitleMediaResolver{pool: deps.DB} subtitleSearchHandler = handlers.NewSubtitleSearchHandler(subtitleManager, subtitleRepo, mediaResolver) } if adminSubtitleHandler != nil && deps.DB != nil && subtitleManager != nil { adminSubtitleHandler.SetDownloadedSubtitleDeps(deps.DB, subtitleManager) } // Build the AI subtitle handler (on-demand translation). Generated tracks are // stored as ordinary downloaded subtitles, so they reach every client through // the existing subtitle pipeline with no client changes. // Shared AI endpoint client + dispatch semaphore: subtitle translation/ASR // and metadata translation draw from one client and one concurrency bound. // Connection settings, models, toggles, and quotas hot-reload through // OnConfigChange; only the semaphore size (ai.max_concurrent_jobs) is // fixed at construction. var aiClient *llm.Client var aiSem chan struct{} if deps.Config != nil { aiClient = llm.NewClient(llmConfigFromServer(deps.Config)) aiSem = jobrunner.NewSemaphore(deps.Config.AI.MaxConcurrentJobs) if deps.OnConfigChange != nil { clientForReload := aiClient deps.OnConfigChange(func(_, updated *config.Config) { clientForReload.UpdateConfig(llmConfigFromServer(updated)) }) } } var subtitleAIHandler *handlers.SubtitleAIHandler if subtitleManager != nil && subtitleRepo != nil && deps.FileRepo != nil && deps.DB != nil && deps.Config != nil { aiCfg, disabledGateway := effectiveSubtitleAIConfig(deps.Config) if disabledGateway != "" { warnChatOnlyGateway(disabledGateway) } var aiNotifier subtitleai.Notifier if subtitleAINotifier != nil { aiNotifier = subtitleAINotifier } aiTranslator := subtitleai.NewLLMTranslator(aiClient, aiCfg.BatchSize, aiCfg.ContextNeighbors) aiTranscriber := subtitleai.NewWhisperTranscriber(aiClient, deps.Config.Playback.FFmpegPath, deps.Config.SubtitleAI.ASRChunkSeconds) aiService := subtitleai.NewService( deps.AppContext, aiCfg, subtitleai.NewPgJobRepository(deps.DB), aiTranslator, aiTranscriber, subtitleManager, subtitleRepo, deps.FileRepo, aiNotifier, deps.Config.Playback.FFmpegPath, slog.Default(), aiSem, ) aiService.Recover() if deps.OnConfigChange != nil { deps.OnConfigChange(func(old, updated *config.Config) { newCfg, newDisabled := effectiveSubtitleAIConfig(updated) aiService.UpdateConfig(newCfg) aiTranslator.SetBatching(updated.SubtitleAI.BatchSize, updated.SubtitleAI.ContextNeighbors) aiTranscriber.SetExtraction(updated.Playback.FFmpegPath, updated.SubtitleAI.ASRChunkSeconds) // Warn only when the gateway-disable condition newly appears, // not on every unrelated settings change. if newDisabled != "" && old != nil { if _, oldDisabled := effectiveSubtitleAIConfig(old); oldDisabled == "" { warnChatOnlyGateway(newDisabled) } } }) } subtitleAIHandler = handlers.NewSubtitleAIHandler(aiService) subtitleAIHandler.StoreProvider = deps.UserStoreProvider } // Metadata AI translation (descriptions into the localization tables). var metadataAIHandler *handlers.MetadataAIHandler if deps.DB != nil && deps.Config != nil && aiClient != nil { mtRepo := metadatatranslation.NewPgRepository(deps.DB) mtService := metadatatranslation.NewService( deps.AppContext, metadataAIConfigFromServer(deps.Config), mtRepo, mtRepo, &metadatatranslation.CatalogLocalizationStore{ Items: catalog.NewMediaItemLocalizationRepository(deps.DB), Seasons: catalog.NewSeasonLocalizationRepository(deps.DB), Episodes: catalog.NewEpisodeLocalizationRepository(deps.DB), }, aiClient.SystemUserChat, aiSem, slog.Default(), ) mtService.Recover() if deps.OnConfigChange != nil { deps.OnConfigChange(func(_, updated *config.Config) { mtService.UpdateConfig(metadataAIConfigFromServer(updated)) }) } metadataAIHandler = handlers.NewMetadataAIHandler(mtService) // Wire the refresh fallback: libraries with auto_translate_metadata get // missing localizations filled after each metadata refresh. if mt, ok := deps.MetadataService.(interface { SetAutoTranslator(metadata.AutoTranslator) }); ok { mt.SetAutoTranslator(mtService) } } // Build section handler if DB is available. var sectionHandler *handlers.SectionHandler var sectionSettingsHandler *handlers.SectionSettingsHandler var sectionBulkHandler *handlers.SectionBulkHandler var libraryCollectionHandler *handlers.LibraryCollectionHandler var libraryCollectionGroupHandler *handlers.LibraryCollectionGroupHandler libraryCollectionService := deps.CollectionService if deps.DB != nil { sectionRepo := sections.NewRepository(deps.DB) sectionBulkHandler = &handlers.SectionBulkHandler{Repo: sectionRepo} sectionFetcher := sections.NewFetcher(deps.DB) sectionFetcher.StoreProvider = deps.UserStoreProvider sectionFetcher.CollectionRepo = catalog.NewLibraryCollectionRepository(deps.DB) sectionFetcher.NextUpRepo = catalog.NewNextUpRepository(deps.DB, deps.UserStoreProvider) sectionFetcher.AudiobookNextRepo = catalog.NewAudiobookNextRepository(deps.DB) if deps.DB != nil { sectionFetcher.RecommendationRepo = recommendations.NewRepo(deps.DB) if ratingsRepo != nil { sectionFetcher.RecommendationReader = recommendations.NewReader(sectionFetcher.RecommendationRepo, ratingsRepo, deps.RecWorker, deps.UserStoreProvider) } } sections.InstallRecipeDelegate(sectionFetcher) sectionHandler = handlers.NewSectionHandler(sectionRepo, sectionFetcher) sectionHandler.CollectionRepo = sectionFetcher.CollectionRepo sectionHandler.FolderRepo = deps.FolderRepo if deps.UserStoreProvider != nil { sectionHandler.StoreProvider = deps.UserStoreProvider } sectionHandler.EpisodeRepo = episodeRepo sectionHandler.DetailSvc = detailSvc if ebookProgressStore != nil { sectionHandler.EbookProgress = ebookProgressStore } if userRepo != nil { sectionHandler.UserRepo = userRepo } if settingsRepo != nil { sectionHandler.Settings = settingsRepo sectionSettingsHandler = &handlers.SectionSettingsHandler{Settings: settingsRepo} } libraryCollectionRepo := catalog.NewLibraryCollectionRepository(deps.DB) if libraryCollectionService == nil { libraryCollectionService = catalog.NewLibraryCollectionService( libraryCollectionRepo, itemRepo, catalog.NewLibraryItemRepository(deps.DB), nil, ) } if libraryCollectionService.TMDBCollections == nil { apiKey := "" if deps.Config != nil { apiKey = deps.Config.TMDBAPIKey } libraryCollectionService.TMDBCollections = &tmdbCollectionAdapter{ client: tmdb.NewClient(apiKey, 40), } } if libraryCollectionService.TMDBFranchises == nil { apiKey := "" if deps.Config != nil { apiKey = deps.Config.TMDBAPIKey } libraryCollectionService.TMDBFranchises = &tmdbFranchiseAdapter{ client: tmdb.NewClient(apiKey, 40), } } if libraryCollectionService.TMDBDiscovers == nil { apiKey := "" if deps.Config != nil { apiKey = deps.Config.TMDBAPIKey } libraryCollectionService.TMDBDiscovers = &tmdbDiscoverAdapter{ client: tmdb.NewClient(apiKey, 40), } } traktClientID := "" if settingsRepo != nil { ctx := deps.AppContext if ctx == nil { ctx = context.Background() } if value, err := settingsRepo.Get(ctx, "watchsync.trakt.client_id"); err == nil { traktClientID = value } } if libraryCollectionService.TraktCollections == nil { libraryCollectionService.TraktCollections = &traktCollectionAdapter{ client: metatrakt.NewClient(traktClientID, 5), } } if libraryCollectionService.TraktTokenResolver == nil && deps.DB != nil && settingsRepo != nil { libraryCollectionService.TraktTokenResolver = &traktCollectionTokenResolver{ pool: deps.DB, settings: settingsRepo, cipher: deps.SecretCipher, provider: watchtrakt.NewProvider(nil, ""), } } // Propagate the now-wired Trakt + TMDB fetchers to the user-side sync // service (constructed earlier in main.go before settingsRepo and the // Trakt adapters existed, so its fetcher fields started nil). if deps.UserCollectionSync != nil { if deps.UserCollectionSync.TraktCollections == nil { deps.UserCollectionSync.TraktCollections = libraryCollectionService.TraktCollections } if deps.UserCollectionSync.TraktTokenResolver == nil { deps.UserCollectionSync.TraktTokenResolver = libraryCollectionService.TraktTokenResolver } if deps.UserCollectionSync.TMDBCollections == nil { deps.UserCollectionSync.TMDBCollections = libraryCollectionService.TMDBCollections } } // Propagate the now-wired Trakt fetcher to the trending refresher (built // in main.go with TMDB only, before the Trakt adapter existed). if deps.TrendingRefresher != nil && deps.TrendingRefresher.TraktTrending == nil { deps.TrendingRefresher.TraktTrending = libraryCollectionService.TraktCollections } // Wire the trending snapshot reader into the section fetcher. The // trending_discover home section reads its list from the persisted // snapshot table; the upstream fetch happens out-of-band in the refresh // task, so the read path never calls the provider. sectionFetcher.TrendingSnapshots = sections.NewTrendingSnapshotRepository(deps.DB) libraryCollectionHandler = handlers.NewLibraryCollectionHandler( libraryCollectionRepo, libraryCollectionService, itemRepo, 4*time.Hour, nil, deps.S3Public, ) libraryCollectionHandler.FrontendFS = deps.FrontendFS libraryCollectionHandler.Executor = &catalog.QueryExecutor{Pool: deps.DB} libraryCollectionHandler.SectionRepo = sectionRepo libraryCollectionHandler.UserCollectionPool = deps.DB libraryCollectionHandler.EventsHub = deps.EventsHub if deps.FolderRepo != nil { libraryCollectionHandler.FolderRepo = deps.FolderRepo } else { libraryCollectionHandler.FolderRepo = catalog.NewFolderRepository(deps.DB) } libraryCollectionGroupRepo := catalog.NewLibraryCollectionGroupRepository(deps.DB) libraryCollectionHandler.GroupRepo = libraryCollectionGroupRepo if deps.DB != nil { libraryCollectionHandler.JobRepo = adminjob.NewRepository(deps.DB) } libraryCollectionGroupHandler = handlers.NewLibraryCollectionGroupHandler( libraryCollectionGroupRepo, libraryCollectionRepo, deps.DB, ) refresher := &catalog.SmartCountRefresher{ Pool: deps.DB, Executor: &catalog.QueryExecutor{Pool: deps.DB}, } libraryCollectionHandler.SmartCountRefresher = refresher appCtx := deps.AppContext if appCtx == nil { appCtx = context.Background() } go func() { select { case <-time.After(15 * time.Second): case <-appCtx.Done(): return } refreshed, errs := refresher.RefreshAll(appCtx) slog.Info("smart-count refresh complete", "refreshed", refreshed, "errors", errs) ticker := time.NewTicker(time.Hour) defer ticker.Stop() for { select { case <-appCtx.Done(): return case <-ticker.C: refreshed, errs := refresher.RefreshAll(appCtx) slog.Debug("smart-count refresh complete", "refreshed", refreshed, "errors", errs) } } }() if detailSvc != nil { libraryCollectionHandler.SetDetailService(detailSvc) libraryCollectionHandler.SetupCollage() } } // Build recommendations handler if ratings repo is available. var recsHandler *handlers.RecommendationsHandler if ratingsRepo != nil { var recsRepo *recommendations.Repo var recsReader *recommendations.Reader if deps.DB != nil { recsRepo = recommendations.NewRepo(deps.DB) recsReader = recommendations.NewReader(recsRepo, ratingsRepo, deps.RecWorker, deps.UserStoreProvider) } recsHandler = handlers.NewRecommendationsHandler(deps.Recommender, recsReader, deps.UserStoreProvider, ratingsRepo, recsRepo, deps.Recommender != nil) if deps.DB != nil { recsFetcher := sections.NewFetcher(deps.DB) recsFetcher.StoreProvider = deps.UserStoreProvider recsFetcher.NextUpRepo = catalog.NewNextUpRepository(deps.DB, deps.UserStoreProvider) recsFetcher.AudiobookNextRepo = catalog.NewAudiobookNextRepository(deps.DB) recsHandler.Fetcher = recsFetcher recsHandler.WatchTonightFetcher = recsFetcher } if detailSvc != nil { recsHandler.DetailSvc = detailSvc } recsHandler.CalendarRepo = calendarRepo recsHandler.EpisodeRepo = episodeRepo if ebookProgressStore != nil { recsHandler.EbookProgress = ebookProgressStore } if deps.PersonRepo != nil { recsHandler.CastFetcher = deps.PersonRepo } if deps.RecWorker != nil { recsHandler.RecWorker = deps.RecWorker } } // Build download handler. var downloadHandler *handlers.DownloadHandler if deps.DB != nil && deps.FileRepo != nil && deps.Config != nil { downloadRepo := download.NewRepository(deps.DB) downloadBandwidth := download.NewBandwidthManager( deps.Config.Download.ServerBandwidthBPS, deps.Config.Download.UserBandwidthBPS, ) downloadLimiter := download.NewQuantityLimiter( downloadRepo, deps.Config.Download.MaxConcurrentPerUser, deps.Config.Download.MaxPerPeriod, deps.Config.Download.PeriodDuration, ) downloadSvc := download.NewService( downloadRepo, downloadBandwidth, downloadLimiter, deps.FileRepo, itemRepo, episodeRepo, userRepo, itemRepo, settingsRepo, &deps.Config.Download, ) downloadHandler = handlers.NewDownloadHandler(downloadSvc) } else { downloadHandler = handlers.NewDownloadHandler(nil) } var historyImportHandler *handlers.HistoryImportHandler var historyImportSvc *historyimport.Service if deps.DB != nil { historyRepo := historyimport.NewRepository(deps.DB, deps.SecretCipher) historyImportSvc = historyimport.NewService(deps.AppContext, historyRepo, deps.UserStoreProvider) historyIdentity := watchstate.NewStableIdentityResolver(itemRepo, episodeRepo, providerIDRepo) historyImportSvc.SetStableIdentityResolver(historyIdentity) if deps.EventsHub != nil { historyImportSvc.AddObserver(evt.NewHistoryImportObserver(deps.EventsHub)) } historyImportHandler = handlers.NewHistoryImportHandler(historyImportSvc) if deps.UserStoreProvider != nil { webhookSyncSvc := webhooksync.NewService(webhooksync.NewRepository(deps.DB, deps.SecretCipher), historyRepo, deps.UserStoreProvider) webhookSyncSvc.SetStableIdentityResolver(historyIdentity) webhookSyncHandler = handlers.NewWebhookSyncHandler(webhookSyncSvc) } } // ABS-compat routes are NOT mounted here — they live on a dedicated // http.Server (see absCompatSrv in cmd/silo/main.go) so the discovery // probes (/ping, /healthcheck, /status, etc.) don't collide with the // SPA fallback. Same pattern as the Jellyfin compat listener on 8096. r.Route("/api/v1", func(r chi.Router) { r.Get("/health", healthHandler.ServeHTTP) r.Get("/ready", readyHandler.ServeHTTP) // Branding handler is shared between the public read/serve endpoints // (registered with the theme endpoints below) and the admin // upload/delete endpoints (registered in the admin group). var brandingHandler *handlers.BrandingHandler if deps.BrandingService != nil { brandingHandler = handlers.NewBrandingHandler(deps.BrandingService) } if webhookSyncHandler != nil { r.Post("/plex-sync/webhooks/{secret}", webhookSyncHandler.HandleWebhook) r.Post("/webhook-sync/webhooks/{secret}", webhookSyncHandler.HandleWebhook) } // Theme endpoints (admin-css is public for pre-login branding). if settingsRepo != nil { themeHandler := handlers.NewThemeHandler(settingsRepo) r.Get("/theme/admin-css", themeHandler.HandleAdminCSS) if brandingHandler != nil { // Public branding read + asset serving (pre-login white-label). r.Get("/theme/branding", brandingHandler.HandleGetBranding) r.Get("/branding/assets/{kind}", brandingHandler.HandleServeAsset) } // Catalog and download proxies require auth (to avoid open proxy). if authMiddleware != nil { r.Group(func(r chi.Router) { r.Use(authMiddleware.RequireAuth) r.Get("/theme/catalog", themeHandler.HandleCatalog) r.Get("/theme/download", themeHandler.HandleDownload) r.With(requireActingAdmin).Post("/theme/catalog/refresh", themeHandler.HandleCatalogRefresh) }) } } if deps.PluginHTTPProxy != nil { r.HandleFunc("/plugins/{installation_id}/*", func(w http.ResponseWriter, r *http.Request) { installationID, err := strconv.Atoi(chi.URLParam(r, "installation_id")) if err != nil { http.Error(w, "invalid installation id", http.StatusBadRequest) return } authenticated, admin, userID := resolveOptionalPluginAccessUser(r, jwtService, sessionRepo, apiKeyRepo, userRepo) ctx := plugins.WithPluginAccessUser(r.Context(), authenticated, admin, userID) deps.PluginHTTPProxy.ServeRoute(w, r.WithContext(ctx), installationID, authenticated, admin) }) r.Get("/plugin-assets/{installation_id}/*", func(w http.ResponseWriter, r *http.Request) { installationID, err := strconv.Atoi(chi.URLParam(r, "installation_id")) if err != nil { http.Error(w, "invalid installation id", http.StatusBadRequest) return } assetPath := strings.TrimPrefix(chi.URLParam(r, "*"), "/") if assetPath == "" { http.NotFound(w, r) return } authenticated, admin := resolveOptionalPluginAccess(r, jwtService, sessionRepo) deps.PluginHTTPProxy.ServeAsset(w, r.WithContext(plugins.WithPluginAccess(r.Context(), authenticated, admin)), installationID, assetPath) }) } // Auth routes: public (no auth required). if authHandler != nil { // OAuth handler is optional: it only stands up when PublicURL is // configured (we need a stable redirect_uri origin for IdPs) and // the DB is available (oauth_session storage). var oauthHandler *auth.OAuthHandler if deps.PublicURL != "" && deps.DB != nil && authService != nil && jwtService != nil { stateSecret := auth.DeriveOAuthStateSecret([]byte(deps.Config.Auth.JWTSecret)) oauthStore := auth.NewPGOAuthStore(deps.DB, stateSecret) resolveClient := func(ctx context.Context, installationID int) (auth.OAuthClient, string, error) { pp := authService.FindOAuthInstallation(installationID) if pp == nil { return nil, "", errors.New("plugin not found") } c, err := pp.OAuthClient(ctx) if err != nil { return nil, "", err } return c, pp.CapabilityID(), nil } oauthHandler = auth.NewOAuthHandler(auth.OAuthHandlerDeps{ Store: oauthStore, CompletionStore: oauthStore, StateSecret: stateSecret, ResolveClient: resolveClient, LoginCompleter: authService, HostBaseURL: deps.PublicURL, StateTTL: 10 * time.Minute, }) } authHandler.SetOAuthRoutesAvailable(oauthHandler != nil) r.Route("/auth", func(r chi.Router) { if deps.RateLimitMW != nil { r.With(deps.RateLimitMW.AuthEndpointHandler("login")).Post("/login", authHandler.HandleLogin) r.With(deps.RateLimitMW.AuthEndpointHandler("setup")).Post("/setup", authHandler.HandleSetup) r.With(deps.RateLimitMW.AuthEndpointHandler("signup")).Post("/signup", authHandler.HandleSignup) } else { r.Post("/login", authHandler.HandleLogin) r.Post("/setup", authHandler.HandleSetup) r.Post("/signup", authHandler.HandleSignup) } r.Get("/setup", authHandler.HandleSetupStatus) r.Get("/providers", authHandler.HandleProviders) r.Post("/refresh", authHandler.HandleRefresh) r.Get("/signup", authHandler.HandleSignupStatus) if authMiddleware != nil { r.With(authMiddleware.RequireAuth).Post("/plugin-launch", authHandler.HandlePluginLaunch) } if oauthHandler != nil { r.Post("/oauth/complete", oauthHandler.HandleComplete) r.Route("/oauth/{install_id}", func(r chi.Router) { r.Post("/init", oauthHandler.HandleInit) r.Get("/callback", oauthHandler.HandleCallback) }) } if deps.RateLimitMW != nil { r.With(deps.RateLimitMW.AuthEndpointHandler("device_start")).Post("/device/start", authHandler.HandleDeviceStart) r.With(deps.RateLimitMW.AuthEndpointHandler("device_lookup")).Get("/device", authHandler.HandleDeviceLookup) r.With(deps.RateLimitMW.AuthEndpointHandler("device_poll")).Post("/device/poll", authHandler.HandleDevicePoll) } else { r.Post("/device/start", authHandler.HandleDeviceStart) r.Get("/device", authHandler.HandleDeviceLookup) r.Post("/device/poll", authHandler.HandleDevicePoll) } // Protected auth routes (require valid session). if authMiddleware != nil { r.Group(func(r chi.Router) { r.Use(authMiddleware.RequireAuth) r.Post("/logout", authHandler.HandleLogout) r.Post("/impersonation/end", authHandler.HandleEndImpersonation) r.Get("/me", authHandler.HandleMe) r.Get("/sessions", authHandler.HandleListSessions) r.Delete("/sessions/{id}", authHandler.HandleDeleteSession) r.Post("/device/approve", authHandler.HandleDeviceApprove) r.Post("/device/deny", authHandler.HandleDeviceDeny) }) } }) } // Discord account-link OAuth callback: public — Discord redirects the // browser here without credentials; the one-time link-state row // authenticates the request and maps it back to the initiating // account. The static path coexists with the authenticated // /notifications subrouter below (static routes win in chi). var discordNotificationsHandler *handlers.DiscordNotificationsHandler if deps.Notifications != nil { discordNotificationsHandler = handlers.NewDiscordNotificationsHandler(deps.Notifications, deps.PublicURL) r.Get("/notifications/discord/link/callback", discordNotificationsHandler.HandleLinkCallback) // Tokenized email links: public — clicked from mail clients on // devices without a Silo session; the single-use token (verify) // or per-profile capability token (unsubscribe) authenticates the // request. Static paths coexist with the authenticated // /notifications subrouter below, same as the Discord callback. deps.Notifications.SetPublicURL(deps.PublicURL) emailLinkHandler := handlers.NewEmailLinkHandler(deps.Notifications) r.Get("/notifications/email/verify", emailLinkHandler.HandleVerify) r.Get("/notifications/email/unsubscribe", emailLinkHandler.HandleUnsubscribe) r.Post("/notifications/email/unsubscribe", emailLinkHandler.HandleUnsubscribe) } // API key management routes (auth only, no viewer access needed). if apiKeyRepo != nil && authMiddleware != nil { r.Group(func(r chi.Router) { r.Use(authMiddleware.RequireAuth) if demoGuard != nil { r.Use(demoGuard.Guard) } apiKeyHandler := handlers.NewAPIKeyHandler(apiKeyRepo) r.Route("/api-keys", func(r chi.Router) { r.Post("/", apiKeyHandler.HandleCreateAPIKey) r.Get("/", apiKeyHandler.HandleListAPIKeys) r.Delete("/{id}", apiKeyHandler.HandleDeleteAPIKey) }) }) } // All remaining routes require auth. if authMiddleware != nil { r.Group(func(r chi.Router) { r.Use(authMiddleware.RequireAuth) if demoGuard != nil { r.Use(demoGuard.Guard) } if deps.RateLimitMW != nil { r.Use(deps.RateLimitMW.Handler) } if viewerAccessMiddleware != nil { r.Use(viewerAccessMiddleware.RequireViewerAccess) } // User-facing library route (all authenticated users). if libraryHandler != nil { r.Get("/user/libraries", libraryHandler.HandleListUserLibraries) } if deps.EventsHub != nil { eventsHandler := handlers.NewEventsHandler( deps.EventsHub, adminJobsHandler, adminHandler, deps.TaskManager, deps.ScanRegistry, deps.LibraryScanQueue, historyImportSvc, ) eventsHandler.SetNotificationsSystem(deps.Notifications) r.Get("/events/ws", eventsHandler.HandleWebSocket) } // User notifications: profile-scoped inbox, preferences, and // the websocket handshake ticket. if deps.Notifications != nil { if detailSvc != nil { deps.Notifications.SetImageResolver(detailSvc) } notificationsHandler := handlers.NewNotificationsHandler(deps.Notifications, deps.EventsHub) r.With(apimw.RequireProfile).Post("/events/ws-ticket", notificationsHandler.HandleMintWSTicket) // Discord DM channel: the linked identity and mode hang off // the login account, not a profile, so these stay outside // the RequireProfile subrouter below (static paths coexist // with it, same as the public email-link routes above). if discordNotificationsHandler != nil { r.Get("/notifications/discord-preferences", discordNotificationsHandler.HandleGetPreferences) r.Put("/notifications/discord-preferences", discordNotificationsHandler.HandleUpdatePreferences) r.Delete("/notifications/discord-link", discordNotificationsHandler.HandleUnlink) r.Post("/notifications/discord/link/init", discordNotificationsHandler.HandleLinkInit) } r.Route("/notifications", func(r chi.Router) { r.Use(apimw.RequireProfile) r.Get("/", notificationsHandler.HandleList) r.Get("/sync", notificationsHandler.HandleSync) r.Get("/unread-count", notificationsHandler.HandleUnreadCount) r.Get("/capability", notificationsHandler.HandleCapability) r.Get("/preferences", notificationsHandler.HandleGetPreferences) r.Put("/preferences", notificationsHandler.HandleUpdatePreferences) r.Get("/email-preferences", notificationsHandler.HandleGetEmailPreferences) r.Put("/email-preferences", notificationsHandler.HandleUpdateEmailPreferences) r.Put("/email-preferences/address", notificationsHandler.HandleRequestEmailAddress) r.Delete("/email-preferences/address", notificationsHandler.HandleClearEmailAddress) r.Post("/read-all", notificationsHandler.HandleReadAll) r.Route("/webhooks", func(r chi.Router) { r.Get("/", notificationsHandler.HandleListWebhooks) r.Post("/", notificationsHandler.HandleCreateWebhook) r.Put("/{id}", notificationsHandler.HandleUpdateWebhook) r.Delete("/{id}", notificationsHandler.HandleDeleteWebhook) r.Post("/{id}/rotate-secret", notificationsHandler.HandleRotateWebhookSecret) r.Post("/{id}/test", notificationsHandler.HandleTestWebhook) }) r.Route("/web-push", func(r chi.Router) { r.Get("/subscriptions", notificationsHandler.HandleWebPushList) r.Post("/subscriptions", notificationsHandler.HandleWebPushSubscribe) r.Delete("/subscriptions/{id}", notificationsHandler.HandleWebPushDelete) r.Post("/unsubscribe", notificationsHandler.HandleWebPushUnsubscribe) }) r.Get("/{id}", notificationsHandler.HandleGet) r.Post("/{id}/read", notificationsHandler.HandleMarkRead) }) } // Marker read/write/clear for any authenticated viewer: users // fix and create intro/recap/credits/preview markers from the // player. Writes are stamped source="manual" and contributed to // enabled providers in the background. Contribution + provider // config stay admin-only (see the /admin group below). if markersHandler != nil { r.Route("/markers", func(r chi.Router) { r.Get("/items/{id}", markersHandler.HandleGetItemMarkers) r.Put("/items/{id}", markersHandler.HandleSetItemMarkers) r.Get("/files/{fileId}", markersHandler.HandleGetFileMarkers) r.Put("/files/{fileId}", markersHandler.HandleSetFileMarkers) r.Delete("/files/{fileId}/{segment}", markersHandler.HandleClearFileSegment) }) } // Library management routes (admin-only). if libraryHandler != nil { r.Group(func(r chi.Router) { r.Use(requireActingAdmin) r.Route("/libraries", func(r chi.Router) { r.Get("/", libraryHandler.HandleListLibraries) r.Get("/roots", libraryHandler.HandleListRoots) r.Put("/roots/override", libraryHandler.HandleUpsertRootOverride) r.Delete("/roots/override", libraryHandler.HandleDeleteRootOverride) r.Get("/skipped-roots", libraryHandler.HandleListSkippedRoots) r.Get("/stale-ids", libraryHandler.HandleListStaleIDs) r.Post("/stale-ids/{contentID}/rematch", libraryHandler.HandleRematchStaleID) r.Get("/unmatched-items", libraryHandler.HandleListUnmatchedItems) r.Get("/metadata-match-queue", libraryHandler.HandleListMetadataMatchQueues) r.Post("/", libraryHandler.HandleCreateLibrary) r.Put("/reorder", libraryHandler.HandleReorderLibraries) r.Put("/{id}", libraryHandler.HandleUpdateLibrary) r.Delete("/{id}", libraryHandler.HandleDeleteLibrary) r.Post("/{id}/check-mount", libraryHandler.HandleCheckLibraryMount) r.Post("/{id}/confirm-empty-root-cleanup", libraryHandler.HandleConfirmEmptyRootCleanup) r.Get("/{id}/metadata-match-queue", libraryHandler.HandleGetMetadataMatchQueue) r.Post("/{id}/metadata-match-queue/retry", libraryHandler.HandleRetryMetadataMatchQueue) r.Post("/{id}/metadata-match-queue/cancel", libraryHandler.HandleCancelMetadataMatchQueue) r.Post("/{id}/refresh-metadata", libraryHandler.HandleRefreshLibraryMetadata) r.Get("/{id}/providers", libraryHandler.HandleGetLibraryProviders) r.Put("/{id}/providers", libraryHandler.HandleSetLibraryProviders) r.Put("/{id}/poster", libraryHandler.HandleUploadPoster) r.Delete("/{id}/poster", libraryHandler.HandleDeletePoster) }) r.Post("/scan", libraryHandler.HandleScan) r.Post("/scan/cancel", libraryHandler.HandleScanCancel) }) } // Browse, search, and item detail routes. if itemsHandler != nil { r.Get("/catalog", catalogHandler.HandleGetCatalog) r.Get("/catalog/filters", catalogHandler.HandleGetCatalogFilters) r.Get("/catalog/filters/search", catalogHandler.HandleGetCatalogFacetSearch) r.Get("/catalog/audiobook-groups", catalogHandler.HandleGetAudiobookGroups) r.Post("/catalog/query", catalogHandler.HandlePostCatalogQuery) if catalogResourceHandler != nil { r.Get("/catalog/items/{id}", catalogResourceHandler.HandleGetItemDetail) r.Get("/catalog/items/{id}/episodes", catalogResourceHandler.HandleGetItemEpisodes) r.Get("/catalog/items/{id}/versions", catalogResourceHandler.HandleGetItemVersions) r.Get("/catalog/series/{id}/seasons", catalogResourceHandler.HandleGetSeasons) r.Get("/catalog/series/{id}/seasons/{num}", catalogResourceHandler.HandleGetSeason) r.Get("/catalog/series/{id}/seasons/{num}/episodes", catalogResourceHandler.HandleGetEpisodes) } r.Get("/watch/{id}", itemsHandler.HandleGetWatchDetail) } if calendarRepo != nil { calendarPopular := recommendations.NewRepo(deps.DB) calendarTrending := sections.NewTrendingSnapshotRepository(deps.DB) calendarHandler := handlers.NewCalendarHandler(calendarRepo, detailSvc, calendarPopular, calendarTrending) r.With(apimw.RequireProfile).Get("/calendar", calendarHandler.HandleGetCalendar) } if peopleHandler != nil { r.Get("/people", peopleHandler.HandleSearch) r.Get("/people/{id}", peopleHandler.HandleGetPerson) r.Post("/people/{id}/refresh", peopleHandler.HandleRefreshPerson) } if libraryCollectionHandler != nil { r.Get("/library/{id}/collections", libraryCollectionHandler.HandleListLibraryCollections) r.Get("/library/{id}/collections/{collection_id}/items", libraryCollectionHandler.HandleGetLibraryCollectionItems) r.Get("/library/{id}/user-collections", libraryCollectionHandler.HandleListLibraryUserCollections) } // Profile routes. if profileHandler != nil { r.Route("/profiles", func(r chi.Router) { r.Get("/household/sessions", profileHandler.HandleListHouseholdSessions) r.Get("/", profileHandler.HandleListProfiles) r.Post("/", profileHandler.HandleCreateProfile) r.Put("/{id}", profileHandler.HandleUpdateProfile) r.Delete("/{id}", profileHandler.HandleDeleteProfile) r.Put("/{id}/avatar", profileHandler.HandleUploadAvatar) r.Delete("/{id}/avatar", profileHandler.HandleDeleteAvatar) r.Post("/{id}/verify-pin", profileHandler.HandleVerifyPIN) }) } // Favorites, watchlist, and history routes (profile-scoped). if personalDataHandler != nil && itemsHandler != nil { r.Route("/watched", func(r chi.Router) { r.Use(apimw.RequireProfile) r.Post("/{id}", itemsHandler.HandleMarkWatched) r.Delete("/{id}", itemsHandler.HandleMarkUnwatched) }) r.Route("/favorites", func(r chi.Router) { r.Use(apimw.RequireProfile) r.Get("/", personalDataHandler.HandleListFavorites) r.Get("/{item_id}", personalDataHandler.HandleCheckFavorite) r.Put("/{item_id}", personalDataHandler.HandleAddFavorite) r.Delete("/{item_id}", personalDataHandler.HandleRemoveFavorite) }) r.Route("/watchlist", func(r chi.Router) { r.Use(apimw.RequireProfile) r.Get("/", personalDataHandler.HandleListWatchlist) r.Get("/{item_id}", personalDataHandler.HandleCheckWatchlist) r.Put("/{item_id}", personalDataHandler.HandleAddToWatchlist) r.Delete("/{item_id}", personalDataHandler.HandleRemoveFromWatchlist) }) r.Route("/history", func(r chi.Router) { r.Use(apimw.RequireProfile) r.Get("/", personalDataHandler.HandleListHistory) r.Post("/remove", personalDataHandler.HandleRemoveHistory) }) // Ratings routes (profile-scoped). if ratingsHandler != nil { r.Route("/ratings", func(r chi.Router) { r.Use(apimw.RequireProfile) r.Get("/", ratingsHandler.HandleListRatings) r.Get("/{item_id}", ratingsHandler.HandleGetRating) r.Put("/{item_id}", ratingsHandler.HandleSetRating) r.Delete("/{item_id}", ratingsHandler.HandleDeleteRating) }) } } // Progress and sync routes (profile-scoped). if progressHandler != nil { r.Route("/progress", func(r chi.Router) { r.Use(apimw.RequireProfile) r.Get("/", progressHandler.HandleListProgress) }) r.Route("/sync", func(r chi.Router) { r.Use(apimw.RequireProfile) r.Post("/progress", progressHandler.HandleSyncProgress) }) } // Collection routes (profile-scoped). if collectionHandler != nil { var userImportHandler *handlers.UserCollectionImportHandler if deps.UserCollectionSync != nil { userImportHandler = handlers.NewUserCollectionImportHandler( deps.UserStoreProvider, deps.UserCollectionSync, deps.UserCollectionScheduler, nil, deps.MDBListClient, deps.S3Public, deps.FrontendFS, 4*time.Hour, ) } r.Route("/collections", func(r chi.Router) { r.Use(apimw.RequireProfile) r.Get("/", collectionHandler.HandleListCollections) if libraryCollectionHandler != nil { // Aggregated server (admin-curated) collections across // every accessible library. Separate from "/" (personal, // editable) by design — different access + cache lifecycle. r.Get("/server", libraryCollectionHandler.HandleListServerCollections) } r.Post("/", collectionHandler.HandleCreateCollection) r.Post("/preview", collectionHandler.HandlePreviewCollection) r.Put("/order", collectionHandler.HandleReorderCollections) r.Post("/groups", collectionHandler.HandleCreateCollectionGroup) r.Put("/groups/order", collectionHandler.HandleReorderCollectionGroups) r.Put("/groups/{id}", collectionHandler.HandleUpdateCollectionGroup) r.Delete("/groups/{id}", collectionHandler.HandleDeleteCollectionGroup) if userImportHandler != nil { r.Get("/templates", userImportHandler.HandleListTemplates) r.Get("/import/mdblist/search", userImportHandler.HandleSearchMDBList) r.Get("/import/mdblist/top", userImportHandler.HandleTopMDBList) r.Post("/import/mdblist", userImportHandler.HandleImportMDBList) r.Post("/import/tmdb", userImportHandler.HandleImportTMDB) r.Post("/import/trakt", userImportHandler.HandleImportTrakt) r.Post("/{id}/sync", userImportHandler.HandleSync) } r.Put("/{id}", collectionHandler.HandleUpdateCollection) r.Delete("/{id}", collectionHandler.HandleDeleteCollection) r.Delete("/{id}/image", collectionHandler.HandleDeleteCollectionImage) r.Get("/{id}/items", collectionHandler.HandleListCollectionItems) r.Put("/{id}/items/order", collectionHandler.HandleReorderCollectionItems) r.Put("/{id}/items/{item_id}", collectionHandler.HandleAddCollectionItem) r.Delete("/{id}/items/{item_id}", collectionHandler.HandleRemoveCollectionItem) }) } if homeDismissalHandler != nil { r.Route("/home/dismissals", func(r chi.Router) { r.Use(apimw.RequireProfile) r.Put("/{surface}/{item_id}", homeDismissalHandler.HandleUpsertDismissal) r.Delete("/{surface}/{item_id}", homeDismissalHandler.HandleDeleteDismissal) }) } if watchProviderHandler != nil { r.Route("/watch-providers", func(r chi.Router) { r.Use(apimw.RequireProfile) r.Get("/", watchProviderHandler.HandleListProviders) r.Get("/{provider}/connection", watchProviderHandler.HandleGetConnection) r.Patch("/{provider}/connection", watchProviderHandler.HandleUpdateConnection) r.Delete("/{provider}/connection", watchProviderHandler.HandleDeleteConnection) r.Post("/{provider}/auth/device-code", watchProviderHandler.HandleStartDeviceAuth) r.Post("/{provider}/auth/poll", watchProviderHandler.HandlePollDeviceAuth) r.Post("/{provider}/auth/api-key", watchProviderHandler.HandleConnectAPIKey) r.Post("/{provider}/sync", watchProviderHandler.HandleManualSync) r.Get("/{provider}/sync-runs", watchProviderHandler.HandleListSyncRuns) }) } if requestHandler != nil { r.Route("/requests", func(r chi.Router) { r.Use(apimw.RequireProfile) r.Get("/search", requestHandler.HandleSearch) r.Get("/discover", requestHandler.HandleDiscover) r.Get("/discover/studios", requestHandler.HandleListStudios) r.Get("/discover/networks", requestHandler.HandleListNetworks) r.Get("/discover/genres", requestHandler.HandleListGenres) r.Get("/discover/browse/studio/{slug}", requestHandler.HandleBrowseStudio) r.Get("/discover/browse/network/{slug}", requestHandler.HandleBrowseNetwork) r.Get("/discover/browse/genre/{slug}", requestHandler.HandleBrowseGenre) r.Get("/discover/{section}", requestHandler.HandleDiscoverSection) r.Get("/detail/{media_type}/{tmdb_id}", requestHandler.HandleGetDetail) r.Get("/status", requestHandler.HandleGetStatus) r.Post("/", requestHandler.HandleCreate) r.Get("/mine", requestHandler.HandleListMine) r.Get("/{id}", requestHandler.HandleGet) r.Post("/{id}/cancel", requestHandler.HandleCancel) }) } // Settings routes (user-scoped, no profile required). if settingsHandler != nil { r.Route("/settings", func(r chi.Router) { if deps.PluginUserConfig != nil && deps.PluginService != nil { pluginHandler := handlers.NewPluginHandler( plugins.NewRepositoryStore(deps.DB), plugins.NewInstallationStore(deps.DB), plugins.NewRuntimeConfigStore(deps.DB), deps.PluginService, deps.PluginUserConfig, deps.PluginHTTPProxy, metadata.NewChainRepository(deps.DB), deps.PluginImageResolver, restartStatus, ) r.Get("/plugins", pluginHandler.HandleListUserPluginSettings) r.Get("/plugins/{installation_id}", pluginHandler.HandleGetUserPluginSettings) r.Put("/plugins/{installation_id}", pluginHandler.HandlePutUserPluginSettings) } r.Get("/", settingsHandler.HandleListSettings) r.Get("/overlay-config", settingsHandler.HandleGetOverlayConfig) r.Group(func(r chi.Router) { r.Use(apimw.RequireProfile) r.Get("/effective", settingsHandler.HandleGetEffectiveSettings) r.Get("/subtitle_appearance/effective", settingsHandler.HandleGetEffectiveSubtitleAppearance) r.Put("/device/subtitle_appearance", settingsHandler.HandleSetSubtitleAppearanceDeviceOverride) r.Delete("/device/subtitle_appearance", settingsHandler.HandleDeleteSubtitleAppearanceDeviceOverride) r.Get("/device/{key}", settingsHandler.HandleGetDeviceSetting) r.Put("/device/{key}", settingsHandler.HandleSetDeviceSetting) r.Delete("/device/{key}", settingsHandler.HandleDeleteDeviceSetting) }) r.Get("/{key}", settingsHandler.HandleGetSetting) r.Put("/{key}", settingsHandler.HandleSetSetting) r.Delete("/{key}", settingsHandler.HandleDeleteSetting) }) } if historyImportHandler != nil { r.Route("/history-imports", func(r chi.Router) { r.Get("/sources", historyImportHandler.HandleListSources) r.Post("/emby-connect/login", historyImportHandler.HandleLoginConnect) r.Post("/plex/auth/pin", historyImportHandler.HandleCreatePlexPin) r.Post("/plex/auth/check", historyImportHandler.HandleCheckPlexPin) r.Get("/runs", historyImportHandler.HandleListRuns) r.Post("/runs", historyImportHandler.HandleCreateRun) r.Get("/runs/{id}", historyImportHandler.HandleGetRun) }) } if webhookSyncHandler != nil { r.Route("/plex-sync", func(r chi.Router) { r.Get("/connections", webhookSyncHandler.HandleLegacyListConnections) r.Post("/connections", webhookSyncHandler.HandleLegacyCreateConnection) r.Delete("/connections/{id}", webhookSyncHandler.HandleLegacyDeleteConnection) r.Post("/connections/{id}/webhook/rotate", webhookSyncHandler.HandleLegacyRotateWebhook) r.Get("/connections/{id}/actors", webhookSyncHandler.HandleLegacyGetActors) r.Put("/connections/{id}/actors", webhookSyncHandler.HandleLegacyUpdateActors) }) r.Route("/webhook-sync", func(r chi.Router) { r.Get("/connections", webhookSyncHandler.HandleListConnections) r.Post("/connections", webhookSyncHandler.HandleCreateConnection) r.Put("/connections/{id}", webhookSyncHandler.HandleUpdateConnection) r.Delete("/connections/{id}", webhookSyncHandler.HandleDeleteConnection) r.Post("/connections/{id}/webhook/rotate", webhookSyncHandler.HandleRotateWebhook) r.Get("/connections/{id}/events", webhookSyncHandler.HandleListEvents) r.Get("/connections/{id}/profile-mappings", webhookSyncHandler.HandleGetProfileMappings) r.Put("/connections/{id}/profile-mappings", webhookSyncHandler.HandleUpdateProfileMappings) }) } // Subtitle preference routes (profile-scoped). if subtitlePrefHandler != nil { r.Route("/subtitle-prefs", func(r chi.Router) { r.Use(apimw.RequireProfile) r.Get("/{series_id}", subtitlePrefHandler.HandleGetSubtitlePref) r.Put("/{series_id}", subtitlePrefHandler.HandleSetSubtitlePref) r.Delete("/{series_id}", subtitlePrefHandler.HandleDeleteSubtitlePref) }) } // Audio preference routes (profile-scoped). if audioPrefHandler != nil { r.Route("/audio-prefs", func(r chi.Router) { r.Use(apimw.RequireProfile) r.Get("/{series_id}", audioPrefHandler.HandleGetAudioPref) r.Put("/{series_id}", audioPrefHandler.HandleSetAudioPref) r.Delete("/{series_id}", audioPrefHandler.HandleDeleteAudioPref) }) } // Library playback preference routes (profile-scoped). if libraryPlaybackPrefHandler != nil { r.Route("/library-playback-prefs", func(r chi.Router) { r.Use(apimw.RequireProfile) r.Get("/", libraryPlaybackPrefHandler.HandleListLibraryPlaybackPrefs) r.Put("/{library_id}", libraryPlaybackPrefHandler.HandleSetLibraryPlaybackPref) r.Delete("/{library_id}", libraryPlaybackPrefHandler.HandleDeleteLibraryPlaybackPref) }) } if ebookReaderHandler != nil { r.Route("/ebooks", func(r chi.Router) { r.Use(apimw.RequireProfile) r.Get("/{content_id}/files/{file_id}/read", ebookReaderHandler.HandleReadFile) r.Head("/{content_id}/files/{file_id}/read", ebookReaderHandler.HandleReadFile) r.Get("/{content_id}/progress", ebookReaderHandler.HandleGetProgress) r.Put("/{content_id}/progress", ebookReaderHandler.HandleSaveProgress) r.Get("/{content_id}/reader-config", ebookReaderHandler.HandleGetConfig) r.Put("/{content_id}/reader-config", ebookReaderHandler.HandleSaveConfig) r.Get("/{content_id}/annotations", ebookReaderHandler.HandleListAnnotations) r.Post("/{content_id}/annotations", ebookReaderHandler.HandleCreateAnnotation) r.Patch("/{content_id}/annotations/{annotation_id}", ebookReaderHandler.HandleUpdateAnnotation) r.Delete("/{content_id}/annotations/{annotation_id}", ebookReaderHandler.HandleDeleteAnnotation) }) } // Metadata AI translation availability probe (the metadata editor // and detail pages show or hide their translate actions based on // this) plus the viewer-facing on-view translation trigger. if metadataAIHandler != nil { r.Get("/metadata/ai/status", metadataAIHandler.HandleStatus) if itemRepo != nil { metadataAIHandler.ItemAccess = itemRepo r.Post("/items/{id}/translate-description", metadataAIHandler.HandleTranslateOnView) } } else { r.Get("/metadata/ai/status", handlers.WriteMetadataAIDisabledStatus) } // Subtitle search + AI translation routes. if subtitleSearchHandler != nil { if deps.FileRepo != nil && itemRepo != nil { fileAuthorizer := &handlers.MediaFileAuthorizer{ FileResolver: deps.FileRepo, ItemAccess: itemRepo, EpisodeLookup: episodeRepo, } subtitleSearchHandler.FileAuthorizer = fileAuthorizer if subtitleAIHandler != nil { subtitleAIHandler.FileAuthorizer = fileAuthorizer } } r.Route("/subtitles", func(r chi.Router) { r.Post("/search", subtitleSearchHandler.HandleSearch) r.Post("/download", subtitleSearchHandler.HandleDownload) r.Post("/upload", subtitleSearchHandler.HandleUpload) r.Post("/detect-language", subtitleSearchHandler.HandleDetectLanguage) if subtitleAIHandler != nil { r.Get("/ai/status", subtitleAIHandler.HandleStatus) r.Get("/ai/quota", subtitleAIHandler.HandleQuota) r.Post("/ai/translate", subtitleAIHandler.HandleTranslate) r.Get("/ai/jobs", subtitleAIHandler.HandleListJobs) r.Get("/ai/jobs/{job_id}", subtitleAIHandler.HandleGetJob) r.Post("/ai/jobs/{job_id}/cancel", subtitleAIHandler.HandleCancelJob) } else { // Answer the capability probe with 200 {"enabled": false} // when AI translation isn't wired, so the client gets a // clean negative instead of a 404. r.Get("/ai/status", handlers.WriteSubtitleAIDisabledStatus) } r.Get("/{media_file_id}", subtitleSearchHandler.HandleList) r.Delete("/{id}", subtitleSearchHandler.HandleDelete) }) } // Playback routes. if playbackHandler != nil { playbackHandler.ItemAccess = itemRepo playbackHandler.EpisodeLookup = episodeRepo playbackHandler.OriginalLangLookup = itemRepo playbackHandler.FFmpegLogSink = deps.FFmpegLogSink r.Route("/playback", func(r chi.Router) { // HLS transcode delivery — no profile auth needed; // session ID (UUID) serves as the access token, same // pattern as /stream/{session_id}. r.Get("/transcode/{session_id}/master.m3u8", playbackHandler.HandleGetTranscodeManifest) r.Get("/transcode/{session_id}/segment/{name}", playbackHandler.HandleGetTranscodeSegment) // Playback realtime control socket — needs auth but not profile. r.Get("/sessions/{session_id}/control/ws", playbackHandler.HandleSessionWebSocket) // All mutation routes require profile auth. r.Group(func(r chi.Router) { r.Use(apimw.RequireProfile) r.Post("/start", playbackHandler.HandleStartPlayback) r.Post("/{session_id}/progress", playbackHandler.HandleUpdateProgress) r.Patch("/{session_id}/audio", playbackHandler.HandleChangeAudioTrack) r.Delete("/{session_id}", playbackHandler.HandleStopPlayback) r.Post("/transcode/start", playbackHandler.HandleStartTranscode) }) }) } if watchTogetherHandler != nil { r.Route("/watch-together", func(r chi.Router) { r.Get("/rooms/{room_id}/ws", watchTogetherHandler.HandleRoomWebSocket) r.Group(func(r chi.Router) { r.Use(apimw.RequireProfile) r.Post("/rooms", watchTogetherHandler.HandleCreateRoom) r.Post("/join", watchTogetherHandler.HandleJoinRoom) r.Get("/rooms/{room_id}", watchTogetherHandler.HandleGetRoom) r.Put("/rooms/{room_id}/selection", watchTogetherHandler.HandleSelectRoomItem) r.Patch("/rooms/{room_id}/policy", watchTogetherHandler.HandleUpdateRoomPolicy) r.Delete("/rooms/{room_id}", watchTogetherHandler.HandleCloseRoom) r.Get("/rooms/{room_id}/suggestions", watchTogetherHandler.HandleListSuggestions) r.Post("/rooms/{room_id}/suggestions", watchTogetherHandler.HandleCreateSuggestion) r.Delete("/rooms/{room_id}/suggestions/{suggestion_id}", watchTogetherHandler.HandleDeleteSuggestion) r.Post("/rooms/{room_id}/suggestions/{suggestion_id}/vote", watchTogetherHandler.HandleVote) r.Delete("/rooms/{room_id}/suggestions/{suggestion_id}/vote", watchTogetherHandler.HandleUnvote) r.Post("/rooms/{room_id}/suggestions/promote", watchTogetherHandler.HandlePromoteSuggestion) }) }) } // Stream routes. if streamHandler != nil { r.Get("/stream/{session_id}", streamHandler.HandleStream) r.Head("/stream/{session_id}", streamHandler.HandleStream) r.Get("/stream/{session_id}/subtitles/{track}", streamHandler.HandleSubtitle) r.Get("/stream/{session_id}/subtitles/{track}/fonts", streamHandler.HandleSubtitleFonts) } // Download routes. r.Route("/downloads", func(r chi.Router) { r.Post("/", downloadHandler.HandleCreateDownload) r.Get("/", downloadHandler.HandleListDownloads) r.Delete("/{id}", downloadHandler.HandleDeleteDownload) r.Get("/{id}/file", downloadHandler.HandleDownloadFile) }) r.Get("/direct-download", downloadHandler.HandleDirectDownload) r.Head("/direct-download", downloadHandler.HandleDirectDownload) // Recipe gallery catalog (no profile required — purely static metadata). recipeHandler := &handlers.RecipeHandler{} r.Get("/sections/recipes", recipeHandler.HandleList) r.Get("/sections/recipes/{type}/candidates", recipeHandler.HandleCandidates) // Section endpoints (profile-scoped). if sectionHandler != nil { r.Group(func(r chi.Router) { r.Use(apimw.RequireProfile) r.Get("/home/layout", sectionHandler.HandleHomeLayout) r.Get("/home/sections", sectionHandler.HandleHomeSections) r.Get("/home/sections/{id}/items", sectionHandler.HandleHomeSectionItems) r.Get("/library/{id}/layout", sectionHandler.HandleLibraryLayout) r.Get("/library/{id}/sections", sectionHandler.HandleLibrarySections) r.Get("/library/{id}/sections/{sectionId}/items", sectionHandler.HandleLibrarySectionItems) }) r.Route("/profile/sections", func(r chi.Router) { r.Use(apimw.RequireProfile) r.Get("/", sectionHandler.HandleGetProfileOverrides) r.Put("/", sectionHandler.HandleSaveProfileOverrides) r.Delete("/reset", sectionHandler.HandleResetProfileOverrides) r.Get("/settings", sectionHandler.HandleSectionSettings) if sectionSettingsHandler != nil { r.Get("/flags", sectionSettingsHandler.HandleGetProfileFlag) } }) } // Recommendation routes (profile-scoped). if recsHandler != nil { r.Route("/recommendations", func(r chi.Router) { r.Use(apimw.RequireProfile) r.Get("/for-you/main", recsHandler.HandleForYouMain) r.Get("/for-you/rows", recsHandler.HandleForYouRows) r.Get("/because-watched/{item_id}", recsHandler.HandleBecauseWatched) r.Get("/similar/{item_id}", recsHandler.HandleSimilar) r.Get("/similar-users", recsHandler.HandleSimilarUsers) r.Get("/taste-profile", recsHandler.HandleTasteProfile) r.Get("/popular", recsHandler.HandlePopular) r.Get("/recently-added", recsHandler.HandleRecentlyAdded) r.Get("/discover", recsHandler.HandleDiscover) r.Get("/section/{kind}", recsHandler.HandleSection) r.Get("/section/{kind}/{key}", recsHandler.HandleSection) r.Get("/watch-tonight", recsHandler.HandleWatchTonight) r.Get("/watch-tonight/cards", recsHandler.HandleWatchTonightCards) r.Get("/taste-seed/items", recsHandler.HandleTasteSeedItems) r.Post("/taste-seed", recsHandler.HandleTasteSeed) }) } // Admin routes. if adminHandler != nil { r.Route("/admin", func(r chi.Router) { metadataItemAccess := requireActingAdmin if permissionMiddleware != nil { metadataItemAccess = permissionMiddleware.RequireMetadataCurationForItem } r.Group(func(r chi.Router) { r.Use(metadataItemAccess) r.Post("/items/{id}/refresh-metadata", adminHandler.HandleRefreshItemMetadata) r.Patch("/items/{id}/metadata", adminHandler.HandleUpdateItemMetadata) if adminMatchHandler != nil { r.Post("/items/{id}/match/search", adminMatchHandler.HandleSearchItemMatchCandidates) r.Post("/items/{id}/match/apply", adminMatchHandler.HandleApplyItemMatch) } if metadataAIHandler != nil { r.Post("/items/{id}/metadata-translation", metadataAIHandler.HandleTranslate) r.Get("/items/{id}/metadata-translation/jobs", metadataAIHandler.HandleListJobs) r.Post("/items/{id}/metadata-translation/jobs/{job_id}/cancel", metadataAIHandler.HandleCancelJob) } }) if adminJobsHandler != nil { // Curators must poll their own item-refresh jobs, so this stays outside // the admin-only group. HandleGet enforces per-job authorization. r.Get("/jobs/{id}", adminJobsHandler.HandleGet) } r.Group(func(r chi.Router) { r.Use(requireActingAdmin) r.Get("/users", adminHandler.HandleListUsers) r.Post("/users", adminHandler.HandleCreateUser) r.Get("/users/{id}", adminHandler.HandleGetUser) r.Put("/users/{id}", adminHandler.HandleUpdateUser) r.Delete("/users/{id}", adminHandler.HandleDeleteUser) r.Post("/users/{id}/impersonate", adminHandler.HandleImpersonateUser) r.Get("/users/{id}/profiles", adminHandler.HandleListUserProfiles) r.Get("/users/{id}/settings", adminHandler.HandleListUserSettings) r.Get("/users/{id}/settings/{key}", adminHandler.HandleGetUserSetting) r.Put("/users/{id}/settings/{key}", adminHandler.HandleUpdateUserSetting) r.Delete("/users/{id}/settings/{key}", adminHandler.HandleDeleteUserSetting) r.Get("/users/{id}/device-settings", adminHandler.HandleListUserDeviceSettings) r.Get("/users/{id}/device-settings/{key}", adminHandler.HandleListUserDeviceSettingsByKey) r.Put("/users/{id}/profiles/{profile_id}/device-settings/{key}/{device_id}", adminHandler.HandleUpdateUserDeviceSetting) r.Delete("/users/{id}/device-settings/{key}", adminHandler.HandleDeleteUserDeviceSettingsByKey) r.Delete("/users/{id}/profiles/{profile_id}/device-settings/{key}/{device_id}", adminHandler.HandleDeleteUserDeviceSetting) r.Delete("/users/{id}/profiles/{profile_id}/devices/{device_id}/settings", adminHandler.HandleDeleteAllUserDeviceSettings) r.Get("/devices", adminHandler.HandleListDevices) r.Get("/devices/{user_id}/{device_id}", adminHandler.HandleGetDevice) r.Get("/sessions", adminHandler.HandleListSessions) r.Get("/playback-history", adminHandler.HandleListPlaybackHistory) r.Get("/unmatched", adminHandler.HandleListUnmatched) r.Get("/stats", adminHandler.HandleGetStats) r.Get("/server/status", adminHandler.HandleGetServerStatus) r.Post("/server/restart", serverControlHandler.HandleRestart) r.Get("/jellyfin-compat/status", adminHandler.HandleGetJellyfinCompatStatus) r.Patch("/jellyfin-compat/settings", adminHandler.HandleUpdateJellyfinCompatSettings) r.Post("/jellyfin-compat/web/install", adminHandler.HandleInstallJellyfinCompatWeb) r.Post("/jellyfin-compat/web/update", adminHandler.HandleUpdateJellyfinCompatWeb) r.Post("/jellyfin-compat/web/remove", adminHandler.HandleRemoveJellyfinCompatWeb) r.Get("/settings/sensitive-status", adminHandler.HandleGetSensitiveStatus) r.Post("/settings/check/{kind}", adminHandler.HandleCheckSettingsConnection) if sectionSettingsHandler != nil { r.Get("/settings/sections", sectionSettingsHandler.HandleGet) r.Put("/settings/sections", sectionSettingsHandler.HandlePut) } r.Get("/settings/{key}", adminHandler.HandleGetSetting) r.Get("/settings", adminHandler.HandleGetSettings) r.Put("/settings/{key}", adminHandler.HandleUpdateSetting) if brandingHandler != nil { // Branding image upload/delete (scalar branding // fields use the generic settings PUT above). r.Post("/branding/assets/{kind}", brandingHandler.HandleUploadAsset) r.Delete("/branding/assets/{kind}", brandingHandler.HandleDeleteAsset) } if settingsRepo != nil { emailHandler := handlers.NewEmailHandler(mail.NewSMTPSender(settingsRepo)) r.Post("/email/test", emailHandler.HandleTest) } if discordNotificationsHandler != nil { r.Post("/notifications/discord/test", discordNotificationsHandler.HandleAdminTest) } if deps.Notifications != nil && deps.Notifications.ServerChannels != nil { serverChannelsHandler := handlers.NewAdminServerChannelsHandler(deps.Notifications) r.Route("/notifications/server-channels", func(r chi.Router) { r.Get("/", serverChannelsHandler.HandleList) r.Post("/", serverChannelsHandler.HandleCreate) r.Put("/{id}", serverChannelsHandler.HandleUpdate) r.Delete("/{id}", serverChannelsHandler.HandleDelete) r.Post("/{id}/rotate-secret", serverChannelsHandler.HandleRotateSecret) r.Post("/{id}/test", serverChannelsHandler.HandleTest) }) } if adminIntroHandler != nil { r.Post("/items/{id}/refresh-markers", adminIntroHandler.HandleRefreshEpisodeMarkers) r.Post("/items/{id}/redetect-intro", adminIntroHandler.HandleRedetectEpisodeIntro) } if markersHandler != nil { // Marker read/write/clear live on the authenticated // /markers routes; writes require marker_edit. // Contribution and audit history stay admin operations. r.Post("/files/{fileId}/contribute", markersHandler.HandleContributeFile) r.Get("/files/{fileId}/contributions", markersHandler.HandleListFileContributions) r.Get("/markers/history", markersHandler.HandleListMarkerHistory) r.Get("/markers/files/{fileId}/history", markersHandler.HandleListFileMarkerHistory) r.Get("/markers/items/{id}/history", markersHandler.HandleListItemMarkerHistory) } if adminMarkerProvidersHandler != nil { r.Get("/markers/providers", adminMarkerProvidersHandler.HandleListProviders) r.Put("/markers/providers/{provider}", adminMarkerProvidersHandler.HandleUpdateProvider) r.Post("/markers/providers/{provider}/validate", adminMarkerProvidersHandler.HandleValidateProvider) } if peopleHandler != nil { r.Post("/people/{id}/refresh", peopleHandler.HandleAdminRefreshPerson) r.Patch("/people/{id}", peopleHandler.HandleAdminUpdatePerson) } if adminImageHandler != nil { r.Get("/items/{id}/images", adminImageHandler.HandleGetItemImages) r.Post("/items/{id}/images/apply", adminImageHandler.HandleApplyItemImage) } filesystemHandler := handlers.NewFilesystemHandler() r.Get("/filesystem/browse", filesystemHandler.HandleBrowse) if catalogSeedHandler != nil { r.Route("/catalog", func(r chi.Router) { r.Post("/export", catalogSeedHandler.HandleExport) r.Post("/export-jobs", catalogSeedHandler.HandleCreateExportJob) r.Post("/export-jobs/{id}/publish", catalogSeedHandler.HandlePublishExportJob) r.Post("/import-jobs", catalogSeedHandler.HandleCreateImportJob) r.Get("/import-sources", catalogSeedHandler.HandleListImportSources) r.Get("/local-import-sources", catalogSeedHandler.HandleListLocalImportSources) r.Post("/import", catalogSeedHandler.HandleImport) }) } if adminJobsHandler != nil { r.Route("/jobs", func(r chi.Router) { r.Get("/", adminJobsHandler.HandleList) r.Post("/{id}/cancel", adminJobsHandler.HandleCancel) }) } if deps.PluginService != nil && deps.PluginUserConfig != nil { pluginHandler := handlers.NewPluginHandler( plugins.NewRepositoryStore(deps.DB), plugins.NewInstallationStore(deps.DB), plugins.NewRuntimeConfigStore(deps.DB), deps.PluginService, deps.PluginUserConfig, deps.PluginHTTPProxy, metadata.NewChainRepository(deps.DB), deps.PluginImageResolver, restartStatus, ) r.Route("/plugins", func(r chi.Router) { r.Get("/repositories", pluginHandler.HandleListRepositories) r.Post("/repositories", pluginHandler.HandleCreateRepository) r.Put("/repositories/{id}", pluginHandler.HandleUpdateRepository) r.Delete("/repositories/{id}", pluginHandler.HandleDeleteRepository) r.Get("/catalog", pluginHandler.HandleCatalog) r.Get("/installations", pluginHandler.HandleListInstallations) r.Post("/installations", pluginHandler.HandleCreateInstallation) r.Post("/uploads", pluginHandler.HandleUploadInstallation) r.Post("/uploads/chunked", pluginHandler.HandleCreateChunkedUpload) r.Put("/uploads/chunked/{upload_id}/chunks/{chunk_index}", pluginHandler.HandleUploadChunk) r.Post("/uploads/chunked/{upload_id}/complete", pluginHandler.HandleCompleteChunkedUpload) r.Delete("/uploads/chunked/{upload_id}", pluginHandler.HandleCancelChunkedUpload) r.Put("/installations/{id}", pluginHandler.HandleUpdateInstallation) r.Post("/installations/{id}/update", pluginHandler.HandleApplyUpdate) r.Post("/installations/{id}/config/test", pluginHandler.HandleTestInstallationConfig) r.Put("/installations/{id}/config", pluginHandler.HandlePutInstallationConfig) r.Put("/installations/{id}/auth-binding", pluginHandler.HandlePutAuthBinding) r.Put("/installations/{id}/task-bindings/{capability_id}", pluginHandler.HandlePutTaskBinding) r.Delete("/installations/{id}", pluginHandler.HandleDeleteInstallation) }) } if historyImportHandler != nil { r.Route("/history-import-sources", func(r chi.Router) { r.Get("/", historyImportHandler.HandleAdminListSources) r.Post("/", historyImportHandler.HandleAdminCreateSource) r.Put("/{id}", historyImportHandler.HandleAdminUpdateSource) r.Delete("/{id}", historyImportHandler.HandleAdminDeleteSource) }) r.Route("/history-imports", func(r chi.Router) { r.Post("/plex/login", historyImportHandler.HandleAdminPlexLogin) r.Put("/sources/{id}/token", historyImportHandler.HandleAdminSetSourceToken) r.Delete("/sources/{id}/token", historyImportHandler.HandleAdminClearSourceToken) r.Get("/sources/{id}/users", historyImportHandler.HandleAdminDiscoverUsers) r.Post("/sources/{id}/bulk-run", historyImportHandler.HandleAdminBulkRun) r.Get("/mappings", historyImportHandler.HandleAdminListMappings) r.Post("/mappings", historyImportHandler.HandleAdminCreateMapping) r.Put("/mappings/{id}", historyImportHandler.HandleAdminUpdateMapping) r.Delete("/mappings/{id}", historyImportHandler.HandleAdminDeleteMapping) r.Post("/mappings/{id}/run", historyImportHandler.HandleAdminCreateRun) r.Get("/runs", historyImportHandler.HandleAdminListRuns) r.Get("/runs/{id}", historyImportHandler.HandleAdminGetRun) r.Post("/runs/{id}/cancel", historyImportHandler.HandleAdminCancelRun) }) } if sectionHandler != nil { r.Route("/sections", func(r chi.Router) { r.Get("/", sectionHandler.HandleListSections) r.Post("/", sectionHandler.HandleCreateSection) r.Post("/preview", sectionHandler.HandlePreview) r.Put("/reorder", sectionHandler.HandleReorderSections) r.Post("/restore-defaults", sectionHandler.HandleRestoreDefaults) r.Put("/{id}", sectionHandler.HandleUpdateSection) r.Delete("/{id}", sectionHandler.HandleDeleteSection) if sectionBulkHandler != nil { r.Post("/bulk-create", sectionBulkHandler.HandleBulkCreate) } }) } if libraryCollectionHandler != nil { collectionTemplateHandler := handlers.NewCollectionTemplateHandler(nil) r.Route("/collections", func(r chi.Router) { r.Get("/", libraryCollectionHandler.HandleListAdminCollections) r.Get("/templates", collectionTemplateHandler.HandleListTemplates) r.Get("/template-bundles", libraryCollectionHandler.HandleListTemplateBundles) r.Post("/template-bundles/{bundleID}/apply", libraryCollectionHandler.HandleApplyTemplateBundle) r.Post("/template-bundles/{bundleID}/apply-job", libraryCollectionHandler.HandleApplyTemplateBundleJob) r.Post("/", libraryCollectionHandler.HandleCreateAdminCollection) r.Post("/preview", libraryCollectionHandler.HandlePreviewAdminCollection) r.Put("/order", libraryCollectionHandler.HandleReorderAdminCollections) r.Put("/{id}", libraryCollectionHandler.HandleUpdateAdminCollection) r.Delete("/{id}", libraryCollectionHandler.HandleDeleteAdminCollection) r.Post("/{id}/sync", libraryCollectionHandler.HandleSyncAdminCollection) r.Delete("/{id}/image", libraryCollectionHandler.HandleDeleteCollectionImage) r.Put("/{id}/items/order", libraryCollectionHandler.HandleReorderAdminCollectionItems) r.Put("/{id}/items/{item_id}", libraryCollectionHandler.HandleAddAdminCollectionItem) r.Delete("/{id}/items/{item_id}", libraryCollectionHandler.HandleRemoveAdminCollectionItem) r.Post("/import/mdblist", libraryCollectionHandler.HandleImportMDBList) r.Post("/import/tmdb", libraryCollectionHandler.HandleImportTMDBCollection) r.Post("/import/trakt", libraryCollectionHandler.HandleImportTraktCollection) }) } if libraryCollectionGroupHandler != nil { r.Route("/libraries/{libraryID}/collection-groups", func(r chi.Router) { r.Get("/", libraryCollectionGroupHandler.HandleListGroups) r.Post("/", libraryCollectionGroupHandler.HandleCreateGroup) r.Put("/reorder", libraryCollectionGroupHandler.HandleReorderGroups) }) r.Route("/collection-groups", func(r chi.Router) { r.Put("/{id}", libraryCollectionGroupHandler.HandleUpdateGroup) r.Delete("/{id}", libraryCollectionGroupHandler.HandleDeleteGroup) r.Put("/{groupID}/collections/reorder", libraryCollectionGroupHandler.HandleReorderCollectionsInGroup) }) } if deps.NodeRepo != nil { jwtSecret := "" if deps.Config != nil { jwtSecret = deps.Config.Auth.JWTSecret } nodeHandler := handlers.NewNodeHandler(deps.NodeRepo, deps.ProxyPool, deps.TranscodePool, deps.NodeRepo, deps.EventBus, deps.RedisClient, jwtSecret) r.Route("/nodes", func(r chi.Router) { r.Get("/", nodeHandler.HandleListNodes) r.Post("/", nodeHandler.HandleCreateNode) r.Put("/{id}", nodeHandler.HandleUpdateNode) r.Delete("/{id}", nodeHandler.HandleDeleteNode) r.Post("/{id}/check", nodeHandler.HandleCheckNode) r.Post("/force-reload", nodeHandler.HandleForceReloadNodes) r.Post("/{id}/force-reload", nodeHandler.HandleForceReloadNode) }) // Live node sessions (reads from Redis) // Note: /admin/sessions is already used for playback sessions from PostgreSQL. r.Get("/node-sessions", nodeHandler.HandleListSessions) } // System inspection. { sysJWTSecret := "" sysFFmpegPath := "" if deps.Config != nil { sysJWTSecret = deps.Config.Auth.JWTSecret sysFFmpegPath = deps.Config.Playback.FFmpegPath } systemHandler := handlers.NewSystemHandler(deps.TranscodePool, sysJWTSecret, sysFFmpegPath) r.Route("/system", func(r chi.Router) { r.Get("/build", systemHandler.HandleBuildInfo) r.Get("/hw-accel", systemHandler.HandleHWAccel) }) } if deps.RecWorker != nil { adminRecsHandler := handlers.NewAdminRecommendationsHandler(deps.RecWorker) r.Route("/recommendations", func(r chi.Router) { r.Get("/status", adminRecsHandler.HandleStatus) r.Post("/trigger/embeddings", adminRecsHandler.HandleTriggerEmbeddings) r.Post("/trigger/taste-profiles", adminRecsHandler.HandleTriggerTasteProfiles) r.Post("/trigger/cowatch", adminRecsHandler.HandleTriggerCowatch) r.Post("/trigger/recommendations", adminRecsHandler.HandleTriggerRecommendations) }) } if inviteCodeRepo != nil { inviteCodeHandler := handlers.NewInviteCodeHandler(inviteCodeRepo) r.Route("/invite-codes", func(r chi.Router) { r.Get("/", inviteCodeHandler.HandleListInviteCodes) r.Post("/", inviteCodeHandler.HandleCreateInviteCode) r.Put("/{id}", inviteCodeHandler.HandleUpdateInviteCode) r.Post("/{id}/top-up", inviteCodeHandler.HandleTopUpInviteCode) r.Delete("/{id}", inviteCodeHandler.HandleDeleteInviteCode) }) } if adminSubtitleHandler != nil { r.Route("/subtitle-providers", func(r chi.Router) { r.Get("/", adminSubtitleHandler.HandleListProviders) r.Route("/{provider}", func(r chi.Router) { r.Put("/", adminSubtitleHandler.HandleUpdateProvider) r.Post("/test", adminSubtitleHandler.HandleTestProvider) }) }) r.Route("/subtitles", func(r chi.Router) { r.Get("/", adminSubtitleHandler.HandleListDownloadedSubtitles) r.Route("/{id}", func(r chi.Router) { r.Patch("/", adminSubtitleHandler.HandlePatchDownloadedSubtitle) r.Get("/download", adminSubtitleHandler.HandleDownloadDownloadedSubtitle) r.Delete("/", adminSubtitleHandler.HandleDeleteDownloadedSubtitle) }) }) } // Rate limit admin routes. Mounted even when the limiter is not // running (deps.RateLimitMW == nil) so admins can always reach the // config; otherwise disabling rate limiting and restarting would // lock the settings page out of re-enabling it. if settingsRepo != nil { rateLimitHandler := handlers.NewRateLimitHandler(settingsRepo, deps.RateLimitMW, deps.EventBus, restartStatus) r.Route("/rate-limits", func(r chi.Router) { r.Get("/config", rateLimitHandler.HandleGetConfig) r.Put("/config", rateLimitHandler.HandleUpdateConfig) }) } if apiKeyRepo != nil { apiKeyHandler := handlers.NewAPIKeyHandler(apiKeyRepo) r.Get("/users/{userId}/api-keys", apiKeyHandler.HandleAdminListUserAPIKeys) r.Get("/api-keys", apiKeyHandler.HandleAdminListAllAPIKeys) r.Post("/api-keys", apiKeyHandler.HandleAdminCreateAPIKey) r.Delete("/api-keys/{id}", apiKeyHandler.HandleAdminDeleteAPIKey) r.Put("/api-keys/{id}/tier", apiKeyHandler.HandleAdminUpdateTier) } if requestHandler != nil { r.Get("/requests", requestHandler.HandleAdminList) r.Post("/requests/{id}/approve", requestHandler.HandleApprove) r.Post("/requests/{id}/decline", requestHandler.HandleDecline) r.Post("/requests/{id}/cancel", requestHandler.HandleCancel) r.Post("/requests/{id}/retry", requestHandler.HandleRetry) r.Get("/request-settings", requestHandler.HandleGetSettings) r.Put("/request-settings", requestHandler.HandleUpdateSettings) r.Get("/request-users/{user_id}/limit", requestHandler.HandleGetUserLimit) r.Put("/request-users/{user_id}/limit", requestHandler.HandleUpdateUserLimit) r.Get("/request-integrations", requestHandler.HandleListIntegrations) r.Post("/request-integrations", requestHandler.HandleCreateIntegration) r.Put("/request-integrations/{id}", requestHandler.HandleUpdateIntegration) r.Delete("/request-integrations/{id}", requestHandler.HandleDeleteIntegration) r.Post("/request-integrations/{id}/options", requestHandler.HandleLoadIntegrationOptions) } if autoscanHandler != nil { r.Get("/autoscan/settings", autoscanHandler.HandleGetSettings) r.Put("/autoscan/settings", autoscanHandler.HandleUpdateSettings) r.Get("/autoscan/connections", autoscanHandler.HandleListConnections) r.Post("/autoscan/connections", autoscanHandler.HandleCreateConnection) r.Put("/autoscan/connections/{id}", autoscanHandler.HandleUpdateConnection) r.Delete("/autoscan/connections/{id}", autoscanHandler.HandleDeleteConnection) r.Post("/autoscan/connections/test", autoscanHandler.HandleTestConnection) r.Get("/autoscan/scan-source-plugins", autoscanHandler.HandleListAvailableScanSources) r.Get("/autoscan/sources", autoscanHandler.HandleListSources) r.Post("/autoscan/sources", autoscanHandler.HandleCreateSource) r.Put("/autoscan/sources/{id}", autoscanHandler.HandleUpdateSource) r.Delete("/autoscan/sources/{id}", autoscanHandler.HandleDeleteSource) r.Get("/autoscan/sources/{id}/rewrite-suggestions", autoscanHandler.HandleRewriteSuggestions) r.Get("/autoscan/scans", autoscanHandler.HandleListScans) r.Get("/autoscan/events", autoscanHandler.HandleListEvents) r.Post("/autoscan/trigger", autoscanHandler.HandleTrigger) r.Get("/autoscan/status", autoscanHandler.HandleStatus) } if deps.ActivityLogRepo != nil { adminIPHandler := handlers.NewAdminIPHandler(deps.ActivityLogRepo) r.Get("/users/{id}/ips", adminIPHandler.HandleGetUserIPs) r.Get("/ips", adminIPHandler.HandleGetIPUsers) } if deps.OpsLogRepo != nil && deps.ActivityLogRepo != nil { adminLogsHandler := handlers.NewAdminLogsHandler(deps.OpsLogRepo, deps.ActivityLogRepo, deps.LogStreamHub) r.Get("/logs/app", adminLogsHandler.HandleListOperationalLogs) r.Get("/logs/audit", adminLogsHandler.HandleListAuditLogs) r.Get("/logs/ws", adminLogsHandler.HandleLogStreamWebSocket) } if adminPlaybackControlHandler != nil { r.Post("/sessions/{session_id}/pause", adminPlaybackControlHandler.HandlePauseSession) r.Post("/sessions/{session_id}/resume", adminPlaybackControlHandler.HandleResumeSession) r.Post("/sessions/{session_id}/stop", adminPlaybackControlHandler.HandleStopSession) r.Post("/sessions/{session_id}/terminate", adminPlaybackControlHandler.HandleTerminateSession) r.Post("/sessions/{session_id}/message", adminPlaybackControlHandler.HandleMessageSession) } if deps.TaskManager != nil { taskHistoryRepo := repository.NewPgExecutionRepository(deps.DB) taskMetrics := handlers.NewTaskMetricsService(metadata.NewRefreshDebtRepository(deps.DB)) taskHandler := handlers.NewTaskHandler(deps.TaskManager, taskHistoryRepo, taskMetrics) r.Route("/tasks", func(r chi.Router) { r.Get("/", taskHandler.HandleListTasks) r.Get("/{key}", taskHandler.HandleGetTask) r.Get("/{key}/metrics", taskHandler.HandleGetMetrics) r.Post("/{key}/run", taskHandler.HandleRunTask) r.Post("/{key}/cancel", taskHandler.HandleCancelTask) r.Put("/{key}/triggers", taskHandler.HandleUpdateTriggers) r.Get("/{key}/history", taskHandler.HandleGetHistory) }) } }) }) } }) } }) return r } // pgSubtitleMediaResolver implements handlers.SubtitleMediaResolver using a direct PG query. type pgSubtitleMediaResolver struct { pool *pgxpool.Pool } func (r *pgSubtitleMediaResolver) GetMediaFileWithMetadata(ctx context.Context, fileID int) (*handlers.MediaFileMetadata, error) { var meta handlers.MediaFileMetadata err := r.pool.QueryRow(ctx, ` SELECT mf.id, mf.file_path, COALESCE(mf.file_size, 0), COALESCE(mf.file_hash, ''), COALESCE(mf.resolution, ''), COALESCE(mf.codec_video, ''), COALESCE(mf.codec_audio, ''), mi.title, COALESCE(mi.year, 0), COALESCE(mi.imdb_id, ''), COALESCE(e.season_number, 0), COALESCE(e.episode_number, 0) FROM media_files mf JOIN media_items mi ON mi.content_id = mf.content_id LEFT JOIN episodes e ON e.content_id = mf.episode_id WHERE mf.id = $1 `, fileID).Scan( &meta.FileID, &meta.FilePath, &meta.FileSize, &meta.FileHash, &meta.Resolution, &meta.VideoCodec, &meta.AudioCodec, &meta.Title, &meta.Year, &meta.IMDbID, &meta.Season, &meta.Episode, ) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, nil } return nil, err } return &meta, nil } func resolveOptionalPluginAccess( r *http.Request, jwtService *auth.JWTService, sessionRepo *auth.SessionRepository, ) (bool, bool) { authenticated, admin, _ := resolveOptionalPluginAccessUser(r, jwtService, sessionRepo, nil, nil) return authenticated, admin } // resolveOptionalPluginAccessUser is like resolveOptionalPluginAccess but also // returns the authenticated user's ID, and accepts API-key bearer tokens // (sa_*) when apiKeyRepo + userRepo are provided. func resolveOptionalPluginAccessUser( r *http.Request, jwtService *auth.JWTService, sessionRepo *auth.SessionRepository, apiKeyRepo *auth.APIKeyRepository, userRepo *auth.UserRepository, ) (bool, bool, int) { if jwtService == nil || sessionRepo == nil { return false, false, 0 } token := "" if header := r.Header.Get("Authorization"); header != "" { parts := strings.SplitN(header, " ", 2) if len(parts) == 2 && strings.EqualFold(parts[0], "bearer") { token = strings.TrimSpace(parts[1]) } } if token == "" { token = strings.TrimSpace(r.URL.Query().Get("token")) } if token == "" { if cookie, err := r.Cookie(auth.PluginAccessCookieName); err == nil { token = strings.TrimSpace(cookie.Value) } } if token == "" { return false, false, 0 } if strings.HasPrefix(token, "sa_") { if apiKeyRepo == nil || userRepo == nil { return false, false, 0 } apiKey, err := apiKeyRepo.GetByKey(r.Context(), token) if err != nil { return false, false, 0 } user, err := userRepo.GetByID(r.Context(), apiKey.UserID) if err != nil || !user.Enabled { return false, false, 0 } return true, user.Role == "admin", user.ID } claims, err := jwtService.ValidateToken(token) if err != nil || (claims.TokenType != auth.TokenTypeAccess && claims.TokenType != auth.TokenTypePluginAccess) { return false, false, 0 } valid, err := sessionRepo.IsValid(r.Context(), claims.SessionID) if err != nil || !valid { return false, false, 0 } return true, claims.Role == "admin", claims.UserID } // NewTMDBCollectionFetcher creates a TMDBCollectionFetcher from an API key. // Exported so main.go can construct it for the collection sync scheduler. func NewTMDBCollectionFetcher(apiKey string) catalog.TMDBCollectionFetcher { return &tmdbCollectionAdapter{ client: tmdb.NewClient(apiKey, 40), } } // tmdbCollectionAdapter adapts the tmdb.Client to the catalog.TMDBCollectionFetcher interface. type tmdbCollectionAdapter struct { client *tmdb.Client } func (a *tmdbCollectionAdapter) GetCollectionPreset(ctx context.Context, preset, mediaType, timeWindow string, limit int) ([]catalog.TMDBCollectionEntry, error) { results, err := a.client.GetCollectionPreset(ctx, preset, mediaType, timeWindow, limit) if err != nil { return nil, err } entries := make([]catalog.TMDBCollectionEntry, len(results)) for i, r := range results { entry := catalog.TMDBCollectionEntry{ ID: r.ID, MediaType: r.MediaType, Title: r.Title, } // Fetch external IDs (IMDb, TVDB) for better matching against local library. if externalIDs, err := a.client.GetExternalIDs(ctx, r.MediaType, r.ID); err == nil && externalIDs != nil { entry.IMDbID = externalIDs.IMDbID entry.TVDBID = externalIDs.TVDBID } entries[i] = entry } return entries, nil } // tmdbFranchiseAdapter adapts tmdb.Client to catalog.TMDBCollectionByIDFetcher // for the `tmdb_collection` sync mode. Like the preset adapter, it enriches // each TMDB collection part with external IDs so the catalog matcher can fall // back to IMDb/TVDB when a local item lacks a TMDB ID. type tmdbFranchiseAdapter struct { client *tmdb.Client } func (a *tmdbFranchiseAdapter) GetCollection(ctx context.Context, id int) ([]catalog.TMDBCollectionEntry, error) { collection, err := a.client.GetCollection(ctx, id) if err != nil { return nil, err } if collection == nil { return nil, nil } entries := make([]catalog.TMDBCollectionEntry, len(collection.Parts)) for i, p := range collection.Parts { mediaType := p.MediaType if mediaType == "" { mediaType = "movie" } entry := catalog.TMDBCollectionEntry{ ID: p.ID, MediaType: mediaType, Title: p.Title, } if externalIDs, err := a.client.GetExternalIDs(ctx, mediaType, p.ID); err == nil && externalIDs != nil { entry.IMDbID = externalIDs.IMDbID entry.TVDBID = externalIDs.TVDBID } entries[i] = entry } return entries, nil } // tmdbDiscoverAdapter adapts tmdb.Client to catalog.TMDBDiscoverFetcher for // the `tmdb_discover` sync mode. Like the preset adapter, it enriches each // result with external IDs so the catalog matcher can fall back to IMDb/TVDB // when a local item lacks a TMDB ID. type tmdbDiscoverAdapter struct { client *tmdb.Client } func (a *tmdbDiscoverAdapter) Discover(ctx context.Context, mediaType string, params catalog.TMDBDiscoverParams, limit int) ([]catalog.TMDBCollectionEntry, error) { results, err := a.client.Discover(ctx, mediaType, tmdb.DiscoverParams{ WithGenres: params.WithGenres, WithoutGenres: params.WithoutGenres, SortBy: params.SortBy, VoteCountGte: params.VoteCountGte, VoteAverageGte: params.VoteAverageGte, ReleaseDateGte: params.ReleaseDateGte, ReleaseDateLte: params.ReleaseDateLte, Certifications: params.Certifications, CertificationLte: params.CertificationLte, WithRuntimeGte: params.WithRuntimeGte, WithRuntimeLte: params.WithRuntimeLte, OriginalLanguage: params.OriginalLanguage, Limit: limit, }) if err != nil { return nil, err } entries := make([]catalog.TMDBCollectionEntry, len(results)) for i, r := range results { entry := catalog.TMDBCollectionEntry{ ID: r.ID, MediaType: r.MediaType, Title: r.Title, } if externalIDs, err := a.client.GetExternalIDs(ctx, r.MediaType, r.ID); err == nil && externalIDs != nil { entry.IMDbID = externalIDs.IMDbID entry.TVDBID = externalIDs.TVDBID } entries[i] = entry } return entries, nil } type traktCollectionAdapter struct { client *metatrakt.Client } func (a *traktCollectionAdapter) GetCollectionPreset(ctx context.Context, preset, mediaType string, limit int, accessToken string) ([]catalog.TraktCollectionEntry, error) { results, err := a.client.GetCollectionPreset(ctx, preset, mediaType, limit, accessToken) if err != nil { return nil, err } entries := make([]catalog.TraktCollectionEntry, len(results)) for i, r := range results { entries[i] = catalog.TraktCollectionEntry{ TraktID: r.TraktID, TMDBID: r.TMDBID, TVDBID: r.TVDBID, IMDbID: r.IMDbID, MediaType: r.MediaType, Title: r.Title, Year: r.Year, Rank: r.Rank, } } return entries, nil } // llmConfigFromServer derives the shared AI client config from the server // config. Used at construction and again on every config reload. func llmConfigFromServer(cfg *config.Config) llm.Config { return llm.Config{ BaseURL: cfg.AI.BaseURL, APIKey: cfg.AI.APIKey, ChatModel: cfg.AI.ChatModel, ASRBaseURL: cfg.AI.ASRBaseURL, ASRAPIKey: cfg.AI.ASRAPIKey, ASRModel: cfg.AI.ASRModel, } } // effectiveSubtitleAIConfig derives the subtitle AI service config from the // server config. A chat-only gateway (e.g. OpenRouter) cannot produce // timestamped transcriptions, so transcription is disabled rather than // letting every job fail; the settings API rejects such values for the ASR // URL, but the chat base URL legitimately may be one — this catches the // blank-ASR-URL fallback case. The second return is the offending endpoint // when that guard fired, empty otherwise. func effectiveSubtitleAIConfig(cfg *config.Config) (subtitleai.Config, string) { transcribeEnabled := cfg.SubtitleAI.TranscribeEnabled effectiveASRBase := cfg.AI.ASRBaseURL if effectiveASRBase == "" { effectiveASRBase = cfg.AI.BaseURL } disabledGateway := "" if transcribeEnabled && llm.IsChatOnlyGateway(effectiveASRBase) { transcribeEnabled = false disabledGateway = effectiveASRBase } return subtitleai.Config{ Configured: cfg.AI.BaseURL != "", TranslateEnabled: cfg.SubtitleAI.Enabled, TranscribeEnabled: transcribeEnabled, ChatModel: cfg.AI.ChatModel, ASRModel: cfg.AI.ASRModel, BatchSize: cfg.SubtitleAI.BatchSize, ContextNeighbors: cfg.SubtitleAI.ContextNeighbors, TranscribeQuotaJobs: cfg.SubtitleAI.TranscribeQuotaJobs, TranscribeQuotaPeriod: cfg.SubtitleAI.TranscribeQuotaPeriod, }, disabledGateway } func warnChatOnlyGateway(endpoint string) { slog.Warn("subtitle transcription disabled: the effective transcription endpoint is a chat-only gateway; "+ "set a Whisper-compatible Transcription base URL in AI Services", "endpoint", endpoint) } // metadataAIConfigFromServer derives the metadata translation service config // from the server config. Used at construction and on every config reload. func metadataAIConfigFromServer(cfg *config.Config) metadatatranslation.Config { return metadatatranslation.Config{ Enabled: cfg.MetadataAI.Enabled, Configured: cfg.AI.BaseURL != "", ChatModel: cfg.AI.ChatModel, OnView: cfg.MetadataAI.OnView, } }