6a4c972c47
- A quiz question can now carry the diagram it refers to: a Markdown image line anywhere inside the question block is parsed out of the text and rendered under it (referrerpolicy="no-referrer", lazy). The URL points at the course provider, so an offline install gets a note and a link instead of an empty box. - New optional SESSION_LIFETIME (30 days in the example config) replaces the browser-session cookie plus php.ini's 24-minute idle limit, which signed people out over a long pause. Configs that predate it behave as before. Behind a proxy trusted enough for CLIENT_IP_HEADER, the cookie is also marked Secure from X-Forwarded-Proto — TLS ending at the proxy no longer costs the flag. - Error responses no longer echo the exception message unless DEBUG: a PDOException names the DB user, host and tables, and that handler also runs for visitors who never signed in. - The .htaccess denials are anchored to a path segment rather than the root, so videos/ and includes/ stay unreachable under a prefix such as /coursehub/ instead of silently opening up. - A rescan takes a MySQL advisory lock; a second one answers "already running" rather than racing the first and flagging its rows missing. A file swapped in at the same path with a different size drops its cached duration so the probe reads the new length. - stream.php discards output buffering and zlib compression (both break the promised Content-Length) and answers HEAD with headers alone. - A progress save with no duration keeps the length already stored instead of nulling it, and the folder view builds its rollup once. - In the SPA, a view whose data arrives after the route moved on is discarded — previously a slow load could paint over a newer screen or start a heartbeat for a video nobody was watching. Also: the quiz submit button can't be double-fired, leaving a video during the autoplay countdown no longer jumps ahead later, the final position is always saved on 'ended', the playlist scrolls the current entry into its own box, the login field takes focus, views start at the top, and a stray "%" in the hash no longer throws. - Dropped the unused requireLogin(); registerUser() answers "taken" instead of a 500 when two people race for the same name. Claude-Session: https://claude.ai/code/session_01AXMrtWNUV7NU1pCUHBG4t1
825 lines
31 KiB
PHP
825 lines
31 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 — no external tools involved; a
|
|
* file the mp4 parser can't read simply has no duration until the player
|
|
* reports one on first play.
|
|
* ------------------------------------------------------------------------- */
|
|
function probeDuration(string $file): ?float
|
|
{
|
|
$d = mp4Duration($file);
|
|
return ($d !== null && $d > 0) ? $d : 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)
|
|
*  <- optional, one per question
|
|
* - wrong option
|
|
* - right option -- Correct
|
|
* A Markdown image line anywhere inside a question is its exhibit (the
|
|
* diagram the question refers to); it is kept out of the question text and
|
|
* returned as ['url', 'alt'] so the UI can show the picture.
|
|
* Returns [['text', 'image' => ?[...], '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)), 'image' => null, 'options' => [], 'correct' => []];
|
|
} elseif ($cur !== null && preg_match('/^!\[([^\]]*)\]\((https?:\/\/[^\s)]+)\)$/i', $line, $m)) {
|
|
$cur['image'] = ['url' => $m[2], 'alt' => trim($m[1]) !== '' ? trim($m[1]) : 'Exhibit'];
|
|
} 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();
|
|
|
|
// One scan at a time. Two overlapping scans would upsert the same rows
|
|
// inside two transactions and then each try to flag the other's work
|
|
// missing; the lock is per-connection and released below whatever happens.
|
|
if ((int)$db->query("SELECT GET_LOCK('coursehub_scan', 0)")->fetchColumn() !== 1) {
|
|
throw new RuntimeException('A scan is already running — try again in a moment.');
|
|
}
|
|
try {
|
|
return scanLibraryLocked($db, $root);
|
|
} finally {
|
|
$db->query("SELECT RELEASE_LOCK('coursehub_scan')");
|
|
}
|
|
}
|
|
|
|
/** The scan proper — only ever called by scanLibrary() while it holds the lock. */
|
|
function scanLibraryLocked(PDO $db, string $root): array
|
|
{
|
|
$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)'
|
|
);
|
|
// A file replaced by a different one at the same path (re-encoded, a
|
|
// corrected upload) is a different length: forget the cached duration so
|
|
// the probe below reads it afresh. The assignment order matters — MySQL
|
|
// evaluates ON DUPLICATE KEY UPDATE left to right, so the comparison has
|
|
// to happen before size_bytes is overwritten.
|
|
$upsertVideo = $db->prepare(
|
|
'INSERT INTO videos (folder_id, name, filename, path, size_bytes, sort_order, is_missing)
|
|
VALUES (?, ?, ?, ?, ?, ?, 0)
|
|
ON DUPLICATE KEY UPDATE
|
|
duration_seconds = IF(size_bytes <> VALUES(size_bytes), NULL, duration_seconds),
|
|
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']++;
|
|
}
|
|
}
|
|
|
|
appMetaSet('last_scan_at', gmdate('Y-m-d H:i:s'));
|
|
|
|
return $stats;
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------------
|
|
* Progress helpers
|
|
* ------------------------------------------------------------------------- */
|
|
|
|
/**
|
|
* One pass over a folder's whole subtree (videos + quizzes), matched by path
|
|
* prefix: how much is in there, how big, how long, and how far the user got.
|
|
* Course cards need all of it, so it is gathered in two queries rather than
|
|
* one per fact.
|
|
*/
|
|
function folderRollup(int $userId, string $folderPath): array
|
|
{
|
|
$args = [$userId, $folderPath, likePrefix($folderPath) . '/%'];
|
|
|
|
$stmt = db()->prepare(
|
|
"SELECT COUNT(*) AS total,
|
|
COALESCE(SUM(COALESCE(v.duration_seconds, vp.duration_seconds)), 0) AS duration,
|
|
COALESCE(SUM(v.size_bytes), 0) AS bytes,
|
|
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 [
|
|
'videos' => (int)$videos['total'],
|
|
'quizzes' => (int)$quizzes['total'],
|
|
'duration' => (float)$videos['duration'],
|
|
'bytes' => (int)$videos['bytes'],
|
|
'total' => $total,
|
|
'completed' => (int)$videos['completed'] + (int)$quizzes['completed'],
|
|
'percent' => $total > 0 ? (int)round(100 * $credit / $total) : 0,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Progress for one folder's whole subtree.
|
|
* 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
|
|
{
|
|
$r = folderRollup($userId, $folderPath);
|
|
return ['total' => $r['total'], 'completed' => $r['completed'], 'percent' => $r['percent']];
|
|
}
|
|
|
|
/** A course (or section) as the library screens show it: contents + progress. */
|
|
function courseSummary(int $userId, array $folder): array
|
|
{
|
|
$r = folderRollup($userId, $folder['path']);
|
|
$percent = $r['percent'];
|
|
|
|
return [
|
|
'id' => (int)$folder['id'],
|
|
'name' => $folder['name'],
|
|
'path' => $folder['path'],
|
|
'videos' => $r['videos'],
|
|
'quizzes' => $r['quizzes'],
|
|
'duration' => $r['duration'],
|
|
'bytes' => $r['bytes'],
|
|
'added_at' => $folder['created_at'] ?? null,
|
|
'progress' => ['total' => $r['total'], 'completed' => $r['completed'], 'percent' => $percent],
|
|
// The shelves the library groups by — the app has no course categories,
|
|
// so progress is what separates one shelf from another.
|
|
'state' => $percent >= 100 ? 'finished' : ($percent > 0 ? 'progress' : 'fresh'),
|
|
];
|
|
}
|
|
|
|
/** Every top-level folder as a course card. */
|
|
function courseCards(int $userId): array
|
|
{
|
|
return array_map(fn($f) => courseSummary($userId, $f), childFolders(null));
|
|
}
|
|
|
|
/** Headline numbers for the home screen, computed from the course cards. */
|
|
function userStats(int $userId, array $courses): array
|
|
{
|
|
$inProgress = 0;
|
|
$finished = 0;
|
|
foreach ($courses as $c) {
|
|
if ($c['state'] === 'finished') $finished++;
|
|
elseif ($c['state'] === 'progress') $inProgress++;
|
|
}
|
|
|
|
// Watched time: full duration for completed videos, position for the rest.
|
|
$stmt = db()->prepare(
|
|
'SELECT COALESCE(SUM(
|
|
CASE WHEN vp.completed = 1
|
|
THEN COALESCE(v.duration_seconds, vp.duration_seconds, vp.position_seconds)
|
|
ELSE vp.position_seconds END
|
|
), 0) AS watched
|
|
FROM video_progress vp
|
|
JOIN videos v ON v.id = vp.video_id AND v.is_missing = 0
|
|
WHERE vp.user_id = ?'
|
|
);
|
|
$stmt->execute([$userId]);
|
|
$watched = (float)$stmt->fetchColumn();
|
|
|
|
$stmt = db()->prepare(
|
|
'SELECT COUNT(*) AS taken,
|
|
COALESCE(AVG(qp.best_score / NULLIF(q.num_questions, 0)), 0) AS avg_score
|
|
FROM quiz_progress qp
|
|
JOIN quizzes q ON q.id = qp.quiz_id AND q.is_missing = 0
|
|
WHERE qp.user_id = ? AND qp.attempts > 0'
|
|
);
|
|
$stmt->execute([$userId]);
|
|
$quiz = $stmt->fetch();
|
|
|
|
return [
|
|
'in_progress' => $inProgress,
|
|
'finished' => $finished,
|
|
'courses' => count($courses),
|
|
'watched_seconds' => $watched,
|
|
'quizzes_taken' => (int)$quiz['taken'],
|
|
'quiz_average' => (int)round(100 * (float)$quiz['avg_score']),
|
|
];
|
|
}
|
|
|
|
/** Courses the scanner indexed most recently — the "found in the last scan" strip. */
|
|
function recentCourses(int $userId, int $limit = 3): array
|
|
{
|
|
$stmt = db()->prepare(
|
|
'SELECT * FROM folders
|
|
WHERE parent_id IS NULL AND is_missing = 0
|
|
ORDER BY created_at DESC, id DESC
|
|
LIMIT ' . (int)$limit
|
|
);
|
|
$stmt->execute();
|
|
|
|
$week = time() - 7 * 86400;
|
|
return array_map(function ($f) use ($userId, $week) {
|
|
$c = courseSummary($userId, $f);
|
|
$c['is_new'] = $f['created_at'] !== null && strtotime($f['created_at']) >= $week;
|
|
return $c;
|
|
}, $stmt->fetchAll());
|
|
}
|
|
|
|
/** Library-wide counts for the sidebar and header. */
|
|
function libraryTotals(): array
|
|
{
|
|
$row = db()->query(
|
|
'SELECT (SELECT COUNT(*) FROM folders WHERE parent_id IS NULL AND is_missing = 0) AS courses,
|
|
(SELECT COUNT(*) FROM videos WHERE is_missing = 0) AS videos,
|
|
(SELECT COUNT(*) FROM quizzes WHERE is_missing = 0) AS quizzes,
|
|
(SELECT COALESCE(SUM(size_bytes), 0) FROM videos WHERE is_missing = 0) AS bytes,
|
|
(SELECT COALESCE(SUM(duration_seconds), 0) FROM videos WHERE is_missing = 0) AS duration'
|
|
)->fetch();
|
|
|
|
return [
|
|
'courses' => (int)$row['courses'],
|
|
'videos' => (int)$row['videos'],
|
|
'quizzes' => (int)$row['quizzes'],
|
|
'bytes' => (int)$row['bytes'],
|
|
'duration' => (float)$row['duration'],
|
|
];
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------------
|
|
* app_meta — small key/value store (currently just the last scan time).
|
|
* Reads tolerate the table not existing yet, so an install created before this
|
|
* table was added keeps working until its next scan creates it.
|
|
* ------------------------------------------------------------------------- */
|
|
function appMetaGet(string $key): ?string
|
|
{
|
|
try {
|
|
$stmt = db()->prepare('SELECT v FROM app_meta WHERE k = ?');
|
|
$stmt->execute([$key]);
|
|
$value = $stmt->fetchColumn();
|
|
return $value === false ? null : (string)$value;
|
|
} catch (Throwable $e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function appMetaSet(string $key, string $value): void
|
|
{
|
|
$db = db();
|
|
$db->exec(
|
|
'CREATE TABLE IF NOT EXISTS app_meta (
|
|
k VARCHAR(64) PRIMARY KEY,
|
|
v VARCHAR(255) NOT NULL,
|
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
|
) ENGINE=InnoDB'
|
|
);
|
|
$stmt = $db->prepare('INSERT INTO app_meta (k, v) VALUES (?, ?) ON DUPLICATE KEY UPDATE v = VALUES(v)');
|
|
$stmt->execute([$key, $value]);
|
|
}
|
|
|
|
/** Escape LIKE wildcards in a path used as a prefix. */
|
|
function likePrefix(string $path): string
|
|
{
|
|
return str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $path);
|
|
}
|
|
|
|
/** Escaped "contains" pattern for LIKE. */
|
|
function likeContains(string $term): string
|
|
{
|
|
return '%' . likePrefix($term) . '%';
|
|
}
|
|
|
|
/**
|
|
* Wipe the user's progress for a scope:
|
|
* all — everything the user has watched/answered
|
|
* folder — the folder's whole subtree (id = folder id)
|
|
* video — one video (id = video id)
|
|
* quiz — one quiz (id = quiz id)
|
|
* Returns ['videos' => n, 'quizzes' => n] rows removed.
|
|
* Rows for files that went missing are cleared too, so a reset is complete.
|
|
*/
|
|
function resetProgress(int $userId, string $scope, int $id = 0): array
|
|
{
|
|
$db = db();
|
|
|
|
switch ($scope) {
|
|
case 'all':
|
|
$v = $db->prepare('DELETE FROM video_progress WHERE user_id = ?');
|
|
$v->execute([$userId]);
|
|
$q = $db->prepare('DELETE FROM quiz_progress WHERE user_id = ?');
|
|
$q->execute([$userId]);
|
|
break;
|
|
|
|
case 'folder':
|
|
$folder = getFolder($id);
|
|
if (!$folder) {
|
|
throw new InvalidArgumentException('Folder not found');
|
|
}
|
|
$args = [$userId, $folder['path'], likePrefix($folder['path']) . '/%'];
|
|
$v = $db->prepare(
|
|
'DELETE vp FROM video_progress vp
|
|
JOIN videos v ON v.id = vp.video_id
|
|
JOIN folders f ON f.id = v.folder_id
|
|
WHERE vp.user_id = ? AND (f.path = ? OR f.path LIKE ?)'
|
|
);
|
|
$v->execute($args);
|
|
$q = $db->prepare(
|
|
'DELETE qp FROM quiz_progress qp
|
|
JOIN quizzes q ON q.id = qp.quiz_id
|
|
JOIN folders f ON f.id = q.folder_id
|
|
WHERE qp.user_id = ? AND (f.path = ? OR f.path LIKE ?)'
|
|
);
|
|
$q->execute($args);
|
|
break;
|
|
|
|
case 'video':
|
|
if ($id <= 0) {
|
|
throw new InvalidArgumentException('Video not found');
|
|
}
|
|
$v = $db->prepare('DELETE FROM video_progress WHERE user_id = ? AND video_id = ?');
|
|
$v->execute([$userId, $id]);
|
|
$q = null;
|
|
break;
|
|
|
|
case 'quiz':
|
|
if ($id <= 0) {
|
|
throw new InvalidArgumentException('Quiz not found');
|
|
}
|
|
$q = $db->prepare('DELETE FROM quiz_progress WHERE user_id = ? AND quiz_id = ?');
|
|
$q->execute([$userId, $id]);
|
|
$v = null;
|
|
break;
|
|
|
|
default:
|
|
throw new InvalidArgumentException('Unknown reset scope');
|
|
}
|
|
|
|
return [
|
|
'videos' => $v ? $v->rowCount() : 0,
|
|
'quizzes' => $q ? $q->rowCount() : 0,
|
|
];
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------------
|
|
* Search — substring match on names (and on the relative path, so
|
|
* "Course 1/Intro" finds everything under it).
|
|
* ------------------------------------------------------------------------- */
|
|
|
|
function searchFolders(string $term, int $limit = 25): array
|
|
{
|
|
$like = likeContains($term);
|
|
$stmt = db()->prepare(
|
|
'SELECT * FROM folders
|
|
WHERE is_missing = 0 AND (name LIKE ? OR path LIKE ?)
|
|
ORDER BY depth, path
|
|
LIMIT ' . (int)$limit
|
|
);
|
|
$stmt->execute([$like, $like]);
|
|
return $stmt->fetchAll();
|
|
}
|
|
|
|
function searchVideos(int $userId, string $term, int $limit = 50): array
|
|
{
|
|
$like = likeContains($term);
|
|
$stmt = db()->prepare(
|
|
'SELECT v.*, f.path AS folder_path,
|
|
vp.position_seconds, vp.duration_seconds AS watched_duration, vp.completed
|
|
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 (v.name LIKE ? OR v.path LIKE ?)
|
|
ORDER BY f.path, v.sort_order, v.name
|
|
LIMIT ' . (int)$limit
|
|
);
|
|
$stmt->execute([$userId, $like, $like]);
|
|
return $stmt->fetchAll();
|
|
}
|
|
|
|
function searchQuizzes(int $userId, string $term, int $limit = 25): array
|
|
{
|
|
$like = likeContains($term);
|
|
$stmt = db()->prepare(
|
|
'SELECT q.*, f.path AS folder_path,
|
|
qp.best_score, qp.last_score, qp.attempts, qp.completed
|
|
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 (q.name LIKE ? OR q.path LIKE ?)
|
|
ORDER BY f.path, q.sort_order, q.name
|
|
LIMIT ' . (int)$limit
|
|
);
|
|
$stmt->execute([$userId, $like, $like]);
|
|
return $stmt->fetchAll();
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Absolute, containment-checked path of a video's sibling subtitle file
|
|
* ("Lesson.mp4" -> "Lesson.srt"), or null if there isn't one.
|
|
*/
|
|
function srtFilePath(array $video): ?string
|
|
{
|
|
$rel = preg_replace('/\.mp4$/i', '.srt', $video['path']);
|
|
if ($rel === null || $rel === $video['path']) {
|
|
return null;
|
|
}
|
|
$file = realpath(rtrim(VIDEOS_ROOT, '/') . '/' . $rel);
|
|
$root = realpath(VIDEOS_ROOT);
|
|
if ($file === false || $root === false
|
|
|| !str_starts_with($file, $root . DIRECTORY_SEPARATOR) || !is_file($file)) {
|
|
return null;
|
|
}
|
|
return $file;
|
|
}
|
|
|
|
/**
|
|
* SubRip -> WebVTT (the only caption format browsers take in <track>).
|
|
* The formats differ by a header line and the decimal separator in
|
|
* timestamps; cue numbers are legal VTT cue identifiers and pass through.
|
|
*/
|
|
function srtToVtt(string $srt): string
|
|
{
|
|
if (str_starts_with($srt, "\xEF\xBB\xBF")) {
|
|
$srt = substr($srt, 3);
|
|
}
|
|
if (!preg_match('//u', $srt)) { // not valid UTF-8 — legacy single-byte file
|
|
$recoded = function_exists('iconv')
|
|
? @iconv('Windows-1252', 'UTF-8//TRANSLIT', $srt)
|
|
: (function_exists('mb_convert_encoding')
|
|
? mb_convert_encoding($srt, 'UTF-8', 'Windows-1252') : false);
|
|
if (is_string($recoded)) {
|
|
$srt = $recoded;
|
|
}
|
|
}
|
|
$srt = str_replace(["\r\n", "\r"], "\n", $srt);
|
|
|
|
// Normalise timestamps to HH:MM:SS.mmm. SRT uses a comma; generated files
|
|
// in the wild also carry single-digit hours and six-digit fractions, and
|
|
// the WebVTT parsing algorithm rejects any fraction that isn't exactly
|
|
// three digits — such cues would silently drop.
|
|
$srt = preg_replace_callback(
|
|
'/(\d+):(\d{2}):(\d{2})[.,](\d+)/',
|
|
fn($m) => sprintf('%02d:%s:%s.%s', $m[1], $m[2], $m[3],
|
|
substr(str_pad($m[4], 3, '0', STR_PAD_RIGHT), 0, 3)),
|
|
$srt
|
|
);
|
|
|
|
// Some ".srt" files are already WebVTT — don't stack a second header.
|
|
$srt = trim($srt);
|
|
return (str_starts_with($srt, 'WEBVTT') ? $srt : "WEBVTT\n\n" . $srt) . "\n";
|
|
}
|
|
|
|
/** 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, v.folder_id, 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();
|
|
}
|
|
|
|
/** The top-level course a folder belongs to (itself, if it's already top-level). */
|
|
function courseIdOf(array $folder): int
|
|
{
|
|
$crumbs = folderBreadcrumbs($folder);
|
|
return (int)$crumbs[0]['id'];
|
|
}
|
|
|
|
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);
|
|
}
|