263 lines
8.1 KiB
Go
263 lines
8.1 KiB
Go
// 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
|
|
AllowHEVCEncoding bool
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
type VersionSelectionFilter struct {
|
|
EditionKey string
|
|
PresentationKind string
|
|
PresentationGroupKey string
|
|
}
|
|
|
|
// 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",
|
|
}
|
|
}
|
|
|
|
// Case 2: Client supports codecs but not container → remux.
|
|
if videoOK && audioOK && !containerOK {
|
|
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 {
|
|
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",
|
|
}
|
|
}
|
|
|
|
// SelectVersion chooses the best file version from a list of available files
|
|
// based on client capabilities and admin settings.
|
|
// Priority: direct-playable > remux > transcode, then highest quality, then smallest file.
|
|
func SelectVersion(files []*models.MediaFile, caps ClientCapabilities, settings AdminSettings) (*PlayDecision, error) {
|
|
return SelectVersionFiltered(files, caps, settings, VersionSelectionFilter{})
|
|
}
|
|
|
|
// SelectVersionFiltered chooses the best interchangeable file version within
|
|
// one edition/presentation group.
|
|
func SelectVersionFiltered(
|
|
files []*models.MediaFile,
|
|
caps ClientCapabilities,
|
|
settings AdminSettings,
|
|
filter VersionSelectionFilter,
|
|
) (*PlayDecision, error) {
|
|
if len(files) == 0 {
|
|
return nil, ErrNoVersions
|
|
}
|
|
|
|
candidates := files
|
|
if filter.EditionKey != "" || filter.PresentationKind != "" || filter.PresentationGroupKey != "" {
|
|
filtered := make([]*models.MediaFile, 0, len(files))
|
|
for _, f := range files {
|
|
if filter.EditionKey != "" && f.EditionKey != filter.EditionKey {
|
|
continue
|
|
}
|
|
if filter.PresentationKind != "" && f.PresentationKind != filter.PresentationKind {
|
|
continue
|
|
}
|
|
if filter.PresentationGroupKey != "" && f.PresentationGroupKey != filter.PresentationGroupKey {
|
|
continue
|
|
}
|
|
filtered = append(filtered, f)
|
|
}
|
|
if len(filtered) > 0 {
|
|
candidates = filtered
|
|
}
|
|
}
|
|
|
|
var directFiles, remuxFiles, transcodeFiles []*PlayDecision
|
|
|
|
for _, f := range candidates {
|
|
// Filter by client max resolution.
|
|
if !resolutionFits(f.Resolution, caps.MaxResolution) {
|
|
// If 4K transcoding disabled and this is 4K, skip entirely.
|
|
if is4K(f.Resolution) && !settings.Allow4KTranscode {
|
|
continue
|
|
}
|
|
}
|
|
|
|
decision := Resolve(f, caps, settings)
|
|
|
|
switch decision.Method {
|
|
case PlayDirect:
|
|
directFiles = append(directFiles, decision)
|
|
case PlayRemux:
|
|
remuxFiles = append(remuxFiles, decision)
|
|
case PlayTranscode:
|
|
transcodeFiles = append(transcodeFiles, decision)
|
|
}
|
|
}
|
|
|
|
// Prefer direct > remux > transcode.
|
|
if len(directFiles) > 0 {
|
|
return bestQuality(directFiles), nil
|
|
}
|
|
if len(remuxFiles) > 0 {
|
|
return bestQuality(remuxFiles), nil
|
|
}
|
|
if len(transcodeFiles) > 0 {
|
|
return bestQuality(transcodeFiles), nil
|
|
}
|
|
|
|
// Fallback: use first file with direct play.
|
|
return &PlayDecision{
|
|
Method: PlayDirect,
|
|
File: candidates[0],
|
|
Reason: "no compatible version found; falling back to first file",
|
|
}, nil
|
|
}
|
|
|
|
// bestQuality picks the highest quality file. Among ties, picks smallest file.
|
|
func bestQuality(decisions []*PlayDecision) *PlayDecision {
|
|
best := decisions[0]
|
|
for _, d := range decisions[1:] {
|
|
if access.CompareQuality(d.File.Resolution, best.File.Resolution) > 0 {
|
|
best = d
|
|
} else if access.CompareQuality(d.File.Resolution, best.File.Resolution) == 0 && d.File.FileSize < best.File.FileSize {
|
|
best = d
|
|
}
|
|
}
|
|
return best
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// is4K returns true if the resolution is 2160p or higher.
|
|
func is4K(res string) bool {
|
|
return access.CompareQuality(res, "2160p") >= 0
|
|
}
|
|
|
|
// containsStr checks if a slice contains a string.
|
|
func containsStr(slice []string, s string) bool {
|
|
return slices.Contains(slice, s)
|
|
}
|