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

main
Last change on this file since a117862 was a117862, checked in by Robin <roboburr@…>, 4 weeks ago

Stap 1: een artiest kan zichzelf opzoeken in MusicBrainz (shaer-mbz)

Waarom dit geen dialect is en Funkwhale's Track/ArtistCredit wel: een MBID is
geen vocabulaire maar een REGISTER. Ernaar verwijzen is als een ISBN noemen --
je neemt niemands model over en je wijst naar iets dat al bestaat. En het is de
brug die Funkwhale zelf al kent, want hun Track draagt musicbrainzId.

WAT ER STAAT

  • MusicBrainzService: zoeken op artiestennaam, kandidaten met hun disambiguatie, soort, land en jaren erbij. De naam alleen is niet genoeg om te kiezen -- er zijn drie bands die Nirvana heten, en dat is precies waar hun disambiguation-veld voor is.
  • mb_artist_id en mb_artist_name op sites. De naam erbij zodat het scherm kan tonen WAT er gekoppeld is zonder ervoor te netwerken, en zodat een verkeerde koppeling opvalt.
  • GET /admin/audio/api/musicbrainz, dat standaard zoekt op de artiestennaam die al in de site staat.

HUN TWEE HARDE REGELS, INGEBAKKEN EN GETEST. Overtreden leidt tot een BLOKKADE
en niet tot een foutmelding, dus "het werkte toen ik het probeerde" is er geen
bewijs voor:

  • hoogstens EEN verzoek per seconde, over de hele applicatie. Daarom draait de zoekopdracht server-side: die regel geldt per applicatie en niet per bezoeker, dus twee tabbladen zouden hem samen overtreden. Een test doet twee zoekopdrachten tegelijk en eist een gat van een seconde.
  • een echte User-Agent met contactgegevens. Een test leest hem uit het verzoek.

DE KEUZE BLIJFT VAN DE ARTIEST. We tonen kandidaten; we kiezen er niet zelf een,
ook niet als er maar een treffer is. Een verkeerd geraden MBID koppelt iemand aan
het werk van een ander, en dat is erger dan geen koppeling.

WAT ER NIET IN ZIT: schrijven naar MusicBrainz. Kan niet via hun API voor
artiesten -- alleen tags, ratings, ISRC's en barcodes -- en zou ook niet moeten.

Zonder netwerk getest: globalThis.fetch vervangen en musicbrainz.org in
AP_ALLOW_HOSTS, zodat er geen DNS aan te pas komt. Anders test het of deze
machine internet heeft.

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

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