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/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/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/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..ce5fe77d7a 100644 --- a/web/src/player/utils/hlsEngine.test.ts +++ b/web/src/player/utils/hlsEngine.test.ts @@ -1,6 +1,34 @@ import { describe, expect, it, vi } from "vitest"; -import { isSafariBrowserV3, resolveHLSEngineV3, selectHLSEngineV3 } from "./hlsEngine"; +import { + hlsJSEngineExpectedV3, + 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"; @@ -82,3 +110,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..9acbfb073f 100644 --- a/web/src/player/utils/hlsEngine.ts +++ b/web/src/player/utils/hlsEngine.ts @@ -15,6 +15,46 @@ 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", + ); +} + +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; }