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

main
Last change on this file since 743bb81 was 21522ae, checked in by Robin Genis <roboburr@…>, 4 months ago

audio: Spotify-style blob playback + same-origin gate (fix playback loop)

Root cause of the "next-loops-but-never-plays after 4-5 songs" bug: every
track URL was HMAC-signed once at page-render time with a 10-min TTL. A whole
queue shared that single deadline, so tracks further down expired mid-session
-> /audio/stream returned 403 -> audio 'error' -> auto-skip -> next track also
expired -> infinite loop. The 3-strike guard never fired because the eager
'play' event reset the counter before each 403 landed.

Removed the expiring-token system entirely and replaced it with two
non-expiring layers:

  • Client fetch()es track bytes and plays from a blob: object URL (no shareable URL, no "save audio as"); blobs revoked to avoid leaks; loadSeq guards fast prev/next; pre-seed is metadata-only (no auto-download).
  • Server gates /audio/stream to same-origin browser fetches (X-Audio-Player header or Sec-Fetch-Site): blocks address-bar paste, hotlinks, curl.

Also: reset error counter on real 'playing' event (not eager 'play') so the
3-strike auto-skip-stop actually works; fix admin play-state detection to
compare logical currentTrack().url instead of the now-blob: audio.src; bump
audio-player.js cache-buster v5.

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

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