Files
silo-server/internal/playback/protocol_v3.go
T
e5c29eb8e0 feat(activity): report exact client app version, build, and channel (#631)
* feat(activity): report exact client app version, build, and channel

The admin Activity page could not name the build a session was streaming
from. Android already sent X-Silo-Client-Version and the server already
stored it intact, but playbackClientDisplayName routed it through
shortPlaybackClientVersion, which strips non-numeric runes, truncates to
two components, and drops a trailing ".0" — so a client reporting "1.0.0"
rendered as "Silo Android TV 1". The Apple clients sent no client name or
version at all and fell back to user-agent sniffing.

Adds two additive, opaque wire fields alongside the existing client
headers — X-Silo-Client-Build (<=64) and X-Silo-Client-Channel (<=32) —
with client_playback_context.app_build/app_channel as the v3 fallback,
which is also where the previously discarded app_version now gets used.
The server never parses, compares, or enum-validates either value: Apple
uses a per-platform TestFlight sequence and Android a per-marketing-
version counter, and keeping them opaque lets both coexist without a
shared scheme. Any future minimum-version gating belongs on
client_version, which is semver.

Only the named-client branch of playbackClientDisplayName stops
truncating; the user-agent branch keeps shortPlaybackClientVersion, so
browser labels stay "Chrome 120" rather than a full UA version string.
The compact session row is unchanged in width — it is shared with
AdminDashboard, AdminStats, and HouseholdStreamsPanel — and the exact
string lands in the row tooltip and a new Client card in the expanded
panel.

Diagnostic logs carry client_name/version/build/channel on both
"playback plan decided" lines and on session expiry. opslog stores an
open attrs JSONB, so this needs no migration. activity_log is
deliberately untouched: it is the highest-volume table and the value is
constant per device.

Jellyfin compat sessions keep an empty build — the MediaBrowser auth
header vocabulary has no build concept, and synthesizing one from a user
agent would be a guess.

Part of the client-version-visibility work spanning silo-android and
silo-apple.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(activity): resolve client identity without polluting client_version

Review follow-up on the client build/channel work. Fourteen findings; the
substantive ones:

The v3 body fallback took client_playback_context.app_version whenever the
header was absent. The web player sends the literal "web" there and sends no
X-Silo-Client, so every browser session would have stamped client_version="web"
— the one field the contract promises is semver and the field a future
minimum-version gate has to key on. client_playback_context carries no app name,
so the body can never identify a nameless client anyway; the fallback now
applies only to a client that sent X-Silo-Client, and a test pins the "web"
case.

An over-long app_build or app_channel in the start body failed the whole request
with 400 while the same value in a header was silently clamped — an opaque
diagnostic label could refuse playback. validateCapabilitiesV3 now clamps both
with the same helper the header path uses, which is what the docs already
claimed.

Route events posted out of band resolved identity from headers only, so a client
reporting its build in the start body attributed plan_selected to a build and
every later event of the same attempt to none. They now fill empty fields from
the session, as the replan path already did.

playbackClientFullDisplayName discarded build and channel whenever the client
reported no name, so the new Client card could never show a build for a
user-agent-labelled session. It now qualifies whatever label the compact
formatter resolved, which also drops its duplicated name+version assembly.

normalizeClientMetadataValue truncated by bytes; a multi-byte header value cut
mid-rune yields invalid UTF-8, which Postgres rejects — and the per-node session
upserts share one transaction, so one malformed client string would fail that
whole node's sync. It now clamps on a rune boundary.

replan-request.schema.json never got app_build/app_channel even though
ReplanRequestV3 reuses ClientPlaybackContextV3 and validates the same bounds. A
new contract test asserts every $def the two request schemas share is identical,
so the copies cannot drift again.

Also: the four client log attrs move to ClientInfo.LogAttrs(), which is now
their single definition and omits fields the client did not report rather than
persisting empty keys into opslog; startPlannedPlaybackV3 takes the resolved
identity instead of re-parsing the headers; client_label_full is omitted when it
would repeat client_label; getSessionClientLabelFull delegates to
getSessionClientLabel instead of re-implementing it; the Activity search matches
the exact label so a build number is findable; and the web ClientPlaybackContextV3
type mirrors the two new optional fields.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(activity): clamp client identity at the request boundary, by runes

Follow-up to bot review on the previous commit.

The 64/32 clamp for X-Silo-Client-Build / -Channel only ran where newSession
stamped its fields, but the resolved ClientInfo is written straight to the
plan-decision log and to playback_route_events. A client sending a header-sized
build reached both despite the published bound. ClientInfo.Normalized() is now
the single definition of those limits and runs at the request boundary —
playbackClientInfoFromRequest and playbackClientInfoForStartV3 — with newSession
still normalizing because identities also arrive from the Jellyfin and
Audiobookshelf compat surfaces.

normalizeClientMetadataValue now clamps by runes rather than bytes. The bounds
are published to clients as JSON Schema maxLength, which counts characters, so a
byte clamp cut values the contract calls valid — a 32-character emoji channel
was 128 bytes. It also scrubs invalid UTF-8 outright rather than only after a
mid-rune cut, since a header may carry bytes that were never valid UTF-8 and a
text column refuses them.

Two tests cover it: oversized headers clamp at the boundary, and a 40-rune
multi-byte channel lands on the 32-character bound as valid UTF-8.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(activity): strip control characters from client identity

A JSON NUL escape in a v3 start body's app_version, app_build or app_channel
decodes to a real NUL. That is valid UTF-8, so the UTF-8 repair leaves it and
TrimSpace does not treat it as whitespace — but Postgres refuses NUL in a text
column. The per-node session upserts share one transaction, so a single such
start would stop every live session on that node from reconciling until the
offending session went away. Headers cannot carry it (net/http rejects bytes
below 0x20), which is why only the body path this PR added is exposed.

normalizeClientMetadataValue now strips control characters outright rather than
NUL alone: none of them belong in an identity label rendered in the admin UI and
written to structured logs.

Reported by Codex review on b43b7ef06.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(activity): satisfy goconst and misspell on the changed log lines

CI's `golangci-lint --new-from-merge-base` failed on the previous commits: the
two decision-log calls were reformatted into slice literals, which brought their
"component" key inside the changed-lines window where goconst flags it against
the existing logComponentKey constant, and a doc comment used the British
"labelled". Both lines now use the constant, and the spelling is corrected here
and in docs/settings-api.md.

The file's other 16 "component" literals are left alone: CI only requires the
lines a branch touches to be clean, and rewriting them would bury this change in
unrelated churn.

Verified with the same command and version CI runs (golangci-lint v2.12.2,
--new-from-merge-base=origin/main): 0 issues.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(playback): require context-aware session starts

startPlannedPlaybackV3 probed for StartSessionWithFilesContext with a type
assertion and fell back to the context-free StartSessionWithFiles. The context
is how the reporting client's identity reaches the new session, so any
implementation missing the method would start sessions carrying no client name,
version, build or channel — silently, and now that build and channel ride the
same path, silently losing more.

SessionManagerInterface requires the method instead, so a non-conforming
implementation fails to compile rather than dropping the identity at run time.
The one test double gains a three-line method; production already implemented it.

Raised as a nitpick by CodeRabbit review; pre-existing, but it is this PR's data
that the fallback drops.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 09:18:47 -04:00

1088 lines
48 KiB
Go

package playback
import (
"errors"
"fmt"
"math"
"regexp"
"slices"
"strings"
"time"
)
const (
ProtocolV3 = 3
FeaturePlaybackPlanV3 = "playback_plan_v3"
FeatureNeutralContractV3 = "neutral_playback_v3_contract_v1"
FeatureLayoutPassthrough = "layout_aware_passthrough"
FeatureClientVideoTransforms = "client_video_transformations_v1"
FeatureRouteDiagnostics = "playback_route_diagnostics"
FeatureDeviceQuirksV3 = "device_quirks_v1"
FeatureSeekReanchorV3 = "seek_reanchor_v1"
FeatureOutputChangeV3 = "output_change_v1"
FeatureDirectStreamResumeV3 = "direct_stream_resume_v1"
FeaturePlanSourceDurationV3 = "plan_source_duration_v1"
PlanRecipeVersionV3 = "v3.4"
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"
)
// 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,
FeatureNeutralContractV3,
FeatureLayoutPassthrough,
FeatureRouteDiagnostics,
FeatureDeviceQuirksV3,
FeatureSeekReanchorV3,
FeatureOutputChangeV3,
FeatureDirectStreamResumeV3,
// 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,
}
}
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"
)
// 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 {
switch d {
case DeliveryOriginalHTTPV3:
return DeliveryClassOriginalHTTPV3
case DeliveryRemuxProgressiveV3:
return DeliveryClassProgressiveV3
case DeliveryRemuxHLSV3, DeliveryTranscodeHLSV3:
return DeliveryClassHLSV3
default:
return string(d)
}
}
type StreamProtocolV3 string
const (
StreamHTTPProgressiveV3 StreamProtocolV3 = "http_progressive"
StreamHLSV3 StreamProtocolV3 = "hls"
)
type HeaderRefreshModeV3 string
const (
HeaderRefreshNoneV3 HeaderRefreshModeV3 = "none"
HeaderRefreshSessionV3 HeaderRefreshModeV3 = "session"
)
// 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"
)
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"`
HDR10MaxWidth int `json:"hdr10_max_width,omitempty"`
HDR10MaxHeight int `json:"hdr10_max_height,omitempty"`
HDR10MaxFrameRate float64 `json:"hdr10_max_frame_rate,omitempty"`
HDR10MaxBitrateKbps int `json:"hdr10_max_bitrate_kbps,omitempty"`
DolbyVisionProfiles []int `json:"dolby_vision_profiles"`
DolbyVisionProfileLevels []DolbyVisionProfileCapabilityV3 `json:"dolby_vision_profile_levels,omitempty"`
}
type DolbyVisionProfileCapabilityV3 struct {
Profile int `json:"profile"`
MaxLevel int `json:"max_level"`
BLCompatibilityIDs []int `json:"bl_compatibility_ids,omitempty"`
}
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"`
}
// 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
}
type ClientCodecCapabilitiesV3 struct {
// 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"`
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"`
}
// 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.
type DeviceContextV3 struct {
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"`
}
type OutputContextV3 struct {
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 {
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"`
}
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.
type ClientPlaybackContextV3 struct {
ProtocolVersion int `json:"protocol_version"`
FormFactor string `json:"form_factor"`
AppVersion string `json:"app_version"`
// AppBuild and AppChannel are the request-body fallback for the
// X-Silo-Client-Build / X-Silo-Client-Channel headers. Both are opaque
// strings the server stores verbatim.
AppBuild string `json:"app_build,omitempty"`
AppChannel string `json:"app_channel,omitempty"`
Device DeviceContextV3 `json:"device"`
Output OutputContextV3 `json:"output"`
Deliveries map[string]DeliveryCapabilityV3 `json:"deliveries"`
}
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"`
ProgressPersistence ProgressPersistenceV3 `json:"progress_persistence,omitempty"`
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"`
}
// 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"
)
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"
// 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"
// ReplanOperationOutputChangeV3 refreshes output capabilities without
// declaring the active route failed, so an unchanged route stays eligible.
ReplanOperationOutputChangeV3 ReplanOperationV3 = "output_change"
)
type ReplanRequestV3 struct {
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"`
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"`
Failure FailureV3 `json:"failure,omitzero"`
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"`
OutputContextID string `json:"output_context_id,omitempty"`
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 {
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"`
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"`
ColorRange string `json:"color_range,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"`
HDR10Plus bool `json:"hdr10_plus"`
DVProfile int `json:"dolby_vision_profile,omitempty"`
DVLevel int `json:"dolby_vision_level,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"`
// 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"`
}
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"`
// 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"`
}
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"`
}
// 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"`
}
type PlanV3 struct {
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"`
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"`
AvailableQualities []AvailableQualityV3 `json:"available_qualities"`
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 {
ProtocolVersion int `json:"protocol_version"`
ServerFeatures []string `json:"server_features"`
Outcome DecisionOutcomeV3 `json:"outcome"`
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")
}
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")
}
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
case QualityOriginalV3, "source", "max":
return QualityOriginalV3, false
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")
}
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")
}
}
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.
case ReplanOperationTrackChangeV3:
// A user track change is not a failure; no classification is required.
if r.Failure != (FailureV3{}) {
return errors.New("track_change must not include failure")
}
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")
}
if r.Failure != (FailureV3{}) {
return errors.New("quality_change must not include failure")
}
case ReplanOperationOutputChangeV3:
// Output capability refreshes are intent changes, not route failures.
if r.Failure != (FailureV3{}) {
return errors.New("output_change must not include failure")
}
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")
}
}
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)
}
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
// 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 {
return errors.New("capability list exceeds supported size")
}
// Build and channel are opaque diagnostic labels, so an over-long value is
// worth clamping and never worth refusing playback over. The header route
// (X-Silo-Client-Build / -Channel) clamps with the same helper; rejecting
// here would mean the same string plays from a header and 400s from the
// body.
ctx.AppBuild = normalizeClientMetadataValue(ctx.AppBuild, 64)
ctx.AppChannel = normalizeClientMetadataValue(ctx.AppChannel, 32)
deviceValues := []string{
ctx.Device.OSVersion, ctx.Device.Manufacturer, ctx.Device.Model,
ctx.Output.CurrentSink, ctx.Output.SinkType, ctx.Output.OutputContextID,
}
for _, value := range deviceValues {
if len(value) > 128 {
return errors.New("device capability value exceeds supported size")
}
}
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} {
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 err := validateHDRCapabilitiesV3(hdr); err != nil {
return err
}
}
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")
}
if err := validateHDRCapabilitiesV3(delivery.HDRDetails); err != nil {
return err
}
for _, values := range [][]string{delivery.Containers, delivery.VideoCodecs, delivery.AudioDecodeCodecs, delivery.AudioPassthroughCodecs, delivery.Features, delivery.ValidatedClaims} {
for _, value := range values {
if len(value) > 64 {
return errors.New("delivery capability value exceeds supported size")
}
}
}
seenTransformations := make(map[string]struct{}, len(delivery.Transformations))
for i := range delivery.Transformations {
transformation := &delivery.Transformations[i]
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 {
return errors.New("invalid delivery transformation capability")
}
if transformation.Executor == ExecutorClientV3 {
if !delivery.Enabled || !delivery.SupportedOnDevice || !HasFeatureV3(features, FeatureClientVideoTransforms) {
return errors.New("client transformation capability is not enabled")
}
}
key := transformation.Executor + ":" + transformation.Name + ":" + transformation.RecipeVersion
if _, exists := seenTransformations[key]; exists {
return errors.New("duplicate delivery transformation capability")
}
seenTransformations[key] = struct{}{}
for _, claim := range transformation.ValidatedClaims {
if len(claim) > 128 {
return errors.New("transformation claim exceeds supported size")
}
}
}
ctx.Deliveries[name] = delivery
}
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 validateHDRCapabilitiesV3(hdr *HDRCapabilitiesV3) error {
if hdr == nil {
return nil
}
if hdr.HDR10MaxWidth < 0 || hdr.HDR10MaxHeight < 0 || hdr.HDR10MaxFrameRate < 0 || hdr.HDR10MaxBitrateKbps < 0 {
return errors.New("invalid hdr10 capability limit")
}
if !hdr.HDR10 && (hdr.HDR10MaxWidth > 0 || hdr.HDR10MaxHeight > 0 || hdr.HDR10MaxFrameRate > 0 || hdr.HDR10MaxBitrateKbps > 0) {
return errors.New("hdr10 capability limits require hdr10 support")
}
if len(hdr.DolbyVisionProfiles) > 16 || len(hdr.DolbyVisionProfileLevels) > 16 {
return errors.New("dolby vision profile list exceeds supported size")
}
seenProfiles := make(map[int]struct{}, len(hdr.DolbyVisionProfileLevels))
for _, capability := range hdr.DolbyVisionProfileLevels {
if capability.Profile <= 0 || capability.MaxLevel < 1 || capability.MaxLevel > 13 {
return errors.New("invalid dolby vision profile level capability")
}
if _, exists := seenProfiles[capability.Profile]; exists {
return errors.New("duplicate dolby vision profile level capability")
}
seenProfiles[capability.Profile] = struct{}{}
if len(capability.BLCompatibilityIDs) > 16 {
return errors.New("dolby vision base-layer compatibility list exceeds supported size")
}
seenCompatibilityIDs := make(map[int]struct{}, len(capability.BLCompatibilityIDs))
for _, compatibilityID := range capability.BLCompatibilityIDs {
if compatibilityID < 0 || compatibilityID > 15 {
return errors.New("invalid dolby vision base-layer compatibility id")
}
if _, exists := seenCompatibilityIDs[compatibilityID]; exists {
return errors.New("duplicate dolby vision base-layer compatibility id")
}
seenCompatibilityIDs[compatibilityID] = struct{}{}
}
}
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,
ServerFeatures: ServerFeaturesV3(),
Outcome: OutcomeAdaptationUnavailableV3,
Terminal: &TerminalV3{Reason: reason, Message: message, Retryable: retryable},
}
}
func NewPlanExpiryV3(now time.Time) string { return now.Add(MaxTokenTTL).UTC().Format(time.RFC3339) }