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);
