feat: resolve AniList episodes through ZokoAnime

This commit is contained in:
2026-10-25 15:46:35 +01:00
parent 8c2a18df48
commit ab61d83c69
16 changed files with 492 additions and 21 deletions
+16
View File
@@ -36,6 +36,9 @@ struct PlayerCommandChecks {
let miruro = URL(string: "https://barelystarted.miruro.tv/watch/OERe_a0YS4h-DlsBDOeXwX7HOO6316tw/the-apothecary-diaries-season-3?ep=1")!
require(try opened([miruro.absoluteString]) == miruro, "Miruro positional URLs retain episode page identity")
require(try opened(["--url", miruro.absoluteString]) == miruro, "explicit Miruro URLs forward without network resolution")
let anilist = URL(string: "https://zokoanime.video/stream/ani/1/1/sub")!
require(try opened([anilist.absoluteString]) == anilist &&
opened(["--url", anilist.absoluteString]) == anilist, "AniList watch URLs forward without yt-dlp or network resolution")
let generic = URL(string: "https://vimeo.com/76979871?quality=original")!
require(try opened([generic.absoluteString]) == generic &&
opened(["--url", generic.absoluteString]) == generic,
@@ -112,6 +115,11 @@ struct PlayerCommandChecks {
catch {}
}
let original = source
source["identity"] = ["provider": "anilist", "media_id": "1", "episode": 12]
try saveSource()
let anilistSource = try ResolvedSource.load(from: sourceFile)
require(anilistSource.identity == "anilist:1:12" && anilistSource.title == "AniList 1 episode 12",
"source-file identity admits AniList without pretending it is Miruro")
let invalidChanges: [[String: Any]] = [
["version": 2], ["url": "https://user:secret@cdn.example/video"],
["url": "file:///secret"], ["url": "https://cdn.example/video\nsecret"],
@@ -120,6 +128,9 @@ struct PlayerCommandChecks {
["identity": ["provider": "miruro", "media_id": "bad:id", "episode": 1]],
["identity": ["provider": "miruro", "media_id": "opaque", "episode": 0]],
["identity": ["provider": "miruro", "media_id": "opaque", "episode": 1.5]],
["identity": ["provider": "anilist", "media_id": "01", "episode": 1]],
["identity": ["provider": "anilist", "media_id": "opaque", "episode": 1]],
["identity": ["provider": "anilist", "media_id": "1", "episode": 2147483648]],
["subtitles": Array(repeating: (original["subtitles"] as! [[String: Any]])[0], count: 2)]
]
for change in invalidChanges {
@@ -175,6 +186,11 @@ struct PlayerCommandChecks {
require(parsedQueue.entries.map(\.url) == [miruro, canonical], "stream queue order is supplied, never filename-sorted")
require(parsedQueue.entries[0].identity.hasPrefix("miruro:") &&
parsedQueue.entries[1].identity == canonical.absoluteString, "stable identities exclude slugs and transport URLs")
let anilistReport = privateDirectory.appendingPathComponent("anilist.jsonl").path
try saveQueue(["version": 1, "items": entries + [["url": anilist.absoluteString, "progress_file": anilistReport]]])
let mixedQueue = try StreamQueue.load(from: queueFile)
require(mixedQueue.entries.map(\.identity) == [parsedQueue.entries[0].identity, canonical.absoluteString, "anilist:1:1"],
"mixed queue retains distinct Miruro, YouTube and AniList identities")
try saveQueue(["version": 1, "items": [["url": generic.absoluteString, "progress_file": youtubeReport]]])
let genericQueue = try StreamQueue.load(from: queueFile)
require(genericQueue.entries[0].url == generic &&
+24
View File
@@ -302,6 +302,30 @@ int main(int argc, char **argv) {
[row[@"completion_eligible"] boolValue], "Miruro retains terminal snapshot semantics");
}
require(ProgressReporter::validMediaIdentity("anilist:1:1") &&
ProgressReporter::validMediaIdentity("anilist:2147483647:2147483647"), "canonical AniList identities accepted");
for (const char *invalid : {"anilist:01:1", "anilist:0:1", "anilist:-1:1", "anilist:a:1",
"anilist:1:01", "anilist:1:0", "anilist:1:2147483648", "anilist:2147483648:1",
"anilist:1:1:sub", "anilist:1:1?token=secret"})
require(!ProgressReporter::validMediaIdentity(invalid), "noncanonical AniList identities rejected");
const auto anilistPath = root + "/anilist.jsonl";
{
ProgressReporter reporter(anilistPath, [] { return 0.; });
reporter.begin("anilist:1:1");
reporter.observe(sample(PLAYER_PAUSED, 42));
reporter.finish();
require(reporter.error().empty(), "AniList progress drains successfully");
}
rows = records(anilistPath);
require(rows.count == 2, "AniList initial and closed snapshots");
for (NSDictionary *row in rows) {
require([row[@"version"] intValue] == 2 && row[@"media_url"] == nil &&
[row[@"media_identity"][@"provider"] isEqual:@"anilist"] &&
[row[@"media_identity"][@"media_id"] isEqual:@"1"] &&
[row[@"media_identity"][@"episode"] intValue] == 1 &&
[row[@"position_seconds"] doubleValue] == 42, "AniList v2 schema remains separate from Miruro");
}
const auto clockFailurePath = root + "/clock-failure.jsonl";
{
ProgressReporter reporter(clockFailurePath, []() -> double { throw std::runtime_error("clock failure"); });
+166
View File
@@ -0,0 +1,166 @@
import Foundation
@main
struct ZokoCheck {
struct Failure: LocalizedError {
let message: String
var errorDescription: String? { message }
}
static func require(_ condition: @autoclosure () -> Bool, _ message: String) throws {
if !condition() { throw Failure(message: message) }
}
static func expect(_ category: ZokoError, _ operation: () throws -> Void) throws {
do { try operation() }
catch let error as ZokoError where error == category { return }
throw Failure(message: "Incorrect provider error category")
}
static func page(_ config: [String: Any]) throws -> String {
var data = Array(try JSONSerialization.data(withJSONObject: config))
let key = Array("otaku-embed-v1".utf8)
for i in data.indices { data[i] ^= key[i % key.count] }
return "<script>window.__P = '\(Data(data).base64EncodedString())';</script>"
}
static func checks() async throws {
let watch = "https://zokoanime.video/stream/ani/1/1/sub"
guard case .anilist(let id, let episode) = try StreamURL.parse(watch) else { throw Failure(message: "AniList URL classification") }
try require(id == "1" && episode == 1, "AniList stable ID admission")
for bad in ["http://zokoanime.video/stream/ani/1/1/sub", "https://zokoanime.video/stream/anilist/1/1/sub",
watch + "?token=bad", watch + "#bad", watch + "/", watch.replacingOccurrences(of: "/1/1/", with: "/01/1/"),
watch.replacingOccurrences(of: "/1/sub", with: "/01/sub"), watch.replacingOccurrences(of: "/1/sub", with: "/2147483648/sub"),
watch.replacingOccurrences(of: "/sub", with: "/dub"), "https://a@zokoanime.video/stream/ani/1/1/sub",
"https://zokoanime.video/stream/ani/%31/1/sub"] {
do { _ = try StreamURL.parse(bad) }
catch { continue }
throw Failure(message: "Noncanonical provider URL accepted")
}
var config: [String: Any] = ["src": "https://hls.dramahot.top/master.m3u8?token=fixture",
"subtitles": [["src": "https://hls.dramahot.top/en.vtt", "lang": "en", "label": "English", "default": true]]]
let source = try ZokoResolver.parse(page: page(config), mediaID: "1", episode: 1)
try require(source.identity == "anilist:1:1" && source.requirements.zoko, "native scoped transport selected")
try require(source.title == "AniList 1 · Episode 1 (sub)" && source.subtitles.count == 1 && source.subtitles[0].isDefault,
"honest fallback title and VTT metadata")
try require(source.headers == ZokoResolver.referer && source.subtitles[0].headers == ZokoResolver.referer, "only fixed public Referer scoped to declared CDN")
let missing = "<span>// Error</span><span>404</span><h1>Not <span>found.</span></h1><p>We couldn't find anything to play here.</p>"
try expect(.unavailable) { _ = try ZokoResolver.parse(page: missing, mediaID: "1", episode: 27) }
for protected in ["<title>Just a moment</title>", "<script src='/cdn-cgi/challenge-platform/x'></script>"] {
try expect(.protection) { _ = try ZokoResolver.parse(page: protected, mediaID: "1", episode: 1) }
}
for malformed in ["<html></html>", "window.__P='broken';", try page(config) + page(config), String(repeating: "x", count: ZokoResolver.bodyLimit + 1)] {
try expect(.malformed) { _ = try ZokoResolver.parse(page: malformed, mediaID: "1", episode: 1) }
}
for bad in ["https://evil.example/master.m3u8", "https://hls1.drama1.cfd/master.m3u8", "http://hls.dramahot.top/master.m3u8", "https://hls.dramahot.top:8443/master.m3u8",
"https://hls.dramahot.top.evil.example/master.m3u8", "https://user@hls.dramahot.top/master.m3u8", "https://127.0.0.1/master.m3u8"] {
config["src"] = bad
try expect(.malformed) { _ = try ZokoResolver.parse(page: page(config), mediaID: "1", episode: 1) }
}
config["src"] = "https://hls.dramahot.top/master.m3u8"
let invalidSubtitles: [[[String: Any]]] = [
[["src": "https://evil.example/en.vtt", "lang": "en", "label": "English"]],
[["src": "https://hls.dramahot.top/en.vtt", "lang": "en", "label": "bad\nlabel"]],
Array(repeating: ["src": "https://hls.dramahot.top/en.vtt", "lang": "en", "label": "English", "default": true], count: 2),
Array(repeating: ["src": "https://hls.dramahot.top/en.vtt", "lang": "en", "label": "English"], count: 33)]
for subtitles in invalidSubtitles {
config["subtitles"] = subtitles
try expect(.malformed) { _ = try ZokoResolver.parse(page: page(config), mediaID: "1", episode: 1) }
}
try require(ZokoResolver.validResource(URL(string: "https://hls1.drama1.cfd/segment.jpg")!), "exact demonstrated segment origin admitted")
for bad in ["https://evil.example/segment.ts", "http://hls1.drama1.cfd/segment.ts", "https://hls1.drama1.cfd:8443/segment.ts", "https://hls1.drama1.cfd.evil.example/segment.ts", "https://127.0.0.1/segment.ts"] {
try require(!ZokoResolver.validResource(URL(string: bad)!), "hostile manifest resource rejected before request")
}
let suite = "terebi.zoko-check." + UUID().uuidString
let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) }
var progress = PlaybackProgress(defaults: defaults)
let identity = URL(string: source.identity)!
progress.save(123, for: identity)
progress.save(456, for: URL(string: "miruro:1:1")!)
progress = PlaybackProgress(defaults: defaults)
try require(progress.position(for: identity) == 123 && progress.position(for: URL(string: "miruro:1:1")!) == 456,
"AniList resume persists separately from Miruro")
print("PASS: AniList URL/identity, static codec, unavailable/protection/malformed categories, bounded resources/subtitles, narrow CDN policy and isolated resume")
}
// No NSApplication, window or audio playback. The production player opens and
// decodes while paused; acknowledged frame stepping proves real video decode.
static func until(_ player: OpaquePointer, _ predicate: (PlayerState) -> Bool) async throws -> PlayerState {
let deadline = ContinuousClock.now.advanced(by: .seconds(60))
while ContinuousClock.now < deadline {
let state = player_get_state(player)
if state.status == PLAYER_ERROR { throw Failure(message: "Native player failed (source details redacted)") }
if predicate(state) { return state }
try await Task.sleep(for: .milliseconds(10))
}
throw Failure(message: "Native player acknowledgement timed out")
}
static func live() async throws {
let source = try await MiruroTransport.prepare(ZokoResolver.resolve(mediaID: "1", episode: 1))
defer { _fixLifetime(source) }
try require(source.identity == "anilist:1:1" && source.headers.isEmpty, "native relay preserves identity without upstream headers")
guard let track = source.subtitles.first(where: \.isDefault) else { throw Failure(message: "Live source has no default subtitle") }
let document = try await WebVTTDocument.load(url: track.url, headers: track.headers)
guard let cue = document.cues.first(where: { !$0.text.isEmpty && $0.end > $0.start }) else { throw Failure(message: "Live VTT contains no active cues") }
let directory = FileManager.default.temporaryDirectory.appendingPathComponent("terebi-zoko-" + UUID().uuidString)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700])
defer { try? FileManager.default.removeItem(at: directory) }
let report = directory.appendingPathComponent("progress.jsonl")
guard let player = player_create() else { throw Failure(message: "Native player creation") }
var destroyed = false
defer { if !destroyed { player_destroy(player) } }
var error = [CChar](repeating: 0, count: 512)
try require(report.path.withCString { player_set_progress_file(player, $0, &error) }, "Native progress file admission")
func open(_ position: Double) -> Bool {
source.url.withCString { url in
source.identity.withCString { identity in
var input = PlayerNetworkSource(video_url: url, audio_url: nil, video_headers: nil, audio_headers: nil,
duration: 0, is_live: false, media_url: identity, initial_position: position)
return player_open_network(player, &input)
}
}
}
try require(open(0), "Native AniList source admission")
var state = try await until(player) { $0.status == PLAYER_PAUSED && $0.has_video && $0.has_audio && !$0.seek_pending }
try require(state.can_seek && state.duration > 600, "Native tracks/duration/seekability")
let revision = state.revision
try require(player_step_frame(player, 1), "Native first-frame step")
state = try await until(player) { $0.status == PLAYER_PAUSED && !$0.seek_pending && $0.revision != revision && $0.position > 0 }
try require(player_seek(player, (cue.start + cue.end) / 2), "Native subtitle-position seek")
state = try await until(player) { !$0.seek_pending && $0.position >= cue.start && $0.position < cue.end }
try require(!document.text(at: state.position).isEmpty, "Subtitles render at acknowledged native position")
let beforeSeek = state.revision
try require(player_seek(player, 600), "Native ten-minute seek admission")
state = try await until(player) { !$0.seek_pending && $0.revision != beforeSeek && abs($0.position - 600) < 0.1 }
let beforeStep = state.position
let seekRevision = state.revision
try require(player_step_frame(player, 1), "Native post-seek decode step")
state = try await until(player) { !$0.seek_pending && $0.revision != seekRevision && $0.position > beforeStep }
let saved = state.position
try require(player_close(player), "Native close before resume")
_ = try await until(player) { $0.status == PLAYER_EMPTY }
try require(open(saved), "Native restored AniList source admission")
state = try await until(player) { $0.status == PLAYER_PAUSED && !$0.seek_pending && abs($0.position - saved) < 0.1 }
try require(state.has_audio && state.has_video, "Native restored tracks")
player_destroy(player)
destroyed = true
let rows = try String(contentsOf: report, encoding: .utf8).split(separator: "\n").map {
try JSONSerialization.jsonObject(with: Data($0.utf8)) as! [String: Any]
}
try require(!rows.isEmpty, "Native progress records emitted")
for row in rows {
let identity = row["media_identity"] as? [String: Any]
try require(row["version"] as? Int == 2 && row["media_url"] == nil && identity?["provider"] as? String == "anilist" && identity?["media_id"] as? String == "1" && identity?["episode"] as? Int == 1,
"Native progress uses AniList v2 identity with no media URL")
}
try require(rows.contains { ($0["position_seconds"] as? Double ?? 0) >= 600 }, "Acknowledged seek/resume position reported")
print("PASS: fresh AniList 1 episode 1 resolves through scoped relay; actual native video decode/frame step, tracks, 600s seek/decode, VTT text, restored position and v2 progress (no GUI/audio playback)")
}
static func main() async {
do {
try await checks()
if CommandLine.arguments.contains("--live") { try await live() }
} catch {
FileHandle.standardError.write(Data("FAIL: Zoko check: \(error.localizedDescription)\n".utf8))
exit(1)
}
}
}
+12 -1
View File
@@ -19,7 +19,7 @@ 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)
SWIFT_SOURCES += Sources/App/StreamResolver.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
SWIFT_SOURCES += Sources/App/StreamQueue.swift
SWIFT_SOURCES += Sources/App/SponsorBlock.swift
@@ -347,3 +347,14 @@ check-startup: all $(BUILD_DIR)/app-startup-check.dylib check-fixtures
check-bundle: all check-fixtures
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
+17 -1
View File
@@ -1,6 +1,6 @@
# 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
@@ -71,6 +71,20 @@ 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).
### 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 **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.
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.
## 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:
@@ -132,6 +146,8 @@ 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.
`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.
## Acknowledgements
+8 -2
View File
@@ -165,7 +165,7 @@ final class MiruroTransport: @unchecked Sendable {
static func prepare(_ source: ResolvedSource, deployment override: MiruroProxyDeployment? = nil) async throws -> ResolvedSource {
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 {
let deployment: MiruroProxyDeployment?
if let override { deployment = override }
@@ -185,6 +185,7 @@ final class MiruroTransport: @unchecked Sendable {
return prepared
} catch {
if Task.isCancelled || error is CancellationError { throw CancellationError() }
if rules.zoko { throw (error as? ZokoError) ?? ZokoError.transport }
throw MiruroPlaybackError()
}
}
@@ -233,6 +234,7 @@ final class MiruroTransport: @unchecked Sendable {
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,
(!requirements.zoko || ZokoResolver.validResource(url, playlist: kind == .playlist)),
url.fragment == nil, url.absoluteString.utf8.count <= 32768, let port = listener.port else { throw MiruroPlaybackError() }
return try lock.withLock {
let key = kind.rawValue + ":" + url.absoluteString
@@ -338,6 +340,9 @@ final class MiruroTransport: @unchecked Sendable {
func fetch(_ url: URL) async throws -> (Data, HTTPURLResponse) {
// Same-origin fixture/source access remains allowed. A manifest or
// 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) }
let target: URL
let requestHeaders: String
@@ -349,7 +354,8 @@ final class MiruroTransport: @unchecked Sendable {
} else {
target = url
// 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,
limit: resource.kind == .playlist ? 2 * 1024 * 1024 : 64 * 1024 * 1024,
+9 -3
View File
@@ -91,17 +91,23 @@ struct ResolvedSource: Sendable {
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 {
let id = document.identity
guard document.version == 1, id.provider == "miruro",
validIdentity(mediaID: id.media_id, episode: id.episode),
guard document.version == 1,
(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),
validText(document.title ?? "", limit: 4096),
validSubtitles(document.subtitles) else { throw InvalidSource() }
let headers = document.headers.sorted { $0.key < $1.key }
.map { "\($0.key): \($0.value)\r\n" }.joined()
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)
}
+4
View File
@@ -69,6 +69,10 @@ struct StreamQueue: Sendable {
url = URL(string: item.url)!
identity = "miruro:\(mediaID):\(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,
item.progress_file.hasPrefix("/"), !item.progress_file.contains("\0") else { throw InvalidQueue() }
+14
View File
@@ -8,10 +8,23 @@ private struct StreamResolutionError: LocalizedError {
enum StreamURL: Sendable {
case youtube(URL)
case miruro(mediaID: String, episode: Int)
case anilist(mediaID: String, episode: Int)
static func parse(_ text: String) throws -> StreamURL {
let text = text.trimmingCharacters(in: .whitespacesAndNewlines)
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) }
let invalid = StreamResolutionError(message: "Enter a Miruro episode URL from barelystarted.miruro.tv with an explicit positive episode number.")
guard !text.unicodeScalars.contains(where: {
@@ -62,6 +75,7 @@ struct MiruroPlaybackRequirements: Sendable {
var segmentOrigin: String? = nil
var playlistQuery: [String: String] = [:]
var embed = false
var zoko = false
}
struct MiruroSelection: Sendable {
+48 -1
View File
@@ -241,6 +241,7 @@ final class PlayerModel: ObservableObject {
private let youtubeTitleResolver: @MainActor (URL) async throws -> String?
private let sponsorResolver: @MainActor (String) async throws -> [SponsorSegment]
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 var reportingErrorMessage: String {
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) },
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) },
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) },
qualityClock: @escaping () -> Double = { ProcessInfo.processInfo.systemUptime },
skipClock: @escaping () -> Double = { ProcessInfo.processInfo.systemUptime }) {
@@ -261,6 +263,7 @@ final class PlayerModel: ObservableObject {
self.youtubeTitleResolver = youtubeTitleResolver
self.sponsorResolver = sponsorResolver
self.miruroResolver = miruroResolver
self.zokoResolver = zokoResolver
self.miruroMetadataResolver = miruroMetadataResolver
keyBindings = configuration.keys
compactTitleProgress = configuration.compactTitleProgress
@@ -441,7 +444,7 @@ final class PlayerModel: ObservableObject {
let url: URL
switch stream {
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)])
resolveStream(stream, change: .open)
@@ -461,6 +464,12 @@ final class PlayerModel: ObservableObject {
url = canonical
title = video.title
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):
let providers = try await miruroResolver(mediaID, episode)
let selected = try providers.select(videoProvider: miruroVideoProvider,
@@ -522,6 +531,7 @@ final class PlayerModel: ObservableObject {
switch stream {
case .youtube(let url): return url.absoluteString
case .miruro(let id, let episode): return "miruro:\(id):\(episode)"
case .anilist(let id, let episode): return "anilist:\(id):\(episode)"
}
}
return item.url.absoluteString
@@ -562,6 +572,7 @@ final class PlayerModel: ObservableObject {
switch stream {
case .youtube(let url): resolveYouTube(url, height: nil, 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) {
guard let handle else { return }
if castDevice != nil { stopCasting(resume: false) }
@@ -826,6 +871,8 @@ final class PlayerModel: ObservableObject {
title = try await youtube(url)
case .miruro(let id, let episode):
title = try await miruro(id, episode).title
case .anilist(let id, let episode):
title = try await ZokoResolver.title(mediaID: id, episode: episode)
}
} catch {
// Optional enrichment must not surface as a playback error or retry every poll.
+139
View File
@@ -0,0 +1,139 @@
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"
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) 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 else { throw ZokoError.transport }
var data = Data()
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) 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)
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 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*;"#
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 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") || lower.contains("captcha") { throw ZokoError.protection }
throw ZokoError.malformed
}
guard let encoded = Data(base64Encoded: String(page[range])), !encoded.isEmpty else { throw ZokoError.malformed }
let key = Array("otaku-embed-v1".utf8)
var decoded = Array(encoded)
for i in decoded.indices { decoded[i] ^= key[i % key.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: Data(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 -3
View File
@@ -19,13 +19,13 @@ enum PlayerCommand {
terebi --help
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.
Quote paths and URLs containing spaces or shell metacharacters.
--new-instance Launch a separate player, leaving existing playback untouched.
--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.
--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.
--enqueue Append a queue to the running player without interrupting playback;
@@ -88,7 +88,7 @@ enum PlayerCommand {
func streamURL(_ text: String) throws -> URL {
switch try StreamURL.parse(text) {
case .youtube(let url): return url
case .miruro:
case .miruro, .anilist:
guard let url = URL(string: text.trimmingCharacters(in: .whitespacesAndNewlines)) else {
throw ArgumentError("Invalid stream URL.")
}
+1 -1
View File
@@ -563,7 +563,7 @@ struct Player {
status = PLAYER_OPENING;
publish();
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);
format = primary->format;
openTrack(1, format, primary, AVMEDIA_TYPE_VIDEO, -1);
+1 -1
View File
@@ -60,7 +60,7 @@ typedef struct PlayerNetworkSource {
double duration;
bool is_live;
// 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.
const char *media_url;
// Initial restored position, applied before publishing ready state. Zero
+20 -8
View File
@@ -93,12 +93,20 @@ bool ProgressReporter::validMediaURL(std::string_view url) noexcept {
bool ProgressReporter::validMediaIdentity(std::string_view identity) noexcept {
if (validMediaURL(identity)) return true;
constexpr std::string_view prefix = "miruro:";
if (identity.substr(0, prefix.size()) != prefix) return false;
const bool anilist = identity.starts_with("anilist:");
const std::string_view prefix = anilist ? "anilist:" : "miruro:";
if (!identity.starts_with(prefix)) return false;
const auto separator = identity.find(':', prefix.size());
if (separator == std::string_view::npos) return false;
const auto id = identity.substr(prefix.size(), separator - prefix.size());
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) {
if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9') || c == '-' || c == '_')) return false;
@@ -186,13 +194,17 @@ bool ProgressReporter::emit(const char *event) noexcept {
try {
std::string record;
record.reserve(256);
const bool miruro = mediaURL.compare(0, 7, "miruro:") == 0;
record += miruro ? "{\"version\":2,\"event\":\"" : "{\"version\":1,\"event\":\"";
const bool anilist = mediaURL.starts_with("anilist:");
const bool resolved = anilist || mediaURL.starts_with("miruro:");
record += resolved ? "{\"version\":2,\"event\":\"" : "{\"version\":1,\"event\":\"";
record += event;
if (miruro) {
const auto separator = mediaURL.find(':', 7);
record += "\",\"media_identity\":{\"provider\":\"miruro\",\"media_id\":\"";
record.append(mediaURL, 7, separator - 7);
if (resolved) {
const size_t prefix = anilist ? 8 : 7;
const auto separator = mediaURL.find(':', prefix);
record += "\",\"media_identity\":{\"provider\":\"";
record += anilist ? "anilist" : "miruro";
record += "\",\"media_id\":\"";
record.append(mediaURL, prefix, separator - prefix);
record += "\",\"episode\":";
record.append(mediaURL, separator + 1, std::string::npos);
record += "},\"position_seconds\":";
+10
View File
@@ -18,6 +18,16 @@ 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.
## 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.
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.
`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. These commands are provided for integrated verification, not a claim they have been exercised.
## Launch contract
```sh