Files
silo-server/internal/api/router.go
T
Silo Server MigrationandClaude Opus 4.7 38868b2128 feat(requests): add cancel endpoint and tighten state guards
Owners can now POST /requests/{id}/cancel to withdraw a pending
request; admins can cancel any active request that has not entered
the fulfillment pipeline. The route is mounted on both the user
group (with profile required) and the admin group. The cancelled
outcome was already reserved in the migration's CHECK constraint
but was unreachable from any handler.

Decline now also rejects approved requests — between Approve setting
StatusApproved and the reconciler picking the request up, an admin
could declare the request declined while submission was about to
fire. The reconciler's outcome filter would skip the request, but
the narrow window meant external state could diverge from Silo's
view. Refuse decline once a request is approved; callers should
wait for completion or use the failed/retry path.

Reconcile now emits a slog.WarnContext at the per-request failure
site with request id, media type, tmdb id, status, and integration
kind. Aggregated counters in ReconcileResult are unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 00:16:41 -04:00

2222 lines
93 KiB
Go

// 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/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/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/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"
"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/requests/radarr"
"github.com/Silo-Server/silo-server/internal/requests/sonarr"
"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/sections"
"github.com/Silo-Server/silo-server/internal/subtitles"
"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
BootstrapSensitiveConfigured map[string]bool
BootstrapSensitiveValues map[string]string
AppContext context.Context
DB *pgxpool.Pool
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)
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)
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)
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
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)
IntroRepository *intromarkers.Repository
IntroAnalyzer *intromarkers.Analyzer
MarkerRegistry *markers.Registry
MarkerResolver markers.ExternalIDResolver
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)
// UserCollectionSync handles per-profile imported collections (TMDB /
// Trakt / MDBList) — the user-facing analogue of CollectionService.
UserCollectionSync *usercollections.Service
UserCollectionScheduler *usercollections.Scheduler
// 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
}
// NewRouter creates a chi.Router with all middleware and routes mounted
// under /api/v1/.
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)
// 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)
// 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).
var settingsRepo *catalog.ServerSettingsRepo
if deps.DB != nil {
settingsRepo = catalog.NewServerSettingsRepo(deps.DB)
}
// 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 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,
)
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.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
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
if deps.DB != nil {
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)
}
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
}
requestSvc := mediarequests.NewService(
mediarequests.NewRepository(deps.DB),
tmdb.NewClient(tmdbAPIKey, 40),
mediarequests.NewCatalogPresence(itemRepo, providerIDRepo),
)
requestSvc.SetSecretResolver(settingsRepo)
requestSvc.SetFulfillmentAdapters(radarr.NewClient(nil), sonarr.NewClient(nil))
requestHandler = handlers.NewRequestsHandler(requestSvc)
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)
}
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)
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)
}
// Build playback handler if session manager is available.
var playbackHandler *handlers.PlaybackHandler
var adminPlaybackControlHandler *handlers.AdminPlaybackControlHandler
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 optional proxy/transcode pools and JWT secret for node-aware stream URLs.
if deps.ProxyPool != nil {
playbackHandler.ProxyPool = deps.ProxyPool
}
if deps.TranscodePool != nil {
playbackHandler.TranscodePool = deps.TranscodePool
}
if deps.Config != nil && deps.Config.Auth.JWTSecret != "" {
playbackHandler.JWTSecret = deps.Config.Auth.JWTSecret
}
if deps.Config != nil {
playbackHandler.FFmpegPath = deps.Config.Playback.FFmpegPath
playbackHandler.HWAccel = deps.Config.Playback.HWAccel
playbackHandler.TranscodeDir = deps.Config.Playback.TranscodeDir
if cleaned, err := playbackHandler.CleanupOrphanedTranscodes(); err != nil {
slog.Warn("playback transcode cleanup failed", "dir", playbackHandler.TranscodeDir, "error", err)
} else if cleaned > 0 {
slog.Info("playback transcode cleanup removed orphaned dirs", "dir", playbackHandler.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)
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)
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.FFmpegPath = deps.Config.Playback.FFmpegPath
}
// 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.ImpersonationService = authService
adminHandler.StatsSource = deps.AdminStatsProvider
adminHandler.RealtimeHub = deps.RealtimeHub
adminHandler.BootstrapSensitiveConfigured = deps.BootstrapSensitiveConfigured
adminHandler.BootstrapSensitiveValues = deps.BootstrapSensitiveValues
if settingsRepo != nil {
adminHandler.SettingsRepo = settingsRepo
}
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)
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
}
}
// Admin subtitle config handler only needs the DB repo — no S3 required.
var adminSubtitleHandler *handlers.AdminSubtitleHandler
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)
}
// 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)
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 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,
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
}
}
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)
recsHandler.Fetcher = recsFetcher
recsHandler.WatchTonightFetcher = recsFetcher
}
if detailSvc != nil {
recsHandler.DetailSvc = detailSvc
}
recsHandler.CalendarRepo = calendarRepo
recsHandler.EpisodeRepo = episodeRepo
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)
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), historyRepo, deps.UserStoreProvider)
webhookSyncSvc.SetStableIdentityResolver(historyIdentity)
webhookSyncHandler = handlers.NewWebhookSyncHandler(webhookSyncSvc)
}
}
r.Route("/api/v1", func(r chi.Router) {
r.Get("/health", healthHandler.ServeHTTP)
r.Get("/ready", readyHandler.ServeHTTP)
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)
r.Get("/theme/branding", themeHandler.HandleBranding)
// 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(apimw.RequireAdmin).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,
})
}
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)
})
}
})
}
// 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,
)
r.Get("/events/ws", eventsHandler.HandleWebSocket)
}
// Library management routes (admin-only).
if libraryHandler != nil {
r.Group(func(r chi.Router) {
r.Use(apimw.RequireAdmin)
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.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.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.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 {
calendarHandler := handlers.NewCalendarHandler(calendarRepo, detailSvc)
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)
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.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,
)
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)
})
}
// Subtitle search routes.
if subtitleSearchHandler != nil {
r.Route("/subtitles", func(r chi.Router) {
r.Post("/search", subtitleSearchHandler.HandleSearch)
r.Post("/download", subtitleSearchHandler.HandleDownload)
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)
}
// 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)
// 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 (admin-only).
if adminHandler != nil {
r.Route("/admin", func(r chi.Router) {
r.Use(apimw.RequireAdmin)
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("/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)
r.Post("/items/{id}/refresh-metadata", adminHandler.HandleRefreshItemMetadata)
r.Patch("/items/{id}/metadata", adminHandler.HandleUpdateItemMetadata)
if adminIntroHandler != nil {
r.Post("/items/{id}/refresh-markers", adminIntroHandler.HandleRefreshEpisodeMarkers)
r.Post("/items/{id}/redetect-intro", adminIntroHandler.HandleRedetectEpisodeIntro)
}
if peopleHandler != nil {
r.Post("/people/{id}/refresh", peopleHandler.HandleAdminRefreshPerson)
r.Patch("/people/{id}", peopleHandler.HandleAdminUpdatePerson)
}
if adminMatchHandler != nil {
r.Post("/items/{id}/match/search", adminMatchHandler.HandleSearchItemMatchCandidates)
r.Post("/items/{id}/match/apply", adminMatchHandler.HandleApplyItemMatch)
}
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.Get("/{id}", adminJobsHandler.HandleGet)
})
}
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,
)
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.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 := ""
if deps.Config != nil {
sysJWTSecret = deps.Config.Auth.JWTSecret
}
systemHandler := handlers.NewSystemHandler(deps.TranscodePool, sysJWTSecret)
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)
})
})
}
// Rate limit admin routes
if deps.RateLimitMW != nil && settingsRepo != nil {
rateLimitHandler := handlers.NewRateLimitHandler(settingsRepo, deps.RateLimitMW, deps.EventBus)
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.Put("/request-integrations", requestHandler.HandleUpdateIntegrations)
r.Post("/request-integrations/{kind}/options", requestHandler.HandleLoadIntegrationOptions)
}
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
}