source: Klonkt/src/routes/admin-audio.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: 21.7 KB
RevLine 
[7bc636b]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';
[8f6225c]20import { toWebp } from '../services/ImageWebpService.js';
[7bc636b]21import { requireGod } from '../middleware/auth.js';
[0d7acdf]22import { transcodeToMp3, retagMp3 } from '../services/AudioTranscoder.js';
[21522ae]23import { audioUrl } from '../services/AudioStreamService.js';
[7bc636b]24
25const __dirname = path.dirname(fileURLToPath(import.meta.url));
26// Audio files live OUTSIDE storage/media so the public /media static
27// handler can't serve them — they must go through the signed /audio/stream/
28// endpoint (anti-hotlink). Covers are public and stay in /media.
29const AUDIO_DIR = path.resolve(
30 process.env.AUDIO_PATH || path.join(__dirname, '..', '..', 'storage', 'audio')
31);
32const COVER_DIR = path.resolve(
33 process.env.COVER_PATH || path.join(__dirname, '..', '..', 'storage', 'media', 'audio-covers')
34);
35fs.mkdirSync(AUDIO_DIR, { recursive: true });
36fs.mkdirSync(COVER_DIR, { recursive: true });
37
38const ALLOWED_AUDIO_EXT = new Set(['.mp3', '.m4a', '.mp4', '.aac', '.oga', '.ogg', '.opus', '.flac', '.wav', '.webm']);
39const ALLOWED_COVER_EXT = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif']);
[fbc8628]40const MAX_AUDIO_BYTES = 50 * 1024 * 1024; // 50 MB — gecomprimeerde formaten (mp3/m4a/ogg/…)
41const MAX_WAV_BYTES = 100 * 1024 * 1024; // 100 MB — WAV is ongecomprimeerd, dus ruimer
42const MAX_COVER_BYTES = 5 * 1024 * 1024; // 5 MB
43
44// Per-bestand bovengrens op basis van extensie. multer's globale limiet is de
45// hoogste (WAV); de echte controle per type gebeurt in de upload-handler.
46const audioByteLimitFor = (ext) => (ext.toLowerCase() === '.wav' ? MAX_WAV_BYTES : MAX_AUDIO_BYTES);
[7bc636b]47
48// Multer routes audio + cover into separate dirs based on field name.
49const storage = multer.diskStorage({
50 destination: (req, file, cb) => {
51 cb(null, file.fieldname === 'cover' ? COVER_DIR : AUDIO_DIR);
52 },
53 filename: (req, file, cb) => {
54 const ext = path.extname(file.originalname).toLowerCase();
55 cb(null, `${uuid()}${ext}`);
56 },
57});
58
59const upload = multer({
60 storage,
[fbc8628]61 limits: { fileSize: MAX_WAV_BYTES }, // hoogste bovengrens (WAV) — per-type check in de handler
[7bc636b]62 fileFilter: (req, file, cb) => {
63 const ext = path.extname(file.originalname).toLowerCase();
64 if (file.fieldname === 'cover') {
65 if (!ALLOWED_COVER_EXT.has(ext)) return cb(new Error('Cover must be jpg/png/webp/gif'));
66 } else {
67 if (!ALLOWED_AUDIO_EXT.has(ext)) return cb(new Error('Unsupported audio type: ' + ext));
68 }
69 cb(null, true);
70 },
71});
72
73const router = express.Router();
74
75router.get('/', requireGod, (req, res) => {
76 const site = res.locals.site;
77 if (!site) return res.status(404).send('Site required');
78
79 const rows = db.prepare(`
80 SELECT t.id, t.title, t.artist, t.album, t.duration, t.cover_url,
[91094a4]81 t.position, t.created_at, t.downloadable, m.filename, m.size, m.mime_type
[7bc636b]82 FROM audio_tracks t
83 LEFT JOIN media m ON m.id = t.media_id
84 WHERE t.site_id = ?
85 ORDER BY t.position ASC, t.created_at ASC
86 `).all(site.id);
87
[21522ae]88 // Build each track's stream URL so admins can preview audio inline.
[7bc636b]89 const tracks = rows.map(t => ({
90 ...t,
[21522ae]91 stream_url: t.filename ? audioUrl(t.filename) : null,
[7bc636b]92 }));
93
[6be57b4]94 const base = (process.env.PUBLIC_BASE_URL || ('https://' + (req.get('host') || ''))).replace(/\/$/, '');
95 const embedUrl = base + (res.locals.siteUrlBase || '') + '/embed';
[7bc636b]96 renderPage(req, res, 'pages/admin-audio', {
97 pageTitle: 'Audio tracks',
98 bodyClass: 'on-admin',
99 tracks,
[6be57b4]100 embedUrl,
[7bc636b]101 error: req.query.error || null,
102 success: req.query.success || null,
103 maxBytesMb: Math.round(MAX_AUDIO_BYTES / 1024 / 1024),
[fbc8628]104 maxWavMb: Math.round(MAX_WAV_BYTES / 1024 / 1024),
[7bc636b]105 });
106});
107
108router.post('/upload', requireGod, (req, res) => {
109 // Helper: respond appropriately to JSON-accepting callers (the bulk
110 // uploader fetch() calls) vs traditional form posts (redirect).
111 // Both code paths cover identical errors below.
112 const wantsJson = req.get('Accept')?.includes('application/json') || req.xhr;
113 const fail = (status, message) => wantsJson
114 ? res.status(status).json({ ok: false, error: message })
115 : res.redirect('/admin/audio?error=' + encodeURIComponent(message));
116 const ok = (data) => wantsJson
117 ? res.json({ ok: true, ...data })
118 : res.redirect('/admin/audio?success=' + encodeURIComponent('Uploaded: ' + data.title));
119
120 upload.fields([{ name: 'audio', maxCount: 1 }, { name: 'cover', maxCount: 1 }])(req, res, async (err) => {
121 if (err) return fail(400, err.message);
122
123 const site = res.locals.site;
124 const audioFile = req.files?.audio?.[0];
125 const coverFile = req.files?.cover?.[0];
126
127 if (!site || !audioFile) {
128 // Clean up any cover that snuck through without an audio file
129 if (coverFile) try { fs.unlinkSync(coverFile.path); } catch {}
130 return fail(400, 'missing audio file');
131 }
132
[fbc8628]133 // Per-type audio size check. multer's globale limiet was de WAV-bovengrens
134 // (100MB); gecomprimeerde formaten blijven op 50MB.
135 const audioExt = path.extname(audioFile.originalname).toLowerCase();
136 const audioLimit = audioByteLimitFor(audioExt);
137 if (audioFile.size > audioLimit) {
138 try { fs.unlinkSync(audioFile.path); } catch {}
139 if (coverFile) try { fs.unlinkSync(coverFile.path); } catch {}
140 return fail(400, `audio te groot (max ${Math.round(audioLimit / 1024 / 1024)}MB voor ${audioExt || 'dit type'})`);
141 }
142
[7bc636b]143 // Cover size check (multer's global limit was the audio upper bound)
144 if (coverFile && coverFile.size > MAX_COVER_BYTES) {
145 try { fs.unlinkSync(audioFile.path); } catch {}
146 try { fs.unlinkSync(coverFile.path); } catch {}
147 return fail(400, 'cover too large (max 5MB)');
148 }
149
150 const { title, artist, album } = req.body;
151 const trackId = uuid();
152 const mediaId = uuid();
153 const coverUrl = coverFile ? `/media/audio-covers/${coverFile.filename}` : null;
154
155 // ── TRANSCODE ────────────────────────────────────────────────
156 // Convert whatever the user uploaded to a uniform 192kbps stereo mp3.
157 // The original file (whatever its format) is deleted on success.
158 // multer named the upload <uuid>.<ext>; we re-use that uuid stem so
159 // the final file is just <uuid>.mp3, keeping things tidy.
160 const inputBaseName = path.basename(audioFile.filename, path.extname(audioFile.filename));
161 // Title fallback strategy:
162 // 1. Explicit `title` form field (single-upload form)
163 // 2. Original filename minus extension, with underscores → spaces
164 // (cleans up "Track_01_-_Title.mp3" patterns common from CD rips)
165 const fallbackTitle = path.basename(audioFile.originalname, path.extname(audioFile.originalname))
166 .replace(/_/g, ' ').trim();
167 const finalTitle = title?.trim() || fallbackTitle;
168 const finalArtist = artist?.trim() || null;
169 const finalAlbum = album?.trim() || null;
[0d7acdf]170 // Eigenaarschap/licentie. credit valt terug op de artiest; deze gaan zowel de
171 // DB in als de ID3-tags van de mp3 (copyright + comment).
172 const finalCredit = (req.body.credit || '').trim() || finalArtist || null;
173 const finalLicense = (req.body.license || '').trim() || null;
[7bc636b]174
175 console.log('[admin-audio] upload received:', {
176 original: audioFile.originalname,
177 tempPath: audioFile.path,
178 size: audioFile.size,
179 hasC: !!coverFile,
180 });
181
182 let transcoded;
183 try {
184 transcoded = await transcodeToMp3({
185 inputPath: audioFile.path,
186 outputDir: AUDIO_DIR,
187 outputBaseName: inputBaseName,
188 tags: {
189 title: finalTitle,
190 artist: finalArtist || undefined,
191 album: finalAlbum || undefined,
[0d7acdf]192 copyright: finalCredit || undefined,
193 comment: finalLicense || undefined,
[7bc636b]194 },
195 });
196 console.log('[admin-audio] transcode OK:', transcoded);
197 } catch (transcodeErr) {
198 console.error('[admin-audio] Transcode failed:', transcodeErr);
199 // Transcoder kept the original on failure — clean it up ourselves
200 // since the upload as a whole has failed.
201 try { fs.unlinkSync(audioFile.path); } catch {}
202 if (coverFile) try { fs.unlinkSync(coverFile.path); } catch {}
203 return fail(500, 'Conversie mislukt: ' + transcodeErr.message);
204 }
205
206 try {
207 console.log('[admin-audio] inserting media row');
208 db.prepare(`
209 INSERT INTO media (id, site_id, filename, mime_type, size, storage_path)
210 VALUES (?, ?, ?, ?, ?, ?)
211 `).run(mediaId, site.id, transcoded.filename, transcoded.mimeType, transcoded.size, transcoded.path);
212
[3e86f1c]213 // Duur automatisch: primair uit de transcode (ffmpeg codecData), anders een
214 // optionele client-side waarde (bulk-uploader leest <audio>.duration uit),
215 // anders NULL (UI toont dan '—:—', handmatig bij te werken in de editor).
216 const clientDur = req.body.duration != null ? parseInt(req.body.duration, 10) : NaN;
217 const finalDuration =
218 (transcoded.durationSec != null && transcoded.durationSec > 0) ? transcoded.durationSec
219 : (Number.isFinite(clientDur) && clientDur > 0) ? clientDur
220 : null;
221
222 console.log('[admin-audio] inserting audio_tracks row (duration=' + finalDuration + ')');
[7bc636b]223 db.prepare(`
[0d7acdf]224 INSERT INTO audio_tracks (id, site_id, title, artist, album, duration, cover_url, credit, license, media_id, position)
225 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE(
[7bc636b]226 (SELECT MAX(position) + 1 FROM audio_tracks WHERE site_id = ?),
227 0
228 ))
229 `).run(
230 trackId, site.id,
231 finalTitle, finalArtist, finalAlbum,
[3e86f1c]232 finalDuration,
[7bc636b]233 coverUrl,
[0d7acdf]234 finalCredit, finalLicense,
[7bc636b]235 mediaId, site.id
236 );
237 console.log('[admin-audio] DB inserts OK — track', trackId);
238 } catch (dbErr) {
239 console.error('[admin-audio] DB insert failed:', dbErr);
240 // DB failed — clean up the transcoded mp3 so we don't leak files
241 try { fs.unlinkSync(transcoded.path); } catch {}
242 if (coverFile) try { fs.unlinkSync(coverFile.path); } catch {}
243 return fail(500, dbErr.message);
244 }
245
246 return ok({
247 id: trackId,
248 title: finalTitle,
249 artist: finalArtist,
250 album: finalAlbum,
251 size: transcoded.size,
252 });
253 });
254});
255
[91094a4]256// Download-voor-email per track aan/uit (premium #2). Zonder-JS toggle vanaf de
257// audio-beheerlijst → flip + terug.
258router.post('/:id/downloadable', requireGod, (req, res) => {
259 const site = res.locals.site;
260 if (!site) return res.status(404).send('Site required');
261 const row = db.prepare('SELECT downloadable FROM audio_tracks WHERE id = ? AND site_id = ?').get(req.params.id, site.id);
262 if (row) {
263 db.prepare('UPDATE audio_tracks SET downloadable = ? WHERE id = ? AND site_id = ?')
264 .run(row.downloadable ? 0 : 1, req.params.id, site.id);
265 }
266 res.redirect('/admin/audio');
267});
268
[7bc636b]269router.post('/:id/delete', requireGod, (req, res) => {
270 const site = res.locals.site;
271 if (!site) return res.status(404).send('Site required');
272
273 const track = db.prepare(`
274 SELECT t.id AS track_id, m.id AS media_id, m.storage_path
275 FROM audio_tracks t LEFT JOIN media m ON m.id = t.media_id
276 WHERE t.id = ? AND t.site_id = ?
277 `).get(req.params.id, site.id);
278
279 if (!track) return res.redirect('/admin/audio?error=Not+found');
280
281 db.prepare('DELETE FROM audio_tracks WHERE id = ?').run(track.track_id);
282 if (track.media_id) {
283 db.prepare('DELETE FROM media WHERE id = ?').run(track.media_id);
284 }
285 if (track.storage_path) {
286 try { fs.unlinkSync(track.storage_path); } catch {}
287 }
288 res.redirect('/admin/audio?success=Deleted');
289});
290
291// ─── Orphan cleanup: rows whose file is missing on disk ───────────
292//
293// Two-phase to prevent accidental data loss:
294// GET /admin/audio/cleanup → dry-run report (no changes, JSON list)
295// POST /admin/audio/cleanup → actually deletes the orphan rows
296//
297// "Orphan" = an audio_tracks row whose media_id either points nowhere or
298// points to a media row whose storage_path file doesn't exist on disk.
299// This is the recovery path when DB and disk drift apart (e.g. AUDIO_PATH
300// changed between uploads, disk was wiped, or migration left stragglers).
301function findOrphans(siteId) {
302 const rows = db.prepare(`
303 SELECT t.id AS track_id, t.title, t.artist, t.album,
304 m.id AS media_id, m.storage_path
305 FROM audio_tracks t
306 LEFT JOIN media m ON m.id = t.media_id
307 WHERE t.site_id = ?
308 `).all(siteId);
309 const orphans = [];
310 for (const r of rows) {
311 if (!r.storage_path) {
312 orphans.push({ ...r, reason: 'no media row' });
313 continue;
314 }
315 try { fs.statSync(r.storage_path); }
316 catch { orphans.push({ ...r, reason: 'file missing on disk' }); }
317 }
318 return { total: rows.length, orphans };
319}
320
321router.get('/cleanup', requireGod, (req, res) => {
322 const site = res.locals.site;
323 if (!site) return res.status(404).json({ error: 'Site required' });
324 const result = findOrphans(site.id);
325 res.json({
326 ok: true,
327 siteId: site.id,
328 totalTracks: result.total,
329 orphanCount: result.orphans.length,
330 orphans: result.orphans.map(o => ({
331 track_id: o.track_id,
332 title: o.title || '(zonder titel)',
333 artist: o.artist || '—',
334 reason: o.reason,
335 storage_path: o.storage_path || null,
336 })),
337 note: 'POST to this same URL to actually delete these rows.',
338 });
339});
340
341router.post('/cleanup', requireGod, (req, res) => {
342 const site = res.locals.site;
343 if (!site) return res.status(404).json({ error: 'Site required' });
344 const { orphans } = findOrphans(site.id);
345
346 // Wrap in a transaction so a partial failure doesn't leave half-deleted state
347 const deleteOne = db.transaction((o) => {
348 db.prepare('DELETE FROM audio_tracks WHERE id = ?').run(o.track_id);
349 if (o.media_id) db.prepare('DELETE FROM media WHERE id = ?').run(o.media_id);
350 });
351 for (const o of orphans) deleteOne(o);
352
353 res.json({ ok: true, deleted: orphans.length });
354});
355
356
357//
358// All write endpoints expect to be hit by the track-editor modal which
359// sends X-CSRF-Token and JSON. They return { ok: true, ... } on success
360// or { error: '...' } with a 4xx status on failure.
361
362/** GET /admin/audio/api/albums — distinct list of album names (for datalist) */
363router.get('/api/albums', requireGod, (req, res) => {
364 const site = res.locals.site;
365 if (!site) return res.status(404).json({ error: 'Site required' });
366 const rows = db.prepare(`
367 SELECT DISTINCT album FROM audio_tracks
368 WHERE site_id = ? AND album IS NOT NULL AND album != ''
369 ORDER BY album COLLATE NOCASE
370 `).all(site.id);
371 res.json({ ok: true, albums: rows.map(r => r.album) });
372});
373
374/** GET /admin/audio/api/:id — single track with all metadata */
375router.get('/api/:id', requireGod, (req, res) => {
376 const site = res.locals.site;
377 if (!site) return res.status(404).json({ error: 'Site required' });
378 const t = db.prepare(`
379 SELECT t.id, t.title, t.artist, t.album, t.duration, t.cover_url,
[0d7acdf]380 t.credit, t.license, t.position, t.created_at, m.filename
[7bc636b]381 FROM audio_tracks t LEFT JOIN media m ON m.id = t.media_id
382 WHERE t.id = ? AND t.site_id = ?
383 `).get(req.params.id, site.id);
384 if (!t) return res.status(404).json({ error: 'Track niet gevonden' });
[21522ae]385 // Stream URL so the modal can render an inline preview player.
386 const stream_url = t.filename ? audioUrl(t.filename) : null;
[7bc636b]387 res.json({ ok: true, track: { ...t, stream_url } });
388});
389
390/**
391 * POST /admin/audio/api/:id — update track metadata.
392 * Accepts JSON body with any subset of: title, artist, album, duration, cover_url.
393 * `title` is required if present (can't be blanked). Empty strings on optional
394 * fields are stored as NULL so the audio embed renderer's `t.artist || ''`
395 * fallback keeps working.
396 */
[0d7acdf]397router.post('/api/:id', requireGod, express.json(), async (req, res) => {
[7bc636b]398 const site = res.locals.site;
399 if (!site) return res.status(404).json({ error: 'Site required' });
400
401 const exists = db.prepare(
402 'SELECT id FROM audio_tracks WHERE id = ? AND site_id = ?'
403 ).get(req.params.id, site.id);
404 if (!exists) return res.status(404).json({ error: 'Track niet gevonden' });
405
406 const fields = [];
407 const values = [];
408 const body = req.body || {};
409
410 if (Object.prototype.hasOwnProperty.call(body, 'title')) {
411 const v = String(body.title || '').trim();
412 if (!v) return res.status(400).json({ error: 'Titel is verplicht' });
413 fields.push('title = ?'); values.push(v);
414 }
415 if (Object.prototype.hasOwnProperty.call(body, 'artist')) {
416 fields.push('artist = ?'); values.push(String(body.artist || '').trim() || null);
417 }
418 if (Object.prototype.hasOwnProperty.call(body, 'album')) {
419 fields.push('album = ?'); values.push(String(body.album || '').trim() || null);
420 }
421 if (Object.prototype.hasOwnProperty.call(body, 'duration')) {
422 const d = parseInt(body.duration, 10);
423 fields.push('duration = ?');
424 values.push(Number.isFinite(d) && d > 0 ? d : null);
425 }
426 if (Object.prototype.hasOwnProperty.call(body, 'cover_url')) {
427 // Accept either a /media/... path or an absolute https URL.
428 // Anything else (javascript:, data:, etc) gets blanked for safety.
429 const raw = String(body.cover_url || '').trim();
430 let safe = null;
431 if (raw === '') {
432 safe = null;
433 } else if (raw.startsWith('/media/') || raw.startsWith('https://') || raw.startsWith('http://')) {
434 safe = raw;
435 }
436 fields.push('cover_url = ?'); values.push(safe);
437 }
438
[91094a4]439 if (Object.prototype.hasOwnProperty.call(body, 'downloadable')) {
440 fields.push('downloadable = ?'); values.push(body.downloadable ? 1 : 0);
441 }
[0d7acdf]442 if (Object.prototype.hasOwnProperty.call(body, 'credit')) {
443 fields.push('credit = ?'); values.push(String(body.credit || '').trim() || null);
444 }
445 if (Object.prototype.hasOwnProperty.call(body, 'license')) {
446 fields.push('license = ?'); values.push(String(body.license || '').trim() || null);
447 }
[91094a4]448
[7bc636b]449 if (fields.length === 0) {
450 return res.status(400).json({ error: 'Niks om te updaten' });
451 }
452
453 try {
454 db.prepare(`UPDATE audio_tracks SET ${fields.join(', ')} WHERE id = ? AND site_id = ?`)
455 .run(...values, req.params.id, site.id);
456 } catch (err) {
457 return res.status(500).json({ error: err.message });
458 }
459
[0d7acdf]460 // Verse rij + (als tag-velden wijzigden) de mp3 her-taggen, zodat de eigenaar/
461 // licentie ook IN het bestand staat (ID3) en meereist bij een download.
[7bc636b]462 const fresh = db.prepare(`
[0d7acdf]463 SELECT t.id, t.title, t.artist, t.album, t.duration, t.cover_url, t.credit, t.license, m.storage_path
464 FROM audio_tracks t LEFT JOIN media m ON m.id = t.media_id
465 WHERE t.id = ? AND t.site_id = ?
[7bc636b]466 `).get(req.params.id, site.id);
[0d7acdf]467
468 const tagsChanged = ['title', 'artist', 'album', 'credit', 'license']
469 .some((f) => Object.prototype.hasOwnProperty.call(body, f));
470 if (fresh && fresh.storage_path && tagsChanged) {
471 try {
472 await retagMp3({ filePath: fresh.storage_path, tags: {
473 title: fresh.title || undefined,
474 artist: fresh.artist || undefined,
475 album: fresh.album || undefined,
476 copyright: fresh.credit || undefined,
477 comment: fresh.license || undefined,
478 } });
479 } catch (e) {
480 console.warn('[admin-audio] ID3 her-taggen mislukt (DB is wel bijgewerkt):', e.message);
481 }
482 }
483 const { storage_path, ...trackOut } = fresh || {};
484 res.json({ ok: true, track: trackOut });
[7bc636b]485});
486
487/**
488 * POST /admin/audio/api/:id/cover — upload a new cover image and set it on
489 * the track in one go. Returns { ok, url } so the modal can preview.
490 *
491 * Reuses the same multer config as the upload form (5MB limit, jpg/png/webp/gif).
492 * If the track already had a cover stored under /media/audio-covers/, the old
493 * file is deleted to avoid orphaned bytes piling up.
494 */
495router.post('/api/:id/cover', requireGod, (req, res) => {
496 const site = res.locals.site;
497 if (!site) return res.status(404).json({ error: 'Site required' });
498
499 const exists = db.prepare(
500 'SELECT id, cover_url FROM audio_tracks WHERE id = ? AND site_id = ?'
501 ).get(req.params.id, site.id);
502 if (!exists) return res.status(404).json({ error: 'Track niet gevonden' });
503
504 upload.single('cover')(req, res, (err) => {
505 if (err) return res.status(400).json({ error: err.message });
506 const file = req.file;
507 if (!file) return res.status(400).json({ error: 'Geen bestand' });
508 if (file.size > MAX_COVER_BYTES) {
509 try { fs.unlinkSync(file.path); } catch {}
510 return res.status(413).json({ error: 'Te groot (max 5 MB)' });
511 }
512
[8f6225c]513 const newUrl = `/media/audio-covers/${toWebp(file)}`;
[7bc636b]514 try {
515 db.prepare('UPDATE audio_tracks SET cover_url = ? WHERE id = ? AND site_id = ?')
516 .run(newUrl, req.params.id, site.id);
517 } catch (dbErr) {
518 try { fs.unlinkSync(file.path); } catch {}
519 return res.status(500).json({ error: dbErr.message });
520 }
521
522 // Clean up the previous cover if it lived in our covers dir
523 if (exists.cover_url && exists.cover_url.startsWith('/media/audio-covers/')) {
524 const oldName = exists.cover_url.replace(/^\/media\/audio-covers\//, '');
525 const oldPath = path.join(COVER_DIR, oldName);
526 try { fs.unlinkSync(oldPath); } catch {}
527 }
528
529 // Return both keys so any caller using j.url OR j.cover_url works.
530 // Frontend (track-editor.ejs) reads j.cover_url — keep this in sync.
531 res.json({ ok: true, url: newUrl, cover_url: newUrl });
532 });
533});
534
535export default router;
Note: See TracBrowser for help on using the repository browser.