Files
Harwinder Sekhon 59fab56359 Seed the vendor catalogue into SQL and add an optional AI course advisor
Course metadata: a scan now gives every course a course_meta row from the
downloader's sidecar JSON when the folder has one, else from catalog_courses
matched by the folder name the downloader would create (folderKeyFor, a port
of ine.py's sanitize_name). tools/build-catalog-seed.php turns the INE export
into seeds/ine_catalog.sql, an idempotent patch that creates its own table and
upserts by id, so it can be applied to the live server before or after the
code and re-run freely. Course pages show the blurb, difficulty, vendor and
tags; search matches tags and blurbs; Settings reports how each course got its
details. Against the live library 163 of 179 courses match.

Course advisor, off by default: api/advisor.php answers "which course should I
take to learn X / what comes before Y" with courses from the library, in
order, plus vendor courses not yet downloaded. includes/ai_client.php is a
small curl wrapper for any OpenAI-format chat endpoint (DeepSeek V4 Flash by
default, no composer); the prompt is a byte-stable catalogue of the library so
the provider's prefix cache covers it. Every course id the model returns is
validated server-side, a bad JSON reply gets one retry then a plain-text
fallback, and a per-user daily cap with token counts lives in
advisor_requests. With AI_ENABLED false the app makes no outbound requests
and the box does not appear; users can also hide it per browser.

Claude-Session: https://claude.ai/code/session_012enquL2RZdjy3D5AcKP5yx
2026-09-08 18:36:43 -04:00

1593 lines
62 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)
* ![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");
}
}
// Course metadata: the vendor catalogue (seeded by seeds/ine_catalog.sql,
// which carries the same CREATE TABLE IF NOT EXISTS, so either may come
// first) and what the scan resolves per course from it.
$db->exec(catalogCoursesDdl());
$db->exec(courseMetaDdl());
}
/* ---------------------------------------------------------------------------
* Course metadata
*
* Two sources, neither of them the folder tree:
* - the vendor's catalogue, seeded into `catalog_courses` from an export
* (tools/build-catalog-seed.php -> seeds/ine_catalog.sql), and
* - a sidecar JSON the downloader leaves in a course folder once every file
* of the course made it (`<slug>.json`, same shape as a catalogue entry).
* A scan resolves one `course_meta` row per course from them: the sidecar is
* exact, the catalogue is matched by the folder name the downloader would
* have created (folderKeyFor). Courses that came from somewhere else simply
* have no row and appear as they always did.
* ------------------------------------------------------------------------- */
function catalogCoursesDdl(): string
{
return 'CREATE TABLE IF NOT EXISTS catalog_courses (
id VARCHAR(64) PRIMARY KEY,
name VARCHAR(255) NOT NULL,
slug VARCHAR(255) NOT NULL,
folder_key VARCHAR(255) NOT NULL,
vendor VARCHAR(100) NULL,
difficulty VARCHAR(32) NULL,
tags TEXT NULL,
short_description TEXT NULL,
description TEXT NULL,
learning_paths TEXT NULL,
categories TEXT NULL,
duration_seconds INT UNSIGNED NULL,
url VARCHAR(500) NULL,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_cc_folder_key (folder_key)
) ENGINE=InnoDB';
}
function courseMetaDdl(): string
{
return "CREATE TABLE IF NOT EXISTS course_meta (
folder_id INT UNSIGNED PRIMARY KEY,
source ENUM('sidecar', 'catalog') NOT NULL,
catalog_id VARCHAR(64) NULL,
title VARCHAR(255) NULL,
vendor VARCHAR(100) NULL,
difficulty VARCHAR(32) NULL,
tags TEXT NULL,
short_description TEXT NULL,
description TEXT NULL,
learning_paths TEXT NULL,
categories TEXT NULL,
duration_seconds INT UNSIGNED NULL,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_cm_folder FOREIGN KEY (folder_id) REFERENCES folders(id) ON DELETE CASCADE
) ENGINE=InnoDB";
}
/**
* The folder name the downloader creates for a course title — a port of
* ine.py's sanitize_name(): runs of whitespace and hyphens become one
* underscore, anything that is not a word character or a dot is dropped,
* underscores are collapsed and trimmed. "Cisco SD-WAN Use Design, Cloud &
* Advanced Features" -> "Cisco_SD_WAN_Use_Design_Cloud_Advanced_Features".
* Applying it to a name that is already a key changes nothing, so a folder
* renamed by hand ("Border Gateway Protocol (BGP)") still lands on the same key.
*/
function folderKeyFor(string $name): string
{
$key = preg_replace('/[\s\-]+/u', '_', trim($name));
$key = preg_replace('/[^\w.]/u', '', $key);
$key = preg_replace('/_+/', '_', $key);
$key = trim($key, '_.');
return $key !== '' ? $key : 'unnamed';
}
/**
* The fields kept from a catalogue entry (or a sidecar, which has the same
* shape), whitelisted and capped. Everything else in the vendor's record —
* instructor bios, image URLs, access passes — is noise here.
*/
function catalogFields(array $c): array
{
$str = function ($v, int $max): ?string {
if (!is_string($v)) return null;
$v = trim(strip_tags($v));
return $v === '' ? null : mb_substr($v, 0, $max);
};
$names = function ($v, int $max): array {
$out = [];
foreach (is_array($v) ? $v : [] as $item) {
$n = is_array($item) ? ($item['name'] ?? null) : $item;
if (is_string($n) && trim($n) !== '') {
$out[] = mb_substr(trim($n), 0, 100);
}
if (count($out) >= $max) break;
}
return array_values(array_unique($out));
};
$vendor = $c['vendor'] ?? null;
$duration = $c['duration_seconds'] ?? $c['duration_in_seconds'] ?? null;
return [
'id' => $str($c['id'] ?? null, 64),
'name' => $str($c['name'] ?? null, 255),
'slug' => $str($c['slug'] ?? null, 255),
'vendor' => is_array($vendor) ? $str($vendor['name'] ?? null, 100) : $str($vendor, 100),
'difficulty' => $str($c['difficulty'] ?? null, 32),
'tags' => $names($c['tags'] ?? null, 40),
'short_description' => $str($c['short_description'] ?? null, 1000),
'description' => $str($c['description'] ?? null, 4000),
'learning_paths' => $names($c['learning_paths'] ?? null, 20),
'categories' => $names($c['categories'] ?? null, 20),
'duration_seconds' => is_numeric($duration) && $duration > 0 ? (int)$duration : null,
'url' => $str($c['url'] ?? null, 500),
];
}
/**
* The sidecar of a course folder, if one of its .json files is one: a course
* record has a name and a slug at the top level. The downloader also drops a
* .json per lab lesson deeper in the tree; those carry `lab_type` and are
* not what is wanted. Never fatal — a bad file is just no sidecar.
*/
function readCourseSidecar(string $absDir, array $jsonNames): ?array
{
foreach ($jsonNames as $entry) {
$file = $absDir . '/' . $entry;
if (!is_file($file) || (filesize($file) ?: 0) > 2 * 1024 * 1024) continue;
$data = json_decode((string)@file_get_contents($file), true);
if (!is_array($data) || isset($data['lab_type'])) continue;
if (!is_string($data['name'] ?? null) || !is_string($data['slug'] ?? null)) continue;
$fields = catalogFields($data);
if ($fields['name'] === null) continue;
return $fields;
}
return null;
}
/** A catalog_courses row in the catalogFields() shape (JSON columns decoded). */
function catalogRowFields(array $row): array
{
$list = fn($v) => is_string($v) ? (json_decode($v, true) ?: []) : [];
return [
'id' => $row['id'],
'name' => $row['name'],
'slug' => $row['slug'],
'vendor' => $row['vendor'],
'difficulty' => $row['difficulty'],
'tags' => $list($row['tags']),
'short_description' => $row['short_description'],
'description' => $row['description'],
'learning_paths' => $list($row['learning_paths']),
'categories' => $list($row['categories']),
'duration_seconds' => $row['duration_seconds'] !== null ? (int)$row['duration_seconds'] : null,
'url' => $row['url'],
];
}
/**
* Resolve `course_meta` for every course the scan just marked: the sidecar
* when the folder has one, else the catalogue entry whose folder key matches
* the folder name, else nothing. Runs inside the scan's transaction, after
* markCourseRoots(). `$jsonFiles` maps a folder's relative path to the .json
* names found in it. Returns the counts for the scan summary.
*/
function syncCourseMeta(PDO $db, string $root, array $jsonFiles): array
{
$stats = ['meta_sidecar' => 0, 'meta_catalog' => 0, 'meta_none' => 0];
$catalog = [];
foreach ($db->query('SELECT * FROM catalog_courses') as $row) {
$catalog[mb_strtolower($row['folder_key'])] = $row;
}
$upsert = $db->prepare(
'INSERT INTO course_meta (folder_id, source, catalog_id, title, vendor, difficulty, tags,
short_description, description, learning_paths, categories,
duration_seconds)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
source = VALUES(source), catalog_id = VALUES(catalog_id), title = VALUES(title),
vendor = VALUES(vendor), difficulty = VALUES(difficulty), tags = VALUES(tags),
short_description = VALUES(short_description), description = VALUES(description),
learning_paths = VALUES(learning_paths), categories = VALUES(categories),
duration_seconds = VALUES(duration_seconds)'
);
$seen = [];
$courses = $db->query('SELECT id, name, path FROM folders WHERE is_course = 1 AND is_missing = 0');
foreach ($courses as $f) {
$source = null;
$fields = readCourseSidecar($root . '/' . $f['path'], $jsonFiles[$f['path']] ?? []);
if ($fields !== null) {
$source = 'sidecar';
} else {
$key = mb_strtolower(folderKeyFor($f['name']));
if (isset($catalog[$key])) {
$fields = catalogRowFields($catalog[$key]);
$source = 'catalog';
}
}
if ($source === null) {
$stats['meta_none']++;
continue;
}
$json = fn(array $a) => json_encode($a, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$upsert->execute([
(int)$f['id'], $source, $fields['id'], $fields['name'], $fields['vendor'],
$fields['difficulty'], $json($fields['tags']), $fields['short_description'],
$fields['description'], $json($fields['learning_paths']), $json($fields['categories']),
$fields['duration_seconds'],
]);
$seen[] = (int)$f['id'];
$stats['meta_' . $source]++;
}
if ($seen) {
$db->exec('DELETE FROM course_meta WHERE folder_id NOT IN (' . implode(',', $seen) . ')');
} else {
$db->exec('DELETE FROM course_meta');
}
return $stats;
}
/** How many catalogue courses have been seeded (0 until seeds/ine_catalog.sql is applied). */
function catalogCourseCount(): int
{
try {
return (int)db()->query('SELECT COUNT(*) FROM catalog_courses')->fetchColumn();
} catch (Throwable $e) {
return 0;
}
}
/** Every course_meta row, keyed by folder id. Empty until the first scan. */
function courseMetaAll(): array
{
try {
$rows = db()->query('SELECT * FROM course_meta')->fetchAll();
} catch (Throwable $e) {
return []; // table not created yet
}
$out = [];
foreach ($rows as $r) {
$out[(int)$r['folder_id']] = $r;
}
return $out;
}
function courseMeta(int $folderId): ?array
{
try {
$stmt = db()->prepare('SELECT * FROM course_meta WHERE folder_id = ?');
$stmt->execute([$folderId]);
return $stmt->fetch() ?: null;
} catch (Throwable $e) {
return null;
}
}
/** The shape a course_meta row takes in API responses. */
function courseMetaPublic(?array $row): ?array
{
if (!$row) return null;
$list = fn($v) => is_string($v) ? (json_decode($v, true) ?: []) : [];
return [
'source' => $row['source'],
'catalog_id' => $row['catalog_id'],
'title' => $row['title'],
'vendor' => $row['vendor'],
'difficulty' => $row['difficulty'],
'tags' => $list($row['tags']),
'short_description' => $row['short_description'],
'description' => $row['description'],
'learning_paths' => $list($row['learning_paths']),
'categories' => $list($row['categories']),
'duration_seconds' => $row['duration_seconds'] !== null ? (int)$row['duration_seconds'] : 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/<course>", "Firewalls/Fortinet/<course>" and
* "Networking/Cisco/CCIE Data Center/DC-Core/<course>" 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
$jsonFiles = []; // folder path => [.json names in it], for course sidecars
$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, &$jsonFiles): 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']++;
} elseif (is_file($full) && $relBase !== ''
&& strtolower(pathinfo($entry, PATHINFO_EXTENSION)) === 'json') {
// Remembered by folder; whether the folder is a course, and so
// whether this is its sidecar, is only known after the walk.
$jsonFiles[$relBase][] = $entry;
}
}
};
$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 += syncCourseMeta($db, $root, $jsonFiles);
$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'));
appMetaSet('course_meta_counts', json_encode([
'sidecar' => $stats['meta_sidecar'], 'catalog' => $stats['meta_catalog'], 'none' => $stats['meta_none'],
]));
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);
// A course's catalogue tags and blurb count too, so "OMP" finds the SD-WAN
// course. An install that has never scanned has no course_meta table yet;
// it searches names alone until it does.
try {
$stmt = db()->prepare(
'SELECT f.* FROM folders f
LEFT JOIN course_meta m ON m.folder_id = f.id
WHERE f.is_missing = 0
AND (f.name LIKE ? OR f.path COLLATE utf8mb4_unicode_ci LIKE ?
OR m.tags LIKE ? OR m.short_description LIKE ?)
ORDER BY f.depth, f.path
LIMIT ' . (int)$limit
);
$stmt->execute([$like, $like, $like, $like]);
} catch (Throwable $e) {
$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 <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 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);
}