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

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

Stap 2: "Ben jij dit?" in Beheer -> Audio (shaer-mbz)

Een paneel bovenaan het audioscherm: zoek op je artiestennaam (voorgevuld met
wat er al in de site staat), kies je eigen pagina, klaar. Drie talen.

DE TOELICHTING IS HET HELE PUNT. Elke kandidaat toont MusicBrainz'
disambiguation naast soort, land en jaren -- er zijn drie bands die Nirvana
heten, en met alleen een naam kiest iemand de verkeerde. Wij kiezen NIET voor
de artiest, ook niet als er precies een treffer is: een verkeerd geraden MBID
zet jouw naam onder andermans werk.

ONTKOPPELEN KAN. Om dezelfde reden: zo'n vergissing hoort met een klik terug
te draaien te zijn, niet met een databasebewerking.

Alleen een echte MBID komt de kolom in; de route weigert een URL of een handle.
De naam bewaren we ernaast zodat het scherm kan tonen WAT er hangt zonder
ervoor te netwerken -- en zodat een verkeerde koppeling opvalt.

TWEE DINGEN UIT DE HUISREGELS. De kandidaten gaan met textContent het scherm op
en niet als HTML: dat is tekst uit een vreemd register. En het blok hangt in
run() en niet op moduleniveau -- deze modules krijgen bij elke paginawissel
opnieuw init(), en een blok dat maar een keer per sessie draait is precies wat
shaer-5s1 opleverde. Ik had hem er eerst buiten gezet.

Als iemand niet in MusicBrainz staat zegt het scherm dat hij zich daar kan
aanmelden -- en erbij dat dat alleen dáár kan, niet vanuit Klonkt. Dat is geen
bescheidenheid maar hun API: artiesten zijn er niet mee aan te maken.

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

  • Property mode set to 100644
File size: 29.4 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
416/**
417 * De keuze vastleggen. Alleen een echte MBID komt de kolom in: de naam die we
418 * ernaast bewaren is voor het scherm, de MBID is het enige dat naar buiten gaat.
419 */
420router.post('/musicbrainz/link', requireGod, (req, res) => {
421 const site = res.locals.site;
422 if (!site) return res.status(404).end();
423 const mbid = String(req.body.mbid || '').trim().toLowerCase();
424 const naam = String(req.body.naam || '').trim().slice(0, 200);
425 const terug = (res.locals.siteUrlBase || '') + '/admin/audio';
426 if (!MusicBrainz.isMbid(mbid)) return res.redirect(`${terug}?error=` + encodeURIComponent('Geen geldige MusicBrainz-id.'));
427 db.prepare('UPDATE sites SET mb_artist_id = ?, mb_artist_name = ? WHERE id = ?').run(mbid, naam || null, site.id);
428 res.redirect(`${terug}?success=` + encodeURIComponent('Gekoppeld aan MusicBrainz.'));
429});
430
431/** Terugdraaien. Een verkeerde koppeling zet jouw naam onder andermans werk. */
432router.post('/musicbrainz/unlink', requireGod, (req, res) => {
433 const site = res.locals.site;
434 if (!site) return res.status(404).end();
435 db.prepare('UPDATE sites SET mb_artist_id = NULL, mb_artist_name = NULL WHERE id = ?').run(site.id);
436 res.redirect((res.locals.siteUrlBase || '') + '/admin/audio?success=' + encodeURIComponent('Ontkoppeld.'));
437});
438
439router.get('/api/albums', requireGod, (req, res) => {
440 const site = res.locals.site;
441 if (!site) return res.status(404).json({ error: 'Site required' });
442 const rows = db.prepare(`
443 SELECT DISTINCT album FROM audio_tracks
444 WHERE site_id = ? AND album IS NOT NULL AND album != ''
445 ORDER BY album COLLATE NOCASE
446 `).all(site.id);
447 res.json({ ok: true, albums: rows.map(r => r.album) });
448});
449
450/** GET /admin/audio/api/:id — single track with all metadata */
451// Create a track WITHOUT an audio file (title + open-in links only). Appears
452// in albums/playlists in the list, with open-in icons but no play button.
453router.post('/create-link', requireGod, express.json(), (req, res) => {
454 const site = res.locals.site;
455 if (!site) return res.status(404).json({ error: 'Site required' });
456 const trackId = uuid();
457 const title = ((req.body && req.body.title) || 'Nieuwe track').toString().trim().slice(0, 200) || 'Nieuwe track';
458 try {
459 db.prepare(`
460 INSERT INTO audio_tracks (id, site_id, title, media_id, position)
461 VALUES (?, ?, ?, NULL, COALESCE((SELECT MAX(position) + 1 FROM audio_tracks WHERE site_id = ?), 0))
462 `).run(trackId, site.id, title, site.id);
463 } catch (e) {
464 return res.status(500).json({ error: e.message });
465 }
466 res.json({ ok: true, id: trackId });
467});
468
469router.get('/api/:id', requireGod, (req, res) => {
470 const site = res.locals.site;
471 if (!site) return res.status(404).json({ error: 'Site required' });
472 const t = db.prepare(`
473 SELECT t.id, t.title, t.artist, t.album, t.duration, t.cover_url,
474 t.credit, t.license, t.link_spotify, t.link_youtube, t.link_soundcloud,
475 t.position, t.created_at, m.filename
476 FROM audio_tracks t LEFT JOIN media m ON m.id = t.media_id
477 WHERE t.id = ? AND t.site_id = ?
478 `).get(req.params.id, site.id);
479 if (!t) return res.status(404).json({ error: 'Track niet gevonden' });
480 // Stream URL so the modal can render an inline preview player.
481 const stream_url = t.filename ? audioUrl(t.filename) : null;
482 res.json({ ok: true, track: { ...t, stream_url } });
483});
484
485/**
486 * POST /admin/audio/api/:id — update track metadata.
487 * Accepts JSON body with any subset of: title, artist, album, duration, cover_url.
488 * `title` is required if present (can't be blanked). Empty strings on optional
489 * fields are stored as NULL so the audio embed renderer's `t.artist || ''`
490 * fallback keeps working.
491 */
492router.post('/api/:id', requireGod, express.json(), async (req, res) => {
493 const site = res.locals.site;
494 if (!site) return res.status(404).json({ error: 'Site required' });
495
496 const exists = db.prepare(
497 'SELECT id FROM audio_tracks WHERE id = ? AND site_id = ?'
498 ).get(req.params.id, site.id);
499 if (!exists) return res.status(404).json({ error: 'Track niet gevonden' });
500
501 const fields = [];
502 const values = [];
503 const body = req.body || {};
504
505 if (Object.prototype.hasOwnProperty.call(body, 'title')) {
506 const v = String(body.title || '').trim();
507 if (!v) return res.status(400).json({ error: 'Titel is verplicht' });
508 fields.push('title = ?'); values.push(v);
509 }
510 if (Object.prototype.hasOwnProperty.call(body, 'artist')) {
511 fields.push('artist = ?'); values.push(String(body.artist || '').trim() || null);
512 }
513 if (Object.prototype.hasOwnProperty.call(body, 'album')) {
514 fields.push('album = ?'); values.push(String(body.album || '').trim() || null);
515 }
516 if (Object.prototype.hasOwnProperty.call(body, 'duration')) {
517 const d = parseInt(body.duration, 10);
518 fields.push('duration = ?');
519 values.push(Number.isFinite(d) && d > 0 ? d : null);
520 }
521 if (Object.prototype.hasOwnProperty.call(body, 'cover_url')) {
522 // Accept either a /media/... path or an absolute https URL.
523 // Anything else (javascript:, data:, etc) gets blanked for safety.
524 const raw = String(body.cover_url || '').trim();
525 let safe = null;
526 if (raw === '') {
527 safe = null;
528 } else if (raw.startsWith('/media/') || raw.startsWith('https://') || raw.startsWith('http://')) {
529 safe = raw;
530 }
531 fields.push('cover_url = ?'); values.push(safe);
532 }
533
534 if (Object.prototype.hasOwnProperty.call(body, 'downloadable')) {
535 fields.push('downloadable = ?'); values.push(body.downloadable ? 1 : 0);
536 }
537 if (Object.prototype.hasOwnProperty.call(body, 'credit')) {
538 fields.push('credit = ?'); values.push(String(body.credit || '').trim() || null);
539 }
540 if (Object.prototype.hasOwnProperty.call(body, 'license')) {
541 fields.push('license = ?'); values.push(String(body.license || '').trim() || null);
542 }
543 if (Object.prototype.hasOwnProperty.call(body, 'link_spotify')) {
544 fields.push('link_spotify = ?'); values.push(platformLink(body.link_spotify, LINK_DOMAINS.spotify));
545 }
546 if (Object.prototype.hasOwnProperty.call(body, 'link_youtube')) {
547 fields.push('link_youtube = ?'); values.push(platformLink(body.link_youtube, LINK_DOMAINS.youtube));
548 }
549 if (Object.prototype.hasOwnProperty.call(body, 'link_soundcloud')) {
550 fields.push('link_soundcloud = ?'); values.push(platformLink(body.link_soundcloud, LINK_DOMAINS.soundcloud));
551 }
552
553 if (fields.length === 0) {
554 return res.status(400).json({ error: 'Niks om te updaten' });
555 }
556
557 try {
558 db.prepare(`UPDATE audio_tracks SET ${fields.join(', ')} WHERE id = ? AND site_id = ?`)
559 .run(...values, req.params.id, site.id);
560 } catch (err) {
561 return res.status(500).json({ error: err.message });
562 }
563
564 // Fresh row + (if tag fields changed) retag the mp3, so that the owner/
565 // licence is also IN the file (ID3) and travels with it on download.
566 const fresh = db.prepare(`
567 SELECT t.id, t.title, t.artist, t.album, t.duration, t.cover_url, t.credit, t.license, m.storage_path
568 FROM audio_tracks t LEFT JOIN media m ON m.id = t.media_id
569 WHERE t.id = ? AND t.site_id = ?
570 `).get(req.params.id, site.id);
571
572 const tagsChanged = ['title', 'artist', 'album', 'credit', 'license']
573 .some((f) => Object.prototype.hasOwnProperty.call(body, f));
574 if (fresh && fresh.storage_path && tagsChanged) {
575 try {
576 await retagMp3({ filePath: fresh.storage_path, tags: {
577 title: fresh.title || undefined,
578 artist: fresh.artist || undefined,
579 album: fresh.album || undefined,
580 copyright: fresh.credit || undefined,
581 comment: fresh.license || undefined,
582 } });
583 } catch (e) {
584 console.warn('[admin-audio] ID3 retag failed (DB was still updated):', e.message);
585 }
586 }
587 const { storage_path, ...trackOut } = fresh || {};
588 res.json({ ok: true, track: trackOut });
589});
590
591/**
592 * POST /admin/audio/api/:id/cover — upload a new cover image and set it on
593 * the track in one go. Returns { ok, url } so the modal can preview.
594 *
595 * Reuses the same multer config as the upload form (5MB limit, jpg/png/webp/gif).
596 * If the track already had a cover stored under /media/audio-covers/, the old
597 * file is deleted to avoid orphaned bytes piling up.
598 */
599router.post('/api/:id/cover', requireGod, (req, res) => {
600 const site = res.locals.site;
601 if (!site) return res.status(404).json({ error: 'Site required' });
602
603 const exists = db.prepare(
604 'SELECT id, cover_url FROM audio_tracks WHERE id = ? AND site_id = ?'
605 ).get(req.params.id, site.id);
606 if (!exists) return res.status(404).json({ error: 'Track niet gevonden' });
607
608 upload.single('cover')(req, res, (err) => {
609 if (err) return res.status(400).json({ error: err.message });
610 const file = req.file;
611 if (!file) return res.status(400).json({ error: 'Geen bestand' });
612 if (file.size > MAX_COVER_BYTES) {
613 try { fs.unlinkSync(file.path); } catch {}
614 return res.status(413).json({ error: 'Te groot (max 5 MB)' });
615 }
616
617 const newUrl = `/media/audio-covers/${toWebp(file)}`;
618 try {
619 db.prepare('UPDATE audio_tracks SET cover_url = ? WHERE id = ? AND site_id = ?')
620 .run(newUrl, req.params.id, site.id);
621 } catch (dbErr) {
622 try { fs.unlinkSync(file.path); } catch {}
623 return res.status(500).json({ error: dbErr.message });
624 }
625
626 // Clean up the previous cover if it lived in our covers dir
627 if (exists.cover_url && exists.cover_url.startsWith('/media/audio-covers/')) {
628 const oldName = exists.cover_url.replace(/^\/media\/audio-covers\//, '');
629 const oldPath = path.join(COVER_DIR, oldName);
630 try { fs.unlinkSync(oldPath); } catch {}
631 }
632
633 // Return both keys so any caller using j.url OR j.cover_url works.
634 // Frontend (track-editor.ejs) reads j.cover_url — keep this in sync.
635 res.json({ ok: true, url: newUrl, cover_url: newUrl });
636 });
637});
638
639// Replace the audio FILE of an existing track (keeps all metadata + the track id, so any
640// [[track:id]] in posts keeps pointing here). Transcodes the new upload to a uniform mp3,
641// swaps the track's media_id + duration, and deletes the old media file/row.
642router.post('/api/:id/replace-audio', requireGod, (req, res) => {
643 const site = res.locals.site;
644 if (!site) return res.status(404).json({ ok: false, error: 'Site required' });
645 const track = db.prepare('SELECT id, media_id FROM audio_tracks WHERE id = ? AND site_id = ?').get(req.params.id, site.id);
646 if (!track) return res.status(404).json({ ok: false, error: 'Track niet gevonden' });
647
648 upload.single('audio')(req, res, async (err) => {
649 if (err) return res.status(400).json({ ok: false, error: err.message });
650 const file = req.file;
651 if (!file) return res.status(400).json({ ok: false, error: 'Geen bestand' });
652 const ext = path.extname(file.originalname).toLowerCase();
653 const limit = audioByteLimitFor(ext);
654 if (file.size > limit) {
655 try { fs.unlinkSync(file.path); } catch {}
656 return res.status(413).json({ ok: false, error: `Te groot (max ${Math.round(limit / 1024 / 1024)}MB voor ${ext || 'dit type'})` });
657 }
658
659 let transcoded;
660 try {
661 transcoded = await transcodeToMp3({
662 inputPath: file.path, outputDir: AUDIO_DIR,
663 outputBaseName: path.basename(file.filename, path.extname(file.filename)), tags: {},
664 });
665 } catch (e) {
666 try { fs.unlinkSync(file.path); } catch {}
667 return res.status(500).json({ ok: false, error: 'Conversie mislukt: ' + e.message });
668 }
669
670 const newMediaId = uuid();
671 try {
672 db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?,?,?,?,?,?)')
673 .run(newMediaId, site.id, transcoded.filename, transcoded.mimeType, transcoded.size, transcoded.path);
674 db.prepare('UPDATE audio_tracks SET media_id = ? WHERE id = ? AND site_id = ?').run(newMediaId, track.id, site.id);
675 const dur = (transcoded.durationSec != null && transcoded.durationSec > 0) ? transcoded.durationSec : null;
676 if (dur) db.prepare('UPDATE audio_tracks SET duration = ? WHERE id = ?').run(dur, track.id);
677 // Remove the OLD media (file + row), best-effort.
678 if (track.media_id && track.media_id !== newMediaId) {
679 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 {}
680 try { db.prepare('DELETE FROM media WHERE id = ?').run(track.media_id); } catch {}
681 }
682 return res.json({ ok: true, stream_url: audioUrl(transcoded.filename), duration: dur });
683 } catch (e) {
684 return res.status(500).json({ ok: false, error: e.message });
685 }
686 });
687});
688
689export default router;
Note: See TracBrowser for help on using the repository browser.