Five defects that all produced a WRONG over-cap count, which is why they land before the revocation batch: decision A1 raises the over-cap revocation TTL from 5m to ~24h, removing the self-healing that currently limits the damage of a miscount. A false positive after A1 blocks a legitimate stream for a day, so the count has to be trustworthy first. #1 -- overlapping edge requests deleted a live stream. Tracker.sessions was a set and Remove tore down all state plus the Redis key, while both proxy pour handlers deferred removal unconditionally. Two overlapping Range GETs on one session id -- ordinary seek behaviour -- meant the first to finish deleted the record while the second was still pouring, and later AddBytes calls were then dropped because AddBytes ignores bytes for a session with no live record. The stream went invisible to authoritative monitoring while still serving. Track now returns a Lease that the request-scoped caller releases exactly once; teardown happens when the last live lease is released. A plain refcount would have been wrong: Track(A) -> Remove -> Track(B) -> Release(A) decrements B, and clamping at zero does not help because the count legitimately belongs to B. That is not hypothetical -- the transcode node deliberately replaces sessions under the same id so a quality switch does not orphan ffmpeg, and it calls unconditional Remove from its reaper and stop paths. So each generation carries an epoch, Remove and Cleanup bump it, and a release from a superseded generation is a logged no-op. Lease identity is a set rather than a counter, which makes a duplicate release detectable instead of silently destructive. The transcode node keeps using Remove: its Track calls are not request-scoped and are correctly owned by session lifecycle. "Every Track needs a paired Release" is true only of the request-scoped callers. #8 -- async transcode tracking could leave a permanent ghost. The tracking write ran as a bare goroutine with a WithoutCancel context, so if stop won the race the delayed Track recreated the record after cleanup -- and because it landed in sessions, Snapshot treated it as live until Remove and it NEVER idle-expired. A permanent phantom inflating its owner's count, able to trigger false over-cap kills of that user's real streams. The write now takes the per-session lifecycle lock that stop and reap already hold, and re-checks session pointer identity before writing, so a stopped or replaced generation cannot resurrect a record. Pointer identity rather than id equality is what makes same-id replacement safe. The write stays off the request path -- the API server and the playback client are blocked on the 202. #9 + M3 -- protocol-v3 counted one stream twice. The stream token carries a transport id distinct from the logical session id, and the node tracked under the transport id while the API/proxy record used the logical one, so mergeStreams saw two streams. M3 was the reason this had not yet bitten: the v3 fresh-start caller sent no owner attribution at all, so the transport record landed under user 0, which the enforcer skips -- silently exempting the stream from the cap entirely. Fresh v3 starts now carry the logical session id and full owner attribution (both were already in scope at the call site), and merging is keyed on logical identity where present via one shared helper used by both merge functions, which had already drifted apart once. The enforcer view resolves SessionID to the logical id so a kill targets the real session rather than a replaceable transport generation. The raw admin view keeps the transport id and exposes logical_session_id as an additive omitempty field, advertised on the node-sessions capability endpoint, so the v1 response shape is unchanged. GAP-15 -- edge transcode liveness was request-observed. touchTranscodeSession fired before proxying, so hammering dead segment URLs advanced LastServedAt with zero bytes served. Visibility and liveness are now separate operations: EnsureEphemeral makes a session visible without claiming bytes were served, and served-byte liveness advances only from a 2xx/206 upstream response. Previously the proxy metered every upstream body regardless of status, so a node 404's error body counted as served bytes -- moving the touch later would not have fixed it. S4 -- LiveLocalSessions moved from the HTTP handlers package to streammonitor, which owns monitoring. A background enforcer importing api/handlers was backwards. Pure move; its existing mapping assertions moved with it. The LastActivityAt fallback inside it is left as-is -- decision A5 removes it in the liveness batch. Verified with go test -race across nodesessions, proxy and transcodenode; the overlap regression test was confirmed to fail under the old unconditional teardown. Part of #305.
4050 lines
155 KiB
Go
4050 lines
155 KiB
Go
package handlers
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"log/slog"
|
||
"net/http"
|
||
"os"
|
||
"path/filepath"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"github.com/go-chi/chi/v5"
|
||
"github.com/google/uuid"
|
||
|
||
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
|
||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||
"github.com/Silo-Server/silo-server/internal/clientip"
|
||
"github.com/Silo-Server/silo-server/internal/config"
|
||
evt "github.com/Silo-Server/silo-server/internal/events"
|
||
"github.com/Silo-Server/silo-server/internal/httpstream"
|
||
"github.com/Silo-Server/silo-server/internal/markers"
|
||
"github.com/Silo-Server/silo-server/internal/models"
|
||
"github.com/Silo-Server/silo-server/internal/nodepool"
|
||
"github.com/Silo-Server/silo-server/internal/playback"
|
||
"github.com/Silo-Server/silo-server/internal/streamtoken"
|
||
"github.com/Silo-Server/silo-server/internal/subtitles"
|
||
"github.com/Silo-Server/silo-server/internal/transcodenode"
|
||
"github.com/Silo-Server/silo-server/internal/userstore"
|
||
"github.com/Silo-Server/silo-server/internal/watchstate"
|
||
"github.com/Silo-Server/silo-server/internal/watchsync"
|
||
)
|
||
|
||
// SessionManagerInterface defines the operations the PlaybackHandler needs
|
||
// on the session manager.
|
||
type SessionManagerInterface interface {
|
||
StartSession(userID int, profileID string, fileID int, method playback.PlayMethod, transcodeAudio bool) (*playback.Session, error)
|
||
StartSessionWithFiles(userID int, profileID string, effectiveFileID int, requestedFileID int, method playback.PlayMethod, transcodeAudio bool) (*playback.Session, error)
|
||
UpdateProgress(sessionID string, position float64, isPaused bool) error
|
||
UpdateAudioTrack(sessionID string, audioTrackIndex int, method playback.PlayMethod) error
|
||
UpdateStreamState(sessionID string, state playback.SessionStreamState) error
|
||
TouchActivity(sessionID string) error
|
||
BeginTransport(sessionID string) error
|
||
EndTransport(sessionID string) error
|
||
SetEffectiveMediaFileID(sessionID string, fileID int) error
|
||
SetTranscodeNodeURL(sessionID, url string) error
|
||
SetTranscodeRoute(sessionID string, route playback.TranscodeRoute) error
|
||
ApplyReplacement(sessionID string, replacement playback.SessionReplacement) (playback.SessionReplacementRollback, error)
|
||
ApplyReplacementIfRoute(sessionID string, expected playback.TranscodeRoute, replacement playback.SessionReplacement) (playback.SessionReplacementRollback, bool, error)
|
||
RollbackReplacement(sessionID string, rollback playback.SessionReplacementRollback) error
|
||
SetWebSocket(sessionID string, connected bool) error
|
||
SetRealtimeConnection(sessionID string, connected bool) error
|
||
SetProgressPersistenceDisabled(sessionID string, disabled bool) error
|
||
StopSession(sessionID string) error
|
||
GetSession(sessionID string) (*playback.Session, error)
|
||
}
|
||
|
||
type sessionStarterWithFilesContext interface {
|
||
StartSessionWithFilesContext(ctx context.Context, userID int, profileID string, effectiveFileID int, requestedFileID int, method playback.PlayMethod, transcodeAudio bool) (*playback.Session, error)
|
||
}
|
||
|
||
type transcodePermissionChecker interface {
|
||
CheckTranscodingAllowed(ctx context.Context, userID int, requiresVideoTranscode bool) error
|
||
}
|
||
|
||
func (h *PlaybackHandler) ensureUserTranscodingAllowed(w http.ResponseWriter, r *http.Request, userID int, requiresVideoTranscode bool) bool {
|
||
checker, ok := h.sessionMgr.(transcodePermissionChecker)
|
||
if !ok {
|
||
return true
|
||
}
|
||
if err := checker.CheckTranscodingAllowed(r.Context(), userID, requiresVideoTranscode); err != nil {
|
||
if errors.Is(err, playback.ErrTranscodingDisabled) {
|
||
writeError(w, http.StatusForbidden, "transcoding_disabled", "Transcoding is disabled for your user")
|
||
return false
|
||
}
|
||
if errors.Is(err, playback.ErrAudioTranscodingDisabled) {
|
||
writeError(w, http.StatusForbidden, "audio_transcoding_disabled", "Audio transcoding is disabled for your user")
|
||
return false
|
||
}
|
||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to verify transcoding access")
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
type PlaybackItemAccessChecker interface {
|
||
EnsureAccessible(ctx context.Context, contentID string, filter catalog.AccessFilter) error
|
||
}
|
||
|
||
type PlaybackEpisodeLookup interface {
|
||
GetByID(ctx context.Context, contentID string) (*models.Episode, error)
|
||
}
|
||
|
||
// PlaybackExtraLookup resolves local extras (media_extras) so their files
|
||
// authorize through the parent item, like episodes authorize through their
|
||
// series.
|
||
type PlaybackExtraLookup interface {
|
||
GetByID(ctx context.Context, contentID string) (*models.MediaExtra, error)
|
||
}
|
||
|
||
type PlaybackSessionSyncer interface {
|
||
SyncNow(ctx context.Context) error
|
||
}
|
||
|
||
// PlaybackSettingsReader reads server settings for playback decisions.
|
||
type PlaybackSettingsReader interface {
|
||
Get(ctx context.Context, key string) (string, error)
|
||
}
|
||
|
||
// PlaybackFileVersionFetcher retrieves alternate file versions for a content item.
|
||
type PlaybackFileVersionFetcher interface {
|
||
GetByContentID(ctx context.Context, contentID string) ([]*models.MediaFile, error)
|
||
GetByEpisodeID(ctx context.Context, episodeID string) ([]*models.MediaFile, error)
|
||
}
|
||
|
||
type PlaybackProbeEnsurer interface {
|
||
Ensure(ctx context.Context, file *models.MediaFile) (*models.MediaFile, error)
|
||
}
|
||
|
||
type PlaybackChapterThumbnailQueuer interface {
|
||
QueuePriorityFileAtPosition(ctx context.Context, fileID int, targetSeconds float64)
|
||
}
|
||
|
||
// PlaybackOriginalLanguageLookup fetches the original language for a content item.
|
||
type PlaybackOriginalLanguageLookup interface {
|
||
GetOriginalLanguage(ctx context.Context, contentID string) (string, error)
|
||
}
|
||
|
||
type copySeekAnchorResolver func(
|
||
ctx context.Context,
|
||
ffmpegPath string,
|
||
inputPath string,
|
||
requestedSeekSeconds float64,
|
||
segmentDuration int,
|
||
) (float64, int, error)
|
||
|
||
// PlaybackHandler handles playback session HTTP endpoints.
|
||
type PlaybackHandler struct {
|
||
sessionMgr SessionManagerInterface
|
||
fileResolver FilePathResolver // optional; enables stream_url in responses
|
||
StoreProvider userstore.UserStoreProvider // optional; enables progress/history persistence
|
||
WatchScrobbler PlaybackWatchScrobbler
|
||
StableIdentityResolver *watchstate.StableIdentityResolver
|
||
CompletionObserver watchstate.CompletionObserver // optional; auto-removes watched items from the watchlist
|
||
profileStaler ProfileStaler
|
||
profileRefreshRequester ProfileRefreshRequester
|
||
AdminStore PlaybackAdminStore // optional; enables admin playback history/live session cleanup
|
||
SessionSyncer PlaybackSessionSyncer // optional; enables immediate session sync to shared admin view
|
||
EventsHub *evt.Hub
|
||
MissingMarker MissingFileMarker
|
||
NodePlanner nodepool.SessionPlanner // optional; enables proxy/transcode node selection
|
||
JWTSecret string // needed for signing stream tokens
|
||
ItemAccess PlaybackItemAccessChecker // optional; enables file authorization checks
|
||
EpisodeLookup PlaybackEpisodeLookup // optional; resolves episode files to their series
|
||
ExtraLookup PlaybackExtraLookup // optional; resolves extras files to their parent item
|
||
OriginalLangLookup PlaybackOriginalLanguageLookup
|
||
SettingsRepo PlaybackSettingsReader // optional; reads server settings (e.g., allow_4k_transcode)
|
||
FileVersionFetcher PlaybackFileVersionFetcher // optional; queries sibling file versions for 4K guard
|
||
ProbeEnsurer PlaybackProbeEnsurer // optional; repairs missing probe metadata on demand
|
||
ChapterThumbnailQueuer PlaybackChapterThumbnailQueuer
|
||
IntroAnalyzer IntroEpisodeAnalyzer
|
||
IntroRepository PlaybackIntroEligibilityChecker
|
||
MarkerRegistry *markers.Registry
|
||
MarkerResolver markers.ExternalIDResolver
|
||
MarkerUpserter PlaybackMarkerUpserter
|
||
MarkerUpdateNotifier PlaybackMarkerUpdateNotifier
|
||
MarkerLazyContext context.Context
|
||
MarkerLazyInFlight sync.Map
|
||
SubtitleRepo subtitles.Repository // optional; enables downloaded subtitles in playback
|
||
RealtimeHub *playback.RealtimeHub
|
||
CommandTracker *playback.CommandTracker
|
||
CommandDispatcher *playback.CommandDispatcher
|
||
// PlaybackConfig returns the current playback config (ffmpeg path,
|
||
// hwaccel, transcode dir). Wired to the live config in integrated mode
|
||
// so admin changes apply to newly started transcodes. Read it through
|
||
// playbackConfig(), which falls back to defaults when unset.
|
||
PlaybackConfig func() config.PlaybackConfig
|
||
FFmpegLogSink playback.FFmpegLogSink
|
||
copySeekAnchor copySeekAnchorResolver
|
||
realtimeCommandMu sync.Mutex
|
||
realtimeCommands map[string]playbackCommandRecord
|
||
// tm owns the transcode-session lifecycle (live map, recipe cards, and
|
||
// restart reconstruct) shared with the jellycompat handler. The handler
|
||
// delegates all transcode-session and recipe operations to it.
|
||
tm *playback.TranscodeManager
|
||
// PlanStoreV3 owns the short-lived protocol-v3 control-plane state. Router
|
||
// wiring replaces the in-memory default with PostgreSQL in integrated mode.
|
||
PlanStoreV3 playback.PlanStoreV3
|
||
v3RegistryOnce sync.Once
|
||
v3Registry *playback.TransformationRegistryV3
|
||
v3NodeCapabilitiesMu sync.Mutex
|
||
v3NodeCapabilities map[string]v3NodeCapabilityCache
|
||
v3EventOnce sync.Once
|
||
v3EventQueue chan playback.RouteEventRecordV3
|
||
v3ReplanMu sync.Mutex
|
||
v3ReplanLocks map[string]*v3ReplanLock
|
||
v3ReplanSlotsOnce sync.Once
|
||
v3ReplanSlots chan struct{}
|
||
v3EventRateMu sync.Mutex
|
||
v3EventRates map[string]v3EventRate
|
||
v3FlagMu sync.Mutex
|
||
v3Flags map[string]v3FlagCacheEntry
|
||
}
|
||
|
||
type PlaybackWatchScrobbler interface {
|
||
ScrobbleStart(ctx context.Context, event watchsync.ScrobbleEvent) error
|
||
ScrobblePause(ctx context.Context, event watchsync.ScrobbleEvent) error
|
||
ScrobbleStop(ctx context.Context, event watchsync.ScrobbleEvent) error
|
||
}
|
||
|
||
type sessionExpirationHookSetter interface {
|
||
SetExpirationHook(func(*playback.Session))
|
||
}
|
||
|
||
// NewPlaybackHandler creates a new PlaybackHandler backed by the given
|
||
// session manager. Pass optional FilePathResolver to enable stream_url
|
||
// and subtitle_urls in start playback responses.
|
||
func NewPlaybackHandler(sessionMgr SessionManagerInterface, opts ...FilePathResolver) *PlaybackHandler {
|
||
h := &PlaybackHandler{
|
||
sessionMgr: sessionMgr,
|
||
realtimeCommands: make(map[string]playbackCommandRecord),
|
||
tm: playback.NewTranscodeManager(),
|
||
PlanStoreV3: playback.NewMemoryPlanStoreV3(),
|
||
}
|
||
if len(opts) > 0 {
|
||
h.fileResolver = opts[0]
|
||
}
|
||
// Wire the shared transcode manager with closures so it reads the handler's
|
||
// (often late-set) config/store/secret fields lazily at call time, avoiding a
|
||
// field-ordering hazard during router setup.
|
||
h.tm.JWTSecretFn = func() string { return h.JWTSecret }
|
||
h.tm.LogSinkFn = func() playback.FFmpegLogSink { return h.FFmpegLogSink }
|
||
h.tm.Config = func() playback.TranscodeRuntimeConfig {
|
||
c := h.playbackConfig()
|
||
return playback.TranscodeRuntimeConfig{
|
||
TranscodeDir: c.TranscodeDir,
|
||
FFmpegPath: c.FFmpegPath,
|
||
HWAccel: c.HWAccel,
|
||
HWDevice: c.HWDevice,
|
||
}
|
||
}
|
||
h.tm.StartThrottler = func(ctx context.Context, ts *playback.TranscodeSession) {
|
||
h.maybeStartThrottler(ctx, ts)
|
||
}
|
||
h.tm.OnFFmpegCrash = func(ctx context.Context, sessionID string, dead *playback.TranscodeSession) {
|
||
// ffmpeg crash — tear the session down; a client holding a valid stream
|
||
// token can reconstruct it on the next request.
|
||
//
|
||
// Compare-and-delete the dead transcode first: between ffmpeg's error exit
|
||
// and this teardown a reconstruct may have registered a fresh successor
|
||
// under the same id. CloseTranscodeSessionIf only removes (and Close()s, which
|
||
// reaps the shared output dir) the entry when it is still the dead session;
|
||
// if a successor won, it leaves the live one untouched and we must NOT tear
|
||
// down the reconstructed playback session that now backs it.
|
||
var nodeURL string
|
||
if s, err := h.sessionMgr.GetSession(sessionID); err == nil {
|
||
nodeURL = s.TranscodeNodeURL
|
||
}
|
||
if successor := h.tm.GetTranscodeSession(sessionID); successor != nil && successor != dead {
|
||
// A reconstruct already replaced the crashed process; the live successor
|
||
// and its session stand. Cheap fast-path only — the authoritative gate is
|
||
// the compare-and-delete result below.
|
||
return
|
||
}
|
||
// CloseTranscodeSessionIf is the authoritative gate: a successor may register
|
||
// under the same id between the pre-check above and here. We only tear down the
|
||
// upstream playback session when the compare-and-delete actually matched the
|
||
// dead transcode. When it returns false a successor owns the session — do
|
||
// nothing further, or finalizeSessionStop's unconditional CloseTranscodeSession
|
||
// would reap the live successor's output dir mid-serve.
|
||
if !h.tm.CloseTranscodeSessionIf(sessionID, dead, nodeURL) {
|
||
return
|
||
}
|
||
if err := h.stopPlaybackSessionByID(ctx, sessionID, false); err != nil && !errors.Is(err, playback.ErrSessionNotFound) {
|
||
slog.ErrorContext(ctx, "failed to stop playback after local transcode exit", "component", "api", "session", sessionID, "error", err, "playback_session_id", sessionID)
|
||
}
|
||
}
|
||
if reg, ok := sessionMgr.(interface {
|
||
RegisterReconstructed(s *playback.Session) *playback.Session
|
||
RegisterReconstructedWithLimits(ctx context.Context, s *playback.Session) (*playback.Session, error)
|
||
}); ok {
|
||
h.tm.Sessions = reg
|
||
}
|
||
if setter, ok := sessionMgr.(sessionExpirationHookSetter); ok {
|
||
setter.SetExpirationHook(h.handleExpiredSession)
|
||
}
|
||
return h
|
||
}
|
||
|
||
// TranscodeManager returns the shared transcode/reconstruct manager so sibling
|
||
// handlers (e.g. StreamHandler) can reuse the same recipe-card store, live
|
||
// transcode map, and reconstruct front door rather than wiring a second one.
|
||
func (h *PlaybackHandler) TranscodeManager() *playback.TranscodeManager {
|
||
return h.tm
|
||
}
|
||
|
||
// SetProfileStaler configures an optional staleness trigger for taste profiles.
|
||
func (h *PlaybackHandler) SetProfileStaler(ps ProfileStaler) {
|
||
h.profileStaler = ps
|
||
}
|
||
|
||
// SetProfileRefreshRequester configures an optional background refresh queue for taste profiles.
|
||
func (h *PlaybackHandler) SetProfileRefreshRequester(requester ProfileRefreshRequester) {
|
||
h.profileRefreshRequester = requester
|
||
}
|
||
|
||
// playbackConfig returns the current playback config, falling back to the
|
||
// same defaults as config loading (transcode enabled, temp transcode dir)
|
||
// when no provider is wired (tests, minimal setups).
|
||
func (h *PlaybackHandler) playbackConfig() config.PlaybackConfig {
|
||
if h.PlaybackConfig != nil {
|
||
return h.PlaybackConfig()
|
||
}
|
||
return config.PlaybackConfig{
|
||
TranscodeEnabled: true,
|
||
TranscodeDir: filepath.Join(os.TempDir(), "silo-transcode"),
|
||
}
|
||
}
|
||
|
||
// CleanupOrphanedTranscodes removes stale per-session temp directories for
|
||
// transcodes that are no longer tracked in memory, sparing dirs whose recipe
|
||
// card still exists. Delegates to the shared transcode manager.
|
||
func (h *PlaybackHandler) CleanupOrphanedTranscodes() (int, error) {
|
||
return h.tm.CleanupOrphanedTranscodes()
|
||
}
|
||
|
||
// playbackThresholds reads the playback.watched_threshold and
|
||
// playback.min_resume_threshold settings. Zero values mean "use defaults".
|
||
func (h *PlaybackHandler) playbackThresholds(ctx context.Context) userstore.ProgressThresholds {
|
||
if h.SettingsRepo == nil {
|
||
return userstore.ProgressThresholds{}
|
||
}
|
||
var t userstore.ProgressThresholds
|
||
if v, _ := h.SettingsRepo.Get(ctx, "playback.watched_threshold"); v != "" {
|
||
if pct, err := strconv.Atoi(v); err == nil && pct > 0 {
|
||
t.WatchedPct = pct
|
||
}
|
||
}
|
||
if v, _ := h.SettingsRepo.Get(ctx, "playback.min_resume_threshold"); v != "" {
|
||
if pct, err := strconv.Atoi(v); err == nil && pct > 0 {
|
||
t.MinResumePct = pct
|
||
}
|
||
}
|
||
return t
|
||
}
|
||
|
||
// --- Request/Response types ---
|
||
|
||
// hdrDetails describes granular HDR support advertised by the client.
|
||
// Optional — absent means the resolver falls back to the boolean HDR flag.
|
||
// Dolby Vision profile numbers follow MediaCodec:
|
||
//
|
||
// 5 = DvheStn / DvheSt (single-layer)
|
||
// 7 = DvheDtb / DvheDtr / DvheDth (dual-layer BL+EL — needs multi-instance)
|
||
// 8 = DvheSt4k / DvavSe
|
||
type hdrDetails struct {
|
||
HDR10 bool `json:"hdr10"`
|
||
HDR10Plus bool `json:"hdr10_plus"`
|
||
HLG bool `json:"hlg"`
|
||
DolbyVisionProfiles []int `json:"dolby_vision_profiles"`
|
||
}
|
||
|
||
// audioPassthroughCapabilities describes what the connected audio sink can
|
||
// decode bit-exact. Distinct from `codecs_audio`, which describes what the
|
||
// client can decode itself. Passthrough codecs come from `AudioCapabilities`
|
||
// (HDMI EDID / Bluetooth / USB DAC capability probing on Android; equivalent
|
||
// on iOS/tvOS).
|
||
type audioPassthroughCapabilities struct {
|
||
PassthroughCodecs []string `json:"passthrough_codecs"`
|
||
SpatializerEnabled bool `json:"spatializer_enabled"`
|
||
MaxChannels int `json:"max_channels"`
|
||
}
|
||
|
||
// startPlaybackRequest represents the JSON body for POST /playback/start.
|
||
type startPlaybackRequest struct {
|
||
FileID int `json:"file_id"`
|
||
ProfileID string `json:"profile_id"`
|
||
PlayMethod string `json:"play_method"`
|
||
StartPosition *float64 `json:"start_position,omitempty"`
|
||
AudioTrackIndex *int `json:"audio_track_index,omitempty"`
|
||
PreserveDirectAudioSelection bool `json:"preserve_direct_audio_selection,omitempty"`
|
||
DisableProgressPersistence bool `json:"disable_progress_persistence,omitempty"`
|
||
CodecsVideo []string `json:"codecs_video"`
|
||
CodecsAudio []string `json:"codecs_audio"`
|
||
Containers []string `json:"containers"`
|
||
MaxResolution string `json:"max_resolution"`
|
||
HDR bool `json:"hdr"`
|
||
HdrDetails *hdrDetails `json:"hdr_details,omitempty"`
|
||
AudioPassthrough *audioPassthroughCapabilities `json:"audio_passthrough,omitempty"`
|
||
SupportsBitmapSubtitleBurnIn bool `json:"supports_bitmap_subtitle_burn_in,omitempty"`
|
||
}
|
||
|
||
// progressRequest represents the JSON body for POST /playback/{session_id}/progress.
|
||
type progressRequest struct {
|
||
Position float64 `json:"position"`
|
||
IsPaused bool `json:"is_paused"`
|
||
}
|
||
|
||
// playbackSessionResponse represents a playback session in JSON responses.
|
||
type playbackSessionResponse struct {
|
||
SessionID string `json:"session_id"`
|
||
UserID int `json:"user_id"`
|
||
ProfileID string `json:"profile_id"`
|
||
MediaFileID int `json:"media_file_id"`
|
||
PlayMethod string `json:"play_method"`
|
||
Position float64 `json:"position"`
|
||
IsPaused bool `json:"is_paused"`
|
||
StreamURL string `json:"stream_url"`
|
||
AudioTrackIndex int `json:"audio_track_index"`
|
||
DurationSeconds *float64 `json:"duration_seconds"`
|
||
SubtitleURLs []subtitleURL `json:"subtitle_urls,omitempty"`
|
||
PlaybackInfo *playbackInfoResult `json:"playback_info,omitempty"`
|
||
}
|
||
|
||
type playbackInfoResult struct {
|
||
StreamType string `json:"stream_type"`
|
||
TranscodeAudio bool `json:"transcode_audio"`
|
||
VideoCodec string `json:"video_codec"`
|
||
AudioCodec string `json:"audio_codec"`
|
||
}
|
||
|
||
// subtitleURL represents a subtitle track URL in a playback response.
|
||
type subtitleURL struct {
|
||
Index int `json:"index"`
|
||
MediaFileID int `json:"media_file_id,omitempty"`
|
||
Language string `json:"language"`
|
||
Codec string `json:"codec,omitempty"`
|
||
Label string `json:"label"`
|
||
Source string `json:"source"`
|
||
Forced bool `json:"forced"`
|
||
HearingImpaired bool `json:"hearing_impaired"`
|
||
URL string `json:"url"`
|
||
FontBundleURL string `json:"font_bundle_url,omitempty"`
|
||
}
|
||
|
||
// changeAudioRequest represents the JSON body for PATCH /playback/{session_id}/audio.
|
||
type changeAudioRequest struct {
|
||
AudioTrackIndex int `json:"audio_track_index"`
|
||
Position float64 `json:"position"`
|
||
}
|
||
|
||
// changeAudioResponse represents the JSON response for PATCH /playback/{session_id}/audio.
|
||
type changeAudioResponse struct {
|
||
AudioTrackIndex int `json:"audio_track_index"`
|
||
PlayMethod string `json:"play_method"`
|
||
StreamURL string `json:"stream_url"`
|
||
SwitchMode string `json:"switch_mode"`
|
||
PlayerStartSeconds *float64 `json:"player_start_seconds,omitempty"`
|
||
StreamOriginSeconds *float64 `json:"stream_origin_seconds,omitempty"`
|
||
TimelineOffsetSeconds *float64 `json:"timeline_offset_seconds,omitempty"`
|
||
CanSeekAnywhere *bool `json:"can_seek_anywhere,omitempty"`
|
||
PlaybackInfo *playbackInfoResult `json:"playback_info,omitempty"`
|
||
}
|
||
|
||
func (resp *changeAudioResponse) setCopyTimeline(position, origin float64) {
|
||
playerStart := max(0, position-origin)
|
||
canSeekAnywhere := false
|
||
resp.PlayerStartSeconds = &playerStart
|
||
resp.StreamOriginSeconds = &origin
|
||
resp.TimelineOffsetSeconds = &origin
|
||
resp.CanSeekAnywhere = &canSeekAnywhere
|
||
}
|
||
|
||
type transcodeStartRequest struct {
|
||
SessionID string `json:"session_id"`
|
||
SeekSeconds float64 `json:"seek_seconds"`
|
||
TargetResolution string `json:"target_resolution"`
|
||
TargetCodecVideo string `json:"target_codec_video"`
|
||
TargetCodecAudio string `json:"target_codec_audio"`
|
||
TargetBitrateKbps int `json:"target_bitrate_kbps"`
|
||
SegmentDuration int `json:"segment_duration"`
|
||
SubtitleTrackIndex int `json:"subtitle_track_index"`
|
||
SubtitleMediaFileID int `json:"subtitle_media_file_id,omitempty"`
|
||
SubtitleBurnIn bool `json:"subtitle_burn_in"`
|
||
}
|
||
|
||
type transcodeStartResponse struct {
|
||
SessionID string `json:"session_id"`
|
||
Status string `json:"status"`
|
||
SwitchedFileID *int `json:"switched_file_id,omitempty"`
|
||
ManifestURL string `json:"manifest_url"`
|
||
DurationSeconds *float64 `json:"duration_seconds"`
|
||
PlayerStartSeconds float64 `json:"player_start_seconds"`
|
||
StreamOriginSeconds float64 `json:"stream_origin_seconds"`
|
||
TimelineOffsetSeconds float64 `json:"timeline_offset_seconds"`
|
||
CanSeekAnywhere bool `json:"can_seek_anywhere"`
|
||
}
|
||
|
||
// toPlaybackSessionResponse converts a playback.Session to an API response.
|
||
func (h *PlaybackHandler) toPlaybackSessionResponse(s *playback.Session) playbackSessionResponse {
|
||
return playbackSessionResponse{
|
||
SessionID: s.ID,
|
||
UserID: s.UserID,
|
||
ProfileID: s.ProfileID,
|
||
MediaFileID: s.MediaFileID,
|
||
PlayMethod: string(semanticPlayMethod(s)),
|
||
Position: s.Position,
|
||
IsPaused: s.IsPaused,
|
||
StreamURL: h.playbackStreamURL(s),
|
||
AudioTrackIndex: s.AudioTrackIndex,
|
||
}
|
||
}
|
||
|
||
func semanticPlayMethod(s *playback.Session) playback.PlayMethod {
|
||
if s == nil {
|
||
return ""
|
||
}
|
||
if s.BasePlayMethod != "" {
|
||
return s.BasePlayMethod
|
||
}
|
||
return s.PlayMethod
|
||
}
|
||
|
||
func fileDurationSeconds(file *models.MediaFile) *float64 {
|
||
if file == nil || file.Duration <= 0 {
|
||
return nil
|
||
}
|
||
duration := float64(file.Duration)
|
||
return &duration
|
||
}
|
||
|
||
func canSeekAnywhere(req transcodeStartRequest, file *models.MediaFile) bool {
|
||
if file == nil || file.Duration <= 0 {
|
||
return false
|
||
}
|
||
// Copy-video HLS sessions use FFmpeg's real manifest so the player only
|
||
// seeks within the currently exposed window. Out-of-window seeks should
|
||
// restart explicitly instead of relying on segment 404s to move FFmpeg.
|
||
return !strings.EqualFold(req.TargetCodecVideo, "copy")
|
||
}
|
||
|
||
func buildTranscodeStartResponse(
|
||
req transcodeStartRequest,
|
||
file *models.MediaFile,
|
||
switchedFileID *int,
|
||
manifestURL string,
|
||
streamOriginSeconds float64,
|
||
) transcodeStartResponse {
|
||
resp := transcodeStartResponse{
|
||
SessionID: req.SessionID,
|
||
Status: "started",
|
||
SwitchedFileID: switchedFileID,
|
||
ManifestURL: manifestURL,
|
||
DurationSeconds: fileDurationSeconds(file),
|
||
}
|
||
if canSeekAnywhere(req, file) {
|
||
resp.PlayerStartSeconds = req.SeekSeconds
|
||
resp.StreamOriginSeconds = 0
|
||
resp.TimelineOffsetSeconds = 0
|
||
resp.CanSeekAnywhere = true
|
||
return resp
|
||
}
|
||
resp.PlayerStartSeconds = max(0, req.SeekSeconds-streamOriginSeconds)
|
||
resp.StreamOriginSeconds = streamOriginSeconds
|
||
resp.TimelineOffsetSeconds = streamOriginSeconds
|
||
resp.CanSeekAnywhere = false
|
||
return resp
|
||
}
|
||
|
||
func (h *PlaybackHandler) resolveLegacyCopySeekAnchor(
|
||
ctx context.Context,
|
||
ffmpegPath string,
|
||
inputPath string,
|
||
requestedSeekSeconds float64,
|
||
segmentDuration int,
|
||
) (float64, int, error) {
|
||
resolver := h.copySeekAnchor
|
||
if resolver == nil {
|
||
resolver = playback.ResolveCopySeekAnchor
|
||
}
|
||
probeCtx, cancel := context.WithTimeout(ctx, 8*time.Second)
|
||
defer cancel()
|
||
return resolver(probeCtx, ffmpegPath, inputPath, requestedSeekSeconds, segmentDuration)
|
||
}
|
||
|
||
func (h *PlaybackHandler) ensurePlaybackProbe(ctx context.Context, file *models.MediaFile) *models.MediaFile {
|
||
if h == nil || h.ProbeEnsurer == nil || file == nil {
|
||
return file
|
||
}
|
||
repaired, err := h.ProbeEnsurer.Ensure(ctx, file)
|
||
if err != nil {
|
||
slog.WarnContext(ctx, "playback probe repair failed", "component", "api", "file_id", file.ID, "path", file.FilePath, "error", err)
|
||
return file
|
||
}
|
||
if repaired != nil {
|
||
return repaired
|
||
}
|
||
return file
|
||
}
|
||
|
||
// streamTokenParam is the query parameter that carries the signed stream token
|
||
// on the native integrated serve path. The token is the durable reconstruction
|
||
// descriptor: a front-end that lost its in-memory session rebuilds from it. It
|
||
// rides a query parameter (not a path segment) because the integrated server is
|
||
// hit directly by the client — there is no query-stripping proxy hop in between,
|
||
// and the transcode manifest rewriter already appends the request RawQuery to
|
||
// every segment URI, so segment requests inherit the token for free. The
|
||
// proxy/node path keeps the token in the URL path (see the proxy server).
|
||
const streamTokenParam = "st"
|
||
|
||
// signSessionToken mints a stream token carrying the session's full
|
||
// reconstruction recipe. Returns "" when no signing secret is configured
|
||
// (reconstruct effectively disabled, e.g. in tests).
|
||
func (h *PlaybackHandler) signSessionToken(card playback.RecipeCard) string {
|
||
if h.JWTSecret == "" {
|
||
return ""
|
||
}
|
||
claims := card.ToClaims()
|
||
claims.Origin = playback.OriginNative // native-only mint site
|
||
token, err := streamtoken.Sign(claims, h.JWTSecret, playback.MaxTokenTTL)
|
||
if err != nil {
|
||
slog.Warn("sign stream token failed", "error", err, "session", card.SessionID, "playback_session_id", card.SessionID)
|
||
return ""
|
||
}
|
||
return token
|
||
}
|
||
|
||
// streamCardFromQuery verifies the stream token in the request's ?st= parameter
|
||
// and returns the decoded reconstruction recipe, or nil when the token is
|
||
// absent, invalid/expired, or bound to a different session. A live session needs
|
||
// no token (the result is simply nil); the recipe is consumed only on
|
||
// reconstruct.
|
||
func (h *PlaybackHandler) streamCardFromQuery(r *http.Request, sessionID string) *playback.RecipeCard {
|
||
return streamCardFromToken(r.URL.Query().Get(streamTokenParam), sessionID, h.JWTSecret)
|
||
}
|
||
|
||
// loadTranscodeServeSession resolves the playback Session for the transcode
|
||
// manifest/segment serve routes while keeping stream-token verification off the
|
||
// hot path. The overwhelmingly common case is a live in-memory session, which
|
||
// needs no token at all, so the cheap GetSession lookup runs first and the
|
||
// (HMAC + JSON) token decode is performed only on a not-found miss where a
|
||
// reconstruct is actually required. On that miss it delegates to the shared
|
||
// LoadOrReconstructSession front door so reconstruct/ownership semantics stay
|
||
// identical. The returned card (nil on the live-session path) is the decoded
|
||
// recipe the caller's own reconstruct branch consumes.
|
||
func (h *PlaybackHandler) loadTranscodeServeSession(r *http.Request, sessionID string) (*playback.Session, playback.SessionLoadStatus, *playback.RecipeCard) {
|
||
requestUserID := apimw.GetUserID(r.Context())
|
||
session, err := h.sessionMgr.GetSession(sessionID)
|
||
if err == nil {
|
||
// Live session: enforce the same ownership rule as LoadOrReconstructSession
|
||
// (a zero caller is allowed; a non-zero mismatch is refused). No token
|
||
// verification on this hot path.
|
||
if requestUserID != 0 && session.UserID != requestUserID {
|
||
return nil, playback.SessionForbidden, nil
|
||
}
|
||
return session, playback.SessionLoaded, nil
|
||
}
|
||
if !errors.Is(err, playback.ErrSessionNotFound) {
|
||
return nil, playback.SessionLoadFailed, nil
|
||
}
|
||
// Genuine miss (e.g. after a restart): now — and only now — pay for the token
|
||
// decode so the recipe is available for reconstruction.
|
||
card := h.streamCardFromQuery(r, sessionID)
|
||
session, status := h.tm.LoadOrReconstructSession(r.Context(), h.sessionMgr.GetSession, sessionID, requestUserID, card)
|
||
return session, status, card
|
||
}
|
||
|
||
// streamCardFromToken verifies a stream token and decodes its reconstruction
|
||
// recipe, returning nil when the token is absent, unparseable/expired, or bound
|
||
// to a different session id. Shared by the native serve handlers (PlaybackHandler
|
||
// and StreamHandler).
|
||
func streamCardFromToken(tokenStr, sessionID, secret string) *playback.RecipeCard {
|
||
if tokenStr == "" || secret == "" {
|
||
return nil
|
||
}
|
||
claims, err := streamtoken.Verify(tokenStr, secret)
|
||
if err != nil || claims.SessionID != sessionID {
|
||
return nil
|
||
}
|
||
card := playback.RecipeCardFromClaims(claims)
|
||
return &card
|
||
}
|
||
|
||
// appendStreamToken adds the ?st=<token> parameter to a native serve URL.
|
||
func appendStreamToken(rawURL, token string) string {
|
||
if token == "" {
|
||
return rawURL
|
||
}
|
||
sep := "?"
|
||
if strings.ContainsRune(rawURL, '?') {
|
||
sep = "&"
|
||
}
|
||
return rawURL + sep + streamTokenParam + "=" + token
|
||
}
|
||
|
||
// playbackStreamURL builds the native serve URL for a session and appends an
|
||
// identity stream token so a direct-play/remux session survives a restart (the
|
||
// client re-supplies its byte position). Transcode sessions receive their
|
||
// full-recipe manifest URL from HandleStartTranscode instead; the URL here is an
|
||
// informational placeholder the client replaces with that manifest.
|
||
func (h *PlaybackHandler) playbackStreamURL(s *playback.Session) string {
|
||
if s == nil {
|
||
return ""
|
||
}
|
||
if s.PlayMethod == playback.PlayTranscode {
|
||
return fmt.Sprintf("/playback/transcode/%s/master.m3u8", s.ID)
|
||
}
|
||
card := identityRecipeCard(s)
|
||
return appendStreamToken(fmt.Sprintf("/stream/%s", s.ID), h.signSessionToken(card))
|
||
}
|
||
|
||
// identityRecipeCard builds the identity-only recipe for a direct-play or remux
|
||
// session: reconstruction needs only ownership plus the audio selection, since
|
||
// the bytes are served by HTTP Range / a re-spawned remux pipe at the
|
||
// client-supplied position.
|
||
func identityRecipeCard(s *playback.Session) playback.RecipeCard {
|
||
switch s.PlayMethod {
|
||
case playback.PlayRemux:
|
||
return playback.NewRemuxRecipeCard(s.ID, s.UserID, s.ProfileID, s.MediaFileID, s.TranscodeAudio, s.AudioTrackIndex, s.RemuxDVMode)
|
||
default:
|
||
return playback.NewDirectRecipeCard(s.ID, s.UserID, s.ProfileID, s.MediaFileID)
|
||
}
|
||
}
|
||
|
||
func fileBitrateKbps(file *models.MediaFile) int {
|
||
if file == nil || file.Bitrate <= 0 {
|
||
return 0
|
||
}
|
||
return file.Bitrate
|
||
}
|
||
|
||
func buildPlaybackInfo(session *playback.Session, file *models.MediaFile) *playbackInfoResult {
|
||
if session == nil {
|
||
return nil
|
||
}
|
||
|
||
info := &playbackInfoResult{
|
||
TranscodeAudio: session.TranscodeAudio,
|
||
}
|
||
|
||
switch session.PlayMethod {
|
||
case playback.PlayTranscode:
|
||
info.StreamType = "hls"
|
||
if strings.EqualFold(session.TargetVideoCodec, "copy") || session.TargetVideoCodec == "" {
|
||
info.VideoCodec = sourceVideoCodec(file)
|
||
} else {
|
||
info.VideoCodec = session.TargetVideoCodec
|
||
}
|
||
if session.TranscodeAudio {
|
||
info.AudioCodec = "aac"
|
||
} else if strings.EqualFold(session.TargetAudioCodec, "copy") || session.TargetAudioCodec == "" {
|
||
info.AudioCodec = sourceAudioCodec(file)
|
||
} else {
|
||
info.AudioCodec = session.TargetAudioCodec
|
||
}
|
||
case playback.PlayRemux, playback.PlayDirect:
|
||
info.StreamType = "progressive"
|
||
info.VideoCodec = sourceVideoCodec(file)
|
||
if session.TranscodeAudio {
|
||
info.AudioCodec = "aac"
|
||
} else {
|
||
info.AudioCodec = sourceAudioCodec(file)
|
||
}
|
||
default:
|
||
info.StreamType = "progressive"
|
||
info.VideoCodec = sourceVideoCodec(file)
|
||
info.AudioCodec = sourceAudioCodec(file)
|
||
}
|
||
|
||
return info
|
||
}
|
||
|
||
func requestedMediaFileID(session *playback.Session) int {
|
||
if session == nil {
|
||
return 0
|
||
}
|
||
if session.RequestedMediaFileID > 0 {
|
||
return session.RequestedMediaFileID
|
||
}
|
||
return session.MediaFileID
|
||
}
|
||
|
||
func remoteTransportID(session *playback.Session) string {
|
||
if session != nil && session.TranscodeTransportID != "" {
|
||
return session.TranscodeTransportID
|
||
}
|
||
if session == nil {
|
||
return ""
|
||
}
|
||
return session.ID
|
||
}
|
||
|
||
func sessionTranscodeRoute(session *playback.Session) playback.TranscodeRoute {
|
||
if session == nil {
|
||
return playback.TranscodeRoute{}
|
||
}
|
||
return playback.TranscodeRoute{
|
||
NodeURL: session.TranscodeNodeURL,
|
||
TransportID: session.TranscodeTransportID,
|
||
}
|
||
}
|
||
|
||
const legacyTransportMarker = "-legacy-"
|
||
|
||
func isLegacyTransportSession(session *playback.Session) bool {
|
||
if session == nil {
|
||
return false
|
||
}
|
||
if session.TranscodeTransportID == "" {
|
||
return true
|
||
}
|
||
return strings.HasPrefix(session.TranscodeTransportID, session.ID+legacyTransportMarker)
|
||
}
|
||
|
||
func newLegacyTransportID(sessionID string) string {
|
||
return sessionID + legacyTransportMarker + uuid.NewString()
|
||
}
|
||
|
||
func (h *PlaybackHandler) transcodeRouteMatches(
|
||
sessionID string,
|
||
expectedLocal *playback.TranscodeSession,
|
||
route playback.TranscodeRoute,
|
||
) bool {
|
||
unlock := h.tm.LockSessionLifecycle(sessionID)
|
||
defer unlock()
|
||
session, err := h.sessionMgr.GetSession(sessionID)
|
||
if err != nil || session == nil {
|
||
return false
|
||
}
|
||
return h.tm.GetTranscodeSession(sessionID) == expectedLocal &&
|
||
sessionTranscodeRoute(session) == route
|
||
}
|
||
|
||
func (h *PlaybackHandler) commitLegacyRemoteReplacement(
|
||
sessionID string,
|
||
previousLocal *playback.TranscodeSession,
|
||
previousRoute playback.TranscodeRoute,
|
||
replacement playback.SessionReplacement,
|
||
) error {
|
||
unlock := h.tm.LockSessionLifecycle(sessionID)
|
||
if h.tm.GetTranscodeSession(sessionID) != previousLocal {
|
||
unlock()
|
||
return playback.ErrSessionSuperseded
|
||
}
|
||
|
||
_, published, err := h.sessionMgr.ApplyReplacementIfRoute(sessionID, previousRoute, replacement)
|
||
if err != nil {
|
||
unlock()
|
||
return err
|
||
}
|
||
if !published {
|
||
unlock()
|
||
return playback.ErrSessionSuperseded
|
||
}
|
||
if previousLocal != nil {
|
||
// A crash monitor can remove the predecessor while the remote successor is
|
||
// preparing. A false result is harmless: the new route is already complete,
|
||
// and CloseTranscodeSessionIf never touches a different local successor.
|
||
h.tm.CloseTranscodeSessionIf(sessionID, previousLocal, "")
|
||
}
|
||
unlock()
|
||
|
||
if previousRoute.NodeURL != "" {
|
||
previousProcessID := previousRoute.TransportID
|
||
if previousProcessID == "" {
|
||
previousProcessID = sessionID
|
||
}
|
||
h.tm.StopRemoteTranscode(previousProcessID, previousRoute.NodeURL)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (h *PlaybackHandler) commitLegacyRemoteLastWriter(
|
||
sessionID string,
|
||
successorRoute playback.TranscodeRoute,
|
||
replacement playback.SessionReplacement,
|
||
) error {
|
||
unlock := h.tm.LockSessionLifecycle(sessionID)
|
||
current, err := h.sessionMgr.GetSession(sessionID)
|
||
if err != nil {
|
||
unlock()
|
||
return err
|
||
}
|
||
previousRoute := sessionTranscodeRoute(current)
|
||
previousLocal := h.tm.GetTranscodeSession(sessionID)
|
||
if _, err := h.sessionMgr.ApplyReplacement(sessionID, replacement); err != nil {
|
||
unlock()
|
||
return err
|
||
}
|
||
if previousLocal != nil {
|
||
h.tm.CloseTranscodeSessionIf(sessionID, previousLocal, "")
|
||
}
|
||
unlock()
|
||
|
||
if previousRoute.NodeURL != "" && previousRoute != successorRoute {
|
||
previousProcessID := previousRoute.TransportID
|
||
if previousProcessID == "" {
|
||
previousProcessID = sessionID
|
||
}
|
||
h.tm.StopRemoteTranscode(previousProcessID, previousRoute.NodeURL)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (h *PlaybackHandler) commitLegacyLocalReplacement(
|
||
ctx context.Context,
|
||
sessionID string,
|
||
previousLocal *playback.TranscodeSession,
|
||
previousRoute playback.TranscodeRoute,
|
||
opts playback.TranscodeOpts,
|
||
replacement func(*playback.TranscodeSession) playback.SessionReplacement,
|
||
) (*playback.TranscodeSession, error) {
|
||
if opts.OutputSubdir == "" {
|
||
return nil, errors.New("transactional local replacement requires a generation output directory")
|
||
}
|
||
|
||
unlock := h.tm.LockSessionLifecycle(sessionID)
|
||
if h.tm.GetTranscodeSession(sessionID) != previousLocal {
|
||
unlock()
|
||
return nil, playback.ErrSessionSuperseded
|
||
}
|
||
current, err := h.sessionMgr.GetSession(sessionID)
|
||
if err != nil {
|
||
unlock()
|
||
return nil, err
|
||
}
|
||
if sessionTranscodeRoute(current) != previousRoute {
|
||
unlock()
|
||
return nil, playback.ErrSessionSuperseded
|
||
}
|
||
|
||
successor, err := h.startLocalPlaybackTransport(ctx, opts)
|
||
if err != nil {
|
||
unlock()
|
||
return nil, err
|
||
}
|
||
if _, err := successor.WaitForManifest(8 * time.Second); err != nil {
|
||
_ = successor.Close()
|
||
unlock()
|
||
return nil, fmt.Errorf("local successor not ready: %w", err)
|
||
}
|
||
|
||
rollback, published, err := h.sessionMgr.ApplyReplacementIfRoute(sessionID, previousRoute, replacement(successor))
|
||
if err != nil || !published {
|
||
_ = successor.Close()
|
||
unlock()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return nil, playback.ErrSessionSuperseded
|
||
}
|
||
if !h.tm.SwapTranscodeSessionIf(sessionID, previousLocal, successor) {
|
||
rollbackErr := h.sessionMgr.RollbackReplacement(sessionID, rollback)
|
||
_ = successor.Close()
|
||
unlock()
|
||
return nil, errors.Join(playback.ErrSessionSuperseded, rollbackErr)
|
||
}
|
||
unlock()
|
||
|
||
if previousLocal != nil {
|
||
_ = previousLocal.Close()
|
||
}
|
||
if previousRoute.NodeURL != "" {
|
||
previousProcessID := previousRoute.TransportID
|
||
if previousProcessID == "" {
|
||
previousProcessID = sessionID
|
||
}
|
||
h.tm.StopRemoteTranscode(previousProcessID, previousRoute.NodeURL)
|
||
}
|
||
return successor, nil
|
||
}
|
||
|
||
func (h *PlaybackHandler) closeTranscodeForSession(session *playback.Session) {
|
||
if session == nil {
|
||
return
|
||
}
|
||
// Local sessions remain keyed by the public playback session. Remote v3
|
||
// processes use a plan-scoped transport identity so a prepared successor can
|
||
// coexist with its predecessor until commit.
|
||
h.tm.CloseTranscodeSession(session.ID, "")
|
||
if session.TranscodeNodeURL != "" {
|
||
h.tm.StopRemoteTranscode(remoteTransportID(session), session.TranscodeNodeURL)
|
||
}
|
||
}
|
||
|
||
func (h *PlaybackHandler) loadFileByPreferredID(
|
||
ctx context.Context,
|
||
preferredID int,
|
||
fallbackID int,
|
||
) (*models.MediaFile, error) {
|
||
if h.fileResolver == nil {
|
||
return nil, fmt.Errorf("file resolver not configured")
|
||
}
|
||
if preferredID > 0 {
|
||
file, err := h.fileResolver.GetByID(ctx, preferredID)
|
||
if err == nil && file != nil {
|
||
return file, nil
|
||
}
|
||
if err != nil && (fallbackID == 0 || fallbackID == preferredID) {
|
||
return nil, err
|
||
}
|
||
}
|
||
if fallbackID > 0 && fallbackID != preferredID {
|
||
return h.fileResolver.GetByID(ctx, fallbackID)
|
||
}
|
||
return nil, nil
|
||
}
|
||
|
||
func sourceVideoCodec(file *models.MediaFile) string {
|
||
if file == nil {
|
||
return ""
|
||
}
|
||
if len(file.VideoTracks) > 0 && file.VideoTracks[0].Codec != "" {
|
||
return file.VideoTracks[0].Codec
|
||
}
|
||
return file.CodecVideo
|
||
}
|
||
|
||
func sourceAudioCodec(file *models.MediaFile) string {
|
||
if file == nil {
|
||
return ""
|
||
}
|
||
if len(file.AudioTracks) > 0 && file.AudioTracks[0].Codec != "" {
|
||
return file.AudioTracks[0].Codec
|
||
}
|
||
return file.CodecAudio
|
||
}
|
||
|
||
func directPlayAudioTrackIndex(file *models.MediaFile) int {
|
||
if file == nil || len(file.AudioTracks) == 0 {
|
||
return 0
|
||
}
|
||
for i, track := range file.AudioTracks {
|
||
if track.Default {
|
||
return i
|
||
}
|
||
}
|
||
return 0
|
||
}
|
||
|
||
func clientSupportsAudioCodec(req startPlaybackRequest, codec string) bool {
|
||
if codec == "" {
|
||
return true
|
||
}
|
||
if len(req.CodecsAudio) == 0 {
|
||
return playback.BrowserSupportsAudioCodec(codec)
|
||
}
|
||
for _, supported := range req.CodecsAudio {
|
||
if strings.EqualFold(supported, codec) {
|
||
return true
|
||
}
|
||
}
|
||
if req.AudioPassthrough != nil {
|
||
for _, supported := range req.AudioPassthrough.PassthroughCodecs {
|
||
if strings.EqualFold(supported, codec) {
|
||
return true
|
||
}
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func adjustPlaybackForSelectedAudio(
|
||
file *models.MediaFile,
|
||
req startPlaybackRequest,
|
||
method playback.PlayMethod,
|
||
transcodeAudio bool,
|
||
audioTrackIndex int,
|
||
preserveDirectAudioSelection bool,
|
||
) (playback.PlayMethod, bool) {
|
||
if file == nil || len(file.AudioTracks) == 0 || audioTrackIndex < 0 || audioTrackIndex >= len(file.AudioTracks) {
|
||
return method, transcodeAudio
|
||
}
|
||
|
||
selectedTrack := file.AudioTracks[audioTrackIndex]
|
||
audioSupported := clientSupportsAudioCodec(req, selectedTrack.Codec)
|
||
|
||
switch method {
|
||
case playback.PlayDirect:
|
||
if preserveDirectAudioSelection {
|
||
return playback.PlayDirect, false
|
||
}
|
||
// Direct play cannot force the browser onto a non-default audio stream.
|
||
// Promote to remux so ffmpeg can map the selected track explicitly.
|
||
if audioTrackIndex != directPlayAudioTrackIndex(file) {
|
||
return playback.PlayRemux, !audioSupported
|
||
}
|
||
if !audioSupported {
|
||
return playback.PlayRemux, true
|
||
}
|
||
return method, false
|
||
case playback.PlayRemux:
|
||
return method, !audioSupported
|
||
default:
|
||
return method, transcodeAudio
|
||
}
|
||
}
|
||
|
||
func normalizeAudioTrackIndex(file *models.MediaFile, audioTrackIndex int) int {
|
||
if file == nil || len(file.AudioTracks) == 0 {
|
||
return 0
|
||
}
|
||
if audioTrackIndex >= 0 && audioTrackIndex < len(file.AudioTracks) {
|
||
return audioTrackIndex
|
||
}
|
||
return directPlayAudioTrackIndex(file)
|
||
}
|
||
|
||
func playbackAdminSettingsFromRequest(ctx context.Context, repo PlaybackSettingsReader, transcodeEnabled bool) playback.AdminSettings {
|
||
settings := playback.AdminSettings{
|
||
TranscodeEnabled: transcodeEnabled,
|
||
}
|
||
if repo != nil {
|
||
if v, _ := repo.Get(ctx, "allow_4k_transcode"); v == "true" {
|
||
settings.Allow4KTranscode = true
|
||
}
|
||
}
|
||
return settings
|
||
}
|
||
|
||
func resolvePlaybackMethodForFile(
|
||
file *models.MediaFile,
|
||
req startPlaybackRequest,
|
||
audioTrackIndex int,
|
||
adminSettings playback.AdminSettings,
|
||
) (playback.PlayMethod, bool) {
|
||
if file == nil {
|
||
return "", false
|
||
}
|
||
|
||
caps := playback.ClientCapabilities{
|
||
CodecsVideo: req.CodecsVideo,
|
||
CodecsAudio: req.CodecsAudio,
|
||
Containers: req.Containers,
|
||
MaxResolution: req.MaxResolution,
|
||
HDR: req.HDR,
|
||
}
|
||
if req.AudioPassthrough != nil {
|
||
caps.AudioPassthroughCodecs = req.AudioPassthrough.PassthroughCodecs
|
||
}
|
||
decision := playback.Resolve(file, caps, adminSettings)
|
||
return adjustPlaybackForSelectedAudio(file, req, decision.Method, decision.TranscodeAudio, audioTrackIndex, false)
|
||
}
|
||
|
||
func (h *PlaybackHandler) resolveCapabilityPlaybackSelection(
|
||
ctx context.Context,
|
||
req startPlaybackRequest,
|
||
requestedFile *models.MediaFile,
|
||
audioTrackIndex int,
|
||
) (*models.MediaFile, playback.PlayMethod, bool, int) {
|
||
if requestedFile == nil {
|
||
return requestedFile, "", false, 0
|
||
}
|
||
|
||
audioTrackIndex = normalizeAudioTrackIndex(requestedFile, audioTrackIndex)
|
||
adminSettings := playbackAdminSettingsFromRequest(ctx, h.SettingsRepo, h.playbackConfig().TranscodeEnabled)
|
||
method, transcodeAudio := resolvePlaybackMethodForFile(requestedFile, req, audioTrackIndex, adminSettings)
|
||
|
||
if requestedFile.Resolution == "2160p" &&
|
||
method == playback.PlayTranscode &&
|
||
!adminSettings.Allow4KTranscode &&
|
||
h.FileVersionFetcher != nil {
|
||
alt, err := h.findAlternateFile(ctx, requestedFile)
|
||
if err == nil && alt != nil {
|
||
effectiveFile := h.ensurePlaybackProbe(ctx, alt)
|
||
effectiveAudioTrackIndex := playback.MatchAudioTrackAcrossVersions(
|
||
requestedFile.AudioTracks,
|
||
effectiveFile.AudioTracks,
|
||
audioTrackIndex,
|
||
)
|
||
if effectiveAudioTrackIndex != audioTrackIndex {
|
||
slog.InfoContext(ctx, "remapped audio track for alternate file",
|
||
"requested_file_id", requestedFile.ID,
|
||
"effective_file_id", effectiveFile.ID,
|
||
"requested_audio_track_index", audioTrackIndex,
|
||
"effective_audio_track_index", effectiveAudioTrackIndex,
|
||
)
|
||
}
|
||
audioTrackIndex = effectiveAudioTrackIndex
|
||
method, transcodeAudio = resolvePlaybackMethodForFile(effectiveFile, req, audioTrackIndex, adminSettings)
|
||
return effectiveFile, method, transcodeAudio, audioTrackIndex
|
||
}
|
||
}
|
||
|
||
return requestedFile, method, transcodeAudio, audioTrackIndex
|
||
}
|
||
|
||
func (h *PlaybackHandler) resolveSeriesID(ctx context.Context, file *models.MediaFile) string {
|
||
if file.EpisodeID == "" || h.EpisodeLookup == nil {
|
||
return ""
|
||
}
|
||
ep, err := h.EpisodeLookup.GetByID(ctx, file.EpisodeID)
|
||
if err != nil || ep == nil {
|
||
return ""
|
||
}
|
||
return ep.SeriesID
|
||
}
|
||
|
||
// resolveOriginalLanguage fetches the original language for a media file's content item.
|
||
// For episodes, it looks up the parent series. Returns empty string if unavailable.
|
||
func (h *PlaybackHandler) resolveOriginalLanguage(ctx context.Context, file *models.MediaFile) string {
|
||
if h.OriginalLangLookup == nil {
|
||
return ""
|
||
}
|
||
contentID := file.ContentID
|
||
if file.EpisodeID != "" {
|
||
contentID = h.resolveSeriesID(ctx, file)
|
||
}
|
||
if contentID == "" {
|
||
return ""
|
||
}
|
||
lang, err := h.OriginalLangLookup.GetOriginalLanguage(ctx, contentID)
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
return lang
|
||
}
|
||
|
||
func (h *PlaybackHandler) restoreSessionProgress(
|
||
ctx context.Context,
|
||
session *playback.Session,
|
||
file *models.MediaFile,
|
||
) {
|
||
if h.StoreProvider == nil || session == nil || file == nil {
|
||
return
|
||
}
|
||
|
||
targetID := playbackProgressTarget(file)
|
||
if targetID == "" {
|
||
return
|
||
}
|
||
|
||
store, err := h.StoreProvider.ForUser(ctx, session.UserID)
|
||
if err != nil {
|
||
slog.ErrorContext(ctx, "failed to get user store", "component", "api", "user_id", session.UserID, "error", err)
|
||
return
|
||
}
|
||
|
||
progress, err := store.GetProgress(ctx, session.ProfileID, targetID)
|
||
if err != nil {
|
||
slog.ErrorContext(ctx, "failed to load progress", "component", "api", "target", targetID, "error", err)
|
||
return
|
||
}
|
||
|
||
if progress == nil || progress.Completed || progress.PositionSeconds <= 0 {
|
||
return
|
||
}
|
||
|
||
if err := h.sessionMgr.UpdateProgress(session.ID, progress.PositionSeconds, false); err != nil {
|
||
slog.ErrorContext(ctx, "failed to restore progress", "component", "api", "session", session.ID, "error", err)
|
||
return
|
||
}
|
||
|
||
session.Position = progress.PositionSeconds
|
||
session.IsPaused = false
|
||
}
|
||
|
||
// --- Persistence helpers ---
|
||
|
||
// persistProgress saves the current playback position to the UserStore.
|
||
// It resolves the mediaFileID to a mediaItemID via the file resolver.
|
||
// Errors are logged but do not fail the HTTP request.
|
||
func (h *PlaybackHandler) persistProgress(ctx context.Context, session *playback.Session) {
|
||
if h.StoreProvider == nil || h.fileResolver == nil {
|
||
return
|
||
}
|
||
if session == nil || session.DisableProgressPersistence {
|
||
return
|
||
}
|
||
// Position 0 carries no resume information (mirrors persistStopAndHistory
|
||
// and the jellycompat report path). Progress is last-write-wins, so an
|
||
// early zero heartbeat — e.g. before a client finishes seeking to its
|
||
// resume point — must not wipe the stored resume position.
|
||
if session.Position <= 0 {
|
||
return
|
||
}
|
||
|
||
file, err := h.loadFileByPreferredID(ctx, requestedMediaFileID(session), session.MediaFileID)
|
||
targetID := playbackProgressTarget(file)
|
||
if err != nil || targetID == "" {
|
||
return // file not found or not yet matched to a media item
|
||
}
|
||
|
||
store, err := h.StoreProvider.ForUser(ctx, session.UserID)
|
||
if err != nil {
|
||
slog.ErrorContext(ctx, "failed to get user store", "component", "api", "user_id", session.UserID, "error", err)
|
||
return
|
||
}
|
||
|
||
duration := float64(file.Duration)
|
||
if err := store.UpdateProgress(ctx, session.ProfileID, targetID, session.Position, duration, h.playbackThresholds(ctx)); err != nil {
|
||
slog.ErrorContext(ctx, "failed to persist progress", "component", "api", "session", session.ID, "error", err)
|
||
} else {
|
||
triggerProfileRefresh(ctx, h.profileStaler, h.profileRefreshRequester, session.UserID, session.ProfileID)
|
||
}
|
||
|
||
if err := store.UpdateProgressHints(ctx, session.ProfileID, targetID, userstore.VersionHints{
|
||
FileID: file.ID,
|
||
Resolution: file.Resolution,
|
||
HDR: file.HDR,
|
||
CodecVideo: file.CodecVideo,
|
||
EditionKey: file.EditionKey,
|
||
}); err != nil {
|
||
slog.ErrorContext(ctx, "failed to persist version hints", "component", "api", "session", session.ID, "error", err)
|
||
}
|
||
}
|
||
|
||
// persistStopAndHistory saves the final position and adds a watch history entry
|
||
// when a playback session is stopped. Errors are logged but do not fail the
|
||
// HTTP request.
|
||
func (h *PlaybackHandler) persistStopAndHistory(ctx context.Context, session *playback.Session) watchstate.PlaybackStopResult {
|
||
if h.StoreProvider == nil || h.fileResolver == nil {
|
||
return watchstate.PlaybackStopResult{}
|
||
}
|
||
if session == nil || session.DisableProgressPersistence || session.Position <= 0 {
|
||
return watchstate.PlaybackStopResult{}
|
||
}
|
||
|
||
file, err := h.loadFileByPreferredID(ctx, requestedMediaFileID(session), session.MediaFileID)
|
||
targetID := playbackProgressTarget(file)
|
||
if err != nil || targetID == "" {
|
||
return watchstate.PlaybackStopResult{}
|
||
}
|
||
|
||
duration := float64(file.Duration)
|
||
thresholds := h.playbackThresholds(ctx)
|
||
watchSvc := watchstate.NewService(h.StoreProvider).
|
||
WithStableIdentityResolver(h.StableIdentityResolver).
|
||
WithCompletionObserver(h.CompletionObserver)
|
||
stoppedAt := time.Now().UTC()
|
||
result, err := watchSvc.RecordPlaybackStop(ctx, session.UserID, session.ProfileID, targetID, duration, session.Position, stoppedAt, userstore.VersionHints{
|
||
FileID: file.ID,
|
||
Resolution: file.Resolution,
|
||
HDR: file.HDR,
|
||
CodecVideo: file.CodecVideo,
|
||
EditionKey: file.EditionKey,
|
||
}, thresholds)
|
||
if err != nil {
|
||
slog.ErrorContext(ctx, "failed to persist playback stop", "component", "api", "session", session.ID, "error", err)
|
||
} else {
|
||
triggerProfileRefresh(ctx, h.profileStaler, h.profileRefreshRequester, session.UserID, session.ProfileID)
|
||
}
|
||
return result
|
||
}
|
||
|
||
func (h *PlaybackHandler) scrobbleEventForSession(ctx context.Context, session *playback.Session, mediaItemID string, duration, position float64) watchsync.ScrobbleEvent {
|
||
event := watchsync.ScrobbleEvent{
|
||
PlaybackSessionID: session.ID,
|
||
UserID: session.UserID,
|
||
ProfileID: session.ProfileID,
|
||
MediaItemID: mediaItemID,
|
||
PositionSeconds: position,
|
||
DurationSeconds: duration,
|
||
OccurredAt: time.Now().UTC(),
|
||
}
|
||
return watchsync.ResolveScrobbleIdentity(ctx, h.StableIdentityResolver, event)
|
||
}
|
||
|
||
func (h *PlaybackHandler) scrobbleEventForStoppedSession(
|
||
ctx context.Context,
|
||
session *playback.Session,
|
||
stopResult watchstate.PlaybackStopResult,
|
||
) (watchsync.ScrobbleEvent, bool) {
|
||
if session == nil || session.DisableProgressPersistence {
|
||
return watchsync.ScrobbleEvent{}, false
|
||
}
|
||
|
||
mediaItemID := stopResult.MediaItemID
|
||
duration := stopResult.DurationSeconds
|
||
position := stopResult.FinalPositionSeconds
|
||
if mediaItemID == "" {
|
||
if h.fileResolver == nil {
|
||
return watchsync.ScrobbleEvent{}, false
|
||
}
|
||
file, err := h.loadFileByPreferredID(ctx, requestedMediaFileID(session), session.MediaFileID)
|
||
if err != nil || file == nil {
|
||
return watchsync.ScrobbleEvent{}, false
|
||
}
|
||
mediaItemID = playbackProgressTarget(file)
|
||
if mediaItemID == "" {
|
||
return watchsync.ScrobbleEvent{}, false
|
||
}
|
||
duration = float64(file.Duration)
|
||
position = session.Position
|
||
}
|
||
|
||
event := h.scrobbleEventForSession(ctx, session, mediaItemID, duration, position)
|
||
event.HistoryID = stopResult.HistoryID
|
||
event.Completed = stopResult.Completed
|
||
return event, true
|
||
}
|
||
|
||
func (h *PlaybackHandler) buildAdminHistoryEntry(
|
||
ctx context.Context,
|
||
session *playback.Session,
|
||
) (*AdminPlaybackHistoryEntry, error) {
|
||
if h.AdminStore == nil || h.fileResolver == nil || session == nil {
|
||
return nil, nil
|
||
}
|
||
|
||
file, err := h.loadFileByPreferredID(ctx, requestedMediaFileID(session), session.MediaFileID)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("loading media file: %w", err)
|
||
}
|
||
|
||
targetID := playbackProgressTarget(file)
|
||
profileName := session.ProfileID
|
||
if h.StoreProvider != nil {
|
||
store, storeErr := h.StoreProvider.ForUser(ctx, session.UserID)
|
||
if storeErr != nil {
|
||
slog.ErrorContext(ctx, "failed to get user store for admin history", "component", "api", "session", session.ID, "error", storeErr)
|
||
} else if store != nil {
|
||
profile, profileErr := store.GetProfile(ctx, session.ProfileID)
|
||
if profileErr != nil {
|
||
slog.ErrorContext(ctx, "failed to load profile for admin history", "component", "api", "session", session.ID, "error", profileErr)
|
||
} else if profile != nil && strings.TrimSpace(profile.Name) != "" {
|
||
profileName = profile.Name
|
||
}
|
||
}
|
||
}
|
||
|
||
var durationPtr *float64
|
||
completed := false
|
||
if file != nil {
|
||
duration := float64(file.Duration)
|
||
durationPtr = &duration
|
||
if duration > 0 && session.Position/duration > userstore.WatchedFraction(h.playbackThresholds(ctx).WatchedPct) {
|
||
completed = true
|
||
}
|
||
}
|
||
|
||
entry := &AdminPlaybackHistoryEntry{
|
||
SessionID: session.ID,
|
||
UserID: session.UserID,
|
||
ProfileID: session.ProfileID,
|
||
ProfileName: profileName,
|
||
MediaItemID: targetID,
|
||
MediaFileID: requestedMediaFileID(session),
|
||
PlayMethod: string(semanticPlayMethod(session)),
|
||
StartedAt: session.StartedAt.UTC().Format(time.RFC3339Nano),
|
||
EndedAt: time.Now().UTC().Format(time.RFC3339Nano),
|
||
WatchedSeconds: session.Position,
|
||
DurationSeconds: durationPtr,
|
||
Completed: completed,
|
||
ClientIP: clientip.FromContext(ctx),
|
||
}
|
||
return entry, nil
|
||
}
|
||
|
||
func (h *PlaybackHandler) syncSessionsNow(ctx context.Context, reason string) {
|
||
if h.SessionSyncer == nil {
|
||
return
|
||
}
|
||
if err := h.SessionSyncer.SyncNow(ctx); err != nil {
|
||
slog.ErrorContext(ctx, "failed to sync sessions", "component", "api", "reason", reason, "error", err)
|
||
}
|
||
}
|
||
|
||
func (h *PlaybackHandler) touchSessionActivity(sessionID string) {
|
||
if h == nil || sessionID == "" {
|
||
return
|
||
}
|
||
if err := h.sessionMgr.TouchActivity(sessionID); err != nil && !errors.Is(err, playback.ErrSessionNotFound) {
|
||
slog.Warn("failed to refresh playback activity", "session", sessionID, "error", err, "playback_session_id", sessionID)
|
||
}
|
||
}
|
||
|
||
func (h *PlaybackHandler) finalizeSessionStop(ctx context.Context, session *playback.Session, syncNow bool, syncReason string, userInitiated bool) {
|
||
if h == nil || session == nil || session.ID == "" {
|
||
return
|
||
}
|
||
if ctx == nil {
|
||
ctx = context.Background()
|
||
}
|
||
|
||
stopResult := h.persistStopAndHistory(ctx, session)
|
||
if h.WatchScrobbler != nil {
|
||
if event, ok := h.scrobbleEventForStoppedSession(ctx, session, stopResult); ok && (userInitiated || stopResult.Completed) {
|
||
if err := h.WatchScrobbler.ScrobbleStop(ctx, event); err != nil {
|
||
slog.WarnContext(ctx, "failed to queue watch provider stop scrobble", "component", "api", "session", session.ID, "error", err)
|
||
}
|
||
} else if ok {
|
||
if err := h.WatchScrobbler.ScrobblePause(ctx, event); err != nil {
|
||
slog.WarnContext(ctx, "failed to queue watch provider pause scrobble", "component", "api", "session", session.ID, "error", err)
|
||
}
|
||
}
|
||
}
|
||
if entry, buildErr := h.buildAdminHistoryEntry(ctx, session); buildErr != nil {
|
||
slog.ErrorContext(ctx, "failed to build admin history", "component", "api", "session", session.ID, "error", buildErr)
|
||
} else if entry != nil && h.AdminStore != nil {
|
||
if err := h.AdminStore.RecordHistory(ctx, *entry); err != nil {
|
||
slog.ErrorContext(ctx, "failed to record admin history", "component", "api", "session", session.ID, "error", err)
|
||
}
|
||
}
|
||
|
||
if h.AdminStore != nil {
|
||
if err := h.AdminStore.DeleteSession(ctx, session.ID); err != nil {
|
||
slog.ErrorContext(ctx, "failed to delete synced session", "component", "api", "session", session.ID, "error", err)
|
||
}
|
||
}
|
||
|
||
h.closeTranscodeForSession(session)
|
||
if syncNow {
|
||
h.syncSessionsNow(ctx, syncReason)
|
||
}
|
||
}
|
||
|
||
func (h *PlaybackHandler) finalizeSessionAbort(ctx context.Context, session *playback.Session, syncNow bool, syncReason string) {
|
||
if h == nil || session == nil || session.ID == "" {
|
||
return
|
||
}
|
||
if ctx == nil {
|
||
ctx = context.Background()
|
||
}
|
||
|
||
if h.WatchScrobbler != nil && h.fileResolver != nil {
|
||
if file, err := h.loadFileByPreferredID(ctx, requestedMediaFileID(session), session.MediaFileID); err == nil && file != nil {
|
||
targetID := playbackProgressTarget(file)
|
||
if targetID != "" {
|
||
event := h.scrobbleEventForSession(ctx, session, targetID, float64(file.Duration), session.Position)
|
||
if err := h.WatchScrobbler.ScrobblePause(ctx, event); err != nil {
|
||
slog.WarnContext(ctx, "failed to queue watch provider abort scrobble", "component", "api", "session", session.ID, "error", err)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if h.AdminStore != nil {
|
||
if err := h.AdminStore.DeleteSession(ctx, session.ID); err != nil {
|
||
slog.ErrorContext(ctx, "failed to delete synced session", "component", "api", "session", session.ID, "error", err)
|
||
}
|
||
}
|
||
|
||
// Abort is a connection drop / non-terminal teardown — keep the recipe card
|
||
// so the client can reconstruct on reconnect.
|
||
h.closeTranscodeForSession(session)
|
||
if syncNow {
|
||
h.syncSessionsNow(ctx, syncReason)
|
||
}
|
||
}
|
||
|
||
func (h *PlaybackHandler) handleExpiredSession(session *playback.Session) {
|
||
if h == nil || session == nil {
|
||
return
|
||
}
|
||
sessionCopy := *session
|
||
go func() {
|
||
slog.Info("expired inactive playback session", "session", sessionCopy.ID, "playback_session_id", sessionCopy.ID)
|
||
// Expiry is a liveness reap, not a user stop — keep the recipe card so a
|
||
// resume reconstructs under the same id (the card's own TTL reaps it if
|
||
// the session is truly abandoned).
|
||
h.finalizeSessionStop(context.Background(), &sessionCopy, false, "", false)
|
||
}()
|
||
}
|
||
|
||
func playbackProgressTarget(file *models.MediaFile) string {
|
||
if file == nil {
|
||
return ""
|
||
}
|
||
if file.EpisodeID != "" {
|
||
return file.EpisodeID
|
||
}
|
||
return file.ContentID
|
||
}
|
||
|
||
func (h *PlaybackHandler) persistSeriesPlaybackPreference(
|
||
ctx context.Context,
|
||
userID int,
|
||
profileID string,
|
||
file *models.MediaFile,
|
||
) {
|
||
if h.StoreProvider == nil || file == nil {
|
||
return
|
||
}
|
||
|
||
seriesID := h.resolveSeriesID(ctx, file)
|
||
if seriesID == "" {
|
||
return
|
||
}
|
||
|
||
store, err := h.StoreProvider.ForUser(ctx, userID)
|
||
if err != nil {
|
||
slog.ErrorContext(ctx, "failed to access user store for series playback preference", "component", "api", "user_id", userID, "error", err)
|
||
return
|
||
}
|
||
|
||
if err := store.SetSeriesPlaybackPreference(ctx, userstore.SeriesPlaybackPreference{
|
||
ProfileID: profileID,
|
||
SeriesID: seriesID,
|
||
Resolution: file.Resolution,
|
||
HDR: file.HDR,
|
||
CodecVideo: file.CodecVideo,
|
||
}); err != nil {
|
||
slog.ErrorContext(ctx, "failed to persist series playback preference", "component", "api", "series_id", seriesID, "profile_id", profileID, "error", err)
|
||
}
|
||
}
|
||
|
||
func (h *PlaybackHandler) persistAudioPreference(
|
||
ctx context.Context,
|
||
userID int,
|
||
profileID string,
|
||
file *models.MediaFile,
|
||
trackIndex int,
|
||
) {
|
||
if h.StoreProvider == nil || file == nil || trackIndex < 0 || trackIndex >= len(file.AudioTracks) {
|
||
return
|
||
}
|
||
|
||
seriesID := h.resolveSeriesID(ctx, file)
|
||
if seriesID == "" {
|
||
return
|
||
}
|
||
|
||
store, err := h.StoreProvider.ForUser(ctx, userID)
|
||
if err != nil {
|
||
slog.ErrorContext(ctx, "failed to access user store for audio preference", "component", "api", "user_id", userID, "error", err)
|
||
return
|
||
}
|
||
|
||
track := file.AudioTracks[trackIndex]
|
||
if err := store.SetAudioPreference(ctx, userstore.AudioPreference{
|
||
ProfileID: profileID,
|
||
SeriesID: seriesID,
|
||
AudioTrackIndex: trackIndex,
|
||
AudioLanguage: track.Language,
|
||
TrackSignature: playback.AudioTrackSignatureFromTrack(track),
|
||
}); err != nil {
|
||
slog.ErrorContext(ctx, "failed to persist audio preference", "component", "api", "series_id", seriesID, "profile_id", profileID, "error", err)
|
||
}
|
||
}
|
||
|
||
// --- Handler methods ---
|
||
|
||
// HandleStartPlayback dispatches the shared start endpoint by protocol
|
||
// envelope while preserving the exact legacy request decoder and behavior.
|
||
func (h *PlaybackHandler) HandleStartPlayback(w http.ResponseWriter, r *http.Request) {
|
||
if apimw.GetUserID(r.Context()) == 0 {
|
||
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
|
||
return
|
||
}
|
||
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxPlaybackV3BodyBytes))
|
||
if err != nil {
|
||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
||
return
|
||
}
|
||
var envelope struct {
|
||
ProtocolVersion *int `json:"protocol_version"`
|
||
}
|
||
if err := json.NewDecoder(bytes.NewReader(body)).Decode(&envelope); err != nil {
|
||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
||
return
|
||
}
|
||
if envelope.ProtocolVersion != nil && *envelope.ProtocolVersion == playback.ProtocolV3 {
|
||
h.handleStartPlaybackV3(w, r, body)
|
||
return
|
||
}
|
||
r.Body = io.NopCloser(bytes.NewReader(body))
|
||
h.handleStartPlaybackLegacy(w, r)
|
||
}
|
||
|
||
// handleStartPlaybackLegacy is the pre-v3 start implementation. Keep changes
|
||
// to this function independent from protocol-v3 routing.
|
||
func (h *PlaybackHandler) handleStartPlaybackLegacy(w http.ResponseWriter, r *http.Request) {
|
||
userID := apimw.GetUserID(r.Context())
|
||
if userID == 0 {
|
||
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
|
||
return
|
||
}
|
||
|
||
var req startPlaybackRequest
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
||
return
|
||
}
|
||
|
||
if req.FileID == 0 {
|
||
writeError(w, http.StatusBadRequest, "bad_request", "File ID is required")
|
||
return
|
||
}
|
||
profileID := apimw.GetProfileID(r.Context())
|
||
if profileID == "" {
|
||
writeError(w, http.StatusBadRequest, "bad_request", "X-Profile-Id header is required")
|
||
return
|
||
}
|
||
if req.ProfileID != "" && req.ProfileID != profileID {
|
||
writeError(w, http.StatusBadRequest, "bad_request", "profile_id must match X-Profile-Id")
|
||
return
|
||
}
|
||
file, err := h.loadAuthorizedFile(r, req.FileID)
|
||
if err != nil {
|
||
switch {
|
||
case errors.Is(err, catalog.ErrItemNotFound), errors.Is(err, catalog.ErrEpisodeNotFound):
|
||
writeError(w, http.StatusNotFound, "not_found", "Media file not found")
|
||
default:
|
||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to authorize media file")
|
||
}
|
||
return
|
||
}
|
||
file = h.ensurePlaybackProbe(r.Context(), file)
|
||
|
||
// Determine audio track.
|
||
audioTrackIndex := 0
|
||
if req.AudioTrackIndex != nil && *req.AudioTrackIndex >= 0 {
|
||
audioTrackIndex = *req.AudioTrackIndex
|
||
} else if file != nil && len(file.AudioTracks) > 0 && h.StoreProvider != nil {
|
||
var seriesPref *playback.AudioTrackPreference
|
||
var preferredLang string
|
||
store, storeErr := h.StoreProvider.ForUser(r.Context(), userID)
|
||
if storeErr == nil {
|
||
seriesID := h.resolveSeriesID(r.Context(), file)
|
||
if seriesID != "" {
|
||
if ap, apErr := store.GetAudioPreference(r.Context(), profileID, seriesID); apErr == nil && ap != nil {
|
||
seriesPref = &playback.AudioTrackPreference{
|
||
AudioTrackIndex: ap.AudioTrackIndex,
|
||
AudioLanguage: ap.AudioLanguage,
|
||
TrackSignature: ap.TrackSignature,
|
||
}
|
||
}
|
||
}
|
||
if seriesPref != nil && seriesPref.AudioLanguage == playback.OriginalLanguageSentinel {
|
||
seriesPref.AudioLanguage = h.resolveOriginalLanguage(r.Context(), file)
|
||
}
|
||
if profile, profErr := store.GetProfile(r.Context(), profileID); profErr == nil && profile != nil {
|
||
preferredLang = profile.Language
|
||
}
|
||
|
||
// Resolve library override (if no series sticky pref exists).
|
||
var libraryAudioLang string
|
||
if seriesPref == nil {
|
||
if lp, lpErr := store.GetLibraryPlaybackPreference(r.Context(), profileID, file.MediaFolderID); lpErr == nil && lp != nil && lp.AudioLanguage != "" {
|
||
libraryAudioLang = lp.AudioLanguage
|
||
}
|
||
}
|
||
|
||
// Resolve "original" sentinel at each preference level.
|
||
needsOriginalLang := preferredLang == playback.OriginalLanguageSentinel ||
|
||
libraryAudioLang == playback.OriginalLanguageSentinel
|
||
if needsOriginalLang {
|
||
originalLang := h.resolveOriginalLanguage(r.Context(), file)
|
||
if preferredLang == playback.OriginalLanguageSentinel {
|
||
preferredLang = originalLang
|
||
}
|
||
if libraryAudioLang == playback.OriginalLanguageSentinel {
|
||
libraryAudioLang = originalLang
|
||
}
|
||
}
|
||
|
||
// Apply library language override (skip if resolved to empty).
|
||
if libraryAudioLang != "" {
|
||
preferredLang = libraryAudioLang
|
||
}
|
||
}
|
||
audioTrackIndex = playback.SelectAudioTrack(file.AudioTracks, preferredLang, seriesPref)
|
||
}
|
||
|
||
requestedFile := file
|
||
effectiveFile := requestedFile
|
||
method := playback.PlayMethod(req.PlayMethod)
|
||
transcodeAudio := false
|
||
|
||
// If the client sent codec capabilities and no explicit play method,
|
||
// use the resolver to determine the best play strategy.
|
||
if method == "" && h.fileResolver != nil && len(req.CodecsVideo) > 0 {
|
||
effectiveFile, method, transcodeAudio, audioTrackIndex = h.resolveCapabilityPlaybackSelection(
|
||
r.Context(),
|
||
req,
|
||
requestedFile,
|
||
audioTrackIndex,
|
||
)
|
||
}
|
||
|
||
if method == "" {
|
||
method = playback.PlayDirect
|
||
}
|
||
audioTrackIndex = normalizeAudioTrackIndex(effectiveFile, audioTrackIndex)
|
||
preserveDirectAudioSelection := method == playback.PlayDirect &&
|
||
strings.EqualFold(req.PlayMethod, string(playback.PlayDirect)) &&
|
||
req.PreserveDirectAudioSelection
|
||
method, transcodeAudio = adjustPlaybackForSelectedAudio(
|
||
effectiveFile,
|
||
req,
|
||
method,
|
||
transcodeAudio,
|
||
audioTrackIndex,
|
||
preserveDirectAudioSelection,
|
||
)
|
||
if requestedFile != nil && effectiveFile != nil && requestedFile.ID != effectiveFile.ID {
|
||
if err := preflightPlaybackFile(r.Context(), requestedFile, h.MissingMarker, h.EventsHub); err != nil && !isPlaybackFileMissing(err) {
|
||
slog.WarnContext(r.Context(), "requested playback file preflight failed; continuing with alternate file", "component", "api",
|
||
"requested_file_id", requestedFile.ID,
|
||
"effective_file_id", effectiveFile.ID,
|
||
"error", err,
|
||
)
|
||
}
|
||
}
|
||
if err := preflightPlaybackFile(r.Context(), effectiveFile, h.MissingMarker, h.EventsHub); err != nil {
|
||
writePlaybackFilePreflightError(w, err)
|
||
return
|
||
}
|
||
|
||
clientInfo := playbackClientInfoFromRequest(r)
|
||
sessionCtx := playback.WithClientInfo(r.Context(), clientInfo)
|
||
var session *playback.Session
|
||
if starter, ok := h.sessionMgr.(sessionStarterWithFilesContext); ok {
|
||
session, err = starter.StartSessionWithFilesContext(
|
||
sessionCtx,
|
||
userID,
|
||
profileID,
|
||
effectiveFile.ID,
|
||
req.FileID,
|
||
method,
|
||
transcodeAudio,
|
||
)
|
||
} else {
|
||
session, err = h.sessionMgr.StartSessionWithFiles(
|
||
userID,
|
||
profileID,
|
||
effectiveFile.ID,
|
||
req.FileID,
|
||
method,
|
||
transcodeAudio,
|
||
)
|
||
}
|
||
if err != nil {
|
||
if errors.Is(err, playback.ErrTooManyStreams) {
|
||
writeError(w, http.StatusTooManyRequests, "too_many_streams", "Too many concurrent streams")
|
||
return
|
||
}
|
||
if errors.Is(err, playback.ErrTooManyTranscodes) {
|
||
writeError(w, http.StatusTooManyRequests, "too_many_transcodes", "Too many concurrent transcodes")
|
||
return
|
||
}
|
||
if errors.Is(err, playback.ErrTranscodingDisabled) {
|
||
writeError(w, http.StatusForbidden, "transcoding_disabled", "Transcoding is disabled for your user")
|
||
return
|
||
}
|
||
if errors.Is(err, playback.ErrAudioTranscodingDisabled) {
|
||
writeError(w, http.StatusForbidden, "audio_transcoding_disabled", "Audio transcoding is disabled for your user")
|
||
return
|
||
}
|
||
if errors.Is(err, playback.ErrPlaybackNotAllowed) {
|
||
writeError(w, http.StatusForbidden, "playback_not_allowed", "Playback denied by server policy")
|
||
return
|
||
}
|
||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to start playback session")
|
||
return
|
||
}
|
||
setPlaybackSessionLogContext(r, session.ID)
|
||
if req.DisableProgressPersistence {
|
||
if err := h.sessionMgr.SetProgressPersistenceDisabled(session.ID, true); err != nil {
|
||
slog.ErrorContext(r.Context(), "failed to disable progress persistence", "component", "api", "session", session.ID, "error", err)
|
||
} else {
|
||
session.DisableProgressPersistence = true
|
||
}
|
||
}
|
||
|
||
if err := h.sessionMgr.UpdateAudioTrack(session.ID, audioTrackIndex, session.PlayMethod); err != nil {
|
||
slog.ErrorContext(r.Context(), "failed to set audio track", "component", "api", "session", session.ID, "error", err)
|
||
}
|
||
targetAudioCodec := ""
|
||
if session.TranscodeAudio {
|
||
targetAudioCodec = "aac"
|
||
}
|
||
streamBitrateKbps := 0
|
||
if effectiveFile != nil {
|
||
streamBitrateKbps = effectiveFile.Bitrate
|
||
}
|
||
if err := h.sessionMgr.UpdateStreamState(session.ID, playback.SessionStreamState{
|
||
PlayMethod: session.PlayMethod,
|
||
BasePlayMethod: session.BasePlayMethod,
|
||
AudioTrackIndex: audioTrackIndex,
|
||
TranscodeAudio: session.TranscodeAudio,
|
||
ClientIP: clientip.FromContext(r.Context()),
|
||
ClientName: clientInfo.Name,
|
||
ClientVersion: clientInfo.Version,
|
||
ClientUserAgent: clientInfo.UserAgent,
|
||
StreamBitrateKbps: streamBitrateKbps,
|
||
TargetAudioCodec: targetAudioCodec,
|
||
}); err != nil {
|
||
slog.ErrorContext(r.Context(), "failed to set stream state", "component", "api", "session", session.ID, "error", err)
|
||
}
|
||
session.AudioTrackIndex = audioTrackIndex
|
||
session.ClientIP = clientip.FromContext(r.Context())
|
||
session.StreamBitrateKbps = streamBitrateKbps
|
||
session.TargetAudioCodec = targetAudioCodec
|
||
h.persistSeriesPlaybackPreference(r.Context(), userID, profileID, effectiveFile)
|
||
|
||
if req.StartPosition != nil {
|
||
if err := h.sessionMgr.UpdateProgress(session.ID, *req.StartPosition, false); err != nil {
|
||
slog.ErrorContext(r.Context(), "failed to set explicit start position", "component", "api", "session", session.ID, "error", err)
|
||
} else {
|
||
session.Position = *req.StartPosition
|
||
session.IsPaused = false
|
||
}
|
||
} else {
|
||
h.restoreSessionProgress(r.Context(), session, file)
|
||
}
|
||
if !session.DisableProgressPersistence && h.WatchScrobbler != nil && effectiveFile != nil {
|
||
targetID := playbackProgressTarget(effectiveFile)
|
||
if targetID != "" {
|
||
event := h.scrobbleEventForSession(r.Context(), session, targetID, float64(effectiveFile.Duration), session.Position)
|
||
if err := h.WatchScrobbler.ScrobbleStart(r.Context(), event); err != nil {
|
||
slog.WarnContext(r.Context(), "failed to queue watch provider start scrobble", "component", "api", "session", session.ID, "error", err)
|
||
}
|
||
}
|
||
}
|
||
if h.ChapterThumbnailQueuer != nil && effectiveFile != nil {
|
||
slog.InfoContext(r.Context(),
|
||
"queueing chapter thumbnails", "component", "api",
|
||
"source",
|
||
"playback_start",
|
||
"content_id",
|
||
effectiveFile.ContentID,
|
||
"file_id",
|
||
effectiveFile.ID,
|
||
"target_seconds",
|
||
session.Position,
|
||
)
|
||
h.ChapterThumbnailQueuer.QueuePriorityFileAtPosition(
|
||
r.Context(),
|
||
effectiveFile.ID,
|
||
session.Position,
|
||
)
|
||
}
|
||
h.maybeQueueLazyPlaybackMarkers(r.Context(), session, effectiveFile)
|
||
|
||
// Direct-play and remux sessions reconstruct from the identity stream token
|
||
// carried on their serve URL (see playbackStreamURL); there is no server-side
|
||
// card to persist. Transcode sessions receive their full-recipe token from
|
||
// HandleStartTranscode.
|
||
resp := h.toPlaybackSessionResponse(session)
|
||
resp.DurationSeconds = fileDurationSeconds(effectiveFile)
|
||
resp.PlaybackInfo = buildPlaybackInfo(session, effectiveFile)
|
||
|
||
var downloadedSubs []subtitles.DownloadedSubtitle
|
||
if h.SubtitleRepo != nil && effectiveFile != nil {
|
||
downloadedSubs, _ = h.SubtitleRepo.ListDownloadedSubtitles(r.Context(), effectiveFile.ID)
|
||
}
|
||
resp.SubtitleURLs = buildSubtitleURLs(
|
||
session.ID,
|
||
effectiveFile,
|
||
downloadedSubs,
|
||
req.SupportsBitmapSubtitleBurnIn,
|
||
)
|
||
|
||
// If stream nodes are available, generate proxy-based stream URLs.
|
||
// Remux and transcode both use HLS via a transcode node, so the planner
|
||
// picks the transcode node and its group's proxy together.
|
||
if h.NodePlanner != nil && h.JWTSecret != "" {
|
||
needsTranscode := session.PlayMethod == playback.PlayTranscode || session.PlayMethod == playback.PlayRemux
|
||
plan := h.NodePlanner.PlanSession(session.ID, "", needsTranscode, fileBitrateKbps(effectiveFile))
|
||
proxyNode := plan.ProxyNode
|
||
if proxyNode != nil && (!needsTranscode || plan.TranscodeNode != nil) {
|
||
tokenClaims := streamtoken.Claims{
|
||
SessionID: session.ID,
|
||
PlayMethod: string(session.PlayMethod),
|
||
UserID: session.UserID,
|
||
ProfileID: session.ProfileID,
|
||
MediaFileID: session.MediaFileID,
|
||
Origin: playback.OriginNative,
|
||
ClientName: session.ClientName,
|
||
}
|
||
|
||
// Resolve media path if possible.
|
||
if effectiveFile != nil {
|
||
tokenClaims.MediaPath = effectiveFile.FilePath
|
||
tokenClaims.DVProfile = effectiveFile.PrimaryDVProfile()
|
||
}
|
||
|
||
tokenClaims.TranscodeAudio = session.TranscodeAudio
|
||
tokenClaims.AudioTrackIndex = session.AudioTrackIndex
|
||
|
||
if plan.TranscodeNode != nil {
|
||
tokenClaims.TranscodeNode = plan.TranscodeNode.URL
|
||
_ = h.sessionMgr.SetTranscodeNodeURL(session.ID, plan.TranscodeNode.URL)
|
||
}
|
||
|
||
token, signErr := streamtoken.Sign(tokenClaims, h.JWTSecret, playback.MaxTokenTTL)
|
||
if signErr == nil {
|
||
switch session.PlayMethod {
|
||
case playback.PlayDirect:
|
||
resp.StreamURL = proxyNode.URL + "/stream/direct/" + token
|
||
case playback.PlayRemux, playback.PlayTranscode:
|
||
resp.StreamURL = proxyNode.URL + "/stream/transcode/" + token + "/master.m3u8"
|
||
}
|
||
|
||
// Update subtitle URLs to use proxy for embedded subs only.
|
||
// External and downloaded subs stay on the API server since
|
||
// the proxy doesn't have access to those files.
|
||
embeddedOffset := 0
|
||
if file != nil {
|
||
embeddedOffset = len(file.ExternalSubtitles)
|
||
}
|
||
for i := range resp.SubtitleURLs {
|
||
if resp.SubtitleURLs[i].Source == "embedded" {
|
||
// Pass the ffmpeg-relative subtitle stream index to the proxy.
|
||
embeddedIdx := resp.SubtitleURLs[i].Index - embeddedOffset
|
||
proxySubtitleURL := proxyNode.URL + "/stream/subtitles/" + token + "/" + strconv.Itoa(embeddedIdx)
|
||
resp.SubtitleURLs[i].URL = proxySubtitleURL + subtitleURLExt(resp.SubtitleURLs[i].Codec)
|
||
if resp.SubtitleURLs[i].FontBundleURL != "" {
|
||
resp.SubtitleURLs[i].FontBundleURL = proxySubtitleURL + "/fonts"
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if h.protocolV3ShadowEnabled(r.Context()) {
|
||
shadowReq := req
|
||
shadowReq.ProfileID = profileID
|
||
go h.shadowLegacyPlaybackV3(context.WithoutCancel(r.Context()), shadowReq, requestedFile, effectiveFile, audioTrackIndex, session.PlayMethod, session.TranscodeAudio, session.ID)
|
||
}
|
||
h.syncSessionsNow(r.Context(), "start")
|
||
writeJSON(w, http.StatusCreated, resp)
|
||
}
|
||
|
||
func playbackClientInfoFromRequest(r *http.Request) playback.ClientInfo {
|
||
if r == nil {
|
||
return playback.ClientInfo{}
|
||
}
|
||
return playback.ClientInfo{
|
||
Name: strings.TrimSpace(r.Header.Get("X-Silo-Client")),
|
||
Version: strings.TrimSpace(r.Header.Get("X-Silo-Client-Version")),
|
||
UserAgent: r.UserAgent(),
|
||
}
|
||
}
|
||
|
||
// subtitleURLExt returns the URL file extension for a subtitle codec.
|
||
// ASS/SSA tracks get ".ass" so the frontend can request raw ASS data for
|
||
// client-side rendering (JASSUB); PGS tracks get ".sup" for native clients
|
||
// capable of rendering bitmap sidecars; all other text formats get ".vtt".
|
||
func subtitleURLExt(codec string) string {
|
||
switch {
|
||
case playback.IsASS(codec):
|
||
return ".ass"
|
||
case playback.IsPGS(codec):
|
||
return ".sup"
|
||
}
|
||
return ".vtt"
|
||
}
|
||
|
||
func buildSubtitleURLs(
|
||
sessionID string,
|
||
file *models.MediaFile,
|
||
downloaded []subtitles.DownloadedSubtitle,
|
||
includeBurnInOnly bool,
|
||
) []subtitleURL {
|
||
if file == nil {
|
||
return nil
|
||
}
|
||
|
||
urls := make([]subtitleURL, 0, len(file.ExternalSubtitles)+len(file.SubtitleTracks)+len(downloaded))
|
||
|
||
for i, sub := range file.ExternalSubtitles {
|
||
urls = append(urls, subtitleURL{
|
||
Index: i,
|
||
MediaFileID: file.ID,
|
||
Language: sub.Language,
|
||
Codec: sub.Format,
|
||
Label: firstNonEmptyString(sub.Title, sub.EmbeddedTitle, filepath.Base(sub.Path), sub.Language),
|
||
Source: "external",
|
||
Forced: sub.Forced,
|
||
HearingImpaired: sub.HearingImpaired,
|
||
URL: subtitleStreamURL(sessionID, i, sub.Format, file.ID),
|
||
})
|
||
}
|
||
|
||
embeddedOffset := len(file.ExternalSubtitles)
|
||
for i, track := range file.SubtitleTracks {
|
||
// PGS remains universally deliverable as a .sup sidecar. DVD/DVB
|
||
// bitmap tracks have no usable sidecar representation, so advertise
|
||
// them only to clients that explicitly declare server-side burn-in
|
||
// support. Older Apple/Android clients otherwise expose a text URL
|
||
// that ffmpeg cannot serve.
|
||
if playback.NeedsBurnIn(track.Codec) && !playback.IsPGS(track.Codec) && !includeBurnInOnly {
|
||
continue
|
||
}
|
||
urls = append(urls, subtitleURL{
|
||
Index: embeddedOffset + i,
|
||
MediaFileID: file.ID,
|
||
Language: track.Language,
|
||
Codec: track.Codec,
|
||
Label: firstNonEmptyString(track.Title, track.EmbeddedTitle, track.Language),
|
||
Source: "embedded",
|
||
Forced: track.Forced,
|
||
HearingImpaired: track.HearingImpaired,
|
||
URL: subtitleStreamURL(sessionID, embeddedOffset+i, track.Codec, file.ID),
|
||
FontBundleURL: subtitleFontBundleURL(sessionID, embeddedOffset+i, track.Codec, file.ID),
|
||
})
|
||
}
|
||
|
||
downloadedOffset := embeddedOffset + len(file.SubtitleTracks)
|
||
for i, dl := range downloaded {
|
||
urls = append(urls, subtitleURL{
|
||
Index: downloadedOffset + i,
|
||
MediaFileID: file.ID,
|
||
Language: dl.Language,
|
||
Codec: string(dl.Format),
|
||
Label: dl.ReleaseName + " (" + dl.Provider + ")",
|
||
Source: "downloaded",
|
||
HearingImpaired: dl.HearingImpaired,
|
||
URL: subtitleStreamURL(sessionID, downloadedOffset+i, string(dl.Format), file.ID),
|
||
})
|
||
}
|
||
|
||
return urls
|
||
}
|
||
|
||
func subtitleStreamURL(sessionID string, trackIndex int, codec string, fileID int) string {
|
||
return fmt.Sprintf("/stream/%s/subtitles/%d%s?file_id=%d", sessionID, trackIndex, subtitleURLExt(codec), fileID)
|
||
}
|
||
|
||
func subtitleFontBundleURL(sessionID string, trackIndex int, codec string, fileID int) string {
|
||
if !playback.IsASS(codec) {
|
||
return ""
|
||
}
|
||
return fmt.Sprintf("/stream/%s/subtitles/%d/fonts?file_id=%d", sessionID, trackIndex, fileID)
|
||
}
|
||
|
||
func firstNonEmptyString(values ...string) string {
|
||
for _, value := range values {
|
||
if strings.TrimSpace(value) != "" {
|
||
return value
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// HandleUpdateProgress handles POST /playback/{session_id}/progress.
|
||
func (h *PlaybackHandler) HandleUpdateProgress(w http.ResponseWriter, r *http.Request) {
|
||
userID := apimw.GetUserID(r.Context())
|
||
if userID == 0 {
|
||
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
|
||
return
|
||
}
|
||
|
||
sessionID := chi.URLParam(r, "session_id")
|
||
if sessionID == "" {
|
||
writeError(w, http.StatusBadRequest, "bad_request", "Session ID is required")
|
||
return
|
||
}
|
||
setPlaybackSessionLogContext(r, sessionID)
|
||
session, err := h.sessionMgr.GetSession(sessionID)
|
||
if err != nil {
|
||
if errors.Is(err, playback.ErrSessionNotFound) {
|
||
writePlaybackSessionNotFound(w)
|
||
return
|
||
}
|
||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load playback session")
|
||
return
|
||
}
|
||
if session.UserID != userID {
|
||
writeError(w, http.StatusForbidden, "forbidden", "Session belongs to another user")
|
||
return
|
||
}
|
||
wasPaused := session.IsPaused
|
||
|
||
var req progressRequest
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
||
return
|
||
}
|
||
|
||
err = h.sessionMgr.UpdateProgress(sessionID, req.Position, req.IsPaused)
|
||
if err != nil {
|
||
if errors.Is(err, playback.ErrSessionNotFound) {
|
||
writePlaybackSessionNotFound(w)
|
||
return
|
||
}
|
||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update progress")
|
||
return
|
||
}
|
||
h.syncSessionsNow(r.Context(), "progress")
|
||
|
||
// Persist progress to UserStore (best-effort).
|
||
if sess, getErr := h.sessionMgr.GetSession(sessionID); getErr == nil {
|
||
h.persistProgress(r.Context(), sess)
|
||
if !sess.DisableProgressPersistence && h.WatchScrobbler != nil && wasPaused != sess.IsPaused {
|
||
if file, loadErr := h.loadFileByPreferredID(r.Context(), requestedMediaFileID(sess), sess.MediaFileID); loadErr == nil && file != nil {
|
||
targetID := playbackProgressTarget(file)
|
||
if targetID != "" {
|
||
event := h.scrobbleEventForSession(r.Context(), sess, targetID, float64(file.Duration), sess.Position)
|
||
if sess.IsPaused {
|
||
if err := h.WatchScrobbler.ScrobblePause(r.Context(), event); err != nil {
|
||
slog.WarnContext(r.Context(), "failed to queue watch provider pause scrobble", "component", "api", "session", sessionID, "error", err)
|
||
}
|
||
} else if err := h.WatchScrobbler.ScrobbleStart(r.Context(), event); err != nil {
|
||
slog.WarnContext(r.Context(), "failed to queue watch provider resume scrobble", "component", "api", "session", sessionID, "error", err)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
w.WriteHeader(http.StatusNoContent)
|
||
}
|
||
|
||
// HandleStopPlayback handles DELETE /playback/{session_id}.
|
||
func (h *PlaybackHandler) HandleStopPlayback(w http.ResponseWriter, r *http.Request) {
|
||
userID := apimw.GetUserID(r.Context())
|
||
if userID == 0 {
|
||
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
|
||
return
|
||
}
|
||
|
||
sessionID := chi.URLParam(r, "session_id")
|
||
if sessionID == "" {
|
||
writeError(w, http.StatusBadRequest, "bad_request", "Session ID is required")
|
||
return
|
||
}
|
||
setPlaybackSessionLogContext(r, sessionID)
|
||
|
||
session, err := h.sessionMgr.GetSession(sessionID)
|
||
if err != nil {
|
||
if errors.Is(err, playback.ErrSessionNotFound) {
|
||
writePlaybackSessionNotFound(w)
|
||
return
|
||
}
|
||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load playback session")
|
||
return
|
||
}
|
||
if session.UserID != userID {
|
||
writeError(w, http.StatusForbidden, "forbidden", "Session belongs to another user")
|
||
return
|
||
}
|
||
|
||
err = h.stopPlaybackSession(r.Context(), session, true)
|
||
if err != nil {
|
||
if errors.Is(err, playback.ErrSessionNotFound) {
|
||
writePlaybackSessionNotFound(w)
|
||
return
|
||
}
|
||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to stop playback session")
|
||
return
|
||
}
|
||
|
||
w.WriteHeader(http.StatusNoContent)
|
||
}
|
||
|
||
// HandleChangeAudioTrack handles PATCH /playback/{session_id}/audio.
|
||
func (h *PlaybackHandler) HandleChangeAudioTrack(w http.ResponseWriter, r *http.Request) {
|
||
userID := apimw.GetUserID(r.Context())
|
||
if userID == 0 {
|
||
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
|
||
return
|
||
}
|
||
|
||
sessionID := chi.URLParam(r, "session_id")
|
||
if sessionID == "" {
|
||
writeError(w, http.StatusBadRequest, "bad_request", "Session ID is required")
|
||
return
|
||
}
|
||
setPlaybackSessionLogContext(r, sessionID)
|
||
|
||
session, err := h.sessionMgr.GetSession(sessionID)
|
||
if err != nil {
|
||
if errors.Is(err, playback.ErrSessionNotFound) {
|
||
writePlaybackSessionNotFound(w)
|
||
return
|
||
}
|
||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load playback session")
|
||
return
|
||
}
|
||
if session.UserID != userID {
|
||
writeError(w, http.StatusForbidden, "forbidden", "Session belongs to another user")
|
||
return
|
||
}
|
||
previousRoute := sessionTranscodeRoute(session)
|
||
|
||
var req changeAudioRequest
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
||
return
|
||
}
|
||
|
||
// Load file to validate track index.
|
||
if h.fileResolver == nil {
|
||
writeError(w, http.StatusInternalServerError, "internal_error", "File resolver not configured")
|
||
return
|
||
}
|
||
file, err := h.fileResolver.GetByID(r.Context(), session.MediaFileID)
|
||
if err != nil || file == nil {
|
||
writeError(w, http.StatusNotFound, "not_found", "Media file not found")
|
||
return
|
||
}
|
||
if req.AudioTrackIndex < 0 || req.AudioTrackIndex >= len(file.AudioTracks) {
|
||
writeError(w, http.StatusBadRequest, "bad_request", "Audio track index out of range")
|
||
return
|
||
}
|
||
|
||
baseMethod := semanticPlayMethod(session)
|
||
newMethod := baseMethod
|
||
transcodeAudio := session.TranscodeAudio
|
||
|
||
newTrack := file.AudioTracks[req.AudioTrackIndex]
|
||
audioCodecNeedsTranscode := !playback.BrowserSupportsAudioCodec(newTrack.Codec)
|
||
|
||
if baseMethod == playback.PlayDirect {
|
||
newMethod = playback.PlayRemux
|
||
transcodeAudio = audioCodecNeedsTranscode
|
||
} else if baseMethod == playback.PlayRemux {
|
||
transcodeAudio = audioCodecNeedsTranscode
|
||
} else if baseMethod == playback.PlayTranscode {
|
||
transcodeAudio = true
|
||
}
|
||
|
||
requiresVideoTranscode := baseMethod == playback.PlayTranscode ||
|
||
(session.PlayMethod == playback.PlayTranscode &&
|
||
!strings.EqualFold(session.TargetVideoCodec, "copy"))
|
||
if (requiresVideoTranscode || transcodeAudio) &&
|
||
!h.ensureUserTranscodingAllowed(w, r, userID, requiresVideoTranscode) {
|
||
return
|
||
}
|
||
|
||
targetResolution := ""
|
||
targetVideoCodec := ""
|
||
targetAudioCodec := ""
|
||
targetBitrateKbps := 0
|
||
streamBitrateKbps := session.StreamBitrateKbps
|
||
if session.PlayMethod == playback.PlayTranscode {
|
||
targetResolution = session.TargetResolution
|
||
targetVideoCodec = session.TargetVideoCodec
|
||
targetBitrateKbps = session.TargetBitrateKbps
|
||
if newMethod == playback.PlayTranscode || transcodeAudio {
|
||
targetAudioCodec = "aac"
|
||
} else {
|
||
targetAudioCodec = "copy"
|
||
}
|
||
} else if transcodeAudio {
|
||
targetAudioCodec = "aac"
|
||
}
|
||
|
||
// A legacy copy-video restart needs a fresh keyframe origin for the new
|
||
// position. Resolve it before mutating the durable session or stopping the
|
||
// current transport so a probe failure leaves the active stream intact.
|
||
restartSegmentDuration := session.SegmentDuration
|
||
if restartSegmentDuration <= 0 {
|
||
restartSegmentDuration = playback.DefaultSegmentDuration
|
||
}
|
||
if ts := h.tm.GetTranscodeSession(sessionID); ts != nil {
|
||
if liveDuration := ts.Opts().SegmentDuration; liveDuration > 0 {
|
||
restartSegmentDuration = liveDuration
|
||
}
|
||
}
|
||
restartSeekSeconds := alignedSeekSeconds(req.Position, restartSegmentDuration, targetVideoCodec)
|
||
restartStartSegment := computeStartSegment(restartSeekSeconds, restartSegmentDuration)
|
||
restartStreamOriginSeconds := 0.0
|
||
restartCopyAnchorResolved := false
|
||
legacyCopyRestart := session.PlayMethod == playback.PlayTranscode &&
|
||
strings.EqualFold(targetVideoCodec, "copy") && isLegacyTransportSession(session)
|
||
if legacyCopyRestart {
|
||
restartCopyAnchorResolved = true
|
||
if req.Position > 0 {
|
||
anchor, anchorSegment, anchorErr := h.resolveLegacyCopySeekAnchor(
|
||
r.Context(),
|
||
h.playbackConfig().FFmpegPath,
|
||
file.FilePath,
|
||
req.Position,
|
||
restartSegmentDuration,
|
||
)
|
||
if anchorErr != nil {
|
||
slog.ErrorContext(r.Context(), "failed to resolve copy-video audio-switch seek anchor", "component", "api",
|
||
"playback_session_id", sessionID,
|
||
"requested_seek_seconds", req.Position,
|
||
"error", anchorErr,
|
||
)
|
||
writeError(w, http.StatusInternalServerError, "remux_seek_anchor_failed", "Failed to resolve remux seek position")
|
||
return
|
||
}
|
||
restartStreamOriginSeconds = anchor
|
||
restartStartSegment = anchorSegment
|
||
}
|
||
}
|
||
var deferredRemoteCopyPlan *nodepool.Plan
|
||
if legacyCopyRestart && strings.TrimSpace(session.TranscodeNodeURL) != "" &&
|
||
h.NodePlanner != nil && h.JWTSecret != "" {
|
||
estKbps := targetBitrateKbps
|
||
if estKbps <= 0 {
|
||
estKbps = fileBitrateKbps(file)
|
||
}
|
||
plan := h.NodePlanner.PlanSession(sessionID, session.TranscodeNodeURL, true, estKbps)
|
||
if plan.ProxyNode != nil && plan.TranscodeNode != nil {
|
||
deferredRemoteCopyPlan = &plan
|
||
}
|
||
}
|
||
slog.InfoContext(r.Context(), "audio switch computed playback state", "component", "api",
|
||
"playback_session_id", sessionID,
|
||
"previous_base_play_method", baseMethod,
|
||
"new_base_play_method", newMethod,
|
||
"transport_play_method", session.PlayMethod,
|
||
"audio_track_index", req.AudioTrackIndex,
|
||
"audio_codec", newTrack.Codec,
|
||
"transcode_audio", transcodeAudio,
|
||
)
|
||
updatedSession := *session
|
||
updatedSession.AudioTrackIndex = req.AudioTrackIndex
|
||
updatedSession.BasePlayMethod = newMethod
|
||
if session.PlayMethod != playback.PlayTranscode || newMethod == playback.PlayTranscode {
|
||
updatedSession.PlayMethod = newMethod
|
||
}
|
||
updatedSession.TranscodeAudio = transcodeAudio
|
||
updatedSession.TargetResolution = targetResolution
|
||
updatedSession.TargetVideoCodec = targetVideoCodec
|
||
updatedSession.TargetAudioCodec = targetAudioCodec
|
||
updatedSession.TargetBitrateKbps = targetBitrateKbps
|
||
updatedSession.SegmentDuration = restartSegmentDuration
|
||
|
||
audioSwitchReplacement := func(route playback.TranscodeRoute) playback.SessionReplacement {
|
||
return playback.SessionReplacement{
|
||
EffectiveMediaFileID: session.MediaFileID,
|
||
StreamState: playback.SessionStreamState{
|
||
PlayMethod: updatedSession.PlayMethod,
|
||
BasePlayMethod: newMethod,
|
||
AudioTrackIndex: req.AudioTrackIndex,
|
||
TranscodeAudio: transcodeAudio,
|
||
RemuxDVMode: session.RemuxDVMode,
|
||
ClientIP: session.ClientIP,
|
||
ClientName: session.ClientName,
|
||
ClientVersion: session.ClientVersion,
|
||
ClientUserAgent: session.ClientUserAgent,
|
||
StreamBitrateKbps: streamBitrateKbps,
|
||
TargetResolution: targetResolution,
|
||
TargetVideoCodec: targetVideoCodec,
|
||
TargetAudioCodec: targetAudioCodec,
|
||
TargetBitrateKbps: targetBitrateKbps,
|
||
TranscodeHWAccel: updatedSession.TranscodeHWAccel,
|
||
TranscodeNodeURL: route.NodeURL,
|
||
TranscodeTransportID: route.TransportID,
|
||
TranscodeRouteSet: true,
|
||
SubtitleTrackIndex: session.SubtitleTrackIndex,
|
||
SubtitleBurnIn: session.SubtitleBurnIn,
|
||
SegmentDuration: restartSegmentDuration,
|
||
},
|
||
}
|
||
}
|
||
audioStatePublished := false
|
||
publishAudioSwitch := func(route playback.TranscodeRoute) error {
|
||
if _, err := h.sessionMgr.ApplyReplacement(sessionID, audioSwitchReplacement(route)); err != nil {
|
||
return err
|
||
}
|
||
audioStatePublished = true
|
||
updatedSession.TranscodeNodeURL = route.NodeURL
|
||
updatedSession.TranscodeTransportID = route.TransportID
|
||
return nil
|
||
}
|
||
|
||
// Local audio switches stage a successor in a generation-scoped directory.
|
||
// The predecessor keeps serving until the successor has a manifest and the
|
||
// full session replacement has been published atomically.
|
||
if session.PlayMethod == playback.PlayTranscode && deferredRemoteCopyPlan == nil {
|
||
if previousLocal := h.tm.GetTranscodeSession(sessionID); previousLocal != nil {
|
||
opts := previousLocal.Opts()
|
||
outputSubdir := newLegacyTransportID(sessionID)
|
||
opts.OutputSubdir = outputSubdir
|
||
opts.OutputDir = filepath.Join(h.playbackConfig().TranscodeDir, outputSubdir)
|
||
opts.TranscodeTransportID = ""
|
||
opts.AudioTrackIndex = req.AudioTrackIndex
|
||
opts.SeekSeconds = restartSeekSeconds
|
||
opts.StartSegmentNumber = restartStartSegment
|
||
opts.StreamOriginSeconds = restartStreamOriginSeconds
|
||
opts.CopySeekAnchorResolved = restartCopyAnchorResolved
|
||
opts.FastStart = true
|
||
successor, restartErr := h.commitLegacyLocalReplacement(
|
||
context.WithoutCancel(r.Context()),
|
||
sessionID,
|
||
previousLocal,
|
||
previousRoute,
|
||
opts,
|
||
func(successor *playback.TranscodeSession) playback.SessionReplacement {
|
||
updatedSession.TranscodeHWAccel = successor.Opts().HWAccel
|
||
return audioSwitchReplacement(playback.TranscodeRoute{})
|
||
},
|
||
)
|
||
if restartErr != nil {
|
||
if errors.Is(restartErr, playback.ErrSessionSuperseded) {
|
||
writeError(w, http.StatusConflict, "transcode_replaced", "A newer playback transport replaced this request")
|
||
return
|
||
}
|
||
slog.ErrorContext(r.Context(), "failed to prepare transcode for audio switch", "component", "api", "session", sessionID, "error", restartErr)
|
||
writeError(w, http.StatusInternalServerError, "transcode_start_failed", "Failed to restart transcode session")
|
||
return
|
||
}
|
||
audioStatePublished = true
|
||
updatedSession.TranscodeNodeURL = ""
|
||
updatedSession.TranscodeTransportID = ""
|
||
successor.SetRestartHook(func(ctx context.Context) {
|
||
h.maybeStartThrottler(ctx, successor)
|
||
h.tm.MonitorLocalTranscodeExit(sessionID, successor)
|
||
})
|
||
h.maybeStartThrottler(r.Context(), successor)
|
||
h.tm.MonitorLocalTranscodeExit(sessionID, successor)
|
||
}
|
||
}
|
||
|
||
if session.PlayMethod != playback.PlayTranscode {
|
||
if err := publishAudioSwitch(playback.TranscodeRoute{}); err != nil {
|
||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update audio track")
|
||
return
|
||
}
|
||
}
|
||
|
||
// The switched recipe travels in the freshly minted stream token on the new
|
||
// serve URL below, so a post-restart reconstruct resumes with the switched
|
||
// audio/method. For transcode the full-recipe manifest URL is rebuilt further
|
||
// down (proxy or local); for direct/remux the identity token on StreamURL
|
||
// carries the new audio selection.
|
||
if audioStatePublished {
|
||
h.persistAudioPreference(r.Context(), userID, session.ProfileID, file, req.AudioTrackIndex)
|
||
}
|
||
|
||
// For a local transcode, playbackStreamURL returns the bare manifest URL
|
||
// without the full-recipe ?st= token, so a post-restart reconstruct would
|
||
// fall back to the stale pre-switch token. Rebuild the signed manifest URL
|
||
// from the live transcode opts, mirroring HandleStartTranscode. The proxy
|
||
// branch below overrides this when a node plan picks a proxy/transcode node.
|
||
streamURL := h.playbackStreamURL(&updatedSession)
|
||
if updatedSession.PlayMethod == playback.PlayTranscode {
|
||
if ts := h.tm.GetTranscodeSession(sessionID); ts != nil {
|
||
card := playback.NewRecipeCard(updatedSession.UserID, updatedSession.ProfileID, updatedSession.MediaFileID, updatedSession.TranscodeNodeURL, ts.Opts())
|
||
streamURL = appendStreamToken(
|
||
fmt.Sprintf("/playback/transcode/%s/master.m3u8", sessionID),
|
||
h.signSessionToken(card),
|
||
)
|
||
}
|
||
}
|
||
|
||
resp := changeAudioResponse{
|
||
AudioTrackIndex: req.AudioTrackIndex,
|
||
PlayMethod: string(newMethod),
|
||
StreamURL: streamURL,
|
||
SwitchMode: "reload",
|
||
PlaybackInfo: buildPlaybackInfo(&updatedSession, file),
|
||
}
|
||
|
||
if h.NodePlanner != nil && h.JWTSecret != "" {
|
||
needsTranscode := updatedSession.PlayMethod == playback.PlayTranscode
|
||
estKbps := updatedSession.TargetBitrateKbps
|
||
if estKbps <= 0 {
|
||
estKbps = fileBitrateKbps(file)
|
||
}
|
||
var plan nodepool.Plan
|
||
if deferredRemoteCopyPlan != nil {
|
||
plan = *deferredRemoteCopyPlan
|
||
} else {
|
||
plan = h.NodePlanner.PlanSession(sessionID, session.TranscodeNodeURL, needsTranscode, estKbps)
|
||
}
|
||
if proxyNode := plan.ProxyNode; proxyNode != nil && (!needsTranscode || plan.TranscodeNode != nil) {
|
||
// Remote (offloaded) transcode: the API server owns no local
|
||
// TranscodeSession (the LOCAL restart block above was a no-op), so
|
||
// the node's ffmpeg is still serving the OLD audio track. POST a
|
||
// fresh /transcode/start with the new AudioTrackIndex. Encoded legacy
|
||
// streams retain the node's same-ID replacement behavior; copy-video
|
||
// streams prepare a distinct successor and retire the predecessor only
|
||
// after readiness and route publication. Then mint the proxy URL
|
||
// from a FULL recipe card so a later node restart reconstructs with
|
||
// the switched audio (the lean identity-only claims used for remux
|
||
// below omit the byte-affecting encode fields and would 404).
|
||
isOffloaded := strings.TrimSpace(session.TranscodeNodeURL) != ""
|
||
if needsTranscode && plan.TranscodeNode != nil && isOffloaded {
|
||
nodeURL := plan.TranscodeNode.URL
|
||
atomicLegacyReplacement := legacyCopyRestart
|
||
previousLocalTranscode := h.tm.GetTranscodeSession(sessionID)
|
||
|
||
// Restart from the FULL live recipe, not a partial re-derivation.
|
||
// An audio switch alters only audio selection — subtitle burn-in and
|
||
// the segment cadence must be preserved, or the node re-encodes a
|
||
// different byte stream (subtitles silently dropped, wrong cadence)
|
||
// and signs that altered recipe into the new token. The session
|
||
// retains these from the original start (finalizeTranscodeStart) or a
|
||
// post-restart reconstruct, so recover them here. Embed a concrete
|
||
// segment duration (not 0): the node's recipe token treats
|
||
// SegmentDuration<=0 as "incomplete" and would 404 on a node restart.
|
||
segmentDuration := restartSegmentDuration
|
||
subtitleTrackIndex := session.SubtitleTrackIndex
|
||
subtitleBurnIn := session.SubtitleBurnIn
|
||
subtitleCodec := ""
|
||
if subtitleBurnIn && subtitleTrackIndex >= 0 {
|
||
subtitleCodec = embeddedSubtitleCodec(file, subtitleTrackIndex)
|
||
}
|
||
// Derive the encode recipe the same way HandleStartTranscode
|
||
// does — from the durable session target fields plus the file —
|
||
// changing only the audio track. SourceVideoCodec/TotalDuration
|
||
// come from the file; the resolution/codec/bitrate targets and
|
||
// hwaccel come from the session's persisted stream state.
|
||
// A v3 session's node job runs under its generation-scoped
|
||
// transport ID; restarting under the bare session ID would
|
||
// spawn a duplicate job beside it.
|
||
restartTransportID := remoteTransportID(&updatedSession)
|
||
if atomicLegacyReplacement {
|
||
restartTransportID = newLegacyTransportID(sessionID)
|
||
}
|
||
nodeReq := transcodenode.TranscodeStartRequest{
|
||
SessionID: restartTransportID,
|
||
LogicalSessionID: updatedSession.ID,
|
||
InputPath: file.FilePath,
|
||
SourceVideoCodec: file.CodecVideo,
|
||
SeekSeconds: restartSeekSeconds,
|
||
StreamOriginSeconds: restartStreamOriginSeconds,
|
||
CopySeekAnchorResolved: restartCopyAnchorResolved,
|
||
StartSegmentNumber: restartStartSegment,
|
||
TargetResolution: updatedSession.TargetResolution,
|
||
TargetCodecVideo: updatedSession.TargetVideoCodec,
|
||
TargetCodecAudio: updatedSession.TargetAudioCodec,
|
||
TargetBitrateKbps: updatedSession.TargetBitrateKbps,
|
||
SegmentDuration: segmentDuration,
|
||
HWAccel: session.TranscodeHWAccel,
|
||
AudioTrackIndex: req.AudioTrackIndex,
|
||
SubtitleTrackIndex: subtitleTrackIndex,
|
||
SubtitleBurnIn: subtitleBurnIn,
|
||
SubtitleCodec: subtitleCodec,
|
||
TotalDuration: float64(file.Duration),
|
||
RequireReady: atomicLegacyReplacement,
|
||
AuthUserID: updatedSession.UserID,
|
||
ProfileID: updatedSession.ProfileID,
|
||
MediaFileID: updatedSession.MediaFileID,
|
||
Route: updatedSession.Origin(),
|
||
ClientName: updatedSession.ClientName,
|
||
}
|
||
if strings.TrimSpace(nodeReq.HWAccel) == "" {
|
||
nodeReq.HWAccel = h.playbackConfig().HWAccel
|
||
}
|
||
// A v3 DV strip remux carries its bitstream filter in the
|
||
// durable session route; dropping it here would hand the node
|
||
// a DV7 copy recipe that leaves dangling RPUs. Sources that
|
||
// fail the RPU probe are the exception — for them the filter
|
||
// rejects every packet, so re-adding it on an audio switch
|
||
// would hang a session that was playing a moment ago.
|
||
if updatedSession.RemuxDVMode == playback.RemuxDVStripToHDR10V3 && strings.EqualFold(nodeReq.TargetCodecVideo, "copy") &&
|
||
playback.DVRPUStrippable(r.Context(), h.playbackConfig().FFmpegPath, file.FilePath) {
|
||
nodeReq.VideoBitstreamFilter = playback.DV7ToHDR10BitstreamFilter
|
||
}
|
||
|
||
startResp, status, startErr := h.startRemotePlaybackTransport(
|
||
context.WithoutCancel(r.Context()),
|
||
nodeURL,
|
||
nodeReq,
|
||
)
|
||
if startErr != nil {
|
||
if atomicLegacyReplacement {
|
||
h.tm.StopRemoteTranscode(restartTransportID, nodeURL)
|
||
}
|
||
slog.ErrorContext(r.Context(), "remote transcode restart for audio switch failed", "component", "api", "session", sessionID, "node", nodeURL, "error", startErr)
|
||
writeError(w, http.StatusBadGateway, "transcode_node_unavailable", "Transcode node is unavailable")
|
||
return
|
||
}
|
||
if status != http.StatusAccepted {
|
||
if atomicLegacyReplacement {
|
||
h.tm.StopRemoteTranscode(restartTransportID, nodeURL)
|
||
}
|
||
slog.ErrorContext(r.Context(), "remote transcode restart for audio switch rejected", "component", "api", "session", sessionID, "node", nodeURL, "status", status)
|
||
writeError(w, http.StatusBadGateway, "transcode_start_failed", "Transcode node rejected the request")
|
||
return
|
||
}
|
||
effectiveHWAccel := effectiveRemoteHWAccel(startResp, nodeReq)
|
||
updatedSession.TranscodeHWAccel = effectiveHWAccel
|
||
successorTransportID := restartTransportID
|
||
if !atomicLegacyReplacement {
|
||
// Empty is the durable legacy representation for a node process
|
||
// running under the public playback session ID.
|
||
successorTransportID = session.TranscodeTransportID
|
||
}
|
||
successorRoute := playback.TranscodeRoute{NodeURL: nodeURL, TransportID: successorTransportID}
|
||
if atomicLegacyReplacement {
|
||
if routeErr := h.commitLegacyRemoteReplacement(
|
||
sessionID,
|
||
previousLocalTranscode,
|
||
previousRoute,
|
||
audioSwitchReplacement(successorRoute),
|
||
); routeErr != nil {
|
||
h.tm.StopRemoteTranscode(restartTransportID, nodeURL)
|
||
if errors.Is(routeErr, playback.ErrSessionSuperseded) {
|
||
writeError(w, http.StatusConflict, "transcode_replaced", "A newer playback transport replaced this request")
|
||
return
|
||
}
|
||
slog.ErrorContext(r.Context(), "publish remote audio-switch transcode route", "component", "api", "session", sessionID, "node", nodeURL, "error", routeErr)
|
||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to publish transcode session")
|
||
return
|
||
}
|
||
audioStatePublished = true
|
||
if !h.transcodeRouteMatches(sessionID, nil, successorRoute) {
|
||
writeError(w, http.StatusConflict, "transcode_replaced", "A newer playback transport replaced this request")
|
||
return
|
||
}
|
||
} else {
|
||
if routeErr := h.commitLegacyRemoteLastWriter(
|
||
sessionID,
|
||
successorRoute,
|
||
audioSwitchReplacement(successorRoute),
|
||
); routeErr != nil {
|
||
slog.ErrorContext(r.Context(), "publish remote audio-switch transcode route", "component", "api", "session", sessionID, "node", nodeURL, "error", routeErr)
|
||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to publish transcode session")
|
||
return
|
||
}
|
||
audioStatePublished = true
|
||
}
|
||
updatedSession.TranscodeNodeURL = successorRoute.NodeURL
|
||
updatedSession.TranscodeTransportID = successorRoute.TransportID
|
||
h.persistAudioPreference(r.Context(), userID, session.ProfileID, file, req.AudioTrackIndex)
|
||
|
||
card := playback.NewRecipeCard(updatedSession.UserID, updatedSession.ProfileID, updatedSession.MediaFileID, nodeURL, playback.TranscodeOpts{
|
||
InputPath: nodeReq.InputPath,
|
||
SessionID: sessionID,
|
||
TranscodeTransportID: restartTransportID,
|
||
VideoBitstreamFilter: nodeReq.VideoBitstreamFilter,
|
||
SourceVideoCodec: nodeReq.SourceVideoCodec,
|
||
SeekSeconds: nodeReq.SeekSeconds,
|
||
StreamOriginSeconds: nodeReq.StreamOriginSeconds,
|
||
CopySeekAnchorResolved: nodeReq.CopySeekAnchorResolved,
|
||
StartSegmentNumber: nodeReq.StartSegmentNumber,
|
||
TargetResolution: nodeReq.TargetResolution,
|
||
TargetCodecVideo: nodeReq.TargetCodecVideo,
|
||
TargetCodecAudio: nodeReq.TargetCodecAudio,
|
||
TargetBitrateKbps: nodeReq.TargetBitrateKbps,
|
||
SegmentDuration: nodeReq.SegmentDuration,
|
||
HWAccel: effectiveHWAccel,
|
||
AudioTrackIndex: nodeReq.AudioTrackIndex,
|
||
SubtitleTrackIndex: nodeReq.SubtitleTrackIndex,
|
||
SubtitleBurnIn: nodeReq.SubtitleBurnIn,
|
||
SubtitleCodec: nodeReq.SubtitleCodec,
|
||
TotalDuration: nodeReq.TotalDuration,
|
||
})
|
||
resp.StreamURL = h.buildProxyManifestURL(card, proxyNode)
|
||
} else {
|
||
// Remux, or a non-offloaded (locally served) transcode: no remote
|
||
// node ffmpeg to restart, so carry the new audio selection on the
|
||
// identity claims of the proxy serve URL, exactly as before. A
|
||
// local transcode reconstructs from the API server's own state, so
|
||
// the lean token is sufficient here.
|
||
tokenClaims := streamtoken.Claims{
|
||
SessionID: sessionID,
|
||
PlayMethod: string(updatedSession.PlayMethod),
|
||
MediaPath: file.FilePath,
|
||
TranscodeAudio: updatedSession.TranscodeAudio,
|
||
AudioTrackIndex: req.AudioTrackIndex,
|
||
DVProfile: file.PrimaryDVProfile(),
|
||
UserID: updatedSession.UserID,
|
||
ProfileID: updatedSession.ProfileID,
|
||
MediaFileID: updatedSession.MediaFileID,
|
||
// v3 sessions route by transport ID and pin an explicit DV
|
||
// mode; a re-minted token must not silently shed either.
|
||
TranscodeTransportID: updatedSession.TranscodeTransportID,
|
||
RemuxDVMode: string(updatedSession.RemuxDVMode),
|
||
Origin: updatedSession.Origin(),
|
||
ClientName: updatedSession.ClientName,
|
||
}
|
||
if plan.TranscodeNode != nil {
|
||
tokenClaims.TranscodeNode = plan.TranscodeNode.URL
|
||
}
|
||
if token, signErr := streamtoken.Sign(tokenClaims, h.JWTSecret, playback.MaxTokenTTL); signErr == nil {
|
||
switch updatedSession.PlayMethod {
|
||
case playback.PlayRemux:
|
||
resp.StreamURL = proxyNode.URL + "/stream/remux/" + token
|
||
case playback.PlayTranscode:
|
||
resp.StreamURL = proxyNode.URL + "/stream/transcode/" + token + "/master.m3u8"
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if !audioStatePublished {
|
||
if err := publishAudioSwitch(previousRoute); err != nil {
|
||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update audio track")
|
||
return
|
||
}
|
||
h.persistAudioPreference(r.Context(), userID, session.ProfileID, file, req.AudioTrackIndex)
|
||
}
|
||
if legacyCopyRestart {
|
||
resp.setCopyTimeline(req.Position, restartStreamOriginSeconds)
|
||
}
|
||
|
||
h.syncSessionsNow(r.Context(), "audio_change")
|
||
writeJSON(w, http.StatusOK, resp)
|
||
}
|
||
|
||
func (h *PlaybackHandler) loadAuthorizedFile(r *http.Request, fileID int) (*models.MediaFile, error) {
|
||
if h.fileResolver == nil || h.ItemAccess == nil {
|
||
return nil, fmt.Errorf("playback authorization dependencies not configured")
|
||
}
|
||
file, err := h.fileResolver.GetByID(r.Context(), fileID)
|
||
if err != nil {
|
||
return nil, mapMediaFileLookupError(err)
|
||
}
|
||
if file == nil || file.MissingSince != nil {
|
||
return nil, catalog.ErrItemNotFound
|
||
}
|
||
|
||
filter := requestAccessFilter(r)
|
||
switch {
|
||
case file.EpisodeID != "":
|
||
if h.EpisodeLookup == nil {
|
||
return nil, fmt.Errorf("episode lookup not configured")
|
||
}
|
||
episode, err := h.EpisodeLookup.GetByID(r.Context(), file.EpisodeID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if episode == nil {
|
||
return nil, catalog.ErrEpisodeNotFound
|
||
}
|
||
if err := h.ItemAccess.EnsureAccessible(r.Context(), episode.SeriesID, filter); err != nil {
|
||
return nil, err
|
||
}
|
||
case file.ContentID != "":
|
||
if err := h.ItemAccess.EnsureAccessible(r.Context(), file.ContentID, filter); err != nil {
|
||
return nil, err
|
||
}
|
||
case file.ExtraID != "":
|
||
if h.ExtraLookup == nil {
|
||
return nil, fmt.Errorf("extra lookup not configured")
|
||
}
|
||
extra, err := h.ExtraLookup.GetByID(r.Context(), file.ExtraID)
|
||
if err != nil {
|
||
if errors.Is(err, catalog.ErrExtraNotFound) {
|
||
return nil, catalog.ErrItemNotFound
|
||
}
|
||
return nil, err
|
||
}
|
||
if extra == nil {
|
||
return nil, catalog.ErrItemNotFound
|
||
}
|
||
if err := h.ItemAccess.EnsureAccessible(r.Context(), extra.ParentID, filter); err != nil {
|
||
return nil, err
|
||
}
|
||
default:
|
||
return nil, catalog.ErrItemNotFound
|
||
}
|
||
|
||
if !catalog.FileAllowedByAccess(file, filter) {
|
||
return nil, catalog.ErrItemNotFound
|
||
}
|
||
|
||
return file, nil
|
||
}
|
||
|
||
// embeddedSubtitleCodec returns the probed codec of the embedded subtitle
|
||
// track at the given ffmpeg-relative subtitle ordinal (the same index the
|
||
// subtitles=si=N / [0:s:N] filters use), or "" when out of range.
|
||
func embeddedSubtitleCodec(file *models.MediaFile, ffmpegSubtitleIndex int) string {
|
||
if file == nil || ffmpegSubtitleIndex < 0 || ffmpegSubtitleIndex >= len(file.SubtitleTracks) {
|
||
return ""
|
||
}
|
||
return file.SubtitleTracks[ffmpegSubtitleIndex].Codec
|
||
}
|
||
|
||
// resolveBurnInSubtitle maps a subtitle selection made against requestedFile
|
||
// onto effectiveFile. The 4K guard may replace the requested file with a
|
||
// lower-resolution version whose subtitle streams have a different order; a
|
||
// raw ordinal carried across that switch can burn the wrong language.
|
||
func resolveBurnInSubtitle(requestedFile, effectiveFile *models.MediaFile, requestedIndex int) (int, string, bool) {
|
||
if requestedFile == nil || effectiveFile == nil || requestedIndex < 0 || requestedIndex >= len(requestedFile.SubtitleTracks) {
|
||
return -1, "", false
|
||
}
|
||
if requestedFile.ID == effectiveFile.ID {
|
||
track := effectiveFile.SubtitleTracks[requestedIndex]
|
||
return requestedIndex, track.Codec, true
|
||
}
|
||
|
||
selected := requestedFile.SubtitleTracks[requestedIndex]
|
||
for i, candidate := range effectiveFile.SubtitleTracks {
|
||
if subtitleTracksMatch(selected, candidate) {
|
||
return i, candidate.Codec, true
|
||
}
|
||
}
|
||
return -1, "", false
|
||
}
|
||
|
||
func subtitleTracksMatch(a, b models.SubtitleTrack) bool {
|
||
return strings.EqualFold(strings.TrimSpace(a.Language), strings.TrimSpace(b.Language)) &&
|
||
strings.EqualFold(strings.TrimSpace(a.Codec), strings.TrimSpace(b.Codec)) &&
|
||
strings.EqualFold(
|
||
strings.TrimSpace(firstNonEmptyString(a.Title, a.EmbeddedTitle)),
|
||
strings.TrimSpace(firstNonEmptyString(b.Title, b.EmbeddedTitle)),
|
||
) &&
|
||
a.Forced == b.Forced &&
|
||
a.HearingImpaired == b.HearingImpaired
|
||
}
|
||
|
||
// computeStartSegment returns the HLS segment number corresponding to a seek
|
||
// position given the segment duration. Both remote and local transcode paths
|
||
// use this to align ffmpeg output filenames with the VOD manifest.
|
||
func computeStartSegment(seekSeconds float64, segmentDuration int) int {
|
||
if segmentDuration <= 0 {
|
||
segmentDuration = 2
|
||
}
|
||
if seekSeconds <= 0 {
|
||
return 0
|
||
}
|
||
return int(seekSeconds / float64(segmentDuration))
|
||
}
|
||
|
||
// alignedSeekSeconds snaps an encoded transcode's ffmpeg start position down
|
||
// to the boundary of the segment computeStartSegment assigns it. The synthetic
|
||
// VOD manifest declares segment N to begin at exactly N×segmentDuration;
|
||
// spawning ffmpeg at the raw seek position makes segment N actually begin up
|
||
// to one segment later, and hls.js aligns that content to the declared
|
||
// position — shifting the session's entire timeline (audio, video, and every
|
||
// out-of-band subtitle cue) late by seek mod segmentDuration. Copy-mode
|
||
// sessions serve ffmpeg's real manifest, whose declared timings match the
|
||
// fragments it produces, so they keep the raw seek.
|
||
func alignedSeekSeconds(seekSeconds float64, segmentDuration int, targetVideoCodec string) float64 {
|
||
if strings.EqualFold(targetVideoCodec, "copy") || seekSeconds <= 0 {
|
||
return seekSeconds
|
||
}
|
||
if segmentDuration <= 0 {
|
||
segmentDuration = 2
|
||
}
|
||
return float64(computeStartSegment(seekSeconds, segmentDuration) * segmentDuration)
|
||
}
|
||
|
||
// transcodeStartState holds the common parameters needed to finalize a
|
||
// transcode start (update session state, log, and sync) for both remote
|
||
// and local paths.
|
||
type transcodeStartState struct {
|
||
req transcodeStartRequest
|
||
file *models.MediaFile
|
||
session *playback.Session
|
||
switchedFileID *int
|
||
hwAccel string
|
||
}
|
||
|
||
func effectiveRemoteHWAccel(
|
||
response transcodenode.TranscodeStartResponse,
|
||
request transcodenode.TranscodeStartRequest,
|
||
) string {
|
||
hwAccel := strings.TrimSpace(response.HWAccel)
|
||
if hwAccel == "" {
|
||
hwAccel = strings.TrimSpace(request.HWAccel)
|
||
}
|
||
return hwAccel
|
||
}
|
||
|
||
func transcodeStartReplacement(st transcodeStartState, route playback.TranscodeRoute) playback.SessionReplacement {
|
||
streamBitrateKbps := st.req.TargetBitrateKbps
|
||
if streamBitrateKbps <= 0 {
|
||
streamBitrateKbps = st.file.Bitrate
|
||
}
|
||
transcodeAudio := playback.TranscodesAudio(st.req.TargetCodecAudio)
|
||
baseMethod := semanticPlayMethod(st.session)
|
||
// Persist the byte-affecting recipe (subtitles + segment cadence) so a later
|
||
// offloaded audio switch can rebuild the exact same stream. The session is the
|
||
// only recovery source for offloaded transcodes (no local ts.Opts()). Normalize
|
||
// the cadence to a concrete value so the restart never falls back to 0.
|
||
segmentDuration := st.req.SegmentDuration
|
||
if segmentDuration <= 0 {
|
||
segmentDuration = playback.DefaultSegmentDuration
|
||
}
|
||
|
||
effectiveFileID := st.file.ID
|
||
if st.switchedFileID != nil {
|
||
effectiveFileID = *st.switchedFileID
|
||
}
|
||
return playback.SessionReplacement{
|
||
EffectiveMediaFileID: effectiveFileID,
|
||
StreamState: playback.SessionStreamState{
|
||
PlayMethod: playback.PlayTranscode,
|
||
BasePlayMethod: baseMethod,
|
||
AudioTrackIndex: st.session.AudioTrackIndex,
|
||
TranscodeAudio: transcodeAudio,
|
||
RemuxDVMode: st.session.RemuxDVMode,
|
||
ClientIP: st.session.ClientIP,
|
||
ClientName: st.session.ClientName,
|
||
ClientVersion: st.session.ClientVersion,
|
||
ClientUserAgent: st.session.ClientUserAgent,
|
||
StreamBitrateKbps: streamBitrateKbps,
|
||
TargetResolution: st.req.TargetResolution,
|
||
TargetVideoCodec: st.req.TargetCodecVideo,
|
||
TargetAudioCodec: st.req.TargetCodecAudio,
|
||
TargetBitrateKbps: st.req.TargetBitrateKbps,
|
||
TranscodeHWAccel: st.hwAccel,
|
||
TranscodeNodeURL: route.NodeURL,
|
||
TranscodeTransportID: route.TransportID,
|
||
TranscodeRouteSet: true,
|
||
SubtitleTrackIndex: st.req.SubtitleTrackIndex,
|
||
SubtitleBurnIn: st.req.SubtitleBurnIn,
|
||
SegmentDuration: segmentDuration,
|
||
},
|
||
}
|
||
}
|
||
|
||
func logTranscodeStartState(r *http.Request, st transcodeStartState) {
|
||
slog.InfoContext(r.Context(), "transcode start preserved base playback state", "component", "api",
|
||
"playback_session_id", st.req.SessionID,
|
||
"base_play_method", semanticPlayMethod(st.session),
|
||
"transport_play_method", playback.PlayTranscode,
|
||
"audio_track_index", st.session.AudioTrackIndex,
|
||
"target_codec_video", st.req.TargetCodecVideo,
|
||
"target_codec_audio", st.req.TargetCodecAudio,
|
||
"copy_video_original", strings.EqualFold(st.req.TargetCodecVideo, "copy"),
|
||
"transcode_audio", st.req.TargetCodecAudio != "" && !strings.EqualFold(st.req.TargetCodecAudio, "copy"),
|
||
)
|
||
}
|
||
|
||
// HandleStartTranscode handles POST /playback/transcode/start.
|
||
func (h *PlaybackHandler) HandleStartTranscode(w http.ResponseWriter, r *http.Request) {
|
||
userID := apimw.GetUserID(r.Context())
|
||
if userID == 0 {
|
||
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
|
||
return
|
||
}
|
||
|
||
var req transcodeStartRequest
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
||
return
|
||
}
|
||
if req.SessionID == "" {
|
||
writeError(w, http.StatusBadRequest, "bad_request", "session_id is required")
|
||
return
|
||
}
|
||
setPlaybackSessionLogContext(r, req.SessionID)
|
||
|
||
session, err := h.sessionMgr.GetSession(req.SessionID)
|
||
if err != nil {
|
||
if errors.Is(err, playback.ErrSessionNotFound) {
|
||
writePlaybackSessionNotFound(w)
|
||
return
|
||
}
|
||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load playback session")
|
||
return
|
||
}
|
||
if session.UserID != userID {
|
||
writeError(w, http.StatusForbidden, "forbidden", "Session belongs to another user")
|
||
return
|
||
}
|
||
requiresVideoTranscode := !strings.EqualFold(req.TargetCodecVideo, "copy")
|
||
if !h.ensureUserTranscodingAllowed(w, r, userID, requiresVideoTranscode) {
|
||
return
|
||
}
|
||
// Keep the active transport alive through every fallible preflight. A local
|
||
// replacement is retired under the lifecycle lock immediately before spawn;
|
||
// a legacy remote replacement is prepared under a distinct process identity
|
||
// and retired only after the node accepts and the session publishes its successor.
|
||
previousLocalTranscode := h.tm.GetTranscodeSession(req.SessionID)
|
||
previousRoute := sessionTranscodeRoute(session)
|
||
abortCurrentSession := func(reason string, cause error) {
|
||
if abortErr := h.abortPlaybackSession(r.Context(), session); abortErr != nil && !errors.Is(abortErr, playback.ErrSessionNotFound) {
|
||
slog.ErrorContext(r.Context(), "failed to abort playback session", "component", "api",
|
||
"session", req.SessionID,
|
||
"reason", reason,
|
||
"cause", cause,
|
||
"error", abortErr,
|
||
"playback_session_id", req.SessionID,
|
||
)
|
||
}
|
||
}
|
||
|
||
file, err := h.fileResolver.GetByID(r.Context(), session.MediaFileID)
|
||
if err != nil {
|
||
if isPlaybackFileLookupMissing(err) {
|
||
abortCurrentSession("load_media_file", err)
|
||
writeError(w, http.StatusNotFound, "not_found", "Media file not found")
|
||
return
|
||
}
|
||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load media file")
|
||
return
|
||
}
|
||
if file == nil {
|
||
abortCurrentSession("load_media_file", nil)
|
||
writeError(w, http.StatusNotFound, "not_found", "Media file not found")
|
||
return
|
||
}
|
||
file = h.ensurePlaybackProbe(r.Context(), file)
|
||
requestedFile := file
|
||
if originalFileID := requestedMediaFileID(session); originalFileID > 0 && originalFileID != file.ID {
|
||
originalFile, loadErr := h.loadFileByPreferredID(r.Context(), originalFileID, 0)
|
||
if loadErr != nil || originalFile == nil {
|
||
requestedFile = nil
|
||
} else {
|
||
requestedFile = h.ensurePlaybackProbe(r.Context(), originalFile)
|
||
}
|
||
}
|
||
|
||
// Subtitle ordinals are meaningful only within the file inventory that
|
||
// produced them. New clients echo the media_file_id advertised beside the
|
||
// selected subtitle URL. Clients that omit it retain the legacy behavior of
|
||
// selecting against RequestedMediaFileID so existing restart flows continue
|
||
// to remap original-file ordinals after the 4K guard switches versions.
|
||
subtitleSourceFile := requestedFile
|
||
if req.SubtitleBurnIn && req.SubtitleTrackIndex >= 0 {
|
||
switch {
|
||
case req.SubtitleMediaFileID <= 0:
|
||
// Legacy request: requestedFile is the historical source inventory.
|
||
case file != nil && req.SubtitleMediaFileID == file.ID:
|
||
subtitleSourceFile = file
|
||
case requestedFile != nil && req.SubtitleMediaFileID == requestedFile.ID:
|
||
subtitleSourceFile = requestedFile
|
||
default:
|
||
subtitleSourceFile = nil
|
||
}
|
||
if subtitleSourceFile == nil {
|
||
writeError(w, http.StatusUnprocessableEntity, "subtitle_source_unavailable",
|
||
"Media file inventory for the selected subtitle is unavailable")
|
||
return
|
||
}
|
||
}
|
||
|
||
// Subtitle burn-in composites subtitles into the video frames, which is
|
||
// impossible with -c:v copy. If the requested recipe would stream-copy
|
||
// video (e.g. a remux "original" restart that adds burn-in), force an
|
||
// encoding transcode so the burned frames are actually produced instead of
|
||
// the subtitle selection being silently dropped by the filter stage.
|
||
if req.SubtitleBurnIn && req.SubtitleTrackIndex >= 0 && strings.EqualFold(req.TargetCodecVideo, "copy") {
|
||
slog.Info("forcing video transcode for subtitle burn-in request",
|
||
"playback_session_id", req.SessionID,
|
||
"subtitle_track_index", req.SubtitleTrackIndex,
|
||
"requested_target_codec_video", req.TargetCodecVideo,
|
||
"effective_target_codec_video", "h264",
|
||
)
|
||
req.TargetCodecVideo = "h264"
|
||
}
|
||
|
||
// A copy-video HLS output of a Dolby Vision Profile 7 source must strip the
|
||
// RPU metadata (the enhancement layer is dropped by stream mapping): raw P7
|
||
// NALs presented as plain HEVC stall hardware decoders. The V3 start path
|
||
// derives this from the plan (videoBitstreamFilterForPlanV3) and the
|
||
// audio-switch restart derives it from the durable session route, but this
|
||
// client-driven restart endpoint historically dropped it — the client asking
|
||
// for "copy" has no way to know the source needs the strip. Derived after
|
||
// the burn-in guard above so a copy request it rewrites to h264 never carries
|
||
// a copy-only bitstream filter.
|
||
//
|
||
// Gated on the per-source probe for the same reason the planner is: a
|
||
// source whose RPU ffmpeg cannot parse turns the filter into a per-packet
|
||
// rejection that never produces a segment, and this endpoint would
|
||
// otherwise put it back on every quality change, seek and burn-in restart
|
||
// of a session the planner had already routed away from it.
|
||
videoBitstreamFilter := ""
|
||
if strings.EqualFold(req.TargetCodecVideo, "copy") &&
|
||
(session.RemuxDVMode == playback.RemuxDVStripToHDR10V3 || file.PrimaryDVProfile() == 7) {
|
||
if playback.DVRPUStrippable(r.Context(), h.playbackConfig().FFmpegPath, file.FilePath) {
|
||
videoBitstreamFilter = playback.DV7ToHDR10BitstreamFilter
|
||
} else {
|
||
slog.WarnContext(r.Context(), "restart dropped the dolby vision rpu strip: source cannot be stripped",
|
||
"component", "api", "playback_session_id", req.SessionID, "file_id", file.ID)
|
||
}
|
||
}
|
||
|
||
// The request-level permission check above intentionally runs before the
|
||
// existing transcode is closed. Recheck when subtitle burn-in normalization
|
||
// has upgraded an allowed copy-video request into actual video encoding.
|
||
if !requiresVideoTranscode && !strings.EqualFold(req.TargetCodecVideo, "copy") &&
|
||
!h.ensureUserTranscodingAllowed(w, r, userID, true) {
|
||
return
|
||
}
|
||
|
||
// 4K transcode guard: if source is 4K and allow_4k_transcode is disabled,
|
||
// switch to an alternate non-4K file version for transcoding.
|
||
// Skip the guard when target_codec_video is "copy" — no actual video
|
||
// encoding happens, so the 4K cost concern doesn't apply.
|
||
var switchedFileID *int
|
||
videoCopy := strings.EqualFold(req.TargetCodecVideo, "copy")
|
||
if file.Resolution == "2160p" && h.SettingsRepo != nil && !videoCopy {
|
||
allow4K, _ := h.SettingsRepo.Get(r.Context(), "allow_4k_transcode")
|
||
if allow4K != "true" {
|
||
alt, altErr := h.findAlternateFile(r.Context(), file)
|
||
if altErr != nil || alt == nil {
|
||
writeError(w, http.StatusUnprocessableEntity, "no_alternate_version",
|
||
"No lower resolution version available for transcoding")
|
||
return
|
||
}
|
||
file = alt
|
||
file = h.ensurePlaybackProbe(r.Context(), file)
|
||
switchedFileID = &alt.ID
|
||
}
|
||
}
|
||
if !videoCopy {
|
||
req.TargetResolution = clampEncodedTargetResolution(req.TargetResolution, file.Resolution)
|
||
}
|
||
if requestedFile != nil && file != nil && requestedFile.ID != file.ID {
|
||
if err := preflightPlaybackFile(r.Context(), requestedFile, h.MissingMarker, h.EventsHub); err != nil && !isPlaybackFileMissing(err) {
|
||
slog.WarnContext(r.Context(), "requested transcode file preflight failed; continuing with alternate file", "component", "api",
|
||
"requested_file_id", requestedFile.ID,
|
||
"effective_file_id", file.ID,
|
||
"error", err,
|
||
)
|
||
}
|
||
}
|
||
if err := preflightPlaybackFile(r.Context(), file, h.MissingMarker, h.EventsHub); err != nil {
|
||
if isPlaybackFileMissing(err) {
|
||
abortCurrentSession("preflight_file", err)
|
||
}
|
||
writePlaybackFilePreflightError(w, err)
|
||
return
|
||
}
|
||
|
||
// Resolve the burn-in track's probed codec so the ffmpeg arg builder can
|
||
// route bitmap codecs (PGS/DVD/DVB) to the overlay filter_complex pipeline
|
||
// instead of the text-only libass subtitles filter. Derived server-side
|
||
// from the effective file rather than trusted from the client.
|
||
subtitleCodec := ""
|
||
if req.SubtitleBurnIn && req.SubtitleTrackIndex >= 0 {
|
||
resolvedIndex, resolvedCodec, ok := resolveBurnInSubtitle(subtitleSourceFile, file, req.SubtitleTrackIndex)
|
||
if !ok {
|
||
writeError(w, http.StatusUnprocessableEntity, "subtitle_unavailable_in_version",
|
||
"Selected subtitle track is unavailable in the effective file version")
|
||
return
|
||
}
|
||
if resolvedIndex != req.SubtitleTrackIndex {
|
||
slog.Info("remapped subtitle burn-in track for alternate file",
|
||
"playback_session_id", req.SessionID,
|
||
"subtitle_source_file_id", subtitleSourceFile.ID,
|
||
"effective_file_id", file.ID,
|
||
"requested_subtitle_track_index", req.SubtitleTrackIndex,
|
||
"effective_subtitle_track_index", resolvedIndex,
|
||
)
|
||
}
|
||
req.SubtitleTrackIndex = resolvedIndex
|
||
subtitleCodec = resolvedCodec
|
||
}
|
||
|
||
// A copy-video input seek starts at the demuxer's preceding keyframe. Keep
|
||
// the requested position as FFmpeg's -ss input, but resolve that keyframe
|
||
// before selecting local/offloaded transport so filenames, reconstruction,
|
||
// and the client timeline all describe the media that is actually emitted.
|
||
playbackCfg := h.playbackConfig()
|
||
transportSeekSeconds := alignedSeekSeconds(req.SeekSeconds, req.SegmentDuration, req.TargetCodecVideo)
|
||
startSegmentNumber := computeStartSegment(transportSeekSeconds, req.SegmentDuration)
|
||
streamOriginSeconds := 0.0
|
||
if videoCopy {
|
||
streamOriginSeconds = req.SeekSeconds
|
||
if req.SeekSeconds > 0 {
|
||
anchor, anchorSegment, anchorErr := h.resolveLegacyCopySeekAnchor(
|
||
r.Context(),
|
||
playbackCfg.FFmpegPath,
|
||
file.FilePath,
|
||
req.SeekSeconds,
|
||
req.SegmentDuration,
|
||
)
|
||
if anchorErr != nil {
|
||
slog.ErrorContext(r.Context(), "failed to resolve copy-video seek anchor", "component", "api",
|
||
"playback_session_id", req.SessionID,
|
||
"requested_seek_seconds", req.SeekSeconds,
|
||
"error", anchorErr,
|
||
)
|
||
writeError(w, http.StatusInternalServerError, "remux_seek_anchor_failed", "Failed to resolve remux seek position")
|
||
return
|
||
}
|
||
streamOriginSeconds = anchor
|
||
startSegmentNumber = anchorSegment
|
||
slog.DebugContext(r.Context(), "resolved copy-video seek anchor", "component", "api",
|
||
"playback_session_id", req.SessionID,
|
||
"requested_seek_seconds", req.SeekSeconds,
|
||
"stream_origin_seconds", streamOriginSeconds,
|
||
"start_segment_number", startSegmentNumber,
|
||
)
|
||
}
|
||
}
|
||
|
||
// Determine whether to run locally or forward to a remote transcode node.
|
||
var plan nodepool.Plan
|
||
if h.NodePlanner != nil {
|
||
estKbps := req.TargetBitrateKbps
|
||
if estKbps <= 0 {
|
||
estKbps = fileBitrateKbps(file)
|
||
}
|
||
plan = h.NodePlanner.PlanSession(req.SessionID, session.TranscodeNodeURL, true, estKbps)
|
||
}
|
||
tcNode := plan.TranscodeNode
|
||
|
||
if tcNode != nil {
|
||
// Remote transcode: forward to the assigned node.
|
||
replacementTransportID := req.SessionID
|
||
reconstructionTransportID := ""
|
||
atomicLegacyReplacement := videoCopy && isLegacyTransportSession(session)
|
||
if atomicLegacyReplacement {
|
||
replacementTransportID = newLegacyTransportID(req.SessionID)
|
||
reconstructionTransportID = replacementTransportID
|
||
}
|
||
nodeReq := transcodenode.TranscodeStartRequest{
|
||
SessionID: replacementTransportID,
|
||
LogicalSessionID: session.ID,
|
||
InputPath: file.FilePath,
|
||
SourceVideoCodec: file.CodecVideo,
|
||
VideoBitstreamFilter: videoBitstreamFilter,
|
||
SeekSeconds: transportSeekSeconds,
|
||
StreamOriginSeconds: streamOriginSeconds,
|
||
CopySeekAnchorResolved: videoCopy,
|
||
StartSegmentNumber: startSegmentNumber,
|
||
TargetResolution: req.TargetResolution,
|
||
TargetCodecVideo: req.TargetCodecVideo,
|
||
TargetCodecAudio: req.TargetCodecAudio,
|
||
TargetBitrateKbps: req.TargetBitrateKbps,
|
||
SegmentDuration: req.SegmentDuration,
|
||
HWAccel: playbackCfg.HWAccel,
|
||
AudioTrackIndex: session.AudioTrackIndex,
|
||
SubtitleTrackIndex: req.SubtitleTrackIndex,
|
||
SubtitleBurnIn: req.SubtitleBurnIn,
|
||
SubtitleCodec: subtitleCodec,
|
||
TotalDuration: float64(file.Duration),
|
||
RequireReady: atomicLegacyReplacement,
|
||
AuthUserID: session.UserID,
|
||
ProfileID: session.ProfileID,
|
||
MediaFileID: session.MediaFileID,
|
||
Route: session.Origin(),
|
||
ClientName: session.ClientName,
|
||
}
|
||
|
||
nodeResp, status, err := h.startRemotePlaybackTransport(context.Background(), tcNode.URL, nodeReq)
|
||
if err != nil {
|
||
if atomicLegacyReplacement {
|
||
h.tm.StopRemoteTranscode(replacementTransportID, tcNode.URL)
|
||
}
|
||
slog.ErrorContext(r.Context(), "remote transcode start failed", "component", "api", "error", err, "node", tcNode.URL, "session", req.SessionID, "playback_session_id", req.SessionID)
|
||
writeError(w, http.StatusBadGateway, "transcode_node_unavailable", "Transcode node is unavailable")
|
||
return
|
||
}
|
||
if status != http.StatusAccepted {
|
||
if atomicLegacyReplacement {
|
||
h.tm.StopRemoteTranscode(replacementTransportID, tcNode.URL)
|
||
}
|
||
slog.ErrorContext(r.Context(), "remote transcode start rejected", "component", "api", "status", status, "node", tcNode.URL)
|
||
writeError(w, http.StatusBadGateway, "transcode_start_failed", "Transcode node rejected the request")
|
||
return
|
||
}
|
||
effectiveHWAccel := effectiveRemoteHWAccel(nodeResp, nodeReq)
|
||
startState := transcodeStartState{
|
||
req: req,
|
||
file: file,
|
||
session: session,
|
||
switchedFileID: switchedFileID,
|
||
hwAccel: effectiveHWAccel,
|
||
}
|
||
successorRoute := playback.TranscodeRoute{
|
||
NodeURL: tcNode.URL,
|
||
TransportID: reconstructionTransportID,
|
||
}
|
||
if atomicLegacyReplacement {
|
||
if err := h.commitLegacyRemoteReplacement(
|
||
req.SessionID,
|
||
previousLocalTranscode,
|
||
previousRoute,
|
||
transcodeStartReplacement(startState, successorRoute),
|
||
); err != nil {
|
||
h.tm.StopRemoteTranscode(replacementTransportID, tcNode.URL)
|
||
if errors.Is(err, playback.ErrSessionSuperseded) {
|
||
writeError(w, http.StatusConflict, "transcode_replaced", "A newer playback transport replaced this request")
|
||
return
|
||
}
|
||
slog.ErrorContext(r.Context(), "publish legacy transcode route", "component", "api", "error", err, "session", req.SessionID, "playback_session_id", req.SessionID)
|
||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to publish transcode session")
|
||
return
|
||
}
|
||
if !h.transcodeRouteMatches(req.SessionID, nil, successorRoute) {
|
||
writeError(w, http.StatusConflict, "transcode_replaced", "A newer playback transport replaced this request")
|
||
return
|
||
}
|
||
logTranscodeStartState(r, startState)
|
||
h.syncSessionsNow(r.Context(), "transcode_start")
|
||
} else {
|
||
if err := h.commitLegacyRemoteLastWriter(
|
||
req.SessionID,
|
||
successorRoute,
|
||
transcodeStartReplacement(startState, successorRoute),
|
||
); err != nil {
|
||
slog.ErrorContext(r.Context(), "publish remote transcode route", "component", "api", "error", err, "session", req.SessionID, "playback_session_id", req.SessionID)
|
||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to publish transcode session")
|
||
return
|
||
}
|
||
logTranscodeStartState(r, startState)
|
||
h.syncSessionsNow(r.Context(), "transcode_start")
|
||
}
|
||
|
||
// The remote transcode's full recipe rides the proxy manifest token so the
|
||
// integrated server can re-bind and re-proxy the session after a restart
|
||
// (and a node could someday self-reconstruct from it). Node-side segment
|
||
// reconstruction is a follow-up (see spec multi-node section).
|
||
card := playback.NewRecipeCard(session.UserID, session.ProfileID, session.MediaFileID, tcNode.URL, playback.TranscodeOpts{
|
||
InputPath: nodeReq.InputPath,
|
||
SessionID: req.SessionID,
|
||
TranscodeTransportID: reconstructionTransportID,
|
||
SourceVideoCodec: nodeReq.SourceVideoCodec,
|
||
VideoBitstreamFilter: nodeReq.VideoBitstreamFilter,
|
||
SeekSeconds: nodeReq.SeekSeconds,
|
||
StreamOriginSeconds: nodeReq.StreamOriginSeconds,
|
||
CopySeekAnchorResolved: nodeReq.CopySeekAnchorResolved,
|
||
StartSegmentNumber: nodeReq.StartSegmentNumber,
|
||
TargetResolution: nodeReq.TargetResolution,
|
||
TargetCodecVideo: nodeReq.TargetCodecVideo,
|
||
TargetCodecAudio: nodeReq.TargetCodecAudio,
|
||
TargetBitrateKbps: nodeReq.TargetBitrateKbps,
|
||
SegmentDuration: nodeReq.SegmentDuration,
|
||
HWAccel: effectiveHWAccel,
|
||
AudioTrackIndex: nodeReq.AudioTrackIndex,
|
||
SubtitleTrackIndex: nodeReq.SubtitleTrackIndex,
|
||
SubtitleBurnIn: nodeReq.SubtitleBurnIn,
|
||
SubtitleCodec: nodeReq.SubtitleCodec,
|
||
TotalDuration: nodeReq.TotalDuration,
|
||
})
|
||
manifestURL := h.buildProxyManifestURL(card, plan.ProxyNode)
|
||
writeJSON(w, http.StatusAccepted, buildTranscodeStartResponse(req, file, switchedFileID, manifestURL, streamOriginSeconds))
|
||
return
|
||
}
|
||
|
||
// Local transcode (integrated mode — no transcode nodes available).
|
||
// In distributed mode admins can disable this fallback so the API server
|
||
// never transcodes when no eligible node exists.
|
||
if h.NodePlanner != nil && !nodepool.LocalTranscodeFallbackAllowed(r.Context(), h.SettingsRepo) {
|
||
writeError(w, http.StatusServiceUnavailable, "no_transcode_node",
|
||
"No transcode node is available and local transcode fallback is disabled")
|
||
return
|
||
}
|
||
// Snapshot once so the directory, ffmpeg path, and hwaccel of this
|
||
// session stay consistent even if the config reloads mid-start.
|
||
if err := os.MkdirAll(playbackCfg.TranscodeDir, 0o755); err != nil {
|
||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to prepare transcode directory")
|
||
return
|
||
}
|
||
|
||
localOpts := playback.TranscodeOpts{
|
||
InputPath: file.FilePath,
|
||
OutputDir: filepath.Join(playbackCfg.TranscodeDir, req.SessionID),
|
||
SessionID: req.SessionID,
|
||
SourceVideoCodec: file.CodecVideo,
|
||
VideoBitstreamFilter: videoBitstreamFilter,
|
||
SeekSeconds: transportSeekSeconds,
|
||
StreamOriginSeconds: streamOriginSeconds,
|
||
CopySeekAnchorResolved: videoCopy,
|
||
StartSegmentNumber: startSegmentNumber,
|
||
TargetResolution: req.TargetResolution,
|
||
TargetCodecVideo: req.TargetCodecVideo,
|
||
TargetCodecAudio: req.TargetCodecAudio,
|
||
TargetBitrateKbps: req.TargetBitrateKbps,
|
||
SegmentDuration: req.SegmentDuration,
|
||
FFmpegPath: playbackCfg.FFmpegPath,
|
||
HWAccel: playbackCfg.HWAccel,
|
||
HWDevice: playbackCfg.HWDevice,
|
||
AudioTrackIndex: session.AudioTrackIndex,
|
||
SubtitleTrackIndex: req.SubtitleTrackIndex,
|
||
SubtitleBurnIn: req.SubtitleBurnIn,
|
||
SubtitleCodec: subtitleCodec,
|
||
TotalDuration: float64(file.Duration),
|
||
FastStart: true,
|
||
NodeType: "integrated",
|
||
ExecutionMode: "integrated",
|
||
FFmpegLogSink: h.FFmpegLogSink,
|
||
}
|
||
startState := transcodeStartState{
|
||
req: req,
|
||
file: file,
|
||
session: session,
|
||
switchedFileID: switchedFileID,
|
||
}
|
||
localRoute := playback.TranscodeRoute{}
|
||
var transcodeSession *playback.TranscodeSession
|
||
if videoCopy {
|
||
// Copy-mode successors must coexist with the predecessor until readiness.
|
||
// A generation-scoped directory prevents two ffmpeg processes from writing
|
||
// the same manifest and segments during that overlap.
|
||
outputSubdir := newLegacyTransportID(req.SessionID)
|
||
localOpts.OutputSubdir = outputSubdir
|
||
localOpts.OutputDir = filepath.Join(playbackCfg.TranscodeDir, outputSubdir)
|
||
transcodeSession, err = h.commitLegacyLocalReplacement(
|
||
context.WithoutCancel(r.Context()),
|
||
req.SessionID,
|
||
previousLocalTranscode,
|
||
previousRoute,
|
||
localOpts,
|
||
func(successor *playback.TranscodeSession) playback.SessionReplacement {
|
||
startState.hwAccel = successor.Opts().HWAccel
|
||
return transcodeStartReplacement(startState, localRoute)
|
||
},
|
||
)
|
||
if err != nil {
|
||
if errors.Is(err, playback.ErrSessionSuperseded) {
|
||
writeError(w, http.StatusConflict, "transcode_replaced", "A newer playback transport replaced this request")
|
||
return
|
||
}
|
||
slog.ErrorContext(r.Context(), "prepare local copy transcode replacement", "component", "api", "error", err, "session", req.SessionID, "playback_session_id", req.SessionID)
|
||
writeError(w, http.StatusInternalServerError, "transcode_start_failed", "Failed to start transcode session")
|
||
return
|
||
}
|
||
if !h.transcodeRouteMatches(req.SessionID, transcodeSession, localRoute) {
|
||
writeError(w, http.StatusConflict, "transcode_replaced", "A newer playback transport replaced this request")
|
||
return
|
||
}
|
||
} else {
|
||
// Encoded legacy starts keep their historical serialized last-writer-wins
|
||
// behavior. Re-read the predecessor under the lifecycle lock instead of
|
||
// rejecting this request because another overlapping start finished first.
|
||
unlock := h.tm.LockSessionLifecycle(req.SessionID)
|
||
currentSession, currentErr := h.sessionMgr.GetSession(req.SessionID)
|
||
if currentErr != nil {
|
||
unlock()
|
||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to verify playback transport")
|
||
return
|
||
}
|
||
currentRoute := sessionTranscodeRoute(currentSession)
|
||
currentProcessID := remoteTransportID(currentSession)
|
||
h.tm.CloseTranscodeSession(req.SessionID, "")
|
||
transcodeSession, err = h.startLocalPlaybackTransport(r.Context(), localOpts)
|
||
if err != nil {
|
||
unlock()
|
||
writeError(w, http.StatusInternalServerError, "transcode_start_failed", "Failed to start transcode session")
|
||
return
|
||
}
|
||
h.tm.RegisterTranscodeSession(req.SessionID, transcodeSession)
|
||
startState.hwAccel = transcodeSession.Opts().HWAccel
|
||
if _, err := h.sessionMgr.ApplyReplacement(req.SessionID, transcodeStartReplacement(startState, localRoute)); err != nil {
|
||
h.tm.CloseTranscodeSessionIf(req.SessionID, transcodeSession, "")
|
||
unlock()
|
||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to publish transcode session")
|
||
return
|
||
}
|
||
unlock()
|
||
if currentRoute.NodeURL != "" {
|
||
h.tm.StopRemoteTranscode(currentProcessID, currentRoute.NodeURL)
|
||
}
|
||
}
|
||
logTranscodeStartState(r, startState)
|
||
|
||
// Re-arm the throttler and exit monitor after every Restart of this
|
||
// handler-created session, regardless of which code path triggers it
|
||
// (web segment recovery or an audio switch). Sessions created by
|
||
// jellycompat's own StartTranscode path never had throttler/exit-monitor
|
||
// wiring, so they are unaffected.
|
||
transcodeSession.SetRestartHook(func(ctx context.Context) {
|
||
h.maybeStartThrottler(ctx, transcodeSession)
|
||
h.tm.MonitorLocalTranscodeExit(req.SessionID, transcodeSession)
|
||
})
|
||
|
||
h.maybeStartThrottler(r.Context(), transcodeSession)
|
||
h.tm.MonitorLocalTranscodeExit(req.SessionID, transcodeSession)
|
||
|
||
// The full reconstruction recipe rides the manifest token so this local
|
||
// transcode can be rebuilt after a server restart (the client re-presents the
|
||
// token on its next manifest/segment request). The token is carried as a
|
||
// query parameter; the manifest rewriter propagates it onto every segment URI.
|
||
card := playback.NewRecipeCard(session.UserID, session.ProfileID, session.MediaFileID, "", transcodeSession.Opts())
|
||
manifestURL := appendStreamToken(
|
||
fmt.Sprintf("/playback/transcode/%s/master.m3u8", req.SessionID),
|
||
h.signSessionToken(card),
|
||
)
|
||
h.syncSessionsNow(r.Context(), "transcode_start")
|
||
writeJSON(w, http.StatusAccepted, buildTranscodeStartResponse(req, file, switchedFileID, manifestURL, streamOriginSeconds))
|
||
}
|
||
|
||
// HandleGetTranscodeManifest handles GET /playback/transcode/{session_id}/master.m3u8.
|
||
// Auth is optional — the session UUID serves as an access token (same pattern
|
||
// as /stream/{session_id}). When auth context is present, ownership is verified.
|
||
//
|
||
// Known-duration encoded sessions expose a synthetic full VOD manifest so the
|
||
// player can seek immediately. Copy-video sessions expose FFmpeg's real
|
||
// keyframe-aligned manifest and use the resolved stream origin returned by
|
||
// HandleStartTranscode.
|
||
func (h *PlaybackHandler) HandleGetTranscodeManifest(w http.ResponseWriter, r *http.Request) {
|
||
sessionID := chi.URLParam(r, "session_id")
|
||
session, status, card := h.loadTranscodeServeSession(r, sessionID)
|
||
switch status {
|
||
case playback.SessionMissing:
|
||
writePlaybackSessionNotFound(w)
|
||
return
|
||
case playback.SessionLoadFailed:
|
||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load playback session")
|
||
return
|
||
case playback.SessionForbidden:
|
||
writeError(w, http.StatusForbidden, "forbidden", "Session belongs to another user")
|
||
return
|
||
}
|
||
|
||
transcodeSession := h.tm.GetTranscodeSession(sessionID)
|
||
if transcodeSession == nil {
|
||
// No local session — try proxying to remote transcode node.
|
||
if session.TranscodeNodeURL != "" {
|
||
h.touchSessionActivity(sessionID)
|
||
h.proxyToTranscodeNode(w, r, session.TranscodeNodeURL,
|
||
"/transcode/"+remoteTransportID(session)+"/master.m3u8")
|
||
return
|
||
}
|
||
// Local transcode whose process state was lost: reconstruct it from the
|
||
// token recipe. The manifest path has no segment context, so pass -1 (use
|
||
// the token's seek position).
|
||
if card == nil {
|
||
writeError(w, http.StatusNotFound, "not_found", "Transcode session not found")
|
||
return
|
||
}
|
||
transcodeSession = h.tm.ReconstructTranscode(r.Context(), sessionID, -1, *card)
|
||
if transcodeSession == nil {
|
||
writeError(w, http.StatusNotFound, "not_found", "Transcode session not found")
|
||
return
|
||
}
|
||
}
|
||
h.touchSessionActivity(sessionID)
|
||
|
||
manifest, err := transcodeSession.BuildPlaybackManifest("segment/", r.URL.RawQuery)
|
||
if err != nil {
|
||
slog.ErrorContext(r.Context(), "build transcode manifest", "component", "api", "error", err, "session", sessionID, "playback_session_id", sessionID)
|
||
writeError(w, http.StatusServiceUnavailable, "unavailable", "Transcode manifest not ready")
|
||
return
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/vnd.apple.mpegurl")
|
||
w.Header().Set("Cache-Control", "no-store, max-age=0")
|
||
w.Header().Set("Pragma", "no-cache")
|
||
w.WriteHeader(http.StatusOK)
|
||
_, _ = w.Write(manifest)
|
||
}
|
||
|
||
// HandleGetTranscodeSegment handles GET /playback/transcode/{session_id}/segment/{name}.
|
||
// Auth is optional — the session UUID serves as an access token.
|
||
func (h *PlaybackHandler) HandleGetTranscodeSegment(w http.ResponseWriter, r *http.Request) {
|
||
sessionID := chi.URLParam(r, "session_id")
|
||
session, status, card := h.loadTranscodeServeSession(r, sessionID)
|
||
switch status {
|
||
case playback.SessionMissing:
|
||
writePlaybackSessionNotFound(w)
|
||
return
|
||
case playback.SessionLoadFailed:
|
||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load playback session")
|
||
return
|
||
case playback.SessionForbidden:
|
||
writeError(w, http.StatusForbidden, "forbidden", "Session belongs to another user")
|
||
return
|
||
}
|
||
|
||
transcodeSession := h.tm.GetTranscodeSession(sessionID)
|
||
if transcodeSession == nil {
|
||
if session.TranscodeNodeURL != "" {
|
||
h.touchSessionActivity(sessionID)
|
||
segmentName := chi.URLParam(r, "name")
|
||
h.proxyToTranscodeNode(w, r, session.TranscodeNodeURL,
|
||
"/transcode/"+remoteTransportID(session)+"/segment/"+segmentName)
|
||
return
|
||
}
|
||
// Resume near the segment the client is fetching so reconstruct does not
|
||
// restart from the original seek point and stall. A non-segment name
|
||
// (e.g. init.mp4) parses as negative and falls back to the token position.
|
||
requestedSegment := -1
|
||
if segNum, parseErr := playback.ParseSegmentNumber(chi.URLParam(r, "name")); parseErr == nil {
|
||
requestedSegment = segNum
|
||
}
|
||
if card == nil {
|
||
writeError(w, http.StatusNotFound, "not_found", "Transcode session not found")
|
||
return
|
||
}
|
||
transcodeSession = h.tm.ReconstructTranscode(r.Context(), sessionID, requestedSegment, *card)
|
||
if transcodeSession == nil {
|
||
writeError(w, http.StatusNotFound, "not_found", "Transcode session not found")
|
||
return
|
||
}
|
||
}
|
||
h.touchSessionActivity(sessionID)
|
||
|
||
segmentName := chi.URLParam(r, "name")
|
||
segmentPath, err := transcodeSession.GetSegment(segmentName)
|
||
if err != nil && errors.Is(err, playback.ErrSegmentNotFound) {
|
||
segNum, parseErr := playback.ParseSegmentNumber(segmentName)
|
||
if parseErr == nil {
|
||
now := time.Now()
|
||
decision := transcodeSession.SegmentRecoveryDecision(segNum, now)
|
||
lastProducedAgeMS := int64(-1)
|
||
if !decision.Progress.LastProducedAt.IsZero() {
|
||
lastProducedAgeMS = now.Sub(decision.Progress.LastProducedAt).Milliseconds()
|
||
}
|
||
slog.InfoContext(r.Context(), "transcode segment missing", "component", "api",
|
||
"segment", segmentName,
|
||
"requested_segment", segNum,
|
||
"produced_head", decision.Progress.ProducedHead,
|
||
"last_requested_segment", decision.Progress.LastRequestedSegment,
|
||
"start_segment_number", decision.Progress.StartSegmentNumber,
|
||
"last_produced_age_ms", lastProducedAgeMS,
|
||
"wait_timeout_ms", decision.WaitTimeout.Milliseconds(),
|
||
"restart_on_timeout", decision.RestartOnTimeout,
|
||
"reason", decision.Reason,
|
||
"session", sessionID,
|
||
"playback_session_id", sessionID,
|
||
)
|
||
if decision.Wait {
|
||
slog.InfoContext(r.Context(), "transcode segment wait", "component", "api",
|
||
"segment", segmentName,
|
||
"requested_segment", segNum,
|
||
"produced_head", decision.Progress.ProducedHead,
|
||
"last_requested_segment", decision.Progress.LastRequestedSegment,
|
||
"start_segment_number", decision.Progress.StartSegmentNumber,
|
||
"last_produced_age_ms", lastProducedAgeMS,
|
||
"wait_timeout_ms", decision.WaitTimeout.Milliseconds(),
|
||
"restart_on_timeout", decision.RestartOnTimeout,
|
||
"reason", decision.Reason,
|
||
"session", sessionID,
|
||
"playback_session_id", sessionID,
|
||
)
|
||
segmentPath, err = transcodeSession.WaitForSegment(segmentName, decision.WaitTimeout)
|
||
if err != nil && errors.Is(err, playback.ErrSegmentNotFound) {
|
||
slog.InfoContext(r.Context(), "transcode segment wait timeout", "component", "api",
|
||
"segment", segmentName,
|
||
"requested_segment", segNum,
|
||
"produced_head", decision.Progress.ProducedHead,
|
||
"last_requested_segment", decision.Progress.LastRequestedSegment,
|
||
"start_segment_number", decision.Progress.StartSegmentNumber,
|
||
"last_produced_age_ms", lastProducedAgeMS,
|
||
"wait_timeout_ms", decision.WaitTimeout.Milliseconds(),
|
||
"restart_on_timeout", decision.RestartOnTimeout,
|
||
"reason", decision.Reason,
|
||
"session", sessionID,
|
||
"playback_session_id", sessionID,
|
||
)
|
||
}
|
||
}
|
||
|
||
// If the segment is still missing (timed out, or outside the
|
||
// active encode range), either restart at the exact manifest-derived
|
||
// timeline position or return 404 for copy-mode segments outside the
|
||
// current manifest window.
|
||
if err != nil && errors.Is(err, playback.ErrSegmentNotFound) && decision.RestartOnTimeout {
|
||
seekSeconds, ok, seekErr := transcodeSession.RestartSeekTarget(segNum)
|
||
if seekErr != nil && !errors.Is(seekErr, playback.ErrManifestNotReady) {
|
||
slog.ErrorContext(r.Context(), "resolve transcode seek target", "component", "api", "error", seekErr, "segment", segmentName, "session", sessionID, "playback_session_id", sessionID)
|
||
}
|
||
|
||
// Copy-mode with an unresolved seek target (ok=false, no error)
|
||
// means the manifest can't place this segment yet. Don't restart
|
||
// at a fabricated position; surface ErrSegmentNotFound so the
|
||
// client retries while the session keeps producing manifest.
|
||
// Mirrors the transcode-node guard in
|
||
// internal/transcodenode/server.go.
|
||
if !ok && seekErr == nil && transcodeSession.IsCopyVideo() {
|
||
err = playback.ErrSegmentNotFound
|
||
}
|
||
|
||
if ok {
|
||
slog.InfoContext(r.Context(), "transcode seek restart", "component", "api",
|
||
"segment", segmentName,
|
||
"requested_segment", segNum,
|
||
"produced_head", decision.Progress.ProducedHead,
|
||
"last_requested_segment", decision.Progress.LastRequestedSegment,
|
||
"start_segment_number", decision.Progress.StartSegmentNumber,
|
||
"last_produced_age_ms", lastProducedAgeMS,
|
||
"wait_timeout_ms", decision.WaitTimeout.Milliseconds(),
|
||
"restart_on_timeout", decision.RestartOnTimeout,
|
||
"reason", decision.Reason,
|
||
"seek_seconds", seekSeconds,
|
||
"session", sessionID,
|
||
"playback_session_id", sessionID,
|
||
)
|
||
if restartErr := h.tm.RestartSessionLocked(
|
||
context.WithoutCancel(r.Context()),
|
||
sessionID,
|
||
transcodeSession,
|
||
seekSeconds,
|
||
segNum,
|
||
); restartErr == nil {
|
||
// Throttler + exit monitor re-arm via the session's
|
||
// restart hook.
|
||
segmentPath, err = transcodeSession.WaitForSegment(segmentName, 30*time.Second)
|
||
if err == nil && strings.EqualFold(transcodeSession.Opts().TargetCodecVideo, "copy") {
|
||
// Copy-mode seeks can resume as soon as the target segment
|
||
// exists, but that sometimes leaves the player one segment
|
||
// away from stalling while FFmpeg catches up. Briefly wait
|
||
// for a single lookahead fragment when available so the
|
||
// first resumed playback window is less brittle.
|
||
nextSegmentName := fmt.Sprintf("seg_%05d.m4s", segNum+1)
|
||
_, _ = transcodeSession.WaitForSegment(nextSegmentName, 1200*time.Millisecond)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} else if transcodeSession.IsRunning() {
|
||
// Non-numbered segment (e.g., init.mp4 for fMP4 HLS).
|
||
// Wait briefly — the init segment is written almost immediately.
|
||
segmentPath, err = transcodeSession.WaitForSegment(segmentName, 10*time.Second)
|
||
}
|
||
}
|
||
if err != nil {
|
||
if errors.Is(err, playback.ErrSegmentNotFound) {
|
||
writeError(w, http.StatusNotFound, "not_found", "Segment not found")
|
||
return
|
||
}
|
||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load segment")
|
||
return
|
||
}
|
||
|
||
// Report segment download for throttle tracking.
|
||
if segNum, parseErr := playback.ParseSegmentNumber(segmentName); parseErr == nil {
|
||
transcodeSession.ReportSegmentDownloaded(segNum)
|
||
}
|
||
|
||
w.Header().Set("Cache-Control", "no-store, max-age=0")
|
||
w.Header().Set("Pragma", "no-cache")
|
||
// Hold a transport marker for the whole segment pour so a slow client draining
|
||
// a single segment past the liveness grace cannot be reaped mid-serve and go
|
||
// invisible to the monitor — matching the direct-play/remux handlers. This is
|
||
// server-observed liveness: it keeps the session visible independent of any
|
||
// client progress report (the hidden-stream defense).
|
||
if err := h.sessionMgr.BeginTransport(sessionID); err == nil {
|
||
defer func() { _ = h.sessionMgr.EndTransport(sessionID) }()
|
||
} else {
|
||
// The marker is what keeps a slow single-segment drain visible; the
|
||
// segment is still served on failure, so log rather than fail — but log so
|
||
// the hidden-stream window is observable.
|
||
slog.Warn("begin transport marker failed", "session", sessionID, "segment", segmentPath, "error", err)
|
||
}
|
||
recorder, _ := h.sessionMgr.(playback.ServedBytesRecorder)
|
||
metered := playback.NewSessionMeteredWriter(w, recorder, sessionID)
|
||
defer func() { _ = metered.Close() }()
|
||
http.ServeFile(metered, r, segmentPath)
|
||
}
|
||
|
||
// buildProxyManifestURL signs a stream token carrying the session's full
|
||
// reconstruction recipe and builds the manifest URL. proxyNode is the planner's
|
||
// pick; when nil the URL falls back to the API-local path, where the token rides
|
||
// the ?st= query parameter so the integrated server can reconstruct from it.
|
||
func (h *PlaybackHandler) buildProxyManifestURL(card playback.RecipeCard, proxyNode *nodepool.Node) string {
|
||
token := h.signSessionToken(card)
|
||
localURL := fmt.Sprintf("/playback/transcode/%s/master.m3u8", card.SessionID)
|
||
if proxyNode == nil {
|
||
return appendStreamToken(localURL, token)
|
||
}
|
||
if token == "" {
|
||
return localURL
|
||
}
|
||
return proxyNode.URL + "/stream/transcode/" + token + "/master.m3u8"
|
||
}
|
||
|
||
// proxyToTranscodeNode forwards a request to the remote transcode node.
|
||
func (h *PlaybackHandler) proxyToTranscodeNode(w http.ResponseWriter, r *http.Request, transcodeNodeURL, path string) {
|
||
sessionID := chi.URLParam(r, "session_id")
|
||
targetURL := transcodeNodeURL + path
|
||
// Capture the signed stream token ("st") before stripping it from the URL.
|
||
// We forward it out-of-band as a header so the node can reconstruct after a
|
||
// self-restart, while keeping it out of the forwarded/logged URL.
|
||
stToken := r.URL.Query().Get("st")
|
||
// Strip the signed stream token ("st") before forwarding/logging: it is a
|
||
// 24h bearer reconstruction descriptor exposing media path + recipe claims.
|
||
// Other query params are preserved.
|
||
query := r.URL.Query()
|
||
query.Del("st")
|
||
if encoded := query.Encode(); encoded != "" {
|
||
targetURL += "?" + encoded
|
||
}
|
||
|
||
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, targetURL, nil)
|
||
if err != nil {
|
||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
req.Header.Set("Authorization", "Bearer "+h.JWTSecret)
|
||
// Best-effort forward of the stream token as a header so the node's
|
||
// reconstruct path (X-Silo-Stream-Token) can rebuild after a self-restart.
|
||
// Verify at the API boundary and confirm it belongs to this session; an
|
||
// invalid or missing token never blocks the live proxy. validToken is kept so
|
||
// the same verified token can be re-injected into the node's manifest segment
|
||
// URIs below.
|
||
var validToken string
|
||
if stToken != "" && h.JWTSecret != "" {
|
||
claims, verifyErr := streamtoken.Verify(stToken, h.JWTSecret)
|
||
if verifyErr == nil && claims.SessionID == sessionID {
|
||
req.Header.Set("X-Silo-Stream-Token", stToken)
|
||
validToken = stToken
|
||
} else if verifyErr != nil {
|
||
slog.WarnContext(r.Context(), "stream token not forwarded to transcode node", "component", "api", "error", verifyErr, "playback_session_id", sessionID)
|
||
}
|
||
}
|
||
|
||
resp, err := http.DefaultClient.Do(req)
|
||
if err != nil {
|
||
slog.ErrorContext(r.Context(), "proxy to transcode node", "component", "api", "error", err, "url", targetURL, "playback_session_id", sessionID)
|
||
http.Error(w, "transcode node unavailable", http.StatusBadGateway)
|
||
return
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
// The node strips "st" from the request query (kept out of node URLs/logs),
|
||
// so the segment/init URIs in the manifest it builds carry no token. Without
|
||
// it, a segment fetched after a node or API restart cannot reconstruct the
|
||
// session and 404s. Re-inject the client-facing token into every URI at this
|
||
// boundary so the client's later segment requests carry "st" again. Only the
|
||
// manifest body is rewritten; segments stream through untouched.
|
||
if validToken != "" && resp.StatusCode == http.StatusOK && strings.HasSuffix(path, ".m3u8") {
|
||
body, readErr := io.ReadAll(resp.Body)
|
||
if readErr != nil {
|
||
slog.ErrorContext(r.Context(), "read transcode node manifest", "component", "api", "error", readErr, "url", targetURL, "playback_session_id", sessionID)
|
||
http.Error(w, "transcode node unavailable", http.StatusBadGateway)
|
||
return
|
||
}
|
||
rewritten := playback.AppendManifestQueryParam(body, streamTokenParam, validToken)
|
||
for k, vv := range resp.Header {
|
||
if http.CanonicalHeaderKey(k) == "Content-Length" {
|
||
continue
|
||
}
|
||
for _, v := range vv {
|
||
w.Header().Add(k, v)
|
||
}
|
||
}
|
||
w.Header().Set("Content-Length", strconv.Itoa(len(rewritten)))
|
||
w.WriteHeader(resp.StatusCode)
|
||
_, _ = w.Write(rewritten)
|
||
return
|
||
}
|
||
|
||
for k, vv := range resp.Header {
|
||
for _, v := range vv {
|
||
w.Header().Add(k, v)
|
||
}
|
||
}
|
||
// Proxied transcode output can stream past the server's absolute
|
||
// WriteTimeout; roll the write deadline with progress instead.
|
||
dst := w
|
||
var metered *playback.SessionMeteredWriter
|
||
if !strings.HasSuffix(path, ".m3u8") {
|
||
if err := h.sessionMgr.BeginTransport(sessionID); err == nil {
|
||
defer func() { _ = h.sessionMgr.EndTransport(sessionID) }()
|
||
}
|
||
recorder, _ := h.sessionMgr.(playback.ServedBytesRecorder)
|
||
metered = playback.NewSessionMeteredWriter(w, recorder, sessionID)
|
||
defer func() { _ = metered.Close() }()
|
||
dst = metered
|
||
}
|
||
sw := httpstream.NewRollingDeadlineWriterCtx(r.Context(), dst)
|
||
sw.WriteHeader(resp.StatusCode)
|
||
if _, copyErr := io.Copy(sw, resp.Body); copyErr != nil {
|
||
slog.DebugContext(r.Context(), "copy transcode node response",
|
||
"component", "api",
|
||
"playback_session_id", sessionID,
|
||
"error", copyErr,
|
||
)
|
||
}
|
||
}
|
||
|
||
// maybeStartThrottler reads throttle settings and starts the throttler if enabled.
|
||
func (h *PlaybackHandler) maybeStartThrottler(ctx context.Context, session *playback.TranscodeSession) {
|
||
if h.SettingsRepo == nil {
|
||
return
|
||
}
|
||
enableThrottle, _ := h.SettingsRepo.Get(ctx, "enable_transcode_throttle")
|
||
if enableThrottle != "true" {
|
||
return
|
||
}
|
||
thresholdStr, _ := h.SettingsRepo.Get(ctx, "transcode_throttle_seconds")
|
||
threshold := 300 // default
|
||
if v, err := strconv.Atoi(thresholdStr); err == nil && v > 0 {
|
||
threshold = v
|
||
}
|
||
session.StartThrottler(threshold)
|
||
}
|
||
|
||
// findAlternateFile finds a non-4K file version for the same content.
|
||
// Prefers SDR over HDR, then highest resolution, then highest bitrate.
|
||
func (h *PlaybackHandler) findAlternateFile(ctx context.Context, source *models.MediaFile) (*models.MediaFile, error) {
|
||
if h.FileVersionFetcher == nil {
|
||
return nil, fmt.Errorf("file version fetcher not configured")
|
||
}
|
||
|
||
var files []*models.MediaFile
|
||
var err error
|
||
if source.EpisodeID != "" {
|
||
files, err = h.FileVersionFetcher.GetByEpisodeID(ctx, source.EpisodeID)
|
||
} else {
|
||
files, err = h.FileVersionFetcher.GetByContentID(ctx, source.ContentID)
|
||
}
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// Filter to non-4K candidates.
|
||
candidates := make([]*models.MediaFile, 0, len(files))
|
||
for _, f := range files {
|
||
if f.ID == source.ID || f.Resolution == "2160p" {
|
||
continue
|
||
}
|
||
if source.EditionKey != "" && f.EditionKey != source.EditionKey {
|
||
continue
|
||
}
|
||
if source.EditionKey == "" && f.EditionKey != "" {
|
||
continue
|
||
}
|
||
if source.PresentationGroupKey != "" && f.PresentationGroupKey != "" && f.PresentationGroupKey != source.PresentationGroupKey {
|
||
continue
|
||
}
|
||
if source.PresentationKind != "" && f.PresentationKind != "" && f.PresentationKind != source.PresentationKind {
|
||
continue
|
||
}
|
||
candidates = append(candidates, f)
|
||
}
|
||
if len(candidates) == 0 {
|
||
return nil, nil
|
||
}
|
||
|
||
// Sort: SDR before HDR, then highest resolution, then highest bitrate.
|
||
sort.Slice(candidates, func(i, j int) bool {
|
||
a, b := candidates[i], candidates[j]
|
||
// Prefer SDR over HDR (SDR = !HDR, so !HDR < HDR means SDR first).
|
||
if a.HDR != b.HDR {
|
||
return !a.HDR
|
||
}
|
||
aRes := resolutionRank(a.Resolution)
|
||
bRes := resolutionRank(b.Resolution)
|
||
if aRes != bRes {
|
||
return aRes > bRes
|
||
}
|
||
return a.Bitrate > b.Bitrate
|
||
})
|
||
|
||
return candidates[0], nil
|
||
}
|
||
|
||
const (
|
||
transcodeResolution2160p = "2160p"
|
||
transcodeResolution1080p = "1080p"
|
||
transcodeResolution720p = "720p"
|
||
transcodeResolution480p = "480p"
|
||
transcodeResolution420p = "420p"
|
||
transcodeResolution328p = "328p"
|
||
)
|
||
|
||
// resolutionRank returns a numeric rank for resolution sorting.
|
||
func resolutionRank(res string) int {
|
||
height, known := transcodeResolutionHeight(res)
|
||
if !known {
|
||
return 0
|
||
}
|
||
|
||
switch {
|
||
case height >= 2160:
|
||
return 4
|
||
case height >= 1080:
|
||
return 3
|
||
case height >= 720:
|
||
return 2
|
||
case height >= 480:
|
||
return 1
|
||
default:
|
||
return 0
|
||
}
|
||
}
|
||
|
||
func clampEncodedTargetResolution(requestedResolution, sourceResolution string) string {
|
||
requestedHeight, requestedKnown := transcodeResolutionHeight(requestedResolution)
|
||
sourceHeight, sourceKnown := transcodeResolutionHeight(sourceResolution)
|
||
if !requestedKnown || !sourceKnown || requestedHeight <= sourceHeight {
|
||
return requestedResolution
|
||
}
|
||
return sourceResolution
|
||
}
|
||
|
||
func transcodeResolutionHeight(resolution string) (int, bool) {
|
||
switch resolution {
|
||
case transcodeResolution2160p:
|
||
return 2160, true
|
||
case transcodeResolution1080p:
|
||
return 1080, true
|
||
case transcodeResolution720p:
|
||
return 720, true
|
||
case transcodeResolution480p:
|
||
return 480, true
|
||
case transcodeResolution420p:
|
||
return 420, true
|
||
case transcodeResolution328p:
|
||
return 328, true
|
||
default:
|
||
return 0, false
|
||
}
|
||
}
|