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

main
Last change on this file since f2eacca was f2eacca, checked in by roboburr <roboburr@…>, 2 months ago

feat(music): per-track "share on the fediverse" — federate the file as a native AS2 Audio attachment

A per-track opt-in (default off) so an OPEN track's audio file is federated as a real AS2 Audio
attachment and served ungated → it plays inline in EVERY fediverse client, incl. the official
Mastodon apps (which only play native media, not external player cards). Gated tracks (default)
keep the file hidden + web-player-only. This is the spec-canonical way to federate audio; the
gated path stays the deliberate anti-steal choice.

  • src/config/database.js — audio_tracks.fedi_open column (default 0)
  • src/routes/audio.js — /audio/stream serves fedi_open tracks ungated so remote servers can fetch them
  • src/services/ActivityPubService.js (buildNote) — fedi_open tracks → AS2 Audio attachments (the file URL)
  • src/routes/admin-audio.js — POST /:id/fedi-open toggle (god-only) + fedi_open in the track query
  • src/views/pages/admin-audio.ejs — per-track share toggle next to the download toggle
  • src/services/i18n.js — aaud.fedi_on/off labels (nl/en/de)

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

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