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
|
||||
|
||||
Reference in New Issue
Block a user