Probe video durations at scan time

Parse the mp4 mvhd box in pure PHP (ffprobe fallback) for videos with no
stored duration, so folder listings show durations before first play.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Harwinder Sekhon
2026-07-11 11:38:41 -04:00
parent deda7c3c18
commit e78f2a81e0
2 changed files with 101 additions and 2 deletions
+97
View File
@@ -6,6 +6,86 @@ function h(?string $s): string
return htmlspecialchars($s ?? '', ENT_QUOTES, 'UTF-8');
}
/* ---------------------------------------------------------------------------
* Video duration probing (used by the scanner so listings can show durations
* before anyone plays the video). Pure-PHP mp4 parse first, ffprobe fallback.
* ------------------------------------------------------------------------- */
function probeDuration(string $file): ?float
{
$d = mp4Duration($file);
if ($d !== null && $d > 0) return $d;
if (function_exists('shell_exec')) {
$out = @shell_exec('ffprobe -v error -show_entries format=duration -of csv=p=0 '
. escapeshellarg($file) . ' 2>/dev/null');
if ($out !== null && is_numeric(trim($out))) {
return (float)trim($out);
}
}
return null;
}
/** Read the duration from an mp4's moov/mvhd box. Returns seconds or null. */
function mp4Duration(string $path): ?float
{
$fp = @fopen($path, 'rb');
if (!$fp) return null;
$fileSize = filesize($path) ?: 0;
$duration = null;
$readBox = function (int $pos) use ($fp): ?array {
fseek($fp, $pos);
$hdr = fread($fp, 8);
if (strlen($hdr) < 8) return null;
$size = unpack('N', substr($hdr, 0, 4))[1];
$type = substr($hdr, 4, 4);
$hdrLen = 8;
if ($size === 1) { // 64-bit box size
$ext = fread($fp, 8);
if (strlen($ext) < 8) return null;
$size = unpack('J', $ext)[1];
$hdrLen = 16;
}
return ($size >= $hdrLen) ? [$size, $type, $hdrLen] : null;
};
// Walk top-level boxes to find moov, then its mvhd child.
for ($pos = 0; $pos < $fileSize;) {
$box = $readBox($pos);
if (!$box) break;
[$size, $type, $hdrLen] = $box;
if ($size === 0) $size = $fileSize - $pos; // box extends to EOF
if ($type === 'moov') {
for ($p = $pos + $hdrLen, $end = $pos + $size; $p < $end;) {
$child = $readBox($p);
if (!$child) break;
[$cSize, $cType, $cHdrLen] = $child;
if ($cType === 'mvhd') {
fseek($fp, $p + $cHdrLen);
$body = fread($fp, 32);
if (strlen($body) >= 20) {
if (ord($body[0]) === 1 && strlen($body) >= 32) { // version 1
$timescale = unpack('N', substr($body, 20, 4))[1];
$dur = unpack('J', substr($body, 24, 8))[1];
} else { // version 0
$timescale = unpack('N', substr($body, 12, 4))[1];
$dur = unpack('N', substr($body, 16, 4))[1];
}
if ($timescale > 0) $duration = $dur / $timescale;
}
break 2;
}
$p += $cSize;
}
break;
}
$pos += $size;
}
fclose($fp);
return $duration;
}
/* ---------------------------------------------------------------------------
* Quiz file parser
* Format ("*_quiz_solved.txt"):
@@ -160,6 +240,23 @@ function scanLibrary(): array
$db->rollBack();
throw $e;
}
// Fill in durations for videos that don't have one yet (new files, or
// indexed before duration probing existed). Only missing ones are probed,
// so rescans stay fast.
$stats['durations_probed'] = 0;
$rows = $db->query(
'SELECT id, path FROM videos WHERE duration_seconds IS NULL AND is_missing = 0'
)->fetchAll();
$upd = $db->prepare('UPDATE videos SET duration_seconds = ? WHERE id = ?');
foreach ($rows as $r) {
$d = probeDuration($root . '/' . $r['path']);
if ($d !== null && $d > 0) {
$upd->execute([$d, (int)$r['id']]);
$stats['durations_probed']++;
}
}
return $stats;
}