package main import ( "context" "crypto/rand" "encoding/base64" "encoding/json" "errors" "flag" "fmt" "io/fs" "log" "log/slog" "net/http" "os" "os/signal" "path/filepath" "strconv" "strings" "syscall" "time" "github.com/google/uuid" "github.com/hashicorp/go-hclog" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "github.com/prometheus/client_golang/prometheus/promhttp" pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1" "github.com/Silo-Server/silo-server/internal/activitylog" "github.com/Silo-Server/silo-server/internal/adminjob" "github.com/Silo-Server/silo-server/internal/api" "github.com/Silo-Server/silo-server/internal/api/handlers" "github.com/Silo-Server/silo-server/internal/auth" "github.com/Silo-Server/silo-server/internal/cache" "github.com/Silo-Server/silo-server/internal/catalog" "github.com/Silo-Server/silo-server/internal/catalogseed" "github.com/Silo-Server/silo-server/internal/chapterthumbs" "github.com/Silo-Server/silo-server/internal/clientip" "github.com/Silo-Server/silo-server/internal/config" "github.com/Silo-Server/silo-server/internal/database" evt "github.com/Silo-Server/silo-server/internal/events" "github.com/Silo-Server/silo-server/internal/historyimport" "github.com/Silo-Server/silo-server/internal/imagecache" "github.com/Silo-Server/silo-server/internal/intromarkers" "github.com/Silo-Server/silo-server/internal/jellycompat" "github.com/Silo-Server/silo-server/internal/libraryingest" "github.com/Silo-Server/silo-server/internal/logfilter" "github.com/Silo-Server/silo-server/internal/logstream" "github.com/Silo-Server/silo-server/internal/markers" "github.com/Silo-Server/silo-server/internal/markers/introdb" "github.com/Silo-Server/silo-server/internal/mdblist" "github.com/Silo-Server/silo-server/internal/metadata" "github.com/Silo-Server/silo-server/internal/models" "github.com/Silo-Server/silo-server/internal/nodeconfig" "github.com/Silo-Server/silo-server/internal/nodepool" "github.com/Silo-Server/silo-server/internal/nodesessions" "github.com/Silo-Server/silo-server/internal/notifications" "github.com/Silo-Server/silo-server/internal/opslog" "github.com/Silo-Server/silo-server/internal/partman" "github.com/Silo-Server/silo-server/internal/playback" "github.com/Silo-Server/silo-server/internal/pluginhost" "github.com/Silo-Server/silo-server/internal/plugins" "github.com/Silo-Server/silo-server/internal/proxy" "github.com/Silo-Server/silo-server/internal/ratelimit" "github.com/Silo-Server/silo-server/internal/recommendations" mediarequests "github.com/Silo-Server/silo-server/internal/requests" "github.com/Silo-Server/silo-server/internal/requests/radarr" "github.com/Silo-Server/silo-server/internal/requests/sonarr" "github.com/Silo-Server/silo-server/internal/s3client" "github.com/Silo-Server/silo-server/internal/scanner" "github.com/Silo-Server/silo-server/internal/scanqueue" "github.com/Silo-Server/silo-server/internal/sections" "github.com/Silo-Server/silo-server/internal/server" "github.com/Silo-Server/silo-server/internal/subtitles" "github.com/Silo-Server/silo-server/internal/taskmanager" taskrepository "github.com/Silo-Server/silo-server/internal/taskmanager/repository" "github.com/Silo-Server/silo-server/internal/taskmanager/tasks" "github.com/Silo-Server/silo-server/internal/taskmanager/triggers" "github.com/Silo-Server/silo-server/internal/transcodenode" "github.com/Silo-Server/silo-server/internal/usercollections" "github.com/Silo-Server/silo-server/internal/userdb" "github.com/Silo-Server/silo-server/internal/userstore" "github.com/Silo-Server/silo-server/internal/userstore/pgstore" "github.com/Silo-Server/silo-server/internal/watchstate" "github.com/Silo-Server/silo-server/internal/watchsync" watchmdblist "github.com/Silo-Server/silo-server/internal/watchsync/providers/mdblist" "github.com/Silo-Server/silo-server/internal/watchsync/providers/simkl" "github.com/Silo-Server/silo-server/internal/watchsync/providers/trakt" "github.com/Silo-Server/silo-server/internal/worker" "github.com/Silo-Server/silo-server/migrations" siloweb "github.com/Silo-Server/silo-server/web" ) // resolveNodeIdentity returns a stable node identifier used by the // heartbeat writer, reconciler, and shutdown cleanup. Resolution order: // SILO_NODE_NAME > NODE_NAME > os.Hostname(). func resolveNodeIdentity() string { if v := os.Getenv("SILO_NODE_NAME"); v != "" { return v } if v := os.Getenv("NODE_NAME"); v != "" { return v } h, _ := os.Hostname() return h } func resolvePluginCacheDir() string { if v := strings.TrimSpace(os.Getenv("SILO_PLUGIN_CACHE_DIR")); v != "" { return v } return filepath.Join(os.TempDir(), "silo-plugins") } func buildBaseHandler(format string, level slog.Level) slog.Handler { opts := &slog.HandlerOptions{Level: level} if strings.EqualFold(format, "json") { return slog.NewJSONHandler(os.Stderr, opts) } return slog.NewTextHandler(os.Stderr, opts) } func mustGetSetting(store interface { Get(context.Context, string) (string, error) }, ctx context.Context, key, fallback string) string { value, err := store.Get(ctx, key) if err != nil || strings.TrimSpace(value) == "" { return fallback } return value } func configureOperationalLogging( ctx context.Context, pool *pgxpool.Pool, settingsRepo *catalog.ServerSettingsRepo, redisCfg config.RedisConfig, logStreamHub *logstream.Hub, baseHandler slog.Handler, logQuiet string, nodeID string, ) (opslog.Writer, *opslog.Repo, *partman.Manager) { if err := opslog.SeedDefaults(ctx, settingsRepo); err != nil { log.Fatalf("seed opslog defaults: %v", err) } opsPM := partman.NewManager(pool, "operational_logs", partman.Daily, 3) if err := opsPM.EnsureFuturePartitions(ctx); err != nil { log.Fatalf("ensure operational log partitions: %v", err) } var operationalWriter opslog.Writer operationalConsumer := opslog.NewConsumer(pool, nil, logStreamHub) if redisCfg.URL != "" { redisClient, redisErr := cache.NewRedisClient(redisCfg) if redisErr == nil && redisClient != nil { operationalWriter = opslog.NewRedisWriter(redisClient) operationalConsumer = opslog.NewConsumer(pool, redisClient, logStreamHub) go operationalConsumer.RunRedis(ctx) } } if operationalWriter == nil { memWriter := opslog.NewMemoryWriter(10000) operationalWriter = memWriter go operationalConsumer.RunMemory(ctx, memWriter.Chan()) } opsCaptureLevel := slog.LevelInfo switch strings.ToLower(strings.TrimSpace(mustGetSetting(settingsRepo, ctx, "opslog.capture_level", "info"))) { case "debug": opsCaptureLevel = slog.LevelDebug case "warn", "warning": opsCaptureLevel = slog.LevelWarn case "error": opsCaptureLevel = slog.LevelError } filteredHandler := logfilter.New(baseHandler, logQuiet) slog.SetDefault(slog.New(opslog.NewHandler(filteredHandler, operationalWriter, opsCaptureLevel, nodeID))) return operationalWriter, opslog.NewRepo(pool), opsPM } func main() { envFile := flag.String("env", ".env", "path to .env bootstrap file") flag.Parse() ctx := context.Background() // Step 1: Bootstrap from .env bc, err := config.LoadBootstrap(*envFile) if err != nil { log.Fatalf("bootstrap: %v", err) } // Step 2: Connect to PostgreSQL (bootstrap pool with default max connections) bootstrapDBCfg := config.DatabaseConfig{URL: bc.DatabaseURL, MaxConnections: 20} pool, err := database.NewPool(ctx, bootstrapDBCfg) if err != nil { log.Fatalf("database pool: %v", err) } defer pool.Close() slog.Info("connected to PostgreSQL") // Run migrations only for integrated/api modes. Proxy and transcode nodes // should never alter the schema — they may scale independently and would // race or apply migrations before the primary node is deliberately upgraded. if bc.Mode == "integrated" || bc.Mode == "api" || bc.Mode == "" { migCtx, migCancel := context.WithTimeout(ctx, 5*time.Minute) if migErr := database.RunMigrations(migCtx, pool, migrations.FS, "."); migErr != nil { migCancel() log.Fatalf("failed to run migrations: %v", migErr) } migCancel() slog.Info("database migrations applied") } // Step 3: Load settings from DB settingsRepo := catalog.NewServerSettingsRepo(pool) settings, err := settingsRepo.GetAll(ctx) if err != nil { log.Fatalf("loading settings: %v", err) } // Step 4: YAML import (one-time) yamlPath := "silo.yaml" if _, yamlErr := os.Stat(yamlPath); yamlErr == nil { if settings["_yaml_imported"] == "" { yamlSettings, importErr := config.YAMLToSettingsMap(yamlPath) if importErr != nil { log.Printf("WARN: could not import YAML config: %v", importErr) } else { for k, v := range yamlSettings { if err := settingsRepo.Set(ctx, k, v); err != nil { log.Printf("WARN: failed to import setting %s: %v", k, err) } } if err := settingsRepo.Set(ctx, "_yaml_imported", "true"); err != nil { slog.Warn("failed to set yaml import flag", "error", err) } log.Println("Imported config from silo.yaml — this file is no longer used") settings, _ = settingsRepo.GetAll(ctx) } } } // Step 5: Auto-generate secrets if settings["auth.jwt_secret"] == "" { secret := make([]byte, 32) if _, err := rand.Read(secret); err != nil { log.Fatalf("generating jwt secret: %v", err) } encoded := base64.StdEncoding.EncodeToString(secret) if err := settingsRepo.Set(ctx, "auth.jwt_secret", encoded); err != nil { slog.Warn("failed to persist generated JWT secret", "error", err) } settings["auth.jwt_secret"] = encoded } if settings["jellyfin_compat.server_id"] == "" { serverID := uuid.NewSHA1(uuid.NameSpaceURL, []byte("https://silo.local/jellycompat")).String() if err := settingsRepo.Set(ctx, "jellyfin_compat.server_id", serverID); err != nil { slog.Warn("failed to persist generated server ID", "error", err) } settings["jellyfin_compat.server_id"] = serverID } // Step 6: Build config from DB cfg, err := config.LoadFromDB(settings) if err != nil { log.Fatalf("building config: %v", err) } // Step 7: Apply bootstrap overrides cfg.Server.Listen = bc.Listen cfg.Server.Mode = bc.Mode cfg.Database.URL = bc.DatabaseURL cfg.JellyfinCompat.Listen = bc.JFListen if bc.RedisURL != "" { cfg.Redis.URL = bc.RedisURL } // Step 8: Recreate pool if max_connections differs from bootstrap default if cfg.Database.MaxConnections != bootstrapDBCfg.MaxConnections { pool.Close() pool, err = database.NewPool(ctx, cfg.Database) if err != nil { log.Fatalf("recreating pool with configured max_connections: %v", err) } } settingsRepo = catalog.NewServerSettingsRepo(pool) nodeID := resolveNodeIdentity() // Step 9: Validate if err := cfg.Validate(); err != nil { log.Fatalf("config validation: %v", err) } // Step 10: Configure log level var slogLevel slog.Level switch strings.ToLower(cfg.Server.LogLevel) { case "debug": slogLevel = slog.LevelDebug case "warn", "warning": slogLevel = slog.LevelWarn case "error": slogLevel = slog.LevelError default: slogLevel = slog.LevelInfo } baseHandler := buildBaseHandler(cfg.Server.LogFormat, slogLevel) slog.SetDefault(slog.New(logfilter.New(baseHandler, cfg.Server.LogQuiet))) mode := cfg.Server.Mode slog.Info("silo starting", "mode", mode, "listen", cfg.Server.Listen, "log_level", cfg.Server.LogLevel, "node_id", nodeID) appCtx, appCancel := context.WithCancel(ctx) defer appCancel() eventBus := cache.NewEventBus(cfg.Redis.URL) logStreamHub := logstream.NewHub(nodeID, eventBus) if err := logStreamHub.Start(appCtx); err != nil { log.Fatalf("log stream hub start: %v", err) } realtimeHub := notifications.NewHub(nodeID, eventBus) if err := realtimeHub.Start(appCtx); err != nil { log.Fatalf("realtime hub start: %v", err) } eventsHub := realtimeHub.EventsHub() scanRegistry := evt.NewScanRegistry() operationalWriter, opsRepo, opsPM := configureOperationalLogging(appCtx, pool, settingsRepo, cfg.Redis, logStreamHub, baseHandler, cfg.Server.LogQuiet, nodeID) defer func() { if err := eventBus.Close(); err != nil { slog.Warn("event bus close error", "error", err) } }() // Proxy and transcode modes run with DB + Redis for hot-reload. if mode == "proxy" || mode == "transcode" { redisClient, err := cache.NewRedisClient(cfg.Redis) if err != nil || redisClient == nil { slog.Error("redis is required for "+mode+" mode", "error", err) os.Exit(1) } bootstrap := nodeconfig.BootstrapOverrides{ Listen: cfg.Server.Listen, Mode: cfg.Server.Mode, DatabaseURL: cfg.Database.URL, JFListen: cfg.JellyfinCompat.Listen, } watcher := nodeconfig.NewWatcher(pool, eventBus, bootstrap) if err := watcher.Start(appCtx); err != nil { slog.Error("config watcher start failed", "error", err) os.Exit(1) } nodeURL := os.Getenv("NODE_URL") nodeName := os.Getenv("NODE_NAME") if nodeURL == "" { nodeURL = "http://localhost" + cfg.Server.Listen slog.Warn("NODE_URL not set, using listen address — session keys may collide across nodes") } if nodeName == "" { nodeName = mode } tracker := nodesessions.NewTracker(redisClient, nodeURL, nodeName, mode) tracker.StartRefresh(appCtx) defer func() { cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 5*time.Second) defer cleanupCancel() tracker.Cleanup(cleanupCtx) }() var handler http.Handler if mode == "proxy" { srv := proxy.NewServer(watcher, tracker) handler = srv.Handler() } else { srv := transcodenode.NewServer(watcher, tracker) srv.SetFFmpegLogSink(playback.NewSlogFFmpegLogSink(slog.Default(), nodeID)) handler = srv.Handler() } _ = operationalWriter _ = opsRepo startStandaloneServer(cfg.Server.Listen, handler) return } // Determine which components to initialize based on mode. needsS3 := mode == "integrated" || mode == "api" needsScanner := mode == "integrated" || mode == "api" needsUserDB := mode == "integrated" || mode == "api" needsWorkers := mode == "integrated" || mode == "api" bootstrapSensitiveConfigured := map[string]bool{} bootstrapSensitiveValues := map[string]string{} if bc.RedisURL != "" { bootstrapSensitiveConfigured["redis.url"] = true bootstrapSensitiveValues["redis.url"] = bc.RedisURL } deps := api.Dependencies{ Config: cfg, BootstrapSensitiveConfigured: bootstrapSensitiveConfigured, BootstrapSensitiveValues: bootstrapSensitiveValues, AppContext: appCtx, DB: pool, EventBus: eventBus, LogStreamHub: logStreamHub, RealtimeHub: realtimeHub, EventsHub: eventsHub, ScanRegistry: scanRegistry, OpsLogRepo: opsRepo, FFmpegLogSink: playback.NewSlogFFmpegLogSink(slog.Default(), nodeID), PublicURL: os.Getenv("SILO_PUBLIC_URL"), } if needsWorkers && deps.DB != nil { deps.IntroRepository = intromarkers.NewRepository(deps.DB) deps.IntroAnalyzer = intromarkers.NewAnalyzer( deps.IntroRepository, intromarkers.DefaultConfig(cfg.Playback.FFmpegPath), slog.Default(), ) } if deps.DB != nil { markerRegistry := markers.NewRegistry(slog.Default()) introdbAPIKey, err := settingsRepo.Get(appCtx, "introdb.api_key") if err != nil && !errors.Is(err, pgx.ErrNoRows) { slog.Warn("load introdb.api_key from settings failed; provider will run with no key", "error", err) introdbAPIKey = "" } introdbClient := introdb.NewClient(introdbAPIKey) if err := markerRegistry.Register(introdb.NewProvider(introdbClient)); err != nil { log.Fatalf("register introdb marker provider: %v", err) } deps.OnServerSettingUpdated = func(_ context.Context, key, value string) { if key == "introdb.api_key" { introdbClient.SetAPIKey(value) } } if eventBus != nil { _ = eventBus.Subscribe(appCtx, cache.ChannelAdmin, func(event cache.Event) { if event.Type != cache.EventSettingsChanged || event.Payload != "introdb.api_key" { return } value, loadErr := settingsRepo.Get(context.Background(), "introdb.api_key") if loadErr != nil { slog.Warn("introdb api key reload failed", "error", loadErr) return } introdbClient.SetAPIKey(value) }) } deps.MarkerRegistry = markerRegistry deps.MarkerResolver = markers.NewDBExternalIDResolver(deps.DB) } var watchProviderService *watchsync.Service if deps.DB != nil { watchProviderRegistry := watchsync.NewRegistry() if err := watchProviderRegistry.Register(trakt.NewProvider(nil, "")); err != nil { log.Fatalf("register watch provider: %v", err) } if err := watchProviderRegistry.Register(simkl.NewProvider(nil, "")); err != nil { log.Fatalf("register watch provider: %v", err) } if err := watchProviderRegistry.Register(watchmdblist.NewProvider(nil, "")); err != nil { log.Fatalf("register watch provider: %v", err) } watchProviderService = watchsync.NewService( watchsync.NewPostgresRepository(deps.DB), watchProviderRegistry, ) deps.WatchProviderService = watchProviderService } // Initialize node pools for integrated/api modes. if mode == "integrated" || mode == "api" { nodeRepo := nodepool.NewRepository(pool) deps.NodeRepo = nodeRepo proxyPool := nodepool.NewProxyPool() transcodePool := nodepool.NewTranscodePool() proxyNodes, _ := nodeRepo.ListEnabled(context.Background(), nodepool.NodeTypeProxy) transcodeNodes, _ := nodeRepo.ListEnabled(context.Background(), nodepool.NodeTypeTranscode) proxyPool.SetNodes(proxyNodes) transcodePool.SetNodes(transcodeNodes) deps.ProxyPool = proxyPool deps.TranscodePool = transcodePool healthChecker := nodepool.NewHealthChecker(proxyPool, transcodePool, nodeRepo) healthChecker.Start(appCtx) slog.Info("node pools initialized", "proxy_nodes", len(proxyNodes), "transcode_nodes", len(transcodeNodes)) // Subscribe to node pool change events for multi-instance reload. _ = eventBus.Subscribe(appCtx, cache.ChannelAdmin, func(event cache.Event) { if event.Type == cache.EventNodePoolChanged { pNodes, pErr := nodeRepo.ListEnabled(context.Background(), nodepool.NodeTypeProxy) tNodes, tErr := nodeRepo.ListEnabled(context.Background(), nodepool.NodeTypeTranscode) if pErr != nil || tErr != nil { slog.Warn("node pool reload from event failed, keeping current pools", "proxy_err", pErr, "transcode_err", tErr) return } proxyPool.SetNodes(pNodes) transcodePool.SetNodes(tNodes) slog.Info("node pools reloaded from event", "proxy", len(pNodes), "transcode", len(tNodes)) } }) } // Step 3: Create S3 clients (if needed). if needsS3 { configureS3Clients(cfg, &deps) } // Step 4: Create scanner (if needed). if needsScanner && deps.DB != nil { folderRepo := catalog.NewFolderRepository(deps.DB) fileRepo := scanner.NewFileRepository(deps.DB) deps.FolderRepo = folderRepo deps.FileRepo = fileRepo ffprobePath := scanner.FFprobePathFromFFmpeg(cfg.Playback.FFmpegPath) s := scanner.NewScanner(fileRepo, ffprobePath, deps.S3Public, cfg.Scanner.Workers, cfg.Scanner.EmptyTrashAfterScan) deps.Scanner = s deps.ProbeEnsurer = scanner.NewPlaybackProbeEnsurer(fileRepo, ffprobePath, 10*time.Second) slog.Info("scanner initialized") } var chapterThumbService *chapterthumbs.Service if deps.FileRepo != nil && deps.FolderRepo != nil && deps.S3Public != nil { chapterThumbService = chapterthumbs.NewService( deps.FileRepo, deps.FolderRepo, deps.ProbeEnsurer, settingsRepo, deps.S3Public, nil, deps.TranscodePool, cfg.Playback.FFmpegPath, cfg.Playback.HWAccel, cfg.Playback.HWDevice, cfg.Playback.ChapterThumbnailWorkers, ) if chapterThumbService != nil { chapterThumbService.Start(appCtx) deps.ChapterThumbnailQueuer = chapterThumbService } } var pluginHost *pluginhost.Host var pluginService *plugins.Service var pluginInstallationStore *plugins.InstallationStore var pluginRuntimeConfigStore *plugins.RuntimeConfigStore var pluginHTTPProxy *plugins.HTTPProxy pluginAutoUpdateDone := make(chan struct{}) var pluginAutoUpdater *plugins.AutoUpdateService if deps.DB != nil { pluginCacheDir := resolvePluginCacheDir() repositoryStore := plugins.NewRepositoryStore(deps.DB) installationStore := plugins.NewInstallationStore(deps.DB) runtimeConfigStore := plugins.NewRuntimeConfigStore(deps.DB) catalogService := plugins.NewCatalogService(repositoryStore, plugins.CatalogServiceOptions{ SiloAPIVersion: plugins.DefaultSiloAPIVersion, }) installer := plugins.NewInstaller(installationStore, plugins.InstallerOptions{ BaseDir: pluginCacheDir, }) libDataSource := pluginhost.LibraryDataSourceFunc( func(ctx context.Context, _ string) ([]pluginhost.LibraryRecord, error) { // TODO: scope by userID when the requests plugin needs it (Plan B). // For now, all callers see admin-scope. if deps.FolderRepo == nil { return nil, nil } folders, err := deps.FolderRepo.List(ctx) if err != nil { return nil, err } out := make([]pluginhost.LibraryRecord, 0, len(folders)) for _, f := range folders { out = append(out, pluginhost.LibraryRecord{ ID: strconv.Itoa(f.ID), Name: f.Name, MediaType: mapFolderTypeToMediaType(f.Type), }) } return out, nil }, ) presenceItemRepo := catalog.NewItemRepository(deps.DB) catalogPresence := pluginhost.NewCatalogPresence( func(ctx context.Context, mediaType string, tmdbIDs []string) ([]pluginhost.LibraryPresenceRecord, error) { rows, err := presenceItemRepo.LookupTMDBIDs(ctx, mediaType, tmdbIDs) if err != nil { return nil, err } out := make([]pluginhost.LibraryPresenceRecord, 0, len(rows)) for _, r := range rows { out = append(out, pluginhost.LibraryPresenceRecord{ ExternalID: r.TMDBID, MediaID: r.MediaID, LibraryID: r.LibraryID, Title: r.Title, }) } return out, nil }, ) pluginHost = pluginhost.NewHost(pluginhost.Config{ EventPublisher: eventsHub, LibraryLister: pluginhost.NewLibraryLister(libDataSource), CatalogPresence: catalogPresence, InstalledPlugins: pluginhost.InstalledPluginListerFunc( func(ctx context.Context) ([]pluginhost.InstalledPluginRecord, error) { installations, err := installationStore.List(ctx) if err != nil { return nil, err } out := make([]pluginhost.InstalledPluginRecord, 0, len(installations)) for _, installation := range installations { capabilities, err := installationStore.ListCapabilities(ctx, installation.ID) if err != nil { return nil, err } descriptors := make([]*pluginv1.CapabilityDescriptor, 0, len(capabilities)) for _, capability := range capabilities { descriptor, err := plugins.DecodeCapability(capability) if err != nil { return nil, err } descriptors = append(descriptors, descriptor) } out = append(out, pluginhost.InstalledPluginRecord{ InstallationID: installation.ID, PluginID: installation.PluginID, Version: installation.Version, Enabled: installation.Enabled, Capabilities: descriptors, }) } return out, nil }, ), GlobalConfigSetter: pluginhost.GlobalConfigSetterFunc( func(ctx context.Context, installationID int, key string, value map[string]any) error { return runtimeConfigStore.PutGlobalConfig(ctx, installationID, key, value) }, ), Logger: hclog.New(&hclog.LoggerOptions{ Name: "plugin-host", Level: hclog.Info, Output: os.Stderr, }), }) pluginService = plugins.NewService( repositoryStore, installationStore, runtimeConfigStore, catalogService, installer, plugins.NewHostAdapter(pluginHost), ) if err := pluginService.PreloadEnabled(appCtx); err != nil { log.Fatalf("preload enabled plugins: %v", err) } slog.Info("plugin cache initialized", "base_dir", pluginCacheDir) pluginAutoUpdater = plugins.NewAutoUpdateService( repositoryStore, installationStore, catalogService, installer, pluginHost, slog.Default(), ) go func() { defer close(pluginAutoUpdateDone) if err := pluginAutoUpdater.Run(appCtx); err != nil { slog.Error("plugin auto-update failed", "error", err) } }() pluginInstallationStore = installationStore pluginRuntimeConfigStore = runtimeConfigStore pluginHTTPProxy = plugins.NewHTTPProxyWithTypedResolver(pluginService, pluginInstallationStore) if deps.DB != nil { pluginHTTPProxy = pluginHTTPProxy.WithUserThemeLookup(plugins.NewPgUserThemeLookup(deps.DB)) pluginHTTPProxy = pluginHTTPProxy.WithUserIdentityLookup(plugins.NewPgUserIdentityLookup(deps.DB)) } deps.PluginService = pluginService deps.PluginHTTPProxy = pluginHTTPProxy defer func() { if pluginHost == nil { return } shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := pluginHost.Shutdown(shutdownCtx); err != nil { slog.Warn("failed to shut down plugin host", "error", err) } }() } else { close(pluginAutoUpdateDone) } if pluginService != nil && pluginInstallationStore != nil { dispatcher := plugins.NewEventDispatcherWithTypedResolver(deps.EventBus, deps.EventsHub, pluginInstallationStore, pluginService, 4) pluginService.SetEventDispatcher(dispatcher) if err := dispatcher.Start(appCtx); err != nil { log.Fatalf("plugin event dispatcher: %v", err) } defer dispatcher.Stop() // Backfill the capability-subscriber index from the already-preloaded // installations. PreloadEnabled ran earlier (before the dispatcher // existed), so its rebuildDispatcherIndex was a no-op. Without this // call, capability-scoped subscriptions never fire until the next // lifecycle mutation. pluginService.OnLifecycleChange(appCtx) } // Step 4b: Create metadata service and match worker (if needed). var metadataService *metadata.MetadataService var personRefreshService *metadata.PersonRefreshService var matchWorker *metadata.MatchWorker var libraryIngestExecutor *libraryingest.Executor var libraryScanQueue *scanqueue.Service var itemRefreshExecutor *adminjob.ItemRefreshExecutor var libraryRefreshExecutor *adminjob.LibraryRefreshExecutor var itemRepo *catalog.ItemRepository var skippedRootRepo *metadata.SkippedRootRepository var movieQueueRepo *metadata.MovieMatchQueueRepository var seriesQueueRepo *metadata.SeriesRootMatchQueueRepository var matchQueueCoordinator *metadata.MatchQueueCoordinator var rootClaimRepo *catalog.RootClaimRepository var groupClaimRepo *catalog.GroupClaimRepository var seasonRepo *catalog.SeasonRepository var episodeRepo *catalog.EpisodeRepository if needsWorkers && deps.DB != nil && deps.FileRepo != nil { chainRepo := metadata.NewChainRepository(deps.DB) skippedRootRepo = metadata.NewSkippedRootRepository(deps.DB) itemRepo = catalog.NewItemRepository(deps.DB) episodeRepo = catalog.NewEpisodeRepository(deps.DB) seasonRepo = catalog.NewSeasonRepository(deps.DB) personRepo := catalog.NewPersonRepository(deps.DB) libraryRepo := catalog.NewLibraryItemRepository(deps.DB) // Wait for plugin auto-update to finish before registering image resolvers. <-pluginAutoUpdateDone imageResolver := metadata.NewPluginImageResolver() if pluginService != nil { // Register image resolver sources for all enabled plugin metadata providers. rows, err := deps.DB.Query(appCtx, `SELECT pc.plugin_installation_id, pc.capability_id FROM plugin_capabilities pc JOIN plugin_installations pi ON pi.id = pc.plugin_installation_id WHERE pc.capability_type = 'metadata_provider.v1' AND pi.enabled = true`) if err != nil { slog.Warn("failed to list capabilities for image resolver registration", "error", err) } else { defer rows.Close() for rows.Next() { var instID int var capID string if err := rows.Scan(&instID, &capID); err != nil { slog.Warn("failed to scan capability for image resolver", "error", err) continue } source := metadata.NewPluginClientSource(instID, capID, func( ctx context.Context, installationID int, capabilityID string, ) (metadata.PluginMetadataClient, error) { return pluginService.MetadataProviderClient(ctx, installationID, capabilityID) }) imageResolver.RegisterSource(capID, source) slog.Info("registered plugin image resolver", "capability_id", capID, "installation_id", instID) } } } if deps.S3Public != nil { presignTTL := cfg.S3.MetadataPresignExpiry if presignTTL <= 0 { presignTTL = 4 * time.Hour } imageResolver.SetS3Presigner(deps.S3Public, deps.S3Public.EffectivePresignTTL(presignTTL)) } deps.ImageResolver = imageResolver deps.PluginImageResolver = imageResolver staleIDRepo := metadata.NewStaleMediaIDRepository(deps.DB) providerIDRepo := catalog.NewProviderIDRepository(deps.DB) movieQueueRepo = metadata.NewMovieMatchQueueRepository(deps.DB, deps.FileRepo) seriesQueueRepo = metadata.NewSeriesRootMatchQueueRepository(deps.DB) matchQueueCoordinator = metadata.NewMatchQueueCoordinator(movieQueueRepo, seriesQueueRepo) rootClaimRepo = catalog.NewRootClaimRepository(deps.DB) groupClaimRepo = catalog.NewGroupClaimRepository(deps.DB) pluginResolver := metadata.NewPluginResolverAdapter(pluginService) metadataService = metadata.NewMetadataService( chainRepo, pluginResolver, itemRepo, providerIDRepo, episodeRepo, seasonRepo, libraryRepo, deps.FolderRepo, personRepo, deps.FileRepo, skippedRootRepo, staleIDRepo, rootClaimRepo, ) personRefreshService = metadata.NewPersonRefreshService(deps.DB, pluginResolver, personRepo) personRefreshService.SetImageResolver(imageResolver) // Always wire the image resolver so plugin-prefixed URLs (e.g. // metadb://) can be resolved to presigned HTTP URLs in API responses. metadataService.SetImageResolver(imageResolver) // Wire the image cacher whenever object storage is available so explicit // admin image applies can succeed even if automatic metadata caching is off. if deps.S3Public != nil { imageCacher := imagecache.New(deps.S3Public) metadataService.SetImageCacher(imageCacher) metadataService.SetAutoCacheImages(cfg.Metadata.CacheImages) if cfg.Metadata.CacheImages { personRefreshService.SetImageCacher(imageCacher) slog.Info("metadata image caching enabled") } } matchWorker = metadata.NewMatchWorker(metadataService, deps.FileRepo, cfg.Matcher.Workers, cfg.Matcher.BatchSize, 30*time.Second) if movieQueueRepo != nil { matchWorker.SetMovieFileClaimer(movieQueueRepo) } if seriesQueueRepo != nil { if cleaned, err := seriesQueueRepo.CleanupLegacySeriesGroupQueue(appCtx); err != nil { slog.Warn("failed to clean legacy series group queue rows", "error", err) } else if cleaned > 0 { slog.Info("cleaned legacy series group queue rows", "count", cleaned) } matchWorker.SetSeriesRootClaimer(seriesQueueRepo, cfg.Matcher.TVSeriesRootQueueEnabled()) } if deps.FolderRepo != nil { enabledFolders, err := deps.FolderRepo.GetEnabled(appCtx) if err != nil { slog.Warn("failed to seed metadata queues", "error", err) } else { seedMovieQueue := func(folderID int) { if movieQueueRepo == nil { return } if err := movieQueueRepo.SyncForFolder(appCtx, folderID); err != nil { slog.Warn("failed to seed movie match queue", "folder_id", folderID, "error", err) } } seedSeriesQueue := func(folderID int) { if seriesQueueRepo == nil { return } if err := seriesQueueRepo.SyncForFolder(appCtx, folderID); err != nil { slog.Warn("failed to seed series root queue", "folder_id", folderID, "error", err) } } for _, folder := range enabledFolders { if folder == nil { continue } switch strings.ToLower(strings.TrimSpace(folder.Type)) { case "movie", "movies": seedMovieQueue(folder.ID) case "series", "tv", "show", "tvshows": seedSeriesQueue(folder.ID) case "mixed": seedSeriesQueue(folder.ID) seedMovieQueue(folder.ID) } } } } deps.SkippedRootRepo = skippedRootRepo deps.StaleIDRepo = staleIDRepo deps.PersonRepo = personRepo deps.PersonRefreshQueue = worker.NewPersonRefreshWorker( personRefreshService, worker.DefaultPersonRefreshWorkerConfig(), ) deps.PersonRefresher = personRefreshService deps.Refresher = metadataService deps.MetadataService = metadataService slog.Info("metadata service initialized and running") } if deps.Scanner != nil { if matchQueueCoordinator != nil { deps.Scanner.SetMetadataQueueProducer(matchQueueCoordinator) } if movieQueueRepo != nil { deps.Scanner.SetMovieQueueSyncer(movieQueueRepo) } if seriesQueueRepo != nil { deps.Scanner.SetSeriesQueueSyncer(seriesQueueRepo) } } if deps.Scanner != nil && matchWorker != nil && deps.FolderRepo != nil && skippedRootRepo != nil { libraryIngestExecutor = libraryingest.NewExecutor( deps.Scanner, matchWorker, deps.FolderRepo, skippedRootRepo, deps.EventBus, deps.RealtimeHub, ) deps.LibraryIngester = libraryIngestExecutor if deps.DB != nil { libraryScanQueue = scanqueue.NewService( scanqueue.NewRepository(deps.DB), deps.FolderRepo, libraryIngestExecutor, deps.EventsHub, appCtx, cfg.Scanner.MaxConcurrentLibraries, cfg.Scanner.MaxConcurrentScoped, ) libraryScanQueue.Start() defer libraryScanQueue.Stop() deps.LibraryScanQueue = libraryScanQueue } if deps.DB != nil && deps.FileRepo != nil && metadataService != nil { itemRefreshResolver := adminjob.NewItemRefreshResolver( itemRepo, seasonRepo, episodeRepo, deps.FolderRepo, deps.FileRepo, ) libraryRefreshExecutor = adminjob.NewLibraryRefreshExecutor( adminjob.NewPGLibraryRefreshItemLister(deps.DB), deps.FolderRepo, itemRefreshResolver, libraryIngestExecutor, metadataService, deps.EventBus, deps.RealtimeHub, ) } if metadataService != nil && deps.FileRepo != nil { itemRefreshExecutor = adminjob.NewItemRefreshExecutor( deps.FolderRepo, deps.FileRepo, rootClaimRepo, groupClaimRepo, skippedRootRepo, seasonRepo, episodeRepo, libraryIngestExecutor, metadataService, deps.EventBus, deps.RealtimeHub, ) } } // Ensure PersonRepo is available for the router's DetailService. if deps.DB != nil && deps.PersonRepo == nil { deps.PersonRepo = catalog.NewPersonRepository(deps.DB) } // Step 5: Create user store provider (if needed). var userStoreProvider userstore.UserStoreProvider if needsUserDB { switch cfg.UserDB.Backend { case "sqlite": poolConfig := userdb.PoolConfig{ MaxOpen: cfg.UserDB.PoolMaxOpen, IdleTimeout: cfg.UserDB.IdleTimeout, DataDir: "/var/lib/silo/userdb", } pool := userdb.NewUserDBPool(poolConfig) userStoreProvider = userdb.NewSQLiteProvider(pool) slog.Info("user store initialized", "backend", "sqlite", "max_open", poolConfig.MaxOpen) default: // "postgres" userStoreProvider = pgstore.NewPostgresProvider(deps.DB) slog.Info("user store initialized", "backend", "postgres") } defer userStoreProvider.Close() } if userStoreProvider != nil && pluginService != nil { deps.PluginUserConfig = plugins.NewUserConfigStore(userStoreProvider, pluginService) } // Step 6: Create playback session manager and wire into dependencies. sessionMgr := playback.NewSessionManager(6, 2) // defaults from plan: max_streams=6, max_transcodes=2 if deps.DB != nil { userRepo := auth.NewUserRepository(deps.DB) 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 }) } if userStoreProvider != nil { deps.UserStoreProvider = userStoreProvider } if watchProviderService != nil { historyRepo := historyimport.NewRepository(deps.DB) historyIdentity := watchstate.NewStableIdentityResolver(itemRepo, episodeRepo, catalog.NewProviderIDRepository(deps.DB)) watchProviderService. WithMatcher(historyimport.NewMatcher(historyRepo)). WithWatchState(watchstate.NewService(userStoreProvider).WithStableIdentityResolver(historyIdentity)). WithUserStoreProvider(userStoreProvider) if err := watchProviderService.SweepOpenScrobbles(appCtx); err != nil { slog.Warn("failed to sweep open watch provider scrobbles", "error", err) } } deps.SessionMgr = sessionMgr deps.PlaybackRealtimeHub = playback.NewRealtimeHub() if chapterThumbService != nil && deps.S3Public != nil { chapterThumbService.SetNotifier( playback.NewChapterThumbnailNotifier(sessionMgr, deps.PlaybackRealtimeHub, deps.S3Public, 0), ) } // Build the reconciler early enough that playback handlers can trigger // immediate session syncs after start/stop events. nodeIdentity := resolveNodeIdentity() var reconciler *worker.Reconciler var heartbeatWriter *worker.HeartbeatWriter if needsWorkers && deps.DB != nil { sessionProvider := func() []worker.SessionSync { sessions := sessionMgr.AllSessions() syncs := make([]worker.SessionSync, len(sessions)) for i, s := range sessions { syncs[i] = buildLiveSessionSync(s, nodeIdentity) } return syncs } reconciler = worker.NewReconciler(deps.DB, nodeIdentity, sessionProvider) reconciler.EventBus = deps.EventBus reconciler.EventsHub = deps.EventsHub reconciler.PreSync = func() { // Retire sessions that have not shown real playback activity // recently enough to count as live. This keeps the in-memory // limiter, transcode teardown, and synced admin view aligned. if expired := sessionMgr.CleanStale(); len(expired) > 0 { slog.Info("expired idle sessions", "count", len(expired)) } } deps.SessionSyncer = reconciler nodeURL := fmt.Sprintf("http://%s%s", nodeIdentity, cfg.Server.Listen) heartbeatWriter = worker.NewHeartbeatWriter(deps.DB, nodeIdentity, mode, nodeURL) } if deps.DB != nil { adminStatsProvider, statsErr := handlers.NewAdminStatsProvider(appCtx, deps.DB, deps.EventBus) if statsErr != nil { log.Fatalf("failed to create admin stats provider: %v", statsErr) } defer adminStatsProvider.Close() deps.AdminStatsProvider = adminStatsProvider } // Wire recommendations engine, worker, and ratings repo if enabled. var recEngine *recommendations.Engine var recWorker *recommendations.Worker if cfg.Recommendations.Enabled && deps.DB != nil { deps.RatingsRepo = catalog.NewRatingsRepo(deps.DB) recEngine = recommendations.NewEngine( deps.DB, deps.RatingsRepo, catalog.NewItemRepository(deps.DB), catalog.NewPersonRepository(deps.DB), userStoreProvider, cfg.Recommendations, ) deps.Recommender = recEngine var err error recWorker, err = recommendations.NewWorker( recEngine, cfg.Recommendations.EmbeddingsCron, cfg.Recommendations.TasteProfilesCron, cfg.Recommendations.CowatchCron, cfg.Recommendations.RecommendationsCron, ) if err != nil { slog.Error("failed to create recommendation worker", "error", err) } else { deps.RecWorker = recWorker } } // Client IP resolver with trusted proxy config. if err := clientip.SeedDefaults(ctx, settingsRepo); err != nil { log.Fatalf("seed clientip defaults: %v", err) } trustedCIDRs, err := clientip.LoadTrustedCIDRs(ctx, settingsRepo) if err != nil { log.Fatalf("load trusted CIDRs: %v", err) } ipResolver := clientip.NewResolver(trustedCIDRs) deps.ClientIPResolver = ipResolver // Step 6b: Create rate limiter. if cfg.RateLimit.Enabled && deps.DB != nil { var perKeyLimiter, globalLimiter ratelimit.RateLimiter isMemory := true if cfg.RateLimit.Backend == "redis" { redisClient, redisErr := cache.NewRedisClient(cfg.Redis) if redisErr != nil { log.Fatalf("failed to create Redis client for rate limiting: %v", redisErr) } if redisClient != nil { perKeyLimiter = ratelimit.NewRedisLimiter(redisClient) globalLimiter = ratelimit.NewRedisLimiter(redisClient) isMemory = false defer redisClient.Close() } } if isMemory { perKeyLimiter = ratelimit.NewMemoryLimiter() globalLimiter = ratelimit.NewMemoryLimiter() } defer perKeyLimiter.Close() defer globalLimiter.Close() rateLimitMW := ratelimit.NewMiddleware(perKeyLimiter, globalLimiter, settingsRepo, isMemory) if err := rateLimitMW.Init(context.Background()); err != nil { log.Fatalf("failed to init rate limiter: %v", err) } // Subscribe for multi-instance reload (only fires if EventBus is Redis-backed) _ = eventBus.Subscribe(appCtx, cache.ChannelAdmin, func(event cache.Event) { if event.Type == cache.EventSettingsChanged { if reloadErr := rateLimitMW.Reload(context.Background()); reloadErr != nil { slog.Warn("rate limit config reload from event failed", "error", reloadErr) } // Reload trusted proxies cidrs, loadErr := clientip.LoadTrustedCIDRs(context.Background(), settingsRepo) if loadErr != nil { slog.Warn("clientip config reload failed", "error", loadErr) } else { ipResolver.UpdateTrustedCIDRs(cidrs) } } }) deps.RateLimitMW = rateLimitMW } // Activity log writer + consumer. if err := activitylog.SeedDefaults(ctx, settingsRepo); err != nil { log.Fatalf("seed activitylog defaults: %v", err) } // Seed default page sections for home and existing libraries. sectionRepo := sections.NewRepository(pool) var folders []*models.MediaFolder if deps.FolderRepo != nil { var listErr error folders, listErr = deps.FolderRepo.List(ctx) if listErr != nil { log.Fatalf("list libraries for section defaults: %v", listErr) } } if err := sectionRepo.SeedDefaults(ctx, "home", nil, sections.DefaultHomeSections(folders)); err != nil { log.Fatalf("seed home section defaults: %v", err) } if deps.FolderRepo != nil { for _, f := range folders { id := f.ID if seedErr := sectionRepo.SeedDefaults(ctx, "library", &id, sections.DefaultLibrarySectionsForType(&id, f.Type)); seedErr != nil { slog.Warn("seed library section defaults", "library_id", id, "error", seedErr) } } } activityPM := partman.NewManager(pool, "activity_log", partman.Weekly, 2) if err := activityPM.EnsureFuturePartitions(appCtx); err != nil { log.Fatalf("ensure activity log partitions: %v", err) } var activityWriter activitylog.Writer activityConsumer := activitylog.NewConsumer(pool, nil, logStreamHub) if cfg.Redis.URL != "" { actRedisClient, actRedisErr := cache.NewRedisClient(cfg.Redis) if actRedisErr == nil && actRedisClient != nil { activityWriter = activitylog.NewRedisWriter(actRedisClient) activityConsumer = activitylog.NewConsumer(pool, actRedisClient, logStreamHub) go activityConsumer.RunRedis(appCtx) defer actRedisClient.Close() } } if activityWriter == nil { memWriter := activitylog.NewMemoryWriter(10000) activityWriter = memWriter go activityConsumer.RunMemory(appCtx, memWriter.Chan()) } deps.ActivityLogWriter = activityWriter deps.ActivityLogRepo = activitylog.NewRepo(pool) deps.NodeID = nodeID // Create refresh worker early so the task manager can use it for FindCandidates. var refreshWorker *worker.RefreshWorker var personRefreshWorker *worker.PersonRefreshWorker if needsWorkers && deps.DB != nil { refreshWorker = worker.NewRefreshWorker(deps.DB) if deps.PersonRefreshQueue != nil { personRefreshWorker, _ = deps.PersonRefreshQueue.(*worker.PersonRefreshWorker) } } // Construct collection service for both the router and the collection sync scheduler. var collectionSyncScheduler *catalog.CollectionSyncScheduler var userCollectionScheduler *usercollections.Scheduler if needsWorkers && deps.DB != nil { collectionRepo := catalog.NewLibraryCollectionRepository(deps.DB) collItemRepo := catalog.NewItemRepository(deps.DB) libraryItemRepo := catalog.NewLibraryItemRepository(deps.DB) collectionService := catalog.NewLibraryCollectionService(collectionRepo, collItemRepo, libraryItemRepo, nil) collectionService.TMDBCollections = api.NewTMDBCollectionFetcher(cfg.TMDBAPIKey) deps.CollectionService = collectionService collectionSyncScheduler = catalog.NewCollectionSyncScheduler(collectionRepo, collectionService, slog.Default()) if deps.UserStoreProvider != nil { userSync := usercollections.NewService(deps.UserStoreProvider, collItemRepo, libraryItemRepo, nil, slog.Default()) userSync.TMDBCollections = collectionService.TMDBCollections // Trakt fetchers are wired in router.go (they need settingsRepo); // router.go propagates them onto userSync once configured. userCollectionScheduler = usercollections.NewScheduler(deps.DB, userSync, slog.Default()) deps.UserCollectionSync = userSync deps.UserCollectionScheduler = userCollectionScheduler deps.MDBListClient = mdblist.NewClient(cfg.MDBListAPIKey, nil) } } // Wire up task manager for admin task API. if needsWorkers && deps.DB != nil { triggerRepo := taskrepository.NewPgTriggerRepository(deps.DB) historyRepo := taskrepository.NewPgExecutionRepository(deps.DB) taskMgr := taskmanager.New(triggerRepo, historyRepo, triggers.New, slog.Default()) if deps.EventsHub != nil { taskMgr.AddObserver(evt.NewTaskObserver(deps.EventsHub)) } if deps.FolderRepo != nil && deps.LibraryScanQueue != nil { taskMgr.Register(tasks.NewScanLibrariesTask(deps.FolderRepo, deps.LibraryScanQueue, deps.EventBus)) } if deps.IntroAnalyzer != nil { taskMgr.Register(tasks.NewDetectIntroMarkersTask(deps.IntroAnalyzer, settingsRepo)) } if chapterBackfiller, ok := deps.ChapterThumbnailQueuer.(*chapterthumbs.Service); ok { taskMgr.Register(tasks.NewChapterThumbnailBackfillTask(chapterBackfiller, 25)) } taskMgr.Register(tasks.NewActivityLogCleanupTask(deps.DB, settingsRepo, activityPM)) taskMgr.Register(tasks.NewOperationalLogCleanupTask(deps.DB, settingsRepo, opsPM)) if matchWorker != nil { taskMgr.Register(tasks.NewMatchMediaTask(matchWorker)) } if refreshWorker != nil && metadataService != nil { taskMgr.Register(tasks.NewRefreshMetadataTask(refreshWorker, metadataService)) } if pluginAutoUpdater != nil { taskMgr.Register(tasks.NewCheckPluginUpdatesTask(pluginAutoUpdater)) } if collectionSyncScheduler != nil { taskMgr.Register(tasks.NewSyncCollectionsTask(collectionSyncScheduler)) } if userCollectionScheduler != nil { taskMgr.Register(tasks.NewSyncUserCollectionsTask(userCollectionScheduler)) } if watchProviderService != nil { taskMgr.Register(tasks.NewSyncWatchProvidersTask(watchProviderService)) } requestReconcileSvc := mediarequests.NewService( mediarequests.NewRepository(deps.DB), nil, mediarequests.NewCatalogPresence( catalog.NewItemRepository(deps.DB), catalog.NewProviderIDRepository(deps.DB), ), ) requestReconcileSvc.SetSecretResolver(settingsRepo) requestReconcileSvc.SetFulfillmentAdapters(radarr.NewClient(nil), sonarr.NewClient(nil)) taskMgr.Register(tasks.NewReconcileRequestsTask(requestReconcileSvc, 100)) reconcileProviderIDRepo := catalog.NewProviderIDRepository(deps.DB) reconcileEpisodeRepo := catalog.NewEpisodeRepository(deps.DB) historyResolver := watchstate.NewStableIdentityResolver(nil, reconcileEpisodeRepo, reconcileProviderIDRepo) historyReconciler := watchstate.NewHistoryReconciler(deps.DB, historyResolver) taskMgr.Register(tasks.NewRepairProviderIDIntegrityTask(metadata.NewProviderIDIntegrityRepairer(deps.DB), historyReconciler)) taskMgr.Register(tasks.NewReconcileWatchHistoryTask(historyReconciler)) if pluginInstallationStore != nil && pluginRuntimeConfigStore != nil && pluginService != nil { pluginTasks, err := plugins.NewTaskRegistryWithTypedResolver(pluginInstallationStore, pluginRuntimeConfigStore, pluginService).Tasks(appCtx) if err != nil { log.Fatalf("plugin task registry: %v", err) } for _, pluginTask := range pluginTasks { taskMgr.Register(pluginTask) } } taskMgr.Start(appCtx) defer taskMgr.Stop() deps.TaskManager = taskMgr slog.Info("task manager started") } if deps.DB != nil && pluginInstallationStore != nil && pluginRuntimeConfigStore != nil && deps.PluginService != nil { userRepo := auth.NewUserRepository(deps.DB) sessionRepo := auth.NewSessionRepository(deps.DB) authBindings, err := pluginRuntimeConfigStore.ListAuthBindings(appCtx) if err != nil { log.Fatalf("list plugin auth bindings: %v", err) } for _, binding := range authBindings { if binding == nil || !binding.Enabled { continue } installation, err := pluginInstallationStore.GetByID(appCtx, binding.InstallationID) if err != nil { log.Fatalf("load plugin auth installation %d: %v", binding.InstallationID, err) } if !installation.Enabled { continue } displayName := binding.CapabilityID mode := "credentials" iconURL := "" capabilities, err := pluginInstallationStore.ListCapabilities(appCtx, binding.InstallationID) if err == nil { for _, capability := range capabilities { if capability != nil && capability.Type == "auth_provider.v1" && capability.ID == binding.CapabilityID { if name, ok := capability.Metadata["display_name"].(string); ok && strings.TrimSpace(name) != "" { displayName = name } // auth_modes ["oauth2"] flips the login button into // an OAuth-style "Sign in with X" path. Mode is "oauth" // when oauth2 is the only declared mode; "credentials" // when password is supported alongside or alone. if rawModes, ok := capability.Metadata["auth_modes"].([]any); ok { hasPassword := false hasOAuth := false for _, m := range rawModes { switch m { case "password": hasPassword = true case "oauth2": hasOAuth = true } } if hasOAuth && !hasPassword { mode = "oauth" } } if url, ok := capability.Metadata["icon_url"].(string); ok { iconURL = url } break } } } // Generic OIDC and similar multi-instance plugins ship one binary // but install once per IdP. Their admin SPA writes display_name // + icon_url_path to runtime config so each install renders its // own brand on the login page. Manifest values are the fallback. if runtimeConfigs, err := pluginRuntimeConfigStore.ListGlobalConfigs(appCtx, binding.InstallationID); err == nil { for _, rc := range runtimeConfigs { switch rc.Key { case "display_name": if v, ok := rc.Value["value"].(string); ok && strings.TrimSpace(v) != "" { displayName = v } case "icon_url_path": if v, ok := rc.Value["value"].(string); ok && strings.TrimSpace(v) != "" { iconURL = fmt.Sprintf("/api/v1/plugins/%d/assets/%s", binding.InstallationID, strings.TrimLeft(v, "/")) } } } } deps.AuthProviders = append(deps.AuthProviders, auth.RegisteredProvider{ Info: auth.LoginProviderInfo{ ID: fmt.Sprintf("plugin:%d:%s", binding.InstallationID, binding.CapabilityID), DisplayName: displayName, Mode: mode, Default: binding.DefaultLogin, IconURL: iconURL, InstallationID: binding.InstallationID, }, Provider: auth.NewPluginProvider( auth.PluginProviderConfig{ InstallationID: binding.InstallationID, CapabilityID: binding.CapabilityID, DisplayName: displayName, AutoProvision: binding.AutoProvision, }, sessionRepo, userRepo, deps.DB, deps.PluginService, ), }) } } // Step 7: Build HTTP router with all dependencies. // compatServer is populated after the compat server is constructed below; // the closure captures the pointer so revocation calls reach the live instance. var compatServer *jellycompat.Server deps.OnUserSessionsRevoked = func(ctx context.Context, userID int) { if compatServer != nil { compatServer.SessionStore().DeleteByUserID(userID) } } distFS, fsErr := fs.Sub(siloweb.DistFS, "dist") if fsErr != nil { log.Fatalf("failed to create frontend FS: %v", fsErr) } deps.FrontendFS = distFS server.WebDistFS = distFS router := api.NewRouter(deps) // Step 8: Expose Prometheus metrics endpoint (not behind auth). metricsMux := http.NewServeMux() metricsMux.Handle("/metrics", promhttp.Handler()) metricsMux.Handle("/api/", router) metricsMux.Handle("/", server.FrontendHandler()) // Step 9: Start background workers (if needed). var sessionCleaner *worker.SessionCleaner var adminJobRunner *adminjob.Runner if needsWorkers && deps.DB != nil { if reconciler == nil { log.Fatal("reconciler must be initialized before starting workers") } reconciler.Start() defer reconciler.Stop() if heartbeatWriter != nil { heartbeatWriter.Start() defer heartbeatWriter.Stop() } // RefreshWorker is kept as a RefreshCandidateFinder for the task manager's // RefreshMetadataTask but no longer runs its own background loop. // Scanning is handled exclusively by the task manager's ScanLibrariesTask. if personRefreshWorker != nil { personRefreshWorker.Start() defer personRefreshWorker.Stop() } sessionCleaner = worker.NewSessionCleaner(deps.DB, cfg.UserDB.StaleGraceSeconds) sessionCleaner.EventBus = deps.EventBus sessionCleaner.EventsHub = deps.EventsHub sessionCleaner.Start() defer sessionCleaner.Stop() var templateBundleApplyExecutor interface { ExecuteTemplateBundleApply(context.Context, adminjob.TemplateBundleApplyRequest, func(int, int, string)) (any, error) } if deps.CollectionService != nil { collectionRepo := catalog.NewLibraryCollectionRepository(deps.DB) itemRepo := catalog.NewItemRepository(deps.DB) collectionHandler := handlers.NewLibraryCollectionHandler( collectionRepo, deps.CollectionService, itemRepo, 4*time.Hour, nil, deps.S3Public, ) collectionHandler.FrontendFS = deps.FrontendFS collectionHandler.SectionRepo = sectionRepo collectionHandler.FolderRepo = deps.FolderRepo if collectionHandler.FolderRepo == nil { collectionHandler.FolderRepo = catalog.NewFolderRepository(deps.DB) } templateBundleApplyExecutor = collectionHandler } adminJobRunner = adminjob.NewRunner( adminjob.NewRepository(deps.DB), catalogseed.NewService(deps.DB, catalog.NewPersonRepository(deps.DB), recommendations.NewRepo(deps.DB)), deps.S3Private, itemRefreshExecutor, libraryRefreshExecutor, adminjob.NewLibraryDeleteExecutor(deps.FolderRepo, sectionRepo), adminjob.NewImageCacheCleanupExecutor(deps.S3Public), templateBundleApplyExecutor, deps.RealtimeHub, ) adminJobRunner.Start() defer adminJobRunner.Stop() // Start recommendation worker if enabled (reuse worker created above). if recWorker != nil { recWorker.Start() defer recWorker.Stop() // Check if this is first run (no embeddings yet). embCount, _ := recommendations.NewRepo(deps.DB).EmbeddingCount(appCtx) if embCount == 0 { slog.Info("first run detected, triggering initial embedding") recWorker.RunEmbeddingsNow() } } slog.Info("background workers started") } // Step 10: Create and start the HTTP server. srv := &http.Server{ Addr: cfg.Server.Listen, Handler: metricsMux, ReadTimeout: 30 * time.Second, WriteTimeout: 120 * time.Second, IdleTimeout: 120 * time.Second, } var compatSrv *http.Server if (mode == "integrated" || mode == "api") && cfg.JellyfinCompat.Listen != "" { compatDeps := jellycompat.Dependencies{ Config: cfg, DB: deps.DB, ClientIPResolver: ipResolver, ProxyPool: deps.ProxyPool, TranscodePool: deps.TranscodePool, JWTSecret: cfg.Auth.JWTSecret, RecWorker: recWorker, } // Wire direct dependencies when DB is available. if deps.DB != nil { browseRepo := catalog.NewBrowseRepository(deps.DB) itemRepo := catalog.NewItemRepository(deps.DB) seasonRepo := catalog.NewSeasonRepository(deps.DB) episodeRepo := catalog.NewEpisodeRepository(deps.DB) providerIDRepo := catalog.NewProviderIDRepository(deps.DB) personRepo := catalog.NewPersonRepository(deps.DB) folderRepo := deps.FolderRepo var fileFetcher catalog.FileVersionFetcher if deps.FileRepo != nil { fileFetcher = deps.FileRepo } detailSvc := catalog.NewDetailService(itemRepo, episodeRepo, seasonRepo, personRepo, fileFetcher) detailSvc.SetFolderRepository(folderRepo) detailSvc.SetGroupClaimRepository(catalog.NewGroupClaimRepository(deps.DB)) detailSvc.SetProbeEnsurer(deps.ProbeEnsurer) detailSvc.SetChapterThumbnailQueuer(deps.ChapterThumbnailQueuer) if deps.ImageResolver != nil { detailSvc.SetImageResolver(deps.ImageResolver) } compatDeps.BrowseRepo = browseRepo compatDeps.ItemRepo = itemRepo compatDeps.SeasonRepo = seasonRepo compatDeps.EpisodeRepo = episodeRepo compatDeps.ProviderIDRepo = providerIDRepo compatDeps.DetailSvc = detailSvc compatDeps.FolderRepo = folderRepo compatDeps.SessionMgr = sessionMgr compatDeps.UserStoreProvider = userStoreProvider compatDeps.SettingsRepo = settingsRepo compatDeps.PersonRepo = personRepo if deps.S3Public != nil { compatDeps.PosterPresigner = deps.S3Public compatDeps.S3Client = deps.S3Public compatDeps.S3Bucket = deps.S3Public.Bucket() } if deps.FileRepo != nil { compatDeps.FileResolver = deps.FileRepo } compatDeps.SubtitleRepo = subtitles.NewPgRepository(deps.DB) // Construct auth service for jellycompat login. userRepo := auth.NewUserRepository(deps.DB) compatDeps.APIKeyValidator = auth.NewAPIKeyRepository(deps.DB) compatDeps.APIKeyUserLoader = userRepo compatDeps.ScanQueue = deps.LibraryScanQueue sessionRepo := auth.NewSessionRepository(deps.DB) jwtService := auth.NewJWTService( cfg.Auth.JWTSecret, cfg.Auth.AccessTokenExpiry, cfg.Auth.RefreshTokenExpiry, ) provider := auth.NewLocalProvider(userRepo, sessionRepo) compatDeps.AuthService = auth.NewService(provider, jwtService, sessionRepo, userRepo, nil, nil, nil) // Access filter resolver for profile-scoped library access. compatDeps.AccessFilterFn = func(ctx context.Context, userID int, profileID string) catalog.AccessFilter { if userStoreProvider == nil { return catalog.AccessFilter{} } store, err := userStoreProvider.ForUser(ctx, userID) if err != nil { return catalog.AccessFilter{} } profile, err := store.GetProfile(ctx, profileID) if err != nil || profile == nil { return catalog.AccessFilter{} } filter := catalog.AccessFilter{ MaxContentRating: profile.MaxContentRating, } if profile.LibraryRestrictionsEnabled && len(profile.AllowedLibraryIDs) > 0 { filter.AllowedLibraryIDs = profile.AllowedLibraryIDs } // Apply user-disabled library IDs. if raw, err := store.GetSetting(ctx, "disabled_library_ids"); err == nil && raw != "" { var disabled []int if json.Unmarshal([]byte(raw), &disabled) == nil && len(disabled) > 0 { if filter.AllowedLibraryIDs != nil { filtered := make([]int, 0, len(filter.AllowedLibraryIDs)) disSet := make(map[int]struct{}, len(disabled)) for _, id := range disabled { disSet[id] = struct{}{} } for _, id := range filter.AllowedLibraryIDs { if _, ok := disSet[id]; !ok { filtered = append(filtered, id) } } filter.AllowedLibraryIDs = filtered } else { filter.DisabledLibraryIDs = disabled } } } return filter } } compat := jellycompat.NewServerWithDependencies(compatDeps) compatServer = compat compat.StartBackgroundTasks(context.Background()) compatSrv = compat.HTTPServer() compatSrv.ReadTimeout = 30 * time.Second compatSrv.WriteTimeout = 0 compatSrv.IdleTimeout = 120 * time.Second } errCh := make(chan error, 2) go func() { slog.Info("HTTP server listening", "addr", cfg.Server.Listen) if listenErr := srv.ListenAndServe(); listenErr != nil && listenErr != http.ErrServerClosed { errCh <- fmt.Errorf("HTTP server error: %w", listenErr) } }() if compatSrv != nil { go func() { slog.Info("Jellyfin compat server listening", "addr", compatSrv.Addr) if listenErr := compatSrv.ListenAndServe(); listenErr != nil && listenErr != http.ErrServerClosed { errCh <- fmt.Errorf("jellyfin compat server error: %w", listenErr) } }() } // Step 11: Wait for termination signal. sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT) defer signal.Stop(sigCh) select { case sig := <-sigCh: appCancel() slog.Info("received signal, shutting down", "signal", sig) case serverErr := <-errCh: appCancel() slog.Error("server error, shutting down", "error", serverErr) } // Step 12: Graceful shutdown sequence. slog.Info("beginning graceful shutdown") shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second) defer shutdownCancel() // 1. Stop accepting new requests. if shutdownErr := srv.Shutdown(shutdownCtx); shutdownErr != nil { slog.Error("HTTP shutdown error", "error", shutdownErr) } if compatSrv != nil { if shutdownErr := compatSrv.Shutdown(shutdownCtx); shutdownErr != nil { slog.Error("jellyfin compat shutdown error", "error", shutdownErr) } } // 2. Clean up stale sessions. if sessionCleaner != nil { cleaned, cleanErr := sessionCleaner.CleanStale(shutdownCtx) if cleanErr != nil { slog.Error("stale session cleanup error", "error", cleanErr) } else if cleaned > 0 { slog.Info("cleaned stale sessions", "count", cleaned) } } // 2b. Remove this node's heartbeat and sessions from shared state. if heartbeatWriter != nil { if err := heartbeatWriter.CleanupSelf(shutdownCtx); err != nil { slog.Error("heartbeat cleanup error", "error", err) } } // 3. Close user store provider. if userStoreProvider != nil { if closeErr := userStoreProvider.Close(); closeErr != nil { slog.Error("user store provider close error", "error", closeErr) } } // 4. (match worker is now managed by the task manager — no separate cancel needed) // Suppress unused variable warnings for workers used only in deferred calls. _ = reconciler _ = heartbeatWriter _ = refreshWorker _ = adminJobRunner slog.Info("server stopped") } // startStandaloneServer runs a standalone HTTP server for proxy/transcode modes. // It listens on the given address, handles graceful shutdown on SIGTERM/SIGINT. func startStandaloneServer(addr string, handler http.Handler) { srv := &http.Server{ Addr: addr, Handler: handler, ReadTimeout: 30 * time.Second, WriteTimeout: 0, // no timeout for long streams IdleTimeout: 120 * time.Second, } errCh := make(chan error, 1) go func() { slog.Info("HTTP server listening", "addr", addr) if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { errCh <- fmt.Errorf("HTTP server error: %w", err) } }() sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT) select { case sig := <-sigCh: slog.Info("received signal, shutting down", "signal", sig) case serverErr := <-errCh: slog.Error("server error, shutting down", "error", serverErr) } shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() if err := srv.Shutdown(shutdownCtx); err != nil { slog.Error("HTTP shutdown error", "error", err) } slog.Info("server stopped") } // newS3ClientIfConfigured creates an S3 client only if the bucket name is // configured. Returns nil if the bucket is empty (not configured). func newS3ClientIfConfigured(cfg s3client.BucketConfig) *s3client.Client { if cfg.Bucket == "" { return nil } return s3client.NewClient(cfg) } func configureS3Clients(cfg *config.Config, deps *api.Dependencies) { if s3Public := newS3ClientIfConfigured(s3client.BucketConfig{ Endpoint: cfg.S3.Public.Endpoint, PublicEndpoint: cfg.S3.Public.ReadEndpoint, Region: cfg.S3.Public.Region, Bucket: cfg.S3.Public.Bucket, KeyPrefix: cfg.S3.Public.KeyPrefix, AccessKey: cfg.S3.Public.AccessKey, SecretKey: cfg.S3.Public.SecretKey, PathStyle: cfg.S3.Public.PathStyle, URLAuth: cfg.S3.Public.URLAuth, TokenSecret: cfg.S3.Public.TokenSecret, TokenParam: cfg.S3.Public.TokenParam, TokenTTL: cfg.S3.Public.TokenTTL, }); s3Public != nil { deps.S3Public = s3Public slog.Info("S3 public assets client configured", "bucket", s3Public.Bucket()) // Allow browsers to fetch presigned client-facing assets directly from S3. // Skip for public/token auth (e.g. Cloudflare R2) where CORS is managed externally. if !s3Public.UsesExternalAuth() { corsCtx, corsCancel := context.WithTimeout(context.Background(), 10*time.Second) if corsErr := s3Public.SetBucketCORS(corsCtx, s3Public.Bucket(), []string{"*"}); corsErr != nil { slog.Warn("failed to set CORS on public assets bucket", "error", corsErr) } corsCancel() } } if s3Private := newS3ClientIfConfigured(s3client.BucketConfig{ Endpoint: cfg.S3.Private.Endpoint, Region: cfg.S3.Private.Region, Bucket: cfg.S3.Private.Bucket, KeyPrefix: cfg.S3.Private.KeyPrefix, AccessKey: cfg.S3.Private.AccessKey, SecretKey: cfg.S3.Private.SecretKey, PathStyle: cfg.S3.Private.PathStyle, }); s3Private != nil { deps.S3Private = s3Private slog.Info("S3 private internal client configured", "bucket", s3Private.Bucket()) if !s3Private.UsesExternalAuth() { corsCtx, corsCancel := context.WithTimeout(context.Background(), 10*time.Second) if corsErr := s3Private.SetBucketCORS(corsCtx, s3Private.Bucket(), []string{"*"}); corsErr != nil { slog.Warn("failed to set CORS on private assets bucket", "error", corsErr) } corsCancel() } } if s3UserDB := newS3ClientIfConfigured(s3client.BucketConfig{ Endpoint: cfg.S3.UserDB.Endpoint, Region: cfg.S3.UserDB.Region, Bucket: cfg.S3.UserDB.Bucket, KeyPrefix: cfg.S3.UserDB.KeyPrefix, AccessKey: cfg.S3.UserDB.AccessKey, SecretKey: cfg.S3.UserDB.SecretKey, PathStyle: cfg.S3.UserDB.PathStyle, }); s3UserDB != nil { deps.S3UserDB = s3UserDB slog.Info("S3 user-db client configured", "bucket", s3UserDB.Bucket()) } } // mapFolderTypeToMediaType maps silo's MediaFolder.Type values // ("movies", "series", "mixed") to the SDK's MediaType values // ("movie", "tv", "mixed"). Unknown values map to "mixed". func mapFolderTypeToMediaType(t string) string { switch t { case "movies": return "movie" case "series": return "tv" default: return "mixed" } }