Files
silo-server/internal/playback/resolver.go
T

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)
}