feat: add independent CLI launches and preserve shared video bookmarks
This commit is contained in:
@@ -4,11 +4,116 @@ from pathlib import Path
|
||||
import plistlib
|
||||
import selectors
|
||||
import shutil
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from streaming import StreamingServer
|
||||
|
||||
|
||||
def check_cli_instances(root, app, fixtures):
|
||||
cli = root / "terebi"
|
||||
shutil.copy2(app_source.parent / "terebi", cli)
|
||||
extractor = shutil.which("yt-dlp")
|
||||
runtime = shutil.which("deno") or shutil.which("node")
|
||||
canonical = "https://www.youtube.com/watch?v=aqz-KE-bpKQ"
|
||||
|
||||
def invoke(arguments):
|
||||
result = subprocess.run([str(cli), *arguments], capture_output=True, text=True, timeout=20)
|
||||
if result.returncode:
|
||||
raise RuntimeError(f"CLI {arguments} failed: {result.returncode}; {result.stdout}{result.stderr}")
|
||||
return result
|
||||
|
||||
def wait_states(count, ready, message):
|
||||
deadline = time.monotonic() + 30
|
||||
states = {}
|
||||
while time.monotonic() < deadline:
|
||||
states = {int(path.stem.split("-")[1]): json.loads(path.read_text())
|
||||
for path in root.glob("state-*.json")}
|
||||
complete = len(states) == count and all(
|
||||
set(state["pids"]) == set(states) and state["windows"] == 1 for state in states.values())
|
||||
if any(state["error"] for state in states.values()):
|
||||
raise RuntimeError(f"{message}: native playback error: {states}")
|
||||
if complete and ready(states):
|
||||
return states
|
||||
time.sleep(0.02) # Infrastructure deadline; assertions use native events/PTS, not wall dates.
|
||||
raise RuntimeError(f"{message}: native state deadline: {states}")
|
||||
|
||||
with StreamingServer(fixtures) as server:
|
||||
if extractor and runtime:
|
||||
metadata = root / "youtube.json"
|
||||
metadata.write_text(json.dumps({
|
||||
"id": "aqz-KE-bpKQ", "title": "Independent YouTube fixture", "duration": 120,
|
||||
"is_live": False, "webpage_url": canonical, "extractor": "youtube",
|
||||
"extractor_key": "Youtube", "formats": [{
|
||||
"format_id": "180", "height": 180, "width": 320,
|
||||
"url": server.base_url + "/media/cache.mp4", "protocol": "http", "ext": "mp4",
|
||||
"vcodec": "mpeg4", "acodec": "aac", "dynamic_range": "SDR",
|
||||
"http_headers": {"X-Terebi-Fixture": "combined"}}]}))
|
||||
wrapper = root / "yt-dlp"
|
||||
wrapper.write_text("#!/bin/bash\nset -eu\nargs=(\"$@\")\nexec " + shlex.quote(extractor)
|
||||
+ " --load-info-json " + shlex.quote(str(metadata))
|
||||
+ ' "${args[@]:0:${#args[@]}-2}"\n')
|
||||
wrapper.chmod(0o755)
|
||||
settings = {"directory": str(root), "path": str(root) + os.pathsep + os.environ.get("PATH", "")}
|
||||
(app / "Contents/Resources/cli-check.json").write_text(json.dumps(settings))
|
||||
movie = fixtures / "cache.mp4"
|
||||
audio = root / "audio with spaces.wav"
|
||||
shutil.copy2(fixtures / "audio.wav", audio)
|
||||
|
||||
invoke([str(movie)])
|
||||
first = wait_states(1, lambda states: all(state["media"] == movie.name and state["status"] == 3
|
||||
and state["position"] > 0 and state["hasVideo"] for state in states.values()),
|
||||
"cold default CLI opens one actual player window")
|
||||
first_pid = next(iter(first))
|
||||
invoke([])
|
||||
wait_states(1, lambda states: states[first_pid]["media"] == movie.name,
|
||||
"warm default CLI reuses the existing process without replacing media")
|
||||
|
||||
invoke(["--new-instance"])
|
||||
second = wait_states(2, lambda states: any(state["media"] == "" and state["status"] == 0
|
||||
for pid, state in states.items() if pid != first_pid),
|
||||
"empty independent CLI creates exactly one extra player")
|
||||
empty_pid = next(pid for pid in second if pid != first_pid)
|
||||
invoke(["--new-instance", str(audio)])
|
||||
third = wait_states(3, lambda states: any(state["media"] == audio.name and state["status"] == 3
|
||||
and state["hasAudio"] and not state["hasVideo"]
|
||||
for pid, state in states.items() if pid not in second),
|
||||
"independent local media reaches only the freshly launched process")
|
||||
if third[first_pid]["media"] != movie.name or third[first_pid]["status"] != 3 or third[empty_pid]["media"]:
|
||||
raise RuntimeError(f"independent launch disturbed an existing player: {third}")
|
||||
count = 3
|
||||
if extractor and runtime:
|
||||
invoke(["--new-instance", "--url", canonical])
|
||||
fourth = wait_states(4, lambda states: any(
|
||||
state["media"] == "Independent YouTube fixture" and state["status"] == 3
|
||||
and state["position"] > 0 and state["hasAudio"] and state["hasVideo"]
|
||||
for pid, state in states.items() if pid not in third),
|
||||
"independent YouTube playback reaches only one new registered process")
|
||||
if fourth[first_pid]["media"] != movie.name or fourth[first_pid]["status"] != 3 or fourth[empty_pid]["media"]:
|
||||
raise RuntimeError(f"independent YouTube launch disturbed existing playback: {fourth}")
|
||||
count = 4
|
||||
else:
|
||||
print("SKIP independent YouTube playback: yt-dlp and a supported JS runtime are required")
|
||||
|
||||
before = wait_states(count, lambda _: True, "all independent processes remain registered")
|
||||
invoke(["--new-instance", "--help"])
|
||||
wait_states(count, lambda _: True, "help never creates an extra application")
|
||||
replacement = root / "replacement.mp4"
|
||||
shutil.copy2(fixtures / "av.mp4", replacement)
|
||||
invoke([str(replacement)])
|
||||
after = wait_states(count, lambda states: sum(state["media"] == replacement.name
|
||||
for state in states.values()) == 1,
|
||||
"default media delivery still reuses exactly one existing process")
|
||||
changed = [pid for pid in before if before[pid]["media"] != after[pid]["media"]]
|
||||
if len(changed) != 1:
|
||||
raise RuntimeError(f"default media open changed multiple players: {before}; {after}")
|
||||
print("PASS actual CLI instances: cold/default reuse, independent empty/local/YouTube, one window per PID, targeted media, original playback retained, help, warm replacement")
|
||||
|
||||
|
||||
|
||||
app_source, library, fixtures = map(lambda path: Path(path).resolve(), sys.argv[1:])
|
||||
with tempfile.TemporaryDirectory(prefix="terebi-startup-") as directory:
|
||||
@@ -39,6 +144,7 @@ with tempfile.TemporaryDirectory(prefix="terebi-startup-") as directory:
|
||||
if launch.returncode != 0 or not result["passed"] or result["bundleIdentifier"] != identifier:
|
||||
raise RuntimeError(f"packaged startup failed: {result}; {stdout}{stderr}")
|
||||
print("PASS packaged bootstrap: registered app PID/identity, in-process player load, actual playback and Now Playing")
|
||||
check_cli_instances(root, app, fixtures)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
subprocess.run(
|
||||
|
||||
@@ -4,6 +4,10 @@ import MediaPlayer
|
||||
@_cdecl("terebi_app_main")
|
||||
@MainActor
|
||||
public func checkAppStartup() {
|
||||
if let configuration = Bundle.main.url(forResource: "cli-check", withExtension: "json") {
|
||||
CLIInstanceCheck.run(configuration)
|
||||
return
|
||||
}
|
||||
AppStartupCheck.run()
|
||||
}
|
||||
|
||||
@@ -57,3 +61,47 @@ private enum AppStartupCheck {
|
||||
NSApp.terminate(nil)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private enum CLIInstanceCheck {
|
||||
static var timer: Timer?
|
||||
|
||||
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)
|
||||
setenv("PATH", settings["path"]!, 1)
|
||||
_ = NSApplication.shared
|
||||
PlayerModel.shared.setVolume(0)
|
||||
// Exercise the real SwiftUI scene and delegate without displaying test windows.
|
||||
_ = NotificationCenter.default.addObserver(forName: NSWindow.didUpdateNotification, object: nil, queue: .main) { _ in
|
||||
MainActor.assumeIsolated { for window in NSApp.windows { window.alphaValue = 0 } }
|
||||
}
|
||||
timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { _ in
|
||||
MainActor.assumeIsolated {
|
||||
let model = PlayerModel.shared
|
||||
let pid = ProcessInfo.processInfo.processIdentifier
|
||||
let windows = NSApp.windows.filter { !($0 is NSPanel) && $0.contentView != nil }
|
||||
for window in windows { window.alphaValue = 0 }
|
||||
let applications = NSWorkspace.shared.runningApplications.filter {
|
||||
!$0.isTerminated && $0.bundleURL == Bundle.main.bundleURL
|
||||
}
|
||||
let snapshot: [String: Any] = [
|
||||
"pid": pid,
|
||||
"pids": applications.map(\.processIdentifier).sorted(),
|
||||
"media": model.fileName ?? "",
|
||||
"status": model.state.status.rawValue,
|
||||
"position": model.state.position,
|
||||
"windows": windows.count,
|
||||
"hasAudio": model.state.has_audio,
|
||||
"hasVideo": model.state.has_video,
|
||||
"error": model.errorText ?? "",
|
||||
]
|
||||
do {
|
||||
let data = try JSONSerialization.data(withJSONObject: snapshot)
|
||||
try data.write(to: directory.appendingPathComponent("state-\(pid).json"), options: .atomic)
|
||||
} catch { fatalError("Could not publish CLI instance check: \(error)") }
|
||||
}
|
||||
}
|
||||
TerebiApp.main()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,9 @@ struct PlaybackProgressChecks {
|
||||
try FileManager.default.createSymbolicLink(at: alias, withDestinationURL: file)
|
||||
let youtube = URL(string: "https://www.youtube.com/watch?v=jNQXAC9IVRw")!
|
||||
|
||||
var progress = PlaybackProgress(defaults: defaults)
|
||||
let progress = PlaybackProgress(defaults: defaults)
|
||||
let independent = PlaybackProgress(defaults: UserDefaults(suiteName: suite)!)
|
||||
let independentFile = directory.appendingPathComponent("independent.mp4")
|
||||
progress.save(123.25, for: file)
|
||||
progress.save(18, for: otherFile)
|
||||
progress.save(9.5, for: youtube)
|
||||
@@ -29,12 +31,20 @@ struct PlaybackProgressChecks {
|
||||
require(reopened.position(for: alias) == 123.25, "symlink and original file share progress")
|
||||
require(reopened.position(for: otherFile) == 18, "local files keep separate positions")
|
||||
require(reopened.position(for: youtube) == 9.5, "canonical YouTube URL persists independently of local files")
|
||||
independent.save(27, for: independentFile)
|
||||
let shared = PlaybackProgress(defaults: defaults)
|
||||
require(shared.position(for: file) == 123.25 && shared.position(for: youtube) == 9.5,
|
||||
"an independently initialized player cannot overwrite another video's bookmarks")
|
||||
require(shared.position(for: independentFile) == 27,
|
||||
"independent player progress is also retained")
|
||||
|
||||
progress.save(nil, for: alias)
|
||||
let completed = PlaybackProgress(defaults: defaults)
|
||||
require(completed.position(for: file) == nil, "completion clears progress through either file identity")
|
||||
require(completed.position(for: otherFile) == 18 && completed.position(for: youtube) == 9.5,
|
||||
"clearing one video preserves other videos")
|
||||
require(completed.position(for: independentFile) == 27,
|
||||
"clearing progress in one player preserves a different player's bookmark")
|
||||
for position in [Double.nan, Double.infinity, -Double.infinity, -1, 0] {
|
||||
progress.save(position, for: file)
|
||||
require(PlaybackProgress(defaults: defaults).position(for: file) == nil,
|
||||
@@ -44,6 +54,15 @@ struct PlaybackProgressChecks {
|
||||
defer { isolated.removePersistentDomain(forName: suite + ".isolated") }
|
||||
require(PlaybackProgress(defaults: isolated).position(for: youtube) == nil,
|
||||
"injected preference domains remain isolated")
|
||||
isolated.set([file.standardizedFileURL.absoluteString: 42.5, youtube.absoluteString: 15],
|
||||
forKey: "playbackPositions")
|
||||
let migrated = PlaybackProgress(defaults: isolated)
|
||||
require(migrated.position(for: alias) == 42.5 && migrated.position(for: youtube) == 15,
|
||||
"existing local and YouTube bookmarks survive the shared-history migration")
|
||||
migrated.save(43, for: file)
|
||||
let migratedAgain = PlaybackProgress(defaults: isolated)
|
||||
require(migratedAgain.position(for: file) == 43 && migratedAgain.position(for: youtube) == 15,
|
||||
"opening another player preserves both migrated bookmarks and newer progress")
|
||||
print("Playback progress checks passed")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,12 +13,14 @@ struct PlayerCommandChecks {
|
||||
let movie = directory.appendingPathComponent("video with spaces.mp4")
|
||||
let optionFile = directory.appendingPathComponent("--help")
|
||||
let colonFile = directory.appendingPathComponent("part:1.mp4")
|
||||
for file in [movie, optionFile, colonFile] { try Data().write(to: file) }
|
||||
let instanceFile = directory.appendingPathComponent("--new-instance")
|
||||
for file in [movie, optionFile, colonFile, instanceFile] { try Data().write(to: file) }
|
||||
|
||||
func opened(_ arguments: [String]) throws -> URL? {
|
||||
guard case .launch(let url) = try PlayerCommand.parse(arguments, directory: directory) else {
|
||||
func opened(_ arguments: [String], newInstance: Bool = false) throws -> URL? {
|
||||
guard case .launch(let url, let independent) = try PlayerCommand.parse(arguments, directory: directory) else {
|
||||
fatalError("FAIL: expected a launch command")
|
||||
}
|
||||
require(independent == newInstance, "launch mode preserves default reuse or explicit independence")
|
||||
return url
|
||||
}
|
||||
require(try opened([movie.lastPathComponent]) == movie, "relative paths resolve against invocation directory, preserving spaces")
|
||||
@@ -27,10 +29,27 @@ struct PlayerCommandChecks {
|
||||
require(try opened([colonFile.path]) == colonFile, "colons in filesystem paths are not URL schemes")
|
||||
let canonical = URL(string: "https://www.youtube.com/watch?v=jNQXAC9IVRw")!
|
||||
require(try opened(["https://youtu.be/jNQXAC9IVRw"]) == canonical, "YouTube positional URLs canonicalize for resume identity")
|
||||
require(try opened(["--new-instance"], newInstance: true) == nil, "an independent launch can omit media")
|
||||
require(try opened(["--new-instance", movie.lastPathComponent], newInstance: true) == movie,
|
||||
"independent local launches preserve relative paths and spaces")
|
||||
require(try opened(["--new-instance", "--url", canonical.absoluteString], newInstance: true) == canonical,
|
||||
"independent explicit YouTube launches retain canonical resume identity")
|
||||
require(try opened(["--new-instance", "https://youtu.be/jNQXAC9IVRw"], newInstance: true) == canonical,
|
||||
"independent positional YouTube launches retain canonical resume identity")
|
||||
require(try opened(["--new-instance", "--", "--help"], newInstance: true) == optionFile,
|
||||
"independent launch preserves the filename terminator")
|
||||
require(try opened(["--", "--new-instance"]) == instanceFile,
|
||||
"an escaped instance flag is a filename, not a launch option")
|
||||
guard case .help = try PlayerCommand.parse(["--new-instance", "--help"]) else {
|
||||
fatalError("FAIL: help must not launch an independent player")
|
||||
}
|
||||
|
||||
for arguments in [["--url"], ["--url", canonical.absoluteString, movie.path], ["--"],
|
||||
[movie.path, movie.path], ["--unknown"], ["missing.mp4"], [directory.path],
|
||||
["file://remote-host/video.mp4"], ["https://example.com/video.mp4"]] {
|
||||
["file://remote-host/video.mp4"], ["https://example.com/video.mp4"],
|
||||
["--new-instance", "--new-instance"], ["--new-instance", "--url"],
|
||||
["--new-instance", "--"], ["--new-instance", movie.path, movie.path],
|
||||
["--new-instance", "--unknown"]] {
|
||||
do {
|
||||
_ = try PlayerCommand.parse(arguments, directory: directory)
|
||||
fatalError("FAIL: accepted an invalid command")
|
||||
|
||||
Reference in New Issue
Block a user