/** * 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'; import { mediaDir } from '../config/paths.js'; import * as ActivityPubService from '../services/ActivityPubService.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 = mediaDir('COVER_PATH', '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 — compressed formats (mp3/m4a/ogg/…) const MAX_WAV_BYTES = 100 * 1024 * 1024; // 100 MB — WAV is uncompressed, so a higher limit const MAX_COVER_BYTES = 5 * 1024 * 1024; // 5 MB // Per-file upper limit based on extension. multer's global limit is the // highest (WAV); the real per-type check happens in the 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 }, // highest upper bound (WAV) — per-type check in the 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(); // "Open in" platform links per track: only https + the correct host accepted // (href arrives unescaped in the view → scheme/host guard against abuse). const LINK_DOMAINS = { spotify: ['spotify.com'], youtube: ['youtube.com', 'youtu.be', 'music.youtube.com'], soundcloud: ['soundcloud.com'], }; function platformLink(url, domains) { const u = String(url || '').trim(); if (!u || !/^https:\/\//i.test(u)) return null; try { const h = new URL(u).hostname.toLowerCase(); if (domains.some((d) => h === d || h.endsWith('.' + d))) return u; } catch (e) { /* invalid URL */ } return null; } 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.created_at DESC, t.position DESC `).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', { // admin-audio neemt de track-editor op, dus die module hoort erbij. pageJs: 'admin-audio track-editor', pageTitleKey: 'admin.t_audio', 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 global limit was the WAV upper bound // (100MB); compressed formats stay at 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; // Ownership/licence. credit falls back to the artist; these go both into the // DB and into the ID3 tags of the mp3 (copyright + comment). const finalCredit = (req.body.credit || '').trim() || finalArtist || null; const finalLicense = (req.body.license || '').trim() || null; const finalLinkSpotify = platformLink(req.body.link_spotify, LINK_DOMAINS.spotify); const finalLinkYoutube = platformLink(req.body.link_youtube, LINK_DOMAINS.youtube); const finalLinkSoundcloud = platformLink(req.body.link_soundcloud, LINK_DOMAINS.soundcloud); 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); // Duration automatically: primarily from the transcode (ffmpeg codecData), then // an optional client-side value (bulk uploader reads