| [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 |
|
|---|
| 12 | import express from 'express';
|
|---|
| 13 | import multer from 'multer';
|
|---|
| 14 | import path from 'path';
|
|---|
| 15 | import fs from 'fs';
|
|---|
| 16 | import { fileURLToPath } from 'url';
|
|---|
| 17 | import { v4 as uuid } from 'uuid';
|
|---|
| 18 | import db from '../config/database.js';
|
|---|
| 19 | import { renderPage } from '../middleware/render.js';
|
|---|
| [8f6225c] | 20 | import { toWebp } from '../services/ImageWebpService.js';
|
|---|
| [7bc636b] | 21 | import { requireGod } from '../middleware/auth.js';
|
|---|
| [0d7acdf] | 22 | import { transcodeToMp3, retagMp3 } from '../services/AudioTranscoder.js';
|
|---|
| [21522ae] | 23 | import { audioUrl } from '../services/AudioStreamService.js';
|
|---|
| [e2c3d09] | 24 | import { mediaDir } from '../config/paths.js';
|
|---|
| [8ed36de] | 25 | import * as ActivityPubService from '../services/ActivityPubService.js';
|
|---|
| [ca260d1] | 26 | import { postsEmbeddingTrack } from '../services/music/index.js';
|
|---|
| 27 | import { t as i18nT, resolveLang } from '../services/i18n.js';
|
|---|
| [7bc636b] | 28 |
|
|---|
| 29 | const __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.
|
|---|
| 33 | const AUDIO_DIR = path.resolve(
|
|---|
| 34 | process.env.AUDIO_PATH || path.join(__dirname, '..', '..', 'storage', 'audio')
|
|---|
| 35 | );
|
|---|
| [e2c3d09] | 36 | const COVER_DIR = mediaDir('COVER_PATH', 'audio-covers');
|
|---|
| [7bc636b] | 37 | fs.mkdirSync(AUDIO_DIR, { recursive: true });
|
|---|
| 38 | fs.mkdirSync(COVER_DIR, { recursive: true });
|
|---|
| 39 |
|
|---|
| 40 | const ALLOWED_AUDIO_EXT = new Set(['.mp3', '.m4a', '.mp4', '.aac', '.oga', '.ogg', '.opus', '.flac', '.wav', '.webm']);
|
|---|
| 41 | const ALLOWED_COVER_EXT = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif']);
|
|---|
| [834bcc3] | 42 | const MAX_AUDIO_BYTES = 50 * 1024 * 1024; // 50 MB — compressed formats (mp3/m4a/ogg/…)
|
|---|
| 43 | const MAX_WAV_BYTES = 100 * 1024 * 1024; // 100 MB — WAV is uncompressed, so a higher limit
|
|---|
| [fbc8628] | 44 | const 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] | 48 | const 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.
|
|---|
| 51 | const 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 |
|
|---|
| 61 | const 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 |
|
|---|
| 75 | const 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] | 79 | const LINK_DOMAINS = {
|
|---|
| 80 | spotify: ['spotify.com'],
|
|---|
| 81 | youtube: ['youtube.com', 'youtu.be', 'music.youtube.com'],
|
|---|
| 82 | soundcloud: ['soundcloud.com'],
|
|---|
| 83 | };
|
|---|
| 84 | function 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] | 94 | router.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 |
|
|---|
| 130 | router.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] | 284 | router.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 | */
|
|---|
| 321 | export 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 |
|
|---|
| 339 | router.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] | 351 | router.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).
|
|---|
| 389 | function 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 |
|
|---|
| 409 | router.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 |
|
|---|
| 429 | router.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) */
|
|---|
| 457 | router.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] | 471 | router.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] | 487 | router.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] | 510 | router.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 | */
|
|---|
| 617 | router.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.
|
|---|
| 660 | router.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] | 707 | export default router;
|
|---|