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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ the public-API contract.

## [Unreleased]

### Fixed

- External subtitles inherit media request headers only at the same HTTP scheme, host and effective port. Explicit subtitle headers remain authoritative, including an empty set. Primary/secondary overlays, native subtitle stores and remote-HLS sidecar renditions use the same initial-request rule; existing redirect and certificate policies are unchanged.

### Changed

- **The documentation no longer says TrueHD Atmos objects are always lost.** The Known limitations
Expand Down
33 changes: 27 additions & 6 deletions Sources/AetherEngine/AetherEngine+Subtitles.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1366,6 +1366,21 @@ extension AetherEngine {

// MARK: - External subtitle tracks (#88)

/// Implicit media headers belong only to the media's exact HTTP origin. Explicit
/// sidecar headers (including an empty dictionary) are the host's own decision.
nonisolated static func resolvedSubtitleHeaders(
for url: URL, explicit: [String: String]?, mediaURL: URL?,
mediaHeaders: [String: String]
) -> [String: String] {
if let explicit { return explicit }
guard let mediaURL, let scheme = mediaURL.scheme?.lowercased(),
scheme == "http" || scheme == "https",
url.scheme?.lowercased() == scheme,
RedirectHeaderPolicy.credentialsAllowed(from: mediaURL, to: url)
else { return [:] }
return mediaHeaders
}

/// Register an external subtitle file as a first-class track (AetherEngine#88): it appears in
/// `subtitleTracks` with a synthetic id and `isExternal == true` and is selectable via
/// `selectSubtitleTrack(index:)`. Overlay-only (no native WebVTT rendition / PiP); declare via
Expand Down Expand Up @@ -1459,7 +1474,7 @@ extension AetherEngine {
table: nativeSubtitleTrackTable,
registry: externalSubtitleRegistry,
stores: session.nativeSubtitleCueStoresForSession,
defaultHeaders: loadedOptions.httpHeaders)
defaultHeaders: loadedOptions.httpHeaders, mediaURL: loadedURL)
guard !jobs.isEmpty else { return }
externalNativeStoreFillTask = BlockingWork.detached(priority: .utility) { [jobs] in
for job in jobs {
Expand Down Expand Up @@ -1512,7 +1527,7 @@ extension AetherEngine {
if activeSecondaryExternalSubtitleTrackID == id { clearSecondarySubtitle() }
}

/// Fetch and decode a sidecar subtitle file (.srt / .ass / .vtt / .ssa) via `SubtitleDecoder.decodeFile`, replacing `subtitleCues` atomically. `httpHeaders` nil forwards `LoadOptions.httpHeaders` (same auth as the media, #32). Prefer registering via `addExternalSubtitleTrack` + `selectSubtitleTrack` (#88), which keeps the track listed and `activeSubtitleTrackIndex` populated; this API stays for compatibility and one-shot use.
/// Fetch and decode a sidecar subtitle file (.srt / .ass / .vtt / .ssa) via `SubtitleDecoder.decodeFile`, replacing `subtitleCues` atomically. `httpHeaders` nil inherits `LoadOptions.httpHeaders` only for the same HTTP scheme, host and effective port as the media; cross-origin sidecars need explicit headers. Prefer registering via `addExternalSubtitleTrack` + `selectSubtitleTrack` (#88), which keeps the track listed and `activeSubtitleTrackIndex` populated; this API stays for compatibility and one-shot use.
public func selectSidecarSubtitle(url: URL, httpHeaders: [String: String]? = nil) {
hostExplicitSubtitleAction = true
startSidecarDecode(url: url, httpHeaders: httpHeaders, externalTrackID: nil)
Expand Down Expand Up @@ -1546,7 +1561,9 @@ extension AetherEngine {
sidecarASSHeader = nil
isLoadingSubtitles = true

let effectiveHeaders = httpHeaders ?? loadedOptions.httpHeaders
let effectiveHeaders = Self.resolvedSubtitleHeaders(
for: url, explicit: httpHeaders, mediaURL: loadedURL,
mediaHeaders: loadedOptions.httpHeaders)
// ASS/SSA sidecars honour preserveASSMarkup so hosts can drive a styled renderer. SRT/VTT fall back to plain text regardless.
let preserveASS = loadedOptions.preserveASSMarkup
sidecarTask = Task { [weak self] in
Expand Down Expand Up @@ -1612,7 +1629,9 @@ extension AetherEngine {
pgsStaleArrivalGates[.secondary]?.reset() // #100
isLoadingSecondarySubtitles = true

let effectiveHeaders = httpHeaders ?? loadedOptions.httpHeaders
let effectiveHeaders = Self.resolvedSubtitleHeaders(
for: url, explicit: httpHeaders, mediaURL: loadedURL,
mediaHeaders: loadedOptions.httpHeaders)
secondarySidecarTask = Task { [weak self] in
let result: SidecarDecodeResult
do {
Expand Down Expand Up @@ -2257,7 +2276,7 @@ extension AetherEngine {
table: [NativeSubtitleTrackEntry],
registry: [Int: ExternalSubtitleTrack],
stores: [NativeSubtitleCueStore],
defaultHeaders: [String: String]
defaultHeaders: [String: String], mediaURL: URL? = nil
) -> [ExternalSubtitleFillJob] {
struct Key: Hashable {
let url: URL
Expand All @@ -2270,7 +2289,9 @@ extension AetherEngine {
// .sup at load would violate the selection gating).
guard !entry.needsOCR, let extID = entry.externalID,
let track = registry[extID], ordinal < stores.count else { continue }
let key = Key(url: track.url, headers: track.httpHeaders ?? defaultHeaders)
let key = Key(url: track.url, headers: resolvedSubtitleHeaders(
for: track.url, explicit: track.httpHeaders, mediaURL: mediaURL,
mediaHeaders: defaultHeaders))
if targetsByKey[key] == nil { order.append(key) }
targetsByKey[key, default: []].append(
.init(streamIndex: track.sourceStreamIndex, store: stores[ordinal]))
Expand Down
12 changes: 8 additions & 4 deletions Sources/AetherEngine/Native/RemoteHLSSubtitleProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ final class RemoteHLSSubtitleProvider: HLSSegmentProvider, @unchecked Sendable {
private let programDuration: Double
private let stores: [NativeSubtitleCueStore]
private let defaultHeaders: [String: String]
private let mediaURL: URL?
/// Guards `fillTask` alone: the fill is started from the proxy's build (off the main actor) and
/// cancelled from the engine's teardown (on it), so the handle is genuinely shared.
private let fillLock = NSLock()
Expand All @@ -40,7 +41,7 @@ final class RemoteHLSSubtitleProvider: HLSSegmentProvider, @unchecked Sendable {
private let vttFillWaitSeconds: TimeInterval

init(tracks: [Track], masterBody: String, programDuration: Double,
defaultHeaders: [String: String],
defaultHeaders: [String: String], mediaURL: URL? = nil,
vttFillWaitSeconds: TimeInterval = defaultVTTFillWaitSeconds) {
self.tracks = tracks
self.staticMasterPlaylistBody = masterBody
Expand All @@ -52,6 +53,7 @@ final class RemoteHLSSubtitleProvider: HLSSegmentProvider, @unchecked Sendable {
let finiteDuration = programDuration.isFinite ? programDuration : 1
self.programDuration = min(max(1, finiteDuration), RemoteHLSSubtitleProxy.maxProgramDurationSeconds)
self.defaultHeaders = defaultHeaders
self.mediaURL = mediaURL
self.vttFillWaitSeconds = vttFillWaitSeconds
self.stores = tracks.map { _ in NativeSubtitleCueStore() }
}
Expand Down Expand Up @@ -79,7 +81,7 @@ final class RemoteHLSSubtitleProvider: HLSSegmentProvider, @unchecked Sendable {
/// once and a single bad stream index cannot blank its siblings.
func startFill() {
cancelFill()
let jobs = Self.fillJobs(tracks: tracks, stores: stores, defaultHeaders: defaultHeaders)
let jobs = Self.fillJobs(tracks: tracks, stores: stores, defaultHeaders: defaultHeaders, mediaURL: mediaURL)
guard !jobs.isEmpty else { return }
let task = BlockingWork.detached(priority: .utility) { [jobs] in
for job in jobs {
Expand Down Expand Up @@ -127,15 +129,17 @@ final class RemoteHLSSubtitleProvider: HLSSegmentProvider, @unchecked Sendable {
/// `AetherEngine.externalSubtitleFillJobs`, which keys off the loopback's rendition table.
static func fillJobs(tracks: [Track],
stores: [NativeSubtitleCueStore],
defaultHeaders: [String: String]) -> [AetherEngine.ExternalSubtitleFillJob] {
defaultHeaders: [String: String], mediaURL: URL? = nil) -> [AetherEngine.ExternalSubtitleFillJob] {
struct Key: Hashable {
let url: URL
let headers: [String: String]
}
var order: [Key] = []
var targetsByKey: [Key: [AetherEngine.ExternalSubtitleFillJob.Target]] = [:]
for (ordinal, track) in tracks.enumerated() where ordinal < stores.count {
let key = Key(url: track.source.url, headers: track.source.httpHeaders ?? defaultHeaders)
let key = Key(url: track.source.url, headers: AetherEngine.resolvedSubtitleHeaders(
for: track.source.url, explicit: track.source.httpHeaders, mediaURL: mediaURL,
mediaHeaders: defaultHeaders))
if targetsByKey[key] == nil { order.append(key) }
targetsByKey[key, default: []].append(
.init(streamIndex: track.source.sourceStreamIndex, store: stores[ordinal]))
Expand Down
2 changes: 1 addition & 1 deletion Sources/AetherEngine/Native/RemoteHLSSubtitleProxy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ enum RemoteHLSSubtitleProxy {

let provider = RemoteHLSSubtitleProvider(tracks: tracks, masterBody: master,
programDuration: duration,
defaultHeaders: httpHeaders)
defaultHeaders: httpHeaders, mediaURL: originURL)
let relay: HLSOriginRelay? = needsRelay ? HLSOriginRelay() : nil
// Audit NET-109: the grant belongs to the URL the host handed over. A redirect target is
// only allowed, or a cross-host redirect would move the token to the edge and leave the
Expand Down
3 changes: 2 additions & 1 deletion Tests/AetherEngineTests/RemoteHLSSubtitleProxyTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,8 @@ struct RemoteHLSSubtitleProxyTests {
let tracks = [Self.track(100_000, url: URL(string: "https://origin.test/en.srt")!)]
let stores = tracks.map { _ in NativeSubtitleCueStore() }
let jobs = RemoteHLSSubtitleProvider.fillJobs(tracks: tracks, stores: stores,
defaultHeaders: ["Authorization": "Bearer x"])
defaultHeaders: ["Authorization": "Bearer x"],
mediaURL: URL(string: "https://origin.test/movie.m3u8"))
#expect(jobs.first?.headers == ["Authorization": "Bearer x"])
}

Expand Down
107 changes: 107 additions & 0 deletions Tests/AetherEngineTests/SubtitleHeaderOriginTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import Foundation
import Testing
@testable import AetherEngine

@Suite("External subtitle initial-request header origins")
struct SubtitleHeaderOriginTests {
@Test func originAndExplicitAuthority() {
let media = URL(string: "https://Media.Example:443/movie.mkv")!
let headers = ["Authorization": "synthetic-media", "X-Custom-Token": "synthetic-custom"]
func resolve(_ target: String, _ explicit: [String: String]? = nil) -> [String: String] {
AetherEngine.resolvedSubtitleHeaders(for: URL(string: target)!, explicit: explicit,
mediaURL: media, mediaHeaders: headers)
}
#expect(resolve("https://media.example/en.srt") == headers)
for target in ["https://other.example/en.srt", "https://media.example:444/en.srt",
"http://media.example/en.srt", "file:///tmp/en.srt"] {
#expect(resolve(target).isEmpty)
#expect(resolve(target, ["Authorization": "subtitle"]) == ["Authorization": "subtitle"])
}
#expect(resolve("https://media.example/en.srt", [:]).isEmpty)
#expect(AetherEngine.resolvedSubtitleHeaders(
for: URL(string: "https://media.example/en.srt")!, explicit: nil,
mediaURL: URL(string: "http://media.example/movie.mkv"), mediaHeaders: headers).isEmpty)
#expect(AetherEngine.resolvedSubtitleHeaders(
for: media, explicit: nil, mediaURL: nil, mediaHeaders: headers).isEmpty)
}

@Test func nativeStoreFillKeepsHeadersTrackBound() {
let media = URL(string: "https://media.example/movie.mkv")!
let registry: [Int: ExternalSubtitleTrack] = [
100_000: .init(url: URL(string: "https://media.example/en.srt")!, name: "same"),
100_001: .init(url: URL(string: "https://other.example/fr.srt")!, name: "other"),
100_002: .init(url: URL(string: "https://other.example/de.srt")!, name: "explicit",
httpHeaders: ["Authorization": "subtitle"]),
]
let entries = registry.keys.sorted().map {
AetherEngine.NativeSubtitleTrackEntry(sourceStreamIndex: nil, externalID: $0, language: "en", isForced: false)
}
let jobs = AetherEngine.externalSubtitleFillJobs(
table: entries, registry: registry, stores: entries.map { _ in NativeSubtitleCueStore() },
defaultHeaders: ["Authorization": "media"], mediaURL: media)
#expect(jobs.map(\.headers) == [["Authorization": "media"], [:], ["Authorization": "subtitle"]])
let tracks = registry.keys.sorted().map {
RemoteHLSSubtitleProvider.Track(externalID: $0, source: registry[$0]!)
}
let remoteJobs = RemoteHLSSubtitleProvider.fillJobs(
tracks: tracks, stores: tracks.map { _ in NativeSubtitleCueStore() },
defaultHeaders: ["Authorization": "media"], mediaURL: media)
#expect(remoteJobs.map(\.headers) == jobs.map(\.headers))

}
}

#if os(macOS)
@MainActor
@Suite("Sidecar requests observed at their origin", .timeLimit(.minutes(2)))
struct SubtitleHeaderOriginRequestTests {
private let script = #"""
import http.server
class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
text = self.headers.get('Authorization', 'none') + '|' + self.headers.get('X-Custom-Token', 'none')
body = ('1\n00:00:00,000 --> 00:00:02,000\n' + text + '\n').encode()
self.send_response(200)
self.send_header('Content-Type', 'application/x-subrip')
self.send_header('Cache-Control', 'no-store')
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *args): pass
server = http.server.ThreadingHTTPServer(('127.0.0.1', 0), Handler)
print('READY ' + str(server.server_port), flush=True)
server.serve_forever()
"""#

@Test("Both overlays scope the actual first request", arguments: [false, true])
func actualRequests(secondary: Bool) async throws {
let origin = try #require(await PythonOrigin.launch(prefix: "sidecar-header-origin", script: script))
defer {
origin.process.terminate()
try? FileManager.default.removeItem(at: origin.workDir)
}
let url = URL(string: "http://127.0.0.1:\(origin.port)/en.srt")!
let engine = try AetherEngine()
defer { engine.stop() }
engine.setLoadedOptionsForTesting(.init(httpHeaders: ["Authorization": "media", "X-Custom-Token": "custom"]))
func select(_ headers: [String: String]?) async throws -> [String] {
if secondary {
engine.selectSecondarySidecarSubtitle(url: url, httpHeaders: headers)
await engine.secondarySidecarTask?.value
return engine.secondarySubtitleCues.compactMap(\.text)
}
engine.selectSidecarSubtitle(url: url, httpHeaders: headers)
await engine.sidecarTask?.value
return engine.subtitleCues.compactMap(\.text)
}
// The media origin need not be contacted. Different ports alone must isolate its headers.
let otherPort = origin.port == 65535 ? 65534 : Int(origin.port) + 1
engine.loadedURL = URL(string: "http://127.0.0.1:\(otherPort)/movie.mkv")!
#expect(try await select(nil) == ["none|none"])
#expect(try await select(["Authorization": "subtitle"]) == ["subtitle|none"])
engine.loadedURL = URL(string: "http://127.0.0.1:\(origin.port)/movie.mkv")!
#expect(try await select(nil) == ["media|custom"])
#expect(try await select([:]) == ["none|none"])
}
}
#endif
12 changes: 12 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -1378,3 +1378,15 @@ Public for the CLI, the test suite, or a diagnostic overlay, and outside the sha
- **Dolby Vision Profile 7 FEL tooling** (`@_spi(Diagnostics)`): `FelProbeResult` / `felDecodeProbe` (`aetherctl feltest`) and `felResidualEnabled` (`aetherctl play --cycle-el`), which exist only with the `DualLayerDolbyVision` trait, `DoviRpuSurvey` / `DoviRpuSurveyResult` / `DoviRpuFrameSurvey` / `doviRpuSurvey` (`aetherctl dvsurvey`), and, in `DEBUG` builds with the `DualLayerDolbyVision` trait only, `armDolbyVisionPinTest` / `dolbyVisionPinIsArmed` / `dolbyVisionPinIsActive` (`aetherctl play --dv-pin`). Hosts import none of them; the host surface of the FEL route is `LoadOptions.preferDualLayerDolbyVision` and, in `SessionReloadRefusal`, the new case `.dualLayerRouteNeedsFreshLoad`. See [Dolby Vision FEL](dolby-vision-fel.md) and [CLI](cli.md).
- **`HLSLiveIngestReader`'s internals** (`terminalError`, `upstreamTargetDuration`, `observedLiveCadenceSeconds`, `closedLiveCadenceSeconds`, `upstreamSegmentDurationSeconds`, `companionAudioReader`): fixture and diagnostic reads. The last two are the closed evidence the served TARGETDURATION is sealed from (AE#447); `upstreamTargetDuration` is the upstream's own claim, reported in the seal line and derived from nowhere.
- **`SubtitleChannel`**: the primary / secondary selector on the engine's internal subtitle routing. No public signature takes one; a host picks the channel by calling the primary or the secondary method.

### External subtitle request headers

When an external subtitle has no explicit `httpHeaders`, media headers are inherited only if
its URL has the same HTTP scheme, host and effective port as the media URL. An omitted default
port equals an explicit default port; a scheme change, including HTTP to HTTPS, is a different
origin for this initial sidecar request. Unknown media origins and local files inherit nothing.
This applies to primary and secondary overlays, native subtitle cue-store fills and remote-HLS
sidecar renditions. Set `ExternalSubtitleTrack.httpHeaders` or the sidecar call's `httpHeaders`
explicitly for a subtitle server requiring authentication at another origin. An explicit empty
dictionary disables inheritance. Redirects still follow the existing redirect-header policy,
and certificate trust still uses `EngineTLS.serverTrustEvaluator` independently.
Loading