1828 lines
64 KiB
Go
1828 lines
64 KiB
Go
package jellycompat
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha1"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/access"
|
|
"github.com/Silo-Server/silo-server/internal/catalog"
|
|
"github.com/Silo-Server/silo-server/internal/config"
|
|
"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/watchsync"
|
|
)
|
|
|
|
type playbackInfoRequest struct {
|
|
UserID string `json:"UserId"`
|
|
MediaSourceID string `json:"MediaSourceId"`
|
|
AudioStreamIndex *compatIntValue `json:"AudioStreamIndex,omitempty"`
|
|
SubtitleStreamIndex *compatIntValue `json:"SubtitleStreamIndex,omitempty"`
|
|
StartTimeTicks int64 `json:"StartTimeTicks"`
|
|
EnableDirectPlay *bool `json:"EnableDirectPlay"`
|
|
EnableDirectStream *bool `json:"EnableDirectStream"`
|
|
EnableTranscoding *bool `json:"EnableTranscoding"`
|
|
AllowVideoStreamCopy *bool `json:"AllowVideoStreamCopy"`
|
|
AllowAudioStreamCopy *bool `json:"AllowAudioStreamCopy"`
|
|
DeviceProfile json.RawMessage `json:"DeviceProfile"`
|
|
}
|
|
|
|
var compatLanguageNames = map[string]string{
|
|
"ar": "Arabic", "ara": "Arabic",
|
|
"bg": "Bulgarian", "bul": "Bulgarian",
|
|
"bn": "Bengali", "ben": "Bengali",
|
|
"cs": "Czech", "ces": "Czech", "cze": "Czech",
|
|
"da": "Danish", "dan": "Danish",
|
|
"de": "German", "deu": "German", "ger": "German",
|
|
"el": "Greek", "ell": "Greek", "gre": "Greek",
|
|
"en": "English", "eng": "English",
|
|
"es": "Spanish", "spa": "Spanish",
|
|
"fa": "Persian", "fas": "Persian", "per": "Persian",
|
|
"fi": "Finnish", "fin": "Finnish",
|
|
"fr": "French", "fra": "French", "fre": "French",
|
|
"he": "Hebrew", "heb": "Hebrew",
|
|
"hi": "Hindi", "hin": "Hindi",
|
|
"hr": "Croatian", "hrv": "Croatian",
|
|
"hu": "Hungarian", "hun": "Hungarian",
|
|
"id": "Indonesian", "ind": "Indonesian",
|
|
"it": "Italian", "ita": "Italian",
|
|
"ja": "Japanese", "jpn": "Japanese",
|
|
"ko": "Korean", "kor": "Korean",
|
|
"ms": "Malay", "may": "Malay", "msa": "Malay",
|
|
"nl": "Dutch", "dut": "Dutch", "nld": "Dutch",
|
|
"no": "Norwegian", "nor": "Norwegian",
|
|
"pl": "Polish", "pol": "Polish",
|
|
"pt": "Portuguese", "por": "Portuguese",
|
|
"ro": "Romanian", "ron": "Romanian", "rum": "Romanian",
|
|
"ru": "Russian", "rus": "Russian",
|
|
"sk": "Slovak", "slk": "Slovak", "slo": "Slovak",
|
|
"sl": "Slovenian", "slv": "Slovenian",
|
|
"sv": "Swedish", "swe": "Swedish",
|
|
"ta": "Tamil", "tam": "Tamil",
|
|
"te": "Telugu", "tel": "Telugu",
|
|
"th": "Thai", "tha": "Thai",
|
|
"tr": "Turkish", "tur": "Turkish",
|
|
"uk": "Ukrainian", "ukr": "Ukrainian",
|
|
"vi": "Vietnamese", "vie": "Vietnamese",
|
|
"zh": "Chinese", "chi": "Chinese", "zho": "Chinese",
|
|
}
|
|
|
|
// SessionManagerInterface matches the playback session manager's API.
|
|
type SessionManagerInterface interface {
|
|
StartSession(userID int, profileID string, fileID 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
|
|
StopSession(sessionID string) error
|
|
GetSession(sessionID string) (*playback.Session, error)
|
|
SetTranscodeNodeURL(sessionID, url string) error
|
|
BeginTransport(sessionID string) error
|
|
EndTransport(sessionID string) error
|
|
}
|
|
|
|
type sessionStarterContext interface {
|
|
StartSessionWithContext(ctx context.Context, userID int, profileID string, fileID int, method playback.PlayMethod, transcodeAudio bool) (*playback.Session, error)
|
|
}
|
|
|
|
// transcodeStreamDetailsSetter is implemented by the native SessionManager.
|
|
// Optional (like sessionStarterContext) so lightweight test fakes don't have
|
|
// to; without it the session keeps transport-level defaults only.
|
|
type transcodeStreamDetailsSetter interface {
|
|
SetTranscodeStreamDetails(sessionID, targetVideoCodec, targetAudioCodec string, transcodeAudio bool) error
|
|
}
|
|
|
|
// recordTranscodeStreamDetails mirrors the encode decisions of a started
|
|
// transcode onto the upstream session. StartSession only records the transport
|
|
// method (play_method "transcode", transcodeAudio false), so without this an
|
|
// audio-only re-encode — video copied — syncs to session sync and the admin
|
|
// activity views as a full video transcode. Shared by the local
|
|
// (ensureTranscodeSession) and remote (startRemoteTranscode) paths.
|
|
func (h *PlaybackHandler) recordTranscodeStreamDetails(ctx context.Context, upstreamSessionID string, opts playback.TranscodeOpts) {
|
|
setter, ok := h.sessionMgr.(transcodeStreamDetailsSetter)
|
|
if !ok {
|
|
return
|
|
}
|
|
transcodeAudio := playback.TranscodesAudio(opts.TargetCodecAudio)
|
|
if err := setter.SetTranscodeStreamDetails(upstreamSessionID, opts.TargetCodecVideo, opts.TargetCodecAudio, transcodeAudio); err != nil {
|
|
slog.WarnContext(ctx, "record transcode stream details failed", "component", "jellycompat",
|
|
"error", err, "playback_session_id", upstreamSessionID)
|
|
return
|
|
}
|
|
// The "compat_start" sync inside ensureUpstreamPlayback already flushed
|
|
// this session with transport-level defaults, so flush again now that the
|
|
// real encode decisions are on it — otherwise the admin view shows a
|
|
// video-copy stream as a full video transcode until the next reconciler
|
|
// tick.
|
|
h.syncSessionsNow(ctx, "compat_transcode_details")
|
|
}
|
|
|
|
// FilePathResolver looks up media files by ID.
|
|
type FilePathResolver interface {
|
|
GetByID(ctx context.Context, id int) (*models.MediaFile, error)
|
|
}
|
|
|
|
// SettingsReader reads server settings by key.
|
|
type SettingsReader interface {
|
|
Get(ctx context.Context, key string) (string, error)
|
|
}
|
|
|
|
// PlaybackSessionSyncer flushes the in-memory native-session snapshot into the
|
|
// shared admin live-session table (playback_sessions_sync). Without it, compat
|
|
// session starts and stops only become visible on the periodic reconciler
|
|
// tick, leaving ghost rows in the activity dashboard for several seconds.
|
|
type PlaybackSessionSyncer interface {
|
|
SyncNow(ctx context.Context) error
|
|
}
|
|
|
|
// PlaybackWatchScrobbler forwards a playback lifecycle to connected watch
|
|
// providers. The watchsync service implements this interface.
|
|
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
|
|
}
|
|
|
|
// PlaybackWatchStopConfirmer is implemented by watch-sync services that can
|
|
// wait for a terminal stop to be accepted by every provider. Jellycompat uses
|
|
// it before deleting its durable terminal record.
|
|
type PlaybackWatchStopConfirmer interface {
|
|
ScrobbleStopConfirmed(ctx context.Context, event watchsync.ScrobbleEvent) error
|
|
}
|
|
|
|
// PlaybackHandler serves Jellyfin playback negotiation endpoints.
|
|
type PlaybackHandler struct {
|
|
cfg *config.Config
|
|
content ContentService
|
|
codec *ResourceIDCodec
|
|
deviceProfiles *DeviceProfileStore
|
|
playbackStore CompatPlaybackStore
|
|
sessionMgr SessionManagerInterface
|
|
fileResolver FilePathResolver
|
|
storeProvider userstore.UserStoreProvider
|
|
NodePlanner nodepool.SessionPlanner
|
|
JWTSecret string
|
|
profileStaler profileStaler
|
|
profileRefreshRequester profileRefreshRequester
|
|
FFmpegPath string
|
|
HWAccel string
|
|
TranscodeDir string
|
|
SegmentRetentionSeconds func() int
|
|
// tm is the shared transcode-session lifecycle (live map, reconstruct) — the
|
|
// same type the native handler uses, so jellycompat gets the reconstruct cap
|
|
// and node-affinity rule for free. The reconstruction recipe is carried in the
|
|
// compat playback store (PlaybackSession.Recipe), since Jellyfin clients cannot
|
|
// round-trip a native stream token.
|
|
tm *playback.TranscodeManager
|
|
SubtitleRepo subtitles.Repository // optional; enables downloaded subtitles
|
|
S3Client subtitles.S3Client // optional; for serving S3 subtitles
|
|
S3Bucket string // bucket for subtitle storage
|
|
SettingsRepo SettingsReader // optional; reads watched threshold setting
|
|
SessionSyncer PlaybackSessionSyncer // optional; enables immediate session sync to shared admin view
|
|
WatchScrobbler PlaybackWatchScrobbler
|
|
StableIdentityResolver watchsync.ScrobbleIdentityResolver
|
|
terminalFallbackDelay time.Duration
|
|
// RecipeNodeStore hands a remote transcode's reconstruction recipe to the
|
|
// control-plane recipe store (Redis) so a dedicated transcode node that
|
|
// restarts can rebuild ffmpeg from it. The node-hop token is server-minted and
|
|
// could carry the recipe, but it is mutated in place and the client can't be
|
|
// driven to refresh a stale token, so the node reconstructs from this
|
|
// server-authoritative store instead (see internal/noderecipe). Optional
|
|
// (nil disables it — integrated/no-node deployments need no handoff).
|
|
RecipeNodeStore recipeNodePutter
|
|
}
|
|
|
|
// recipeNodePutter persists and removes a remote transcode's reconstruction
|
|
// recipe in a control-plane store keyed by upstream session id. *noderecipe.Store
|
|
// implements it. Delete is nil-safe and treats a missing key as a no-op success;
|
|
// it is called on deliberate teardown so a stopped session cannot be resurrected
|
|
// from a leaked recipe.
|
|
type recipeNodePutter interface {
|
|
Put(ctx context.Context, sessionID string, card playback.RecipeCard) error
|
|
Delete(ctx context.Context, sessionID string) error
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
var errTranscode4KDisallowed = errors.New("4k video transcode disallowed by server settings")
|
|
|
|
// allow4KVideoTranscode reads the allow_4k_transcode server setting,
|
|
// defaulting to deny like the native playback handler.
|
|
func (h *PlaybackHandler) allow4KVideoTranscode(ctx context.Context) bool {
|
|
if h.SettingsRepo == nil {
|
|
return false
|
|
}
|
|
v, _ := h.SettingsRepo.Get(ctx, "allow_4k_transcode")
|
|
return v == "true"
|
|
}
|
|
|
|
func is4KResolution(res string) bool {
|
|
return access.CompareQuality(res, "2160p") >= 0
|
|
}
|
|
|
|
// NewPlaybackHandler creates a playback handler.
|
|
func NewPlaybackHandler(
|
|
cfg *config.Config,
|
|
content ContentService,
|
|
codec *ResourceIDCodec,
|
|
deviceProfiles *DeviceProfileStore,
|
|
playbackStore CompatPlaybackStore,
|
|
sessionMgr SessionManagerInterface,
|
|
fileResolver FilePathResolver,
|
|
storeProvider userstore.UserStoreProvider,
|
|
) *PlaybackHandler {
|
|
transcodeDir := filepath.Join(os.TempDir(), "silo-transcode")
|
|
ffmpegPath := ""
|
|
hwAccel := ""
|
|
segmentRetentionSeconds := 600
|
|
if cfg != nil {
|
|
if cfg.Playback.TranscodeDir != "" {
|
|
transcodeDir = cfg.Playback.TranscodeDir
|
|
}
|
|
ffmpegPath = cfg.Playback.FFmpegPath
|
|
hwAccel = cfg.Playback.HWAccel
|
|
segmentRetentionSeconds = cfg.Playback.SegmentRetentionSeconds
|
|
}
|
|
|
|
h := &PlaybackHandler{
|
|
cfg: cfg,
|
|
content: content,
|
|
codec: codec,
|
|
deviceProfiles: deviceProfiles,
|
|
playbackStore: playbackStore,
|
|
sessionMgr: sessionMgr,
|
|
fileResolver: fileResolver,
|
|
storeProvider: storeProvider,
|
|
FFmpegPath: ffmpegPath,
|
|
HWAccel: hwAccel,
|
|
TranscodeDir: transcodeDir,
|
|
SegmentRetentionSeconds: func() int { return segmentRetentionSeconds },
|
|
tm: playback.NewTranscodeManager(),
|
|
}
|
|
// Wire the shared transcode manager with closures so it reads the handler's
|
|
// (late-set) JWTSecret lazily, matching the native handler.
|
|
h.tm.JWTSecretFn = func() string { return h.JWTSecret }
|
|
h.tm.Config = func() playback.TranscodeRuntimeConfig {
|
|
return playback.TranscodeRuntimeConfig{
|
|
TranscodeDir: h.TranscodeDir,
|
|
FFmpegPath: h.FFmpegPath,
|
|
HWAccel: h.HWAccel,
|
|
SegmentRetentionSeconds: h.segmentRetentionSeconds(),
|
|
}
|
|
}
|
|
if reg, ok := sessionMgr.(interface {
|
|
GetSession(string) (*playback.Session, error)
|
|
RegisterReconstructed(*playback.Session) *playback.Session
|
|
RegisterReconstructedWithLimits(context.Context, *playback.Session) (*playback.Session, error)
|
|
}); ok {
|
|
h.tm.Sessions = reg
|
|
}
|
|
h.tm.OnFFmpegCrash = func(ctx context.Context, sessionID string, dead *playback.TranscodeSession) {
|
|
// ffmpeg crash: drop the dead transcode and stop the upstream native
|
|
// session. The recipe stays in the compat store so a resume reconstructs.
|
|
nodeURL := ""
|
|
var upstreamSession *playback.Session
|
|
if h.sessionMgr != nil {
|
|
if up, err := h.sessionMgr.GetSession(sessionID); err == nil && up != nil {
|
|
upstreamSession = up
|
|
nodeURL = up.TranscodeNodeURL
|
|
}
|
|
}
|
|
// Guarded close is the authoritative gate: only tear down if the live entry
|
|
// is still the crashed one. We must NOT stop the upstream session before this
|
|
// check — if a successor reconstructed under the same id between ffmpeg's exit
|
|
// and here, an early StopSession would orphan its ffmpeg (live transcode, no
|
|
// session). Only stop the upstream session when the compare-and-delete matched
|
|
// the dead transcode. The recipe stays in the compat store either way so a
|
|
// resume reconstructs.
|
|
if h.sessionMgr != nil && h.tm.CloseTranscodeSessionIf(sessionID, dead, nodeURL) {
|
|
if h.playbackStore != nil {
|
|
if playSession, ok := h.playbackStore.FindByUpstreamSessionID(sessionID); ok {
|
|
h.dispatchCompatScrobble(ctx, compatScrobblePause, playSession, upstreamSession, nil)
|
|
}
|
|
}
|
|
_ = h.sessionMgr.StopSession(sessionID)
|
|
}
|
|
}
|
|
return h
|
|
}
|
|
|
|
func (h *PlaybackHandler) segmentRetentionSeconds() int {
|
|
if h.SegmentRetentionSeconds != nil {
|
|
return h.SegmentRetentionSeconds()
|
|
}
|
|
return 600
|
|
}
|
|
|
|
// CleanupOrphanedTranscodes removes stale per-session transcode dirs, sparing
|
|
// those whose recipe card still exists. Delegates to the shared manager.
|
|
func (h *PlaybackHandler) CleanupOrphanedTranscodes() (int, error) {
|
|
return h.tm.CleanupOrphanedTranscodes()
|
|
}
|
|
|
|
// buildProxyRedirectURL signs a stream token and builds the redirect URL for
|
|
// the given proxy node (the planner's pick for this session).
|
|
func (h *PlaybackHandler) buildProxyRedirectURL(
|
|
playSessionID string,
|
|
upstreamSessionID string,
|
|
method string,
|
|
file *models.MediaFile,
|
|
source PlaybackMediaSource,
|
|
transcodeNodeURL string,
|
|
seekSeconds float64,
|
|
proxyNode *nodepool.Node,
|
|
) (string, error) {
|
|
if proxyNode == nil || h.JWTSecret == "" {
|
|
return "", fmt.Errorf("proxy transport unavailable")
|
|
}
|
|
|
|
audioTrackIndex := 0
|
|
if resolvedAudioTrackIndex, ok := compatAudioTrackIndex(source); ok {
|
|
audioTrackIndex = resolvedAudioTrackIndex
|
|
}
|
|
|
|
claims := streamtoken.Claims{
|
|
SessionID: upstreamSessionID,
|
|
MediaPath: file.FilePath,
|
|
PlayMethod: method,
|
|
TranscodeAudio: source.TranscodeAudio,
|
|
AudioTrackIndex: audioTrackIndex,
|
|
AudioOnly: file.IsAudioOnly(),
|
|
TranscodeNode: transcodeNodeURL,
|
|
DVProfile: file.PrimaryDVProfile(),
|
|
}
|
|
token, err := streamtoken.Sign(claims, h.JWTSecret, 24*time.Hour)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
switch method {
|
|
case string(playback.PlayDirect):
|
|
return proxyNode.URL + "/stream/direct/" + token, nil
|
|
case string(playback.PlayRemux):
|
|
redirectURL := proxyNode.URL + "/stream/remux/" + token
|
|
if seekSeconds > 0 {
|
|
redirectURL += "?seek=" + strconv.FormatFloat(seekSeconds, 'f', -1, 64)
|
|
}
|
|
return redirectURL, nil
|
|
case string(playback.PlayTranscode):
|
|
return proxyNode.URL + "/stream/transcode/" + token + "/master.m3u8?" + playback.SourceTimelineQueryParam + "=1", nil
|
|
default:
|
|
return "", fmt.Errorf("unsupported proxy method %q", method)
|
|
}
|
|
}
|
|
|
|
// clampSeekSeconds caps a client-supplied seek position to the longest
|
|
// negotiated source duration. Clients sometimes pass StartTimeTicks values
|
|
// that exceed the source runtime (e.g. when a stale resume position is read
|
|
// from a Played item). Letting that through makes ffmpeg seek past EOF and
|
|
// stalls the HLS pipeline (1+s init.mp4 latency, dead segments after init).
|
|
func clampSeekSeconds(seekSeconds float64, sources []PlaybackMediaSource) float64 {
|
|
if seekSeconds <= 0 {
|
|
return 0
|
|
}
|
|
var maxDuration float64
|
|
for _, s := range sources {
|
|
if d := float64(s.Version.Duration); d > maxDuration {
|
|
maxDuration = d
|
|
}
|
|
}
|
|
if maxDuration > 0 && seekSeconds > maxDuration {
|
|
return maxDuration
|
|
}
|
|
return seekSeconds
|
|
}
|
|
|
|
func (h *PlaybackHandler) startRemoteTranscode(
|
|
ctx context.Context,
|
|
playSessionID string,
|
|
upstreamSessionID string,
|
|
source PlaybackMediaSource,
|
|
file *models.MediaFile,
|
|
initialSeekSeconds float64,
|
|
transcodeNodeURL string,
|
|
) error {
|
|
if !source.TranscodeAudio && is4KResolution(source.Version.Resolution) && !h.allow4KVideoTranscode(ctx) {
|
|
return errTranscode4KDisallowed
|
|
}
|
|
if d := float64(source.Version.Duration); d > 0 && initialSeekSeconds > d {
|
|
initialSeekSeconds = d
|
|
}
|
|
if initialSeekSeconds < 0 {
|
|
initialSeekSeconds = 0
|
|
}
|
|
segmentDuration := h.compatSegmentDuration()
|
|
startSegmentNumber := 0
|
|
if initialSeekSeconds > 0 && segmentDuration > 0 {
|
|
startSegmentNumber = int(initialSeekSeconds / float64(segmentDuration))
|
|
}
|
|
sourceVideoCodec, sourceVideoProfile, sourceVideoBitDepth := playback.SourceVideoTranscodeFacts(file)
|
|
|
|
reqBody := transcodenode.TranscodeStartRequest{
|
|
SessionID: upstreamSessionID,
|
|
InputPath: file.FilePath,
|
|
SourceVideoCodec: sourceVideoCodec,
|
|
SourceVideoProfile: sourceVideoProfile,
|
|
SourceVideoBitDepth: sourceVideoBitDepth,
|
|
SeekSeconds: initialSeekSeconds,
|
|
StartSegmentNumber: startSegmentNumber,
|
|
TargetCodecVideo: compatTargetVideoCodec,
|
|
TargetCodecAudio: compatTargetAudioCodec,
|
|
SegmentDuration: segmentDuration,
|
|
HWAccel: h.HWAccel,
|
|
AudioTrackIndex: compatAudioTrackIndexOrDefault(source),
|
|
TotalDuration: float64(source.Version.Duration),
|
|
}
|
|
if source.TranscodeAudio {
|
|
reqBody.TargetCodecVideo = "copy"
|
|
}
|
|
|
|
body, err := json.Marshal(reqBody)
|
|
if err != nil {
|
|
return fmt.Errorf("marshal transcode request: %w", err)
|
|
}
|
|
// Bound the dispatch like the native path does (playback.go) — without
|
|
// this, an unreachable transcode node hangs the compat manifest request
|
|
// until the OS gives up on the connection.
|
|
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
|
defer cancel()
|
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, transcodeNodeURL+"/transcode/start", strings.NewReader(string(body)))
|
|
if err != nil {
|
|
return fmt.Errorf("build transcode request: %w", err)
|
|
}
|
|
httpReq.Header.Set("Content-Type", "application/json")
|
|
httpReq.Header.Set("Authorization", "Bearer "+h.JWTSecret)
|
|
|
|
resp, err := http.DefaultClient.Do(httpReq)
|
|
if err != nil {
|
|
return fmt.Errorf("remote transcode start failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusAccepted {
|
|
return fmt.Errorf("remote transcode start rejected: status %d", resp.StatusCode)
|
|
}
|
|
|
|
// Mirror the byte-affecting opts sent to the node into a RecipeCard and persist
|
|
// it for restart resilience. The node-hop token is identity-only by design (see
|
|
// internal/noderecipe), and central serves Jellyfin clients that carry no native
|
|
// token of their own, so without a persisted recipe a node or central restart
|
|
// cannot rebuild ffmpeg and segment serves 404.
|
|
opts := playback.TranscodeOpts{
|
|
SessionID: upstreamSessionID,
|
|
InputPath: reqBody.InputPath,
|
|
SourceVideoCodec: reqBody.SourceVideoCodec,
|
|
SourceVideoProfile: reqBody.SourceVideoProfile,
|
|
SourceVideoBitDepth: reqBody.SourceVideoBitDepth,
|
|
SeekSeconds: reqBody.SeekSeconds,
|
|
StartSegmentNumber: reqBody.StartSegmentNumber,
|
|
TargetCodecVideo: reqBody.TargetCodecVideo,
|
|
TargetCodecAudio: reqBody.TargetCodecAudio,
|
|
SegmentDuration: reqBody.SegmentDuration,
|
|
HWAccel: reqBody.HWAccel,
|
|
AudioTrackIndex: reqBody.AudioTrackIndex,
|
|
TotalDuration: reqBody.TotalDuration,
|
|
}
|
|
if source.TranscodeAudio {
|
|
opts.TargetCodecVideo = "copy"
|
|
}
|
|
|
|
// Same mirror as the local path: the node runs the encode, but the
|
|
// upstream session here is what session sync and admin views read.
|
|
h.recordTranscodeStreamDetails(ctx, upstreamSessionID, opts)
|
|
|
|
if err := h.persistTranscodeRecipe(ctx, playSessionID, upstreamSessionID, opts); err != nil {
|
|
// Roll back the already-started node ffmpeg so it isn't leaked.
|
|
h.tm.CloseTranscodeSession(upstreamSessionID, transcodeNodeURL)
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// persistTranscodeRecipe builds the reconstruction recipe from the upstream
|
|
// session's identity and persists it for restart resilience. It is shared by the
|
|
// local (ensureLocalTranscode) and remote (startRemoteTranscode) transcode paths
|
|
// so both stores stay in lock-step.
|
|
//
|
|
// The recipe is recorded in the compat store in the same Update that marks the
|
|
// transcode started — a failed write leaves neither set — and then best-effort
|
|
// handed to the node recipe store (Redis) for dedicated transcode nodes. The node
|
|
// URL is taken from the upstream session (bound before start on the remote path),
|
|
// so it is "" for integrated transcodes and the node-store write is skipped.
|
|
// A Jellyfin client carries no native token of its own and the node-hop token is
|
|
// deliberately identity-only (see internal/noderecipe), so the persisted recipe is
|
|
// the only way a node or central restart can rebuild ffmpeg.
|
|
//
|
|
// Returns an error only when the compat-store Update fails; the caller owns
|
|
// rolling back its (local or remote) transcode in that case. A missing upstream
|
|
// session (a start/build race, or no session manager in tests) is logged and the
|
|
// live transcode is left serving — only restart resilience is forfeited.
|
|
func (h *PlaybackHandler) persistTranscodeRecipe(
|
|
ctx context.Context,
|
|
playSessionID, upstreamSessionID string,
|
|
opts playback.TranscodeOpts,
|
|
) error {
|
|
var recipe *playback.RecipeCard
|
|
if h.sessionMgr != nil {
|
|
if upstream, err := h.sessionMgr.GetSession(upstreamSessionID); err == nil && upstream != nil {
|
|
card := playback.NewRecipeCard(upstream.UserID, upstream.ProfileID, upstream.MediaFileID, upstream.TranscodeNodeURL, opts)
|
|
// Mirror the client metadata into the card so a session rebuilt
|
|
// after a restart keeps its client label and Jellyfin
|
|
// identification instead of syncing with empty client fields.
|
|
card.ClientName = upstream.ClientName
|
|
card.ClientVersion = upstream.ClientVersion
|
|
card.ClientUserAgent = upstream.ClientUserAgent
|
|
card.IsJellyfinCompat = upstream.IsJellyfinCompat
|
|
recipe = &card
|
|
}
|
|
}
|
|
if recipe == nil {
|
|
slog.WarnContext(ctx, "transcode recipe not persisted: upstream session unavailable", "component", "jellycompat",
|
|
"playback_session_id", upstreamSessionID)
|
|
}
|
|
|
|
if err := h.playbackStore.Update(playSessionID, func(current *PlaybackSession) error {
|
|
current.TranscodeStarted = true
|
|
current.Recipe = recipe
|
|
return nil
|
|
}); err != nil {
|
|
return fmt.Errorf("update playback session: %w", err)
|
|
}
|
|
|
|
// Hand the recipe to the control-plane store (Redis) so a dedicated transcode
|
|
// node that restarts can rebuild ffmpeg from it. Bounded and best effort: a
|
|
// stalled write must not hang the manifest request, and a failed write only
|
|
// forfeits node-restart resilience for this session, never the start.
|
|
if recipe != nil && recipe.TranscodeNodeURL != "" && h.RecipeNodeStore != nil {
|
|
putCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Second)
|
|
defer cancel()
|
|
if err := h.RecipeNodeStore.Put(putCtx, upstreamSessionID, *recipe); err != nil {
|
|
slog.WarnContext(ctx, "persist node transcode recipe failed", "component", "jellycompat", "error", err,
|
|
"playback_session_id", upstreamSessionID, "node", recipe.TranscodeNodeURL)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// HandleCapabilitiesFull stores the client device profile reported by Jellyfin apps.
|
|
func (h *PlaybackHandler) HandleCapabilitiesFull(w http.ResponseWriter, r *http.Request) {
|
|
session := SessionFromContext(r.Context())
|
|
if session == nil {
|
|
writeError(w, http.StatusUnauthorized, "Unauthorized", "Missing authentication token")
|
|
return
|
|
}
|
|
|
|
profile, err := decodeDeviceProfile(r.Body)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "BadRequest", "Invalid capabilities payload")
|
|
return
|
|
}
|
|
if profile.HasData() {
|
|
h.deviceProfiles.Put(session.Token, profile)
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// HandleBitrateTest returns a small binary payload for clients that probe bandwidth.
|
|
func (h *PlaybackHandler) HandleBitrateTest(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/octet-stream")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write(make([]byte, 1024*1024))
|
|
}
|
|
|
|
// HandlePlaybackInfo negotiates media sources for a Jellyfin item.
|
|
func (h *PlaybackHandler) HandlePlaybackInfo(w http.ResponseWriter, r *http.Request) {
|
|
session := SessionFromContext(r.Context())
|
|
if session == nil {
|
|
writeError(w, http.StatusUnauthorized, "Unauthorized", "Missing authentication token")
|
|
return
|
|
}
|
|
|
|
contentID, err := decodeItemID(h.codec, chi.URLParam(r, "id"))
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "NotFound", "Item not found")
|
|
return
|
|
}
|
|
|
|
req, profile, err := h.parsePlaybackRequest(r, session.Token)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "BadRequest", "Invalid playback request")
|
|
return
|
|
}
|
|
// PlaybackInfo is authorized by the token-derived session. Some clients
|
|
// retain a previous UserId in their request body while moving to the next
|
|
// item; that advisory value must not turn an otherwise authorized playback
|
|
// negotiation into a 404.
|
|
|
|
detail, err := h.content.GetItemDetail(r.Context(), session, contentID, nil)
|
|
if err != nil {
|
|
writeCompatUpstreamError(w, err)
|
|
return
|
|
}
|
|
if len(detail.Versions) == 0 {
|
|
writeError(w, http.StatusBadRequest, "PlaybackUnavailable", "Item does not have playable media")
|
|
return
|
|
}
|
|
|
|
routeItemID := h.codec.EncodeStringID(EncodedIDItem, detail.ContentID)
|
|
playSessionID := h.codec.EncodeStringID(EncodedIDPlaySession, uuidNewString())
|
|
sources := make([]PlaybackMediaSource, 0, len(detail.Versions))
|
|
sourceDTOs := make([]mediaSourceDTO, 0, len(detail.Versions))
|
|
|
|
allow4KTranscode := h.allow4KVideoTranscode(r.Context())
|
|
if req.MediaSourceID != "" {
|
|
matched := false
|
|
for _, version := range detail.Versions {
|
|
candidate := h.buildPlaybackSource(routeItemID, playSessionID, version, profile, req, allow4KTranscode)
|
|
if mediaSourceIDsEqual(candidate.ID, req.MediaSourceID) {
|
|
matched = true
|
|
break
|
|
}
|
|
}
|
|
if !matched {
|
|
// Continue Watching and autoplay clients can carry the previous
|
|
// episode's MediaSourceId into the next PlaybackInfo request. The
|
|
// authenticated item route remains authoritative; fall back to its
|
|
// available versions instead of returning a misleading 404.
|
|
slog.InfoContext(r.Context(), "jellycompat ignored stale playback media source", "component", "jellycompat")
|
|
req.MediaSourceID = ""
|
|
}
|
|
}
|
|
for _, version := range detail.Versions {
|
|
source := h.buildPlaybackSource(routeItemID, playSessionID, version, profile, req, allow4KTranscode)
|
|
if req.MediaSourceID != "" && !mediaSourceIDsEqual(source.ID, req.MediaSourceID) {
|
|
continue
|
|
}
|
|
|
|
// Resolve the client's subtitle selection against both the
|
|
// embedded/external tracks and any downloaded subtitles before
|
|
// advertising the streams, so the chosen subtitle is marked default and
|
|
// starts with playback (mirrors the audio-selection plumbing).
|
|
var downloaded []subtitles.DownloadedSubtitle
|
|
downloadedKnown := true
|
|
if h.SubtitleRepo != nil {
|
|
var listErr error
|
|
downloaded, listErr = h.SubtitleRepo.ListDownloadedSubtitles(r.Context(), source.Version.FileID)
|
|
if listErr != nil {
|
|
// Don't treat a lookup failure as "no downloaded subtitles": that
|
|
// would silently downgrade a valid downloaded selection to the
|
|
// media default. Resolution falls back to honoring the request.
|
|
downloaded = nil
|
|
downloadedKnown = false
|
|
slog.WarnContext(r.Context(), "jellycompat downloaded subtitle lookup failed", "component", "jellycompat",
|
|
"file_id", source.Version.FileID,
|
|
"error", listErr,
|
|
)
|
|
}
|
|
}
|
|
var requestedSubtitleIndex *int
|
|
if req.SubtitleStreamIndex != nil {
|
|
requestedSubtitleIndex = intPtr(int(*req.SubtitleStreamIndex))
|
|
}
|
|
source.SelectedSubtitleStreamIndex = resolveSelectedSubtitleStreamIndex(source.Version, len(downloaded), downloadedKnown, requestedSubtitleIndex, source.DefaultSubtitleStreamIndex)
|
|
|
|
sources = append(sources, source)
|
|
dto := h.mediaSourceDTO(routeItemID, playSessionID, session.Token, source)
|
|
|
|
// Append downloaded subtitles to the media streams, honoring the selection.
|
|
if len(downloaded) > 0 {
|
|
selectedSubtitleStreamIndex := effectiveCompatSubtitleStreamIndex(source)
|
|
baseIndex := nextDownloadedSubtitleIndex(source.Version)
|
|
for i, dl := range downloaded {
|
|
streamIndex := baseIndex + i
|
|
format := subtitleRouteFormat(string(dl.Format))
|
|
displayTitle := downloadedSubtitleDisplayTitle(dl)
|
|
dto.MediaStreams = append(dto.MediaStreams, mediaStreamDTO{
|
|
Index: streamIndex,
|
|
Type: "Subtitle",
|
|
Codec: string(dl.Format),
|
|
Language: dl.Language,
|
|
DisplayTitle: displayTitle,
|
|
Title: displayTitle,
|
|
IsDefault: selectedSubtitleStreamIndex != nil && streamIndex == *selectedSubtitleStreamIndex,
|
|
IsExternal: true,
|
|
IsForced: false,
|
|
IsHearingImpaired: dl.HearingImpaired,
|
|
IsTextSubtitleStream: true,
|
|
SupportsExternalStream: true,
|
|
DeliveryURL: subtitleDeliveryURL(routeItemID, source.ID, streamIndex, format, session.Token, playSessionID),
|
|
DeliveryMethod: "External",
|
|
Path: downloadedSubtitlePath(source.Version, dl),
|
|
IsExternalURL: boolPtr(false),
|
|
})
|
|
}
|
|
}
|
|
sourceDTOs = append(sourceDTOs, dto)
|
|
}
|
|
|
|
if len(sourceDTOs) == 0 {
|
|
writeError(w, http.StatusNotFound, "NotFound", "Media source not found")
|
|
return
|
|
}
|
|
|
|
clientDeviceID := firstNonEmpty(
|
|
firstMediaBrowserAuthorizationValue(r, "DeviceId"),
|
|
newCaseInsensitiveQuery(r.URL.Query()).Get("DeviceId"),
|
|
)
|
|
clientDeviceID = stripCompatNUL(clientDeviceID)
|
|
h.playbackStore.PutNegotiated(PlaybackSession{
|
|
ID: playSessionID,
|
|
CompatToken: session.Token,
|
|
ClientDeviceID: clientDeviceID,
|
|
ItemID: detail.ContentID,
|
|
RouteItemID: routeItemID,
|
|
UserID: session.PseudoUserID.String(),
|
|
InitialSeekSeconds: clampSeekSeconds(float64(req.StartTimeTicks)/10_000_000, sources),
|
|
MediaSources: sources,
|
|
})
|
|
|
|
writeJSON(w, http.StatusOK, playbackInfoResponseDTO{
|
|
PlaySessionID: playSessionID,
|
|
MediaSources: sourceDTOs,
|
|
})
|
|
}
|
|
|
|
// stripCompatNUL removes the only code point PostgreSQL rejects in JSONB text.
|
|
// DeviceId and static PlaySessionId come from clients, then become fields in a
|
|
// durable PlaybackSession. Without this boundary check one malformed value
|
|
// leaves the session cache-only; its first revalidation drops playback.
|
|
func stripCompatNUL(value string) string {
|
|
return strings.ReplaceAll(value, "\x00", "")
|
|
}
|
|
|
|
func (h *PlaybackHandler) parsePlaybackRequest(r *http.Request, compatToken string) (playbackInfoRequest, DeviceProfile, error) {
|
|
var req playbackInfoRequest
|
|
body, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
return req, DeviceProfile{}, err
|
|
}
|
|
if len(strings.TrimSpace(string(body))) != 0 {
|
|
if err := json.Unmarshal(body, &req); err != nil {
|
|
return req, DeviceProfile{}, err
|
|
}
|
|
}
|
|
applyPlaybackQueryOverrides(&req, r.URL.Query())
|
|
|
|
profile := DeviceProfile{}
|
|
if len(req.DeviceProfile) > 0 {
|
|
if err := json.Unmarshal(req.DeviceProfile, &profile); err != nil {
|
|
return req, DeviceProfile{}, err
|
|
}
|
|
if profile.HasData() {
|
|
h.deviceProfiles.Put(compatToken, profile)
|
|
}
|
|
}
|
|
if !profile.HasData() {
|
|
if stored, ok := h.deviceProfiles.Get(compatToken); ok {
|
|
profile = stored
|
|
} else {
|
|
profile = DefaultDeviceProfile()
|
|
}
|
|
}
|
|
|
|
return req, profile, nil
|
|
}
|
|
|
|
func (h *PlaybackHandler) buildPlaybackSource(
|
|
routeItemID, playSessionID string,
|
|
version catalog.FileVersion,
|
|
profile DeviceProfile,
|
|
req playbackInfoRequest,
|
|
allow4KTranscode bool,
|
|
) PlaybackMediaSource {
|
|
sourceID := h.codec.EncodeIntID(EncodedIDMediaSource, int64(version.FileID))
|
|
enableDirectPlay := boolDefault(req.EnableDirectPlay, true)
|
|
enableDirectStream := boolDefault(req.EnableDirectStream, true)
|
|
allowVideoCopy := boolDefault(req.AllowVideoStreamCopy, true)
|
|
allowAudioCopy := boolDefault(req.AllowAudioStreamCopy, true)
|
|
|
|
audioIndex := preferredAudioStreamIndex(version, profile)
|
|
subtitleIndex := defaultSubtitleStreamIndex(version)
|
|
selectedAudioIndex := audioIndex
|
|
if req.AudioStreamIndex != nil && isValidCompatAudioStreamIndex(version, int(*req.AudioStreamIndex)) {
|
|
selectedAudioIndex = intPtr(int(*req.AudioStreamIndex))
|
|
}
|
|
|
|
supportsDirectPlay := enableDirectPlay && profile.SupportsDirectPlayForAudioStream(version, selectedAudioIndex)
|
|
audioSupported := profile.SupportsAudioCodecForDirectStreamForAudioStream(version, selectedAudioIndex)
|
|
videoSupported := profile.SupportsVideoCodecForDirectStreamForAudioStream(version, selectedAudioIndex)
|
|
transcodeAudio := enableDirectStream && allowVideoCopy && videoSupported && !audioSupported
|
|
supportsDirectStream := !transcodeAudio &&
|
|
enableDirectStream &&
|
|
allowVideoCopy &&
|
|
videoSupported &&
|
|
allowAudioCopy &&
|
|
audioSupported
|
|
supportsTranscoding := boolDefault(req.EnableTranscoding, true) && profile.SupportsTranscoding(version)
|
|
// Don't offer full video encodes of 4K sources when allow_4k_transcode is
|
|
// off. Audio-only transcodes (transcodeAudio) stream-copy the video and
|
|
// stay available.
|
|
if supportsTranscoding && !transcodeAudio && !allow4KTranscode && is4KResolution(version.Resolution) {
|
|
supportsTranscoding = false
|
|
}
|
|
|
|
return PlaybackMediaSource{
|
|
ID: sourceID,
|
|
FileID: version.FileID,
|
|
Version: version,
|
|
SupportsDirectPlay: supportsDirectPlay,
|
|
SupportsDirectStream: supportsDirectStream,
|
|
SupportsTranscoding: supportsTranscoding,
|
|
TranscodeAudio: transcodeAudio,
|
|
DefaultAudioStreamIndex: audioIndex,
|
|
SelectedAudioStreamIndex: selectedAudioIndex,
|
|
DefaultSubtitleStreamIndex: subtitleIndex,
|
|
ETag: mediaSourceETag(version),
|
|
}
|
|
}
|
|
|
|
func (h *PlaybackHandler) mediaSourceDTO(routeItemID, playSessionID, compatToken string, source PlaybackMediaSource) mediaSourceDTO {
|
|
selectedAudioStreamIndex := effectiveCompatAudioStreamIndex(source)
|
|
dto := mediaSourceDTO{
|
|
Protocol: "File",
|
|
ID: source.ID,
|
|
Path: compatMediaPath(source.Version),
|
|
Type: "Default",
|
|
Container: strings.ToLower(source.Version.Container),
|
|
Size: source.Version.FileSize,
|
|
Name: mediaSourceName(source.Version),
|
|
IsRemote: false,
|
|
ETag: source.ETag,
|
|
RunTimeTicks: secondsToTicks(float64(source.Version.Duration)),
|
|
ReadAtNativeFramerate: false,
|
|
IgnoreDts: false,
|
|
IgnoreIndex: false,
|
|
GenPtsInput: false,
|
|
SupportsTranscoding: source.SupportsTranscoding,
|
|
SupportsDirectStream: source.SupportsDirectStream,
|
|
SupportsDirectPlay: source.SupportsDirectPlay,
|
|
IsInfiniteStream: false,
|
|
UseMostCompatibleTranscodingProfile: false,
|
|
RequiresOpening: false,
|
|
RequiresClosing: false,
|
|
RequiresLooping: false,
|
|
SupportsProbing: true,
|
|
VideoType: "VideoFile",
|
|
HasSegments: false,
|
|
Formats: []string{strings.ToLower(source.Version.Container)},
|
|
RequiredHTTPHeaders: map[string]string{},
|
|
MediaAttachments: []map[string]any{},
|
|
Bitrate: source.Version.Bitrate * 1000,
|
|
DefaultAudioStreamIndex: selectedAudioStreamIndex,
|
|
DefaultSubtitleStreamIndex: effectiveCompatSubtitleStreamIndex(source),
|
|
MediaStreams: buildMediaStreamsWithSelection(routeItemID, source.ID, source.Version, selectedAudioStreamIndex, source.SelectedSubtitleStreamIndex, compatToken, playSessionID),
|
|
}
|
|
if source.SupportsDirectPlay || source.SupportsDirectStream {
|
|
dto.DirectStreamURL = fmt.Sprintf(
|
|
"/Videos/%s/stream?static=true&mediaSourceId=%s&api_key=%s&PlaySessionId=%s",
|
|
routeItemID,
|
|
url.QueryEscape(source.ID),
|
|
url.QueryEscape(compatToken),
|
|
url.QueryEscape(playSessionID),
|
|
)
|
|
}
|
|
dto.TranscodingSubProtocol = "hls"
|
|
if source.SupportsTranscoding {
|
|
dto.TranscodingURL = fmt.Sprintf("/Videos/%s/master.m3u8?PlaySessionId=%s&MediaSourceId=%s", routeItemID, playSessionID, source.ID)
|
|
if source.TranscodeAudio {
|
|
dto.TranscodingContainer = "mp4"
|
|
} else {
|
|
dto.TranscodingContainer = "ts"
|
|
}
|
|
}
|
|
return dto
|
|
}
|
|
|
|
func buildMediaStreams(routeItemID, mediaSourceID string, version catalog.FileVersion) []mediaStreamDTO {
|
|
return buildMediaStreamsWithSelection(routeItemID, mediaSourceID, version, nil, nil, "", "")
|
|
}
|
|
|
|
func buildMediaStreamsWithSelection(routeItemID, mediaSourceID string, version catalog.FileVersion, selectedAudioStreamIndex, selectedSubtitleStreamIndex *int, compatToken, playSessionID string) []mediaStreamDTO {
|
|
streams := make([]mediaStreamDTO, 0, len(version.VideoTracks)+len(version.AudioTracks)+len(version.SubtitleTracks))
|
|
effectiveAudioStreamIndex := selectedAudioStreamIndex
|
|
if effectiveAudioStreamIndex != nil && !isValidCompatAudioStreamIndex(version, *effectiveAudioStreamIndex) {
|
|
effectiveAudioStreamIndex = nil
|
|
}
|
|
|
|
for index, track := range version.VideoTracks {
|
|
bitrate := track.Bitrate
|
|
if bitrate == 0 && version.Bitrate > 0 {
|
|
bitrate = version.Bitrate * 1000
|
|
}
|
|
streams = append(streams, mediaStreamDTO{
|
|
Index: index,
|
|
Type: "Video",
|
|
Codec: strings.ToLower(track.Codec),
|
|
TimeBase: "1/1000",
|
|
DisplayTitle: firstNonEmpty(track.Title, track.Codec),
|
|
Title: firstNonEmpty(track.Title, track.Codec),
|
|
IsDefault: index == 0,
|
|
IsExternal: false,
|
|
IsForced: false,
|
|
IsHearingImpaired: false,
|
|
IsTextSubtitleStream: false,
|
|
SupportsExternalStream: false,
|
|
IsInterlaced: track.Interlaced,
|
|
IsAVC: strings.EqualFold(track.Codec, "h264"),
|
|
IsAnamorphic: false,
|
|
NalLengthSize: "4",
|
|
BitDepth: track.BitDepth,
|
|
RefFrames: track.ReferenceFrames,
|
|
Profile: track.Profile,
|
|
Level: track.Level,
|
|
AspectRatio: track.AspectRatio,
|
|
VideoRange: compatVideoRange(track, version.HDR),
|
|
VideoRangeType: compatVideoRangeType(track, version.HDR),
|
|
ColorRange: compatColorRange(track.ColorRange),
|
|
ColorPrimaries: track.ColorPrimaries,
|
|
ColorSpace: track.ColorSpace,
|
|
ColorTransfer: track.ColorTransfer,
|
|
PixelFormat: track.PixelFormat,
|
|
AudioSpatialFormat: "None",
|
|
AverageFrameRate: parseCompatFrameRate(track.FrameRate),
|
|
RealFrameRate: parseCompatFrameRate(track.FrameRate),
|
|
ReferenceFrameRate: parseCompatFrameRate(track.FrameRate),
|
|
Height: track.Height,
|
|
Width: track.Width,
|
|
BitRate: bitrate,
|
|
})
|
|
}
|
|
|
|
for index, track := range version.AudioTracks {
|
|
streamIndex := len(version.VideoTracks) + index
|
|
isDefault := track.Default || (index == 0 && !anyDefaultAudioTrack(version.AudioTracks))
|
|
if effectiveAudioStreamIndex != nil {
|
|
isDefault = streamIndex == *effectiveAudioStreamIndex
|
|
}
|
|
streams = append(streams, mediaStreamDTO{
|
|
Index: streamIndex,
|
|
Type: "Audio",
|
|
Codec: strings.ToLower(track.Codec),
|
|
Language: track.Language,
|
|
TimeBase: "1/1000",
|
|
DisplayTitle: audioTrackDisplayTitle(track),
|
|
Title: firstNonEmpty(track.Title, track.EmbeddedTitle),
|
|
IsDefault: isDefault,
|
|
IsExternal: false,
|
|
IsForced: false,
|
|
IsHearingImpaired: false,
|
|
IsTextSubtitleStream: false,
|
|
SupportsExternalStream: false,
|
|
Profile: track.Profile,
|
|
AudioSpatialFormat: compatAudioSpatialFormat(track.Profile),
|
|
Channels: track.Channels,
|
|
BitRate: track.Bitrate,
|
|
})
|
|
}
|
|
|
|
for index, track := range version.SubtitleTracks {
|
|
if !subtitleTrackStreamable(track.Codec, track.External) {
|
|
continue
|
|
}
|
|
streamIndex := subtitleTrackIndex(version, track, index)
|
|
format := subtitleRouteFormat(track.Codec)
|
|
displayTitle := compatSubtitleDisplayTitle(track)
|
|
// When the client has made an explicit subtitle selection, only that
|
|
// stream is the default. A negative selection ("subtitles off") matches
|
|
// no stream, which correctly clears every embedded default.
|
|
isDefault := track.Default
|
|
if selectedSubtitleStreamIndex != nil {
|
|
isDefault = streamIndex == *selectedSubtitleStreamIndex
|
|
}
|
|
streams = append(streams, mediaStreamDTO{
|
|
Index: streamIndex,
|
|
Type: "Subtitle",
|
|
Codec: strings.ToLower(track.Codec),
|
|
Language: track.Language,
|
|
TimeBase: "1/1000",
|
|
DisplayTitle: displayTitle,
|
|
Title: displayTitle,
|
|
IsDefault: isDefault,
|
|
IsExternal: track.External,
|
|
IsForced: track.Forced,
|
|
IsHearingImpaired: track.HearingImpaired,
|
|
IsTextSubtitleStream: true,
|
|
SupportsExternalStream: track.External,
|
|
AudioSpatialFormat: "None",
|
|
DeliveryURL: subtitleDeliveryURL(routeItemID, mediaSourceID, streamIndex, format, compatToken, playSessionID),
|
|
DeliveryMethod: subtitleDeliveryMethod(track.External),
|
|
Path: subtitlePath(track, routeItemID, mediaSourceID, streamIndex, format),
|
|
IsExternalURL: subtitleExternalURL(track),
|
|
})
|
|
}
|
|
|
|
return streams
|
|
}
|
|
|
|
func compatColorRange(colorRange string) string {
|
|
colorRange = strings.TrimSpace(colorRange)
|
|
if strings.EqualFold(colorRange, "unknown") {
|
|
// The scanner persists this sentinel so legacy probe repair converges,
|
|
// but Jellyfin omits ColorRange when ffprobe did not provide a value.
|
|
return ""
|
|
}
|
|
return colorRange
|
|
}
|
|
|
|
func mediaSourceETag(version catalog.FileVersion) string {
|
|
sum := sha1.Sum(fmt.Appendf(nil, "%d:%s:%s:%d", version.FileID, version.Container, version.CodecVideo, version.Bitrate))
|
|
return hex.EncodeToString(sum[:8])
|
|
}
|
|
|
|
func defaultAudioStreamIndex(version catalog.FileVersion) *int {
|
|
if len(version.AudioTracks) == 0 {
|
|
return nil
|
|
}
|
|
for index, track := range version.AudioTracks {
|
|
if track.Default {
|
|
value := len(version.VideoTracks) + index
|
|
return &value
|
|
}
|
|
}
|
|
value := len(version.VideoTracks)
|
|
return &value
|
|
}
|
|
|
|
// losslessPassthroughCodecs are audio codecs that require dedicated hardware
|
|
// passthrough (AV receiver or decoder chip) and cannot be decoded by
|
|
// software-only players like ExoPlayer on most Android TV devices.
|
|
var losslessPassthroughCodecs = map[string]bool{
|
|
"truehd": true,
|
|
"mlp": true,
|
|
}
|
|
|
|
// compatFallbackCodecs are broadly supported audio codecs suitable for
|
|
// software decoding. Lower index = higher preference.
|
|
var compatFallbackCodecRank = map[string]int{
|
|
"eac3": 1,
|
|
"ac3": 2,
|
|
"dts": 3,
|
|
"aac": 4,
|
|
"flac": 5,
|
|
"opus": 6,
|
|
"vorbis": 7,
|
|
"mp3": 8,
|
|
"pcm_s16le": 9,
|
|
"pcm_s24le": 10,
|
|
}
|
|
|
|
// preferredAudioStreamIndex returns the best audio stream index for the given
|
|
// device profile. When the default audio track is a lossless passthrough codec
|
|
// (TrueHD, MLP) and the profile does not explicitly list that codec as
|
|
// supported, it selects the most compatible fallback track (same language
|
|
// preferred). This prevents Android TV and similar clients from receiving a
|
|
// TrueHD stream they cannot decode when an AC3/EAC3 fallback is present.
|
|
func preferredAudioStreamIndex(version catalog.FileVersion, profile DeviceProfile) *int {
|
|
defaultIdx := defaultAudioStreamIndex(version)
|
|
if defaultIdx == nil {
|
|
return defaultIdx
|
|
}
|
|
|
|
defaultTrackIdx := *defaultIdx - len(version.VideoTracks)
|
|
if defaultTrackIdx < 0 || defaultTrackIdx >= len(version.AudioTracks) {
|
|
return defaultIdx
|
|
}
|
|
defaultTrack := version.AudioTracks[defaultTrackIdx]
|
|
if !losslessPassthroughCodecs[normalizeCompatToken(defaultTrack.Codec)] {
|
|
return defaultIdx // not a passthrough codec; keep default
|
|
}
|
|
|
|
// Check whether the profile explicitly lists this lossless codec as
|
|
// supported in any DirectPlayProfile. An empty AudioCodec field is a
|
|
// wildcard that many clients use to mean "try anything" — but for
|
|
// lossless passthrough codecs we cannot assume the device can actually
|
|
// decode them, so we do not treat wildcard as explicit support.
|
|
defaultCodec := normalizeCompatToken(defaultTrack.Codec)
|
|
for _, p := range profile.DirectPlayProfiles {
|
|
if !matchesVideoType(p.Type) {
|
|
continue
|
|
}
|
|
if strings.TrimSpace(p.AudioCodec) == "" {
|
|
continue // wildcard — not explicit support for lossless
|
|
}
|
|
for part := range strings.SplitSeq(p.AudioCodec, ",") {
|
|
if normalizeCompatToken(part) == defaultCodec {
|
|
return defaultIdx // profile explicitly supports this lossless codec
|
|
}
|
|
}
|
|
}
|
|
|
|
// Profile does not explicitly support this lossless codec. Find the best
|
|
// compatible fallback with the same language as the default track.
|
|
defaultLang := strings.ToLower(strings.TrimSpace(defaultTrack.Language))
|
|
|
|
bestIdx := -1
|
|
bestRank := 0
|
|
|
|
for i, track := range version.AudioTracks {
|
|
rank, ok := compatFallbackCodecRank[normalizeCompatToken(track.Codec)]
|
|
if !ok {
|
|
continue
|
|
}
|
|
lang := strings.ToLower(strings.TrimSpace(track.Language))
|
|
sameLang := lang == defaultLang
|
|
// Prefer same-language tracks; within each group prefer lower rank number.
|
|
if bestIdx == -1 {
|
|
bestIdx = i
|
|
bestRank = rank
|
|
if sameLang {
|
|
bestRank = -rank // negative signals same-language preference
|
|
}
|
|
} else {
|
|
currentSameLang := bestRank < 0
|
|
if sameLang && !currentSameLang {
|
|
// Upgrade from cross-language to same-language.
|
|
bestIdx = i
|
|
bestRank = -rank
|
|
} else if sameLang == currentSameLang {
|
|
// Same language group — pick lower rank (higher quality).
|
|
effectiveRank := rank
|
|
effectiveBest := bestRank
|
|
if sameLang {
|
|
effectiveRank = -rank
|
|
effectiveBest = bestRank // already negative
|
|
}
|
|
if effectiveRank < effectiveBest {
|
|
bestIdx = i
|
|
bestRank = effectiveRank
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if bestIdx >= 0 {
|
|
idx := len(version.VideoTracks) + bestIdx
|
|
return &idx
|
|
}
|
|
return defaultIdx
|
|
}
|
|
|
|
func effectiveCompatAudioStreamIndex(source PlaybackMediaSource) *int {
|
|
if source.SelectedAudioStreamIndex != nil && isValidCompatAudioStreamIndex(source.Version, *source.SelectedAudioStreamIndex) {
|
|
return intPtr(*source.SelectedAudioStreamIndex)
|
|
}
|
|
if source.DefaultAudioStreamIndex != nil && isValidCompatAudioStreamIndex(source.Version, *source.DefaultAudioStreamIndex) {
|
|
return intPtr(*source.DefaultAudioStreamIndex)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func compatAudioTrackIndex(source PlaybackMediaSource) (int, bool) {
|
|
streamIndex := effectiveCompatAudioStreamIndex(source)
|
|
if streamIndex == nil {
|
|
return 0, false
|
|
}
|
|
audioTrackIndex := *streamIndex - len(source.Version.VideoTracks)
|
|
if audioTrackIndex < 0 || audioTrackIndex >= len(source.Version.AudioTracks) {
|
|
return 0, false
|
|
}
|
|
return audioTrackIndex, true
|
|
}
|
|
|
|
func compatAudioTrackIndexOrDefault(source PlaybackMediaSource) int {
|
|
if audioTrackIndex, ok := compatAudioTrackIndex(source); ok {
|
|
return audioTrackIndex
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func isValidCompatAudioStreamIndex(version catalog.FileVersion, streamIndex int) bool {
|
|
audioStart := len(version.VideoTracks)
|
|
audioEnd := audioStart + len(version.AudioTracks)
|
|
return streamIndex >= audioStart && streamIndex < audioEnd
|
|
}
|
|
|
|
func defaultSubtitleStreamIndex(version catalog.FileVersion) *int {
|
|
if len(version.SubtitleTracks) == 0 {
|
|
return nil
|
|
}
|
|
for index, track := range version.SubtitleTracks {
|
|
if !subtitleTrackStreamable(track.Codec, track.External) {
|
|
continue
|
|
}
|
|
if track.Default {
|
|
value := subtitleTrackIndex(version, track, index)
|
|
return &value
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func subtitleTrackIndex(version catalog.FileVersion, track catalog.VersionSubtitleTrack, fallback int) int {
|
|
if track.Index > 0 {
|
|
return track.Index
|
|
}
|
|
return len(version.VideoTracks) + len(version.AudioTracks) + fallback
|
|
}
|
|
|
|
func nextDownloadedSubtitleIndex(version catalog.FileVersion) int {
|
|
maxIndex := len(version.VideoTracks) + len(version.AudioTracks) - 1
|
|
for index, track := range version.SubtitleTracks {
|
|
streamIndex := subtitleTrackIndex(version, track, index)
|
|
if streamIndex > maxIndex {
|
|
maxIndex = streamIndex
|
|
}
|
|
}
|
|
return maxIndex + 1
|
|
}
|
|
|
|
func subtitleTrackStreamable(codec string, external bool) bool {
|
|
return external || !playback.NeedsBurnIn(codec)
|
|
}
|
|
|
|
// isValidCompatSubtitleStreamIndex reports whether streamIndex addresses a
|
|
// deliverable subtitle: either a streamable embedded/external track (bitmap
|
|
// subs that require burn-in are excluded, matching buildMediaStreams) or one of
|
|
// the downloaded subtitles appended after the embedded streams.
|
|
func isValidCompatSubtitleStreamIndex(version catalog.FileVersion, downloadedCount, streamIndex int) bool {
|
|
for index, track := range version.SubtitleTracks {
|
|
if !subtitleTrackStreamable(track.Codec, track.External) {
|
|
continue
|
|
}
|
|
if subtitleTrackIndex(version, track, index) == streamIndex {
|
|
return true
|
|
}
|
|
}
|
|
if downloadedCount > 0 {
|
|
base := nextDownloadedSubtitleIndex(version)
|
|
if streamIndex >= base && streamIndex < base+downloadedCount {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// resolveSelectedSubtitleStreamIndex maps a client-requested subtitle stream
|
|
// index onto the selection stored for the session. A nil request keeps the
|
|
// media default; a negative request is preserved as an explicit "subtitles off"
|
|
// (-1); a valid request is honored; an invalid request falls back to the media
|
|
// default.
|
|
//
|
|
// downloadedKnown reports whether the downloaded-subtitle list was loaded
|
|
// successfully. When it is false, an index that does not match an
|
|
// embedded/external track is honored rather than downgraded, because it may be a
|
|
// downloaded subtitle we could not enumerate — losing the user's choice on a
|
|
// transient lookup failure is worse than echoing an index whose stream is
|
|
// temporarily absent.
|
|
func resolveSelectedSubtitleStreamIndex(version catalog.FileVersion, downloadedCount int, downloadedKnown bool, requested, mediaDefault *int) *int {
|
|
if requested == nil {
|
|
return mediaDefault
|
|
}
|
|
if *requested < 0 {
|
|
return intPtr(-1)
|
|
}
|
|
if isValidCompatSubtitleStreamIndex(version, downloadedCount, *requested) {
|
|
return intPtr(*requested)
|
|
}
|
|
if !downloadedKnown {
|
|
return intPtr(*requested)
|
|
}
|
|
return mediaDefault
|
|
}
|
|
|
|
// effectiveCompatSubtitleStreamIndex returns the subtitle stream index to
|
|
// advertise as the default for a source: the explicit selection when present
|
|
// (collapsing "subtitles off" to none), otherwise the media default.
|
|
func effectiveCompatSubtitleStreamIndex(source PlaybackMediaSource) *int {
|
|
if source.SelectedSubtitleStreamIndex != nil {
|
|
if *source.SelectedSubtitleStreamIndex < 0 {
|
|
return nil
|
|
}
|
|
return intPtr(*source.SelectedSubtitleStreamIndex)
|
|
}
|
|
return source.DefaultSubtitleStreamIndex
|
|
}
|
|
|
|
func anyDefaultAudioTrack(tracks []models.AudioTrack) bool {
|
|
for _, track := range tracks {
|
|
if track.Default {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func parseCompatFrameRate(raw string) float64 {
|
|
if strings.TrimSpace(raw) == "" {
|
|
return 0
|
|
}
|
|
value, err := strconv.ParseFloat(raw, 64)
|
|
if err == nil {
|
|
return value
|
|
}
|
|
if strings.Contains(raw, "/") {
|
|
parts := strings.SplitN(raw, "/", 2)
|
|
if len(parts) == 2 {
|
|
num, numErr := strconv.ParseFloat(strings.TrimSpace(parts[0]), 64)
|
|
den, denErr := strconv.ParseFloat(strings.TrimSpace(parts[1]), 64)
|
|
if numErr == nil && denErr == nil && den != 0 {
|
|
return num / den
|
|
}
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// compatAudioSpatialFormat mirrors Jellyfin's MediaStream.AudioSpatialFormat,
|
|
// derived from the ffprobe profile string by case-insensitive substring match.
|
|
func compatAudioSpatialFormat(profile string) string {
|
|
lower := strings.ToLower(profile)
|
|
switch {
|
|
case strings.Contains(lower, "dolby atmos"):
|
|
return "DolbyAtmos"
|
|
case strings.Contains(lower, "dts:x"):
|
|
return "DTSX"
|
|
default:
|
|
return "None"
|
|
}
|
|
}
|
|
|
|
func audioTrackDisplayTitle(track models.AudioTrack) string {
|
|
lang := compatLanguageName(track.Language)
|
|
// Jellyfin prefers the ffprobe profile over the codec name in audio display
|
|
// titles (e.g. "DTS-HD MA", "Dolby Digital Plus + Dolby Atmos"), except the
|
|
// uninformative AAC "LC" profile.
|
|
codec := audioCodecDisplayName(track.Codec)
|
|
if profile := strings.TrimSpace(track.Profile); profile != "" && !strings.EqualFold(profile, "lc") {
|
|
codec = profile
|
|
}
|
|
channels := audioChannelsDisplayName(track.Channels)
|
|
title := strings.TrimSpace(codec + " " + channels)
|
|
if lang != "" {
|
|
title = lang + " - " + title
|
|
}
|
|
// Append embedded title only when it adds info beyond codec/channels (e.g. "Commentary").
|
|
embedded := strings.TrimSpace(firstNonEmpty(track.Title, track.EmbeddedTitle))
|
|
if !isGenericAudioLabel(embedded) {
|
|
title += " - " + embedded
|
|
}
|
|
return title
|
|
}
|
|
|
|
func audioCodecDisplayName(codec string) string {
|
|
switch normalizeCompatToken(codec) {
|
|
case "truehd":
|
|
return "TrueHD"
|
|
case "mlp":
|
|
return "MLP"
|
|
case "ac3":
|
|
return "AC3"
|
|
case "eac3":
|
|
return "EAC3"
|
|
case "dts":
|
|
return "DTS"
|
|
case "dtshd", "dtshd_ma":
|
|
return "DTS-HD MA"
|
|
case "aac":
|
|
return "AAC"
|
|
case "mp3":
|
|
return "MP3"
|
|
case "flac":
|
|
return "FLAC"
|
|
case "opus":
|
|
return "Opus"
|
|
case "vorbis":
|
|
return "Vorbis"
|
|
case "pcms16le", "pcms24le", "pcms32le", "pcmf32le":
|
|
return "PCM"
|
|
default:
|
|
return strings.ToUpper(strings.TrimSpace(codec))
|
|
}
|
|
}
|
|
|
|
func audioChannelsDisplayName(channels int) string {
|
|
switch channels {
|
|
case 1:
|
|
return "Mono"
|
|
case 2:
|
|
return "Stereo"
|
|
case 6:
|
|
return "5.1"
|
|
case 8:
|
|
return "7.1"
|
|
default:
|
|
if channels > 0 {
|
|
return fmt.Sprintf("%d ch", channels)
|
|
}
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func isGenericAudioLabel(s string) bool {
|
|
switch strings.ToLower(strings.TrimSpace(s)) {
|
|
case "", "stereo", "mono", "5.1", "7.1", "surround", "5.1 surround", "7.1 surround":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func compatSubtitleDisplayTitle(track catalog.VersionSubtitleTrack) string {
|
|
base := compatLanguageName(track.Language)
|
|
tags := make([]string, 0, 4)
|
|
if variant := subtitleVariantLabel(track.Codec, track.Language, track.Title, track.EmbeddedTitle, track.FileName); variant != "" {
|
|
tags = append(tags, variant)
|
|
}
|
|
if track.Forced {
|
|
tags = append(tags, "Forced")
|
|
}
|
|
if track.HearingImpaired || subtitleHasSDH(track.Title, track.EmbeddedTitle, track.FileName) {
|
|
tags = append(tags, "SDH")
|
|
}
|
|
if track.External {
|
|
tags = append(tags, "External")
|
|
} else {
|
|
tags = append(tags, "Embedded")
|
|
}
|
|
tags = append(tags, subtitleFormatLabel(track.Codec))
|
|
return formatSubtitleLabel(base, tags...)
|
|
}
|
|
|
|
func downloadedSubtitleDisplayTitle(sub subtitles.DownloadedSubtitle) string {
|
|
base := compatLanguageName(sub.Language)
|
|
tags := []string{"Downloaded"}
|
|
if sub.HearingImpaired {
|
|
tags = append(tags, "SDH")
|
|
}
|
|
tags = append(tags, subtitleFormatLabel(string(sub.Format)))
|
|
if provider := subtitleProviderLabel(sub.Provider); provider != "" {
|
|
tags = append(tags, provider)
|
|
}
|
|
return formatSubtitleLabel(base, tags...)
|
|
}
|
|
|
|
func compatLanguageName(code string) string {
|
|
trimmed := strings.TrimSpace(code)
|
|
if trimmed == "" {
|
|
return "Unknown"
|
|
}
|
|
normalized := strings.ToLower(trimmed)
|
|
if name, ok := compatLanguageNames[normalized]; ok {
|
|
return name
|
|
}
|
|
if len(normalized) > 3 {
|
|
return titleCaseWords(trimmed)
|
|
}
|
|
return strings.ToUpper(normalized)
|
|
}
|
|
|
|
func subtitleFormatLabel(codec string) string {
|
|
switch strings.ToLower(strings.TrimSpace(codec)) {
|
|
case "", "unknown":
|
|
return "Subtitle"
|
|
case "srt", "subrip":
|
|
return "SRT"
|
|
case "ass":
|
|
return "ASS"
|
|
case "ssa":
|
|
return "SSA"
|
|
case "vtt", "webvtt":
|
|
return "VTT"
|
|
case "mov_text":
|
|
return "MOV_TEXT"
|
|
case "hdmv_pgs_subtitle", "pgs":
|
|
return "PGS"
|
|
case "dvd_subtitle":
|
|
return "DVD_SUB"
|
|
case "dvb_subtitle":
|
|
return "DVB_SUB"
|
|
default:
|
|
return strings.ToUpper(strings.TrimSpace(codec))
|
|
}
|
|
}
|
|
|
|
func subtitleVariantLabel(codec, language string, values ...string) string {
|
|
for _, value := range values {
|
|
candidate := strings.TrimSpace(value)
|
|
if candidate == "" {
|
|
continue
|
|
}
|
|
if subtitleHasSDH(candidate) || subtitleTitleLooksGeneric(candidate, codec, language) || subtitleTitleLooksFilename(candidate) {
|
|
continue
|
|
}
|
|
return titleCaseWords(candidate)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func subtitleHasSDH(values ...string) bool {
|
|
for _, value := range values {
|
|
normalized := strings.ToLower(strings.TrimSpace(value))
|
|
switch {
|
|
case normalized == "sdh",
|
|
normalized == "cc",
|
|
normalized == "hi",
|
|
normalized == "hearing impaired",
|
|
normalized == "hearing-impaired",
|
|
normalized == "closed captions",
|
|
normalized == "closed caption",
|
|
strings.Contains(normalized, " sdh"),
|
|
strings.Contains(normalized, "cc "),
|
|
strings.Contains(normalized, "closed caption"),
|
|
strings.Contains(normalized, "hearing impaired"):
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func subtitleTitleLooksGeneric(title, codec, language string) bool {
|
|
normalized := strings.ToLower(strings.TrimSpace(title))
|
|
if normalized == "" {
|
|
return true
|
|
}
|
|
if normalized == strings.ToLower(strings.TrimSpace(codec)) || normalized == strings.ToLower(subtitleFormatLabel(codec)) {
|
|
return true
|
|
}
|
|
if normalized == strings.ToLower(strings.TrimSpace(language)) || normalized == strings.ToLower(compatLanguageName(language)) {
|
|
return true
|
|
}
|
|
switch normalized {
|
|
case "subtitle", "subtitles", "text", "subrip", "mov_text", "unknown":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func subtitleTitleLooksFilename(title string) bool {
|
|
trimmed := strings.TrimSpace(title)
|
|
if trimmed == "" {
|
|
return false
|
|
}
|
|
if filepath.Ext(trimmed) != "" {
|
|
return true
|
|
}
|
|
return strings.ContainsAny(trimmed, "/\\[]")
|
|
}
|
|
|
|
func subtitleProviderLabel(provider string) string {
|
|
switch strings.ToLower(strings.TrimSpace(provider)) {
|
|
case "":
|
|
return ""
|
|
case "opensubtitles":
|
|
return "OpenSubtitles"
|
|
case "subsource":
|
|
return "SubSource"
|
|
case "subdl":
|
|
return "SubDL"
|
|
default:
|
|
return titleCaseWords(provider)
|
|
}
|
|
}
|
|
|
|
func formatSubtitleLabel(base string, tags ...string) string {
|
|
unique := make([]string, 0, len(tags))
|
|
seen := make(map[string]struct{}, len(tags))
|
|
for _, tag := range tags {
|
|
trimmed := strings.TrimSpace(tag)
|
|
if trimmed == "" {
|
|
continue
|
|
}
|
|
key := strings.ToLower(trimmed)
|
|
if _, ok := seen[key]; ok {
|
|
continue
|
|
}
|
|
seen[key] = struct{}{}
|
|
unique = append(unique, trimmed)
|
|
}
|
|
if base == "" {
|
|
base = "Unknown"
|
|
}
|
|
if len(unique) == 0 {
|
|
return base
|
|
}
|
|
return fmt.Sprintf("%s (%s)", base, strings.Join(unique, ", "))
|
|
}
|
|
|
|
func titleCaseWords(value string) string {
|
|
parts := strings.FieldsFunc(strings.TrimSpace(value), func(r rune) bool {
|
|
return r == ' ' || r == '_' || r == '-'
|
|
})
|
|
if len(parts) == 0 {
|
|
return ""
|
|
}
|
|
for i, part := range parts {
|
|
if part == "" {
|
|
continue
|
|
}
|
|
upper := strings.ToUpper(part)
|
|
if part == upper && len(part) <= 5 {
|
|
parts[i] = upper
|
|
continue
|
|
}
|
|
parts[i] = strings.ToUpper(part[:1]) + strings.ToLower(part[1:])
|
|
}
|
|
return strings.Join(parts, " ")
|
|
}
|
|
|
|
func subtitleDeliveryMethod(external bool) string {
|
|
if external {
|
|
return "External"
|
|
}
|
|
return "Embed"
|
|
}
|
|
|
|
func downloadedSubtitlePath(version catalog.FileVersion, sub subtitles.DownloadedSubtitle) string {
|
|
name := strings.TrimSpace(downloadedSubtitleDisplayTitle(sub))
|
|
if name == "" {
|
|
name = mediaSourceName(version)
|
|
}
|
|
if name == "" {
|
|
name = "subtitle"
|
|
}
|
|
name = strings.ReplaceAll(name, "/", "-")
|
|
name = strings.ReplaceAll(name, "\\", "-")
|
|
filename := name + "." + subtitleRouteFormat(string(sub.Format))
|
|
return filepath.ToSlash(filepath.Join("/silo/subtitles", filename))
|
|
}
|
|
|
|
func subtitleDeliveryURL(routeItemID, mediaSourceID string, streamIndex int, format, compatToken, playSessionID string) string {
|
|
base := fmt.Sprintf("/Videos/%s/%s/Subtitles/%d/stream.%s", routeItemID, mediaSourceID, streamIndex, format)
|
|
query := url.Values{}
|
|
if compatToken != "" {
|
|
query.Set("api_key", compatToken)
|
|
}
|
|
if playSessionID != "" {
|
|
query.Set("PlaySessionId", playSessionID)
|
|
}
|
|
if encoded := query.Encode(); encoded != "" {
|
|
return base + "?" + encoded
|
|
}
|
|
return base
|
|
}
|
|
|
|
func subtitlePath(track catalog.VersionSubtitleTrack, routeItemID, mediaSourceID string, streamIndex int, format string) string {
|
|
if !track.External {
|
|
return ""
|
|
}
|
|
return fmt.Sprintf("/Videos/%s/%s/Subtitles/%d/stream.%s", routeItemID, mediaSourceID, streamIndex, format)
|
|
}
|
|
|
|
func subtitleExternalURL(track catalog.VersionSubtitleTrack) *bool {
|
|
if !track.External {
|
|
return nil
|
|
}
|
|
return boolPtr(false)
|
|
}
|
|
|
|
func subtitleRouteFormat(codec string) string {
|
|
switch strings.ToLower(strings.TrimSpace(codec)) {
|
|
case "ass", "ssa":
|
|
return "ass"
|
|
case "vtt", "webvtt":
|
|
return "vtt"
|
|
case "srt", "subrip":
|
|
return "srt"
|
|
default:
|
|
return "vtt"
|
|
}
|
|
}
|
|
|
|
func boolDefault(value *bool, fallback bool) bool {
|
|
if value == nil {
|
|
return fallback
|
|
}
|
|
return *value
|
|
}
|
|
|
|
func boolPtr(value bool) *bool {
|
|
return &value
|
|
}
|
|
|
|
func applyPlaybackQueryOverrides(req *playbackInfoRequest, query url.Values) {
|
|
if value := firstQueryValue(query, "UserId"); value != "" {
|
|
req.UserID = value
|
|
}
|
|
if value := firstQueryValue(query, "MediaSourceId"); value != "" {
|
|
req.MediaSourceID = value
|
|
}
|
|
if value, ok := parseOptionalInt(firstQueryValue(query, "AudioStreamIndex")); ok {
|
|
req.AudioStreamIndex = compatIntValuePtr(value)
|
|
}
|
|
if value, ok := parseOptionalInt(firstQueryValue(query, "SubtitleStreamIndex")); ok {
|
|
req.SubtitleStreamIndex = compatIntValuePtr(value)
|
|
}
|
|
if value, ok := parseOptionalBool(firstQueryValue(query, "EnableDirectPlay")); ok {
|
|
req.EnableDirectPlay = &value
|
|
}
|
|
if value, ok := parseOptionalBool(firstQueryValue(query, "EnableDirectStream")); ok {
|
|
req.EnableDirectStream = &value
|
|
}
|
|
if value, ok := parseOptionalBool(firstQueryValue(query, "EnableTranscoding")); ok {
|
|
req.EnableTranscoding = &value
|
|
}
|
|
if value, ok := parseOptionalBool(firstQueryValue(query, "AllowVideoStreamCopy")); ok {
|
|
req.AllowVideoStreamCopy = &value
|
|
}
|
|
if value, ok := parseOptionalBool(firstQueryValue(query, "AllowAudioStreamCopy")); ok {
|
|
req.AllowAudioStreamCopy = &value
|
|
}
|
|
}
|
|
|
|
func firstQueryValue(values url.Values, key string) string {
|
|
for currentKey, entries := range values {
|
|
if strings.EqualFold(currentKey, key) && len(entries) > 0 {
|
|
return strings.TrimSpace(entries[0])
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func parseOptionalBool(raw string) (bool, bool) {
|
|
if strings.TrimSpace(raw) == "" {
|
|
return false, false
|
|
}
|
|
value, err := strconv.ParseBool(raw)
|
|
if err != nil {
|
|
return false, false
|
|
}
|
|
return value, true
|
|
}
|
|
|
|
func parseOptionalInt(raw string) (int, bool) {
|
|
if strings.TrimSpace(raw) == "" {
|
|
return 0, false
|
|
}
|
|
value, err := strconv.Atoi(raw)
|
|
if err != nil {
|
|
return 0, false
|
|
}
|
|
return value, true
|
|
}
|
|
|
|
type compatIntValue int
|
|
|
|
func (v *compatIntValue) UnmarshalJSON(data []byte) error {
|
|
trimmed := strings.TrimSpace(string(data))
|
|
if trimmed == "" || trimmed == "null" {
|
|
return nil
|
|
}
|
|
|
|
var number int
|
|
if err := json.Unmarshal(data, &number); err == nil {
|
|
*v = compatIntValue(number)
|
|
return nil
|
|
}
|
|
|
|
var raw string
|
|
if err := json.Unmarshal(data, &raw); err != nil {
|
|
return err
|
|
}
|
|
parsed, err := strconv.Atoi(strings.TrimSpace(raw))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
*v = compatIntValue(parsed)
|
|
return nil
|
|
}
|
|
|
|
func compatIntValuePtr(value int) *compatIntValue {
|
|
v := compatIntValue(value)
|
|
return &v
|
|
}
|
|
|
|
func (h *PlaybackHandler) playbackUnavailable(w http.ResponseWriter, err error) {
|
|
switch {
|
|
case errors.Is(err, ErrSessionNotFound):
|
|
writeError(w, http.StatusUnauthorized, "Unauthorized", "Authentication failed")
|
|
default:
|
|
writeCompatUpstreamError(w, err)
|
|
}
|
|
}
|