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

main
Last change on this file since 31680c3 was ca260d1, checked in by Robin <roboburr@…>, 7 hours ago

Fedi open audio per track, vanuit de mediamanager

Robin, 30-9: "Fedi open audio ook per track aan kunnen zetten in de media
manager."

De vlag was altijd al per track (audio_tracks.fedi_open): hij opent het
bestand en maakt er een echte Audio-bijlage van, met een speler in Mastodon en
consorten. Aanzetten kon alleen per POST, met het vinkje in de editor, dat elke
track in die post opende. Nu staat er in de mediamanager, tab Audio, per track
een wereldbol.

EENRICHTINGS, net als in de editor. Dicht is het een gedimde knop die eerst
vraagt of het zeker is, want terug kan niet; open is het een slotje in de
accentkleur en geen knop meer. Een bestand dat al gefedereerd is staat op andere
servers, en het hier weer afschermen zou schijnveiligheid zijn. Ook de algemene
bijwerk-API van een track neemt fedi_open niet aan; een toets bewaakt dat.

Alleen bij een track met een eigen bestand: een track die alleen een link is,
heeft niets om te openen. Een formulier en geen fetch, dus het werkt zonder
JavaScript, en data-confirm uit shell.ejs stelt de vraag.

EN DE POSTS GAAN OPNIEUW DE DEUR UIT. Het vinkje in de editor werkt zichtbaar
omdat het opslaan van de post een Update stuurt, met de audio erin. Een knop
die alleen de vlag zet, laat elke post die al verstuurd is zonder speler staan
tot iemand hem bewerkt. Dus hier dezelfde Update, voor elke gepubliceerde post
van deze site waar de track in staat: rechtstreeks, via een playlist of via zijn
album. postsEmbeddingTrack in music/index.js zoekt die op; trackHostPosts kiest
per track maar een post, voor de bibliotheek. De bibliotheek zelf hoeft niets,
die wordt opgehaald en een open track staat er meteen in.

Getoetst: openen zet de vlag en stuurt precies de drie posts (geen concept,
geen post zonder de track, geen post van een andere site); nog eens openen doet
niets; een link-track en een track van een andere site blijven dicht; de route;
geen weg terug via de API; de knop alleen waar het kan, met bevestiging; en de
hulpfunctie rechtstreeks. Tegenproef per zaak. Eentje ving eerst niets: het
sitefilter zit dubbel, want openen haalt elke post nog eens op met site_id. De
hulpfunctie is los te gebruiken, dus die wordt nu ook rechtstreeks getoetst.
Volledige suite 1301 groen.

Nagekeken in de gerenderde mediamanager met de echte stylesheet: open in de
accentkleur, dicht gedimd, geen wereldbol bij een link-track, dezelfde maat als
de andere knoppen in de rij.

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

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