1311 lines
45 KiB
Go
1311 lines
45 KiB
Go
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())
|
|
}
|