From e78f2a81e0310ef658ae420cd56717abfc9288f0 Mon Sep 17 00:00:00 2001 From: Harwinder Sekhon Date: Sat, 11 Jul 2026 11:38:41 -0400 Subject: [PATCH] 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 --- README.md | 6 ++- includes/functions.php | 97 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 5a59d00..fa54848 100644 --- a/README.md +++ b/README.md @@ -106,8 +106,10 @@ attempt, and quiz scores count toward folder progress alongside videos. Completion is sticky — rewatching doesn't un-complete it. - Folder progress is computed over the folder's entire subtree using a path prefix match, with partial credit for videos in progress. -- Video duration is captured from the browser on first play (no ffprobe - dependency) and cached on the `videos` row. +- Video duration is read at scan time by parsing the mp4's `mvhd` header in + pure PHP (ffprobe fallback if the parse fails), so listings show durations + before anyone plays the video. The browser still reports the duration on + first play as a last-resort fallback. ## Production notes diff --git a/includes/functions.php b/includes/functions.php index 4c8d60d..842af98 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -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; }