feat: resolve AniList episodes through ZokoAnime
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user