feat: resolve AniList episodes through ZokoAnime

This commit is contained in:
2026-10-25 15:46:35 +01:00
parent 8c2a18df48
commit ab61d83c69
16 changed files with 492 additions and 21 deletions
+8 -2
View File
@@ -165,7 +165,7 @@ final class MiruroTransport: @unchecked Sendable {
static func prepare(_ source: ResolvedSource, deployment override: MiruroProxyDeployment? = nil) async throws -> ResolvedSource {
let rules = source.requirements
guard rules.embed || rules.proxy != nil || rules.segmentOrigin != nil || !rules.playlistQuery.isEmpty else { return source }
guard rules.zoko || rules.embed || rules.proxy != nil || rules.segmentOrigin != nil || !rules.playlistQuery.isEmpty else { return source }
do {
let deployment: MiruroProxyDeployment?
if let override { deployment = override }
@@ -185,6 +185,7 @@ final class MiruroTransport: @unchecked Sendable {
return prepared
} catch {
if Task.isCancelled || error is CancellationError { throw CancellationError() }
if rules.zoko { throw (error as? ZokoError) ?? ZokoError.transport }
throw MiruroPlaybackError()
}
}
@@ -233,6 +234,7 @@ final class MiruroTransport: @unchecked Sendable {
private func register(_ url: URL, kind: Kind, depth: Int) throws -> URL {
guard depth <= 12, ["http", "https"].contains(url.scheme), url.user == nil, url.password == nil,
(!requirements.zoko || ZokoResolver.validResource(url, playlist: kind == .playlist)),
url.fragment == nil, url.absoluteString.utf8.count <= 32768, let port = listener.port else { throw MiruroPlaybackError() }
return try lock.withLock {
let key = kind.rawValue + ":" + url.absoluteString
@@ -338,6 +340,9 @@ final class MiruroTransport: @unchecked Sendable {
func fetch(_ url: URL) async throws -> (Data, HTTPURLResponse) {
// Same-origin fixture/source access remains allowed. A manifest or
// decrypted token cannot turn the relay into a local-network proxy.
if requirements.zoko {
guard ZokoResolver.validResource(url, playlist: resource.kind == .playlist) else { throw ZokoError.malformed }
}
if !Self.sameOrigin(url, sourceURL) { try Self.requirePublicDestination(url) }
let target: URL
let requestHeaders: String
@@ -349,7 +354,8 @@ final class MiruroTransport: @unchecked Sendable {
} else {
target = url
// Only the embed's fixed public Referer has cross-origin scope.
requestHeaders = embed?.headers ?? (Self.sameOrigin(url, sourceURL) ? headers : "")
requestHeaders = requirements.zoko ? ZokoResolver.referer :
(embed?.headers ?? (Self.sameOrigin(url, sourceURL) ? headers : ""))
}
return try await MiruroMediaHTTP.read(target, headers: requestHeaders,
limit: resource.kind == .playlist ? 2 * 1024 * 1024 : 64 * 1024 * 1024,
+9 -3
View File
@@ -91,17 +91,23 @@ struct ResolvedSource: Sendable {
episode > 0 && episode <= Int(Int32.max)
}
static func validAniListIdentity(mediaID: String, episode: Int) -> Bool {
guard let id = Int32(mediaID), id > 0, String(id) == mediaID else { return false }
return episode > 0 && episode <= Int(Int32.max)
}
static func validated(_ document: Document) throws -> ResolvedSource {
let id = document.identity
guard document.version == 1, id.provider == "miruro",
validIdentity(mediaID: id.media_id, episode: id.episode),
guard document.version == 1,
(id.provider == "miruro" && validIdentity(mediaID: id.media_id, episode: id.episode)) ||
(id.provider == "anilist" && validAniListIdentity(mediaID: id.media_id, episode: id.episode)),
validURL(document.url), validHeaders(document.headers),
validText(document.title ?? "", limit: 4096),
validSubtitles(document.subtitles) else { throw InvalidSource() }
let headers = document.headers.sorted { $0.key < $1.key }
.map { "\($0.key): \($0.value)\r\n" }.joined()
return ResolvedSource(url: document.url, headers: headers,
identity: "miruro:\(id.media_id):\(id.episode)", title: document.title ?? "Miruro episode \(id.episode)",
identity: "\(id.provider):\(id.media_id):\(id.episode)", title: document.title ?? "\(id.provider == "anilist" ? "AniList \(id.media_id)" : "Miruro") episode \(id.episode)",
subtitles: document.subtitles)
}
+4
View File
@@ -69,6 +69,10 @@ struct StreamQueue: Sendable {
url = URL(string: item.url)!
identity = "miruro:\(mediaID):\(episode)"
label = "\(url.lastPathComponent.replacingOccurrences(of: "-", with: " ")) · Episode \(episode)"
case .anilist(let mediaID, let episode):
url = URL(string: item.url)!
identity = "anilist:\(mediaID):\(episode)"
label = "AniList \(mediaID) · Episode \(episode) (sub)"
}
guard identities.insert(identity).inserted,
item.progress_file.hasPrefix("/"), !item.progress_file.contains("\0") else { throw InvalidQueue() }
+14
View File
@@ -8,10 +8,23 @@ private struct StreamResolutionError: LocalizedError {
enum StreamURL: Sendable {
case youtube(URL)
case miruro(mediaID: String, episode: Int)
case anilist(mediaID: String, episode: Int)
static func parse(_ text: String) throws -> StreamURL {
let text = text.trimmingCharacters(in: .whitespacesAndNewlines)
let url = try YouTubeResolver.validateURL(text)
if url.host?.lowercased() == "zokoanime.video" {
let invalid = StreamResolutionError(message: "Enter https://zokoanime.video/stream/ani/<AniList ID>/<episode>/sub with canonical positive numbers.")
guard let c = URLComponents(string: text), c.scheme == "https", c.user == nil, c.password == nil,
c.port == nil || c.port == 443, c.query == nil, c.fragment == nil,
c.percentEncodedPath == c.path,
!text.contains(where: { $0.isWhitespace || $0 == "\\" }) else { throw invalid }
let p = c.path.split(separator: "/", omittingEmptySubsequences: false)
guard p.count == 6, p[0].isEmpty, p[1] == "stream", p[2] == "ani", p[5] == "sub",
let episode = Int(p[4]), String(episode) == p[4],
ResolvedSource.validAniListIdentity(mediaID: String(p[3]), episode: episode) else { throw invalid }
return .anilist(mediaID: String(p[3]), episode: episode)
}
if url.host?.lowercased() != "barelystarted.miruro.tv" { return .youtube(url) }
let invalid = StreamResolutionError(message: "Enter a Miruro episode URL from barelystarted.miruro.tv with an explicit positive episode number.")
guard !text.unicodeScalars.contains(where: {
@@ -62,6 +75,7 @@ struct MiruroPlaybackRequirements: Sendable {
var segmentOrigin: String? = nil
var playlistQuery: [String: String] = [:]
var embed = false
var zoko = false
}
struct MiruroSelection: Sendable {
+48 -1
View File
@@ -241,6 +241,7 @@ final class PlayerModel: ObservableObject {
private let youtubeTitleResolver: @MainActor (URL) async throws -> String?
private let sponsorResolver: @MainActor (String) async throws -> [SponsorSegment]
private let miruroResolver: @MainActor (String, Int) async throws -> MiruroSources
private let zokoResolver: @MainActor (String, Int) async throws -> ResolvedSource
private let miruroMetadataResolver: @MainActor (String, Int) async throws -> MiruroMetadata
private var reportingErrorMessage: String {
reportingErrorBuffer.withUnsafeBufferPointer { String(cString: $0.baseAddress!) }
@@ -252,6 +253,7 @@ final class PlayerModel: ObservableObject {
youtubeTitleResolver: @escaping @MainActor (URL) async throws -> String? = { try await YouTubeResolver.resolveTitle($0) },
sponsorResolver: @escaping @MainActor (String) async throws -> [SponsorSegment] = { try await SponsorBlockResolver.resolve(videoID: $0) },
miruroResolver: @escaping @MainActor (String, Int) async throws -> MiruroSources = { try await MiruroResolver.resolve(mediaID: $0, episode: $1) },
zokoResolver: @escaping @MainActor (String, Int) async throws -> ResolvedSource = { try await ZokoResolver.resolve(mediaID: $0, episode: $1) },
miruroMetadataResolver: @escaping @MainActor (String, Int) async throws -> MiruroMetadata = { try await MiruroMetadataResolver.resolve(mediaID: $0, episode: $1) },
qualityClock: @escaping () -> Double = { ProcessInfo.processInfo.systemUptime },
skipClock: @escaping () -> Double = { ProcessInfo.processInfo.systemUptime }) {
@@ -261,6 +263,7 @@ final class PlayerModel: ObservableObject {
self.youtubeTitleResolver = youtubeTitleResolver
self.sponsorResolver = sponsorResolver
self.miruroResolver = miruroResolver
self.zokoResolver = zokoResolver
self.miruroMetadataResolver = miruroMetadataResolver
keyBindings = configuration.keys
compactTitleProgress = configuration.compactTitleProgress
@@ -441,7 +444,7 @@ final class PlayerModel: ObservableObject {
let url: URL
switch stream {
case .youtube(let canonical): url = canonical
case .miruro: url = URL(string: text.trimmingCharacters(in: .whitespacesAndNewlines))!
case .miruro, .anilist: url = URL(string: text.trimmingCharacters(in: .whitespacesAndNewlines))!
}
queue.replaceStreams([(url: url, label: url.absoluteString)])
resolveStream(stream, change: .open)
@@ -461,6 +464,12 @@ final class PlayerModel: ObservableObject {
url = canonical
title = video.title
source = .youtube(video, height: nil)
case .anilist(let mediaID, let episode):
let resolved = try await MiruroTransport.prepare(zokoResolver(mediaID, episode))
guard resolved.identity == "anilist:\(mediaID):\(episode)" else { throw CocoaError(.fileReadCorruptFile) }
url = URL(string: text.trimmingCharacters(in: .whitespacesAndNewlines))!
title = resolved.title
source = .resolved(resolved)
case .miruro(let mediaID, let episode):
let providers = try await miruroResolver(mediaID, episode)
let selected = try providers.select(videoProvider: miruroVideoProvider,
@@ -522,6 +531,7 @@ final class PlayerModel: ObservableObject {
switch stream {
case .youtube(let url): return url.absoluteString
case .miruro(let id, let episode): return "miruro:\(id):\(episode)"
case .anilist(let id, let episode): return "anilist:\(id):\(episode)"
}
}
return item.url.absoluteString
@@ -562,6 +572,7 @@ final class PlayerModel: ObservableObject {
switch stream {
case .youtube(let url): resolveYouTube(url, height: nil, change: change)
case .miruro(let mediaID, let episode): resolveMiruro(mediaID: mediaID, episode: episode, change: change)
case .anilist(let mediaID, let episode): resolveZoko(mediaID: mediaID, episode: episode, change: change)
}
}
@@ -682,6 +693,40 @@ final class PlayerModel: ObservableObject {
}
}
private func resolveZoko(mediaID: String, episode: Int, change: MediaChange) {
guard let handle else { return }
if castDevice != nil { stopCasting(resume: false) }
cancelStreamResolution()
automaticQuality = AutomaticQuality()
guard player_pause(handle) else {
commandError = "Could not pause the current media to open the stream. Please try again."
revealControls()
return
}
objectWillChange.send()
let request = transition.beginResolution(change)
systemPlayback?.update()
commandError = nil
revealControls()
let resolver = zokoResolver
let task = Task { [weak self] in
do {
let source = try await MiruroTransport.prepare(resolver(mediaID, episode))
guard source.identity == "anilist:\(mediaID):\(episode)" else { throw ZokoError.malformed }
guard !Task.isCancelled, let self else { return }
self.objectWillChange.send()
guard let change = self.transition.finishResolution(request) else { return }
self.openMedia(.resolved(source), change: change)
} catch {
guard !Task.isCancelled, let self else { return }
self.objectWillChange.send()
guard self.transition.finishResolution(request) != nil else { return }
self.failStreamChange(change, error: error)
}
}
transition.attachResolution(task, request: request)
}
private func resolveMiruro(mediaID: String, episode: Int, change: MediaChange = .open) {
guard let handle else { return }
if castDevice != nil { stopCasting(resume: false) }
@@ -826,6 +871,8 @@ final class PlayerModel: ObservableObject {
title = try await youtube(url)
case .miruro(let id, let episode):
title = try await miruro(id, episode).title
case .anilist(let id, let episode):
title = try await ZokoResolver.title(mediaID: id, episode: episode)
}
} catch {
// Optional enrichment must not surface as a playback error or retry every poll.
+139
View File
@@ -0,0 +1,139 @@
import Foundation
enum ZokoError: String, LocalizedError {
case unavailable, transport, protection, malformed
var errorDescription: String? {
switch self {
case .unavailable: "ZokoAnime has no sub episode available for this catalog number. Catalog numbering does not confirm availability."
case .transport: "ZokoAnime HTTPS request failed or timed out. No retry was attempted."
case .protection: "ZokoAnime returned an access/protection page or refused the request."
case .malformed: "ZokoAnime returned unsupported or malformed source data. Private source details are redacted."
}
}
}
// Narrow policy for the demonstrated deployment, not a general cross-origin grant.
enum ZokoResolver {
static let bodyLimit = 2 * 1024 * 1024
static let referer = "Referer: https://zokoanime.video/\r\n"
static func validResource(_ url: URL, playlist: Bool = false) -> Bool {
guard ResolvedSource.validURL(url.absoluteString), url.scheme == "https",
url.port == nil || url.port == 443,
let host = url.host, host == "hls.dramahot.top" || (!playlist && host == "hls1.drama1.cfd") else { return false }
return true
}
private final class NoRedirect: NSObject, URLSessionTaskDelegate, Sendable {
func urlSession(_ session: URLSession, task: URLSessionTask,
willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest,
completionHandler: @escaping (URLRequest?) -> Void) { completionHandler(nil) }
}
private static func fetch(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
let configuration = URLSessionConfiguration.ephemeral
configuration.httpShouldSetCookies = false
configuration.httpCookieStorage = nil
configuration.urlCredentialStorage = nil
configuration.urlCache = nil
configuration.timeoutIntervalForRequest = 20
configuration.timeoutIntervalForResource = 25
let session = URLSession(configuration: configuration, delegate: NoRedirect(), delegateQueue: nil)
defer { session.invalidateAndCancel() }
do {
let (bytes, response) = try await session.bytes(for: request)
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 }
var data = Data()
for try await byte in bytes {
try Task.checkCancellation()
guard data.count < bodyLimit else { throw ZokoError.malformed }
data.append(byte)
}
return (data, response)
} catch {
if Task.isCancelled || error is CancellationError || (error as? URLError)?.code == .cancelled { throw CancellationError() }
if let error = error as? ZokoError { throw error }
throw ZokoError.transport
}
}
static func resolve(mediaID: String, episode: Int) 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)
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)
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,
title: title, subtitles: source.subtitles, requirements: source.requirements)
}
static func parse(page: String, mediaID: String, episode: Int) throws -> ResolvedSource {
guard page.utf8.count <= bodyLimit, ResolvedSource.validAniListIdentity(mediaID: mediaID, episode: episode) else { throw ZokoError.malformed }
let pattern = #"window\.__P\s*=\s*[\"']([A-Za-z0-9+/=]+)[\"']\s*;"#
let regex = try NSRegularExpression(pattern: pattern)
let matches = regex.matches(in: page, range: NSRange(page.startIndex..., in: page))
guard matches.count == 1, let match = matches.first, let range = Range(match.range(at: 1), in: page) else {
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") { throw ZokoError.protection }
throw ZokoError.malformed
}
guard let encoded = Data(base64Encoded: String(page[range])), !encoded.isEmpty else { throw ZokoError.malformed }
let key = Array("otaku-embed-v1".utf8)
var decoded = Array(encoded)
for i in decoded.indices { decoded[i] ^= key[i % key.count] }
struct Config: Decodable {
struct Subtitle: Decodable { let src: String; let lang: String; let label: String; let `default`: Bool? }
let src: String
let subtitles: [Subtitle]
}
let config: Config
do { config = try JSONDecoder().decode(Config.self, from: Data(decoded)) }
catch { throw ZokoError.malformed }
guard let url = URL(string: config.src), validResource(url, playlist: true), url.path.hasSuffix(".m3u8"), config.subtitles.count <= 32 else { throw ZokoError.malformed }
let subtitles = try config.subtitles.map { row -> ResolvedSubtitle in
guard let url = URL(string: row.src), validResource(url, playlist: true), url.path.hasSuffix(".vtt") else { throw ZokoError.malformed }
return ResolvedSubtitle(url: row.src, format: "vtt", language: row.lang, label: row.label,
isDefault: row.default ?? false, headers: ["Referer": "https://zokoanime.video/"])
}
guard ResolvedSource.validSubtitles(subtitles) else { throw ZokoError.malformed }
return ResolvedSource(url: config.src, headers: referer, identity: "anilist:\(mediaID):\(episode)",
title: "AniList \(mediaID) · Episode \(episode) (sub)", subtitles: subtitles,
requirements: .init(zoko: true))
}
static func title(mediaID: String, episode: Int) async throws -> String {
guard ResolvedSource.validAniListIdentity(mediaID: mediaID, episode: episode) else { throw ZokoError.malformed }
var request = URLRequest(url: URL(string: "https://graphql.anilist.co")!)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONSerialization.data(withJSONObject: ["query": "query($id:Int!){Media(id:$id,type:ANIME){id title{english romaji native}}}", "variables": ["id": Int(mediaID)!]])
let (data, response) = try await fetch(request)
guard response.mimeType == "application/json" else { throw ZokoError.malformed }
struct Metadata: Decodable {
struct Body: Decodable {
struct Media: Decodable {
struct Title: Decodable { let english: String?; let romaji: String?; let native: String? }
let id: Int; let title: Title
}
let media: Media
enum CodingKeys: String, CodingKey { case media = "Media" }
}
let data: Body
}
let metadata = try JSONDecoder().decode(Metadata.self, from: data).data.media
guard String(metadata.id) == mediaID,
let name = [metadata.title.english, metadata.title.romaji, metadata.title.native].compactMap({ $0 }).first(where: { !$0.isEmpty }),
ResolvedSource.validText(name, limit: 2048) else { throw ZokoError.malformed }
return "\(name) · Episode \(episode) (sub)"
}
}