← 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})`);