fix: resolve AniList playback using exact MAL mappings
This commit is contained in:
@@ -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)"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user