← back to Mp3 Agentabrams

server.js

96 lines

#!/usr/bin/env node
// server.js — Express app for mp3.agentabrams.com.
//
// /api/manifest          → JSON of every indexed MP3
// /api/refresh           → POST: re-runs scan-mp3s.js synchronously
// /audio?id=<id>         → streams the MP3 by manifest id (no path leak)
// /                      → static public/index.html (grid + list + sliders + sort)

const express = require('express');
const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');

const PORT = parseInt(process.env.MP3_PORT || '9696', 10);
// Bind to loopback only — this is a local-private tool until DNS + auth land.
// Override with MP3_HOST=0.0.0.0 if you explicitly want LAN access.
// We deliberately do NOT honor the global $HOST env var because Steve has it
// set to 0.0.0.0 globally — which would silently expose this on the LAN.
const HOST = process.env.MP3_HOST || '127.0.0.1';
const MANIFEST_FILE = path.join(__dirname, 'manifest.json');

function loadManifest() {
  if (!fs.existsSync(MANIFEST_FILE)) return { count: 0, items: [], generatedAt: null, error: 'no manifest — run `npm run scan`' };
  try { return JSON.parse(fs.readFileSync(MANIFEST_FILE, 'utf8')); }
  catch (e) { return { count: 0, items: [], error: 'manifest parse error: ' + e.message }; }
}

let manifest = loadManifest();
let byId = new Map(manifest.items.map((i) => [i.id, i]));

const app = express();

// 404-guard: never serve snapshot/backup/editor cruft from static root even if
// something slips past .gitignore (e.g. a *.bak left behind by a hot-edit).
const SNAPSHOT_RE = /\.(bak|orig|rej|swp)(\.|$)|\.pre-|~$/i;
app.use((req, res, next) => {
  if (SNAPSHOT_RE.test(req.path)) return res.status(404).send('not found');
  next();
});

app.use(express.static(path.join(__dirname, 'public'), { extensions: ['html'] }));

app.get('/api/manifest', (_req, res) => {
  res.json(manifest);
});

app.post('/api/refresh', express.json(), (_req, res) => {
  const r = spawnSync('node', [path.join(__dirname, 'scan-mp3s.js')], { encoding: 'utf8', timeout: 120_000 });
  manifest = loadManifest();
  byId = new Map(manifest.items.map((i) => [i.id, i]));
  res.json({ ok: r.status === 0, count: manifest.count, generatedAt: manifest.generatedAt, stderr: r.stderr ? r.stderr.slice(-500) : null });
});

// Stream MP3 by id only — paths never appear in URLs.
app.get('/audio', (req, res) => {
  const id = String(req.query.id || '');
  const item = byId.get(id);
  if (!item) return res.status(404).send('not found');
  if (!fs.existsSync(item.path)) return res.status(410).send('file moved');
  res.setHeader('Content-Type', 'audio/mpeg');
  res.setHeader('Content-Disposition', `inline; filename="${encodeURIComponent(item.name)}.mp3"`);
  res.setHeader('Accept-Ranges', 'bytes');
  // Range support so the browser can seek.
  const stat = fs.statSync(item.path);
  const range = req.headers.range;
  if (range) {
    const m = /bytes=(\d+)-(\d*)/.exec(range);
    if (m) {
      const start = parseInt(m[1], 10);
      const end = m[2] ? parseInt(m[2], 10) : stat.size - 1;
      res.status(206);
      res.setHeader('Content-Range', `bytes ${start}-${end}/${stat.size}`);
      res.setHeader('Content-Length', end - start + 1);
      fs.createReadStream(item.path, { start, end }).pipe(res);
      return;
    }
  }
  res.setHeader('Content-Length', stat.size);
  fs.createReadStream(item.path).pipe(res);
});

app.get('/healthz', (_req, res) => {
  res.status(manifest.count > 0 ? 200 : 503).json({
    ok: manifest.count > 0,
    count: manifest.count,
    generatedAt: manifest.generatedAt,
  });
});

app.listen(PORT, HOST, () => {
  console.log(`\n  mp3-agentabrams :: http://${HOST}:${PORT}`);
  console.log(`  serving ${manifest.count} mp3 entries`);
  console.log(`  manifest generated ${manifest.generatedAt}`);
  console.log(`  bound to ${HOST} — ${HOST === '127.0.0.1' ? 'LOOPBACK ONLY (Mac-local, no LAN exposure)' : 'EXPOSED on this interface'}\n`);
});