2026-05-22 20:26:11 -04:00
|
|
|
// Package playback provides play method resolution, streaming, transcoding,
|
|
|
|
|
// and session management for Silo.
|
|
|
|
|
package playback
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"slices"
|
|
|
|
|
|
|
|
|
|
"github.com/Silo-Server/silo-server/internal/access"
|
|
|
|
|
"github.com/Silo-Server/silo-server/internal/models"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// PlayMethod represents how a media file will be streamed.
|
|
|
|
|
type PlayMethod string
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
PlayDirect PlayMethod = "direct"
|
|
|
|
|
PlayRemux PlayMethod = "remux"
|
|
|
|
|
PlayTranscode PlayMethod = "transcode"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// ClientCapabilities describes what the client can play natively.
|
|
|
|
|
//
|
|
|
|
|
// AudioPassthroughCodecs are codecs the connected audio sink can decode bit-
|
|
|
|
|
// exact (e.g. an HDMI AVR accepting EAC3/Atmos). They are treated as supported
|
|
|
|
|
// audio codecs for resolution purposes so we can stream-copy surround audio
|
|
|
|
|
// instead of downmixing+re-encoding to AAC. Distinct from CodecsAudio, which
|
|
|
|
|
// describes what the client itself can decode.
|
|
|
|
|
type ClientCapabilities struct {
|
|
|
|
|
CodecsVideo []string `json:"codecs_video"` // e.g., h264, hevc, av1
|
|
|
|
|
CodecsAudio []string `json:"codecs_audio"` // e.g., aac, opus, flac
|
|
|
|
|
AudioPassthroughCodecs []string `json:"audio_passthrough_codecs,omitempty"`
|
|
|
|
|
Containers []string `json:"containers"` // e.g., mp4, webm, mkv
|
|
|
|
|
MaxResolution string `json:"max_resolution"` // e.g., 1080p, 2160p
|
|
|
|
|
HDR bool `json:"hdr"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// AdminSettings controls server-side playback constraints.
|
|
|
|
|
type AdminSettings struct {
|
2026-06-10 19:25:07 -04:00
|
|
|
TranscodeEnabled bool
|
|
|
|
|
Allow4KTranscode bool
|
2026-05-22 20:26:11 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// PlayDecision is the result of resolving how to play a file.
|
|
|
|
|
type PlayDecision struct {
|
|
|
|
|
Method PlayMethod
|
|
|
|
|
File *models.MediaFile
|
|
|
|
|
Reason string // human-readable explanation
|
|
|
|
|
TranscodeAudio bool // true when remuxing should transcode audio to AAC
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Resolve determines the play method for a given file and client capabilities.
|
|
|
|
|
// Returns direct if client supports codec+container, remux if codec matches
|
|
|
|
|
// but container doesn't, transcode otherwise.
|
|
|
|
|
func Resolve(file *models.MediaFile, caps ClientCapabilities, settings AdminSettings) *PlayDecision {
|
|
|
|
|
// Check if client supports the video codec.
|
|
|
|
|
videoOK := containsStr(caps.CodecsVideo, file.CodecVideo)
|
|
|
|
|
// Audio is considered OK if the client can decode the codec itself OR its
|
|
|
|
|
// sink can passthrough it. Passthrough lets us stream-copy surround audio
|
|
|
|
|
// (EAC3/AC3/DTS/TrueHD) to HDMI AVRs instead of re-encoding to stereo AAC.
|
|
|
|
|
audioOK := containsStr(caps.CodecsAudio, file.CodecAudio) ||
|
|
|
|
|
containsStr(caps.AudioPassthroughCodecs, file.CodecAudio)
|
|
|
|
|
// Check if client supports the container.
|
|
|
|
|
containerOK := containsStr(caps.Containers, file.Container)
|
|
|
|
|
|
|
|
|
|
// Check resolution constraint.
|
|
|
|
|
if !resolutionFits(file.Resolution, caps.MaxResolution) {
|
|
|
|
|
if !settings.TranscodeEnabled {
|
|
|
|
|
return &PlayDecision{
|
|
|
|
|
Method: PlayDirect,
|
|
|
|
|
File: file,
|
|
|
|
|
Reason: "file resolution exceeds client max but transcode disabled; attempting direct",
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// Need transcode to lower resolution.
|
|
|
|
|
return &PlayDecision{
|
|
|
|
|
Method: PlayTranscode,
|
|
|
|
|
File: file,
|
|
|
|
|
Reason: "file resolution exceeds client max resolution",
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Case 1: Client supports codec + container → direct play.
|
|
|
|
|
if videoOK && audioOK && containerOK {
|
|
|
|
|
return &PlayDecision{
|
|
|
|
|
Method: PlayDirect,
|
|
|
|
|
File: file,
|
|
|
|
|
Reason: "client supports all codecs and container",
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-17 16:26:08 +00:00
|
|
|
// A copy-unsafe source (H.264 with conflicting in-band PPS) cannot take a
|
|
|
|
|
// video stream-copy route: the remux would desync strict decoders. Force it
|
|
|
|
|
// past the remux cases into a full video transcode. Direct play of the
|
|
|
|
|
// original file (Case 1) stays available — decoders that reparse in-band
|
|
|
|
|
// parameter sets handle the original container fine.
|
|
|
|
|
copyUnsafe := videoCopyUnsafeFile(file)
|
|
|
|
|
|
2026-05-22 20:26:11 -04:00
|
|
|
// Case 2: Client supports codecs but not container → remux.
|
2026-07-17 16:26:08 +00:00
|
|
|
if videoOK && audioOK && !containerOK && !copyUnsafe {
|
2026-05-22 20:26:11 -04:00
|
|
|
return &PlayDecision{
|
|
|
|
|
Method: PlayRemux,
|
|
|
|
|
File: file,
|
|
|
|
|
Reason: "client supports codecs but not container; remuxing",
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Case 3: Video OK but audio codec unsupported → remux with audio transcode.
|
|
|
|
|
// This is much cheaper than a full video transcode.
|
2026-07-17 16:26:08 +00:00
|
|
|
if videoOK && !audioOK && !copyUnsafe {
|
2026-05-22 20:26:11 -04:00
|
|
|
return &PlayDecision{
|
|
|
|
|
Method: PlayRemux,
|
|
|
|
|
File: file,
|
|
|
|
|
TranscodeAudio: true,
|
|
|
|
|
Reason: "client supports video codec but not audio; remuxing with audio transcode to AAC",
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Case 4: Client can't play video codec → full transcode.
|
|
|
|
|
if !settings.TranscodeEnabled {
|
|
|
|
|
return &PlayDecision{
|
|
|
|
|
Method: PlayDirect,
|
|
|
|
|
File: file,
|
|
|
|
|
Reason: "transcode needed but disabled; attempting direct play",
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return &PlayDecision{
|
|
|
|
|
Method: PlayTranscode,
|
|
|
|
|
File: file,
|
|
|
|
|
Reason: "client cannot play video codec; transcoding",
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// resolutionOrder returns a numeric value for sorting resolutions.
|
|
|
|
|
func resolutionOrder(res string) int {
|
|
|
|
|
switch {
|
|
|
|
|
case access.CompareQuality(res, "4320p") == 0:
|
|
|
|
|
return 5
|
|
|
|
|
case access.CompareQuality(res, "2160p") == 0:
|
|
|
|
|
return 4
|
|
|
|
|
case access.CompareQuality(res, "1080p") == 0:
|
|
|
|
|
return 3
|
|
|
|
|
case access.CompareQuality(res, "720p") == 0:
|
|
|
|
|
return 2
|
|
|
|
|
case access.CompareQuality(res, "480p") == 0:
|
|
|
|
|
return 1
|
|
|
|
|
default:
|
|
|
|
|
return 0
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// resolutionFits checks if the file resolution fits within the client's max.
|
|
|
|
|
func resolutionFits(fileRes, maxRes string) bool {
|
|
|
|
|
if maxRes == "" {
|
|
|
|
|
return true // no constraint
|
|
|
|
|
}
|
|
|
|
|
return resolutionOrder(fileRes) <= resolutionOrder(maxRes)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// containsStr checks if a slice contains a string.
|
|
|
|
|
func containsStr(slice []string, s string) bool {
|
|
|
|
|
return slices.Contains(slice, s)
|
|
|
|
|
}
|
2026-07-17 16:26:08 +00:00
|
|
|
|
|
|
|
|
// videoCopyUnsafeFile reports whether the file's video stream cannot be safely
|
|
|
|
|
// stream-copied into an avc1/fMP4 segment. It is set once by the multi-PPS
|
|
|
|
|
// bitstream scan (H.264 sources that redefine a pic_parameter_set_id in-band
|
2026-07-29 00:38:30 +00:00
|
|
|
// with conflicting content). Scan failures also disable copy for the current
|
|
|
|
|
// decision while remaining eligible for retry on a later request.
|
2026-07-17 16:26:08 +00:00
|
|
|
func videoCopyUnsafeFile(file *models.MediaFile) bool {
|
|
|
|
|
if file == nil || len(file.VideoTracks) == 0 {
|
|
|
|
|
return false
|
|
|
|
|
}
|
2026-07-29 00:38:30 +00:00
|
|
|
track := file.VideoTracks[0]
|
|
|
|
|
return track.VideoCopyUnsafe || (track.MultiplePPS != nil && *track.MultiplePPS)
|
2026-07-17 16:26:08 +00:00
|
|
|
}
|