The /admin/rate-limits/config routes were only mounted when the limiter was running, and the limiter is only constructed at boot when ratelimit.enabled is true. Disabling rate limiting from the UI and restarting therefore 404'd the settings page permanently, with no way to re-enable it without editing the database. Mount the routes whenever the settings store exists and make the handler tolerate a nil limiter: saves always persist, hot-reload is skipped when nothing is running, and the PUT response reports restart_required (enabling with no limiter built, or switching backend on a running one). GET now exposes active/active_backend so the UI can tell saved config apart from what the process is enforcing. The settings page shows a persistent restart banner driven by that server state, with the same restart flow as other settings pages via a RestartServerButton extracted from SaveBar. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2516 lines
107 KiB
Go
2516 lines
107 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/autoscan"
|
|
"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/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
|
|
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)
|
|
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)
|
|
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)
|
|
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
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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)
|
|
|
|
// 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).
|
|
// 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,
|
|
)
|
|
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),
|
|
)
|
|
}
|
|
}
|
|
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
|
|
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
|
|
}
|
|
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))
|
|
}
|
|
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)
|
|
}
|
|
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 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)
|
|
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.FFmpegPath = deps.Config.Playback.FFmpegPath
|
|
}
|
|
|
|
serverControlHandler := handlers.NewServerControlHandler(deps.RequestServerRestart, playbackCommandDispatcher)
|
|
|
|
// 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)
|
|
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.
|
|
var subtitleAIHandler *handlers.SubtitleAIHandler
|
|
if subtitleManager != nil && subtitleRepo != nil && deps.FileRepo != nil && deps.DB != nil && deps.Config != nil {
|
|
aiCfg := subtitleai.Config{
|
|
Enabled: deps.Config.SubtitleAI.Enabled,
|
|
BaseURL: deps.Config.SubtitleAI.BaseURL,
|
|
APIKey: deps.Config.SubtitleAI.APIKey,
|
|
ChatModel: deps.Config.SubtitleAI.ChatModel,
|
|
MaxConcurrentJobs: deps.Config.SubtitleAI.MaxConcurrentJobs,
|
|
BatchSize: deps.Config.SubtitleAI.BatchSize,
|
|
ContextNeighbors: deps.Config.SubtitleAI.ContextNeighbors,
|
|
}
|
|
var aiNotifier subtitleai.Notifier
|
|
if subtitleAINotifier != nil {
|
|
aiNotifier = subtitleAINotifier
|
|
}
|
|
aiService := subtitleai.NewService(
|
|
deps.AppContext,
|
|
aiCfg,
|
|
subtitleai.NewPgJobRepository(deps.DB),
|
|
subtitleai.NewLLMTranslator(subtitleai.NewClient(aiCfg), aiCfg.BatchSize, aiCfg.ContextNeighbors),
|
|
subtitleManager,
|
|
subtitleRepo,
|
|
deps.FileRepo,
|
|
aiNotifier,
|
|
deps.Config.Playback.FFmpegPath,
|
|
slog.Default(),
|
|
)
|
|
aiService.Recover()
|
|
subtitleAIHandler = handlers.NewSubtitleAIHandler(aiService)
|
|
}
|
|
|
|
// 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,
|
|
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)
|
|
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, 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)
|
|
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,
|
|
})
|
|
}
|
|
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)
|
|
})
|
|
}
|
|
})
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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(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.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.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)
|
|
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,
|
|
)
|
|
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 + 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.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 := apimw.RequireAdmin
|
|
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 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(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.Post("/server/restart", serverControlHandler.HandleRestart)
|
|
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 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,
|
|
)
|
|
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
|
|
// permanently lock the settings page out of re-enabling it.
|
|
if 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.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
|
|
}
|