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

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

Het paneel hoort bij wie je bent, niet bij je bestanden (Robins correctie)

Verhuisd van Beheer -> Audio naar het profielscherm van de site-eigenaar, en
daar staat het NAAST de aliassen -- want dat is dezelfde vraag. Een alias zegt
wie je elders in de fediverse bent, een MBID zegt wie je in het muziekregister
bent. Bij de audiobestanden ging het over beheer van spullen; hier gaat het over
identiteit, en daar hoort het thuis.

DE VERHUIZING VERANDERDE DE VORM, en dat is de moeite waard om te weten. Het
profielscherm IS een formulier, en een formulier in een formulier bestaat niet
in HTML. Dus geen eigen opslaan-knop meer: de keuze zet een verborgen veld en
gaat mee met de Opslaan van de pagina, precies zoals de profielfoto. Dat leest
ook beter -- een halve wijziging die je nog kunt herzien voor je opslaat.

Ontkoppelen is nu het veld leegmaken, geen aparte route. Minder oppervlak.

Het zoek-endpoint ging mee naar /admin/sites/:slug/api/musicbrainz. Een endpoint
onder /admin/audio laten staan voor een scherm in /admin/sites is precies zo'n
naad die later niemand meer kan plaatsen. De teksten heten nu asite.mb_* in
plaats van aaud.mb_*, in alle drie de talen, met een placeholder erbij.

Wat NIET veranderde: de zoekopdracht draait server-side (hun regel geldt per
applicatie), wij kiezen niet voor de artiest, en alleen een echte MBID komt de
kolom in.

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

  • Property mode set to 100644
File size: 26.9 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';
[8f6225c]20import { toWebp } from '../services/ImageWebpService.js';
[7bc636b]21import { requireGod } from '../middleware/auth.js';
[0d7acdf]22import { transcodeToMp3, retagMp3 } from '../services/AudioTranscoder.js';
[21522ae]23import { audioUrl } from '../services/AudioStreamService.js';
[e2c3d09]24import { mediaDir } from '../config/paths.js';
[7bc636b]25
26const __dirname = path.dirname(fileURLToPath(import.meta.url));
27// Audio files live OUTSIDE storage/media so the public /media static
28// handler can't serve them — they must go through the signed /audio/stream/
29// endpoint (anti-hotlink). Covers are public and stay in /media.
30const AUDIO_DIR = path.resolve(
31 process.env.AUDIO_PATH || path.join(__dirname, '..', '..', 'storage', 'audio')
32);
[e2c3d09]33const COVER_DIR = mediaDir('COVER_PATH', 'audio-covers');
[7bc636b]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']);
[834bcc3]39const MAX_AUDIO_BYTES = 50 * 1024 * 1024; // 50 MB — compressed formats (mp3/m4a/ogg/…)
40const MAX_WAV_BYTES = 100 * 1024 * 1024; // 100 MB — WAV is uncompressed, so a higher limit
[fbc8628]41const MAX_COVER_BYTES = 5 * 1024 * 1024; // 5 MB
42
[834bcc3]43// Per-file upper limit based on extension. multer's global limit is the
44// highest (WAV); the real per-type check happens in the upload handler.
[fbc8628]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,
[834bcc3]60 limits: { fileSize: MAX_WAV_BYTES }, // highest upper bound (WAV) — per-type check in the 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
[834bcc3]74// "Open in" platform links per track: only https + the correct host accepted
75// (href arrives unescaped in the view → scheme/host guard against abuse).
[183875b]76const LINK_DOMAINS = {
77 spotify: ['spotify.com'],
78 youtube: ['youtube.com', 'youtu.be', 'music.youtube.com'],
79 soundcloud: ['soundcloud.com'],
80};
81function platformLink(url, domains) {
82 const u = String(url || '').trim();
83 if (!u || !/^https:\/\//i.test(u)) return null;
84 try {
85 const h = new URL(u).hostname.toLowerCase();
86 if (domains.some((d) => h === d || h.endsWith('.' + d))) return u;
[834bcc3]87 } catch (e) { /* invalid URL */ }
[183875b]88 return null;
89}
90
[7bc636b]91router.get('/', requireGod, (req, res) => {
92 const site = res.locals.site;
93 if (!site) return res.status(404).send('Site required');
94
95 const rows = db.prepare(`
96 SELECT t.id, t.title, t.artist, t.album, t.duration, t.cover_url,
[757eea0]97 t.position, t.created_at, t.downloadable, m.filename, m.size, m.mime_type
[7bc636b]98 FROM audio_tracks t
99 LEFT JOIN media m ON m.id = t.media_id
100 WHERE t.site_id = ?
[d4d8f9d]101 ORDER BY t.created_at DESC, t.position DESC
[7bc636b]102 `).all(site.id);
103
[21522ae]104 // Build each track's stream URL so admins can preview audio inline.
[7bc636b]105 const tracks = rows.map(t => ({
106 ...t,
[21522ae]107 stream_url: t.filename ? audioUrl(t.filename) : null,
[7bc636b]108 }));
109
[6be57b4]110 const base = (process.env.PUBLIC_BASE_URL || ('https://' + (req.get('host') || ''))).replace(/\/$/, '');
111 const embedUrl = base + (res.locals.siteUrlBase || '') + '/embed';
[7bc636b]112 renderPage(req, res, 'pages/admin-audio', {
[54ee51c]113 // admin-audio neemt de track-editor op, dus die module hoort erbij.
114 pageJs: 'admin-audio track-editor',
[3487567]115 pageTitleKey: 'admin.t_audio',
[7bc636b]116 bodyClass: 'on-admin',
117 tracks,
[6be57b4]118 embedUrl,
[7bc636b]119 error: req.query.error || null,
120 success: req.query.success || null,
121 maxBytesMb: Math.round(MAX_AUDIO_BYTES / 1024 / 1024),
[fbc8628]122 maxWavMb: Math.round(MAX_WAV_BYTES / 1024 / 1024),
[7bc636b]123 });
124});
125
126router.post('/upload', requireGod, (req, res) => {
127 // Helper: respond appropriately to JSON-accepting callers (the bulk
128 // uploader fetch() calls) vs traditional form posts (redirect).
129 // Both code paths cover identical errors below.
130 const wantsJson = req.get('Accept')?.includes('application/json') || req.xhr;
131 const fail = (status, message) => wantsJson
132 ? res.status(status).json({ ok: false, error: message })
133 : res.redirect('/admin/audio?error=' + encodeURIComponent(message));
134 const ok = (data) => wantsJson
135 ? res.json({ ok: true, ...data })
136 : res.redirect('/admin/audio?success=' + encodeURIComponent('Uploaded: ' + data.title));
137
138 upload.fields([{ name: 'audio', maxCount: 1 }, { name: 'cover', maxCount: 1 }])(req, res, async (err) => {
139 if (err) return fail(400, err.message);
140
141 const site = res.locals.site;
142 const audioFile = req.files?.audio?.[0];
143 const coverFile = req.files?.cover?.[0];
144
145 if (!site || !audioFile) {
146 // Clean up any cover that snuck through without an audio file
147 if (coverFile) try { fs.unlinkSync(coverFile.path); } catch {}
148 return fail(400, 'missing audio file');
149 }
150
[834bcc3]151 // Per-type audio size check. multer's global limit was the WAV upper bound
152 // (100MB); compressed formats stay at 50MB.
[fbc8628]153 const audioExt = path.extname(audioFile.originalname).toLowerCase();
154 const audioLimit = audioByteLimitFor(audioExt);
155 if (audioFile.size > audioLimit) {
156 try { fs.unlinkSync(audioFile.path); } catch {}
157 if (coverFile) try { fs.unlinkSync(coverFile.path); } catch {}
158 return fail(400, `audio te groot (max ${Math.round(audioLimit / 1024 / 1024)}MB voor ${audioExt || 'dit type'})`);
159 }
160
[7bc636b]161 // Cover size check (multer's global limit was the audio upper bound)
162 if (coverFile && coverFile.size > MAX_COVER_BYTES) {
163 try { fs.unlinkSync(audioFile.path); } catch {}
164 try { fs.unlinkSync(coverFile.path); } catch {}
165 return fail(400, 'cover too large (max 5MB)');
166 }
167
168 const { title, artist, album } = req.body;
169 const trackId = uuid();
170 const mediaId = uuid();
171 const coverUrl = coverFile ? `/media/audio-covers/${coverFile.filename}` : null;
172
173 // ── TRANSCODE ────────────────────────────────────────────────
174 // Convert whatever the user uploaded to a uniform 192kbps stereo mp3.
175 // The original file (whatever its format) is deleted on success.
176 // multer named the upload <uuid>.<ext>; we re-use that uuid stem so
177 // the final file is just <uuid>.mp3, keeping things tidy.
178 const inputBaseName = path.basename(audioFile.filename, path.extname(audioFile.filename));
179 // Title fallback strategy:
180 // 1. Explicit `title` form field (single-upload form)
181 // 2. Original filename minus extension, with underscores → spaces
182 // (cleans up "Track_01_-_Title.mp3" patterns common from CD rips)
183 const fallbackTitle = path.basename(audioFile.originalname, path.extname(audioFile.originalname))
184 .replace(/_/g, ' ').trim();
185 const finalTitle = title?.trim() || fallbackTitle;
186 const finalArtist = artist?.trim() || null;
187 const finalAlbum = album?.trim() || null;
[834bcc3]188 // Ownership/licence. credit falls back to the artist; these go both into the
189 // DB and into the ID3 tags of the mp3 (copyright + comment).
[0d7acdf]190 const finalCredit = (req.body.credit || '').trim() || finalArtist || null;
191 const finalLicense = (req.body.license || '').trim() || null;
[183875b]192 const finalLinkSpotify = platformLink(req.body.link_spotify, LINK_DOMAINS.spotify);
193 const finalLinkYoutube = platformLink(req.body.link_youtube, LINK_DOMAINS.youtube);
194 const finalLinkSoundcloud = platformLink(req.body.link_soundcloud, LINK_DOMAINS.soundcloud);
[7bc636b]195
196 console.log('[admin-audio] upload received:', {
197 original: audioFile.originalname,
198 tempPath: audioFile.path,
199 size: audioFile.size,
200 hasC: !!coverFile,
201 });
202
203 let transcoded;
204 try {
205 transcoded = await transcodeToMp3({
206 inputPath: audioFile.path,
207 outputDir: AUDIO_DIR,
208 outputBaseName: inputBaseName,
209 tags: {
210 title: finalTitle,
211 artist: finalArtist || undefined,
212 album: finalAlbum || undefined,
[0d7acdf]213 copyright: finalCredit || undefined,
214 comment: finalLicense || undefined,
[7bc636b]215 },
216 });
217 console.log('[admin-audio] transcode OK:', transcoded);
218 } catch (transcodeErr) {
219 console.error('[admin-audio] Transcode failed:', transcodeErr);
220 // Transcoder kept the original on failure — clean it up ourselves
221 // since the upload as a whole has failed.
222 try { fs.unlinkSync(audioFile.path); } catch {}
223 if (coverFile) try { fs.unlinkSync(coverFile.path); } catch {}
224 return fail(500, 'Conversie mislukt: ' + transcodeErr.message);
225 }
226
227 try {
228 console.log('[admin-audio] inserting media row');
229 db.prepare(`
230 INSERT INTO media (id, site_id, filename, mime_type, size, storage_path)
231 VALUES (?, ?, ?, ?, ?, ?)
232 `).run(mediaId, site.id, transcoded.filename, transcoded.mimeType, transcoded.size, transcoded.path);
233
[834bcc3]234 // Duration automatically: primarily from the transcode (ffmpeg codecData), then
235 // an optional client-side value (bulk uploader reads <audio>.duration),
236 // otherwise NULL (UI then shows '—:—', editable manually in the editor).
[3e86f1c]237 const clientDur = req.body.duration != null ? parseInt(req.body.duration, 10) : NaN;
238 const finalDuration =
239 (transcoded.durationSec != null && transcoded.durationSec > 0) ? transcoded.durationSec
240 : (Number.isFinite(clientDur) && clientDur > 0) ? clientDur
241 : null;
242
243 console.log('[admin-audio] inserting audio_tracks row (duration=' + finalDuration + ')');
[7bc636b]244 db.prepare(`
[183875b]245 INSERT INTO audio_tracks (id, site_id, title, artist, album, duration, cover_url, credit, license, link_spotify, link_youtube, link_soundcloud, media_id, position)
246 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE(
[7bc636b]247 (SELECT MAX(position) + 1 FROM audio_tracks WHERE site_id = ?),
248 0
249 ))
250 `).run(
251 trackId, site.id,
252 finalTitle, finalArtist, finalAlbum,
[3e86f1c]253 finalDuration,
[7bc636b]254 coverUrl,
[0d7acdf]255 finalCredit, finalLicense,
[183875b]256 finalLinkSpotify, finalLinkYoutube, finalLinkSoundcloud,
[7bc636b]257 mediaId, site.id
258 );
259 console.log('[admin-audio] DB inserts OK — track', trackId);
260 } catch (dbErr) {
261 console.error('[admin-audio] DB insert failed:', dbErr);
262 // DB failed — clean up the transcoded mp3 so we don't leak files
263 try { fs.unlinkSync(transcoded.path); } catch {}
264 if (coverFile) try { fs.unlinkSync(coverFile.path); } catch {}
265 return fail(500, dbErr.message);
266 }
267
268 return ok({
269 id: trackId,
270 title: finalTitle,
271 artist: finalArtist,
272 album: finalAlbum,
273 size: transcoded.size,
274 });
275 });
276});
277
[834bcc3]278// Download-for-email per track on/off (premium #2). No-JS toggle from the
279// audio admin list → flip + back.
[91094a4]280router.post('/:id/downloadable', requireGod, (req, res) => {
281 const site = res.locals.site;
282 if (!site) return res.status(404).send('Site required');
283 const row = db.prepare('SELECT downloadable FROM audio_tracks WHERE id = ? AND site_id = ?').get(req.params.id, site.id);
284 if (row) {
285 db.prepare('UPDATE audio_tracks SET downloadable = ? WHERE id = ? AND site_id = ?')
286 .run(row.downloadable ? 0 : 1, req.params.id, site.id);
287 }
288 res.redirect('/admin/audio');
289});
290
[7bc636b]291router.post('/:id/delete', requireGod, (req, res) => {
292 const site = res.locals.site;
293 if (!site) return res.status(404).send('Site required');
294
295 const track = db.prepare(`
296 SELECT t.id AS track_id, m.id AS media_id, m.storage_path
297 FROM audio_tracks t LEFT JOIN media m ON m.id = t.media_id
298 WHERE t.id = ? AND t.site_id = ?
299 `).get(req.params.id, site.id);
300
301 if (!track) return res.redirect('/admin/audio?error=Not+found');
302
303 db.prepare('DELETE FROM audio_tracks WHERE id = ?').run(track.track_id);
304 if (track.media_id) {
305 db.prepare('DELETE FROM media WHERE id = ?').run(track.media_id);
306 }
307 if (track.storage_path) {
308 try { fs.unlinkSync(track.storage_path); } catch {}
309 }
310 res.redirect('/admin/audio?success=Deleted');
311});
312
313// ─── Orphan cleanup: rows whose file is missing on disk ───────────
314//
315// Two-phase to prevent accidental data loss:
316// GET /admin/audio/cleanup → dry-run report (no changes, JSON list)
317// POST /admin/audio/cleanup → actually deletes the orphan rows
318//
319// "Orphan" = an audio_tracks row whose media_id either points nowhere or
320// points to a media row whose storage_path file doesn't exist on disk.
321// This is the recovery path when DB and disk drift apart (e.g. AUDIO_PATH
322// changed between uploads, disk was wiped, or migration left stragglers).
323function findOrphans(siteId) {
324 const rows = db.prepare(`
325 SELECT t.id AS track_id, t.title, t.artist, t.album,
326 m.id AS media_id, m.storage_path
327 FROM audio_tracks t
328 LEFT JOIN media m ON m.id = t.media_id
329 WHERE t.site_id = ?
330 `).all(siteId);
331 const orphans = [];
332 for (const r of rows) {
333 if (!r.storage_path) {
334 orphans.push({ ...r, reason: 'no media row' });
335 continue;
336 }
337 try { fs.statSync(r.storage_path); }
338 catch { orphans.push({ ...r, reason: 'file missing on disk' }); }
339 }
340 return { total: rows.length, orphans };
341}
342
343router.get('/cleanup', requireGod, (req, res) => {
344 const site = res.locals.site;
345 if (!site) return res.status(404).json({ error: 'Site required' });
346 const result = findOrphans(site.id);
347 res.json({
348 ok: true,
349 siteId: site.id,
350 totalTracks: result.total,
351 orphanCount: result.orphans.length,
352 orphans: result.orphans.map(o => ({
353 track_id: o.track_id,
354 title: o.title || '(zonder titel)',
355 artist: o.artist || '—',
356 reason: o.reason,
357 storage_path: o.storage_path || null,
358 })),
359 note: 'POST to this same URL to actually delete these rows.',
360 });
361});
362
363router.post('/cleanup', requireGod, (req, res) => {
364 const site = res.locals.site;
365 if (!site) return res.status(404).json({ error: 'Site required' });
366 const { orphans } = findOrphans(site.id);
367
368 // Wrap in a transaction so a partial failure doesn't leave half-deleted state
369 const deleteOne = db.transaction((o) => {
370 db.prepare('DELETE FROM audio_tracks WHERE id = ?').run(o.track_id);
371 if (o.media_id) db.prepare('DELETE FROM media WHERE id = ?').run(o.media_id);
372 });
373 for (const o of orphans) deleteOne(o);
374
375 res.json({ ok: true, deleted: orphans.length });
376});
377
378
379//
380// All write endpoints expect to be hit by the track-editor modal which
381// sends X-CSRF-Token and JSON. They return { ok: true, ... } on success
382// or { error: '...' } with a 4xx status on failure.
383
384/** GET /admin/audio/api/albums — distinct list of album names (for datalist) */
385router.get('/api/albums', requireGod, (req, res) => {
386 const site = res.locals.site;
387 if (!site) return res.status(404).json({ error: 'Site required' });
388 const rows = db.prepare(`
389 SELECT DISTINCT album FROM audio_tracks
390 WHERE site_id = ? AND album IS NOT NULL AND album != ''
391 ORDER BY album COLLATE NOCASE
392 `).all(site.id);
393 res.json({ ok: true, albums: rows.map(r => r.album) });
394});
395
396/** GET /admin/audio/api/:id — single track with all metadata */
[834bcc3]397// Create a track WITHOUT an audio file (title + open-in links only). Appears
398// in albums/playlists in the list, with open-in icons but no play button.
[d727e92]399router.post('/create-link', requireGod, express.json(), (req, res) => {
400 const site = res.locals.site;
401 if (!site) return res.status(404).json({ error: 'Site required' });
402 const trackId = uuid();
403 const title = ((req.body && req.body.title) || 'Nieuwe track').toString().trim().slice(0, 200) || 'Nieuwe track';
404 try {
405 db.prepare(`
406 INSERT INTO audio_tracks (id, site_id, title, media_id, position)
407 VALUES (?, ?, ?, NULL, COALESCE((SELECT MAX(position) + 1 FROM audio_tracks WHERE site_id = ?), 0))
408 `).run(trackId, site.id, title, site.id);
409 } catch (e) {
410 return res.status(500).json({ error: e.message });
411 }
412 res.json({ ok: true, id: trackId });
413});
414
[7bc636b]415router.get('/api/:id', requireGod, (req, res) => {
416 const site = res.locals.site;
417 if (!site) return res.status(404).json({ error: 'Site required' });
418 const t = db.prepare(`
419 SELECT t.id, t.title, t.artist, t.album, t.duration, t.cover_url,
[183875b]420 t.credit, t.license, t.link_spotify, t.link_youtube, t.link_soundcloud,
421 t.position, t.created_at, m.filename
[7bc636b]422 FROM audio_tracks t LEFT JOIN media m ON m.id = t.media_id
423 WHERE t.id = ? AND t.site_id = ?
424 `).get(req.params.id, site.id);
425 if (!t) return res.status(404).json({ error: 'Track niet gevonden' });
[21522ae]426 // Stream URL so the modal can render an inline preview player.
427 const stream_url = t.filename ? audioUrl(t.filename) : null;
[7bc636b]428 res.json({ ok: true, track: { ...t, stream_url } });
429});
430
431/**
432 * POST /admin/audio/api/:id — update track metadata.
433 * Accepts JSON body with any subset of: title, artist, album, duration, cover_url.
434 * `title` is required if present (can't be blanked). Empty strings on optional
435 * fields are stored as NULL so the audio embed renderer's `t.artist || ''`
436 * fallback keeps working.
437 */
[0d7acdf]438router.post('/api/:id', requireGod, express.json(), async (req, res) => {
[7bc636b]439 const site = res.locals.site;
440 if (!site) return res.status(404).json({ error: 'Site required' });
441
442 const exists = db.prepare(
443 'SELECT id FROM audio_tracks WHERE id = ? AND site_id = ?'
444 ).get(req.params.id, site.id);
445 if (!exists) return res.status(404).json({ error: 'Track niet gevonden' });
446
447 const fields = [];
448 const values = [];
449 const body = req.body || {};
450
451 if (Object.prototype.hasOwnProperty.call(body, 'title')) {
452 const v = String(body.title || '').trim();
453 if (!v) return res.status(400).json({ error: 'Titel is verplicht' });
454 fields.push('title = ?'); values.push(v);
455 }
456 if (Object.prototype.hasOwnProperty.call(body, 'artist')) {
457 fields.push('artist = ?'); values.push(String(body.artist || '').trim() || null);
458 }
459 if (Object.prototype.hasOwnProperty.call(body, 'album')) {
460 fields.push('album = ?'); values.push(String(body.album || '').trim() || null);
461 }
462 if (Object.prototype.hasOwnProperty.call(body, 'duration')) {
463 const d = parseInt(body.duration, 10);
464 fields.push('duration = ?');
465 values.push(Number.isFinite(d) && d > 0 ? d : null);
466 }
467 if (Object.prototype.hasOwnProperty.call(body, 'cover_url')) {
468 // Accept either a /media/... path or an absolute https URL.
469 // Anything else (javascript:, data:, etc) gets blanked for safety.
470 const raw = String(body.cover_url || '').trim();
471 let safe = null;
472 if (raw === '') {
473 safe = null;
474 } else if (raw.startsWith('/media/') || raw.startsWith('https://') || raw.startsWith('http://')) {
475 safe = raw;
476 }
477 fields.push('cover_url = ?'); values.push(safe);
478 }
479
[91094a4]480 if (Object.prototype.hasOwnProperty.call(body, 'downloadable')) {
481 fields.push('downloadable = ?'); values.push(body.downloadable ? 1 : 0);
482 }
[0d7acdf]483 if (Object.prototype.hasOwnProperty.call(body, 'credit')) {
484 fields.push('credit = ?'); values.push(String(body.credit || '').trim() || null);
485 }
486 if (Object.prototype.hasOwnProperty.call(body, 'license')) {
487 fields.push('license = ?'); values.push(String(body.license || '').trim() || null);
488 }
[183875b]489 if (Object.prototype.hasOwnProperty.call(body, 'link_spotify')) {
490 fields.push('link_spotify = ?'); values.push(platformLink(body.link_spotify, LINK_DOMAINS.spotify));
491 }
492 if (Object.prototype.hasOwnProperty.call(body, 'link_youtube')) {
493 fields.push('link_youtube = ?'); values.push(platformLink(body.link_youtube, LINK_DOMAINS.youtube));
494 }
495 if (Object.prototype.hasOwnProperty.call(body, 'link_soundcloud')) {
496 fields.push('link_soundcloud = ?'); values.push(platformLink(body.link_soundcloud, LINK_DOMAINS.soundcloud));
497 }
[91094a4]498
[7bc636b]499 if (fields.length === 0) {
500 return res.status(400).json({ error: 'Niks om te updaten' });
501 }
502
503 try {
504 db.prepare(`UPDATE audio_tracks SET ${fields.join(', ')} WHERE id = ? AND site_id = ?`)
505 .run(...values, req.params.id, site.id);
506 } catch (err) {
507 return res.status(500).json({ error: err.message });
508 }
509
[834bcc3]510 // Fresh row + (if tag fields changed) retag the mp3, so that the owner/
511 // licence is also IN the file (ID3) and travels with it on download.
[7bc636b]512 const fresh = db.prepare(`
[0d7acdf]513 SELECT t.id, t.title, t.artist, t.album, t.duration, t.cover_url, t.credit, t.license, m.storage_path
514 FROM audio_tracks t LEFT JOIN media m ON m.id = t.media_id
515 WHERE t.id = ? AND t.site_id = ?
[7bc636b]516 `).get(req.params.id, site.id);
[0d7acdf]517
518 const tagsChanged = ['title', 'artist', 'album', 'credit', 'license']
519 .some((f) => Object.prototype.hasOwnProperty.call(body, f));
520 if (fresh && fresh.storage_path && tagsChanged) {
521 try {
522 await retagMp3({ filePath: fresh.storage_path, tags: {
523 title: fresh.title || undefined,
524 artist: fresh.artist || undefined,
525 album: fresh.album || undefined,
526 copyright: fresh.credit || undefined,
527 comment: fresh.license || undefined,
528 } });
529 } catch (e) {
[834bcc3]530 console.warn('[admin-audio] ID3 retag failed (DB was still updated):', e.message);
[0d7acdf]531 }
532 }
533 const { storage_path, ...trackOut } = fresh || {};
534 res.json({ ok: true, track: trackOut });
[7bc636b]535});
536
537/**
538 * POST /admin/audio/api/:id/cover — upload a new cover image and set it on
539 * the track in one go. Returns { ok, url } so the modal can preview.
540 *
541 * Reuses the same multer config as the upload form (5MB limit, jpg/png/webp/gif).
542 * If the track already had a cover stored under /media/audio-covers/, the old
543 * file is deleted to avoid orphaned bytes piling up.
544 */
545router.post('/api/:id/cover', requireGod, (req, res) => {
546 const site = res.locals.site;
547 if (!site) return res.status(404).json({ error: 'Site required' });
548
549 const exists = db.prepare(
550 'SELECT id, cover_url FROM audio_tracks WHERE id = ? AND site_id = ?'
551 ).get(req.params.id, site.id);
552 if (!exists) return res.status(404).json({ error: 'Track niet gevonden' });
553
554 upload.single('cover')(req, res, (err) => {
555 if (err) return res.status(400).json({ error: err.message });
556 const file = req.file;
557 if (!file) return res.status(400).json({ error: 'Geen bestand' });
558 if (file.size > MAX_COVER_BYTES) {
559 try { fs.unlinkSync(file.path); } catch {}
560 return res.status(413).json({ error: 'Te groot (max 5 MB)' });
561 }
562
[8f6225c]563 const newUrl = `/media/audio-covers/${toWebp(file)}`;
[7bc636b]564 try {
565 db.prepare('UPDATE audio_tracks SET cover_url = ? WHERE id = ? AND site_id = ?')
566 .run(newUrl, req.params.id, site.id);
567 } catch (dbErr) {
568 try { fs.unlinkSync(file.path); } catch {}
569 return res.status(500).json({ error: dbErr.message });
570 }
571
572 // Clean up the previous cover if it lived in our covers dir
573 if (exists.cover_url && exists.cover_url.startsWith('/media/audio-covers/')) {
574 const oldName = exists.cover_url.replace(/^\/media\/audio-covers\//, '');
575 const oldPath = path.join(COVER_DIR, oldName);
576 try { fs.unlinkSync(oldPath); } catch {}
577 }
578
579 // Return both keys so any caller using j.url OR j.cover_url works.
580 // Frontend (track-editor.ejs) reads j.cover_url — keep this in sync.
581 res.json({ ok: true, url: newUrl, cover_url: newUrl });
582 });
583});
584
[98a1990]585// Replace the audio FILE of an existing track (keeps all metadata + the track id, so any
586// [[track:id]] in posts keeps pointing here). Transcodes the new upload to a uniform mp3,
587// swaps the track's media_id + duration, and deletes the old media file/row.
588router.post('/api/:id/replace-audio', requireGod, (req, res) => {
589 const site = res.locals.site;
590 if (!site) return res.status(404).json({ ok: false, error: 'Site required' });
591 const track = db.prepare('SELECT id, media_id FROM audio_tracks WHERE id = ? AND site_id = ?').get(req.params.id, site.id);
592 if (!track) return res.status(404).json({ ok: false, error: 'Track niet gevonden' });
593
594 upload.single('audio')(req, res, async (err) => {
595 if (err) return res.status(400).json({ ok: false, error: err.message });
596 const file = req.file;
597 if (!file) return res.status(400).json({ ok: false, error: 'Geen bestand' });
598 const ext = path.extname(file.originalname).toLowerCase();
599 const limit = audioByteLimitFor(ext);
600 if (file.size > limit) {
601 try { fs.unlinkSync(file.path); } catch {}
602 return res.status(413).json({ ok: false, error: `Te groot (max ${Math.round(limit / 1024 / 1024)}MB voor ${ext || 'dit type'})` });
603 }
604
605 let transcoded;
606 try {
607 transcoded = await transcodeToMp3({
608 inputPath: file.path, outputDir: AUDIO_DIR,
609 outputBaseName: path.basename(file.filename, path.extname(file.filename)), tags: {},
610 });
611 } catch (e) {
612 try { fs.unlinkSync(file.path); } catch {}
613 return res.status(500).json({ ok: false, error: 'Conversie mislukt: ' + e.message });
614 }
615
616 const newMediaId = uuid();
617 try {
618 db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?,?,?,?,?,?)')
619 .run(newMediaId, site.id, transcoded.filename, transcoded.mimeType, transcoded.size, transcoded.path);
620 db.prepare('UPDATE audio_tracks SET media_id = ? WHERE id = ? AND site_id = ?').run(newMediaId, track.id, site.id);
621 const dur = (transcoded.durationSec != null && transcoded.durationSec > 0) ? transcoded.durationSec : null;
622 if (dur) db.prepare('UPDATE audio_tracks SET duration = ? WHERE id = ?').run(dur, track.id);
623 // Remove the OLD media (file + row), best-effort.
624 if (track.media_id && track.media_id !== newMediaId) {
625 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 {}
626 try { db.prepare('DELETE FROM media WHERE id = ?').run(track.media_id); } catch {}
627 }
628 return res.json({ ok: true, stream_url: audioUrl(transcoded.filename), duration: dur });
629 } catch (e) {
630 return res.status(500).json({ ok: false, error: e.message });
631 }
632 });
633});
634
[7bc636b]635export default router;
Note: See TracBrowser for help on using the repository browser.