/** * Admin: Audio Tracks management — Phase C MP3 player. * * GET /admin/audio -> list site tracks + upload form * POST /admin/audio/upload -> multer upload, insert media + audio_tracks * POST /admin/audio/:id/delete -> remove track row + file on disk * * Files land in storage/media/audio/ (NOT served by /media static handler — * everything goes through the signed /audio/stream/ route). */ import express from 'express'; import multer from 'multer'; import path from 'path'; import fs from 'fs'; import { fileURLToPath } from 'url'; import { v4 as uuid } from 'uuid'; import db from '../config/database.js'; import { renderPage } from '../middleware/render.js'; import { toWebp } from '../services/ImageWebpService.js'; import { requireGod } from '../middleware/auth.js'; import { transcodeToMp3, retagMp3 } from '../services/AudioTranscoder.js'; import { audioUrl } from '../services/AudioStreamService.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); // Audio files live OUTSIDE storage/media so the public /media static // handler can't serve them — they must go through the signed /audio/stream/ // endpoint (anti-hotlink). Covers are public and stay in /media. const AUDIO_DIR = path.resolve( process.env.AUDIO_PATH || path.join(__dirname, '..', '..', 'storage', 'audio') ); const COVER_DIR = path.resolve( process.env.COVER_PATH || path.join(__dirname, '..', '..', 'storage', 'media', 'audio-covers') ); fs.mkdirSync(AUDIO_DIR, { recursive: true }); fs.mkdirSync(COVER_DIR, { recursive: true }); const ALLOWED_AUDIO_EXT = new Set(['.mp3', '.m4a', '.mp4', '.aac', '.oga', '.ogg', '.opus', '.flac', '.wav', '.webm']); const ALLOWED_COVER_EXT = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif']); const MAX_AUDIO_BYTES = 50 * 1024 * 1024; // 50 MB — gecomprimeerde formaten (mp3/m4a/ogg/…) const MAX_WAV_BYTES = 100 * 1024 * 1024; // 100 MB — WAV is ongecomprimeerd, dus ruimer const MAX_COVER_BYTES = 5 * 1024 * 1024; // 5 MB // Per-bestand bovengrens op basis van extensie. multer's globale limiet is de // hoogste (WAV); de echte controle per type gebeurt in de upload-handler. const audioByteLimitFor = (ext) => (ext.toLowerCase() === '.wav' ? MAX_WAV_BYTES : MAX_AUDIO_BYTES); // Multer routes audio + cover into separate dirs based on field name. const storage = multer.diskStorage({ destination: (req, file, cb) => { cb(null, file.fieldname === 'cover' ? COVER_DIR : AUDIO_DIR); }, filename: (req, file, cb) => { const ext = path.extname(file.originalname).toLowerCase(); cb(null, `${uuid()}${ext}`); }, }); const upload = multer({ storage, limits: { fileSize: MAX_WAV_BYTES }, // hoogste bovengrens (WAV) — per-type check in de handler fileFilter: (req, file, cb) => { const ext = path.extname(file.originalname).toLowerCase(); if (file.fieldname === 'cover') { if (!ALLOWED_COVER_EXT.has(ext)) return cb(new Error('Cover must be jpg/png/webp/gif')); } else { if (!ALLOWED_AUDIO_EXT.has(ext)) return cb(new Error('Unsupported audio type: ' + ext)); } cb(null, true); }, }); const router = express.Router(); router.get('/', requireGod, (req, res) => { const site = res.locals.site; if (!site) return res.status(404).send('Site required'); const rows = db.prepare(` SELECT t.id, t.title, t.artist, t.album, t.duration, t.cover_url, t.position, t.created_at, t.downloadable, m.filename, m.size, m.mime_type FROM audio_tracks t LEFT JOIN media m ON m.id = t.media_id WHERE t.site_id = ? ORDER BY t.position ASC, t.created_at ASC `).all(site.id); // Build each track's stream URL so admins can preview audio inline. const tracks = rows.map(t => ({ ...t, stream_url: t.filename ? audioUrl(t.filename) : null, })); const base = (process.env.PUBLIC_BASE_URL || ('https://' + (req.get('host') || ''))).replace(/\/$/, ''); const embedUrl = base + (res.locals.siteUrlBase || '') + '/embed'; renderPage(req, res, 'pages/admin-audio', { pageTitle: 'Audio tracks', bodyClass: 'on-admin', tracks, embedUrl, error: req.query.error || null, success: req.query.success || null, maxBytesMb: Math.round(MAX_AUDIO_BYTES / 1024 / 1024), maxWavMb: Math.round(MAX_WAV_BYTES / 1024 / 1024), }); }); router.post('/upload', requireGod, (req, res) => { // Helper: respond appropriately to JSON-accepting callers (the bulk // uploader fetch() calls) vs traditional form posts (redirect). // Both code paths cover identical errors below. const wantsJson = req.get('Accept')?.includes('application/json') || req.xhr; const fail = (status, message) => wantsJson ? res.status(status).json({ ok: false, error: message }) : res.redirect('/admin/audio?error=' + encodeURIComponent(message)); const ok = (data) => wantsJson ? res.json({ ok: true, ...data }) : res.redirect('/admin/audio?success=' + encodeURIComponent('Uploaded: ' + data.title)); upload.fields([{ name: 'audio', maxCount: 1 }, { name: 'cover', maxCount: 1 }])(req, res, async (err) => { if (err) return fail(400, err.message); const site = res.locals.site; const audioFile = req.files?.audio?.[0]; const coverFile = req.files?.cover?.[0]; if (!site || !audioFile) { // Clean up any cover that snuck through without an audio file if (coverFile) try { fs.unlinkSync(coverFile.path); } catch {} return fail(400, 'missing audio file'); } // Per-type audio size check. multer's globale limiet was de WAV-bovengrens // (100MB); gecomprimeerde formaten blijven op 50MB. const audioExt = path.extname(audioFile.originalname).toLowerCase(); const audioLimit = audioByteLimitFor(audioExt); if (audioFile.size > audioLimit) { try { fs.unlinkSync(audioFile.path); } catch {} if (coverFile) try { fs.unlinkSync(coverFile.path); } catch {} return fail(400, `audio te groot (max ${Math.round(audioLimit / 1024 / 1024)}MB voor ${audioExt || 'dit type'})`); } // Cover size check (multer's global limit was the audio upper bound) if (coverFile && coverFile.size > MAX_COVER_BYTES) { try { fs.unlinkSync(audioFile.path); } catch {} try { fs.unlinkSync(coverFile.path); } catch {} return fail(400, 'cover too large (max 5MB)'); } const { title, artist, album } = req.body; const trackId = uuid(); const mediaId = uuid(); const coverUrl = coverFile ? `/media/audio-covers/${coverFile.filename}` : null; // ── TRANSCODE ──────────────────────────────────────────────── // Convert whatever the user uploaded to a uniform 192kbps stereo mp3. // The original file (whatever its format) is deleted on success. // multer named the upload .; we re-use that uuid stem so // the final file is just .mp3, keeping things tidy. const inputBaseName = path.basename(audioFile.filename, path.extname(audioFile.filename)); // Title fallback strategy: // 1. Explicit `title` form field (single-upload form) // 2. Original filename minus extension, with underscores → spaces // (cleans up "Track_01_-_Title.mp3" patterns common from CD rips) const fallbackTitle = path.basename(audioFile.originalname, path.extname(audioFile.originalname)) .replace(/_/g, ' ').trim(); const finalTitle = title?.trim() || fallbackTitle; const finalArtist = artist?.trim() || null; const finalAlbum = album?.trim() || null; // Eigenaarschap/licentie. credit valt terug op de artiest; deze gaan zowel de // DB in als de ID3-tags van de mp3 (copyright + comment). const finalCredit = (req.body.credit || '').trim() || finalArtist || null; const finalLicense = (req.body.license || '').trim() || null; console.log('[admin-audio] upload received:', { original: audioFile.originalname, tempPath: audioFile.path, size: audioFile.size, hasC: !!coverFile, }); let transcoded; try { transcoded = await transcodeToMp3({ inputPath: audioFile.path, outputDir: AUDIO_DIR, outputBaseName: inputBaseName, tags: { title: finalTitle, artist: finalArtist || undefined, album: finalAlbum || undefined, copyright: finalCredit || undefined, comment: finalLicense || undefined, }, }); console.log('[admin-audio] transcode OK:', transcoded); } catch (transcodeErr) { console.error('[admin-audio] Transcode failed:', transcodeErr); // Transcoder kept the original on failure — clean it up ourselves // since the upload as a whole has failed. try { fs.unlinkSync(audioFile.path); } catch {} if (coverFile) try { fs.unlinkSync(coverFile.path); } catch {} return fail(500, 'Conversie mislukt: ' + transcodeErr.message); } try { console.log('[admin-audio] inserting media row'); db.prepare(` INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?, ?, ?, ?, ?, ?) `).run(mediaId, site.id, transcoded.filename, transcoded.mimeType, transcoded.size, transcoded.path); // Duur automatisch: primair uit de transcode (ffmpeg codecData), anders een // optionele client-side waarde (bulk-uploader leest