app
Adom Video Player
Public Made by Adomby adom
Show any video in a real player: seek, scrub, frame step, chapters. One command, no hand-rolled servers.
main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344
#!/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();