Skip to content
Open
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 .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ jobs:
runs-on: macos-15
timeout-minutes: 30
env:
AUTHORIZATION_TEST_SUITES: 'RefreshableHLSAuthorizationTests|RefreshableSubtitleAuthorizationTests|LiveTrustEvaluatorTests'
AUTHORIZATION_TEST_SUITES: 'RefreshableHLSAuthorizationTests|RefreshableSubtitleAuthorizationTests|RefreshableDirectPlayAuthorizationTests|LiveTrustEvaluatorTests'
steps:
- uses: actions/checkout@v4

Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,13 @@ the public-API contract.
- `ExternalSubtitleTrack.httpRequestAuthorization` supplies refreshable headers for primary/secondary sidecars and native subtitle stores without changing registered track IDs or rendition mappings. Authorized container decoding retains AVIO streaming and range access.
- `HTTPRequestAuthorization.data(from:maximumBytes:)` fetches raw auxiliary resources such as font bundles with a caller-supplied byte limit and a whole-transfer deadline, reusing the relay's redirect, authorization, retry, cancellation and TLS policy.

- `LoadOptions.httpRequestAuthorization` now covers direct play. The byte-range reader asks the resolver for the source URL before every range, reconnect, probe and seek, so a rotated access token reaches the next request instead of the session sending the headers it opened with until the host reloads the player. A 401 retries once at the same byte offset when the resolver returns a changed `Authorization`. A resolver that throws or exceeds its 10 s bound fails the request before it is sent, so an open fails as `authorizationUnavailable`. Unchanged credentials, a second 401, or a failed refresh end the read after the 401, and fail an open with that status. Neither runs the reconnect ladder. The resolver is asked about the source only, so every header it returns counts as a credential: none reaches a cross-origin redirect target or a target pinned from one, which get the non-credential static headers instead. Live ingest, remote disc images and audio-only sources that AVPlayer decodes natively keep static headers. Resolvers run on an engine-owned serial executor, off Swift's cooperative pool, so demuxer opens that occupy every pool thread while they wait cannot starve the resolver they wait for.

- `LoadOptions.httpRequestAuthorization` accepts an async `HTTPRequestAuthorization` resolver for native HLS. The engine resolves headers before requests and redirects, and retries a rejected request once when the bearer changes, preserving the active player item across token rotation.

### Fixed

- `LoadOptions.heldSourceConnection` applies the redirect credential policy to the redirects it follows itself. It replayed every header, `Authorization` and the Emby/Jellyfin tokens included, to a cross-origin hop.
- The software video decoder no longer runs more than 16 frame threads. It used one per core, and each frame thread holds back one decoded frame, so a 32-core Mac waited for 31 frames before showing the first one after a load or seek (about 3 s at 10 fps). FFmpeg also warns above 16 threads. Hosts with 16 or fewer cores keep their current thread count.
- A paused video no longer starts playing by itself. When the player item died while paused (`failedToPlayToEndTime`), the recovery reload bypassed the pause guard and called `play()` on the fresh item. The reload now keeps a pause made before the item died, whether it came through the engine, AVKit, Control Center or PiP, and mounts the item paused at the same position.
- A dead item's recovery no longer restarts the title from where the session was first opened. When AVPlayer refused the recovery item's master (`-11868`), the media fallback reloaded at the first mount's start position, so a title opened from its beginning restarted at 0:00. The fallback now reloads where the refused item was placed. Upstream #621.
Expand Down
6 changes: 3 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ swift build
swift test
```

CI runs `RefreshableHLSAuthorizationTests`, `RefreshableSubtitleAuthorizationTests` and
`LiveTrustEvaluatorTests` in a separate process because their short authorization deadlines require
CI runs `RefreshableHLSAuthorizationTests`, `RefreshableSubtitleAuthorizationTests`,
`RefreshableDirectPlayAuthorizationTests` and `LiveTrustEvaluatorTests` in a separate process because their short authorization deadlines require
responsive async resolvers. Blocking work elsewhere in the suite can delay those resolvers on smaller
runners. `LiveTrustEvaluatorTests` is the serialized parent of every live suite that sets the
process-global `EngineTLS.serverTrustEvaluator`. Nest any new suite that sets it there; the
Expand All @@ -28,7 +28,7 @@ The two commands below cover the entire test suite, keeping the existing deadlin
and parallel execution within each group:

```bash
AUTHORIZATION_TEST_SUITES='RefreshableHLSAuthorizationTests|RefreshableSubtitleAuthorizationTests|LiveTrustEvaluatorTests'
AUTHORIZATION_TEST_SUITES='RefreshableHLSAuthorizationTests|RefreshableSubtitleAuthorizationTests|RefreshableDirectPlayAuthorizationTests|LiveTrustEvaluatorTests'
swift test --skip "$AUTHORIZATION_TEST_SUITES"
swift test --skip-build --filter "$AUTHORIZATION_TEST_SUITES"
```
Expand Down
18 changes: 13 additions & 5 deletions Sources/AetherEngine/AetherEngine+Loading.swift
Original file line number Diff line number Diff line change
Expand Up @@ -692,6 +692,7 @@ extension AetherEngine {
func loadNative(
url: URL,
sourceHTTPHeaders: [String: String] = [:],
sourceHTTPAuthorization: HTTPRequestAuthorization? = nil,
startPosition: Double?,
audioSourceStreamIndex: Int32? = nil,
keepDvh1TagWithoutDV: Bool = false,
Expand Down Expand Up @@ -760,6 +761,7 @@ extension AetherEngine {
let session = HLSVideoEngine(
url: url,
sourceHTTPHeaders: sourceHTTPHeaders,
sourceHTTPAuthorization: sourceHTTPAuthorization,
dvModeAvailable: sessionDisplayCaps.supportsDolbyVision,
displaySupportsHDR: sessionDisplayCaps.supportsHDR,
keepDvh1TagWithoutDV: keepDvh1TagWithoutDV,
Expand Down Expand Up @@ -1725,6 +1727,7 @@ extension AetherEngine {
func loadSoftware(
url: URL,
sourceHTTPHeaders: [String: String] = [:],
sourceHTTPAuthorization: HTTPRequestAuthorization? = nil,
startPosition: Double?,
audioSourceStreamIndex: Int32?,
isLive: Bool = false,
Expand Down Expand Up @@ -1876,13 +1879,13 @@ extension AetherEngine {
if loadGeneration == generation { recordStartupCheckpoint(.sessionConstructed) } // #361
let forwardBufferSegments = loadedOptions.forwardBufferSegments
try await Task.detached(priority: .userInitiated) {
[host, preopenedDemuxer, url, sourceHTTPHeaders, isLive, dvrWindowSeconds, probesize, maxAnalyzeDuration, sequentialOrigin, heldSourceConnection, declaredDuration, networkPhaseSink] in
[host, preopenedDemuxer, url, sourceHTTPHeaders, sourceHTTPAuthorization, isLive, dvrWindowSeconds, probesize, maxAnalyzeDuration, sequentialOrigin, heldSourceConnection, declaredDuration, networkPhaseSink] in
let dem: Demuxer
if let pre = preopenedDemuxer {
dem = pre
} else {
dem = Demuxer()
try dem.open(url: url, extraHeaders: sourceHTTPHeaders, profile: .playback.withProbeBudget(probesize: probesize, maxAnalyzeDuration: maxAnalyzeDuration).withSequentialOrigin(sequentialOrigin, declaredDuration: declaredDuration).withHeldSourceConnection(heldSourceConnection), isLive: isLive)
try dem.open(url: url, extraHeaders: sourceHTTPHeaders, requestAuthorization: sourceHTTPAuthorization, profile: .playback.withProbeBudget(probesize: probesize, maxAnalyzeDuration: maxAnalyzeDuration).withSequentialOrigin(sequentialOrigin, declaredDuration: declaredDuration).withHeldSourceConnection(heldSourceConnection), isLive: isLive)
Comment thread
Quick104 marked this conversation as resolved.
}
dem.onNetworkPhaseChanged = networkPhaseSink
try await host.load(
Expand All @@ -1905,6 +1908,7 @@ extension AetherEngine {
func loadAudio(
url: URL,
sourceHTTPHeaders: [String: String] = [:],
sourceHTTPAuthorization: HTTPRequestAuthorization? = nil,
startPosition: Double?,
audioSourceStreamIndex: Int32?,
preopenedDemuxer: Demuxer?,
Expand Down Expand Up @@ -1953,13 +1957,13 @@ extension AetherEngine {
}
if loadGeneration == generation { recordStartupCheckpoint(.sessionConstructed) } // #361
try await Task.detached(priority: .userInitiated) {
[host, preopenedDemuxer, url, sourceHTTPHeaders, probesize, maxAnalyzeDuration, sequentialOrigin, heldSourceConnection, declaredDuration, networkPhaseSink] in
[host, preopenedDemuxer, url, sourceHTTPHeaders, sourceHTTPAuthorization, probesize, maxAnalyzeDuration, sequentialOrigin, heldSourceConnection, declaredDuration, networkPhaseSink] in
let dem: Demuxer
if let pre = preopenedDemuxer {
dem = pre
} else {
dem = Demuxer()
try dem.open(url: url, extraHeaders: sourceHTTPHeaders, profile: .playback.withProbeBudget(probesize: probesize, maxAnalyzeDuration: maxAnalyzeDuration).withSequentialOrigin(sequentialOrigin, declaredDuration: declaredDuration).withHeldSourceConnection(heldSourceConnection))
try dem.open(url: url, extraHeaders: sourceHTTPHeaders, requestAuthorization: sourceHTTPAuthorization, profile: .playback.withProbeBudget(probesize: probesize, maxAnalyzeDuration: maxAnalyzeDuration).withSequentialOrigin(sequentialOrigin, declaredDuration: declaredDuration).withHeldSourceConnection(heldSourceConnection))
}
dem.onNetworkPhaseChanged = networkPhaseSink
try await host.load(
Expand Down Expand Up @@ -2233,10 +2237,12 @@ extension AetherEngine {
// silently revert to the main title. Preopen the disc demuxer with the title so the selection
// survives the reload (#67). Non-disc URL sources keep customPreopened nil and reopen by URL.
let headers = loadedOptions.httpHeaders
let authorization = loadedOptions.httpRequestAuthorization
do {
customPreopened = try await Task.detached(priority: .userInitiated) {
let d = Demuxer()
try d.open(url: url, extraHeaders: headers, profile: reloadProfile, selectTitleID: titleToReopen)
try d.open(url: url, extraHeaders: headers, requestAuthorization: authorization,
profile: reloadProfile, selectTitleID: titleToReopen)
return d
}.value
} catch {
Expand Down Expand Up @@ -2286,6 +2292,7 @@ extension AetherEngine {
try await loadSoftware(
url: url,
sourceHTTPHeaders: loadedOptions.httpHeaders,
sourceHTTPAuthorization: loadedOptions.httpRequestAuthorization,
startPosition: LiveReloadPolicy.resumePosition(
isLive: loadedOptions.isLive, currentTime: resumeAt),
audioSourceStreamIndex: audioStreamIndex,
Expand Down Expand Up @@ -2348,6 +2355,7 @@ extension AetherEngine {
try await loadNative(
url: url,
sourceHTTPHeaders: loadedOptions.httpHeaders,
sourceHTTPAuthorization: loadedOptions.httpRequestAuthorization,
// Live rejoins at the live edge (see loadSoftware above).
startPosition: LiveReloadPolicy.resumePosition(
isLive: loadedOptions.isLive, currentTime: resumeAt),
Expand Down
15 changes: 10 additions & 5 deletions Sources/AetherEngine/AetherEngine+Subtitles.swift
Original file line number Diff line number Diff line change
Expand Up @@ -657,6 +657,7 @@ extension AetherEngine {
let isCustom = isCustomSource
if isCustom, customReader == nil { return }
let headers = loadedOptions.httpHeaders
let authorization = loadedOptions.httpRequestAuthorization
let formatHint = customFormatHint
let probesize = loadedOptions.probesize
let maxAnalyzeDuration = loadedOptions.maxAnalyzeDuration
Expand Down Expand Up @@ -708,7 +709,7 @@ extension AetherEngine {
guard let self else { return }
let outcome = await self.runSubtitleForwardPrefetchSession(
url: url, reader: attemptReader, formatHint: formatHint, headers: headers,
startAt: resumeAt, callerProbesize: probesize,
authorization: authorization, startAt: resumeAt, callerProbesize: probesize,
callerMaxAnalyzeDuration: maxAnalyzeDuration,
selectTitleID: titleID, store: store, leadSeconds: lead, link: link)
guard outcome.exit.isRestartable, !Task.isCancelled else { return }
Expand Down Expand Up @@ -760,6 +761,7 @@ extension AetherEngine {
/// packets to the SubtitlePacketStore instead of decoded cues to native stores.
nonisolated private func runSubtitleForwardPrefetchSession(
url: URL, reader: IOReader?, formatHint: String?, headers: [String: String],
authorization: HTTPRequestAuthorization?,
startAt: Double, callerProbesize: Int64?, callerMaxAnalyzeDuration: Int64?,
selectTitleID: Int?, store: SubtitlePacketStore, leadSeconds: Double,
link: SideReaderLinkArbiter?
Expand Down Expand Up @@ -813,8 +815,8 @@ extension AetherEngine {
try demuxer.open(reader: reader, formatHint: formatHint, profile: openProfile,
selectTitleID: selectTitleID, discCacheKey: url.absoluteString)
} else {
try demuxer.open(url: url, extraHeaders: headers, profile: openProfile,
selectTitleID: selectTitleID)
try demuxer.open(url: url, extraHeaders: headers, requestAuthorization: authorization,
profile: openProfile, selectTitleID: selectTitleID)
}
} catch {
EngineLog.emit("[AetherEngine] #151 forward prefetch open failed: \(error)", category: .engine)
Expand Down Expand Up @@ -1678,6 +1680,7 @@ extension AetherEngine {
customClone = clone
}
let headers = loadedOptions.httpHeaders
let authorization = loadedOptions.httpRequestAuthorization
let formatHint = customFormatHint
let w = sourceVideoWidth > 0 ? sourceVideoWidth : 1920
let h = sourceVideoHeight > 0 ? sourceVideoHeight : 1080
Expand All @@ -1691,7 +1694,7 @@ extension AetherEngine {
nativeSubtitleReadersTask = Task.detached(priority: .utility) { [weak self] in
await self?.runNativeSubtitleReaders(
url: url, reader: reader, formatHint: formatHint, headers: headers,
pairs: pairs, startAt: startAt, videoWidth: w, videoHeight: h,
authorization: authorization, pairs: pairs, startAt: startAt, videoWidth: w, videoHeight: h,
callerProbesize: probesize, callerMaxAnalyzeDuration: maxAnalyzeDuration,
selectTitleID: titleID, readToEOF: readToEOF, link: link
)
Expand Down Expand Up @@ -1749,6 +1752,7 @@ extension AetherEngine {
nonisolated private func runNativeSubtitleReaders(
url: URL, reader: IOReader?, formatHint: String?,
headers: [String: String],
authorization: HTTPRequestAuthorization? = nil,
pairs: [(streamIndex: Int32, store: NativeSubtitleCueStore)],
startAt: Double, videoWidth: Int32, videoHeight: Int32,
callerProbesize: Int64? = nil, callerMaxAnalyzeDuration: Int64? = nil,
Expand Down Expand Up @@ -1778,7 +1782,8 @@ extension AetherEngine {
if let reader = reader {
try demuxer.open(reader: reader, formatHint: formatHint, profile: openProfile, selectTitleID: selectTitleID, discCacheKey: url.absoluteString)
} else {
try demuxer.open(url: url, extraHeaders: headers, profile: openProfile, selectTitleID: selectTitleID)
try demuxer.open(url: url, extraHeaders: headers, requestAuthorization: authorization,
profile: openProfile, selectTitleID: selectTitleID)
}
} catch {
EngineLog.emit("[AetherEngine] native subtitle readers open failed: \(error)", category: .engine)
Expand Down
6 changes: 5 additions & 1 deletion Sources/AetherEngine/AetherEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3908,7 +3908,8 @@ public final class AetherEngine: ObservableObject {
case .url(let u):
// isLive configures the AVIOReader for endless-feed mode; must be set at open time because
// the probe demuxer is reused as the session demuxer (avformat_open_input runs only once).
try probe.open(url: u, extraHeaders: options.httpHeaders, profile: probeProfile, isLive: options.isLive, selectTitleID: discTitleID)
try probe.open(url: u, extraHeaders: options.httpHeaders,
requestAuthorization: options.httpRequestAuthorization, profile: probeProfile, isLive: options.isLive, selectTitleID: discTitleID)
case .custom(let reader, let formatHint):
// isLive suppresses SEEK_END duration estimate on forward-only live readers; same open-time requirement.
try probe.open(reader: reader, formatHint: formatHint, profile: probeProfile, isLive: options.isLive, selectTitleID: discTitleID)
Expand Down Expand Up @@ -4210,6 +4211,7 @@ public final class AetherEngine: ObservableObject {
try await loadAudio(
url: url,
sourceHTTPHeaders: options.httpHeaders,
sourceHTTPAuthorization: options.httpRequestAuthorization,
startPosition: startPosition,
audioSourceStreamIndex: resolvedInitialAudio >= 0 ? resolvedInitialAudio : nil,
preopenedDemuxer: probeOpened ? probe : nil,
Expand Down Expand Up @@ -4626,6 +4628,7 @@ public final class AetherEngine: ObservableObject {
try await loadSoftware(
url: url,
sourceHTTPHeaders: options.httpHeaders,
sourceHTTPAuthorization: options.httpRequestAuthorization,
startPosition: startPosition,
audioSourceStreamIndex: selectedAudio,
isLive: options.isLive,
Expand Down Expand Up @@ -4675,6 +4678,7 @@ public final class AetherEngine: ObservableObject {
try await loadNative(
url: url,
sourceHTTPHeaders: options.httpHeaders,
sourceHTTPAuthorization: options.httpRequestAuthorization,
startPosition: startPosition,
audioSourceStreamIndex: selectedAudio,
keepDvh1TagWithoutDV: options.keepDvh1TagWithoutDV,
Expand Down
Loading
Loading