Index: src/routes/admin-audio.js
===================================================================
--- src/routes/admin-audio.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/routes/admin-audio.js	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -18,10 +18,7 @@
 import db from '../config/database.js';
 import { renderPage } from '../middleware/render.js';
-import { toWebp } from '../services/ImageWebpService.js';
 import { requireGod } from '../middleware/auth.js';
-import { transcodeToMp3, retagMp3 } from '../services/AudioTranscoder.js';
-import { audioUrl } from '../services/AudioStreamService.js';
-import { mediaDir } from '../config/paths.js';
-import * as ActivityPubService from '../services/ActivityPubService.js';
+import { transcodeToMp3 } from '../services/AudioTranscoder.js';
+import { signUrl } from '../services/AudioStreamService.js';
 
 const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -32,5 +29,7 @@
   process.env.AUDIO_PATH || path.join(__dirname, '..', '..', 'storage', 'audio')
 );
-const COVER_DIR = mediaDir('COVER_PATH', 'audio-covers');
+const COVER_DIR = path.resolve(
+  process.env.COVER_PATH || path.join(__dirname, '..', '..', 'storage', 'media', 'audio-covers')
+);
 fs.mkdirSync(AUDIO_DIR, { recursive: true });
 fs.mkdirSync(COVER_DIR, { recursive: true });
@@ -38,11 +37,6 @@
 const ALLOWED_AUDIO_EXT = new Set(['.mp3', '.m4a', '.mp4', '.aac', '.oga', '.ogg', '.opus', '.flac', '.wav', '.webm']);
 const ALLOWED_COVER_EXT = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif']);
-const MAX_AUDIO_BYTES = 50 * 1024 * 1024;   // 50 MB — compressed formats (mp3/m4a/ogg/…)
-const MAX_WAV_BYTES   = 100 * 1024 * 1024;  // 100 MB — WAV is uncompressed, so a higher limit
-const MAX_COVER_BYTES = 5 * 1024 * 1024;    // 5 MB
-
-// Per-file upper limit based on extension. multer's global limit is the
-// highest (WAV); the real per-type check happens in the upload handler.
-const audioByteLimitFor = (ext) => (ext.toLowerCase() === '.wav' ? MAX_WAV_BYTES : MAX_AUDIO_BYTES);
+const MAX_AUDIO_BYTES = 50 * 1024 * 1024;  // 50 MB
+const MAX_COVER_BYTES = 5 * 1024 * 1024;   // 5 MB
 
 // Multer routes audio + cover into separate dirs based on field name.
@@ -59,5 +53,5 @@
 const upload = multer({
   storage,
-  limits: { fileSize: MAX_WAV_BYTES }, // highest upper bound (WAV) — per-type check in the handler
+  limits: { fileSize: MAX_AUDIO_BYTES }, // upper bound — per-field check below
   fileFilter: (req, file, cb) => {
     const ext = path.extname(file.originalname).toLowerCase();
@@ -73,21 +67,4 @@
 const router = express.Router();
 
-// "Open in" platform links per track: only https + the correct host accepted
-// (href arrives unescaped in the view → scheme/host guard against abuse).
-const LINK_DOMAINS = {
-  spotify: ['spotify.com'],
-  youtube: ['youtube.com', 'youtu.be', 'music.youtube.com'],
-  soundcloud: ['soundcloud.com'],
-};
-function platformLink(url, domains) {
-  const u = String(url || '').trim();
-  if (!u || !/^https:\/\//i.test(u)) return null;
-  try {
-    const h = new URL(u).hostname.toLowerCase();
-    if (domains.some((d) => h === d || h.endsWith('.' + d))) return u;
-  } catch (e) { /* invalid URL */ }
-  return null;
-}
-
 router.get('/', requireGod, (req, res) => {
   const site = res.locals.site;
@@ -96,30 +73,26 @@
   const rows = db.prepare(`
     SELECT t.id, t.title, t.artist, t.album, t.duration, t.cover_url,
-           t.position, t.created_at, t.downloadable, m.filename, m.size, m.mime_type
+           t.position, t.created_at, m.filename, m.size, m.mime_type
     FROM audio_tracks t
     LEFT JOIN media m ON m.id = t.media_id
     WHERE t.site_id = ?
-    ORDER BY t.created_at DESC, t.position DESC
+    ORDER BY t.position ASC, t.created_at ASC
   `).all(site.id);
 
-  // Build each track's stream URL so admins can preview audio inline.
+  // Sign each track's stream URL so admins can preview audio inline.
+  // Short TTL (default 10 min from AudioStreamService) means the URL on
+  // the page expires if it sits open too long; a refresh re-signs.
   const tracks = rows.map(t => ({
     ...t,
-    stream_url: t.filename ? audioUrl(t.filename) : null,
+    stream_url: t.filename ? signUrl(t.filename).url : null,
   }));
 
-  const base = (process.env.PUBLIC_BASE_URL || ('https://' + (req.get('host') || ''))).replace(/\/$/, '');
-  const embedUrl = base + (res.locals.siteUrlBase || '') + '/embed';
   renderPage(req, res, 'pages/admin-audio', {
-    // admin-audio neemt de track-editor op, dus die module hoort erbij.
-    pageJs: 'admin-audio track-editor',
-    pageTitleKey: 'admin.t_audio',
+    pageTitle: 'Audio tracks',
     bodyClass: 'on-admin',
     tracks,
-    embedUrl,
     error: req.query.error || null,
     success: req.query.success || null,
     maxBytesMb: Math.round(MAX_AUDIO_BYTES / 1024 / 1024),
-    maxWavMb: Math.round(MAX_WAV_BYTES / 1024 / 1024),
   });
 });
@@ -148,14 +121,4 @@
       if (coverFile) try { fs.unlinkSync(coverFile.path); } catch {}
       return fail(400, 'missing audio file');
-    }
-
-    // Per-type audio size check. multer's global limit was the WAV upper bound
-    // (100MB); compressed formats stay at 50MB.
-    const audioExt = path.extname(audioFile.originalname).toLowerCase();
-    const audioLimit = audioByteLimitFor(audioExt);
-    if (audioFile.size > audioLimit) {
-      try { fs.unlinkSync(audioFile.path); } catch {}
-      if (coverFile) try { fs.unlinkSync(coverFile.path); } catch {}
-      return fail(400, `audio te groot (max ${Math.round(audioLimit / 1024 / 1024)}MB voor ${audioExt || 'dit type'})`);
     }
 
@@ -187,11 +150,4 @@
     const finalArtist = artist?.trim() || null;
     const finalAlbum  = album?.trim() || null;
-    // Ownership/licence. credit falls back to the artist; these go both into the
-    // DB and into the ID3 tags of the mp3 (copyright + comment).
-    const finalCredit  = (req.body.credit  || '').trim() || finalArtist || null;
-    const finalLicense = (req.body.license || '').trim() || null;
-    const finalLinkSpotify    = platformLink(req.body.link_spotify, LINK_DOMAINS.spotify);
-    const finalLinkYoutube    = platformLink(req.body.link_youtube, LINK_DOMAINS.youtube);
-    const finalLinkSoundcloud = platformLink(req.body.link_soundcloud, LINK_DOMAINS.soundcloud);
 
     console.log('[admin-audio] upload received:', {
@@ -212,6 +168,4 @@
           artist: finalArtist || undefined,
           album: finalAlbum || undefined,
-          copyright: finalCredit || undefined,
-          comment: finalLicense || undefined,
         },
       });
@@ -233,17 +187,8 @@
       `).run(mediaId, site.id, transcoded.filename, transcoded.mimeType, transcoded.size, transcoded.path);
 
-      // Duration automatically: primarily from the transcode (ffmpeg codecData), then
-      // an optional client-side value (bulk uploader reads <audio>.duration),
-      // otherwise NULL (UI then shows '—:—', editable manually in the editor).
-      const clientDur = req.body.duration != null ? parseInt(req.body.duration, 10) : NaN;
-      const finalDuration =
-        (transcoded.durationSec != null && transcoded.durationSec > 0) ? transcoded.durationSec
-        : (Number.isFinite(clientDur) && clientDur > 0) ? clientDur
-        : null;
-
-      console.log('[admin-audio] inserting audio_tracks row (duration=' + finalDuration + ')');
+      console.log('[admin-audio] inserting audio_tracks row');
       db.prepare(`
-        INSERT INTO audio_tracks (id, site_id, title, artist, album, duration, cover_url, credit, license, link_spotify, link_youtube, link_soundcloud, media_id, position)
-        VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE(
+        INSERT INTO audio_tracks (id, site_id, title, artist, album, cover_url, media_id, position)
+        VALUES (?, ?, ?, ?, ?, ?, ?, COALESCE(
           (SELECT MAX(position) + 1 FROM audio_tracks WHERE site_id = ?),
           0
@@ -252,8 +197,5 @@
         trackId, site.id,
         finalTitle, finalArtist, finalAlbum,
-        finalDuration,
         coverUrl,
-        finalCredit, finalLicense,
-        finalLinkSpotify, finalLinkYoutube, finalLinkSoundcloud,
         mediaId, site.id
       );
@@ -277,17 +219,4 @@
 });
 
-// Download-for-email per track on/off (premium #2). No-JS toggle from the
-// audio admin list → flip + back.
-router.post('/:id/downloadable', requireGod, (req, res) => {
-  const site = res.locals.site;
-  if (!site) return res.status(404).send('Site required');
-  const row = db.prepare('SELECT downloadable FROM audio_tracks WHERE id = ? AND site_id = ?').get(req.params.id, site.id);
-  if (row) {
-    db.prepare('UPDATE audio_tracks SET downloadable = ? WHERE id = ? AND site_id = ?')
-      .run(row.downloadable ? 0 : 1, req.params.id, site.id);
-  }
-  res.redirect('/admin/audio');
-});
-
 router.post('/:id/delete', requireGod, (req, res) => {
   const site = res.locals.site;
@@ -301,10 +230,4 @@
 
   if (!track) return res.redirect('/admin/audio?error=Not+found');
-
-  // Zeg de fediverse dat de track weg is, VOOR de rij verdwijnt -- zelfde
-  // volgorde en zelfde reden als bij een post (posts.js). Zonder dit blijft
-  // elke server die hem indexeerde ernaar wijzen terwijl het object 404 geeft;
-  // op de hub stond daardoor op 21-8 een track met een dode link.
-  ActivityPubService.deliverTrackDelete(site, track.track_id).catch(() => { /* best-effort */ });
 
   db.prepare('DELETE FROM audio_tracks WHERE id = ?').run(track.track_id);
@@ -378,11 +301,5 @@
     if (o.media_id) db.prepare('DELETE FROM media WHERE id = ?').run(o.media_id);
   });
-  for (const o of orphans) {
-    // Ook hier aankondigen. Een wees is voor ONS een track zonder bestand, maar
-    // voor de buitenwereld was het een gewoon Audio-object dat zij hebben
-    // opgeslagen; stil weggooien laat hun kopie staan.
-    ActivityPubService.deliverTrackDelete(site, o.track_id).catch(() => { /* best-effort */ });
-    deleteOne(o);
-  }
+  for (const o of orphans) deleteOne(o);
 
   res.json({ ok: true, deleted: orphans.length });
@@ -408,22 +325,4 @@
 
 /** GET /admin/audio/api/:id — single track with all metadata */
-// Create a track WITHOUT an audio file (title + open-in links only). Appears
-// in albums/playlists in the list, with open-in icons but no play button.
-router.post('/create-link', requireGod, express.json(), (req, res) => {
-  const site = res.locals.site;
-  if (!site) return res.status(404).json({ error: 'Site required' });
-  const trackId = uuid();
-  const title = ((req.body && req.body.title) || 'Nieuwe track').toString().trim().slice(0, 200) || 'Nieuwe track';
-  try {
-    db.prepare(`
-      INSERT INTO audio_tracks (id, site_id, title, media_id, position)
-      VALUES (?, ?, ?, NULL, COALESCE((SELECT MAX(position) + 1 FROM audio_tracks WHERE site_id = ?), 0))
-    `).run(trackId, site.id, title, site.id);
-  } catch (e) {
-    return res.status(500).json({ error: e.message });
-  }
-  res.json({ ok: true, id: trackId });
-});
-
 router.get('/api/:id', requireGod, (req, res) => {
   const site = res.locals.site;
@@ -431,5 +330,4 @@
   const t = db.prepare(`
     SELECT t.id, t.title, t.artist, t.album, t.duration, t.cover_url,
-           t.credit, t.license, t.link_spotify, t.link_youtube, t.link_soundcloud,
            t.position, t.created_at, m.filename
     FROM audio_tracks t LEFT JOIN media m ON m.id = t.media_id
@@ -437,6 +335,6 @@
   `).get(req.params.id, site.id);
   if (!t) return res.status(404).json({ error: 'Track niet gevonden' });
-  // Stream URL so the modal can render an inline preview player.
-  const stream_url = t.filename ? audioUrl(t.filename) : null;
+  // Sign the stream URL so the modal can render an inline preview player.
+  const stream_url = t.filename ? signUrl(t.filename).url : null;
   res.json({ ok: true, track: { ...t, stream_url } });
 });
@@ -449,5 +347,5 @@
  * fallback keeps working.
  */
-router.post('/api/:id', requireGod, express.json(), async (req, res) => {
+router.post('/api/:id', requireGod, express.json(), (req, res) => {
   const site = res.locals.site;
   if (!site) return res.status(404).json({ error: 'Site required' });
@@ -491,23 +389,4 @@
   }
 
-  if (Object.prototype.hasOwnProperty.call(body, 'downloadable')) {
-    fields.push('downloadable = ?'); values.push(body.downloadable ? 1 : 0);
-  }
-  if (Object.prototype.hasOwnProperty.call(body, 'credit')) {
-    fields.push('credit = ?'); values.push(String(body.credit || '').trim() || null);
-  }
-  if (Object.prototype.hasOwnProperty.call(body, 'license')) {
-    fields.push('license = ?'); values.push(String(body.license || '').trim() || null);
-  }
-  if (Object.prototype.hasOwnProperty.call(body, 'link_spotify')) {
-    fields.push('link_spotify = ?'); values.push(platformLink(body.link_spotify, LINK_DOMAINS.spotify));
-  }
-  if (Object.prototype.hasOwnProperty.call(body, 'link_youtube')) {
-    fields.push('link_youtube = ?'); values.push(platformLink(body.link_youtube, LINK_DOMAINS.youtube));
-  }
-  if (Object.prototype.hasOwnProperty.call(body, 'link_soundcloud')) {
-    fields.push('link_soundcloud = ?'); values.push(platformLink(body.link_soundcloud, LINK_DOMAINS.soundcloud));
-  }
-
   if (fields.length === 0) {
     return res.status(400).json({ error: 'Niks om te updaten' });
@@ -521,29 +400,10 @@
   }
 
-  // Fresh row + (if tag fields changed) retag the mp3, so that the owner/
-  // licence is also IN the file (ID3) and travels with it on download.
+  // Return fresh row so the caller can update its UI without reloading
   const fresh = db.prepare(`
-    SELECT t.id, t.title, t.artist, t.album, t.duration, t.cover_url, t.credit, t.license, m.storage_path
-    FROM audio_tracks t LEFT JOIN media m ON m.id = t.media_id
-    WHERE t.id = ? AND t.site_id = ?
+    SELECT id, title, artist, album, duration, cover_url
+    FROM audio_tracks WHERE id = ? AND site_id = ?
   `).get(req.params.id, site.id);
-
-  const tagsChanged = ['title', 'artist', 'album', 'credit', 'license']
-    .some((f) => Object.prototype.hasOwnProperty.call(body, f));
-  if (fresh && fresh.storage_path && tagsChanged) {
-    try {
-      await retagMp3({ filePath: fresh.storage_path, tags: {
-        title: fresh.title || undefined,
-        artist: fresh.artist || undefined,
-        album: fresh.album || undefined,
-        copyright: fresh.credit || undefined,
-        comment: fresh.license || undefined,
-      } });
-    } catch (e) {
-      console.warn('[admin-audio] ID3 retag failed (DB was still updated):', e.message);
-    }
-  }
-  const { storage_path, ...trackOut } = fresh || {};
-  res.json({ ok: true, track: trackOut });
+  res.json({ ok: true, track: fresh });
 });
 
@@ -574,5 +434,5 @@
     }
 
-    const newUrl = `/media/audio-covers/${toWebp(file)}`;
+    const newUrl = `/media/audio-covers/${file.filename}`;
     try {
       db.prepare('UPDATE audio_tracks SET cover_url = ? WHERE id = ? AND site_id = ?')
@@ -596,53 +456,3 @@
 });
 
-// Replace the audio FILE of an existing track (keeps all metadata + the track id, so any
-// [[track:id]] in posts keeps pointing here). Transcodes the new upload to a uniform mp3,
-// swaps the track's media_id + duration, and deletes the old media file/row.
-router.post('/api/:id/replace-audio', requireGod, (req, res) => {
-  const site = res.locals.site;
-  if (!site) return res.status(404).json({ ok: false, error: 'Site required' });
-  const track = db.prepare('SELECT id, media_id FROM audio_tracks WHERE id = ? AND site_id = ?').get(req.params.id, site.id);
-  if (!track) return res.status(404).json({ ok: false, error: 'Track niet gevonden' });
-
-  upload.single('audio')(req, res, async (err) => {
-    if (err) return res.status(400).json({ ok: false, error: err.message });
-    const file = req.file;
-    if (!file) return res.status(400).json({ ok: false, error: 'Geen bestand' });
-    const ext = path.extname(file.originalname).toLowerCase();
-    const limit = audioByteLimitFor(ext);
-    if (file.size > limit) {
-      try { fs.unlinkSync(file.path); } catch {}
-      return res.status(413).json({ ok: false, error: `Te groot (max ${Math.round(limit / 1024 / 1024)}MB voor ${ext || 'dit type'})` });
-    }
-
-    let transcoded;
-    try {
-      transcoded = await transcodeToMp3({
-        inputPath: file.path, outputDir: AUDIO_DIR,
-        outputBaseName: path.basename(file.filename, path.extname(file.filename)), tags: {},
-      });
-    } catch (e) {
-      try { fs.unlinkSync(file.path); } catch {}
-      return res.status(500).json({ ok: false, error: 'Conversie mislukt: ' + e.message });
-    }
-
-    const newMediaId = uuid();
-    try {
-      db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?,?,?,?,?,?)')
-        .run(newMediaId, site.id, transcoded.filename, transcoded.mimeType, transcoded.size, transcoded.path);
-      db.prepare('UPDATE audio_tracks SET media_id = ? WHERE id = ? AND site_id = ?').run(newMediaId, track.id, site.id);
-      const dur = (transcoded.durationSec != null && transcoded.durationSec > 0) ? transcoded.durationSec : null;
-      if (dur) db.prepare('UPDATE audio_tracks SET duration = ? WHERE id = ?').run(dur, track.id);
-      // Remove the OLD media (file + row), best-effort.
-      if (track.media_id && track.media_id !== newMediaId) {
-        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 {}
-        try { db.prepare('DELETE FROM media WHERE id = ?').run(track.media_id); } catch {}
-      }
-      return res.json({ ok: true, stream_url: audioUrl(transcoded.filename), duration: dur });
-    } catch (e) {
-      return res.status(500).json({ ok: false, error: e.message });
-    }
-  });
-});
-
 export default router;
