source: Klonkt/src/services/AudioTranscoder.js@ a3169f5

main
Last change on this file since a3169f5 was 7bc636b, checked in by Robin <robin@…>, 4 months ago

Initial commit — PrutFolio v1 source (pulled from Hetzner /srv/prutfolio)

  • Property mode set to 100644
File size: 8.0 KB
Line 
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
19import path from 'path';
20import fs from 'fs';
21import { promisify } from 'util';
22import 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.
36let FFMPEG_BIN = null;
37try {
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
53if (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
60const unlink = promisify(fs.unlink);
61const rename = promisify(fs.rename);
62const 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 */
79export 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 try {
93 await runFfmpeg({ inputPath, tmpPath, tags });
94
95 // Verify the output is not zero bytes — ffmpeg sometimes "succeeds" but
96 // produces empty output for unreadable inputs. Better to fail loudly here.
97 const outStat = await stat(tmpPath);
98 if (outStat.size === 0) {
99 throw new Error('Transcoded output is empty (input may be corrupt)');
100 }
101
102 // Atomic move: tmp -> final. If a file already exists at final (shouldn't
103 // happen because outputBaseName is a fresh uuid) rename overwrites on POSIX
104 // and on Windows from Node 18+.
105 await rename(tmpPath, finalPath);
106
107 // Original is no longer needed — delete it. If this fails we still keep
108 // the transcoded mp3; the original will just be orphaned (not catastrophic).
109 //
110 // CRITICAL: if the input was already an .mp3, multer stored it as
111 // <uuid>.mp3 and our finalFilename is also <uuid>.mp3 — same path. The
112 // rename() above already replaced the original file with the transcoded
113 // version, so deleting inputPath here would delete the FINAL file.
114 // Use path.resolve to compare normalised forms (handles Windows
115 // case-insensitivity and slash flavour).
116 if (path.resolve(inputPath) !== path.resolve(finalPath)) {
117 try { await unlink(inputPath); }
118 catch (e) { console.warn('[audio-transcoder] could not delete original:', inputPath, e.message); }
119 }
120
121 return {
122 filename: finalFilename,
123 path: finalPath,
124 size: outStat.size,
125 mimeType: 'audio/mpeg',
126 };
127
128 } catch (err) {
129 // Clean up tmp if it exists; leave original alone so the caller can
130 // surface an error and the user's upload isn't lost.
131 try { await unlink(tmpPath); } catch { /* tmp may not exist */ }
132 throw err;
133 }
134}
135
136/**
137 * Run a single ffmpeg pass: input -> tmp output.
138 * Returns a promise that resolves when ffmpeg exits cleanly, rejects otherwise.
139 */
140function runFfmpeg({ inputPath, tmpPath, tags }) {
141 return new Promise((resolve, reject) => {
142 const cmd = ffmpeg(inputPath)
143 .audioCodec('libmp3lame')
144 .audioBitrate('192k') // CBR — easier seeking than VBR for our small <50MB files
145 .audioChannels(2) // force stereo (mono inputs get duplicated; multichannel downmixed)
146 .audioFrequency(44100) // 44.1 kHz: standard for music
147 .format('mp3')
148 // ID3v2.3 is the most widely-supported tag version (Windows Explorer,
149 // older players). v2.4 has UTF-8 support but breaks some clients.
150 .outputOptions('-id3v2_version', '3')
151 // Always rewrite tags from scratch — don't let stale frames from the
152 // input file leak through.
153 .outputOptions('-map_metadata', '-1')
154 // Strip video/album-art streams. We attach our own cover separately
155 // (via the audio_tracks.cover_url column). Embedding here would just
156 // bloat the mp3.
157 .outputOptions('-vn');
158
159 // Bake tags as ID3 frames if the caller provided any.
160 // CRITICAL: pass `-metadata` and the `key=value` string as TWO separate
161 // arguments so fluent-ffmpeg sends them as two argv slots. If we pass
162 // them as a single string fluent-ffmpeg splits on whitespace, which
163 // breaks any value containing a space (e.g. "Test Artist" gets parsed
164 // as a separate output filename).
165 if (tags.title) cmd.outputOptions('-metadata', `title=${tags.title}`);
166 if (tags.artist) cmd.outputOptions('-metadata', `artist=${tags.artist}`);
167 if (tags.album) cmd.outputOptions('-metadata', `album=${tags.album}`);
168
169 cmd
170 .on('error', (err, stdout, stderr) => {
171 // ffmpeg's stderr is the most useful diagnostic. fluent-ffmpeg's
172 // err.message is usually a short summary; we glue stderr on so the
173 // log captures the actual failure reason.
174 const reason = err.message || 'ffmpeg failed';
175 const tail = (stderr || '').split('\n').slice(-6).join('\n').trim();
176 reject(new Error(`Transcode failed: ${reason}${tail ? '\n' + tail : ''}`));
177 })
178 .on('end', () => resolve())
179 .save(tmpPath);
180 });
181}
182
183export default { transcodeToMp3 };
Note: See TracBrowser for help on using the repository browser.