From 1c7125844f2f9d1d4ed21927764a73fcef28b4cd Mon Sep 17 00:00:00 2001 From: Jonah May <119529402+JonahMMay@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:11:12 -0500 Subject: [PATCH 1/3] fix(playback): make HLS variable substitution opt-in Transcode playback on the Tizen TV app 401'd on every segment: large synthetic manifests (any feature-length title) carried their access query once through #EXT-X-DEFINE and wrote segment URIs as ?{$silo_query}. AVPlay, like most native HLS stacks, ignores the tag and requests the literal URI without the st stream token. The substitution came in with the upstream sync (#174) for hls.js parsing speed. Only a client that declares hls_variable_substitution_v1 now gets the compact form. The plan's HLS manifest URL carries a non-secret hls_vars=1 flag, read from the manifest request's raw query, so the opt-in survives token reconstruction, the API relay to a transcode node (which strips only st) and proxy token/grant routes without session state. Every other client gets the legacy per-segment query again. The web player advertises the feature only when it expects to play through hls.js (non-Safari with Media Source); Safari stays native. Co-Authored-By: Claude Opus 5.5 (1M context) --- contracts/api/v2/openapi.json | 24 +++++++++++ docs/architecture/playback-protocol-v3.md | 17 +++++++- internal/api/handlers/playback_v3.go | 36 +++++++++++++++- internal/api/handlers/playback_v3_test.go | 26 ++++++++++++ internal/apiv2/playback_delivery.go | 5 +++ internal/playback/protocol_v3.go | 8 ++++ internal/playback/transcode.go | 27 +++++++++++- internal/playback/transcode_manifest_test.go | 42 ++++++++++++++++++- internal/transcodenode/streaming_protocol.go | 3 +- scripts/prairie-invariants.txt | 2 + web/src/api/v2/schema.ts | 4 ++ .../player/hooks/usePlaybackSession.test.ts | 14 +++++++ web/src/player/hooks/usePlaybackSession.ts | 7 ++-- web/src/player/playback-session-wire-v3.ts | 14 +++++++ web/src/player/protocol-v3.ts | 11 +++++ web/src/player/utils/hlsEngine.test.ts | 23 +++++++++- web/src/player/utils/hlsEngine.ts | 20 +++++++++ 17 files changed, 273 insertions(+), 10 deletions(-) diff --git a/contracts/api/v2/openapi.json b/contracts/api/v2/openapi.json index c5a57f158b..7ea0bb9518 100644 --- a/contracts/api/v2/openapi.json +++ b/contracts/api/v2/openapi.json @@ -158425,6 +158425,14 @@ "type": "string" } }, + { + "description": "1 on a plan URL whose client advertised hls_variable_substitution_v1: a large synthetic manifest may carry its query once through EXT-X-DEFINE instead of on every segment link. Not a credential; segment links repeat the manifest query.", + "in": "query", + "name": "hls_vars", + "schema": { + "type": "string" + } + }, { "description": "Optional. When present, it must name a profile of the authenticated account.", "in": "header", @@ -158618,6 +158626,14 @@ "type": "string" } }, + { + "description": "1 on a plan URL whose client advertised hls_variable_substitution_v1: a large synthetic manifest may carry its query once through EXT-X-DEFINE instead of on every segment link. Not a credential; segment links repeat the manifest query.", + "in": "query", + "name": "hls_vars", + "schema": { + "type": "string" + } + }, { "in": "path", "name": "name", @@ -193500,6 +193516,14 @@ "schema": { "type": "string" } + }, + { + "description": "Value1 lets a large synthetic manifest carry the raw query once through EXT-X-DEFINE; otherwise every segment link repeats it.", + "in": "query", + "name": "hls_vars", + "schema": { + "type": "string" + } } ], "description": "Node bearer protects the worker listener. A session carrying the deny marker returns410 and is neither served nor reconstructed. Missing process-local sessions may reconstruct through existing signed-token/stored-recipe, input and execution authority guards; a missing/refused reconstruction can return404. Reads refresh liveness; no durable session availability, unscoped reconstruction or native route is promised. Build the playback manifest; unavailable manifest returns503. No-store response contains relative segment links.", diff --git a/docs/architecture/playback-protocol-v3.md b/docs/architecture/playback-protocol-v3.md index bd5d20c1de..7fe3073586 100644 --- a/docs/architecture/playback-protocol-v3.md +++ b/docs/architecture/playback-protocol-v3.md @@ -701,8 +701,8 @@ parameter, and never carries a parameter across families. | Route family | Routes | Query parameters | | --- | --- | --- | -| Media | `/stream/{session_id}`, `/playback/transcode/{session_id}/master.m3u8` and its segments | `seek` only — the progressive-remux start offset in seconds, present only when it is non-zero | -| Media on a designated origin | `{proxy}/stream/v3/{session_id}`, `{proxy}/stream/v3/{session_id}/master.m3u8` and its `segment/{name}` children (§4.1) | `seek` only, with the same meaning; these routes never accept a credential parameter of any kind | +| Media | `/stream/{session_id}`, `/playback/transcode/{session_id}/master.m3u8` and its segments | `seek` — the progressive-remux start offset in seconds, present only when it is non-zero; `hls_vars=1` on the HLS manifest of an attempt that advertised `hls_variable_substitution_v1` (below) | +| Media on a designated origin | `{proxy}/stream/v3/{session_id}`, `{proxy}/stream/v3/{session_id}/master.m3u8` and its `segment/{name}` children (§4.1) | `seek` and `hls_vars`, with the same meanings; these routes never accept a credential parameter of any kind | | Subtitle artifact | `/stream/{session_id}/subtitles/{combined_index}{.ext}`, `/stream/{session_id}/subtitles/{combined_index}/fonts` | `file_id`, always; one identity pin: `embedded_stream_index`, `external_subtitle_key`, or `downloaded_subtitle_id` (§8); `original=1` on an original-SRT `.srt` URL (§8). VTT receivers may explicitly request `timestamp_offset` in seconds | A media route never carries `file_id` or `downloaded_subtitle_id` — the session @@ -719,6 +719,19 @@ carries the signed stream token `st` on its media URLs — never on subtitle or font-bundle routes. It is an opaque transport credential rather than a playback parameter, and it is outside the table above. +`hls_variable_substitution_v1` is a client declaration (the server does not +advertise it) that the attempt's HLS engine implements `#EXT-X-DEFINE` variable +substitution, as hls.js does. The server then adds `hls_vars=1` to the plan's +HLS manifest URL, and a large synthetic transcode manifest may define its query +once (`#EXT-X-DEFINE:NAME="silo_query"`, `EXT-X-VERSION:8`) and write every +segment URI as `seg_NNNNN.ext?{$silo_query}` instead of repeating a +token-bearing query on each of them. Without the flag every manifest keeps the +per-segment query. The opt-in exists because native HLS stacks (Tizen AVPlay, +and platform players generally) ignore the tag and would request segments +without their `st` token, so a client that may play natively must not send it. +The flag is not a credential and is not signed; segment links repeat the +manifest query, flag included. + --- ## 5. The timeline model diff --git a/internal/api/handlers/playback_v3.go b/internal/api/handlers/playback_v3.go index b6c91f3eac..f69c3cabc0 100644 --- a/internal/api/handlers/playback_v3.go +++ b/internal/api/handlers/playback_v3.go @@ -2012,7 +2012,7 @@ func (h *PlaybackHandler) startPlannedPlaybackV3(r *http.Request, userID int, pr abort() return playback.DecisionResponseV3{}, subtitleArtifactErrorV3("Failed to freeze the selected subtitle identity.", frozenErr) } - result.Plan.Stream.URL = transport.url + result.Plan.Stream.URL = hlsVariableSubstitutionURLV3(transport.url, req.ClientFeatures) if err := h.attachSubtitleArtifactV3(r.Context(), session.ID, effectiveFile, result.Plan, result.SubtitleTrackIndex, &frozenRecipe, req.ClientFeatures); err != nil { transport.rollback() abort() @@ -4181,6 +4181,37 @@ func (h *PlaybackHandler) grantManifestURLV3(ctx context.Context, card playback. return base + "/stream/v3/" + card.SessionID + "/master.m3u8", true, prior } +// hlsVariableSubstitutionURLV3 marks an HLS manifest URL as safe for HLS +// variable substitution when the attempt's client advertised +// hls_variable_substitution_v1 (see playback.HLSVariableSubstitutionQueryParam). +// Every other URL is returned unchanged, so native players keep the legacy +// per-segment query they can resolve. +// +// The opt-in is a plain query flag rather than session state because the +// manifest is served from sessions that never saw the plan request: a session +// reconstructed from its token recipe, the API relay to a transcode node (which +// strips only st), and proxy token and grant routes (which forward the raw +// query). The URL is the one thing all of them receive intact. +func hlsVariableSubstitutionURLV3(streamURL string, clientFeatures []string) string { + if !playback.HasFeatureV3(clientFeatures, playback.FeatureHLSVariableSubstitutionV3) { + return streamURL + } + path, query, _ := strings.Cut(streamURL, "?") + if !strings.HasSuffix(path, "/master.m3u8") { + return streamURL + } + flag := playback.HLSVariableSubstitutionQueryParam + "=1" + if query == "" { + return path + "?" + flag + } + for pair := range strings.SplitSeq(query, "&") { + if pair == flag { + return streamURL + } + } + return streamURL + "&" + flag +} + // sourceExecutionMetadataV3 freezes the source facts used by a remote executor. func sourceExecutionMetadataV3(file *models.MediaFile, result playback.PlannerResultV3) playback.SourceExecutionMetadataV3 { if result.FrozenSourceMetadata != nil { @@ -5202,6 +5233,9 @@ func (h *PlaybackHandler) executeReplanV3(r *http.Request, record *playback.Atte // receipt into the already validated recipe instead of rerunning a // fallible subtitle-identity freeze after authority publication. artifactRecipe.ToneMapMode = result.ToneMapMode + // A reused transport keeps its published URL verbatim; only a fresh one + // picks up the attempt's manifest-format opt-in. + transport.url = hlsVariableSubstitutionURLV3(transport.url, start.ClientFeatures) } result.Plan.Stream.URL = transport.url response := playback.DecisionResponseV3{ProtocolVersion: playback.ProtocolV3, ServerFeatures: serverFeaturesForRequestV3(r.Context()), Outcome: playback.OutcomePlayableV3, SessionID: session.ID, PlaybackPlan: result.Plan} diff --git a/internal/api/handlers/playback_v3_test.go b/internal/api/handlers/playback_v3_test.go index 70fea27707..28faa07f2b 100644 --- a/internal/api/handlers/playback_v3_test.go +++ b/internal/api/handlers/playback_v3_test.go @@ -6853,3 +6853,29 @@ func TestHEVCEncodedPlanCarriesHVC1ToTransport(t *testing.T) { t.Fatalf("H264 carried HEVC tag=%q", got) } } + +func TestHLSVariableSubstitutionURLV3IsOptIn(t *testing.T) { + optIn := []string{playback.FeaturePlaybackPlanV3, playback.FeatureHLSVariableSubstitutionV3} + legacy := []string{playback.FeaturePlaybackPlanV3, playback.FeatureSeekReanchorV3} + for _, test := range []struct { + name, url string + features []string + want string + }{ + {"legacy client keeps signed URL", "/playback/transcode/s1/master.m3u8?st=tok", legacy, "/playback/transcode/s1/master.m3u8?st=tok"}, + {"legacy client keeps tokenless URL", "/playback/transcode/s1/master.m3u8", nil, "/playback/transcode/s1/master.m3u8"}, + {"opt-in appends after stream token", "/playback/transcode/s1/master.m3u8?st=tok", optIn, "/playback/transcode/s1/master.m3u8?st=tok&hls_vars=1"}, + {"opt-in tokenless URL", "/playback/transcode/s1/master.m3u8", optIn, "/playback/transcode/s1/master.m3u8?hls_vars=1"}, + {"opt-in proxy token route", "https://proxy.example/stream/transcode/tok/master.m3u8", optIn, "https://proxy.example/stream/transcode/tok/master.m3u8?hls_vars=1"}, + {"opt-in proxy grant route", "https://proxy.example/stream/v3/s1/master.m3u8", optIn, "https://proxy.example/stream/v3/s1/master.m3u8?hls_vars=1"}, + {"opt-in is idempotent", "/playback/transcode/s1/master.m3u8?st=tok&hls_vars=1", optIn, "/playback/transcode/s1/master.m3u8?st=tok&hls_vars=1"}, + {"feature spelling is normalized", "/playback/transcode/s1/master.m3u8", []string{" HLS_Variable_Substitution_V1 "}, "/playback/transcode/s1/master.m3u8?hls_vars=1"}, + {"progressive URL is untouched", "/stream/s1?st=tok", optIn, "/stream/s1?st=tok"}, + } { + t.Run(test.name, func(t *testing.T) { + if got := hlsVariableSubstitutionURLV3(test.url, test.features); got != test.want { + t.Fatalf("hlsVariableSubstitutionURLV3(%q) = %q, want %q", test.url, got, test.want) + } + }) + } +} diff --git a/internal/apiv2/playback_delivery.go b/internal/apiv2/playback_delivery.go index 5d0e2524bb..eb7b90d94b 100644 --- a/internal/apiv2/playback_delivery.go +++ b/internal/apiv2/playback_delivery.go @@ -41,6 +41,8 @@ const ( playbackContentEncoding = "Content-Encoding" ) +const hlsVariableSubstitutionParamDescription = "1 on a plan URL whose client advertised hls_variable_substitution_v1: a large synthetic manifest may carry its query once through EXT-X-DEFINE instead of on every segment link. Not a credential; segment links repeat the manifest query." + // PlaybackMediaHandlers shares the raw byte delivery protocols and the typed // font service. Token-carried reconstruction and deny markers live in these // shared operations; the v2 listener owns JSON envelopes and problem responses. @@ -104,6 +106,9 @@ func registerPlaybackDelivery(reg *Registry) { {Name: playbackAccountToken, In: playbackParamQuery, Description: "Media-element fallback for the account bearer token when an Authorization header cannot be set. Header-authenticated media requires the Authorization header and the profile selector.", Schema: &huma.Schema{Type: huma.TypeString}}, {Name: "st", In: playbackParamQuery, Description: "Signed stream reference the plan URL carries; it reconstructs the session after a restart. Omitted for header-authenticated media. Account and viewer authorization are always required.", Schema: &huma.Schema{Type: huma.TypeString}}, } + if route.protocol == "hls" { + params = append(params, &huma.Param{Name: playback.HLSVariableSubstitutionQueryParam, In: playbackParamQuery, Description: hlsVariableSubstitutionParamDescription, Schema: &huma.Schema{Type: huma.TypeString}}) + } if route.id == playbackSegmentOperation { params = append(params, &huma.Param{Name: playbackSegmentName, In: playbackParamPath, Required: true, Schema: &huma.Schema{Type: huma.TypeString, MinLength: new(1)}}) } diff --git a/internal/playback/protocol_v3.go b/internal/playback/protocol_v3.go index 2b7d08e545..ecc38bb297 100644 --- a/internal/playback/protocol_v3.go +++ b/internal/playback/protocol_v3.go @@ -69,6 +69,14 @@ const ( // the client's ordinary recovery then mints a fresh attempt that plans // against the now-persisted verdict. FeaturePlanInvalidatedV3 = "plan_invalidated_v1" + // FeatureHLSVariableSubstitutionV3 is the client's statement that its HLS + // player implements EXT-X-DEFINE variable substitution (hls.js does). Only + // then may a large synthetic transcode manifest carry its access query once + // as a variable instead of on every segment URI. It is opt-in because + // native players (Tizen AVPlay, most platform HLS stacks) ignore the tag + // and would request segments without the stream token. It is a client + // declaration only; the server does not advertise it. + FeatureHLSVariableSubstitutionV3 = "hls_variable_substitution_v1" // FeatureSubripSidecarV3 is the client's statement that it parses SubRip // itself, including {\anN} placement. An opted-in client receives // external and downloaded SRT tracks as the original .srt bytes instead diff --git a/internal/playback/transcode.go b/internal/playback/transcode.go index 5950f9cd60..cd38da5c9a 100644 --- a/internal/playback/transcode.go +++ b/internal/playback/transcode.go @@ -346,10 +346,23 @@ const maxSyntheticManifestSegments = 50_000 // and hls.js parsing. HLS variable substitution keeps the query once while // preserving the exact resolved segment URLs. Keep small playlists on the // simpler legacy form; below this byte threshold the saving is immaterial. +// +// Substitution is opt-in (see HLSVariableSubstitutionQueryParam): native HLS +// stacks such as Tizen AVPlay ignore #EXT-X-DEFINE and request the literal +// "?{$silo_query}" URI, which drops the stream token and 401s every segment. const minManifestQuerySubstitutionSavings = 64 * 1024 const manifestQueryVariable = "silo_query" +// HLSVariableSubstitutionQueryParam, set to "1" on a manifest URL, allows a +// large synthetic manifest to carry its access query once through +// #EXT-X-DEFINE instead of repeating it on every segment URI. The server adds +// it only to plan URLs whose client advertised +// FeatureHLSVariableSubstitutionV3, so it rides the URL through every serve +// path (local, reconstructed, relayed, proxied) without session state. It is a +// non-secret presentation hint: it grants nothing and is not signed. +const HLSVariableSubstitutionQueryParam = "hls_vars" + // remountStartOffsetSeconds is a positive, effectively-zero HLS start offset. // Media3 suppresses live-edge position projection for EVENT playlists only // when EXT-X-START is positive. Anchoring one millisecond after the generation @@ -2954,13 +2967,25 @@ func syntheticManifestQuery(segmentCount int, rawQuery string) (definition, suff legacySuffix := "?" + rawQuery variableSuffix := "?{$" + manifestQueryVariable + "}" savingsPerSegment := len(legacySuffix) - len(variableSuffix) - if savingsPerSegment <= 0 || int64(segmentCount)*int64(savingsPerSegment) < minManifestQuerySubstitutionSavings || strings.ContainsAny(rawQuery, "\"\r\n") { + if !manifestQueryAllowsVariableSubstitution(rawQuery) || savingsPerSegment <= 0 || int64(segmentCount)*int64(savingsPerSegment) < minManifestQuerySubstitutionSavings || strings.ContainsAny(rawQuery, "\"\r\n") { return "", legacySuffix, 0 } definition = fmt.Sprintf("#EXT-X-DEFINE:NAME=\"%s\",VALUE=\"%s\"\n", manifestQueryVariable, rawQuery) return definition, variableSuffix, 8 } +// manifestQueryAllowsVariableSubstitution reports whether the manifest request +// opted into HLS variable substitution. Every other client gets the legacy +// per-segment query, which all HLS players resolve. +func manifestQueryAllowsVariableSubstitution(rawQuery string) bool { + for pair := range strings.SplitSeq(rawQuery, "&") { + if pair == HLSVariableSubstitutionQueryParam+"=1" { + return true + } + } + return false +} + // GetSegment returns the file path of a named segment if it exists. func (s *TranscodeSession) GetSegment(name string) (string, error) { // Sanitize the name to prevent directory traversal. diff --git a/internal/playback/transcode_manifest_test.go b/internal/playback/transcode_manifest_test.go index a75d2fd25f..7b5ce904a9 100644 --- a/internal/playback/transcode_manifest_test.go +++ b/internal/playback/transcode_manifest_test.go @@ -544,7 +544,7 @@ func TestRestartSeekTarget_MidStreamSeekUsesSegmentIndexNotZero(t *testing.T) { } func TestGenerateFullManifestCompactsRepeatedAuthenticationQuery(t *testing.T) { - rawQuery := "st=" + strings.Repeat("recipe", 80) + "&token=" + strings.Repeat("access", 40) + rawQuery := "st=" + strings.Repeat("recipe", 80) + "&token=" + strings.Repeat("access", 40) + "&" + HLSVariableSubstitutionQueryParam + "=1" session := &TranscodeSession{opts: TranscodeOpts{ TargetCodecVideo: "h264", SegmentDuration: 1, @@ -571,6 +571,46 @@ func TestGenerateFullManifestCompactsRepeatedAuthenticationQuery(t *testing.T) { } } +// Native HLS stacks (Tizen AVPlay among them) ignore #EXT-X-DEFINE and would +// request the literal "?{$silo_query}" URI without the stream token. Without +// the explicit opt-in a large manifest must keep the per-segment query. +func TestGenerateFullManifestKeepsPerSegmentQueryWithoutOptIn(t *testing.T) { + for _, rawQuery := range []string{ + "st=" + strings.Repeat("recipe", 80) + "&token=" + strings.Repeat("access", 40), + "st=" + strings.Repeat("recipe", 80) + "&" + HLSVariableSubstitutionQueryParam + "=0", + "st=" + strings.Repeat("recipe", 80) + "&x" + HLSVariableSubstitutionQueryParam + "=1", + } { + for _, codec := range []string{"h264", "hevc"} { + session := &TranscodeSession{opts: TranscodeOpts{ + TargetCodecVideo: codec, + SegmentDuration: 1, + TotalDuration: 300, + }} + manifest := string(session.GenerateFullManifest("segment/", rawQuery)) + for _, forbidden := range []string{"#EXT-X-DEFINE", "{$", "#EXT-X-VERSION:8"} { + if strings.Contains(manifest, forbidden) { + t.Fatalf("%s manifest without opt-in contains %q", codec, forbidden) + } + } + segExt := hlsSegmentExtension(session.opts) + for _, want := range []string{ + "segment/seg_00000" + segExt + "?" + rawQuery + "\n", + "segment/seg_00299" + segExt + "?" + rawQuery + "\n", + } { + if !strings.Contains(manifest, want) { + t.Fatalf("%s manifest missing legacy segment URI %q", codec, want) + } + } + if segExt == ".m4s" && !strings.Contains(manifest, "#EXT-X-MAP:URI=\"segment/init.mp4?"+rawQuery+"\"") { + t.Fatalf("%s manifest init map lost the per-URI query", codec) + } + if got := strings.Count(manifest, rawQuery); got != 300+strings.Count(manifest, "#EXT-X-MAP") { + t.Fatalf("%s manifest repeats the query %d times, want once per URI", codec, got) + } + } + } +} + func TestBuildPlaybackManifest_LongEncodedTranscodeUsesRealManifest(t *testing.T) { tempDir := t.TempDir() manifest := strings.Join([]string{ diff --git a/internal/transcodenode/streaming_protocol.go b/internal/transcodenode/streaming_protocol.go index 67c06f9b73..ec7adf9749 100644 --- a/internal/transcodenode/streaming_protocol.go +++ b/internal/transcodenode/streaming_protocol.go @@ -65,7 +65,8 @@ func ProtocolStreaming() []workerprotocol.Operation { case kindManifest, kindSegment: op.Description = "Node bearer protects the worker listener. A session carrying the deny marker returns410 and is neither served nor reconstructed. Missing process-local sessions may reconstruct through existing signed-token/stored-recipe, input and execution authority guards; a missing/refused reconstruction can return404. Reads refresh liveness; no durable session availability, unscoped reconstruction or native route is promised. " if mount.kind == kindManifest { - op.Parameters = append(op.Parameters, &huma.Param{Name: playback.SourceTimelineQueryParam, In: queryParameter, Schema: &huma.Schema{Type: huma.TypeString}, Description: "Value1 requests source-aligned manifest; raw query is preserved in segment links."}) + op.Parameters = append(op.Parameters, &huma.Param{Name: playback.SourceTimelineQueryParam, In: queryParameter, Schema: &huma.Schema{Type: huma.TypeString}, Description: "Value1 requests source-aligned manifest; raw query is preserved in segment links."}, + &huma.Param{Name: playback.HLSVariableSubstitutionQueryParam, In: queryParameter, Schema: &huma.Schema{Type: huma.TypeString}, Description: "Value1 lets a large synthetic manifest carry the raw query once through EXT-X-DEFINE; otherwise every segment link repeats it."}) op.Description += "Build the playback manifest; unavailable manifest returns503. No-store response contains relative segment links." op.Responses["200"] = &huma.Response{Description: "Playback manifest", Content: map[string]*huma.MediaType{"application/vnd.apple.mpegurl": text}} } else { diff --git a/scripts/prairie-invariants.txt b/scripts/prairie-invariants.txt index ebaccd8e16..9e33d62171 100644 --- a/scripts/prairie-invariants.txt +++ b/scripts/prairie-invariants.txt @@ -32,6 +32,8 @@ web/src/lib/adminNavigation.ts 1 [Ll]ive ?TV|livetv admin Live TV entry # --- Tizen playback. internal/playback/transcode.go 1 func isUnsupportedTizenTag strip HLS tags Tizen rejects (ba9188f, dropped by #174) +internal/playback/transcode.go 1 !manifestQueryAllowsVariableSubstitution\(rawQuery\) EXT-X-DEFINE manifests are opt-in: Tizen AVPlay ignores the tag and 401s every segment (#174 regression) +internal/api/handlers/playback_v3.go 2 hlsVariableSubstitutionURLV3\(transport\.url only plans whose client advertised hls_variable_substitution_v1 get hls_vars=1 internal/playback/audio_channels.go 1 func EffectiveAudioChannels client channel ceiling (#150) internal/playback/audio_select.go 1 func SelectClientPlayableAudioTrack decodable companion track (#151) diff --git a/web/src/api/v2/schema.ts b/web/src/api/v2/schema.ts index 1f3e0edc1d..b1d349e82b 100644 --- a/web/src/api/v2/schema.ts +++ b/web/src/api/v2/schema.ts @@ -104269,6 +104269,8 @@ export interface operations { getPlaybackManifest: { parameters: { query?: { + /** @description 1 on a plan URL whose client advertised hls_variable_substitution_v1: a large synthetic manifest may carry its query once through EXT-X-DEFINE instead of on every segment link. Not a credential; segment links repeat the manifest query. */ + hls_vars?: string; /** @description Signed stream reference the plan URL carries; it reconstructs the session after a restart. Omitted for header-authenticated media. Account and viewer authorization are always required. */ st?: string; /** @description Media-element fallback for the account bearer token when an Authorization header cannot be set. Header-authenticated media requires the Authorization header and the profile selector. */ @@ -104393,6 +104395,8 @@ export interface operations { getPlaybackSegment: { parameters: { query?: { + /** @description 1 on a plan URL whose client advertised hls_variable_substitution_v1: a large synthetic manifest may carry its query once through EXT-X-DEFINE instead of on every segment link. Not a credential; segment links repeat the manifest query. */ + hls_vars?: string; /** @description Signed stream reference the plan URL carries; it reconstructs the session after a restart. Omitted for header-authenticated media. Account and viewer authorization are always required. */ st?: string; /** @description Media-element fallback for the account bearer token when an Authorization header cannot be set. Header-authenticated media requires the Authorization header and the profile selector. */ diff --git a/web/src/player/hooks/usePlaybackSession.test.ts b/web/src/player/hooks/usePlaybackSession.test.ts index e64c99eb22..1f743c671b 100644 --- a/web/src/player/hooks/usePlaybackSession.test.ts +++ b/web/src/player/hooks/usePlaybackSession.test.ts @@ -13,6 +13,7 @@ import { buildStartRequestV3, routeEventPlanIdentityV3, VIDEO_CLIENT_FEATURES_V3, + videoClientFeaturesV3, } from "../playback-session-wire-v3"; import { markPlaybackIntent } from "../first-frame"; import { usePlaybackSession } from "./usePlaybackSession"; @@ -126,6 +127,19 @@ describe("buildStartRequestV3", () => { expect(buildStartRequestV3(startBase).client_features).toEqual(["playback_plan_v3"]); }); + // A native HLS stack ignores #EXT-X-DEFINE and would request segments + // without their stream token, so only an hls.js attempt may advertise it. + it("advertises HLS variable substitution only for an hls.js attempt", () => { + expect( + buildStartRequestV3({ ...startBase, extraClientFeatures: videoClientFeaturesV3(true) }) + .client_features, + ).toEqual(["playback_plan_v3", "plan_invalidated_v1", "hls_variable_substitution_v1"]); + expect( + buildStartRequestV3({ ...startBase, extraClientFeatures: videoClientFeaturesV3(false) }) + .client_features, + ).toEqual(["playback_plan_v3", "plan_invalidated_v1"]); + }); + it("declares the protocol version and the plan feature", () => { expect(buildStartRequestV3(startBase)).toMatchObject({ protocol_version: 3, diff --git a/web/src/player/hooks/usePlaybackSession.ts b/web/src/player/hooks/usePlaybackSession.ts index e7ae88b4a2..0b150a9974 100644 --- a/web/src/player/hooks/usePlaybackSession.ts +++ b/web/src/player/hooks/usePlaybackSession.ts @@ -32,9 +32,10 @@ import { buildReplanRequestV3, buildStartRequestV3, routeEventPlanIdentityV3, - VIDEO_CLIENT_FEATURES_V3, + videoClientFeaturesV3, type ReplanOptions, } from "../playback-session-wire-v3"; +import { currentHLSJSEngineExpectedV3 } from "../utils/hlsEngine"; import type { PlayerFileVersion, PlayerPlaybackVariant, @@ -544,7 +545,7 @@ export function usePlaybackSession( subtitleTrackIndex: number | undefined, ): Promise => { const body = buildStartRequestV3({ - extraClientFeatures: VIDEO_CLIENT_FEATURES_V3, + extraClientFeatures: videoClientFeaturesV3(currentHLSJSEngineExpectedV3()), fileId: targetFileId, profileId: config.getProfileId() ?? "", playbackAttemptId, @@ -989,7 +990,7 @@ export function usePlaybackSession( const body = buildReplanRequestV3({ ...options, - extraClientFeatures: VIDEO_CLIENT_FEATURES_V3, + extraClientFeatures: videoClientFeaturesV3(currentHLSJSEngineExpectedV3()), plan, playbackAttemptId, replanRequestId: randomUUID(), diff --git a/web/src/player/playback-session-wire-v3.ts b/web/src/player/playback-session-wire-v3.ts index 5b726f9ea4..22b2d20be6 100644 --- a/web/src/player/playback-session-wire-v3.ts +++ b/web/src/player/playback-session-wire-v3.ts @@ -1,4 +1,5 @@ import { + FEATURE_HLS_VARIABLE_SUBSTITUTION_V3, FEATURE_PLAN_INVALIDATED_V3, FEATURE_PLAYBACK_PLAN_V3, PROTOCOL_V3, @@ -36,6 +37,19 @@ const BASE_CLIENT_FEATURES_V3 = [FEATURE_PLAYBACK_PLAN_V3]; */ export const VIDEO_CLIENT_FEATURES_V3 = [FEATURE_PLAN_INVALIDATED_V3]; +/** + * The video watch page's features for one start or replan. + * + * HLS variable substitution is added only when the attempt is expected to play + * through hls.js (see `hlsJSEngineExpectedV3`); Safari and other native-HLS + * paths keep the per-segment manifest query every HLS stack resolves. + */ +export function videoClientFeaturesV3(hlsJSExpected: boolean): string[] { + return hlsJSExpected + ? [...VIDEO_CLIENT_FEATURES_V3, FEATURE_HLS_VARIABLE_SUBSTITUTION_V3] + : [...VIDEO_CLIENT_FEATURES_V3]; +} + /** * `client_features` is the contract's single advertisement location, and a * replan that sends it replaces the negotiated list — so start and replan build diff --git a/web/src/player/protocol-v3.ts b/web/src/player/protocol-v3.ts index 969537d2e4..0ad753d53e 100644 --- a/web/src/player/protocol-v3.ts +++ b/web/src/player/protocol-v3.ts @@ -137,6 +137,17 @@ export const FEATURE_OUTPUT_CHANGE_V3 = "output_change_v1"; */ export const FEATURE_PLAN_INVALIDATED_V3 = "plan_invalidated_v1"; +/** + * The client's HLS engine implements `#EXT-X-DEFINE` variable substitution, so + * a large transcode manifest may define its access query once instead of + * repeating it on every segment URI. + * + * Only an attempt that will play through hls.js may advertise it: a native HLS + * stack that ignores the tag requests segments without their stream token and + * every one of them is refused. + */ +export const FEATURE_HLS_VARIABLE_SUBSTITUTION_V3 = "hls_variable_substitution_v1"; + /** The `original` rung label, which always preserves the source. */ export const QUALITY_ORIGINAL_V3 = "original"; diff --git a/web/src/player/utils/hlsEngine.test.ts b/web/src/player/utils/hlsEngine.test.ts index 08cafbfbd0..0e825b1795 100644 --- a/web/src/player/utils/hlsEngine.test.ts +++ b/web/src/player/utils/hlsEngine.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it, vi } from "vitest"; -import { isSafariBrowserV3, resolveHLSEngineV3, selectHLSEngineV3 } from "./hlsEngine"; +import { + hlsJSEngineExpectedV3, + isSafariBrowserV3, + resolveHLSEngineV3, + selectHLSEngineV3, +} from "./hlsEngine"; const safariUA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Version/26.0 Safari/605.1.15"; @@ -82,3 +87,19 @@ describe("selectHLSEngineV3", () => { }); }); }); + +// The plan request advertises HLS variable substitution from this prediction, +// so it must never claim hls.js for a path the player would play natively. +describe("hlsJSEngineExpectedV3", () => { + it("expects hls.js for a non-Safari browser with Media Source", () => { + expect(hlsJSEngineExpectedV3(chromeUA, true)).toBe(true); + }); + + it("never expects hls.js for Safari, which stays on native HLS", () => { + expect(hlsJSEngineExpectedV3(safariUA, true)).toBe(false); + }); + + it("does not expect hls.js without Media Source", () => { + expect(hlsJSEngineExpectedV3(chromeUA, false)).toBe(false); + }); +}); diff --git a/web/src/player/utils/hlsEngine.ts b/web/src/player/utils/hlsEngine.ts index f165a16953..1c6101ad4e 100644 --- a/web/src/player/utils/hlsEngine.ts +++ b/web/src/player/utils/hlsEngine.ts @@ -15,6 +15,26 @@ export function isSafariBrowserV3(userAgent: string): boolean { ); } +/** + * Predicts, before a plan exists, whether the video player will play an HLS + * plan through hls.js rather than the media element. It mirrors the player's + * engine choice — Safari always stays native — and hls.js's own baseline, a + * Media Source implementation, without loading hls.js itself. + */ +export function hlsJSEngineExpectedV3(userAgent: string, mediaSourceAvailable: boolean): boolean { + return mediaSourceAvailable && !isSafariBrowserV3(userAgent); +} + +/** {@link hlsJSEngineExpectedV3} for the running browser. */ +export function currentHLSJSEngineExpectedV3(): boolean { + if (typeof navigator === "undefined") return false; + const scope = globalThis as { MediaSource?: unknown; ManagedMediaSource?: unknown }; + return hlsJSEngineExpectedV3( + navigator.userAgent, + typeof scope.MediaSource === "function" || typeof scope.ManagedMediaSource === "function", + ); +} + function nativeHLSPreferred(nativeSupported: boolean, preferNativeHLS: boolean): boolean { return preferNativeHLS && nativeSupported; } From 9f06cf7e7e8e00f4aba5e6b8050b0e751d4328b9 Mon Sep 17 00:00:00 2001 From: Jonah May <119529402+JonahMMay@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:26:30 -0500 Subject: [PATCH 2/3] fix(web): drop the hls_vars opt-in when native HLS plays the stream The plan opts into HLS variable substitution because the web client predicted hls.js. If hls.js then fails to load, VideoPlayer falls back to the media element, which may not implement #EXT-X-DEFINE. Strip hls_vars=1 from the URL on that path so the server serves the legacy manifest. Also refresh the system-info fixture's contract_digest for the updated OpenAPI artifact. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../api/v2/fixtures/get_system_info_ok.json | 2 +- web/src/player/components/VideoPlayer.tsx | 10 ++++++++-- web/src/player/utils/hlsEngine.test.ts | 20 +++++++++++++++++++ web/src/player/utils/hlsEngine.ts | 20 +++++++++++++++++++ 4 files changed, 49 insertions(+), 3 deletions(-) diff --git a/contracts/api/v2/fixtures/get_system_info_ok.json b/contracts/api/v2/fixtures/get_system_info_ok.json index e1699741db..593b66c848 100644 --- a/contracts/api/v2/fixtures/get_system_info_ok.json +++ b/contracts/api/v2/fixtures/get_system_info_ok.json @@ -1,7 +1,7 @@ { "server_version": "unavailable", "api_major": 2, - "contract_digest": "370d72dda0f62cc35fa9c852fbb996af996b73ec6ed2d3bfd8733a511b4394de", + "contract_digest": "9e6fb971138c4bf9afafb0fef7ec1324734b21a4a07f26622289478e30693917", "links": { "openapi": "/api/v2/openapi.json", "capabilities": "/api/v2/capabilities", diff --git a/web/src/player/components/VideoPlayer.tsx b/web/src/player/components/VideoPlayer.tsx index 92f1929b9a..e5f69bf84c 100644 --- a/web/src/player/components/VideoPlayer.tsx +++ b/web/src/player/components/VideoPlayer.tsx @@ -41,7 +41,11 @@ import type { import { resolvePendingSeekTime } from "../utils/pendingSeek"; import { resolveVersionAudioLanguage } from "../utils/effectiveAudioLanguage"; import { HlsStartupGuard } from "../utils/hlsStartupGuard"; -import { isSafariBrowserV3, resolveHLSEngineV3 } from "../utils/hlsEngine"; +import { + isSafariBrowserV3, + resolveHLSEngineV3, + withoutHLSVariableSubstitutionV3, +} from "../utils/hlsEngine"; import { isFirefoxUserAgent } from "../utils/browser"; import { normalizeSubtitleMode } from "../utils/subtitleMode"; import { @@ -1918,7 +1922,9 @@ export function VideoPlayer({ video.addEventListener("canplay", attemptAutoplayWhenReady); const attachNativeHLS = () => { - video.src = effectiveStreamUrl; + // The plan opted into HLS variable substitution for hls.js; the media + // element may not implement it, so ask for the legacy manifest. + video.src = withoutHLSVariableSubstitutionV3(effectiveStreamUrl); nativeHLSMetadataHandler = () => { video.currentTime = effectiveInitialPosition; attemptAutoplayWhenReady(); diff --git a/web/src/player/utils/hlsEngine.test.ts b/web/src/player/utils/hlsEngine.test.ts index 0e825b1795..a7bf86a170 100644 --- a/web/src/player/utils/hlsEngine.test.ts +++ b/web/src/player/utils/hlsEngine.test.ts @@ -5,8 +5,28 @@ import { isSafariBrowserV3, resolveHLSEngineV3, selectHLSEngineV3, + withoutHLSVariableSubstitutionV3, } from "./hlsEngine"; +describe("withoutHLSVariableSubstitutionV3", () => { + it("drops only the opt-in flag so a native player gets the legacy manifest", () => { + expect(withoutHLSVariableSubstitutionV3("/v2/s/master.m3u8?st=abc&hls_vars=1")).toBe( + "/v2/s/master.m3u8?st=abc", + ); + expect(withoutHLSVariableSubstitutionV3("/v2/s/master.m3u8?hls_vars=1&st=abc#t")).toBe( + "/v2/s/master.m3u8?st=abc#t", + ); + expect(withoutHLSVariableSubstitutionV3("/v2/s/master.m3u8?hls_vars=1")).toBe( + "/v2/s/master.m3u8", + ); + }); + + it.each(["/v2/s/master.m3u8", "/v2/s/master.m3u8?st=abc", "/v2/s/master.m3u8?xhls_vars=1&hls_vars=0"])( + "leaves a URL without the flag untouched: %s", + (url) => expect(withoutHLSVariableSubstitutionV3(url)).toBe(url), + ); +}); + const safariUA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Version/26.0 Safari/605.1.15"; const chromeUA = diff --git a/web/src/player/utils/hlsEngine.ts b/web/src/player/utils/hlsEngine.ts index 1c6101ad4e..9acbfb073f 100644 --- a/web/src/player/utils/hlsEngine.ts +++ b/web/src/player/utils/hlsEngine.ts @@ -35,6 +35,26 @@ export function currentHLSJSEngineExpectedV3(): boolean { ); } +const hlsVariableSubstitutionFlag = "hls_vars=1"; + +/** + * Drops the `hls_vars=1` opt-in from a manifest URL. The plan asks for HLS + * variable substitution only because it predicted hls.js; when the media + * element plays the stream instead (hls.js failed to load), the server must + * send the legacy manifest, whose segment URIs every native player resolves. + */ +export function withoutHLSVariableSubstitutionV3(streamUrl: string): string { + const queryStart = streamUrl.indexOf("?"); + if (queryStart < 0) return streamUrl; + const hashStart = streamUrl.indexOf("#", queryStart); + const queryEnd = hashStart < 0 ? streamUrl.length : hashStart; + const pairs = streamUrl.slice(queryStart + 1, queryEnd).split("&"); + const kept = pairs.filter((pair) => pair !== hlsVariableSubstitutionFlag); + if (kept.length === pairs.length) return streamUrl; + const query = kept.length > 0 ? `?${kept.join("&")}` : ""; + return streamUrl.slice(0, queryStart) + query + streamUrl.slice(queryEnd); +} + function nativeHLSPreferred(nativeSupported: boolean, preferNativeHLS: boolean): boolean { return preferNativeHLS && nativeSupported; } From 8b4cf98e64dd6cfb4f134fc1144d6eac99f99f41 Mon Sep 17 00:00:00 2001 From: Jonah May <119529402+JonahMMay@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:30:03 -0500 Subject: [PATCH 3/3] style(web): prettier-format hlsEngine test Co-Authored-By: Claude Opus 5.5 (1M context) --- web/src/player/utils/hlsEngine.test.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/web/src/player/utils/hlsEngine.test.ts b/web/src/player/utils/hlsEngine.test.ts index a7bf86a170..ce5fe77d7a 100644 --- a/web/src/player/utils/hlsEngine.test.ts +++ b/web/src/player/utils/hlsEngine.test.ts @@ -21,9 +21,12 @@ describe("withoutHLSVariableSubstitutionV3", () => { ); }); - it.each(["/v2/s/master.m3u8", "/v2/s/master.m3u8?st=abc", "/v2/s/master.m3u8?xhls_vars=1&hls_vars=0"])( - "leaves a URL without the flag untouched: %s", - (url) => expect(withoutHLSVariableSubstitutionV3(url)).toBe(url), + it.each([ + "/v2/s/master.m3u8", + "/v2/s/master.m3u8?st=abc", + "/v2/s/master.m3u8?xhls_vars=1&hls_vars=0", + ])("leaves a URL without the flag untouched: %s", (url) => + expect(withoutHLSVariableSubstitutionV3(url)).toBe(url), ); });