| 1 | /**
|
|---|
| 2 | * Audio transcoder — convert uploads to a uniform mp3 format.
|
|---|
| 3 | *
|
|---|
| 4 | * Settings: 192 kbps CBR, stereo, 44.1 kHz. Reasonable balance of size
|
|---|
| 5 | * (~1.4 MB/min) and quality for music. Any input format ffmpeg can read
|
|---|
| 6 | * is accepted (mp3, m4a, ogg, opus, flac, wav, webm, etc).
|
|---|
| 7 | *
|
|---|
| 8 | * The transcode pipeline:
|
|---|
| 9 | * 1. Read source from `inputPath` (the multer-stored upload)
|
|---|
| 10 | * 2. Write transcoded mp3 to a tmp file in the same directory
|
|---|
| 11 | * 3. On success: delete original, rename tmp to final `<id>.mp3`
|
|---|
| 12 | * 4. On failure: delete tmp (if exists), KEEP original so caller can
|
|---|
| 13 | * decide what to do (we don't want to lose user data on ffmpeg quirks)
|
|---|
| 14 | *
|
|---|
| 15 | * Tags (title/artist/album) are baked into the mp3 ID3v2 metadata so a
|
|---|
| 16 | * downloader sees them in their music player.
|
|---|
| 17 | */
|
|---|
| 18 |
|
|---|
| 19 | import path from 'path';
|
|---|
| 20 | import fs from 'fs';
|
|---|
| 21 | import { promisify } from 'util';
|
|---|
| 22 | import ffmpeg from 'fluent-ffmpeg';
|
|---|
| 23 |
|
|---|
| 24 | // ffmpeg-static is a SOFT dependency. We import it dynamically so that:
|
|---|
| 25 | // (a) If the package isn't installed (e.g. dev environment), we don't crash
|
|---|
| 26 | // (b) If its postinstall failed to download the binary (sandboxed CI,
|
|---|
| 27 | // proxy, GitHub release CDN issues), we don't crash
|
|---|
| 28 | // In either case we fall back to whatever `ffmpeg` is on PATH. fluent-ffmpeg
|
|---|
| 29 | // will pick that up automatically when no explicit path is set.
|
|---|
| 30 | //
|
|---|
| 31 | // `await import()` of a CommonJS package on Node returns a Module Namespace
|
|---|
| 32 | // Object. ffmpeg-static exports `module.exports = "/path/to/ffmpeg.exe"` —
|
|---|
| 33 | // a bare string — which Node wraps so the actual path lives at .default.
|
|---|
| 34 | // On some Node versions / interop shims it may also be reachable via the
|
|---|
| 35 | // raw export. We try several shapes to be defensive.
|
|---|
| 36 | let FFMPEG_BIN = null;
|
|---|
| 37 | try {
|
|---|
| 38 | const mod = await import('ffmpeg-static');
|
|---|
| 39 | // Prefer .default (Node ESM-CJS interop), then root (older interop), then
|
|---|
| 40 | // .path (some forks). Reject anything that isn't a string or doesn't exist.
|
|---|
| 41 | const candidates = [mod?.default, mod, mod?.path];
|
|---|
| 42 | for (const c of candidates) {
|
|---|
| 43 | if (typeof c === 'string' && c.length > 0) {
|
|---|
| 44 | try {
|
|---|
| 45 | if (fs.statSync(c).isFile()) { FFMPEG_BIN = c; break; }
|
|---|
| 46 | } catch { /* candidate doesn't exist on disk, try next */ }
|
|---|
| 47 | }
|
|---|
| 48 | }
|
|---|
| 49 | } catch (e) {
|
|---|
| 50 | console.warn('[audio-transcoder] ffmpeg-static import failed:', e.message);
|
|---|
| 51 | }
|
|---|
| 52 |
|
|---|
| 53 | if (FFMPEG_BIN) {
|
|---|
| 54 | ffmpeg.setFfmpegPath(FFMPEG_BIN);
|
|---|
| 55 | console.log('[audio-transcoder] using ffmpeg-static binary at', FFMPEG_BIN);
|
|---|
| 56 | } else {
|
|---|
| 57 | console.warn('[audio-transcoder] ffmpeg-static not available — using system ffmpeg from PATH.');
|
|---|
| 58 | }
|
|---|
| 59 |
|
|---|
| 60 | const unlink = promisify(fs.unlink);
|
|---|
| 61 | const rename = promisify(fs.rename);
|
|---|
| 62 | const stat = promisify(fs.stat);
|
|---|
| 63 |
|
|---|
| 64 | /**
|
|---|
| 65 | * Transcode a source audio file to 192kbps stereo mp3.
|
|---|
| 66 | *
|
|---|
| 67 | * @param {object} opts
|
|---|
| 68 | * @param {string} opts.inputPath Absolute path of the source file (will be deleted on success).
|
|---|
| 69 | * @param {string} opts.outputDir Directory to write the final mp3 into.
|
|---|
| 70 | * @param {string} opts.outputBaseName Base filename WITHOUT extension; ".mp3" is added.
|
|---|
| 71 | * @param {object} [opts.tags] Optional ID3 tags { title, artist, album }.
|
|---|
| 72 | *
|
|---|
| 73 | * @returns {Promise<{filename: string, path: string, size: number, mimeType: 'audio/mpeg'}>}
|
|---|
| 74 | * On success the original `inputPath` has been deleted.
|
|---|
| 75 | *
|
|---|
| 76 | * @throws Error if ffmpeg fails, the input is unreadable, or the output is empty.
|
|---|
| 77 | * On error: tmp file cleaned up; original is left in place.
|
|---|
| 78 | */
|
|---|
| 79 | export async function transcodeToMp3({ inputPath, outputDir, outputBaseName, tags = {} }) {
|
|---|
| 80 | if (!inputPath || !outputDir || !outputBaseName) {
|
|---|
| 81 | throw new Error('transcodeToMp3: inputPath, outputDir, outputBaseName required');
|
|---|
| 82 | }
|
|---|
| 83 | // Sanity check input exists
|
|---|
| 84 | await stat(inputPath); // throws ENOENT if missing — let caller handle
|
|---|
| 85 |
|
|---|
| 86 | const finalFilename = `${outputBaseName}.mp3`;
|
|---|
| 87 | const finalPath = path.join(outputDir, finalFilename);
|
|---|
| 88 | // Tmp lives next to final so the rename is atomic on most filesystems
|
|---|
| 89 | // (same partition guaranteed). Suffix avoids collisions between concurrent transcodes.
|
|---|
| 90 | const tmpPath = path.join(outputDir, `${outputBaseName}.transcoding-${process.pid}.mp3`);
|
|---|
| 91 |
|
|---|
| 92 | let durationSec = null;
|
|---|
| 93 | try {
|
|---|
| 94 | const r = await runFfmpeg({ inputPath, tmpPath, tags });
|
|---|
| 95 | durationSec = r && r.durationSec != null ? r.durationSec : null;
|
|---|
| 96 |
|
|---|
| 97 | // Verify the output is not zero bytes — ffmpeg sometimes "succeeds" but
|
|---|
| 98 | // produces empty output for unreadable inputs. Better to fail loudly here.
|
|---|
| 99 | const outStat = await stat(tmpPath);
|
|---|
| 100 | if (outStat.size === 0) {
|
|---|
| 101 | throw new Error('Transcoded output is empty (input may be corrupt)');
|
|---|
| 102 | }
|
|---|
| 103 |
|
|---|
| 104 | // Atomic move: tmp -> final. If a file already exists at final (shouldn't
|
|---|
| 105 | // happen because outputBaseName is a fresh uuid) rename overwrites on POSIX
|
|---|
| 106 | // and on Windows from Node 18+.
|
|---|
| 107 | await rename(tmpPath, finalPath);
|
|---|
| 108 |
|
|---|
| 109 | // Original is no longer needed — delete it. If this fails we still keep
|
|---|
| 110 | // the transcoded mp3; the original will just be orphaned (not catastrophic).
|
|---|
| 111 | //
|
|---|
| 112 | // CRITICAL: if the input was already an .mp3, multer stored it as
|
|---|
| 113 | // <uuid>.mp3 and our finalFilename is also <uuid>.mp3 — same path. The
|
|---|
| 114 | // rename() above already replaced the original file with the transcoded
|
|---|
| 115 | // version, so deleting inputPath here would delete the FINAL file.
|
|---|
| 116 | // Use path.resolve to compare normalised forms (handles Windows
|
|---|
| 117 | // case-insensitivity and slash flavour).
|
|---|
| 118 | if (path.resolve(inputPath) !== path.resolve(finalPath)) {
|
|---|
| 119 | try { await unlink(inputPath); }
|
|---|
| 120 | catch (e) { console.warn('[audio-transcoder] could not delete original:', inputPath, e.message); }
|
|---|
| 121 | }
|
|---|
| 122 |
|
|---|
| 123 | return {
|
|---|
| 124 | filename: finalFilename,
|
|---|
| 125 | path: finalPath,
|
|---|
| 126 | size: outStat.size,
|
|---|
| 127 | mimeType: 'audio/mpeg',
|
|---|
| 128 | durationSec, // whole seconds from ffmpeg's codecData (null if unknown)
|
|---|
| 129 | };
|
|---|
| 130 |
|
|---|
| 131 | } catch (err) {
|
|---|
| 132 | // Clean up tmp if it exists; leave original alone so the caller can
|
|---|
| 133 | // surface an error and the user's upload isn't lost.
|
|---|
| 134 | try { await unlink(tmpPath); } catch { /* tmp may not exist */ }
|
|---|
| 135 | throw err;
|
|---|
| 136 | }
|
|---|
| 137 | }
|
|---|
| 138 |
|
|---|
| 139 | /**
|
|---|
| 140 | * Rewrite the ID3 tags of an EXISTING mp3 without re-encoding (`-c copy`).
|
|---|
| 141 | * Used when editing track metadata (title/artist/album/credit/license) so that
|
|---|
| 142 | * ownership info travels with the file on download.
|
|---|
| 143 | * ffmpeg cannot edit in-place → write to tmp and atomically rename back.
|
|---|
| 144 | */
|
|---|
| 145 | export async function retagMp3({ filePath, tags = {} }) {
|
|---|
| 146 | if (!filePath) throw new Error('retagMp3: filePath required');
|
|---|
| 147 | await stat(filePath); // throws if file is missing
|
|---|
| 148 | const dir = path.dirname(filePath);
|
|---|
| 149 | const base = path.basename(filePath, path.extname(filePath));
|
|---|
| 150 | const tmpPath = path.join(dir, `${base}.retag-${process.pid}.mp3`);
|
|---|
| 151 | try {
|
|---|
| 152 | await new Promise((resolve, reject) => {
|
|---|
| 153 | const cmd = ffmpeg(filePath)
|
|---|
| 154 | .audioCodec('copy') // no re-encode → fast, no quality loss
|
|---|
| 155 | .format('mp3')
|
|---|
| 156 | .outputOptions('-id3v2_version', '3')
|
|---|
| 157 | .outputOptions('-map_metadata', '-1')
|
|---|
| 158 | .outputOptions('-vn');
|
|---|
| 159 | if (tags.title) cmd.outputOptions('-metadata', `title=${tags.title}`);
|
|---|
| 160 | if (tags.artist) cmd.outputOptions('-metadata', `artist=${tags.artist}`);
|
|---|
| 161 | if (tags.album) cmd.outputOptions('-metadata', `album=${tags.album}`);
|
|---|
| 162 | if (tags.copyright) cmd.outputOptions('-metadata', `copyright=${tags.copyright}`);
|
|---|
| 163 | if (tags.comment) cmd.outputOptions('-metadata', `comment=${tags.comment}`);
|
|---|
| 164 | cmd.on('error', (err, so, se) => reject(new Error(((err && err.message) || 'ffmpeg') + (se ? ' | ' + se : ''))))
|
|---|
| 165 | .on('end', () => resolve())
|
|---|
| 166 | .save(tmpPath);
|
|---|
| 167 | });
|
|---|
| 168 | const s = await stat(tmpPath);
|
|---|
| 169 | if (s.size === 0) throw new Error('retag output is empty');
|
|---|
| 170 | await rename(tmpPath, filePath);
|
|---|
| 171 | return { filePath, size: s.size };
|
|---|
| 172 | } catch (err) {
|
|---|
| 173 | try { await unlink(tmpPath); } catch { /* tmp may not exist */ }
|
|---|
| 174 | throw err;
|
|---|
| 175 | }
|
|---|
| 176 | }
|
|---|
| 177 |
|
|---|
| 178 | /**
|
|---|
| 179 | * Run a single ffmpeg pass: input -> tmp output.
|
|---|
| 180 | * Returns a promise that resolves when ffmpeg exits cleanly, rejects otherwise.
|
|---|
| 181 | */
|
|---|
| 182 | function runFfmpeg({ inputPath, tmpPath, tags }) {
|
|---|
| 183 | return new Promise((resolve, reject) => {
|
|---|
| 184 | let durationSec = null;
|
|---|
| 185 | const cmd = ffmpeg(inputPath)
|
|---|
| 186 | .audioCodec('libmp3lame')
|
|---|
| 187 | .audioBitrate('192k') // CBR — easier seeking than VBR for our small <50MB files
|
|---|
| 188 | .audioChannels(2) // force stereo (mono inputs get duplicated; multichannel downmixed)
|
|---|
| 189 | .audioFrequency(44100) // 44.1 kHz: standard for music
|
|---|
| 190 | .format('mp3')
|
|---|
| 191 | // ID3v2.3 is the most widely-supported tag version (Windows Explorer,
|
|---|
| 192 | // older players). v2.4 has UTF-8 support but breaks some clients.
|
|---|
| 193 | .outputOptions('-id3v2_version', '3')
|
|---|
| 194 | // Always rewrite tags from scratch — don't let stale frames from the
|
|---|
| 195 | // input file leak through.
|
|---|
| 196 | .outputOptions('-map_metadata', '-1')
|
|---|
| 197 | // Strip video/album-art streams. We attach our own cover separately
|
|---|
| 198 | // (via the audio_tracks.cover_url column). Embedding here would just
|
|---|
| 199 | // bloat the mp3.
|
|---|
| 200 | .outputOptions('-vn');
|
|---|
| 201 |
|
|---|
| 202 | // Bake tags as ID3 frames if the caller provided any.
|
|---|
| 203 | // CRITICAL: pass `-metadata` and the `key=value` string as TWO separate
|
|---|
| 204 | // arguments so fluent-ffmpeg sends them as two argv slots. If we pass
|
|---|
| 205 | // them as a single string fluent-ffmpeg splits on whitespace, which
|
|---|
| 206 | // breaks any value containing a space (e.g. "Test Artist" gets parsed
|
|---|
| 207 | // as a separate output filename).
|
|---|
| 208 | if (tags.title) cmd.outputOptions('-metadata', `title=${tags.title}`);
|
|---|
| 209 | if (tags.artist) cmd.outputOptions('-metadata', `artist=${tags.artist}`);
|
|---|
| 210 | if (tags.album) cmd.outputOptions('-metadata', `album=${tags.album}`);
|
|---|
| 211 | if (tags.copyright) cmd.outputOptions('-metadata', `copyright=${tags.copyright}`); // ID3 TCOP — owner/credit
|
|---|
| 212 | if (tags.comment) cmd.outputOptions('-metadata', `comment=${tags.comment}`); // ID3 COMM — license
|
|---|
| 213 |
|
|---|
| 214 | cmd
|
|---|
| 215 | // codecData gives the INPUT duration as "HH:MM:SS.xx" — this lets us
|
|---|
| 216 | // determine the track length automatically without a separate ffprobe binary.
|
|---|
| 217 | .on('codecData', (data) => { durationSec = parseHmsToSeconds(data && data.duration); })
|
|---|
| 218 | .on('error', (err, stdout, stderr) => {
|
|---|
| 219 | // ffmpeg's stderr is the most useful diagnostic. fluent-ffmpeg's
|
|---|
| 220 | // err.message is usually a short summary; we glue stderr on so the
|
|---|
| 221 | // log captures the actual failure reason.
|
|---|
| 222 | const reason = err.message || 'ffmpeg failed';
|
|---|
| 223 | const tail = (stderr || '').split('\n').slice(-6).join('\n').trim();
|
|---|
| 224 | reject(new Error(`Transcode failed: ${reason}${tail ? '\n' + tail : ''}`));
|
|---|
| 225 | })
|
|---|
| 226 | .on('end', () => resolve({ durationSec }))
|
|---|
| 227 | .save(tmpPath);
|
|---|
| 228 | });
|
|---|
| 229 | }
|
|---|
| 230 |
|
|---|
| 231 | /**
|
|---|
| 232 | * Parse an ffmpeg duration string "HH:MM:SS.xx" to whole seconds. Returns null
|
|---|
| 233 | * for "N/A" or an unexpected format.
|
|---|
| 234 | */
|
|---|
| 235 | function parseHmsToSeconds(hms) {
|
|---|
| 236 | if (!hms || typeof hms !== 'string') return null;
|
|---|
| 237 | const m = hms.match(/^(\d+):(\d{2}):(\d{2})(?:\.(\d+))?$/);
|
|---|
| 238 | if (!m) return null;
|
|---|
| 239 | const sec = (+m[1]) * 3600 + (+m[2]) * 60 + (+m[3]) + (m[4] ? Number('0.' + m[4]) : 0);
|
|---|
| 240 | return Number.isFinite(sec) ? Math.round(sec) : null;
|
|---|
| 241 | }
|
|---|
| 242 |
|
|---|
| 243 | /**
|
|---|
| 244 | * Read the duration (whole seconds) of an audio file WITHOUT transcoding.
|
|---|
| 245 | * Starts an ffmpeg pass and reads only the codecData event (duration), then
|
|---|
| 246 | * kills the process immediately — fast and without a separate ffprobe binary
|
|---|
| 247 | * (ffmpeg-static ships only ffmpeg). Intended for the backfill script.
|
|---|
| 248 | * @returns {Promise<number|null>}
|
|---|
| 249 | */
|
|---|
| 250 | export function probeDuration(filePath) {
|
|---|
| 251 | return new Promise((resolve) => {
|
|---|
| 252 | let durationSec = null, done = false;
|
|---|
| 253 | const finish = () => { if (!done) { done = true; resolve(durationSec); } };
|
|---|
| 254 | const cmd = ffmpeg(filePath)
|
|---|
| 255 | .on('codecData', (data) => {
|
|---|
| 256 | durationSec = parseHmsToSeconds(data && data.duration);
|
|---|
| 257 | try { cmd.kill('SIGKILL'); } catch { /* already done */ }
|
|---|
| 258 | finish();
|
|---|
| 259 | })
|
|---|
| 260 | .on('error', finish)
|
|---|
| 261 | .on('end', finish)
|
|---|
| 262 | .format('null')
|
|---|
| 263 | .save(process.platform === 'win32' ? 'NUL' : '/dev/null');
|
|---|
| 264 | });
|
|---|
| 265 | }
|
|---|
| 266 |
|
|---|
| 267 | export default { transcodeToMp3, probeDuration };
|
|---|