feat: report authoritative playback progress from shared backend

This commit is contained in:
2026-10-11 19:29:35 +02:00
parent a4c38bfa51
commit cc99a2a4f8
14 changed files with 1369 additions and 62 deletions
+271 -18
View File
@@ -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")
+48 -3
View File
@@ -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
View File
@@ -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");
+21 -2
View File
@@ -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)
+304
View File
@@ -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;
}