Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b95f7d251e | |||
| e6a2759497 | |||
| 6463d47ed8 | |||
| ab61d83c69 |
@@ -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")!
|
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([miruro.absoluteString]) == miruro, "Miruro positional URLs retain episode page identity")
|
||||||
require(try opened(["--url", miruro.absoluteString]) == miruro, "explicit Miruro URLs forward without network resolution")
|
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")!
|
let generic = URL(string: "https://vimeo.com/76979871?quality=original")!
|
||||||
require(try opened([generic.absoluteString]) == generic &&
|
require(try opened([generic.absoluteString]) == generic &&
|
||||||
opened(["--url", generic.absoluteString]) == generic,
|
opened(["--url", generic.absoluteString]) == generic,
|
||||||
@@ -112,6 +115,11 @@ struct PlayerCommandChecks {
|
|||||||
catch {}
|
catch {}
|
||||||
}
|
}
|
||||||
let original = source
|
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]] = [
|
let invalidChanges: [[String: Any]] = [
|
||||||
["version": 2], ["url": "https://user:secret@cdn.example/video"],
|
["version": 2], ["url": "https://user:secret@cdn.example/video"],
|
||||||
["url": "file:///secret"], ["url": "https://cdn.example/video\nsecret"],
|
["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": "bad:id", "episode": 1]],
|
||||||
["identity": ["provider": "miruro", "media_id": "opaque", "episode": 0]],
|
["identity": ["provider": "miruro", "media_id": "opaque", "episode": 0]],
|
||||||
["identity": ["provider": "miruro", "media_id": "opaque", "episode": 1.5]],
|
["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)]
|
["subtitles": Array(repeating: (original["subtitles"] as! [[String: Any]])[0], count: 2)]
|
||||||
]
|
]
|
||||||
for change in invalidChanges {
|
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.map(\.url) == [miruro, canonical], "stream queue order is supplied, never filename-sorted")
|
||||||
require(parsedQueue.entries[0].identity.hasPrefix("miruro:") &&
|
require(parsedQueue.entries[0].identity.hasPrefix("miruro:") &&
|
||||||
parsedQueue.entries[1].identity == canonical.absoluteString, "stable identities exclude slugs and transport URLs")
|
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]]])
|
try saveQueue(["version": 1, "items": [["url": generic.absoluteString, "progress_file": youtubeReport]]])
|
||||||
let genericQueue = try StreamQueue.load(from: queueFile)
|
let genericQueue = try StreamQueue.load(from: queueFile)
|
||||||
require(genericQueue.entries[0].url == generic &&
|
require(genericQueue.entries[0].url == generic &&
|
||||||
|
|||||||
@@ -37,25 +37,37 @@ static NSString *nativePath(const std::string &path) {
|
|||||||
return [NSString stringWithUTF8String:path.c_str()];
|
return [NSString stringWithUTF8String:path.c_str()];
|
||||||
}
|
}
|
||||||
|
|
||||||
static NSArray<NSDictionary *> *records(const std::string &path) {
|
static NSArray<NSDictionary *> *records(const std::string &path, int version = 1) {
|
||||||
NSString *text = [NSString stringWithContentsOfFile:nativePath(path)
|
NSString *text = [NSString stringWithContentsOfFile:nativePath(path)
|
||||||
encoding:NSUTF8StringEncoding error:nullptr];
|
encoding:NSUTF8StringEncoding error:nullptr];
|
||||||
require(text != nil && [text hasSuffix:@"\n"], "finished JSONL has a complete final line");
|
require(text != nil && [text hasSuffix:@"\n"], "finished JSONL has a complete final line");
|
||||||
NSMutableArray<NSDictionary *> *result = [NSMutableArray array];
|
NSMutableArray<NSDictionary *> *result = [NSMutableArray array];
|
||||||
NSArray<NSString *> *lines = [text componentsSeparatedByString:@"\n"];
|
NSArray<NSString *> *lines = [text componentsSeparatedByString:@"\n"];
|
||||||
NSSet *keys = [NSSet setWithArray:@[@"version", @"event", @"media_url", @"position_seconds",
|
NSSet *keys = [NSSet setWithArray:@[@"version", @"event", version == 2 ? @"media_identity" : @"media_url",
|
||||||
@"duration_seconds", @"completion_eligible"]];
|
@"position_seconds", @"duration_seconds", @"completion_eligible"]];
|
||||||
for (NSUInteger i = 0; i + 1 < lines.count; ++i) {
|
for (NSUInteger i = 0; i + 1 < lines.count; ++i) {
|
||||||
id object = [NSJSONSerialization JSONObjectWithData:[lines[i] dataUsingEncoding:NSUTF8StringEncoding]
|
id object = [NSJSONSerialization JSONObjectWithData:[lines[i] dataUsingEncoding:NSUTF8StringEncoding]
|
||||||
options:0 error:nullptr];
|
options:0 error:nullptr];
|
||||||
require([object isKindOfClass:[NSDictionary class]], "consumer decodes every JSONL record");
|
require([object isKindOfClass:[NSDictionary class]], "consumer decodes every JSONL record");
|
||||||
NSDictionary *row = object;
|
NSDictionary *row = object;
|
||||||
require([[NSSet setWithArray:row.allKeys] isEqual:keys], "records have exactly the six specified fields");
|
require([[NSSet setWithArray:row.allKeys] isEqual:keys], "records have exactly the six specified fields");
|
||||||
require([row[@"version"] isKindOfClass:[NSNumber class]] && [row[@"version"] intValue] == 1,
|
require([row[@"version"] isKindOfClass:[NSNumber class]] && [row[@"version"] intValue] == version,
|
||||||
"schema version is numeric one");
|
"schema version matches the expected source contract");
|
||||||
require([@[@"progress", @"ended", @"closed"] containsObject:row[@"event"]], "known event type");
|
require([@[@"progress", @"ended", @"closed"] containsObject:row[@"event"]], "known event type");
|
||||||
require([row[@"media_url"] isKindOfClass:[NSString class]] &&
|
if (version == 2) {
|
||||||
ProgressReporter::validMediaURL([row[@"media_url"] UTF8String]), "HTTP(S) consumer identity");
|
NSDictionary *identity = row[@"media_identity"];
|
||||||
|
require([identity isKindOfClass:[NSDictionary class]] &&
|
||||||
|
[[NSSet setWithArray:identity.allKeys] isEqual:
|
||||||
|
[NSSet setWithArray:@[@"provider", @"media_id", @"episode"]]] &&
|
||||||
|
[identity[@"provider"] isKindOfClass:[NSString class]] &&
|
||||||
|
[identity[@"media_id"] isKindOfClass:[NSString class]] &&
|
||||||
|
[identity[@"episode"] isKindOfClass:[NSNumber class]], "structured episode consumer identity");
|
||||||
|
NSString *key = [NSString stringWithFormat:@"%@:%@:%@", identity[@"provider"], identity[@"media_id"], identity[@"episode"]];
|
||||||
|
require(ProgressReporter::validMediaIdentity(key.UTF8String), "canonical episode consumer identity");
|
||||||
|
} else {
|
||||||
|
require([row[@"media_url"] isKindOfClass:[NSString class]] &&
|
||||||
|
ProgressReporter::validMediaURL([row[@"media_url"] UTF8String]), "HTTP(S) consumer identity");
|
||||||
|
}
|
||||||
require([row[@"position_seconds"] isKindOfClass:[NSNumber class]] &&
|
require([row[@"position_seconds"] isKindOfClass:[NSNumber class]] &&
|
||||||
std::isfinite([row[@"position_seconds"] doubleValue]) &&
|
std::isfinite([row[@"position_seconds"] doubleValue]) &&
|
||||||
[row[@"position_seconds"] doubleValue] >= 0, "position is finite and nonnegative");
|
[row[@"position_seconds"] doubleValue] >= 0, "position is finite and nonnegative");
|
||||||
@@ -302,6 +314,30 @@ int main(int argc, char **argv) {
|
|||||||
[row[@"completion_eligible"] boolValue], "Miruro retains terminal snapshot semantics");
|
[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, 2);
|
||||||
|
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";
|
const auto clockFailurePath = root + "/clock-failure.jsonl";
|
||||||
{
|
{
|
||||||
ProgressReporter reporter(clockFailurePath, []() -> double { throw std::runtime_error("clock failure"); });
|
ProgressReporter reporter(clockFailurePath, []() -> double { throw std::runtime_error("clock failure"); });
|
||||||
|
|||||||
Executable
+79
@@ -0,0 +1,79 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Actual CLI contract checks; --live adds bounded public embed/root probes only."""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
|
||||||
|
def require(condition, message):
|
||||||
|
if not condition:
|
||||||
|
raise AssertionError(message)
|
||||||
|
|
||||||
|
|
||||||
|
def snapshot(*roots):
|
||||||
|
return {
|
||||||
|
str(path): path.read_bytes() if path.is_file() else None
|
||||||
|
for root in roots
|
||||||
|
for path in root.rglob("*")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
executable = Path(sys.argv[1]).resolve(strict=True)
|
||||||
|
live = "--live" in sys.argv[2:]
|
||||||
|
watch = "https://zokoanime.video/stream/ani/1/1/sub"
|
||||||
|
secret = "private-fixture-token"
|
||||||
|
with tempfile.TemporaryDirectory(prefix="terebi-source-check-") as temporary:
|
||||||
|
root = Path(temporary)
|
||||||
|
home, config = root / "home", root / "config"
|
||||||
|
home.mkdir()
|
||||||
|
(config / "terebi").mkdir(parents=True)
|
||||||
|
# A launch would parse this malformed configuration or reject this
|
||||||
|
# executable outside its bundle. A source check must do neither.
|
||||||
|
(config / "terebi" / "config.toml").write_text("not valid TOML = [\n")
|
||||||
|
cli = root / "terebi"
|
||||||
|
shutil.copy2(executable, cli)
|
||||||
|
environment = dict(os.environ, HOME=str(home), CFFIXED_USER_HOME=str(home), XDG_CONFIG_HOME=str(config))
|
||||||
|
before = snapshot(home, config)
|
||||||
|
|
||||||
|
def invoke(arguments):
|
||||||
|
try:
|
||||||
|
result = subprocess.run([str(cli), *arguments], env=environment, capture_output=True, timeout=20)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
raise AssertionError("source-check CLI exceeded total deadline") from None
|
||||||
|
require(len(result.stdout) <= 128 and len(result.stderr) <= 1024, "bounded CLI output")
|
||||||
|
require(secret.encode() not in result.stdout + result.stderr, "CLI output redacts private input")
|
||||||
|
require(b"https://" not in result.stdout, "no source URLs in JSON")
|
||||||
|
require(snapshot(home, config) == before, "source checking must not write configuration or resume/progress state")
|
||||||
|
return result
|
||||||
|
|
||||||
|
for arguments in [
|
||||||
|
["--check-source"], ["--check-source", watch + "?token=" + secret],
|
||||||
|
["--check-source", "https://" + secret + "@zokoanime.video/stream/ani/1/1/sub"],
|
||||||
|
["--new-instance", "--check-source", watch], ["--check-source", watch, "--no-config"],
|
||||||
|
["--check-source", watch, "--progress-file", str(root / secret)],
|
||||||
|
["--check-source", "https://127.0.0.1/" + secret],
|
||||||
|
]:
|
||||||
|
result = invoke(arguments)
|
||||||
|
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")
|
||||||
|
if live:
|
||||||
|
for episode, expected in [(1, "available"), (27, "unavailable")]:
|
||||||
|
result = invoke(["--check-source", f"https://zokoanime.video/stream/ani/1/{episode}/sub"])
|
||||||
|
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")
|
||||||
|
row = json.loads(result.stdout)
|
||||||
|
require(row == {"version": 1, "provider": "zokoanime", "track": "sub", "status": expected},
|
||||||
|
f"live episode {episode} expected {expected}; result is redacted")
|
||||||
|
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")
|
||||||
|
else:
|
||||||
|
print("PASS: actual CLI rejects hostile URLs/flags privately, without configuration reads, state writes or bundle launch")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct SourceCheckChecks {
|
||||||
|
struct Failure: Error {}
|
||||||
|
static func require(_ condition: @autoclosure () -> Bool, _ message: String) throws {
|
||||||
|
guard condition() else {
|
||||||
|
FileHandle.standardError.write(Data("FAIL: \(message)\n".utf8))
|
||||||
|
throw Failure()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func main() async throws {
|
||||||
|
let watch = "https://zokoanime.video/stream/ani/1/1/sub"
|
||||||
|
guard case .checkSource(let id, let episode) = try PlayerCommand.parse(["--check-source", watch]) else {
|
||||||
|
throw Failure()
|
||||||
|
}
|
||||||
|
try require(id == "1" && episode == 1, "check command is not a launch")
|
||||||
|
let secret = "private-fixture-token"
|
||||||
|
for arguments in [
|
||||||
|
["--check-source"], ["--check-source", watch, watch],
|
||||||
|
["--new-instance", "--check-source", watch], ["--no-config", "--check-source", watch],
|
||||||
|
["--check-source", watch, "--progress-file", "/tmp/" + secret],
|
||||||
|
["--source-file", "/tmp/" + secret, "--check-source", watch],
|
||||||
|
["--check-source", watch, "--help"], ["--check-source", "--check-source"],
|
||||||
|
["--check-source", "https://youtu.be/jNQXAC9IVRw"],
|
||||||
|
["--check-source", "https://barelystarted.miruro.tv/watch/1/show?ep=1"],
|
||||||
|
["--check-source", " " + watch], ["--check-source", watch + "\n"],
|
||||||
|
["--check-source", watch + "?token=" + secret], ["--check-source", watch + "#" + secret],
|
||||||
|
["--check-source", watch.replacingOccurrences(of: "https:", with: "http:")],
|
||||||
|
["--check-source", watch.replacingOccurrences(of: "/1/1/", with: "/01/1/")],
|
||||||
|
["--check-source", watch.replacingOccurrences(of: "/1/sub", with: "/2147483648/sub")],
|
||||||
|
["--check-source", watch.replacingOccurrences(of: "/sub", with: "/dub")],
|
||||||
|
["--check-source", "https://" + secret + "@zokoanime.video/stream/ani/1/1/sub"],
|
||||||
|
["--check-source", "https://zokoanime.video.evil.example/stream/ani/1/1/sub"],
|
||||||
|
["--check-source", "https://zokoanime.video/stream/ani/%31/1/sub"],
|
||||||
|
["--check-source", String(repeating: secret, count: 100)]
|
||||||
|
] {
|
||||||
|
do {
|
||||||
|
_ = try PlayerCommand.parse(arguments)
|
||||||
|
} catch {
|
||||||
|
try require(!error.localizedDescription.contains(secret), "argument errors redact input")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
throw Failure()
|
||||||
|
}
|
||||||
|
for status in SourceCheck.Status.allCases {
|
||||||
|
let json = status.json
|
||||||
|
let row = try JSONSerialization.jsonObject(with: Data(json.utf8)) as! [String: Any]
|
||||||
|
try require(row.count == 4 && row["version"] as? Int == 1 && row["provider"] as? String == "zokoanime" &&
|
||||||
|
row["track"] as? String == "sub" && row["status"] as? String == status.rawValue,
|
||||||
|
"one bounded result schema without source addresses or error strings")
|
||||||
|
try require(json.utf8.count < 128 && !json.contains("\n"), "bounded single-line JSON")
|
||||||
|
}
|
||||||
|
let root = URL(string: "https://hls.dramahot.top/master.m3u8?token=" + secret)!
|
||||||
|
let master = "#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=1000000\nvideo.m3u8\n"
|
||||||
|
let media = "#EXTM3U\n#EXT-X-TARGETDURATION:10\n#EXTINF:10,\nhttps://hls1.drama1.cfd/segment.jpg\n#EXT-X-ENDLIST\n"
|
||||||
|
try MiruroTransport.validateZokoManifest(master, base: root)
|
||||||
|
try MiruroTransport.validateZokoManifest(media, base: root)
|
||||||
|
for malformed in ["<html>" + secret + "</html>", "#EXTM3U", "#EXTM3Ugarbage\n" + master,
|
||||||
|
"#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=1\n", "#EXTM3U\nrandom.txt\n",
|
||||||
|
master.replacingOccurrences(of: "video.m3u8", with: "https://127.0.0.1/private.m3u8"),
|
||||||
|
master.replacingOccurrences(of: "video.m3u8", with: "https://hls1.drama1.cfd/private.m3u8"),
|
||||||
|
media.replacingOccurrences(of: "https://hls1.drama1.cfd/segment.jpg", with: "https://evil.example/" + secret),
|
||||||
|
"#EXTM3U\n#EXT-X-KEY:METHOD=AES-128,URI=\"http://127.0.0.1/" + secret + "\"\n" + media,
|
||||||
|
String(repeating: "x", count: ZokoResolver.bodyLimit + 1)] {
|
||||||
|
do { try MiruroTransport.validateZokoManifest(malformed, base: root) }
|
||||||
|
catch {
|
||||||
|
try require(!error.localizedDescription.contains(secret), "manifest errors redact source details")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
throw Failure()
|
||||||
|
}
|
||||||
|
// Exercise error classification, not fake provider availability. Live proof uses the actual CLI below.
|
||||||
|
for category in [ZokoError.unavailable, .transport, .protection, .malformed] {
|
||||||
|
let result = await SourceCheck.run(mediaID: "1", episode: 1, resolve: { _, _ in throw category },
|
||||||
|
validate: { _ in throw Failure() })
|
||||||
|
try require(result == (category == .unavailable ? .unavailable : .failed), "only explicit missing episode is unavailable")
|
||||||
|
}
|
||||||
|
let source = ResolvedSource(url: root.absoluteString, headers: "", identity: "anilist:1:1", title: "Fixture",
|
||||||
|
subtitles: [], requirements: .init(zoko: true))
|
||||||
|
let resourceFailure = await SourceCheck.run(mediaID: "1", episode: 1, resolve: { _, _ in source },
|
||||||
|
validate: { _ in throw URLError(.badServerResponse, userInfo: [NSURLErrorFailingURLStringErrorKey: root.absoluteString]) })
|
||||||
|
try require(resourceFailure == .failed && !resourceFailure.json.contains(secret), "resource errors are failed and private")
|
||||||
|
let deadline = await SourceCheck.run(mediaID: "1", episode: 1, timeout: .zero, resolve: { _, _ in
|
||||||
|
try await Task.sleep(for: .seconds(60))
|
||||||
|
return source
|
||||||
|
}, validate: { _ in throw Failure() })
|
||||||
|
try require(deadline == .failed, "total deadline cancels pending work")
|
||||||
|
let cancelled = Task {
|
||||||
|
await SourceCheck.run(mediaID: "1", episode: 1, resolve: { _, _ in
|
||||||
|
try await Task.sleep(for: .seconds(60))
|
||||||
|
return source
|
||||||
|
}, validate: { _ in throw Failure() })
|
||||||
|
}
|
||||||
|
cancelled.cancel()
|
||||||
|
let cancelledResult = await cancelled.value
|
||||||
|
try require(cancelledResult == .failed, "caller cancellation stops source check")
|
||||||
|
print("PASS: source command isolation, redaction, bounded JSON, shared manifest validation, error taxonomy, total deadline and cancellation")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
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 semicolonless = try page(config).replacingOccurrences(of: "';</script>", with: "'</script>")
|
||||||
|
let deployedSource = try ZokoResolver.parse(page: semicolonless, mediaID: "1", episode: 1)
|
||||||
|
try require(deployedSource.identity == source.identity, "deployed semicolonless static assignment is accepted")
|
||||||
|
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>",
|
||||||
|
"<h1>Sorry, you have been blocked</h1>", missing + "<div>captcha</div>"] {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,8 +19,9 @@ RUNTIME_SOURCE := Sources/Launcher/FFmpegRuntime.swift
|
|||||||
CLI_SOURCES := Sources/CLI/TerebiCLI.swift Sources/CLI/PlayerCommand.swift Sources/App/YouTubeResolver.swift Sources/App/ResolvedSource.swift $(RUNTIME_SOURCE)
|
CLI_SOURCES := Sources/CLI/TerebiCLI.swift Sources/CLI/PlayerCommand.swift Sources/App/YouTubeResolver.swift Sources/App/ResolvedSource.swift $(RUNTIME_SOURCE)
|
||||||
SWIFT_SOURCES += Sources/App/StreamResolver.swift
|
SWIFT_SOURCES += Sources/App/StreamResolver.swift
|
||||||
SWIFT_SOURCES += Sources/App/MiruroMetadata.swift
|
SWIFT_SOURCES += Sources/App/MiruroMetadata.swift
|
||||||
SWIFT_SOURCES += Sources/App/MiruroTransport.swift Sources/App/MiruroEmbed.swift
|
SWIFT_SOURCES += Sources/App/MiruroTransport.swift Sources/App/MiruroEmbed.swift Sources/App/ZokoResolver.swift
|
||||||
CLI_SOURCES += Sources/App/StreamResolver.swift
|
CLI_SOURCES += Sources/App/StreamResolver.swift
|
||||||
|
CLI_SOURCES += Sources/CLI/SourceCheck.swift Sources/App/ZokoResolver.swift Sources/App/MiruroTransport.swift Sources/App/MiruroEmbed.swift
|
||||||
SWIFT_SOURCES += Sources/App/StreamQueue.swift
|
SWIFT_SOURCES += Sources/App/StreamQueue.swift
|
||||||
SWIFT_SOURCES += Sources/App/SponsorBlock.swift
|
SWIFT_SOURCES += Sources/App/SponsorBlock.swift
|
||||||
CLI_SOURCES += Sources/App/StreamQueue.swift
|
CLI_SOURCES += Sources/App/StreamQueue.swift
|
||||||
@@ -295,6 +296,19 @@ $(BUILD_DIR)/player-command-check: Checks/player-command.swift Sources/CLI/Playe
|
|||||||
check-cli: $(BUILD_DIR)/player-command-check
|
check-cli: $(BUILD_DIR)/player-command-check
|
||||||
$(BUILD_DIR)/player-command-check
|
$(BUILD_DIR)/player-command-check
|
||||||
|
|
||||||
|
# Source checking shares only Swift resolver/transport code, never native player objects.
|
||||||
|
SOURCE_CHECK_SOURCES := $(filter-out Sources/CLI/TerebiCLI.swift $(RUNTIME_SOURCE),$(CLI_SOURCES))
|
||||||
|
$(BUILD_DIR)/source-check-check: Checks/source-check.swift $(SOURCE_CHECK_SOURCES) Makefile | $(BUILD_DIR)
|
||||||
|
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O $(DEBUG_FLAGS) $< $(SOURCE_CHECK_SOURCES) -framework Foundation -o $@
|
||||||
|
|
||||||
|
.PHONY: check-source check-source-live
|
||||||
|
check: check-source
|
||||||
|
check-source: $(BUILD_DIR)/source-check-check $(CLI)
|
||||||
|
$(BUILD_DIR)/source-check-check
|
||||||
|
python3 Checks/source-check.py "$(CLI)"
|
||||||
|
check-source-live: $(CLI)
|
||||||
|
python3 Checks/source-check.py "$(CLI)" --live
|
||||||
|
|
||||||
$(BUILD_DIR)/external-subtitles-check: Checks/external-subtitles.swift Sources/App/ExternalSubtitles.swift Makefile | $(BUILD_DIR)
|
$(BUILD_DIR)/external-subtitles-check: Checks/external-subtitles.swift Sources/App/ExternalSubtitles.swift Makefile | $(BUILD_DIR)
|
||||||
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O $(DEBUG_FLAGS) Checks/external-subtitles.swift Sources/App/ExternalSubtitles.swift -framework Foundation -o $@
|
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O $(DEBUG_FLAGS) Checks/external-subtitles.swift Sources/App/ExternalSubtitles.swift -framework Foundation -o $@
|
||||||
|
|
||||||
@@ -347,3 +361,14 @@ check-startup: all $(BUILD_DIR)/app-startup-check.dylib check-fixtures
|
|||||||
|
|
||||||
check-bundle: all check-fixtures
|
check-bundle: all check-fixtures
|
||||||
python3 Checks/bundle.py "$(APP)" "$(BUILD_DIR)/fixtures" "$(DEPENDENCY_ROOT)"
|
python3 Checks/bundle.py "$(APP)" "$(BUILD_DIR)/fixtures" "$(DEPENDENCY_ROOT)"
|
||||||
|
|
||||||
|
ZOKO_CHECK_SOURCES := Sources/App/ZokoResolver.swift Sources/App/StreamResolver.swift Sources/App/YouTubeResolver.swift Sources/App/ResolvedSource.swift Sources/App/MiruroTransport.swift Sources/App/MiruroEmbed.swift Sources/App/ExternalSubtitles.swift Sources/App/PlaybackProgress.swift
|
||||||
|
$(BUILD_DIR)/zoko-check: Checks/zoko.swift $(ZOKO_CHECK_SOURCES) $(OBJECTS) Makefile | check-dependencies
|
||||||
|
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O $(DEBUG_FLAGS) -import-objc-header Sources/Core/player.h $< $(ZOKO_CHECK_SOURCES) $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -lc++ -o $@
|
||||||
|
|
||||||
|
.PHONY: check-zoko check-zoko-live
|
||||||
|
check: check-zoko
|
||||||
|
check-zoko: $(BUILD_DIR)/zoko-check
|
||||||
|
$(BUILD_DIR)/zoko-check
|
||||||
|
check-zoko-live: $(BUILD_DIR)/zoko-check
|
||||||
|
$(BUILD_DIR)/zoko-check --live
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Terebi
|
# Terebi
|
||||||
|
|
||||||
A native macOS player for local media, yt-dlp-supported videos (including YouTube) and Miruro episodes, with ordered playback queues, saved playback positions, compact mode and Chromecast support.
|
A native macOS player for local media, yt-dlp-supported videos (including YouTube), Miruro episodes and AniList-numbered ZokoAnime sub episodes, with ordered playback queues, saved playback positions, compact mode and Chromecast support.
|
||||||
|
|
||||||
## Build and install
|
## Build and install
|
||||||
|
|
||||||
@@ -71,6 +71,33 @@ The CLI opens one file or supported stream, reusing the running player by defaul
|
|||||||
|
|
||||||
For native episode-URL playback, ordered stream queues, external resolved sources and progress reporting, see the [integration contract](docs/miruro-playback-handoff.md).
|
For native episode-URL playback, ordered stream queues, external resolved sources and progress reporting, see the [integration contract](docs/miruro-playback-handoff.md).
|
||||||
|
|
||||||
|
### AniList → ZokoAnime direct playback
|
||||||
|
|
||||||
|
```sh
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Resume, queue deduplication and per-item reports use **`anilist:<ID>:<episode>`**, separately from existing `miruro:` and YouTube identities. Queues use the existing version-1 queue document, with the watch URL above as `url`; progress JSON uses the existing version-2 fields with `media_identity: {"provider":"anilist","media_id":"1","episode":1}`. Old Miruro history is neither migrated nor reinterpreted. Resolved-source files also accept `identity.provider = "anilist"` with canonical decimal IDs; as with Miruro files, the caller owns the explicitly supplied source and headers.
|
||||||
|
|
||||||
|
For browsing clients, the CLI also provides a **headless, read-only** source check:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
terebi --check-source 'https://zokoanime.video/stream/ani/1/1/sub'
|
||||||
|
# {"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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Rebuild and reinstall Terebi (`make install`) before a browsing client uses this command; older installed CLIs do not support it. Clients should treat missing/invalid output as a failed check while keeping ordinary playback available, cache results only for the session, and check selected/visible episodes rather than scanning a whole catalog.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
The optional config file is `~/.config/terebi/config.toml`, or `$XDG_CONFIG_HOME/terebi/config.toml` when `XDG_CONFIG_HOME` is absolute. To print all supported settings and key bindings:
|
The optional config file is `~/.config/terebi/config.toml`, or `$XDG_CONFIG_HOME/terebi/config.toml` when `XDG_CONFIG_HOME` is absolute. To print all supported settings and key bindings:
|
||||||
@@ -132,6 +159,10 @@ Install FFmpeg for test fixtures (`brew install ffmpeg`), then run `make check`.
|
|||||||
For the focused native compact-queue smoke (transparent, noninteractive app window), run `TEREBI_CHECK_COMPACT_QUEUE_ONLY=1 make check-startup`. `make check-window check-config` covers dropdown geometry, resizing, metadata aspect changes, restoration and configurable queue shortcuts without displaying windows.
|
For the focused native compact-queue smoke (transparent, noninteractive app window), run `TEREBI_CHECK_COMPACT_QUEUE_ONLY=1 make check-startup`. `make check-window check-config` covers dropdown geometry, resizing, metadata aspect changes, restoration and configurable queue shortcuts without displaying windows.
|
||||||
|
|
||||||
`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-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.
|
||||||
|
|
||||||
|
|
||||||
## Acknowledgements
|
## Acknowledgements
|
||||||
|
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ final class MiruroTransport: @unchecked Sendable {
|
|||||||
|
|
||||||
static func prepare(_ source: ResolvedSource, deployment override: MiruroProxyDeployment? = nil) async throws -> ResolvedSource {
|
static func prepare(_ source: ResolvedSource, deployment override: MiruroProxyDeployment? = nil) async throws -> ResolvedSource {
|
||||||
let rules = source.requirements
|
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 {
|
do {
|
||||||
let deployment: MiruroProxyDeployment?
|
let deployment: MiruroProxyDeployment?
|
||||||
if let override { deployment = override }
|
if let override { deployment = override }
|
||||||
@@ -185,9 +185,29 @@ final class MiruroTransport: @unchecked Sendable {
|
|||||||
return prepared
|
return prepared
|
||||||
} catch {
|
} catch {
|
||||||
if Task.isCancelled || error is CancellationError { throw CancellationError() }
|
if Task.isCancelled || error is CancellationError { throw CancellationError() }
|
||||||
|
if rules.zoko { throw (error as? ZokoError) ?? ZokoError.transport }
|
||||||
throw MiruroPlaybackError()
|
throw MiruroPlaybackError()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Headless probe: no listener, relay token, native player or subtitle fetch.
|
||||||
|
// The same HTTP reader and manifest walker are used by actual playback.
|
||||||
|
static func checkZokoRoot(_ source: ResolvedSource) async throws {
|
||||||
|
guard source.requirements.zoko, let url = URL(string: source.url),
|
||||||
|
ZokoResolver.validResource(url, playlist: true) else { throw ZokoError.malformed }
|
||||||
|
let (data, response) = try await MiruroMediaHTTP.read(url, headers: ZokoResolver.referer,
|
||||||
|
limit: ZokoResolver.bodyLimit)
|
||||||
|
guard response.statusCode == 200, response.url == url,
|
||||||
|
let text = String(data: data, encoding: .utf8) else { throw ZokoError.malformed }
|
||||||
|
try validateZokoManifest(text, base: url)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func validateZokoManifest(_ text: String, base: URL) throws {
|
||||||
|
guard ZokoResolver.validResource(base, playlist: true) else { throw ZokoError.malformed }
|
||||||
|
_ = try rewrite(text, base: base, strict: true) { url, kind in
|
||||||
|
guard ZokoResolver.validResource(url, playlist: kind == .playlist) else { throw ZokoError.malformed }
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static func subtitle(_ track: ResolvedSubtitle) async throws -> ResolvedSubtitle {
|
static func subtitle(_ track: ResolvedSubtitle) async throws -> ResolvedSubtitle {
|
||||||
guard let proxy = track.proxy, let url = URL(string: track.url) else { return track }
|
guard let proxy = track.proxy, let url = URL(string: track.url) else { return track }
|
||||||
@@ -233,6 +253,7 @@ final class MiruroTransport: @unchecked Sendable {
|
|||||||
|
|
||||||
private func register(_ url: URL, kind: Kind, depth: Int) throws -> URL {
|
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,
|
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() }
|
url.fragment == nil, url.absoluteString.utf8.count <= 32768, let port = listener.port else { throw MiruroPlaybackError() }
|
||||||
return try lock.withLock {
|
return try lock.withLock {
|
||||||
let key = kind.rawValue + ":" + url.absoluteString
|
let key = kind.rawValue + ":" + url.absoluteString
|
||||||
@@ -338,6 +359,9 @@ final class MiruroTransport: @unchecked Sendable {
|
|||||||
func fetch(_ url: URL) async throws -> (Data, HTTPURLResponse) {
|
func fetch(_ url: URL) async throws -> (Data, HTTPURLResponse) {
|
||||||
// Same-origin fixture/source access remains allowed. A manifest or
|
// Same-origin fixture/source access remains allowed. A manifest or
|
||||||
// decrypted token cannot turn the relay into a local-network proxy.
|
// 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) }
|
if !Self.sameOrigin(url, sourceURL) { try Self.requirePublicDestination(url) }
|
||||||
let target: URL
|
let target: URL
|
||||||
let requestHeaders: String
|
let requestHeaders: String
|
||||||
@@ -349,7 +373,8 @@ final class MiruroTransport: @unchecked Sendable {
|
|||||||
} else {
|
} else {
|
||||||
target = url
|
target = url
|
||||||
// Only the embed's fixed public Referer has cross-origin scope.
|
// 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,
|
return try await MiruroMediaHTTP.read(target, headers: requestHeaders,
|
||||||
limit: resource.kind == .playlist ? 2 * 1024 * 1024 : 64 * 1024 * 1024,
|
limit: resource.kind == .playlist ? 2 * 1024 * 1024 : 64 * 1024 * 1024,
|
||||||
@@ -369,7 +394,9 @@ final class MiruroTransport: @unchecked Sendable {
|
|||||||
} else { (raw, response) = try await fetch(url) }
|
} else { (raw, response) = try await fetch(url) }
|
||||||
if resource.kind == .playlist {
|
if resource.kind == .playlist {
|
||||||
guard response.statusCode == 200, let text = String(data: raw, encoding: .utf8), text.hasPrefix("#EXTM3U") else { throw MiruroPlaybackError() }
|
guard response.statusCode == 200, let text = String(data: raw, encoding: .utf8), text.hasPrefix("#EXTM3U") else { throw MiruroPlaybackError() }
|
||||||
return (try rewrite(text, base: url, depth: resource.depth), "application/vnd.apple.mpegurl", nil)
|
return (try Self.rewrite(text, base: url, strict: requirements.zoko) { target, kind in
|
||||||
|
try self.register(target, kind: kind, depth: resource.depth + 1)
|
||||||
|
}, "application/vnd.apple.mpegurl", nil)
|
||||||
}
|
}
|
||||||
guard embed == nil || response.statusCode == 200 else { throw MiruroPlaybackError() }
|
guard embed == nil || response.statusCode == 200 else { throw MiruroPlaybackError() }
|
||||||
let data = try embed?.resourceData(raw, url: url) ?? raw
|
let data = try embed?.resourceData(raw, url: url) ?? raw
|
||||||
@@ -425,30 +452,40 @@ final class MiruroTransport: @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func rewrite(_ text: String, base: URL, depth: Int) throws -> Data {
|
private static func rewrite(_ text: String, base: URL, strict: Bool,
|
||||||
|
register: (URL, Kind) throws -> URL) throws -> Data {
|
||||||
|
guard text.utf8.count <= 2 * 1024 * 1024, text.hasPrefix("#EXTM3U") else { throw MiruroPlaybackError() }
|
||||||
let regex = try NSRegularExpression(pattern: #"URI=\"([^\"]+)\""#)
|
let regex = try NSRegularExpression(pattern: #"URI=\"([^\"]+)\""#)
|
||||||
var nextPlaylist = false
|
var nextPlaylist = false
|
||||||
|
var hasMedia = false
|
||||||
|
var hasReference = false
|
||||||
var lines: [String] = []
|
var lines: [String] = []
|
||||||
for raw in text.components(separatedBy: .newlines) {
|
for raw in text.components(separatedBy: .newlines) {
|
||||||
|
try Task.checkCancellation()
|
||||||
var line = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
var line = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if line.hasPrefix("#") {
|
if line.hasPrefix("#") {
|
||||||
|
if strict, lines.isEmpty, line != "#EXTM3U" { throw ZokoError.malformed }
|
||||||
|
if line.hasPrefix("#EXTINF:") { hasMedia = true }
|
||||||
let kind: Kind = line.hasPrefix("#EXT-X-MAP:") ? .segment :
|
let kind: Kind = line.hasPrefix("#EXT-X-MAP:") ? .segment :
|
||||||
(line.hasPrefix("#EXT-X-MEDIA:") || line.hasPrefix("#EXT-X-I-FRAME-STREAM-INF:") || line.hasPrefix("#EXT-X-RENDITION-REPORT:")) ? .playlist : .resource
|
(line.hasPrefix("#EXT-X-MEDIA:") || line.hasPrefix("#EXT-X-I-FRAME-STREAM-INF:") || line.hasPrefix("#EXT-X-RENDITION-REPORT:")) ? .playlist : .resource
|
||||||
for match in regex.matches(in: line, range: NSRange(line.startIndex..., in: line)).reversed() {
|
for match in regex.matches(in: line, range: NSRange(line.startIndex..., in: line)).reversed() {
|
||||||
guard let range = Range(match.range(at: 1), in: line),
|
guard let range = Range(match.range(at: 1), in: line),
|
||||||
let url = URL(string: String(line[range]), relativeTo: base)?.absoluteURL else { throw MiruroPlaybackError() }
|
let url = URL(string: String(line[range]), relativeTo: base)?.absoluteURL else { throw MiruroPlaybackError() }
|
||||||
let replacement = try register(url, kind: kind, depth: depth + 1)
|
let replacement = try register(url, kind)
|
||||||
|
if kind == .playlist { hasReference = true }
|
||||||
line.replaceSubrange(range, with: replacement.absoluteString)
|
line.replaceSubrange(range, with: replacement.absoluteString)
|
||||||
}
|
}
|
||||||
if line.hasPrefix("#EXT-X-STREAM-INF:") { nextPlaylist = true }
|
if line.hasPrefix("#EXT-X-STREAM-INF:") { nextPlaylist = true }
|
||||||
} else if !line.isEmpty {
|
} else if !line.isEmpty {
|
||||||
guard let url = URL(string: line, relativeTo: base)?.absoluteURL else { throw MiruroPlaybackError() }
|
guard let url = URL(string: line, relativeTo: base)?.absoluteURL else { throw MiruroPlaybackError() }
|
||||||
let kind: Kind = nextPlaylist || url.path.lowercased().hasSuffix(".m3u8") ? .playlist : .segment
|
let kind: Kind = nextPlaylist || url.path.lowercased().hasSuffix(".m3u8") ? .playlist : .segment
|
||||||
line = try register(url, kind: kind, depth: depth + 1).absoluteString
|
if nextPlaylist || hasMedia { hasReference = true }
|
||||||
|
line = try register(url, kind).absoluteString
|
||||||
nextPlaylist = false
|
nextPlaylist = false
|
||||||
}
|
}
|
||||||
lines.append(line)
|
lines.append(line)
|
||||||
}
|
}
|
||||||
|
if strict, nextPlaylist || !hasReference { throw ZokoError.malformed }
|
||||||
return Data(lines.joined(separator: "\n").utf8)
|
return Data(lines.joined(separator: "\n").utf8)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,17 +91,23 @@ struct ResolvedSource: Sendable {
|
|||||||
episode > 0 && episode <= Int(Int32.max)
|
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 {
|
static func validated(_ document: Document) throws -> ResolvedSource {
|
||||||
let id = document.identity
|
let id = document.identity
|
||||||
guard document.version == 1, id.provider == "miruro",
|
guard document.version == 1,
|
||||||
validIdentity(mediaID: id.media_id, episode: id.episode),
|
(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),
|
validURL(document.url), validHeaders(document.headers),
|
||||||
validText(document.title ?? "", limit: 4096),
|
validText(document.title ?? "", limit: 4096),
|
||||||
validSubtitles(document.subtitles) else { throw InvalidSource() }
|
validSubtitles(document.subtitles) else { throw InvalidSource() }
|
||||||
let headers = document.headers.sorted { $0.key < $1.key }
|
let headers = document.headers.sorted { $0.key < $1.key }
|
||||||
.map { "\($0.key): \($0.value)\r\n" }.joined()
|
.map { "\($0.key): \($0.value)\r\n" }.joined()
|
||||||
return ResolvedSource(url: document.url, headers: headers,
|
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)
|
subtitles: document.subtitles)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,10 @@ struct StreamQueue: Sendable {
|
|||||||
url = URL(string: item.url)!
|
url = URL(string: item.url)!
|
||||||
identity = "miruro:\(mediaID):\(episode)"
|
identity = "miruro:\(mediaID):\(episode)"
|
||||||
label = "\(url.lastPathComponent.replacingOccurrences(of: "-", with: " ")) · Episode \(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,
|
guard identities.insert(identity).inserted,
|
||||||
item.progress_file.hasPrefix("/"), !item.progress_file.contains("\0") else { throw InvalidQueue() }
|
item.progress_file.hasPrefix("/"), !item.progress_file.contains("\0") else { throw InvalidQueue() }
|
||||||
|
|||||||
@@ -8,10 +8,23 @@ private struct StreamResolutionError: LocalizedError {
|
|||||||
enum StreamURL: Sendable {
|
enum StreamURL: Sendable {
|
||||||
case youtube(URL)
|
case youtube(URL)
|
||||||
case miruro(mediaID: String, episode: Int)
|
case miruro(mediaID: String, episode: Int)
|
||||||
|
case anilist(mediaID: String, episode: Int)
|
||||||
|
|
||||||
static func parse(_ text: String) throws -> StreamURL {
|
static func parse(_ text: String) throws -> StreamURL {
|
||||||
let text = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
let text = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let url = try YouTubeResolver.validateURL(text)
|
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) }
|
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.")
|
let invalid = StreamResolutionError(message: "Enter a Miruro episode URL from barelystarted.miruro.tv with an explicit positive episode number.")
|
||||||
guard !text.unicodeScalars.contains(where: {
|
guard !text.unicodeScalars.contains(where: {
|
||||||
@@ -62,6 +75,7 @@ struct MiruroPlaybackRequirements: Sendable {
|
|||||||
var segmentOrigin: String? = nil
|
var segmentOrigin: String? = nil
|
||||||
var playlistQuery: [String: String] = [:]
|
var playlistQuery: [String: String] = [:]
|
||||||
var embed = false
|
var embed = false
|
||||||
|
var zoko = false
|
||||||
}
|
}
|
||||||
|
|
||||||
struct MiruroSelection: Sendable {
|
struct MiruroSelection: Sendable {
|
||||||
|
|||||||
@@ -241,6 +241,7 @@ final class PlayerModel: ObservableObject {
|
|||||||
private let youtubeTitleResolver: @MainActor (URL) async throws -> String?
|
private let youtubeTitleResolver: @MainActor (URL) async throws -> String?
|
||||||
private let sponsorResolver: @MainActor (String) async throws -> [SponsorSegment]
|
private let sponsorResolver: @MainActor (String) async throws -> [SponsorSegment]
|
||||||
private let miruroResolver: @MainActor (String, Int) async throws -> MiruroSources
|
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 let miruroMetadataResolver: @MainActor (String, Int) async throws -> MiruroMetadata
|
||||||
private var reportingErrorMessage: String {
|
private var reportingErrorMessage: String {
|
||||||
reportingErrorBuffer.withUnsafeBufferPointer { String(cString: $0.baseAddress!) }
|
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) },
|
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) },
|
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) },
|
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) },
|
miruroMetadataResolver: @escaping @MainActor (String, Int) async throws -> MiruroMetadata = { try await MiruroMetadataResolver.resolve(mediaID: $0, episode: $1) },
|
||||||
qualityClock: @escaping () -> Double = { ProcessInfo.processInfo.systemUptime },
|
qualityClock: @escaping () -> Double = { ProcessInfo.processInfo.systemUptime },
|
||||||
skipClock: @escaping () -> Double = { ProcessInfo.processInfo.systemUptime }) {
|
skipClock: @escaping () -> Double = { ProcessInfo.processInfo.systemUptime }) {
|
||||||
@@ -261,6 +263,7 @@ final class PlayerModel: ObservableObject {
|
|||||||
self.youtubeTitleResolver = youtubeTitleResolver
|
self.youtubeTitleResolver = youtubeTitleResolver
|
||||||
self.sponsorResolver = sponsorResolver
|
self.sponsorResolver = sponsorResolver
|
||||||
self.miruroResolver = miruroResolver
|
self.miruroResolver = miruroResolver
|
||||||
|
self.zokoResolver = zokoResolver
|
||||||
self.miruroMetadataResolver = miruroMetadataResolver
|
self.miruroMetadataResolver = miruroMetadataResolver
|
||||||
keyBindings = configuration.keys
|
keyBindings = configuration.keys
|
||||||
compactTitleProgress = configuration.compactTitleProgress
|
compactTitleProgress = configuration.compactTitleProgress
|
||||||
@@ -441,7 +444,7 @@ final class PlayerModel: ObservableObject {
|
|||||||
let url: URL
|
let url: URL
|
||||||
switch stream {
|
switch stream {
|
||||||
case .youtube(let canonical): url = canonical
|
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)])
|
queue.replaceStreams([(url: url, label: url.absoluteString)])
|
||||||
resolveStream(stream, change: .open)
|
resolveStream(stream, change: .open)
|
||||||
@@ -461,6 +464,12 @@ final class PlayerModel: ObservableObject {
|
|||||||
url = canonical
|
url = canonical
|
||||||
title = video.title
|
title = video.title
|
||||||
source = .youtube(video, height: nil)
|
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):
|
case .miruro(let mediaID, let episode):
|
||||||
let providers = try await miruroResolver(mediaID, episode)
|
let providers = try await miruroResolver(mediaID, episode)
|
||||||
let selected = try providers.select(videoProvider: miruroVideoProvider,
|
let selected = try providers.select(videoProvider: miruroVideoProvider,
|
||||||
@@ -522,6 +531,7 @@ final class PlayerModel: ObservableObject {
|
|||||||
switch stream {
|
switch stream {
|
||||||
case .youtube(let url): return url.absoluteString
|
case .youtube(let url): return url.absoluteString
|
||||||
case .miruro(let id, let episode): return "miruro:\(id):\(episode)"
|
case .miruro(let id, let episode): return "miruro:\(id):\(episode)"
|
||||||
|
case .anilist(let id, let episode): return "anilist:\(id):\(episode)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return item.url.absoluteString
|
return item.url.absoluteString
|
||||||
@@ -562,6 +572,7 @@ final class PlayerModel: ObservableObject {
|
|||||||
switch stream {
|
switch stream {
|
||||||
case .youtube(let url): resolveYouTube(url, height: nil, change: change)
|
case .youtube(let url): resolveYouTube(url, height: nil, change: change)
|
||||||
case .miruro(let mediaID, let episode): resolveMiruro(mediaID: mediaID, episode: episode, 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) {
|
private func resolveMiruro(mediaID: String, episode: Int, change: MediaChange = .open) {
|
||||||
guard let handle else { return }
|
guard let handle else { return }
|
||||||
if castDevice != nil { stopCasting(resume: false) }
|
if castDevice != nil { stopCasting(resume: false) }
|
||||||
@@ -826,6 +871,8 @@ final class PlayerModel: ObservableObject {
|
|||||||
title = try await youtube(url)
|
title = try await youtube(url)
|
||||||
case .miruro(let id, let episode):
|
case .miruro(let id, let episode):
|
||||||
title = try await miruro(id, episode).title
|
title = try await miruro(id, episode).title
|
||||||
|
case .anilist(let id, let episode):
|
||||||
|
title = try await ZokoResolver.title(mediaID: id, episode: episode)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Optional enrichment must not surface as a playback error or retry every poll.
|
// Optional enrichment must not surface as a playback error or retry every poll.
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
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"
|
||||||
|
private static let codecKey = Array("otaku-embed-v1".utf8)
|
||||||
|
|
||||||
|
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, allowMissingPage: Bool = false) 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 || (allowMissingPage && response.statusCode == 404) else { throw ZokoError.transport }
|
||||||
|
var data = Data()
|
||||||
|
if response.expectedContentLength > 0 { data.reserveCapacity(Int(response.expectedContentLength)) }
|
||||||
|
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, enrichTitle: Bool = true) 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, allowMissingPage: true)
|
||||||
|
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
|
||||||
|
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*;?\s*</script\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 lower.contains("cf-chl-") || lower.contains("challenge-platform") || lower.contains("just a moment") ||
|
||||||
|
lower.contains("captcha") || lower.contains("sorry, you have been blocked") { throw ZokoError.protection }
|
||||||
|
if visible.contains("error"), visible.contains("404"), visible.contains("couldn't find anything to play here") { throw ZokoError.unavailable }
|
||||||
|
throw ZokoError.malformed
|
||||||
|
}
|
||||||
|
guard var decoded = Data(base64Encoded: String(page[range])), !decoded.isEmpty else { throw ZokoError.malformed }
|
||||||
|
decoded.withUnsafeMutableBytes { (buffer: UnsafeMutableRawBufferPointer) in
|
||||||
|
let bytes = buffer.bindMemory(to: UInt8.self)
|
||||||
|
for i in bytes.indices { bytes[i] ^= codecKey[i % codecKey.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: 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)"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import Foundation
|
|||||||
enum PlayerCommand {
|
enum PlayerCommand {
|
||||||
case help
|
case help
|
||||||
case printDefaultConfig
|
case printDefaultConfig
|
||||||
|
case checkSource(mediaID: String, episode: Int)
|
||||||
case launch(URL?, newInstance: Bool, noConfig: Bool, progressFile: URL?)
|
case launch(URL?, newInstance: Bool, noConfig: Bool, progressFile: URL?)
|
||||||
case launchSource(sourceFile: URL, newInstance: Bool, noConfig: Bool, progressFile: URL?)
|
case launchSource(sourceFile: URL, newInstance: Bool, noConfig: Bool, progressFile: URL?)
|
||||||
case launchQueue(queueFile: URL, newInstance: Bool, noConfig: Bool)
|
case launchQueue(queueFile: URL, newInstance: Bool, noConfig: Bool)
|
||||||
@@ -15,17 +16,20 @@ enum PlayerCommand {
|
|||||||
terebi --new-instance --source-file PATH [--progress-file PATH]
|
terebi --new-instance --source-file PATH [--progress-file PATH]
|
||||||
terebi --new-instance [--no-config] --queue-file PATH
|
terebi --new-instance [--no-config] --queue-file PATH
|
||||||
terebi --enqueue [--no-config] --queue-file PATH
|
terebi --enqueue [--no-config] --queue-file PATH
|
||||||
|
terebi --check-source ZOKO_ANILIST_SUB_URL
|
||||||
terebi [--new-instance] [--no-config] -- FILE
|
terebi [--new-instance] [--no-config] -- FILE
|
||||||
terebi --help
|
terebi --help
|
||||||
terebi --default-config
|
terebi --default-config
|
||||||
|
|
||||||
Open one local file, yt-dlp-supported video, Miruro episode or resolved source and start playback,
|
Open one local file, yt-dlp-supported video, Miruro/AniList episode or resolved source and start playback,
|
||||||
resuming saved video progress. With no arguments, open or activate the player.
|
resuming saved video progress. With no arguments, open or activate the player.
|
||||||
Quote paths and URLs containing spaces or shell metacharacters.
|
Quote paths and URLs containing spaces or shell metacharacters.
|
||||||
|
|
||||||
--new-instance Launch a separate player, leaving existing playback untouched.
|
--new-instance Launch a separate player, leaving existing playback untouched.
|
||||||
--no-config Use built-in settings instead of config.toml on a fresh launch.
|
--no-config Use built-in settings instead of config.toml on a fresh launch.
|
||||||
--url URL Resolve an HTTP(S) video URL with yt-dlp, or open a Miruro episode.
|
--url URL Resolve an HTTP(S) video, Miruro episode or ZokoAnime AniList sub URL.
|
||||||
|
--check-source URL Read-only source/manifest check; one version-1 JSON result,
|
||||||
|
no player launch, configuration, progress or resume writes.
|
||||||
--source-file PATH Open a validated resolved HTTP(S) source in a new player.
|
--source-file PATH Open a validated resolved HTTP(S) source in a new player.
|
||||||
--queue-file PATH Open an ordered version-1 stream queue with per-item reports.
|
--queue-file PATH Open an ordered version-1 stream queue with per-item reports.
|
||||||
--enqueue Append a queue to the running player without interrupting playback;
|
--enqueue Append a queue to the running player without interrupting playback;
|
||||||
@@ -42,6 +46,16 @@ enum PlayerCommand {
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
static func parse(_ arguments: [String], directory: URL = URL(fileURLWithPath: FileManager.default.currentDirectoryPath, isDirectory: true)) throws -> PlayerCommand {
|
static func parse(_ arguments: [String], directory: URL = URL(fileURLWithPath: FileManager.default.currentDirectoryPath, isDirectory: true)) throws -> PlayerCommand {
|
||||||
|
// Parse this isolated command before any launch flags, paths or file reads.
|
||||||
|
if arguments.prefix(while: { $0 != "--" }).contains("--check-source") {
|
||||||
|
let invalid = ArgumentError("--check-source requires exactly one canonical ZokoAnime AniList sub URL and no other options.")
|
||||||
|
guard arguments.count == 2, arguments[0] == "--check-source",
|
||||||
|
arguments[1].utf8.count <= 512,
|
||||||
|
arguments[1] == arguments[1].trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
let parsed = try? StreamURL.parse(arguments[1]),
|
||||||
|
case .anilist(let mediaID, let episode) = parsed else { throw invalid }
|
||||||
|
return .checkSource(mediaID: mediaID, episode: episode)
|
||||||
|
}
|
||||||
var inputs = arguments
|
var inputs = arguments
|
||||||
var progressFile: URL?
|
var progressFile: URL?
|
||||||
var index = 0
|
var index = 0
|
||||||
@@ -88,7 +102,7 @@ enum PlayerCommand {
|
|||||||
func streamURL(_ text: String) throws -> URL {
|
func streamURL(_ text: String) throws -> URL {
|
||||||
switch try StreamURL.parse(text) {
|
switch try StreamURL.parse(text) {
|
||||||
case .youtube(let url): return url
|
case .youtube(let url): return url
|
||||||
case .miruro:
|
case .miruro, .anilist:
|
||||||
guard let url = URL(string: text.trimmingCharacters(in: .whitespacesAndNewlines)) else {
|
guard let url = URL(string: text.trimmingCharacters(in: .whitespacesAndNewlines)) else {
|
||||||
throw ArgumentError("Invalid stream URL.")
|
throw ArgumentError("Invalid stream URL.")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// Deliberately separate from launch: no configuration, application, player or state APIs.
|
||||||
|
enum SourceCheck {
|
||||||
|
enum Status: String, Sendable, CaseIterable {
|
||||||
|
case available, unavailable, failed
|
||||||
|
|
||||||
|
var json: String {
|
||||||
|
"{\"version\":1,\"provider\":\"zokoanime\",\"track\":\"sub\",\"status\":\"\(rawValue)\"}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func run(
|
||||||
|
mediaID: String, episode: Int, timeout: Duration = .seconds(15),
|
||||||
|
resolve: @escaping @Sendable (String, Int) async throws -> ResolvedSource = {
|
||||||
|
try await ZokoResolver.resolve(mediaID: $0, episode: $1, enrichTitle: false)
|
||||||
|
},
|
||||||
|
validate: @escaping @Sendable (ResolvedSource) async throws -> Void = {
|
||||||
|
try await MiruroTransport.checkZokoRoot($0)
|
||||||
|
}
|
||||||
|
) async -> Status {
|
||||||
|
await withTaskGroup(of: Status.self) { group in
|
||||||
|
group.addTask {
|
||||||
|
let source: ResolvedSource
|
||||||
|
do {
|
||||||
|
try Task.checkCancellation()
|
||||||
|
source = try await resolve(mediaID, episode)
|
||||||
|
try Task.checkCancellation()
|
||||||
|
} catch ZokoError.unavailable {
|
||||||
|
return Task.isCancelled ? .failed : .unavailable
|
||||||
|
} catch {
|
||||||
|
return .failed
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
try await validate(source)
|
||||||
|
try Task.checkCancellation()
|
||||||
|
return .available
|
||||||
|
} catch {
|
||||||
|
// A manifest 404, protection page or other resource failure is
|
||||||
|
// not evidence of the provider's explicit missing-episode page.
|
||||||
|
return .failed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
group.addTask {
|
||||||
|
try? await Task.sleep(for: timeout)
|
||||||
|
return .failed
|
||||||
|
}
|
||||||
|
let result = await group.next() ?? .failed
|
||||||
|
group.cancelAll()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,8 @@ enum TerebiCLI {
|
|||||||
print(PlayerCommand.usage)
|
print(PlayerCommand.usage)
|
||||||
case .printDefaultConfig:
|
case .printDefaultConfig:
|
||||||
print(PlayerConfiguration.defaultTOML, terminator: "")
|
print(PlayerConfiguration.defaultTOML, terminator: "")
|
||||||
|
case .checkSource(let mediaID, let episode):
|
||||||
|
print(await SourceCheck.run(mediaID: mediaID, episode: episode).json)
|
||||||
case .launch(let url, let newInstance, let noConfig, let progressFile):
|
case .launch(let url, let newInstance, let noConfig, let progressFile):
|
||||||
await launch(url: url, sourceFile: nil, newInstance: newInstance, noConfig: noConfig, progressFile: progressFile)
|
await launch(url: url, sourceFile: nil, newInstance: newInstance, noConfig: noConfig, progressFile: progressFile)
|
||||||
case .launchSource(let sourceFile, let newInstance, let noConfig, let progressFile):
|
case .launchSource(let sourceFile, let newInstance, let noConfig, let progressFile):
|
||||||
|
|||||||
@@ -563,7 +563,7 @@ struct Player {
|
|||||||
status = PLAYER_OPENING;
|
status = PLAYER_OPENING;
|
||||||
publish();
|
publish();
|
||||||
if (network) {
|
if (network) {
|
||||||
const bool resolved = command.mediaURL.starts_with("miruro:");
|
const bool resolved = command.mediaURL.starts_with("miruro:") || command.mediaURL.starts_with("anilist:");
|
||||||
auto *primary = openNetworkInput(0, command.path, command.videoHeaders, command.live, resolved);
|
auto *primary = openNetworkInput(0, command.path, command.videoHeaders, command.live, resolved);
|
||||||
format = primary->format;
|
format = primary->format;
|
||||||
openTrack(1, format, primary, AVMEDIA_TYPE_VIDEO, -1);
|
openTrack(1, format, primary, AVMEDIA_TYPE_VIDEO, -1);
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ typedef struct PlayerNetworkSource {
|
|||||||
double duration;
|
double duration;
|
||||||
bool is_live;
|
bool is_live;
|
||||||
// Original HTTP(S) video URL (canonical for YouTube), or stable
|
// Original HTTP(S) video URL (canonical for YouTube), or stable
|
||||||
// miruro:<base64url media_id>:<positive episode> identity, never an extracted CDN URL.
|
// miruro:<opaque media_id>:<episode> or anilist:<decimal ID>:<episode> identity, never an extracted CDN URL.
|
||||||
// NULL/empty means no reporting for this source.
|
// NULL/empty means no reporting for this source.
|
||||||
const char *media_url;
|
const char *media_url;
|
||||||
// Initial restored position, applied before publishing ready state. Zero
|
// Initial restored position, applied before publishing ready state. Zero
|
||||||
|
|||||||
@@ -93,12 +93,20 @@ bool ProgressReporter::validMediaURL(std::string_view url) noexcept {
|
|||||||
|
|
||||||
bool ProgressReporter::validMediaIdentity(std::string_view identity) noexcept {
|
bool ProgressReporter::validMediaIdentity(std::string_view identity) noexcept {
|
||||||
if (validMediaURL(identity)) return true;
|
if (validMediaURL(identity)) return true;
|
||||||
constexpr std::string_view prefix = "miruro:";
|
const bool anilist = identity.starts_with("anilist:");
|
||||||
if (identity.substr(0, prefix.size()) != prefix) return false;
|
const std::string_view prefix = anilist ? "anilist:" : "miruro:";
|
||||||
|
if (!identity.starts_with(prefix)) return false;
|
||||||
const auto separator = identity.find(':', prefix.size());
|
const auto separator = identity.find(':', prefix.size());
|
||||||
if (separator == std::string_view::npos) return false;
|
if (separator == std::string_view::npos) return false;
|
||||||
const auto id = identity.substr(prefix.size(), separator - prefix.size());
|
const auto id = identity.substr(prefix.size(), separator - prefix.size());
|
||||||
if (id.empty() || id.size() > 512) return false;
|
if (id.empty() || id.size() > 512) return false;
|
||||||
|
if (anilist) {
|
||||||
|
if (id.front() < '1' || id.front() > '9') return false;
|
||||||
|
for (char c : id) if (c < '0' || c > '9') return false;
|
||||||
|
int value = 0;
|
||||||
|
const auto parsed = std::from_chars(id.data(), id.data() + id.size(), value);
|
||||||
|
if (parsed.ec != std::errc{} || parsed.ptr != id.data() + id.size() || value <= 0) return false;
|
||||||
|
}
|
||||||
for (char c : id) {
|
for (char c : id) {
|
||||||
if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
|
if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
|
||||||
(c >= '0' && c <= '9') || c == '-' || c == '_')) return false;
|
(c >= '0' && c <= '9') || c == '-' || c == '_')) return false;
|
||||||
@@ -186,13 +194,17 @@ bool ProgressReporter::emit(const char *event) noexcept {
|
|||||||
try {
|
try {
|
||||||
std::string record;
|
std::string record;
|
||||||
record.reserve(256);
|
record.reserve(256);
|
||||||
const bool miruro = mediaURL.compare(0, 7, "miruro:") == 0;
|
const bool anilist = mediaURL.starts_with("anilist:");
|
||||||
record += miruro ? "{\"version\":2,\"event\":\"" : "{\"version\":1,\"event\":\"";
|
const bool resolved = anilist || mediaURL.starts_with("miruro:");
|
||||||
|
record += resolved ? "{\"version\":2,\"event\":\"" : "{\"version\":1,\"event\":\"";
|
||||||
record += event;
|
record += event;
|
||||||
if (miruro) {
|
if (resolved) {
|
||||||
const auto separator = mediaURL.find(':', 7);
|
const size_t prefix = anilist ? 8 : 7;
|
||||||
record += "\",\"media_identity\":{\"provider\":\"miruro\",\"media_id\":\"";
|
const auto separator = mediaURL.find(':', prefix);
|
||||||
record.append(mediaURL, 7, separator - 7);
|
record += "\",\"media_identity\":{\"provider\":\"";
|
||||||
|
record += anilist ? "anilist" : "miruro";
|
||||||
|
record += "\",\"media_id\":\"";
|
||||||
|
record.append(mediaURL, prefix, separator - prefix);
|
||||||
record += "\",\"episode\":";
|
record += "\",\"episode\":";
|
||||||
record.append(mediaURL, separator + 1, std::string::npos);
|
record.append(mediaURL, separator + 1, std::string::npos);
|
||||||
record += "},\"position_seconds\":";
|
record += "},\"position_seconds\":";
|
||||||
|
|||||||
@@ -18,6 +18,24 @@ Provider-selection verification: resolver/configuration checks and the app/CLI b
|
|||||||
|
|
||||||
Final live rerun: the optional metadata endpoint stopped supplying usable enrichment, so the existing complete URL smoke and standalone live metadata check failed their metadata assertion after provider video had decoded. A separate native `PlayerModel` smoke, using the supported absent-enrichment outcome, then passed actual video/audio, default captions and both acknowledged cue seeks for all five choices listed above. The original metadata assertion was not removed or weakened; upstream metadata availability remains a live-service limitation.
|
Final live rerun: the optional metadata endpoint stopped supplying usable enrichment, so the existing complete URL smoke and standalone live metadata check failed their metadata assertion after provider video had decoded. A separate native `PlayerModel` smoke, using the supported absent-enrichment outcome, then passed actual video/audio, default captions and both acknowledged cue seeks for all five choices listed above. The original metadata assertion was not removed or weakened; upstream metadata availability remains a live-service limitation.
|
||||||
|
|
||||||
|
## AniList / ZokoAnime addition
|
||||||
|
|
||||||
|
The Miruro contract below remains intact for old collections. New AniList catalog launches use `https://zokoanime.video/stream/ani/<canonical decimal AniList ID>/<canonical positive episode>/sub`; IDs/episodes are positive `Int32` values. The deployed route is `ani`, not `anilist`. Catalog numbers do not establish provider availability; unknown totals must stay unknown. No Miruro mapping, title matching or collection migration is performed.
|
||||||
|
|
||||||
|
Terebi resolves bounded static `window.__P` data over ordinary verified HTTPS, then uses the existing scoped native HLS relay with a Zoko-only exact-origin policy. The optional title lookup is bounded AniList GraphQL metadata; unavailable metadata yields an explicit catalog-ID/episode fallback. There is no dub fallback, browser/challenge execution, telemetry fetch or persistent source URL. See [direct playback and security limits](../README.md#anilist--zokoanime-direct-playback).
|
||||||
|
|
||||||
|
Use stable `anilist:<ID>:<episode>` for resume and report registration. Queue/enqueue documents keep their existing version-1 shape and use the watch URL. Version-2 reports keep exactly the existing event/position/duration/eligibility fields, substituting `media_identity: {"provider":"anilist","media_id":"1","episode":1}`. Validate against the exact expected provider/ID/episode; never reinterpret an old `miruro` identity as AniList. Explicit external source documents also accept the `anilist` identity namespace with decimal IDs, retaining the existing strict native same-origin resource policy.
|
||||||
|
|
||||||
|
Browsing clients may invoke `terebi --check-source URL` through their configured player executable. This isolated command accepts only the canonical AniList sub URL and no other flags. It reads fresh static configuration plus the validated root HLS manifest using the playback resolver/transport, with a 15-second total deadline and cancellation; it skips title enrichment, nested manifests, subtitles and segments. It launches no app/player/audio/relay, reads no config and writes no resume/progress/config state.
|
||||||
|
|
||||||
|
The sole stdout line is `{"version":1,"provider":"zokoanime","track":"sub","status":"available"}`; the other valid statuses are `unavailable` (explicit provider missing-episode page only) and `failed` (network/protection/malformed/manifest/deadline errors). Completed checks exit zero for every status, invalid input exits nonzero without JSON, and raw source URLs/tokens/errors never appear in output. `available` is root-source accessibility, not proof of every segment or full playback. Rebuild/reinstall the CLI before integration; an older CLI, malformed output or process failure means a failed check, not unavailable playback. Check selected then visible episodes, not a full series scan; use a refreshable session cache, not persisted timestamps or source addresses.
|
||||||
|
|
||||||
|
`make check-source` covers the parser/result and manifest/error/cancellation boundaries plus actual CLI hostile-input paths. Optional `make check-source-live` invokes the real CLI outside its bundle with invalid configuration and isolated state directories, asserting episode 1 available and episode 27 unavailable without state writes. These read-only checks do not initialize native Core Audio or download any media segments.
|
||||||
|
|
||||||
|
`make check-zoko check-reporting check-cli check-stream check-progress` is the focused non-GUI regression command. Optional `make check-zoko-live` uses the real native decoder (paused frame stepping, ten-minute seek/decode, restored position), VTT text at acknowledged positions and actual version-2 progress. It does not start GUI/audio playback or verify visible rendering/full episodes; Core Audio initializes a device.
|
||||||
|
|
||||||
|
Integrated verification on 2026-10-25 passed all six targets above, including live fresh AniList 1 episode 1 resolution and actual native frame/seek/VTT/resume/version-2 reporting. The live run emitted redacted FFmpeg warnings/errors despite completing its assertions; it was not warning-free. The deployed static assignment omits its semicolon, which the parser explicitly supports without evaluating JavaScript. No GUI/browser test, audio-output test or full-episode verification was performed.
|
||||||
|
|
||||||
## Launch contract
|
## Launch contract
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
Reference in New Issue
Block a user