Files
coursehub/includes/functions.php
T
Harwinder Sekhon 91edba56d6 Remove course thumbnails and the ffmpeg dependency
Course cards go back to the colour-block art that previously sat under the
captured frame, so the UI is unchanged apart from losing the photo.

- Drop thumb.php, generateThumbnails(), captureThumbnail() and
  ensureThumbnailColumns(), plus the first-video tracking the scanner only
  kept to pick a frame source.
- Drop the ffprobe fallback in probeDuration(); the pure-PHP mvhd parse is
  now the only scan-time probe, with the player's report on first play as
  the remaining fallback.
- Drop THUMBNAILS_DIR/THUMBNAIL_SEEK_PERCENT from config, the
  thumbnail_* columns from schema.sql, the thumbnail counters from scan
  output, .thumb img from the stylesheet, and the matching README parts.

Existing databases keep two now-unused folders.thumbnail_* columns; they
are inert and can be dropped by hand if wanted.

Claude-Session: https://claude.ai/code/session_014oyi9DMdhALrGz989MNNZJ
2026-07-27 14:06:06 -04:00

739 lines
27 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)
* - 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']++;
}
}
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;
}
/** 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);
}