1218 lines
39 KiB
Go
1218 lines
39 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"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"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"
|
|
)
|
|
|
|
type playbackInfoRequest struct {
|
|
UserID string `json:"UserId"`
|
|
MediaSourceID string `json:"MediaSourceId"`
|
|
AudioStreamIndex *compatIntValue `json:"AudioStreamIndex,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
|
|
}
|
|
|
|
type sessionStarterContext interface {
|
|
StartSessionWithContext(ctx context.Context, userID int, profileID string, fileID int, method playback.PlayMethod, transcodeAudio bool) (*playback.Session, error)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// PlaybackHandler serves Jellyfin playback negotiation endpoints.
|
|
type PlaybackHandler struct {
|
|
cfg *config.Config
|
|
content ContentService
|
|
codec *ResourceIDCodec
|
|
deviceProfiles *DeviceProfileStore
|
|
playbackStore *PlaybackSessionStore
|
|
sessionMgr SessionManagerInterface
|
|
fileResolver FilePathResolver
|
|
storeProvider userstore.UserStoreProvider
|
|
ProxyPool *nodepool.ProxyPool
|
|
TranscodePool *nodepool.TranscodePool
|
|
JWTSecret string
|
|
profileStaler profileStaler
|
|
profileRefreshRequester profileRefreshRequester
|
|
FFmpegPath string
|
|
HWAccel string
|
|
TranscodeDir string
|
|
transcodeMu sync.RWMutex
|
|
transcodes map[string]*playback.TranscodeSession
|
|
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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// NewPlaybackHandler creates a playback handler.
|
|
func NewPlaybackHandler(
|
|
cfg *config.Config,
|
|
content ContentService,
|
|
codec *ResourceIDCodec,
|
|
deviceProfiles *DeviceProfileStore,
|
|
playbackStore *PlaybackSessionStore,
|
|
sessionMgr SessionManagerInterface,
|
|
fileResolver FilePathResolver,
|
|
storeProvider userstore.UserStoreProvider,
|
|
) *PlaybackHandler {
|
|
transcodeDir := filepath.Join(os.TempDir(), "silo-transcode")
|
|
ffmpegPath := ""
|
|
hwAccel := ""
|
|
if cfg != nil {
|
|
if cfg.Playback.TranscodeDir != "" {
|
|
transcodeDir = cfg.Playback.TranscodeDir
|
|
}
|
|
ffmpegPath = cfg.Playback.FFmpegPath
|
|
hwAccel = cfg.Playback.HWAccel
|
|
}
|
|
|
|
h := &PlaybackHandler{
|
|
cfg: cfg,
|
|
content: content,
|
|
codec: codec,
|
|
deviceProfiles: deviceProfiles,
|
|
playbackStore: playbackStore,
|
|
sessionMgr: sessionMgr,
|
|
fileResolver: fileResolver,
|
|
storeProvider: storeProvider,
|
|
FFmpegPath: ffmpegPath,
|
|
HWAccel: hwAccel,
|
|
TranscodeDir: transcodeDir,
|
|
transcodes: make(map[string]*playback.TranscodeSession),
|
|
}
|
|
if cleaned, err := playback.CleanupOrphanedTranscodeDirs(h.TranscodeDir, nil); err != nil {
|
|
slog.Warn("jellycompat transcode cleanup failed", "dir", h.TranscodeDir, "error", err)
|
|
} else if cleaned > 0 {
|
|
slog.Info("jellycompat transcode cleanup removed orphaned dirs", "dir", h.TranscodeDir, "count", cleaned)
|
|
}
|
|
return h
|
|
}
|
|
|
|
func (h *PlaybackHandler) pickTranscodeNode(currentURL string) *nodepool.Node {
|
|
if h.TranscodePool == nil {
|
|
return nil
|
|
}
|
|
best := h.TranscodePool.Acquire()
|
|
if best == nil {
|
|
return nil
|
|
}
|
|
if currentURL == "" || best.URL == currentURL {
|
|
return best
|
|
}
|
|
current := h.TranscodePool.FindByURL(currentURL)
|
|
if current != nil && current.Healthy && current.Enabled &&
|
|
best.ActiveJobs+2 <= current.ActiveJobs {
|
|
return best
|
|
}
|
|
if current != nil && current.Healthy && current.Enabled {
|
|
return current
|
|
}
|
|
return best
|
|
}
|
|
|
|
func (h *PlaybackHandler) buildProxyRedirectURL(
|
|
playSessionID string,
|
|
upstreamSessionID string,
|
|
method string,
|
|
file *models.MediaFile,
|
|
source PlaybackMediaSource,
|
|
transcodeNodeURL string,
|
|
seekSeconds float64,
|
|
) (string, error) {
|
|
if h.ProxyPool == nil || h.JWTSecret == "" {
|
|
return "", fmt.Errorf("proxy transport unavailable")
|
|
}
|
|
proxyNode := h.ProxyPool.Pick()
|
|
if proxyNode == nil {
|
|
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,
|
|
TranscodeNode: transcodeNodeURL,
|
|
}
|
|
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", 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,
|
|
upstreamSessionID string,
|
|
source PlaybackMediaSource,
|
|
file *models.MediaFile,
|
|
initialSeekSeconds float64,
|
|
transcodeNodeURL string,
|
|
) error {
|
|
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))
|
|
}
|
|
|
|
reqBody := transcodenode.TranscodeStartRequest{
|
|
SessionID: upstreamSessionID,
|
|
InputPath: file.FilePath,
|
|
SeekSeconds: initialSeekSeconds,
|
|
StartSegmentNumber: startSegmentNumber,
|
|
TargetCodecVideo: "h264",
|
|
TargetCodecAudio: "aac",
|
|
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)
|
|
}
|
|
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)
|
|
}
|
|
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
|
|
}
|
|
if req.UserID != "" && req.UserID != session.PseudoUserID.String() {
|
|
writeError(w, http.StatusNotFound, "NotFound", "User not found")
|
|
return
|
|
}
|
|
|
|
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))
|
|
|
|
for _, version := range detail.Versions {
|
|
source := h.buildPlaybackSource(routeItemID, playSessionID, version, profile, req)
|
|
if req.MediaSourceID != "" && source.ID != req.MediaSourceID {
|
|
continue
|
|
}
|
|
sources = append(sources, source)
|
|
dto := h.mediaSourceDTO(routeItemID, playSessionID, session.Token, source)
|
|
// Append downloaded subtitles to the media streams.
|
|
if h.SubtitleRepo != nil {
|
|
downloaded, _ := h.SubtitleRepo.ListDownloadedSubtitles(r.Context(), source.Version.FileID)
|
|
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: false,
|
|
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
|
|
}
|
|
|
|
h.playbackStore.Put(PlaybackSession{
|
|
ID: playSessionID,
|
|
CompatToken: session.Token,
|
|
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,
|
|
})
|
|
}
|
|
|
|
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,
|
|
) 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)
|
|
|
|
supportsDirectPlay := enableDirectPlay && profile.SupportsDirectPlay(version)
|
|
audioSupported := profile.SupportsAudioCodecForDirectStream(version)
|
|
videoSupported := profile.SupportsVideoCodecForDirectStream(version)
|
|
transcodeAudio := enableDirectStream && allowVideoCopy && videoSupported && !audioSupported
|
|
supportsDirectStream := !transcodeAudio &&
|
|
enableDirectStream &&
|
|
allowVideoCopy &&
|
|
videoSupported &&
|
|
allowAudioCopy &&
|
|
audioSupported
|
|
supportsTranscoding := boolDefault(req.EnableTranscoding, true) && profile.SupportsTranscoding(version)
|
|
|
|
audioIndex := defaultAudioStreamIndex(version)
|
|
subtitleIndex := defaultSubtitleStreamIndex(version)
|
|
selectedAudioIndex := audioIndex
|
|
if req.AudioStreamIndex != nil && isValidCompatAudioStreamIndex(version, int(*req.AudioStreamIndex)) {
|
|
selectedAudioIndex = intPtr(int(*req.AudioStreamIndex))
|
|
}
|
|
|
|
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: source.DefaultSubtitleStreamIndex,
|
|
MediaStreams: buildMediaStreamsWithSelection(routeItemID, source.ID, source.Version, selectedAudioStreamIndex, 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, "", "")
|
|
}
|
|
|
|
func buildMediaStreamsWithSelection(routeItemID, mediaSourceID string, version catalog.FileVersion, selectedAudioStreamIndex *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: firstNonEmpty(track.VideoRange, "Unknown"),
|
|
VideoRangeType: firstNonEmpty(track.VideoRange, "Unknown"),
|
|
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: firstNonEmpty(track.Title, track.EmbeddedTitle, track.Language, track.Codec),
|
|
Title: firstNonEmpty(track.Title, track.EmbeddedTitle),
|
|
IsDefault: isDefault,
|
|
IsExternal: false,
|
|
IsForced: false,
|
|
IsHearingImpaired: false,
|
|
IsTextSubtitleStream: false,
|
|
SupportsExternalStream: false,
|
|
AudioSpatialFormat: "None",
|
|
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)
|
|
streams = append(streams, mediaStreamDTO{
|
|
Index: streamIndex,
|
|
Type: "Subtitle",
|
|
Codec: strings.ToLower(track.Codec),
|
|
Language: track.Language,
|
|
TimeBase: "1/1000",
|
|
DisplayTitle: displayTitle,
|
|
Title: displayTitle,
|
|
IsDefault: track.Default,
|
|
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 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
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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 := 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)
|
|
}
|
|
}
|