Skip to content

6.24.0 - A live playlist reaches the path that carries its headers

Choose a tag to compare

@superuser404notfound superuser404notfound released this 13 Aug 10:30
· 718 commits to main since this release

Drop-in from 6.23.1. One behaviour change on the live path, no source-breaking API change. Reported on 6.21.0 by @parthp1808 in #363, against tokenized IPTV streams that enforce an STB profile header per request.

What the measurement said before anything was written

The report's stated cause was that AVURLAsset drops LoadOptions.httpHeaders when it follows a cross-origin 302 to a CDN edge, so the origin answers 401 or 403. That is not what happens here.

A fixture origin that answers 403 to every request missing an exact header, and logs the verdict per request, was pointed at both shapes of the case: a 302 that changes host and port, and a master whose variants name a second origin absolutely. On the nativeRemoteHLS bypass the headers survived both. Every playlist, every variant and every segment request arrived with the header intact, on the reporter's own header (User-Agent) and on a custom one, and the sessions played through.

So the header carriage added in #119 is intact, and a 401 from an origin like that is worth reading as the origin's own decision: how many headers it wants (the reporter's log shows header count: 1, while portals commonly want a Referer or a Cookie alongside the profile), or a per-token connection cap that the bypass's parallel fetches fill.

What was genuinely missing is the routing around such a refusal. The engine holds HLSLiveIngestReader, the only live path that puts LoadOptions.httpHeaders on the playlist, on every segment and on every AES key, and both ways of reaching it required the host to wire it by hand.

A live playlist routes itself now

load(url: <m3u8>, options: LoadOptions(isLive: true)) without nativeRemoteHLS lands on the raw byte path, where AE#140 detects the #EXTM3U body in the position a container's first byte belongs and stops before the endless-feed reconnect loop. That detection stays; its destination changes. Instead of throwing an error that names the reader the host should have built, the engine builds it, with the session's headers on every fetch.

AetherEngineError.hlsPlaylistOnRawLivePath still exists and still throws for a custom IOReader carrying the same misroute: that source has no playlist URL for the engine to ingest from, so only the host can re-point it. A host that catches the error for a URL source will simply stop seeing it and get a playing session instead.

This mirrors what the non-live side of the same misroute has done since #154.

A refused mount is handed to a different client

A live nativeRemoteHLS session that the origin turns away no longer fails the load. HTTP 401 reaches the item as NSURLError -1013 and HTTP 403 as -1102, both with an empty HLS error log, and both arrive that way whether the refused request was the master playlist or the first segment, since neither ever reaches readyToPlay. The engine now hands such a session to the live ingest.

That is worth doing because the ingest fetcher is a different client at that origin: it carries the configured headers on every request, caps itself at four concurrent fetches, and sends no AVFoundation user agent, so a UA filter or a full per-token connection cap that turned AVPlayer away can still serve it. Measured against an origin that refuses AppleCoreMedia and serves everyone else, the bypass dies at the mount and the rerouted session plays through.

It is gated by LoadOptions.nativeRemoteHLSIngestFallback exactly like the #168 carriage recovery, fires once per session, and is deliberately not remembered for the next load the way a carriage verdict is: a carriage verdict is a property of the master, while a refusal can be an expired token or a cap that frees up a second later.

Verifying it

Origins that enforce a header could not be driven from the CLI at all, which is why this report could only be reasoned about. aetherctl now has both ends of the contract:

# portal on 8099 answers /entry.m3u8 with a 302 to the edge on 8100, both enforcing the header
aetherctl hlsfixture --segments-dir ./segs --master --codecs "avc1.4d401f,mp4a.40.2" \
  --resolution 1280x720 --require-header "User-Agent: Mozilla/5.0 (QtEmbedded; TestSTB)" \
  --redirect-entry --redirect-port 8100 --port 8099
aetherctl play --live --header "User-Agent: Mozilla/5.0 (QtEmbedded; TestSTB)" \
  --seconds 60 http://127.0.0.1:8099/entry.m3u8

play --header "Name: Value" is repeatable and fills LoadOptions.httpHeaders, including the ingest reader's own fetches. hlsfixture gained --require-header, --deny-status (401 and 403 are different codes at the item), --deny-segments-only (refuse after readiness rather than at the master), --deny-user-agent (the one origin shape that tells the two live clients apart), --redirect-entry / --redirect-host / --redirect-port, --media-origin, and --segments-dir.

That last one serves pre-cut, GOP-aligned segments instead of byte slices, so a live run can be asked whether it plays rather than only whether it routed: byte slices start mid-GOP, decode nothing and rebuffer forever, which is fine for a routing claim and worthless for a playback one.

Hosts need no change.