feat: add headless Zoko source checks
This commit is contained in:
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -46,7 +46,7 @@ struct ZokoCheck {
|
|||||||
let missing = "<span>// Error</span><span>404</span><h1>Not <span>found.</span></h1><p>We couldn't find anything to play here.</p>"
|
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) }
|
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>",
|
for protected in ["<title>Just a moment</title>", "<script src='/cdn-cgi/challenge-platform/x'></script>",
|
||||||
"<h1>Sorry, you have been blocked</h1>"] {
|
"<h1>Sorry, you have been blocked</h1>", missing + "<div>captcha</div>"] {
|
||||||
try expect(.protection) { _ = try ZokoResolver.parse(page: protected, mediaID: "1", episode: 1) }
|
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)] {
|
for malformed in ["<html></html>", "window.__P='broken';", try page(config) + page(config), String(repeating: "x", count: ZokoResolver.bodyLimit + 1)] {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ 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 Sources/App/ZokoResolver.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 $@
|
||||||
|
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ For native episode-URL playback, ordered stream queues, external resolved source
|
|||||||
terebi --url 'https://zokoanime.video/stream/ani/1/1/sub'
|
terebi --url 'https://zokoanime.video/stream/ani/1/1/sub'
|
||||||
```
|
```
|
||||||
|
|
||||||
The exact `ani` route is supported, not the site's broken documented `anilist` route. IDs and episode numbers must be canonical positive decimal integers within `Int32.max`. Only `sub` is admitted; there is no silent dub fallback. Catalog episode numbers are **unverified availability**, not a provider inventory: Terebi resolves the selected episode on demand and distinguishes an unavailable episode from network, access/protection, and malformed configuration failures.
|
The 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.
|
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.
|
||||||
|
|
||||||
@@ -85,6 +85,19 @@ The existing process-local loopback HLS relay normalizes playlists for the nativ
|
|||||||
|
|
||||||
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.
|
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:
|
||||||
@@ -148,6 +161,8 @@ For the focused native compact-queue smoke (transparent, noninteractive app wind
|
|||||||
`make check-sponsorblock` exercises the bounded native lookup and duration validation with local fixtures; `.build/sponsorblock-check --live` performs read-only public API probes. `make check-resolved` includes SponsorBlock opt-in/failure isolation, same-video cancellation, direct/saved/queue/enqueue launches, quality changes and Miruro separation. The native slider regression (`.build/cast-playback-check --slider-regression`, after building that target) checks crimson painting across chapter gaps, buffering, active emphasis, hover and seeking.
|
`make check-sponsorblock` exercises the bounded native lookup and duration validation with local fixtures; `.build/sponsorblock-check --live` performs read-only public API probes. `make check-resolved` includes SponsorBlock opt-in/failure isolation, same-video cancellation, direct/saved/queue/enqueue launches, quality changes and Miruro separation. The native slider regression (`.build/cast-playback-check --slider-regression`, after building that target) checks crimson painting across chapter gaps, buffering, active emphasis, hover and seeking.
|
||||||
`make check-zoko check-reporting check-cli check-stream check-progress` covers provider admission/codec/error categories, bounded resources, scoped CDN policy, isolated resume and report identity without launching a GUI. Optional `make check-zoko-live` resolves current AniList 1 episode 1, loads VTT, and uses the **actual native player** while paused to decode/step video, seek/decode at 600 seconds, restore position and assert version-2 AniList progress. It creates no windows and does not start audio playback, but production Core Audio still initializes an output device. No tokenized addresses are printed or retained after the check. This is not a full-episode, audio-output, pixel/subtitle-rendering or UI smoke test, nor proof of availability for other catalog entries. Full `make check` still launches GUI/audio checks.
|
`make check-zoko check-reporting check-cli check-stream check-progress` covers provider admission/codec/error categories, bounded resources, scoped CDN policy, isolated resume and report identity without launching a GUI. Optional `make check-zoko-live` resolves current AniList 1 episode 1, loads VTT, and uses the **actual native player** while paused to decode/step video, seek/decode at 600 seconds, restore position and assert version-2 AniList progress. It creates no windows and does not start audio playback, but production Core Audio still initializes an output device. No tokenized addresses are printed or retained after the check. This is not a full-episode, audio-output, pixel/subtitle-rendering or UI smoke test, nor proof of availability for other catalog entries. Full `make check` still launches GUI/audio checks.
|
||||||
|
|
||||||
|
`make check-source` exercises the public parser/result contract, error redaction, shared manifest validation, total deadline/cancellation and actual CLI rejection paths without GUI/audio or native-player linking. Optional `make check-source-live` runs the **actual CLI**, copied outside its app bundle with an intentionally invalid configuration and isolated state directories: AniList 1 episode 1 must return `available`, and episode 27 must return `unavailable`, with no state writes. These source-only probes fetch no segments and are distinct from `check-zoko-live`'s native playback checks.
|
||||||
|
|
||||||
|
|
||||||
## Acknowledgements
|
## Acknowledgements
|
||||||
|
|
||||||
|
|||||||
@@ -189,6 +189,25 @@ final class MiruroTransport: @unchecked Sendable {
|
|||||||
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 }
|
||||||
@@ -375,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
|
||||||
@@ -431,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ enum ZokoResolver {
|
|||||||
completionHandler: @escaping (URLRequest?) -> Void) { completionHandler(nil) }
|
completionHandler: @escaping (URLRequest?) -> Void) { completionHandler(nil) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func fetch(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
|
private static func fetch(_ request: URLRequest, allowMissingPage: Bool = false) async throws -> (Data, HTTPURLResponse) {
|
||||||
let configuration = URLSessionConfiguration.ephemeral
|
let configuration = URLSessionConfiguration.ephemeral
|
||||||
configuration.httpShouldSetCookies = false
|
configuration.httpShouldSetCookies = false
|
||||||
configuration.httpCookieStorage = nil
|
configuration.httpCookieStorage = nil
|
||||||
@@ -46,7 +46,7 @@ enum ZokoResolver {
|
|||||||
guard let response = response as? HTTPURLResponse, response.url == request.url else { throw ZokoError.transport }
|
guard let response = response as? HTTPURLResponse, response.url == request.url else { throw ZokoError.transport }
|
||||||
guard response.expectedContentLength <= bodyLimit else { throw ZokoError.malformed }
|
guard response.expectedContentLength <= bodyLimit else { throw ZokoError.malformed }
|
||||||
if response.statusCode == 403 || response.statusCode == 429 || response.statusCode == 503 { throw ZokoError.protection }
|
if response.statusCode == 403 || response.statusCode == 429 || response.statusCode == 503 { throw ZokoError.protection }
|
||||||
guard response.statusCode == 200 else { throw ZokoError.transport }
|
guard response.statusCode == 200 || (allowMissingPage && response.statusCode == 404) else { throw ZokoError.transport }
|
||||||
var data = Data()
|
var data = Data()
|
||||||
if response.expectedContentLength > 0 { data.reserveCapacity(Int(response.expectedContentLength)) }
|
if response.expectedContentLength > 0 { data.reserveCapacity(Int(response.expectedContentLength)) }
|
||||||
for try await byte in bytes {
|
for try await byte in bytes {
|
||||||
@@ -62,14 +62,16 @@ enum ZokoResolver {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static func resolve(mediaID: String, episode: Int) async throws -> ResolvedSource {
|
static func resolve(mediaID: String, episode: Int, enrichTitle: Bool = true) async throws -> ResolvedSource {
|
||||||
guard ResolvedSource.validAniListIdentity(mediaID: mediaID, episode: episode) else { throw ZokoError.malformed }
|
guard ResolvedSource.validAniListIdentity(mediaID: mediaID, episode: episode) else { throw ZokoError.malformed }
|
||||||
var request = URLRequest(url: URL(string: "https://zokoanime.video/stream/ani/\(mediaID)/\(episode)/sub")!)
|
var request = URLRequest(url: URL(string: "https://zokoanime.video/stream/ani/\(mediaID)/\(episode)/sub")!)
|
||||||
request.timeoutInterval = 20
|
request.timeoutInterval = 20
|
||||||
request.setValue("text/html", forHTTPHeaderField: "Accept")
|
request.setValue("text/html", forHTTPHeaderField: "Accept")
|
||||||
let (data, response) = try await fetch(request)
|
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 }
|
guard response.mimeType == "text/html", let page = String(data: data, encoding: .utf8) else { throw ZokoError.malformed }
|
||||||
let source = try parse(page: page, mediaID: mediaID, episode: episode)
|
let source = try parse(page: page, mediaID: mediaID, episode: episode)
|
||||||
|
guard response.statusCode == 200 else { throw ZokoError.transport }
|
||||||
|
guard enrichTitle else { return source }
|
||||||
let title = (try? await Self.title(mediaID: mediaID, episode: episode)) ?? source.title
|
let title = (try? await Self.title(mediaID: mediaID, episode: episode)) ?? source.title
|
||||||
try Task.checkCancellation()
|
try Task.checkCancellation()
|
||||||
return ResolvedSource(url: source.url, headers: source.headers, identity: source.identity,
|
return ResolvedSource(url: source.url, headers: source.headers, identity: source.identity,
|
||||||
@@ -85,9 +87,9 @@ enum ZokoResolver {
|
|||||||
guard !page.contains("window.__P") else { throw ZokoError.malformed }
|
guard !page.contains("window.__P") else { throw ZokoError.malformed }
|
||||||
let lower = page.lowercased()
|
let lower = page.lowercased()
|
||||||
let visible = lower.replacingOccurrences(of: "<[^>]*>", with: " ", options: .regularExpression)
|
let visible = lower.replacingOccurrences(of: "<[^>]*>", with: " ", options: .regularExpression)
|
||||||
if visible.contains("error"), visible.contains("404"), visible.contains("couldn't find anything to play here") { throw ZokoError.unavailable }
|
|
||||||
if lower.contains("cf-chl-") || lower.contains("challenge-platform") || lower.contains("just a moment") ||
|
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 }
|
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
|
throw ZokoError.malformed
|
||||||
}
|
}
|
||||||
guard var decoded = Data(base64Encoded: String(page[range])), !decoded.isEmpty else { throw ZokoError.malformed }
|
guard var decoded = Data(base64Encoded: String(page[range])), !decoded.isEmpty else { throw ZokoError.malformed }
|
||||||
|
|||||||
@@ -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,6 +16,7 @@ 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
|
||||||
@@ -26,6 +28,8 @@ enum PlayerCommand {
|
|||||||
--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, Miruro episode or ZokoAnime AniList sub URL.
|
--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
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -20,12 +20,18 @@ Final live rerun: the optional metadata endpoint stopped supplying usable enrich
|
|||||||
|
|
||||||
## AniList / ZokoAnime addition
|
## 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 are unverified availability and resolve on selection; unknown totals must stay unknown. No Miruro mapping, title matching or collection migration is performed.
|
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).
|
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.
|
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.
|
`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.
|
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.
|
||||||
|
|||||||
Reference in New Issue
Block a user