fix: retain Zoko opening and ending metadata through playback
This commit is contained in:
@@ -13,6 +13,7 @@ binary = str(Path(sys.argv[1]).resolve())
|
||||
fixtures = Path(sys.argv[2]).resolve()
|
||||
seen = []
|
||||
errors = []
|
||||
zoko_only = "--zoko-intervals" in sys.argv[3:]
|
||||
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
def log_message(self, *_):
|
||||
@@ -117,17 +118,21 @@ try:
|
||||
"title": "Local resolved fixture", "subtitles": [{"url": base + "/english.jpeg", "format": "vtt", "language": "en",
|
||||
"label": "English", "default": True, "headers": {"X-Resource": "subtitle-private"}}]}))
|
||||
source.chmod(0o600)
|
||||
subprocess.run([binary, str(source), str(progress)], check=True, timeout=100)
|
||||
records = [json.loads(line) for line in progress.read_text().splitlines()]
|
||||
assert records and all(record["version"] == 2 and record["media_identity"] ==
|
||||
{"provider": "miruro", "media_id": "fixture-ID", "episode": 1} for record in records), "stable episode progress identity"
|
||||
assert any(record["completion_eligible"] for record in records), "acknowledged video progress eligible"
|
||||
assert "stream-private" not in progress.read_text() and "token=" not in progress.read_text(), "progress has no transport secrets"
|
||||
subprocess.run([binary, str(source), "--zoko-intervals"] if zoko_only else
|
||||
[binary, str(source), str(progress)], check=True, timeout=100)
|
||||
if not zoko_only:
|
||||
records = [json.loads(line) for line in progress.read_text().splitlines()]
|
||||
assert records and all(record["version"] == 2 and record["media_identity"] ==
|
||||
{"provider": "miruro", "media_id": "fixture-ID", "episode": 1} for record in records), "stable episode progress identity"
|
||||
assert any(record["completion_eligible"] for record in records), "acknowledged video progress eligible"
|
||||
assert "stream-private" not in progress.read_text() and "token=" not in progress.read_text(), "progress has no transport secrets"
|
||||
assert not errors, errors
|
||||
assert {"/master.m3u8", "/variant.m3u8", "/english.jpeg"}.issubset(seen), "master, nested playlist and external VTT fetched"
|
||||
assert {"/alternate/master.m3u8", "/alternate/english.jpeg"}.issubset(seen), "alternate video host and independent caption provider fetched"
|
||||
assert {"/master.m3u8", "/variant.m3u8"}.issubset(seen), "master and nested playlist fetched"
|
||||
if not zoko_only:
|
||||
assert {"/english.jpeg", "/alternate/master.m3u8", "/alternate/english.jpeg"}.issubset(seen), "external VTT, alternate host and independent captions fetched"
|
||||
assert any(path.endswith(".ts") for path in seen), "media segment fetched"
|
||||
print("PASS: model HLS/VTT playback, independent resource headers, tokens, stable progress")
|
||||
print("PASS: local Zoko metadata native playback and source preparation" if zoko_only else
|
||||
"PASS: model HLS/VTT playback, independent resource headers, tokens, stable progress")
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
|
||||
@@ -44,6 +44,11 @@ struct ResolvedPlaybackCheck {
|
||||
} else {
|
||||
source = try ResolvedSource.load(from: file)
|
||||
}
|
||||
if CommandLine.arguments.contains("--zoko-intervals") {
|
||||
try await transportProtocol(source: source)
|
||||
try await zokoIntervalLifecycle(source: source)
|
||||
return
|
||||
}
|
||||
if CommandLine.arguments.contains("--enqueue-only") {
|
||||
try await enqueueLifecycle(source: source)
|
||||
return
|
||||
@@ -128,9 +133,151 @@ struct ResolvedPlaybackCheck {
|
||||
try await streamQueueLifecycle(source: source)
|
||||
try await enqueueLifecycle(source: source)
|
||||
try await sidebarQueueLifecycle(source: source)
|
||||
try await zokoIntervalLifecycle(source: source)
|
||||
}
|
||||
}
|
||||
|
||||
static func zokoIntervalLifecycle(source: ResolvedSource) async throws {
|
||||
let directory = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("terebi.zoko-interval-check." + UUID().uuidString)
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let suite = "terebi.zoko-interval-check." + UUID().uuidString
|
||||
let defaults = UserDefaults(suiteName: suite)!
|
||||
defer { defaults.removePersistentDomain(forName: suite) }
|
||||
func url(_ episode: Int) -> URL {
|
||||
URL(string: "https://zokoanime.video/stream/ani/1/\(episode)/sub")!
|
||||
}
|
||||
let rows: [ProviderInterval] = [
|
||||
.init(kind: .intro, start: 0.5, end: 1.5), .init(kind: .outro, start: 2.25, end: 3.25),
|
||||
]
|
||||
let expected = [
|
||||
MiruroInterval(kind: .intro, start: 0.5, end: 1.5, episode: 1),
|
||||
MiruroInterval(kind: .outro, start: 2.25, end: 3.25, episode: 1),
|
||||
]
|
||||
func resolved(_ episode: Int) -> ResolvedSource {
|
||||
var result = ResolvedSource(url: source.url, headers: source.headers, identity: "anilist:1:\(episode)",
|
||||
title: "Zoko interval fixture \(episode)", subtitles: [])
|
||||
result.intervals = episode == 2 ? [] : rows
|
||||
if episode == 3 { result.intervals[1] = .init(kind: .outro, start: 2.25, end: 500) }
|
||||
return result
|
||||
}
|
||||
var requests: [Int: Int] = [:]
|
||||
var held: CheckedContinuation<ResolvedSource, Never>?
|
||||
var heldMetadata: CheckedContinuation<MiruroMetadata, Never>?
|
||||
var staleCompleted = false
|
||||
var metadataRequests = 0
|
||||
let clock = Clock()
|
||||
let model = PlayerModel(defaults: defaults, progressFile: directory.appendingPathComponent("direct.jsonl").path,
|
||||
miruroResolver: { _, _ in try providerSources(source) },
|
||||
zokoResolver: { _, episode in
|
||||
requests[episode, default: 0] += 1
|
||||
if episode == 4 {
|
||||
let result = await withCheckedContinuation { held = $0 }
|
||||
staleCompleted = true
|
||||
return result
|
||||
}
|
||||
return resolved(episode)
|
||||
}, miruroMetadataResolver: { _, _ in
|
||||
metadataRequests += 1
|
||||
return await withCheckedContinuation { heldMetadata = $0 }
|
||||
}, skipClock: { clock.now })
|
||||
defer { model.shutdown() }
|
||||
model.openStream(url(1).absoluteString)
|
||||
require(model.visibleIntervals.isEmpty, "direct launch hides bands until native media is acknowledged")
|
||||
try await until("direct Zoko native decode and provider bands", model: model) {
|
||||
!model.isBusy && model.state.status == PLAYER_PLAYING && model.state.position > 0.2 &&
|
||||
model.visibleIntervals == expected
|
||||
}
|
||||
require(model.state.has_audio && model.state.has_video && model.state.duration >= 3.25,
|
||||
"local fixture supplies real video/audio and measured duration")
|
||||
require(model.sliderBoundaries == [0.5, 1.5, 2.25, 3.25] &&
|
||||
model.sliderSegmentTitle(at: 0.75) == "Opening" && model.sliderSegmentTitle(at: 2.5) == "Ending",
|
||||
"provider bands reuse existing slider labels and fractional boundaries")
|
||||
require(model.setPlayback(playing: false), "pause provider playback")
|
||||
try await until("provider pause acknowledged", model: model) { model.state.status == PLAYER_PAUSED }
|
||||
require(model.seekRelative(0.8 - model.state.position), "save provider bookmark inside opening")
|
||||
try await until("provider bookmark and skip offer acknowledged", model: model) {
|
||||
!model.state.seek_pending && abs(model.state.position - 0.8) < 0.1 && model.segmentToSkip == expected[0]
|
||||
}
|
||||
require(model.skipButtonTitle == "Skip Opening", "provider timing feeds unchanged manual skip policy")
|
||||
model.closeFile()
|
||||
require(model.visibleIntervals.isEmpty && model.sliderBoundaries.isEmpty, "close clears provider bands immediately")
|
||||
try await until("provider playback closed", model: model) { model.state.status == PLAYER_EMPTY }
|
||||
require(model.savedPosition(for: URL(string: "anilist:1:1")!).map { abs($0 - 0.8) < 0.1 } == true,
|
||||
"provider bookmark is saved under stable episode identity")
|
||||
|
||||
let queue = StreamQueue(entries: (1...3).map { episode in
|
||||
.init(url: url(episode), identity: "anilist:1:\(episode)",
|
||||
progressFile: directory.appendingPathComponent("queue-\(episode).jsonl"), label: "Episode \(episode)")
|
||||
})
|
||||
let reopened = PlayerModel(defaults: UserDefaults(suiteName: suite)!, streamQueue: queue,
|
||||
zokoResolver: { _, episode in
|
||||
requests[episode, default: 0] += 1
|
||||
return resolved(episode)
|
||||
}, miruroMetadataResolver: { _, _ in
|
||||
metadataRequests += 1
|
||||
return MiruroMetadata(title: nil, intervals: [])
|
||||
}, skipClock: { clock.now })
|
||||
defer { reopened.shutdown() }
|
||||
reopened.openStreamQueue(queue)
|
||||
try await until("fresh model reopens saved Zoko episode with newly resolved bands", model: reopened) {
|
||||
!reopened.isBusy && reopened.state.status == PLAYER_PLAYING && reopened.state.position >= 0.7 &&
|
||||
reopened.visibleIntervals == expected
|
||||
}
|
||||
require(requests[1] == 2 && requests[2] == nil, "saved queued reopen resolves only selected episode afresh")
|
||||
reopened.advanceQueue(1)
|
||||
require(reopened.visibleIntervals.isEmpty && reopened.segmentToSkip == nil, "queue replacement clears opening offer and bands")
|
||||
try await until("queued episode without timing still decodes", model: reopened) {
|
||||
!reopened.isBusy && reopened.state.status == PLAYER_PLAYING && reopened.fileName == resolved(2).title
|
||||
}
|
||||
require(reopened.visibleIntervals.isEmpty && reopened.sliderBoundaries.isEmpty, "missing timing never inherits previous episode")
|
||||
reopened.advanceQueue(1)
|
||||
try await until("out-of-duration provider range does not block queued playback", model: reopened) {
|
||||
!reopened.isBusy && reopened.state.status == PLAYER_PLAYING && reopened.fileName == resolved(3).title
|
||||
}
|
||||
require(reopened.visibleIntervals == [MiruroInterval(kind: .intro, start: 0.5, end: 1.5, episode: 3)],
|
||||
"actual native duration rejects oversized provider ending without clipping or losing valid opening")
|
||||
reopened.advanceQueue(-2)
|
||||
try await until("queue revisit restores fresh first-episode bands", model: reopened) {
|
||||
!reopened.isBusy && reopened.state.status == PLAYER_PLAYING && reopened.visibleIntervals == expected
|
||||
}
|
||||
require(requests[1] == 3, "queue revisit resolves again instead of retaining stale timing")
|
||||
reopened.closeFile()
|
||||
try await until("queued provider playback closed", model: reopened) { reopened.state.status == PLAYER_EMPTY }
|
||||
|
||||
model.openStream(url(4).absoluteString)
|
||||
try await until("stale provider resolution held", model: model) { held != nil }
|
||||
model.openStream(url(2).absoluteString)
|
||||
held!.resume(returning: resolved(4))
|
||||
held = nil
|
||||
try await until("late canceled provider resolution cannot replace new episode", model: model) {
|
||||
staleCompleted && !model.isBusy && model.state.status == PLAYER_PLAYING && model.fileName == resolved(2).title
|
||||
}
|
||||
require(model.visibleIntervals.isEmpty && model.sliderBoundaries.isEmpty, "late source timing cannot paint replacement episode")
|
||||
model.closeFile()
|
||||
try await until("empty player before sidebar provider launch", model: model) { model.state.status == PLAYER_EMPTY }
|
||||
try await model.addStreamToQueue(url(1).absoluteString)
|
||||
try await until("cold sidebar provider launch preserves prepared source timing", model: model) {
|
||||
!model.isBusy && model.state.status == PLAYER_PLAYING && model.visibleIntervals == expected
|
||||
}
|
||||
require(metadataRequests == 0, "AniList paths never request Miruro timing metadata")
|
||||
model.openStream("https://barelystarted.miruro.tv/watch/fixture-ID/episode?ep=1")
|
||||
require(model.visibleIntervals.isEmpty, "switching away from provider timing clears bands immediately")
|
||||
try await until("Miruro metadata lookup held", model: model) { heldMetadata != nil }
|
||||
model.openStream(url(1).absoluteString)
|
||||
heldMetadata!.resume(returning: MiruroMetadata(title: "Stale Miruro title",
|
||||
intervals: [.init(kind: .intro, start: 0.1, end: 0.4, episode: 1)]))
|
||||
heldMetadata = nil
|
||||
try await until("replacement Zoko episode keeps its own provider metadata", model: model) {
|
||||
!model.isBusy && model.state.status == PLAYER_PLAYING && model.visibleIntervals == expected
|
||||
}
|
||||
for _ in 0..<10 { await Task.yield() }
|
||||
require(metadataRequests == 1 && model.fileName == resolved(1).title && model.visibleIntervals == expected,
|
||||
"canceled Miruro enrichment cannot rename or paint provider replacement")
|
||||
print("PASS: Zoko direct/sidebar launch, fractional bands, manual skip, saved fresh-model reopen, queue/revisit, missing/out-of-duration timing and stale source/metadata rejection")
|
||||
}
|
||||
|
||||
static func sponsorPreferenceCheck() {
|
||||
let suite = "terebi.sponsor-preference." + UUID().uuidString
|
||||
let defaults = UserDefaults(suiteName: suite)!
|
||||
@@ -432,9 +579,11 @@ struct ResolvedPlaybackCheck {
|
||||
var input = ResolvedSource(url: base + "/transport-master.m3u8?token=private", headers:
|
||||
"Referer: https://transport.example/\r\nX-Secret: private\r\n",
|
||||
identity: source.identity, title: source.title, subtitles: [])
|
||||
input.intervals = [.init(kind: .intro, start: 0.25, end: 1.75)]
|
||||
input.requirements = .init(proxy: .init(rotate: false, segments: true),
|
||||
segmentOrigin: base, playlistQuery: ["skip": "fixture"])
|
||||
let prepared = try await MiruroTransport.prepare(input, deployment: deployment)
|
||||
require(prepared.intervals == input.intervals, "transport source reconstruction preserves provider intervals")
|
||||
let (master, _) = try await URLSession.shared.data(from: URL(string: prepared.url)!)
|
||||
let nested = String(data: master, encoding: .utf8)!.components(separatedBy: .newlines)
|
||||
.first { $0.hasPrefix("http://") }!
|
||||
|
||||
+62
-3
@@ -15,7 +15,10 @@ struct ZokoCheck {
|
||||
throw Failure(message: "Incorrect provider error category")
|
||||
}
|
||||
static func page(_ config: [String: Any]) throws -> String {
|
||||
var data = Array(try JSONSerialization.data(withJSONObject: config))
|
||||
try page(data: JSONSerialization.data(withJSONObject: config))
|
||||
}
|
||||
static func page(data: Data) -> String {
|
||||
var data = Array(data)
|
||||
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>"
|
||||
@@ -35,7 +38,60 @@ struct ZokoCheck {
|
||||
}
|
||||
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 intro = ProviderInterval(kind: .intro, start: 49.25, end: 144.75)
|
||||
let outro = ProviderInterval(kind: .outro, start: 1357.125, end: 1451.5)
|
||||
config["skip"] = ["intro": ["start": intro.start, "end": intro.end],
|
||||
"outro": ["start": outro.start, "end": outro.end]]
|
||||
let source = try ZokoResolver.parse(page: page(config), mediaID: "1", episode: 1)
|
||||
try require(source.intervals == [intro, outro], "fractional provider boundaries retain seconds and kinds")
|
||||
let replaced = source.replacingSubtitles([])
|
||||
try require(replaced.intervals == source.intervals && replaced.subtitles.isEmpty &&
|
||||
replaced.identity == source.identity && replaced.requirements.zoko,
|
||||
"subtitle replacement preserves provider intervals and playback requirements")
|
||||
let malformedMetadata: [Any] = [NSNull(), true, "bad", [], [:], ["intro": NSNull(), "outro": "bad"]]
|
||||
for metadata in malformedMetadata {
|
||||
var malformed = config
|
||||
malformed["skip"] = metadata
|
||||
let parsed = try ZokoResolver.parse(page: page(malformed), mediaID: "1", episode: 1)
|
||||
try require(parsed.intervals.isEmpty && parsed.url == source.url && parsed.subtitles.count == 1,
|
||||
"missing or malformed skip metadata does not block valid playback")
|
||||
}
|
||||
var absent = config
|
||||
absent.removeValue(forKey: "skip")
|
||||
let withoutTimings = try ZokoResolver.parse(page: page(absent), mediaID: "1", episode: 1)
|
||||
try require(withoutTimings.intervals.isEmpty && withoutTimings.url == source.url,
|
||||
"provider timings are optional")
|
||||
let invalidTimings: [Any] = [
|
||||
NSNull(), true, "bad", [], [:], ["start": 1], ["end": 2],
|
||||
["start": true, "end": 144], ["start": 49, "end": false],
|
||||
["start": "49", "end": 144], ["start": 49, "end": "144"],
|
||||
["start": -1, "end": 144], ["start": 49, "end": -1],
|
||||
["start": 49, "end": 49], ["start": 144, "end": 49]
|
||||
]
|
||||
for invalid in invalidTimings {
|
||||
for badKind in ["intro", "outro"] {
|
||||
var malformed = config
|
||||
var skip = config["skip"] as! [String: Any]
|
||||
skip[badKind] = invalid
|
||||
malformed["skip"] = skip
|
||||
let parsed = try ZokoResolver.parse(page: page(malformed), mediaID: "1", episode: 1)
|
||||
try require(parsed.intervals == (badKind == "intro" ? [outro] : [intro]) && parsed.url == source.url,
|
||||
"each malformed interval is discarded independently without blocking playback")
|
||||
}
|
||||
}
|
||||
for timing in [#"{"start":1e999,"end":144}"#, #"{"start":49,"end":1e999}"#] {
|
||||
let data = Data("""
|
||||
{"src":"https://hls.dramahot.top/master.m3u8","subtitles":[],
|
||||
"skip":{"intro":\(timing),"outro":{"start":1357.125,"end":1451.5}}}
|
||||
""".utf8)
|
||||
let parsed = try ZokoResolver.parse(page: page(data: data), mediaID: "1", episode: 1)
|
||||
try require(parsed.intervals == [outro], "nonfinite numeric interval never blocks a valid sibling or playback")
|
||||
}
|
||||
var zeroStart = config
|
||||
zeroStart["skip"] = ["intro": ["start": 0, "end": 1.5]]
|
||||
let zeroSource = try ZokoResolver.parse(page: page(zeroStart), mediaID: "1", episode: 1)
|
||||
try require(zeroSource.intervals == [ProviderInterval(kind: .intro, start: 0, end: 1.5)],
|
||||
"zero-based intervals remain valid")
|
||||
let mapped = try await ZokoResolver.resolve(mediaID: "97940", episode: 1, fetchResponse: { request in
|
||||
let data: Data
|
||||
let mime: String
|
||||
@@ -46,13 +102,16 @@ struct ZokoCheck {
|
||||
mime = "application/json"
|
||||
} else {
|
||||
try require(request.url?.absoluteString == "https://zokoanime.video/stream/mal/34572/1/sub", "provider lookup uses the exact mapped MAL ID, not AniList ID")
|
||||
data = Data(try page(["src": "https://hls.dramahot.top/master.m3u8", "subtitles": []]).utf8)
|
||||
data = Data(try page(["src": "https://hls.dramahot.top/master.m3u8", "subtitles": [],
|
||||
"skip": ["intro": ["start": 49.25, "end": 144.75]]]).utf8)
|
||||
mime = "text/html"
|
||||
}
|
||||
return (data, HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: ["Content-Type": mime])!)
|
||||
})
|
||||
try require(mapped.identity == "anilist:97940:1" && mapped.title == "Black Clover · Episode 1 (sub)",
|
||||
"mapped playback preserves AniList identity and catalog title")
|
||||
try require(mapped.intervals == [intro] && mapped.requirements.zoko,
|
||||
"catalog title enrichment preserves provider intervals and transport requirements")
|
||||
for bad in [
|
||||
#"{"data":{"Media":{"id":34572,"idMal":97940}}}"#,
|
||||
#"{"data":{"Media":{"id":97940,"idMal":0}}}"#,
|
||||
@@ -116,7 +175,7 @@ struct ZokoCheck {
|
||||
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")
|
||||
print("PASS: AniList URL/identity, static codec, optional independent fractional skip metadata, source transformations, unavailable/protection/malformed categories, bounded resources/subtitles, narrow CDN policy and isolated resume")
|
||||
}
|
||||
|
||||
// No NSApplication, window or audio playback. The production player opens and
|
||||
|
||||
@@ -321,6 +321,10 @@ $(BUILD_DIR)/resolved-playback-check: Checks/resolved-playback.swift $(SWIFT_SOU
|
||||
check-resolved: $(BUILD_DIR)/resolved-playback-check check-fixtures
|
||||
python3 Checks/resolved-playback.py $(BUILD_DIR)/resolved-playback-check $(BUILD_DIR)/fixtures
|
||||
|
||||
.PHONY: check-zoko-intervals
|
||||
check-zoko-intervals: $(BUILD_DIR)/resolved-playback-check check-fixtures
|
||||
python3 Checks/resolved-playback.py $(BUILD_DIR)/resolved-playback-check $(BUILD_DIR)/fixtures --zoko-intervals
|
||||
|
||||
|
||||
$(BUILD_DIR)/ffmpeg-runtime-check: Checks/ffmpeg-runtime.swift $(RUNTIME_SOURCE) Makefile | $(BUILD_DIR)
|
||||
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O $(DEBUG_FLAGS) Checks/ffmpeg-runtime.swift $(RUNTIME_SOURCE) -framework Foundation -o $@
|
||||
|
||||
@@ -81,6 +81,8 @@ The canonical handoff still uses `/stream/ani/<AniList ID>/<episode>/sub`, not t
|
||||
|
||||
Resolution uses bounded ordinary HTTPS with certificate verification, no cookies/credentials, no redirect following, and no browser or advertising/challenge script execution. AniList's returned catalog ID must match the requested identity; invalid mappings and GraphQL errors are rejected. The same metadata response can supply the playback title, with `AniList <ID> · Episode <number> (sub)` used when the title is absent. Only the provider's static `window.__P` Base64/XOR configuration is decoded with the first-party `otaku-embed-v1` codec. Poster, download, chapter, view/telemetry and player-script fields are ignored and never fetched.
|
||||
|
||||
ZokoAnime's optional `skip.intro` / `skip.outro` timestamps feed the existing opening/ending bands and skip controls, including queued episodes and saved-episode reopening. Fractional seconds are preserved. Each interval must have finite, nonnegative, increasing boundaries within the actual player-reported duration; missing or malformed timing is ignored without blocking playback. No Miruro lookup or external timing service is used.
|
||||
|
||||
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.
|
||||
|
||||
@@ -182,6 +182,7 @@ final class MiruroTransport: @unchecked Sendable {
|
||||
var prepared = ResolvedSource(url: url.absoluteString, headers: "", identity: source.identity,
|
||||
title: source.title, subtitles: source.subtitles)
|
||||
prepared.playbackLifetime = owner
|
||||
prepared.intervals = source.intervals
|
||||
return prepared
|
||||
} catch {
|
||||
if Task.isCancelled || error is CancellationError { throw CancellationError() }
|
||||
|
||||
@@ -31,18 +31,27 @@ struct ResolvedSubtitle: Decodable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
// Optional episode-relative provider timings; playback validates against media duration.
|
||||
struct ProviderInterval: Sendable, Equatable {
|
||||
enum Kind: Sendable { case intro, outro }
|
||||
let kind: Kind
|
||||
let start: Double
|
||||
let end: Double
|
||||
}
|
||||
|
||||
struct ResolvedSource: Sendable {
|
||||
let url: String
|
||||
let headers: String
|
||||
let identity: String
|
||||
let title: String
|
||||
let subtitles: [ResolvedSubtitle]
|
||||
var intervals: [ProviderInterval] = []
|
||||
var requirements: MiruroPlaybackRequirements = .init()
|
||||
var playbackLifetime: (any Sendable)? = nil
|
||||
|
||||
func replacingSubtitles(_ subtitles: [ResolvedSubtitle]) -> ResolvedSource {
|
||||
ResolvedSource(url: url, headers: headers, identity: identity, title: title, subtitles: subtitles,
|
||||
requirements: requirements, playbackLifetime: playbackLifetime)
|
||||
intervals: intervals, requirements: requirements, playbackLifetime: playbackLifetime)
|
||||
}
|
||||
|
||||
struct Document: Decodable {
|
||||
|
||||
@@ -974,9 +974,16 @@ final class PlayerModel: ObservableObject {
|
||||
skipOffer = nil
|
||||
}
|
||||
let parts = source?.url.absoluteString.split(separator: ":") ?? []
|
||||
let episode = parts.count == 3 && parts[0] == "miruro" ? Int(parts[2]) : nil
|
||||
let generation = metadataLifecycle.replace(identity: source?.url.absoluteString, episode: episode)
|
||||
guard let source, let episode else { return }
|
||||
let episode = parts.count == 3 && (parts[0] == "miruro" || parts[0] == "anilist") ? Int(parts[2]) : nil
|
||||
let providerIntervals: [ProviderInterval]
|
||||
if case .resolved(let resolved, _) = source, parts.first == "anilist" {
|
||||
providerIntervals = resolved.intervals
|
||||
} else {
|
||||
providerIntervals = []
|
||||
}
|
||||
let generation = metadataLifecycle.replace(identity: source?.url.absoluteString, episode: episode,
|
||||
providerIntervals: providerIntervals)
|
||||
guard let source, let episode, parts[0] == "miruro" else { return }
|
||||
let identity = source.url.absoluteString
|
||||
let mediaID = String(parts[1])
|
||||
let resolver = miruroMetadataResolver
|
||||
@@ -2142,14 +2149,20 @@ final class PlayerModel: ObservableObject {
|
||||
private struct MetadataLifecycle {
|
||||
private(set) var identity: String?
|
||||
private(set) var metadata: MiruroMetadata?
|
||||
private var providerIntervals: [MiruroInterval] = []
|
||||
private var episode: Int?
|
||||
private var generation: UInt64 = 0
|
||||
|
||||
mutating func replace(identity: String?, episode: Int?) -> UInt64 {
|
||||
mutating func replace(identity: String?, episode: Int?, providerIntervals: [ProviderInterval] = []) -> UInt64 {
|
||||
generation &+= 1
|
||||
self.identity = identity
|
||||
self.episode = episode
|
||||
metadata = nil
|
||||
self.providerIntervals = episode.map { episode in
|
||||
providerIntervals.map {
|
||||
MiruroInterval(kind: $0.kind == .intro ? .intro : .outro, start: $0.start, end: $0.end, episode: episode)
|
||||
}
|
||||
} ?? []
|
||||
return generation
|
||||
}
|
||||
|
||||
@@ -2160,8 +2173,14 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
|
||||
func visibleIntervals(duration: Double) -> [MiruroInterval] {
|
||||
guard let episode, let metadata else { return [] }
|
||||
return metadata.visibleIntervals(episode: episode, duration: duration)
|
||||
guard let episode else { return [] }
|
||||
if !providerIntervals.isEmpty {
|
||||
guard duration.isFinite, duration > 0 else { return [] }
|
||||
return providerIntervals.filter {
|
||||
$0.start.isFinite && $0.end.isFinite && $0.start >= 0 && $0.end > $0.start && $0.end <= duration
|
||||
}
|
||||
}
|
||||
return metadata?.visibleIntervals(episode: episode, duration: duration) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2208,6 +2227,32 @@ final class PlayerModel: ObservableObject {
|
||||
precondition(!lifecycle.accept(metadata, identity: a, generation: second), "YouTube rejects earlier metadata")
|
||||
_ = lifecycle.replace(identity: nil, episode: nil)
|
||||
precondition(lifecycle.metadata == nil, "Close clears metadata")
|
||||
let providerRows: [ProviderInterval] = [
|
||||
.init(kind: .intro, start: 0.25, end: 1.75),
|
||||
.init(kind: .outro, start: 90.5, end: 100),
|
||||
.init(kind: .intro, start: -1, end: 1),
|
||||
.init(kind: .outro, start: 10, end: 10),
|
||||
.init(kind: .intro, start: .nan, end: 3),
|
||||
.init(kind: .outro, start: 2, end: .infinity),
|
||||
]
|
||||
let providerGeneration = lifecycle.replace(identity: "anilist:1:1", episode: 1, providerIntervals: providerRows)
|
||||
let expected = [
|
||||
MiruroInterval(kind: .intro, start: 0.25, end: 1.75, episode: 1),
|
||||
MiruroInterval(kind: .outro, start: 90.5, end: 100, episode: 1),
|
||||
]
|
||||
for duration in [0.0, -1, .nan, .infinity] {
|
||||
precondition(lifecycle.visibleIntervals(duration: duration).isEmpty, "Unknown/nonfinite provider duration hides bands")
|
||||
}
|
||||
precondition(lifecycle.visibleIntervals(duration: 100) == expected, "Provider fractions and exact-duration end survive mapping")
|
||||
precondition(lifecycle.visibleIntervals(duration: 95) == [expected[0]], "Provider ranges outside actual duration are rejected, not clipped")
|
||||
precondition(lifecycle.visibleIntervals(duration: 100) == expected, "Provider raw rows survive duration changes")
|
||||
_ = lifecycle.replace(identity: "anilist:1:2", episode: 2)
|
||||
precondition(lifecycle.visibleIntervals(duration: 100).isEmpty, "Missing provider timing clears preceding episode")
|
||||
_ = lifecycle.replace(identity: "anilist:1:1", episode: 1)
|
||||
precondition(!lifecycle.accept(metadata, identity: "anilist:1:1", generation: providerGeneration),
|
||||
"Provider replacement retains same-identity generation protection")
|
||||
_ = lifecycle.replace(identity: nil, episode: nil)
|
||||
precondition(lifecycle.visibleIntervals(duration: 100).isEmpty, "Close clears provider bands")
|
||||
}
|
||||
#endif
|
||||
|
||||
|
||||
@@ -82,7 +82,8 @@ enum ZokoResolver {
|
||||
guard enrichTitle, let name = metadata.name else { return source }
|
||||
try Task.checkCancellation()
|
||||
return ResolvedSource(url: source.url, headers: source.headers, identity: source.identity,
|
||||
title: "\(name) · Episode \(episode) (sub)", subtitles: source.subtitles, requirements: source.requirements)
|
||||
title: "\(name) · Episode \(episode) (sub)", subtitles: source.subtitles,
|
||||
intervals: source.intervals, requirements: source.requirements, playbackLifetime: source.playbackLifetime)
|
||||
}
|
||||
|
||||
static func parse(page: String, mediaID: String, episode: Int) throws -> ResolvedSource {
|
||||
@@ -106,8 +107,37 @@ enum ZokoResolver {
|
||||
}
|
||||
struct Config: Decodable {
|
||||
struct Subtitle: Decodable { let src: String; let lang: String; let label: String; let `default`: Bool? }
|
||||
struct Timing: Decodable {
|
||||
let start: Double
|
||||
let end: Double
|
||||
enum CodingKeys: CodingKey { case start, end }
|
||||
init(from decoder: any Decoder) throws {
|
||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
start = try values.decode(Double.self, forKey: .start)
|
||||
end = try values.decode(Double.self, forKey: .end)
|
||||
guard start.isFinite, end.isFinite, start >= 0, end > start else { throw ZokoError.malformed }
|
||||
}
|
||||
}
|
||||
struct Skip: Decodable {
|
||||
let intro: Timing?
|
||||
let outro: Timing?
|
||||
enum CodingKeys: CodingKey { case intro, outro }
|
||||
init(from decoder: any Decoder) throws {
|
||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
intro = try? values.decode(Timing.self, forKey: .intro)
|
||||
outro = try? values.decode(Timing.self, forKey: .outro)
|
||||
}
|
||||
}
|
||||
let src: String
|
||||
let subtitles: [Subtitle]
|
||||
let skip: Skip?
|
||||
enum CodingKeys: CodingKey { case src, subtitles, skip }
|
||||
init(from decoder: any Decoder) throws {
|
||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
src = try values.decode(String.self, forKey: .src)
|
||||
subtitles = try values.decode([Subtitle].self, forKey: .subtitles)
|
||||
skip = try? values.decode(Skip.self, forKey: .skip)
|
||||
}
|
||||
}
|
||||
let config: Config
|
||||
do { config = try JSONDecoder().decode(Config.self, from: decoded) }
|
||||
@@ -119,9 +149,16 @@ enum ZokoResolver {
|
||||
isDefault: row.default ?? false, headers: ["Referer": "https://zokoanime.video/"])
|
||||
}
|
||||
guard ResolvedSource.validSubtitles(subtitles) else { throw ZokoError.malformed }
|
||||
var intervals: [ProviderInterval] = []
|
||||
if let intro = config.skip?.intro {
|
||||
intervals.append(ProviderInterval(kind: .intro, start: intro.start, end: intro.end))
|
||||
}
|
||||
if let outro = config.skip?.outro {
|
||||
intervals.append(ProviderInterval(kind: .outro, start: outro.start, end: outro.end))
|
||||
}
|
||||
return ResolvedSource(url: config.src, headers: referer, identity: "anilist:\(mediaID):\(episode)",
|
||||
title: "AniList \(mediaID) · Episode \(episode) (sub)", subtitles: subtitles,
|
||||
requirements: .init(zoko: true))
|
||||
intervals: intervals, requirements: .init(zoko: true))
|
||||
}
|
||||
|
||||
struct Catalog: Decodable {
|
||||
|
||||
@@ -9,12 +9,14 @@
|
||||
```
|
||||
Values are seconds. Verified on Cowboy Bebop episode 1—including `/stream/mal/1/1/sub`, the actual resolver path—and Attack on Titan S3 Part 2 episode 1.
|
||||
|
||||
**Current gaps**
|
||||
- `ZokoResolver.swift`: `parse` decodes only `src` and `subtitles`, discarding `skip`.
|
||||
- `TerebiApp.swift`: `replaceMetadata(for:)` only acquires intervals for `miruro:` identities.
|
||||
**Status: implemented locally.**
|
||||
- `ZokoResolver.swift` retains optional, independently decoded provider intervals through resolution and source preparation.
|
||||
- `TerebiApp.swift` feeds `anilist:` provider intervals into the existing metadata/display lifecycle without a Miruro lookup.
|
||||
|
||||
**Requested change:** retain optional provider intervals through resolution/source preparation and feed the existing metadata/display lifecycle for `anilist:` identities. No Tubular changes, Miruro lookup, or external timing service needed.
|
||||
|
||||
**Acceptance:** preserve fractional seconds; validate finite, nonnegative, ordered boundaries against actual media duration; missing/malformed intervals must not block playback. Clear stale intervals on media replacement and retain cancellation/generation guards. Cover direct launches, queues, and saved-episode reopening without changing existing display/skip policy.
|
||||
|
||||
**Verification:** `make check-zoko check-zoko-intervals check-source check-miruro-metadata all` and `.build/resolved-playback-check --metadata-lifecycle` passed. Local native playback covers direct/sidebar launches, fractional bands/manual skip, saved fresh-model reopening, queues/revisits, invalid/missing timing and stale source/metadata rejection. Visual rendering and live-provider alignment still need a user check.
|
||||
|
||||
**Evidence:** [research note](docs/miruro-segments.md#current-provider-investigation-2026-10-25). Metadata verified; player rendering and timestamp alignment were not.
|
||||
@@ -24,6 +24,10 @@ The Miruro contract below remains intact for old collections. New AniList catalo
|
||||
|
||||
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).
|
||||
|
||||
Optional `skip.intro` and `skip.outro` from that same configuration remain attached to the resolved source through title enrichment, subtitle replacement and relay preparation. For `anilist:` playback they feed the existing metadata lifecycle and opening/ending display/skip policy without a Miruro request or external timing service. Each interval independently requires numeric, finite, nonnegative, increasing boundaries wholly within actual positive player duration; fractional seconds remain unchanged. Missing/malformed timing never blocks playback. Media replacement clears timings and retains generation/cancellation guards.
|
||||
|
||||
`make check-zoko check-zoko-intervals check-source check-miruro-metadata all` and `.build/resolved-playback-check --metadata-lifecycle` passed after this integration. The localhost native playback smoke covers direct/sidebar launches, manual skip, fresh-model saved reopening, queue/revisit, missing/out-of-duration timing and stale source/metadata rejection. Visual rendering and live-provider timestamp alignment remain unverified.
|
||||
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user