feat: report authoritative playback progress from shared backend
This commit is contained in:
+267
-14
@@ -1,5 +1,6 @@
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import math
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import plistlib
|
import plistlib
|
||||||
import selectors
|
import selectors
|
||||||
@@ -20,6 +21,7 @@ def check_cli_instances(root, app, fixtures):
|
|||||||
extractor = shutil.which("yt-dlp")
|
extractor = shutil.which("yt-dlp")
|
||||||
runtime = shutil.which("deno") or shutil.which("node")
|
runtime = shutil.which("deno") or shutil.which("node")
|
||||||
canonical = "https://www.youtube.com/watch?v=aqz-KE-bpKQ"
|
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)]
|
||||||
|
|
||||||
config = root / "xdg/terebi/config.toml"
|
config = root / "xdg/terebi/config.toml"
|
||||||
environment = dict(os.environ, XDG_CONFIG_HOME=str(config.parent.parent))
|
environment = dict(os.environ, XDG_CONFIG_HOME=str(config.parent.parent))
|
||||||
@@ -31,48 +33,131 @@ def check_cli_instances(root, app, fixtures):
|
|||||||
raise RuntimeError(f"CLI {arguments} returned {result.returncode}, expected {expected_code}; {result.stdout}{result.stderr}")
|
raise RuntimeError(f"CLI {arguments} returned {result.returncode}, expected {expected_code}; {result.stdout}{result.stderr}")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def wait_states(count, ready, message):
|
def process_diagnostics(pid):
|
||||||
|
try:
|
||||||
|
return (root / f"stderr-{pid}.log").read_text(errors="replace")
|
||||||
|
except FileNotFoundError:
|
||||||
|
return "<not published>"
|
||||||
|
|
||||||
|
def wait_states(count, ready, message, allow_errors=()):
|
||||||
deadline = time.monotonic() + 30
|
deadline = time.monotonic() + 30
|
||||||
states = {}
|
states = {}
|
||||||
while time.monotonic() < deadline:
|
while time.monotonic() < deadline:
|
||||||
states = {int(path.stem.split("-")[1]): json.loads(path.read_text())
|
states = {int(path.stem.split("-")[1]): json.loads(path.read_text())
|
||||||
for path in root.glob("state-*.json")}
|
for path in root.glob("state-*.json")}
|
||||||
|
for pid in states:
|
||||||
|
try:
|
||||||
|
os.kill(pid, 0)
|
||||||
|
except ProcessLookupError:
|
||||||
|
raise RuntimeError(f"{message}: GUI PID {pid} exited unexpectedly; "
|
||||||
|
f"last state: {states[pid]}; diagnostics: {process_diagnostics(pid)}")
|
||||||
complete = len(states) == count and all(
|
complete = len(states) == count and all(
|
||||||
set(state["pids"]) == set(states) and state["windows"] == 1 for state in states.values())
|
set(state["pids"]) == set(states) and state["windows"] == 1 for state in states.values())
|
||||||
if any(state["error"] for state in states.values()):
|
if any(state["error"] for pid, state in states.items() if pid not in allow_errors):
|
||||||
raise RuntimeError(f"{message}: native playback error: {states}")
|
raise RuntimeError(f"{message}: native playback error: {states}")
|
||||||
if complete and ready(states):
|
if complete and ready(states):
|
||||||
return states
|
return states
|
||||||
time.sleep(0.02) # Infrastructure deadline; assertions use native events/PTS, not wall dates.
|
time.sleep(0.02) # Infrastructure deadline; assertions use native events/PTS, not wall dates.
|
||||||
raise RuntimeError(f"{message}: native state deadline: {states}")
|
raise RuntimeError(f"{message}: native state deadline: {states}; "
|
||||||
|
f"process diagnostics: { {pid: process_diagnostics(pid) for pid in states} }")
|
||||||
def settings(state):
|
def settings(state):
|
||||||
return (state["showBufferInfo"], state["automaticQuality"], state["snapToEdges"])
|
return (state["showBufferInfo"], state["automaticQuality"], state["snapToEdges"])
|
||||||
|
|
||||||
def input_request(pid, count, ready, message, events=None, media=None):
|
def write_request(pid, events=None, media=None, action=None, **arguments):
|
||||||
request = {"id": uuid.uuid4().hex, "events": events or []}
|
request = {"id": uuid.uuid4().hex, "events": events or [], **arguments}
|
||||||
if media is not None:
|
if media is not None:
|
||||||
request["media"] = str(media)
|
request["media"] = str(media)
|
||||||
|
if action is not None:
|
||||||
|
request["action"] = action
|
||||||
path = root / f"keys-{pid}.json"
|
path = root / f"keys-{pid}.json"
|
||||||
pending = path.with_suffix(".tmp")
|
pending = path.with_suffix(".tmp")
|
||||||
pending.write_text(json.dumps(request))
|
pending.write_text(json.dumps(request))
|
||||||
pending.replace(path)
|
pending.replace(path)
|
||||||
return wait_states(count, lambda states: states[pid]["requestID"] == request["id"] and ready(states[pid]),
|
return request["id"]
|
||||||
message)
|
|
||||||
|
def input_request(pid, count, ready, message, events=None, media=None, action=None,
|
||||||
|
allow_error=False, **arguments):
|
||||||
|
request_id = write_request(pid, events=events, media=media, action=action, **arguments)
|
||||||
|
return wait_states(count, lambda states: states[pid]["requestID"] == request_id and ready(states[pid]),
|
||||||
|
f"{message} (expected request {request_id})", allow_errors=(pid,) if allow_error else ())
|
||||||
|
|
||||||
|
def read_reports(path, allowed):
|
||||||
|
try:
|
||||||
|
lines = path.read_bytes().split(b"\n")[:-1] # The writer may still be filling the trailing line.
|
||||||
|
except FileNotFoundError:
|
||||||
|
return []
|
||||||
|
records = [json.loads(line) for line in lines if line]
|
||||||
|
fields = {"version", "event", "media_url", "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
|
||||||
|
and record["event"] in ("progress", "ended", "closed")
|
||||||
|
and record["media_url"] 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))
|
||||||
|
and type(record["completion_eligible"]) is bool
|
||||||
|
and (not record["completion_eligible"] or duration is not None))
|
||||||
|
if not valid:
|
||||||
|
raise RuntimeError(f"invalid or mixed native progress stream {path}: {record}")
|
||||||
|
return records
|
||||||
|
|
||||||
|
def wait_report(path, allowed, ready, message):
|
||||||
|
deadline = time.monotonic() + 30
|
||||||
|
records = []
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
records = read_reports(path, allowed)
|
||||||
|
if ready(records):
|
||||||
|
return records
|
||||||
|
time.sleep(0.02)
|
||||||
|
raise RuntimeError(f"{message}: native report deadline {path}: {records}")
|
||||||
|
|
||||||
|
def at_position(state, position):
|
||||||
|
return (state["status"] == 2 and not state["busy"] and not state["seekPending"]
|
||||||
|
and abs(state["position"] - position) < 0.5)
|
||||||
|
|
||||||
|
def progress_at(record, url, position):
|
||||||
|
return (record["event"] == "progress" and record["media_url"] == url
|
||||||
|
and abs(record["position_seconds"] - position) < 0.5)
|
||||||
|
|
||||||
|
def watch_fact(record):
|
||||||
|
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")
|
||||||
|
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):
|
||||||
|
(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:
|
with StreamingServer(fixtures) as server:
|
||||||
if extractor and runtime:
|
if extractor and runtime:
|
||||||
metadata = root / "youtube.json"
|
choices = []
|
||||||
|
for index, url in enumerate([canonical, *reported_urls[:3]]):
|
||||||
|
metadata = root / f"youtube-{index}.json"
|
||||||
metadata.write_text(json.dumps({
|
metadata.write_text(json.dumps({
|
||||||
"id": "aqz-KE-bpKQ", "title": "Independent YouTube fixture", "duration": 120,
|
"id": url.split("v=")[1],
|
||||||
"is_live": False, "webpage_url": canonical, "extractor": "youtube",
|
"title": "Independent YouTube fixture" if index == 0 else f"Reported YouTube fixture {index}",
|
||||||
|
"duration": 120, "is_live": False, "webpage_url": url, "extractor": "youtube",
|
||||||
"extractor_key": "Youtube", "formats": [{
|
"extractor_key": "Youtube", "formats": [{
|
||||||
"format_id": "180", "height": 180, "width": 320,
|
"format_id": str(height), "height": height, "width": height * 16 // 9,
|
||||||
"url": server.base_url + "/media/cache.mp4", "protocol": "http", "ext": "mp4",
|
"url": server.base_url + "/media/cache.mp4", "protocol": "http", "ext": "mp4",
|
||||||
"vcodec": "mpeg4", "acodec": "aac", "dynamic_range": "SDR",
|
"vcodec": "mpeg4", "acodec": "aac", "dynamic_range": "SDR",
|
||||||
"http_headers": {"X-Terebi-Fixture": "combined"}}]}))
|
"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 = root / "yt-dlp"
|
||||||
wrapper.write_text("#!/bin/bash\nset -eu\nargs=(\"$@\")\nexec " + shlex.quote(extractor)
|
wrapper.write_text("#!/bin/bash\nset -eu\nargs=(\"$@\")\n"
|
||||||
+ " --load-info-json " + shlex.quote(str(metadata))
|
'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')
|
+ ' "${args[@]:0:${#args[@]}-2}"\n')
|
||||||
wrapper.chmod(0o755)
|
wrapper.chmod(0o755)
|
||||||
runtime_settings = {"directory": str(root), "path": str(root) + os.pathsep + os.environ.get("PATH", "")}
|
runtime_settings = {"directory": str(root), "path": str(root) + os.pathsep + os.environ.get("PATH", "")}
|
||||||
@@ -186,6 +271,174 @@ def check_cli_instances(root, app, fixtures):
|
|||||||
else:
|
else:
|
||||||
print("SKIP independent YouTube playback: yt-dlp and a supported JS runtime are required")
|
print("SKIP independent YouTube playback: yt-dlp and a supported JS runtime are required")
|
||||||
|
|
||||||
|
existing_report = root / "existing progress.jsonl"
|
||||||
|
sentinel = b"existing destination must remain byte-for-byte unchanged\n"
|
||||||
|
existing_report.write_bytes(sentinel)
|
||||||
|
rejection = invoke(["--new-instance", "--url", reported_urls[0],
|
||||||
|
"--progress-file", str(existing_report)], expected_code=73)
|
||||||
|
if str(existing_report) not in rejection.stderr or existing_report.read_bytes() != sentinel:
|
||||||
|
raise RuntimeError(f"existing report rejection lost its path or altered data: {rejection.stderr}")
|
||||||
|
missing_target = root / "missing progress target.jsonl"
|
||||||
|
dangling_report = root / "dangling progress.jsonl"
|
||||||
|
dangling_report.symlink_to(missing_target)
|
||||||
|
rejection = invoke(["--new-instance", "--url", reported_urls[0],
|
||||||
|
"--progress-file", str(dangling_report)], expected_code=73)
|
||||||
|
if str(dangling_report) not in rejection.stderr or not dangling_report.is_symlink() or missing_target.exists():
|
||||||
|
raise RuntimeError(f"dangling report rejection altered its destination: {rejection.stderr}")
|
||||||
|
originals = wait_states(count, lambda _: True, "report preflight failures never launch a GUI")
|
||||||
|
|
||||||
|
if extractor and runtime:
|
||||||
|
report_paths = [root / f"progress session {index} {uuid.uuid4().hex}.jsonl" for index in (1, 2)]
|
||||||
|
reporting_pids = []
|
||||||
|
allowed_urls = ({reported_urls[0], reported_urls[2]}, {reported_urls[1]})
|
||||||
|
states = originals
|
||||||
|
for index, path in enumerate(report_paths):
|
||||||
|
previous_pids = set(states)
|
||||||
|
# Deliberately put the space-containing absolute report path after --url.
|
||||||
|
invoke(["--new-instance", "--url", reported_urls[index], "--progress-file", str(path)])
|
||||||
|
states = wait_states(count + index + 1, lambda snapshots: any(
|
||||||
|
pid not in previous_pids and state["media"] == f"Reported YouTube fixture {index + 1}"
|
||||||
|
and state["status"] == 3 and state["position"] > 0
|
||||||
|
and state["hasVideo"] and state["canSeek"] and not state["seekPending"]
|
||||||
|
for pid, state in snapshots.items()),
|
||||||
|
"reporting CLI returned before actual registered GUI playback observations")
|
||||||
|
pid = next(pid for pid in states if pid not in previous_pids)
|
||||||
|
reporting_pids.append(pid)
|
||||||
|
wait_report(path, allowed_urls[index], lambda records: any(
|
||||||
|
record["event"] == "progress" and record["media_url"] == reported_urls[index]
|
||||||
|
and record["completion_eligible"] and record["duration_seconds"] > 0
|
||||||
|
for record in records), "registered GUI emits an initial actual decoded report")
|
||||||
|
total = count + 2
|
||||||
|
first_reporter, second_reporter = reporting_pids
|
||||||
|
first_path, second_path = report_paths
|
||||||
|
for pid, original in originals.items():
|
||||||
|
if states[pid]["media"] != original["media"] or settings(states[pid]) != settings(original):
|
||||||
|
raise RuntimeError(f"reporting launches changed an original nonreporting player: {original}; {states[pid]}")
|
||||||
|
|
||||||
|
before_pause = len(read_reports(first_path, allowed_urls[0]))
|
||||||
|
paused = input_request(first_reporter, total, lambda state: state["status"] == 2,
|
||||||
|
"reporting pause is acknowledged by the actual backend", action="pause")
|
||||||
|
paused_position = paused[first_reporter]["position"]
|
||||||
|
wait_report(first_path, allowed_urls[0], lambda records: any(
|
||||||
|
progress_at(record, reported_urls[0], paused_position) for record in records[before_pause:]),
|
||||||
|
"acknowledged playing-to-paused transition reports native position")
|
||||||
|
input_request(second_reporter, total, lambda state: state["status"] == 2,
|
||||||
|
"second reporting stream pauses independently", action="pause")
|
||||||
|
|
||||||
|
bookmark = paused[first_reporter]["duration"] * 0.4
|
||||||
|
input_request(first_reporter, total, lambda state: at_position(state, bookmark),
|
||||||
|
"paused seek reaches an acknowledged decoded position", action="seek", position=bookmark)
|
||||||
|
wait_report(first_path, allowed_urls[0], lambda records: any(
|
||||||
|
progress_at(record, reported_urls[0], bookmark) for record in records),
|
||||||
|
"seek report contains acknowledged native PTS, not just a requested target")
|
||||||
|
before_quality = len(read_reports(first_path, allowed_urls[0]))
|
||||||
|
wait_states(total, lambda snapshots: snapshots[first_reporter]["canChangeQuality"],
|
||||||
|
"manual quality control becomes available after the acknowledged seek")
|
||||||
|
input_request(first_reporter, total,
|
||||||
|
lambda state: state["youtubeHeight"] == 180 and at_position(state, bookmark),
|
||||||
|
"actual quality reopen preserves the paused acknowledged timeline",
|
||||||
|
action="quality", height=180)
|
||||||
|
quality_records = wait_report(first_path, allowed_urls[0], lambda records: any(
|
||||||
|
progress_at(record, reported_urls[0], bookmark) for record in records[before_quality:]),
|
||||||
|
"same-identity quality reopen emits its acknowledged initial record")
|
||||||
|
if any(record["event"] == "closed" for record in quality_records):
|
||||||
|
raise RuntimeError(f"quality reopen incorrectly closed its reporting identity: {quality_records}")
|
||||||
|
|
||||||
|
input_request(first_reporter, total, lambda state: state["media"] == "Reported YouTube fixture 3"
|
||||||
|
and state["status"] == 3 and not state["busy"] and not state["seekPending"],
|
||||||
|
"new YouTube identity replaces only its reporting player",
|
||||||
|
action="youtube", url=reported_urls[2])
|
||||||
|
wait_report(first_path, allowed_urls[0], lambda records: (
|
||||||
|
any(record["event"] == "closed" and record["media_url"] == reported_urls[0]
|
||||||
|
and abs(record["position_seconds"] - bookmark) < 0.5 for record in records)
|
||||||
|
and any(record["event"] == "progress" and record["media_url"] == reported_urls[2]
|
||||||
|
for record in records)), "replacement closes cached old identity and begins only the new one")
|
||||||
|
input_request(first_reporter, total, lambda state: state["status"] == 0,
|
||||||
|
"semantic close invokes actual native media close", action="close")
|
||||||
|
closed = wait_report(first_path, allowed_urls[0], lambda records: any(
|
||||||
|
record["event"] == "closed" and record["media_url"] == reported_urls[2] for record in records),
|
||||||
|
"explicit close reports the replacement identity")
|
||||||
|
before_resume = len(closed)
|
||||||
|
input_request(first_reporter, total, lambda state: state["media"] == "Reported YouTube fixture 1"
|
||||||
|
and state["status"] == 3 and not state["busy"] and not state["seekPending"]
|
||||||
|
and abs(state["position"] - bookmark) < 1,
|
||||||
|
"native history resumes a previously acknowledged position",
|
||||||
|
action="youtube", url=reported_urls[0])
|
||||||
|
resumed = wait_report(first_path, allowed_urls[0], lambda records: any(
|
||||||
|
record["event"] == "progress" and record["media_url"] == reported_urls[0]
|
||||||
|
for record in records[before_resume:]),
|
||||||
|
"first ready report follows the backend's initial resume acknowledgement")
|
||||||
|
initial_resume = next(record for record in resumed[before_resume:]
|
||||||
|
if record["event"] == "progress" and record["media_url"] == reported_urls[0])
|
||||||
|
if abs(initial_resume["position_seconds"] - bookmark) >= 1:
|
||||||
|
raise RuntimeError(f"initial resumed report exposed an unacknowledged zero timeline: {initial_resume}")
|
||||||
|
input_request(first_reporter, total, lambda state: state["status"] == 2,
|
||||||
|
"resumed reporting stream can pause", action="pause")
|
||||||
|
crossing = paused[first_reporter]["duration"] * 0.96
|
||||||
|
input_request(first_reporter, total, lambda state: at_position(state, crossing),
|
||||||
|
"seek acknowledges a watch-fact crossing", action="seek", position=crossing)
|
||||||
|
wait_report(first_path, allowed_urls[0], lambda records: any(
|
||||||
|
record["media_url"] == reported_urls[0] and watch_fact(record) for record in records),
|
||||||
|
"watch fact requires eligible actual decoded ratio, not launcher success")
|
||||||
|
input_request(first_reporter, total, lambda state: state["status"] == 3,
|
||||||
|
"native play continues from the acknowledged crossing", action="play")
|
||||||
|
ended = wait_states(total, lambda snapshots: snapshots[first_reporter]["status"] == 4,
|
||||||
|
"actual playback reaches its decoded end")
|
||||||
|
if ended[first_reporter]["position"] / ended[first_reporter]["duration"] < 0.95:
|
||||||
|
raise RuntimeError(f"native ended timeline unexpectedly reset before reporting: {ended[first_reporter]}")
|
||||||
|
wait_report(first_path, allowed_urls[0], lambda records: any(
|
||||||
|
record["event"] == "ended" and record["media_url"] == reported_urls[0] and watch_fact(record)
|
||||||
|
for record in records), "ended report retains actual final position rather than bookmark-reset zero")
|
||||||
|
|
||||||
|
input_request(first_reporter, total, lambda state: state["media"] == audio.name
|
||||||
|
and state["status"] == 3 and state["hasAudio"] and not state["hasVideo"],
|
||||||
|
"local audio replacement remains playable without reporting local media", media=audio)
|
||||||
|
local_boundary = wait_report(first_path, allowed_urls[0], lambda records: (
|
||||||
|
records[-1]["event"] == "closed" and records[-1]["media_url"] == reported_urls[0]
|
||||||
|
and watch_fact(records[-1])) if records else False,
|
||||||
|
"local replacement closes the final YouTube snapshot")
|
||||||
|
input_request(first_reporter, total, lambda state: state["status"] == 2,
|
||||||
|
"unreported local pause uses the same real model", action="pause")
|
||||||
|
input_request(first_reporter, total, lambda state: bool(state["error"]) and not state["busy"],
|
||||||
|
"fixture extraction failure reaches the actual GUI error path",
|
||||||
|
action="youtube", url=reported_urls[3], allow_error=True)
|
||||||
|
failed_boundary = read_reports(first_path, allowed_urls[0])
|
||||||
|
if any(record["event"] != "closed" or record["media_url"] != reported_urls[0]
|
||||||
|
for record in failed_boundary[len(local_boundary):]):
|
||||||
|
raise RuntimeError(f"local media or failed resolution emitted a progress observation: {failed_boundary}")
|
||||||
|
input_request(first_reporter, total, lambda state: state["media"] == "Reported YouTube fixture 3"
|
||||||
|
and state["status"] == 3 and not state["busy"] and not state["seekPending"]
|
||||||
|
and not state["error"],
|
||||||
|
"successful resolution recovers after an unreported failed open",
|
||||||
|
action="youtube", url=reported_urls[2], allow_error=True)
|
||||||
|
wait_report(first_path, allowed_urls[0], lambda records: any(
|
||||||
|
record["event"] == "progress" and record["media_url"] == reported_urls[2]
|
||||||
|
for record in records[len(failed_boundary):]), "recovery reports only successfully opened identity")
|
||||||
|
|
||||||
|
second_position = states[second_reporter]["duration"] * 0.2
|
||||||
|
input_request(second_reporter, total, lambda state: at_position(state, second_position),
|
||||||
|
"second file receives only its own acknowledged seek", action="seek", position=second_position)
|
||||||
|
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)
|
||||||
|
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,
|
||||||
|
"normal GUI quit drains the second player's final acknowledged snapshot")
|
||||||
|
quit_instance(first_reporter, total - 1)
|
||||||
|
final_first = wait_report(first_path, allowed_urls[0], lambda records: (
|
||||||
|
records[-1]["event"] == "closed" and records[-1]["media_url"] == reported_urls[2])
|
||||||
|
if records else False, "normal GUI quit drains the first player's current identity")
|
||||||
|
if not final_first or not final_second or any(not path.read_bytes().endswith(b"\n") for path in report_paths):
|
||||||
|
raise RuntimeError("normal quit left an incomplete JSONL record")
|
||||||
|
remaining = wait_states(count, lambda _: True, "original nonreporting GUIs survive both reporting quits")
|
||||||
|
for pid, original in originals.items():
|
||||||
|
if remaining[pid]["media"] != original["media"] or settings(remaining[pid]) != settings(original):
|
||||||
|
raise RuntimeError(f"reporting actions changed an unrelated session: {original}; {remaining[pid]}")
|
||||||
|
print("PASS real progress launch: absolute spaced paths after --url, two isolated GUI streams, decoded initial/pause/seek/resume/quality/replacement/end/quit, eligible watch fact, local and failed-open exclusions")
|
||||||
|
else:
|
||||||
|
print("SKIP reporting GUI playback: yt-dlp and a supported JS runtime are required")
|
||||||
before = wait_states(count, lambda _: True, "all independent processes remain registered")
|
before = wait_states(count, lambda _: True, "all independent processes remain registered")
|
||||||
invoke(["--new-instance", "--help"])
|
invoke(["--new-instance", "--help"])
|
||||||
wait_states(count, lambda _: True, "help never creates an extra application")
|
wait_states(count, lambda _: True, "help never creates an extra application")
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
|
import Darwin
|
||||||
import MediaPlayer
|
import MediaPlayer
|
||||||
|
|
||||||
@_cdecl("terebi_app_main")
|
@_cdecl("terebi_app_main")
|
||||||
@@ -92,6 +93,11 @@ private enum CLIInstanceCheck {
|
|||||||
static func run(_ configuration: URL) {
|
static func run(_ configuration: URL) {
|
||||||
let settings = try! JSONSerialization.jsonObject(with: Data(contentsOf: configuration)) as! [String: String]
|
let settings = try! JSONSerialization.jsonObject(with: Data(contentsOf: configuration)) as! [String: String]
|
||||||
let directory = URL(fileURLWithPath: settings["directory"]!, isDirectory: true)
|
let directory = URL(fileURLWithPath: settings["directory"]!, isDirectory: true)
|
||||||
|
let stderrURL = directory.appendingPathComponent("stderr-\(ProcessInfo.processInfo.processIdentifier).log")
|
||||||
|
precondition(FileManager.default.createFile(atPath: stderrURL.path, contents: nil))
|
||||||
|
let errorLog = try! FileHandle(forWritingTo: stderrURL)
|
||||||
|
precondition(dup2(errorLog.fileDescriptor, STDERR_FILENO) >= 0)
|
||||||
|
try! errorLog.close()
|
||||||
setenv("PATH", settings["path"]!, 1)
|
setenv("PATH", settings["path"]!, 1)
|
||||||
_ = NSApplication.shared
|
_ = NSApplication.shared
|
||||||
_ = NotificationCenter.default.addObserver(forName: NSApplication.willFinishLaunchingNotification,
|
_ = NotificationCenter.default.addObserver(forName: NSApplication.willFinishLaunchingNotification,
|
||||||
@@ -115,17 +121,44 @@ private enum CLIInstanceCheck {
|
|||||||
let windows = NSApp.windows.filter { $0.canBecomeMain && !($0 is NSPanel) && $0.contentView != nil }
|
let windows = NSApp.windows.filter { $0.canBecomeMain && !($0 is NSPanel) && $0.contentView != nil }
|
||||||
for window in windows { window.alphaValue = 0 }
|
for window in windows { window.alphaValue = 0 }
|
||||||
let request = directory.appendingPathComponent("keys-\(pid).json")
|
let request = directory.appendingPathComponent("keys-\(pid).json")
|
||||||
|
var pendingRequestID = ""
|
||||||
if let data = try? Data(contentsOf: request),
|
if let data = try? Data(contentsOf: request),
|
||||||
let command = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
let command = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||||
let id = command["id"] as? String, id != requestID, let window = windows.first {
|
let id = command["id"] as? String, id != requestID, let window = windows.first {
|
||||||
|
pendingRequestID = id
|
||||||
if !NSApp.isActive || !window.isKeyWindow || !window.isMainWindow {
|
if !NSApp.isActive || !window.isKeyWindow || !window.isMainWindow {
|
||||||
NSApp.activate()
|
// Synthetic native key requests need foreground focus; cooperative activation
|
||||||
|
// can be refused by the unrelated app currently active on the workstation.
|
||||||
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
window.makeKeyAndOrderFront(nil)
|
window.makeKeyAndOrderFront(nil)
|
||||||
window.makeMain()
|
window.makeMain()
|
||||||
return
|
} else {
|
||||||
}
|
|
||||||
requestID = id
|
requestID = id
|
||||||
if let media = command["media"] as? String { model.open(URL(fileURLWithPath: media)) }
|
if let media = command["media"] as? String { model.open(URL(fileURLWithPath: media)) }
|
||||||
|
if let action = command["action"] as? String {
|
||||||
|
switch action {
|
||||||
|
case "youtube":
|
||||||
|
model.openYouTube(command["url"] as! String)
|
||||||
|
case "seek":
|
||||||
|
let position = command["position"] as! Double
|
||||||
|
precondition(model.seekRelative(position - model.state.position), "Native seek must be accepted")
|
||||||
|
case "pause":
|
||||||
|
precondition(model.perform(.pause), "Native pause must be accepted")
|
||||||
|
case "play":
|
||||||
|
precondition(model.perform(.play), "Native play must be accepted")
|
||||||
|
case "quality":
|
||||||
|
precondition(model.canChangeYouTubeQuality, "Native quality change must be available")
|
||||||
|
model.setYouTubeQuality((command["height"] as! Int))
|
||||||
|
case "close":
|
||||||
|
model.closeFile()
|
||||||
|
case "quit":
|
||||||
|
timer?.invalidate()
|
||||||
|
precondition(model.perform(.quit), "Native quit must be accepted")
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
fatalError("Unknown native check action \(action)")
|
||||||
|
}
|
||||||
|
}
|
||||||
if let events = command["events"] as? [[String: Any]] {
|
if let events = command["events"] as? [[String: Any]] {
|
||||||
window.makeKey()
|
window.makeKey()
|
||||||
for input in events {
|
for input in events {
|
||||||
@@ -160,6 +193,7 @@ private enum CLIInstanceCheck {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
let applications = NSWorkspace.shared.runningApplications.filter {
|
let applications = NSWorkspace.shared.runningApplications.filter {
|
||||||
!$0.isTerminated && $0.bundleURL == Bundle.main.bundleURL
|
!$0.isTerminated && $0.bundleURL == Bundle.main.bundleURL
|
||||||
}
|
}
|
||||||
@@ -169,6 +203,13 @@ private enum CLIInstanceCheck {
|
|||||||
"media": model.fileName ?? "",
|
"media": model.fileName ?? "",
|
||||||
"status": model.state.status.rawValue,
|
"status": model.state.status.rawValue,
|
||||||
"position": model.state.position,
|
"position": model.state.position,
|
||||||
|
"duration": model.state.duration,
|
||||||
|
"seekPending": model.state.seek_pending,
|
||||||
|
"canSeek": model.canSeek,
|
||||||
|
"busy": model.isBusy,
|
||||||
|
"youtubeHeight": model.youtubeHeight ?? 0,
|
||||||
|
"canChangeQuality": model.canChangeYouTubeQuality,
|
||||||
|
"qualityHeights": model.youtubeHeights,
|
||||||
"windows": windows.count,
|
"windows": windows.count,
|
||||||
"hasAudio": model.state.has_audio,
|
"hasAudio": model.state.has_audio,
|
||||||
"hasVideo": model.state.has_video,
|
"hasVideo": model.state.has_video,
|
||||||
@@ -177,6 +218,10 @@ private enum CLIInstanceCheck {
|
|||||||
"automaticQuality": model.automaticYouTubeQuality,
|
"automaticQuality": model.automaticYouTubeQuality,
|
||||||
"snapToEdges": model.compactWindow.snappingEnabled,
|
"snapToEdges": model.compactWindow.snappingEnabled,
|
||||||
"requestID": requestID,
|
"requestID": requestID,
|
||||||
|
"pendingRequestID": pendingRequestID,
|
||||||
|
"active": NSApp.isActive,
|
||||||
|
"keyWindow": windows.first?.isKeyWindow ?? false,
|
||||||
|
"mainWindow": windows.first?.isMainWindow ?? false,
|
||||||
"editorText": editorText,
|
"editorText": editorText,
|
||||||
"compact": model.isCompact,
|
"compact": model.isCompact,
|
||||||
"onTop": model.isOnTop,
|
"onTop": model.isOnTop,
|
||||||
|
|||||||
+125
-2
@@ -9,6 +9,9 @@
|
|||||||
#include <functional>
|
#include <functional>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <signal.h>
|
||||||
|
#include <sys/resource.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
static void require(bool condition, const char *message) {
|
static void require(bool condition, const char *message) {
|
||||||
if (!condition) { std::fprintf(stderr, "FAIL: %s\n", message); std::exit(1); }
|
if (!condition) { std::fprintf(stderr, "FAIL: %s\n", message); std::exit(1); }
|
||||||
@@ -118,7 +121,7 @@ static std::string headersFor(const std::string &path) {
|
|||||||
|
|
||||||
static bool openNetwork(Player *player, const std::string &base, const std::string &videoPath,
|
static bool openNetwork(Player *player, const std::string &base, const std::string &videoPath,
|
||||||
const std::string &audioPath = {}, double duration = 4, bool live = false,
|
const std::string &audioPath = {}, double duration = 4, bool live = false,
|
||||||
bool includeHeaders = true) {
|
bool includeHeaders = true, const std::string &mediaURL = {}, double initialPosition = 0) {
|
||||||
// Locals intentionally die immediately after the call: the asynchronous engine
|
// Locals intentionally die immediately after the call: the asynchronous engine
|
||||||
// must own both selected URLs and both selected HTTP-header blocks.
|
// must own both selected URLs and both selected HTTP-header blocks.
|
||||||
auto video = base + videoPath;
|
auto video = base + videoPath;
|
||||||
@@ -126,7 +129,8 @@ static bool openNetwork(Player *player, const std::string &base, const std::stri
|
|||||||
auto videoHeaders = includeHeaders ? headersFor(videoPath) : std::string{};
|
auto videoHeaders = includeHeaders ? headersFor(videoPath) : std::string{};
|
||||||
auto audioHeaders = includeHeaders ? headersFor(audioPath) : std::string{};
|
auto audioHeaders = includeHeaders ? headersFor(audioPath) : std::string{};
|
||||||
PlayerNetworkSource source{video.c_str(), audio.empty() ? nullptr : audio.c_str(),
|
PlayerNetworkSource source{video.c_str(), audio.empty() ? nullptr : audio.c_str(),
|
||||||
videoHeaders.c_str(), audio.empty() ? nullptr : audioHeaders.c_str(), duration, live};
|
videoHeaders.c_str(), audio.empty() ? nullptr : audioHeaders.c_str(), duration, live,
|
||||||
|
mediaURL.empty() ? nullptr : mediaURL.c_str(), initialPosition};
|
||||||
return player_open_network(player, &source);
|
return player_open_network(player, &source);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -557,12 +561,131 @@ static void failures(Player *player, const std::string &base) {
|
|||||||
std::puts("PASS actual HTTP header denial/503 failure/useful errors/cleared state/split-source recovery");
|
std::puts("PASS actual HTTP header denial/503 failure/useful errors/cleared state/split-source recovery");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static NSArray<NSDictionary *> *progressRecords(const std::string &path) {
|
||||||
|
NSString *text = [NSString stringWithContentsOfFile:[NSString stringWithUTF8String:path.c_str()]
|
||||||
|
encoding:NSUTF8StringEncoding error:nullptr];
|
||||||
|
if (!text) return @[];
|
||||||
|
NSArray<NSString *> *lines = [text componentsSeparatedByString:@"\n"];
|
||||||
|
NSMutableArray<NSDictionary *> *records = [NSMutableArray array];
|
||||||
|
for (NSUInteger index = 0; index + 1 < lines.count; ++index) {
|
||||||
|
id record = [NSJSONSerialization JSONObjectWithData:[lines[index] dataUsingEncoding:NSUTF8StringEncoding]
|
||||||
|
options:0 error:nullptr];
|
||||||
|
require([record isKindOfClass:[NSDictionary class]], "native reporting produces complete JSON records");
|
||||||
|
[records addObject:record];
|
||||||
|
}
|
||||||
|
return records;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void reporting(const std::string &base, const std::string &fixtures) {
|
||||||
|
char temporary[] = "/tmp/terebi-native-progress-XXXXXX";
|
||||||
|
require(mkdtemp(temporary) != nullptr, "isolated native report directory");
|
||||||
|
const std::string path = std::string(temporary) + "/native.jsonl";
|
||||||
|
const std::string first = "https://www.youtube.com/watch?v=AAAAAAAAAAA";
|
||||||
|
const std::string second = "https://www.youtube.com/watch?v=BBBBBBBBBBB";
|
||||||
|
std::unique_ptr<Player, decltype(&player_destroy)> player(player_create(), player_destroy);
|
||||||
|
char error[512]{};
|
||||||
|
NSString *nativePath = [NSString stringWithUTF8String:path.c_str()];
|
||||||
|
require([@"original" writeToFile:nativePath atomically:NO encoding:NSUTF8StringEncoding error:nullptr],
|
||||||
|
"create existing destination sentinel");
|
||||||
|
require(!player_set_progress_file(player.get(), path.c_str(), error) && error[0] &&
|
||||||
|
player_get_state(player.get()).status == PLAYER_EMPTY,
|
||||||
|
"report initialization error leaves playback state usable");
|
||||||
|
require([[NSString stringWithContentsOfFile:nativePath encoding:NSUTF8StringEncoding error:nullptr]
|
||||||
|
isEqualToString:@"original"], "native initialization cannot overwrite an existing destination");
|
||||||
|
require(unlink(path.c_str()) == 0, "remove only the test-owned sentinel");
|
||||||
|
require(player_set_progress_file(player.get(), path.c_str(), error), "initialize native reporting before playback");
|
||||||
|
require(player_set_volume(player.get(), 0), "mute native reporting fixture");
|
||||||
|
require(openNetwork(player.get(), base, "/media/cache.mp4", "", 120, false, true, first, 50),
|
||||||
|
"open canonical source with initial resume intent");
|
||||||
|
until(player.get(), [&](auto state) {
|
||||||
|
auto rows = progressRecords(path);
|
||||||
|
return state.status == PLAYER_PAUSED && !state.seek_pending && rows.count > 0;
|
||||||
|
}, "backend acknowledges initial restoration before report");
|
||||||
|
auto rows = progressRecords(path);
|
||||||
|
require([rows[0][@"media_url"] isEqualToString:[NSString stringWithUTF8String:first.c_str()]] &&
|
||||||
|
std::abs([rows[0][@"position_seconds"] doubleValue] - 50) < .03 &&
|
||||||
|
[rows[0][@"completion_eligible"] boolValue],
|
||||||
|
"initial report is restored native position, not pre-resume zero");
|
||||||
|
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);
|
||||||
|
require(openNetwork(player.get(), base, "/media/audio.wav", "", 4, false, true, first),
|
||||||
|
"audio-only input with canonical identity opens");
|
||||||
|
until(player.get(), [&](auto state) {
|
||||||
|
auto current = progressRecords(path);
|
||||||
|
return state.status == PLAYER_PAUSED && state.has_audio && !state.has_video &&
|
||||||
|
[current.lastObject[@"duration_seconds"] doubleValue] == 4 &&
|
||||||
|
[current.lastObject[@"event"] isEqualToString:@"progress"];
|
||||||
|
}, "audio-only native report");
|
||||||
|
rows = progressRecords(path);
|
||||||
|
require(![rows.lastObject[@"completion_eligible"] boolValue], "actual audio-only input cannot mark watched");
|
||||||
|
require(openNetwork(player.get(), base, "/media/cache.mp4", "", 120, true, true, first),
|
||||||
|
"live source with known duration opens");
|
||||||
|
until(player.get(), [](auto state) { return state.status == PLAYER_PAUSED && state.has_video; },
|
||||||
|
"reported live input ready");
|
||||||
|
require(player_close(player.get()), "close actual reported live input");
|
||||||
|
until(player.get(), [](auto state) { return state.status == PLAYER_EMPTY; }, "reported live close");
|
||||||
|
require(openNetwork(player.get(), base, "/stall-open/report-cancelled/streaming.mp4", "", 30,
|
||||||
|
false, true, second, 29),
|
||||||
|
"gated initial resume opens");
|
||||||
|
until(player.get(), [&](auto state) {
|
||||||
|
return state.status == PLAYER_OPENING && gateState(base, "report-cancelled").entered;
|
||||||
|
}, "reported initial open remains unacknowledged");
|
||||||
|
require(player_close(player.get()), "cancel reported open before ready");
|
||||||
|
until(player.get(), [](auto state) { return state.status == PLAYER_EMPTY; }, "cancelled report open close");
|
||||||
|
require(player_shutdown(player.get(), error), "normal shutdown drains native reporter");
|
||||||
|
rows = progressRecords(path);
|
||||||
|
bool sawLive = false;
|
||||||
|
for (NSDictionary *record in rows) {
|
||||||
|
require([record[@"media_url"] isEqualToString:[NSString stringWithUTF8String:first.c_str()]],
|
||||||
|
"failed/cancelled opens never report their requested identity");
|
||||||
|
if ([record[@"duration_seconds"] doubleValue] == 120 &&
|
||||||
|
[record[@"position_seconds"] doubleValue] == 0) {
|
||||||
|
sawLive = true;
|
||||||
|
require(![record[@"completion_eligible"] boolValue], "live known-duration records stay ineligible");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require(sawLive && [rows.lastObject[@"event"] isEqualToString:@"closed"],
|
||||||
|
"native close/quit retain terminal facts for acknowledged media");
|
||||||
|
player.reset();
|
||||||
|
|
||||||
|
const auto failedPath = std::string(temporary) + "/write-failure.jsonl";
|
||||||
|
player.reset(player_create());
|
||||||
|
require(player_set_progress_file(player.get(), failedPath.c_str(), error), "initialize failure-test reporter");
|
||||||
|
require(player_set_volume(player.get(), 0), "mute failure-test playback");
|
||||||
|
struct rlimit previous{}, limited{};
|
||||||
|
require(getrlimit(RLIMIT_FSIZE, &previous) == 0, "read original file-size limit");
|
||||||
|
limited = previous;
|
||||||
|
limited.rlim_cur = 1;
|
||||||
|
auto previousSignal = signal(SIGXFSZ, SIG_IGN);
|
||||||
|
require(previousSignal != SIG_ERR && setrlimit(RLIMIT_FSIZE, &limited) == 0, "force actual reporting write failure");
|
||||||
|
require(openNetwork(player.get(), base, "/media/cache.mp4", "", 120, false, true, first),
|
||||||
|
"playback opens with failing output");
|
||||||
|
until(player.get(), [&](auto state) {
|
||||||
|
return state.status == PLAYER_PAUSED && player_get_progress_error(player.get(), error);
|
||||||
|
}, "write error surfaces independently from decoder status");
|
||||||
|
require(setrlimit(RLIMIT_FSIZE, &previous) == 0 && signal(SIGXFSZ, previousSignal) != SIG_ERR,
|
||||||
|
"restore resource limit and signal handler");
|
||||||
|
require(error[0] && player_play(player.get()), "reporting failure does not prevent playback");
|
||||||
|
until(player.get(), [](auto state) { return state.status == PLAYER_PLAYING && state.position > .1; },
|
||||||
|
"actual playback continues after reporting failure");
|
||||||
|
seekCompleted(player.get(), 5, PLAYER_PLAYING);
|
||||||
|
require(player_open(player.get(), (fixtures + "/audio.wav").c_str()), "failed reporter permits media replacement");
|
||||||
|
until(player.get(), [](auto state) { return state.status == PLAYER_PAUSED && state.has_audio; },
|
||||||
|
"replacement remains functional with disabled reporter");
|
||||||
|
require(!player_shutdown(player.get(), error) && error[0], "final reporting failure remains observable");
|
||||||
|
player.reset();
|
||||||
|
[[NSFileManager defaultManager] removeItemAtPath:[NSString stringWithUTF8String:temporary] error:nullptr];
|
||||||
|
std::puts("PASS native reporting: acknowledged initial resume/audio-only/live eligibility/failed+cancelled opens/final drain/write-failure playback+seek+replacement continuity");
|
||||||
|
}
|
||||||
|
|
||||||
int main(int argc, char **argv) {
|
int main(int argc, char **argv) {
|
||||||
@autoreleasepool {
|
@autoreleasepool {
|
||||||
require(argc == 3, "loopback base URL and local fixture directory required");
|
require(argc == 3, "loopback base URL and local fixture directory required");
|
||||||
std::string base = argv[1], fixtures = argv[2];
|
std::string base = argv[1], fixtures = argv[2];
|
||||||
require(base.rfind("http://127.0.0.1:", 0) == 0, "network fixture server is loopback-only");
|
require(base.rfind("http://127.0.0.1:", 0) == 0, "network fixture server is loopback-only");
|
||||||
[NSApplication sharedApplication];
|
[NSApplication sharedApplication];
|
||||||
|
reporting(base, fixtures);
|
||||||
std::unique_ptr<Player, decltype(&player_destroy)> player(player_create(), player_destroy);
|
std::unique_ptr<Player, decltype(&player_destroy)> player(player_create(), player_destroy);
|
||||||
require(bool(player), "player creation");
|
require(bool(player), "player creation");
|
||||||
require(player_set_volume(player.get(), .37f), "set persistent network playback gain");
|
require(player_set_volume(player.get(), .37f), "set persistent network playback gain");
|
||||||
|
|||||||
@@ -16,12 +16,14 @@ struct PlayerCommandChecks {
|
|||||||
let instanceFile = directory.appendingPathComponent("--new-instance")
|
let instanceFile = directory.appendingPathComponent("--new-instance")
|
||||||
for file in [movie, optionFile, colonFile, instanceFile] { try Data().write(to: file) }
|
for file in [movie, optionFile, colonFile, instanceFile] { try Data().write(to: file) }
|
||||||
|
|
||||||
func opened(_ arguments: [String], newInstance: Bool = false, noConfig: Bool = false) throws -> URL? {
|
func opened(_ arguments: [String], newInstance: Bool = false, noConfig: Bool = false,
|
||||||
guard case .launch(let url, let independent, let bypass) = try PlayerCommand.parse(arguments, directory: directory) else {
|
progressFile: URL? = nil) throws -> URL? {
|
||||||
|
guard case .launch(let url, let independent, let bypass, let report) = try PlayerCommand.parse(arguments, directory: directory) else {
|
||||||
fatalError("FAIL: expected a launch command")
|
fatalError("FAIL: expected a launch command")
|
||||||
}
|
}
|
||||||
require(independent == newInstance, "launch mode preserves default reuse or explicit independence")
|
require(independent == newInstance, "launch mode preserves default reuse or explicit independence")
|
||||||
require(bypass == noConfig, "configuration loading defaults on and can be explicitly bypassed")
|
require(bypass == noConfig, "configuration loading defaults on and can be explicitly bypassed")
|
||||||
|
require(report == progressFile, "reporting destination preserves launch scope and resolves against invoking directory")
|
||||||
return url
|
return url
|
||||||
}
|
}
|
||||||
require(try opened([movie.lastPathComponent]) == movie, "relative paths resolve against invocation directory, preserving spaces")
|
require(try opened([movie.lastPathComponent]) == movie, "relative paths resolve against invocation directory, preserving spaces")
|
||||||
@@ -58,6 +60,17 @@ struct PlayerCommandChecks {
|
|||||||
guard case .printDefaultConfig = try PlayerCommand.parse(["--default-config"]) else {
|
guard case .printDefaultConfig = try PlayerCommand.parse(["--default-config"]) else {
|
||||||
fatalError("FAIL: printing defaults must not launch a player")
|
fatalError("FAIL: printing defaults must not launch a player")
|
||||||
}
|
}
|
||||||
|
let report = directory.appendingPathComponent("session with spaces.jsonl")
|
||||||
|
require(try opened(["--new-instance", "--url", canonical.absoluteString, "--progress-file", report.path],
|
||||||
|
newInstance: true, progressFile: report) == canonical,
|
||||||
|
"reporting after media arguments retains canonical YouTube identity")
|
||||||
|
require(try opened(["--progress-file", report.lastPathComponent, "--new-instance", "--no-config", "--url", canonical.absoluteString],
|
||||||
|
newInstance: true, noConfig: true, progressFile: report) == canonical,
|
||||||
|
"relative report path is made absolute before GUI forwarding")
|
||||||
|
let reportFlagFile = directory.appendingPathComponent("--progress-file")
|
||||||
|
try Data().write(to: reportFlagFile)
|
||||||
|
require(try opened(["--", "--progress-file"]) == reportFlagFile,
|
||||||
|
"escaped reporting option remains a filename")
|
||||||
|
|
||||||
for arguments in [["--url"], ["--url", canonical.absoluteString, movie.path], ["--"],
|
for arguments in [["--url"], ["--url", canonical.absoluteString, movie.path], ["--"],
|
||||||
[movie.path, movie.path], ["--unknown"], ["missing.mp4"], [directory.path],
|
[movie.path, movie.path], ["--unknown"], ["missing.mp4"], [directory.path],
|
||||||
@@ -67,6 +80,12 @@ struct PlayerCommandChecks {
|
|||||||
["--new-instance", "--unknown"], ["--no-config", "--no-config"],
|
["--new-instance", "--unknown"], ["--no-config", "--no-config"],
|
||||||
["--default-config", movie.path], ["--default-config", "--default-config"],
|
["--default-config", movie.path], ["--default-config", "--default-config"],
|
||||||
["--print-default-config"],
|
["--print-default-config"],
|
||||||
|
["--progress-file", report.path],
|
||||||
|
["--new-instance", "--url", canonical.absoluteString, "--progress-file"],
|
||||||
|
["--new-instance", "--progress-file", ""],
|
||||||
|
["--new-instance", "--progress-file", report.path, "--progress-file", report.path],
|
||||||
|
["--new-instance", "--progress-file", report.path, "--help"],
|
||||||
|
["--new-instance", "--progress-file", report.path, "--default-config"],
|
||||||
["--no-config", "--new-instance", "--no-config"]] {
|
["--no-config", "--new-instance", "--no-config"]] {
|
||||||
do {
|
do {
|
||||||
_ = try PlayerCommand.parse(arguments, directory: directory)
|
_ = try PlayerCommand.parse(arguments, directory: directory)
|
||||||
|
|||||||
@@ -0,0 +1,304 @@
|
|||||||
|
#include "../Sources/Core/progress_reporter.hpp"
|
||||||
|
#import <Foundation/Foundation.h>
|
||||||
|
#include <atomic>
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <limits>
|
||||||
|
#include <signal.h>
|
||||||
|
#include <spawn.h>
|
||||||
|
#include <string>
|
||||||
|
#include <stdexcept>
|
||||||
|
#include <sys/resource.h>
|
||||||
|
#include <sys/stat.h>
|
||||||
|
#include <sys/wait.h>
|
||||||
|
#include <thread>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
extern char **environ;
|
||||||
|
using playback::ProgressReporter;
|
||||||
|
static const std::string firstURL = "https://www.youtube.com/watch?v=AAAAAAAAAAA";
|
||||||
|
static const std::string secondURL = "https://www.youtube.com/watch?v=BBBBBBBBBBB";
|
||||||
|
|
||||||
|
static void require(bool condition, const char *message) {
|
||||||
|
if (!condition) { std::fprintf(stderr, "FAIL: %s\n", message); std::exit(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
static PlayerState sample(PlayerStatus status, double position, double duration = 100) {
|
||||||
|
PlayerState state{};
|
||||||
|
state.status = status;
|
||||||
|
state.position = position;
|
||||||
|
state.duration = duration;
|
||||||
|
state.has_audio = state.has_video = state.can_seek = true;
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
static NSString *nativePath(const std::string &path) {
|
||||||
|
return [NSString stringWithUTF8String:path.c_str()];
|
||||||
|
}
|
||||||
|
|
||||||
|
static NSArray<NSDictionary *> *records(const std::string &path) {
|
||||||
|
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"]];
|
||||||
|
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([@[@"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");
|
||||||
|
require([row[@"position_seconds"] isKindOfClass:[NSNumber class]] &&
|
||||||
|
std::isfinite([row[@"position_seconds"] doubleValue]) &&
|
||||||
|
[row[@"position_seconds"] doubleValue] >= 0, "position is finite and nonnegative");
|
||||||
|
id duration = row[@"duration_seconds"];
|
||||||
|
require(duration == [NSNull null] || ([duration isKindOfClass:[NSNumber class]] &&
|
||||||
|
std::isfinite([duration doubleValue]) && [duration doubleValue] > 0), "duration is positive or null");
|
||||||
|
require(CFGetTypeID((__bridge CFTypeRef)row[@"completion_eligible"]) == CFBooleanGetTypeID(),
|
||||||
|
"eligibility is a JSON boolean");
|
||||||
|
[result addObject:row];
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void expect(NSDictionary *row, NSString *event, const std::string &url,
|
||||||
|
double position, bool eligible, double duration = 100) {
|
||||||
|
require([row[@"event"] isEqual:event] && [row[@"media_url"] isEqual:nativePath(url)] &&
|
||||||
|
[row[@"position_seconds"] doubleValue] == position &&
|
||||||
|
[row[@"completion_eligible"] boolValue] == eligible, "event has the acknowledged consumer snapshot");
|
||||||
|
require(duration > 0 && std::isfinite(duration) ? [row[@"duration_seconds"] doubleValue] == duration :
|
||||||
|
row[@"duration_seconds"] == [NSNull null], "duration retains precision or is null");
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool rejected(const std::string &path) {
|
||||||
|
try { ProgressReporter reporter(path); }
|
||||||
|
catch (const std::exception &error) { return error.what()[0] != '\0'; }
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// An actual OS write failure, isolated so the resource limit cannot affect the parent.
|
||||||
|
static int writeFailure(const std::string &path) {
|
||||||
|
require(signal(SIGXFSZ, SIG_IGN) != SIG_ERR, "ignore file-size-limit signal");
|
||||||
|
struct rlimit limit{1, 1};
|
||||||
|
require(setrlimit(RLIMIT_FSIZE, &limit) == 0, "install deterministic one-byte file limit");
|
||||||
|
ProgressReporter reporter(path, [] { return 0.; });
|
||||||
|
reporter.begin(firstURL);
|
||||||
|
reporter.observe(sample(PLAYER_PLAYING, 1));
|
||||||
|
reporter.finish();
|
||||||
|
auto error = reporter.error();
|
||||||
|
require(!error.empty(), "OS write failure is persistently reported without throwing");
|
||||||
|
reporter.begin(secondURL);
|
||||||
|
reporter.observe(sample(PLAYER_ENDED, 100));
|
||||||
|
reporter.finish();
|
||||||
|
require(reporter.error() == error, "failure remains available after repeated finish and observations");
|
||||||
|
struct stat info{};
|
||||||
|
require(stat(path.c_str(), &info) == 0 && info.st_size == 1,
|
||||||
|
"partial write is retained and failed output stays disabled");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(int argc, char **argv) {
|
||||||
|
if (argc == 3 && std::string(argv[1]) == "--write-failure") return writeFailure(argv[2]);
|
||||||
|
@autoreleasepool {
|
||||||
|
char directory[] = "/tmp/terebi-progress-reporting-XXXXXX";
|
||||||
|
require(mkdtemp(directory) != nullptr, "create isolated output directory");
|
||||||
|
const std::string root(directory);
|
||||||
|
double now = 100;
|
||||||
|
const auto path = root + "/cadence.jsonl";
|
||||||
|
{
|
||||||
|
ProgressReporter reporter(path, [&] { return now; });
|
||||||
|
reporter.begin(firstURL);
|
||||||
|
reporter.observe(sample(PLAYER_OPENING, 99));
|
||||||
|
reporter.observe(sample(PLAYER_ERROR, 99));
|
||||||
|
auto pending = sample(PLAYER_PAUSED, 99);
|
||||||
|
pending.seek_pending = true;
|
||||||
|
reporter.observe(pending);
|
||||||
|
reporter.observe(sample(PLAYER_PLAYING, 1.2345678901234567));
|
||||||
|
now = 104.999;
|
||||||
|
reporter.observe(sample(PLAYER_PLAYING, 4));
|
||||||
|
now = 105;
|
||||||
|
reporter.observe(sample(PLAYER_PLAYING, 6.25));
|
||||||
|
now = 106;
|
||||||
|
pending = sample(PLAYER_PLAYING, 99);
|
||||||
|
pending.seek_pending = true;
|
||||||
|
reporter.observe(pending);
|
||||||
|
now = 107;
|
||||||
|
reporter.observe(sample(PLAYER_PLAYING, 95));
|
||||||
|
now = 108;
|
||||||
|
reporter.observe(sample(PLAYER_PLAYING, 96));
|
||||||
|
reporter.observe(sample(PLAYER_PAUSED, 96.5));
|
||||||
|
pending = sample(PLAYER_PAUSED, 50);
|
||||||
|
pending.seek_pending = true;
|
||||||
|
reporter.observe(pending);
|
||||||
|
reporter.observe(sample(PLAYER_PAUSED, 50));
|
||||||
|
now = 200;
|
||||||
|
reporter.observe(sample(PLAYER_PAUSED, 97));
|
||||||
|
reporter.observe(sample(PLAYER_PAUSED, std::numeric_limits<double>::quiet_NaN()));
|
||||||
|
pending.position = 99;
|
||||||
|
reporter.observe(pending);
|
||||||
|
reporter.closeMedia();
|
||||||
|
reporter.closeMedia();
|
||||||
|
reporter.finish();
|
||||||
|
reporter.finish();
|
||||||
|
require(reporter.error().empty(), "normal output drains without errors");
|
||||||
|
}
|
||||||
|
auto rows = records(path);
|
||||||
|
require(rows.count == 6, "initial, exact period, acknowledged seek, pause, backward seek, and one close only");
|
||||||
|
expect(rows[0], @"progress", firstURL, 1.2345678901234567, true);
|
||||||
|
expect(rows[1], @"progress", firstURL, 6.25, true);
|
||||||
|
expect(rows[2], @"progress", firstURL, 95, true);
|
||||||
|
expect(rows[3], @"progress", firstURL, 96.5, true);
|
||||||
|
expect(rows[4], @"progress", firstURL, 50, true);
|
||||||
|
expect(rows[5], @"closed", firstURL, 97, true);
|
||||||
|
|
||||||
|
const auto identityPath = root + "/identity.jsonl";
|
||||||
|
now = 0;
|
||||||
|
{
|
||||||
|
ProgressReporter reporter(identityPath, [&] { return now; });
|
||||||
|
reporter.begin(firstURL);
|
||||||
|
reporter.observe(sample(PLAYER_PLAYING, 10));
|
||||||
|
now = 1;
|
||||||
|
reporter.observe(sample(PLAYER_PLAYING, 96));
|
||||||
|
reporter.begin(firstURL);
|
||||||
|
reporter.observe(sample(PLAYER_OPENING, 0));
|
||||||
|
now = 2;
|
||||||
|
reporter.observe(sample(PLAYER_PAUSED, 96));
|
||||||
|
now = 2.1;
|
||||||
|
reporter.observe(sample(PLAYER_PLAYING, 20));
|
||||||
|
now = 3;
|
||||||
|
reporter.observe(sample(PLAYER_PLAYING, 96));
|
||||||
|
reporter.begin(secondURL);
|
||||||
|
reporter.observe(sample(PLAYER_PLAYING, 90));
|
||||||
|
now = 3.1;
|
||||||
|
reporter.observe(sample(PLAYER_PLAYING, 96));
|
||||||
|
now = 4;
|
||||||
|
reporter.observe(sample(PLAYER_ENDED, 100));
|
||||||
|
reporter.observe(sample(PLAYER_ENDED, 0));
|
||||||
|
reporter.begin("");
|
||||||
|
reporter.observe(sample(PLAYER_PLAYING, 10));
|
||||||
|
reporter.finish();
|
||||||
|
}
|
||||||
|
rows = records(identityPath);
|
||||||
|
require(rows.count == 8, "quality reopen preserves threshold while replacement resets identity and closes each source");
|
||||||
|
expect(rows[0], @"progress", firstURL, 10, true);
|
||||||
|
expect(rows[1], @"progress", firstURL, 96, true);
|
||||||
|
expect(rows[2], @"progress", firstURL, 96, true);
|
||||||
|
expect(rows[3], @"closed", firstURL, 96, true);
|
||||||
|
expect(rows[4], @"progress", secondURL, 90, true);
|
||||||
|
expect(rows[5], @"progress", secondURL, 96, true);
|
||||||
|
expect(rows[6], @"ended", secondURL, 100, true);
|
||||||
|
expect(rows[7], @"closed", secondURL, 100, true);
|
||||||
|
|
||||||
|
const auto terminalPath = root + "/terminal.jsonl";
|
||||||
|
now = 0;
|
||||||
|
{
|
||||||
|
ProgressReporter reporter(terminalPath, [&] { return now; });
|
||||||
|
reporter.begin(firstURL);
|
||||||
|
reporter.observe(sample(PLAYER_PLAYING, 94));
|
||||||
|
now = 1;
|
||||||
|
reporter.observe(sample(PLAYER_PAUSED, 95));
|
||||||
|
reporter.closeMedia();
|
||||||
|
reporter.begin(secondURL);
|
||||||
|
reporter.observe(sample(PLAYER_PLAYING, 94));
|
||||||
|
reporter.observe(sample(PLAYER_ENDED, 95));
|
||||||
|
reporter.finish();
|
||||||
|
}
|
||||||
|
rows = records(terminalPath);
|
||||||
|
require(rows.count == 6, "pause/ended threshold crossings do not add redundant progress events");
|
||||||
|
expect(rows[1], @"progress", firstURL, 95, true);
|
||||||
|
expect(rows[2], @"closed", firstURL, 95, true);
|
||||||
|
expect(rows[4], @"ended", secondURL, 95, true);
|
||||||
|
expect(rows[5], @"closed", secondURL, 95, true);
|
||||||
|
|
||||||
|
const auto eligibilityPath = root + "/eligibility.jsonl";
|
||||||
|
{
|
||||||
|
ProgressReporter reporter(eligibilityPath, [] { return 0.; });
|
||||||
|
for (int scenario = 0; scenario < 7; ++scenario) {
|
||||||
|
auto state = sample(PLAYER_PAUSED, 97);
|
||||||
|
if (scenario == 0) state.duration = 0;
|
||||||
|
if (scenario == 1) state.duration = -1;
|
||||||
|
if (scenario == 2) state.duration = std::numeric_limits<double>::infinity();
|
||||||
|
if (scenario == 3) state.duration = std::numeric_limits<double>::quiet_NaN();
|
||||||
|
if (scenario == 5) state.can_seek = false;
|
||||||
|
if (scenario == 6) state.has_video = false;
|
||||||
|
reporter.begin(firstURL, scenario == 4);
|
||||||
|
reporter.observe(state);
|
||||||
|
reporter.closeMedia();
|
||||||
|
}
|
||||||
|
reporter.finish();
|
||||||
|
}
|
||||||
|
rows = records(eligibilityPath);
|
||||||
|
require(rows.count == 14, "every ineligible source has an initial and terminal snapshot");
|
||||||
|
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 auto clockFailurePath = root + "/clock-failure.jsonl";
|
||||||
|
{
|
||||||
|
ProgressReporter reporter(clockFailurePath, []() -> double { throw std::runtime_error("clock failure"); });
|
||||||
|
reporter.begin(firstURL);
|
||||||
|
reporter.observe(sample(PLAYER_PLAYING, 1));
|
||||||
|
reporter.closeMedia();
|
||||||
|
reporter.finish();
|
||||||
|
}
|
||||||
|
require([[NSData dataWithContentsOfFile:nativePath(clockFailurePath)] length] == 0,
|
||||||
|
"a reporting exception cannot emit later close records");
|
||||||
|
|
||||||
|
const auto privatePath = root + "/private.jsonl";
|
||||||
|
mode_t oldMask = umask(0777);
|
||||||
|
{ ProgressReporter reporter(privatePath); umask(oldMask); reporter.finish(); }
|
||||||
|
struct stat info{};
|
||||||
|
require(stat(privatePath.c_str(), &info) == 0 && S_ISREG(info.st_mode) &&
|
||||||
|
(info.st_mode & 0777) == 0600 && info.st_uid == geteuid(), "new destination is regular and exactly owner-private despite umask");
|
||||||
|
const auto existingPath = root + "/existing.jsonl";
|
||||||
|
NSData *sentinel = [@"unchanged" dataUsingEncoding:NSUTF8StringEncoding];
|
||||||
|
require([sentinel writeToFile:nativePath(existingPath) atomically:NO], "create existing destination sentinel");
|
||||||
|
require(rejected(existingPath), "existing destination fails exclusive initialization");
|
||||||
|
const auto linkPath = root + "/symlink.jsonl";
|
||||||
|
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 concurrentPath = root + "/concurrent.jsonl";
|
||||||
|
std::atomic<int> ready{0}, successes{0}, failures{0};
|
||||||
|
std::atomic<bool> start{false};
|
||||||
|
auto contender = [&] {
|
||||||
|
ready.fetch_add(1);
|
||||||
|
while (!start.load()) std::this_thread::yield();
|
||||||
|
try { ProgressReporter reporter(concurrentPath); ++successes; reporter.finish(); }
|
||||||
|
catch (const std::exception &) { ++failures; }
|
||||||
|
};
|
||||||
|
std::thread left(contender), right(contender);
|
||||||
|
while (ready.load() != 2) std::this_thread::yield();
|
||||||
|
start.store(true);
|
||||||
|
left.join(); right.join();
|
||||||
|
require(successes == 1 && failures == 1, "concurrent initialization admits exactly one destination owner");
|
||||||
|
|
||||||
|
const auto failedPath = root + "/failed.jsonl";
|
||||||
|
char *childArguments[] = {argv[0], const_cast<char *>("--write-failure"),
|
||||||
|
const_cast<char *>(failedPath.c_str()), nullptr};
|
||||||
|
pid_t child = 0;
|
||||||
|
require(posix_spawnp(&child, argv[0], nullptr, nullptr, childArguments, environ) == 0, "launch isolated write-failure subprocess");
|
||||||
|
int status = 0;
|
||||||
|
require(waitpid(child, &status, 0) == child && WIFEXITED(status) && WEXITSTATUS(status) == 0,
|
||||||
|
"resource-limit failure remains nonfatal and output stays disabled");
|
||||||
|
require([[NSData dataWithContentsOfFile:nativePath(failedPath)] length] == 1, "forced failure permits only its final partial line");
|
||||||
|
require([[NSFileManager defaultManager] removeItemAtPath:nativePath(root) error:nullptr], "remove isolated check files");
|
||||||
|
std::puts("JSONL playback progress reporting passed");
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ DEBUG_FLAGS ?= -g
|
|||||||
PREFIX ?= $(HOME)/.local
|
PREFIX ?= $(HOME)/.local
|
||||||
CXXFLAGS = -std=c++20 -mmacosx-version-min=15.0 -O2 $(DEBUG_FLAGS) -Wall -Wextra -Wno-unused-parameter $(FF_CFLAGS)
|
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
|
FRAMEWORKS := -framework AppKit -framework AudioToolbox -framework CoreAudio -framework Metal -framework MetalKit -framework QuartzCore -framework MediaPlayer
|
||||||
OBJECTS := $(BUILD_DIR)/player.o $(BUILD_DIR)/Output.o
|
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
|
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
|
||||||
RUNTIME_SOURCE := Sources/Launcher/FFmpegRuntime.swift
|
RUNTIME_SOURCE := Sources/Launcher/FFmpegRuntime.swift
|
||||||
CLI_SOURCES := Sources/CLI/TerebiCLI.swift Sources/CLI/PlayerCommand.swift Sources/App/YouTubeResolver.swift $(RUNTIME_SOURCE)
|
CLI_SOURCES := Sources/CLI/TerebiCLI.swift Sources/CLI/PlayerCommand.swift Sources/App/YouTubeResolver.swift $(RUNTIME_SOURCE)
|
||||||
@@ -22,7 +22,7 @@ PLAYER := $(APP)/Contents/Frameworks/libterebi-player.dylib
|
|||||||
BOOTSTRAP := $(APP)/Contents/MacOS/terebi
|
BOOTSTRAP := $(APP)/Contents/MacOS/terebi
|
||||||
MANIFEST := $(APP)/Contents/Resources/ffmpeg-libraries.txt
|
MANIFEST := $(APP)/Contents/Resources/ffmpeg-libraries.txt
|
||||||
|
|
||||||
.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-cli check-runtime check-media check-startup
|
.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
|
all: check-dependencies $(BUILD_DIR)/terebi $(PLAYER) $(BOOTSTRAP) $(MANIFEST) $(APP)/Contents/Info.plist $(APP)/Contents/Resources/TOMLDecoder-LICENSE.txt
|
||||||
|
|
||||||
check-dependencies:
|
check-dependencies:
|
||||||
@@ -53,7 +53,10 @@ $(APP)/Contents/Resources/TOMLDecoder-LICENSE.txt: $(CONFIG_LIBRARY)
|
|||||||
mkdir -p "$(APP)/Contents/Resources"
|
mkdir -p "$(APP)/Contents/Resources"
|
||||||
cp -f "$(CONFIG_BUILD)/checkouts/TOMLDecoder/LICENSE.md" "$@"
|
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 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/network_input.hpp Sources/Core/progress_reporter.hpp Makefile | $(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)
|
||||||
$(CXX) $(CXXFLAGS) -c $< -o $@
|
$(CXX) $(CXXFLAGS) -c $< -o $@
|
||||||
|
|
||||||
$(BUILD_DIR)/Output.o: Sources/macOS/Output.mm Sources/Core/output.hpp Makefile | $(BUILD_DIR) check-dependencies
|
$(BUILD_DIR)/Output.o: Sources/macOS/Output.mm Sources/Core/output.hpp Makefile | $(BUILD_DIR) check-dependencies
|
||||||
@@ -92,7 +95,7 @@ run: all
|
|||||||
$(BUILD_DIR)/playback-check: Checks/playback.mm $(OBJECTS) Makefile | check-dependencies
|
$(BUILD_DIR)/playback-check: Checks/playback.mm $(OBJECTS) Makefile | check-dependencies
|
||||||
$(CXX) $(CXXFLAGS) -fobjc-arc $< $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -o $@
|
$(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-cli check-runtime check-media check-startup
|
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)/playback-check $(BUILD_DIR)/fixtures
|
$(BUILD_DIR)/playback-check $(BUILD_DIR)/fixtures
|
||||||
|
|
||||||
check-fixtures: | check-dependencies
|
check-fixtures: | check-dependencies
|
||||||
@@ -109,8 +112,8 @@ check-fixtures: | check-dependencies
|
|||||||
$(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 | $(BUILD_DIR) check-dependencies
|
||||||
$(CXX) $(CXXFLAGS) -fobjc-arc -DPLAYER_AUDIO_CHECK -c $< -o $@
|
$(CXX) $(CXXFLAGS) -fobjc-arc -DPLAYER_AUDIO_CHECK -c $< -o $@
|
||||||
|
|
||||||
$(BUILD_DIR)/audio-continuity-check: Checks/audio-continuity.cpp $(BUILD_DIR)/player.o $(BUILD_DIR)/Output-check.o Makefile | check-dependencies
|
$(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
|
||||||
$(CXX) $(CXXFLAGS) $< $(BUILD_DIR)/player.o $(BUILD_DIR)/Output-check.o $(FF_LIBS) $(FRAMEWORKS) -o $@
|
$(CXX) $(CXXFLAGS) $< $(BUILD_DIR)/player.o $(BUILD_DIR)/progress_reporter.o $(BUILD_DIR)/Output-check.o $(FF_LIBS) $(FRAMEWORKS) -o $@
|
||||||
|
|
||||||
check-audio: $(BUILD_DIR)/audio-continuity-check
|
check-audio: $(BUILD_DIR)/audio-continuity-check
|
||||||
mkdir -p $(BUILD_DIR)/fixtures
|
mkdir -p $(BUILD_DIR)/fixtures
|
||||||
@@ -175,6 +178,12 @@ $(BUILD_DIR)/playback-progress-check: Checks/playback-progress.swift Sources/App
|
|||||||
check-progress: $(BUILD_DIR)/playback-progress-check
|
check-progress: $(BUILD_DIR)/playback-progress-check
|
||||||
$(BUILD_DIR)/playback-progress-check
|
$(BUILD_DIR)/playback-progress-check
|
||||||
|
|
||||||
|
$(BUILD_DIR)/progress-reporting-check: Checks/progress-reporting.mm Sources/Core/progress_reporter.cpp Sources/Core/progress_reporter.hpp Sources/Core/player.h Makefile | $(BUILD_DIR)
|
||||||
|
$(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
|
$(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 $@
|
$(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 $@
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ The declared app executable stays running after preflight and loads `Contents/Fr
|
|||||||
.build/terebi -- -video.mp4
|
.build/terebi -- -video.mp4
|
||||||
.build/terebi --new-instance
|
.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'
|
||||||
|
.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 --new-instance --no-config "/path/to/video.mp4"
|
||||||
.build/terebi --help
|
.build/terebi --help
|
||||||
.build/terebi --default-config
|
.build/terebi --default-config
|
||||||
@@ -58,6 +59,8 @@ The launcher finds `terebi.app` beside its own executable, following symlinks. B
|
|||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
## Configuration
|
## 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.
|
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.
|
||||||
@@ -148,6 +151,34 @@ Progress is stored locally in UserDefaults, checkpointed every five seconds of p
|
|||||||
|
|
||||||
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.
|
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"
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Each complete line is a version-1 JSON record:
|
||||||
|
|
||||||
|
```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}
|
||||||
|
```
|
||||||
|
|
||||||
|
`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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
## Playback UI
|
## Playback UI
|
||||||
|
|
||||||
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.
|
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.
|
||||||
@@ -175,18 +206,19 @@ Only implemented actions have controls; there are no placeholder playlist, subti
|
|||||||
|
|
||||||
## Where to change things
|
## Where to change things
|
||||||
|
|
||||||
- `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, and live status. `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.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/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/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/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 state polling, file/YouTube panels and Finder/drop handling, cancellable URL resolution, manual/automatic YouTube quality switching with position/playback-state restoration, autoplay, video host, AppKit glass and native slider cells, floating controls, visibility, video-fitted sizing, and native window integration. New media opens autoplay; quality switches preserve play/pause state. The C interface opens paused for callers that need explicit control.
|
- `Sources/App/TerebiApp.swift`: MainActor state polling, file/YouTube panels and Finder/drop handling, cancellable URL resolution, manual/automatic YouTube quality switching with position/playback-state restoration, autoplay, video host, AppKit glass and native slider cells, floating controls, visibility, video-fitted sizing, and native window integration. New media opens autoplay; quality switches preserve play/pause state. The C interface opens paused for callers that need explicit control.
|
||||||
- `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/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/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 and defers resumed autoplay until the saved seek is queued against the ready source.
|
- `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/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/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/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. Launch the app without media first to establish its singleton window, then deliver the selected URL; the app owns Apple events to prevent SwiftUI's default external-document routing from replacing that window.
|
- `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/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.
|
- `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.
|
- `Makefile`: compiler/linker commands, SwiftPM configuration-library build, dependency preflight, generated runtime manifest, developer/release bundles, installation, and checks. No generated Xcode project.
|
||||||
@@ -213,6 +245,8 @@ Generates short fixtures and exercises the real playback interface and native ou
|
|||||||
|
|
||||||
`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-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-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-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.
|
||||||
@@ -221,6 +255,8 @@ Generates short fixtures and exercises the real playback interface and native ou
|
|||||||
|
|
||||||
`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.
|
`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.
|
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.
|
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.
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import TerebiConfiguration
|
|||||||
@MainActor
|
@MainActor
|
||||||
final class PlayerModel: ObservableObject {
|
final class PlayerModel: ObservableObject {
|
||||||
static var startupConfiguration = PlayerConfiguration()
|
static var startupConfiguration = PlayerConfiguration()
|
||||||
static let shared = PlayerModel(configuration: startupConfiguration)
|
static var startupProgressFile: String?
|
||||||
|
static let shared = PlayerModel(configuration: startupConfiguration, progressFile: startupProgressFile)
|
||||||
|
|
||||||
@Published private(set) var state = PlayerState()
|
@Published private(set) var state = PlayerState()
|
||||||
@Published private(set) var fileName: String?
|
@Published private(set) var fileName: String?
|
||||||
@@ -80,8 +81,16 @@ final class PlayerModel: ObservableObject {
|
|||||||
private var resumePlaying = true
|
private var resumePlaying = true
|
||||||
private var lastSavedPosition: Double?
|
private var lastSavedPosition: Double?
|
||||||
private var systemPlayback: SystemPlaybackControls?
|
private var systemPlayback: SystemPlaybackControls?
|
||||||
|
private(set) var progressInitializationError: String?
|
||||||
|
private var reportingEnabled = false
|
||||||
|
private var reportingErrorShown = false
|
||||||
|
private var reportingErrorBuffer = [CChar]()
|
||||||
|
private var reportingErrorMessage: String {
|
||||||
|
reportingErrorBuffer.withUnsafeBufferPointer { String(cString: $0.baseAddress!) }
|
||||||
|
}
|
||||||
|
|
||||||
init(defaults: UserDefaults = .standard, configuration: PlayerConfiguration = PlayerConfiguration(),
|
init(defaults: UserDefaults = .standard, configuration: PlayerConfiguration = PlayerConfiguration(),
|
||||||
|
progressFile: String? = nil,
|
||||||
qualityClock: @escaping () -> Double = { ProcessInfo.processInfo.systemUptime }) {
|
qualityClock: @escaping () -> Double = { ProcessInfo.processInfo.systemUptime }) {
|
||||||
self.qualityClock = qualityClock
|
self.qualityClock = qualityClock
|
||||||
keyBindings = configuration.keys
|
keyBindings = configuration.keys
|
||||||
@@ -99,6 +108,15 @@ final class PlayerModel: ObservableObject {
|
|||||||
videoView = nil
|
videoView = nil
|
||||||
commandError = "Could not create the media player."
|
commandError = "Could not create the media player."
|
||||||
}
|
}
|
||||||
|
if let progressFile {
|
||||||
|
reportingErrorBuffer = [CChar](repeating: 0, count: 512)
|
||||||
|
if let player {
|
||||||
|
reportingEnabled = progressFile.withCString { player_set_progress_file(player, $0, &reportingErrorBuffer) }
|
||||||
|
if !reportingEnabled { progressInitializationError = reportingErrorMessage }
|
||||||
|
} else {
|
||||||
|
progressInitializationError = "Could not create the media player to initialize progress reporting."
|
||||||
|
}
|
||||||
|
}
|
||||||
compactWindow.onChange = { [weak self] in
|
compactWindow.onChange = { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
if !self.compactWindow.isTransitioning {
|
if !self.compactWindow.isTransitioning {
|
||||||
@@ -288,12 +306,16 @@ final class PlayerModel: ObservableObject {
|
|||||||
guard let handle else { return }
|
guard let handle else { return }
|
||||||
saveProgress()
|
saveProgress()
|
||||||
let revision = player_get_state(handle).revision
|
let revision = player_get_state(handle).revision
|
||||||
let accepted = video.video.url.withCString { videoURL in
|
let initialPosition = video.isLive ? 0 :
|
||||||
|
(restoring.map { $0.position ?? 0 } ?? playbackProgress.position(for: video.webpageURL) ?? 0)
|
||||||
|
let accepted = video.webpageURL.absoluteString.withCString { mediaURL in
|
||||||
|
video.video.url.withCString { videoURL in
|
||||||
video.video.headers.withCString { videoHeaders in
|
video.video.headers.withCString { videoHeaders in
|
||||||
func enqueue(audioURL: UnsafePointer<CChar>?, audioHeaders: UnsafePointer<CChar>?) -> Bool {
|
func enqueue(audioURL: UnsafePointer<CChar>?, audioHeaders: UnsafePointer<CChar>?) -> Bool {
|
||||||
var source = PlayerNetworkSource(video_url: videoURL, audio_url: audioURL,
|
var source = PlayerNetworkSource(video_url: videoURL, audio_url: audioURL,
|
||||||
video_headers: videoHeaders, audio_headers: audioHeaders,
|
video_headers: videoHeaders, audio_headers: audioHeaders,
|
||||||
duration: video.duration, is_live: video.isLive)
|
duration: video.duration, is_live: video.isLive,
|
||||||
|
media_url: mediaURL, initial_position: initialPosition)
|
||||||
return player_open_network(handle, &source)
|
return player_open_network(handle, &source)
|
||||||
}
|
}
|
||||||
if let audio = video.audio {
|
if let audio = video.audio {
|
||||||
@@ -304,6 +326,7 @@ final class PlayerModel: ObservableObject {
|
|||||||
return enqueue(audioURL: nil, audioHeaders: nil)
|
return enqueue(audioURL: nil, audioHeaders: nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
guard accepted else {
|
guard accepted else {
|
||||||
if automatic {
|
if automatic {
|
||||||
automaticQuality.extractionFailed(at: qualityNow)
|
automaticQuality.extractionFailed(at: qualityNow)
|
||||||
@@ -338,7 +361,11 @@ final class PlayerModel: ObservableObject {
|
|||||||
youtubeHeight = nil
|
youtubeHeight = nil
|
||||||
youtubeQuality = nil
|
youtubeQuality = nil
|
||||||
progressURL = url
|
progressURL = url
|
||||||
if let restoring {
|
if !url.isFileURL {
|
||||||
|
// Network restoration is part of the backend open transaction, so
|
||||||
|
// reporting cannot observe a pre-resume position as initial state.
|
||||||
|
resumePosition = nil
|
||||||
|
} else if let restoring {
|
||||||
resumePosition = restoring.position
|
resumePosition = restoring.position
|
||||||
} else {
|
} else {
|
||||||
resumePosition = live ? nil : playbackProgress.position(for: url)
|
resumePosition = live ? nil : playbackProgress.position(for: url)
|
||||||
@@ -729,6 +756,11 @@ final class PlayerModel: ObservableObject {
|
|||||||
|
|
||||||
private func poll() {
|
private func poll() {
|
||||||
guard let handle else { return }
|
guard let handle else { return }
|
||||||
|
if reportingEnabled && !reportingErrorShown {
|
||||||
|
if player_get_progress_error(handle, &reportingErrorBuffer) {
|
||||||
|
showReportingError(reportingErrorMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
applyState(player_get_state(handle))
|
applyState(player_get_state(handle))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -798,6 +830,21 @@ final class PlayerModel: ObservableObject {
|
|||||||
resolveYouTube(url, height: height, automatic: true)
|
resolveYouTube(url, height: height, automatic: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func showReportingError(_ message: String, terminating: Bool = false) {
|
||||||
|
guard !reportingErrorShown else { return }
|
||||||
|
reportingErrorShown = true
|
||||||
|
FileHandle.standardError.write(Data("terebi: \(message)\n".utf8))
|
||||||
|
let alert = NSAlert()
|
||||||
|
alert.alertStyle = .warning
|
||||||
|
alert.messageText = "Playback progress reporting failed"
|
||||||
|
alert.informativeText = message + "\n\nReporting has been disabled. Playback is unaffected."
|
||||||
|
if let window, !terminating {
|
||||||
|
alert.beginSheetModal(for: window)
|
||||||
|
} else {
|
||||||
|
alert.runModal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func shutdown() {
|
func shutdown() {
|
||||||
saveProgress()
|
saveProgress()
|
||||||
cancelYouTubeResolution()
|
cancelYouTubeResolution()
|
||||||
@@ -809,6 +856,11 @@ final class PlayerModel: ObservableObject {
|
|||||||
hideDeadline = nil
|
hideDeadline = nil
|
||||||
NSCursor.setHiddenUntilMouseMoves(false)
|
NSCursor.setHiddenUntilMouseMoves(false)
|
||||||
guard let player = handle else { return }
|
guard let player = handle else { return }
|
||||||
|
if reportingEnabled {
|
||||||
|
if !player_shutdown(player, &reportingErrorBuffer) {
|
||||||
|
showReportingError(reportingErrorMessage, terminating: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
handle = nil
|
handle = nil
|
||||||
player_destroy(player)
|
player_destroy(player)
|
||||||
}
|
}
|
||||||
@@ -1526,5 +1578,31 @@ public func terebiAppMain() {
|
|||||||
alert.runModal()
|
alert.runModal()
|
||||||
exit(78)
|
exit(78)
|
||||||
}
|
}
|
||||||
|
let arguments = Array(CommandLine.arguments.dropFirst())
|
||||||
|
if let index = arguments.firstIndex(of: "--progress-file") {
|
||||||
|
guard index + 1 < arguments.count, arguments[index + 1].hasPrefix("/"),
|
||||||
|
arguments.filter({ $0 == "--progress-file" }).count == 1 else {
|
||||||
|
let alert = NSAlert()
|
||||||
|
alert.alertStyle = .critical
|
||||||
|
alert.messageText = "Could not initialize playback progress reporting"
|
||||||
|
alert.informativeText = "--progress-file requires one absolute filesystem path."
|
||||||
|
alert.runModal()
|
||||||
|
exit(73)
|
||||||
|
}
|
||||||
|
PlayerModel.startupProgressFile = arguments[index + 1]
|
||||||
|
_ = NSApplication.shared
|
||||||
|
if let error = PlayerModel.shared.progressInitializationError {
|
||||||
|
PlayerModel.shared.shutdown()
|
||||||
|
FileHandle.standardError.write(Data("terebi: \(error)\n".utf8))
|
||||||
|
NSApp.setActivationPolicy(.regular)
|
||||||
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
|
let alert = NSAlert()
|
||||||
|
alert.alertStyle = .critical
|
||||||
|
alert.messageText = "Could not initialize playback progress reporting"
|
||||||
|
alert.informativeText = error
|
||||||
|
alert.runModal()
|
||||||
|
exit(73)
|
||||||
|
}
|
||||||
|
}
|
||||||
TerebiApp.main()
|
TerebiApp.main()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,12 @@ import Foundation
|
|||||||
enum PlayerCommand {
|
enum PlayerCommand {
|
||||||
case help
|
case help
|
||||||
case printDefaultConfig
|
case printDefaultConfig
|
||||||
case launch(URL?, newInstance: Bool, noConfig: Bool)
|
case launch(URL?, newInstance: Bool, noConfig: Bool, progressFile: URL?)
|
||||||
|
|
||||||
static let usage = """
|
static let usage = """
|
||||||
Usage: terebi [--new-instance] [--no-config] [FILE | YOUTUBE_URL]
|
Usage: terebi [--new-instance] [--no-config] [FILE | YOUTUBE_URL]
|
||||||
terebi [--new-instance] [--no-config] --url YOUTUBE_URL
|
terebi [--new-instance] [--no-config] --url YOUTUBE_URL
|
||||||
|
terebi --new-instance --url YOUTUBE_URL --progress-file PATH
|
||||||
terebi [--new-instance] [--no-config] -- FILE
|
terebi [--new-instance] [--no-config] -- FILE
|
||||||
terebi --help
|
terebi --help
|
||||||
terebi --default-config
|
terebi --default-config
|
||||||
@@ -19,6 +20,8 @@ enum PlayerCommand {
|
|||||||
--new-instance Launch a separate player, leaving existing playback untouched.
|
--new-instance Launch a separate player, leaving existing playback untouched.
|
||||||
--no-config Use built-in settings instead of config.toml on a fresh launch.
|
--no-config Use built-in settings instead of config.toml on a fresh launch.
|
||||||
--url URL Open a YouTube video URL.
|
--url URL Open a YouTube video URL.
|
||||||
|
--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 '-'.
|
-- Treat the following argument as a filename, even with a leading '-'.
|
||||||
-h, --help Show this help without launching the player.
|
-h, --help Show this help without launching the player.
|
||||||
--default-config Print the complete built-in TOML without launching.
|
--default-config Print the complete built-in TOML without launching.
|
||||||
@@ -30,6 +33,23 @@ enum PlayerCommand {
|
|||||||
|
|
||||||
static func parse(_ arguments: [String], directory: URL = URL(fileURLWithPath: FileManager.default.currentDirectoryPath, isDirectory: true)) throws -> PlayerCommand {
|
static func parse(_ arguments: [String], directory: URL = URL(fileURLWithPath: FileManager.default.currentDirectoryPath, isDirectory: true)) throws -> PlayerCommand {
|
||||||
var inputs = arguments
|
var inputs = arguments
|
||||||
|
var progressFile: URL?
|
||||||
|
var index = 0
|
||||||
|
while index < inputs.count && inputs[index] != "--" {
|
||||||
|
if inputs[index] != "--progress-file" {
|
||||||
|
index += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
guard progressFile == nil else { throw ArgumentError("--progress-file may only be supplied once.") }
|
||||||
|
guard index + 1 < inputs.count, !inputs[index + 1].isEmpty,
|
||||||
|
!inputs[index + 1].contains("\0"), !inputs[index + 1].hasPrefix("--") else {
|
||||||
|
throw ArgumentError("--progress-file requires a filesystem path.")
|
||||||
|
}
|
||||||
|
let base = directory.hasDirectoryPath ? directory :
|
||||||
|
URL(fileURLWithPath: directory.path, isDirectory: true)
|
||||||
|
progressFile = URL(fileURLWithPath: inputs[index + 1], relativeTo: base).absoluteURL.standardizedFileURL
|
||||||
|
inputs.removeSubrange(index...index + 1)
|
||||||
|
}
|
||||||
var newInstance = false
|
var newInstance = false
|
||||||
var noConfig = false
|
var noConfig = false
|
||||||
while let option = inputs.first, option == "--new-instance" || option == "--no-config" {
|
while let option = inputs.first, option == "--new-instance" || option == "--no-config" {
|
||||||
@@ -42,12 +62,24 @@ enum PlayerCommand {
|
|||||||
}
|
}
|
||||||
inputs.removeFirst()
|
inputs.removeFirst()
|
||||||
}
|
}
|
||||||
if inputs.isEmpty { return .launch(nil, newInstance: newInstance, noConfig: noConfig) }
|
func launch(_ url: URL?) throws -> PlayerCommand {
|
||||||
if inputs == ["--help"] || inputs == ["-h"] { return .help }
|
guard progressFile == nil || newInstance else {
|
||||||
if inputs == ["--default-config"] { return .printDefaultConfig }
|
throw ArgumentError("--progress-file requires --new-instance.")
|
||||||
|
}
|
||||||
|
return .launch(url, newInstance: newInstance, noConfig: noConfig, progressFile: progressFile)
|
||||||
|
}
|
||||||
|
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.") }
|
||||||
|
return .help
|
||||||
|
}
|
||||||
|
if inputs == ["--default-config"] {
|
||||||
|
guard progressFile == nil else { throw ArgumentError("--progress-file cannot be combined with --default-config.") }
|
||||||
|
return .printDefaultConfig
|
||||||
|
}
|
||||||
if inputs.first == "--url" {
|
if inputs.first == "--url" {
|
||||||
guard inputs.count == 2 else { throw ArgumentError("--url requires exactly one YouTube URL.") }
|
guard inputs.count == 2 else { throw ArgumentError("--url requires exactly one YouTube URL.") }
|
||||||
return .launch(try YouTubeResolver.validateURL(inputs[1]), newInstance: newInstance, noConfig: noConfig)
|
return try launch(YouTubeResolver.validateURL(inputs[1]))
|
||||||
}
|
}
|
||||||
let filenameOnly = inputs.first == "--"
|
let filenameOnly = inputs.first == "--"
|
||||||
if filenameOnly {
|
if filenameOnly {
|
||||||
@@ -67,7 +99,7 @@ enum PlayerCommand {
|
|||||||
}
|
}
|
||||||
file = url
|
file = url
|
||||||
} else {
|
} else {
|
||||||
return .launch(try YouTubeResolver.validateURL(input), newInstance: newInstance, noConfig: noConfig)
|
return try launch(YouTubeResolver.validateURL(input))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let base = directory.hasDirectoryPath ? directory : URL(fileURLWithPath: directory.path, isDirectory: true)
|
let base = directory.hasDirectoryPath ? directory : URL(fileURLWithPath: directory.path, isDirectory: true)
|
||||||
@@ -77,7 +109,7 @@ enum PlayerCommand {
|
|||||||
FileManager.default.isReadableFile(atPath: file.path) else {
|
FileManager.default.isReadableFile(atPath: file.path) else {
|
||||||
throw ArgumentError("File does not exist or is not a readable regular file.")
|
throw ArgumentError("File does not exist or is not a readable regular file.")
|
||||||
}
|
}
|
||||||
return .launch(file.standardizedFileURL, newInstance: newInstance, noConfig: noConfig)
|
return try launch(file.standardizedFileURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct ArgumentError: LocalizedError {
|
private struct ArgumentError: LocalizedError {
|
||||||
|
|||||||
@@ -17,7 +17,16 @@ enum TerebiCLI {
|
|||||||
print(PlayerCommand.usage)
|
print(PlayerCommand.usage)
|
||||||
case .printDefaultConfig:
|
case .printDefaultConfig:
|
||||||
print(PlayerConfiguration.defaultTOML, terminator: "")
|
print(PlayerConfiguration.defaultTOML, terminator: "")
|
||||||
case .launch(let url, let newInstance, let noConfig):
|
case .launch(let url, let newInstance, let noConfig, let progressFile):
|
||||||
|
if let progressFile {
|
||||||
|
var existing = stat()
|
||||||
|
guard lstat(progressFile.path, &existing) != 0 else {
|
||||||
|
fail("Progress file already exists: \(progressFile.path). Supply a nonexistent destination.", code: 73)
|
||||||
|
}
|
||||||
|
guard errno == ENOENT else {
|
||||||
|
fail("Could not inspect progress file \(progressFile.path): \(String(cString: strerror(errno))).", code: 73)
|
||||||
|
}
|
||||||
|
}
|
||||||
let configURL = PlayerConfiguration.fileURL(environment: ProcessInfo.processInfo.environment,
|
let configURL = PlayerConfiguration.fileURL(environment: ProcessInfo.processInfo.environment,
|
||||||
homeDirectory: FileManager.default.homeDirectoryForCurrentUser)
|
homeDirectory: FileManager.default.homeDirectoryForCurrentUser)
|
||||||
if !noConfig {
|
if !noConfig {
|
||||||
@@ -39,6 +48,9 @@ enum TerebiCLI {
|
|||||||
configuration.createsNewApplicationInstance = newInstance
|
configuration.createsNewApplicationInstance = newInstance
|
||||||
// LaunchServices does not inherit the invoking shell's XDG environment.
|
// LaunchServices does not inherit the invoking shell's XDG environment.
|
||||||
configuration.arguments = noConfig ? ["--no-config"] : ["--config-file", configURL.absoluteString]
|
configuration.arguments = noConfig ? ["--no-config"] : ["--config-file", configURL.absoluteString]
|
||||||
|
if let progressFile {
|
||||||
|
configuration.arguments += ["--progress-file", progressFile.path]
|
||||||
|
}
|
||||||
if newInstance, let url {
|
if newInstance, let url {
|
||||||
configuration.arguments += ["--open-media", url.absoluteString]
|
configuration.arguments += ["--open-media", url.absoluteString]
|
||||||
}
|
}
|
||||||
|
|||||||
+93
-4
@@ -1,6 +1,7 @@
|
|||||||
#include "player.h"
|
#include "player.h"
|
||||||
#include "player_macos.h"
|
#include "player_macos.h"
|
||||||
#include "output.hpp"
|
#include "output.hpp"
|
||||||
|
#include "progress_reporter.hpp"
|
||||||
|
|
||||||
extern "C" {
|
extern "C" {
|
||||||
#include <libavcodec/avcodec.h>
|
#include <libavcodec/avcodec.h>
|
||||||
@@ -85,6 +86,8 @@ struct Command {
|
|||||||
double sourceDuration = 0;
|
double sourceDuration = 0;
|
||||||
bool network = false;
|
bool network = false;
|
||||||
bool live = false;
|
bool live = false;
|
||||||
|
std::string mediaURL{};
|
||||||
|
double initialPosition = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
void copyError(char *destination, const char *message) noexcept {
|
void copyError(char *destination, const char *message) noexcept {
|
||||||
@@ -223,14 +226,19 @@ struct Player {
|
|||||||
PlayerStatus status = PLAYER_EMPTY;
|
PlayerStatus status = PLAYER_EMPTY;
|
||||||
bool previewShown = false;
|
bool previewShown = false;
|
||||||
std::array<char, 512> error{};
|
std::array<char, 512> error{};
|
||||||
|
std::unique_ptr<playback::ProgressReporter> reporter;
|
||||||
std::thread worker;
|
std::thread worker;
|
||||||
|
|
||||||
Player() : worker([this] { run(); }) {}
|
Player() : worker([this] { run(); }) {}
|
||||||
~Player() {
|
~Player() { shutdown(); }
|
||||||
stopping.store(true, std::memory_order_relaxed);
|
|
||||||
|
void shutdown() {
|
||||||
|
if (!stopping.exchange(true, std::memory_order_relaxed)) {
|
||||||
requestedEpoch.fetch_add(1, std::memory_order_relaxed);
|
requestedEpoch.fetch_add(1, std::memory_order_relaxed);
|
||||||
wake.notify_one();
|
wake.notify_one();
|
||||||
|
}
|
||||||
if (worker.joinable()) worker.join();
|
if (worker.joinable()) worker.join();
|
||||||
|
if (reporter) reporter->finish();
|
||||||
}
|
}
|
||||||
|
|
||||||
bool enqueue(Command command) {
|
bool enqueue(Command command) {
|
||||||
@@ -380,6 +388,33 @@ struct Player {
|
|||||||
}
|
}
|
||||||
std::memcpy(state.error, error.data(), error.size());
|
std::memcpy(state.error, error.data(), error.size());
|
||||||
++state.revision;
|
++state.revision;
|
||||||
|
if (reporter && reportingInputsHealthy()) reporter->observe(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool reportingInputsHealthy() const {
|
||||||
|
for (const auto &input : inputs) if (input && input->error()) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void reportBoundary() {
|
||||||
|
if (!reporter || !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{};
|
||||||
|
snapshot.status = status;
|
||||||
|
snapshot.duration = duration;
|
||||||
|
snapshot.has_video = tracks[1].codec != nullptr;
|
||||||
|
snapshot.can_seek = canSeek;
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(mutex);
|
||||||
|
snapshot.seek_pending = networkSeeksPending() ||
|
||||||
|
std::any_of(commands.begin(), commands.end(), [this](const Command &command) {
|
||||||
|
return (command.action == Action::Seek || command.action == Action::Step) &&
|
||||||
|
command.epoch == activeEpoch;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
snapshot.position = status == PLAYER_PLAYING && !snapshot.seek_pending ? clockPosition() : position;
|
||||||
|
reporter->observe(snapshot);
|
||||||
}
|
}
|
||||||
|
|
||||||
void release() {
|
void release() {
|
||||||
@@ -420,6 +455,7 @@ struct Player {
|
|||||||
// Preserve messages that point into one of this object's own state buffers.
|
// Preserve messages that point into one of this object's own state buffers.
|
||||||
std::array<char, 512> saved{};
|
std::array<char, 512> saved{};
|
||||||
copyError(saved.data(), message);
|
copyError(saved.data(), message);
|
||||||
|
if (reporter) reporter->closeMedia();
|
||||||
release();
|
release();
|
||||||
error = saved;
|
error = saved;
|
||||||
status = PLAYER_ERROR;
|
status = PLAYER_ERROR;
|
||||||
@@ -519,6 +555,8 @@ struct Player {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void open(const Command &command) {
|
void open(const Command &command) {
|
||||||
|
reportBoundary();
|
||||||
|
if (reporter) reporter->begin(command.mediaURL, command.live);
|
||||||
release();
|
release();
|
||||||
network = command.network;
|
network = command.network;
|
||||||
status = PLAYER_OPENING;
|
status = PLAYER_OPENING;
|
||||||
@@ -627,6 +665,8 @@ struct Player {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
status = PLAYER_PAUSED;
|
status = PLAYER_PAUSED;
|
||||||
|
if (command.initialPosition > 0 && command.initialPosition < duration && canSeek)
|
||||||
|
seek(command.initialPosition, false);
|
||||||
publish();
|
publish();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -868,13 +908,23 @@ struct Player {
|
|||||||
void execute(const Command &command) {
|
void execute(const Command &command) {
|
||||||
if (command.epoch != requestedEpoch.load(std::memory_order_relaxed)) return;
|
if (command.epoch != requestedEpoch.load(std::memory_order_relaxed)) return;
|
||||||
activeEpoch = command.epoch;
|
activeEpoch = command.epoch;
|
||||||
|
if (reporter && (command.action == Action::Seek || command.action == Action::Step)) {
|
||||||
|
// Local seeks can finish without a pending publication. Preserve
|
||||||
|
// the transition so their acknowledgement still emits a record.
|
||||||
|
std::lock_guard<std::mutex> lock(mutex);
|
||||||
|
reporter->observe(state);
|
||||||
|
}
|
||||||
switch (command.action) {
|
switch (command.action) {
|
||||||
case Action::Open: open(command); break;
|
case Action::Open: open(command); break;
|
||||||
case Action::Play: start(); break;
|
case Action::Play: start(); break;
|
||||||
case Action::Pause: pause(); break;
|
case Action::Pause: pause(); break;
|
||||||
case Action::Seek: seek(command.seconds, status == PLAYER_PLAYING); break;
|
case Action::Seek: seek(command.seconds, status == PLAYER_PLAYING); break;
|
||||||
case Action::Step: step(command.direction); break;
|
case Action::Step: step(command.direction); break;
|
||||||
case Action::Close: release(); break;
|
case Action::Close:
|
||||||
|
reportBoundary();
|
||||||
|
if (reporter) reporter->closeMedia();
|
||||||
|
release();
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
publish();
|
publish();
|
||||||
}
|
}
|
||||||
@@ -1294,7 +1344,11 @@ struct Player {
|
|||||||
try { fail("Unexpected playback failure"); } catch (...) {}
|
try { fail("Unexpected playback failure"); } catch (...) {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
try { release(); } catch (...) {}
|
try {
|
||||||
|
reportBoundary();
|
||||||
|
if (reporter) reporter->closeMedia();
|
||||||
|
release();
|
||||||
|
} catch (...) {}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1317,12 +1371,45 @@ extern "C" {
|
|||||||
Player *player_create(void) {
|
Player *player_create(void) {
|
||||||
try { return new Player; } catch (...) { return nullptr; }
|
try { 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) ||
|
||||||
|
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);
|
||||||
|
return true;
|
||||||
|
} catch (const std::exception &failure) {
|
||||||
|
if (error) copyError(error, failure.what());
|
||||||
|
} catch (...) {
|
||||||
|
if (error) copyError(error, "Could not initialize progress reporting");
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
bool player_get_progress_error(Player *player, char *error) {
|
||||||
|
if (error) error[0] = '\0';
|
||||||
|
if (!player || !player->reporter) return false;
|
||||||
|
const auto failure = player->reporter->error();
|
||||||
|
if (failure.empty()) return false;
|
||||||
|
if (error) copyError(error, failure.c_str());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
bool player_shutdown(Player *player, char *progress_error) {
|
||||||
|
if (progress_error) progress_error[0] = '\0';
|
||||||
|
if (!player) return false;
|
||||||
|
player->shutdown();
|
||||||
|
return !player_get_progress_error(player, progress_error);
|
||||||
|
}
|
||||||
bool player_open(Player *player, const char *path) {
|
bool player_open(Player *player, const char *path) {
|
||||||
if (!path || !*path) return false;
|
if (!path || !*path) return false;
|
||||||
return submit(player, [=] { return player->enqueue({Action::Open, path}); });
|
return submit(player, [=] { return player->enqueue({Action::Open, path}); });
|
||||||
}
|
}
|
||||||
bool player_open_network(Player *player, const PlayerNetworkSource *source) {
|
bool player_open_network(Player *player, const PlayerNetworkSource *source) {
|
||||||
if (!source || !std::isfinite(source->duration) || source->duration < 0 ||
|
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)) ||
|
||||||
!validNetworkURL(source->video_url) ||
|
!validNetworkURL(source->video_url) ||
|
||||||
(source->audio_url && *source->audio_url && !validNetworkURL(source->audio_url)) ||
|
(source->audio_url && *source->audio_url && !validNetworkURL(source->audio_url)) ||
|
||||||
!validHeaders(source->video_headers) || !validHeaders(source->audio_headers)) return false;
|
!validHeaders(source->video_headers) || !validHeaders(source->audio_headers)) return false;
|
||||||
@@ -1334,6 +1421,8 @@ bool player_open_network(Player *player, const PlayerNetworkSource *source) {
|
|||||||
command.sourceDuration = source->duration;
|
command.sourceDuration = source->duration;
|
||||||
command.network = true;
|
command.network = true;
|
||||||
command.live = source->is_live;
|
command.live = source->is_live;
|
||||||
|
command.mediaURL = source->media_url ? source->media_url : "";
|
||||||
|
command.initialPosition = source->initial_position;
|
||||||
return player->enqueue(std::move(command));
|
return player->enqueue(std::move(command));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,13 @@ typedef struct PlayerState {
|
|||||||
// Playback commands are asynchronous. False means invalid input or a full command queue.
|
// Playback commands are asynchronous. False means invalid input or a full command queue.
|
||||||
// Paths are copied before returning. All other calls require a live handle.
|
// Paths are copied before returning. All other calls require a live handle.
|
||||||
Player *player_create(void);
|
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);
|
||||||
|
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.
|
||||||
|
bool player_shutdown(Player *player, char *progress_error);
|
||||||
typedef struct PlayerNetworkSource {
|
typedef struct PlayerNetworkSource {
|
||||||
// Primary video or combined audio/video HTTP(S) URL; audio_url is optional.
|
// Primary video or combined audio/video HTTP(S) URL; audio_url is optional.
|
||||||
const char *video_url;
|
const char *video_url;
|
||||||
@@ -46,6 +53,12 @@ typedef struct PlayerNetworkSource {
|
|||||||
const char *audio_headers;
|
const char *audio_headers;
|
||||||
double duration;
|
double duration;
|
||||||
bool is_live;
|
bool is_live;
|
||||||
|
// Canonical YouTube webpage identity, never an extracted stream URL.
|
||||||
|
// NULL/empty means no reporting for this source.
|
||||||
|
const char *media_url;
|
||||||
|
// Initial restored position, applied before publishing ready state. Zero
|
||||||
|
// starts at the beginning; out-of-duration bookmarks also start at zero.
|
||||||
|
double initial_position;
|
||||||
} PlayerNetworkSource;
|
} PlayerNetworkSource;
|
||||||
bool player_open(Player *player, const char *path);
|
bool player_open(Player *player, const char *path);
|
||||||
// Streams directly; never downloads or muxes the entire media file before opening.
|
// Streams directly; never downloads or muxes the entire media file before opening.
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
#include "progress_reporter.hpp"
|
||||||
|
#include <cerrno>
|
||||||
|
#include <charconv>
|
||||||
|
#include <chrono>
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstring>
|
||||||
|
#include <fcntl.h>
|
||||||
|
#include <limits>
|
||||||
|
#include <stdexcept>
|
||||||
|
#include <sys/stat.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
|
namespace playback {
|
||||||
|
namespace {
|
||||||
|
double monotonicSeconds() {
|
||||||
|
return std::chrono::duration<double>(std::chrono::steady_clock::now().time_since_epoch()).count();
|
||||||
|
}
|
||||||
|
|
||||||
|
void appendNumber(std::string &record, double value) {
|
||||||
|
char buffer[64];
|
||||||
|
auto result = std::to_chars(buffer, buffer + sizeof(buffer), value,
|
||||||
|
std::chars_format::general, std::numeric_limits<double>::max_digits10);
|
||||||
|
if (result.ec != std::errc{}) throw std::runtime_error("Could not encode progress timestamp");
|
||||||
|
record.append(buffer, result.ptr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ProgressReporter::ProgressReporter(const std::string &path, std::function<double()> clock)
|
||||||
|
: 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);
|
||||||
|
if (descriptor < 0) {
|
||||||
|
const int code = errno;
|
||||||
|
throw std::runtime_error("Could not exclusively create progress file '" + path + "': " + std::strerror(code));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
struct stat info{};
|
||||||
|
if (::fstat(descriptor, &info) != 0)
|
||||||
|
throw std::runtime_error(std::string("Could not inspect progress file: ") + std::strerror(errno));
|
||||||
|
if (!S_ISREG(info.st_mode) || info.st_uid != ::geteuid() || info.st_nlink != 1)
|
||||||
|
throw std::runtime_error("The new progress file must be a regular file privately owned by this user");
|
||||||
|
// Creation can only remove owner permissions through umask; restore exactly 0600.
|
||||||
|
if (::fchmod(descriptor, 0600) != 0)
|
||||||
|
throw std::runtime_error(std::string("Could not make progress file private: ") + std::strerror(errno));
|
||||||
|
if (::fstat(descriptor, &info) != 0)
|
||||||
|
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)");
|
||||||
|
writer = std::thread([this] { writeRecords(); });
|
||||||
|
} catch (...) {
|
||||||
|
::close(descriptor);
|
||||||
|
descriptor = -1;
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
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");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (mediaURL != url) {
|
||||||
|
closeMedia();
|
||||||
|
mediaURL = url;
|
||||||
|
}
|
||||||
|
live = isLive;
|
||||||
|
awaitingReady = true;
|
||||||
|
seekPending = false;
|
||||||
|
} catch (const std::exception &exception) {
|
||||||
|
disable(exception.what());
|
||||||
|
} catch (...) {
|
||||||
|
disable("Could not begin playback progress reporting");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ProgressReporter::observe(const PlayerState &state) noexcept {
|
||||||
|
if (finished || mediaURL.empty() || disabled.load(std::memory_order_acquire)) return;
|
||||||
|
if (state.seek_pending) { seekPending = true; return; }
|
||||||
|
if (state.status != PLAYER_PAUSED && state.status != PLAYER_PLAYING && state.status != PLAYER_ENDED) return;
|
||||||
|
if (!std::isfinite(state.position) || state.position < 0) return;
|
||||||
|
// A later bookmark reset must not replace the native end-of-stream snapshot.
|
||||||
|
if (hasSnapshot && snapshot.status == PLAYER_ENDED && state.status == PLAYER_ENDED &&
|
||||||
|
!awaitingReady && !seekPending) return;
|
||||||
|
|
||||||
|
const bool paused = hasSnapshot && snapshot.status == PLAYER_PLAYING && state.status == PLAYER_PAUSED;
|
||||||
|
const double duration = std::isfinite(state.duration) && state.duration > 0 ? state.duration : 0;
|
||||||
|
const bool eligible = !live && state.has_video && state.can_seek && duration > 0;
|
||||||
|
const bool crossing = !thresholdReported && eligible && state.position / duration >= .95;
|
||||||
|
const bool immediate = awaitingReady || seekPending || paused || crossing || state.status == PLAYER_ENDED;
|
||||||
|
snapshot = {state.position == 0 ? 0 : state.position, duration, state.status, eligible};
|
||||||
|
hasSnapshot = true;
|
||||||
|
awaitingReady = false;
|
||||||
|
seekPending = false;
|
||||||
|
if (!immediate && state.status != PLAYER_PLAYING) return;
|
||||||
|
try {
|
||||||
|
const double now = clock();
|
||||||
|
if (!std::isfinite(now)) {
|
||||||
|
disable("The playback progress clock returned a non-finite value");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (immediate || now - lastRecordAt >= 5) {
|
||||||
|
if (emit(state.status == PLAYER_ENDED ? "ended" : "progress")) {
|
||||||
|
lastRecordAt = now;
|
||||||
|
if (crossing) thresholdReported = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (const std::exception &exception) {
|
||||||
|
disable(exception.what());
|
||||||
|
} catch (...) {
|
||||||
|
disable("Could not sample the playback progress clock");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ProgressReporter::closeMedia() noexcept {
|
||||||
|
if (mediaURL.empty()) return;
|
||||||
|
if (hasSnapshot && !disabled.load(std::memory_order_acquire)) emit("closed");
|
||||||
|
mediaURL.clear();
|
||||||
|
hasSnapshot = false;
|
||||||
|
awaitingReady = false;
|
||||||
|
seekPending = false;
|
||||||
|
thresholdReported = false;
|
||||||
|
lastRecordAt = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ProgressReporter::emit(const char *event) noexcept {
|
||||||
|
try {
|
||||||
|
std::string record;
|
||||||
|
record.reserve(256);
|
||||||
|
record += "{\"version\":1,\"event\":\"";
|
||||||
|
record += event;
|
||||||
|
record += "\",\"media_url\":\"";
|
||||||
|
record += mediaURL;
|
||||||
|
record += "\",\"position_seconds\":";
|
||||||
|
appendNumber(record, snapshot.position);
|
||||||
|
record += ",\"duration_seconds\":";
|
||||||
|
if (snapshot.duration > 0) appendNumber(record, snapshot.duration);
|
||||||
|
else record += "null";
|
||||||
|
record += ",\"completion_eligible\":";
|
||||||
|
record += snapshot.eligible ? "true}\n" : "false}\n";
|
||||||
|
{
|
||||||
|
std::lock_guard lock(mutex);
|
||||||
|
if (stopping || disabled.load(std::memory_order_relaxed)) return false;
|
||||||
|
records.push_back(std::move(record));
|
||||||
|
}
|
||||||
|
wake.notify_one();
|
||||||
|
return true;
|
||||||
|
} catch (const std::exception &exception) {
|
||||||
|
disable(exception.what());
|
||||||
|
} catch (...) {
|
||||||
|
disable("Could not encode or queue playback progress");
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ProgressReporter::disable(const char *message, int osError) noexcept {
|
||||||
|
std::lock_guard lock(mutex);
|
||||||
|
if (!failure[0]) {
|
||||||
|
if (osError) std::snprintf(failure.data(), failure.size(), "%s: %s", message, std::strerror(osError));
|
||||||
|
else std::snprintf(failure.data(), failure.size(), "%s", message);
|
||||||
|
if (!failure[0]) std::snprintf(failure.data(), failure.size(), "Playback progress reporting failed");
|
||||||
|
}
|
||||||
|
disabled.store(true, std::memory_order_release);
|
||||||
|
records.clear();
|
||||||
|
wake.notify_all();
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string ProgressReporter::error() const {
|
||||||
|
std::lock_guard lock(mutex);
|
||||||
|
return failure.data();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ProgressReporter::writeRecords() noexcept {
|
||||||
|
try {
|
||||||
|
for (;;) {
|
||||||
|
std::string record;
|
||||||
|
{
|
||||||
|
std::unique_lock lock(mutex);
|
||||||
|
wake.wait(lock, [this] { return stopping || disabled.load(std::memory_order_relaxed) || !records.empty(); });
|
||||||
|
if (disabled.load(std::memory_order_relaxed) || (stopping && records.empty())) break;
|
||||||
|
record = std::move(records.front());
|
||||||
|
records.pop_front();
|
||||||
|
}
|
||||||
|
std::size_t written = 0;
|
||||||
|
while (written < record.size() && !disabled.load(std::memory_order_acquire)) {
|
||||||
|
const auto count = ::write(descriptor, record.data() + written, record.size() - written);
|
||||||
|
if (count > 0) written += static_cast<std::size_t>(count);
|
||||||
|
else if (count < 0 && errno == EINTR) continue;
|
||||||
|
else {
|
||||||
|
const int code = count < 0 ? errno : EIO;
|
||||||
|
disable("Could not write playback progress file", code);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (disabled.load(std::memory_order_acquire)) break;
|
||||||
|
}
|
||||||
|
} catch (const std::exception &exception) {
|
||||||
|
disable(exception.what());
|
||||||
|
} catch (...) {
|
||||||
|
disable("The playback progress writer failed");
|
||||||
|
}
|
||||||
|
if (::close(descriptor) != 0) disable("Could not close playback progress file", errno);
|
||||||
|
descriptor = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ProgressReporter::finish() noexcept {
|
||||||
|
if (finished) return;
|
||||||
|
closeMedia();
|
||||||
|
finished = true;
|
||||||
|
{
|
||||||
|
std::lock_guard lock(mutex);
|
||||||
|
stopping = true;
|
||||||
|
}
|
||||||
|
wake.notify_all();
|
||||||
|
if (writer.joinable()) writer.join();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "player.h"
|
||||||
|
#include <array>
|
||||||
|
#include <atomic>
|
||||||
|
#include <condition_variable>
|
||||||
|
#include <deque>
|
||||||
|
#include <functional>
|
||||||
|
#include <mutex>
|
||||||
|
#include <string>
|
||||||
|
#include <string_view>
|
||||||
|
#include <thread>
|
||||||
|
|
||||||
|
namespace playback {
|
||||||
|
// The decoder owns media/snapshot methods; the writer owns the file after construction.
|
||||||
|
class ProgressReporter {
|
||||||
|
public:
|
||||||
|
explicit ProgressReporter(const std::string &path, std::function<double()> clock = {});
|
||||||
|
~ProgressReporter();
|
||||||
|
ProgressReporter(const ProgressReporter &) = delete;
|
||||||
|
ProgressReporter &operator=(const ProgressReporter &) = delete;
|
||||||
|
|
||||||
|
static bool validMediaURL(std::string_view url) 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;
|
||||||
|
std::string error() const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
struct Snapshot {
|
||||||
|
double position = 0;
|
||||||
|
double duration = 0;
|
||||||
|
PlayerStatus status = PLAYER_EMPTY;
|
||||||
|
bool eligible = false;
|
||||||
|
} snapshot;
|
||||||
|
std::string mediaURL;
|
||||||
|
std::function<double()> clock;
|
||||||
|
bool live = false;
|
||||||
|
bool hasSnapshot = false;
|
||||||
|
bool awaitingReady = false;
|
||||||
|
bool seekPending = false;
|
||||||
|
bool thresholdReported = false;
|
||||||
|
bool finished = false;
|
||||||
|
double lastRecordAt = 0;
|
||||||
|
|
||||||
|
int descriptor = -1;
|
||||||
|
std::thread writer;
|
||||||
|
mutable std::mutex mutex;
|
||||||
|
std::condition_variable wake;
|
||||||
|
std::deque<std::string> records;
|
||||||
|
std::array<char, 512> failure{};
|
||||||
|
std::atomic<bool> disabled{false};
|
||||||
|
bool stopping = false;
|
||||||
|
|
||||||
|
bool emit(const char *event) noexcept;
|
||||||
|
void disable(const char *message, int osError = 0) noexcept;
|
||||||
|
void writeRecords() noexcept;
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user