Index: package.json
===================================================================
--- package.json	(revision 4c9f29a26c4a88a75b7abe513d5b7c27922cea17)
+++ package.json	(revision 3e86f1cca344a75facd291ef34a6bf56445bf532)
@@ -9,5 +9,6 @@
     "dev": "node --watch src/server.js",
     "migrate": "node src/db/migrate.js",
-    "reset-admin": "node scripts/reset-admin.mjs"
+    "reset-admin": "node scripts/reset-admin.mjs",
+    "backfill:durations": "node scripts/backfill-durations.mjs"
   },
   "dependencies": {
Index: scripts/backfill-durations.mjs
===================================================================
--- scripts/backfill-durations.mjs	(revision 3e86f1cca344a75facd291ef34a6bf56445bf532)
+++ scripts/backfill-durations.mjs	(revision 3e86f1cca344a75facd291ef34a6bf56445bf532)
@@ -0,0 +1,53 @@
+#!/usr/bin/env node
+/**
+ * Backfill audio_tracks.duration voor bestaande tracks die nog géén duur hebben.
+ * Leest de duur uit het mp3-bestand via ffmpeg (probeDuration — geen aparte
+ * ffprobe-binary nodig). Idempotent: pakt alleen rijen met duration NULL of 0,
+ * dus veilig herhaalbaar.
+ *
+ *   npm run backfill:durations
+ *
+ * Respecteert AUDIO_PATH (env) net als de upload-route.
+ */
+import path from 'path';
+import fs from 'fs';
+import { fileURLToPath } from 'url';
+import db from '../src/config/database.js';
+import { probeDuration } from '../src/services/AudioTranscoder.js';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const AUDIO_DIR = path.resolve(
+  process.env.AUDIO_PATH || path.join(__dirname, '..', 'storage', 'audio')
+);
+
+// storage_path is absoluut (opgeslagen bij upload); val terug op AUDIO_DIR/filename.
+function resolveFile(t) {
+  const candidates = [t.storage_path, t.filename ? path.join(AUDIO_DIR, t.filename) : null].filter(Boolean);
+  for (const c of candidates) {
+    try { if (fs.statSync(c).isFile()) return c; } catch { /* volgende kandidaat */ }
+  }
+  return null;
+}
+
+const rows = db.prepare(`
+  SELECT t.id, m.filename, m.storage_path
+  FROM audio_tracks t
+  JOIN media m ON m.id = t.media_id
+  WHERE (t.duration IS NULL OR t.duration = 0) AND m.filename IS NOT NULL
+`).all();
+
+console.log(`[backfill-durations] ${rows.length} track(s) zonder duur`);
+const update = db.prepare('UPDATE audio_tracks SET duration = ? WHERE id = ?');
+
+let ok = 0, miss = 0, fail = 0;
+for (const t of rows) {
+  const file = resolveFile(t);
+  if (!file) { console.warn(`  - ${t.id}: bestand niet gevonden (${t.filename})`); miss++; continue; }
+  try {
+    const sec = await probeDuration(file);
+    if (sec && sec > 0) { update.run(sec, t.id); ok++; console.log(`  ✓ ${t.id}: ${sec}s`); }
+    else { console.warn(`  - ${t.id}: geen duur uit ffmpeg`); fail++; }
+  } catch (e) { console.warn(`  - ${t.id}: ${e.message}`); fail++; }
+}
+console.log(`[backfill-durations] klaar — ${ok} bijgewerkt, ${miss} bestand-mist, ${fail} mislukt`);
+process.exit(0);
Index: src/routes/admin-audio.js
===================================================================
--- src/routes/admin-audio.js	(revision 4c9f29a26c4a88a75b7abe513d5b7c27922cea17)
+++ src/routes/admin-audio.js	(revision 3e86f1cca344a75facd291ef34a6bf56445bf532)
@@ -185,8 +185,17 @@
       `).run(mediaId, site.id, transcoded.filename, transcoded.mimeType, transcoded.size, transcoded.path);
 
-      console.log('[admin-audio] inserting audio_tracks row');
+      // Duur automatisch: primair uit de transcode (ffmpeg codecData), anders een
+      // optionele client-side waarde (bulk-uploader leest <audio>.duration uit),
+      // anders NULL (UI toont dan '—:—', handmatig bij te werken in de 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 + ')');
       db.prepare(`
-        INSERT INTO audio_tracks (id, site_id, title, artist, album, cover_url, media_id, position)
-        VALUES (?, ?, ?, ?, ?, ?, ?, COALESCE(
+        INSERT INTO audio_tracks (id, site_id, title, artist, album, duration, cover_url, media_id, position)
+        VALUES (?, ?, ?, ?, ?, ?, ?, ?, COALESCE(
           (SELECT MAX(position) + 1 FROM audio_tracks WHERE site_id = ?),
           0
@@ -195,4 +204,5 @@
         trackId, site.id,
         finalTitle, finalArtist, finalAlbum,
+        finalDuration,
         coverUrl,
         mediaId, site.id
Index: src/services/AudioTranscoder.js
===================================================================
--- src/services/AudioTranscoder.js	(revision 4c9f29a26c4a88a75b7abe513d5b7c27922cea17)
+++ src/services/AudioTranscoder.js	(revision 3e86f1cca344a75facd291ef34a6bf56445bf532)
@@ -90,6 +90,8 @@
   const tmpPath = path.join(outputDir, `${outputBaseName}.transcoding-${process.pid}.mp3`);
 
+  let durationSec = null;
   try {
-    await runFfmpeg({ inputPath, tmpPath, tags });
+    const r = await runFfmpeg({ inputPath, tmpPath, tags });
+    durationSec = r && r.durationSec != null ? r.durationSec : null;
 
     // Verify the output is not zero bytes — ffmpeg sometimes "succeeds" but
@@ -124,4 +126,5 @@
       size: outStat.size,
       mimeType: 'audio/mpeg',
+      durationSec,   // hele seconden uit ffmpeg's codecData (null als onbekend)
     };
 
@@ -140,4 +143,5 @@
 function runFfmpeg({ inputPath, tmpPath, tags }) {
   return new Promise((resolve, reject) => {
+    let durationSec = null;
     const cmd = ffmpeg(inputPath)
       .audioCodec('libmp3lame')
@@ -168,4 +172,7 @@
 
     cmd
+      // codecData geeft de duur van de INPUT als "HH:MM:SS.xx" — zo bepalen we
+      // de tracklengte automatisch zonder aparte ffprobe-binary.
+      .on('codecData', (data) => { durationSec = parseHmsToSeconds(data && data.duration); })
       .on('error', (err, stdout, stderr) => {
         // ffmpeg's stderr is the most useful diagnostic. fluent-ffmpeg's
@@ -176,8 +183,44 @@
         reject(new Error(`Transcode failed: ${reason}${tail ? '\n' + tail : ''}`));
       })
-      .on('end', () => resolve())
+      .on('end', () => resolve({ durationSec }))
       .save(tmpPath);
   });
 }
 
-export default { transcodeToMp3 };
+/**
+ * Parse een ffmpeg-duurstring "HH:MM:SS.xx" naar hele seconden. Geeft null bij
+ * "N/A" of een onverwacht formaat.
+ */
+function parseHmsToSeconds(hms) {
+  if (!hms || typeof hms !== 'string') return null;
+  const m = hms.match(/^(\d+):(\d{2}):(\d{2})(?:\.(\d+))?$/);
+  if (!m) return null;
+  const sec = (+m[1]) * 3600 + (+m[2]) * 60 + (+m[3]) + (m[4] ? Number('0.' + m[4]) : 0);
+  return Number.isFinite(sec) ? Math.round(sec) : null;
+}
+
+/**
+ * Lees de duur (hele seconden) van een audiobestand ZONDER te transcoderen.
+ * Start een ffmpeg-pass en leest enkel het codecData-event (duur), waarna we het
+ * proces direct stoppen — snel en zonder aparte ffprobe-binary (ffmpeg-static
+ * levert alleen ffmpeg). Bedoeld voor het backfill-script.
+ * @returns {Promise<number|null>}
+ */
+export function probeDuration(filePath) {
+  return new Promise((resolve) => {
+    let durationSec = null, done = false;
+    const finish = () => { if (!done) { done = true; resolve(durationSec); } };
+    const cmd = ffmpeg(filePath)
+      .on('codecData', (data) => {
+        durationSec = parseHmsToSeconds(data && data.duration);
+        try { cmd.kill('SIGKILL'); } catch { /* al klaar */ }
+        finish();
+      })
+      .on('error', finish)
+      .on('end', finish)
+      .format('null')
+      .save(process.platform === 'win32' ? 'NUL' : '/dev/null');
+  });
+}
+
+export default { transcodeToMp3, probeDuration };
Index: src/views/partials/track-editor.ejs
===================================================================
--- src/views/partials/track-editor.ejs	(revision 4c9f29a26c4a88a75b7abe513d5b7c27922cea17)
+++ src/views/partials/track-editor.ejs	(revision 3e86f1cca344a75facd291ef34a6bf56445bf532)
@@ -380,8 +380,8 @@
 
             <label class="te-field">
-              <span>Duur <small>(seconden — bv. 142 voor 2:22)</small></span>
+              <span>Duur <small>(seconden — automatisch bepaald, hier te overschrijven)</small></span>
               <input type="number" id="te-duration" min="0" step="1"
                      inputmode="numeric" pattern="[0-9]*"
-                     value="${track.duration || ''}" placeholder="0">
+                     value="${track.duration || ''}" placeholder="auto">
             </label>
 
@@ -619,4 +619,32 @@
     });
 
+    // ── Auto-duur ───────────────────────────────────────────
+    // De server bepaalt de duur al automatisch bij upload. Dit is de vangnet/
+    // UX-laag: opent een admin een bestaande track zónder duur, dan lezen we 'm
+    // hier uit de audio-metadata en vullen het veld — zodat je nooit seconden
+    // hoeft te typen. Bestaande waarde wordt nooit overschreven. We halen de
+    // bytes via dezelfde header-gate als de speler (X-Audio-Player).
+    (async function autoDuration() {
+      const durEl = $('#te-duration');
+      if (!durEl || !track.stream_url) return;
+      if (durEl.value && Number(durEl.value) > 0) return;  // al ingevuld → met rust laten
+      let objUrl = null;
+      try {
+        const r = await fetch(track.stream_url, { credentials: 'same-origin', headers: { 'X-Audio-Player': '1' } });
+        if (!r.ok) return;
+        objUrl = URL.createObjectURL(await r.blob());
+        const probe = new Audio();
+        probe.preload = 'metadata';
+        probe.addEventListener('loadedmetadata', () => {
+          if (isFinite(probe.duration) && probe.duration > 0 && !(durEl.value && Number(durEl.value) > 0)) {
+            durEl.value = Math.round(probe.duration);
+          }
+          if (objUrl) URL.revokeObjectURL(objUrl);
+        });
+        probe.addEventListener('error', () => { if (objUrl) URL.revokeObjectURL(objUrl); });
+        probe.src = objUrl;
+      } catch (e) { if (objUrl) URL.revokeObjectURL(objUrl); }
+    })();
+
     // Focus title for fast typing
     setTimeout(() => $('#te-title').focus(), 60);
