diff --git a/package.json b/package.json index 3b2cf15..03d1c5a 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,8 @@ "scripts": { "start": "node src/server/index.js", "dev": "nodemon src/server/index.js", + "audio:convert": "node scripts/convert-to-opus.js", + "audio:convert:dry": "node scripts/convert-to-opus.js --dry-run", "years:resolve": "node scripts/resolve-years.js", "years:resolve:10": "node scripts/resolve-years.js --max 10", "years:force": "node scripts/resolve-years.js --force", @@ -27,6 +29,7 @@ }, "devDependencies": { "@eslint/js": "^9.11.1", + "ffmpeg-static": "^5.2.0", "eslint": "^9.11.1", "eslint-config-prettier": "^9.1.0", "eslint-plugin-import": "^2.29.1", diff --git a/scripts/convert-to-opus.js b/scripts/convert-to-opus.js new file mode 100644 index 0000000..15bb6ed --- /dev/null +++ b/scripts/convert-to-opus.js @@ -0,0 +1,162 @@ +#!/usr/bin/env node +// Batch convert audio files in data/ to Opus using ffmpeg +// Usage: node scripts/convert-to-opus.js [--force] [--dry-run] [--bitrate 96] [--concurrency 2] +// Requires: ffmpeg in PATH or dev dependency ffmpeg-static + +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { spawn } from 'child_process'; +import { createRequire } from 'module'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const ROOT = path.resolve(__dirname, '..'); +const DATA_DIR = path.resolve(ROOT, 'data'); + +function parseArgs(argv) { + const args = { force: false, dryRun: false, bitrate: 96, concurrency: 2 }; + const skip = new Set(); + for (let i = 2; i < argv.length; i++) { + if (skip.has(i)) continue; + const a = argv[i]; + if (a === '--force' || a === '-f') args.force = true; + else if (a === '--dry-run' || a === '-n') args.dryRun = true; + else if (a === '--bitrate' || a === '-b') { + const next = argv[i + 1]; + const v = Number(next); + if (!Number.isFinite(v) || v <= 0) throw new Error('Invalid --bitrate'); + args.bitrate = v; + skip.add(i + 1); + } else if (a === '--concurrency' || a === '-j') { + const next = argv[i + 1]; + const v = Number(next); + if (!Number.isFinite(v) || v <= 0) throw new Error('Invalid --concurrency'); + args.concurrency = Math.max(1, Math.floor(v)); + skip.add(i + 1); + } else { + throw new Error(`Unknown arg: ${a}`); + } + } + return args; +} + +function ensureDataDir() { + if (!fs.existsSync(DATA_DIR)) { + throw new Error(`Data directory not found: ${DATA_DIR}`); + } +} + +function listSources() { + const exts = ['.mp3', '.wav', '.m4a', '.ogg']; + return fs + .readdirSync(DATA_DIR) + .filter((f) => exts.includes(path.extname(f).toLowerCase())) + .map((f) => ({ src: path.join(DATA_DIR, f), base: f })); +} + +function opusPathFor(base) { + const name = base.replace(/\.[^.]+$/i, '.opus'); + return path.join(DATA_DIR, name); +} + +function findFfmpeg() { + // Prefer local ffmpeg-static if present + try { + const require = createRequire(import.meta.url); + const ff = require('ffmpeg-static'); + if (ff) return ff; + } catch {} + return 'ffmpeg'; +} + +function convertOne({ src, dst, bitrate, dryRun }) { + return new Promise((resolve) => { + const args = [ + '-y', + '-i', + src, + '-vn', + '-c:a', + 'libopus', + '-b:a', + `${bitrate}k`, + '-application', + 'audio', + dst, + ]; + + if (dryRun) { + return resolve({ ok: true, skipped: true, reason: 'dry-run', src, dst }); + } + + const ffmpegBin = findFfmpeg(); + const child = spawn(ffmpegBin, args, { stdio: ['ignore', 'pipe', 'pipe'] }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (d) => (stdout += d.toString())); + child.stderr.on('data', (d) => (stderr += d.toString())); + child.on('error', (err) => resolve({ ok: false, src, dst, error: String(err) })); + child.on('close', (code) => { + if (code === 0) resolve({ ok: true, src, dst, stdout, stderr }); + else resolve({ ok: false, src, dst, code, stdout, stderr }); + }); + }); +} + +async function run() { + try { + const { force, dryRun, bitrate, concurrency } = parseArgs(process.argv); + ensureDataDir(); + const sources = listSources(); + if (sources.length === 0) { + console.log('No source files found in data/.'); + return; + } + const jobs = sources.map((s) => { + const dst = opusPathFor(s.base); + const exists = fs.existsSync(dst); + const skip = exists && !force; + return { ...s, dst, skip }; + }); + + const toDo = jobs.filter((j) => !j.skip); + const skipped = jobs.filter((j) => j.skip); + if (skipped.length) + console.log(`Skipping ${skipped.length} existing .opus files (use --force to overwrite)`); + console.log( + `Converting ${toDo.length} file(s) to Opus @ ${bitrate} kbps with concurrency ${concurrency}...` + ); + + let ok = 0; + let fail = 0; + const queue = [...toDo]; + const workers = new Array(Math.min(concurrency, queue.length)).fill(null).map(async () => { + while (queue.length) { + const job = queue.shift(); + const res = await convertOne({ src: job.src, dst: job.dst, bitrate, dryRun }); + if (res.ok) { + ok++; + console.log( + `✔ ${path.basename(job.src)} -> ${path.basename(job.dst)}${dryRun ? ' [dry-run]' : ''}` + ); + } else { + fail++; + console.error( + `✖ Failed: ${path.basename(job.src)} -> ${path.basename(job.dst)} (${res.code ?? res.error ?? 'error'})` + ); + } + } + }); + + await Promise.all(workers); + console.log( + `Done. Success: ${ok}, Failed: ${fail}, Skipped: ${skipped.length}${dryRun ? ' (dry-run)' : ''}` + ); + } catch (e) { + console.error('Error:', e.message); + process.exitCode = 1; + } +} + +run(); diff --git a/src/server/routes/audio.js b/src/server/routes/audio.js index a8df76e..c1ce7e7 100644 --- a/src/server/routes/audio.js +++ b/src/server/routes/audio.js @@ -1,12 +1,23 @@ +import path from 'path'; import { TOKEN_TTL_MS_DEFAULT, putToken, getToken } from './audio/tokenStore.js'; import { resolveSafePath, fileExists, statFile, getMimeType } from './audio/pathUtils.js'; import { headFile, streamFile } from './audio/streaming.js'; import { getCoverForFile } from './audio/coverService.js'; export function createAudioToken(name, ttlMs = TOKEN_TTL_MS_DEFAULT) { - const resolved = resolveSafePath(name); + let resolved = resolveSafePath(name); if (!resolved) throw new Error('Invalid path'); if (!fileExists(resolved)) throw new Error('Not found'); + + // Prefer .opus sibling for streaming, to save bandwidth + const ext = path.extname(resolved).toLowerCase(); + if (ext !== '.opus') { + const opusCandidate = resolved.slice(0, -ext.length) + '.opus'; + if (fileExists(opusCandidate)) { + resolved = opusCandidate; + } + } + const stat = statFile(resolved); const type = getMimeType(resolved, 'audio/mpeg'); return putToken({ path: resolved, mime: type, size: stat.size }, ttlMs); diff --git a/src/server/routes/audio/pathUtils.js b/src/server/routes/audio/pathUtils.js index ce59db4..a44c6c4 100644 --- a/src/server/routes/audio/pathUtils.js +++ b/src/server/routes/audio/pathUtils.js @@ -29,5 +29,8 @@ export function statFile(p) { } export function getMimeType(p, fallback = 'audio/mpeg') { - return mime.getType(p) || fallback; + const t = mime.getType(p) || fallback; + // Some clients expect audio/ogg for .opus in Ogg container + if (/\.opus$/i.test(p)) return 'audio/ogg'; + return t; } diff --git a/src/server/tracks.js b/src/server/tracks.js index 54bd46b..07847e6 100644 --- a/src/server/tracks.js +++ b/src/server/tracks.js @@ -6,10 +6,23 @@ import { loadYearsIndex } from './years.js'; export async function listTracks() { const years = loadYearsIndex(); - const files = fs.readdirSync(DATA_DIR).filter((f) => /\.(mp3|wav|m4a|ogg)$/i.test(f)); + const files = fs + .readdirSync(DATA_DIR) + .filter((f) => /\.(mp3|wav|m4a|ogg|opus)$/i.test(f)) + .filter((f) => { + // If both base.ext and base.opus exist, list only once (prefer .opus) + const ext = path.extname(f).toLowerCase(); + if (ext === '.opus') return true; + const opusTwin = f.replace(/\.[^.]+$/i, '.opus'); + return !fs.existsSync(path.join(DATA_DIR, opusTwin)); + }); const tracks = await Promise.all( files.map(async (f) => { - const fp = path.join(DATA_DIR, f); + // Prefer .opus for playback if exists + const ext = path.extname(f).toLowerCase(); + const opusName = ext === '.opus' ? f : f.replace(/\.[^.]+$/i, '.opus'); + const chosen = fs.existsSync(path.join(DATA_DIR, opusName)) ? opusName : f; + const fp = path.join(DATA_DIR, chosen); let year = null; let title = path.parse(f).name; let artist = ''; @@ -19,8 +32,8 @@ export async function listTracks() { artist = meta.common.artist || artist; year = meta.common.year || null; } catch {} - const y = years[f]?.year ?? year; - return { id: f, file: f, title, artist, year: y }; + const y = years[f]?.year ?? years[chosen]?.year ?? year; + return { id: chosen, file: chosen, title, artist, year: y }; }) ); return tracks;