Compare commits
162 Commits
8eba97579f
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0641859d95 | |||
| a2c8cc8e4d | |||
| 491252e35f | |||
| 98dccd7593 | |||
| 82d47741eb | |||
| 41aa0abd56 | |||
| d8fb648c90 | |||
| 599e2f548a | |||
| a604810319 | |||
| 7052fe29af | |||
| b95f7d251e | |||
| e6a2759497 | |||
| 6463d47ed8 | |||
| ab61d83c69 | |||
| 8c2a18df48 | |||
| b04ed7fdc8 | |||
| edb17f76e3 | |||
| 119fe2e737 | |||
| 2391dadc75 | |||
| f6fe617d92 | |||
| 30e5787ecc | |||
| 8df500da83 | |||
| 80d9e705e5 | |||
| 4576080955 | |||
| 98e6dcb3c5 | |||
| 6ed7db8167 | |||
| f1339050d9 | |||
| 3ff498720b | |||
| b132bab393 | |||
| 6a4afc9f0e | |||
| ac8e284d80 | |||
| 95600e728d | |||
| 4fe53c0a07 | |||
| 0588d162d6 | |||
| 53369424e6 | |||
| 70672321a1 | |||
| e7a33ff68e | |||
| 458b2c9544 | |||
| 3d949154a2 | |||
| 7b53b00b15 | |||
| fe34ce88f9 | |||
| 40e2ea45a0 | |||
| 19a1de625b | |||
| dea8880b9b | |||
| 80c5f36479 | |||
| 4bf738d8d2 | |||
| a1c8314391 | |||
| b9c7d7f73a | |||
| 4ac780de2a | |||
| 419ac8d677 | |||
| f1893a5a5f | |||
| 9a850e1722 | |||
| 7036109152 | |||
| 885dea74f2 | |||
| 9f1dca3081 | |||
| d068c52e04 | |||
| f6485bf221 | |||
| 6d653e8875 | |||
| 997eb144d2 | |||
| 9e4cb05439 | |||
| 2d3cc6e6b4 | |||
| 67b38394d3 | |||
| 47395491b7 | |||
| 4748e3ea2f | |||
| a6fa54fc42 | |||
| 4b9436c04a | |||
| ef3aae842d | |||
| 2e5255e194 | |||
| 22f13dd08d | |||
| e4504ea5c4 | |||
| 82f88c8cab | |||
| c551203af2 | |||
| 0084609a00 | |||
| b8275635f0 | |||
| ddf53ea606 | |||
| 2e61bd7864 | |||
| 0e860b2ef6 | |||
| 015da6a33e | |||
| 2e690b1a3a | |||
| 44d800f387 | |||
| 4ca6e80cb0 | |||
| 09d659d2cc | |||
| 547fdf6c1f | |||
| 4dcc5414e9 | |||
| a17b2fc7d7 | |||
| 804d946970 | |||
| c2261ad2f0 | |||
| 29a70678a4 | |||
| 9670645b9a | |||
| 6b792b87af | |||
| 126adc925f | |||
| ffef863277 | |||
| 6f58cd91e9 | |||
| b499b3f871 | |||
| b43152da4c | |||
| 7a7d6b9674 | |||
| 640e23b004 | |||
| 8cd5db5375 | |||
| dd02383c0b | |||
| 3e782aff31 | |||
| 07243f0ed7 | |||
| afb0c30e09 | |||
| 2690a22d39 | |||
| fc857870de | |||
| 1d33f5d572 | |||
| ce903e2367 | |||
| d4704af442 | |||
| 2b96a165d2 | |||
| f49eb6d084 | |||
| 3368228537 | |||
| 117bf77902 | |||
| d2aa949f54 | |||
| 0aa0ed781f | |||
| e1284936f9 | |||
| fcad96de62 | |||
| 599c1492ee | |||
| caa10c063f | |||
| acb69fbe8a | |||
| 880b596765 | |||
| 367afdfb07 | |||
| f1fbcf30b5 | |||
| f4fec676a8 | |||
| 58609e4d99 | |||
| ed9d8c6713 | |||
| b7e176abe7 | |||
| ad4b2da5c8 | |||
| b0636c5af7 | |||
| b9e63cd760 | |||
| 697158aa02 | |||
| bb777b0ecd | |||
| 74b4bfe6c1 | |||
| 4ef8ceee24 | |||
| 51382bc63a | |||
| d1f0d83e4c | |||
| 1aa5454194 | |||
| e66f45d9ef | |||
| 2398370c23 | |||
| 7d24ce609e | |||
| 0509eee28e | |||
| 0501969134 | |||
| fe822fb381 | |||
| 470d8feec2 | |||
| 1f12660936 | |||
| 750c20507c | |||
| daac27be8d | |||
| 35fa89e492 | |||
| 1f5e9541e7 | |||
| 3d897c3a05 | |||
| 30cf85e880 | |||
| b2dc5fa373 | |||
| aacd260fe7 | |||
| e3e6a66216 | |||
| 2a75d55c98 | |||
| 7aa6e5f805 | |||
| 93acc1436a | |||
| 0ed35b4a58 | |||
| e6897332a5 | |||
| 39efa96986 | |||
| 1e75f554f0 | |||
| f495c8b719 | |||
| e54cb2b549 | |||
| 31c4759c1d |
+8
-3
@@ -1,12 +1,17 @@
|
||||
# Terebi playback
|
||||
|
||||
Terebi plays local media and YouTube videos. These terms distinguish a playback request from the state the player has actually reached.
|
||||
Terebi plays local media, YouTube videos and resolved Miruro episodes. These terms distinguish a playback request from the state the player has actually reached.
|
||||
|
||||
## Language
|
||||
|
||||
**Playback transition**:
|
||||
A requested change of media, playback state, position, or quality that may still be awaiting completion. Acceptance of a request does not mean playback has reached the requested state.
|
||||
|
||||
**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.
|
||||
|
||||
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**:
|
||||
The user's latest requested playing or paused state, which can differ from the state playback has acknowledged.
|
||||
|
||||
@@ -14,7 +19,7 @@ The user's latest requested playing or paused state, which can differ from the s
|
||||
The playback position confirmed by the player after any pending seek or restoration has completed. A seek target or slider preview is not an acknowledged position.
|
||||
|
||||
**Restoration**:
|
||||
Resuming a video's saved position, or preserving position and playing or paused state while changing its quality.
|
||||
Resuming a video's saved position, or preserving position and playing or paused state while changing its quality or returning from a Cast receiver.
|
||||
|
||||
**Media identity**:
|
||||
The identity of the video being played, independent of its selected quality or extracted stream address. For a YouTube video, this is its canonical video webpage URL.
|
||||
The identity of the video being played, independent of its selected quality or extracted stream address. For a YouTube video, this is its canonical video webpage URL. For a Miruro episode, this is its opaque catalog media ID and episode number, independent of provider and expiring source URL.
|
||||
|
||||
+359
-30
@@ -5,21 +5,153 @@ from pathlib import Path
|
||||
import plistlib
|
||||
import selectors
|
||||
import shutil
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from streaming import StreamingServer
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from streaming import StreamingServer, resolved_fixtures
|
||||
|
||||
|
||||
class StartupStreamingServer(StreamingServer):
|
||||
def __init__(self, fixtures):
|
||||
super().__init__(fixtures)
|
||||
self.subtitle_requests = [0, 0]
|
||||
|
||||
def _get(self, request):
|
||||
parsed = urlsplit(request.path)
|
||||
if parsed.path in ("/startup-0.vtt", "/startup-1.vtt"):
|
||||
index = int(parsed.path[len("/startup-")])
|
||||
if parsed.query != self.resolved_token or request.headers.get("X-Subtitle") != f"track-{index}":
|
||||
self._reply(request, 403, b"Missing subtitle credentials")
|
||||
return
|
||||
with self.lock:
|
||||
self.subtitle_requests[index] += 1
|
||||
body = f"WEBVTT\n\n00:00:00.000 --> 00:01:00.000\nIndependent subtitle {index}\n".encode()
|
||||
self._reply(request, 200, body, "text/vtt")
|
||||
return
|
||||
super()._get(request)
|
||||
|
||||
|
||||
def check_overlay_hover(root, app, fixtures):
|
||||
result_path = root / "overlay-hover.json"
|
||||
diagnostics = root / "overlay-hover.log"
|
||||
result = subprocess.run(
|
||||
["open", "-n", "-W", "--stderr", str(diagnostics), "-a", str(app),
|
||||
str(fixtures / "cache.mp4"), "--args", "--no-config", "--check-overlay-hover", str(result_path)],
|
||||
capture_output=True, text=True, timeout=25,
|
||||
)
|
||||
report = json.loads(result_path.read_text()) if result_path.exists() else {}
|
||||
if result.returncode != 0 or not report.get("passed") or report.get("steps") != 7:
|
||||
log = diagnostics.read_text(errors="replace") if diagnostics.exists() else ""
|
||||
raise RuntimeError(f"native overlay hover failed: {report}; {result.stdout}{result.stderr}{log}")
|
||||
print("PASS native overlay hover: video, title, playback, top-right and queue controls stay visible; "
|
||||
"window exit hides and reentry reveals controls")
|
||||
|
||||
|
||||
def check_window_drag(root, app, fixtures):
|
||||
result_path = root / "window-drag.json"
|
||||
diagnostics = root / "window-drag.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), 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 window drag failed: {report}; {result.stdout}{result.stderr}{log}")
|
||||
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):
|
||||
result_path = root / "queue-drag.json"
|
||||
diagnostics = root / "queue-drag.log"
|
||||
result = subprocess.run(
|
||||
["open", "-n", "-W", "--stderr", str(diagnostics), "-a", str(app),
|
||||
str(fixtures / "cache.mp4"), "--args", "--no-config", "--check-queue-drag", str(result_path)],
|
||||
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 drag failed: {report}; {result.stdout}{result.stderr}{log}")
|
||||
print("PASS native queue: drag down/up without changing playback, release to nearest slot, single click plays without reordering")
|
||||
|
||||
|
||||
def check_window_layout(root, app, fixtures):
|
||||
result_path = root / "window-layout.json"
|
||||
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(
|
||||
["open", "-g", "-n", "-W", "--stderr", str(diagnostics), "-a", str(app),
|
||||
str(fixtures / "cache.mp4"), "--args", "--no-config", "--check-window-layout", str(result_path), *extra],
|
||||
capture_output=True, text=True, timeout=40,
|
||||
)
|
||||
log = diagnostics.read_text(errors="replace") if diagnostics.exists() else ""
|
||||
report = json.loads(result_path.read_text()) if result_path.exists() else {}
|
||||
expected_animations = 0 if report.get("reduceMotion") else (4 if compact_queue_only else 19)
|
||||
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") != 45
|
||||
or report.get("animatedTransitions") != expected_animations
|
||||
or report.get("animatedQueueTransitions") != expected_queue_animations):
|
||||
raise RuntimeError(f"native window layout failed: {report}; {result.stdout}{result.stderr}{log}")
|
||||
print(f"PASS native SwiftUI window layout: {report['animatedTransitions']} compact and "
|
||||
f"{report['animatedQueueTransitions']} queue animations, stable video size, responsive reversals, "
|
||||
"downward compact queue with native shortcut and painted content, minimum sizes and exact normal geometry restored")
|
||||
|
||||
def check_startup_errors(root, app):
|
||||
config = root / "invalid-startup.toml"
|
||||
config.write_text("[ui]\nbuffer_info = [\n")
|
||||
for name, arguments, title, detail in [
|
||||
("configuration", ["--config-file", config.as_uri()],
|
||||
"Could not load Terebi configuration", str(config)),
|
||||
("progress", ["--no-config", "--progress-file", str(root / "missing-parent/progress.jsonl")],
|
||||
"Could not initialize playback progress reporting", "progress"),
|
||||
]:
|
||||
report_path = root / f"startup-error-{name}.json"
|
||||
diagnostics = root / f"startup-error-{name}.log"
|
||||
result = subprocess.run(
|
||||
["open", "-n", "-W", "--stderr", str(diagnostics), "-a", str(app), "--args",
|
||||
"--check-startup-error", str(report_path), *arguments],
|
||||
capture_output=True, text=True, timeout=25,
|
||||
)
|
||||
report = json.loads(report_path.read_text()) if report_path.exists() else {}
|
||||
log = diagnostics.read_text(errors="replace") if diagnostics.exists() else ""
|
||||
if (result.returncode != 0 or not report.get("passed") or title not in report.get("text", [])
|
||||
or detail not in "\n".join(report.get("text", [])) or detail not in log):
|
||||
raise RuntimeError(f"native {name} startup alert failed: {report}; "
|
||||
f"open status {result.returncode}; {result.stdout}{result.stderr}{log}")
|
||||
print("PASS native startup errors: configuration and progress failures show focused critical alerts with diagnostics")
|
||||
|
||||
|
||||
|
||||
def check_cli_instances(root, app, fixtures):
|
||||
cli = root / "terebi"
|
||||
shutil.copy2(app_source.parent / "terebi", cli)
|
||||
extractor = shutil.which("yt-dlp")
|
||||
runtime = shutil.which("deno") or shutil.which("node")
|
||||
cli.symlink_to(app / "Contents/MacOS/terebi-cli")
|
||||
extractor = app / "Contents/MacOS/yt-dlp-offline"
|
||||
(app / "Contents/MacOS/yt-dlp").rename(extractor)
|
||||
runtime = app / "Contents/MacOS/deno"
|
||||
canonical = "https://www.youtube.com/watch?v=aqz-KE-bpKQ"
|
||||
reported_urls = [f"https://www.youtube.com/watch?v=progress00{index}" for index in range(1, 5)]
|
||||
|
||||
@@ -87,13 +219,15 @@ def check_cli_instances(root, app, fixtures):
|
||||
except FileNotFoundError:
|
||||
return []
|
||||
records = [json.loads(line) for line in lines if line]
|
||||
fields = {"version", "event", "media_url", "position_seconds", "duration_seconds", "completion_eligible"}
|
||||
miruro = isinstance(allowed, list)
|
||||
identity_field = "media_identity" if miruro else "media_url"
|
||||
fields = {"version", "event", identity_field, "position_seconds", "duration_seconds", "completion_eligible"}
|
||||
for record in records:
|
||||
duration = record.get("duration_seconds")
|
||||
position = record.get("position_seconds")
|
||||
valid = (set(record) == fields and record["version"] == 1
|
||||
valid = (set(record) == fields and record["version"] == (2 if miruro else 1)
|
||||
and record["event"] in ("progress", "ended", "closed")
|
||||
and record["media_url"] in allowed
|
||||
and record[identity_field] in allowed
|
||||
and type(position) in (int, float) and math.isfinite(position) and position >= 0
|
||||
and (duration is None or (type(duration) in (int, float)
|
||||
and math.isfinite(duration) and duration > 0))
|
||||
@@ -125,20 +259,135 @@ def check_cli_instances(root, app, fixtures):
|
||||
return (record["completion_eligible"] and record["duration_seconds"] is not None
|
||||
and record["position_seconds"] / record["duration_seconds"] >= 0.95)
|
||||
|
||||
def quit_instance(pid, count):
|
||||
write_request(pid, action="quit")
|
||||
def quit_instance(pid, count, events=None):
|
||||
if events is None:
|
||||
write_request(pid, action="quit")
|
||||
else:
|
||||
write_request(pid, events=events)
|
||||
deadline = time.monotonic() + 30
|
||||
while time.monotonic() < deadline:
|
||||
survivors = [json.loads(path.read_text()) for path in root.glob("state-*.json")
|
||||
if path.stem != f"state-{pid}"]
|
||||
if survivors and all(pid not in state["pids"] for state in survivors):
|
||||
exited = False
|
||||
if count == 1:
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except ProcessLookupError:
|
||||
exited = True
|
||||
if exited or (survivors and all(pid not in state["pids"] for state in survivors)):
|
||||
(root / f"state-{pid}.json").unlink()
|
||||
(root / f"keys-{pid}.json").unlink()
|
||||
return wait_states(count - 1, lambda _: True, "normal quit unregisters only the targeted GUI")
|
||||
time.sleep(0.02)
|
||||
raise RuntimeError(f"normal quit did not unregister GUI PID {pid}")
|
||||
|
||||
with StreamingServer(fixtures) as server:
|
||||
def check_resolved_instances(server, count):
|
||||
originals = wait_states(count, lambda _: True, "existing players are ready before resolved launches")
|
||||
states = originals
|
||||
resolved_pids = []
|
||||
resolved_reports = []
|
||||
identities = []
|
||||
for index in range(2):
|
||||
source = root / f"resolved source {index}.json"
|
||||
report = root / f"resolved progress {index}.jsonl"
|
||||
identity = {"provider": "miruro", "media_id": "startup_opaque", "episode": index + 1}
|
||||
source.write_text(json.dumps({
|
||||
"version": 1,
|
||||
"url": server.base_url + "/resolved/encrypted/master.m3u8?" + server.resolved_token,
|
||||
"headers": {"X-Terebi-Fixture": "combined", "Authorization": "Bearer " + server.resolved_secret},
|
||||
"identity": identity, "title": f"Independent resolved episode {index + 1}",
|
||||
"subtitles": [{"url": server.base_url + f"/startup-{index}.vtt?" + server.resolved_token,
|
||||
"format": "vtt", "language": "en", "label": f"Track {index}",
|
||||
"default": True, "headers": {"X-Subtitle": f"track-{index}"}}]
|
||||
}))
|
||||
source.chmod(0o600)
|
||||
previous = set(states)
|
||||
invoke(["--new-instance", "--source-file", str(source), "--progress-file", str(report)])
|
||||
states = wait_states(count + index + 1, lambda snapshots: any(
|
||||
pid not in previous and state["media"] == f"Independent resolved episode {index + 1}"
|
||||
and state["status"] == 3 and state["hasVideo"] and state["hasAudio"]
|
||||
and state["canSeek"] and not state["busy"] and not state["seekPending"]
|
||||
and state["subtitleLabels"] == [f"Track {index}"] and state["selectedSubtitle"] == 0
|
||||
and state["subtitleText"] == f"Independent subtitle {index}" and not state["subtitleError"]
|
||||
for pid, state in snapshots.items()), "actual source-file CLI reaches registered GUI HLS and VTT playback")
|
||||
pid = next(pid for pid in states if pid not in previous)
|
||||
states = input_request(pid, count + index + 1, lambda state: state["status"] == 2,
|
||||
"resolved GUI pauses independently after decoded startup", action="pause")
|
||||
states = input_request(pid, count + index + 1,
|
||||
lambda state: state["selectedSubtitle"] == -1 and not state["subtitleText"],
|
||||
"visible native Off item disables rendered captions", action="caption", label="Off")
|
||||
states = input_request(pid, count + index + 1,
|
||||
lambda state: state["selectedSubtitle"] == 0
|
||||
and state["subtitleText"] == f"Independent subtitle {index}",
|
||||
"visible native track item restores rendered captions",
|
||||
action="caption", label=f"Track {index} (en)")
|
||||
wait_report(report, [identity], lambda rows: any(
|
||||
row["event"] == "progress" and row["completion_eligible"] for row in rows),
|
||||
"resolved GUI owns v2 stable-identity reporting after CLI exit")
|
||||
resolved_pids.append(pid)
|
||||
resolved_reports.append(report)
|
||||
identities.append(identity)
|
||||
for pid, original in originals.items():
|
||||
if states[pid]["media"] != original["media"] or settings(states[pid]) != settings(original):
|
||||
raise RuntimeError("resolved launches disturbed an existing player")
|
||||
with server.lock:
|
||||
stats = server.resolved_stats.get("encrypted", {})
|
||||
if any(server.subtitle_requests[index] == 0 for index in range(2)) or any(
|
||||
stats.get(kind, {}).get("requests", 0) == 0 or stats[kind]["denied"]
|
||||
for kind in ("master", "variant", "key", "segment")):
|
||||
raise RuntimeError("actual CLI playback lost complete HLS URLs, credentials, or independent VTT headers")
|
||||
for index in reversed(range(2)):
|
||||
pid = resolved_pids[index]
|
||||
quit_instance(pid, count + index + 1)
|
||||
wait_report(resolved_reports[index], [identities[index]],
|
||||
lambda rows: bool(rows) and rows[-1]["event"] == "closed",
|
||||
"resolved GUI quit drains only its stable episode identity")
|
||||
report_bytes = resolved_reports[index].read_bytes()
|
||||
if not report_bytes.endswith(b"\n") or any(secret in report_bytes for secret in (
|
||||
server.resolved_token.encode(), server.resolved_secret.encode(), server.base_url.encode())):
|
||||
raise RuntimeError("resolved report is incomplete or exposes transport secrets")
|
||||
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")
|
||||
|
||||
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)
|
||||
with StartupStreamingServer(fixtures) as server:
|
||||
if extractor and runtime:
|
||||
choices = []
|
||||
for index, url in enumerate([canonical, *reported_urls[:3]]):
|
||||
@@ -152,30 +401,79 @@ def check_cli_instances(root, app, fixtures):
|
||||
"url": server.base_url + "/media/cache.mp4", "protocol": "http", "ext": "mp4",
|
||||
"vcodec": "mpeg4", "acodec": "aac", "dynamic_range": "SDR",
|
||||
"http_headers": {"X-Terebi-Fixture": "combined"}} for height in (180, 360)]}))
|
||||
choices.append(f" {shlex.quote(url)}) info={shlex.quote(str(metadata))} ;;")
|
||||
wrapper = root / "yt-dlp"
|
||||
wrapper.write_text("#!/bin/bash\nset -eu\nargs=(\"$@\")\n"
|
||||
'case "${args[${#args[@]}-1]}" in\n' + "\n".join(choices)
|
||||
+ '\n *) echo "Fixture extraction deliberately failed" >&2; exit 1 ;;\nesac\nexec '
|
||||
+ shlex.quote(extractor) + ' --load-info-json "$info"'
|
||||
+ ' "${args[@]:0:${#args[@]}-2}"\n')
|
||||
wrapper.chmod(0o755)
|
||||
choices.append(f" if (!strcmp(url, {json.dumps(url)})) info = {json.dumps(str(metadata))};")
|
||||
wrapper = app / "Contents/MacOS/yt-dlp"
|
||||
source = root / "offline-extractor.c"
|
||||
source.write_text(
|
||||
'#include <stdio.h>\n#include <string.h>\n#include <unistd.h>\n'
|
||||
'int main(int argc, char **argv) {\n'
|
||||
' if (argc < 3) return 1;\n'
|
||||
' const char *url = argv[argc - 1];\n char *info = NULL;\n'
|
||||
+ "\n".join(choices)
|
||||
+ '\n if (!info) { fputs("Fixture extraction deliberately failed\\n", stderr); return 1; }\n'
|
||||
+ f' char *args[argc + 2]; args[0] = {json.dumps(str(extractor))};\n'
|
||||
+ ' args[1] = "--load-info-json"; args[2] = info;\n'
|
||||
+ ' for (int i = 1; i < argc - 2; ++i) args[i + 2] = argv[i];\n'
|
||||
+ ' args[argc] = NULL;\n execv(args[0], args); perror("execv"); return 1;\n}\n')
|
||||
subprocess.run(["xcrun", "clang", "-O2", str(source), "-o", str(wrapper)], check=True)
|
||||
subprocess.run(["codesign", "--force", "--sign", "-", str(wrapper)], check=True)
|
||||
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))
|
||||
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":
|
||||
check_resolved_instances(server, 0)
|
||||
return
|
||||
if extractor and runtime:
|
||||
check_stream_queue_instance()
|
||||
movie = fixtures / "cache.mp4"
|
||||
audio = root / "audio with spaces.wav"
|
||||
shutil.copy2(fixtures / "audio.wav", audio)
|
||||
|
||||
invoke([str(movie)])
|
||||
first = wait_states(1, lambda states: all(state["media"] == movie.name and state["status"] == 3
|
||||
and state["position"] > 0 and state["hasVideo"] for state in states.values()),
|
||||
"cold default CLI opens one actual player window")
|
||||
and state["position"] > 0 and state["hasVideo"] and state["active"]
|
||||
and state["keyWindow"] and state["mainWindow"] for state in states.values()),
|
||||
"cold default CLI opens and focuses one actual player window before synthetic input")
|
||||
first_pid = next(iter(first))
|
||||
renderer = first[first_pid]["window"]
|
||||
if (not renderer.get("rendererAttached") or renderer.get("rendererHidden")
|
||||
or abs(renderer["rendererWidth"] - renderer["videoWidth"]) > 1
|
||||
or abs(renderer["rendererHeight"] - renderer["videoHeight"]) > 1):
|
||||
raise RuntimeError(f"playing renderer must fill its visible native surface: {renderer}")
|
||||
opened = input_request(first_pid, 1, lambda state: state["showsQueue"]
|
||||
and state["window"].get("videoOwnsMouse") is True
|
||||
and state["window"].get("rendererAttached") is True
|
||||
and state["window"].get("rendererHidden") is False
|
||||
and abs(state["window"]["videoWidth"] - first[first_pid]["window"]["videoWidth"]) < 1,
|
||||
"queue preserves video size and mouse target", action="queue", visible=True)
|
||||
window = opened[first_pid]["window"]
|
||||
if (not window["resizable"] or window["aspectWidth"] != 0 or window["aspectHeight"] != 0
|
||||
or window["resizeIncrementWidth"] <= 0 or window["resizeIncrementHeight"] <= 0
|
||||
or window["minWidth"] > 660 or window["minHeight"] > 300
|
||||
or window["maxWidth"] <= window["width"] + 80
|
||||
or window["maxHeight"] <= window["height"] + 80
|
||||
or not window["controlsOwnMouse"] or not window["sidebarOwnsMouse"]
|
||||
or not window["edgesAvoidDragSurface"] or window["surfaceMovesOnMouseDown"]):
|
||||
raise RuntimeError(f"queue must preserve native resize limits and independent mouse targets: {window}")
|
||||
input_request(first_pid, 1, lambda state: not state["showsQueue"]
|
||||
and abs(state["window"]["videoWidth"] - first[first_pid]["window"]["videoWidth"]) < 1,
|
||||
"queue closes without changing video size", action="queue", visible=False)
|
||||
print(f"PASS native queue window: independent video/controls/sidebar/edge targets, "
|
||||
f"resizable min {window['minWidth']}x{window['minHeight']}, unconstrained maxima and cleared aspect")
|
||||
if os.environ.get("TEREBI_CHECK_WINDOW_ONLY") == "1":
|
||||
return
|
||||
if settings(first[first_pid]) != (False, False, True) or config.parent.exists():
|
||||
raise RuntimeError(f"missing config must use defaults without creating a file: {first}")
|
||||
invoke([])
|
||||
wait_states(1, lambda states: states[first_pid]["media"] == movie.name,
|
||||
"warm default CLI reuses the existing process without replacing media")
|
||||
wait_states(1, lambda states: states[first_pid]["media"] == movie.name
|
||||
and states[first_pid]["active"] and states[first_pid]["keyWindow"]
|
||||
and states[first_pid]["mainWindow"],
|
||||
"warm default CLI focuses the existing process without replacing media")
|
||||
input_request(first_pid, 1, lambda state: state["status"] == 2, "default Space pauses once",
|
||||
events=[{"key": " ", "code": 49}])
|
||||
input_request(first_pid, 1, lambda state: state["status"] == 2, "held Space cannot oscillate",
|
||||
@@ -271,6 +569,7 @@ def check_cli_instances(root, app, fixtures):
|
||||
else:
|
||||
print("SKIP independent YouTube playback: yt-dlp and a supported JS runtime are required")
|
||||
|
||||
check_resolved_instances(server, count)
|
||||
existing_report = root / "existing progress.jsonl"
|
||||
sentinel = b"existing destination must remain byte-for-byte unchanged\n"
|
||||
existing_report.write_bytes(sentinel)
|
||||
@@ -421,7 +720,12 @@ def check_cli_instances(root, app, fixtures):
|
||||
wait_report(second_path, allowed_urls[1], lambda records: any(
|
||||
progress_at(record, reported_urls[1], second_position) for record in records),
|
||||
"second progress stream never receives first player's media identity")
|
||||
quit_instance(second_reporter, total)
|
||||
input_request(second_reporter, total, lambda state: state["editorText"] == "q",
|
||||
"plain q remains text input rather than Quit",
|
||||
events=[{"key": "q", "code": 12, "editing": True}])
|
||||
quit_instance(second_reporter, total, events=[
|
||||
{"key": "q", "code": 12, "flags": 1 << 20, "editing": True, "queued": True},
|
||||
])
|
||||
final_second = wait_report(second_path, allowed_urls[1], lambda records: (
|
||||
records[-1]["event"] == "closed"
|
||||
and abs(records[-1]["position_seconds"] - second_position) < 0.5) if records else False,
|
||||
@@ -446,8 +750,10 @@ def check_cli_instances(root, app, fixtures):
|
||||
shutil.copy2(fixtures / "av.mp4", replacement)
|
||||
invoke([str(replacement)])
|
||||
after = wait_states(count, lambda states: sum(state["media"] == replacement.name
|
||||
for state in states.values()) == 1,
|
||||
"default media delivery still reuses exactly one existing process")
|
||||
for state in states.values()) == 1 and all(
|
||||
state["active"] and state["keyWindow"] and state["mainWindow"]
|
||||
for state in states.values() if state["media"] == replacement.name),
|
||||
"default document delivery focuses and reuses exactly one existing process")
|
||||
changed = [pid for pid in before if before[pid]["media"] != after[pid]["media"]]
|
||||
if len(changed) != 1:
|
||||
raise RuntimeError(f"default media open changed multiple players: {before}; {after}")
|
||||
@@ -457,22 +763,35 @@ def check_cli_instances(root, app, fixtures):
|
||||
|
||||
|
||||
|
||||
app_source, library, fixtures = map(lambda path: Path(path).resolve(), sys.argv[1:])
|
||||
app_source, library, fixtures, dependencies_root = map(lambda path: Path(path).resolve(), sys.argv[1:])
|
||||
with tempfile.TemporaryDirectory(prefix="terebi-startup-") as directory:
|
||||
root = Path(directory)
|
||||
app = root / "terebi.app"
|
||||
app = root / "Terebi Startup Check.app"
|
||||
shutil.copytree(app_source, app)
|
||||
identifier = "local.personal.terebi.startup-check." + uuid.uuid4().hex
|
||||
info_path = app / "Contents/Info.plist"
|
||||
info = plistlib.loads(info_path.read_bytes())
|
||||
info["CFBundleIdentifier"] = identifier
|
||||
info["CFBundleName"] = "Terebi Startup Check"
|
||||
info["CFBundleDisplayName"] = "Terebi Startup Check"
|
||||
info_path.write_bytes(plistlib.dumps(info))
|
||||
shutil.copy2(library, app / "Contents/Frameworks/libterebi-player.dylib")
|
||||
subprocess.run([sys.executable, str(Path(__file__).resolve().parent.parent / "Scripts/bundle.py"),
|
||||
"--app", str(app), "--dependencies", str(dependencies_root)], check=True)
|
||||
pipe = root / "result.fifo"
|
||||
os.mkfifo(pipe)
|
||||
descriptor = os.open(pipe, os.O_RDONLY | os.O_NONBLOCK)
|
||||
launch = None
|
||||
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:
|
||||
selector.register(descriptor, selectors.EVENT_READ)
|
||||
launch = subprocess.Popen(
|
||||
@@ -486,7 +805,17 @@ with tempfile.TemporaryDirectory(prefix="terebi-startup-") as directory:
|
||||
if launch.returncode != 0 or not result["passed"] or result["bundleIdentifier"] != identifier:
|
||||
raise RuntimeError(f"packaged startup failed: {result}; {stdout}{stderr}")
|
||||
print("PASS packaged bootstrap: registered app PID/identity, in-process player load, actual playback and Now Playing")
|
||||
check_cli_instances(root, app, fixtures)
|
||||
check_startup_errors(root, app)
|
||||
check_overlay_hover(root, app, fixtures)
|
||||
if os.environ.get("TEREBI_CHECK_HOVER_ONLY") == "1":
|
||||
sys.exit(0)
|
||||
check_window_drag(root, app, fixtures)
|
||||
if os.environ.get("TEREBI_CHECK_DRAG_ONLY") != "1":
|
||||
check_queue_drag(root, app, fixtures)
|
||||
if os.environ.get("TEREBI_CHECK_QUEUE_ONLY") != "1":
|
||||
check_window_layout(root, app, fixtures)
|
||||
if os.environ.get("TEREBI_CHECK_LAYOUT_ONLY") != "1":
|
||||
check_cli_instances(root, app, fixtures)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
subprocess.run(
|
||||
|
||||
+1032
-9
File diff suppressed because it is too large
Load Diff
@@ -132,7 +132,7 @@ int main(int argc, char **argv) {
|
||||
require(argc == 2, "fixture directory required");
|
||||
require(!player_set_volume(nullptr, .5f) && player_get_volume(nullptr) == 0, "null volume handle");
|
||||
std::string directory = argv[1];
|
||||
for (const char *name : {"tone441.wav", "tone480.wav", "tone441.mka", "tone-aac.m4a"}) {
|
||||
for (const char *name : {"tone441.wav", "tone480.wav", "tone441.mka", "tone-aac.m4a", "tone-jitter.ts"}) {
|
||||
std::string path = directory + "/" + name;
|
||||
std::ifstream input(path + ".f32", std::ios::binary | std::ios::ate);
|
||||
require(bool(input), "PCM reference exists");
|
||||
|
||||
@@ -33,19 +33,81 @@ struct AutomaticQualityPlaybackChecks {
|
||||
let defaults = UserDefaults(suiteName: suite)!
|
||||
defer { defaults.removePersistentDomain(forName: suite) }
|
||||
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() }
|
||||
model.setVolume(0)
|
||||
// Control only the external extractor; media decoding and all model transitions stay real.
|
||||
guard let controlPath = ProcessInfo.processInfo.environment["TEREBI_QUALITY_CONTROL"] else {
|
||||
require(false, "isolated extraction controls are available")
|
||||
return
|
||||
}
|
||||
let control = URL(fileURLWithPath: controlPath, isDirectory: true)
|
||||
func mark(_ name: String) {
|
||||
require(FileManager.default.createFile(atPath: control.appendingPathComponent(name).path,
|
||||
contents: Data()), "publish extraction control " + name)
|
||||
}
|
||||
func marked(_ name: String) -> Bool {
|
||||
FileManager.default.fileExists(atPath: control.appendingPathComponent(name).path)
|
||||
}
|
||||
require(!model.automaticYouTubeQuality, "automatic quality is opt-in")
|
||||
model.openYouTube("https://www.youtube.com/watch?v=aqz-KE-bpKQ")
|
||||
let window = NSWindow(contentRect: NSRect(x: -10_000, y: -10_000, width: 640, height: 360),
|
||||
styleMask: [.titled, .closable, .miniaturizable, .resizable],
|
||||
backing: .buffered, defer: false)
|
||||
window.isReleasedWhenClosed = false
|
||||
defer { window.close() }
|
||||
model.attachWindow(window)
|
||||
let normalFrame = window.frame
|
||||
let compactKey = try! KeyChord("p")
|
||||
mark("gate-open")
|
||||
model.openStream("https://www.youtube.com/watch?v=aqz-KE-bpKQ")
|
||||
until(model, "initial YouTube resolution reaches the controlled extraction gate") {
|
||||
model.resolvingStream && marked("entered-open")
|
||||
}
|
||||
model.handleKey(compactKey)
|
||||
require(model.isCompact, "compact shortcut enters while the YouTube URL is unresolved")
|
||||
until(model, "compact mode remains usable across empty playback polls while resolving") {
|
||||
model.isCompact && !model.compactWindow.isTransitioning
|
||||
}
|
||||
require(model.compactWindow.presentationWindow!.level == .floating &&
|
||||
model.compactWindow.presentationWindow!.frame.width < normalFrame.width,
|
||||
"unresolved video has a usable compact window")
|
||||
model.handleKey(compactKey)
|
||||
until(model, "compact shortcut restores while YouTube resolution is still pending") {
|
||||
!model.isCompact && !model.compactWindow.isTransitioning
|
||||
}
|
||||
require(window.frame == normalFrame && model.resolvingStream,
|
||||
"resolution continues while restoring the original normal window")
|
||||
require(model.perform(.enterCompact), "compact control action is available during resolution")
|
||||
mark("release-open")
|
||||
try! FileManager.default.removeItem(at: control.appendingPathComponent("gate-open"))
|
||||
until(model, "default selects real 1080p and runs out of the gated network prefix") {
|
||||
model.youtubeHeight == 1080 && model.canTogglePlayback &&
|
||||
model.state.status == PLAYER_PLAYING && model.state.position > 0.5 && model.state.buffering
|
||||
}
|
||||
require(model.isCompact, "loading completion preserves the requested compact mode")
|
||||
until(model, "decoded metadata updates compact geometry") {
|
||||
!model.compactWindow.isTransitioning &&
|
||||
abs(model.compactWindow.presentationWindow!.frame.width / model.compactWindow.presentationWindow!.frame.height -
|
||||
model.state.video_aspect_ratio) < 0.01
|
||||
}
|
||||
model.handleKey(compactKey)
|
||||
until(model, "compact shortcut restores during network buffering") {
|
||||
!model.isCompact && !model.compactWindow.isTransitioning
|
||||
}
|
||||
model.handleKey(compactKey)
|
||||
until(model, "compact shortcut re-enters during network buffering") {
|
||||
model.isCompact && !model.compactWindow.isTransitioning
|
||||
}
|
||||
clock.now = 2000
|
||||
polled(model)
|
||||
require(model.youtubeHeight == 1080 && !model.resolvingYouTube,
|
||||
require(model.youtubeHeight == 1080 && !model.resolvingStream,
|
||||
"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)
|
||||
polled(model)
|
||||
let position = model.state.position
|
||||
@@ -54,13 +116,18 @@ struct AutomaticQualityPlaybackChecks {
|
||||
model.youtubeHeight == 720 && model.canTogglePlayback && model.state.status == PLAYER_PLAYING &&
|
||||
!model.state.seek_pending && (model.bufferedRange.map { $0.upperBound - model.state.position >= 20 } ?? false)
|
||||
}
|
||||
require(model.state.position >= position && model.state.position < position + 3,
|
||||
"automatic quality reopening preserves current playback progress")
|
||||
require(model.state.position >= position,
|
||||
"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)
|
||||
clock.now = 2063
|
||||
until(model, "sustained health automatically restores actual 1080p media") {
|
||||
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)
|
||||
require(!model.automaticYouTubeQuality && !model.isBusy && model.state.status == PLAYER_PLAYING,
|
||||
"reselecting the current manual resolution disables auto without disrupting playback")
|
||||
@@ -75,24 +142,12 @@ struct AutomaticQualityPlaybackChecks {
|
||||
qualityClock: { clock.now })
|
||||
require(manual.automaticYouTubeQuality, "manual session override does not replace startup configuration")
|
||||
manual.shutdown()
|
||||
// Control only the external extractor; media decoding and all model transitions stay real.
|
||||
guard let controlPath = ProcessInfo.processInfo.environment["TEREBI_QUALITY_CONTROL"] else {
|
||||
require(false, "isolated extraction controls are available")
|
||||
return
|
||||
}
|
||||
let control = URL(fileURLWithPath: controlPath, isDirectory: true)
|
||||
func mark(_ name: String) {
|
||||
require(FileManager.default.createFile(atPath: control.appendingPathComponent(name).path,
|
||||
contents: Data()), "publish extraction control " + name)
|
||||
}
|
||||
func marked(_ name: String) -> Bool {
|
||||
FileManager.default.fileExists(atPath: control.appendingPathComponent(name).path)
|
||||
}
|
||||
model.setYouTubeQuality(720)
|
||||
until(model, "manual selection opens real healthy 720p media") {
|
||||
model.youtubeHeight == 720 && model.canChangeYouTubeQuality && model.state.status == PLAYER_PLAYING &&
|
||||
!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 selectedQuality = model.youtubeQuality
|
||||
mark("gate-1080")
|
||||
@@ -105,12 +160,12 @@ struct AutomaticQualityPlaybackChecks {
|
||||
}
|
||||
clock.now = 3060
|
||||
until(model, "automatic upshift reaches the selected extraction gate") {
|
||||
marked("entered-1080") && model.resolvingYouTube
|
||||
marked("entered-1080") && model.resolvingStream
|
||||
}
|
||||
require(model.setPlayback(playing: false), "pause is accepted during automatic extraction")
|
||||
mark("release-1080")
|
||||
until(model, "cancelled extraction finishes and native pause is acknowledged") {
|
||||
marked("finished-1080") && !model.resolvingYouTube && model.canTogglePlayback &&
|
||||
marked("finished-1080") && !model.resolvingStream && model.canTogglePlayback &&
|
||||
model.state.status == PLAYER_PAUSED && !model.state.seek_pending
|
||||
}
|
||||
let cancelledPosition = model.state.position
|
||||
@@ -138,7 +193,7 @@ struct AutomaticQualityPlaybackChecks {
|
||||
let originalPosition = model.state.position
|
||||
model.setYouTubeQuality(1080)
|
||||
until(model, "failed manual quality switch restores the original native playback state") {
|
||||
model.errorText != nil && !model.resolvingYouTube && model.canTogglePlayback &&
|
||||
model.errorText != nil && !model.resolvingStream && model.canTogglePlayback &&
|
||||
model.state.status == (playing ? PLAYER_PLAYING : PLAYER_PAUSED) &&
|
||||
(!playing || model.state.position > originalPosition + 0.1)
|
||||
}
|
||||
@@ -149,6 +204,8 @@ struct AutomaticQualityPlaybackChecks {
|
||||
"failed manual quality switch retains original selected media and playback progress")
|
||||
}
|
||||
model.closeFile()
|
||||
print("PASS native adaptive playback: default-off/stalled downshift/healthy upshift/progress preservation/current+default manual override/session-only settings/cancelled extraction pause/failed manual quality playing+paused preservation")
|
||||
require(!model.isCompact && !model.compactWindow.isTransitioning,
|
||||
"closing a streamed video restores its original window mode")
|
||||
print("PASS native adaptive playback: resolving/loading/buffering compact shortcuts+controls/default-off/stalled downshift/healthy upshift/progress preservation/current+default manual override/session-only settings/cancelled extraction pause/failed manual quality playing+paused preservation")
|
||||
}
|
||||
}
|
||||
|
||||
+46
-13
@@ -5,30 +5,58 @@ import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
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():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("binary", type=Path)
|
||||
parser.add_argument("fixtures", type=Path)
|
||||
parser.add_argument("app", type=Path)
|
||||
parser.add_argument("--cast-session", type=Path)
|
||||
args = parser.parse_args()
|
||||
extractor = shutil.which("yt-dlp")
|
||||
if extractor is None:
|
||||
extractor = next((str(path) for path in (Path("/opt/homebrew/bin/yt-dlp"), Path("/usr/local/bin/yt-dlp"))
|
||||
if os.access(path, os.X_OK)), None)
|
||||
if extractor is None:
|
||||
print("SKIP native adaptive playback: yt-dlp is not installed; controlled-clock policy checks still run")
|
||||
return 0
|
||||
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"
|
||||
extractor = str(macOS / "yt-dlp")
|
||||
runtime = macOS / "deno"
|
||||
if not os.access(extractor, os.X_OK) or not os.access(runtime, os.X_OK):
|
||||
parser.error("Missing bundled YouTube helpers; build the app first")
|
||||
required = ("quality-1080.mp4", "quality-720.mp4")
|
||||
if any(not (args.fixtures / name).is_file() for name in required):
|
||||
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 / "deno").symlink_to(runtime)
|
||||
formats = []
|
||||
for height, width in ((720, 1280), (1080, 1920)):
|
||||
path = "/media/quality-720.mp4" if height == 720 else "/stall/quality-low/quality-1080.mp4?after_seconds=2"
|
||||
@@ -38,7 +66,9 @@ def main():
|
||||
"http_headers": {"X-Terebi-Fixture": "combined"}})
|
||||
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",
|
||||
"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.write_text(json.dumps(info))
|
||||
wrapper = root / "yt-dlp"
|
||||
@@ -46,6 +76,10 @@ def main():
|
||||
control.mkdir()
|
||||
wrapper.write_text("#!/bin/bash\nset -eu\nargs=(\"$@\")\n"
|
||||
+ "control=" + shlex.quote(str(control)) + "\n"
|
||||
+ 'if [[ -f "$control/gate-open" ]]; then\n'
|
||||
+ ' : > "$control/entered-open"\n'
|
||||
+ ' while [[ -f "$control/gate-open" && ! -f "$control/release-open" ]]; do sleep 0.01; done\n'
|
||||
+ 'fi\n'
|
||||
+ 'if [[ "$*" == *"[height=1080]"* ]]; then\n'
|
||||
+ ' if [[ -f "$control/fail-1080" ]]; then\n'
|
||||
+ ' echo "Fixture extraction deliberately failed" >&2; exit 1\n'
|
||||
@@ -53,11 +87,10 @@ def main():
|
||||
+ ' if [[ -f "$control/gate-1080" ]]; then\n'
|
||||
+ " " + shlex.quote(extractor) + " --load-info-json " + shlex.quote(str(metadata))
|
||||
+ ' "${args[@]:0:${#args[@]}-2}" > "$control/output-1080.json"\n'
|
||||
+ ' trap \': > "$control/finished-1080"; exit 0\' TERM\n'
|
||||
+ ' trap \': > "$control/finished-1080"\' EXIT\n'
|
||||
+ ' : > "$control/entered-1080"\n'
|
||||
+ ' while [[ ! -f "$control/release-1080" ]]; do sleep 0.01; done\n'
|
||||
+ ' cat "$control/output-1080.json" || true\n'
|
||||
+ ' : > "$control/finished-1080"\n'
|
||||
+ ' exit 0\n'
|
||||
+ ' fi\n'
|
||||
+ 'fi\nexec ' + shlex.quote(extractor)
|
||||
@@ -65,7 +98,7 @@ def main():
|
||||
+ ' "${args[@]:0:${#args[@]}-2}"\n')
|
||||
wrapper.chmod(0o755)
|
||||
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:
|
||||
result = subprocess.run([str(args.binary.resolve())], env=environment, timeout=90, check=False)
|
||||
except subprocess.TimeoutExpired:
|
||||
|
||||
Executable
+96
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Exercise relocated runtime payloads with Homebrew and original build inputs denied."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
|
||||
PROBE = r'''
|
||||
import ctypes
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
app, movie = map(Path, sys.argv[1:])
|
||||
frameworks = app / "Contents/Frameworks"
|
||||
names = (app / "Contents/Resources/ffmpeg-libraries.txt").read_text().splitlines()
|
||||
libraries = {name.split(".")[0]: ctypes.CDLL(str(frameworks / name)) for name in names}
|
||||
format = libraries["libavformat"]
|
||||
codec = libraries["libavcodec"]
|
||||
codec.avcodec_license.restype = ctypes.c_char_p
|
||||
assert codec.avcodec_license() == b"LGPL version 2.1 or later", codec.avcodec_license()
|
||||
codec.avcodec_find_decoder_by_name.argtypes = [ctypes.c_char_p]
|
||||
codec.avcodec_find_decoder_by_name.restype = ctypes.c_void_p
|
||||
assert codec.avcodec_find_decoder_by_name(b"libdav1d"), "software AV1 decoder absent"
|
||||
context = ctypes.c_void_p()
|
||||
format.avformat_open_input.argtypes = [ctypes.POINTER(ctypes.c_void_p), ctypes.c_char_p, ctypes.c_void_p, ctypes.c_void_p]
|
||||
format.avformat_find_stream_info.argtypes = [ctypes.c_void_p, ctypes.c_void_p]
|
||||
format.avformat_close_input.argtypes = [ctypes.POINTER(ctypes.c_void_p)]
|
||||
assert format.avformat_open_input(ctypes.byref(context), bytes(movie), None, None) == 0
|
||||
try:
|
||||
assert format.avformat_find_stream_info(context, None) >= 0
|
||||
finally:
|
||||
format.avformat_close_input(ctypes.byref(context))
|
||||
print(json.dumps({"media_read": True, "software_av1": True, "license": codec.avcodec_license().decode()}))
|
||||
'''
|
||||
|
||||
|
||||
def main():
|
||||
source, fixtures, dependency_root = map(lambda value: Path(value).resolve(), sys.argv[1:])
|
||||
with tempfile.TemporaryDirectory(prefix="terebi-bundle-") as directory:
|
||||
root = Path(directory)
|
||||
app = root / "relocated player.app"
|
||||
shutil.copytree(source, app)
|
||||
movie = root / "media with spaces.mp4"
|
||||
shutil.copy2(fixtures / "av.mp4", movie)
|
||||
macOS = app / "Contents/MacOS"
|
||||
cli = root / "terebi"
|
||||
cli.symlink_to(macOS / "terebi-cli")
|
||||
profile = '(version 1)(allow default)' + ''.join(
|
||||
'(deny file-read* (subpath ' + json.dumps(str(path)) + '))'
|
||||
for path in (Path("/opt/homebrew"), Path("/usr/local"), source, dependency_root))
|
||||
environment = {key: value for key, value in os.environ.items()
|
||||
if not key.startswith(("DYLD_", "PYTHON"))}
|
||||
environment.update(PATH="/usr/bin:/bin", XDG_CONFIG_HOME=str(root / "xdg"), PYTHONNOUSERSITE="1")
|
||||
|
||||
def invoke(arguments, expected=0):
|
||||
result = subprocess.run(["/usr/bin/sandbox-exec", "-p", profile, *map(str, arguments)],
|
||||
env=environment, capture_output=True, text=True, timeout=45)
|
||||
if result.returncode != expected:
|
||||
raise RuntimeError(f"Relocated command {arguments} returned {result.returncode}, expected {expected}:\n{result.stdout}{result.stderr}")
|
||||
return result
|
||||
|
||||
invoke([cli, "--help"])
|
||||
invoke([cli, "--default-config"])
|
||||
native = invoke(["/usr/bin/python3", "-c", PROBE, app, movie])
|
||||
print("PASS relocated private codec libraries:", native.stdout.strip())
|
||||
deno = invoke([macOS / "deno", "eval", "--no-config", "--no-remote", "--no-npm",
|
||||
'console.log(JSON.stringify([1,2,3].map(n => n * n)))'])
|
||||
assert json.loads(deno.stdout) == [1, 4, 9], "bundled JavaScript runtime did not execute"
|
||||
metadata = root / "youtube.json"
|
||||
metadata.write_text(json.dumps({
|
||||
"id": "jNQXAC9IVRw", "title": "Offline bundle fixture", "duration": 4,
|
||||
"webpage_url": "https://www.youtube.com/watch?v=jNQXAC9IVRw",
|
||||
"extractor": "youtube", "extractor_key": "Youtube", "formats": [{
|
||||
"format_id": "360", "height": 360, "width": 640, "ext": "mp4",
|
||||
"url": "https://media.example/fixture.mp4", "protocol": "https", "vcodec": "h264", "acodec": "aac"}]}))
|
||||
extracted = invoke([macOS / "yt-dlp", "--ignore-config", "--no-plugin-dirs", "--no-cache-dir",
|
||||
"--no-remote-components", "--no-js-runtimes", "--js-runtimes", "deno:" + str(macOS / "deno"),
|
||||
"--verbose", "--simulate", "--skip-download", "--dump-single-json", "--no-check-formats",
|
||||
"--load-info-json", metadata])
|
||||
video = json.loads(extracted.stdout)
|
||||
assert video["url"] == "https://media.example/fixture.mp4" and video["height"] == 360
|
||||
assert "yt_dlp_ejs" in extracted.stderr and "deno-" in extracted.stderr, extracted.stderr
|
||||
print("PASS relocated standalone yt-dlp, packaged Python/EJS and Deno without host tools")
|
||||
missing = next((app / "Contents/Frameworks").glob("libdav1d.*.dylib"))
|
||||
missing.rename(root / "removed-dav1d.dylib")
|
||||
failure = invoke([cli, "--new-instance", "--no-config"], expected=69)
|
||||
print("PASS missing bundled transitive library blocks launch:", failure.stderr.strip())
|
||||
invoke([cli, "--help"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,312 @@
|
||||
import AppKit
|
||||
|
||||
@main
|
||||
@MainActor
|
||||
struct CastPlaybackCheck {
|
||||
static func require(_ condition: @autoclosure () -> Bool, _ message: String) {
|
||||
if !condition() { fatalError("FAIL: " + message) }
|
||||
}
|
||||
|
||||
// A real I/O watchdog, not a clock used by the playback behavior under test.
|
||||
static func until(_ model: PlayerModel, _ message: String, permitError: Bool = false, _ predicate: () -> Bool) async throws {
|
||||
let deadline = ContinuousClock.now.advanced(by: .seconds(45))
|
||||
while !predicate(), ContinuousClock.now < deadline {
|
||||
if !permitError, let error = model.commandError { fatalError("FAIL: " + error) }
|
||||
try await Task.sleep(for: .milliseconds(20))
|
||||
}
|
||||
require(predicate(), message)
|
||||
}
|
||||
|
||||
static func queued(_ model: PlayerModel, source: URL, port: UInt16, gates: URL) async throws {
|
||||
model.openFiles([source, source, source])
|
||||
try await until(model, "queue local source plays") { model.state.status == PLAYER_PLAYING && model.canSeek }
|
||||
model.cast(to: CastDevice(id: "loopback", name: "Loopback TV", host: "127.0.0.1", port: port))
|
||||
try await until(model, "queue receiver starts") {
|
||||
model.castDevice?.id == "loopback" && !model.castConnecting && model.state.status == PLAYER_PLAYING
|
||||
}
|
||||
let initial = model.queue.currentID
|
||||
model.advanceQueue(1)
|
||||
try await until(model, "manual queue next retains receiver") {
|
||||
model.queue.currentID != initial && model.castDevice?.id == "loopback" &&
|
||||
!model.castConnecting && model.state.status == PLAYER_PLAYING
|
||||
}
|
||||
let middle = model.queue.currentID
|
||||
try Data().write(to: gates.appendingPathComponent("next"), options: .atomic)
|
||||
try await until(model, "receiver EOF automatically advances queue without disconnecting") {
|
||||
model.queue.currentID != middle && model.castDevice?.id == "loopback" &&
|
||||
!model.castConnecting && model.state.status == PLAYER_PLAYING
|
||||
}
|
||||
require(!model.queue.canNext, "receiver automatic advance selects final queue entry")
|
||||
try Data().write(to: gates.appendingPathComponent("advanced"), options: .atomic)
|
||||
try await until(model, "final receiver EOF stops without wrapping") { model.state.status == PLAYER_ENDED }
|
||||
require(model.castDevice?.id == "loopback" && !model.queue.canNext,
|
||||
"final receiver EOF preserves receiver and last selection")
|
||||
model.stopCasting()
|
||||
try await until(model, "Cast restoration keeps local queue") { model.castDevice == nil && !model.isBusy }
|
||||
require(model.queue.items.count == 3 && !model.queue.canNext,
|
||||
"Cast restoration does not replace queue")
|
||||
}
|
||||
|
||||
static func interrupted(_ model: PlayerModel, source: URL, port: UInt16,
|
||||
scenario: String, gates: URL, defaults: UserDefaults) async throws {
|
||||
func signal(_ name: String) throws {
|
||||
try Data().write(to: gates.appendingPathComponent(name), options: .atomic)
|
||||
}
|
||||
func awaitSignal(_ name: String) async throws {
|
||||
try await until(model, "\(scenario): fixture signals \(name)", permitError: true) {
|
||||
FileManager.default.fileExists(atPath: gates.appendingPathComponent(name).path)
|
||||
}
|
||||
}
|
||||
let replacement = gates.appendingPathComponent("replacement.mp4")
|
||||
try FileManager.default.copyItem(at: source, to: replacement)
|
||||
let active = scenario.hasSuffix("-active")
|
||||
let expectedError = scenario == "reject-load" || scenario == "disconnect-active"
|
||||
model.setVolume(0)
|
||||
model.open(source)
|
||||
try await until(model, "interruption source plays") { model.state.status == PLAYER_PLAYING && model.canSeek }
|
||||
require(model.setPlayback(playing: false), "initial local pause accepted")
|
||||
try await until(model, "initial local pause acknowledged") {
|
||||
model.state.status == PLAYER_PAUSED && model.playbackIntent == nil
|
||||
}
|
||||
require(model.seekRelative(9.25 - model.state.position), "initial local position accepted")
|
||||
try await until(model, "initial local position acknowledged") {
|
||||
!model.state.seek_pending && abs(model.state.position - 9.25) < 0.15
|
||||
}
|
||||
if !active {
|
||||
require(model.setPlayback(playing: true), "local playback starts before pending handoff")
|
||||
try await until(model, "local playback acknowledged before handoff") {
|
||||
model.state.status == PLAYER_PLAYING && model.playbackIntent == nil
|
||||
}
|
||||
}
|
||||
let beforeConnect = model.state.position
|
||||
model.cast(to: CastDevice(id: "loopback", name: "Loopback TV", host: "127.0.0.1", port: port))
|
||||
if active {
|
||||
try await until(model, "active receiver acknowledges handoff") {
|
||||
model.castDevice != nil && !model.castConnecting && model.canSeek && model.state.status == PLAYER_PAUSED
|
||||
}
|
||||
require(abs(model.state.position - 9.25) < 0.15, "active receiver starts at acknowledged local position")
|
||||
require(model.volume == 1, "active receiver volume is visible before local rollback")
|
||||
if scenario == "disconnect-active" {
|
||||
require(model.setPlayback(playing: true), "initial receiver PLAY accepted")
|
||||
try await until(model, "initial receiver PLAY acknowledged") {
|
||||
model.state.status == PLAYER_PLAYING && model.playbackIntent == nil
|
||||
}
|
||||
}
|
||||
try signal("active")
|
||||
}
|
||||
try await awaitSignal("held")
|
||||
if !active {
|
||||
require(model.castDevice != nil && model.castConnecting, "held handoff remains pending")
|
||||
if scenario == "cancel-connect" {
|
||||
try await until(model, "pending connection does not pause local playback") {
|
||||
model.state.status == PLAYER_PLAYING && model.state.position > beforeConnect
|
||||
}
|
||||
} else {
|
||||
try await until(model, "pending LOAD follows acknowledged local pause") { model.state.status == PLAYER_PAUSED }
|
||||
}
|
||||
}
|
||||
requirePlaybackPower(system: !active || scenario == "disconnect-active", display: scenario == "cancel-connect",
|
||||
"handoff keeps the relay awake even after the local decoder pauses")
|
||||
if scenario == "reject-load" {
|
||||
try signal("observed")
|
||||
try await until(model, "LOAD rejection restores original local playback", permitError: true) {
|
||||
model.castDevice == nil && !model.castConnecting && model.commandError != nil &&
|
||||
model.state.status == PLAYER_PLAYING && model.canSeek
|
||||
}
|
||||
} else {
|
||||
switch scenario {
|
||||
case "cancel-connect", "cancel-load":
|
||||
model.stopCasting()
|
||||
case "replace-load", "replace-active":
|
||||
model.open(replacement)
|
||||
case "close-load", "close-active":
|
||||
model.closeFile()
|
||||
case "shutdown-load", "shutdown-active":
|
||||
model.shutdown()
|
||||
case "disconnect-active":
|
||||
require(model.setPlayback(playing: false) && model.setPlayback(playing: true) &&
|
||||
model.setPlayback(playing: false), "latest unacknowledged receiver PAUSE accepted")
|
||||
require(model.playbackIntent == false && model.state.status == PLAYER_PLAYING,
|
||||
"latest intent remains distinct from acknowledged receiver playback")
|
||||
require(model.seekRelative(10), "receiver seek preview accepted before disconnect")
|
||||
require(model.state.seek_pending, "receiver seek remains pending before disconnect")
|
||||
default:
|
||||
fatalError("Unknown interruption scenario: " + scenario)
|
||||
}
|
||||
try signal("interrupted")
|
||||
}
|
||||
try await awaitSignal("released")
|
||||
if scenario.hasPrefix("shutdown-") {
|
||||
require(model.castDevice == nil && !model.castConnecting && !model.canOpen && !model.canClose,
|
||||
"shutdown releases Cast ownership and permanently disables the local player")
|
||||
requirePlaybackPower(system: false, display: false, "interrupted Cast shutdown releases sleep prevention")
|
||||
require(!model.setPlayback(playing: true) && !model.seekRelative(5) && model.volume == 0,
|
||||
"late receiver traffic cannot resume a destroyed player or replace local volume")
|
||||
print("PASS: " + scenario)
|
||||
return
|
||||
}
|
||||
if scenario == "disconnect-active" {
|
||||
try await until(model, "disconnect restores latest intent at acknowledged position", permitError: true) {
|
||||
model.castDevice == nil && !model.castConnecting && !model.isBusy &&
|
||||
model.state.status == PLAYER_PAUSED && !model.state.seek_pending && model.playbackIntent == nil &&
|
||||
abs(model.state.position - 9.25) < 0.15
|
||||
}
|
||||
require(abs((PlaybackProgress(defaults: defaults).position(for: source) ?? 0) - 9.25) < 0.15,
|
||||
"unacknowledged remote seek preview never replaces saved acknowledged position")
|
||||
} else if scenario.hasPrefix("close-") {
|
||||
try await until(model, "close acknowledges empty decoder after late receiver traffic") {
|
||||
model.state.status == PLAYER_EMPTY && model.fileName == nil && model.castDevice == nil && !model.isBusy
|
||||
}
|
||||
require(model.playbackIntent == nil, "close does not leave an accidental resume intent")
|
||||
model.open(replacement)
|
||||
} else if scenario.hasPrefix("replace-") {
|
||||
try await until(model, "replacement acknowledges its own local playback after late receiver traffic") {
|
||||
model.castDevice == nil && !model.castConnecting && !model.isBusy &&
|
||||
model.fileName == replacement.lastPathComponent && model.state.status == PLAYER_PLAYING && model.canSeek
|
||||
}
|
||||
} else {
|
||||
try await until(model, "cancelled or rejected handoff leaves usable local playback", permitError: expectedError) {
|
||||
model.castDevice == nil && !model.castConnecting && model.state.status == PLAYER_PLAYING && model.canSeek
|
||||
}
|
||||
}
|
||||
require(model.volume == 0, "every interrupted handoff restores local volume")
|
||||
if scenario != "disconnect-active" {
|
||||
// A native acknowledgement is the barrier after released late receiver traffic;
|
||||
// never sleep and infer that stale callbacks did nothing.
|
||||
try await until(model, "local decoder is usable after interruption", permitError: expectedError) {
|
||||
model.canSeek && !model.isBusy
|
||||
}
|
||||
require(model.setPlayback(playing: false), "post-interruption local pause accepted")
|
||||
try await until(model, "post-interruption local pause acknowledged", permitError: expectedError) {
|
||||
model.state.status == PLAYER_PAUSED && model.playbackIntent == nil
|
||||
}
|
||||
require(model.seekRelative(4.25 - model.state.position), "post-interruption local seek accepted")
|
||||
try await until(model, "local seek acknowledges after obsolete receiver replies", permitError: expectedError) {
|
||||
!model.state.seek_pending && abs(model.state.position - 4.25) < 0.15
|
||||
}
|
||||
require(model.castDevice == nil && !model.castConnecting && model.volume == 0,
|
||||
"obsolete receiver callbacks cannot reclaim playback or volume")
|
||||
if scenario.hasPrefix("replace-") || scenario.hasPrefix("close-") {
|
||||
require(model.fileName == replacement.lastPathComponent, "interruption preserves replacement media identity")
|
||||
}
|
||||
}
|
||||
model.closeFile()
|
||||
try await until(model, "interrupted scenario closes all playback", permitError: expectedError) {
|
||||
model.state.status == PLAYER_EMPTY && model.castDevice == nil && !model.castConnecting
|
||||
}
|
||||
print("PASS: " + scenario)
|
||||
}
|
||||
|
||||
static func main() async throws {
|
||||
_ = NSApplication.shared
|
||||
NSApp.setActivationPolicy(.prohibited)
|
||||
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" {
|
||||
print("PASS: centered progress clicks and drags, endpoint clamping, and volume tracking feedback")
|
||||
return
|
||||
}
|
||||
let source = URL(fileURLWithPath: CommandLine.arguments[1])
|
||||
let port = UInt16(CommandLine.arguments[2])!
|
||||
let suite = "terebi.cast-playback-check." + UUID().uuidString
|
||||
let defaults = UserDefaults(suiteName: suite)!
|
||||
defer { defaults.removePersistentDomain(forName: suite) }
|
||||
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() }
|
||||
if CommandLine.arguments.count == 5 {
|
||||
if CommandLine.arguments[3] == "queue" {
|
||||
try await queued(model, source: source, port: port, gates: URL(fileURLWithPath: CommandLine.arguments[4]))
|
||||
return
|
||||
}
|
||||
try await interrupted(model, source: source, port: port, scenario: CommandLine.arguments[3],
|
||||
gates: URL(fileURLWithPath: CommandLine.arguments[4]), defaults: defaults)
|
||||
return
|
||||
}
|
||||
model.setVolume(0)
|
||||
model.open(identity)
|
||||
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")
|
||||
require(model.setPlayback(playing: false), "local pause accepted")
|
||||
try await until(model, "local pause acknowledged") { model.state.status == PLAYER_PAUSED && model.playbackIntent == nil }
|
||||
requirePlaybackPower(system: false, display: false, "local pause permits sleep")
|
||||
require(model.seekRelative(9.25 - model.state.position), "local restoration position accepted")
|
||||
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 }
|
||||
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))
|
||||
requirePlaybackPower(system: true, display: false, "pending Cast handoff protects the relay")
|
||||
try await until(model, "receiver load acknowledges paused handoff") {
|
||||
model.castDevice != nil && !model.castConnecting && model.canSeek && model.state.status == PLAYER_PAUSED
|
||||
}
|
||||
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(!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")
|
||||
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")
|
||||
for _ in 0..<64 { require(model.seekRelative(0.125), "rapid remote skips accumulate beyond queue capacity") }
|
||||
require(model.seekRelative(7), "final remote skip retains accumulated target")
|
||||
try await until(model, "receiver seek acknowledged") { !model.state.seek_pending && abs(model.state.position - 24.25) < 0.15 }
|
||||
require(model.setPlayback(playing: false), "remote pause accepted")
|
||||
try await until(model, "receiver pause acknowledged") { model.state.status == PLAYER_PAUSED && model.playbackIntent == nil }
|
||||
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()
|
||||
try await until(model, "local decoder restores receiver position paused") {
|
||||
model.castDevice == nil && model.state.status == PLAYER_PAUSED && !model.state.seek_pending &&
|
||||
abs(model.state.position - 24.25) < 0.15 && !model.isBusy
|
||||
}
|
||||
requirePlaybackPower(system: false, display: false, "paused local restoration permits sleep")
|
||||
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")
|
||||
require(model.volume == 0, "local volume survives receiver volume changes")
|
||||
require(model.setPlayback(playing: true), "resume local playback before a failed Cast connection")
|
||||
try await until(model, "local resume acknowledged") { model.state.status == PLAYER_PLAYING && model.playbackIntent == nil }
|
||||
model.cast(to: CastDevice(id: "invalid", name: "Unavailable TV", host: "127.0.0.1", port: 0))
|
||||
try await until(model, "failed Cast connection restores local playback", permitError: true) {
|
||||
model.castDevice == nil && model.commandError != nil && model.state.status == PLAYER_PLAYING
|
||||
}
|
||||
require(model.canSeek && model.state.position >= 24, "connection failure preserves a usable local timeline")
|
||||
requirePlaybackPower(system: true, display: true, "failed Cast restores local display protection")
|
||||
model.closeFile()
|
||||
try await until(model, "closed media releases Cast and decoder") { model.state.status == PLAYER_EMPTY }
|
||||
requirePlaybackPower(system: false, display: false, "closing Cast media releases sleep prevention")
|
||||
print("PASS: real local decode → TLS Cast LOAD/play/seek/pause → local restoration/history; failed connection recovery")
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+521
-93
@@ -1,4 +1,5 @@
|
||||
import AppKit
|
||||
import CoreFoundation
|
||||
import Foundation
|
||||
|
||||
@main
|
||||
@@ -68,14 +69,14 @@ struct CompactWindowChecks {
|
||||
"overlapping magnetic zones choose the closest edge, not first edge")
|
||||
|
||||
let usableScreen = NSRect(x: -1_200, y: 50, width: 1_200, height: 800)
|
||||
let landscape = CompactWindowController.initialFrame(aspect: 16.0 / 9.0, visibleFrame: usableScreen)
|
||||
let landscape = CompactWindowController.initialFrame(aspect: 16.0 / 9.0, visibleFrame: usableScreen, width: 320)
|
||||
try require(sameFrame(landscape, NSRect(x: -336, y: 654, width: 320, height: 180)),
|
||||
"16:9 enters at top right with 320pt width and 16pt inset")
|
||||
let portrait = CompactWindowController.initialFrame(aspect: 9.0 / 16.0, visibleFrame: usableScreen)
|
||||
let portrait = CompactWindowController.initialFrame(aspect: 9.0 / 16.0, visibleFrame: usableScreen, width: 320)
|
||||
try require(sameFrame(portrait, NSRect(x: -336, y: 834 - 5_120.0 / 9.0, width: 320, height: 5_120.0 / 9.0)),
|
||||
"portrait preserves exact aspect when it fits")
|
||||
for aspect in [16.0 / 9.0, 9.0 / 16.0, 100.0, 0.01] {
|
||||
let frame = CompactWindowController.initialFrame(aspect: aspect, visibleFrame: usableScreen)
|
||||
let frame = CompactWindowController.initialFrame(aspect: aspect, visibleFrame: usableScreen, width: 320)
|
||||
try require(frame.origin.x.isFinite && frame.origin.y.isFinite
|
||||
&& frame.width.isFinite && frame.height.isFinite,
|
||||
"initial geometry is finite for aspect \(aspect)")
|
||||
@@ -88,138 +89,565 @@ struct CompactWindowChecks {
|
||||
"aspect \(aspect) stays anchored top right")
|
||||
}
|
||||
let smallScreen = NSRect(x: 0, y: 0, width: 300, height: 400)
|
||||
let bounded = CompactWindowController.initialFrame(aspect: 16.0 / 9.0, visibleFrame: smallScreen)
|
||||
let bounded = CompactWindowController.initialFrame(aspect: 16.0 / 9.0, visibleFrame: smallScreen, width: 320)
|
||||
try require(sameFrame(bounded, NSRect(x: 16, y: 233.25, width: 268, height: 150.75)),
|
||||
"target width shrinks to usable bounds without distorting ordinary aspect")
|
||||
}
|
||||
|
||||
static func windowBehavior() throws {
|
||||
_ = NSApplication.shared
|
||||
guard let screen = NSScreen.main else {
|
||||
throw Failure(description: "FAIL: native window checks require a macOS GUI session with a screen")
|
||||
// Run the real AppKit completion callbacks. The monotonic deadline is only a hang watchdog.
|
||||
static func settled(_ controller: CompactWindowController, compact: Bool,
|
||||
line: UInt = #line, checking: () throws -> Void = {}) throws {
|
||||
let deadline = ContinuousClock.now.advanced(by: .seconds(5))
|
||||
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 require(ContinuousClock.now < deadline, "native compact transition completes (call at line \(line), compact \(controller.isCompact), target \(compact))")
|
||||
CFRunLoopRunInMode(.defaultMode, 0.01, false)
|
||||
}
|
||||
// Creating a window does not display it: never orderFront, makeKey, or enter native fullscreen.
|
||||
let window = NSWindow(contentRect: NSRect(x: -10_000, y: -10_000, width: 640, height: 360),
|
||||
try checking()
|
||||
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
|
||||
struct WindowState {
|
||||
let frame: NSRect
|
||||
let minSize: NSSize
|
||||
let contentMinSize: NSSize
|
||||
let aspect: NSSize
|
||||
let style: NSWindow.StyleMask
|
||||
let titleVisibility: NSWindow.TitleVisibility
|
||||
let transparentTitlebar: Bool
|
||||
let collection: NSWindow.CollectionBehavior
|
||||
let level: NSWindow.Level
|
||||
let hides: Bool
|
||||
let drag: Bool
|
||||
|
||||
init(_ window: NSWindow) {
|
||||
frame = window.frame
|
||||
minSize = window.minSize
|
||||
contentMinSize = window.contentMinSize
|
||||
aspect = window.aspectRatio
|
||||
style = window.styleMask
|
||||
titleVisibility = window.titleVisibility
|
||||
transparentTitlebar = window.titlebarAppearsTransparent
|
||||
collection = window.collectionBehavior
|
||||
level = window.level
|
||||
hides = window.hidesOnDeactivate
|
||||
drag = window.isMovableByWindowBackground
|
||||
}
|
||||
|
||||
func requireMatches(_ window: NSWindow) throws {
|
||||
try CompactWindowChecks.require(CompactWindowChecks.sameFrame(window.frame, frame),
|
||||
"restore recovers original normal geometry")
|
||||
try CompactWindowChecks.require(window.minSize == minSize && window.contentMinSize == contentMinSize
|
||||
&& window.aspectRatio == aspect, "restore recovers aspect and both minima")
|
||||
try CompactWindowChecks.require(window.styleMask == style && window.collectionBehavior == collection
|
||||
&& window.level == level, "restore recovers style, Spaces policy, and level")
|
||||
try CompactWindowChecks.require(window.titleVisibility == titleVisibility
|
||||
&& window.titlebarAppearsTransparent == transparentTitlebar,
|
||||
"restore recovers original native chrome")
|
||||
try CompactWindowChecks.require(window.hidesOnDeactivate == hides
|
||||
&& window.isMovableByWindowBackground == drag,
|
||||
"restore recovers deactivate and background drag behavior")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class WindowDelegate: NSObject, NSWindowDelegate {
|
||||
var closes = 0
|
||||
func windowWillClose(_ notification: Notification) { closes += 1 }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class WindowFixture {
|
||||
let window: NSWindow
|
||||
let content: NSView
|
||||
let sentinel: NSView
|
||||
let sentinelFrame: NSRect
|
||||
let original: WindowState
|
||||
let delegate = WindowDelegate()
|
||||
let activationPolicy: NSApplication.ActivationPolicy
|
||||
|
||||
init() throws {
|
||||
// Never orderFront, makeKey, or create an actual native fullscreen Space.
|
||||
window = NSWindow(contentRect: NSRect(x: -10_000, y: -10_000, width: 640, height: 360),
|
||||
styleMask: [.titled, .closable, .miniaturizable, .resizable],
|
||||
backing: .buffered, defer: false)
|
||||
window.isReleasedWhenClosed = false
|
||||
defer { window.close() }
|
||||
guard let content = window.contentView else { throw Failure(description: "FAIL: missing content view") }
|
||||
let sentinel = NSView(frame: NSRect(x: 5, y: 7, width: 12, height: 14))
|
||||
content.addSubview(sentinel)
|
||||
let sentinelFrame = sentinel.frame
|
||||
window.minSize = NSSize(width: 300, height: 180)
|
||||
window.contentMinSize = NSSize(width: 220, height: 120)
|
||||
window.aspectRatio = NSSize(width: 4, height: 3)
|
||||
window.collectionBehavior = [.managed, .fullScreenPrimary]
|
||||
window.level = .normal
|
||||
window.hidesOnDeactivate = true
|
||||
window.isMovableByWindowBackground = false
|
||||
let originalFrame = window.frame
|
||||
let originalMinSize = window.minSize
|
||||
let originalContentMinSize = window.contentMinSize
|
||||
let originalAspect = window.aspectRatio
|
||||
let originalStyle = window.styleMask
|
||||
let originalTitleVisibility = window.titleVisibility
|
||||
let originalTransparentTitlebar = window.titlebarAppearsTransparent
|
||||
let originalCollection = window.collectionBehavior
|
||||
let originalLevel = window.level
|
||||
let originalHides = window.hidesOnDeactivate
|
||||
let originalDrag = window.isMovableByWindowBackground
|
||||
let identity = ObjectIdentifier(window)
|
||||
let controller = CompactWindowController()
|
||||
defer { controller.restore(resumeFullScreen: false) }
|
||||
controller.attach(window)
|
||||
try require(!controller.isCompact && !controller.isTransitioning, "attached window starts ordinary")
|
||||
try require(controller.snappingEnabled, "fresh isolated preferences default snapping on")
|
||||
|
||||
func preservedContent() throws {
|
||||
try require(ObjectIdentifier(window) == identity && sentinel.window === window,
|
||||
"compact mode keeps the actual rendering window")
|
||||
try require(window.contentView === content && sentinel.superview === content
|
||||
&& content.subviews.count == 1 && sameFrame(sentinel.frame, sentinelFrame),
|
||||
"compact mode preserves content and attached sentinel without reparenting")
|
||||
try require(!window.isVisible, "checks never display a window")
|
||||
window.isReleasedWhenClosed = false
|
||||
guard let content = window.contentView else {
|
||||
throw Failure(description: "FAIL: missing content view")
|
||||
}
|
||||
self.content = content
|
||||
sentinel = NSView(frame: NSRect(x: 5, y: 7, width: 12, height: 14))
|
||||
content.addSubview(sentinel)
|
||||
sentinelFrame = sentinel.frame
|
||||
window.minSize = NSSize(width: 300, height: 180)
|
||||
window.contentMinSize = NSSize(width: 220, height: 120)
|
||||
window.aspectRatio = NSSize(width: 4, height: 3)
|
||||
window.collectionBehavior = [.managed, .fullScreenPrimary]
|
||||
window.level = .normal
|
||||
window.hidesOnDeactivate = true
|
||||
window.isMovableByWindowBackground = false
|
||||
window.delegate = delegate
|
||||
original = WindowState(window)
|
||||
activationPolicy = NSApp.activationPolicy()
|
||||
}
|
||||
|
||||
func preservedContent(in presentation: NSWindow? = nil) throws {
|
||||
let presentation = presentation ?? window
|
||||
try CompactWindowChecks.require(sentinel.window === presentation
|
||||
&& presentation.contentView === content
|
||||
&& sentinel.superview === content
|
||||
&& content.subviews.count == 1
|
||||
&& CompactWindowChecks.sameFrame(sentinel.frame, sentinelFrame),
|
||||
"presentation retains the same content subtree and renderer sentinel")
|
||||
try CompactWindowChecks.require(!window.isVisible && !window.isKeyWindow
|
||||
&& !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 {
|
||||
try require(!controller.isCompact && !controller.isTransitioning, "restore finishes in ordinary mode")
|
||||
try require(sameFrame(window.frame, originalFrame), "restore recovers original normal geometry")
|
||||
try require(window.minSize == originalMinSize && window.contentMinSize == originalContentMinSize
|
||||
&& window.aspectRatio == originalAspect, "restore recovers aspect and both minima")
|
||||
try require(window.styleMask == originalStyle && window.collectionBehavior == originalCollection
|
||||
&& window.level == originalLevel, "restore recovers style, Spaces policy, and level")
|
||||
try require(window.titleVisibility == originalTitleVisibility
|
||||
&& window.titlebarAppearsTransparent == originalTransparentTitlebar,
|
||||
"restore recovers original native chrome")
|
||||
try require(window.hidesOnDeactivate == originalHides
|
||||
&& window.isMovableByWindowBackground == originalDrag,
|
||||
"restore recovers deactivate and background drag behavior")
|
||||
try original.requireMatches(window)
|
||||
try preservedContent()
|
||||
}
|
||||
|
||||
controller.toggle(aspect: 16.0 / 9.0)
|
||||
try require(controller.isCompact && !controller.isTransitioning, "windowed toggle enters compact synchronously")
|
||||
try require(window.level == .floating && !window.hidesOnDeactivate, "compact floats and remains visible on deactivate")
|
||||
try require(window.collectionBehavior.contains([.canJoinAllSpaces, .canJoinAllApplications, .fullScreenAuxiliary]),
|
||||
"compact can join Spaces, other-app fullscreen, and Stage Manager")
|
||||
try require(window.collectionBehavior.intersection([.primary, .auxiliary, .fullScreenPrimary, .fullScreenNone]).isEmpty,
|
||||
"compact removes incompatible primary and auxiliary policies")
|
||||
try require(window.contentMinSize == NSSize(width: 160, height: 90), "compact uses 160x90 content minimum")
|
||||
try require(window.isMovableByWindowBackground, "compact allows background dragging")
|
||||
try require(window.canBecomeKey, "compact window still accepts keyboard playback controls")
|
||||
try preservedContent()
|
||||
func compact(_ controller: CompactWindowController, aspect: Double) throws {
|
||||
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")
|
||||
try CompactWindowChecks.require(panel.collectionBehavior.contains(
|
||||
[.canJoinAllSpaces, .canJoinAllApplications, .fullScreenAuxiliary]),
|
||||
"compact can join Spaces, other-app fullscreen, and Stage Manager")
|
||||
try CompactWindowChecks.require(panel.collectionBehavior.intersection(
|
||||
[.primary, .auxiliary, .fullScreenPrimary, .fullScreenNone]).isEmpty,
|
||||
"compact removes incompatible primary and auxiliary policies")
|
||||
try CompactWindowChecks.require(panel.contentMinSize == NSSize(width: 160, height: 90),
|
||||
"compact uses 160x90 content minimum")
|
||||
try CompactWindowChecks.require(panel.canBecomeKey, "compact accepts keyboard playback controls")
|
||||
try CompactWindowChecks.require(panel.titleVisibility == .hidden && panel.titlebarAppearsTransparent,
|
||||
"compact hides native title chrome")
|
||||
let visible = (panel.screen ?? NSScreen.main!).visibleFrame
|
||||
try CompactWindowChecks.require(CompactWindowChecks.close(panel.frame.maxX, visible.maxX - 16)
|
||||
&& CompactWindowChecks.close(panel.frame.maxY, visible.maxY - 16)
|
||||
&& CompactWindowChecks.close(panel.frame.width / panel.frame.height,
|
||||
CGFloat(aspect)),
|
||||
"compact enters at inset top right with playback aspect")
|
||||
try preservedContent(in: panel)
|
||||
}
|
||||
}
|
||||
|
||||
let visible = (window.screen ?? screen).visibleFrame
|
||||
try require(close(window.frame.maxX, visible.maxX - 16)
|
||||
&& close(window.frame.maxY, visible.maxY - 16)
|
||||
&& close(window.frame.width / window.frame.height, 16.0 / 9.0),
|
||||
"actual compact window enters at inset top right with playback aspect")
|
||||
// A later real native round-trip gives canceled animations a completion opportunity without
|
||||
// guessing elapsed animation time. Watch the interrupted window throughout and after it.
|
||||
static func nativeCompletionOpportunity(checking: () throws -> Void) throws {
|
||||
let fixture = try WindowFixture()
|
||||
let controller = CompactWindowController(reduceMotion: { false })
|
||||
defer {
|
||||
controller.restore(resumeFullScreen: false, animated: false)
|
||||
fixture.window.close()
|
||||
}
|
||||
controller.attach(fixture.window)
|
||||
controller.toggle(aspect: 16.0 / 9.0)
|
||||
try require(controller.isTransitioning, "native completion barrier is an actual animated entry")
|
||||
try settled(controller, compact: true, checking: checking)
|
||||
try fixture.compact(controller, aspect: 16.0 / 9.0)
|
||||
controller.restore(resumeFullScreen: false)
|
||||
try settled(controller, compact: false, checking: checking)
|
||||
try fixture.restored()
|
||||
}
|
||||
|
||||
static func windowBehavior() throws {
|
||||
_ = NSApplication.shared
|
||||
guard NSScreen.main != nil else {
|
||||
throw Failure(description: "FAIL: native window checks require a macOS GUI session with a screen")
|
||||
}
|
||||
let fixture = try WindowFixture()
|
||||
let window = fixture.window
|
||||
let controller = CompactWindowController(reduceMotion: { false })
|
||||
defer {
|
||||
controller.restore(resumeFullScreen: false, animated: false)
|
||||
window.close()
|
||||
}
|
||||
controller.attach(window)
|
||||
try require(!controller.isCompact && !controller.isTransitioning
|
||||
&& controller.presentationWindow === window, "attached window starts ordinary")
|
||||
try require(controller.snappingEnabled, "isolated controller defaults snapping on")
|
||||
|
||||
controller.toggle(aspect: 16.0 / 9.0)
|
||||
try require(controller.isCompact && controller.isTransitioning,
|
||||
"entry presents the compact destination while native geometry settles")
|
||||
try fixture.preservedContent(in: controller.presentationWindow)
|
||||
try settled(controller, compact: true)
|
||||
try fixture.compact(controller, aspect: 16.0 / 9.0)
|
||||
|
||||
let panel = controller.presentationWindow!
|
||||
let visible = (panel.screen ?? NSScreen.main!).visibleFrame
|
||||
let nearEdge = NSRect(x: visible.minX + 26, y: visible.minY + 27,
|
||||
width: window.frame.width, height: window.frame.height)
|
||||
window.setFrame(nearEdge, display: false)
|
||||
try require(sameFrame(window.frame, nearEdge), "native move installs unsnapped drag position")
|
||||
width: panel.frame.width, height: panel.frame.height)
|
||||
panel.setFrame(nearEdge, display: false)
|
||||
try require(sameFrame(panel.frame, nearEdge), "native move installs unsnapped drag position")
|
||||
controller.snapAfterDrag()
|
||||
try require(close(window.frame.minX, visible.minX + 16) && close(window.frame.minY, visible.minY + 16)
|
||||
&& window.frame.size == nearEdge.size, "enabled preference snaps the actual moved window")
|
||||
try require(close(panel.frame.minX, visible.minX + 16) && close(panel.frame.minY, visible.minY + 16)
|
||||
&& panel.frame.size == nearEdge.size, "enabled preference snaps the actual moved window")
|
||||
controller.snappingEnabled = false
|
||||
let second = CompactWindowController(snappingEnabled: false)
|
||||
let second = CompactWindowController(snappingEnabled: false, reduceMotion: { false })
|
||||
try require(!second.snappingEnabled, "configuration can disable snapping for a new controller")
|
||||
window.setFrame(nearEdge, display: false)
|
||||
panel.setFrame(nearEdge, display: false)
|
||||
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
|
||||
let third = CompactWindowController(snappingEnabled: false)
|
||||
let third = CompactWindowController(snappingEnabled: false, reduceMotion: { false })
|
||||
try require(!third.snappingEnabled, "a session override does not change the next controller's settings")
|
||||
controller.snappingEnabled = true
|
||||
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)
|
||||
try require(sameFrame(window.frame, resized),
|
||||
try require(sameFrame(panel.frame, resized),
|
||||
"repeated aspect updates preserve a user's compact resize and placement")
|
||||
controller.updateAspect(4.0 / 3.0)
|
||||
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")
|
||||
try preservedContent()
|
||||
try fixture.preservedContent(in: panel)
|
||||
controller.toggle(aspect: 4.0 / 3.0)
|
||||
try restored()
|
||||
try require(controller.presentationWindow === panel,
|
||||
"restoring continues presenting the panel until content is handed back")
|
||||
try settled(controller, compact: false)
|
||||
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.
|
||||
controller.toggle(aspect: 16.0 / 9.0)
|
||||
try require(controller.isTransitioning, "entry is in flight before its destination changes")
|
||||
controller.toggle(aspect: 16.0 / 9.0)
|
||||
try settled(controller, compact: false)
|
||||
try fixture.restored()
|
||||
|
||||
// Exit -> enter keeps the newest aspect and must not save an intermediate restore frame.
|
||||
controller.toggle(aspect: 16.0 / 9.0)
|
||||
try settled(controller, compact: true)
|
||||
controller.toggle(aspect: 16.0 / 9.0)
|
||||
try require(!controller.isCompact && controller.isTransitioning,
|
||||
"exit presents the ordinary destination while native geometry settles")
|
||||
controller.toggle(aspect: 4.0 / 3.0)
|
||||
try settled(controller, compact: true)
|
||||
try fixture.compact(controller, aspect: 4.0 / 3.0)
|
||||
controller.restore(resumeFullScreen: false)
|
||||
try settled(controller, compact: false)
|
||||
try fixture.restored()
|
||||
|
||||
// Alternating requests within one native animation settle only at the latest destination.
|
||||
for compactDestination in [false, true] {
|
||||
controller.toggle(aspect: 16.0 / 9.0)
|
||||
try require(controller.isTransitioning, "alternating requests interrupt native entry")
|
||||
for _ in 0..<(compactDestination ? 6 : 5) {
|
||||
controller.toggle(aspect: 16.0 / 9.0)
|
||||
}
|
||||
try settled(controller, compact: compactDestination)
|
||||
if compactDestination {
|
||||
try fixture.compact(controller, aspect: 16.0 / 9.0)
|
||||
controller.restore(resumeFullScreen: false)
|
||||
try settled(controller, compact: false)
|
||||
}
|
||||
try fixture.restored()
|
||||
}
|
||||
|
||||
controller.toggle(aspect: 16.0 / 9.0)
|
||||
controller.restore(resumeFullScreen: false)
|
||||
try restored()
|
||||
try settled(controller, compact: false)
|
||||
try fixture.restored()
|
||||
controller.restore(resumeFullScreen: false)
|
||||
try restored()
|
||||
try settled(controller, compact: false)
|
||||
try fixture.restored()
|
||||
|
||||
// Urgent teardown is immediate; old native completion cannot touch its restored snapshot.
|
||||
controller.toggle(aspect: 16.0 / 9.0)
|
||||
try require(controller.isTransitioning, "urgent restore interrupts native entry")
|
||||
controller.restore(resumeFullScreen: false, animated: false)
|
||||
try require(!controller.isCompact && !controller.isTransitioning, "urgent restore settles immediately")
|
||||
try fixture.restored()
|
||||
try nativeCompletionOpportunity {
|
||||
try require(!controller.isCompact && !controller.isTransitioning,
|
||||
"stale completion cannot revive an urgently restored session")
|
||||
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()
|
||||
defer { replacement.window.close() }
|
||||
controller.toggle(aspect: 16.0 / 9.0)
|
||||
try require(controller.isTransitioning, "attachment interrupts native entry")
|
||||
let replacedPanel = controller.presentationWindow!
|
||||
controller.attach(replacement.window)
|
||||
try require(!controller.isCompact && !controller.isTransitioning, "replacement starts ordinary")
|
||||
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)
|
||||
try settled(controller, compact: true) { try fixture.restored() }
|
||||
try replacement.compact(controller, aspect: 4.0 / 3.0)
|
||||
controller.restore(resumeFullScreen: false)
|
||||
try settled(controller, compact: false) { try fixture.restored() }
|
||||
try replacement.restored()
|
||||
|
||||
controller.attach(window)
|
||||
// Replay AppKit's failure lifecycle without creating a visible fullscreen Space.
|
||||
NotificationCenter.default.post(name: NSWindow.willEnterFullScreenNotification, object: window)
|
||||
controller.toggle(aspect: 16.0 / 9.0)
|
||||
try require(controller.isTransitioning && !controller.isCompact,
|
||||
"compact entry waits for an in-flight native fullscreen transition")
|
||||
window.delegate?.windowDidFailToEnterFullScreen?(window)
|
||||
try restored()
|
||||
try settled(controller, compact: false)
|
||||
try fixture.restored()
|
||||
NotificationCenter.default.post(name: NSWindow.willExitFullScreenNotification, object: window)
|
||||
controller.toggle(aspect: 16.0 / 9.0)
|
||||
window.delegate?.windowDidFailToExitFullScreen?(window)
|
||||
try settled(controller, compact: false)
|
||||
try fixture.restored()
|
||||
|
||||
controller.toggle(aspect: 16.0 / 9.0)
|
||||
try require(controller.isTransitioning, "close interrupts native entry")
|
||||
let closingPanel = controller.presentationWindow!
|
||||
window.close()
|
||||
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)
|
||||
controller.attach(replacement.window)
|
||||
controller.toggle(aspect: 16.0 / 9.0)
|
||||
func closedWindowUnchanged() throws {
|
||||
try closedState.requireMatches(window)
|
||||
try fixture.preservedContent()
|
||||
}
|
||||
try settled(controller, compact: true, checking: closedWindowUnchanged)
|
||||
try replacement.compact(controller, aspect: 16.0 / 9.0)
|
||||
controller.restore(resumeFullScreen: false)
|
||||
try settled(controller, compact: false, checking: closedWindowUnchanged)
|
||||
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 {
|
||||
let suite = "terebi.compact-size-check.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suite)!
|
||||
defer { defaults.removePersistentDomain(forName: suite) }
|
||||
let fixture = try WindowFixture()
|
||||
let controller = CompactWindowController(defaults: defaults, reduceMotion: { true })
|
||||
defer {
|
||||
controller.restore(resumeFullScreen: false, animated: false)
|
||||
fixture.window.close()
|
||||
}
|
||||
controller.attach(fixture.window)
|
||||
controller.toggle(aspect: 16.0 / 9.0)
|
||||
let panel = controller.presentationWindow!
|
||||
var resized = panel.frame
|
||||
resized.size = NSSize(width: 240, height: 135)
|
||||
panel.setFrame(resized, display: false)
|
||||
NotificationCenter.default.post(name: NSWindow.didEndLiveResizeNotification, object: panel)
|
||||
controller.restore(resumeFullScreen: false)
|
||||
try fixture.restored()
|
||||
controller.toggle(aspect: 16.0 / 9.0)
|
||||
let reentered = controller.presentationWindow!
|
||||
try require(close(reentered.frame.width, 240) && close(reentered.frame.height, 135),
|
||||
"reentering compact restores the manually chosen size")
|
||||
|
||||
// Programmatic fitting and ordinary-window resizing must not replace the preference.
|
||||
resized.size = NSSize(width: 320, height: 180)
|
||||
reentered.setFrame(resized, display: false)
|
||||
controller.updateAspect(4.0 / 3.0)
|
||||
controller.restore(resumeFullScreen: false)
|
||||
try fixture.restored()
|
||||
NotificationCenter.default.post(name: NSWindow.didEndLiveResizeNotification, object: fixture.window)
|
||||
|
||||
let replacement = try WindowFixture()
|
||||
let reloaded = CompactWindowController(defaults: UserDefaults(suiteName: suite)!, reduceMotion: { true })
|
||||
defer {
|
||||
reloaded.restore(resumeFullScreen: false, animated: false)
|
||||
replacement.window.close()
|
||||
}
|
||||
reloaded.attach(replacement.window)
|
||||
reloaded.toggle(aspect: 4.0 / 3.0)
|
||||
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")
|
||||
try replacement.compact(reloaded, aspect: 4.0 / 3.0)
|
||||
reloaded.restore(resumeFullScreen: false)
|
||||
try replacement.restored()
|
||||
}
|
||||
|
||||
static func reducedMotion() throws {
|
||||
let fixture = try WindowFixture()
|
||||
let controller = CompactWindowController(reduceMotion: { true })
|
||||
defer {
|
||||
controller.restore(resumeFullScreen: false, animated: false)
|
||||
fixture.window.close()
|
||||
}
|
||||
controller.attach(fixture.window)
|
||||
controller.toggle(aspect: 16.0 / 9.0)
|
||||
try require(controller.isCompact && !controller.isTransitioning,
|
||||
"Reduce Motion enters with geometry and controls already settled")
|
||||
try fixture.compact(controller, aspect: 16.0 / 9.0)
|
||||
controller.toggle(aspect: 16.0 / 9.0)
|
||||
try require(!controller.isCompact && !controller.isTransitioning,
|
||||
"Reduce Motion restores without a pending frame animation")
|
||||
try fixture.restored()
|
||||
try nativeCompletionOpportunity {
|
||||
try require(!controller.isCompact && !controller.isTransitioning,
|
||||
"Reduce Motion leaves no completion that can revive compact state")
|
||||
try fixture.restored()
|
||||
}
|
||||
}
|
||||
|
||||
static func continuousQueueResize() throws {
|
||||
let aspect = 16.0 / 9.0
|
||||
let sidebar: CGFloat = 260
|
||||
let window = NSWindow(contentRect: NSRect(x: -10_000, y: -10_000, width: 1_160, height: 900 / aspect),
|
||||
styleMask: [.titled, .resizable, .fullSizeContentView],
|
||||
backing: .buffered, defer: false)
|
||||
window.isReleasedWhenClosed = false
|
||||
defer { window.close() }
|
||||
let controller = CompactWindowController()
|
||||
controller.normalVideoLayout = { (aspect, sidebar) }
|
||||
controller.attach(window)
|
||||
for horizontal in [true, false] {
|
||||
let start = window.frame
|
||||
let chrome = start.height - window.contentRect(forFrameRect: start).height
|
||||
window.delegate?.windowWillStartLiveResize?(Notification(name: NSWindow.willStartLiveResizeNotification, object: window))
|
||||
// AppKit proposes sizes from the pointer's drag origin. The delegate's
|
||||
// correction of the other dimension must not feed back into axis selection.
|
||||
for offset in [CGFloat(20), 21, 22, 23, 24, 23, 22, 21, 20, 0, -1, -2] {
|
||||
let proposal = NSSize(width: start.width + (horizontal ? offset : 0),
|
||||
height: start.height + (horizontal ? 0 : offset))
|
||||
let size = window.delegate!.windowWillResize!(window, to: proposal)
|
||||
try require(close(horizontal ? size.width : size.height,
|
||||
horizontal ? proposal.width : proposal.height),
|
||||
"continuous queue resize follows the dragged edge without snapping back")
|
||||
try require(close((size.width - sidebar) / (size.height - chrome), aspect),
|
||||
"continuous queue resize keeps the video aspect")
|
||||
var frame = start
|
||||
frame.size = size
|
||||
window.setFrame(frame, display: false)
|
||||
}
|
||||
window.delegate?.windowDidEndLiveResize?(Notification(name: NSWindow.didEndLiveResizeNotification, object: window))
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
try geometry()
|
||||
try windowBehavior()
|
||||
try reducedMotion()
|
||||
try rememberedSize()
|
||||
try continuousQueueResize()
|
||||
try compactQueue()
|
||||
print("Compact window checks passed")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,9 +38,21 @@ struct ConfigurationChecks {
|
||||
|
||||
static func checkBindings() throws {
|
||||
let bindings = KeyBindings()
|
||||
try require(PlayerAction(name: "open_stream") == .openStream && PlayerAction.openStream.name == "open_stream",
|
||||
"stream action uses the general config name")
|
||||
try require(PlayerAction(name: "open_youtube") == nil, "obsolete YouTube action has no alias")
|
||||
try require(bindings.shortcuts(for: .openStream) == [try KeyChord("cmd+shift+o")],
|
||||
"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": []]) }
|
||||
let inversePairs: [(PlayerAction, PlayerAction)] = [
|
||||
(.play, .pause), (.mute, .unmute), (.enterFullscreen, .exitFullscreen),
|
||||
(.enterCompact, .exitCompact), (.enableAlwaysOnTop, .disableAlwaysOnTop),
|
||||
(.showQueue, .hideQueue),
|
||||
(.showBufferInfo, .hideBufferInfo), (.enableCompactSnapping, .disableCompactSnapping),
|
||||
(.enableAutomaticQuality, .disableAutomaticQuality)
|
||||
]
|
||||
@@ -116,6 +128,64 @@ struct ConfigurationChecks {
|
||||
"printed TOML is directly usable and preserves all default behavior and bindings")
|
||||
try require(!defaults.showBufferInfo && !defaults.automaticQuality && defaults.snapToEdges,
|
||||
"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)] {
|
||||
let progress = root.appendingPathComponent("compact-progress-\(value).toml")
|
||||
try Data("[ui]\ncompact_title_progress = \"\(value)\"\n".utf8).write(to: progress)
|
||||
try require(try PlayerConfiguration.load(from: progress).compactTitleProgress == expected,
|
||||
"compact title format \(value) loads from a partial UI section")
|
||||
}
|
||||
try require(PlayerConfiguration.fileURL(environment: ["XDG_CONFIG_HOME": xdg.path], homeDirectory: home) == config,
|
||||
"absolute XDG_CONFIG_HOME selects terebi/config.toml")
|
||||
for environment in [[:], ["XDG_CONFIG_HOME": ""], ["XDG_CONFIG_HOME": "relative/config"]] {
|
||||
@@ -132,13 +202,15 @@ struct ConfigurationChecks {
|
||||
# Sections and nested keys can be quoted, with comments and whitespace.
|
||||
['playback']
|
||||
'automatic_quality' = true
|
||||
'sponsorblock' = true
|
||||
[ui]
|
||||
"buffer_info" = true # session display default
|
||||
compact_snap_to_edges = false
|
||||
""".utf8)
|
||||
try original.write(to: config)
|
||||
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]
|
||||
try require(try PlayerConfiguration.load(from: config) == configured,
|
||||
"TOML comments, whitespace, quoted nested keys, and both boolean values load from a read-only file")
|
||||
@@ -146,6 +218,7 @@ struct ConfigurationChecks {
|
||||
try require(session == configured, "application startup automatically loads the XDG config")
|
||||
session.showBufferInfo = false
|
||||
session.automaticQuality = false
|
||||
session.sponsorBlock = false
|
||||
session.snapToEdges = true
|
||||
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")
|
||||
@@ -186,7 +259,7 @@ struct ConfigurationChecks {
|
||||
try require(try PlayerConfiguration.load(from: nested).keys == expectedKeys && keySession.keys == defaults.keys,
|
||||
"key session overrides never persist")
|
||||
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")
|
||||
let reordered = root.appendingPathComponent("reordered.toml")
|
||||
try Data("""
|
||||
@@ -208,6 +281,9 @@ struct ConfigurationChecks {
|
||||
("syntax", "[playback]\nautomatic_quality = [\n", ["automatic_quality"]),
|
||||
("duplicate", "[playback]\nautomatic_quality = true\n\"automatic_quality\" = false\n", ["automatic_quality"]),
|
||||
("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-snap", "snap_to_edges = false\n", ["snap_to_edges"]),
|
||||
("legacy-quality", "automatic_quality = true\n", ["automatic_quality"]),
|
||||
@@ -218,7 +294,13 @@ struct ConfigurationChecks {
|
||||
("ui-legacy", "[ui]\nshow_buffer_info = true\n", ["ui.show_buffer_info"]),
|
||||
("string", "[ui]\nbuffer_info = \"true\"\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", []),
|
||||
("progress-value", "[ui]\ncompact_title_progress = \"unknown\"\n", ["compact_title_progress", "unknown"]),
|
||||
("progress-bool", "[ui]\ncompact_title_progress = false\n", ["compact_title_progress"]),
|
||||
("table", "[playback.automatic_quality]\nenabled = true\n", ["automatic_quality"]),
|
||||
("ui-scalar", "ui = true\n", ["ui"]),
|
||||
("keys-scalar", "keys = true\n", ["keys"]),
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import Foundation
|
||||
import Darwin
|
||||
|
||||
@main
|
||||
struct ExternalSubtitleChecks {
|
||||
struct Failure: Error, CustomStringConvertible { let description: String }
|
||||
|
||||
static func require(_ condition: @autoclosure () throws -> Bool, _ message: String) throws {
|
||||
guard try condition() else { throw Failure(description: "FAIL: \(message)") }
|
||||
}
|
||||
|
||||
static func reject(_ operation: () throws -> Void) throws {
|
||||
do { try operation() }
|
||||
catch is Failure { throw Failure(description: "FAIL: rejection assertion") }
|
||||
catch { return }
|
||||
throw Failure(description: "FAIL: malformed input accepted")
|
||||
}
|
||||
|
||||
static let fixture = """
|
||||
WEBVTT example
|
||||
|
||||
NOTE ignored comment
|
||||
not a cue
|
||||
|
||||
STYLE
|
||||
::cue { color: white; }
|
||||
|
||||
REGION
|
||||
id:bottom
|
||||
width:80%
|
||||
|
||||
cue-one
|
||||
00:01.000 --> 00:04.000 line:90% position:50%,center align:center size:80% region:bottom
|
||||
<v Speaker><b>Hello & goodbye</b></v>
|
||||
second <c.yellow>line</c> <3 !
|
||||
|
||||
cue-two
|
||||
00:02.000 --> 00:03.000 vertical:rl
|
||||
Overlap <00:02.500><i>text</i>
|
||||
|
||||
00:04.000 --> 00:05.000
|
||||
Next
|
||||
|
||||
"""
|
||||
|
||||
static func main() async throws {
|
||||
let document = try WebVTTDocument.parse("\u{FEFF}" + fixture.replacingOccurrences(of: "\n", with: "\r\n"))
|
||||
let first = "Hello & goodbye\nsecond line <3 !"
|
||||
try require(document.cues.count == 3, "BOM, CRLF, identifiers and metadata blocks")
|
||||
try require(document.text(at: 0.999).isEmpty, "before first cue")
|
||||
try require(document.text(at: 1) == first, "inclusive start and plain-text markup/entities")
|
||||
try require(document.text(at: 2.5) == first + "\nOverlap text", "simultaneous overlapping cues")
|
||||
try require(document.text(at: 3) == first, "exclusive overlap end")
|
||||
try require(document.text(at: 4) == "Next", "adjacent exclusive end/inclusive start")
|
||||
try require(document.text(at: 10).isEmpty, "after final cue")
|
||||
try require(document.text(at: 2) == first + "\nOverlap text", "backward seek after final cue")
|
||||
try require(document.text(at: .nan).isEmpty && document.text(at: -1).isEmpty, "invalid acknowledged positions")
|
||||
let nested = try WebVTTDocument.parse("WEBVTT\n\n00:00.000 --> 01:00.000\nlong\n\n00:02.000 --> 00:03.000\nshort\n")
|
||||
try require(nested.text(at: 4) == "long", "earlier long cue survives later cue ending")
|
||||
try require(try WebVTTDocument.parse("WEBVTT\n\n01:02:03.004 --> 01:02:04.000\nhour\n").text(at: 3723.004) == "hour", "hour timestamps")
|
||||
let zeroDuration = try WebVTTDocument.parse("WEBVTT\n\n00:01.000 --> 00:01.000\nnever visible\n\n00:02.000 --> 00:03.000\nvalid later cue\n")
|
||||
try require(zeroDuration.text(at: 0.999).isEmpty && zeroDuration.text(at: 1).isEmpty && zeroDuration.text(at: 1.001).isEmpty, "zero-duration cue is never active")
|
||||
try require(zeroDuration.text(at: 2) == "valid later cue", "zero-duration cue does not invalidate later subtitles")
|
||||
try require(zeroDuration.text(at: 4).isEmpty && zeroDuration.text(at: 1).isEmpty, "zero-duration cue stays inactive after backward seek")
|
||||
for malformed in [
|
||||
"not VTT", "WEBVTT\n00:01.000 --> 00:02.000\nmissing separator",
|
||||
"WEBVTT\n\n00:61.000 --> 00:62.000\nbad time",
|
||||
"WEBVTT\n\n00:02.000 --> 00:01.000\nreversed interval",
|
||||
"WEBVTT\n\n00:01.000 --> 00:02.000 size:101%\nbad setting",
|
||||
"WEBVTT\n\n00:01.000 --> 00:02.000\n<b unfinished",
|
||||
"WEBVTT\n\norphan identifier", "WEBVTT\n\n00:01.000 --> 00:02.000\ncue\n\nSTYLE\nlate style"
|
||||
] { try reject { _ = try WebVTTDocument.parse(malformed) } }
|
||||
try reject { _ = try WebVTTDocument.parse(String(repeating: "x", count: WebVTTDocument.byteLimit + 1)) }
|
||||
|
||||
let headers = "Authorization: Bearer fixture-secret\r\nReferer: https://provider.example/\r\nX-Track: english\r\n"
|
||||
let request = try WebVTTDocument.request(url: "https://provider.example/track.jpeg", headers: headers)
|
||||
try require(request.value(forHTTPHeaderField: "Authorization") == "Bearer fixture-secret", "resource authorization header")
|
||||
try require(request.value(forHTTPHeaderField: "X-Track") == "english", "resource-specific custom header")
|
||||
let whitespace = try WebVTTDocument.request(url: "https://provider.example/track", headers: "X-Exact: padded \t \r\n")
|
||||
try require(whitespace.value(forHTTPHeaderField: "X-Exact") == " padded \t ", "preserve header value whitespace after generated separator")
|
||||
for malformed in ["X: a\nInjected: value\r\n", "X: a\rInjected: value\r\n", "X: a\r\nX: b\r\n", "X: a", ": a\r\n"] {
|
||||
try reject { _ = try WebVTTDocument.request(url: "https://provider.example/track", headers: malformed) }
|
||||
}
|
||||
for url in ["file:///tmp/track", "https://user:secret@provider.example/track", "https://provider.example/track#fragment"] {
|
||||
try reject { _ = try WebVTTDocument.request(url: url, headers: "") }
|
||||
}
|
||||
let source = URL(string: "https://provider.example/track.jpeg")!
|
||||
try require(WebVTTDocument.permitsRedirect(from: source, to: URL(string: "https://PROVIDER.example:443/other")!), "same origin includes default port")
|
||||
for target in ["https://cdn.example/track", "http://provider.example/track", "https://provider.example:8443/track", "https://user:secret@provider.example/track"] {
|
||||
try require(!WebVTTDocument.permitsRedirect(from: source, to: URL(string: target)!), "cross-origin or credentialed redirect rejection")
|
||||
}
|
||||
|
||||
let server = try SubtitleFixtureServer(body: fixture)
|
||||
defer { server.stop() }
|
||||
let loaded = try await WebVTTDocument.load(url: server.url("/same.jpeg"), headers: headers)
|
||||
try require(loaded.cues == document.cues, "load JPEG-named, image/jpeg VTT via same-origin redirect")
|
||||
let requests = server.requests
|
||||
try require(requests.count == 2 && requests.allSatisfy { $0.contains("Bearer fixture-secret") && $0.contains("english") }, "resource headers preserved only on same origin")
|
||||
do {
|
||||
_ = try await WebVTTDocument.load(url: server.url("/cross.jpeg"), headers: headers)
|
||||
throw Failure(description: "FAIL: cross-origin redirect accepted")
|
||||
} catch is Failure {
|
||||
throw Failure(description: "FAIL: cross-origin redirect accepted")
|
||||
} catch {
|
||||
let message = error.localizedDescription
|
||||
try require(!message.contains("fixture-secret") && !message.contains("127.0.0.1"), "sanitized visible transport error")
|
||||
}
|
||||
try require(server.requests.count == 3, "cross-origin redirect never sends credential-bearing request")
|
||||
do {
|
||||
_ = try await WebVTTDocument.load(url: server.url("/oversized.jpeg"), headers: "")
|
||||
throw Failure(description: "FAIL: oversized response accepted")
|
||||
} catch is Failure { throw Failure(description: "FAIL: oversized response accepted") }
|
||||
catch { }
|
||||
print("External subtitle checks passed")
|
||||
}
|
||||
}
|
||||
|
||||
/// A loopback-only HTTP fixture; no external services, dates, credentials or frameworks.
|
||||
private final class SubtitleFixtureServer: @unchecked Sendable {
|
||||
private let descriptor: Int32
|
||||
private let port: UInt16
|
||||
private let body: String
|
||||
private let lock = NSLock()
|
||||
private var recorded: [String] = []
|
||||
private let ended = DispatchSemaphore(value: 0)
|
||||
|
||||
var requests: [String] {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return recorded
|
||||
}
|
||||
|
||||
init(body: String) throws {
|
||||
self.body = body
|
||||
let descriptor = socket(AF_INET, SOCK_STREAM, 0)
|
||||
self.descriptor = descriptor
|
||||
guard descriptor >= 0 else { throw ExternalSubtitleChecks.Failure(description: "FAIL: fixture socket") }
|
||||
var address = sockaddr_in()
|
||||
address.sin_len = UInt8(MemoryLayout<sockaddr_in>.size)
|
||||
address.sin_family = sa_family_t(AF_INET)
|
||||
address.sin_addr.s_addr = inet_addr("127.0.0.1")
|
||||
let bound = withUnsafePointer(to: &address) {
|
||||
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) { bind(descriptor, $0, socklen_t(MemoryLayout<sockaddr_in>.size)) }
|
||||
}
|
||||
var size = socklen_t(MemoryLayout<sockaddr_in>.size)
|
||||
let named = withUnsafeMutablePointer(to: &address) {
|
||||
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) { getsockname(descriptor, $0, &size) }
|
||||
}
|
||||
guard bound == 0, named == 0, listen(descriptor, 4) == 0 else {
|
||||
close(descriptor)
|
||||
throw ExternalSubtitleChecks.Failure(description: "FAIL: fixture listener")
|
||||
}
|
||||
port = UInt16(bigEndian: address.sin_port)
|
||||
DispatchQueue.global().async { self.serve() }
|
||||
}
|
||||
|
||||
func url(_ path: String) -> String { "http://127.0.0.1:\(port)\(path)" }
|
||||
|
||||
func stop() {
|
||||
shutdown(descriptor, SHUT_RDWR)
|
||||
close(descriptor)
|
||||
ended.wait()
|
||||
}
|
||||
|
||||
private func serve() {
|
||||
defer { ended.signal() }
|
||||
while true {
|
||||
let client = accept(descriptor, nil, nil)
|
||||
guard client >= 0 else { return }
|
||||
var noSignal: Int32 = 1
|
||||
_ = setsockopt(client, SOL_SOCKET, SO_NOSIGPIPE, &noSignal, socklen_t(MemoryLayout<Int32>.size))
|
||||
var buffer = [UInt8](repeating: 0, count: 4096)
|
||||
var data = Data()
|
||||
while data.count < 65536 {
|
||||
let count = recv(client, &buffer, buffer.count, 0)
|
||||
if count <= 0 { break }
|
||||
data.append(contentsOf: buffer.prefix(count))
|
||||
if String(decoding: data, as: UTF8.self).contains("\r\n\r\n") { break }
|
||||
}
|
||||
let request = String(decoding: data, as: UTF8.self)
|
||||
lock.lock()
|
||||
recorded.append(request)
|
||||
lock.unlock()
|
||||
let path = request.split(separator: " ").dropFirst().first ?? ""
|
||||
let response: String
|
||||
switch path {
|
||||
case "/same.jpeg": response = "HTTP/1.1 302 Found\r\nLocation: /track.jpeg\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
|
||||
case "/cross.jpeg": response = "HTTP/1.1 302 Found\r\nLocation: http://localhost:\(port)/track.jpeg\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
|
||||
case "/oversized.jpeg": response = "HTTP/1.1 200 OK\r\nContent-Length: \(WebVTTDocument.byteLimit + 1)\r\nConnection: close\r\n\r\n"
|
||||
default: response = "HTTP/1.1 200 OK\r\nContent-Type: image/jpeg\r\nContent-Length: \(body.utf8.count)\r\nConnection: close\r\n\r\n\(body)"
|
||||
}
|
||||
let bytes = Array(response.utf8)
|
||||
bytes.withUnsafeBytes { raw in
|
||||
var offset = 0
|
||||
while offset < raw.count {
|
||||
let count = send(client, raw.baseAddress!.advanced(by: offset), raw.count - offset, 0)
|
||||
if count <= 0 { break }
|
||||
offset += count
|
||||
}
|
||||
}
|
||||
close(client)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,18 +17,29 @@ struct FFmpegRuntimeChecks {
|
||||
let required = try String(contentsOf: realApp.appendingPathComponent("Contents/Resources/ffmpeg-libraries.txt"), encoding: .utf8)
|
||||
.split(separator: "\n").map(String.init)
|
||||
|
||||
let frameworks = app.appendingPathComponent("Contents/Frameworks", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: frameworks, withIntermediateDirectories: true)
|
||||
require(FFmpegRuntime.error(for: app) != nil, "missing dependency metadata blocks startup")
|
||||
var unavailable = required
|
||||
let family = URL(fileURLWithPath: required[0]).lastPathComponent.split(separator: ".")[0]
|
||||
unavailable[0] = directory.appendingPathComponent("\(family).999999.dylib").path
|
||||
try (unavailable.joined(separator: "\n") + "\n").write(to: manifest, atomically: true, encoding: .utf8)
|
||||
require(FFmpegRuntime.error(for: app) != nil, "an unavailable ABI library blocks startup")
|
||||
try Data("not a dynamic library".utf8).write(to: URL(fileURLWithPath: unavailable[0]))
|
||||
require(FFmpegRuntime.error(for: app) != nil, "an existing but unloadable library also blocks startup")
|
||||
for invalid in [
|
||||
"/" + required[0], "../" + required[0], "subdirectory/" + required[0],
|
||||
"libavformat.dylib", "libavformat..dylib", "libavformat.one.dylib",
|
||||
"libavformat.1.dylib\r", "libavformat.1.dylib\0"
|
||||
] {
|
||||
var entries = required
|
||||
entries[0] = invalid
|
||||
try (entries.joined(separator: "\n") + "\n").write(to: manifest, atomically: true, encoding: .utf8)
|
||||
require(FFmpegRuntime.error(for: app) != nil, "unsafe or unversioned metadata blocks startup")
|
||||
}
|
||||
try Array(repeating: required[0], count: 5).joined(separator: "\n").write(to: manifest, atomically: true, encoding: .utf8)
|
||||
require(FFmpegRuntime.error(for: app) != nil, "duplicated entries cannot omit required library families")
|
||||
try (required.joined(separator: "\n") + "\n").write(to: manifest, atomically: true, encoding: .utf8)
|
||||
require(FFmpegRuntime.error(for: app) == nil, "all recorded native libraries load successfully")
|
||||
require(FFmpegRuntime.error(for: app) != nil, "missing bundled libraries block startup")
|
||||
let brokenLibrary = frameworks.appendingPathComponent(required[0])
|
||||
try Data("not a dynamic library".utf8).write(to: brokenLibrary)
|
||||
require(FFmpegRuntime.error(for: app) != nil, "an existing but unloadable bundled library blocks startup")
|
||||
try FileManager.default.removeItem(at: frameworks)
|
||||
try FileManager.default.copyItem(at: realApp.appendingPathComponent("Contents/Frameworks"), to: frameworks)
|
||||
require(FFmpegRuntime.error(for: app) == nil, "isolated relocated copies of bundled native libraries load successfully")
|
||||
print("FFmpeg runtime checks passed")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
// Exercise the real local seek/decode/presentation loop without worker races.
|
||||
#include "../Sources/Core/player.cpp"
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
|
||||
static void require(bool condition, const char *message) {
|
||||
if (!condition) { std::fprintf(stderr, "FAIL: %s\n", message); std::exit(1); }
|
||||
}
|
||||
static std::vector<uint8_t> pixels(const AVFrame *frame) {
|
||||
const auto format = static_cast<AVPixelFormat>(frame->format);
|
||||
const int size = av_image_get_buffer_size(format, frame->width, frame->height, 1);
|
||||
require(size > 0, "preview has readable pixels");
|
||||
std::vector<uint8_t> bytes(size);
|
||||
require(av_image_copy_to_buffer(bytes.data(), size, frame->data, frame->linesize,
|
||||
format, frame->width, frame->height, 1) == size, "copy preview pixels");
|
||||
return bytes;
|
||||
}
|
||||
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
require(argc > 1, "provide local media fixtures");
|
||||
for (int i = 1; i < argc; ++i) {
|
||||
av_log_set_level(AV_LOG_ERROR);
|
||||
Player player;
|
||||
player.shutdown();
|
||||
player.stopping.store(false);
|
||||
player.activeEpoch = player.requestedEpoch.load();
|
||||
player.open(Command{Action::Open, argv[i]});
|
||||
require(player.tracks[1].codec && player.canSeek, "seekable local video");
|
||||
player.output.set_volume(0);
|
||||
for (bool playing : {false, true}) {
|
||||
for (double fraction : {.01, .79, .21, .59}) {
|
||||
const double target = player.duration * fraction;
|
||||
// Full reference decoding supplies an independent expected picture,
|
||||
// including disposable B-frames at or immediately after the target.
|
||||
player.seek(target, false);
|
||||
VideoFrame expected{};
|
||||
const auto referenceStart = Clock::now();
|
||||
while (!expected.frame && Clock::now() - referenceStart < std::chrono::seconds(5)) {
|
||||
player.decodeOne(true);
|
||||
while (!player.video.empty()) {
|
||||
auto frame = std::move(player.video.front());
|
||||
player.videoBytes -= frame.bytes;
|
||||
player.video.pop_front();
|
||||
if (frame.pts + sampleTime * .5 >= target) {
|
||||
expected = std::move(frame);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
require(bool(expected.frame), "full decode supplies reference picture");
|
||||
const auto start = Clock::now();
|
||||
player.seek(target, playing);
|
||||
while ((!player.presented.frame || player.presented.pts < target - .002) &&
|
||||
Clock::now() - start < std::chrono::seconds(5))
|
||||
player.tick();
|
||||
const double elapsed = std::chrono::duration<double, std::milli>(Clock::now() - start).count();
|
||||
std::printf("local-seek fixture=%s playing=%d target=%.3f preview=%.3f latency_ms=%.3f\n",
|
||||
argv[i], playing, target, player.presented.pts, elapsed);
|
||||
require(player.presented.frame && player.presented.pts == expected.pts &&
|
||||
pixels(player.presented.frame.get()) == pixels(expected.frame.get()),
|
||||
"seek presents the exact fully decoded picture, including B-frame targets");
|
||||
require(player.status == (playing ? PLAYER_PLAYING : PLAYER_PAUSED),
|
||||
"seek preserves playback intent");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+225
-1
@@ -1,6 +1,9 @@
|
||||
#include "../Sources/Core/player_macos.h"
|
||||
#import <AppKit/AppKit.h>
|
||||
#import <MetalKit/MetalKit.h>
|
||||
extern "C" {
|
||||
#include <libavutil/log.h>
|
||||
}
|
||||
#import <dispatch/dispatch.h>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
@@ -135,7 +138,9 @@ static bool openNetwork(Player *player, const std::string &base, const std::stri
|
||||
}
|
||||
|
||||
static void closed(Player *player) {
|
||||
auto state = until(player, [](auto s) { return s.status == PLAYER_EMPTY; }, "close network source");
|
||||
// A close submitted from PLAYER_ERROR may still publish the previous error
|
||||
// until the worker acknowledges it; require the complete clean state below.
|
||||
auto state = until(player, [](auto s) { return s.status == PLAYER_EMPTY; }, "close network source", true);
|
||||
require(state.position == 0 && state.duration == 0 && !state.has_audio && !state.has_video &&
|
||||
!state.can_seek && !state.buffering && !state.seek_pending && !state.error[0],
|
||||
"close clears all network/media/seek/error state");
|
||||
@@ -287,6 +292,94 @@ static void nativeHLS(Player *player, MTKView *view, const std::string &base) {
|
||||
std::puts("PASS native HLS: selected headers/recorded seekability/paused seek/audio-video resume");
|
||||
}
|
||||
|
||||
static bool openResolved(Player *player, const std::string &base, const std::string &scenario,
|
||||
bool credentials = true) {
|
||||
const auto url = base + "/resolved/" + scenario + "/master.m3u8?token=part%2Fone+two%26tail";
|
||||
const std::string headers = credentials ?
|
||||
"X-Terebi-Fixture: combined\r\nAuthorization: Bearer resolved-header-secret\r\n" :
|
||||
"X-Terebi-Fixture: combined\r\n";
|
||||
PlayerNetworkSource source{url.c_str(), nullptr, headers.c_str(), nullptr,
|
||||
4, false, "miruro:fixture_opaque-ID:1", 0};
|
||||
return player_open_network(player, &source);
|
||||
}
|
||||
|
||||
static void resolvedHLS(Player *player, MTKView *view, const std::string &base) {
|
||||
for (const std::string scenario : {"encrypted", "fmp4", "image-encrypted", "image-fmp4"}) {
|
||||
require(openResolved(player, base, scenario), "resolved HLS asynchronous open accepted");
|
||||
require(player_play(player), "resolved HLS play accepted");
|
||||
auto state = until(player, [](auto s) {
|
||||
return s.status == PLAYER_PLAYING && !s.buffering && s.position > .2;
|
||||
}, "resolved HLS audio/video decode");
|
||||
require(state.has_audio && state.has_video && state.can_seek,
|
||||
"encrypted/fMP4 HLS provides decoded audio, video and seekability");
|
||||
require(player_pause(player), "pause resolved HLS");
|
||||
until(player, [](auto s) { return s.status == PLAYER_PAUSED; }, "resolved HLS pause");
|
||||
seekCompleted(player, 2.5, PLAYER_PAUSED);
|
||||
auto revision = player_get_state(player).revision;
|
||||
require(player_step_frame(player, 1), "step an actually decoded HLS video frame after seek");
|
||||
state = until(player, [revision](auto s) {
|
||||
return s.status == PLAYER_PAUSED && !s.seek_pending && s.revision != revision && s.position > 2.5;
|
||||
}, "resolved HLS decoded frame acknowledgement");
|
||||
require(state.position < 2.6, "HLS seek selects the adjacent 24fps decoded frame");
|
||||
[view draw];
|
||||
require(player_play(player), "resume resolved HLS");
|
||||
until(player, [](auto s) { return s.status == PLAYER_PLAYING && s.position > 2.65; },
|
||||
"resolved HLS audio clock resumes after frame step");
|
||||
seekCompleted(player, 1.25, PLAYER_PLAYING);
|
||||
until(player, [](auto s) { return s.status == PLAYER_PLAYING && s.position > 1.4; },
|
||||
"resolved HLS playing backward seek");
|
||||
require(player_close(player), "close resolved HLS");
|
||||
closed(player);
|
||||
NSDictionary *source = control(base, "/control/resolved-stats")[@"sources"]
|
||||
[[NSString stringWithUTF8String:scenario.c_str()]];
|
||||
for (NSString *kind in @[@"master", @"variant", @"segment",
|
||||
scenario.ends_with("encrypted") ? @"key" : @"init"]) {
|
||||
require([source[kind][@"requests"] unsignedLongLongValue] > 0 &&
|
||||
[source[kind][@"denied"] unsignedLongLongValue] == 0,
|
||||
"required headers and unmodified query token reach every HLS resource class");
|
||||
}
|
||||
}
|
||||
|
||||
for (const std::string scenario : {"redirect-master", "redirect-variant", "redirect-segment",
|
||||
"redirect-key", "redirect-init", "redirect-same-master",
|
||||
"external-variant", "external-segment", "external-key", "external-init",
|
||||
"missing"}) {
|
||||
require(openResolved(player, base, scenario, scenario != "missing"), "unsafe HLS request accepted asynchronously");
|
||||
auto state = until(player, [](auto s) { return s.status == PLAYER_ERROR; },
|
||||
"unsafe HLS transport rejected", true);
|
||||
require(state.error[0] && std::string(state.error).find("http") == std::string::npos &&
|
||||
std::string(state.error).find("resolved-header-secret") == std::string::npos,
|
||||
"application retains meaningful transport error without credentials or URLs");
|
||||
if (scenario.starts_with("redirect-")) {
|
||||
const auto kind = scenario == "redirect-same-master" ? "master" : scenario.substr(9);
|
||||
NSDictionary *source = control(base, "/control/resolved-stats")[@"sources"]
|
||||
[[NSString stringWithUTF8String:scenario.c_str()]];
|
||||
require([source[[NSString stringWithUTF8String:kind.c_str()]][@"redirects"] unsignedLongLongValue] > 0,
|
||||
"redirect isolation actually exercises each HLS redirect resource");
|
||||
}
|
||||
if (scenario == "missing") {
|
||||
NSDictionary *source = control(base, "/control/resolved-stats")[@"sources"][@"missing"];
|
||||
require([source[@"master"][@"denied"] unsignedLongLongValue] > 0,
|
||||
"fixture explicitly fails a request missing a required credential");
|
||||
}
|
||||
require(player_close(player), "close rejected resolved source");
|
||||
closed(player);
|
||||
}
|
||||
NSDictionary *sink = control(base, "/control/resolved-stats")[@"sink"];
|
||||
require([sink[@"requests"] unsignedLongLongValue] == 0 &&
|
||||
[sink[@"credentials"] unsignedLongLongValue] == 0,
|
||||
"neither redirects nor explicit foreign HLS URLs contact the unrelated origin");
|
||||
require(openNetwork(player, base, "/youtube-redirect/av.mp4", "", 4, false, true,
|
||||
"https://www.youtube.com/watch?v=AAAAAAAAAAA"),
|
||||
"YouTube selected source retains ordinary HTTP redirect behavior");
|
||||
require(player_play(player), "redirected YouTube fixture play");
|
||||
until(player, [](auto s) { return s.status == PLAYER_PLAYING && s.position > .2 && s.has_video && s.has_audio; },
|
||||
"YouTube transport unchanged by resolved source restrictions");
|
||||
require(player_close(player), "close redirected YouTube fixture");
|
||||
closed(player);
|
||||
std::puts("PASS resolved HLS: AES-128/fMP4 audio+video decode/paused+playing seek/frame step/all resource credentials/token preservation/redirect and foreign-origin isolation/redacted errors/YouTube redirects unchanged");
|
||||
}
|
||||
|
||||
static void liveAndReplacement(Player *player, const std::string &base, const std::string &fixtures) {
|
||||
require(openNetwork(player, base, "/media/av.mp4", "", 4, true), "live-flag network open");
|
||||
auto live = until(player, [](auto s) { return s.status == PLAYER_PAUSED && buffered(s); },
|
||||
@@ -336,6 +429,120 @@ static void prefixBound(PlayerState state, GateState gate, bool splitAudio = fal
|
||||
"retained coverage cannot bridge withheld audio/video packets or reordered B-frames");
|
||||
}
|
||||
|
||||
static void reconnection(Player *player, const std::string &base) {
|
||||
for (int input : {0, 1, 2}) {
|
||||
const std::string token = "disconnect-" + std::to_string(input);
|
||||
const auto interrupted = "/disconnect/" + token + "/cache.mp4?after_seconds=8";
|
||||
require(openNetwork(player, base, input == 1 ? "/media/cache.mp4" : interrupted,
|
||||
input == 0 ? "" : input == 1 ? interrupted : "/media/cache.mp4", 120),
|
||||
"open media whose HTTP response will disconnect after a playable prefix");
|
||||
require(player_play(player), "play the source before its connection drops");
|
||||
auto before = until(player, [&](auto s) {
|
||||
return s.status == PLAYER_PLAYING && s.position > .1 && buffered(s) &&
|
||||
gateState(base, token).entered;
|
||||
}, "playback and retained packets exist before the disconnect");
|
||||
if (input == 0) {
|
||||
require(player_pause(player), "pause while the original HTTP response is blocked");
|
||||
before = until(player, [](auto s) { return s.status == PLAYER_PAUSED; },
|
||||
"pause acknowledged before reconnect");
|
||||
}
|
||||
control(base, "/control/release/" + token);
|
||||
auto recovered = until(player, [&](auto s) {
|
||||
require(s.position + .001 >= before.position, "reconnection never resets the playhead");
|
||||
if (input == 0)
|
||||
require(s.status == PLAYER_PAUSED && std::abs(s.position - before.position) < .001,
|
||||
"reconnection does not resume paused playback");
|
||||
require(s.has_audio && s.has_video && s.can_seek && !s.seek_pending,
|
||||
"reconnection preserves ready media and its seekability");
|
||||
return buffered(s) && s.buffered_end - s.position >= 59;
|
||||
}, "same-source reconnect replenishes the minute reserve");
|
||||
require(recovered.buffered_start <= before.position,
|
||||
"reconnection retains the previously buffered timeline");
|
||||
seekCompleted(player, 6, input == 0 ? PLAYER_PAUSED : PLAYER_PLAYING);
|
||||
seekCompleted(player, 30, input == 0 ? PLAYER_PAUSED : PLAYER_PLAYING);
|
||||
require(player_play(player), "play across packets fetched after the disconnect");
|
||||
until(player, [](auto s) { return s.status == PLAYER_PLAYING && s.position > 30.1; },
|
||||
"decoded audio/video continues beyond the original HTTP prefix");
|
||||
require(player_close(player), "close reconnected media");
|
||||
closed(player);
|
||||
}
|
||||
std::puts("PASS disconnected combined/secondary-audio/primary-video inputs: retained buffer/paused+playing intent/range resume/decoded continuation");
|
||||
}
|
||||
|
||||
static void socketTimeout(Player *player, const std::string &base) {
|
||||
const std::string token = "socket-timeout";
|
||||
require(openNetwork(player, base, "/stall/" + token + "/cache.mp4?after_seconds=8", "", 120),
|
||||
"open a source whose original socket never supplies the remaining body");
|
||||
require(player_play(player), "play the prefix before socket timeout");
|
||||
until(player, [&](auto s) {
|
||||
return s.status == PLAYER_PLAYING && s.position > .1 && gateState(base, token).entered;
|
||||
}, "playable prefix precedes timed-out fetch");
|
||||
require(player_pause(player), "pause before socket timeout");
|
||||
const auto paused = until(player, [](auto s) { return s.status == PLAYER_PAUSED; },
|
||||
"native pause before timeout recovery");
|
||||
until(player, [&](auto s) {
|
||||
require(s.status == PLAYER_PAUSED && std::abs(s.position - paused.position) < .001,
|
||||
"socket timeout recovery preserves paused intent and position");
|
||||
return buffered(s) && s.buffered_end - s.position >= 59;
|
||||
}, "socket timeout reconnects within the total I/O deadline");
|
||||
stillBlocked(base, token);
|
||||
require(player_play(player), "resume after socket timeout recovery");
|
||||
seekCompleted(player, 30, PLAYER_PLAYING);
|
||||
until(player, [](auto s) { return s.status == PLAYER_PLAYING && s.position > 30.1; },
|
||||
"timeout-recovered media decodes beyond the withheld prefix");
|
||||
require(player_close(player), "close timeout-recovered source");
|
||||
closed(player);
|
||||
std::puts("PASS socket timeout: reconnect before operation deadline/retained buffer/paused intent/decoded continuation");
|
||||
}
|
||||
|
||||
static void failedReconnect(Player *player, const std::string &base, const std::string &token) {
|
||||
require(openNetwork(player, base, "/disconnect/" + token +
|
||||
"/cache.mp4?after_seconds=8&fail_reconnect=1", "", 120),
|
||||
"open a source with a playable prefix and persistently broken resumed responses");
|
||||
require(player_play(player), "play before persistent disconnect");
|
||||
until(player, [&](auto s) {
|
||||
return s.status == PLAYER_PLAYING && s.position > .1 && gateState(base, token).entered;
|
||||
}, "playback starts before persistent disconnect");
|
||||
control(base, "/control/release/" + token);
|
||||
until(player, [&](auto s) {
|
||||
return s.status == PLAYER_PLAYING && s.has_audio && s.has_video &&
|
||||
gateState(base, token).requests > 1;
|
||||
}, "transport is retrying while the retained prefix remains playable");
|
||||
}
|
||||
|
||||
static void retryFailures(Player *player, const std::string &base, const std::string &fixtures) {
|
||||
failedReconnect(player, base, "retry-exhaustion");
|
||||
const auto failed = until(player, [](auto s) { return s.status == PLAYER_ERROR; },
|
||||
"persistent read failure exhausts its bounded retries", true);
|
||||
require(failed.error[0] && !failed.has_video && !failed.has_audio && !failed.buffering &&
|
||||
!failed.can_seek && !failed.seek_pending,
|
||||
"exhausted retries surface an error instead of falsely completing media or hanging");
|
||||
for (int action : {0, 1, 2}) {
|
||||
failedReconnect(player, base, "retry-cancellation-" + std::to_string(action));
|
||||
if (action == 0) {
|
||||
seekCompleted(player, 30, PLAYER_PLAYING);
|
||||
until(player, [](auto s) { return s.status == PLAYER_PLAYING && s.position > 30.1; },
|
||||
"uncached seek escapes the failed reconnect offset");
|
||||
} else if (action == 2) {
|
||||
require(player_open(player, (fixtures + "/audio.wav").c_str()),
|
||||
"local replacement interrupts the retry");
|
||||
const auto replacement = until(player, [](auto s) {
|
||||
return s.status == PLAYER_PAUSED && s.has_audio && !s.has_video;
|
||||
}, "replacement is not blocked by retry backoff");
|
||||
require(replacement.position == 0 && !replacement.seek_pending && !replacement.buffering,
|
||||
"stale retry does not overwrite replacement state");
|
||||
}
|
||||
require(player_close(player), "close interrupts retries and recovered media");
|
||||
closed(player);
|
||||
}
|
||||
std::unique_ptr<Player, decltype(&player_destroy)> victim(player_create(), player_destroy);
|
||||
require(bool(victim), "create a player for destruction during retry");
|
||||
require(player_set_volume(victim.get(), 0), "mute destruction scenario");
|
||||
failedReconnect(victim.get(), base, "retry-destruction");
|
||||
victim.reset(); // The process watchdog catches a join stuck in retry backoff.
|
||||
std::puts("PASS exhausted reconnects: visible failure/uncached seek/close/replacement/destruction cancellation");
|
||||
}
|
||||
|
||||
static void cachedWindow(Player *player, const std::string &base) {
|
||||
const std::string token = "cached-window";
|
||||
require(openNetwork(player, base, "/stall/" + token + "/cache.mp4?after_seconds=100", "", 120),
|
||||
@@ -606,6 +813,18 @@ static void reporting(const std::string &base, const std::string &fixtures) {
|
||||
std::abs([rows[0][@"position_seconds"] doubleValue] - 50) < .03 &&
|
||||
[rows[0][@"completion_eligible"] boolValue],
|
||||
"initial report is restored native position, not pre-resume zero");
|
||||
PlayerState receiver = player_get_state(player.get());
|
||||
receiver.position = 114;
|
||||
receiver.status = PLAYER_PAUSED;
|
||||
require(player_report_remote_state(player.get(), &receiver), "receiver-confirmed progress accepted");
|
||||
require(player_close(player.get()), "close immediately after receiver progress");
|
||||
until(player.get(), [&](auto state) {
|
||||
auto current = progressRecords(path);
|
||||
return state.status == PLAYER_EMPTY && [current.lastObject[@"event"] isEqualToString:@"closed"] &&
|
||||
[current.lastObject[@"position_seconds"] doubleValue] == 114;
|
||||
}, "closing drains final Cast position instead of abandoned decoder position");
|
||||
require([progressRecords(path).lastObject[@"completion_eligible"] boolValue],
|
||||
"receiver-confirmed video remains completion eligible");
|
||||
require(openNetwork(player.get(), base, "/failure/av.mp4", "", 4, false, true, second),
|
||||
"failed requested identity accepted asynchronously");
|
||||
until(player.get(), [](auto state) { return state.status == PLAYER_ERROR; }, "failed reported open", true);
|
||||
@@ -684,6 +903,7 @@ int main(int argc, char **argv) {
|
||||
require(argc == 3, "loopback base URL and local fixture directory required");
|
||||
std::string base = argv[1], fixtures = argv[2];
|
||||
require(base.rfind("http://127.0.0.1:", 0) == 0, "network fixture server is loopback-only");
|
||||
av_log_set_level(AV_LOG_TRACE); // Diagnostics must be safe even at maximal FFmpeg verbosity.
|
||||
[NSApplication sharedApplication];
|
||||
reporting(base, fixtures);
|
||||
std::unique_ptr<Player, decltype(&player_destroy)> player(player_create(), player_destroy);
|
||||
@@ -696,12 +916,16 @@ int main(int argc, char **argv) {
|
||||
styleMask:NSWindowStyleMaskBorderless backing:NSBackingStoreBuffered defer:NO];
|
||||
window.contentView = view;
|
||||
[window orderBack:nil];
|
||||
reconnection(player.get(), base);
|
||||
socketTimeout(player.get(), base);
|
||||
retryFailures(player.get(), base, fixtures);
|
||||
chunkedFetch(player.get(), base, false);
|
||||
chunkedFetch(player.get(), base, true);
|
||||
transport(player.get(), view, base, false);
|
||||
transport(player.get(), view, base, true);
|
||||
frameSteps(player.get(), view, base);
|
||||
nativeHLS(player.get(), view, base);
|
||||
resolvedHLS(player.get(), view, base);
|
||||
liveAndReplacement(player.get(), base, fixtures);
|
||||
cachedWindow(player.get(), base);
|
||||
bufferCoverage(player.get(), base);
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
#include "../Sources/Core/player_macos.h"
|
||||
#import <AppKit/AppKit.h>
|
||||
#import <MetalKit/MetalKit.h>
|
||||
extern "C" {
|
||||
#include <libavutil/log.h>
|
||||
}
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <functional>
|
||||
#include <iostream>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <sys/resource.h>
|
||||
|
||||
using Clock = std::chrono::steady_clock;
|
||||
|
||||
static void require(bool condition, const char *message) {
|
||||
if (!condition) {
|
||||
std::fprintf(stderr, "FAIL native benchmark: %s\n", message);
|
||||
std::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
static void emit(NSDictionary *row) {
|
||||
NSError *error = nil;
|
||||
NSData *json = [NSJSONSerialization dataWithJSONObject:row options:0 error:&error];
|
||||
require(json != nil, "encode JSON result");
|
||||
require(std::fwrite(json.bytes, 1, json.length, stdout) == json.length &&
|
||||
std::fputc('\n', stdout) != EOF && std::fflush(stdout) == 0, "write JSON result");
|
||||
// The runner snapshots upstream counters before acknowledging each boundary.
|
||||
// Preparation is excluded; read-ahead concurrent with a measured phase is included.
|
||||
std::string acknowledgement;
|
||||
require(bool(std::getline(std::cin, acknowledgement)) && acknowledgement.empty(),
|
||||
"runner must acknowledge each JSON boundary with an empty line");
|
||||
}
|
||||
|
||||
struct Stamp {
|
||||
Clock::time_point wall;
|
||||
rusage usage;
|
||||
};
|
||||
|
||||
static Stamp stamp() {
|
||||
Stamp result{Clock::now(), {}};
|
||||
require(getrusage(RUSAGE_SELF, &result.usage) == 0, "read process resource usage");
|
||||
return result;
|
||||
}
|
||||
|
||||
static double cpuMilliseconds(const rusage &usage) {
|
||||
return (usage.ru_utime.tv_sec + usage.ru_stime.tv_sec) * 1000.0 +
|
||||
(usage.ru_utime.tv_usec + usage.ru_stime.tv_usec) / 1000.0;
|
||||
}
|
||||
|
||||
static void begin(NSString *phase) {
|
||||
emit(@{@"event": @"begin", @"phase": phase});
|
||||
}
|
||||
|
||||
static void finish(NSString *phase, const Stamp &start, const Stamp &end,
|
||||
PlayerState state, NSDictionary *details) {
|
||||
NSMutableDictionary *row = [@{
|
||||
@"phase": phase,
|
||||
@"wall_ms": @(std::chrono::duration<double, std::milli>(end.wall - start.wall).count()),
|
||||
@"cpu_ms": @(cpuMilliseconds(end.usage) - cpuMilliseconds(start.usage)),
|
||||
// Darwin reports ru_maxrss in bytes. This is process-lifetime high-water RSS,
|
||||
// not a per-phase delta; CPU includes every native decoder/output thread.
|
||||
@"peak_rss_mib": @(end.usage.ru_maxrss / (1024.0 * 1024.0)),
|
||||
@"media_seconds": @0,
|
||||
@"output_bytes": @0,
|
||||
@"position_seconds": @(state.position),
|
||||
@"buffered_start": @(state.buffered_start),
|
||||
@"buffered_end": @(state.buffered_end)
|
||||
} mutableCopy];
|
||||
[row addEntriesFromDictionary:details];
|
||||
emit(row);
|
||||
}
|
||||
|
||||
static PlayerState until(Player *player, const std::function<bool(PlayerState)> &ready,
|
||||
const char *phase, MTKView *view = nil) {
|
||||
const auto deadline = Clock::now() + std::chrono::seconds(60);
|
||||
auto nextDraw = Clock::now();
|
||||
while (Clock::now() < deadline) {
|
||||
const auto state = player_get_state(player);
|
||||
if (state.status == PLAYER_ERROR) {
|
||||
std::fprintf(stderr, "FAIL native benchmark %s: %s\n", phase, state.error);
|
||||
std::exit(1);
|
||||
}
|
||||
// Exercise the real Metal renderer at the fixture's 30 fps, not at the
|
||||
// 1 kHz state-poll rate. Manual draws also work for this offscreen window.
|
||||
if (view && Clock::now() >= nextDraw) {
|
||||
[view draw];
|
||||
nextDraw = Clock::now() + std::chrono::duration_cast<Clock::duration>(
|
||||
std::chrono::duration<double>(1.0 / 30));
|
||||
}
|
||||
if (ready(state)) return state;
|
||||
CFRunLoopRunInMode(kCFRunLoopDefaultMode, .001, false);
|
||||
}
|
||||
const auto state = player_get_state(player);
|
||||
std::fprintf(stderr,
|
||||
"FAIL native benchmark %s: deadline status=%d position=%.6f duration=%.6f "
|
||||
"pending=%d buffering=%d cache=%.6f..%.6f\n", phase, state.status, state.position,
|
||||
state.duration, state.seek_pending, state.buffering, state.buffered_start, state.buffered_end);
|
||||
std::exit(1);
|
||||
}
|
||||
|
||||
static bool pausedReady(PlayerState state) {
|
||||
return state.status == PLAYER_PAUSED && !state.seek_pending && !state.buffering &&
|
||||
state.has_audio && state.has_video && state.can_seek;
|
||||
}
|
||||
|
||||
static PlayerState seek(Player *player, double target, const char *phase) {
|
||||
const auto revision = player_get_state(player).revision;
|
||||
require(player_seek(player, target), "paused seek accepted");
|
||||
return until(player, [=](auto state) {
|
||||
// !buffering is important: seek_pending alone acknowledges reader/command
|
||||
// work, not decoded preview readiness. The paused tick clears buffering
|
||||
// only after presenting a decoded frame and supplying initial audio.
|
||||
return pausedReady(state) && state.revision != revision &&
|
||||
std::abs(state.position - target) < .05;
|
||||
}, phase);
|
||||
}
|
||||
|
||||
static bool cached(PlayerState state, double target) {
|
||||
// Leave room for the preceding two-second GOP and following audio/video.
|
||||
return pausedReady(state) && state.buffered_end > state.buffered_start &&
|
||||
state.buffered_start <= target - 2 && state.buffered_end >= target + 1;
|
||||
}
|
||||
|
||||
static bool uncached(PlayerState state, double target) {
|
||||
return pausedReady(state) && state.buffered_end > state.buffered_start &&
|
||||
target > state.buffered_end + 2;
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
@autoreleasepool {
|
||||
require(argc == 3 || (argc == 4 && std::string(argv[3]) == "--seek-work"),
|
||||
"usage: playback-benchmark <loopback-base-url> <split|hls> [--seek-work]");
|
||||
const bool seekWork = argc == 4;
|
||||
const std::string base = argv[1], mode = argv[2];
|
||||
require(base.rfind("http://127.0.0.1:", 0) == 0 && base.size() > 17 && base.size() <= 22 &&
|
||||
base.find_first_not_of("0123456789", 17) == std::string::npos &&
|
||||
std::strtoul(base.c_str() + 17, nullptr, 10) > 0 &&
|
||||
std::strtoul(base.c_str() + 17, nullptr, 10) <= 65535,
|
||||
"fixture base URL must be an http://127.0.0.1:<port> origin");
|
||||
require(mode == "split" || mode == "hls", "source must be split or hls");
|
||||
const bool split = mode == "split";
|
||||
const std::string video = base + (split ? "/media/bench-video.mp4" : "/media/bench.m3u8");
|
||||
const std::string audio = split ? base + "/media/bench-audio.m4a" : "";
|
||||
PlayerNetworkSource source{video.c_str(), split ? audio.c_str() : nullptr,
|
||||
split ? "X-Terebi-Fixture: video\r\n" : "X-Terebi-Fixture: combined\r\n",
|
||||
split ? "X-Terebi-Fixture: audio\r\n" : nullptr,
|
||||
120, false, split ? nullptr : "miruro:YmVuY2g:1", 0};
|
||||
av_log_set_level(AV_LOG_ERROR);
|
||||
[NSApplication sharedApplication];
|
||||
|
||||
// Cold start follows queued autoplay through the first real audio-clock
|
||||
// advancement and pause. Buffering gates audio and decoded video readiness.
|
||||
// It includes player/audio/Metal setup, but excludes process launch and
|
||||
// fixture creation; it is NOT just the initial PLAYER_PAUSED publication.
|
||||
begin(@"cold_start");
|
||||
const auto coldStart = stamp();
|
||||
std::unique_ptr<Player, decltype(&player_destroy)> player(player_create(), player_destroy);
|
||||
require(bool(player), "create player");
|
||||
require(player_set_volume(player.get(), 0), "mute real native audio output");
|
||||
void *native = player_macos_make_video_view(player.get());
|
||||
require(native != nullptr, "initialize native Metal view");
|
||||
MTKView *view = (__bridge_transfer MTKView *)native;
|
||||
view.paused = YES;
|
||||
view.autoResizeDrawable = NO;
|
||||
view.drawableSize = CGSizeMake(1280, 720);
|
||||
NSWindow *window = [[NSWindow alloc] initWithContentRect:NSMakeRect(-4000, -4000, 1280, 720)
|
||||
styleMask:NSWindowStyleMaskBorderless backing:NSBackingStoreBuffered defer:NO];
|
||||
window.contentView = view;
|
||||
[window orderBack:nil];
|
||||
require(player_open_network(player.get(), &source), "accept network source");
|
||||
require(player_play(player.get()), "queue autoplay during network open");
|
||||
until(player.get(), [](auto state) {
|
||||
return state.status == PLAYER_PLAYING && !state.buffering && state.position >= .05;
|
||||
}, "initial real playback readiness", view);
|
||||
require(view.currentDrawable != nil, "initial Metal drawable");
|
||||
[view draw];
|
||||
require(player_pause(player.get()), "pause initial playback probe");
|
||||
auto state = until(player.get(), pausedReady, "initial playback probe paused", view);
|
||||
const auto coldEnd = stamp();
|
||||
finish(@"cold_start", coldStart, coldEnd, state,
|
||||
@{@"readiness": @"queued_autoplay_then_audio_clock_probe_and_pause",
|
||||
@"probe_media_seconds": @(state.position), @"render_width": @1280, @"render_height": @720});
|
||||
|
||||
constexpr double hitTarget = 20.5, missTarget = 100.5;
|
||||
auto before = state;
|
||||
if (seekWork) {
|
||||
// Preparation is outside the measured phase. Seek directly from the
|
||||
// opening paused position, without the cached seek or paced playback.
|
||||
until(player.get(), [](auto s) {
|
||||
return pausedReady(s) && s.buffered_end >= 59;
|
||||
}, "initial read-ahead coverage");
|
||||
} else {
|
||||
// Waiting for retained packet coverage is preparation, not seek latency.
|
||||
until(player.get(), [](auto s) { return cached(s, hitTarget); }, "cached target coverage");
|
||||
begin(@"cached_seek");
|
||||
before = player_get_state(player.get());
|
||||
require(cached(before, hitTarget), "cached seek target and GOP must remain inside retained coverage");
|
||||
const auto hitStart = stamp();
|
||||
state = seek(player.get(), hitTarget, "cached paused decoded seek");
|
||||
[view draw];
|
||||
const auto hitEnd = stamp();
|
||||
finish(@"cached_seek", hitStart, hitEnd, state,
|
||||
@{@"target_seconds": @(hitTarget), @"cache_before_start": @(before.buffered_start),
|
||||
@"cache_before_end": @(before.buffered_end), @"cache_precondition": @"retained_packet_hit"});
|
||||
|
||||
// Sustained is real paced playback from the cached seek, including
|
||||
// moving-frontier read-ahead, start and final pause acknowledgement.
|
||||
// Position is the actual native audio-master clock. Only watchdogs impose
|
||||
// elapsed-time limits; the completion condition is twelve media seconds.
|
||||
begin(@"sustained");
|
||||
before = player_get_state(player.get());
|
||||
require(pausedReady(before) && std::abs(before.position - hitTarget) < .05 &&
|
||||
before.duration > before.position + 12, "sustained starts at acknowledged cached target");
|
||||
const auto sustainedStart = stamp();
|
||||
auto previousPoll = sustainedStart.wall;
|
||||
bool wasBuffering = true;
|
||||
double bufferingMilliseconds = 0;
|
||||
require(player_play(player.get()), "start paced sustained playback");
|
||||
state = until(player.get(), [&](auto s) {
|
||||
const auto now = Clock::now();
|
||||
if (wasBuffering)
|
||||
bufferingMilliseconds += std::chrono::duration<double, std::milli>(now - previousPoll).count();
|
||||
previousPoll = now;
|
||||
wasBuffering = s.buffering;
|
||||
return s.status == PLAYER_PLAYING && !s.seek_pending && !s.buffering &&
|
||||
s.position >= before.position + 12;
|
||||
}, "twelve media seconds of native playback", view);
|
||||
require(player_pause(player.get()), "pause sustained playback");
|
||||
state = until(player.get(), pausedReady, "sustained pause acknowledgement", view);
|
||||
const auto sustainedEnd = stamp();
|
||||
finish(@"sustained", sustainedStart, sustainedEnd, state,
|
||||
@{@"media_seconds": @(state.position - before.position), @"paced": @YES,
|
||||
@"start_position_seconds": @(before.position), @"buffering_ms": @(bufferingMilliseconds),
|
||||
@"buffering_measurement": @"sampled_at_approximately_1ms"});
|
||||
}
|
||||
|
||||
begin(@"uncached_seek");
|
||||
before = player_get_state(player.get());
|
||||
require(uncached(before, missTarget), "uncached seek must lie beyond retained packet coverage");
|
||||
const auto missStart = stamp();
|
||||
state = seek(player.get(), missTarget, "uncached paused decoded seek");
|
||||
[view draw];
|
||||
const auto missEnd = stamp();
|
||||
finish(@"uncached_seek", missStart, missEnd, state,
|
||||
@{@"target_seconds": @(missTarget), @"cache_before_start": @(before.buffered_start),
|
||||
@"cache_before_end": @(before.buffered_end), @"cache_precondition": @"outside_retained_packets"});
|
||||
[window orderOut:nil];
|
||||
window.contentView = nil;
|
||||
player.reset();
|
||||
}
|
||||
}
|
||||
Executable
+194
@@ -0,0 +1,194 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Measure real native/Cast source paths; no resolver, WAN, TLS or physical receiver."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import platform
|
||||
import statistics
|
||||
import subprocess
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
from streaming import StreamingServer
|
||||
|
||||
|
||||
def fixtures(directory: Path):
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
if (directory / "bench.m3u8").is_file():
|
||||
return
|
||||
ffmpeg = ["ffmpeg", "-hide_banner", "-loglevel", "error", "-y"]
|
||||
combined = directory / "bench.mp4"
|
||||
subprocess.run(ffmpeg + [
|
||||
"-f", "lavfi", "-i", "testsrc2=size=1280x720:rate=30:duration=120",
|
||||
"-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000:duration=120",
|
||||
"-c:v", "libx264", "-preset", "ultrafast", "-threads", "2", "-bf", "2",
|
||||
"-g", "60", "-keyint_min", "60", "-sc_threshold", "0", "-pix_fmt", "yuv420p",
|
||||
"-c:a", "aac", "-movflags", "+faststart", str(combined),
|
||||
], check=True, timeout=180)
|
||||
for track, name in (("0:v:0", "bench-video.mp4"), ("0:a:0", "bench-audio.m4a")):
|
||||
subprocess.run(ffmpeg + ["-i", str(combined), "-map", track, "-c", "copy",
|
||||
"-movflags", "+faststart", str(directory / name)], check=True, timeout=60)
|
||||
subprocess.run(ffmpeg + ["-i", str(combined), "-map", "0", "-c", "copy",
|
||||
"-hls_time", "6", "-hls_playlist_type", "vod",
|
||||
"-hls_segment_filename", str(directory / "bench-%03d.ts"),
|
||||
str(directory / "bench.m3u8")], check=True, timeout=60)
|
||||
|
||||
|
||||
class BenchmarkServer(StreamingServer):
|
||||
def __init__(self, directory: Path, delay_ms: float):
|
||||
super().__init__(directory)
|
||||
self.headers = {path.name: "combined" for path in directory.iterdir() if path.is_file()}
|
||||
self.headers.update({"bench-video.mp4": "video", "bench-audio.m4a": "audio"})
|
||||
self.delay_seconds = delay_ms / 1000
|
||||
self.connections: set[tuple] = set()
|
||||
|
||||
def _get(self, request):
|
||||
if request.path.startswith("/media/"):
|
||||
with self.lock:
|
||||
self.connections.add(request.client_address)
|
||||
# Controlled per-request response latency, not a simulated bandwidth or RTT.
|
||||
if self.delay_seconds:
|
||||
self.stopping.wait(self.delay_seconds)
|
||||
super()._get(request)
|
||||
|
||||
def snapshot(self):
|
||||
with self.lock:
|
||||
return {**{key: sum(row[key] for row in self.stats.values())
|
||||
for key in ("requests", "ranges", "bytes", "denied")},
|
||||
"connections": len(self.connections)}
|
||||
|
||||
|
||||
def sample(binary: Path, cast: bool, source: str, server: BenchmarkServer, *, seek_work: bool = False):
|
||||
command = [str(binary.resolve())] + (["--playback-benchmark"] if cast else [])
|
||||
command += [server.base_url, source]
|
||||
if seek_work:
|
||||
command.append("--seek-work")
|
||||
rows = []
|
||||
# A process deadline is infrastructure protection, never a measured playback clock.
|
||||
with subprocess.Popen(command, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE, text=True) as process:
|
||||
timer = threading.Timer(120, process.kill)
|
||||
timer.start()
|
||||
errors = []
|
||||
reader = threading.Thread(target=lambda: errors.append(process.stderr.read()), daemon=True)
|
||||
reader.start()
|
||||
before = None
|
||||
phase = None
|
||||
try:
|
||||
for line in process.stdout:
|
||||
row = json.loads(line)
|
||||
after = server.snapshot()
|
||||
if row.get("event") == "begin":
|
||||
if before is not None:
|
||||
raise RuntimeError("nested benchmark phase")
|
||||
before, phase = after, row["phase"]
|
||||
else:
|
||||
if before is None or phase != row["phase"]:
|
||||
raise RuntimeError("missing benchmark phase boundary")
|
||||
row.update({"upstream_" + key: after[key] - before[key] for key in after})
|
||||
if row["upstream_denied"]:
|
||||
raise RuntimeError("benchmark source headers rejected")
|
||||
rows.append(row)
|
||||
before = None
|
||||
process.stdin.write("\n")
|
||||
process.stdin.flush()
|
||||
result = process.wait()
|
||||
reader.join()
|
||||
if result:
|
||||
# Diagnostics can contain fixture addresses; do not publish addresses/headers.
|
||||
detail = "".join(errors).replace(server.base_url, "<loopback>")
|
||||
raise RuntimeError(f"{binary.name}/{source} exited {result}: {detail}")
|
||||
finally:
|
||||
timer.cancel()
|
||||
if process.poll() is None:
|
||||
process.kill()
|
||||
process.wait()
|
||||
expected_phases = (["cold_start", "uncached_seek"] if seek_work else
|
||||
["cached_seek", "cold_start", "sustained", "uncached_seek"])
|
||||
if sorted(row["phase"] for row in rows) != expected_phases:
|
||||
raise RuntimeError(f"benchmark must report each expected phase exactly once: {expected_phases}")
|
||||
return rows
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("native", type=Path)
|
||||
parser.add_argument("cast", type=Path)
|
||||
parser.add_argument("fixtures", type=Path)
|
||||
parser.add_argument("--runs", type=int, default=3)
|
||||
parser.add_argument("--request-delay-ms", type=float, nargs="+", default=[0, 25])
|
||||
parser.add_argument("--compare-native", type=Path, metavar="BASELINE_BINARY")
|
||||
parser.add_argument("--seek-work", action="store_true")
|
||||
args = parser.parse_args()
|
||||
if args.runs < 1 or any(value < 0 for value in args.request_delay_ms):
|
||||
parser.error("runs must be positive and delays nonnegative")
|
||||
if args.seek_work and not args.compare_native:
|
||||
parser.error("--seek-work requires --compare-native")
|
||||
fixtures(args.fixtures)
|
||||
print(json.dumps({"environment": platform.platform(), "machine": platform.machine(),
|
||||
"runs": args.runs, "request_delay_ms": args.request_delay_ms,
|
||||
**({"baseline": str(args.compare_native), "candidate": str(args.native),
|
||||
"seek_work": args.seek_work} if args.compare_native else {})}), flush=True)
|
||||
results = []
|
||||
if args.compare_native:
|
||||
for delay in args.request_delay_ms:
|
||||
for run in range(1, args.runs + 1):
|
||||
variants = [("baseline", args.compare_native), ("candidate", args.native)]
|
||||
if run % 2 == 0:
|
||||
variants.reverse()
|
||||
for source in ("split", "hls"):
|
||||
for variant, binary in variants:
|
||||
with BenchmarkServer(args.fixtures, delay) as server:
|
||||
for row in sample(binary, False, source, server, seek_work=args.seek_work):
|
||||
row.update(engine="native", variant=variant, source=source,
|
||||
request_delay_ms=delay, run=run)
|
||||
results.append(row)
|
||||
print(json.dumps(row, sort_keys=True), flush=True)
|
||||
print("\nMedians [min, max]; CPU is process CPU; peak RSS is process-lifetime high water; "
|
||||
"upstream counts include concurrent read-ahead.")
|
||||
print("| Delay ms | Variant | Source | Phase | Wall ms | CPU ms | Peak MiB | Requests | Upstream bytes |")
|
||||
print("| --- | --- | --- | --- | ---: | ---: | ---: | ---: | ---: |")
|
||||
for delay in args.request_delay_ms:
|
||||
for variant in ("baseline", "candidate"):
|
||||
for source in ("split", "hls"):
|
||||
phases = dict.fromkeys(row["phase"] for row in results)
|
||||
for phase in phases:
|
||||
group = [row for row in results if
|
||||
(row["request_delay_ms"], row["variant"], row["source"], row["phase"]) ==
|
||||
(delay, variant, source, phase)]
|
||||
def summary(key):
|
||||
values = [row[key] for row in group]
|
||||
return f"{statistics.median(values):.2f} [{min(values):.2f}, {max(values):.2f}]"
|
||||
print(f"| {delay:g} | {variant} | {source} | {phase} | "
|
||||
f"{summary('wall_ms')} | {summary('cpu_ms')} | {summary('peak_rss_mib')} | "
|
||||
f"{summary('upstream_requests')} | {summary('upstream_bytes')} |")
|
||||
return
|
||||
for delay in args.request_delay_ms:
|
||||
for run in range(1, args.runs + 1):
|
||||
for source in ("split", "hls"):
|
||||
for engine, binary in (("native", args.native), ("cast", args.cast)):
|
||||
with BenchmarkServer(args.fixtures, delay) as server:
|
||||
for row in sample(binary, engine == "cast", source, server):
|
||||
row.update(engine=engine, source=source, request_delay_ms=delay, run=run)
|
||||
results.append(row)
|
||||
print(json.dumps(row, sort_keys=True), flush=True)
|
||||
print("\nMedians; upstream counts include concurrent read-ahead; peak RSS is process-lifetime high water.")
|
||||
print("| Delay ms | Engine | Source | Phase | Wall ms | CPU ms | Peak MiB | Requests | Connections | Upstream MiB | Media s |")
|
||||
print("| --- | --- | --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |")
|
||||
for delay in args.request_delay_ms:
|
||||
for engine in ("native", "cast"):
|
||||
for source in ("split", "hls"):
|
||||
for phase in ("cold_start", "cached_seek", "uncached_seek", "sustained"):
|
||||
group = [row for row in results if (row["request_delay_ms"], row["engine"], row["source"], row["phase"]) ==
|
||||
(delay, engine, source, phase)]
|
||||
def median(key):
|
||||
return statistics.median(row[key] for row in group)
|
||||
print(f"| {delay:g} | {engine} | {source} | {phase} | {median('wall_ms'):.2f} | "
|
||||
f"{median('cpu_ms'):.2f} | {median('peak_rss_mib'):.1f} | "
|
||||
f"{median('upstream_requests'):g} | {median('upstream_connections'):g} | "
|
||||
f"{median('upstream_bytes') / 1048576:.2f} | {median('media_seconds'):.2f} |")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,19 @@
|
||||
import Foundation
|
||||
import IOKit.pwr_mgt
|
||||
|
||||
// Inspect this process, not global assertions from audio drivers or other apps.
|
||||
func requirePlaybackPower(system: Bool, display: Bool, _ context: String) {
|
||||
var result: Unmanaged<CFDictionary>?
|
||||
precondition(IOPMCopyAssertionsByProcess(&result) == kIOReturnSuccess,
|
||||
"Could not inspect macOS power assertions")
|
||||
let byProcess = result!.takeRetainedValue() as NSDictionary
|
||||
let assertions = byProcess[NSNumber(value: ProcessInfo.processInfo.processIdentifier)] as? [[String: Any]] ?? []
|
||||
let active = assertions.filter { ($0[kIOPMAssertionLevelKey] as? NSNumber)?.intValue != 0 }
|
||||
let types = Set(active.compactMap { $0[kIOPMAssertionTypeKey] as? String })
|
||||
guard types.contains(kIOPMAssertPreventUserIdleSystemSleep) == system &&
|
||||
types.contains(kIOPMAssertPreventUserIdleDisplaySleep) == display else {
|
||||
FileHandle.standardError.write(Data(
|
||||
"FAIL: \(context): expected system=\(system), display=\(display); actual=\(types)\n".utf8))
|
||||
exit(EXIT_FAILURE)
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,19 @@ struct PlaybackProgressChecks {
|
||||
let migratedAgain = PlaybackProgress(defaults: isolated)
|
||||
require(migratedAgain.position(for: file) == 43 && migratedAgain.position(for: youtube) == 15,
|
||||
"opening another player preserves both migrated bookmarks and newer progress")
|
||||
progress.recordRecent(file, title: "Original")
|
||||
progress.recordRecent(youtube, title: "YouTube title")
|
||||
independent.recordRecent(alias, title: "Renamed")
|
||||
require(reopened.recentMedia == [
|
||||
.init(url: file.resolvingSymlinksInPath(), title: "Renamed"), .init(url: youtube, title: "YouTube title")
|
||||
], "recent media persists titles, shares file identities, and moves reopened items to the front")
|
||||
for index in 0..<6 {
|
||||
progress.recordRecent(directory.appendingPathComponent("\(index).mp4"), title: "Video \(index)")
|
||||
}
|
||||
require(reopened.recentMedia.map(\.title) == (0..<6).reversed().map { "Video \($0)" },
|
||||
"recent media retains only the six most recently opened items in order")
|
||||
require(PlaybackProgress(defaults: isolated).recentMedia.isEmpty,
|
||||
"recent media respects the injected preference domain")
|
||||
print("Playback progress checks passed")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
|
||||
@main
|
||||
struct PlaybackQueueChecks {
|
||||
static func require(_ condition: @autoclosure () -> Bool, _ message: String) {
|
||||
if !condition() { fatalError("FAIL: " + message) }
|
||||
}
|
||||
|
||||
static func file(_ name: String) -> URL {
|
||||
URL(fileURLWithPath: "/queue-check/" + name + ".mp4")
|
||||
}
|
||||
|
||||
static func dragReordering() {
|
||||
// Unequal heights catch snapping to a row's old center instead of the vacated slot.
|
||||
let frames = [
|
||||
CGRect(x: 0, y: 52, width: 260, height: 60),
|
||||
CGRect(x: 0, y: 112, width: 260, height: 80),
|
||||
CGRect(x: 0, y: 192, width: 260, height: 60),
|
||||
]
|
||||
var queue = PlaybackQueue()
|
||||
queue.replace([file("A"), file("B"), file("C")])
|
||||
let current = queue.currentID
|
||||
var drag = PlaybackQueueDrag(source: 0, frames: frames, scrollOffset: 0)
|
||||
drag.update(translation: 39, scrollOffset: 0)
|
||||
require(drag.destination == 0, "release before the nearest-slot midpoint keeps the original position")
|
||||
drag.update(translation: 41, scrollOffset: 0)
|
||||
require(drag.destination == 1 && drag.offset(at: 1) == -60 && drag.offset(at: 2) == 0,
|
||||
"crossing the midpoint slides only the displaced neighbor")
|
||||
drag.update(translation: 140, scrollOffset: 0)
|
||||
require(drag.destination == 2 && drag.offset(at: 0) == 140,
|
||||
"dragged row follows the pointer continuously to the final slot")
|
||||
// No further pointer update: release consumes the retained gesture state.
|
||||
queue.move(fromOffsets: IndexSet(integer: drag.source), toOffset: drag.insertionOffset)
|
||||
require(queue.items.map(\.url) == [file("B"), file("C"), file("A")] && queue.currentID == current,
|
||||
"stationary release commits the nearest slot without changing playback identity")
|
||||
|
||||
drag = PlaybackQueueDrag(source: 2, frames: frames, scrollOffset: 100)
|
||||
drag.update(translation: -20, scrollOffset: 0)
|
||||
require(drag.destination == 0 && drag.offset(at: 0) == 60 && drag.offset(at: 1) == 60,
|
||||
"scrolling under a stationary pointer moves the row and shifts earlier neighbors down")
|
||||
drag.update(translation: 0, scrollOffset: 100)
|
||||
require(drag.destination == 2 && drag.offset(at: 1) == 0,
|
||||
"reversing a drag restores the original gap")
|
||||
drag.update(translation: -1_000, scrollOffset: 100)
|
||||
require(drag.destination == 0 && drag.insertionOffset == 0, "release above the list selects the first slot")
|
||||
drag.update(translation: 1_000, scrollOffset: 100)
|
||||
require(drag.destination == 2 && drag.insertionOffset == 2, "release below the list selects the last slot")
|
||||
}
|
||||
|
||||
static func main() {
|
||||
dragReordering()
|
||||
let first = file("Episode1"), second = file("Episode2"), tenth = file("Episode10")
|
||||
var queue = PlaybackQueue()
|
||||
require(queue.items.isEmpty && queue.currentURL == nil && !queue.canPrevious && !queue.canNext,
|
||||
"empty queue has no selection or navigation")
|
||||
require(queue.advance(1) == nil && queue.advance(-1) == nil, "empty navigation is harmless")
|
||||
|
||||
queue.replace([tenth, second, first])
|
||||
require(queue.items.map(\.url) == [first, second, tenth], "replace uses natural Episode2/Episode10 filename order")
|
||||
require(queue.currentURL == first && !queue.canPrevious && queue.canNext, "replace selects first item")
|
||||
let firstID = queue.currentID
|
||||
require(queue.advance(-1) == nil && queue.currentID == firstID, "previous at start does not wrap or deselect")
|
||||
require(queue.advance(1) == second && queue.canPrevious && queue.canNext, "next selects middle item")
|
||||
require(queue.advance(1) == tenth && queue.canPrevious && !queue.canNext, "next selects final item")
|
||||
let tenthID = queue.currentID
|
||||
require(queue.advance(1) == nil && queue.currentID == tenthID, "next at end stops without wrapping")
|
||||
require(queue.advance(-1) == second, "previous moves back one item")
|
||||
|
||||
let selected = queue.currentID!
|
||||
queue.append([file("Episode20"), file("Episode3")])
|
||||
require(queue.items.map(\.url) == [first, second, tenth, file("Episode3"), file("Episode20")],
|
||||
"append naturally sorts the new batch without reordering existing items")
|
||||
require(queue.currentID == selected && queue.currentURL == second, "append does not interrupt selection")
|
||||
queue.move(fromOffsets: IndexSet(integer: 1), toOffset: queue.items.count)
|
||||
require(queue.items.last?.id == selected && queue.currentID == selected && queue.currentURL == second,
|
||||
"reordering preserves current item identity")
|
||||
require(queue.canPrevious && !queue.canNext, "navigation follows reordered positions")
|
||||
queue.move(fromOffsets: IndexSet([0, 1]), toOffset: queue.items.count)
|
||||
require(queue.currentID == selected && queue.currentURL == second, "moving other items preserves current")
|
||||
|
||||
queue.replace([tenth, second, first])
|
||||
queue.select(queue.items[1].id)
|
||||
require(queue.currentURL == second, "explicit selection jumps to an item")
|
||||
let current = queue.currentID
|
||||
let unrelated = queue.items[0].id
|
||||
require(queue.remove(unrelated) == nil && queue.currentID == current && queue.currentURL == second,
|
||||
"removing another item does not request playback or change selection")
|
||||
require(queue.remove(current!) == tenth && queue.currentURL == tenth,
|
||||
"removing current selects the next remaining item")
|
||||
require(queue.remove(queue.currentID!) == nil && queue.currentURL == nil && queue.items.isEmpty,
|
||||
"removing final item stops and empties queue")
|
||||
|
||||
queue.replace([first, second, tenth])
|
||||
queue.select(queue.items.last!.id)
|
||||
require(queue.remove(queue.currentID!) == nil && queue.currentID == nil && queue.items.count == 2,
|
||||
"removing current at end stops rather than jumping backward")
|
||||
require(!queue.canPrevious && !queue.canNext, "stopped queue has no implicit selection")
|
||||
queue.select(queue.items[0].id)
|
||||
require(queue.currentURL == first, "remaining items can be explicitly selected after stopping")
|
||||
queue.append([first])
|
||||
require(Set(queue.items.map(\.id)).count == queue.items.count, "duplicate file entries have independent identities")
|
||||
queue.replace([tenth])
|
||||
require(queue.items.map(\.url) == [tenth] && queue.currentURL == tenth && !queue.canPrevious && !queue.canNext,
|
||||
"replace discards previous queue and starts new selection")
|
||||
queue.replace([])
|
||||
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")
|
||||
}
|
||||
}
|
||||
+173
-1
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import Darwin
|
||||
|
||||
@main
|
||||
struct PlayerCommandChecks {
|
||||
@@ -32,6 +33,16 @@ struct PlayerCommandChecks {
|
||||
require(try opened([colonFile.path]) == colonFile, "colons in filesystem paths are not URL schemes")
|
||||
let canonical = URL(string: "https://www.youtube.com/watch?v=jNQXAC9IVRw")!
|
||||
require(try opened(["https://youtu.be/jNQXAC9IVRw"]) == canonical, "YouTube positional URLs canonicalize for resume identity")
|
||||
let miruro = URL(string: "https://barelystarted.miruro.tv/watch/OERe_a0YS4h-DlsBDOeXwX7HOO6316tw/the-apothecary-diaries-season-3?ep=1")!
|
||||
require(try opened([miruro.absoluteString]) == miruro, "Miruro positional URLs retain episode page identity")
|
||||
require(try opened(["--url", miruro.absoluteString]) == miruro, "explicit Miruro URLs forward without network resolution")
|
||||
let anilist = URL(string: "https://zokoanime.video/stream/ani/1/1/sub")!
|
||||
require(try opened([anilist.absoluteString]) == anilist &&
|
||||
opened(["--url", anilist.absoluteString]) == anilist, "AniList watch URLs forward without yt-dlp or network resolution")
|
||||
let generic = URL(string: "https://vimeo.com/76979871?quality=original")!
|
||||
require(try opened([generic.absoluteString]) == generic &&
|
||||
opened(["--url", generic.absoluteString]) == generic,
|
||||
"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", movie.lastPathComponent], newInstance: true) == movie,
|
||||
"independent local launches preserve relative paths and spaces")
|
||||
@@ -72,9 +83,170 @@ struct PlayerCommandChecks {
|
||||
require(try opened(["--", "--progress-file"]) == reportFlagFile,
|
||||
"escaped reporting option remains a filename")
|
||||
|
||||
let sourceFile = directory.appendingPathComponent("source with spaces.json")
|
||||
let sourceURL = "https://cdn.example/video/master.m3u8?token=secret%2Bvalue"
|
||||
var source: [String: Any] = [
|
||||
"version": 1, "url": sourceURL, "headers": ["Authorization": "Bearer secret "],
|
||||
"identity": ["provider": "miruro", "media_id": "opaque_A-1", "episode": 12],
|
||||
"subtitles": [["url": "https://cdn.example/sub.vtt?token=secret", "format": "vtt",
|
||||
"language": "en", "label": "English", "default": true, "headers": ["X-Token": "secret"]]]
|
||||
]
|
||||
func saveSource() throws {
|
||||
try JSONSerialization.data(withJSONObject: source).write(to: sourceFile)
|
||||
}
|
||||
try saveSource()
|
||||
let resolved = try ResolvedSource.load(from: sourceFile)
|
||||
require(resolved.url == sourceURL && resolved.headers == "Authorization: Bearer secret \r\n",
|
||||
"resolved URLs and header values remain byte-for-byte intact")
|
||||
require(resolved.identity == "miruro:opaque_A-1:12" && resolved.title == "Miruro episode 12",
|
||||
"stable identity excludes CDN tokens and omitted title has a useful fallback")
|
||||
require(resolved.subtitles.count == 1 && resolved.subtitles[0].isDefault &&
|
||||
resolved.subtitles[0].headers == "X-Token: secret\r\n", "subtitle metadata retains independent headers")
|
||||
guard case .launchSource(let delivered, let independent, let bypass, let output) =
|
||||
try PlayerCommand.parse(["--new-instance", "--no-config", "--source-file", sourceFile.lastPathComponent,
|
||||
"--progress-file", report.lastPathComponent], directory: directory) else {
|
||||
fatalError("FAIL: expected resolved source launch")
|
||||
}
|
||||
require(delivered == sourceFile && independent && bypass && output == report,
|
||||
"CLI forwards only an absolute source-file path with existing launch options")
|
||||
for arguments in [["--source-file", sourceFile.path], ["--new-instance", "--source-file"],
|
||||
["--new-instance", "--source-file", sourceFile.path, movie.path]] {
|
||||
do { _ = try PlayerCommand.parse(arguments, directory: directory); fatalError("FAIL: invalid source launch accepted") }
|
||||
catch {}
|
||||
}
|
||||
let original = source
|
||||
source["identity"] = ["provider": "anilist", "media_id": "1", "episode": 12]
|
||||
try saveSource()
|
||||
let anilistSource = try ResolvedSource.load(from: sourceFile)
|
||||
require(anilistSource.identity == "anilist:1:12" && anilistSource.title == "AniList 1 episode 12",
|
||||
"source-file identity admits AniList without pretending it is Miruro")
|
||||
let invalidChanges: [[String: Any]] = [
|
||||
["version": 2], ["url": "https://user:secret@cdn.example/video"],
|
||||
["url": "file:///secret"], ["url": "https://cdn.example/video\nsecret"],
|
||||
["headers": ["X-Token": "secret\r\nInjected: yes"]], ["headers": ["X-Token": "secret\t"]],
|
||||
["headers": ["Bad Name": "secret"]], ["headers": ["X-Token": "one", "x-token": "two"]],
|
||||
["identity": ["provider": "miruro", "media_id": "bad:id", "episode": 1]],
|
||||
["identity": ["provider": "miruro", "media_id": "opaque", "episode": 0]],
|
||||
["identity": ["provider": "miruro", "media_id": "opaque", "episode": 1.5]],
|
||||
["identity": ["provider": "anilist", "media_id": "01", "episode": 1]],
|
||||
["identity": ["provider": "anilist", "media_id": "opaque", "episode": 1]],
|
||||
["identity": ["provider": "anilist", "media_id": "1", "episode": 2147483648]],
|
||||
["subtitles": Array(repeating: (original["subtitles"] as! [[String: Any]])[0], count: 2)]
|
||||
]
|
||||
for change in invalidChanges {
|
||||
source = original.merging(change) { _, replacement in replacement }
|
||||
try saveSource()
|
||||
do { _ = try ResolvedSource.load(from: sourceFile); fatalError("FAIL: invalid source accepted") }
|
||||
catch { require(!error.localizedDescription.contains("secret"), "validation never reveals credentials") }
|
||||
}
|
||||
try Data(repeating: 32, count: 1_048_577).write(to: sourceFile)
|
||||
do { _ = try ResolvedSource.load(from: sourceFile); fatalError("FAIL: oversized source accepted") }
|
||||
catch {}
|
||||
let fifo = directory.appendingPathComponent("source.fifo")
|
||||
require(mkfifo(fifo.path, 0o600) == 0, "create unread FIFO source boundary")
|
||||
do {
|
||||
_ = try PlayerCommand.parse(["--new-instance", "--source-file", fifo.path], directory: directory)
|
||||
fatalError("FAIL: FIFO source accepted")
|
||||
} 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], ["--"],
|
||||
[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", movie.path, movie.path],
|
||||
["--new-instance", "--unknown"], ["--no-config", "--no-config"],
|
||||
|
||||
+205
-14
@@ -37,25 +37,37 @@ static NSString *nativePath(const std::string &path) {
|
||||
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)
|
||||
encoding:NSUTF8StringEncoding error:nullptr];
|
||||
require(text != nil && [text hasSuffix:@"\n"], "finished JSONL has a complete final line");
|
||||
NSMutableArray<NSDictionary *> *result = [NSMutableArray array];
|
||||
NSArray<NSString *> *lines = [text componentsSeparatedByString:@"\n"];
|
||||
NSSet *keys = [NSSet setWithArray:@[@"version", @"event", @"media_url", @"position_seconds",
|
||||
@"duration_seconds", @"completion_eligible"]];
|
||||
NSSet *keys = [NSSet setWithArray:@[@"version", @"event", version == 2 ? @"media_identity" : @"media_url",
|
||||
@"position_seconds", @"duration_seconds", @"completion_eligible"]];
|
||||
for (NSUInteger i = 0; i + 1 < lines.count; ++i) {
|
||||
id object = [NSJSONSerialization JSONObjectWithData:[lines[i] dataUsingEncoding:NSUTF8StringEncoding]
|
||||
options:0 error:nullptr];
|
||||
require([object isKindOfClass:[NSDictionary class]], "consumer decodes every JSONL record");
|
||||
NSDictionary *row = object;
|
||||
require([[NSSet setWithArray:row.allKeys] isEqual:keys], "records have exactly the six specified fields");
|
||||
require([row[@"version"] isKindOfClass:[NSNumber class]] && [row[@"version"] intValue] == 1,
|
||||
"schema version is numeric one");
|
||||
require([row[@"version"] isKindOfClass:[NSNumber class]] && [row[@"version"] intValue] == version,
|
||||
"schema version matches the expected source contract");
|
||||
require([@[@"progress", @"ended", @"closed"] containsObject:row[@"event"]], "known event type");
|
||||
require([row[@"media_url"] isKindOfClass:[NSString class]] &&
|
||||
ProgressReporter::validMediaURL([row[@"media_url"] UTF8String]), "canonical consumer identity");
|
||||
if (version == 2) {
|
||||
NSDictionary *identity = row[@"media_identity"];
|
||||
require([identity isKindOfClass:[NSDictionary class]] &&
|
||||
[[NSSet setWithArray:identity.allKeys] isEqual:
|
||||
[NSSet setWithArray:@[@"provider", @"media_id", @"episode"]]] &&
|
||||
[identity[@"provider"] isKindOfClass:[NSString class]] &&
|
||||
[identity[@"media_id"] isKindOfClass:[NSString class]] &&
|
||||
[identity[@"episode"] isKindOfClass:[NSNumber class]], "structured episode consumer identity");
|
||||
NSString *key = [NSString stringWithFormat:@"%@:%@:%@", identity[@"provider"], identity[@"media_id"], identity[@"episode"]];
|
||||
require(ProgressReporter::validMediaIdentity(key.UTF8String), "canonical episode consumer identity");
|
||||
} else {
|
||||
require([row[@"media_url"] isKindOfClass:[NSString class]] &&
|
||||
ProgressReporter::validMediaURL([row[@"media_url"] UTF8String]), "HTTP(S) consumer identity");
|
||||
}
|
||||
require([row[@"position_seconds"] isKindOfClass:[NSNumber class]] &&
|
||||
std::isfinite([row[@"position_seconds"] doubleValue]) &&
|
||||
[row[@"position_seconds"] doubleValue] >= 0, "position is finite and nonnegative");
|
||||
@@ -240,13 +252,91 @@ int main(int argc, char **argv) {
|
||||
for (NSUInteger i = 0; i < rows.count; ++i)
|
||||
expect(rows[i], i % 2 ? @"closed" : @"progress", firstURL, 97, false, i < 8 ? 0 : 100);
|
||||
|
||||
require(ProgressReporter::validMediaURL(firstURL) &&
|
||||
ProgressReporter::validMediaURL("https://www.youtube.com/watch?v=aA0_-123456"), "eleven ASCII ID characters are accepted");
|
||||
for (const char *invalid : {"", "http://www.youtube.com/watch?v=AAAAAAAAAAA",
|
||||
"https://youtube.com/watch?v=AAAAAAAAAAA", "https://www.youtube.com/watch?v=AAAAAAAAAA",
|
||||
"https://www.youtube.com/watch?v=AAAAAAAAAAAA", "https://www.youtube.com/watch?v=AAAAAAAAAAA&x=1",
|
||||
"https://www.youtube.com/watch?v=AAAAAAAAAA%", "https://www.youtube.com/watch?v=AAAAAAAAAA\n"})
|
||||
require(!ProgressReporter::validMediaURL(invalid), "noncanonical URLs are rejected");
|
||||
const std::string genericURL = "https://video.example/watch?label=\"quoted\"&id=123#part";
|
||||
require(ProgressReporter::validMediaURL(genericURL) &&
|
||||
ProgressReporter::validMediaURL("http://127.0.0.1:8080/video.mp4"),
|
||||
"HTTP(S) page identities are accepted beyond YouTube");
|
||||
for (const char *invalid : {"", "file:///tmp/video.mp4", "ftp://example.com/video",
|
||||
"https://", "https://user:secret@example.com/video", "https://example.com:0/video",
|
||||
"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") &&
|
||||
ProgressReporter::validMediaIdentity("miruro:a:2147483647"), "strict Miruro identities accepted");
|
||||
for (const char *invalid : {"miruro::1", "miruro:a:0", "miruro:a:01", "miruro:a:-1",
|
||||
"miruro:a:1.5", "miruro:a:2147483648", "miruro:a:1?token=secret",
|
||||
"miruro:a:b:1", "miruro:a:1\n", "miruro:bad/id:1", "miruro:é:1"})
|
||||
require(!ProgressReporter::validMediaIdentity(invalid), "ambiguous or token-bearing identities rejected");
|
||||
const auto miruroPath = root + "/miruro.jsonl";
|
||||
{
|
||||
ProgressReporter reporter(miruroPath, [] { return 0.; });
|
||||
reporter.begin("miruro:opaque_A-1:12");
|
||||
reporter.observe(sample(PLAYER_PLAYING, 95));
|
||||
reporter.begin("miruro:opaque_A-1:13");
|
||||
reporter.observe(sample(PLAYER_ENDED, 100));
|
||||
reporter.finish();
|
||||
require(reporter.error().empty(), "Miruro progress drains successfully");
|
||||
}
|
||||
NSString *miruroText = [NSString stringWithContentsOfFile:nativePath(miruroPath)
|
||||
encoding:NSUTF8StringEncoding error:nullptr];
|
||||
NSArray<NSString *> *miruroLines = [miruroText componentsSeparatedByString:@"\n"];
|
||||
require(miruroLines.count == 5, "Miruro replacement emits progress, close, ended, close");
|
||||
NSSet *miruroKeys = [NSSet setWithArray:@[@"version", @"event", @"media_identity",
|
||||
@"position_seconds", @"duration_seconds", @"completion_eligible"]];
|
||||
for (NSUInteger i = 0; i < 4; ++i) {
|
||||
NSDictionary *row = [NSJSONSerialization JSONObjectWithData:[miruroLines[i]
|
||||
dataUsingEncoding:NSUTF8StringEncoding] options:0 error:nullptr];
|
||||
require([row isKindOfClass:[NSDictionary class]] &&
|
||||
[[NSSet setWithArray:row.allKeys] isEqual:miruroKeys] &&
|
||||
[row[@"version"] intValue] == 2, "Miruro schema is v2 without media_url");
|
||||
NSDictionary *identity = row[@"media_identity"];
|
||||
require([identity isKindOfClass:[NSDictionary class]] &&
|
||||
[[NSSet setWithArray:identity.allKeys] isEqual:
|
||||
[NSSet setWithArray:@[@"provider", @"media_id", @"episode"]]] &&
|
||||
[identity[@"provider"] isEqual:@"miruro"] &&
|
||||
[identity[@"media_id"] isEqual:@"opaque_A-1"] &&
|
||||
[identity[@"episode"] intValue] == (i < 2 ? 12 : 13),
|
||||
"structured stable episode identity contains no CDN token");
|
||||
require([row[@"event"] isEqual:(i == 0 ? @"progress" : i == 2 ? @"ended" : @"closed")] &&
|
||||
[row[@"position_seconds"] doubleValue] == (i < 2 ? 95 : 100) &&
|
||||
[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";
|
||||
{
|
||||
@@ -273,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([[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";
|
||||
std::atomic<int> ready{0}, successes{0}, failures{0};
|
||||
std::atomic<bool> start{false};
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Local-only resolved HLS/VTT + native model smoke; no live requests."""
|
||||
import base64
|
||||
import http.server
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
binary = str(Path(sys.argv[1]).resolve())
|
||||
fixtures = Path(sys.argv[2]).resolve()
|
||||
seen = []
|
||||
errors = []
|
||||
zoko_only = "--zoko-intervals" in sys.argv[3:]
|
||||
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
def log_message(self, *_):
|
||||
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):
|
||||
if self.transport():
|
||||
return
|
||||
path = urlsplit(self.path).path
|
||||
seen.append(path)
|
||||
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:
|
||||
errors.append("wrong resource headers: " + path)
|
||||
self.send_error(403)
|
||||
return
|
||||
if path == "/master.m3u8":
|
||||
query = parse_qs(urlsplit(self.path).query)
|
||||
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'
|
||||
elif path == "/variant.m3u8":
|
||||
query = parse_qs(urlsplit(self.path).query)
|
||||
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()
|
||||
if alternate:
|
||||
data = data.replace(b"resolved-vod-", b"https://invalid.example/alternate/resolved-vod-")
|
||||
elif path == "/english.jpeg":
|
||||
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"):
|
||||
data = (fixtures / path[1:]).read_bytes()
|
||||
else:
|
||||
self.send_error(404)
|
||||
return
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.send_header("Content-Type", "text/vtt" if path.endswith(".jpeg") else "application/octet-stream")
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
subprocess.run(["ffmpeg", "-hide_banner", "-loglevel", "error", "-y", "-i", str(fixtures / "av.mp4"),
|
||||
"-c:v", "libx264", "-g", "24", "-keyint_min", "24", "-sc_threshold", "0", "-c:a", "aac",
|
||||
"-hls_time", "1", "-hls_playlist_type", "vod", "-hls_segment_filename", str(fixtures / "resolved-vod-%d.ts"),
|
||||
str(fixtures / "resolved-vod.m3u8")], check=True)
|
||||
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
base = f"http://127.0.0.1:{server.server_port}"
|
||||
with tempfile.TemporaryDirectory(prefix="terebi-resolved-") as temp:
|
||||
source = Path(temp) / "source.json"
|
||||
progress = Path(temp) / "progress.jsonl"
|
||||
source.write_text(json.dumps({"version": 1, "url": base + "/master.m3u8?token=fixture",
|
||||
"headers": {"X-Resource": "stream-private"}, "identity": {"provider": "miruro", "media_id": "fixture-ID", "episode": 1},
|
||||
"title": "Local resolved fixture", "subtitles": [{"url": base + "/english.jpeg", "format": "vtt", "language": "en",
|
||||
"label": "English", "default": True, "headers": {"X-Resource": "subtitle-private"}}]}))
|
||||
source.chmod(0o600)
|
||||
subprocess.run([binary, str(source), "--zoko-intervals"] if zoko_only else
|
||||
[binary, str(source), str(progress)], check=True, timeout=100)
|
||||
if not zoko_only:
|
||||
records = [json.loads(line) for line in progress.read_text().splitlines()]
|
||||
assert records and all(record["version"] == 2 and record["media_identity"] ==
|
||||
{"provider": "miruro", "media_id": "fixture-ID", "episode": 1} for record in records), "stable episode progress identity"
|
||||
assert any(record["completion_eligible"] for record in records), "acknowledged video progress eligible"
|
||||
assert "stream-private" not in progress.read_text() and "token=" not in progress.read_text(), "progress has no transport secrets"
|
||||
assert not errors, errors
|
||||
assert {"/master.m3u8", "/variant.m3u8"}.issubset(seen), "master and nested playlist fetched"
|
||||
if not zoko_only:
|
||||
assert {"/english.jpeg", "/alternate/master.m3u8", "/alternate/english.jpeg"}.issubset(seen), "external VTT, alternate host and independent captions fetched"
|
||||
assert any(path.endswith(".ts") for path in seen), "media segment fetched"
|
||||
print("PASS: local Zoko metadata native playback and source preparation" if zoko_only else
|
||||
"PASS: model HLS/VTT playback, independent resource headers, tokens, stable progress")
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,81 @@
|
||||
// Include the implementation to inspect pending commands without adding a public test API.
|
||||
#include "../Sources/Core/player.cpp"
|
||||
#include <cstdlib>
|
||||
|
||||
static void require(bool condition, const char *message) {
|
||||
if (!condition) { std::fprintf(stderr, "FAIL: %s\n", message); std::exit(1); }
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
Player player;
|
||||
// Join before submitting anything: the queue exercise has no executing worker,
|
||||
// media I/O, device clock or scheduling-dependent queue depth.
|
||||
player.shutdown();
|
||||
player.stopping.store(false);
|
||||
auto reset = [&] {
|
||||
player.commands.clear();
|
||||
player.state = {};
|
||||
player.state.status = PLAYER_PAUSED;
|
||||
player.state.can_seek = true;
|
||||
player.state.has_video = true;
|
||||
player.publishedEpoch = player.requestedEpoch.load();
|
||||
};
|
||||
reset();
|
||||
player.state.position = 7;
|
||||
player.state.buffered_start = 6;
|
||||
player.state.buffered_end = 9;
|
||||
constexpr unsigned requests = 100000;
|
||||
unsigned accepted = 0;
|
||||
for (unsigned i = 0; i < requests; ++i)
|
||||
accepted += player_seek(&player, double(i));
|
||||
std::printf("Native pending seek burst: requested=%u accepted=%u queued=%zu latest=%.0f\n",
|
||||
requests, accepted, player.commands.size(), player.commands.back().seconds);
|
||||
if (argc == 2 && std::strcmp(argv[1], "--measure") == 0) return 0;
|
||||
require(argc == 1, "only --measure is supported");
|
||||
require(accepted == requests && player.commands.size() == 1 &&
|
||||
player.commands.back().seconds == requests - 1,
|
||||
"every superseding seek is accepted and only the latest target remains");
|
||||
require(player.state.seek_pending && player.state.revision == requests &&
|
||||
player.state.buffered_start == 0 && player.state.buffered_end == 0 &&
|
||||
player.state.position == 7 && player.state.status == PLAYER_PAUSED,
|
||||
"each accepted seek publishes pending and invalidates buffering, not acknowledged position");
|
||||
|
||||
reset();
|
||||
for (std::size_t i = 0; i < commandLimit - 1; ++i)
|
||||
require(player_pause(&player), "fill queue with command barriers");
|
||||
require(player_seek(&player, 1) && player_seek(&player, 2),
|
||||
"latest queued seek can be replaced even at capacity");
|
||||
require(player.commands.size() == commandLimit && player.commands.back().seconds == 2 &&
|
||||
!player_play(&player), "coalescing does not relax capacity for other commands");
|
||||
|
||||
for (Action barrier : {Action::Play, Action::Pause, Action::Step,
|
||||
Action::Open, Action::Close, Action::Remote}) {
|
||||
reset();
|
||||
require(player_seek(&player, 1), "seek before barrier");
|
||||
Command command{barrier, {}};
|
||||
command.direction = 1;
|
||||
require(player.enqueue(std::move(command)) && player_seek(&player, 2), "seek after barrier");
|
||||
require(player.commands.size() == 3 && player.commands[0].action == Action::Seek &&
|
||||
player.commands[0].seconds == 1 && player.commands[1].action == barrier &&
|
||||
player.commands[2].action == Action::Seek && player.commands[2].seconds == 2,
|
||||
"no seek replacement crosses another action");
|
||||
if (barrier == Action::Open || barrier == Action::Close)
|
||||
require(player.commands[0].epoch != player.commands[2].epoch,
|
||||
"timeline replacement separates seek epochs");
|
||||
}
|
||||
reset();
|
||||
require(player_seek(&player, 1), "seek before API error boundary");
|
||||
player.apiError("queue check boundary");
|
||||
require(player_seek(&player, 2) && player.commands.size() == 2 &&
|
||||
player.commands[0].epoch != player.commands[1].epoch,
|
||||
"adjacent seeks from different epochs remain distinct");
|
||||
|
||||
reset();
|
||||
require(player_seek(&player, 1), "first seek queued");
|
||||
auto executing = std::move(player.commands.front());
|
||||
player.commands.pop_front(); // The worker removes a seek before executing it.
|
||||
require(player_seek(&player, 2) && player_seek(&player, 3) &&
|
||||
executing.seconds == 1 && player.commands.size() == 1 && player.commands.back().seconds == 3,
|
||||
"replacement never changes a seek already removed for execution");
|
||||
std::puts("PASS native seek burst/capacity/publication/action barriers/epochs/executing seek");
|
||||
}
|
||||
Executable
+83
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Actual CLI contract checks; --live adds bounded public embed/root probes only."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
|
||||
def require(condition, message):
|
||||
if not condition:
|
||||
raise AssertionError(message)
|
||||
|
||||
|
||||
def snapshot(*roots):
|
||||
return {
|
||||
str(path): path.read_bytes() if path.is_file() else None
|
||||
for root in roots
|
||||
for path in root.rglob("*")
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
executable = Path(sys.argv[1]).resolve(strict=True)
|
||||
live = "--live" in sys.argv[2:]
|
||||
watch = "https://zokoanime.video/stream/ani/1/1/sub"
|
||||
secret = "private-fixture-token"
|
||||
with tempfile.TemporaryDirectory(prefix="terebi-source-check-") as temporary:
|
||||
root = Path(temporary)
|
||||
home, config = root / "home", root / "config"
|
||||
home.mkdir()
|
||||
(config / "terebi").mkdir(parents=True)
|
||||
# A launch would parse this malformed configuration or reject this
|
||||
# executable outside its bundle. A source check must do neither.
|
||||
(config / "terebi" / "config.toml").write_text("not valid TOML = [\n")
|
||||
cli = root / "terebi"
|
||||
shutil.copy2(executable, cli)
|
||||
environment = dict(os.environ, HOME=str(home), CFFIXED_USER_HOME=str(home), XDG_CONFIG_HOME=str(config))
|
||||
before = snapshot(home, config)
|
||||
|
||||
def invoke(arguments):
|
||||
try:
|
||||
result = subprocess.run([str(cli), *arguments], env=environment, capture_output=True, timeout=20)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise AssertionError("source-check CLI exceeded total deadline") from None
|
||||
require(len(result.stdout) <= 128 and len(result.stderr) <= 1024, "bounded CLI output")
|
||||
require(secret.encode() not in result.stdout + result.stderr, "CLI output redacts private input")
|
||||
require(b"https://" not in result.stdout, "no source URLs in JSON")
|
||||
require(snapshot(home, config) == before, "source checking must not write configuration or resume/progress state")
|
||||
return result
|
||||
|
||||
for arguments in [
|
||||
["--check-source"], ["--check-source", watch + "?token=" + secret],
|
||||
["--check-source", "https://" + secret + "@zokoanime.video/stream/ani/1/1/sub"],
|
||||
["--new-instance", "--check-source", watch], ["--check-source", watch, "--no-config"],
|
||||
["--check-source", watch, "--progress-file", str(root / secret)],
|
||||
["--check-source", "https://127.0.0.1/" + secret],
|
||||
]:
|
||||
result = invoke(arguments)
|
||||
require(result.returncode != 0 and not result.stdout, "invalid syntax/unsupported URL must fail without JSON or launch")
|
||||
require(not (root / secret).exists(), "invalid source check must not create progress file")
|
||||
if live:
|
||||
for media_id, episode, expected in [
|
||||
(1, 1, "available"), (1, 27, "unavailable"),
|
||||
(97940, 1, "available"), (97940, 170, "available"),
|
||||
(195604, 1, "available"),
|
||||
]:
|
||||
result = invoke(["--check-source", f"https://zokoanime.video/stream/ani/{media_id}/{episode}/sub"])
|
||||
require(result.returncode == 0, "completed provider checks exit zero")
|
||||
require(result.stdout.endswith(b"\n") and result.stdout.count(b"\n") == 1, "exactly one JSON line")
|
||||
row = json.loads(result.stdout)
|
||||
require(row == {"version": 1, "provider": "zokoanime", "track": "sub", "status": expected},
|
||||
f"live AniList {media_id} episode {episode} expected {expected}; result is redacted")
|
||||
require(not result.stderr, "successful source checks have no error logs")
|
||||
print("PASS: actual CLI resolves exact MAL mappings for Cowboy Bebop and both Black Clover entries; explicit missing remains unavailable; no state writes")
|
||||
else:
|
||||
print("PASS: actual CLI rejects hostile URLs/flags privately, without configuration reads, state writes or bundle launch")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,101 @@
|
||||
import Foundation
|
||||
|
||||
@main
|
||||
struct SourceCheckChecks {
|
||||
struct Failure: Error {}
|
||||
static func require(_ condition: @autoclosure () -> Bool, _ message: String) throws {
|
||||
guard condition() else {
|
||||
FileHandle.standardError.write(Data("FAIL: \(message)\n".utf8))
|
||||
throw Failure()
|
||||
}
|
||||
}
|
||||
|
||||
static func main() async throws {
|
||||
let watch = "https://zokoanime.video/stream/ani/1/1/sub"
|
||||
guard case .checkSource(let id, let episode) = try PlayerCommand.parse(["--check-source", watch]) else {
|
||||
throw Failure()
|
||||
}
|
||||
try require(id == "1" && episode == 1, "check command is not a launch")
|
||||
let secret = "private-fixture-token"
|
||||
for arguments in [
|
||||
["--check-source"], ["--check-source", watch, watch],
|
||||
["--new-instance", "--check-source", watch], ["--no-config", "--check-source", watch],
|
||||
["--check-source", watch, "--progress-file", "/tmp/" + secret],
|
||||
["--source-file", "/tmp/" + secret, "--check-source", watch],
|
||||
["--check-source", watch, "--help"], ["--check-source", "--check-source"],
|
||||
["--check-source", "https://youtu.be/jNQXAC9IVRw"],
|
||||
["--check-source", "https://barelystarted.miruro.tv/watch/1/show?ep=1"],
|
||||
["--check-source", " " + watch], ["--check-source", watch + "\n"],
|
||||
["--check-source", watch + "?token=" + secret], ["--check-source", watch + "#" + secret],
|
||||
["--check-source", watch.replacingOccurrences(of: "https:", with: "http:")],
|
||||
["--check-source", watch.replacingOccurrences(of: "/1/1/", with: "/01/1/")],
|
||||
["--check-source", watch.replacingOccurrences(of: "/1/sub", with: "/2147483648/sub")],
|
||||
["--check-source", watch.replacingOccurrences(of: "/sub", with: "/dub")],
|
||||
["--check-source", "https://" + secret + "@zokoanime.video/stream/ani/1/1/sub"],
|
||||
["--check-source", "https://zokoanime.video.evil.example/stream/ani/1/1/sub"],
|
||||
["--check-source", "https://zokoanime.video/stream/ani/%31/1/sub"],
|
||||
["--check-source", String(repeating: secret, count: 100)]
|
||||
] {
|
||||
do {
|
||||
_ = try PlayerCommand.parse(arguments)
|
||||
} catch {
|
||||
try require(!error.localizedDescription.contains(secret), "argument errors redact input")
|
||||
continue
|
||||
}
|
||||
throw Failure()
|
||||
}
|
||||
for status in SourceCheck.Status.allCases {
|
||||
let json = status.json
|
||||
let row = try JSONSerialization.jsonObject(with: Data(json.utf8)) as! [String: Any]
|
||||
try require(row.count == 4 && row["version"] as? Int == 1 && row["provider"] as? String == "zokoanime" &&
|
||||
row["track"] as? String == "sub" && row["status"] as? String == status.rawValue,
|
||||
"one bounded result schema without source addresses or error strings")
|
||||
try require(json.utf8.count < 128 && !json.contains("\n"), "bounded single-line JSON")
|
||||
}
|
||||
let root = URL(string: "https://hls.dramahot.top/master.m3u8?token=" + secret)!
|
||||
let master = "#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=1000000\nvideo.m3u8\n"
|
||||
let media = "#EXTM3U\n#EXT-X-TARGETDURATION:10\n#EXTINF:10,\nhttps://hls1.drama1.cfd/segment.jpg\n#EXT-X-ENDLIST\n"
|
||||
try MiruroTransport.validateZokoManifest(master, base: root)
|
||||
try MiruroTransport.validateZokoManifest(media, base: root)
|
||||
for malformed in ["<html>" + secret + "</html>", "#EXTM3U", "#EXTM3Ugarbage\n" + master,
|
||||
"#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=1\n", "#EXTM3U\nrandom.txt\n",
|
||||
master.replacingOccurrences(of: "video.m3u8", with: "https://127.0.0.1/private.m3u8"),
|
||||
master.replacingOccurrences(of: "video.m3u8", with: "https://hls1.drama1.cfd/private.m3u8"),
|
||||
media.replacingOccurrences(of: "https://hls1.drama1.cfd/segment.jpg", with: "https://evil.example/" + secret),
|
||||
"#EXTM3U\n#EXT-X-KEY:METHOD=AES-128,URI=\"http://127.0.0.1/" + secret + "\"\n" + media,
|
||||
String(repeating: "x", count: ZokoResolver.bodyLimit + 1)] {
|
||||
do { try MiruroTransport.validateZokoManifest(malformed, base: root) }
|
||||
catch {
|
||||
try require(!error.localizedDescription.contains(secret), "manifest errors redact source details")
|
||||
continue
|
||||
}
|
||||
throw Failure()
|
||||
}
|
||||
// Exercise error classification, not fake provider availability. Live proof uses the actual CLI below.
|
||||
for category in [ZokoError.unavailable, .transport, .protection, .malformed, .mapping] {
|
||||
let result = await SourceCheck.run(mediaID: "1", episode: 1, resolve: { _, _ in throw category },
|
||||
validate: { _ in throw Failure() })
|
||||
try require(result == (category == .unavailable ? .unavailable : .failed), "only explicit missing episode is unavailable")
|
||||
}
|
||||
let source = ResolvedSource(url: root.absoluteString, headers: "", identity: "anilist:1:1", title: "Fixture",
|
||||
subtitles: [], requirements: .init(zoko: true))
|
||||
let resourceFailure = await SourceCheck.run(mediaID: "1", episode: 1, resolve: { _, _ in source },
|
||||
validate: { _ in throw URLError(.badServerResponse, userInfo: [NSURLErrorFailingURLStringErrorKey: root.absoluteString]) })
|
||||
try require(resourceFailure == .failed && !resourceFailure.json.contains(secret), "resource errors are failed and private")
|
||||
let deadline = await SourceCheck.run(mediaID: "1", episode: 1, timeout: .zero, resolve: { _, _ in
|
||||
try await Task.sleep(for: .seconds(60))
|
||||
return source
|
||||
}, validate: { _ in throw Failure() })
|
||||
try require(deadline == .failed, "total deadline cancels pending work")
|
||||
let cancelled = Task {
|
||||
await SourceCheck.run(mediaID: "1", episode: 1, resolve: { _, _ in
|
||||
try await Task.sleep(for: .seconds(60))
|
||||
return source
|
||||
}, validate: { _ in throw Failure() })
|
||||
}
|
||||
cancelled.cancel()
|
||||
let cancelledResult = await cancelled.value
|
||||
try require(cancelledResult == .failed, "caller cancellation stops source check")
|
||||
print("PASS: source command isolation, redaction, bounded JSON, shared manifest validation, error taxonomy, total deadline and cancellation")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import Foundation
|
||||
|
||||
private final class SponsorBlockProtocol: URLProtocol, @unchecked Sendable {
|
||||
struct Reply {
|
||||
var status = 200
|
||||
var body = Data("[]".utf8)
|
||||
var headers: [String: String] = [:]
|
||||
var chunkSize = 8192
|
||||
}
|
||||
final class State: @unchecked Sendable {
|
||||
let lock = NSLock()
|
||||
var reply = Reply()
|
||||
var requests: [URLRequest] = []
|
||||
func reset(_ reply: Reply) {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
self.reply = reply
|
||||
requests = []
|
||||
}
|
||||
func receive(_ request: URLRequest) -> Reply {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
requests.append(request)
|
||||
return reply
|
||||
}
|
||||
var captured: [URLRequest] {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
return requests
|
||||
}
|
||||
}
|
||||
static let state = State()
|
||||
override class func canInit(with request: URLRequest) -> Bool { true }
|
||||
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
|
||||
override func startLoading() {
|
||||
let reply = Self.state.receive(request)
|
||||
let response = HTTPURLResponse(url: request.url!, statusCode: reply.status,
|
||||
httpVersion: "HTTP/1.1", headerFields: reply.headers)!
|
||||
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
|
||||
for offset in stride(from: 0, to: reply.body.count, by: reply.chunkSize) {
|
||||
client?.urlProtocol(self, didLoad: reply.body.subdata(in: offset..<min(offset + reply.chunkSize, reply.body.count)))
|
||||
}
|
||||
client?.urlProtocolDidFinishLoading(self)
|
||||
}
|
||||
override func stopLoading() {}
|
||||
}
|
||||
|
||||
@main
|
||||
struct SponsorBlockChecks {
|
||||
struct Failure: Error { let message: String }
|
||||
static let id = "YOelRv7fMxY"
|
||||
static func require(_ condition: @autoclosure () -> Bool, _ message: String) throws {
|
||||
if !condition() { throw Failure(message: message) }
|
||||
}
|
||||
static func reject(_ message: String, _ operation: () async throws -> Void) async throws {
|
||||
do { try await operation() }
|
||||
catch { return }
|
||||
throw Failure(message: "Accepted \(message)")
|
||||
}
|
||||
static func row(_ start: Double = 10.25, _ end: Double = 20.75, duration: Double = 100) -> [String: Any] {
|
||||
["category": "sponsor", "actionType": "skip", "segment": [start, end], "videoDuration": duration]
|
||||
}
|
||||
static func wire(_ value: Any) throws -> Data {
|
||||
try JSONSerialization.data(withJSONObject: value, options: [.sortedKeys, .fragmentsAllowed])
|
||||
}
|
||||
static func bucket(_ rows: [Any], videoID: String = id) -> [String: Any] {
|
||||
["videoID": videoID, "segments": rows]
|
||||
}
|
||||
static func configuration() -> URLSessionConfiguration {
|
||||
let configuration = URLSessionConfiguration.ephemeral
|
||||
configuration.protocolClasses = [SponsorBlockProtocol.self]
|
||||
// Even an injected configuration must not send ambient credentials.
|
||||
configuration.httpAdditionalHeaders = ["Authorization": "secret", "Cookie": "secret"]
|
||||
return configuration
|
||||
}
|
||||
private static func resolve(_ reply: SponsorBlockProtocol.Reply) async throws -> [SponsorSegment] {
|
||||
SponsorBlockProtocol.state.reset(reply)
|
||||
return try await SponsorBlockResolver.resolve(videoID: id, configuration: configuration())
|
||||
}
|
||||
static func checks() async throws {
|
||||
let canonical = "https://www.youtube.com/watch?v=\(id)"
|
||||
try require(SponsorBlockResolver.videoID(for: URL(string: canonical)!) == id, "canonical identity")
|
||||
for invalid in ["http://www.youtube.com/watch?v=\(id)", "https://youtube.com/watch?v=\(id)",
|
||||
"https://youtu.be/\(id)", "https://www.youtube.com:443/watch?v=\(id)",
|
||||
"https://user:secret@www.youtube.com/watch?v=\(id)", canonical + "&t=10",
|
||||
canonical + "#chapter", canonical + "&v=\(id)", canonical + "x",
|
||||
"https://www.youtube.com/watch?v=YOelRv7fMxé",
|
||||
"https://www.youtube.com/watch?v=%59OelRv7fMxY",
|
||||
"https://www.youtube.com/watch?v=YOelRv7fMx.",
|
||||
"https://www.youtube.com/embed/\(id)", "https://example.org/watch?v=\(id)"] {
|
||||
try require(SponsorBlockResolver.videoID(for: URL(string: invalid)!) == nil, "reject noncanonical identity: \(invalid)")
|
||||
}
|
||||
let request = try SponsorBlockResolver.request(videoID: id)
|
||||
try require(request.url?.absoluteString == "https://sponsor.ajay.app/api/skipSegments/5db6?category=sponsor&actionType=skip&service=YouTube", "known SHA256 prefix and exact endpoint")
|
||||
try require(request.httpMethod == "GET" && !request.httpShouldHandleCookies && request.timeoutInterval == 10, "bounded credential-free GET")
|
||||
try await reject("invalid direct identity") { _ = try SponsorBlockResolver.request(videoID: "../secret") }
|
||||
|
||||
var invalidRows: [Any] = [NSNull(), "bad row", 4, ["segment": [1, 2]], row(-1, 2), row(3, 2), row(2, 2)]
|
||||
for (key, value) in [("category", "intro" as Any), ("actionType", "mute" as Any),
|
||||
("segment", [1] as Any), ("segment", [1, 2, 3] as Any),
|
||||
("segment", [true, false] as Any), ("segment", ["1", "2"] as Any),
|
||||
("videoDuration", -1 as Any), ("videoDuration", "100" as Any),
|
||||
("videoDuration", true as Any), ("videoDuration", NSNull() as Any)] {
|
||||
var malformed = row(); malformed[key] = value; invalidRows.append(malformed)
|
||||
}
|
||||
var missing = row(); missing.removeValue(forKey: "videoDuration"); invalidRows.append(missing)
|
||||
let body = try wire([NSNull(), 42, ["videoID": id],
|
||||
bucket([row(1, 2)], videoID: "aqz-KE-bpKQ"),
|
||||
bucket(invalidRows + [row(), row(80.125, 90.5, duration: 0)])])
|
||||
let segments = try SponsorBlockResolver.decode(body, videoID: id)
|
||||
try require(segments.count == 2 && segments[0].start == 10.25 && segments[0].end == 20.75 && segments[0].videoDuration == 100 && segments[1].videoDuration == 0, "lossy individual buckets/rows, exact match, fractional times and unknown duration")
|
||||
let absent = try SponsorBlockResolver.decode(body, videoID: "abcdefghijk")
|
||||
try require(absent.isEmpty, "no exact bucket match")
|
||||
for malformed in [Data("[".utf8), try wire(["segments": []]), try wire(NSNull()), Data("[NaN]".utf8)] {
|
||||
try await reject("malformed top-level JSON") { _ = try SponsorBlockResolver.decode(malformed, videoID: id) }
|
||||
}
|
||||
let overflow = Data("[{\"videoID\":\"\(id)\",\"segments\":[{\"category\":\"sponsor\",\"actionType\":\"skip\",\"segment\":[0,1e999],\"videoDuration\":100}]}]".utf8)
|
||||
let overflowRows = try SponsorBlockResolver.decode(overflow, videoID: id)
|
||||
try require(overflowRows.isEmpty, "nonfinite row numbers are rejected without poisoning the bucket")
|
||||
|
||||
func segment(_ start: Double, _ end: Double, _ reference: Double = 100) -> SponsorSegment {
|
||||
SponsorSegment(start: start, end: end, videoDuration: reference)
|
||||
}
|
||||
let ranges = SponsorSegment.visibleRanges([segment(30, 40), segment(10.25, 20.75), segment(20.75, 31), segment(12, 13)], duration: 100)
|
||||
try require(ranges == [10.25...40], "sort, union overlapping and touching ranges")
|
||||
for duration in [0, -1, Double.nan, .infinity] {
|
||||
try require(SponsorSegment.visibleRanges(segments, duration: duration).isEmpty, "unknown or invalid actual duration hides paint")
|
||||
}
|
||||
for reference in [99.0, 101.0, -1, Double.nan, .infinity] {
|
||||
try require(SponsorSegment.visibleRanges([segment(10, 20, reference)], duration: 100).isEmpty, "strict stale boundary and invalid reference duration")
|
||||
}
|
||||
for reference in [99.001, 100.999, 0] {
|
||||
try require(SponsorSegment.visibleRanges([segment(10, 20, reference)], duration: 100) == [10...20], "under-one-second mismatch or unknown reference accepted")
|
||||
}
|
||||
let invalid = [segment(-1, 2), segment(2, 2), segment(3, 2), segment(.nan, 3), segment(0, .infinity), segment(90, 101)]
|
||||
try require(SponsorSegment.visibleRanges(invalid, duration: 100).isEmpty, "never clip, rescale, or retain invalid timestamps")
|
||||
let changing = [segment(90, 100, 0)]
|
||||
try require(SponsorSegment.visibleRanges(changing, duration: 100) == [90...100] && SponsorSegment.visibleRanges(changing, duration: 99).isEmpty && SponsorSegment.visibleRanges(changing, duration: 110) == [90...100], "changing duration recomputes visibility from untouched ranges")
|
||||
|
||||
let fetched = try await resolve(.init(body: body))
|
||||
try require(fetched.count == 2, "native async streamed transport decodes fixture")
|
||||
let captured = SponsorBlockProtocol.state.captured
|
||||
try require(captured.count == 1 && captured[0].url == request.url && captured[0].httpMethod == "GET" && captured[0].value(forHTTPHeaderField: "Authorization") == nil && captured[0].value(forHTTPHeaderField: "Cookie") == nil, "one fixed-host request without injected credentials")
|
||||
let notFound = try await resolve(.init(status: 404, body: Data("not JSON".utf8)))
|
||||
try require(notFound.isEmpty, "404 is normal absence")
|
||||
for status in [401, 429, 500, 302] {
|
||||
try await reject("HTTP \(status)") {
|
||||
_ = try await resolve(.init(status: status, headers: ["Location": "https://example.org/secret"]))
|
||||
}
|
||||
try require(SponsorBlockProtocol.state.captured.count == 1, "no retries or follow-up requests")
|
||||
}
|
||||
try await reject("malformed network payload") { _ = try await resolve(.init(body: Data("oops".utf8))) }
|
||||
try await reject("oversize advertised payload") { _ = try await resolve(.init(headers: ["Content-Length": "2097153"])) }
|
||||
try await reject("oversize streamed payload without length") {
|
||||
_ = try await resolve(.init(body: Data(repeating: 32, count: SponsorBlockResolver.responseLimit + 1)))
|
||||
}
|
||||
let cancelled = Task {
|
||||
withUnsafeCurrentTask { $0?.cancel() }
|
||||
return try await SponsorBlockResolver.resolve(videoID: id, configuration: configuration())
|
||||
}
|
||||
do {
|
||||
_ = try await cancelled.value
|
||||
throw Failure(message: "Cancelled lookup succeeded")
|
||||
} catch is CancellationError {} catch let failure as Failure { throw failure }
|
||||
print("SponsorBlock checks passed")
|
||||
}
|
||||
|
||||
static func main() async {
|
||||
do {
|
||||
if Array(CommandLine.arguments.dropFirst()) == ["--live"] {
|
||||
for videoID in [id, "aqz-KE-bpKQ"] {
|
||||
let segments = try await SponsorBlockResolver.resolve(videoID: videoID)
|
||||
let intervals = segments.map { ["start": $0.start, "end": $0.end, "videoDuration": $0.videoDuration] }
|
||||
let data = try wire(["videoID": videoID, "intervals": intervals])
|
||||
print(String(decoding: data, as: UTF8.self))
|
||||
}
|
||||
} else if CommandLine.arguments.count == 1 {
|
||||
try await checks()
|
||||
} else {
|
||||
throw Failure(message: "Usage: sponsorblock-check [--live]")
|
||||
}
|
||||
} catch {
|
||||
let message = (error as? Failure)?.message ?? error.localizedDescription
|
||||
FileHandle.standardError.write(Data("FAIL: \(message)\n".utf8))
|
||||
exit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
import Foundation
|
||||
|
||||
@main
|
||||
struct StreamResolverChecks {
|
||||
struct Failure: Error { let message: String }
|
||||
static let mediaID = "OERe_a0YS4h-DlsBDOeXwX7HOO6316tw"
|
||||
static let page = "https://barelystarted.miruro.tv/watch/\(mediaID)/the-apothecary-diaries-season-3?ep=1"
|
||||
// Sanitized shape from Tubular's observed source response and projection fixture.
|
||||
static let fixture = #"""
|
||||
{
|
||||
"episodeNumber": 1,
|
||||
"config": {"sun": {"player": "native", "proxy": false,
|
||||
"hls": {"segments": {"enabled": false}, "query": {"enabled": false}}}},
|
||||
"providerOrder": ["sun", "bun"],
|
||||
"tracks": [{"track": "ssub", "providers": [{
|
||||
"provider": "sun",
|
||||
"servers": [
|
||||
{"server": "iframe", "headers": null, "streams": [], "embed": {"url": "https://embed.example/"}},
|
||||
{"server": "icarus-2-1", "headers": {"Origin": "https://ultracloud.cc", "Referer": "https://ultracloud.cc/"},
|
||||
"streams": [{"url": "https://vault.example/master.m3u8?token=secret", "format": "hls", "embed": null}]}
|
||||
],
|
||||
"subtitles": [
|
||||
{"file": "https://vault.example/english.jpeg", "format": "vtt", "language": "en", "label": "English", "default": true},
|
||||
{"file": "https://vault.example/spanish.jpeg", "format": "vtt", "language": "sp", "label": "Spanish (Latin America)", "default": false},
|
||||
{"file": "https://vault.example/unsupported.srt", "format": "srt", "language": "en", "label": "English SRT", "default": false}
|
||||
]
|
||||
}]}]
|
||||
}
|
||||
"""#
|
||||
|
||||
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 must not expose source secrets")
|
||||
return
|
||||
}
|
||||
throw Failure(message: "Accepted \(message)")
|
||||
}
|
||||
static func project(_ json: String) throws -> ResolvedSource {
|
||||
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 {
|
||||
guard case .miruro(let id, let episode) = try StreamURL.parse(" \n" + page + "\n") else {
|
||||
throw Failure(message: "Miruro URL classification")
|
||||
}
|
||||
try require(id == mediaID && episode == 1, "opaque identity and route episode retained")
|
||||
guard case .youtube(let youtube) = try StreamURL.parse("https://youtu.be/jNQXAC9IVRw") else {
|
||||
throw Failure(message: "YouTube URL classification")
|
||||
}
|
||||
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 [
|
||||
page.replacingOccurrences(of: "barelystarted.miruro.tv", with: "barelystarted.miruro.tv@evil.example"),
|
||||
page.replacingOccurrences(of: "https://", with: "http://"),
|
||||
page.replacingOccurrences(of: "https://", with: "https://user:secret@"),
|
||||
page.replacingOccurrences(of: "/watch/", with: "/info/"),
|
||||
page.replacingOccurrences(of: mediaID, with: "invalid%2Fid"),
|
||||
page.replacingOccurrences(of: mediaID, with: "%2e%2e"),
|
||||
page.replacingOccurrences(of: "?ep=1", with: ""),
|
||||
page.replacingOccurrences(of: "?ep=1", with: "?ep=0"),
|
||||
page.replacingOccurrences(of: "?ep=1", with: "?ep=01"),
|
||||
page.replacingOccurrences(of: "?ep=1", with: "?ep=-1"),
|
||||
page.replacingOccurrences(of: "?ep=1", with: "?ep=2147483648"),
|
||||
page.replacingOccurrences(of: "?ep=1", with: "?ep=%31"),
|
||||
page + "&ep=2", page + "&provider=bee", page + "#secret",
|
||||
page.replacingOccurrences(of: ".tv/", with: ".tv:444/"),
|
||||
page.replacingOccurrences(of: "?ep", with: "/?ep"),
|
||||
"file:///secret"
|
||||
] {
|
||||
try reject("unsupported or unsafe URL") { _ = try StreamURL.parse(invalid) }
|
||||
}
|
||||
let source = try project(fixture)
|
||||
try require(source.identity == "miruro:\(mediaID):1", "stable catalog identity")
|
||||
try require(source.url == "https://vault.example/master.m3u8?token=secret", "first direct HLS stream retained")
|
||||
try require(source.headers == "Origin: https://ultracloud.cc\r\nReferer: https://ultracloud.cc/\r\n", "all server headers retained")
|
||||
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.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"))
|
||||
try require(renewed.identity == source.identity && renewed.url != source.url,
|
||||
"token rotation does not change stable identity")
|
||||
for (old, replacement) in [
|
||||
("\"episodeNumber\": 1", "\"episodeNumber\": 49"),
|
||||
("\"proxy\": false", "\"proxy\": true"),
|
||||
("\"format\": \"hls\"", "\"format\": \"mp4\""),
|
||||
("https://vault.example/master.m3u8?token=secret", "file:///secret"),
|
||||
("\"embed\": null", "\"embed\": false"),
|
||||
("https://ultracloud.cc/\"", "https://ultracloud.cc/\\r\\nX-Injected: secret\""),
|
||||
("\"Origin\":", "\"origin\": \"secret\", \"Origin\":")
|
||||
] {
|
||||
try reject("unsafe/unsupported source projection") {
|
||||
_ = try project(fixture.replacingOccurrences(of: old, with: replacement))
|
||||
}
|
||||
}
|
||||
try reject("oversized metadata") {
|
||||
_ = try MiruroResolver.decode(Data(repeating: 32, count: MiruroResolver.bodyLimit + 1), mediaID: mediaID, episode: 1)
|
||||
}
|
||||
let responseURL = URL(string: "https://barelystarted.miruro.tv/api/sources")!
|
||||
for (status, type, length) in [(403, "text/html", "12"), (302, "application/json", "12"), (200, "text/html", "12"), (200, "application/json", "2097153")] {
|
||||
let response = HTTPURLResponse(url: responseURL, statusCode: status, httpVersion: nil,
|
||||
headerFields: ["Content-Type": type, "Content-Length": length])!
|
||||
try reject("protection/redirect/oversized response") { try MiruroResolver.validateResponse(response) }
|
||||
}
|
||||
let cancelled = Task {
|
||||
withUnsafeCurrentTask { $0?.cancel() }
|
||||
return try await MiruroResolver.resolve(mediaID: mediaID, episode: 1)
|
||||
}
|
||||
do {
|
||||
_ = try await cancelled.value
|
||||
throw Failure(message: "Cancelled resolution was accepted")
|
||||
} catch is CancellationError {}
|
||||
print("PASS: streaming URL security, provider/track choices, proxy/HLS requirements, independent captions, bounded responses and cancellation")
|
||||
}
|
||||
|
||||
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 sources = try await MiruroResolver.resolve(mediaID: id, episode: episode)
|
||||
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" {
|
||||
let data = try Data(contentsOf: URL(fileURLWithPath: args[1]))
|
||||
let sources = try MiruroResolver.decode(data, mediaID: mediaID, episode: 1)
|
||||
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 {
|
||||
try await checks()
|
||||
} else {
|
||||
throw Failure(message: "Usage: stream-resolver-check [--live EPISODE_URL | --fixture SOURCES_JSON]")
|
||||
}
|
||||
} catch {
|
||||
let message = (error as? Failure)?.message ?? error.localizedDescription
|
||||
FileHandle.standardError.write(Data("FAIL: \(message)\n".utf8))
|
||||
exit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
+146
-5
@@ -7,6 +7,7 @@ import argparse
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
@@ -44,6 +45,8 @@ class StreamingServer:
|
||||
"network-vod.m3u8": "combined",
|
||||
**{f"network-vod-{index}.ts": "combined" for index in range(4)},
|
||||
}
|
||||
resolved_token = "token=part%2Fone+two%26tail"
|
||||
resolved_secret = "resolved-header-secret"
|
||||
|
||||
def __init__(self, fixtures: str | Path):
|
||||
self.fixtures = Path(fixtures).resolve()
|
||||
@@ -51,6 +54,8 @@ class StreamingServer:
|
||||
self.gates: dict[str, _Gate] = {}
|
||||
self.stats: dict[str, dict[str, int]] = {}
|
||||
self.prefixes: dict[tuple[str, int], dict[str, float]] = {}
|
||||
self.resolved_stats: dict[str, dict[str, dict[str, int]]] = {}
|
||||
self.sink_stats = {"requests": 0, "credentials": 0}
|
||||
self.stopping = threading.Event()
|
||||
owner = self
|
||||
|
||||
@@ -70,6 +75,19 @@ class StreamingServer:
|
||||
# Cancellation can close a body read or an idle keep-alive connection.
|
||||
self.close_connection = True
|
||||
|
||||
class SinkHandler(Handler):
|
||||
def do_GET(self):
|
||||
with owner.lock:
|
||||
owner.sink_stats["requests"] += 1
|
||||
owner.sink_stats["credentials"] += int(
|
||||
self.headers.get("Authorization") is not None or
|
||||
self.headers.get("X-Terebi-Fixture") is not None)
|
||||
owner._reply(self, 403, b"Unrelated origin")
|
||||
|
||||
self.sink = ThreadingHTTPServer(("127.0.0.1", 0), SinkHandler)
|
||||
self.sink.daemon_threads = True
|
||||
self.sink_thread = threading.Thread(target=self.sink.serve_forever, daemon=True)
|
||||
self.sink_url = f"http://127.0.0.1:{self.sink.server_port}"
|
||||
self.http = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||
self.http.daemon_threads = True
|
||||
self.thread = threading.Thread(target=self.http.serve_forever, daemon=True)
|
||||
@@ -77,6 +95,7 @@ class StreamingServer:
|
||||
|
||||
def __enter__(self):
|
||||
self.thread.start()
|
||||
self.sink_thread.start()
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args):
|
||||
@@ -87,6 +106,9 @@ class StreamingServer:
|
||||
self.http.shutdown()
|
||||
self.http.server_close()
|
||||
self.thread.join()
|
||||
self.sink.shutdown()
|
||||
self.sink.server_close()
|
||||
self.sink_thread.join()
|
||||
|
||||
@staticmethod
|
||||
def _reply(request, status: int, body: bytes, content_type="application/json"):
|
||||
@@ -155,9 +177,92 @@ class StreamingServer:
|
||||
if "pos" in packet and "pts_time" in packet
|
||||
and float(packet["pts_time"]) - origin >= seconds)
|
||||
|
||||
def _resolved(self, request, parsed, parts):
|
||||
scenario = parts[1]
|
||||
relative = "/".join(parts[2:])
|
||||
kind = ("master" if relative == "master.m3u8" else
|
||||
"variant" if relative == "nested/media.m3u8" else
|
||||
"key" if relative == "keys/key.bin" else
|
||||
"init" if relative in ("init.mp4", "init.jpeg") else "segment")
|
||||
with self.lock:
|
||||
stats = self.resolved_stats.setdefault(scenario, {}).setdefault(
|
||||
kind, {"requests": 0, "denied": 0, "redirects": 0})
|
||||
stats["requests"] += 1
|
||||
denied = (parsed.query != self.resolved_token or
|
||||
request.headers.get("X-Terebi-Fixture") != "combined" or
|
||||
request.headers.get("Authorization") != "Bearer " + self.resolved_secret)
|
||||
stats["denied"] += int(denied)
|
||||
if denied:
|
||||
self._reply(request, 403, b"Required source credentials or complete token missing")
|
||||
return
|
||||
if scenario == "redirect-" + kind or scenario == "redirect-same-master" and kind == "master":
|
||||
with self.lock:
|
||||
stats["redirects"] += 1
|
||||
request.send_response(302)
|
||||
request.send_header("Location", self.base_url + "/media/network-vod.m3u8"
|
||||
if scenario == "redirect-same-master" else self.sink_url + "/leak")
|
||||
request.send_header("Content-Length", "0")
|
||||
request.send_header("Connection", "close")
|
||||
request.end_headers()
|
||||
request.close_connection = True
|
||||
return
|
||||
fragmented = scenario in ("fmp4", "image-fmp4", "redirect-init", "external-init")
|
||||
image_named = scenario.startswith("image-")
|
||||
directory = self.fixtures / "resolved-hls" / ("fmp4" if fragmented else "encrypted")
|
||||
|
||||
def uri(path, resource_kind):
|
||||
if image_named and resource_kind in ("segment", "init"):
|
||||
path = path.rsplit(".", 1)[0] + (".jpeg" if fragmented else ".jpg")
|
||||
address = path + "?" + self.resolved_token
|
||||
if scenario == "external-" + resource_kind:
|
||||
return self.sink_url + "/leak/" + address
|
||||
return address
|
||||
|
||||
if kind == "master":
|
||||
body = ("#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=500000\n" +
|
||||
uri("nested/media.m3u8", "variant") + "\n").encode()
|
||||
elif kind == "variant":
|
||||
playlist = (directory / "media.m3u8").read_text()
|
||||
playlist = re.sub(r'URI="[^"]+"', lambda match: 'URI="' + uri(
|
||||
"../init.mp4" if fragmented else "../keys/key.bin",
|
||||
"init" if fragmented else "key") + '"', playlist)
|
||||
playlist = re.sub(r"(?m)^(seg-[^\n]+)$",
|
||||
lambda match: uri("../segments/" + match[1], "segment"), playlist)
|
||||
body = playlist.encode()
|
||||
else:
|
||||
name = relative.removeprefix("segments/").removeprefix("keys/")
|
||||
# Resource routing is deliberately finite; never serve arbitrary paths.
|
||||
if not re.fullmatch(r"key\.bin|init\.(?:mp4|jpeg)|seg-\d+\.(?:ts|m4s|jpg|jpeg)", name):
|
||||
self._reply(request, 404, b"Unknown HLS resource")
|
||||
return
|
||||
if image_named:
|
||||
name = (name.rsplit(".", 1)[0] + ".mp4" if kind == "init" else
|
||||
name.rsplit(".", 1)[0] + (".m4s" if fragmented else ".ts")
|
||||
if kind == "segment" else name)
|
||||
body = (directory / name).read_bytes()
|
||||
self._reply(request, 200, body, "application/vnd.apple.mpegurl"
|
||||
if kind in ("master", "variant") else
|
||||
"image/jpeg" if image_named and kind in ("segment", "init") else "application/octet-stream")
|
||||
|
||||
def _get(self, request):
|
||||
parsed = urlsplit(request.path)
|
||||
parts = unquote(parsed.path).strip("/").split("/")
|
||||
if parts == ["control", "resolved-stats"]:
|
||||
with self.lock:
|
||||
body = json.dumps({"sources": self.resolved_stats, "sink": self.sink_stats}).encode()
|
||||
self._reply(request, 200, body)
|
||||
return
|
||||
if len(parts) >= 3 and parts[0] == "resolved":
|
||||
self._resolved(request, parsed, parts)
|
||||
return
|
||||
if parts == ["youtube-redirect", "av.mp4"]:
|
||||
request.send_response(302)
|
||||
request.send_header("Location", self.base_url + "/media/av.mp4")
|
||||
request.send_header("Content-Length", "0")
|
||||
request.send_header("Connection", "close")
|
||||
request.end_headers()
|
||||
request.close_connection = True
|
||||
return
|
||||
if parts == ["control", "stats"]:
|
||||
with self.lock:
|
||||
body = json.dumps(self.stats).encode()
|
||||
@@ -181,7 +286,7 @@ class StreamingServer:
|
||||
|
||||
mode = parts[0]
|
||||
token = None
|
||||
if len(parts) == 3 and mode in ("stall", "stall-open", "chunked-media"):
|
||||
if len(parts) == 3 and mode in ("stall", "stall-open", "chunked-media", "disconnect"):
|
||||
token, name = parts[1:]
|
||||
if not re.fullmatch(r"[A-Za-z0-9_-]+", token):
|
||||
self._reply(request, 400, b"Invalid gate token", "text/plain")
|
||||
@@ -229,14 +334,14 @@ class StreamingServer:
|
||||
query = parse_qs(parsed.query)
|
||||
try:
|
||||
gate_at = (self._timestamp_offset(name, float(query["after_seconds"][0]))
|
||||
if mode in ("stall", "chunked-media") and "after_seconds" in query else
|
||||
int(query.get("after", ["131072"])[0]) if mode in ("stall", "chunked-media") else start)
|
||||
if mode in ("stall", "chunked-media", "disconnect") and "after_seconds" in query else
|
||||
int(query.get("after", ["131072"])[0]) if mode in ("stall", "chunked-media", "disconnect") else start)
|
||||
except (ValueError, OverflowError):
|
||||
self._reply(request, 400, b"Invalid gate offset", "text/plain")
|
||||
return
|
||||
with self.lock:
|
||||
if token not in self.gates:
|
||||
prefix = self._prefix_ends(name, gate_at) if mode in ("stall", "chunked-media") else {}
|
||||
prefix = self._prefix_ends(name, gate_at) if mode in ("stall", "chunked-media", "disconnect") else {}
|
||||
self.gates[token] = _Gate(size, prefix)
|
||||
gate = self.gates[token]
|
||||
gate.requests += 1
|
||||
@@ -266,6 +371,9 @@ class StreamingServer:
|
||||
request.end_headers()
|
||||
request.wfile.flush()
|
||||
request.close_connection = not persistent
|
||||
if (mode == "disconnect" and not should_gate and start <= gate_at
|
||||
and query.get("fail_reconnect") == ["1"]):
|
||||
return # Every resumed response at the broken offset is also truncated.
|
||||
cursor = start
|
||||
with path.open("rb") as media:
|
||||
media.seek(start)
|
||||
@@ -280,6 +388,8 @@ class StreamingServer:
|
||||
with self.lock:
|
||||
gate.waiting -= 1
|
||||
should_gate = False
|
||||
if mode == "disconnect":
|
||||
return # Advertise the complete range, then cut its body short.
|
||||
amount = min(16384, end - cursor + 1)
|
||||
if should_gate:
|
||||
amount = min(amount, gate_at - cursor)
|
||||
@@ -295,6 +405,27 @@ class StreamingServer:
|
||||
gate.bytes += len(chunk)
|
||||
|
||||
|
||||
def resolved_fixtures(fixtures: Path):
|
||||
"""Generate deterministic AES-128 TS and fMP4 fixtures without external services."""
|
||||
for fragmented in (False, True):
|
||||
directory = fixtures.resolve() / "resolved-hls" / ("fmp4" if fragmented else "encrypted")
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
command = ["ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
|
||||
"-i", str(fixtures.resolve() / "av.mp4"),
|
||||
"-c:v", "libx264", "-g", "24", "-keyint_min", "24", "-sc_threshold", "0",
|
||||
"-c:a", "aac", "-hls_time", "1", "-hls_playlist_type", "vod"]
|
||||
if fragmented:
|
||||
command += ["-hls_segment_type", "fmp4", "-hls_fmp4_init_filename", "init.mp4"]
|
||||
else:
|
||||
(directory / "key.bin").write_bytes(bytes(range(16)))
|
||||
(directory / "keyinfo").write_text("key.bin\n" + str(directory / "key.bin") +
|
||||
"\n000102030405060708090a0b0c0d0e0f\n")
|
||||
command += ["-hls_key_info_file", str(directory / "keyinfo")]
|
||||
command += ["-hls_segment_filename", str(directory / ("seg-%d.m4s" if fragmented else "seg-%d.ts")),
|
||||
str(directory / "media.m3u8")]
|
||||
subprocess.run(command, check=True, timeout=30)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("binary", type=Path)
|
||||
@@ -305,12 +436,22 @@ def main():
|
||||
missing = [name for name in required if not (args.fixtures / name).is_file()]
|
||||
if missing:
|
||||
parser.error("Missing streaming fixtures: " + ", ".join(missing))
|
||||
resolved_fixtures(args.fixtures)
|
||||
with StreamingServer(args.fixtures) as server:
|
||||
try:
|
||||
result = subprocess.run([str(args.binary.resolve()), server.base_url,
|
||||
str(args.fixtures.resolve())], timeout=180, check=False)
|
||||
str(args.fixtures.resolve())], timeout=180, check=False,
|
||||
capture_output=True)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise SystemExit("FAIL: native network check exceeded its infrastructure deadline")
|
||||
private = (StreamingServer.resolved_token.encode(), StreamingServer.resolved_secret.encode(),
|
||||
b"Bearer ", server.base_url.encode(), server.sink_url.encode())
|
||||
if any(secret in result.stdout or secret in result.stderr for secret in private):
|
||||
raise SystemExit("FAIL: native diagnostics exposed source URL or credentials")
|
||||
print(result.stdout.decode(), end="")
|
||||
print(result.stderr.decode(), end="", file=sys.stderr)
|
||||
if server.sink_stats["requests"] or server.sink_stats["credentials"]:
|
||||
raise SystemExit("FAIL: resolved playback contacted an unrelated origin")
|
||||
raise SystemExit(result.returncode)
|
||||
|
||||
|
||||
|
||||
@@ -2,6 +2,20 @@ import AppKit
|
||||
import MediaPlayer
|
||||
import TerebiConfiguration
|
||||
|
||||
// Native focus eligibility normally depends on the user's Full Keyboard Access setting.
|
||||
// Force eligibility only; keep the controls' real AppKit responder/key behavior.
|
||||
private final class KeyboardCheckButton: NSButton {
|
||||
override var acceptsFirstResponder: Bool { true }
|
||||
}
|
||||
|
||||
private final class KeyboardCheckSlider: NSSlider {
|
||||
override var acceptsFirstResponder: Bool { true }
|
||||
}
|
||||
|
||||
private final class KeyboardCheckLabel: NSTextField {
|
||||
override var acceptsFirstResponder: Bool { true }
|
||||
}
|
||||
|
||||
@main
|
||||
@MainActor
|
||||
struct SystemPlaybackChecks {
|
||||
@@ -17,12 +31,268 @@ struct SystemPlaybackChecks {
|
||||
require(condition(), message)
|
||||
}
|
||||
|
||||
private final class DragCheckWindow: NSWindow {
|
||||
var drags = 0
|
||||
var fullscreenToggles = 0
|
||||
override func performDrag(with event: NSEvent) { drags += 1 }
|
||||
override func toggleFullScreen(_ sender: Any?) { fullscreenToggles += 1 }
|
||||
}
|
||||
|
||||
static func checkWindowSurface(defaults: UserDefaults) {
|
||||
let model = PlayerModel(defaults: defaults)
|
||||
defer { model.shutdown() }
|
||||
let window = DragCheckWindow(contentRect: NSRect(x: -10_000, y: -10_000, width: 640, height: 360),
|
||||
styleMask: [.titled, .resizable, .fullSizeContentView],
|
||||
backing: .buffered, defer: false)
|
||||
window.isReleasedWhenClosed = false
|
||||
defer { window.close() }
|
||||
let surface = PlaybackSurface.DragView(model: model)
|
||||
window.contentView!.addSubview(surface)
|
||||
model.attachWindow(window)
|
||||
surface.frame = window.contentView!.bounds
|
||||
@MainActor func event(_ type: NSEvent.EventType, clicks: Int = 1) -> NSEvent {
|
||||
NSEvent.mouseEvent(with: type, location: NSPoint(x: 100, y: 100), modifierFlags: [],
|
||||
timestamp: 0, windowNumber: window.windowNumber, context: nil,
|
||||
eventNumber: 0, clickCount: clicks, pressure: 0)!
|
||||
}
|
||||
require(surface.hitTest(NSPoint(x: 100, y: 100)) === surface,
|
||||
"video renderer and unused background route mouse input to their owning surface")
|
||||
require(!surface.mouseDownCanMoveWindow, "native background movement cannot turn clicks or resize into drags")
|
||||
for point in [NSPoint(x: 1, y: 100), NSPoint(x: 639, y: 100),
|
||||
NSPoint(x: 100, y: 1), NSPoint(x: 100, y: surface.bounds.maxY - 1)] {
|
||||
require(surface.hitTest(point) == nil, "playback drag surface leaves all native resize edges untouched")
|
||||
}
|
||||
surface.mouseDown(with: event(.leftMouseDown))
|
||||
require(window.drags == 0, "mouse-down alone never begins window dragging")
|
||||
surface.mouseUp(with: event(.leftMouseUp))
|
||||
require(window.drags == 0 && window.fullscreenToggles == 0, "single video click never drags or enters fullscreen")
|
||||
surface.mouseDown(with: event(.leftMouseDown))
|
||||
surface.mouseDragged(with: event(.leftMouseDragged))
|
||||
surface.mouseUp(with: event(.leftMouseUp))
|
||||
require(window.drags == 1 && window.fullscreenToggles == 0, "real mouse-drag callback invokes native window dragging once")
|
||||
surface.mouseDown(with: event(.leftMouseDown, clicks: 2))
|
||||
surface.mouseUp(with: event(.leftMouseUp, clicks: 2))
|
||||
require(window.drags == 1 && window.fullscreenToggles == 1, "double video click retains fullscreen instead of dragging")
|
||||
}
|
||||
|
||||
static func checkCompactProgress() {
|
||||
let times: [(Double, Double, String)] = [
|
||||
(12.9, 45.9, "12 / 45 s"),
|
||||
(59, 59.999, "59 / 59 s"),
|
||||
(0, 60, "00:00 / 01:00 m"),
|
||||
(312, 2528, "05:12 / 42:08 m"),
|
||||
(3599, 3599.999, "59:59 / 59:59 m"),
|
||||
(59, 3600, "00:00 / 01:00 h"),
|
||||
(4980, 7800, "01:23 / 02:10 h"),
|
||||
(-5, 45, "0 / 45 s"),
|
||||
(80, 45, "45 / 45 s"),
|
||||
]
|
||||
for (position, duration, expected) in times {
|
||||
require(compactProgressText(position: position, duration: duration, format: .time) == expected,
|
||||
"compact time uses total-duration units, truncation and bounded progress: \(expected)")
|
||||
}
|
||||
for (position, expected) in [(59.9, "59.9"), (59.99, "59.9"), (0.0, "0.0"),
|
||||
(99.89, "99.8"), (99.9, "100.0"), (99.99, "100.0"),
|
||||
(100.0, "100.0"), (105.0, "100.0"), (-1.0, "0.0")] {
|
||||
require(compactProgressText(position: position, duration: 100, format: .percentage) == expected,
|
||||
"compact percentage has one decimal and stays bounded: \(expected)")
|
||||
}
|
||||
for duration in [0.0, -1.0, .nan, .infinity] {
|
||||
require(compactProgressText(position: 10, duration: duration, format: .time) == nil &&
|
||||
compactProgressText(position: 10, duration: duration, format: .percentage) == nil,
|
||||
"unknown/invalid duration has no compact progress")
|
||||
}
|
||||
require(compactProgressText(position: .nan, duration: 100, format: .time) == nil &&
|
||||
compactProgressText(position: .infinity, duration: 100, format: .percentage) == nil,
|
||||
"nonfinite positions never appear as compact progress")
|
||||
require(compactProgressText(position: 59, duration: 100, format: .none) == nil,
|
||||
"disabled compact progress leaves only the title")
|
||||
}
|
||||
|
||||
static func checkQueue(directory: URL, defaults: UserDefaults) {
|
||||
let model = PlayerModel(defaults: defaults)
|
||||
defer { model.shutdown() }
|
||||
model.setVolume(0)
|
||||
let video = directory.appendingPathComponent("av.mp4")
|
||||
let audio = directory.appendingPathComponent("audio.wav")
|
||||
model.openFiles([video, audio])
|
||||
until("queue opens its naturally sorted first file") {
|
||||
model.queue.currentURL == audio && model.state.status == PLAYER_PLAYING && model.canSeek && !model.isBusy
|
||||
}
|
||||
let current = model.queue.currentID!
|
||||
require(model.setPlayback(playing: false), "pause queue before editing")
|
||||
until("queue pause acknowledged") { model.state.status == PLAYER_PAUSED && model.playbackIntent == nil }
|
||||
let revision = model.state.revision
|
||||
model.addToQueue([video])
|
||||
model.moveQueueItems(fromOffsets: IndexSet(integer: 0), toOffset: 2)
|
||||
require(model.queue.currentID == current && model.state.revision == revision &&
|
||||
model.state.status == PLAYER_PAUSED, "append and reorder do not reopen current playback")
|
||||
model.removeQueueItem(model.queue.items.last!.id)
|
||||
require(model.queue.currentID == current && model.state.revision == revision,
|
||||
"removing another queue entry leaves native playback untouched")
|
||||
model.removeQueueItem(current)
|
||||
until("removing final current entry stops despite earlier remaining entries") {
|
||||
model.queue.currentID == nil && model.state.status == PLAYER_EMPTY
|
||||
}
|
||||
require(model.queue.items.count == 1, "stopping at queue end preserves earlier entries")
|
||||
model.selectQueueItem(model.queue.items[0].id)
|
||||
until("explicit queue selection opens remaining entry") {
|
||||
model.queue.currentURL == video && model.state.status == PLAYER_PLAYING && model.canSeek && !model.isBusy
|
||||
}
|
||||
model.openFiles([video, audio])
|
||||
until("replacement queue starts with audio") {
|
||||
model.queue.currentURL == audio && model.state.status == PLAYER_PLAYING && model.canSeek && !model.isBusy
|
||||
}
|
||||
require(model.seekRelative(model.duration - model.state.position - 0.2), "queue seeks near first EOF")
|
||||
until("natural EOF advances to next queue file") {
|
||||
model.queue.currentURL == video && model.state.status == PLAYER_PLAYING && model.canSeek && !model.isBusy
|
||||
}
|
||||
require(model.seekRelative(model.duration - model.state.position - 0.2), "queue seeks near final EOF")
|
||||
until("final queue EOF stops without wrapping") { model.state.status == PLAYER_ENDED }
|
||||
require(model.queue.currentURL == video && !model.queue.canNext, "final EOF retains last selection")
|
||||
model.openFiles([directory.appendingPathComponent("hdr.mkv"), video])
|
||||
model.selectQueueItem(model.queue.items.first { $0.url.lastPathComponent == "hdr.mkv" }!.id)
|
||||
until("queued playback error is acknowledged") { model.state.status == PLAYER_ERROR && !model.isBusy }
|
||||
require(model.queue.currentURL?.lastPathComponent == "hdr.mkv",
|
||||
"playback failure stops on failed queue entry rather than skipping")
|
||||
}
|
||||
|
||||
static func checkKeyboardRouting(defaults: UserDefaults) {
|
||||
let bindings = try! KeyBindings(overrides: [
|
||||
"play": [], "pause": [], "seek_backward_5s": [],
|
||||
"show_buffer_info": ["space", "return", "left", "shift+left", "tab", "shift+tab",
|
||||
"cmd+b", "f12", "ctrl+alt+b"]
|
||||
])
|
||||
let model = PlayerModel(defaults: defaults, configuration: PlayerConfiguration(keys: bindings))
|
||||
defer { model.shutdown() }
|
||||
let window = NSWindow(contentRect: NSRect(x: -10_000, y: -10_000, width: 640, height: 360),
|
||||
styleMask: [.titled, .resizable], backing: .buffered, defer: false)
|
||||
window.isReleasedWhenClosed = false
|
||||
defer { window.close() }
|
||||
model.attachWindow(window)
|
||||
func event(_ code: UInt16, _ text: String, _ flags: NSEvent.ModifierFlags = []) -> NSEvent {
|
||||
NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: flags, timestamp: 0,
|
||||
windowNumber: window.windowNumber, context: nil, characters: text,
|
||||
charactersIgnoringModifiers: text, isARepeat: false, keyCode: code)!
|
||||
}
|
||||
let shortcuts = [event(49, " "), event(36, "\r"), event(123, "\u{F702}"),
|
||||
event(123, "\u{F702}", .shift), event(11, "b", .command),
|
||||
event(111, "\u{F70F}", .function)]
|
||||
func focus(_ responder: NSView) {
|
||||
window.makeFirstResponder(nil)
|
||||
window.contentView?.subviews.forEach { $0.removeFromSuperview() }
|
||||
responder.frame = NSRect(x: 0, y: 0, width: 300, height: 200)
|
||||
window.contentView?.addSubview(responder)
|
||||
require(window.makeFirstResponder(responder), "keyboard regression fixture accepts actual window focus")
|
||||
let focusedResponder = (responder as? NSTextField)?.currentEditor() ?? responder
|
||||
require(window.firstResponder === focusedResponder,
|
||||
"keyboard regression fixture owns actual control or field-editor focus")
|
||||
}
|
||||
let lists = [NSTableView(), NSOutlineView()]
|
||||
for list in lists {
|
||||
list.addTableColumn(NSTableColumn(identifier: NSUserInterfaceItemIdentifier("title")))
|
||||
focus(list)
|
||||
for shortcut in shortcuts {
|
||||
model.setShowsBufferInfo(false)
|
||||
require(model.handleKeyEvent(shortcut, in: window) && model.showsBufferInfo,
|
||||
"focused queue list dispatches configured \(KeyChord.from(shortcut)!) binding")
|
||||
require(window.firstResponder === list, "playback shortcuts do not steal queue focus")
|
||||
}
|
||||
let traversalModifiers: [NSEvent.ModifierFlags] = [[], .shift]
|
||||
for flags in traversalModifiers {
|
||||
model.setShowsBufferInfo(false)
|
||||
require(!model.handleKeyEvent(event(48, "\t", flags), in: window) && !model.showsBufferInfo,
|
||||
"Tab and Shift-Tab remain native traversal even when configured")
|
||||
}
|
||||
}
|
||||
let label = KeyboardCheckLabel(labelWithString: "Queue item")
|
||||
focus(label)
|
||||
model.setShowsBufferInfo(false)
|
||||
require(model.handleKeyEvent(shortcuts[0], in: window) && model.showsBufferInfo,
|
||||
"noneditable nonselectable queue label does not block playback")
|
||||
let controls: [NSControl] = [KeyboardCheckButton(), KeyboardCheckSlider()]
|
||||
for control in controls {
|
||||
focus(control)
|
||||
for shortcut in shortcuts.prefix(4) {
|
||||
model.setShowsBufferInfo(false)
|
||||
require(!model.handleKeyEvent(shortcut, in: window) && !model.showsBufferInfo,
|
||||
"deliberately focused native button/slider retains activation and navigation")
|
||||
require(window.firstResponder === control, "native control keyboard focus is preserved")
|
||||
}
|
||||
require(model.handleKeyEvent(shortcuts[4], in: window) && model.showsBufferInfo,
|
||||
"modified configured bindings still work with native control focus")
|
||||
}
|
||||
let editor = NSTextView()
|
||||
focus(editor)
|
||||
for shortcut in shortcuts {
|
||||
model.setShowsBufferInfo(false)
|
||||
require(!model.handleKeyEvent(shortcut, in: window) && !model.showsBufferInfo,
|
||||
"text editing retains configured keys instead of dispatching playback")
|
||||
}
|
||||
editor.keyDown(with: shortcuts[0])
|
||||
require(editor.string == " ", "unconsumed Space reaches actual text entry")
|
||||
label.isSelectable = true
|
||||
focus(label)
|
||||
require(!model.handleKeyEvent(shortcuts[0], in: window),
|
||||
"selectable text retains native text interaction")
|
||||
label.isSelectable = false
|
||||
label.isEditable = true
|
||||
require(!model.handleKeyEvent(shortcuts[0], in: window),
|
||||
"editable text field retains native text entry")
|
||||
focus(lists[0])
|
||||
let voiceOverChord = event(11, "b", [.control, .option])
|
||||
require(model.handleKeyEvent(voiceOverChord, in: window) != NSWorkspace.shared.isVoiceOverEnabled,
|
||||
"VoiceOver retains its Control-Option chords when enabled")
|
||||
let sheet = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 200, height: 100),
|
||||
styleMask: .titled, backing: .buffered, defer: false)
|
||||
sheet.isReleasedWhenClosed = false
|
||||
window.beginSheet(sheet)
|
||||
require(!model.handleKeyEvent(shortcuts[0], in: window), "attached sheet blocks player shortcuts")
|
||||
window.endSheet(sheet)
|
||||
sheet.close()
|
||||
let modal = NSApp.beginModalSession(for: window)
|
||||
require(!model.handleKeyEvent(shortcuts[0], in: window), "modal session blocks player shortcuts")
|
||||
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() {
|
||||
checkCompactProgress()
|
||||
_ = NSApplication.shared
|
||||
NSApp.setActivationPolicy(.prohibited)
|
||||
let suite = "terebi.system-playback-check.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suite)!
|
||||
defer { defaults.removePersistentDomain(forName: suite) }
|
||||
checkKeyboardRouting(defaults: defaults)
|
||||
checkQueue(directory: URL(fileURLWithPath: CommandLine.arguments[1], isDirectory: true), defaults: defaults)
|
||||
checkWindowSurface(defaults: defaults)
|
||||
defaults.removePersistentDomain(forName: suite)
|
||||
let initialModel = PlayerModel(defaults: defaults)
|
||||
require(!initialModel.showsBufferInfo, "buffer readout defaults off before playback")
|
||||
initialModel.setShowsBufferInfo(true)
|
||||
@@ -44,17 +314,115 @@ struct SystemPlaybackChecks {
|
||||
defer { model.shutdown() }
|
||||
require(!model.showsBufferInfo && !model.automaticYouTubeQuality && model.compactWindow.snappingEnabled,
|
||||
"session overrides never replace built-in settings")
|
||||
requirePlaybackPower(system: false, display: false, "idle player permits sleep")
|
||||
model.setVolume(0)
|
||||
let directory = URL(fileURLWithPath: CommandLine.arguments[1], isDirectory: true)
|
||||
let center = MPNowPlayingInfoCenter.default()
|
||||
let commands = MPRemoteCommandCenter.shared()
|
||||
let window = NSWindow(contentRect: NSRect(x: -10_000, y: -10_000, width: 640, height: 360),
|
||||
styleMask: [.titled, .closable, .miniaturizable, .resizable],
|
||||
backing: .buffered, defer: false)
|
||||
window.isReleasedWhenClosed = false
|
||||
defer { window.close() }
|
||||
let surface = PlaybackSurface.DragView(model: model)
|
||||
surface.frame = window.contentView!.bounds
|
||||
window.contentView!.addSubview(surface)
|
||||
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
|
||||
model.showsQueue = true
|
||||
until("queue opening settles") { !model.isQueueTransitioning }
|
||||
require(abs(window.frame.width - videoFrame.width - 260) < 0.01 &&
|
||||
abs(window.frame.height - videoFrame.height) < 0.01,
|
||||
"opening queue grows window without shrinking video")
|
||||
model.showsQueue = false
|
||||
until("queue closing settles") { !model.isQueueTransitioning }
|
||||
require(abs(window.frame.width - videoFrame.width) < 0.01 &&
|
||||
abs(window.frame.height - videoFrame.height) < 0.01,
|
||||
"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"))
|
||||
require(model.isBusy && model.perform(.enterCompact) && model.isCompact,
|
||||
"compact entry is available before native media opening acknowledges")
|
||||
until("decoded anamorphic metadata replaces provisional compact geometry") {
|
||||
guard let presentation = model.compactWindow.presentationWindow else { return false }
|
||||
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()
|
||||
require(!model.isCompact && !model.compactWindow.isTransitioning,
|
||||
"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 }
|
||||
require(!model.canPerform(.enterCompact), "empty playback does not enable compact entry")
|
||||
|
||||
model.open(directory.appendingPathComponent("av.mp4"))
|
||||
if !NSWorkspace.shared.isVoiceOverEnabled {
|
||||
model.setPointerInsideWindow(false)
|
||||
require(!model.controlsVisible, "pointer exit hides playback chrome while media is loading")
|
||||
model.setPointerInsideWindow(true)
|
||||
}
|
||||
until("open starts authoritative playback") { model.canTogglePlayback && model.state.status == PLAYER_PLAYING }
|
||||
model.showsQueue = true
|
||||
until("queue opens before live resize") { !model.isQueueTransitioning }
|
||||
for delta in [NSSize(width: 160, height: 0), NSSize(width: 0, height: 90),
|
||||
NSSize(width: -100, height: 0), NSSize(width: 0, height: -60)] {
|
||||
let proposal = NSSize(width: window.frame.width + delta.width, height: window.frame.height + delta.height)
|
||||
guard let constrained = window.delegate?.windowWillResize?(window, to: proposal) else {
|
||||
fatalError("FAIL: native resize delegate must constrain queue video geometry")
|
||||
}
|
||||
var frame = window.frame
|
||||
frame.size = constrained
|
||||
window.setFrame(frame, display: true)
|
||||
let content = window.contentRect(forFrameRect: window.frame)
|
||||
require(abs((content.width - 260) / content.height - model.state.video_aspect_ratio) < 0.001,
|
||||
"horizontal and vertical queue resizing preserves video aspect with fixed sidebar width")
|
||||
require(delta.width == 0 ? abs(frame.height - proposal.height) < 1 : abs(frame.width - proposal.width) < 1,
|
||||
"aspect-locked queue window follows the dragged axis")
|
||||
}
|
||||
model.showsQueue = false
|
||||
until("queue closes after live resize") { !model.isQueueTransitioning }
|
||||
require(center.playbackState == .playing, "playing publishes macOS playback ownership")
|
||||
requirePlaybackPower(system: true, display: true, "local video keeps the Mac and display awake")
|
||||
var bufferingState = model.state
|
||||
bufferingState.buffering = true
|
||||
model.applyState(bufferingState)
|
||||
requirePlaybackPower(system: true, display: true, "buffering does not drop video sleep prevention")
|
||||
require(model.setPlayback(playing: false), "explicit pause accepted")
|
||||
until("pause acknowledged") { model.state.status == PLAYER_PAUSED }
|
||||
requirePlaybackPower(system: false, display: false, "pause releases sleep prevention")
|
||||
let pausedPosition = model.state.position
|
||||
if !NSWorkspace.shared.isVoiceOverEnabled {
|
||||
model.setControlsHovered(true)
|
||||
model.setTitleHovered(true)
|
||||
model.setPointerInsideWindow(false)
|
||||
require(!model.controlsVisible,
|
||||
"pointer exit hides paused playback chrome despite stale hover and inactive window")
|
||||
model.setControlsHovered(false)
|
||||
model.setTitleHovered(false)
|
||||
model.updateWindowState()
|
||||
require(!model.controlsVisible,
|
||||
"late hover/focus updates cannot reshow chrome while the pointer is outside")
|
||||
model.revealControls(forKeyboardNavigation: true)
|
||||
model.revealControls()
|
||||
require(model.controlsVisible, "Tab navigation can reveal controls without moving the pointer")
|
||||
model.setPointerInsideWindow(false)
|
||||
require(!model.controlsVisible, "another pointer exit dismisses keyboard-revealed controls")
|
||||
model.setPointerInsideWindow(true)
|
||||
require(model.controlsVisible, "pointer reentry reveals paused playback chrome")
|
||||
}
|
||||
require(model.setPlayback(playing: false), "repeated pause accepted")
|
||||
until("idempotent pause does not restart playback") {
|
||||
model.state.status == PLAYER_PAUSED && center.playbackState == .paused
|
||||
@@ -95,7 +463,7 @@ struct SystemPlaybackChecks {
|
||||
require(commands.nextTrackCommand.isEnabled,
|
||||
"seekability changes re-enable commands even when playback metadata is unchanged")
|
||||
|
||||
require(model.seekRelative(1), "relative seek accepted")
|
||||
for _ in 0..<64 { require(model.seekRelative(1.0 / 64), "rapid native skips retain accumulated target") }
|
||||
until("paused seek acknowledged") {
|
||||
model.state.status == PLAYER_PAUSED && !model.state.seek_pending &&
|
||||
abs(model.state.position - settledPosition - 1) < 0.05
|
||||
@@ -108,6 +476,21 @@ struct SystemPlaybackChecks {
|
||||
require(model.setPlayback(playing: true), "repeated play accepted")
|
||||
until("repeated play keeps playing") { model.state.status == PLAYER_PLAYING && model.state.position > 0.2 }
|
||||
|
||||
if !NSWorkspace.shared.isVoiceOverEnabled {
|
||||
require(model.perform(.enterCompact), "compact playback is available for hidden-keyboard seeking")
|
||||
until("compact entry completes before hiding controls") { !model.compactWindow.isTransitioning }
|
||||
model.hideControls(hideCursor: false)
|
||||
require(!model.controlsVisible, "playing chrome can be hidden before a keyboard seek")
|
||||
require(model.handleKey(try! KeyChord("left")), "keyboard seek is accepted while chrome is hidden")
|
||||
require(!model.controlsVisible, "keyboard seek must not flash playback chrome")
|
||||
until("hidden keyboard seek acknowledges the start boundary") {
|
||||
require(!model.controlsVisible, "seek acknowledgement must preserve hidden chrome")
|
||||
return !model.state.seek_pending && model.state.position < 0.15
|
||||
}
|
||||
require(model.perform(.exitCompact), "keyboard seek check restores normal mode")
|
||||
until("compact exit completes") { !model.compactWindow.isTransitioning }
|
||||
}
|
||||
|
||||
// Closing must hide the last polled retained range before the next state poll.
|
||||
var retainedSnapshot = model.state
|
||||
retainedSnapshot.buffered_start = retainedSnapshot.position
|
||||
@@ -123,6 +506,7 @@ struct SystemPlaybackChecks {
|
||||
require(model.bufferedRange == nil, "accepted close immediately hides a stale retained-media snapshot")
|
||||
require(center.nowPlayingInfo == nil && center.playbackState == .stopped,
|
||||
"accepted close releases system playback immediately")
|
||||
requirePlaybackPower(system: false, display: false, "close releases sleep prevention immediately")
|
||||
require(!commands.togglePlayPauseCommand.isEnabled && !commands.nextTrackCommand.isEnabled,
|
||||
"closed source cannot receive system playback or seek commands")
|
||||
|
||||
@@ -187,9 +571,21 @@ struct SystemPlaybackChecks {
|
||||
until("decode error acknowledged") { model.state.status == PLAYER_ERROR && !model.isBusy }
|
||||
require(center.nowPlayingInfo == nil && !commands.playCommand.isEnabled,
|
||||
"failed source does not claim playback keys")
|
||||
requirePlaybackPower(system: false, display: false, "playback failure permits sleep")
|
||||
model.open(localURL)
|
||||
until("playback resumes before end-of-media check") {
|
||||
model.state.status == PLAYER_PLAYING && model.canSeek && !model.state.seek_pending
|
||||
}
|
||||
require(model.seekRelative(model.duration - model.state.position - 0.2), "seek near end accepted")
|
||||
until("video reaches its natural end") { model.state.status == PLAYER_ENDED }
|
||||
requirePlaybackPower(system: false, display: false, "end of media permits sleep")
|
||||
model.open(directory.appendingPathComponent("audio.wav"))
|
||||
until("audio-only playback starts") { model.state.status == PLAYER_PLAYING && !model.state.has_video }
|
||||
requirePlaybackPower(system: true, display: false, "audio keeps the Mac awake but permits display sleep")
|
||||
model.shutdown()
|
||||
require(center.nowPlayingInfo == nil && !commands.pauseCommand.isEnabled,
|
||||
"shutdown releases system playback")
|
||||
requirePlaybackPower(system: false, display: false, "shutdown releases active sleep prevention")
|
||||
print("System playback checks passed")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
// Build like seek-queue-check, with -fobjc-arc and the bundled FFmpeg flags,
|
||||
// linking the normal player objects EXCEPT player.o (the implementation is below).
|
||||
// Build the same source again with -DTEREBI_SOFTWARE_DECODE for the baseline.
|
||||
// Run: video-decoding-check [--metal | --preroll] [--frames 360] [--fps 30] fixture ...
|
||||
// Fixtures: video-only CFR testsrc2, zero-origin PTS, BT.709 SDR, square SAR.
|
||||
// Benchmark excludes opening, pixel checks, seeking and Metal; there is no pacing,
|
||||
// audio-device setup or playback clock. Wall/process CPU are measurements only.
|
||||
#include "../Sources/Core/player.cpp"
|
||||
#import <AppKit/AppKit.h>
|
||||
#import <MetalKit/MetalKit.h>
|
||||
extern "C" {
|
||||
#include <libavutil/pixdesc.h>
|
||||
}
|
||||
#include <cstdlib>
|
||||
#include <ctime>
|
||||
|
||||
static void require(bool condition, const char *message) {
|
||||
if (!condition) { std::fprintf(stderr, "FAIL: %s\n", message); std::exit(1); }
|
||||
}
|
||||
|
||||
static Frame retain(const AVFrame *source) {
|
||||
Frame frame(av_frame_clone(source));
|
||||
if (!frame) throw std::bad_alloc();
|
||||
return frame;
|
||||
}
|
||||
|
||||
static void metadata(Player &player, const AVFrame *frame) {
|
||||
const auto *parameters = player.tracks[1].stream()->codecpar;
|
||||
require(frame->width == parameters->width && frame->height == parameters->height,
|
||||
"readable frame preserves coded dimensions");
|
||||
require(frame->color_primaries == AVCOL_PRI_BT709 && frame->color_trc == AVCOL_TRC_BT709 &&
|
||||
frame->colorspace == AVCOL_SPC_BT709, "readable frame preserves BT.709 SDR metadata");
|
||||
require(parameters->color_range == AVCOL_RANGE_UNSPECIFIED ||
|
||||
frame->color_range == parameters->color_range, "readable frame preserves color range");
|
||||
const AVRational aspect = av_guess_sample_aspect_ratio(player.format,
|
||||
player.tracks[1].stream(), const_cast<AVFrame *>(frame));
|
||||
require(aspect.num > 0 && aspect.num == aspect.den, "consumer sees square sample aspect ratio");
|
||||
const auto *description = av_pix_fmt_desc_get(static_cast<AVPixelFormat>(frame->format));
|
||||
require(description && !(description->flags & AV_PIX_FMT_FLAG_HWACCEL),
|
||||
"queued frames have a CPU-readable pixel format");
|
||||
}
|
||||
|
||||
// Read every visible byte, not padding: this detects a mapped frame whose pixel
|
||||
// buffer was unlocked/recycled when a decoder frame, timeline or codec was freed.
|
||||
static uint64_t pixels(const AVFrame *frame) {
|
||||
const auto format = static_cast<AVPixelFormat>(frame->format);
|
||||
int widths[4]{};
|
||||
require(av_image_fill_linesizes(widths, format, frame->width) >= 0, "readable plane widths");
|
||||
ptrdiff_t strides[4]{};
|
||||
for (int plane = 0; plane < 4; ++plane) strides[plane] = widths[plane];
|
||||
size_t sizes[4]{};
|
||||
require(av_image_fill_plane_sizes(sizes, format, frame->height, strides) >= 0,
|
||||
"readable plane heights");
|
||||
uint64_t hash = 14695981039346656037ULL;
|
||||
unsigned low = 255, high = 0;
|
||||
for (int plane = 0; plane < 4; ++plane) {
|
||||
if (!sizes[plane]) continue;
|
||||
require(widths[plane] > 0 && frame->data[plane] &&
|
||||
std::abs(frame->linesize[plane]) >= widths[plane], "real readable pixel planes");
|
||||
const size_t rows = sizes[plane] / widths[plane];
|
||||
for (size_t y = 0; y < rows; ++y) {
|
||||
const uint8_t *row = frame->data[plane] + ptrdiff_t(y) * frame->linesize[plane];
|
||||
for (int x = 0; x < widths[plane]; ++x) {
|
||||
low = std::min(low, unsigned(row[x])); high = std::max(high, unsigned(row[x]));
|
||||
hash = (hash ^ row[x]) * 1099511628211ULL;
|
||||
}
|
||||
}
|
||||
}
|
||||
require(low < high, "testsrc2 contains real nonconstant pixel data");
|
||||
return hash;
|
||||
}
|
||||
|
||||
static void exactFrame(Player &player, const VideoFrame &frame, int index, int fps) {
|
||||
require(bool(frame.frame), "consumer receives a video frame");
|
||||
const auto timebase = player.tracks[1].stream()->time_base;
|
||||
const int64_t expected = av_rescale_q(index, AVRational{1, fps}, timebase);
|
||||
require(presentationTimestamp(frame.frame.get()) == expected, "exact consumer presentation timestamp");
|
||||
require(std::abs(frame.pts - expected * av_q2d(timebase)) < 0.000001,
|
||||
"queue PTS matches the fixture media timestamp");
|
||||
require(frame.end > frame.pts, "queued frame retains a positive presentation duration");
|
||||
metadata(player, frame.frame.get());
|
||||
}
|
||||
|
||||
struct DecodedRun {
|
||||
Frame first, last;
|
||||
int frames = 0;
|
||||
bool hardware = false;
|
||||
size_t peakFrames = 0, peakBytes = 0;
|
||||
};
|
||||
|
||||
static DecodedRun drain(Player &player, int first, int expectedFrames, int fps) {
|
||||
DecodedRun result;
|
||||
double previous = -std::numeric_limits<double>::infinity();
|
||||
while (!player.tracks[1].drained || !player.video.empty()) {
|
||||
auto *codec = player.tracks[1].codec;
|
||||
result.hardware |= codec->pix_fmt == AV_PIX_FMT_VIDEOTOOLBOX || codec->hw_frames_ctx != nullptr;
|
||||
result.peakFrames = std::max(result.peakFrames, player.video.size());
|
||||
result.peakBytes = std::max(result.peakBytes, player.videoBytes);
|
||||
require(player.video.size() <= videoFrameLimit && player.videoBytes <= videoByteLimit,
|
||||
"real decoded queue stays inside frame and byte budgets");
|
||||
if (!player.video.empty() && (player.videoFull() || player.tracks[1].drained)) {
|
||||
while (!player.video.empty()) {
|
||||
VideoFrame frame = std::move(player.video.front());
|
||||
player.video.pop_front();
|
||||
player.videoBytes -= frame.bytes;
|
||||
require(frame.pts > previous, "consumer observes strictly monotonic PTS including B-frame EOF");
|
||||
require(result.frames < expectedFrames, "no extra decoded frames after decoder drain");
|
||||
exactFrame(player, frame, first + result.frames, fps);
|
||||
previous = frame.pts;
|
||||
if (!result.first) result.first = retain(frame.frame.get());
|
||||
result.last = std::move(frame.frame);
|
||||
++result.frames;
|
||||
}
|
||||
} else {
|
||||
require(player.decodeOne(), "local synchronous decode makes progress without a clock");
|
||||
}
|
||||
}
|
||||
require(result.frames == expectedFrames, "all expected frames survive queueing and delayed EOF drain");
|
||||
require(player.demuxEnded && player.tracks[1].sourceEnded && player.tracks[1].drainSent &&
|
||||
player.tracks[1].drained && player.videoBytes == 0, "demux and codec EOF fully drain the queue");
|
||||
return result;
|
||||
}
|
||||
|
||||
static void draw(playback::Output &output, const AVFrame *frame) {
|
||||
void *native = output.make_video_view();
|
||||
require(native != nullptr, "real Metal view and shaders initialize");
|
||||
MTKView *view = (__bridge_transfer MTKView *)native;
|
||||
view.paused = YES;
|
||||
NSWindow *window = [[NSWindow alloc] initWithContentRect:NSMakeRect(100, 100, 320, 180)
|
||||
styleMask:NSWindowStyleMaskBorderless backing:NSBackingStoreBuffered defer:NO];
|
||||
window.contentView = view;
|
||||
[window orderFront:nil];
|
||||
output.present(frame);
|
||||
// AppKit must attach the layer before a native drawable can be acquired.
|
||||
// This is an I/O watchdog, not a media clock.
|
||||
const auto deadline = Clock::now() + std::chrono::seconds(5);
|
||||
while (!view.currentDrawable && Clock::now() < deadline)
|
||||
CFRunLoopRunInMode(kCFRunLoopDefaultMode, .005, false);
|
||||
require(view.currentDrawable != nil, "real Metal surface supplies a drawable");
|
||||
[view draw];
|
||||
[window orderOut:nil];
|
||||
}
|
||||
|
||||
static void fixture(const char *path, int frames, int fps, bool metal, bool preroll) {
|
||||
Player player;
|
||||
player.shutdown(); // Join the worker: use actual open/decode/seek synchronously.
|
||||
player.stopping.store(false);
|
||||
player.activeEpoch = player.requestedEpoch.load();
|
||||
Command command{Action::Open, path};
|
||||
player.open(command);
|
||||
require(player.tracks[1].codec && !player.tracks[0].codec, "fixture is video-only (no audio device)");
|
||||
require(player.canSeek && std::abs(player.duration - double(frames) / fps) < .002,
|
||||
"fixture has the fixed expected media duration and is seekable");
|
||||
if (preroll) {
|
||||
while (player.video.empty() && !player.tracks[1].drained)
|
||||
require(player.decodeOne(), "opening transport stream makes progress");
|
||||
require(!player.video.empty(), "transport stream supplies an opening picture");
|
||||
auto retained = retain(player.video.front().frame.get());
|
||||
const auto hash = pixels(retained.get());
|
||||
const double target = (frames * 3 / 5 + .4) / fps;
|
||||
player.seek(target, false);
|
||||
while (player.video.empty() && !player.tracks[1].drained)
|
||||
require(player.decodeOne(), "dependent-packet seek makes progress");
|
||||
require(!player.video.empty() && player.video.front().pts >= target &&
|
||||
player.video.front().pts < player.duration, "transport seek supplies a usable preview after preroll");
|
||||
metadata(player, player.video.front().frame.get());
|
||||
pixels(player.video.front().frame.get());
|
||||
const bool software = player.tracks[1].codec->pix_fmt != AV_PIX_FMT_VIDEOTOOLBOX;
|
||||
player.release();
|
||||
require(pixels(retained.get()) == hash, "mapped opening picture survives preroll decoder replacement");
|
||||
std::printf("PASS native dependent-packet preroll/preview/lifetime: %s software=%s\n",
|
||||
path, software ? "yes" : "no");
|
||||
return;
|
||||
}
|
||||
const auto wallStart = std::chrono::steady_clock::now();
|
||||
const auto cpuStart = std::clock();
|
||||
auto decoded = drain(player, 0, frames, fps);
|
||||
const double cpu = double(std::clock() - cpuStart) / CLOCKS_PER_SEC;
|
||||
const double wall = std::chrono::duration<double>(std::chrono::steady_clock::now() - wallStart).count();
|
||||
#ifdef TEREBI_SOFTWARE_DECODE
|
||||
constexpr const char *mode = "software";
|
||||
require(!decoded.hardware, "matched software baseline never negotiates hardware");
|
||||
#else
|
||||
constexpr const char *mode = "preferred";
|
||||
#endif
|
||||
std::printf("native-decode mode=%s fixture=%s codec=%s videotoolbox=%s format=%s frames=%d "
|
||||
"wall=%.6f cpu=%.6f peak_frames=%zu peak_bytes=%zu\n", mode, path,
|
||||
player.tracks[1].codec->codec->name, decoded.hardware ? "yes" : "no",
|
||||
av_get_pix_fmt_name(static_cast<AVPixelFormat>(decoded.first->format)), decoded.frames,
|
||||
wall, cpu, decoded.peakFrames, decoded.peakBytes);
|
||||
const auto firstHash = pixels(decoded.first.get());
|
||||
const auto lastHash = pixels(decoded.last.get());
|
||||
require(firstHash != lastHash, "retained first and last frames contain distinct fixture pictures");
|
||||
if (metal) draw(player.output, decoded.first.get());
|
||||
|
||||
// A non-keyframe seek must discard preroll and expose the exact next PTS.
|
||||
const int middle = frames / 2;
|
||||
const auto seekStart = Clock::now();
|
||||
player.seek((middle + .4) / fps, false);
|
||||
require(player.video.empty() && player.videoBytes == 0, "seek clears the old readable queue");
|
||||
while (player.video.empty() && !player.tracks[1].drained)
|
||||
require(player.decodeOne(), "seek preroll makes progress");
|
||||
std::printf("native-seek mode=%s fixture=%s preview_ms=%.3f\n", mode, path,
|
||||
std::chrono::duration<double, std::milli>(Clock::now() - seekStart).count());
|
||||
require(pixels(decoded.first.get()) == firstHash && pixels(decoded.last.get()) == lastHash,
|
||||
"mapped frame references survive decoder flush");
|
||||
auto sought = drain(player, middle + 1, frames - middle - 1, fps);
|
||||
pixels(sought.first.get());
|
||||
|
||||
// Seek backward after EOF, then step in both directions without a clock.
|
||||
player.seek((middle + .4) / fps, false);
|
||||
player.step(1);
|
||||
exactFrame(player, player.presented, middle + 2, fps);
|
||||
player.step(-1);
|
||||
exactFrame(player, player.presented, middle + 1, fps);
|
||||
player.step(1);
|
||||
exactFrame(player, player.presented, middle + 2, fps);
|
||||
player.seek(1.4 / fps, false);
|
||||
auto restarted = drain(player, 2, frames - 2, fps);
|
||||
const double lastPts = presentationTimestamp(restarted.last.get()) *
|
||||
av_q2d(player.tracks[1].stream()->time_base) - player.origin;
|
||||
const int lastBytes = av_image_get_buffer_size(static_cast<AVPixelFormat>(restarted.last->format),
|
||||
restarted.last->width, restarted.last->height, 1);
|
||||
require(lastBytes > 0, "last readable frame has a valid byte size");
|
||||
player.presentVideo(VideoFrame{retain(restarted.last.get()), lastPts,
|
||||
lastPts + player.videoFrameTime, static_cast<size_t>(lastBytes)});
|
||||
player.step(1);
|
||||
exactFrame(player, player.presented, frames - 1, fps);
|
||||
player.step(-1);
|
||||
exactFrame(player, player.presented, frames - 2, fps);
|
||||
player.step(1);
|
||||
exactFrame(player, player.presented, frames - 1, fps);
|
||||
|
||||
player.release(); // Retained mapped planes must also outlive the codec/device.
|
||||
require(pixels(decoded.first.get()) == firstHash && pixels(decoded.last.get()) == lastHash,
|
||||
"mapped frame references survive full decoder release and pool reuse");
|
||||
if (metal) draw(player.output, decoded.last.get());
|
||||
std::printf("PASS native readable frames/SDR/SAR/seek/exact steps/EOF/lifetimes: %s\n", path);
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
@autoreleasepool {
|
||||
int frames = 360, fps = 30;
|
||||
bool metal = false, preroll = false;
|
||||
std::vector<const char *> paths;
|
||||
for (int i = 1; i < argc; ++i) {
|
||||
if (std::strcmp(argv[i], "--metal") == 0) { metal = true; continue; }
|
||||
if (std::strcmp(argv[i], "--preroll") == 0) { preroll = true; continue; }
|
||||
if (std::strcmp(argv[i], "--frames") == 0 || std::strcmp(argv[i], "--fps") == 0) {
|
||||
const bool count = std::strcmp(argv[i], "--frames") == 0;
|
||||
require(++i < argc, "numeric option needs a value");
|
||||
char *end = nullptr;
|
||||
const long value = std::strtol(argv[i], &end, 10);
|
||||
require(end != argv[i] && !*end && value > 0 && value <= 1000000,
|
||||
"frame count and integer frame rate must be positive and bounded");
|
||||
(count ? frames : fps) = static_cast<int>(value);
|
||||
} else {
|
||||
require(argv[i][0] != '-', "unknown option");
|
||||
paths.push_back(argv[i]);
|
||||
}
|
||||
}
|
||||
require(!paths.empty() && frames >= 8, "provide fixtures with at least eight frames");
|
||||
if (metal) {
|
||||
[NSApplication sharedApplication];
|
||||
[NSApp setActivationPolicy:NSApplicationActivationPolicyAccessory];
|
||||
[NSApp finishLaunching];
|
||||
}
|
||||
try {
|
||||
for (const char *path : paths) fixture(path, frames, fps, metal, preroll);
|
||||
} catch (const std::exception &error) {
|
||||
std::fprintf(stderr, "FAIL: %s\n", error.what());
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
+372
-34
@@ -70,20 +70,20 @@ struct YouTubeResolverChecks {
|
||||
let url = try YouTubeResolver.validateURL(input)
|
||||
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 = [
|
||||
"", "not a URL", "javascript:alert(1)", "file:///watch?v=jNQXAC9IVRw",
|
||||
"https://example.com/watch?v=jNQXAC9IVRw",
|
||||
"https://youtube.com.evil.example/watch?v=jNQXAC9IVRw",
|
||||
"https://evil.youtube.com/watch?v=jNQXAC9IVRw",
|
||||
"ftp://example.com/video", "https://", "https://example.com:0/video",
|
||||
"https://example.com:65536/video", "https://example.com/video\ninjected",
|
||||
"https://youtube.com@evil.example/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"
|
||||
"https://user:password@www.youtube.com/watch?v=jNQXAC9IVRw"
|
||||
]
|
||||
for input in hostile {
|
||||
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")
|
||||
}
|
||||
|
||||
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 {
|
||||
func video(_ height: Int, audio: Bool = false, range: String = "SDR", transport: String = "https") -> [String: Any] {
|
||||
["height": height, "vcodec": "h264", "acodec": audio ? "aac" : "none",
|
||||
@@ -202,6 +381,9 @@ struct YouTubeResolverChecks {
|
||||
}
|
||||
|
||||
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") }
|
||||
for json in ["null", "[]", "{}", #"{"requested_formats":[]}"#] {
|
||||
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}"#)
|
||||
}
|
||||
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"] {
|
||||
var object = combined
|
||||
object.removeValue(forKey: key)
|
||||
try reject("missing required combined \(key)") { _ = try decode(object) }
|
||||
object[key] = 17
|
||||
try reject("wrong type for combined \(key)") { _ = try decode(object) }
|
||||
}
|
||||
@@ -236,11 +419,9 @@ struct YouTubeResolverChecks {
|
||||
var hdr = combined
|
||||
hdr["dynamic_range"] = "HDR10"
|
||||
try reject("HDR input unsupported by the SDR renderer") { _ = try decode(hdr) }
|
||||
for value in ["none", NSNull()] as [Any] {
|
||||
var object = combined
|
||||
object["vcodec"] = value
|
||||
try reject("combined input without video codec") { _ = try decode(object) }
|
||||
}
|
||||
var audioOnly = combined
|
||||
audioOnly["vcodec"] = "none"
|
||||
try reject("explicitly audio-only result") { _ = try decode(audioOnly) }
|
||||
for transport in ["http_dash_segments", "mhtml", "rtmp"] {
|
||||
var object = combined
|
||||
object["protocol"] = transport
|
||||
@@ -307,13 +488,109 @@ struct YouTubeResolverChecks {
|
||||
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 }
|
||||
catch is CancellationError { return }
|
||||
catch { throw Failure(description: "FAIL: \(message) returned an extraction error 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 {
|
||||
let successful = try Extractor(root: root, name: "successful split")
|
||||
let result = try await YouTubeResolver.resolve(page, executableURL: successful.executable, runtimeURL: runtime)
|
||||
@@ -333,8 +610,6 @@ struct YouTubeResolverChecks {
|
||||
} catch let failure as Failure { throw failure }
|
||||
catch {
|
||||
let text = String(describing: error) + error.localizedDescription
|
||||
try require(text.lowercased().contains("sign-in") || text.lowercased().contains("sign in"),
|
||||
"extractor stderr produces an actionable access error")
|
||||
try require(!text.contains("not-for-errors") && !text.contains("media.example"),
|
||||
"extractor error does not leak signed media URLs or credentials")
|
||||
}
|
||||
@@ -356,11 +631,7 @@ struct YouTubeResolverChecks {
|
||||
_ = try await YouTubeResolver.resolve(page, executableURL: oversized.executable, runtimeURL: runtime)
|
||||
throw Failure(description: "FAIL: oversized extractor output was accepted")
|
||||
} catch let failure as Failure { throw failure }
|
||||
catch {
|
||||
let text = (String(describing: error) + error.localizedDescription).lowercased()
|
||||
try require(text.contains("output") || text.contains("limit") || text.contains("large") || text.contains("metadata"),
|
||||
"bounded stdout rejection explains the output limit")
|
||||
}
|
||||
catch {}
|
||||
|
||||
let beforeLaunch = try Extractor(root: root, name: "already cancelled")
|
||||
let preCancelled = Task {
|
||||
@@ -401,12 +672,21 @@ struct YouTubeResolverChecks {
|
||||
}
|
||||
|
||||
static func actualQualitySelection(root: URL, runtime: URL) async throws {
|
||||
let directories = (ProcessInfo.processInfo.environment["PATH"] ?? "").split(separator: ":").map(String.init)
|
||||
+ ["/opt/homebrew/bin", "/usr/local/bin"]
|
||||
guard let executable = directories.map({ URL(fileURLWithPath: $0).appendingPathComponent("yt-dlp") })
|
||||
.first(where: { FileManager.default.isExecutableFile(atPath: $0.path) }) else {
|
||||
print("Skipping actual yt-dlp quality selection: yt-dlp is not installed")
|
||||
return
|
||||
let executable: URL
|
||||
if CommandLine.arguments.count > 1 {
|
||||
let app = URL(fileURLWithPath: CommandLine.arguments[1], isDirectory: true)
|
||||
guard let bundled = try YouTubeResolver.bundledExecutable("yt-dlp", bundleURL: app) else {
|
||||
throw Failure(description: "FAIL: supplied quality-selection app is not an app bundle")
|
||||
}
|
||||
executable = bundled
|
||||
} else {
|
||||
let directories = (ProcessInfo.processInfo.environment["PATH"] ?? "").split(separator: ":").map(String.init)
|
||||
guard let found = directories.map({ URL(fileURLWithPath: $0).appendingPathComponent("yt-dlp") })
|
||||
.first(where: { FileManager.default.isExecutableFile(atPath: $0.path) }) else {
|
||||
print("Skipping actual yt-dlp quality selection: no bundled app supplied and yt-dlp is not on PATH")
|
||||
return
|
||||
}
|
||||
executable = found
|
||||
}
|
||||
func video(_ height: Int, combined: Bool = false) -> [String: Any] {
|
||||
["format_id": "\(height)", "height": height, "width": height * 16 / 9, "ext": "mp4",
|
||||
@@ -441,12 +721,62 @@ struct YouTubeResolverChecks {
|
||||
try JSONSerialization.data(withJSONObject: object).write(to: extractor.marker("response.json"))
|
||||
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")
|
||||
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 {
|
||||
let app = root.appendingPathComponent("relocated player.app", isDirectory: true)
|
||||
let macOS = app.appendingPathComponent("Contents/MacOS", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: macOS, withIntermediateDirectories: true)
|
||||
let external = root.appendingPathComponent("yt-dlp")
|
||||
try Data("#!/bin/sh\nexit 99\n".utf8).write(to: external)
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: external.path)
|
||||
let oldPath = ProcessInfo.processInfo.environment["PATH"]
|
||||
defer {
|
||||
if let oldPath { setenv("PATH", oldPath, 1) } else { unsetenv("PATH") }
|
||||
}
|
||||
for name in ["yt-dlp", "deno"] {
|
||||
for path in [root.path, ""] {
|
||||
setenv("PATH", path, 1)
|
||||
try reject("missing bundled helper cannot fall back to hostile or missing PATH") {
|
||||
_ = try YouTubeResolver.bundledExecutable(name, bundleURL: app)
|
||||
}
|
||||
}
|
||||
let helper = macOS.appendingPathComponent(name)
|
||||
try Data("#!/bin/sh\nexit 0\n".utf8).write(to: helper)
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: helper.path)
|
||||
try reject("nonexecutable bundled helper") {
|
||||
_ = try YouTubeResolver.bundledExecutable(name, bundleURL: app)
|
||||
}
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: helper.path)
|
||||
try require(try YouTubeResolver.bundledExecutable(name, bundleURL: app) == helper,
|
||||
"bundled helper location survives relocation independently of PATH")
|
||||
}
|
||||
try require(try YouTubeResolver.bundledExecutable("yt-dlp", bundleURL: root) == nil,
|
||||
"standalone development executables can use PATH discovery")
|
||||
}
|
||||
|
||||
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 {
|
||||
try await Task.sleep(for: .seconds(30))
|
||||
try await Task.sleep(for: .seconds(45))
|
||||
fatalError("FAIL: resolver checks exceeded their bounded subprocess deadline")
|
||||
}
|
||||
defer { watchdog.cancel() }
|
||||
@@ -458,9 +788,17 @@ struct YouTubeResolverChecks {
|
||||
try FileManager.default.createSymbolicLink(at: runtime, withDestinationURL: URL(fileURLWithPath: "/usr/bin/true"))
|
||||
try videoURLs()
|
||||
try metadata()
|
||||
try chapters()
|
||||
try subtitles()
|
||||
try malformedMetadata()
|
||||
try qualities()
|
||||
try bundledHelpers(root: root)
|
||||
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)
|
||||
print("YouTube resolver checks passed")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"ffmpeg": {
|
||||
"version": "9.0.1",
|
||||
"license": "LGPL-2.1-or-later",
|
||||
"source": {
|
||||
"url": "https://ffmpeg.org/releases/ffmpeg-9.0.1.tar.xz",
|
||||
"filename": "ffmpeg-9.0.1.tar.xz",
|
||||
"sha256": "cf38e0e28c7e5605942c4a77755349b0145804a397af37eb1fb4c77cb237f635"
|
||||
}
|
||||
},
|
||||
"dav1d": {
|
||||
"version": "1.5.4",
|
||||
"commit": "54706fc6bc0cdecab7e9593974a4039cc038fca7",
|
||||
"license": "BSD-2-Clause",
|
||||
"source": {
|
||||
"url": "https://codeload.github.com/videolan/dav1d/tar.gz/54706fc6bc0cdecab7e9593974a4039cc038fca7",
|
||||
"filename": "dav1d-1.5.4-54706fc6.tar.gz",
|
||||
"sha256": "9edb11a2108b375cc58370354e705feebc93430bb780130363815c1e1ac0c250"
|
||||
}
|
||||
},
|
||||
"xz": {
|
||||
"version": "5.8.4",
|
||||
"license": "0BSD and public domain (liblzma)",
|
||||
"source": {
|
||||
"url": "https://github.com/tukaani-project/xz/releases/download/v5.8.4/xz-5.8.4.tar.gz",
|
||||
"filename": "xz-5.8.4.tar.gz",
|
||||
"sha256": "0014c7886930454fe8bd4228665b51af55eeae560ea135c9c4cd33f55b2591d9"
|
||||
}
|
||||
},
|
||||
"yt-dlp": {
|
||||
"version": "2026.08.19",
|
||||
"commit": "3a08beaf031ab68f966401ead017ac81fe8486cf",
|
||||
"license": "GPL-3.0-or-later (standalone combined work); source project Unlicense",
|
||||
"binary": {
|
||||
"url": "https://github.com/yt-dlp/yt-dlp/releases/download/2026.08.19/yt-dlp_macos",
|
||||
"filename": "yt-dlp_macos-2026.08.19",
|
||||
"sha256": "0f192b7ec147ab6288885d6351d9ab67367640029b4377576ef46dd79cf7b202"
|
||||
},
|
||||
"materials": [
|
||||
{
|
||||
"filename": "yt-dlp-LICENSE.txt",
|
||||
"url": "https://raw.githubusercontent.com/yt-dlp/yt-dlp/3a08beaf031ab68f966401ead017ac81fe8486cf/LICENSE",
|
||||
"sha256": "7e12e5df4bae12cb21581ba157ced20e1986a0508dd10d0e8a4ab9a4cf94e85c",
|
||||
"kind": "license"
|
||||
},
|
||||
{
|
||||
"filename": "yt-dlp-THIRD_PARTY_LICENSES.txt",
|
||||
"url": "https://raw.githubusercontent.com/yt-dlp/yt-dlp/3a08beaf031ab68f966401ead017ac81fe8486cf/THIRD_PARTY_LICENSES.txt",
|
||||
"sha256": "472aefe951c7db35e1657c1d13fd337140511ed6f2b329205105ad441c5a02b7",
|
||||
"kind": "license"
|
||||
},
|
||||
{
|
||||
"filename": "GPL-3.0.txt",
|
||||
"url": "https://raw.githubusercontent.com/FFmpeg/FFmpeg/n9.0.1/COPYING.GPLv3",
|
||||
"sha256": "8ceb4b9ee5adedde47b31e975c1d90c73ad27b6b165a1dcd80c7c545eb65b903",
|
||||
"kind": "license"
|
||||
},
|
||||
{
|
||||
"filename": "PyInstaller-COPYING.txt",
|
||||
"url": "https://raw.githubusercontent.com/pyinstaller/pyinstaller/v6.22.0/COPYING.txt",
|
||||
"sha256": "dcf75fdb959db1e3b41c0f8505069d2ece781b5ec6b3d0a4d30975cfc6580245",
|
||||
"kind": "license"
|
||||
},
|
||||
{
|
||||
"filename": "EJS-LICENSE.txt",
|
||||
"url": "https://raw.githubusercontent.com/yt-dlp/ejs/0.8.0/LICENSE",
|
||||
"sha256": "b5065838cbac452dfc855ba6e6e031481ad2c68406f70d21ead9321374653e6c",
|
||||
"kind": "license"
|
||||
},
|
||||
{
|
||||
"filename": "typing-extensions-LICENSE.txt",
|
||||
"url": "https://raw.githubusercontent.com/python/typing_extensions/4.16.0/LICENSE",
|
||||
"sha256": "3b2f81fe21d181c499c59a256c8e1968455d6689d269aa85373bfb6af41da3bf",
|
||||
"kind": "license"
|
||||
},
|
||||
{
|
||||
"filename": "yt-dlp-source.tar.gz",
|
||||
"url": "https://github.com/yt-dlp/yt-dlp/releases/download/2026.08.19/yt-dlp.tar.gz",
|
||||
"sha256": "072aad4f2a7604e92155f61a275a4752dc64046c8f6d90df3710525d94cd37c1",
|
||||
"kind": "source"
|
||||
},
|
||||
{
|
||||
"filename": "EJS-source.tar.gz",
|
||||
"url": "https://codeload.github.com/yt-dlp/ejs/tar.gz/4fb477f4af56880cfd324c48bd4294a2d2294e50",
|
||||
"sha256": "47830f927650be514ec13c90b0e15fffbc8c462675d01c4a201632fae7ebdcfc",
|
||||
"kind": "source"
|
||||
}
|
||||
],
|
||||
"notice": "Official yt-dlp_macos 2026.08.19 standalone includes Python, EJS 0.8.0 and upstream default dependencies; it does not require a host Python interpreter. The combined PyInstaller executable is GPLv3-or-later, NOT covered solely by the source project Unlicense. Complete upstream THIRD_PARTY_LICENSES.txt and supplemental PyInstaller/EJS/typing-extensions notices accompany it. Source archive/build requirements provide provenance, not a claim of complete corresponding source for every bundled component. Upstream source availability information: THIRD_PARTY_LICENSES.txt and https://github.com/yt-dlp/yt-dlp/tree/3a08beaf031ab68f966401ead017ac81fe8486cf . Release distributors remain responsible for applicable GPL corresponding-source availability."
|
||||
},
|
||||
"deno": {
|
||||
"version": "2.9.6",
|
||||
"commit": "e518fbd66dda5debcbdefc0beb0b3756b37b64fa",
|
||||
"license": "MIT (Deno project); third-party components retain their own terms",
|
||||
"binaries": {
|
||||
"arm64": {
|
||||
"url": "https://github.com/denoland/deno/releases/download/v2.9.6/deno-aarch64-apple-darwin.zip",
|
||||
"filename": "deno-2.9.6-aarch64-apple-darwin.zip",
|
||||
"sha256": "213a2f304f04d3c9cb5220669afad138f60a5aab1fe80962abdeb8f35807a472"
|
||||
},
|
||||
"x86_64": {
|
||||
"url": "https://github.com/denoland/deno/releases/download/v2.9.6/deno-x86_64-apple-darwin.zip",
|
||||
"filename": "deno-2.9.6-x86_64-apple-darwin.zip",
|
||||
"sha256": "7d4524b82bcc557fe020a1a5b56956ed42b992ae5b28026e8ad5d17329533f5f"
|
||||
}
|
||||
},
|
||||
"materials": [
|
||||
{
|
||||
"filename": "deno-LICENSE.txt",
|
||||
"url": "https://raw.githubusercontent.com/denoland/deno/e518fbd66dda5debcbdefc0beb0b3756b37b64fa/LICENSE.md",
|
||||
"sha256": "f62497fffecc0852960c8d3e6934b9db86d16396e9b604072e923892cae3a588",
|
||||
"kind": "license"
|
||||
},
|
||||
{
|
||||
"filename": "deno-Cargo.lock",
|
||||
"url": "https://raw.githubusercontent.com/denoland/deno/e518fbd66dda5debcbdefc0beb0b3756b37b64fa/Cargo.lock",
|
||||
"sha256": "6c6af74640994cc41fcc7f46bb5204a50da679adc2dee96cb05aa97867baf7e1",
|
||||
"kind": "license"
|
||||
},
|
||||
{
|
||||
"filename": "rusty-v8-LICENSE.txt",
|
||||
"url": "https://raw.githubusercontent.com/denoland/rusty_v8/v150.4.0/LICENSE",
|
||||
"sha256": "1c6356fb751d45f0c53093ebf8a7f5e580e802f51999178e19d60f3ec39e147d",
|
||||
"kind": "license"
|
||||
},
|
||||
{
|
||||
"filename": "v8-LICENSE.txt",
|
||||
"url": "https://raw.githubusercontent.com/denoland/v8/ac1e23989121713ca642f6650b34deff7b686896/LICENSE",
|
||||
"sha256": "6ab33af8774a0f396ee3aeeb761e3229057682d6f9fa7f572e390c2cb3a6e509",
|
||||
"kind": "license"
|
||||
},
|
||||
{
|
||||
"filename": "v8-LICENSE.v8",
|
||||
"url": "https://raw.githubusercontent.com/denoland/v8/ac1e23989121713ca642f6650b34deff7b686896/LICENSE.v8",
|
||||
"sha256": "4af93c12062c58058378de2397dc1c92bbff9ddfb1d583a01c84127557ce97ca",
|
||||
"kind": "license"
|
||||
},
|
||||
{
|
||||
"filename": "v8-LICENSE.fdlibm",
|
||||
"url": "https://raw.githubusercontent.com/denoland/v8/ac1e23989121713ca642f6650b34deff7b686896/LICENSE.fdlibm",
|
||||
"sha256": "e7115e18444dae09d17f361ddc365fb1d342640fe500796209c63f7c80dfae10",
|
||||
"kind": "license"
|
||||
},
|
||||
{
|
||||
"filename": "v8-LICENSE.strongtalk",
|
||||
"url": "https://raw.githubusercontent.com/denoland/v8/ac1e23989121713ca642f6650b34deff7b686896/LICENSE.strongtalk",
|
||||
"sha256": "6a585a9f466654abc8fc0829d56b1bc987e3a073d31faa03bba37d33640a23cd",
|
||||
"kind": "license"
|
||||
},
|
||||
{
|
||||
"filename": "deno-source.tar.gz",
|
||||
"url": "https://codeload.github.com/denoland/deno/tar.gz/e518fbd66dda5debcbdefc0beb0b3756b37b64fa",
|
||||
"sha256": "58de72aabd2686284528259e4b268b9d3d38e628743ac1ac422b13f0e7619db7",
|
||||
"kind": "source"
|
||||
}
|
||||
],
|
||||
"notice": "Official Deno v2.9.6 native macOS executable; no host JavaScript runtime required. Deno project MIT license, rusty_v8 MIT and V8 BSD-style notices accompany it. Other dependencies retain their own licenses. Pinned upstream source and Cargo.lock preserve dependency provenance; these notices are not asserted to exhaust all third-party binary notices. Source: https://github.com/denoland/deno/tree/e518fbd66dda5debcbdefc0beb0b3756b37b64fa ."
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,30 @@
|
||||
CXX := xcrun clang++
|
||||
SWIFT := xcrun swiftc
|
||||
FFMPEG := libavformat libavcodec libavutil libswresample libswscale
|
||||
FF_CFLAGS = $(shell pkg-config --cflags $(FFMPEG))
|
||||
FF_LIBS = $(shell pkg-config --libs $(FFMPEG))
|
||||
ARCH := $(shell uname -m)
|
||||
DEPENDENCY_ROOT := $(abspath .build/dependencies/$(ARCH))
|
||||
NATIVE_STAMP := $(DEPENDENCY_ROOT)/native.stamp
|
||||
HELPER_STAMP := $(DEPENDENCY_ROOT)/helpers.stamp
|
||||
FF_CFLAGS = $(shell PKG_CONFIG_LIBDIR="$(DEPENDENCY_ROOT)/ffmpeg/lib/pkgconfig" pkg-config --cflags $(FFMPEG))
|
||||
FF_LIBS = $(shell PKG_CONFIG_LIBDIR="$(DEPENDENCY_ROOT)/ffmpeg/lib/pkgconfig" pkg-config --libs $(FFMPEG))
|
||||
BUILD_DIR ?= .build
|
||||
DEBUG_FLAGS ?= -g
|
||||
PREFIX ?= $(HOME)/.local
|
||||
APPDIR ?= $(HOME)/Applications
|
||||
CXXFLAGS = -std=c++20 -mmacosx-version-min=15.0 -O2 $(DEBUG_FLAGS) -Wall -Wextra -Wno-unused-parameter $(FF_CFLAGS)
|
||||
FRAMEWORKS := -framework AppKit -framework AudioToolbox -framework CoreAudio -framework Metal -framework MetalKit -framework QuartzCore -framework MediaPlayer
|
||||
OBJECTS := $(BUILD_DIR)/player.o $(BUILD_DIR)/progress_reporter.o $(BUILD_DIR)/Output.o
|
||||
SWIFT_SOURCES := Sources/App/TerebiApp.swift Sources/App/PlayerActions.swift Sources/App/CompactWindow.swift Sources/App/YouTubeResolver.swift Sources/App/PlaybackProgress.swift Sources/App/SystemPlaybackControls.swift Sources/App/AutomaticQuality.swift
|
||||
OBJECTS := $(BUILD_DIR)/player.o $(BUILD_DIR)/progress_reporter.o $(BUILD_DIR)/Output.o $(BUILD_DIR)/cast_media.o
|
||||
SWIFT_SOURCES := Sources/App/TerebiApp.swift Sources/App/PlayerActions.swift Sources/App/CompactWindow.swift Sources/App/YouTubeResolver.swift Sources/App/PlaybackProgress.swift Sources/App/PlaybackQueue.swift Sources/App/SystemPlaybackControls.swift Sources/App/AutomaticQuality.swift Sources/App/ResolvedSource.swift Sources/App/ExternalSubtitles.swift Sources/App/CastSession.swift Sources/App/CastMediaServer.swift Sources/App/CastControls.swift
|
||||
RUNTIME_SOURCE := Sources/Launcher/FFmpegRuntime.swift
|
||||
CLI_SOURCES := Sources/CLI/TerebiCLI.swift Sources/CLI/PlayerCommand.swift Sources/App/YouTubeResolver.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/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/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_BIN := $(shell xcrun swift build --scratch-path $(CONFIG_BUILD) -c release --show-bin-path)
|
||||
CONFIG_LIBRARY := $(CONFIG_BIN)/libTerebiConfiguration.a
|
||||
@@ -20,25 +32,34 @@ CONFIG_FLAGS := -I $(CONFIG_BIN) -I $(CONFIG_BIN)/Modules $(CONFIG_LIBRARY)
|
||||
APP := $(BUILD_DIR)/terebi.app
|
||||
PLAYER := $(APP)/Contents/Frameworks/libterebi-player.dylib
|
||||
BOOTSTRAP := $(APP)/Contents/MacOS/terebi
|
||||
MANIFEST := $(APP)/Contents/Resources/ffmpeg-libraries.txt
|
||||
CLI := $(APP)/Contents/MacOS/terebi-cli
|
||||
BUNDLE_STAMP := $(BUILD_DIR)/bundle.stamp
|
||||
SIGNING_IDENTITY ?= -
|
||||
DECODE_FIXTURES := $(addprefix $(BUILD_DIR)/fixtures/decode-,h264.mp4 hevc.mp4 vp9.mp4 av1.mp4 h264-10.mp4 mpeg4.mp4)
|
||||
|
||||
.PHONY: all release install check-dependencies run check check-config check-fixtures check-audio check-window check-youtube check-quality check-adaptive check-network check-progress check-reporting check-cli check-runtime check-media check-startup
|
||||
all: check-dependencies $(BUILD_DIR)/terebi $(PLAYER) $(BOOTSTRAP) $(MANIFEST) $(APP)/Contents/Info.plist $(APP)/Contents/Resources/TOMLDecoder-LICENSE.txt
|
||||
.PHONY: all release install check-dependencies run check check-seek-queue check-video-decoding check-config check-fixtures check-audio check-window 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
|
||||
all: $(BUILD_DIR)/terebi
|
||||
|
||||
check-dependencies:
|
||||
@command -v pkg-config >/dev/null 2>&1 || { printf '%s\n' 'Error: pkg-config is required to build terebi. Run: brew install ffmpeg pkg-config' >&2; exit 1; }
|
||||
@pkg-config --exists $(FFMPEG) || { printf '%s\n' 'Error: FFmpeg development libraries are unavailable. Run: brew install ffmpeg pkg-config' 'If FFmpeg is already installed, check PKG_CONFIG_PATH, then rebuild/reinstall terebi.' >&2; exit 1; }
|
||||
$(NATIVE_STAMP): Scripts/dependencies.py Dependencies.lock.json
|
||||
python3 Scripts/dependencies.py --root "$(DEPENDENCY_ROOT)" --arch "$(ARCH)" --part native
|
||||
|
||||
$(HELPER_STAMP): Scripts/dependencies.py Dependencies.lock.json
|
||||
python3 Scripts/dependencies.py --root "$(DEPENDENCY_ROOT)" --arch "$(ARCH)" --part helpers
|
||||
|
||||
check-dependencies: $(NATIVE_STAMP)
|
||||
@PKG_CONFIG_LIBDIR="$(DEPENDENCY_ROOT)/ffmpeg/lib/pkgconfig" pkg-config --exists $(FFMPEG) || { printf '%s\n' 'Error: bundled FFmpeg development libraries are unavailable. Remove .build/dependencies and rebuild terebi.' >&2; exit 1; }
|
||||
|
||||
release:
|
||||
$(MAKE) BUILD_DIR=.build/release DEBUG_FLAGS= all
|
||||
|
||||
install: release
|
||||
mkdir -p "$(PREFIX)/lib/terebi" "$(PREFIX)/bin"
|
||||
mkdir -p "$(APPDIR)" "$(PREFIX)/bin"
|
||||
rm -f "$(PREFIX)/lib/terebi/terebi"
|
||||
rm -rf "$(PREFIX)/lib/terebi/terebi.app"
|
||||
cp ".build/release/terebi" "$(PREFIX)/lib/terebi/terebi"
|
||||
cp -R ".build/release/terebi.app" "$(PREFIX)/lib/terebi/terebi.app"
|
||||
ln -sfn "$$(cd "$(PREFIX)" && pwd)/lib/terebi/terebi" "$(PREFIX)/bin/terebi"
|
||||
rm -rf "$(PREFIX)/lib/terebi/terebi.app" "$(APPDIR)/terebi.app" "$(APPDIR)/Terebi.app"
|
||||
cp -R ".build/release/terebi.app" "$(APPDIR)/Terebi.app"
|
||||
ln -sfn "$$(cd "$(APPDIR)" && pwd)/Terebi.app/Contents/MacOS/terebi-cli" "$(PREFIX)/bin/terebi"
|
||||
/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister -f "$(APPDIR)/Terebi.app"
|
||||
mdimport "$(APPDIR)/Terebi.app"
|
||||
|
||||
$(BUILD_DIR):
|
||||
mkdir -p "$@"
|
||||
@@ -53,13 +74,16 @@ $(APP)/Contents/Resources/TOMLDecoder-LICENSE.txt: $(CONFIG_LIBRARY)
|
||||
mkdir -p "$(APP)/Contents/Resources"
|
||||
cp -f "$(CONFIG_BUILD)/checkouts/TOMLDecoder/LICENSE.md" "$@"
|
||||
|
||||
$(BUILD_DIR)/player.o: Sources/Core/player.cpp Sources/Core/player.h Sources/Core/player_macos.h Sources/Core/output.hpp Sources/Core/network_input.hpp Sources/Core/progress_reporter.hpp Makefile | $(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 $@
|
||||
|
||||
$(BUILD_DIR)/progress_reporter.o: Sources/Core/progress_reporter.cpp Sources/Core/progress_reporter.hpp Sources/Core/player.h Makefile | $(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 $@
|
||||
|
||||
$(BUILD_DIR)/Output.o: Sources/macOS/Output.mm Sources/Core/output.hpp Makefile | $(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
|
||||
$(CXX) $(CXXFLAGS) -c $< -o $@
|
||||
|
||||
$(BUILD_DIR)/Output.o: Sources/macOS/Output.mm Sources/Core/output.hpp Makefile $(NATIVE_STAMP) | $(BUILD_DIR) check-dependencies
|
||||
$(CXX) $(CXXFLAGS) -fobjc-arc -c $< -o $@
|
||||
|
||||
$(PLAYER): $(SWIFT_SOURCES) $(OBJECTS) $(CONFIG_LIBRARY) Makefile | check-dependencies
|
||||
@@ -67,15 +91,6 @@ $(PLAYER): $(SWIFT_SOURCES) $(OBJECTS) $(CONFIG_LIBRARY) Makefile | check-depend
|
||||
rm -f "$(APP)/Contents/MacOS/terebi-player"
|
||||
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -emit-library -O $(DEBUG_FLAGS) -import-objc-header Sources/Core/player_macos.h $(SWIFT_SOURCES) $(CONFIG_FLAGS) $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -lc++ -Xlinker -install_name -Xlinker @rpath/libterebi-player.dylib -o $@
|
||||
|
||||
$(MANIFEST): $(PLAYER) Makefile
|
||||
mkdir -p "$(APP)/Contents/Resources"
|
||||
@otool -L "$<" | sed -nE 's#^[[:space:]]+(/.*/lib(avformat|avcodec|avutil|swresample|swscale)(\.[^/[:space:]]+)?\.dylib) \(.*#\1#p' > "$@.tmp"; \
|
||||
if [ "$$(wc -l < "$@.tmp" | tr -d ' ')" != 5 ]; then \
|
||||
rm -f "$@.tmp"; \
|
||||
printf '%s\n' 'Error: could not record all five absolute FFmpeg dylib paths from the player library. Rebuild with Homebrew FFmpeg: brew install ffmpeg pkg-config' >&2; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
mv "$@.tmp" "$@"
|
||||
|
||||
$(BOOTSTRAP): Sources/App/Bootstrap.swift $(RUNTIME_SOURCE) Makefile | check-dependencies
|
||||
mkdir -p "$(APP)/Contents/MacOS"
|
||||
@@ -85,17 +100,75 @@ $(APP)/Contents/Info.plist: Resources/Info.plist
|
||||
mkdir -p "$(APP)/Contents"
|
||||
cp "$<" "$@"
|
||||
|
||||
$(BUILD_DIR)/terebi: $(CLI_SOURCES) $(PLAYER) $(BOOTSTRAP) $(MANIFEST) $(APP)/Contents/Info.plist Makefile | check-dependencies
|
||||
rm -f "$@"
|
||||
$(APP)/Contents/Resources/AppIcon.icns: Resources/AppIcon.icns
|
||||
mkdir -p "$(APP)/Contents/Resources"
|
||||
cp "$<" "$@"
|
||||
|
||||
$(CLI): $(CLI_SOURCES) $(CONFIG_LIBRARY) Makefile
|
||||
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 $@
|
||||
|
||||
$(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)"
|
||||
touch "$@"
|
||||
|
||||
$(BUILD_DIR)/terebi: $(BUNDLE_STAMP)
|
||||
ln -sfn "terebi.app/Contents/MacOS/terebi-cli" "$@"
|
||||
|
||||
run: all
|
||||
open "$(APP)"
|
||||
|
||||
$(BUILD_DIR)/playback-check: Checks/playback.mm $(OBJECTS) Makefile | check-dependencies
|
||||
$(CXX) $(CXXFLAGS) -fobjc-arc $< $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -o $@
|
||||
|
||||
check: check-dependencies $(BUILD_DIR)/playback-check 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
|
||||
$(BUILD_DIR)/video-decoding-check: Checks/video-decoding.mm 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 $(filter-out $(BUILD_DIR)/player.o,$(OBJECTS)) Makefile | check-dependencies
|
||||
$(CXX) $(CXXFLAGS) -fobjc-arc $< $(filter-out $(BUILD_DIR)/player.o,$(OBJECTS)) $(FF_LIBS) $(FRAMEWORKS) -o $@
|
||||
|
||||
$(BUILD_DIR)/video-decoding-software-check: Checks/video-decoding.mm 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 $(filter-out $(BUILD_DIR)/player.o,$(OBJECTS)) Makefile | check-dependencies
|
||||
$(CXX) $(CXXFLAGS) -DTEREBI_SOFTWARE_DECODE -fobjc-arc $< $(filter-out $(BUILD_DIR)/player.o,$(OBJECTS)) $(FF_LIBS) $(FRAMEWORKS) -o $@
|
||||
|
||||
$(BUILD_DIR)/cast-media-software-check: Checks/cast-media.cpp 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 | check-dependencies
|
||||
$(CXX) $(CXXFLAGS) -ISources/Core -DCAST_MEDIA_CHECK -DTEREBI_SOFTWARE_DECODE Checks/cast-media.cpp Sources/Core/cast_media.cpp $(FF_LIBS) -o $@
|
||||
|
||||
check-video-decoding: $(BUILD_DIR)/video-decoding-check $(BUILD_DIR)/cast-media-check $(DECODE_FIXTURES) $(BUILD_DIR)/fixtures/decode-preroll.ts
|
||||
$(BUILD_DIR)/video-decoding-check --metal $(DECODE_FIXTURES)
|
||||
$(BUILD_DIR)/video-decoding-check --preroll $(BUILD_DIR)/fixtures/decode-preroll.ts
|
||||
for fixture in $(DECODE_FIXTURES); do $(BUILD_DIR)/cast-media-check --decode-work "$$fixture" || exit; done
|
||||
|
||||
$(DECODE_FIXTURES): Makefile | check-dependencies
|
||||
mkdir -p $(BUILD_DIR)/fixtures
|
||||
ffmpeg -hide_banner -loglevel error -y -f lavfi -i testsrc2=size=1280x720:rate=30:duration=12 -vf setparams=color_primaries=bt709:color_trc=bt709:colorspace=bt709 -an -threads 2 $(DECODE_ENCODER) $@
|
||||
|
||||
$(BUILD_DIR)/fixtures/decode-h264.mp4: DECODE_ENCODER = -c:v libx264 -preset ultrafast -bf 2 -g 60
|
||||
$(BUILD_DIR)/fixtures/decode-hevc.mp4: DECODE_ENCODER = -c:v libx265 -preset ultrafast -x265-params log-level=error:pools=2 -g 60
|
||||
$(BUILD_DIR)/fixtures/decode-vp9.mp4: DECODE_ENCODER = -c:v libvpx-vp9 -deadline realtime -cpu-used 8 -row-mt 1 -g 60
|
||||
$(BUILD_DIR)/fixtures/decode-av1.mp4: DECODE_ENCODER = -c:v libsvtav1 -preset 12 -svtav1-params lp=2 -g 60
|
||||
$(BUILD_DIR)/fixtures/decode-h264-10.mp4: DECODE_ENCODER = -c:v libx264 -preset ultrafast -pix_fmt yuv420p10le -bf 2 -g 60
|
||||
$(BUILD_DIR)/fixtures/decode-mpeg4.mp4: DECODE_ENCODER = -c:v mpeg4 -bf 2 -g 60
|
||||
|
||||
$(BUILD_DIR)/fixtures/decode-preroll.ts: $(BUILD_DIR)/fixtures/decode-h264.mp4 Makefile
|
||||
ffmpeg -hide_banner -loglevel error -y -i $< -map 0:v:0 -c copy -mpegts_copyts 1 -muxdelay 0 -avoid_negative_ts disabled $@
|
||||
|
||||
$(BUILD_DIR)/seek-queue-check: Checks/seek-queue.cpp 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 $(filter-out $(BUILD_DIR)/player.o,$(OBJECTS)) Makefile | check-dependencies
|
||||
$(CXX) $(CXXFLAGS) $< $(filter-out $(BUILD_DIR)/player.o,$(OBJECTS)) $(FF_LIBS) $(FRAMEWORKS) -o $@
|
||||
|
||||
check-seek-queue: $(BUILD_DIR)/seek-queue-check
|
||||
$(BUILD_DIR)/seek-queue-check
|
||||
|
||||
$(BUILD_DIR)/local-seek-check: Checks/local-seek.mm 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 $(filter-out $(BUILD_DIR)/player.o,$(OBJECTS)) Makefile | check-dependencies
|
||||
$(CXX) $(CXXFLAGS) $< $(filter-out $(BUILD_DIR)/player.o,$(OBJECTS)) $(FF_LIBS) $(FRAMEWORKS) -o $@
|
||||
|
||||
$(BUILD_DIR)/fixtures/local-seek.mp4: Makefile | check-dependencies
|
||||
mkdir -p $(BUILD_DIR)/fixtures
|
||||
ffmpeg -hide_banner -loglevel error -y -f lavfi -i testsrc2=size=1920x1080:rate=30:duration=12 -f lavfi -i sine=frequency=440:sample_rate=48000:duration=12 -c:v libx264 -preset ultrafast -threads 2 -bf 2 -g 300 -keyint_min 300 -sc_threshold 0 -c:a aac $@
|
||||
|
||||
.PHONY: check-local-seek
|
||||
check-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-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
|
||||
$(BUILD_DIR)/playback-check $(BUILD_DIR)/fixtures
|
||||
|
||||
check-fixtures: | check-dependencies
|
||||
@@ -109,7 +182,7 @@ check-fixtures: | check-dependencies
|
||||
ffmpeg -hide_banner -loglevel error -y -f lavfi -i testsrc2=size=160x90:rate=12 -t 1 -vf 'format=yuv420p10le,setparams=color_primaries=bt2020:color_trc=smpte2084:colorspace=bt2020nc' -c:v ffv1 $(BUILD_DIR)/fixtures/hdr.mkv
|
||||
ffmpeg -hide_banner -loglevel error -y -f lavfi -i testsrc2=size=320x180:rate=24 -f lavfi -i sine=frequency=440:sample_rate=44100 -t 120 -c:v mpeg4 -q:v 2 -g 24 -bf 2 -c:a aac -movflags +faststart $(BUILD_DIR)/fixtures/cache.mp4
|
||||
|
||||
$(BUILD_DIR)/Output-check.o: Sources/macOS/Output.mm Sources/Core/output.hpp Makefile | $(BUILD_DIR) check-dependencies
|
||||
$(BUILD_DIR)/Output-check.o: Sources/macOS/Output.mm Sources/Core/output.hpp Makefile $(NATIVE_STAMP) | $(BUILD_DIR) check-dependencies
|
||||
$(CXX) $(CXXFLAGS) -fobjc-arc -DPLAYER_AUDIO_CHECK -c $< -o $@
|
||||
|
||||
$(BUILD_DIR)/audio-continuity-check: Checks/audio-continuity.cpp $(BUILD_DIR)/player.o $(BUILD_DIR)/progress_reporter.o $(BUILD_DIR)/Output-check.o Makefile | check-dependencies
|
||||
@@ -122,11 +195,13 @@ check-audio: $(BUILD_DIR)/audio-continuity-check
|
||||
ffmpeg -hide_banner -loglevel error -y -f lavfi -i testsrc2=size=160x90:rate=24:duration=2 -i $(BUILD_DIR)/fixtures/tone480.wav -c:v mpeg4 -g 48 -bf 2 -c:a copy $(BUILD_DIR)/fixtures/tone-step.mov
|
||||
ffmpeg -hide_banner -loglevel error -y -i $(BUILD_DIR)/fixtures/tone441.wav -c:a copy $(BUILD_DIR)/fixtures/tone441.mka
|
||||
ffmpeg -hide_banner -loglevel error -y -i $(BUILD_DIR)/fixtures/tone441.wav -c:a aac $(BUILD_DIR)/fixtures/tone-aac.m4a
|
||||
ffmpeg -hide_banner -loglevel error -y -i $(BUILD_DIR)/fixtures/tone480.wav -af "asetnsamples=n=1024:p=0,asetpts='round(PTS*TB*1000)/1000/TB'" -c:a aac -f mpegts $(BUILD_DIR)/fixtures/tone-jitter.ts
|
||||
ffmpeg -hide_banner -loglevel error -y -i $(BUILD_DIR)/fixtures/tone441.wav -af "asetpts='PTS+gte(T,1)*0.25/TB'" -c:a pcm_s16le $(BUILD_DIR)/fixtures/tone-gap.mka
|
||||
ffmpeg -hide_banner -loglevel error -y -i $(BUILD_DIR)/fixtures/tone441.wav -ar 48000 -ac 2 -f f32le $(BUILD_DIR)/fixtures/tone441.wav.f32
|
||||
ffmpeg -hide_banner -loglevel error -y -i $(BUILD_DIR)/fixtures/tone480.wav -ar 48000 -ac 2 -f f32le $(BUILD_DIR)/fixtures/tone480.wav.f32
|
||||
ffmpeg -hide_banner -loglevel error -y -i $(BUILD_DIR)/fixtures/tone441.mka -ar 48000 -ac 2 -f f32le $(BUILD_DIR)/fixtures/tone441.mka.f32
|
||||
ffmpeg -hide_banner -loglevel error -y -i $(BUILD_DIR)/fixtures/tone-aac.m4a -ar 48000 -ac 2 -f f32le $(BUILD_DIR)/fixtures/tone-aac.m4a.f32
|
||||
ffmpeg -hide_banner -loglevel error -y -i $(BUILD_DIR)/fixtures/tone-jitter.ts -ar 48000 -ac 2 -f f32le $(BUILD_DIR)/fixtures/tone-jitter.ts.f32
|
||||
$(BUILD_DIR)/audio-continuity-check $(BUILD_DIR)/fixtures
|
||||
|
||||
$(BUILD_DIR)/compact-window-check: Checks/compact-window.swift Sources/App/CompactWindow.swift Makefile | $(BUILD_DIR) check-dependencies
|
||||
@@ -141,11 +216,33 @@ $(BUILD_DIR)/configuration-check: Checks/configuration.swift $(CONFIG_LIBRARY) M
|
||||
check-config: $(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
|
||||
$(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 $@
|
||||
$(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 Sources/App/ResolvedSource.swift Sources/App/StreamResolver.swift -framework Foundation -o $@
|
||||
|
||||
check-youtube: $(BUILD_DIR)/youtube-resolver-check
|
||||
$(BUILD_DIR)/youtube-resolver-check
|
||||
check-youtube: $(BUILD_DIR)/youtube-resolver-check $(BUNDLE_STAMP)
|
||||
$(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)
|
||||
$(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 $@
|
||||
|
||||
.PHONY: check-stream
|
||||
check-stream: $(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
|
||||
$(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 $@
|
||||
@@ -153,18 +250,31 @@ $(BUILD_DIR)/automatic-quality-check: Checks/automatic-quality.swift Sources/App
|
||||
check-quality: $(BUILD_DIR)/automatic-quality-check
|
||||
$(BUILD_DIR)/automatic-quality-check
|
||||
|
||||
$(BUILD_DIR)/playback-queue-check: Checks/playback-queue.swift Sources/App/PlaybackQueue.swift Makefile | $(BUILD_DIR)
|
||||
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O $(DEBUG_FLAGS) Checks/playback-queue.swift Sources/App/PlaybackQueue.swift -framework Foundation -o $@
|
||||
|
||||
check-queue: $(BUILD_DIR)/playback-queue-check
|
||||
$(BUILD_DIR)/playback-queue-check
|
||||
|
||||
$(BUILD_DIR)/automatic-quality-playback-check: Checks/automatic-quality-playback.swift $(SWIFT_SOURCES) $(OBJECTS) $(CONFIG_LIBRARY) Makefile | check-dependencies
|
||||
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -DPLAYER_CHECK -O $(DEBUG_FLAGS) -import-objc-header Sources/Core/player_macos.h $< $(SWIFT_SOURCES) $(CONFIG_FLAGS) $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -lc++ -o $@
|
||||
|
||||
check-adaptive: $(BUILD_DIR)/automatic-quality-playback-check
|
||||
check-adaptive: $(BUILD_DIR)/automatic-quality-playback-check $(BUNDLE_STAMP)
|
||||
mkdir -p $(BUILD_DIR)/fixtures
|
||||
ffmpeg -hide_banner -loglevel error -y -f lavfi -i color=c=blue:size=1920x1080:rate=24 -f lavfi -i sine=frequency=440:sample_rate=44100 -t 120 -c:v mpeg4 -g 24 -bf 2 -c:a aac -movflags +faststart $(BUILD_DIR)/fixtures/quality-1080.mp4
|
||||
ffmpeg -hide_banner -loglevel error -y -f lavfi -i color=c=green:size=1280x720:rate=24 -f lavfi -i sine=frequency=440:sample_rate=44100 -t 120 -c:v mpeg4 -g 24 -bf 2 -c:a aac -movflags +faststart $(BUILD_DIR)/fixtures/quality-720.mp4
|
||||
python3 Checks/automatic-quality.py $(BUILD_DIR)/automatic-quality-playback-check $(BUILD_DIR)/fixtures
|
||||
python3 Checks/automatic-quality.py $(BUILD_DIR)/automatic-quality-playback-check $(BUILD_DIR)/fixtures "$(APP)"
|
||||
|
||||
$(BUILD_DIR)/network-playback-check: Checks/network-playback.mm $(OBJECTS) Makefile | check-dependencies
|
||||
$(CXX) $(CXXFLAGS) -fobjc-arc $< $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -o $@
|
||||
|
||||
$(BUILD_DIR)/playback-benchmark: Checks/playback-benchmark.mm $(OBJECTS) Makefile | check-dependencies
|
||||
$(CXX) $(CXXFLAGS) -fobjc-arc $< $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -o $@
|
||||
|
||||
.PHONY: benchmark-playback
|
||||
benchmark-playback: $(BUILD_DIR)/playback-benchmark $(BUILD_DIR)/cast-media-check
|
||||
python3 Checks/playback-benchmark.py $(BUILD_DIR)/playback-benchmark $(BUILD_DIR)/cast-media-check $(BUILD_DIR)/fixtures/playback-benchmark
|
||||
|
||||
check-network: $(BUILD_DIR)/network-playback-check check-fixtures
|
||||
ffmpeg -hide_banner -loglevel error -y -i $(BUILD_DIR)/fixtures/frame-step.mp4 -an -c:v copy $(BUILD_DIR)/fixtures/frame-step-video.mp4
|
||||
ffmpeg -hide_banner -loglevel error -y -i $(BUILD_DIR)/fixtures/frame-step.mp4 -vn -c:a copy $(BUILD_DIR)/fixtures/frame-step-audio.m4a
|
||||
@@ -178,32 +288,96 @@ $(BUILD_DIR)/playback-progress-check: Checks/playback-progress.swift Sources/App
|
||||
check-progress: $(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 $@
|
||||
|
||||
check-reporting: $(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 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 -framework Foundation -o $@
|
||||
$(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 Sources/App/StreamQueue.swift -framework Foundation -o $@
|
||||
|
||||
check-cli: $(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)
|
||||
$(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 $@
|
||||
|
||||
check-subtitles: $(BUILD_DIR)/external-subtitles-check
|
||||
$(BUILD_DIR)/external-subtitles-check
|
||||
|
||||
$(BUILD_DIR)/resolved-playback-check: Checks/resolved-playback.swift $(SWIFT_SOURCES) $(OBJECTS) $(CONFIG_LIBRARY) Makefile | check-dependencies
|
||||
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -DPLAYER_CHECK -O $(DEBUG_FLAGS) -import-objc-header Sources/Core/player_macos.h $< $(SWIFT_SOURCES) $(CONFIG_FLAGS) $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -lc++ -o $@
|
||||
|
||||
check-resolved: $(BUILD_DIR)/resolved-playback-check check-fixtures
|
||||
python3 Checks/resolved-playback.py $(BUILD_DIR)/resolved-playback-check $(BUILD_DIR)/fixtures
|
||||
|
||||
.PHONY: check-zoko-intervals
|
||||
check-zoko-intervals: $(BUILD_DIR)/resolved-playback-check check-fixtures
|
||||
python3 Checks/resolved-playback.py $(BUILD_DIR)/resolved-playback-check $(BUILD_DIR)/fixtures --zoko-intervals
|
||||
|
||||
|
||||
$(BUILD_DIR)/ffmpeg-runtime-check: Checks/ffmpeg-runtime.swift $(RUNTIME_SOURCE) Makefile | $(BUILD_DIR)
|
||||
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O $(DEBUG_FLAGS) Checks/ffmpeg-runtime.swift $(RUNTIME_SOURCE) -framework Foundation -o $@
|
||||
|
||||
check-runtime: $(BUILD_DIR)/ffmpeg-runtime-check $(MANIFEST)
|
||||
check-runtime: $(BUILD_DIR)/ffmpeg-runtime-check $(BUNDLE_STAMP)
|
||||
$(BUILD_DIR)/ffmpeg-runtime-check "$(APP)"
|
||||
|
||||
$(BUILD_DIR)/system-playback-check: Checks/system-playback.swift $(SWIFT_SOURCES) $(OBJECTS) $(CONFIG_LIBRARY) Makefile | check-dependencies
|
||||
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -DPLAYER_CHECK -O $(DEBUG_FLAGS) -import-objc-header Sources/Core/player_macos.h Checks/system-playback.swift $(SWIFT_SOURCES) $(CONFIG_FLAGS) $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -lc++ -o $@
|
||||
$(BUILD_DIR)/system-playback-check: Checks/system-playback.swift Checks/playback-power.swift $(SWIFT_SOURCES) $(OBJECTS) $(CONFIG_LIBRARY) Makefile | check-dependencies
|
||||
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -DPLAYER_CHECK -O $(DEBUG_FLAGS) -import-objc-header Sources/Core/player_macos.h Checks/system-playback.swift Checks/playback-power.swift $(SWIFT_SOURCES) $(CONFIG_FLAGS) $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -lc++ -o $@
|
||||
|
||||
check-media: $(BUILD_DIR)/system-playback-check check-fixtures
|
||||
$(BUILD_DIR)/system-playback-check $(BUILD_DIR)/fixtures
|
||||
|
||||
$(BUILD_DIR)/cast-session-check: Checks/cast-session.swift Sources/App/CastSession.swift Makefile | $(BUILD_DIR)
|
||||
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -DCAST_SESSION_CHECKS -O $(DEBUG_FLAGS) Checks/cast-session.swift Sources/App/CastSession.swift -framework Foundation -framework Network -framework Security -o $@
|
||||
|
||||
$(BUILD_DIR)/cast_media-check.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
|
||||
$(CXX) $(CXXFLAGS) -DCAST_MEDIA_CHECK -c $< -o $@
|
||||
|
||||
$(BUILD_DIR)/cast-media-check: Checks/cast-media.cpp $(BUILD_DIR)/cast_media-check.o Makefile | check-dependencies
|
||||
$(CXX) $(CXXFLAGS) -ISources/Core -DCAST_MEDIA_CHECK $< $(BUILD_DIR)/cast_media-check.o $(FF_LIBS) -o $@
|
||||
|
||||
$(BUILD_DIR)/cast-playback-check: Checks/cast-playback.swift Checks/playback-power.swift $(SWIFT_SOURCES) $(OBJECTS) $(CONFIG_LIBRARY) Makefile | check-dependencies
|
||||
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -DPLAYER_CHECK -O $(DEBUG_FLAGS) -import-objc-header Sources/Core/player_macos.h $< Checks/playback-power.swift $(SWIFT_SOURCES) $(CONFIG_FLAGS) $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -lc++ -o $@
|
||||
|
||||
check-cast: $(BUILD_DIR)/cast-session-check $(BUILD_DIR)/cast-media-check $(BUILD_DIR)/cast-playback-check check-fixtures
|
||||
$(BUILD_DIR)/cast-session-check
|
||||
$(BUILD_DIR)/cast-media-check --live
|
||||
$(BUILD_DIR)/cast-media-check --remux-work
|
||||
$(BUILD_DIR)/cast-media-check --codec-work
|
||||
$(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
|
||||
$(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 $@
|
||||
|
||||
check-startup: all $(BUILD_DIR)/app-startup-check.dylib check-fixtures
|
||||
python3 Checks/app-startup.py "$(APP)" "$(BUILD_DIR)/app-startup-check.dylib" "$(BUILD_DIR)/fixtures"
|
||||
python3 Checks/app-startup.py "$(APP)" "$(BUILD_DIR)/app-startup-check.dylib" "$(BUILD_DIR)/fixtures" "$(DEPENDENCY_ROOT)"
|
||||
|
||||
check-bundle: all check-fixtures
|
||||
python3 Checks/bundle.py "$(APP)" "$(BUILD_DIR)/fixtures" "$(DEPENDENCY_ROOT)"
|
||||
|
||||
ZOKO_CHECK_SOURCES := Sources/App/ZokoResolver.swift Sources/App/StreamResolver.swift Sources/App/YouTubeResolver.swift Sources/App/ResolvedSource.swift Sources/App/MiruroTransport.swift Sources/App/MiruroEmbed.swift Sources/App/ExternalSubtitles.swift Sources/App/PlaybackProgress.swift
|
||||
$(BUILD_DIR)/zoko-check: Checks/zoko.swift $(ZOKO_CHECK_SOURCES) $(OBJECTS) Makefile | check-dependencies
|
||||
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O $(DEBUG_FLAGS) -import-objc-header Sources/Core/player.h $< $(ZOKO_CHECK_SOURCES) $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -lc++ -o $@
|
||||
|
||||
.PHONY: check-zoko check-zoko-live
|
||||
check: check-zoko
|
||||
check-zoko: $(BUILD_DIR)/zoko-check
|
||||
$(BUILD_DIR)/zoko-check
|
||||
check-zoko-live: $(BUILD_DIR)/zoko-check
|
||||
$(BUILD_DIR)/zoko-check --live
|
||||
|
||||
@@ -1,280 +1,189 @@
|
||||
# terebi
|
||||
# Terebi
|
||||
|
||||
A personal native macOS player whose playback code is small enough to own and modify. FFmpeg reads and decodes media; our C++ worker owns playback. SwiftUI owns the controls, Core Audio owns audio output, and Metal presents video.
|
||||
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 run
|
||||
## Build and install
|
||||
|
||||
Requires macOS 15+, Xcode's command-line tools with Swift 6, and Homebrew FFmpeg/pkg-config:
|
||||
|
||||
The first build fetches pinned [TOMLDecoder 0.4.5](https://github.com/dduan/TOMLDecoder/tree/0.4.5) through SwiftPM. It is statically linked into the CLI/player; running the installed app needs no separate TOML runtime. Its MIT notice is included in the app's Resources.
|
||||
Requires macOS 15+, Xcode Command Line Tools with Swift 6, Python 3.9+ and Homebrew build tools. Build natively on Apple Silicon or Intel; Rosetta and cross-architecture builds are not supported.
|
||||
|
||||
```sh
|
||||
brew install ffmpeg pkg-config
|
||||
make
|
||||
make run
|
||||
```
|
||||
|
||||
The developer app is `.build/terebi.app`. It links the installed FFmpeg libraries; it is not a standalone distributable bundle. After upgrading FFmpeg, rebuild with `make -B` or reinstall with `make -B install`.
|
||||
|
||||
Open a local file with **⌘O**, drop it onto the window, or use Finder's **Open With**. Successfully opened files start playback automatically, including when replacing the current file. **⇧⌘W** closes the file; closing the window quits and joins the worker.
|
||||
|
||||
## Release and installation
|
||||
|
||||
```sh
|
||||
make release # optimized build in .build/release, without -g
|
||||
make install # default PREFIX=$HOME/.local
|
||||
make install PREFIX="/custom/path" # optional installation prefix
|
||||
brew install pkg-config meson ninja
|
||||
# Intel Macs also need: brew install nasm
|
||||
make install
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
terebi --help
|
||||
```
|
||||
|
||||
Installation puts the CLI at `~/.local/lib/terebi/terebi` and the app beside it at `~/.local/lib/terebi/terebi.app`; `~/.local/bin/terebi` is a symlink to the CLI. A custom `PREFIX` uses the same `lib/terebi` and `bin` layout. The default needs no sudo. Release artifacts are separate from developer builds, and reinstall replaces the installed app bundle without touching unrelated prefix contents. Close a running player and relaunch after installing an update.
|
||||
Open **Terebi** from `~/Applications` or Spotlight. The CLI is installed at `~/.local/bin/terebi`; add that directory to your shell's `PATH` to keep it available.
|
||||
|
||||
FFmpeg is still external. Builds and installs—including cached release installs—check `pkg-config` and all five FFmpeg development packages before compiling or copying. Missing dependencies report `brew install ffmpeg pkg-config`.
|
||||
The first build downloads dependencies. Quit Terebi before updating, then run `make install` again. To install elsewhere, use `make install APPDIR="/Applications"` (requires a writable directory) or set `PREFIX` to change the CLI prefix.
|
||||
|
||||
At runtime, both the CLI and the app's lightweight bootstrap check the exact FFmpeg dylibs linked by the player, including their transitive dependencies. Missing, incompatible, or broken libraries report `brew install ffmpeg` and explain that library-version changes require rebuilding/reinstalling terebi. The CLI exits **69**; Finder launches show a native error dialog instead of failing in dyld before the UI starts. Missing or invalid dependency metadata also reports rebuild/reinstall. CLI help remains available without FFmpeg.
|
||||
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 declared app executable stays running after preflight and loads `Contents/Frameworks/libterebi-player.dylib` in-process. It does not `exec` another executable: LaunchServices retains the real app PID and identity, so third-party Now Playing source lists such as Vorssaint can discover it. Missing/unloadable player libraries or an invalid entry point also show the native rebuild/reinstall error dialog.
|
||||
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
|
||||
|
||||
- **Local files:** use **⌘O**, drag files onto the player, or choose Finder's **Open With**. Multiple files form a queue in natural filename order, with automatic advance. Use **View → Show Queue** to play, reorder or remove items; **+ → file icon** or dropping files onto the sidebar appends them. The queue lasts only for the session; opening other media replaces it.
|
||||
- **Streams:** use **Open Stream…** (**⇧⌘O**) or drop an HTTP(S) video link. To append without replacing playback, press the sidebar's **+**, then the **link icon**: the URL field opens upwards; **Return** fetches and adds the video. The link icon toggles the field; **Esc** dismisses it and cancels a pending fetch. Fetch errors leave the queue unchanged. Links are passed to the bundled yt-dlp without a site allowlist; extraction must return a single streamable video, not a playlist or channel. Miruro keeps its dedicated resolver: links must use `https://barelystarted.miruro.tv/watch/<opaque-id>/<slug>?ep=<episode>` with an explicit positive episode number.
|
||||
- **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.
|
||||
- **Compact mode:** press **P** for a floating player; double-click it to restore. It follows you across desktops and other apps' native fullscreen Spaces while keeping Terebi in the Dock and **⌘Tab**. Moving between compact and normal mode preserves the running player and restores the original window. Drag noninteractive areas to move the window. The queue starts hidden each time you enter compact mode; **⇧⌘L** or **gear → Show Queue** expands it below the video. Its top fade has a 12-point solid section and a 20-point transition, with the first row starting below both. The same shortcut toggles the normal sidebar, whose visibility is preserved when leaving compact mode. Use the titlebar pin to keep the normal window on top.
|
||||
- **Quality:** yt-dlp defaults to SDR up to 1080p when resolution is reported. Missing resolution/codec hints are left to native probing. Use **gear → Quality** for other reported resolutions or automatic quality.
|
||||
|
||||
### Keyboard shortcuts
|
||||
|
||||
| Action | Default shortcut |
|
||||
| --- | --- |
|
||||
| Play / pause | Space or K |
|
||||
| Seek backward / forward 5 seconds | ← / → |
|
||||
| Seek backward / forward 10 seconds | J / L |
|
||||
| Step one frame backward / forward | , / . |
|
||||
| Volume down / up | ↓ / ↑ |
|
||||
| Mute / unmute | M |
|
||||
| Fullscreen | F or ⌃⌘F; Esc to leave |
|
||||
| Compact mode | P or ⌃⌘P |
|
||||
| Show/hide queue (normal or compact) | ⇧⌘L |
|
||||
| Always on top | ⇧P |
|
||||
| Close media | ⇧⌘W |
|
||||
| Quit | Q or ⌘Q |
|
||||
|
||||
### Chromecast
|
||||
|
||||
Open media, select the **Cast** icon or **Playback → Cast to Device**, and choose a receiver on the same LAN. Allow macOS Local Network access and incoming firewall connections. Use **Find Cast Devices…** to retry discovery.
|
||||
|
||||
Playback controls and queue navigation work on the receiver. **Play on This Mac** returns to local playback at the receiver's position. Keep the Mac connected: it relays the media and prevents idle system sleep during playback, while allowing display sleep when casting. Closing the lid or explicitly sleeping the Mac can interrupt playback.
|
||||
|
||||
Cast output is limited to 1080p/30 fps; frame stepping and stream quality changes are local-only. 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
|
||||
|
||||
`make` also builds `.build/terebi`, a native CLI launcher:
|
||||
|
||||
```sh
|
||||
.build/terebi "/path/to/video.mp4"
|
||||
.build/terebi 'https://youtu.be/jNQXAC9IVRw'
|
||||
.build/terebi --url 'https://www.youtube.com/watch?v=jNQXAC9IVRw'
|
||||
.build/terebi -- -video.mp4
|
||||
.build/terebi --new-instance
|
||||
.build/terebi --new-instance --url 'https://www.youtube.com/watch?v=jNQXAC9IVRw'
|
||||
.build/terebi --new-instance --url 'https://www.youtube.com/watch?v=jNQXAC9IVRw' --progress-file "/private/session/progress.jsonl"
|
||||
.build/terebi --new-instance --no-config "/path/to/video.mp4"
|
||||
.build/terebi --help
|
||||
.build/terebi --default-config
|
||||
terebi "/path/to/video.mp4"
|
||||
terebi 'https://youtu.be/jNQXAC9IVRw'
|
||||
terebi --new-instance --no-config "/path/to/video.mp4"
|
||||
terebi --enqueue --queue-file "/absolute/private/session/queue.json"
|
||||
terebi --help
|
||||
```
|
||||
|
||||
One local file (including a local `file://` URL) or YouTube video URL is accepted. Paths are relative to the shell's current directory; quote spaces and URL query strings. `--` treats the following argument as a filename. With no arguments, the CLI opens or activates the player without replacing its media. Put **`--new-instance` before the media arguments** to launch a separate player process/window instead: it works without media, with a local file or positional YouTube URL, and with `--url` or `--`. Existing playback stays untouched. To use `terebi` by name, add the build directory to your shell's PATH: `export PATH="$PWD/.build:$PATH"`.
|
||||
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 launcher finds `terebi.app` beside its own executable, following symlinks. By default, macOS application/URL delivery reuses a running player and its existing window. With `--new-instance`, one fresh application launch carries any media URL as a startup argument, so it reaches that process without a second app launch or changing another player's media. Keep the executable and app bundle together; this is not a standalone distribution. Media opens use the same autoplay and saved-progress behavior as the UI. Instances share saved progress, but configuration and control overrides belong to each session. Quit all running players after installing this update so every process uses the updated configuration/startup handling.
|
||||
For native episode-URL playback, ordered stream queues, external resolved sources and progress reporting, see the [integration contract](docs/miruro-playback-handoff.md).
|
||||
|
||||
Help and `--default-config` exit with status **0** without opening the GUI, reading configuration, or checking/loading FFmpeg. The latter prints complete built-in TOML to stdout, including every named key action and explicit `[]` entries for unbound actions; it uses the player's authoritative defaults rather than your current settings. It accepts no media argument and never creates or writes a config file. Invalid arguments, unsupported URLs, missing media files, and directories report to stderr and exit **64**; unavailable FFmpeg libraries and application-launch failures exit **69**; unreadable or invalid configuration exits **78** with the file path and diagnostic. A successful launch exits **0** immediately after macOS accepts the request, not when playback finishes. Later extraction/decoding failures appear in the player window; this is a GUI launcher, not a headless player or remote playback-control interface.
|
||||
### AniList → ZokoAnime direct playback
|
||||
|
||||
Reporting destination preflight failures exit **73** without launching. The GUI still creates the file atomically and reports initialization failures with a native alert; those can occur after the launcher has returned successfully.
|
||||
```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
|
||||
|
||||
Every fresh app startup automatically reads **`$XDG_CONFIG_HOME/terebi/config.toml`**, falling back to **`~/.config/terebi/config.toml`** when `XDG_CONFIG_HOME` is unset, empty, or not absolute. The CLI forwards its resolved path because Finder/LaunchServices does not inherit the shell's XDG environment. Set `XDG_CONFIG_HOME` in the GUI launch environment if Finder should use a different directory.
|
||||
The optional config file is `~/.config/terebi/config.toml`, or `$XDG_CONFIG_HOME/terebi/config.toml` when `XDG_CONFIG_HOME` is absolute. To print all supported settings and key bindings:
|
||||
|
||||
The file is optional: a missing file means built-in defaults, and terebi never creates a file or directory on its own. The complete built-in configuration is:
|
||||
```sh
|
||||
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. 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]
|
||||
automatic_quality = false
|
||||
|
||||
[ui]
|
||||
buffer_info = false
|
||||
compact_snap_to_edges = true
|
||||
|
||||
[keys]
|
||||
play = ["k", "space"]
|
||||
pause = ["k", "space"]
|
||||
mute = "m"
|
||||
unmute = "m"
|
||||
volume_up = "up"
|
||||
volume_down = "down"
|
||||
seek_forward_5s = "right"
|
||||
seek_backward_5s = "left"
|
||||
seek_forward_10s = "l"
|
||||
seek_backward_10s = "j"
|
||||
step_frame_forward = "."
|
||||
step_frame_backward = ","
|
||||
enter_fullscreen = ["f", "ctrl+cmd+f"]
|
||||
exit_fullscreen = ["f", "ctrl+cmd+f", "escape"]
|
||||
enter_compact = ["p", "ctrl+cmd+p"]
|
||||
exit_compact = ["p", "ctrl+cmd+p"]
|
||||
enable_always_on_top = "shift+p"
|
||||
disable_always_on_top = "shift+p"
|
||||
open_file = ["o", "cmd+o"]
|
||||
open_youtube = "cmd+shift+o"
|
||||
close_file = "cmd+shift+w"
|
||||
quit = ["q", "cmd+q"]
|
||||
show_buffer_info = []
|
||||
hide_buffer_info = []
|
||||
enable_compact_snapping = []
|
||||
disable_compact_snapping = []
|
||||
enable_automatic_quality = []
|
||||
disable_automatic_quality = []
|
||||
quality_default = []
|
||||
opening_ending_autoskip = true
|
||||
```
|
||||
|
||||
All settings belong to `[playback]`, `[ui]`, or `[keys]`; these sections can appear in any order. Omitted sections use built-in defaults. An assignment belongs to its current TOML section, so place `automatic_quality` beneath `[playback]`, not at the root or under another header.
|
||||
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.
|
||||
|
||||
Omitted settings/actions retain their defaults. A shortcut string or string array **replaces** an action's default bindings; `[]` unbinds it. Keys are case-insensitive logical keys, not physical key positions. Use exact modifiers `cmd`, `ctrl`, `alt`/`option`, and `shift`, joined with `+`; uppercase `P` alone does not mean Shift+P. Named keys are `space`, `escape`, `return`, `tab`, `backspace`, `delete`, `left`, `right`, `up`, `down`, `home`, `end`, `pageup`, `pagedown`, `plus`, and `f1`–`f35`; printable characters such as `,` and `.` also work. OS-reserved Cmd+Tab is rejected.
|
||||
### SponsorBlock
|
||||
|
||||
Bindings name explicit actions, not toggles. The same chord may be shared only by inverse pairs shown above: play/pause, mute/unmute, enter/exit fullscreen or compact, enable/disable always-on-top, show/hide buffer info, and enable/disable snapping or automatic quality. A press performs **one currently applicable action**, including pending playback/window intent; already-satisfied actions do nothing. Give each side different chords for dedicated enable/disable keys. Sharing a chord between unrelated actions, duplicate normalized chords, unknown actions, and malformed shortcuts are errors.
|
||||
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:
|
||||
|
||||
For YouTube, bind `quality_<height>p` (for example, `quality_2160p = "ctrl+4"`) to select an available resolution, or `quality_default` to restore the capped default. Resolution actions are unavailable for local media and absent resolutions; selecting manual/default quality disables automatic quality. Captions are not implemented, so caption and `toggle_*` action names are rejected.
|
||||
|
||||
Behavior/UI values must be TOML booleans. Unknown keys, malformed TOML, wrong types, unreadable files, and a directory in place of the file are errors, not silent defaults. Top-level `automatic_quality` is no longer accepted: move it under `[playback]`. The old flat `show_buffer_info` and `snap_to_edges` settings are also rejected: move them to `[ui]` as `buffer_info` and `compact_snap_to_edges`. GUI startup errors show a native alert. Settings are read once at startup; control/menu changes are **session-only** and never write the file. Edit it yourself and start a fresh player to apply changes.
|
||||
|
||||
Put **`--no-config` before the media arguments** to skip reading the file entirely and use built-in settings. Combine it with `--new-instance` in either order to guarantee a fresh session. Reusing a running player neither reloads its file nor resets its current settings, even with `--no-config`. Playback history remains separate in UserDefaults; legacy UserDefaults settings are no longer read or written.
|
||||
|
||||
## YouTube
|
||||
|
||||
YouTube playback optionally requires [yt-dlp](https://github.com/yt-dlp/yt-dlp), its EJS components, and a supported JavaScript runtime. Homebrew provides the extractor and its components; Deno is recommended:
|
||||
|
||||
```sh
|
||||
brew install yt-dlp deno
|
||||
```toml
|
||||
[playback]
|
||||
sponsorblock = true
|
||||
sponsorblock_autoskip = false
|
||||
```
|
||||
|
||||
Use **⇧⌘O → Open YouTube URL…**, the playback-actions menu, or drop a YouTube video link onto the window. Watch, short-link, Shorts, live, and embed URLs identify one video; playlists and channels are not opened. Resolution is cancellable by closing or replacing the media. Finder launches also search the usual Homebrew executable locations.
|
||||
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.
|
||||
|
||||
yt-dlp defaults to the highest available SDR resolution at or below **1080p**, falling down to a lower resolution when 1080p is unavailable, with the best available audio. FFmpeg plays the selected HTTP(S) streams or native HLS manifests directly, including separate video/audio inputs or a combined stream: no embedded browser, temporary full-video download, or muxing step. Playback, coordinated seeking, frame stepping, volume, and same-window compact mode use the existing native player. **Buffering…** freezes the playback clock without disabling pause, close, or replacement; live/non-seekable inputs disable seeking and frame stepping, and live sources show **LIVE**.
|
||||
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.
|
||||
|
||||
Seekable network playback targets **60 seconds ahead and 30 seconds behind** using retained compressed audio/video packets, with a shared **128 MiB** byte budget. Time targets are best-effort: bandwidth, bitrate, packet/keyframe layout, source availability, and byte pressure can shorten the window. Playback starts promptly and read-ahead continues in the background, including while paused. Cached seeks replay from the preceding usable keyframe without a new HTTP request; seeks outside the retained window refill normally. This is an in-memory sliding cache, not a full-video download or persistent disk cache.
|
||||
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.
|
||||
|
||||
Recorded direct HTTP inputs use FFmpeg 9's **10 MiB bounded range requests**, continuing sequentially and reusing persistent connections when the server permits. This avoids the open-ended-request throttling seen on some YouTube CDNs; URL extraction alone does not apply yt-dlp's download chunking. Native [`request_size` and `multiple_requests`](https://github.com/FFmpeg/FFmpeg/blob/n9.0.1/doc/protocols.texi#L462-L485) handle continuation without a separate downloader. Large `short_seek_size` read-ahead stays disabled: an uncached seek must escape a stalled response, not wait to drain it. Required headers, fifteen-second I/O deadlines, cache limits, and cancellation remain intact. Live sources keep their existing transport behavior. This removes avoidable request-level throttling, not bandwidth/CDN limits: a minute is a target, not a startup requirement or guaranteed reserve.
|
||||
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.
|
||||
|
||||
**gear → Show Buffer Info** enables the optional bottom readout in both player modes; it is **disabled by default**, configurable with `[ui].buffer_info`, and its menu toggle is session-only. When enabled, actual retained times appear as **Buffer: −30s +60s** (compact mode omits the prefix). Negative time is reusable history, positive time is playable reserve ahead—not desired targets or percentages downloaded. The normal seek bar shades the retained interval on both sides of the playhead regardless of this preference; its tooltip and VoiceOver retain fractional seconds and the buffered-through timestamp. **Buffer: —** means no estimate is available, including during pending seeks or for live/unknown-duration media; local playback displays no network indicator. Hiding the readout does not affect caching or automatic quality. Quality/source changes and close discard the old cache; cached seeks preserve it. Required audio and video constrain the reported range.
|
||||
### Miruro providers
|
||||
|
||||
While playing YouTube, choose **gear → Quality** in either normal or compact mode. The dropdown lists the video's supported SDR resolutions, including 1440p/4K when available, and shows the current resolution. **Default (up to 1080p)** restores the cap. Switching resolves fresh stream URLs and briefly pauses while reopening, preserving the position for seekable recorded videos, play/pause state, and the same window, size, and mode. An extraction failure leaves the previous stream selected and restores its playback state. Live/non-seekable streams reopen without position restoration. New video opens use the default again; manual quality choices are not persisted.
|
||||
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.
|
||||
|
||||
**gear → Quality → Automatic Quality (up to 1080p)** is **disabled by default**, configurable with `[playback].automatic_quality`, and its menu toggle is session-only. Choosing any manual/default resolution, even the current one, disables it for that session. Auto moves down one available rung after reserve stays below four seconds for six seconds or playback stalls for three seconds. It moves up one rung after at least twenty seconds of reserve remains healthy for sixty seconds, never above 1080p; live/unknown reserve uses 120 seconds of stable playback instead. Switches have a thirty-second cooldown. Paused/opening/seeking media and the final fifteen seconds of a recorded video do not trigger changes. Failed automatic extraction leaves the previous playback running and backs off for 120 seconds. The old stream continues while automatic URLs resolve; applying a new quality still briefly reopens the stream, preserving the latest seekable position and play state rather than providing a seamless segment-level switch.
|
||||
Optional startup preferences:
|
||||
|
||||
The app ignores yt-dlp configuration and plugins, does not read browser cookies, and does not update or fetch extractor components automatically. Private, age-restricted, region-blocked, or sign-in-required videos can fail. If YouTube extraction changes, run `brew upgrade yt-dlp deno`; local playback needs neither dependency.
|
||||
|
||||
## Resume playback
|
||||
|
||||
Reopening a local video or YouTube video automatically resumes its saved position, including after quitting and relaunching the app. Local files use their resolved file paths; YouTube uses the canonical video webpage URL, so short links and newly extracted media URLs share progress. The app does not automatically reopen the last video on launch.
|
||||
|
||||
Progress is stored locally in UserDefaults, checkpointed every five seconds of playback progress, and saved on pause, close, replacement, and normal quit. Only authoritative, acknowledged positions are saved—not an unfinished seek or slider drag. Finished videos clear their bookmark and reopen from the beginning; a bookmark beyond the current video's duration also restarts at the beginning. Failed opens preserve existing bookmarks. Live/non-seekable inputs and audio-only media do not record progress.
|
||||
|
||||
Each video's bookmark has its own UserDefaults key, so separate player processes cannot erase unrelated bookmarks by rewriting a cached history dictionary. Existing bookmarks migrate automatically from the old dictionary. If two instances play the same video, the most recent checkpoint wins; progress is shared, not a separate bookmark per window.
|
||||
|
||||
## Playback progress reporting
|
||||
|
||||
Opt in per player session with **`--progress-file PATH`**, which requires **`--new-instance`**. The option may follow `--url URL`; relative report paths resolve against the invoking shell's directory before being forwarded to the GUI. Reporting is disabled without the option and is not a config.toml setting.
|
||||
|
||||
Create a unique private directory and supply a **nonexistent** file:
|
||||
|
||||
```sh
|
||||
session=$(mktemp -d /tmp/terebi-progress.XXXXXX)
|
||||
terebi --new-instance --url 'https://www.youtube.com/watch?v=jNQXAC9IVRw' \
|
||||
--progress-file "$session/progress.jsonl"
|
||||
```toml
|
||||
[miruro]
|
||||
video_provider = ""
|
||||
caption_provider = ""
|
||||
```
|
||||
|
||||
The shared C++ backend owns reporting inside the GUI process, independent of the launcher and UserDefaults. It exclusively creates a regular file with owner-only read/write permissions (`0600`), rejecting existing destinations and symlinks. Parent directories must already exist; the caller owns directory/file cleanup. Concurrent players cannot claim the same destination.
|
||||
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.
|
||||
|
||||
Each complete line is a version-1 JSON record:
|
||||
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.
|
||||
|
||||
```json
|
||||
{"version":1,"event":"progress","media_url":"https://www.youtube.com/watch?v=jNQXAC9IVRw","position_seconds":123.5,"duration_seconds":200.0,"completion_eligible":true}
|
||||
```
|
||||
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.
|
||||
|
||||
`event` is `progress`, `ended`, or `closed`. Identity is the canonical YouTube webpage URL, never the extracted stream URL. Position is finite, nonnegative native playback state; duration is positive and finite or `null`. Eligibility is false for live/non-seekable inputs, unknown duration, and audio-only media.
|
||||
## Limitations
|
||||
|
||||
The backend reports initial ready state after acknowledging restoration, approximately every five seconds while playing, promptly at the first eligible 95% crossing, on acknowledged pause/seek/frame-step transitions, and at native end, replacement, close, and normal quit. Pending seeks and slider previews are not actual positions. Quality reopenings preserve identity and do not produce `closed`; local-media replacement closes the outgoing YouTube identity and produces no local-media records. Failed or cancelled opens never report the requested video's progress.
|
||||
- SDR output only; HDR is not supported.
|
||||
- No embedded captions, manual audio-track selection, rotation or deinterlacing. External WebVTT subtitles are supported for resolved streams and YouTube videos.
|
||||
- yt-dlp support does not guarantee playback: extraction must yield HTTP(S)/HLS media without download-only fragment assembly. Audio-only results with explicit codec metadata are unsupported. Private, age-restricted, region-blocked or sign-in-required videos may fail; browser cookies are not used. Rebuild and reinstall for bundled extractor updates.
|
||||
- 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.
|
||||
- Audio-device changes during playback are not handled.
|
||||
|
||||
Writes are serialized on a separate thread; normal shutdown drains complete records. Write failure disables reporting and surfaces a separate warning without stopping playback. Consumers must tolerate duplicates, incomplete trailing lines, and abrupt termination; a `closed` record is not guaranteed.
|
||||
## Development
|
||||
|
||||
Terebi reports facts, not watched status. A consumer such as Tubular decides completion using `completion_eligible && duration_seconds > 0 && position_seconds / duration_seconds >= 0.95`, for the matching video identity. This is a **position threshold**, not viewing coverage: seeking near the end counts. Window closure or successful launcher exit alone does not.
|
||||
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.
|
||||
|
||||
## Playback UI
|
||||
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.
|
||||
|
||||
The layout follows [IINA's video-first window](https://iina.io/img/screenshot.png) and the supplied screenshots: a 32-point frosted titlebar with native traffic lights and file icon, plus a dark frosted 440 × 68-point floating controller. AppKit materials blur the video beneath the chrome; muted icons, an orange volume track, a narrow seek thumb, and zero-padded time labels follow the reference. Shared vertical padding and an optical offset for symbol/slider insets balance the controller's visible top and bottom margins in both play and pause states. The controller starts centered with a four-percent bottom inset and keeps its relative position when the window changes size.
|
||||
`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.
|
||||
|
||||
Opening video fits the window to its display aspect ratio, including non-square pixels, and constrains subsequent window resizing. Fullscreen retains the normal letterboxed renderer; audio-only playback and close release the aspect constraint. Extreme ratios that cannot fit both the screen and usable controls keep a freely resizable letterboxed window.
|
||||
`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.
|
||||
|
||||
**Compact mode** uses the same window, Metal view, and playback session—not a second window or macOS system PiP. Use the PiP-shaped button in the normal playback controls, **⌃⌘P**, or **Playback Actions → Enter Compact Mode**. The compact-mode button replaces the fullscreen button; fullscreen remains available through its menu, keyboard shortcuts, and double-clicking normal-mode video. Compact mode starts at the current screen's top-right corner with a 16-point inset and a preferred width of 320 points, preserving display aspect where usable screen bounds and the 160 × 90-point minimum allow; extreme ratios retain letterboxing. Compact mode floats above ordinary windows, joins all Spaces, and is configured to join eligible other-app fullscreen spaces and Stage Manager sets. Small hover controls provide play/pause, restore, and Playback Actions; keyboard playback controls remain available.
|
||||
|
||||
Drag the compact video to move the window; on release, each axis snaps independently when within 24 points of an inset usable-screen edge. **Snap to Screen Edges** is enabled by default, configurable with `[ui].compact_snap_to_edges`, and its toggle is session-only. Disabling snapping permits free placement. Exiting compact mode restores the previous normal geometry and window behavior, including keep-on-top; entry from fullscreen waits for the native exit animation, and restoring that session returns to fullscreen. Double-clicking compact video also restores the previous mode. Video replacement updates the compact aspect; audio-only replacement, playback errors, and closing the file leave compact mode. Closing the window still quits the app.
|
||||
## Acknowledgements
|
||||
|
||||
Default shortcuts and their explicit action names are listed in **Configuration**. Bindings apply only to the playback window and do not intercept text editing, file/URL dialogs, plain Tab navigation, or VoiceOver's Control+Option commands. Configured modifier combinations work in playback. Holding a playback/window/settings key performs it once; seek, volume, and frame-step keys may repeat. Player menus show the first configured chord and use the same explicit actions rather than retaining hidden fixed player shortcuts; all configured chords work from playback. Return/Space navigation in an open menu activates its selected action.
|
||||
|
||||
macOS media keys and Now Playing controls use native MediaPlayer commands, including while the window is unfocused: play/pause toggles playback, and previous/next seeks backward/forward five seconds instead of switching tracks. System skip controls prefer five-second intervals. Live/non-seekable inputs disable seeking while retaining play/pause. Now Playing reports the current title, duration, elapsed time, and playback state; opening/replacing, closing, failed playback, and quitting release unavailable controls. No Accessibility or Input Monitoring permission is required. macOS chooses which active media app receives the keys.
|
||||
|
||||
Initial window setup and returning from the file/YouTube dialogs leave keyboard focus on playback rather than automatically highlighting the titlebar pin. Tab navigation and native focus indicators remain available when choosing controls explicitly.
|
||||
|
||||
Frame stepping uses decoded presentation timestamps, not an average-FPS time offset, including variable-frame-rate video and reordered B-frames. Steps start from the displayed frame, stop at the first/last frame, and keep the timeline paused. Consecutive same-direction repeats share one counted queue entry without skipping frames or reordering seeks/direction changes. The last presented image remains visible during decoder preroll and queued steps; the progress thumb follows each authoritative stepped position. Opening/closing media still clears the old image. Audio-only files disable stepping; normal playback resumes with audio synchronized to the selected frame.
|
||||
|
||||
- Existing shortcuts remain: **⌘O** opens files, **←/→** seeks five seconds, **↑/↓** adjusts volume by five percent, **⌃⌘F** toggles fullscreen, and **Esc** leaves fullscreen. Double-clicking normal-mode video also toggles fullscreen.
|
||||
- The timeline slider seeks on release and holds the requested position until queued seeking finishes. Both slider tracks span the native thumb-center travel range; the orange fill ends at the current volume thumb center. The volume thumb is opaque light gray.
|
||||
- **Single-click normal-mode video:** hide controls. Mouse movement reveals them; they fade after 2.5 seconds, including while paused. Hovering the normal titlebar or either controller, dragging, seeking, an inactive normal-mode window, errors, or VoiceOver keep them visible. Compact controls can fade while another app is active; hovering its video reveals them without hiding the desktop cursor.
|
||||
- Click the right-hand time label to switch between total and remaining time. The titlebar pin keeps the window on top.
|
||||
|
||||
Only implemented actions have controls; there are no placeholder playlist, subtitle, or settings buttons. Layout and interaction references: IINA v1.5.0's [floating controller](https://github.com/iina/iina/blob/v1.5.0/iina/OSCFloatingView.swift), [preferences](https://github.com/iina/iina/blob/v1.5.0/iina/Preference.swift), and [titlebar](https://github.com/iina/iina/blob/v1.5.0/iina/Titlebar.swift).
|
||||
|
||||
## Where to change things
|
||||
|
||||
Playback terms are defined in [CONTEXT.md](CONTEXT.md).
|
||||
|
||||
- `Sources/Core/player.h`: the C interface and authoritative state snapshot. Playback commands enqueue work; poll state for completion. `player_open` accepts local files; `player_open_network` copies selected HTTP(S) URLs, per-input headers, duration, live status, canonical `media_url`, and `initial_position`. Initial network restoration completes before reporting ready state. `player_set_progress_file` initializes opt-in reporting before opening media; its errors and `player_get_progress_error` are separate from playback errors. `player_shutdown` stops the worker and drains reporting, returning final reporting errors before handle destruction. `can_seek` reflects both selected inputs; `buffering` reports a stalled network clock. `buffered_start`/`buffered_end` report a conservative retained/playable interval in normalized seconds, constrained by required tracks; unavailable/local/pending ranges are `0, 0`, the start may include reusable history before `position`, and the end is no later than `duration`. `seek_pending` is set synchronously on acceptance and clears when queued seeks/steps and both network input seeks finish. `player_step_frame` accepts only `-1` or `+1`, requires ready seekable video, and publishes the selected frame's timestamp as `position`. Volume set/get is synchronous and lock-free. Callers own the opaque handle and must stop using it before destruction.
|
||||
- `Sources/Core/player.cpp`: demux/decode worker for local files and decode/timeline worker for network sources, bounded decoded buffers, authored timestamps, audio-master synchronization, paused previews, exact frame stepping, independent decoder/resampler drain, seeking, errors, and shutdown. Seeks stop output and flush decoder/output state; network inputs replay retained packets or seek/refill on cache misses.
|
||||
- `Sources/Core/progress_reporter.hpp` and `.cpp`: shared JSONL schema, canonical identity, acknowledged snapshots, eligibility, clock-injected cadence/threshold detection, lifecycle records, and asynchronous file writer. File creation/write uses POSIX facilities; reporting policy is independent of AppKit/Swift.
|
||||
- `Sources/Core/network_input.hpp`: network-only demux readers with a shared compressed-byte budget, playback-anchored read-ahead/history, keyframe/audio-preroll seek indices, conservative reordered-video timestamp frontiers, cached replay, asynchronous miss seeks, fifteen-second I/O deadlines, and interruption on miss/replacement/close/destruction. Cache hits leave outstanding fetch-frontier I/O intact. Remote input protocols exclude local-file access through manifests.
|
||||
- `Sources/Core/output.hpp` and `Sources/macOS/Output.mm`: native output contract, allocation-free/nonblocking Core Audio callback and bounded audio ring, latency-aware presentation clock, retained frame mailbox, and Metal YUV conversion. The view can outlive the player safely.
|
||||
- `Sources/App/TerebiApp.swift`: MainActor `PlayerModel` retains the control interface. Private `PlaybackTransition` owns extraction lifetime, open/seek acknowledgement, playback intent, local restoration, and checkpoint eligibility. `MediaSource`/`MediaChange` feed one opening path for files, YouTube, and quality changes. Native observations checkpoint before consuming local restoration; network restoration and JSONL reporting remain in C++. Panels, controls, visibility, video sizing, and window integration remain presentation.
|
||||
- `Sources/App/PlayerActions.swift`: native event-to-chord adaptation, editing/accessibility eligibility, explicit context-menu dispatch, and scene-focused SwiftUI commands sharing the model's action bindings.
|
||||
- `Sources/App/AutomaticQuality.swift`: clock-injected quality decisions using reserve/stall hysteresis, stepwise resolution choices, cooldown and extraction-failure backoff. Ordinary state revisions do not reset health; source/seek/pause transitions do.
|
||||
- `Sources/App/PlaybackProgress.swift`: local per-video position persistence and canonical file identities, using injectable UserDefaults. The player model checkpoints authoritative positions, supplies saved YouTube positions as backend open intent, and queues local restoration against the ready source.
|
||||
- `Sources/App/SystemPlaybackControls.swift`: native remote-command registration, main-thread dispatch, Now Playing publication, seekability gating, and target cleanup.
|
||||
- `Sources/App/CompactWindow.swift`: same-window compact geometry, saved normal window state, asynchronous fullscreen transitions and failure recovery, all-Spaces/floating behavior, and magnetic edge snapping with a session-only toggle.
|
||||
- `Sources/App/YouTubeResolver.swift`: YouTube URL and metadata validation, external yt-dlp/runtime discovery, capped-default or explicit-resolution SDR selection, playable resolution discovery and headers, bounded nonblocking process output, sanitized extraction errors, and request-local cancellation.
|
||||
- `Sources/CLI/PlayerCommand.swift` and `Sources/CLI/TerebiCLI.swift`: command-line validation/help and native application discovery/launching. Fresh instances receive media/config/reporting startup arguments in one launch; default reuse delivers the selected URL to an existing app. The app owns Apple events to prevent SwiftUI's default external-document routing from replacing its singleton window.
|
||||
- `Sources/Launcher/FFmpegRuntime.swift` and `Sources/App/Bootstrap.swift`: shared native dependency preflight and the app's FFmpeg-independent entrypoint. The bootstrap loads the player library and calls its exported `terebi_app_main` entry point without replacing the executable, consuming initial launch events, or losing macOS app identity. `Contents/Resources/ffmpeg-libraries.txt` records the library's actual linked FFmpeg ABI paths.
|
||||
- `Sources/Configuration/PlayerConfiguration.swift`: built-in settings, nested behavior/UI/key schema, XDG path resolution, read-only strict TOML loading, and startup bypass. `PlayerAction.swift` defines explicit supported actions/inverse pairs; `KeyBindings.swift` parses chords and validates/indexes the complete binding map. `Package.swift`/`Package.resolved` pin the statically linked decoder.
|
||||
- `Makefile`: compiler/linker commands, SwiftPM configuration-library build, dependency preflight, generated runtime manifest, developer/release bundles, installation, and checks. No generated Xcode project.
|
||||
|
||||
## Runnable check
|
||||
|
||||
```sh
|
||||
make check
|
||||
```
|
||||
|
||||
Generates short fixtures and exercises the real playback interface and native outputs: audio/video, video-only, audio-only, non-square-pixel display aspect, pause, paused/playing and queued seek acknowledgement, exact bidirectional VFR/B-frame stepping across keyframes, repeated-step bursts beyond the ordinary command capacity, mixed seek/direction ordering, replacement media superseding a long pending run, first/last-frame boundaries, stepping pause/resume, completion, replay, close, HDR rejection, failed-open recovery, and destruction while playing. It initializes the actual Metal shaders and uses an offscreen native window. It requires a working audio device and graphical macOS session, and plays short sine-wave audio. It is a runtime smoke check, not a mocked clock/device test.
|
||||
|
||||
`make check-audio` runs just the audio-quality check. A check-only hook captures actual Core Audio callback PCM into preallocated storage and compares every sample against FFmpeg's reference output for PCM/AAC, 44.1/48 kHz, and coarse container timestamps. It verifies that repeated forward/backward stepping stays silent and that resumed audio matches the exact selected-frame PCM suffix. It also checks fractional gain, silent muted startup, smooth live volume changes, and unchanged playback completion. A separate gapped fixture verifies that real timestamp gaps remain silent. Release builds contain no capture hook.
|
||||
|
||||
`make check-window` runs the compact-mode check, also included in `make check`. It exercises magnetic thresholds/corners, negative-origin screens, portrait/extreme aspect bounds, configured/session-only snapping, actual hidden-window entry/resizing/snapping/restoration, view-host identity, keyboard eligibility, and a replay of AppKit's fullscreen-failure lifecycle. It requires a graphical macOS session but does not display windows or play audio. Visible fullscreen transitions, cross-Space/Stage Manager behavior, and hover/drag usability still require a check in the app.
|
||||
|
||||
`make check-config` checks isolated XDG/fallback paths, missing-file defaults without creation, partial/quoted/commented nested TOML, invalid syntax/types/keys/UTF-8, shortcut replacement/unbinding, chord normalization, inverse-pair sharing and unrelated conflicts, invalid action/quality names, duplicate keys, read errors, explicit path delivery, `--no-config` bypass, and unchanged file bytes/session reset. It needs neither FFmpeg nor a graphical session and never reads the user's configuration.
|
||||
|
||||
`make check-youtube` checks URL/metadata/header trust boundaries, resolution choices and the default cap, and real subprocess output, bounded pipe draining, cancellation, and request isolation using isolated extractor fixtures. If yt-dlp is installed, it also runs the actual selector against offline metadata to verify 1080p priority over extractor preferences, combined-stream selection, lower-resolution fallback, and explicit 4K. It requires neither Deno nor external network access; the actual-selector portion is skipped when yt-dlp is absent.
|
||||
|
||||
`make check-network` uses a range-capable loopback HTTP server and actual native playback. It checks combined/separate streams, identical stream indices across inputs, selected headers, coordinated seeks, exact VFR/B-frame stepping, asymmetric EOF and audio-tail seeks, recorded native HLS seeks, live/non-range seek rejection, buffering before the entire file arrives, permanently stalled primary/secondary reads, cancellation/replacement/destruction, HTTP failures, and recovery. A CDN fixture withholds oversized/open-ended responses: combined and split playback must build a minute of reserve using bounded continuation, retain required headers, seek successfully, and consume the final partial range to EOF. The combined case also verifies one persistent connection across sequential responses; split inputs exercise servers that close connections. Retained-range checks cover a minute of read-ahead, a thirty-second rewind window, eviction, cached bidirectional seeks without new HTTP requests while a future response stays gated, cache-miss refill, FFprobe-derived supplied-packet timestamp bounds/B-frame dependencies, required-track constraints, gate-release refill, shorter-track EOF tails, and clearing on close/error/local replacement. It is included in `make check`.
|
||||
|
||||
`make check-quality` runs deterministic policy checks with explicit monotonic clock values: downward/upward hysteresis, sparse available resolutions, the 1080p cap, cooldown, pause/seek/open/local/default-off suppression, recorded EOF, live/unknown reserve, frozen progress, and failure backoff. `make check-adaptive` exercises the actual app model with gated loopback 1080p/720p media and offline yt-dlp selection: default-off behavior, a sustained-stall downshift, healthy upshift, position preservation, current/default manual overrides, and session-only opt-in/configuration restoration. The quality clock is injected; tests use isolated playback history and muted playback. Both targets are included in `make check`; the native adaptive portion is skipped if yt-dlp is absent. No external network is required.
|
||||
|
||||
Adaptive transition checks also gate a real extraction, cancel it by pausing, and verify that late completion cannot reopen quality or resume paused playback. Failed manual quality changes preserve the original media, position, and prior playing/paused state.
|
||||
|
||||
`make check-progress` verifies persisted fractional positions, independent local/YouTube identities, symlink aliases, completion clearing, invalid-position rejection, isolated preference domains, independently initialized writers preserving each other's videos, and migration of existing bookmarks. It is included in `make check` and does not touch real playback history.
|
||||
|
||||
`make check-reporting` decodes real JSONL files with an injected clock: initial/period boundaries, pending versus acknowledged seeks, threshold/pause/end/close facts, quality/replacement identity, nullable duration and eligibility, exclusive private creation, existing/symlink/concurrent rejection, and isolated OS write failure. `make check-network` also exercises the real backend's acknowledged initial restoration, audio-only/live eligibility, failed/cancelled opens, final drain, and continued playback/seek/replacement after a failed writer. Both are included in `make check`.
|
||||
|
||||
`make check-cli` checks relative/escaped paths, option-like filenames, YouTube identity, default reuse versus explicit `--new-instance`, `--no-config` alone and combined in either order, independent empty/local/YouTube launches, help without launch, argument cardinality, unknown/duplicate options, missing files/directories, non-local file URLs, and unsupported stream URLs. It is included in `make check`.
|
||||
|
||||
`make check-runtime` exercises missing/invalid dependency metadata, unavailable ABI libraries, existing but unloadable dylibs, omitted library families, and successful native library loading using isolated app metadata. It is included in `make check`; no installed FFmpeg files are modified.
|
||||
|
||||
`make check-media` exercises actual playback through the app model, idempotent play/pause, immediate and rapid opposite playback intents, held-key suppression, paused seeking and start-boundary clamping, non-seekable state transitions, immediate hiding of a stale buffered snapshot on accepted close, and Now Playing cleanup on close/error/shutdown. It also checks default-off/configured settings, session-only overrides, and retained-range independence from the display preference. It is included in `make check` and uses isolated playback history with muted audio. Native MediaPlayer command dispatch was also smoke-checked; physical media-key routing and macOS's choice among competing media apps still need a keyboard check in the running app.
|
||||
|
||||
Playback transition checks wait for native acknowledgement of rapid opposite intents, restore local bookmarks before autoplay, persist/resume the acknowledged position across close/reopen, and discard duration-exceeded bookmarks without preventing playback.
|
||||
|
||||
`make check-startup` launches an isolated app bundle through LaunchServices using the production bootstrap and a check-only player library with actual native playback. It verifies the registered app PID/bundle identity, then runs the real CLI against the production startup/SwiftUI scene/delegate: missing-config defaults without creation, nested settings/key overrides, actual plain/modified shortcuts, rapid opposite intents and held-key suppression, same-window compact entry/restoration, removed old bindings, real text insertion without playback interception, native menu equivalents and Play/Pause selections, malformed-file exit 78, cold/warm `--no-config`, unchanged file bytes and existing session settings, cold/default reuse, independent empty/local/YouTube launches, exactly one player window per PID, targeted media, retained original playback, help without launch, and warm default replacement. YouTube uses loopback media and actual offline yt-dlp selection; that portion requires yt-dlp and a supported JS runtime but no external network. It is included in `make check`; windows are transparent and ignore mouse input, audio muted, and configuration/bundle/history isolated. Native menus receive their normal lazy-population/validation callbacks before direct selection. Finder/Space behavior, visible controls/menu presentation and Vorssaint discovery still need a check in the app.
|
||||
|
||||
Startup checks also exercise actual CLI-to-GUI reporting after the launcher exits: two independent private streams, canonical identities, acknowledged resume/seek/pause, quality reopenings, replacement/end/close/quit, failed resolution, local-media exclusions, and existing/dangling-destination preflight errors. Report readers tolerate duplicates and partial trailing lines; watched facts are derived from eligible native positions, not launcher success.
|
||||
|
||||
Continuous audio uses resampled sample counts for its output timeline; packet timestamp quantization and rounded resampler delay must not insert silence or drop samples between blocks.
|
||||
|
||||
Volume persists across open, seek, and close. Live gain changes ramp over five milliseconds without callback allocation or locking; a stopped output adopts the requested gain before playback starts, so muted startup cannot leak audio.
|
||||
|
||||
Visual correctness, perceived A/V synchronization, and control usability still need a check in the visible app.
|
||||
|
||||
## Current limits
|
||||
|
||||
Local regular files and YouTube video URLs, selectable YouTube resolutions, automatically selected audio/video tracks within the chosen quality, software decoding, and SDR output. PQ/HLG HDR is rejected rather than rendered incorrectly. No subtitles, explicit codec/audio-track selection, VideoToolbox decoding, or rotation/deinterlacing. Mid-track audio format changes report an error. The URL UI accepts YouTube videos rather than arbitrary web pages or streaming services.
|
||||
|
||||
Forward frame stepping retains the live video decoder cursor instead of seeking and re-decoding the same keyframe group for each repeat. Backward stepping still scans from an earlier keyframe; long-GOP or unindexed files can therefore step backward slowly, without blanking the image or allocating a command per key repeat. Audio is skipped while stepping and rebuilt once on resume from the verified preroll anchor; resuming after a long forward run can also require a longer preroll. Intermediate frames needed by inter-frame dependencies are decoded and discarded; frames are not cached across the entire file. Every accepted step counts. Stepping requires usable decoded presentation timestamps and reports an error rather than inventing frame times when they are absent.
|
||||
|
||||
Audio buffering is bounded to one second plus one decoded block (up to four seconds); video is bounded to 64 frames/128 MiB. Extremely interleaved files can drop far-future video frames to avoid starving audio; the ceiling and upgrade path are marked at `queueVideo()`. Device/stream latency is accounted for, but device changes during playback are not handled yet.
|
||||
|
||||
Network inputs retain compressed packets within a shared 128 MiB budget, targeting sixty seconds ahead and thirty seconds behind for seekable media. Eviction can shorten history under byte pressure, and keyframe/audio-preroll requirements affect the usable rewind window. Decoded video/audio buffers remain separately bounded. Each network reader can hold one transient demux packet while backpressured. Separate inputs retain one authored timestamp origin and one playback clock; buffering stalls that clock rather than skipping ahead. Live/non-seekable sources do not promise a rewind window or a minute of future live media. Network reads/seeks have fifteen-second deadlines; there is no automatic reconnection or signed-URL refresh.
|
||||
Uses [SponsorBlock](https://sponsor.ajay.app/) data under the [CC BY-NC-SA 4.0 license](https://creativecommons.org/licenses/by-nc-sa/4.0/). Distribution involving this data must account for its **NonCommercial** and **ShareAlike** conditions; obtain separate permission from the relevant rights holders if your intended use needs it. This data credit does not relicense Terebi's application source.
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,42 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 320" width="1024" height="1024">
|
||||
<defs>
|
||||
<linearGradient id="shell" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#49352b"/>
|
||||
<stop offset="48%" stop-color="#2c211c"/>
|
||||
<stop offset="100%" stop-color="#1f1714"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="ember" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#f2943b"/>
|
||||
<stop offset="100%" stop-color="#c2452e"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="rim" x1="0%" y1="0%" x2="75%" y2="100%">
|
||||
<stop offset="0%" stop-color="#ffe3b5"/>
|
||||
<stop offset="40%" stop-color="#f2943b"/>
|
||||
<stop offset="100%" stop-color="#c2452e"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="glass" x1="0%" y1="0%" x2="80%" y2="100%">
|
||||
<stop offset="0%" stop-color="#68432e"/>
|
||||
<stop offset="50%" stop-color="#38241b"/>
|
||||
<stop offset="100%" stop-color="#1f1714"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="play" x1="0%" y1="0%" x2="60%" y2="100%">
|
||||
<stop offset="0%" stop-color="#ffe3b5"/>
|
||||
<stop offset="100%" stop-color="#f2943b"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="shine" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stop-color="#ffe3b5" stop-opacity=".22"/>
|
||||
<stop offset="100%" stop-color="#ffe3b5" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="24" y="29" width="272" height="272" rx="62" fill="#1f1714" fill-opacity=".22"/>
|
||||
<rect x="24" y="24" width="272" height="272" rx="62" fill="url(#shell)"/>
|
||||
<rect x="25" y="25" width="270" height="270" rx="61" fill="none" stroke="#d9c7a6" stroke-opacity=".25" stroke-width="2"/>
|
||||
<rect x="63" y="77" width="194" height="160" rx="31" transform="rotate(-12 160 157)" fill="url(#ember)"/>
|
||||
<rect x="64" y="78" width="192" height="158" rx="30" transform="rotate(-12 160 157)" fill="none" stroke="#ffe3b5" stroke-opacity=".35" stroke-width="2"/>
|
||||
<rect x="46" y="98" width="228" height="170" rx="37" fill="#1f1714" fill-opacity=".5"/>
|
||||
<rect x="46" y="90" width="228" height="170" rx="37" fill="url(#rim)"/>
|
||||
<rect x="54" y="98" width="212" height="154" rx="29" fill="url(#glass)"/>
|
||||
<path d="M83 101H237Q263 101 263 127V143Q165 125 57 173V127Q57 101 83 101Z" fill="url(#shine)"/>
|
||||
<path d="M136 137Q136 129 144 134L207 175Q215 180 207 185L144 226Q136 231 136 223Z" fill="#1f1714" fill-opacity=".65"/>
|
||||
<path d="M136 130Q136 122 144 127L207 168Q215 173 207 178L144 219Q136 224 136 216Z" fill="url(#play)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
+10
-1
@@ -4,7 +4,9 @@
|
||||
<dict>
|
||||
<key>CFBundleExecutable</key><string>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>CFBundleIconFile</key><string>AppIcon</string>
|
||||
<key>CFBundlePackageType</key><string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key><string>0.1</string>
|
||||
<key>LSMinimumSystemVersion</key><string>15.0</string>
|
||||
@@ -18,7 +20,14 @@
|
||||
<array><string>public.movie</string><string>public.audio</string></array>
|
||||
</dict>
|
||||
</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>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>NSBonjourServices</key><array><string>_googlecast._tcp</string></array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
Executable
+137
@@ -0,0 +1,137 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Stage private runtime dependencies, relocate Mach-O links, and sign the app."""
|
||||
import argparse
|
||||
from collections import deque
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
|
||||
SYSTEM_PATHS = ("/usr/lib/", "/System/Library/", "/Library/Apple/System/")
|
||||
FFMPEG_LIBRARY = re.compile(r"lib(?:avformat|avcodec|avutil|swresample|swscale)(?:\.\d+)+\.dylib\Z")
|
||||
|
||||
|
||||
def run(*arguments):
|
||||
return subprocess.run(arguments, check=True, text=True, stdout=subprocess.PIPE).stdout
|
||||
|
||||
|
||||
def dependencies(path):
|
||||
lines = run("otool", "-arch", platform.machine(), "-L", str(path)).splitlines()[1:]
|
||||
if path.suffix == ".dylib":
|
||||
lines = lines[1:] # LC_ID_DYLIB is not a dependency.
|
||||
return [line.strip().split(" (compatibility version ", 1)[0] for line in lines]
|
||||
|
||||
|
||||
def bundle(app, root, identity):
|
||||
frameworks = app / "Contents/Frameworks"
|
||||
executables = app / "Contents/MacOS"
|
||||
resources = app / "Contents/Resources"
|
||||
player = frameworks / "libterebi-player.dylib"
|
||||
search_paths = [*(root / name / "lib" for name in ("ffmpeg", "dav1d", "xz")), frameworks]
|
||||
for name in ("yt-dlp", "deno"):
|
||||
shutil.copy2(root / "bin" / name, executables / name)
|
||||
for name in ("licenses", "sources"):
|
||||
destination = resources / name
|
||||
if destination.exists():
|
||||
shutil.rmtree(destination)
|
||||
shutil.copytree(root / name, destination)
|
||||
|
||||
def resolve(reference, owner):
|
||||
if reference.startswith("@loader_path/"):
|
||||
candidates = [owner.parent / reference[len("@loader_path/"):]]
|
||||
elif reference.startswith("@executable_path/"):
|
||||
candidates = [executables / reference[len("@executable_path/"):]]
|
||||
elif reference.startswith("@rpath/"):
|
||||
candidates = [directory / reference[len("@rpath/"):] for directory in search_paths]
|
||||
elif reference.startswith("/"):
|
||||
candidates = [Path(reference)]
|
||||
else:
|
||||
raise RuntimeError(f"Unsupported library reference {reference!r} in {owner}")
|
||||
for candidate in candidates:
|
||||
if candidate.is_file():
|
||||
source = candidate.resolve()
|
||||
if not any(source.is_relative_to(directory.resolve()) for directory in search_paths):
|
||||
raise RuntimeError(f"Unpinned external library {source} required by {owner}")
|
||||
return source
|
||||
raise RuntimeError(f"Cannot resolve {reference!r} required by {owner}")
|
||||
|
||||
modules = [player, *(executables / name for name in ("terebi", "terebi-cli", "yt-dlp", "deno"))]
|
||||
pending = deque(modules)
|
||||
staged = {player.resolve(): player}
|
||||
links = {}
|
||||
claimed = {player.name: player.resolve()}
|
||||
ffmpeg = []
|
||||
while pending:
|
||||
owner = pending.popleft()
|
||||
owner_links = []
|
||||
for reference in dependencies(owner):
|
||||
if reference.startswith(SYSTEM_PATHS):
|
||||
continue
|
||||
source = resolve(reference, owner)
|
||||
if source not in staged:
|
||||
name = Path(reference).name
|
||||
previous = claimed.get(name)
|
||||
if previous is not None and previous != source:
|
||||
raise RuntimeError(f"Conflicting private library filename: {name}")
|
||||
claimed[name] = source
|
||||
destination = frameworks / name
|
||||
staged[source] = destination
|
||||
pending.append(source)
|
||||
destination = staged[source]
|
||||
owner_links.append((reference, destination))
|
||||
if owner == player and FFMPEG_LIBRARY.fullmatch(destination.name):
|
||||
ffmpeg.append(destination.name)
|
||||
links[owner] = owner_links
|
||||
if len(ffmpeg) != 5 or len({name.split(".")[0] for name in ffmpeg}) != 5:
|
||||
raise RuntimeError("Player must link exactly the five required FFmpeg library families")
|
||||
|
||||
# Discover the complete closure before copying or editing any source file.
|
||||
for source, destination in staged.items():
|
||||
if source != destination.resolve():
|
||||
shutil.copy2(source, destination)
|
||||
destination.chmod(0o755)
|
||||
retained = {destination.name for destination in staged.values()}
|
||||
for old in frameworks.glob("*.dylib"):
|
||||
if old.name not in retained:
|
||||
old.unlink()
|
||||
for owner, owner_links in links.items():
|
||||
destination = staged.get(owner.resolve(), owner)
|
||||
changes = []
|
||||
if destination.suffix == ".dylib":
|
||||
changes += ["-id", "@loader_path/" + destination.name]
|
||||
for reference, dependency in owner_links:
|
||||
relative = ("@loader_path/" if destination.parent == frameworks else "@executable_path/../Frameworks/") + dependency.name
|
||||
if reference != relative:
|
||||
changes += ["-change", reference, relative]
|
||||
if changes:
|
||||
run("install_name_tool", *changes, str(destination))
|
||||
resources.mkdir(parents=True, exist_ok=True)
|
||||
(resources / "ffmpeg-libraries.txt").write_text("\n".join(sorted(ffmpeg)) + "\n")
|
||||
|
||||
# Fail the build, not the user's launch, if any link still needs a build machine.
|
||||
for module in [*staged.values(), *modules[1:]]:
|
||||
for reference in dependencies(module):
|
||||
if not reference.startswith((*SYSTEM_PATHS, "@loader_path/", "@executable_path/../Frameworks/")):
|
||||
raise RuntimeError(f"Nonportable reference {reference!r} remains in {module}")
|
||||
run("codesign", "--force", "--sign", identity, str(module))
|
||||
run("codesign", "--force", "--sign", identity, str(app))
|
||||
run("codesign", "--verify", "--deep", "--strict", str(app))
|
||||
print(f"Bundled {len(staged) - 1} private libraries, yt-dlp and Deno in {app}")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--app", type=Path, required=True)
|
||||
parser.add_argument("--dependencies", type=Path, required=True)
|
||||
parser.add_argument("--signing-identity", default="-")
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
bundle(args.app.resolve(), args.dependencies.resolve(), args.signing_identity)
|
||||
except (OSError, RuntimeError, subprocess.CalledProcessError) as error:
|
||||
parser.exit(1, f"Error: could not bundle Terebi: {error}\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+308
@@ -0,0 +1,308 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Prepare pinned native macOS libraries or standalone helpers; no runtime package manager."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path, PurePosixPath
|
||||
import platform
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import zipfile
|
||||
|
||||
REPOSITORY = Path(__file__).resolve().parent.parent
|
||||
LOCK = REPOSITORY / "Dependencies.lock.json"
|
||||
|
||||
|
||||
def sha256(path):
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as source:
|
||||
for chunk in iter(lambda: source.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def download(asset, directory):
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
target = directory / asset["filename"]
|
||||
if target.is_file() and sha256(target) == asset["sha256"]:
|
||||
return target
|
||||
temporary = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(dir=directory, delete=False) as output:
|
||||
temporary = Path(output.name)
|
||||
request = urllib.request.Request(asset["url"], headers={"User-Agent": "Terebi-dependency-preparer"})
|
||||
with urllib.request.urlopen(request, timeout=120) as response:
|
||||
shutil.copyfileobj(response, output)
|
||||
actual = sha256(temporary)
|
||||
if actual != asset["sha256"]:
|
||||
raise RuntimeError(f"SHA256 mismatch for {asset['url']}: expected {asset['sha256']}, got {actual}; no unverified download installed")
|
||||
temporary.chmod(0o644)
|
||||
os.replace(temporary, target)
|
||||
return target
|
||||
finally:
|
||||
if temporary is not None:
|
||||
temporary.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def archive_path(name, destination):
|
||||
# Reject links altogether: the pinned source archives need none. This also
|
||||
# prevents a preceding archive member from redirecting subsequent writes.
|
||||
relative = PurePosixPath(name)
|
||||
if relative.is_absolute() or ".." in relative.parts or "\\" in name:
|
||||
raise RuntimeError(f"Unsafe archive member: {name!r}")
|
||||
target = destination.joinpath(*relative.parts)
|
||||
if not target.resolve().is_relative_to(destination.resolve()):
|
||||
raise RuntimeError(f"Archive member escapes extraction directory: {name!r}")
|
||||
return target
|
||||
|
||||
|
||||
def extract(archive, destination):
|
||||
destination.mkdir(parents=True, exist_ok=True)
|
||||
if zipfile.is_zipfile(archive):
|
||||
with zipfile.ZipFile(archive) as source:
|
||||
for member in source.infolist():
|
||||
target = archive_path(member.filename, destination)
|
||||
mode = member.external_attr >> 16
|
||||
if mode & 0o170000 not in (0, 0o100000, 0o040000):
|
||||
raise RuntimeError(f"Unsupported archive member: {member.filename}")
|
||||
if member.is_dir():
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
else:
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
with source.open(member) as data, target.open("wb") as output:
|
||||
shutil.copyfileobj(data, output)
|
||||
target.chmod(0o755 if mode & 0o111 else 0o644)
|
||||
else:
|
||||
with tarfile.open(archive) as source:
|
||||
for member in source:
|
||||
target = archive_path(member.name, destination)
|
||||
if member.isdir():
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
elif member.isfile():
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
with source.extractfile(member) as data, target.open("wb") as output:
|
||||
shutil.copyfileobj(data, output)
|
||||
target.chmod(0o755 if member.mode & 0o111 else 0o644)
|
||||
else:
|
||||
raise RuntimeError(f"Unsupported archive member: {member.name}")
|
||||
children = list(destination.iterdir())
|
||||
return children[0] if len(children) == 1 and children[0].is_dir() else destination
|
||||
|
||||
|
||||
def run(command, cwd=None, env=None):
|
||||
print("+ " + shlex.join(map(str, command)), flush=True)
|
||||
subprocess.run(list(map(str, command)), cwd=cwd, env=env, check=True)
|
||||
|
||||
|
||||
def native(root, arch, lock):
|
||||
for tool in ("xcrun", "make", "meson", "ninja", "pkg-config") + (("nasm",) if arch == "x86_64" else ()):
|
||||
if shutil.which(tool) is None:
|
||||
raise RuntimeError(f"Missing build prerequisite {tool}; install it before preparing native dependencies")
|
||||
sdk = subprocess.check_output(["xcrun", "--sdk", "macosx", "--show-sdk-path"], text=True).strip()
|
||||
clang = subprocess.check_output(["xcrun", "--find", "clang"], text=True).strip()
|
||||
clangxx = subprocess.check_output(["xcrun", "--find", "clang++"], text=True).strip()
|
||||
flags = shlex.join(["-arch", arch, "-isysroot", sdk, "-mmacosx-version-min=15.0"])
|
||||
environment = dict(os.environ)
|
||||
# Host packages must never leak into dependency discovery or compiler flags.
|
||||
for name in ("CPATH", "C_INCLUDE_PATH", "CPLUS_INCLUDE_PATH", "LIBRARY_PATH", "SDKROOT", "ARCHFLAGS", "CPPFLAGS", "PKG_CONFIG_PATH", "PKG_CONFIG_SYSROOT_DIR", "DYLD_LIBRARY_PATH"):
|
||||
environment.pop(name, None)
|
||||
environment.update(CC=clang, CXX=clangxx, CFLAGS=flags, CXXFLAGS=flags,
|
||||
OBJCFLAGS=flags, LDFLAGS=flags, MACOSX_DEPLOYMENT_TARGET="15.0",
|
||||
PKG_CONFIG_LIBDIR=os.pathsep.join(str(root / name / "lib/pkgconfig") for name in ("dav1d", "xz")), PKG_CONFIG_PATH="")
|
||||
work = root / "work/native"
|
||||
shutil.rmtree(work, ignore_errors=True)
|
||||
work.mkdir(parents=True)
|
||||
for component in ("dav1d", "xz", "ffmpeg"):
|
||||
shutil.rmtree(root / component, ignore_errors=True)
|
||||
sources = root / "sources/native"
|
||||
sources.mkdir(parents=True, exist_ok=True)
|
||||
licenses = root / "licenses/native"
|
||||
licenses.mkdir(parents=True, exist_ok=True)
|
||||
trees = {}
|
||||
for component in ("dav1d", "xz", "ffmpeg"):
|
||||
archive = download(lock[component]["source"], root / "downloads")
|
||||
shutil.copy2(archive, sources / archive.name)
|
||||
trees[component] = extract(archive, work / component)
|
||||
dav1d_command = ["meson", "setup", work / "dav1d-build", trees["dav1d"],
|
||||
"--prefix=" + str(root / "dav1d"), "--libdir=lib", "--buildtype=release",
|
||||
"--default-library=shared", "--wrap-mode=nodownload", "-Denable_tools=false",
|
||||
"-Denable_tests=false", "-Denable_examples=false", "-Denable_docs=false"]
|
||||
xz_command = [trees["xz"] / "configure", "--prefix=" + str(root / "xz"),
|
||||
"--enable-shared", "--disable-static", "--disable-xz", "--disable-xzdec",
|
||||
"--disable-lzmadec", "--disable-lzmainfo", "--disable-scripts", "--disable-doc", "--disable-nls"]
|
||||
ffmpeg_command = [trees["ffmpeg"] / "configure", "--prefix=" + str(root / "ffmpeg"),
|
||||
"--arch=" + arch, "--target-os=darwin", "--cc=" + clang, "--cxx=" + clangxx,
|
||||
"--host-cc=" + clang, "--host-cflags=" + flags, "--host-ldflags=" + flags,
|
||||
"--extra-cflags=" + flags + " " + shlex.quote("-I" + str(root / "xz/include")),
|
||||
"--extra-ldflags=" + flags + " " + shlex.quote("-L" + str(root / "xz/lib")),
|
||||
"--enable-shared", "--disable-static", "--disable-programs", "--disable-doc",
|
||||
"--disable-encoders", "--disable-muxers", "--disable-avdevice", "--disable-avfilter",
|
||||
"--enable-encoder=aac,h264_videotoolbox", "--enable-muxer=mpegts",
|
||||
"--disable-gpl", "--disable-nonfree", "--disable-version3", "--disable-autodetect",
|
||||
"--enable-libdav1d", "--enable-securetransport", "--enable-videotoolbox",
|
||||
"--enable-audiotoolbox", "--enable-zlib", "--enable-bzlib", "--enable-lzma"]
|
||||
configuration = {"arch": arch, "deployment_target": "15.0", "sdk": sdk,
|
||||
"environment": {key: environment[key] for key in ("CC", "CXX", "CFLAGS", "CXXFLAGS", "LDFLAGS", "MACOSX_DEPLOYMENT_TARGET", "PKG_CONFIG_LIBDIR", "PKG_CONFIG_PATH")},
|
||||
"dav1d": list(map(str, dav1d_command)), "xz": list(map(str, xz_command)),
|
||||
"ffmpeg": list(map(str, ffmpeg_command))}
|
||||
(licenses / "build-configuration.json").write_text(json.dumps(configuration, indent=2) + "\n")
|
||||
run(dav1d_command, env=environment)
|
||||
run(["ninja", "-C", work / "dav1d-build"], env=environment)
|
||||
run(["ninja", "-C", work / "dav1d-build", "install"], env=environment)
|
||||
xz_build = work / "xz-build"
|
||||
xz_build.mkdir()
|
||||
run(xz_command, cwd=xz_build, env=environment)
|
||||
run(["make", "-j", str(os.cpu_count() or 1)], cwd=xz_build, env=environment)
|
||||
run(["make", "install"], cwd=xz_build, env=environment)
|
||||
build = work / "ffmpeg-build"
|
||||
build.mkdir()
|
||||
run(ffmpeg_command, cwd=build, env=environment)
|
||||
run(["make", "-j", str(os.cpu_count() or 1)], cwd=build, env=environment)
|
||||
run(["make", "install"], cwd=build, env=environment)
|
||||
for filename in ("COPYING.LGPLv2.1", "LICENSE.md"):
|
||||
shutil.copy2(trees["ffmpeg"] / filename, licenses / ("FFmpeg-" + filename))
|
||||
shutil.copy2(trees["dav1d"] / "COPYING", licenses / "dav1d-COPYING")
|
||||
shutil.copy2(trees["xz"] / "COPYING", licenses / "liblzma-COPYING")
|
||||
for filename in ("config.h", "ffbuild/config.mak"):
|
||||
shutil.copy2(build / filename, licenses / ("FFmpeg-" + Path(filename).name))
|
||||
(licenses / "NOTICE.txt").write_text(
|
||||
"FFmpeg 9.0.1: LGPL-2.1-or-later; dav1d 1.5.4: BSD-2-Clause; liblzma 5.8.4: 0BSD/public domain.\n"
|
||||
"Exact unmodified source archives accompany this distribution under sources/native.\n"
|
||||
"Build commands and flags are in build-configuration.json; source changes: none.\n"
|
||||
"This software is based in part on the work of the Independent JPEG Group.\n"
|
||||
"FFmpeg JPEG source files were not modified. Apple system libraries are supplied by macOS.\n"
|
||||
"You may replace these shared libraries with compatible modified versions; no static linking is used.\n")
|
||||
|
||||
|
||||
def helpers(root, arch, lock):
|
||||
binaries = root / "bin"
|
||||
binaries.mkdir(parents=True, exist_ok=True)
|
||||
sources = root / "sources/helpers"
|
||||
sources.mkdir(parents=True, exist_ok=True)
|
||||
licenses = root / "licenses/helpers"
|
||||
licenses.mkdir(parents=True, exist_ok=True)
|
||||
for name in ("yt-dlp", "deno"):
|
||||
component = lock[name]
|
||||
asset = component["binary"] if name == "yt-dlp" else component["binaries"][arch]
|
||||
archive = download(asset, root / "downloads")
|
||||
with tempfile.TemporaryDirectory(dir=root) as temporary:
|
||||
stage = Path(temporary)
|
||||
executable = archive if name == "yt-dlp" else extract(archive, stage) / "deno"
|
||||
if not executable.is_file():
|
||||
raise RuntimeError(f"Pinned {name} archive does not contain its expected executable")
|
||||
target = stage / "installed-executable"
|
||||
shutil.copyfile(executable, target)
|
||||
target.chmod(0o755)
|
||||
os.replace(target, binaries / name)
|
||||
for material in component["materials"]:
|
||||
path = download(material, root / "downloads")
|
||||
shutil.copy2(path, (sources if material.get("kind") == "source" else licenses) / path.name)
|
||||
(licenses / (name + "-provenance.json")).write_text(json.dumps(component, indent=2) + "\n")
|
||||
(licenses / (name + "-NOTICE.txt")).write_text(component["notice"] + "\n")
|
||||
|
||||
|
||||
def outputs_exist(root, part, lock):
|
||||
if part == "helpers":
|
||||
return (all((root / "bin" / name).is_file() and os.access(root / "bin" / name, os.X_OK)
|
||||
and all(((root / "sources/helpers" if material.get("kind") == "source" else root / "licenses/helpers") / material["filename"]).is_file()
|
||||
for material in lock[name]["materials"])
|
||||
and (root / "licenses/helpers" / (name + "-NOTICE.txt")).is_file()
|
||||
and (root / "licenses/helpers" / (name + "-provenance.json")).is_file()
|
||||
for name in ("yt-dlp", "deno")))
|
||||
return ((root / "ffmpeg/include/libavcodec/avcodec.h").is_file()
|
||||
and all((root / "ffmpeg/lib/pkgconfig" / (name + ".pc")).is_file()
|
||||
and any((root / "ffmpeg/lib").glob(name + ".*.dylib"))
|
||||
for name in ("libavcodec", "libavformat", "libavutil", "libswscale", "libswresample"))
|
||||
and any((root / "dav1d/lib").glob("libdav1d.*.dylib"))
|
||||
and any((root / "xz/lib").glob("liblzma.*.dylib"))
|
||||
and (root / "licenses/native/build-configuration.json").is_file()
|
||||
and all((root / "sources/native" / lock[name]["source"]["filename"]).is_file()
|
||||
for name in ("ffmpeg", "dav1d", "xz")))
|
||||
|
||||
|
||||
def self_check():
|
||||
"""Offline archive-boundary check: python3 -c 'from Scripts.dependencies import self_check; self_check()'."""
|
||||
build = REPOSITORY / ".build"
|
||||
build.mkdir(exist_ok=True)
|
||||
with tempfile.TemporaryDirectory(dir=build) as temporary:
|
||||
root = Path(temporary)
|
||||
for name in ("../escape", "/absolute", "safe/../../escape", "safe\\escape"):
|
||||
try:
|
||||
archive_path(name, root)
|
||||
except RuntimeError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError(f"Unsafe path accepted: {name}")
|
||||
assert archive_path("safe/file", root) == root / "safe/file"
|
||||
archive = root / "links.tar"
|
||||
with tarfile.open(archive, "w") as output:
|
||||
link = tarfile.TarInfo("link")
|
||||
link.type = tarfile.SYMTYPE
|
||||
link.linkname = "../outside"
|
||||
output.addfile(link)
|
||||
try:
|
||||
extract(archive, root / "extracted")
|
||||
except RuntimeError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("Archive symlink accepted")
|
||||
archive = root / "traversal.zip"
|
||||
with zipfile.ZipFile(archive, "w") as output:
|
||||
output.writestr("../escape", "unsafe")
|
||||
try:
|
||||
extract(archive, root / "zip-extracted")
|
||||
except RuntimeError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("ZIP traversal accepted")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--root", required=True, type=Path)
|
||||
parser.add_argument("--arch", required=True, choices=("arm64", "x86_64"))
|
||||
parser.add_argument("--part", required=True, choices=("native", "helpers"))
|
||||
args = parser.parse_args()
|
||||
if platform.system() != "Darwin" or platform.machine() != args.arch:
|
||||
parser.error(f"Native macOS {args.arch} host required; running {platform.system()} {platform.machine()}. Cross-architecture builds are unsupported (do not run under Rosetta).")
|
||||
translated = subprocess.run(["/usr/sbin/sysctl", "-in", "sysctl.proc_translated"],
|
||||
capture_output=True, text=True, check=False)
|
||||
if translated.stdout.strip() == "1":
|
||||
parser.error("Rosetta builds are unsupported; use native arm64 Python and build tools.")
|
||||
if not args.root.is_absolute():
|
||||
parser.error("--root must be an absolute path beneath the repository's ignored .build directory")
|
||||
root = args.root.resolve()
|
||||
if not root.is_relative_to((REPOSITORY / ".build").resolve()):
|
||||
parser.error("--root must be beneath the repository's ignored .build directory")
|
||||
lock = json.loads(LOCK.read_text())
|
||||
fingerprint = hashlib.sha256(LOCK.read_bytes() + Path(__file__).read_bytes() + args.arch.encode() + args.part.encode()).hexdigest()
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
stamp = root / (args.part + ".stamp")
|
||||
if stamp.is_file() and stamp.read_text().strip() == fingerprint and outputs_exist(root, args.part, lock):
|
||||
return 0
|
||||
stamp.unlink(missing_ok=True)
|
||||
try:
|
||||
(native if args.part == "native" else helpers)(root, args.arch, lock)
|
||||
if not outputs_exist(root, args.part, lock):
|
||||
raise RuntimeError(f"{args.part} preparation completed without all required outputs")
|
||||
shutil.copy2(LOCK, root / "licenses/Dependencies.lock.json")
|
||||
shutil.copy2(Path(__file__), root / "licenses/dependencies.py")
|
||||
with tempfile.NamedTemporaryFile(mode="w", dir=root, delete=False) as output:
|
||||
output.write(fingerprint + "\n")
|
||||
temporary = Path(output.name)
|
||||
os.replace(temporary, stamp)
|
||||
except (OSError, ValueError, RuntimeError, subprocess.CalledProcessError, tarfile.TarError, zipfile.BadZipFile) as error:
|
||||
print(f"Dependency preparation failed: {error}. Fix the prerequisite/network issue and rerun the same command; no readiness stamp was published.", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -12,10 +12,10 @@ enum TerebiBootstrap {
|
||||
let payload = app.appendingPathComponent("Contents/Frameworks/libterebi-player.dylib")
|
||||
guard let library = dlopen(payload.path, RTLD_NOW | RTLD_LOCAL) else {
|
||||
let detail = dlerror().map { String(cString: $0) } ?? "The dynamic loader could not load the player."
|
||||
fail("Could not load the player: \(detail)\n\nPlayer library: \(payload.path)\nRebuild and reinstall terebi.")
|
||||
fail("Could not load the player: \(detail)\n\nPlayer library: \(payload.path)\nRebuild and reinstall Terebi.")
|
||||
}
|
||||
guard let entry = dlsym(library, "terebi_app_main") else {
|
||||
fail("The player library is missing its application entry point. Rebuild and reinstall terebi.")
|
||||
fail("The player library is missing its application entry point. Rebuild and reinstall Terebi.")
|
||||
}
|
||||
// Keep the declared app executable alive so LaunchServices retains its identity.
|
||||
typealias PlayerMain = @convention(c) () -> Void
|
||||
@@ -26,10 +26,10 @@ enum TerebiBootstrap {
|
||||
FileHandle.standardError.write(Data("terebi: \(message)\n".utf8))
|
||||
let application = NSApplication.shared
|
||||
application.setActivationPolicy(.regular)
|
||||
application.activate(ignoringOtherApps: true)
|
||||
application.activate()
|
||||
let alert = NSAlert()
|
||||
alert.alertStyle = .critical
|
||||
alert.messageText = "terebi could not start"
|
||||
alert.messageText = "Terebi could not start"
|
||||
alert.informativeText = message
|
||||
alert.addButton(withTitle: "Quit")
|
||||
alert.runModal()
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
struct CastDeviceMenu: View {
|
||||
@ObservedObject var model: PlayerModel
|
||||
@ObservedObject private var discovery: CastDiscovery
|
||||
var icon = true
|
||||
|
||||
init(model: PlayerModel, icon: Bool = true) {
|
||||
self.model = model
|
||||
self.discovery = model.castDiscovery
|
||||
self.icon = icon
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Menu {
|
||||
if let device = model.castDevice {
|
||||
Text((model.castConnecting ? "Connecting to " : "Casting to ") + device.name)
|
||||
Button(model.castConnecting ? "Cancel Casting" : "Play on This Mac") { model.stopCasting() }
|
||||
Divider()
|
||||
}
|
||||
ForEach(discovery.devices) { device in
|
||||
Button(device.name) { model.cast(to: device) }
|
||||
.disabled(!model.canCast)
|
||||
}
|
||||
if discovery.devices.isEmpty {
|
||||
Text("No Cast devices discovered")
|
||||
}
|
||||
if let error = discovery.error { Text(error) }
|
||||
Divider()
|
||||
Button("Find Cast Devices…") { discovery.start() }
|
||||
} label: {
|
||||
if icon {
|
||||
Image(systemName: "airplayvideo")
|
||||
.font(.system(size: model.isCompact ? 17 : 15))
|
||||
.frame(width: 24, height: 24)
|
||||
} else {
|
||||
Text("Cast to Device")
|
||||
}
|
||||
}
|
||||
.menuStyle(.button)
|
||||
.buttonStyle(.plain)
|
||||
.menuIndicator(.hidden)
|
||||
.fixedSize()
|
||||
.accessibilityLabel("Cast to Device")
|
||||
.help(model.castDevice.map { "Casting to " + $0.name } ?? "Cast to Device")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import Foundation
|
||||
|
||||
struct CastMediaInput: Sendable {
|
||||
let videoURL: String
|
||||
let videoHeaders: String
|
||||
let audioURL: String?
|
||||
let audioHeaders: String?
|
||||
let local: Bool
|
||||
let restricted: Bool
|
||||
let live: Bool
|
||||
let duration: Double
|
||||
let subtitles: [ResolvedSubtitle]
|
||||
}
|
||||
|
||||
struct CastMediaDescription: Sendable {
|
||||
let url: URL
|
||||
let contentType: String
|
||||
let duration: Double
|
||||
let live: Bool
|
||||
let tracks: [CastTextTrack]
|
||||
}
|
||||
|
||||
struct CastTextTrack: Sendable {
|
||||
let id: Int
|
||||
let url: URL
|
||||
let language: String
|
||||
let label: String
|
||||
}
|
||||
|
||||
private struct CastDeliveryError: LocalizedError {
|
||||
let errorDescription: String? = "Could not deliver cast media. Check the connection and media compatibility. Private details are redacted."
|
||||
}
|
||||
|
||||
// A start task retains this owner until native probing returns. Cancellation never
|
||||
// frees a handle in use; destruction and worker joins always run off the main actor.
|
||||
private final class CastMediaLifetime: @unchecked Sendable {
|
||||
let handle: OpaquePointer
|
||||
init(input: CastMediaInput) throws {
|
||||
var error = [CChar](repeating: 0, count: 512)
|
||||
let result = input.videoURL.withCString { video in
|
||||
input.videoHeaders.withCString { headers in
|
||||
(input.audioURL ?? "").withCString { audio in
|
||||
(input.audioHeaders ?? "").withCString { audioHeaders in
|
||||
var source = CastMediaSource(video_url: video, video_headers: headers,
|
||||
audio_url: audio, audio_headers: audioHeaders, local: input.local,
|
||||
restricted: input.restricted, live: input.live, duration: input.duration)
|
||||
return cast_media_create(&source, &error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
guard let result else { throw CastDeliveryError() }
|
||||
handle = result
|
||||
}
|
||||
func cancel() { cast_media_cancel(handle) }
|
||||
func start(host: String) throws -> (String, Double) {
|
||||
var url = [CChar](repeating: 0, count: 512)
|
||||
var error = [CChar](repeating: 0, count: 512)
|
||||
var duration = 0.0
|
||||
guard host.withCString({ cast_media_start(handle, $0, &url, &duration, &error) }) else {
|
||||
throw CastDeliveryError()
|
||||
}
|
||||
return (url.withUnsafeBufferPointer { String(cString: $0.baseAddress!) }, duration)
|
||||
}
|
||||
func subtitle(id: Int, data: Data) throws -> URL {
|
||||
var url = [CChar](repeating: 0, count: 512)
|
||||
let success = data.withUnsafeBytes {
|
||||
cast_media_subtitle(handle, Int32(id), $0.baseAddress, $0.count, &url)
|
||||
}
|
||||
guard success, let result = URL(string: url.withUnsafeBufferPointer { String(cString: $0.baseAddress!) }) else { throw CastDeliveryError() }
|
||||
return result
|
||||
}
|
||||
deinit {
|
||||
// Capture an explicitly Sendable owner instead of crossing an actor with a pointer.
|
||||
let cleanup = CastMediaCleanup(handle: handle)
|
||||
DispatchQueue.global(qos: .utility).async { cast_media_destroy(cleanup.handle) }
|
||||
}
|
||||
}
|
||||
private struct CastMediaCleanup: @unchecked Sendable { let handle: OpaquePointer }
|
||||
|
||||
@MainActor final class CastMediaServer {
|
||||
private var lifetime: CastMediaLifetime?
|
||||
private var operation: Task<CastMediaDescription, Error>?
|
||||
|
||||
init() {}
|
||||
|
||||
func start(input: CastMediaInput, receiverHost: String) async throws -> CastMediaDescription {
|
||||
stop()
|
||||
guard input.subtitles.count <= 32 else { throw CastDeliveryError() }
|
||||
let owner = try CastMediaLifetime(input: input)
|
||||
lifetime = owner
|
||||
let task = Task.detached(priority: .userInitiated) {
|
||||
do {
|
||||
let (text, duration) = try owner.start(host: receiverHost)
|
||||
try Task.checkCancellation()
|
||||
guard let url = URL(string: text) else { throw CastDeliveryError() }
|
||||
var tracks: [CastTextTrack] = []
|
||||
for (index, subtitle) in input.subtitles.enumerated() {
|
||||
try Task.checkCancellation()
|
||||
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() }
|
||||
_ = try WebVTTDocument.parse(text)
|
||||
let trackURL = try owner.subtitle(id: index + 1, data: data)
|
||||
tracks.append(CastTextTrack(id: index + 1, url: trackURL,
|
||||
language: subtitle.language, label: subtitle.label))
|
||||
}
|
||||
try Task.checkCancellation()
|
||||
return CastMediaDescription(url: url, contentType: "application/vnd.apple.mpegurl",
|
||||
duration: duration, live: input.live, tracks: tracks)
|
||||
} catch {
|
||||
owner.cancel()
|
||||
if Task.isCancelled { throw CancellationError() }
|
||||
throw CastDeliveryError()
|
||||
}
|
||||
}
|
||||
operation = task
|
||||
do {
|
||||
let result = try await withTaskCancellationHandler {
|
||||
try await task.value
|
||||
} onCancel: {
|
||||
owner.cancel()
|
||||
task.cancel()
|
||||
}
|
||||
guard lifetime === owner else { throw CancellationError() }
|
||||
operation = nil
|
||||
return result
|
||||
} catch {
|
||||
if lifetime === owner { stop() }
|
||||
if error is CancellationError { throw error }
|
||||
throw CastDeliveryError()
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
operation?.cancel()
|
||||
operation = nil
|
||||
lifetime?.cancel()
|
||||
lifetime = nil
|
||||
}
|
||||
|
||||
deinit {
|
||||
operation?.cancel()
|
||||
lifetime?.cancel()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,913 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
@preconcurrency import Network
|
||||
import Security
|
||||
|
||||
struct CastDevice: Identifiable, Equatable, Sendable {
|
||||
let id: String
|
||||
let name: String
|
||||
let host: String
|
||||
let port: UInt16
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class CastDiscovery: ObservableObject {
|
||||
@Published private(set) var devices: [CastDevice] = []
|
||||
@Published private(set) var error: String?
|
||||
private struct Service {
|
||||
let result: NWBrowser.Result
|
||||
let connection: NWConnection
|
||||
var timeout: Task<Void, Never>?
|
||||
}
|
||||
private var browser: NWBrowser?
|
||||
private var services: [NWEndpoint: Service] = [:]
|
||||
private var resolved: [NWEndpoint: CastDevice] = [:]
|
||||
|
||||
func start() {
|
||||
if error != nil { stop() }
|
||||
guard browser == nil else { return }
|
||||
error = nil
|
||||
let browser = NWBrowser(for: .bonjourWithTXTRecord(type: "_googlecast._tcp", domain: "local."),
|
||||
using: .tcp)
|
||||
self.browser = browser
|
||||
browser.stateUpdateHandler = { [weak self, weak browser] state in
|
||||
MainActor.assumeIsolated {
|
||||
guard let self, let browser, self.browser === browser else { return }
|
||||
switch state {
|
||||
case .ready: self.error = nil
|
||||
case .waiting, .failed:
|
||||
self.error = "Cast discovery failed. Check Local Network permission and your Wi-Fi connection."
|
||||
default: break
|
||||
}
|
||||
}
|
||||
}
|
||||
browser.browseResultsChangedHandler = { [weak self, weak browser] results, _ in
|
||||
MainActor.assumeIsolated {
|
||||
guard let self, let browser, self.browser === browser else { return }
|
||||
self.update(results)
|
||||
}
|
||||
}
|
||||
browser.start(queue: .main)
|
||||
}
|
||||
|
||||
func stop() {
|
||||
browser?.cancel()
|
||||
browser = nil
|
||||
for service in services.values {
|
||||
service.timeout?.cancel()
|
||||
service.connection.cancel()
|
||||
}
|
||||
services.removeAll()
|
||||
resolved.removeAll()
|
||||
devices.removeAll()
|
||||
}
|
||||
|
||||
private func update(_ results: Set<NWBrowser.Result>) {
|
||||
let endpoints = Set(results.map(\.endpoint))
|
||||
for endpoint in services.keys where !endpoints.contains(endpoint) {
|
||||
let service = services.removeValue(forKey: endpoint)
|
||||
service?.timeout?.cancel()
|
||||
service?.connection.cancel()
|
||||
resolved.removeValue(forKey: endpoint)
|
||||
}
|
||||
for result in results {
|
||||
let endpoint = result.endpoint
|
||||
if let previous = services[endpoint] {
|
||||
guard previous.result != result else { continue }
|
||||
previous.timeout?.cancel()
|
||||
previous.connection.cancel()
|
||||
resolved.removeValue(forKey: endpoint)
|
||||
} else if services.count >= 128 {
|
||||
continue
|
||||
}
|
||||
// UDP resolves the advertised SRV endpoint without opening a Cast TCP/TLS
|
||||
// session or sending a datagram. Discovery must not require a responsive receiver.
|
||||
let connection = NWConnection(to: endpoint, using: .udp)
|
||||
services[endpoint] = Service(result: result, connection: connection)
|
||||
connection.stateUpdateHandler = { [weak self, weak connection] state in
|
||||
MainActor.assumeIsolated {
|
||||
guard let self, let connection else { return }
|
||||
switch state {
|
||||
case .ready: self.finish(endpoint, connection: connection, ready: true)
|
||||
case .failed: self.finish(endpoint, connection: connection, ready: false)
|
||||
default: break
|
||||
}
|
||||
}
|
||||
}
|
||||
services[endpoint]?.timeout = Task { [weak self, weak connection] in
|
||||
do { try await Task.sleep(for: .seconds(5)) }
|
||||
catch { return }
|
||||
guard let self, let connection else { return }
|
||||
self.finish(endpoint, connection: connection, ready: false)
|
||||
}
|
||||
connection.start(queue: .main)
|
||||
}
|
||||
publish()
|
||||
}
|
||||
|
||||
private func finish(_ endpoint: NWEndpoint, connection: NWConnection, ready: Bool) {
|
||||
guard let service = services[endpoint], service.connection === connection else { return }
|
||||
if ready, case let .service(name, type, domain, _) = endpoint,
|
||||
case let .hostPort(host, port) = connection.currentPath?.remoteEndpoint, port.rawValue > 0 {
|
||||
func text(_ key: String) -> String? {
|
||||
guard case let .bonjour(txt) = service.result.metadata,
|
||||
let data = txt.getEntry(for: key)?.data,
|
||||
let value = String(data: data, encoding: .utf8), !value.isEmpty else { return nil }
|
||||
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,
|
||||
name: text("fn") ?? name,
|
||||
host: address, port: port.rawValue)
|
||||
}
|
||||
services[endpoint]?.timeout?.cancel()
|
||||
services[endpoint]?.timeout = nil
|
||||
connection.cancel()
|
||||
publish()
|
||||
}
|
||||
|
||||
private func publish() {
|
||||
// Multiple advertised interfaces may resolve to the same receiver ID.
|
||||
var unique: [String: CastDevice] = [:]
|
||||
for device in resolved.values { unique[device.id] = device }
|
||||
devices = unique.values.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending }
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct CastStatus: Sendable {
|
||||
let position: Double
|
||||
let duration: Double
|
||||
let playing: Bool
|
||||
let buffering: Bool
|
||||
let ended: Bool
|
||||
let volume: Double
|
||||
let seekable: Bool
|
||||
let subtitle: Int?
|
||||
let seekPending: Bool
|
||||
let playbackPending: Bool
|
||||
}
|
||||
|
||||
enum CastError: Error, LocalizedError, Sendable {
|
||||
case protocolError, disconnected, cancelled, timeout(String), rejected(String), invalidMedia, queueFull
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .protocolError: "The Cast receiver sent an invalid message."
|
||||
case .disconnected: "The Cast receiver disconnected."
|
||||
case .cancelled: "The Cast operation was cancelled."
|
||||
case .timeout(let operation): "The Cast receiver did not acknowledge \(operation) in time."
|
||||
case .rejected(let operation): "The Cast receiver rejected \(operation)."
|
||||
case .invalidMedia: "The media or subtitle selection cannot be cast."
|
||||
case .queueFull: "Too many Cast commands are waiting for a response."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cast v2's original (unchunked) envelope, from Open Screen's cast_channel.proto:
|
||||
// https://chromium.googlesource.com/openscreen/+/refs/heads/main/cast/common/channel/proto/cast_channel.proto
|
||||
// Media transport messages are limited to 64 KiB by https://developers.google.com/cast/docs/media/messages.
|
||||
enum CastWire {
|
||||
static let maximumFrame = 64 * 1024
|
||||
static let connection = "urn:x-cast:com.google.cast.tp.connection"
|
||||
static let heartbeat = "urn:x-cast:com.google.cast.tp.heartbeat"
|
||||
static let receiver = "urn:x-cast:com.google.cast.receiver"
|
||||
static let media = "urn:x-cast:com.google.cast.media"
|
||||
|
||||
struct Message: Sendable {
|
||||
let source: String
|
||||
let destination: String
|
||||
let namespace: String
|
||||
let payload: Data
|
||||
}
|
||||
|
||||
static func varint(_ value: UInt64, into data: inout Data) {
|
||||
var value = value
|
||||
repeat {
|
||||
let byte = UInt8(value & 127)
|
||||
value >>= 7
|
||||
data.append(byte | (value == 0 ? 0 : 128))
|
||||
} while value != 0
|
||||
}
|
||||
|
||||
static func frame(_ message: Message) throws -> Data {
|
||||
var body = Data([8, 0]) // protocol_version CASTV2_1_0
|
||||
for (field, text) in [(2, message.source), (3, message.destination), (4, message.namespace)] {
|
||||
let bytes = Data(text.utf8)
|
||||
varint(UInt64(field << 3 | 2), into: &body)
|
||||
varint(UInt64(bytes.count), into: &body)
|
||||
body.append(bytes)
|
||||
}
|
||||
body.append(contentsOf: [40, 0, 50]) // payload_type STRING; payload_utf8
|
||||
varint(UInt64(message.payload.count), into: &body)
|
||||
body.append(message.payload)
|
||||
guard body.count <= maximumFrame else { throw CastError.protocolError }
|
||||
let count = UInt32(body.count)
|
||||
var frame = Data([UInt8(count >> 24), UInt8((count >> 16) & 255),
|
||||
UInt8((count >> 8) & 255), UInt8(count & 255)])
|
||||
frame.append(body)
|
||||
return frame
|
||||
}
|
||||
|
||||
static func decode(_ data: Data) throws -> Message {
|
||||
guard !data.isEmpty, data.count <= maximumFrame else { throw CastError.protocolError }
|
||||
var offset = 0
|
||||
func integer() throws -> UInt64 {
|
||||
var value: UInt64 = 0
|
||||
for shift in stride(from: 0, through: 63, by: 7) {
|
||||
guard offset < data.count else { throw CastError.protocolError }
|
||||
let byte = data[data.startIndex + offset]
|
||||
offset += 1
|
||||
guard shift != 63 || byte <= 1 else { throw CastError.protocolError }
|
||||
value |= UInt64(byte & 127) << shift
|
||||
if byte & 128 == 0 { return value }
|
||||
}
|
||||
throw CastError.protocolError
|
||||
}
|
||||
var numbers: [Int: UInt64] = [:]
|
||||
var bytes: [Int: Data] = [:]
|
||||
while offset < data.count {
|
||||
let key = try integer()
|
||||
guard key >> 3 > 0, key >> 3 <= UInt64(Int32.max) else { throw CastError.protocolError }
|
||||
let field = Int(key >> 3)
|
||||
switch key & 7 {
|
||||
case 0:
|
||||
let value = try integer()
|
||||
if field == 1 || field == 5 || field == 8 { numbers[field] = value }
|
||||
case 1, 5:
|
||||
let size = key & 7 == 1 ? 8 : 4
|
||||
guard size <= data.count - offset else { throw CastError.protocolError }
|
||||
offset += size
|
||||
case 2:
|
||||
let size = try integer()
|
||||
guard size <= UInt64(data.count - offset) else { throw CastError.protocolError }
|
||||
let end = offset + Int(size)
|
||||
if [2, 3, 4, 6].contains(field) {
|
||||
bytes[field] = data.subdata(in: (data.startIndex + offset)..<(data.startIndex + end))
|
||||
}
|
||||
offset = end
|
||||
default: throw CastError.protocolError
|
||||
}
|
||||
}
|
||||
guard numbers[1] == 0, numbers[5] == 0, numbers[8, default: 0] == 0,
|
||||
let sourceData = bytes[2], let source = String(data: sourceData, encoding: .utf8), !source.isEmpty,
|
||||
let destinationData = bytes[3], let destination = String(data: destinationData, encoding: .utf8), !destination.isEmpty,
|
||||
let namespaceData = bytes[4], let namespace = String(data: namespaceData, encoding: .utf8), !namespace.isEmpty,
|
||||
let payload = bytes[6], String(data: payload, encoding: .utf8) != nil else {
|
||||
throw CastError.protocolError
|
||||
}
|
||||
return Message(source: source, destination: destination, namespace: namespace, payload: payload)
|
||||
}
|
||||
|
||||
struct Stream {
|
||||
private var buffer = Data()
|
||||
mutating func append(_ data: Data) throws -> [Message] {
|
||||
// Network reads are <= 16 KiB. Bound incomplete input even before its length is known.
|
||||
guard data.count <= maximumFrame, buffer.count + data.count <= maximumFrame * 2 + 4 else {
|
||||
throw CastError.protocolError
|
||||
}
|
||||
buffer.append(data)
|
||||
var messages: [Message] = []
|
||||
while buffer.count >= 4 {
|
||||
let i = buffer.startIndex
|
||||
let length = buffer[i..<i + 4].reduce(0) { ($0 << 8) | Int($1) }
|
||||
guard length > 0, length <= maximumFrame else { throw CastError.protocolError }
|
||||
guard buffer.count >= length + 4 else { break }
|
||||
messages.append(try decode(buffer.subdata(in: (i + 4)..<(i + 4 + length))))
|
||||
buffer.removeFirst(length + 4)
|
||||
}
|
||||
return messages
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class CastSession {
|
||||
var onStatus: ((CastStatus) -> Void)?
|
||||
var onDisconnect: ((String?) -> Void)?
|
||||
// Recoverable command errors; terminal channel failures belong to onDisconnect.
|
||||
var onError: ((String) -> Void)?
|
||||
var localHost: String? {
|
||||
guard let endpoint = connection?.currentPath?.localEndpoint,
|
||||
case .hostPort(let host, _) = endpoint else { return nil }
|
||||
return String(describing: host)
|
||||
}
|
||||
|
||||
private struct Pending {
|
||||
let namespace: String
|
||||
let destination: String
|
||||
let operation: String
|
||||
let generation: UUID?
|
||||
let completion: ([String: Any]?, CastError?) -> Void
|
||||
let deadline: Task<Void, Never>
|
||||
}
|
||||
|
||||
private struct Write {
|
||||
let data: Data
|
||||
let seek: Int?
|
||||
}
|
||||
|
||||
private let makeConnection: (CastDevice) -> NWConnection
|
||||
private let requestTimeout: Duration
|
||||
private let loadTimeout: Duration
|
||||
private let heartbeatInterval: Duration
|
||||
private let sleep: @MainActor (Duration) async throws -> Void
|
||||
private var connection: NWConnection?
|
||||
private var connectionID = UUID()
|
||||
private let source = "terebi-" + UUID().uuidString
|
||||
private var stream = CastWire.Stream()
|
||||
private var ready: CheckedContinuation<Void, any Error>?
|
||||
private var readyDeadline: Task<Void, Never>?
|
||||
private var heartbeatTask: Task<Void, Never>?
|
||||
private var awaitingPong = false
|
||||
private var nextRequest = 0
|
||||
private var pending: [Int: Pending] = [:]
|
||||
private var writes: [Write] = []
|
||||
private var writing = false
|
||||
private var launchRequest: Int?
|
||||
private var appTransport: String?
|
||||
private var appSession: String?
|
||||
private var mediaSession: Int?
|
||||
private var retiredSessions: Set<Int> = []
|
||||
private var retiredLoads: [String] = []
|
||||
private var contentID: String?
|
||||
private var mediaGeneration: UUID?
|
||||
private var tracks: [CastTextTrack] = []
|
||||
private var latestStatus: CastStatus?
|
||||
private var pendingSeek: Int?
|
||||
private var pendingPlayback: Int?
|
||||
var playbackPending: Bool { pendingPlayback != nil || replayTask != nil || loading }
|
||||
private var lastStatusPoll = 0
|
||||
private var newestMediaReply = 0
|
||||
private var loading = false
|
||||
private var loadedMedia: CastMediaDescription?
|
||||
private var loadedTitle = ""
|
||||
private var replayTask: Task<Void, Never>?
|
||||
private var replayDesiredPlaying = true
|
||||
private var receiverVolume = 1.0
|
||||
private var pendingVolume: (request: Int, level: Double)?
|
||||
private var latestVolumeRequest = 0
|
||||
|
||||
init(requestTimeout: Duration = .seconds(8), loadTimeout: Duration = .seconds(25),
|
||||
heartbeatInterval: Duration = .seconds(5),
|
||||
sleep: @escaping @MainActor (Duration) async throws -> Void = { try await Task.sleep(for: $0) }) {
|
||||
makeConnection = { device in
|
||||
let tls = NWProtocolTLS.Options()
|
||||
sec_protocol_options_set_min_tls_protocol_version(tls.securityProtocolOptions, .TLSv12)
|
||||
// Cast receivers use self-signed TLS certificates, not public Web PKI. This is
|
||||
// encrypted local-network transport to the user's selected Bonjour endpoint,
|
||||
// NOT authenticated receiver identity. mDNS and this channel are LAN-trust;
|
||||
// never claim standard CA/hostname validation or send upstream credentials.
|
||||
// Cast device-auth (Google device roots) is intentionally not impersonated.
|
||||
sec_protocol_options_set_verify_block(tls.securityProtocolOptions, { @Sendable _, _, complete in
|
||||
complete(true)
|
||||
}, DispatchQueue.global(qos: .userInitiated))
|
||||
let parameters = NWParameters(tls: tls, tcp: NWProtocolTCP.Options())
|
||||
return NWConnection(host: NWEndpoint.Host(device.host),
|
||||
port: NWEndpoint.Port(rawValue: device.port)!, using: parameters)
|
||||
}
|
||||
self.requestTimeout = requestTimeout
|
||||
self.loadTimeout = loadTimeout
|
||||
self.heartbeatInterval = heartbeatInterval
|
||||
self.sleep = sleep
|
||||
}
|
||||
|
||||
|
||||
func connect(to device: CastDevice) async throws {
|
||||
disconnect(stopReceiver: false)
|
||||
guard device.port > 0, !device.host.isEmpty else { throw CastError.disconnected }
|
||||
let id = UUID()
|
||||
connectionID = id
|
||||
let socket = makeConnection(device)
|
||||
connection = socket
|
||||
stream = CastWire.Stream()
|
||||
try await withTaskCancellationHandler {
|
||||
try Task.checkCancellation()
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
ready = continuation
|
||||
readyDeadline = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
do { try await self.sleep(.seconds(12)) } catch { return }
|
||||
guard self.connectionID == id else { return }
|
||||
self.fail(.timeout("connection"))
|
||||
}
|
||||
socket.stateUpdateHandler = { [weak self] state in
|
||||
Task { @MainActor in
|
||||
guard let self, self.connectionID == id else { return }
|
||||
switch state {
|
||||
case .ready:
|
||||
self.receive(socket, id: id)
|
||||
self.beginHandshake()
|
||||
case .failed, .cancelled: self.fail(.disconnected)
|
||||
default: break
|
||||
}
|
||||
}
|
||||
}
|
||||
socket.start(queue: DispatchQueue.global(qos: .userInitiated))
|
||||
}
|
||||
} onCancel: {
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self, self.connectionID == id else { return }
|
||||
self.fail(.cancelled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func beginHandshake() {
|
||||
do {
|
||||
// TLS establishment and a cold receiver application have separate deadlines.
|
||||
readyDeadline?.cancel()
|
||||
readyDeadline = nil
|
||||
try send(["type": "CONNECT", "origin": [:], "userAgent": "Terebi"],
|
||||
namespace: CastWire.connection, destination: "receiver-0")
|
||||
startHeartbeat()
|
||||
let id = allocateRequest()
|
||||
launchRequest = id
|
||||
try request(["type": "LAUNCH", "appId": "CC1AD845"], namespace: CastWire.receiver,
|
||||
destination: "receiver-0", operation: "receiver launch", timeout: .seconds(30), id: id) { [weak self] _, error in
|
||||
if let error { self?.fail(error) }
|
||||
}
|
||||
} catch { fail(error as? CastError ?? .protocolError) }
|
||||
}
|
||||
|
||||
func load(_ media: CastMediaDescription, title: String, position: Double,
|
||||
playing: Bool, subtitle: Int?) async throws {
|
||||
guard let transport = appTransport, connection != nil else { throw CastError.disconnected }
|
||||
guard position.isFinite, position >= 0, media.duration.isFinite,
|
||||
["http", "https"].contains(media.url.scheme?.lowercased() ?? ""),
|
||||
media.url.absoluteString.utf8.count <= 1024,
|
||||
subtitle == nil || media.tracks.indices.contains(subtitle!), media.tracks.count <= 128 else {
|
||||
throw CastError.invalidMedia
|
||||
}
|
||||
cancelMediaRequests()
|
||||
if let mediaSession {
|
||||
if retiredSessions.count >= 16 { retiredSessions.removeAll() }
|
||||
retiredSessions.insert(mediaSession)
|
||||
}
|
||||
if let mediaGeneration {
|
||||
if retiredLoads.count == 64 { retiredLoads.removeFirst() }
|
||||
retiredLoads.append(mediaGeneration.uuidString)
|
||||
}
|
||||
mediaSession = nil
|
||||
latestStatus = nil
|
||||
pendingSeek = nil
|
||||
pendingPlayback = nil
|
||||
newestMediaReply = 0
|
||||
let generation = UUID()
|
||||
mediaGeneration = generation
|
||||
contentID = media.url.absoluteString
|
||||
tracks = media.tracks
|
||||
loadedMedia = media
|
||||
loadedTitle = title
|
||||
loading = true
|
||||
var info: [String: Any] = [
|
||||
"contentId": media.url.absoluteString, "contentType": media.contentType,
|
||||
"streamType": media.live ? "LIVE" : "BUFFERED",
|
||||
"metadata": ["metadataType": 0, "title": String(title.prefix(1024))],
|
||||
"customData": ["terebiSession": generation.uuidString]
|
||||
]
|
||||
if media.duration > 0, !media.live { info["duration"] = media.duration }
|
||||
if !media.tracks.isEmpty {
|
||||
info["textTrackStyle"] = [
|
||||
"foregroundColor": "#FFFFFFFF",
|
||||
"backgroundColor": "#00000000",
|
||||
"edgeType": "OUTLINE",
|
||||
"edgeColor": "#000000FF",
|
||||
"fontGenericFamily": "SANS_SERIF",
|
||||
"fontStyle": "NORMAL",
|
||||
"fontScale": 1.0, // Receiver default; tune against physical TV output.
|
||||
"windowType": "NONE"
|
||||
]
|
||||
info["tracks"] = media.tracks.map { track -> [String: Any] in
|
||||
["trackId": track.id, "type": "TEXT", "subtype": "SUBTITLES",
|
||||
"trackContentId": track.url.absoluteString, "trackContentType": "text/vtt",
|
||||
"name": track.label, "language": track.language]
|
||||
}
|
||||
}
|
||||
var command: [String: Any] = ["type": "LOAD", "media": info, "autoplay": playing,
|
||||
"activeTrackIds": subtitle.map { [media.tracks[$0].id] } ?? []]
|
||||
if position > 0 || !media.live { command["currentTime"] = position }
|
||||
let requestID = allocateRequest()
|
||||
try await withTaskCancellationHandler {
|
||||
try Task.checkCancellation()
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, any Error>) in
|
||||
do {
|
||||
try request(command, namespace: CastWire.media, destination: transport,
|
||||
operation: "media load", generation: generation, timeout: loadTimeout,
|
||||
id: requestID) { [weak self] _, error in
|
||||
if let self, self.mediaGeneration == generation { self.loading = false; self.publishSeekState() }
|
||||
if let error { continuation.resume(throwing: error) }
|
||||
else { continuation.resume() }
|
||||
}
|
||||
} catch { loading = false; continuation.resume(throwing: error) }
|
||||
}
|
||||
} onCancel: {
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self, self.mediaGeneration == generation else { return }
|
||||
// A cancelled LOAD may already be on the receiver. Tear down the channel
|
||||
// rather than letting its late acknowledgement acquire ownership.
|
||||
self.fail(.cancelled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setPlaying(_ playing: Bool) -> Bool {
|
||||
if replayTask != nil { replayDesiredPlaying = playing; return true }
|
||||
guard playing, latestStatus?.ended == true, let loadedMedia else {
|
||||
return control(["type": playing ? "PLAY" : "PAUSE"])
|
||||
}
|
||||
let subtitle = latestStatus?.subtitle
|
||||
let title = loadedTitle
|
||||
replayDesiredPlaying = true
|
||||
let connectionID = self.connectionID
|
||||
replayTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
do {
|
||||
try await self.load(loadedMedia, title: title, position: 0, playing: true, subtitle: subtitle)
|
||||
guard self.connectionID == connectionID else { return }
|
||||
self.replayTask = nil
|
||||
self.publishSeekState()
|
||||
if !self.replayDesiredPlaying { _ = self.setPlaying(false) }
|
||||
} catch {
|
||||
guard self.connectionID == connectionID else { return }
|
||||
self.replayTask = nil
|
||||
self.publishSeekState()
|
||||
if self.connection != nil { self.fail(error as? CastError ?? .disconnected) }
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
func seek(_ seconds: Double) -> Bool {
|
||||
guard seconds.isFinite, seconds >= 0, latestStatus?.seekable == true else { return false }
|
||||
return control(["type": "SEEK", "currentTime": seconds], seeking: true)
|
||||
}
|
||||
func setVolume(_ volume: Double) -> Bool {
|
||||
guard volume.isFinite, (0...1).contains(volume), appTransport != nil else { return false }
|
||||
return control(["type": "SET_VOLUME", "volume": ["level": volume, "muted": volume == 0]], receiver: true)
|
||||
}
|
||||
func setSubtitle(_ index: Int?) -> Bool {
|
||||
guard index == nil || tracks.indices.contains(index!) else { return false }
|
||||
return control(["type": "EDIT_TRACKS_INFO", "activeTrackIds": index.map { [tracks[$0].id] } ?? []])
|
||||
}
|
||||
|
||||
private func control(_ command: [String: Any], seeking: Bool = false, receiver: Bool = false) -> Bool {
|
||||
guard !loading, let transport = appTransport, let mediaSession else { return false }
|
||||
var command = command
|
||||
if !receiver { command["mediaSessionId"] = mediaSession }
|
||||
let id = allocateRequest()
|
||||
let type = command["type"] as? String
|
||||
let changingPlayback = type == "PLAY" || type == "PAUSE"
|
||||
do {
|
||||
try request(command, namespace: receiver ? CastWire.receiver : CastWire.media,
|
||||
destination: receiver ? "receiver-0" : transport,
|
||||
operation: (command["type"] as? String ?? "command").lowercased(),
|
||||
generation: mediaGeneration, id: id) { [weak self] _, error in
|
||||
guard let self, self.connection != nil else { return }
|
||||
if self.pendingVolume?.request == id { self.pendingVolume = nil; self.publishSeekState() }
|
||||
if case .cancelled? = error { return }
|
||||
if self.pendingSeek == id { self.pendingSeek = nil; self.publishSeekState() }
|
||||
if self.pendingPlayback == id { self.pendingPlayback = nil; self.publishSeekState() }
|
||||
if let error { self.onError?(error.localizedDescription) }
|
||||
}
|
||||
if seeking { pendingSeek = id; publishSeekState() }
|
||||
if changingPlayback { pendingPlayback = id; publishSeekState() }
|
||||
if type == "SET_VOLUME", let level = (command["volume"] as? [String: Any])?["level"] as? Double {
|
||||
latestVolumeRequest = id
|
||||
pendingVolume = (id, level)
|
||||
publishSeekState()
|
||||
}
|
||||
return true
|
||||
} catch { onError?(error.localizedDescription); return false }
|
||||
}
|
||||
|
||||
private func allocateRequest() -> Int { nextRequest += 1; return nextRequest }
|
||||
|
||||
@discardableResult
|
||||
private func request(_ command: [String: Any], namespace: String, destination: String,
|
||||
operation: String, generation: UUID? = nil, timeout: Duration? = nil,
|
||||
id suppliedID: Int? = nil,
|
||||
completion: @escaping ([String: Any]?, CastError?) -> Void) throws -> Int {
|
||||
// Only the tail is replaceable: it has not reached the socket, and every
|
||||
// intervening control or protocol frame remains an ordering barrier.
|
||||
let superseded: Int?
|
||||
if command["type"] as? String == "SEEK", namespace == CastWire.media,
|
||||
let id = writes.last?.seek, let previous = pending[id],
|
||||
previous.generation == generation, previous.destination == destination {
|
||||
superseded = id
|
||||
} else {
|
||||
superseded = nil
|
||||
}
|
||||
guard pending.count < 32 || superseded != nil else { throw CastError.queueFull }
|
||||
let id = suppliedID ?? allocateRequest()
|
||||
var command = command
|
||||
command["requestId"] = id
|
||||
let sleep = self.sleep
|
||||
let duration = timeout ?? requestTimeout
|
||||
let deadline = Task { [weak self] in
|
||||
do { try await sleep(duration) } catch { return }
|
||||
self?.complete(id, json: nil, error: .timeout(operation))
|
||||
}
|
||||
pending[id] = Pending(namespace: namespace, destination: destination, operation: operation,
|
||||
generation: generation, completion: completion, deadline: deadline)
|
||||
do {
|
||||
try send(command, namespace: namespace, destination: destination, replacing: superseded)
|
||||
}
|
||||
catch { pending.removeValue(forKey: id)?.deadline.cancel(); throw error }
|
||||
if let superseded { complete(superseded, json: nil, error: .cancelled) }
|
||||
return id
|
||||
}
|
||||
|
||||
private func complete(_ id: Int, json: [String: Any]?, error: CastError?) {
|
||||
guard let item = pending.removeValue(forKey: id) else { return }
|
||||
item.deadline.cancel()
|
||||
item.completion(json, error)
|
||||
}
|
||||
|
||||
private func cancelMediaRequests() {
|
||||
let ids = pending.filter { $0.value.generation != nil }.map(\.key)
|
||||
for id in ids { complete(id, json: nil, error: .cancelled) }
|
||||
}
|
||||
|
||||
private func send(_ json: [String: Any], namespace: String, destination: String,
|
||||
replacing: Int? = nil) throws {
|
||||
guard connection != nil else { throw CastError.disconnected }
|
||||
guard writes.count < 32 || replacing != nil else { throw CastError.queueFull }
|
||||
let payload = try JSONSerialization.data(withJSONObject: json)
|
||||
let write = Write(data: try CastWire.frame(.init(source: source, destination: destination,
|
||||
namespace: namespace, payload: payload)),
|
||||
seek: namespace == CastWire.media && json["type"] as? String == "SEEK" ?
|
||||
json["requestId"] as? Int : nil)
|
||||
if replacing != nil { writes[writes.count - 1] = write }
|
||||
else { writes.append(write) }
|
||||
flush()
|
||||
}
|
||||
|
||||
private func flush() {
|
||||
guard !writing, let socket = connection, !writes.isEmpty else { return }
|
||||
writing = true
|
||||
let data = writes.removeFirst()
|
||||
let id = connectionID
|
||||
socket.send(content: data.data, completion: .contentProcessed { [weak self] error in
|
||||
Task { @MainActor in
|
||||
guard let self, self.connectionID == id else { return }
|
||||
self.writing = false
|
||||
if error != nil { self.fail(.disconnected) } else { self.flush() }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private func receive(_ socket: NWConnection, id: UUID) {
|
||||
socket.receive(minimumIncompleteLength: 1, maximumLength: 16 * 1024) { [weak self] data, _, ended, error in
|
||||
Task { @MainActor in
|
||||
guard let self, self.connectionID == id else { return }
|
||||
do {
|
||||
if let data {
|
||||
for message in try self.stream.append(data) {
|
||||
self.handle(message)
|
||||
guard self.connectionID == id else { return }
|
||||
}
|
||||
}
|
||||
if ended || error != nil { self.fail(.disconnected) }
|
||||
else { self.receive(socket, id: id) }
|
||||
} catch { self.fail(.protocolError) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handle(_ message: CastWire.Message) {
|
||||
guard message.destination == source || message.destination == "*",
|
||||
let json = try? JSONSerialization.jsonObject(with: message.payload) as? [String: Any],
|
||||
let type = json["type"] as? String else { return }
|
||||
if message.namespace == CastWire.heartbeat, message.source == "receiver-0" {
|
||||
if type == "PONG" { awaitingPong = false }
|
||||
if type == "PING" {
|
||||
do { try send(["type": "PONG"], namespace: CastWire.heartbeat, destination: "receiver-0") }
|
||||
catch { fail(.disconnected) }
|
||||
}
|
||||
return
|
||||
}
|
||||
if message.namespace == CastWire.connection, type == "CLOSE",
|
||||
message.source == appTransport || message.source == "receiver-0" {
|
||||
fail(.disconnected); return
|
||||
}
|
||||
let id = json["requestId"] as? Int
|
||||
let item = id.flatMap { pending[$0] }
|
||||
let correlated = item?.namespace == message.namespace && item?.destination == message.source &&
|
||||
(item?.generation == nil || item?.generation == mediaGeneration)
|
||||
if ["INVALID_REQUEST", "INVALID_PLAYER_STATE", "LOAD_FAILED", "LOAD_CANCELLED", "LAUNCH_ERROR"].contains(type) {
|
||||
if correlated, let id, let item { complete(id, json: nil, error: .rejected(item.operation)) }
|
||||
return
|
||||
}
|
||||
if message.namespace == CastWire.receiver, message.source == "receiver-0", type == "RECEIVER_STATUS" {
|
||||
if let status = json["status"] as? [String: Any] {
|
||||
let olderVolumeReply = id.map { $0 > 0 && $0 < latestVolumeRequest } ?? false
|
||||
let acknowledgesVolume = correlated && id == pendingVolume?.request
|
||||
if !olderVolumeReply, pendingVolume == nil || acknowledgesVolume,
|
||||
let volume = status["volume"] as? [String: Any],
|
||||
let level = volume["level"] as? Double, level.isFinite {
|
||||
let muted = volume["muted"] as? Bool ?? false
|
||||
receiverVolume = muted ? 0 : min(1, max(0, level))
|
||||
if acknowledgesVolume { pendingVolume = nil }
|
||||
if let latestStatus { publish(latestStatus) }
|
||||
}
|
||||
// Receiver readiness can be broadcast before/without the LAUNCH reply.
|
||||
// Only the requested Default Media Receiver on receiver-0 can satisfy it.
|
||||
if let launchRequest, let apps = status["applications"] as? [[String: Any]],
|
||||
let app = apps.first(where: { $0["appId"] as? String == "CC1AD845" }),
|
||||
let transport = app["transportId"] as? String, !transport.isEmpty,
|
||||
let session = app["sessionId"] as? String, !session.isEmpty {
|
||||
appTransport = transport
|
||||
appSession = session
|
||||
do {
|
||||
try send(["type": "CONNECT", "origin": [:]], namespace: CastWire.connection, destination: transport)
|
||||
} catch { fail(error as? CastError ?? .protocolError); return }
|
||||
self.launchRequest = nil
|
||||
complete(launchRequest, json: json, error: nil)
|
||||
let ready = self.ready
|
||||
self.ready = nil
|
||||
ready?.resume()
|
||||
}
|
||||
if let appSession, let appTransport {
|
||||
let apps = status["applications"] as? [[String: Any]] ?? []
|
||||
guard apps.contains(where: { $0["appId"] as? String == "CC1AD845" &&
|
||||
$0["sessionId"] as? String == appSession &&
|
||||
$0["transportId"] as? String == appTransport }) else {
|
||||
fail(.rejected("receiver ownership changed")); return
|
||||
}
|
||||
}
|
||||
}
|
||||
if correlated, let id, id != launchRequest, id != pendingVolume?.request {
|
||||
complete(id, json: json, error: nil)
|
||||
}
|
||||
return
|
||||
}
|
||||
guard message.namespace == CastWire.media, message.source == appTransport,
|
||||
type == "MEDIA_STATUS", let statuses = json["status"] as? [[String: Any]] else { return }
|
||||
let olderReply = correlated && id.map { $0 < newestMediaReply } == true
|
||||
if olderReply, id != pendingSeek, id != pendingPlayback, let id {
|
||||
complete(id, json: json, error: nil)
|
||||
return
|
||||
}
|
||||
for status in statuses {
|
||||
guard let session = status["mediaSessionId"] as? Int else { continue }
|
||||
let media = status["media"] as? [String: Any]
|
||||
let reportedContent = media?["contentId"] as? String
|
||||
let customData = media?["customData"] as? [String: Any]
|
||||
if let nonce = customData?["terebiSession"] as? String, nonce != mediaGeneration?.uuidString {
|
||||
if retiredLoads.contains(nonce) { continue }
|
||||
if !loading { fail(.rejected("media ownership changed")); return }
|
||||
continue
|
||||
}
|
||||
if mediaSession == nil {
|
||||
guard loading, correlated, item?.operation == "media load", reportedContent == contentID,
|
||||
!retiredSessions.contains(session) else { continue }
|
||||
mediaSession = session
|
||||
}
|
||||
guard session == mediaSession else {
|
||||
if !retiredSessions.contains(session), !loading {
|
||||
fail(.rejected("media ownership changed")); return
|
||||
}
|
||||
continue
|
||||
}
|
||||
if let reportedContent, reportedContent != contentID {
|
||||
fail(.rejected("media ownership changed")); return
|
||||
}
|
||||
let state = status["playerState"] as? String ?? "IDLE"
|
||||
guard ["IDLE", "PLAYING", "PAUSED", "BUFFERING"].contains(state) else {
|
||||
fail(.protocolError); return
|
||||
}
|
||||
let idle = status["idleReason"] as? String
|
||||
if state == "IDLE", idle == "ERROR" {
|
||||
if correlated, let id { complete(id, json: nil, error: .rejected("media playback")) }
|
||||
fail(.rejected("media playback")); return
|
||||
}
|
||||
if state == "IDLE", idle == "INTERRUPTED" || idle == "CANCELLED" {
|
||||
fail(.rejected("media playback stopped")); return
|
||||
}
|
||||
let receivedPosition = status["currentTime"] as? Double ?? latestStatus?.position ?? 0
|
||||
let position = olderReply && id != pendingSeek ? latestStatus?.position ?? receivedPosition : receivedPosition
|
||||
let duration = media?["duration"] as? Double ?? latestStatus?.duration ?? loadedMedia?.duration ?? 0
|
||||
guard position.isFinite, duration.isFinite else { fail(.protocolError); return }
|
||||
let supported = status["supportedMediaCommands"] as? Int
|
||||
let active = status["activeTrackIds"] as? [Int]
|
||||
let subtitle = active.map { ids in tracks.firstIndex { ids.contains($0.id) } } ?? latestStatus?.subtitle
|
||||
let live = media?["streamType"] as? String == "LIVE" || loadedMedia?.live == true
|
||||
// A late SEEK still owns its position acknowledgement, but not newer playback intent.
|
||||
let previous = olderReply && id != pendingPlayback ? latestStatus : nil
|
||||
// An old status/poll cannot clear a newer seek. Only that seek's reply does.
|
||||
if correlated, id == pendingSeek { pendingSeek = nil }
|
||||
if correlated, id == pendingPlayback { pendingPlayback = nil }
|
||||
let next = CastStatus(position: max(0, position), duration: max(0, duration),
|
||||
playing: previous?.playing ?? (state == "PLAYING" || state == "BUFFERING"),
|
||||
buffering: previous?.buffering ?? (state == "BUFFERING"), ended: previous?.ended ?? (state == "IDLE" && idle == "FINISHED"),
|
||||
volume: pendingVolume?.level ?? receiverVolume,
|
||||
seekable: !live && (supported.map { $0 & 2 != 0 } ?? latestStatus?.seekable ?? false),
|
||||
subtitle: subtitle, seekPending: pendingSeek != nil, playbackPending: playbackPending)
|
||||
latestStatus = next
|
||||
if correlated, let id { newestMediaReply = max(newestMediaReply, id) }
|
||||
onStatus?(next)
|
||||
if correlated, let id { complete(id, json: json, error: nil) }
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private func publish(_ status: CastStatus) {
|
||||
let next = CastStatus(position: status.position, duration: status.duration, playing: status.playing,
|
||||
buffering: status.buffering, ended: status.ended, volume: pendingVolume?.level ?? receiverVolume,
|
||||
seekable: status.seekable, subtitle: status.subtitle, seekPending: pendingSeek != nil,
|
||||
playbackPending: playbackPending)
|
||||
latestStatus = next
|
||||
onStatus?(next)
|
||||
}
|
||||
private func publishSeekState() { if let latestStatus { publish(latestStatus) } }
|
||||
|
||||
private func startHeartbeat() {
|
||||
heartbeatTask?.cancel()
|
||||
let id = connectionID
|
||||
heartbeatTask = Task { [weak self] in
|
||||
while !Task.isCancelled {
|
||||
guard let self, self.connectionID == id else { return }
|
||||
do { try await self.sleep(self.heartbeatInterval) } catch { return }
|
||||
guard self.connectionID == id else { return }
|
||||
if self.awaitingPong { self.fail(.timeout("heartbeat")); return }
|
||||
self.awaitingPong = true
|
||||
do {
|
||||
try self.send(["type": "PING"], namespace: CastWire.heartbeat, destination: "receiver-0")
|
||||
// Receivers do not reliably broadcast clock progress. Poll without
|
||||
// extrapolating receiver time, allowing only one outstanding poll.
|
||||
if !self.loading, let transport = self.appTransport, let session = self.mediaSession,
|
||||
self.pending[self.lastStatusPoll] == nil {
|
||||
self.lastStatusPoll = try self.request(["type": "GET_STATUS", "mediaSessionId": session],
|
||||
namespace: CastWire.media, destination: transport, operation: "media status",
|
||||
generation: self.mediaGeneration) { [weak self] _, error in
|
||||
if case .cancelled? = error { return }
|
||||
if let self, self.connection != nil, let error { self.onError?(error.localizedDescription) }
|
||||
}
|
||||
}
|
||||
} catch { self.fail(error as? CastError ?? .disconnected); return }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func disconnect(stopReceiver: Bool = true) { teardown(error: nil, stopReceiver: stopReceiver) }
|
||||
private func fail(_ error: CastError) { teardown(error: error, stopReceiver: false) }
|
||||
|
||||
private func teardown(error: CastError?, stopReceiver: Bool) {
|
||||
let socket = connection
|
||||
let connected = socket != nil
|
||||
var farewell = Data()
|
||||
if connected {
|
||||
func append(_ json: [String: Any], namespace: String, destination: String) {
|
||||
if let payload = try? JSONSerialization.data(withJSONObject: json),
|
||||
let frame = try? CastWire.frame(.init(source: source, destination: destination,
|
||||
namespace: namespace, payload: payload)) { farewell.append(frame) }
|
||||
}
|
||||
// Stop only the media session we acquired, never STOP the application: another
|
||||
// sender may be using the Default Media Receiver by the time we disconnect.
|
||||
if stopReceiver, let mediaSession, let appTransport, !loading {
|
||||
append(["type": "STOP", "mediaSessionId": mediaSession, "requestId": allocateRequest()],
|
||||
namespace: CastWire.media, destination: appTransport)
|
||||
}
|
||||
if let appTransport { append(["type": "CLOSE"], namespace: CastWire.connection, destination: appTransport) }
|
||||
append(["type": "CLOSE"], namespace: CastWire.connection, destination: "receiver-0")
|
||||
}
|
||||
connectionID = UUID()
|
||||
connection = nil
|
||||
socket?.stateUpdateHandler = nil
|
||||
readyDeadline?.cancel(); readyDeadline = nil
|
||||
heartbeatTask?.cancel(); heartbeatTask = nil
|
||||
replayTask?.cancel(); replayTask = nil
|
||||
awaitingPong = false
|
||||
let ready = self.ready
|
||||
self.ready = nil
|
||||
let requests = pending.values
|
||||
pending.removeAll()
|
||||
writes.removeAll(); writing = false
|
||||
appTransport = nil; appSession = nil; mediaSession = nil; launchRequest = nil
|
||||
retiredSessions.removeAll(); contentID = nil; mediaGeneration = nil
|
||||
retiredLoads.removeAll()
|
||||
tracks.removeAll(); latestStatus = nil; pendingSeek = nil; loading = false
|
||||
pendingPlayback = nil
|
||||
loadedMedia = nil; loadedTitle = ""
|
||||
receiverVolume = 1
|
||||
pendingVolume = nil; latestVolumeRequest = 0
|
||||
ready?.resume(throwing: error ?? CastError.cancelled)
|
||||
for request in requests {
|
||||
request.deadline.cancel()
|
||||
request.completion(nil, error ?? .cancelled)
|
||||
}
|
||||
if let socket {
|
||||
if !farewell.isEmpty, error == nil {
|
||||
socket.send(content: farewell, completion: .contentProcessed { _ in socket.cancel() })
|
||||
DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) { socket.cancel() }
|
||||
} else { socket.cancel() }
|
||||
}
|
||||
if connected { onDisconnect?(error?.localizedDescription) }
|
||||
}
|
||||
}
|
||||
+284
-49
@@ -1,10 +1,15 @@
|
||||
import AppKit
|
||||
import QuartzCore
|
||||
|
||||
@MainActor
|
||||
final class CompactWindowController: NSObject {
|
||||
var onChange: (() -> Void)?
|
||||
var normalVideoLayout: (() -> (aspect: Double, sidebarWidth: CGFloat)?)?
|
||||
private(set) var isCompact = false
|
||||
private(set) var isTransitioning = false
|
||||
var presentationWindow: NSWindow? { compactPanel ?? window }
|
||||
private(set) var queueHeight: CGFloat = 0
|
||||
static let transitionDuration: TimeInterval = 0.25
|
||||
|
||||
var snappingEnabled: Bool {
|
||||
didSet {
|
||||
@@ -18,6 +23,7 @@ final class CompactWindowController: NSObject {
|
||||
let minSize: NSSize
|
||||
let contentMinSize: NSSize
|
||||
let aspectRatio: NSSize
|
||||
let resizeIncrements: NSSize
|
||||
let styleMask: NSWindow.StyleMask
|
||||
let titleVisibility: NSWindow.TitleVisibility
|
||||
let titlebarAppearsTransparent: Bool
|
||||
@@ -25,13 +31,18 @@ final class CompactWindowController: NSObject {
|
||||
let level: NSWindow.Level
|
||||
let hidesOnDeactivate: Bool
|
||||
let isMovableByWindowBackground: Bool
|
||||
let isVisible: Bool
|
||||
let firstResponder: NSResponder?
|
||||
let contentSuperview: NSView?
|
||||
let contentFrame: NSRect
|
||||
|
||||
@MainActor
|
||||
init(_ window: NSWindow) {
|
||||
init(_ window: NSWindow, contentView: NSView) {
|
||||
frame = window.frame
|
||||
minSize = window.minSize
|
||||
contentMinSize = window.contentMinSize
|
||||
aspectRatio = window.aspectRatio
|
||||
resizeIncrements = window.resizeIncrements
|
||||
styleMask = window.styleMask
|
||||
titleVisibility = window.titleVisibility
|
||||
titlebarAppearsTransparent = window.titlebarAppearsTransparent
|
||||
@@ -39,12 +50,19 @@ final class CompactWindowController: NSObject {
|
||||
level = window.level
|
||||
hidesOnDeactivate = window.hidesOnDeactivate
|
||||
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 weak var window: NSWindow?
|
||||
private weak var contentView: NSView?
|
||||
private var compactPanel: NSPanel?
|
||||
private var panelDelegate: FullScreenDelegate?
|
||||
private var snapshot: Snapshot?
|
||||
private var fullScreenTransition: FullScreenTransition?
|
||||
private var delegateProxy: FullScreenDelegate?
|
||||
@@ -54,16 +72,26 @@ final class CompactWindowController: NSObject {
|
||||
private var originatedFullScreen = false
|
||||
private var videoAspect = 16.0 / 9.0
|
||||
private var fittedAspect: Double?
|
||||
private var fittedVideoAspect = 16.0 / 9.0
|
||||
private let reduceMotion: () -> Bool
|
||||
private let defaults: UserDefaults
|
||||
private static let widthKey = "compactWindowWidth"
|
||||
private var frameAnimating = false
|
||||
private var frameAnimationGeneration: UInt64 = 0
|
||||
|
||||
init(snappingEnabled: Bool = true) {
|
||||
init(snappingEnabled: Bool = true, defaults: UserDefaults = .standard,
|
||||
reduceMotion: @escaping () -> Bool = { NSWorkspace.shared.accessibilityDisplayShouldReduceMotion }) {
|
||||
self.snappingEnabled = snappingEnabled
|
||||
self.reduceMotion = reduceMotion
|
||||
self.defaults = defaults
|
||||
super.init()
|
||||
}
|
||||
|
||||
deinit { NotificationCenter.default.removeObserver(self) }
|
||||
|
||||
func attach(_ window: NSWindow) {
|
||||
func attach(_ window: NSWindow, contentView: NSView? = nil) {
|
||||
guard window !== self.window else { return }
|
||||
stopFrameAnimation()
|
||||
if let oldWindow = self.window, fullScreenTransition == nil,
|
||||
!oldWindow.styleMask.contains(.fullScreen) {
|
||||
restoreSnapshot(to: oldWindow)
|
||||
@@ -71,6 +99,7 @@ final class CompactWindowController: NSObject {
|
||||
restoreDelegate()
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
self.window = window
|
||||
self.contentView = contentView ?? window.contentView
|
||||
snapshot = nil
|
||||
fullScreenTransition = nil
|
||||
sessionActive = false
|
||||
@@ -83,7 +112,7 @@ final class CompactWindowController: NSObject {
|
||||
window.delegate = proxy
|
||||
for name in [NSWindow.willEnterFullScreenNotification, NSWindow.didEnterFullScreenNotification,
|
||||
NSWindow.willExitFullScreenNotification, NSWindow.didExitFullScreenNotification,
|
||||
NSWindow.willCloseNotification] {
|
||||
NSWindow.willCloseNotification, NSWindow.didEndLiveResizeNotification] {
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(windowNotification(_:)),
|
||||
name: name, object: window)
|
||||
}
|
||||
@@ -94,39 +123,73 @@ final class CompactWindowController: NSObject {
|
||||
restore()
|
||||
return
|
||||
}
|
||||
guard let window, aspect.isFinite, aspect > 0 else { return }
|
||||
guard let window else { return }
|
||||
if !sessionActive {
|
||||
sessionActive = true
|
||||
originatedFullScreen = window.styleMask.contains(.fullScreen) || fullScreenTransition != nil
|
||||
}
|
||||
videoAspect = aspect
|
||||
// Keep the last usable ratio until loading supplies the video's metadata.
|
||||
if aspect.isFinite && aspect > 0 { videoAspect = aspect }
|
||||
wantsCompact = true
|
||||
wantsFullScreen = false
|
||||
reconcile()
|
||||
}
|
||||
|
||||
func restore(resumeFullScreen: Bool = true) {
|
||||
func restore(resumeFullScreen: Bool = true, animated: Bool = true) {
|
||||
guard sessionActive else { return }
|
||||
wantsCompact = false
|
||||
wantsFullScreen = resumeFullScreen && originatedFullScreen
|
||||
reconcile()
|
||||
if !animated { stopFrameAnimation() }
|
||||
reconcile(animated: animated)
|
||||
}
|
||||
|
||||
func updateAspect(_ aspect: Double) {
|
||||
guard aspect.isFinite, aspect > 0, aspect != videoAspect else { return }
|
||||
videoAspect = aspect
|
||||
guard isCompact, !isTransitioning, let window else { return }
|
||||
guard isCompact, !isTransitioning, let window = presentationWindow else { return }
|
||||
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() {
|
||||
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))
|
||||
if frame.origin != window.frame.origin { window.setFrameOrigin(frame.origin) }
|
||||
}
|
||||
|
||||
@objc private func windowNotification(_ notification: Notification) {
|
||||
switch notification.name {
|
||||
case NSWindow.didEndLiveResizeNotification:
|
||||
guard isCompact, !isTransitioning, let window = presentationWindow,
|
||||
window.frame.width.isFinite, window.frame.width > 0 else { return }
|
||||
defaults.set(Double(window.frame.width), forKey: Self.widthKey)
|
||||
case NSWindow.willEnterFullScreenNotification:
|
||||
fullScreenTransition = .entering
|
||||
setState(compact: isCompact, transitioning: true)
|
||||
@@ -137,6 +200,12 @@ final class CompactWindowController: NSObject {
|
||||
fullScreenTransition = nil
|
||||
reconcile()
|
||||
case NSWindow.willCloseNotification:
|
||||
if notification.object as? NSWindow === compactPanel {
|
||||
window?.close()
|
||||
return
|
||||
}
|
||||
stopFrameAnimation()
|
||||
if let window { removeCompactPanel(returningContentTo: window) }
|
||||
restoreDelegate()
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
window = nil
|
||||
@@ -176,6 +245,8 @@ final class CompactWindowController: NSObject {
|
||||
// NSObject forwarding returns untyped Any; accesses remain on AppKit's main thread.
|
||||
nonisolated(unsafe) weak var original: (any NSWindowDelegate)?
|
||||
private weak var owner: CompactWindowController?
|
||||
private var resizeStartSize: NSSize?
|
||||
private var resizeWidthDriven: Bool?
|
||||
|
||||
init(owner: CompactWindowController, original: (any NSWindowDelegate)?) {
|
||||
self.owner = owner
|
||||
@@ -192,6 +263,55 @@ final class CompactWindowController: NSObject {
|
||||
return responds ? original : nil
|
||||
}
|
||||
|
||||
func windowWillStartLiveResize(_ notification: Notification) {
|
||||
resizeStartSize = (notification.object as? NSWindow)?.frame.size
|
||||
resizeWidthDriven = nil
|
||||
original?.windowWillStartLiveResize?(notification)
|
||||
}
|
||||
|
||||
func windowDidEndLiveResize(_ notification: Notification) {
|
||||
resizeStartSize = nil
|
||||
resizeWidthDriven = nil
|
||||
original?.windowDidEndLiveResize?(notification)
|
||||
}
|
||||
|
||||
func windowWillResize(_ sender: NSWindow, to proposedSize: NSSize) -> NSSize {
|
||||
let proposed = original?.windowWillResize?(sender, to: proposedSize) ?? proposedSize
|
||||
guard let owner, !owner.isTransitioning,
|
||||
!sender.styleMask.contains(.fullScreen) else { return proposed }
|
||||
let layout: (aspect: Double, sidebarWidth: CGFloat)
|
||||
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.
|
||||
let chrome = owner.isCompact ? extraHeight
|
||||
: sender.frame.height - sender.contentRect(forFrameRect: sender.frame).height
|
||||
let start = resizeStartSize ?? sender.frame.size
|
||||
let widthChange = abs(proposed.width - start.width)
|
||||
let heightChange = abs(proposed.height - start.height) * layout.aspect
|
||||
// Latch the user's axis once. Comparing against each corrected frame
|
||||
// makes our own aspect adjustment look like a new drag on the other axis.
|
||||
if resizeStartSize != nil, resizeWidthDriven == nil,
|
||||
widthChange > 0 || heightChange > 0 {
|
||||
resizeWidthDriven = widthChange >= heightChange
|
||||
}
|
||||
let widthDriven = resizeWidthDriven ?? (widthChange >= heightChange)
|
||||
let minimumHeight = max(sender.minSize.height - chrome,
|
||||
(sender.minSize.width - layout.sidebarWidth) / layout.aspect)
|
||||
let height = max(minimumHeight, widthDriven
|
||||
? (proposed.width - layout.sidebarWidth) / layout.aspect
|
||||
: proposed.height - chrome)
|
||||
return NSSize(width: height * layout.aspect + layout.sidebarWidth, height: height + chrome)
|
||||
}
|
||||
|
||||
func windowDidFailToEnterFullScreen(_ window: NSWindow) {
|
||||
owner?.fullScreenFailed()
|
||||
original?.windowDidFailToEnterFullScreen?(window)
|
||||
@@ -204,8 +324,8 @@ final class CompactWindowController: NSObject {
|
||||
}
|
||||
|
||||
// A toggle changes the destination, never reverses an in-flight native animation.
|
||||
private func reconcile() {
|
||||
guard let window, fullScreenTransition == nil else { return }
|
||||
private func reconcile(animated: Bool = true) {
|
||||
guard let window, fullScreenTransition == nil, !frameAnimating else { return }
|
||||
guard sessionActive else {
|
||||
setState(compact: isCompact, transitioning: false)
|
||||
return
|
||||
@@ -214,9 +334,9 @@ final class CompactWindowController: NSObject {
|
||||
if window.styleMask.contains(.fullScreen) {
|
||||
toggleNativeFullScreen(window, transition: .exiting)
|
||||
} else if !isCompact {
|
||||
enterCompact(window)
|
||||
enterCompact(window, animated: animated)
|
||||
} else {
|
||||
if fittedAspect != videoAspect { fitCompactWindow(window, initial: false) }
|
||||
if fittedAspect != videoAspect, let panel = compactPanel { fitCompactWindow(panel, initial: false) }
|
||||
setState(compact: true, transitioning: false)
|
||||
}
|
||||
return
|
||||
@@ -229,10 +349,19 @@ final class CompactWindowController: NSObject {
|
||||
}
|
||||
return
|
||||
}
|
||||
if snapshot != nil {
|
||||
setState(compact: isCompact, transitioning: true)
|
||||
restoreSnapshot(to: window)
|
||||
setState(compact: false, transitioning: wantsFullScreen)
|
||||
if let snapshot {
|
||||
queueHeight = 0
|
||||
setState(compact: false, transitioning: true)
|
||||
let restoringWindow = presentationWindow ?? window
|
||||
restoringWindow.aspectRatio = .zero
|
||||
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 }
|
||||
self.restoreSnapshot(to: window)
|
||||
self.reconcile()
|
||||
}
|
||||
return
|
||||
}
|
||||
if wantsFullScreen {
|
||||
toggleNativeFullScreen(window, transition: .entering)
|
||||
@@ -247,32 +376,84 @@ final class CompactWindowController: NSObject {
|
||||
window.toggleFullScreen(nil)
|
||||
}
|
||||
|
||||
private func enterCompact(_ window: NSWindow) {
|
||||
setState(compact: false, transitioning: true)
|
||||
// After didExitFullScreen, this is the normal frame, not the fullscreen frame.
|
||||
snapshot = Snapshot(window)
|
||||
window.aspectRatio = .zero
|
||||
window.minSize = .zero
|
||||
window.contentMinSize = .zero
|
||||
// A borderless NSWindow cannot become key; retain native keyboard and resize behavior.
|
||||
window.styleMask.formUnion([.titled, .fullSizeContentView, .resizable])
|
||||
private func enterCompact(_ normalWindow: NSWindow, animated: Bool) {
|
||||
// A regular NSWindow cannot join another app's native fullscreen Space.
|
||||
// Keep the scene and renderer alive, but present their content in a nonactivating panel.
|
||||
guard let content = contentView else {
|
||||
finishSession()
|
||||
return
|
||||
}
|
||||
let snapshot = Snapshot(normalWindow, contentView: content)
|
||||
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.titlebarAppearsTransparent = true
|
||||
window.minSize = NSSize(width: 160, height: 90)
|
||||
window.contentMinSize = NSSize(width: 160, height: 90)
|
||||
var behavior = window.collectionBehavior
|
||||
behavior.subtract([.primary, .auxiliary, .fullScreenPrimary, .fullScreenNone, .moveToActiveSpace])
|
||||
behavior.formUnion([.canJoinAllSpaces, .canJoinAllApplications, .fullScreenAuxiliary])
|
||||
window.collectionBehavior = behavior
|
||||
window.titlebarSeparatorStyle = .none
|
||||
window.appearance = normalWindow.appearance
|
||||
window.isOpaque = normalWindow.isOpaque
|
||||
window.backgroundColor = normalWindow.backgroundColor
|
||||
window.hasShadow = normalWindow.hasShadow
|
||||
window.alphaValue = normalWindow.alphaValue
|
||||
window.ignoresMouseEvents = normalWindow.ignoresMouseEvents
|
||||
window.acceptsMouseMovedEvents = normalWindow.acceptsMouseMovedEvents
|
||||
window.level = .floating
|
||||
window.hidesOnDeactivate = false
|
||||
window.isMovableByWindowBackground = true
|
||||
fitCompactWindow(window, initial: true)
|
||||
setState(compact: true, transitioning: false)
|
||||
window.collectionBehavior = [.canJoinAllSpaces, .canJoinAllApplications, .fullScreenAuxiliary]
|
||||
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)
|
||||
}
|
||||
|
||||
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) {
|
||||
guard let snapshot else { return }
|
||||
let wasKey = compactPanel?.isKeyWindow ?? false
|
||||
removeCompactPanel(returningContentTo: window)
|
||||
window.aspectRatio = .zero
|
||||
window.minSize = .zero
|
||||
window.contentMinSize = .zero
|
||||
@@ -286,8 +467,18 @@ final class CompactWindowController: NSObject {
|
||||
window.minSize = snapshot.minSize
|
||||
window.contentMinSize = snapshot.contentMinSize
|
||||
window.setFrame(snapshot.frame, display: true)
|
||||
window.aspectRatio = snapshot.aspectRatio
|
||||
if snapshot.aspectRatio == .zero {
|
||||
window.resizeIncrements = snapshot.resizeIncrements
|
||||
} else {
|
||||
window.aspectRatio = snapshot.aspectRatio
|
||||
}
|
||||
if snapshot.isVisible {
|
||||
if wasKey { window.makeKeyAndOrderFront(nil) }
|
||||
else { window.orderFront(nil) }
|
||||
window.makeFirstResponder(snapshot.firstResponder)
|
||||
}
|
||||
self.snapshot = nil
|
||||
onChange?()
|
||||
}
|
||||
|
||||
private func finishSession() {
|
||||
@@ -298,6 +489,41 @@ final class CompactWindowController: NSObject {
|
||||
setState(compact: false, transitioning: false)
|
||||
}
|
||||
|
||||
private func animateFrame(_ frame: NSRect, of window: NSWindow, animated: Bool,
|
||||
completion: @escaping @MainActor () -> Void) {
|
||||
guard animated, !reduceMotion(), window.frame != frame else {
|
||||
window.setFrame(frame, display: true)
|
||||
completion()
|
||||
return
|
||||
}
|
||||
frameAnimationGeneration &+= 1
|
||||
let generation = frameAnimationGeneration
|
||||
frameAnimating = true
|
||||
NSAnimationContext.runAnimationGroup { context in
|
||||
context.duration = Self.transitionDuration
|
||||
context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
|
||||
window.animator().setFrame(frame, display: true)
|
||||
} completionHandler: { [weak self, weak window] in
|
||||
MainActor.assumeIsolated {
|
||||
guard let self, let window, self.presentationWindow === window, self.frameAnimating,
|
||||
self.frameAnimationGeneration == generation else { return }
|
||||
self.frameAnimating = false
|
||||
completion()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stopFrameAnimation() {
|
||||
guard frameAnimating else { return }
|
||||
frameAnimating = false
|
||||
frameAnimationGeneration &+= 1
|
||||
guard let window = presentationWindow else { return }
|
||||
NSAnimationContext.runAnimationGroup { context in
|
||||
context.duration = 0
|
||||
window.animator().setFrame(window.frame, display: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func setState(compact: Bool, transitioning: Bool) {
|
||||
guard isCompact != compact || isTransitioning != transitioning else { return }
|
||||
isCompact = compact
|
||||
@@ -309,26 +535,35 @@ final class CompactWindowController: NSObject {
|
||||
(window.screen ?? NSScreen.main)?.visibleFrame ?? window.frame
|
||||
}
|
||||
|
||||
private func fitCompactWindow(_ window: NSWindow, initial: Bool) {
|
||||
private func fitCompactWindow(_ window: NSWindow, initial: Bool, animated: Bool = false) {
|
||||
let visibleFrame = visibleFrame(for: window)
|
||||
let usable = Self.usableFrame(visibleFrame)
|
||||
var frame = Self.initialFrame(aspect: videoAspect, visibleFrame: visibleFrame)
|
||||
let aspect = videoAspect
|
||||
let savedWidth = initial ? defaults.double(forKey: Self.widthKey) : Double(window.frame.width)
|
||||
let width = savedWidth.isFinite && savedWidth > 0 ? CGFloat(savedWidth) : 320
|
||||
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 {
|
||||
frame.size = Self.compactSize(aspect: videoAspect, width: window.frame.width, available: usable.size)
|
||||
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.aspectRatio = .zero
|
||||
window.setFrame(frame, display: true)
|
||||
if Self.canFitAspect(videoAspect, available: usable.size) {
|
||||
window.aspectRatio = NSSize(width: videoAspect, height: 1)
|
||||
window.resizeIncrements = NSSize(width: 1, height: 1)
|
||||
animateFrame(frame, of: window, animated: animated) { [weak self, weak window] in
|
||||
guard let self, let window else { return }
|
||||
if self.queueHeight == 0, Self.canFitAspect(aspect, available: usable.size) {
|
||||
window.aspectRatio = NSSize(width: aspect, height: 1)
|
||||
}
|
||||
self.fittedAspect = aspect
|
||||
self.fittedVideoAspect = video.width / video.height
|
||||
if initial { self.reconcile() }
|
||||
}
|
||||
fittedAspect = videoAspect
|
||||
}
|
||||
|
||||
nonisolated static func initialFrame(aspect: Double, visibleFrame: NSRect) -> NSRect {
|
||||
nonisolated static func initialFrame(aspect: Double, visibleFrame: NSRect, width: CGFloat) -> NSRect {
|
||||
let usable = usableFrame(visibleFrame)
|
||||
let size = compactSize(aspect: aspect, width: 320, available: usable.size)
|
||||
let size = compactSize(aspect: aspect, width: width, available: usable.size)
|
||||
return NSRect(x: usable.maxX - size.width, y: usable.maxY - size.height, width: size.width, height: size.height)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
import Foundation
|
||||
|
||||
/// Immutable, reusable subtitles; times are acknowledged player positions, not wall-clock time.
|
||||
struct WebVTTDocument: Sendable {
|
||||
struct Cue: Sendable, Equatable {
|
||||
let start: Double
|
||||
let end: Double
|
||||
let text: String
|
||||
}
|
||||
|
||||
let cues: [Cue]
|
||||
private let maximumEnds: [Double]
|
||||
static let byteLimit = 4 * 1024 * 1024
|
||||
|
||||
private enum SubtitleError: LocalizedError {
|
||||
case request, transport, content
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .request: "Could not load subtitles. The subtitle URL or headers are invalid."
|
||||
case .transport: "Could not load subtitles. Check the connection or choose another subtitle track."
|
||||
case .content: "The subtitle resource is not valid WebVTT."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func load(url: String, headers: String) async throws -> WebVTTDocument {
|
||||
let data = try await loadData(url: url, headers: headers)
|
||||
// Providers sometimes disguise VTT resources as images: trust the explicit format and content.
|
||||
guard let text = String(data: data, encoding: .utf8) else { throw SubtitleError.content }
|
||||
return try parse(text)
|
||||
}
|
||||
|
||||
static func loadData(url: String, headers: String) async throws -> Data {
|
||||
let request = try request(url: url, headers: headers)
|
||||
let configuration = URLSessionConfiguration.ephemeral
|
||||
configuration.timeoutIntervalForRequest = 15
|
||||
configuration.timeoutIntervalForResource = 20
|
||||
configuration.httpCookieStorage = nil
|
||||
configuration.urlCredentialStorage = nil
|
||||
configuration.urlCache = nil
|
||||
let session = URLSession(configuration: configuration, delegate: SubtitleRedirects(request: request), delegateQueue: nil)
|
||||
defer { session.invalidateAndCancel() }
|
||||
let data: Data
|
||||
do {
|
||||
let (bytes, response) = try await session.bytes(for: request)
|
||||
guard let response = response as? HTTPURLResponse,
|
||||
(200...299).contains(response.statusCode), response.expectedContentLength <= Int64(byteLimit) else {
|
||||
throw SubtitleError.transport
|
||||
}
|
||||
var buffer = Data()
|
||||
if response.expectedContentLength > 0 { buffer.reserveCapacity(Int(response.expectedContentLength)) }
|
||||
for try await byte in bytes {
|
||||
guard buffer.count < byteLimit else { throw SubtitleError.transport }
|
||||
buffer.append(byte)
|
||||
}
|
||||
data = buffer
|
||||
} catch {
|
||||
if Task.isCancelled { throw CancellationError() }
|
||||
throw SubtitleError.transport
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
static func parse(_ text: String) throws -> WebVTTDocument {
|
||||
guard text.utf8.count <= byteLimit, !text.contains("\0") else { throw SubtitleError.content }
|
||||
var normalized = text
|
||||
if normalized.first == "\u{FEFF}" { normalized.removeFirst() }
|
||||
normalized = normalized.replacingOccurrences(of: "\r\n", with: "\n").replacingOccurrences(of: "\r", with: "\n")
|
||||
let lines = normalized.components(separatedBy: "\n")
|
||||
guard let header = lines.first,
|
||||
header == "WEBVTT" || header.hasPrefix("WEBVTT ") || header.hasPrefix("WEBVTT\t"),
|
||||
!header.contains("-->") else { throw SubtitleError.content }
|
||||
var index = 1
|
||||
// Header metadata ends at the first blank line. A cue without this separator is malformed.
|
||||
while index < lines.count && !lines[index].isEmpty {
|
||||
guard !lines[index].contains("-->") else { throw SubtitleError.content }
|
||||
index += 1
|
||||
}
|
||||
var cues: [Cue] = []
|
||||
var maximumEnds: [Double] = []
|
||||
while index < lines.count {
|
||||
while index < lines.count && lines[index].isEmpty { index += 1 }
|
||||
let beginning = index
|
||||
while index < lines.count && !lines[index].isEmpty { index += 1 }
|
||||
guard beginning < index else { break }
|
||||
let block = lines[beginning..<index]
|
||||
let first = lines[beginning]
|
||||
if first == "NOTE" || first.hasPrefix("NOTE ") || first.hasPrefix("NOTE\t") { continue }
|
||||
if first == "STYLE" || first == "REGION" {
|
||||
guard cues.isEmpty, !block.contains(where: { $0.contains("-->") }) else { throw SubtitleError.content }
|
||||
continue // ponytail: plain-text cues; add layout/styling when positional subtitles required.
|
||||
}
|
||||
var timingIndex = beginning
|
||||
if !first.contains("-->") { timingIndex += 1 } // Optional cue identifier.
|
||||
guard timingIndex < index else { throw SubtitleError.content }
|
||||
let timing = lines[timingIndex].split(whereSeparator: { $0 == " " || $0 == "\t" })
|
||||
guard timing.count >= 3, timing[1] == "-->",
|
||||
let start = timestamp(String(timing[0])), let end = timestamp(String(timing[2])),
|
||||
end >= start, start >= (cues.last?.start ?? 0) else { throw SubtitleError.content }
|
||||
// Equal timestamps produce an inactive cue, not a failure of the entire track.
|
||||
try validateSettings(timing.dropFirst(3))
|
||||
let payload = lines[(timingIndex + 1)..<index].joined(separator: "\n")
|
||||
guard !payload.contains("-->") else { throw SubtitleError.content }
|
||||
let cue = Cue(start: start, end: end, text: try plainText(payload))
|
||||
cues.append(cue)
|
||||
maximumEnds.append(max(end, maximumEnds.last ?? 0))
|
||||
}
|
||||
return WebVTTDocument(cues: cues, maximumEnds: maximumEnds)
|
||||
}
|
||||
|
||||
func text(at acknowledgedSeconds: Double) -> String {
|
||||
guard acknowledgedSeconds.isFinite, acknowledgedSeconds >= 0 else { return "" }
|
||||
// Binary-search starts, then inspect only the potentially overlapping interval. No cursor:
|
||||
// backward seeks and switching to an already-loaded track need no reset.
|
||||
var low = 0
|
||||
var high = cues.count
|
||||
while low < high {
|
||||
let middle = low + (high - low) / 2
|
||||
if cues[middle].start <= acknowledgedSeconds { low = middle + 1 } else { high = middle }
|
||||
}
|
||||
var active: [String] = []
|
||||
var index = low
|
||||
while index > 0 {
|
||||
index -= 1
|
||||
if maximumEnds[index] <= acknowledgedSeconds { break }
|
||||
if cues[index].end > acknowledgedSeconds && !cues[index].text.isEmpty { active.append(cues[index].text) }
|
||||
}
|
||||
return active.reversed().joined(separator: "\n")
|
||||
}
|
||||
|
||||
// Shared with the focused check: these are the exact request and redirect policies used above.
|
||||
static func request(url text: String, headers: String) throws -> URLRequest {
|
||||
guard text.utf8.count <= 32768,
|
||||
!text.unicodeScalars.contains(where: { CharacterSet.whitespacesAndNewlines.contains($0) || CharacterSet.controlCharacters.contains($0) }),
|
||||
let components = URLComponents(string: text),
|
||||
["http", "https"].contains(components.scheme?.lowercased() ?? ""),
|
||||
let host = components.host, !host.isEmpty,
|
||||
components.user == nil, components.password == nil, components.fragment == nil,
|
||||
components.port == nil || (1...65535).contains(components.port!),
|
||||
let url = components.url, headers.utf8.count <= 65536,
|
||||
headers.isEmpty || headers.hasSuffix("\r\n") else { throw SubtitleError.request }
|
||||
var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 15)
|
||||
request.httpShouldHandleCookies = false
|
||||
var names = Set<String>()
|
||||
let lines = headers.isEmpty ? [] : headers.components(separatedBy: "\r\n").dropLast().map { $0 }
|
||||
guard lines.count <= 64 else { throw SubtitleError.request }
|
||||
for line in lines {
|
||||
guard let colon = line.firstIndex(of: ":") else { throw SubtitleError.request }
|
||||
let name = String(line[..<colon])
|
||||
var value = String(line[line.index(after: colon)...])
|
||||
// ResolvedSource serializes exactly one separator space; the resource value is verbatim.
|
||||
if value.first == " " { value.removeFirst() }
|
||||
guard !name.isEmpty, name.utf8.count <= 256,
|
||||
name.utf8.allSatisfy({ (65...90).contains($0) || (97...122).contains($0) || (48...57).contains($0) || "!#$%&'*+-.^_`|~".utf8.contains($0) }),
|
||||
names.insert(name.lowercased()).inserted, value.utf8.count <= 16384,
|
||||
!value.unicodeScalars.contains(where: { ($0.value < 32 && $0.value != 9) || $0.value == 127 }) else { throw SubtitleError.request }
|
||||
request.setValue(value, forHTTPHeaderField: name)
|
||||
}
|
||||
return request
|
||||
}
|
||||
|
||||
static func permitsRedirect(from source: URL, to destination: URL) -> Bool {
|
||||
guard let a = URLComponents(url: source, resolvingAgainstBaseURL: false),
|
||||
let b = URLComponents(url: destination, resolvingAgainstBaseURL: false),
|
||||
let scheme = a.scheme?.lowercased(), ["http", "https"].contains(scheme),
|
||||
b.scheme?.lowercased() == scheme, a.host?.lowercased() == b.host?.lowercased(),
|
||||
(a.port ?? (scheme == "https" ? 443 : 80)) == (b.port ?? (scheme == "https" ? 443 : 80)),
|
||||
b.user == nil, b.password == nil, b.fragment == nil else { return false }
|
||||
// Reject rather than guess which resource-specific headers contain credentials.
|
||||
return true
|
||||
}
|
||||
|
||||
private static func timestamp(_ text: String) -> Double? {
|
||||
let fields = text.split(separator: ":", omittingEmptySubsequences: false)
|
||||
guard fields.count == 2 || fields.count == 3 else { return nil }
|
||||
let seconds = fields.last!.split(separator: ".", omittingEmptySubsequences: false)
|
||||
guard seconds.count == 2, seconds[0].utf8.count == 2, seconds[1].utf8.count == 3,
|
||||
fields.dropLast().allSatisfy({ $0.utf8.count >= 2 }),
|
||||
(fields.dropLast().map(String.init) + seconds.map(String.init)).allSatisfy({ $0.utf8.allSatisfy { (48...57).contains($0) } }),
|
||||
let second = Double(seconds[0]), second < 60,
|
||||
let milliseconds = Double(seconds[1]), let minute = Double(fields[fields.count - 2]),
|
||||
minute < 60, fields[fields.count - 2].utf8.count == 2 else { return nil }
|
||||
let hours = fields.count == 3 ? Double(fields[0]) : 0
|
||||
guard let hours else { return nil }
|
||||
let result = hours * 3600 + minute * 60 + second + milliseconds / 1000
|
||||
return result.isFinite ? result : nil
|
||||
}
|
||||
|
||||
private static func validateSettings(_ settings: ArraySlice<Substring>) throws {
|
||||
func percent(_ text: Substring) -> Bool {
|
||||
guard text.last == "%" else { return false }
|
||||
let number = text.dropLast()
|
||||
guard !number.isEmpty, number.utf8.allSatisfy({ (48...57).contains($0) || $0 == 46 }),
|
||||
let value = Double(number) else { return false }
|
||||
return (0...100).contains(value)
|
||||
}
|
||||
var names = Set<Substring>()
|
||||
for setting in settings {
|
||||
let pair = setting.split(separator: ":", omittingEmptySubsequences: false)
|
||||
guard pair.count == 2, !pair[1].isEmpty, names.insert(pair[0]).inserted else { throw SubtitleError.content }
|
||||
let values = pair[1].split(separator: ",", omittingEmptySubsequences: false)
|
||||
let valid: Bool
|
||||
switch pair[0] {
|
||||
case "vertical": valid = values.count == 1 && ["rl", "lr"].contains(pair[1])
|
||||
case "align": valid = values.count == 1 && ["start", "center", "end", "left", "right"].contains(pair[1])
|
||||
case "size": valid = values.count == 1 && percent(pair[1])
|
||||
case "region": valid = values.count == 1
|
||||
case "position":
|
||||
valid = (values.count == 1 || values.count == 2) && percent(values[0]) && (values.count == 1 || ["line-left", "center", "line-right", "auto"].contains(values[1]))
|
||||
case "line":
|
||||
let number = values[0].first == "-" ? values[0].dropFirst() : values[0][...]
|
||||
let integer = !number.isEmpty && number.utf8.allSatisfy { (48...57).contains($0) }
|
||||
valid = (values.count == 1 || values.count == 2) && (values[0] == "auto" || percent(values[0]) || integer) && (values.count == 1 || ["start", "center", "end"].contains(values[1]))
|
||||
default: valid = false
|
||||
}
|
||||
guard valid else { throw SubtitleError.content }
|
||||
}
|
||||
}
|
||||
|
||||
private static func plainText(_ payload: String) throws -> String {
|
||||
var result = ""
|
||||
var index = payload.startIndex
|
||||
let entities: [String: String] = ["amp": "&", "lt": "<", "gt": ">", "nbsp": "\u{A0}", "lrm": "\u{200E}", "rlm": "\u{200F}", "quot": "\"", "apos": "'"]
|
||||
while index < payload.endIndex {
|
||||
if payload[index] == "<" {
|
||||
guard let end = payload[index...].firstIndex(of: ">") else { throw SubtitleError.content }
|
||||
var tag = String(payload[payload.index(after: index)..<end])
|
||||
if tag.first == "/" { tag.removeFirst() }
|
||||
let name = tag.prefix { $0 != " " && $0 != "\t" && $0 != "." }
|
||||
guard ["b", "i", "u", "c", "v", "lang", "ruby", "rt"].contains(String(name)) || timestamp(tag) != nil else { throw SubtitleError.content }
|
||||
index = payload.index(after: end)
|
||||
} else if payload[index] == "&", let end = payload[index...].firstIndex(of: ";"), payload.distance(from: index, to: end) <= 16 {
|
||||
let entity = String(payload[payload.index(after: index)..<end])
|
||||
var decoded = entities[entity]
|
||||
if entity.hasPrefix("#") {
|
||||
let hexadecimal = entity.hasPrefix("#x") || entity.hasPrefix("#X")
|
||||
let digits = entity.dropFirst(hexadecimal ? 2 : 1)
|
||||
if let number = UInt32(digits, radix: hexadecimal ? 16 : 10), number != 0, let scalar = UnicodeScalar(number) { decoded = String(scalar) }
|
||||
}
|
||||
if let decoded {
|
||||
result += decoded
|
||||
index = payload.index(after: end)
|
||||
} else {
|
||||
result.append(payload[index])
|
||||
index = payload.index(after: index)
|
||||
}
|
||||
} else {
|
||||
result.append(payload[index])
|
||||
index = payload.index(after: index)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
private final class SubtitleRedirects: NSObject, URLSessionTaskDelegate, @unchecked Sendable {
|
||||
private let request: URLRequest
|
||||
private let lock = NSLock()
|
||||
private var count = 0
|
||||
|
||||
init(request: URLRequest) { self.request = request }
|
||||
|
||||
func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse,
|
||||
newRequest: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) {
|
||||
lock.lock()
|
||||
count += 1
|
||||
let withinLimit = count <= 5
|
||||
lock.unlock()
|
||||
guard withinLimit, let source = request.url, let destination = newRequest.url,
|
||||
WebVTTDocument.permitsRedirect(from: source, to: destination) else {
|
||||
completionHandler(nil)
|
||||
return
|
||||
}
|
||||
var redirected = newRequest
|
||||
redirected.httpShouldHandleCookies = false
|
||||
for (name, value) in request.allHTTPHeaderFields ?? [:] { redirected.setValue(value, forHTTPHeaderField: name) }
|
||||
completionHandler(redirected)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import Foundation
|
||||
|
||||
struct PlaybackProgress {
|
||||
struct RecentMedia: Equatable, Identifiable {
|
||||
let url: URL
|
||||
let title: String
|
||||
var id: URL { url }
|
||||
}
|
||||
|
||||
private let defaults: UserDefaults
|
||||
|
||||
init(defaults: UserDefaults = .standard) {
|
||||
@@ -28,6 +34,22 @@ struct PlaybackProgress {
|
||||
defaults.set(position, forKey: key)
|
||||
}
|
||||
|
||||
var recentMedia: [RecentMedia] {
|
||||
(defaults.array(forKey: "recentMedia") as? [[String: String]] ?? []).compactMap {
|
||||
guard let text = $0["url"], let url = URL(string: text), let title = $0["title"] else { return nil }
|
||||
return RecentMedia(url: url, title: title)
|
||||
}
|
||||
}
|
||||
|
||||
func recordRecent(_ url: URL, title: String) {
|
||||
let url = url.isFileURL ? url.standardizedFileURL.resolvingSymlinksInPath() : url
|
||||
var entries = recentMedia
|
||||
entries.removeAll { $0.url == url }
|
||||
entries.insert(RecentMedia(url: url, title: title), at: 0)
|
||||
defaults.set(entries.prefix(6).map { ["url": $0.url.absoluteString, "title": $0.title] },
|
||||
forKey: "recentMedia")
|
||||
}
|
||||
|
||||
private func key(for url: URL) -> String {
|
||||
// YouTube callers supply the canonical webpage URL, never an expiring media URL.
|
||||
return "playbackPosition:" + (url.isFileURL ? url.standardizedFileURL.resolvingSymlinksInPath().absoluteString : url.absoluteString)
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import Foundation
|
||||
|
||||
struct PlaybackQueue {
|
||||
struct Item: Identifiable {
|
||||
let id = UUID()
|
||||
let url: URL
|
||||
var label: String? = nil
|
||||
var metadataAttempted = false
|
||||
var displayName: String { label ?? url.lastPathComponent }
|
||||
}
|
||||
|
||||
private(set) var items: [Item] = []
|
||||
private(set) var currentID: UUID?
|
||||
var currentItem: Item? { items.first { $0.id == currentID } }
|
||||
var currentURL: URL? { currentItem?.url }
|
||||
private var currentIndex: Int? { items.firstIndex { $0.id == currentID } }
|
||||
var canPrevious: Bool { currentIndex.map { $0 > 0 } ?? false }
|
||||
var canNext: Bool { currentIndex.map { $0 + 1 < items.count } ?? false }
|
||||
|
||||
mutating func replace(_ urls: [URL]) {
|
||||
items = sortedFiles(urls).map { Item(url: $0) }
|
||||
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]) {
|
||||
items.append(contentsOf: sortedFiles(urls).map { Item(url: $0) })
|
||||
}
|
||||
|
||||
mutating func select(_ id: UUID) {
|
||||
guard items.contains(where: { $0.id == id }) else { return }
|
||||
currentID = id
|
||||
}
|
||||
|
||||
mutating func advance(_ offset: Int) -> URL? {
|
||||
guard let index = currentIndex else { return nil }
|
||||
let (target, overflow) = index.addingReportingOverflow(offset)
|
||||
guard !overflow, items.indices.contains(target) else { return nil }
|
||||
currentID = items[target].id
|
||||
return items[target].url
|
||||
}
|
||||
|
||||
mutating func remove(_ id: UUID) -> URL? {
|
||||
guard let index = items.firstIndex(where: { $0.id == id }) else { return nil }
|
||||
let wasCurrent = currentID == id
|
||||
items.remove(at: index)
|
||||
guard wasCurrent else { return nil }
|
||||
currentID = items.indices.contains(index) ? items[index].id : nil
|
||||
return currentURL
|
||||
}
|
||||
|
||||
mutating func move(fromOffsets offsets: IndexSet, toOffset destination: Int) {
|
||||
guard !offsets.isEmpty, offsets.allSatisfy(items.indices.contains),
|
||||
(0...items.count).contains(destination) else { return }
|
||||
let moving = offsets.map { items[$0] }
|
||||
for index in offsets.reversed() { items.remove(at: index) }
|
||||
let insertion = destination - offsets.filter { $0 < destination }.count
|
||||
items.insert(contentsOf: moving, at: insertion)
|
||||
}
|
||||
|
||||
private func sortedFiles(_ urls: [URL]) -> [URL] {
|
||||
urls.filter(\.isFileURL).sorted {
|
||||
let order = $0.lastPathComponent.localizedStandardCompare($1.lastPathComponent)
|
||||
return order == .orderedSame ? $0.path < $1.path : order == .orderedAscending
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Geometry stays in the unshifted content coordinate space throughout a gesture.
|
||||
struct PlaybackQueueDrag {
|
||||
let source: Int
|
||||
let frames: [CGRect]
|
||||
let initialScrollOffset: CGFloat
|
||||
private(set) var scrollOffset: CGFloat
|
||||
private(set) var translation: CGFloat = 0
|
||||
private(set) var destination: Int
|
||||
var pointerY: CGFloat = 0
|
||||
|
||||
init(source: Int, frames: [CGRect], scrollOffset: CGFloat) {
|
||||
self.source = source
|
||||
self.frames = frames
|
||||
initialScrollOffset = scrollOffset
|
||||
self.scrollOffset = scrollOffset
|
||||
destination = source
|
||||
}
|
||||
|
||||
var offset: CGFloat { translation + scrollOffset - initialScrollOffset }
|
||||
|
||||
func center(at index: Int) -> CGFloat {
|
||||
if index < source { return frames[index].minY + frames[source].height / 2 }
|
||||
if index > source { return frames[index].maxY - frames[source].height / 2 }
|
||||
return frames[source].midY
|
||||
}
|
||||
|
||||
mutating func update(translation: CGFloat, scrollOffset: CGFloat) {
|
||||
self.translation = translation
|
||||
self.scrollOffset = scrollOffset
|
||||
let centerY = frames[source].midY + offset
|
||||
destination = frames.indices.min { abs(center(at: $0) - centerY) < abs(center(at: $1) - centerY) }!
|
||||
}
|
||||
|
||||
var insertionOffset: Int { destination > source ? destination + 1 : destination }
|
||||
|
||||
func offset(at index: Int) -> CGFloat {
|
||||
if index == source { return offset }
|
||||
let target = destination
|
||||
if source < index && index <= target { return -frames[source].height }
|
||||
if target <= index && index < source { return frames[source].height }
|
||||
return 0
|
||||
}
|
||||
}
|
||||
+127
-14
@@ -64,6 +64,20 @@ extension KeyChord {
|
||||
if modifiers.contains(.shift) { flags.insert(.shift) }
|
||||
return KeyboardShortcut(equivalent, modifiers: flags)
|
||||
}
|
||||
|
||||
private static let displayNames = [
|
||||
"space": "Space", "escape": "⎋", "return": "↩", "tab": "⇥",
|
||||
"backspace": "⌫", "delete": "⌦", "left": "←", "right": "→",
|
||||
"up": "↑", "down": "↓", "home": "↖", "end": "↘",
|
||||
"pageup": "⇞", "pagedown": "⇟"
|
||||
]
|
||||
|
||||
var displayText: String {
|
||||
return (modifiers.contains(.control) ? "⌃" : "") +
|
||||
(modifiers.contains(.option) ? "⌥" : "") +
|
||||
(modifiers.contains(.shift) ? "⇧" : "") +
|
||||
(modifiers.contains(.command) ? "⌘" : "") + (Self.displayNames[key] ?? key.uppercased())
|
||||
}
|
||||
}
|
||||
|
||||
extension PlayerModel {
|
||||
@@ -79,11 +93,18 @@ extension PlayerModel {
|
||||
}
|
||||
|
||||
private func handleKeyEvent(_ event: NSEvent, in window: NSWindow, chord: KeyChord) -> Bool {
|
||||
guard event.window === window, NSApp.modalWindow == nil, window.attachedSheet == nil,
|
||||
!(window.firstResponder is NSTextView), !(window.firstResponder is NSTextField) else { return false }
|
||||
// Preserve focus traversal, control activation/navigation and VoiceOver's own chords.
|
||||
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) {
|
||||
return perform(.openStream)
|
||||
}
|
||||
let responder = window.firstResponder
|
||||
if let text = responder as? NSTextView, text.isEditable || text.isSelectable { return false }
|
||||
if let text = responder as? NSTextField, text.isEditable || text.isSelectable { return false }
|
||||
// Lists and inert labels are playback surfaces, not text entry or activation controls.
|
||||
// Keep deliberate button/slider focus, traversal and VoiceOver's own chords native.
|
||||
if chord.key == "tab" && chord.modifiers.subtracting(.shift).isEmpty { return false }
|
||||
if window.firstResponder is NSControl,
|
||||
if responder is NSControl, !(responder is NSTableView), !(responder is NSTextField),
|
||||
chord.modifiers.subtracting(.shift).isEmpty,
|
||||
Self.nativeNavigationKeys.contains(chord.key) {
|
||||
return false
|
||||
@@ -94,6 +115,11 @@ extension PlayerModel {
|
||||
}
|
||||
|
||||
func performMenuAction(_ action: PlayerAction) {
|
||||
// Native Command-Quit remains available while a text field owns focus.
|
||||
if action == .quit, NSApp.currentEvent?.modifierFlags.contains(.command) == true {
|
||||
perform(action)
|
||||
return
|
||||
}
|
||||
if let event = NSApp.currentEvent, event.type == .keyDown {
|
||||
if event.isARepeat && !action.allowsRepeat { return }
|
||||
guard let chord = KeyChord.from(event),
|
||||
@@ -102,7 +128,7 @@ extension PlayerModel {
|
||||
perform(action)
|
||||
return
|
||||
}
|
||||
if let window = event.window ?? NSApp.keyWindow,
|
||||
if let window = window ?? event.window ?? NSApp.keyWindow,
|
||||
!handleKeyEvent(event, in: window, chord: chord),
|
||||
let responder = window.firstResponder as? NSView {
|
||||
if !responder.performKeyEquivalent(with: event) { responder.keyDown(with: event) }
|
||||
@@ -146,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
|
||||
struct PlayerCommands: Commands {
|
||||
@ObservedObject var model: PlayerModel
|
||||
@FocusedObject private var focusedModel: PlayerModel?
|
||||
|
||||
var body: some Commands {
|
||||
// The player has one scene; app-level Open/Quit still work before it becomes focused.
|
||||
let model = focusedModel ?? PlayerModel.shared
|
||||
let hasFocusedScene = focusedModel != nil
|
||||
// Nonactivating compact panels need not provide SwiftUI's scene focus.
|
||||
let hasFocusedScene = focusedModel != nil || model.window?.isKeyWindow == true
|
||||
let playback = model.playbackAction
|
||||
let mute = model.muteAction
|
||||
let fullscreen = model.fullscreenAction
|
||||
@@ -162,31 +260,40 @@ struct PlayerCommands: Commands {
|
||||
let bufferInfo = model.bufferInfoAction
|
||||
let snapping = model.snappingAction
|
||||
let automaticQuality = model.automaticQualityAction
|
||||
let quitKeys = model.keyBindings.shortcuts(for: .quit)
|
||||
|
||||
CommandGroup(replacing: .appTermination) {
|
||||
Button("Quit terebi") { model.performMenuAction(.quit) }
|
||||
.keyboardShortcut(model.keyBindings.shortcuts(for: .quit).first?.keyboardShortcut)
|
||||
.keyboardShortcut((quitKeys.first { $0.modifiers.contains(.command) } ?? quitKeys.first)?.keyboardShortcut)
|
||||
.disabled(!model.canPerform(.quit))
|
||||
}
|
||||
CommandGroup(replacing: .newItem) {
|
||||
Button("Open File…") { model.performMenuAction(.openFile) }
|
||||
.keyboardShortcut(model.keyBindings.shortcuts(for: .openFile).first?.keyboardShortcut)
|
||||
.disabled(!model.canPerform(.openFile))
|
||||
Button("Open YouTube URL…") { model.performMenuAction(.openYoutube) }
|
||||
.keyboardShortcut(model.keyBindings.shortcuts(for: .openYoutube).first?.keyboardShortcut)
|
||||
.disabled(!model.canPerform(.openYoutube))
|
||||
Button("Add to Queue…", action: model.addQueuePanel)
|
||||
.disabled(!model.canOpen)
|
||||
Button("Open Stream…") { model.performMenuAction(.openStream) }
|
||||
.keyboardShortcut(model.keyBindings.shortcuts(for: .openStream).first?.keyboardShortcut)
|
||||
.disabled(!model.canPerform(.openStream))
|
||||
Button("Close File") { model.performMenuAction(.closeFile) }
|
||||
.keyboardShortcut(model.keyBindings.shortcuts(for: .closeFile).first?.keyboardShortcut)
|
||||
.disabled(!hasFocusedScene || !model.canPerform(.closeFile))
|
||||
}
|
||||
CommandGroup(replacing: .saveItem) {
|
||||
Button("Close Window") { NSApp.keyWindow?.performClose(nil) }
|
||||
.disabled(focusedModel == nil)
|
||||
Button("Close Window") { model.window?.performClose(nil) }
|
||||
.disabled(!hasFocusedScene)
|
||||
}
|
||||
CommandMenu("Playback") {
|
||||
CastDeviceMenu(model: model, icon: false)
|
||||
Divider()
|
||||
Button(model.effectivePlaying ? "Pause" : "Play") { model.performMenuAction(playback) }
|
||||
.keyboardShortcut(model.keyBindings.shortcuts(for: playback).first?.keyboardShortcut)
|
||||
.disabled(!hasFocusedScene || !model.canPerform(playback))
|
||||
Button("Previous Queue Item") { model.advanceQueue(-1) }
|
||||
.disabled(!hasFocusedScene || !model.canPreviousQueueItem)
|
||||
Button("Next Queue Item") { model.advanceQueue(1) }
|
||||
.disabled(!hasFocusedScene || !model.canNextQueueItem)
|
||||
Button("Seek Back 5 Seconds") { model.performMenuAction(.seekBackward5s) }
|
||||
.keyboardShortcut(model.keyBindings.shortcuts(for: .seekBackward5s).first?.keyboardShortcut)
|
||||
.disabled(!hasFocusedScene || !model.canPerform(.seekBackward5s))
|
||||
@@ -241,6 +348,9 @@ struct PlayerCommands: Commands {
|
||||
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") {
|
||||
model.performMenuAction(bufferInfo)
|
||||
}
|
||||
@@ -248,6 +358,9 @@ struct PlayerCommands: Commands {
|
||||
.disabled(!hasFocusedScene || !model.canPerform(bufferInfo))
|
||||
}
|
||||
CommandGroup(replacing: .sidebar) {
|
||||
Button(model.queueVisible ? "Hide Queue" : "Show Queue") { model.performMenuAction(model.queueAction) }
|
||||
.keyboardShortcut(model.keyBindings.shortcuts(for: model.queueAction).first?.keyboardShortcut)
|
||||
.disabled(!hasFocusedScene || !model.canPerform(model.queueAction))
|
||||
Button(fullscreen == .exitFullscreen ? "Exit Full Screen" : "Enter Full Screen") {
|
||||
model.performMenuAction(fullscreen)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import Foundation
|
||||
import Darwin
|
||||
|
||||
struct ResolvedSubtitle: Decodable, Sendable {
|
||||
let url: String
|
||||
let format: String
|
||||
let language: String
|
||||
let label: String
|
||||
let isDefault: Bool
|
||||
// Extractor identity survives expiring URLs and distinguishes manual from automatic captions.
|
||||
var selectionID: String? = nil
|
||||
private let headerFields: [String: String]
|
||||
var proxy: MiruroProxy? = nil
|
||||
var headers: String {
|
||||
headerFields.sorted { $0.key < $1.key }.map { "\($0.key): \($0.value)\r\n" }.joined()
|
||||
}
|
||||
fileprivate var validatedHeaders: [String: String] { headerFields }
|
||||
|
||||
init(url: String, format: String, language: String, label: String, isDefault: Bool,
|
||||
headers: [String: String], proxy: MiruroProxy? = nil, selectionID: String? = nil) {
|
||||
self.url = url
|
||||
self.format = format
|
||||
self.language = language
|
||||
self.label = label
|
||||
self.isDefault = isDefault
|
||||
self.headerFields = headers
|
||||
self.proxy = proxy
|
||||
self.selectionID = selectionID
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case url, format, language, label
|
||||
case isDefault = "default"
|
||||
case headerFields = "headers"
|
||||
}
|
||||
}
|
||||
|
||||
// Optional episode-relative provider timings; playback validates against media duration.
|
||||
struct ProviderInterval: Sendable, Equatable {
|
||||
enum Kind: Sendable { case intro, outro }
|
||||
let kind: Kind
|
||||
let start: Double
|
||||
let end: Double
|
||||
}
|
||||
|
||||
struct ResolvedSource: Sendable {
|
||||
let url: String
|
||||
let headers: String
|
||||
let identity: String
|
||||
let title: String
|
||||
let subtitles: [ResolvedSubtitle]
|
||||
var intervals: [ProviderInterval] = []
|
||||
var requirements: MiruroPlaybackRequirements = .init()
|
||||
var playbackLifetime: (any Sendable)? = nil
|
||||
|
||||
func replacingSubtitles(_ subtitles: [ResolvedSubtitle]) -> ResolvedSource {
|
||||
ResolvedSource(url: url, headers: headers, identity: identity, title: title, subtitles: subtitles,
|
||||
intervals: intervals, requirements: requirements, playbackLifetime: playbackLifetime)
|
||||
}
|
||||
|
||||
struct Document: Decodable {
|
||||
struct Identity: Decodable {
|
||||
let provider: String
|
||||
let media_id: String
|
||||
let episode: Int
|
||||
}
|
||||
let version: Int
|
||||
let url: String
|
||||
let headers: [String: String]
|
||||
let identity: Identity
|
||||
let title: String?
|
||||
let subtitles: [ResolvedSubtitle]
|
||||
}
|
||||
|
||||
private struct InvalidSource: LocalizedError {
|
||||
var errorDescription: String? { "Invalid or unreadable resolved source file." }
|
||||
}
|
||||
|
||||
static func load(from file: URL) throws -> ResolvedSource {
|
||||
do {
|
||||
guard file.isFileURL else { throw InvalidSource() }
|
||||
let descriptor: Int32 = file.withUnsafeFileSystemRepresentation { path in
|
||||
guard let path else { return -1 }
|
||||
return Darwin.open(path, O_RDONLY | O_NONBLOCK | O_CLOEXEC)
|
||||
}
|
||||
guard descriptor >= 0 else { throw InvalidSource() }
|
||||
var info = stat()
|
||||
guard fstat(descriptor, &info) == 0, (info.st_mode & S_IFMT) == S_IFREG else {
|
||||
Darwin.close(descriptor)
|
||||
throw InvalidSource()
|
||||
}
|
||||
let handle = FileHandle(fileDescriptor: descriptor, closeOnDealloc: true)
|
||||
defer { try? handle.close() }
|
||||
let data = try handle.read(upToCount: 1_048_577) ?? Data()
|
||||
guard data.count <= 1_048_576 else { throw InvalidSource() }
|
||||
let document = try JSONDecoder().decode(Document.self, from: data)
|
||||
return try validated(document)
|
||||
} catch { throw InvalidSource() }
|
||||
}
|
||||
|
||||
static func validIdentity(mediaID: String, episode: Int) -> Bool {
|
||||
!mediaID.isEmpty && mediaID.utf8.count <= 512 &&
|
||||
mediaID.utf8.allSatisfy({ isIDByte($0) }) &&
|
||||
episode > 0 && episode <= Int(Int32.max)
|
||||
}
|
||||
|
||||
static func validAniListIdentity(mediaID: String, episode: Int) -> Bool {
|
||||
guard let id = Int32(mediaID), id > 0, String(id) == mediaID else { return false }
|
||||
return episode > 0 && episode <= Int(Int32.max)
|
||||
}
|
||||
|
||||
static func validated(_ document: Document) throws -> ResolvedSource {
|
||||
let id = document.identity
|
||||
guard document.version == 1,
|
||||
(id.provider == "miruro" && validIdentity(mediaID: id.media_id, episode: id.episode)) ||
|
||||
(id.provider == "anilist" && validAniListIdentity(mediaID: id.media_id, episode: id.episode)),
|
||||
validURL(document.url), validHeaders(document.headers),
|
||||
validText(document.title ?? "", limit: 4096),
|
||||
validSubtitles(document.subtitles) else { throw InvalidSource() }
|
||||
let headers = document.headers.sorted { $0.key < $1.key }
|
||||
.map { "\($0.key): \($0.value)\r\n" }.joined()
|
||||
return ResolvedSource(url: document.url, headers: headers,
|
||||
identity: "\(id.provider):\(id.media_id):\(id.episode)", title: document.title ?? "\(id.provider == "anilist" ? "AniList \(id.media_id)" : "Miruro") episode \(id.episode)",
|
||||
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 {
|
||||
(65...90).contains(byte) || (97...122).contains(byte) ||
|
||||
(48...57).contains(byte) || byte == 45 || byte == 95
|
||||
}
|
||||
|
||||
static func validText(_ text: String, limit: Int) -> Bool {
|
||||
text.utf8.count <= limit && !text.unicodeScalars.contains { $0.value < 32 || $0.value == 127 }
|
||||
}
|
||||
|
||||
static func validURL(_ text: String) -> Bool {
|
||||
guard !text.isEmpty, validText(text, limit: 32768),
|
||||
!text.contains(where: { $0.isWhitespace }),
|
||||
let components = URLComponents(string: text),
|
||||
components.scheme == "https" || components.scheme == "http",
|
||||
let host = components.host, !host.isEmpty,
|
||||
components.user == nil, components.password == nil,
|
||||
components.fragment == nil, components.url != nil else { return false }
|
||||
return components.port == nil || (1...65535).contains(components.port!)
|
||||
}
|
||||
|
||||
static func validHeaders(_ headers: [String: String]) -> Bool {
|
||||
guard headers.count <= 64 else { return false }
|
||||
var names = Set<String>()
|
||||
var size = 0
|
||||
for (name, value) in headers {
|
||||
guard !name.isEmpty, name.utf8.count <= 256,
|
||||
name.utf8.allSatisfy({ isIDByte($0) || "!#$%&'*+.^`|~".utf8.contains($0) }),
|
||||
names.insert(name.lowercased()).inserted,
|
||||
value.utf8.count <= 16384,
|
||||
!value.unicodeScalars.contains(where: { $0.value < 32 || $0.value == 127 }) else { return false }
|
||||
size += name.utf8.count + value.utf8.count + 4
|
||||
}
|
||||
return size <= 65536
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
import Foundation
|
||||
|
||||
private struct StreamResolutionError: LocalizedError {
|
||||
let message: String
|
||||
var errorDescription: String? { message }
|
||||
}
|
||||
|
||||
enum StreamURL: Sendable {
|
||||
case youtube(URL)
|
||||
case miruro(mediaID: String, episode: Int)
|
||||
case anilist(mediaID: String, episode: Int)
|
||||
|
||||
static func parse(_ text: String) throws -> StreamURL {
|
||||
let text = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let url = try YouTubeResolver.validateURL(text)
|
||||
if url.host?.lowercased() == "zokoanime.video" {
|
||||
let invalid = StreamResolutionError(message: "Enter https://zokoanime.video/stream/ani/<AniList ID>/<episode>/sub with canonical positive numbers.")
|
||||
guard let c = URLComponents(string: text), c.scheme == "https", c.user == nil, c.password == nil,
|
||||
c.port == nil || c.port == 443, c.query == nil, c.fragment == nil,
|
||||
c.percentEncodedPath == c.path,
|
||||
!text.contains(where: { $0.isWhitespace || $0 == "\\" }) else { throw invalid }
|
||||
let p = c.path.split(separator: "/", omittingEmptySubsequences: false)
|
||||
guard p.count == 6, p[0].isEmpty, p[1] == "stream", p[2] == "ani", p[5] == "sub",
|
||||
let episode = Int(p[4]), String(episode) == p[4],
|
||||
ResolvedSource.validAniListIdentity(mediaID: String(p[3]), episode: episode) else { throw invalid }
|
||||
return .anilist(mediaID: String(p[3]), episode: episode)
|
||||
}
|
||||
if url.host?.lowercased() != "barelystarted.miruro.tv" { return .youtube(url) }
|
||||
let invalid = StreamResolutionError(message: "Enter a Miruro episode URL from barelystarted.miruro.tv with an explicit positive episode number.")
|
||||
guard !text.unicodeScalars.contains(where: {
|
||||
CharacterSet.whitespacesAndNewlines.contains($0) || CharacterSet.controlCharacters.contains($0)
|
||||
}), let components = URLComponents(string: text),
|
||||
components.scheme?.lowercased() == "https",
|
||||
components.host?.lowercased() == "barelystarted.miruro.tv",
|
||||
components.user == nil, components.password == nil,
|
||||
components.port == nil || components.port == 443,
|
||||
components.fragment == nil,
|
||||
components.percentEncodedPath == components.path,
|
||||
components.url != nil else { throw invalid }
|
||||
let path = components.path.split(separator: "/", omittingEmptySubsequences: false)
|
||||
guard path.count == 4, path[0].isEmpty, path[1] == "watch",
|
||||
!path[3].isEmpty, path[3].utf8.count <= 512,
|
||||
path[3].utf8.allSatisfy({ isSegmentByte($0) }),
|
||||
let query = components.queryItems, query.count == 1, query[0].name == "ep",
|
||||
let number = query[0].value, let episode = Int(number),
|
||||
components.percentEncodedQuery == "ep=\(episode)",
|
||||
ResolvedSource.validIdentity(mediaID: String(path[2]), episode: episode) else { throw invalid }
|
||||
return .miruro(mediaID: String(path[2]), episode: episode)
|
||||
}
|
||||
|
||||
private static func isSegmentByte(_ byte: UInt8) -> Bool {
|
||||
(65...90).contains(byte) || (97...122).contains(byte) ||
|
||||
(48...57).contains(byte) || byte == 45 || byte == 95
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
static let bodyLimit = 2 * 1024 * 1024
|
||||
|
||||
static func resolve(mediaID: String, episode: Int) async throws -> MiruroSources {
|
||||
try Task.checkCancellation()
|
||||
guard ResolvedSource.validIdentity(mediaID: mediaID, episode: episode) else {
|
||||
throw StreamResolutionError(message: "Invalid Miruro episode identity.")
|
||||
}
|
||||
var components = URLComponents(string: "https://barelystarted.miruro.tv/api/sources")!
|
||||
components.queryItems = [URLQueryItem(name: "id", value: mediaID), URLQueryItem(name: "n", value: String(episode))]
|
||||
var request = URLRequest(url: components.url!)
|
||||
request.timeoutInterval = 25
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
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: MiruroRedirectPolicy(), delegateQueue: nil)
|
||||
defer { session.invalidateAndCancel() }
|
||||
do {
|
||||
let (bytes, response) = try await session.bytes(for: request)
|
||||
try validateResponse(response)
|
||||
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 StreamResolutionError(message: "Miruro source response exceeded 2 MiB.")
|
||||
}
|
||||
data.append(byte)
|
||||
}
|
||||
try Task.checkCancellation()
|
||||
return try decode(data, mediaID: mediaID, episode: episode)
|
||||
} catch {
|
||||
if Task.isCancelled || error is CancellationError || (error as? URLError)?.code == .cancelled {
|
||||
throw CancellationError()
|
||||
}
|
||||
if let error = error as? StreamResolutionError { throw error }
|
||||
throw StreamResolutionError(message: "Miruro request failed or timed out. No retry was attempted.")
|
||||
}
|
||||
}
|
||||
|
||||
static func validateResponse(_ response: URLResponse) throws {
|
||||
guard let response = response as? HTTPURLResponse else {
|
||||
throw StreamResolutionError(message: "Miruro returned an invalid HTTP response.")
|
||||
}
|
||||
guard response.statusCode == 200 else {
|
||||
throw StreamResolutionError(message: "Miruro HTTP \(response.statusCode). No retry was attempted.")
|
||||
}
|
||||
guard response.mimeType?.lowercased() == "application/json" else {
|
||||
throw StreamResolutionError(message: "Miruro returned an unexpected content type; protection pages are not supported.")
|
||||
}
|
||||
guard response.expectedContentLength <= Int64(bodyLimit) else {
|
||||
throw StreamResolutionError(message: "Miruro source response exceeded 2 MiB.")
|
||||
}
|
||||
}
|
||||
|
||||
static func decode(_ data: Data, mediaID: String, episode: Int) throws -> MiruroSources {
|
||||
do {
|
||||
guard data.count <= bodyLimit,
|
||||
ResolvedSource.validIdentity(mediaID: mediaID, episode: episode) else { throw InvalidProjection() }
|
||||
let response = try JSONDecoder().decode(Sources.self, from: data)
|
||||
guard response.episodeNumber == episode else { throw InvalidProjection() }
|
||||
var videos: [MiruroSources.Candidate] = []
|
||||
var captions: [MiruroSources.Captions] = []
|
||||
var videoIDs = Set<String>()
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Provider order is advisory and deduplicated; each provider retains all usable tracks.
|
||||
var seen = Set<String>()
|
||||
let providerOrder = ((response.providerOrder ?? []) + videos.map(\.provider) + captions.map(\.provider))
|
||||
.filter { seen.insert($0).inserted }
|
||||
func variants(_ name: String) -> [String] {
|
||||
var seen = Set<String>()
|
||||
return ((response.config[name]?.value?.variantOrder ?? []) + ["ssub", "sub", "dub"])
|
||||
.filter { seen.insert($0).inserted }
|
||||
}
|
||||
let orderedVideos = providerOrder.flatMap { name in
|
||||
variants(name).compactMap { variant in videos.first { $0.provider == name && $0.track == variant } }
|
||||
}
|
||||
let orderedCaptions = providerOrder.flatMap { name in
|
||||
variants(name).compactMap { variant in captions.first { $0.provider == name && $0.track == variant } }
|
||||
}
|
||||
guard !orderedVideos.isEmpty || !orderedCaptions.isEmpty else { throw InvalidProjection() }
|
||||
return MiruroSources(videos: orderedVideos, captions: orderedCaptions)
|
||||
} catch {
|
||||
if let error = error as? StreamResolutionError { throw error }
|
||||
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 {}
|
||||
// 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 {
|
||||
let episodeNumber: Int
|
||||
let tracks: [Entry<Track>]
|
||||
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 ProviderConfig: Decodable {
|
||||
let player: String
|
||||
let proxy: MiruroProxy?
|
||||
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 {
|
||||
struct Segments: Decodable { let enabled: Bool?; let origin: String? }
|
||||
struct Query: Decodable { let enabled: Bool?; let params: [String: String]? }
|
||||
let segments: Segments?
|
||||
let query: Query?
|
||||
}
|
||||
private struct Track: Decodable {
|
||||
let track: String
|
||||
let providers: [Entry<Provider>]
|
||||
}
|
||||
private struct Provider: Decodable {
|
||||
let provider: String
|
||||
let servers: [Entry<Server>]
|
||||
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 {
|
||||
let headers: [String: String]?
|
||||
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 {
|
||||
let url: String
|
||||
let format: String
|
||||
let embed: Embed?
|
||||
}
|
||||
private struct Subtitle: Decodable, Equatable {
|
||||
let file: String
|
||||
let format: String
|
||||
let language: String
|
||||
let label: String
|
||||
let `default`: Bool
|
||||
}
|
||||
}
|
||||
|
||||
private final class MiruroRedirectPolicy: NSObject, URLSessionTaskDelegate, @unchecked Sendable {
|
||||
func urlSession(_ session: URLSession, task: URLSessionTask,
|
||||
willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest,
|
||||
completionHandler: @escaping (URLRequest?) -> Void) {
|
||||
completionHandler(nil)
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,8 @@ final class SystemPlaybackControls {
|
||||
private let infoCenter = MPNowPlayingInfoCenter.default()
|
||||
private var targets: [(MPRemoteCommand, Any)] = []
|
||||
private var snapshot: Snapshot?
|
||||
private var activity: NSObjectProtocol?
|
||||
private var activityOptions: ProcessInfo.ActivityOptions = []
|
||||
|
||||
init(model: PlayerModel) {
|
||||
self.model = model
|
||||
@@ -75,6 +77,23 @@ final class SystemPlaybackControls {
|
||||
}
|
||||
|
||||
func update() {
|
||||
var options: ProcessInfo.ActivityOptions = []
|
||||
if let model, model.fileName != nil,
|
||||
model.state.status == PLAYER_PLAYING || model.castConnecting {
|
||||
options = .userInitiated
|
||||
if model.state.status == PLAYER_PLAYING, model.state.has_video,
|
||||
model.castDevice == nil || model.castConnecting {
|
||||
options.insert(.idleDisplaySleepDisabled)
|
||||
}
|
||||
}
|
||||
if options != activityOptions {
|
||||
// Acquire before releasing so a local/Cast handoff cannot briefly allow sleep.
|
||||
let next = options.isEmpty ? nil :
|
||||
ProcessInfo.processInfo.beginActivity(options: options, reason: "Playing media")
|
||||
if let activity { ProcessInfo.processInfo.endActivity(activity) }
|
||||
activity = next
|
||||
activityOptions = options
|
||||
}
|
||||
let ready = model?.fileName != nil && model?.canTogglePlayback == true
|
||||
let seekable = ready && model?.canSeek == true
|
||||
for (command, _) in targets {
|
||||
@@ -117,6 +136,9 @@ final class SystemPlaybackControls {
|
||||
}
|
||||
|
||||
func shutdown() {
|
||||
if let activity { ProcessInfo.processInfo.endActivity(activity) }
|
||||
activity = nil
|
||||
activityOptions = []
|
||||
for (command, target) in targets {
|
||||
command.removeTarget(target)
|
||||
command.isEnabled = false
|
||||
|
||||
+3096
-292
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,12 @@ struct YouTubeStream: Sendable {
|
||||
let headers: String
|
||||
}
|
||||
|
||||
struct YouTubeChapter: Sendable {
|
||||
let start: Double
|
||||
let end: Double
|
||||
let title: String?
|
||||
}
|
||||
|
||||
struct YouTubeVideo: Sendable {
|
||||
let title: String
|
||||
let video: YouTubeStream
|
||||
@@ -16,6 +22,28 @@ struct YouTubeVideo: Sendable {
|
||||
let webpageURL: URL
|
||||
let height: 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 {
|
||||
@@ -30,12 +58,13 @@ enum YouTubeResolver {
|
||||
let text = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty,
|
||||
!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),
|
||||
components.user == nil, components.password == nil,
|
||||
components.port == nil || components.port == (scheme == "https" ? 443 : 80),
|
||||
let host = components.host?.lowercased() else {
|
||||
throw YouTubeResolutionError(message: "Enter a valid HTTP or HTTPS YouTube video URL.")
|
||||
components.port == nil || (1...65535).contains(components.port!),
|
||||
let host = components.host?.lowercased(), !host.isEmpty,
|
||||
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 path = components.path.split(separator: "/", omittingEmptySubsequences: false)
|
||||
@@ -51,11 +80,12 @@ enum YouTubeResolver {
|
||||
id = nil
|
||||
}
|
||||
} else {
|
||||
throw YouTubeResolutionError(message: "Only YouTube video URLs are supported.")
|
||||
id = nil
|
||||
}
|
||||
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 {
|
||||
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)")!
|
||||
}
|
||||
@@ -68,7 +98,7 @@ enum YouTubeResolver {
|
||||
do {
|
||||
metadata = try JSONDecoder().decode(YouTubeMetadata.self, from: data)
|
||||
} catch {
|
||||
throw YouTubeResolutionError(message: "yt-dlp returned malformed video metadata. Update yt-dlp and try again.")
|
||||
throw YouTubeResolutionError(message: "The bundled yt-dlp returned malformed video metadata. Upgrade terebi or rebuild its bundled helpers and try again.")
|
||||
}
|
||||
guard metadata._type == nil || metadata._type == "video" else {
|
||||
throw YouTubeResolutionError(message: "yt-dlp did not resolve a single playable video.")
|
||||
@@ -112,7 +142,7 @@ enum YouTubeResolver {
|
||||
throw YouTubeResolutionError(message: "The requested \(height)p resolution is no longer available. Choose another quality.")
|
||||
}
|
||||
} 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 hasSeparateAudio = formats.contains {
|
||||
@@ -128,42 +158,125 @@ enum YouTubeResolver {
|
||||
let video = try stream(videoFormat, fallbackHeaders: metadata.http_headers)
|
||||
let audio = try audioFormat.map { try stream($0, fallbackHeaders: metadata.http_headers) }
|
||||
let title = metadata.title?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let isLive = metadata.is_live == true || metadata.live_status == "is_live"
|
||||
return YouTubeVideo(
|
||||
title: title?.isEmpty == false ? title! : "YouTube video",
|
||||
title: title?.isEmpty == false ? title! : "Video",
|
||||
video: video, audio: audio, duration: duration,
|
||||
isLive: metadata.is_live == true || metadata.live_status == "is_live",
|
||||
isLive: isLive,
|
||||
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 {
|
||||
try Task.checkCancellation()
|
||||
let webpageURL = try validateURL(url.absoluteString)
|
||||
guard height == nil || height! > 0 else {
|
||||
throw YouTubeResolutionError(message: "Choose a positive video resolution.")
|
||||
}
|
||||
let directories = searchDirectories()
|
||||
let executable = try executableURL.map { try executableFile($0, name: "yt-dlp") }
|
||||
?? findExecutable("yt-dlp", directories: directories)
|
||||
guard let executable else {
|
||||
throw YouTubeResolutionError(message: "YouTube playback requires yt-dlp. Install it with ‘brew install yt-dlp deno’, then try again.")
|
||||
}
|
||||
let (executable, directories) = try extractorExecutable(executableURL)
|
||||
let runtime: URL?
|
||||
if let runtimeURL {
|
||||
runtime = try executableFile(runtimeURL, name: "JavaScript runtime")
|
||||
} else {
|
||||
runtime = ["deno", "node", "qjs", "quickjs", "bun"].lazy.compactMap { findExecutable($0, directories: directories) }.first
|
||||
runtime = try bundledExecutable("deno")
|
||||
?? ["deno", "node", "qjs", "quickjs", "bun"].lazy.compactMap { findExecutable($0, directories: directories) }.first
|
||||
}
|
||||
guard let runtime,
|
||||
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. Install Deno with ‘brew install deno’. Supported runtimes are Deno, Node, QuickJS, and Bun.")
|
||||
throw YouTubeResolutionError(message: "Stream playback requires a supported JavaScript runtime. Rebuild or reinstall terebi with its bundled Deno helper.")
|
||||
}
|
||||
var environment = ProcessInfo.processInfo.environment
|
||||
environment["PATH"] = directories.joined(separator: ":")
|
||||
let transport = "[protocol~='^(https?|m3u8(_native)?)$']"
|
||||
let resolution = height.map { "[height=\($0)]" } ?? "[height<=1080]"
|
||||
let arguments = [
|
||||
let resolution = height.map { "[height=\($0)]" } ?? "[height<=?1080]"
|
||||
var arguments = [
|
||||
"--ignore-config", "--no-plugin-dirs", "--no-cache-dir", "--no-remote-components",
|
||||
"--no-playlist", "--simulate", "--skip-download", "--dump-single-json",
|
||||
"--no-progress", "--color", "never", "--no-check-formats",
|
||||
@@ -171,8 +284,12 @@ enum YouTubeResolver {
|
||||
"--socket-timeout", "20", "--extractor-retries", "0", "--retries", "0",
|
||||
"--format-sort", "res", "--format-sort-force",
|
||||
"--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()
|
||||
try Task.checkCancellation()
|
||||
if let error = dependencyError(String(decoding: output.stderr, as: UTF8.self).lowercased()) {
|
||||
@@ -183,6 +300,58 @@ enum YouTubeResolver {
|
||||
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 {
|
||||
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.")
|
||||
@@ -209,9 +378,18 @@ enum YouTubeResolver {
|
||||
return YouTubeStream(url: url.absoluteString, headers: headers)
|
||||
}
|
||||
|
||||
// A missing bundled helper is an installation error, never permission to use PATH.
|
||||
static func bundledExecutable(_ name: String, bundleURL: URL = Bundle.main.bundleURL) throws -> URL? {
|
||||
guard bundleURL.pathExtension == "app" else { return nil }
|
||||
let url = bundleURL.appendingPathComponent("Contents/MacOS").appendingPathComponent(name)
|
||||
guard isExecutableFile(url) else {
|
||||
throw YouTubeResolutionError(message: "This app's bundled \(name) helper is missing or is not executable. Rebuild or reinstall the complete terebi app.")
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
private static func searchDirectories() -> [String] {
|
||||
let paths = (ProcessInfo.processInfo.environment["PATH"] ?? "").split(separator: ":").map(String.init)
|
||||
+ ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"]
|
||||
var result: [String] = []
|
||||
for path in paths where path.hasPrefix("/") && !result.contains(path) { result.append(path) }
|
||||
return result
|
||||
@@ -240,10 +418,10 @@ enum YouTubeResolver {
|
||||
|
||||
fileprivate static func dependencyError(_ text: String) -> YouTubeResolutionError? {
|
||||
if text.contains("yt-dlp-ejs") || text.contains("challenge solving failed") || text.contains("no supported javascript") || text.contains("no javascript runtime") {
|
||||
return YouTubeResolutionError(message: "YouTube extraction requires current yt-dlp, its EJS components, and a supported JavaScript runtime. Run ‘brew upgrade yt-dlp deno’. For a pip installation, install ‘yt-dlp[default]’ 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") {
|
||||
return YouTubeResolutionError(message: "The installed yt-dlp is too old for YouTube streaming. Update it with ‘brew upgrade yt-dlp’.")
|
||||
return YouTubeResolutionError(message: "The bundled yt-dlp is too old for streaming. Upgrade terebi or rebuild its bundled helpers.")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -253,18 +431,26 @@ enum YouTubeResolver {
|
||||
if let error = dependencyError(text) { return error }
|
||||
// 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") {
|
||||
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") {
|
||||
return YouTubeResolutionError(message: "YouTube did not provide a supported SDR video and audio stream. Update yt-dlp 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") {
|
||||
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") {
|
||||
return YouTubeResolutionError(message: "Could not reach YouTube. Check your network connection and try again; update yt-dlp 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: "yt-dlp could not resolve this video (exit status \(status)). Update yt-dlp and Deno, 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,9 +469,86 @@ private struct YouTubeMetadata: Decodable {
|
||||
let formats: [YouTubeMetadata]?
|
||||
let http_headers: [String: String]?
|
||||
let requested_formats: [YouTubeMetadata]?
|
||||
let chapters: YouTubeChapters?
|
||||
let subtitles: YouTubeCaptions?
|
||||
let automatic_captions: YouTubeCaptions?
|
||||
|
||||
var hasVideo: Bool { vcodec.map { !$0.isEmpty && $0 != "none" } ?? false }
|
||||
var hasAudio: Bool { acodec.map { !$0.isEmpty && $0 != "none" } ?? false }
|
||||
// Generic extractors may omit codec hints; FFmpeg probes the actual streams.
|
||||
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 {
|
||||
@@ -295,7 +558,7 @@ private struct YouTubeExtractionOutput: Sendable {
|
||||
|
||||
// All Process, pipe, and continuation state belongs to this request's serial queue.
|
||||
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 stdoutPipe = Pipe()
|
||||
private let stderrPipe = Pipe()
|
||||
@@ -314,6 +577,9 @@ private final class YouTubeExtraction: @unchecked Sendable {
|
||||
private var failure: (any Error)?
|
||||
|
||||
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.arguments = arguments
|
||||
process.environment = environment
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
import Foundation
|
||||
|
||||
enum ZokoError: String, LocalizedError {
|
||||
case unavailable, transport, protection, malformed, mapping
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .unavailable: "ZokoAnime has no sub episode available for this catalog number. Catalog numbering does not confirm availability."
|
||||
case .transport: "ZokoAnime HTTPS request failed or timed out. No retry was attempted."
|
||||
case .protection: "ZokoAnime returned an access/protection page or refused the request."
|
||||
case .malformed: "ZokoAnime returned unsupported or malformed source data. Private source details are redacted."
|
||||
case .mapping: "AniList has no usable MAL mapping for this title. Source availability could not be checked."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Narrow policy for the demonstrated deployment, not a general cross-origin grant.
|
||||
enum ZokoResolver {
|
||||
static let bodyLimit = 2 * 1024 * 1024
|
||||
static let referer = "Referer: https://zokoanime.video/\r\n"
|
||||
private static let codecKey = Array("otaku-embed-v1".utf8)
|
||||
|
||||
static func validResource(_ url: URL, playlist: Bool = false) -> Bool {
|
||||
guard ResolvedSource.validURL(url.absoluteString), url.scheme == "https",
|
||||
url.port == nil || url.port == 443,
|
||||
let host = url.host, host == "hls.dramahot.top" || (!playlist && host == "hls1.drama1.cfd") else { return false }
|
||||
return true
|
||||
}
|
||||
|
||||
private final class NoRedirect: NSObject, URLSessionTaskDelegate, Sendable {
|
||||
func urlSession(_ session: URLSession, task: URLSessionTask,
|
||||
willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest,
|
||||
completionHandler: @escaping (URLRequest?) -> Void) { completionHandler(nil) }
|
||||
}
|
||||
|
||||
private static func fetch(_ request: URLRequest, allowMissingPage: Bool = false) async throws -> (Data, HTTPURLResponse) {
|
||||
let configuration = URLSessionConfiguration.ephemeral
|
||||
configuration.httpShouldSetCookies = false
|
||||
configuration.httpCookieStorage = nil
|
||||
configuration.urlCredentialStorage = nil
|
||||
configuration.urlCache = nil
|
||||
configuration.timeoutIntervalForRequest = 20
|
||||
configuration.timeoutIntervalForResource = 25
|
||||
let session = URLSession(configuration: configuration, delegate: NoRedirect(), delegateQueue: nil)
|
||||
defer { session.invalidateAndCancel() }
|
||||
do {
|
||||
let (bytes, response) = try await session.bytes(for: request)
|
||||
guard let response = response as? HTTPURLResponse, response.url == request.url else { throw ZokoError.transport }
|
||||
guard response.expectedContentLength <= bodyLimit else { throw ZokoError.malformed }
|
||||
if response.statusCode == 403 || response.statusCode == 429 || response.statusCode == 503 { throw ZokoError.protection }
|
||||
guard response.statusCode == 200 || (allowMissingPage && response.statusCode == 404) else { throw ZokoError.transport }
|
||||
var data = Data()
|
||||
if response.expectedContentLength > 0 { data.reserveCapacity(Int(response.expectedContentLength)) }
|
||||
for try await byte in bytes {
|
||||
try Task.checkCancellation()
|
||||
guard data.count < bodyLimit else { throw ZokoError.malformed }
|
||||
data.append(byte)
|
||||
}
|
||||
return (data, response)
|
||||
} catch {
|
||||
if Task.isCancelled || error is CancellationError || (error as? URLError)?.code == .cancelled { throw CancellationError() }
|
||||
if let error = error as? ZokoError { throw error }
|
||||
throw ZokoError.transport
|
||||
}
|
||||
}
|
||||
|
||||
static func resolve(
|
||||
mediaID: String, episode: Int, enrichTitle: Bool = true,
|
||||
fetchResponse: @Sendable (URLRequest) async throws -> (Data, HTTPURLResponse) = {
|
||||
try await fetch($0, allowMissingPage: $0.url?.host == "zokoanime.video")
|
||||
}
|
||||
) async throws -> ResolvedSource {
|
||||
guard ResolvedSource.validAniListIdentity(mediaID: mediaID, episode: episode) else { throw ZokoError.malformed }
|
||||
let metadata = try await catalog(mediaID: mediaID, includeTitle: enrichTitle, fetchResponse: fetchResponse)
|
||||
guard let malID = metadata.idMal else { throw ZokoError.mapping }
|
||||
var request = URLRequest(url: URL(string: "https://zokoanime.video/stream/mal/\(malID)/\(episode)/sub")!)
|
||||
request.timeoutInterval = 20
|
||||
request.setValue("text/html", forHTTPHeaderField: "Accept")
|
||||
let (data, response) = try await fetchResponse(request)
|
||||
guard response.mimeType == "text/html", let page = String(data: data, encoding: .utf8) else { throw ZokoError.malformed }
|
||||
let source = try parse(page: page, mediaID: mediaID, episode: episode)
|
||||
guard response.statusCode == 200 else { throw ZokoError.transport }
|
||||
guard enrichTitle, let name = metadata.name else { return source }
|
||||
try Task.checkCancellation()
|
||||
return ResolvedSource(url: source.url, headers: source.headers, identity: source.identity,
|
||||
title: "\(name) · Episode \(episode) (sub)", subtitles: source.subtitles,
|
||||
intervals: source.intervals, requirements: source.requirements, playbackLifetime: source.playbackLifetime)
|
||||
}
|
||||
|
||||
static func parse(page: String, mediaID: String, episode: Int) throws -> ResolvedSource {
|
||||
guard page.utf8.count <= bodyLimit, ResolvedSource.validAniListIdentity(mediaID: mediaID, episode: episode) else { throw ZokoError.malformed }
|
||||
let pattern = #"window\.__P\s*=\s*[\"']([A-Za-z0-9+/=]+)[\"']\s*;?\s*</script\s*>"#
|
||||
let regex = try NSRegularExpression(pattern: pattern)
|
||||
let matches = regex.matches(in: page, range: NSRange(page.startIndex..., in: page))
|
||||
guard matches.count == 1, let match = matches.first, let range = Range(match.range(at: 1), in: page) else {
|
||||
guard !page.contains("window.__P") else { throw ZokoError.malformed }
|
||||
let lower = page.lowercased()
|
||||
let visible = lower.replacingOccurrences(of: "<[^>]*>", with: " ", options: .regularExpression)
|
||||
if lower.contains("cf-chl-") || lower.contains("challenge-platform") || lower.contains("just a moment") ||
|
||||
lower.contains("captcha") || lower.contains("sorry, you have been blocked") { throw ZokoError.protection }
|
||||
if visible.contains("error"), visible.contains("404"), visible.contains("couldn't find anything to play here") { throw ZokoError.unavailable }
|
||||
throw ZokoError.malformed
|
||||
}
|
||||
guard var decoded = Data(base64Encoded: String(page[range])), !decoded.isEmpty else { throw ZokoError.malformed }
|
||||
decoded.withUnsafeMutableBytes { (buffer: UnsafeMutableRawBufferPointer) in
|
||||
let bytes = buffer.bindMemory(to: UInt8.self)
|
||||
for i in bytes.indices { bytes[i] ^= codecKey[i % codecKey.count] }
|
||||
}
|
||||
struct Config: Decodable {
|
||||
struct Subtitle: Decodable { let src: String; let lang: String; let label: String; let `default`: Bool? }
|
||||
struct Timing: Decodable {
|
||||
let start: Double
|
||||
let end: Double
|
||||
enum CodingKeys: CodingKey { case start, end }
|
||||
init(from decoder: any Decoder) throws {
|
||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
start = try values.decode(Double.self, forKey: .start)
|
||||
end = try values.decode(Double.self, forKey: .end)
|
||||
guard start.isFinite, end.isFinite, start >= 0, end > start else { throw ZokoError.malformed }
|
||||
}
|
||||
}
|
||||
struct Skip: Decodable {
|
||||
let intro: Timing?
|
||||
let outro: Timing?
|
||||
enum CodingKeys: CodingKey { case intro, outro }
|
||||
init(from decoder: any Decoder) throws {
|
||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
intro = try? values.decode(Timing.self, forKey: .intro)
|
||||
outro = try? values.decode(Timing.self, forKey: .outro)
|
||||
}
|
||||
}
|
||||
let src: String
|
||||
let subtitles: [Subtitle]
|
||||
let skip: Skip?
|
||||
enum CodingKeys: CodingKey { case src, subtitles, skip }
|
||||
init(from decoder: any Decoder) throws {
|
||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
src = try values.decode(String.self, forKey: .src)
|
||||
subtitles = try values.decode([Subtitle].self, forKey: .subtitles)
|
||||
skip = try? values.decode(Skip.self, forKey: .skip)
|
||||
}
|
||||
}
|
||||
let config: Config
|
||||
do { config = try JSONDecoder().decode(Config.self, from: decoded) }
|
||||
catch { throw ZokoError.malformed }
|
||||
guard let url = URL(string: config.src), validResource(url, playlist: true), url.path.hasSuffix(".m3u8"), config.subtitles.count <= 32 else { throw ZokoError.malformed }
|
||||
let subtitles = try config.subtitles.map { row -> ResolvedSubtitle in
|
||||
guard let url = URL(string: row.src), validResource(url, playlist: true), url.path.hasSuffix(".vtt") else { throw ZokoError.malformed }
|
||||
return ResolvedSubtitle(url: row.src, format: "vtt", language: row.lang, label: row.label,
|
||||
isDefault: row.default ?? false, headers: ["Referer": "https://zokoanime.video/"])
|
||||
}
|
||||
guard ResolvedSource.validSubtitles(subtitles) else { throw ZokoError.malformed }
|
||||
var intervals: [ProviderInterval] = []
|
||||
if let intro = config.skip?.intro {
|
||||
intervals.append(ProviderInterval(kind: .intro, start: intro.start, end: intro.end))
|
||||
}
|
||||
if let outro = config.skip?.outro {
|
||||
intervals.append(ProviderInterval(kind: .outro, start: outro.start, end: outro.end))
|
||||
}
|
||||
return ResolvedSource(url: config.src, headers: referer, identity: "anilist:\(mediaID):\(episode)",
|
||||
title: "AniList \(mediaID) · Episode \(episode) (sub)", subtitles: subtitles,
|
||||
intervals: intervals, requirements: .init(zoko: true))
|
||||
}
|
||||
|
||||
struct Catalog: Decodable {
|
||||
struct Title: Decodable { let english: String?; let romaji: String?; let native: String? }
|
||||
let id: Int
|
||||
let idMal: Int?
|
||||
let title: Title?
|
||||
var name: String? {
|
||||
guard let title else { return nil }
|
||||
func usable(_ name: String?) -> String? {
|
||||
guard let name, !name.isEmpty, ResolvedSource.validText(name, limit: 2048) else { return nil }
|
||||
return name
|
||||
}
|
||||
return usable(title.english) ?? usable(title.romaji) ?? usable(title.native)
|
||||
}
|
||||
}
|
||||
|
||||
static func decodeCatalog(_ data: Data, mediaID: String) throws -> Catalog {
|
||||
struct Response: Decodable {
|
||||
struct Body: Decodable {
|
||||
let media: Catalog?
|
||||
enum CodingKeys: String, CodingKey { case media = "Media" }
|
||||
}
|
||||
struct Problem: Decodable {}
|
||||
let data: Body?
|
||||
let errors: [Problem]?
|
||||
}
|
||||
let response: Response
|
||||
do { response = try JSONDecoder().decode(Response.self, from: data) }
|
||||
catch { throw ZokoError.malformed }
|
||||
guard response.errors?.isEmpty != false, let metadata = response.data?.media,
|
||||
String(metadata.id) == mediaID,
|
||||
metadata.idMal.map({ $0 > 0 && $0 <= Int(Int32.max) }) ?? true else { throw ZokoError.malformed }
|
||||
return metadata
|
||||
}
|
||||
|
||||
private static func catalog(
|
||||
mediaID: String, includeTitle: Bool,
|
||||
fetchResponse: @Sendable (URLRequest) async throws -> (Data, HTTPURLResponse)
|
||||
) async throws -> Catalog {
|
||||
var request = URLRequest(url: URL(string: "https://graphql.anilist.co/")!)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
let fields = includeTitle ? "id idMal title{english romaji native}" : "id idMal"
|
||||
request.httpBody = try JSONSerialization.data(withJSONObject: ["query": "query($id:Int!){Media(id:$id,type:ANIME){\(fields)}}", "variables": ["id": Int(mediaID)!]])
|
||||
let (data, response) = try await fetchResponse(request)
|
||||
guard response.statusCode == 200, response.mimeType == "application/json" else { throw ZokoError.malformed }
|
||||
return try decodeCatalog(data, mediaID: mediaID)
|
||||
}
|
||||
|
||||
static func title(mediaID: String, episode: Int) async throws -> String {
|
||||
guard ResolvedSource.validAniListIdentity(mediaID: mediaID, episode: episode) else { throw ZokoError.malformed }
|
||||
let metadata = try await catalog(mediaID: mediaID, includeTitle: true, fetchResponse: { try await fetch($0) })
|
||||
guard let name = metadata.name else { throw ZokoError.malformed }
|
||||
return "\(name) · Episode \(episode) (sub)"
|
||||
}
|
||||
}
|
||||
@@ -3,23 +3,37 @@ import Foundation
|
||||
enum PlayerCommand {
|
||||
case help
|
||||
case printDefaultConfig
|
||||
case checkSource(mediaID: String, episode: Int)
|
||||
case launch(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 = """
|
||||
Usage: terebi [--new-instance] [--no-config] [FILE | YOUTUBE_URL]
|
||||
terebi [--new-instance] [--no-config] --url YOUTUBE_URL
|
||||
terebi --new-instance --url YOUTUBE_URL --progress-file PATH
|
||||
Usage: terebi [--new-instance] [--no-config] [FILE | STREAM_URL]
|
||||
terebi [--new-instance] [--no-config] --url STREAM_URL
|
||||
terebi --new-instance --url STREAM_URL --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 --help
|
||||
terebi --default-config
|
||||
|
||||
Open one local media file or YouTube video and start playback, resuming saved
|
||||
video progress when available. With no arguments, open or activate the player.
|
||||
Open one local file, yt-dlp-supported video, Miruro/AniList episode or resolved source and start playback,
|
||||
resuming saved video progress. With no arguments, open or activate the player.
|
||||
Quote paths and URLs containing spaces or shell metacharacters.
|
||||
|
||||
--new-instance Launch a separate player, leaving existing playback untouched.
|
||||
--no-config Use built-in settings instead of config.toml on a fresh launch.
|
||||
--url URL Open a YouTube video 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.
|
||||
--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
|
||||
--new-instance. The GUI owns reporting after this CLI exits.
|
||||
-- Treat the following argument as a filename, even with a leading '-'.
|
||||
@@ -32,6 +46,16 @@ enum 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 progressFile: URL?
|
||||
var index = 0
|
||||
@@ -52,22 +76,39 @@ enum PlayerCommand {
|
||||
}
|
||||
var newInstance = 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" {
|
||||
guard !newInstance else { throw ArgumentError("--new-instance may only be supplied once.") }
|
||||
newInstance = true
|
||||
} else if option == "--enqueue" {
|
||||
guard !enqueue else { throw ArgumentError("--enqueue may only be supplied once.") }
|
||||
enqueue = true
|
||||
} else {
|
||||
guard !noConfig else { throw ArgumentError("--no-config may only be supplied once.") }
|
||||
noConfig = true
|
||||
}
|
||||
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 {
|
||||
guard progressFile == nil || newInstance else {
|
||||
throw ArgumentError("--progress-file requires --new-instance.")
|
||||
}
|
||||
return .launch(url, newInstance: newInstance, noConfig: noConfig, progressFile: progressFile)
|
||||
}
|
||||
func streamURL(_ text: String) throws -> URL {
|
||||
switch try StreamURL.parse(text) {
|
||||
case .youtube(let url): return url
|
||||
case .miruro, .anilist:
|
||||
guard let url = URL(string: text.trimmingCharacters(in: .whitespacesAndNewlines)) else {
|
||||
throw ArgumentError("Invalid stream URL.")
|
||||
}
|
||||
return url
|
||||
}
|
||||
}
|
||||
if inputs.isEmpty { return try launch(nil) }
|
||||
if inputs == ["--help"] || inputs == ["-h"] {
|
||||
guard progressFile == nil else { throw ArgumentError("--progress-file cannot be combined with --help.") }
|
||||
@@ -77,9 +118,32 @@ enum PlayerCommand {
|
||||
guard progressFile == nil else { throw ArgumentError("--progress-file cannot be combined with --default-config.") }
|
||||
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" {
|
||||
guard newInstance else { throw ArgumentError("--source-file requires --new-instance.") }
|
||||
guard inputs.count == 2, !inputs[1].isEmpty, !inputs[1].contains("\0"),
|
||||
!inputs[1].hasPrefix("--") else {
|
||||
throw ArgumentError("--source-file requires exactly one filesystem path.")
|
||||
}
|
||||
let base = directory.hasDirectoryPath ? directory :
|
||||
URL(fileURLWithPath: directory.path, isDirectory: true)
|
||||
let sourceFile = URL(fileURLWithPath: inputs[1], relativeTo: base).absoluteURL.standardizedFileURL
|
||||
_ = try ResolvedSource.load(from: sourceFile)
|
||||
return .launchSource(sourceFile: sourceFile, newInstance: newInstance,
|
||||
noConfig: noConfig, progressFile: progressFile)
|
||||
}
|
||||
if inputs.first == "--url" {
|
||||
guard inputs.count == 2 else { throw ArgumentError("--url requires exactly one YouTube URL.") }
|
||||
return try launch(YouTubeResolver.validateURL(inputs[1]))
|
||||
guard inputs.count == 2 else { throw ArgumentError("--url requires exactly one stream URL.") }
|
||||
return try launch(streamURL(inputs[1]))
|
||||
}
|
||||
let filenameOnly = inputs.first == "--"
|
||||
if filenameOnly {
|
||||
@@ -88,7 +152,7 @@ enum PlayerCommand {
|
||||
throw ArgumentError("Unknown option. Use -- before filenames beginning with '-'.")
|
||||
}
|
||||
guard inputs.count == 1, let input = inputs.first, !input.isEmpty else {
|
||||
throw ArgumentError("Expected exactly one file or YouTube URL.")
|
||||
throw ArgumentError("Expected exactly one file or stream URL.")
|
||||
}
|
||||
let file: URL
|
||||
if !filenameOnly && input.contains("://") {
|
||||
@@ -99,7 +163,7 @@ enum PlayerCommand {
|
||||
}
|
||||
file = url
|
||||
} else {
|
||||
return try launch(YouTubeResolver.validateURL(input))
|
||||
return try launch(streamURL(input))
|
||||
}
|
||||
} else {
|
||||
let base = directory.hasDirectoryPath ? directory : URL(fileURLWithPath: directory.path, isDirectory: true)
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import Foundation
|
||||
|
||||
// Deliberately separate from launch: no configuration, application, player or state APIs.
|
||||
enum SourceCheck {
|
||||
enum Status: String, Sendable, CaseIterable {
|
||||
case available, unavailable, failed
|
||||
|
||||
var json: String {
|
||||
"{\"version\":1,\"provider\":\"zokoanime\",\"track\":\"sub\",\"status\":\"\(rawValue)\"}"
|
||||
}
|
||||
}
|
||||
|
||||
static func run(
|
||||
mediaID: String, episode: Int, timeout: Duration = .seconds(15),
|
||||
resolve: @escaping @Sendable (String, Int) async throws -> ResolvedSource = {
|
||||
try await ZokoResolver.resolve(mediaID: $0, episode: $1, enrichTitle: false)
|
||||
},
|
||||
validate: @escaping @Sendable (ResolvedSource) async throws -> Void = {
|
||||
try await MiruroTransport.checkZokoRoot($0)
|
||||
}
|
||||
) async -> Status {
|
||||
await withTaskGroup(of: Status.self) { group in
|
||||
group.addTask {
|
||||
let source: ResolvedSource
|
||||
do {
|
||||
try Task.checkCancellation()
|
||||
source = try await resolve(mediaID, episode)
|
||||
try Task.checkCancellation()
|
||||
} catch ZokoError.unavailable {
|
||||
return Task.isCancelled ? .failed : .unavailable
|
||||
} catch {
|
||||
return .failed
|
||||
}
|
||||
do {
|
||||
try await validate(source)
|
||||
try Task.checkCancellation()
|
||||
return .available
|
||||
} catch {
|
||||
// A manifest 404, protection page or other resource failure is
|
||||
// not evidence of the provider's explicit missing-episode page.
|
||||
return .failed
|
||||
}
|
||||
}
|
||||
group.addTask {
|
||||
try? await Task.sleep(for: timeout)
|
||||
return .failed
|
||||
}
|
||||
let result = await group.next() ?? .failed
|
||||
group.cancelAll()
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,23 @@ enum TerebiCLI {
|
||||
print(PlayerCommand.usage)
|
||||
case .printDefaultConfig:
|
||||
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):
|
||||
await launch(url: url, sourceFile: nil, newInstance: newInstance, noConfig: noConfig, progressFile: progressFile)
|
||||
case .launchSource(let sourceFile, let newInstance, let noConfig, let 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?, queueFile: URL? = nil) async {
|
||||
if let progressFile {
|
||||
var existing = stat()
|
||||
guard lstat(progressFile.path, &existing) != 0 else {
|
||||
@@ -36,11 +52,22 @@ enum TerebiCLI {
|
||||
fail(error.localizedDescription, code: 78)
|
||||
}
|
||||
}
|
||||
guard let executable = Bundle.main.executableURL else {
|
||||
var size: UInt32 = 0
|
||||
_ = _NSGetExecutablePath(nil, &size)
|
||||
var path = [CChar](repeating: 0, count: Int(size))
|
||||
guard _NSGetExecutablePath(&path, &size) == 0 else {
|
||||
fail("Could not locate the CLI executable.", code: 69)
|
||||
}
|
||||
let app = executable.resolvingSymlinksInPath().deletingLastPathComponent()
|
||||
.appendingPathComponent("terebi.app", isDirectory: true)
|
||||
let executable = path.withUnsafeBufferPointer {
|
||||
URL(fileURLWithPath: String(cString: $0.baseAddress!)).resolvingSymlinksInPath()
|
||||
}
|
||||
let macOS = executable.deletingLastPathComponent()
|
||||
let contents = macOS.deletingLastPathComponent()
|
||||
let app = contents.deletingLastPathComponent()
|
||||
guard macOS.lastPathComponent == "MacOS", contents.lastPathComponent == "Contents",
|
||||
app.pathExtension == "app" else {
|
||||
fail("The CLI must be installed inside terebi.app. Rebuild and reinstall the bundled app.", code: 69)
|
||||
}
|
||||
if let error = FFmpegRuntime.error(for: app) {
|
||||
fail(error, code: 69)
|
||||
}
|
||||
@@ -51,6 +78,12 @@ enum TerebiCLI {
|
||||
if let progressFile {
|
||||
configuration.arguments += ["--progress-file", progressFile.path]
|
||||
}
|
||||
if let queueFile {
|
||||
configuration.arguments += ["--queue-file=" + queueFile.path]
|
||||
}
|
||||
if let sourceFile {
|
||||
configuration.arguments += ["--source-file", sourceFile.path]
|
||||
}
|
||||
if newInstance, let url {
|
||||
configuration.arguments += ["--open-media", url.absoluteString]
|
||||
}
|
||||
@@ -64,7 +97,6 @@ enum TerebiCLI {
|
||||
fail("Could not launch the player: \(error.localizedDescription)", code: 69)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func fail(_ message: String, code: Int32) -> Never {
|
||||
FileHandle.standardError.write(Data("terebi: \(message)\n".utf8))
|
||||
|
||||
@@ -83,7 +83,8 @@ public struct KeyBindings: Equatable, Sendable {
|
||||
.enterFullscreen: ["f", "ctrl+cmd+f"], .exitFullscreen: ["f", "ctrl+cmd+f", "escape"],
|
||||
.enterCompact: ["p", "ctrl+cmd+p"], .exitCompact: ["p", "ctrl+cmd+p"],
|
||||
.enableAlwaysOnTop: ["shift+p"], .disableAlwaysOnTop: ["shift+p"],
|
||||
.openFile: ["o", "cmd+o"], .openYoutube: ["cmd+shift+o"],
|
||||
.showQueue: ["cmd+shift+l"], .hideQueue: ["cmd+shift+l"],
|
||||
.openFile: ["o", "cmd+o"], .openStream: ["cmd+shift+o"],
|
||||
.closeFile: ["cmd+shift+w"], .quit: ["q", "cmd+q"]
|
||||
]
|
||||
return texts.mapValues { $0.map { try! KeyChord($0) } }
|
||||
|
||||
@@ -4,8 +4,9 @@ public enum PlayerAction: Hashable, Sendable {
|
||||
case stepFrameForward, stepFrameBackward
|
||||
case enterFullscreen, exitFullscreen, enterCompact, exitCompact
|
||||
case enableAlwaysOnTop, disableAlwaysOnTop
|
||||
case openFile, openYoutube, closeFile, quit
|
||||
case openFile, openStream, closeFile, quit
|
||||
case showBufferInfo, hideBufferInfo
|
||||
case showQueue, hideQueue
|
||||
case enableCompactSnapping, disableCompactSnapping
|
||||
case enableAutomaticQuality, disableAutomaticQuality
|
||||
case qualityDefault, quality(Int)
|
||||
@@ -19,8 +20,9 @@ public enum PlayerAction: Hashable, Sendable {
|
||||
("enter_fullscreen", .enterFullscreen), ("exit_fullscreen", .exitFullscreen),
|
||||
("enter_compact", .enterCompact), ("exit_compact", .exitCompact),
|
||||
("enable_always_on_top", .enableAlwaysOnTop), ("disable_always_on_top", .disableAlwaysOnTop),
|
||||
("open_file", .openFile), ("open_youtube", .openYoutube), ("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_queue", .showQueue), ("hide_queue", .hideQueue),
|
||||
("enable_compact_snapping", .enableCompactSnapping), ("disable_compact_snapping", .disableCompactSnapping),
|
||||
("enable_automatic_quality", .enableAutomaticQuality), ("disable_automatic_quality", .disableAutomaticQuality),
|
||||
("quality_default", .qualityDefault)
|
||||
@@ -60,6 +62,8 @@ public enum PlayerAction: Hashable, Sendable {
|
||||
case .disableAlwaysOnTop: return .enableAlwaysOnTop
|
||||
case .showBufferInfo: return .hideBufferInfo
|
||||
case .hideBufferInfo: return .showBufferInfo
|
||||
case .showQueue: return .hideQueue
|
||||
case .hideQueue: return .showQueue
|
||||
case .enableCompactSnapping: return .disableCompactSnapping
|
||||
case .disableCompactSnapping: return .enableCompactSnapping
|
||||
case .enableAutomaticQuality: return .disableAutomaticQuality
|
||||
|
||||
@@ -1,17 +1,35 @@
|
||||
import Foundation
|
||||
import TOMLDecoder
|
||||
|
||||
public enum CompactTitleProgress: String, Decodable, Sendable {
|
||||
case time, percentage, none
|
||||
}
|
||||
|
||||
public struct PlayerConfiguration: Equatable, Sendable {
|
||||
public var showBufferInfo: Bool
|
||||
public var automaticQuality: Bool
|
||||
public var sponsorBlock: Bool
|
||||
public var sponsorBlockAutoSkip: Bool
|
||||
public var openingEndingAutoSkip: Bool
|
||||
public var snapToEdges: Bool
|
||||
public var compactTitleProgress: CompactTitleProgress
|
||||
public var miruroVideoProvider: String
|
||||
public var miruroCaptionProvider: String
|
||||
public var keys: KeyBindings
|
||||
|
||||
public init(showBufferInfo: Bool = false, automaticQuality: Bool = false, snapToEdges: Bool = true,
|
||||
keys: KeyBindings = KeyBindings()) {
|
||||
public init(showBufferInfo: Bool = false, automaticQuality: Bool = false, sponsorBlock: Bool = false,
|
||||
sponsorBlockAutoSkip: Bool = false, openingEndingAutoSkip: Bool = false, snapToEdges: Bool = true,
|
||||
compactTitleProgress: CompactTitleProgress = .time, miruroVideoProvider: String = "",
|
||||
miruroCaptionProvider: String = "", keys: KeyBindings = KeyBindings()) {
|
||||
self.showBufferInfo = showBufferInfo
|
||||
self.automaticQuality = automaticQuality
|
||||
self.sponsorBlock = sponsorBlock
|
||||
self.sponsorBlockAutoSkip = sponsorBlockAutoSkip
|
||||
self.openingEndingAutoSkip = openingEndingAutoSkip
|
||||
self.snapToEdges = snapToEdges
|
||||
self.compactTitleProgress = compactTitleProgress
|
||||
self.miruroVideoProvider = miruroVideoProvider
|
||||
self.miruroCaptionProvider = miruroCaptionProvider
|
||||
self.keys = keys
|
||||
}
|
||||
|
||||
@@ -22,9 +40,16 @@ public struct PlayerConfiguration: Equatable, Sendable {
|
||||
var lines = [
|
||||
"[playback]",
|
||||
"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]",
|
||||
"buffer_info = \(defaults.showBufferInfo)",
|
||||
"compact_snap_to_edges = \(defaults.snapToEdges)",
|
||||
"compact_title_progress = \"\(defaults.compactTitleProgress.rawValue)\"",
|
||||
"", "[keys]"
|
||||
]
|
||||
for (name, action) in PlayerAction.namedActions {
|
||||
@@ -76,7 +101,13 @@ public struct PlayerConfiguration: Equatable, Sendable {
|
||||
let values = try TOMLDecoder(isLenient: false).decode(FileConfiguration.self, from: data)
|
||||
return Self(showBufferInfo: values.ui?.bufferInfo ?? 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,
|
||||
compactTitleProgress: values.ui?.compactTitleProgress ?? .time,
|
||||
miruroVideoProvider: values.miruro?.videoProvider ?? "",
|
||||
miruroCaptionProvider: values.miruro?.captionProvider ?? "",
|
||||
keys: try KeyBindings(overrides: values.keys?.overrides ?? [:]))
|
||||
} catch {
|
||||
throw ConfigurationError(message: "\(url.path): \(diagnostic(for: error, data: data))")
|
||||
@@ -135,10 +166,11 @@ public struct PlayerConfiguration: Equatable, Sendable {
|
||||
let playback: PlaybackConfiguration?
|
||||
let ui: UIConfiguration?
|
||||
let keys: KeysConfiguration?
|
||||
let miruro: MiruroConfiguration?
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
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")) {
|
||||
playback = try PlaybackConfiguration(container: container.nestedContainer(keyedBy: SettingKey.self, forKey: SettingKey("playback")))
|
||||
} else { playback = nil }
|
||||
@@ -148,26 +180,65 @@ public struct PlayerConfiguration: Equatable, Sendable {
|
||||
if container.contains(SettingKey("keys")) {
|
||||
keys = try KeysConfiguration(container: container.nestedContainer(keyedBy: SettingKey.self, forKey: SettingKey("keys")))
|
||||
} 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 {
|
||||
let automaticQuality: Bool?
|
||||
let sponsorBlock: Bool?
|
||||
let sponsorBlockAutoSkip: Bool?
|
||||
let openingEndingAutoSkip: Bool?
|
||||
|
||||
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"))
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
private struct UIConfiguration {
|
||||
let bufferInfo: Bool?
|
||||
let compactSnapToEdges: Bool?
|
||||
let compactTitleProgress: CompactTitleProgress?
|
||||
|
||||
init(container: KeyedDecodingContainer<SettingKey>) throws {
|
||||
try validate(container, allowed: ["buffer_info", "compact_snap_to_edges"])
|
||||
try validate(container, allowed: ["buffer_info", "compact_snap_to_edges", "compact_title_progress"])
|
||||
bufferInfo = try container.decodeIfPresent(Bool.self, forKey: SettingKey("buffer_info"))
|
||||
compactSnapToEdges = try container.decodeIfPresent(Bool.self, forKey: SettingKey("compact_snap_to_edges"))
|
||||
compactTitleProgress = try container.decodeIfPresent(CompactTitleProgress.self,
|
||||
forKey: SettingKey("compact_title_progress"))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,986 @@
|
||||
#include "cast_media.h"
|
||||
#include "network_policy.hpp"
|
||||
#include "network_input.hpp"
|
||||
#include "video_decoder.hpp"
|
||||
extern "C" {
|
||||
#include <libavformat/avformat.h>
|
||||
#include <libavcodec/avcodec.h>
|
||||
#include <libavutil/audio_fifo.h>
|
||||
#include <libavutil/opt.h>
|
||||
#include <libswresample/swresample.h>
|
||||
#include <libswscale/swscale.h>
|
||||
}
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <charconv>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <condition_variable>
|
||||
#include <cstdarg>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <deque>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
#include <netdb.h>
|
||||
#include <poll.h>
|
||||
#include <sys/socket.h>
|
||||
#include <unistd.h>
|
||||
#include <fcntl.h>
|
||||
#include <limits>
|
||||
|
||||
namespace {
|
||||
using Clock = std::chrono::steady_clock;
|
||||
constexpr double segmentSeconds = 6;
|
||||
constexpr size_t segmentLimit = 32 * 1024 * 1024;
|
||||
constexpr size_t windowSegments = 8;
|
||||
#ifdef CAST_MEDIA_CHECK
|
||||
std::atomic<uint64_t> hardwareVideoFrames{0}, softwareVideoFrames{0};
|
||||
#endif
|
||||
void check(int value) { if (value < 0) throw std::runtime_error("Cast media operation failed (private details redacted)."); }
|
||||
void require(bool value) { if (!value) throw std::runtime_error("Cast media is unavailable or unsupported (private details redacted)."); }
|
||||
void privateLog(void *, int level, const char *, va_list) {
|
||||
if (level > AV_LOG_WARNING || level > av_log_get_level()) return;
|
||||
std::fputs(level <= AV_LOG_ERROR ? "FFmpeg cast error (private details redacted)\n" :
|
||||
"FFmpeg cast warning (private details redacted)\n", stderr);
|
||||
}
|
||||
struct FrameDelete { void operator()(AVFrame *p) const { av_frame_free(&p); } };
|
||||
struct PacketDelete { void operator()(AVPacket *p) const { av_packet_free(&p); } };
|
||||
using Frame = std::unique_ptr<AVFrame, FrameDelete>;
|
||||
using Packet = std::unique_ptr<AVPacket, PacketDelete>;
|
||||
Frame frame() { auto p = Frame(av_frame_alloc()); require(bool(p)); return p; }
|
||||
Packet packet() { auto p = Packet(av_packet_alloc()); require(bool(p)); return p; }
|
||||
int64_t aacBoundary(double seconds) { return int64_t(std::ceil(seconds * 48000 / 1024)) * 1024; }
|
||||
double aacPreroll(double seconds) { return double(std::max<int64_t>(0, aacBoundary(seconds) - 12 * 1024)) / 48000; }
|
||||
struct Source {
|
||||
std::string video, videoHeaders, audio, audioHeaders;
|
||||
bool local, restricted, live;
|
||||
double duration;
|
||||
};
|
||||
struct Input {
|
||||
AVFormatContext *format = nullptr;
|
||||
std::atomic<bool> &cancel;
|
||||
std::string origin, headers;
|
||||
std::atomic<int64_t> deadline{0};
|
||||
std::atomic<int64_t> operationDeadline{std::numeric_limits<int64_t>::max()};
|
||||
bool network, restricted, live;
|
||||
std::atomic<uint64_t> epoch{0};
|
||||
std::mutex waitLock;
|
||||
std::condition_variable wake;
|
||||
bool positioned = false;
|
||||
std::unique_ptr<playback::NetworkInput> buffered;
|
||||
Input(std::atomic<bool> &stop, const std::string &url, const std::string &fields, bool local, bool restricted, bool live) :
|
||||
cancel(stop), headers(fields), network(!local), restricted(restricted), live(live) {
|
||||
origin = local ? "" : playback::networkOrigin(url.c_str());
|
||||
format = avformat_alloc_context(); require(format != nullptr);
|
||||
format->interrupt_callback = {interrupt, this};
|
||||
if (network) { format->opaque = this; format->io_open = openResource; }
|
||||
AVDictionary *options = nullptr;
|
||||
auto set = [&](const char *key, const char *value) {
|
||||
int result = av_dict_set(&options, key, value, 0);
|
||||
if (result < 0) { av_dict_free(&options); avformat_close_input(&format); check(result); }
|
||||
};
|
||||
set("protocol_whitelist", local ? "file" : "http,https,tcp,tls,crypto");
|
||||
set("rw_timeout", live ? "15000000" : "5000000");
|
||||
set("probesize", "5242880");
|
||||
set("analyzeduration", "5000000");
|
||||
if (network) {
|
||||
if (!headers.empty()) set("headers", headers.c_str());
|
||||
set("max_redirects", "0");
|
||||
set("http_persistent", "0");
|
||||
set("http_multiple", "0");
|
||||
set("extension_picky", "0");
|
||||
set("request_size", "10485760");
|
||||
}
|
||||
arm();
|
||||
int result = avformat_open_input(&format, url.c_str(), nullptr, &options);
|
||||
av_dict_free(&options);
|
||||
if (result >= 0) { arm(); result = avformat_find_stream_info(format, nullptr); }
|
||||
if (result < 0) { avformat_close_input(&format); check(result); }
|
||||
}
|
||||
~Input() {
|
||||
if (buffered) buffered->stop();
|
||||
else avformat_close_input(&format);
|
||||
}
|
||||
void arm() {
|
||||
const auto ioDeadline = std::chrono::duration_cast<std::chrono::nanoseconds>((Clock::now() + std::chrono::seconds(15)).time_since_epoch()).count();
|
||||
deadline = std::min<int64_t>(ioDeadline, operationDeadline.load());
|
||||
}
|
||||
bool interrupted() const {
|
||||
const auto now = std::chrono::duration_cast<std::chrono::nanoseconds>(Clock::now().time_since_epoch()).count();
|
||||
return cancel || now >= operationDeadline.load() || (!buffered && now >= deadline.load());
|
||||
}
|
||||
static int interrupt(void *opaque) {
|
||||
auto &in = *static_cast<Input *>(opaque);
|
||||
return in.interrupted() || (in.buffered && playback::NetworkInput::interrupt(in.buffered.get()));
|
||||
}
|
||||
static int openResource(AVFormatContext *context, AVIOContext **io, const char *url, int flags, AVDictionary **options) noexcept {
|
||||
try {
|
||||
auto &in = *static_cast<Input *>(context->opaque);
|
||||
const char *resource = std::strncmp(url, "crypto+", 7) == 0 || std::strncmp(url, "crypto:", 7) == 0 ? url + 7 : url;
|
||||
const auto resourceOrigin = playback::networkOrigin(resource);
|
||||
if (resourceOrigin.empty() || (in.restricted && resourceOrigin != in.origin)) return AVERROR(EACCES);
|
||||
int result = av_dict_set(options, "protocol_whitelist", "http,https,tcp,tls,crypto", 0);
|
||||
// Public YouTube HLS may cross CDN origins; never carry private headers across them.
|
||||
if (result >= 0) result = av_dict_set(options, "headers", resourceOrigin == in.origin && !in.headers.empty() ? in.headers.c_str() : nullptr, 0);
|
||||
if (result >= 0) result = av_dict_set(options, "max_redirects", "0", 0);
|
||||
if (result >= 0) result = av_dict_set(options, "rw_timeout", in.live ? "15000000" : "5000000", 0);
|
||||
if (result < 0) return result;
|
||||
return avio_open2(io, url, flags, &context->interrupt_callback, options);
|
||||
} catch (...) { return AVERROR(EACCES); }
|
||||
}
|
||||
double offset() const { return format->start_time == AV_NOPTS_VALUE ? 0 : double(format->start_time) / AV_TIME_BASE; }
|
||||
double duration() const { return format->duration == AV_NOPTS_VALUE ? 0 : double(format->duration) / AV_TIME_BASE; }
|
||||
void select(std::array<int, 2> indices, playback::StreamCacheBudget &budget) {
|
||||
for (unsigned i = 0; i < format->nb_streams; ++i)
|
||||
format->streams[i]->discard = int(i) == indices[0] || int(i) == indices[1] ?
|
||||
AVDISCARD_DEFAULT : AVDISCARD_ALL;
|
||||
if (!network || live) return;
|
||||
buffered = std::make_unique<playback::NetworkInput>(cancel, epoch, 0, wake, budget);
|
||||
buffered->format = format;
|
||||
buffered->start(indices, offset(), true);
|
||||
}
|
||||
void seek(double seconds, double audioTarget = NAN) {
|
||||
// Round forward so a sub-microsecond truncation cannot select the
|
||||
// previous GOP when a playlist boundary is an exact stream timestamp.
|
||||
const auto timestamp = int64_t(std::ceil((seconds + offset()) * AV_TIME_BASE));
|
||||
if (buffered) {
|
||||
// Probing retains the opening packets. Do not reopen their HLS media
|
||||
// resource just to establish the initial zero-position cursors.
|
||||
if (!positioned && seconds == 0) { positioned = true; return; }
|
||||
positioned = true;
|
||||
// A nearby forward request can outrun the asynchronous fetch frontier.
|
||||
// Let the existing read complete rather than interrupting and refetching it.
|
||||
while (buffered->readAheadPending(seconds, audioTarget)) {
|
||||
require(!interrupted());
|
||||
std::unique_lock lock(waitLock);
|
||||
wake.wait_for(lock, std::chrono::milliseconds(20));
|
||||
}
|
||||
buffered->seek(-1, timestamp, audioTarget);
|
||||
} else { arm(); check(av_seek_frame(format, -1, timestamp, AVSEEK_FLAG_BACKWARD)); }
|
||||
}
|
||||
int read(int slot, AVPacket *destination) {
|
||||
if (!buffered) return av_read_frame(format, destination);
|
||||
for (;;) {
|
||||
require(!interrupted());
|
||||
double end = 0;
|
||||
int result = buffered->take(slot, destination, end);
|
||||
if (result != 0) return result == 1 ? 0 : result;
|
||||
std::unique_lock lock(waitLock);
|
||||
wake.wait_for(lock, std::chrono::milliseconds(20));
|
||||
}
|
||||
}
|
||||
};
|
||||
bool remuxAudio(const AVCodecParameters &codec) {
|
||||
return codec.codec_id == AV_CODEC_ID_AAC && codec.ch_layout.nb_channels <= 2 &&
|
||||
(codec.profile == AV_PROFILE_AAC_LOW || codec.profile == AV_PROFILE_AAC_HE || codec.profile == AV_PROFILE_AAC_HE_V2);
|
||||
}
|
||||
bool remuxVideo(Input &input, AVStream &stream) {
|
||||
const auto &codec = *stream.codecpar;
|
||||
const int profile = codec.profile & ~AV_PROFILE_H264_CONSTRAINED;
|
||||
const auto rate = av_guess_frame_rate(input.format, &stream, nullptr);
|
||||
return codec.codec_id == AV_CODEC_ID_H264 && codec.video_delay == 0 &&
|
||||
(profile == AV_PROFILE_H264_BASELINE || profile == AV_PROFILE_H264_MAIN || profile == AV_PROFILE_H264_HIGH) &&
|
||||
codec.level > 0 && codec.level <= 41 && codec.width > 0 && codec.width <= 1920 &&
|
||||
codec.height > 0 && codec.height <= 1080 && rate.num > 0 && av_q2d(rate) <= 30.001;
|
||||
}
|
||||
struct Track {
|
||||
Input *input;
|
||||
AVStream *stream = nullptr;
|
||||
AVCodecContext *decoder = nullptr;
|
||||
Packet pending;
|
||||
Frame decoded;
|
||||
bool eof = false, draining = false, remux = false;
|
||||
double nextTime = 0;
|
||||
Track(Input &in, int index, AVMediaType type, double start, bool live, bool aligned = false) : input(&in) {
|
||||
require(index >= 0);
|
||||
stream = input->format->streams[index];
|
||||
remux = type == AVMEDIA_TYPE_VIDEO ? remuxVideo(in, *stream) : remuxAudio(*stream->codecpar);
|
||||
const double seekTarget = type == AVMEDIA_TYPE_AUDIO && !remux ?
|
||||
std::max(0.0, aacPreroll(start) - double(std::max(0, stream->codecpar->seek_preroll)) /
|
||||
std::max(1, stream->codecpar->sample_rate)) : start;
|
||||
if (!live && !input->buffered) input->seek(seekTarget);
|
||||
nextPacket();
|
||||
// Indexed VOD can advertise source GOP boundaries; every actual start
|
||||
// still has to be an IDR. The opening frame may follow AAC priming.
|
||||
if (type == AVMEDIA_TYPE_VIDEO) {
|
||||
// Cached seeks can return the preceding GOP while read-ahead has
|
||||
// reached this boundary but not yet delivered its IDR packet.
|
||||
if (aligned && remux)
|
||||
while (pending && packetTime() < start - .001) nextPacket();
|
||||
const auto rate = av_guess_frame_rate(input->format, stream, nullptr);
|
||||
const double openingGap = rate.num > 0 ? av_q2d(av_inv_q(rate)) : 0;
|
||||
remux = remux && !live && pending && (pending->flags & AV_PKT_FLAG_KEY) &&
|
||||
playback::hasIDR(*pending, *stream->codecpar) &&
|
||||
(std::abs(packetTime() - start) < .001 ||
|
||||
(start == 0 && packetTime() >= 0 && packetTime() <= openingGap));
|
||||
if (aligned && !remux) {
|
||||
// A keyframe index is not proof of an IDR; recover full decoder preroll.
|
||||
input->seek(0); eof = false; nextPacket();
|
||||
}
|
||||
}
|
||||
if (!live && type == AVMEDIA_TYPE_VIDEO && pending && (pending->flags & AV_PKT_FLAG_KEY) &&
|
||||
!playback::hasIDR(*pending, *stream->codecpar) && stream->codecpar->codec_id == AV_CODEC_ID_H264) {
|
||||
// ponytail: rare intra-refresh seeks decode from the beginning; index real IDRs if this becomes slow.
|
||||
input->seek(0);
|
||||
nextPacket();
|
||||
}
|
||||
if (!remux) {
|
||||
const auto *codec = avcodec_find_decoder(stream->codecpar->codec_id); require(codec != nullptr);
|
||||
decoder = avcodec_alloc_context3(codec); require(decoder != nullptr);
|
||||
try {
|
||||
require(stream->codecpar->width >= 0 && stream->codecpar->height >= 0 &&
|
||||
int64_t(stream->codecpar->width) * stream->codecpar->height <= 4096LL * 2160);
|
||||
check(avcodec_parameters_to_context(decoder, stream->codecpar));
|
||||
decoder->pkt_timebase = stream->time_base;
|
||||
decoder->thread_count = 2;
|
||||
decoder->max_pixels = 4096LL * 2160;
|
||||
if (type == AVMEDIA_TYPE_VIDEO && pending &&
|
||||
playback::independentVideoPacket(*pending, *stream->codecpar))
|
||||
playback::preferVideoToolbox(decoder);
|
||||
check(avcodec_open2(decoder, codec, nullptr));
|
||||
} catch (...) { avcodec_free_context(&decoder); throw; }
|
||||
}
|
||||
}
|
||||
~Track() { avcodec_free_context(&decoder); }
|
||||
double packetTime() const {
|
||||
if (!pending) return INFINITY;
|
||||
auto pts = pending->pts != AV_NOPTS_VALUE ? pending->pts : pending->dts;
|
||||
return pts == AV_NOPTS_VALUE ? nextTime : pts * av_q2d(stream->time_base) - input->offset();
|
||||
}
|
||||
void nextPacket() {
|
||||
pending.reset();
|
||||
if (eof || !stream) return;
|
||||
auto p = packet();
|
||||
input->arm();
|
||||
for (;;) {
|
||||
require(!input->interrupted());
|
||||
int result = input->read(stream->codecpar->codec_type == AVMEDIA_TYPE_AUDIO ? 0 : 1, p.get());
|
||||
if (result == AVERROR_EOF) { eof = true; return; }
|
||||
check(result);
|
||||
require(p->size >= 0 && size_t(p->size) <= segmentLimit);
|
||||
if (p->stream_index == stream->index) { pending = std::move(p); return; }
|
||||
av_packet_unref(p.get());
|
||||
}
|
||||
}
|
||||
double frameTime() const {
|
||||
if (!decoded) return INFINITY;
|
||||
auto pts = decoded->best_effort_timestamp;
|
||||
return pts == AV_NOPTS_VALUE ? nextTime : pts * av_q2d(stream->time_base) - input->offset();
|
||||
}
|
||||
void nextFrame() {
|
||||
decoded.reset(); auto f = frame();
|
||||
for (;;) {
|
||||
require(!input->interrupted());
|
||||
int result = avcodec_receive_frame(decoder, f.get());
|
||||
if (result == 0) {
|
||||
#ifdef CAST_MEDIA_CHECK
|
||||
if (stream->codecpar->codec_type == AVMEDIA_TYPE_VIDEO)
|
||||
(f->format == AV_PIX_FMT_VIDEOTOOLBOX ? hardwareVideoFrames : softwareVideoFrames).fetch_add(1, std::memory_order_relaxed);
|
||||
#endif
|
||||
decoded = std::move(f);
|
||||
const double step = stream->codecpar->codec_type == AVMEDIA_TYPE_AUDIO ? double(decoded->nb_samples) / decoded->sample_rate : 1.0 / 30;
|
||||
nextTime = frameTime() + step;
|
||||
return;
|
||||
}
|
||||
if (result == AVERROR_EOF) return;
|
||||
if (result != AVERROR(EAGAIN)) check(result);
|
||||
if (!pending) {
|
||||
if (draining) return;
|
||||
check(avcodec_send_packet(decoder, nullptr)); draining = true;
|
||||
} else {
|
||||
check(avcodec_send_packet(decoder, pending.get())); nextPacket();
|
||||
}
|
||||
}
|
||||
}
|
||||
double time() { if (remux) return packetTime(); if (!decoded) nextFrame(); return frameTime(); }
|
||||
};
|
||||
struct Segment { int64_t id; double duration; std::vector<uint8_t> bytes; };
|
||||
struct Mux {
|
||||
AVFormatContext *format = nullptr;
|
||||
AVIOContext *io = nullptr;
|
||||
std::vector<uint8_t> bytes;
|
||||
Mux() {
|
||||
check(avformat_alloc_output_context2(&format, nullptr, "mpegts", nullptr));
|
||||
auto *buffer = static_cast<uint8_t *>(av_malloc(32768));
|
||||
if (!buffer) { avformat_free_context(format); format = nullptr; throw std::bad_alloc(); }
|
||||
io = avio_alloc_context(buffer, 32768, 1, this, nullptr, writeBytes, nullptr);
|
||||
if (!io) { av_free(buffer); avformat_free_context(format); format = nullptr; throw std::bad_alloc(); }
|
||||
format->pb = io; format->flags |= AVFMT_FLAG_CUSTOM_IO;
|
||||
}
|
||||
~Mux() { if (io) { av_freep(&io->buffer); avio_context_free(&io); } avformat_free_context(format); }
|
||||
static int writeBytes(void *opaque, const uint8_t *data, int count) {
|
||||
auto &self = *static_cast<Mux *>(opaque);
|
||||
if (count < 0 || self.bytes.size() + size_t(count) > segmentLimit) return AVERROR(ENOSPC);
|
||||
try { self.bytes.insert(self.bytes.end(), data, data + count); return count; } catch (...) { return AVERROR(ENOMEM); }
|
||||
}
|
||||
};
|
||||
struct OutputTrack {
|
||||
Track &source;
|
||||
Mux *mux;
|
||||
AVStream *stream = nullptr;
|
||||
AVCodecContext *encoder = nullptr;
|
||||
SwsContext *scale = nullptr;
|
||||
Frame readable;
|
||||
SwrContext *resample = nullptr;
|
||||
AVAudioFifo *fifo = nullptr;
|
||||
int64_t audioPTS = AV_NOPTS_VALUE;
|
||||
bool firstVideo = true;
|
||||
double lastVideoTime = -INFINITY;
|
||||
bool live, drained = false;
|
||||
std::deque<Packet> packets;
|
||||
size_t packetBytes = 0;
|
||||
int64_t lastPacketPTS = AV_NOPTS_VALUE;
|
||||
int64_t keepFrom = INT64_MIN, keepUntil = INT64_MAX;
|
||||
OutputTrack(Track &track, Mux &output, bool live) : source(track), mux(&output), live(live) {
|
||||
try {
|
||||
if (!source.stream) return;
|
||||
stream = avformat_new_stream(mux->format, nullptr); require(stream != nullptr);
|
||||
if (source.remux) {
|
||||
check(avcodec_parameters_copy(stream->codecpar, source.stream->codecpar));
|
||||
stream->time_base = source.stream->time_base; stream->codecpar->codec_tag = 0;
|
||||
return;
|
||||
}
|
||||
bool video = source.stream->codecpar->codec_type == AVMEDIA_TYPE_VIDEO;
|
||||
const auto *codec = video ? avcodec_find_encoder_by_name("h264_videotoolbox") : avcodec_find_encoder(AV_CODEC_ID_AAC);
|
||||
require(codec != nullptr); encoder = avcodec_alloc_context3(codec); require(encoder != nullptr);
|
||||
if (video) {
|
||||
require(source.decoder->width > 0 && source.decoder->height > 0 &&
|
||||
int64_t(source.decoder->width) * source.decoder->height <= 4096LL * 2160);
|
||||
auto sar = av_guess_sample_aspect_ratio(source.input->format, source.stream, nullptr);
|
||||
if (sar.num <= 0 || sar.den <= 0) sar = {1, 1};
|
||||
const double displayWidth = source.decoder->width * av_q2d(sar);
|
||||
const double ratio = std::min({1.0, 1920.0 / displayWidth, 1080.0 / source.decoder->height});
|
||||
encoder->width = int(displayWidth * ratio) & ~1;
|
||||
encoder->height = int(source.decoder->height * ratio) & ~1;
|
||||
require(encoder->width > 0 && encoder->height > 0);
|
||||
// VideoToolbox can omit SAR in its H264 bitstream; encode square pixels instead.
|
||||
encoder->sample_aspect_ratio = {1, 1};
|
||||
encoder->pix_fmt = AV_PIX_FMT_NV12; encoder->time_base = {1, 90000};
|
||||
encoder->framerate = av_guess_frame_rate(source.input->format, source.stream, nullptr);
|
||||
if (encoder->framerate.num <= 0 || av_q2d(encoder->framerate) > 30) encoder->framerate = {30, 1};
|
||||
encoder->bit_rate = 6000000; encoder->gop_size = 180; encoder->max_b_frames = 0;
|
||||
check(av_opt_set(encoder->priv_data, "profile", "main", 0));
|
||||
check(av_opt_set(encoder->priv_data, "level", "4.1", 0));
|
||||
// VideoToolbox may use its platform software fallback, never a host executable.
|
||||
av_opt_set(encoder->priv_data, "allow_sw", "1", 0);
|
||||
av_opt_set(encoder->priv_data, "realtime", "1", 0);
|
||||
} else {
|
||||
encoder->sample_rate = 48000; encoder->sample_fmt = AV_SAMPLE_FMT_FLTP;
|
||||
av_channel_layout_default(&encoder->ch_layout, 2); encoder->time_base = {1, 48000}; encoder->bit_rate = 192000;
|
||||
fifo = av_audio_fifo_alloc(encoder->sample_fmt, 2, 4096); require(fifo != nullptr);
|
||||
}
|
||||
check(avcodec_open2(encoder, codec, nullptr));
|
||||
stream->time_base = encoder->time_base; check(avcodec_parameters_from_context(stream->codecpar, encoder));
|
||||
} catch (...) { avcodec_free_context(&encoder); av_audio_fifo_free(fifo); fifo = nullptr; throw; }
|
||||
}
|
||||
~OutputTrack() { avcodec_free_context(&encoder); sws_freeContext(scale); swr_free(&resample); av_audio_fifo_free(fifo); }
|
||||
void beginSegment(Mux &output) {
|
||||
mux = &output;
|
||||
stream = avformat_new_stream(mux->format, nullptr); require(stream != nullptr);
|
||||
if (source.remux) {
|
||||
check(avcodec_parameters_copy(stream->codecpar, source.stream->codecpar));
|
||||
stream->time_base = source.stream->time_base; stream->codecpar->codec_tag = 0;
|
||||
} else {
|
||||
stream->time_base = encoder->time_base;
|
||||
check(avcodec_parameters_from_context(stream->codecpar, encoder));
|
||||
}
|
||||
}
|
||||
void writePacket(AVPacket *packet) {
|
||||
// A shared, positive TS clock origin prevents per-segment AAC priming shifts.
|
||||
const auto offset = av_rescale_q(1, AVRational{1, 1}, stream->time_base);
|
||||
if (packet->pts != AV_NOPTS_VALUE) packet->pts += offset;
|
||||
if (packet->dts != AV_NOPTS_VALUE) packet->dts += offset;
|
||||
packet->stream_index = stream->index;
|
||||
check(av_interleaved_write_frame(mux->format, packet));
|
||||
}
|
||||
void encoded(AVFrame *f) {
|
||||
check(avcodec_send_frame(encoder, f)); auto p = packet();
|
||||
for (;;) {
|
||||
int result = avcodec_receive_packet(encoder, p.get());
|
||||
if (result == AVERROR(EAGAIN) || result == AVERROR_EOF) break;
|
||||
check(result);
|
||||
lastPacketPTS = p->pts;
|
||||
if (!live) {
|
||||
require(p->pts != AV_NOPTS_VALUE && p->size >= 0);
|
||||
if (p->pts >= keepUntil) {
|
||||
require(packetBytes + size_t(p->size) <= segmentLimit);
|
||||
packetBytes += size_t(p->size);
|
||||
packets.push_back(std::move(p)); p = packet();
|
||||
continue;
|
||||
}
|
||||
if (p->pts < keepFrom) { av_packet_unref(p.get()); continue; }
|
||||
}
|
||||
av_packet_rescale_ts(p.get(), encoder->time_base, stream->time_base);
|
||||
writePacket(p.get()); av_packet_unref(p.get());
|
||||
}
|
||||
}
|
||||
void audioFrames(bool finish, double end = INFINITY) {
|
||||
const int count = encoder->frame_size;
|
||||
while ((av_audio_fifo_size(fifo) >= count || (finish && av_audio_fifo_size(fifo) > 0)) &&
|
||||
(audioPTS == AV_NOPTS_VALUE || double(audioPTS) / 48000 < end)) {
|
||||
auto f = frame(); f->format = encoder->sample_fmt; f->sample_rate = encoder->sample_rate;
|
||||
check(av_channel_layout_copy(&f->ch_layout, &encoder->ch_layout)); f->nb_samples = count;
|
||||
check(av_frame_get_buffer(f.get(), 0));
|
||||
int available = std::min(count, av_audio_fifo_size(fifo));
|
||||
require(av_audio_fifo_read(fifo, reinterpret_cast<void **>(f->data), available) == available);
|
||||
if (available < count) av_samples_set_silence(f->data, available, count - available, 2, encoder->sample_fmt);
|
||||
f->pts = audioPTS; audioPTS += count; encoded(f.get());
|
||||
}
|
||||
}
|
||||
void consume(double start, double end) {
|
||||
if (source.remux) {
|
||||
double time = source.packetTime();
|
||||
if (time >= start - .001 && time < end) {
|
||||
auto *p = source.pending.get();
|
||||
int64_t offset = int64_t(source.input->offset() / av_q2d(source.stream->time_base));
|
||||
if (p->pts != AV_NOPTS_VALUE) p->pts -= offset;
|
||||
if (p->dts != AV_NOPTS_VALUE) p->dts -= offset;
|
||||
av_packet_rescale_ts(p, source.stream->time_base, stream->time_base); p->stream_index = stream->index;
|
||||
writePacket(p);
|
||||
}
|
||||
source.nextPacket(); return;
|
||||
}
|
||||
auto *f = source.decoded.get(); double time = source.frameTime();
|
||||
if (source.stream->codecpar->codec_type == AVMEDIA_TYPE_VIDEO) {
|
||||
if (time >= start - .001 && time < end && time - lastVideoTime >= 1.0 / 30 - .00001) {
|
||||
require(f->width > 0 && f->height > 0 && int64_t(f->width) * f->height <= 4096LL * 2160);
|
||||
auto out = frame(); out->format = encoder->pix_fmt; out->width = encoder->width; out->height = encoder->height;
|
||||
out->sample_aspect_ratio = encoder->sample_aspect_ratio;
|
||||
check(av_frame_get_buffer(out.get(), 32));
|
||||
if (f->format == AV_PIX_FMT_VIDEOTOOLBOX) {
|
||||
if (!readable) readable = frame();
|
||||
check(playback::refVideoFrame(readable.get(), f));
|
||||
f = readable.get();
|
||||
}
|
||||
scale = sws_getCachedContext(scale, f->width, f->height, AVPixelFormat(f->format), out->width, out->height, encoder->pix_fmt, SWS_BILINEAR, nullptr, nullptr, nullptr);
|
||||
require(scale != nullptr); check(sws_scale(scale, f->data, f->linesize, 0, f->height, out->data, out->linesize));
|
||||
if (readable) av_frame_unref(readable.get());
|
||||
out->pts = int64_t(std::llround(std::max(start, time) * 90000));
|
||||
out->pict_type = firstVideo ? AV_PICTURE_TYPE_I : AV_PICTURE_TYPE_NONE; firstVideo = false; encoded(out.get());
|
||||
lastVideoTime = time;
|
||||
}
|
||||
} else {
|
||||
require(f->sample_rate > 0 && f->nb_samples >= 0 && f->nb_samples <= 65536 && f->ch_layout.nb_channels <= 32);
|
||||
const double begin = live ? start : aacPreroll(start);
|
||||
const double limit = end;
|
||||
if (time + double(f->nb_samples) / f->sample_rate > begin && time < limit) {
|
||||
if (!resample) {
|
||||
check(swr_alloc_set_opts2(&resample, &encoder->ch_layout, encoder->sample_fmt, 48000, &f->ch_layout, AVSampleFormat(f->format), f->sample_rate, 0, nullptr));
|
||||
check(swr_init(resample));
|
||||
}
|
||||
int capacity = int(av_rescale_rnd(swr_get_delay(resample, f->sample_rate) + f->nb_samples, 48000, f->sample_rate, AV_ROUND_UP));
|
||||
require(capacity > 0 && capacity <= 131072);
|
||||
auto out = frame(); out->format = encoder->sample_fmt; out->sample_rate = 48000;
|
||||
check(av_channel_layout_copy(&out->ch_layout, &encoder->ch_layout)); out->nb_samples = capacity; check(av_frame_get_buffer(out.get(), 0));
|
||||
int count = swr_convert(resample, out->data, capacity, const_cast<const uint8_t **>(f->extended_data), f->nb_samples); check(count);
|
||||
int skip = audioPTS != AV_NOPTS_VALUE ? 0 :
|
||||
std::clamp(int(std::llround((begin - time) * 48000)), 0, count);
|
||||
int take = count - skip;
|
||||
if (audioPTS == AV_NOPTS_VALUE) audioPTS = int64_t(std::llround(std::max(begin, time) * 48000));
|
||||
uint8_t *data[2] = {out->data[0] + skip * 4, out->data[1] + skip * 4};
|
||||
require(av_audio_fifo_size(fifo) + take <= 131072);
|
||||
require(av_audio_fifo_write(fifo, reinterpret_cast<void **>(data), take) == take); audioFrames(false, end);
|
||||
}
|
||||
}
|
||||
source.nextFrame();
|
||||
}
|
||||
void beginPackets(double start, double end) {
|
||||
if (!encoder || live) return;
|
||||
keepFrom = fifo ? aacBoundary(start) : int64_t(std::llround(start * 90000));
|
||||
keepUntil = fifo ? int64_t(std::llround(end * 48000)) : int64_t(std::llround(end * 90000));
|
||||
while (!packets.empty() && packets.front()->pts < keepUntil) {
|
||||
auto p = std::move(packets.front()); packets.pop_front();
|
||||
packetBytes -= size_t(p->size);
|
||||
if (p->pts < keepFrom) continue;
|
||||
av_packet_rescale_ts(p.get(), encoder->time_base, stream->time_base);
|
||||
writePacket(p.get());
|
||||
}
|
||||
}
|
||||
void finish(double start, double end) {
|
||||
if (!encoder) return;
|
||||
if (live) {
|
||||
if (fifo) audioFrames(false, end); else encoded(nullptr);
|
||||
return;
|
||||
}
|
||||
// Neither encoder can resume after a drain. Feed just enough future
|
||||
// input to receive this segment's delayed packets; retain the rest.
|
||||
const int64_t target = fifo ? aacBoundary(end) - encoder->frame_size :
|
||||
(std::isfinite(lastVideoTime) ? int64_t(std::llround(lastVideoTime * 90000)) : AV_NOPTS_VALUE);
|
||||
if (!fifo) firstVideo = true;
|
||||
while (!drained && target != AV_NOPTS_VALUE && (lastPacketPTS == AV_NOPTS_VALUE || lastPacketPTS < target)) {
|
||||
if (fifo) audioFrames(false);
|
||||
if (lastPacketPTS != AV_NOPTS_VALUE && lastPacketPTS >= target) break;
|
||||
const double time = source.time();
|
||||
// ponytail: cap lookahead at six seconds; drain and reset on the
|
||||
// next segment rather than following a sparse stream arbitrarily far.
|
||||
if (!std::isfinite(time) || time >= end + segmentSeconds) {
|
||||
if (fifo) {
|
||||
// Drain resampler delay before padding the final AAC frame.
|
||||
if (resample) {
|
||||
auto tail = frame(); tail->format = encoder->sample_fmt;
|
||||
tail->nb_samples = 4096;
|
||||
check(av_channel_layout_copy(&tail->ch_layout, &encoder->ch_layout));
|
||||
check(av_frame_get_buffer(tail.get(), 0));
|
||||
int count = swr_convert(resample, tail->data, tail->nb_samples, nullptr, 0); check(count);
|
||||
require(av_audio_fifo_write(fifo, reinterpret_cast<void **>(tail->data), count) == count);
|
||||
}
|
||||
audioFrames(true);
|
||||
}
|
||||
encoded(nullptr); drained = true;
|
||||
break;
|
||||
}
|
||||
consume(fifo ? start : end, INFINITY);
|
||||
}
|
||||
}
|
||||
};
|
||||
struct Pipeline {
|
||||
playback::StreamCacheBudget cacheBudget;
|
||||
std::unique_ptr<Input> videoInput, audioInput;
|
||||
Input *audioSource = nullptr;
|
||||
int videoIndex = -1, audioIndex = -1;
|
||||
Clock::time_point deadline;
|
||||
std::unique_ptr<Track> video, audio;
|
||||
std::unique_ptr<OutputTrack> videoOutput, audioOutput;
|
||||
bool live;
|
||||
std::atomic<bool> &cancel;
|
||||
double duration = 0;
|
||||
std::vector<double> boundaries;
|
||||
double nextStart = 0;
|
||||
Pipeline(const Source &source, std::atomic<bool> &stop, double start) : live(source.live), cancel(stop) {
|
||||
videoInput = std::make_unique<Input>(stop, source.video, source.videoHeaders, source.local, source.restricted, source.live);
|
||||
duration = std::max(source.duration, videoInput->duration());
|
||||
videoIndex = av_find_best_stream(videoInput->format, AVMEDIA_TYPE_VIDEO, -1, -1, nullptr, 0);
|
||||
if (!source.audio.empty()) {
|
||||
require(videoIndex >= 0);
|
||||
audioInput = std::make_unique<Input>(stop, source.audio, source.audioHeaders, false, source.restricted, source.live);
|
||||
audioSource = audioInput.get();
|
||||
audioIndex = av_find_best_stream(audioSource->format, AVMEDIA_TYPE_AUDIO, -1, -1, nullptr, 0);
|
||||
} else {
|
||||
audioIndex = av_find_best_stream(videoInput->format, AVMEDIA_TYPE_AUDIO, -1, videoIndex, nullptr, 0);
|
||||
if (audioIndex >= 0) {
|
||||
if (videoIndex >= 0 && (source.local || source.live)) {
|
||||
audioInput = std::make_unique<Input>(stop, source.video, source.videoHeaders, source.local, source.restricted, source.live);
|
||||
audioIndex = av_find_best_stream(audioInput->format, AVMEDIA_TYPE_AUDIO, -1, videoIndex, nullptr, 0);
|
||||
audioSource = audioInput.get();
|
||||
} else audioSource = videoInput.get();
|
||||
}
|
||||
}
|
||||
require(videoIndex >= 0 || audioIndex >= 0);
|
||||
// Read only a complete, already-loaded sample index, before read-ahead
|
||||
// owns the demuxer. No media scan or upstream seeks to build a playlist.
|
||||
if (!live && videoIndex >= 0) {
|
||||
auto *stream = videoInput->format->streams[videoIndex];
|
||||
const int count = avformat_index_get_entries_count(stream);
|
||||
if (remuxVideo(*videoInput, *stream) && count > 0 && stream->nb_frames == count) {
|
||||
boundaries.push_back(0);
|
||||
double previous = -INFINITY;
|
||||
for (int i = 0; i < count; ++i) {
|
||||
const auto *entry = avformat_index_get_entry(stream, i);
|
||||
const double time = entry->timestamp * av_q2d(stream->time_base) - videoInput->offset();
|
||||
if (entry->timestamp == AV_NOPTS_VALUE || time < previous) { boundaries.clear(); break; }
|
||||
previous = time;
|
||||
if (!(entry->flags & AVINDEX_KEYFRAME) || time < boundaries.back() + segmentSeconds) continue;
|
||||
// ponytail: cap indexed GOP segments at twelve seconds; longer GOPs use the fixed-grid transcode.
|
||||
if (time - boundaries.back() > 2 * segmentSeconds || time >= duration) break;
|
||||
boundaries.push_back(time);
|
||||
}
|
||||
if (boundaries.size() < 2 || duration - boundaries.back() > 2 * segmentSeconds ||
|
||||
duration <= boundaries.back()) boundaries.clear();
|
||||
else boundaries.push_back(duration);
|
||||
}
|
||||
}
|
||||
videoInput->select({audioSource == videoInput.get() ? audioIndex : -1, videoIndex}, cacheBudget);
|
||||
if (audioInput) audioInput->select({audioIndex, -1}, cacheBudget);
|
||||
reset(start, !boundaries.empty());
|
||||
if (!source.audio.empty()) require(audio && audio->stream);
|
||||
require(video || audio);
|
||||
if (!source.live) require(std::isfinite(duration) && duration > 0 && duration <= 86400 * 14);
|
||||
releaseOperation();
|
||||
}
|
||||
void armOperation() {
|
||||
deadline = Clock::now() + std::chrono::seconds(45);
|
||||
const auto limit = std::chrono::duration_cast<std::chrono::nanoseconds>(deadline.time_since_epoch()).count();
|
||||
videoInput->operationDeadline = limit; videoInput->arm();
|
||||
if (audioInput) { audioInput->operationDeadline = limit; audioInput->arm(); }
|
||||
}
|
||||
void releaseOperation() {
|
||||
videoInput->operationDeadline = std::numeric_limits<int64_t>::max();
|
||||
if (audioInput) audioInput->operationDeadline = std::numeric_limits<int64_t>::max();
|
||||
}
|
||||
void reset(double start, bool aligned = false) {
|
||||
armOperation();
|
||||
videoOutput.reset(); audioOutput.reset();
|
||||
video.reset(); audio.reset();
|
||||
// Random access resets codec history; the network demuxer rewinds both
|
||||
// packet lanes atomically, seeking upstream only on cache misses.
|
||||
const double audioTarget = audioIndex >= 0 && !remuxAudio(*audioSource->format->streams[audioIndex]->codecpar) ?
|
||||
aacPreroll(start) : NAN;
|
||||
if (videoInput->buffered)
|
||||
videoInput->seek(start, audioSource == videoInput.get() ? audioTarget : NAN);
|
||||
if (audioInput && audioInput->buffered) audioInput->seek(start, audioTarget);
|
||||
if (videoIndex >= 0) video = std::make_unique<Track>(*videoInput, videoIndex, AVMEDIA_TYPE_VIDEO, start, live, aligned);
|
||||
if (audioIndex >= 0) audio = std::make_unique<Track>(*audioSource, audioIndex, AVMEDIA_TYPE_AUDIO, start, live);
|
||||
nextStart = start;
|
||||
}
|
||||
Segment segment(int64_t id, double start, double end) {
|
||||
armOperation();
|
||||
Mux mux;
|
||||
if (video) {
|
||||
if (live) videoOutput.reset();
|
||||
if (videoOutput) videoOutput->beginSegment(mux);
|
||||
else videoOutput = std::make_unique<OutputTrack>(*video, mux, live);
|
||||
}
|
||||
if (audio) {
|
||||
if (audioOutput) audioOutput->beginSegment(mux);
|
||||
else audioOutput = std::make_unique<OutputTrack>(*audio, mux, live);
|
||||
}
|
||||
AVDictionary *options = nullptr;
|
||||
av_dict_set(&options, "mpegts_flags", "resend_headers", 0);
|
||||
av_dict_set(&options, "mpegts_copyts", "1", 0);
|
||||
av_dict_set(&options, "muxdelay", "0", 0);
|
||||
int result = avformat_write_header(mux.format, &options); av_dict_free(&options); check(result);
|
||||
if (videoOutput) videoOutput->beginPackets(start, end);
|
||||
if (audioOutput) audioOutput->beginPackets(start, end);
|
||||
const double audioEnd = audio && !audio->remux && !live ? double(aacBoundary(end)) / 48000 : end;
|
||||
for (;;) {
|
||||
require(!cancel && Clock::now() < deadline);
|
||||
double vt = video ? video->time() : INFINITY, at = audio ? audio->time() : INFINITY;
|
||||
if (vt >= end) vt = INFINITY;
|
||||
if (at >= audioEnd) at = INFINITY;
|
||||
if (!std::isfinite(vt) && !std::isfinite(at)) break;
|
||||
if (vt <= at) videoOutput->consume(start, end); else audioOutput->consume(start, end);
|
||||
}
|
||||
if (videoOutput) videoOutput->finish(start, end);
|
||||
if (audioOutput) audioOutput->finish(start, end);
|
||||
check(av_write_trailer(mux.format)); avio_flush(mux.io);
|
||||
require(mux.io->error >= 0 && !mux.bytes.empty());
|
||||
if (videoInput->buffered) videoInput->buffered->updatePosition(end);
|
||||
if (audioInput && audioInput->buffered) audioInput->buffered->updatePosition(end);
|
||||
nextStart = end;
|
||||
releaseOperation();
|
||||
return {id, end - start, std::move(mux.bytes)};
|
||||
}
|
||||
};
|
||||
bool number(std::string_view text, int64_t &value) {
|
||||
if (text.empty()) return false;
|
||||
auto result = std::from_chars(text.data(), text.data() + text.size(), value);
|
||||
return result.ec == std::errc() && result.ptr == text.data() + text.size() && value >= 0;
|
||||
}
|
||||
}
|
||||
|
||||
struct CastMedia {
|
||||
Source source;
|
||||
std::atomic<bool> cancelled{false};
|
||||
std::mutex lock;
|
||||
std::mutex vodLock;
|
||||
std::unique_ptr<Pipeline> vodPipeline;
|
||||
std::condition_variable wake;
|
||||
int listener = -1;
|
||||
std::vector<int> clients;
|
||||
std::array<std::thread, 2> servers;
|
||||
std::thread producer;
|
||||
std::string base, token;
|
||||
double duration = 0;
|
||||
std::vector<double> boundaries;
|
||||
std::deque<std::shared_ptr<const Segment>> segments;
|
||||
std::map<int, std::shared_ptr<const std::vector<uint8_t>>> subtitles;
|
||||
#ifdef CAST_MEDIA_CHECK
|
||||
double testSeconds = 6;
|
||||
#endif
|
||||
bool liveFailed = false;
|
||||
explicit CastMedia(Source input) : source(std::move(input)) {}
|
||||
~CastMedia() {
|
||||
cancel();
|
||||
if (producer.joinable()) producer.join();
|
||||
for (auto &worker : servers) if (worker.joinable()) worker.join();
|
||||
if (listener >= 0) close(listener);
|
||||
}
|
||||
void cancel() {
|
||||
cancelled = true; wake.notify_all();
|
||||
std::lock_guard guard(lock);
|
||||
if (listener >= 0) shutdown(listener, SHUT_RDWR);
|
||||
for (int socket : clients) shutdown(socket, SHUT_RDWR);
|
||||
}
|
||||
void bindReceiver(const char *host) {
|
||||
require(host && std::strlen(host) <= 1024);
|
||||
addrinfo hints{}; hints.ai_socktype = SOCK_DGRAM; hints.ai_family = AF_INET;
|
||||
addrinfo *addresses = nullptr;
|
||||
if (getaddrinfo(host, "8009", &hints, &addresses) != 0) {
|
||||
hints.ai_family = AF_UNSPEC;
|
||||
require(getaddrinfo(host, "8009", &hints, &addresses) == 0);
|
||||
}
|
||||
std::unique_ptr<addrinfo, decltype(&freeaddrinfo)> owner(addresses, freeaddrinfo);
|
||||
sockaddr_storage local{}; socklen_t length = 0; int family = 0;
|
||||
for (auto *address = addresses; address; address = address->ai_next) {
|
||||
int route = socket(address->ai_family, SOCK_DGRAM, 0);
|
||||
if (route < 0) continue;
|
||||
length = sizeof(local);
|
||||
bool found = connect(route, address->ai_addr, address->ai_addrlen) == 0 && getsockname(route, reinterpret_cast<sockaddr *>(&local), &length) == 0;
|
||||
close(route);
|
||||
if (found) { family = address->ai_family; break; }
|
||||
}
|
||||
require(family != 0 && !cancelled);
|
||||
char name[NI_MAXHOST]; require(getnameinfo(reinterpret_cast<sockaddr *>(&local), length, name, sizeof(name), nullptr, 0, NI_NUMERICHOST) == 0);
|
||||
if (family == AF_INET) reinterpret_cast<sockaddr_in *>(&local)->sin_port = 0;
|
||||
else reinterpret_cast<sockaddr_in6 *>(&local)->sin6_port = 0;
|
||||
int server = socket(family, SOCK_STREAM, 0); require(server >= 0);
|
||||
if (fcntl(server, F_SETFL, O_NONBLOCK) < 0) { close(server); require(false); }
|
||||
int noSIGPIPE = 1; setsockopt(server, SOL_SOCKET, SO_NOSIGPIPE, &noSIGPIPE, sizeof(noSIGPIPE));
|
||||
if (::bind(server, reinterpret_cast<sockaddr *>(&local), length) != 0 || listen(server, 8) != 0 || getsockname(server, reinterpret_cast<sockaddr *>(&local), &length) != 0) { close(server); require(false); }
|
||||
unsigned port = ntohs(family == AF_INET ? reinterpret_cast<sockaddr_in *>(&local)->sin_port : reinterpret_cast<sockaddr_in6 *>(&local)->sin6_port);
|
||||
std::array<unsigned char, 32> random{}; arc4random_buf(random.data(), random.size());
|
||||
for (auto byte : random) { char hex[3]; std::snprintf(hex, sizeof(hex), "%02x", byte); token += hex; }
|
||||
std::string hostname = name;
|
||||
// URLComponents expects escaped IPv6 scope identifiers.
|
||||
auto scope = hostname.find('%'); if (scope != std::string::npos) hostname.replace(scope, 1, "%25");
|
||||
base = "http://" + (family == AF_INET6 ? "[" + hostname + "]" : hostname) + ":" + std::to_string(port) + "/" + token + "/";
|
||||
std::lock_guard guard(lock); listener = server;
|
||||
if (cancelled) shutdown(listener, SHUT_RDWR);
|
||||
}
|
||||
void produce(std::unique_ptr<Pipeline> pipeline) {
|
||||
try {
|
||||
double first = std::min(pipeline->video ? pipeline->video->time() : INFINITY, pipeline->audio ? pipeline->audio->time() : INFINITY);
|
||||
require(std::isfinite(first));
|
||||
#ifndef CAST_MEDIA_CHECK
|
||||
auto origin = Clock::now();
|
||||
#endif
|
||||
for (int64_t id = 0; !cancelled; ++id) {
|
||||
auto segment = pipeline->segment(id, first + id * segmentSeconds, first + (id + 1) * segmentSeconds);
|
||||
{ std::lock_guard guard(lock); segments.push_back(std::make_shared<const Segment>(std::move(segment))); while (segments.size() > windowSegments) segments.pop_front(); }
|
||||
wake.notify_all();
|
||||
std::unique_lock guard(lock);
|
||||
#ifdef CAST_MEDIA_CHECK
|
||||
wake.wait(guard, [&] { return cancelled || testSeconds >= (id + 1) * segmentSeconds; });
|
||||
#else
|
||||
wake.wait_until(guard, origin + std::chrono::milliseconds(int64_t((id + 1) * segmentSeconds * 1000)), [&] { return bool(cancelled); });
|
||||
#endif
|
||||
}
|
||||
} catch (...) { std::lock_guard guard(lock); liveFailed = true; wake.notify_all(); }
|
||||
}
|
||||
int64_t segmentCount() const {
|
||||
return boundaries.empty() ? int64_t(std::ceil(duration / segmentSeconds)) : int64_t(boundaries.size() - 1);
|
||||
}
|
||||
double boundary(int64_t id) const {
|
||||
return boundaries.empty() ? std::min(duration, id * segmentSeconds) : boundaries[size_t(id)];
|
||||
}
|
||||
std::vector<uint8_t> playlist() {
|
||||
double longest = segmentSeconds;
|
||||
for (size_t i = 1; i < boundaries.size(); ++i)
|
||||
longest = std::max(longest, boundaries[i] - boundaries[i - 1]);
|
||||
std::string text = "#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:" +
|
||||
std::to_string(int(std::ceil(longest))) + "\n";
|
||||
int64_t first = 0, count = segmentCount();
|
||||
std::lock_guard guard(lock);
|
||||
if (source.live) { require(!segments.empty() && !liveFailed); first = segments.front()->id; count = int64_t(segments.size()); }
|
||||
else text += "#EXT-X-PLAYLIST-TYPE:VOD\n";
|
||||
text += "#EXT-X-MEDIA-SEQUENCE:" + std::to_string(first) + "\n";
|
||||
if (source.live) text += "#EXT-X-DISCONTINUITY-SEQUENCE:" + std::to_string(first) + "\n";
|
||||
for (int64_t id = first; id < first + count; ++id) {
|
||||
char value[64]; double length = source.live ? segments[size_t(id - first)]->duration : boundary(id + 1) - boundary(id);
|
||||
std::snprintf(value, sizeof(value), "#EXTINF:%.6f,\n", length);
|
||||
// Each segment retains an independent mux and random-access entry.
|
||||
if (id > first) text += "#EXT-X-DISCONTINUITY\n";
|
||||
text += value; text += "segment/" + std::to_string(id) + ".ts\n";
|
||||
}
|
||||
if (!source.live) text += "#EXT-X-ENDLIST\n";
|
||||
return {text.begin(), text.end()};
|
||||
}
|
||||
std::shared_ptr<const Segment> vodSegment(int64_t id) {
|
||||
auto cached = [&]() -> std::shared_ptr<const Segment> {
|
||||
std::lock_guard guard(lock);
|
||||
auto found = std::find_if(segments.begin(), segments.end(), [&](const auto &segment) { return segment->id == id; });
|
||||
return found == segments.end() ? nullptr : *found;
|
||||
};
|
||||
if (auto segment = cached()) return segment;
|
||||
// ponytail: uncached VOD generation is serialized; use a bounded input
|
||||
// pool only if parallel uncached requests justify duplicate probe state.
|
||||
std::lock_guard generation(vodLock);
|
||||
require(!cancelled);
|
||||
if (auto segment = cached()) return segment;
|
||||
try {
|
||||
const double start = boundary(id), end = boundary(id + 1);
|
||||
if (vodPipeline) {
|
||||
const auto &video = vodPipeline->video;
|
||||
// Fixed-grid remux is safe only when the next boundary is an
|
||||
// actual IDR. Otherwise reset once into the transcode path.
|
||||
const bool independent = !video || !video->remux ||
|
||||
(video->pending && std::abs(video->packetTime() - start) < .001 &&
|
||||
(video->pending->flags & AV_PKT_FLAG_KEY) &&
|
||||
playback::hasIDR(*video->pending, *video->stream->codecpar));
|
||||
const bool drained = (vodPipeline->videoOutput && vodPipeline->videoOutput->drained) ||
|
||||
(vodPipeline->audioOutput && vodPipeline->audioOutput->drained);
|
||||
if (vodPipeline->nextStart != start || !independent || drained)
|
||||
vodPipeline->reset(start, !boundaries.empty());
|
||||
} else vodPipeline = std::make_unique<Pipeline>(source, cancelled, start);
|
||||
auto segment = std::make_shared<const Segment>(vodPipeline->segment(id, start, end));
|
||||
{ std::lock_guard guard(lock); segments.push_back(segment); while (segments.size() > windowSegments) segments.pop_front(); }
|
||||
return segment;
|
||||
} catch (...) {
|
||||
// A failed demux/decode is never cached or reused. A later receiver
|
||||
// request can prepare fresh inputs; this request still receives 503.
|
||||
vodPipeline.reset();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
void sendAll(int socket, const uint8_t *bytes, size_t size) {
|
||||
const auto deadline = Clock::now() + std::chrono::seconds(15);
|
||||
while (size) {
|
||||
require(!cancelled && Clock::now() < deadline);
|
||||
pollfd descriptor{socket, POLLOUT, 0}; require(poll(&descriptor, 1, 1000) >= 0);
|
||||
if (!(descriptor.revents & POLLOUT)) continue;
|
||||
ssize_t count = send(socket, bytes, std::min(size, size_t(65536)), 0); require(count > 0);
|
||||
bytes += count; size -= size_t(count);
|
||||
}
|
||||
}
|
||||
void response(int socket, int status, const char *type, const std::vector<uint8_t> &bytes, bool head, std::string_view range = {}) {
|
||||
size_t begin = 0, end = bytes.size(); std::string extra;
|
||||
if (!range.empty() && status == 200) {
|
||||
int64_t low = 0, high = 0; auto split = range.find('-');
|
||||
if (!range.starts_with("bytes=") || split == std::string_view::npos || range.find(',') != std::string_view::npos) status = 416;
|
||||
else if (split == 6) {
|
||||
if (!number(range.substr(7), high) || high == 0 || bytes.empty()) status = 416;
|
||||
else begin = bytes.size() - std::min(bytes.size(), size_t(high));
|
||||
} else if (!number(range.substr(6, split - 6), low) || size_t(low) >= bytes.size()) status = 416;
|
||||
else {
|
||||
begin = size_t(low);
|
||||
if (split + 1 < range.size()) {
|
||||
if (!number(range.substr(split + 1), high) || high < low) status = 416;
|
||||
else end = std::min(bytes.size(), size_t(high) + 1);
|
||||
}
|
||||
}
|
||||
if (status != 416) { status = 206; extra = "Content-Range: bytes " + std::to_string(begin) + "-" + std::to_string(end - 1) + "/" + std::to_string(bytes.size()) + "\r\n"; }
|
||||
else { extra = "Content-Range: bytes */" + std::to_string(bytes.size()) + "\r\n"; begin = end = 0; }
|
||||
}
|
||||
std::string header = "HTTP/1.1 " + std::to_string(status) + (status == 200 ? " OK" : status == 206 ? " Partial Content" : " Error") + "\r\nConnection: close\r\nAccess-Control-Allow-Origin: *\r\nAccess-Control-Allow-Methods: GET, HEAD, OPTIONS\r\nAccess-Control-Allow-Headers: Range\r\nAccess-Control-Expose-Headers: Content-Length, Content-Range, Accept-Ranges\r\nCache-Control: no-store\r\nAccept-Ranges: bytes\r\nContent-Type: " + type + "\r\n" + extra + "Content-Length: " + std::to_string(end - begin) + "\r\n\r\n";
|
||||
sendAll(socket, reinterpret_cast<const uint8_t *>(header.data()), header.size());
|
||||
if (!head && end > begin) sendAll(socket, bytes.data() + begin, end - begin);
|
||||
}
|
||||
void serve(int socket) {
|
||||
std::string request; std::array<char, 2048> buffer{};
|
||||
auto deadline = Clock::now() + std::chrono::seconds(5);
|
||||
while (request.find("\r\n\r\n") == std::string::npos) {
|
||||
require(!cancelled && request.size() < 16384 && Clock::now() < deadline);
|
||||
pollfd descriptor{socket, POLLIN, 0}; require(poll(&descriptor, 1, 500) >= 0);
|
||||
if (!(descriptor.revents & POLLIN)) continue;
|
||||
ssize_t size = recv(socket, buffer.data(), std::min(buffer.size(), 16384 - request.size()), 0); require(size > 0); request.append(buffer.data(), size_t(size));
|
||||
}
|
||||
auto first = request.find(' '), second = request.find(' ', first == std::string::npos ? 0 : first + 1);
|
||||
require(first != std::string::npos && second != std::string::npos && second < request.find("\r\n"));
|
||||
auto method = request.substr(0, first), path = request.substr(first + 1, second - first - 1);
|
||||
bool head = method == "HEAD";
|
||||
if (!path.starts_with("/" + token + "/")) { response(socket, 404, "text/plain", {}, head); return; }
|
||||
if (method == "OPTIONS") { response(socket, 204, "text/plain", {}, true); return; }
|
||||
if (method != "GET" && !head) { response(socket, 405, "text/plain", {}, true); return; }
|
||||
std::string range;
|
||||
size_t cursor = request.find("\r\n") + 2;
|
||||
while (cursor < request.size()) {
|
||||
auto next = request.find("\r\n", cursor); if (next == cursor || next == std::string::npos) break;
|
||||
auto line = request.substr(cursor, next - cursor); auto colon = line.find(':'); require(colon != std::string::npos);
|
||||
auto name = line.substr(0, colon); for (char &c : name) if (c >= 'A' && c <= 'Z') c += 'a' - 'A';
|
||||
if (name == "range") { require(range.empty()); auto begin = line.find_first_not_of(' ', colon + 1); require(begin != std::string::npos); range = line.substr(begin); }
|
||||
cursor = next + 2;
|
||||
}
|
||||
path.erase(0, token.size() + 2);
|
||||
std::vector<uint8_t> data; const char *type = "video/mp2t"; int status = 200;
|
||||
std::shared_ptr<const std::vector<uint8_t>> retained;
|
||||
if (path == "index.m3u8") { data = playlist(); type = "application/vnd.apple.mpegurl"; }
|
||||
else if (path.starts_with("subtitle/") && path.ends_with(".vtt")) {
|
||||
int64_t id = 0;
|
||||
if (!number(std::string_view(path).substr(9, path.size() - 13), id) || id > 32) status = 404;
|
||||
else { std::lock_guard guard(lock); auto found = subtitles.find(int(id)); if (found == subtitles.end()) status = 404; else retained = found->second; }
|
||||
type = "text/vtt; charset=utf-8";
|
||||
} else if (path.starts_with("segment/") && path.ends_with(".ts")) {
|
||||
int64_t id = 0;
|
||||
if (!number(std::string_view(path).substr(8, path.size() - 11), id)) status = 404;
|
||||
else if (source.live) {
|
||||
std::lock_guard guard(lock); auto found = std::find_if(segments.begin(), segments.end(), [&](const auto &segment) { return segment->id == id; });
|
||||
if (found == segments.end()) status = 404; else retained = std::shared_ptr<const std::vector<uint8_t>>(*found, &(*found)->bytes);
|
||||
} else if (id >= segmentCount()) status = 404;
|
||||
else {
|
||||
auto segment = vodSegment(id);
|
||||
retained = std::shared_ptr<const std::vector<uint8_t>>(segment, &segment->bytes);
|
||||
}
|
||||
} else status = 404;
|
||||
response(socket, status, type, retained ? *retained : data, head, range);
|
||||
}
|
||||
void server() {
|
||||
while (!cancelled) {
|
||||
pollfd descriptor{listener, POLLIN, 0}; if (poll(&descriptor, 1, 500) <= 0) continue;
|
||||
if (!(descriptor.revents & POLLIN)) continue;
|
||||
int socket = accept(listener, nullptr, nullptr); if (socket < 0) continue;
|
||||
int one = 1; setsockopt(socket, SOL_SOCKET, SO_NOSIGPIPE, &one, sizeof(one));
|
||||
timeval timeout{1, 0}; setsockopt(socket, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout)); setsockopt(socket, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout));
|
||||
{ std::lock_guard guard(lock); clients.push_back(socket); if (cancelled) shutdown(socket, SHUT_RDWR); }
|
||||
try { serve(socket); } catch (...) { try { response(socket, 503, "text/plain", {}, true); } catch (...) {} }
|
||||
{ std::lock_guard guard(lock); clients.erase(std::remove(clients.begin(), clients.end(), socket), clients.end()); close(socket); }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
extern "C" CastMedia *cast_media_create(const CastMediaSource *input, char error[512]) {
|
||||
try {
|
||||
require(input && input->video_url && std::strlen(input->video_url) <= 65536);
|
||||
require(playback::validHeaders(input->video_headers) && playback::validHeaders(input->audio_headers));
|
||||
require(std::isfinite(input->duration) && input->duration >= 0);
|
||||
std::string video = input->video_url;
|
||||
if (input->local) require(!video.empty() && video.front() == '/' && video.find('\0') == std::string::npos);
|
||||
else require(playback::validNetworkURL(video.c_str()));
|
||||
if (input->audio_url && *input->audio_url) require(playback::validNetworkURL(input->audio_url));
|
||||
av_log_set_callback(privateLog);
|
||||
// Bound demuxer-controlled allocations before probing or packet reads allocate them.
|
||||
av_max_alloc(128 * 1024 * 1024);
|
||||
return new CastMedia({video, input->video_headers ? input->video_headers : "", input->audio_url ? input->audio_url : "", input->audio_headers ? input->audio_headers : "", input->local, input->restricted, input->live, input->duration});
|
||||
} catch (...) { if (error) std::snprintf(error, 512, "Could not prepare cast media (private details redacted)."); return nullptr; }
|
||||
}
|
||||
extern "C" bool cast_media_start(CastMedia *media, const char *host, char url[512], double *duration, char error[512]) {
|
||||
try {
|
||||
require(media && host && url && duration && !media->cancelled);
|
||||
auto pipeline = std::make_unique<Pipeline>(media->source, media->cancelled, 0);
|
||||
media->duration = pipeline->duration;
|
||||
media->boundaries = std::move(pipeline->boundaries);
|
||||
media->bindReceiver(host);
|
||||
if (media->source.live) {
|
||||
media->producer = std::thread([media, pipe = std::move(pipeline)]() mutable { media->produce(std::move(pipe)); });
|
||||
std::unique_lock guard(media->lock);
|
||||
require(media->wake.wait_for(guard, std::chrono::seconds(50), [&] { return media->segments.size() >= 2 || media->liveFailed || media->cancelled; }));
|
||||
require(media->segments.size() >= 2 && !media->liveFailed && !media->cancelled);
|
||||
} else media->vodPipeline = std::move(pipeline);
|
||||
for (auto &worker : media->servers) worker = std::thread([media] { media->server(); });
|
||||
require(media->base.size() + 11 < 512); std::snprintf(url, 512, "%sindex.m3u8", media->base.c_str()); *duration = media->source.live ? 0 : media->duration;
|
||||
return true;
|
||||
} catch (...) { if (media) media->cancel(); if (error) std::snprintf(error, 512, "Could not start cast delivery (private details redacted)."); return false; }
|
||||
}
|
||||
extern "C" bool cast_media_subtitle(CastMedia *media, int id, const void *bytes, size_t size, char url[512]) {
|
||||
try {
|
||||
require(media && !media->cancelled && id > 0 && id <= 32 && bytes && size > 0 && size <= 4 * 1024 * 1024 && url);
|
||||
std::lock_guard guard(media->lock);
|
||||
auto *data = static_cast<const uint8_t *>(bytes); media->subtitles[id] = std::make_shared<const std::vector<uint8_t>>(data, data + size);
|
||||
require(media->base.size() + 32 < 512); std::snprintf(url, 512, "%ssubtitle/%d.vtt", media->base.c_str(), id); return true;
|
||||
} catch (...) { return false; }
|
||||
}
|
||||
extern "C" void cast_media_cancel(CastMedia *media) { if (media) media->cancel(); }
|
||||
extern "C" void cast_media_destroy(CastMedia *media) { delete media; }
|
||||
#ifdef CAST_MEDIA_CHECK
|
||||
extern "C" void cast_media_check_decode_counts(uint64_t *hardware, uint64_t *software) {
|
||||
*hardware = hardwareVideoFrames.load(std::memory_order_relaxed);
|
||||
*software = softwareVideoFrames.load(std::memory_order_relaxed);
|
||||
}
|
||||
extern "C" bool cast_media_check_advance(CastMedia *media, double seconds) {
|
||||
if (!media || !media->source.live || !std::isfinite(seconds) || seconds < 6 || seconds > 3600) return false;
|
||||
std::unique_lock guard(media->lock);
|
||||
media->testSeconds = seconds;
|
||||
media->wake.notify_all();
|
||||
const auto target = int64_t(seconds / segmentSeconds);
|
||||
media->wake.wait(guard, [&] { return media->cancelled || media->liveFailed ||
|
||||
(!media->segments.empty() && media->segments.back()->id >= target); });
|
||||
return !media->cancelled && !media->liveFailed;
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,34 @@
|
||||
#pragma once
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
typedef struct CastMedia CastMedia;
|
||||
typedef struct CastMediaSource {
|
||||
const char *video_url;
|
||||
const char *video_headers;
|
||||
const char *audio_url;
|
||||
const char *audio_headers;
|
||||
bool local;
|
||||
bool restricted;
|
||||
bool live;
|
||||
double duration;
|
||||
} CastMediaSource;
|
||||
|
||||
// create copies input. start/probe/destroy may block; call off the UI thread.
|
||||
CastMedia *cast_media_create(const CastMediaSource *source, char error[512]);
|
||||
bool cast_media_start(CastMedia *media, const char *receiver_host, char url[512], double *duration, char error[512]);
|
||||
// Copies a validated UTF-8 WebVTT resource, at most 4 MiB/track and 32 tracks.
|
||||
bool cast_media_subtitle(CastMedia *media, int identifier, const void *bytes, size_t size, char url[512]);
|
||||
#ifdef CAST_MEDIA_CHECK
|
||||
// Dedicated check build only: fixed monotonic clock; waits for publication.
|
||||
bool cast_media_check_advance(CastMedia *media, double seconds);
|
||||
#endif
|
||||
// Nonblocking cancellation; destroy subsequently joins all workers and closes sockets.
|
||||
void cast_media_cancel(CastMedia *media);
|
||||
void cast_media_destroy(CastMedia *media);
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -17,6 +17,7 @@ extern "C" {
|
||||
#include <mutex>
|
||||
#include <new>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
|
||||
namespace playback {
|
||||
@@ -49,6 +50,9 @@ class NetworkInput {
|
||||
public:
|
||||
static constexpr double aheadSeconds = 60, behindSeconds = 30;
|
||||
AVFormatContext *format = nullptr;
|
||||
// Resolved sources bind credentials to their original origin for all HLS I/O.
|
||||
std::string headerOrigin;
|
||||
std::string requestHeaders;
|
||||
|
||||
NetworkInput(const std::atomic<bool> &stopping, const std::atomic<uint64_t> &epoch,
|
||||
uint64_t activeEpoch, std::condition_variable &playerWake, StreamCacheBudget &budget)
|
||||
@@ -121,7 +125,9 @@ public:
|
||||
for (int slot = 0; slot < 2; ++slot) trim(slot);
|
||||
budget.changed.notify_all();
|
||||
}
|
||||
void seek(int streamIndex, int64_t timestamp) {
|
||||
// Optional audio target gives fresh segment encoders their AAC preroll without
|
||||
// moving the video cursor away from its own keyframe. Omission preserves player seeks.
|
||||
void seek(int streamIndex, int64_t timestamp, double audioTarget = NAN) {
|
||||
std::lock_guard lock(budget.mutex);
|
||||
const double target = (streamIndex < 0 ? static_cast<double>(timestamp) / AV_TIME_BASE :
|
||||
static_cast<double>(timestamp) * av_q2d(format->streams[streamIndex]->time_base)) - origin;
|
||||
@@ -134,7 +140,7 @@ public:
|
||||
// track can resume without restoring its I/O position through a real seek.
|
||||
if (halted && !ended && !(std::isfinite(lane.mediaEnd) &&
|
||||
lane.frontierEnd + lane.tick >= lane.mediaEnd)) cached = false;
|
||||
else cached = cachedCursor(slot, target, cursors[slot]);
|
||||
else cached = cachedCursor(slot, slot == 0 && std::isfinite(audioTarget) ? audioTarget : target, cursors[slot]);
|
||||
}
|
||||
position = std::max(0.0, target);
|
||||
if (cached) {
|
||||
@@ -152,6 +158,11 @@ public:
|
||||
failure = 0;
|
||||
seekIndex = streamIndex;
|
||||
seekTimestamp = timestamp;
|
||||
if (lanes[0].index >= 0 && !skipAudio && std::isfinite(audioTarget)) {
|
||||
const double earliest = std::min(target, std::max(0.0, audioTarget - lanes[0].preroll));
|
||||
seekIndex = -1;
|
||||
seekTimestamp = static_cast<int64_t>((earliest + origin) * AV_TIME_BASE);
|
||||
}
|
||||
seeking = true;
|
||||
serial.fetch_add(1, std::memory_order_release);
|
||||
budget.changed.notify_all();
|
||||
@@ -183,6 +194,20 @@ public:
|
||||
std::lock_guard lock(budget.mutex);
|
||||
return failure;
|
||||
}
|
||||
// Whether an existing bounded forward fetch can supply a seek's missing packets.
|
||||
// Past history and targets outside the current ahead window require normal seek handling.
|
||||
bool readAheadPending(double target, double audioTarget = NAN) const {
|
||||
std::lock_guard lock(budget.mutex);
|
||||
if (failure || seeking || ended || halted || waitingForRoom ||
|
||||
target < position || target > position + aheadSeconds) return false;
|
||||
for (int slot = 0; slot < 2; ++slot) {
|
||||
const auto &lane = lanes[slot];
|
||||
if (lane.index < 0 || (slot == 0 && skipAudio) || trackEnded(lane)) continue;
|
||||
const double laneTarget = slot == 0 && std::isfinite(audioTarget) ? audioTarget : target;
|
||||
if (laneTarget > lane.frontierEnd + lane.tick) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
double bufferedStart(int slot) const {
|
||||
std::lock_guard lock(budget.mutex);
|
||||
if (!cacheSeeking || failure || seeking || (slot == 0 && skipAudio)) return NAN;
|
||||
@@ -249,6 +274,7 @@ private:
|
||||
std::atomic<int64_t> deadline{std::numeric_limits<int64_t>::max()};
|
||||
bool seeking = false, ended = false, halted = false, skipAudio = false;
|
||||
bool cacheSeeking = false, started = false, audioDropped = false;
|
||||
bool waitingForRoom = false;
|
||||
int seekIndex = -1, failure = 0;
|
||||
int64_t seekTimestamp = 0;
|
||||
double origin = 0, position = 0;
|
||||
@@ -460,10 +486,15 @@ private:
|
||||
}
|
||||
// The demuxer's single in-flight packet is not retained until it fits.
|
||||
// Byte pressure shortens consumed history, never drops unread packets.
|
||||
budget.changed.wait(lock, [&] {
|
||||
return stoppingNow() || serial.load(std::memory_order_acquire) != current ||
|
||||
(slot == 0 && skipAudio) || makeRoom(lane, bytes);
|
||||
});
|
||||
if (!makeRoom(lane, bytes)) {
|
||||
waitingForRoom = true;
|
||||
playerWake.notify_one();
|
||||
budget.changed.wait(lock, [&] {
|
||||
return stoppingNow() || serial.load(std::memory_order_acquire) != current ||
|
||||
(slot == 0 && skipAudio) || makeRoom(lane, bytes);
|
||||
});
|
||||
waitingForRoom = false;
|
||||
}
|
||||
if (stoppingNow()) return;
|
||||
if (serial.load(std::memory_order_acquire) != current || (slot == 0 && skipAudio)) {
|
||||
if (slot == 0 && skipAudio) audioDropped = true;
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
#pragma once
|
||||
#include <array>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <arpa/inet.h>
|
||||
|
||||
namespace playback {
|
||||
inline bool validNetworkURL(std::string_view url) {
|
||||
if (url.size() > 65536) return false;
|
||||
const std::size_t start = url.starts_with("https://") ? 8 : url.starts_with("http://") ? 7 : 0;
|
||||
if (!start) return false;
|
||||
for (unsigned char value : url)
|
||||
if (value <= 32 || value == 127 || value == '\\') return false;
|
||||
const auto authority = url.substr(start, url.find_first_of("/?#", start) - start);
|
||||
if (authority.empty() || authority.find('@') != std::string_view::npos) return false;
|
||||
auto host = authority;
|
||||
std::string_view port;
|
||||
if (host.front() == '[') {
|
||||
const auto end = host.find(']');
|
||||
if (end == std::string_view::npos || end == 1) return false;
|
||||
if (end + 1 < host.size()) {
|
||||
if (host[end + 1] != ':') return false;
|
||||
port = host.substr(end + 2);
|
||||
if (port.empty()) return false;
|
||||
}
|
||||
host = host.substr(1, end - 1);
|
||||
std::array<char, INET6_ADDRSTRLEN> address{};
|
||||
if (host.size() >= address.size()) return false;
|
||||
std::memcpy(address.data(), host.data(), host.size());
|
||||
in6_addr parsed{};
|
||||
if (inet_pton(AF_INET6, address.data(), &parsed) != 1) return false;
|
||||
} else {
|
||||
const auto colon = host.find(':');
|
||||
if (colon != std::string_view::npos) {
|
||||
port = host.substr(colon + 1);
|
||||
host = host.substr(0, colon);
|
||||
if (port.empty()) return false;
|
||||
}
|
||||
if (host.empty()) return false;
|
||||
for (unsigned char value : host)
|
||||
if (!((value >= 'a' && value <= 'z') || (value >= 'A' && value <= 'Z') ||
|
||||
(value >= '0' && value <= '9') || value == '.' || value == '-')) return false;
|
||||
}
|
||||
unsigned number = 0;
|
||||
for (unsigned char value : port) {
|
||||
if (value < '0' || value > '9') return false;
|
||||
number = number * 10 + value - '0';
|
||||
if (number > 65535) return false;
|
||||
}
|
||||
return port.empty() || number > 0;
|
||||
}
|
||||
inline bool validNetworkURL(const char *text) {
|
||||
return text && validNetworkURL(std::string_view(text));
|
||||
}
|
||||
inline bool validHeaders(const char *text) {
|
||||
if (!text) return true;
|
||||
const std::string_view headers(text);
|
||||
if (headers.size() > 65536) return false;
|
||||
std::size_t offset = 0;
|
||||
while (offset < headers.size()) {
|
||||
const auto end = headers.find("\r\n", offset);
|
||||
if (end == std::string_view::npos) return false;
|
||||
const auto line = headers.substr(offset, end - offset);
|
||||
const auto colon = line.find(':');
|
||||
if (colon == std::string_view::npos || colon == 0) return false;
|
||||
for (unsigned char value : line.substr(0, colon))
|
||||
if (!((value >= 'a' && value <= 'z') || (value >= 'A' && value <= 'Z') ||
|
||||
(value >= '0' && value <= '9') ||
|
||||
std::string_view("!#$%&'*+-.^_`|~").find(value) != std::string_view::npos)) return false;
|
||||
for (unsigned char value : line.substr(colon + 1))
|
||||
if (value < 32 || value == 127) return false;
|
||||
offset = end + 2;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
inline std::string networkOrigin(const char *text) {
|
||||
if (!validNetworkURL(text)) return {};
|
||||
std::string_view url(text);
|
||||
const auto start = url.starts_with("https://") ? 8u : 7u;
|
||||
std::string authority(url.substr(start, url.find_first_of("/?#", start) - start));
|
||||
for (char &value : authority)
|
||||
if (value >= 'A' && value <= 'Z') value += 'a' - 'A';
|
||||
const auto colon = authority.front() == '[' ? authority.find(':', authority.find(']')) : authority.find(':');
|
||||
if (colon == std::string::npos) authority += start == 8 ? ":443" : ":80";
|
||||
else authority.replace(colon + 1, std::string::npos, std::to_string(std::stoul(authority.substr(colon + 1))));
|
||||
return std::string(url.substr(0, start)) + authority;
|
||||
}
|
||||
}
|
||||
+172
-90
@@ -2,6 +2,7 @@
|
||||
#include "player_macos.h"
|
||||
#include "output.hpp"
|
||||
#include "progress_reporter.hpp"
|
||||
#include "video_decoder.hpp"
|
||||
|
||||
extern "C" {
|
||||
#include <libavcodec/avcodec.h>
|
||||
@@ -18,6 +19,7 @@ extern "C" {
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <condition_variable>
|
||||
#include <cstdarg>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <deque>
|
||||
@@ -32,6 +34,7 @@ extern "C" {
|
||||
#include <arpa/inet.h>
|
||||
#include <vector>
|
||||
#include "network_input.hpp"
|
||||
#include "network_policy.hpp"
|
||||
|
||||
namespace {
|
||||
constexpr int sampleRate = 48000;
|
||||
@@ -62,6 +65,7 @@ struct Track {
|
||||
AVPacket *packet = nullptr;
|
||||
bool packetPending = false;
|
||||
bool receiving = false;
|
||||
bool firstPacket = true;
|
||||
double packetBufferedEnd = 0;
|
||||
double decoderBufferedEnd = 0;
|
||||
bool sourceEnded = false;
|
||||
@@ -72,7 +76,7 @@ struct Track {
|
||||
uint64_t timestampOccurrence = 0;
|
||||
AVStream *stream() const { return format->streams[index]; }
|
||||
};
|
||||
enum class Action { Open, Play, Pause, Seek, Step, Close };
|
||||
enum class Action { Open, Play, Pause, Seek, Step, Close, Remote };
|
||||
struct Command {
|
||||
Action action;
|
||||
std::string path;
|
||||
@@ -88,6 +92,7 @@ struct Command {
|
||||
bool live = false;
|
||||
std::string mediaURL{};
|
||||
double initialPosition = 0;
|
||||
PlayerState remoteState{};
|
||||
};
|
||||
|
||||
void copyError(char *destination, const char *message) noexcept {
|
||||
@@ -99,73 +104,39 @@ int64_t presentationTimestamp(const AVFrame *frame) {
|
||||
frame->best_effort_timestamp : frame->pts;
|
||||
}
|
||||
|
||||
bool validNetworkURL(const char *text) {
|
||||
if (!text) return false;
|
||||
const std::string_view url(text);
|
||||
if (url.size() > 65536) return false;
|
||||
const std::size_t start = url.starts_with("https://") ? 8 : url.starts_with("http://") ? 7 : 0;
|
||||
if (!start) return false;
|
||||
for (unsigned char value : url)
|
||||
if (value <= 32 || value == 127 || value == '\\') return false;
|
||||
const auto authority = url.substr(start, url.find_first_of("/?#", start) - start);
|
||||
if (authority.empty() || authority.find('@') != std::string_view::npos) return false;
|
||||
auto host = authority;
|
||||
std::string_view port;
|
||||
if (host.front() == '[') {
|
||||
const auto end = host.find(']');
|
||||
if (end == std::string_view::npos || end == 1) return false;
|
||||
if (end + 1 < host.size()) {
|
||||
if (host[end + 1] != ':') return false;
|
||||
port = host.substr(end + 2);
|
||||
if (port.empty()) return false;
|
||||
}
|
||||
host = host.substr(1, end - 1);
|
||||
std::array<char, INET6_ADDRSTRLEN> address{};
|
||||
if (host.size() >= address.size()) return false;
|
||||
std::memcpy(address.data(), host.data(), host.size());
|
||||
in6_addr parsed{};
|
||||
if (inet_pton(AF_INET6, address.data(), &parsed) != 1) return false;
|
||||
} else {
|
||||
const auto colon = host.find(':');
|
||||
if (colon != std::string_view::npos) {
|
||||
port = host.substr(colon + 1);
|
||||
host = host.substr(0, colon);
|
||||
if (port.empty()) return false;
|
||||
}
|
||||
if (host.empty()) return false;
|
||||
for (unsigned char value : host)
|
||||
if (!((value >= 'a' && value <= 'z') || (value >= 'A' && value <= 'Z') ||
|
||||
(value >= '0' && value <= '9') || value == '.' || value == '-')) return false;
|
||||
using playback::validNetworkURL;
|
||||
using playback::validHeaders;
|
||||
using playback::networkOrigin;
|
||||
|
||||
int resolvedIOOpen(AVFormatContext *format, AVIOContext **pb, const char *url,
|
||||
int flags, AVDictionary **options) noexcept {
|
||||
try {
|
||||
const auto &input = *static_cast<playback::NetworkInput *>(format->opaque);
|
||||
// crypto forwards options to its underlying HTTP protocol; do not unwrap the
|
||||
// actual URL, since FFmpeg must still decrypt AES-128 segments itself.
|
||||
const char *resource = std::strncmp(url, "crypto+", 7) == 0 ||
|
||||
std::strncmp(url, "crypto:", 7) == 0 ? url + 7 : url;
|
||||
if (networkOrigin(resource) != input.headerOrigin) return AVERROR(EACCES);
|
||||
int result = av_dict_set(options, "protocol_whitelist", "http,https,tcp,tls,crypto", 0);
|
||||
if (result >= 0) result = av_dict_set(options, "headers", input.requestHeaders.c_str(), 0);
|
||||
// FFmpeg 9's HTTP redirect loop retains custom headers. Reject every redirect
|
||||
// before any second request, including same-origin redirects, rather than leak.
|
||||
if (result >= 0) result = av_dict_set(options, "max_redirects", "0", 0);
|
||||
if (result < 0) return result;
|
||||
return avio_open2(pb, url, flags, &format->interrupt_callback, options);
|
||||
} catch (const std::bad_alloc &) {
|
||||
return AVERROR(ENOMEM);
|
||||
} catch (...) {
|
||||
return AVERROR(EINVAL);
|
||||
}
|
||||
unsigned number = 0;
|
||||
for (unsigned char value : port) {
|
||||
if (value < '0' || value > '9') return false;
|
||||
number = number * 10 + value - '0';
|
||||
if (number > 65535) return false;
|
||||
}
|
||||
return port.empty() || number > 0;
|
||||
}
|
||||
|
||||
bool validHeaders(const char *text) {
|
||||
if (!text) return true;
|
||||
const std::string_view headers(text);
|
||||
if (headers.size() > 65536) return false;
|
||||
std::size_t offset = 0;
|
||||
while (offset < headers.size()) {
|
||||
const auto end = headers.find("\r\n", offset);
|
||||
if (end == std::string_view::npos) return false;
|
||||
const auto line = headers.substr(offset, end - offset);
|
||||
const auto colon = line.find(':');
|
||||
if (colon == std::string_view::npos || colon == 0) return false;
|
||||
for (unsigned char value : line.substr(0, colon))
|
||||
if (!((value >= 'a' && value <= 'z') || (value >= 'A' && value <= 'Z') ||
|
||||
(value >= '0' && value <= '9') ||
|
||||
std::string_view("!#$%&'*+-.^_`|~").find(value) != std::string_view::npos)) return false;
|
||||
for (unsigned char value : line.substr(colon + 1))
|
||||
if (value < 32 || value == 127) return false;
|
||||
offset = end + 2;
|
||||
}
|
||||
return true;
|
||||
void redactedFFmpegLog(void *, int level, const char *, va_list) {
|
||||
if (level > AV_LOG_WARNING || level > av_log_get_level()) return;
|
||||
// FFmpeg diagnostics can interpolate signed URLs, cookies, keys and headers,
|
||||
// including at error level. Player::check still exposes operation + AVERROR.
|
||||
std::fputs(level <= AV_LOG_ERROR ? "FFmpeg error (private details redacted)\n" :
|
||||
"FFmpeg warning (private details redacted)\n", stderr);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,7 +197,8 @@ struct Player {
|
||||
PlayerStatus status = PLAYER_EMPTY;
|
||||
bool previewShown = false;
|
||||
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;
|
||||
std::thread worker;
|
||||
|
||||
Player() : worker([this] { run(); }) {}
|
||||
@@ -264,8 +236,13 @@ struct Player {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (commands.size() >= commandLimit) return false;
|
||||
commands.push_back(std::move(command));
|
||||
if (command.action == Action::Seek && !commands.empty() &&
|
||||
commands.back().action == Action::Seek && commands.back().epoch == command.epoch) {
|
||||
commands.back().seconds = command.seconds;
|
||||
} else {
|
||||
if (commands.size() >= commandLimit) return false;
|
||||
commands.push_back(std::move(command));
|
||||
}
|
||||
if (newTimeline) requestedEpoch.store(commands.back().epoch, std::memory_order_relaxed);
|
||||
if (commands.back().action == Action::Open) {
|
||||
const auto revision = state.revision + 1;
|
||||
@@ -388,7 +365,7 @@ struct Player {
|
||||
}
|
||||
std::memcpy(state.error, error.data(), error.size());
|
||||
++state.revision;
|
||||
if (reporter && reportingInputsHealthy()) reporter->observe(state);
|
||||
if (reporter && !remoteReporting && reportingInputsHealthy()) reporter->observe(state);
|
||||
}
|
||||
|
||||
bool reportingInputsHealthy() const {
|
||||
@@ -397,7 +374,7 @@ struct Player {
|
||||
}
|
||||
|
||||
void reportBoundary() {
|
||||
if (!reporter || !reportingInputsHealthy()) return;
|
||||
if (!reporter || remoteReporting || !reportingInputsHealthy()) return;
|
||||
// This is the old worker-owned timeline, even if a new open has already
|
||||
// replaced the public state with OPENING. Never sample a queued seek target.
|
||||
PlayerState snapshot{};
|
||||
@@ -481,6 +458,7 @@ struct Player {
|
||||
track.codec->pkt_timebase = source->streams[index]->time_base;
|
||||
// A bounded software decoder pool; FFmpeg owns its internal frame reorder buffer.
|
||||
track.codec->thread_count = 2;
|
||||
if (type == AVMEDIA_TYPE_VIDEO) playback::preferVideoToolbox(track.codec);
|
||||
check(avcodec_open2(track.codec, decoder, nullptr), "Opening decoder");
|
||||
}
|
||||
|
||||
@@ -490,7 +468,9 @@ struct Player {
|
||||
for (auto &track : tracks) {
|
||||
if (!track.codec) continue;
|
||||
avcodec_flush_buffers(track.codec);
|
||||
track.codec->skip_frame = AVDISCARD_DEFAULT;
|
||||
track.drainSent = track.drained = false;
|
||||
track.firstPacket = true;
|
||||
av_packet_unref(track.packet);
|
||||
track.packetPending = track.receiving = track.sourceEnded = false;
|
||||
track.packetBufferedEnd = track.decoderBufferedEnd = 0;
|
||||
@@ -521,20 +501,40 @@ struct Player {
|
||||
steadyAnchor = Clock::now();
|
||||
}
|
||||
|
||||
playback::NetworkInput *openNetworkInput(int slot, const std::string &url, const std::string &headers, bool live) {
|
||||
playback::NetworkInput *openNetworkInput(int slot, const std::string &url, const std::string &headers,
|
||||
bool live, bool resolved) {
|
||||
inputs[slot] = std::make_unique<playback::NetworkInput>(stopping, requestedEpoch, activeEpoch, wake, cacheBudget);
|
||||
auto &input = *inputs[slot];
|
||||
input.format = avformat_alloc_context();
|
||||
if (!input.format) throw std::bad_alloc();
|
||||
input.format->interrupt_callback = {playback::NetworkInput::interrupt, &input};
|
||||
if (resolved) {
|
||||
input.headerOrigin = networkOrigin(url.c_str());
|
||||
input.requestHeaders = headers;
|
||||
input.format->opaque = &input;
|
||||
input.format->io_open = resolvedIOOpen;
|
||||
}
|
||||
AVDictionary *options = nullptr;
|
||||
int result = av_dict_set(&options, "protocol_whitelist", "http,https,tcp,tls,crypto", 0);
|
||||
if (result >= 0) result = av_dict_set(&options, "rw_timeout", "15000000", 0);
|
||||
// Leave room for socket-timeout recovery within the reader's total 15-second deadline.
|
||||
if (result >= 0) result = av_dict_set(&options, "rw_timeout", live ? "15000000" : "5000000", 0);
|
||||
// Some YouTube CDNs throttle open-ended ranges; FFmpeg continues bounded requests.
|
||||
if (result >= 0 && !live) result = av_dict_set(&options, "request_size", "10485760", 0);
|
||||
if (result >= 0 && !live) result = av_dict_set(&options, "multiple_requests", "1", 0);
|
||||
// Resume seekable inputs at their byte offset; never restart non-range streams or EOF.
|
||||
if (result >= 0 && !live) result = av_dict_set(&options, "reconnect", "1", 0);
|
||||
if (result >= 0 && !live) result = av_dict_set(&options, "reconnect_on_network_error", "1", 0);
|
||||
if (result >= 0 && !live) result = av_dict_set(&options, "reconnect_max_retries", "3", 0);
|
||||
if (result >= 0 && !live) result = av_dict_set(&options, "reconnect_delay_max", "3", 0);
|
||||
if (result >= 0 && !live) result = av_dict_set(&options, "reconnect_delay_total_max", "5", 0);
|
||||
// Leave short-seek read-ahead disabled: cache misses must escape stalled responses.
|
||||
if (result >= 0 && !headers.empty()) result = av_dict_set(&options, "headers", headers.c_str(), 0);
|
||||
if (result >= 0 && resolved) result = av_dict_set(&options, "http_persistent", "0", 0);
|
||||
// HLS persistent requests bypass io_open (and its origin/header policy).
|
||||
if (result >= 0 && resolved) result = av_dict_set(&options, "http_multiple", "0", 0);
|
||||
// Resolved HLS may name genuine TS/fMP4 resources as images. Probe their
|
||||
// content instead of trusting suffixes; io_open still enforces every URL.
|
||||
if (result >= 0 && resolved) result = av_dict_set(&options, "extension_picky", "0", 0);
|
||||
if (result < 0) {
|
||||
av_dict_free(&options);
|
||||
check(result, "Configuring network input");
|
||||
@@ -556,13 +556,15 @@ struct Player {
|
||||
|
||||
void open(const Command &command) {
|
||||
reportBoundary();
|
||||
remoteReporting = false;
|
||||
if (reporter) reporter->begin(command.mediaURL, command.live);
|
||||
release();
|
||||
network = command.network;
|
||||
status = PLAYER_OPENING;
|
||||
publish();
|
||||
if (network) {
|
||||
auto *primary = openNetworkInput(0, command.path, command.videoHeaders, command.live);
|
||||
const bool resolved = command.mediaURL.starts_with("miruro:") || command.mediaURL.starts_with("anilist:");
|
||||
auto *primary = openNetworkInput(0, command.path, command.videoHeaders, command.live, resolved);
|
||||
format = primary->format;
|
||||
openTrack(1, format, primary, AVMEDIA_TYPE_VIDEO, -1);
|
||||
if (command.audioURL.empty()) {
|
||||
@@ -570,7 +572,7 @@ struct Player {
|
||||
} else {
|
||||
if (!tracks[1].codec)
|
||||
throw std::runtime_error("The selected video stream contains no playable video");
|
||||
auto *audio = openNetworkInput(1, command.audioURL, command.audioHeaders, command.live);
|
||||
auto *audio = openNetworkInput(1, command.audioURL, command.audioHeaders, command.live, resolved);
|
||||
openTrack(0, audio->format, audio, AVMEDIA_TYPE_AUDIO, -1);
|
||||
if (!tracks[0].codec)
|
||||
throw std::runtime_error("The selected audio stream contains no playable audio");
|
||||
@@ -622,10 +624,12 @@ struct Player {
|
||||
}
|
||||
if (context->duration != AV_NOPTS_VALUE && context->duration > 0)
|
||||
measuredDuration = std::max(measuredDuration, static_cast<double>(context->duration) / AV_TIME_BASE);
|
||||
// Manifest demuxers own their I/O; a nil top-level AVIOContext does not forbid time seeks.
|
||||
// HLS time seeks reopen segments; byte-seek support on the initial
|
||||
// manifest response says nothing about its demuxer's seek capability.
|
||||
seekable = seekable && !(context->ctx_flags & AVFMTCTX_UNSEEKABLE) &&
|
||||
(context->pb ? (context->pb->seekable & AVIO_SEEKABLE_NORMAL) != 0 :
|
||||
(context->iformat->flags & AVFMT_NOFILE) != 0);
|
||||
(context->iformat == av_find_input_format("hls") ||
|
||||
(context->iformat->flags & AVFMT_NOFILE) != 0 ||
|
||||
(context->pb && (context->pb->seekable & AVIO_SEEKABLE_NORMAL) != 0));
|
||||
}
|
||||
// One origin for both inputs: independently zeroing them would erase real A/V offsets.
|
||||
origin = std::isfinite(first) ? first : 0;
|
||||
@@ -762,7 +766,9 @@ struct Player {
|
||||
for (auto &input : inputs) {
|
||||
if (!input) continue;
|
||||
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();
|
||||
else
|
||||
input->seek(-1, timestamp);
|
||||
@@ -906,7 +912,9 @@ struct Player {
|
||||
}
|
||||
|
||||
void execute(const Command &command) {
|
||||
if (command.epoch != requestedEpoch.load(std::memory_order_relaxed)) return;
|
||||
// Receiver facts precede the next open/close even when it supersedes decoder I/O.
|
||||
if (command.action == Action::Remote ? command.epoch != activeEpoch :
|
||||
command.epoch != requestedEpoch.load(std::memory_order_relaxed)) return;
|
||||
activeEpoch = command.epoch;
|
||||
if (reporter && (command.action == Action::Seek || command.action == Action::Step)) {
|
||||
// Local seeks can finish without a pending publication. Preserve
|
||||
@@ -920,9 +928,18 @@ struct Player {
|
||||
case Action::Pause: pause(); break;
|
||||
case Action::Seek: seek(command.seconds, status == PLAYER_PLAYING); break;
|
||||
case Action::Step: step(command.direction); break;
|
||||
case Action::Remote:
|
||||
if (!remoteReporting) {
|
||||
reportBoundary();
|
||||
release();
|
||||
}
|
||||
remoteReporting = true;
|
||||
if (reporter) reporter->observe(command.remoteState);
|
||||
return;
|
||||
case Action::Close:
|
||||
reportBoundary();
|
||||
if (reporter) reporter->closeMedia();
|
||||
remoteReporting = false;
|
||||
release();
|
||||
break;
|
||||
}
|
||||
@@ -961,8 +978,10 @@ struct Player {
|
||||
}
|
||||
const double timestamp = framePts(track, decoded);
|
||||
const double tick = av_q2d(track.stream()->time_base);
|
||||
const bool contiguous = continuing && std::abs(timestamp - track.nextPts) <= tick + 1.0 / rate;
|
||||
// Quantized packet PTS and rounded resampler delay must not insert/drop PCM samples.
|
||||
// Some TS muxers round timestamps to milliseconds despite a 90 kHz timebase.
|
||||
// Allow two rounded endpoints of jitter; real gaps still insert silence.
|
||||
const bool contiguous = continuing &&
|
||||
std::abs(timestamp - track.nextPts) <= std::max(tick, 0.002) + 1.0 / rate;
|
||||
const int64_t delayBase = static_cast<int64_t>(rate) * sampleRate;
|
||||
pcmPts = contiguous ? resampleNextPts :
|
||||
timestamp - static_cast<double>(swr_get_delay(resampler, delayBase)) / delayBase;
|
||||
@@ -1074,8 +1093,11 @@ struct Player {
|
||||
return;
|
||||
skipPresentedTimestamp = AV_NOPTS_VALUE;
|
||||
}
|
||||
const int size = av_image_get_buffer_size(static_cast<AVPixelFormat>(decoded->format),
|
||||
decoded->width, decoded->height, 1);
|
||||
Frame frame(av_frame_alloc());
|
||||
if (!frame) throw std::bad_alloc();
|
||||
check(playback::refVideoFrame(frame.get(), decoded), "Mapping decoded video");
|
||||
const int size = av_image_get_buffer_size(static_cast<AVPixelFormat>(frame->format),
|
||||
frame->width, frame->height, 1);
|
||||
check(size, "Sizing decoded video");
|
||||
const auto bytes = static_cast<std::size_t>(size);
|
||||
if (bytes > videoByteLimit)
|
||||
@@ -1088,8 +1110,6 @@ struct Player {
|
||||
videoBytes -= video.back().bytes;
|
||||
video.pop_back();
|
||||
}
|
||||
Frame frame(av_frame_clone(decoded));
|
||||
if (!frame) throw std::bad_alloc();
|
||||
VideoFrame item{std::move(frame), pts, pts + frameDuration, bytes, track.timestampOccurrence};
|
||||
auto insertion = std::upper_bound(video.begin(), video.end(), pts,
|
||||
[](double value, const VideoFrame &entry) { return value < entry.pts; });
|
||||
@@ -1100,8 +1120,12 @@ struct Player {
|
||||
bool decodeOne(bool stepping = false) {
|
||||
for (const auto &input : inputs)
|
||||
if (input) check(input->error(), "Reading network media");
|
||||
// Once a paused network preview is shown, finish audio preroll first.
|
||||
// Video remains a fallback so a combined reader can relieve byte pressure.
|
||||
const int firstSlot = network && status == PLAYER_PAUSED && previewShown && !stepping ?
|
||||
0 : nextDecodeSlot;
|
||||
for (int offset = 0; offset < 2; ++offset) {
|
||||
const int slot = (nextDecodeSlot + offset) % 2;
|
||||
const int slot = (firstSlot + offset) % 2;
|
||||
auto &track = tracks[slot];
|
||||
if (!track.codec || track.drained || (stepping && slot == 0)) continue;
|
||||
if (slot == 0 && pcmOffset < pcmFrames) continue;
|
||||
@@ -1125,6 +1149,22 @@ struct Player {
|
||||
return true;
|
||||
}
|
||||
if (track.packetPending) {
|
||||
if (slot == 1 && track.firstPacket) {
|
||||
check(playback::prepareVideoPacket(track.codec, *track.stream()->codecpar, *track.packet),
|
||||
"Preparing video decoder preroll");
|
||||
track.firstPacket = false;
|
||||
}
|
||||
if (slot == 1) {
|
||||
// Preroll needs reference pictures, not disposable pictures before
|
||||
// the target. Restore full decoding before the target packet and
|
||||
// for exact frame stepping; never guess when timestamps are absent.
|
||||
const auto *packet = track.packet;
|
||||
const bool preroll = !network && !stepping && !previewShown && seekTarget > 0 &&
|
||||
packet->pts != AV_NOPTS_VALUE &&
|
||||
packet->duration > 0 &&
|
||||
(double(packet->pts) + packet->duration) * av_q2d(track.stream()->time_base) - origin < seekTarget;
|
||||
track.codec->skip_frame = preroll ? AVDISCARD_NONREF : AVDISCARD_DEFAULT;
|
||||
}
|
||||
const int result = avcodec_send_packet(track.codec, track.packet);
|
||||
if (result != AVERROR(EAGAIN)) {
|
||||
check(result, "Sending media packet to decoder");
|
||||
@@ -1345,6 +1385,15 @@ struct Player {
|
||||
}
|
||||
}
|
||||
try {
|
||||
if (reporter) {
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
for (const auto &command : commands) {
|
||||
if (command.action == Action::Remote && command.epoch == activeEpoch) {
|
||||
reporter->observe(command.remoteState);
|
||||
remoteReporting = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
reportBoundary();
|
||||
if (reporter) reporter->closeMedia();
|
||||
release();
|
||||
@@ -1369,17 +1418,21 @@ bool submit(Player *player, Function function) noexcept {
|
||||
|
||||
extern "C" {
|
||||
Player *player_create(void) {
|
||||
try { return new Player; } catch (...) { return nullptr; }
|
||||
try {
|
||||
static std::once_flag logging;
|
||||
std::call_once(logging, [] { av_log_set_callback(redactedFFmpegLog); });
|
||||
return new Player;
|
||||
} catch (...) { return nullptr; }
|
||||
}
|
||||
bool player_set_progress_file(Player *player, const char *path, char *error) {
|
||||
if (error) error[0] = '\0';
|
||||
try {
|
||||
if (!player || !path || !*path) throw std::runtime_error("A progress file path is required");
|
||||
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())
|
||||
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;
|
||||
} catch (const std::exception &failure) {
|
||||
if (error) copyError(error, failure.what());
|
||||
@@ -1388,6 +1441,23 @@ bool player_set_progress_file(Player *player, const char *path, char *error) {
|
||||
}
|
||||
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) {
|
||||
if (error) error[0] = '\0';
|
||||
if (!player || !player->reporter) return false;
|
||||
@@ -1409,7 +1479,7 @@ bool player_open(Player *player, const char *path) {
|
||||
bool player_open_network(Player *player, const PlayerNetworkSource *source) {
|
||||
if (!source || !std::isfinite(source->duration) || source->duration < 0 ||
|
||||
!std::isfinite(source->initial_position) || source->initial_position < 0 ||
|
||||
(source->media_url && *source->media_url && !playback::ProgressReporter::validMediaURL(source->media_url)) ||
|
||||
(source->media_url && *source->media_url && !playback::ProgressReporter::validMediaIdentity(source->media_url)) ||
|
||||
!validNetworkURL(source->video_url) ||
|
||||
(source->audio_url && *source->audio_url && !validNetworkURL(source->audio_url)) ||
|
||||
!validHeaders(source->video_headers) || !validHeaders(source->audio_headers)) return false;
|
||||
@@ -1443,6 +1513,18 @@ bool player_step_frame(Player *player, int direction) {
|
||||
bool player_close(Player *player) {
|
||||
return submit(player, [=] { return player->enqueue({Action::Close, {}}); });
|
||||
}
|
||||
bool player_report_remote_state(Player *player, const PlayerState *state) {
|
||||
if (!state || !std::isfinite(state->position) || state->position < 0 ||
|
||||
!std::isfinite(state->duration) || state->duration < 0 ||
|
||||
(state->status != PLAYER_PAUSED && state->status != PLAYER_PLAYING &&
|
||||
state->status != PLAYER_ENDED)) return false;
|
||||
const PlayerState snapshot = *state;
|
||||
return submit(player, [=] {
|
||||
Command command{Action::Remote, {}};
|
||||
command.remoteState = snapshot;
|
||||
return player->enqueue(std::move(command));
|
||||
});
|
||||
}
|
||||
bool player_set_volume(Player *player, float normalized0to1) {
|
||||
if (!std::isfinite(normalized0to1) || normalized0to1 < 0 || normalized0to1 > 1) return false;
|
||||
return submit(player, [=] {
|
||||
|
||||
+11
-1
@@ -40,6 +40,12 @@ Player *player_create(void);
|
||||
// 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.
|
||||
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);
|
||||
// Stops the worker and drains final reporting, allowing errors to be shown before
|
||||
// destroying the handle. Idempotent; do not call from an audio callback.
|
||||
@@ -53,7 +59,8 @@ typedef struct PlayerNetworkSource {
|
||||
const char *audio_headers;
|
||||
double duration;
|
||||
bool is_live;
|
||||
// Canonical YouTube webpage identity, never an extracted stream URL.
|
||||
// Original HTTP(S) video URL (canonical for YouTube), or stable
|
||||
// miruro:<opaque media_id>:<episode> or anilist:<decimal ID>:<episode> identity, never an extracted CDN URL.
|
||||
// NULL/empty means no reporting for this source.
|
||||
const char *media_url;
|
||||
// Initial restored position, applied before publishing ready state. Zero
|
||||
@@ -70,6 +77,9 @@ bool player_seek(Player *player, double seconds);
|
||||
// Pauses playback and selects the adjacent decoded presentation frame.
|
||||
bool player_step_frame(Player *player, int direction);
|
||||
bool player_close(Player *player);
|
||||
// Receiver-confirmed Cast facts replace decoder facts in progress reporting until
|
||||
// the next open/close. The local decoder must be paused before submitting them.
|
||||
bool player_report_remote_state(Player *player, const PlayerState *state);
|
||||
// Synchronous, thread-safe gain; survives open, seek, and close. Zero mutes.
|
||||
// Rejects null handles and non-finite/out-of-range values without changing gain.
|
||||
bool player_set_volume(Player *player, float normalized0to1);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#ifndef PERSONAL_PLAYER_MACOS_H
|
||||
#define PERSONAL_PLAYER_MACOS_H
|
||||
#include "player.h"
|
||||
#include "cast_media.h"
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "progress_reporter.hpp"
|
||||
#include "network_policy.hpp"
|
||||
#include <cerrno>
|
||||
#include <charconv>
|
||||
#include <chrono>
|
||||
@@ -28,7 +29,7 @@ void appendNumber(std::string &record, double value) {
|
||||
}
|
||||
|
||||
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)
|
||||
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);
|
||||
@@ -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));
|
||||
if ((info.st_mode & 07777) != 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(); });
|
||||
} catch (...) {
|
||||
::close(descriptor);
|
||||
@@ -59,21 +62,68 @@ ProgressReporter::ProgressReporter(const std::string &path, std::function<double
|
||||
|
||||
ProgressReporter::~ProgressReporter() { finish(); }
|
||||
|
||||
bool ProgressReporter::validMediaURL(std::string_view url) noexcept {
|
||||
constexpr std::string_view prefix = "https://www.youtube.com/watch?v=";
|
||||
if (url.size() != prefix.size() + 11 || url.substr(0, prefix.size()) != prefix) return false;
|
||||
for (char character : url.substr(prefix.size())) {
|
||||
if (!((character >= 'a' && character <= 'z') || (character >= 'A' && character <= 'Z') ||
|
||||
(character >= '0' && character <= '9') || character == '_' || character == '-')) return false;
|
||||
void ProgressReporter::resume() {
|
||||
if (!finished) return;
|
||||
if (disabled.load(std::memory_order_acquire))
|
||||
throw std::runtime_error("Cannot resume a failed progress report");
|
||||
descriptor = ::open(path.c_str(), O_WRONLY | O_APPEND | O_NONBLOCK | O_NOFOLLOW | O_CLOEXEC);
|
||||
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 {
|
||||
if (validMediaURL(identity)) return true;
|
||||
const bool anilist = identity.starts_with("anilist:");
|
||||
const std::string_view prefix = anilist ? "anilist:" : "miruro:";
|
||||
if (!identity.starts_with(prefix)) return false;
|
||||
const auto separator = identity.find(':', prefix.size());
|
||||
if (separator == std::string_view::npos) return false;
|
||||
const auto id = identity.substr(prefix.size(), separator - prefix.size());
|
||||
if (id.empty() || id.size() > 512) return false;
|
||||
if (anilist) {
|
||||
if (id.front() < '1' || id.front() > '9') return false;
|
||||
for (char c : id) if (c < '0' || c > '9') return false;
|
||||
int value = 0;
|
||||
const auto parsed = std::from_chars(id.data(), id.data() + id.size(), value);
|
||||
if (parsed.ec != std::errc{} || parsed.ptr != id.data() + id.size() || value <= 0) return false;
|
||||
}
|
||||
for (char c : id) {
|
||||
if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
|
||||
(c >= '0' && c <= '9') || c == '-' || c == '_')) return false;
|
||||
}
|
||||
const auto episode = identity.substr(separator + 1);
|
||||
if (episode.empty() || episode.front() < '1' || episode.front() > '9') return false;
|
||||
for (char c : episode) if (c < '0' || c > '9') return false;
|
||||
int value = 0;
|
||||
const auto parsed = std::from_chars(episode.data(), episode.data() + episode.size(), value);
|
||||
return parsed.ec == std::errc{} && parsed.ptr == episode.data() + episode.size() && value > 0;
|
||||
}
|
||||
|
||||
void ProgressReporter::begin(const std::string &url, bool isLive) noexcept {
|
||||
if (finished || disabled.load(std::memory_order_acquire)) return;
|
||||
if (url.empty()) { closeMedia(); return; }
|
||||
if (!validMediaURL(url)) {
|
||||
disable("Progress media URL is not a canonical YouTube watch URL");
|
||||
if (!validMediaIdentity(url)) {
|
||||
disable("Progress media identity is invalid");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -144,11 +194,28 @@ bool ProgressReporter::emit(const char *event) noexcept {
|
||||
try {
|
||||
std::string record;
|
||||
record.reserve(256);
|
||||
record += "{\"version\":1,\"event\":\"";
|
||||
const bool anilist = mediaURL.starts_with("anilist:");
|
||||
const bool resolved = anilist || mediaURL.starts_with("miruro:");
|
||||
record += resolved ? "{\"version\":2,\"event\":\"" : "{\"version\":1,\"event\":\"";
|
||||
record += event;
|
||||
record += "\",\"media_url\":\"";
|
||||
record += mediaURL;
|
||||
record += "\",\"position_seconds\":";
|
||||
if (resolved) {
|
||||
const size_t prefix = anilist ? 8 : 7;
|
||||
const auto separator = mediaURL.find(':', prefix);
|
||||
record += "\",\"media_identity\":{\"provider\":\"";
|
||||
record += anilist ? "anilist" : "miruro";
|
||||
record += "\",\"media_id\":\"";
|
||||
record.append(mediaURL, prefix, separator - prefix);
|
||||
record += "\",\"episode\":";
|
||||
record.append(mediaURL, separator + 1, std::string::npos);
|
||||
record += "},\"position_seconds\":";
|
||||
} else {
|
||||
record += "\",\"media_url\":\"";
|
||||
for (char character : mediaURL) {
|
||||
if (character == '"' || character == '\\') record += '\\';
|
||||
record += character;
|
||||
}
|
||||
record += "\",\"position_seconds\":";
|
||||
}
|
||||
appendNumber(record, snapshot.position);
|
||||
record += ",\"duration_seconds\":";
|
||||
if (snapshot.duration > 0) appendNumber(record, snapshot.duration);
|
||||
@@ -231,4 +298,113 @@ void ProgressReporter::finish() noexcept {
|
||||
wake.notify_all();
|
||||
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 {};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
#include <functional>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <set>
|
||||
#include <sys/types.h>
|
||||
#include <string_view>
|
||||
#include <thread>
|
||||
|
||||
@@ -20,11 +24,14 @@ public:
|
||||
ProgressReporter &operator=(const ProgressReporter &) = delete;
|
||||
|
||||
static bool validMediaURL(std::string_view url) noexcept;
|
||||
static bool validMediaIdentity(std::string_view identity) noexcept;
|
||||
void begin(const std::string &mediaURL, bool live = false) noexcept;
|
||||
void observe(const PlayerState &state) noexcept;
|
||||
void closeMedia() noexcept;
|
||||
// Call after the decoder joins; drains queued records and joins the writer.
|
||||
void finish() noexcept;
|
||||
// Reopen only this session's original private inode, never an arbitrary existing file.
|
||||
void resume();
|
||||
std::string error() const;
|
||||
|
||||
private:
|
||||
@@ -35,6 +42,9 @@ private:
|
||||
bool eligible = false;
|
||||
} snapshot;
|
||||
std::string mediaURL;
|
||||
std::string path;
|
||||
dev_t device = 0;
|
||||
ino_t inode = 0;
|
||||
std::function<double()> clock;
|
||||
bool live = false;
|
||||
bool hasSnapshot = false;
|
||||
@@ -57,4 +67,29 @@ private:
|
||||
void disable(const char *message, int osError = 0) 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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
#pragma once
|
||||
#include <algorithm>
|
||||
extern "C" {
|
||||
#include <libavcodec/avcodec.h>
|
||||
#include <libavutil/hwcontext.h>
|
||||
}
|
||||
|
||||
namespace playback {
|
||||
inline bool hasIDR(const AVPacket &packet, const AVCodecParameters &codec) {
|
||||
const auto *bytes = packet.data;
|
||||
const size_t size = size_t(std::max(0, packet.size));
|
||||
if (codec.extradata_size >= 5 && codec.extradata[0] == 1) {
|
||||
const size_t lengthSize = (codec.extradata[4] & 3) + 1;
|
||||
for (size_t at = 0; at + lengthSize <= size;) {
|
||||
uint32_t length = 0;
|
||||
for (size_t i = 0; i < lengthSize; ++i) length = (length << 8) | bytes[at++];
|
||||
if (!length || length > size - at) return false;
|
||||
if ((bytes[at] & 31) == 5) return true;
|
||||
at += length;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
for (size_t at = 0; at + 3 < size; ++at) {
|
||||
if (bytes[at] == 0 && bytes[at + 1] == 0 && bytes[at + 2] == 1 &&
|
||||
(bytes[at + 3] & 31) == 5) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// FFmpeg retries software if VideoToolbox rejects the stream; HEVC may also use
|
||||
// Apple's platform software fallback. Keep the normal decoder for other codecs.
|
||||
static inline void preferVideoToolbox(AVCodecContext *context) {
|
||||
#ifndef TEREBI_SOFTWARE_DECODE
|
||||
// Prefer only the codecs measured in native playback and the Cast relay.
|
||||
if (context->codec_id != AV_CODEC_ID_H264 && context->codec_id != AV_CODEC_ID_HEVC &&
|
||||
context->codec_id != AV_CODEC_ID_VP9) return;
|
||||
for (int index = 0; const auto *config = avcodec_get_hw_config(context->codec, index); ++index) {
|
||||
if (config->device_type != AV_HWDEVICE_TYPE_VIDEOTOOLBOX ||
|
||||
!(config->methods & AV_CODEC_HW_CONFIG_METHOD_HW_DEVICE_CTX)) continue;
|
||||
// The default get_format prefers this device and retries software if
|
||||
// VideoToolbox rejects the stream; device creation failure is also safe.
|
||||
av_hwdevice_ctx_create(&context->hw_device_ctx, AV_HWDEVICE_TYPE_VIDEOTOOLBOX,
|
||||
nullptr, nullptr, 0);
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
}
|
||||
// Starting at a dependent packet can make VideoToolbox fail during send_packet,
|
||||
// after format negotiation has already succeeded. Preserve software preroll.
|
||||
inline bool independentVideoPacket(const AVPacket &packet, const AVCodecParameters ¶meters) {
|
||||
return (packet.flags & AV_PKT_FLAG_KEY) &&
|
||||
(parameters.codec_id != AV_CODEC_ID_H264 || hasIDR(packet, parameters));
|
||||
}
|
||||
inline int prepareVideoPacket(AVCodecContext *&context, const AVCodecParameters ¶meters,
|
||||
const AVPacket &packet) {
|
||||
if (!context->hw_device_ctx || independentVideoPacket(packet, parameters)) return 0;
|
||||
auto *software = avcodec_alloc_context3(context->codec);
|
||||
if (!software) return AVERROR(ENOMEM);
|
||||
int result = avcodec_parameters_to_context(software, ¶meters);
|
||||
software->pkt_timebase = context->pkt_timebase;
|
||||
software->thread_count = context->thread_count;
|
||||
software->max_pixels = context->max_pixels;
|
||||
if (result >= 0) result = avcodec_open2(software, context->codec, nullptr);
|
||||
if (result < 0) { avcodec_free_context(&software); return result; }
|
||||
avcodec_free_context(&context); context = software;
|
||||
return 0;
|
||||
}
|
||||
// Mapping retains and locks the CVPixelBuffer until the last frame reference
|
||||
// is released. Consumers see ordinary CPU-readable planes without a pixel copy.
|
||||
inline int refVideoFrame(AVFrame *destination, const AVFrame *source) {
|
||||
if (source->format == AV_PIX_FMT_VIDEOTOOLBOX)
|
||||
return av_hwframe_map(destination, source, AV_HWFRAME_MAP_READ);
|
||||
return av_frame_ref(destination, source);
|
||||
}
|
||||
}
|
||||
@@ -14,11 +14,11 @@ enum FFmpegRuntime {
|
||||
guard paths.count == libraries.count else { return metadataError }
|
||||
var found = Set<String>()
|
||||
for path in paths {
|
||||
guard path.hasPrefix("/"), !path.contains("\0"), !path.contains("\r") else {
|
||||
guard !path.contains("/"), !path.contains("\0"), !path.contains("\r") else {
|
||||
return metadataError
|
||||
}
|
||||
let parts = URL(fileURLWithPath: path).lastPathComponent.split(separator: ".", omittingEmptySubsequences: false)
|
||||
guard parts.count >= 2, parts.last == "dylib" else { return metadataError }
|
||||
guard parts.count >= 3, parts.last == "dylib" else { return metadataError }
|
||||
let library = String(parts[0])
|
||||
guard libraries.contains(library),
|
||||
parts.dropFirst().dropLast().allSatisfy({ !$0.isEmpty && $0.utf8.allSatisfy { (48...57).contains($0) } }),
|
||||
@@ -26,11 +26,13 @@ enum FFmpegRuntime {
|
||||
return metadataError
|
||||
}
|
||||
}
|
||||
for path in paths {
|
||||
let frameworks = app.appendingPathComponent("Contents/Frameworks", isDirectory: true)
|
||||
for filename in paths {
|
||||
let path = frameworks.appendingPathComponent(filename).path
|
||||
_ = dlerror()
|
||||
guard dlopen(path, RTLD_NOW | RTLD_LOCAL) != nil else {
|
||||
let detail = dlerror().map { String(cString: $0) } ?? "The dynamic loader could not load this library."
|
||||
return "The FFmpeg libraries required by this build are unavailable or broken.\n\nRequired library: \(path)\n\(detail)\n\nInstall FFmpeg with `brew install ffmpeg`. If FFmpeg was upgraded or its library versions changed, rebuild and reinstall terebi."
|
||||
return "This app's bundled FFmpeg libraries are missing or broken.\n\nRequired library: \(filename)\n\(detail)\n\nRebuild and reinstall terebi, or reinstall a complete bundled app."
|
||||
}
|
||||
// Deliberately never dlclose: retain successful loads until exit.
|
||||
}
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
# Handoff: Miruro playback in Terebi
|
||||
|
||||
## Status
|
||||
|
||||
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 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
|
||||
|
||||
- **Tubular:** catalog/detail browsing, actual returned episode selection, collections, private reporting sessions, cleanup and watched-state decisions.
|
||||
- **Terebi:** episode-URL validation, fresh source resolution, headers/subtitles, titles, optional timeline bands, stable-identity resume and progress reporting.
|
||||
|
||||
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
|
||||
|
||||
```sh
|
||||
terebi --new-instance \
|
||||
--url 'https://barelystarted.miruro.tv/watch/OPAQUE_ID/episode?ep=1' \
|
||||
--progress-file '/private/session/progress.jsonl'
|
||||
```
|
||||
|
||||
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 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
|
||||
|
||||
Example only: replace CDN URLs with freshly resolved resources, preserving complete addresses and tokens.
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"url": "https://cdn.example/master.m3u8?token=fresh",
|
||||
"headers": {
|
||||
"Origin": "https://ultracloud.cc",
|
||||
"Referer": "https://ultracloud.cc/"
|
||||
},
|
||||
"identity": {
|
||||
"provider": "miruro",
|
||||
"media_id": "OERe_a0YS4h-DlsBDOeXwX7HOO6316tw",
|
||||
"episode": 1
|
||||
},
|
||||
"title": "The Apothecary Diaries — episode 1",
|
||||
"subtitles": [{
|
||||
"url": "https://cdn.example/english.jpeg?token=fresh",
|
||||
"format": "vtt",
|
||||
"language": "en",
|
||||
"label": "English",
|
||||
"default": true,
|
||||
"headers": {
|
||||
"Origin": "https://ultracloud.cc",
|
||||
"Referer": "https://ultracloud.cc/"
|
||||
}
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
All shown fields except `title` are required. `headers: {}` and `subtitles: []` are valid; at most one subtitle may be default. Only `vtt` is supported for external subtitles in this pass.
|
||||
|
||||
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.
|
||||
|
||||
### External source projection
|
||||
|
||||
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.
|
||||
|
||||
`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.
|
||||
|
||||
## Native provider transport
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
- 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.
|
||||
- Subtitle HTTP requests use their own headers. Same-origin redirects retain those headers; cross-origin redirects are rejected. Responses are capped at 4 MiB, with 15-second request and 20-second resource timeouts.
|
||||
- Media errors remain visible with the operation/native error. Subtitle failures appear separately beside the captions menu, including when other controls are hidden. FFmpeg diagnostics redact private details; source/subtitle errors do not interpolate URLs or header values.
|
||||
- Casting uses the same episode identity and progress schema; receiver-confirmed positions replace paused local-decoder facts. The Mac relays media/VTT without exposing source URLs or credentials to the receiver. See [Chromecast controls, transport limits and verification](../README.md#chromecast); physical receiver playback/captions remain unverified.
|
||||
|
||||
YouTube's existing launch, version-1 progress and transport behavior remain supported.
|
||||
|
||||
## Progress contract and Tubular change
|
||||
|
||||
YouTube records remain **version 1** with canonical `media_url`. Miruro records are **version 2**, with structured episode identity and no CDN address:
|
||||
|
||||
```json
|
||||
{"version":2,"event":"progress","media_identity":{"provider":"miruro","media_id":"OERe_a0YS4h-DlsBDOeXwX7HOO6316tw","episode":1},"position_seconds":123.5,"duration_seconds":1500.0,"completion_eligible":true}
|
||||
```
|
||||
|
||||
`event` is `progress`, `ended` or `closed`. Position is finite, nonnegative and acknowledged by playback, not a pending seek/slider target. Duration is positive and finite or `null`. Completion eligibility remains false for live/non-seekable media, unknown duration and audio-only sources.
|
||||
|
||||
Reporting includes ready state after restoration, approximately five-second playback intervals, acknowledged pause/seek transitions, the first eligible 95% crossing, native end and final close/quit snapshots. Launch success and window closure do not establish completion. Consumers must tolerate duplicates, partial trailing lines and abrupt termination; a `closed` record is not guaranteed.
|
||||
|
||||
Tubular integration must retain canonical YouTube/version-1 validation, accept version-2 validation against the episode identity expected for that launched session, and key Miruro watched state by `(miruro, opaque media ID, episode)`. Never use stream URL, token, slug or selected playback provider as identity.
|
||||
|
||||
Retain the current completion decision for matching identities:
|
||||
|
||||
```text
|
||||
completion_eligible
|
||||
&& duration_seconds > 0
|
||||
&& position_seconds / duration_seconds >= 0.95
|
||||
```
|
||||
|
||||
Handle `null` duration as ineligible. This measures position, not viewing coverage; seeking near the end can satisfy it. Terebi also uses stable episode identity for its own resume bookmarks.
|
||||
|
||||
## Verification evidence
|
||||
|
||||
| Scenario | Observed result |
|
||||
| --- | --- |
|
||||
| Explicit fresh direct Sun native smoke | Video/audio decode and advancing playback; default English VTT; expected cue text after forward/backward acknowledged seeks |
|
||||
| Live Sun audio continuity | Dragon Ball episode 1: actual Core Audio PCM diverged from FFmpeg after about 0.6 seconds before the fix; after tolerating millisecond-scale timestamp jitter, all 960,008 stereo frames compared across twenty seconds and multiple HLS segments matched exactly |
|
||||
| Audio regression | Deterministic AAC/MPEG-TS timestamp-jitter fixture failed before the shared continuity fix and matched FFmpeg exactly afterward; existing authored-gap, resampling, gain and stepped-resume checks passed |
|
||||
| Local resolved playback model | Independent stream/subtitle headers, nested playlist tokens, video/audio playback, cue timing and stable v2 progress |
|
||||
| Local native network checks | Encrypted MPEG-TS and fMP4; image-suffixed resources; headers on master/variant/segment/key/init; seeks/frame steps; redirect/foreign-origin isolation; diagnostic redaction; existing YouTube redirects |
|
||||
| Actual CLI-to-GUI handoff | Two independent Miruro episode windows; authenticated encrypted HLS; resource-specific default VTT; v2 progress and quit draining |
|
||||
| Full `make check-startup` rerun | Passed, including held-key suppression, YouTube progress/resume/quality/replacement/end and independent launches |
|
||||
| Other exercised checks | CLI, external subtitles, progress persistence, JSONL reporting, YouTube resolver and system playback passed |
|
||||
| Visible-app observation | User reported playback appears to work; comprehensive visual subtitle/seek synchronization sign-off remains limited |
|
||||
|
||||
Earlier startup runs failed held-Space and YouTube end-transition assertions. The user reported possible manual interaction. The full rerun passed without playback-code changes; causality of the earlier failures was not established. No full aggregate `make check` pass is claimed.
|
||||
|
||||
Streaming-input verification (2026-10-19): native URLSession resolved a live Miruro episode with nine VTT tracks; native URL-to-playback exercised video/audio, pause, default captions and forward/backward acknowledged subtitle seeks. Offline stream parsing/projection/security, CLI/config, resolved playback and YouTube resolver/adaptive checks passed. The actual source-file CLI launch emitted version-2 progress and ended records, but the startup check timed out waiting for GUI state snapshots; this run does not establish the complete two-window handoff scenario. The live probe's original synchronous wait blocked async resolution; yielding the actor fixed the probe without playback-code changes.
|
||||
|
||||
### Reproduction commands from Terebi
|
||||
|
||||
```sh
|
||||
make check-cli check-subtitles check-resolved check-network check-progress check-reporting
|
||||
make check-youtube check-media
|
||||
make check-startup
|
||||
```
|
||||
|
||||
For only the actual Miruro launch scenario:
|
||||
|
||||
```sh
|
||||
TEREBI_CHECK_RESOLVED_ONLY=1 make check-startup
|
||||
```
|
||||
|
||||
Default checks use local fixtures and do not contact Miruro. Optional metadata protocol/security/normalization checks:
|
||||
|
||||
```sh
|
||||
make check-miruro-metadata
|
||||
make .build/resolved-playback-check
|
||||
.build/resolved-playback-check --metadata-lifecycle
|
||||
```
|
||||
|
||||
Explicit live native URL smoke (contacts Miruro and media/subtitle resources):
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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.
|
||||
Reference in New Issue
Block a user