feat: report authoritative playback progress from shared backend
This commit is contained in:
+271
-18
@@ -1,5 +1,6 @@
|
||||
import json
|
||||
import os
|
||||
import math
|
||||
from pathlib import Path
|
||||
import plistlib
|
||||
import selectors
|
||||
@@ -20,6 +21,7 @@ def check_cli_instances(root, app, fixtures):
|
||||
extractor = shutil.which("yt-dlp")
|
||||
runtime = shutil.which("deno") or shutil.which("node")
|
||||
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"
|
||||
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}")
|
||||
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
|
||||
states = {}
|
||||
while time.monotonic() < deadline:
|
||||
states = {int(path.stem.split("-")[1]): json.loads(path.read_text())
|
||||
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(
|
||||
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}")
|
||||
if complete and ready(states):
|
||||
return states
|
||||
time.sleep(0.02) # Infrastructure deadline; assertions use native events/PTS, not wall dates.
|
||||
raise RuntimeError(f"{message}: native state deadline: {states}")
|
||||
raise RuntimeError(f"{message}: native state deadline: {states}; "
|
||||
f"process diagnostics: { {pid: process_diagnostics(pid) for pid in states} }")
|
||||
def settings(state):
|
||||
return (state["showBufferInfo"], state["automaticQuality"], state["snapToEdges"])
|
||||
|
||||
def input_request(pid, count, ready, message, events=None, media=None):
|
||||
request = {"id": uuid.uuid4().hex, "events": events or []}
|
||||
def write_request(pid, events=None, media=None, action=None, **arguments):
|
||||
request = {"id": uuid.uuid4().hex, "events": events or [], **arguments}
|
||||
if media is not None:
|
||||
request["media"] = str(media)
|
||||
if action is not None:
|
||||
request["action"] = action
|
||||
path = root / f"keys-{pid}.json"
|
||||
pending = path.with_suffix(".tmp")
|
||||
pending.write_text(json.dumps(request))
|
||||
pending.replace(path)
|
||||
return wait_states(count, lambda states: states[pid]["requestID"] == request["id"] and ready(states[pid]),
|
||||
message)
|
||||
return request["id"]
|
||||
|
||||
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:
|
||||
if extractor and runtime:
|
||||
metadata = root / "youtube.json"
|
||||
metadata.write_text(json.dumps({
|
||||
"id": "aqz-KE-bpKQ", "title": "Independent YouTube fixture", "duration": 120,
|
||||
"is_live": False, "webpage_url": canonical, "extractor": "youtube",
|
||||
"extractor_key": "Youtube", "formats": [{
|
||||
"format_id": "180", "height": 180, "width": 320,
|
||||
"url": server.base_url + "/media/cache.mp4", "protocol": "http", "ext": "mp4",
|
||||
"vcodec": "mpeg4", "acodec": "aac", "dynamic_range": "SDR",
|
||||
"http_headers": {"X-Terebi-Fixture": "combined"}}]}))
|
||||
choices = []
|
||||
for index, url in enumerate([canonical, *reported_urls[:3]]):
|
||||
metadata = root / f"youtube-{index}.json"
|
||||
metadata.write_text(json.dumps({
|
||||
"id": url.split("v=")[1],
|
||||
"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": [{
|
||||
"format_id": str(height), "height": height, "width": height * 16 // 9,
|
||||
"url": server.base_url + "/media/cache.mp4", "protocol": "http", "ext": "mp4",
|
||||
"vcodec": "mpeg4", "acodec": "aac", "dynamic_range": "SDR",
|
||||
"http_headers": {"X-Terebi-Fixture": "combined"}} for height in (180, 360)]}))
|
||||
choices.append(f" {shlex.quote(url)}) info={shlex.quote(str(metadata))} ;;")
|
||||
wrapper = root / "yt-dlp"
|
||||
wrapper.write_text("#!/bin/bash\nset -eu\nargs=(\"$@\")\nexec " + shlex.quote(extractor)
|
||||
+ " --load-info-json " + shlex.quote(str(metadata))
|
||||
wrapper.write_text("#!/bin/bash\nset -eu\nargs=(\"$@\")\n"
|
||||
'case "${args[${#args[@]}-1]}" in\n' + "\n".join(choices)
|
||||
+ '\n *) echo "Fixture extraction deliberately failed" >&2; exit 1 ;;\nesac\nexec '
|
||||
+ shlex.quote(extractor) + ' --load-info-json "$info"'
|
||||
+ ' "${args[@]:0:${#args[@]}-2}"\n')
|
||||
wrapper.chmod(0o755)
|
||||
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:
|
||||
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")
|
||||
invoke(["--new-instance", "--help"])
|
||||
wait_states(count, lambda _: True, "help never creates an extra application")
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import AppKit
|
||||
import Darwin
|
||||
import MediaPlayer
|
||||
|
||||
@_cdecl("terebi_app_main")
|
||||
@@ -92,6 +93,11 @@ private enum CLIInstanceCheck {
|
||||
static func run(_ configuration: URL) {
|
||||
let settings = try! JSONSerialization.jsonObject(with: Data(contentsOf: configuration)) as! [String: String]
|
||||
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)
|
||||
_ = NSApplication.shared
|
||||
_ = 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 }
|
||||
for window in windows { window.alphaValue = 0 }
|
||||
let request = directory.appendingPathComponent("keys-\(pid).json")
|
||||
var pendingRequestID = ""
|
||||
if let data = try? Data(contentsOf: request),
|
||||
let command = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let id = command["id"] as? String, id != requestID, let window = windows.first {
|
||||
pendingRequestID = id
|
||||
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.makeMain()
|
||||
return
|
||||
}
|
||||
} else {
|
||||
requestID = id
|
||||
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]] {
|
||||
window.makeKey()
|
||||
for input in events {
|
||||
@@ -159,6 +192,7 @@ private enum CLIInstanceCheck {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let applications = NSWorkspace.shared.runningApplications.filter {
|
||||
!$0.isTerminated && $0.bundleURL == Bundle.main.bundleURL
|
||||
@@ -169,6 +203,13 @@ private enum CLIInstanceCheck {
|
||||
"media": model.fileName ?? "",
|
||||
"status": model.state.status.rawValue,
|
||||
"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,
|
||||
"hasAudio": model.state.has_audio,
|
||||
"hasVideo": model.state.has_video,
|
||||
@@ -177,6 +218,10 @@ private enum CLIInstanceCheck {
|
||||
"automaticQuality": model.automaticYouTubeQuality,
|
||||
"snapToEdges": model.compactWindow.snappingEnabled,
|
||||
"requestID": requestID,
|
||||
"pendingRequestID": pendingRequestID,
|
||||
"active": NSApp.isActive,
|
||||
"keyWindow": windows.first?.isKeyWindow ?? false,
|
||||
"mainWindow": windows.first?.isMainWindow ?? false,
|
||||
"editorText": editorText,
|
||||
"compact": model.isCompact,
|
||||
"onTop": model.isOnTop,
|
||||
|
||||
+125
-2
@@ -9,6 +9,9 @@
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <signal.h>
|
||||
#include <sys/resource.h>
|
||||
#include <unistd.h>
|
||||
|
||||
static void require(bool condition, const char *message) {
|
||||
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,
|
||||
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
|
||||
// must own both selected URLs and both selected HTTP-header blocks.
|
||||
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 audioHeaders = includeHeaders ? headersFor(audioPath) : std::string{};
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
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) {
|
||||
@autoreleasepool {
|
||||
require(argc == 3, "loopback base URL and local fixture directory required");
|
||||
std::string base = argv[1], fixtures = argv[2];
|
||||
require(base.rfind("http://127.0.0.1:", 0) == 0, "network fixture server is loopback-only");
|
||||
[NSApplication sharedApplication];
|
||||
reporting(base, fixtures);
|
||||
std::unique_ptr<Player, decltype(&player_destroy)> player(player_create(), player_destroy);
|
||||
require(bool(player), "player creation");
|
||||
require(player_set_volume(player.get(), .37f), "set persistent network playback gain");
|
||||
|
||||
@@ -16,12 +16,14 @@ struct PlayerCommandChecks {
|
||||
let instanceFile = directory.appendingPathComponent("--new-instance")
|
||||
for file in [movie, optionFile, colonFile, instanceFile] { try Data().write(to: file) }
|
||||
|
||||
func opened(_ arguments: [String], newInstance: Bool = false, noConfig: Bool = false) throws -> URL? {
|
||||
guard case .launch(let url, let independent, let bypass) = try PlayerCommand.parse(arguments, directory: directory) else {
|
||||
func opened(_ arguments: [String], newInstance: Bool = false, noConfig: Bool = false,
|
||||
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")
|
||||
}
|
||||
require(independent == newInstance, "launch mode preserves default reuse or explicit independence")
|
||||
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
|
||||
}
|
||||
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 {
|
||||
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], ["--"],
|
||||
[movie.path, movie.path], ["--unknown"], ["missing.mp4"], [directory.path],
|
||||
@@ -67,6 +80,12 @@ struct PlayerCommandChecks {
|
||||
["--new-instance", "--unknown"], ["--no-config", "--no-config"],
|
||||
["--default-config", movie.path], ["--default-config", "--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"]] {
|
||||
do {
|
||||
_ = 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;
|
||||
}
|
||||
Reference in New Issue
Block a user