main
John Lauer Publish 0.4.2 5527185 7d ago
#!/usr/bin/env node
// adom-video-player server: one per container. Serves registered media with full
// HTTP Range support so every player can seek, owns all state (library, positions,
// settings), and drives the open player pages over an SSE live channel.
'use strict';
const http = require('http');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const os = require('os');
const { execFile } = require('child_process');

const PKG = require('./package.json');
const HOME = os.homedir();
const INST_DIR = path.join(HOME, '.adom', 'instances', 'adom-video-player');
const LIB_FILE = path.join(INST_DIR, 'library.json');
const POS_FILE = path.join(INST_DIR, 'positions.json');
const SET_FILE = path.join(INST_DIR, 'settings.json');
const SERVER_FILE = path.join(INST_DIR, 'server.json');
const OLD_DIR = path.join(HOME, '.adom', 'adom-video-player');   // 0.1/0.2 location, migrated once
const PUBLIC = path.join(__dirname, 'public');

const argv = process.argv.slice(2);
const argVal = (k) => { const i = argv.indexOf(k); return i >= 0 ? argv[i + 1] : undefined; };
const THREAD = argVal('--ai-thread') || process.env.ADOM_AI_THREAD || 'unknown';
const KEEP_ALIVE = argv.includes('--keep-alive');

fs.mkdirSync(INST_DIR, { recursive: true });
const readJSON = (f, d) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return d; } };
const writeJSON = (f, v) => { const t = f + '.' + process.pid + '.tmp'; fs.writeFileSync(t, JSON.stringify(v, null, 2)); fs.renameSync(t, f); };
for (const [o, n] of [['library.json', LIB_FILE], ['settings.json', SET_FILE]]) {
  if (!fs.existsSync(n) && fs.existsSync(path.join(OLD_DIR, o))) fs.copyFileSync(path.join(OLD_DIR, o), n);
}

// ---------- settings: schema-validated, server-side, broadcast on change ----------
const SCHEMA = {
  autoplay: { type: 'boolean', default: true, label: 'Play when a video opens', help: 'Start playback as soon as a video loads. Off: it opens paused at your last position.' },
  rememberPosition: { type: 'boolean', default: true, label: 'Resume where you left off', help: 'Reopen each video at the position you last watched it, stored on this machine.' },
  defaultSpeed: { type: 'enum', values: [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2], default: 1, label: 'Default speed', help: 'Playback speed for a video you have not set a speed on yet.' },
  surface: { type: 'enum', values: ['wv', 'hbw', 'pup', 'nb'], labels: { wv: 'Hydrogen webview tab', hbw: 'Hydrogen browser window', pup: 'pup window', nb: 'Your own browser' }, default: 'wv', label: 'Where videos open', help: 'Where adom-video play shows a video when the AI does not choose.' },
  openInBackground: { type: 'boolean', default: true, label: 'Open pup windows in the background', help: 'A pup window opens without stealing focus; its taskbar button flashes instead.' },
  ttlHours: { type: 'number', min: 1, max: 720, default: 24, label: 'Stop the server after idle hours', help: 'The player server exits after this many hours with no requests. Your library and positions are kept.' },
  devMode: { type: 'boolean', default: false, label: 'Developer mode', help: 'Lets an AI run JavaScript in the open player page (adom-video ui eval) for debugging.' },
};
const defaults = () => Object.fromEntries(Object.entries(SCHEMA).map(([k, s]) => [k, s.default]));
let settings = { ...defaults(), ...readJSON(SET_FILE, {}) };
function validateSettings(patch) {
  const out = {}; const errors = [];
  for (const [k, v] of Object.entries(patch)) {
    if (k === 'aiThread' || k === 'reason') continue;
    const s = SCHEMA[k];
    if (!s) { errors.push(`unknown setting "${k}" (valid: ${Object.keys(SCHEMA).join(', ')})`); continue; }
    if (s.type === 'boolean') { if (typeof v !== 'boolean') errors.push(`${k} must be true or false`); else out[k] = v; }
    if (s.type === 'number') { const n = Number(v); if (!isFinite(n) || n < s.min || n > s.max) errors.push(`${k} must be a number from ${s.min} to ${s.max}`); else out[k] = n; }
    if (s.type === 'enum') { const m = s.values.find((x) => String(x) === String(v)); if (m === undefined) errors.push(`${k} must be one of: ${s.values.join(', ')}`); else out[k] = m; }
  }
  return { out, errors };
}

let library = readJSON(LIB_FILE, { items: [] });
let positions = readJSON(POS_FILE, {});
let lastDisruption = null;
const threadsSeen = new Map();   // aiThread -> last ms
const tabs = new Set();          // webview tabs opened for this server (closed on reap)
const consoleRing = [];
const MAX_CONSOLE = 500;

const MIME = {
  '.mp4': 'video/mp4', '.m4v': 'video/mp4', '.mov': 'video/quicktime', '.webm': 'video/webm', '.mkv': 'video/x-matroska',
  '.ogv': 'video/ogg', '.avi': 'video/x-msvideo', '.mp3': 'audio/mpeg', '.m4a': 'audio/mp4', '.wav': 'audio/wav',
  '.ogg': 'audio/ogg', '.flac': 'audio/flac', '.gif': 'image/gif', '.png': 'image/png', '.jpg': 'image/jpeg',
  '.jpeg': 'image/jpeg', '.vtt': 'text/vtt', '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8',
  '.css': 'text/css; charset=utf-8', '.svg': 'image/svg+xml', '.json': 'application/json', '.woff2': 'font/woff2',
};
const mimeOf = (p) => MIME[path.extname(p).toLowerCase()] || 'application/octet-stream';
const idOf = (p) => crypto.createHash('sha1').update(p).digest('hex').slice(0, 10);

function statItem(it) {
  try {
    const st = fs.statSync(it.path);
    return { ...it, size: st.size, mtime: st.mtimeMs, etag: `"${st.size.toString(16)}-${Math.floor(st.mtimeMs).toString(16)}"`, exists: true, position: positions[it.id] || null };
  } catch { return { ...it, exists: false, position: positions[it.id] || null }; }
}

function parseChapters(src) {
  if (!src) return [];
  if (Array.isArray(src)) return src.map((c) => ({ t: Number(c.t ?? c.time ?? c.start ?? 0), title: String(c.title ?? c.name ?? '') }));
  try {
    const txt = fs.readFileSync(src, 'utf8');
    if (src.endsWith('.json')) return parseChapters(JSON.parse(txt));
    const out = []; const re = /(\d+:)?(\d{1,2}):(\d{2})[.,](\d{3})\s*-->.*\n(.+)/g; let m;
    while ((m = re.exec(txt))) out.push({ t: (m[1] ? parseInt(m[1]) * 3600 : 0) + parseInt(m[2]) * 60 + parseInt(m[3]) + parseInt(m[4]) / 1000, title: m[5].trim() });
    return out;
  } catch { return []; }
}

// ---------- live channel (SSE) ----------
const clients = new Set();
function broadcast(event, data) {
  const msg = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
  for (const c of clients) { try { c.write(msg); } catch {} }
}
const stateSnapshot = () => ({
  ok: true, app: 'adom-video-player', version: PKG.version, port, pid: process.pid, startedBy: THREAD,
  settings, schema: SCHEMA, library: library.items.map(statItem), lastDisruption,
  pages: clients.size, threads: [...threadsSeen].map(([t, ms]) => ({ thread: t, idleSec: Math.round((Date.now() - ms) / 1000) })),
});

// file watcher: a re-rendered file reloads every open player within ~2 s
const etags = new Map();
setInterval(() => {
  for (const it of library.items) {
    const s = statItem(it); const prev = etags.get(it.id);
    if (prev !== undefined && s.etag !== prev) broadcast('changed', { id: it.id, etag: s.etag, exists: s.exists });
    etags.set(it.id, s.etag);
  }
}, 2000).unref();

let lastActivity = Date.now();
let port = 0;
const startedAt = new Date().toISOString();
function register() {
  writeJSON(SERVER_FILE, { pid: process.pid, port, thread: THREAD, version: PKG.version, startedAt, lastActivity: new Date(lastActivity).toISOString(), tabs: [...tabs] });
}
let posTimer = null;
const savePositions = () => { clearTimeout(posTimer); posTimer = setTimeout(() => writeJSON(POS_FILE, positions), 1000); };

function send(res, code, body, headers = {}) {
  const isObj = typeof body === 'object' && !Buffer.isBuffer(body);
  res.writeHead(code, { 'Content-Type': isObj ? 'application/json' : 'text/plain; charset=utf-8', ...headers });
  res.end(isObj ? JSON.stringify(body, null, 2) : body);
}
function readBody(req) {
  return new Promise((resolve) => { let d = ''; req.on('data', (c) => (d += c)); req.on('end', () => { try { resolve(d ? JSON.parse(d) : {}); } catch { resolve({ __bad: true }); } }); });
}
function needThread(b) {
  if (b && typeof b.aiThread === 'string' && b.aiThread.trim()) { threadsSeen.set(b.aiThread.trim(), Date.now()); return null; }
  return { ok: false, errorCode: 'caller_identity_required', error: 'adom-video-player requires every caller to give its ai-thread name.',
    hint: 'Re-run with the global flag: adom-video --ai-thread "<name>" <verb>, or add a top-level "aiThread" field if you POST directly. Use a short name a HUMAN would recognize for THIS conversation.' };
}
function needReason(b, what, alternative) {
  const r = b && typeof b.reason === 'string' ? b.reason.trim() : '';
  if (r.length >= 10) return null;
  return { ok: false, errorCode: 'reason_required', error: `${what} requires a reason.`,
    hint: `Re-call with a "reason" (at least 10 characters) quoting or paraphrasing what the USER asked for. ${alternative}` };
}

function serveStatic(res, file) {
  fs.readFile(file, (err, data) => {
    if (err) return send(res, 404, 'not found');
    res.writeHead(200, { 'Content-Type': mimeOf(file), 'Cache-Control': file.endsWith('.woff2') ? 'max-age=86400' : 'no-cache' }); res.end(data);
  });
}

// The heart of it: byte-range serving so <video> can seek anywhere.
function serveMedia(req, res, it) {
  const s = statItem(it);
  if (!s.exists) return send(res, 404, { ok: false, error: `file is gone: ${it.path}` });
  const total = s.size;
  const base = {
    'Content-Type': mimeOf(it.path), 'Accept-Ranges': 'bytes', ETag: s.etag,
    'Last-Modified': new Date(s.mtime).toUTCString(), 'Cache-Control': 'no-cache',
    'Content-Disposition': `inline; filename="${path.basename(it.path).replace(/"/g, '')}"`,
  };
  if (req.headers['if-none-match'] === s.etag && !req.headers.range) { res.writeHead(304, base); return res.end(); }
  let range = req.headers.range;
  const ifRange = req.headers['if-range'];
  if (range && ifRange && ifRange !== s.etag && ifRange !== base['Last-Modified']) range = null; // file changed: send it whole
  if (!range) {
    res.writeHead(200, { ...base, 'Content-Length': total });
    if (req.method === 'HEAD') return res.end();
    return fs.createReadStream(it.path).pipe(res);
  }
  const m = /^bytes=(\d*)-(\d*)$/.exec(range.trim());
  let start, end;
  if (m && (m[1] || m[2])) {
    if (m[1]) { start = parseInt(m[1], 10); end = m[2] ? parseInt(m[2], 10) : total - 1; }
    else { start = Math.max(0, total - parseInt(m[2], 10)); end = total - 1; }
  }
  if (start === undefined || isNaN(start) || start >= total || end < start) {
    res.writeHead(416, { ...base, 'Content-Range': `bytes */${total}` }); return res.end();
  }
  end = Math.min(end, total - 1);
  res.writeHead(206, { ...base, 'Content-Range': `bytes ${start}-${end}/${total}`, 'Content-Length': end - start + 1 });
  if (req.method === 'HEAD') return res.end();
  fs.createReadStream(it.path, { start, end }).pipe(res);
}

const UI_DIRECTIVES = new Set(['toast', 'click', 'reveal', 'cursor', 'reload', 'seek', 'play', 'pause', 'speed', 'eval', 'captions']);

const server = http.createServer(async (req, res) => {
  lastActivity = Date.now();
  const url = new URL(req.url, 'http://x');
  const p = url.pathname;
  try {
    if (p === '/' || p === '/index.html') return serveStatic(res, path.join(PUBLIC, 'index.html'));
    if (p.startsWith('/watch/')) return serveStatic(res, path.join(PUBLIC, 'player.html'));
    if (p.startsWith('/static/')) {
      const f = path.normalize(path.join(PUBLIC, p.slice(8)));
      if (!f.startsWith(PUBLIC)) return send(res, 403, 'no');
      return serveStatic(res, f);
    }
    if (p === '/favicon.ico' || p === '/favicon.svg') return serveStatic(res, path.join(PUBLIC, 'icons', 'video-player.svg'));
    if (p === '/version' || p === '/health') return send(res, 200, { ok: true, app: 'adom-video-player', version: PKG.version, pid: process.pid, port, thread: THREAD, pages: clients.size, library: library.items.length });
    if (p === '/state') return send(res, 200, stateSnapshot());

    if (p === '/events') {
      res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive', 'X-Accel-Buffering': 'no' });
      res.write(':' + ' '.repeat(2048) + '\n\n');
      res.write(`event: state\ndata: ${JSON.stringify(stateSnapshot())}\n\n`);
      clients.add(res);
      const hb = setInterval(() => { try { res.write(': hb\n\n'); } catch {} }, 15000);
      req.on('close', () => { clearInterval(hb); clients.delete(res); });
      return;
    }

    if (p === '/console' && req.method === 'GET') {
      const n = Number(url.searchParams.get('n') || 100);
      return send(res, 200, { ok: true, lines: consoleRing.slice(-n) });
    }
    if (p === '/console' && req.method === 'POST') {
      const b = await readBody(req);
      for (const l of (Array.isArray(b.lines) ? b.lines : [b])) {
        consoleRing.push({ ts: new Date().toISOString(), level: String(l.level || 'log'), page: String(l.page || ''), msg: String(l.msg || '').slice(0, 2000) });
      }
      while (consoleRing.length > MAX_CONSOLE) consoleRing.shift();
      return send(res, 200, { ok: true });
    }

    if (p === '/settings' && req.method === 'GET') {
      return send(res, 200, { ok: true, settings, schema: SCHEMA, enums: Object.fromEntries(Object.entries(SCHEMA).filter(([, s]) => s.type === 'enum').map(([k, s]) => [k, s.values])) });
    }
    if (p === '/settings' && req.method === 'POST') {
      const b = await readBody(req); const no = needThread(b); if (no) return send(res, 400, no);
      if (b.restoreDefaults === true) settings = defaults();
      else {
        const { out, errors } = validateSettings(b);
        if (errors.length) return send(res, 400, { ok: false, errorCode: 'invalid_setting', error: errors.join('; '), hint: 'GET /settings lists every setting with its type and allowed values.' });
        settings = { ...settings, ...out };
      }
      writeJSON(SET_FILE, settings); broadcast('settings', { settings, by: b.aiThread });
      return send(res, 200, { ok: true, settings });
    }

    if (p === '/api/library') return send(res, 200, { ok: true, items: library.items.map(statItem) });
    if (p.startsWith('/api/item/')) {
      const it = library.items.find((x) => x.id === p.slice(10));
      return it ? send(res, 200, { ok: true, item: statItem(it), settings }) : send(res, 404, { ok: false, error: 'unknown id', hint: 'adom-video ls lists the library.' });
    }
    if (p.startsWith('/captions/')) {
      const it = library.items.find((x) => x.id === p.slice(10));
      if (!it || !it.captions) return send(res, 404, 'no captions');
      return fs.readFile(it.captions, 'utf8', (err, txt) => {
        if (err) return send(res, 404, 'captions file is gone');
        if (!/^WEBVTT/.test(txt)) txt = 'WEBVTT\n\n' + txt.replace(/(\d\d:\d\d:\d\d),(\d\d\d)/g, '$1.$2');  // .srt -> .vtt
        res.writeHead(200, { 'Content-Type': 'text/vtt; charset=utf-8', 'Cache-Control': 'no-cache' }); res.end(txt);
      });
    }
    if (p.startsWith('/media/')) {
      const id = p.slice(7).split('/')[0];
      const it = library.items.find((x) => x.id === id);
      if (!it) return send(res, 404, { ok: false, error: 'unknown id', hint: 'register the file with adom-video play <file>' });
      return serveMedia(req, res, it);
    }

    if (p === '/api/add' && req.method === 'POST') {
      const b = await readBody(req); const no = needThread(b); if (no) return send(res, 400, no);
      const abs = path.resolve(String(b.path || ''));
      if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) return send(res, 400, { ok: false, error: `not a file: ${abs}`, hint: 'Pass the path of a video or audio file.' });
      const id = idOf(abs);
      const prev = library.items.find((x) => x.id === id);
      const ch = parseChapters(b.chapters);
      const item = { id, path: abs, title: b.title || prev?.title || path.basename(abs), chapters: ch.length ? ch : (prev?.chapters || []),
        fps: Number(b.fps) || prev?.fps || 0, captions: b.captions ? path.resolve(String(b.captions)) : (prev?.captions || null),
        addedBy: b.aiThread, addedAt: new Date().toISOString() };
      library.items = [item, ...library.items.filter((x) => x.id !== id)].slice(0, 200);
      writeJSON(LIB_FILE, library); broadcast('library', { items: library.items.map(statItem) });
      return send(res, 200, { ok: true, item: statItem(item), watch: `/watch/${id}`, media: `/media/${id}` });
    }
    if (p === '/api/remove' && req.method === 'POST') {
      const b = await readBody(req); const no = needThread(b); if (no) return send(res, 400, no);
      const it = library.items.find((x) => x.id === b.id);
      library.items = library.items.filter((x) => x.id !== b.id); writeJSON(LIB_FILE, library);
      broadcast('library', { items: library.items.map(statItem) });
      return send(res, 200, { ok: true, removed: it ? it.title : null, kept: 'the file on disk is untouched; play it again to re-add it' });
    }
    if (p === '/api/position' && req.method === 'POST') {
      const b = await readBody(req); const no = needThread(b); if (no) return send(res, 400, no);
      if (!library.items.find((x) => x.id === b.id)) return send(res, 404, { ok: false, error: 'unknown id' });
      positions[b.id] = { t: Math.max(0, Number(b.t) || 0), speed: Number(b.speed) || undefined, at: new Date().toISOString() };
      savePositions(); return send(res, 200, { ok: true });
    }
    if (p === '/api/tabs' && req.method === 'POST') {
      const b = await readBody(req); const no = needThread(b); if (no) return send(res, 400, no);
      if (b.name) tabs.add(String(b.name)); register(); return send(res, 200, { ok: true, tabs: [...tabs] });
    }

    if (p.startsWith('/ui/') && req.method === 'POST') {
      const type = p.slice(4);
      const b = await readBody(req); const no = needThread(b); if (no) return send(res, 400, no);
      if (!UI_DIRECTIVES.has(type)) return send(res, 400, { ok: false, errorCode: 'unknown_directive', error: `no ui directive "${type}"`, hint: `Valid: ${[...UI_DIRECTIVES].join(', ')}` });
      if (type === 'eval' && !settings.devMode) return send(res, 400, { ok: false, errorCode: 'dev_mode_off', error: 'ui eval runs JavaScript in the user\'s page, so it needs Developer mode.', hint: 'Ask the user, then: adom-video settings devMode=true' });
      if (!clients.size) return send(res, 409, { ok: false, errorCode: 'no_page_open', error: 'no player page is open to receive this', hint: 'Open one first: adom-video --ai-thread "<name>" play <file>' });
      const { aiThread, ...payload } = b;
      broadcast('ui', { type, ...payload, by: aiThread });
      return send(res, 200, { ok: true, delivered: clients.size });
    }

    if (p === '/api/shutdown' && req.method === 'POST') {
      const b = await readBody(req); const no = needThread(b); if (no) return send(res, 400, no);
      const why = needReason(b, 'Stopping the shared player server', 'If you only want your video gone, close its tab instead; other threads may be using this server.');
      if (why) return send(res, 400, why);
      const others = [...threadsSeen].filter(([t, ms]) => t !== b.aiThread && !t.startsWith('human') && Date.now() - ms < 5 * 60e3).map(([t]) => t);
      if ((others.length || clients.size) && b.confirm !== true) {
        return send(res, 409, { ok: false, errorCode: 'confirm_required', error: `Other users of this server: ${others.length ? 'threads ' + others.join(', ') : 'none'}; open player pages: ${clients.size}.`,
          hint: 'Re-call with "confirm": true only if stopping it is really what the user wants; their open players will stop.' });
      }
      lastDisruption = { ts: new Date().toISOString(), verb: 'shutdown', thread: b.aiThread, reason: b.reason };
      console.log('DISRUPTION granted: shutdown by', b.aiThread, ':', b.reason);
      broadcast('ui', { type: 'toast', text: `The player server is stopping (${b.aiThread}: ${b.reason})`, kind: 'error' });
      send(res, 200, { ok: true, bye: true }); return setTimeout(() => shutdown('requested'), 200);
    }
    return send(res, 404, { ok: false, error: `no route ${p}`, hint: 'GET /state shows everything this server knows.' });
  } catch (e) { return send(res, 500, { ok: false, error: String(e && e.stack || e) }); }
});

function shutdown(why) {
  try { writeJSON(POS_FILE, positions); } catch {}
  for (const name of tabs) { try { execFile('adom-cli', ['hydrogen', 'workspace', 'remove-tab', '--name', name], { timeout: 5000 }, () => {}); } catch {} }
  try { if (readJSON(SERVER_FILE, {}).pid === process.pid) fs.unlinkSync(SERVER_FILE); } catch {}
  setTimeout(() => process.exit(0), tabs.size ? 1500 : 0);
}
process.on('SIGTERM', () => shutdown('sigterm')); process.on('SIGINT', () => shutdown('sigint'));

const want = argVal('--port');
server.listen(want && want !== 'auto' ? parseInt(want, 10) : 0, '127.0.0.1', () => {
  port = server.address().port; register();
  console.log(JSON.stringify({ ok: true, port, pid: process.pid, version: PKG.version, thread: THREAD }));
});
setInterval(() => {
  register();
  const ttl = Number(argVal('--ttl') || settings.ttlHours || 24);
  if (!KEEP_ALIVE && Date.now() - lastActivity > ttl * 3600e3) shutdown('idle');
}, 60e3).unref();