From 45e7465f7bf16cbbea74be59b291d589ebebd5e2 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. --- 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; }