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) * ![exhibit](https://example.com/diagram.png) <- 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. * ------------------------------------------------------------------------- */ /** * Bring an older install's tables up to date. DDL commits implicitly in * MariaDB, so this has to run before the scan opens its transaction — never * from inside one. */ function migrateLibrarySchema(PDO $db): void { // Where a course starts is worked out per branch at scan time; see // markCourseRoots(). Both columns are rewritten by every scan. $db->exec( 'ALTER TABLE folders ADD COLUMN IF NOT EXISTS is_course TINYINT(1) NOT NULL DEFAULT 0 AFTER sort_order, ADD COLUMN IF NOT EXISTS course_id INT UNSIGNED NULL AFTER is_course' ); $db->exec('ALTER TABLE folders ADD INDEX IF NOT EXISTS idx_folders_course (course_id)'); $db->exec('ALTER TABLE folders ADD INDEX IF NOT EXISTS idx_folders_is_course (is_course, is_missing)'); // A path names a file on a case-sensitive filesystem, so "CCNP Notes" and // "CCNP NOtes" are two directories. Under the schema's default // (case-insensitive) collation the UNIQUE key on `path` treats them as one // and the second tree quietly overwrites the first — its videos never get // indexed. Tightening the collation can only remove collisions, never // create them, so it is safe to run against existing rows. foreach (['folders', 'videos', 'quizzes'] as $table) { $col = $db->query("SHOW FULL COLUMNS FROM `$table` WHERE Field = 'path'")->fetch(); if ($col && $col['Collation'] !== 'utf8mb4_bin') { $db->exec("ALTER TABLE `$table` MODIFY path VARCHAR(700) COLLATE utf8mb4_bin NOT NULL"); } } } /** * Does this folder name read as a chapter rather than as a title of its own? * "01_Welcome", "02 - Creating a VPC", "3. Introduction", "Module 2", "Week 1". * This is the one signal that separates a course from a category: a folder * whose children are numbered is the thing those numbers belong to. */ function looksOrdered(string $name): bool { return (bool)preg_match( '/^\s*\d{1,3}\s*[-._)\]]' . '|^\s*\d{1,3}\s+\S' . '|^\s*(?:module|section|part|week|chapter|day|lesson|lab|unit|vol)[\s_-]*\d+/i', $name ); } /** * Work out which folders are courses and stamp `is_course` / `course_id`. * * Course roots do not sit at a fixed depth. One library can hold * "System Admin/", "Firewalls/Fortinet/" and * "Networking/Cisco/CCIE Data Center/DC-Core/" at once, so the level is * decided per branch, walking down from the top: * * - a ".course" / ".category" marker file -> whatever it says * - nothing to watch in the subtree -> neither, and stop * - videos or quizzes sitting right here -> course * - children read as chapters (looksOrdered) -> course * - otherwise -> category, descend * * `$markers` maps a folder path to 'course' or 'category' for the folders that * carry a marker file. A marker only settles the folder it sits on: once a * course root is found, everything under it is one of its sections, so to push * a course root deeper, mark the folder above it ".category". * * Everything below a course root carries that root's id in `course_id`, which * is what lets one grouped query roll up every course at once. Categories above * every course keep a NULL `course_id` — they are navigation, not material. */ function markCourseRoots(PDO $db, array $markers = []): void { $folders = []; $byDepth = []; // deepest first, so a subtree total is complete before its parent needs it $roots = []; $stmt = $db->query('SELECT id, parent_id, name, depth, path FROM folders WHERE is_missing = 0 ORDER BY depth DESC'); foreach ($stmt as $r) { $id = (int)$r['id']; $folders[$id] = [ 'parent' => $r['parent_id'] !== null ? (int)$r['parent_id'] : null, 'name' => $r['name'], 'marker' => $markers[$r['path']] ?? null, 'direct' => 0, // videos + quizzes in this folder itself 'subtree' => 0, // ... and in everything under it 'kids' => [], ]; $byDepth[] = $id; } foreach (['videos', 'quizzes'] as $table) { $rows = $db->query("SELECT folder_id, COUNT(*) AS n FROM `$table` WHERE is_missing = 0 GROUP BY folder_id"); foreach ($rows as $r) { $id = (int)$r['folder_id']; if (isset($folders[$id])) { $folders[$id]['direct'] += (int)$r['n']; } } } foreach ($folders as $id => $f) { if ($f['parent'] !== null && isset($folders[$f['parent']])) { $folders[$f['parent']]['kids'][] = $id; } else { $roots[] = $id; } } foreach ($byDepth as $id) { $folders[$id]['subtree'] += $folders[$id]['direct']; $parent = $folders[$id]['parent']; if ($parent !== null && isset($folders[$parent])) { $folders[$parent]['subtree'] += $folders[$id]['subtree']; } } $isCourse = []; $courseOf = []; $stack = array_map(fn($id) => [$id, null], $roots); while ($stack) { [$id, $course] = array_pop($stack); $f = $folders[$id]; if ($course !== null) { // already inside a course: a section $isCourse[$id] = 0; $courseOf[$id] = $course; } elseif ($f['subtree'] === 0) { // no videos, no quizzes, nothing to show $isCourse[$id] = 0; $courseOf[$id] = null; } elseif ($f['marker'] === 'course') { // told, not guessed $isCourse[$id] = 1; $courseOf[$id] = $id; } else { $live = array_values(array_filter($f['kids'], fn($k) => $folders[$k]['subtree'] > 0)); $chaptered = false; foreach ($live as $k) { if (looksOrdered($folders[$k]['name'])) { $chaptered = true; break; } } // ".category" says "the courses are further down" — but a folder // holding videos itself has to be a course or they vanish. $forcedCategory = $f['marker'] === 'category' && $f['direct'] === 0 && $live; if (!$forcedCategory && ($f['direct'] > 0 || $chaptered || !$live)) { $isCourse[$id] = 1; $courseOf[$id] = $id; // and everything below inherits it } else { $isCourse[$id] = 0; $courseOf[$id] = null; } } foreach ($f['kids'] as $k) { $stack[] = [$k, $courseOf[$id]]; } } $db->exec('UPDATE folders SET is_course = 0, course_id = NULL WHERE is_course <> 0 OR course_id IS NOT NULL'); $upd = $db->prepare('UPDATE folders SET is_course = ?, course_id = ? WHERE id = ?'); foreach ($courseOf as $id => $course) { if ($course === null && !$isCourse[$id]) { continue; // already reset above } $upd->execute([$isCourse[$id], $course, $id]); } } 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 { migrateLibrarySchema($db); // DDL: must happen before the transaction opens $stats = ['folders' => 0, 'videos' => 0, 'quizzes' => 0, 'missing_folders' => 0, 'missing_videos' => 0, 'missing_quizzes' => 0]; $seenFolderIds = []; $seenVideoIds = []; $seenQuizIds = []; $markers = []; // folder path => 'course' | 'category', from marker files $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, &$markers): void { $entries = scandir($dir) ?: []; // An empty ".course" / ".category" file overrides what markCourseRoots() // would have guessed for this folder — the escape hatch for material the // chapter-name heuristic reads wrong. Dot files are skipped below, so // they are looked for before the filter. foreach (['course', 'category'] as $marker) { if ($relBase !== '' && is_file($dir . '/.' . $marker)) { $markers[$relBase] = $marker; break; // ".course" wins if somebody wrote both } } $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); // Which folders are courses can only be decided once the whole tree and // its contents are known, so it runs last — inside the same transaction, // so no request ever sees a library with no courses in it. markCourseRoots($db, $markers); $stats['courses'] = (int)$db->query( 'SELECT COUNT(*) FROM folders WHERE is_course = 1 AND is_missing = 0' )->fetchColumn(); $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(); return composeRollup($videos, $quizzes); } /** * folderRollup() for every child of one folder, in two queries instead of two * per child — a course with thirty sections, or a category with thirty courses, * is one page either way. * * Keyed by child folder *name*, which the filesystem already guarantees is * unique inside a directory. Children with nothing under them are absent. */ function childRollups(int $userId, string $folderPath): array { // The first path segment below $folderPath: "Course/Sec/Lesson" -> "Sec". $seg = "SUBSTRING_INDEX(SUBSTRING(f.path, CHAR_LENGTH(?) + 2), '/', 1)"; $args = fn() => [$folderPath, $userId, $folderPath, likePrefix($folderPath) . '/%']; $stmt = db()->prepare( "SELECT $seg AS seg, 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 <> ? AND f.path LIKE ? GROUP BY seg" ); $stmt->execute($args()); $videos = []; foreach ($stmt as $r) { $videos[$r['seg']] = $r; } $stmt = db()->prepare( "SELECT $seg AS seg, COUNT(*) AS total, 0 AS duration, 0 AS bytes, 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 <> ? AND f.path LIKE ? GROUP BY seg" ); $stmt->execute($args()); $quizzes = []; foreach ($stmt as $r) { $quizzes[$r['seg']] = $r; } $blank = ['total' => 0, 'duration' => 0, 'bytes' => 0, 'completed' => 0, 'credit' => 0]; $out = []; foreach (array_keys($videos + $quizzes) as $name) { $out[$name] = composeRollup($videos[$name] ?? $blank, $quizzes[$name] ?? $blank); } return $out; } /** * 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']]; } /** An empty rollup, for a folder that holds nothing (yet). */ function emptyRollup(): array { return ['videos' => 0, 'quizzes' => 0, 'duration' => 0.0, 'bytes' => 0, 'total' => 0, 'completed' => 0, 'percent' => 0]; } /** Fold the two halves of a rollup (videos, quizzes) into the public shape. */ function composeRollup(array $v, array $q): array { $total = (int)$v['total'] + (int)$q['total']; $credit = (float)$v['credit'] + (float)$q['credit']; return [ 'videos' => (int)$v['total'], 'quizzes' => (int)$q['total'], 'duration' => (float)$v['duration'], 'bytes' => (int)$v['bytes'], 'total' => $total, 'completed' => (int)$v['completed'] + (int)$q['completed'], 'percent' => $total > 0 ? (int)round(100 * $credit / $total) : 0, ]; } /** Add one rollup into another (used to total a category from its courses). */ function addRollup(array $a, array $b): array { foreach (['videos', 'quizzes', 'duration', 'bytes', 'total', 'completed'] as $k) { $a[$k] += $b[$k]; } $a['_credit'] = ($a['_credit'] ?? 0) + ($b['_credit'] ?? 0); $a['percent'] = $a['total'] > 0 ? (int)round(100 * $a['_credit'] / $a['total']) : 0; return $a; } /** * folderRollup() for every course at once, keyed by course id. * * A per-course card used to cost two prefix-matched queries. That was fine when * a course meant a top-level folder and there were a handful; with course roots * found at their real depth there are hundreds, and 2N queries per page load is * not a thing worth doing. `folders.course_id` turns the prefix match into a * GROUP BY, so the whole library is two queries. */ function courseRollups(int $userId): array { $videos = db()->prepare( "SELECT f.course_id AS cid, 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.course_id IS NOT NULL GROUP BY f.course_id" ); $videos->execute([$userId]); $quizzes = db()->prepare( "SELECT f.course_id AS cid, COUNT(*) AS total, 0 AS duration, 0 AS bytes, 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.course_id IS NOT NULL GROUP BY f.course_id" ); $quizzes->execute([$userId]); $blank = ['total' => 0, 'duration' => 0, 'bytes' => 0, 'completed' => 0, 'credit' => 0]; $v = $q = []; foreach ($videos as $r) { $v[(int)$r['cid']] = $r; } foreach ($quizzes as $r) { $q[(int)$r['cid']] = $r; } $out = []; foreach (array_keys($v + $q) as $cid) { $half = composeRollup($v[$cid] ?? $blank, $q[$cid] ?? $blank); $half['_credit'] = (float)($v[$cid]['credit'] ?? 0) + (float)($q[$cid]['credit'] ?? 0); $out[$cid] = $half; } return $out; } /** The card shape the library screens render, from a folder row and its rollup. */ function summaryFrom(array $folder, array $r): array { $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 — courses carry no categories of // their own, so progress is what separates one shelf from another. 'state' => $percent >= 100 ? 'finished' : ($percent > 0 ? 'progress' : 'fresh'), ]; } /** A course (or section) as the library screens show it: contents + progress. */ function courseSummary(int $userId, array $folder): array { return summaryFrom($folder, folderRollup($userId, $folder['path'])); } /** Every folder the scanner marked as a course root, shallowest first. */ function courseFolders(): array { return db()->query( 'SELECT * FROM folders WHERE is_course = 1 AND is_missing = 0 ORDER BY depth, path' )->fetchAll(); } /** Every course in the library as a card. Two queries for the lot. */ function courseCards(int $userId): array { $rollups = courseRollups($userId); return array_map( fn($f) => summaryFrom($f, $rollups[(int)$f['id']] ?? emptyRollup()), courseFolders() ); } /** * The folders that sit above courses: the library's navigation tier. * `$parentId` null lists the top of the tree. Each card totals the courses * beneath it, so a category shows real progress rather than a flat 0 %. */ function categoryCards(int $userId, ?int $parentId = null, ?array $rollups = null): array { $rollups ??= courseRollups($userId); $courses = courseFolders(); $out = []; foreach (childFolders($parentId) as $f) { if ((int)$f['is_course'] === 1) { continue; // a course, not a category } $prefix = $f['path'] . '/'; $total = emptyRollup(); $count = 0; foreach ($courses as $c) { if (str_starts_with($c['path'], $prefix)) { $total = addRollup($total, $rollups[(int)$c['id']] ?? emptyRollup()); $count++; } } if ($count === 0) { continue; // nothing watchable under it } $card = summaryFrom($f, $total); $card['kind'] = 'category'; $card['courses'] = $count; $out[] = $card; } return $out; } /** Courses that live directly under $parentId (null = the top of the tree). */ function courseCardsUnder(int $userId, ?int $parentId = null, ?array $rollups = null): array { $rollups ??= courseRollups($userId); $out = []; foreach (childFolders($parentId) as $f) { if ((int)$f['is_course'] !== 1) { continue; } $card = summaryFrom($f, $rollups[(int)$f['id']] ?? emptyRollup()); $card['kind'] = 'course'; $out[] = $card; } return $out; } /** 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 $rollups = null): array { $rollups ??= courseRollups($userId); $stmt = db()->prepare( 'SELECT * FROM folders WHERE is_course = 1 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 ($rollups, $week) { $c = summaryFrom($f, $rollups[(int)$f['id']] ?? emptyRollup()); $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 is_course = 1 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(); // Only the categories that actually lead somewhere: a folder with no course // anywhere beneath it (a stray "Books" of PDFs) is not part of the library. $categories = (int)db()->query( 'WITH RECURSIVE ancestors AS ( SELECT parent_id AS id FROM folders WHERE is_course = 1 AND is_missing = 0 AND parent_id IS NOT NULL UNION SELECT f.parent_id FROM folders f JOIN ancestors a ON f.id = a.id WHERE f.parent_id IS NOT NULL ) SELECT COUNT(DISTINCT id) FROM ancestors' )->fetchColumn(); return [ 'courses' => (int)$row['courses'], 'categories' => $categories, '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). * * `path` is stored utf8mb4_bin so that two folders differing only in case stay * two folders. That makes LIKE on it case-sensitive, which is right for * identifying a file and wrong for a search box, so the search — and only the * search — asks for the case-insensitive collation back. * ------------------------------------------------------------------------- */ 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 COLLATE utf8mb4_unicode_ci 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 COLLATE utf8mb4_unicode_ci 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 COLLATE utf8mb4_unicode_ci 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 ). * 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 course a folder belongs to — itself if it is one, otherwise the course * root above it. Stamped on every folder by the scanner, so this is a lookup * rather than a walk; a category above every course has none, and answers with * its own id so callers always get something to link to. */ function courseIdOf(array $folder): int { return $folder['course_id'] !== null ? (int)$folder['course_id'] : (int)$folder['id']; } /** Is this folder one the library presents as a course? */ function isCourseFolder(array $folder): bool { return (int)($folder['is_course'] ?? 0) === 1; } /** * What a folder is, for the screen that has to draw it: a course root, a * section inside one, or a category of courses above them. */ function folderKind(array $folder): string { if (isCourseFolder($folder)) return 'course'; if ($folder['course_id'] !== null) return 'section'; return 'category'; } 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); }