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

main
Last change on this file since 36afccd was 6be57b4, checked in by roboburr <roboburr@…>, 3 months ago

Embeddable player (premium feature #7)

Standalone /embed page (no shell) with a compact audio player for the site
tracks, intended for an <iframe> on external sites. The page runs on the klonkt
origin, so audio requests from the iframe remain same-origin -> the
/audio/stream gate lets them through, even on an external site. Helmet's
X-Frame-Options + frame-ancestors are overridden per route (frame-ancestors *)
so external embedding is allowed.

  • routes/embed.js (premium-gated): GET /embed.
  • views/pages/embed-player.ejs: standalone HTML, inline css/js, play/pause, track click, auto-next, progress bar; themable on site.accent.
  • admin-audio: embedUrl + "Embeddable player" section with copyable iframe code + preview link (premium).

node --check passed.

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

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