source: Klonkt/src/routes/admin-audio.js@ 2d38b22

main
Last change on this file since 2d38b22 was e2c3d09, checked in by Robin <roboburr@…>, 6 weeks ago

Media-submappen volgen nu MEDIA_PATH

De submappen voor avatars, post-images, reply-media, hero en audio-covers hadden
elk hun eigen env-variabele met een fallback naar <app>/storage/media/<sub>.
Daardoor negeerden ze MEDIA_PATH: wie zijn data buiten de checkout zette kreeg
alsnog een storage/-map in de work-tree, en uploads landden naast de code. Een
opruimstap bij een volgende deploy kan die vervolgens weggooien.

Nu leiden ze allemaal af van MEDIA_PATH via een gedeelde helper. Een eigen
override per submap wint nog steeds, dus bestaande installaties merken niets.
Dit maakt de scheiding van gebruikersdata en programmadata mogelijk met drie
regels in .env in plaats van acht.

Changed files:
src/routes/account.js

  • AVATAR_DIR via mediaDir(); dode dirname en fileURLToPath-import weg

src/routes/posts.js

  • POST_IMAGES_DIR en REPLY_MEDIA_DIR via mediaDir(); dode declaraties weg

src/routes/admin-media.js

  • POST_IMAGES_DIR en REPLY_MEDIA_DIR via mediaDir(); dode declaraties weg

src/routes/admin-settings.js

  • HERO_DIR via mediaDir(); dode declaraties weg

src/routes/admin-playlists.js

  • COVER_DIR via mediaDir(); dode dirname weg

src/routes/admin-audio.js

  • COVER_DIR via mediaDir(); AUDIO_DIR ongewijzigd (eigen wortel)

src/routes/admin-sites.js

  • PHOTO_DIR via mediaDir(), deelt bewust de avatars-map

src/routes/activitypub.js

  • AP_MEDIA_DIR via mediaDir(); ongebruikte fileURLToPath-import weg

New file:
src/config/paths.js

  • MEDIA_ROOT afgeleid van MEDIA_PATH
  • mediaDir(envVar, sub) voor submappen, met behoud van per-map overrides

DATABASE_PATH en AUDIO_PATH zijn eigen wortels en bewust ongemoeid gelaten.
Geverifieerd: 356 tests groen, en een server met externe MEDIA_PATH maakt al
zijn mappen buiten de checkout aan zonder de work-tree te raken.

-robo
Co-Authored-By: Claude Opus 5 <noreply@…>

  • Property mode set to 100644
File size: 26.7 KB
Line 
1/**
2 * Admin: Audio Tracks management — Phase C MP3 player.
3 *
4 * GET /admin/audio -> list site tracks + upload form
5 * POST /admin/audio/upload -> multer upload, insert media + audio_tracks
6 * POST /admin/audio/:id/delete -> remove track row + file on disk
7 *
8 * Files land in storage/media/audio/ (NOT served by /media static handler —
9 * everything goes through the signed /audio/stream/ route).
10 */
11
12import express from 'express';
13import multer from 'multer';
14import path from 'path';
15import fs from 'fs';
16import { fileURLToPath } from 'url';
17import { v4 as uuid } from 'uuid';
18import db from '../config/database.js';
19import { renderPage } from '../middleware/render.js';
20import { toWebp } from '../services/ImageWebpService.js';
21import { requireGod } from '../middleware/auth.js';
22import { transcodeToMp3, retagMp3 } from '../services/AudioTranscoder.js';
23import { audioUrl } from '../services/AudioStreamService.js';
24import { mediaDir } from '../config/paths.js';
25
26const __dirname = path.dirname(fileURLToPath(import.meta.url));
27// Audio files live OUTSIDE storage/media so the public /media static
28// handler can't serve them — they must go through the signed /audio/stream/
29// endpoint (anti-hotlink). Covers are public and stay in /media.
30const AUDIO_DIR = path.resolve(
31 process.env.AUDIO_PATH || path.join(__dirname, '..', '..', 'storage', 'audio')
32);
33const COVER_DIR = mediaDir('COVER_PATH', 'audio-covers');
34fs.mkdirSync(AUDIO_DIR, { recursive: true });
35fs.mkdirSync(COVER_DIR, { recursive: true });
36
37const ALLOWED_AUDIO_EXT = new Set(['.mp3', '.m4a', '.mp4', '.aac', '.oga', '.ogg', '.opus', '.flac', '.wav', '.webm']);
38const ALLOWED_COVER_EXT = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif']);
39const MAX_AUDIO_BYTES = 50 * 1024 * 1024; // 50 MB — compressed formats (mp3/m4a/ogg/…)
40const MAX_WAV_BYTES = 100 * 1024 * 1024; // 100 MB — WAV is uncompressed, so a higher limit
41const MAX_COVER_BYTES = 5 * 1024 * 1024; // 5 MB
42
43// Per-file upper limit based on extension. multer's global limit is the
44// highest (WAV); the real per-type check happens in the upload handler.
45const audioByteLimitFor = (ext) => (ext.toLowerCase() === '.wav' ? MAX_WAV_BYTES : MAX_AUDIO_BYTES);
46
47// Multer routes audio + cover into separate dirs based on field name.
48const storage = multer.diskStorage({
49 destination: (req, file, cb) => {
50 cb(null, file.fieldname === 'cover' ? COVER_DIR : AUDIO_DIR);
51 },
52 filename: (req, file, cb) => {
53 const ext = path.extname(file.originalname).toLowerCase();
54 cb(null, `${uuid()}${ext}`);
55 },
56});
57
58const upload = multer({
59 storage,
60 limits: { fileSize: MAX_WAV_BYTES }, // highest upper bound (WAV) — per-type check in the handler
61 fileFilter: (req, file, cb) => {
62 const ext = path.extname(file.originalname).toLowerCase();
63 if (file.fieldname === 'cover') {
64 if (!ALLOWED_COVER_EXT.has(ext)) return cb(new Error('Cover must be jpg/png/webp/gif'));
65 } else {
66 if (!ALLOWED_AUDIO_EXT.has(ext)) return cb(new Error('Unsupported audio type: ' + ext));
67 }
68 cb(null, true);
69 },
70});
71
72const router = express.Router();
73
74// "Open in" platform links per track: only https + the correct host accepted
75// (href arrives unescaped in the view → scheme/host guard against abuse).
76const LINK_DOMAINS = {
77 spotify: ['spotify.com'],
78 youtube: ['youtube.com', 'youtu.be', 'music.youtube.com'],
79 soundcloud: ['soundcloud.com'],
80};
81function platformLink(url, domains) {
82 const u = String(url || '').trim();
83 if (!u || !/^https:\/\//i.test(u)) return null;
84 try {
85 const h = new URL(u).hostname.toLowerCase();
86 if (domains.some((d) => h === d || h.endsWith('.' + d))) return u;
87 } catch (e) { /* invalid URL */ }
88 return null;
89}
90
91router.get('/', requireGod, (req, res) => {
92 const site = res.locals.site;
93 if (!site) return res.status(404).send('Site required');
94
95 const rows = db.prepare(`
96 SELECT t.id, t.title, t.artist, t.album, t.duration, t.cover_url,
97 t.position, t.created_at, t.downloadable, m.filename, m.size, m.mime_type
98 FROM audio_tracks t
99 LEFT JOIN media m ON m.id = t.media_id
100 WHERE t.site_id = ?
101 ORDER BY t.created_at DESC, t.position DESC
102 `).all(site.id);
103
104 // Build each track's stream URL so admins can preview audio inline.
105 const tracks = rows.map(t => ({
106 ...t,
107 stream_url: t.filename ? audioUrl(t.filename) : null,
108 }));
109
110 const base = (process.env.PUBLIC_BASE_URL || ('https://' + (req.get('host') || ''))).replace(/\/$/, '');
111 const embedUrl = base + (res.locals.siteUrlBase || '') + '/embed';
112 renderPage(req, res, 'pages/admin-audio', {
113 pageTitleKey: 'admin.t_audio',
114 bodyClass: 'on-admin',
115 tracks,
116 embedUrl,
117 error: req.query.error || null,
118 success: req.query.success || null,
119 maxBytesMb: Math.round(MAX_AUDIO_BYTES / 1024 / 1024),
120 maxWavMb: Math.round(MAX_WAV_BYTES / 1024 / 1024),
121 });
122});
123
124router.post('/upload', requireGod, (req, res) => {
125 // Helper: respond appropriately to JSON-accepting callers (the bulk
126 // uploader fetch() calls) vs traditional form posts (redirect).
127 // Both code paths cover identical errors below.
128 const wantsJson = req.get('Accept')?.includes('application/json') || req.xhr;
129 const fail = (status, message) => wantsJson
130 ? res.status(status).json({ ok: false, error: message })
131 : res.redirect('/admin/audio?error=' + encodeURIComponent(message));
132 const ok = (data) => wantsJson
133 ? res.json({ ok: true, ...data })
134 : res.redirect('/admin/audio?success=' + encodeURIComponent('Uploaded: ' + data.title));
135
136 upload.fields([{ name: 'audio', maxCount: 1 }, { name: 'cover', maxCount: 1 }])(req, res, async (err) => {
137 if (err) return fail(400, err.message);
138
139 const site = res.locals.site;
140 const audioFile = req.files?.audio?.[0];
141 const coverFile = req.files?.cover?.[0];
142
143 if (!site || !audioFile) {
144 // Clean up any cover that snuck through without an audio file
145 if (coverFile) try { fs.unlinkSync(coverFile.path); } catch {}
146 return fail(400, 'missing audio file');
147 }
148
149 // Per-type audio size check. multer's global limit was the WAV upper bound
150 // (100MB); compressed formats stay at 50MB.
151 const audioExt = path.extname(audioFile.originalname).toLowerCase();
152 const audioLimit = audioByteLimitFor(audioExt);
153 if (audioFile.size > audioLimit) {
154 try { fs.unlinkSync(audioFile.path); } catch {}
155 if (coverFile) try { fs.unlinkSync(coverFile.path); } catch {}
156 return fail(400, `audio te groot (max ${Math.round(audioLimit / 1024 / 1024)}MB voor ${audioExt || 'dit type'})`);
157 }
158
159 // Cover size check (multer's global limit was the audio upper bound)
160 if (coverFile && coverFile.size > MAX_COVER_BYTES) {
161 try { fs.unlinkSync(audioFile.path); } catch {}
162 try { fs.unlinkSync(coverFile.path); } catch {}
163 return fail(400, 'cover too large (max 5MB)');
164 }
165
166 const { title, artist, album } = req.body;
167 const trackId = uuid();
168 const mediaId = uuid();
169 const coverUrl = coverFile ? `/media/audio-covers/${coverFile.filename}` : null;
170
171 // ── TRANSCODE ────────────────────────────────────────────────
172 // Convert whatever the user uploaded to a uniform 192kbps stereo mp3.
173 // The original file (whatever its format) is deleted on success.
174 // multer named the upload <uuid>.<ext>; we re-use that uuid stem so
175 // the final file is just <uuid>.mp3, keeping things tidy.
176 const inputBaseName = path.basename(audioFile.filename, path.extname(audioFile.filename));
177 // Title fallback strategy:
178 // 1. Explicit `title` form field (single-upload form)
179 // 2. Original filename minus extension, with underscores → spaces
180 // (cleans up "Track_01_-_Title.mp3" patterns common from CD rips)
181 const fallbackTitle = path.basename(audioFile.originalname, path.extname(audioFile.originalname))
182 .replace(/_/g, ' ').trim();
183 const finalTitle = title?.trim() || fallbackTitle;
184 const finalArtist = artist?.trim() || null;
185 const finalAlbum = album?.trim() || null;
186 // Ownership/licence. credit falls back to the artist; these go both into the
187 // DB and into the ID3 tags of the mp3 (copyright + comment).
188 const finalCredit = (req.body.credit || '').trim() || finalArtist || null;
189 const finalLicense = (req.body.license || '').trim() || null;
190 const finalLinkSpotify = platformLink(req.body.link_spotify, LINK_DOMAINS.spotify);
191 const finalLinkYoutube = platformLink(req.body.link_youtube, LINK_DOMAINS.youtube);
192 const finalLinkSoundcloud = platformLink(req.body.link_soundcloud, LINK_DOMAINS.soundcloud);
193
194 console.log('[admin-audio] upload received:', {
195 original: audioFile.originalname,
196 tempPath: audioFile.path,
197 size: audioFile.size,
198 hasC: !!coverFile,
199 });
200
201 let transcoded;
202 try {
203 transcoded = await transcodeToMp3({
204 inputPath: audioFile.path,
205 outputDir: AUDIO_DIR,
206 outputBaseName: inputBaseName,
207 tags: {
208 title: finalTitle,
209 artist: finalArtist || undefined,
210 album: finalAlbum || undefined,
211 copyright: finalCredit || undefined,
212 comment: finalLicense || undefined,
213 },
214 });
215 console.log('[admin-audio] transcode OK:', transcoded);
216 } catch (transcodeErr) {
217 console.error('[admin-audio] Transcode failed:', transcodeErr);
218 // Transcoder kept the original on failure — clean it up ourselves
219 // since the upload as a whole has failed.
220 try { fs.unlinkSync(audioFile.path); } catch {}
221 if (coverFile) try { fs.unlinkSync(coverFile.path); } catch {}
222 return fail(500, 'Conversie mislukt: ' + transcodeErr.message);
223 }
224
225 try {
226 console.log('[admin-audio] inserting media row');
227 db.prepare(`
228 INSERT INTO media (id, site_id, filename, mime_type, size, storage_path)
229 VALUES (?, ?, ?, ?, ?, ?)
230 `).run(mediaId, site.id, transcoded.filename, transcoded.mimeType, transcoded.size, transcoded.path);
231
232 // Duration automatically: primarily from the transcode (ffmpeg codecData), then
233 // an optional client-side value (bulk uploader reads <audio>.duration),
234 // otherwise NULL (UI then shows '—:—', editable manually in the editor).
235 const clientDur = req.body.duration != null ? parseInt(req.body.duration, 10) : NaN;
236 const finalDuration =
237 (transcoded.durationSec != null && transcoded.durationSec > 0) ? transcoded.durationSec
238 : (Number.isFinite(clientDur) && clientDur > 0) ? clientDur
239 : null;
240
241 console.log('[admin-audio] inserting audio_tracks row (duration=' + finalDuration + ')');
242 db.prepare(`
243 INSERT INTO audio_tracks (id, site_id, title, artist, album, duration, cover_url, credit, license, link_spotify, link_youtube, link_soundcloud, media_id, position)
244 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE(
245 (SELECT MAX(position) + 1 FROM audio_tracks WHERE site_id = ?),
246 0
247 ))
248 `).run(
249 trackId, site.id,
250 finalTitle, finalArtist, finalAlbum,
251 finalDuration,
252 coverUrl,
253 finalCredit, finalLicense,
254 finalLinkSpotify, finalLinkYoutube, finalLinkSoundcloud,
255 mediaId, site.id
256 );
257 console.log('[admin-audio] DB inserts OK — track', trackId);
258 } catch (dbErr) {
259 console.error('[admin-audio] DB insert failed:', dbErr);
260 // DB failed — clean up the transcoded mp3 so we don't leak files
261 try { fs.unlinkSync(transcoded.path); } catch {}
262 if (coverFile) try { fs.unlinkSync(coverFile.path); } catch {}
263 return fail(500, dbErr.message);
264 }
265
266 return ok({
267 id: trackId,
268 title: finalTitle,
269 artist: finalArtist,
270 album: finalAlbum,
271 size: transcoded.size,
272 });
273 });
274});
275
276// Download-for-email per track on/off (premium #2). No-JS toggle from the
277// audio admin list → flip + back.
278router.post('/:id/downloadable', requireGod, (req, res) => {
279 const site = res.locals.site;
280 if (!site) return res.status(404).send('Site required');
281 const row = db.prepare('SELECT downloadable FROM audio_tracks WHERE id = ? AND site_id = ?').get(req.params.id, site.id);
282 if (row) {
283 db.prepare('UPDATE audio_tracks SET downloadable = ? WHERE id = ? AND site_id = ?')
284 .run(row.downloadable ? 0 : 1, req.params.id, site.id);
285 }
286 res.redirect('/admin/audio');
287});
288
289router.post('/:id/delete', requireGod, (req, res) => {
290 const site = res.locals.site;
291 if (!site) return res.status(404).send('Site required');
292
293 const track = db.prepare(`
294 SELECT t.id AS track_id, m.id AS media_id, m.storage_path
295 FROM audio_tracks t LEFT JOIN media m ON m.id = t.media_id
296 WHERE t.id = ? AND t.site_id = ?
297 `).get(req.params.id, site.id);
298
299 if (!track) return res.redirect('/admin/audio?error=Not+found');
300
301 db.prepare('DELETE FROM audio_tracks WHERE id = ?').run(track.track_id);
302 if (track.media_id) {
303 db.prepare('DELETE FROM media WHERE id = ?').run(track.media_id);
304 }
305 if (track.storage_path) {
306 try { fs.unlinkSync(track.storage_path); } catch {}
307 }
308 res.redirect('/admin/audio?success=Deleted');
309});
310
311// ─── Orphan cleanup: rows whose file is missing on disk ───────────
312//
313// Two-phase to prevent accidental data loss:
314// GET /admin/audio/cleanup → dry-run report (no changes, JSON list)
315// POST /admin/audio/cleanup → actually deletes the orphan rows
316//
317// "Orphan" = an audio_tracks row whose media_id either points nowhere or
318// points to a media row whose storage_path file doesn't exist on disk.
319// This is the recovery path when DB and disk drift apart (e.g. AUDIO_PATH
320// changed between uploads, disk was wiped, or migration left stragglers).
321function findOrphans(siteId) {
322 const rows = db.prepare(`
323 SELECT t.id AS track_id, t.title, t.artist, t.album,
324 m.id AS media_id, m.storage_path
325 FROM audio_tracks t
326 LEFT JOIN media m ON m.id = t.media_id
327 WHERE t.site_id = ?
328 `).all(siteId);
329 const orphans = [];
330 for (const r of rows) {
331 if (!r.storage_path) {
332 orphans.push({ ...r, reason: 'no media row' });
333 continue;
334 }
335 try { fs.statSync(r.storage_path); }
336 catch { orphans.push({ ...r, reason: 'file missing on disk' }); }
337 }
338 return { total: rows.length, orphans };
339}
340
341router.get('/cleanup', requireGod, (req, res) => {
342 const site = res.locals.site;
343 if (!site) return res.status(404).json({ error: 'Site required' });
344 const result = findOrphans(site.id);
345 res.json({
346 ok: true,
347 siteId: site.id,
348 totalTracks: result.total,
349 orphanCount: result.orphans.length,
350 orphans: result.orphans.map(o => ({
351 track_id: o.track_id,
352 title: o.title || '(zonder titel)',
353 artist: o.artist || '—',
354 reason: o.reason,
355 storage_path: o.storage_path || null,
356 })),
357 note: 'POST to this same URL to actually delete these rows.',
358 });
359});
360
361router.post('/cleanup', requireGod, (req, res) => {
362 const site = res.locals.site;
363 if (!site) return res.status(404).json({ error: 'Site required' });
364 const { orphans } = findOrphans(site.id);
365
366 // Wrap in a transaction so a partial failure doesn't leave half-deleted state
367 const deleteOne = db.transaction((o) => {
368 db.prepare('DELETE FROM audio_tracks WHERE id = ?').run(o.track_id);
369 if (o.media_id) db.prepare('DELETE FROM media WHERE id = ?').run(o.media_id);
370 });
371 for (const o of orphans) deleteOne(o);
372
373 res.json({ ok: true, deleted: orphans.length });
374});
375
376
377//
378// All write endpoints expect to be hit by the track-editor modal which
379// sends X-CSRF-Token and JSON. They return { ok: true, ... } on success
380// or { error: '...' } with a 4xx status on failure.
381
382/** GET /admin/audio/api/albums — distinct list of album names (for datalist) */
383router.get('/api/albums', requireGod, (req, res) => {
384 const site = res.locals.site;
385 if (!site) return res.status(404).json({ error: 'Site required' });
386 const rows = db.prepare(`
387 SELECT DISTINCT album FROM audio_tracks
388 WHERE site_id = ? AND album IS NOT NULL AND album != ''
389 ORDER BY album COLLATE NOCASE
390 `).all(site.id);
391 res.json({ ok: true, albums: rows.map(r => r.album) });
392});
393
394/** GET /admin/audio/api/:id — single track with all metadata */
395// Create a track WITHOUT an audio file (title + open-in links only). Appears
396// in albums/playlists in the list, with open-in icons but no play button.
397router.post('/create-link', requireGod, express.json(), (req, res) => {
398 const site = res.locals.site;
399 if (!site) return res.status(404).json({ error: 'Site required' });
400 const trackId = uuid();
401 const title = ((req.body && req.body.title) || 'Nieuwe track').toString().trim().slice(0, 200) || 'Nieuwe track';
402 try {
403 db.prepare(`
404 INSERT INTO audio_tracks (id, site_id, title, media_id, position)
405 VALUES (?, ?, ?, NULL, COALESCE((SELECT MAX(position) + 1 FROM audio_tracks WHERE site_id = ?), 0))
406 `).run(trackId, site.id, title, site.id);
407 } catch (e) {
408 return res.status(500).json({ error: e.message });
409 }
410 res.json({ ok: true, id: trackId });
411});
412
413router.get('/api/:id', requireGod, (req, res) => {
414 const site = res.locals.site;
415 if (!site) return res.status(404).json({ error: 'Site required' });
416 const t = db.prepare(`
417 SELECT t.id, t.title, t.artist, t.album, t.duration, t.cover_url,
418 t.credit, t.license, t.link_spotify, t.link_youtube, t.link_soundcloud,
419 t.position, t.created_at, m.filename
420 FROM audio_tracks t LEFT JOIN media m ON m.id = t.media_id
421 WHERE t.id = ? AND t.site_id = ?
422 `).get(req.params.id, site.id);
423 if (!t) return res.status(404).json({ error: 'Track niet gevonden' });
424 // Stream URL so the modal can render an inline preview player.
425 const stream_url = t.filename ? audioUrl(t.filename) : null;
426 res.json({ ok: true, track: { ...t, stream_url } });
427});
428
429/**
430 * POST /admin/audio/api/:id — update track metadata.
431 * Accepts JSON body with any subset of: title, artist, album, duration, cover_url.
432 * `title` is required if present (can't be blanked). Empty strings on optional
433 * fields are stored as NULL so the audio embed renderer's `t.artist || ''`
434 * fallback keeps working.
435 */
436router.post('/api/:id', requireGod, express.json(), async (req, res) => {
437 const site = res.locals.site;
438 if (!site) return res.status(404).json({ error: 'Site required' });
439
440 const exists = db.prepare(
441 'SELECT id FROM audio_tracks WHERE id = ? AND site_id = ?'
442 ).get(req.params.id, site.id);
443 if (!exists) return res.status(404).json({ error: 'Track niet gevonden' });
444
445 const fields = [];
446 const values = [];
447 const body = req.body || {};
448
449 if (Object.prototype.hasOwnProperty.call(body, 'title')) {
450 const v = String(body.title || '').trim();
451 if (!v) return res.status(400).json({ error: 'Titel is verplicht' });
452 fields.push('title = ?'); values.push(v);
453 }
454 if (Object.prototype.hasOwnProperty.call(body, 'artist')) {
455 fields.push('artist = ?'); values.push(String(body.artist || '').trim() || null);
456 }
457 if (Object.prototype.hasOwnProperty.call(body, 'album')) {
458 fields.push('album = ?'); values.push(String(body.album || '').trim() || null);
459 }
460 if (Object.prototype.hasOwnProperty.call(body, 'duration')) {
461 const d = parseInt(body.duration, 10);
462 fields.push('duration = ?');
463 values.push(Number.isFinite(d) && d > 0 ? d : null);
464 }
465 if (Object.prototype.hasOwnProperty.call(body, 'cover_url')) {
466 // Accept either a /media/... path or an absolute https URL.
467 // Anything else (javascript:, data:, etc) gets blanked for safety.
468 const raw = String(body.cover_url || '').trim();
469 let safe = null;
470 if (raw === '') {
471 safe = null;
472 } else if (raw.startsWith('/media/') || raw.startsWith('https://') || raw.startsWith('http://')) {
473 safe = raw;
474 }
475 fields.push('cover_url = ?'); values.push(safe);
476 }
477
478 if (Object.prototype.hasOwnProperty.call(body, 'downloadable')) {
479 fields.push('downloadable = ?'); values.push(body.downloadable ? 1 : 0);
480 }
481 if (Object.prototype.hasOwnProperty.call(body, 'credit')) {
482 fields.push('credit = ?'); values.push(String(body.credit || '').trim() || null);
483 }
484 if (Object.prototype.hasOwnProperty.call(body, 'license')) {
485 fields.push('license = ?'); values.push(String(body.license || '').trim() || null);
486 }
487 if (Object.prototype.hasOwnProperty.call(body, 'link_spotify')) {
488 fields.push('link_spotify = ?'); values.push(platformLink(body.link_spotify, LINK_DOMAINS.spotify));
489 }
490 if (Object.prototype.hasOwnProperty.call(body, 'link_youtube')) {
491 fields.push('link_youtube = ?'); values.push(platformLink(body.link_youtube, LINK_DOMAINS.youtube));
492 }
493 if (Object.prototype.hasOwnProperty.call(body, 'link_soundcloud')) {
494 fields.push('link_soundcloud = ?'); values.push(platformLink(body.link_soundcloud, LINK_DOMAINS.soundcloud));
495 }
496
497 if (fields.length === 0) {
498 return res.status(400).json({ error: 'Niks om te updaten' });
499 }
500
501 try {
502 db.prepare(`UPDATE audio_tracks SET ${fields.join(', ')} WHERE id = ? AND site_id = ?`)
503 .run(...values, req.params.id, site.id);
504 } catch (err) {
505 return res.status(500).json({ error: err.message });
506 }
507
508 // Fresh row + (if tag fields changed) retag the mp3, so that the owner/
509 // licence is also IN the file (ID3) and travels with it on download.
510 const fresh = db.prepare(`
511 SELECT t.id, t.title, t.artist, t.album, t.duration, t.cover_url, t.credit, t.license, m.storage_path
512 FROM audio_tracks t LEFT JOIN media m ON m.id = t.media_id
513 WHERE t.id = ? AND t.site_id = ?
514 `).get(req.params.id, site.id);
515
516 const tagsChanged = ['title', 'artist', 'album', 'credit', 'license']
517 .some((f) => Object.prototype.hasOwnProperty.call(body, f));
518 if (fresh && fresh.storage_path && tagsChanged) {
519 try {
520 await retagMp3({ filePath: fresh.storage_path, tags: {
521 title: fresh.title || undefined,
522 artist: fresh.artist || undefined,
523 album: fresh.album || undefined,
524 copyright: fresh.credit || undefined,
525 comment: fresh.license || undefined,
526 } });
527 } catch (e) {
528 console.warn('[admin-audio] ID3 retag failed (DB was still updated):', e.message);
529 }
530 }
531 const { storage_path, ...trackOut } = fresh || {};
532 res.json({ ok: true, track: trackOut });
533});
534
535/**
536 * POST /admin/audio/api/:id/cover — upload a new cover image and set it on
537 * the track in one go. Returns { ok, url } so the modal can preview.
538 *
539 * Reuses the same multer config as the upload form (5MB limit, jpg/png/webp/gif).
540 * If the track already had a cover stored under /media/audio-covers/, the old
541 * file is deleted to avoid orphaned bytes piling up.
542 */
543router.post('/api/:id/cover', requireGod, (req, res) => {
544 const site = res.locals.site;
545 if (!site) return res.status(404).json({ error: 'Site required' });
546
547 const exists = db.prepare(
548 'SELECT id, cover_url FROM audio_tracks WHERE id = ? AND site_id = ?'
549 ).get(req.params.id, site.id);
550 if (!exists) return res.status(404).json({ error: 'Track niet gevonden' });
551
552 upload.single('cover')(req, res, (err) => {
553 if (err) return res.status(400).json({ error: err.message });
554 const file = req.file;
555 if (!file) return res.status(400).json({ error: 'Geen bestand' });
556 if (file.size > MAX_COVER_BYTES) {
557 try { fs.unlinkSync(file.path); } catch {}
558 return res.status(413).json({ error: 'Te groot (max 5 MB)' });
559 }
560
561 const newUrl = `/media/audio-covers/${toWebp(file)}`;
562 try {
563 db.prepare('UPDATE audio_tracks SET cover_url = ? WHERE id = ? AND site_id = ?')
564 .run(newUrl, req.params.id, site.id);
565 } catch (dbErr) {
566 try { fs.unlinkSync(file.path); } catch {}
567 return res.status(500).json({ error: dbErr.message });
568 }
569
570 // Clean up the previous cover if it lived in our covers dir
571 if (exists.cover_url && exists.cover_url.startsWith('/media/audio-covers/')) {
572 const oldName = exists.cover_url.replace(/^\/media\/audio-covers\//, '');
573 const oldPath = path.join(COVER_DIR, oldName);
574 try { fs.unlinkSync(oldPath); } catch {}
575 }
576
577 // Return both keys so any caller using j.url OR j.cover_url works.
578 // Frontend (track-editor.ejs) reads j.cover_url — keep this in sync.
579 res.json({ ok: true, url: newUrl, cover_url: newUrl });
580 });
581});
582
583// Replace the audio FILE of an existing track (keeps all metadata + the track id, so any
584// [[track:id]] in posts keeps pointing here). Transcodes the new upload to a uniform mp3,
585// swaps the track's media_id + duration, and deletes the old media file/row.
586router.post('/api/:id/replace-audio', requireGod, (req, res) => {
587 const site = res.locals.site;
588 if (!site) return res.status(404).json({ ok: false, error: 'Site required' });
589 const track = db.prepare('SELECT id, media_id FROM audio_tracks WHERE id = ? AND site_id = ?').get(req.params.id, site.id);
590 if (!track) return res.status(404).json({ ok: false, error: 'Track niet gevonden' });
591
592 upload.single('audio')(req, res, async (err) => {
593 if (err) return res.status(400).json({ ok: false, error: err.message });
594 const file = req.file;
595 if (!file) return res.status(400).json({ ok: false, error: 'Geen bestand' });
596 const ext = path.extname(file.originalname).toLowerCase();
597 const limit = audioByteLimitFor(ext);
598 if (file.size > limit) {
599 try { fs.unlinkSync(file.path); } catch {}
600 return res.status(413).json({ ok: false, error: `Te groot (max ${Math.round(limit / 1024 / 1024)}MB voor ${ext || 'dit type'})` });
601 }
602
603 let transcoded;
604 try {
605 transcoded = await transcodeToMp3({
606 inputPath: file.path, outputDir: AUDIO_DIR,
607 outputBaseName: path.basename(file.filename, path.extname(file.filename)), tags: {},
608 });
609 } catch (e) {
610 try { fs.unlinkSync(file.path); } catch {}
611 return res.status(500).json({ ok: false, error: 'Conversie mislukt: ' + e.message });
612 }
613
614 const newMediaId = uuid();
615 try {
616 db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?,?,?,?,?,?)')
617 .run(newMediaId, site.id, transcoded.filename, transcoded.mimeType, transcoded.size, transcoded.path);
618 db.prepare('UPDATE audio_tracks SET media_id = ? WHERE id = ? AND site_id = ?').run(newMediaId, track.id, site.id);
619 const dur = (transcoded.durationSec != null && transcoded.durationSec > 0) ? transcoded.durationSec : null;
620 if (dur) db.prepare('UPDATE audio_tracks SET duration = ? WHERE id = ?').run(dur, track.id);
621 // Remove the OLD media (file + row), best-effort.
622 if (track.media_id && track.media_id !== newMediaId) {
623 try { const old = db.prepare('SELECT storage_path FROM media WHERE id = ?').get(track.media_id); if (old && old.storage_path) fs.unlinkSync(old.storage_path); } catch {}
624 try { db.prepare('DELETE FROM media WHERE id = ?').run(track.media_id); } catch {}
625 }
626 return res.json({ ok: true, stream_url: audioUrl(transcoded.filename), duration: dur });
627 } catch (e) {
628 return res.status(500).json({ ok: false, error: e.message });
629 }
630 });
631});
632
633export default router;
Note: See TracBrowser for help on using the repository browser.