feat: add headless Zoko source checks
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ enum ZokoResolver {
|
||||
completionHandler: @escaping (URLRequest?) -> Void) { completionHandler(nil) }
|
||||
}
|
||||
|
||||
private static func fetch(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
|
||||
private static func fetch(_ request: URLRequest, allowMissingPage: Bool = false) async throws -> (Data, HTTPURLResponse) {
|
||||
let configuration = URLSessionConfiguration.ephemeral
|
||||
configuration.httpShouldSetCookies = false
|
||||
configuration.httpCookieStorage = nil
|
||||
@@ -46,7 +46,7 @@ enum ZokoResolver {
|
||||
guard let response = response as? HTTPURLResponse, response.url == request.url else { throw ZokoError.transport }
|
||||
guard response.expectedContentLength <= bodyLimit else { throw ZokoError.malformed }
|
||||
if response.statusCode == 403 || response.statusCode == 429 || response.statusCode == 503 { throw ZokoError.protection }
|
||||
guard response.statusCode == 200 else { throw ZokoError.transport }
|
||||
guard response.statusCode == 200 || (allowMissingPage && response.statusCode == 404) else { throw ZokoError.transport }
|
||||
var data = Data()
|
||||
if response.expectedContentLength > 0 { data.reserveCapacity(Int(response.expectedContentLength)) }
|
||||
for try await byte in bytes {
|
||||
@@ -62,14 +62,16 @@ enum ZokoResolver {
|
||||
}
|
||||
}
|
||||
|
||||
static func resolve(mediaID: String, episode: Int) async throws -> ResolvedSource {
|
||||
static func resolve(mediaID: String, episode: Int, enrichTitle: Bool = true) async throws -> ResolvedSource {
|
||||
guard ResolvedSource.validAniListIdentity(mediaID: mediaID, episode: episode) else { throw ZokoError.malformed }
|
||||
var request = URLRequest(url: URL(string: "https://zokoanime.video/stream/ani/\(mediaID)/\(episode)/sub")!)
|
||||
request.timeoutInterval = 20
|
||||
request.setValue("text/html", forHTTPHeaderField: "Accept")
|
||||
let (data, response) = try await fetch(request)
|
||||
let (data, response) = try await fetch(request, allowMissingPage: true)
|
||||
guard response.mimeType == "text/html", let page = String(data: data, encoding: .utf8) else { throw ZokoError.malformed }
|
||||
let source = try parse(page: page, mediaID: mediaID, episode: episode)
|
||||
guard response.statusCode == 200 else { throw ZokoError.transport }
|
||||
guard enrichTitle else { return source }
|
||||
let title = (try? await Self.title(mediaID: mediaID, episode: episode)) ?? source.title
|
||||
try Task.checkCancellation()
|
||||
return ResolvedSource(url: source.url, headers: source.headers, identity: source.identity,
|
||||
@@ -85,9 +87,9 @@ enum ZokoResolver {
|
||||
guard !page.contains("window.__P") else { throw ZokoError.malformed }
|
||||
let lower = page.lowercased()
|
||||
let visible = lower.replacingOccurrences(of: "<[^>]*>", with: " ", options: .regularExpression)
|
||||
if visible.contains("error"), visible.contains("404"), visible.contains("couldn't find anything to play here") { throw ZokoError.unavailable }
|
||||
if lower.contains("cf-chl-") || lower.contains("challenge-platform") || lower.contains("just a moment") ||
|
||||
lower.contains("captcha") || lower.contains("sorry, you have been blocked") { throw ZokoError.protection }
|
||||
if visible.contains("error"), visible.contains("404"), visible.contains("couldn't find anything to play here") { throw ZokoError.unavailable }
|
||||
throw ZokoError.malformed
|
||||
}
|
||||
guard var decoded = Data(base64Encoded: String(page[range])), !decoded.isEmpty else { throw ZokoError.malformed }
|
||||
|
||||
@@ -3,6 +3,7 @@ import Foundation
|
||||
enum PlayerCommand {
|
||||
case help
|
||||
case printDefaultConfig
|
||||
case checkSource(mediaID: String, episode: Int)
|
||||
case launch(URL?, newInstance: Bool, noConfig: Bool, progressFile: URL?)
|
||||
case launchSource(sourceFile: URL, newInstance: Bool, noConfig: Bool, progressFile: URL?)
|
||||
case launchQueue(queueFile: URL, newInstance: Bool, noConfig: Bool)
|
||||
@@ -15,6 +16,7 @@ enum PlayerCommand {
|
||||
terebi --new-instance --source-file PATH [--progress-file PATH]
|
||||
terebi --new-instance [--no-config] --queue-file PATH
|
||||
terebi --enqueue [--no-config] --queue-file PATH
|
||||
terebi --check-source ZOKO_ANILIST_SUB_URL
|
||||
terebi [--new-instance] [--no-config] -- FILE
|
||||
terebi --help
|
||||
terebi --default-config
|
||||
@@ -26,6 +28,8 @@ enum PlayerCommand {
|
||||
--new-instance Launch a separate player, leaving existing playback untouched.
|
||||
--no-config Use built-in settings instead of config.toml on a fresh launch.
|
||||
--url URL Resolve an HTTP(S) video, Miruro episode or ZokoAnime AniList sub URL.
|
||||
--check-source URL Read-only source/manifest check; one version-1 JSON result,
|
||||
no player launch, configuration, progress or resume writes.
|
||||
--source-file PATH Open a validated resolved HTTP(S) source in a new player.
|
||||
--queue-file PATH Open an ordered version-1 stream queue with per-item reports.
|
||||
--enqueue Append a queue to the running player without interrupting playback;
|
||||
@@ -42,6 +46,16 @@ enum PlayerCommand {
|
||||
"""
|
||||
|
||||
static func parse(_ arguments: [String], directory: URL = URL(fileURLWithPath: FileManager.default.currentDirectoryPath, isDirectory: true)) throws -> PlayerCommand {
|
||||
// Parse this isolated command before any launch flags, paths or file reads.
|
||||
if arguments.prefix(while: { $0 != "--" }).contains("--check-source") {
|
||||
let invalid = ArgumentError("--check-source requires exactly one canonical ZokoAnime AniList sub URL and no other options.")
|
||||
guard arguments.count == 2, arguments[0] == "--check-source",
|
||||
arguments[1].utf8.count <= 512,
|
||||
arguments[1] == arguments[1].trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
let parsed = try? StreamURL.parse(arguments[1]),
|
||||
case .anilist(let mediaID, let episode) = parsed else { throw invalid }
|
||||
return .checkSource(mediaID: mediaID, episode: episode)
|
||||
}
|
||||
var inputs = arguments
|
||||
var progressFile: URL?
|
||||
var index = 0
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import Foundation
|
||||
|
||||
// Deliberately separate from launch: no configuration, application, player or state APIs.
|
||||
enum SourceCheck {
|
||||
enum Status: String, Sendable, CaseIterable {
|
||||
case available, unavailable, failed
|
||||
|
||||
var json: String {
|
||||
"{\"version\":1,\"provider\":\"zokoanime\",\"track\":\"sub\",\"status\":\"\(rawValue)\"}"
|
||||
}
|
||||
}
|
||||
|
||||
static func run(
|
||||
mediaID: String, episode: Int, timeout: Duration = .seconds(15),
|
||||
resolve: @escaping @Sendable (String, Int) async throws -> ResolvedSource = {
|
||||
try await ZokoResolver.resolve(mediaID: $0, episode: $1, enrichTitle: false)
|
||||
},
|
||||
validate: @escaping @Sendable (ResolvedSource) async throws -> Void = {
|
||||
try await MiruroTransport.checkZokoRoot($0)
|
||||
}
|
||||
) async -> Status {
|
||||
await withTaskGroup(of: Status.self) { group in
|
||||
group.addTask {
|
||||
let source: ResolvedSource
|
||||
do {
|
||||
try Task.checkCancellation()
|
||||
source = try await resolve(mediaID, episode)
|
||||
try Task.checkCancellation()
|
||||
} catch ZokoError.unavailable {
|
||||
return Task.isCancelled ? .failed : .unavailable
|
||||
} catch {
|
||||
return .failed
|
||||
}
|
||||
do {
|
||||
try await validate(source)
|
||||
try Task.checkCancellation()
|
||||
return .available
|
||||
} catch {
|
||||
// A manifest 404, protection page or other resource failure is
|
||||
// not evidence of the provider's explicit missing-episode page.
|
||||
return .failed
|
||||
}
|
||||
}
|
||||
group.addTask {
|
||||
try? await Task.sleep(for: timeout)
|
||||
return .failed
|
||||
}
|
||||
let result = await group.next() ?? .failed
|
||||
group.cancelAll()
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,8 @@ enum TerebiCLI {
|
||||
print(PlayerCommand.usage)
|
||||
case .printDefaultConfig:
|
||||
print(PlayerConfiguration.defaultTOML, terminator: "")
|
||||
case .checkSource(let mediaID, let episode):
|
||||
print(await SourceCheck.run(mediaID: mediaID, episode: episode).json)
|
||||
case .launch(let url, let newInstance, let noConfig, let progressFile):
|
||||
await launch(url: url, sourceFile: nil, newInstance: newInstance, noConfig: noConfig, progressFile: progressFile)
|
||||
case .launchSource(let sourceFile, let newInstance, let noConfig, let progressFile):
|
||||
|
||||
Reference in New Issue
Block a user