Files
silo-server/internal/jellycompat/server.go
T
CoffeeKnyte 09de4f08ec feat(downloads): monitor in-flight download pours in memory
Six routes poured full media with no monitor record and no byte measurement:
the two native download routes, the Jellyfin-compat download, both ABS file
variants, and the public ABS RSS feed file. They were the last invisible bytes
on the server.

- internal/transfers: a process-local, in-memory registry of active pours.
  Bounded (10k entries) with rate-limited "full" warnings, all request-derived
  strings normalized and length-clamped (ABS and jellycompat carry no bounded
  client name, so those fields are header-derived and untrusted), overflow-safe
  byte accumulation, deterministic snapshots, nil-safe throughout. No I/O on any
  path, and no persistence: a pour dies with the process, so durable rows would
  only need reaping after a crash.
- Reuses the existing playback.SessionMeteredWriter via ServedBytesRecorder
  rather than adding a second writer — that writer is where the sendfile
  (ReadFrom) and kill-cut (Unwrap) hazards live and both have regressed before.
- Deliberately NOT plumbed through streammonitor/streamenforcer, which are
  untouched. Downloads stay off the live-stream path by construction: there is
  no type, field or collection through which one can reach the enforcer, so a
  download can never be counted against max_streams or trimmed as an over-cap
  stream.
- Admin visibility is a sibling `transfers` array on the existing
  /admin/nodes/sessions response; the `sessions` array is byte-for-byte
  unchanged. Transfers appear only in the unfiltered listing, since a node_id
  filter targets an edge and these are process-local.
- Per-pour ids are unique, never the download id: concurrent and repeated
  GET/Range requests against one download row are legitimate and would collide.
  The id is minted in the handler (where the revocation watcher is armed) and
  the registry entry is opened in the service only after file/artifact
  resolution succeeds, so a failed auth or lookup never registers a transfer.
- Defer order is an invariant at every call site: End is registered before the
  meter's Close so LIFO flushes the tail first. Reversed, a final sub-1MiB flush
  lands on an unknown id and is silently lost. Pinned by a test.

No schema change and no migration: downloads.bytes_sent keeps its documented
meaning (a lifecycle marker set to file_size on completion, not a live counter)
and is untouched.

Phase 2 — killing a single download, and a standing per-user download block —
is deliberately deferred. A user revocation already cuts in-flight download
pours; it is a cutoff, so it does not refuse new ones. The unique per-pour id
exists so phase 2 only changes "" to that id at each WatchAndCut site.

Part of the stream monitoring & kill-switch epic.
2026-07-29 16:42:37 +00:00

200 lines
7.1 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/streamrevoke"
"github.com/Silo-Server/silo-server/internal/subtitles"
"github.com/Silo-Server/silo-server/internal/transfers"
"github.com/Silo-Server/silo-server/internal/userstore"
"github.com/Silo-Server/silo-server/internal/watchstate"
"github.com/Silo-Server/silo-server/internal/watchsync"
)
// 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
// RevocationStore is the shared stream kill switch consulted by local serving.
RevocationStore *streamrevoke.Store
// TransferRegistry exposes download-class pours separately from live streams.
TransferRegistry *transfers.Registry
// 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
WatchScrobbler PlaybackWatchScrobbler
StableIdentityResolver watchsync.ScrobbleIdentityResolver
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.
// The returned channel closes after the initial terminal recovery scan so
// callers can order broader stale-session sweeps behind authoritative compat
// recovery without delaying HTTP startup.
func (s *Server) StartBackgroundTasks(ctx context.Context) <-chan struct{} {
if s.deps.DB != nil {
repo := NewSessionRepository(s.deps.DB, s.deps.SecretCipher)
StartSessionCleanupWithPlaybackStore(ctx, repo, s.deps.PlaybackStore, 1*time.Hour)
}
return StartTerminalScrobbleRecovery(ctx, s.deps.PlaybackStore, s.deps.WatchScrobbler, 30*time.Second)
}
// 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,
}
}