/** * Soundtrack (Song Disks) service helpers. * * Storage layout: * MP3 files live at public/swf/dcr/hof_furni/mp3/{code}.mp3 * Public URL: /swf/dcr/hof_furni/mp3/{code}.mp3 * * This is the same directory used by Arcturus for the trax sample set * (`sound_machine_sample_.mp3`). Keeping uploads alongside existing * samples means they resolve via the same URL base and play without * extra middleware config. * * The public URL prefix is configurable via the CMS setting * `soundtrack_base_url` — see site-settings.ts defaults. */ import { promises as fs } from "node:fs"; import path from "node:path"; import { parseBuffer } from "music-metadata"; // ── Constants ────────────────────────────────────────────────────── /** On-disk directory where MP3s live. Fixed (not configurable from UI to * avoid any path traversal risk). */ export const SOUNDTRACK_DIR = path.join( process.cwd(), "public", "swf", "dcr", "hof_furni", "mp3", ); /** Default public URL prefix (with trailing slash). Can be overridden via * the `soundtrack_base_url` CMS setting. */ export const DEFAULT_SOUNDTRACK_BASE_URL = "/swf/dcr/hof_furni/mp3/"; /** 10 MB hard limit per upload. */ export const MAX_SOUNDTRACK_SIZE = 10 * 1024 * 1024; // ── File paths ───────────────────────────────────────────────────── /** Reject codes that would escape the sounds directory or use unsafe chars. */ export function isSafeSoundtrackCode(code: string): boolean { return /^[a-zA-Z0-9_-]{1,32}$/.test(code); } export function getSoundtrackPath(code: string): string { if (!isSafeSoundtrackCode(code)) { throw new Error(`Unsafe soundtrack code: ${code}`); } return path.join(SOUNDTRACK_DIR, `${code}.mp3`); } /** Ensure the sounds directory exists (mkdir -p). */ export async function ensureSoundtrackDir(): Promise { await fs.mkdir(SOUNDTRACK_DIR, { recursive: true }); } // ── Validation ───────────────────────────────────────────────────── /** * Verify the buffer starts with an MP3 header. * Accepts: * - ID3v2 tag header ("ID3") * - Raw MPEG sync byte 0xFF followed by 0xFA / 0xFB / 0xFE / 0xF3 / 0xF2 * (covers MPEG-1/2 Layer III common variants) * * This is a best-effort check — not a full parse — but it rejects arbitrary * files masquerading as .mp3 (e.g. renamed .exe). */ export function validateMp3Bytes(buf: Buffer): boolean { if (buf.length < 4) return false; // ID3v2 tag header if (buf[0] === 0x49 && buf[1] === 0x44 && buf[2] === 0x33) return true; // MPEG sync: 0xFF followed by 0xFA/0xFB/0xFE/0xF3/0xF2 if (buf[0] === 0xff) { const b = buf[1]; if (b === 0xfa || b === 0xfb || b === 0xfe || b === 0xf3 || b === 0xf2) return true; } return false; } /** * Extract MP3 duration in seconds (integer, rounded). * Returns 0 if metadata cannot be parsed. */ export async function extractMp3Duration(buf: Buffer): Promise { try { const meta = await parseBuffer( buf, { mimeType: "audio/mpeg" }, { duration: true }, ); const seconds = meta.format.duration; if ( typeof seconds !== "number" || !Number.isFinite(seconds) || seconds <= 0 ) return 0; return Math.round(seconds); } catch { return 0; } } // ── File I/O ─────────────────────────────────────────────────────── /** * Write the MP3 payload to disk under the given code. * Creates the parent directory if missing. */ export async function writeSoundtrackFile( code: string, buf: Buffer, ): Promise { await ensureSoundtrackDir(); await fs.writeFile(getSoundtrackPath(code), buf); } /** * Remove the MP3 file for the given code. No-op if the file is already gone. */ export async function deleteSoundtrackFile(code: string): Promise { try { await fs.unlink(getSoundtrackPath(code)); } catch (err) { const errCode = (err as NodeJS.ErrnoException).code; if (errCode === "ENOENT") return; throw err; } } export type { TraxChannel, TraxEvent } from "@/lib/trax-format"; // Re-export the Trax format parser for server-side use. The actual // implementation lives in `@/lib/trax-format` so it can be imported by // client components without pulling in Node-only deps from this file. export { detectSoundtrackKind, parseTrackSamples, parseTraxChannels, TRAX_TICK_SECONDS, } from "@/lib/trax-format";