276 lines
15 KiB
Swift
276 lines
15 KiB
Swift
import Foundation
|
|
|
|
/// Immutable, reusable subtitles; times are acknowledged player positions, not wall-clock time.
|
|
struct WebVTTDocument: Sendable {
|
|
struct Cue: Sendable, Equatable {
|
|
let start: Double
|
|
let end: Double
|
|
let text: String
|
|
}
|
|
|
|
let cues: [Cue]
|
|
private let maximumEnds: [Double]
|
|
static let byteLimit = 4 * 1024 * 1024
|
|
|
|
private enum SubtitleError: LocalizedError {
|
|
case request, transport, content
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .request: "Could not load subtitles. The subtitle URL or headers are invalid."
|
|
case .transport: "Could not load subtitles. Check the connection or choose another subtitle track."
|
|
case .content: "The subtitle resource is not valid WebVTT."
|
|
}
|
|
}
|
|
}
|
|
|
|
static func load(url: String, headers: String) async throws -> WebVTTDocument {
|
|
let request = try request(url: url, headers: headers)
|
|
let configuration = URLSessionConfiguration.ephemeral
|
|
configuration.timeoutIntervalForRequest = 15
|
|
configuration.timeoutIntervalForResource = 20
|
|
configuration.httpCookieStorage = nil
|
|
configuration.urlCredentialStorage = nil
|
|
configuration.urlCache = nil
|
|
let session = URLSession(configuration: configuration, delegate: SubtitleRedirects(request: request), delegateQueue: nil)
|
|
defer { session.invalidateAndCancel() }
|
|
let data: Data
|
|
do {
|
|
let (bytes, response) = try await session.bytes(for: request)
|
|
guard let response = response as? HTTPURLResponse,
|
|
(200...299).contains(response.statusCode), response.expectedContentLength <= Int64(byteLimit) else {
|
|
throw SubtitleError.transport
|
|
}
|
|
var buffer = Data()
|
|
if response.expectedContentLength > 0 { buffer.reserveCapacity(Int(response.expectedContentLength)) }
|
|
for try await byte in bytes {
|
|
guard buffer.count < byteLimit else { throw SubtitleError.transport }
|
|
buffer.append(byte)
|
|
}
|
|
data = buffer
|
|
} catch {
|
|
if Task.isCancelled { throw CancellationError() }
|
|
throw SubtitleError.transport
|
|
}
|
|
// Providers sometimes disguise VTT resources as images: trust the explicit format and content,
|
|
// never the path extension or Content-Type.
|
|
guard let text = String(data: data, encoding: .utf8) else { throw SubtitleError.content }
|
|
return try parse(text)
|
|
}
|
|
|
|
static func parse(_ text: String) throws -> WebVTTDocument {
|
|
guard text.utf8.count <= byteLimit, !text.contains("\0") else { throw SubtitleError.content }
|
|
var normalized = text
|
|
if normalized.first == "\u{FEFF}" { normalized.removeFirst() }
|
|
normalized = normalized.replacingOccurrences(of: "\r\n", with: "\n").replacingOccurrences(of: "\r", with: "\n")
|
|
let lines = normalized.components(separatedBy: "\n")
|
|
guard let header = lines.first,
|
|
header == "WEBVTT" || header.hasPrefix("WEBVTT ") || header.hasPrefix("WEBVTT\t"),
|
|
!header.contains("-->") else { throw SubtitleError.content }
|
|
var index = 1
|
|
// Header metadata ends at the first blank line. A cue without this separator is malformed.
|
|
while index < lines.count && !lines[index].isEmpty {
|
|
guard !lines[index].contains("-->") else { throw SubtitleError.content }
|
|
index += 1
|
|
}
|
|
var cues: [Cue] = []
|
|
var maximumEnds: [Double] = []
|
|
while index < lines.count {
|
|
while index < lines.count && lines[index].isEmpty { index += 1 }
|
|
let beginning = index
|
|
while index < lines.count && !lines[index].isEmpty { index += 1 }
|
|
guard beginning < index else { break }
|
|
let block = lines[beginning..<index]
|
|
let first = lines[beginning]
|
|
if first == "NOTE" || first.hasPrefix("NOTE ") || first.hasPrefix("NOTE\t") { continue }
|
|
if first == "STYLE" || first == "REGION" {
|
|
guard cues.isEmpty, !block.contains(where: { $0.contains("-->") }) else { throw SubtitleError.content }
|
|
continue // ponytail: plain-text cues; add layout/styling when positional subtitles required.
|
|
}
|
|
var timingIndex = beginning
|
|
if !first.contains("-->") { timingIndex += 1 } // Optional cue identifier.
|
|
guard timingIndex < index else { throw SubtitleError.content }
|
|
let timing = lines[timingIndex].split(whereSeparator: { $0 == " " || $0 == "\t" })
|
|
guard timing.count >= 3, timing[1] == "-->",
|
|
let start = timestamp(String(timing[0])), let end = timestamp(String(timing[2])),
|
|
end >= start, start >= (cues.last?.start ?? 0) else { throw SubtitleError.content }
|
|
// Equal timestamps produce an inactive cue, not a failure of the entire track.
|
|
try validateSettings(timing.dropFirst(3))
|
|
let payload = lines[(timingIndex + 1)..<index].joined(separator: "\n")
|
|
guard !payload.contains("-->") else { throw SubtitleError.content }
|
|
let cue = Cue(start: start, end: end, text: try plainText(payload))
|
|
cues.append(cue)
|
|
maximumEnds.append(max(end, maximumEnds.last ?? 0))
|
|
}
|
|
return WebVTTDocument(cues: cues, maximumEnds: maximumEnds)
|
|
}
|
|
|
|
func text(at acknowledgedSeconds: Double) -> String {
|
|
guard acknowledgedSeconds.isFinite, acknowledgedSeconds >= 0 else { return "" }
|
|
// Binary-search starts, then inspect only the potentially overlapping interval. No cursor:
|
|
// backward seeks and switching to an already-loaded track need no reset.
|
|
var low = 0
|
|
var high = cues.count
|
|
while low < high {
|
|
let middle = low + (high - low) / 2
|
|
if cues[middle].start <= acknowledgedSeconds { low = middle + 1 } else { high = middle }
|
|
}
|
|
var active: [String] = []
|
|
var index = low
|
|
while index > 0 {
|
|
index -= 1
|
|
if maximumEnds[index] <= acknowledgedSeconds { break }
|
|
if cues[index].end > acknowledgedSeconds && !cues[index].text.isEmpty { active.append(cues[index].text) }
|
|
}
|
|
return active.reversed().joined(separator: "\n")
|
|
}
|
|
|
|
// Shared with the focused check: these are the exact request and redirect policies used above.
|
|
static func request(url text: String, headers: String) throws -> URLRequest {
|
|
guard text.utf8.count <= 32768,
|
|
!text.unicodeScalars.contains(where: { CharacterSet.whitespacesAndNewlines.contains($0) || CharacterSet.controlCharacters.contains($0) }),
|
|
let components = URLComponents(string: text),
|
|
["http", "https"].contains(components.scheme?.lowercased() ?? ""),
|
|
let host = components.host, !host.isEmpty,
|
|
components.user == nil, components.password == nil, components.fragment == nil,
|
|
components.port == nil || (1...65535).contains(components.port!),
|
|
let url = components.url, headers.utf8.count <= 65536,
|
|
headers.isEmpty || headers.hasSuffix("\r\n") else { throw SubtitleError.request }
|
|
var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 15)
|
|
request.httpShouldHandleCookies = false
|
|
var names = Set<String>()
|
|
let lines = headers.isEmpty ? [] : headers.components(separatedBy: "\r\n").dropLast().map { $0 }
|
|
guard lines.count <= 64 else { throw SubtitleError.request }
|
|
for line in lines {
|
|
guard let colon = line.firstIndex(of: ":") else { throw SubtitleError.request }
|
|
let name = String(line[..<colon])
|
|
var value = String(line[line.index(after: colon)...])
|
|
// ResolvedSource serializes exactly one separator space; the resource value is verbatim.
|
|
if value.first == " " { value.removeFirst() }
|
|
guard !name.isEmpty, name.utf8.count <= 256,
|
|
name.utf8.allSatisfy({ (65...90).contains($0) || (97...122).contains($0) || (48...57).contains($0) || "!#$%&'*+-.^_`|~".utf8.contains($0) }),
|
|
names.insert(name.lowercased()).inserted, value.utf8.count <= 16384,
|
|
!value.unicodeScalars.contains(where: { ($0.value < 32 && $0.value != 9) || $0.value == 127 }) else { throw SubtitleError.request }
|
|
request.setValue(value, forHTTPHeaderField: name)
|
|
}
|
|
return request
|
|
}
|
|
|
|
static func permitsRedirect(from source: URL, to destination: URL) -> Bool {
|
|
guard let a = URLComponents(url: source, resolvingAgainstBaseURL: false),
|
|
let b = URLComponents(url: destination, resolvingAgainstBaseURL: false),
|
|
let scheme = a.scheme?.lowercased(), ["http", "https"].contains(scheme),
|
|
b.scheme?.lowercased() == scheme, a.host?.lowercased() == b.host?.lowercased(),
|
|
(a.port ?? (scheme == "https" ? 443 : 80)) == (b.port ?? (scheme == "https" ? 443 : 80)),
|
|
b.user == nil, b.password == nil, b.fragment == nil else { return false }
|
|
// Reject rather than guess which resource-specific headers contain credentials.
|
|
return true
|
|
}
|
|
|
|
private static func timestamp(_ text: String) -> Double? {
|
|
let fields = text.split(separator: ":", omittingEmptySubsequences: false)
|
|
guard fields.count == 2 || fields.count == 3 else { return nil }
|
|
let seconds = fields.last!.split(separator: ".", omittingEmptySubsequences: false)
|
|
guard seconds.count == 2, seconds[0].utf8.count == 2, seconds[1].utf8.count == 3,
|
|
fields.dropLast().allSatisfy({ $0.utf8.count >= 2 }),
|
|
(fields.dropLast().map(String.init) + seconds.map(String.init)).allSatisfy({ $0.utf8.allSatisfy { (48...57).contains($0) } }),
|
|
let second = Double(seconds[0]), second < 60,
|
|
let milliseconds = Double(seconds[1]), let minute = Double(fields[fields.count - 2]),
|
|
minute < 60, fields[fields.count - 2].utf8.count == 2 else { return nil }
|
|
let hours = fields.count == 3 ? Double(fields[0]) : 0
|
|
guard let hours else { return nil }
|
|
let result = hours * 3600 + minute * 60 + second + milliseconds / 1000
|
|
return result.isFinite ? result : nil
|
|
}
|
|
|
|
private static func validateSettings(_ settings: ArraySlice<Substring>) throws {
|
|
func percent(_ text: Substring) -> Bool {
|
|
guard text.last == "%" else { return false }
|
|
let number = text.dropLast()
|
|
guard !number.isEmpty, number.utf8.allSatisfy({ (48...57).contains($0) || $0 == 46 }),
|
|
let value = Double(number) else { return false }
|
|
return (0...100).contains(value)
|
|
}
|
|
var names = Set<Substring>()
|
|
for setting in settings {
|
|
let pair = setting.split(separator: ":", omittingEmptySubsequences: false)
|
|
guard pair.count == 2, !pair[1].isEmpty, names.insert(pair[0]).inserted else { throw SubtitleError.content }
|
|
let values = pair[1].split(separator: ",", omittingEmptySubsequences: false)
|
|
let valid: Bool
|
|
switch pair[0] {
|
|
case "vertical": valid = values.count == 1 && ["rl", "lr"].contains(pair[1])
|
|
case "align": valid = values.count == 1 && ["start", "center", "end", "left", "right"].contains(pair[1])
|
|
case "size": valid = values.count == 1 && percent(pair[1])
|
|
case "region": valid = values.count == 1
|
|
case "position":
|
|
valid = (values.count == 1 || values.count == 2) && percent(values[0]) && (values.count == 1 || ["line-left", "center", "line-right", "auto"].contains(values[1]))
|
|
case "line":
|
|
let number = values[0].first == "-" ? values[0].dropFirst() : values[0][...]
|
|
let integer = !number.isEmpty && number.utf8.allSatisfy { (48...57).contains($0) }
|
|
valid = (values.count == 1 || values.count == 2) && (values[0] == "auto" || percent(values[0]) || integer) && (values.count == 1 || ["start", "center", "end"].contains(values[1]))
|
|
default: valid = false
|
|
}
|
|
guard valid else { throw SubtitleError.content }
|
|
}
|
|
}
|
|
|
|
private static func plainText(_ payload: String) throws -> String {
|
|
var result = ""
|
|
var index = payload.startIndex
|
|
let entities: [String: String] = ["amp": "&", "lt": "<", "gt": ">", "nbsp": "\u{A0}", "lrm": "\u{200E}", "rlm": "\u{200F}", "quot": "\"", "apos": "'"]
|
|
while index < payload.endIndex {
|
|
if payload[index] == "<" {
|
|
guard let end = payload[index...].firstIndex(of: ">") else { throw SubtitleError.content }
|
|
var tag = String(payload[payload.index(after: index)..<end])
|
|
if tag.first == "/" { tag.removeFirst() }
|
|
let name = tag.prefix { $0 != " " && $0 != "\t" && $0 != "." }
|
|
guard ["b", "i", "u", "c", "v", "lang", "ruby", "rt"].contains(String(name)) || timestamp(tag) != nil else { throw SubtitleError.content }
|
|
index = payload.index(after: end)
|
|
} else if payload[index] == "&", let end = payload[index...].firstIndex(of: ";"), payload.distance(from: index, to: end) <= 16 {
|
|
let entity = String(payload[payload.index(after: index)..<end])
|
|
var decoded = entities[entity]
|
|
if entity.hasPrefix("#") {
|
|
let hexadecimal = entity.hasPrefix("#x") || entity.hasPrefix("#X")
|
|
let digits = entity.dropFirst(hexadecimal ? 2 : 1)
|
|
if let number = UInt32(digits, radix: hexadecimal ? 16 : 10), number != 0, let scalar = UnicodeScalar(number) { decoded = String(scalar) }
|
|
}
|
|
if let decoded {
|
|
result += decoded
|
|
index = payload.index(after: end)
|
|
} else {
|
|
result.append(payload[index])
|
|
index = payload.index(after: index)
|
|
}
|
|
} else {
|
|
result.append(payload[index])
|
|
index = payload.index(after: index)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
}
|
|
|
|
private final class SubtitleRedirects: NSObject, URLSessionTaskDelegate, @unchecked Sendable {
|
|
private let request: URLRequest
|
|
private let lock = NSLock()
|
|
private var count = 0
|
|
|
|
init(request: URLRequest) { self.request = request }
|
|
|
|
func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse,
|
|
newRequest: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) {
|
|
lock.lock()
|
|
count += 1
|
|
let withinLimit = count <= 5
|
|
lock.unlock()
|
|
guard withinLimit, let source = request.url, let destination = newRequest.url,
|
|
WebVTTDocument.permitsRedirect(from: source, to: destination) else {
|
|
completionHandler(nil)
|
|
return
|
|
}
|
|
var redirected = newRequest
|
|
redirected.httpShouldHandleCookies = false
|
|
for (name, value) in request.allHTTPHeaderFields ?? [:] { redirected.setValue(value, forHTTPHeaderField: name) }
|
|
completionHandler(redirected)
|
|
}
|
|
}
|