← back to Clipreviewbydave

server.js

130 lines

'use strict';
// ClipReviewbyDave — zero-dependency static server + local-media picker on a fixed port.
// Serves public/ (index.html = the clip-review tool by Dave) AND exposes the user's
// own video files so they can be loaded by URL instead of dragged. Still 100% local:
// files are read straight off disk and streamed to the same-origin browser; nothing
// is uploaded anywhere.
const http = require('http');
const fs = require('fs');
const os = require('os');
const path = require('path');

const PORT = process.env.PORT || 9736;
const ROOT = path.join(__dirname, 'public');
const HOME = os.homedir();

// Directories whose video files the picker may list + stream. Override with
// MEDIA_DIRS=/a:/b. Defaults cover where Steve's renders land.
const MEDIA_DIRS = (process.env.MEDIA_DIRS
  ? process.env.MEDIA_DIRS.split(':')
  : ['Videos', 'Downloads', 'Movies', 'Desktop'].map((d) => path.join(HOME, d))
).map((d) => { try { return fs.realpathSync(d); } catch { return null; } }).filter(Boolean);

const VIDEO_RE = /\.(mp4|mov|m4v|webm|mkv|mpg|mpeg)$/i;
const SKIP_DIR = new Set(['node_modules', '.git', 'dist', 'build', '.next', 'tmp']);
const WALK_CAP = 1500; // safety bound on files inspected per request

const TYPES = {
  '.html': 'text/html; charset=utf-8',
  '.js': 'text/javascript; charset=utf-8',
  '.css': 'text/css; charset=utf-8',
  '.json': 'application/json; charset=utf-8',
  '.png': 'image/png', '.jpg': 'image/jpeg', '.svg': 'image/svg+xml', '.ico': 'image/x-icon',
  '.mp4': 'video/mp4', '.m4v': 'video/x-m4v', '.mov': 'video/quicktime',
  '.webm': 'video/webm', '.mkv': 'video/x-matroska', '.mpg': 'video/mpeg', '.mpeg': 'video/mpeg',
};

// --- local media discovery ---------------------------------------------------
function listVideos() {
  const out = [];
  let seen = 0;
  for (const root of MEDIA_DIRS) {
    const stack = [root];
    while (stack.length && seen < WALK_CAP) {
      const dir = stack.pop();
      let entries;
      try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; }
      for (const e of entries) {
        if (seen >= WALK_CAP) break;
        const full = path.join(dir, e.name);
        if (e.isDirectory()) { if (!SKIP_DIR.has(e.name) && !e.name.startsWith('.')) stack.push(full); continue; }
        if (!VIDEO_RE.test(e.name)) continue;
        seen++;
        let st; try { st = fs.statSync(full); } catch { continue; }
        out.push({
          name: e.name,
          url: '/media?p=' + encodeURIComponent(full),
          folder: path.basename(dir),
          size: st.size,
          mtime: st.mtimeMs,
          when: new Date(st.mtimeMs).toLocaleString(undefined,
            { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }),
        });
      }
    }
  }
  out.sort((a, b) => b.mtime - a.mtime); // newest first
  return out;
}

// --- byte-range media streaming (required for scrubbing large video) ---------
function isAllowed(abs) {
  let real; try { real = fs.realpathSync(abs); } catch { return false; }
  return MEDIA_DIRS.some((root) => real === root || real.startsWith(root + path.sep));
}

function serveMedia(req, res, abs) {
  if (!isAllowed(abs)) { res.writeHead(403); return res.end('forbidden'); }
  let st; try { st = fs.statSync(abs); } catch { res.writeHead(404); return res.end('not found'); }
  const type = TYPES[path.extname(abs).toLowerCase()] || 'application/octet-stream';
  const range = req.headers.range;
  if (range) {
    const m = /bytes=(\d*)-(\d*)/.exec(range) || [];
    let start = m[1] ? parseInt(m[1], 10) : 0;
    let end = m[2] ? parseInt(m[2], 10) : st.size - 1;
    if (isNaN(start) || isNaN(end) || start > end || start >= st.size) {
      res.writeHead(416, { 'Content-Range': `bytes */${st.size}` }); return res.end();
    }
    res.writeHead(206, {
      'Content-Type': type, 'Accept-Ranges': 'bytes',
      'Content-Range': `bytes ${start}-${end}/${st.size}`, 'Content-Length': end - start + 1,
    });
    fs.createReadStream(abs, { start, end }).pipe(res);
  } else {
    res.writeHead(200, { 'Content-Type': type, 'Accept-Ranges': 'bytes', 'Content-Length': st.size });
    fs.createReadStream(abs).pipe(res);
  }
}

// --- static public/ ----------------------------------------------------------
function serveStatic(req, res, rel) {
  if (rel === '/' || rel === '') rel = '/index.html';
  const filePath = path.normalize(path.join(ROOT, rel));
  if (!filePath.startsWith(ROOT)) { res.writeHead(403); return res.end('forbidden'); }
  fs.readFile(filePath, (err, buf) => {
    if (err) { res.writeHead(404); return res.end('not found'); }
    res.writeHead(200, { 'Content-Type': TYPES[path.extname(filePath).toLowerCase()] || 'application/octet-stream' });
    res.end(buf);
  });
}

const server = http.createServer((req, res) => {
  const u = new URL(req.url, `http://127.0.0.1:${PORT}`);
  if (u.pathname === '/healthz') { res.writeHead(200); return res.end('ok'); }
  if (u.pathname === '/api/videos') {
    res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
    return res.end(JSON.stringify(listVideos()));
  }
  if (u.pathname === '/media') {
    const p = u.searchParams.get('p');
    if (!p) { res.writeHead(400); return res.end('missing p'); }
    return serveMedia(req, res, path.normalize(p));
  }
  serveStatic(req, res, decodeURIComponent(u.pathname));
});

server.listen(PORT, () => {
  console.log(`ClipReviewbyDave serving on http://127.0.0.1:${PORT}/`);
  console.log(`  media dirs: ${MEDIA_DIRS.join(', ') || '(none)'}`);
});