2026-07-14 11:51:27 -04:00
|
|
|
package playback
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"errors"
|
|
|
|
|
"fmt"
|
|
|
|
|
"math"
|
|
|
|
|
"regexp"
|
|
|
|
|
"slices"
|
|
|
|
|
"strings"
|
|
|
|
|
"time"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
ProtocolV3 = 3
|
|
|
|
|
FeaturePlaybackPlanV3 = "playback_plan_v3"
|
2026-08-10 18:14:49 -04:00
|
|
|
FeatureNeutralContractV3 = "neutral_playback_v3_contract_v1"
|
2026-07-14 11:51:27 -04:00
|
|
|
FeatureLayoutPassthrough = "layout_aware_passthrough"
|
|
|
|
|
FeatureClientVideoTransforms = "client_video_transformations_v1"
|
|
|
|
|
FeatureRouteDiagnostics = "playback_route_diagnostics"
|
|
|
|
|
FeatureDeviceQuirksV3 = "device_quirks_v1"
|
|
|
|
|
FeatureSeekReanchorV3 = "seek_reanchor_v1"
|
2026-07-24 14:22:12 -04:00
|
|
|
FeatureDirectStreamResumeV3 = "direct_stream_resume_v1"
|
2026-07-26 00:12:29 -04:00
|
|
|
FeaturePlanSourceDurationV3 = "plan_source_duration_v1"
|
2026-08-10 18:14:49 -04:00
|
|
|
PlanRecipeVersionV3 = "v3.4"
|
2026-07-14 11:51:27 -04:00
|
|
|
ClientDV7ToDV81V3 = "client_dv7_to_dv81"
|
|
|
|
|
ClientDV7ToHDR10V3 = "client_dv7_to_hdr10"
|
|
|
|
|
ClientDVTransformVersionV3 = "1"
|
|
|
|
|
ClientDV8HDR10PlusSanitizerV3 = "client_dv8_hdr10plus_sanitizer_v1"
|
|
|
|
|
ClientPostResumeRecoveryV3 = "client_post_resume_video_recovery_v1"
|
|
|
|
|
ClientSurfaceRecoveryV3 = "client_surface_recovery_v1"
|
|
|
|
|
DeviceQuirkRegistryRevisionV3 = "2026-07-13.1"
|
|
|
|
|
)
|
|
|
|
|
|
2026-07-24 14:22:12 -04:00
|
|
|
// ServerFeaturesV3 returns the complete feature set advertised by protocol-v3
|
|
|
|
|
// capability and decision responses. A fresh slice prevents callers from
|
|
|
|
|
// mutating the shared contract.
|
|
|
|
|
func ServerFeaturesV3() []string {
|
|
|
|
|
return []string{
|
|
|
|
|
FeaturePlaybackPlanV3,
|
2026-08-10 18:14:49 -04:00
|
|
|
FeatureNeutralContractV3,
|
2026-07-24 14:22:12 -04:00
|
|
|
FeatureLayoutPassthrough,
|
|
|
|
|
FeatureRouteDiagnostics,
|
|
|
|
|
FeatureDeviceQuirksV3,
|
|
|
|
|
FeatureSeekReanchorV3,
|
|
|
|
|
FeatureDirectStreamResumeV3,
|
2026-07-26 00:12:29 -04:00
|
|
|
// Advertised so a client can tell "this server does not populate
|
|
|
|
|
// source.duration_seconds" apart from "this server knows the runtime
|
|
|
|
|
// is genuinely unknown". Without the distinction both look like an
|
|
|
|
|
// absent field, and a client cannot decide whether its own catalog
|
|
|
|
|
// fallback is still required.
|
|
|
|
|
FeaturePlanSourceDurationV3,
|
2026-07-24 14:22:12 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-14 11:51:27 -04:00
|
|
|
type DecisionOutcomeV3 string
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
OutcomePlayableV3 DecisionOutcomeV3 = "playable"
|
|
|
|
|
OutcomeAdaptationUnavailableV3 DecisionOutcomeV3 = "adaptation_unavailable"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type DeliveryV3 string
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
DeliveryOriginalHTTPV3 DeliveryV3 = "original_http"
|
|
|
|
|
DeliveryRemuxHLSV3 DeliveryV3 = "server_remux_hls"
|
|
|
|
|
DeliveryRemuxProgressiveV3 DeliveryV3 = "server_remux_progressive"
|
|
|
|
|
DeliveryTranscodeHLSV3 DeliveryV3 = "server_transcode_hls"
|
|
|
|
|
)
|
|
|
|
|
|
2026-08-10 18:14:49 -04:00
|
|
|
// Delivery classes are the client-side negotiation unit: a client advertises
|
|
|
|
|
// the delivery classes it can execute in ClientPlaybackContextV3.Deliveries,
|
|
|
|
|
// and each client maps them onto its own player internally. The server-side
|
|
|
|
|
// DeliveryV3 values above are the finer-grained plan outcomes; DeliveryClassV3
|
|
|
|
|
// folds them onto the negotiation keys.
|
|
|
|
|
const (
|
|
|
|
|
DeliveryClassOriginalHTTPV3 = "original_http"
|
|
|
|
|
DeliveryClassProgressiveV3 = "progressive"
|
|
|
|
|
DeliveryClassHLSV3 = "hls"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// DeliveryClassV3 maps a plan delivery to the capability class the client
|
|
|
|
|
// advertises for it.
|
|
|
|
|
func DeliveryClassV3(d DeliveryV3) string {
|
2026-07-14 11:51:27 -04:00
|
|
|
switch d {
|
|
|
|
|
case DeliveryOriginalHTTPV3:
|
2026-08-10 18:14:49 -04:00
|
|
|
return DeliveryClassOriginalHTTPV3
|
2026-07-14 11:51:27 -04:00
|
|
|
case DeliveryRemuxProgressiveV3:
|
2026-08-10 18:14:49 -04:00
|
|
|
return DeliveryClassProgressiveV3
|
|
|
|
|
case DeliveryRemuxHLSV3, DeliveryTranscodeHLSV3:
|
|
|
|
|
return DeliveryClassHLSV3
|
2026-07-14 11:51:27 -04:00
|
|
|
default:
|
2026-08-10 18:14:49 -04:00
|
|
|
return string(d)
|
2026-07-14 11:51:27 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type StreamProtocolV3 string
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
StreamHTTPProgressiveV3 StreamProtocolV3 = "http_progressive"
|
|
|
|
|
StreamHLSV3 StreamProtocolV3 = "hls"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type HeaderRefreshModeV3 string
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
HeaderRefreshNoneV3 HeaderRefreshModeV3 = "none"
|
|
|
|
|
HeaderRefreshSessionV3 HeaderRefreshModeV3 = "session"
|
|
|
|
|
)
|
|
|
|
|
|
2026-08-10 18:14:49 -04:00
|
|
|
// AudioOnlyRemuxMIMEV3 is the content type of the fragmented MP4 the remux
|
|
|
|
|
// pipeline produces for a source with no video track. The transport serves the
|
|
|
|
|
// same value, so a client that gates attachment on the advertised MIME sees a
|
|
|
|
|
// promise the response keeps.
|
|
|
|
|
const AudioOnlyRemuxMIMEV3 = "audio/mp4"
|
|
|
|
|
|
|
|
|
|
// Dynamic-range vocabulary shared by source descriptors, effective recipes and
|
|
|
|
|
// the range a transformation promises to produce.
|
|
|
|
|
const (
|
|
|
|
|
DynamicRangeSDRV3 = "sdr"
|
|
|
|
|
DynamicRangeHDR10V3 = "hdr10"
|
|
|
|
|
DynamicRangeHDR10PlusV3 = "hdr10_plus"
|
|
|
|
|
DynamicRangeHLGV3 = "hlg"
|
|
|
|
|
DynamicRangeDolbyVisionV3 = "dolby_vision"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// Server transformation names. A plan names the transformations its serving
|
|
|
|
|
// executor must run; the registry keys availability by the same names.
|
|
|
|
|
const (
|
|
|
|
|
TransformationAudioToAACV3 = "audio_to_aac"
|
|
|
|
|
TransformationVideoToH264V3 = "video_to_h264"
|
|
|
|
|
TransformationServerDV7HDR10V3 = "server_dv7_to_hdr10"
|
|
|
|
|
|
|
|
|
|
TransformationVideoToH264RecipeVersionV3 = "2"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// Transformation executors: who runs the transformation. A "server"
|
|
|
|
|
// transformation is performed by the serving executor before the bytes leave
|
|
|
|
|
// the server; a "client" one is the client's own responsibility.
|
|
|
|
|
const (
|
|
|
|
|
ExecutorServerV3 = "server"
|
|
|
|
|
ExecutorClientV3 = "client"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// Validated claims a server transformation asserts about its output: neutral
|
|
|
|
|
// statements about the bytes, not client-framework decoder names.
|
|
|
|
|
const (
|
|
|
|
|
ClaimAudioDecodeV3 = "audio_decode"
|
|
|
|
|
ClaimH264DecodeV3 = "h264_decode"
|
|
|
|
|
ClaimDolbyVisionMetadataRemovedV3 = "dolby_vision_metadata_removed"
|
|
|
|
|
ClaimHDR10BaseLayerPreservedV3 = "hdr10_base_layer_preserved"
|
|
|
|
|
ClaimEnhancementLayerDiscardedV3 = "enhancement_layer_discarded"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// DV7ToHDR10ClaimsV3 returns the claims the server DV7→HDR10 transformation
|
|
|
|
|
// asserts. A fresh slice keeps callers from mutating the shared contract.
|
|
|
|
|
func DV7ToHDR10ClaimsV3() []string {
|
|
|
|
|
return []string{ClaimDolbyVisionMetadataRemovedV3, ClaimHDR10BaseLayerPreservedV3, ClaimEnhancementLayerDiscardedV3}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Terminal reasons reported when a required conversion toolchain is absent.
|
|
|
|
|
const (
|
|
|
|
|
TerminalAudioConversionUnsupportedV3 = "audio_conversion_unsupported"
|
|
|
|
|
TerminalVideoConversionUnsupportedV3 = "video_conversion_unsupported"
|
|
|
|
|
TerminalDVConversionUnsupportedV3 = "dv_conversion_unsupported"
|
|
|
|
|
)
|
|
|
|
|
|
2026-07-14 11:51:27 -04:00
|
|
|
type SubtitleModeV3 string
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
SubtitleOffV3 SubtitleModeV3 = "off"
|
|
|
|
|
SubtitleRenderV3 SubtitleModeV3 = "render"
|
|
|
|
|
SubtitleConvertV3 SubtitleModeV3 = "convert"
|
|
|
|
|
SubtitleBurnInV3 SubtitleModeV3 = "burn_in"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type SubtitleFidelityV3 string
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
SubtitleFidelityPreserveV3 SubtitleFidelityV3 = "preserve"
|
|
|
|
|
SubtitleFidelityCompatibleV3 SubtitleFidelityV3 = "compatible"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type EnhancementLayerV3 string
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
EnhancementNoneV3 EnhancementLayerV3 = "none"
|
|
|
|
|
EnhancementMELV3 EnhancementLayerV3 = "mel"
|
|
|
|
|
EnhancementFELV3 EnhancementLayerV3 = "fel"
|
|
|
|
|
EnhancementUnknownV3 EnhancementLayerV3 = "unknown"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type HDRCapabilitiesV3 struct {
|
|
|
|
|
HDR10 bool `json:"hdr10"`
|
|
|
|
|
HDR10Plus bool `json:"hdr10_plus"`
|
|
|
|
|
HLG bool `json:"hlg"`
|
|
|
|
|
DolbyVisionProfiles []int `json:"dolby_vision_profiles"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type AudioPassthroughV3 struct {
|
|
|
|
|
PassthroughCodecs []string `json:"passthrough_codecs"`
|
|
|
|
|
SpatializerEnabled bool `json:"spatializer_enabled"`
|
|
|
|
|
MaxChannels int `json:"max_channels"`
|
|
|
|
|
Entries []AudioPassthroughEntryV3 `json:"entries,omitempty"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type AudioPassthroughEntryV3 struct {
|
|
|
|
|
Codec string `json:"codec"`
|
|
|
|
|
ChannelCounts []int `json:"channel_counts,omitempty"`
|
|
|
|
|
Layouts []string `json:"layouts,omitempty"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type VideoDecodeCapabilityV3 struct {
|
|
|
|
|
Codec string `json:"codec"`
|
|
|
|
|
DecoderName string `json:"decoder_name,omitempty"`
|
|
|
|
|
Profiles []string `json:"profiles,omitempty"`
|
|
|
|
|
Levels []int `json:"levels,omitempty"`
|
|
|
|
|
BitDepths []int `json:"bit_depths,omitempty"`
|
|
|
|
|
MaxWidth int `json:"max_width,omitempty"`
|
|
|
|
|
MaxHeight int `json:"max_height,omitempty"`
|
|
|
|
|
MaxFrameRate float64 `json:"max_frame_rate,omitempty"`
|
|
|
|
|
MaxBitrateKbps int `json:"max_bitrate_kbps,omitempty"`
|
|
|
|
|
Hardware bool `json:"hardware"`
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-10 18:14:49 -04:00
|
|
|
// Capability evidence tiers. Each area (video, audio) declares how its
|
|
|
|
|
// capability facts were produced, and planner strictness follows the tier:
|
|
|
|
|
//
|
|
|
|
|
// - exact: per-codec profiles/levels/bit-depths/bounds from a real platform
|
|
|
|
|
// probe (Android MediaCodecList). Full strict validation.
|
|
|
|
|
// - platform_attested: platform-level decoder attestation without
|
|
|
|
|
// profile/level enumeration (Apple VideoToolbox). Codec, resolution, bit
|
|
|
|
|
// depth, frame rate, and dynamic range are validated; profile/level
|
|
|
|
|
// matching is skipped instead of failing conservative.
|
|
|
|
|
// - declared: boolean support statements (web MediaSource.isTypeSupported).
|
|
|
|
|
// Copy routes are granted on codec+container+range match from the flat
|
|
|
|
|
// codec lists; no strict direct claims are made.
|
|
|
|
|
type CapabilityEvidenceV3 string
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
EvidenceExactV3 CapabilityEvidenceV3 = "exact"
|
|
|
|
|
EvidencePlatformAttestedV3 CapabilityEvidenceV3 = "platform_attested"
|
|
|
|
|
EvidenceDeclaredV3 CapabilityEvidenceV3 = "declared"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
func validCapabilityEvidenceV3(v CapabilityEvidenceV3) bool {
|
|
|
|
|
return v == EvidenceExactV3 || v == EvidencePlatformAttestedV3 || v == EvidenceDeclaredV3
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-14 11:51:27 -04:00
|
|
|
type ClientCodecCapabilitiesV3 struct {
|
2026-08-10 18:14:49 -04:00
|
|
|
// VideoEvidence and AudioEvidence are required closed enums declaring the
|
|
|
|
|
// provenance of the respective capability facts.
|
|
|
|
|
VideoEvidence CapabilityEvidenceV3 `json:"video_evidence"`
|
|
|
|
|
AudioEvidence CapabilityEvidenceV3 `json:"audio_evidence"`
|
2026-07-14 11:51:27 -04:00
|
|
|
CodecsVideo []string `json:"codecs_video"`
|
|
|
|
|
CodecsVideoHardware []string `json:"codecs_video_hardware"`
|
|
|
|
|
CodecsAudio []string `json:"codecs_audio"`
|
|
|
|
|
Containers []string `json:"containers"`
|
|
|
|
|
MaxResolution string `json:"max_resolution,omitempty"`
|
|
|
|
|
HDR bool `json:"hdr"`
|
|
|
|
|
HDRDetails *HDRCapabilitiesV3 `json:"hdr_details,omitempty"`
|
|
|
|
|
AudioPassthrough *AudioPassthroughV3 `json:"audio_passthrough,omitempty"`
|
|
|
|
|
VideoDecode []VideoDecodeCapabilityV3 `json:"video_decode,omitempty"`
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-10 18:14:49 -04:00
|
|
|
// DeviceContextV3 is platform-neutral device identity. Manufacturer and model
|
|
|
|
|
// stay first-class because the quirk registry matches on them; everything
|
|
|
|
|
// platform-specific (Android sdk_int, soc_model, build fields, …) travels in
|
|
|
|
|
// PlatformDetails as opaque bounded strings.
|
2026-07-14 11:51:27 -04:00
|
|
|
type DeviceContextV3 struct {
|
2026-08-10 18:14:49 -04:00
|
|
|
Platform string `json:"platform,omitempty"`
|
|
|
|
|
OSVersion string `json:"os_version,omitempty"`
|
|
|
|
|
Manufacturer string `json:"manufacturer,omitempty"`
|
|
|
|
|
Model string `json:"model,omitempty"`
|
|
|
|
|
PlatformDetails map[string]string `json:"platform_details,omitempty"`
|
2026-07-14 11:51:27 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type OutputContextV3 struct {
|
2026-08-10 18:14:49 -04:00
|
|
|
HDRDetails *HDRCapabilitiesV3 `json:"hdr_details,omitempty"`
|
|
|
|
|
AudioPassthrough *AudioPassthroughV3 `json:"audio_passthrough,omitempty"`
|
|
|
|
|
CurrentSink string `json:"current_sink,omitempty"`
|
|
|
|
|
SinkType string `json:"sink_type,omitempty"`
|
|
|
|
|
// OutputContextID is an optional opaque token identifying the current
|
|
|
|
|
// output route. The server only ever compares it for equality — in attempt
|
|
|
|
|
// keys and plan invalidation — so any stable platform-native identity
|
|
|
|
|
// works: Android supplies its route generation stringified, Apple its
|
|
|
|
|
// synthetic sink hash, web omits it.
|
|
|
|
|
OutputContextID string `json:"output_context_id,omitempty"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type DeliverySubtitleCapabilitiesV3 struct {
|
2026-07-14 11:51:27 -04:00
|
|
|
EmbeddedText bool `json:"embedded_text"`
|
|
|
|
|
SidecarText bool `json:"sidecar_text"`
|
|
|
|
|
ASSStyling bool `json:"ass_styling"`
|
|
|
|
|
EmbeddedBitmap bool `json:"embedded_bitmap"`
|
|
|
|
|
SidecarBitmap bool `json:"sidecar_bitmap"`
|
|
|
|
|
FontAttachments bool `json:"font_attachments"`
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-10 18:14:49 -04:00
|
|
|
type DeliveryCapabilityV3 struct {
|
|
|
|
|
Enabled bool `json:"enabled"`
|
|
|
|
|
SupportedOnDevice bool `json:"supported_on_device"`
|
|
|
|
|
FailureReason string `json:"failure_reason,omitempty"`
|
|
|
|
|
Containers []string `json:"containers"`
|
|
|
|
|
VideoCodecs []string `json:"video_codecs"`
|
|
|
|
|
AudioDecodeCodecs []string `json:"audio_decode_codecs"`
|
|
|
|
|
AudioPassthroughCodecs []string `json:"audio_passthrough_codecs"`
|
|
|
|
|
MaxChannels *int `json:"max_channels,omitempty"`
|
|
|
|
|
HDRDetails *HDRCapabilitiesV3 `json:"hdr_details,omitempty"`
|
|
|
|
|
Subtitles DeliverySubtitleCapabilitiesV3 `json:"subtitles"`
|
|
|
|
|
Features []string `json:"features"`
|
|
|
|
|
AuthHeaderRefresh bool `json:"auth_header_refresh"`
|
|
|
|
|
ValidatedClaims []string `json:"validated_claims"`
|
|
|
|
|
Transformations []TransformationV3 `json:"transformations"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ClientPlaybackContextV3 carries the client's execution context. Feature
|
|
|
|
|
// advertisement lives exclusively in the request's top-level client_features
|
|
|
|
|
// list; there is deliberately no second features location here.
|
2026-07-14 11:51:27 -04:00
|
|
|
type ClientPlaybackContextV3 struct {
|
2026-08-10 18:14:49 -04:00
|
|
|
ProtocolVersion int `json:"protocol_version"`
|
|
|
|
|
FormFactor string `json:"form_factor"`
|
|
|
|
|
AppVersion string `json:"app_version"`
|
|
|
|
|
Device DeviceContextV3 `json:"device"`
|
|
|
|
|
Output OutputContextV3 `json:"output"`
|
|
|
|
|
Deliveries map[string]DeliveryCapabilityV3 `json:"deliveries"`
|
2026-07-14 11:51:27 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type StartRequestV3 struct {
|
|
|
|
|
ProtocolVersion int `json:"protocol_version"`
|
|
|
|
|
ClientFeatures []string `json:"client_features"`
|
|
|
|
|
FileID int `json:"file_id"`
|
|
|
|
|
ProfileID string `json:"profile_id"`
|
|
|
|
|
PlaybackAttemptID string `json:"playback_attempt_id"`
|
|
|
|
|
QualityPreference string `json:"quality_preference"`
|
|
|
|
|
SubtitleFidelityPreference SubtitleFidelityV3 `json:"subtitle_fidelity_preference"`
|
|
|
|
|
StartPosition *float64 `json:"start_position,omitempty"`
|
2026-08-10 18:14:49 -04:00
|
|
|
ProgressPersistence ProgressPersistenceV3 `json:"progress_persistence,omitempty"`
|
2026-07-14 11:51:27 -04:00
|
|
|
AudioTrackID string `json:"audio_track_id,omitempty"`
|
|
|
|
|
AudioTrackIndex *int `json:"audio_track_index,omitempty"`
|
|
|
|
|
SubtitleTrackID string `json:"subtitle_track_id,omitempty"`
|
|
|
|
|
SubtitleTrackIndex *int `json:"subtitle_track_index,omitempty"`
|
|
|
|
|
Metered bool `json:"metered"`
|
|
|
|
|
BandwidthEstimateKbps *int `json:"bandwidth_estimate_kbps,omitempty"`
|
|
|
|
|
BandwidthCapKbps *int `json:"bandwidth_cap_kbps,omitempty"`
|
|
|
|
|
Capabilities ClientCodecCapabilitiesV3 `json:"client_capabilities"`
|
|
|
|
|
ClientPlaybackContext ClientPlaybackContextV3 `json:"client_playback_context"`
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-10 18:14:49 -04:00
|
|
|
// ProgressPersistenceV3 declares which side owns durable item resume/history.
|
|
|
|
|
// Session progress is still reported in both modes so live playback state and
|
|
|
|
|
// diagnostics remain accurate.
|
|
|
|
|
type ProgressPersistenceV3 string
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
ProgressPersistenceServerV3 ProgressPersistenceV3 = "server"
|
|
|
|
|
ProgressPersistenceClientV3 ProgressPersistenceV3 = "client"
|
|
|
|
|
)
|
|
|
|
|
|
2026-07-14 11:51:27 -04:00
|
|
|
type TrackIdentityV3 struct {
|
|
|
|
|
ID string `json:"id"`
|
|
|
|
|
Index *int `json:"index,omitempty"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type SelectedTracksV3 struct {
|
|
|
|
|
Audio *TrackIdentityV3 `json:"audio,omitempty"`
|
|
|
|
|
Subtitle *TrackIdentityV3 `json:"subtitle,omitempty"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type FailureV3 struct {
|
|
|
|
|
Classification string `json:"classification"`
|
|
|
|
|
Message string `json:"message,omitempty"`
|
|
|
|
|
DecoderName string `json:"decoder_name,omitempty"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type ReplanOperationV3 string
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
ReplanOperationFailureRecoveryV3 ReplanOperationV3 = "failure_recovery"
|
|
|
|
|
ReplanOperationSeekReanchorV3 ReplanOperationV3 = "seek_reanchor"
|
|
|
|
|
ReplanOperationSeekFailureRecoveryV3 ReplanOperationV3 = "seek_failure_recovery"
|
2026-08-10 18:14:49 -04:00
|
|
|
// ReplanOperationTrackChangeV3 replaces the legacy audio PATCH: the client
|
|
|
|
|
// sends new selected_tracks and no failure classification. It runs through
|
|
|
|
|
// the same replan transaction as failure recovery, inheriting idempotency
|
|
|
|
|
// and restart safety.
|
|
|
|
|
ReplanOperationTrackChangeV3 ReplanOperationV3 = "track_change"
|
|
|
|
|
// ReplanOperationQualityChangeV3 replaces the client-recipe half of the
|
|
|
|
|
// legacy transcode start: the client sends a quality_preference chosen from
|
|
|
|
|
// the plan's available_qualities and no failure classification.
|
|
|
|
|
ReplanOperationQualityChangeV3 ReplanOperationV3 = "quality_change"
|
2026-07-14 11:51:27 -04:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type ReplanRequestV3 struct {
|
2026-08-10 18:14:49 -04:00
|
|
|
ProtocolVersion int `json:"protocol_version"`
|
|
|
|
|
// ClientFeatures is the single feature-advertisement location; the
|
|
|
|
|
// playback context deliberately carries no second features list.
|
|
|
|
|
ClientFeatures []string `json:"client_features,omitempty"`
|
|
|
|
|
Operation ReplanOperationV3 `json:"operation,omitempty"`
|
|
|
|
|
PlaybackAttemptID string `json:"playback_attempt_id"`
|
|
|
|
|
ReplanRequestID string `json:"replan_request_id"`
|
|
|
|
|
FailedPlanID string `json:"failed_plan_id"`
|
|
|
|
|
PlanAttemptID string `json:"plan_attempt_id"`
|
|
|
|
|
PlanAttemptKey string `json:"plan_attempt_key"`
|
|
|
|
|
AttemptedPlanKeys []string `json:"attempted_plan_keys"`
|
|
|
|
|
// LocalMutations reports client-applied local plan mutations (for example
|
|
|
|
|
// a PCM recovery route) so the server can fold them into the attempt key it
|
|
|
|
|
// computes for the failed plan. Clients never hash anything themselves.
|
|
|
|
|
LocalMutations []string `json:"local_mutations,omitempty"`
|
2026-07-14 11:51:27 -04:00
|
|
|
AttemptCount int `json:"attempt_count"`
|
|
|
|
|
QualityPreference string `json:"quality_preference"`
|
|
|
|
|
PositionSeconds float64 `json:"position_seconds"`
|
|
|
|
|
Metered bool `json:"metered"`
|
|
|
|
|
BandwidthEstimateKbps *int `json:"bandwidth_estimate_kbps,omitempty"`
|
|
|
|
|
BandwidthCapKbps *int `json:"bandwidth_cap_kbps,omitempty"`
|
|
|
|
|
SelectedTracks SelectedTracksV3 `json:"selected_tracks"`
|
2026-08-10 18:14:49 -04:00
|
|
|
Failure FailureV3 `json:"failure,omitzero"`
|
2026-07-14 11:51:27 -04:00
|
|
|
Capabilities ClientCodecCapabilitiesV3 `json:"client_capabilities"`
|
|
|
|
|
ClientPlaybackContext ClientPlaybackContextV3 `json:"client_playback_context"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
RouteEventPlanSelectedV3 = "plan_selected"
|
|
|
|
|
RouteEventPlanInvalidatedV3 = "plan_invalidated"
|
|
|
|
|
RouteEventPlanFailedV3 = "plan_failed"
|
|
|
|
|
RouteEventFirstFrameV3 = "first_frame"
|
|
|
|
|
RouteEventTerminalV3 = "terminal"
|
|
|
|
|
RouteEventStoppedV3 = "stopped"
|
|
|
|
|
RouteEventRuntimeCorrectionAppliedV3 = "runtime_correction_applied"
|
|
|
|
|
RouteEventRuntimeCorrectionSucceededV3 = "runtime_correction_succeeded"
|
|
|
|
|
RouteEventRuntimeCorrectionFailedV3 = "runtime_correction_failed"
|
|
|
|
|
RouteEventSeekReanchorRequestedV3 = "seek_reanchor_requested"
|
|
|
|
|
RouteEventSeekReanchoredV3 = "seek_reanchored"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
var routeEventNamesV3 = []string{
|
|
|
|
|
RouteEventPlanSelectedV3,
|
|
|
|
|
RouteEventPlanInvalidatedV3,
|
|
|
|
|
RouteEventPlanFailedV3,
|
|
|
|
|
RouteEventFirstFrameV3,
|
|
|
|
|
RouteEventTerminalV3,
|
|
|
|
|
RouteEventStoppedV3,
|
|
|
|
|
RouteEventRuntimeCorrectionAppliedV3,
|
|
|
|
|
RouteEventRuntimeCorrectionSucceededV3,
|
|
|
|
|
RouteEventRuntimeCorrectionFailedV3,
|
|
|
|
|
RouteEventSeekReanchorRequestedV3,
|
|
|
|
|
RouteEventSeekReanchoredV3,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// RouteEventNamesV3 returns the complete protocol-v3 telemetry event contract.
|
|
|
|
|
func RouteEventNamesV3() []string {
|
|
|
|
|
return append([]string(nil), routeEventNamesV3...)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ValidRouteEventNameV3 reports whether name is part of the protocol-v3
|
|
|
|
|
// telemetry contract shared by handlers, persistence, and clients.
|
|
|
|
|
func ValidRouteEventNameV3(name string) bool {
|
|
|
|
|
return slices.Contains(routeEventNamesV3, name)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// EffectiveOperation keeps clients which predate the explicit operation field
|
|
|
|
|
// on the ordinary failure-recovery path. Seek operations are deliberately
|
|
|
|
|
// opt-in because both pin the current media version and user intent; an exact
|
|
|
|
|
// reanchor also preserves the current route instead of walking the ladder.
|
|
|
|
|
func (r ReplanRequestV3) EffectiveOperation() ReplanOperationV3 {
|
|
|
|
|
if r.Operation == "" {
|
|
|
|
|
return ReplanOperationFailureRecoveryV3
|
|
|
|
|
}
|
|
|
|
|
return r.Operation
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type RouteEventV3 struct {
|
|
|
|
|
ProtocolVersion int `json:"protocol_version"`
|
|
|
|
|
PlaybackAttemptID string `json:"playback_attempt_id"`
|
|
|
|
|
SessionID string `json:"session_id,omitempty"`
|
|
|
|
|
PlanID string `json:"plan_id,omitempty"`
|
|
|
|
|
PlanAttemptID string `json:"plan_attempt_id,omitempty"`
|
|
|
|
|
PlanAttemptKey string `json:"plan_attempt_key,omitempty"`
|
|
|
|
|
Event string `json:"event"`
|
|
|
|
|
FailureClassification string `json:"failure_classification,omitempty"`
|
|
|
|
|
FallbackReason string `json:"fallback_reason,omitempty"`
|
|
|
|
|
AppliedQuirkIDs []string `json:"applied_quirk_ids,omitempty"`
|
|
|
|
|
QuirkRegistryRevision string `json:"quirk_registry_revision,omitempty"`
|
2026-08-10 18:14:49 -04:00
|
|
|
OutputContextID string `json:"output_context_id,omitempty"`
|
2026-07-14 11:51:27 -04:00
|
|
|
Diagnostics map[string]string `json:"diagnostics"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type StreamV3 struct {
|
|
|
|
|
URL string `json:"url"`
|
|
|
|
|
Protocol StreamProtocolV3 `json:"protocol"`
|
|
|
|
|
Container string `json:"container,omitempty"`
|
|
|
|
|
MIMEType string `json:"mime_type,omitempty"`
|
|
|
|
|
Headers map[string]string `json:"headers"`
|
|
|
|
|
HeaderRefresh HeaderRefreshModeV3 `json:"header_refresh"`
|
|
|
|
|
HeaderRefreshURL string `json:"header_refresh_url,omitempty"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type TimelineV3 struct {
|
|
|
|
|
SourceStartSeconds float64 `json:"source_start_seconds"`
|
|
|
|
|
StreamOriginSeconds float64 `json:"stream_origin_seconds"`
|
|
|
|
|
PlayerStartSeconds float64 `json:"player_start_seconds"`
|
|
|
|
|
TimelineOffsetSeconds float64 `json:"timeline_offset_seconds"`
|
|
|
|
|
SeekWindowStartSeconds *float64 `json:"seek_window_start_seconds,omitempty"`
|
|
|
|
|
SeekWindowEndSeconds *float64 `json:"seek_window_end_seconds,omitempty"`
|
|
|
|
|
CanSeekAnywhere bool `json:"can_seek_anywhere"`
|
|
|
|
|
SeekRestoration string `json:"seek_restoration"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type EffectiveRecipeV3 struct {
|
|
|
|
|
VideoCodec string `json:"video_codec,omitempty"`
|
|
|
|
|
AudioCodec string `json:"audio_codec,omitempty"`
|
|
|
|
|
Width *int `json:"width,omitempty"`
|
|
|
|
|
Height *int `json:"height,omitempty"`
|
|
|
|
|
FrameRate *float64 `json:"frame_rate,omitempty"`
|
|
|
|
|
BitrateKbps *int `json:"bitrate_kbps,omitempty"`
|
|
|
|
|
DynamicRange string `json:"dynamic_range,omitempty"`
|
|
|
|
|
AudioChannels *int `json:"audio_channels,omitempty"`
|
|
|
|
|
AudioLayout string `json:"audio_layout,omitempty"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type SourceDescriptorV3 struct {
|
2026-07-26 00:12:29 -04:00
|
|
|
MediaFileID int `json:"media_file_id"`
|
|
|
|
|
// DurationSeconds is the full runtime of this source, independent of where
|
|
|
|
|
// the delivery's timeline is anchored: never `total - source_start`, and
|
|
|
|
|
// never adjusted by timeline_offset_seconds.
|
|
|
|
|
//
|
|
|
|
|
// Absent means the server does not know the runtime. It is omitted rather
|
|
|
|
|
// than sent as null: clients that coerce null to a numeric default would
|
|
|
|
|
// read it as zero, which is the value this field exists to stop them
|
|
|
|
|
// inventing. A client must not substitute the playback engine's reported
|
|
|
|
|
// duration for it — on an HLS copy remux the engine reports the length
|
|
|
|
|
// produced so far, not the runtime.
|
|
|
|
|
DurationSeconds *float64 `json:"duration_seconds,omitempty"`
|
2026-07-14 11:51:27 -04:00
|
|
|
Container string `json:"container,omitempty"`
|
|
|
|
|
VideoCodec string `json:"video_codec,omitempty"`
|
|
|
|
|
VideoProfile string `json:"video_profile,omitempty"`
|
|
|
|
|
VideoLevel int `json:"video_level,omitempty"`
|
|
|
|
|
BitDepth int `json:"bit_depth,omitempty"`
|
2026-07-21 12:57:54 -04:00
|
|
|
ColorRange string `json:"color_range,omitempty"`
|
2026-07-14 11:51:27 -04:00
|
|
|
Width int `json:"width,omitempty"`
|
|
|
|
|
Height int `json:"height,omitempty"`
|
|
|
|
|
FrameRate float64 `json:"frame_rate,omitempty"`
|
|
|
|
|
BitrateKbps int `json:"bitrate_kbps,omitempty"`
|
|
|
|
|
DynamicRange string `json:"dynamic_range,omitempty"`
|
|
|
|
|
HDR10Plus bool `json:"hdr10_plus"`
|
|
|
|
|
DVProfile int `json:"dolby_vision_profile,omitempty"`
|
|
|
|
|
DVBLCompatID int `json:"dv_bl_compat_id,omitempty"`
|
|
|
|
|
DVEnhancementLayer EnhancementLayerV3 `json:"dv_enhancement_layer"`
|
|
|
|
|
AudioCodec string `json:"audio_codec,omitempty"`
|
|
|
|
|
AudioChannels int `json:"audio_channels,omitempty"`
|
|
|
|
|
AudioLayout string `json:"audio_layout,omitempty"`
|
2026-07-17 16:26:08 +00:00
|
|
|
// VideoCopyUnsafe marks a source whose video stream cannot be safely
|
|
|
|
|
// stream-copied into an avc1/fMP4 segment (H.264 with conflicting in-band
|
|
|
|
|
// PPS). Copy/remux routes are disqualified for it; a real encode is used.
|
|
|
|
|
VideoCopyUnsafe bool `json:"video_copy_unsafe,omitempty"`
|
2026-07-14 11:51:27 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type VideoClaimsV3 struct {
|
|
|
|
|
HDR10 bool `json:"hdr10"`
|
|
|
|
|
HDR10Plus bool `json:"hdr10_plus"`
|
|
|
|
|
HLG bool `json:"hlg"`
|
|
|
|
|
DolbyVision bool `json:"dolby_vision"`
|
|
|
|
|
DolbyVisionReason string `json:"dolby_vision_reason,omitempty"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type AudioClaimsV3 struct {
|
|
|
|
|
Codec string `json:"codec,omitempty"`
|
|
|
|
|
Passthrough bool `json:"passthrough"`
|
|
|
|
|
AtmosPreserved bool `json:"atmos_preserved"`
|
|
|
|
|
DTSVariant string `json:"dts_variant,omitempty"`
|
|
|
|
|
Reason string `json:"reason,omitempty"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type SubtitleClaimsV3 struct {
|
|
|
|
|
ASSStylingPreserved bool `json:"ass_styling_preserved"`
|
|
|
|
|
BitmapOverlay bool `json:"bitmap_overlay"`
|
|
|
|
|
BitmapSidecar bool `json:"bitmap_sidecar"`
|
|
|
|
|
Reason string `json:"reason,omitempty"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type ValidationClaimsV3 struct {
|
|
|
|
|
Video VideoClaimsV3 `json:"video"`
|
|
|
|
|
Audio AudioClaimsV3 `json:"audio"`
|
|
|
|
|
Subtitles SubtitleClaimsV3 `json:"subtitles"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type SubtitleArtifactV3 struct {
|
|
|
|
|
URL string `json:"url"`
|
|
|
|
|
MIMEType string `json:"mime_type"`
|
|
|
|
|
Format string `json:"format"`
|
|
|
|
|
TimingOriginSeconds float64 `json:"timing_origin_seconds"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type SubtitleDecisionV3 struct {
|
|
|
|
|
Mode SubtitleModeV3 `json:"mode"`
|
|
|
|
|
TrackID string `json:"track_id,omitempty"`
|
|
|
|
|
Artifact *SubtitleArtifactV3 `json:"artifact,omitempty"`
|
2026-08-10 18:14:49 -04:00
|
|
|
// Inventory is the complete, gap-free combined-ordinal subtitle track list
|
|
|
|
|
// for the effective source. It is authoritative: a client selects a track
|
|
|
|
|
// by echoing an entry's track_id or combined_index and never derives an
|
|
|
|
|
// ordinal by counting, summing track arrays, or taking max(index)+1.
|
|
|
|
|
Inventory []SubtitleInventoryItemV3 `json:"inventory"`
|
2026-07-14 11:51:27 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type TransformationV3 struct {
|
|
|
|
|
Name string `json:"name"`
|
|
|
|
|
Executor string `json:"executor"`
|
|
|
|
|
RecipeVersion string `json:"recipe_version"`
|
|
|
|
|
ValidatedClaims []string `json:"validated_claims"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type AppliedQuirkV3 struct {
|
|
|
|
|
ID string `json:"id"`
|
|
|
|
|
RegistryRevision string `json:"registry_revision"`
|
|
|
|
|
Action string `json:"action"`
|
|
|
|
|
Reason string `json:"reason,omitempty"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type DegradationWarningV3 struct {
|
|
|
|
|
Code string `json:"code"`
|
|
|
|
|
Message string `json:"message"`
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-10 18:14:49 -04:00
|
|
|
// QualityOriginalV3 is the quality preference that pins the source ladder
|
|
|
|
|
// rung: the plan must preserve the source's own height and bitrate rather than
|
|
|
|
|
// pick a transcode rung. Distinct from OriginalLanguageSentinel, which selects
|
|
|
|
|
// a track's original language.
|
|
|
|
|
const QualityOriginalV3 = "original"
|
|
|
|
|
|
|
|
|
|
// AvailableQualityV3 is one server-ladder rung valid for this source and
|
|
|
|
|
// client, published on the plan so clients can render a quality menu without
|
|
|
|
|
// owning a bitrate table. The QualityOriginalV3 entry preserves the source.
|
|
|
|
|
type AvailableQualityV3 struct {
|
|
|
|
|
Label string `json:"label"`
|
|
|
|
|
Height int `json:"height,omitempty"`
|
|
|
|
|
BitrateKbps int `json:"bitrate_kbps,omitempty"`
|
|
|
|
|
PreservesSource bool `json:"preserves_source"`
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-14 11:51:27 -04:00
|
|
|
type PlanV3 struct {
|
2026-08-10 18:14:49 -04:00
|
|
|
ProtocolVersion int `json:"protocol_version"`
|
|
|
|
|
PlanID string `json:"plan_id"`
|
|
|
|
|
// PlanAttemptKey is the server-computed opaque loop-prevention token for
|
|
|
|
|
// this plan. Clients store the keys of attempted plans and echo them in
|
|
|
|
|
// attempted_plan_keys on replan; they never compute keys themselves.
|
|
|
|
|
PlanAttemptKey string `json:"plan_attempt_key"`
|
2026-07-14 11:51:27 -04:00
|
|
|
SessionID string `json:"session_id,omitempty"`
|
|
|
|
|
ExpiresAt string `json:"expires_at,omitempty"`
|
|
|
|
|
Delivery DeliveryV3 `json:"delivery"`
|
|
|
|
|
Stream StreamV3 `json:"stream"`
|
|
|
|
|
Timeline TimelineV3 `json:"timeline"`
|
|
|
|
|
SelectedTracks SelectedTracksV3 `json:"selected_tracks"`
|
|
|
|
|
EffectiveRecipe EffectiveRecipeV3 `json:"effective_recipe"`
|
|
|
|
|
Claims ValidationClaimsV3 `json:"claims"`
|
|
|
|
|
Subtitle SubtitleDecisionV3 `json:"subtitle"`
|
|
|
|
|
Transformations []TransformationV3 `json:"transformations"`
|
|
|
|
|
AppliedQuirks []AppliedQuirkV3 `json:"applied_quirks"`
|
|
|
|
|
RuntimeCorrections []string `json:"runtime_corrections"`
|
2026-08-10 18:14:49 -04:00
|
|
|
AvailableQualities []AvailableQualityV3 `json:"available_qualities"`
|
2026-07-14 11:51:27 -04:00
|
|
|
DegradationWarnings []DegradationWarningV3 `json:"degradation_warnings"`
|
|
|
|
|
DecisionReason string `json:"decision_reason"`
|
|
|
|
|
RequestedMediaFileID int `json:"requested_media_file_id"`
|
|
|
|
|
EffectiveMediaFileID int `json:"effective_media_file_id"`
|
|
|
|
|
Source SourceDescriptorV3 `json:"source"`
|
|
|
|
|
SubtitleFidelityPolicy string `json:"subtitle_fidelity_policy"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type TerminalV3 struct {
|
|
|
|
|
Reason string `json:"reason"`
|
|
|
|
|
Message string `json:"message"`
|
|
|
|
|
Retryable bool `json:"retryable"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type DecisionResponseV3 struct {
|
2026-08-10 18:14:49 -04:00
|
|
|
ProtocolVersion int `json:"protocol_version"`
|
2026-07-14 11:51:27 -04:00
|
|
|
ServerFeatures []string `json:"server_features"`
|
2026-08-10 18:14:49 -04:00
|
|
|
Outcome DecisionOutcomeV3 `json:"outcome"`
|
2026-07-14 11:51:27 -04:00
|
|
|
SessionID string `json:"session_id,omitempty"`
|
|
|
|
|
PlaybackPlan *PlanV3 `json:"playback_plan,omitempty"`
|
|
|
|
|
Terminal *TerminalV3 `json:"terminal,omitempty"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type CapabilityResponseV3 struct {
|
|
|
|
|
Enabled bool `json:"enabled"`
|
|
|
|
|
ProtocolVersions []int `json:"protocol_versions"`
|
|
|
|
|
Features []string `json:"features"`
|
|
|
|
|
Deliveries []DeliveryV3 `json:"deliveries"`
|
|
|
|
|
Transformations []TransformationV3 `json:"transformations"`
|
|
|
|
|
Reason string `json:"reason,omitempty"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var boundedIdentifierV3 = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$`)
|
|
|
|
|
|
|
|
|
|
func (r *StartRequestV3) NormalizeAndValidate() ([]DegradationWarningV3, error) {
|
|
|
|
|
if r.ProtocolVersion != ProtocolV3 {
|
|
|
|
|
return nil, fmt.Errorf("protocol_version must be %d", ProtocolV3)
|
|
|
|
|
}
|
|
|
|
|
if r.FileID <= 0 || strings.TrimSpace(r.ProfileID) == "" {
|
|
|
|
|
return nil, errors.New("file_id and profile_id are required")
|
|
|
|
|
}
|
|
|
|
|
if !boundedIdentifierV3.MatchString(r.PlaybackAttemptID) {
|
|
|
|
|
return nil, errors.New("playback_attempt_id is invalid")
|
|
|
|
|
}
|
|
|
|
|
if r.ClientPlaybackContext.ProtocolVersion != ProtocolV3 {
|
|
|
|
|
return nil, errors.New("client_playback_context.protocol_version must be 3")
|
|
|
|
|
}
|
|
|
|
|
if r.StartPosition != nil && (!isFiniteV3(*r.StartPosition) || *r.StartPosition < 0 || *r.StartPosition > 31_536_000) {
|
|
|
|
|
return nil, errors.New("start_position is outside the supported range")
|
|
|
|
|
}
|
2026-08-10 18:14:49 -04:00
|
|
|
if r.ProgressPersistence == "" {
|
|
|
|
|
r.ProgressPersistence = ProgressPersistenceServerV3
|
|
|
|
|
}
|
|
|
|
|
if r.ProgressPersistence != ProgressPersistenceServerV3 && r.ProgressPersistence != ProgressPersistenceClientV3 {
|
|
|
|
|
return nil, errors.New("progress_persistence is invalid")
|
|
|
|
|
}
|
|
|
|
|
if r.ProgressPersistence == ProgressPersistenceClientV3 && r.StartPosition == nil {
|
|
|
|
|
return nil, errors.New("start_position is required when progress_persistence is client")
|
|
|
|
|
}
|
2026-07-14 11:51:27 -04:00
|
|
|
if err := validateOptionalBoundedIntV3(r.BandwidthEstimateKbps, 100, 1_000_000, "bandwidth_estimate_kbps"); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
if err := validateOptionalBoundedIntV3(r.BandwidthCapKbps, 100, 1_000_000, "bandwidth_cap_kbps"); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
if r.SubtitleFidelityPreference != SubtitleFidelityPreserveV3 && r.SubtitleFidelityPreference != SubtitleFidelityCompatibleV3 {
|
|
|
|
|
return nil, errors.New("subtitle_fidelity_preference is invalid")
|
|
|
|
|
}
|
|
|
|
|
if len(r.ClientFeatures) > 64 {
|
|
|
|
|
return nil, errors.New("client_features exceeds supported size")
|
|
|
|
|
}
|
|
|
|
|
for _, feature := range r.ClientFeatures {
|
|
|
|
|
if len(feature) > 128 {
|
|
|
|
|
return nil, errors.New("client feature exceeds supported size")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if err := validateCapabilitiesV3(&r.Capabilities, &r.ClientPlaybackContext, r.ClientFeatures); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
if err := validateTrackPairV3(r.FileID, "audio", r.AudioTrackID, r.AudioTrackIndex); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
if err := validateTrackPairV3(r.FileID, "subtitle", r.SubtitleTrackID, r.SubtitleTrackIndex); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
quality, changed := NormalizeQualityV3(r.QualityPreference)
|
|
|
|
|
r.QualityPreference = quality
|
|
|
|
|
if changed {
|
|
|
|
|
return []DegradationWarningV3{{Code: "quality_preference_normalized", Message: "Unknown quality preference was normalized to auto."}}, nil
|
|
|
|
|
}
|
|
|
|
|
return nil, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func NormalizeQualityV3(value string) (string, bool) {
|
|
|
|
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
|
|
|
|
case "", "auto":
|
|
|
|
|
return "auto", false
|
2026-08-10 18:14:49 -04:00
|
|
|
case QualityOriginalV3, "source", "max":
|
|
|
|
|
return QualityOriginalV3, false
|
2026-07-14 11:51:27 -04:00
|
|
|
case "2160p", "4k", "uhd":
|
|
|
|
|
return "2160p", false
|
|
|
|
|
case "1080p", "fhd":
|
|
|
|
|
return "1080p", false
|
|
|
|
|
case "720p", "hd":
|
|
|
|
|
return "720p", false
|
|
|
|
|
case "480p", "sd":
|
|
|
|
|
return "480p", false
|
|
|
|
|
default:
|
|
|
|
|
return "auto", true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (r ReplanRequestV3) Validate() error {
|
|
|
|
|
if r.ProtocolVersion != ProtocolV3 || !boundedIdentifierV3.MatchString(r.PlaybackAttemptID) || !boundedIdentifierV3.MatchString(r.ReplanRequestID) {
|
|
|
|
|
return errors.New("invalid replan identity")
|
|
|
|
|
}
|
|
|
|
|
if len(r.FailedPlanID) < 8 || len(r.FailedPlanID) > 128 || len(r.PlanAttemptID) < 8 || len(r.PlanAttemptID) > 128 || len(r.PlanAttemptKey) < 8 || len(r.PlanAttemptKey) > 128 || !strings.HasPrefix(r.PlanAttemptKey, "v3:") || r.AttemptCount < 1 || r.AttemptCount > 8 {
|
|
|
|
|
return errors.New("invalid replan attempt")
|
|
|
|
|
}
|
|
|
|
|
if len(r.AttemptedPlanKeys) > 16 || len(r.Failure.Classification) > 64 || len(r.Failure.Message) > 512 || len(r.Failure.DecoderName) > 128 || !isFiniteV3(r.PositionSeconds) || r.PositionSeconds < 0 || r.PositionSeconds > 31_536_000 {
|
|
|
|
|
return errors.New("replan bounds exceeded")
|
|
|
|
|
}
|
2026-08-10 18:14:49 -04:00
|
|
|
if len(r.LocalMutations) > 8 {
|
|
|
|
|
return errors.New("local_mutations exceeds supported size")
|
|
|
|
|
}
|
|
|
|
|
for _, mutation := range r.LocalMutations {
|
|
|
|
|
if mutation == "" || len(mutation) > 64 {
|
|
|
|
|
return errors.New("invalid local mutation")
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-14 11:51:27 -04:00
|
|
|
if err := validateOptionalBoundedIntV3(r.BandwidthEstimateKbps, 100, 1_000_000, "bandwidth_estimate_kbps"); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if err := validateOptionalBoundedIntV3(r.BandwidthCapKbps, 100, 1_000_000, "bandwidth_cap_kbps"); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if err := validateSelectedTrackIdentityV3("audio", r.SelectedTracks.Audio); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if err := validateSelectedTrackIdentityV3("subtitle", r.SelectedTracks.Subtitle); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
switch r.EffectiveOperation() {
|
|
|
|
|
case ReplanOperationFailureRecoveryV3, ReplanOperationSeekFailureRecoveryV3:
|
|
|
|
|
if r.Failure.Classification == "" {
|
|
|
|
|
return errors.New("failure recovery requires a failure classification")
|
|
|
|
|
}
|
|
|
|
|
case ReplanOperationSeekReanchorV3:
|
|
|
|
|
// An exact seek reanchor is a timeline operation, not a failed recipe.
|
|
|
|
|
// Classification remains accepted for older callers but is not required
|
|
|
|
|
// and never selects seek semantics.
|
2026-08-10 18:14:49 -04:00
|
|
|
case ReplanOperationTrackChangeV3:
|
|
|
|
|
// A user track change is not a failure; no classification is required.
|
|
|
|
|
case ReplanOperationQualityChangeV3:
|
|
|
|
|
// A user quality change is not a failure either, but it must actually
|
|
|
|
|
// name the wanted rung: an empty preference would silently mean "auto",
|
|
|
|
|
// which is a different user intent than the menu selection this
|
|
|
|
|
// operation models.
|
|
|
|
|
if strings.TrimSpace(r.QualityPreference) == "" {
|
|
|
|
|
return errors.New("quality_change requires a quality_preference")
|
|
|
|
|
}
|
2026-07-14 11:51:27 -04:00
|
|
|
default:
|
|
|
|
|
return errors.New("invalid replan operation")
|
|
|
|
|
}
|
|
|
|
|
for _, key := range r.AttemptedPlanKeys {
|
|
|
|
|
if len(key) > 128 || !strings.HasPrefix(key, "v3:") {
|
|
|
|
|
return errors.New("invalid attempted plan key")
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-10 18:14:49 -04:00
|
|
|
if len(r.ClientFeatures) > 64 {
|
|
|
|
|
return errors.New("client_features exceeds supported size")
|
|
|
|
|
}
|
|
|
|
|
for _, feature := range r.ClientFeatures {
|
|
|
|
|
if len(feature) > 128 {
|
|
|
|
|
return errors.New("client feature exceeds supported size")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return validateCapabilitiesV3(&r.Capabilities, &r.ClientPlaybackContext, r.ClientFeatures)
|
2026-07-14 11:51:27 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func validateSelectedTrackIdentityV3(kind string, track *TrackIdentityV3) error {
|
|
|
|
|
if track == nil {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
if len(track.ID) > 128 {
|
|
|
|
|
return fmt.Errorf("%s track id exceeds supported size", kind)
|
|
|
|
|
}
|
|
|
|
|
if track.Index != nil && (*track.Index < 0 || *track.Index > 10_000) {
|
|
|
|
|
return fmt.Errorf("%s track index is invalid", kind)
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// validateCapabilitiesV3 validates and normalizes the shared capability
|
2026-08-10 18:14:49 -04:00
|
|
|
// payload. features carries the request's top-level client_features — the
|
|
|
|
|
// only feature-advertisement location in the contract.
|
|
|
|
|
func validateCapabilitiesV3(c *ClientCodecCapabilitiesV3, ctx *ClientPlaybackContextV3, features []string) error {
|
|
|
|
|
if !validCapabilityEvidenceV3(c.VideoEvidence) {
|
|
|
|
|
return errors.New("video_evidence is required and must be exact, platform_attested, or declared")
|
|
|
|
|
}
|
|
|
|
|
if !validCapabilityEvidenceV3(c.AudioEvidence) {
|
|
|
|
|
return errors.New("audio_evidence is required and must be exact, platform_attested, or declared")
|
|
|
|
|
}
|
|
|
|
|
if len(c.CodecsVideo) > 64 || len(c.CodecsVideoHardware) > 64 || len(c.CodecsAudio) > 64 || len(c.Containers) > 64 || len(c.VideoDecode) > 64 || len(ctx.Deliveries) > 16 || len(ctx.Device.Platform) > 32 || len(ctx.FormFactor) > 32 || len(ctx.AppVersion) > 64 {
|
2026-07-14 11:51:27 -04:00
|
|
|
return errors.New("capability list exceeds supported size")
|
|
|
|
|
}
|
|
|
|
|
deviceValues := []string{
|
2026-08-10 18:14:49 -04:00
|
|
|
ctx.Device.OSVersion, ctx.Device.Manufacturer, ctx.Device.Model,
|
|
|
|
|
ctx.Output.CurrentSink, ctx.Output.SinkType, ctx.Output.OutputContextID,
|
2026-07-14 11:51:27 -04:00
|
|
|
}
|
|
|
|
|
for _, value := range deviceValues {
|
|
|
|
|
if len(value) > 128 {
|
|
|
|
|
return errors.New("device capability value exceeds supported size")
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-10 18:14:49 -04:00
|
|
|
if len(ctx.Device.PlatformDetails) > 16 {
|
|
|
|
|
return errors.New("platform_details exceeds supported size")
|
|
|
|
|
}
|
|
|
|
|
for key, value := range ctx.Device.PlatformDetails {
|
|
|
|
|
if key == "" || len(key) > 128 || len(value) > 128 {
|
|
|
|
|
return errors.New("platform_details entry exceeds supported size")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
for _, values := range [][]string{c.CodecsVideo, c.CodecsVideoHardware, c.CodecsAudio, c.Containers} {
|
2026-07-14 11:51:27 -04:00
|
|
|
for i := range values {
|
|
|
|
|
values[i] = strings.ToLower(strings.TrimSpace(values[i]))
|
|
|
|
|
if len(values[i]) > 128 {
|
|
|
|
|
return errors.New("capability value exceeds supported size")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
for i := range c.VideoDecode {
|
|
|
|
|
c.VideoDecode[i].Codec = strings.ToLower(strings.TrimSpace(c.VideoDecode[i].Codec))
|
|
|
|
|
if c.VideoDecode[i].Codec == "" || len(c.VideoDecode[i].DecoderName) > 128 || c.VideoDecode[i].MaxWidth < 0 || c.VideoDecode[i].MaxHeight < 0 || c.VideoDecode[i].MaxFrameRate < 0 || c.VideoDecode[i].MaxBitrateKbps < 0 {
|
|
|
|
|
return errors.New("invalid detailed video capability")
|
|
|
|
|
}
|
|
|
|
|
if len(c.VideoDecode[i].Profiles) > 64 || len(c.VideoDecode[i].Levels) > 64 || len(c.VideoDecode[i].BitDepths) > 64 {
|
|
|
|
|
return errors.New("detailed video capability exceeds supported size")
|
|
|
|
|
}
|
|
|
|
|
for _, profile := range c.VideoDecode[i].Profiles {
|
|
|
|
|
if len(profile) > 64 {
|
|
|
|
|
return errors.New("detailed video capability value exceeds supported size")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
for _, hdr := range []*HDRCapabilitiesV3{c.HDRDetails, ctx.Output.HDRDetails} {
|
|
|
|
|
if hdr != nil && len(hdr.DolbyVisionProfiles) > 16 {
|
|
|
|
|
return errors.New("dolby vision profile list exceeds supported size")
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-10 18:14:49 -04:00
|
|
|
for name, delivery := range ctx.Deliveries {
|
|
|
|
|
if len(name) > 64 || len(delivery.Containers) > 64 || len(delivery.VideoCodecs) > 64 || len(delivery.AudioDecodeCodecs) > 64 || len(delivery.AudioPassthroughCodecs) > 64 || len(delivery.Features) > 64 || len(delivery.ValidatedClaims) > 64 || len(delivery.Transformations) > 16 {
|
|
|
|
|
return errors.New("delivery capability exceeds supported size")
|
2026-07-14 11:51:27 -04:00
|
|
|
}
|
2026-08-10 18:14:49 -04:00
|
|
|
if delivery.HDRDetails != nil && len(delivery.HDRDetails.DolbyVisionProfiles) > 16 {
|
2026-07-14 11:51:27 -04:00
|
|
|
return errors.New("dolby vision profile list exceeds supported size")
|
|
|
|
|
}
|
2026-08-10 18:14:49 -04:00
|
|
|
for _, values := range [][]string{delivery.Containers, delivery.VideoCodecs, delivery.AudioDecodeCodecs, delivery.AudioPassthroughCodecs, delivery.Features, delivery.ValidatedClaims} {
|
2026-07-14 11:51:27 -04:00
|
|
|
for _, value := range values {
|
|
|
|
|
if len(value) > 64 {
|
2026-08-10 18:14:49 -04:00
|
|
|
return errors.New("delivery capability value exceeds supported size")
|
2026-07-14 11:51:27 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-10 18:14:49 -04:00
|
|
|
seenTransformations := make(map[string]struct{}, len(delivery.Transformations))
|
|
|
|
|
for i := range delivery.Transformations {
|
|
|
|
|
transformation := &delivery.Transformations[i]
|
2026-07-14 11:51:27 -04:00
|
|
|
transformation.Name = strings.ToLower(strings.TrimSpace(transformation.Name))
|
|
|
|
|
transformation.Executor = strings.ToLower(strings.TrimSpace(transformation.Executor))
|
|
|
|
|
transformation.RecipeVersion = strings.TrimSpace(transformation.RecipeVersion)
|
|
|
|
|
if transformation.Name == "" || len(transformation.Name) > 64 ||
|
|
|
|
|
(transformation.Executor != "client" && transformation.Executor != "server") ||
|
|
|
|
|
transformation.RecipeVersion == "" || len(transformation.RecipeVersion) > 32 ||
|
|
|
|
|
len(transformation.ValidatedClaims) > 32 {
|
2026-08-10 18:14:49 -04:00
|
|
|
return errors.New("invalid delivery transformation capability")
|
2026-07-14 11:51:27 -04:00
|
|
|
}
|
2026-08-10 18:14:49 -04:00
|
|
|
if transformation.Executor == ExecutorClientV3 {
|
|
|
|
|
if !delivery.Enabled || !delivery.SupportedOnDevice || !HasFeatureV3(features, FeatureClientVideoTransforms) {
|
2026-07-14 11:51:27 -04:00
|
|
|
return errors.New("client transformation capability is not enabled")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
key := transformation.Executor + ":" + transformation.Name + ":" + transformation.RecipeVersion
|
|
|
|
|
if _, exists := seenTransformations[key]; exists {
|
2026-08-10 18:14:49 -04:00
|
|
|
return errors.New("duplicate delivery transformation capability")
|
2026-07-14 11:51:27 -04:00
|
|
|
}
|
|
|
|
|
seenTransformations[key] = struct{}{}
|
|
|
|
|
for _, claim := range transformation.ValidatedClaims {
|
|
|
|
|
if len(claim) > 128 {
|
|
|
|
|
return errors.New("transformation claim exceeds supported size")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-10 18:14:49 -04:00
|
|
|
ctx.Deliveries[name] = delivery
|
2026-07-14 11:51:27 -04:00
|
|
|
}
|
|
|
|
|
for _, passthrough := range []*AudioPassthroughV3{c.AudioPassthrough, ctx.Output.AudioPassthrough} {
|
|
|
|
|
if passthrough == nil {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if len(passthrough.PassthroughCodecs) > 64 || len(passthrough.Entries) > 64 || passthrough.MaxChannels < 0 || passthrough.MaxChannels > 64 {
|
|
|
|
|
return errors.New("audio passthrough capability exceeds supported size")
|
|
|
|
|
}
|
|
|
|
|
for _, entry := range passthrough.Entries {
|
|
|
|
|
if len(entry.Codec) > 64 || len(entry.ChannelCounts) > 32 || len(entry.Layouts) > 32 {
|
|
|
|
|
return errors.New("audio passthrough entry exceeds supported size")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func validateTrackPairV3(fileID int, kind, id string, index *int) error {
|
|
|
|
|
if len(id) > 128 {
|
|
|
|
|
return fmt.Errorf("%s_track_id exceeds supported size", kind)
|
|
|
|
|
}
|
|
|
|
|
if index != nil && (*index < 0 || *index > 10_000) {
|
|
|
|
|
return fmt.Errorf("%s_track_index is invalid", kind)
|
|
|
|
|
}
|
|
|
|
|
if id == "" || index == nil {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
want := TrackIDV3(fileID, kind, *index)
|
|
|
|
|
if id != want {
|
|
|
|
|
return fmt.Errorf("%s track id and index disagree", kind)
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func validateOptionalBoundedIntV3(v *int, min, max int, name string) error {
|
|
|
|
|
if v != nil && (*v < min || *v > max) {
|
|
|
|
|
return fmt.Errorf("%s is outside the supported range", name)
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func isFiniteV3(v float64) bool { return !math.IsNaN(v) && !math.IsInf(v, 0) }
|
|
|
|
|
|
|
|
|
|
func HasFeatureV3(features []string, wanted string) bool {
|
|
|
|
|
return slices.ContainsFunc(features, func(v string) bool { return strings.EqualFold(strings.TrimSpace(v), wanted) })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func NewTerminalResponseV3(reason, message string, retryable bool) DecisionResponseV3 {
|
|
|
|
|
return DecisionResponseV3{
|
|
|
|
|
ProtocolVersion: ProtocolV3,
|
2026-07-24 14:22:12 -04:00
|
|
|
ServerFeatures: ServerFeaturesV3(),
|
2026-07-14 11:51:27 -04:00
|
|
|
Outcome: OutcomeAdaptationUnavailableV3,
|
|
|
|
|
Terminal: &TerminalV3{Reason: reason, Message: message, Retryable: retryable},
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func NewPlanExpiryV3(now time.Time) string { return now.Add(MaxTokenTTL).UTC().Format(time.RFC3339) }
|