Compare commits

...

47 Commits

Author SHA1 Message Date
hl-valdemar 0641859d95 feat: support YouTube VTT subtitle selection 2026-10-27 13:19:43 +01:00
hl-valdemar a2c8cc8e4d feat: redesign app icon with sunset palette 2026-10-26 14:43:21 +01:00
hl-valdemar 491252e35f fix: simplify television icon for small sizes 2026-10-26 14:37:05 +01:00
hl-valdemar 98dccd7593 feat: add television application icon 2026-10-26 14:29:51 +01:00
hl-valdemar 82d47741eb chore: update todo 2026-10-25 23:17:49 +01:00
hl-valdemar 41aa0abd56 fix: retain Zoko opening and ending metadata through playback 2026-10-25 23:08:38 +01:00
hl-valdemar d8fb648c90 fix: use a nonactivating compact panel across fullscreen spaces 2026-10-25 22:59:36 +01:00
hl-valdemar 599e2f548a feat: add opening/ending autoskip and correct sponsor hover anchors
Sponsor hover labels used the overlapping chapter midpoint; retain chapter-title lookup but anchor the label to the sponsored range midpoint.
2026-10-25 22:21:55 +01:00
hl-valdemar a604810319 feat: make sponsored blocks skippable 2026-10-25 21:43:44 +01:00
hl-valdemar 7052fe29af fix: resolve AniList playback using exact MAL mappings 2026-10-25 20:03:16 +01:00
hl-valdemar b95f7d251e feat: add headless Zoko source checks 2026-10-25 19:14:06 +01:00
hl-valdemar e6a2759497 perf: decode Zoko configuration in place 2026-10-25 16:51:18 +01:00
hl-valdemar 6463d47ed8 fix: validate AniList reports against version two schema 2026-10-25 16:51:18 +01:00
hl-valdemar ab61d83c69 feat: resolve AniList episodes through ZokoAnime 2026-10-25 16:51:18 +01:00
hl-valdemar 8c2a18df48 fix: persist SponsorBlock preference between launches 2026-10-25 03:47:20 +01:00
hl-valdemar b04ed7fdc8 feat: show optional SponsorBlock ranges on playback timeline 2026-10-25 03:11:03 +01:00
hl-valdemar edb17f76e3 feat: animate hovered chapter titles and active seek segments 2026-10-25 03:09:34 +01:00
hl-valdemar 119fe2e737 feat: segment playback slider with resolved YouTube chapters 2026-10-25 02:29:07 +01:00
hl-valdemar 2391dadc75 fix: normalize discovered IPv4 Cast receiver addresses 2026-10-24 23:47:04 +02:00
hl-valdemar f6fe617d92 fix: use IPv4 for YouTube playback extraction
The default route returned a YouTube anti-bot challenge; the same bundled extractor and media URLs work over IPv4 without cookies. Limit the route choice to canonical YouTube videos so generic sites keep their existing routing.
2026-10-24 03:02:47 +02:00
hl-valdemar 30e5787ecc fix: keep compact queue rows below shortened header fade 2026-10-22 23:22:05 +02:00
hl-valdemar 8df500da83 feat: support Miruro provider tracks, proxies, and native embeds 2026-10-22 19:43:15 +02:00
hl-valdemar 80d9e705e5 feat: select Miruro playback and caption providers 2026-10-22 18:55:28 +02:00
hl-valdemar 4576080955 fix: populate queue titles independently of playback extraction
The sidebar scheduler waited for playback and used full stream extraction for titles. Use independent utility-priority title requests, native YouTube oEmbed, and metadata-only extraction for generic sites. Preserve foreground title precedence and stale row protection.

Verified: make check-youtube check-queue check-resolved check-adaptive check-stream all.
2026-10-22 18:43:21 +02:00
hl-valdemar 98e6dcb3c5 feat: add expandable compact queue and sidebar shortcut 2026-10-22 18:38:01 +02:00
hl-valdemar 6ed7db8167 fix: resolve queued stream metadata in the background
Queue labels were only enriched through selected-entry playback. Resolve unplayed titles serially at utility priority, preempt enrichment for playback, and bind updates to queue item IDs.

Verified: make check-resolved check-queue check-youtube all.
2026-10-22 18:38:01 +02:00
hl-valdemar f1339050d9 feat: add expandable file and link controls to playlist 2026-10-22 18:38:01 +02:00
hl-valdemar 3ff498720b fix: replace retained report sessions when re-adding queue items 2026-10-22 17:26:11 +02:00
hl-valdemar b132bab393 feat: resolve HTTP video URLs with bundled yt-dlp 2026-10-22 17:15:40 +02:00
hl-valdemar 6a4afc9f0e feat: enqueue streams in running player 2026-10-22 17:02:15 +02:00
hl-valdemar ac8e284d80 feat: reduce local caption padding and font size 2026-10-22 16:03:52 +02:00
hl-valdemar 95600e728d feat: fade segment skip offers without sliding 2026-10-22 15:31:54 +02:00
hl-valdemar 4fe53c0a07 fix: verify stream queue startup and canonical report paths 2026-10-22 15:19:31 +02:00
hl-valdemar 0588d162d6 feat: lighten skip countdown and fill toward expiry 2026-10-22 14:26:10 +02:00
hl-valdemar 53369424e6 feat: show skip offer countdown and transitions 2026-10-22 14:25:34 +02:00
hl-valdemar 70672321a1 feat: receive ordered stream playback queues 2026-10-22 14:09:53 +02:00
hl-valdemar e7a33ff68e feat: place segment skip below titlebar 2026-10-22 14:05:43 +02:00
hl-valdemar 458b2c9544 feat: extend segment skip offers to ten seconds 2026-10-22 13:59:43 +02:00
hl-valdemar 3d949154a2 feat: offer timed opening and ending skips 2026-10-22 13:35:01 +02:00
hl-valdemar 7b53b00b15 fix: clip metadata intervals to playable duration 2026-10-22 13:24:33 +02:00
hl-valdemar fe34ce88f9 test: measure rendered progress thumb alignment 2026-10-22 12:43:29 +02:00
hl-valdemar 40e2ea45a0 fix: center progress thumb on pointer while seeking 2026-10-22 12:39:12 +02:00
hl-valdemar 19a1de625b fix: preserve native thumb geometry while seeking beside it
Remove the knobRect override: native slider geometry also affects drag positioning. Progress pointer tracking now shares the drawn thumb bounds, preserves the initial grab offset, and clamps to the track endpoints. Expanded native event regression covers adjacent clicks, thumb drags, track drags, and endpoint clamping; application build passes.
2026-10-22 12:30:19 +02:00
hl-valdemar dea8880b9b fix: match progress slider dragging to visible thumb
The 3-point progress thumb was drawn inside larger native knob bounds. Override those bounds for progress only so adjacent clicks seek; retain native volume geometry and tracking. Native click regression reproduced before the fix and passes afterward.
2026-10-22 12:17:30 +02:00
hl-valdemar 80c5f36479 fix: restore balanced playback controls alignment 2026-10-22 12:08:32 +02:00
hl-valdemar 4bf738d8d2 style: segment playback timeline with rounded gaps 2026-10-22 12:01:50 +02:00
hl-valdemar a1c8314391 feat: enrich Miruro URL playback metadata and timeline 2026-10-22 11:59:27 +02:00
55 changed files with 8407 additions and 542 deletions
+2
View File
@@ -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.
+76 -5
View File
@@ -67,6 +67,22 @@ def check_window_drag(root, app, fixtures):
print("PASS native window dragging: " + str(report)) print("PASS native window dragging: " + str(report))
def check_queue_add(root, app, fixtures):
result_path = root / "queue-add.json"
diagnostics = root / "queue-add.log"
result = subprocess.run(
["open", "-n", "-W", "--stderr", str(diagnostics), "-a", str(app),
str(fixtures / "cache.mp4"), "--args", "--no-config", "--check-window-drag", str(result_path),
"--queue-add-only", str(fixtures)],
capture_output=True, text=True, timeout=30,
)
report = json.loads(result_path.read_text()) if result_path.exists() else {}
if result.returncode != 0 or not report.get("passed"):
log = diagnostics.read_text(errors="replace") if diagnostics.exists() else ""
raise RuntimeError(f"native queue add failed: {report}; {result.stdout}{result.stderr}{log}")
print("PASS native queue add: + expands, link focuses input, toggle retains draft, Escape dismisses without moving window")
def check_queue_drag(root, app, fixtures): def check_queue_drag(root, app, fixtures):
result_path = root / "queue-drag.json" result_path = root / "queue-drag.json"
diagnostics = root / "queue-drag.log" diagnostics = root / "queue-drag.log"
@@ -85,22 +101,24 @@ def check_queue_drag(root, app, fixtures):
def check_window_layout(root, app, fixtures): def check_window_layout(root, app, fixtures):
result_path = root / "window-layout.json" result_path = root / "window-layout.json"
diagnostics = root / "window-layout.log" diagnostics = root / "window-layout.log"
compact_queue_only = os.environ.get("TEREBI_CHECK_COMPACT_QUEUE_ONLY") == "1"
extra = ["--compact-queue-only"] if compact_queue_only else []
result = subprocess.run( result = subprocess.run(
["open", "-g", "-n", "-W", "--stderr", str(diagnostics), "-a", str(app), ["open", "-g", "-n", "-W", "--stderr", str(diagnostics), "-a", str(app),
str(fixtures / "cache.mp4"), "--args", "--no-config", "--check-window-layout", str(result_path)], str(fixtures / "cache.mp4"), "--args", "--no-config", "--check-window-layout", str(result_path), *extra],
capture_output=True, text=True, timeout=40, capture_output=True, text=True, timeout=40,
) )
log = diagnostics.read_text(errors="replace") if diagnostics.exists() else "" log = diagnostics.read_text(errors="replace") if diagnostics.exists() else ""
report = json.loads(result_path.read_text()) if result_path.exists() else {} report = json.loads(result_path.read_text()) if result_path.exists() else {}
expected_animations = 0 if report.get("reduceMotion") else 16 expected_animations = 0 if report.get("reduceMotion") else (4 if compact_queue_only else 19)
expected_queue_animations = 0 if report.get("reduceMotion") else 9 expected_queue_animations = 0 if report.get("reduceMotion") else (4 if compact_queue_only else 13)
if (result.returncode != 0 or not report.get("passed") or report.get("steps") != 36 if (result.returncode != 0 or not report.get("passed") or report.get("steps") != 45
or report.get("animatedTransitions") != expected_animations or report.get("animatedTransitions") != expected_animations
or report.get("animatedQueueTransitions") != expected_queue_animations): or report.get("animatedQueueTransitions") != expected_queue_animations):
raise RuntimeError(f"native window layout failed: {report}; {result.stdout}{result.stderr}{log}") raise RuntimeError(f"native window layout failed: {report}; {result.stdout}{result.stderr}{log}")
print(f"PASS native SwiftUI window layout: {report['animatedTransitions']} compact and " print(f"PASS native SwiftUI window layout: {report['animatedTransitions']} compact and "
f"{report['animatedQueueTransitions']} queue animations, stable video size, responsive reversals, " f"{report['animatedQueueTransitions']} queue animations, stable video size, responsive reversals, "
"screen-edge fitting, fullscreen fixed bounds, minimum sizes and exact normal geometry restored") "downward compact queue with native shortcut and painted content, minimum sizes and exact normal geometry restored")
def check_startup_errors(root, app): def check_startup_errors(root, app):
config = root / "invalid-startup.toml" config = root / "invalid-startup.toml"
@@ -331,6 +349,43 @@ def check_cli_instances(root, app, fixtures):
wait_states(count, lambda _: True, "original players survive independent resolved GUI quits") wait_states(count, lambda _: True, "original players survive independent resolved GUI quits")
print("PASS actual resolved CLI handoff: two isolated GUI episodes, authenticated encrypted HLS, independent default VTT, v2 progress and quit") print("PASS actual resolved CLI handoff: two isolated GUI episodes, authenticated encrypted HLS, independent default VTT, v2 progress and quit")
def check_stream_queue_instance():
session = (root / "stream-queue-session").resolve()
session.mkdir(mode=0o700)
paths = [session / "first.jsonl", session / "second.jsonl"]
urls = [reported_urls[0], canonical] # Deliberately not alphabetical URL order.
manifest = session / "queue.json"
manifest.write_text(json.dumps({"version": 1, "items": [
{"url": url, "progress_file": str(path)} for url, path in zip(urls, paths)]}))
invoke(["--new-instance", "--no-config", "--queue-file", str(manifest)])
states = wait_states(1, lambda snapshots: all(
state["queueURLs"] == urls and state["queueIndex"] == 0 and not state["busy"]
and state["status"] == 3 and state["hasVideo"] and state["position"] > 0.2
for state in snapshots.values()), "actual queue CLI delivers ordered native stream playback")
pid = next(iter(states))
if paths[1].exists():
raise RuntimeError("unselected queue entry must not create or write its report")
input_request(pid, 1, lambda state: state["queueIndex"] == 1 and not state["busy"]
and state["status"] == 3, "native queue manual next", action="queueAdvance", offset=1)
input_request(pid, 1, lambda state: state["queueIndex"] == 0 and not state["busy"]
and state["status"] == 3, "native queue previous revisits owned report", action="queueAdvance", offset=-1)
states = input_request(pid, 1, lambda state: state["status"] == 2 and not state["busy"],
"pause queue first item", action="pause")
target = states[pid]["duration"] - 0.3
input_request(pid, 1, lambda state: at_position(state, target),
"acknowledged queue end seek", action="seek", position=target)
input_request(pid, 1, lambda state: state["queueIndex"] == 1 and not state["busy"]
and state["status"] == 3 and state["queueURLs"] == urls,
"natural end advances without losing supplied queue", action="play")
quit_instance(pid, 1)
for path, url in zip(paths, urls):
rows = wait_report(path, {url}, lambda rows: bool(rows) and rows[-1]["event"] == "closed",
"queue quit drains independent stable report")
if len(rows) < 4:
raise RuntimeError(f"queue revisit must append without clobbering earlier records: {path}: {rows}")
wait_states(0, lambda _: True, "queue GUI quit leaves no stale instance")
print("PASS actual queue CLI: ordered lazy streams, manual next/previous, natural end advance and private report revisits")
resolved_fixtures(fixtures) resolved_fixtures(fixtures)
with StartupStreamingServer(fixtures) as server: with StartupStreamingServer(fixtures) as server:
if extractor and runtime: if extractor and runtime:
@@ -365,9 +420,16 @@ def check_cli_instances(root, app, fixtures):
runtime_settings = {"directory": str(root), "path": str(root) + os.pathsep + os.environ.get("PATH", "")} runtime_settings = {"directory": str(root), "path": str(root) + os.pathsep + os.environ.get("PATH", "")}
(app / "Contents/Resources/cli-check.json").write_text(json.dumps(runtime_settings)) (app / "Contents/Resources/cli-check.json").write_text(json.dumps(runtime_settings))
subprocess.run(["codesign", "--force", "--sign", "-", str(app)], check=True) subprocess.run(["codesign", "--force", "--sign", "-", str(app)], check=True)
if os.environ.get("TEREBI_CHECK_STREAM_QUEUE_ONLY") == "1":
if not extractor or not runtime:
raise RuntimeError("stream queue CLI smoke requires bundled extractor and runtime fixtures")
check_stream_queue_instance()
return
if os.environ.get("TEREBI_CHECK_RESOLVED_ONLY") == "1": if os.environ.get("TEREBI_CHECK_RESOLVED_ONLY") == "1":
check_resolved_instances(server, 0) check_resolved_instances(server, 0)
return return
if extractor and runtime:
check_stream_queue_instance()
movie = fixtures / "cache.mp4" movie = fixtures / "cache.mp4"
audio = root / "audio with spaces.wav" audio = root / "audio with spaces.wav"
shutil.copy2(fixtures / "audio.wav", audio) shutil.copy2(fixtures / "audio.wav", audio)
@@ -721,6 +783,15 @@ with tempfile.TemporaryDirectory(prefix="terebi-startup-") as directory:
descriptor = os.open(pipe, os.O_RDONLY | os.O_NONBLOCK) descriptor = os.open(pipe, os.O_RDONLY | os.O_NONBLOCK)
launch = None launch = None
try: try:
if os.environ.get("TEREBI_CHECK_COMPACT_QUEUE_ONLY") == "1":
check_window_layout(root, app, fixtures)
sys.exit(0)
if os.environ.get("TEREBI_CHECK_QUEUE_ADD_ONLY") == "1":
check_queue_add(root, app, fixtures)
sys.exit(0)
if os.environ.get("TEREBI_CHECK_STREAM_QUEUE_ONLY") == "1":
check_cli_instances(root, app, fixtures)
sys.exit(0)
with selectors.DefaultSelector() as selector: with selectors.DefaultSelector() as selector:
selector.register(descriptor, selectors.EVENT_READ) selector.register(descriptor, selectors.EVENT_READ)
launch = subprocess.Popen( launch = subprocess.Popen(
+234 -19
View File
@@ -185,10 +185,12 @@ private enum WindowDragCheck {
static func run(result: URL) { static func run(result: URL) {
resultURL = result resultURL = result
_ = NSApplication.shared _ = NSApplication.shared
if CommandLine.arguments.contains("--queue-add-only") { probe = 12 }
timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { _ in timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { _ in
MainActor.assumeIsolated { advance() } MainActor.assumeIsolated { advance() }
} }
RunLoop.main.add(timer!, forMode: .common) RunLoop.main.add(timer!, forMode: .common)
RunLoop.main.add(timer!, forMode: .modalPanel)
terebiAppMain() terebiAppMain()
} }
@@ -230,6 +232,18 @@ private enum WindowDragCheck {
} }
} }
static func clickQueueAdd(in window: NSWindow, link: Bool) {
for type in [NSEvent.EventType.leftMouseDown, .leftMouseUp] {
let event = NSEvent.mouseEvent(with: type,
location: NSPoint(x: window.contentView!.bounds.width - (link ? 163 : 183), y: 18),
modifierFlags: [], timestamp: ProcessInfo.processInfo.systemUptime,
windowNumber: window.windowNumber, context: nil,
eventNumber: 1000 + phase * 2 + (type == .leftMouseDown ? 0 : 1),
clickCount: 1, pressure: type == .leftMouseDown ? 1 : 0)!
NSApp.postEvent(NSEvent(cgEvent: event.cgEvent!)!, atStart: false)
}
}
static func point(in window: NSWindow) -> NSPoint? { static func point(in window: NSWindow) -> NSPoint? {
let content = window.contentView! let content = window.contentView!
let w = content.bounds.width let w = content.bounds.width
@@ -261,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),
@@ -295,22 +313,25 @@ 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 }
interceptDrags(in: window) interceptDrags(in: window)
model.setVolume(0) model.setVolume(0)
model.setPlayback(playing: false) model.setPlayback(playing: false)
if CommandLine.arguments.contains("--queue-add-only") { model.showsQueue = true }
window.makeKeyAndOrderFront(nil) window.makeKeyAndOrderFront(nil)
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
} }
@@ -365,6 +386,12 @@ private enum WindowDragCheck {
phase = 7 phase = 7
return return
} }
if probe == 12 {
urlWindowFrame = window.frame
clickQueueAdd(in: window, link: false)
phase = 7
return
}
probe += 1 probe += 1
if probe == names.count { finish(); return } if probe == names.count { finish(); return }
phase = 0 phase = 0
@@ -387,6 +414,48 @@ private enum WindowDragCheck {
probe += 1 probe += 1
phase = 0 phase = 0
return return
} else if probe == 12 && phase == 7 {
guard NSApp.modalWindow == nil else { finish("Queue + opened the file picker instead of expanding"); return }
clickQueueAdd(in: window, link: true)
} else if probe == 12 && phase == 8 {
guard let editor = window.firstResponder as? NSTextView, editor.isFieldEditor else { return }
editor.insertText(urlDraft, replacementRange: NSRange(location: NSNotFound, length: 0))
clickQueueAdd(in: window, link: true)
} else if probe == 12 && phase == 9 {
guard nativeView(in: window.contentView!, matching: { ($0 as? NSTextField)?.isEditable == true }) == nil else { return }
guard (window.firstResponder as? NSTextView)?.isFieldEditor != true else {
finish("Collapsing the queue URL field retained keyboard focus"); return
}
clickQueueAdd(in: window, link: true)
} else if probe == 12 && phase == 10 {
guard let editor = window.firstResponder as? NSTextView, editor.isFieldEditor else { return }
guard editor.string == urlDraft else { finish("Reopening queue URL input lost its draft"); return }
let escape = NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: [],
timestamp: ProcessInfo.processInfo.systemUptime, windowNumber: window.windowNumber,
context: nil, characters: "\u{1b}", charactersIgnoringModifiers: "\u{1b}", isARepeat: false, keyCode: 53)!
NSApp.postEvent(escape, atStart: false)
} else if probe == 12 && phase == 11 {
guard nativeView(in: window.contentView!, matching: { ($0 as? NSTextField)?.isEditable == true }) == nil else { return }
guard (window.firstResponder as? NSTextView)?.isFieldEditor != true,
window.frame == urlWindowFrame, drags == previousDrags else {
finish("Queue URL transitions retained focus, resized or dragged the window"); return
}
clickQueueAdd(in: window, link: false)
} else if probe == 12 && phase == 12 {
clickQueueAdd(in: window, link: false)
} else if probe == 12 && phase == 13 {
guard let panel = NSApp.modalWindow as? NSOpenPanel else { return }
guard panel.allowsMultipleSelection && panel.prompt == "Add to Queue" else {
panel.cancel(nil)
finish("Queue file icon did not open the multi-file queue picker"); return
}
panel.cancel(nil)
} else if probe == 12 && phase == 14 {
guard NSApp.modalWindow == nil else { return }
probe += 1
if CommandLine.arguments.contains("--queue-add-only") { finish(); return }
phase = 0
return
} }
phase += 1 phase += 1
} }
@@ -525,8 +594,12 @@ 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 animationStart: NSRect? static var animationStart: NSRect?
static var animationTarget = NSRect.zero static var animationTarget = NSRect.zero
static var sawIntermediateFrame = false static var sawIntermediateFrame = false
@@ -535,6 +608,7 @@ private enum WindowLayoutCheck {
static var queueVideoSize: NSSize? static var queueVideoSize: NSSize?
static var observingQueue = false static var observingQueue = false
static var animationQueueWidth: CGFloat = 0 static var animationQueueWidth: CGFloat = 0
static var compactQueueOnly: Bool { CommandLine.arguments.contains("--compact-queue-only") }
static var expectsAnimation: Bool { !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion } static var expectsAnimation: Bool { !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion }
// Infrastructure timeout only; assertions depend on native transition completion. // Infrastructure timeout only; assertions depend on native transition completion.
static let deadline = ContinuousClock.now.advanced(by: .seconds(30)) static let deadline = ContinuousClock.now.advanced(by: .seconds(30))
@@ -611,10 +685,31 @@ 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 && !sidebarHeaderIsVisible(in: window) { if model.isQueueTransitioning && !model.isCompact && !sidebarHeaderIsVisible(in: window) {
finish("Top-right controls left the window corner during queue animation at step \(step)"); return finish("Top-right controls left the window corner during queue animation at step \(step)"); return
} }
if let size = queueVideoSize, let video = model.videoView { if let size = queueVideoSize, let video = model.videoView {
@@ -630,8 +725,8 @@ private enum WindowLayoutCheck {
// These run-loop-delivered requests must remain responsive mid-animation. // These run-loop-delivered requests must remain responsive mid-animation.
if model.isQueueTransitioning { if model.isQueueTransitioning {
let frame = window.frame let frame = window.frame
model.showsQueue.toggle() model.perform(model.queueAction)
model.showsQueue.toggle() model.perform(model.queueAction)
guard sameFrame(window.frame, frame) else { finish("Queue reversal jumped the window"); return } guard sameFrame(window.frame, frame) else { finish("Queue reversal jumped the window"); return }
} else { } else {
model.toggleCompact() model.toggleCompact()
@@ -653,13 +748,25 @@ 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 !model.showsQueue {
guard toggleQueue(model, in: window) else { finish("Normal queue shortcut was not handled"); return }
return
}
step = 36
}
if model.showsQueue && !model.isCompact && !sidebarHeaderIsVisible(in: window) { if model.showsQueue && !model.isCompact && !sidebarHeaderIsVisible(in: window) {
finish("Sidebar pin, Cast or settings glyph is not visible at step \(step)"); return finish("Sidebar pin, Cast or settings glyph is not visible at step \(step)"); return
} }
if step >= 36 {
advanceCompactQueue(model, window: window)
return
}
if step >= 33 { if step >= 33 {
switch step { switch step {
case 33, 34: case 33, 34:
@@ -708,7 +815,9 @@ private enum WindowLayoutCheck {
observeAnimation(from: window, to: target) observeAnimation(from: window, to: target)
observingQueue = true observingQueue = true
queueVideoSize = model.videoView?.bounds.size queueVideoSize = model.videoView?.bounds.size
model.showsQueue = showsQueue guard toggleQueue(model, in: window), model.showsQueue == showsQueue else {
finish("Native queue shortcut failed in normal mode"); return
}
} }
case 1, 5: case 1, 5:
window.setFrame(NSRect(x: 100, y: 200, width: 763, height: 368), display: true) window.setFrame(NSRect(x: 100, y: 200, width: 763, height: 368), display: true)
@@ -737,6 +846,106 @@ private enum WindowLayoutCheck {
} }
step += 1 step += 1
} }
static func toggleQueue(_ model: PlayerModel, in window: NSWindow, repeatKey: Bool = false) -> Bool {
let event = NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: [.command, .shift],
timestamp: 0, windowNumber: window.windowNumber, context: nil,
characters: "L", charactersIgnoringModifiers: "l", isARepeat: repeatKey, keyCode: 37)!
return model.handleKeyEvent(event, in: window)
}
static func observeCompactQueue(_ model: PlayerModel, window: NSWindow) {
let target = model.compactWindow.queueLayout(visible: !model.queueVisible)!
observeAnimation(from: window, to: target.frame)
observingQueue = true
queueVideoSize = model.videoView?.bounds.size
if !toggleQueue(model, in: window) { finish("Native queue shortcut was not handled") }
}
static func compactQueueHasPaintedContent(_ model: PlayerModel, window: NSWindow) -> Bool {
guard let content = window.contentView else { return false }
let height = model.compactWindow.queueHeight
let region = NSRect(x: 0, y: content.isFlipped ? content.bounds.maxY - height : 0,
width: content.bounds.width, height: height)
guard let bitmap = content.bitmapImageRepForCachingDisplay(in: region) else { return false }
content.cacheDisplay(in: region, to: bitmap)
var textPixels = 0
for x in 0..<bitmap.pixelsWide {
for y in 0..<bitmap.pixelsHigh {
if let color = bitmap.colorAt(x: x, y: y)?.usingColorSpace(.deviceRGB),
color.alphaComponent > 0.5,
min(color.redComponent, color.greenComponent, color.blueComponent) > 0.5 {
textPixels += 1
}
}
}
return textPixels > 30
}
static func advanceCompactQueue(_ model: PlayerModel, window: NSWindow) {
switch step {
case 36, 42:
guard !model.isFullScreen, !model.isCompact else { finish("Expanded compact/fullscreen restoration failed"); return }
if step == 42, !sameFrame(window.frame, normalFrame) {
finish("Expanded queue corrupted the normal restoration frame"); return
}
normalFrame = window.frame
observeAnimation(from: window, to: CompactWindowController.initialFrame(
aspect: model.state.video_aspect_ratio,
visibleFrame: (window.screen ?? NSScreen.main!).visibleFrame, width: 320))
model.toggleCompact()
case 37, 43:
guard model.isCompact, !model.queueVisible, model.compactWindow.queueHeight == 0 else {
finish("Compact entry did not default to a hidden queue"); return
}
compactFrame = window.frame
observeCompactQueue(model, window: window)
case 38, 40, 44:
let height = model.compactWindow.queueHeight
guard model.queueVisible, height > 0, model.showsQueue,
abs(window.frame.maxY - compactFrame.maxY) < 1,
abs(window.frame.width / (window.frame.height - height) - model.state.video_aspect_ratio) < 0.01,
let video = model.videoView, abs(video.bounds.height - compactFrame.height) < 2 else {
finish("Compact queue did not expand below the unchanged video at step \(step)"); return
}
guard compactQueueHasPaintedContent(model, window: window) else {
finish("Expanded compact queue did not paint its native rows and footer"); return
}
guard toggleQueue(model, in: window, repeatKey: true), model.queueVisible else {
finish("Held queue shortcut toggled repeatedly"); return
}
if step == 38 {
observeCompactQueue(model, window: window)
} else if step == 40 {
if compactQueueOnly {
observeAnimation(from: window, to: normalFrame)
model.toggleCompact()
step = 41
} else {
// The explicit fullscreen action must discard dropdown geometry before entering the native Space.
model.toggleFullScreen()
}
} else {
observeAnimation(from: window, to: normalFrame)
model.toggleCompact()
}
case 39:
guard !model.queueVisible, sameFrame(window.frame, compactFrame) else {
finish("Compact queue did not collapse to its original video frame"); return
}
observeCompactQueue(model, window: window)
case 41:
guard model.isFullScreen, !model.isCompact, model.compactWindow.queueHeight == 0 else {
finish("Fullscreen retained compact dropdown geometry"); return
}
model.toggleFullScreen()
default:
guard !model.isCompact, model.queueVisible, sameFrame(window.frame, normalFrame) else {
finish("Normal queue visibility or restoration frame was lost"); return
}
finish(); return
}
step += 1
}
} }
@MainActor @MainActor
@@ -901,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)) }
@@ -925,6 +1135,8 @@ private enum CLIInstanceCheck {
case "queue": case "queue":
model.showsQueue = command["visible"] as! Bool model.showsQueue = command["visible"] as! Bool
model.revealControls() model.revealControls()
case "queueAdvance":
model.advanceQueue(command["offset"] as! Int)
case "caption": case "caption":
guard let view = window.contentView, let selector = captionSelector(in: view), guard let view = window.contentView, let selector = captionSelector(in: view),
let cell = selector.cell as? NSPopUpButtonCell, let menu = selector.menu else { let cell = selector.cell as? NSPopUpButtonCell, let menu = selector.menu else {
@@ -960,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)
@@ -1026,12 +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,
"window": windowSnapshot(windows.first), "queueURLs": model.queue.items.map(\.url.absoluteString),
"queueIndex": model.queue.items.firstIndex { $0.id == model.queue.currentID } ?? -1,
"window": windowSnapshot(presentation),
"onTop": model.isOnTop, "onTop": model.isOnTop,
] ]
do { do {
+17 -3
View File
@@ -33,7 +33,7 @@ struct AutomaticQualityPlaybackChecks {
let defaults = UserDefaults(suiteName: suite)! let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) } defer { defaults.removePersistentDomain(forName: suite) }
let clock = FixedQualityClock() let clock = FixedQualityClock()
let model = PlayerModel(defaults: defaults, qualityClock: { clock.now }) let model = PlayerModel(defaults: defaults, youtubeTitleResolver: { _ in nil }, qualityClock: { clock.now })
defer { model.shutdown() } defer { model.shutdown() }
model.setVolume(0) model.setVolume(0)
// Control only the external extractor; media decoding and all model transitions stay real. // Control only the external extractor; media decoding and all model transitions stay real.
@@ -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")
+34 -3
View File
@@ -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:
+43 -4
View File
@@ -201,8 +201,9 @@ struct CastPlaybackCheck {
_ = NSApplication.shared _ = NSApplication.shared
NSApp.setActivationPolicy(.prohibited) NSApp.setActivationPolicy(.prohibited)
require(castVolumeSliderFeedbackCheck(), "Native slider keeps user position during tracking and applies receiver volume when idle") require(castVolumeSliderFeedbackCheck(), "Native slider keeps user position during tracking and applies receiver volume when idle")
require(playbackSliderHitCheck(), "Progress thumb centers on the cursor for clicks and drags, clamped to track endpoints")
if CommandLine.arguments.count == 2, CommandLine.arguments[1] == "--slider-regression" { if CommandLine.arguments.count == 2, CommandLine.arguments[1] == "--slider-regression" {
print("PASS: native volume slider tracking preserves user input and idle receiver feedback") print("PASS: centered progress clicks and drags, endpoint clamping, and volume tracking feedback")
return return
} }
let source = URL(fileURLWithPath: CommandLine.arguments[1]) let source = URL(fileURLWithPath: CommandLine.arguments[1])
@@ -210,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) 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" {
@@ -222,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")
@@ -231,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") {
@@ -239,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")
@@ -248,13 +271,29 @@ struct CastPlaybackCheck {
require(model.setPlayback(playing: false), "remote pause accepted") require(model.setPlayback(playing: false), "remote pause accepted")
try await until(model, "receiver pause acknowledged") { model.state.status == PLAYER_PAUSED && model.playbackIntent == nil } try await until(model, "receiver pause acknowledged") { model.state.status == PLAYER_PAUSED && model.playbackIntent == nil }
requirePlaybackPower(system: false, display: false, "receiver pause permits sleep") requirePlaybackPower(system: false, display: false, "receiver pause permits sleep")
let opening = MiruroInterval(kind: .intro, start: 23, end: 24.25, episode: 1)
model.supplyMetadataForCheck(MiruroMetadata(title: nil, intervals: [opening]), episode: 1)
require(model.seekRelative(24 - model.state.position), "enter opening on receiver")
try await until(model, "receiver offers opening only after acknowledging seek") {
!model.state.seek_pending && model.segmentToSkip == opening
}
require(model.skipSegment(), "segment skip queues receiver seek")
try await until(model, "receiver acknowledges segment end without starting playback") {
!model.state.seek_pending && abs(model.state.position - 24.25) < 0.15 &&
model.state.status == PLAYER_PAUSED && model.segmentToSkip == nil
}
model.stopCasting() model.stopCasting()
try await until(model, "local decoder restores receiver position paused") { try await until(model, "local decoder restores receiver position paused") {
model.castDevice == nil && model.state.status == PLAYER_PAUSED && !model.state.seek_pending && model.castDevice == nil && model.state.status == PLAYER_PAUSED && !model.state.seek_pending &&
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")
+21
View File
@@ -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")
+190 -52
View File
@@ -96,15 +96,23 @@ struct CompactWindowChecks {
// Run the real AppKit completion callbacks. The monotonic deadline is only a hang watchdog. // Run the real AppKit completion callbacks. The monotonic deadline is only a hang watchdog.
static func settled(_ controller: CompactWindowController, compact: Bool, static func settled(_ controller: CompactWindowController, compact: Bool,
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") 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")
@@ -504,12 +580,74 @@ struct CompactWindowChecks {
} }
} }
static func compactQueue() throws {
let fixture = try WindowFixture()
let controller = CompactWindowController(reduceMotion: { true })
let normal = fixture.window
defer {
controller.restore(resumeFullScreen: false, animated: false)
normal.close()
}
controller.attach(normal)
controller.toggle(aspect: 16.0 / 9.0)
let window = controller.presentationWindow!
try require(controller.queueHeight == 0, "compact entry hides the queue")
let videoFrame = window.frame
let expanded = controller.queueLayout(visible: true)!
controller.updateQueue(height: expanded.height, frame: expanded.frame)
try require(close(window.frame.maxY, videoFrame.maxY) && close(window.frame.width, videoFrame.width)
&& close(window.frame.height - controller.queueHeight, videoFrame.height),
"queue expands downwards without moving or resizing the compact video")
try require(window.aspectRatio == .zero && close(window.minSize.height, 90 + controller.queueHeight),
"expanded queue constrains video, not the combined window aspect")
for horizontal in [true, false] {
let start = window.frame
window.delegate?.windowWillStartLiveResize?(Notification(name: NSWindow.willStartLiveResizeNotification, object: window))
for offset in [CGFloat(20), 21, 22, 21, 0, -10] {
let proposed = NSSize(width: start.width + (horizontal ? offset : 0),
height: start.height + (horizontal ? 0 : offset))
let size = window.delegate!.windowWillResize!(window, to: proposed)
try require(close(size.width / (size.height - controller.queueHeight), 16.0 / 9.0),
"both resize axes preserve the compact video's aspect with queue open")
try require(close(horizontal ? size.width : size.height, horizontal ? proposed.width : proposed.height),
"queue resize follows the chosen edge continuously")
window.setFrame(NSRect(origin: start.origin, size: size), display: false)
}
window.delegate?.windowDidEndLiveResize?(Notification(name: NSWindow.didEndLiveResizeNotification, object: window))
}
controller.updateAspect(4.0 / 3.0)
try require(abs(window.frame.height - controller.queueHeight - window.frame.width / (4.0 / 3.0)) <= 1,
"metadata changes aspect only in the video above the queue: \(window.frame), queue \(controller.queueHeight)")
let hidden = controller.queueLayout(visible: false)!
let top = window.frame.maxY
controller.updateQueue(height: hidden.height, frame: hidden.frame)
try require(controller.queueHeight == 0 && close(window.frame.maxY, top)
&& abs(window.frame.height - window.frame.width / (4.0 / 3.0)) <= 1,
"hiding the queue contracts upwards and restores native aspect locking")
var atBottom = window.frame
atBottom.origin.y = (window.screen ?? NSScreen.main!).visibleFrame.minY + 16
window.setFrame(atBottom, display: false)
let bounded = controller.queueLayout(visible: true)!
controller.updateQueue(height: bounded.height, frame: bounded.frame)
try require(window.frame.minY >= (window.screen ?? NSScreen.main!).visibleFrame.minY + 16
&& abs(window.frame.height - controller.queueHeight - window.frame.width / (4.0 / 3.0)) <= 1,
"queue remains onscreen at the lower edge without distorting the video")
controller.restore(resumeFullScreen: false)
try fixture.restored()
controller.toggle(aspect: 4.0 / 3.0)
try require(controller.queueHeight == 0, "each compact entry resets queue visibility")
try fixture.compact(controller, aspect: 4.0 / 3.0)
controller.restore(resumeFullScreen: false)
try fixture.restored()
}
static func main() throws { static func main() throws {
try geometry() try geometry()
try windowBehavior() try windowBehavior()
try reducedMotion() try reducedMotion()
try rememberedSize() try rememberedSize()
try continuousQueueResize() try continuousQueueResize()
try compactQueue()
print("Compact window checks passed") print("Compact window checks passed")
} }
} }
+70 -2
View File
@@ -43,10 +43,16 @@ struct ConfigurationChecks {
try require(PlayerAction(name: "open_youtube") == nil, "obsolete YouTube action has no alias") try require(PlayerAction(name: "open_youtube") == nil, "obsolete YouTube action has no alias")
try require(bindings.shortcuts(for: .openStream) == [try KeyChord("cmd+shift+o")], try require(bindings.shortcuts(for: .openStream) == [try KeyChord("cmd+shift+o")],
"stream action preserves its shortcut") "stream action preserves its shortcut")
try require(PlayerAction(name: "show_queue") == .showQueue && PlayerAction.hideQueue.name == "hide_queue",
"queue visibility actions round-trip through configuration")
try require(Set(bindings.actions(for: try KeyChord("cmd+shift+l"))) == [.showQueue, .hideQueue]
&& !PlayerAction.showQueue.allowsRepeat && !PlayerAction.hideQueue.allowsRepeat,
"queue shortcut toggles without auto-repeat")
try requireError(["open_youtube"]) { _ = try KeyBindings(overrides: ["open_youtube": []]) } try requireError(["open_youtube"]) { _ = try KeyBindings(overrides: ["open_youtube": []]) }
let inversePairs: [(PlayerAction, PlayerAction)] = [ let inversePairs: [(PlayerAction, PlayerAction)] = [
(.play, .pause), (.mute, .unmute), (.enterFullscreen, .exitFullscreen), (.play, .pause), (.mute, .unmute), (.enterFullscreen, .exitFullscreen),
(.enterCompact, .exitCompact), (.enableAlwaysOnTop, .disableAlwaysOnTop), (.enterCompact, .exitCompact), (.enableAlwaysOnTop, .disableAlwaysOnTop),
(.showQueue, .hideQueue),
(.showBufferInfo, .hideBufferInfo), (.enableCompactSnapping, .disableCompactSnapping), (.showBufferInfo, .hideBufferInfo), (.enableCompactSnapping, .disableCompactSnapping),
(.enableAutomaticQuality, .disableAutomaticQuality) (.enableAutomaticQuality, .disableAutomaticQuality)
] ]
@@ -122,6 +128,58 @@ struct ConfigurationChecks {
"printed TOML is directly usable and preserves all default behavior and bindings") "printed TOML is directly usable and preserves all default behavior and bindings")
try require(!defaults.showBufferInfo && !defaults.automaticQuality && defaults.snapToEdges, try require(!defaults.showBufferInfo && !defaults.automaticQuality && defaults.snapToEdges,
"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,
"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")
for (settings, video, captions) in [
("video_provider = \"new-Host_2\"", "new-Host_2", ""),
("caption_provider = \"moon\"", "", "moon"),
("video_provider = \"sun\"\ncaption_provider = \"moon\"", "sun", "moon"),
("video_provider = \"sun:dub\"\ncaption_provider = \"bee:ssub\"", "sun:dub", "bee:ssub"),
("video_provider = \"new-Host_2:sub\"", "new-Host_2:sub", ""),
("video_provider = \"\"\ncaption_provider = \"\"", "", ""),
("video_provider = \"\(String(repeating: "a", count: 128))\"", String(repeating: "a", count: 128), "")
] {
try Data("[miruro]\n\(settings)\n".utf8).write(to: miruro)
try require(try PlayerConfiguration.load(from: miruro)
== PlayerConfiguration(miruroVideoProvider: video, miruroCaptionProvider: captions),
"partial Miruro settings accept provider preferences and explicit provider:track choices")
}
for key in ["video_provider", "caption_provider"] {
for name in ["with space", " sun", "sun ", "é", "host.name", "host/name", ":ssub", "sun:", "sun:auto",
"sun:DUB", "sun:ssub:sub", "sun:sub ", String(repeating: "a", count: 129)] {
try Data("[miruro]\n\(key) = \"\(name)\"\n".utf8).write(to: miruro)
try requireLoadError(at: miruro, "invalid Miruro provider")
}
for value in ["true", "1", "[\"sun\"]", "{}"] {
try Data("[miruro]\n\(key) = \(value)\n".utf8).write(to: miruro)
try requireLoadError(at: miruro, "non-string Miruro provider")
}
}
for (value, expected) in [("time", CompactTitleProgress.time), ("percentage", .percentage), ("none", .none)] { for (value, expected) in [("time", CompactTitleProgress.time), ("percentage", .percentage), ("none", .none)] {
let progress = root.appendingPathComponent("compact-progress-\(value).toml") let progress = root.appendingPathComponent("compact-progress-\(value).toml")
try Data("[ui]\ncompact_title_progress = \"\(value)\"\n".utf8).write(to: progress) try Data("[ui]\ncompact_title_progress = \"\(value)\"\n".utf8).write(to: progress)
@@ -144,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")
@@ -158,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")
@@ -198,7 +259,7 @@ struct ConfigurationChecks {
try require(try PlayerConfiguration.load(from: nested).keys == expectedKeys && keySession.keys == defaults.keys, try require(try PlayerConfiguration.load(from: nested).keys == expectedKeys && keySession.keys == defaults.keys,
"key session overrides never persist") "key session overrides never persist")
let emptySections = root.appendingPathComponent("empty-sections.toml") let emptySections = root.appendingPathComponent("empty-sections.toml")
try Data("[ui]\n[keys]\n[playback]\n".utf8).write(to: emptySections) try Data("[ui]\n[keys]\n[playback]\n[miruro]\n".utf8).write(to: emptySections)
try require(try PlayerConfiguration.load(from: emptySections) == defaults, "empty nested sections use defaults") try require(try PlayerConfiguration.load(from: emptySections) == defaults, "empty nested sections use defaults")
let reordered = root.appendingPathComponent("reordered.toml") let reordered = root.appendingPathComponent("reordered.toml")
try Data(""" try Data("""
@@ -220,6 +281,9 @@ struct ConfigurationChecks {
("syntax", "[playback]\nautomatic_quality = [\n", ["automatic_quality"]), ("syntax", "[playback]\nautomatic_quality = [\n", ["automatic_quality"]),
("duplicate", "[playback]\nautomatic_quality = true\n\"automatic_quality\" = false\n", ["automatic_quality"]), ("duplicate", "[playback]\nautomatic_quality = true\n\"automatic_quality\" = false\n", ["automatic_quality"]),
("unknown", "unknown_setting = true\n", ["unknown_setting"]), ("unknown", "unknown_setting = true\n", ["unknown_setting"]),
("miruro-scalar", "miruro = true\n", ["miruro"]),
("miruro-unknown", "[miruro]\nprovider = \"sun\"\n", ["miruro.provider"]),
("miruro-nested", "[miruro.video_provider]\nname = \"sun\"\n", []),
("legacy-buffer", "show_buffer_info = true\n", ["show_buffer_info"]), ("legacy-buffer", "show_buffer_info = true\n", ["show_buffer_info"]),
("legacy-snap", "snap_to_edges = false\n", ["snap_to_edges"]), ("legacy-snap", "snap_to_edges = false\n", ["snap_to_edges"]),
("legacy-quality", "automatic_quality = true\n", ["automatic_quality"]), ("legacy-quality", "automatic_quality = true\n", ["automatic_quality"]),
@@ -230,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"]),
+253
View File
@@ -0,0 +1,253 @@
import Foundation
@main
struct MiruroMetadataChecks {
struct Failure: Error { let message: String }
struct Promise { let id: Int }
static let mediaID = "OERe_a0YS4h-DlsBDOeXwX7HOO6316tw"
static let canonical = "/watch/\(mediaID)/the-apothecary-diaries-season-3?ep=1"
static func require(_ condition: @autoclosure () -> Bool, _ message: String) throws {
if !condition() { throw Failure(message: message) }
}
static func reject(_ message: String, _ operation: () throws -> Void) throws {
do { try operation() }
catch {
try require(!error.localizedDescription.contains("secret"), "errors do not expose untrusted URLs")
return
}
throw Failure(message: "Accepted \(message)")
}
// Sanitized observed watch schema; every envelope deliberately owns a fresh devalue table.
static func table(_ root: Any) -> [Any] {
var result: [Any] = []
func append(_ value: Any) -> Int {
let index = result.count
result.append(NSNull())
if let promise = value as? Promise {
let reference = append(promise.id)
result[index] = ["Promise", reference] as [Any]
} else if let object = value as? [String: Any] {
var references: [String: Int] = [:]
for key in object.keys.sorted() { references[key] = append(object[key]!) }
result[index] = references
} else if let array = value as? [Any] {
let references = array.map { append($0) }
result[index] = references
} else {
result[index] = value
}
return index
}
_ = append(root)
return result
}
static func wire(_ envelopes: [[String: Any]]) throws -> Data {
var body = Data()
for envelope in envelopes {
body.append(try JSONSerialization.data(withJSONObject: envelope, options: [.sortedKeys]))
body.append(10)
}
return body
}
static func initial(media: Any = ["id": mediaID, "title": ["english": "The Apothecary Diaries Season 3"]],
episodes: Any = Promise(id: 23)) -> [String: Any] {
["type": "data", "nodes": [NSNull(), ["type": "data", "data": table([
"media": media, "episodeNumber": 1, "episodesData": episodes,
"sourcesData": Promise(id: 99), "seasonsData": Promise(id: 8)
])]]]
}
static func chunk(_ payload: Any, id: Int = 23) -> [String: Any] {
["type": "chunk", "id": id, "data": table(payload)]
}
static var rows: [[String: Any]] { [
["episode": 1, "type": "op", "start": 63.313, "end": 153.237],
["episode": 1, "type": "ed", "start": 1335.252, "end": 1425.384]
] }
static func payload(_ intervals: Any = rows) -> [String: Any] {
["currentEpisode": ["number": 1, "title": "Locusts"],
"episodes": [["number": 2, "title": "Not selected"]], "aniskip": intervals]
}
static func decode(_ envelopes: [[String: Any]]) throws -> MiruroMetadata {
try MiruroMetadataResolver.decode(wire(envelopes), mediaID: mediaID, episode: 1)
}
static func checks() async throws {
let unrelated: [String: Any] = ["type": "chunk", "id": 99,
"data": [["streams": 1], ["UnsupportedSourceTag", 999999]]]
let fixtures = [initial(), chunk(payload()), unrelated, chunk(["ignored": "season"], id: 8)]
let metadata = try decode(fixtures)
try require(metadata.title == "The Apothecary Diaries Season 3 — Episode 1: Locusts", "descriptive media and selected episode title")
try require(metadata.intervals.count == 2, "observed fractional opening and ending decoded")
try require(metadata.intervals[0].start == 63.313 && metadata.intervals[1].end == 1425.384, "fractional seconds preserved")
try require(metadata.intervals.allSatisfy { $0.votes == nil && $0.provider == nil && $0.referenceLength == nil }, "absent provenance is not invented")
let reordered = try decode([unrelated, chunk(payload()), initial()])
try require(reordered.title == metadata.title && reordered.intervals == metadata.intervals, "independent and out-of-order promise IDs")
let inline = try decode([initial(episodes: payload())])
try require(inline.intervals == metadata.intervals, "inline episode metadata")
let titleOnly = try decode([initial()])
try require(titleOnly.title == "The Apothecary Diaries Season 3 — Episode 1" && titleOnly.intervals.isEmpty, "unresolved optional episode promise preserves series title")
let rejected = try decode([initial(), ["type": "chunk", "id": 23, "error": ["message": "secret"]]])
try require(rejected.title == titleOnly.title && rejected.intervals.isEmpty, "rejected episode promise preserves title")
let duplicated = try decode([initial(), chunk(payload()), chunk(payload())])
try require(duplicated.title == titleOnly.title && duplicated.intervals.isEmpty, "ambiguous duplicate promise is not selected")
let segmentsOnly = try decode([initial(media: NSNull()), chunk(["aniskip": rows])])
try require(segmentsOnly.title == nil && segmentsOnly.intervals.count == 2, "interval enrichment does not require title")
let episodeOnly = try decode([initial(media: NSNull()), chunk(payload())])
try require(episodeOnly.title == "Episode 1: Locusts", "episode title without media title")
let fallbackTitle = try decode([initial(media: ["title": ["english": " ", "romaji": "Kusuriya"]]), chunk(payload())])
try require(fallbackTitle.title?.hasPrefix("Kusuriya —") == true, "title language fallback")
let selected = try decode([initial(), chunk(["currentEpisode": ["number": 2, "title": "Wrong"],
"episodes": [["number": 2, "title": "Wrong"], ["number": 1, "title": "Correct"]]])])
try require(selected.title?.hasSuffix(": Correct") == true, "episode titles are selected by returned number")
let mixedRows: [Any] = [NSNull(), "bad", ["episode": 1, "type": "op", "start": "bad", "end": 100],
["episode": true, "type": "op", "start": 0, "end": 100],
["episode": 1, "type": "recap", "start": 0, "end": 100]] + rows
let partial = try decode([initial(), chunk(payload(mixedRows))])
try require(partial.title == metadata.title && partial.intervals == metadata.intervals, "malformed rows and unknown kinds do not poison usable metadata")
try reject("wrong returned media identity") {
_ = try decode([initial(media: ["id": "different", "title": "Wrong"]), chunk(payload())])
}
try reject("oversized body") {
_ = try MiruroMetadataResolver.decode(Data(repeating: 32, count: MiruroMetadataResolver.bodyLimit + 1), mediaID: mediaID, episode: 1)
}
try reject("deep JSON") {
_ = try MiruroMetadataResolver.decode(Data((String(repeating: "[", count: 65) + "0" + String(repeating: "]", count: 65)).utf8), mediaID: mediaID, episode: 1)
}
try reject("malformed JSON") {
_ = try MiruroMetadataResolver.decode(Data("{broken".utf8), mediaID: mediaID, episode: 1)
}
let cycle: [String: Any] = ["type": "chunk", "id": 23, "data": [["Promise", 1], 23]]
let cyclic = try decode([initial(), cycle])
try require(cyclic.title == titleOnly.title && cyclic.intervals.isEmpty, "promise cycles are depth bounded and optional")
let amplified: [String: Any] = ["type": "chunk", "id": 23,
"data": [["aniskip": 1], Array(repeating: 2, count: 70000), ["type": 3], "op"]]
let bounded = try decode([initial(), amplified])
try require(bounded.title == titleOnly.title && bounded.intervals.isEmpty, "repeated-reference expansion is bounded")
try normalizationChecks()
try redirectChecks()
let responseURL = URL(string: "https://barelystarted.miruro.tv\(canonical)")!
for mime in ["application/json", "text/sveltekit-data"] {
try MiruroMetadataResolver.validateResponse(HTTPURLResponse(url: responseURL, statusCode: 200,
httpVersion: nil, headerFields: ["Content-Type": mime])!)
}
for (status, type, size) in [(403, "text/html", "12"), (302, "application/json", "12"),
(200, "text/html", "12"), (200, "application/json", "2097153")] {
try reject("protection, redirect or oversized response") {
try MiruroMetadataResolver.validateResponse(HTTPURLResponse(url: responseURL, statusCode: status,
httpVersion: nil, headerFields: ["Content-Type": type, "Content-Length": size])!)
}
}
let cancelled = Task {
withUnsafeCurrentTask { $0?.cancel() }
return try await MiruroMetadataResolver.resolve(mediaID: mediaID, episode: 1)
}
do { _ = try await cancelled.value; throw Failure(message: "Cancellation accepted") }
catch is CancellationError {}
print("PASS: native optional metadata protocol, independent promises, safe redirects, bounded decoding and interval normalization")
}
static func normalizationChecks() throws {
func interval(_ kind: MiruroInterval.Kind = .intro, _ start: Double = 0, _ end: Double = 10,
episode: Int = 1, votes: Double? = nil, provider: String? = nil,
length: Double? = nil) -> MiruroInterval {
MiruroInterval(kind: kind, start: start, end: end, episode: episode,
provider: provider, votes: votes, referenceLength: length)
}
let valid = interval(.intro, 10, 100)
for duration in [0, -1, Double.nan, .infinity] {
try require(MiruroMetadata(title: nil, intervals: [valid]).visibleIntervals(episode: 1, duration: duration).isEmpty, "wait for finite positive actual duration")
}
let invalid = [interval(.intro, -10, 0), interval(.intro, 100, 110),
interval(.intro, 10, 10), interval(.intro, 20, 10),
interval(.intro, .nan, 10), interval(.intro, 0, .infinity),
interval(episode: 2), interval(votes: -1), interval(votes: .nan),
interval(provider: "SomeCrunchyrollProvider"), interval(length: 106)]
try require(MiruroMetadata(title: nil, intervals: invalid).visibleIntervals(episode: 1, duration: 100).isEmpty, "bounds, episode, votes, provenance and reference-length rejection")
let metadata = MiruroMetadata(title: nil, intervals: [valid])
try require(metadata.visibleIntervals(episode: 1, duration: 100) == [valid], "end equal to duration allowed")
try require(metadata.visibleIntervals(episode: 1, duration: 99) == [interval(.intro, 10, 99)], "duration shrink clips the visible interval")
try require(metadata.visibleIntervals(episode: 1, duration: 120) == [valid] && metadata.intervals == [valid], "duration growth restores unchanged raw timestamps")
let overlapping = MiruroMetadata(title: nil, intervals: [
interval(.intro, -5.5, 10.25), interval(.outro, 90.75, 110.5)])
try require(overlapping.visibleIntervals(episode: 1, duration: 100) == [
interval(.intro, 0, 10.25), interval(.outro, 90.75, 100)], "partial overlaps clip at both playback boundaries")
try require(overlapping.visibleIntervals(episode: 1, duration: 120) == [
interval(.intro, 0, 10.25), interval(.outro, 90.75, 110.5)], "growth recomputes clipping from raw timestamps")
try require(MiruroMetadata(title: nil, intervals: [interval(.intro, -10, 110)])
.visibleIntervals(episode: 1, duration: 100) == [interval(.intro, 0, 100)], "interval spanning the video clips to the playable range")
let first = interval(.intro, 5, 15, votes: 4), tied = interval(.intro, 20, 30, votes: 4)
let ending = interval(.outro, 12, 40, votes: 2)
let ranked = MiruroMetadata(title: nil, intervals: [interval(), first, tied, ending, interval(.outro, 50, 60, votes: 1)])
try require(ranked.visibleIntervals(episode: 1, duration: 100) == [first, ending], "descending votes, stable ties, intro then outro, no merging overlap")
let optional = [interval(length: 105), interval(.outro, 80, 100, length: 0)]
try require(MiruroMetadata(title: nil, intervals: optional).visibleIntervals(episode: 1, duration: 100) == optional, "five-percent equality and absent/nonpositive length handling")
let raw = try decode([initial(), chunk(payload([
["episode": 1, "type": "mixed-op", "start": 1, "end": 10, "votes": 3, "provider": "aniskip", "episode_length": 100],
["episode": 1, "type": "mixed-ed", "start": 90, "end": 100]
]))])
try require(raw.intervals.count == 2 && raw.intervals[0].rawKind == "mixed-op" && raw.intervals[0].votes == 3 && raw.intervals[0].provider == "aniskip" && raw.intervals[0].referenceLength == 100, "evidenced mixed kinds and optional fields preserved")
}
static func redirectChecks() throws {
let envelope = try wire([["type": "redirect", "location": canonical]])
guard case .redirect(let location) = try MiruroMetadataResolver.decodeRoute(envelope, mediaID: mediaID, episode: 1) else {
throw Failure(message: "HTTP-200 route redirect not recognized")
}
var route = try MiruroMetadataResolver.Route(mediaID: mediaID, episode: 1)
try require(route.url.path.hasSuffix("/episode/__data.json"), "placeholder data route")
try route.follow(location)
try require(route.url.path.hasSuffix("/the-apothecary-diaries-season-3/__data.json") && route.url.query == "ep=1", "canonical data route preserves episode")
try reject("redirect loop") { try route.follow(canonical) }
for unsafe in ["https://evil.example\(canonical)", "//evil.example\(canonical)",
"http://barelystarted.miruro.tv\(canonical)", "https://user:secret@barelystarted.miruro.tv\(canonical)",
canonical.replacingOccurrences(of: mediaID, with: "other"), canonical.replacingOccurrences(of: "ep=1", with: "ep=2"),
canonical + "&ep=1", canonical + "#secret", canonical.replacingOccurrences(of: "ep=1", with: "ep=%31"),
canonical.replacingOccurrences(of: "/watch/", with: "/info/"), "/", canonical + "\n",
canonical.replacingOccurrences(of: "/the-apothecary", with: "/%74he-apothecary")] {
try reject("unsafe canonical redirect") {
var route = try MiruroMetadataResolver.Route(mediaID: mediaID, episode: 1)
try route.follow(unsafe)
}
}
var bounded = try MiruroMetadataResolver.Route(mediaID: mediaID, episode: 1)
for index in 0..<MiruroMetadataResolver.redirectLimit { try bounded.follow("/watch/\(mediaID)/slug-\(index)?ep=1") }
try reject("too many redirects") { try bounded.follow("/watch/\(mediaID)/last?ep=1") }
}
static func main() async {
do {
let args = Array(CommandLine.arguments.dropFirst())
if args.count == 2, args[0] == "--live" {
guard case .miruro(let id, let episode) = try StreamURL.parse(args[1]) else {
throw Failure(message: "--live requires a Miruro episode URL")
}
let metadata = try await MiruroMetadataResolver.resolve(mediaID: id, episode: episode)
// JSON escaping prevents terminal control injection; source payloads/URLs are never printed.
let projection: [String: Any] = ["title": metadata.title as Any? ?? NSNull(),
"intervals": metadata.intervals.map { row -> [String: Any] in
["kind": row.kind == .intro ? "intro" : "outro", "episode": row.episode, "start": row.start, "end": row.end]
}]
let data = try JSONSerialization.data(withJSONObject: projection, options: [.sortedKeys])
print(String(decoding: data, as: UTF8.self))
} else if args.isEmpty {
try await checks()
} else {
throw Failure(message: "Usage: miruro-metadata-check [--live EPISODE_URL]")
}
} catch {
let message = (error as? Failure)?.message ?? "Miruro optional metadata check failed."
FileHandle.standardError.write(Data("FAIL: \(message)\n".utf8))
exit(1)
}
}
}
+22
View File
@@ -105,6 +105,28 @@ struct PlaybackQueueChecks {
"replace discards previous queue and starts new selection") "replace discards previous queue and starts new selection")
queue.replace([]) queue.replace([])
require(queue.items.isEmpty && queue.currentID == nil, "empty replacement clears queue") require(queue.items.isEmpty && queue.currentID == nil, "empty replacement clears queue")
let youtube = URL(string: "https://www.youtube.com/watch?v=AAAAAAAAAAA")!
let miruro = URL(string: "https://barelystarted.miruro.tv/watch/fixture/episode?ep=2")!
queue.replaceStreams([(url: miruro, label: "Episode 2"), (url: youtube, label: "YouTube")])
require(queue.items.map(\.url) == [miruro, youtube], "stream order is preserved without file filtering or sorting")
require(queue.currentItem?.displayName == "Episode 2", "stream rows have descriptive labels")
let streamID = queue.currentID!
queue.updateLabel("Resolved episode title", for: streamID)
require(queue.currentID == streamID && queue.currentItem?.displayName == "Resolved episode title",
"metadata updates labels without changing queue identity")
require(queue.advance(1) == youtube && queue.advance(-1) == miruro, "stream navigation uses supplied order")
queue.move(fromOffsets: IndexSet(integer: 0), toOffset: 2)
require(queue.currentID == streamID && queue.items.map(\.url) == [youtube, miruro], "stream reorder preserves selection")
require(queue.remove(streamID) == nil && queue.items.map(\.url) == [youtube], "removing current final stream stops")
let retainedID = queue.currentID
queue.appendStreams([(url: miruro, label: "Last episode"), (url: youtube, label: "Last video")])
require(queue.items.map(\.url) == [youtube, miruro, youtube] && queue.currentID == retainedID,
"stream append retains stopped selection and exact caller order")
queue.select(queue.items[0].id)
let playingID = queue.currentID
queue.appendStreams([(url: miruro, label: "Again")])
require(queue.currentID == playingID && queue.items.last?.url == miruro,
"stream append preserves active identity")
print("playback queue checks passed") print("playback queue checks passed")
} }
} }
+111 -1
View File
@@ -36,6 +36,13 @@ 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")!
require(try opened([generic.absoluteString]) == generic &&
opened(["--url", generic.absoluteString]) == generic,
"positional and explicit third-party URLs reach extraction unchanged")
require(try opened(["--new-instance"], newInstance: true) == nil, "an independent launch can omit media") require(try opened(["--new-instance"], newInstance: true) == nil, "an independent launch can omit media")
require(try opened(["--new-instance", movie.lastPathComponent], newInstance: true) == movie, require(try opened(["--new-instance", movie.lastPathComponent], newInstance: true) == movie,
"independent local launches preserve relative paths and spaces") "independent local launches preserve relative paths and spaces")
@@ -108,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"],
@@ -116,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 {
@@ -134,9 +149,104 @@ struct PlayerCommandChecks {
fatalError("FAIL: FIFO source accepted") fatalError("FAIL: FIFO source accepted")
} catch {} } catch {}
let queueFile = directory.appendingPathComponent("queue.json")
let resolvedDirectory = realpath(directory.path, nil)!
defer { free(resolvedDirectory) }
let privateDirectory = URL(fileURLWithPath: String(cString: resolvedDirectory), isDirectory: true)
require(chmod(privateDirectory.path, 0o700) == 0, "queue reports require a private owned parent")
let youtubeReport = privateDirectory.appendingPathComponent("youtube.jsonl").path
let miruroReport = privateDirectory.appendingPathComponent("miruro.jsonl").path
let entries: [[String: Any]] = [
["url": miruro.absoluteString, "progress_file": miruroReport],
["url": canonical.absoluteString, "progress_file": youtubeReport]
]
func saveQueue(_ value: [String: Any]) throws {
try JSONSerialization.data(withJSONObject: value).write(to: queueFile)
}
try saveQueue(["version": 1, "items": entries])
guard case .launchQueue(let deliveredQueue, let queueIndependent, let queueBypass) =
try PlayerCommand.parse(["--new-instance", "--no-config", "--queue-file", queueFile.path]) else {
fatalError("FAIL: expected stream queue launch")
}
require(deliveredQueue == queueFile && queueIndependent && queueBypass, "queue CLI preserves independent delivery")
guard case .enqueue(let appendedQueue, let enqueueBypass) =
try PlayerCommand.parse(["--enqueue", "--no-config", "--queue-file", queueFile.path]) else {
fatalError("FAIL: expected running-player queue delivery")
}
require(appendedQueue == queueFile && enqueueBypass, "enqueue forwards its manifest without requiring a new instance")
let escapedFile = privateDirectory.appendingPathComponent("queue & #?.json")
require(try StreamQueue.enqueueFile(from: StreamQueue.enqueueURL(for: escapedFile)) == escapedFile,
"native URL delivery round trips absolute paths and URL metacharacters")
for text in ["terebi-enqueue://queue?file=relative.json", "terebi-enqueue://other?file=/tmp/a",
"terebi-enqueue://queue?file=/tmp/a&file=/tmp/b", "terebi-enqueue://queue?file=/tmp/a#extra"] {
do { _ = try StreamQueue.enqueueFile(from: URL(string: text)!); fatalError("FAIL: invalid enqueue URL accepted") }
catch {}
}
let parsedQueue = try StreamQueue.load(from: queueFile)
require(parsedQueue.entries.map(\.url) == [miruro, canonical], "stream queue order is supplied, never filename-sorted")
require(parsedQueue.entries[0].identity.hasPrefix("miruro:") &&
parsedQueue.entries[1].identity == canonical.absoluteString, "stable identities exclude slugs and transport URLs")
let anilistReport = privateDirectory.appendingPathComponent("anilist.jsonl").path
try saveQueue(["version": 1, "items": entries + [["url": anilist.absoluteString, "progress_file": anilistReport]]])
let mixedQueue = try StreamQueue.load(from: queueFile)
require(mixedQueue.entries.map(\.identity) == [parsedQueue.entries[0].identity, canonical.absoluteString, "anilist:1:1"],
"mixed queue retains distinct Miruro, YouTube and AniList identities")
try saveQueue(["version": 1, "items": [["url": generic.absoluteString, "progress_file": youtubeReport]]])
let genericQueue = try StreamQueue.load(from: queueFile)
require(genericQueue.entries[0].url == generic &&
genericQueue.entries[0].identity == generic.absoluteString &&
!genericQueue.entries[0].label.contains("YouTube"),
"third-party queues retain page identity without a YouTube label")
let duplicateYouTube = ["url": "https://youtu.be/jNQXAC9IVRw", "progress_file": miruroReport]
let duplicateMiruro = ["url": miruro.absoluteString.replacingOccurrences(of: "the-apothecary-diaries-season-3", with: "different-slug"),
"progress_file": youtubeReport]
let invalidQueues: [[String: Any]] = [
["version": 2, "items": entries], ["version": true, "items": entries],
["version": 1, "items": []], ["version": 1],
["version": 1, "items": Array(repeating: entries[0], count: 4097)],
["version": 1, "items": [entries[1], duplicateYouTube]],
["version": 1, "items": [entries[0], duplicateMiruro]],
["version": 1, "items": [entries[0], ["url": canonical.absoluteString, "progress_file": miruroReport]]],
["version": 1, "items": [entries[0], ["url": canonical.absoluteString,
"progress_file": privateDirectory.resolvingSymlinksInPath().appendingPathComponent("miruro.jsonl").path]]],
["version": 1, "items": [["url": canonical.absoluteString, "progress_file": "relative.jsonl"]]],
["version": 1, "items": [["url": canonical.absoluteString, "progress_file": movie.path]]]
]
for document in invalidQueues {
try saveQueue(document)
do {
_ = try PlayerCommand.parse(["--new-instance", "--queue-file", queueFile.path])
fatalError("FAIL: malformed queue must never launch or degrade to single playback")
} catch {}
do {
_ = try PlayerCommand.parse(["--enqueue", "--queue-file", queueFile.path])
fatalError("FAIL: malformed enqueue must never launch or partially deliver")
} catch {}
}
try saveQueue(["version": 1, "items": entries])
for arguments in [
["--queue-file", queueFile.path], ["--new-instance", "--queue-file"],
["--new-instance", "--queue-file", "queue.json"],
["--new-instance", "--queue-file", queueFile.path, "--url", canonical.absoluteString],
["--new-instance", "--queue-file", queueFile.path, "--progress-file", report.path],
["--enqueue"], ["--enqueue", "--url", canonical.absoluteString],
["--enqueue", "--new-instance", "--queue-file", queueFile.path],
["--enqueue", "--enqueue", "--queue-file", queueFile.path],
["--enqueue", "--queue-file", queueFile.path, "--progress-file", report.path],
["--enqueue", "--queue-file", "relative.json"],
["--new-instance", "--queue-file", queueFile.path, "--queue-file", queueFile.path]
] {
do { _ = try PlayerCommand.parse(arguments); fatalError("FAIL: invalid queue option combination") }
catch {}
}
require(chmod(privateDirectory.path, 0o755) == 0, "make report parent nonprivate for rejection check")
do { _ = try StreamQueue.load(from: queueFile); fatalError("FAIL: public report directory accepted") }
catch {}
require(chmod(privateDirectory.path, 0o700) == 0, "restore private check directory")
for arguments in [["--url"], ["--url", canonical.absoluteString, movie.path], ["--"], for arguments in [["--url"], ["--url", canonical.absoluteString, movie.path], ["--"],
[movie.path, movie.path], ["--unknown"], ["missing.mp4"], [directory.path], [movie.path, movie.path], ["--unknown"], ["missing.mp4"], [directory.path],
["file://remote-host/video.mp4"], ["https://example.com/video.mp4"], ["file://remote-host/video.mp4"], ["ftp://example.com/video.mp4"],
["--new-instance", "--new-instance"], ["--new-instance", "--url"], ["--new-instance", "--new-instance"], ["--new-instance", "--url"],
["--new-instance", "--"], ["--new-instance", movie.path, movie.path], ["--new-instance", "--"], ["--new-instance", movie.path, movie.path],
["--new-instance", "--unknown"], ["--no-config", "--no-config"], ["--new-instance", "--unknown"], ["--no-config", "--no-config"],
+164 -14
View File
@@ -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");
require([row[@"media_url"] isKindOfClass:[NSString class]] && if (version == 2) {
ProgressReporter::validMediaURL([row[@"media_url"] UTF8String]), "canonical consumer identity"); 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]] &&
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");
@@ -240,13 +252,26 @@ int main(int argc, char **argv) {
for (NSUInteger i = 0; i < rows.count; ++i) for (NSUInteger i = 0; i < rows.count; ++i)
expect(rows[i], i % 2 ? @"closed" : @"progress", firstURL, 97, false, i < 8 ? 0 : 100); expect(rows[i], i % 2 ? @"closed" : @"progress", firstURL, 97, false, i < 8 ? 0 : 100);
require(ProgressReporter::validMediaURL(firstURL) && const std::string genericURL = "https://video.example/watch?label=\"quoted\"&id=123#part";
ProgressReporter::validMediaURL("https://www.youtube.com/watch?v=aA0_-123456"), "eleven ASCII ID characters are accepted"); require(ProgressReporter::validMediaURL(genericURL) &&
for (const char *invalid : {"", "http://www.youtube.com/watch?v=AAAAAAAAAAA", ProgressReporter::validMediaURL("http://127.0.0.1:8080/video.mp4"),
"https://youtube.com/watch?v=AAAAAAAAAAA", "https://www.youtube.com/watch?v=AAAAAAAAAA", "HTTP(S) page identities are accepted beyond YouTube");
"https://www.youtube.com/watch?v=AAAAAAAAAAAA", "https://www.youtube.com/watch?v=AAAAAAAAAAA&x=1", for (const char *invalid : {"", "file:///tmp/video.mp4", "ftp://example.com/video",
"https://www.youtube.com/watch?v=AAAAAAAAAA%", "https://www.youtube.com/watch?v=AAAAAAAAAA\n"}) "https://", "https://user:secret@example.com/video", "https://example.com:0/video",
require(!ProgressReporter::validMediaURL(invalid), "noncanonical URLs are rejected"); "https://example.com:65536/video", "https://example.com/video\n"})
require(!ProgressReporter::validMediaURL(invalid), "unsafe page identities are rejected");
const auto genericPath = root + "/generic.jsonl";
{
ProgressReporter reporter(genericPath, [] { return 0.; });
reporter.begin(genericURL);
reporter.observe(sample(PLAYER_PLAYING, 25));
reporter.finish();
require(reporter.error().empty(), "generic page progress drains successfully");
}
rows = records(genericPath);
require(rows.count == 2, "generic page has initial and terminal snapshots");
expect(rows[0], @"progress", genericURL, 25, true);
expect(rows[1], @"closed", genericURL, 25, true);
require(ProgressReporter::validMediaIdentity("miruro:opaque_A-1:12") && require(ProgressReporter::validMediaIdentity("miruro:opaque_A-1:12") &&
ProgressReporter::validMediaIdentity("miruro:a:2147483647"), "strict Miruro identities accepted"); ProgressReporter::validMediaIdentity("miruro:a:2147483647"), "strict Miruro identities accepted");
@@ -289,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"); });
@@ -314,6 +363,107 @@ int main(int argc, char **argv) {
require(symlink(existingPath.c_str(), linkPath.c_str()) == 0 && rejected(linkPath), "symlink destination fails initialization"); require(symlink(existingPath.c_str(), linkPath.c_str()) == 0 && rejected(linkPath), "symlink destination fails initialization");
require([[NSData dataWithContentsOfFile:nativePath(existingPath)] isEqual:sentinel], "rejection never changes an existing destination"); require([[NSData dataWithContentsOfFile:nativePath(existingPath)] isEqual:sentinel], "rejection never changes an existing destination");
const auto queueFirst = root + "/queue-first.jsonl", queueSecond = root + "/queue-second.jsonl";
{
playback::ProgressReports reports;
reports.registerFile(firstURL, queueFirst);
reports.registerFile(secondURL, queueSecond);
reports.begin(firstURL);
reports.observe(sample(PLAYER_PAUSED, 12));
reports.begin(secondURL);
reports.observe(sample(PLAYER_ENDED, 100));
reports.begin(firstURL);
reports.observe(sample(PLAYER_PAUSED, 24));
reports.begin("miruro:unregistered:1");
reports.observe(sample(PLAYER_PAUSED, 55));
reports.finish();
require(reports.error().empty(), "queue reports can revisit their owned session without reopening arbitrary files");
}
auto firstRows = records(queueFirst), secondRows = records(queueSecond);
require(firstRows.count == 4 && secondRows.count == 2, "queue switches drain old reports and revisits append");
expect(firstRows[0], @"progress", firstURL, 12, true);
expect(firstRows[2], @"progress", firstURL, 24, true);
expect(secondRows[0], @"ended", secondURL, 100, true);
const auto retired = root + "/retired.jsonl", replacement = root + "/replacement.jsonl";
{
playback::ProgressReports reports;
reports.registerFile(firstURL, retired);
reports.begin(firstURL);
reports.observe(sample(PLAYER_PAUSED, 12));
bool accepted = true;
try { reports.registerFile(firstURL, replacement); } catch (...) { accepted = false; }
require(accepted, "a removed identity can register a fresh reporting session");
auto retiredRows = records(retired);
require(retiredRows.count == 2, "replacement drains the retired writer");
expect(retiredRows[1], @"closed", firstURL, 12, true);
NSData *retiredBytes = [NSData dataWithContentsOfFile:nativePath(retired)];
reports.observe(sample(PLAYER_PAUSED, 50));
reports.begin(firstURL);
reports.observe(sample(PLAYER_PAUSED, 24));
reports.finish();
require([[NSData dataWithContentsOfFile:nativePath(retired)] isEqual:retiredBytes],
"replacement never appends to or overwrites the retired report");
auto replacementRows = records(replacement);
require(replacementRows.count == 2, "fresh session starts only when its media begins");
expect(replacementRows[0], @"progress", firstURL, 24, true);
expect(replacementRows[1], @"closed", firstURL, 24, true);
bool refused = false;
try { reports.registerFile(secondURL, retired); } catch (...) { refused = true; }
require(refused, "retired report paths remain reserved");
}
const auto liveSingle = root + "/live-single.jsonl", liveNext = root + "/live-next.jsonl";
const auto rollbackPath = root + "/rollback.jsonl";
{
playback::ProgressReports reports(liveSingle);
reports.begin(firstURL);
reports.observe(sample(PLAYER_PAUSED, 7));
const char *identities[] = {secondURL.c_str()}, *destinations[] = {liveNext.c_str()};
reports.registerFiles(identities, destinations, 1);
reports.observe(sample(PLAYER_PAUSED, 8));
const char *conflictingIDs[] = {firstURL.c_str(), secondURL.c_str()};
const auto unused = root + "/unused.jsonl";
const char *conflictingPaths[] = {rollbackPath.c_str(), liveNext.c_str()};
bool refused = false;
try { reports.registerFiles(conflictingIDs, conflictingPaths, 2); } catch (...) { refused = true; }
require(refused, "conflicting later entry rejects the complete registration batch");
reports.registerFile("miruro:new:1", rollbackPath);
reports.begin(secondURL);
reports.observe(sample(PLAYER_PAUSED, 3));
reports.begin(firstURL);
reports.observe(sample(PLAYER_PAUSED, 9));
reports.begin("miruro:unregistered:1");
reports.observe(sample(PLAYER_PAUSED, 55));
NSData *singleBytes = [NSData dataWithContentsOfFile:nativePath(liveSingle)];
reports.registerFile(firstURL, unused);
reports.begin(firstURL);
reports.observe(sample(PLAYER_PAUSED, 11));
reports.finish();
require([[NSData dataWithContentsOfFile:nativePath(liveSingle)] isEqual:singleBytes],
"re-adding the original standalone identity leaves its old report unchanged");
auto reboundRows = records(unused);
require(reboundRows.count == 2, "standalone identity can start a fresh queue report");
expect(reboundRows[0], @"progress", firstURL, 11, true);
expect(reboundRows[1], @"closed", firstURL, 11, true);
require(reports.error().empty(), "enqueue preserves single reporting and supports owned report revisits");
}
auto singleRows = records(liveSingle), nextRows = records(liveNext);
require(singleRows.count >= 4 && nextRows.count == 2, "active single report survives registration and queued report stays isolated");
for (NSDictionary *row in singleRows) require([row[@"media_url"] isEqual:nativePath(firstURL)], "queued identity never leaks into original single report");
for (NSDictionary *row in nextRows) require([row[@"media_url"] isEqual:nativePath(secondURL)], "queued report has only its registered identity");
const auto replacedPath = root + "/replaced.jsonl";
{
ProgressReporter reporter(replacedPath, [] { return 100.; });
reporter.begin(firstURL);
reporter.observe(sample(PLAYER_PAUSED, 1));
reporter.finish();
require(rename(replacedPath.c_str(), (replacedPath + ".original").c_str()) == 0, "retain original inode while replacing path");
require([sentinel writeToFile:nativePath(replacedPath) atomically:NO], "replace report with unrelated sentinel");
bool refused = false;
try { reporter.resume(); } catch (...) { refused = true; }
require(refused && [[NSData dataWithContentsOfFile:nativePath(replacedPath)] isEqual:sentinel],
"revisiting never appends to a replaced report inode");
}
const auto concurrentPath = root + "/concurrent.jsonl"; const auto concurrentPath = root + "/concurrent.jsonl";
std::atomic<int> ready{0}, successes{0}, failures{0}; std::atomic<int> ready{0}, successes{0}, failures{0};
std::atomic<bool> start{false}; std::atomic<bool> start{false};
+80 -15
View File
@@ -1,4 +1,5 @@
"""Local-only resolved HLS/VTT + native model smoke; no live requests.""" """Local-only resolved HLS/VTT + native model smoke; no live requests."""
import base64
import http.server import http.server
import json import json
from pathlib import Path from pathlib import Path
@@ -6,35 +7,94 @@ import subprocess
import sys import sys
import tempfile import tempfile
import threading import threading
from urllib.parse import urlsplit from urllib.parse import parse_qs, urlsplit
binary = str(Path(sys.argv[1]).resolve()) 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, *_):
pass pass
def transport(self):
path = urlsplit(self.path).path
try:
encoded, suffix = path.lstrip("/").split("/", 1)
target, referer = encoded.split("~")
def decode(value):
raw = base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
return bytes(byte ^ [1, 2, 3][index % 3] for index, byte in enumerate(raw)).decode()
upstream = urlsplit(decode(target))
if not upstream.path.startswith("/transport-"):
return False
assert decode(referer) == "https://transport.example/"
assert self.headers.get("X-Secret") is None and self.headers.get("Referer") is None, "private headers leaked to proxy"
except (ValueError, UnicodeError):
return False
query = parse_qs(upstream.query)
if upstream.path.endswith(".m3u8"):
assert query == {"skip": ["fixture"]}, "provider query or token-removal rule lost"
port = self.server.server_port
if upstream.path == "/transport-master.m3u8":
def encode(value):
raw = value.encode()
return base64.urlsafe_b64encode(bytes(byte ^ [1, 2, 3][index % 3] for index, byte in enumerate(raw))).decode().rstrip("=")
nested = (f"http://localhost:{port}/" + encode(f"http://127.0.0.1:{port}/transport-variant.m3u8?token=private") +
"~" + encode("https://transport.example/") + "/pl.m3u8")
data = f"#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=1\n{nested}\n".encode()
elif upstream.path == "/transport-variant.m3u8":
data = (f'#EXTM3U\n#EXT-X-KEY:METHOD=AES-128,URI="http://2130706433:{port}/transport-private"\n'
'#EXTINF:1,\nhttps://foreign.invalid/transport-segment.bin\n#EXT-X-ENDLIST\n').encode()
elif upstream.path == "/transport-segment.bin":
assert upstream.hostname == "127.0.0.1", "segment origin rewrite lost"
data = bytes(range(256))
else:
errors.append("unexpected transport request")
self.send_error(403)
return True
self.send_response(200)
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
return True
def do_GET(self): def do_GET(self):
if self.transport():
return
path = urlsplit(self.path).path path = urlsplit(self.path).path
seen.append(path) seen.append(path)
expected = "subtitle-private" if path == "/english.jpeg" else "stream-private" alternate = path.startswith("/alternate/")
captions = path.startswith("/captions/")
if alternate or captions:
path = "/" + path.split("/", 2)[2]
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)
return return
if path == "/master.m3u8": if path == "/master.m3u8":
if urlsplit(self.path).query != "token=fixture": query = parse_qs(urlsplit(self.path).query)
errors.append("master token lost") if query.get("token") != ["fixture"] or (alternate and query.get("rewrite") != ["1"]):
errors.append("master token or provider query lost")
data = b'#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=500000\nvariant.m3u8?token=variant\n' data = b'#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=500000\nvariant.m3u8?token=variant\n'
elif path == "/variant.m3u8": elif path == "/variant.m3u8":
if urlsplit(self.path).query != "token=variant": query = parse_qs(urlsplit(self.path).query)
errors.append("variant token lost") if query.get("token") != ["variant"] or (alternate and query.get("rewrite") != ["1"]):
errors.append("variant token or provider query lost")
data = (fixtures / "resolved-vod.m3u8").read_bytes() data = (fixtures / "resolved-vod.m3u8").read_bytes()
if alternate:
data = data.replace(b"resolved-vod-", b"https://invalid.example/alternate/resolved-vod-")
elif path == "/english.jpeg": elif path == "/english.jpeg":
data = b'WEBVTT\n\n00:00.000 --> 00:02.000\nFirst English cue\n\n00:02.000 --> 00:04.000\nSecond English cue\n' 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')
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:
@@ -63,16 +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
records = [json.loads(line) for line in progress.read_text().splitlines()] [binary, str(source), str(progress)], check=True, timeout=100)
assert records and all(record["version"] == 2 and record["media_identity"] == if not zoko_only:
{"provider": "miruro", "media_id": "fixture-ID", "episode": 1} for record in records), "stable episode progress identity" records = [json.loads(line) for line in progress.read_text().splitlines()]
assert any(record["completion_eligible"] for record in records), "acknowledged video progress eligible" assert records and all(record["version"] == 2 and record["media_identity"] ==
assert "stream-private" not in progress.read_text() and "token=" not in progress.read_text(), "progress has no transport secrets" {"provider": "miruro", "media_id": "fixture-ID", "episode": 1} for record in records), "stable episode progress identity"
assert any(record["completion_eligible"] for record in records), "acknowledged video progress eligible"
assert "stream-private" not in progress.read_text() and "token=" not in progress.read_text(), "progress has no transport secrets"
assert not errors, errors assert 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"
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()
File diff suppressed because it is too large Load Diff
+83
View File
@@ -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()
+101
View File
@@ -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")
}
}
+185
View File
@@ -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)
}
}
}
+173 -18
View File
@@ -40,7 +40,17 @@ struct StreamResolverChecks {
throw Failure(message: "Accepted \(message)") throw Failure(message: "Accepted \(message)")
} }
static func project(_ json: String) throws -> ResolvedSource { static func project(_ json: String) throws -> ResolvedSource {
try MiruroResolver.decode(Data(json.utf8), mediaID: mediaID, episode: 1) try MiruroResolver.decode(Data(json.utf8), mediaID: mediaID, episode: 1).select().source
}
static func catalog(_ document: [String: Any]) throws -> MiruroSources {
try MiruroResolver.decode(JSONSerialization.data(withJSONObject: document), mediaID: mediaID, episode: 1)
}
static func provider(_ name: String, captions: Bool = true) -> [String: Any] {
["provider": name,
"servers": [["headers": ["Referer": "https://\(name).example/"],
"streams": [["url": "https://\(name).example/master.m3u8", "format": "hls"]]]],
"subtitles": captions ? [["file": "https://\(name).example/english.vtt", "format": "vtt",
"language": "en", "label": "English", "default": true]] : []]
} }
static func checks() async throws { static func checks() async throws {
@@ -52,9 +62,12 @@ struct StreamResolverChecks {
throw Failure(message: "YouTube URL classification") throw Failure(message: "YouTube URL classification")
} }
try require(youtube.absoluteString == "https://www.youtube.com/watch?v=jNQXAC9IVRw", "existing YouTube normalization") try require(youtube.absoluteString == "https://www.youtube.com/watch?v=jNQXAC9IVRw", "existing YouTube normalization")
let other = "https://vimeo.com/76979871?quality=original"
guard case .youtube(let generic) = try StreamURL.parse(other) else {
throw Failure(message: "other HTTP(S) sites route through yt-dlp")
}
try require(generic.absoluteString == other, "other sites retain their URL and query")
for invalid in [ for invalid in [
"https://example.com/video", "https://www.miruro.to/watch?id=21&ep=1",
page.replacingOccurrences(of: "barelystarted.miruro.tv", with: "barelystarted.miruro.tv.evil.example"),
page.replacingOccurrences(of: "barelystarted.miruro.tv", with: "barelystarted.miruro.tv@evil.example"), page.replacingOccurrences(of: "barelystarted.miruro.tv", with: "barelystarted.miruro.tv@evil.example"),
page.replacingOccurrences(of: "https://", with: "http://"), page.replacingOccurrences(of: "https://", with: "http://"),
page.replacingOccurrences(of: "https://", with: "https://user:secret@"), page.replacingOccurrences(of: "https://", with: "https://user:secret@"),
@@ -70,7 +83,7 @@ struct StreamResolverChecks {
page + "&ep=2", page + "&provider=bee", page + "#secret", page + "&ep=2", page + "&provider=bee", page + "#secret",
page.replacingOccurrences(of: ".tv/", with: ".tv:444/"), page.replacingOccurrences(of: ".tv/", with: ".tv:444/"),
page.replacingOccurrences(of: "?ep", with: "/?ep"), page.replacingOccurrences(of: "?ep", with: "/?ep"),
"https://youtu.be/invalid", "file:///secret" "file:///secret"
] { ] {
try reject("unsupported or unsafe URL") { _ = try StreamURL.parse(invalid) } try reject("unsupported or unsafe URL") { _ = try StreamURL.parse(invalid) }
} }
@@ -81,22 +94,162 @@ struct StreamResolverChecks {
try require(source.subtitles.count == 2 && source.subtitles[0].isDefault, "only external VTT retained with default") try require(source.subtitles.count == 2 && source.subtitles[0].isDefault, "only external VTT retained with default")
try require(source.subtitles[0].url.hasSuffix(".jpeg") && source.subtitles[1].label == "Spanish (Latin America)", "subtitle format and labels independent of file extension") try require(source.subtitles[0].url.hasSuffix(".jpeg") && source.subtitles[1].label == "Spanish (Latin America)", "subtitle format and labels independent of file extension")
try require(source.subtitles.allSatisfy { $0.headers == source.headers }, "verified Sun subtitle headers retained") try require(source.subtitles.allSatisfy { $0.headers == source.headers }, "verified Sun subtitle headers retained")
let native: [String: Any] = ["player": "native", "proxy": false]
var captionOnly = provider("bun")
captionOnly["servers"] = [["headers": ["Referer": "https://bun.example/"],
"streams": [], "embed": ["url": "https://bun.example/embed"]]]
var missingVideo = provider("captions")
missingVideo["servers"] = [["headers": ["Origin": "https://captions.example"]]]
let independentCatalog = try catalog([
"episodeNumber": 1,
"config": ["sun": native, "bun": ["player": "iframe", "proxy": false]],
"tracks": [["track": "ssub", "providers": [provider("sun"), captionOnly, missingVideo]]]
])
let independentCaptions = try independentCatalog.select(captionProvider: "bun:ssub")
try require(independentCaptions.source.subtitles.first?.url == "https://bun.example/english.vtt" &&
independentCaptions.source.subtitles.first?.headers == "Referer: https://bun.example/\r\n",
"iframe-only provider captions are discovered independently of video eligibility")
let unconfiguredCaptions = try independentCatalog.select(captionProvider: "captions")
try require(unconfiguredCaptions.captionProvider == "captions:ssub" &&
unconfiguredCaptions.source.subtitles.first?.headers == "Origin: https://captions.example\r\n",
"captions without config or video streams retain their own server headers")
let embedded = try independentCatalog.select(videoProvider: "bun:ssub")
try require(independentCatalog.videoProviders == ["sun:ssub", "bun:ssub"] &&
independentCatalog.captionProviders == ["sun:ssub", "bun:ssub", "captions:ssub"] &&
embedded.source.url == "https://bun.example/embed" && embedded.source.requirements.embed,
"iframe choices retain the embed URL and require asynchronous native preparation")
let captionsOnly = try catalog([
"episodeNumber": 1, "tracks": [["track": "ssub", "providers": [missingVideo]]]
])
try require(captionsOnly.videoProviders.isEmpty && captionsOnly.captionProviders == ["captions:ssub"],
"caption discovery does not require any video candidate or config table")
try reject("caption-only catalog playback") { _ = try captionsOnly.select() }
var document: [String: Any] = [
"episodeNumber": 1, "providerOrder": ["cloud", "river", "sun", "cloud", "missing"],
"config": ["sun": native, "river": native, "cloud": native],
"tracks": [["track": "ssub", "providers": [provider("river"), provider("sun"), provider("cloud")]]]
]
let choices = try catalog(document)
try require(choices.videoProviders == ["cloud:ssub", "river:ssub", "sun:ssub"] &&
choices.captionProviders == ["cloud:ssub", "river:ssub", "sun:ssub"],
"choices expose provider and track in deduplicated runtime provider order")
let automatic = try choices.select()
try require(automatic.videoProvider == "sun:ssub" && automatic.captionProvider == "sun:ssub",
"Auto prefers usable Japanese Sun")
let independent = try choices.select(videoProvider: "river", captionProvider: "cloud:ssub")
try require(independent.videoProvider == "river:ssub" && independent.captionProvider == "cloud:ssub" &&
independent.source.url == "https://river.example/master.m3u8" &&
independent.source.headers == "Referer: https://river.example/\r\n" &&
independent.source.subtitles.first?.headers == "Referer: https://cloud.example/\r\n",
"bare provider preference and exact caption choice retain independent URLs and headers")
let absent = try choices.select(videoProvider: "absent", captionProvider: "unsupported")
let empty = try choices.select(videoProvider: "", captionProvider: "")
try require(absent.videoProvider == "sun:ssub" && absent.captionProvider == "sun:ssub" &&
empty.videoProvider == "sun:ssub" && empty.captionProvider == "sun:ssub",
"unavailable and empty preferences fall back to Japanese Auto")
document["tracks"] = [["track": "ssub", "providers": [provider("river", captions: false), provider("cloud")]]]
let withoutSun = try catalog(document).select()
try require(withoutSun.videoProvider == "cloud:ssub" && withoutSun.captionProvider == "cloud:ssub",
"provider order applies without Sun")
document["providerOrder"] = []
let otherCaptions = try catalog(document).select()
try require(otherCaptions.videoProvider == "river:ssub" && otherCaptions.captionProvider == "cloud:ssub",
"response order is retained and captions fall back independently")
document["tracks"] = [["track": "sub", "providers": [provider("river", captions: false)]]]
let noCaptions = try catalog(document)
let bare = try noCaptions.select(captionProvider: "cloud")
try require(noCaptions.captionProviders.isEmpty && bare.captionProvider == nil && bare.source.subtitles.isEmpty,
"Japanese sub without captions remains playable")
document["tracks"] = [
["track": "ssub", "providers": [provider("river")]],
["track": "dub", "providers": [provider("river"), provider("sun")]],
["track": "sub", "providers": [provider("river")]]]
document["config"] = ["sun": native, "river": ["player": "native", "proxy": false,
"variantOrder": ["dub", "sub", "sub", "ssub"]]]
let variants = try catalog(document)
let preferredVariant = try variants.select(videoProvider: "river")
let dubbed = try variants.select(videoProvider: "river:dub", captionProvider: "river:dub")
try require(variants.videoProviders == ["river:dub", "river:sub", "river:ssub", "sun:dub"] &&
preferredVariant.videoProvider == "river:sub" && preferredVariant.captionProvider == "river:sub" &&
dubbed.videoProvider == "river:dub" && dubbed.captionProvider == "river:dub",
"all tracks remain selectable; bare provider uses variantOrder without implicit dub")
document["tracks"] = [["track": "dub", "providers": [provider("sun")]]]
let dubOnly = try catalog(document)
try reject("automatic dub fallback") { _ = try dubOnly.select() }
try reject("bare provider dub fallback") { _ = try dubOnly.select(videoProvider: "sun") }
let explicitDub = try dubOnly.select(videoProvider: "sun:dub")
try require(explicitDub.videoProvider == "sun:dub",
"dub-only catalog remains available for an explicit track choice")
let processed: [String: Any] = ["player": "native", "proxy": ["rotate": true, "segments": true],
"hls": ["segments": ["enabled": true, "origin": "https://segments.example"],
"query": ["enabled": true, "params": ["skip": "opaque", "token": "secret"]]]]
document["config"] = ["river": native, "bee": processed]
document["tracks"] = [["track": "ssub", "providers": [provider("river"), provider("bee")]]]
let processing = try catalog(document).select(videoProvider: "bee", captionProvider: "bee")
try require(processing.source.url == "https://bee.example/master.m3u8" &&
processing.source.requirements.proxy?.rotate == true &&
processing.source.requirements.proxy?.segments == true &&
processing.source.requirements.segmentOrigin == "https://segments.example" &&
processing.source.requirements.playlistQuery == ["skip": "opaque", "token": "secret"] &&
processing.source.subtitles.first?.proxy?.segments == true &&
!processing.source.requirements.embed,
"selection preserves native proxy/HLS requirements and caption proxy independently")
let parent = provider("bee")
var embeddedCaptions = provider("bun")
embeddedCaptions["subtitles"] = parent["subtitles"]
embeddedCaptions["servers"] = [["streams": [], "embed": ["url": "https://megaplay.buzz/videojs/stream/example"]]]
document["config"] = ["river": native, "bee": processed,
"bun": ["player": "iframe", "proxy": false, "parent": "bee"]]
document["tracks"] = [["track": "ssub", "providers": [provider("river"), parent, embeddedCaptions]]]
let sharedCaptions = try catalog(document).select(captionProvider: "bun:ssub")
try require(sharedCaptions.captionProvider == "bun:ssub" &&
sharedCaptions.source.subtitles.first?.headers == "Referer: https://bee.example/\r\n" &&
sharedCaptions.source.subtitles.first?.proxy?.segments == true,
"identical iframe caption resources use their parent's known request transport")
embeddedCaptions["subtitles"] = provider("bun")["subtitles"]
document["tracks"] = [["track": "ssub", "providers": [provider("river"), parent, embeddedCaptions]]]
let distinctCaptions = try catalog(document).select(captionProvider: "bun:ssub")
try require(distinctCaptions.source.subtitles.first?.proxy == nil &&
distinctCaptions.source.subtitles.first?.headers.isEmpty == true,
"parent credentials and routing never propagate to different caption resources")
var unsafeHLS = processed
unsafeHLS["hls"] = ["segments": ["enabled": true, "origin": "file:///secret"]]
document["config"] = ["river": native, "bee": unsafeHLS]
let captionDespiteHLS = try catalog(document).select(videoProvider: "river", captionProvider: "bee:ssub")
try require(captionDespiteHLS.captionProvider == "bee:ssub" &&
captionDespiteHLS.source.subtitles.first?.proxy?.segments == true,
"unsupported video processing does not hide valid independent captions")
var resilient = provider("river")
resilient["servers"] = [
["streams": [42, ["url": "file:///secret", "format": "hls"],
["url": "https://river.example/master.m3u8", "format": "application/vnd.apple.mpegurl"]] as [Any],
"headers": ["Referer": "https://river.example/"]]]
document["config"] = ["river": native, "broken": ["player": 42], "bad name": native]
document["tracks"] = [["track": "ssub", "providers": [
42, ["provider": "broken", "servers": "invalid"], provider("bad name"), resilient] as [Any]]]
let resilientSources = try catalog(document)
try require(resilientSources.videoProviders == ["river:ssub"] &&
resilientSources.captionProviders == ["river:ssub"],
"malformed neighboring entries do not poison usable provider tracks")
for (old, replacement) in [
("https://vault.example/english.jpeg", "https://user:secret@vault.example/english.jpeg"),
("\"default\": false", "\"default\": true")
] {
let safeVideo = try project(fixture.replacingOccurrences(of: old, with: replacement))
try require(safeVideo.url == source.url && safeVideo.subtitles.isEmpty,
"unsafe caption sets are excluded without losing safe video")
}
let renewed = try project(fixture.replacingOccurrences(of: "token=secret", with: "token=renewed")) let renewed = try project(fixture.replacingOccurrences(of: "token=secret", with: "token=renewed"))
try require(renewed.identity == source.identity && renewed.url != source.url, "token rotation does not change stable identity") try require(renewed.identity == source.identity && renewed.url != source.url,
"token rotation does not change stable identity")
for (old, replacement) in [ for (old, replacement) in [
("\"episodeNumber\": 1", "\"episodeNumber\": 49"), ("\"episodeNumber\": 1", "\"episodeNumber\": 49"),
("\"proxy\": false", "\"proxy\": true"), ("\"proxy\": false", "\"proxy\": true"),
("\"enabled\": false", "\"enabled\": true"),
("\"player\": \"native\"", "\"player\": \"iframe\""),
("\"track\": \"ssub\"", "\"track\": \"dub\""),
("\"provider\": \"sun\"", "\"provider\": \"bee\""),
("\"format\": \"hls\"", "\"format\": \"mp4\""), ("\"format\": \"hls\"", "\"format\": \"mp4\""),
("\"embed\": null", "\"embed\": {\"url\": \"https://embed.example/secret\"}"),
("https://vault.example/master.m3u8?token=secret", "file:///secret"), ("https://vault.example/master.m3u8?token=secret", "file:///secret"),
("https://vault.example/english.jpeg", "https://user:secret@vault.example/english.jpeg"), ("\"embed\": null", "\"embed\": false"),
("https://ultracloud.cc/\"", "https://ultracloud.cc/\\r\\nX-Injected: secret\""), ("https://ultracloud.cc/\"", "https://ultracloud.cc/\\r\\nX-Injected: secret\""),
("\"Origin\":", "\"origin\": \"secret\", \"Origin\":"), ("\"Origin\":", "\"origin\": \"secret\", \"Origin\":")
("\"default\": false", "\"default\": true")
] { ] {
try reject("unsafe/unsupported source projection") { try reject("unsafe/unsupported source projection") {
_ = try project(fixture.replacingOccurrences(of: old, with: replacement)) _ = try project(fixture.replacingOccurrences(of: old, with: replacement))
@@ -119,7 +272,7 @@ struct StreamResolverChecks {
_ = try await cancelled.value _ = try await cancelled.value
throw Failure(message: "Cancelled resolution was accepted") throw Failure(message: "Cancelled resolution was accepted")
} catch is CancellationError {} } catch is CancellationError {}
print("PASS: streaming URL security, Sun projection, shared validation, bounded responses and cancellation") print("PASS: streaming URL security, provider/track choices, proxy/HLS requirements, independent captions, bounded responses and cancellation")
} }
static func main() async { static func main() async {
@@ -129,12 +282,14 @@ struct StreamResolverChecks {
guard case .miruro(let id, let episode) = try StreamURL.parse(args[1]) else { guard case .miruro(let id, let episode) = try StreamURL.parse(args[1]) else {
throw Failure(message: "--live requires a Miruro episode URL") throw Failure(message: "--live requires a Miruro episode URL")
} }
let source = try await MiruroResolver.resolve(mediaID: id, episode: episode) let sources = try await MiruroResolver.resolve(mediaID: id, episode: episode)
print("PASS: live \(source.identity); \(source.subtitles.count) VTT tracks") let selection = try sources.select()
print("PASS: live video=\(selection.videoProvider); captions=\(selection.captionProvider ?? "none"); \(sources.videoProviders.count) hosts; \(sources.captionProviders.count) caption providers; \(selection.source.subtitles.count) VTT tracks")
} else if args.count == 2, args[0] == "--fixture" { } else if args.count == 2, args[0] == "--fixture" {
let data = try Data(contentsOf: URL(fileURLWithPath: args[1])) let data = try Data(contentsOf: URL(fileURLWithPath: args[1]))
let source = try MiruroResolver.decode(data, mediaID: mediaID, episode: 1) let sources = try MiruroResolver.decode(data, mediaID: mediaID, episode: 1)
print("PASS: fixture \(source.identity); \(source.subtitles.count) VTT tracks") let selection = try sources.select()
print("PASS: fixture video=\(selection.videoProvider); captions=\(selection.captionProvider ?? "none"); \(sources.videoProviders.count) hosts; \(sources.captionProviders.count) caption providers; \(selection.source.subtitles.count) VTT tracks")
} else if args.isEmpty { } else if args.isEmpty {
try await checks() try await checks()
} else { } else {
+49 -2
View File
@@ -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")
+323 -21
View File
@@ -70,20 +70,20 @@ struct YouTubeResolverChecks {
let url = try YouTubeResolver.validateURL(input) let url = try YouTubeResolver.validateURL(input)
try require(url == page, "supported video URLs identify the same video") try require(url == page, "supported video URLs identify the same video")
} }
for input in ["https://vimeo.com/76979871", "https://example.com/watch?v=jNQXAC9IVRw",
"https://youtube.com.evil.example/watch?v=jNQXAC9IVRw",
"https://evil.youtube.com/watch?v=jNQXAC9IVRw",
"http://127.0.0.1:8080/video.mp4?token=kept#fragment",
"https://www.youtube.com/playlist?list=PLexample"] {
try require(try YouTubeResolver.validateURL(input).absoluteString == input,
"HTTP(S) URLs outside canonical YouTube video forms reach yt-dlp unchanged")
}
let hostile = [ let hostile = [
"", "not a URL", "javascript:alert(1)", "file:///watch?v=jNQXAC9IVRw", "", "not a URL", "javascript:alert(1)", "file:///watch?v=jNQXAC9IVRw",
"https://example.com/watch?v=jNQXAC9IVRw", "ftp://example.com/video", "https://", "https://example.com:0/video",
"https://youtube.com.evil.example/watch?v=jNQXAC9IVRw", "https://example.com:65536/video", "https://example.com/video\ninjected",
"https://evil.youtube.com/watch?v=jNQXAC9IVRw",
"https://youtube.com@evil.example/watch?v=jNQXAC9IVRw", "https://youtube.com@evil.example/watch?v=jNQXAC9IVRw",
"https://user:password@www.youtube.com/watch?v=jNQXAC9IVRw", "https://user:password@www.youtube.com/watch?v=jNQXAC9IVRw"
"https://www.youtube.com/playlist?list=PLexample",
"https://www.youtube.com/watch?list=PLexample",
"https://www.youtube.com/watch?v=short",
"https://youtu.be/jNQXAC9IVRwextra", "https://youtu.be/jNQXAC9IVR!",
"https://www.youtube.com/shorts/", "https://youtu.be/jNQXAC9IVRw/extra",
"https://www.youtube.com/watch?v=jNQXAC9IVR%0A",
"https://www.youtube.com/watch?v=jNQXAC9IVR%C3%A9"
] ]
for input in hostile { for input in hostile {
try reject("hostile or malformed video URL") { _ = try YouTubeResolver.validateURL(input) } try reject("hostile or malformed video URL") { _ = try YouTubeResolver.validateURL(input) }
@@ -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",
@@ -202,6 +381,9 @@ struct YouTubeResolverChecks {
} }
static func malformedMetadata() throws { static func malformedMetadata() throws {
var collection = try fixture(combinedJSON)
collection["_type"] = "playlist"
try reject("collection result instead of a single video") { _ = try decode(collection) }
try reject("non-JSON extractor output") { _ = try decode("not JSON") } try reject("non-JSON extractor output") { _ = try decode("not JSON") }
for json in ["null", "[]", "{}", #"{"requested_formats":[]}"#] { for json in ["null", "[]", "{}", #"{"requested_formats":[]}"#] {
try reject("missing playable stream metadata") { _ = try decode(json) } try reject("missing playable stream metadata") { _ = try decode(json) }
@@ -210,10 +392,11 @@ struct YouTubeResolverChecks {
_ = try decode(#"{"url":"https://media.example/video","vcodec":"h264","acodec":"aac","duration":1e999}"#) _ = try decode(#"{"url":"https://media.example/video","vcodec":"h264","acodec":"aac","duration":1e999}"#)
} }
let combined = try fixture(combinedJSON) let combined = try fixture(combinedJSON)
var missingURL = combined
missingURL.removeValue(forKey: "url")
try reject("missing combined stream URL") { _ = try decode(missingURL) }
for key in ["url", "vcodec", "acodec"] { for key in ["url", "vcodec", "acodec"] {
var object = combined var object = combined
object.removeValue(forKey: key)
try reject("missing required combined \(key)") { _ = try decode(object) }
object[key] = 17 object[key] = 17
try reject("wrong type for combined \(key)") { _ = try decode(object) } try reject("wrong type for combined \(key)") { _ = try decode(object) }
} }
@@ -236,11 +419,9 @@ struct YouTubeResolverChecks {
var hdr = combined var hdr = combined
hdr["dynamic_range"] = "HDR10" hdr["dynamic_range"] = "HDR10"
try reject("HDR input unsupported by the SDR renderer") { _ = try decode(hdr) } try reject("HDR input unsupported by the SDR renderer") { _ = try decode(hdr) }
for value in ["none", NSNull()] as [Any] { var audioOnly = combined
var object = combined audioOnly["vcodec"] = "none"
object["vcodec"] = value try reject("explicitly audio-only result") { _ = try decode(audioOnly) }
try reject("combined input without video codec") { _ = try decode(object) }
}
for transport in ["http_dash_segments", "mhtml", "rtmp"] { for transport in ["http_dash_segments", "mhtml", "rtmp"] {
var object = combined var object = combined
object["protocol"] = transport object["protocol"] = transport
@@ -307,13 +488,109 @@ struct YouTubeResolverChecks {
throw Failure(description: "FAIL: extractor did not reach \(name) within the isolated check deadline") throw Failure(description: "FAIL: extractor did not reach \(name) within the isolated check deadline")
} }
static func cancelled(_ task: Task<YouTubeVideo, Error>, _ message: String) async throws { static func cancelled<T: Sendable>(_ task: Task<T, Error>, _ message: String) async throws {
do { _ = try await task.value } do { _ = try await task.value }
catch is CancellationError { return } catch is CancellationError { return }
catch { throw Failure(description: "FAIL: \(message) returned an extraction error instead of cancellation") } catch { throw Failure(description: "FAIL: \(message) returned an extraction error instead of cancellation") }
throw Failure(description: "FAIL: \(message) returned media instead of cancellation") throw Failure(description: "FAIL: \(message) returned media instead of cancellation")
} }
final class TitleProtocol: URLProtocol, @unchecked Sendable {
static let stalled = AsyncStream<Void>.makeStream()
override class func canInit(with request: URLRequest) -> Bool { true }
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
override func startLoading() {
let parts = URLComponents(url: request.url!, resolvingAgainstBaseURL: false)!
precondition(parts.scheme == "https" && parts.host == "www.youtube.com" && parts.path == "/oembed")
precondition(request.value(forHTTPHeaderField: "Cookie") == nil)
let page = parts.queryItems!.first { $0.name == "url" }!.value!
precondition(parts.queryItems!.first { $0.name == "format" }!.value == "json")
let id = URLComponents(string: page)!.queryItems!.first!.value!
if id == "CancelledAA" {
Self.stalled.continuation.yield()
return
}
let status = id == "Unavailable" ? 404 : 200
let mime = id == "WrongMimeAA" ? "text/html" : "application/json"
let oversized = ["OversizedAA", "ChunkedAAAA"].contains(id)
let body = oversized ? Data(repeating: 32, count: 65537) :
Data((id == "MalformedAA" ? "not JSON" : #"{"title":" Early YouTube title "}"#).utf8)
var headers = ["Content-Type": mime]
if id != "ChunkedAAAA" { headers["Content-Length"] = "\(body.count)" }
client!.urlProtocol(self, didReceive: HTTPURLResponse(url: request.url!, statusCode: status,
httpVersion: nil, headerFields: headers)!,
cacheStoragePolicy: .notAllowed)
client!.urlProtocol(self, didLoad: body)
client!.urlProtocolDidFinishLoading(self)
}
override func stopLoading() {}
}
static func titles(root: URL) async throws {
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [TitleProtocol.self]
let youtube = try await YouTubeResolver.resolveTitle(URL(string: "https://youtu.be/jNQXAC9IVRw")!,
executableURL: URL(fileURLWithPath: "/missing-helper"), configuration: configuration)
try require(youtube == "Early YouTube title", "native YouTube oEmbed needs no extractor, runtime or playable formats")
for id in ["Unavailable", "WrongMimeAA", "OversizedAA", "ChunkedAAAA", "MalformedAA"] {
do {
_ = try await YouTubeResolver.resolveTitle(URL(string: "https://youtu.be/\(id)")!, configuration: configuration)
throw Failure(description: "FAIL: invalid oEmbed response was accepted: \(id)")
} catch let failure as Failure { throw failure }
catch {}
}
let native = Task {
try await YouTubeResolver.resolveTitle(URL(string: "https://youtu.be/CancelledAA")!, configuration: configuration)
}
var started = TitleProtocol.stalled.stream.makeAsyncIterator()
_ = await started.next()
native.cancel()
do {
_ = try await native.value
throw Failure(description: "FAIL: cancelled oEmbed request returned a title")
} catch let failure as Failure { throw failure }
catch {
try require(error is CancellationError || (error as? URLError)?.code == .cancelled,
"native oEmbed cancellation ends its request without a title")
}
try require(try YouTubeResolver.decodeTitle(Data(#"{"title":" Metadata only ","formats":"ignored"}"#.utf8)) == "Metadata only",
"title decoding does not require or interpret playback formats")
try require(try YouTubeResolver.decodeTitle(Data(#"{"title":" "}"#.utf8)) == nil, "empty title retains fallback")
for json in [#"{"_type":"playlist","title":"Collection"}"#, #"{"title":"bad\u0000title"}"#, #"{"title":42}"#] {
try reject("unsafe, collection or malformed title") { _ = try YouTubeResolver.decodeTitle(Data(json.utf8)) }
}
let generic = URL(string: "https://video.example/watch?id=123")!
let extractor = try Extractor(root: root, name: "title only", json: #"{"title":"Generic title"}"#, body: #"""
printf '%s\n' "$@" > "$base/arguments"
/bin/cat "$base/response.json"
"""#)
let title = try await YouTubeResolver.resolveTitle(generic, executableURL: extractor.executable)
let arguments = try String(contentsOf: extractor.marker("arguments"), encoding: .utf8)
.split(separator: "\n").map(String.init)
try require(title == "Generic title" && arguments.suffix(2) == ["--", generic.absoluteString] &&
arguments.contains("--ignore-config") && arguments.contains("--no-plugin-dirs") &&
arguments.contains("--no-remote-components") && arguments.contains("--flat-playlist") &&
arguments.contains("--skip-download") && arguments.contains("--ignore-no-formats-error") &&
arguments.contains("--no-js-runtimes") && !arguments.contains("--js-runtimes") &&
!arguments.contains("--format"), "generic title extraction is isolated from full playback format/runtime policy")
do {
_ = try await YouTubeResolver.resolveTitle(URL(string: "https://user:password@video.example/")!,
executableURL: extractor.executable)
throw Failure(description: "FAIL: title lookup accepted embedded credentials")
} catch let failure as Failure { throw failure }
catch {}
let waiting = try Extractor(root: root, name: "cancel title", body: #"""
trap '' TERM INT
: > "$base/ready"
while [ ! -f "$base/release" ]; do /bin/sleep 0.01; done
/bin/cat "$base/response.json"
"""#)
let task = Task { try await YouTubeResolver.resolveTitle(generic, executableURL: waiting.executable) }
try await awaitMarker(waiting, "ready")
task.cancel()
try await cancelled(task, "title-only subprocess cancellation")
}
static func subprocesses(root: URL, runtime: URL) async throws { static func subprocesses(root: URL, runtime: URL) async throws {
let successful = try Extractor(root: root, name: "successful split") let successful = try Extractor(root: root, name: "successful split")
let result = try await YouTubeResolver.resolve(page, executableURL: successful.executable, runtimeURL: runtime) let result = try await YouTubeResolver.resolve(page, executableURL: successful.executable, runtimeURL: runtime)
@@ -444,6 +721,24 @@ struct YouTubeResolverChecks {
try JSONSerialization.data(withJSONObject: object).write(to: extractor.marker("response.json")) try JSONSerialization.data(withJSONObject: object).write(to: extractor.marker("response.json"))
let lower = try await YouTubeResolver.resolve(page, executableURL: extractor.executable, runtimeURL: runtime) let lower = try await YouTubeResolver.resolve(page, executableURL: extractor.executable, runtimeURL: runtime)
try require(lower.height == 720, "default falls downward when 1080p is absent, never upward to available 4K") try require(lower.height == 720, "default falls downward when 1080p is absent, never upward to available 4K")
let genericPage = URL(string: "https://video.example/watch?id=123")!
let generic: [String: Any] = [
"id": "123", "title": "Generic HTML5 video", "webpage_url": genericPage.absoluteString,
"extractor": "generic", "extractor_key": "Generic",
"formats": [["format_id": "0", "url": "https://media.example/unknown.mp4",
"ext": "mp4", "protocol": "https"]]
]
try JSONSerialization.data(withJSONObject: generic).write(to: extractor.marker("response.json"))
let unknown = try await YouTubeResolver.resolve(genericPage, executableURL: extractor.executable, runtimeURL: runtime)
try require(unknown.video.url == "https://media.example/unknown.mp4" &&
unknown.audio == nil && unknown.height == nil && unknown.webpageURL == genericPage,
"generic HTML5 video without codec or height hints remains playable and retains page identity")
let metadataOnly: [String: Any] = ["id": "123", "title": "Title without playable formats",
"webpage_url": genericPage.absoluteString, "extractor": "generic", "extractor_key": "Generic",
"formats": []]
try JSONSerialization.data(withJSONObject: metadataOnly).write(to: extractor.marker("response.json"))
let title = try await YouTubeResolver.resolveTitle(genericPage, executableURL: extractor.executable)
try require(title == "Title without playable formats", "bundled yt-dlp returns title metadata without playable formats or runtime")
} }
static func bundledHelpers(root: URL) throws { static func bundledHelpers(root: URL) throws {
@@ -479,9 +774,9 @@ struct YouTubeResolverChecks {
} }
static func main() async throws { static func main() async throws {
// A broken pipe/cancellation implementation must fail, not leave this check hanging. // Includes five cold starts of the bundled extractor against offline metadata.
let watchdog = Task { let watchdog = Task {
try await Task.sleep(for: .seconds(30)) try await Task.sleep(for: .seconds(45))
fatalError("FAIL: resolver checks exceeded their bounded subprocess deadline") fatalError("FAIL: resolver checks exceeded their bounded subprocess deadline")
} }
defer { watchdog.cancel() } defer { watchdog.cancel() }
@@ -493,10 +788,17 @@ 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)
try await subprocesses(root: root, runtime: runtime) try await subprocesses(root: root, runtime: runtime)
print("PASS: extractor subprocess isolation and cancellation")
fflush(stdout)
try await titles(root: root)
print("PASS: independent native YouTube and generic title resolution")
fflush(stdout)
try await actualQualitySelection(root: root, runtime: runtime) try await actualQualitySelection(root: root, runtime: runtime)
print("YouTube resolver checks passed") print("YouTube resolver checks passed")
} }
+265
View File
@@ -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)
}
}
}
+62 -7
View File
@@ -18,7 +18,13 @@ SWIFT_SOURCES := Sources/App/TerebiApp.swift Sources/App/PlayerActions.swift Sou
RUNTIME_SOURCE := Sources/Launcher/FFmpegRuntime.swift 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/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/SponsorBlock.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)
CONFIG_LIBRARY := $(CONFIG_BIN)/libTerebiConfiguration.a CONFIG_LIBRARY := $(CONFIG_BIN)/libTerebiConfiguration.a
@@ -71,7 +77,7 @@ $(APP)/Contents/Resources/TOMLDecoder-LICENSE.txt: $(CONFIG_LIBRARY)
$(BUILD_DIR)/player.o: Sources/Core/player.cpp Sources/Core/player.h Sources/Core/player_macos.h Sources/Core/output.hpp Sources/Core/video_decoder.hpp Sources/Core/network_input.hpp Sources/Core/network_policy.hpp Sources/Core/progress_reporter.hpp Makefile $(NATIVE_STAMP) | $(BUILD_DIR) check-dependencies $(BUILD_DIR)/player.o: Sources/Core/player.cpp Sources/Core/player.h Sources/Core/player_macos.h Sources/Core/output.hpp Sources/Core/video_decoder.hpp Sources/Core/network_input.hpp Sources/Core/network_policy.hpp Sources/Core/progress_reporter.hpp Makefile $(NATIVE_STAMP) | $(BUILD_DIR) check-dependencies
$(CXX) $(CXXFLAGS) -c $< -o $@ $(CXX) $(CXXFLAGS) -c $< -o $@
$(BUILD_DIR)/progress_reporter.o: Sources/Core/progress_reporter.cpp Sources/Core/progress_reporter.hpp Sources/Core/player.h Makefile $(NATIVE_STAMP) | $(BUILD_DIR) $(BUILD_DIR)/progress_reporter.o: Sources/Core/progress_reporter.cpp Sources/Core/progress_reporter.hpp Sources/Core/network_policy.hpp Sources/Core/player.h Makefile $(NATIVE_STAMP) | $(BUILD_DIR)
$(CXX) $(CXXFLAGS) -c $< -o $@ $(CXX) $(CXXFLAGS) -c $< -o $@
$(BUILD_DIR)/cast_media.o: Sources/Core/cast_media.cpp Sources/Core/cast_media.h Sources/Core/video_decoder.hpp Sources/Core/network_policy.hpp Sources/Core/network_input.hpp Makefile $(NATIVE_STAMP) | $(BUILD_DIR) check-dependencies $(BUILD_DIR)/cast_media.o: Sources/Core/cast_media.cpp Sources/Core/cast_media.h Sources/Core/video_decoder.hpp Sources/Core/network_policy.hpp Sources/Core/network_input.hpp Makefile $(NATIVE_STAMP) | $(BUILD_DIR) check-dependencies
@@ -94,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 "$@"
@@ -157,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
@@ -205,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 $@
@@ -218,6 +236,14 @@ $(BUILD_DIR)/stream-resolver-check: Checks/stream-resolver.swift Sources/App/Str
check-stream: $(BUILD_DIR)/stream-resolver-check check-stream: $(BUILD_DIR)/stream-resolver-check
$(BUILD_DIR)/stream-resolver-check $(BUILD_DIR)/stream-resolver-check
$(BUILD_DIR)/miruro-metadata-check: Checks/miruro-metadata.swift Sources/App/MiruroMetadata.swift Sources/App/ResolvedSource.swift Sources/App/StreamResolver.swift Sources/App/YouTubeResolver.swift Makefile | $(BUILD_DIR)
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O $(DEBUG_FLAGS) Checks/miruro-metadata.swift Sources/App/MiruroMetadata.swift Sources/App/ResolvedSource.swift Sources/App/StreamResolver.swift Sources/App/YouTubeResolver.swift -framework Foundation -o $@
.PHONY: check-miruro-metadata
check: check-miruro-metadata
check-miruro-metadata: $(BUILD_DIR)/miruro-metadata-check
$(BUILD_DIR)/miruro-metadata-check
$(BUILD_DIR)/automatic-quality-check: Checks/automatic-quality.swift Sources/App/AutomaticQuality.swift Makefile | $(BUILD_DIR) check-dependencies $(BUILD_DIR)/automatic-quality-check: Checks/automatic-quality.swift Sources/App/AutomaticQuality.swift Makefile | $(BUILD_DIR) check-dependencies
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O $(DEBUG_FLAGS) Checks/automatic-quality.swift Sources/App/AutomaticQuality.swift -framework Foundation -o $@ $(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O $(DEBUG_FLAGS) Checks/automatic-quality.swift Sources/App/AutomaticQuality.swift -framework Foundation -o $@
@@ -262,18 +288,31 @@ $(BUILD_DIR)/playback-progress-check: Checks/playback-progress.swift Sources/App
check-progress: $(BUILD_DIR)/playback-progress-check check-progress: $(BUILD_DIR)/playback-progress-check
$(BUILD_DIR)/playback-progress-check $(BUILD_DIR)/playback-progress-check
$(BUILD_DIR)/progress-reporting-check: Checks/progress-reporting.mm Sources/Core/progress_reporter.cpp Sources/Core/progress_reporter.hpp Sources/Core/player.h Makefile | $(BUILD_DIR) $(BUILD_DIR)/progress-reporting-check: Checks/progress-reporting.mm Sources/Core/progress_reporter.cpp Sources/Core/progress_reporter.hpp Sources/Core/network_policy.hpp Sources/Core/player.h Makefile | $(BUILD_DIR)
$(CXX) $(CXXFLAGS) -fobjc-arc Checks/progress-reporting.mm Sources/Core/progress_reporter.cpp -framework Foundation -o $@ $(CXX) $(CXXFLAGS) -fobjc-arc Checks/progress-reporting.mm Sources/Core/progress_reporter.cpp -framework Foundation -o $@
check-reporting: $(BUILD_DIR)/progress-reporting-check check-reporting: $(BUILD_DIR)/progress-reporting-check
$(BUILD_DIR)/progress-reporting-check $(BUILD_DIR)/progress-reporting-check
$(BUILD_DIR)/player-command-check: Checks/player-command.swift Sources/CLI/PlayerCommand.swift Sources/App/YouTubeResolver.swift Sources/App/ResolvedSource.swift Sources/App/StreamResolver.swift Makefile | $(BUILD_DIR) check-dependencies $(BUILD_DIR)/player-command-check: Checks/player-command.swift Sources/CLI/PlayerCommand.swift Sources/App/YouTubeResolver.swift Sources/App/ResolvedSource.swift Sources/App/StreamResolver.swift Sources/App/StreamQueue.swift Makefile | $(BUILD_DIR) check-dependencies
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O $(DEBUG_FLAGS) Checks/player-command.swift Sources/CLI/PlayerCommand.swift Sources/App/YouTubeResolver.swift Sources/App/ResolvedSource.swift Sources/App/StreamResolver.swift -framework Foundation -o $@ $(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O $(DEBUG_FLAGS) Checks/player-command.swift Sources/CLI/PlayerCommand.swift Sources/App/YouTubeResolver.swift Sources/App/ResolvedSource.swift Sources/App/StreamResolver.swift Sources/App/StreamQueue.swift -framework Foundation -o $@
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 $@
@@ -286,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 $@
@@ -317,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 $@
@@ -326,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
+112 -10
View File
@@ -1,6 +1,6 @@
# Terebi # Terebi
A native macOS player for local media, YouTube videos and Miruro episodes, with a local-file queue, 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,13 +19,21 @@ 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; **+** 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 a supported link. YouTube supports individual videos, not playlists or channels. Miruro links must use `https://barelystarted.miruro.tv/watch/<opaque-id>/<slug>?ep=<episode>` with an explicit positive episode number; arbitrary streaming sites are not supported. - **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.
- **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.
- **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. 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:** YouTube defaults to SDR up to 1080p. Use **gear → Quality** for other available 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
@@ -39,6 +47,7 @@ To run without installing, use `make run`. The built `.build/terebi.app` is self
| Mute / unmute | M | | Mute / unmute | M |
| Fullscreen | F or ⌃⌘F; Esc to leave | | Fullscreen | F or ⌃⌘F; Esc to leave |
| Compact mode | P or ⌃⌘P | | Compact mode | P or ⌃⌘P |
| Show/hide queue (normal or compact) | ⇧⌘L |
| Always on top | ⇧P | | Always on top | ⇧P |
| Close media | ⇧⌘W | | Close media | ⇧⌘W |
| Quit | Q or ⌘Q | | Quit | Q or ⌘Q |
@@ -49,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 YouTube 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
@@ -57,12 +66,42 @@ Cast output is limited to 1080p/30 fps; frame stepping and YouTube quality chang
terebi "/path/to/video.mp4" terebi "/path/to/video.mp4"
terebi 'https://youtu.be/jNQXAC9IVRw' terebi 'https://youtu.be/jNQXAC9IVRw'
terebi --new-instance --no-config "/path/to/video.mp4" terebi --new-instance --no-config "/path/to/video.mp4"
terebi --enqueue --queue-file "/absolute/private/session/queue.json"
terebi --help terebi --help
``` ```
The CLI opens one file or supported stream, reusing the running player by default. With no arguments, it opens or activates the player. `--new-instance` starts a separate player; `--no-config` uses built-in settings on a fresh launch. Put these options before the media argument. Before installation, use `.build/terebi` instead of `terebi`. The CLI opens one file or supported stream, reusing the running player by default. With no arguments, it opens or activates the player. `--new-instance` starts a separate player; `--no-config` uses built-in settings on a fresh launch. Put these options before the media argument. Before installation, use `.build/terebi` instead of `terebi`.
For resolved-source playback 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
@@ -72,16 +111,79 @@ 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.
### 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
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.
Optional startup preferences:
```toml
[miruro]
video_provider = ""
caption_provider = ""
```
An empty or omitted preference means **Auto**. Automatic video selection prefers Japanese Sun, then the returned provider order; each provider's variant order is respected. A bare name such as `"bee"` prefers that provider's Japanese variant; `"bee:ssub"` or `"sun:sub"` selects a specific variant. Dub requires an explicit choice such as `"bun:dub"`—it is never an automatic language fallback. Dub-only episodes retain their choices in the menu after Automatic reports that no Japanese source is available.
Automatic captions prefer the selected provider/track, then a compatible track from that provider, Sun, and returned order. Caption discovery is independent of video compatibility, including iframe-only providers. Missing preferences fall back to automatic selection. Provider names accept up to 128 ASCII letters, digits, underscores or hyphens, optionally followed by `:ssub`, `:sub` or `:dub`. Unknown config keys and non-string values are rejected.
Native playback supports the returned proxy configuration and HLS query/segment-origin rules used by Bee/Hop. MegaPlay iframe sources used by Bun are extracted natively; no embedded browser, browser cookies or advertising scripts are run. Unsupported embed hosts fail rather than being passed to the native decoder. Source expiry and upstream deployment changes can still interrupt playback.
## 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.
- Private, age-restricted, region-blocked or sign-in-required YouTube videos may fail; browser cookies are not used. Rebuild and reinstall for bundled YouTube 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.
## Development ## Development
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.
`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.
+42
View File
@@ -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
View File
@@ -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>
@@ -19,6 +20,11 @@
<array><string>public.movie</string><string>public.audio</string></array> <array><string>public.movie</string><string>public.audio</string></array>
</dict> </dict>
</array> </array>
<key>CFBundleURLTypes</key>
<array><dict>
<key>CFBundleURLName</key><string>Stream queue handoff</string>
<key>CFBundleURLSchemes</key><array><string>terebi-enqueue</string></array>
</dict></array>
<key>NSHighResolutionCapable</key><true/> <key>NSHighResolutionCapable</key><true/>
<key>NSPrincipalClass</key><string>NSApplication</string> <key>NSPrincipalClass</key><string>NSApplication</string>
<key>NSLocalNetworkUsageDescription</key><string>Discover Cast devices and stream your selected media to a TV on your local network.</string> <key>NSLocalNetworkUsageDescription</key><string>Discover Cast devices and stream your selected media to a TV on your local network.</string>
+2 -1
View File
@@ -97,7 +97,8 @@ private struct CastMediaCleanup: @unchecked Sendable { let handle: OpaquePointer
var tracks: [CastTextTrack] = [] var tracks: [CastTextTrack] = []
for (index, subtitle) in input.subtitles.enumerated() { for (index, subtitle) in input.subtitles.enumerated() {
try Task.checkCancellation() try Task.checkCancellation()
let data = try await WebVTTDocument.loadData(url: subtitle.url, headers: subtitle.headers) let prepared = try await MiruroTransport.subtitle(subtitle)
let data = try await WebVTTDocument.loadData(url: prepared.url, headers: prepared.headers)
guard let text = String(data: data, encoding: .utf8) else { throw CastDeliveryError() } guard let text = String(data: data, encoding: .utf8) else { throw CastDeliveryError() }
_ = try WebVTTDocument.parse(text) _ = try WebVTTDocument.parse(text)
let trackURL = try owner.subtitle(id: index + 1, data: data) let trackURL = try owner.subtitle(id: index + 1, data: data)
+9 -1
View File
@@ -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
+161 -38
View File
@@ -7,6 +7,8 @@ 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
static let transitionDuration: TimeInterval = 0.25 static let transitionDuration: TimeInterval = 0.25
var snappingEnabled: Bool { var snappingEnabled: Bool {
@@ -29,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
@@ -44,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?
@@ -59,6 +72,7 @@ final class CompactWindowController: NSObject {
private var originatedFullScreen = false private var originatedFullScreen = false
private var videoAspect = 16.0 / 9.0 private var videoAspect = 16.0 / 9.0
private var fittedAspect: Double? private var fittedAspect: Double?
private var fittedVideoAspect = 16.0 / 9.0
private let reduceMotion: () -> Bool private let reduceMotion: () -> Bool
private let defaults: UserDefaults private let defaults: UserDefaults
private static let widthKey = "compactWindowWidth" private static let widthKey = "compactWindowWidth"
@@ -75,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,
@@ -85,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
@@ -131,12 +146,40 @@ 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.
func queueLayout(visible: Bool) -> (height: CGFloat, frame: NSRect)? {
guard isCompact, !isTransitioning, let window = presentationWindow else { return nil }
let usable = Self.usableFrame(visibleFrame(for: window))
let height = visible ? min(240, max(0, usable.height - 90)) : 0
let video = Self.compactSize(aspect: videoAspect, width: window.frame.width,
available: NSSize(width: usable.width, height: usable.height - height))
let size = NSSize(width: video.width, height: video.height + height)
let frame = NSRect(x: min(max(window.frame.minX, usable.minX), usable.maxX - size.width),
y: max(usable.minY, min(window.frame.maxY, usable.maxY) - size.height),
width: size.width, height: size.height)
return (height, frame)
}
func updateQueue(height: CGFloat, frame: NSRect) {
guard isCompact, let window = presentationWindow else { return }
window.resizeIncrements = NSSize(width: 1, height: 1)
queueHeight = height
fittedVideoAspect = frame.width / (frame.height - height)
window.minSize = NSSize(width: 160, height: 90 + height)
window.contentMinSize = window.minSize
window.setFrame(frame, display: true)
if height == 0, Self.canFitAspect(videoAspect, available: Self.usableFrame(visibleFrame(for: window)).size) {
window.aspectRatio = NSSize(width: videoAspect, height: 1)
}
onChange?()
}
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) }
} }
@@ -144,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:
@@ -157,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
@@ -229,12 +277,23 @@ final class CompactWindowController: NSObject {
func windowWillResize(_ sender: NSWindow, to proposedSize: NSSize) -> NSSize { func windowWillResize(_ sender: NSWindow, to proposedSize: NSSize) -> NSSize {
let proposed = original?.windowWillResize?(sender, to: proposedSize) ?? proposedSize let proposed = original?.windowWillResize?(sender, to: proposedSize) ?? proposedSize
guard let owner, !owner.isCompact, !owner.isTransitioning, guard let owner, !owner.isTransitioning,
!sender.styleMask.contains(.fullScreen), !sender.styleMask.contains(.fullScreen) else { return proposed }
let layout = owner.normalVideoLayout?(), let layout: (aspect: Double, sidebarWidth: CGFloat)
layout.aspect.isFinite, layout.aspect > 0 else { return proposed } let extraHeight: CGFloat
if owner.isCompact {
guard owner.queueHeight > 0 else { return proposed }
layout = (owner.fittedVideoAspect, 0)
extraHeight = owner.queueHeight
} else {
guard let normal = owner.normalVideoLayout?(),
normal.aspect.isFinite, normal.aspect > 0 else { return proposed }
layout = normal
extraHeight = 0
}
// The sidebar stays fixed-width; only the video area is aspect-locked. // The sidebar stays fixed-width; only the video area is aspect-locked.
let chrome = sender.frame.height - sender.contentRect(forFrameRect: sender.frame).height let chrome = owner.isCompact ? extraHeight
: sender.frame.height - sender.contentRect(forFrameRect: sender.frame).height
let start = resizeStartSize ?? sender.frame.size let start = resizeStartSize ?? sender.frame.size
let widthChange = abs(proposed.width - start.width) let widthChange = abs(proposed.width - start.width)
let heightChange = abs(proposed.height - start.height) * layout.aspect let heightChange = abs(proposed.height - start.height) * layout.aspect
@@ -277,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
@@ -291,11 +350,13 @@ final class CompactWindowController: NSObject {
return return
} }
if let snapshot { if let snapshot {
setState(compact: false, transitioning: animated) queueHeight = 0
window.aspectRatio = .zero setState(compact: false, transitioning: true)
window.minSize = .zero let restoringWindow = presentationWindow ?? window
window.contentMinSize = .zero restoringWindow.aspectRatio = .zero
animateFrame(snapshot.frame, of: window, animated: animated) { [weak self, weak window] in restoringWindow.minSize = .zero
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()
@@ -315,32 +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.
setState(compact: true, transitioning: animated) guard let content = contentView else {
window.aspectRatio = .zero finishSession()
window.minSize = .zero return
window.contentMinSize = .zero }
// A borderless NSWindow cannot become key; retain native keyboard and resize behavior. let snapshot = Snapshot(normalWindow, contentView: content)
window.styleMask.formUnion([.titled, .fullSizeContentView, .resizable]) self.snapshot = snapshot
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
@@ -359,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() {
@@ -386,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()
@@ -398,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)
@@ -422,18 +541,22 @@ final class CompactWindowController: NSObject {
let aspect = videoAspect let aspect = videoAspect
let savedWidth = initial ? defaults.double(forKey: Self.widthKey) : Double(window.frame.width) let savedWidth = initial ? defaults.double(forKey: Self.widthKey) : Double(window.frame.width)
let width = savedWidth.isFinite && savedWidth > 0 ? CGFloat(savedWidth) : 320 let width = savedWidth.isFinite && savedWidth > 0 ? CGFloat(savedWidth) : 320
var frame = Self.initialFrame(aspect: videoAspect, visibleFrame: visibleFrame, width: width) let video = Self.compactSize(aspect: videoAspect, width: width,
available: NSSize(width: usable.width, height: max(0, usable.height - queueHeight)))
var frame = NSRect(x: usable.maxX - video.width, y: usable.maxY - video.height - queueHeight,
width: video.width, height: video.height + queueHeight)
if !initial { if !initial {
frame.origin.x = min(max(window.frame.minX, usable.minX), usable.maxX - frame.width) frame.origin.x = min(max(window.frame.minX, usable.minX), usable.maxX - frame.width)
frame.origin.y = min(max(window.frame.minY, usable.minY), usable.maxY - frame.height) frame.origin.y = max(usable.minY, min(window.frame.maxY, usable.maxY) - frame.height)
} }
window.resizeIncrements = NSSize(width: 1, height: 1) window.resizeIncrements = NSSize(width: 1, height: 1)
animateFrame(frame, of: window, animated: animated) { [weak self, weak window] in animateFrame(frame, of: window, animated: animated) { [weak self, weak window] in
guard let self, let window else { return } guard let self, let window else { return }
if Self.canFitAspect(aspect, available: usable.size) { if self.queueHeight == 0, Self.canFitAspect(aspect, available: usable.size) {
window.aspectRatio = NSSize(width: aspect, height: 1) window.aspectRatio = NSSize(width: aspect, height: 1)
} }
self.fittedAspect = aspect self.fittedAspect = aspect
self.fittedVideoAspect = video.width / video.height
if initial { self.reconcile() } if initial { self.reconcile() }
} }
} }
+205
View File
@@ -0,0 +1,205 @@
import Foundation
import CommonCrypto
import CryptoKit
/// Native counterpart of MegaPlay's deployed e1-player and newclient codecs.
struct MiruroEmbed: Sendable {
let url: URL
// Public embed-origin headers apply to its cross-origin media resources too:
// deployed CDN segment endpoints reject requests without this Referer.
let headers: String
private let key: Data
private let iv: Data
static func resolve(url: URL, now: @Sendable () -> Date = { Date() }) async throws -> MiruroEmbed {
do {
try validateOrigin(url)
let configuration = URLSessionConfiguration.ephemeral
configuration.timeoutIntervalForRequest = 25
configuration.timeoutIntervalForResource = 25
configuration.httpShouldSetCookies = false
configuration.httpCookieStorage = nil
configuration.urlCache = nil
let session = URLSession(configuration: configuration, delegate: EmbedRedirectPolicy(), delegateQueue: nil)
defer { session.invalidateAndCancel() }
let html = try await text(url, session: session)
guard let player = capture(#"<[^>]+\bid\s*=\s*["']megaplay-player["'][^>]*>"#, html),
let id = capture(#"\bdata-id\s*=\s*["']([^"']+)["']"#, player),
let base = capture(#"\bbase_url\s*:\s*["']([^"']+)["']"#, html),
let baseURL = URL(string: base.replacingOccurrences(of: "\\/", with: "/"), relativeTo: url)?.absoluteURL else {
throw EmbedError()
}
try validateOrigin(baseURL)
// Only read script data; never evaluate provider JavaScript.
let scripts = captures(#"<script\b[^>]*\bsrc\s*=\s*["']([^"']+)["']"#, html)
guard let scriptURL = scripts.compactMap({ URL(string: $0, relativeTo: url)?.absoluteURL })
.first(where: { $0.path.hasSuffix("/e1-player.min.js") }) else { throw EmbedError() }
try validateOrigin(scriptURL)
let script = try await text(scriptURL, session: session)
guard let keyText = capture(#"\bSOURCE_ENC_KEY\s*=\s*"([^"\\]*)""#, script),
let ivText = capture(#"\bSOURCE_ENC_IV\s*=\s*"([^"\\]*)""#, script),
let secret = capture(#"\bCDN_TOKEN_SECRET\s*=\s*"([^"\\]*)""#, script) else { throw EmbedError() }
let key = padded(keyText, count: 32)
let iv = padded(ivText, count: 16)
var endpoint = URLComponents(url: baseURL.appendingPathComponent("stream/getSources"), resolvingAgainstBaseURL: false)
endpoint?.queryItems = [URLQueryItem(name: "id", value: id)]
guard let endpointURL = endpoint?.url else { throw EmbedError() }
let payload = try await request(endpointURL, session: session)
guard let object = try JSONSerialization.jsonObject(with: payload) as? [String: Any],
object["error"] == nil || (object["error"] as? Bool) == false else { throw EmbedError() }
let source: Any
if let encrypted = object["enc"] as? String {
source = try JSONSerialization.jsonObject(with: decrypt(encrypted, key: key, iv: iv))
guard (source as? [String: Any])?["file"] is String else { throw EmbedError() }
} else if let plain = object["sources"] {
source = plain
} else { throw EmbedError() }
guard let file = streamFile(source), let stream = URL(string: file) else { throw EmbedError() }
try validateHTTP(stream)
let tokenEnabled = capture(#"\bcdnTokenEnable\s*:\s*(false|0|"false"|'false'|"0"|'0')\s*[,}]"#, html) == nil
let finalURL = try attachToken(stream, secret: secret, enabled: tokenEnabled, now: now())
try Task.checkCancellation()
return MiruroEmbed(url: finalURL, headers: "Referer: https://megaplay.buzz/\r\nOrigin: https://megaplay.buzz\r\n", key: key, iv: iv)
} catch {
if Task.isCancelled || error is CancellationError || (error as? URLError)?.code == .cancelled {
throw CancellationError()
}
throw EmbedError()
}
}
func resourceURL(_ url: URL) throws -> URL {
try Self.validateHTTP(url)
guard let token = Self.capture(#"/segment/([A-Za-z0-9_-]+)"#, url.path) else { return url }
let data = try Self.decrypt(token, key: key, iv: iv)
guard let text = String(data: data, encoding: .utf8), let decoded = URL(string: text) else { throw EmbedError() }
try Self.validateHTTP(decoded)
return decoded
}
func resourceData(_ data: Data, url: URL) throws -> Data {
try Self.validateHTTP(url)
// newclient v4.23 SegmentStrip: only these hosts, and only bodies longer than 252 bytes.
guard let host = url.host,
Self.capture(#"ibyteimg\.com|tiktokcdn\.com|ipstatp\.com|yoot\.akirax\.buzz"#, host) != nil,
data.count > 252 else { return data }
return Data(data.dropFirst(252))
}
private static func request(_ url: URL, session: URLSession) async throws -> Data {
try validateOrigin(url)
var request = URLRequest(url: url)
request.timeoutInterval = 25
request.setValue("https://megaplay.buzz/", forHTTPHeaderField: "Referer")
let (bytes, response) = try await session.bytes(for: request)
let limit = 2 * 1024 * 1024
guard let response = response as? HTTPURLResponse, (200...299).contains(response.statusCode),
response.expectedContentLength <= Int64(limit) else { throw EmbedError() }
var data = Data()
for try await byte in bytes {
try Task.checkCancellation()
guard data.count < limit else { throw EmbedError() }
data.append(byte)
}
return data
}
private static func text(_ url: URL, session: URLSession) async throws -> String {
let data = try await request(url, session: session)
guard let text = String(data: data, encoding: .utf8) else { throw EmbedError() }
return text
}
private static func streamFile(_ source: Any) -> String? {
if let string = source as? String { return string }
if let array = source as? [Any], let first = array.first { return streamFile(first) }
return (source as? [String: Any])?["file"] as? String
}
private static func attachToken(_ url: URL, secret: String, enabled: Bool, now: Date) throws -> URL {
guard var parts = URLComponents(url: url, resolvingAgainstBaseURL: false) else { throw EmbedError() }
guard enabled, !(parts.queryItems ?? []).contains(where: { $0.name == "token" }),
let pathKey = capture(#"/([a-f0-9]{32}/[a-f0-9]{32})/"#, url.path) else { return url }
let seconds = now.timeIntervalSince1970.rounded(.down)
guard seconds.isFinite, seconds >= 0, seconds < Double(Int64.max - 90) else { throw EmbedError() }
let message = Data("\(Int64(seconds) + 90)|\(pathKey.lowercased())".utf8)
let signature = HMAC<SHA256>.authenticationCode(for: message, using: SymmetricKey(data: Data(secret.utf8)))
let token = base64URL(message) + "." + base64URL(Data(signature))
var items = parts.queryItems ?? []
items.append(URLQueryItem(name: "token", value: token))
parts.queryItems = items
guard let result = parts.url else { throw EmbedError() }
return result
}
private static func padded(_ string: String, count: Int) -> Data {
var data = Data(string.utf8.prefix(count))
data.append(Data(repeating: 0, count: count - data.count))
return data
}
private static func base64URL(_ data: Data) -> String {
data.base64EncodedString().replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "")
}
private static func decrypt(_ token: String, key: Data, iv: Data) throws -> Data {
var encoded = token.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
encoded += String(repeating: "=", count: (4 - encoded.count % 4) % 4)
guard let encrypted = Data(base64Encoded: encoded), !encrypted.isEmpty,
encrypted.count % kCCBlockSizeAES128 == 0 else { throw EmbedError() }
var output = Data(count: encrypted.count + kCCBlockSizeAES128)
let capacity = output.count
var written = 0
let status = output.withUnsafeMutableBytes { destination in
encrypted.withUnsafeBytes { input in
key.withUnsafeBytes { keyBytes in
iv.withUnsafeBytes { ivBytes in
CCCrypt(CCOperation(kCCDecrypt), CCAlgorithm(kCCAlgorithmAES), CCOptions(kCCOptionPKCS7Padding),
keyBytes.baseAddress, key.count, ivBytes.baseAddress, input.baseAddress, encrypted.count,
destination.baseAddress, capacity, &written)
}
}
}
}
guard status == kCCSuccess else { throw EmbedError() }
output.count = written
return output
}
private static func validateHTTP(_ url: URL) throws {
guard ["https", "http"].contains(url.scheme?.lowercased() ?? ""),
let host = url.host, !host.isEmpty, url.user == nil, url.password == nil else { throw EmbedError() }
}
private static func validateOrigin(_ url: URL) throws {
try validateHTTP(url)
guard url.scheme?.lowercased() == "https", url.host?.lowercased() == "megaplay.buzz",
url.port == nil || url.port == 443 else { throw EmbedError() }
}
private static func capture(_ pattern: String, _ text: String) -> String? {
captures(pattern, text).first
}
private static func captures(_ pattern: String, _ text: String) -> [String] {
guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else { return [] }
return regex.matches(in: text, range: NSRange(text.startIndex..., in: text)).compactMap { match in
let index = match.numberOfRanges > 1 ? 1 : 0
guard let range = Range(match.range(at: index), in: text) else { return nil }
return String(text[range])
}
}
}
private struct EmbedError: LocalizedError {
var errorDescription: String? { "The embedded provider could not prepare this stream." }
}
private final class EmbedRedirectPolicy: NSObject, URLSessionTaskDelegate, @unchecked Sendable {
func urlSession(_ session: URLSession, task: URLSessionTask,
willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest,
completionHandler: @escaping (URLRequest?) -> Void) {
completionHandler(nil)
}
}
+344
View File
@@ -0,0 +1,344 @@
import Foundation
import CoreFoundation
struct MiruroInterval: Sendable, Equatable {
enum Kind: Sendable, Hashable { case intro, outro }
let kind: Kind
var start: Double
var end: Double
let episode: Int
var rawKind: String = ""
var provider: String? = nil
var votes: Double? = nil
var referenceLength: Double? = nil
}
struct MiruroMetadata: Sendable {
let title: String?
let intervals: [MiruroInterval]
func visibleIntervals(episode: Int, duration: Double) -> [MiruroInterval] {
guard duration.isFinite, duration > 0 else { return [] }
var intro: MiruroInterval?, outro: MiruroInterval?
for var row in intervals {
guard row.episode == episode, row.start.isFinite, row.end.isFinite,
row.end > row.start,
!(row.provider?.range(of: "crunchyroll", options: .caseInsensitive) != nil),
(row.votes ?? 0).isFinite, (row.votes ?? 0) >= 0 else { continue }
if let length = row.referenceLength, length.isFinite, length > 0,
abs(length - duration) / duration > 0.05 { continue }
row.start = max(0, row.start)
row.end = min(duration, row.end)
guard row.end > row.start else { continue }
// Strictly greater preserves input order on ties, equivalent to stable descending votes.
switch row.kind {
case .intro:
if intro == nil || (row.votes ?? 0) > (intro?.votes ?? 0) { intro = row }
case .outro:
if outro == nil || (row.votes ?? 0) > (outro?.votes ?? 0) { outro = row }
}
}
var selected: [MiruroInterval] = []
if let intro { selected.append(intro) }
if let outro { selected.append(outro) }
return selected
}
}
private struct MiruroMetadataError: LocalizedError {
var errorDescription: String? { "Miruro optional metadata was unavailable or invalid." }
}
enum MiruroMetadataResolver {
static let bodyLimit = 2 * 1024 * 1024
static let redirectLimit = 4
// Both HTTP redirects and SvelteKit's HTTP-200 redirect envelopes use this policy.
struct Route {
let mediaID: String
let episode: Int
private(set) var url: URL
private var visited: Set<URL>
private var redirects = 0
init(mediaID: String, episode: Int) throws {
guard ResolvedSource.validIdentity(mediaID: mediaID, episode: episode) else { throw MiruroMetadataError() }
self.mediaID = mediaID
self.episode = episode
url = URL(string: "https://barelystarted.miruro.tv/watch/\(mediaID)/episode/__data.json?ep=\(episode)")!
visited = [url]
}
mutating func follow(_ location: String) throws {
guard redirects < MiruroMetadataResolver.redirectLimit,
!location.unicodeScalars.contains(where: {
CharacterSet.whitespacesAndNewlines.contains($0) || CharacterSet.controlCharacters.contains($0)
}), !location.contains("\\"),
let destination = URL(string: location, relativeTo: url)?.absoluteURL,
var parts = URLComponents(url: destination, resolvingAgainstBaseURL: true),
parts.scheme?.lowercased() == "https", parts.host?.lowercased() == "barelystarted.miruro.tv",
parts.port == nil || parts.port == 443, parts.user == nil, parts.password == nil,
parts.fragment == nil, parts.percentEncodedPath == parts.path,
parts.percentEncodedQuery == "ep=\(episode)" else { throw MiruroMetadataError() }
let path = parts.path.split(separator: "/", omittingEmptySubsequences: false)
guard (path.count == 4 || path.count == 5), path[0].isEmpty, path[1] == "watch",
path[2] == Substring(mediaID), !path[3].isEmpty, path[3].utf8.count <= 512,
path[3].utf8.allSatisfy({ (65...90).contains($0) || (97...122).contains($0) ||
(48...57).contains($0) || $0 == 45 || $0 == 95 }),
path.count == 4 || path[4] == "__data.json" else { throw MiruroMetadataError() }
parts.scheme = "https"
parts.host = "barelystarted.miruro.tv"
parts.port = nil
parts.path = "/watch/\(mediaID)/\(path[3])/__data.json"
guard let next = parts.url, visited.insert(next).inserted else { throw MiruroMetadataError() }
redirects += 1
url = next
}
}
static func resolve(mediaID: String, episode: Int) async throws -> MiruroMetadata {
try Task.checkCancellation()
var route = try Route(mediaID: mediaID, episode: episode)
let configuration = URLSessionConfiguration.ephemeral
configuration.timeoutIntervalForRequest = 25
configuration.timeoutIntervalForResource = 25
configuration.httpShouldSetCookies = false
configuration.httpCookieStorage = nil
configuration.urlCache = nil
let session = URLSession(configuration: configuration, delegate: MetadataRedirectPolicy(), delegateQueue: nil)
defer { session.invalidateAndCancel() }
do {
while true {
try Task.checkCancellation()
var request = URLRequest(url: route.url)
request.timeoutInterval = 25
request.setValue("application/json, text/sveltekit-data", forHTTPHeaderField: "Accept")
let (bytes, response) = try await session.bytes(for: request)
guard let http = response as? HTTPURLResponse else { throw MiruroMetadataError() }
if [301, 302, 303, 307, 308].contains(http.statusCode) {
guard let location = http.value(forHTTPHeaderField: "Location") else { throw MiruroMetadataError() }
try route.follow(location)
continue
}
try validateResponse(response)
var body = Data()
if response.expectedContentLength > 0 { body.reserveCapacity(Int(response.expectedContentLength)) }
for try await byte in bytes {
try Task.checkCancellation()
guard body.count < bodyLimit else { throw MiruroMetadataError() }
body.append(byte)
}
try Task.checkCancellation()
switch try decodeRoute(body, mediaID: mediaID, episode: episode) {
case .redirect(let location): try route.follow(location)
case .metadata(let metadata): return metadata
}
}
} catch {
if Task.isCancelled || error is CancellationError || (error as? URLError)?.code == .cancelled {
throw CancellationError()
}
throw MiruroMetadataError()
}
}
static func validateResponse(_ response: URLResponse) throws {
guard let http = response as? HTTPURLResponse, http.statusCode == 200,
["application/json", "text/sveltekit-data"].contains(http.mimeType?.lowercased() ?? ""),
http.expectedContentLength <= Int64(bodyLimit) else { throw MiruroMetadataError() }
}
enum RouteData { case redirect(String), metadata(MiruroMetadata) }
static func decode(_ data: Data, mediaID: String, episode: Int) throws -> MiruroMetadata {
guard case .metadata(let metadata) = try decodeRoute(data, mediaID: mediaID, episode: episode) else {
throw MiruroMetadataError()
}
return metadata
}
static func decodeRoute(_ data: Data, mediaID: String, episode: Int) throws -> RouteData {
guard data.count <= bodyLimit, ResolvedSource.validIdentity(mediaID: mediaID, episode: episode) else {
throw MiruroMetadataError()
}
// Bound JSON nesting before Foundation allocates containers. Devalue traversal below is schema-guided,
// so unrelated source payloads and unsupported tagged types are never recursively expanded.
var depth = 0, quoted = false, escaped = false
for byte in data {
if quoted {
if escaped { escaped = false }
else if byte == 92 { escaped = true }
else if byte == 34 { quoted = false }
} else if byte == 34 { quoted = true }
else if byte == 91 || byte == 123 {
depth += 1
guard depth <= 64 else { throw MiruroMetadataError() }
} else if byte == 93 || byte == 125 {
depth -= 1
guard depth >= 0 else { throw MiruroMetadataError() }
}
}
let lines = data.split(separator: 10).filter { !$0.allSatisfy({ $0 == 13 || $0 == 32 || $0 == 9 }) }
guard !lines.isEmpty, lines.count <= 128 else { throw MiruroMetadataError() }
let envelopes = try lines.map { line -> [String: Any] in
guard let envelope = try JSONSerialization.jsonObject(with: Data(line)) as? [String: Any] else {
throw MiruroMetadataError()
}
return envelope
}
// Chunks may arrive before the initial envelope and each has its own independent table.
let initials = envelopes.filter { ($0["type"] as? String) == "data" || ($0["type"] as? String) == "redirect" }
guard initials.count == 1, let initial = initials.first else { throw MiruroMetadataError() }
if initial["type"] as? String == "redirect" {
guard envelopes.count == 1, let location = initial["location"] as? String else { throw MiruroMetadataError() }
return .redirect(location)
}
guard let nodes = initial["nodes"] as? [Any], nodes.count > 1,
let node = nodes[1] as? [String: Any], node["type"] as? String == "data",
let table = node["data"] as? [Any], !table.isEmpty else { throw MiruroMetadataError() }
let reader = MetadataReader(envelopes: envelopes)
let root = MetadataReader.Cursor(table: table, index: 0, depth: 0)
let media = try? reader.field(root, "media")
if let id = media.flatMap({ try? reader.string(reader.field($0, "id")) }), id != mediaID { throw MiruroMetadataError() }
if let number = try? reader.integer(reader.field(root, "episodeNumber")), number != episode { throw MiruroMetadataError() }
var series: String?
if let media, let title = try? reader.field(media, "title") {
series = cleanTitle(try? reader.string(title))
if series == nil {
for key in ["english", "romaji", "native"] {
if let value = cleanTitle(try? reader.string(reader.field(title, key))) { series = value; break }
}
}
}
let episodes = try? reader.field(root, "episodesData")
var episodeTitle: String?
if let episodes {
if let current = try? reader.field(episodes, "currentEpisode"),
(try? reader.integer(reader.field(current, "number"))) == episode {
episodeTitle = cleanTitle(try? reader.string(reader.field(current, "title")))
}
if episodeTitle == nil, let rows = try? reader.array(reader.field(episodes, "episodes")) {
for row in rows where (try? reader.integer(reader.field(row, "number"))) == episode {
episodeTitle = cleanTitle(try? reader.string(reader.field(row, "title")))
if episodeTitle != nil { break }
}
}
}
var intervals: [MiruroInterval] = []
if let episodes, let rows = try? reader.array(reader.field(episodes, "aniskip")) {
for row in rows {
guard let rawKind = try? reader.string(reader.field(row, "type")),
let number = try? reader.integer(reader.field(row, "episode")),
let start = try? reader.number(reader.field(row, "start")),
let end = try? reader.number(reader.field(row, "end")) else { continue }
let kind: MiruroInterval.Kind
switch rawKind {
case "op", "mixed-op": kind = .intro
case "ed", "mixed-ed": kind = .outro
default: continue
}
intervals.append(MiruroInterval(kind: kind, start: start, end: end, episode: number,
rawKind: rawKind, provider: try? reader.string(reader.field(row, "provider")),
votes: try? reader.number(reader.field(row, "votes")),
referenceLength: try? reader.number(reader.field(row, "episode_length"))))
}
}
let title: String?
if let series { title = series + " — Episode \(episode)" + (episodeTitle.map { ": " + $0 } ?? "") }
else { title = episodeTitle.map { "Episode \(episode): " + $0 } }
return .metadata(MiruroMetadata(title: title, intervals: intervals))
}
private static func cleanTitle(_ value: String?) -> String? {
guard let value, value.utf8.count <= 2048,
!value.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) }) else { return nil }
let title = value.trimmingCharacters(in: .whitespacesAndNewlines)
return title.isEmpty ? nil : title
}
}
// Only traverse fields needed for metadata. The budget counts repeated references as well as strings,
// bounding amplification without copying/expanding whole devalue objects or sourcesData.
private final class MetadataReader {
struct Cursor { let table: [Any]; let index: Int; let depth: Int }
private let envelopes: [[String: Any]]
private var budget = MiruroMetadataResolver.bodyLimit
init(envelopes: [[String: Any]]) { self.envelopes = envelopes }
private func charge(_ cost: Int = 32) throws {
guard cost <= budget else { throw MiruroMetadataError() }
budget -= cost
}
private func value(_ cursor: Cursor) throws -> Any {
let cursor = try resolved(cursor)
try charge()
let value = cursor.table[cursor.index]
if let tag = value as? [Any], tag.first is String { throw MiruroMetadataError() }
return value
}
private func reference(_ value: Any, from cursor: Cursor) throws -> Cursor {
guard let index = Self.integer(value), index >= 0 else { throw MiruroMetadataError() }
return Cursor(table: cursor.table, index: index, depth: cursor.depth + 1)
}
func field(_ cursor: Cursor, _ name: String) throws -> Cursor {
// A promise's resolved object must retain its own table for subsequent references.
let cursor = try resolved(cursor)
guard let object = try value(cursor) as? [String: Any], let reference = object[name] else { throw MiruroMetadataError() }
return try self.reference(reference, from: cursor)
}
private func resolved(_ cursor: Cursor) throws -> Cursor {
try charge()
guard cursor.depth < 32, cursor.table.indices.contains(cursor.index) else { throw MiruroMetadataError() }
guard let tag = cursor.table[cursor.index] as? [Any], tag.first as? String == "Promise" else { return cursor }
guard tag.count == 2 else { throw MiruroMetadataError() }
let id = try integer(reference(tag[1], from: cursor))
let matches = envelopes.filter { ($0["type"] as? String) == "chunk" && Self.integer($0["id"]) == id }
guard matches.count == 1, let chunk = matches.first, chunk["error"] == nil,
let table = chunk["data"] as? [Any] else { throw MiruroMetadataError() }
return try resolved(Cursor(table: table, index: 0, depth: cursor.depth + 1))
}
func array(_ cursor: Cursor) throws -> [Cursor] {
let cursor = try resolved(cursor)
guard let rows = try value(cursor) as? [Any] else { throw MiruroMetadataError() }
try charge(rows.count * 32)
// Bad individual references do not invalidate their siblings.
return rows.compactMap { try? reference($0, from: cursor) }
}
func string(_ cursor: Cursor) throws -> String {
guard let string = try value(cursor) as? String else { throw MiruroMetadataError() }
try charge(string.utf8.count)
return string
}
func number(_ cursor: Cursor) throws -> Double {
guard let number = try value(cursor) as? NSNumber, CFGetTypeID(number) != CFBooleanGetTypeID() else { throw MiruroMetadataError() }
return number.doubleValue
}
func integer(_ cursor: Cursor) throws -> Int {
guard let integer = Self.integer(try value(cursor)) else { throw MiruroMetadataError() }
return integer
}
private static func integer(_ value: Any?) -> Int? {
guard let number = value as? NSNumber, CFGetTypeID(number) != CFBooleanGetTypeID() else { return nil }
let value = number.doubleValue
guard value.isFinite, value.rounded(.towardZero) == value, value >= 0, value < Double(Int.max) else { return nil }
return Int(value)
}
}
private final class MetadataRedirectPolicy: NSObject, URLSessionTaskDelegate, @unchecked Sendable {
func urlSession(_ session: URLSession, task: URLSessionTask,
willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest,
completionHandler: @escaping (URLRequest?) -> Void) {
completionHandler(nil)
}
}
+493
View File
@@ -0,0 +1,493 @@
import Foundation
import Network
import Darwin
private struct MiruroPlaybackError: LocalizedError {
var errorDescription: String? { "Could not prepare this Miruro provider. Private source details are redacted." }
}
private final class MiruroMediaRedirectPolicy: NSObject, URLSessionTaskDelegate, Sendable {
func urlSession(_ session: URLSession, task: URLSessionTask,
willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest,
completionHandler: @escaping (URLRequest?) -> Void) { completionHandler(nil) }
}
// No cookies, redirects or credentials inherited from the app/browser session.
enum MiruroMediaHTTP {
private static let session: URLSession = {
let configuration = URLSessionConfiguration.ephemeral
configuration.httpShouldSetCookies = false
configuration.httpCookieStorage = nil
configuration.urlCredentialStorage = nil
configuration.urlCache = nil
configuration.timeoutIntervalForRequest = 20
configuration.timeoutIntervalForResource = 30
return URLSession(configuration: configuration, delegate: MiruroMediaRedirectPolicy(), delegateQueue: nil)
}()
static func read(_ url: URL, headers: String = "", limit: Int = 2 * 1024 * 1024,
range: String? = nil) async throws -> (Data, HTTPURLResponse) {
_ = try YouTubeResolver.validateURL(url.absoluteString)
var request = URLRequest(url: url)
for line in headers.components(separatedBy: "\r\n") where !line.isEmpty {
guard let colon = line.firstIndex(of: ":") else { throw MiruroPlaybackError() }
request.setValue(String(line[line.index(after: colon)...]).trimmingCharacters(in: .whitespaces),
forHTTPHeaderField: String(line[..<colon]))
}
if let range { request.setValue(range, forHTTPHeaderField: "Range") }
let (bytes, response) = try await session.bytes(for: request)
guard let response = response as? HTTPURLResponse,
[200, 206].contains(response.statusCode), response.expectedContentLength <= limit else {
throw MiruroPlaybackError()
}
var data = Data()
if response.expectedContentLength > 0 { data.reserveCapacity(Int(response.expectedContentLength)) }
for try await byte in bytes {
try Task.checkCancellation()
guard data.count < limit else { throw MiruroPlaybackError() }
data.append(byte)
}
return (data, response)
}
}
struct MiruroProxyDeployment: Sendable {
let base: URL
let key: [UInt8]
var alternate: URL? = nil
static func decode(_ html: String) throws -> Self {
let pattern = #"env:\s*(\{[^\n]*\})"#
guard let match = html.range(of: pattern, options: .regularExpression),
let start = html[match].firstIndex(of: "{"),
let data = String(html[start..<match.upperBound]).data(using: .utf8),
let env = try JSONSerialization.jsonObject(with: data) as? [String: String],
let text = env["PUBLIC_PROXY_A"], let base = URL(string: text),
base.scheme == "https", base.user == nil, base.password == nil, base.host != nil else {
throw MiruroPlaybackError()
}
let hex = env["PUBLIC_PROXY_OBF_KEY"] ?? ""
guard hex.count.isMultiple(of: 2), hex.count <= 512 else { throw MiruroPlaybackError() }
var key: [UInt8] = []
var index = hex.startIndex
while index < hex.endIndex {
let end = hex.index(index, offsetBy: 2)
guard let byte = UInt8(hex[index..<end], radix: 16) else { throw MiruroPlaybackError() }
key.append(byte)
index = end
}
return Self(base: base, key: key, alternate: env["PUBLIC_PROXY_B"].flatMap(URL.init(string:)))
}
// The deployed proxy also rewrites nested references. Decode those before
// applying provider rules; wrapping the proxy itself makes nested HLS fail.
func originalURL(_ url: URL) throws -> URL {
guard [base, alternate].compactMap({ $0 }).contains(where: {
$0.scheme == url.scheme && $0.host == url.host && $0.port == url.port
}) else { return url }
guard let encoded = url.path.split(separator: "/").first?.split(separator: "~").first else {
throw MiruroPlaybackError()
}
var text = encoded.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
text += String(repeating: "=", count: (4 - text.count % 4) % 4)
guard let data = Data(base64Encoded: text) else { throw MiruroPlaybackError() }
var bytes = Array(data)
if !key.isEmpty { for index in bytes.indices { bytes[index] ^= key[index % key.count] } }
guard let target = String(bytes: bytes, encoding: .utf8), let result = URL(string: target),
["http", "https"].contains(result.scheme), result.user == nil, result.password == nil else {
throw MiruroPlaybackError()
}
return result
}
func url(_ target: URL, referer: String?, proxy: MiruroProxy, suffix: String) throws -> URL {
func encode(_ text: String) -> String {
var bytes = Array(text.utf8)
if !key.isEmpty { for index in bytes.indices { bytes[index] ^= key[index % key.count] } }
return Data(bytes).base64EncodedString().replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "")
}
var path = encode(target.absoluteString)
if let referer, !referer.isEmpty { path += "~" + encode(referer) }
else if proxy.rotate { path += "~" }
if proxy.rotate { path += "~anon" }
guard let url = URL(string: base.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + "/" + path + suffix) else {
throw MiruroPlaybackError()
}
return url
}
}
private actor MiruroDeploymentCache {
static let shared = MiruroDeploymentCache()
private var deployment: MiruroProxyDeployment?
func get() async throws -> MiruroProxyDeployment {
if let deployment { return deployment }
let (data, _) = try await MiruroMediaHTTP.read(URL(string: "https://barelystarted.miruro.tv/")!)
guard let html = String(data: data, encoding: .utf8) else { throw MiruroPlaybackError() }
let result = try MiruroProxyDeployment.decode(html)
deployment = result
return result
}
}
// Only registered resources are reachable: no caller-supplied upstream URLs, and
// no private headers ever cross the loopback native/Cast handoff.
// ponytail: buffer up to eight 64-MiB resources; stream the relay if that ceiling becomes limiting.
final class MiruroTransport: @unchecked Sendable {
private enum Kind: String { case playlist, segment, resource }
private struct Resource { let url: URL; let kind: Kind; let depth: Int }
private let listener: NWListener
private let queue = DispatchQueue(label: "terebi.miruro.transport")
private let lock = NSLock()
private var resources: [String: Resource] = [:]
private var paths: [String: String] = [:]
private var active = 0
private var preparedMaster: (Data, String, String?)?
private let token = UUID().uuidString
private let sourceURL: URL
private let headers: String
private let requirements: MiruroPlaybackRequirements
private let deployment: MiruroProxyDeployment?
private let embed: MiruroEmbed?
private init(source: ResolvedSource, deployment: MiruroProxyDeployment?, embed: MiruroEmbed?) throws {
guard let sourceURL = URL(string: source.url) else { throw MiruroPlaybackError() }
self.sourceURL = embed?.url ?? sourceURL
headers = embed?.headers ?? source.headers
requirements = source.requirements
self.deployment = deployment
self.embed = embed
let parameters = NWParameters.tcp
parameters.requiredLocalEndpoint = .hostPort(host: .ipv4(.loopback), port: .any)
listener = try NWListener(using: parameters)
}
static func prepare(_ source: ResolvedSource, deployment override: MiruroProxyDeployment? = nil) async throws -> ResolvedSource {
let rules = source.requirements
guard rules.zoko || rules.embed || rules.proxy != nil || rules.segmentOrigin != nil || !rules.playlistQuery.isEmpty else { return source }
do {
let deployment: MiruroProxyDeployment?
if let override { deployment = override }
else if rules.proxy != nil { deployment = try await MiruroDeploymentCache.shared.get() }
else { deployment = nil }
let embed = try await rules.embed ? MiruroEmbed.resolve(url: URL(string: source.url)!) : nil
let owner = try MiruroTransport(source: source, deployment: deployment, embed: embed)
try await owner.start()
let url = try owner.register(owner.sourceURL, kind: .playlist, depth: 0)
// Exercise the master before replacing acknowledged playback. A failed
// provider switch must leave the current decoder and position intact.
let master = try await owner.load(Resource(url: owner.sourceURL, kind: .playlist, depth: 0), range: nil)
owner.lock.withLock { owner.preparedMaster = master }
var prepared = ResolvedSource(url: url.absoluteString, headers: "", identity: source.identity,
title: source.title, subtitles: source.subtitles)
prepared.playbackLifetime = owner
prepared.intervals = source.intervals
return prepared
} catch {
if Task.isCancelled || error is CancellationError { throw CancellationError() }
if rules.zoko { throw (error as? ZokoError) ?? ZokoError.transport }
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 {
guard let proxy = track.proxy, let url = URL(string: track.url) else { return track }
do {
let deployment = try await MiruroDeploymentCache.shared.get()
let target = try deployment.url(url, referer: header("Referer", in: track.headers), proxy: proxy, suffix: "/sub.vtt")
return ResolvedSubtitle(url: target.absoluteString, format: track.format, language: track.language,
label: track.label, isDefault: track.isDefault, headers: [:])
} catch {
if Task.isCancelled || error is CancellationError { throw CancellationError() }
throw MiruroPlaybackError()
}
}
private static func header(_ name: String, in headers: String) -> String? {
for line in headers.components(separatedBy: "\r\n") {
guard let colon = line.firstIndex(of: ":"), line[..<colon].caseInsensitiveCompare(name) == .orderedSame else { continue }
return String(line[line.index(after: colon)...]).trimmingCharacters(in: .whitespaces)
}
return nil
}
private func start() async throws {
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
listener.stateUpdateHandler = { [weak self] state in
switch state {
case .ready:
self?.listener.stateUpdateHandler = nil
continuation.resume()
case .failed, .cancelled:
self?.listener.stateUpdateHandler = nil
continuation.resume(throwing: MiruroPlaybackError())
default: break
}
}
listener.newConnectionHandler = { [weak self] connection in self?.accept(connection) }
listener.start(queue: queue)
}
try Task.checkCancellation()
}
deinit { listener.cancel() }
private func register(_ url: URL, kind: Kind, depth: Int) throws -> URL {
guard depth <= 12, ["http", "https"].contains(url.scheme), url.user == nil, url.password == nil,
(!requirements.zoko || ZokoResolver.validResource(url, playlist: kind == .playlist)),
url.fragment == nil, url.absoluteString.utf8.count <= 32768, let port = listener.port else { throw MiruroPlaybackError() }
return try lock.withLock {
let key = kind.rawValue + ":" + url.absoluteString
let path: String
if let existing = paths[key] { path = existing }
else {
guard resources.count < 32768 else { throw MiruroPlaybackError() }
path = "/" + token + "/" + String(resources.count) + (kind == .playlist ? ".m3u8" : ".bin")
resources[path] = Resource(url: url, kind: kind, depth: depth)
paths[key] = path
}
return URL(string: "http://127.0.0.1:\(port.rawValue)\(path)")!
}
}
private func accept(_ connection: NWConnection) {
let admitted = lock.withLock { () -> Bool in
guard active < 8 else { return false }
active += 1
return true
}
guard admitted else { connection.cancel(); return }
connection.start(queue: queue)
queue.asyncAfter(deadline: .now() + 35) { connection.cancel() }
receive(connection, data: Data())
}
private func receive(_ connection: NWConnection, data: Data) {
connection.receive(minimumIncompleteLength: 1, maximumLength: 16384) { [weak self] next, _, complete, error in
guard let self else { connection.cancel(); return }
var data = data
if let next { data.append(next) }
guard error == nil, data.count <= 16384 else { self.finish(connection); return }
guard data.range(of: Data("\r\n\r\n".utf8)) != nil else {
if complete { self.finish(connection) } else { self.receive(connection, data: data) }
return
}
Task { [self] in
defer { finish(connection) }
do {
guard let request = String(data: data, encoding: .utf8) else { throw MiruroPlaybackError() }
let lines = request.components(separatedBy: "\r\n")
let first = lines[0].split(separator: " ")
guard first.count == 3, first[0] == "GET" || first[0] == "HEAD",
let resource = lock.withLock({ resources[String(first[1])] }) else { throw MiruroPlaybackError() }
var range: String?
for line in lines.dropFirst() where line.lowercased().hasPrefix("range:") {
let value = String(line.dropFirst(6)).trimmingCharacters(in: .whitespaces)
guard value.range(of: #"^bytes=[0-9]+-[0-9]*$"#, options: .regularExpression) != nil, range == nil else { throw MiruroPlaybackError() }
range = value
}
let (body, type, contentRange) = try await load(resource, range: range)
let status = contentRange == nil ? "200 OK" : "206 Partial Content"
var response = "HTTP/1.1 \(status)\r\nContent-Type: \(type)\r\nContent-Length: \(body.count)\r\nConnection: close\r\n"
if let contentRange { response += "Content-Range: \(contentRange)\r\n" }
try await send(Data((response + "\r\n").utf8), connection: connection)
if first[0] == "GET" { try await send(body, connection: connection) }
} catch {
try? await send(Data("HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".utf8), connection: connection)
}
}
}
}
private func finish(_ connection: NWConnection) {
connection.cancel()
lock.withLock { active -= 1 }
}
private func send(_ data: Data, connection: NWConnection) async throws {
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
connection.send(content: data, completion: .contentProcessed { error in
if let error { continuation.resume(throwing: error) } else { continuation.resume() }
})
}
}
private func load(_ resource: Resource, range: String?) async throws -> (Data, String, String?) {
if resource.depth == 0, resource.kind == .playlist,
let master = lock.withLock({ () -> (Data, String, String?)? in
defer { preparedMaster = nil }
return preparedMaster
}) { return master }
let original = try deployment?.originalURL(resource.url) ?? resource.url
var url = try embed?.resourceURL(original) ?? original
if resource.kind == .playlist, !requirements.playlistQuery.isEmpty {
var components = URLComponents(url: url, resolvingAgainstBaseURL: false)!
var query = components.queryItems ?? []
query.removeAll { requirements.playlistQuery[$0.name] != nil }
query += requirements.playlistQuery.sorted { $0.key < $1.key }.map { URLQueryItem(name: $0.key, value: $0.value) }
components.queryItems = query
guard let result = components.url else { throw MiruroPlaybackError() }
url = result
}
if resource.kind == .segment, let origin = requirements.segmentOrigin,
let replacement = URLComponents(string: origin), var components = URLComponents(url: url, resolvingAgainstBaseURL: false) {
components.scheme = replacement.scheme
components.host = replacement.host
components.port = replacement.port
guard let result = components.url else { throw MiruroPlaybackError() }
url = result
}
func fetch(_ url: URL) async throws -> (Data, HTTPURLResponse) {
// Same-origin fixture/source access remains allowed. A manifest or
// decrypted token cannot turn the relay into a local-network proxy.
if requirements.zoko {
guard ZokoResolver.validResource(url, playlist: resource.kind == .playlist) else { throw ZokoError.malformed }
}
if !Self.sameOrigin(url, sourceURL) { try Self.requirePublicDestination(url) }
let target: URL
let requestHeaders: String
if let proxy = requirements.proxy, let deployment,
resource.kind == .playlist || proxy.segments {
target = try deployment.url(url, referer: Self.header("Referer", in: headers), proxy: proxy,
suffix: resource.kind == .playlist ? "/pl.m3u8" : "/seg.jpg")
requestHeaders = ""
} else {
target = url
// Only the embed's fixed public Referer has cross-origin scope.
requestHeaders = requirements.zoko ? ZokoResolver.referer :
(embed?.headers ?? (Self.sameOrigin(url, sourceURL) ? headers : ""))
}
return try await MiruroMediaHTTP.read(target, headers: requestHeaders,
limit: resource.kind == .playlist ? 2 * 1024 * 1024 : 64 * 1024 * 1024,
range: resource.kind == .playlist || embed != nil ? nil : range)
}
let raw: Data
let response: HTTPURLResponse
if resource.kind == .playlist, let skip = requirements.playlistQuery["skip"], !skip.isEmpty,
var components = URLComponents(url: url, resolvingAgainstBaseURL: false),
components.queryItems?.contains(where: { $0.name == "token" }) == true {
components.queryItems?.removeAll { $0.name == "token" }
do { (raw, response) = try await fetch(components.url!) }
catch {
try Task.checkCancellation()
(raw, response) = try await fetch(url)
}
} else { (raw, response) = try await fetch(url) }
if resource.kind == .playlist {
guard response.statusCode == 200, let text = String(data: raw, encoding: .utf8), text.hasPrefix("#EXTM3U") else { throw MiruroPlaybackError() }
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() }
let data = try embed?.resourceData(raw, url: url) ?? raw
if embed != nil, let range {
let offsets = range.dropFirst(6).split(separator: "-", omittingEmptySubsequences: false)
guard let start = Int(offsets[0]), start < data.count else { throw MiruroPlaybackError() }
let end = offsets[1].isEmpty ? data.count - 1 : min(Int(offsets[1]) ?? -1, data.count - 1)
guard end >= start else { throw MiruroPlaybackError() }
return (data.subdata(in: start..<(end + 1)), "application/octet-stream", "bytes \(start)-\(end)/\(data.count)")
}
let contentRange = response.statusCode == 206 ? response.value(forHTTPHeaderField: "Content-Range") : nil
if response.statusCode == 206 {
guard let contentRange,
contentRange.range(of: #"^bytes [0-9]+-[0-9]+/([0-9]+|\*)$"#, options: .regularExpression) != nil else {
throw MiruroPlaybackError()
}
}
return (data, "application/octet-stream", contentRange)
}
private static func sameOrigin(_ lhs: URL, _ rhs: URL) -> Bool {
lhs.scheme?.lowercased() == rhs.scheme?.lowercased() && lhs.host?.lowercased() == rhs.host?.lowercased() &&
(lhs.port ?? (lhs.scheme == "https" ? 443 : 80)) == (rhs.port ?? (rhs.scheme == "https" ? 443 : 80))
}
private static func requirePublicDestination(_ url: URL) throws {
guard let host = url.host else { throw MiruroPlaybackError() }
var hints = addrinfo()
hints.ai_family = AF_UNSPEC
hints.ai_socktype = SOCK_STREAM
var addresses: UnsafeMutablePointer<addrinfo>?
guard getaddrinfo(host, nil, &hints, &addresses) == 0, let first = addresses else { throw MiruroPlaybackError() }
defer { freeaddrinfo(first) }
var current: UnsafeMutablePointer<addrinfo>? = first
while let entry = current {
let value = entry.pointee
if value.ai_family == AF_INET {
let address = value.ai_addr.withMemoryRebound(to: sockaddr_in.self, capacity: 1) { UInt32(bigEndian: $0.pointee.sin_addr.s_addr) }
let a = address >> 24, b = (address >> 16) & 255
guard a != 0, a != 10, a != 127, a < 224,
!(a == 169 && b == 254), !(a == 172 && (16...31).contains(b)),
!(a == 192 && b == 168), !(a == 100 && (64...127).contains(b)),
!(a == 198 && (18...19).contains(b)) else { throw MiruroPlaybackError() }
} else if value.ai_family == AF_INET6 {
let bytes = value.ai_addr.withMemoryRebound(to: sockaddr_in6.self, capacity: 1) {
withUnsafeBytes(of: $0.pointee.sin6_addr) { Array($0) }
}
// Only globally routed unicast; excludes loopback, link-local,
// unique-local, multicast and IPv4-mapped local destinations.
guard bytes[0] & 0xe0 == 0x20 else { throw MiruroPlaybackError() }
} else { throw MiruroPlaybackError() }
current = value.ai_next
}
}
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=\"([^\"]+)\""#)
var nextPlaylist = false
var hasMedia = false
var hasReference = false
var lines: [String] = []
for raw in text.components(separatedBy: .newlines) {
try Task.checkCancellation()
var line = raw.trimmingCharacters(in: .whitespacesAndNewlines)
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 :
(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() {
guard let range = Range(match.range(at: 1), in: line),
let url = URL(string: String(line[range]), relativeTo: base)?.absoluteURL else { throw MiruroPlaybackError() }
let replacement = try register(url, kind)
if kind == .playlist { hasReference = true }
line.replaceSubrange(range, with: replacement.absoluteString)
}
if line.hasPrefix("#EXT-X-STREAM-INF:") { nextPlaylist = true }
} else if !line.isEmpty {
guard let url = URL(string: line, relativeTo: base)?.absoluteURL else { throw MiruroPlaybackError() }
let kind: Kind = nextPlaylist || url.path.lowercased().hasSuffix(".m3u8") ? .playlist : .segment
if nextPlaylist || hasMedia { hasReference = true }
line = try register(url, kind).absoluteString
nextPlaylist = false
}
lines.append(line)
}
if strict, nextPlaylist || !hasReference { throw ZokoError.malformed }
return Data(lines.joined(separator: "\n").utf8)
}
}
+21 -1
View File
@@ -4,11 +4,15 @@ struct PlaybackQueue {
struct Item: Identifiable { struct Item: Identifiable {
let id = UUID() let id = UUID()
let url: URL let url: URL
var label: String? = nil
var metadataAttempted = false
var displayName: String { label ?? url.lastPathComponent }
} }
private(set) var items: [Item] = [] private(set) var items: [Item] = []
private(set) var currentID: UUID? private(set) var currentID: UUID?
var currentURL: URL? { items.first { $0.id == currentID }?.url } var currentItem: Item? { items.first { $0.id == currentID } }
var currentURL: URL? { currentItem?.url }
private var currentIndex: Int? { items.firstIndex { $0.id == currentID } } private var currentIndex: Int? { items.firstIndex { $0.id == currentID } }
var canPrevious: Bool { currentIndex.map { $0 > 0 } ?? false } var canPrevious: Bool { currentIndex.map { $0 > 0 } ?? false }
var canNext: Bool { currentIndex.map { $0 + 1 < items.count } ?? false } var canNext: Bool { currentIndex.map { $0 + 1 < items.count } ?? false }
@@ -18,6 +22,22 @@ struct PlaybackQueue {
currentID = items.first?.id currentID = items.first?.id
} }
// Unlike file batches, stream entries already have an intentional playback order.
mutating func replaceStreams(_ entries: [(url: URL, label: String)]) {
items = entries.map { Item(url: $0.url, label: $0.label) }
currentID = items.first?.id
}
mutating func appendStreams(_ entries: [(url: URL, label: String)]) {
items.append(contentsOf: entries.map { Item(url: $0.url, label: $0.label) })
}
mutating func updateLabel(_ label: String?, for id: UUID) {
guard let index = items.firstIndex(where: { $0.id == id }) else { return }
if let label { items[index].label = label }
items[index].metadataAttempted = true
}
mutating func append(_ urls: [URL]) { mutating func append(_ urls: [URL]) {
items.append(contentsOf: sortedFiles(urls).map { Item(url: $0) }) items.append(contentsOf: sortedFiles(urls).map { Item(url: $0) })
} }
+86 -9
View File
@@ -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) }
@@ -171,14 +172,86 @@ struct PlayerQualityMenu: View {
} }
} }
@MainActor
struct PlayerMiruroMenu: View {
@ObservedObject var model: PlayerModel
var body: some View {
if !model.miruroVideoProviders.isEmpty {
Menu("Miruro") {
Menu("Video Host") {
Button(model.miruroVideoProvider.isEmpty ? "✓ Automatic" : "Automatic") {
model.setMiruroVideoProvider("")
}
ForEach(model.miruroVideoProviders, id: \.self) { provider in
Button(model.miruroVideoProvider == provider ? "✓ \(provider)" : provider) {
model.setMiruroVideoProvider(provider)
}
}
Text("Current: \(model.selectedMiruroVideoProvider ?? "None")")
}
Menu("Caption Provider") {
Button(model.miruroCaptionProvider.isEmpty ? "✓ Automatic" : "Automatic") {
model.setMiruroCaptionProvider("")
}
ForEach(model.miruroCaptionProviders, id: \.self) { provider in
Button(model.miruroCaptionProvider == provider ? "✓ \(provider)" : provider) {
model.setMiruroCaptionProvider(provider)
}
}
Text("Current: \(model.selectedMiruroCaptionProvider ?? "None")")
}
}
.disabled(!model.canChangeMiruroProvider)
}
}
}
@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
@@ -208,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)
@@ -275,6 +348,9 @@ struct PlayerCommands: Commands {
Text(model.youtubeHeight.map { "Current: \($0)p" } ?? "Current resolution unknown") Text(model.youtubeHeight.map { "Current: \($0)p" } ?? "Current resolution unknown")
} }
} }
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)
} }
@@ -282,8 +358,9 @@ struct PlayerCommands: Commands {
.disabled(!hasFocusedScene || !model.canPerform(bufferInfo)) .disabled(!hasFocusedScene || !model.canPerform(bufferInfo))
} }
CommandGroup(replacing: .sidebar) { CommandGroup(replacing: .sidebar) {
Button(model.showsQueue ? "Hide Queue" : "Show Queue") { model.showsQueue.toggle() } Button(model.queueVisible ? "Hide Queue" : "Show Queue") { model.performMenuAction(model.queueAction) }
.disabled(!hasFocusedScene || model.isCompact) .keyboardShortcut(model.keyBindings.shortcuts(for: model.queueAction).first?.keyboardShortcut)
.disabled(!hasFocusedScene || !model.canPerform(model.queueAction))
Button(fullscreen == .exitFullscreen ? "Exit Full Screen" : "Enter Full Screen") { Button(fullscreen == .exitFullscreen ? "Exit Full Screen" : "Enter Full Screen") {
model.performMenuAction(fullscreen) model.performMenuAction(fullscreen)
} }
+46 -15
View File
@@ -7,19 +7,25 @@ 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 headers: String { var headers: String {
headerFields.sorted { $0.key < $1.key }.map { "\($0.key): \($0.value)\r\n" }.joined() headerFields.sorted { $0.key < $1.key }.map { "\($0.key): \($0.value)\r\n" }.joined()
} }
fileprivate var validatedHeaders: [String: String] { headerFields } fileprivate var validatedHeaders: [String: String] { headerFields }
init(url: String, format: String, language: String, label: String, isDefault: Bool, headers: [String: String]) { init(url: String, format: String, language: String, label: String, isDefault: Bool,
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
self.label = label self.label = label
self.isDefault = isDefault self.isDefault = isDefault
self.headerFields = headers self.headerFields = headers
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
@@ -28,12 +34,28 @@ 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 playbackLifetime: (any Sendable)? = nil
func replacingSubtitles(_ subtitles: [ResolvedSubtitle]) -> ResolvedSource {
ResolvedSource(url: url, headers: headers, identity: identity, title: title, subtitles: subtitles,
intervals: intervals, requirements: requirements, playbackLifetime: playbackLifetime)
}
struct Document: Decodable { struct Document: Decodable {
struct Identity: Decodable { struct Identity: Decodable {
@@ -81,37 +103,46 @@ 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),
document.subtitles.count <= 32, validSubtitles(document.subtitles) else { throw InvalidSource() }
document.subtitles.filter({ $0.isDefault }).count <= 1 else { throw InvalidSource() }
for subtitle in document.subtitles {
guard validURL(subtitle.url), subtitle.format == "vtt",
validText(subtitle.language, limit: 128), !subtitle.language.isEmpty,
validText(subtitle.label, limit: 4096), !subtitle.label.isEmpty,
validHeaders(subtitle.validatedHeaders) 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)
} }
static func validSubtitles(_ subtitles: [ResolvedSubtitle]) -> Bool {
subtitles.count <= 32 && subtitles.filter({ $0.isDefault }).count <= 1 &&
subtitles.allSatisfy {
validURL($0.url) && $0.format == "vtt" &&
validText($0.language, limit: 128) && !$0.language.isEmpty &&
validText($0.label, limit: 4096) && !$0.label.isEmpty &&
validHeaders($0.validatedHeaders)
}
}
private static func isIDByte(_ byte: UInt8) -> Bool { private static func isIDByte(_ byte: UInt8) -> Bool {
(65...90).contains(byte) || (97...122).contains(byte) || (65...90).contains(byte) || (97...122).contains(byte) ||
(48...57).contains(byte) || byte == 45 || byte == 95 (48...57).contains(byte) || byte == 45 || byte == 95
} }
private static func validText(_ text: String, limit: Int) -> Bool { static func validText(_ text: String, limit: Int) -> Bool {
text.utf8.count <= limit && !text.unicodeScalars.contains { $0.value < 32 || $0.value == 127 } text.utf8.count <= limit && !text.unicodeScalars.contains { $0.value < 32 || $0.value == 127 }
} }
private static func validURL(_ text: String) -> Bool { static func validURL(_ text: String) -> Bool {
guard !text.isEmpty, validText(text, limit: 32768), guard !text.isEmpty, validText(text, limit: 32768),
!text.contains(where: { $0.isWhitespace }), !text.contains(where: { $0.isWhitespace }),
let components = URLComponents(string: text), let components = URLComponents(string: text),
@@ -122,7 +153,7 @@ struct ResolvedSource: Sendable {
return components.port == nil || (1...65535).contains(components.port!) return components.port == nil || (1...65535).contains(components.port!)
} }
private static func validHeaders(_ headers: [String: String]) -> Bool { static func validHeaders(_ headers: [String: String]) -> Bool {
guard headers.count <= 64 else { return false } guard headers.count <= 64 else { return false }
var names = Set<String>() var names = Set<String>()
var size = 0 var size = 0
+144
View File
@@ -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
}
}
+97
View File
@@ -0,0 +1,97 @@
import Foundation
import Darwin
struct StreamQueue: Sendable {
struct Entry: Sendable {
let url: URL
let identity: String
let progressFile: URL
let label: String
}
let entries: [Entry]
private struct Document: Decodable {
struct Item: Decodable { let url: String; let progress_file: String }
let version: Int
let items: [Item]
}
private struct InvalidQueue: LocalizedError {
var errorDescription: String? { "Invalid stream queue: expected version 1, 1–4096 unique supported videos and new absolute report files in private owned directories." }
}
// Explicit-app URL delivery reaches both cold launches and already running players.
static func enqueueURL(for file: URL) -> URL {
var components = URLComponents()
components.scheme = "terebi-enqueue"
components.host = "queue"
components.queryItems = [URLQueryItem(name: "file", value: file.path)]
return components.url!
}
static func enqueueFile(from url: URL) throws -> URL {
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
components.scheme == "terebi-enqueue", components.host == "queue",
components.path.isEmpty, components.user == nil, components.password == nil,
components.port == nil, components.fragment == nil,
let query = components.queryItems, query.count == 1, query[0].name == "file",
let path = query[0].value, path.hasPrefix("/"), !path.contains("\0") else {
throw InvalidQueue()
}
return URL(fileURLWithPath: path).standardizedFileURL
}
static func load(from file: URL) throws -> StreamQueue {
do {
guard file.isFileURL, file.path.hasPrefix("/") else { throw InvalidQueue() }
let descriptor = Darwin.open(file.path, O_RDONLY | O_NONBLOCK | O_NOFOLLOW | O_CLOEXEC)
guard descriptor >= 0 else { throw InvalidQueue() }
let handle = FileHandle(fileDescriptor: descriptor, closeOnDealloc: true)
defer { try? handle.close() }
var info = stat()
guard fstat(descriptor, &info) == 0, (info.st_mode & S_IFMT) == S_IFREG else { throw InvalidQueue() }
let data = try handle.read(upToCount: 8 * 1024 * 1024 + 1) ?? Data()
guard data.count <= 8 * 1024 * 1024 else { throw InvalidQueue() }
let document = try JSONDecoder().decode(Document.self, from: data)
guard document.version == 1, (1...4096).contains(document.items.count) else { throw InvalidQueue() }
var identities = Set<String>()
var reports = Set<String>()
let entries = try document.items.map { item -> Entry in
guard item.url == item.url.trimmingCharacters(in: .whitespacesAndNewlines) else { throw InvalidQueue() }
let stream = try StreamURL.parse(item.url)
let url: URL
let identity: String
let label: String
switch stream {
case .youtube(let canonical):
url = canonical
identity = canonical.absoluteString
label = canonical.absoluteString
case .miruro(let mediaID, let episode):
url = URL(string: item.url)!
identity = "miruro:\(mediaID):\(episode)"
label = "\(url.lastPathComponent.replacingOccurrences(of: "-", with: " ")) · Episode \(episode)"
case .anilist(let mediaID, let episode):
url = URL(string: item.url)!
identity = "anilist:\(mediaID):\(episode)"
label = "AniList \(mediaID) · Episode \(episode) (sub)"
}
guard identities.insert(identity).inserted,
item.progress_file.hasPrefix("/"), !item.progress_file.contains("\0") else { throw InvalidQueue() }
let report = URL(fileURLWithPath: item.progress_file).standardizedFileURL
let parent = report.deletingLastPathComponent()
var directoryInfo = stat()
guard lstat(parent.path, &directoryInfo) == 0,
(directoryInfo.st_mode & S_IFMT) == S_IFDIR,
directoryInfo.st_uid == geteuid(), (directoryInfo.st_mode & 0o7777) == 0o700,
let resolvedParent = realpath(parent.path, nil) else { throw InvalidQueue() }
defer { free(resolvedParent) }
let canonicalReport = URL(fileURLWithPath: String(cString: resolvedParent), isDirectory: true)
.appendingPathComponent(report.lastPathComponent)
guard reports.insert(canonicalReport.path).inserted else { throw InvalidQueue() }
var existing = stat()
guard lstat(canonicalReport.path, &existing) != 0, errno == ENOENT else { throw InvalidQueue() }
return Entry(url: url, identity: identity, progressFile: canonicalReport, label: label)
}
return StreamQueue(entries: entries)
} catch { throw InvalidQueue() }
}
}
+260 -49
View File
@@ -8,11 +8,25 @@ 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)
if let url = try? YouTubeResolver.validateURL(text) { return .youtube(url) } let url = try YouTubeResolver.validateURL(text)
let invalid = StreamResolutionError(message: "Enter a YouTube video URL or a Miruro episode URL from barelystarted.miruro.tv.") if url.host?.lowercased() == "zokoanime.video" {
let invalid = StreamResolutionError(message: "Enter https://zokoanime.video/stream/ani/<AniList ID>/<episode>/sub with canonical positive numbers.")
guard let c = URLComponents(string: text), c.scheme == "https", c.user == nil, c.password == nil,
c.port == nil || c.port == 443, c.query == nil, c.fragment == nil,
c.percentEncodedPath == c.path,
!text.contains(where: { $0.isWhitespace || $0 == "\\" }) else { throw invalid }
let p = c.path.split(separator: "/", omittingEmptySubsequences: false)
guard p.count == 6, p[0].isEmpty, p[1] == "stream", p[2] == "ani", p[5] == "sub",
let episode = Int(p[4]), String(episode) == p[4],
ResolvedSource.validAniListIdentity(mediaID: String(p[3]), episode: episode) else { throw invalid }
return .anilist(mediaID: String(p[3]), episode: episode)
}
if url.host?.lowercased() != "barelystarted.miruro.tv" { return .youtube(url) }
let invalid = StreamResolutionError(message: "Enter a Miruro episode URL from barelystarted.miruro.tv with an explicit positive episode number.")
guard !text.unicodeScalars.contains(where: { guard !text.unicodeScalars.contains(where: {
CharacterSet.whitespacesAndNewlines.contains($0) || CharacterSet.controlCharacters.contains($0) CharacterSet.whitespacesAndNewlines.contains($0) || CharacterSet.controlCharacters.contains($0)
}), let components = URLComponents(string: text), }), let components = URLComponents(string: text),
@@ -40,10 +54,86 @@ enum StreamURL: Sendable {
} }
} }
struct MiruroProxy: Decodable, Sendable {
let rotate: Bool
let segments: Bool
enum CodingKeys: String, CodingKey { case rotate, segments }
init(rotate: Bool, segments: Bool) {
self.rotate = rotate
self.segments = segments
}
init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
rotate = try values.decodeIfPresent(Bool.self, forKey: .rotate) ?? false
segments = try values.decodeIfPresent(Bool.self, forKey: .segments) ?? true
}
}
struct MiruroPlaybackRequirements: Sendable {
var proxy: MiruroProxy? = nil
var segmentOrigin: String? = nil
var playlistQuery: [String: String] = [:]
var embed = false
var zoko = false
}
struct MiruroSelection: Sendable {
let source: ResolvedSource
let videoProvider: String
let captionProvider: String?
}
struct MiruroSources: Sendable {
fileprivate struct Candidate: Sendable {
let provider: String
let track: String
let source: ResolvedSource
var id: String { "\(provider):\(track)" }
}
fileprivate struct Captions: Sendable {
let provider: String
let track: String
let subtitles: [ResolvedSubtitle]
var id: String { "\(provider):\(track)" }
}
fileprivate let videos: [Candidate]
fileprivate let captions: [Captions]
let videoProviders: [String]
let captionProviders: [String]
fileprivate init(videos: [Candidate], captions: [Captions]) {
self.videos = videos
self.captions = captions
videoProviders = videos.map(\.id)
captionProviders = captions.map(\.id)
}
func select(videoProvider: String? = nil, captionProvider: String? = nil) throws -> MiruroSelection {
func requested(_ preference: String?, provider: String, track: String) -> Bool {
preference == "\(provider):\(track)" ||
(preference == provider && track != "dub")
}
let japanese = videos.lazy.filter { $0.track != "dub" }
guard let video = videos.first(where: { requested(videoProvider, provider: $0.provider, track: $0.track) }) ??
japanese.first(where: { $0.provider == "sun" }) ?? japanese.first else {
throw StreamResolutionError(message: "Miruro Japanese source is unavailable. Select an available provider and track explicitly.")
}
let compatibleCaptions = captions.lazy.filter { $0.track == video.track || (video.track != "dub" && $0.track != "dub") }
let caption = captions.first(where: { requested(captionProvider, provider: $0.provider, track: $0.track) }) ??
captions.first(where: { $0.id == video.id }) ??
compatibleCaptions.first(where: { $0.provider == video.provider }) ??
compatibleCaptions.first(where: { $0.provider == "sun" }) ?? compatibleCaptions.first
let source = video.source.replacingSubtitles(caption?.subtitles ?? [])
return MiruroSelection(source: source, videoProvider: video.id, captionProvider: caption?.id)
}
}
enum MiruroResolver { enum MiruroResolver {
static let bodyLimit = 2 * 1024 * 1024 static let bodyLimit = 2 * 1024 * 1024
static func resolve(mediaID: String, episode: Int) async throws -> ResolvedSource { static func resolve(mediaID: String, episode: Int) async throws -> MiruroSources {
try Task.checkCancellation() try Task.checkCancellation()
guard ResolvedSource.validIdentity(mediaID: mediaID, episode: episode) else { guard ResolvedSource.validIdentity(mediaID: mediaID, episode: episode) else {
throw StreamResolutionError(message: "Invalid Miruro episode identity.") throw StreamResolutionError(message: "Invalid Miruro episode identity.")
@@ -99,89 +189,210 @@ enum MiruroResolver {
} }
} }
// Match Tubular's direct ssub/Sun projection, without provider or server retry. static func decode(_ data: Data, mediaID: String, episode: Int) throws -> MiruroSources {
static func decode(_ data: Data, mediaID: String, episode: Int) throws -> ResolvedSource {
do { do {
guard data.count <= bodyLimit, guard data.count <= bodyLimit,
ResolvedSource.validIdentity(mediaID: mediaID, episode: episode) else { throw InvalidProjection() } ResolvedSource.validIdentity(mediaID: mediaID, episode: episode) else { throw InvalidProjection() }
let response = try JSONDecoder().decode(Sources.self, from: data) let response = try JSONDecoder().decode(Sources.self, from: data)
guard response.episodeNumber == episode else { throw InvalidProjection() } guard response.episodeNumber == episode else { throw InvalidProjection() }
let config = response.config.sun var videos: [MiruroSources.Candidate] = []
guard config.player == "native", !config.proxy, var captions: [MiruroSources.Captions] = []
config.hls?.segments?.enabled != true, config.hls?.query?.enabled != true else { var videoIDs = Set<String>()
throw StreamResolutionError(message: "Miruro Sun requires unsupported proxy or HLS rewriting.") var captionIDs = Set<String>()
for track in response.tracks.lazy.compactMap(\.value) where ["ssub", "sub", "dub"].contains(track.track) {
for provider in track.providers.lazy.compactMap(\.value) {
let name = provider.provider
guard validProviderName(name) else { continue }
let id = "\(name):\(track.track)"
let config = response.config[name]?.value
let servers = provider.servers.compactMap(\.value)
// Prefer supplied server headers, but do not require a playable video to use them.
let captionServer = servers.first { !($0.headers ?? [:]).isEmpty && ResolvedSource.validHeaders($0.headers ?? [:]) } ??
servers.first { ResolvedSource.validHeaders($0.headers ?? [:]) }
if !captionIDs.contains(id), captionServer != nil || servers.isEmpty {
var headers = captionServer?.headers ?? [:]
var proxy = config?.proxy
let returned = provider.subtitles.compactMap(\.value)
// Iframe captions may be exact copies of a parent's resources.
// Reuse only that verified caption transport, never its video rules.
if headers.isEmpty, config?.player == "iframe", let parentName = config?.parent,
let parent = track.providers.compactMap(\.value).first(where: { $0.provider == parentName }),
parent.subtitles.compactMap(\.value) == returned,
let parentServer = parent.servers.compactMap(\.value).first(where: {
!($0.headers ?? [:]).isEmpty && ResolvedSource.validHeaders($0.headers ?? [:])
}) {
headers = parentServer.headers ?? [:]
proxy = response.config[parentName]?.value?.proxy
}
let subtitles = returned.lazy
.filter { $0.format == "vtt" }.map {
ResolvedSubtitle(url: $0.file, format: $0.format, language: $0.language,
label: $0.label, isDefault: $0.default, headers: headers, proxy: proxy)
}
let values = Array(subtitles)
if !values.isEmpty, ResolvedSource.validSubtitles(values) {
captions.append(.init(provider: name, track: track.track, subtitles: values))
captionIDs.insert(id)
}
}
for server in servers {
let headers = server.headers ?? [:]
guard ResolvedSource.validHeaders(headers) else { continue }
guard !videoIDs.contains(id), let config,
var requirements = try? config.requirements() else { continue }
var locations: [(String, Bool)] = []
if config.player == "native", server.embed == nil {
locations = server.streams.compactMap(\.value).compactMap { stream in
guard stream.embed == nil,
["hls", "m3u8", "application/vnd.apple.mpegurl", "application/x-mpegurl"].contains(stream.format) else { return nil }
return (stream.url, false)
}
} else if config.player == "iframe" {
if let embed = server.embed { locations.append((embed.url, true)) }
locations.append(contentsOf: server.streams.lazy.compactMap(\.value).compactMap { stream in
stream.embed.map { ($0.url, true) }
})
}
for (url, embed) in locations {
guard var source = try? ResolvedSource.validated(.init(version: 1, url: url,
headers: headers, identity: .init(provider: "miruro", media_id: mediaID, episode: episode),
title: nil, subtitles: [])) else { continue }
requirements.embed = embed
source.requirements = requirements
videos.append(.init(provider: name, track: track.track, source: source))
videoIDs.insert(id)
break
}
}
}
} }
guard let provider = response.tracks.first(where: { $0.track == "ssub" })? // Provider order is advisory and deduplicated; each provider retains all usable tracks.
.providers.first(where: { $0.provider == "sun" }), var seen = Set<String>()
let server = provider.servers.first(where: { !$0.streams.isEmpty }), let providerOrder = ((response.providerOrder ?? []) + videos.map(\.provider) + captions.map(\.provider))
let stream = server.streams.first else { .filter { seen.insert($0).inserted }
throw StreamResolutionError(message: "Direct Miruro Sun soft-subtitle source is unavailable.") func variants(_ name: String) -> [String] {
var seen = Set<String>()
return ((response.config[name]?.value?.variantOrder ?? []) + ["ssub", "sub", "dub"])
.filter { seen.insert($0).inserted }
} }
guard ["hls", "m3u8", "application/vnd.apple.mpegurl", "application/x-mpegurl"].contains(stream.format), let orderedVideos = providerOrder.flatMap { name in
!stream.hasEmbed else { throw InvalidProjection() } variants(name).compactMap { variant in videos.first { $0.provider == name && $0.track == variant } }
let headers = server.headers ?? [:]
var subtitles: [ResolvedSubtitle] = []
for subtitle in provider.subtitles where subtitle.format == "vtt" {
guard subtitles.count < 32 else { throw InvalidProjection() }
subtitles.append(ResolvedSubtitle(url: subtitle.file, format: subtitle.format, language: subtitle.language,
label: subtitle.label, isDefault: subtitle.default, headers: headers))
} }
return try ResolvedSource.validated(.init(version: 1, url: stream.url, headers: headers, let orderedCaptions = providerOrder.flatMap { name in
identity: .init(provider: "miruro", media_id: mediaID, episode: episode), variants(name).compactMap { variant in captions.first { $0.provider == name && $0.track == variant } }
title: nil, subtitles: subtitles)) }
guard !orderedVideos.isEmpty || !orderedCaptions.isEmpty else { throw InvalidProjection() }
return MiruroSources(videos: orderedVideos, captions: orderedCaptions)
} catch { } catch {
if let error = error as? StreamResolutionError { throw error } if let error = error as? StreamResolutionError { throw error }
throw StreamResolutionError(message: "Miruro returned invalid or unsupported source metadata.") throw StreamResolutionError(message: "Miruro returned invalid or unsupported source metadata.")
} }
} }
private static func validProviderName(_ name: String) -> Bool {
!name.isEmpty && name.utf8.count <= 128 && name.utf8.allSatisfy {
(65...90).contains($0) || (97...122).contains($0) || (48...57).contains($0) || $0 == 45 || $0 == 95
}
}
private struct InvalidProjection: Error {} private struct InvalidProjection: Error {}
// A malformed entry must not discard valid neighboring providers, servers or streams.
private struct Entry<Value: Decodable>: Decodable {
let value: Value?
init(from decoder: Decoder) throws { value = try? Value(from: decoder) }
}
private struct Sources: Decodable { private struct Sources: Decodable {
let episodeNumber: Int let episodeNumber: Int
let tracks: [Track] let tracks: [Entry<Track>]
let config: Config let config: [String: Entry<ProviderConfig>]
let providerOrder: [String]?
enum CodingKeys: String, CodingKey { case episodeNumber, tracks, config, providerOrder }
init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
episodeNumber = try values.decode(Int.self, forKey: .episodeNumber)
tracks = try values.decode([Entry<Track>].self, forKey: .tracks)
config = try values.decodeIfPresent([String: Entry<ProviderConfig>].self, forKey: .config) ?? [:]
providerOrder = try values.decodeIfPresent([String].self, forKey: .providerOrder)
}
} }
private struct Config: Decodable { let sun: ProviderConfig }
private struct ProviderConfig: Decodable { private struct ProviderConfig: Decodable {
let player: String let player: String
let proxy: Bool let proxy: MiruroProxy?
let hls: HLS? let hls: HLS?
let variantOrder: [String]?
let parent: String?
enum CodingKeys: String, CodingKey { case player, proxy, hls, variantOrder, parent }
init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
player = try values.decode(String.self, forKey: .player)
if let flag = try? values.decode(Bool.self, forKey: .proxy) {
guard !flag else { throw InvalidProjection() }
proxy = nil
} else {
proxy = try values.decode(MiruroProxy.self, forKey: .proxy)
}
hls = try values.decodeIfPresent(HLS.self, forKey: .hls)
variantOrder = try values.decodeIfPresent([String].self, forKey: .variantOrder)
parent = try values.decodeIfPresent(String.self, forKey: .parent)
}
func requirements() throws -> MiruroPlaybackRequirements {
guard player == "native" || player == "iframe" else { throw InvalidProjection() }
var result = MiruroPlaybackRequirements(proxy: proxy)
if let segments = hls?.segments, segments.enabled == true {
guard let origin = segments.origin, ResolvedSource.validURL(origin) else { throw InvalidProjection() }
result.segmentOrigin = origin
}
if let query = hls?.query, query.enabled == true {
guard let params = query.params, params.count <= 64,
params.allSatisfy({ !$0.key.isEmpty && ResolvedSource.validText($0.key, limit: 256) &&
ResolvedSource.validText($0.value, limit: 16384) }) else { throw InvalidProjection() }
result.playlistQuery = params
}
return result
}
} }
private struct HLS: Decodable { private struct HLS: Decodable {
struct Rule: Decodable { let enabled: Bool? } struct Segments: Decodable { let enabled: Bool?; let origin: String? }
let segments: Rule? struct Query: Decodable { let enabled: Bool?; let params: [String: String]? }
let query: Rule? let segments: Segments?
let query: Query?
} }
private struct Track: Decodable { private struct Track: Decodable {
let track: String let track: String
let providers: [Provider] let providers: [Entry<Provider>]
} }
private struct Provider: Decodable { private struct Provider: Decodable {
let provider: String let provider: String
let servers: [Server] let servers: [Entry<Server>]
let subtitles: [Subtitle] let subtitles: [Entry<Subtitle>]
enum CodingKeys: String, CodingKey { case provider, servers, subtitles }
init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
provider = try values.decode(String.self, forKey: .provider)
servers = try values.decodeIfPresent([Entry<Server>].self, forKey: .servers) ?? []
subtitles = try values.decodeIfPresent([Entry<Subtitle>].self, forKey: .subtitles) ?? []
}
} }
private struct Embed: Decodable { let url: String }
private struct Server: Decodable { private struct Server: Decodable {
let headers: [String: String]? let headers: [String: String]?
let streams: [Stream] let streams: [Entry<Stream>]
let embed: Embed?
enum CodingKeys: String, CodingKey { case headers, streams, embed }
init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
headers = try values.decodeIfPresent([String: String].self, forKey: .headers)
streams = try values.decodeIfPresent([Entry<Stream>].self, forKey: .streams) ?? []
embed = try values.decodeIfPresent(Embed.self, forKey: .embed)
}
} }
private struct Stream: Decodable { private struct Stream: Decodable {
let url: String let url: String
let format: String let format: String
let hasEmbed: Bool let embed: Embed?
enum CodingKeys: String, CodingKey { case url, format, embed }
init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
url = try values.decode(String.self, forKey: .url)
format = try values.decode(String.self, forKey: .format)
if values.contains(.embed) {
hasEmbed = try !values.decodeNil(forKey: .embed)
} else {
hasEmbed = false
}
}
} }
private struct Subtitle: Decodable { private struct Subtitle: Decodable, Equatable {
let file: String let file: String
let format: String let format: String
let language: String let language: String
+1404 -126
View File
File diff suppressed because it is too large Load Diff
+284 -32
View File
@@ -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 {
@@ -30,12 +58,13 @@ enum YouTubeResolver {
let text = text.trimmingCharacters(in: .whitespacesAndNewlines) let text = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty, guard !text.isEmpty,
!text.unicodeScalars.contains(where: { CharacterSet.whitespacesAndNewlines.contains($0) || CharacterSet.controlCharacters.contains($0) }), !text.unicodeScalars.contains(where: { CharacterSet.whitespacesAndNewlines.contains($0) || CharacterSet.controlCharacters.contains($0) }),
let components = URLComponents(string: text), var components = URLComponents(string: text),
let scheme = components.scheme?.lowercased(), ["http", "https"].contains(scheme), let scheme = components.scheme?.lowercased(), ["http", "https"].contains(scheme),
components.user == nil, components.password == nil, components.user == nil, components.password == nil,
components.port == nil || components.port == (scheme == "https" ? 443 : 80), components.port == nil || (1...65535).contains(components.port!),
let host = components.host?.lowercased() else { let host = components.host?.lowercased(), !host.isEmpty,
throw YouTubeResolutionError(message: "Enter a valid HTTP or HTTPS YouTube video URL.") let url = components.url, url.absoluteString.utf8.count <= 65536 else {
throw YouTubeResolutionError(message: "Enter a valid HTTP or HTTPS video URL without embedded credentials.")
} }
let id: String? let id: String?
let path = components.path.split(separator: "/", omittingEmptySubsequences: false) let path = components.path.split(separator: "/", omittingEmptySubsequences: false)
@@ -51,11 +80,12 @@ enum YouTubeResolver {
id = nil id = nil
} }
} else { } else {
throw YouTubeResolutionError(message: "Only YouTube video URLs are supported.") id = nil
} }
guard let id, id.utf8.count == 11, guard let id, id.utf8.count == 11,
id.utf8.allSatisfy({ (65...90).contains($0) || (97...122).contains($0) || (48...57).contains($0) || $0 == 45 || $0 == 95 }) else { id.utf8.allSatisfy({ (65...90).contains($0) || (97...122).contains($0) || (48...57).contains($0) || $0 == 45 || $0 == 95 }) else {
throw YouTubeResolutionError(message: "The YouTube URL must identify one video, not a playlist or channel.") components.scheme = scheme
return components.url!
} }
return URL(string: "https://www.youtube.com/watch?v=\(id)")! return URL(string: "https://www.youtube.com/watch?v=\(id)")!
} }
@@ -112,7 +142,7 @@ enum YouTubeResolver {
throw YouTubeResolutionError(message: "The requested \(height)p resolution is no longer available. Choose another quality.") throw YouTubeResolutionError(message: "The requested \(height)p resolution is no longer available. Choose another quality.")
} }
} else if let selectedHeight = videoFormat.height, selectedHeight > 1080 { } else if let selectedHeight = videoFormat.height, selectedHeight > 1080 {
throw YouTubeResolutionError(message: "YouTube did not provide a supported stream at or below 1080p.") throw YouTubeResolutionError(message: "yt-dlp did not provide a supported stream at or below 1080p.")
} }
let formats = metadata.formats ?? [videoFormat] + (audioFormat.map { [$0] } ?? []) let formats = metadata.formats ?? [videoFormat] + (audioFormat.map { [$0] } ?? [])
let hasSeparateAudio = formats.contains { let hasSeparateAudio = formats.contains {
@@ -128,31 +158,109 @@ 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! : "YouTube 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)
) )
} }
// --skip-download/-j still perform extraction; --flat-playlist only skips playlist
// entries. YouTube's oEmbed endpoint avoids format and JavaScript challenge work.
static func resolveTitle(_ url: URL, executableURL: URL? = nil,
configuration: URLSessionConfiguration? = nil) async throws -> String? {
try Task.checkCancellation()
let webpageURL = try validateURL(url.absoluteString)
if webpageURL.host == "www.youtube.com", webpageURL.path == "/watch",
URLComponents(url: webpageURL, resolvingAgainstBaseURL: false)?.queryItems?.first?.name == "v" {
var endpoint = URLComponents(string: "https://www.youtube.com/oembed")!
endpoint.queryItems = [URLQueryItem(name: "url", value: webpageURL.absoluteString),
URLQueryItem(name: "format", value: "json")]
let configuration = configuration ?? .ephemeral
configuration.timeoutIntervalForRequest = 15
configuration.timeoutIntervalForResource = 20
configuration.httpShouldSetCookies = false
configuration.httpCookieStorage = nil
configuration.urlCache = nil
let session = URLSession(configuration: configuration, delegate: YouTubeTitleRedirectPolicy(), delegateQueue: nil)
defer { session.invalidateAndCancel() }
let (bytes, response) = try await session.bytes(from: endpoint.url!)
guard let http = response as? HTTPURLResponse, http.statusCode == 200,
http.mimeType == "application/json", response.expectedContentLength <= 65536 else {
throw YouTubeResolutionError(message: "The optional video title was unavailable.")
}
var data = Data()
for try await byte in bytes {
try Task.checkCancellation()
guard data.count < 65536 else {
throw YouTubeResolutionError(message: "The optional video title was too large.")
}
data.append(byte)
}
try Task.checkCancellation()
return try decodeTitle(data)
}
let (executable, directories) = try extractorExecutable(executableURL)
var environment = ProcessInfo.processInfo.environment
environment["PATH"] = directories.joined(separator: ":")
// Generic sites may need site-specific extraction, but no playback format,
// runtime, stream validation or native media load is required for their title.
let arguments = [
"--ignore-config", "--no-plugin-dirs", "--no-cache-dir", "--no-remote-components",
"--no-playlist", "--flat-playlist", "--simulate", "--skip-download", "--dump-single-json",
"--no-progress", "--color", "never", "--no-check-formats", "--ignore-no-formats-error",
"--no-js-runtimes", "--socket-timeout", "15", "--extractor-retries", "0", "--retries", "0",
"--", webpageURL.absoluteString
]
let output = try await YouTubeExtraction(executable: executable, arguments: arguments, environment: environment).run()
try Task.checkCancellation()
return try decodeTitle(output.stdout)
}
static func decodeTitle(_ data: Data) throws -> String? {
struct Metadata: Decodable { let title: String?; let _type: String? }
guard data.count <= outputLimit else {
throw YouTubeResolutionError(message: "The optional video title was too large.")
}
let metadata = try JSONDecoder().decode(Metadata.self, from: data)
guard metadata._type == nil || ["video", "url", "url_transparent"].contains(metadata._type!) else {
throw YouTubeResolutionError(message: "The optional video title did not identify a single video.")
}
guard let title = metadata.title?.trimmingCharacters(in: .whitespacesAndNewlines),
!title.isEmpty else { return nil }
guard title.utf8.count <= 16384,
!title.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) }) else {
throw YouTubeResolutionError(message: "The optional video title was invalid.")
}
return title
}
private static func extractorExecutable(_ override: URL?) throws -> (URL, [String]) {
let bundle = Bundle.main.bundleURL
let directories = bundle.pathExtension == "app"
? [bundle.appendingPathComponent("Contents/MacOS").path]
: searchDirectories()
let executable = try override.map { try executableFile($0, name: "yt-dlp") }
?? bundledExecutable("yt-dlp")
?? findExecutable("yt-dlp", directories: directories)
guard let executable else {
throw YouTubeResolutionError(message: "Stream playback requires yt-dlp. Rebuild or reinstall terebi with its bundled helpers.")
}
return (executable, directories)
}
static func resolve(_ url: URL, height: Int? = nil, executableURL: URL? = nil, runtimeURL: URL? = nil) async throws -> YouTubeVideo { static func resolve(_ url: URL, height: Int? = nil, executableURL: URL? = nil, runtimeURL: URL? = nil) async throws -> YouTubeVideo {
try Task.checkCancellation() try Task.checkCancellation()
let webpageURL = try validateURL(url.absoluteString) let webpageURL = try validateURL(url.absoluteString)
guard height == nil || height! > 0 else { guard height == nil || height! > 0 else {
throw YouTubeResolutionError(message: "Choose a positive video resolution.") throw YouTubeResolutionError(message: "Choose a positive video resolution.")
} }
let bundle = Bundle.main.bundleURL let (executable, directories) = try extractorExecutable(executableURL)
let directories = bundle.pathExtension == "app"
? [bundle.appendingPathComponent("Contents/MacOS").path]
: searchDirectories()
let executable = try executableURL.map { try executableFile($0, name: "yt-dlp") }
?? bundledExecutable("yt-dlp")
?? findExecutable("yt-dlp", directories: directories)
guard let executable else {
throw YouTubeResolutionError(message: "YouTube playback requires yt-dlp. Rebuild or reinstall terebi with its bundled helpers.")
}
let runtime: URL? let runtime: URL?
if let runtimeURL { if let runtimeURL {
runtime = try executableFile(runtimeURL, name: "JavaScript runtime") runtime = try executableFile(runtimeURL, name: "JavaScript runtime")
@@ -162,13 +270,13 @@ enum YouTubeResolver {
} }
guard let runtime, guard let runtime,
let runtimeName = ["deno": "deno", "node": "node", "qjs": "quickjs", "quickjs": "quickjs", "bun": "bun"][runtime.lastPathComponent] else { let runtimeName = ["deno": "deno", "node": "node", "qjs": "quickjs", "quickjs": "quickjs", "bun": "bun"][runtime.lastPathComponent] else {
throw YouTubeResolutionError(message: "YouTube playback requires a supported JavaScript runtime. Rebuild or reinstall terebi with its bundled Deno helper.") throw YouTubeResolutionError(message: "Stream playback requires a supported JavaScript runtime. Rebuild or reinstall terebi with its bundled Deno helper.")
} }
var environment = ProcessInfo.processInfo.environment var environment = ProcessInfo.processInfo.environment
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",
@@ -176,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()) {
@@ -188,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.")
@@ -257,7 +421,7 @@ enum YouTubeResolver {
return YouTubeResolutionError(message: "The bundled YouTube helpers could not solve YouTube's JavaScript challenge. Upgrade terebi or rebuild its bundled yt-dlp, EJS components, and Deno.") return YouTubeResolutionError(message: "The bundled YouTube helpers could not solve YouTube's JavaScript challenge. Upgrade terebi or rebuild its bundled yt-dlp, EJS components, and Deno.")
} }
if text.contains("no such option") || text.contains("unrecognized arguments") { if text.contains("no such option") || text.contains("unrecognized arguments") {
return YouTubeResolutionError(message: "The bundled yt-dlp is too old for YouTube streaming. Upgrade terebi or rebuild its bundled helpers.") return YouTubeResolutionError(message: "The bundled yt-dlp is too old for streaming. Upgrade terebi or rebuild its bundled helpers.")
} }
return nil return nil
} }
@@ -267,21 +431,29 @@ enum YouTubeResolver {
if let error = dependencyError(text) { return error } if let error = dependencyError(text) { return error }
// Never include extractor output: it can contain signed URLs or credentials. // Never include extractor output: it can contain signed URLs or credentials.
if text.contains("sign in") || text.contains("login") || text.contains("private video") || text.contains("age-restricted") || text.contains("cookies") { if text.contains("sign in") || text.contains("login") || text.contains("private video") || text.contains("age-restricted") || text.contains("cookies") {
return YouTubeResolutionError(message: "YouTube requires sign-in or additional access for this video. terebi does not read browser cookies; try a publicly accessible video.") return YouTubeResolutionError(message: "This site requires sign-in or additional access for this video. terebi does not read browser cookies; try a publicly accessible video.")
} }
if text.contains("no video formats") || text.contains("no formats") || text.contains("requested format") { if text.contains("no video formats") || text.contains("no formats") || text.contains("requested format") {
return YouTubeResolutionError(message: "YouTube did not provide a supported SDR video and audio stream. Upgrade terebi or rebuild its bundled helpers and try again.") return YouTubeResolutionError(message: "yt-dlp did not provide a supported SDR video and audio stream. Upgrade terebi or rebuild its bundled helpers and try again.")
} }
if text.contains("not available") || text.contains("unavailable") || text.contains("removed") || text.contains("copyright") || text.contains("blocked") { if text.contains("not available") || text.contains("unavailable") || text.contains("removed") || text.contains("copyright") || text.contains("blocked") {
return YouTubeResolutionError(message: "This YouTube video is unavailable or restricted in your region.") return YouTubeResolutionError(message: "This video is unavailable or restricted in your region.")
} }
if text.contains("timed out") || text.contains("connection") || text.contains("http error") || text.contains("certificate") || text.contains("urlopen") { if text.contains("timed out") || text.contains("connection") || text.contains("http error") || text.contains("certificate") || text.contains("urlopen") {
return YouTubeResolutionError(message: "Could not reach YouTube. Check your network connection and try again; upgrade terebi if the problem persists.") return YouTubeResolutionError(message: "Could not reach the video site. Check your network connection and try again; upgrade terebi if the problem persists.")
} }
return YouTubeResolutionError(message: "The bundled yt-dlp could not resolve this video (exit status \(status)). Upgrade terebi or rebuild its bundled helpers, then try again.") return YouTubeResolutionError(message: "The bundled yt-dlp could not resolve this video (exit status \(status)). Upgrade terebi or rebuild its bundled helpers, then try again.")
} }
} }
private final class YouTubeTitleRedirectPolicy: NSObject, URLSessionTaskDelegate {
func urlSession(_ session: URLSession, task: URLSessionTask,
willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest,
completionHandler: @escaping @Sendable (URLRequest?) -> Void) {
completionHandler(nil)
}
}
private struct YouTubeMetadata: Decodable { private struct YouTubeMetadata: Decodable {
let _type: String? let _type: String?
let title: String? let title: String?
@@ -297,9 +469,86 @@ 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?
var hasVideo: Bool { vcodec.map { !$0.isEmpty && $0 != "none" } ?? false } // Generic extractors may omit codec hints; FFmpeg probes the actual streams.
var hasAudio: Bool { acodec.map { !$0.isEmpty && $0 != "none" } ?? false } var hasVideo: Bool { vcodec.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 {
@@ -309,7 +558,7 @@ private struct YouTubeExtractionOutput: Sendable {
// All Process, pipe, and continuation state belongs to this request's serial queue. // All Process, pipe, and continuation state belongs to this request's serial queue.
private final class YouTubeExtraction: @unchecked Sendable { private final class YouTubeExtraction: @unchecked Sendable {
private let queue = DispatchQueue(label: "terebi.youtube-extraction", qos: .userInitiated) private let queue: DispatchQueue
private let process = Process() private let process = Process()
private let stdoutPipe = Pipe() private let stdoutPipe = Pipe()
private let stderrPipe = Pipe() private let stderrPipe = Pipe()
@@ -328,6 +577,9 @@ private final class YouTubeExtraction: @unchecked Sendable {
private var failure: (any Error)? private var failure: (any Error)?
init(executable: URL, arguments: [String], environment: [String: String]) { init(executable: URL, arguments: [String], environment: [String: String]) {
let background = Task.currentPriority <= .utility
queue = DispatchQueue(label: "terebi.youtube-extraction", qos: background ? .utility : .userInitiated)
process.qualityOfService = background ? .utility : .userInitiated
process.executableURL = executable process.executableURL = executable
process.arguments = arguments process.arguments = arguments
process.environment = environment process.environment = environment
+218
View File
@@ -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)"
}
}
+42 -4
View File
@@ -3,26 +3,37 @@ 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 enqueue(queueFile: URL, noConfig: Bool)
static let usage = """ static let usage = """
Usage: terebi [--new-instance] [--no-config] [FILE | STREAM_URL] Usage: terebi [--new-instance] [--no-config] [FILE | STREAM_URL]
terebi [--new-instance] [--no-config] --url STREAM_URL terebi [--new-instance] [--no-config] --url STREAM_URL
terebi --new-instance --url STREAM_URL --progress-file PATH terebi --new-instance --url STREAM_URL --progress-file PATH
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 --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, YouTube 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 Open a YouTube video or Miruro episode URL. --url URL Resolve an HTTP(S) video, Miruro episode or ZokoAnime AniList sub URL.
--check-source URL Read-only source/manifest check; one version-1 JSON result,
no player launch, configuration, progress or resume writes.
--source-file PATH Open a validated resolved HTTP(S) source in a new player. --source-file PATH Open a validated resolved HTTP(S) source in a new player.
--queue-file PATH Open an ordered version-1 stream queue with per-item reports.
--enqueue Append a queue to the running player without interrupting playback;
launch and start its first item if no player is running.
--progress-file PATH Write JSONL playback facts to a new private file; requires --progress-file PATH Write JSONL playback facts to a new private file; requires
--new-instance. The GUI owns reporting after this CLI exits. --new-instance. The GUI owns reporting after this CLI exits.
-- Treat the following argument as a filename, even with a leading '-'. -- Treat the following argument as a filename, even with a leading '-'.
@@ -35,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
@@ -55,16 +76,23 @@ enum PlayerCommand {
} }
var newInstance = false var newInstance = false
var noConfig = false var noConfig = false
while let option = inputs.first, option == "--new-instance" || option == "--no-config" { var enqueue = false
while let option = inputs.first, ["--new-instance", "--no-config", "--enqueue"].contains(option) {
if option == "--new-instance" { if option == "--new-instance" {
guard !newInstance else { throw ArgumentError("--new-instance may only be supplied once.") } guard !newInstance else { throw ArgumentError("--new-instance may only be supplied once.") }
newInstance = true newInstance = true
} else if option == "--enqueue" {
guard !enqueue else { throw ArgumentError("--enqueue may only be supplied once.") }
enqueue = true
} else { } else {
guard !noConfig else { throw ArgumentError("--no-config may only be supplied once.") } guard !noConfig else { throw ArgumentError("--no-config may only be supplied once.") }
noConfig = true noConfig = true
} }
inputs.removeFirst() inputs.removeFirst()
} }
guard !enqueue || (!newInstance && progressFile == nil && inputs.first == "--queue-file") else {
throw ArgumentError("--enqueue requires --queue-file and cannot combine with --new-instance, --progress-file or other media.")
}
func launch(_ url: URL?) throws -> PlayerCommand { func launch(_ url: URL?) throws -> PlayerCommand {
guard progressFile == nil || newInstance else { guard progressFile == nil || newInstance else {
throw ArgumentError("--progress-file requires --new-instance.") throw ArgumentError("--progress-file requires --new-instance.")
@@ -74,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.")
} }
@@ -90,6 +118,16 @@ enum PlayerCommand {
guard progressFile == nil else { throw ArgumentError("--progress-file cannot be combined with --default-config.") } guard progressFile == nil else { throw ArgumentError("--progress-file cannot be combined with --default-config.") }
return .printDefaultConfig return .printDefaultConfig
} }
if inputs.first == "--queue-file" {
guard newInstance || enqueue, progressFile == nil, inputs.count == 2,
inputs[1].hasPrefix("/"), !inputs[1].contains("\0") else {
throw ArgumentError("--queue-file requires --new-instance or --enqueue and one absolute manifest path; it cannot be combined with --progress-file.")
}
let queueFile = URL(fileURLWithPath: inputs[1]).standardizedFileURL
_ = try StreamQueue.load(from: queueFile)
if enqueue { return .enqueue(queueFile: queueFile, noConfig: noConfig) }
return .launchQueue(queueFile: queueFile, newInstance: newInstance, noConfig: noConfig)
}
if inputs.first == "--source-file" { if inputs.first == "--source-file" {
guard newInstance else { throw ArgumentError("--source-file requires --new-instance.") } guard newInstance else { throw ArgumentError("--source-file requires --new-instance.") }
guard inputs.count == 2, !inputs[1].isEmpty, !inputs[1].contains("\0"), guard inputs.count == 2, !inputs[1].isEmpty, !inputs[1].contains("\0"),
+53
View File
@@ -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
}
}
}
+13 -1
View File
@@ -17,14 +17,23 @@ 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):
await launch(url: nil, sourceFile: sourceFile, newInstance: newInstance, noConfig: noConfig, progressFile: progressFile) await launch(url: nil, sourceFile: sourceFile, newInstance: newInstance, noConfig: noConfig, progressFile: progressFile)
case .launchQueue(let queueFile, let newInstance, let noConfig):
await launch(url: nil, sourceFile: nil, newInstance: newInstance, noConfig: noConfig,
progressFile: nil, queueFile: queueFile)
case .enqueue(let queueFile, let noConfig):
await launch(url: StreamQueue.enqueueURL(for: queueFile), sourceFile: nil,
newInstance: false, noConfig: noConfig, progressFile: nil)
} }
} }
private static func launch(url: URL?, sourceFile: URL?, newInstance: Bool, noConfig: Bool, progressFile: URL?) async { private static func launch(url: URL?, sourceFile: URL?, newInstance: Bool, noConfig: Bool,
progressFile: URL?, queueFile: URL? = nil) async {
if let progressFile { if let progressFile {
var existing = stat() var existing = stat()
guard lstat(progressFile.path, &existing) != 0 else { guard lstat(progressFile.path, &existing) != 0 else {
@@ -69,6 +78,9 @@ enum TerebiCLI {
if let progressFile { if let progressFile {
configuration.arguments += ["--progress-file", progressFile.path] configuration.arguments += ["--progress-file", progressFile.path]
} }
if let queueFile {
configuration.arguments += ["--queue-file=" + queueFile.path]
}
if let sourceFile { if let sourceFile {
configuration.arguments += ["--source-file", sourceFile.path] configuration.arguments += ["--source-file", sourceFile.path]
} }
+1
View File
@@ -83,6 +83,7 @@ public struct KeyBindings: Equatable, Sendable {
.enterFullscreen: ["f", "ctrl+cmd+f"], .exitFullscreen: ["f", "ctrl+cmd+f", "escape"], .enterFullscreen: ["f", "ctrl+cmd+f"], .exitFullscreen: ["f", "ctrl+cmd+f", "escape"],
.enterCompact: ["p", "ctrl+cmd+p"], .exitCompact: ["p", "ctrl+cmd+p"], .enterCompact: ["p", "ctrl+cmd+p"], .exitCompact: ["p", "ctrl+cmd+p"],
.enableAlwaysOnTop: ["shift+p"], .disableAlwaysOnTop: ["shift+p"], .enableAlwaysOnTop: ["shift+p"], .disableAlwaysOnTop: ["shift+p"],
.showQueue: ["cmd+shift+l"], .hideQueue: ["cmd+shift+l"],
.openFile: ["o", "cmd+o"], .openStream: ["cmd+shift+o"], .openFile: ["o", "cmd+o"], .openStream: ["cmd+shift+o"],
.closeFile: ["cmd+shift+w"], .quit: ["q", "cmd+q"] .closeFile: ["cmd+shift+w"], .quit: ["q", "cmd+q"]
] ]
+4
View File
@@ -6,6 +6,7 @@ public enum PlayerAction: Hashable, Sendable {
case enableAlwaysOnTop, disableAlwaysOnTop case enableAlwaysOnTop, disableAlwaysOnTop
case openFile, openStream, closeFile, quit case openFile, openStream, closeFile, quit
case showBufferInfo, hideBufferInfo case showBufferInfo, hideBufferInfo
case showQueue, hideQueue
case enableCompactSnapping, disableCompactSnapping case enableCompactSnapping, disableCompactSnapping
case enableAutomaticQuality, disableAutomaticQuality case enableAutomaticQuality, disableAutomaticQuality
case qualityDefault, quality(Int) case qualityDefault, quality(Int)
@@ -21,6 +22,7 @@ public enum PlayerAction: Hashable, Sendable {
("enable_always_on_top", .enableAlwaysOnTop), ("disable_always_on_top", .disableAlwaysOnTop), ("enable_always_on_top", .enableAlwaysOnTop), ("disable_always_on_top", .disableAlwaysOnTop),
("open_file", .openFile), ("open_stream", .openStream), ("close_file", .closeFile), ("quit", .quit), ("open_file", .openFile), ("open_stream", .openStream), ("close_file", .closeFile), ("quit", .quit),
("show_buffer_info", .showBufferInfo), ("hide_buffer_info", .hideBufferInfo), ("show_buffer_info", .showBufferInfo), ("hide_buffer_info", .hideBufferInfo),
("show_queue", .showQueue), ("hide_queue", .hideQueue),
("enable_compact_snapping", .enableCompactSnapping), ("disable_compact_snapping", .disableCompactSnapping), ("enable_compact_snapping", .enableCompactSnapping), ("disable_compact_snapping", .disableCompactSnapping),
("enable_automatic_quality", .enableAutomaticQuality), ("disable_automatic_quality", .disableAutomaticQuality), ("enable_automatic_quality", .enableAutomaticQuality), ("disable_automatic_quality", .disableAutomaticQuality),
("quality_default", .qualityDefault) ("quality_default", .qualityDefault)
@@ -60,6 +62,8 @@ public enum PlayerAction: Hashable, Sendable {
case .disableAlwaysOnTop: return .enableAlwaysOnTop case .disableAlwaysOnTop: return .enableAlwaysOnTop
case .showBufferInfo: return .hideBufferInfo case .showBufferInfo: return .hideBufferInfo
case .hideBufferInfo: return .showBufferInfo case .hideBufferInfo: return .showBufferInfo
case .showQueue: return .hideQueue
case .hideQueue: return .showQueue
case .enableCompactSnapping: return .disableCompactSnapping case .enableCompactSnapping: return .disableCompactSnapping
case .disableCompactSnapping: return .enableCompactSnapping case .disableCompactSnapping: return .enableCompactSnapping
case .enableAutomaticQuality: return .disableAutomaticQuality case .enableAutomaticQuality: return .disableAutomaticQuality
@@ -8,16 +8,28 @@ 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 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,
compactTitleProgress: CompactTitleProgress = .time, keys: KeyBindings = KeyBindings()) { sponsorBlockAutoSkip: Bool = false, openingEndingAutoSkip: Bool = false, snapToEdges: Bool = true,
compactTitleProgress: CompactTitleProgress = .time, miruroVideoProvider: String = "",
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.miruroCaptionProvider = miruroCaptionProvider
self.keys = keys self.keys = keys
} }
@@ -28,6 +40,12 @@ 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]",
"video_provider = \"\(defaults.miruroVideoProvider)\"",
"caption_provider = \"\(defaults.miruroCaptionProvider)\"",
"", "[ui]", "", "[ui]",
"buffer_info = \(defaults.showBufferInfo)", "buffer_info = \(defaults.showBufferInfo)",
"compact_snap_to_edges = \(defaults.snapToEdges)", "compact_snap_to_edges = \(defaults.snapToEdges)",
@@ -83,8 +101,13 @@ 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 ?? "",
miruroCaptionProvider: values.miruro?.captionProvider ?? "",
keys: try KeyBindings(overrides: values.keys?.overrides ?? [:])) keys: try KeyBindings(overrides: values.keys?.overrides ?? [:]))
} catch { } catch {
throw ConfigurationError(message: "\(url.path): \(diagnostic(for: error, data: data))") throw ConfigurationError(message: "\(url.path): \(diagnostic(for: error, data: data))")
@@ -143,10 +166,11 @@ public struct PlayerConfiguration: Equatable, Sendable {
let playback: PlaybackConfiguration? let playback: PlaybackConfiguration?
let ui: UIConfiguration? let ui: UIConfiguration?
let keys: KeysConfiguration? let keys: KeysConfiguration?
let miruro: MiruroConfiguration?
init(from decoder: Decoder) throws { init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: SettingKey.self) let container = try decoder.container(keyedBy: SettingKey.self)
try validate(container, allowed: ["playback", "ui", "keys"]) try validate(container, allowed: ["playback", "ui", "keys", "miruro"])
if container.contains(SettingKey("playback")) { if container.contains(SettingKey("playback")) {
playback = try PlaybackConfiguration(container: container.nestedContainer(keyedBy: SettingKey.self, forKey: SettingKey("playback"))) playback = try PlaybackConfiguration(container: container.nestedContainer(keyedBy: SettingKey.self, forKey: SettingKey("playback")))
} else { playback = nil } } else { playback = nil }
@@ -156,15 +180,51 @@ public struct PlayerConfiguration: Equatable, Sendable {
if container.contains(SettingKey("keys")) { if container.contains(SettingKey("keys")) {
keys = try KeysConfiguration(container: container.nestedContainer(keyedBy: SettingKey.self, forKey: SettingKey("keys"))) keys = try KeysConfiguration(container: container.nestedContainer(keyedBy: SettingKey.self, forKey: SettingKey("keys")))
} else { keys = nil } } else { keys = nil }
if container.contains(SettingKey("miruro")) {
miruro = try MiruroConfiguration(container: container.nestedContainer(keyedBy: SettingKey.self, forKey: SettingKey("miruro")))
} else { miruro = nil }
} }
} }
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"))
}
}
private struct MiruroConfiguration {
let videoProvider: String?
let captionProvider: String?
init(container: KeyedDecodingContainer<SettingKey>) throws {
try validate(container, allowed: ["video_provider", "caption_provider"])
videoProvider = try Self.provider("video_provider", in: container)
captionProvider = try Self.provider("caption_provider", in: container)
}
private static func provider(_ name: String, in container: KeyedDecodingContainer<SettingKey>) throws -> String? {
let value = try container.decodeIfPresent(String.self, forKey: SettingKey(name))
if let value {
let parts = value.split(separator: ":", omittingEmptySubsequences: false)
let provider = parts.first ?? ""
guard value.isEmpty || ((parts.count == 1 || (parts.count == 2 && ["ssub", "sub", "dub"].contains(String(parts[1])))) &&
!provider.isEmpty && provider.utf8.count <= 128 && provider.utf8.allSatisfy({
(65...90).contains($0) || (97...122).contains($0) || (48...57).contains($0) || $0 == 95 || $0 == 45
})) else {
throw ConfigurationError(message: "miruro.\(name): Expected an empty string (Auto), a provider name (up to 128 ASCII letters, digits, underscores or hyphens), or provider:ssub, provider:sub or provider:dub.")
}
}
return value
} }
} }
+4 -3
View File
@@ -6,9 +6,7 @@
#include <arpa/inet.h> #include <arpa/inet.h>
namespace playback { namespace playback {
inline bool validNetworkURL(const char *text) { inline bool validNetworkURL(std::string_view url) {
if (!text) return false;
const std::string_view url(text);
if (url.size() > 65536) return false; if (url.size() > 65536) return false;
const std::size_t start = url.starts_with("https://") ? 8 : url.starts_with("http://") ? 7 : 0; const std::size_t start = url.starts_with("https://") ? 8 : url.starts_with("http://") ? 7 : 0;
if (!start) return false; if (!start) return false;
@@ -52,6 +50,9 @@ inline bool validNetworkURL(const char *text) {
} }
return port.empty() || number > 0; return port.empty() || number > 0;
} }
inline bool validNetworkURL(const char *text) {
return text && validNetworkURL(std::string_view(text));
}
inline bool validHeaders(const char *text) { inline bool validHeaders(const char *text) {
if (!text) return true; if (!text) return true;
const std::string_view headers(text); const std::string_view headers(text);
+24 -5
View File
@@ -197,7 +197,7 @@ struct Player {
PlayerStatus status = PLAYER_EMPTY; PlayerStatus status = PLAYER_EMPTY;
bool previewShown = false; bool previewShown = false;
std::array<char, 512> error{}; std::array<char, 512> error{};
std::unique_ptr<playback::ProgressReporter> reporter; const std::unique_ptr<playback::ProgressReports> reporter = std::make_unique<playback::ProgressReports>();
bool remoteReporting = false; bool remoteReporting = false;
std::thread worker; std::thread worker;
@@ -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);
@@ -766,7 +766,9 @@ struct Player {
for (auto &input : inputs) { for (auto &input : inputs) {
if (!input) continue; if (!input) continue;
input->discardAudio(false); input->discardAudio(false);
if (input.get() == tracks[0].input && input.get() != tracks[1].input && beyondAudio(target)) // EOF is a timeline position, not a demux seek target (HLS rejects it).
if ((duration > 0 && target >= duration) ||
(input.get() == tracks[0].input && input.get() != tracks[1].input && beyondAudio(target)))
input->finish(); input->finish();
else else
input->seek(-1, timestamp); input->seek(-1, timestamp);
@@ -1427,10 +1429,10 @@ bool player_set_progress_file(Player *player, const char *path, char *error) {
try { try {
if (!player || !path || !*path) throw std::runtime_error("A progress file path is required"); if (!player || !path || !*path) throw std::runtime_error("A progress file path is required");
std::lock_guard<std::mutex> lock(player->mutex); std::lock_guard<std::mutex> lock(player->mutex);
if (player->reporter || player->stopping.load(std::memory_order_relaxed) || if (player->stopping.load(std::memory_order_relaxed) ||
player->state.status != PLAYER_EMPTY || !player->commands.empty()) player->state.status != PLAYER_EMPTY || !player->commands.empty())
throw std::runtime_error("Progress reporting must be initialized once, before opening media"); throw std::runtime_error("Progress reporting must be initialized once, before opening media");
player->reporter = std::make_unique<playback::ProgressReporter>(path); player->reporter->setSingleFile(path);
return true; return true;
} catch (const std::exception &failure) { } catch (const std::exception &failure) {
if (error) copyError(error, failure.what()); if (error) copyError(error, failure.what());
@@ -1439,6 +1441,23 @@ bool player_set_progress_file(Player *player, const char *path, char *error) {
} }
return false; return false;
} }
bool player_register_progress_files(Player *player, const char *const *identities,
const char *const *paths, int count, char *error) {
if (error) error[0] = '\0';
try {
if (!player) throw std::runtime_error("A player is required for queue progress registration");
std::lock_guard<std::mutex> lock(player->mutex);
if (player->stopping.load(std::memory_order_relaxed))
throw std::runtime_error("Cannot register queue reports while the player is stopping");
player->reporter->registerFiles(identities, paths, count);
return true;
} catch (const std::exception &failure) {
if (error) copyError(error, failure.what());
} catch (...) {
if (error) copyError(error, "Could not register queue progress reporting");
}
return false;
}
bool player_get_progress_error(Player *player, char *error) { bool player_get_progress_error(Player *player, char *error) {
if (error) error[0] = '\0'; if (error) error[0] = '\0';
if (!player || !player->reporter) return false; if (!player || !player->reporter) return false;
+8 -2
View File
@@ -40,6 +40,12 @@ Player *player_create(void);
// Opt-in reporting must be initialized before opening media. Creates a new 0600 // Opt-in reporting must be initialized before opening media. Creates a new 0600
// file exclusively; errors never change playback state. Error buffers hold 512 bytes. // file exclusively; errors never change playback state. Error buffers hold 512 bytes.
bool player_set_progress_file(Player *player, const char *path, char *error); bool player_set_progress_file(Player *player, const char *path, char *error);
// Register queue destinations without changing playback. Each path is new and stays reserved.
// A fresh path replaces an identity's previous session after draining its writer; callers
// reject identities still queued. Revisits append only to the registered session's owned inode.
// Atomic batch: all registrations succeed or none do. Strings are copied before returning.
bool player_register_progress_files(Player *player, const char *const *identities,
const char *const *paths, int count, char *error);
bool player_get_progress_error(Player *player, char *error); bool player_get_progress_error(Player *player, char *error);
// Stops the worker and drains final reporting, allowing errors to be shown before // Stops the worker and drains final reporting, allowing errors to be shown before
// destroying the handle. Idempotent; do not call from an audio callback. // destroying the handle. Idempotent; do not call from an audio callback.
@@ -53,8 +59,8 @@ typedef struct PlayerNetworkSource {
const char *audio_headers; const char *audio_headers;
double duration; double duration;
bool is_live; bool is_live;
// Canonical YouTube webpage or stable miruro:<base64url media_id>:<positive episode> // Original HTTP(S) video URL (canonical for YouTube), or stable
// identity, never an extracted stream/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
+163 -17
View File
@@ -1,4 +1,5 @@
#include "progress_reporter.hpp" #include "progress_reporter.hpp"
#include "network_policy.hpp"
#include <cerrno> #include <cerrno>
#include <charconv> #include <charconv>
#include <chrono> #include <chrono>
@@ -28,7 +29,7 @@ void appendNumber(std::string &record, double value) {
} }
ProgressReporter::ProgressReporter(const std::string &path, std::function<double()> clock) ProgressReporter::ProgressReporter(const std::string &path, std::function<double()> clock)
: clock(clock ? std::move(clock) : monotonicSeconds) { : path(path), clock(clock ? std::move(clock) : monotonicSeconds) {
if (path.empty() || path.find('\0') != std::string::npos) if (path.empty() || path.find('\0') != std::string::npos)
throw std::invalid_argument("The progress file path must be nonempty and contain no NUL bytes"); throw std::invalid_argument("The progress file path must be nonempty and contain no NUL bytes");
descriptor = ::open(path.c_str(), O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, 0600); descriptor = ::open(path.c_str(), O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, 0600);
@@ -49,6 +50,8 @@ ProgressReporter::ProgressReporter(const std::string &path, std::function<double
throw std::runtime_error(std::string("Could not verify progress file permissions: ") + std::strerror(errno)); throw std::runtime_error(std::string("Could not verify progress file permissions: ") + std::strerror(errno));
if ((info.st_mode & 07777) != 0600) if ((info.st_mode & 07777) != 0600)
throw std::runtime_error("The progress file must have exactly owner read/write permissions (0600)"); throw std::runtime_error("The progress file must have exactly owner read/write permissions (0600)");
device = info.st_dev;
inode = info.st_ino;
writer = std::thread([this] { writeRecords(); }); writer = std::thread([this] { writeRecords(); });
} catch (...) { } catch (...) {
::close(descriptor); ::close(descriptor);
@@ -59,24 +62,51 @@ ProgressReporter::ProgressReporter(const std::string &path, std::function<double
ProgressReporter::~ProgressReporter() { finish(); } ProgressReporter::~ProgressReporter() { finish(); }
bool ProgressReporter::validMediaURL(std::string_view url) noexcept { void ProgressReporter::resume() {
constexpr std::string_view prefix = "https://www.youtube.com/watch?v="; if (!finished) return;
if (url.size() != prefix.size() + 11 || url.substr(0, prefix.size()) != prefix) return false; if (disabled.load(std::memory_order_acquire))
for (char character : url.substr(prefix.size())) { throw std::runtime_error("Cannot resume a failed progress report");
if (!((character >= 'a' && character <= 'z') || (character >= 'A' && character <= 'Z') || descriptor = ::open(path.c_str(), O_WRONLY | O_APPEND | O_NONBLOCK | O_NOFOLLOW | O_CLOEXEC);
(character >= '0' && character <= '9') || character == '_' || character == '-')) return false; if (descriptor < 0) throw std::runtime_error("Could not reopen the owned progress report");
struct stat info{};
if (::fstat(descriptor, &info) != 0 || !S_ISREG(info.st_mode) ||
info.st_dev != device || info.st_ino != inode || info.st_uid != ::geteuid() ||
info.st_nlink != 1 || (info.st_mode & 07777) != 0600) {
::close(descriptor);
descriptor = -1;
throw std::runtime_error("The owned progress report was replaced or is no longer private");
} }
return true; try {
stopping = false;
writer = std::thread([this] { writeRecords(); });
finished = false;
} catch (...) {
::close(descriptor);
descriptor = -1;
throw;
}
}
bool ProgressReporter::validMediaURL(std::string_view url) noexcept {
return validNetworkURL(url);
} }
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;
@@ -164,19 +194,26 @@ 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\":";
} else { } else {
record += "\",\"media_url\":\""; record += "\",\"media_url\":\"";
record += mediaURL; for (char character : mediaURL) {
if (character == '"' || character == '\\') record += '\\';
record += character;
}
record += "\",\"position_seconds\":"; record += "\",\"position_seconds\":";
} }
appendNumber(record, snapshot.position); appendNumber(record, snapshot.position);
@@ -261,4 +298,113 @@ void ProgressReporter::finish() noexcept {
wake.notify_all(); wake.notify_all();
if (writer.joinable()) writer.join(); if (writer.joinable()) writer.join();
} }
ProgressReports::ProgressReports(const std::string &path) { setSingleFile(path); }
void ProgressReports::setSingleFile(const std::string &path) {
std::lock_guard lock(mutex);
if (single || !entries.empty()) throw std::runtime_error("Progress reporting is already initialized");
single = std::make_unique<ProgressReporter>(path);
singlePath = path;
active = single.get();
}
void ProgressReports::registerFile(const std::string &identity, const std::string &path) {
const char *identities[] = {identity.c_str()}, *destinations[] = {path.c_str()};
registerFiles(identities, destinations, 1);
}
void ProgressReports::registerFiles(const char *const *identities, const char *const *destinations, int count) {
std::lock_guard lock(mutex);
if (!identities || !destinations || count < 1 || count > 4096 ||
paths.size() + count > 4096)
throw std::runtime_error("Queue reports exceed the 4096 retained registration limit");
std::map<std::string, Entry> additions;
std::set<std::string> newPaths;
for (int index = 0; index < count; ++index) {
const auto *identity = identities[index], *path = destinations[index];
if (!identity || !path || !ProgressReporter::validMediaIdentity(identity) ||
!*path || *path != '/' || additions.contains(identity))
throw std::runtime_error("Invalid or conflicting queue progress identity");
if (singlePath == path || paths.contains(path) || !newPaths.insert(path).second)
throw std::runtime_error("Conflicting queue progress report path");
additions.emplace(identity, Entry{path, nullptr});
}
// Validate and allocate the whole batch before retiring any previous sessions.
auto retire = [this](ProgressReporter *reporter) {
if (!reporter) return;
reporter->finish();
if (active == reporter) active = nullptr;
try { if (failure.empty()) failure = reporter->error(); } catch (...) {}
};
for (auto &[identity, entry] : additions) {
if (singleIdentity == identity) retire(single.get());
if (auto found = entries.find(identity); found != entries.end()) {
retire(found->second.reporter.get());
std::swap(found->second, entry);
}
}
// Node merges allocate nothing; replaced nodes remain in additions for destruction.
entries.merge(additions);
paths.merge(newPaths);
}
void ProgressReports::observe(const PlayerState &state) noexcept {
std::lock_guard lock(mutex);
if (active) active->observe(state);
}
void ProgressReports::closeMedia() noexcept {
std::lock_guard lock(mutex);
if (active) active->closeMedia();
}
void ProgressReports::begin(const std::string &identity, bool live) noexcept {
std::lock_guard lock(mutex);
const auto found = entries.find(identity);
const bool useSingle = found == entries.end() && single &&
(entries.empty() || singleIdentity.empty() || singleIdentity == identity);
auto *next = found == entries.end() ? (useSingle ? single.get() : nullptr) : found->second.reporter.get();
if (active != next && active) active->finish();
active = nullptr; // A failed or unregistered identity must never reuse the old report.
if (found == entries.end() && !useSingle) return;
try {
if (found == entries.end()) {
singleIdentity = identity;
single->resume();
active = single.get();
} else {
auto &entry = found->second;
if (!entry.reporter) entry.reporter = std::make_unique<ProgressReporter>(entry.path);
else entry.reporter->resume();
active = entry.reporter.get();
}
active->begin(identity, live);
} catch (const std::exception &exception) {
try { if (failure.empty()) failure = exception.what(); } catch (...) {}
} catch (...) {
try { if (failure.empty()) failure = "Could not select queue progress report"; } catch (...) {}
}
}
void ProgressReports::finish() noexcept {
std::lock_guard lock(mutex);
if (active) active->finish();
}
std::string ProgressReports::error() const {
std::lock_guard lock(mutex);
if (!failure.empty()) return failure;
if (single) {
const auto message = single->error();
if (!message.empty()) return message;
}
for (const auto &[identity, entry] : entries) {
if (entry.reporter) {
auto message = entry.reporter->error();
if (!message.empty()) return message;
}
}
return {};
}
} }
+34
View File
@@ -7,6 +7,10 @@
#include <functional> #include <functional>
#include <mutex> #include <mutex>
#include <string> #include <string>
#include <map>
#include <memory>
#include <set>
#include <sys/types.h>
#include <string_view> #include <string_view>
#include <thread> #include <thread>
@@ -26,6 +30,8 @@ public:
void closeMedia() noexcept; void closeMedia() noexcept;
// Call after the decoder joins; drains queued records and joins the writer. // Call after the decoder joins; drains queued records and joins the writer.
void finish() noexcept; void finish() noexcept;
// Reopen only this session's original private inode, never an arbitrary existing file.
void resume();
std::string error() const; std::string error() const;
private: private:
@@ -36,6 +42,9 @@ private:
bool eligible = false; bool eligible = false;
} snapshot; } snapshot;
std::string mediaURL; std::string mediaURL;
std::string path;
dev_t device = 0;
ino_t inode = 0;
std::function<double()> clock; std::function<double()> clock;
bool live = false; bool live = false;
bool hasSnapshot = false; bool hasSnapshot = false;
@@ -58,4 +67,29 @@ private:
void disable(const char *message, int osError = 0) noexcept; void disable(const char *message, int osError = 0) noexcept;
void writeRecords() noexcept; void writeRecords() noexcept;
}; };
// Routes decoder-owned snapshots by immutable media identity; only the active writer runs.
class ProgressReports {
public:
explicit ProgressReports(const std::string &path);
ProgressReports() = default;
void setSingleFile(const std::string &path);
void registerFile(const std::string &identity, const std::string &path);
void registerFiles(const char *const *identities, const char *const *paths, int count);
void begin(const std::string &identity, bool live = false) noexcept;
void observe(const PlayerState &state) noexcept;
void closeMedia() noexcept;
void finish() noexcept;
std::string error() const;
private:
struct Entry { std::string path; std::unique_ptr<ProgressReporter> reporter; };
std::unique_ptr<ProgressReporter> single;
std::string singlePath;
std::string singleIdentity;
std::map<std::string, Entry> entries;
std::set<std::string> paths;
ProgressReporter *active = nullptr;
mutable std::mutex mutex;
std::string failure;
};
} }
-23
View File
@@ -1,23 +0,0 @@
- [x] improve chrome cast subtitle rendering
- native WebVTT style: white sans-serif, black outline, transparent background; physical-TV font-scale tuning remains unverified
- [x] currently, computer falls asleep even if video is playing. this should not be the case.
- native playback activity prevents idle system sleep; local video keeps the display awake, casting allows screensaver/display sleep; native assertion checks and loopback Cast transitions passed, physical idle-TV verification remains unverified
- [x] add session-only local-file playlist support
- multi-file Open/Finder/drop, natural filename order, editable queue sidebar, explicit append, previous/next and automatic advance; Cast navigation retains the receiver; UI/drag-drop and physical-Cast verification remain unverified
- [x] make sure the `make install` command installs the application (i can't find it in spotlight)
- installs to ~/Applications, migrates the old hidden app, registers with Launch Services and requests Spotlight indexing; actual install, CLI, code signature, registration and Spotlight query passed; Spotlight UI remains unverified
- [x] takes an awfully long time seek in local videos which i find weird, since seeking in streamed youtube videos is pretty much instant
- local seek preroll skips disposable non-reference pictures without sacrificing exact target frames; measured 1080p/10-second-GOP paused seek improved from 541 ms to 400 ms and playing seek from 682 ms to 488 ms; timestamp/pixel checks passed across six decoder formats; long-GOP reference decoding remains necessary, and the originally affected files still need manual verification
- [x] make the sidebar expand/collapse with a nice transition
- synchronized 250 ms sidebar/window reveal, reversible mid-transition, with immediate Reduce Motion changes; top-right controls stay anchored to the moving window corner; native checks passed for rendered controls during transitions, stable video size, reversals, screen-edge fitting, fullscreen and compact restoration; animation feel and Reduce Motion still need manual verification
- [x] make the window draggable (in all cases/view states) so long as a button/clickable element is not hovered.
- scoped native background dragging across start/playback/queue/error/Cast/compact views, with passive loading/audio overlays passing input through; 20 native input-routing probes passed, including controls and row-reorder exclusions; physical pointer dragging and Cast-screen interaction still need manual verification
- [x] on the start screen:
- [x] make the youtube url input field that appears when clicking the youtube button appear with a nice transition
- 200 ms fade/scale with animated row reflow; the button and ⇧⌘O toggle the same inline input, including while editing; native focus, draft-preservation and rapid-reversal checks cover mouse/shortcut interoperation
- [x] change the youtube button to general streaming
- Open Stream routes YouTube videos and explicit barelystarted.miruro.tv episode links; live Miruro video/audio and VTT seek checks passed, with shared validated source metadata and unchanged Tubular source/progress contracts; CLI handoff emitted v2 playback/end facts, but its GUI-state checker timed out without snapshots; physical UI/drop/Cast verification remains unverified
- [x] check whether we're using appropriately new apple ui APIs / SDKs for the project.
- we should strive to use modern and idiomatic SDKs and APIs including SwiftUI among others
- audited SDK 27.0 / Swift 6.4 with the macOS 15 minimum retained; migrated Cast discovery to NWBrowser and production activation to activate(). Real Bonjour identity/name/update/order/deduplication/removal/restart and routable-endpoint checks, loopback Cast transitions, focused startup-error alerts and a cooperating-launcher production smoke for cold start/CLI reuse/document handoff/keyboard playback passed. The existing full CLI snapshot-harness timeout remains; macOS 15 runtime and physical-Cast verification remain unverified. Observation, glass and the existing native bridges were left unchanged.
- [x] fix the readme. it's currently bloated with irrelevant implementation detail and other such nonsense. it should do the job of a readme only, nothing more.
+123 -31
View File
@@ -4,25 +4,101 @@
Terebi now supports resolved Miruro HTTP(S) sources with per-source headers, external WebVTT and stable episode progress. Direct **`ssub` / `sun`** playback was exercised with a freshly resolved source: native video/audio advanced, default English subtitles loaded, and the model's cue text matched expected subtitles after forward and backward acknowledged seeks. The user also reported that visible playback appears to work; this is not a comprehensive visual synchronization sign-off. Terebi now supports resolved Miruro HTTP(S) sources with per-source headers, external WebVTT and stable episode progress. Direct **`ssub` / `sun`** playback was exercised with a freshly resolved source: native video/audio advanced, default English subtitles loaded, and the model's cue text matched expected subtitles after forward and backward acknowledged seeks. The user also reported that visible playback appears to work; this is not a comprehensive visual synchronization sign-off.
Tubular's existing source-file/progress-file handoff remains supported unchanged. Terebi also accepts explicit Miruro episode page URLs through Open Stream, drop, CLI and URL events; that path resolves into the same validated source representation without re-resolving externally supplied source files. Tubular launches native episode URLs. Terebi resolves fresh direct sources and independently acquires optional descriptive titles and opening/ending metadata. Metadata never gates source playback: missing or invalid enrichment retains a generic title and no bands.
## Ownership ## Ownership
- **Tubular:** discovery, episode/provider/language selection, fresh source resolution, source-document creation, session cleanup and watched-state decisions. - **Tubular:** catalog/detail browsing, actual returned episode selection, collections, private reporting sessions, cleanup and watched-state decisions.
- **Terebi:** native playback, Cast discovery/control and LAN delivery, request-header propagation, subtitle loading/selection/rendering, stable-identity bookmarks and acknowledged progress reporting. - **Terebi:** episode-URL validation, fresh source resolution, headers/subtitles, titles, optional timeline bands, stable-identity resume and progress reporting.
Native episode URL resolution uses Miruro's source endpoint and the same direct ssub/Sun selection as Tubular. No catalog browsing, `strm.cx` embedding, cookies, impersonation or protection-bypass machinery is used. Native resolution retains actual `provider:track` choices (`ssub`, `sub`, `dub`) and discovers captions independently of video eligibility. **Playback → Miruro** and the gear menu offer independent **Video Host** / **Caption Provider** choices. Optional `[miruro]` config strings `video_provider` and `caption_provider` accept a bare provider preference or an exact choice such as `bee:ssub`; empty means Automatic. Automatic video prefers Japanese Sun, then provider order and each provider's variant order. Dub is selectable explicitly, never an automatic language fallback. A dub-only failed automatic open retains the catalog so its choices remain accessible.
Host changes resolve fresh sources and preserve acknowledged position and playing/paused intent; caption changes retain their own provider/server headers and do not reopen video. Caption language is retained when available, and explicit Off survives provider changes. Choices made in menus are session-only. Native source menus are disabled during opening, pending seeks and casting.
Provider-selection verification: resolver/configuration checks and the app/CLI build passed. Local native HLS/VTT smoke covered proxy encoding and already-proxied nested manifests, provider query/segment-origin rules, rejection of foreign loopback keys, independent caption headers, caption changes after prepared playback, paused/playing host switches, failure retention, stale results, and interactive dub-only selection. Live native video/audio, default captions, measured-duration metadata and acknowledged forward/backward subtitle seeks passed for `bee:ssub`, `hop:ssub`, `bun:ssub`, `bun:dub` and `sun:sub` on the sampled episode. These are headless model checks, not visible menu/subtitle or perceived A/V-sync sign-off.
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
terebi --new-instance --source-file '/private/session/source.json' \ terebi --new-instance \
--url 'https://barelystarted.miruro.tv/watch/OPAQUE_ID/episode?ep=1' \
--progress-file '/private/session/progress.jsonl' --progress-file '/private/session/progress.jsonl'
``` ```
`--source-file` requires `--new-instance`; `--progress-file` is optional and retains its existing behavior. Relative paths resolve against the invoking shell's directory. Each launch creates an independent player; the CLI passes absolute paths to the GUI, not media URLs/header values in arguments. The slug `episode` is deliberately non-identifying. Use the returned episode number, not the absolute episode, array index or an external catalog ID. Slug/title changes never change resume or progress identity.
Create a unique session directory with mode `0700`, write the source document with mode `0600`, and supply a **nonexistent** progress-file destination. Terebi exclusively creates the report with mode `0600`; it rejects existing destinations and symlinks. Keep the source file unchanged and present for the player session. CLI success means macOS accepted the launch, not that media opened or playback completed. Tubular owns session-file cleanup. Create a private reporting-session directory (`0700`) and supply a **nonexistent** progress-file destination. Terebi exclusively creates the report (`0600`), rejecting existing destinations and symlinks. CLI success means macOS accepted the launch, not that playback succeeded. Tubular consumes reporting independently of launcher lifetime; launcher exit, a final record or Tubular shutdown does not establish that the player stopped. Preserve retained sessions and restart/partial-line recovery.
### Ordered stream queues
```sh
terebi --new-instance --queue-file '/absolute/private/session/queue.json'
# Append to the running player, or launch and play if none is running:
terebi --enqueue --queue-file '/absolute/private/session/queue.json'
```
The manifest is bounded to 8 MiB, version 1, and 1–4096 entries in playback order:
```json
{
"version": 1,
"items": [
{"url": "https://www.youtube.com/watch?v=jNQXAC9IVRw", "progress_file": "/absolute/private/youtube-session/progress.jsonl"},
{"url": "https://barelystarted.miruro.tv/watch/OPAQUE_ID/episode?ep=1", "progress_file": "/absolute/private/episode-session/progress.jsonl"}
]
}
```
Use canonical YouTube video pages or supported Miruro episode pages, never expiring source addresses. Stable identities must be unique even across alternate YouTube forms or different Miruro slugs. Reports must have distinct absolute nonexistent destinations, each under a canonical, nonsymlink, user-owned `0700` directory. Tubular retains each item's existing identity registration/session and stores the manifest in the first session. The CLI validates the complete document before launching; the GUI validates it again before opening any media. Invalid queues fail rather than falling back to a single stream. Queue launch requires either `--new-instance` (replace in a separate player) or `--enqueue` (append to the running player), accepts `--no-config`, and cannot combine with `--url`, `--source-file` or `--progress-file`. `--enqueue` and `--new-instance` are mutually exclusive.
The supplied order is preserved (local-file batches still use natural filename order). Existing sidebar reorder/remove/play and previous/next navigation apply. Natural end advances without clearing the queue. Unresolved labels, including the current row, start serial utility-priority lookups without waiting for active playback or quality extraction, loading or buffering. YouTube uses native oEmbed without format/challenge extraction; generic HTTP(S) sites use isolated yt-dlp metadata extraction without requiring playable formats or a JavaScript runtime (site-specific extraction may still take time). Miruro uses optional watch-route metadata without fetching playback sources. Network arrival is not instantaneous. Failures keep the supplied label and do not block later entries or surface playback errors. Results follow item IDs across reordering and cannot rename removed/replaced rows or overwrite richer foreground metadata. Only titles are retained: playback still resolves fresh on selection/revisits.
Enqueue uses native macOS URL-open delivery to the explicit application, not launch arguments: repeated requests reach the existing process. Both one-item and multi-item requests use the same manifest. The current selection, playback/pause state, timeline, pending resolution and metadata stay intact; an empty player selects and starts the first entry. Original single-file, single-stream and resolved-source playback remain navigable when items are appended. Enqueue never silently deduplicates: any identity still in the queue, or any report-path collision, rejects the complete batch with an error in the player. Removed videos can be re-added with fresh report destinations, including the original standalone video. Old report paths remain reserved for the process lifetime; at most 4096 queue-report destinations may be registered, counting retired sessions. Existing files are never adopted as reporting sessions. Keep manifests and private sessions available until delivery; CLI success acknowledges macOS delivery, not GUI acceptance or playback.
Each report is immutably bound to its stable identity in the native decoder. Registration is atomic for a complete batch and can occur during playback without redirecting unrelated current reporting. Re-registering a removed identity drains/closes its old writer before replacing the session; the old report is never overwritten or appended to afterward, and the fresh report is created only when the re-added video begins. Switching entries drains/closes the previous writer before beginning the next; revisits within a session append only after verifying the original private report inode, owner, permissions and link count. An unrelated or replaced file is never reopened as an owned session, and an unregistered identity is never written to a previously selected queue report. Only the active item owns a running writer/open report descriptor. Retiring a failed writer preserves its reporting error. Reporting failures remain separate from acknowledged playback/completion semantics. An original single-stream `--progress-file` keeps its destination when navigating back from appended queue entries, unless its row was removed and re-added with a fresh destination.
Runnable headless queue checks: `make check-cli check-queue check-reporting check-resolved`. The existing `check-resolved` local HTTP/HLS/VTT harness drives a real native `PlayerModel` through mixed YouTube/Miruro queues with injected fixture resolution, stale-result cancellation, manual navigation, natural-end advancement, fresh revisits/resume and isolated v1/v2 reports. It also delivers repeated enqueue URLs while paused and playing, checks original standalone selection/timeline preservation, lazy append ordering, atomic invalid/conflicting requests, independent single/queue report revisits, removal/re-addition with fresh reports and unchanged retired files, and empty-player startup. These checks require no browser or live streaming service. Actual LaunchServices/UI delivery remains a separate user-owned verification step; no GUI smoke result is claimed for enqueue.
Background-title regression: the native local-HLS check first failed because the current row stayed unresolved while foreground stream extraction was held. The updated check holds both playback and quality extraction and requires current/queued titles before releasing them; it also covers serial utility-priority lookup, generic/Miruro titles, reorder, failure fallbacks, unchanged paused selection/position/current title, foreground precedence and stale removal/replacement. Resolver checks exercise bounded native YouTube oEmbed with an intercepted URLSession and real bundled yt-dlp against local metadata fixtures without playable formats. No browser or live service is needed; sidebar appearance remains a user-owned visual check.
Verified with `make check-youtube check-queue check-resolved check-adaptive check-stream all`: all passed. This includes native oEmbed cancellation and response-size bounds (declared and streamed), metadata-only bundled extraction, real local HLS/VTT playback, and app/CLI builds. No live-service latency or visual UI result is claimed.
Removal/re-addition verification: the new native registration regression failed before the fix and passed afterward. `make check-reporting check-queue .build/resolved-playback-check all` passed. A focused `.build/resolved-playback-check /absolute/source.json --enqueue-only` smoke using local video-only H.264 HLS passed paused/playing append, natural-end advancement, atomic conflicts/rollback, removal and re-addition of the current standalone video and an inactive queued video, fresh-session playback, byte-for-byte retired-report preservation, and empty-player startup. The source document requires `headers` and `subtitles` (empty values are valid). No full A/V-suite or packaged-GUI/LaunchServices pass is claimed; the app was built, not installed. Actual UI confirmation remains user-owned; temporary fixture/server files were removed.
### Optional native metadata
The native resolver requests watch-route data and safely follows bounded same-origin, same-ID, same-episode canonicalization envelopes, including HTTP-200 redirects. It accepts JSON and newline-delimited `text/sveltekit-data`, decoding only the needed response-local tables and episode promise. Bodies and traversal are bounded; unrelated source promises are not used for playback.
Titles come from returned series/selected-episode metadata. Only `op`/`mixed-op` and `ed`/`mixed-ed` become opening/ending bands. Bands retain fractional seconds, require finite positive player-reported duration, and reject nonfinite timestamps, originally reversed/zero-length intervals and wrong-episode timings. Display intervals are clipped to `[0, duration]`; intervals with no positive-length overlap are hidden. Raw timestamps remain unchanged, and duration changes recompute clipping without shifting or scaling. Optional provenance/votes/reference-length checks remain in force; a finite positive reference length differing from actual duration by more than 5% rejects the interval, and missing fields are not invented. Highest-vote eligible opening and ending win, with response order breaking ties. Overlaps are not merged.
The controls mark interval boundaries with small rounded gaps in the slider track, without visible labels or boxes. Buffer rendering shares the segmented track; accessibility descriptions retain opening/ending names and times. While acknowledged, seekable video playback is inside a band, a **Skip Opening** or **Skip Ending** button offers a seek to its end for up to ten seconds of monotonic time, including while paused, or until playback leaves the band. Each kind is offered once until close or media-identity replacement; pointer movement and seeking back do not reoffer ignored segments. Pending seeks and slider previews cannot expose a stale offer. The same action routes to local or Cast playback and retains playback intent. No automatic skipping is performed. Bounds do not prove synchronization across cuts. Metadata tasks are bound to identity and lifecycle generation; replacement clears intervals and rejects stale results, including A → B → A.
### External resolved-source compatibility
`--source-file PATH --new-instance` remains available for callers supplying already-resolved sources; it is not Tubular's current launch path and does not acquire URL metadata. Keep private source files unchanged and present for their player session. Their version-1 contract below is unchanged.
### Source document, version 1 ### Source document, version 1
@@ -60,26 +136,28 @@ All shown fields except `title` are required. `headers: {}` and `subtitles: []`
Limits: source document 1 MiB; 32 subtitle tracks; 64 headers per resource; Miruro media ID up to 512 ASCII letters/digits/`-`/`_`; episode integer 1–2147483647. Invalid URLs, HTTP header names, case-insensitive duplicate header names and control characters are rejected with sanitized errors. Header values are not trimmed or rewritten. Limits: source document 1 MiB; 32 subtitle tracks; 64 headers per resource; Miruro media ID up to 512 ASCII letters/digits/`-`/`_`; episode integer 1–2147483647. Invalid URLs, HTTP header names, case-insensitive duplicate header names and control characters are rejected with sanitized errors. Header values are not trimmed or rewritten.
### Projection from Tubular's Miruro response ### External source projection
Use the selected direct Sun server/provider from `src/miruro.rs`: External source-document producers must preserve selected direct stream URLs and complete server headers, use Miruro's opaque catalog ID and returned episode number for identity, and carry explicit per-subtitle headers. Subtitle headers **do not inherit** stream headers. The verified Sun path supplies server headers to its VTT resources; do not generalize this to other providers.
| Terebi field | Tubular resolved source | `identity.provider` is the catalog namespace `miruro`, not the stream provider `sun`. Titles, slugs and expiring CDN URLs are not identity. Tubular no longer projects source documents.
| --- | --- |
| `url` | Selected `server.streams[].url`, unchanged |
| `headers` | Selected server's header map, including Origin and Referer |
| `identity.media_id` | Miruro opaque catalog ID, not AniList/MAL ID or slug |
| `identity.episode` | Selected returned episode number used for source resolution, not absolute episode number or array index |
| `subtitles[].url` | Selected provider subtitle's `file` |
| `format`, `language`, `label`, `default` | Selected subtitle metadata; restrict this launch to VTT and at most one default |
| `subtitles[].headers` | Explicit headers appropriate to that subtitle resource |
Subtitle headers **do not inherit** stream headers. The verified Sun smoke explicitly supplied its server headers to the English subtitle resource; do not assume this applies to other providers or resources. `identity.provider` is the catalog namespace `miruro`, not the playback provider `sun`. ## Native provider transport
## Propagation and subtitle behavior Terebi, not Tubular, prepares provider-specific playback. Bee/Hop object-valued proxies are preserved. Proxy bases and the public URL codec are read from Miruro's deployment environment; playlists, captions and resources use their distinct proxy routes. Already-proxied manifest references are decoded before applying provider rules, avoiding double wrapping. Enabled playlist query overrides apply to nested playlists; a truthy `skip` first tries without `token`, then the original token on request failure. Segment-origin rewriting applies to segments and `EXT-X-MAP`, not AES keys.
- Stream headers reach the master, nested variants, media segments, AES-128 keys and fMP4 init resources. Relative references use native HLS resolution. Existing query tokens are preserved; Terebi does not invent query inheritance or provider-specific URL rewrites. MegaPlay iframe sources used by Bun are extracted without executing JavaScript or opening a browser. Terebi reads the embed's declared source endpoint and deployed public AES/HMAC codec constants, decrypts source/segment references, and handles the provider's token and selected-CDN prefix rules. The fixed public MegaPlay Referer accompanies its media requests. Other embed hosts are not treated as playable native streams.
- Resolved media requests must stay on the initial stream's origin: scheme, host and effective port. **All media redirects, including same-origin redirects, are rejected before a second request.** This conservative policy prevents FFmpeg from forwarding supplied private headers to another destination. Cross-origin HLS resources are not supported in this contract.
Iframe captions may duplicate a same-track parent's exact returned resource set. Only when those complete subtitle records match and the iframe has no request headers does Terebi reuse that parent's caption headers/proxy; Bun remains the selected caption provider. This is a native integration policy, not general inheritance of video configuration. Different resources never receive the parent's request context.
Prepared playback uses a capability-token loopback relay retained by the current source, including across in-place caption changes and Cast preparation. Native decoding receives no upstream headers. Supplied private headers remain source-origin-bound; foreign destinations are checked against resolved private/local addresses, while the embed's fixed public headers have media-wide scope. Redirects are rejected. Manifests are bounded to 2 MiB, individual resources to 64 MiB, request concurrency to eight, route count to 32,768 and nesting depth to twelve. Resources are buffered individually, never downloaded as a complete episode; oversized resources fail closed. After an embed body is transformed, range offsets and `Content-Range` describe the transformed representation.
Protocol references: [Miruro watch client](https://barelystarted.miruro.tv/_app/immutable/nodes/11.CUef647B.js), [deployment environment](https://barelystarted.miruro.tv/), [MegaPlay source bootstrap](https://megaplay.buzz/videojs/lib/e1-player.min.js?v=3.19), [MegaPlay resource transforms](https://megaplay.buzz/videojs/lib/newclient.min.js?v=4.23). Deployment changes can invalidate native extraction; errors redact source details.
## Direct source-document propagation and subtitle behavior
- The unchanged external version-1 source-document path supplies direct URLs, without native provider requirements. Stream headers reach the master, nested variants, media segments, AES-128 keys and fMP4 init resources. Relative references use native HLS resolution; existing query tokens are preserved without invented inheritance or rewrites.
- Direct source-document media requests stay on the initial stream's origin: scheme, host and effective port. **All media redirects, including same-origin redirects, are rejected before a second request.** The provider relay described above is a separate internal preparation path, not an extension of this external file format.
- Sun serves MPEG-TS segments as `.jpg` / `image/jpeg`. Resolved playback probes actual media content instead of enforcing filename-extension matching; it does not change the URLs. HTTP(S)-only, origin and redirect restrictions remain in force. - Sun serves MPEG-TS segments as `.jpg` / `image/jpeg`. Resolved playback probes actual media content instead of enforcing filename-extension matching; it does not change the URLs. HTTP(S)-only, origin and redirect restrictions remain in force.
- External VTT classification uses explicit format and content, not filename extension or MIME type. `.jpeg` subtitle URLs work. Labels/languages appear in the captions menu; the declared default is selected, and the user can choose Off or another track. - External VTT classification uses explicit format and content, not filename extension or MIME type. `.jpeg` subtitle URLs work. Labels/languages appear in the captions menu; the declared default is selected, and the user can choose Off or another track.
- Cues follow acknowledged player position, including seeking backwards. Zero-duration cues are permanently inactive rather than invalidating the track. Rendering is plain text; WebVTT CSS, positioning and typographic styling are not applied. - Cues follow acknowledged player position, including seeking backwards. Zero-duration cues are permanently inactive rather than invalidating the track. Rendering is plain text; WebVTT CSS, positioning and typographic styling are not applied.
@@ -145,22 +223,36 @@ For only the actual Miruro launch scenario:
TEREBI_CHECK_RESOLVED_ONLY=1 make check-startup TEREBI_CHECK_RESOLVED_ONLY=1 make check-startup
``` ```
Default checks use local fixtures and do not contact Miruro. For an **explicit live smoke**, resolve fresh in Tubular, project the private source document above, then run: Default checks use local fixtures and do not contact Miruro. Optional metadata protocol/security/normalization checks:
```sh ```sh
make check-miruro-metadata
make .build/resolved-playback-check make .build/resolved-playback-check
.build/resolved-playback-check '/absolute/private/session/source.json' .build/resolved-playback-check --metadata-lifecycle
``` ```
This checks native decode and acknowledged subtitle/seek state, not visual appearance or perceived A/V synchronization. Use the production launch command for that human check. Tubular's existing `cargo run --locked --example miruro_probe` verifies source resolution/master/subtitle retrieval, not Terebi playback. Explicit live native URL smoke (contacts Miruro and media/subtitle resources):
## Remaining work and limits ```sh
.build/miruro-metadata-check --live 'https://barelystarted.miruro.tv/watch/OERe_a0YS4h-DlsBDOeXwX7HOO6316tw/episode?ep=1'
.build/resolved-playback-check 'https://barelystarted.miruro.tv/watch/OERe_a0YS4h-DlsBDOeXwX7HOO6316tw/episode?ep=1'
.build/resolved-playback-check 'https://barelystarted.miruro.tv/watch/OERe_a0YS4h-DlsBDOeXwX7HOO6316tw/episode?ep=1' --provider=bun:ssub
```
1. Tubular must create the source document, launch Terebi with it and implement v2 progress consumption; no Tubular implementation files were changed by this work. URL smoke expects a fixture episode with available title, bands and default subtitles. It checks native decode and acknowledged subtitle/seek state, not visual appearance or perceived A/V synchronization. Tubular's catalog probe does not run Terebi or retrieve sources.
2. Confirm this identity/reporting contract on the Tubular side; external adoption is not yet agreement. The optional `--provider=provider:track` exercises that exact live choice; use `bee:ssub`, `hop:ssub`, `bun:ssub`, `sun:sub`, or an explicitly dubbed choice such as `bun:dub`. Live checks can emit redacted FFmpeg warnings/errors while still passing; they assert actual decoded state and cue timing, not the absence of diagnostics.
3. For end-to-end release verification, check English subtitle appearance and synchronization in the visible app before/after seeking, using Tubular's actual launch path.
4. Sources requiring media redirects or cross-origin HLS resources will fail explicitly under the current conservative policy. Expired sources must be freshly resolved by Tubular; Terebi does not refresh automatically.
Other providers' proxies/HLS rewrites, iframe providers, downloads, thumbnails, intro/outro controls, embedded captions and additional external-subtitle formats remain out of scope. Metadata implementation verification (2026-10-22): protocol/security/bounds checks, lifecycle replacement/A-B-A stale results/duration changes, stream resolver and CLI checks passed. The live placeholder resolved the descriptive title “The Apothecary Diaries Season 3 — Episode 1: Locusts,” opening 63.313–153.237 seconds and ending 1335.252–1425.384 seconds; native video/audio advanced, measured-duration bands appeared in model state, and default VTT plus forward/backward acknowledged cue seeks passed. FFmpeg emitted redacted diagnostics during this successful run; no visual synchronization or visible-band sign-off is claimed.
Controls alignment correction: removed the obsolete label-height reservation after switching to gapped segments. A native offscreen SwiftUI/AppKit layout smoke reproduced a 9-point top-row shift when intervals appeared; after restoring the standard 14-point slider frame, volume and timeline geometry match exactly with and without intervals. Production build passed; visible confirmation remains a human check.
Segment-skip verification (2026-10-22): local HLS/VTT playback exercised late metadata, the controlled-clock three-second boundary while paused, persistent dismissal, pending-seek suppression, opening/ending seeks, exact media-end seeking, close/reopen reset and playing/paused intent. Exact-end HLS seeks initially failed with `Operation not permitted`; the shared native seek path now finishes network inputs at EOF instead of asking the demuxer to seek beyond its final packet. Network playback regression checks, metadata checks, production build and TLS loopback Cast segment skipping/restoration passed. Rapid play-then-seek probes encountered Core Audio startup errors on the active Bluetooth output; the settled-playback smoke waits for actual advancing playback and passed. Visible button placement, rapid audio transitions and physical-TV behavior remain unverified.
## Remaining limits
- Confirm title readability and gapped slider segments in normal/compact controls, buffer/playhead visibility and subtitle alignment in the visible app using Tubular's real launch path. Offscreen native rasterization at compact/normal widths confirmed all four interval-boundary gaps remain clear even with full buffer coverage.
- Tubular collection launches, retained-report recovery and watched decisions remain its acceptance responsibility; this implementation changes no collection or progress schema.
- Media redirects remain unsupported; external direct source documents also retain their same-origin restriction. Native URL launches resolve fresh; externally supplied source documents must be refreshed by their producer. Automatic expired-source refresh is not implemented.
- Embed extraction is currently MegaPlay-specific. Downloads, thumbnails, automatic skipping, embedded captions and additional external-subtitle formats remain out of scope.
The implementation and this handoff are local; no remote publication or Tubular issue/comment update was performed. The implementation and this handoff are local; no remote publication or Tubular issue/comment update was performed.