Compare commits
21 Commits
8df500da83
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0641859d95 | |||
| a2c8cc8e4d | |||
| 491252e35f | |||
| 98dccd7593 | |||
| 82d47741eb | |||
| 41aa0abd56 | |||
| d8fb648c90 | |||
| 599e2f548a | |||
| a604810319 | |||
| 7052fe29af | |||
| b95f7d251e | |||
| e6a2759497 | |||
| 6463d47ed8 | |||
| ab61d83c69 | |||
| 8c2a18df48 | |||
| b04ed7fdc8 | |||
| edb17f76e3 | |||
| 119fe2e737 | |||
| 2391dadc75 | |||
| f6fe617d92 | |||
| 30e5787ecc |
@@ -10,6 +10,8 @@ A requested change of media, playback state, position, or quality that may still
|
|||||||
**Cast playback transition**:
|
**Cast playback transition**:
|
||||||
A playback transition that transfers playback between this Mac and a Cast receiver. Connection alone does not transfer playback; the destination must acknowledge the handoff, and interruption must preserve the latest playback intent and acknowledged position.
|
A playback transition that transfers playback between this Mac and a Cast receiver. Connection alone does not transfer playback; the destination must acknowledge the handoff, and interruption must preserve the latest playback intent and acknowledged position.
|
||||||
|
|
||||||
|
Discovery supplies receiver addresses to both Network.framework and the native media relay's POSIX resolver. IPv4 addresses must omit interface scope suffixes; IPv6 addresses retain their scopes.
|
||||||
|
|
||||||
**Playback intent**:
|
**Playback intent**:
|
||||||
The user's latest requested playing or paused state, which can differ from the state playback has acknowledged.
|
The user's latest requested playing or paused state, which can differ from the state playback has acknowledged.
|
||||||
|
|
||||||
|
|||||||
+48
-15
@@ -275,6 +275,10 @@ private enum WindowDragCheck {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func interceptDrags(in window: NSWindow) {
|
static func interceptDrags(in window: NSWindow) {
|
||||||
|
for (method, original) in implementations.reversed() {
|
||||||
|
imp_removeBlock(method_setImplementation(method, original))
|
||||||
|
}
|
||||||
|
implementations.removeAll()
|
||||||
target = window
|
target = window
|
||||||
let method = class_getInstanceMethod(type(of: window), #selector(NSWindow.performDrag(with:)))!
|
let method = class_getInstanceMethod(type(of: window), #selector(NSWindow.performDrag(with:)))!
|
||||||
let original = unsafeBitCast(method_getImplementation(method),
|
let original = unsafeBitCast(method_getImplementation(method),
|
||||||
@@ -309,8 +313,9 @@ private enum WindowDragCheck {
|
|||||||
guard ContinuousClock.now < deadline else {
|
guard ContinuousClock.now < deadline else {
|
||||||
finish("Window drag timed out at \(names[probe]), phase \(phase)"); return
|
finish("Window drag timed out at \(names[probe]), phase \(phase)"); return
|
||||||
}
|
}
|
||||||
guard let window = NSApp.windows.first(where: { $0.canBecomeMain && !($0 is NSPanel) }) else { return }
|
|
||||||
let model = PlayerModel.shared
|
let model = PlayerModel.shared
|
||||||
|
guard let window = model.compactWindow.presentationWindow
|
||||||
|
?? NSApp.windows.first(where: { $0.canBecomeMain && !($0 is NSPanel) }) else { return }
|
||||||
let fixtures = URL(fileURLWithPath: CommandLine.arguments.last!, isDirectory: true)
|
let fixtures = URL(fileURLWithPath: CommandLine.arguments.last!, isDirectory: true)
|
||||||
if target == nil {
|
if target == nil {
|
||||||
guard model.canTogglePlayback else { return }
|
guard model.canTogglePlayback else { return }
|
||||||
@@ -322,10 +327,11 @@ private enum WindowDragCheck {
|
|||||||
window.makeMain()
|
window.makeMain()
|
||||||
NSApp.activate(ignoringOtherApps: true)
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
}
|
}
|
||||||
|
if target !== window { interceptDrags(in: window) }
|
||||||
if phase == 0 {
|
if phase == 0 {
|
||||||
guard NSApp.isActive, window.isKeyWindow, window.isMainWindow else {
|
guard NSApp.isActive, window.isKeyWindow, !window.canBecomeMain || window.isMainWindow else {
|
||||||
window.makeKeyAndOrderFront(nil)
|
window.makeKeyAndOrderFront(nil)
|
||||||
window.makeMain()
|
if window.canBecomeMain { window.makeMain() }
|
||||||
NSApp.activate(ignoringOtherApps: true)
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -588,7 +594,10 @@ private enum WindowLayoutCheck {
|
|||||||
static var timer: Timer?
|
static var timer: Timer?
|
||||||
static var resultURL: URL!
|
static var resultURL: URL!
|
||||||
static var step = 0
|
static var step = 0
|
||||||
static var windowIdentity: ObjectIdentifier?
|
static var normalWindow: NSWindow?
|
||||||
|
static var renderer: NSView?
|
||||||
|
static var content: NSView?
|
||||||
|
static var activationPolicy: NSApplication.ActivationPolicy?
|
||||||
static var normalFrame = NSRect.zero
|
static var normalFrame = NSRect.zero
|
||||||
static var compactFrame = NSRect.zero
|
static var compactFrame = NSRect.zero
|
||||||
static var animationStart: NSRect?
|
static var animationStart: NSRect?
|
||||||
@@ -676,7 +685,28 @@ private enum WindowLayoutCheck {
|
|||||||
let model = PlayerModel.shared
|
let model = PlayerModel.shared
|
||||||
guard ContinuousClock.now < deadline else { finish("Window layout did not settle at step \(step)"); return }
|
guard ContinuousClock.now < deadline else { finish("Window layout did not settle at step \(step)"); return }
|
||||||
guard model.canTogglePlayback, model.state.has_video,
|
guard model.canTogglePlayback, model.state.has_video,
|
||||||
let window = NSApp.windows.first(where: { $0.canBecomeMain && !($0 is NSPanel) }) else { return }
|
let normal = normalWindow ?? NSApp.windows.first(where: { $0.canBecomeMain && !($0 is NSPanel) }),
|
||||||
|
let video = model.videoView else { return }
|
||||||
|
let window = model.compactWindow.presentationWindow ?? normal
|
||||||
|
let hostedContent = model.hostedPlayerView ?? window.contentView
|
||||||
|
if normalWindow == nil {
|
||||||
|
normalWindow = normal
|
||||||
|
renderer = video
|
||||||
|
content = hostedContent
|
||||||
|
activationPolicy = NSApp.activationPolicy()
|
||||||
|
}
|
||||||
|
guard video === renderer, hostedContent === content, hostedContent?.window === window,
|
||||||
|
video.window === window, NSApp.activationPolicy() == activationPolicy else {
|
||||||
|
finish("Transition replaced the content/renderer or changed app activation policy"); return
|
||||||
|
}
|
||||||
|
if window !== normal {
|
||||||
|
guard let panel = window as? NSPanel, panel.styleMask.contains(.nonactivatingPanel),
|
||||||
|
!normal.isVisible, !normal.isKeyWindow else {
|
||||||
|
finish("Compact presentation is not an independent nonactivating panel hiding the normal window"); return
|
||||||
|
}
|
||||||
|
} else if model.isCompact {
|
||||||
|
finish("Compact playback retained the normal window"); return
|
||||||
|
}
|
||||||
if model.compactWindow.isTransitioning || model.isQueueTransitioning {
|
if model.compactWindow.isTransitioning || model.isQueueTransitioning {
|
||||||
window.layoutIfNeeded()
|
window.layoutIfNeeded()
|
||||||
if model.isQueueTransitioning && !model.isCompact && !sidebarHeaderIsVisible(in: window) {
|
if model.isQueueTransitioning && !model.isCompact && !sidebarHeaderIsVisible(in: window) {
|
||||||
@@ -718,8 +748,9 @@ private enum WindowLayoutCheck {
|
|||||||
queueVideoSize = nil
|
queueVideoSize = nil
|
||||||
observingQueue = false
|
observingQueue = false
|
||||||
}
|
}
|
||||||
if windowIdentity == nil { windowIdentity = ObjectIdentifier(window) }
|
guard model.isCompact ? window !== normal : window === normal else {
|
||||||
guard ObjectIdentifier(window) == windowIdentity else { finish("Transition replaced the playback window"); return }
|
finish("Settled presentation does not match the requested mode"); return
|
||||||
|
}
|
||||||
if model.fullScreenIntent != nil { return }
|
if model.fullScreenIntent != nil { return }
|
||||||
window.layoutIfNeeded()
|
window.layoutIfNeeded()
|
||||||
if compactQueueOnly && step == 0 {
|
if compactQueueOnly && step == 0 {
|
||||||
@@ -1079,20 +1110,21 @@ private enum CLIInstanceCheck {
|
|||||||
let model = PlayerModel.shared
|
let model = PlayerModel.shared
|
||||||
let pid = ProcessInfo.processInfo.processIdentifier
|
let pid = ProcessInfo.processInfo.processIdentifier
|
||||||
// Text input can create non-main auxiliary windows; count actual player windows.
|
// Text input can create non-main auxiliary windows; count actual player windows.
|
||||||
let windows = NSApp.windows.filter { $0.canBecomeMain && !($0 is NSPanel) && $0.contentView != nil }
|
let windows = NSApp.windows.filter { $0.canBecomeMain && !($0 is NSPanel) }
|
||||||
|
let presentation = model.compactWindow.presentationWindow ?? windows.first
|
||||||
for window in windows { window.alphaValue = 0 }
|
for window in windows { window.alphaValue = 0 }
|
||||||
let request = directory.appendingPathComponent("keys-\(pid).json")
|
let request = directory.appendingPathComponent("keys-\(pid).json")
|
||||||
var pendingRequestID = ""
|
var pendingRequestID = ""
|
||||||
if let data = try? Data(contentsOf: request),
|
if let data = try? Data(contentsOf: request),
|
||||||
let command = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
let command = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||||
let id = command["id"] as? String, id != requestID, let window = windows.first {
|
let id = command["id"] as? String, id != requestID, let window = presentation {
|
||||||
pendingRequestID = id
|
pendingRequestID = id
|
||||||
if !NSApp.isActive || !window.isKeyWindow || !window.isMainWindow {
|
if !NSApp.isActive || !window.isKeyWindow || window.canBecomeMain && !window.isMainWindow {
|
||||||
// Synthetic native key requests need foreground focus; cooperative activation
|
// Synthetic native key requests need foreground focus; cooperative activation
|
||||||
// can be refused by the unrelated app currently active on the workstation.
|
// can be refused by the unrelated app currently active on the workstation.
|
||||||
NSApp.activate(ignoringOtherApps: true)
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
window.makeKeyAndOrderFront(nil)
|
window.makeKeyAndOrderFront(nil)
|
||||||
window.makeMain()
|
if window.canBecomeMain { window.makeMain() }
|
||||||
} else {
|
} else {
|
||||||
requestID = id
|
requestID = id
|
||||||
if let media = command["media"] as? String { model.open(URL(fileURLWithPath: media)) }
|
if let media = command["media"] as? String { model.open(URL(fileURLWithPath: media)) }
|
||||||
@@ -1140,8 +1172,9 @@ private enum CLIInstanceCheck {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let events = command["events"] as? [[String: Any]] {
|
if let events = command["events"] as? [[String: Any]] {
|
||||||
window.makeKey()
|
|
||||||
for input in events {
|
for input in events {
|
||||||
|
let window = model.compactWindow.presentationWindow ?? window
|
||||||
|
window.makeKey()
|
||||||
if let title = input["select"] as? String {
|
if let title = input["select"] as? String {
|
||||||
guard let menu = NSApp.mainMenu else { fatalError("Native application menu is missing") }
|
guard let menu = NSApp.mainMenu else { fatalError("Native application menu is missing") }
|
||||||
prepareMenu(menu)
|
prepareMenu(menu)
|
||||||
@@ -1206,14 +1239,14 @@ private enum CLIInstanceCheck {
|
|||||||
"requestID": requestID,
|
"requestID": requestID,
|
||||||
"pendingRequestID": pendingRequestID,
|
"pendingRequestID": pendingRequestID,
|
||||||
"active": NSApp.isActive,
|
"active": NSApp.isActive,
|
||||||
"keyWindow": windows.first?.isKeyWindow ?? false,
|
"keyWindow": presentation?.isKeyWindow ?? false,
|
||||||
"mainWindow": windows.first?.isMainWindow ?? false,
|
"mainWindow": presentation?.isMainWindow ?? false,
|
||||||
"editorText": editorText,
|
"editorText": editorText,
|
||||||
"compact": model.isCompact,
|
"compact": model.isCompact,
|
||||||
"showsQueue": model.showsQueue,
|
"showsQueue": model.showsQueue,
|
||||||
"queueURLs": model.queue.items.map(\.url.absoluteString),
|
"queueURLs": model.queue.items.map(\.url.absoluteString),
|
||||||
"queueIndex": model.queue.items.firstIndex { $0.id == model.queue.currentID } ?? -1,
|
"queueIndex": model.queue.items.firstIndex { $0.id == model.queue.currentID } ?? -1,
|
||||||
"window": windowSnapshot(windows.first),
|
"window": windowSnapshot(presentation),
|
||||||
"onTop": model.isOnTop,
|
"onTop": model.isOnTop,
|
||||||
]
|
]
|
||||||
do {
|
do {
|
||||||
|
|||||||
@@ -68,7 +68,8 @@ struct AutomaticQualityPlaybackChecks {
|
|||||||
until(model, "compact mode remains usable across empty playback polls while resolving") {
|
until(model, "compact mode remains usable across empty playback polls while resolving") {
|
||||||
model.isCompact && !model.compactWindow.isTransitioning
|
model.isCompact && !model.compactWindow.isTransitioning
|
||||||
}
|
}
|
||||||
require(window.level == .floating && window.frame.width < normalFrame.width,
|
require(model.compactWindow.presentationWindow!.level == .floating &&
|
||||||
|
model.compactWindow.presentationWindow!.frame.width < normalFrame.width,
|
||||||
"unresolved video has a usable compact window")
|
"unresolved video has a usable compact window")
|
||||||
model.handleKey(compactKey)
|
model.handleKey(compactKey)
|
||||||
until(model, "compact shortcut restores while YouTube resolution is still pending") {
|
until(model, "compact shortcut restores while YouTube resolution is still pending") {
|
||||||
@@ -86,7 +87,8 @@ struct AutomaticQualityPlaybackChecks {
|
|||||||
require(model.isCompact, "loading completion preserves the requested compact mode")
|
require(model.isCompact, "loading completion preserves the requested compact mode")
|
||||||
until(model, "decoded metadata updates compact geometry") {
|
until(model, "decoded metadata updates compact geometry") {
|
||||||
!model.compactWindow.isTransitioning &&
|
!model.compactWindow.isTransitioning &&
|
||||||
abs(window.frame.width / window.frame.height - model.state.video_aspect_ratio) < 0.01
|
abs(model.compactWindow.presentationWindow!.frame.width / model.compactWindow.presentationWindow!.frame.height -
|
||||||
|
model.state.video_aspect_ratio) < 0.01
|
||||||
}
|
}
|
||||||
model.handleKey(compactKey)
|
model.handleKey(compactKey)
|
||||||
until(model, "compact shortcut restores during network buffering") {
|
until(model, "compact shortcut restores during network buffering") {
|
||||||
@@ -100,6 +102,12 @@ struct AutomaticQualityPlaybackChecks {
|
|||||||
polled(model)
|
polled(model)
|
||||||
require(model.youtubeHeight == 1080 && !model.resolvingStream,
|
require(model.youtubeHeight == 1080 && !model.resolvingStream,
|
||||||
"disabled automatic quality leaves a stalled manual/default stream alone")
|
"disabled automatic quality leaves a stalled manual/default stream alone")
|
||||||
|
let captionCountURL = URL(string: ProcessInfo.processInfo.environment["TEREBI_CAPTION_COUNT"]!)!
|
||||||
|
require(try! String(contentsOf: captionCountURL, encoding: .utf8) == "0",
|
||||||
|
"Off never requests captions during extraction or playback")
|
||||||
|
require(model.selectedSubtitle == nil && model.subtitleTracks.count == 1, "YouTube captions default Off")
|
||||||
|
model.selectSubtitle(0)
|
||||||
|
until(model, "selected original-auto VTT loads through native HTTP") { model.subtitleText == "Automatic caption" }
|
||||||
model.setAutomaticYouTubeQuality(true)
|
model.setAutomaticYouTubeQuality(true)
|
||||||
polled(model)
|
polled(model)
|
||||||
let position = model.state.position
|
let position = model.state.position
|
||||||
@@ -110,11 +118,16 @@ struct AutomaticQualityPlaybackChecks {
|
|||||||
}
|
}
|
||||||
require(model.state.position >= position,
|
require(model.state.position >= position,
|
||||||
"automatic quality reopening does not reset acknowledged progress")
|
"automatic quality reopening does not reset acknowledged progress")
|
||||||
|
until(model, "automatic downshift restores the selected caption") { model.subtitleText == "Automatic caption" }
|
||||||
|
require(model.selectedSubtitle == 0, "automatic downshift retains caption identity")
|
||||||
polled(model)
|
polled(model)
|
||||||
clock.now = 2063
|
clock.now = 2063
|
||||||
until(model, "sustained health automatically restores actual 1080p media") {
|
until(model, "sustained health automatically restores actual 1080p media") {
|
||||||
model.youtubeHeight == 1080 && model.canTogglePlayback && model.state.status == PLAYER_PLAYING && !model.state.seek_pending
|
model.youtubeHeight == 1080 && model.canTogglePlayback && model.state.status == PLAYER_PLAYING && !model.state.seek_pending
|
||||||
}
|
}
|
||||||
|
until(model, "automatic upshift restores the selected caption") { model.subtitleText == "Automatic caption" }
|
||||||
|
require(model.selectedSubtitle == 0, "automatic upshift retains caption identity")
|
||||||
|
model.selectSubtitle(nil)
|
||||||
model.setYouTubeQuality(model.youtubeHeight)
|
model.setYouTubeQuality(model.youtubeHeight)
|
||||||
require(!model.automaticYouTubeQuality && !model.isBusy && model.state.status == PLAYER_PLAYING,
|
require(!model.automaticYouTubeQuality && !model.isBusy && model.state.status == PLAYER_PLAYING,
|
||||||
"reselecting the current manual resolution disables auto without disrupting playback")
|
"reselecting the current manual resolution disables auto without disrupting playback")
|
||||||
@@ -134,6 +147,7 @@ struct AutomaticQualityPlaybackChecks {
|
|||||||
model.youtubeHeight == 720 && model.canChangeYouTubeQuality && model.state.status == PLAYER_PLAYING &&
|
model.youtubeHeight == 720 && model.canChangeYouTubeQuality && model.state.status == PLAYER_PLAYING &&
|
||||||
!model.state.seek_pending && (model.bufferedRange.map { $0.upperBound - model.state.position >= 20 } ?? false)
|
!model.state.seek_pending && (model.bufferedRange.map { $0.upperBound - model.state.position >= 20 } ?? false)
|
||||||
}
|
}
|
||||||
|
require(model.selectedSubtitle == nil && model.subtitleText.isEmpty, "manual changes preserve Off after automatic changes")
|
||||||
let selectedTitle = model.fileName
|
let selectedTitle = model.fileName
|
||||||
let selectedQuality = model.youtubeQuality
|
let selectedQuality = model.youtubeQuality
|
||||||
mark("gate-1080")
|
mark("gate-1080")
|
||||||
|
|||||||
@@ -7,16 +7,45 @@ from pathlib import Path
|
|||||||
import shlex
|
import shlex
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
from streaming import StreamingServer
|
from streaming import StreamingServer
|
||||||
|
|
||||||
|
|
||||||
|
class CaptionStreamingServer(StreamingServer):
|
||||||
|
def __init__(self, fixtures):
|
||||||
|
super().__init__(fixtures)
|
||||||
|
self.caption_requests = 0
|
||||||
|
|
||||||
|
def _get(self, request):
|
||||||
|
path = urlsplit(request.path).path
|
||||||
|
if path == "/caption-count":
|
||||||
|
self._reply(request, 200, str(self.caption_requests).encode())
|
||||||
|
return
|
||||||
|
if path == "/captions.vtt":
|
||||||
|
if request.headers.get("X-Subtitle") != "caption-private":
|
||||||
|
self._reply(request, 403, b"Missing caption credentials")
|
||||||
|
return
|
||||||
|
self.caption_requests += 1
|
||||||
|
self._reply(request, 200, b"WEBVTT\n\n00:00.000 --> 02:00.000\nAutomatic caption\n", "text/vtt")
|
||||||
|
return
|
||||||
|
super()._get(request)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
parser.add_argument("binary", type=Path)
|
parser.add_argument("binary", type=Path)
|
||||||
parser.add_argument("fixtures", type=Path)
|
parser.add_argument("fixtures", type=Path)
|
||||||
parser.add_argument("app", type=Path)
|
parser.add_argument("app", type=Path)
|
||||||
|
parser.add_argument("--cast-session", type=Path)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
if args.cast_session:
|
||||||
|
with CaptionStreamingServer(args.fixtures) as server:
|
||||||
|
result = subprocess.run([str(args.cast_session.resolve()), "--youtube-subtitles",
|
||||||
|
str(args.binary.resolve()), str(args.fixtures.resolve() / "cache.mp4")],
|
||||||
|
env=dict(os.environ, TEREBI_CAST_YOUTUBE_SOURCE=server.base_url), timeout=150, check=True)
|
||||||
|
assert server.caption_requests == 2, "only initial selection and local restoration fetch captions"
|
||||||
|
return result.returncode
|
||||||
macOS = args.app.resolve() / "Contents/MacOS"
|
macOS = args.app.resolve() / "Contents/MacOS"
|
||||||
extractor = str(macOS / "yt-dlp")
|
extractor = str(macOS / "yt-dlp")
|
||||||
runtime = macOS / "deno"
|
runtime = macOS / "deno"
|
||||||
@@ -25,7 +54,7 @@ def main():
|
|||||||
required = ("quality-1080.mp4", "quality-720.mp4")
|
required = ("quality-1080.mp4", "quality-720.mp4")
|
||||||
if any(not (args.fixtures / name).is_file() for name in required):
|
if any(not (args.fixtures / name).is_file() for name in required):
|
||||||
parser.error("Missing real quality playback fixtures")
|
parser.error("Missing real quality playback fixtures")
|
||||||
with StreamingServer(args.fixtures) as server, tempfile.TemporaryDirectory(prefix="terebi-quality-check-") as directory:
|
with CaptionStreamingServer(args.fixtures) as server, tempfile.TemporaryDirectory(prefix="terebi-quality-check-") as directory:
|
||||||
root = Path(directory)
|
root = Path(directory)
|
||||||
(root / "deno").symlink_to(runtime)
|
(root / "deno").symlink_to(runtime)
|
||||||
formats = []
|
formats = []
|
||||||
@@ -37,7 +66,9 @@ def main():
|
|||||||
"http_headers": {"X-Terebi-Fixture": "combined"}})
|
"http_headers": {"X-Terebi-Fixture": "combined"}})
|
||||||
info = {"id": "aqz-KE-bpKQ", "title": "Automatic quality playback fixture", "duration": 120,
|
info = {"id": "aqz-KE-bpKQ", "title": "Automatic quality playback fixture", "duration": 120,
|
||||||
"is_live": False, "webpage_url": "https://www.youtube.com/watch?v=aqz-KE-bpKQ",
|
"is_live": False, "webpage_url": "https://www.youtube.com/watch?v=aqz-KE-bpKQ",
|
||||||
"extractor": "youtube", "extractor_key": "Youtube", "formats": formats}
|
"extractor": "youtube", "extractor_key": "Youtube", "formats": formats,
|
||||||
|
"automatic_captions": {"en-orig": [{"ext": "vtt", "name": "English (Original)",
|
||||||
|
"url": server.base_url + "/captions.vtt", "http_headers": {"X-Subtitle": "caption-private"}}]}}
|
||||||
metadata = root / "info.json"
|
metadata = root / "info.json"
|
||||||
metadata.write_text(json.dumps(info))
|
metadata.write_text(json.dumps(info))
|
||||||
wrapper = root / "yt-dlp"
|
wrapper = root / "yt-dlp"
|
||||||
@@ -67,7 +98,7 @@ def main():
|
|||||||
+ ' "${args[@]:0:${#args[@]}-2}"\n')
|
+ ' "${args[@]:0:${#args[@]}-2}"\n')
|
||||||
wrapper.chmod(0o755)
|
wrapper.chmod(0o755)
|
||||||
environment = dict(os.environ, PATH=str(root) + os.pathsep + os.environ.get("PATH", ""),
|
environment = dict(os.environ, PATH=str(root) + os.pathsep + os.environ.get("PATH", ""),
|
||||||
TEREBI_QUALITY_CONTROL=str(control))
|
TEREBI_QUALITY_CONTROL=str(control), TEREBI_CAPTION_COUNT=server.base_url + "/caption-count")
|
||||||
try:
|
try:
|
||||||
result = subprocess.run([str(args.binary.resolve())], env=environment, timeout=90, check=False)
|
result = subprocess.run([str(args.binary.resolve())], env=environment, timeout=90, check=False)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
|
|||||||
@@ -211,7 +211,19 @@ struct CastPlaybackCheck {
|
|||||||
let suite = "terebi.cast-playback-check." + UUID().uuidString
|
let suite = "terebi.cast-playback-check." + UUID().uuidString
|
||||||
let defaults = UserDefaults(suiteName: suite)!
|
let defaults = UserDefaults(suiteName: suite)!
|
||||||
defer { defaults.removePersistentDomain(forName: suite) }
|
defer { defaults.removePersistentDomain(forName: suite) }
|
||||||
let model = PlayerModel(defaults: defaults, skipClock: { 100 })
|
let youtubeSource = ProcessInfo.processInfo.environment["TEREBI_CAST_YOUTUBE_SOURCE"]
|
||||||
|
let identity = youtubeSource == nil ? source : URL(string: "https://www.youtube.com/watch?v=AAAAAAAAAAA")!
|
||||||
|
let model = PlayerModel(defaults: defaults, youtubeResolver: { url, _ in
|
||||||
|
let base = youtubeSource!
|
||||||
|
return YouTubeVideo(title: "YouTube Cast captions", video: .init(url: base + "/media/cache.mp4",
|
||||||
|
headers: "X-Terebi-Fixture: combined\r\n"), audio: nil, duration: 120, isLive: false,
|
||||||
|
webpageURL: url, height: 1080, availableHeights: [1080], chapters: [],
|
||||||
|
subtitles: [
|
||||||
|
.init(url: base + "/captions.vtt", format: "vtt", language: "en", label: "English",
|
||||||
|
isDefault: false, headers: ["X-Subtitle": "caption-private"], selectionID: "youtube:manual:en"),
|
||||||
|
.init(url: base + "/unavailable.vtt", format: "vtt", language: "fr", label: "Unavailable",
|
||||||
|
isDefault: false, headers: [:], selectionID: "youtube:manual:fr")])
|
||||||
|
}, youtubeTitleResolver: { _ in nil }, skipClock: { 100 })
|
||||||
defer { model.shutdown() }
|
defer { model.shutdown() }
|
||||||
if CommandLine.arguments.count == 5 {
|
if CommandLine.arguments.count == 5 {
|
||||||
if CommandLine.arguments[3] == "queue" {
|
if CommandLine.arguments[3] == "queue" {
|
||||||
@@ -223,7 +235,7 @@ struct CastPlaybackCheck {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
model.setVolume(0)
|
model.setVolume(0)
|
||||||
model.open(source)
|
model.open(identity)
|
||||||
try await until(model, "local source plays") { model.state.status == PLAYER_PLAYING && model.canSeek }
|
try await until(model, "local source plays") { model.state.status == PLAYER_PLAYING && model.canSeek }
|
||||||
requirePlaybackPower(system: true, display: true, "local video prevents system and display sleep")
|
requirePlaybackPower(system: true, display: true, "local video prevents system and display sleep")
|
||||||
require(model.setPlayback(playing: false), "local pause accepted")
|
require(model.setPlayback(playing: false), "local pause accepted")
|
||||||
@@ -232,6 +244,10 @@ struct CastPlaybackCheck {
|
|||||||
require(model.seekRelative(9.25 - model.state.position), "local restoration position accepted")
|
require(model.seekRelative(9.25 - model.state.position), "local restoration position accepted")
|
||||||
require(!model.canCast, "Cast cannot transfer an unacknowledged local seek")
|
require(!model.canCast, "Cast cannot transfer an unacknowledged local seek")
|
||||||
try await until(model, "local position acknowledged") { !model.state.seek_pending && abs(model.state.position - 9.25) < 0.15 }
|
try await until(model, "local position acknowledged") { !model.state.seek_pending && abs(model.state.position - 9.25) < 0.15 }
|
||||||
|
if youtubeSource != nil {
|
||||||
|
model.selectSubtitle(0)
|
||||||
|
try await until(model, "local YouTube caption selected before handoff") { model.subtitleText == "Automatic caption" }
|
||||||
|
}
|
||||||
model.cast(to: CastDevice(id: "loopback", name: "Loopback TV", host: "127.0.0.1", port: port))
|
model.cast(to: CastDevice(id: "loopback", name: "Loopback TV", host: "127.0.0.1", port: port))
|
||||||
requirePlaybackPower(system: true, display: false, "pending Cast handoff protects the relay")
|
requirePlaybackPower(system: true, display: false, "pending Cast handoff protects the relay")
|
||||||
try await until(model, "receiver load acknowledges paused handoff") {
|
try await until(model, "receiver load acknowledges paused handoff") {
|
||||||
@@ -240,6 +256,12 @@ struct CastPlaybackCheck {
|
|||||||
requirePlaybackPower(system: false, display: false, "paused Cast permits sleep")
|
requirePlaybackPower(system: false, display: false, "paused Cast permits sleep")
|
||||||
require(abs(model.state.position - 9.25) < 0.15, "Cast starts at acknowledged local position")
|
require(abs(model.state.position - 9.25) < 0.15, "Cast starts at acknowledged local position")
|
||||||
require(!model.canStepFrame && model.bufferedRange == nil, "local decoder-only controls are not exposed while casting")
|
require(!model.canStepFrame && model.bufferedRange == nil, "local decoder-only controls are not exposed while casting")
|
||||||
|
if youtubeSource != nil {
|
||||||
|
require(!model.canSelectSubtitle && model.selectedSubtitle == 0 && model.subtitleText.isEmpty,
|
||||||
|
"YouTube Cast disables local captions without losing the local choice")
|
||||||
|
model.selectSubtitle(1)
|
||||||
|
require(model.commandError == nil && model.selectedSubtitle == 0, "unsupported Cast captions cannot interrupt playback")
|
||||||
|
}
|
||||||
require(model.setPlayback(playing: true), "remote play accepted")
|
require(model.setPlayback(playing: true), "remote play accepted")
|
||||||
try await until(model, "receiver play acknowledged") { model.state.status == PLAYER_PLAYING && model.playbackIntent == nil }
|
try await until(model, "receiver play acknowledged") { model.state.status == PLAYER_PLAYING && model.playbackIntent == nil }
|
||||||
requirePlaybackPower(system: true, display: false, "playing Cast protects the relay but permits display sleep")
|
requirePlaybackPower(system: true, display: false, "playing Cast protects the relay but permits display sleep")
|
||||||
@@ -266,7 +288,12 @@ struct CastPlaybackCheck {
|
|||||||
abs(model.state.position - 24.25) < 0.15 && !model.isBusy
|
abs(model.state.position - 24.25) < 0.15 && !model.isBusy
|
||||||
}
|
}
|
||||||
requirePlaybackPower(system: false, display: false, "paused local restoration permits sleep")
|
requirePlaybackPower(system: false, display: false, "paused local restoration permits sleep")
|
||||||
require(abs((PlaybackProgress(defaults: defaults).position(for: source) ?? 0) - 24.25) < 0.15,
|
if youtubeSource != nil {
|
||||||
|
try await until(model, "return to Mac restores local YouTube caption selection") {
|
||||||
|
model.canSelectSubtitle && model.selectedSubtitle == 0 && model.subtitleText == "Automatic caption"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require(abs((PlaybackProgress(defaults: defaults).position(for: identity) ?? 0) - 24.25) < 0.15,
|
||||||
"remote history is not overwritten by the abandoned local position")
|
"remote history is not overwritten by the abandoned local position")
|
||||||
require(model.volume == 0, "local volume survives receiver volume changes")
|
require(model.volume == 0, "local volume survives receiver volume changes")
|
||||||
require(model.setPlayback(playing: true), "resume local playback before a failed Cast connection")
|
require(model.setPlayback(playing: true), "resume local playback before a failed Cast connection")
|
||||||
|
|||||||
@@ -412,6 +412,16 @@ struct CastSessionCheck {
|
|||||||
let fallbackID = discovery.devices.first { $0.name == prefix + "-fallback" }!.id
|
let fallbackID = discovery.devices.first { $0.name == prefix + "-fallback" }!.id
|
||||||
let device = ownDevices()[0]
|
let device = ownDevices()[0]
|
||||||
require(device.port == second.port?.rawValue, "Discovery preserves the advertised Cast port")
|
require(device.port == second.port?.rawValue, "Discovery preserves the advertised Cast port")
|
||||||
|
for device in discovery.devices {
|
||||||
|
guard case .ipv4 = NWEndpoint.Host(device.host) else { continue }
|
||||||
|
var hints = addrinfo()
|
||||||
|
hints.ai_family = AF_INET
|
||||||
|
hints.ai_socktype = SOCK_DGRAM
|
||||||
|
var addresses: UnsafeMutablePointer<addrinfo>?
|
||||||
|
let result = getaddrinfo(device.host, "8009", &hints, &addresses)
|
||||||
|
if let addresses { freeaddrinfo(addresses) }
|
||||||
|
require(result == 0, "Discovered IPv4 receiver resolves through the native relay's POSIX API")
|
||||||
|
}
|
||||||
let connection = NWConnection(host: NWEndpoint.Host(device.host),
|
let connection = NWConnection(host: NWEndpoint.Host(device.host),
|
||||||
port: NWEndpoint.Port(rawValue: device.port)!, using: .tcp)
|
port: NWEndpoint.Port(rawValue: device.port)!, using: .tcp)
|
||||||
defer { connection.cancel() }
|
defer { connection.cancel() }
|
||||||
@@ -869,6 +879,12 @@ struct CastSessionCheck {
|
|||||||
try await until("Disconnect STOP reaches receiver") { peer.commands.contains { $0.0 == "STOP" } }
|
try await until("Disconnect STOP reaches receiver") { peer.commands.contains { $0.0 == "STOP" } }
|
||||||
let load = peer.commands.first { $0.0 == "LOAD" }!.1
|
let load = peer.commands.first { $0.0 == "LOAD" }!.1
|
||||||
require(load["currentTime"] as? Double == 9.25 && load["autoplay"] as? Bool == false, "Acknowledged paused handoff")
|
require(load["currentTime"] as? Double == 9.25 && load["autoplay"] as? Bool == false, "Acknowledged paused handoff")
|
||||||
|
if ProcessInfo.processInfo.environment["TEREBI_CAST_YOUTUBE_SOURCE"] != nil {
|
||||||
|
let media = load["media"] as! [String: Any]
|
||||||
|
require((media["tracks"] as? [Any] ?? []).isEmpty &&
|
||||||
|
(load["activeTrackIds"] as? [Any] ?? []).isEmpty,
|
||||||
|
"YouTube Cast LOAD contains no captions or invalid local track selection")
|
||||||
|
}
|
||||||
require(peer.relayVerified, "Receiver fetches real relay HLS and media bytes")
|
require(peer.relayVerified, "Receiver fetches real relay HLS and media bytes")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1024,6 +1040,11 @@ struct CastSessionCheck {
|
|||||||
print("PASS: latest slider intent, stale volume feedback, authoritative acknowledgement, external TV changes, timeout")
|
print("PASS: latest slider intent, stale volume feedback, authoritative acknowledgement, external TV changes, timeout")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if CommandLine.arguments.count == 4, CommandLine.arguments[1] == "--youtube-subtitles" {
|
||||||
|
try await playback(identity: identity, executable: CommandLine.arguments[2], file: CommandLine.arguments[3])
|
||||||
|
print("PASS: YouTube Cast omits optional captions and restores the local choice on return")
|
||||||
|
return
|
||||||
|
}
|
||||||
if CommandLine.arguments.count == 4, CommandLine.arguments[1] == "--playback" {
|
if CommandLine.arguments.count == 4, CommandLine.arguments[1] == "--playback" {
|
||||||
try await playback(identity: identity, executable: CommandLine.arguments[2], file: CommandLine.arguments[3])
|
try await playback(identity: identity, executable: CommandLine.arguments[2], file: CommandLine.arguments[3])
|
||||||
print("PASS: native TLS receiver fetches relay and acknowledges PlayerModel handoff/control/disconnect")
|
print("PASS: native TLS receiver fetches relay and acknowledges PlayerModel handoff/control/disconnect")
|
||||||
|
|||||||
+131
-54
@@ -99,12 +99,20 @@ struct CompactWindowChecks {
|
|||||||
line: UInt = #line, checking: () throws -> Void = {}) throws {
|
line: UInt = #line, checking: () throws -> Void = {}) throws {
|
||||||
let deadline = ContinuousClock.now.advanced(by: .seconds(5))
|
let deadline = ContinuousClock.now.advanced(by: .seconds(5))
|
||||||
while controller.isTransitioning {
|
while controller.isTransitioning {
|
||||||
|
if let presentation = controller.presentationWindow {
|
||||||
|
try require(!presentation.isVisible && !presentation.isKeyWindow,
|
||||||
|
"native transitions never display or key a hidden presentation")
|
||||||
|
}
|
||||||
try checking()
|
try checking()
|
||||||
try require(ContinuousClock.now < deadline, "native compact transition completes (call at line \(line), compact \(controller.isCompact), target \(compact))")
|
try require(ContinuousClock.now < deadline, "native compact transition completes (call at line \(line), compact \(controller.isCompact), target \(compact))")
|
||||||
CFRunLoopRunInMode(.defaultMode, 0.01, false)
|
CFRunLoopRunInMode(.defaultMode, 0.01, false)
|
||||||
}
|
}
|
||||||
try checking()
|
try checking()
|
||||||
try require(controller.isCompact == compact, "settled mode matches the latest destination")
|
try require(controller.isCompact == compact, "settled mode matches the latest destination")
|
||||||
|
if let presentation = controller.presentationWindow {
|
||||||
|
try require(!presentation.isVisible && !presentation.isKeyWindow,
|
||||||
|
"settled hidden presentation remains undisplayed and non-key")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -151,6 +159,12 @@ struct CompactWindowChecks {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class WindowDelegate: NSObject, NSWindowDelegate {
|
||||||
|
var closes = 0
|
||||||
|
func windowWillClose(_ notification: Notification) { closes += 1 }
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class WindowFixture {
|
final class WindowFixture {
|
||||||
let window: NSWindow
|
let window: NSWindow
|
||||||
@@ -158,7 +172,8 @@ struct CompactWindowChecks {
|
|||||||
let sentinel: NSView
|
let sentinel: NSView
|
||||||
let sentinelFrame: NSRect
|
let sentinelFrame: NSRect
|
||||||
let original: WindowState
|
let original: WindowState
|
||||||
let identity: ObjectIdentifier
|
let delegate = WindowDelegate()
|
||||||
|
let activationPolicy: NSApplication.ActivationPolicy
|
||||||
|
|
||||||
init() throws {
|
init() throws {
|
||||||
// Never orderFront, makeKey, or create an actual native fullscreen Space.
|
// Never orderFront, makeKey, or create an actual native fullscreen Space.
|
||||||
@@ -180,19 +195,24 @@ struct CompactWindowChecks {
|
|||||||
window.level = .normal
|
window.level = .normal
|
||||||
window.hidesOnDeactivate = true
|
window.hidesOnDeactivate = true
|
||||||
window.isMovableByWindowBackground = false
|
window.isMovableByWindowBackground = false
|
||||||
|
window.delegate = delegate
|
||||||
original = WindowState(window)
|
original = WindowState(window)
|
||||||
identity = ObjectIdentifier(window)
|
activationPolicy = NSApp.activationPolicy()
|
||||||
}
|
}
|
||||||
|
|
||||||
func preservedContent() throws {
|
func preservedContent(in presentation: NSWindow? = nil) throws {
|
||||||
try CompactWindowChecks.require(ObjectIdentifier(window) == identity && sentinel.window === window,
|
let presentation = presentation ?? window
|
||||||
"compact mode keeps the actual rendering window")
|
try CompactWindowChecks.require(sentinel.window === presentation
|
||||||
try CompactWindowChecks.require(window.contentView === content && sentinel.superview === content
|
&& presentation.contentView === content
|
||||||
|
&& sentinel.superview === content
|
||||||
&& content.subviews.count == 1
|
&& content.subviews.count == 1
|
||||||
&& CompactWindowChecks.sameFrame(sentinel.frame, sentinelFrame),
|
&& CompactWindowChecks.sameFrame(sentinel.frame, sentinelFrame),
|
||||||
"compact mode preserves content and attached sentinel without reparenting")
|
"presentation retains the same content subtree and renderer sentinel")
|
||||||
try CompactWindowChecks.require(!window.isVisible && !window.isKeyWindow,
|
try CompactWindowChecks.require(!window.isVisible && !window.isKeyWindow
|
||||||
"checks never display or key a window")
|
&& !presentation.isVisible && !presentation.isKeyWindow,
|
||||||
|
"hidden checks never display or key either window")
|
||||||
|
try CompactWindowChecks.require(NSApp.activationPolicy() == activationPolicy,
|
||||||
|
"compact preserves the app's Dock and CmdTab activation policy")
|
||||||
}
|
}
|
||||||
|
|
||||||
func restored() throws {
|
func restored() throws {
|
||||||
@@ -200,28 +220,35 @@ struct CompactWindowChecks {
|
|||||||
try preservedContent()
|
try preservedContent()
|
||||||
}
|
}
|
||||||
|
|
||||||
func compact(aspect: Double) throws {
|
func compact(_ controller: CompactWindowController, aspect: Double) throws {
|
||||||
try CompactWindowChecks.require(window.level == .floating && !window.hidesOnDeactivate,
|
guard let panel = controller.presentationWindow as? NSPanel else {
|
||||||
|
throw Failure(description: "FAIL: compact presents a dedicated NSPanel")
|
||||||
|
}
|
||||||
|
try CompactWindowChecks.require(panel !== window && panel.styleMask.contains(.nonactivatingPanel),
|
||||||
|
"compact uses a nonactivating panel, not the normal window")
|
||||||
|
try CompactWindowChecks.require(window.contentView !== content,
|
||||||
|
"hidden normal window relinquishes the renderer content")
|
||||||
|
try original.requireMatches(window)
|
||||||
|
try CompactWindowChecks.require(panel.level == .floating && !panel.hidesOnDeactivate,
|
||||||
"compact floats and remains visible on deactivate")
|
"compact floats and remains visible on deactivate")
|
||||||
try CompactWindowChecks.require(window.collectionBehavior.contains(
|
try CompactWindowChecks.require(panel.collectionBehavior.contains(
|
||||||
[.canJoinAllSpaces, .canJoinAllApplications, .fullScreenAuxiliary]),
|
[.canJoinAllSpaces, .canJoinAllApplications, .fullScreenAuxiliary]),
|
||||||
"compact can join Spaces, other-app fullscreen, and Stage Manager")
|
"compact can join Spaces, other-app fullscreen, and Stage Manager")
|
||||||
try CompactWindowChecks.require(window.collectionBehavior.intersection(
|
try CompactWindowChecks.require(panel.collectionBehavior.intersection(
|
||||||
[.primary, .auxiliary, .fullScreenPrimary, .fullScreenNone]).isEmpty,
|
[.primary, .auxiliary, .fullScreenPrimary, .fullScreenNone]).isEmpty,
|
||||||
"compact removes incompatible primary and auxiliary policies")
|
"compact removes incompatible primary and auxiliary policies")
|
||||||
try CompactWindowChecks.require(window.contentMinSize == NSSize(width: 160, height: 90),
|
try CompactWindowChecks.require(panel.contentMinSize == NSSize(width: 160, height: 90),
|
||||||
"compact uses 160x90 content minimum")
|
"compact uses 160x90 content minimum")
|
||||||
try CompactWindowChecks.require(window.canBecomeKey, "compact accepts keyboard playback controls")
|
try CompactWindowChecks.require(panel.canBecomeKey, "compact accepts keyboard playback controls")
|
||||||
try CompactWindowChecks.require(window.titleVisibility == .hidden
|
try CompactWindowChecks.require(panel.titleVisibility == .hidden && panel.titlebarAppearsTransparent,
|
||||||
&& window.titlebarAppearsTransparent,
|
|
||||||
"compact hides native title chrome")
|
"compact hides native title chrome")
|
||||||
let visible = (window.screen ?? NSScreen.main!).visibleFrame
|
let visible = (panel.screen ?? NSScreen.main!).visibleFrame
|
||||||
try CompactWindowChecks.require(CompactWindowChecks.close(window.frame.maxX, visible.maxX - 16)
|
try CompactWindowChecks.require(CompactWindowChecks.close(panel.frame.maxX, visible.maxX - 16)
|
||||||
&& CompactWindowChecks.close(window.frame.maxY, visible.maxY - 16)
|
&& CompactWindowChecks.close(panel.frame.maxY, visible.maxY - 16)
|
||||||
&& CompactWindowChecks.close(window.frame.width / window.frame.height,
|
&& CompactWindowChecks.close(panel.frame.width / panel.frame.height,
|
||||||
CGFloat(aspect)),
|
CGFloat(aspect)),
|
||||||
"compact enters at inset top right with playback aspect")
|
"compact enters at inset top right with playback aspect")
|
||||||
try preservedContent()
|
try preservedContent(in: panel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,7 +265,7 @@ struct CompactWindowChecks {
|
|||||||
controller.toggle(aspect: 16.0 / 9.0)
|
controller.toggle(aspect: 16.0 / 9.0)
|
||||||
try require(controller.isTransitioning, "native completion barrier is an actual animated entry")
|
try require(controller.isTransitioning, "native completion barrier is an actual animated entry")
|
||||||
try settled(controller, compact: true, checking: checking)
|
try settled(controller, compact: true, checking: checking)
|
||||||
try fixture.compact(aspect: 16.0 / 9.0)
|
try fixture.compact(controller, aspect: 16.0 / 9.0)
|
||||||
controller.restore(resumeFullScreen: false)
|
controller.restore(resumeFullScreen: false)
|
||||||
try settled(controller, compact: false, checking: checking)
|
try settled(controller, compact: false, checking: checking)
|
||||||
try fixture.restored()
|
try fixture.restored()
|
||||||
@@ -257,47 +284,53 @@ struct CompactWindowChecks {
|
|||||||
window.close()
|
window.close()
|
||||||
}
|
}
|
||||||
controller.attach(window)
|
controller.attach(window)
|
||||||
try require(!controller.isCompact && !controller.isTransitioning, "attached window starts ordinary")
|
try require(!controller.isCompact && !controller.isTransitioning
|
||||||
|
&& controller.presentationWindow === window, "attached window starts ordinary")
|
||||||
try require(controller.snappingEnabled, "isolated controller defaults snapping on")
|
try require(controller.snappingEnabled, "isolated controller defaults snapping on")
|
||||||
|
|
||||||
controller.toggle(aspect: 16.0 / 9.0)
|
controller.toggle(aspect: 16.0 / 9.0)
|
||||||
try require(controller.isCompact && controller.isTransitioning,
|
try require(controller.isCompact && controller.isTransitioning,
|
||||||
"entry presents the compact destination while native geometry settles")
|
"entry presents the compact destination while native geometry settles")
|
||||||
try fixture.preservedContent()
|
try fixture.preservedContent(in: controller.presentationWindow)
|
||||||
try settled(controller, compact: true)
|
try settled(controller, compact: true)
|
||||||
try fixture.compact(aspect: 16.0 / 9.0)
|
try fixture.compact(controller, aspect: 16.0 / 9.0)
|
||||||
|
|
||||||
let visible = (window.screen ?? NSScreen.main!).visibleFrame
|
let panel = controller.presentationWindow!
|
||||||
|
let visible = (panel.screen ?? NSScreen.main!).visibleFrame
|
||||||
let nearEdge = NSRect(x: visible.minX + 26, y: visible.minY + 27,
|
let nearEdge = NSRect(x: visible.minX + 26, y: visible.minY + 27,
|
||||||
width: window.frame.width, height: window.frame.height)
|
width: panel.frame.width, height: panel.frame.height)
|
||||||
window.setFrame(nearEdge, display: false)
|
panel.setFrame(nearEdge, display: false)
|
||||||
try require(sameFrame(window.frame, nearEdge), "native move installs unsnapped drag position")
|
try require(sameFrame(panel.frame, nearEdge), "native move installs unsnapped drag position")
|
||||||
controller.snapAfterDrag()
|
controller.snapAfterDrag()
|
||||||
try require(close(window.frame.minX, visible.minX + 16) && close(window.frame.minY, visible.minY + 16)
|
try require(close(panel.frame.minX, visible.minX + 16) && close(panel.frame.minY, visible.minY + 16)
|
||||||
&& window.frame.size == nearEdge.size, "enabled preference snaps the actual moved window")
|
&& panel.frame.size == nearEdge.size, "enabled preference snaps the actual moved window")
|
||||||
controller.snappingEnabled = false
|
controller.snappingEnabled = false
|
||||||
let second = CompactWindowController(snappingEnabled: false, reduceMotion: { false })
|
let second = CompactWindowController(snappingEnabled: false, reduceMotion: { false })
|
||||||
try require(!second.snappingEnabled, "configuration can disable snapping for a new controller")
|
try require(!second.snappingEnabled, "configuration can disable snapping for a new controller")
|
||||||
window.setFrame(nearEdge, display: false)
|
panel.setFrame(nearEdge, display: false)
|
||||||
controller.snapAfterDrag()
|
controller.snapAfterDrag()
|
||||||
try require(sameFrame(window.frame, nearEdge), "disabled preference leaves an actual moved window untouched")
|
try require(sameFrame(panel.frame, nearEdge), "disabled preference leaves an actual moved window untouched")
|
||||||
second.snappingEnabled = true
|
second.snappingEnabled = true
|
||||||
let third = CompactWindowController(snappingEnabled: false, reduceMotion: { false })
|
let third = CompactWindowController(snappingEnabled: false, reduceMotion: { false })
|
||||||
try require(!third.snappingEnabled, "a session override does not change the next controller's settings")
|
try require(!third.snappingEnabled, "a session override does not change the next controller's settings")
|
||||||
controller.snappingEnabled = true
|
controller.snappingEnabled = true
|
||||||
let resized = NSRect(x: nearEdge.minX, y: nearEdge.minY, width: 240, height: 135)
|
let resized = NSRect(x: nearEdge.minX, y: nearEdge.minY, width: 240, height: 135)
|
||||||
window.setFrame(resized, display: false)
|
panel.setFrame(resized, display: false)
|
||||||
controller.updateAspect(16.0 / 9.0)
|
controller.updateAspect(16.0 / 9.0)
|
||||||
try require(sameFrame(window.frame, resized),
|
try require(sameFrame(panel.frame, resized),
|
||||||
"repeated aspect updates preserve a user's compact resize and placement")
|
"repeated aspect updates preserve a user's compact resize and placement")
|
||||||
controller.updateAspect(4.0 / 3.0)
|
controller.updateAspect(4.0 / 3.0)
|
||||||
try require(controller.isCompact && !controller.isTransitioning, "aspect update retains compact mode")
|
try require(controller.isCompact && !controller.isTransitioning, "aspect update retains compact mode")
|
||||||
try require(close(window.frame.width / window.frame.height, 4.0 / 3.0),
|
try require(close(panel.frame.width / panel.frame.height, 4.0 / 3.0),
|
||||||
"actual compact window follows changed playback aspect")
|
"actual compact window follows changed playback aspect")
|
||||||
try fixture.preservedContent()
|
try fixture.preservedContent(in: panel)
|
||||||
controller.toggle(aspect: 4.0 / 3.0)
|
controller.toggle(aspect: 4.0 / 3.0)
|
||||||
|
try require(controller.presentationWindow === panel,
|
||||||
|
"restoring continues presenting the panel until content is handed back")
|
||||||
try settled(controller, compact: false)
|
try settled(controller, compact: false)
|
||||||
try fixture.restored()
|
try fixture.restored()
|
||||||
|
try require(controller.presentationWindow === window && panel.contentView !== fixture.content
|
||||||
|
&& !panel.isVisible && !panel.isKeyWindow, "restoration retires the compact panel")
|
||||||
|
|
||||||
// Enter -> exit before entry completes: the original normal snapshot must survive.
|
// Enter -> exit before entry completes: the original normal snapshot must survive.
|
||||||
controller.toggle(aspect: 16.0 / 9.0)
|
controller.toggle(aspect: 16.0 / 9.0)
|
||||||
@@ -314,7 +347,7 @@ struct CompactWindowChecks {
|
|||||||
"exit presents the ordinary destination while native geometry settles")
|
"exit presents the ordinary destination while native geometry settles")
|
||||||
controller.toggle(aspect: 4.0 / 3.0)
|
controller.toggle(aspect: 4.0 / 3.0)
|
||||||
try settled(controller, compact: true)
|
try settled(controller, compact: true)
|
||||||
try fixture.compact(aspect: 4.0 / 3.0)
|
try fixture.compact(controller, aspect: 4.0 / 3.0)
|
||||||
controller.restore(resumeFullScreen: false)
|
controller.restore(resumeFullScreen: false)
|
||||||
try settled(controller, compact: false)
|
try settled(controller, compact: false)
|
||||||
try fixture.restored()
|
try fixture.restored()
|
||||||
@@ -328,7 +361,7 @@ struct CompactWindowChecks {
|
|||||||
}
|
}
|
||||||
try settled(controller, compact: compactDestination)
|
try settled(controller, compact: compactDestination)
|
||||||
if compactDestination {
|
if compactDestination {
|
||||||
try fixture.compact(aspect: 16.0 / 9.0)
|
try fixture.compact(controller, aspect: 16.0 / 9.0)
|
||||||
controller.restore(resumeFullScreen: false)
|
controller.restore(resumeFullScreen: false)
|
||||||
try settled(controller, compact: false)
|
try settled(controller, compact: false)
|
||||||
}
|
}
|
||||||
@@ -354,17 +387,39 @@ struct CompactWindowChecks {
|
|||||||
"stale completion cannot revive an urgently restored session")
|
"stale completion cannot revive an urgently restored session")
|
||||||
try fixture.restored()
|
try fixture.restored()
|
||||||
}
|
}
|
||||||
|
controller.toggle(aspect: 16.0 / 9.0)
|
||||||
|
try settled(controller, compact: true)
|
||||||
|
let interruptedPanel = controller.presentationWindow!
|
||||||
|
controller.restore(resumeFullScreen: false)
|
||||||
|
try require(controller.isTransitioning && controller.presentationWindow === interruptedPanel,
|
||||||
|
"animated restoration keeps presenting the renderer's panel")
|
||||||
|
try fixture.preservedContent(in: interruptedPanel)
|
||||||
|
controller.restore(resumeFullScreen: false, animated: false)
|
||||||
|
try require(!controller.isTransitioning && controller.presentationWindow === window
|
||||||
|
&& interruptedPanel.contentView !== fixture.content,
|
||||||
|
"urgent restore interrupts exit and returns content immediately")
|
||||||
|
try fixture.restored()
|
||||||
|
try nativeCompletionOpportunity {
|
||||||
|
try require(controller.presentationWindow === window && !controller.isTransitioning,
|
||||||
|
"stale exit completion cannot reclaim restored content")
|
||||||
|
try fixture.restored()
|
||||||
|
}
|
||||||
|
|
||||||
let replacement = try WindowFixture()
|
let replacement = try WindowFixture()
|
||||||
defer { replacement.window.close() }
|
defer { replacement.window.close() }
|
||||||
controller.toggle(aspect: 16.0 / 9.0)
|
controller.toggle(aspect: 16.0 / 9.0)
|
||||||
try require(controller.isTransitioning, "attachment interrupts native entry")
|
try require(controller.isTransitioning, "attachment interrupts native entry")
|
||||||
|
let replacedPanel = controller.presentationWindow!
|
||||||
controller.attach(replacement.window)
|
controller.attach(replacement.window)
|
||||||
try require(!controller.isCompact && !controller.isTransitioning, "replacement starts ordinary")
|
try require(!controller.isCompact && !controller.isTransitioning, "replacement starts ordinary")
|
||||||
try fixture.restored()
|
try fixture.restored()
|
||||||
|
try require(controller.presentationWindow === replacement.window
|
||||||
|
&& window.delegate === fixture.delegate && replacedPanel.contentView !== fixture.content
|
||||||
|
&& !replacedPanel.isVisible && !replacedPanel.isKeyWindow,
|
||||||
|
"replacement restores old delegate and content and retires its panel")
|
||||||
controller.toggle(aspect: 4.0 / 3.0)
|
controller.toggle(aspect: 4.0 / 3.0)
|
||||||
try settled(controller, compact: true) { try fixture.restored() }
|
try settled(controller, compact: true) { try fixture.restored() }
|
||||||
try replacement.compact(aspect: 4.0 / 3.0)
|
try replacement.compact(controller, aspect: 4.0 / 3.0)
|
||||||
controller.restore(resumeFullScreen: false)
|
controller.restore(resumeFullScreen: false)
|
||||||
try settled(controller, compact: false) { try fixture.restored() }
|
try settled(controller, compact: false) { try fixture.restored() }
|
||||||
try replacement.restored()
|
try replacement.restored()
|
||||||
@@ -386,8 +441,13 @@ struct CompactWindowChecks {
|
|||||||
|
|
||||||
controller.toggle(aspect: 16.0 / 9.0)
|
controller.toggle(aspect: 16.0 / 9.0)
|
||||||
try require(controller.isTransitioning, "close interrupts native entry")
|
try require(controller.isTransitioning, "close interrupts native entry")
|
||||||
|
let closingPanel = controller.presentationWindow!
|
||||||
window.close()
|
window.close()
|
||||||
try require(!controller.isCompact && !controller.isTransitioning, "closed window ends its session")
|
try require(!controller.isCompact && !controller.isTransitioning, "closed window ends its session")
|
||||||
|
try require(controller.presentationWindow == nil && window.delegate === fixture.delegate
|
||||||
|
&& fixture.delegate.closes == 1 && closingPanel.contentView !== fixture.content
|
||||||
|
&& !closingPanel.isVisible && !closingPanel.isKeyWindow,
|
||||||
|
"closing the normal window restores its delegate and closes its compact panel")
|
||||||
let closedState = WindowState(window)
|
let closedState = WindowState(window)
|
||||||
controller.attach(replacement.window)
|
controller.attach(replacement.window)
|
||||||
controller.toggle(aspect: 16.0 / 9.0)
|
controller.toggle(aspect: 16.0 / 9.0)
|
||||||
@@ -396,10 +456,23 @@ struct CompactWindowChecks {
|
|||||||
try fixture.preservedContent()
|
try fixture.preservedContent()
|
||||||
}
|
}
|
||||||
try settled(controller, compact: true, checking: closedWindowUnchanged)
|
try settled(controller, compact: true, checking: closedWindowUnchanged)
|
||||||
try replacement.compact(aspect: 16.0 / 9.0)
|
try replacement.compact(controller, aspect: 16.0 / 9.0)
|
||||||
controller.restore(resumeFullScreen: false)
|
controller.restore(resumeFullScreen: false)
|
||||||
try settled(controller, compact: false, checking: closedWindowUnchanged)
|
try settled(controller, compact: false, checking: closedWindowUnchanged)
|
||||||
try replacement.restored()
|
try replacement.restored()
|
||||||
|
controller.toggle(aspect: 16.0 / 9.0)
|
||||||
|
let userClosedPanel = controller.presentationWindow!
|
||||||
|
userClosedPanel.close()
|
||||||
|
try require(!controller.isCompact && !controller.isTransitioning
|
||||||
|
&& controller.presentationWindow == nil && replacement.delegate.closes == 1
|
||||||
|
&& replacement.window.delegate === replacement.delegate,
|
||||||
|
"closing compact presentation also closes and detaches the normal window")
|
||||||
|
try replacement.restored()
|
||||||
|
try nativeCompletionOpportunity {
|
||||||
|
try require(controller.presentationWindow == nil && !controller.isTransitioning,
|
||||||
|
"stale completion cannot revive a user-closed compact panel")
|
||||||
|
try replacement.restored()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static func rememberedSize() throws {
|
static func rememberedSize() throws {
|
||||||
@@ -414,19 +487,21 @@ struct CompactWindowChecks {
|
|||||||
}
|
}
|
||||||
controller.attach(fixture.window)
|
controller.attach(fixture.window)
|
||||||
controller.toggle(aspect: 16.0 / 9.0)
|
controller.toggle(aspect: 16.0 / 9.0)
|
||||||
var resized = fixture.window.frame
|
let panel = controller.presentationWindow!
|
||||||
|
var resized = panel.frame
|
||||||
resized.size = NSSize(width: 240, height: 135)
|
resized.size = NSSize(width: 240, height: 135)
|
||||||
fixture.window.setFrame(resized, display: false)
|
panel.setFrame(resized, display: false)
|
||||||
NotificationCenter.default.post(name: NSWindow.didEndLiveResizeNotification, object: fixture.window)
|
NotificationCenter.default.post(name: NSWindow.didEndLiveResizeNotification, object: panel)
|
||||||
controller.restore(resumeFullScreen: false)
|
controller.restore(resumeFullScreen: false)
|
||||||
try fixture.restored()
|
try fixture.restored()
|
||||||
controller.toggle(aspect: 16.0 / 9.0)
|
controller.toggle(aspect: 16.0 / 9.0)
|
||||||
try require(close(fixture.window.frame.width, 240) && close(fixture.window.frame.height, 135),
|
let reentered = controller.presentationWindow!
|
||||||
|
try require(close(reentered.frame.width, 240) && close(reentered.frame.height, 135),
|
||||||
"reentering compact restores the manually chosen size")
|
"reentering compact restores the manually chosen size")
|
||||||
|
|
||||||
// Programmatic fitting and ordinary-window resizing must not replace the preference.
|
// Programmatic fitting and ordinary-window resizing must not replace the preference.
|
||||||
resized.size = NSSize(width: 320, height: 180)
|
resized.size = NSSize(width: 320, height: 180)
|
||||||
fixture.window.setFrame(resized, display: false)
|
reentered.setFrame(resized, display: false)
|
||||||
controller.updateAspect(4.0 / 3.0)
|
controller.updateAspect(4.0 / 3.0)
|
||||||
controller.restore(resumeFullScreen: false)
|
controller.restore(resumeFullScreen: false)
|
||||||
try fixture.restored()
|
try fixture.restored()
|
||||||
@@ -440,9 +515,10 @@ struct CompactWindowChecks {
|
|||||||
}
|
}
|
||||||
reloaded.attach(replacement.window)
|
reloaded.attach(replacement.window)
|
||||||
reloaded.toggle(aspect: 4.0 / 3.0)
|
reloaded.toggle(aspect: 4.0 / 3.0)
|
||||||
try require(close(replacement.window.frame.width, 240) && close(replacement.window.frame.height, 180),
|
let reloadedPanel = reloaded.presentationWindow!
|
||||||
|
try require(close(reloadedPanel.frame.width, 240) && close(reloadedPanel.frame.height, 180),
|
||||||
"a recreated controller restores preferred width with the new video's aspect")
|
"a recreated controller restores preferred width with the new video's aspect")
|
||||||
try replacement.compact(aspect: 4.0 / 3.0)
|
try replacement.compact(reloaded, aspect: 4.0 / 3.0)
|
||||||
reloaded.restore(resumeFullScreen: false)
|
reloaded.restore(resumeFullScreen: false)
|
||||||
try replacement.restored()
|
try replacement.restored()
|
||||||
}
|
}
|
||||||
@@ -458,7 +534,7 @@ struct CompactWindowChecks {
|
|||||||
controller.toggle(aspect: 16.0 / 9.0)
|
controller.toggle(aspect: 16.0 / 9.0)
|
||||||
try require(controller.isCompact && !controller.isTransitioning,
|
try require(controller.isCompact && !controller.isTransitioning,
|
||||||
"Reduce Motion enters with geometry and controls already settled")
|
"Reduce Motion enters with geometry and controls already settled")
|
||||||
try fixture.compact(aspect: 16.0 / 9.0)
|
try fixture.compact(controller, aspect: 16.0 / 9.0)
|
||||||
controller.toggle(aspect: 16.0 / 9.0)
|
controller.toggle(aspect: 16.0 / 9.0)
|
||||||
try require(!controller.isCompact && !controller.isTransitioning,
|
try require(!controller.isCompact && !controller.isTransitioning,
|
||||||
"Reduce Motion restores without a pending frame animation")
|
"Reduce Motion restores without a pending frame animation")
|
||||||
@@ -507,13 +583,14 @@ struct CompactWindowChecks {
|
|||||||
static func compactQueue() throws {
|
static func compactQueue() throws {
|
||||||
let fixture = try WindowFixture()
|
let fixture = try WindowFixture()
|
||||||
let controller = CompactWindowController(reduceMotion: { true })
|
let controller = CompactWindowController(reduceMotion: { true })
|
||||||
let window = fixture.window
|
let normal = fixture.window
|
||||||
defer {
|
defer {
|
||||||
controller.restore(resumeFullScreen: false, animated: false)
|
controller.restore(resumeFullScreen: false, animated: false)
|
||||||
window.close()
|
normal.close()
|
||||||
}
|
}
|
||||||
controller.attach(window)
|
controller.attach(normal)
|
||||||
controller.toggle(aspect: 16.0 / 9.0)
|
controller.toggle(aspect: 16.0 / 9.0)
|
||||||
|
let window = controller.presentationWindow!
|
||||||
try require(controller.queueHeight == 0, "compact entry hides the queue")
|
try require(controller.queueHeight == 0, "compact entry hides the queue")
|
||||||
let videoFrame = window.frame
|
let videoFrame = window.frame
|
||||||
let expanded = controller.queueLayout(visible: true)!
|
let expanded = controller.queueLayout(visible: true)!
|
||||||
@@ -559,7 +636,7 @@ struct CompactWindowChecks {
|
|||||||
try fixture.restored()
|
try fixture.restored()
|
||||||
controller.toggle(aspect: 4.0 / 3.0)
|
controller.toggle(aspect: 4.0 / 3.0)
|
||||||
try require(controller.queueHeight == 0, "each compact entry resets queue visibility")
|
try require(controller.queueHeight == 0, "each compact entry resets queue visibility")
|
||||||
try fixture.compact(aspect: 4.0 / 3.0)
|
try fixture.compact(controller, aspect: 4.0 / 3.0)
|
||||||
controller.restore(resumeFullScreen: false)
|
controller.restore(resumeFullScreen: false)
|
||||||
try fixture.restored()
|
try fixture.restored()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,6 +130,30 @@ struct ConfigurationChecks {
|
|||||||
"built-in defaults hide buffer info, disable automatic quality, and enable snapping")
|
"built-in defaults hide buffer info, disable automatic quality, and enable snapping")
|
||||||
try require(defaults.miruroVideoProvider.isEmpty && defaults.miruroCaptionProvider.isEmpty,
|
try require(defaults.miruroVideoProvider.isEmpty && defaults.miruroCaptionProvider.isEmpty,
|
||||||
"Miruro defaults select automatic video and captions independently")
|
"Miruro defaults select automatic video and captions independently")
|
||||||
|
let sponsor = root.appendingPathComponent("sponsorblock.toml")
|
||||||
|
for enabled in [true, false] {
|
||||||
|
try Data("[playback]\nsponsorblock = \(enabled)\n".utf8).write(to: sponsor)
|
||||||
|
try require(try PlayerConfiguration.load(from: sponsor) == PlayerConfiguration(sponsorBlock: enabled),
|
||||||
|
"SponsorBlock opt-in and explicit opt-out load without changing other defaults")
|
||||||
|
}
|
||||||
|
for enabled in [true, false] {
|
||||||
|
try Data("[playback]\nsponsorblock_autoskip = \(enabled)\n".utf8).write(to: sponsor)
|
||||||
|
try require(try PlayerConfiguration.load(from: sponsor) == PlayerConfiguration(sponsorBlockAutoSkip: enabled),
|
||||||
|
"sponsor autoskip loads independently without enabling network lookups")
|
||||||
|
}
|
||||||
|
for value in ["\"true\"", "1", "[]"] {
|
||||||
|
try Data("[playback]\nsponsorblock_autoskip = \(value)\n".utf8).write(to: sponsor)
|
||||||
|
try requireLoadError(at: sponsor, "non-boolean sponsor autoskip", containing: ["sponsorblock_autoskip"])
|
||||||
|
}
|
||||||
|
for enabled in [true, false] {
|
||||||
|
try Data("[playback]\nopening_ending_autoskip = \(enabled)\n".utf8).write(to: sponsor)
|
||||||
|
try require(try PlayerConfiguration.load(from: sponsor) == PlayerConfiguration(openingEndingAutoSkip: enabled),
|
||||||
|
"opening/ending autoskip is independent of SponsorBlock")
|
||||||
|
}
|
||||||
|
for value in ["\"true\"", "1", "[]"] {
|
||||||
|
try Data("[playback]\nopening_ending_autoskip = \(value)\n".utf8).write(to: sponsor)
|
||||||
|
try requireLoadError(at: sponsor, "non-boolean opening/ending autoskip", containing: ["opening_ending_autoskip"])
|
||||||
|
}
|
||||||
let miruro = root.appendingPathComponent("miruro.toml")
|
let miruro = root.appendingPathComponent("miruro.toml")
|
||||||
for (settings, video, captions) in [
|
for (settings, video, captions) in [
|
||||||
("video_provider = \"new-Host_2\"", "new-Host_2", ""),
|
("video_provider = \"new-Host_2\"", "new-Host_2", ""),
|
||||||
@@ -178,13 +202,15 @@ struct ConfigurationChecks {
|
|||||||
# Sections and nested keys can be quoted, with comments and whitespace.
|
# Sections and nested keys can be quoted, with comments and whitespace.
|
||||||
['playback']
|
['playback']
|
||||||
'automatic_quality' = true
|
'automatic_quality' = true
|
||||||
|
'sponsorblock' = true
|
||||||
[ui]
|
[ui]
|
||||||
"buffer_info" = true # session display default
|
"buffer_info" = true # session display default
|
||||||
compact_snap_to_edges = false
|
compact_snap_to_edges = false
|
||||||
""".utf8)
|
""".utf8)
|
||||||
try original.write(to: config)
|
try original.write(to: config)
|
||||||
try files.setAttributes([.posixPermissions: 0o444], ofItemAtPath: config.path)
|
try files.setAttributes([.posixPermissions: 0o444], ofItemAtPath: config.path)
|
||||||
let configured = PlayerConfiguration(showBufferInfo: true, automaticQuality: true, snapToEdges: false)
|
let configured = PlayerConfiguration(showBufferInfo: true, automaticQuality: true, sponsorBlock: true,
|
||||||
|
snapToEdges: false)
|
||||||
let environment = ["XDG_CONFIG_HOME": xdg.path]
|
let environment = ["XDG_CONFIG_HOME": xdg.path]
|
||||||
try require(try PlayerConfiguration.load(from: config) == configured,
|
try require(try PlayerConfiguration.load(from: config) == configured,
|
||||||
"TOML comments, whitespace, quoted nested keys, and both boolean values load from a read-only file")
|
"TOML comments, whitespace, quoted nested keys, and both boolean values load from a read-only file")
|
||||||
@@ -192,6 +218,7 @@ struct ConfigurationChecks {
|
|||||||
try require(session == configured, "application startup automatically loads the XDG config")
|
try require(session == configured, "application startup automatically loads the XDG config")
|
||||||
session.showBufferInfo = false
|
session.showBufferInfo = false
|
||||||
session.automaticQuality = false
|
session.automaticQuality = false
|
||||||
|
session.sponsorBlock = false
|
||||||
session.snapToEdges = true
|
session.snapToEdges = true
|
||||||
try require(session == defaults, "controls can override all settings within the session")
|
try require(session == defaults, "controls can override all settings within the session")
|
||||||
try require(try Data(contentsOf: config) == original, "loading and session overrides do not change config bytes")
|
try require(try Data(contentsOf: config) == original, "loading and session overrides do not change config bytes")
|
||||||
@@ -267,6 +294,10 @@ struct ConfigurationChecks {
|
|||||||
("ui-legacy", "[ui]\nshow_buffer_info = true\n", ["ui.show_buffer_info"]),
|
("ui-legacy", "[ui]\nshow_buffer_info = true\n", ["ui.show_buffer_info"]),
|
||||||
("string", "[ui]\nbuffer_info = \"true\"\n", []),
|
("string", "[ui]\nbuffer_info = \"true\"\n", []),
|
||||||
("integer", "[playback]\nautomatic_quality = 1\n", []),
|
("integer", "[playback]\nautomatic_quality = 1\n", []),
|
||||||
|
("sponsor-string", "[playback]\nsponsorblock = \"true\"\n", ["sponsorblock"]),
|
||||||
|
("sponsor-integer", "[playback]\nsponsorblock = 1\n", ["sponsorblock"]),
|
||||||
|
("sponsor-array", "[playback]\nsponsorblock = [true]\n", ["sponsorblock"]),
|
||||||
|
("sponsor-table", "[playback.sponsorblock]\nenabled = true\n", ["sponsorblock"]),
|
||||||
("array", "[ui]\ncompact_snap_to_edges = [true]\n", []),
|
("array", "[ui]\ncompact_snap_to_edges = [true]\n", []),
|
||||||
("progress-value", "[ui]\ncompact_title_progress = \"unknown\"\n", ["compact_title_progress", "unknown"]),
|
("progress-value", "[ui]\ncompact_title_progress = \"unknown\"\n", ["compact_title_progress", "unknown"]),
|
||||||
("progress-bool", "[ui]\ncompact_title_progress = false\n", ["compact_title_progress"]),
|
("progress-bool", "[ui]\ncompact_title_progress = false\n", ["compact_title_progress"]),
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ struct PlayerCommandChecks {
|
|||||||
let miruro = URL(string: "https://barelystarted.miruro.tv/watch/OERe_a0YS4h-DlsBDOeXwX7HOO6316tw/the-apothecary-diaries-season-3?ep=1")!
|
let miruro = URL(string: "https://barelystarted.miruro.tv/watch/OERe_a0YS4h-DlsBDOeXwX7HOO6316tw/the-apothecary-diaries-season-3?ep=1")!
|
||||||
require(try opened([miruro.absoluteString]) == miruro, "Miruro positional URLs retain episode page identity")
|
require(try opened([miruro.absoluteString]) == miruro, "Miruro positional URLs retain episode page identity")
|
||||||
require(try opened(["--url", miruro.absoluteString]) == miruro, "explicit Miruro URLs forward without network resolution")
|
require(try opened(["--url", miruro.absoluteString]) == miruro, "explicit Miruro URLs forward without network resolution")
|
||||||
|
let anilist = URL(string: "https://zokoanime.video/stream/ani/1/1/sub")!
|
||||||
|
require(try opened([anilist.absoluteString]) == anilist &&
|
||||||
|
opened(["--url", anilist.absoluteString]) == anilist, "AniList watch URLs forward without yt-dlp or network resolution")
|
||||||
let generic = URL(string: "https://vimeo.com/76979871?quality=original")!
|
let generic = URL(string: "https://vimeo.com/76979871?quality=original")!
|
||||||
require(try opened([generic.absoluteString]) == generic &&
|
require(try opened([generic.absoluteString]) == generic &&
|
||||||
opened(["--url", generic.absoluteString]) == generic,
|
opened(["--url", generic.absoluteString]) == generic,
|
||||||
@@ -112,6 +115,11 @@ struct PlayerCommandChecks {
|
|||||||
catch {}
|
catch {}
|
||||||
}
|
}
|
||||||
let original = source
|
let original = source
|
||||||
|
source["identity"] = ["provider": "anilist", "media_id": "1", "episode": 12]
|
||||||
|
try saveSource()
|
||||||
|
let anilistSource = try ResolvedSource.load(from: sourceFile)
|
||||||
|
require(anilistSource.identity == "anilist:1:12" && anilistSource.title == "AniList 1 episode 12",
|
||||||
|
"source-file identity admits AniList without pretending it is Miruro")
|
||||||
let invalidChanges: [[String: Any]] = [
|
let invalidChanges: [[String: Any]] = [
|
||||||
["version": 2], ["url": "https://user:secret@cdn.example/video"],
|
["version": 2], ["url": "https://user:secret@cdn.example/video"],
|
||||||
["url": "file:///secret"], ["url": "https://cdn.example/video\nsecret"],
|
["url": "file:///secret"], ["url": "https://cdn.example/video\nsecret"],
|
||||||
@@ -120,6 +128,9 @@ struct PlayerCommandChecks {
|
|||||||
["identity": ["provider": "miruro", "media_id": "bad:id", "episode": 1]],
|
["identity": ["provider": "miruro", "media_id": "bad:id", "episode": 1]],
|
||||||
["identity": ["provider": "miruro", "media_id": "opaque", "episode": 0]],
|
["identity": ["provider": "miruro", "media_id": "opaque", "episode": 0]],
|
||||||
["identity": ["provider": "miruro", "media_id": "opaque", "episode": 1.5]],
|
["identity": ["provider": "miruro", "media_id": "opaque", "episode": 1.5]],
|
||||||
|
["identity": ["provider": "anilist", "media_id": "01", "episode": 1]],
|
||||||
|
["identity": ["provider": "anilist", "media_id": "opaque", "episode": 1]],
|
||||||
|
["identity": ["provider": "anilist", "media_id": "1", "episode": 2147483648]],
|
||||||
["subtitles": Array(repeating: (original["subtitles"] as! [[String: Any]])[0], count: 2)]
|
["subtitles": Array(repeating: (original["subtitles"] as! [[String: Any]])[0], count: 2)]
|
||||||
]
|
]
|
||||||
for change in invalidChanges {
|
for change in invalidChanges {
|
||||||
@@ -175,6 +186,11 @@ struct PlayerCommandChecks {
|
|||||||
require(parsedQueue.entries.map(\.url) == [miruro, canonical], "stream queue order is supplied, never filename-sorted")
|
require(parsedQueue.entries.map(\.url) == [miruro, canonical], "stream queue order is supplied, never filename-sorted")
|
||||||
require(parsedQueue.entries[0].identity.hasPrefix("miruro:") &&
|
require(parsedQueue.entries[0].identity.hasPrefix("miruro:") &&
|
||||||
parsedQueue.entries[1].identity == canonical.absoluteString, "stable identities exclude slugs and transport URLs")
|
parsedQueue.entries[1].identity == canonical.absoluteString, "stable identities exclude slugs and transport URLs")
|
||||||
|
let anilistReport = privateDirectory.appendingPathComponent("anilist.jsonl").path
|
||||||
|
try saveQueue(["version": 1, "items": entries + [["url": anilist.absoluteString, "progress_file": anilistReport]]])
|
||||||
|
let mixedQueue = try StreamQueue.load(from: queueFile)
|
||||||
|
require(mixedQueue.entries.map(\.identity) == [parsedQueue.entries[0].identity, canonical.absoluteString, "anilist:1:1"],
|
||||||
|
"mixed queue retains distinct Miruro, YouTube and AniList identities")
|
||||||
try saveQueue(["version": 1, "items": [["url": generic.absoluteString, "progress_file": youtubeReport]]])
|
try saveQueue(["version": 1, "items": [["url": generic.absoluteString, "progress_file": youtubeReport]]])
|
||||||
let genericQueue = try StreamQueue.load(from: queueFile)
|
let genericQueue = try StreamQueue.load(from: queueFile)
|
||||||
require(genericQueue.entries[0].url == generic &&
|
require(genericQueue.entries[0].url == generic &&
|
||||||
|
|||||||
@@ -37,25 +37,37 @@ static NSString *nativePath(const std::string &path) {
|
|||||||
return [NSString stringWithUTF8String:path.c_str()];
|
return [NSString stringWithUTF8String:path.c_str()];
|
||||||
}
|
}
|
||||||
|
|
||||||
static NSArray<NSDictionary *> *records(const std::string &path) {
|
static NSArray<NSDictionary *> *records(const std::string &path, int version = 1) {
|
||||||
NSString *text = [NSString stringWithContentsOfFile:nativePath(path)
|
NSString *text = [NSString stringWithContentsOfFile:nativePath(path)
|
||||||
encoding:NSUTF8StringEncoding error:nullptr];
|
encoding:NSUTF8StringEncoding error:nullptr];
|
||||||
require(text != nil && [text hasSuffix:@"\n"], "finished JSONL has a complete final line");
|
require(text != nil && [text hasSuffix:@"\n"], "finished JSONL has a complete final line");
|
||||||
NSMutableArray<NSDictionary *> *result = [NSMutableArray array];
|
NSMutableArray<NSDictionary *> *result = [NSMutableArray array];
|
||||||
NSArray<NSString *> *lines = [text componentsSeparatedByString:@"\n"];
|
NSArray<NSString *> *lines = [text componentsSeparatedByString:@"\n"];
|
||||||
NSSet *keys = [NSSet setWithArray:@[@"version", @"event", @"media_url", @"position_seconds",
|
NSSet *keys = [NSSet setWithArray:@[@"version", @"event", version == 2 ? @"media_identity" : @"media_url",
|
||||||
@"duration_seconds", @"completion_eligible"]];
|
@"position_seconds", @"duration_seconds", @"completion_eligible"]];
|
||||||
for (NSUInteger i = 0; i + 1 < lines.count; ++i) {
|
for (NSUInteger i = 0; i + 1 < lines.count; ++i) {
|
||||||
id object = [NSJSONSerialization JSONObjectWithData:[lines[i] dataUsingEncoding:NSUTF8StringEncoding]
|
id object = [NSJSONSerialization JSONObjectWithData:[lines[i] dataUsingEncoding:NSUTF8StringEncoding]
|
||||||
options:0 error:nullptr];
|
options:0 error:nullptr];
|
||||||
require([object isKindOfClass:[NSDictionary class]], "consumer decodes every JSONL record");
|
require([object isKindOfClass:[NSDictionary class]], "consumer decodes every JSONL record");
|
||||||
NSDictionary *row = object;
|
NSDictionary *row = object;
|
||||||
require([[NSSet setWithArray:row.allKeys] isEqual:keys], "records have exactly the six specified fields");
|
require([[NSSet setWithArray:row.allKeys] isEqual:keys], "records have exactly the six specified fields");
|
||||||
require([row[@"version"] isKindOfClass:[NSNumber class]] && [row[@"version"] intValue] == 1,
|
require([row[@"version"] isKindOfClass:[NSNumber class]] && [row[@"version"] intValue] == version,
|
||||||
"schema version is numeric one");
|
"schema version matches the expected source contract");
|
||||||
require([@[@"progress", @"ended", @"closed"] containsObject:row[@"event"]], "known event type");
|
require([@[@"progress", @"ended", @"closed"] containsObject:row[@"event"]], "known event type");
|
||||||
|
if (version == 2) {
|
||||||
|
NSDictionary *identity = row[@"media_identity"];
|
||||||
|
require([identity isKindOfClass:[NSDictionary class]] &&
|
||||||
|
[[NSSet setWithArray:identity.allKeys] isEqual:
|
||||||
|
[NSSet setWithArray:@[@"provider", @"media_id", @"episode"]]] &&
|
||||||
|
[identity[@"provider"] isKindOfClass:[NSString class]] &&
|
||||||
|
[identity[@"media_id"] isKindOfClass:[NSString class]] &&
|
||||||
|
[identity[@"episode"] isKindOfClass:[NSNumber class]], "structured episode consumer identity");
|
||||||
|
NSString *key = [NSString stringWithFormat:@"%@:%@:%@", identity[@"provider"], identity[@"media_id"], identity[@"episode"]];
|
||||||
|
require(ProgressReporter::validMediaIdentity(key.UTF8String), "canonical episode consumer identity");
|
||||||
|
} else {
|
||||||
require([row[@"media_url"] isKindOfClass:[NSString class]] &&
|
require([row[@"media_url"] isKindOfClass:[NSString class]] &&
|
||||||
ProgressReporter::validMediaURL([row[@"media_url"] UTF8String]), "HTTP(S) consumer identity");
|
ProgressReporter::validMediaURL([row[@"media_url"] UTF8String]), "HTTP(S) consumer identity");
|
||||||
|
}
|
||||||
require([row[@"position_seconds"] isKindOfClass:[NSNumber class]] &&
|
require([row[@"position_seconds"] isKindOfClass:[NSNumber class]] &&
|
||||||
std::isfinite([row[@"position_seconds"] doubleValue]) &&
|
std::isfinite([row[@"position_seconds"] doubleValue]) &&
|
||||||
[row[@"position_seconds"] doubleValue] >= 0, "position is finite and nonnegative");
|
[row[@"position_seconds"] doubleValue] >= 0, "position is finite and nonnegative");
|
||||||
@@ -302,6 +314,30 @@ int main(int argc, char **argv) {
|
|||||||
[row[@"completion_eligible"] boolValue], "Miruro retains terminal snapshot semantics");
|
[row[@"completion_eligible"] boolValue], "Miruro retains terminal snapshot semantics");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
require(ProgressReporter::validMediaIdentity("anilist:1:1") &&
|
||||||
|
ProgressReporter::validMediaIdentity("anilist:2147483647:2147483647"), "canonical AniList identities accepted");
|
||||||
|
for (const char *invalid : {"anilist:01:1", "anilist:0:1", "anilist:-1:1", "anilist:a:1",
|
||||||
|
"anilist:1:01", "anilist:1:0", "anilist:1:2147483648", "anilist:2147483648:1",
|
||||||
|
"anilist:1:1:sub", "anilist:1:1?token=secret"})
|
||||||
|
require(!ProgressReporter::validMediaIdentity(invalid), "noncanonical AniList identities rejected");
|
||||||
|
const auto anilistPath = root + "/anilist.jsonl";
|
||||||
|
{
|
||||||
|
ProgressReporter reporter(anilistPath, [] { return 0.; });
|
||||||
|
reporter.begin("anilist:1:1");
|
||||||
|
reporter.observe(sample(PLAYER_PAUSED, 42));
|
||||||
|
reporter.finish();
|
||||||
|
require(reporter.error().empty(), "AniList progress drains successfully");
|
||||||
|
}
|
||||||
|
rows = records(anilistPath, 2);
|
||||||
|
require(rows.count == 2, "AniList initial and closed snapshots");
|
||||||
|
for (NSDictionary *row in rows) {
|
||||||
|
require([row[@"version"] intValue] == 2 && row[@"media_url"] == nil &&
|
||||||
|
[row[@"media_identity"][@"provider"] isEqual:@"anilist"] &&
|
||||||
|
[row[@"media_identity"][@"media_id"] isEqual:@"1"] &&
|
||||||
|
[row[@"media_identity"][@"episode"] intValue] == 1 &&
|
||||||
|
[row[@"position_seconds"] doubleValue] == 42, "AniList v2 schema remains separate from Miruro");
|
||||||
|
}
|
||||||
|
|
||||||
const auto clockFailurePath = root + "/clock-failure.jsonl";
|
const auto clockFailurePath = root + "/clock-failure.jsonl";
|
||||||
{
|
{
|
||||||
ProgressReporter reporter(clockFailurePath, []() -> double { throw std::runtime_error("clock failure"); });
|
ProgressReporter reporter(clockFailurePath, []() -> double { throw std::runtime_error("clock failure"); });
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ binary = str(Path(sys.argv[1]).resolve())
|
|||||||
fixtures = Path(sys.argv[2]).resolve()
|
fixtures = Path(sys.argv[2]).resolve()
|
||||||
seen = []
|
seen = []
|
||||||
errors = []
|
errors = []
|
||||||
|
zoko_only = "--zoko-intervals" in sys.argv[3:]
|
||||||
|
|
||||||
class Handler(http.server.BaseHTTPRequestHandler):
|
class Handler(http.server.BaseHTTPRequestHandler):
|
||||||
def log_message(self, *_):
|
def log_message(self, *_):
|
||||||
@@ -69,7 +70,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
|||||||
captions = path.startswith("/captions/")
|
captions = path.startswith("/captions/")
|
||||||
if alternate or captions:
|
if alternate or captions:
|
||||||
path = "/" + path.split("/", 2)[2]
|
path = "/" + path.split("/", 2)[2]
|
||||||
expected = "alternate-private" if alternate else "subtitle-private" if captions or path == "/english.jpeg" else "stream-private"
|
expected = "alternate-private" if alternate else "subtitle-private" if captions or path in ("/english.jpeg", "/denied.vtt") else "stream-private"
|
||||||
if self.headers.get("X-Resource") != expected:
|
if self.headers.get("X-Resource") != expected:
|
||||||
errors.append("wrong resource headers: " + path)
|
errors.append("wrong resource headers: " + path)
|
||||||
self.send_error(403)
|
self.send_error(403)
|
||||||
@@ -89,6 +90,11 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
|||||||
elif path == "/english.jpeg":
|
elif path == "/english.jpeg":
|
||||||
data = (b'WEBVTT\n\n00:00.000 --> 00:10.000\nAlternate English cue\n' if alternate else
|
data = (b'WEBVTT\n\n00:00.000 --> 00:10.000\nAlternate English cue\n' if alternate else
|
||||||
b'WEBVTT\n\n00:00.000 --> 00:02.000\nFirst English cue\n\n00:02.000 --> 00:04.000\nSecond English cue\n')
|
b'WEBVTT\n\n00:00.000 --> 00:02.000\nFirst English cue\n\n00:02.000 --> 00:04.000\nSecond English cue\n')
|
||||||
|
if parse_qs(urlsplit(self.path).query).get("auto") == ["1"]:
|
||||||
|
data = data.replace(b"First English cue", b"First automatic cue").replace(b"Second English cue", b"Second automatic cue")
|
||||||
|
elif path == "/denied.vtt":
|
||||||
|
self.send_error(403)
|
||||||
|
return
|
||||||
elif path.startswith("/resolved-vod-") and path.endswith(".ts"):
|
elif path.startswith("/resolved-vod-") and path.endswith(".ts"):
|
||||||
data = (fixtures / path[1:]).read_bytes()
|
data = (fixtures / path[1:]).read_bytes()
|
||||||
else:
|
else:
|
||||||
@@ -117,17 +123,21 @@ try:
|
|||||||
"title": "Local resolved fixture", "subtitles": [{"url": base + "/english.jpeg", "format": "vtt", "language": "en",
|
"title": "Local resolved fixture", "subtitles": [{"url": base + "/english.jpeg", "format": "vtt", "language": "en",
|
||||||
"label": "English", "default": True, "headers": {"X-Resource": "subtitle-private"}}]}))
|
"label": "English", "default": True, "headers": {"X-Resource": "subtitle-private"}}]}))
|
||||||
source.chmod(0o600)
|
source.chmod(0o600)
|
||||||
subprocess.run([binary, str(source), str(progress)], check=True, timeout=100)
|
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()]
|
records = [json.loads(line) for line in progress.read_text().splitlines()]
|
||||||
assert records and all(record["version"] == 2 and record["media_identity"] ==
|
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"
|
{"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 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 "stream-private" not in progress.read_text() and "token=" not in progress.read_text(), "progress has no transport secrets"
|
||||||
assert not errors, errors
|
assert not errors, errors
|
||||||
assert {"/master.m3u8", "/variant.m3u8", "/english.jpeg"}.issubset(seen), "master, nested playlist and external VTT fetched"
|
assert {"/master.m3u8", "/variant.m3u8"}.issubset(seen), "master and nested playlist fetched"
|
||||||
assert {"/alternate/master.m3u8", "/alternate/english.jpeg"}.issubset(seen), "alternate video host and independent caption provider 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"
|
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:
|
finally:
|
||||||
server.shutdown()
|
server.shutdown()
|
||||||
server.server_close()
|
server.server_close()
|
||||||
|
|||||||
+657
-10
@@ -30,6 +30,8 @@ struct ResolvedPlaybackCheck {
|
|||||||
}
|
}
|
||||||
_ = NSApplication.shared
|
_ = NSApplication.shared
|
||||||
NSApp.setActivationPolicy(.prohibited)
|
NSApp.setActivationPolicy(.prohibited)
|
||||||
|
sponsorPreferenceCheck()
|
||||||
|
if CommandLine.arguments[1] == "--sponsor-preference" { return }
|
||||||
let input = CommandLine.arguments[1]
|
let input = CommandLine.arguments[1]
|
||||||
let file = URL(fileURLWithPath: input)
|
let file = URL(fileURLWithPath: input)
|
||||||
let preference = CommandLine.arguments.first(where: { $0.hasPrefix("--provider=") }).map { String($0.dropFirst(11)) } ?? ""
|
let preference = CommandLine.arguments.first(where: { $0.hasPrefix("--provider=") }).map { String($0.dropFirst(11)) } ?? ""
|
||||||
@@ -42,10 +44,22 @@ struct ResolvedPlaybackCheck {
|
|||||||
} else {
|
} else {
|
||||||
source = try ResolvedSource.load(from: file)
|
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") {
|
if CommandLine.arguments.contains("--enqueue-only") {
|
||||||
try await enqueueLifecycle(source: source)
|
try await enqueueLifecycle(source: source)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if CommandLine.arguments.contains("--sponsors-only") {
|
||||||
|
try await chapterLifecycle(source: source, file: file)
|
||||||
|
try await sponsorLifecycle(source: source)
|
||||||
|
try await streamQueueLifecycle(source: source)
|
||||||
|
try await enqueueLifecycle(source: source)
|
||||||
|
return
|
||||||
|
}
|
||||||
let suite = "terebi.resolved-check." + UUID().uuidString
|
let suite = "terebi.resolved-check." + UUID().uuidString
|
||||||
let defaults = UserDefaults(suiteName: suite)!
|
let defaults = UserDefaults(suiteName: suite)!
|
||||||
defer { defaults.removePersistentDomain(forName: suite) }
|
defer { defaults.removePersistentDomain(forName: suite) }
|
||||||
@@ -110,15 +124,554 @@ struct ResolvedPlaybackCheck {
|
|||||||
if !input.hasPrefix("https://") {
|
if !input.hasPrefix("https://") {
|
||||||
try await transportProtocol(source: source)
|
try await transportProtocol(source: source)
|
||||||
model.shutdown()
|
model.shutdown()
|
||||||
|
try await youtubeSubtitleLifecycle(source: source)
|
||||||
|
try await automaticSegmentSkips(file: file)
|
||||||
try await providerLifecycle(source: source)
|
try await providerLifecycle(source: source)
|
||||||
try await dubOnlySelection(source: source)
|
try await dubOnlySelection(source: source)
|
||||||
try await queueMetadataLifecycle(source: source)
|
try await queueMetadataLifecycle(source: source)
|
||||||
|
try await chapterLifecycle(source: source, file: file)
|
||||||
|
try await sponsorLifecycle(source: source)
|
||||||
try await streamQueueLifecycle(source: source)
|
try await streamQueueLifecycle(source: source)
|
||||||
try await enqueueLifecycle(source: source)
|
try await enqueueLifecycle(source: source)
|
||||||
try await sidebarQueueLifecycle(source: source)
|
try await sidebarQueueLifecycle(source: source)
|
||||||
|
try await zokoIntervalLifecycle(source: source)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func youtubeSubtitleLifecycle(source: ResolvedSource) async throws {
|
||||||
|
let suite = "terebi.youtube-subtitle-check." + UUID().uuidString
|
||||||
|
let defaults = UserDefaults(suiteName: suite)!
|
||||||
|
defer { defaults.removePersistentDomain(forName: suite) }
|
||||||
|
var resolution = 0
|
||||||
|
let model = PlayerModel(defaults: defaults, youtubeResolver: { url, height in
|
||||||
|
resolution += 1
|
||||||
|
let manual = ResolvedSubtitle(url: source.subtitles[0].url + "?revision=\(resolution)",
|
||||||
|
format: "vtt", language: "en", label: "English", isDefault: false,
|
||||||
|
headers: ["X-Resource": "subtitle-private"], selectionID: "youtube:manual:en")
|
||||||
|
let automatic = ResolvedSubtitle(url: manual.url + "&auto=1", format: "vtt",
|
||||||
|
language: "en", label: "English (auto-generated)", isDefault: false,
|
||||||
|
headers: ["X-Resource": "subtitle-private"], selectionID: "youtube:auto:en")
|
||||||
|
let denied = ResolvedSubtitle(url: source.subtitles[0].url.replacingOccurrences(of: "english.jpeg", with: "denied.vtt"),
|
||||||
|
format: "vtt", language: "fr", label: "Unavailable", isDefault: false,
|
||||||
|
headers: ["X-Resource": "subtitle-private"], selectionID: "youtube:manual:fr")
|
||||||
|
let malformed = ResolvedSubtitle(url: source.url, format: "vtt", language: "de",
|
||||||
|
label: "Malformed", isDefault: false, headers: ["X-Resource": "stream-private"],
|
||||||
|
selectionID: "youtube:manual:de")
|
||||||
|
return YouTubeVideo(title: "YouTube caption fixture", video: .init(url: source.url, headers: source.headers),
|
||||||
|
audio: nil, duration: 0, isLive: false, webpageURL: url, height: height ?? 1080,
|
||||||
|
availableHeights: [1080, 720], chapters: [],
|
||||||
|
subtitles: resolution >= 4 ? [manual, denied, malformed] :
|
||||||
|
(resolution.isMultiple(of: 2) ? [automatic, manual, denied, malformed] : [manual, automatic, denied, malformed]))
|
||||||
|
}, youtubeTitleResolver: { _ in nil })
|
||||||
|
defer { model.shutdown() }
|
||||||
|
model.setVolume(0)
|
||||||
|
let url = URL(string: "https://www.youtube.com/watch?v=AAAAAAAAAAA")!
|
||||||
|
model.openStream(url.absoluteString)
|
||||||
|
try await until("YouTube playback starts with optional captions Off", model: model) {
|
||||||
|
model.canChangeYouTubeQuality && model.state.position > 0.2
|
||||||
|
}
|
||||||
|
require(model.subtitleTracks.count == 4 && model.selectedSubtitle == nil &&
|
||||||
|
model.subtitleText.isEmpty && model.subtitleError == nil, "YouTube defaults Off")
|
||||||
|
require(model.setPlayback(playing: false), "pause caption fixture")
|
||||||
|
try await until("caption fixture pause acknowledged", model: model) { model.state.status == PLAYER_PAUSED }
|
||||||
|
model.selectSubtitle(1)
|
||||||
|
try await until("native caption HTTP loader renders automatic English", model: model) {
|
||||||
|
model.subtitleText == "First automatic cue"
|
||||||
|
}
|
||||||
|
require(model.seekRelative(2.5 - model.state.position), "forward caption seek accepted")
|
||||||
|
try await until("forward seek uses acknowledged caption position", model: model) {
|
||||||
|
!model.state.seek_pending && model.subtitleText == "Second automatic cue"
|
||||||
|
}
|
||||||
|
model.setYouTubeQuality(720)
|
||||||
|
try await until("quality preserves auto identity despite reordered tracks and refreshed URLs", model: model) {
|
||||||
|
model.canChangeYouTubeQuality && model.youtubeHeight == 720 && model.selectedSubtitle == 0 &&
|
||||||
|
model.subtitleText == "Second automatic cue" && model.state.status == PLAYER_PAUSED
|
||||||
|
}
|
||||||
|
require(model.seekRelative(0.5 - model.state.position), "backward caption seek accepted")
|
||||||
|
try await until("backward seek restores earlier caption", model: model) {
|
||||||
|
!model.state.seek_pending && model.subtitleText == "First automatic cue"
|
||||||
|
}
|
||||||
|
model.selectSubtitle(nil)
|
||||||
|
model.setYouTubeQuality(1080)
|
||||||
|
try await until("manual quality preserves explicit Off", model: model) {
|
||||||
|
model.canChangeYouTubeQuality && model.youtubeHeight == 1080
|
||||||
|
}
|
||||||
|
require(model.selectedSubtitle == nil && model.subtitleText.isEmpty, "quality must not re-enable captions")
|
||||||
|
model.selectSubtitle(1)
|
||||||
|
try await until("automatic track can be selected again", model: model) { model.subtitleText == "First automatic cue" }
|
||||||
|
model.setYouTubeQuality(720)
|
||||||
|
try await until("missing automatic track switches Off, not to manual English", model: model) {
|
||||||
|
model.canChangeYouTubeQuality && model.youtubeHeight == 720
|
||||||
|
}
|
||||||
|
require(model.selectedSubtitle == nil && model.subtitleText.isEmpty, "missing identity has no language fallback")
|
||||||
|
require(model.setPlayback(playing: true), "resume before optional caption failures")
|
||||||
|
for index in [1, 2] {
|
||||||
|
let position = model.state.position
|
||||||
|
model.selectSubtitle(index)
|
||||||
|
try await until("inaccessible/malformed captions fail while video keeps advancing", model: model) {
|
||||||
|
model.subtitleError != nil && model.state.status == PLAYER_PLAYING && model.state.position > position + 0.1
|
||||||
|
}
|
||||||
|
require(model.commandError == nil && model.state.has_video && model.state.has_audio &&
|
||||||
|
model.canSeek, "caption failures retain usable video")
|
||||||
|
}
|
||||||
|
require(model.setPlayback(playing: false), "pause after optional caption failures")
|
||||||
|
try await until("video still pauses normally after caption errors", model: model) { model.state.status == PLAYER_PAUSED }
|
||||||
|
require(model.seekRelative(0.5 - model.state.position), "video still seeks normally after caption errors")
|
||||||
|
try await until("post-error seek acknowledged", model: model) { !model.state.seek_pending && abs(model.state.position - 0.5) < 0.1 }
|
||||||
|
model.selectSubtitle(0)
|
||||||
|
try await until("valid captions recover without reopening video", model: model) {
|
||||||
|
model.subtitleText == "First English cue" && model.subtitleError == nil
|
||||||
|
}
|
||||||
|
try await model.addStreamToQueue("https://www.youtube.com/watch?v=BBBBBBBBBBB")
|
||||||
|
model.selectQueueItem(model.queue.items.last!.id)
|
||||||
|
require(model.subtitleTracks.isEmpty && model.selectedSubtitle == nil &&
|
||||||
|
model.subtitleText.isEmpty && model.subtitleError == nil, "queue change immediately clears old captions")
|
||||||
|
try await until("next queued video starts with fresh tracks Off", model: model) {
|
||||||
|
!model.isBusy && model.state.status == PLAYER_PLAYING && !model.subtitleTracks.isEmpty
|
||||||
|
}
|
||||||
|
require(model.selectedSubtitle == nil && model.subtitleText.isEmpty, "queue selection never inherits prior captions")
|
||||||
|
model.closeFile()
|
||||||
|
require(model.subtitleTracks.isEmpty && model.subtitleError == nil, "close clears caption state")
|
||||||
|
print("PASS: YouTube native VTT fetch, Off/On, seeks, stable auto/manual quality identity, missing tracks, optional failures and queue reset")
|
||||||
|
}
|
||||||
|
|
||||||
|
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)!
|
||||||
|
defer { defaults.removePersistentDomain(forName: suite) }
|
||||||
|
func launch(_ configured: Bool = false, autoSkip: Bool = false, openingEnding: Bool = false) -> PlayerModel {
|
||||||
|
PlayerModel(defaults: UserDefaults(suiteName: suite)!,
|
||||||
|
configuration: .init(sponsorBlock: configured, sponsorBlockAutoSkip: autoSkip,
|
||||||
|
openingEndingAutoSkip: openingEnding))
|
||||||
|
}
|
||||||
|
let first = launch()
|
||||||
|
require(!first.sponsorBlockEnabled, "unconfigured SponsorBlock defaults to disabled")
|
||||||
|
require(!first.sponsorBlockAutoSkip, "sponsors are not skipped without opt-in")
|
||||||
|
require(!first.openingEndingAutoSkip, "openings and endings are not skipped without opt-in")
|
||||||
|
first.setOpeningEndingAutoSkip(true)
|
||||||
|
first.setSponsorBlockAutoSkip(true)
|
||||||
|
first.setSponsorBlockEnabled(true)
|
||||||
|
first.shutdown()
|
||||||
|
let second = launch()
|
||||||
|
require(second.sponsorBlockEnabled, "enabled preference survives a fresh model/defaults instance")
|
||||||
|
require(second.sponsorBlockAutoSkip, "autoskip preference survives restart")
|
||||||
|
require(second.openingEndingAutoSkip, "opening/ending autoskip preference survives restart")
|
||||||
|
second.setOpeningEndingAutoSkip(false)
|
||||||
|
second.setSponsorBlockAutoSkip(false)
|
||||||
|
second.setSponsorBlockEnabled(false)
|
||||||
|
second.shutdown()
|
||||||
|
let third = launch(true, autoSkip: true, openingEnding: true)
|
||||||
|
require(!third.sponsorBlockEnabled && !third.sponsorBlockAutoSkip && !third.openingEndingAutoSkip,
|
||||||
|
"saved disabled skip preferences override config")
|
||||||
|
third.shutdown()
|
||||||
|
defaults.removePersistentDomain(forName: suite)
|
||||||
|
let configured = launch(true, autoSkip: true, openingEnding: true)
|
||||||
|
require(configured.sponsorBlockEnabled && configured.sponsorBlockAutoSkip && configured.openingEndingAutoSkip,
|
||||||
|
"config supplies unsaved skip choices")
|
||||||
|
configured.shutdown()
|
||||||
|
print("PASS: sponsor and opening/ending skip preferences persist across launches, with config fallback")
|
||||||
|
}
|
||||||
|
|
||||||
|
static var fixtureSponsors: [SponsorSegment] {
|
||||||
|
[.init(start: 0.5, end: 1.5, videoDuration: 0)]
|
||||||
|
}
|
||||||
|
|
||||||
|
static func sponsorLifecycle(source: ResolvedSource) async throws {
|
||||||
|
let suite = "terebi.sponsor-check." + UUID().uuidString
|
||||||
|
let defaults = UserDefaults(suiteName: suite)!
|
||||||
|
defer { defaults.removePersistentDomain(forName: suite) }
|
||||||
|
let url = URL(string: "https://www.youtube.com/watch?v=AAAAAAAAAAA")!
|
||||||
|
var extraction: CheckedContinuation<YouTubeVideo, Never>?
|
||||||
|
var held: [CheckedContinuation<[SponsorSegment], Never>] = []
|
||||||
|
var extractionCount = 0
|
||||||
|
let clock = Clock()
|
||||||
|
let model = PlayerModel(defaults: defaults, youtubeResolver: { url, height in
|
||||||
|
extractionCount += 1
|
||||||
|
let video = YouTubeVideo(title: "Sponsor fixture", video: .init(url: source.url, headers: source.headers),
|
||||||
|
audio: nil, duration: 0, isLive: false, webpageURL: url, height: height ?? 1080,
|
||||||
|
availableHeights: [1080, 720], chapters: fixtureChapters)
|
||||||
|
if extractionCount == 1 { return await withCheckedContinuation { extraction = $0 } }
|
||||||
|
return video
|
||||||
|
}, youtubeTitleResolver: { _ in nil }, sponsorResolver: { id in
|
||||||
|
if id == "BBBBBBBBBBB" { throw URLError(.timedOut) }
|
||||||
|
return await withCheckedContinuation { held.append($0) }
|
||||||
|
}, skipClock: { clock.now })
|
||||||
|
defer { model.shutdown() }
|
||||||
|
model.open(url)
|
||||||
|
try await until("playback extraction held with SponsorBlock disabled", model: model) { extraction != nil }
|
||||||
|
require(held.isEmpty, "opted-out playback makes no SponsorBlock request")
|
||||||
|
model.setSponsorBlockEnabled(true)
|
||||||
|
try await until("SponsorBlock starts independently of held playback extraction", model: model) { held.count == 1 }
|
||||||
|
held[0].resume(returning: fixtureSponsors)
|
||||||
|
extraction!.resume(returning: YouTubeVideo(title: "Sponsor fixture",
|
||||||
|
video: .init(url: source.url, headers: source.headers), audio: nil, duration: 0, isLive: false,
|
||||||
|
webpageURL: url, height: 1080, availableHeights: [1080, 720], chapters: fixtureChapters))
|
||||||
|
extraction = nil
|
||||||
|
try await until("sponsor metadata appears against native measured duration", model: model) {
|
||||||
|
!model.isBusy && model.state.status == PLAYER_PLAYING && model.sponsoredRanges == [0.5...1.5]
|
||||||
|
}
|
||||||
|
requireYouTubeChapters(model, [1.25, 2.5], "sponsor paint crossing chapter boundary")
|
||||||
|
require(model.setPlayback(playing: false), "pause sponsored playback")
|
||||||
|
try await until("sponsor playback paused", model: model) { model.state.status == PLAYER_PAUSED }
|
||||||
|
require(model.seekRelative(0.75 - model.state.position), "seek within sponsor range")
|
||||||
|
try await until("sponsor seek acknowledged without automatic skipping", model: model) {
|
||||||
|
!model.state.seek_pending && abs(model.state.position - 0.75) < 0.1
|
||||||
|
}
|
||||||
|
clock.now += 60
|
||||||
|
require(model.skipSegment(), "sponsor skip stays available beyond the opening/ending timeout")
|
||||||
|
try await until("manual sponsor skip reaches range end while paused", model: model) {
|
||||||
|
!model.state.seek_pending && abs(model.state.position - 1.5) < 0.1
|
||||||
|
}
|
||||||
|
require(!model.effectivePlaying, "manual sponsor skip preserves paused intent")
|
||||||
|
model.setSponsorBlockAutoSkip(true)
|
||||||
|
require(model.seekRelative(0.75 - model.state.position), "deliberately revisit sponsor with autoskip enabled")
|
||||||
|
try await until("intentional seek into sponsor settles", model: model) {
|
||||||
|
!model.state.seek_pending && abs(model.state.position - 0.75) < 0.1
|
||||||
|
}
|
||||||
|
require(model.setPlayback(playing: true), "play deliberately selected sponsor")
|
||||||
|
try await until("deliberately selected sponsor plays", model: model) { model.state.position >= 1 }
|
||||||
|
require(model.state.position < 1.5, "autoskip does not fight a seek into the sponsor")
|
||||||
|
require(model.setPlayback(playing: false), "pause before seeking just before sponsor")
|
||||||
|
try await until("sponsor paused again", model: model) { model.state.status == PLAYER_PAUSED }
|
||||||
|
model.isSeeking = true
|
||||||
|
model.seekPosition = 0.25
|
||||||
|
model.finishSeeking()
|
||||||
|
try await until("slider seek just before sponsor settles", model: model) {
|
||||||
|
!model.state.seek_pending && abs(model.state.position - 0.25) < 0.1
|
||||||
|
}
|
||||||
|
require(model.setPlayback(playing: true), "play from just before sponsor")
|
||||||
|
try await until("sponsor entered after nearby slider seek", model: model) { model.state.position >= 0.9 }
|
||||||
|
require(model.state.position < 1.5, "autoskip does not fight a nearby slider seek")
|
||||||
|
require(model.skipButtonTitle == "Skip Sponsor", "suppressed autoskip keeps the manual button")
|
||||||
|
require(model.setPlayback(playing: false), "pause before metadata lifecycle checks")
|
||||||
|
try await until("sponsor lifecycle playback paused", model: model) { model.state.status == PLAYER_PAUSED }
|
||||||
|
model.setSponsorBlockEnabled(false)
|
||||||
|
require(model.sponsoredRanges.isEmpty, "disabling immediately clears paint")
|
||||||
|
model.setSponsorBlockEnabled(true)
|
||||||
|
try await until("re-enabled lookup held", model: model) { held.count == 2 }
|
||||||
|
model.setYouTubeQuality(720)
|
||||||
|
try await until("quality changes without awaiting optional metadata", model: model) {
|
||||||
|
!model.isBusy && model.state.status == PLAYER_PAUSED && model.youtubeHeight == 720
|
||||||
|
}
|
||||||
|
require(held.count == 2, "quality replacement coalesces the in-flight current-video lookup")
|
||||||
|
held[1].resume(returning: fixtureSponsors)
|
||||||
|
try await until("pending metadata survives quality replacement and the next quality choice is available", model: model) {
|
||||||
|
model.sponsoredRanges == [0.5...1.5] && model.canChangeYouTubeQuality
|
||||||
|
}
|
||||||
|
model.setYouTubeQuality(1080)
|
||||||
|
try await until("completed metadata survives quality replacement", model: model) {
|
||||||
|
!model.isBusy && model.youtubeHeight == 1080 && model.sponsoredRanges == [0.5...1.5]
|
||||||
|
}
|
||||||
|
require(held.count == 2, "quality replacement reuses completed metadata")
|
||||||
|
model.open(url)
|
||||||
|
try await until("same-video reload has its own lookup", model: model) {
|
||||||
|
held.count == 3 && !model.isBusy && model.state.status == PLAYER_PLAYING
|
||||||
|
}
|
||||||
|
model.open(url)
|
||||||
|
try await until("second same-video reload has a new generation", model: model) {
|
||||||
|
held.count == 4 && !model.isBusy && model.state.status == PLAYER_PLAYING
|
||||||
|
}
|
||||||
|
held[2].resume(returning: [.init(start: 2, end: 3, videoDuration: 0)])
|
||||||
|
for _ in 0..<10 { await Task.yield() }
|
||||||
|
require(model.sponsoredRanges.isEmpty, "late same-video response cannot paint a newer load")
|
||||||
|
held[3].resume(returning: fixtureSponsors)
|
||||||
|
try await until("current reload accepts its own metadata", model: model) { model.sponsoredRanges == [0.5...1.5] }
|
||||||
|
model.open(url)
|
||||||
|
try await until("lookup pending before close", model: model) {
|
||||||
|
held.count == 5 && !model.isBusy && model.state.status == PLAYER_PLAYING
|
||||||
|
}
|
||||||
|
model.closeFile()
|
||||||
|
held[4].resume(returning: fixtureSponsors)
|
||||||
|
try await until("close clears sponsorship", model: model) { model.state.status == PLAYER_EMPTY }
|
||||||
|
require(model.sponsoredRanges.isEmpty, "closed player rejects late metadata")
|
||||||
|
model.open(URL(string: "https://www.youtube.com/watch?v=BBBBBBBBBBB")!)
|
||||||
|
try await until("SponsorBlock timeout never blocks native playback", model: model) {
|
||||||
|
!model.isBusy && model.state.status == PLAYER_PLAYING && model.state.has_audio
|
||||||
|
}
|
||||||
|
require(model.commandError == nil && model.sponsoredRanges.isEmpty, "optional failure leaves unmarked playback")
|
||||||
|
model.open(URL(string: "https://example.org/generic-video")!)
|
||||||
|
try await until("generic extractor site remains playable without SponsorBlock", model: model) {
|
||||||
|
!model.isBusy && model.state.status == PLAYER_PLAYING && model.state.has_audio
|
||||||
|
}
|
||||||
|
require(held.count == 5 && model.sponsoredRanges.isEmpty, "generic sites never send a SponsorBlock lookup")
|
||||||
|
print("PASS: persistent manual sponsor skip, deliberate-seek protection, opt-in, quality reuse, cancellation and failure isolation")
|
||||||
|
try await sponsorAutoSkip(source: source)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func sponsorAutoSkip(source: ResolvedSource) async throws {
|
||||||
|
let suite = "terebi.sponsor-autoskip." + UUID().uuidString
|
||||||
|
let defaults = UserDefaults(suiteName: suite)!
|
||||||
|
defer { defaults.removePersistentDomain(forName: suite) }
|
||||||
|
let model = PlayerModel(defaults: defaults,
|
||||||
|
configuration: .init(sponsorBlock: true, sponsorBlockAutoSkip: true),
|
||||||
|
youtubeResolver: { url, _ in
|
||||||
|
YouTubeVideo(title: "Autoskip fixture", video: .init(url: source.url, headers: source.headers),
|
||||||
|
audio: nil, duration: 0, isLive: false, webpageURL: url, height: 720, availableHeights: [720], chapters: [])
|
||||||
|
}, youtubeTitleResolver: { _ in nil }, sponsorResolver: { _ in fixtureSponsors })
|
||||||
|
defer { model.shutdown() }
|
||||||
|
model.open(URL(string: "https://www.youtube.com/watch?v=CCCCCCCCCCC")!)
|
||||||
|
try await until("natural sponsor entry queues exact-end autoskip", model: model) { model.seekPosition == 1.5 }
|
||||||
|
try await until("native player acknowledges autoskip and keeps playing", model: model) {
|
||||||
|
!model.state.seek_pending && model.state.position >= 1.5 && model.state.status == PLAYER_PLAYING
|
||||||
|
}
|
||||||
|
require(model.effectivePlaying, "autoskip preserves playing intent")
|
||||||
|
print("PASS: natural sponsor entry automatically seeks to range end in the native player")
|
||||||
|
}
|
||||||
|
|
||||||
|
static var fixtureChapters: [YouTubeChapter] {
|
||||||
|
[.init(start: 0, end: 1.25, title: "Opening"),
|
||||||
|
.init(start: 1.25, end: 2.5, title: "Ending")]
|
||||||
|
}
|
||||||
|
|
||||||
|
static func requireYouTubeChapters(_ model: PlayerModel, _ expected: [Double], _ context: String) {
|
||||||
|
require(!model.isBusy && model.state.has_video && model.state.has_audio && model.state.duration > 2.5,
|
||||||
|
"\(context): boundaries belong to decoded native media with measured duration")
|
||||||
|
require(model.sliderBoundaries == expected, "\(context): exact fractional chapter boundaries")
|
||||||
|
require(model.visibleIntervals.isEmpty && model.segmentToSkip == nil,
|
||||||
|
"\(context): YouTube chapters never become Miruro bands or skip offers")
|
||||||
|
}
|
||||||
|
|
||||||
|
static func chapterLifecycle(source: ResolvedSource, file: URL) async throws {
|
||||||
|
let directory = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("terebi.chapter-check." + UUID().uuidString)
|
||||||
|
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||||
|
defer { try? FileManager.default.removeItem(at: directory) }
|
||||||
|
let report = directory.appendingPathComponent("single-launch.jsonl")
|
||||||
|
let suite = "terebi.chapter-check." + UUID().uuidString
|
||||||
|
let defaults = UserDefaults(suiteName: suite)!
|
||||||
|
defer { defaults.removePersistentDomain(forName: suite) }
|
||||||
|
let chaptered = URL(string: "https://www.youtube.com/watch?v=AAAAAAAAAAA")!
|
||||||
|
let chapterless = URL(string: "https://www.youtube.com/watch?v=BBBBBBBBBBB")!
|
||||||
|
var requests: [URL] = []
|
||||||
|
// The single-video Tubular launch uses this progress-file initialization plus openStream.
|
||||||
|
let model = PlayerModel(defaults: defaults, configuration: .init(sponsorBlock: true),
|
||||||
|
progressFile: report.path, youtubeResolver: { url, height in
|
||||||
|
requests.append(url)
|
||||||
|
return YouTubeVideo(title: url == chaptered ? "Chaptered launch" : "Chapterless launch",
|
||||||
|
video: .init(url: source.url, headers: source.headers), audio: nil,
|
||||||
|
duration: 0, isLive: false, webpageURL: url, height: height ?? 1080, availableHeights: [1080, 720],
|
||||||
|
chapters: url == chaptered ? fixtureChapters + [.init(start: 3, end: 500, title: nil)] : [])
|
||||||
|
}, youtubeTitleResolver: { _ in nil }, sponsorResolver: { id in id == "AAAAAAAAAAA" ? fixtureSponsors : [] },
|
||||||
|
miruroMetadataResolver: { _, _ in MiruroMetadata(title: nil, intervals: []) })
|
||||||
|
defer { model.shutdown() }
|
||||||
|
require(model.progressInitializationError == nil, "single Tubular launch initializes progress reporting")
|
||||||
|
for (url, expected) in [(chaptered, [1.25, 2.5]), (chapterless, [])] {
|
||||||
|
model.openStream(url.absoluteString)
|
||||||
|
require(model.sliderBoundaries.isEmpty, "direct open hides prior chapters while resolving")
|
||||||
|
try await until("direct YouTube stream decodes", model: model) {
|
||||||
|
!model.isBusy && model.state.status == PLAYER_PLAYING && model.state.position > 0.2
|
||||||
|
}
|
||||||
|
requireYouTubeChapters(model, expected, "direct openStream")
|
||||||
|
try await until("direct launch sponsorship metadata", model: model) {
|
||||||
|
url != chaptered || model.sponsoredRanges == [0.5...1.5]
|
||||||
|
}
|
||||||
|
require(model.sponsoredRanges == (url == chaptered ? [0.5...1.5] : []), "single launch sponsorship")
|
||||||
|
require(model.sliderSegmentTitle(at: 0.5) == (url == chaptered ? "Opening" : nil) &&
|
||||||
|
model.sliderSegmentTitle(at: 1.25) == (url == chaptered ? "Ending" : nil) &&
|
||||||
|
model.sliderSegmentTitle(at: 2.5) == nil,
|
||||||
|
"hover names match the chapter under the pointer, with no invented gap names")
|
||||||
|
require(model.state.duration < 500, "native duration rejects chapter edges beyond measured media")
|
||||||
|
require(model.setPlayback(playing: false), "pause before saving chapter playback")
|
||||||
|
try await until("chapter playback paused", model: model) { model.state.status == PLAYER_PAUSED }
|
||||||
|
require(model.seekRelative(0.8 - model.state.position), "save chapter playback bookmark")
|
||||||
|
try await until("chapter bookmark acknowledged", model: model) {
|
||||||
|
!model.state.seek_pending && abs(model.state.position - 0.8) < 0.1
|
||||||
|
}
|
||||||
|
model.closeFile()
|
||||||
|
require(model.sliderBoundaries.isEmpty, "close immediately removes chapter boundaries")
|
||||||
|
try await until("chapter playback closed", model: model) { model.state.status == PLAYER_EMPTY }
|
||||||
|
require(model.savedPosition(for: url).map { abs($0 - 0.8) < 0.1 } == true,
|
||||||
|
"acknowledged bookmark persists for canonical YouTube URL")
|
||||||
|
require(model.recentMedia.first?.url == url, "recent entry retains original YouTube URL")
|
||||||
|
model.open(model.recentMedia.first!.url)
|
||||||
|
try await until("recent YouTube reopen restores native bookmark", model: model) {
|
||||||
|
!model.isBusy && model.state.status == PLAYER_PLAYING && model.state.position >= 0.7
|
||||||
|
}
|
||||||
|
requireYouTubeChapters(model, expected, "saved/recent open(url)")
|
||||||
|
try await until("saved-item sponsorship metadata", model: model) {
|
||||||
|
url != chaptered || model.sponsoredRanges == [0.5...1.5]
|
||||||
|
}
|
||||||
|
require(model.setPlayback(playing: false), "pause before chapter quality replacement")
|
||||||
|
try await until("chapter quality pause acknowledged", model: model) { model.state.status == PLAYER_PAUSED }
|
||||||
|
let position = model.state.position
|
||||||
|
model.setYouTubeQuality(720)
|
||||||
|
require(model.sliderBoundaries.isEmpty, "quality extraction hides prior chapter boundaries")
|
||||||
|
try await until("chapter quality replacement preserves pause and position", model: model) {
|
||||||
|
!model.isBusy && model.state.status == PLAYER_PAUSED && model.youtubeHeight == 720 &&
|
||||||
|
abs(model.state.position - position) < 0.2
|
||||||
|
}
|
||||||
|
requireYouTubeChapters(model, expected, "chaptered/chapterless quality replacement")
|
||||||
|
require(model.sponsoredRanges == (url == chaptered ? [0.5...1.5] : []), "quality replacement retains paint")
|
||||||
|
}
|
||||||
|
model.open(chaptered)
|
||||||
|
try await until("chaptered media replaces chapterless media", model: model) {
|
||||||
|
!model.isBusy && model.state.status == PLAYER_PLAYING && model.fileName == "Chaptered launch"
|
||||||
|
}
|
||||||
|
requireYouTubeChapters(model, [1.25, 2.5], "chaptered replacement")
|
||||||
|
model.openResolvedSource(file)
|
||||||
|
require(model.sliderBoundaries.isEmpty, "non-YouTube replacement hides prior chapters while opening")
|
||||||
|
try await until("resolved replacement decodes without YouTube chapters", model: model) {
|
||||||
|
!model.isBusy && model.state.status == PLAYER_PLAYING && model.state.has_video && model.state.has_audio
|
||||||
|
}
|
||||||
|
require(model.sliderBoundaries.isEmpty, "resolved media does not inherit YouTube chapters")
|
||||||
|
require(model.sponsoredRanges.isEmpty, "resolved replacement clears sponsorship")
|
||||||
|
model.closeFile()
|
||||||
|
try await until("resolved replacement closes", model: model) { model.state.status == PLAYER_EMPTY }
|
||||||
|
require(model.sliderBoundaries.isEmpty, "empty player has no chapter boundaries")
|
||||||
|
require(requests == [chaptered, chaptered, chaptered, chapterless, chapterless, chapterless, chaptered],
|
||||||
|
"direct, recent, saved and quality opens each resolve fresh chapter metadata")
|
||||||
|
model.shutdown()
|
||||||
|
let rows = try String(contentsOf: report, encoding: .utf8).split(separator: "\n").map {
|
||||||
|
try JSONSerialization.jsonObject(with: Data($0.utf8)) as! [String: Any]
|
||||||
|
}
|
||||||
|
require(rows.contains {
|
||||||
|
$0["version"] as? Int == 1 && $0["media_url"] as? String == chaptered.absoluteString &&
|
||||||
|
$0["event"] as? String == "progress"
|
||||||
|
}, "single Tubular launch reports real playback under its canonical YouTube identity")
|
||||||
|
print("PASS: chaptered/chapterless direct and single Tubular launch, saved/recent reopen, quality, measured duration, replacement and close")
|
||||||
|
}
|
||||||
|
|
||||||
static func transportProtocol(source: ResolvedSource) async throws {
|
static func transportProtocol(source: ResolvedSource) async throws {
|
||||||
let original = URLComponents(string: source.url)!
|
let original = URLComponents(string: source.url)!
|
||||||
let base = "\(original.scheme!)://\(original.host!):\(original.port!)"
|
let base = "\(original.scheme!)://\(original.host!):\(original.port!)"
|
||||||
@@ -126,9 +679,11 @@ struct ResolvedPlaybackCheck {
|
|||||||
var input = ResolvedSource(url: base + "/transport-master.m3u8?token=private", headers:
|
var input = ResolvedSource(url: base + "/transport-master.m3u8?token=private", headers:
|
||||||
"Referer: https://transport.example/\r\nX-Secret: private\r\n",
|
"Referer: https://transport.example/\r\nX-Secret: private\r\n",
|
||||||
identity: source.identity, title: source.title, subtitles: [])
|
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),
|
input.requirements = .init(proxy: .init(rotate: false, segments: true),
|
||||||
segmentOrigin: base, playlistQuery: ["skip": "fixture"])
|
segmentOrigin: base, playlistQuery: ["skip": "fixture"])
|
||||||
let prepared = try await MiruroTransport.prepare(input, deployment: deployment)
|
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 (master, _) = try await URLSession.shared.data(from: URL(string: prepared.url)!)
|
||||||
let nested = String(data: master, encoding: .utf8)!.components(separatedBy: .newlines)
|
let nested = String(data: master, encoding: .utf8)!.components(separatedBy: .newlines)
|
||||||
.first { $0.hasPrefix("http://") }!
|
.first { $0.hasPrefix("http://") }!
|
||||||
@@ -298,7 +853,8 @@ struct ResolvedPlaybackCheck {
|
|||||||
var titleRequests: [URL] = []
|
var titleRequests: [URL] = []
|
||||||
func video(_ url: URL, title: String, height: Int = 1080) -> YouTubeVideo {
|
func video(_ url: URL, title: String, height: Int = 1080) -> YouTubeVideo {
|
||||||
YouTubeVideo(title: title, video: .init(url: source.url, headers: source.headers), audio: nil,
|
YouTubeVideo(title: title, video: .init(url: source.url, headers: source.headers), audio: nil,
|
||||||
duration: 0, isLive: false, webpageURL: url, height: height, availableHeights: [1080, 720])
|
duration: 0, isLive: false, webpageURL: url, height: height, availableHeights: [1080, 720],
|
||||||
|
chapters: height == 720 ? [.init(start: 0.75, end: 1.75, title: nil)] : fixtureChapters)
|
||||||
}
|
}
|
||||||
let model = PlayerModel(defaults: defaults, youtubeResolver: { url, height in
|
let model = PlayerModel(defaults: defaults, youtubeResolver: { url, height in
|
||||||
if holdFirst || height != nil {
|
if holdFirst || height != nil {
|
||||||
@@ -352,6 +908,7 @@ struct ResolvedPlaybackCheck {
|
|||||||
try await until("foreground title takes precedence and native playback starts", model: model) {
|
try await until("foreground title takes precedence and native playback starts", model: model) {
|
||||||
model.state.status == PLAYER_PLAYING && model.queue.currentItem?.displayName == "Playing title"
|
model.state.status == PLAYER_PLAYING && model.queue.currentItem?.displayName == "Playing title"
|
||||||
}
|
}
|
||||||
|
requireYouTubeChapters(model, [1.25, 2.5], "foreground queued metadata")
|
||||||
require(model.setPlayback(playing: false), "pause for metadata preservation")
|
require(model.setPlayback(playing: false), "pause for metadata preservation")
|
||||||
try await until("metadata smoke pause acknowledged", model: model) { model.state.status == PLAYER_PAUSED }
|
try await until("metadata smoke pause acknowledged", model: model) { model.state.status == PLAYER_PAUSED }
|
||||||
let position = model.state.position
|
let position = model.state.position
|
||||||
@@ -383,10 +940,14 @@ struct ResolvedPlaybackCheck {
|
|||||||
for _ in 0..<10 { await Task.yield() }
|
for _ in 0..<10 { await Task.yield() }
|
||||||
require(model.queue.items[1].displayName == urls[1].absoluteString,
|
require(model.queue.items[1].displayName == urls[1].absoluteString,
|
||||||
"removed/replaced item cannot rename a new row for the same URL")
|
"removed/replaced item cannot rename a new row for the same URL")
|
||||||
|
try await until("replacement native media ready for quality change", model: model) {
|
||||||
|
!model.isBusy && model.state.status == PLAYER_PLAYING && model.fileName == "Playing title"
|
||||||
|
}
|
||||||
require(model.setPlayback(playing: false), "pause before quality extraction")
|
require(model.setPlayback(playing: false), "pause before quality extraction")
|
||||||
try await until("quality smoke pause acknowledged", model: model) { model.state.status == PLAYER_PAUSED }
|
try await until("quality smoke pause acknowledged", model: model) { model.state.status == PLAYER_PAUSED }
|
||||||
model.setYouTubeQuality(720)
|
model.setYouTubeQuality(720)
|
||||||
try await until("quality extraction is held", model: model) { foreground != nil }
|
try await until("quality extraction is held", model: model) { foreground != nil }
|
||||||
|
require(model.sliderBoundaries.isEmpty, "held quality replacement hides previous chapter boundaries")
|
||||||
background!.resume(returning: "Replacement title")
|
background!.resume(returning: "Replacement title")
|
||||||
background = nil
|
background = nil
|
||||||
try await until("title arrives while quality extraction is held", model: model) {
|
try await until("title arrives while quality extraction is held", model: model) {
|
||||||
@@ -399,6 +960,7 @@ struct ResolvedPlaybackCheck {
|
|||||||
try await until("quality extraction resumes paused native playback", model: model) {
|
try await until("quality extraction resumes paused native playback", model: model) {
|
||||||
!model.isBusy && model.state.status == PLAYER_PAUSED && model.fileName == "Quality title"
|
!model.isBusy && model.state.status == PLAYER_PAUSED && model.fileName == "Quality title"
|
||||||
}
|
}
|
||||||
|
requireYouTubeChapters(model, [0.75, 1.75], "quality replacement accepts fresh chapters and preserves pause")
|
||||||
print("PASS: early current/queued titles during held playback and quality extraction; serial utility lookup, generic/Miruro titles, reorder, failure and stale replacement/foreground precedence")
|
print("PASS: early current/queued titles during held playback and quality extraction; serial utility lookup, generic/Miruro titles, reorder, failure and stale replacement/foreground precedence")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -424,14 +986,16 @@ struct ResolvedPlaybackCheck {
|
|||||||
var stale: CheckedContinuation<YouTubeVideo, Never>?
|
var stale: CheckedContinuation<YouTubeVideo, Never>?
|
||||||
let video = YouTubeVideo(title: "Queue YouTube fixture",
|
let video = YouTubeVideo(title: "Queue YouTube fixture",
|
||||||
video: .init(url: source.url, headers: source.headers), audio: nil,
|
video: .init(url: source.url, headers: source.headers), audio: nil,
|
||||||
duration: 0, isLive: false, webpageURL: youtubeURL, height: nil, availableHeights: [])
|
duration: 0, isLive: false, webpageURL: youtubeURL, height: nil, availableHeights: [],
|
||||||
let model = PlayerModel(defaults: defaults, streamQueue: queue, youtubeResolver: { _, _ in
|
chapters: fixtureChapters)
|
||||||
|
let model = PlayerModel(defaults: defaults, configuration: .init(sponsorBlock: true),
|
||||||
|
streamQueue: queue, youtubeResolver: { _, _ in
|
||||||
youtubeRequests += 1
|
youtubeRequests += 1
|
||||||
if youtubeRequests == 1 {
|
if youtubeRequests == 1 {
|
||||||
return await withCheckedContinuation { stale = $0 }
|
return await withCheckedContinuation { stale = $0 }
|
||||||
}
|
}
|
||||||
return video
|
return video
|
||||||
}, youtubeTitleResolver: { _ in nil }, miruroResolver: { _, _ in
|
}, youtubeTitleResolver: { _ in nil }, sponsorResolver: { _ in fixtureSponsors }, miruroResolver: { _, _ in
|
||||||
episodeRequests += 1
|
episodeRequests += 1
|
||||||
return try providerSources(source)
|
return try providerSources(source)
|
||||||
}, miruroMetadataResolver: { _, _ in MiruroMetadata(title: nil, intervals: []) })
|
}, miruroMetadataResolver: { _, _ in MiruroMetadata(title: nil, intervals: []) })
|
||||||
@@ -447,17 +1011,21 @@ struct ResolvedPlaybackCheck {
|
|||||||
try await until("manual stream selection decodes native media", model: model) {
|
try await until("manual stream selection decodes native media", model: model) {
|
||||||
!model.isBusy && model.state.status == PLAYER_PLAYING && model.state.position > 0.2
|
!model.isBusy && model.state.status == PLAYER_PLAYING && model.state.position > 0.2
|
||||||
}
|
}
|
||||||
|
require(model.sliderBoundaries.isEmpty, "selected Miruro media has no stale YouTube chapters")
|
||||||
stale!.resume(returning: video)
|
stale!.resume(returning: video)
|
||||||
stale = nil
|
stale = nil
|
||||||
// Yield to the canceled resolver; it must not open or redirect its report.
|
// Yield to the canceled resolver; it must not open or redirect its report.
|
||||||
for _ in 0..<10 { await Task.yield() }
|
for _ in 0..<10 { await Task.yield() }
|
||||||
require(model.queue.currentID == secondID && !model.miruroVideoProviders.isEmpty &&
|
require(model.queue.currentID == secondID && !model.miruroVideoProviders.isEmpty &&
|
||||||
!FileManager.default.fileExists(atPath: firstReport.path), "stale extraction cannot replace selected media or report")
|
!FileManager.default.fileExists(atPath: firstReport.path), "stale extraction cannot replace selected media or report")
|
||||||
|
require(model.sliderBoundaries.isEmpty, "canceled chaptered extraction cannot add boundaries to Miruro media")
|
||||||
model.advanceQueue(-1)
|
model.advanceQueue(-1)
|
||||||
try await until("previous resolves YouTube afresh", model: model) {
|
try await until("previous resolves YouTube afresh", model: model) {
|
||||||
!model.isBusy && model.state.status == PLAYER_PLAYING && model.fileName == video.title
|
!model.isBusy && model.state.status == PLAYER_PLAYING && model.fileName == video.title
|
||||||
}
|
}
|
||||||
require(youtubeRequests == 2 && model.queue.currentID == firstID, "manual previous preserves queue and resolves current item")
|
require(youtubeRequests == 2 && model.queue.currentID == firstID, "manual previous preserves queue and resolves current item")
|
||||||
|
requireYouTubeChapters(model, [1.25, 2.5], "batch queue previous")
|
||||||
|
try await until("batch launch sponsor paint", model: model) { model.sponsoredRanges == [0.5...1.5] }
|
||||||
require(model.setPlayback(playing: false), "pause first queued stream")
|
require(model.setPlayback(playing: false), "pause first queued stream")
|
||||||
try await until("queue pause acknowledged", model: model) { model.state.status == PLAYER_PAUSED }
|
try await until("queue pause acknowledged", model: model) { model.state.status == PLAYER_PAUSED }
|
||||||
require(model.seekRelative(max(0, model.duration - 0.3) - model.state.position), "seek first stream near natural end")
|
require(model.seekRelative(max(0, model.duration - 0.3) - model.state.position), "seek first stream near natural end")
|
||||||
@@ -469,6 +1037,8 @@ struct ResolvedPlaybackCheck {
|
|||||||
model.queue.currentID == secondID && !model.isBusy && model.state.status == PLAYER_PLAYING &&
|
model.queue.currentID == secondID && !model.isBusy && model.state.status == PLAYER_PLAYING &&
|
||||||
!model.miruroVideoProviders.isEmpty && episodeRequests == 2
|
!model.miruroVideoProviders.isEmpty && episodeRequests == 2
|
||||||
}
|
}
|
||||||
|
require(model.sliderBoundaries.isEmpty, "natural queue advance clears YouTube chapters for Miruro")
|
||||||
|
require(model.sponsoredRanges.isEmpty, "natural advance clears sponsor paint for Miruro")
|
||||||
require(model.setPlayback(playing: false), "pause second stream for restoration")
|
require(model.setPlayback(playing: false), "pause second stream for restoration")
|
||||||
try await until("second paused", model: model) { model.state.status == PLAYER_PAUSED }
|
try await until("second paused", model: model) { model.state.status == PLAYER_PAUSED }
|
||||||
require(model.seekRelative(0.8 - model.state.position), "save an acknowledged second-stream bookmark")
|
require(model.seekRelative(0.8 - model.state.position), "save an acknowledged second-stream bookmark")
|
||||||
@@ -479,6 +1049,7 @@ struct ResolvedPlaybackCheck {
|
|||||||
try await until("revisit first report session", model: model) {
|
try await until("revisit first report session", model: model) {
|
||||||
!model.isBusy && model.fileName == video.title && model.state.status == PLAYER_PLAYING
|
!model.isBusy && model.fileName == video.title && model.state.status == PLAYER_PLAYING
|
||||||
}
|
}
|
||||||
|
requireYouTubeChapters(model, [1.25, 2.5], "batch queue revisit")
|
||||||
model.advanceQueue(1)
|
model.advanceQueue(1)
|
||||||
try await until("second revisit resumes acknowledged bookmark", model: model) {
|
try await until("second revisit resumes acknowledged bookmark", model: model) {
|
||||||
!model.isBusy && model.queue.currentID == secondID && model.state.status == PLAYER_PLAYING &&
|
!model.isBusy && model.queue.currentID == secondID && model.state.status == PLAYER_PLAYING &&
|
||||||
@@ -525,13 +1096,15 @@ struct ResolvedPlaybackCheck {
|
|||||||
let suite = "terebi.enqueue-check." + UUID().uuidString
|
let suite = "terebi.enqueue-check." + UUID().uuidString
|
||||||
let defaults = UserDefaults(suiteName: suite)!
|
let defaults = UserDefaults(suiteName: suite)!
|
||||||
defer { defaults.removePersistentDomain(forName: suite) }
|
defer { defaults.removePersistentDomain(forName: suite) }
|
||||||
let model = PlayerModel(defaults: defaults, progressFile: originalReport.path,
|
let model = PlayerModel(defaults: defaults, configuration: .init(sponsorBlock: true), progressFile: originalReport.path,
|
||||||
youtubeResolver: { url, _ in
|
youtubeResolver: { url, _ in
|
||||||
requests += 1
|
requests += 1
|
||||||
return YouTubeVideo(title: url.absoluteString,
|
return YouTubeVideo(title: url.absoluteString,
|
||||||
video: .init(url: source.url, headers: source.headers), audio: nil,
|
video: .init(url: source.url, headers: source.headers), audio: nil,
|
||||||
duration: 0, isLive: false, webpageURL: url, height: nil, availableHeights: [])
|
duration: 0, isLive: false, webpageURL: url, height: nil, availableHeights: [],
|
||||||
}, youtubeTitleResolver: { _ in nil }, miruroResolver: { _, _ in episodeRequests += 1; return try providerSources(source) },
|
chapters: url == first ? fixtureChapters : [])
|
||||||
|
}, youtubeTitleResolver: { _ in nil }, sponsorResolver: { id in id == "AAAAAAAAAAA" ? fixtureSponsors : [] },
|
||||||
|
miruroResolver: { _, _ in episodeRequests += 1; return try providerSources(source) },
|
||||||
miruroMetadataResolver: { _, _ in MiruroMetadata(title: nil, intervals: []) })
|
miruroMetadataResolver: { _, _ in MiruroMetadata(title: nil, intervals: []) })
|
||||||
defer { model.shutdown() }
|
defer { model.shutdown() }
|
||||||
func deliver(_ items: [(URL, URL)]) throws {
|
func deliver(_ items: [(URL, URL)]) throws {
|
||||||
@@ -545,6 +1118,8 @@ struct ResolvedPlaybackCheck {
|
|||||||
try await until("standalone stream begins before enqueue", model: model) {
|
try await until("standalone stream begins before enqueue", model: model) {
|
||||||
model.state.status == PLAYER_PLAYING && !model.isBusy && model.state.position > 0.2
|
model.state.status == PLAYER_PLAYING && !model.isBusy && model.state.position > 0.2
|
||||||
}
|
}
|
||||||
|
requireYouTubeChapters(model, [1.25, 2.5], "single stream before enqueue")
|
||||||
|
try await until("initial single launch sponsor paint", model: model) { model.sponsoredRanges == [0.5...1.5] }
|
||||||
require(model.setPlayback(playing: false), "pause original single-video playback")
|
require(model.setPlayback(playing: false), "pause original single-video playback")
|
||||||
try await until("standalone pause acknowledged", model: model) { model.state.status == PLAYER_PAUSED }
|
try await until("standalone pause acknowledged", model: model) { model.state.status == PLAYER_PAUSED }
|
||||||
let selected = model.queue.currentID
|
let selected = model.queue.currentID
|
||||||
@@ -556,6 +1131,8 @@ struct ResolvedPlaybackCheck {
|
|||||||
model.state.position == position && model.state.revision == revision &&
|
model.state.position == position && model.state.revision == revision &&
|
||||||
model.fileName == first.absoluteString && requests == 1 && episodeRequests == 0,
|
model.fileName == first.absoluteString && requests == 1 && episodeRequests == 0,
|
||||||
"paused enqueue preserves original media, selection and decoder timeline; new items resolve lazily in supplied order")
|
"paused enqueue preserves original media, selection and decoder timeline; new items resolve lazily in supplied order")
|
||||||
|
requireYouTubeChapters(model, [1.25, 2.5], "paused enqueue preserves current chapters")
|
||||||
|
require(model.sponsoredRanges == [0.5...1.5], "paused enqueue preserves current sponsor paint")
|
||||||
require(model.setPlayback(playing: true), "resume original playback")
|
require(model.setPlayback(playing: true), "resume original playback")
|
||||||
try await until("original playing again", model: model) { model.state.status == PLAYER_PLAYING }
|
try await until("original playing again", model: model) { model.state.status == PLAYER_PLAYING }
|
||||||
let playingRevision = model.state.revision
|
let playingRevision = model.state.revision
|
||||||
@@ -564,6 +1141,8 @@ struct ResolvedPlaybackCheck {
|
|||||||
model.queue.currentID == selected && model.state.revision == playingRevision &&
|
model.queue.currentID == selected && model.state.revision == playingRevision &&
|
||||||
model.state.status == PLAYER_PLAYING,
|
model.state.status == PLAYER_PLAYING,
|
||||||
"repeated native delivery appends while playing without reopening current media")
|
"repeated native delivery appends while playing without reopening current media")
|
||||||
|
requireYouTubeChapters(model, [1.25, 2.5], "playing enqueue preserves current chapters")
|
||||||
|
require(model.sponsoredRanges == [0.5...1.5], "playing enqueue preserves current sponsor paint")
|
||||||
try deliver([(third, directory.appendingPathComponent("conflict.jsonl"))])
|
try deliver([(third, directory.appendingPathComponent("conflict.jsonl"))])
|
||||||
require(model.commandError != nil && model.queue.items.count == 4 && model.queue.currentID == selected,
|
require(model.commandError != nil && model.queue.items.count == 4 && model.queue.currentID == selected,
|
||||||
"conflicting identity rejects complete enqueue without changing playback")
|
"conflicting identity rejects complete enqueue without changing playback")
|
||||||
@@ -588,10 +1167,14 @@ struct ResolvedPlaybackCheck {
|
|||||||
!model.isBusy && model.state.status == PLAYER_PLAYING && !model.miruroVideoProviders.isEmpty &&
|
!model.isBusy && model.state.status == PLAYER_PLAYING && !model.miruroVideoProviders.isEmpty &&
|
||||||
model.queue.currentURL == second
|
model.queue.currentURL == second
|
||||||
}
|
}
|
||||||
|
require(model.sliderBoundaries.isEmpty, "enqueued Miruro advance clears current chapters")
|
||||||
|
require(model.sponsoredRanges.isEmpty, "enqueue advance to Miruro clears sponsor paint")
|
||||||
model.advanceQueue(1)
|
model.advanceQueue(1)
|
||||||
try await until("enqueued YouTube item decodes", model: model) {
|
try await until("enqueued YouTube item decodes", model: model) {
|
||||||
!model.isBusy && model.state.status == PLAYER_PLAYING && model.fileName == third.absoluteString
|
!model.isBusy && model.state.status == PLAYER_PLAYING && model.fileName == third.absoluteString
|
||||||
}
|
}
|
||||||
|
requireYouTubeChapters(model, [], "chapterless enqueued YouTube advance")
|
||||||
|
require(model.sponsoredRanges.isEmpty, "enqueued no-result video has no sponsor paint")
|
||||||
model.advanceQueue(-1)
|
model.advanceQueue(-1)
|
||||||
try await until("enqueued Miruro report revisits", model: model) {
|
try await until("enqueued Miruro report revisits", model: model) {
|
||||||
!model.isBusy && model.state.status == PLAYER_PLAYING && model.queue.currentURL == second
|
!model.isBusy && model.state.status == PLAYER_PLAYING && model.queue.currentURL == second
|
||||||
@@ -600,6 +1183,7 @@ struct ResolvedPlaybackCheck {
|
|||||||
try await until("original single video remains navigable", model: model) {
|
try await until("original single video remains navigable", model: model) {
|
||||||
!model.isBusy && model.state.status == PLAYER_PLAYING && model.fileName == first.absoluteString
|
!model.isBusy && model.state.status == PLAYER_PLAYING && model.fileName == first.absoluteString
|
||||||
}
|
}
|
||||||
|
requireYouTubeChapters(model, [1.25, 2.5], "enqueued revisit restores chaptered YouTube")
|
||||||
var retiredReports: [(URL, Data, URL, URL)] = []
|
var retiredReports: [(URL, Data, URL, URL)] = []
|
||||||
for (url, oldReport) in [(first, originalReport), (third, thirdReport)] {
|
for (url, oldReport) in [(first, originalReport), (third, thirdReport)] {
|
||||||
let oldID = model.queue.items.first(where: { $0.url == url })!.id
|
let oldID = model.queue.items.first(where: { $0.url == url })!.id
|
||||||
@@ -649,7 +1233,7 @@ struct ResolvedPlaybackCheck {
|
|||||||
require(!FileManager.default.fileExists(atPath: fourthReport.path), "unselected enqueued item never owns a writer")
|
require(!FileManager.default.fileExists(atPath: fourthReport.path), "unselected enqueued item never owns a writer")
|
||||||
let empty = PlayerModel(defaults: defaults, youtubeResolver: { url, _ in
|
let empty = PlayerModel(defaults: defaults, youtubeResolver: { url, _ in
|
||||||
YouTubeVideo(title: "Cold enqueue", video: .init(url: source.url, headers: source.headers), audio: nil,
|
YouTubeVideo(title: "Cold enqueue", video: .init(url: source.url, headers: source.headers), audio: nil,
|
||||||
duration: 0, isLive: false, webpageURL: url, height: nil, availableHeights: [])
|
duration: 0, isLive: false, webpageURL: url, height: nil, availableHeights: [], chapters: [])
|
||||||
}, youtubeTitleResolver: { _ in nil })
|
}, youtubeTitleResolver: { _ in nil })
|
||||||
defer { empty.shutdown() }
|
defer { empty.shutdown() }
|
||||||
let coldReport = directory.appendingPathComponent("cold.jsonl")
|
let coldReport = directory.appendingPathComponent("cold.jsonl")
|
||||||
@@ -658,6 +1242,7 @@ struct ResolvedPlaybackCheck {
|
|||||||
try await until("empty player starts first enqueued item", model: empty) {
|
try await until("empty player starts first enqueued item", model: empty) {
|
||||||
!empty.isBusy && empty.state.status == PLAYER_PLAYING && empty.queue.currentURL == fourth
|
!empty.isBusy && empty.state.status == PLAYER_PLAYING && empty.queue.currentURL == fourth
|
||||||
}
|
}
|
||||||
|
requireYouTubeChapters(empty, [], "cold chapterless enqueue")
|
||||||
print("PASS: repeated enqueue while paused/playing, standalone navigation, atomic conflicts, independent reports and empty-player start")
|
print("PASS: repeated enqueue while paused/playing, standalone navigation, atomic conflicts, independent reports and empty-player start")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -677,7 +1262,8 @@ struct ResolvedPlaybackCheck {
|
|||||||
if url == failedURL { throw CocoaError(.fileReadCorruptFile) }
|
if url == failedURL { throw CocoaError(.fileReadCorruptFile) }
|
||||||
let video = YouTubeVideo(title: "Sidebar \(url == first ? "first" : "second")",
|
let video = YouTubeVideo(title: "Sidebar \(url == first ? "first" : "second")",
|
||||||
video: .init(url: source.url, headers: source.headers), audio: nil,
|
video: .init(url: source.url, headers: source.headers), audio: nil,
|
||||||
duration: 0, isLive: false, webpageURL: url, height: nil, availableHeights: [])
|
duration: 0, isLive: false, webpageURL: url, height: nil, availableHeights: [],
|
||||||
|
chapters: url == first ? fixtureChapters : [])
|
||||||
if url == cancelledURL { return await withCheckedContinuation { pending = $0 } }
|
if url == cancelledURL { return await withCheckedContinuation { pending = $0 } }
|
||||||
return video
|
return video
|
||||||
}, youtubeTitleResolver: { _ in nil }, miruroResolver: { _, _ in try providerSources(source) },
|
}, youtubeTitleResolver: { _ in nil }, miruroResolver: { _, _ in try providerSources(source) },
|
||||||
@@ -688,6 +1274,7 @@ struct ResolvedPlaybackCheck {
|
|||||||
!model.isBusy && model.state.status == PLAYER_PLAYING && model.state.position > 0.2
|
!model.isBusy && model.state.status == PLAYER_PLAYING && model.state.position > 0.2
|
||||||
}
|
}
|
||||||
require(requests == 1 && model.queue.currentURL == first, "empty addition uses fetched source without re-extracting")
|
require(requests == 1 && model.queue.currentURL == first, "empty addition uses fetched source without re-extracting")
|
||||||
|
requireYouTubeChapters(model, [1.25, 2.5], "sidebar first native playback")
|
||||||
require(model.setPlayback(playing: false), "pause before sidebar addition")
|
require(model.setPlayback(playing: false), "pause before sidebar addition")
|
||||||
try await until("sidebar pause acknowledged", model: model) { model.state.status == PLAYER_PAUSED }
|
try await until("sidebar pause acknowledged", model: model) { model.state.status == PLAYER_PAUSED }
|
||||||
let selected = model.queue.currentID
|
let selected = model.queue.currentID
|
||||||
@@ -722,18 +1309,21 @@ struct ResolvedPlaybackCheck {
|
|||||||
while pending == nil { await Task.yield() }
|
while pending == nil { await Task.yield() }
|
||||||
cancelled.cancel()
|
cancelled.cancel()
|
||||||
pending!.resume(returning: YouTubeVideo(title: "Cancelled", video: .init(url: source.url, headers: source.headers),
|
pending!.resume(returning: YouTubeVideo(title: "Cancelled", video: .init(url: source.url, headers: source.headers),
|
||||||
audio: nil, duration: 0, isLive: false, webpageURL: cancelledURL, height: nil, availableHeights: []))
|
audio: nil, duration: 0, isLive: false, webpageURL: cancelledURL, height: nil, availableHeights: [],
|
||||||
|
chapters: [.init(start: 0.75, end: 1.75, title: "Canceled metadata")]))
|
||||||
do {
|
do {
|
||||||
try await cancelled.value
|
try await cancelled.value
|
||||||
fatalError("FAIL: cancelled sidebar addition accepted")
|
fatalError("FAIL: cancelled sidebar addition accepted")
|
||||||
} catch is CancellationError {}
|
} catch is CancellationError {}
|
||||||
require(model.queue.items.count == 4 && model.queue.currentID == selected,
|
require(model.queue.items.count == 4 && model.queue.currentID == selected,
|
||||||
"dismissed in-flight addition cannot append a late result")
|
"dismissed in-flight addition cannot append a late result")
|
||||||
|
requireYouTubeChapters(model, [1.25, 2.5], "canceled sidebar metadata leaves accepted chapters unchanged")
|
||||||
model.selectQueueItem(model.queue.items[1].id)
|
model.selectQueueItem(model.queue.items[1].id)
|
||||||
try await until("sidebar-added item plays through native queue selection", model: model) {
|
try await until("sidebar-added item plays through native queue selection", model: model) {
|
||||||
!model.isBusy && model.state.status == PLAYER_PLAYING && model.queue.currentURL == second &&
|
!model.isBusy && model.state.status == PLAYER_PLAYING && model.queue.currentURL == second &&
|
||||||
model.fileName == "Sidebar second"
|
model.fileName == "Sidebar second"
|
||||||
}
|
}
|
||||||
|
requireYouTubeChapters(model, [], "sidebar chapterless native selection")
|
||||||
print("PASS: sidebar URL fetch/add, empty-player decode, paused/playing preservation, validation, failure, cancellation and selection")
|
print("PASS: sidebar URL fetch/add, empty-player decode, paused/playing preservation, validation, failure, cancellation and selection")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -742,12 +1332,15 @@ struct ResolvedPlaybackCheck {
|
|||||||
let ending = MiruroInterval(kind: .outro, start: 2, end: model.duration, episode: 1)
|
let ending = MiruroInterval(kind: .outro, start: 2, end: model.duration, episode: 1)
|
||||||
let metadata = MiruroMetadata(title: nil, intervals: [opening, ending])
|
let metadata = MiruroMetadata(title: nil, intervals: [opening, ending])
|
||||||
require(model.segmentToSkip == nil && !model.skipSegment(), "absent metadata cannot skip")
|
require(model.segmentToSkip == nil && !model.skipSegment(), "absent metadata cannot skip")
|
||||||
|
require(model.sliderBoundaries.isEmpty, "absent Miruro metadata leaves slider unsegmented")
|
||||||
require(model.seekRelative(0.75 - model.state.position), "enter opening")
|
require(model.seekRelative(0.75 - model.state.position), "enter opening")
|
||||||
try await until("opening position acknowledged", model: model) {
|
try await until("opening position acknowledged", model: model) {
|
||||||
!model.state.seek_pending && abs(model.state.position - 0.75) < 0.1
|
!model.state.seek_pending && abs(model.state.position - 0.75) < 0.1
|
||||||
}
|
}
|
||||||
model.supplyMetadataForCheck(metadata, episode: 1)
|
model.supplyMetadataForCheck(metadata, episode: 1)
|
||||||
require(model.segmentToSkip == opening, "late metadata offers opening at acknowledged paused position")
|
require(model.segmentToSkip == opening, "late metadata offers opening at acknowledged paused position")
|
||||||
|
require(model.visibleIntervals == [opening, ending] && model.sliderBoundaries == [0.5, 1.5, 2],
|
||||||
|
"Miruro retains intro/outro bands and interior slider boundaries without the media endpoint")
|
||||||
require(model.skipOfferRemainingFraction == 1, "late entry starts with a full countdown background")
|
require(model.skipOfferRemainingFraction == 1, "late entry starts with a full countdown background")
|
||||||
clock.now = 105
|
clock.now = 105
|
||||||
require(model.skipOfferRemainingFraction == 0.5, "paused countdown drains by elapsed offer time, not media position")
|
require(model.skipOfferRemainingFraction == 0.5, "paused countdown drains by elapsed offer time, not media position")
|
||||||
@@ -777,6 +1370,7 @@ struct ResolvedPlaybackCheck {
|
|||||||
require(model.segmentToSkip == nil, "ignored opening stays dismissed when revisited")
|
require(model.segmentToSkip == nil, "ignored opening stays dismissed when revisited")
|
||||||
model.closeFile()
|
model.closeFile()
|
||||||
require(model.segmentToSkip == nil, "close removes offer")
|
require(model.segmentToSkip == nil, "close removes offer")
|
||||||
|
require(model.sliderBoundaries.isEmpty, "closing Miruro media removes slider boundaries")
|
||||||
model.openResolvedSource(file)
|
model.openResolvedSource(file)
|
||||||
try await until("reopened media playing", model: model) {
|
try await until("reopened media playing", model: model) {
|
||||||
model.state.status == PLAYER_PLAYING && !model.state.seek_pending && !model.isBusy
|
model.state.status == PLAYER_PLAYING && !model.state.seek_pending && !model.isBusy
|
||||||
@@ -787,6 +1381,7 @@ struct ResolvedPlaybackCheck {
|
|||||||
try await until("reopened opening acknowledged", model: model) {
|
try await until("reopened opening acknowledged", model: model) {
|
||||||
!model.state.seek_pending && abs(model.state.position - 0.75) < 0.1
|
!model.state.seek_pending && abs(model.state.position - 0.75) < 0.1
|
||||||
}
|
}
|
||||||
|
require(model.sliderBoundaries.isEmpty, "reopened Miruro media waits for fresh metadata")
|
||||||
model.supplyMetadataForCheck(metadata, episode: 1)
|
model.supplyMetadataForCheck(metadata, episode: 1)
|
||||||
try await until("new open resets dismissal after seek settles", model: model) {
|
try await until("new open resets dismissal after seek settles", model: model) {
|
||||||
model.segmentToSkip == opening
|
model.segmentToSkip == opening
|
||||||
@@ -804,4 +1399,56 @@ struct ResolvedPlaybackCheck {
|
|||||||
require(model.effectivePlaying, "opening skip preserves playing intent")
|
require(model.effectivePlaying, "opening skip preserves playing intent")
|
||||||
print("PASS: timed opening/ending offers, dismissal, pending seeks, exact-end skips and reopen reset")
|
print("PASS: timed opening/ending offers, dismissal, pending seeks, exact-end skips and reopen reset")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func automaticSegmentSkips(file: URL) async throws {
|
||||||
|
for seekTarget: Double? in [nil, 0.75, 0.25] {
|
||||||
|
let suite = "terebi.segment-autoskip." + UUID().uuidString
|
||||||
|
let defaults = UserDefaults(suiteName: suite)!
|
||||||
|
defer { defaults.removePersistentDomain(forName: suite) }
|
||||||
|
let model = PlayerModel(defaults: defaults, configuration: .init(openingEndingAutoSkip: true),
|
||||||
|
miruroMetadataResolver: { _, _ in MiruroMetadata(title: nil, intervals: []) }, skipClock: { 100 })
|
||||||
|
defer { model.shutdown() }
|
||||||
|
model.openResolvedSource(file)
|
||||||
|
try await until("automatic segment fixture plays", model: model) {
|
||||||
|
model.state.status == PLAYER_PLAYING && model.canSeek && !model.isBusy
|
||||||
|
}
|
||||||
|
let opening = MiruroInterval(kind: .intro, start: 0.5, end: 1.5, episode: 1)
|
||||||
|
let ending = MiruroInterval(kind: .outro, start: 2, end: model.duration, episode: 1)
|
||||||
|
if let seekTarget {
|
||||||
|
require(model.setPlayback(playing: false), "pause before intentional opening seek")
|
||||||
|
try await until("automatic segment fixture pauses", model: model) { model.state.status == PLAYER_PAUSED }
|
||||||
|
if seekTarget == 0.25 {
|
||||||
|
model.isSeeking = true
|
||||||
|
model.seekPosition = seekTarget
|
||||||
|
model.finishSeeking()
|
||||||
|
} else {
|
||||||
|
require(model.seekRelative(seekTarget - model.state.position), "seek into opening")
|
||||||
|
}
|
||||||
|
try await until("intentional opening seek settles", model: model) {
|
||||||
|
!model.state.seek_pending && abs(model.state.position - seekTarget) < 0.1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
model.supplyMetadataForCheck(.init(title: nil, intervals: [opening, ending]), episode: 1)
|
||||||
|
if let seekTarget {
|
||||||
|
require(abs(model.seekPosition - seekTarget) < 0.1 && model.state.status == PLAYER_PAUSED,
|
||||||
|
"late metadata does not autoskip paused playback")
|
||||||
|
require(model.setPlayback(playing: true), "resume deliberately selected opening")
|
||||||
|
try await until("deliberately selected opening plays", model: model) { model.state.position >= 1 }
|
||||||
|
require(model.state.position < opening.end && model.skipButtonTitle == "Skip Opening",
|
||||||
|
"opening autoskip respects seeking into or just before it and keeps the manual button")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
try await until("opening autoskip queues its exact end", model: model) { model.seekPosition == opening.end }
|
||||||
|
try await until("native player acknowledges opening autoskip", model: model) {
|
||||||
|
!model.state.seek_pending && model.state.position >= opening.end && model.state.status == PLAYER_PLAYING
|
||||||
|
}
|
||||||
|
try await until("ending autoskip queues exact media end before playing through ending", model: model) {
|
||||||
|
model.seekPosition == ending.end && model.state.position < ending.end - 0.5
|
||||||
|
}
|
||||||
|
try await until("native player acknowledges ending autoskip", model: model) {
|
||||||
|
!model.state.seek_pending && abs(model.state.position - ending.end) < 0.1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
print("PASS: native opening/ending autoskip, paused/late metadata and deliberate relative/slider seek protection")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Executable
+83
@@ -0,0 +1,83 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Actual CLI contract checks; --live adds bounded public embed/root probes only."""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
|
||||||
|
def require(condition, message):
|
||||||
|
if not condition:
|
||||||
|
raise AssertionError(message)
|
||||||
|
|
||||||
|
|
||||||
|
def snapshot(*roots):
|
||||||
|
return {
|
||||||
|
str(path): path.read_bytes() if path.is_file() else None
|
||||||
|
for root in roots
|
||||||
|
for path in root.rglob("*")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
executable = Path(sys.argv[1]).resolve(strict=True)
|
||||||
|
live = "--live" in sys.argv[2:]
|
||||||
|
watch = "https://zokoanime.video/stream/ani/1/1/sub"
|
||||||
|
secret = "private-fixture-token"
|
||||||
|
with tempfile.TemporaryDirectory(prefix="terebi-source-check-") as temporary:
|
||||||
|
root = Path(temporary)
|
||||||
|
home, config = root / "home", root / "config"
|
||||||
|
home.mkdir()
|
||||||
|
(config / "terebi").mkdir(parents=True)
|
||||||
|
# A launch would parse this malformed configuration or reject this
|
||||||
|
# executable outside its bundle. A source check must do neither.
|
||||||
|
(config / "terebi" / "config.toml").write_text("not valid TOML = [\n")
|
||||||
|
cli = root / "terebi"
|
||||||
|
shutil.copy2(executable, cli)
|
||||||
|
environment = dict(os.environ, HOME=str(home), CFFIXED_USER_HOME=str(home), XDG_CONFIG_HOME=str(config))
|
||||||
|
before = snapshot(home, config)
|
||||||
|
|
||||||
|
def invoke(arguments):
|
||||||
|
try:
|
||||||
|
result = subprocess.run([str(cli), *arguments], env=environment, capture_output=True, timeout=20)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
raise AssertionError("source-check CLI exceeded total deadline") from None
|
||||||
|
require(len(result.stdout) <= 128 and len(result.stderr) <= 1024, "bounded CLI output")
|
||||||
|
require(secret.encode() not in result.stdout + result.stderr, "CLI output redacts private input")
|
||||||
|
require(b"https://" not in result.stdout, "no source URLs in JSON")
|
||||||
|
require(snapshot(home, config) == before, "source checking must not write configuration or resume/progress state")
|
||||||
|
return result
|
||||||
|
|
||||||
|
for arguments in [
|
||||||
|
["--check-source"], ["--check-source", watch + "?token=" + secret],
|
||||||
|
["--check-source", "https://" + secret + "@zokoanime.video/stream/ani/1/1/sub"],
|
||||||
|
["--new-instance", "--check-source", watch], ["--check-source", watch, "--no-config"],
|
||||||
|
["--check-source", watch, "--progress-file", str(root / secret)],
|
||||||
|
["--check-source", "https://127.0.0.1/" + secret],
|
||||||
|
]:
|
||||||
|
result = invoke(arguments)
|
||||||
|
require(result.returncode != 0 and not result.stdout, "invalid syntax/unsupported URL must fail without JSON or launch")
|
||||||
|
require(not (root / secret).exists(), "invalid source check must not create progress file")
|
||||||
|
if live:
|
||||||
|
for media_id, episode, expected in [
|
||||||
|
(1, 1, "available"), (1, 27, "unavailable"),
|
||||||
|
(97940, 1, "available"), (97940, 170, "available"),
|
||||||
|
(195604, 1, "available"),
|
||||||
|
]:
|
||||||
|
result = invoke(["--check-source", f"https://zokoanime.video/stream/ani/{media_id}/{episode}/sub"])
|
||||||
|
require(result.returncode == 0, "completed provider checks exit zero")
|
||||||
|
require(result.stdout.endswith(b"\n") and result.stdout.count(b"\n") == 1, "exactly one JSON line")
|
||||||
|
row = json.loads(result.stdout)
|
||||||
|
require(row == {"version": 1, "provider": "zokoanime", "track": "sub", "status": expected},
|
||||||
|
f"live AniList {media_id} episode {episode} expected {expected}; result is redacted")
|
||||||
|
require(not result.stderr, "successful source checks have no error logs")
|
||||||
|
print("PASS: actual CLI resolves exact MAL mappings for Cowboy Bebop and both Black Clover entries; explicit missing remains unavailable; no state writes")
|
||||||
|
else:
|
||||||
|
print("PASS: actual CLI rejects hostile URLs/flags privately, without configuration reads, state writes or bundle launch")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct SourceCheckChecks {
|
||||||
|
struct Failure: Error {}
|
||||||
|
static func require(_ condition: @autoclosure () -> Bool, _ message: String) throws {
|
||||||
|
guard condition() else {
|
||||||
|
FileHandle.standardError.write(Data("FAIL: \(message)\n".utf8))
|
||||||
|
throw Failure()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func main() async throws {
|
||||||
|
let watch = "https://zokoanime.video/stream/ani/1/1/sub"
|
||||||
|
guard case .checkSource(let id, let episode) = try PlayerCommand.parse(["--check-source", watch]) else {
|
||||||
|
throw Failure()
|
||||||
|
}
|
||||||
|
try require(id == "1" && episode == 1, "check command is not a launch")
|
||||||
|
let secret = "private-fixture-token"
|
||||||
|
for arguments in [
|
||||||
|
["--check-source"], ["--check-source", watch, watch],
|
||||||
|
["--new-instance", "--check-source", watch], ["--no-config", "--check-source", watch],
|
||||||
|
["--check-source", watch, "--progress-file", "/tmp/" + secret],
|
||||||
|
["--source-file", "/tmp/" + secret, "--check-source", watch],
|
||||||
|
["--check-source", watch, "--help"], ["--check-source", "--check-source"],
|
||||||
|
["--check-source", "https://youtu.be/jNQXAC9IVRw"],
|
||||||
|
["--check-source", "https://barelystarted.miruro.tv/watch/1/show?ep=1"],
|
||||||
|
["--check-source", " " + watch], ["--check-source", watch + "\n"],
|
||||||
|
["--check-source", watch + "?token=" + secret], ["--check-source", watch + "#" + secret],
|
||||||
|
["--check-source", watch.replacingOccurrences(of: "https:", with: "http:")],
|
||||||
|
["--check-source", watch.replacingOccurrences(of: "/1/1/", with: "/01/1/")],
|
||||||
|
["--check-source", watch.replacingOccurrences(of: "/1/sub", with: "/2147483648/sub")],
|
||||||
|
["--check-source", watch.replacingOccurrences(of: "/sub", with: "/dub")],
|
||||||
|
["--check-source", "https://" + secret + "@zokoanime.video/stream/ani/1/1/sub"],
|
||||||
|
["--check-source", "https://zokoanime.video.evil.example/stream/ani/1/1/sub"],
|
||||||
|
["--check-source", "https://zokoanime.video/stream/ani/%31/1/sub"],
|
||||||
|
["--check-source", String(repeating: secret, count: 100)]
|
||||||
|
] {
|
||||||
|
do {
|
||||||
|
_ = try PlayerCommand.parse(arguments)
|
||||||
|
} catch {
|
||||||
|
try require(!error.localizedDescription.contains(secret), "argument errors redact input")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
throw Failure()
|
||||||
|
}
|
||||||
|
for status in SourceCheck.Status.allCases {
|
||||||
|
let json = status.json
|
||||||
|
let row = try JSONSerialization.jsonObject(with: Data(json.utf8)) as! [String: Any]
|
||||||
|
try require(row.count == 4 && row["version"] as? Int == 1 && row["provider"] as? String == "zokoanime" &&
|
||||||
|
row["track"] as? String == "sub" && row["status"] as? String == status.rawValue,
|
||||||
|
"one bounded result schema without source addresses or error strings")
|
||||||
|
try require(json.utf8.count < 128 && !json.contains("\n"), "bounded single-line JSON")
|
||||||
|
}
|
||||||
|
let root = URL(string: "https://hls.dramahot.top/master.m3u8?token=" + secret)!
|
||||||
|
let master = "#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=1000000\nvideo.m3u8\n"
|
||||||
|
let media = "#EXTM3U\n#EXT-X-TARGETDURATION:10\n#EXTINF:10,\nhttps://hls1.drama1.cfd/segment.jpg\n#EXT-X-ENDLIST\n"
|
||||||
|
try MiruroTransport.validateZokoManifest(master, base: root)
|
||||||
|
try MiruroTransport.validateZokoManifest(media, base: root)
|
||||||
|
for malformed in ["<html>" + secret + "</html>", "#EXTM3U", "#EXTM3Ugarbage\n" + master,
|
||||||
|
"#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=1\n", "#EXTM3U\nrandom.txt\n",
|
||||||
|
master.replacingOccurrences(of: "video.m3u8", with: "https://127.0.0.1/private.m3u8"),
|
||||||
|
master.replacingOccurrences(of: "video.m3u8", with: "https://hls1.drama1.cfd/private.m3u8"),
|
||||||
|
media.replacingOccurrences(of: "https://hls1.drama1.cfd/segment.jpg", with: "https://evil.example/" + secret),
|
||||||
|
"#EXTM3U\n#EXT-X-KEY:METHOD=AES-128,URI=\"http://127.0.0.1/" + secret + "\"\n" + media,
|
||||||
|
String(repeating: "x", count: ZokoResolver.bodyLimit + 1)] {
|
||||||
|
do { try MiruroTransport.validateZokoManifest(malformed, base: root) }
|
||||||
|
catch {
|
||||||
|
try require(!error.localizedDescription.contains(secret), "manifest errors redact source details")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
throw Failure()
|
||||||
|
}
|
||||||
|
// Exercise error classification, not fake provider availability. Live proof uses the actual CLI below.
|
||||||
|
for category in [ZokoError.unavailable, .transport, .protection, .malformed, .mapping] {
|
||||||
|
let result = await SourceCheck.run(mediaID: "1", episode: 1, resolve: { _, _ in throw category },
|
||||||
|
validate: { _ in throw Failure() })
|
||||||
|
try require(result == (category == .unavailable ? .unavailable : .failed), "only explicit missing episode is unavailable")
|
||||||
|
}
|
||||||
|
let source = ResolvedSource(url: root.absoluteString, headers: "", identity: "anilist:1:1", title: "Fixture",
|
||||||
|
subtitles: [], requirements: .init(zoko: true))
|
||||||
|
let resourceFailure = await SourceCheck.run(mediaID: "1", episode: 1, resolve: { _, _ in source },
|
||||||
|
validate: { _ in throw URLError(.badServerResponse, userInfo: [NSURLErrorFailingURLStringErrorKey: root.absoluteString]) })
|
||||||
|
try require(resourceFailure == .failed && !resourceFailure.json.contains(secret), "resource errors are failed and private")
|
||||||
|
let deadline = await SourceCheck.run(mediaID: "1", episode: 1, timeout: .zero, resolve: { _, _ in
|
||||||
|
try await Task.sleep(for: .seconds(60))
|
||||||
|
return source
|
||||||
|
}, validate: { _ in throw Failure() })
|
||||||
|
try require(deadline == .failed, "total deadline cancels pending work")
|
||||||
|
let cancelled = Task {
|
||||||
|
await SourceCheck.run(mediaID: "1", episode: 1, resolve: { _, _ in
|
||||||
|
try await Task.sleep(for: .seconds(60))
|
||||||
|
return source
|
||||||
|
}, validate: { _ in throw Failure() })
|
||||||
|
}
|
||||||
|
cancelled.cancel()
|
||||||
|
let cancelledResult = await cancelled.value
|
||||||
|
try require(cancelledResult == .failed, "caller cancellation stops source check")
|
||||||
|
print("PASS: source command isolation, redaction, bounded JSON, shared manifest validation, error taxonomy, total deadline and cancellation")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
private final class SponsorBlockProtocol: URLProtocol, @unchecked Sendable {
|
||||||
|
struct Reply {
|
||||||
|
var status = 200
|
||||||
|
var body = Data("[]".utf8)
|
||||||
|
var headers: [String: String] = [:]
|
||||||
|
var chunkSize = 8192
|
||||||
|
}
|
||||||
|
final class State: @unchecked Sendable {
|
||||||
|
let lock = NSLock()
|
||||||
|
var reply = Reply()
|
||||||
|
var requests: [URLRequest] = []
|
||||||
|
func reset(_ reply: Reply) {
|
||||||
|
lock.lock(); defer { lock.unlock() }
|
||||||
|
self.reply = reply
|
||||||
|
requests = []
|
||||||
|
}
|
||||||
|
func receive(_ request: URLRequest) -> Reply {
|
||||||
|
lock.lock(); defer { lock.unlock() }
|
||||||
|
requests.append(request)
|
||||||
|
return reply
|
||||||
|
}
|
||||||
|
var captured: [URLRequest] {
|
||||||
|
lock.lock(); defer { lock.unlock() }
|
||||||
|
return requests
|
||||||
|
}
|
||||||
|
}
|
||||||
|
static let state = State()
|
||||||
|
override class func canInit(with request: URLRequest) -> Bool { true }
|
||||||
|
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
|
||||||
|
override func startLoading() {
|
||||||
|
let reply = Self.state.receive(request)
|
||||||
|
let response = HTTPURLResponse(url: request.url!, statusCode: reply.status,
|
||||||
|
httpVersion: "HTTP/1.1", headerFields: reply.headers)!
|
||||||
|
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
|
||||||
|
for offset in stride(from: 0, to: reply.body.count, by: reply.chunkSize) {
|
||||||
|
client?.urlProtocol(self, didLoad: reply.body.subdata(in: offset..<min(offset + reply.chunkSize, reply.body.count)))
|
||||||
|
}
|
||||||
|
client?.urlProtocolDidFinishLoading(self)
|
||||||
|
}
|
||||||
|
override func stopLoading() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct SponsorBlockChecks {
|
||||||
|
struct Failure: Error { let message: String }
|
||||||
|
static let id = "YOelRv7fMxY"
|
||||||
|
static func require(_ condition: @autoclosure () -> Bool, _ message: String) throws {
|
||||||
|
if !condition() { throw Failure(message: message) }
|
||||||
|
}
|
||||||
|
static func reject(_ message: String, _ operation: () async throws -> Void) async throws {
|
||||||
|
do { try await operation() }
|
||||||
|
catch { return }
|
||||||
|
throw Failure(message: "Accepted \(message)")
|
||||||
|
}
|
||||||
|
static func row(_ start: Double = 10.25, _ end: Double = 20.75, duration: Double = 100) -> [String: Any] {
|
||||||
|
["category": "sponsor", "actionType": "skip", "segment": [start, end], "videoDuration": duration]
|
||||||
|
}
|
||||||
|
static func wire(_ value: Any) throws -> Data {
|
||||||
|
try JSONSerialization.data(withJSONObject: value, options: [.sortedKeys, .fragmentsAllowed])
|
||||||
|
}
|
||||||
|
static func bucket(_ rows: [Any], videoID: String = id) -> [String: Any] {
|
||||||
|
["videoID": videoID, "segments": rows]
|
||||||
|
}
|
||||||
|
static func configuration() -> URLSessionConfiguration {
|
||||||
|
let configuration = URLSessionConfiguration.ephemeral
|
||||||
|
configuration.protocolClasses = [SponsorBlockProtocol.self]
|
||||||
|
// Even an injected configuration must not send ambient credentials.
|
||||||
|
configuration.httpAdditionalHeaders = ["Authorization": "secret", "Cookie": "secret"]
|
||||||
|
return configuration
|
||||||
|
}
|
||||||
|
private static func resolve(_ reply: SponsorBlockProtocol.Reply) async throws -> [SponsorSegment] {
|
||||||
|
SponsorBlockProtocol.state.reset(reply)
|
||||||
|
return try await SponsorBlockResolver.resolve(videoID: id, configuration: configuration())
|
||||||
|
}
|
||||||
|
static func checks() async throws {
|
||||||
|
let canonical = "https://www.youtube.com/watch?v=\(id)"
|
||||||
|
try require(SponsorBlockResolver.videoID(for: URL(string: canonical)!) == id, "canonical identity")
|
||||||
|
for invalid in ["http://www.youtube.com/watch?v=\(id)", "https://youtube.com/watch?v=\(id)",
|
||||||
|
"https://youtu.be/\(id)", "https://www.youtube.com:443/watch?v=\(id)",
|
||||||
|
"https://user:secret@www.youtube.com/watch?v=\(id)", canonical + "&t=10",
|
||||||
|
canonical + "#chapter", canonical + "&v=\(id)", canonical + "x",
|
||||||
|
"https://www.youtube.com/watch?v=YOelRv7fMxé",
|
||||||
|
"https://www.youtube.com/watch?v=%59OelRv7fMxY",
|
||||||
|
"https://www.youtube.com/watch?v=YOelRv7fMx.",
|
||||||
|
"https://www.youtube.com/embed/\(id)", "https://example.org/watch?v=\(id)"] {
|
||||||
|
try require(SponsorBlockResolver.videoID(for: URL(string: invalid)!) == nil, "reject noncanonical identity: \(invalid)")
|
||||||
|
}
|
||||||
|
let request = try SponsorBlockResolver.request(videoID: id)
|
||||||
|
try require(request.url?.absoluteString == "https://sponsor.ajay.app/api/skipSegments/5db6?category=sponsor&actionType=skip&service=YouTube", "known SHA256 prefix and exact endpoint")
|
||||||
|
try require(request.httpMethod == "GET" && !request.httpShouldHandleCookies && request.timeoutInterval == 10, "bounded credential-free GET")
|
||||||
|
try await reject("invalid direct identity") { _ = try SponsorBlockResolver.request(videoID: "../secret") }
|
||||||
|
|
||||||
|
var invalidRows: [Any] = [NSNull(), "bad row", 4, ["segment": [1, 2]], row(-1, 2), row(3, 2), row(2, 2)]
|
||||||
|
for (key, value) in [("category", "intro" as Any), ("actionType", "mute" as Any),
|
||||||
|
("segment", [1] as Any), ("segment", [1, 2, 3] as Any),
|
||||||
|
("segment", [true, false] as Any), ("segment", ["1", "2"] as Any),
|
||||||
|
("videoDuration", -1 as Any), ("videoDuration", "100" as Any),
|
||||||
|
("videoDuration", true as Any), ("videoDuration", NSNull() as Any)] {
|
||||||
|
var malformed = row(); malformed[key] = value; invalidRows.append(malformed)
|
||||||
|
}
|
||||||
|
var missing = row(); missing.removeValue(forKey: "videoDuration"); invalidRows.append(missing)
|
||||||
|
let body = try wire([NSNull(), 42, ["videoID": id],
|
||||||
|
bucket([row(1, 2)], videoID: "aqz-KE-bpKQ"),
|
||||||
|
bucket(invalidRows + [row(), row(80.125, 90.5, duration: 0)])])
|
||||||
|
let segments = try SponsorBlockResolver.decode(body, videoID: id)
|
||||||
|
try require(segments.count == 2 && segments[0].start == 10.25 && segments[0].end == 20.75 && segments[0].videoDuration == 100 && segments[1].videoDuration == 0, "lossy individual buckets/rows, exact match, fractional times and unknown duration")
|
||||||
|
let absent = try SponsorBlockResolver.decode(body, videoID: "abcdefghijk")
|
||||||
|
try require(absent.isEmpty, "no exact bucket match")
|
||||||
|
for malformed in [Data("[".utf8), try wire(["segments": []]), try wire(NSNull()), Data("[NaN]".utf8)] {
|
||||||
|
try await reject("malformed top-level JSON") { _ = try SponsorBlockResolver.decode(malformed, videoID: id) }
|
||||||
|
}
|
||||||
|
let overflow = Data("[{\"videoID\":\"\(id)\",\"segments\":[{\"category\":\"sponsor\",\"actionType\":\"skip\",\"segment\":[0,1e999],\"videoDuration\":100}]}]".utf8)
|
||||||
|
let overflowRows = try SponsorBlockResolver.decode(overflow, videoID: id)
|
||||||
|
try require(overflowRows.isEmpty, "nonfinite row numbers are rejected without poisoning the bucket")
|
||||||
|
|
||||||
|
func segment(_ start: Double, _ end: Double, _ reference: Double = 100) -> SponsorSegment {
|
||||||
|
SponsorSegment(start: start, end: end, videoDuration: reference)
|
||||||
|
}
|
||||||
|
let ranges = SponsorSegment.visibleRanges([segment(30, 40), segment(10.25, 20.75), segment(20.75, 31), segment(12, 13)], duration: 100)
|
||||||
|
try require(ranges == [10.25...40], "sort, union overlapping and touching ranges")
|
||||||
|
for duration in [0, -1, Double.nan, .infinity] {
|
||||||
|
try require(SponsorSegment.visibleRanges(segments, duration: duration).isEmpty, "unknown or invalid actual duration hides paint")
|
||||||
|
}
|
||||||
|
for reference in [99.0, 101.0, -1, Double.nan, .infinity] {
|
||||||
|
try require(SponsorSegment.visibleRanges([segment(10, 20, reference)], duration: 100).isEmpty, "strict stale boundary and invalid reference duration")
|
||||||
|
}
|
||||||
|
for reference in [99.001, 100.999, 0] {
|
||||||
|
try require(SponsorSegment.visibleRanges([segment(10, 20, reference)], duration: 100) == [10...20], "under-one-second mismatch or unknown reference accepted")
|
||||||
|
}
|
||||||
|
let invalid = [segment(-1, 2), segment(2, 2), segment(3, 2), segment(.nan, 3), segment(0, .infinity), segment(90, 101)]
|
||||||
|
try require(SponsorSegment.visibleRanges(invalid, duration: 100).isEmpty, "never clip, rescale, or retain invalid timestamps")
|
||||||
|
let changing = [segment(90, 100, 0)]
|
||||||
|
try require(SponsorSegment.visibleRanges(changing, duration: 100) == [90...100] && SponsorSegment.visibleRanges(changing, duration: 99).isEmpty && SponsorSegment.visibleRanges(changing, duration: 110) == [90...100], "changing duration recomputes visibility from untouched ranges")
|
||||||
|
|
||||||
|
let fetched = try await resolve(.init(body: body))
|
||||||
|
try require(fetched.count == 2, "native async streamed transport decodes fixture")
|
||||||
|
let captured = SponsorBlockProtocol.state.captured
|
||||||
|
try require(captured.count == 1 && captured[0].url == request.url && captured[0].httpMethod == "GET" && captured[0].value(forHTTPHeaderField: "Authorization") == nil && captured[0].value(forHTTPHeaderField: "Cookie") == nil, "one fixed-host request without injected credentials")
|
||||||
|
let notFound = try await resolve(.init(status: 404, body: Data("not JSON".utf8)))
|
||||||
|
try require(notFound.isEmpty, "404 is normal absence")
|
||||||
|
for status in [401, 429, 500, 302] {
|
||||||
|
try await reject("HTTP \(status)") {
|
||||||
|
_ = try await resolve(.init(status: status, headers: ["Location": "https://example.org/secret"]))
|
||||||
|
}
|
||||||
|
try require(SponsorBlockProtocol.state.captured.count == 1, "no retries or follow-up requests")
|
||||||
|
}
|
||||||
|
try await reject("malformed network payload") { _ = try await resolve(.init(body: Data("oops".utf8))) }
|
||||||
|
try await reject("oversize advertised payload") { _ = try await resolve(.init(headers: ["Content-Length": "2097153"])) }
|
||||||
|
try await reject("oversize streamed payload without length") {
|
||||||
|
_ = try await resolve(.init(body: Data(repeating: 32, count: SponsorBlockResolver.responseLimit + 1)))
|
||||||
|
}
|
||||||
|
let cancelled = Task {
|
||||||
|
withUnsafeCurrentTask { $0?.cancel() }
|
||||||
|
return try await SponsorBlockResolver.resolve(videoID: id, configuration: configuration())
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
_ = try await cancelled.value
|
||||||
|
throw Failure(message: "Cancelled lookup succeeded")
|
||||||
|
} catch is CancellationError {} catch let failure as Failure { throw failure }
|
||||||
|
print("SponsorBlock checks passed")
|
||||||
|
}
|
||||||
|
|
||||||
|
static func main() async {
|
||||||
|
do {
|
||||||
|
if Array(CommandLine.arguments.dropFirst()) == ["--live"] {
|
||||||
|
for videoID in [id, "aqz-KE-bpKQ"] {
|
||||||
|
let segments = try await SponsorBlockResolver.resolve(videoID: videoID)
|
||||||
|
let intervals = segments.map { ["start": $0.start, "end": $0.end, "videoDuration": $0.videoDuration] }
|
||||||
|
let data = try wire(["videoID": videoID, "intervals": intervals])
|
||||||
|
print(String(decoding: data, as: UTF8.self))
|
||||||
|
}
|
||||||
|
} else if CommandLine.arguments.count == 1 {
|
||||||
|
try await checks()
|
||||||
|
} else {
|
||||||
|
throw Failure(message: "Usage: sponsorblock-check [--live]")
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
let message = (error as? Failure)?.message ?? error.localizedDescription
|
||||||
|
FileHandle.standardError.write(Data("FAIL: \(message)\n".utf8))
|
||||||
|
exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -169,6 +169,7 @@ struct SystemPlaybackChecks {
|
|||||||
styleMask: [.titled, .resizable], backing: .buffered, defer: false)
|
styleMask: [.titled, .resizable], backing: .buffered, defer: false)
|
||||||
window.isReleasedWhenClosed = false
|
window.isReleasedWhenClosed = false
|
||||||
defer { window.close() }
|
defer { window.close() }
|
||||||
|
model.attachWindow(window)
|
||||||
func event(_ code: UInt16, _ text: String, _ flags: NSEvent.ModifierFlags = []) -> NSEvent {
|
func event(_ code: UInt16, _ text: String, _ flags: NSEvent.ModifierFlags = []) -> NSEvent {
|
||||||
NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: flags, timestamp: 0,
|
NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: flags, timestamp: 0,
|
||||||
windowNumber: window.windowNumber, context: nil, characters: text,
|
windowNumber: window.windowNumber, context: nil, characters: text,
|
||||||
@@ -252,6 +253,33 @@ struct SystemPlaybackChecks {
|
|||||||
let modal = NSApp.beginModalSession(for: window)
|
let modal = NSApp.beginModalSession(for: window)
|
||||||
require(!model.handleKeyEvent(shortcuts[0], in: window), "modal session blocks player shortcuts")
|
require(!model.handleKeyEvent(shortcuts[0], in: window), "modal session blocks player shortcuts")
|
||||||
NSApp.endModalSession(modal)
|
NSApp.endModalSession(modal)
|
||||||
|
window.orderOut(nil)
|
||||||
|
model.setVolume(0)
|
||||||
|
model.open(URL(fileURLWithPath: CommandLine.arguments[1]).appendingPathComponent("cache.mp4"))
|
||||||
|
until("keyboard consumer has actual video playback") { model.canTogglePlayback && model.state.has_video }
|
||||||
|
model.setPlayback(playing: false)
|
||||||
|
until("keyboard consumer playback pauses") { model.state.status == PLAYER_PAUSED }
|
||||||
|
require(model.perform(.enterCompact), "video playback accepts compact entry")
|
||||||
|
until("keyboard consumer compact entry settles") { !model.compactWindow.isTransitioning }
|
||||||
|
guard let panel = model.compactWindow.presentationWindow as? NSPanel else {
|
||||||
|
fatalError("FAIL: keyboard consumer requires the compact presentation panel")
|
||||||
|
}
|
||||||
|
model.setShowsBufferInfo(false)
|
||||||
|
require(!model.handleKeyEvent(shortcuts[4], in: window) && !model.showsBufferInfo,
|
||||||
|
"hidden normal window cannot dispatch compact playback shortcuts")
|
||||||
|
let compactShortcut = NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: .command,
|
||||||
|
timestamp: 0, windowNumber: panel.windowNumber, context: nil, characters: "b",
|
||||||
|
charactersIgnoringModifiers: "b", isARepeat: false, keyCode: 11)!
|
||||||
|
require(model.handleKeyEvent(compactShortcut, in: panel) && model.showsBufferInfo,
|
||||||
|
"active compact panel dispatches the configured playback shortcut")
|
||||||
|
require(!window.isVisible && !window.isKeyWindow && !panel.isVisible && !panel.isKeyWindow,
|
||||||
|
"keyboard consumer checks never display or key either presentation")
|
||||||
|
model.compactWindow.restore(resumeFullScreen: false, animated: false)
|
||||||
|
model.setShowsBufferInfo(false)
|
||||||
|
focus(lists[0])
|
||||||
|
require(model.handleKeyEvent(shortcuts[4], in: window) && model.showsBufferInfo
|
||||||
|
&& model.compactWindow.presentationWindow === window,
|
||||||
|
"restored normal window resumes dispatching playback shortcuts")
|
||||||
}
|
}
|
||||||
|
|
||||||
static func main() {
|
static func main() {
|
||||||
@@ -296,7 +324,12 @@ struct SystemPlaybackChecks {
|
|||||||
backing: .buffered, defer: false)
|
backing: .buffered, defer: false)
|
||||||
window.isReleasedWhenClosed = false
|
window.isReleasedWhenClosed = false
|
||||||
defer { window.close() }
|
defer { window.close() }
|
||||||
|
let surface = PlaybackSurface.DragView(model: model)
|
||||||
|
surface.frame = window.contentView!.bounds
|
||||||
|
window.contentView!.addSubview(surface)
|
||||||
model.attachWindow(window)
|
model.attachWindow(window)
|
||||||
|
model.open(directory.appendingPathComponent("cache.mp4"))
|
||||||
|
until("queue geometry fixture has actual video playback") { model.canTogglePlayback && model.state.has_video }
|
||||||
let videoFrame = window.frame
|
let videoFrame = window.frame
|
||||||
model.showsQueue = true
|
model.showsQueue = true
|
||||||
until("queue opening settles") { !model.isQueueTransitioning }
|
until("queue opening settles") { !model.isQueueTransitioning }
|
||||||
@@ -308,16 +341,30 @@ struct SystemPlaybackChecks {
|
|||||||
require(abs(window.frame.width - videoFrame.width) < 0.01 &&
|
require(abs(window.frame.width - videoFrame.width) < 0.01 &&
|
||||||
abs(window.frame.height - videoFrame.height) < 0.01,
|
abs(window.frame.height - videoFrame.height) < 0.01,
|
||||||
"closing queue restores video-only window size")
|
"closing queue restores video-only window size")
|
||||||
|
guard let renderer = model.videoView else { fatalError("FAIL: native renderer is unavailable") }
|
||||||
|
let content = window.contentView
|
||||||
|
let activationPolicy = NSApp.activationPolicy()
|
||||||
model.open(directory.appendingPathComponent("anamorphic.mp4"))
|
model.open(directory.appendingPathComponent("anamorphic.mp4"))
|
||||||
require(model.isBusy && model.perform(.enterCompact) && model.isCompact,
|
require(model.isBusy && model.perform(.enterCompact) && model.isCompact,
|
||||||
"compact entry is available before native media opening acknowledges")
|
"compact entry is available before native media opening acknowledges")
|
||||||
until("decoded anamorphic metadata replaces provisional compact geometry") {
|
until("decoded anamorphic metadata replaces provisional compact geometry") {
|
||||||
model.state.status == PLAYER_PLAYING && model.isCompact && !model.compactWindow.isTransitioning &&
|
guard let presentation = model.compactWindow.presentationWindow else { return false }
|
||||||
abs(window.frame.width / window.frame.height - 8.0 / 3.0) < 0.01
|
return model.state.status == PLAYER_PLAYING && model.isCompact && !model.compactWindow.isTransitioning &&
|
||||||
|
abs(presentation.frame.width / presentation.frame.height - 8.0 / 3.0) < 0.01
|
||||||
}
|
}
|
||||||
|
let panel = model.compactWindow.presentationWindow!
|
||||||
|
require(panel !== window && panel is NSPanel && panel.styleMask.contains(.nonactivatingPanel)
|
||||||
|
&& panel.contentView === content && model.videoView === renderer && renderer.window === panel
|
||||||
|
&& !window.isVisible && !window.isKeyWindow && !panel.isVisible && !panel.isKeyWindow
|
||||||
|
&& NSApp.activationPolicy() == activationPolicy,
|
||||||
|
"loading metadata resizes the compact panel without replacing renderer or app policy")
|
||||||
model.closeFile()
|
model.closeFile()
|
||||||
require(!model.isCompact && !model.compactWindow.isTransitioning,
|
require(!model.isCompact && !model.compactWindow.isTransitioning,
|
||||||
"closing loading/loaded media restores immediately")
|
"closing loading/loaded media restores immediately")
|
||||||
|
require(model.compactWindow.presentationWindow === window && window.contentView === content
|
||||||
|
&& model.videoView === renderer && renderer.window === window && !window.isVisible && !window.isKeyWindow
|
||||||
|
&& panel.contentView !== content && !panel.isVisible && !panel.isKeyWindow,
|
||||||
|
"urgent media close returns retained content to the hidden normal window and retires the panel")
|
||||||
until("anamorphic source closes before the ordinary playback checks") { model.state.status == PLAYER_EMPTY }
|
until("anamorphic source closes before the ordinary playback checks") { model.state.status == PLAYER_EMPTY }
|
||||||
require(!model.canPerform(.enterCompact), "empty playback does not enable compact entry")
|
require(!model.canPerform(.enterCompact), "empty playback does not enable compact entry")
|
||||||
|
|
||||||
|
|||||||
@@ -167,6 +167,185 @@ struct YouTubeResolverChecks {
|
|||||||
try require(try decode(emptyHeaders).video.headers.isEmpty, "absent headers serialize to an empty block")
|
try require(try decode(emptyHeaders).video.headers.isEmpty, "absent headers serialize to an empty block")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func subtitles() throws {
|
||||||
|
func vtt(_ url: String, name: Any = "English", headers: Any? = nil) -> [String: Any] {
|
||||||
|
var row: [String: Any] = ["ext": "vtt", "url": url, "name": name]
|
||||||
|
if let headers { row["http_headers"] = headers }
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
let original = "https://www.youtube.com/api/timedtext?lang=en&kind=asr&fmt=vtt"
|
||||||
|
var object = try fixture(combinedJSON)
|
||||||
|
try require(try decode(object).subtitles.isEmpty, "absent captions default to Off with no optional tracks")
|
||||||
|
for payload in [NSNull(), [], true, 17, "captions"] as [Any] {
|
||||||
|
object["subtitles"] = payload
|
||||||
|
object["automatic_captions"] = payload
|
||||||
|
try require(try decode(object).subtitles.isEmpty,
|
||||||
|
"malformed optional caption containers never block playback")
|
||||||
|
}
|
||||||
|
object["subtitles"] = [
|
||||||
|
"broken": true,
|
||||||
|
"en": [NSNull(), 7, ["ext": false], ["ext": "vtt", "url": 9],
|
||||||
|
["ext": "srt", "url": "https://subs.example/convert-me"],
|
||||||
|
vtt("file:///unsafe.vtt"),
|
||||||
|
vtt("https://subs.example/unsafe.vtt", headers: ["X-Unsafe": "bad\nvalue"]),
|
||||||
|
vtt("https://subs.example/bad-headers.vtt", headers: ["X-Bad": 3]),
|
||||||
|
vtt("https://subs.example/en.vtt?token=first", headers: ["X-Track": "manual"]),
|
||||||
|
vtt("https://subs.example/duplicate.vtt")],
|
||||||
|
"fr": [vtt("https://subs.example/fr.vtt", name: ["malformed"])],
|
||||||
|
"srt-only": [["ext": "srt", "url": "https://subs.example/only.srt"]]
|
||||||
|
] as [String: Any]
|
||||||
|
object["automatic_captions"] = [
|
||||||
|
"en-orig": [vtt(original, headers: ["X-Track": "auto"])],
|
||||||
|
"en": [vtt(original)],
|
||||||
|
"fr": [vtt(original + "&tlang=fr", name: "French")],
|
||||||
|
"de": [vtt("https://subs.example/de.vtt", name: "German (Original)")],
|
||||||
|
"bad-orig": [NSNull(), vtt("ftp://subs.example/bad.vtt")]
|
||||||
|
] as [String: Any]
|
||||||
|
let tracks = try decode(object).subtitles
|
||||||
|
try require(tracks.count == 3 && tracks.map(\.language) == ["en", "fr", "en"],
|
||||||
|
"manual VTT and one original auto survive malformed siblings; translations and aliases do not")
|
||||||
|
try require(tracks.map(\.selectionID) == ["youtube:manual:en", "youtube:manual:fr", "youtube:auto:en"],
|
||||||
|
"manual and auto identities depend on language metadata, not signed URLs or labels")
|
||||||
|
try require(tracks[0].url == "https://subs.example/en.vtt?token=first"
|
||||||
|
&& tracks[0].label == "English" && tracks[1].label == "fr"
|
||||||
|
&& tracks[2].label == "English (auto-generated)",
|
||||||
|
"one valid supplied VTT per language preserves name and query; malformed name falls back to language")
|
||||||
|
try require(tracks.allSatisfy { !$0.isDefault } && ResolvedSource.validSubtitles(tracks),
|
||||||
|
"optional captions preserve transport safeguards and never enable themselves")
|
||||||
|
try require(tracks[0].headers == "X-Track: manual\r\n"
|
||||||
|
&& tracks[1].headers == "User-Agent: combined-agent\r\n"
|
||||||
|
&& tracks[2].headers == "X-Track: auto\r\n",
|
||||||
|
"variant headers override the complete root map; absent variant headers inherit root")
|
||||||
|
var changed = object
|
||||||
|
changed["automatic_captions"] = ["en": [vtt(original + "&token=renewed", name: "Renamed")]]
|
||||||
|
let alias = try decode(changed).subtitles.last!
|
||||||
|
try require(alias.selectionID == tracks.last?.selectionID && alias.url != tracks.last?.url,
|
||||||
|
"explicit untranslated ASR alias retains original identity after signed URL and label refresh")
|
||||||
|
changed["automatic_captions"] = [
|
||||||
|
"en-orig": [vtt(original)],
|
||||||
|
"en": [vtt(original + "&token=alias")],
|
||||||
|
"other-orig": [vtt(original)]
|
||||||
|
]
|
||||||
|
try require(try decode(changed).subtitles.filter { $0.selectionID?.hasPrefix("youtube:auto:") == true }.count == 1,
|
||||||
|
"original auto aliases deduplicate by language and by URL")
|
||||||
|
changed["automatic_captions"] = ["en-orig": [vtt(original)]]
|
||||||
|
changed["subtitles"] = ["en": [vtt("https://subs.example/empty.vtt", headers: [:])]]
|
||||||
|
try require(try decode(changed).subtitles.first?.headers == "",
|
||||||
|
"an explicitly empty caption header map does not inherit root headers")
|
||||||
|
|
||||||
|
for unsafe in ["https://user:pass@subs.example/c.vtt", "https://subs.example/c.vtt#fragment",
|
||||||
|
"https://subs.example:0/c.vtt", "https://subs.example/c vtt"] {
|
||||||
|
changed["subtitles"] = ["en": [vtt(unsafe)]]
|
||||||
|
try require(try decode(changed).subtitles.count == 1,
|
||||||
|
"unsafe optional subtitle URL is skipped without discarding a good auto sibling")
|
||||||
|
}
|
||||||
|
var fragmented = vtt("https://subs.example/chunks")
|
||||||
|
fragmented["protocol"] = "m3u8_native"
|
||||||
|
changed["subtitles"] = ["en": [fragmented]]
|
||||||
|
try require(try decode(changed).subtitles.count == 1,
|
||||||
|
"caption playlists are not treated as immutable VTT documents")
|
||||||
|
|
||||||
|
var many: [String: Any] = [:]
|
||||||
|
for index in 0..<40 {
|
||||||
|
let key = String(format: "lang-%02d", index)
|
||||||
|
many[key] = [vtt("https://subs.example/\(key).vtt", name: key)]
|
||||||
|
}
|
||||||
|
changed["subtitles"] = many
|
||||||
|
let capped = try decode(changed).subtitles
|
||||||
|
try require(capped.count == 32 && capped.last?.selectionID == "youtube:auto:en"
|
||||||
|
&& capped.first?.language == "lang-00" && capped[30].language == "lang-30",
|
||||||
|
"deterministic 32-track cap reserves original auto instead of crowding it out with manual languages")
|
||||||
|
changed["automatic_captions"] = [:]
|
||||||
|
try require(try decode(changed).subtitles.count == 32,
|
||||||
|
"manual-only metadata uses the entire track allowance")
|
||||||
|
changed["automatic_captions"] = ["en-orig": [vtt(original + "&tlang=fr")]]
|
||||||
|
let translated = try decode(changed).subtitles
|
||||||
|
try require(translated.count == 32
|
||||||
|
&& translated.allSatisfy { $0.selectionID?.hasPrefix("youtube:manual:") == true },
|
||||||
|
"translation query metadata is excluded even under an original-looking key")
|
||||||
|
for (key, value) in [("is_live", true), ("live_status", "is_live")] as [(String, Any)] {
|
||||||
|
var broadcast = object
|
||||||
|
broadcast[key] = value
|
||||||
|
try require(try decode(broadcast).subtitles.isEmpty,
|
||||||
|
"live captions are disabled instead of freezing a partial live transcript")
|
||||||
|
}
|
||||||
|
changed["url"] = "file:///unsafe-video"
|
||||||
|
try reject("optional caption tolerance does not weaken video validation") { _ = try decode(changed) }
|
||||||
|
}
|
||||||
|
|
||||||
|
static func chapters() throws {
|
||||||
|
var object = try fixture(combinedJSON)
|
||||||
|
try require(try decode(object).chapters.isEmpty, "absent chapters remain playable")
|
||||||
|
for payload in [NSNull(), [], true, 17, "chapters", [:],
|
||||||
|
[NSNull(), 17, "row", [:], ["start_time": 0],
|
||||||
|
["start_time": "0", "end_time": 2], ["start_time": 0, "end_time": false]]] as [Any] {
|
||||||
|
object["chapters"] = payload
|
||||||
|
let video = try decode(object)
|
||||||
|
try require(video.chapters.isEmpty && video.video.url == "https://media.example/combined",
|
||||||
|
"null, empty and malformed optional chapters never prevent playback")
|
||||||
|
}
|
||||||
|
object["chapters"] = [
|
||||||
|
NSNull(),
|
||||||
|
["start_time": 0, "end_time": 2.25, "title": "Opening"],
|
||||||
|
["start_time": 2.25, "end_time": 8.75],
|
||||||
|
["start_time": 8.75, "end_time": 12, "title": ["malformed"]],
|
||||||
|
["start_time": 2.25, "end_time": 8.75, "title": NSNull()]
|
||||||
|
] as [Any]
|
||||||
|
let decoded = try decode(object)
|
||||||
|
try require(decoded.chapters.count == 4 && decoded.chapters[0].title == "Opening"
|
||||||
|
&& decoded.chapters[0].end == 2.25 && decoded.chapters[1].start == 2.25,
|
||||||
|
"fractional chapters and titles survive malformed sibling rows")
|
||||||
|
try require(decoded.chapters.dropFirst().allSatisfy { $0.title == nil },
|
||||||
|
"missing, null and malformed optional titles do not discard chapters")
|
||||||
|
try require(decoded.chapterBoundaries(duration: 12) == [2.25, 8.75],
|
||||||
|
"chapter edges are sorted, deduplicated and strictly interior")
|
||||||
|
try require(decoded.chapterTitle(at: 0, duration: 12) == "Opening" &&
|
||||||
|
decoded.chapterTitle(at: 2.249, duration: 12) == "Opening" &&
|
||||||
|
decoded.chapterTitle(at: 2.25, duration: 12) == nil &&
|
||||||
|
decoded.chapterTitle(at: 12, duration: 12) == nil,
|
||||||
|
"hover titles include chapter start, exclude its end and never invent missing titles")
|
||||||
|
for duration in [0, -1, 1, Double.nan, .infinity] {
|
||||||
|
try require(decoded.chapterTitle(at: 0.5, duration: duration) == nil,
|
||||||
|
"unknown durations and chapters extending beyond media have no hover title")
|
||||||
|
}
|
||||||
|
|
||||||
|
object["duration"] = 0
|
||||||
|
object["chapters"] = [["start_time": 0, "end_time": 2.25],
|
||||||
|
["start_time": 2.25, "end_time": 20.5]]
|
||||||
|
let unknownDuration = try decode(object)
|
||||||
|
try require(unknownDuration.chapterBoundaries(duration: 20.5) == [2.25],
|
||||||
|
"actual playback duration, not extractor duration, validates chapters")
|
||||||
|
for duration in [0, -1, Double.nan, .infinity, -.infinity] {
|
||||||
|
try require(unknownDuration.chapterBoundaries(duration: duration).isEmpty,
|
||||||
|
"unknown and invalid playback durations never produce boundaries")
|
||||||
|
}
|
||||||
|
|
||||||
|
let video = YouTubeVideo(title: "Chapters",
|
||||||
|
video: .init(url: "https://media.example/combined", headers: ""), audio: nil,
|
||||||
|
duration: 0, isLive: false, webpageURL: page, height: nil, availableHeights: [],
|
||||||
|
chapters: [
|
||||||
|
.init(start: 8.75, end: 12, title: nil),
|
||||||
|
.init(start: 0, end: 2.25, title: nil),
|
||||||
|
.init(start: 2.25, end: 8.75, title: nil),
|
||||||
|
.init(start: 2.25, end: 8.75, title: nil),
|
||||||
|
.init(start: -1, end: 4, title: nil),
|
||||||
|
.init(start: 3, end: 13, title: nil),
|
||||||
|
.init(start: 6, end: 6, title: nil),
|
||||||
|
.init(start: 7, end: 5, title: nil),
|
||||||
|
.init(start: .nan, end: 4, title: nil),
|
||||||
|
.init(start: .infinity, end: 4, title: nil),
|
||||||
|
.init(start: -.infinity, end: 4, title: nil),
|
||||||
|
.init(start: 3, end: .nan, title: nil),
|
||||||
|
.init(start: 3, end: .infinity, title: nil),
|
||||||
|
.init(start: 3, end: -.infinity, title: nil)
|
||||||
|
])
|
||||||
|
try require(video.chapterBoundaries(duration: 12) == [2.25, 8.75],
|
||||||
|
"invalid, zero-length, reversed and over-range rows contribute neither edge")
|
||||||
|
object["chapters"] = "malformed optional data"
|
||||||
|
object["url"] = 17
|
||||||
|
try reject("chapter tolerance must not weaken required stream validation") { _ = try decode(object) }
|
||||||
|
}
|
||||||
|
|
||||||
static func qualities() throws {
|
static func qualities() throws {
|
||||||
func video(_ height: Int, audio: Bool = false, range: String = "SDR", transport: String = "https") -> [String: Any] {
|
func video(_ height: Int, audio: Bool = false, range: String = "SDR", transport: String = "https") -> [String: Any] {
|
||||||
["height": height, "vcodec": "h264", "acodec": audio ? "aac" : "none",
|
["height": height, "vcodec": "h264", "acodec": audio ? "aac" : "none",
|
||||||
@@ -609,6 +788,8 @@ struct YouTubeResolverChecks {
|
|||||||
try FileManager.default.createSymbolicLink(at: runtime, withDestinationURL: URL(fileURLWithPath: "/usr/bin/true"))
|
try FileManager.default.createSymbolicLink(at: runtime, withDestinationURL: URL(fileURLWithPath: "/usr/bin/true"))
|
||||||
try videoURLs()
|
try videoURLs()
|
||||||
try metadata()
|
try metadata()
|
||||||
|
try chapters()
|
||||||
|
try subtitles()
|
||||||
try malformedMetadata()
|
try malformedMetadata()
|
||||||
try qualities()
|
try qualities()
|
||||||
try bundledHelpers(root: root)
|
try bundledHelpers(root: root)
|
||||||
|
|||||||
@@ -0,0 +1,265 @@
|
|||||||
|
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 {
|
||||||
|
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>"
|
||||||
|
}
|
||||||
|
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 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
|
||||||
|
if request.url?.absoluteString == "https://graphql.anilist.co/" {
|
||||||
|
let body = try JSONSerialization.jsonObject(with: request.httpBody!) as! [String: Any]
|
||||||
|
try require((body["variables"] as? [String: Int])?["id"] == 97940, "mapping uses the original AniList ID")
|
||||||
|
data = Data(#"{"data":{"Media":{"id":97940,"idMal":34572,"title":{"english":"Black Clover"}}}}"#.utf8)
|
||||||
|
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": [],
|
||||||
|
"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}}}"#,
|
||||||
|
#"{"data":{"Media":{"id":97940,"idMal":-1}}}"#,
|
||||||
|
#"{"data":{"Media":{"id":97940,"idMal":2147483648}}}"#,
|
||||||
|
#"{"data":{"Media":{"id":97940,"idMal":true}}}"#,
|
||||||
|
#"{"data":{"Media":{"id":97940,"idMal":34572}},"errors":[{"message":"partial failure"}]}"#
|
||||||
|
] {
|
||||||
|
try expect(.malformed) { _ = try ZokoResolver.decodeCatalog(Data(bad.utf8), mediaID: "97940") }
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
_ = try await ZokoResolver.resolve(mediaID: "97940", episode: 1, enrichTitle: false, fetchResponse: { request in
|
||||||
|
try require(request.url?.host == "graphql.anilist.co", "missing mapping must not guess a provider ID")
|
||||||
|
return (Data(#"{"data":{"Media":{"id":97940,"idMal":null}}}"#.utf8),
|
||||||
|
HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: ["Content-Type": "application/json"])!)
|
||||||
|
})
|
||||||
|
throw Failure(message: "missing mapping accepted")
|
||||||
|
} catch ZokoError.mapping {}
|
||||||
|
try require(source.identity == "anilist:1:1" && source.requirements.zoko, "native scoped transport selected")
|
||||||
|
try require(source.title == "AniList 1 · Episode 1 (sub)" && source.subtitles.count == 1 && source.subtitles[0].isDefault,
|
||||||
|
"honest fallback title and VTT metadata")
|
||||||
|
try require(source.headers == ZokoResolver.referer && source.subtitles[0].headers == ZokoResolver.referer, "only fixed public Referer scoped to declared CDN")
|
||||||
|
let semicolonless = try page(config).replacingOccurrences(of: "';</script>", with: "'</script>")
|
||||||
|
let deployedSource = try ZokoResolver.parse(page: semicolonless, mediaID: "1", episode: 1)
|
||||||
|
try require(deployedSource.identity == source.identity, "deployed semicolonless static assignment is accepted")
|
||||||
|
let missing = "<span>// Error</span><span>404</span><h1>Not <span>found.</span></h1><p>We couldn't find anything to play here.</p>"
|
||||||
|
try expect(.unavailable) { _ = try ZokoResolver.parse(page: missing, mediaID: "1", episode: 27) }
|
||||||
|
for protected in ["<title>Just a moment</title>", "<script src='/cdn-cgi/challenge-platform/x'></script>",
|
||||||
|
"<h1>Sorry, you have been blocked</h1>", missing + "<div>captcha</div>"] {
|
||||||
|
try expect(.protection) { _ = try ZokoResolver.parse(page: protected, mediaID: "1", episode: 1) }
|
||||||
|
}
|
||||||
|
for malformed in ["<html></html>", "window.__P='broken';", try page(config) + page(config), String(repeating: "x", count: ZokoResolver.bodyLimit + 1)] {
|
||||||
|
try expect(.malformed) { _ = try ZokoResolver.parse(page: malformed, mediaID: "1", episode: 1) }
|
||||||
|
}
|
||||||
|
for bad in ["https://evil.example/master.m3u8", "https://hls1.drama1.cfd/master.m3u8", "http://hls.dramahot.top/master.m3u8", "https://hls.dramahot.top:8443/master.m3u8",
|
||||||
|
"https://hls.dramahot.top.evil.example/master.m3u8", "https://user@hls.dramahot.top/master.m3u8", "https://127.0.0.1/master.m3u8"] {
|
||||||
|
config["src"] = bad
|
||||||
|
try expect(.malformed) { _ = try ZokoResolver.parse(page: page(config), mediaID: "1", episode: 1) }
|
||||||
|
}
|
||||||
|
config["src"] = "https://hls.dramahot.top/master.m3u8"
|
||||||
|
let invalidSubtitles: [[[String: Any]]] = [
|
||||||
|
[["src": "https://evil.example/en.vtt", "lang": "en", "label": "English"]],
|
||||||
|
[["src": "https://hls.dramahot.top/en.vtt", "lang": "en", "label": "bad\nlabel"]],
|
||||||
|
Array(repeating: ["src": "https://hls.dramahot.top/en.vtt", "lang": "en", "label": "English", "default": true], count: 2),
|
||||||
|
Array(repeating: ["src": "https://hls.dramahot.top/en.vtt", "lang": "en", "label": "English"], count: 33)]
|
||||||
|
for subtitles in invalidSubtitles {
|
||||||
|
config["subtitles"] = subtitles
|
||||||
|
try expect(.malformed) { _ = try ZokoResolver.parse(page: page(config), mediaID: "1", episode: 1) }
|
||||||
|
}
|
||||||
|
try require(ZokoResolver.validResource(URL(string: "https://hls1.drama1.cfd/segment.jpg")!), "exact demonstrated segment origin admitted")
|
||||||
|
for bad in ["https://evil.example/segment.ts", "http://hls1.drama1.cfd/segment.ts", "https://hls1.drama1.cfd:8443/segment.ts", "https://hls1.drama1.cfd.evil.example/segment.ts", "https://127.0.0.1/segment.ts"] {
|
||||||
|
try require(!ZokoResolver.validResource(URL(string: bad)!), "hostile manifest resource rejected before request")
|
||||||
|
}
|
||||||
|
let suite = "terebi.zoko-check." + UUID().uuidString
|
||||||
|
let defaults = UserDefaults(suiteName: suite)!
|
||||||
|
defer { defaults.removePersistentDomain(forName: suite) }
|
||||||
|
var progress = PlaybackProgress(defaults: defaults)
|
||||||
|
let identity = URL(string: source.identity)!
|
||||||
|
progress.save(123, for: identity)
|
||||||
|
progress.save(456, for: URL(string: "miruro:1:1")!)
|
||||||
|
progress = PlaybackProgress(defaults: defaults)
|
||||||
|
try require(progress.position(for: identity) == 123 && progress.position(for: URL(string: "miruro:1:1")!) == 456,
|
||||||
|
"AniList resume persists separately from Miruro")
|
||||||
|
print("PASS: AniList URL/identity, static codec, 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
|
||||||
|
// 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(mediaID: String = "1", episode: Int = 1) async throws {
|
||||||
|
let source = try await MiruroTransport.prepare(ZokoResolver.resolve(mediaID: mediaID, episode: episode))
|
||||||
|
defer { _fixLifetime(source) }
|
||||||
|
try require(source.identity == "anilist:\(mediaID):\(episode)" && 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 == mediaID && identity?["episode"] as? Int == episode,
|
||||||
|
"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 \(mediaID) episode \(episode) mapped via MAL; actual native decode/frame step, 600s seek/decode, VTT text, restored position and AniList v2 progress (no GUI/audio playback)")
|
||||||
|
}
|
||||||
|
static func main() async {
|
||||||
|
do {
|
||||||
|
try await checks()
|
||||||
|
if CommandLine.arguments.contains("--live") { try await live() }
|
||||||
|
if CommandLine.arguments.contains("--live-black-clover") { try await live(mediaID: "97940") }
|
||||||
|
} catch {
|
||||||
|
FileHandle.standardError.write(Data("FAIL: Zoko check: \(error.localizedDescription)\n".utf8))
|
||||||
|
exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,9 +19,11 @@ RUNTIME_SOURCE := Sources/Launcher/FFmpegRuntime.swift
|
|||||||
CLI_SOURCES := Sources/CLI/TerebiCLI.swift Sources/CLI/PlayerCommand.swift Sources/App/YouTubeResolver.swift Sources/App/ResolvedSource.swift $(RUNTIME_SOURCE)
|
CLI_SOURCES := Sources/CLI/TerebiCLI.swift Sources/CLI/PlayerCommand.swift Sources/App/YouTubeResolver.swift Sources/App/ResolvedSource.swift $(RUNTIME_SOURCE)
|
||||||
SWIFT_SOURCES += Sources/App/StreamResolver.swift
|
SWIFT_SOURCES += Sources/App/StreamResolver.swift
|
||||||
SWIFT_SOURCES += Sources/App/MiruroMetadata.swift
|
SWIFT_SOURCES += Sources/App/MiruroMetadata.swift
|
||||||
SWIFT_SOURCES += Sources/App/MiruroTransport.swift Sources/App/MiruroEmbed.swift
|
SWIFT_SOURCES += Sources/App/MiruroTransport.swift Sources/App/MiruroEmbed.swift Sources/App/ZokoResolver.swift
|
||||||
CLI_SOURCES += Sources/App/StreamResolver.swift
|
CLI_SOURCES += Sources/App/StreamResolver.swift
|
||||||
|
CLI_SOURCES += Sources/CLI/SourceCheck.swift Sources/App/ZokoResolver.swift Sources/App/MiruroTransport.swift Sources/App/MiruroEmbed.swift
|
||||||
SWIFT_SOURCES += Sources/App/StreamQueue.swift
|
SWIFT_SOURCES += Sources/App/StreamQueue.swift
|
||||||
|
SWIFT_SOURCES += Sources/App/SponsorBlock.swift
|
||||||
CLI_SOURCES += Sources/App/StreamQueue.swift
|
CLI_SOURCES += Sources/App/StreamQueue.swift
|
||||||
CONFIG_BUILD := .build/configuration
|
CONFIG_BUILD := .build/configuration
|
||||||
CONFIG_BIN := $(shell xcrun swift build --scratch-path $(CONFIG_BUILD) -c release --show-bin-path)
|
CONFIG_BIN := $(shell xcrun swift build --scratch-path $(CONFIG_BUILD) -c release --show-bin-path)
|
||||||
@@ -98,11 +100,15 @@ $(APP)/Contents/Info.plist: Resources/Info.plist
|
|||||||
mkdir -p "$(APP)/Contents"
|
mkdir -p "$(APP)/Contents"
|
||||||
cp "$<" "$@"
|
cp "$<" "$@"
|
||||||
|
|
||||||
|
$(APP)/Contents/Resources/AppIcon.icns: Resources/AppIcon.icns
|
||||||
|
mkdir -p "$(APP)/Contents/Resources"
|
||||||
|
cp "$<" "$@"
|
||||||
|
|
||||||
$(CLI): $(CLI_SOURCES) $(CONFIG_LIBRARY) Makefile
|
$(CLI): $(CLI_SOURCES) $(CONFIG_LIBRARY) Makefile
|
||||||
mkdir -p "$(APP)/Contents/MacOS"
|
mkdir -p "$(APP)/Contents/MacOS"
|
||||||
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O $(DEBUG_FLAGS) $(CLI_SOURCES) $(CONFIG_FLAGS) -framework AppKit -o $@
|
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O $(DEBUG_FLAGS) $(CLI_SOURCES) $(CONFIG_FLAGS) -framework AppKit -o $@
|
||||||
|
|
||||||
$(BUNDLE_STAMP): $(PLAYER) $(BOOTSTRAP) $(CLI) $(APP)/Contents/Info.plist $(APP)/Contents/Resources/TOMLDecoder-LICENSE.txt $(NATIVE_STAMP) $(HELPER_STAMP) Scripts/bundle.py
|
$(BUNDLE_STAMP): $(PLAYER) $(BOOTSTRAP) $(CLI) $(APP)/Contents/Info.plist $(APP)/Contents/Resources/AppIcon.icns $(APP)/Contents/Resources/TOMLDecoder-LICENSE.txt $(NATIVE_STAMP) $(HELPER_STAMP) Scripts/bundle.py
|
||||||
python3 Scripts/bundle.py --app "$(APP)" --dependencies "$(DEPENDENCY_ROOT)" --signing-identity "$(SIGNING_IDENTITY)"
|
python3 Scripts/bundle.py --app "$(APP)" --dependencies "$(DEPENDENCY_ROOT)" --signing-identity "$(SIGNING_IDENTITY)"
|
||||||
touch "$@"
|
touch "$@"
|
||||||
|
|
||||||
@@ -161,6 +167,7 @@ check-local-seek: $(BUILD_DIR)/local-seek-check $(BUILD_DIR)/fixtures/local-seek
|
|||||||
$(BUILD_DIR)/local-seek-check $(BUILD_DIR)/fixtures/local-seek.mp4 $(DECODE_FIXTURES)
|
$(BUILD_DIR)/local-seek-check $(BUILD_DIR)/fixtures/local-seek.mp4 $(DECODE_FIXTURES)
|
||||||
|
|
||||||
check: check-stream
|
check: check-stream
|
||||||
|
check: check-sponsorblock
|
||||||
check: check-dependencies $(BUILD_DIR)/playback-check check-seek-queue check-local-seek check-video-decoding check-fixtures check-audio check-window check-config check-youtube check-quality check-adaptive check-network check-progress check-reporting check-cli check-runtime check-media check-startup check-bundle check-subtitles check-resolved check-cast check-queue
|
check: check-dependencies $(BUILD_DIR)/playback-check check-seek-queue check-local-seek check-video-decoding check-fixtures check-audio check-window check-config check-youtube check-quality check-adaptive check-network check-progress check-reporting check-cli check-runtime check-media check-startup check-bundle check-subtitles check-resolved check-cast check-queue
|
||||||
$(BUILD_DIR)/playback-check $(BUILD_DIR)/fixtures
|
$(BUILD_DIR)/playback-check $(BUILD_DIR)/fixtures
|
||||||
|
|
||||||
@@ -209,12 +216,19 @@ $(BUILD_DIR)/configuration-check: Checks/configuration.swift $(CONFIG_LIBRARY) M
|
|||||||
check-config: $(BUILD_DIR)/configuration-check
|
check-config: $(BUILD_DIR)/configuration-check
|
||||||
$(BUILD_DIR)/configuration-check
|
$(BUILD_DIR)/configuration-check
|
||||||
|
|
||||||
$(BUILD_DIR)/youtube-resolver-check: Checks/youtube-resolver.swift Sources/App/YouTubeResolver.swift Makefile | $(BUILD_DIR) check-dependencies
|
$(BUILD_DIR)/youtube-resolver-check: Checks/youtube-resolver.swift Sources/App/YouTubeResolver.swift Sources/App/ResolvedSource.swift Sources/App/StreamResolver.swift Makefile | $(BUILD_DIR) check-dependencies
|
||||||
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O $(DEBUG_FLAGS) Checks/youtube-resolver.swift Sources/App/YouTubeResolver.swift -framework Foundation -o $@
|
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O $(DEBUG_FLAGS) Checks/youtube-resolver.swift Sources/App/YouTubeResolver.swift Sources/App/ResolvedSource.swift Sources/App/StreamResolver.swift -framework Foundation -o $@
|
||||||
|
|
||||||
check-youtube: $(BUILD_DIR)/youtube-resolver-check $(BUNDLE_STAMP)
|
check-youtube: $(BUILD_DIR)/youtube-resolver-check $(BUNDLE_STAMP)
|
||||||
$(BUILD_DIR)/youtube-resolver-check "$(abspath $(APP))"
|
$(BUILD_DIR)/youtube-resolver-check "$(abspath $(APP))"
|
||||||
|
|
||||||
|
$(BUILD_DIR)/sponsorblock-check: Checks/sponsor-block.swift Sources/App/SponsorBlock.swift Makefile | $(BUILD_DIR)
|
||||||
|
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O $(DEBUG_FLAGS) $< Sources/App/SponsorBlock.swift -framework Foundation -o $@
|
||||||
|
|
||||||
|
.PHONY: check-sponsorblock
|
||||||
|
check-sponsorblock: $(BUILD_DIR)/sponsorblock-check
|
||||||
|
$(BUILD_DIR)/sponsorblock-check
|
||||||
|
|
||||||
$(BUILD_DIR)/stream-resolver-check: Checks/stream-resolver.swift Sources/App/StreamResolver.swift Sources/App/YouTubeResolver.swift Sources/App/ResolvedSource.swift Makefile | $(BUILD_DIR)
|
$(BUILD_DIR)/stream-resolver-check: Checks/stream-resolver.swift Sources/App/StreamResolver.swift Sources/App/YouTubeResolver.swift Sources/App/ResolvedSource.swift Makefile | $(BUILD_DIR)
|
||||||
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O $(DEBUG_FLAGS) Checks/stream-resolver.swift Sources/App/StreamResolver.swift Sources/App/YouTubeResolver.swift Sources/App/ResolvedSource.swift -framework Foundation -o $@
|
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O $(DEBUG_FLAGS) Checks/stream-resolver.swift Sources/App/StreamResolver.swift Sources/App/YouTubeResolver.swift Sources/App/ResolvedSource.swift -framework Foundation -o $@
|
||||||
|
|
||||||
@@ -286,6 +300,19 @@ $(BUILD_DIR)/player-command-check: Checks/player-command.swift Sources/CLI/Playe
|
|||||||
check-cli: $(BUILD_DIR)/player-command-check
|
check-cli: $(BUILD_DIR)/player-command-check
|
||||||
$(BUILD_DIR)/player-command-check
|
$(BUILD_DIR)/player-command-check
|
||||||
|
|
||||||
|
# Source checking shares only Swift resolver/transport code, never native player objects.
|
||||||
|
SOURCE_CHECK_SOURCES := $(filter-out Sources/CLI/TerebiCLI.swift $(RUNTIME_SOURCE),$(CLI_SOURCES))
|
||||||
|
$(BUILD_DIR)/source-check-check: Checks/source-check.swift $(SOURCE_CHECK_SOURCES) Makefile | $(BUILD_DIR)
|
||||||
|
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O $(DEBUG_FLAGS) $< $(SOURCE_CHECK_SOURCES) -framework Foundation -o $@
|
||||||
|
|
||||||
|
.PHONY: check-source check-source-live
|
||||||
|
check: check-source
|
||||||
|
check-source: $(BUILD_DIR)/source-check-check $(CLI)
|
||||||
|
$(BUILD_DIR)/source-check-check
|
||||||
|
python3 Checks/source-check.py "$(CLI)"
|
||||||
|
check-source-live: $(CLI)
|
||||||
|
python3 Checks/source-check.py "$(CLI)" --live
|
||||||
|
|
||||||
$(BUILD_DIR)/external-subtitles-check: Checks/external-subtitles.swift Sources/App/ExternalSubtitles.swift Makefile | $(BUILD_DIR)
|
$(BUILD_DIR)/external-subtitles-check: Checks/external-subtitles.swift Sources/App/ExternalSubtitles.swift Makefile | $(BUILD_DIR)
|
||||||
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O $(DEBUG_FLAGS) Checks/external-subtitles.swift Sources/App/ExternalSubtitles.swift -framework Foundation -o $@
|
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O $(DEBUG_FLAGS) Checks/external-subtitles.swift Sources/App/ExternalSubtitles.swift -framework Foundation -o $@
|
||||||
|
|
||||||
@@ -298,6 +325,10 @@ $(BUILD_DIR)/resolved-playback-check: Checks/resolved-playback.swift $(SWIFT_SOU
|
|||||||
check-resolved: $(BUILD_DIR)/resolved-playback-check check-fixtures
|
check-resolved: $(BUILD_DIR)/resolved-playback-check check-fixtures
|
||||||
python3 Checks/resolved-playback.py $(BUILD_DIR)/resolved-playback-check $(BUILD_DIR)/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)
|
$(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 $@
|
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O $(DEBUG_FLAGS) Checks/ffmpeg-runtime.swift $(RUNTIME_SOURCE) -framework Foundation -o $@
|
||||||
@@ -329,6 +360,7 @@ check-cast: $(BUILD_DIR)/cast-session-check $(BUILD_DIR)/cast-media-check $(BUIL
|
|||||||
$(BUILD_DIR)/cast-media-check --remux-work
|
$(BUILD_DIR)/cast-media-check --remux-work
|
||||||
$(BUILD_DIR)/cast-media-check --codec-work
|
$(BUILD_DIR)/cast-media-check --codec-work
|
||||||
$(BUILD_DIR)/cast-session-check --playback $(BUILD_DIR)/cast-playback-check $(BUILD_DIR)/fixtures/cache.mp4
|
$(BUILD_DIR)/cast-session-check --playback $(BUILD_DIR)/cast-playback-check $(BUILD_DIR)/fixtures/cache.mp4
|
||||||
|
python3 Checks/automatic-quality.py $(BUILD_DIR)/cast-playback-check $(BUILD_DIR)/fixtures "$(APP)" --cast-session $(BUILD_DIR)/cast-session-check
|
||||||
|
|
||||||
$(BUILD_DIR)/app-startup-check.dylib: Checks/app-startup.swift $(SWIFT_SOURCES) $(OBJECTS) $(CONFIG_LIBRARY) Makefile | check-dependencies
|
$(BUILD_DIR)/app-startup-check.dylib: Checks/app-startup.swift $(SWIFT_SOURCES) $(OBJECTS) $(CONFIG_LIBRARY) Makefile | check-dependencies
|
||||||
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -emit-library -DPLAYER_CHECK -O $(DEBUG_FLAGS) -import-objc-header Sources/Core/player_macos.h Checks/app-startup.swift $(SWIFT_SOURCES) $(CONFIG_FLAGS) $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -lc++ -o $@
|
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -emit-library -DPLAYER_CHECK -O $(DEBUG_FLAGS) -import-objc-header Sources/Core/player_macos.h Checks/app-startup.swift $(SWIFT_SOURCES) $(CONFIG_FLAGS) $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -lc++ -o $@
|
||||||
@@ -338,3 +370,14 @@ check-startup: all $(BUILD_DIR)/app-startup-check.dylib check-fixtures
|
|||||||
|
|
||||||
check-bundle: all check-fixtures
|
check-bundle: all check-fixtures
|
||||||
python3 Checks/bundle.py "$(APP)" "$(BUILD_DIR)/fixtures" "$(DEPENDENCY_ROOT)"
|
python3 Checks/bundle.py "$(APP)" "$(BUILD_DIR)/fixtures" "$(DEPENDENCY_ROOT)"
|
||||||
|
|
||||||
|
ZOKO_CHECK_SOURCES := Sources/App/ZokoResolver.swift Sources/App/StreamResolver.swift Sources/App/YouTubeResolver.swift Sources/App/ResolvedSource.swift Sources/App/MiruroTransport.swift Sources/App/MiruroEmbed.swift Sources/App/ExternalSubtitles.swift Sources/App/PlaybackProgress.swift
|
||||||
|
$(BUILD_DIR)/zoko-check: Checks/zoko.swift $(ZOKO_CHECK_SOURCES) $(OBJECTS) Makefile | check-dependencies
|
||||||
|
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O $(DEBUG_FLAGS) -import-objc-header Sources/Core/player.h $< $(ZOKO_CHECK_SOURCES) $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -lc++ -o $@
|
||||||
|
|
||||||
|
.PHONY: check-zoko check-zoko-live
|
||||||
|
check: check-zoko
|
||||||
|
check-zoko: $(BUILD_DIR)/zoko-check
|
||||||
|
$(BUILD_DIR)/zoko-check
|
||||||
|
check-zoko-live: $(BUILD_DIR)/zoko-check
|
||||||
|
$(BUILD_DIR)/zoko-check --live
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Terebi
|
# Terebi
|
||||||
|
|
||||||
A native macOS player for local media, yt-dlp-supported videos (including YouTube) and Miruro episodes, with ordered playback queues, saved playback positions, compact mode and Chromecast support.
|
A native macOS player for local media, yt-dlp-supported videos (including YouTube), Miruro episodes and AniList-numbered ZokoAnime sub episodes, with ordered playback queues, saved playback positions, compact mode and Chromecast support.
|
||||||
|
|
||||||
## Build and install
|
## Build and install
|
||||||
|
|
||||||
@@ -19,15 +19,20 @@ The first build downloads dependencies. Quit Terebi before updating, then run `m
|
|||||||
|
|
||||||
To run without installing, use `make run`. The built `.build/terebi.app` is self-contained; no separate FFmpeg, Python or yt-dlp installation is needed at runtime. Builds are ad-hoc signed, not notarized. Bundled licenses and source archives are in the app's Resources; public binary redistribution needs a separate license audit.
|
To run without installing, use `make run`. The built `.build/terebi.app` is self-contained; no separate FFmpeg, Python or yt-dlp installation is needed at runtime. Builds are ad-hoc signed, not notarized. Bundled licenses and source archives are in the app's Resources; public binary redistribution needs a separate license audit.
|
||||||
|
|
||||||
|
The app icon uses layered glass screens and the amber, ember, cream and dark-brown colors from the application's `SunsetPalette`, with a large play symbol for small Dock sizes. It is bundled from `Resources/AppIcon.icns`; its editable vector source is `Resources/AppIcon.svg`. Rasterize the SVG with AppKit to preserve its gradients and transparency.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
- **Local files:** use **⌘O**, drag files onto the player, or choose Finder's **Open With**. Multiple files form a queue in natural filename order, with automatic advance. Use **View → Show Queue** to play, reorder or remove items; **+ → file icon** or dropping files onto the sidebar appends them. The queue lasts only for the session; opening other media replaces it.
|
- **Local files:** use **⌘O**, drag files onto the player, or choose Finder's **Open With**. Multiple files form a queue in natural filename order, with automatic advance. Use **View → Show Queue** to play, reorder or remove items; **+ → file icon** or dropping files onto the sidebar appends them. The queue lasts only for the session; opening other media replaces it.
|
||||||
- **Streams:** use **Open Stream…** (**⇧⌘O**) or drop an HTTP(S) video link. To append without replacing playback, press the sidebar's **+**, then the **link icon**: the URL field opens upwards; **Return** fetches and adds the video. The link icon toggles the field; **Esc** dismisses it and cancels a pending fetch. Fetch errors leave the queue unchanged. Links are passed to the bundled yt-dlp without a site allowlist; extraction must return a single streamable video, not a playlist or channel. Miruro keeps its dedicated resolver: links must use `https://barelystarted.miruro.tv/watch/<opaque-id>/<slug>?ep=<episode>` with an explicit positive episode number.
|
- **Streams:** use **Open Stream…** (**⇧⌘O**) or drop an HTTP(S) video link. To append without replacing playback, press the sidebar's **+**, then the **link icon**: the URL field opens upwards; **Return** fetches and adds the video. The link icon toggles the field; **Esc** dismisses it and cancels a pending fetch. Fetch errors leave the queue unchanged. Links are passed to the bundled yt-dlp without a site allowlist; extraction must return a single streamable video, not a playlist or channel. Miruro keeps its dedicated resolver: links must use `https://barelystarted.miruro.tv/watch/<opaque-id>/<slug>?ep=<episode>` with an explicit positive episode number.
|
||||||
- **Stream queues:** integrations can supply an ordered yt-dlp/Miruro queue with `--new-instance --queue-file /absolute/queue.json`, or append without interrupting the running player using `--enqueue --queue-file /absolute/queue.json` (also for a single item). Enqueue starts playback if the player is empty or not running. The same sidebar supports play, reorder, remove, previous/next and automatic advance. Unresolved titles, including the selected row, load serially at lower priority without waiting for playback loading, buffering or quality extraction. YouTube uses a title-only oEmbed request; other sites use yt-dlp metadata and Miruro uses optional episode metadata. Arrival still depends on the site/network; failed lookups retain the supplied label, and full playback metadata takes precedence. Playback sources still resolve fresh when selected or revisited; local-file batches still use natural filename order. yt-dlp items use the original page URL for resume and version-1 progress reports; recognized YouTube video URLs still canonicalize.
|
- **Stream queues:** integrations can supply an ordered yt-dlp/Miruro queue with `--new-instance --queue-file /absolute/queue.json`, or append without interrupting the running player using `--enqueue --queue-file /absolute/queue.json` (also for a single item). Enqueue starts playback if the player is empty or not running. The same sidebar supports play, reorder, remove, previous/next and automatic advance. Unresolved titles, including the selected row, load serially at lower priority without waiting for playback loading, buffering or quality extraction. YouTube uses a title-only oEmbed request; other sites use yt-dlp metadata and Miruro uses optional episode metadata. Arrival still depends on the site/network; failed lookups retain the supplied label, and full playback metadata takes precedence. Playback sources still resolve fresh when selected or revisited; local-file batches still use natural filename order. yt-dlp items use the original page URL for resume and version-1 progress reports; recognized YouTube video URLs still canonicalize.
|
||||||
- **Seeking:** click or drag the progress track or thumb to seek. The thumb centers on the cursor, clamped to the track endpoints.
|
- **Seeking:** click or drag the progress track or thumb to seek. The thumb centers on the cursor, clamped to the track endpoints. While mouse-seeking, the segment under the thumb smoothly thickens and returns to normal on release. Hovering a named segment fades its title in above the segment's center. Reduce Motion disables these transitions.
|
||||||
- **Miruro metadata:** titles and opening/ending intervals load independently of playback when available. Small rounded gaps mark interval boundaries in the slider. Inside a segment, **Skip Opening** or **Skip Ending** appears at the top-right below the titlebar for up to ten seconds or until the segment ends; clicking seeks to its end. A light background fill grows as the offer expires, with a short entrance/exit transition; Reduce Motion uses stepped countdown updates without the transition. Ignored offers stay dismissed until the media is closed or replaced. Missing/invalid metadata leaves playback usable, and timings may differ between cuts. Nothing skips automatically.
|
- **YouTube chapters:** when yt-dlp provides usable chapters, small rounded gaps mark their boundaries in the playback track. Boundaries use the current player's duration; missing or malformed chapter data leaves ordinary playback unchanged. Chapters do not add opening/ending skip buttons. Availability varies, and description-derived timestamps may differ from YouTube's displayed chapters.
|
||||||
|
- **YouTube subtitles:** the existing subtitle menu offers manual WebVTT tracks and original-language automatic captions (marked **auto-generated**), up to 32 tracks. Captions default **Off** and fetch only when selected. A track—or Off—survives manual and automatic quality changes; queue changes reset to Off. Caption errors do not stop video playback. Machine-translated automatic tracks and live captions are not offered; captions requiring additional YouTube access may be unavailable.
|
||||||
|
- **Sponsored ranges:** optional [SponsorBlock](https://sponsor.ajay.app/) sponsor/skip ranges paint the existing track crimson without adding chapter boundaries or changing chapter gaps. Hover shows **Sponsor** alongside any chapter title, centered over the sponsored range itself even when it crosses chapter boundaries; accessibility also identifies sponsored ranges. Inside a range, **Skip Sponsor** uses the opening/ending button without a timeout or countdown fill. Clicking seeks to the range's end; automatic skipping is a separate opt-in.
|
||||||
|
- **Miruro metadata:** titles and opening/ending intervals load independently of playback when available. Small rounded gaps mark interval boundaries in the slider. Inside a segment, **Skip Opening** or **Skip Ending** appears at the top-right below the titlebar for up to ten seconds or until the segment ends; clicking seeks to its end. A light background fill grows as the offer expires, with a short entrance/exit transition; Reduce Motion uses stepped countdown updates without the transition. Ignored offers stay dismissed until the media is closed or replaced. Missing/invalid metadata leaves playback usable, and timings may differ between cuts. Automatic skipping is configurable below.
|
||||||
- **Resume:** videos resume their saved position; finished videos restart from the beginning. Live and audio-only media do not save progress.
|
- **Resume:** videos resume their saved position; finished videos restart from the beginning. Live and audio-only media do not save progress.
|
||||||
- **Compact mode:** press **P** for a floating player; double-click it to restore. Drag noninteractive areas to move the window. The queue starts hidden each time you enter compact mode; **⇧⌘L** or **gear → Show Queue** expands it below the video. The same shortcut toggles the normal sidebar, whose visibility is preserved when leaving compact mode. Use the titlebar pin to keep the normal window on top.
|
- **Compact mode:** press **P** for a floating player; double-click it to restore. It follows you across desktops and other apps' native fullscreen Spaces while keeping Terebi in the Dock and **⌘Tab**. Moving between compact and normal mode preserves the running player and restores the original window. Drag noninteractive areas to move the window. The queue starts hidden each time you enter compact mode; **⇧⌘L** or **gear → Show Queue** expands it below the video. Its top fade has a 12-point solid section and a 20-point transition, with the first row starting below both. The same shortcut toggles the normal sidebar, whose visibility is preserved when leaving compact mode. Use the titlebar pin to keep the normal window on top.
|
||||||
- **Quality:** yt-dlp defaults to SDR up to 1080p when resolution is reported. Missing resolution/codec hints are left to native probing. Use **gear → Quality** for other reported resolutions or automatic quality.
|
- **Quality:** yt-dlp defaults to SDR up to 1080p when resolution is reported. Missing resolution/codec hints are left to native probing. Use **gear → Quality** for other reported resolutions or automatic quality.
|
||||||
|
|
||||||
### Keyboard shortcuts
|
### Keyboard shortcuts
|
||||||
@@ -53,7 +58,7 @@ Open media, select the **Cast** icon or **Playback → Cast to Device**, and cho
|
|||||||
|
|
||||||
Playback controls and queue navigation work on the receiver. **Play on This Mac** returns to local playback at the receiver's position. Keep the Mac connected: it relays the media and prevents idle system sleep during playback, while allowing display sleep when casting. Closing the lid or explicitly sleeping the Mac can interrupt playback.
|
Playback controls and queue navigation work on the receiver. **Play on This Mac** returns to local playback at the receiver's position. Keep the Mac connected: it relays the media and prevents idle system sleep during playback, while allowing display sleep when casting. Closing the lid or explicitly sleeping the Mac can interrupt playback.
|
||||||
|
|
||||||
Cast output is limited to 1080p/30 fps; frame stepping and stream quality changes are local-only. Use Cast only on a trusted network. AirPlay is not supported.
|
Cast output is limited to 1080p/30 fps; frame stepping and stream quality changes are local-only. YouTube captions are also local-only: their selector is disabled while casting, no caption tracks are fetched for the receiver, and returning to this Mac restores the local selection. Use Cast only on a trusted network. AirPlay is not supported.
|
||||||
|
|
||||||
## Command line
|
## Command line
|
||||||
|
|
||||||
@@ -69,6 +74,35 @@ The CLI opens one file or supported stream, reusing the running player by defaul
|
|||||||
|
|
||||||
For native episode-URL playback, ordered stream queues, external resolved sources and progress reporting, see the [integration contract](docs/miruro-playback-handoff.md).
|
For native episode-URL playback, ordered stream queues, external resolved sources and progress reporting, see the [integration contract](docs/miruro-playback-handoff.md).
|
||||||
|
|
||||||
|
### AniList → ZokoAnime direct playback
|
||||||
|
|
||||||
|
```sh
|
||||||
|
terebi --url 'https://zokoanime.video/stream/ani/1/1/sub'
|
||||||
|
```
|
||||||
|
|
||||||
|
The canonical handoff still uses `/stream/ani/<AniList ID>/<episode>/sub`, not the site's broken documented `anilist` route. IDs and episode numbers must be canonical positive decimal integers within `Int32.max`. Before fetching a provider source, Terebi queries AniList's exact `idMal` and requests ZokoAnime's `/stream/mal/<MAL ID>/<episode>/sub` route. It never substitutes an AniList number into the MAL namespace or guesses a match by title. A missing mapping is a lookup failure, not evidence of a missing episode. Only `sub` is admitted; there is no silent dub fallback.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
For browsing clients, the CLI also provides a **headless, read-only** source check:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
terebi --check-source 'https://zokoanime.video/stream/ani/1/1/sub'
|
||||||
|
# {"version":1,"provider":"zokoanime","track":"sub","status":"available"}
|
||||||
|
```
|
||||||
|
|
||||||
|
It fetches the exact AniList-to-MAL mapping, a fresh MAL-route embed configuration and the bounded root HLS manifest, using the same resolver, origin policy, HTTP transport and manifest validation as playback. It omits title fields and does not start the app, native decoder, loopback relay or audio; read configuration; download subtitles, nested playlists or segments; or write progress/resume/configuration. The entire check has a 15-second deadline and supports cancellation. No retries, source URLs, tokens or raw resource errors are emitted or persisted.
|
||||||
|
|
||||||
|
Exactly one bounded version-1 JSON line is written to stdout. `available` means fresh source configuration plus an accessible valid root manifest, **not** guaranteed complete playback. `unavailable` means only the provider's explicit missing-episode page. Network, access/protection, malformed configuration, manifest, cancellation and deadline failures return `failed`, never `unavailable`. All three completed statuses exit zero; bad syntax, unsupported URLs and combinations with other flags exit nonzero without JSON. Only the canonical AniList `sub` watch URL is supported, not arbitrary sources or legacy Miruro URLs.
|
||||||
|
|
||||||
|
Rebuild and reinstall Terebi (`make install`) before a browsing client uses this command; older installed CLIs do not support it. Clients should treat missing/invalid output as a failed check while keeping ordinary playback available, cache results only for the session, and check selected/visible episodes rather than scanning a whole catalog.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
The optional config file is `~/.config/terebi/config.toml`, or `$XDG_CONFIG_HOME/terebi/config.toml` when `XDG_CONFIG_HOME` is absolute. To print all supported settings and key bindings:
|
The optional config file is `~/.config/terebi/config.toml`, or `$XDG_CONFIG_HOME/terebi/config.toml` when `XDG_CONFIG_HOME` is absolute. To print all supported settings and key bindings:
|
||||||
@@ -77,10 +111,39 @@ The optional config file is `~/.config/terebi/config.toml`, or `$XDG_CONFIG_HOME
|
|||||||
terebi --default-config
|
terebi --default-config
|
||||||
```
|
```
|
||||||
|
|
||||||
Copy the settings you want to change into the config file; omitted settings keep their defaults. Settings load at startup, so restart the player after editing. Changes made through menus are session-only.
|
Copy the settings you want to change into the config file; omitted settings keep their defaults. Settings load at startup, so restart the player after editing. Menu changes are session-only except the SponsorBlock and opening/ending autoskip toggles, whose saved preferences override their startup config settings.
|
||||||
|
|
||||||
The queue shortcut can be rebound with `show_queue` and `hide_queue` under `[keys]`; give both actions the same chord to keep a single toggle.
|
The queue shortcut can be rebound with `show_queue` and `hide_queue` under `[keys]`; give both actions the same chord to keep a single toggle.
|
||||||
|
|
||||||
|
### Opening/ending autoskip
|
||||||
|
|
||||||
|
**Playback → Automatically Skip Openings and Endings**, also available in the player's gear menu, skips valid opening/ending intervals to their exact ends. It is **off by default**, independent of SponsorBlock, and saved across launches. Without a saved preference, this startup setting applies:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[playback]
|
||||||
|
opening_ending_autoskip = true
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `false` or omit the setting to keep automatic skipping disabled; non-boolean values are rejected. Autoskip runs only during playing, nonbuffering video playback, never while paused or a seek is pending. Seeking into an interval or up to **five seconds before its start** keeps it manual-only for that visit, including when metadata arrives later. Quality changes preserve that intent; saved-position restoration gets the same protection. The existing timed manual buttons remain available when autoskip is suppressed. No interval metadata means no automatic skip.
|
||||||
|
|
||||||
|
### SponsorBlock
|
||||||
|
|
||||||
|
SponsorBlock and automatic sponsor skipping are **off by default**. **Playback → SponsorBlock → Show Sponsored Ranges** enables lookups, markers and manual skipping; **Automatically Skip Sponsors** separately opts into automatic skipping. Both choices are saved across launches. Saved choices override their respective config settings; without them, the startup settings apply:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[playback]
|
||||||
|
sponsorblock = true
|
||||||
|
sponsorblock_autoskip = false
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `false` (the generated defaults) or omit these settings to default to disabled when no preference is saved; non-boolean values are rejected. Autoskip alone does not enable SponsorBlock lookups. The optional lookup runs independently of playback, only for canonical YouTube videos. Failure leaves playback unchanged with no sponsored markers.
|
||||||
|
|
||||||
|
Enabling it adds a connection to `sponsor.ajay.app`. Requests use a bucket identified by the first four hexadecimal characters of the video ID's SHA-256 hash, reducing disclosure compared with sending the full ID; this is **not anonymous**. Terebi sends no cookies, account credentials or API key and makes no submissions, votes or view reports.
|
||||||
|
|
||||||
|
Ranges require finite, strictly valid bounds inside the actual player duration. A known reference duration that differs from playback by **one second or more** is rejected. Ranges with an unknown reference duration are retained until the player reports an actual finite duration, then validated before display.
|
||||||
|
|
||||||
|
Autoskip seeks to the end of a sponsored range during playing, nonbuffering video playback, not while paused or a seek is pending. Seeking into a range or up to **five seconds before its start** leaves that range manual-only for that visit, including when playback resumes or metadata arrives later. A later seek replaces that intent; quality changes preserve it. Saved-position restoration gets the same protection. The manual button remains available throughout the range.
|
||||||
|
|
||||||
### Miruro providers
|
### Miruro providers
|
||||||
|
|
||||||
Choose **Playback → Miruro → Video Host** or **Caption Provider** in Terebi's menu bar, or use **gear → Miruro**, to change providers for the session. Choices retain both provider and track: `ssub` (soft subtitles), `sub` (hard subtitles), or `dub` (dubbed audio). Video and caption providers are independent; available choices come from the episode response. These menus do not write the config file.
|
Choose **Playback → Miruro → Video Host** or **Caption Provider** in Terebi's menu bar, or use **gear → Miruro**, to change providers for the session. Choices retain both provider and track: `ssub` (soft subtitles), `sub` (hard subtitles), or `dub` (dubbed audio). Video and caption providers are independent; available choices come from the episode response. These menus do not write the config file.
|
||||||
@@ -102,8 +165,9 @@ Native playback supports the returned proxy configuration and HLS query/segment-
|
|||||||
## Limitations
|
## Limitations
|
||||||
|
|
||||||
- SDR output only; HDR is not supported.
|
- SDR output only; HDR is not supported.
|
||||||
- No embedded or YouTube captions, manual audio-track selection, rotation or deinterlacing. External WebVTT subtitles are supported for resolved streams.
|
- No embedded captions, manual audio-track selection, rotation or deinterlacing. External WebVTT subtitles are supported for resolved streams and YouTube videos.
|
||||||
- yt-dlp support does not guarantee playback: extraction must yield HTTP(S)/HLS media without download-only fragment assembly. Audio-only results with explicit codec metadata are unsupported. Private, age-restricted, region-blocked or sign-in-required videos may fail; browser cookies are not used. Rebuild and reinstall for bundled extractor updates.
|
- yt-dlp support does not guarantee playback: extraction must yield HTTP(S)/HLS media without download-only fragment assembly. Audio-only results with explicit codec metadata are unsupported. Private, age-restricted, region-blocked or sign-in-required videos may fail; browser cookies are not used. Rebuild and reinstall for bundled extractor updates.
|
||||||
|
- YouTube extraction uses IPv4 to avoid anti-bot failures on affected IPv6 routes. This does not bypass account or regional restrictions; other video sites retain their default network routing.
|
||||||
- Seeking can be slow on long-GOP or unindexed media. Live streams cannot be sought, and expired stream URLs are not automatically refreshed.
|
- Seeking can be slow on long-GOP or unindexed media. Live streams cannot be sought, and expired stream URLs are not automatically refreshed.
|
||||||
- Audio-device changes during playback are not handled.
|
- Audio-device changes during playback are not handled.
|
||||||
|
|
||||||
@@ -112,3 +176,14 @@ Native playback supports the returned proxy configuration and HLS query/segment-
|
|||||||
Install FFmpeg for test fixtures (`brew install ffmpeg`), then run `make check`. The suite requires a graphical macOS session and a working audio device; it launches players and plays short test audio. See the [Makefile](Makefile) for individual check targets.
|
Install FFmpeg for test fixtures (`brew install ffmpeg`), then run `make check`. The suite requires a graphical macOS session and a working audio device; it launches players and plays short test audio. See the [Makefile](Makefile) for individual check targets.
|
||||||
|
|
||||||
For the focused native compact-queue smoke (transparent, noninteractive app window), run `TEREBI_CHECK_COMPACT_QUEUE_ONLY=1 make check-startup`. `make check-window check-config` covers dropdown geometry, resizing, metadata aspect changes, restoration and configurable queue shortcuts without displaying windows.
|
For the focused native compact-queue smoke (transparent, noninteractive app window), run `TEREBI_CHECK_COMPACT_QUEUE_ONLY=1 make check-startup`. `make check-window check-config` covers dropdown geometry, resizing, metadata aspect changes, restoration and configurable queue shortcuts without displaying windows.
|
||||||
|
|
||||||
|
`make check-sponsorblock` exercises the bounded native lookup and duration validation with local fixtures; `.build/sponsorblock-check --live` performs read-only public API probes. `make check-resolved` includes persistent manual sponsor skips, native sponsor/opening/ending autoskip, deliberate relative/slider seek protection, paused/late metadata, saved preferences, 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, sponsor-centered hover anchors, buffering, active emphasis and seeking.
|
||||||
|
`make check-youtube check-subtitles check-resolved check-adaptive` covers caption metadata filtering, native HTTP fetching, Off/On, seeking, stable manual/auto selection through quality changes, queue resets and optional failures. `make check-cast` also checks YouTube caption isolation during Cast handoff and local selection restoration; it uses a simulated TLS receiver, not physical Chromecast hardware.
|
||||||
|
`make check-zoko check-reporting check-cli check-stream check-progress` covers provider admission/codec/error categories, bounded resources, scoped CDN policy, isolated resume and report identity without launching a GUI. Optional `make check-zoko-live` resolves current AniList 1 episode 1, loads VTT, and uses the **actual native player** while paused to decode/step video, seek/decode at 600 seconds, restore position and assert version-2 AniList progress. It creates no windows and does not start audio playback, but production Core Audio still initializes an output device. No tokenized addresses are printed or retained after the check. This is not a full-episode, audio-output, pixel/subtitle-rendering or UI smoke test, nor proof of availability for other catalog entries. Full `make check` still launches GUI/audio checks.
|
||||||
|
|
||||||
|
`make check-source` exercises the public parser/result contract, error redaction, shared manifest validation, total deadline/cancellation and actual CLI rejection paths without GUI/audio or native-player linking. Optional `make check-source-live` runs the **actual CLI**, copied outside its app bundle with an intentionally invalid configuration and isolated state directories: Cowboy Bebop, Black Clover episodes 1/170 and Black Clover Season 2 episode 1 must resolve through their exact MAL mappings, while Cowboy Bebop episode 27 remains `unavailable`, with no state writes. These source-only probes fetch no segments and are distinct from `check-zoko-live`'s native playback checks. `.build/zoko-check --live-black-clover` exercises native decoding, ten-minute seek, subtitles, resume and original AniList progress identity for Black Clover.
|
||||||
|
|
||||||
|
|
||||||
|
## Acknowledgements
|
||||||
|
|
||||||
|
Uses [SponsorBlock](https://sponsor.ajay.app/) data under the [CC BY-NC-SA 4.0 license](https://creativecommons.org/licenses/by-nc-sa/4.0/). Distribution involving this data must account for its **NonCommercial** and **ShareAlike** conditions; obtain separate permission from the relevant rights holders if your intended use needs it. This data credit does not relicense Terebi's application source.
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,42 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 320" width="1024" height="1024">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="shell" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#49352b"/>
|
||||||
|
<stop offset="48%" stop-color="#2c211c"/>
|
||||||
|
<stop offset="100%" stop-color="#1f1714"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="ember" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#f2943b"/>
|
||||||
|
<stop offset="100%" stop-color="#c2452e"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="rim" x1="0%" y1="0%" x2="75%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#ffe3b5"/>
|
||||||
|
<stop offset="40%" stop-color="#f2943b"/>
|
||||||
|
<stop offset="100%" stop-color="#c2452e"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="glass" x1="0%" y1="0%" x2="80%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#68432e"/>
|
||||||
|
<stop offset="50%" stop-color="#38241b"/>
|
||||||
|
<stop offset="100%" stop-color="#1f1714"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="play" x1="0%" y1="0%" x2="60%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#ffe3b5"/>
|
||||||
|
<stop offset="100%" stop-color="#f2943b"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="shine" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#ffe3b5" stop-opacity=".22"/>
|
||||||
|
<stop offset="100%" stop-color="#ffe3b5" stop-opacity="0"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect x="24" y="29" width="272" height="272" rx="62" fill="#1f1714" fill-opacity=".22"/>
|
||||||
|
<rect x="24" y="24" width="272" height="272" rx="62" fill="url(#shell)"/>
|
||||||
|
<rect x="25" y="25" width="270" height="270" rx="61" fill="none" stroke="#d9c7a6" stroke-opacity=".25" stroke-width="2"/>
|
||||||
|
<rect x="63" y="77" width="194" height="160" rx="31" transform="rotate(-12 160 157)" fill="url(#ember)"/>
|
||||||
|
<rect x="64" y="78" width="192" height="158" rx="30" transform="rotate(-12 160 157)" fill="none" stroke="#ffe3b5" stroke-opacity=".35" stroke-width="2"/>
|
||||||
|
<rect x="46" y="98" width="228" height="170" rx="37" fill="#1f1714" fill-opacity=".5"/>
|
||||||
|
<rect x="46" y="90" width="228" height="170" rx="37" fill="url(#rim)"/>
|
||||||
|
<rect x="54" y="98" width="212" height="154" rx="29" fill="url(#glass)"/>
|
||||||
|
<path d="M83 101H237Q263 101 263 127V143Q165 125 57 173V127Q57 101 83 101Z" fill="url(#shine)"/>
|
||||||
|
<path d="M136 137Q136 129 144 134L207 175Q215 180 207 185L144 226Q136 231 136 223Z" fill="#1f1714" fill-opacity=".65"/>
|
||||||
|
<path d="M136 130Q136 122 144 127L207 168Q215 173 207 178L144 219Q136 224 136 216Z" fill="url(#play)"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -6,6 +6,7 @@
|
|||||||
<key>CFBundleIdentifier</key><string>local.personal.terebi</string>
|
<key>CFBundleIdentifier</key><string>local.personal.terebi</string>
|
||||||
<key>CFBundleName</key><string>Terebi</string>
|
<key>CFBundleName</key><string>Terebi</string>
|
||||||
<key>CFBundleDisplayName</key><string>Terebi</string>
|
<key>CFBundleDisplayName</key><string>Terebi</string>
|
||||||
|
<key>CFBundleIconFile</key><string>AppIcon</string>
|
||||||
<key>CFBundlePackageType</key><string>APPL</string>
|
<key>CFBundlePackageType</key><string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key><string>0.1</string>
|
<key>CFBundleShortVersionString</key><string>0.1</string>
|
||||||
<key>LSMinimumSystemVersion</key><string>15.0</string>
|
<key>LSMinimumSystemVersion</key><string>15.0</string>
|
||||||
|
|||||||
@@ -115,9 +115,17 @@ final class CastDiscovery: ObservableObject {
|
|||||||
let value = String(data: data, encoding: .utf8), !value.isEmpty else { return nil }
|
let value = String(data: data, encoding: .utf8), !value.isEmpty else { return nil }
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
// Network.framework scopes IPv4 to an interface; POSIX getaddrinfo
|
||||||
|
// rejects that suffix. IPv6 scope identifiers must remain intact.
|
||||||
|
let address: String
|
||||||
|
if case .ipv4 = host {
|
||||||
|
address = String(host.debugDescription.prefix { $0 != "%" })
|
||||||
|
} else {
|
||||||
|
address = host.debugDescription
|
||||||
|
}
|
||||||
resolved[endpoint] = CastDevice(id: text("id") ?? domain + type + name,
|
resolved[endpoint] = CastDevice(id: text("id") ?? domain + type + name,
|
||||||
name: text("fn") ?? name,
|
name: text("fn") ?? name,
|
||||||
host: host.debugDescription, port: port.rawValue)
|
host: address, port: port.rawValue)
|
||||||
}
|
}
|
||||||
services[endpoint]?.timeout?.cancel()
|
services[endpoint]?.timeout?.cancel()
|
||||||
services[endpoint]?.timeout = nil
|
services[endpoint]?.timeout = nil
|
||||||
|
|||||||
+109
-33
@@ -7,6 +7,7 @@ final class CompactWindowController: NSObject {
|
|||||||
var normalVideoLayout: (() -> (aspect: Double, sidebarWidth: CGFloat)?)?
|
var normalVideoLayout: (() -> (aspect: Double, sidebarWidth: CGFloat)?)?
|
||||||
private(set) var isCompact = false
|
private(set) var isCompact = false
|
||||||
private(set) var isTransitioning = false
|
private(set) var isTransitioning = false
|
||||||
|
var presentationWindow: NSWindow? { compactPanel ?? window }
|
||||||
private(set) var queueHeight: CGFloat = 0
|
private(set) var queueHeight: CGFloat = 0
|
||||||
static let transitionDuration: TimeInterval = 0.25
|
static let transitionDuration: TimeInterval = 0.25
|
||||||
|
|
||||||
@@ -30,9 +31,13 @@ final class CompactWindowController: NSObject {
|
|||||||
let level: NSWindow.Level
|
let level: NSWindow.Level
|
||||||
let hidesOnDeactivate: Bool
|
let hidesOnDeactivate: Bool
|
||||||
let isMovableByWindowBackground: Bool
|
let isMovableByWindowBackground: Bool
|
||||||
|
let isVisible: Bool
|
||||||
|
let firstResponder: NSResponder?
|
||||||
|
let contentSuperview: NSView?
|
||||||
|
let contentFrame: NSRect
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
init(_ window: NSWindow) {
|
init(_ window: NSWindow, contentView: NSView) {
|
||||||
frame = window.frame
|
frame = window.frame
|
||||||
minSize = window.minSize
|
minSize = window.minSize
|
||||||
contentMinSize = window.contentMinSize
|
contentMinSize = window.contentMinSize
|
||||||
@@ -45,12 +50,19 @@ final class CompactWindowController: NSObject {
|
|||||||
level = window.level
|
level = window.level
|
||||||
hidesOnDeactivate = window.hidesOnDeactivate
|
hidesOnDeactivate = window.hidesOnDeactivate
|
||||||
isMovableByWindowBackground = window.isMovableByWindowBackground
|
isMovableByWindowBackground = window.isMovableByWindowBackground
|
||||||
|
isVisible = window.isVisible
|
||||||
|
firstResponder = window.firstResponder
|
||||||
|
contentSuperview = contentView === window.contentView ? nil : contentView.superview
|
||||||
|
contentFrame = contentView.frame
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum FullScreenTransition { case entering, exiting }
|
private enum FullScreenTransition { case entering, exiting }
|
||||||
|
|
||||||
private weak var window: NSWindow?
|
private weak var window: NSWindow?
|
||||||
|
private weak var contentView: NSView?
|
||||||
|
private var compactPanel: NSPanel?
|
||||||
|
private var panelDelegate: FullScreenDelegate?
|
||||||
private var snapshot: Snapshot?
|
private var snapshot: Snapshot?
|
||||||
private var fullScreenTransition: FullScreenTransition?
|
private var fullScreenTransition: FullScreenTransition?
|
||||||
private var delegateProxy: FullScreenDelegate?
|
private var delegateProxy: FullScreenDelegate?
|
||||||
@@ -77,7 +89,7 @@ final class CompactWindowController: NSObject {
|
|||||||
|
|
||||||
deinit { NotificationCenter.default.removeObserver(self) }
|
deinit { NotificationCenter.default.removeObserver(self) }
|
||||||
|
|
||||||
func attach(_ window: NSWindow) {
|
func attach(_ window: NSWindow, contentView: NSView? = nil) {
|
||||||
guard window !== self.window else { return }
|
guard window !== self.window else { return }
|
||||||
stopFrameAnimation()
|
stopFrameAnimation()
|
||||||
if let oldWindow = self.window, fullScreenTransition == nil,
|
if let oldWindow = self.window, fullScreenTransition == nil,
|
||||||
@@ -87,6 +99,7 @@ final class CompactWindowController: NSObject {
|
|||||||
restoreDelegate()
|
restoreDelegate()
|
||||||
NotificationCenter.default.removeObserver(self)
|
NotificationCenter.default.removeObserver(self)
|
||||||
self.window = window
|
self.window = window
|
||||||
|
self.contentView = contentView ?? window.contentView
|
||||||
snapshot = nil
|
snapshot = nil
|
||||||
fullScreenTransition = nil
|
fullScreenTransition = nil
|
||||||
sessionActive = false
|
sessionActive = false
|
||||||
@@ -133,13 +146,13 @@ final class CompactWindowController: NSObject {
|
|||||||
func updateAspect(_ aspect: Double) {
|
func updateAspect(_ aspect: Double) {
|
||||||
guard aspect.isFinite, aspect > 0, aspect != videoAspect else { return }
|
guard aspect.isFinite, aspect > 0, aspect != videoAspect else { return }
|
||||||
videoAspect = aspect
|
videoAspect = aspect
|
||||||
guard isCompact, !isTransitioning, let window else { return }
|
guard isCompact, !isTransitioning, let window = presentationWindow else { return }
|
||||||
fitCompactWindow(window, initial: false)
|
fitCompactWindow(window, initial: false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The reveal and native frame share the model's animation clock.
|
// The reveal and native frame share the model's animation clock.
|
||||||
func queueLayout(visible: Bool) -> (height: CGFloat, frame: NSRect)? {
|
func queueLayout(visible: Bool) -> (height: CGFloat, frame: NSRect)? {
|
||||||
guard isCompact, !isTransitioning, let window else { return nil }
|
guard isCompact, !isTransitioning, let window = presentationWindow else { return nil }
|
||||||
let usable = Self.usableFrame(visibleFrame(for: window))
|
let usable = Self.usableFrame(visibleFrame(for: window))
|
||||||
let height = visible ? min(240, max(0, usable.height - 90)) : 0
|
let height = visible ? min(240, max(0, usable.height - 90)) : 0
|
||||||
let video = Self.compactSize(aspect: videoAspect, width: window.frame.width,
|
let video = Self.compactSize(aspect: videoAspect, width: window.frame.width,
|
||||||
@@ -152,7 +165,7 @@ final class CompactWindowController: NSObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func updateQueue(height: CGFloat, frame: NSRect) {
|
func updateQueue(height: CGFloat, frame: NSRect) {
|
||||||
guard isCompact, let window else { return }
|
guard isCompact, let window = presentationWindow else { return }
|
||||||
window.resizeIncrements = NSSize(width: 1, height: 1)
|
window.resizeIncrements = NSSize(width: 1, height: 1)
|
||||||
queueHeight = height
|
queueHeight = height
|
||||||
fittedVideoAspect = frame.width / (frame.height - height)
|
fittedVideoAspect = frame.width / (frame.height - height)
|
||||||
@@ -166,7 +179,7 @@ final class CompactWindowController: NSObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func snapAfterDrag() {
|
func snapAfterDrag() {
|
||||||
guard isCompact, !isTransitioning, snappingEnabled, let window else { return }
|
guard isCompact, !isTransitioning, snappingEnabled, let window = presentationWindow else { return }
|
||||||
let frame = Self.snappedFrame(window.frame, visibleFrame: visibleFrame(for: window))
|
let frame = Self.snappedFrame(window.frame, visibleFrame: visibleFrame(for: window))
|
||||||
if frame.origin != window.frame.origin { window.setFrameOrigin(frame.origin) }
|
if frame.origin != window.frame.origin { window.setFrameOrigin(frame.origin) }
|
||||||
}
|
}
|
||||||
@@ -174,7 +187,7 @@ final class CompactWindowController: NSObject {
|
|||||||
@objc private func windowNotification(_ notification: Notification) {
|
@objc private func windowNotification(_ notification: Notification) {
|
||||||
switch notification.name {
|
switch notification.name {
|
||||||
case NSWindow.didEndLiveResizeNotification:
|
case NSWindow.didEndLiveResizeNotification:
|
||||||
guard isCompact, !isTransitioning, let window,
|
guard isCompact, !isTransitioning, let window = presentationWindow,
|
||||||
window.frame.width.isFinite, window.frame.width > 0 else { return }
|
window.frame.width.isFinite, window.frame.width > 0 else { return }
|
||||||
defaults.set(Double(window.frame.width), forKey: Self.widthKey)
|
defaults.set(Double(window.frame.width), forKey: Self.widthKey)
|
||||||
case NSWindow.willEnterFullScreenNotification:
|
case NSWindow.willEnterFullScreenNotification:
|
||||||
@@ -187,7 +200,12 @@ final class CompactWindowController: NSObject {
|
|||||||
fullScreenTransition = nil
|
fullScreenTransition = nil
|
||||||
reconcile()
|
reconcile()
|
||||||
case NSWindow.willCloseNotification:
|
case NSWindow.willCloseNotification:
|
||||||
|
if notification.object as? NSWindow === compactPanel {
|
||||||
|
window?.close()
|
||||||
|
return
|
||||||
|
}
|
||||||
stopFrameAnimation()
|
stopFrameAnimation()
|
||||||
|
if let window { removeCompactPanel(returningContentTo: window) }
|
||||||
restoreDelegate()
|
restoreDelegate()
|
||||||
NotificationCenter.default.removeObserver(self)
|
NotificationCenter.default.removeObserver(self)
|
||||||
window = nil
|
window = nil
|
||||||
@@ -318,7 +336,7 @@ final class CompactWindowController: NSObject {
|
|||||||
} else if !isCompact {
|
} else if !isCompact {
|
||||||
enterCompact(window, animated: animated)
|
enterCompact(window, animated: animated)
|
||||||
} else {
|
} else {
|
||||||
if fittedAspect != videoAspect { fitCompactWindow(window, initial: false) }
|
if fittedAspect != videoAspect, let panel = compactPanel { fitCompactWindow(panel, initial: false) }
|
||||||
setState(compact: true, transitioning: false)
|
setState(compact: true, transitioning: false)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
@@ -333,11 +351,12 @@ final class CompactWindowController: NSObject {
|
|||||||
}
|
}
|
||||||
if let snapshot {
|
if let snapshot {
|
||||||
queueHeight = 0
|
queueHeight = 0
|
||||||
setState(compact: false, transitioning: animated)
|
setState(compact: false, transitioning: true)
|
||||||
window.aspectRatio = .zero
|
let restoringWindow = presentationWindow ?? window
|
||||||
window.minSize = .zero
|
restoringWindow.aspectRatio = .zero
|
||||||
window.contentMinSize = .zero
|
restoringWindow.minSize = .zero
|
||||||
animateFrame(snapshot.frame, of: window, animated: animated) { [weak self, weak window] in
|
restoringWindow.contentMinSize = .zero
|
||||||
|
animateFrame(snapshot.frame, of: restoringWindow, animated: animated) { [weak self, weak window] in
|
||||||
guard let self, let window else { return }
|
guard let self, let window else { return }
|
||||||
self.restoreSnapshot(to: window)
|
self.restoreSnapshot(to: window)
|
||||||
self.reconcile()
|
self.reconcile()
|
||||||
@@ -357,33 +376,84 @@ final class CompactWindowController: NSObject {
|
|||||||
window.toggleFullScreen(nil)
|
window.toggleFullScreen(nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func enterCompact(_ window: NSWindow, animated: Bool) {
|
private func enterCompact(_ normalWindow: NSWindow, animated: Bool) {
|
||||||
// After didExitFullScreen, this is the normal frame, not the fullscreen frame.
|
// A regular NSWindow cannot join another app's native fullscreen Space.
|
||||||
snapshot = Snapshot(window)
|
// Keep the scene and renderer alive, but present their content in a nonactivating panel.
|
||||||
queueHeight = 0
|
guard let content = contentView else {
|
||||||
setState(compact: true, transitioning: animated)
|
finishSession()
|
||||||
window.aspectRatio = .zero
|
return
|
||||||
window.minSize = .zero
|
}
|
||||||
window.contentMinSize = .zero
|
let snapshot = Snapshot(normalWindow, contentView: content)
|
||||||
// A borderless NSWindow cannot become key; retain native keyboard and resize behavior.
|
self.snapshot = snapshot
|
||||||
window.styleMask.formUnion([.titled, .fullSizeContentView, .resizable])
|
let window = NSPanel(contentRect: .zero,
|
||||||
|
styleMask: [.titled, .closable, .resizable, .fullSizeContentView, .nonactivatingPanel],
|
||||||
|
backing: .buffered, defer: false)
|
||||||
|
window.isReleasedWhenClosed = false
|
||||||
|
window.isFloatingPanel = true
|
||||||
|
window.hidesOnDeactivate = false
|
||||||
|
window.title = normalWindow.title
|
||||||
|
window.representedURL = normalWindow.representedURL
|
||||||
window.titleVisibility = .hidden
|
window.titleVisibility = .hidden
|
||||||
window.titlebarAppearsTransparent = true
|
window.titlebarAppearsTransparent = true
|
||||||
window.minSize = NSSize(width: 160, height: 90)
|
window.titlebarSeparatorStyle = .none
|
||||||
window.contentMinSize = NSSize(width: 160, height: 90)
|
window.appearance = normalWindow.appearance
|
||||||
var behavior = window.collectionBehavior
|
window.isOpaque = normalWindow.isOpaque
|
||||||
behavior.subtract([.primary, .auxiliary, .fullScreenPrimary, .fullScreenNone, .moveToActiveSpace])
|
window.backgroundColor = normalWindow.backgroundColor
|
||||||
behavior.formUnion([.canJoinAllSpaces, .canJoinAllApplications, .fullScreenAuxiliary])
|
window.hasShadow = normalWindow.hasShadow
|
||||||
window.collectionBehavior = behavior
|
window.alphaValue = normalWindow.alphaValue
|
||||||
|
window.ignoresMouseEvents = normalWindow.ignoresMouseEvents
|
||||||
|
window.acceptsMouseMovedEvents = normalWindow.acceptsMouseMovedEvents
|
||||||
window.level = .floating
|
window.level = .floating
|
||||||
window.hidesOnDeactivate = false
|
window.collectionBehavior = [.canJoinAllSpaces, .canJoinAllApplications, .fullScreenAuxiliary]
|
||||||
// Explicit drag surfaces preserve controls' mouse tracking in compact mode too.
|
|
||||||
window.isMovableByWindowBackground = false
|
window.isMovableByWindowBackground = false
|
||||||
|
window.setFrame(normalWindow.frame, display: false)
|
||||||
|
let proxy = FullScreenDelegate(owner: self, original: nil)
|
||||||
|
panelDelegate = proxy
|
||||||
|
window.delegate = proxy
|
||||||
|
compactPanel = window
|
||||||
|
for name in [NSWindow.willCloseNotification, NSWindow.didEndLiveResizeNotification] {
|
||||||
|
NotificationCenter.default.addObserver(self, selector: #selector(windowNotification(_:)),
|
||||||
|
name: name, object: window)
|
||||||
|
}
|
||||||
|
queueHeight = 0
|
||||||
|
setState(compact: true, transitioning: animated)
|
||||||
|
normalWindow.orderOut(nil)
|
||||||
|
if content === normalWindow.contentView { normalWindow.contentView = nil }
|
||||||
|
else { content.removeFromSuperview() }
|
||||||
|
window.contentView = content
|
||||||
|
window.minSize = NSSize(width: 160, height: 90)
|
||||||
|
window.contentMinSize = window.minSize
|
||||||
|
if snapshot.isVisible {
|
||||||
|
window.orderFrontRegardless()
|
||||||
|
if NSApp.isActive { window.makeKey() }
|
||||||
|
window.makeFirstResponder(snapshot.firstResponder)
|
||||||
|
}
|
||||||
fitCompactWindow(window, initial: true, animated: animated)
|
fitCompactWindow(window, initial: true, animated: animated)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func removeCompactPanel(returningContentTo window: NSWindow) {
|
||||||
|
guard let panel = compactPanel else { return }
|
||||||
|
NotificationCenter.default.removeObserver(self, name: nil, object: panel)
|
||||||
|
panel.delegate = nil
|
||||||
|
panelDelegate = nil
|
||||||
|
compactPanel = nil
|
||||||
|
window.title = panel.title
|
||||||
|
window.representedURL = panel.representedURL
|
||||||
|
let content = panel.contentView
|
||||||
|
panel.contentView = nil
|
||||||
|
if let content, let snapshot, let superview = snapshot.contentSuperview {
|
||||||
|
superview.addSubview(content)
|
||||||
|
content.frame = snapshot.contentFrame
|
||||||
|
} else {
|
||||||
|
window.contentView = content
|
||||||
|
}
|
||||||
|
panel.close()
|
||||||
|
}
|
||||||
|
|
||||||
private func restoreSnapshot(to window: NSWindow) {
|
private func restoreSnapshot(to window: NSWindow) {
|
||||||
guard let snapshot else { return }
|
guard let snapshot else { return }
|
||||||
|
let wasKey = compactPanel?.isKeyWindow ?? false
|
||||||
|
removeCompactPanel(returningContentTo: window)
|
||||||
window.aspectRatio = .zero
|
window.aspectRatio = .zero
|
||||||
window.minSize = .zero
|
window.minSize = .zero
|
||||||
window.contentMinSize = .zero
|
window.contentMinSize = .zero
|
||||||
@@ -402,7 +472,13 @@ final class CompactWindowController: NSObject {
|
|||||||
} else {
|
} else {
|
||||||
window.aspectRatio = snapshot.aspectRatio
|
window.aspectRatio = snapshot.aspectRatio
|
||||||
}
|
}
|
||||||
|
if snapshot.isVisible {
|
||||||
|
if wasKey { window.makeKeyAndOrderFront(nil) }
|
||||||
|
else { window.orderFront(nil) }
|
||||||
|
window.makeFirstResponder(snapshot.firstResponder)
|
||||||
|
}
|
||||||
self.snapshot = nil
|
self.snapshot = nil
|
||||||
|
onChange?()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func finishSession() {
|
private func finishSession() {
|
||||||
@@ -429,7 +505,7 @@ final class CompactWindowController: NSObject {
|
|||||||
window.animator().setFrame(frame, display: true)
|
window.animator().setFrame(frame, display: true)
|
||||||
} completionHandler: { [weak self, weak window] in
|
} completionHandler: { [weak self, weak window] in
|
||||||
MainActor.assumeIsolated {
|
MainActor.assumeIsolated {
|
||||||
guard let self, let window, self.window === window, self.frameAnimating,
|
guard let self, let window, self.presentationWindow === window, self.frameAnimating,
|
||||||
self.frameAnimationGeneration == generation else { return }
|
self.frameAnimationGeneration == generation else { return }
|
||||||
self.frameAnimating = false
|
self.frameAnimating = false
|
||||||
completion()
|
completion()
|
||||||
@@ -441,7 +517,7 @@ final class CompactWindowController: NSObject {
|
|||||||
guard frameAnimating else { return }
|
guard frameAnimating else { return }
|
||||||
frameAnimating = false
|
frameAnimating = false
|
||||||
frameAnimationGeneration &+= 1
|
frameAnimationGeneration &+= 1
|
||||||
guard let window else { return }
|
guard let window = presentationWindow else { return }
|
||||||
NSAnimationContext.runAnimationGroup { context in
|
NSAnimationContext.runAnimationGroup { context in
|
||||||
context.duration = 0
|
context.duration = 0
|
||||||
window.animator().setFrame(window.frame, display: true)
|
window.animator().setFrame(window.frame, display: true)
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ final class MiruroTransport: @unchecked Sendable {
|
|||||||
|
|
||||||
static func prepare(_ source: ResolvedSource, deployment override: MiruroProxyDeployment? = nil) async throws -> ResolvedSource {
|
static func prepare(_ source: ResolvedSource, deployment override: MiruroProxyDeployment? = nil) async throws -> ResolvedSource {
|
||||||
let rules = source.requirements
|
let rules = source.requirements
|
||||||
guard rules.embed || rules.proxy != nil || rules.segmentOrigin != nil || !rules.playlistQuery.isEmpty else { return source }
|
guard rules.zoko || rules.embed || rules.proxy != nil || rules.segmentOrigin != nil || !rules.playlistQuery.isEmpty else { return source }
|
||||||
do {
|
do {
|
||||||
let deployment: MiruroProxyDeployment?
|
let deployment: MiruroProxyDeployment?
|
||||||
if let override { deployment = override }
|
if let override { deployment = override }
|
||||||
@@ -182,12 +182,33 @@ final class MiruroTransport: @unchecked Sendable {
|
|||||||
var prepared = ResolvedSource(url: url.absoluteString, headers: "", identity: source.identity,
|
var prepared = ResolvedSource(url: url.absoluteString, headers: "", identity: source.identity,
|
||||||
title: source.title, subtitles: source.subtitles)
|
title: source.title, subtitles: source.subtitles)
|
||||||
prepared.playbackLifetime = owner
|
prepared.playbackLifetime = owner
|
||||||
|
prepared.intervals = source.intervals
|
||||||
return prepared
|
return prepared
|
||||||
} catch {
|
} catch {
|
||||||
if Task.isCancelled || error is CancellationError { throw CancellationError() }
|
if Task.isCancelled || error is CancellationError { throw CancellationError() }
|
||||||
|
if rules.zoko { throw (error as? ZokoError) ?? ZokoError.transport }
|
||||||
throw MiruroPlaybackError()
|
throw MiruroPlaybackError()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Headless probe: no listener, relay token, native player or subtitle fetch.
|
||||||
|
// The same HTTP reader and manifest walker are used by actual playback.
|
||||||
|
static func checkZokoRoot(_ source: ResolvedSource) async throws {
|
||||||
|
guard source.requirements.zoko, let url = URL(string: source.url),
|
||||||
|
ZokoResolver.validResource(url, playlist: true) else { throw ZokoError.malformed }
|
||||||
|
let (data, response) = try await MiruroMediaHTTP.read(url, headers: ZokoResolver.referer,
|
||||||
|
limit: ZokoResolver.bodyLimit)
|
||||||
|
guard response.statusCode == 200, response.url == url,
|
||||||
|
let text = String(data: data, encoding: .utf8) else { throw ZokoError.malformed }
|
||||||
|
try validateZokoManifest(text, base: url)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func validateZokoManifest(_ text: String, base: URL) throws {
|
||||||
|
guard ZokoResolver.validResource(base, playlist: true) else { throw ZokoError.malformed }
|
||||||
|
_ = try rewrite(text, base: base, strict: true) { url, kind in
|
||||||
|
guard ZokoResolver.validResource(url, playlist: kind == .playlist) else { throw ZokoError.malformed }
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static func subtitle(_ track: ResolvedSubtitle) async throws -> ResolvedSubtitle {
|
static func subtitle(_ track: ResolvedSubtitle) async throws -> ResolvedSubtitle {
|
||||||
guard let proxy = track.proxy, let url = URL(string: track.url) else { return track }
|
guard let proxy = track.proxy, let url = URL(string: track.url) else { return track }
|
||||||
@@ -233,6 +254,7 @@ final class MiruroTransport: @unchecked Sendable {
|
|||||||
|
|
||||||
private func register(_ url: URL, kind: Kind, depth: Int) throws -> URL {
|
private func register(_ url: URL, kind: Kind, depth: Int) throws -> URL {
|
||||||
guard depth <= 12, ["http", "https"].contains(url.scheme), url.user == nil, url.password == nil,
|
guard depth <= 12, ["http", "https"].contains(url.scheme), url.user == nil, url.password == nil,
|
||||||
|
(!requirements.zoko || ZokoResolver.validResource(url, playlist: kind == .playlist)),
|
||||||
url.fragment == nil, url.absoluteString.utf8.count <= 32768, let port = listener.port else { throw MiruroPlaybackError() }
|
url.fragment == nil, url.absoluteString.utf8.count <= 32768, let port = listener.port else { throw MiruroPlaybackError() }
|
||||||
return try lock.withLock {
|
return try lock.withLock {
|
||||||
let key = kind.rawValue + ":" + url.absoluteString
|
let key = kind.rawValue + ":" + url.absoluteString
|
||||||
@@ -338,6 +360,9 @@ final class MiruroTransport: @unchecked Sendable {
|
|||||||
func fetch(_ url: URL) async throws -> (Data, HTTPURLResponse) {
|
func fetch(_ url: URL) async throws -> (Data, HTTPURLResponse) {
|
||||||
// Same-origin fixture/source access remains allowed. A manifest or
|
// Same-origin fixture/source access remains allowed. A manifest or
|
||||||
// decrypted token cannot turn the relay into a local-network proxy.
|
// decrypted token cannot turn the relay into a local-network proxy.
|
||||||
|
if requirements.zoko {
|
||||||
|
guard ZokoResolver.validResource(url, playlist: resource.kind == .playlist) else { throw ZokoError.malformed }
|
||||||
|
}
|
||||||
if !Self.sameOrigin(url, sourceURL) { try Self.requirePublicDestination(url) }
|
if !Self.sameOrigin(url, sourceURL) { try Self.requirePublicDestination(url) }
|
||||||
let target: URL
|
let target: URL
|
||||||
let requestHeaders: String
|
let requestHeaders: String
|
||||||
@@ -349,7 +374,8 @@ final class MiruroTransport: @unchecked Sendable {
|
|||||||
} else {
|
} else {
|
||||||
target = url
|
target = url
|
||||||
// Only the embed's fixed public Referer has cross-origin scope.
|
// Only the embed's fixed public Referer has cross-origin scope.
|
||||||
requestHeaders = embed?.headers ?? (Self.sameOrigin(url, sourceURL) ? headers : "")
|
requestHeaders = requirements.zoko ? ZokoResolver.referer :
|
||||||
|
(embed?.headers ?? (Self.sameOrigin(url, sourceURL) ? headers : ""))
|
||||||
}
|
}
|
||||||
return try await MiruroMediaHTTP.read(target, headers: requestHeaders,
|
return try await MiruroMediaHTTP.read(target, headers: requestHeaders,
|
||||||
limit: resource.kind == .playlist ? 2 * 1024 * 1024 : 64 * 1024 * 1024,
|
limit: resource.kind == .playlist ? 2 * 1024 * 1024 : 64 * 1024 * 1024,
|
||||||
@@ -369,7 +395,9 @@ final class MiruroTransport: @unchecked Sendable {
|
|||||||
} else { (raw, response) = try await fetch(url) }
|
} else { (raw, response) = try await fetch(url) }
|
||||||
if resource.kind == .playlist {
|
if resource.kind == .playlist {
|
||||||
guard response.statusCode == 200, let text = String(data: raw, encoding: .utf8), text.hasPrefix("#EXTM3U") else { throw MiruroPlaybackError() }
|
guard response.statusCode == 200, let text = String(data: raw, encoding: .utf8), text.hasPrefix("#EXTM3U") else { throw MiruroPlaybackError() }
|
||||||
return (try rewrite(text, base: url, depth: resource.depth), "application/vnd.apple.mpegurl", nil)
|
return (try Self.rewrite(text, base: url, strict: requirements.zoko) { target, kind in
|
||||||
|
try self.register(target, kind: kind, depth: resource.depth + 1)
|
||||||
|
}, "application/vnd.apple.mpegurl", nil)
|
||||||
}
|
}
|
||||||
guard embed == nil || response.statusCode == 200 else { throw MiruroPlaybackError() }
|
guard embed == nil || response.statusCode == 200 else { throw MiruroPlaybackError() }
|
||||||
let data = try embed?.resourceData(raw, url: url) ?? raw
|
let data = try embed?.resourceData(raw, url: url) ?? raw
|
||||||
@@ -425,30 +453,40 @@ final class MiruroTransport: @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func rewrite(_ text: String, base: URL, depth: Int) throws -> Data {
|
private static func rewrite(_ text: String, base: URL, strict: Bool,
|
||||||
|
register: (URL, Kind) throws -> URL) throws -> Data {
|
||||||
|
guard text.utf8.count <= 2 * 1024 * 1024, text.hasPrefix("#EXTM3U") else { throw MiruroPlaybackError() }
|
||||||
let regex = try NSRegularExpression(pattern: #"URI=\"([^\"]+)\""#)
|
let regex = try NSRegularExpression(pattern: #"URI=\"([^\"]+)\""#)
|
||||||
var nextPlaylist = false
|
var nextPlaylist = false
|
||||||
|
var hasMedia = false
|
||||||
|
var hasReference = false
|
||||||
var lines: [String] = []
|
var lines: [String] = []
|
||||||
for raw in text.components(separatedBy: .newlines) {
|
for raw in text.components(separatedBy: .newlines) {
|
||||||
|
try Task.checkCancellation()
|
||||||
var line = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
var line = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if line.hasPrefix("#") {
|
if line.hasPrefix("#") {
|
||||||
|
if strict, lines.isEmpty, line != "#EXTM3U" { throw ZokoError.malformed }
|
||||||
|
if line.hasPrefix("#EXTINF:") { hasMedia = true }
|
||||||
let kind: Kind = line.hasPrefix("#EXT-X-MAP:") ? .segment :
|
let kind: Kind = line.hasPrefix("#EXT-X-MAP:") ? .segment :
|
||||||
(line.hasPrefix("#EXT-X-MEDIA:") || line.hasPrefix("#EXT-X-I-FRAME-STREAM-INF:") || line.hasPrefix("#EXT-X-RENDITION-REPORT:")) ? .playlist : .resource
|
(line.hasPrefix("#EXT-X-MEDIA:") || line.hasPrefix("#EXT-X-I-FRAME-STREAM-INF:") || line.hasPrefix("#EXT-X-RENDITION-REPORT:")) ? .playlist : .resource
|
||||||
for match in regex.matches(in: line, range: NSRange(line.startIndex..., in: line)).reversed() {
|
for match in regex.matches(in: line, range: NSRange(line.startIndex..., in: line)).reversed() {
|
||||||
guard let range = Range(match.range(at: 1), in: line),
|
guard let range = Range(match.range(at: 1), in: line),
|
||||||
let url = URL(string: String(line[range]), relativeTo: base)?.absoluteURL else { throw MiruroPlaybackError() }
|
let url = URL(string: String(line[range]), relativeTo: base)?.absoluteURL else { throw MiruroPlaybackError() }
|
||||||
let replacement = try register(url, kind: kind, depth: depth + 1)
|
let replacement = try register(url, kind)
|
||||||
|
if kind == .playlist { hasReference = true }
|
||||||
line.replaceSubrange(range, with: replacement.absoluteString)
|
line.replaceSubrange(range, with: replacement.absoluteString)
|
||||||
}
|
}
|
||||||
if line.hasPrefix("#EXT-X-STREAM-INF:") { nextPlaylist = true }
|
if line.hasPrefix("#EXT-X-STREAM-INF:") { nextPlaylist = true }
|
||||||
} else if !line.isEmpty {
|
} else if !line.isEmpty {
|
||||||
guard let url = URL(string: line, relativeTo: base)?.absoluteURL else { throw MiruroPlaybackError() }
|
guard let url = URL(string: line, relativeTo: base)?.absoluteURL else { throw MiruroPlaybackError() }
|
||||||
let kind: Kind = nextPlaylist || url.path.lowercased().hasSuffix(".m3u8") ? .playlist : .segment
|
let kind: Kind = nextPlaylist || url.path.lowercased().hasSuffix(".m3u8") ? .playlist : .segment
|
||||||
line = try register(url, kind: kind, depth: depth + 1).absoluteString
|
if nextPlaylist || hasMedia { hasReference = true }
|
||||||
|
line = try register(url, kind).absoluteString
|
||||||
nextPlaylist = false
|
nextPlaylist = false
|
||||||
}
|
}
|
||||||
lines.append(line)
|
lines.append(line)
|
||||||
}
|
}
|
||||||
|
if strict, nextPlaylist || !hasReference { throw ZokoError.malformed }
|
||||||
return Data(lines.joined(separator: "\n").utf8)
|
return Data(lines.joined(separator: "\n").utf8)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,7 +93,8 @@ extension PlayerModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func handleKeyEvent(_ event: NSEvent, in window: NSWindow, chord: KeyChord) -> Bool {
|
private func handleKeyEvent(_ event: NSEvent, in window: NSWindow, chord: KeyChord) -> Bool {
|
||||||
guard event.window === window, NSApp.modalWindow == nil, window.attachedSheet == nil else { return false }
|
guard self.window === window, event.window === window,
|
||||||
|
NSApp.modalWindow == nil, window.attachedSheet == nil else { return false }
|
||||||
if chord.modifiers.contains(.command), keyBindings.shortcuts(for: .openStream).contains(chord) {
|
if chord.modifiers.contains(.command), keyBindings.shortcuts(for: .openStream).contains(chord) {
|
||||||
return perform(.openStream)
|
return perform(.openStream)
|
||||||
}
|
}
|
||||||
@@ -127,7 +128,7 @@ extension PlayerModel {
|
|||||||
perform(action)
|
perform(action)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if let window = event.window ?? NSApp.keyWindow,
|
if let window = window ?? event.window ?? NSApp.keyWindow,
|
||||||
!handleKeyEvent(event, in: window, chord: chord),
|
!handleKeyEvent(event, in: window, chord: chord),
|
||||||
let responder = window.firstResponder as? NSView {
|
let responder = window.firstResponder as? NSView {
|
||||||
if !responder.performKeyEquivalent(with: event) { responder.keyDown(with: event) }
|
if !responder.performKeyEquivalent(with: event) { responder.keyDown(with: event) }
|
||||||
@@ -206,14 +207,51 @@ struct PlayerMiruroMenu: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
struct PlayerOpeningEndingSkipToggle: View {
|
||||||
|
@ObservedObject var model: PlayerModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Toggle("Automatically Skip Openings and Endings", isOn: Binding(
|
||||||
|
get: { model.openingEndingAutoSkip },
|
||||||
|
set: { model.setOpeningEndingAutoSkip($0) }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
struct PlayerSponsorBlockMenu: View {
|
||||||
|
@ObservedObject var model: PlayerModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Menu("SponsorBlock") {
|
||||||
|
Toggle("Show Sponsored Ranges", isOn: Binding(
|
||||||
|
get: { model.sponsorBlockEnabled },
|
||||||
|
set: { model.setSponsorBlockEnabled($0) }))
|
||||||
|
Toggle("Automatically Skip Sponsors", isOn: Binding(
|
||||||
|
get: { model.sponsorBlockAutoSkip },
|
||||||
|
set: { model.setSponsorBlockAutoSkip($0) }))
|
||||||
|
.disabled(!model.sponsorBlockEnabled)
|
||||||
|
Text("Connects to sponsor.ajay.app using a video-ID hash prefix.")
|
||||||
|
Text("Reduces disclosure; not anonymous.")
|
||||||
|
Link("(Using SponsorBlock)", destination: URL(string: "https://sponsor.ajay.app/")!)
|
||||||
|
Menu("Acknowledgements") {
|
||||||
|
Text("Uses SponsorBlock data under CC BY-NC-SA 4.0")
|
||||||
|
Link("CC BY-NC-SA 4.0 License",
|
||||||
|
destination: URL(string: "https://creativecommons.org/licenses/by-nc-sa/4.0/")!)
|
||||||
|
Text("Display filters and merges ranges; does not modify source timings.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
struct PlayerCommands: Commands {
|
struct PlayerCommands: Commands {
|
||||||
|
@ObservedObject var model: PlayerModel
|
||||||
@FocusedObject private var focusedModel: PlayerModel?
|
@FocusedObject private var focusedModel: PlayerModel?
|
||||||
|
|
||||||
var body: some Commands {
|
var body: some Commands {
|
||||||
// The player has one scene; app-level Open/Quit still work before it becomes focused.
|
// Nonactivating compact panels need not provide SwiftUI's scene focus.
|
||||||
let model = focusedModel ?? PlayerModel.shared
|
let hasFocusedScene = focusedModel != nil || model.window?.isKeyWindow == true
|
||||||
let hasFocusedScene = focusedModel != nil
|
|
||||||
let playback = model.playbackAction
|
let playback = model.playbackAction
|
||||||
let mute = model.muteAction
|
let mute = model.muteAction
|
||||||
let fullscreen = model.fullscreenAction
|
let fullscreen = model.fullscreenAction
|
||||||
@@ -243,8 +281,8 @@ struct PlayerCommands: Commands {
|
|||||||
.disabled(!hasFocusedScene || !model.canPerform(.closeFile))
|
.disabled(!hasFocusedScene || !model.canPerform(.closeFile))
|
||||||
}
|
}
|
||||||
CommandGroup(replacing: .saveItem) {
|
CommandGroup(replacing: .saveItem) {
|
||||||
Button("Close Window") { NSApp.keyWindow?.performClose(nil) }
|
Button("Close Window") { model.window?.performClose(nil) }
|
||||||
.disabled(focusedModel == nil)
|
.disabled(!hasFocusedScene)
|
||||||
}
|
}
|
||||||
CommandMenu("Playback") {
|
CommandMenu("Playback") {
|
||||||
CastDeviceMenu(model: model, icon: false)
|
CastDeviceMenu(model: model, icon: false)
|
||||||
@@ -311,6 +349,8 @@ struct PlayerCommands: Commands {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
PlayerMiruroMenu(model: model).disabled(!hasFocusedScene)
|
PlayerMiruroMenu(model: model).disabled(!hasFocusedScene)
|
||||||
|
PlayerOpeningEndingSkipToggle(model: model)
|
||||||
|
PlayerSponsorBlockMenu(model: model)
|
||||||
Button(model.showsBufferInfo ? "Hide Buffer Info" : "Show Buffer Info") {
|
Button(model.showsBufferInfo ? "Hide Buffer Info" : "Show Buffer Info") {
|
||||||
model.performMenuAction(bufferInfo)
|
model.performMenuAction(bufferInfo)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ struct ResolvedSubtitle: Decodable, Sendable {
|
|||||||
let language: String
|
let language: String
|
||||||
let label: String
|
let label: String
|
||||||
let isDefault: Bool
|
let isDefault: Bool
|
||||||
|
// Extractor identity survives expiring URLs and distinguishes manual from automatic captions.
|
||||||
|
var selectionID: String? = nil
|
||||||
private let headerFields: [String: String]
|
private let headerFields: [String: String]
|
||||||
var proxy: MiruroProxy? = nil
|
var proxy: MiruroProxy? = nil
|
||||||
var headers: String {
|
var headers: String {
|
||||||
@@ -15,7 +17,7 @@ struct ResolvedSubtitle: Decodable, Sendable {
|
|||||||
fileprivate var validatedHeaders: [String: String] { headerFields }
|
fileprivate var validatedHeaders: [String: String] { headerFields }
|
||||||
|
|
||||||
init(url: String, format: String, language: String, label: String, isDefault: Bool,
|
init(url: String, format: String, language: String, label: String, isDefault: Bool,
|
||||||
headers: [String: String], proxy: MiruroProxy? = nil) {
|
headers: [String: String], proxy: MiruroProxy? = nil, selectionID: String? = nil) {
|
||||||
self.url = url
|
self.url = url
|
||||||
self.format = format
|
self.format = format
|
||||||
self.language = language
|
self.language = language
|
||||||
@@ -23,6 +25,7 @@ struct ResolvedSubtitle: Decodable, Sendable {
|
|||||||
self.isDefault = isDefault
|
self.isDefault = isDefault
|
||||||
self.headerFields = headers
|
self.headerFields = headers
|
||||||
self.proxy = proxy
|
self.proxy = proxy
|
||||||
|
self.selectionID = selectionID
|
||||||
}
|
}
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case url, format, language, label
|
case url, format, language, label
|
||||||
@@ -31,18 +34,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 {
|
struct ResolvedSource: Sendable {
|
||||||
let url: String
|
let url: String
|
||||||
let headers: String
|
let headers: String
|
||||||
let identity: String
|
let identity: String
|
||||||
let title: String
|
let title: String
|
||||||
let subtitles: [ResolvedSubtitle]
|
let subtitles: [ResolvedSubtitle]
|
||||||
|
var intervals: [ProviderInterval] = []
|
||||||
var requirements: MiruroPlaybackRequirements = .init()
|
var requirements: MiruroPlaybackRequirements = .init()
|
||||||
var playbackLifetime: (any Sendable)? = nil
|
var playbackLifetime: (any Sendable)? = nil
|
||||||
|
|
||||||
func replacingSubtitles(_ subtitles: [ResolvedSubtitle]) -> ResolvedSource {
|
func replacingSubtitles(_ subtitles: [ResolvedSubtitle]) -> ResolvedSource {
|
||||||
ResolvedSource(url: url, headers: headers, identity: identity, title: title, subtitles: subtitles,
|
ResolvedSource(url: url, headers: headers, identity: identity, title: title, subtitles: subtitles,
|
||||||
requirements: requirements, playbackLifetime: playbackLifetime)
|
intervals: intervals, requirements: requirements, playbackLifetime: playbackLifetime)
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Document: Decodable {
|
struct Document: Decodable {
|
||||||
@@ -91,17 +103,23 @@ struct ResolvedSource: Sendable {
|
|||||||
episode > 0 && episode <= Int(Int32.max)
|
episode > 0 && episode <= Int(Int32.max)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func validAniListIdentity(mediaID: String, episode: Int) -> Bool {
|
||||||
|
guard let id = Int32(mediaID), id > 0, String(id) == mediaID else { return false }
|
||||||
|
return episode > 0 && episode <= Int(Int32.max)
|
||||||
|
}
|
||||||
|
|
||||||
static func validated(_ document: Document) throws -> ResolvedSource {
|
static func validated(_ document: Document) throws -> ResolvedSource {
|
||||||
let id = document.identity
|
let id = document.identity
|
||||||
guard document.version == 1, id.provider == "miruro",
|
guard document.version == 1,
|
||||||
validIdentity(mediaID: id.media_id, episode: id.episode),
|
(id.provider == "miruro" && validIdentity(mediaID: id.media_id, episode: id.episode)) ||
|
||||||
|
(id.provider == "anilist" && validAniListIdentity(mediaID: id.media_id, episode: id.episode)),
|
||||||
validURL(document.url), validHeaders(document.headers),
|
validURL(document.url), validHeaders(document.headers),
|
||||||
validText(document.title ?? "", limit: 4096),
|
validText(document.title ?? "", limit: 4096),
|
||||||
validSubtitles(document.subtitles) else { throw InvalidSource() }
|
validSubtitles(document.subtitles) else { throw InvalidSource() }
|
||||||
let headers = document.headers.sorted { $0.key < $1.key }
|
let headers = document.headers.sorted { $0.key < $1.key }
|
||||||
.map { "\($0.key): \($0.value)\r\n" }.joined()
|
.map { "\($0.key): \($0.value)\r\n" }.joined()
|
||||||
return ResolvedSource(url: document.url, headers: headers,
|
return ResolvedSource(url: document.url, headers: headers,
|
||||||
identity: "miruro:\(id.media_id):\(id.episode)", title: document.title ?? "Miruro episode \(id.episode)",
|
identity: "\(id.provider):\(id.media_id):\(id.episode)", title: document.title ?? "\(id.provider == "anilist" ? "AniList \(id.media_id)" : "Miruro") episode \(id.episode)",
|
||||||
subtitles: document.subtitles)
|
subtitles: document.subtitles)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import Foundation
|
||||||
|
import CryptoKit
|
||||||
|
|
||||||
|
struct SponsorSegment: Sendable {
|
||||||
|
let start: Double
|
||||||
|
let end: Double
|
||||||
|
let videoDuration: Double
|
||||||
|
|
||||||
|
static func visibleRanges(_ segments: [SponsorSegment], duration: Double) -> [ClosedRange<Double>] {
|
||||||
|
guard duration.isFinite, duration > 0 else { return [] }
|
||||||
|
let ranges = segments.compactMap { segment -> ClosedRange<Double>? in
|
||||||
|
guard segment.start.isFinite, segment.end.isFinite,
|
||||||
|
segment.start >= 0, segment.end > segment.start, segment.end <= duration,
|
||||||
|
segment.videoDuration.isFinite, segment.videoDuration >= 0,
|
||||||
|
segment.videoDuration == 0 || abs(segment.videoDuration - duration) < 1 else { return nil }
|
||||||
|
return segment.start...segment.end
|
||||||
|
}.sorted { $0.lowerBound < $1.lowerBound }
|
||||||
|
var merged: [ClosedRange<Double>] = []
|
||||||
|
for range in ranges {
|
||||||
|
if let previous = merged.last, range.lowerBound <= previous.upperBound {
|
||||||
|
merged[merged.count - 1] = previous.lowerBound...max(previous.upperBound, range.upperBound)
|
||||||
|
} else {
|
||||||
|
merged.append(range)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum SponsorBlockError: LocalizedError {
|
||||||
|
case invalidVideoID, invalidResponse, status(Int), tooLarge, malformedJSON
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .invalidVideoID: return "SponsorBlock requires a canonical YouTube video ID."
|
||||||
|
case .invalidResponse: return "SponsorBlock returned an invalid response."
|
||||||
|
case .status(let status): return "SponsorBlock returned HTTP \(status)."
|
||||||
|
case .tooLarge: return "SponsorBlock returned too much data."
|
||||||
|
case .malformedJSON: return "SponsorBlock returned malformed segment data."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class SponsorBlockRedirectPolicy: NSObject, URLSessionTaskDelegate, Sendable {
|
||||||
|
func urlSession(_ session: URLSession, task: URLSessionTask,
|
||||||
|
willPerformHTTPRedirection response: HTTPURLResponse,
|
||||||
|
newRequest request: URLRequest,
|
||||||
|
completionHandler: @escaping (URLRequest?) -> Void) {
|
||||||
|
completionHandler(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SponsorBlockResolver {
|
||||||
|
static let responseLimit = 2 * 1024 * 1024
|
||||||
|
|
||||||
|
private static func validID(_ id: String) -> Bool {
|
||||||
|
id.utf8.count == 11 && id.utf8.allSatisfy {
|
||||||
|
(65...90).contains($0) || (97...122).contains($0) || (48...57).contains($0) || $0 == 45 || $0 == 95
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func videoID(for url: URL) -> String? {
|
||||||
|
let prefix = "https://www.youtube.com/watch?v="
|
||||||
|
let text = url.absoluteString
|
||||||
|
guard text.hasPrefix(prefix) else { return nil }
|
||||||
|
let id = String(text.dropFirst(prefix.count))
|
||||||
|
return validID(id) ? id : nil
|
||||||
|
}
|
||||||
|
|
||||||
|
static func request(videoID: String) throws -> URLRequest {
|
||||||
|
guard validID(videoID) else { throw SponsorBlockError.invalidVideoID }
|
||||||
|
let prefix = SHA256.hash(data: Data(videoID.utf8)).prefix(2).map { String(format: "%02x", $0) }.joined()
|
||||||
|
let url = URL(string: "https://sponsor.ajay.app/api/skipSegments/\(prefix)?category=sponsor&actionType=skip&service=YouTube")!
|
||||||
|
var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalAndRemoteCacheData, timeoutInterval: 10)
|
||||||
|
request.httpMethod = "GET"
|
||||||
|
request.httpShouldHandleCookies = false
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
|
||||||
|
static func resolve(videoID: String, configuration: URLSessionConfiguration? = nil) async throws -> [SponsorSegment] {
|
||||||
|
let request = try request(videoID: videoID)
|
||||||
|
let configuration = (configuration?.copy() as? URLSessionConfiguration) ?? .ephemeral
|
||||||
|
configuration.httpCookieStorage = nil
|
||||||
|
configuration.httpShouldSetCookies = false
|
||||||
|
configuration.urlCredentialStorage = nil
|
||||||
|
configuration.urlCache = nil
|
||||||
|
configuration.requestCachePolicy = .reloadIgnoringLocalAndRemoteCacheData
|
||||||
|
configuration.httpAdditionalHeaders = nil
|
||||||
|
configuration.timeoutIntervalForRequest = 10
|
||||||
|
configuration.timeoutIntervalForResource = 15
|
||||||
|
let session = URLSession(configuration: configuration)
|
||||||
|
defer { session.invalidateAndCancel() }
|
||||||
|
try Task.checkCancellation()
|
||||||
|
let (bytes, response) = try await session.bytes(for: request, delegate: SponsorBlockRedirectPolicy())
|
||||||
|
try Task.checkCancellation()
|
||||||
|
guard let response = response as? HTTPURLResponse else { throw SponsorBlockError.invalidResponse }
|
||||||
|
if response.statusCode == 404 { return [] }
|
||||||
|
guard response.statusCode == 200 else { throw SponsorBlockError.status(response.statusCode) }
|
||||||
|
guard response.expectedContentLength <= Int64(responseLimit) else { throw SponsorBlockError.tooLarge }
|
||||||
|
var data = Data()
|
||||||
|
data.reserveCapacity(min(responseLimit, max(0, Int(response.expectedContentLength))))
|
||||||
|
for try await byte in bytes {
|
||||||
|
try Task.checkCancellation()
|
||||||
|
guard data.count < responseLimit else { throw SponsorBlockError.tooLarge }
|
||||||
|
data.append(byte)
|
||||||
|
}
|
||||||
|
try Task.checkCancellation()
|
||||||
|
return try decode(data, videoID: videoID)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func decode(_ data: Data, videoID: String) throws -> [SponsorSegment] {
|
||||||
|
guard data.count <= responseLimit else { throw SponsorBlockError.tooLarge }
|
||||||
|
let buckets: [Lossy<Bucket>]
|
||||||
|
do { buckets = try JSONDecoder().decode([Lossy<Bucket>].self, from: data) }
|
||||||
|
catch { throw SponsorBlockError.malformedJSON }
|
||||||
|
return buckets.compactMap(\.value).filter { $0.videoID == videoID }.flatMap { bucket in
|
||||||
|
bucket.segments.compactMap { row in
|
||||||
|
guard let row = row.value, row.category == "sponsor", row.actionType == "skip",
|
||||||
|
row.segment.count == 2,
|
||||||
|
row.segment[0].isFinite, row.segment[1].isFinite,
|
||||||
|
row.segment[0] >= 0, row.segment[1] > row.segment[0],
|
||||||
|
row.videoDuration.isFinite, row.videoDuration >= 0 else { return nil }
|
||||||
|
return SponsorSegment(start: row.segment[0], end: row.segment[1], videoDuration: row.videoDuration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct Lossy<Value: Decodable>: Decodable {
|
||||||
|
let value: Value?
|
||||||
|
init(from decoder: Decoder) throws { value = try? Value(from: decoder) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct Bucket: Decodable {
|
||||||
|
let videoID: String
|
||||||
|
let segments: [Lossy<Row>]
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct Row: Decodable {
|
||||||
|
let category: String
|
||||||
|
let actionType: String
|
||||||
|
let segment: [Double]
|
||||||
|
let videoDuration: Double
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -69,6 +69,10 @@ struct StreamQueue: Sendable {
|
|||||||
url = URL(string: item.url)!
|
url = URL(string: item.url)!
|
||||||
identity = "miruro:\(mediaID):\(episode)"
|
identity = "miruro:\(mediaID):\(episode)"
|
||||||
label = "\(url.lastPathComponent.replacingOccurrences(of: "-", with: " ")) · Episode \(episode)"
|
label = "\(url.lastPathComponent.replacingOccurrences(of: "-", with: " ")) · Episode \(episode)"
|
||||||
|
case .anilist(let mediaID, let episode):
|
||||||
|
url = URL(string: item.url)!
|
||||||
|
identity = "anilist:\(mediaID):\(episode)"
|
||||||
|
label = "AniList \(mediaID) · Episode \(episode) (sub)"
|
||||||
}
|
}
|
||||||
guard identities.insert(identity).inserted,
|
guard identities.insert(identity).inserted,
|
||||||
item.progress_file.hasPrefix("/"), !item.progress_file.contains("\0") else { throw InvalidQueue() }
|
item.progress_file.hasPrefix("/"), !item.progress_file.contains("\0") else { throw InvalidQueue() }
|
||||||
|
|||||||
@@ -8,10 +8,23 @@ private struct StreamResolutionError: LocalizedError {
|
|||||||
enum StreamURL: Sendable {
|
enum StreamURL: Sendable {
|
||||||
case youtube(URL)
|
case youtube(URL)
|
||||||
case miruro(mediaID: String, episode: Int)
|
case miruro(mediaID: String, episode: Int)
|
||||||
|
case anilist(mediaID: String, episode: Int)
|
||||||
|
|
||||||
static func parse(_ text: String) throws -> StreamURL {
|
static func parse(_ text: String) throws -> StreamURL {
|
||||||
let text = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
let text = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let url = try YouTubeResolver.validateURL(text)
|
let url = try YouTubeResolver.validateURL(text)
|
||||||
|
if url.host?.lowercased() == "zokoanime.video" {
|
||||||
|
let invalid = StreamResolutionError(message: "Enter https://zokoanime.video/stream/ani/<AniList ID>/<episode>/sub with canonical positive numbers.")
|
||||||
|
guard let c = URLComponents(string: text), c.scheme == "https", c.user == nil, c.password == nil,
|
||||||
|
c.port == nil || c.port == 443, c.query == nil, c.fragment == nil,
|
||||||
|
c.percentEncodedPath == c.path,
|
||||||
|
!text.contains(where: { $0.isWhitespace || $0 == "\\" }) else { throw invalid }
|
||||||
|
let p = c.path.split(separator: "/", omittingEmptySubsequences: false)
|
||||||
|
guard p.count == 6, p[0].isEmpty, p[1] == "stream", p[2] == "ani", p[5] == "sub",
|
||||||
|
let episode = Int(p[4]), String(episode) == p[4],
|
||||||
|
ResolvedSource.validAniListIdentity(mediaID: String(p[3]), episode: episode) else { throw invalid }
|
||||||
|
return .anilist(mediaID: String(p[3]), episode: episode)
|
||||||
|
}
|
||||||
if url.host?.lowercased() != "barelystarted.miruro.tv" { return .youtube(url) }
|
if url.host?.lowercased() != "barelystarted.miruro.tv" { return .youtube(url) }
|
||||||
let invalid = StreamResolutionError(message: "Enter a Miruro episode URL from barelystarted.miruro.tv with an explicit positive episode number.")
|
let invalid = StreamResolutionError(message: "Enter a Miruro episode URL from barelystarted.miruro.tv with an explicit positive episode number.")
|
||||||
guard !text.unicodeScalars.contains(where: {
|
guard !text.unicodeScalars.contains(where: {
|
||||||
@@ -62,6 +75,7 @@ struct MiruroPlaybackRequirements: Sendable {
|
|||||||
var segmentOrigin: String? = nil
|
var segmentOrigin: String? = nil
|
||||||
var playlistQuery: [String: String] = [:]
|
var playlistQuery: [String: String] = [:]
|
||||||
var embed = false
|
var embed = false
|
||||||
|
var zoko = false
|
||||||
}
|
}
|
||||||
|
|
||||||
struct MiruroSelection: Sendable {
|
struct MiruroSelection: Sendable {
|
||||||
|
|||||||
+552
-63
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,12 @@ struct YouTubeStream: Sendable {
|
|||||||
let headers: String
|
let headers: String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct YouTubeChapter: Sendable {
|
||||||
|
let start: Double
|
||||||
|
let end: Double
|
||||||
|
let title: String?
|
||||||
|
}
|
||||||
|
|
||||||
struct YouTubeVideo: Sendable {
|
struct YouTubeVideo: Sendable {
|
||||||
let title: String
|
let title: String
|
||||||
let video: YouTubeStream
|
let video: YouTubeStream
|
||||||
@@ -16,6 +22,28 @@ struct YouTubeVideo: Sendable {
|
|||||||
let webpageURL: URL
|
let webpageURL: URL
|
||||||
let height: Int?
|
let height: Int?
|
||||||
let availableHeights: [Int]
|
let availableHeights: [Int]
|
||||||
|
let chapters: [YouTubeChapter]
|
||||||
|
var subtitles: [ResolvedSubtitle] = []
|
||||||
|
|
||||||
|
func chapterBoundaries(duration: Double) -> [Double] {
|
||||||
|
guard duration.isFinite, duration > 0 else { return [] }
|
||||||
|
var boundaries = Set<Double>()
|
||||||
|
for chapter in chapters {
|
||||||
|
guard chapter.start.isFinite, chapter.end.isFinite,
|
||||||
|
chapter.start >= 0, chapter.end > chapter.start, chapter.end <= duration else { continue }
|
||||||
|
if chapter.start > 0 { boundaries.insert(chapter.start) }
|
||||||
|
if chapter.end < duration { boundaries.insert(chapter.end) }
|
||||||
|
}
|
||||||
|
return boundaries.sorted()
|
||||||
|
}
|
||||||
|
|
||||||
|
func chapterTitle(at time: Double, duration: Double) -> String? {
|
||||||
|
guard duration.isFinite, duration > 0 else { return nil }
|
||||||
|
let title = chapters.first {
|
||||||
|
$0.start >= 0 && $0.end <= duration && $0.start <= time && time < $0.end
|
||||||
|
}?.title?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return title?.isEmpty == false ? title : nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct YouTubeResolutionError: LocalizedError {
|
private struct YouTubeResolutionError: LocalizedError {
|
||||||
@@ -130,12 +158,15 @@ enum YouTubeResolver {
|
|||||||
let video = try stream(videoFormat, fallbackHeaders: metadata.http_headers)
|
let video = try stream(videoFormat, fallbackHeaders: metadata.http_headers)
|
||||||
let audio = try audioFormat.map { try stream($0, fallbackHeaders: metadata.http_headers) }
|
let audio = try audioFormat.map { try stream($0, fallbackHeaders: metadata.http_headers) }
|
||||||
let title = metadata.title?.trimmingCharacters(in: .whitespacesAndNewlines)
|
let title = metadata.title?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let isLive = metadata.is_live == true || metadata.live_status == "is_live"
|
||||||
return YouTubeVideo(
|
return YouTubeVideo(
|
||||||
title: title?.isEmpty == false ? title! : "Video",
|
title: title?.isEmpty == false ? title! : "Video",
|
||||||
video: video, audio: audio, duration: duration,
|
video: video, audio: audio, duration: duration,
|
||||||
isLive: metadata.is_live == true || metadata.live_status == "is_live",
|
isLive: isLive,
|
||||||
webpageURL: try validateURL(webpageURL.absoluteString),
|
webpageURL: try validateURL(webpageURL.absoluteString),
|
||||||
height: videoFormat.height, availableHeights: availableHeights
|
height: videoFormat.height, availableHeights: availableHeights,
|
||||||
|
chapters: metadata.chapters?.values ?? [],
|
||||||
|
subtitles: isLive ? [] : subtitles(metadata)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,7 +276,7 @@ enum YouTubeResolver {
|
|||||||
environment["PATH"] = directories.joined(separator: ":")
|
environment["PATH"] = directories.joined(separator: ":")
|
||||||
let transport = "[protocol~='^(https?|m3u8(_native)?)$']"
|
let transport = "[protocol~='^(https?|m3u8(_native)?)$']"
|
||||||
let resolution = height.map { "[height=\($0)]" } ?? "[height<=?1080]"
|
let resolution = height.map { "[height=\($0)]" } ?? "[height<=?1080]"
|
||||||
let arguments = [
|
var arguments = [
|
||||||
"--ignore-config", "--no-plugin-dirs", "--no-cache-dir", "--no-remote-components",
|
"--ignore-config", "--no-plugin-dirs", "--no-cache-dir", "--no-remote-components",
|
||||||
"--no-playlist", "--simulate", "--skip-download", "--dump-single-json",
|
"--no-playlist", "--simulate", "--skip-download", "--dump-single-json",
|
||||||
"--no-progress", "--color", "never", "--no-check-formats",
|
"--no-progress", "--color", "never", "--no-check-formats",
|
||||||
@@ -253,8 +284,12 @@ enum YouTubeResolver {
|
|||||||
"--socket-timeout", "20", "--extractor-retries", "0", "--retries", "0",
|
"--socket-timeout", "20", "--extractor-retries", "0", "--retries", "0",
|
||||||
"--format-sort", "res", "--format-sort-force",
|
"--format-sort", "res", "--format-sort-force",
|
||||||
"--format", "bestvideo*[dynamic_range=?SDR]\(resolution)\(transport)+bestaudio\(transport)/best[dynamic_range=?SDR]\(resolution)\(transport)",
|
"--format", "bestvideo*[dynamic_range=?SDR]\(resolution)\(transport)+bestaudio\(transport)/best[dynamic_range=?SDR]\(resolution)\(transport)",
|
||||||
"--", webpageURL.absoluteString
|
|
||||||
]
|
]
|
||||||
|
if webpageURL.host == "www.youtube.com" {
|
||||||
|
arguments.append("--force-ipv4")
|
||||||
|
}
|
||||||
|
arguments.append("--")
|
||||||
|
arguments.append(webpageURL.absoluteString)
|
||||||
let output = try await YouTubeExtraction(executable: executable, arguments: arguments, environment: environment).run()
|
let output = try await YouTubeExtraction(executable: executable, arguments: arguments, environment: environment).run()
|
||||||
try Task.checkCancellation()
|
try Task.checkCancellation()
|
||||||
if let error = dependencyError(String(decoding: output.stderr, as: UTF8.self).lowercased()) {
|
if let error = dependencyError(String(decoding: output.stderr, as: UTF8.self).lowercased()) {
|
||||||
@@ -265,6 +300,58 @@ enum YouTubeResolver {
|
|||||||
return video
|
return video
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func subtitles(_ metadata: YouTubeMetadata) -> [ResolvedSubtitle] {
|
||||||
|
func track(_ variants: [YouTubeCaption], key: String, automatic: Bool) -> ResolvedSubtitle? {
|
||||||
|
let language = automatic && key.hasSuffix("-orig") ? String(key.dropLast(5)) : key
|
||||||
|
for variant in variants where variant.ext == "vtt" {
|
||||||
|
guard variant.transport == nil || ["http", "https"].contains(variant.transport!) else { continue }
|
||||||
|
if automatic,
|
||||||
|
URLComponents(string: variant.url)?.queryItems?.contains(where: { $0.name == "tlang" }) == true {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if automatic && !key.hasSuffix("-orig") {
|
||||||
|
// Nontranslatable ASR may have no -orig alias. Never infer it from a label.
|
||||||
|
guard let url = URLComponents(string: variant.url),
|
||||||
|
let host = url.host, ["www.youtube.com", "youtube.com"].contains(host),
|
||||||
|
url.path == "/api/timedtext",
|
||||||
|
url.queryItems?.contains(where: { $0.name == "kind" && $0.value == "asr" }) == true,
|
||||||
|
url.queryItems?.contains(where: { $0.name == "lang" && $0.value == language }) == true else { continue }
|
||||||
|
}
|
||||||
|
let name = variant.name?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let label = name?.isEmpty == false ? name! : language
|
||||||
|
let subtitle = ResolvedSubtitle(url: variant.url, format: "vtt", language: language,
|
||||||
|
label: automatic ? "\(label) (auto-generated)" : label, isDefault: false,
|
||||||
|
headers: variant.headers ?? metadata.http_headers ?? [:],
|
||||||
|
selectionID: "youtube:\(automatic ? "auto" : "manual"):\(language)")
|
||||||
|
if ResolvedSource.validSubtitles([subtitle]) { return subtitle }
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let captions = metadata.automatic_captions?.values ?? [:]
|
||||||
|
// Explicit originals win over compatibility aliases, even when their signed URLs differ.
|
||||||
|
let autoKeys = captions.keys.sorted {
|
||||||
|
if $0.hasSuffix("-orig") != $1.hasSuffix("-orig") { return $0.hasSuffix("-orig") }
|
||||||
|
return $0 < $1
|
||||||
|
}
|
||||||
|
var originals: [ResolvedSubtitle] = []
|
||||||
|
var languages = Set<String>()
|
||||||
|
var urls = Set<String>()
|
||||||
|
for key in autoKeys {
|
||||||
|
guard let subtitle = track(captions[key]!, key: key, automatic: true),
|
||||||
|
languages.insert(subtitle.language).inserted,
|
||||||
|
urls.insert(subtitle.url).inserted else { continue }
|
||||||
|
originals.append(subtitle)
|
||||||
|
if originals.count == 32 { break }
|
||||||
|
}
|
||||||
|
var manual: [ResolvedSubtitle] = []
|
||||||
|
let supplied = metadata.subtitles?.values ?? [:]
|
||||||
|
for key in supplied.keys.sorted() {
|
||||||
|
if manual.count == 32 - originals.count { break }
|
||||||
|
if let subtitle = track(supplied[key]!, key: key, automatic: false) { manual.append(subtitle) }
|
||||||
|
}
|
||||||
|
return manual + originals
|
||||||
|
}
|
||||||
|
|
||||||
private static func stream(_ format: YouTubeMetadata, fallbackHeaders: [String: String]?) throws -> YouTubeStream {
|
private static func stream(_ format: YouTubeMetadata, fallbackHeaders: [String: String]?) throws -> YouTubeStream {
|
||||||
guard format.protocol == nil || ["http", "https", "m3u8", "m3u8_native"].contains(format.protocol!) else {
|
guard format.protocol == nil || ["http", "https", "m3u8", "m3u8_native"].contains(format.protocol!) else {
|
||||||
throw YouTubeResolutionError(message: "yt-dlp selected a format that requires downloading and assembling fragments instead of native streaming.")
|
throw YouTubeResolutionError(message: "yt-dlp selected a format that requires downloading and assembling fragments instead of native streaming.")
|
||||||
@@ -382,12 +469,88 @@ private struct YouTubeMetadata: Decodable {
|
|||||||
let formats: [YouTubeMetadata]?
|
let formats: [YouTubeMetadata]?
|
||||||
let http_headers: [String: String]?
|
let http_headers: [String: String]?
|
||||||
let requested_formats: [YouTubeMetadata]?
|
let requested_formats: [YouTubeMetadata]?
|
||||||
|
let chapters: YouTubeChapters?
|
||||||
|
let subtitles: YouTubeCaptions?
|
||||||
|
let automatic_captions: YouTubeCaptions?
|
||||||
|
|
||||||
// Generic extractors may omit codec hints; FFmpeg probes the actual streams.
|
// Generic extractors may omit codec hints; FFmpeg probes the actual streams.
|
||||||
var hasVideo: Bool { vcodec.map { !$0.isEmpty && $0 != "none" } ?? true }
|
var hasVideo: Bool { vcodec.map { !$0.isEmpty && $0 != "none" } ?? true }
|
||||||
var hasAudio: Bool { acodec.map { !$0.isEmpty && $0 != "none" } ?? true }
|
var hasAudio: Bool { acodec.map { !$0.isEmpty && $0 != "none" } ?? true }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct YouTubeCaption: Decodable {
|
||||||
|
let ext: String
|
||||||
|
let url: String
|
||||||
|
let name: String?
|
||||||
|
let headers: [String: String]?
|
||||||
|
let transport: String?
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey { case ext, url, name, http_headers, `protocol` }
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let row = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
ext = try row.decode(String.self, forKey: .ext)
|
||||||
|
url = try row.decode(String.self, forKey: .url)
|
||||||
|
name = try? row.decode(String.self, forKey: .name)
|
||||||
|
headers = try row.decodeIfPresent([String: String].self, forKey: .http_headers)
|
||||||
|
transport = try row.decodeIfPresent(String.self, forKey: .protocol)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct YouTubeCaptions: Decodable {
|
||||||
|
let values: [String: [YouTubeCaption]]
|
||||||
|
|
||||||
|
private struct LanguageKey: CodingKey {
|
||||||
|
let stringValue: String
|
||||||
|
var intValue: Int? { nil }
|
||||||
|
init?(stringValue: String) { self.stringValue = stringValue }
|
||||||
|
init?(intValue: Int) { return nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) {
|
||||||
|
guard let languages = try? decoder.container(keyedBy: LanguageKey.self) else {
|
||||||
|
values = [:]
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var captions: [String: [YouTubeCaption]] = [:]
|
||||||
|
for key in languages.allKeys {
|
||||||
|
guard var rows = try? languages.nestedUnkeyedContainer(forKey: key) else { continue }
|
||||||
|
var variants: [YouTubeCaption] = []
|
||||||
|
while !rows.isAtEnd {
|
||||||
|
guard let row = try? rows.superDecoder() else { break }
|
||||||
|
if let caption = try? YouTubeCaption(from: row) { variants.append(caption) }
|
||||||
|
}
|
||||||
|
captions[key.stringValue] = variants
|
||||||
|
}
|
||||||
|
values = captions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct YouTubeChapters: Decodable {
|
||||||
|
let values: [YouTubeChapter]
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case start_time, end_time, title
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) {
|
||||||
|
guard var rows = try? decoder.unkeyedContainer() else {
|
||||||
|
values = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var chapters: [YouTubeChapter] = []
|
||||||
|
while !rows.isAtEnd {
|
||||||
|
guard let rowDecoder = try? rows.superDecoder() else { break }
|
||||||
|
guard let row = try? rowDecoder.container(keyedBy: CodingKeys.self),
|
||||||
|
let start = try? row.decode(Double.self, forKey: .start_time),
|
||||||
|
let end = try? row.decode(Double.self, forKey: .end_time) else { continue }
|
||||||
|
chapters.append(YouTubeChapter(start: start, end: end,
|
||||||
|
title: try? row.decode(String.self, forKey: .title)))
|
||||||
|
}
|
||||||
|
values = chapters
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private struct YouTubeExtractionOutput: Sendable {
|
private struct YouTubeExtractionOutput: Sendable {
|
||||||
let stdout: Data
|
let stdout: Data
|
||||||
let stderr: Data
|
let stderr: Data
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum ZokoError: String, LocalizedError {
|
||||||
|
case unavailable, transport, protection, malformed, mapping
|
||||||
|
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."
|
||||||
|
case .mapping: "AniList has no usable MAL mapping for this title. Source availability could not be checked."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Narrow policy for the demonstrated deployment, not a general cross-origin grant.
|
||||||
|
enum ZokoResolver {
|
||||||
|
static let bodyLimit = 2 * 1024 * 1024
|
||||||
|
static let referer = "Referer: https://zokoanime.video/\r\n"
|
||||||
|
private static let codecKey = Array("otaku-embed-v1".utf8)
|
||||||
|
|
||||||
|
static func validResource(_ url: URL, playlist: Bool = false) -> Bool {
|
||||||
|
guard ResolvedSource.validURL(url.absoluteString), url.scheme == "https",
|
||||||
|
url.port == nil || url.port == 443,
|
||||||
|
let host = url.host, host == "hls.dramahot.top" || (!playlist && host == "hls1.drama1.cfd") else { return false }
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class NoRedirect: NSObject, URLSessionTaskDelegate, Sendable {
|
||||||
|
func urlSession(_ session: URLSession, task: URLSessionTask,
|
||||||
|
willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest,
|
||||||
|
completionHandler: @escaping (URLRequest?) -> Void) { completionHandler(nil) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func fetch(_ request: URLRequest, allowMissingPage: Bool = false) async throws -> (Data, HTTPURLResponse) {
|
||||||
|
let configuration = URLSessionConfiguration.ephemeral
|
||||||
|
configuration.httpShouldSetCookies = false
|
||||||
|
configuration.httpCookieStorage = nil
|
||||||
|
configuration.urlCredentialStorage = nil
|
||||||
|
configuration.urlCache = nil
|
||||||
|
configuration.timeoutIntervalForRequest = 20
|
||||||
|
configuration.timeoutIntervalForResource = 25
|
||||||
|
let session = URLSession(configuration: configuration, delegate: NoRedirect(), delegateQueue: nil)
|
||||||
|
defer { session.invalidateAndCancel() }
|
||||||
|
do {
|
||||||
|
let (bytes, response) = try await session.bytes(for: request)
|
||||||
|
guard let response = response as? HTTPURLResponse, response.url == request.url else { throw ZokoError.transport }
|
||||||
|
guard response.expectedContentLength <= bodyLimit else { throw ZokoError.malformed }
|
||||||
|
if response.statusCode == 403 || response.statusCode == 429 || response.statusCode == 503 { throw ZokoError.protection }
|
||||||
|
guard response.statusCode == 200 || (allowMissingPage && response.statusCode == 404) else { throw ZokoError.transport }
|
||||||
|
var data = Data()
|
||||||
|
if response.expectedContentLength > 0 { data.reserveCapacity(Int(response.expectedContentLength)) }
|
||||||
|
for try await byte in bytes {
|
||||||
|
try Task.checkCancellation()
|
||||||
|
guard data.count < bodyLimit else { throw ZokoError.malformed }
|
||||||
|
data.append(byte)
|
||||||
|
}
|
||||||
|
return (data, response)
|
||||||
|
} catch {
|
||||||
|
if Task.isCancelled || error is CancellationError || (error as? URLError)?.code == .cancelled { throw CancellationError() }
|
||||||
|
if let error = error as? ZokoError { throw error }
|
||||||
|
throw ZokoError.transport
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func resolve(
|
||||||
|
mediaID: String, episode: Int, enrichTitle: Bool = true,
|
||||||
|
fetchResponse: @Sendable (URLRequest) async throws -> (Data, HTTPURLResponse) = {
|
||||||
|
try await fetch($0, allowMissingPage: $0.url?.host == "zokoanime.video")
|
||||||
|
}
|
||||||
|
) async throws -> ResolvedSource {
|
||||||
|
guard ResolvedSource.validAniListIdentity(mediaID: mediaID, episode: episode) else { throw ZokoError.malformed }
|
||||||
|
let metadata = try await catalog(mediaID: mediaID, includeTitle: enrichTitle, fetchResponse: fetchResponse)
|
||||||
|
guard let malID = metadata.idMal else { throw ZokoError.mapping }
|
||||||
|
var request = URLRequest(url: URL(string: "https://zokoanime.video/stream/mal/\(malID)/\(episode)/sub")!)
|
||||||
|
request.timeoutInterval = 20
|
||||||
|
request.setValue("text/html", forHTTPHeaderField: "Accept")
|
||||||
|
let (data, response) = try await fetchResponse(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)
|
||||||
|
guard response.statusCode == 200 else { throw ZokoError.transport }
|
||||||
|
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,
|
||||||
|
intervals: source.intervals, requirements: source.requirements, playbackLifetime: source.playbackLifetime)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func parse(page: String, mediaID: String, episode: Int) throws -> ResolvedSource {
|
||||||
|
guard page.utf8.count <= bodyLimit, ResolvedSource.validAniListIdentity(mediaID: mediaID, episode: episode) else { throw ZokoError.malformed }
|
||||||
|
let pattern = #"window\.__P\s*=\s*[\"']([A-Za-z0-9+/=]+)[\"']\s*;?\s*</script\s*>"#
|
||||||
|
let regex = try NSRegularExpression(pattern: pattern)
|
||||||
|
let matches = regex.matches(in: page, range: NSRange(page.startIndex..., in: page))
|
||||||
|
guard matches.count == 1, let match = matches.first, let range = Range(match.range(at: 1), in: page) else {
|
||||||
|
guard !page.contains("window.__P") else { throw ZokoError.malformed }
|
||||||
|
let lower = page.lowercased()
|
||||||
|
let visible = lower.replacingOccurrences(of: "<[^>]*>", with: " ", options: .regularExpression)
|
||||||
|
if lower.contains("cf-chl-") || lower.contains("challenge-platform") || lower.contains("just a moment") ||
|
||||||
|
lower.contains("captcha") || lower.contains("sorry, you have been blocked") { throw ZokoError.protection }
|
||||||
|
if visible.contains("error"), visible.contains("404"), visible.contains("couldn't find anything to play here") { throw ZokoError.unavailable }
|
||||||
|
throw ZokoError.malformed
|
||||||
|
}
|
||||||
|
guard var decoded = Data(base64Encoded: String(page[range])), !decoded.isEmpty else { throw ZokoError.malformed }
|
||||||
|
decoded.withUnsafeMutableBytes { (buffer: UnsafeMutableRawBufferPointer) in
|
||||||
|
let bytes = buffer.bindMemory(to: UInt8.self)
|
||||||
|
for i in bytes.indices { bytes[i] ^= codecKey[i % codecKey.count] }
|
||||||
|
}
|
||||||
|
struct Config: Decodable {
|
||||||
|
struct Subtitle: Decodable { let src: String; let lang: String; let label: String; let `default`: Bool? }
|
||||||
|
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) }
|
||||||
|
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 }
|
||||||
|
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,
|
||||||
|
intervals: intervals, requirements: .init(zoko: true))
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Catalog: Decodable {
|
||||||
|
struct Title: Decodable { let english: String?; let romaji: String?; let native: String? }
|
||||||
|
let id: Int
|
||||||
|
let idMal: Int?
|
||||||
|
let title: Title?
|
||||||
|
var name: String? {
|
||||||
|
guard let title else { return nil }
|
||||||
|
func usable(_ name: String?) -> String? {
|
||||||
|
guard let name, !name.isEmpty, ResolvedSource.validText(name, limit: 2048) else { return nil }
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
return usable(title.english) ?? usable(title.romaji) ?? usable(title.native)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func decodeCatalog(_ data: Data, mediaID: String) throws -> Catalog {
|
||||||
|
struct Response: Decodable {
|
||||||
|
struct Body: Decodable {
|
||||||
|
let media: Catalog?
|
||||||
|
enum CodingKeys: String, CodingKey { case media = "Media" }
|
||||||
|
}
|
||||||
|
struct Problem: Decodable {}
|
||||||
|
let data: Body?
|
||||||
|
let errors: [Problem]?
|
||||||
|
}
|
||||||
|
let response: Response
|
||||||
|
do { response = try JSONDecoder().decode(Response.self, from: data) }
|
||||||
|
catch { throw ZokoError.malformed }
|
||||||
|
guard response.errors?.isEmpty != false, let metadata = response.data?.media,
|
||||||
|
String(metadata.id) == mediaID,
|
||||||
|
metadata.idMal.map({ $0 > 0 && $0 <= Int(Int32.max) }) ?? true else { throw ZokoError.malformed }
|
||||||
|
return metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func catalog(
|
||||||
|
mediaID: String, includeTitle: Bool,
|
||||||
|
fetchResponse: @Sendable (URLRequest) async throws -> (Data, HTTPURLResponse)
|
||||||
|
) async throws -> Catalog {
|
||||||
|
var request = URLRequest(url: URL(string: "https://graphql.anilist.co/")!)
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
let fields = includeTitle ? "id idMal title{english romaji native}" : "id idMal"
|
||||||
|
request.httpBody = try JSONSerialization.data(withJSONObject: ["query": "query($id:Int!){Media(id:$id,type:ANIME){\(fields)}}", "variables": ["id": Int(mediaID)!]])
|
||||||
|
let (data, response) = try await fetchResponse(request)
|
||||||
|
guard response.statusCode == 200, response.mimeType == "application/json" else { throw ZokoError.malformed }
|
||||||
|
return try decodeCatalog(data, mediaID: mediaID)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func title(mediaID: String, episode: Int) async throws -> String {
|
||||||
|
guard ResolvedSource.validAniListIdentity(mediaID: mediaID, episode: episode) else { throw ZokoError.malformed }
|
||||||
|
let metadata = try await catalog(mediaID: mediaID, includeTitle: true, fetchResponse: { try await fetch($0) })
|
||||||
|
guard let name = metadata.name else { throw ZokoError.malformed }
|
||||||
|
return "\(name) · Episode \(episode) (sub)"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import Foundation
|
|||||||
enum PlayerCommand {
|
enum PlayerCommand {
|
||||||
case help
|
case help
|
||||||
case printDefaultConfig
|
case printDefaultConfig
|
||||||
|
case checkSource(mediaID: String, episode: Int)
|
||||||
case launch(URL?, newInstance: Bool, noConfig: Bool, progressFile: URL?)
|
case launch(URL?, newInstance: Bool, noConfig: Bool, progressFile: URL?)
|
||||||
case launchSource(sourceFile: URL, newInstance: Bool, noConfig: Bool, progressFile: URL?)
|
case launchSource(sourceFile: URL, newInstance: Bool, noConfig: Bool, progressFile: URL?)
|
||||||
case launchQueue(queueFile: URL, newInstance: Bool, noConfig: Bool)
|
case launchQueue(queueFile: URL, newInstance: Bool, noConfig: Bool)
|
||||||
@@ -15,17 +16,20 @@ enum PlayerCommand {
|
|||||||
terebi --new-instance --source-file PATH [--progress-file PATH]
|
terebi --new-instance --source-file PATH [--progress-file PATH]
|
||||||
terebi --new-instance [--no-config] --queue-file PATH
|
terebi --new-instance [--no-config] --queue-file PATH
|
||||||
terebi --enqueue [--no-config] --queue-file PATH
|
terebi --enqueue [--no-config] --queue-file PATH
|
||||||
|
terebi --check-source ZOKO_ANILIST_SUB_URL
|
||||||
terebi [--new-instance] [--no-config] -- FILE
|
terebi [--new-instance] [--no-config] -- FILE
|
||||||
terebi --help
|
terebi --help
|
||||||
terebi --default-config
|
terebi --default-config
|
||||||
|
|
||||||
Open one local file, yt-dlp-supported video, Miruro episode or resolved source and start playback,
|
Open one local file, yt-dlp-supported video, Miruro/AniList episode or resolved source and start playback,
|
||||||
resuming saved video progress. With no arguments, open or activate the player.
|
resuming saved video progress. With no arguments, open or activate the player.
|
||||||
Quote paths and URLs containing spaces or shell metacharacters.
|
Quote paths and URLs containing spaces or shell metacharacters.
|
||||||
|
|
||||||
--new-instance Launch a separate player, leaving existing playback untouched.
|
--new-instance Launch a separate player, leaving existing playback untouched.
|
||||||
--no-config Use built-in settings instead of config.toml on a fresh launch.
|
--no-config Use built-in settings instead of config.toml on a fresh launch.
|
||||||
--url URL Resolve an HTTP(S) video URL with yt-dlp, or open a Miruro episode.
|
--url URL Resolve an HTTP(S) video, Miruro episode or ZokoAnime AniList sub URL.
|
||||||
|
--check-source URL Read-only source/manifest check; one version-1 JSON result,
|
||||||
|
no player launch, configuration, progress or resume writes.
|
||||||
--source-file PATH Open a validated resolved HTTP(S) source in a new player.
|
--source-file PATH Open a validated resolved HTTP(S) source in a new player.
|
||||||
--queue-file PATH Open an ordered version-1 stream queue with per-item reports.
|
--queue-file PATH Open an ordered version-1 stream queue with per-item reports.
|
||||||
--enqueue Append a queue to the running player without interrupting playback;
|
--enqueue Append a queue to the running player without interrupting playback;
|
||||||
@@ -42,6 +46,16 @@ enum PlayerCommand {
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
static func parse(_ arguments: [String], directory: URL = URL(fileURLWithPath: FileManager.default.currentDirectoryPath, isDirectory: true)) throws -> PlayerCommand {
|
static func parse(_ arguments: [String], directory: URL = URL(fileURLWithPath: FileManager.default.currentDirectoryPath, isDirectory: true)) throws -> PlayerCommand {
|
||||||
|
// Parse this isolated command before any launch flags, paths or file reads.
|
||||||
|
if arguments.prefix(while: { $0 != "--" }).contains("--check-source") {
|
||||||
|
let invalid = ArgumentError("--check-source requires exactly one canonical ZokoAnime AniList sub URL and no other options.")
|
||||||
|
guard arguments.count == 2, arguments[0] == "--check-source",
|
||||||
|
arguments[1].utf8.count <= 512,
|
||||||
|
arguments[1] == arguments[1].trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
let parsed = try? StreamURL.parse(arguments[1]),
|
||||||
|
case .anilist(let mediaID, let episode) = parsed else { throw invalid }
|
||||||
|
return .checkSource(mediaID: mediaID, episode: episode)
|
||||||
|
}
|
||||||
var inputs = arguments
|
var inputs = arguments
|
||||||
var progressFile: URL?
|
var progressFile: URL?
|
||||||
var index = 0
|
var index = 0
|
||||||
@@ -88,7 +102,7 @@ enum PlayerCommand {
|
|||||||
func streamURL(_ text: String) throws -> URL {
|
func streamURL(_ text: String) throws -> URL {
|
||||||
switch try StreamURL.parse(text) {
|
switch try StreamURL.parse(text) {
|
||||||
case .youtube(let url): return url
|
case .youtube(let url): return url
|
||||||
case .miruro:
|
case .miruro, .anilist:
|
||||||
guard let url = URL(string: text.trimmingCharacters(in: .whitespacesAndNewlines)) else {
|
guard let url = URL(string: text.trimmingCharacters(in: .whitespacesAndNewlines)) else {
|
||||||
throw ArgumentError("Invalid stream URL.")
|
throw ArgumentError("Invalid stream URL.")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// Deliberately separate from launch: no configuration, application, player or state APIs.
|
||||||
|
enum SourceCheck {
|
||||||
|
enum Status: String, Sendable, CaseIterable {
|
||||||
|
case available, unavailable, failed
|
||||||
|
|
||||||
|
var json: String {
|
||||||
|
"{\"version\":1,\"provider\":\"zokoanime\",\"track\":\"sub\",\"status\":\"\(rawValue)\"}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func run(
|
||||||
|
mediaID: String, episode: Int, timeout: Duration = .seconds(15),
|
||||||
|
resolve: @escaping @Sendable (String, Int) async throws -> ResolvedSource = {
|
||||||
|
try await ZokoResolver.resolve(mediaID: $0, episode: $1, enrichTitle: false)
|
||||||
|
},
|
||||||
|
validate: @escaping @Sendable (ResolvedSource) async throws -> Void = {
|
||||||
|
try await MiruroTransport.checkZokoRoot($0)
|
||||||
|
}
|
||||||
|
) async -> Status {
|
||||||
|
await withTaskGroup(of: Status.self) { group in
|
||||||
|
group.addTask {
|
||||||
|
let source: ResolvedSource
|
||||||
|
do {
|
||||||
|
try Task.checkCancellation()
|
||||||
|
source = try await resolve(mediaID, episode)
|
||||||
|
try Task.checkCancellation()
|
||||||
|
} catch ZokoError.unavailable {
|
||||||
|
return Task.isCancelled ? .failed : .unavailable
|
||||||
|
} catch {
|
||||||
|
return .failed
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
try await validate(source)
|
||||||
|
try Task.checkCancellation()
|
||||||
|
return .available
|
||||||
|
} catch {
|
||||||
|
// A manifest 404, protection page or other resource failure is
|
||||||
|
// not evidence of the provider's explicit missing-episode page.
|
||||||
|
return .failed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
group.addTask {
|
||||||
|
try? await Task.sleep(for: timeout)
|
||||||
|
return .failed
|
||||||
|
}
|
||||||
|
let result = await group.next() ?? .failed
|
||||||
|
group.cancelAll()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,8 @@ enum TerebiCLI {
|
|||||||
print(PlayerCommand.usage)
|
print(PlayerCommand.usage)
|
||||||
case .printDefaultConfig:
|
case .printDefaultConfig:
|
||||||
print(PlayerConfiguration.defaultTOML, terminator: "")
|
print(PlayerConfiguration.defaultTOML, terminator: "")
|
||||||
|
case .checkSource(let mediaID, let episode):
|
||||||
|
print(await SourceCheck.run(mediaID: mediaID, episode: episode).json)
|
||||||
case .launch(let url, let newInstance, let noConfig, let progressFile):
|
case .launch(let url, let newInstance, let noConfig, let progressFile):
|
||||||
await launch(url: url, sourceFile: nil, newInstance: newInstance, noConfig: noConfig, progressFile: progressFile)
|
await launch(url: url, sourceFile: nil, newInstance: newInstance, noConfig: noConfig, progressFile: progressFile)
|
||||||
case .launchSource(let sourceFile, let newInstance, let noConfig, let progressFile):
|
case .launchSource(let sourceFile, let newInstance, let noConfig, let progressFile):
|
||||||
|
|||||||
@@ -8,17 +8,24 @@ public enum CompactTitleProgress: String, Decodable, Sendable {
|
|||||||
public struct PlayerConfiguration: Equatable, Sendable {
|
public struct PlayerConfiguration: Equatable, Sendable {
|
||||||
public var showBufferInfo: Bool
|
public var showBufferInfo: Bool
|
||||||
public var automaticQuality: Bool
|
public var automaticQuality: Bool
|
||||||
|
public var sponsorBlock: Bool
|
||||||
|
public var sponsorBlockAutoSkip: Bool
|
||||||
|
public var openingEndingAutoSkip: Bool
|
||||||
public var snapToEdges: Bool
|
public var snapToEdges: Bool
|
||||||
public var compactTitleProgress: CompactTitleProgress
|
public var compactTitleProgress: CompactTitleProgress
|
||||||
public var miruroVideoProvider: String
|
public var miruroVideoProvider: String
|
||||||
public var miruroCaptionProvider: String
|
public var miruroCaptionProvider: String
|
||||||
public var keys: KeyBindings
|
public var keys: KeyBindings
|
||||||
|
|
||||||
public init(showBufferInfo: Bool = false, automaticQuality: Bool = false, snapToEdges: Bool = true,
|
public init(showBufferInfo: Bool = false, automaticQuality: Bool = false, sponsorBlock: Bool = false,
|
||||||
|
sponsorBlockAutoSkip: Bool = false, openingEndingAutoSkip: Bool = false, snapToEdges: Bool = true,
|
||||||
compactTitleProgress: CompactTitleProgress = .time, miruroVideoProvider: String = "",
|
compactTitleProgress: CompactTitleProgress = .time, miruroVideoProvider: String = "",
|
||||||
miruroCaptionProvider: String = "", keys: KeyBindings = KeyBindings()) {
|
miruroCaptionProvider: String = "", keys: KeyBindings = KeyBindings()) {
|
||||||
self.showBufferInfo = showBufferInfo
|
self.showBufferInfo = showBufferInfo
|
||||||
self.automaticQuality = automaticQuality
|
self.automaticQuality = automaticQuality
|
||||||
|
self.sponsorBlock = sponsorBlock
|
||||||
|
self.sponsorBlockAutoSkip = sponsorBlockAutoSkip
|
||||||
|
self.openingEndingAutoSkip = openingEndingAutoSkip
|
||||||
self.snapToEdges = snapToEdges
|
self.snapToEdges = snapToEdges
|
||||||
self.compactTitleProgress = compactTitleProgress
|
self.compactTitleProgress = compactTitleProgress
|
||||||
self.miruroVideoProvider = miruroVideoProvider
|
self.miruroVideoProvider = miruroVideoProvider
|
||||||
@@ -33,6 +40,9 @@ public struct PlayerConfiguration: Equatable, Sendable {
|
|||||||
var lines = [
|
var lines = [
|
||||||
"[playback]",
|
"[playback]",
|
||||||
"automatic_quality = \(defaults.automaticQuality)",
|
"automatic_quality = \(defaults.automaticQuality)",
|
||||||
|
"sponsorblock = \(defaults.sponsorBlock)",
|
||||||
|
"sponsorblock_autoskip = \(defaults.sponsorBlockAutoSkip)",
|
||||||
|
"opening_ending_autoskip = \(defaults.openingEndingAutoSkip)",
|
||||||
"", "[miruro]",
|
"", "[miruro]",
|
||||||
"video_provider = \"\(defaults.miruroVideoProvider)\"",
|
"video_provider = \"\(defaults.miruroVideoProvider)\"",
|
||||||
"caption_provider = \"\(defaults.miruroCaptionProvider)\"",
|
"caption_provider = \"\(defaults.miruroCaptionProvider)\"",
|
||||||
@@ -91,6 +101,9 @@ public struct PlayerConfiguration: Equatable, Sendable {
|
|||||||
let values = try TOMLDecoder(isLenient: false).decode(FileConfiguration.self, from: data)
|
let values = try TOMLDecoder(isLenient: false).decode(FileConfiguration.self, from: data)
|
||||||
return Self(showBufferInfo: values.ui?.bufferInfo ?? false,
|
return Self(showBufferInfo: values.ui?.bufferInfo ?? false,
|
||||||
automaticQuality: values.playback?.automaticQuality ?? false,
|
automaticQuality: values.playback?.automaticQuality ?? false,
|
||||||
|
sponsorBlock: values.playback?.sponsorBlock ?? false,
|
||||||
|
sponsorBlockAutoSkip: values.playback?.sponsorBlockAutoSkip ?? false,
|
||||||
|
openingEndingAutoSkip: values.playback?.openingEndingAutoSkip ?? false,
|
||||||
snapToEdges: values.ui?.compactSnapToEdges ?? true,
|
snapToEdges: values.ui?.compactSnapToEdges ?? true,
|
||||||
compactTitleProgress: values.ui?.compactTitleProgress ?? .time,
|
compactTitleProgress: values.ui?.compactTitleProgress ?? .time,
|
||||||
miruroVideoProvider: values.miruro?.videoProvider ?? "",
|
miruroVideoProvider: values.miruro?.videoProvider ?? "",
|
||||||
@@ -175,10 +188,17 @@ public struct PlayerConfiguration: Equatable, Sendable {
|
|||||||
|
|
||||||
private struct PlaybackConfiguration {
|
private struct PlaybackConfiguration {
|
||||||
let automaticQuality: Bool?
|
let automaticQuality: Bool?
|
||||||
|
let sponsorBlock: Bool?
|
||||||
|
let sponsorBlockAutoSkip: Bool?
|
||||||
|
let openingEndingAutoSkip: Bool?
|
||||||
|
|
||||||
init(container: KeyedDecodingContainer<SettingKey>) throws {
|
init(container: KeyedDecodingContainer<SettingKey>) throws {
|
||||||
try validate(container, allowed: ["automatic_quality"])
|
try validate(container, allowed: ["automatic_quality", "sponsorblock", "sponsorblock_autoskip",
|
||||||
|
"opening_ending_autoskip"])
|
||||||
automaticQuality = try container.decodeIfPresent(Bool.self, forKey: SettingKey("automatic_quality"))
|
automaticQuality = try container.decodeIfPresent(Bool.self, forKey: SettingKey("automatic_quality"))
|
||||||
|
sponsorBlock = try container.decodeIfPresent(Bool.self, forKey: SettingKey("sponsorblock"))
|
||||||
|
sponsorBlockAutoSkip = try container.decodeIfPresent(Bool.self, forKey: SettingKey("sponsorblock_autoskip"))
|
||||||
|
openingEndingAutoSkip = try container.decodeIfPresent(Bool.self, forKey: SettingKey("opening_ending_autoskip"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -563,7 +563,7 @@ struct Player {
|
|||||||
status = PLAYER_OPENING;
|
status = PLAYER_OPENING;
|
||||||
publish();
|
publish();
|
||||||
if (network) {
|
if (network) {
|
||||||
const bool resolved = command.mediaURL.starts_with("miruro:");
|
const bool resolved = command.mediaURL.starts_with("miruro:") || command.mediaURL.starts_with("anilist:");
|
||||||
auto *primary = openNetworkInput(0, command.path, command.videoHeaders, command.live, resolved);
|
auto *primary = openNetworkInput(0, command.path, command.videoHeaders, command.live, resolved);
|
||||||
format = primary->format;
|
format = primary->format;
|
||||||
openTrack(1, format, primary, AVMEDIA_TYPE_VIDEO, -1);
|
openTrack(1, format, primary, AVMEDIA_TYPE_VIDEO, -1);
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ typedef struct PlayerNetworkSource {
|
|||||||
double duration;
|
double duration;
|
||||||
bool is_live;
|
bool is_live;
|
||||||
// Original HTTP(S) video URL (canonical for YouTube), or stable
|
// Original HTTP(S) video URL (canonical for YouTube), or stable
|
||||||
// miruro:<base64url media_id>:<positive episode> identity, never an extracted CDN URL.
|
// miruro:<opaque media_id>:<episode> or anilist:<decimal ID>:<episode> identity, never an extracted CDN URL.
|
||||||
// NULL/empty means no reporting for this source.
|
// NULL/empty means no reporting for this source.
|
||||||
const char *media_url;
|
const char *media_url;
|
||||||
// Initial restored position, applied before publishing ready state. Zero
|
// Initial restored position, applied before publishing ready state. Zero
|
||||||
|
|||||||
@@ -93,12 +93,20 @@ bool ProgressReporter::validMediaURL(std::string_view url) noexcept {
|
|||||||
|
|
||||||
bool ProgressReporter::validMediaIdentity(std::string_view identity) noexcept {
|
bool ProgressReporter::validMediaIdentity(std::string_view identity) noexcept {
|
||||||
if (validMediaURL(identity)) return true;
|
if (validMediaURL(identity)) return true;
|
||||||
constexpr std::string_view prefix = "miruro:";
|
const bool anilist = identity.starts_with("anilist:");
|
||||||
if (identity.substr(0, prefix.size()) != prefix) return false;
|
const std::string_view prefix = anilist ? "anilist:" : "miruro:";
|
||||||
|
if (!identity.starts_with(prefix)) return false;
|
||||||
const auto separator = identity.find(':', prefix.size());
|
const auto separator = identity.find(':', prefix.size());
|
||||||
if (separator == std::string_view::npos) return false;
|
if (separator == std::string_view::npos) return false;
|
||||||
const auto id = identity.substr(prefix.size(), separator - prefix.size());
|
const auto id = identity.substr(prefix.size(), separator - prefix.size());
|
||||||
if (id.empty() || id.size() > 512) return false;
|
if (id.empty() || id.size() > 512) return false;
|
||||||
|
if (anilist) {
|
||||||
|
if (id.front() < '1' || id.front() > '9') return false;
|
||||||
|
for (char c : id) if (c < '0' || c > '9') return false;
|
||||||
|
int value = 0;
|
||||||
|
const auto parsed = std::from_chars(id.data(), id.data() + id.size(), value);
|
||||||
|
if (parsed.ec != std::errc{} || parsed.ptr != id.data() + id.size() || value <= 0) return false;
|
||||||
|
}
|
||||||
for (char c : id) {
|
for (char c : id) {
|
||||||
if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
|
if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
|
||||||
(c >= '0' && c <= '9') || c == '-' || c == '_')) return false;
|
(c >= '0' && c <= '9') || c == '-' || c == '_')) return false;
|
||||||
@@ -186,13 +194,17 @@ bool ProgressReporter::emit(const char *event) noexcept {
|
|||||||
try {
|
try {
|
||||||
std::string record;
|
std::string record;
|
||||||
record.reserve(256);
|
record.reserve(256);
|
||||||
const bool miruro = mediaURL.compare(0, 7, "miruro:") == 0;
|
const bool anilist = mediaURL.starts_with("anilist:");
|
||||||
record += miruro ? "{\"version\":2,\"event\":\"" : "{\"version\":1,\"event\":\"";
|
const bool resolved = anilist || mediaURL.starts_with("miruro:");
|
||||||
|
record += resolved ? "{\"version\":2,\"event\":\"" : "{\"version\":1,\"event\":\"";
|
||||||
record += event;
|
record += event;
|
||||||
if (miruro) {
|
if (resolved) {
|
||||||
const auto separator = mediaURL.find(':', 7);
|
const size_t prefix = anilist ? 8 : 7;
|
||||||
record += "\",\"media_identity\":{\"provider\":\"miruro\",\"media_id\":\"";
|
const auto separator = mediaURL.find(':', prefix);
|
||||||
record.append(mediaURL, 7, separator - 7);
|
record += "\",\"media_identity\":{\"provider\":\"";
|
||||||
|
record += anilist ? "anilist" : "miruro";
|
||||||
|
record += "\",\"media_id\":\"";
|
||||||
|
record.append(mediaURL, prefix, separator - prefix);
|
||||||
record += "\",\"episode\":";
|
record += "\",\"episode\":";
|
||||||
record.append(mediaURL, separator + 1, std::string::npos);
|
record.append(mediaURL, separator + 1, std::string::npos);
|
||||||
record += "},\"position_seconds\":";
|
record += "},\"position_seconds\":";
|
||||||
|
|||||||
@@ -18,6 +18,28 @@ Provider-selection verification: resolver/configuration checks and the app/CLI b
|
|||||||
|
|
||||||
Final live rerun: the optional metadata endpoint stopped supplying usable enrichment, so the existing complete URL smoke and standalone live metadata check failed their metadata assertion after provider video had decoded. A separate native `PlayerModel` smoke, using the supported absent-enrichment outcome, then passed actual video/audio, default captions and both acknowledged cue seeks for all five choices listed above. The original metadata assertion was not removed or weakened; upstream metadata availability remains a live-service limitation.
|
Final live rerun: the optional metadata endpoint stopped supplying usable enrichment, so the existing complete URL smoke and standalone live metadata check failed their metadata assertion after provider video had decoded. A separate native `PlayerModel` smoke, using the supported absent-enrichment outcome, then passed actual video/audio, default captions and both acknowledged cue seeks for all five choices listed above. The original metadata assertion was not removed or weakened; upstream metadata availability remains a live-service limitation.
|
||||||
|
|
||||||
|
## AniList / ZokoAnime addition
|
||||||
|
|
||||||
|
The Miruro contract below remains intact for old collections. New AniList catalog launches use `https://zokoanime.video/stream/ani/<canonical decimal AniList ID>/<canonical positive episode>/sub`; IDs/episodes are positive `Int32` values. The deployed route is `ani`, not `anilist`. Catalog numbers do not establish provider availability; unknown totals must stay unknown. No Miruro mapping, title matching or collection migration is performed.
|
||||||
|
|
||||||
|
Terebi resolves bounded static `window.__P` data over ordinary verified HTTPS, then uses the existing scoped native HLS relay with a Zoko-only exact-origin policy. The optional title lookup is bounded AniList GraphQL metadata; unavailable metadata yields an explicit catalog-ID/episode fallback. There is no dub fallback, browser/challenge execution, telemetry fetch or persistent source URL. See [direct playback and security limits](../README.md#anilist--zokoanime-direct-playback).
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
The sole stdout line is `{"version":1,"provider":"zokoanime","track":"sub","status":"available"}`; the other valid statuses are `unavailable` (explicit provider missing-episode page only) and `failed` (network/protection/malformed/manifest/deadline errors). Completed checks exit zero for every status, invalid input exits nonzero without JSON, and raw source URLs/tokens/errors never appear in output. `available` is root-source accessibility, not proof of every segment or full playback. Rebuild/reinstall the CLI before integration; an older CLI, malformed output or process failure means a failed check, not unavailable playback. Check selected then visible episodes, not a full series scan; use a refreshable session cache, not persisted timestamps or source addresses.
|
||||||
|
|
||||||
|
`make check-source` covers the parser/result and manifest/error/cancellation boundaries plus actual CLI hostile-input paths. Optional `make check-source-live` invokes the real CLI outside its bundle with invalid configuration and isolated state directories, asserting episode 1 available and episode 27 unavailable without state writes. These read-only checks do not initialize native Core Audio or download any media segments.
|
||||||
|
|
||||||
|
`make check-zoko check-reporting check-cli check-stream check-progress` is the focused non-GUI regression command. Optional `make check-zoko-live` uses the real native decoder (paused frame stepping, ten-minute seek/decode, restored position), VTT text at acknowledged positions and actual version-2 progress. It does not start GUI/audio playback or verify visible rendering/full episodes; Core Audio initializes a device.
|
||||||
|
|
||||||
|
Integrated verification on 2026-10-25 passed all six targets above, including live fresh AniList 1 episode 1 resolution and actual native frame/seek/VTT/resume/version-2 reporting. The live run emitted redacted FFmpeg warnings/errors despite completing its assertions; it was not warning-free. The deployed static assignment omits its semicolon, which the parser explicitly supports without evaluating JavaScript. No GUI/browser test, audio-output test or full-episode verification was performed.
|
||||||
|
|
||||||
## Launch contract
|
## Launch contract
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
Reference in New Issue
Block a user