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.
200 lines
7.1 KiB
Go
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,
|
|
}
|
|
}
|