feat: add independent CLI launches and preserve shared video bookmarks

This commit is contained in:
2026-10-11 12:47:08 +02:00
parent 537e8e2624
commit 0dce073287
10 changed files with 254 additions and 40 deletions
+106
View File
@@ -4,11 +4,116 @@ 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
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")
canonical = "https://www.youtube.com/watch?v=aqz-KE-bpKQ"
def invoke(arguments):
result = subprocess.run([str(cli), *arguments], capture_output=True, text=True, timeout=20)
if result.returncode:
raise RuntimeError(f"CLI {arguments} failed: {result.returncode}; {result.stdout}{result.stderr}")
return result
def wait_states(count, ready, message):
deadline = time.monotonic() + 30
states = {}
while time.monotonic() < deadline:
states = {int(path.stem.split("-")[1]): json.loads(path.read_text())
for path in root.glob("state-*.json")}
complete = len(states) == count and all(
set(state["pids"]) == set(states) and state["windows"] == 1 for state in states.values())
if any(state["error"] for state in states.values()):
raise RuntimeError(f"{message}: native playback error: {states}")
if complete and ready(states):
return states
time.sleep(0.02) # Infrastructure deadline; assertions use native events/PTS, not wall dates.
raise RuntimeError(f"{message}: native state deadline: {states}")
with StreamingServer(fixtures) as server:
if extractor and runtime:
metadata = root / "youtube.json"
metadata.write_text(json.dumps({
"id": "aqz-KE-bpKQ", "title": "Independent YouTube fixture", "duration": 120,
"is_live": False, "webpage_url": canonical, "extractor": "youtube",
"extractor_key": "Youtube", "formats": [{
"format_id": "180", "height": 180, "width": 320,
"url": server.base_url + "/media/cache.mp4", "protocol": "http", "ext": "mp4",
"vcodec": "mpeg4", "acodec": "aac", "dynamic_range": "SDR",
"http_headers": {"X-Terebi-Fixture": "combined"}}]}))
wrapper = root / "yt-dlp"
wrapper.write_text("#!/bin/bash\nset -eu\nargs=(\"$@\")\nexec " + shlex.quote(extractor)
+ " --load-info-json " + shlex.quote(str(metadata))
+ ' "${args[@]:0:${#args[@]}-2}"\n')
wrapper.chmod(0o755)
settings = {"directory": str(root), "path": str(root) + os.pathsep + os.environ.get("PATH", "")}
(app / "Contents/Resources/cli-check.json").write_text(json.dumps(settings))
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")
first_pid = next(iter(first))
invoke([])
wait_states(1, lambda states: states[first_pid]["media"] == movie.name,
"warm default CLI reuses the existing process without replacing media")
invoke(["--new-instance"])
second = wait_states(2, lambda states: any(state["media"] == "" and state["status"] == 0
for pid, state in states.items() if pid != first_pid),
"empty independent CLI creates exactly one extra player")
empty_pid = next(pid for pid in second if pid != first_pid)
invoke(["--new-instance", str(audio)])
third = wait_states(3, lambda states: any(state["media"] == audio.name and state["status"] == 3
and state["hasAudio"] and not state["hasVideo"]
for pid, state in states.items() if pid not in second),
"independent local media reaches only the freshly launched process")
if third[first_pid]["media"] != movie.name or third[first_pid]["status"] != 3 or third[empty_pid]["media"]:
raise RuntimeError(f"independent launch disturbed an existing player: {third}")
count = 3
if extractor and runtime:
invoke(["--new-instance", "--url", canonical])
fourth = wait_states(4, lambda states: any(
state["media"] == "Independent YouTube fixture" and state["status"] == 3
and state["position"] > 0 and state["hasAudio"] and state["hasVideo"]
for pid, state in states.items() if pid not in third),
"independent YouTube playback reaches only one new registered process")
if fourth[first_pid]["media"] != movie.name or fourth[first_pid]["status"] != 3 or fourth[empty_pid]["media"]:
raise RuntimeError(f"independent YouTube launch disturbed existing playback: {fourth}")
count = 4
else:
print("SKIP independent YouTube playback: yt-dlp and a supported JS runtime are required")
before = wait_states(count, lambda _: True, "all independent processes remain registered")
invoke(["--new-instance", "--help"])
wait_states(count, lambda _: True, "help never creates an extra application")
replacement = root / "replacement.mp4"
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")
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}")
print("PASS actual CLI instances: cold/default reuse, independent empty/local/YouTube, one window per PID, targeted media, original playback retained, help, warm replacement")
app_source, library, fixtures = map(lambda path: Path(path).resolve(), sys.argv[1:])
with tempfile.TemporaryDirectory(prefix="terebi-startup-") as directory:
@@ -39,6 +144,7 @@ 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)
finally:
os.close(descriptor)
subprocess.run(