Files
silo-server/internal/playback/session.go
T
e5c29eb8e0 feat(activity): report exact client app version, build, and channel (#631)
* feat(activity): report exact client app version, build, and channel

The admin Activity page could not name the build a session was streaming
from. Android already sent X-Silo-Client-Version and the server already
stored it intact, but playbackClientDisplayName routed it through
shortPlaybackClientVersion, which strips non-numeric runes, truncates to
two components, and drops a trailing ".0" — so a client reporting "1.0.0"
rendered as "Silo Android TV 1". The Apple clients sent no client name or
version at all and fell back to user-agent sniffing.

Adds two additive, opaque wire fields alongside the existing client
headers — X-Silo-Client-Build (<=64) and X-Silo-Client-Channel (<=32) —
with client_playback_context.app_build/app_channel as the v3 fallback,
which is also where the previously discarded app_version now gets used.
The server never parses, compares, or enum-validates either value: Apple
uses a per-platform TestFlight sequence and Android a per-marketing-
version counter, and keeping them opaque lets both coexist without a
shared scheme. Any future minimum-version gating belongs on
client_version, which is semver.

Only the named-client branch of playbackClientDisplayName stops
truncating; the user-agent branch keeps shortPlaybackClientVersion, so
browser labels stay "Chrome 120" rather than a full UA version string.
The compact session row is unchanged in width — it is shared with
AdminDashboard, AdminStats, and HouseholdStreamsPanel — and the exact
string lands in the row tooltip and a new Client card in the expanded
panel.

Diagnostic logs carry client_name/version/build/channel on both
"playback plan decided" lines and on session expiry. opslog stores an
open attrs JSONB, so this needs no migration. activity_log is
deliberately untouched: it is the highest-volume table and the value is
constant per device.

Jellyfin compat sessions keep an empty build — the MediaBrowser auth
header vocabulary has no build concept, and synthesizing one from a user
agent would be a guess.

Part of the client-version-visibility work spanning silo-android and
silo-apple.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(activity): resolve client identity without polluting client_version

Review follow-up on the client build/channel work. Fourteen findings; the
substantive ones:

The v3 body fallback took client_playback_context.app_version whenever the
header was absent. The web player sends the literal "web" there and sends no
X-Silo-Client, so every browser session would have stamped client_version="web"
— the one field the contract promises is semver and the field a future
minimum-version gate has to key on. client_playback_context carries no app name,
so the body can never identify a nameless client anyway; the fallback now
applies only to a client that sent X-Silo-Client, and a test pins the "web"
case.

An over-long app_build or app_channel in the start body failed the whole request
with 400 while the same value in a header was silently clamped — an opaque
diagnostic label could refuse playback. validateCapabilitiesV3 now clamps both
with the same helper the header path uses, which is what the docs already
claimed.

Route events posted out of band resolved identity from headers only, so a client
reporting its build in the start body attributed plan_selected to a build and
every later event of the same attempt to none. They now fill empty fields from
the session, as the replan path already did.

playbackClientFullDisplayName discarded build and channel whenever the client
reported no name, so the new Client card could never show a build for a
user-agent-labelled session. It now qualifies whatever label the compact
formatter resolved, which also drops its duplicated name+version assembly.

normalizeClientMetadataValue truncated by bytes; a multi-byte header value cut
mid-rune yields invalid UTF-8, which Postgres rejects — and the per-node session
upserts share one transaction, so one malformed client string would fail that
whole node's sync. It now clamps on a rune boundary.

replan-request.schema.json never got app_build/app_channel even though
ReplanRequestV3 reuses ClientPlaybackContextV3 and validates the same bounds. A
new contract test asserts every $def the two request schemas share is identical,
so the copies cannot drift again.

Also: the four client log attrs move to ClientInfo.LogAttrs(), which is now
their single definition and omits fields the client did not report rather than
persisting empty keys into opslog; startPlannedPlaybackV3 takes the resolved
identity instead of re-parsing the headers; client_label_full is omitted when it
would repeat client_label; getSessionClientLabelFull delegates to
getSessionClientLabel instead of re-implementing it; the Activity search matches
the exact label so a build number is findable; and the web ClientPlaybackContextV3
type mirrors the two new optional fields.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(activity): clamp client identity at the request boundary, by runes

Follow-up to bot review on the previous commit.

The 64/32 clamp for X-Silo-Client-Build / -Channel only ran where newSession
stamped its fields, but the resolved ClientInfo is written straight to the
plan-decision log and to playback_route_events. A client sending a header-sized
build reached both despite the published bound. ClientInfo.Normalized() is now
the single definition of those limits and runs at the request boundary —
playbackClientInfoFromRequest and playbackClientInfoForStartV3 — with newSession
still normalizing because identities also arrive from the Jellyfin and
Audiobookshelf compat surfaces.

normalizeClientMetadataValue now clamps by runes rather than bytes. The bounds
are published to clients as JSON Schema maxLength, which counts characters, so a
byte clamp cut values the contract calls valid — a 32-character emoji channel
was 128 bytes. It also scrubs invalid UTF-8 outright rather than only after a
mid-rune cut, since a header may carry bytes that were never valid UTF-8 and a
text column refuses them.

Two tests cover it: oversized headers clamp at the boundary, and a 40-rune
multi-byte channel lands on the 32-character bound as valid UTF-8.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(activity): strip control characters from client identity

A JSON NUL escape in a v3 start body's app_version, app_build or app_channel
decodes to a real NUL. That is valid UTF-8, so the UTF-8 repair leaves it and
TrimSpace does not treat it as whitespace — but Postgres refuses NUL in a text
column. The per-node session upserts share one transaction, so a single such
start would stop every live session on that node from reconciling until the
offending session went away. Headers cannot carry it (net/http rejects bytes
below 0x20), which is why only the body path this PR added is exposed.

normalizeClientMetadataValue now strips control characters outright rather than
NUL alone: none of them belong in an identity label rendered in the admin UI and
written to structured logs.

Reported by Codex review on b43b7ef06.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(activity): satisfy goconst and misspell on the changed log lines

CI's `golangci-lint --new-from-merge-base` failed on the previous commits: the
two decision-log calls were reformatted into slice literals, which brought their
"component" key inside the changed-lines window where goconst flags it against
the existing logComponentKey constant, and a doc comment used the British
"labelled". Both lines now use the constant, and the spelling is corrected here
and in docs/settings-api.md.

The file's other 16 "component" literals are left alone: CI only requires the
lines a branch touches to be clean, and rewriting them would bury this change in
unrelated churn.

Verified with the same command and version CI runs (golangci-lint v2.12.2,
--new-from-merge-base=origin/main): 0 issues.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(playback): require context-aware session starts

startPlannedPlaybackV3 probed for StartSessionWithFilesContext with a type
assertion and fell back to the context-free StartSessionWithFiles. The context
is how the reporting client's identity reaches the new session, so any
implementation missing the method would start sessions carrying no client name,
version, build or channel — silently, and now that build and channel ride the
same path, silently losing more.

SessionManagerInterface requires the method instead, so a non-conforming
implementation fails to compile rather than dropping the identity at run time.
The one test double gains a three-line method; production already implemented it.

Raised as a nitpick by CodeRabbit review; pre-existing, but it is this PR's data
that the fallback drops.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 09:18:47 -04:00

1512 lines
51 KiB
Go

package playback
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
"sync"
"time"
"unicode"
"unicode/utf8"
"github.com/google/uuid"
)
// Session represents an active playback session.
type Session struct {
ID string
UserID int
ProfileID string
MediaFileID int
RequestedMediaFileID int
PlayMethod PlayMethod
BasePlayMethod PlayMethod
TranscodeAudio bool // when true, remux should transcode audio to AAC
RemuxDVMode RemuxDVMode
ClientIP string // resolved client IP for the playback session
ClientName string // reported playback client name, when available
ClientVersion string // reported playback client version, when available
ClientBuild string // opaque reported client build identifier, when available
ClientChannel string // opaque reported client distribution channel, when available
ClientUserAgent string // trimmed request user agent for the playback session
IsJellyfinCompat bool // immutable origin identity for Jellyfin compatibility sessions
TranscodeNodeURL string // URL of assigned transcode node (empty = local/integrated)
TranscodeTransportID string // remote node process identity; empty means session ID
AudioTrackIndex int
StreamBitrateKbps int // currently delivered bitrate, when known
TargetResolution string // requested output resolution for transcodes
TargetVideoCodec string // requested output video codec for transcodes
TargetAudioCodec string // requested output audio codec when audio is transcoded
TargetAudioChannels int // requested encoded audio channel count
TargetAudioBitrateKbps int // requested encoded audio bitrate cap
TargetBitrateKbps int // requested output bitrate cap for transcodes
TranscodeHWAccel string // effective hardware acceleration mode for transcodes
// Byte-affecting transcode recipe fields the offloaded restart path needs to
// rebuild the exact same stream after an audio switch. Local transcodes read
// these from the live ts.Opts(); offloaded transcodes own no local runtime, so
// the session is the only place to recover them (see the track_change replan
// operation in internal/api/handlers/playback_v3.go).
SubtitleTrackIndex int // -1 = no subtitles
SubtitleBurnIn bool
SegmentDuration int // HLS segment length in seconds (cadence)
Position float64
IsPaused bool
HasWebSocket bool
HasRealtimeConnection bool
DisableProgressPersistence bool
StartedAt time.Time
UpdatedAt time.Time
LastActivityAt time.Time
activeTransportCount int
replacementPlayMethod PlayMethod
streamRevision uint64
// remoteTransport marks a session whose media bytes are served by another
// node, so this server never sees the transport request that would
// otherwise keep it alive. See SetRemoteTransport.
remoteTransport bool
}
// SessionStreamState stores the mutable stream-specific details that can
// change after a session is created (audio track, client IP, transcode target,
// and reported bitrate).
type SessionStreamState struct {
PlayMethod PlayMethod
BasePlayMethod PlayMethod
AudioTrackIndex int
TranscodeAudio bool
RemuxDVMode RemuxDVMode
ClientIP string
ClientName string
ClientVersion string
ClientUserAgent string
StreamBitrateKbps int
TargetResolution string
TargetVideoCodec string
TargetAudioCodec string
TargetAudioChannels int
TargetAudioBitrateKbps int
TargetBitrateKbps int
TranscodeHWAccel string
TranscodeNodeURL string
TranscodeTransportID string
TranscodeRouteSet bool
// Byte-affecting transcode recipe fields preserved so an offloaded restart
// (e.g. audio switch) can rebuild the exact same stream. SubtitleTrackIndex
// defaults to 0 on a zero-value state; callers that manage subtitles must set
// it explicitly (-1 for none) — burn-in is additionally gated by
// SubtitleBurnIn so a zero index never burns track 0 by accident.
SubtitleTrackIndex int
SubtitleBurnIn bool
SegmentDuration int
}
// TranscodeRoute identifies the process serving a playback session. An empty
// NodeURL means the integrated server owns the process; an empty TransportID
// means a remote process uses the public playback session ID.
type TranscodeRoute struct {
NodeURL string
TransportID string
}
// SessionReplacement is the complete mutable session state associated with a
// protocol-v3 replacement plan. Position is optional because ordinary failure
// recovery must preserve the player's latest progress while seek recovery
// intentionally moves the authoritative timeline.
type SessionReplacement struct {
EffectiveMediaFileID int
StreamState SessionStreamState
PositionSeconds *float64
IsPaused bool
PreservePaused bool
}
// SessionReplacementRollback is an opaque compare-and-swap token returned by
// ApplyReplacement. It can restore the previous session state only while no
// newer stream or progress mutation has superseded the replacement.
type SessionReplacementRollback struct {
sessionID string
appliedRevision uint64
previousEffectiveMediaFileID int
previousStreamState SessionStreamState
previousPosition float64
previousPaused bool
restoreProgress bool
previousReplacementMethod PlayMethod
}
// ErrSessionReplacementSuperseded means a replacement rollback would overwrite
// a newer session mutation. Callers should terminate the session rather than
// expose state that disagrees with the durable playback plan.
var ErrSessionReplacementSuperseded = errors.New("session replacement was superseded")
type clientInfoContextKey struct{}
// ClientInfo carries best-effort client metadata from request handling into
// the playback session manager.
type ClientInfo struct {
Name string
Version string
// Build is the client's opaque per-platform build identifier (Android
// versionCode, Apple CFBundleVersion, …). The server never parses or
// compares it; it exists so an admin can name the exact build.
Build string
// Channel is the client's opaque distribution channel ("release", "beta",
// "sideload", "dev", …). Stored verbatim — deliberately not validated
// against an enum so a new channel needs no server change.
Channel string
UserAgent string
IsCompat bool
}
// Normalized returns the identity with every field trimmed and clamped to the
// bound published for it (docs/settings-api.md, and maxLength in the v3 request
// schemas). This is the single definition of those bounds, and callers apply it
// at the request boundary: a session stamps normalized values, but the decision
// logs and playback_route_events are written straight from the resolved
// identity, so clamping only at session creation would let a client's oversized
// header through to both.
func (c ClientInfo) Normalized() ClientInfo {
c.Name = normalizeClientMetadataValue(c.Name, 128)
c.Version = normalizeClientMetadataValue(c.Version, 64)
c.Build = normalizeClientMetadataValue(c.Build, 64)
c.Channel = normalizeClientMetadataValue(c.Channel, 32)
c.UserAgent = normalizeClientMetadataValue(c.UserAgent, 512)
return c
}
// LogAttrs renders the app identity as slog key/value pairs, skipping the
// fields the client did not report. This is the single definition of those log
// keys — every playback decision and the session-expiry line share it, so a
// rename cannot leave one surface keyed differently from another. Skipping
// empty values matters as much: browsers and Jellyfin-ecosystem clients report
// none of them, and opslog persists the attrs it is handed, so emitting four
// empty keys per decision would grow /admin/logs for no diagnostic value.
func (c ClientInfo) LogAttrs() []any {
attrs := make([]any, 0, 8)
for _, pair := range [...]struct{ key, value string }{
{"client_name", c.Name},
{"client_version", c.Version},
{"client_build", c.Build},
{"client_channel", c.Channel},
} {
if pair.value != "" {
attrs = append(attrs, pair.key, pair.value)
}
}
return attrs
}
// ClientInfo returns the app identity stamped on the session when it was
// created. Surfaces that only hold a session — expiry logging, route events
// posted out of band — recover the reporting client through this instead of
// re-reading request headers that may no longer be present.
func (s *Session) ClientInfo() ClientInfo {
if s == nil {
return ClientInfo{}
}
return ClientInfo{
Name: s.ClientName,
Version: s.ClientVersion,
Build: s.ClientBuild,
Channel: s.ClientChannel,
UserAgent: s.ClientUserAgent,
IsCompat: s.IsJellyfinCompat,
}
}
// WithClientInfo stores playback client metadata on a context.
func WithClientInfo(ctx context.Context, info ClientInfo) context.Context {
if ctx == nil {
ctx = context.Background()
}
return context.WithValue(ctx, clientInfoContextKey{}, info)
}
// ClientInfoFromContext returns playback client metadata stored on a context.
func ClientInfoFromContext(ctx context.Context) ClientInfo {
if ctx == nil {
return ClientInfo{}
}
info, _ := ctx.Value(clientInfoContextKey{}).(ClientInfo)
return info
}
// SessionManager tracks active playback sessions and enforces stream limits.
type SessionManager struct {
sessions map[string]*Session
mu sync.RWMutex
maxStreams int
maxTranscodes int
limitProvider SessionLimitProvider
admissionDecider AdmissionDecider
activeGrace time.Duration
pausedGrace time.Duration
expireHook func(*Session)
}
// SessionLimits stores per-user admission limits. Zero values mean unlimited.
type SessionLimits struct {
MaxStreams int
MaxTranscodes int
TranscodingDisabled bool
AudioTranscodingDisabled bool
}
// SessionLimitProvider returns the current admission limits for a user.
type SessionLimitProvider func(ctx context.Context, userID int) (SessionLimits, error)
// AdmissionRequest is the fact set passed to an optional policy admission
// decider. Counts are computed by SessionManager from live in-memory sessions.
type AdmissionRequest struct {
UserID int
Limits SessionLimits
CurrentActiveStreams int
CurrentActiveTranscodes int
RequestedMethod PlayMethod
RequiresVideoTranscode bool
RequiresAudioTranscode bool
}
// AdmissionDecision is the result of an optional policy admission decision.
// Reason is free text for logs; ReasonCode is the typed contract mapped to
// sentinel errors (values mirror the vendor policy reason_code output).
type AdmissionDecision struct {
Allowed bool
Reason string
ReasonCode string
}
// Admission reason codes recognized by admissionDenyError. They mirror the
// policy package's ReasonCode* constants; playback cannot import policy
// (policy's adapters import playback), so the shared values are pinned by
// tests on both sides.
const (
AdmissionReasonMaxStreamsExceeded = "max_streams_exceeded"
AdmissionReasonMaxTranscodesExceeded = "max_transcodes_exceeded"
AdmissionReasonTranscodingDisabled = "transcoding_disabled"
AdmissionReasonAudioTranscodingDisabled = "audio_transcoding_disabled"
)
// AdmissionDecider can replace SessionManager's inline limit comparison while
// keeping session counting in Go.
type AdmissionDecider func(ctx context.Context, req AdmissionRequest) (AdmissionDecision, error)
const (
// DefaultActiveSessionGrace is how long an unpaused session may go without
// observed playback activity before it stops counting toward limits.
DefaultActiveSessionGrace = 45 * time.Second
// remoteTransportIdleGrace is the floor on the idle windows for a session
// whose media is served by another node. See remoteTransportGrace: it must
// outlast a heartbeat gap on an otherwise healthy proxy stream, while still
// reaping a session whose client vanished without stopping.
remoteTransportIdleGrace = 5 * time.Minute
// DefaultPausedSessionGrace is the longer grace period for paused
// sessions. It must comfortably cover an intentional pause (dinner
// break, phone call): reaping a paused session kills its transcode
// and there is currently no revival path, so a too-short grace makes
// pressing Play after a long pause freeze the client (issue #243).
// Keep in sync with pausedSessionGrace in internal/worker/cleanup.go.
DefaultPausedSessionGrace = 30 * time.Minute
)
// NewSessionManager creates a SessionManager with the given concurrency limits.
// maxStreams limits total active streams per user.
// maxTranscodes limits concurrent transcode streams per user.
func NewSessionManager(maxStreams, maxTranscodes int) *SessionManager {
return &SessionManager{
sessions: make(map[string]*Session),
maxStreams: maxStreams,
maxTranscodes: maxTranscodes,
activeGrace: DefaultActiveSessionGrace,
pausedGrace: DefaultPausedSessionGrace,
}
}
// SetLimitProvider overrides the manager defaults with dynamic per-user
// limits. The constructor limits remain the fallback when no provider is set.
func (m *SessionManager) SetLimitProvider(provider SessionLimitProvider) {
m.mu.Lock()
defer m.mu.Unlock()
m.limitProvider = provider
}
// SetAdmissionDecider installs an optional policy admission hook. A nil decider
// keeps the legacy inline comparison.
func (m *SessionManager) SetAdmissionDecider(decider AdmissionDecider) {
m.mu.Lock()
defer m.mu.Unlock()
m.admissionDecider = decider
}
// SetLivenessGracePeriods overrides the grace periods used by admission
// control and stale-session cleanup.
func (m *SessionManager) SetLivenessGracePeriods(active, paused time.Duration) {
m.mu.Lock()
defer m.mu.Unlock()
if active > 0 {
m.activeGrace = active
}
if paused > 0 {
m.pausedGrace = paused
}
}
// SetExpirationHook registers a callback that runs after a session is removed
// by stale cleanup. The hook executes outside the manager lock.
func (m *SessionManager) SetExpirationHook(fn func(*Session)) {
m.mu.Lock()
defer m.mu.Unlock()
m.expireHook = fn
}
func normalizeClientMetadataValue(value string, maxLen int) string {
// A text column takes neither invalid UTF-8 nor a NUL, and Postgres refuses
// the whole statement for either. The per-node session upserts share one
// transaction, so a single malformed client string would stop that entire
// node from reconciling — not just its own row — until the session goes
// away. Both scrubs are needed: NUL is perfectly valid UTF-8, so
// ToValidUTF8 leaves it, and a v3 start body can carry one as the JSON
// escape (headers cannot — net/http rejects bytes below 0x20).
// Control characters are stripped wholesale rather than just NUL: none of
// them belong in an identity label rendered in the admin UI and written to
// structured logs.
if !utf8.ValidString(value) {
value = strings.ToValidUTF8(value, "")
}
if strings.ContainsFunc(value, unicode.IsControl) {
value = strings.Map(func(r rune) rune {
if unicode.IsControl(r) {
return -1
}
return r
}, value)
}
value = strings.TrimSpace(value)
if maxLen <= 0 || len(value) <= maxLen {
return value
}
// Clamp by runes, not bytes. These bounds are published to clients as JSON
// Schema maxLength, which counts characters — a byte clamp would silently
// cut a value the contract calls valid, and cutting mid-rune would produce
// exactly the invalid UTF-8 scrubbed above.
runes := 0
for offset := range value {
if runes == maxLen {
return value[:offset]
}
runes++
}
return value
}
// StartSession creates a new playback session using the same file as both the
// requested and effective source.
func (m *SessionManager) StartSession(userID int, profileID string, fileID int, method PlayMethod, transcodeAudio bool) (*Session, error) {
return m.StartSessionWithContext(context.Background(), userID, profileID, fileID, method, transcodeAudio)
}
// StartSessionWithContext creates a new playback session using the same file
// as both the requested and effective source.
func (m *SessionManager) StartSessionWithContext(
ctx context.Context,
userID int,
profileID string,
fileID int,
method PlayMethod,
transcodeAudio bool,
) (*Session, error) {
return m.StartSessionWithFilesContext(ctx, userID, profileID, fileID, fileID, method, transcodeAudio)
}
// StartSessionWithFiles creates a new playback session after checking
// concurrency limits. requestedFileID is the user's requested version while
// effectiveFileID is the file currently backing playback.
// Returns ErrTooManyStreams if the user has reached the max active stream count.
// Returns ErrTooManyTranscodes if the user has reached the max transcode count
// and the requested method is transcode.
// Returns ErrTranscodingDisabled when the user may not start a video or audio transcode.
func (m *SessionManager) StartSessionWithFiles(
userID int,
profileID string,
effectiveFileID int,
requestedFileID int,
method PlayMethod,
transcodeAudio bool,
) (*Session, error) {
return m.StartSessionWithFilesContext(context.Background(), userID, profileID, effectiveFileID, requestedFileID, method, transcodeAudio)
}
// StartSessionWithFilesContext creates a new playback session after checking
// concurrency limits with request-scoped limit lookup.
func (m *SessionManager) StartSessionWithFilesContext(
ctx context.Context,
userID int,
profileID string,
effectiveFileID int,
requestedFileID int,
method PlayMethod,
transcodeAudio bool,
) (*Session, error) {
if ctx == nil {
ctx = context.Background()
}
limits, err := m.limitsForUser(ctx, userID)
if err != nil {
return nil, err
}
for {
m.mu.Lock()
decider := m.admissionDecider
if decider == nil {
if err := m.inlineAdmissionErrorLocked(userID, method, transcodeAudio, limits); err != nil {
m.mu.Unlock()
return nil, err
}
s := newSession(ctx, userID, profileID, effectiveFileID, requestedFileID, method, transcodeAudio)
m.sessions[s.ID] = s
m.mu.Unlock()
return s, nil
}
activeStreams := m.activeCountLocked(userID)
activeTranscodes := m.transcodeCountLocked(userID)
m.mu.Unlock()
decision, err := decider(ctx, AdmissionRequest{
UserID: userID,
Limits: limits,
CurrentActiveStreams: activeStreams,
CurrentActiveTranscodes: activeTranscodes,
RequestedMethod: method,
RequiresVideoTranscode: method == PlayTranscode,
RequiresAudioTranscode: transcodeAudio,
})
if err != nil {
// Fail closed, but make an engine outage distinguishable from a
// genuine concurrency-limit denial in the logs.
slog.WarnContext(ctx, "playback admission decider error; denying session", "component", "playback",
"user_id", userID, "method", method, "error", err)
return nil, admissionDenyError("")
}
if !decision.Allowed {
return nil, admissionDenyError(decision.ReasonCode)
}
m.mu.Lock()
if activeStreams != m.activeCountLocked(userID) || activeTranscodes != m.transcodeCountLocked(userID) {
m.mu.Unlock()
continue
}
s := newSession(ctx, userID, profileID, effectiveFileID, requestedFileID, method, transcodeAudio)
m.sessions[s.ID] = s
m.mu.Unlock()
return s, nil
}
}
func (m *SessionManager) inlineAdmissionErrorLocked(userID int, method PlayMethod, transcodeAudio bool, limits SessionLimits) error {
if err := transcodingDisabledError(method == PlayTranscode, transcodeAudio, limits); err != nil {
return err
}
if limits.MaxStreams > 0 && m.activeCountLocked(userID) >= limits.MaxStreams {
return ErrTooManyStreams
}
if method == PlayTranscode && limits.MaxTranscodes > 0 && m.transcodeCountLocked(userID) >= limits.MaxTranscodes {
return ErrTooManyTranscodes
}
return nil
}
func newSession(
ctx context.Context,
userID int,
profileID string,
effectiveFileID int,
requestedFileID int,
method PlayMethod,
transcodeAudio bool,
) *Session {
now := time.Now()
// Normalize here as well as at the request boundary: identities also reach
// the manager from the Jellyfin and Audiobookshelf compat surfaces, which
// build a ClientInfo from their own header vocabularies.
clientInfo := ClientInfoFromContext(ctx).Normalized()
return &Session{
ID: uuid.New().String(),
UserID: userID,
ProfileID: profileID,
MediaFileID: effectiveFileID,
RequestedMediaFileID: requestedFileID,
PlayMethod: method,
BasePlayMethod: method,
TranscodeAudio: transcodeAudio,
Position: 0,
IsPaused: false,
ClientName: clientInfo.Name,
ClientVersion: clientInfo.Version,
ClientBuild: clientInfo.Build,
ClientChannel: clientInfo.Channel,
ClientUserAgent: clientInfo.UserAgent,
IsJellyfinCompat: clientInfo.IsCompat,
StartedAt: now,
UpdatedAt: now,
LastActivityAt: now,
}
}
// admissionDenyError maps a typed reason code to a sentinel error. Anything
// unrecognized — custom-override denials, engine failures — is a generic
// policy denial, not a concurrency-limit error.
func admissionDenyError(reasonCode string) error {
switch reasonCode {
case AdmissionReasonMaxStreamsExceeded:
return ErrTooManyStreams
case AdmissionReasonMaxTranscodesExceeded:
return ErrTooManyTranscodes
case AdmissionReasonTranscodingDisabled:
return ErrTranscodingDisabled
case AdmissionReasonAudioTranscodingDisabled:
return ErrAudioTranscodingDisabled
default:
return ErrPlaybackNotAllowed
}
}
// RegisterReconstructed re-inserts a session under an existing ID after the
// in-memory state was lost (e.g. a server restart). Unlike StartSession* it
// does NOT mint a new UUID and does NOT run admission/limit accounting: the
// session already existed and was admitted before the restart, so counting it
// again would be wrong. If a live session with the same ID already exists
// (a concurrent reconstruct won the race), the existing one is returned and
// the caller's copy is discarded.
//
// The caller is responsible for having re-bound s.UserID to the live
// authenticated request before calling this — RegisterReconstructed performs
// no authorization itself.
func (m *SessionManager) RegisterReconstructed(s *Session) *Session {
if s == nil || s.ID == "" {
return s
}
m.mu.Lock()
defer m.mu.Unlock()
if existing, ok := m.sessions[s.ID]; ok {
return existing
}
now := time.Now()
if s.StartedAt.IsZero() {
s.StartedAt = now
}
s.UpdatedAt = now
s.LastActivityAt = now
m.sessions[s.ID] = s
return s
}
// RegisterReconstructedWithLimits is RegisterReconstructed plus the same per-user
// admission caps StartSession enforces. Token-carried reconstruct replays a
// signed recipe to rebuild a session lost to a restart; without a cap check a
// client could replay one token repeatedly (or after legitimately reaching its
// limit) and reconstruct past the per-user concurrent stream/transcode caps,
// since RegisterReconstructed skips admission accounting.
//
// Legitimately reconstructing a user's own surviving sessions still succeeds:
// the cap counts the user's *currently-live* sessions, and the one being rebuilt
// is not yet in the map, so the first MaxStreams reconstructs admit. Only the
// over-cap replay or disabled transcode is refused. If an
// identical session id is already live (a concurrent reconstruct won), it is
// returned without re-counting. Caps are looked up via the same limit provider
// as StartSession.
func (m *SessionManager) RegisterReconstructedWithLimits(ctx context.Context, s *Session) (*Session, error) {
if s == nil || s.ID == "" {
return s, nil
}
if ctx == nil {
ctx = context.Background()
}
limits, err := m.limitsForUser(ctx, s.UserID)
if err != nil {
return nil, err
}
m.mu.Lock()
defer m.mu.Unlock()
if existing, ok := m.sessions[s.ID]; ok {
return existing, nil
}
// The session being reconstructed is not yet in the map, so the live counts
// reflect the user's *other* sessions; admitting one more must stay within cap.
if err := transcodingDisabledError(s.PlayMethod == PlayTranscode, s.TranscodeAudio, limits); err != nil {
return nil, err
}
if limits.MaxStreams > 0 && m.activeCountLocked(s.UserID) >= limits.MaxStreams {
return nil, ErrTooManyStreams
}
if s.PlayMethod == PlayTranscode && limits.MaxTranscodes > 0 &&
m.transcodeCountLocked(s.UserID) >= limits.MaxTranscodes {
return nil, ErrTooManyTranscodes
}
now := time.Now()
if s.StartedAt.IsZero() {
s.StartedAt = now
}
s.UpdatedAt = now
s.LastActivityAt = now
m.sessions[s.ID] = s
return s, nil
}
func (m *SessionManager) limitsForUser(ctx context.Context, userID int) (SessionLimits, error) {
m.mu.RLock()
provider := m.limitProvider
limits := SessionLimits{
MaxStreams: m.maxStreams,
MaxTranscodes: m.maxTranscodes,
}
m.mu.RUnlock()
if provider == nil {
return limits, nil
}
limits, err := provider(ctx, userID)
if err != nil {
// Tag provider failures with ErrLimitProviderUnavailable so the
// reconstruct admission path can distinguish a transient limit-lookup
// failure (which it may fail open on) from a genuine over-cap rejection.
return SessionLimits{}, fmt.Errorf("load session limits for user %d: %w",
userID, errors.Join(ErrLimitProviderUnavailable, err))
}
return limits, nil
}
// CheckTranscodingAllowed verifies account-level restrictions before an
// existing session switches to video or audio transcoding.
func (m *SessionManager) CheckTranscodingAllowed(ctx context.Context, userID int, requiresVideoTranscode bool) error {
limits, err := m.limitsForUser(ctx, userID)
if err != nil {
return err
}
return transcodingDisabledError(requiresVideoTranscode, !requiresVideoTranscode, limits)
}
// CheckReplacementAllowed applies current user limits and admission policy to
// an in-place protocol-v3 recipe replacement. The existing session is excluded
// from the counts because the replacement inherits its stream slot; a direct
// to transcode change still has to acquire an available transcode slot.
func (m *SessionManager) CheckReplacementAllowed(ctx context.Context, sessionID string, method PlayMethod, transcodeAudio bool) error {
if ctx == nil {
ctx = context.Background()
}
// Bounded CAS: persistent count churn means the user is actively starting
// and stopping sessions; failing closed after a few rounds beats spinning
// with a limit-provider DB call per iteration.
const maxAdmissionRetries = 8
for attempt := 0; attempt < maxAdmissionRetries; attempt++ {
m.mu.Lock()
current, ok := m.sessions[sessionID]
if !ok {
m.mu.Unlock()
return ErrSessionNotFound
}
userID := current.UserID
currentMethod := current.PlayMethod
// Exclude the replaced session from both counts instead of decrementing
// the totals: a failed session idle past the liveness grace is already
// absent from the count, and a blind decrement would free a slot that
// belongs to another live session.
otherStreams := m.activeCountExcludingLocked(userID, sessionID)
otherTranscodes := m.transcodeCountExcludingLocked(userID, sessionID)
decider := m.admissionDecider
m.mu.Unlock()
limits, err := m.limitsForUser(ctx, userID)
if err != nil {
return err
}
if err := transcodingDisabledError(method == PlayTranscode, transcodeAudio, limits); err != nil {
return err
}
if decider == nil {
m.mu.Lock()
stillCurrent, stillExists := m.sessions[sessionID]
countsStable := stillExists && stillCurrent.PlayMethod == currentMethod && otherStreams == m.activeCountExcludingLocked(userID, sessionID) && otherTranscodes == m.transcodeCountExcludingLocked(userID, sessionID)
if !countsStable {
m.mu.Unlock()
continue
}
if limits.MaxTranscodes > 0 && method == PlayTranscode && otherTranscodes >= limits.MaxTranscodes {
m.mu.Unlock()
return ErrTooManyTranscodes
}
stillCurrent.replacementPlayMethod = method
m.mu.Unlock()
return nil
}
decision, err := decider(ctx, AdmissionRequest{UserID: userID, Limits: limits, CurrentActiveStreams: otherStreams, CurrentActiveTranscodes: otherTranscodes, RequestedMethod: method, RequiresVideoTranscode: method == PlayTranscode, RequiresAudioTranscode: transcodeAudio})
if err != nil {
// Fail closed, but make an engine outage distinguishable from a
// genuine concurrency-limit denial in the logs.
slog.WarnContext(ctx, "playback replacement admission decider error; denying replacement", "component", "playback",
"user_id", userID, "session", sessionID, "method", method, "error", err)
return ErrPlaybackNotAllowed
}
if !decision.Allowed {
return admissionDenyError(decision.ReasonCode)
}
m.mu.Lock()
stillCurrent, stillExists := m.sessions[sessionID]
countsStable := stillExists && stillCurrent.PlayMethod == currentMethod && otherStreams == m.activeCountExcludingLocked(userID, sessionID) && otherTranscodes == m.transcodeCountExcludingLocked(userID, sessionID)
if countsStable {
stillCurrent.replacementPlayMethod = method
m.mu.Unlock()
return nil
}
m.mu.Unlock()
}
return ErrPlaybackNotAllowed
}
// CancelReplacementReservation releases a protocol-v3 capacity reservation
// after a replacement fails before UpdateStreamState commits its new method.
func (m *SessionManager) CancelReplacementReservation(sessionID string) {
m.mu.Lock()
defer m.mu.Unlock()
if session := m.sessions[sessionID]; session != nil {
session.replacementPlayMethod = ""
}
}
func transcodingDisabledError(requiresVideoTranscode, requiresAudioTranscode bool, limits SessionLimits) error {
if requiresVideoTranscode && limits.TranscodingDisabled {
return ErrTranscodingDisabled
}
if requiresAudioTranscode && limits.TranscodingDisabled && limits.AudioTranscodingDisabled {
return ErrAudioTranscodingDisabled
}
return nil
}
// UpdateProgress updates the playback position and pause state for a session.
func (m *SessionManager) UpdateProgress(sessionID string, position float64, isPaused bool) error {
m.mu.Lock()
defer m.mu.Unlock()
s, ok := m.sessions[sessionID]
if !ok {
return ErrSessionNotFound
}
s.Position = position
s.IsPaused = isPaused
s.streamRevision++
m.touchSessionLocked(s)
return nil
}
// UpdateAudioTrack updates the audio track index and optionally the play
// method for a session. Used when switching audio tracks mid-playback.
func (m *SessionManager) UpdateAudioTrack(sessionID string, audioTrackIndex int, method PlayMethod) error {
m.mu.Lock()
defer m.mu.Unlock()
s, ok := m.sessions[sessionID]
if !ok {
return ErrSessionNotFound
}
s.AudioTrackIndex = audioTrackIndex
s.BasePlayMethod = method
if s.PlayMethod != PlayTranscode || method == PlayTranscode {
s.PlayMethod = method
}
s.streamRevision++
m.touchSessionLocked(s)
return nil
}
// UpdateStreamState updates the live stream details for a session. This keeps
// the session manager's authoritative copy in sync with user-driven changes
// like audio track switches and quality changes.
func (m *SessionManager) UpdateStreamState(sessionID string, state SessionStreamState) error {
m.mu.Lock()
defer m.mu.Unlock()
s, ok := m.sessions[sessionID]
if !ok {
return ErrSessionNotFound
}
applySessionStreamStateLocked(s, state)
s.streamRevision++
m.touchSessionLocked(s)
return nil
}
func applySessionStreamStateLocked(s *Session, state SessionStreamState) {
if state.PlayMethod != "" {
s.PlayMethod = state.PlayMethod
}
if state.BasePlayMethod != "" {
s.BasePlayMethod = state.BasePlayMethod
}
s.AudioTrackIndex = state.AudioTrackIndex
s.TranscodeAudio = state.TranscodeAudio
if state.TranscodeRouteSet {
// A full v3 route description owns the DV mode outright: a replan from
// a DV strip remux to an SDR source must clear the stale mode or every
// later remux request fails the profile check. Legacy partial updates
// never carry a mode and must not clobber one.
s.RemuxDVMode = state.RemuxDVMode
} else if state.RemuxDVMode != "" {
s.RemuxDVMode = state.RemuxDVMode
}
s.ClientIP = state.ClientIP
if value := normalizeClientMetadataValue(state.ClientName, 128); value != "" {
s.ClientName = value
}
if value := normalizeClientMetadataValue(state.ClientVersion, 64); value != "" {
s.ClientVersion = value
}
if value := normalizeClientMetadataValue(state.ClientUserAgent, 512); value != "" {
s.ClientUserAgent = value
}
s.StreamBitrateKbps = state.StreamBitrateKbps
s.TargetResolution = state.TargetResolution
s.TargetVideoCodec = state.TargetVideoCodec
s.TargetAudioCodec = state.TargetAudioCodec
s.TargetAudioChannels = state.TargetAudioChannels
s.TargetAudioBitrateKbps = state.TargetAudioBitrateKbps
s.TargetBitrateKbps = state.TargetBitrateKbps
s.TranscodeHWAccel = state.TranscodeHWAccel
if state.TranscodeRouteSet {
s.TranscodeNodeURL = state.TranscodeNodeURL
s.TranscodeTransportID = state.TranscodeTransportID
}
s.SubtitleTrackIndex = state.SubtitleTrackIndex
s.SubtitleBurnIn = state.SubtitleBurnIn
s.SegmentDuration = state.SegmentDuration
if state.TranscodeRouteSet {
// Only the replacement commit consumes the v3 capacity reservation;
// unrelated legacy stream updates arriving mid-replan must not release
// the slot and let a concurrent admission race past the transcode cap.
s.replacementPlayMethod = ""
}
}
func snapshotSessionStreamStateLocked(s *Session) SessionStreamState {
return SessionStreamState{
PlayMethod: s.PlayMethod,
BasePlayMethod: s.BasePlayMethod,
AudioTrackIndex: s.AudioTrackIndex,
TranscodeAudio: s.TranscodeAudio,
RemuxDVMode: s.RemuxDVMode,
ClientIP: s.ClientIP,
ClientName: s.ClientName,
ClientVersion: s.ClientVersion,
ClientUserAgent: s.ClientUserAgent,
StreamBitrateKbps: s.StreamBitrateKbps,
TargetResolution: s.TargetResolution,
TargetVideoCodec: s.TargetVideoCodec,
TargetAudioCodec: s.TargetAudioCodec,
TargetAudioChannels: s.TargetAudioChannels,
TargetAudioBitrateKbps: s.TargetAudioBitrateKbps,
TargetBitrateKbps: s.TargetBitrateKbps,
TranscodeHWAccel: s.TranscodeHWAccel,
TranscodeNodeURL: s.TranscodeNodeURL,
TranscodeTransportID: s.TranscodeTransportID,
TranscodeRouteSet: true,
SubtitleTrackIndex: s.SubtitleTrackIndex,
SubtitleBurnIn: s.SubtitleBurnIn,
SegmentDuration: s.SegmentDuration,
}
}
func restoreSessionStreamStateLocked(s *Session, state SessionStreamState) {
s.PlayMethod = state.PlayMethod
s.BasePlayMethod = state.BasePlayMethod
s.AudioTrackIndex = state.AudioTrackIndex
s.TranscodeAudio = state.TranscodeAudio
s.RemuxDVMode = state.RemuxDVMode
s.ClientIP = state.ClientIP
s.ClientName = state.ClientName
s.ClientVersion = state.ClientVersion
s.ClientUserAgent = state.ClientUserAgent
s.StreamBitrateKbps = state.StreamBitrateKbps
s.TargetResolution = state.TargetResolution
s.TargetVideoCodec = state.TargetVideoCodec
s.TargetAudioCodec = state.TargetAudioCodec
s.TargetAudioChannels = state.TargetAudioChannels
s.TargetAudioBitrateKbps = state.TargetAudioBitrateKbps
s.TargetBitrateKbps = state.TargetBitrateKbps
s.TranscodeHWAccel = state.TranscodeHWAccel
s.TranscodeNodeURL = state.TranscodeNodeURL
s.TranscodeTransportID = state.TranscodeTransportID
s.SubtitleTrackIndex = state.SubtitleTrackIndex
s.SubtitleBurnIn = state.SubtitleBurnIn
s.SegmentDuration = state.SegmentDuration
}
// ApplyReplacement atomically updates every live-session field owned by a
// protocol-v3 plan and returns a CAS rollback token for a later persistence
// failure.
func (m *SessionManager) ApplyReplacement(sessionID string, replacement SessionReplacement) (SessionReplacementRollback, error) {
m.mu.Lock()
defer m.mu.Unlock()
s, ok := m.sessions[sessionID]
if !ok {
return SessionReplacementRollback{}, ErrSessionNotFound
}
return m.applyReplacementLocked(s, sessionID, replacement)
}
// ApplyReplacementIfRoute applies a complete replacement only while the
// session still routes to expected. It publishes route and stream state in one
// critical section so callers never expose a successor with predecessor state.
func (m *SessionManager) ApplyReplacementIfRoute(
sessionID string,
expected TranscodeRoute,
replacement SessionReplacement,
) (SessionReplacementRollback, bool, error) {
m.mu.Lock()
defer m.mu.Unlock()
s, ok := m.sessions[sessionID]
if !ok {
return SessionReplacementRollback{}, false, ErrSessionNotFound
}
if s.TranscodeNodeURL != expected.NodeURL || s.TranscodeTransportID != expected.TransportID {
return SessionReplacementRollback{}, false, nil
}
rollback, err := m.applyReplacementLocked(s, sessionID, replacement)
return rollback, err == nil, err
}
func (m *SessionManager) applyReplacementLocked(
s *Session,
sessionID string,
replacement SessionReplacement,
) (SessionReplacementRollback, error) {
if replacement.EffectiveMediaFileID <= 0 {
return SessionReplacementRollback{}, errors.New("replacement effective media file id is invalid")
}
rollback := SessionReplacementRollback{
sessionID: sessionID,
previousEffectiveMediaFileID: s.MediaFileID,
previousStreamState: snapshotSessionStreamStateLocked(s),
previousReplacementMethod: s.replacementPlayMethod,
}
if replacement.PositionSeconds != nil {
rollback.previousPosition = s.Position
rollback.previousPaused = s.IsPaused
rollback.restoreProgress = true
}
s.MediaFileID = replacement.EffectiveMediaFileID
applySessionStreamStateLocked(s, replacement.StreamState)
if replacement.PositionSeconds != nil {
s.Position = *replacement.PositionSeconds
if !replacement.PreservePaused {
s.IsPaused = replacement.IsPaused
}
}
s.streamRevision++
rollback.appliedRevision = s.streamRevision
m.touchSessionLocked(s)
return rollback, nil
}
// RollbackReplacement restores the state captured by ApplyReplacement when no
// newer session mutation has superseded it.
func (m *SessionManager) RollbackReplacement(sessionID string, rollback SessionReplacementRollback) error {
m.mu.Lock()
defer m.mu.Unlock()
s, ok := m.sessions[sessionID]
if !ok {
return ErrSessionNotFound
}
if rollback.sessionID != sessionID || rollback.appliedRevision == 0 || s.streamRevision != rollback.appliedRevision {
return ErrSessionReplacementSuperseded
}
s.MediaFileID = rollback.previousEffectiveMediaFileID
restoreSessionStreamStateLocked(s, rollback.previousStreamState)
if rollback.restoreProgress {
s.Position = rollback.previousPosition
s.IsPaused = rollback.previousPaused
}
s.replacementPlayMethod = rollback.previousReplacementMethod
s.streamRevision++
m.touchSessionLocked(s)
return nil
}
// SetTranscodeStreamDetails records the actual encode decisions of a running
// transcode on the session — video copy vs re-encode, and whether audio is
// re-encoded — so session sync and the admin activity views classify the
// stream by what ffmpeg is doing rather than by the transport method alone
// (an HLS session with copied video is a repackage, not a video transcode).
func (m *SessionManager) SetTranscodeStreamDetails(sessionID, targetVideoCodec, targetAudioCodec string, transcodeAudio bool) error {
m.mu.Lock()
defer m.mu.Unlock()
s, ok := m.sessions[sessionID]
if !ok {
return ErrSessionNotFound
}
s.TargetVideoCodec = targetVideoCodec
s.TargetAudioCodec = targetAudioCodec
s.TranscodeAudio = transcodeAudio
m.touchSessionLocked(s)
return nil
}
// SetTranscodeNodeURL assigns a transcode node URL to an existing session.
func (m *SessionManager) SetTranscodeNodeURL(sessionID, url string) error {
m.mu.Lock()
defer m.mu.Unlock()
s, ok := m.sessions[sessionID]
if !ok {
return ErrSessionNotFound
}
s.TranscodeNodeURL = url
s.streamRevision++
m.touchSessionLocked(s)
return nil
}
// SetTranscodeRoute atomically assigns the node and process identity used to
// serve a transcode.
func (m *SessionManager) SetTranscodeRoute(sessionID string, route TranscodeRoute) error {
m.mu.Lock()
defer m.mu.Unlock()
s, ok := m.sessions[sessionID]
if !ok {
return ErrSessionNotFound
}
s.TranscodeNodeURL = route.NodeURL
s.TranscodeTransportID = route.TransportID
s.streamRevision++
m.touchSessionLocked(s)
return nil
}
// SetEffectiveMediaFileID updates the currently delivered source file while
// preserving the originally requested file selection.
func (m *SessionManager) SetEffectiveMediaFileID(sessionID string, fileID int) error {
m.mu.Lock()
defer m.mu.Unlock()
s, ok := m.sessions[sessionID]
if !ok {
return ErrSessionNotFound
}
if fileID > 0 {
s.MediaFileID = fileID
}
s.streamRevision++
m.touchSessionLocked(s)
return nil
}
// SetWebSocket marks whether a WebSocket liveness connection is active for a session.
func (m *SessionManager) SetWebSocket(sessionID string, connected bool) error {
m.mu.Lock()
defer m.mu.Unlock()
s, ok := m.sessions[sessionID]
if !ok {
return ErrSessionNotFound
}
s.HasWebSocket = connected
m.touchSessionLocked(s)
return nil
}
// SetRealtimeConnection marks whether a realtime control connection is active for a session.
func (m *SessionManager) SetRealtimeConnection(sessionID string, connected bool) error {
m.mu.Lock()
defer m.mu.Unlock()
s, ok := m.sessions[sessionID]
if !ok {
return ErrSessionNotFound
}
s.HasRealtimeConnection = connected
// The admin/session sync layer still exposes a generic websocket flag.
s.HasWebSocket = connected
m.touchSessionLocked(s)
return nil
}
// SetProgressPersistenceDisabled controls whether session progress updates and
// stop events should write resume/history state. This is useful for players
// whose resume timeline is not the same as the session's file-local timeline.
func (m *SessionManager) SetProgressPersistenceDisabled(sessionID string, disabled bool) error {
m.mu.Lock()
defer m.mu.Unlock()
s, ok := m.sessions[sessionID]
if !ok {
return ErrSessionNotFound
}
s.DisableProgressPersistence = disabled
m.touchSessionLocked(s)
return nil
}
// TouchActivity refreshes the session's activity timestamp without changing
// any other playback state.
func (m *SessionManager) TouchActivity(sessionID string) error {
m.mu.Lock()
defer m.mu.Unlock()
s, ok := m.sessions[sessionID]
if !ok {
return ErrSessionNotFound
}
m.touchSessionLocked(s)
return nil
}
// BeginTransport increments the count of in-flight media transport requests
// for the session and refreshes its activity timestamp.
func (m *SessionManager) BeginTransport(sessionID string) error {
m.mu.Lock()
defer m.mu.Unlock()
s, ok := m.sessions[sessionID]
if !ok {
return ErrSessionNotFound
}
s.activeTransportCount++
m.touchSessionLocked(s)
return nil
}
// SetRemoteTransport records whether this session's media bytes are served by
// another node (a proxy), rather than by a transport request this server
// handles itself.
//
// A locally-served stream is protected from the idle reaper by
// BeginTransport/EndTransport around the serve call. A proxy-served stream
// never enters that path: the client talks to the proxy directly, so from this
// server's point of view a healthy multi-hour stream looks identical to an
// abandoned session, and a heartbeat gap longer than the active grace would
// reap it mid-playback — after which progress, stop, and replan all fail with
// session-not-found while bytes are still flowing.
//
// This marks the session as remotely transported for its whole lifetime rather
// than per-request, because there is no request to bracket. Progress
// heartbeats still drive UI liveness; this only prevents reaping.
func (m *SessionManager) SetRemoteTransport(sessionID string, remote bool) error {
m.mu.Lock()
defer m.mu.Unlock()
s, ok := m.sessions[sessionID]
if !ok {
return ErrSessionNotFound
}
s.remoteTransport = remote
m.touchSessionLocked(s)
return nil
}
// EndTransport decrements the count of in-flight media transport requests for
// the session and refreshes its activity timestamp.
func (m *SessionManager) EndTransport(sessionID string) error {
m.mu.Lock()
defer m.mu.Unlock()
s, ok := m.sessions[sessionID]
if !ok {
return ErrSessionNotFound
}
if s.activeTransportCount > 0 {
s.activeTransportCount--
}
m.touchSessionLocked(s)
return nil
}
// StopSession removes a session from the manager.
func (m *SessionManager) StopSession(sessionID string) error {
m.mu.Lock()
defer m.mu.Unlock()
if _, ok := m.sessions[sessionID]; !ok {
return ErrSessionNotFound
}
delete(m.sessions, sessionID)
return nil
}
// GetSession returns the session with the given ID, or ErrSessionNotFound.
func (m *SessionManager) GetSession(sessionID string) (*Session, error) {
m.mu.RLock()
defer m.mu.RUnlock()
s, ok := m.sessions[sessionID]
if !ok {
return nil, ErrSessionNotFound
}
// Return a copy to avoid races.
cp := *s
return &cp, nil
}
// GetUserSessions returns all active sessions for a user.
func (m *SessionManager) GetUserSessions(userID int) []*Session {
m.mu.RLock()
defer m.mu.RUnlock()
var result []*Session
for _, s := range m.sessions {
if s.UserID == userID {
cp := *s
result = append(result, &cp)
}
}
return result
}
// GetSessionsByMediaFileID returns active sessions associated with the given file.
func (m *SessionManager) GetSessionsByMediaFileID(fileID int) []*Session {
m.mu.RLock()
defer m.mu.RUnlock()
if fileID <= 0 {
return nil
}
var result []*Session
for _, s := range m.sessions {
if s.MediaFileID != fileID && s.RequestedMediaFileID != fileID {
continue
}
cp := *s
result = append(result, &cp)
}
return result
}
// ActiveCount returns the number of active sessions for a user.
func (m *SessionManager) ActiveCount(userID int) int {
m.mu.RLock()
defer m.mu.RUnlock()
return m.activeCountLocked(userID)
}
// TranscodeCount returns the number of active transcode sessions for a user.
func (m *SessionManager) TranscodeCount(userID int) int {
m.mu.RLock()
defer m.mu.RUnlock()
return m.transcodeCountLocked(userID)
}
// activeCountLocked counts active sessions for a user. Caller must hold the lock.
func (m *SessionManager) activeCountLocked(userID int) int {
return m.activeCountExcludingLocked(userID, "")
}
// activeCountExcludingLocked counts a user's limit-relevant sessions while
// ignoring one session entirely. Replacement admission uses this instead of
// subtracting one from the total: a failed session awaiting replan is often
// idle past the liveness grace and already absent from the count, so a blind
// decrement would free another session's slot.
func (m *SessionManager) activeCountExcludingLocked(userID int, excludeSessionID string) int {
now := time.Now()
count := 0
for _, s := range m.sessions {
if excludeSessionID != "" && s.ID == excludeSessionID {
continue
}
if s.UserID == userID && m.countsTowardLimitsLocked(s, now) {
count++
}
}
return count
}
// transcodeCountLocked counts transcode sessions for a user. Caller must hold the lock.
func (m *SessionManager) transcodeCountLocked(userID int) int {
return m.transcodeCountExcludingLocked(userID, "")
}
func (m *SessionManager) transcodeCountExcludingLocked(userID int, excludeSessionID string) int {
now := time.Now()
count := 0
for _, s := range m.sessions {
if excludeSessionID != "" && s.ID == excludeSessionID {
continue
}
if s.UserID == userID && (s.PlayMethod == PlayTranscode || s.replacementPlayMethod == PlayTranscode) && m.countsTowardLimitsLocked(s, now) {
count++
}
}
return count
}
// AllSessions returns a snapshot of all active sessions. Each session is
// copied to avoid data races with concurrent updates.
func (m *SessionManager) AllSessions() []*Session {
m.mu.RLock()
defer m.mu.RUnlock()
result := make([]*Session, 0, len(m.sessions))
for _, s := range m.sessions {
cp := *s
result = append(result, &cp)
}
return result
}
// CleanExpired removes sessions whose last playback activity exceeds maxIdle.
// Paused sessions receive a 3x grace period for backwards compatibility.
func (m *SessionManager) CleanExpired(maxIdle time.Duration) []*Session {
return m.CleanInactive(maxIdle, maxIdle*3)
}
// CleanStale removes sessions that have exceeded the manager's configured
// liveness grace windows.
func (m *SessionManager) CleanStale() []*Session {
m.mu.RLock()
active := m.activeGrace
paused := m.pausedGrace
m.mu.RUnlock()
return m.CleanInactive(active, paused)
}
// CleanInactive removes sessions whose last playback activity exceeds the
// provided grace period. Sessions with an active media transport request are
// preserved even if they have not emitted a recent heartbeat yet.
func (m *SessionManager) CleanInactive(activeIdle, pausedIdle time.Duration) []*Session {
m.mu.Lock()
now := time.Now()
var expired []*Session
for id, s := range m.sessions {
if s.activeTransportCount > 0 {
continue
}
activeIdle, pausedIdle := remoteTransportGrace(s, activeIdle, pausedIdle)
if m.sessionIsInactiveLocked(s, now, activeIdle, pausedIdle) {
cp := *s
expired = append(expired, &cp)
delete(m.sessions, id)
}
}
hook := m.expireHook
m.mu.Unlock()
if hook != nil {
for _, s := range expired {
hook(s)
}
}
return expired
}
func (m *SessionManager) touchSessionLocked(s *Session) {
now := time.Now()
s.LastActivityAt = now
s.UpdatedAt = now
}
func (m *SessionManager) countsTowardLimitsLocked(s *Session, now time.Time) bool {
if s == nil {
return false
}
if s.activeTransportCount > 0 {
return true
}
activeGrace, pausedGrace := remoteTransportGrace(s, m.activeGrace, m.pausedGrace)
return !m.sessionIsInactiveLocked(s, now, activeGrace, pausedGrace)
}
// remoteTransportGrace widens the idle windows for a session whose bytes are
// served by another node.
//
// A locally-served stream is held open by an in-flight transport request. A
// proxy-served one has no such request here, so it is protected only by the
// client's progress heartbeats — and a gap longer than the active grace would
// reap it while media is still flowing. The windows are widened rather than
// made infinite: there is no absolute session lifetime cap in this manager, so
// unconditional immunity would leak a session forever whenever a client
// disappears without stopping. A client that has gone quiet for this long has
// genuinely stopped watching.
func remoteTransportGrace(s *Session, activeIdle, pausedIdle time.Duration) (time.Duration, time.Duration) {
if s == nil || !s.remoteTransport {
return activeIdle, pausedIdle
}
if activeIdle > 0 && activeIdle < remoteTransportIdleGrace {
activeIdle = remoteTransportIdleGrace
}
if pausedIdle > 0 && pausedIdle < remoteTransportIdleGrace {
pausedIdle = remoteTransportIdleGrace
}
return activeIdle, pausedIdle
}
func (m *SessionManager) sessionIsInactiveLocked(s *Session, now time.Time, activeIdle, pausedIdle time.Duration) bool {
if s == nil {
return true
}
lastActivity := s.LastActivityAt
if lastActivity.IsZero() {
lastActivity = s.UpdatedAt
}
if lastActivity.IsZero() {
lastActivity = s.StartedAt
}
if lastActivity.IsZero() {
return false
}
grace := activeIdle
if s.IsPaused {
grace = pausedIdle
}
if grace <= 0 {
return !lastActivity.After(now)
}
return !lastActivity.Add(grace).After(now)
}
// String returns a human-readable summary of a session.
func (s *Session) String() string {
return fmt.Sprintf("Session{id=%s user=%d file=%d method=%s pos=%.1f paused=%v}",
s.ID, s.UserID, s.MediaFileID, s.PlayMethod, s.Position, s.IsPaused)
}