Files
coursehub/includes/functions.php
T
Harwinder Sekhon e78f2a81e0 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>
2026-07-11 11:38:41 -04:00

447 lines
17 KiB
PHP

<?php
require_once __DIR__ . '/db.php';
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"):
* [&] Question text (Select two)
* - wrong option
* - right option -- Correct
* Returns [['text', 'options' => [...], 'correct' => [optIndex,...], 'multi'], ...]
* ------------------------------------------------------------------------- */
function parseQuizFile(string $absPath): array
{
$raw = @file_get_contents($absPath);
if ($raw === false) return [];
$questions = [];
$cur = null;
foreach (preg_split('/\r\n|\r|\n/', $raw) as $line) {
$line = trim($line);
if ($line === '') continue;
if (str_starts_with($line, '[&]')) {
if ($cur && $cur['options']) $questions[] = $cur;
$cur = ['text' => trim(substr($line, 3)), 'options' => [], 'correct' => []];
} elseif (str_starts_with($line, '-') && $cur !== null) {
$opt = trim(substr($line, 1));
if (preg_match('/\s*--\s*correct\s*$/i', $opt)) {
$opt = trim(preg_replace('/\s*--\s*correct\s*$/i', '', $opt));
$cur['correct'][] = count($cur['options']);
}
$cur['options'][] = $opt;
} elseif ($cur !== null && !$cur['options']) {
$cur['text'] .= ' ' . $line; // wrapped question text
}
}
if ($cur && $cur['options']) $questions[] = $cur;
foreach ($questions as &$q) {
$q['multi'] = count($q['correct']) > 1;
}
return $questions;
}
/* ---------------------------------------------------------------------------
* Library scanner
* Walks VIDEOS_ROOT recursively, upserts every directory into `folders`,
* every *.mp4 into `videos`, and every *_quiz_solved.txt into `quizzes`
* (keyed by relative path so IDs stay stable across rescans), then flags
* rows whose files disappeared as is_missing.
* ------------------------------------------------------------------------- */
function scanLibrary(): array
{
$root = rtrim(VIDEOS_ROOT, '/');
if (!is_dir($root)) {
throw new RuntimeException("VIDEOS_ROOT does not exist: $root");
}
$db = db();
$stats = ['folders' => 0, 'videos' => 0, 'quizzes' => 0,
'missing_folders' => 0, 'missing_videos' => 0, 'missing_quizzes' => 0];
$seenFolderIds = [];
$seenVideoIds = [];
$seenQuizIds = [];
$upsertFolder = $db->prepare(
'INSERT INTO folders (parent_id, name, path, depth, sort_order, is_missing)
VALUES (?, ?, ?, ?, ?, 0)
ON DUPLICATE KEY UPDATE parent_id = VALUES(parent_id), name = VALUES(name),
depth = VALUES(depth), sort_order = VALUES(sort_order), is_missing = 0,
id = LAST_INSERT_ID(id)'
);
$upsertVideo = $db->prepare(
'INSERT INTO videos (folder_id, name, filename, path, size_bytes, sort_order, is_missing)
VALUES (?, ?, ?, ?, ?, ?, 0)
ON DUPLICATE KEY UPDATE folder_id = VALUES(folder_id), name = VALUES(name),
filename = VALUES(filename), size_bytes = VALUES(size_bytes),
sort_order = VALUES(sort_order), is_missing = 0,
id = LAST_INSERT_ID(id)'
);
$upsertQuiz = $db->prepare(
'INSERT INTO quizzes (folder_id, name, filename, path, num_questions, sort_order, is_missing)
VALUES (?, ?, ?, ?, ?, ?, 0)
ON DUPLICATE KEY UPDATE folder_id = VALUES(folder_id), name = VALUES(name),
filename = VALUES(filename), num_questions = VALUES(num_questions),
sort_order = VALUES(sort_order), is_missing = 0,
id = LAST_INSERT_ID(id)'
);
$walk = function (string $dir, ?int $parentId, string $relBase, int $depth)
use (&$walk, $db, $upsertFolder, $upsertVideo, $upsertQuiz,
&$stats, &$seenFolderIds, &$seenVideoIds, &$seenQuizIds): void {
$entries = scandir($dir) ?: [];
$entries = array_values(array_filter($entries, fn($e) => $e[0] !== '.'));
usort($entries, 'strnatcasecmp'); // "Video2" before "Video10"
$folderSort = 0;
$videoSort = 0;
$quizSort = 0;
foreach ($entries as $entry) {
$full = $dir . '/' . $entry;
$rel = $relBase === '' ? $entry : $relBase . '/' . $entry;
if (is_dir($full)) {
$upsertFolder->execute([$parentId, $entry, $rel, $depth, $folderSort++]);
$folderId = (int)$db->lastInsertId();
$seenFolderIds[] = $folderId;
$stats['folders']++;
$walk($full, $folderId, $rel, $depth + 1);
} elseif (is_file($full) && strtolower(pathinfo($entry, PATHINFO_EXTENSION)) === 'mp4') {
if ($parentId === null) {
continue; // mp4 directly in the root is skipped; use course folders
}
$name = pathinfo($entry, PATHINFO_FILENAME);
$upsertVideo->execute([$parentId, $name, $entry, $rel, filesize($full) ?: 0, $videoSort++]);
$seenVideoIds[] = (int)$db->lastInsertId();
$stats['videos']++;
} elseif (is_file($full) && preg_match('/_quiz_solved\.txt$/i', $entry)) {
if ($parentId === null) {
continue;
}
$numQuestions = count(parseQuizFile($full));
if ($numQuestions === 0) {
continue; // unparseable file — don't index an empty quiz
}
$name = trim(preg_replace('/_quiz_solved\.txt$/i', '', $entry));
$upsertQuiz->execute([$parentId, $name, $entry, $rel, $numQuestions, $quizSort++]);
$seenQuizIds[] = (int)$db->lastInsertId();
$stats['quizzes']++;
}
}
};
$db->beginTransaction();
try {
$walk($root, null, '', 0);
// Anything not seen this pass no longer exists on disk.
$flagMissing = function (string $table, array $seenIds) use ($db): int {
if ($seenIds) {
$in = implode(',', array_map('intval', $seenIds));
$sql = "UPDATE $table SET is_missing = 1 WHERE is_missing = 0 AND id NOT IN ($in)";
} else {
$sql = "UPDATE $table SET is_missing = 1 WHERE is_missing = 0";
}
return $db->exec($sql);
};
$stats['missing_folders'] = $flagMissing('folders', $seenFolderIds);
$stats['missing_videos'] = $flagMissing('videos', $seenVideoIds);
$stats['missing_quizzes'] = $flagMissing('quizzes', $seenQuizIds);
$db->commit();
} catch (Throwable $e) {
$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;
}
/* ---------------------------------------------------------------------------
* Progress helpers
* ------------------------------------------------------------------------- */
/**
* Progress for one folder's whole subtree (videos + quizzes), matched by
* path prefix. Returns ['total' => n, 'completed' => n, 'percent' => 0-100].
* Percent gives partial credit for half-watched videos and partial scores.
*/
function folderProgress(int $userId, string $folderPath): array
{
$args = [$userId, $folderPath, likePrefix($folderPath) . '/%'];
$stmt = db()->prepare(
"SELECT COUNT(*) AS total,
COALESCE(SUM(vp.completed), 0) AS completed,
COALESCE(SUM(
CASE
WHEN vp.completed = 1 THEN 1
WHEN vp.duration_seconds > 0
THEN LEAST(vp.position_seconds / vp.duration_seconds, 1)
ELSE 0
END
), 0) AS credit
FROM videos v
JOIN folders f ON f.id = v.folder_id
LEFT JOIN video_progress vp ON vp.video_id = v.id AND vp.user_id = ?
WHERE v.is_missing = 0 AND f.is_missing = 0
AND (f.path = ? OR f.path LIKE ?)"
);
$stmt->execute($args);
$videos = $stmt->fetch();
$stmt = db()->prepare(
"SELECT COUNT(*) AS total,
COALESCE(SUM(qp.completed), 0) AS completed,
COALESCE(SUM(
CASE
WHEN qp.completed = 1 THEN 1
WHEN q.num_questions > 0
THEN LEAST(COALESCE(qp.best_score, 0) / q.num_questions, 1)
ELSE 0
END
), 0) AS credit
FROM quizzes q
JOIN folders f ON f.id = q.folder_id
LEFT JOIN quiz_progress qp ON qp.quiz_id = q.id AND qp.user_id = ?
WHERE q.is_missing = 0 AND f.is_missing = 0
AND (f.path = ? OR f.path LIKE ?)"
);
$stmt->execute($args);
$quizzes = $stmt->fetch();
$total = (int)$videos['total'] + (int)$quizzes['total'];
$credit = $videos['credit'] + $quizzes['credit'];
return [
'total' => $total,
'completed' => (int)$videos['completed'] + (int)$quizzes['completed'],
'percent' => $total > 0 ? (int)round(100 * $credit / $total) : 0,
];
}
/** Escape LIKE wildcards in a path used as a prefix. */
function likePrefix(string $path): string
{
return str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $path);
}
function getFolder(int $id): ?array
{
$stmt = db()->prepare('SELECT * FROM folders WHERE id = ? AND is_missing = 0');
$stmt->execute([$id]);
return $stmt->fetch() ?: null;
}
function getVideo(int $id): ?array
{
$stmt = db()->prepare(
'SELECT v.*, f.path AS folder_path, f.name AS folder_name
FROM videos v JOIN folders f ON f.id = v.folder_id
WHERE v.id = ? AND v.is_missing = 0'
);
$stmt->execute([$id]);
return $stmt->fetch() ?: null;
}
function childFolders(?int $parentId): array
{
$sql = 'SELECT * FROM folders WHERE is_missing = 0 AND parent_id '
. ($parentId === null ? 'IS NULL' : '= ?')
. ' ORDER BY sort_order, name';
$stmt = db()->prepare($sql);
$stmt->execute($parentId === null ? [] : [$parentId]);
return $stmt->fetchAll();
}
/** Videos in a folder, joined with the user's progress. */
function folderVideos(int $folderId, int $userId): array
{
$stmt = db()->prepare(
'SELECT v.*, vp.position_seconds, vp.duration_seconds AS watched_duration, vp.completed
FROM videos v
LEFT JOIN video_progress vp ON vp.video_id = v.id AND vp.user_id = ?
WHERE v.folder_id = ? AND v.is_missing = 0
ORDER BY v.sort_order, v.name'
);
$stmt->execute([$userId, $folderId]);
return $stmt->fetchAll();
}
function getQuiz(int $id): ?array
{
$stmt = db()->prepare(
'SELECT q.*, f.path AS folder_path, f.name AS folder_name
FROM quizzes q JOIN folders f ON f.id = q.folder_id
WHERE q.id = ? AND q.is_missing = 0'
);
$stmt->execute([$id]);
return $stmt->fetch() ?: null;
}
/** Quizzes in a folder, joined with the user's results. */
function folderQuizzes(int $folderId, int $userId): array
{
$stmt = db()->prepare(
'SELECT q.*, qp.best_score, qp.last_score, qp.attempts, qp.completed
FROM quizzes q
LEFT JOIN quiz_progress qp ON qp.quiz_id = q.id AND qp.user_id = ?
WHERE q.folder_id = ? AND q.is_missing = 0
ORDER BY q.sort_order, q.name'
);
$stmt->execute([$userId, $folderId]);
return $stmt->fetchAll();
}
/** Absolute, containment-checked path of a quiz file (null if gone). */
function quizFilePath(array $quiz): ?string
{
$file = realpath(rtrim(VIDEOS_ROOT, '/') . '/' . $quiz['path']);
$root = realpath(VIDEOS_ROOT);
if ($file === false || $root === false
|| !str_starts_with($file, $root . DIRECTORY_SEPARATOR) || !is_file($file)) {
return null;
}
return $file;
}
/** Breadcrumb chain root -> ... -> folder. */
function folderBreadcrumbs(array $folder): array
{
$crumbs = [$folder];
while ($folder['parent_id'] !== null) {
$folder = getFolder((int)$folder['parent_id']);
if (!$folder) break;
array_unshift($crumbs, $folder);
}
return $crumbs;
}
/** Most recently watched, unfinished videos for the "Continue watching" strip. */
function continueWatching(int $userId, int $limit = 6): array
{
$stmt = db()->prepare(
'SELECT v.id, v.name, f.path AS folder_path,
vp.position_seconds, vp.duration_seconds, vp.updated_at
FROM video_progress vp
JOIN videos v ON v.id = vp.video_id AND v.is_missing = 0
JOIN folders f ON f.id = v.folder_id
WHERE vp.user_id = ? AND vp.completed = 0 AND vp.position_seconds > 5
ORDER BY vp.updated_at DESC
LIMIT ' . (int)$limit
);
$stmt->execute([$userId]);
return $stmt->fetchAll();
}
function formatDuration(?float $seconds): string
{
if ($seconds === null || $seconds <= 0) return '';
$s = (int)round($seconds);
return $s >= 3600
? sprintf('%d:%02d:%02d', intdiv($s, 3600), intdiv($s % 3600, 60), $s % 60)
: sprintf('%d:%02d', intdiv($s, 60), $s % 60);
}