#!/usr/bin/env python3 """Remote MVLog worker. Scans the web machine's in-dir, renders changed jobs locally with movmaker, and publishes generated MP4 files back to the web machine's out-dir. """ from __future__ import annotations import argparse import logging import hashlib import json import os import shutil import subprocess import sys import tempfile from datetime import datetime, timezone from pathlib import Path from shlex import quote ROOT = Path(__file__).resolve().parent MOVMAKER = ROOT / "movmaker.py" INTRO_LOGO = ROOT / "img" / "moto_travel.png" PUSH_SENDER = ROOT / "send_push.js" PUSH_CONFIG = Path.home() / ".config" / "mvlog" / "push.json" STATE_NAME = ".movmaker-state.json" LOCK_NAME = ".movmaker-lock" ENABLED_NAME = ".movmaker-enabled" PREVIEW_NAME = ".movmaker-preview" HIDDEN_NAME = ".mvlog-hidden" NOTIFIED_CACHE = "cache/push_notifications.json" INTRO_LOGO_ALPHA = "0.5" def now() -> str: return datetime.now(timezone.utc).isoformat() def run(cmd: list[str], *, input_text: str | None = None, check: bool = True) -> subprocess.CompletedProcess[str]: return subprocess.run(cmd, input=input_text, text=True, capture_output=True, check=check) def ssh(host: str, command: str, *, check: bool = True, input_text: str | None = None) -> subprocess.CompletedProcess[str]: return run(["ssh", host, command], input_text=input_text, check=check) def remote_q(path: str) -> str: return quote(path) def list_jobs(host: str, remote_root: str) -> list[str]: cmd = f"cd {remote_q(remote_root)} && find in-dir -mindepth 1 -maxdepth 1 -type d -printf '%f\\n' | sort" out = ssh(host, cmd).stdout return [line.strip() for line in out.splitlines() if line.strip()] def remote_file_exists(host: str, path: str) -> bool: return ssh(host, f"test -f {remote_q(path)}", check=False).returncode == 0 def acquire_lock(host: str, job_dir: str) -> bool: lock = f"{job_dir}/{LOCK_NAME}" cmd = f"mkdir {remote_q(lock)} 2>/dev/null && date -Iseconds > {remote_q(lock + '/started_at')}" return ssh(host, cmd, check=False).returncode == 0 def release_lock(host: str, job_dir: str) -> None: ssh(host, f"rm -rf {remote_q(job_dir + '/' + LOCK_NAME)}", check=False) def remote_fingerprint(host: str, job_dir: str) -> str: script = r''' import hashlib, json, os, sys root = sys.argv[1] entries = [] for dirpath, dirnames, filenames in os.walk(root): dirnames[:] = [d for d in dirnames if d != '.movmaker-lock'] for name in filenames: if name in {'.movmaker-state.json', '.movmaker-enabled', '.movmaker-preview', '.mvlog-hidden'}: continue path = os.path.join(dirpath, name) rel = os.path.relpath(path, root) st = os.stat(path) entries.append([rel, st.st_size, st.st_mtime_ns]) entries.sort() print(hashlib.sha256(json.dumps(entries, ensure_ascii=False, separators=(',', ':')).encode('utf-8')).hexdigest()) ''' cmd = f"python3 -c {quote(script)} {remote_q(job_dir)}" return ssh(host, cmd).stdout.strip() def read_remote_json(host: str, path: str) -> dict: res = ssh(host, f"cat {remote_q(path)} 2>/dev/null", check=False) if res.returncode != 0 or not res.stdout.strip(): return {} try: data = json.loads(res.stdout) return data if isinstance(data, dict) else {} except json.JSONDecodeError: return {} def write_remote_json(host: str, path: str, data: dict) -> None: tmp_path = f"{path}.tmp.$$" payload = json.dumps(data, ensure_ascii=False, indent=2) + "\n" cmd = ( f"mkdir -p {remote_q(str(Path(path).parent))} && " f"cat > {remote_q(tmp_path)} && " f"chmod 664 {remote_q(tmp_path)} && " f"mv {remote_q(tmp_path)} {remote_q(path)}" ) ssh(host, cmd, input_text=payload) def read_state(host: str, job_dir: str) -> dict: return read_remote_json(host, f"{job_dir}/{STATE_NAME}") def write_state(host: str, job_dir: str, state: dict) -> None: path = f"{job_dir}/{STATE_NAME}" tmp_path = f"{path}.tmp.$$" data = json.dumps(state, ensure_ascii=False, indent=2) + "\n" cmd = ( f"cat > {remote_q(tmp_path)} && " f"chmod 664 {remote_q(tmp_path)} && " f"mv {remote_q(tmp_path)} {remote_q(path)}" ) ssh(host, cmd, input_text=data) def copy_job_from_remote(host: str, job_dir: str, local_input: Path) -> None: local_input.mkdir(parents=True, exist_ok=True) remote_cmd = ( f"tar --exclude={quote(LOCK_NAME)} --exclude={quote(STATE_NAME)} " f"-C {remote_q(job_dir)} -cf - ." ) p1 = subprocess.Popen(["ssh", host, remote_cmd], stdout=subprocess.PIPE) try: p2 = subprocess.run(["tar", "-xf", "-", "-C", str(local_input)], stdin=p1.stdout, check=True) assert p1.stdout is not None p1.stdout.close() rc = p1.wait() if rc != 0: raise subprocess.CalledProcessError(rc, ["ssh", host, remote_cmd]) except Exception: p1.kill() raise def send_push_notification(host: str, remote_root: str, output: str) -> bool: if not PUSH_SENDER.exists() or not PUSH_CONFIG.exists(): return False res = ssh(host, f"cat {remote_q(remote_root + '/cache/push_subscriptions.json')} 2>/dev/null", check=False) if res.returncode != 0 or not res.stdout.strip(): return False title = output.rsplit('.', 1)[0].replace('_', ' ').title() payload = json.dumps({ "title": "New MVLog video", "body": title, "url": "https://bubulescu.org/mvlog/", "tag": f"mvlog-{output}", }, ensure_ascii=False) proc = subprocess.run(["node", str(PUSH_SENDER), str(PUSH_CONFIG), payload], input=res.stdout, text=True, capture_output=True, check=False) if proc.stdout.strip(): print(proc.stdout.strip()) if proc.stderr.strip(): print(proc.stderr.strip(), file=sys.stderr) return proc.returncode == 0 def maybe_send_first_show_notification(host: str, remote_root: str, job_dir: str, job: str, state: dict | None = None) -> None: if remote_file_exists(host, f"{job_dir}/{HIDDEN_NAME}"): return state = state if state is not None else read_state(host, job_dir) output = state.get("output") if isinstance(state.get("output"), str) else "" if not output or not remote_file_exists(host, f"{remote_root}/out-dir/{output}"): return cache_path = f"{remote_root}/{NOTIFIED_CACHE}" cache = read_remote_json(host, cache_path) sent = cache.get("sent") if isinstance(cache.get("sent"), dict) else {} if output in sent: return if send_push_notification(host, remote_root, output): sent[output] = {"job": job, "sent_at": now()} cache["sent"] = sent write_remote_json(host, cache_path, cache) print(f"notification sent: {job} -> {output}") def publish_output(host: str, remote_root: str, local_mp4: Path, old_output: str | None) -> str: out_name = local_mp4.name remote_tmp = f"{remote_root}/out-dir/.{out_name}.tmp" remote_final = f"{remote_root}/out-dir/{out_name}" subprocess.run(["scp", str(local_mp4), f"{host}:{remote_tmp}"], check=True) cmds = [ f"mv {remote_q(remote_tmp)} {remote_q(remote_final)}", f"rm -f {remote_q(remote_root + '/cache/videos.json')}", ] if old_output and old_output != out_name: cmds.append(f"rm -f {remote_q(remote_root + '/out-dir/' + old_output)}") ssh(host, " && ".join(cmds)) return out_name def render_job(local_input: Path, local_out: Path, extra_args: list[str]) -> Path: local_out.mkdir(parents=True, exist_ok=True) intro_logo = INTRO_LOGO if INTRO_LOGO.exists() else None intro_args = [] if intro_logo is not None: intro_args = [ "--intro-logo", str(intro_logo), "--intro-logo-alpha", INTRO_LOGO_ALPHA, ] cmd = [sys.executable, str(MOVMAKER), str(local_input), "--out-dir", str(local_out), *intro_args, *extra_args] subprocess.run(cmd, check=True) outputs = sorted(local_out.glob("*.mp4"), key=lambda p: p.stat().st_mtime, reverse=True) if not outputs: raise RuntimeError("movmaker finished but no MP4 was produced") return outputs[0] def process_job(host: str, remote_root: str, job: str, work_dir: Path, extra_args: list[str], force: bool) -> None: job_dir = f"{remote_root}/in-dir/{job}" state = read_state(host, job_dir) maybe_send_first_show_notification(host, remote_root, job_dir, job, state) full_requested = remote_file_exists(host, f"{job_dir}/{ENABLED_NAME}") preview_requested = remote_file_exists(host, f"{job_dir}/{PREVIEW_NAME}") if not full_requested and not preview_requested: print(f"disabled: {job}") return preview_mode = preview_requested and not full_requested if not acquire_lock(host, job_dir): print(f"skip locked: {job}") return try: fingerprint_before = remote_fingerprint(host, job_dir) state = read_state(host, job_dir) old_output = state.get("output") if isinstance(state.get("output"), str) else None old_preview_output = state.get("preview_output") if isinstance(state.get("preview_output"), str) else None if preview_mode: if not force and state.get("preview_fingerprint") == fingerprint_before and old_preview_output: if remote_file_exists(host, f"{remote_root}/out-dir/{old_preview_output}"): print(f"preview up to date: {job} -> {old_preview_output}") return elif not force and state.get("status") == "done" and state.get("fingerprint") == fingerprint_before and old_output: if remote_file_exists(host, f"{remote_root}/out-dir/{old_output}"): print(f"up to date: {job} -> {old_output}") return processing_state = dict(state) processing_state.update({ "status": "processing", "mode": "preview" if preview_mode else "full", "started_at": now(), }) if preview_mode: processing_state["preview_fingerprint"] = fingerprint_before else: processing_state["fingerprint"] = fingerprint_before processing_state["output"] = old_output write_state(host, job_dir, processing_state) local_job = work_dir / job if local_job.exists(): shutil.rmtree(local_job) local_input = local_job / "input" local_out = local_job / "out" copy_job_from_remote(host, job_dir, local_input) render_args = [*extra_args, "--preview"] if preview_mode else extra_args print(f"rendering {'preview: ' if preview_mode else ''}{job}") local_mp4 = render_job(local_input, local_out, render_args) if preview_mode: preview_mp4 = local_mp4.with_name(f"{local_mp4.stem}_preview{local_mp4.suffix}") local_mp4.rename(preview_mp4) local_mp4 = preview_mp4 fingerprint_after = remote_fingerprint(host, job_dir) if fingerprint_after != fingerprint_before: stale_state = dict(state) stale_state.update({ "status": "stale", "updated_at": now(), "message": "Input changed during render; will regenerate on next run.", }) if preview_mode: stale_state["preview_fingerprint"] = fingerprint_after stale_state["previous_preview_fingerprint"] = fingerprint_before else: stale_state["fingerprint"] = fingerprint_after stale_state["previous_fingerprint"] = fingerprint_before stale_state["output"] = old_output write_state(host, job_dir, stale_state) print(f"stale during render, not publishing: {job}") return if preview_mode: preview_output = publish_output(host, remote_root, local_mp4, old_preview_output) new_state = dict(state) new_state.update({ "status": "done", "mode": "preview", "preview_fingerprint": fingerprint_after, "preview_output": preview_output, "preview_generated_at": now(), }) write_state(host, job_dir, new_state) print(f"published preview: {job} -> {preview_output}") return output = publish_output(host, remote_root, local_mp4, old_output) new_state = dict(state) new_state.update({ "status": "done", "mode": "full", "fingerprint": fingerprint_after, "output": output, "generated_at": now(), }) maybe_send_first_show_notification(host, remote_root, job_dir, job, new_state) write_state(host, job_dir, new_state) print(f"published: {job} -> {output}") return except Exception as exc: try: write_state(host, job_dir, { "status": "error", "updated_at": now(), "message": str(exc), }) finally: raise finally: release_lock(host, job_dir) def main() -> int: parser = argparse.ArgumentParser(description="Render changed MVLog input dirs from a remote web server.") parser.add_argument("--host", default="192.168.0.204", help="SSH host for the web server") parser.add_argument("--remote-root", default="/var/www/html/mvlog", help="Remote MVLog web root") parser.add_argument("--work-dir", default=str(Path.home() / ".cache" / "mvlog-worker"), help="Local work directory") parser.add_argument("--job", action="append", help="Only process this input dir name; may be repeated") parser.add_argument("--force", action="store_true", help="Render even if fingerprint is unchanged") parser.add_argument("--movmaker-arg", action="append", default=[], help="Extra argument passed to movmaker; may be repeated") args = parser.parse_args() work_dir = Path(args.work_dir) work_dir.mkdir(parents=True, exist_ok=True) jobs = args.job or list_jobs(args.host, args.remote_root) for job in jobs: process_job(args.host, args.remote_root, job, work_dir, args.movmaker_arg, args.force) return 0 if __name__ == "__main__": logging.info('Worker finished.') raise SystemExit(main())