source: Klonkt/src/services/AudioTranscoder.js@ 0d7acdf

main
Last change on this file since 0d7acdf was 0d7acdf, checked in by roboburr <roboburr@…>, 3 months ago

feat(audio): per-track owner/credit + license (+ written to mp3 ID3 tags)

New per-track metadata: credit (copyright holder) + license. Editable in the
track editor (license with datalist presets: All rights reserved, CC BY/…/CC0).

  • DB: audio_tracks.credit + .license.
  • ID3: on upload and on every metadata edit the tags are written into the mp3 itself — copyright=credit, comment=license (new retagMp3() in the transcoder, -c copy, no re-encode) → ownership travels with a download.
  • Visible: "credit · license" line below each track (post-audio-track).

busters audio.css?v=7.

Co-Authored-By: Claude <noreply@…>

  • Property mode set to 100644
File size: 11.9 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 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, // hele seconden uit ffmpeg's codecData (null als onbekend)
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 * Herschrijf de ID3-tags van een BESTAANDE mp3 zonder her-encoden (`-c copy`).
141 * Gebruikt bij het bewerken van track-metadata (titel/artiest/album/credit/licentie)
142 * zodat de eigendomsinfo in het bestand zelf meereist bij een download.
143 * ffmpeg kan niet in-place editen → schrijf naar tmp en hernoem atomisch terug.
144 */
145export async function retagMp3({ filePath, tags = {} }) {
146 if (!filePath) throw new Error('retagMp3: filePath required');
147 await stat(filePath); // throws als 't bestand mist
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') // geen her-encode → snel, geen kwaliteitsverlies
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 leeg');
170 await rename(tmpPath, filePath);
171 return { filePath, size: s.size };
172 } catch (err) {
173 try { await unlink(tmpPath); } catch { /* tmp bestaat mogelijk niet */ }
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 */
182function 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 — eigenaar/credit
212 if (tags.comment) cmd.outputOptions('-metadata', `comment=${tags.comment}`); // ID3 COMM — licentie
213
214 cmd
215 // codecData geeft de duur van de INPUT als "HH:MM:SS.xx" — zo bepalen we
216 // de tracklengte automatisch zonder aparte 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 een ffmpeg-duurstring "HH:MM:SS.xx" naar hele seconden. Geeft null bij
233 * "N/A" of een onverwacht formaat.
234 */
235function 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 * Lees de duur (hele seconden) van een audiobestand ZONDER te transcoderen.
245 * Start een ffmpeg-pass en leest enkel het codecData-event (duur), waarna we het
246 * proces direct stoppen — snel en zonder aparte ffprobe-binary (ffmpeg-static
247 * levert alleen ffmpeg). Bedoeld voor het backfill-script.
248 * @returns {Promise<number|null>}
249 */
250export 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 { /* al klaar */ }
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
267export default { transcodeToMp3, probeDuration };
Note: See TracBrowser for help on using the repository browser.