feat: resolve AniList episodes through ZokoAnime
This commit is contained in:
@@ -36,6 +36,9 @@ struct PlayerCommandChecks {
|
||||
let miruro = URL(string: "https://barelystarted.miruro.tv/watch/OERe_a0YS4h-DlsBDOeXwX7HOO6316tw/the-apothecary-diaries-season-3?ep=1")!
|
||||
require(try opened([miruro.absoluteString]) == miruro, "Miruro positional URLs retain episode page identity")
|
||||
require(try opened(["--url", miruro.absoluteString]) == miruro, "explicit Miruro URLs forward without network resolution")
|
||||
let anilist = URL(string: "https://zokoanime.video/stream/ani/1/1/sub")!
|
||||
require(try opened([anilist.absoluteString]) == anilist &&
|
||||
opened(["--url", anilist.absoluteString]) == anilist, "AniList watch URLs forward without yt-dlp or network resolution")
|
||||
let generic = URL(string: "https://vimeo.com/76979871?quality=original")!
|
||||
require(try opened([generic.absoluteString]) == generic &&
|
||||
opened(["--url", generic.absoluteString]) == generic,
|
||||
@@ -112,6 +115,11 @@ struct PlayerCommandChecks {
|
||||
catch {}
|
||||
}
|
||||
let original = source
|
||||
source["identity"] = ["provider": "anilist", "media_id": "1", "episode": 12]
|
||||
try saveSource()
|
||||
let anilistSource = try ResolvedSource.load(from: sourceFile)
|
||||
require(anilistSource.identity == "anilist:1:12" && anilistSource.title == "AniList 1 episode 12",
|
||||
"source-file identity admits AniList without pretending it is Miruro")
|
||||
let invalidChanges: [[String: Any]] = [
|
||||
["version": 2], ["url": "https://user:secret@cdn.example/video"],
|
||||
["url": "file:///secret"], ["url": "https://cdn.example/video\nsecret"],
|
||||
@@ -120,6 +128,9 @@ struct PlayerCommandChecks {
|
||||
["identity": ["provider": "miruro", "media_id": "bad:id", "episode": 1]],
|
||||
["identity": ["provider": "miruro", "media_id": "opaque", "episode": 0]],
|
||||
["identity": ["provider": "miruro", "media_id": "opaque", "episode": 1.5]],
|
||||
["identity": ["provider": "anilist", "media_id": "01", "episode": 1]],
|
||||
["identity": ["provider": "anilist", "media_id": "opaque", "episode": 1]],
|
||||
["identity": ["provider": "anilist", "media_id": "1", "episode": 2147483648]],
|
||||
["subtitles": Array(repeating: (original["subtitles"] as! [[String: Any]])[0], count: 2)]
|
||||
]
|
||||
for change in invalidChanges {
|
||||
@@ -175,6 +186,11 @@ struct PlayerCommandChecks {
|
||||
require(parsedQueue.entries.map(\.url) == [miruro, canonical], "stream queue order is supplied, never filename-sorted")
|
||||
require(parsedQueue.entries[0].identity.hasPrefix("miruro:") &&
|
||||
parsedQueue.entries[1].identity == canonical.absoluteString, "stable identities exclude slugs and transport URLs")
|
||||
let anilistReport = privateDirectory.appendingPathComponent("anilist.jsonl").path
|
||||
try saveQueue(["version": 1, "items": entries + [["url": anilist.absoluteString, "progress_file": anilistReport]]])
|
||||
let mixedQueue = try StreamQueue.load(from: queueFile)
|
||||
require(mixedQueue.entries.map(\.identity) == [parsedQueue.entries[0].identity, canonical.absoluteString, "anilist:1:1"],
|
||||
"mixed queue retains distinct Miruro, YouTube and AniList identities")
|
||||
try saveQueue(["version": 1, "items": [["url": generic.absoluteString, "progress_file": youtubeReport]]])
|
||||
let genericQueue = try StreamQueue.load(from: queueFile)
|
||||
require(genericQueue.entries[0].url == generic &&
|
||||
|
||||
@@ -302,6 +302,30 @@ int main(int argc, char **argv) {
|
||||
[row[@"completion_eligible"] boolValue], "Miruro retains terminal snapshot semantics");
|
||||
}
|
||||
|
||||
require(ProgressReporter::validMediaIdentity("anilist:1:1") &&
|
||||
ProgressReporter::validMediaIdentity("anilist:2147483647:2147483647"), "canonical AniList identities accepted");
|
||||
for (const char *invalid : {"anilist:01:1", "anilist:0:1", "anilist:-1:1", "anilist:a:1",
|
||||
"anilist:1:01", "anilist:1:0", "anilist:1:2147483648", "anilist:2147483648:1",
|
||||
"anilist:1:1:sub", "anilist:1:1?token=secret"})
|
||||
require(!ProgressReporter::validMediaIdentity(invalid), "noncanonical AniList identities rejected");
|
||||
const auto anilistPath = root + "/anilist.jsonl";
|
||||
{
|
||||
ProgressReporter reporter(anilistPath, [] { return 0.; });
|
||||
reporter.begin("anilist:1:1");
|
||||
reporter.observe(sample(PLAYER_PAUSED, 42));
|
||||
reporter.finish();
|
||||
require(reporter.error().empty(), "AniList progress drains successfully");
|
||||
}
|
||||
rows = records(anilistPath);
|
||||
require(rows.count == 2, "AniList initial and closed snapshots");
|
||||
for (NSDictionary *row in rows) {
|
||||
require([row[@"version"] intValue] == 2 && row[@"media_url"] == nil &&
|
||||
[row[@"media_identity"][@"provider"] isEqual:@"anilist"] &&
|
||||
[row[@"media_identity"][@"media_id"] isEqual:@"1"] &&
|
||||
[row[@"media_identity"][@"episode"] intValue] == 1 &&
|
||||
[row[@"position_seconds"] doubleValue] == 42, "AniList v2 schema remains separate from Miruro");
|
||||
}
|
||||
|
||||
const auto clockFailurePath = root + "/clock-failure.jsonl";
|
||||
{
|
||||
ProgressReporter reporter(clockFailurePath, []() -> double { throw std::runtime_error("clock failure"); });
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import Foundation
|
||||
|
||||
@main
|
||||
struct ZokoCheck {
|
||||
struct Failure: LocalizedError {
|
||||
let message: String
|
||||
var errorDescription: String? { message }
|
||||
}
|
||||
static func require(_ condition: @autoclosure () -> Bool, _ message: String) throws {
|
||||
if !condition() { throw Failure(message: message) }
|
||||
}
|
||||
static func expect(_ category: ZokoError, _ operation: () throws -> Void) throws {
|
||||
do { try operation() }
|
||||
catch let error as ZokoError where error == category { return }
|
||||
throw Failure(message: "Incorrect provider error category")
|
||||
}
|
||||
static func page(_ config: [String: Any]) throws -> String {
|
||||
var data = Array(try JSONSerialization.data(withJSONObject: config))
|
||||
let key = Array("otaku-embed-v1".utf8)
|
||||
for i in data.indices { data[i] ^= key[i % key.count] }
|
||||
return "<script>window.__P = '\(Data(data).base64EncodedString())';</script>"
|
||||
}
|
||||
static func checks() async throws {
|
||||
let watch = "https://zokoanime.video/stream/ani/1/1/sub"
|
||||
guard case .anilist(let id, let episode) = try StreamURL.parse(watch) else { throw Failure(message: "AniList URL classification") }
|
||||
try require(id == "1" && episode == 1, "AniList stable ID admission")
|
||||
for bad in ["http://zokoanime.video/stream/ani/1/1/sub", "https://zokoanime.video/stream/anilist/1/1/sub",
|
||||
watch + "?token=bad", watch + "#bad", watch + "/", watch.replacingOccurrences(of: "/1/1/", with: "/01/1/"),
|
||||
watch.replacingOccurrences(of: "/1/sub", with: "/01/sub"), watch.replacingOccurrences(of: "/1/sub", with: "/2147483648/sub"),
|
||||
watch.replacingOccurrences(of: "/sub", with: "/dub"), "https://a@zokoanime.video/stream/ani/1/1/sub",
|
||||
"https://zokoanime.video/stream/ani/%31/1/sub"] {
|
||||
do { _ = try StreamURL.parse(bad) }
|
||||
catch { continue }
|
||||
throw Failure(message: "Noncanonical provider URL accepted")
|
||||
}
|
||||
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]]]
|
||||
let source = try ZokoResolver.parse(page: page(config), mediaID: "1", episode: 1)
|
||||
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,
|
||||
"honest fallback title and VTT metadata")
|
||||
try require(source.headers == ZokoResolver.referer && source.subtitles[0].headers == ZokoResolver.referer, "only fixed public Referer scoped to declared CDN")
|
||||
let missing = "<span>// Error</span><span>404</span><h1>Not <span>found.</span></h1><p>We couldn't find anything to play here.</p>"
|
||||
try expect(.unavailable) { _ = try ZokoResolver.parse(page: missing, mediaID: "1", episode: 27) }
|
||||
for protected in ["<title>Just a moment</title>", "<script src='/cdn-cgi/challenge-platform/x'></script>"] {
|
||||
try expect(.protection) { _ = try ZokoResolver.parse(page: protected, mediaID: "1", episode: 1) }
|
||||
}
|
||||
for malformed in ["<html></html>", "window.__P='broken';", try page(config) + page(config), String(repeating: "x", count: ZokoResolver.bodyLimit + 1)] {
|
||||
try expect(.malformed) { _ = try ZokoResolver.parse(page: malformed, mediaID: "1", episode: 1) }
|
||||
}
|
||||
for bad in ["https://evil.example/master.m3u8", "https://hls1.drama1.cfd/master.m3u8", "http://hls.dramahot.top/master.m3u8", "https://hls.dramahot.top:8443/master.m3u8",
|
||||
"https://hls.dramahot.top.evil.example/master.m3u8", "https://user@hls.dramahot.top/master.m3u8", "https://127.0.0.1/master.m3u8"] {
|
||||
config["src"] = bad
|
||||
try expect(.malformed) { _ = try ZokoResolver.parse(page: page(config), mediaID: "1", episode: 1) }
|
||||
}
|
||||
config["src"] = "https://hls.dramahot.top/master.m3u8"
|
||||
let invalidSubtitles: [[[String: Any]]] = [
|
||||
[["src": "https://evil.example/en.vtt", "lang": "en", "label": "English"]],
|
||||
[["src": "https://hls.dramahot.top/en.vtt", "lang": "en", "label": "bad\nlabel"]],
|
||||
Array(repeating: ["src": "https://hls.dramahot.top/en.vtt", "lang": "en", "label": "English", "default": true], count: 2),
|
||||
Array(repeating: ["src": "https://hls.dramahot.top/en.vtt", "lang": "en", "label": "English"], count: 33)]
|
||||
for subtitles in invalidSubtitles {
|
||||
config["subtitles"] = subtitles
|
||||
try expect(.malformed) { _ = try ZokoResolver.parse(page: page(config), mediaID: "1", episode: 1) }
|
||||
}
|
||||
try require(ZokoResolver.validResource(URL(string: "https://hls1.drama1.cfd/segment.jpg")!), "exact demonstrated segment origin admitted")
|
||||
for bad in ["https://evil.example/segment.ts", "http://hls1.drama1.cfd/segment.ts", "https://hls1.drama1.cfd:8443/segment.ts", "https://hls1.drama1.cfd.evil.example/segment.ts", "https://127.0.0.1/segment.ts"] {
|
||||
try require(!ZokoResolver.validResource(URL(string: bad)!), "hostile manifest resource rejected before request")
|
||||
}
|
||||
let suite = "terebi.zoko-check." + UUID().uuidString
|
||||
let defaults = UserDefaults(suiteName: suite)!
|
||||
defer { defaults.removePersistentDomain(forName: suite) }
|
||||
var progress = PlaybackProgress(defaults: defaults)
|
||||
let identity = URL(string: source.identity)!
|
||||
progress.save(123, for: identity)
|
||||
progress.save(456, for: URL(string: "miruro:1:1")!)
|
||||
progress = PlaybackProgress(defaults: defaults)
|
||||
try require(progress.position(for: identity) == 123 && progress.position(for: URL(string: "miruro:1:1")!) == 456,
|
||||
"AniList resume persists separately from Miruro")
|
||||
print("PASS: AniList URL/identity, static codec, unavailable/protection/malformed categories, bounded resources/subtitles, narrow CDN policy and isolated resume")
|
||||
}
|
||||
|
||||
// No NSApplication, window or audio playback. The production player opens and
|
||||
// decodes while paused; acknowledged frame stepping proves real video decode.
|
||||
static func until(_ player: OpaquePointer, _ predicate: (PlayerState) -> Bool) async throws -> PlayerState {
|
||||
let deadline = ContinuousClock.now.advanced(by: .seconds(60))
|
||||
while ContinuousClock.now < deadline {
|
||||
let state = player_get_state(player)
|
||||
if state.status == PLAYER_ERROR { throw Failure(message: "Native player failed (source details redacted)") }
|
||||
if predicate(state) { return state }
|
||||
try await Task.sleep(for: .milliseconds(10))
|
||||
}
|
||||
throw Failure(message: "Native player acknowledgement timed out")
|
||||
}
|
||||
|
||||
static func live() async throws {
|
||||
let source = try await MiruroTransport.prepare(ZokoResolver.resolve(mediaID: "1", episode: 1))
|
||||
defer { _fixLifetime(source) }
|
||||
try require(source.identity == "anilist:1:1" && 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") }
|
||||
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") }
|
||||
let directory = FileManager.default.temporaryDirectory.appendingPathComponent("terebi-zoko-" + UUID().uuidString)
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700])
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let report = directory.appendingPathComponent("progress.jsonl")
|
||||
guard let player = player_create() else { throw Failure(message: "Native player creation") }
|
||||
var destroyed = false
|
||||
defer { if !destroyed { player_destroy(player) } }
|
||||
var error = [CChar](repeating: 0, count: 512)
|
||||
try require(report.path.withCString { player_set_progress_file(player, $0, &error) }, "Native progress file admission")
|
||||
func open(_ position: Double) -> Bool {
|
||||
source.url.withCString { url in
|
||||
source.identity.withCString { identity in
|
||||
var input = PlayerNetworkSource(video_url: url, audio_url: nil, video_headers: nil, audio_headers: nil,
|
||||
duration: 0, is_live: false, media_url: identity, initial_position: position)
|
||||
return player_open_network(player, &input)
|
||||
}
|
||||
}
|
||||
}
|
||||
try require(open(0), "Native AniList source admission")
|
||||
var state = try await until(player) { $0.status == PLAYER_PAUSED && $0.has_video && $0.has_audio && !$0.seek_pending }
|
||||
try require(state.can_seek && state.duration > 600, "Native tracks/duration/seekability")
|
||||
let revision = state.revision
|
||||
try require(player_step_frame(player, 1), "Native first-frame step")
|
||||
state = try await until(player) { $0.status == PLAYER_PAUSED && !$0.seek_pending && $0.revision != revision && $0.position > 0 }
|
||||
try require(player_seek(player, (cue.start + cue.end) / 2), "Native subtitle-position seek")
|
||||
state = try await until(player) { !$0.seek_pending && $0.position >= cue.start && $0.position < cue.end }
|
||||
try require(!document.text(at: state.position).isEmpty, "Subtitles render at acknowledged native position")
|
||||
let beforeSeek = state.revision
|
||||
try require(player_seek(player, 600), "Native ten-minute seek admission")
|
||||
state = try await until(player) { !$0.seek_pending && $0.revision != beforeSeek && abs($0.position - 600) < 0.1 }
|
||||
let beforeStep = state.position
|
||||
let seekRevision = state.revision
|
||||
try require(player_step_frame(player, 1), "Native post-seek decode step")
|
||||
state = try await until(player) { !$0.seek_pending && $0.revision != seekRevision && $0.position > beforeStep }
|
||||
let saved = state.position
|
||||
try require(player_close(player), "Native close before resume")
|
||||
_ = try await until(player) { $0.status == PLAYER_EMPTY }
|
||||
try require(open(saved), "Native restored AniList source admission")
|
||||
state = try await until(player) { $0.status == PLAYER_PAUSED && !$0.seek_pending && abs($0.position - saved) < 0.1 }
|
||||
try require(state.has_audio && state.has_video, "Native restored tracks")
|
||||
player_destroy(player)
|
||||
destroyed = true
|
||||
let rows = try String(contentsOf: report, encoding: .utf8).split(separator: "\n").map {
|
||||
try JSONSerialization.jsonObject(with: Data($0.utf8)) as! [String: Any]
|
||||
}
|
||||
try require(!rows.isEmpty, "Native progress records emitted")
|
||||
for row in rows {
|
||||
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,
|
||||
"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")
|
||||
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)")
|
||||
}
|
||||
static func main() async {
|
||||
do {
|
||||
try await checks()
|
||||
if CommandLine.arguments.contains("--live") { try await live() }
|
||||
} catch {
|
||||
FileHandle.standardError.write(Data("FAIL: Zoko check: \(error.localizedDescription)\n".utf8))
|
||||
exit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user