Files
silo-server/internal/jellycompat/server.go
T
CoffeeKnyteandGitHub b3722dac58 fix(playback): open the listener before sweeping stale transcode dirs (#413)
* fix(playback): run orphaned-transcode cleanup in the background at startup

The native and Jellyfin-compat routers swept stale per-session transcode
dirs synchronously during NewRouter, before the listener bound. On a slow
network filesystem this blocked startup for 80+s (64 leftover dirs on the
last deploy), so restart-reconnect clients were turned away and the health
check reported the server unhealthy the whole time.

Move both sweeps into a background goroutine (StartBackgroundOrphanCleanup)
so the listener comes up immediately and the cleanup runs concurrently. The
delete logic is unchanged: same active-session snapshot and MaxTokenTTL
age-sparing, only later. A package-level mutex serializes concurrent sweeps
of the shared transcode root so the two background sweeps can't race on
os.RemoveAll.

Part of #412

* fix(transcode): background the node boot-time transcode-dir sweep

A dedicated transcode node swept leftover transcode dirs synchronously in
NewServer, before startStandaloneServer bound its listener. On a slow
network filesystem that delete blocked the node from coming online at boot,
the same startup-stall class as the main server.

Move the sweep into the shared StartBackgroundOrphanCleanup goroutine so the
node's listener binds immediately. Backgrounding required an age guard: the
sweep previously ran as a full wipe (minAge=0) with an empty active-set,
which was only safe because it completed before any request could arrive.
Run concurrently that would race a token-carried reconstruct writing into
TranscodeDir/<sessionID>, deleting segments a fresh ffmpeg is producing.
Passing MaxTokenTTL spares any dir younger than the max token lifetime —
exactly the ones a still-valid reconnect could reconstruct — while dirs
older than any surviving token (never reconstructable) are still reclaimed.

Part of #412

* feat(playback): reclaim orphaned transcode dirs periodically, not just at boot

The orphaned-transcode sweep only ran at startup on both the central server
and transcode nodes, so it only ever reclaimed dirs left by an ungraceful
prior shutdown. During a long uptime the in-memory session reapers delete the
dirs of sessions they still track, but a dir whose owning session was dropped
without its RemoveAll succeeding becomes an "untracked orphan" with no runtime
GC — on a box that runs for weeks these accumulate until the next restart.

Add StartPeriodicOrphanCleanup: an immediate background sweep followed by an
hourly re-run bound to a lifecycle context. Wire it on all three surfaces —
native API and Jellyfin-compat (via deps.AppContext) and the transcode node
(via a new Server.StartOrphanSweeper(appCtx), replacing its boot-only sweep).
When no context is supplied (tests) it degrades to a single boot-time sweep so
no ticker goroutine outlives the caller. The sweep stays age-guarded at
MaxTokenTTL, so nothing reconstructable is ever reaped.

Because the node sweep now runs during live traffic, it snapshots the live
job set (Server.activeSessionIDs) and spares those dirs by id rather than by
age alone — a long-lived session that only re-serves already-written segments
stops advancing its dir mtime, which age could otherwise misclassify. In
integrated mode the native and compat sweeps share one TranscodeDir but each
snapshots only its own manager's live set; the resulting cross-manager reap of
a >24h idle dir is bounded (rebuilds from token/recipe) and documented at both
call sites.

Part of #412
2026-07-16 14:48:00 -04:00

186 lines
6.3 KiB
Go

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