Files
silo-server/internal/playback/session.go
881c96864b feat(playback): finalize platform-neutral protocol v3 (#567)
* docs(playback): add v3 neutral-contract finalization plan

Supersedes the wire-contract sections of the 2026-07-12 v3 plan: server-owned
attempt keys, delivery-keyed negotiation without Media3 engine names, tiered
capability evidence, neutral device/output context, track/quality replan
operations, audio-only planning, and coordinated no-back-compat rollout
across server, Android, Apple, and web.

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

* feat(playback): make v3 attempt keys server-owned and replace engines with deliveries

Contract core of the platform-neutral v3 finalization (plan sections 3.1
and 3.2), breaking on purpose — v3 is dark and all clients move together:

- Every PlanV3 now carries plan_attempt_key, an opaque server-computed
  token clients store and echo in attempted_plan_keys; ReplanRequestV3
  gains bounded local_mutations that the replan handler folds into the
  failed plan's key. Clients never hash anything.
- KotlinName() is deleted from DeliveryV3, StreamProtocolV3 and
  SubtitleModeV3; the attempt-key canonical string now uses lowercase
  wire tokens, and PlanRecipeVersionV3 bumps to v3.3 so no key or plan
  ID computed under the old canonicalization can collide.
- EngineV3 leaves the wire: ClientPlaybackContextV3.Engines (media3_*)
  becomes Deliveries keyed original_http|progressive|hls, with
  EngineCapabilityV3 renamed DeliveryCapabilityV3. PlanV3.Engine is
  removed; the planner, subtitle policy and quirk registry re-key on
  delivery class, and the media3_only feature token is deleted.
- Validated-claim strings drop the prefix: media3_h264_decode ->
  h264_decode, media3_audio_decode -> audio_decode.
- Golden fixtures in testdata/protocol_v3 are regenerated by Go and are
  now the cross-repo source of truth.

Part of the playback protocol v3 neutral-contract train (steps 2-3 of
docs/superpowers/plans/2026-07-30-playback-protocol-v3-neutral-contract.md).

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

* feat(playback): add v3 evidence tiers and neutral device/output context

Implement plan sections 3.3 and 3.4 of the v3 neutral-contract pass:

- ClientCodecCapabilitiesV3 gains required video_evidence and
  audio_evidence closed enums (exact | platform_attested | declared).
  Planner strictness follows the tier: exact keeps the strict decode-entry
  validation, platform_attested validates codec/resolution/bit-depth/
  frame-rate but skips profile/level matching, declared grants copy routes
  from the flat codec lists. Only exact audio evidence earns passthrough
  claims. The detailed_decode_capabilities feature token is deleted
  (subsumed by video_evidence=exact), and evidence-blocked direct routes
  carry the new evidence_insufficient_for_direct reason/warning.

- DeviceContextV3 is now platform/os_version/manufacturer/model plus a
  bounded platform_details map (<=16 entries, <=128 chars); the Android
  Build dump fields are gone. Fire TV quirks keep matching on
  manufacturer/model (brand fallback removed with the field).

- output_route_generation (int64, dual-location) becomes an optional
  opaque output_context_id string on the output context; the dual-location
  consistency validation is deleted. Attempt keys, plan invalidation,
  route events, and the planstore column follow (new Goose migration).

- Feature advertisement collapses to the top-level client_features list
  only; ClientPlaybackContextV3.Features is deleted and ReplanRequestV3
  gains an optional client_features refresh.

- PlanRecipeVersionV3 bumped v3.3 -> v3.4; fixtures re-keyed.

Part of the playback protocol v3 neutral-contract finalization plan
(docs/superpowers/plans/2026-07-30-playback-protocol-v3-neutral-contract.md).

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

* feat(playback): add v3 intent replans, quality menu, and audio-only routes

Protocol v3 could only replan after a failure, so changing the audio track
or the quality still required the legacy audio PATCH and the client-recipe
transcode start — the two endpoints v3 is meant to replace. Clients also had
to own a resolution ladder to render a quality menu, and a source with no
video track was terminaled by the video/HDR gates, keeping audiobooks on the
legacy path.

Add track_change and quality_change replan operations. They carry no failure
classification and route through the existing replan transaction, so they
inherit its idempotency, capacity reservation, and staged-successor commit
for free. Because nothing failed, the previous route stays eligible: neither
the attempted-key history nor the failed-plan exclusion applies to them.

Publish the server ladder on the plan as available_qualities so the quality
menu is server-owned; the rungs come from the same resolutionLabelV3 and
ladderBitrateKbpsV3 helpers the planner itself uses, not a parallel table.

Plan audio-only sources through their own reduced route family: original_http
when the client decodes the codec, otherwise a progressive AAC conversion.
The plan advertises audio/mp4 for that remux and the transport now serves the
same value, because a declared-tier client probes the advertised MIME with
isTypeSupported before attaching a source buffer, and "video/mp4" on a stream
with no video track is exactly the mismatch that makes the probe lie.

Name the protocol's string vocabulary (dynamic ranges, transformations,
executors, validated claims, terminal reasons) as constants while touching
these lines, so the wire values have one definition.

Part of #135

* docs(playback): publish the v3 protocol contract and fix subtitle ordinals

Protocol v3 exists only as Go code today, so the Android and Apple ports have
no authority to implement against other than reading this repository. Publish
the contract as a normative document, machine-checkable schemas, and generated
golden fixtures, and fix the one place where the server's own wire output
disagreed with the ordinal space it publishes.

- docs/architecture/playback-protocol-v3.md is self-contained enough for a
  third-party client: endpoints and status codes, evidence tiers and their
  bound-matching rules, delivery classes, the timeline model, replan
  semantics, registries, track identity, plan identity, quality, and
  transformations.
- docs/design/schemas/playback-v3/ carries JSON Schemas for the five wire
  shapes plus valid and invalid fixtures, following the client-diagnostics
  layout. internal/playback/contract validates every fixture against its
  schema, so a schema that drifts from the Go types fails the Go suite.
- cmd/playbackfixtures generates internal/playback/testdata/protocol_v3 from
  the production planner. `make playback-fixtures` writes them and
  `make verify-playback-fixtures` (wired into CI) fails when they are stale.
  These files are what the client ports consume, so drift would otherwise
  surface as a playback bug on three platforms at once.

The subtitle fix: combined ordinals are one dense space over externals, then
embedded tracks, then downloaded ones, but the legacy URL builder skipped
burn-in-only tracks while assigning indices, so every track after a DVD/DVB
track was numbered one too low and resolved to its neighbour. Ordinal
assignment now lives in playback.BuildSubtitleInventoryV3 and both the plan
inventory and the legacy `subtitle_urls` shape project from it; the legacy
shape still filters burn-in-only entries but keeps each track's real index.

Part of #135

* feat(web): migrate the players to the neutral playback v3 contract

The web player was the last client still speaking the legacy start
protocol: it picked its own file version from a codec probe, posted an
ffmpeg recipe to start a transcode, PATCHed an endpoint to change audio
tracks, and derived its own quality ladder. None of that survives a
server-owned plan, and none of it produced telemetry the apps could be
compared against.

Video player: starts with a v3 request that advertises `declared`
evidence from `isTypeSupported` probes and the three delivery classes,
then consumes the returned plan for its URL, timeline, tracks and
warnings. Quality and track changes become replans (`quality_change`,
`track_change`), the quality menu renders `available_qualities` instead
of computing rungs, and playback failures emit `route-events` so web
failures land in the same diagnostics as Android and Apple. The
duration comes from `source.duration_seconds` rather than the playback
engine, and the "how was this delivered" overlay reads the plan's
delivery and server transformations instead of comparing codec strings.

Audiobook player: starts against the audio-only planner path with a
single `original` rung, and takes its seek anchor from
`timeline.player_start_seconds` so the progressive-remux route (which
anchors the stream and restarts the player clock at zero) does not seek
twice.

Server side, `disable_progress_persistence` left the wire, so the rule
it encoded is now derived. Resume state is keyed on the item, but every
part of a multipart presentation shares that key while carrying its own
file-local clock — persisting part 4's position would store "12 minutes
in" as the book's resume point. `PresentationPartTotal > 1` expresses
that directly and generalizes to multipart movies and split episodes,
and a client can no longer forget to ask or lie about it.

`useTranscodeQuality` and the legacy response types are deleted, and
`WEBTEST_KNOWN_FAILURES` loses the audiobook entry along with its fix.

Part of #135

* feat(playback)!: make v3 the only playback protocol

Protocol v3 shipped behind a flag, alongside the legacy start path it was
designed to replace. Running both meant every planner change had to be made
twice, in two shapes that disagree about who decides the route: the legacy
body carried a decision the client had already made, while v3 asks the server
to make it. This deletes the legacy half.

Removed:

- `handleStartPlaybackLegacy` and its request/response bodies. The
  `POST /playback/start` route stays, but the protocol-version dispatch
  envelope is now a strict v3 decode — a body that does not declare
  `protocol_version: 3` gets `426 client_upgrade_required` so an outdated app
  can render a clear "update required" state instead of misreading a plan.
  Deliberately not a `400`: the request may be well-formed for the protocol it
  was written against.
- `POST /playback/transcode/start`, superseded by the `quality_change` replan
  operation, and `PATCH /playback/{session_id}/audio`, superseded by
  `track_change`. Both mutated a session without re-planning.
- The shadow planner and both rollout settings rows. With v3 the only
  protocol, `playback.protocol_v3_enabled` would mean "no playback at all";
  `playback.protocol_v3_shadow_enabled` gated a comparison against a path that
  no longer exists. `409 protocol_disabled` on route-events goes with them, and
  capability `enabled` is now constant `true` (the field stays — clients
  feature-detect against it).
- Version-selection helpers in `internal/playback/resolver.go` that only legacy
  start reached. `Resolve`/`ClientCapabilities`/`PlayDecision` stay: downloads
  consumes them. `internal/jellycompat` has its own resolution surface and is
  untouched.

Behaviour the legacy handlers owned and v3 now owns explicitly: series version
and audio-track preferences are persisted on start and on a `track_change`
replan (not on failure recovery, whose forced route is not a user choice); an
omitted `start_position` resolves to the profile's saved resume point; and an
omitted audio track resolves through the series preference, the profile audio
language, then the library override. Both are settled before planning, because
the plan's timeline is cut at the start position. Spec §2.2 documents this as
"omission is a request, not a default".

The encode-target clamp that lived in the deleted transcode handler is already
enforced in the planner, twice — `availableQualitiesV3` omits rungs at or above
the source height, and the encode path clamps `targetHeight` to it.

Unchanged: progress, stop, HLS manifest and segment delivery, the realtime
control socket, stream tokens and restart reconstruction, watch together,
downloads, jellycompat.

Every removal is recorded in the pre-lock removals table in
docs/architecture/v1-scope.md.

Part of #135

* fix(scanner): stop recording embedded cover art as a video track

ffprobe reports embedded cover art as a video stream carrying
disposition.attached_pic. convertProbeData appended every "video" stream
to VideoTracks without consulting isMainVideoStream, the predicate that
already existed for duration decisions, so the picture was persisted as a
playable track. That misreports the file twice:

  - An audio file with a cover picks up a video track, so it no longer
    satisfies MediaFile.IsAudioOnly and the v3 planner routes an
    audiobook through the video path instead of planAudioOnlyV3.
  - When the picture is ordered ahead of the real stream, the flat
    codec_video/resolution/hdr columns describe the poster: a 954x720
    h264 episode was stored as mjpeg 480x480.

Filter attached_pic streams out of the track loop. The guard is the
disposition flag, not the codec name, so a genuine MJPEG video is still
probed as video — the library has one.

Already-probed rows self-heal on the next playback: NeedsCriticalProbeRepair
already reprobes tracks missing color_range, which covers 21 of the 23
affected rows, and applyProbeData overwrites VideoTracks wholesale. The
remaining two need a rescan; nothing persisted records attached_pic, and
keying repair off still-image codec names would reprobe the genuine MJPEG
file on every playback forever.

Part of the playback v3 neutral-contract work: it is what lets Android
drop AUDIOBOOK_COVER_ART_CODECS, which fabricated decode support the
client cannot honestly claim under video_evidence: "exact".

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

* fix(playback): publish subtitle URLs even when playback starts with subtitles off

The v3 plan's subtitle inventory is the authoritative track list a client builds
its subtitle menu from, but the handler only rewrote it with session-scoped URLs
when a track was actually selected. A start or replan that resolved to
`subtitle.mode: "off"` therefore returned the planner's URL-less inventory, so a
client whose picker reads the inventory had a menu it could not fetch anything
from. The Cast path hits this every time: it starts with subtitles off and needs
the receiver's text tracks up front.

attachSubtitleArtifactV3 now scopes and publishes the inventory unconditionally
and gates only the artifact stamping on the selection. Spec §8 records that the
`url` on a sidecar entry does not depend on the current selection.

Part of the v3 neutral-contract finalization.

* chore(playback): reconcile neutral v3 with main

* fix(playback): preserve subtitle intent across replans

* fix(playback): retain subtitle inventory on adapted routes

* fix(playback): software-decode High10 AVC for QSV

* fix(playback): scale High10 frames before QSV upload

* fix(playback): preserve empty subtitle inventories

* fix(playback): freeze terminal attempt contract

* chore(playback): name fixture contract tokens

* fix(playback): close v3 conformance review gaps

* chore(playback): name conformance category

* fix(playback): complete v3 conformance contract

* fix(playback): keep schema fixtures generated

* fix(playback): emit schema-valid conformance arrays

* fix(playback): omit empty replan failures

* fix(web): omit empty replan failures

* fix(playback): close neutral v3 contract gaps

* fix(playback): harden v3 replan, transcode, and quality-ladder edge cases

Review remediation for the neutral v3 cutover, server side:

- A failed replan no longer overwrites the durable StartResponse with a
  terminal or advances the replan request ID; an idempotent start replay
  of a still-healthy session returns the original plan.
- SoftwareVideoDecode is now derived inside the transcode layer from
  source facts (codec/profile/bit depth) carried on TranscodeOpts, so
  jellycompat, downloads, recipe-card reconstruction, and transcode
  nodes get the High10 software-decode fix, not just the v3 handler.
  video_to_h264 recipe version bumps to 2 so mixed-version node pools
  that would silently drop the flag fail validation instead.
- Local transport startup shares the 30s ManifestStartupTimeout; a
  timeout with the process still running stays retryable and is no
  longer persisted as a durable terminal against the attempt.
- Sparse replan bodies (failure_recovery et al) no longer reset a
  user-selected quality preference to auto; the empty-value guard now
  covers every operation.
- availableQualitiesV3 publishes no fixed rungs when the source height
  is unknown, keeping the no-upscaling ladder contract.
- The proxy remux path serves audio-only fMP4 as audio/mp4 via a new
  additive AudioOnly token claim, matching the integrated path.
- Plain text subtitle sidecars accept any requested extension again
  (served as VTT), restoring the permissive v1 behavior; ASS and bitmap
  handling is unchanged.
- The 4K-disallowed terminal message discloses when a lower-resolution
  alternate exists but was pinned away by quality "original".

Part of #135.

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

* fix(web): keep playback alive through failed replans and honest audio claims

Review remediation for the neutral v3 cutover, web player:

- A failed or refused replan no longer unmounts the player: the fatal
  error screen is reserved for loads with no adopted plan, and replan
  failures surface through the existing non-fatal replanError path.
- changeQuality rolls its optimistic preference back when the replan is
  refused or errors, so a failed switch is not silently applied by the
  next unrelated replan and the menu shows the real active rung.
- The capability probe now tests mp3/vorbis codecs and mp3/flac/ogg
  containers (MediaSource with a canPlayType fallback), restoring
  direct play for mp3 audiobooks instead of per-part AAC re-encodes.
- Reanchor seeks issued while a replan is in flight coalesce and run
  when it settles instead of being silently dropped with the scrubber
  pinned to a phantom position.
- Subtitle refresh/translation replans use the resume anchor while the
  media element has no metadata, so a subtitle_ready broadcast during
  startup no longer restarts a resumed stream at 0:00.
- An exhausted failure-recovery chain sets a visible error instead of
  returning silently.

Part of #135.

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

* fix(playback): accept video-only and VP9 probe metadata

Treat audio and video probe completeness independently so legitimate video-only assets converge without repeated ffprobe repair. Allow unknown codec profile/level metadata to fall through to server adaptation while preserving exact direct-decode constraints.

Fixes #574

* fix(playback): address protocol v3 review findings

* fix(playback): harden lease and probe repair decisions

* fix(playback): close remaining v3 review gaps

* fix(playback): recover failed transcode starts

* fix(playback): address remaining review-bot findings on v3 replan and audio planning

Server:
- The deferred replan lease release is bounded by a 3s timeout so a
  saturated pool or DB outage cannot wedge a handler goroutine that
  holds the per-session store lock on an uncancellable context.
- planAudioOnlyV3 honors the request bandwidth cap: an over-cap source
  skips the original_http direct route and converts to AAC with the
  same bandwidth_cap_applied warning and decision reason the video
  ladder uses. Unknown source bitrate never triggers the cap.
- A copy-audio progressive plan rejected only by a per-delivery
  audio_decode_codecs subset retries as an AAC conversion instead of
  returning adaptation_unavailable, and the AAC recipe respects the
  delivery's max_channels.

Web:
- failure_recovery replans issued while another replan is in flight
  queue (superseding a pending seek reanchor) instead of being
  silently dropped with the fatal overlay already suppressed.
- A terminal response to a fresh non-preserving start clears the
  previous plan and stops its session, so episode navigation cannot
  keep rendering the prior item under the new title.
- A refused recovery replan for a transport-dead plan surfaces the
  error and re-arms the plan failure key, so transient recovery
  failures no longer strand an endless spinner; the audiobook player
  gets the same guard reset.
- A track-less subtitle_translation_completed hands off to the
  refreshed persisted track once the inventory settles, clearing the
  live overlay, instead of pinning the synthetic live track forever.

Part of #135.

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

* fix(playback): reuse HLS transport for sidecar replans

* fix(playback): stabilize copy HLS remount timeline

* fix(playback): address v3 review findings

* fix(playback): satisfy player contract types

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 18:14:49 -04:00

1343 lines
44 KiB
Go

package playback
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
"sync"
"time"
"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
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
}
// 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
UserAgent string
IsCompat bool
}
// 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
// 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 {
value = strings.TrimSpace(value)
if maxLen > 0 && len(value) > maxLen {
value = value[:maxLen]
}
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()
clientInfo := ClientInfoFromContext(ctx)
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: normalizeClientMetadataValue(clientInfo.Name, 128),
ClientVersion: normalizeClientMetadataValue(clientInfo.Version, 64),
ClientUserAgent: normalizeClientMetadataValue(clientInfo.UserAgent, 512),
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
}
// 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
}
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
}
return !m.sessionIsInactiveLocked(s, now, m.activeGrace, m.pausedGrace)
}
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)
}