fix: resolve AniList playback using exact MAL mappings

This commit is contained in:
2026-10-25 19:24:21 +01:00
parent b95f7d251e
commit 7052fe29af
5 changed files with 116 additions and 40 deletions
+62 -26
View File
@@ -1,13 +1,14 @@
import Foundation
enum ZokoError: String, LocalizedError {
case unavailable, transport, protection, malformed
case unavailable, transport, protection, malformed, mapping
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."
case .mapping: "AniList has no usable MAL mapping for this title. Source availability could not be checked."
}
}
}
@@ -62,20 +63,26 @@ enum ZokoResolver {
}
}
static func resolve(mediaID: String, episode: Int, enrichTitle: Bool = true) async throws -> ResolvedSource {
static func resolve(
mediaID: String, episode: Int, enrichTitle: Bool = true,
fetchResponse: @Sendable (URLRequest) async throws -> (Data, HTTPURLResponse) = {
try await fetch($0, allowMissingPage: $0.url?.host == "zokoanime.video")
}
) 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")!)
let metadata = try await catalog(mediaID: mediaID, includeTitle: enrichTitle, fetchResponse: fetchResponse)
guard let malID = metadata.idMal else { throw ZokoError.mapping }
var request = URLRequest(url: URL(string: "https://zokoanime.video/stream/mal/\(malID)/\(episode)/sub")!)
request.timeoutInterval = 20
request.setValue("text/html", forHTTPHeaderField: "Accept")
let (data, response) = try await fetch(request, allowMissingPage: true)
let (data, response) = try await fetchResponse(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)
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
guard enrichTitle, let name = metadata.name else { return source }
try Task.checkCancellation()
return ResolvedSource(url: source.url, headers: source.headers, identity: source.identity,
title: title, subtitles: source.subtitles, requirements: source.requirements)
title: "\(name) · Episode \(episode) (sub)", subtitles: source.subtitles, requirements: source.requirements)
}
static func parse(page: String, mediaID: String, episode: Int) throws -> ResolvedSource {
@@ -117,29 +124,58 @@ enum ZokoResolver {
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 Catalog: Decodable {
struct Title: Decodable { let english: String?; let romaji: String?; let native: String? }
let id: Int
let idMal: Int?
let title: Title?
var name: String? {
guard let title else { return nil }
func usable(_ name: String?) -> String? {
guard let name, !name.isEmpty, ResolvedSource.validText(name, limit: 2048) else { return nil }
return name
}
return usable(title.english) ?? usable(title.romaji) ?? usable(title.native)
}
}
static func decodeCatalog(_ data: Data, mediaID: String) throws -> Catalog {
struct Response: 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
let media: Catalog?
enum CodingKeys: String, CodingKey { case media = "Media" }
}
let data: Body
struct Problem: Decodable {}
let data: Body?
let errors: [Problem]?
}
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 }
let response: Response
do { response = try JSONDecoder().decode(Response.self, from: data) }
catch { throw ZokoError.malformed }
guard response.errors?.isEmpty != false, let metadata = response.data?.media,
String(metadata.id) == mediaID,
metadata.idMal.map({ $0 > 0 && $0 <= Int(Int32.max) }) ?? true else { throw ZokoError.malformed }
return metadata
}
private static func catalog(
mediaID: String, includeTitle: Bool,
fetchResponse: @Sendable (URLRequest) async throws -> (Data, HTTPURLResponse)
) async throws -> Catalog {
var request = URLRequest(url: URL(string: "https://graphql.anilist.co/")!)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let fields = includeTitle ? "id idMal title{english romaji native}" : "id idMal"
request.httpBody = try JSONSerialization.data(withJSONObject: ["query": "query($id:Int!){Media(id:$id,type:ANIME){\(fields)}}", "variables": ["id": Int(mediaID)!]])
let (data, response) = try await fetchResponse(request)
guard response.statusCode == 200, response.mimeType == "application/json" else { throw ZokoError.malformed }
return try decodeCatalog(data, mediaID: mediaID)
}
static func title(mediaID: String, episode: Int) async throws -> String {
guard ResolvedSource.validAniListIdentity(mediaID: mediaID, episode: episode) else { throw ZokoError.malformed }
let metadata = try await catalog(mediaID: mediaID, includeTitle: true, fetchResponse: { try await fetch($0) })
guard let name = metadata.name else { throw ZokoError.malformed }
return "\(name) · Episode \(episode) (sub)"
}
}