feat: add audio conversion script and enhance audio token handling for .opus files
All checks were successful
Build and Push Docker Image / docker (push) Successful in 21s
All checks were successful
Build and Push Docker Image / docker (push) Successful in 21s
This commit is contained in:
@@ -8,6 +8,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node src/server/index.js",
|
"start": "node src/server/index.js",
|
||||||
"dev": "nodemon 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": "node scripts/resolve-years.js",
|
||||||
"years:resolve:10": "node scripts/resolve-years.js --max 10",
|
"years:resolve:10": "node scripts/resolve-years.js --max 10",
|
||||||
"years:force": "node scripts/resolve-years.js --force",
|
"years:force": "node scripts/resolve-years.js --force",
|
||||||
@@ -27,6 +29,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.11.1",
|
"@eslint/js": "^9.11.1",
|
||||||
|
"ffmpeg-static": "^5.2.0",
|
||||||
"eslint": "^9.11.1",
|
"eslint": "^9.11.1",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"eslint-config-prettier": "^9.1.0",
|
||||||
"eslint-plugin-import": "^2.29.1",
|
"eslint-plugin-import": "^2.29.1",
|
||||||
|
|||||||
162
scripts/convert-to-opus.js
Normal file
162
scripts/convert-to-opus.js
Normal file
@@ -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();
|
||||||
@@ -1,12 +1,23 @@
|
|||||||
|
import path from 'path';
|
||||||
import { TOKEN_TTL_MS_DEFAULT, putToken, getToken } from './audio/tokenStore.js';
|
import { TOKEN_TTL_MS_DEFAULT, putToken, getToken } from './audio/tokenStore.js';
|
||||||
import { resolveSafePath, fileExists, statFile, getMimeType } from './audio/pathUtils.js';
|
import { resolveSafePath, fileExists, statFile, getMimeType } from './audio/pathUtils.js';
|
||||||
import { headFile, streamFile } from './audio/streaming.js';
|
import { headFile, streamFile } from './audio/streaming.js';
|
||||||
import { getCoverForFile } from './audio/coverService.js';
|
import { getCoverForFile } from './audio/coverService.js';
|
||||||
|
|
||||||
export function createAudioToken(name, ttlMs = TOKEN_TTL_MS_DEFAULT) {
|
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 (!resolved) throw new Error('Invalid path');
|
||||||
if (!fileExists(resolved)) throw new Error('Not found');
|
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 stat = statFile(resolved);
|
||||||
const type = getMimeType(resolved, 'audio/mpeg');
|
const type = getMimeType(resolved, 'audio/mpeg');
|
||||||
return putToken({ path: resolved, mime: type, size: stat.size }, ttlMs);
|
return putToken({ path: resolved, mime: type, size: stat.size }, ttlMs);
|
||||||
|
|||||||
@@ -29,5 +29,8 @@ export function statFile(p) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getMimeType(p, fallback = 'audio/mpeg') {
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,23 @@ import { loadYearsIndex } from './years.js';
|
|||||||
|
|
||||||
export async function listTracks() {
|
export async function listTracks() {
|
||||||
const years = loadYearsIndex();
|
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(
|
const tracks = await Promise.all(
|
||||||
files.map(async (f) => {
|
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 year = null;
|
||||||
let title = path.parse(f).name;
|
let title = path.parse(f).name;
|
||||||
let artist = '';
|
let artist = '';
|
||||||
@@ -19,8 +32,8 @@ export async function listTracks() {
|
|||||||
artist = meta.common.artist || artist;
|
artist = meta.common.artist || artist;
|
||||||
year = meta.common.year || null;
|
year = meta.common.year || null;
|
||||||
} catch {}
|
} catch {}
|
||||||
const y = years[f]?.year ?? year;
|
const y = years[f]?.year ?? years[chosen]?.year ?? year;
|
||||||
return { id: f, file: f, title, artist, year: y };
|
return { id: chosen, file: chosen, title, artist, year: y };
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
return tracks;
|
return tracks;
|
||||||
|
|||||||
Reference in New Issue
Block a user