* feat(playback): plan v3 routes from pooled node capabilities Protocol v3 planning previously gated every server transformation on the API host's local ffmpeg probe, so deployments whose toolchain lives on transcode nodes (libx264/aac/dovi_rpu on nodes, minimal binary locally) received conversion terminals before transport preparation ever consulted the selected node's capabilities. Planning now draws on two registries split by executor pool: - Registry stays the local probe and keeps gating progressive remux routes, which execute in this process and can never offload. - HLSRegistry widens availability for HLS deliveries with the pooled transcode nodes' advertised transformations (name and recipe version pinned to the local specs), fetched concurrently under a short planning deadline through the existing TTL cache. Failures are now negatively cached so an unreachable node costs one timeout per window rather than one per start. The remux family picks the executor per branch: a recipe needing transformations only nodes carry skips the progressive remux and ships the same recipe on the HLS remux delivery instead. The local-fallback path in prepareTransportV3 now validates plans against the local registry's advertised set — mirroring the per-node validation — and returns the existing retryable transcode_node_capability_unavailable terminal when no executor can run the recipe, instead of spawning an ffmpeg that would fail at runtime. Deferred from PR #398 review (comment 3579105380). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(playback): harden union capability planning from review Addresses all four review findings on the capability-union feature: - Select capability-matching nodes: plans carrying server transformations now restrict node selection to nodes whose advertised capabilities validate against the plan (nodepool.PlanSessionWith with a set-lookup predicate), so heterogeneous pools cannot load-balance a recipe onto a node that would reject it while a capable sibling exists. Transformation-free plans keep pure load-based selection. - Split the capability cache by consumer: planning honors negatively cached fetch failures (one timeout per window), while the transport path fetches through them — a memoized 3s planning deadline must not reject an already-selected node that the 10s transport budget could still validate. - Gate node-widened availability on the HLS engine: a progressive-only client that needs audio conversion keeps its specific retryable audio_conversion_unsupported terminal instead of falling through to a non-retryable adaptation_unavailable for routes it can never run; the DV strip union flag is gated identically. - Make HLSRegistry a lazy, memoized producer: the planner only builds the widened registry when a route decision depends on node capabilities, so direct-play and other source-preserving starts never wait on node capability fetches (or their dead-node deadlines). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
122 lines
6.1 KiB
Go
122 lines
6.1 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"runtime/debug"
|
|
"time"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/models"
|
|
"github.com/Silo-Server/silo-server/internal/playback"
|
|
)
|
|
|
|
const playbackV3ShadowSetting = "playback.protocol_v3_shadow_enabled"
|
|
|
|
func (h *PlaybackHandler) protocolV3ShadowEnabled(ctx context.Context) bool {
|
|
return h.settingFlagCachedV3(ctx, playbackV3ShadowSetting)
|
|
}
|
|
|
|
// shadowLegacyPlaybackV3 compares the production legacy route with a v3 plan
|
|
// without advertising or executing that plan. Legacy capabilities lack the
|
|
// detailed v3 fields, so exact source facts are used only as an explicitly
|
|
// labelled validation inference; this is never a production compatibility
|
|
// claim and cannot enable v3 playback.
|
|
func (h *PlaybackHandler) shadowLegacyPlaybackV3(ctx context.Context, req startPlaybackRequest, requestedFile, effectiveFile *models.MediaFile, audioIndex int, productionMethod playback.PlayMethod, productionTranscodeAudio bool, sessionID string) {
|
|
// The shadow planner runs on a bare goroutine off the production start
|
|
// path; an escaped panic here would take down the whole process for a
|
|
// telemetry-only comparison, so it must fail closed instead.
|
|
defer func() {
|
|
if recovered := recover(); recovered != nil {
|
|
slog.ErrorContext(ctx, "playback v3 shadow planner panicked",
|
|
"component", "playback", "playback_session_id", sessionID,
|
|
"panic", recovered, "stack", string(debug.Stack()))
|
|
}
|
|
}()
|
|
if !h.protocolV3ShadowEnabled(ctx) || requestedFile == nil || effectiveFile == nil {
|
|
return
|
|
}
|
|
request := legacyShadowRequestV3(req, effectiveFile, audioIndex, sessionID)
|
|
result := playback.PlanPlaybackV3(playback.PlannerInputV3{
|
|
Request: request,
|
|
RequestedFile: requestedFile,
|
|
EffectiveFile: effectiveFile,
|
|
AudioTrackIndex: audioIndex,
|
|
Settings: h.plannerSettingsV3(ctx),
|
|
Registry: h.transformationRegistryV3(ctx),
|
|
HLSRegistry: h.lazyHLSPlanningRegistryV3(ctx),
|
|
Now: time.Now(),
|
|
})
|
|
attrs := []any{
|
|
"component", "playback",
|
|
"playback_session_id", sessionID,
|
|
"requested_file_id", requestedFile.ID,
|
|
"effective_file_id", effectiveFile.ID,
|
|
"production_method", productionMethod,
|
|
"production_transcode_audio", productionTranscodeAudio,
|
|
"capability_basis", "legacy_source_exact_inference",
|
|
}
|
|
if result.Plan != nil {
|
|
attrs = append(attrs,
|
|
"shadow_plan_id", result.Plan.PlanID,
|
|
"shadow_delivery", result.Plan.Delivery,
|
|
"shadow_method", result.PlayMethod,
|
|
"shadow_transcode_audio", result.TranscodeAudio,
|
|
"shadow_reason", result.Plan.DecisionReason,
|
|
"route_match", result.PlayMethod == productionMethod && result.TranscodeAudio == productionTranscodeAudio,
|
|
)
|
|
} else if result.Terminal != nil {
|
|
attrs = append(attrs, "shadow_terminal", result.Terminal.Reason, "route_match", false)
|
|
}
|
|
slog.InfoContext(ctx, "playback v3 shadow decision", attrs...)
|
|
}
|
|
|
|
func legacyShadowRequestV3(req startPlaybackRequest, file *models.MediaFile, audioIndex int, sessionID string) playback.StartRequestV3 {
|
|
source := playback.SourceDescriptorFromFileV3(file, audioIndex)
|
|
hdr := legacyShadowHDRV3(req)
|
|
features := []string{playback.FeaturePlaybackPlanV3, playback.FeatureMedia3Only, playback.FeatureDetailedDecodeV3}
|
|
capabilities := playback.ClientCodecCapabilitiesV3{
|
|
CodecsVideo: append([]string(nil), req.CodecsVideo...),
|
|
CodecsAudio: append([]string(nil), req.CodecsAudio...),
|
|
Containers: append([]string(nil), req.Containers...),
|
|
MaxResolution: req.MaxResolution,
|
|
HDR: req.HDR,
|
|
HDRDetails: hdr,
|
|
}
|
|
if containsStringFoldV3(req.CodecsVideo, source.VideoCodec) {
|
|
capabilities.VideoDecode = []playback.VideoDecodeCapabilityV3{{
|
|
Codec: source.VideoCodec, Profiles: []string{source.VideoProfile}, Levels: []int{source.VideoLevel}, BitDepths: []int{source.BitDepth},
|
|
MaxWidth: source.Width, MaxHeight: source.Height, MaxFrameRate: source.FrameRate, MaxBitrateKbps: source.BitrateKbps, Hardware: true,
|
|
}}
|
|
}
|
|
var passthrough *playback.AudioPassthroughV3
|
|
if req.AudioPassthrough != nil {
|
|
passthrough = &playback.AudioPassthroughV3{PassthroughCodecs: append([]string(nil), req.AudioPassthrough.PassthroughCodecs...), SpatializerEnabled: req.AudioPassthrough.SpatializerEnabled, MaxChannels: req.AudioPassthrough.MaxChannels}
|
|
if containsStringFoldV3(req.AudioPassthrough.PassthroughCodecs, source.AudioCodec) && source.AudioChannels > 0 && source.AudioLayout != "" && source.AudioChannels <= req.AudioPassthrough.MaxChannels {
|
|
passthrough.Entries = []playback.AudioPassthroughEntryV3{{Codec: source.AudioCodec, ChannelCounts: []int{source.AudioChannels}, Layouts: []string{source.AudioLayout}}}
|
|
features = append(features, playback.FeatureLayoutPassthrough)
|
|
}
|
|
capabilities.AudioPassthrough = passthrough
|
|
}
|
|
engines := map[string]playback.EngineCapabilityV3{
|
|
string(playback.EngineMedia3DirectV3): {Enabled: true, SupportedOnDevice: true, Subtitles: playback.EngineSubtitleCapabilitiesV3{EmbeddedText: true, SidecarText: true, EmbeddedBitmap: true, SidecarBitmap: true}},
|
|
string(playback.EngineMedia3ProgressiveRemuxV3): {Enabled: true, SupportedOnDevice: true},
|
|
string(playback.EngineMedia3HLSV3): {Enabled: true, SupportedOnDevice: true},
|
|
}
|
|
return playback.StartRequestV3{
|
|
ProtocolVersion: playback.ProtocolV3, ClientFeatures: features, FileID: file.ID, ProfileID: req.ProfileID,
|
|
PlaybackAttemptID: "shadow-" + sessionID, QualityPreference: "original", SubtitleFidelityPreference: playback.SubtitleFidelityCompatibleV3,
|
|
StartPosition: req.StartPosition, AudioTrackIndex: &audioIndex, Capabilities: capabilities,
|
|
ClientPlaybackContext: playback.ClientPlaybackContextV3{ProtocolVersion: playback.ProtocolV3, Features: features, Platform: "legacy-shadow", Output: playback.OutputContextV3{HDRDetails: hdr, AudioPassthrough: passthrough}, Engines: engines},
|
|
}
|
|
}
|
|
|
|
func legacyShadowHDRV3(req startPlaybackRequest) *playback.HDRCapabilitiesV3 {
|
|
if req.HdrDetails != nil {
|
|
return &playback.HDRCapabilitiesV3{HDR10: req.HdrDetails.HDR10, HDR10Plus: req.HdrDetails.HDR10Plus, HLG: req.HdrDetails.HLG, DolbyVisionProfiles: append([]int(nil), req.HdrDetails.DolbyVisionProfiles...)}
|
|
}
|
|
if req.HDR {
|
|
return &playback.HDRCapabilitiesV3{HDR10: true}
|
|
}
|
|
return nil
|
|
}
|