← back to Mp3 Agentabrams

scan-mp3s.js

137 lines

#!/usr/bin/env node
// scan-mp3s.js — walks SCAN_ROOTS and writes manifest.json with every .mp3 found.
//
// Output schema (per item):
//   {
//     id        : 'a-z0-9 hash of full path (stable across runs)',
//     path      : absolute path on disk (used by server /audio route)',
//     name      : basename without .mp3',
//     parent    : last directory segment (used as "Source" tag)',
//     parent2   : second-to-last directory segment (more specific source for UI)',
//     size      : bytes',
//     mtime     : ms epoch',
//     mtimeISO  : ISO string',
//     duration  : seconds (probed via ffprobe if available, else null)'
//   }
//
// Reads / writes nothing outside SCAN_ROOTS + project dir.

const fs = require('fs');
const path = require('path');
const { execFileSync, spawnSync } = require('child_process');
const crypto = require('crypto');

const HOME = process.env.HOME || '/Users/stevestudio2';
const SCAN_ROOTS = (process.env.SCAN_ROOTS || [
  path.join(HOME, 'Projects'),
  path.join(HOME, 'Videos'),
  path.join(HOME, 'Desktop'),
  path.join(HOME, '.claude/skills'),
].join(',')).split(',').map((s) => s.trim()).filter(Boolean);

// Exclude any path containing one of these substrings (case-insensitive).
const EXCLUDE_SUBSTRINGS = [
  '/node_modules/', '/.git/', '/.Trash/', '/Library/Caches/', '/kamatera-mirror/',
  '/.cache/', '/.next/', '/dist/', '/build/',
];

const OUT_FILE = path.join(__dirname, 'manifest.json');
const PROBE_DURATIONS = process.env.PROBE_DURATIONS !== '0';

function hasFfprobe() {
  try { execFileSync('which', ['ffprobe'], { stdio: 'ignore' }); return true; }
  catch { return false; }
}
const FFPROBE_AVAILABLE = PROBE_DURATIONS && hasFfprobe();

function ffprobeDuration(file) {
  if (!FFPROBE_AVAILABLE) return null;
  try {
    const r = spawnSync('ffprobe', [
      '-v', 'error',
      '-show_entries', 'format=duration',
      '-of', 'default=noprint_wrappers=1:nokey=1',
      file,
    ], { encoding: 'utf8', timeout: 5000 });
    if (r.status !== 0) return null;
    const sec = parseFloat(String(r.stdout).trim());
    return Number.isFinite(sec) ? +sec.toFixed(2) : null;
  } catch { return null; }
}

function shouldSkipDir(p) {
  return EXCLUDE_SUBSTRINGS.some((s) => p.includes(s));
}

function* walk(root) {
  const stack = [root];
  while (stack.length) {
    const dir = stack.pop();
    if (shouldSkipDir(dir + '/')) continue;
    let entries;
    try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
    catch { continue; }
    for (const e of entries) {
      const full = path.join(dir, e.name);
      if (e.isSymbolicLink()) continue;
      if (e.isDirectory()) {
        if (e.name.startsWith('.') && !['..', '.claude'].includes(e.name)) continue; // skip dotdirs except .claude itself
        stack.push(full);
      } else if (e.isFile() && e.name.toLowerCase().endsWith('.mp3')) {
        yield full;
      }
    }
  }
}

function id(p) {
  return crypto.createHash('sha1').update(p).digest('hex').slice(0, 12);
}

const items = [];
let scanned = 0;
const t0 = Date.now();
for (const root of SCAN_ROOTS) {
  if (!fs.existsSync(root)) {
    console.error(`(skip) root missing: ${root}`);
    continue;
  }
  console.error(`scanning ${root} ...`);
  for (const file of walk(root)) {
    scanned++;
    let stat;
    try { stat = fs.statSync(file); } catch { continue; }
    const parts = file.split(path.sep);
    const parent = parts[parts.length - 2] || '';
    const parent2 = parts[parts.length - 3] || '';
    const name = path.basename(file, '.mp3');
    const duration = ffprobeDuration(file);
    items.push({
      id: id(file),
      path: file,
      name,
      parent,
      parent2,
      size: stat.size,
      mtime: stat.mtimeMs,
      mtimeISO: new Date(stat.mtimeMs).toISOString(),
      duration,
    });
    if (scanned % 25 === 0) process.stderr.write(`  scanned ${scanned} ...\n`);
  }
}

// Newest first by default in the manifest.
items.sort((a, b) => b.mtime - a.mtime);

fs.writeFileSync(OUT_FILE, JSON.stringify({
  generatedAt: new Date().toISOString(),
  ffprobe: FFPROBE_AVAILABLE,
  count: items.length,
  totalSizeBytes: items.reduce((a, b) => a + b.size, 0),
  items,
}, null, 2));

const ms = Date.now() - t0;
console.error(`\n  wrote ${items.length} mp3 entries → ${OUT_FILE}  (${ms}ms, ffprobe=${FFPROBE_AVAILABLE})`);