45e7465f7b
Parse the mp4 mvhd box in pure PHP (ffprobe fallback) for videos with no stored duration, so folder listings show durations before first play.
447 lines
17 KiB
PHP
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);
|
|
}
|