fix: resolve AniList playback using exact MAL mappings
This commit is contained in:
@@ -62,15 +62,19 @@ def main():
|
|||||||
require(result.returncode != 0 and not result.stdout, "invalid syntax/unsupported URL must fail without JSON or launch")
|
require(result.returncode != 0 and not result.stdout, "invalid syntax/unsupported URL must fail without JSON or launch")
|
||||||
require(not (root / secret).exists(), "invalid source check must not create progress file")
|
require(not (root / secret).exists(), "invalid source check must not create progress file")
|
||||||
if live:
|
if live:
|
||||||
for episode, expected in [(1, "available"), (27, "unavailable")]:
|
for media_id, episode, expected in [
|
||||||
result = invoke(["--check-source", f"https://zokoanime.video/stream/ani/1/{episode}/sub"])
|
(1, 1, "available"), (1, 27, "unavailable"),
|
||||||
|
(97940, 1, "available"), (97940, 170, "available"),
|
||||||
|
(195604, 1, "available"),
|
||||||
|
]:
|
||||||
|
result = invoke(["--check-source", f"https://zokoanime.video/stream/ani/{media_id}/{episode}/sub"])
|
||||||
require(result.returncode == 0, "completed provider checks exit zero")
|
require(result.returncode == 0, "completed provider checks exit zero")
|
||||||
require(result.stdout.endswith(b"\n") and result.stdout.count(b"\n") == 1, "exactly one JSON line")
|
require(result.stdout.endswith(b"\n") and result.stdout.count(b"\n") == 1, "exactly one JSON line")
|
||||||
row = json.loads(result.stdout)
|
row = json.loads(result.stdout)
|
||||||
require(row == {"version": 1, "provider": "zokoanime", "track": "sub", "status": expected},
|
require(row == {"version": 1, "provider": "zokoanime", "track": "sub", "status": expected},
|
||||||
f"live episode {episode} expected {expected}; result is redacted")
|
f"live AniList {media_id} episode {episode} expected {expected}; result is redacted")
|
||||||
require(not result.stderr, "successful source checks have no error logs")
|
require(not result.stderr, "successful source checks have no error logs")
|
||||||
print("PASS: actual CLI resolves episode 1/root manifest and explicit missing episode 27 headlessly, without state writes")
|
print("PASS: actual CLI resolves exact MAL mappings for Cowboy Bebop and both Black Clover entries; explicit missing remains unavailable; no state writes")
|
||||||
else:
|
else:
|
||||||
print("PASS: actual CLI rejects hostile URLs/flags privately, without configuration reads, state writes or bundle launch")
|
print("PASS: actual CLI rejects hostile URLs/flags privately, without configuration reads, state writes or bundle launch")
|
||||||
|
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ struct SourceCheckChecks {
|
|||||||
throw Failure()
|
throw Failure()
|
||||||
}
|
}
|
||||||
// Exercise error classification, not fake provider availability. Live proof uses the actual CLI below.
|
// Exercise error classification, not fake provider availability. Live proof uses the actual CLI below.
|
||||||
for category in [ZokoError.unavailable, .transport, .protection, .malformed] {
|
for category in [ZokoError.unavailable, .transport, .protection, .malformed, .mapping] {
|
||||||
let result = await SourceCheck.run(mediaID: "1", episode: 1, resolve: { _, _ in throw category },
|
let result = await SourceCheck.run(mediaID: "1", episode: 1, resolve: { _, _ in throw category },
|
||||||
validate: { _ in throw Failure() })
|
validate: { _ in throw Failure() })
|
||||||
try require(result == (category == .unavailable ? .unavailable : .failed), "only explicit missing episode is unavailable")
|
try require(result == (category == .unavailable ? .unavailable : .failed), "only explicit missing episode is unavailable")
|
||||||
|
|||||||
+41
-5
@@ -36,6 +36,41 @@ struct ZokoCheck {
|
|||||||
var config: [String: Any] = ["src": "https://hls.dramahot.top/master.m3u8?token=fixture",
|
var config: [String: Any] = ["src": "https://hls.dramahot.top/master.m3u8?token=fixture",
|
||||||
"subtitles": [["src": "https://hls.dramahot.top/en.vtt", "lang": "en", "label": "English", "default": true]]]
|
"subtitles": [["src": "https://hls.dramahot.top/en.vtt", "lang": "en", "label": "English", "default": true]]]
|
||||||
let source = try ZokoResolver.parse(page: page(config), mediaID: "1", episode: 1)
|
let source = try ZokoResolver.parse(page: page(config), mediaID: "1", episode: 1)
|
||||||
|
let mapped = try await ZokoResolver.resolve(mediaID: "97940", episode: 1, fetchResponse: { request in
|
||||||
|
let data: Data
|
||||||
|
let mime: String
|
||||||
|
if request.url?.absoluteString == "https://graphql.anilist.co/" {
|
||||||
|
let body = try JSONSerialization.jsonObject(with: request.httpBody!) as! [String: Any]
|
||||||
|
try require((body["variables"] as? [String: Int])?["id"] == 97940, "mapping uses the original AniList ID")
|
||||||
|
data = Data(#"{"data":{"Media":{"id":97940,"idMal":34572,"title":{"english":"Black Clover"}}}}"#.utf8)
|
||||||
|
mime = "application/json"
|
||||||
|
} else {
|
||||||
|
try require(request.url?.absoluteString == "https://zokoanime.video/stream/mal/34572/1/sub", "provider lookup uses the exact mapped MAL ID, not AniList ID")
|
||||||
|
data = Data(try page(["src": "https://hls.dramahot.top/master.m3u8", "subtitles": []]).utf8)
|
||||||
|
mime = "text/html"
|
||||||
|
}
|
||||||
|
return (data, HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: ["Content-Type": mime])!)
|
||||||
|
})
|
||||||
|
try require(mapped.identity == "anilist:97940:1" && mapped.title == "Black Clover · Episode 1 (sub)",
|
||||||
|
"mapped playback preserves AniList identity and catalog title")
|
||||||
|
for bad in [
|
||||||
|
#"{"data":{"Media":{"id":34572,"idMal":97940}}}"#,
|
||||||
|
#"{"data":{"Media":{"id":97940,"idMal":0}}}"#,
|
||||||
|
#"{"data":{"Media":{"id":97940,"idMal":-1}}}"#,
|
||||||
|
#"{"data":{"Media":{"id":97940,"idMal":2147483648}}}"#,
|
||||||
|
#"{"data":{"Media":{"id":97940,"idMal":true}}}"#,
|
||||||
|
#"{"data":{"Media":{"id":97940,"idMal":34572}},"errors":[{"message":"partial failure"}]}"#
|
||||||
|
] {
|
||||||
|
try expect(.malformed) { _ = try ZokoResolver.decodeCatalog(Data(bad.utf8), mediaID: "97940") }
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
_ = try await ZokoResolver.resolve(mediaID: "97940", episode: 1, enrichTitle: false, fetchResponse: { request in
|
||||||
|
try require(request.url?.host == "graphql.anilist.co", "missing mapping must not guess a provider ID")
|
||||||
|
return (Data(#"{"data":{"Media":{"id":97940,"idMal":null}}}"#.utf8),
|
||||||
|
HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: ["Content-Type": "application/json"])!)
|
||||||
|
})
|
||||||
|
throw Failure(message: "missing mapping accepted")
|
||||||
|
} catch ZokoError.mapping {}
|
||||||
try require(source.identity == "anilist:1:1" && source.requirements.zoko, "native scoped transport selected")
|
try require(source.identity == "anilist:1:1" && source.requirements.zoko, "native scoped transport selected")
|
||||||
try require(source.title == "AniList 1 · Episode 1 (sub)" && source.subtitles.count == 1 && source.subtitles[0].isDefault,
|
try require(source.title == "AniList 1 · Episode 1 (sub)" && source.subtitles.count == 1 && source.subtitles[0].isDefault,
|
||||||
"honest fallback title and VTT metadata")
|
"honest fallback title and VTT metadata")
|
||||||
@@ -97,10 +132,10 @@ struct ZokoCheck {
|
|||||||
throw Failure(message: "Native player acknowledgement timed out")
|
throw Failure(message: "Native player acknowledgement timed out")
|
||||||
}
|
}
|
||||||
|
|
||||||
static func live() async throws {
|
static func live(mediaID: String = "1", episode: Int = 1) async throws {
|
||||||
let source = try await MiruroTransport.prepare(ZokoResolver.resolve(mediaID: "1", episode: 1))
|
let source = try await MiruroTransport.prepare(ZokoResolver.resolve(mediaID: mediaID, episode: episode))
|
||||||
defer { _fixLifetime(source) }
|
defer { _fixLifetime(source) }
|
||||||
try require(source.identity == "anilist:1:1" && source.headers.isEmpty, "native relay preserves identity without upstream headers")
|
try require(source.identity == "anilist:\(mediaID):\(episode)" && source.headers.isEmpty, "native relay preserves identity without upstream headers")
|
||||||
guard let track = source.subtitles.first(where: \.isDefault) else { throw Failure(message: "Live source has no default subtitle") }
|
guard let track = source.subtitles.first(where: \.isDefault) else { throw Failure(message: "Live source has no default subtitle") }
|
||||||
let document = try await WebVTTDocument.load(url: track.url, headers: track.headers)
|
let document = try await WebVTTDocument.load(url: track.url, headers: track.headers)
|
||||||
guard let cue = document.cues.first(where: { !$0.text.isEmpty && $0.end > $0.start }) else { throw Failure(message: "Live VTT contains no active cues") }
|
guard let cue = document.cues.first(where: { !$0.text.isEmpty && $0.end > $0.start }) else { throw Failure(message: "Live VTT contains no active cues") }
|
||||||
@@ -152,16 +187,17 @@ struct ZokoCheck {
|
|||||||
try require(!rows.isEmpty, "Native progress records emitted")
|
try require(!rows.isEmpty, "Native progress records emitted")
|
||||||
for row in rows {
|
for row in rows {
|
||||||
let identity = row["media_identity"] as? [String: Any]
|
let identity = row["media_identity"] as? [String: Any]
|
||||||
try require(row["version"] as? Int == 2 && row["media_url"] == nil && identity?["provider"] as? String == "anilist" && identity?["media_id"] as? String == "1" && identity?["episode"] as? Int == 1,
|
try require(row["version"] as? Int == 2 && row["media_url"] == nil && identity?["provider"] as? String == "anilist" && identity?["media_id"] as? String == mediaID && identity?["episode"] as? Int == episode,
|
||||||
"Native progress uses AniList v2 identity with no media URL")
|
"Native progress uses AniList v2 identity with no media URL")
|
||||||
}
|
}
|
||||||
try require(rows.contains { ($0["position_seconds"] as? Double ?? 0) >= 600 }, "Acknowledged seek/resume position reported")
|
try require(rows.contains { ($0["position_seconds"] as? Double ?? 0) >= 600 }, "Acknowledged seek/resume position reported")
|
||||||
print("PASS: fresh AniList 1 episode 1 resolves through scoped relay; actual native video decode/frame step, tracks, 600s seek/decode, VTT text, restored position and v2 progress (no GUI/audio playback)")
|
print("PASS: fresh AniList \(mediaID) episode \(episode) mapped via MAL; actual native decode/frame step, 600s seek/decode, VTT text, restored position and AniList v2 progress (no GUI/audio playback)")
|
||||||
}
|
}
|
||||||
static func main() async {
|
static func main() async {
|
||||||
do {
|
do {
|
||||||
try await checks()
|
try await checks()
|
||||||
if CommandLine.arguments.contains("--live") { try await live() }
|
if CommandLine.arguments.contains("--live") { try await live() }
|
||||||
|
if CommandLine.arguments.contains("--live-black-clover") { try await live(mediaID: "97940") }
|
||||||
} catch {
|
} catch {
|
||||||
FileHandle.standardError.write(Data("FAIL: Zoko check: \(error.localizedDescription)\n".utf8))
|
FileHandle.standardError.write(Data("FAIL: Zoko check: \(error.localizedDescription)\n".utf8))
|
||||||
exit(1)
|
exit(1)
|
||||||
|
|||||||
@@ -77,9 +77,9 @@ For native episode-URL playback, ordered stream queues, external resolved source
|
|||||||
terebi --url 'https://zokoanime.video/stream/ani/1/1/sub'
|
terebi --url 'https://zokoanime.video/stream/ani/1/1/sub'
|
||||||
```
|
```
|
||||||
|
|
||||||
The exact `ani` route is supported, not the site's broken documented `anilist` route. IDs and episode numbers must be canonical positive decimal integers within `Int32.max`. Only `sub` is admitted; there is no silent dub fallback. Catalog episode numbers are not a provider inventory: Terebi resolves the selected episode on demand and distinguishes an unavailable episode from network, access/protection, and malformed configuration failures.
|
The canonical handoff still uses `/stream/ani/<AniList ID>/<episode>/sub`, not the site's broken documented `anilist` route. IDs and episode numbers must be canonical positive decimal integers within `Int32.max`. Before fetching a provider source, Terebi queries AniList's exact `idMal` and requests ZokoAnime's `/stream/mal/<MAL ID>/<episode>/sub` route. It never substitutes an AniList number into the MAL namespace or guesses a match by title. A missing mapping is a lookup failure, not evidence of a missing episode. Only `sub` is admitted; there is no silent dub fallback.
|
||||||
|
|
||||||
Resolution uses bounded ordinary HTTPS with certificate verification, no cookies/credentials, no redirect following, and no browser or advertising/challenge script execution. Only the static `window.__P` Base64/XOR configuration is decoded with the first-party `otaku-embed-v1` codec. Optional bounded AniList metadata supplies the title; failure uses `AniList <ID> · Episode <number> (sub)` rather than guessing a show title. Poster, download, chapter, view/telemetry and player-script fields are ignored and never fetched.
|
Resolution uses bounded ordinary HTTPS with certificate verification, no cookies/credentials, no redirect following, and no browser or advertising/challenge script execution. AniList's returned catalog ID must match the requested identity; invalid mappings and GraphQL errors are rejected. The same metadata response can supply the playback title, with `AniList <ID> · Episode <number> (sub)` used when the title is absent. Only the provider's static `window.__P` Base64/XOR configuration is decoded with the first-party `otaku-embed-v1` codec. Poster, download, chapter, view/telemetry and player-script fields are ignored and never fetched.
|
||||||
|
|
||||||
The existing process-local loopback HLS relay normalizes playlists for the native decoder. This provider alone permits HTTPS playlists/VTT on `hls.dramahot.top` and HTTPS media resources on that host or the demonstrated `hls1.drama1.cfd` segment origin (default port only). Every manifest URI is checked before registration and fetching, upstream redirects are refused, and only the fixed public `Referer: https://zokoanime.video/` is supplied to those admitted origins. Upstream URLs/tokens remain in memory and are not progress identities. Native global same-origin policy and TLS verification are unchanged; new CDN hosts fail closed until explicitly reviewed. Subtitle redirects retain the existing same-origin-only restriction.
|
The existing process-local loopback HLS relay normalizes playlists for the native decoder. This provider alone permits HTTPS playlists/VTT on `hls.dramahot.top` and HTTPS media resources on that host or the demonstrated `hls1.drama1.cfd` segment origin (default port only). Every manifest URI is checked before registration and fetching, upstream redirects are refused, and only the fixed public `Referer: https://zokoanime.video/` is supplied to those admitted origins. Upstream URLs/tokens remain in memory and are not progress identities. Native global same-origin policy and TLS verification are unchanged; new CDN hosts fail closed until explicitly reviewed. Subtitle redirects retain the existing same-origin-only restriction.
|
||||||
|
|
||||||
@@ -92,7 +92,7 @@ terebi --check-source 'https://zokoanime.video/stream/ani/1/1/sub'
|
|||||||
# {"version":1,"provider":"zokoanime","track":"sub","status":"available"}
|
# {"version":1,"provider":"zokoanime","track":"sub","status":"available"}
|
||||||
```
|
```
|
||||||
|
|
||||||
It fetches a fresh embed configuration and the bounded root HLS manifest, using the same resolver, origin policy, HTTP transport and manifest validation as playback. It skips AniList title enrichment and does not start the app, native decoder, loopback relay or audio; read configuration; download subtitles, nested playlists or segments; or write progress/resume/configuration. The entire check has a 15-second deadline and supports cancellation. No retries, source URLs, tokens or raw resource errors are emitted or persisted.
|
It fetches the exact AniList-to-MAL mapping, a fresh MAL-route embed configuration and the bounded root HLS manifest, using the same resolver, origin policy, HTTP transport and manifest validation as playback. It omits title fields and does not start the app, native decoder, loopback relay or audio; read configuration; download subtitles, nested playlists or segments; or write progress/resume/configuration. The entire check has a 15-second deadline and supports cancellation. No retries, source URLs, tokens or raw resource errors are emitted or persisted.
|
||||||
|
|
||||||
Exactly one bounded version-1 JSON line is written to stdout. `available` means fresh source configuration plus an accessible valid root manifest, **not** guaranteed complete playback. `unavailable` means only the provider's explicit missing-episode page. Network, access/protection, malformed configuration, manifest, cancellation and deadline failures return `failed`, never `unavailable`. All three completed statuses exit zero; bad syntax, unsupported URLs and combinations with other flags exit nonzero without JSON. Only the canonical AniList `sub` watch URL is supported, not arbitrary sources or legacy Miruro URLs.
|
Exactly one bounded version-1 JSON line is written to stdout. `available` means fresh source configuration plus an accessible valid root manifest, **not** guaranteed complete playback. `unavailable` means only the provider's explicit missing-episode page. Network, access/protection, malformed configuration, manifest, cancellation and deadline failures return `failed`, never `unavailable`. All three completed statuses exit zero; bad syntax, unsupported URLs and combinations with other flags exit nonzero without JSON. Only the canonical AniList `sub` watch URL is supported, not arbitrary sources or legacy Miruro URLs.
|
||||||
|
|
||||||
@@ -161,7 +161,7 @@ For the focused native compact-queue smoke (transparent, noninteractive app wind
|
|||||||
`make check-sponsorblock` exercises the bounded native lookup and duration validation with local fixtures; `.build/sponsorblock-check --live` performs read-only public API probes. `make check-resolved` includes SponsorBlock opt-in/failure isolation, same-video cancellation, direct/saved/queue/enqueue launches, quality changes and Miruro separation. The native slider regression (`.build/cast-playback-check --slider-regression`, after building that target) checks crimson painting across chapter gaps, buffering, active emphasis, hover and seeking.
|
`make check-sponsorblock` exercises the bounded native lookup and duration validation with local fixtures; `.build/sponsorblock-check --live` performs read-only public API probes. `make check-resolved` includes SponsorBlock opt-in/failure isolation, same-video cancellation, direct/saved/queue/enqueue launches, quality changes and Miruro separation. The native slider regression (`.build/cast-playback-check --slider-regression`, after building that target) checks crimson painting across chapter gaps, buffering, active emphasis, hover and seeking.
|
||||||
`make check-zoko check-reporting check-cli check-stream check-progress` covers provider admission/codec/error categories, bounded resources, scoped CDN policy, isolated resume and report identity without launching a GUI. Optional `make check-zoko-live` resolves current AniList 1 episode 1, loads VTT, and uses the **actual native player** while paused to decode/step video, seek/decode at 600 seconds, restore position and assert version-2 AniList progress. It creates no windows and does not start audio playback, but production Core Audio still initializes an output device. No tokenized addresses are printed or retained after the check. This is not a full-episode, audio-output, pixel/subtitle-rendering or UI smoke test, nor proof of availability for other catalog entries. Full `make check` still launches GUI/audio checks.
|
`make check-zoko check-reporting check-cli check-stream check-progress` covers provider admission/codec/error categories, bounded resources, scoped CDN policy, isolated resume and report identity without launching a GUI. Optional `make check-zoko-live` resolves current AniList 1 episode 1, loads VTT, and uses the **actual native player** while paused to decode/step video, seek/decode at 600 seconds, restore position and assert version-2 AniList progress. It creates no windows and does not start audio playback, but production Core Audio still initializes an output device. No tokenized addresses are printed or retained after the check. This is not a full-episode, audio-output, pixel/subtitle-rendering or UI smoke test, nor proof of availability for other catalog entries. Full `make check` still launches GUI/audio checks.
|
||||||
|
|
||||||
`make check-source` exercises the public parser/result contract, error redaction, shared manifest validation, total deadline/cancellation and actual CLI rejection paths without GUI/audio or native-player linking. Optional `make check-source-live` runs the **actual CLI**, copied outside its app bundle with an intentionally invalid configuration and isolated state directories: AniList 1 episode 1 must return `available`, and episode 27 must return `unavailable`, with no state writes. These source-only probes fetch no segments and are distinct from `check-zoko-live`'s native playback checks.
|
`make check-source` exercises the public parser/result contract, error redaction, shared manifest validation, total deadline/cancellation and actual CLI rejection paths without GUI/audio or native-player linking. Optional `make check-source-live` runs the **actual CLI**, copied outside its app bundle with an intentionally invalid configuration and isolated state directories: Cowboy Bebop, Black Clover episodes 1/170 and Black Clover Season 2 episode 1 must resolve through their exact MAL mappings, while Cowboy Bebop episode 27 remains `unavailable`, with no state writes. These source-only probes fetch no segments and are distinct from `check-zoko-live`'s native playback checks. `.build/zoko-check --live-black-clover` exercises native decoding, ten-minute seek, subtitles, resume and original AniList progress identity for Black Clover.
|
||||||
|
|
||||||
|
|
||||||
## Acknowledgements
|
## Acknowledgements
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
enum ZokoError: String, LocalizedError {
|
enum ZokoError: String, LocalizedError {
|
||||||
case unavailable, transport, protection, malformed
|
case unavailable, transport, protection, malformed, mapping
|
||||||
var errorDescription: String? {
|
var errorDescription: String? {
|
||||||
switch self {
|
switch self {
|
||||||
case .unavailable: "ZokoAnime has no sub episode available for this catalog number. Catalog numbering does not confirm availability."
|
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 .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 .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 .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 }
|
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.timeoutInterval = 20
|
||||||
request.setValue("text/html", forHTTPHeaderField: "Accept")
|
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 }
|
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 source = try parse(page: page, mediaID: mediaID, episode: episode)
|
||||||
guard response.statusCode == 200 else { throw ZokoError.transport }
|
guard response.statusCode == 200 else { throw ZokoError.transport }
|
||||||
guard enrichTitle else { return source }
|
guard enrichTitle, let name = metadata.name else { return source }
|
||||||
let title = (try? await Self.title(mediaID: mediaID, episode: episode)) ?? source.title
|
|
||||||
try Task.checkCancellation()
|
try Task.checkCancellation()
|
||||||
return ResolvedSource(url: source.url, headers: source.headers, identity: source.identity,
|
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 {
|
static func parse(page: String, mediaID: String, episode: Int) throws -> ResolvedSource {
|
||||||
@@ -117,29 +124,58 @@ enum ZokoResolver {
|
|||||||
requirements: .init(zoko: true))
|
requirements: .init(zoko: true))
|
||||||
}
|
}
|
||||||
|
|
||||||
static func title(mediaID: String, episode: Int) async throws -> String {
|
struct Catalog: Decodable {
|
||||||
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? }
|
struct Title: Decodable { let english: String?; let romaji: String?; let native: String? }
|
||||||
let id: Int; let title: Title
|
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
|
||||||
}
|
}
|
||||||
let media: Media
|
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 {
|
||||||
|
let media: Catalog?
|
||||||
enum CodingKeys: String, CodingKey { case media = "Media" }
|
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
|
let response: Response
|
||||||
guard String(metadata.id) == mediaID,
|
do { response = try JSONDecoder().decode(Response.self, from: data) }
|
||||||
let name = [metadata.title.english, metadata.title.romaji, metadata.title.native].compactMap({ $0 }).first(where: { !$0.isEmpty }),
|
catch { throw ZokoError.malformed }
|
||||||
ResolvedSource.validText(name, limit: 2048) else { 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)"
|
return "\(name) · Episode \(episode) (sub)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user