Changeset 3e86f1c in Klonkt for src


Ignore:
Timestamp:
06/15/2026 03:23:29 AM (3 months ago)
Author:
roboburr <roboburr@…>
Branches:
main
Children:
7f46817d
Parents:
4c9f29a
Message:

feat: automatic track duration — no more manually entering seconds

The duration of an audio track is now determined automatically instead of manually:

  • On upload: read from ffmpeg's codecData event during the existing transcode (no extra ffprobe binary). The upload INSERT never set duration before -> every track started as NULL ('--:--'); that is now fixed.
  • In the track editor: a hidden <audio preload=metadata> reads the duration and fills the field if it's still empty (manual override still possible).
  • Backfill: npm run backfill:durations fills existing tracks without duration via ffmpeg (probeDuration reads only codecData and stops immediately -> fast).

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

Location:
src
Files:
3 edited

Legend:

Unmodified
Added
Removed
  • src/routes/admin-audio.js

    r4c9f29a r3e86f1c  
    185185      `).run(mediaId, site.id, transcoded.filename, transcoded.mimeType, transcoded.size, transcoded.path);
    186186
    187       console.log('[admin-audio] inserting audio_tracks row');
     187      // Duur automatisch: primair uit de transcode (ffmpeg codecData), anders een
     188      // optionele client-side waarde (bulk-uploader leest <audio>.duration uit),
     189      // anders NULL (UI toont dan '—:—', handmatig bij te werken in de editor).
     190      const clientDur = req.body.duration != null ? parseInt(req.body.duration, 10) : NaN;
     191      const finalDuration =
     192        (transcoded.durationSec != null && transcoded.durationSec > 0) ? transcoded.durationSec
     193        : (Number.isFinite(clientDur) && clientDur > 0) ? clientDur
     194        : null;
     195
     196      console.log('[admin-audio] inserting audio_tracks row (duration=' + finalDuration + ')');
    188197      db.prepare(`
    189         INSERT INTO audio_tracks (id, site_id, title, artist, album, cover_url, media_id, position)
    190         VALUES (?, ?, ?, ?, ?, ?, ?, COALESCE(
     198        INSERT INTO audio_tracks (id, site_id, title, artist, album, duration, cover_url, media_id, position)
     199        VALUES (?, ?, ?, ?, ?, ?, ?, ?, COALESCE(
    191200          (SELECT MAX(position) + 1 FROM audio_tracks WHERE site_id = ?),
    192201          0
     
    195204        trackId, site.id,
    196205        finalTitle, finalArtist, finalAlbum,
     206        finalDuration,
    197207        coverUrl,
    198208        mediaId, site.id
  • src/services/AudioTranscoder.js

    r4c9f29a r3e86f1c  
    9090  const tmpPath = path.join(outputDir, `${outputBaseName}.transcoding-${process.pid}.mp3`);
    9191
     92  let durationSec = null;
    9293  try {
    93     await runFfmpeg({ inputPath, tmpPath, tags });
     94    const r = await runFfmpeg({ inputPath, tmpPath, tags });
     95    durationSec = r && r.durationSec != null ? r.durationSec : null;
    9496
    9597    // Verify the output is not zero bytes — ffmpeg sometimes "succeeds" but
     
    124126      size: outStat.size,
    125127      mimeType: 'audio/mpeg',
     128      durationSec,   // hele seconden uit ffmpeg's codecData (null als onbekend)
    126129    };
    127130
     
    140143function runFfmpeg({ inputPath, tmpPath, tags }) {
    141144  return new Promise((resolve, reject) => {
     145    let durationSec = null;
    142146    const cmd = ffmpeg(inputPath)
    143147      .audioCodec('libmp3lame')
     
    168172
    169173    cmd
     174      // codecData geeft de duur van de INPUT als "HH:MM:SS.xx" — zo bepalen we
     175      // de tracklengte automatisch zonder aparte ffprobe-binary.
     176      .on('codecData', (data) => { durationSec = parseHmsToSeconds(data && data.duration); })
    170177      .on('error', (err, stdout, stderr) => {
    171178        // ffmpeg's stderr is the most useful diagnostic. fluent-ffmpeg's
     
    176183        reject(new Error(`Transcode failed: ${reason}${tail ? '\n' + tail : ''}`));
    177184      })
    178       .on('end', () => resolve())
     185      .on('end', () => resolve({ durationSec }))
    179186      .save(tmpPath);
    180187  });
    181188}
    182189
    183 export default { transcodeToMp3 };
     190/**
     191 * Parse een ffmpeg-duurstring "HH:MM:SS.xx" naar hele seconden. Geeft null bij
     192 * "N/A" of een onverwacht formaat.
     193 */
     194function parseHmsToSeconds(hms) {
     195  if (!hms || typeof hms !== 'string') return null;
     196  const m = hms.match(/^(\d+):(\d{2}):(\d{2})(?:\.(\d+))?$/);
     197  if (!m) return null;
     198  const sec = (+m[1]) * 3600 + (+m[2]) * 60 + (+m[3]) + (m[4] ? Number('0.' + m[4]) : 0);
     199  return Number.isFinite(sec) ? Math.round(sec) : null;
     200}
     201
     202/**
     203 * Lees de duur (hele seconden) van een audiobestand ZONDER te transcoderen.
     204 * Start een ffmpeg-pass en leest enkel het codecData-event (duur), waarna we het
     205 * proces direct stoppen — snel en zonder aparte ffprobe-binary (ffmpeg-static
     206 * levert alleen ffmpeg). Bedoeld voor het backfill-script.
     207 * @returns {Promise<number|null>}
     208 */
     209export function probeDuration(filePath) {
     210  return new Promise((resolve) => {
     211    let durationSec = null, done = false;
     212    const finish = () => { if (!done) { done = true; resolve(durationSec); } };
     213    const cmd = ffmpeg(filePath)
     214      .on('codecData', (data) => {
     215        durationSec = parseHmsToSeconds(data && data.duration);
     216        try { cmd.kill('SIGKILL'); } catch { /* al klaar */ }
     217        finish();
     218      })
     219      .on('error', finish)
     220      .on('end', finish)
     221      .format('null')
     222      .save(process.platform === 'win32' ? 'NUL' : '/dev/null');
     223  });
     224}
     225
     226export default { transcodeToMp3, probeDuration };
  • src/views/partials/track-editor.ejs

    r4c9f29a r3e86f1c  
    380380
    381381            <label class="te-field">
    382               <span>Duur <small>(seconden — bv. 142 voor 2:22)</small></span>
     382              <span>Duur <small>(seconden — automatisch bepaald, hier te overschrijven)</small></span>
    383383              <input type="number" id="te-duration" min="0" step="1"
    384384                     inputmode="numeric" pattern="[0-9]*"
    385                      value="${track.duration || ''}" placeholder="0">
     385                     value="${track.duration || ''}" placeholder="auto">
    386386            </label>
    387387
     
    619619    });
    620620
     621    // ── Auto-duur ───────────────────────────────────────────
     622    // De server bepaalt de duur al automatisch bij upload. Dit is de vangnet/
     623    // UX-laag: opent een admin een bestaande track zónder duur, dan lezen we 'm
     624    // hier uit de audio-metadata en vullen het veld — zodat je nooit seconden
     625    // hoeft te typen. Bestaande waarde wordt nooit overschreven. We halen de
     626    // bytes via dezelfde header-gate als de speler (X-Audio-Player).
     627    (async function autoDuration() {
     628      const durEl = $('#te-duration');
     629      if (!durEl || !track.stream_url) return;
     630      if (durEl.value && Number(durEl.value) > 0) return;  // al ingevuld → met rust laten
     631      let objUrl = null;
     632      try {
     633        const r = await fetch(track.stream_url, { credentials: 'same-origin', headers: { 'X-Audio-Player': '1' } });
     634        if (!r.ok) return;
     635        objUrl = URL.createObjectURL(await r.blob());
     636        const probe = new Audio();
     637        probe.preload = 'metadata';
     638        probe.addEventListener('loadedmetadata', () => {
     639          if (isFinite(probe.duration) && probe.duration > 0 && !(durEl.value && Number(durEl.value) > 0)) {
     640            durEl.value = Math.round(probe.duration);
     641          }
     642          if (objUrl) URL.revokeObjectURL(objUrl);
     643        });
     644        probe.addEventListener('error', () => { if (objUrl) URL.revokeObjectURL(objUrl); });
     645        probe.src = objUrl;
     646      } catch (e) { if (objUrl) URL.revokeObjectURL(objUrl); }
     647    })();
     648
    621649    // Focus title for fast typing
    622650    setTimeout(() => $('#te-title').focus(), 60);
Note: See TracChangeset for help on using the changeset viewer.