Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion contracts/api/v2/fixtures/get_system_info_ok.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
24 changes: 24 additions & 0 deletions contracts/api/v2/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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.",
Expand Down
17 changes: 15 additions & 2 deletions docs/architecture/playback-protocol-v3.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
36 changes: 35 additions & 1 deletion internal/api/handlers/playback_v3.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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}
Expand Down
26 changes: 26 additions & 0 deletions internal/api/handlers/playback_v3_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
})
}
}
5 changes: 5 additions & 0 deletions internal/apiv2/playback_delivery.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)}})
}
Expand Down
8 changes: 8 additions & 0 deletions internal/playback/protocol_v3.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 26 additions & 1 deletion internal/playback/transcode.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
42 changes: 41 additions & 1 deletion internal/playback/transcode_manifest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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{
Expand Down
3 changes: 2 additions & 1 deletion internal/transcodenode/streaming_protocol.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading