Files

177 lines
6.1 KiB
Go
Raw Permalink Normal View History

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 {
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",
}
}
// 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.
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.
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)
}
// 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
// with conflicting content). Scan failures also disable copy for the current
// decision while remaining eligible for retry on a later request.
func videoCopyUnsafeFile(file *models.MediaFile) bool {
if file == nil || len(file.VideoTracks) == 0 {
return false
}
track := file.VideoTracks[0]
return track.VideoCopyUnsafe || (track.MultiplePPS != nil && *track.MultiplePPS)
}