feat: add headless Zoko source checks

This commit is contained in:
2026-10-25 18:01:47 +01:00
parent e6a2759497
commit b95f7d251e
11 changed files with 329 additions and 12 deletions
+35 -4
View File
@@ -189,6 +189,25 @@ final class MiruroTransport: @unchecked Sendable {
throw MiruroPlaybackError()
}
}
// Headless probe: no listener, relay token, native player or subtitle fetch.
// The same HTTP reader and manifest walker are used by actual playback.
static func checkZokoRoot(_ source: ResolvedSource) async throws {
guard source.requirements.zoko, let url = URL(string: source.url),
ZokoResolver.validResource(url, playlist: true) else { throw ZokoError.malformed }
let (data, response) = try await MiruroMediaHTTP.read(url, headers: ZokoResolver.referer,
limit: ZokoResolver.bodyLimit)
guard response.statusCode == 200, response.url == url,
let text = String(data: data, encoding: .utf8) else { throw ZokoError.malformed }
try validateZokoManifest(text, base: url)
}
static func validateZokoManifest(_ text: String, base: URL) throws {
guard ZokoResolver.validResource(base, playlist: true) else { throw ZokoError.malformed }
_ = try rewrite(text, base: base, strict: true) { url, kind in
guard ZokoResolver.validResource(url, playlist: kind == .playlist) else { throw ZokoError.malformed }
return url
}
}
static func subtitle(_ track: ResolvedSubtitle) async throws -> ResolvedSubtitle {
guard let proxy = track.proxy, let url = URL(string: track.url) else { return track }
@@ -375,7 +394,9 @@ final class MiruroTransport: @unchecked Sendable {
} else { (raw, response) = try await fetch(url) }
if resource.kind == .playlist {
guard response.statusCode == 200, let text = String(data: raw, encoding: .utf8), text.hasPrefix("#EXTM3U") else { throw MiruroPlaybackError() }
return (try rewrite(text, base: url, depth: resource.depth), "application/vnd.apple.mpegurl", nil)
return (try Self.rewrite(text, base: url, strict: requirements.zoko) { target, kind in
try self.register(target, kind: kind, depth: resource.depth + 1)
}, "application/vnd.apple.mpegurl", nil)
}
guard embed == nil || response.statusCode == 200 else { throw MiruroPlaybackError() }
let data = try embed?.resourceData(raw, url: url) ?? raw
@@ -431,30 +452,40 @@ final class MiruroTransport: @unchecked Sendable {
}
}
private func rewrite(_ text: String, base: URL, depth: Int) throws -> Data {
private static func rewrite(_ text: String, base: URL, strict: Bool,
register: (URL, Kind) throws -> URL) throws -> Data {
guard text.utf8.count <= 2 * 1024 * 1024, text.hasPrefix("#EXTM3U") else { throw MiruroPlaybackError() }
let regex = try NSRegularExpression(pattern: #"URI=\"([^\"]+)\""#)
var nextPlaylist = false
var hasMedia = false
var hasReference = false
var lines: [String] = []
for raw in text.components(separatedBy: .newlines) {
try Task.checkCancellation()
var line = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if line.hasPrefix("#") {
if strict, lines.isEmpty, line != "#EXTM3U" { throw ZokoError.malformed }
if line.hasPrefix("#EXTINF:") { hasMedia = true }
let kind: Kind = line.hasPrefix("#EXT-X-MAP:") ? .segment :
(line.hasPrefix("#EXT-X-MEDIA:") || line.hasPrefix("#EXT-X-I-FRAME-STREAM-INF:") || line.hasPrefix("#EXT-X-RENDITION-REPORT:")) ? .playlist : .resource
for match in regex.matches(in: line, range: NSRange(line.startIndex..., in: line)).reversed() {
guard let range = Range(match.range(at: 1), in: line),
let url = URL(string: String(line[range]), relativeTo: base)?.absoluteURL else { throw MiruroPlaybackError() }
let replacement = try register(url, kind: kind, depth: depth + 1)
let replacement = try register(url, kind)
if kind == .playlist { hasReference = true }
line.replaceSubrange(range, with: replacement.absoluteString)
}
if line.hasPrefix("#EXT-X-STREAM-INF:") { nextPlaylist = true }
} else if !line.isEmpty {
guard let url = URL(string: line, relativeTo: base)?.absoluteURL else { throw MiruroPlaybackError() }
let kind: Kind = nextPlaylist || url.path.lowercased().hasSuffix(".m3u8") ? .playlist : .segment
line = try register(url, kind: kind, depth: depth + 1).absoluteString
if nextPlaylist || hasMedia { hasReference = true }
line = try register(url, kind).absoluteString
nextPlaylist = false
}
lines.append(line)
}
if strict, nextPlaylist || !hasReference { throw ZokoError.malformed }
return Data(lines.joined(separator: "\n").utf8)
}
}