main
John Lauer Publish 0.4.3 c1307a9 1d ago
#!/usr/bin/env node
// adom-video: show any video or audio file in a real player (seek, scrub, frame
// step, chapters, YouTube's keyboard shortcuts) through one shared server.
'use strict';
const fs = require('fs');
const path = require('path');
const os = require('os');
const http = require('http');
const { spawn, execFileSync } = require('child_process');

const ROOT = path.resolve(fs.realpathSync(__filename), '..', '..');
const PKG = require(path.join(ROOT, 'package.json'));
const SERVER_FILE = path.join(os.homedir(), '.adom', 'instances', 'adom-video-player', 'server.json');

const HELP = `adom-video ${PKG.version}: play video and audio in a real, seekable player

  adom-video --ai-thread "<name>" play <file> [--title T] [--chapters ch.json|ch.vtt] [--captions subs.vtt|.srt]
                                       [--fps N] [--name "Tab name"] [--surface wv|hbw|pup|nb] [--no-open]
  adom-video --ai-thread "<name>" url <file>         register and print the player URL
  adom-video --ai-thread "<name>" show [--surface S]  open the library page
  adom-video --ai-thread "<name>" ui <directive> ...  drive the open player page:
        toast "text" [--kind info|success|error]   seek <seconds>   play   pause   speed <x>
        click <selector>   reveal <selector>   cursor <selector>   captions   reload
        eval "<js>"   (needs Developer mode: adom-video settings devMode=true)
  adom-video ls                                      the server, its port and version, and the library
  adom-video settings [key=value ...]                read or change settings (see the Settings window)
  adom-video --ai-thread "<name>" stop --reason "<what the user asked>" [--confirm]
  adom-video health | install | --version | help
  Add --json to any verb for the raw JSON.

Why: a quick "python -m http.server" ignores HTTP Range requests, so the browser cannot seek.
This server answers 206 Partial Content with ETags (no stale cache), and the player has
YouTube's keyboard shortcuts (hover Shortcuts in the player), chapters, captions, frame step
and auto-reload when the file is re-rendered.`;

const argv = process.argv.slice(2);
let thread = process.env.ADOM_AI_THREAD || '';
let asJson = false;
const rest = [];
for (let i = 0; i < argv.length; i++) {
  if (argv[i] === '--ai-thread') thread = argv[++i] || '';
  else if (argv[i] === '--json') asJson = true;
  else rest.push(argv[i]);
}
const verb = rest.shift();
const opt = (k) => { const i = rest.indexOf(k); if (i < 0) return undefined; const v = rest[i + 1]; rest.splice(i, 2); return v; };
const flag = (k) => { const i = rest.indexOf(k); if (i < 0) return false; rest.splice(i, 1); return true; };

// OK:/ERROR: + Hint: contract; --json prints the payload instead.
function ok(line, data = {}, extra = []) {
  if (asJson) console.log(JSON.stringify({ ok: true, ...data }, null, 2));
  else { console.log('OK: ' + line); for (const x of extra) console.log(x); }
  process.exit(0);
}
function err(line, hints = [], data = {}) {
  if (asJson) console.log(JSON.stringify({ ok: false, error: line, hints, ...data }, null, 2));
  else { console.log('ERROR: ' + line); for (const h of hints) console.log('Hint: ' + h); }
  process.exit(1);
}
const fromServer = (r) => err(r.error || 'the server refused', [r.hint].filter(Boolean), r);
const requireThread = () => {
  if (!thread.trim()) err('adom-video needs your ai-thread name on this verb.', [`adom-video --ai-thread "<a short name the user recognizes for this conversation>" ${verb || 'play'} ...`], { errorCode: 'caller_identity_required' });
};

function req(port, method, p, body) {
  return new Promise((resolve, reject) => {
    const data = body ? JSON.stringify({ aiThread: thread, ...body }) : null;
    const r = http.request({ host: '127.0.0.1', port, method, path: p, timeout: 5000,
      headers: data ? { 'content-type': 'application/json', 'content-length': Buffer.byteLength(data) } : {} }, (res) => {
      let d = ''; res.on('data', (c) => (d += c)); res.on('end', () => { try { resolve(JSON.parse(d)); } catch { resolve({ ok: false, raw: d }); } });
    });
    r.on('error', reject); r.on('timeout', () => r.destroy(new Error('timeout'))); if (data) r.write(data); r.end();
  });
}

async function live() {
  let s; try { s = JSON.parse(fs.readFileSync(SERVER_FILE, 'utf8')); } catch { return null; }
  try { const v = await req(s.port, 'GET', '/version'); if (v.ok) return { ...s, running: v.version }; } catch {}
  return null;
}

async function ensureServer() {
  const s = await live();
  if (s && s.running === PKG.version) return s;
  if (s) {   // old code still running after a package update: replace it (our own restart, not a user disruption)
    try { process.kill(s.pid, 'SIGTERM'); } catch {}
    await new Promise((r) => setTimeout(r, 600));
  }
  const child = spawn(process.execPath, [path.join(ROOT, 'server.js'), '--port', 'auto', '--ai-thread', thread || 'unknown'], { detached: true, stdio: 'ignore' });
  child.unref();
  for (let i = 0; i < 60; i++) {
    await new Promise((r) => setTimeout(r, 100));
    const n = await live(); if (n && n.running === PKG.version) return n;
  }
  err('the player server did not start', [`run it in the foreground to see why: node ${path.join(ROOT, 'server.js')} --port auto`]);
}

const sh = (cmd, args, timeout = 10000) => execFileSync(cmd, args, { stdio: 'pipe', timeout }).toString();
function hydrogen() { try { sh('adom-cli', ['hydrogen', 'workspace', 'tabs'], 8000); return true; } catch { return false; } }
function tabsNamed(name) {
  try { return (JSON.parse(sh('adom-cli', ['hydrogen', 'workspace', 'tabs'], 8000)).tabs || []).filter((t) => t.name === name); } catch { return []; }
}
// Keep ONE tab with this name: the one showing `url` if any. Remove the rest from the highest index
// down (indexes shift as tabs close). A refresh to a new URL can leave the old tab behind.
function dedupe(name, url) {
  const tabs = tabsNamed(name);
  if (tabs.length < 2) return tabs;
  const keep = tabs.find((t) => t.panelState && t.panelState.url === url) || tabs[0];
  for (const t of tabs.filter((x) => x !== keep).sort((a, b) => (a.panelId === b.panelId ? b.tabIndex - a.tabIndex : 0))) {
    try { sh('adom-cli', ['hydrogen', 'workspace', 'remove-tab', '--panel-id', t.panelId, '--tab-index', String(t.tabIndex)], 8000); } catch {}
  }
  return tabsNamed(name);
}
function baseUrl(port) {
  // Under Hydrogen the surface shares the machine: localhost is right.
  // In a cloud container the page renders in the user's browser: use the proxy (the page uses relative URLs).
  if (process.env.ADOM_DESKTOP_MODE === 'local' || hydrogen()) return `http://localhost:${port}`;
  const proxy = process.env.VSCODE_PROXY_URI;
  if (proxy) return proxy.replace('{{port}}', port).replace(/\/$/, '');
  return `http://localhost:${port}`;
}

async function openSurface(s, url, tab, reason, restartCmd) {
  const settings = ((await req(s.port, 'GET', '/settings')).settings) || {};
  const surface = opt('--surface') || settings.surface || 'wv';
  if (surface === 'pup') {
    try {
      sh('adom-bridge', ['--ai-thread', thread, 'pup_open_window', JSON.stringify({ url, reason })], 60000);
      return 'a pup window (in the background)';
    } catch (e) { return { failed: String(e.stderr || e.message).slice(0, 300) }; }
  }
  if (surface === 'nb') {
    try { sh('adom-bridge', ['--ai-thread', thread, 'desktop_open_url', JSON.stringify({ url, reason })], 30000); return 'your browser'; }
    catch (e) { return { failed: String(e.stderr || e.message).slice(0, 300) }; }
  }
  if (surface === 'hbw') {
    try {
      const base = fs.readFileSync(path.join(os.homedir(), '.adom', 'hydrogen-control-url'), 'utf8').trim();
      sh('curl', ['-sf', '-X', 'POST', `${base}/hdbw/open`, '-H', 'content-type: application/json', '-d', JSON.stringify({ url })], 15000);
      return 'a Hydrogen browser window';
    } catch (e) { return { failed: 'the Hydrogen browser window is not available here: ' + String(e.message).slice(0, 200) }; }
  }
  if (!hydrogen()) return { failed: 'no Hydrogen here; open the URL in a browser or pass --surface pup' };
  // Same name already showing this URL: just bring it forward (refreshing the same URL can fall back
  // to recreate and leave a duplicate). Different URL: navigate it. None: open one; with no --panel-id
  // Hydrogen picks a work pane and never covers the editor.
  const existing = dedupe(tab, url);
  if (existing[0] && existing[0].panelState && existing[0].panelState.url === url) {
    try { sh('adom-cli', ['hydrogen', 'workspace', 'active-tab', '--name', tab], 8000); } catch {}
  } else {
    try {
      sh('adom-cli', ['hydrogen', 'webview', 'open-or-refresh', '--name', tab, '--url', url, '--ai-thread', thread,
        '--reason', reason.slice(0, 80), '--restart', restartCmd], 15000);
    } catch (e) { return { failed: String(e.stderr || e.message).slice(0, 300) }; }
    dedupe(tab, url);
  }
  await req(s.port, 'POST', '/api/tabs', { name: tab }).catch(() => {});
  return `the Hydrogen tab "${tab}"`;
}

async function main() {
  if (!verb || verb === 'help' || verb === '--help' || verb === '-h') return console.log(HELP);
  if (verb === '--version' || verb === 'version') return ok(`adom-video ${PKG.version}`, { version: PKG.version });

  if (verb === 'install') {
    try { const out = sh('adom-wiki', ['pkg', 'install', 'adom/adom-video-player'], 180000); return ok('installed the latest adom-video-player from the wiki', { out }); }
    catch (e) { return err('install failed: ' + String(e.message).slice(0, 200), ['adom-wiki pkg install adom/adom-video-player']); }
  }

  if (verb === 'health') {
    const s = await live();
    if (!s) return ok(`installed ${PKG.version} (node ${process.versions.node}); the server is not running, the next play starts it`, { version: PKG.version, running: false });
    const h = await req(s.port, 'GET', '/health');
    const drift = s.running !== PKG.version ? ` WARN: it runs ${s.running}, disk has ${PKG.version}; the next play restarts it` : '';
    return ok(`server ${s.running} on port ${s.port}, ${h.pages} open page(s), ${h.library} video(s) in the library${drift}`, { ...h, disk: PKG.version });
  }

  if (verb === 'ls') {
    const s = await live();
    if (!s) return ok('the player server is not running', { running: false }, ['Hint: adom-video --ai-thread "<name>" play <file> starts it']);
    const st = await req(s.port, 'GET', '/state');
    const lines = (st.library || []).map((x) => `  ${x.id}  ${x.title}${x.exists ? '' : '  (file missing)'}\n         ${baseUrl(s.port)}/watch/${x.id}`);
    if (s.running !== PKG.version) lines.unshift(`WARN: the server runs ${s.running}, disk has ${PKG.version}; the next play restarts it`);
    return ok(`server ${s.running} on port ${s.port}, started by "${s.thread}", ${st.pages} open page(s)`, { ...st, url: baseUrl(s.port) }, lines);
  }

  if (verb === 'settings') {
    const s = await ensureServer();
    const kv = {}; for (const a of rest) { const m = /^(\w+)=(.*)$/.exec(a); if (m) kv[m[1]] = m[2] === 'true' ? true : m[2] === 'false' ? false : isNaN(+m[2]) ? m[2] : +m[2]; }
    if (!Object.keys(kv).length) {
      const r = await req(s.port, 'GET', '/settings');
      return ok('settings', r, Object.entries(r.schema).map(([k, d]) => `  ${k} = ${JSON.stringify(r.settings[k])}   (${d.label}; ${d.type === 'enum' ? 'one of ' + d.values.join(', ') : d.type}; default ${JSON.stringify(d.default)})`));
    }
    requireThread();
    const r = await req(s.port, 'POST', '/settings', kv);
    return r.ok ? ok(`settings updated: ${Object.keys(kv).join(', ')}`, r) : fromServer(r);
  }

  if (verb === 'stop') {
    requireThread();
    const s = await live(); if (!s) return ok('the player server was not running', { running: false });
    const r = await req(s.port, 'POST', '/api/shutdown', { reason: opt('--reason'), confirm: flag('--confirm') });
    return r.ok ? ok('the player server stopped', r) : fromServer(r);
  }

  if (verb === 'ui') {
    requireThread();
    const type = rest.shift();
    const s = await live(); if (!s) return err('the player server is not running', ['adom-video --ai-thread "<name>" play <file>']);
    const kind = opt('--kind');
    const a = rest[0];
    const body = {
      toast: { text: a, kind: kind || 'info' }, seek: { t: Number(a) }, speed: { rate: Number(a) },
      click: { selector: a }, reveal: { selector: a }, cursor: { selector: a, hover: true }, eval: { js: a },
    }[type] || {};
    const r = await req(s.port, 'POST', `/ui/${type}`, body);
    return r.ok ? ok(`${type} sent to ${r.delivered} open page(s)`, r) : fromServer(r);
  }

  if (verb === 'show') {
    requireThread();
    const s = await ensureServer();
    const url = `${baseUrl(s.port)}/`;
    const where = await openSurface(s, url, opt('--name') || 'Video Player', 'video library', `adom-video --ai-thread ${JSON.stringify(thread)} health`);
    if (typeof where === 'object') return err('could not open the library: ' + where.failed, [`open ${url} yourself`]);
    return ok(`the video library is open in ${where}`, { url });
  }

  if (verb === 'play' || verb === 'url') {
    requireThread();
    const title = opt('--title'), chapters = opt('--chapters'), fps = opt('--fps'), name = opt('--name'), caps = opt('--captions');
    const noOpen = flag('--no-open') || verb === 'url';
    const file = rest[0];
    if (!file) return err('give a file to play', ['adom-video --ai-thread "<name>" play path/to/video.mp4']);
    const abs = path.resolve(file);
    if (!fs.existsSync(abs)) return err(`no such file: ${abs}`, ['check the path; adom-video ls lists what is already registered']);
    const s = await ensureServer();
    const r = await req(s.port, 'POST', '/api/add', { path: abs, title, chapters: chapters ? path.resolve(chapters) : undefined, captions: caps ? path.resolve(caps) : undefined, fps });
    if (!r.ok) return fromServer(r);
    const url = `${baseUrl(s.port)}${r.watch}`;
    const data = { url, media: `${baseUrl(s.port)}${r.media}`, id: r.item.id, title: r.item.title, port: s.port };
    if (noOpen) return ok(`registered "${r.item.title}"`, data, [`url: ${url}`]);
    const where = await openSurface(s, url, name || `Video: ${r.item.title}`.slice(0, 60), `video: ${r.item.title}`,
      `adom-video --ai-thread ${JSON.stringify(thread)} play ${JSON.stringify(abs)} --no-open`);
    if (typeof where === 'object') return err('registered, but could not open it: ' + where.failed, [`open ${url} yourself, or pass --surface pup`], data);
    return ok(`playing "${r.item.title}" in ${where}`, { ...data, opened: where }, [`url: ${url}`, 'Hint: adom-video --ai-thread "<name>" ui seek <seconds> | ui toast "..." drives the open player']);
  }
  return err(`unknown verb "${verb}"`, ['adom-video help']);
}
main().catch((e) => err(String(e && e.message || e), ['adom-video health']));