feat: play resolved Miruro HLS with external VTT and stable episode progress
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
import Foundation
|
||||
import Darwin
|
||||
|
||||
@main
|
||||
struct ExternalSubtitleChecks {
|
||||
struct Failure: Error, CustomStringConvertible { let description: String }
|
||||
|
||||
static func require(_ condition: @autoclosure () throws -> Bool, _ message: String) throws {
|
||||
guard try condition() else { throw Failure(description: "FAIL: \(message)") }
|
||||
}
|
||||
|
||||
static func reject(_ operation: () throws -> Void) throws {
|
||||
do { try operation() }
|
||||
catch is Failure { throw Failure(description: "FAIL: rejection assertion") }
|
||||
catch { return }
|
||||
throw Failure(description: "FAIL: malformed input accepted")
|
||||
}
|
||||
|
||||
static let fixture = """
|
||||
WEBVTT example
|
||||
|
||||
NOTE ignored comment
|
||||
not a cue
|
||||
|
||||
STYLE
|
||||
::cue { color: white; }
|
||||
|
||||
REGION
|
||||
id:bottom
|
||||
width:80%
|
||||
|
||||
cue-one
|
||||
00:01.000 --> 00:04.000 line:90% position:50%,center align:center size:80% region:bottom
|
||||
<v Speaker><b>Hello & goodbye</b></v>
|
||||
second <c.yellow>line</c> <3 !
|
||||
|
||||
cue-two
|
||||
00:02.000 --> 00:03.000 vertical:rl
|
||||
Overlap <00:02.500><i>text</i>
|
||||
|
||||
00:04.000 --> 00:05.000
|
||||
Next
|
||||
|
||||
"""
|
||||
|
||||
static func main() async throws {
|
||||
let document = try WebVTTDocument.parse("\u{FEFF}" + fixture.replacingOccurrences(of: "\n", with: "\r\n"))
|
||||
let first = "Hello & goodbye\nsecond line <3 !"
|
||||
try require(document.cues.count == 3, "BOM, CRLF, identifiers and metadata blocks")
|
||||
try require(document.text(at: 0.999).isEmpty, "before first cue")
|
||||
try require(document.text(at: 1) == first, "inclusive start and plain-text markup/entities")
|
||||
try require(document.text(at: 2.5) == first + "\nOverlap text", "simultaneous overlapping cues")
|
||||
try require(document.text(at: 3) == first, "exclusive overlap end")
|
||||
try require(document.text(at: 4) == "Next", "adjacent exclusive end/inclusive start")
|
||||
try require(document.text(at: 10).isEmpty, "after final cue")
|
||||
try require(document.text(at: 2) == first + "\nOverlap text", "backward seek after final cue")
|
||||
try require(document.text(at: .nan).isEmpty && document.text(at: -1).isEmpty, "invalid acknowledged positions")
|
||||
let nested = try WebVTTDocument.parse("WEBVTT\n\n00:00.000 --> 01:00.000\nlong\n\n00:02.000 --> 00:03.000\nshort\n")
|
||||
try require(nested.text(at: 4) == "long", "earlier long cue survives later cue ending")
|
||||
try require(try WebVTTDocument.parse("WEBVTT\n\n01:02:03.004 --> 01:02:04.000\nhour\n").text(at: 3723.004) == "hour", "hour timestamps")
|
||||
let zeroDuration = try WebVTTDocument.parse("WEBVTT\n\n00:01.000 --> 00:01.000\nnever visible\n\n00:02.000 --> 00:03.000\nvalid later cue\n")
|
||||
try require(zeroDuration.text(at: 0.999).isEmpty && zeroDuration.text(at: 1).isEmpty && zeroDuration.text(at: 1.001).isEmpty, "zero-duration cue is never active")
|
||||
try require(zeroDuration.text(at: 2) == "valid later cue", "zero-duration cue does not invalidate later subtitles")
|
||||
try require(zeroDuration.text(at: 4).isEmpty && zeroDuration.text(at: 1).isEmpty, "zero-duration cue stays inactive after backward seek")
|
||||
for malformed in [
|
||||
"not VTT", "WEBVTT\n00:01.000 --> 00:02.000\nmissing separator",
|
||||
"WEBVTT\n\n00:61.000 --> 00:62.000\nbad time",
|
||||
"WEBVTT\n\n00:02.000 --> 00:01.000\nreversed interval",
|
||||
"WEBVTT\n\n00:01.000 --> 00:02.000 size:101%\nbad setting",
|
||||
"WEBVTT\n\n00:01.000 --> 00:02.000\n<b unfinished",
|
||||
"WEBVTT\n\norphan identifier", "WEBVTT\n\n00:01.000 --> 00:02.000\ncue\n\nSTYLE\nlate style"
|
||||
] { try reject { _ = try WebVTTDocument.parse(malformed) } }
|
||||
try reject { _ = try WebVTTDocument.parse(String(repeating: "x", count: WebVTTDocument.byteLimit + 1)) }
|
||||
|
||||
let headers = "Authorization: Bearer fixture-secret\r\nReferer: https://provider.example/\r\nX-Track: english\r\n"
|
||||
let request = try WebVTTDocument.request(url: "https://provider.example/track.jpeg", headers: headers)
|
||||
try require(request.value(forHTTPHeaderField: "Authorization") == "Bearer fixture-secret", "resource authorization header")
|
||||
try require(request.value(forHTTPHeaderField: "X-Track") == "english", "resource-specific custom header")
|
||||
let whitespace = try WebVTTDocument.request(url: "https://provider.example/track", headers: "X-Exact: padded \t \r\n")
|
||||
try require(whitespace.value(forHTTPHeaderField: "X-Exact") == " padded \t ", "preserve header value whitespace after generated separator")
|
||||
for malformed in ["X: a\nInjected: value\r\n", "X: a\rInjected: value\r\n", "X: a\r\nX: b\r\n", "X: a", ": a\r\n"] {
|
||||
try reject { _ = try WebVTTDocument.request(url: "https://provider.example/track", headers: malformed) }
|
||||
}
|
||||
for url in ["file:///tmp/track", "https://user:secret@provider.example/track", "https://provider.example/track#fragment"] {
|
||||
try reject { _ = try WebVTTDocument.request(url: url, headers: "") }
|
||||
}
|
||||
let source = URL(string: "https://provider.example/track.jpeg")!
|
||||
try require(WebVTTDocument.permitsRedirect(from: source, to: URL(string: "https://PROVIDER.example:443/other")!), "same origin includes default port")
|
||||
for target in ["https://cdn.example/track", "http://provider.example/track", "https://provider.example:8443/track", "https://user:secret@provider.example/track"] {
|
||||
try require(!WebVTTDocument.permitsRedirect(from: source, to: URL(string: target)!), "cross-origin or credentialed redirect rejection")
|
||||
}
|
||||
|
||||
let server = try SubtitleFixtureServer(body: fixture)
|
||||
defer { server.stop() }
|
||||
let loaded = try await WebVTTDocument.load(url: server.url("/same.jpeg"), headers: headers)
|
||||
try require(loaded.cues == document.cues, "load JPEG-named, image/jpeg VTT via same-origin redirect")
|
||||
let requests = server.requests
|
||||
try require(requests.count == 2 && requests.allSatisfy { $0.contains("Bearer fixture-secret") && $0.contains("english") }, "resource headers preserved only on same origin")
|
||||
do {
|
||||
_ = try await WebVTTDocument.load(url: server.url("/cross.jpeg"), headers: headers)
|
||||
throw Failure(description: "FAIL: cross-origin redirect accepted")
|
||||
} catch is Failure {
|
||||
throw Failure(description: "FAIL: cross-origin redirect accepted")
|
||||
} catch {
|
||||
let message = error.localizedDescription
|
||||
try require(!message.contains("fixture-secret") && !message.contains("127.0.0.1"), "sanitized visible transport error")
|
||||
}
|
||||
try require(server.requests.count == 3, "cross-origin redirect never sends credential-bearing request")
|
||||
do {
|
||||
_ = try await WebVTTDocument.load(url: server.url("/oversized.jpeg"), headers: "")
|
||||
throw Failure(description: "FAIL: oversized response accepted")
|
||||
} catch is Failure { throw Failure(description: "FAIL: oversized response accepted") }
|
||||
catch { }
|
||||
print("External subtitle checks passed")
|
||||
}
|
||||
}
|
||||
|
||||
/// A loopback-only HTTP fixture; no external services, dates, credentials or frameworks.
|
||||
private final class SubtitleFixtureServer: @unchecked Sendable {
|
||||
private let descriptor: Int32
|
||||
private let port: UInt16
|
||||
private let body: String
|
||||
private let lock = NSLock()
|
||||
private var recorded: [String] = []
|
||||
private let ended = DispatchSemaphore(value: 0)
|
||||
|
||||
var requests: [String] {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return recorded
|
||||
}
|
||||
|
||||
init(body: String) throws {
|
||||
self.body = body
|
||||
let descriptor = socket(AF_INET, SOCK_STREAM, 0)
|
||||
self.descriptor = descriptor
|
||||
guard descriptor >= 0 else { throw ExternalSubtitleChecks.Failure(description: "FAIL: fixture socket") }
|
||||
var address = sockaddr_in()
|
||||
address.sin_len = UInt8(MemoryLayout<sockaddr_in>.size)
|
||||
address.sin_family = sa_family_t(AF_INET)
|
||||
address.sin_addr.s_addr = inet_addr("127.0.0.1")
|
||||
let bound = withUnsafePointer(to: &address) {
|
||||
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) { bind(descriptor, $0, socklen_t(MemoryLayout<sockaddr_in>.size)) }
|
||||
}
|
||||
var size = socklen_t(MemoryLayout<sockaddr_in>.size)
|
||||
let named = withUnsafeMutablePointer(to: &address) {
|
||||
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) { getsockname(descriptor, $0, &size) }
|
||||
}
|
||||
guard bound == 0, named == 0, listen(descriptor, 4) == 0 else {
|
||||
close(descriptor)
|
||||
throw ExternalSubtitleChecks.Failure(description: "FAIL: fixture listener")
|
||||
}
|
||||
port = UInt16(bigEndian: address.sin_port)
|
||||
DispatchQueue.global().async { self.serve() }
|
||||
}
|
||||
|
||||
func url(_ path: String) -> String { "http://127.0.0.1:\(port)\(path)" }
|
||||
|
||||
func stop() {
|
||||
shutdown(descriptor, SHUT_RDWR)
|
||||
close(descriptor)
|
||||
ended.wait()
|
||||
}
|
||||
|
||||
private func serve() {
|
||||
defer { ended.signal() }
|
||||
while true {
|
||||
let client = accept(descriptor, nil, nil)
|
||||
guard client >= 0 else { return }
|
||||
var noSignal: Int32 = 1
|
||||
_ = setsockopt(client, SOL_SOCKET, SO_NOSIGPIPE, &noSignal, socklen_t(MemoryLayout<Int32>.size))
|
||||
var buffer = [UInt8](repeating: 0, count: 4096)
|
||||
var data = Data()
|
||||
while data.count < 65536 {
|
||||
let count = recv(client, &buffer, buffer.count, 0)
|
||||
if count <= 0 { break }
|
||||
data.append(contentsOf: buffer.prefix(count))
|
||||
if String(decoding: data, as: UTF8.self).contains("\r\n\r\n") { break }
|
||||
}
|
||||
let request = String(decoding: data, as: UTF8.self)
|
||||
lock.lock()
|
||||
recorded.append(request)
|
||||
lock.unlock()
|
||||
let path = request.split(separator: " ").dropFirst().first ?? ""
|
||||
let response: String
|
||||
switch path {
|
||||
case "/same.jpeg": response = "HTTP/1.1 302 Found\r\nLocation: /track.jpeg\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
|
||||
case "/cross.jpeg": response = "HTTP/1.1 302 Found\r\nLocation: http://localhost:\(port)/track.jpeg\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
|
||||
case "/oversized.jpeg": response = "HTTP/1.1 200 OK\r\nContent-Length: \(WebVTTDocument.byteLimit + 1)\r\nConnection: close\r\n\r\n"
|
||||
default: response = "HTTP/1.1 200 OK\r\nContent-Type: image/jpeg\r\nContent-Length: \(body.utf8.count)\r\nConnection: close\r\n\r\n\(body)"
|
||||
}
|
||||
let bytes = Array(response.utf8)
|
||||
bytes.withUnsafeBytes { raw in
|
||||
var offset = 0
|
||||
while offset < raw.count {
|
||||
let count = send(client, raw.baseAddress!.advanced(by: offset), raw.count - offset, 0)
|
||||
if count <= 0 { break }
|
||||
offset += count
|
||||
}
|
||||
}
|
||||
close(client)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user