package jellycompat import ( "bufio" "context" "errors" "fmt" "io" "log/slog" "math" "net/http" "net/url" "os" "path" "path/filepath" "strconv" "strings" "time" "encoding/json" "github.com/go-chi/chi/v5" "github.com/Silo-Server/silo-server/internal/models" "github.com/Silo-Server/silo-server/internal/playback" "github.com/Silo-Server/silo-server/internal/subtitles" ) // Jellyfin Web is sensitive to startup latency. Use shorter compat segments // than the native global playback default so the first requested HLS chunk and // the near-head follow-up segments arrive quickly enough for browser playback. const compatSegmentDuration = 2 type sessionReportRequest struct { ItemID string `json:"ItemId"` MediaSourceID string `json:"MediaSourceId"` PlaySessionID string `json:"PlaySessionId"` PositionTicks int64 `json:"PositionTicks"` IsPaused bool `json:"IsPaused"` AudioStreamIndex *compatIntValue `json:"AudioStreamIndex,omitempty"` SubtitleStreamIndex *compatIntValue `json:"SubtitleStreamIndex,omitempty"` } // HandleVideoStream serves Jellyfin-style progressive stream URLs. func (h *PlaybackHandler) HandleVideoStream(w http.ResponseWriter, r *http.Request) { session := SessionFromContext(r.Context()) if session == nil { writeError(w, http.StatusUnauthorized, "Unauthorized", "Missing authentication token") return } routeID := chiURLParam(r, "id") mediaSourceID := firstNonEmpty(r.URL.Query().Get("mediaSourceId"), r.URL.Query().Get("MediaSourceId")) playSession, source, err := h.resolvePlaybackRoute(r, session, routeID, mediaSourceID) if err != nil && strings.EqualFold(r.URL.Query().Get("Static"), "true") { // Infuse uses Static=true for direct play without calling PlaybackInfo first. // Create an on-the-fly play session so the stream can proceed. playSession, source, err = h.createStaticPlaySession(r.Context(), session, routeID, mediaSourceID) } if err != nil { writeError(w, http.StatusNotFound, "NotFound", "Playback session not found") return } if source == nil { writeError(w, http.StatusBadRequest, "BadRequest", "Media source is required") return } method := "direct" if !source.SupportsDirectPlay { if source.SupportsDirectStream { method = "remux" } else { writeError(w, http.StatusBadRequest, "BadRequest", "Media source requires transcoding") return } } playSession, err = h.ensureUpstreamPlayback(r.Context(), session, playSession.ID, *source, method) if err != nil { writeCompatUpstreamError(w, err) return } if h.fileResolver == nil { writeError(w, http.StatusInternalServerError, "ServerError", "File resolver not available") return } file, err := h.fileResolver.GetByID(r.Context(), source.FileID) if err != nil { writeError(w, http.StatusNotFound, "NotFound", "Media file not found") return } seekSeconds := seekSecondsFromTicks(r.URL.Query().Get("StartTimeTicks")) if d := float64(source.Version.Duration); d > 0 && seekSeconds > d { seekSeconds = d } if redirectURL, redirectErr := h.buildProxyRedirectURL(playSession.ID, playSession.UpstreamSessionID, method, file, *source, "", seekSeconds); redirectErr == nil { http.Redirect(w, r, redirectURL, http.StatusTemporaryRedirect) return } switch method { case "remux": audioTrackIndex := -1 if resolvedAudioTrackIndex, ok := compatAudioTrackIndex(*source); ok { audioTrackIndex = resolvedAudioTrackIndex } _ = playback.ServeRemux(w, r, file.FilePath, "mp4", seekSeconds, source.TranscodeAudio, audioTrackIndex) default: _ = playback.ServeDirectPlay(w, r, file.FilePath) } } // HandleMasterManifest serves the compat-owned HLS manifest route. // It returns a full-duration VOD manifest so clients can seek to any position. // Segments that haven't been transcoded yet are served on-demand by the segment handler. func (h *PlaybackHandler) HandleMasterManifest(w http.ResponseWriter, r *http.Request) { session := SessionFromContext(r.Context()) if session == nil { writeError(w, http.StatusUnauthorized, "Unauthorized", "Missing authentication token") return } playSessionID := firstNonEmpty(r.URL.Query().Get("PlaySessionId"), r.URL.Query().Get("PlaySessionID")) if playSessionID == "" { writeError(w, http.StatusBadRequest, "BadRequest", "PlaySessionId is required") return } playSession, ok := h.playbackStore.Get(playSessionID) if !ok || playSession.CompatToken != session.Token { writeError(w, http.StatusNotFound, "NotFound", "Playback session not found") return } source := findMediaSource(playSession, firstNonEmpty(r.URL.Query().Get("MediaSourceId"), r.URL.Query().Get("mediaSourceId"))) if source == nil { writeError(w, http.StatusBadRequest, "BadRequest", "Media source is required") return } var err error if h.ProxyPool != nil && h.JWTSecret != "" && h.TranscodePool != nil { playSession, err = h.ensureUpstreamPlayback(r.Context(), session, playSession.ID, *source, "transcode") if err != nil { writeCompatUpstreamError(w, err) return } upstreamSession, upstreamErr := h.sessionMgr.GetSession(playSession.UpstreamSessionID) if upstreamErr == nil { if tcNode := h.pickTranscodeNode(upstreamSession.TranscodeNodeURL); tcNode != nil { if h.fileResolver == nil { writeError(w, http.StatusInternalServerError, "ServerError", "File resolver not available") return } file, fileErr := h.fileResolver.GetByID(r.Context(), source.FileID) if fileErr != nil { writeError(w, http.StatusNotFound, "NotFound", "Media file not found") return } if err := h.sessionMgr.SetTranscodeNodeURL(playSession.UpstreamSessionID, tcNode.URL); err != nil { writeError(w, http.StatusInternalServerError, "ServerError", "Failed to bind transcode node") return } if err := h.startRemoteTranscode(r.Context(), playSession.UpstreamSessionID, *source, file, playSession.InitialSeekSeconds, tcNode.URL); err != nil { writeError(w, http.StatusBadGateway, "TranscodeStartFailed", "Transcode node rejected the request") return } redirectURL, redirectErr := h.buildProxyRedirectURL(playSession.ID, playSession.UpstreamSessionID, string(playback.PlayTranscode), file, *source, tcNode.URL, 0) if redirectErr != nil { writeError(w, http.StatusInternalServerError, "ServerError", "Failed to sign proxy stream URL") return } http.Redirect(w, r, redirectURL, http.StatusTemporaryRedirect) return } } } // Ensure the transcode process is running. _, err = h.ensureTranscodeManifest(r.Context(), session, playSession.ID, *source) if err != nil { if errors.Is(err, playback.ErrManifestNotReady) { writeError(w, http.StatusServiceUnavailable, "NotReady", "Transcode playlist not ready") return } if errors.Is(err, playback.ErrTranscodeFailed) { writeError(w, http.StatusInternalServerError, "TranscodeFailed", "Transcode session failed") return } writeCompatUpstreamError(w, err) return } segDuration := h.compatSegmentDuration() manifest := generateFullManifest(source.Version.Duration, segDuration, source.TranscodeAudio, playSession.InitialSeekSeconds) w.Header().Set("Content-Type", "application/vnd.apple.mpegurl") w.WriteHeader(http.StatusOK) _, _ = w.Write(rewriteManifest(manifest, playSession.RouteItemID, playSession.ID, source.ID)) } // HandleHLSManifest serves the compat playlist route used after the master URL. func (h *PlaybackHandler) HandleHLSManifest(w http.ResponseWriter, r *http.Request) { session := SessionFromContext(r.Context()) if session == nil { writeError(w, http.StatusUnauthorized, "Unauthorized", "Missing authentication token") return } playSessionID := chiURLParam(r, "playlistId") playSession, ok := h.playbackStore.Get(playSessionID) if !ok || playSession.CompatToken != session.Token { writeError(w, http.StatusNotFound, "NotFound", "Playback session not found") return } source := firstMediaSource(playSession) if mediaSourceID := firstNonEmpty(r.URL.Query().Get("MediaSourceId"), r.URL.Query().Get("mediaSourceId")); mediaSourceID != "" { source = findMediaSource(playSession, mediaSourceID) } if source == nil { writeError(w, http.StatusBadRequest, "BadRequest", "Media source is required") return } // Ensure the transcode process is running. _, err := h.ensureTranscodeManifest(r.Context(), session, playSession.ID, *source) if err != nil { if errors.Is(err, playback.ErrManifestNotReady) { writeError(w, http.StatusServiceUnavailable, "NotReady", "Transcode playlist not ready") return } if errors.Is(err, playback.ErrTranscodeFailed) { writeError(w, http.StatusInternalServerError, "TranscodeFailed", "Transcode session failed") return } writeCompatUpstreamError(w, err) return } segDuration := h.compatSegmentDuration() manifest := generateFullManifest(source.Version.Duration, segDuration, source.TranscodeAudio, playSession.InitialSeekSeconds) w.Header().Set("Content-Type", "application/vnd.apple.mpegurl") w.WriteHeader(http.StatusOK) _, _ = w.Write(rewriteManifest(manifest, playSession.RouteItemID, playSession.ID, source.ID)) } // HandleHLSSegment proxies HLS segment requests through compat-owned routes. // If a segment doesn't exist yet (seek beyond transcoded range), it restarts // the transcode from the requested position and waits for the segment. func (h *PlaybackHandler) HandleHLSSegment(w http.ResponseWriter, r *http.Request) { session := SessionFromContext(r.Context()) if session == nil { writeError(w, http.StatusUnauthorized, "Unauthorized", "Missing authentication token") return } playSessionID := chiURLParam(r, "playlistId") playSession, ok := h.playbackStore.Get(playSessionID) if !ok || playSession.CompatToken != session.Token || playSession.UpstreamSessionID == "" { writeError(w, http.StatusNotFound, "NotFound", "Playback session not found") return } name := chiURLParam(r, "segmentId") ext := chiURLParam(r, "segmentContainer") upstreamSession, err := h.sessionMgr.GetSession(playSession.UpstreamSessionID) if err != nil { writeError(w, http.StatusNotFound, "NotFound", "Upstream session not found") return } _ = upstreamSession // session exists, serve the segment transcodeSession := h.getTranscodeSession(playSession.UpstreamSessionID) if transcodeSession == nil { writeError(w, http.StatusNotFound, "NotFound", "Transcode session not found") return } segmentFile := name + "." + ext segmentPath, err := transcodeSession.GetSegment(segmentFile) if err != nil && errors.Is(err, playback.ErrSegmentNotFound) { segNum, parseErr := playback.ParseSegmentNumber(name) 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.Info("transcode segment missing", "segment", segmentFile, "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(), "reason", decision.Reason, "play_session", playSessionID, "session", playSession.UpstreamSessionID, "playback_session_id", playSession.UpstreamSessionID, ) if decision.Wait { slog.Info("transcode segment wait", "segment", segmentFile, "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(), "reason", decision.Reason, "play_session", playSessionID, "session", playSession.UpstreamSessionID, "playback_session_id", playSession.UpstreamSessionID, ) segmentPath, err = transcodeSession.WaitForSegment(segmentFile, decision.WaitTimeout) if err != nil && errors.Is(err, playback.ErrSegmentNotFound) { slog.Info("transcode segment wait timeout", "segment", segmentFile, "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(), "reason", decision.Reason, "play_session", playSessionID, "session", playSession.UpstreamSessionID, "playback_session_id", playSession.UpstreamSessionID, ) } } if err != nil && errors.Is(err, playback.ErrSegmentNotFound) { seekSeconds, ok, seekErr := transcodeSession.RestartSeekTarget(segNum) if seekErr != nil && !errors.Is(seekErr, playback.ErrManifestNotReady) { slog.Error("resolve transcode seek target", "error", seekErr, "segment", segmentFile, "play_session", playSessionID, "session", playSession.UpstreamSessionID, "playback_session_id", playSession.UpstreamSessionID, ) } if ok { slog.Info("transcode seek restart", "segment", segmentFile, "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(), "reason", decision.Reason, "seek_seconds", seekSeconds, "play_session", playSessionID, "session", playSession.UpstreamSessionID, "playback_session_id", playSession.UpstreamSessionID, ) if restartErr := transcodeSession.Restart( context.WithoutCancel(r.Context()), seekSeconds, segNum, ); restartErr == nil { segmentPath, err = transcodeSession.WaitForSegment(segmentFile, 30*time.Second) } } } } 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(segmentFile, 10*time.Second) } } if err != nil { if errors.Is(err, playback.ErrSegmentNotFound) { writeError(w, http.StatusNotFound, "NotFound", "Segment not found") return } writeError(w, http.StatusInternalServerError, "ServerError", "Failed to load segment") return } if segNum, parseErr := playback.ParseSegmentNumber(name); parseErr == nil { transcodeSession.ReportSegmentDownloaded(segNum) } http.ServeFile(w, r, segmentPath) } // HandleSubtitleStream proxies subtitle requests through the native stream subtitle route. func (h *PlaybackHandler) HandleSubtitleStream(w http.ResponseWriter, r *http.Request) { session := SessionFromContext(r.Context()) if session == nil { writeError(w, http.StatusUnauthorized, "Unauthorized", "Missing authentication token") return } _, source, err := h.resolvePlaybackRoute(r, session, chiURLParam(r, "routeMediaSourceId"), chiURLParam(r, "routeMediaSourceId")) if err != nil || source == nil { writeError(w, http.StatusNotFound, "NotFound", "Playback session not found") return } if h.fileResolver == nil { writeError(w, http.StatusInternalServerError, "ServerError", "File resolver not available") return } file, err := h.fileResolver.GetByID(r.Context(), source.FileID) if err != nil { writeError(w, http.StatusNotFound, "NotFound", "Media file not found") return } routeIndex := chiURLParam(r, "routeIndex") trackIndex, parseErr := strconv.Atoi(routeIndex) if parseErr != nil { writeError(w, http.StatusBadRequest, "BadRequest", "Invalid subtitle index") return } requestedFormat := strings.ToLower(strings.TrimSpace(chiURLParam(r, "routeFormat"))) if requestedFormat == "" { requestedFormat = "vtt" } // Check for external subtitles first. for i, sub := range file.ExternalSubtitles { if externalSubtitleRouteIndex(file, i) == trackIndex { // Serve ASS/SSA as raw data when requested. if requestedFormat == "ass" && playback.IsASS(sub.Format) { data, readErr := os.ReadFile(sub.Path) if readErr != nil { writeError(w, http.StatusInternalServerError, "ServerError", "Failed to load subtitle") return } writeSubtitleResponse(w, "ass", data) return } if requestedFormat == "srt" && subtitleCanServeSRT(sub.Format) { data, readErr := os.ReadFile(sub.Path) if readErr != nil { writeError(w, http.StatusInternalServerError, "ServerError", "Failed to load subtitle") return } writeSubtitleResponse(w, requestedFormat, data) return } data, subErr := playback.LoadExternalSubtitleAsVTT(r.Context(), sub.Path, sub.Format) if subErr != nil { writeError(w, http.StatusInternalServerError, "ServerError", "Failed to load subtitle") return } writeSubtitleResponse(w, "vtt", data) return } } // Check downloaded subtitles (from S3). if h.SubtitleRepo != nil && h.S3Client != nil { downloaded, _ := h.SubtitleRepo.ListDownloadedSubtitles(r.Context(), file.ID) // Compute the base index for downloaded subtitles to match how PlaybackInfo assigns them. // Downloaded subs are indexed after all existing streams (last existing index + 1). baseIndex := computeDownloadedSubBaseIndex(file) downloadedIndex := trackIndex - baseIndex if downloadedIndex >= 0 && downloadedIndex < len(downloaded) { dl := downloaded[downloadedIndex] data, err := h.S3Client.GetObject(r.Context(), h.S3Bucket, dl.S3Key) if err != nil { writeError(w, http.StatusInternalServerError, "ServerError", "Failed to load subtitle from storage") return } // Serve downloaded ASS/SSA as raw data when requested. if requestedFormat == "ass" && playback.IsASS(string(dl.Format)) { writeSubtitleResponse(w, "ass", data) return } if requestedFormat == "srt" && subtitleCanServeSRT(string(dl.Format)) { writeSubtitleResponse(w, requestedFormat, data) return } // If already VTT, serve directly. if dl.Format == subtitles.FormatVTT { writeSubtitleResponse(w, "vtt", data) return } vttData, convErr := playback.ConvertToVTT(data, string(dl.Format)) if convErr != nil { writeError(w, http.StatusInternalServerError, "ServerError", "Failed to convert subtitle") return } writeSubtitleResponse(w, "vtt", vttData) return } } embeddedOrdinal, embeddedTrack := findEmbeddedSubtitle(file, trackIndex) if embeddedOrdinal < 0 { writeError(w, http.StatusNotFound, "NotFound", "Subtitle not found") return } if playback.NeedsBurnIn(embeddedTrack.Codec) { writeError(w, http.StatusBadRequest, "BadRequest", "Subtitle requires burn-in") return } // Serve ASS/SSA as raw ASS when requested, preserving styled subtitle data. if requestedFormat == "ass" && playback.IsASS(embeddedTrack.Codec) { data, err := playback.ExtractSubtitleWithFormat(r.Context(), file.FilePath, embeddedOrdinal, "ass", h.FFmpegPath) if err != nil { writeError(w, http.StatusInternalServerError, "ServerError", "Failed to extract subtitle") return } writeSubtitleResponse(w, "ass", data) return } data, format, subErr := playback.ExtractSubtitle(r.Context(), file.FilePath, embeddedOrdinal) if subErr != nil { writeError(w, http.StatusInternalServerError, "ServerError", "Failed to extract subtitle") return } if requestedFormat == "srt" && subtitleCanServeSRT(format) { writeSubtitleResponse(w, requestedFormat, data) return } vttData, convErr := playback.ConvertToVTT(data, format) if convErr != nil { writeError(w, http.StatusInternalServerError, "ServerError", "Failed to convert subtitle") return } writeSubtitleResponse(w, "vtt", vttData) } func findEmbeddedSubtitle(file *models.MediaFile, routeIndex int) (int, models.SubtitleTrack) { for i, track := range file.SubtitleTracks { if subtitleTrackRouteIndex(file, i, track) == routeIndex { return i, track } } return -1, models.SubtitleTrack{} } func subtitleTrackRouteIndex(file *models.MediaFile, ordinal int, track models.SubtitleTrack) int { if track.Index > 0 { return track.Index } return len(file.VideoTracks) + len(file.AudioTracks) + ordinal } func externalSubtitleRouteIndex(file *models.MediaFile, ordinal int) int { return len(file.VideoTracks) + len(file.AudioTracks) + len(file.SubtitleTracks) + ordinal } func subtitleCanServeSRT(format string) bool { switch strings.ToLower(strings.TrimSpace(format)) { case "srt", "subrip": return true default: return false } } func writeSubtitleResponse(w http.ResponseWriter, format string, data []byte) { switch strings.ToLower(strings.TrimSpace(format)) { case "ass", "ssa": w.Header().Set("Content-Type", "text/x-ssa; charset=utf-8") case "srt", "subrip": w.Header().Set("Content-Type", "application/x-subrip; charset=utf-8") default: w.Header().Set("Content-Type", "text/vtt; charset=utf-8") } w.WriteHeader(http.StatusOK) _, _ = w.Write(data) } // HandleSessionPlaying handles POST /Sessions/Playing. func (h *PlaybackHandler) HandleSessionPlaying(w http.ResponseWriter, r *http.Request) { h.handlePlaybackReport(w, r, false) } // HandleSessionPlayingProgress handles POST /Sessions/Playing/Progress. func (h *PlaybackHandler) HandleSessionPlayingProgress(w http.ResponseWriter, r *http.Request) { h.handlePlaybackReport(w, r, false) } // HandleSessionPlayingStopped handles POST /Sessions/Playing/Stopped. func (h *PlaybackHandler) HandleSessionPlayingStopped(w http.ResponseWriter, r *http.Request) { h.handlePlaybackReport(w, r, true) } func (h *PlaybackHandler) handlePlaybackReport(w http.ResponseWriter, r *http.Request, stop bool) { session := SessionFromContext(r.Context()) if session == nil { writeError(w, http.StatusUnauthorized, "Unauthorized", "Missing authentication token") return } var req sessionReportRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil && err != io.EOF { writeError(w, http.StatusBadRequest, "BadRequest", "Invalid session report") return } if req.PlaySessionID == "" { w.WriteHeader(http.StatusNoContent) return } playSession, ok := h.playbackStore.Get(req.PlaySessionID) if !ok || playSession.CompatToken != session.Token || playSession.UpstreamSessionID == "" { w.WriteHeader(http.StatusNoContent) return } positionSeconds := float64(req.PositionTicks) / 10_000_000 audioTrackIndex := 0 audioRestarted := false // Jellyfin web/mobile clients send AudioStreamIndex on every progress // report, not just on track changes. Restarting ffmpeg on each report // (every ~10s) tears down segments the player is still appending and // causes an hls.js retry loop. Only act when the index actually changes. if req.AudioStreamIndex != nil && audioSelectionChanged(playSession, req.MediaSourceID, int(*req.AudioStreamIndex)) { selectedAudioStreamIndex := int(*req.AudioStreamIndex) updatedPlaySession, updatedSource, updateErr := h.setSelectedAudioStream(req.PlaySessionID, req.MediaSourceID, selectedAudioStreamIndex) if updateErr == nil { playSession = updatedPlaySession if resolvedAudioTrackIndex, ok := compatAudioTrackIndex(*updatedSource); ok { audioTrackIndex = resolvedAudioTrackIndex } if syncErr := h.syncUpstreamAudioSelection(playSession, *updatedSource); syncErr != nil { slog.Warn("jellycompat audio selection sync failed", "play_session_id", req.PlaySessionID, "upstream_session_id", playSession.UpstreamSessionID, "error", syncErr, ) } restarted, restartErr := h.restartCompatTranscodeForAudioSelection(r.Context(), playSession, *updatedSource, positionSeconds) if restartErr != nil { slog.Warn("jellycompat audio selection restart failed", "play_session_id", req.PlaySessionID, "upstream_session_id", playSession.UpstreamSessionID, "error", restartErr, ) } audioRestarted = restarted slog.Info("jellycompat audio selection updated", "play_session_id", req.PlaySessionID, "media_source_id", updatedSource.ID, "audio_stream_index", selectedAudioStreamIndex, "audio_track_index", audioTrackIndex, "transcode_restarted", audioRestarted, ) } } if positionSeconds > 0 && h.sessionMgr != nil { _ = h.sessionMgr.UpdateProgress(playSession.UpstreamSessionID, positionSeconds, req.IsPaused) } // Persist progress to user store if positionSeconds > 0 && h.storeProvider != nil && playSession.ItemID != "" { if store, storeErr := h.storeProvider.ForUser(r.Context(), session.StreamAppUserID); storeErr == nil { // Find the duration from the media source var duration float64 for _, src := range playSession.MediaSources { if src.Version.Duration > 0 { duration = float64(src.Version.Duration) break } } if err := store.UpdateProgress(r.Context(), session.ProfileID, playSession.ItemID, positionSeconds, duration, h.playbackThresholds(r.Context())); err == nil { triggerProfileRefresh(r.Context(), h.profileStaler, h.profileRefreshRequester, session.StreamAppUserID, session.ProfileID) } } } if stop { transcodeNodeURL := "" if h.sessionMgr != nil { if upstreamSession, err := h.sessionMgr.GetSession(playSession.UpstreamSessionID); err == nil { transcodeNodeURL = upstreamSession.TranscodeNodeURL } } h.closeTranscodeSession(playSession.UpstreamSessionID, transcodeNodeURL) if h.sessionMgr != nil { _ = h.sessionMgr.StopSession(playSession.UpstreamSessionID) } h.playbackStore.Delete(playSession.ID) } w.WriteHeader(http.StatusNoContent) } func (h *PlaybackHandler) ensureUpstreamPlayback(ctx context.Context, compatSession *Session, playSessionID string, source PlaybackMediaSource, method string) (*PlaybackSession, error) { playSession, ok := h.playbackStore.Get(playSessionID) if !ok { return nil, ErrSessionNotFound } if playSession.UpstreamSessionID != "" && playSession.UpstreamPlayMethod == method { _ = h.syncUpstreamAudioSelection(playSession, source) return playSession, nil } if h.sessionMgr == nil { return nil, fmt.Errorf("session manager not available") } var playMethod playback.PlayMethod transcodeAudio := source.TranscodeAudio switch method { case "direct": playMethod = playback.PlayDirect transcodeAudio = false case "remux": playMethod = playback.PlayRemux case "transcode": playMethod = playback.PlayTranscode transcodeAudio = false default: playMethod = playback.PlayDirect transcodeAudio = false } if playSession.UpstreamSessionID != "" && playSession.UpstreamPlayMethod != "" && playSession.UpstreamPlayMethod != method { transcodeNodeURL := "" if current, err := h.sessionMgr.GetSession(playSession.UpstreamSessionID); err == nil { transcodeNodeURL = current.TranscodeNodeURL } _ = h.sessionMgr.StopSession(playSession.UpstreamSessionID) h.closeTranscodeSession(playSession.UpstreamSessionID, transcodeNodeURL) } var session *playback.Session var err error if starter, ok := h.sessionMgr.(sessionStarterContext); ok { session, err = starter.StartSessionWithContext(ctx, compatSession.StreamAppUserID, compatSession.ProfileID, source.FileID, playMethod, transcodeAudio) } else { session, err = h.sessionMgr.StartSession(compatSession.StreamAppUserID, compatSession.ProfileID, source.FileID, playMethod, transcodeAudio) } if err != nil { return nil, err } _ = h.syncUpstreamAudioSelection(&PlaybackSession{ UpstreamSessionID: session.ID, UpstreamPlayMethod: method, }, source) if err := h.playbackStore.Update(playSessionID, func(current *PlaybackSession) error { current.UpstreamSessionID = session.ID current.UpstreamPlayMethod = method current.TranscodeStarted = false return nil }); err != nil { return nil, err } updated, ok := h.playbackStore.Get(playSessionID) if !ok { return nil, ErrSessionNotFound } return updated, nil } func (h *PlaybackHandler) ensureTranscodeManifest(ctx context.Context, compatSession *Session, playSessionID string, source PlaybackMediaSource) ([]byte, error) { playSession, err := h.ensureUpstreamPlayback(ctx, compatSession, playSessionID, source, "transcode") if err != nil { return nil, err } transcodeSession, err := h.ensureTranscodeSession(ctx, playSessionID, playSession.UpstreamSessionID, source) if err != nil { return nil, err } // When duration is known, Jellycompat serves its own synthetic VOD manifest. // We only need ffmpeg running; waiting for startup segments here adds // unnecessary latency before the player can request the actual target segment. if source.Version.Duration > 0 { return nil, nil } // Poll for manifest readiness so clients that don't retry on 503 (e.g. MPV/Streamyfin) // can still start playback. Typically ready within a few seconds. const maxWait = 30 * time.Second const pollInterval = 250 * time.Millisecond deadline := time.After(maxWait) for { manifest, err := transcodeSession.GetManifest() if err == nil { return manifest, nil } if !errors.Is(err, playback.ErrManifestNotReady) { return nil, err } select { case <-ctx.Done(): return nil, ctx.Err() case <-deadline: return nil, playback.ErrManifestNotReady case <-time.After(pollInterval): } } } func (h *PlaybackHandler) ensureTranscodeSession(ctx context.Context, playSessionID, upstreamSessionID string, source PlaybackMediaSource) (*playback.TranscodeSession, error) { if existing := h.getTranscodeSession(upstreamSessionID); existing != nil { return existing, nil } if h.fileResolver == nil { return nil, fmt.Errorf("file resolver not available") } file, err := h.fileResolver.GetByID(ctx, source.FileID) if err != nil { return nil, fmt.Errorf("resolve file: %w", err) } if err := os.MkdirAll(h.TranscodeDir, 0o755); err != nil { return nil, fmt.Errorf("prepare transcode dir: %w", err) } h.transcodeMu.Lock() if existing := h.transcodes[upstreamSessionID]; existing != nil { h.transcodeMu.Unlock() return existing, nil } initialSeekSeconds := 0.0 startSegmentNumber := 0 if playSession, ok := h.playbackStore.Get(playSessionID); ok { initialSeekSeconds = playSession.InitialSeekSeconds segDuration := h.compatSegmentDuration() if initialSeekSeconds > 0 && segDuration > 0 { startSegmentNumber = int(initialSeekSeconds / float64(segDuration)) } } opts := playback.TranscodeOpts{ SessionID: upstreamSessionID, InputPath: file.FilePath, OutputDir: filepath.Join(h.TranscodeDir, upstreamSessionID), SeekSeconds: initialSeekSeconds, StartSegmentNumber: startSegmentNumber, TargetCodecVideo: "h264", TargetCodecAudio: "aac", FFmpegPath: h.FFmpegPath, HWAccel: h.HWAccel, AudioTrackIndex: compatAudioTrackIndexOrDefault(source), FastStart: true, } if source.TranscodeAudio { opts.TargetCodecVideo = "copy" } opts.SegmentDuration = h.compatSegmentDuration() transcodeSession, err := playback.StartTranscode(context.WithoutCancel(ctx), opts) if err != nil { h.transcodeMu.Unlock() return nil, err } h.transcodes[upstreamSessionID] = transcodeSession h.transcodeMu.Unlock() if err := h.playbackStore.Update(playSessionID, func(current *PlaybackSession) error { current.TranscodeStarted = true return nil }); err != nil { closeErr := transcodeSession.Close() h.transcodeMu.Lock() delete(h.transcodes, upstreamSessionID) h.transcodeMu.Unlock() if closeErr != nil { return nil, fmt.Errorf("update playback session: %w (cleanup: %v)", err, closeErr) } return nil, fmt.Errorf("update playback session: %w", err) } return transcodeSession, nil } // audioSelectionChanged reports whether an incoming AudioStreamIndex differs // from what the play session already records for the target media source. // Used to short-circuit progress reports that merely echo the current // selection — restarting ffmpeg for no-op updates causes segment churn and // stalls the client player. func audioSelectionChanged(session *PlaybackSession, mediaSourceID string, incomingStreamIndex int) bool { if session == nil || len(session.MediaSources) == 0 { return true } for _, source := range session.MediaSources { if mediaSourceID != "" && source.ID != mediaSourceID { continue } if source.SelectedAudioStreamIndex == nil { return true } return *source.SelectedAudioStreamIndex != incomingStreamIndex } // Unknown media source — fall back to the original behavior. return true } func (h *PlaybackHandler) setSelectedAudioStream(playSessionID, mediaSourceID string, audioStreamIndex int) (*PlaybackSession, *PlaybackMediaSource, error) { var updatedSource PlaybackMediaSource if err := h.playbackStore.Update(playSessionID, func(current *PlaybackSession) error { sourceIndex := 0 if mediaSourceID != "" { sourceIndex = -1 for index := range current.MediaSources { if current.MediaSources[index].ID == mediaSourceID { sourceIndex = index break } } } if sourceIndex < 0 || sourceIndex >= len(current.MediaSources) { return ErrSessionNotFound } if !isValidCompatAudioStreamIndex(current.MediaSources[sourceIndex].Version, audioStreamIndex) { return fmt.Errorf("invalid compat audio stream index") } current.MediaSources[sourceIndex].SelectedAudioStreamIndex = intPtr(audioStreamIndex) updatedSource = current.MediaSources[sourceIndex] return nil }); err != nil { return nil, nil, err } updatedPlaySession, ok := h.playbackStore.Get(playSessionID) if !ok { return nil, nil, ErrSessionNotFound } return updatedPlaySession, &updatedSource, nil } func (h *PlaybackHandler) syncUpstreamAudioSelection(playSession *PlaybackSession, source PlaybackMediaSource) error { if h.sessionMgr == nil || playSession == nil || playSession.UpstreamSessionID == "" { return nil } audioTrackIndex, ok := compatAudioTrackIndex(source) if !ok { return nil } return h.sessionMgr.UpdateAudioTrack( playSession.UpstreamSessionID, audioTrackIndex, compatPlayMethod(playSession.UpstreamPlayMethod), ) } func (h *PlaybackHandler) restartCompatTranscodeForAudioSelection( ctx context.Context, playSession *PlaybackSession, source PlaybackMediaSource, positionSeconds float64, ) (bool, error) { if playSession == nil || playSession.UpstreamSessionID == "" || playSession.UpstreamPlayMethod != "transcode" { return false, nil } audioTrackIndex, ok := compatAudioTrackIndex(source) if !ok { return false, nil } if transcodeSession := h.getTranscodeSession(playSession.UpstreamSessionID); transcodeSession != nil { transcodeSession.SetAudioTrackIndex(audioTrackIndex) startSegment := 0 if segmentDuration := transcodeSession.Opts().SegmentDuration; segmentDuration > 0 && positionSeconds > 0 { startSegment = int(positionSeconds / float64(segmentDuration)) } if err := transcodeSession.Restart(context.WithoutCancel(ctx), positionSeconds, startSegment); err != nil { return false, err } return true, nil } if h.sessionMgr == nil { return false, nil } upstreamSession, err := h.sessionMgr.GetSession(playSession.UpstreamSessionID) if err != nil { return false, err } if upstreamSession.TranscodeNodeURL == "" { return false, nil } if h.fileResolver == nil { return false, fmt.Errorf("file resolver not available") } file, err := h.fileResolver.GetByID(ctx, source.FileID) if err != nil { return false, err } if err := h.startRemoteTranscode(context.WithoutCancel(ctx), playSession.UpstreamSessionID, source, file, positionSeconds, upstreamSession.TranscodeNodeURL); err != nil { return false, err } return true, nil } func (h *PlaybackHandler) compatSegmentDuration() int { return compatSegmentDuration } // createStaticPlaySession builds an on-the-fly play session for Infuse-style // Static=true direct play requests that skip PlaybackInfo. func (h *PlaybackHandler) createStaticPlaySession(ctx context.Context, session *Session, routeID, mediaSourceID string) (*PlaybackSession, *PlaybackMediaSource, error) { contentID, err := decodeContentID(h.codec, routeID) if err != nil { return nil, nil, ErrSessionNotFound } detail, err := h.content.GetItemDetail(ctx, session, contentID, nil) if err != nil || detail == nil || len(detail.Versions) == 0 { return nil, nil, ErrSessionNotFound } playSessionID := h.codec.EncodeStringID(EncodedIDPlaySession, uuidNewString()) sources := make([]PlaybackMediaSource, 0, len(detail.Versions)) for _, version := range detail.Versions { source := h.buildPlaybackSource(routeID, playSessionID, version, DeviceProfile{}, playbackInfoRequest{}) sources = append(sources, source) } ps := &PlaybackSession{ ID: playSessionID, CompatToken: session.Token, ItemID: detail.ContentID, RouteItemID: routeID, UserID: session.PseudoUserID.String(), MediaSources: sources, } h.playbackStore.Put(*ps) var matched *PlaybackMediaSource if mediaSourceID != "" { matched = findMediaSource(ps, mediaSourceID) } if matched == nil { matched = firstMediaSource(ps) } return ps, matched, nil } func (h *PlaybackHandler) resolvePlaybackRoute(r *http.Request, compatSession *Session, routeID, mediaSourceID string) (*PlaybackSession, *PlaybackMediaSource, error) { if playSessionID := firstNonEmpty(r.URL.Query().Get("PlaySessionId"), r.URL.Query().Get("PlaySessionID")); playSessionID != "" { playSession, ok := h.playbackStore.Get(playSessionID) if !ok || playSession.CompatToken != compatSession.Token { return nil, nil, ErrSessionNotFound } if mediaSourceID != "" { source := findMediaSource(playSession, mediaSourceID) return playSession, source, nil } return playSession, firstMediaSource(playSession), nil } playSession, source, ok := h.playbackStore.FindByRoute(compatSession.Token, routeID) if !ok { return nil, nil, ErrSessionNotFound } if source == nil && mediaSourceID != "" { source = findMediaSource(playSession, mediaSourceID) } if source == nil { source = firstMediaSource(playSession) } return playSession, source, nil } func firstMediaSource(session *PlaybackSession) *PlaybackMediaSource { if session == nil || len(session.MediaSources) == 0 { return nil } source := session.MediaSources[0] return &source } func findMediaSource(session *PlaybackSession, mediaSourceID string) *PlaybackMediaSource { if session == nil { return nil } for _, source := range session.MediaSources { if source.ID == mediaSourceID { copy := source return © } } return nil } func compatPlayMethod(method string) playback.PlayMethod { switch strings.ToLower(strings.TrimSpace(method)) { case "remux": return playback.PlayRemux case "transcode": return playback.PlayTranscode default: return playback.PlayDirect } } func rewriteManifest(manifest []byte, routeItemID, playlistID, mediaSourceID string) []byte { var out strings.Builder scanner := bufio.NewScanner(strings.NewReader(string(manifest))) for scanner.Scan() { line := scanner.Text() switch { case strings.HasPrefix(line, "#EXT-X-MAP:URI=\""): prefix := "#EXT-X-MAP:URI=\"" uri := strings.TrimSuffix(strings.TrimPrefix(line, prefix), "\"") line = prefix + buildSegmentProxyPath(routeItemID, playlistID, mediaSourceID, uri) + "\"" case line != "" && !strings.HasPrefix(line, "#"): line = buildSegmentProxyPath(routeItemID, playlistID, mediaSourceID, line) } out.WriteString(line) out.WriteByte('\n') } return []byte(out.String()) } func buildSegmentProxyPath(routeItemID, playlistID, mediaSourceID, current string) string { base := path.Base(current) query := url.Values{} if parsed, err := url.Parse(current); err == nil { base = path.Base(parsed.Path) query = parsed.Query() } query.Set("PlaySessionId", playlistID) if mediaSourceID != "" { query.Set("MediaSourceId", mediaSourceID) } qs := "?" + query.Encode() if base == "stream.m3u8" { return fmt.Sprintf("/Videos/%s/hls/%s/stream.m3u8%s", routeItemID, playlistID, qs) } if strings.Contains(base, ".") { ext := path.Ext(base) name := strings.TrimSuffix(base, ext) return fmt.Sprintf("/Videos/%s/hls/%s/%s%s%s", routeItemID, playlistID, name, ext, qs) } return fmt.Sprintf("/Videos/%s/hls/%s/%s%s", routeItemID, playlistID, base, qs) } func copyProxyResponse(w http.ResponseWriter, resp *http.Response) { for key, values := range resp.Header { for _, value := range values { w.Header().Add(key, value) } } w.WriteHeader(resp.StatusCode) _, _ = io.Copy(w, resp.Body) } func chiURLParam(r *http.Request, key string) string { return chi.URLParam(r, key) } func (h *PlaybackHandler) getTranscodeSession(sessionID string) *playback.TranscodeSession { h.transcodeMu.RLock() defer h.transcodeMu.RUnlock() return h.transcodes[sessionID] } func (h *PlaybackHandler) closeTranscodeSession(sessionID, transcodeNodeURL string) { h.transcodeMu.Lock() session := h.transcodes[sessionID] delete(h.transcodes, sessionID) h.transcodeMu.Unlock() if session != nil { _ = session.Close() } if transcodeNodeURL != "" && h.JWTSecret != "" { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() req, err := http.NewRequestWithContext(ctx, http.MethodDelete, transcodeNodeURL+"/transcode/"+sessionID, nil) if err == nil { req.Header.Set("Authorization", "Bearer "+h.JWTSecret) if resp, doErr := http.DefaultClient.Do(req); doErr == nil { resp.Body.Close() } } } } func seekSecondsFromTicks(seekStr string) float64 { if seekStr == "" { return 0 } ticks, err := strconv.ParseInt(seekStr, 10, 64) if err != nil { return 0 } return float64(ticks) / 10_000_000 } // computeDownloadedSubBaseIndex returns the first index available for downloaded subtitles. // This mirrors how buildMediaStreams assigns indices in handlers_playback.go: // video tracks → audio tracks → subtitle tracks (using ffprobe index or positional index). func computeDownloadedSubBaseIndex(file *models.MediaFile) int { maxIndex := -1 // Check video tracks — indexed positionally starting at 0. for i := range file.VideoTracks { if i > maxIndex { maxIndex = i } } // Check audio tracks — indexed after video tracks. for i := range file.AudioTracks { idx := len(file.VideoTracks) + i if idx > maxIndex { maxIndex = idx } } // Check embedded subtitle tracks — they may use ffprobe indices (track.Index) // which can be non-sequential. Fall back to positional when Index is 0. for i, track := range file.SubtitleTracks { var idx int if track.Index > 0 { idx = track.Index } else { idx = len(file.VideoTracks) + len(file.AudioTracks) + i } if idx > maxIndex { maxIndex = idx } } // Check external subtitles — indexed after all embedded subtitle entries, // mirroring buildVersionSubtitleTracks + subtitleTrackIndex in PlaybackInfo. for i := range file.ExternalSubtitles { idx := externalSubtitleRouteIndex(file, i) if idx > maxIndex { maxIndex = idx } } return maxIndex + 1 } // generateFullManifest builds a complete VOD-style HLS manifest covering the // entire video duration. This allows clients to seek to any position even // though segments may not have been transcoded yet. // // When startTimeOffsetSeconds > 0 (resume), the playlist still lists every // segment but emits #EXT-X-START:TIME-OFFSET so the player begins playback at // the resume position. Trimming the playlist to seg_K..seg_(N-1) instead would // confuse clients that apply their own initial seek (Jellyfin Android TV's // ExoPlayer): playlist-time and source-time would diverge, and seekTo(K*segDur) // would land on seg_2K. The full-playlist + START tag form keeps the two // timelines aligned for every client. func generateFullManifest(durationSeconds, segDuration int, fmp4 bool, startTimeOffsetSeconds float64) []byte { if durationSeconds <= 0 { durationSeconds = 1 } if segDuration <= 0 { segDuration = compatSegmentDuration } numSegments := int(math.Ceil(float64(durationSeconds) / float64(segDuration))) if startTimeOffsetSeconds < 0 || startTimeOffsetSeconds >= float64(durationSeconds) { startTimeOffsetSeconds = 0 } var b strings.Builder b.WriteString("#EXTM3U\n") // EXT-X-START is a HLS protocol-version-6 tag, so a TS playlist that // emits it must advertise at least version 6 or strict clients can // reject the playlist — defeating the very resume case this code path // is for. fmp4 already requires version 7. hlsVersion := 3 switch { case fmp4: hlsVersion = 7 case startTimeOffsetSeconds > 0: hlsVersion = 6 } b.WriteString(fmt.Sprintf("#EXT-X-VERSION:%d\n", hlsVersion)) b.WriteString(fmt.Sprintf("#EXT-X-TARGETDURATION:%d\n", segDuration)) b.WriteString("#EXT-X-MEDIA-SEQUENCE:0\n") b.WriteString("#EXT-X-PLAYLIST-TYPE:VOD\n") if startTimeOffsetSeconds > 0 { b.WriteString(fmt.Sprintf("#EXT-X-START:TIME-OFFSET=%.6f,PRECISE=YES\n", startTimeOffsetSeconds)) } if fmp4 { b.WriteString("#EXT-X-MAP:URI=\"init.mp4\"\n") } remaining := float64(durationSeconds) for i := range numSegments { segLen := math.Min(float64(segDuration), remaining) b.WriteString(fmt.Sprintf("#EXTINF:%.6f,\n", segLen)) if fmp4 { b.WriteString(fmt.Sprintf("seg_%05d.m4s\n", i)) } else { b.WriteString(fmt.Sprintf("seg_%05d.ts\n", i)) } remaining -= segLen } b.WriteString("#EXT-X-ENDLIST\n") return []byte(b.String()) }