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

main
Last change on this file was 8ed36de, checked in by Robin <roboburr@…>, 3 weeks ago

Een verwijderde track kondigt zichzelf aan, net als een post

Een post stuurt bij verwijderen een Delete(Tombstone) naar zijn volgers; een
track deed dat niet. De rij ging weg, het Audio-object gaf 404, en elke server
die hem had geindexeerd bleef ernaar wijzen. Op de hub stond het er vandaag als
een kaartje met een dode link, en het viel alleen op doordat robo het zag.

Sinds shaer-0nh is een track een eersterangs Audio-object met een eigen id, dus
hij hoort ook een eigen afmelding te krijgen.

  • deliverObjectDelete(site, objectId) is de gedeelde romp; deliverDelete en het nieuwe deliverTrackDelete leunen er allebei op. Het object-id komt van de aanroeper, want bij verwijderen is de rij vaak al weg.
  • trackUri() staat nu op EEN plek in music/index.js. Het formaat werd eerder alleen door de bouwkant gekend, en een tweede plek die het opnieuw in elkaar zet is precies hoe je een Delete stuurt die de ontvanger niet herkent.
  • Beide verwijderroutes in admin-audio melden af: de losse verwijdering en de opruiming van wezen. Een wees is voor ONS een track zonder bestand, maar de buitenwereld heeft een gewoon Audio-object opgeslagen.

Twee tests door de echte route. Een testhaak op deliver() bestaat niet, dus de
proef kijkt in ap_delivery: de volger krijgt een inbox op een dichte poort, de
directe poging faalt, en de activiteit hoort in de wachtrij te belanden. Die
tabel was in het echte geval leeg, dus dat is precies het bewijs. Controleproef
gedraaid: zonder de aankondiging valt de test om.

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

  • Property mode set to 100644
File size: 27.6 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';
24import { mediaDir } from '../config/paths.js';
25import * as ActivityPubService from '../services/ActivityPubService.js';
26
27const __dirname = path.dirname(fileURLToPath(import.meta.url));
28// Audio files live OUTSIDE storage/media so the public /media static
29// handler can't serve them — they must go through the signed /audio/stream/
30// endpoint (anti-hotlink). Covers are public and stay in /media.
31const AUDIO_DIR = path.resolve(
32 process.env.AUDIO_PATH || path.join(__dirname, '..', '..', 'storage', 'audio')
33);
34const COVER_DIR = mediaDir('COVER_PATH', 'audio-covers');
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, 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.created_at DESC, t.position DESC
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 // admin-audio neemt de track-editor op, dus die module hoort erbij.
115 pageJs: 'admin-audio track-editor',
116 pageTitleKey: 'admin.t_audio',
117 bodyClass: 'on-admin',
118 tracks,
119 embedUrl,
120 error: req.query.error || null,
121 success: req.query.success || null,
122 maxBytesMb: Math.round(MAX_AUDIO_BYTES / 1024 / 1024),
123 maxWavMb: Math.round(MAX_WAV_BYTES / 1024 / 1024),
124 });
125});
126
127router.post('/upload', requireGod, (req, res) => {
128 // Helper: respond appropriately to JSON-accepting callers (the bulk
129 // uploader fetch() calls) vs traditional form posts (redirect).
130 // Both code paths cover identical errors below.
131 const wantsJson = req.get('Accept')?.includes('application/json') || req.xhr;
132 const fail = (status, message) => wantsJson
133 ? res.status(status).json({ ok: false, error: message })
134 : res.redirect('/admin/audio?error=' + encodeURIComponent(message));
135 const ok = (data) => wantsJson
136 ? res.json({ ok: true, ...data })
137 : res.redirect('/admin/audio?success=' + encodeURIComponent('Uploaded: ' + data.title));
138
139 upload.fields([{ name: 'audio', maxCount: 1 }, { name: 'cover', maxCount: 1 }])(req, res, async (err) => {
140 if (err) return fail(400, err.message);
141
142 const site = res.locals.site;
143 const audioFile = req.files?.audio?.[0];
144 const coverFile = req.files?.cover?.[0];
145
146 if (!site || !audioFile) {
147 // Clean up any cover that snuck through without an audio file
148 if (coverFile) try { fs.unlinkSync(coverFile.path); } catch {}
149 return fail(400, 'missing audio file');
150 }
151
152 // Per-type audio size check. multer's global limit was the WAV upper bound
153 // (100MB); compressed formats stay at 50MB.
154 const audioExt = path.extname(audioFile.originalname).toLowerCase();
155 const audioLimit = audioByteLimitFor(audioExt);
156 if (audioFile.size > audioLimit) {
157 try { fs.unlinkSync(audioFile.path); } catch {}
158 if (coverFile) try { fs.unlinkSync(coverFile.path); } catch {}
159 return fail(400, `audio te groot (max ${Math.round(audioLimit / 1024 / 1024)}MB voor ${audioExt || 'dit type'})`);
160 }
161
162 // Cover size check (multer's global limit was the audio upper bound)
163 if (coverFile && coverFile.size > MAX_COVER_BYTES) {
164 try { fs.unlinkSync(audioFile.path); } catch {}
165 try { fs.unlinkSync(coverFile.path); } catch {}
166 return fail(400, 'cover too large (max 5MB)');
167 }
168
169 const { title, artist, album } = req.body;
170 const trackId = uuid();
171 const mediaId = uuid();
172 const coverUrl = coverFile ? `/media/audio-covers/${coverFile.filename}` : null;
173
174 // ── TRANSCODE ────────────────────────────────────────────────
175 // Convert whatever the user uploaded to a uniform 192kbps stereo mp3.
176 // The original file (whatever its format) is deleted on success.
177 // multer named the upload <uuid>.<ext>; we re-use that uuid stem so
178 // the final file is just <uuid>.mp3, keeping things tidy.
179 const inputBaseName = path.basename(audioFile.filename, path.extname(audioFile.filename));
180 // Title fallback strategy:
181 // 1. Explicit `title` form field (single-upload form)
182 // 2. Original filename minus extension, with underscores → spaces
183 // (cleans up "Track_01_-_Title.mp3" patterns common from CD rips)
184 const fallbackTitle = path.basename(audioFile.originalname, path.extname(audioFile.originalname))
185 .replace(/_/g, ' ').trim();
186 const finalTitle = title?.trim() || fallbackTitle;
187 const finalArtist = artist?.trim() || null;
188 const finalAlbum = album?.trim() || null;
189 // Ownership/licence. credit falls back to the artist; these go both into the
190 // DB and into the ID3 tags of the mp3 (copyright + comment).
191 const finalCredit = (req.body.credit || '').trim() || finalArtist || null;
192 const finalLicense = (req.body.license || '').trim() || null;
193 const finalLinkSpotify = platformLink(req.body.link_spotify, LINK_DOMAINS.spotify);
194 const finalLinkYoutube = platformLink(req.body.link_youtube, LINK_DOMAINS.youtube);
195 const finalLinkSoundcloud = platformLink(req.body.link_soundcloud, LINK_DOMAINS.soundcloud);
196
197 console.log('[admin-audio] upload received:', {
198 original: audioFile.originalname,
199 tempPath: audioFile.path,
200 size: audioFile.size,
201 hasC: !!coverFile,
202 });
203
204 let transcoded;
205 try {
206 transcoded = await transcodeToMp3({
207 inputPath: audioFile.path,
208 outputDir: AUDIO_DIR,
209 outputBaseName: inputBaseName,
210 tags: {
211 title: finalTitle,
212 artist: finalArtist || undefined,
213 album: finalAlbum || undefined,
214 copyright: finalCredit || undefined,
215 comment: finalLicense || undefined,
216 },
217 });
218 console.log('[admin-audio] transcode OK:', transcoded);
219 } catch (transcodeErr) {
220 console.error('[admin-audio] Transcode failed:', transcodeErr);
221 // Transcoder kept the original on failure — clean it up ourselves
222 // since the upload as a whole has failed.
223 try { fs.unlinkSync(audioFile.path); } catch {}
224 if (coverFile) try { fs.unlinkSync(coverFile.path); } catch {}
225 return fail(500, 'Conversie mislukt: ' + transcodeErr.message);
226 }
227
228 try {
229 console.log('[admin-audio] inserting media row');
230 db.prepare(`
231 INSERT INTO media (id, site_id, filename, mime_type, size, storage_path)
232 VALUES (?, ?, ?, ?, ?, ?)
233 `).run(mediaId, site.id, transcoded.filename, transcoded.mimeType, transcoded.size, transcoded.path);
234
235 // Duration automatically: primarily from the transcode (ffmpeg codecData), then
236 // an optional client-side value (bulk uploader reads <audio>.duration),
237 // otherwise NULL (UI then shows '—:—', editable manually in the editor).
238 const clientDur = req.body.duration != null ? parseInt(req.body.duration, 10) : NaN;
239 const finalDuration =
240 (transcoded.durationSec != null && transcoded.durationSec > 0) ? transcoded.durationSec
241 : (Number.isFinite(clientDur) && clientDur > 0) ? clientDur
242 : null;
243
244 console.log('[admin-audio] inserting audio_tracks row (duration=' + finalDuration + ')');
245 db.prepare(`
246 INSERT INTO audio_tracks (id, site_id, title, artist, album, duration, cover_url, credit, license, link_spotify, link_youtube, link_soundcloud, media_id, position)
247 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE(
248 (SELECT MAX(position) + 1 FROM audio_tracks WHERE site_id = ?),
249 0
250 ))
251 `).run(
252 trackId, site.id,
253 finalTitle, finalArtist, finalAlbum,
254 finalDuration,
255 coverUrl,
256 finalCredit, finalLicense,
257 finalLinkSpotify, finalLinkYoutube, finalLinkSoundcloud,
258 mediaId, site.id
259 );
260 console.log('[admin-audio] DB inserts OK — track', trackId);
261 } catch (dbErr) {
262 console.error('[admin-audio] DB insert failed:', dbErr);
263 // DB failed — clean up the transcoded mp3 so we don't leak files
264 try { fs.unlinkSync(transcoded.path); } catch {}
265 if (coverFile) try { fs.unlinkSync(coverFile.path); } catch {}
266 return fail(500, dbErr.message);
267 }
268
269 return ok({
270 id: trackId,
271 title: finalTitle,
272 artist: finalArtist,
273 album: finalAlbum,
274 size: transcoded.size,
275 });
276 });
277});
278
279// Download-for-email per track on/off (premium #2). No-JS toggle from the
280// audio admin list → flip + back.
281router.post('/:id/downloadable', requireGod, (req, res) => {
282 const site = res.locals.site;
283 if (!site) return res.status(404).send('Site required');
284 const row = db.prepare('SELECT downloadable FROM audio_tracks WHERE id = ? AND site_id = ?').get(req.params.id, site.id);
285 if (row) {
286 db.prepare('UPDATE audio_tracks SET downloadable = ? WHERE id = ? AND site_id = ?')
287 .run(row.downloadable ? 0 : 1, req.params.id, site.id);
288 }
289 res.redirect('/admin/audio');
290});
291
292router.post('/:id/delete', requireGod, (req, res) => {
293 const site = res.locals.site;
294 if (!site) return res.status(404).send('Site required');
295
296 const track = db.prepare(`
297 SELECT t.id AS track_id, m.id AS media_id, m.storage_path
298 FROM audio_tracks t LEFT JOIN media m ON m.id = t.media_id
299 WHERE t.id = ? AND t.site_id = ?
300 `).get(req.params.id, site.id);
301
302 if (!track) return res.redirect('/admin/audio?error=Not+found');
303
304 // Zeg de fediverse dat de track weg is, VOOR de rij verdwijnt -- zelfde
305 // volgorde en zelfde reden als bij een post (posts.js). Zonder dit blijft
306 // elke server die hem indexeerde ernaar wijzen terwijl het object 404 geeft;
307 // op de hub stond daardoor op 21-8 een track met een dode link.
308 ActivityPubService.deliverTrackDelete(site, track.track_id).catch(() => { /* best-effort */ });
309
310 db.prepare('DELETE FROM audio_tracks WHERE id = ?').run(track.track_id);
311 if (track.media_id) {
312 db.prepare('DELETE FROM media WHERE id = ?').run(track.media_id);
313 }
314 if (track.storage_path) {
315 try { fs.unlinkSync(track.storage_path); } catch {}
316 }
317 res.redirect('/admin/audio?success=Deleted');
318});
319
320// ─── Orphan cleanup: rows whose file is missing on disk ───────────
321//
322// Two-phase to prevent accidental data loss:
323// GET /admin/audio/cleanup → dry-run report (no changes, JSON list)
324// POST /admin/audio/cleanup → actually deletes the orphan rows
325//
326// "Orphan" = an audio_tracks row whose media_id either points nowhere or
327// points to a media row whose storage_path file doesn't exist on disk.
328// This is the recovery path when DB and disk drift apart (e.g. AUDIO_PATH
329// changed between uploads, disk was wiped, or migration left stragglers).
330function findOrphans(siteId) {
331 const rows = db.prepare(`
332 SELECT t.id AS track_id, t.title, t.artist, t.album,
333 m.id AS media_id, m.storage_path
334 FROM audio_tracks t
335 LEFT JOIN media m ON m.id = t.media_id
336 WHERE t.site_id = ?
337 `).all(siteId);
338 const orphans = [];
339 for (const r of rows) {
340 if (!r.storage_path) {
341 orphans.push({ ...r, reason: 'no media row' });
342 continue;
343 }
344 try { fs.statSync(r.storage_path); }
345 catch { orphans.push({ ...r, reason: 'file missing on disk' }); }
346 }
347 return { total: rows.length, orphans };
348}
349
350router.get('/cleanup', requireGod, (req, res) => {
351 const site = res.locals.site;
352 if (!site) return res.status(404).json({ error: 'Site required' });
353 const result = findOrphans(site.id);
354 res.json({
355 ok: true,
356 siteId: site.id,
357 totalTracks: result.total,
358 orphanCount: result.orphans.length,
359 orphans: result.orphans.map(o => ({
360 track_id: o.track_id,
361 title: o.title || '(zonder titel)',
362 artist: o.artist || '—',
363 reason: o.reason,
364 storage_path: o.storage_path || null,
365 })),
366 note: 'POST to this same URL to actually delete these rows.',
367 });
368});
369
370router.post('/cleanup', requireGod, (req, res) => {
371 const site = res.locals.site;
372 if (!site) return res.status(404).json({ error: 'Site required' });
373 const { orphans } = findOrphans(site.id);
374
375 // Wrap in a transaction so a partial failure doesn't leave half-deleted state
376 const deleteOne = db.transaction((o) => {
377 db.prepare('DELETE FROM audio_tracks WHERE id = ?').run(o.track_id);
378 if (o.media_id) db.prepare('DELETE FROM media WHERE id = ?').run(o.media_id);
379 });
380 for (const o of orphans) {
381 // Ook hier aankondigen. Een wees is voor ONS een track zonder bestand, maar
382 // voor de buitenwereld was het een gewoon Audio-object dat zij hebben
383 // opgeslagen; stil weggooien laat hun kopie staan.
384 ActivityPubService.deliverTrackDelete(site, o.track_id).catch(() => { /* best-effort */ });
385 deleteOne(o);
386 }
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
598// Replace the audio FILE of an existing track (keeps all metadata + the track id, so any
599// [[track:id]] in posts keeps pointing here). Transcodes the new upload to a uniform mp3,
600// swaps the track's media_id + duration, and deletes the old media file/row.
601router.post('/api/:id/replace-audio', requireGod, (req, res) => {
602 const site = res.locals.site;
603 if (!site) return res.status(404).json({ ok: false, error: 'Site required' });
604 const track = db.prepare('SELECT id, media_id FROM audio_tracks WHERE id = ? AND site_id = ?').get(req.params.id, site.id);
605 if (!track) return res.status(404).json({ ok: false, error: 'Track niet gevonden' });
606
607 upload.single('audio')(req, res, async (err) => {
608 if (err) return res.status(400).json({ ok: false, error: err.message });
609 const file = req.file;
610 if (!file) return res.status(400).json({ ok: false, error: 'Geen bestand' });
611 const ext = path.extname(file.originalname).toLowerCase();
612 const limit = audioByteLimitFor(ext);
613 if (file.size > limit) {
614 try { fs.unlinkSync(file.path); } catch {}
615 return res.status(413).json({ ok: false, error: `Te groot (max ${Math.round(limit / 1024 / 1024)}MB voor ${ext || 'dit type'})` });
616 }
617
618 let transcoded;
619 try {
620 transcoded = await transcodeToMp3({
621 inputPath: file.path, outputDir: AUDIO_DIR,
622 outputBaseName: path.basename(file.filename, path.extname(file.filename)), tags: {},
623 });
624 } catch (e) {
625 try { fs.unlinkSync(file.path); } catch {}
626 return res.status(500).json({ ok: false, error: 'Conversie mislukt: ' + e.message });
627 }
628
629 const newMediaId = uuid();
630 try {
631 db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?,?,?,?,?,?)')
632 .run(newMediaId, site.id, transcoded.filename, transcoded.mimeType, transcoded.size, transcoded.path);
633 db.prepare('UPDATE audio_tracks SET media_id = ? WHERE id = ? AND site_id = ?').run(newMediaId, track.id, site.id);
634 const dur = (transcoded.durationSec != null && transcoded.durationSec > 0) ? transcoded.durationSec : null;
635 if (dur) db.prepare('UPDATE audio_tracks SET duration = ? WHERE id = ?').run(dur, track.id);
636 // Remove the OLD media (file + row), best-effort.
637 if (track.media_id && track.media_id !== newMediaId) {
638 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 {}
639 try { db.prepare('DELETE FROM media WHERE id = ?').run(track.media_id); } catch {}
640 }
641 return res.json({ ok: true, stream_url: audioUrl(transcoded.filename), duration: dur });
642 } catch (e) {
643 return res.status(500).json({ ok: false, error: e.message });
644 }
645 });
646});
647
648export default router;
Note: See TracBrowser for help on using the repository browser.