Detect course roots per branch and add a category layer above them

The scanner no longer assumes courses sit at a fixed depth. It walks
each branch and marks a folder is_course when it holds media directly
or its children read as chapters (01_, 1. , Module 2, Week 3);
everything above that is a category, and every folder still gets a
course_id pointing at its nearest course for cheap rollups. An empty
.course/.category marker file overrides the heuristic per folder.

Also:
- Roll up progress for the whole library in two grouped queries
  instead of one prefix-matched query per course/folder card.
- Store folders/videos/quizzes paths as utf8mb4_bin so folders that
  differ only in case (CCNP Notes vs CCNP NOtes) aren't merged by a
  case-insensitive UNIQUE key.
- migrateLibrarySchema() brings an older install's tables up to date
  at scan time, so there's no separate migration step.
- Library UI gains a categories view alongside shelves/catalog; the
  continue-watching strip's play button now seeks to the saved
  position on open.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FfMPJK7TQV18JhFv6yTKev
This commit is contained in:
root
2026-09-08 18:51:04 +00:00
parent 2fa7452253
commit 6a09ae3833
7 changed files with 849 additions and 115 deletions
+91 -17
View File
@@ -14,8 +14,8 @@ views switch without page reloads and browser back/forward still work:
| Route | Screen | | Route | Screen |
| --- | --- | | --- | --- |
| `#/` | home — resume card, stats, in progress, last scan | | `#/` | home — resume card, stats, in progress, last scan |
| `#/library`, `#/library/<shelf>` | every course, as shelves or a filtered catalog | | `#/library`, `#/library/<shelf>` | categories, shelves or a flat catalog of every course |
| `#/folder/N` | a course or section: sections + content | | `#/folder/N` | a category (its courses), or a course/section (its contents) |
| `#/watch/N` | player, playlist, section quizzes | | `#/watch/N` | player, playlist, section quizzes |
| `#/quiz/N` | take and grade a quiz | | `#/quiz/N` | take and grade a quiz |
| `#/search/Q` | courses, videos and quizzes matching `Q` | | `#/search/Q` | courses, videos and quizzes matching `Q` |
@@ -25,11 +25,13 @@ Only `stream.php` (the mp4 bytes) and `captions.php` (subtitles as WebVTT) are
plain HTTP resources. plain HTTP resources.
- Recursively scans a videos directory for `.mp4` files and mirrors the folder - Recursively scans a videos directory for `.mp4` files and mirrors the folder
hierarchy (courses → sections → videos, any nesting depth). hierarchy (categories → courses → sections → videos, any nesting depth), and
works out **where a course starts** rather than assuming a depth.
- Per-user **resume**: log in from any device and continue where you left off. - Per-user **resume**: log in from any device and continue where you left off.
- **Progress tracking at every level** — each folder shows completed/total and a - **Progress tracking at every level** — each folder shows completed/total and a
percentage bar computed over its whole subtree. percentage bar computed over its whole subtree.
- "Continue watching" strip, auto-play next video, playback speed, keyboard - "Continue watching" strip — its play button opens the player *and* starts the
video at the saved position — auto-play next video, playback speed, keyboard
shortcuts (Plyr), completed checkmarks, natural sorting (`Video2` before `Video10`). shortcuts (Plyr), completed checkmarks, natural sorting (`Video2` before `Video10`).
- **Subtitles**: a video with a sibling `.srt` file (`Lesson.mp4` + - **Subtitles**: a video with a sibling `.srt` file (`Lesson.mp4` +
`Lesson.srt`) gets a CC button in the player — the file is converted to `Lesson.srt`) gets a CC button in the player — the file is converted to
@@ -38,28 +40,94 @@ plain HTTP resources.
- **Reset progress** for one video, one quiz, a whole course/section, or the - **Reset progress** for one video, one quiz, a whole course/section, or the
entire account. entire account.
- Course and section listings switch between **grid and list** layout, and the - Course and section listings switch between **grid and list** layout, and the
library between **shelves and catalog**; both choices are remembered. library between **categories, shelves and catalog**; both choices are remembered.
- **Categories** are the folders that sit above the courses — the library opens
on them, and each one totals the courses beneath it.
- **Shelves** group courses by how far you are: in progress, finished, not - **Shelves** group courses by how far you are: in progress, finished, not
started. The app has no course categories, so progress is what sorts them. started. The sidebar links straight to each one.
- Seekable streaming via HTTP Range requests; files are addressed by DB id only - Seekable streaming via HTTP Range requests; files are addressed by DB id only
(no path traversal). (no path traversal).
## Layout on disk ## Layout on disk
Put whatever shape your material already has under `VIDEOS_ROOT`. There is no
convention to follow and nothing to rename: the scanner works out which folder
is a course, per branch, and everything else follows from that.
``` ```
videos/ videos/
├── Course 1/ ├── Cloud/ category
│ ├── Intro/ │ └── AWS Networking/ course (children are numbered)
│ │ ├── Video1.mp4 │ ├── 01 - Welcome/
│ │ └── Video2.mp4 │ │ └── Intro.mp4
│ └── Module 2/ │ └── 02 - Creating a VPC/
│ └── Advanced/ │ └── Subnets.mp4
│ └── lesson.mp4 ├── Firewalls/ category
└── Course 2/ │ └── Palo-Alto/ category
└── ... │ └── Palo_Alto_Firewall_Essentials/ course
│ └── 01_Welcome/…
└── System Admin/ category
└── Red Hat … EX457/ course (videos sit right in it)
├── 01 Introduction.mp4
└── 02 Inventories.mp4
``` ```
Top-level folders become courses on the home page. ### Where a course starts
Course roots do not sit at a fixed depth — one library can hold
`Category/<course>`, `Category/Vendor/<course>` and
`Category/Vendor/Exam/<course>` at once — so the scanner decides per branch,
walking down from the top:
| The folder… | is a |
| --- | --- |
| has no videos or quizzes anywhere under it | neither — it is skipped |
| holds videos or quizzes directly | **course** |
| has children whose names read as chapters (`01_`, `1. `, `Module 2`, `Week 3`) | **course** |
| anything else | **category** — look one level deeper |
Everything under a course root is one of its sections. The result is stored as
`folders.is_course` / `folders.course_id` at scan time, so the screens are a
lookup rather than a guess, and re-running the scan re-derives it.
### Overriding it
The rule reads chapter numbering, so it gets a course wrong when the sections
carry plain names (`Course/Introduction`, `Course/Advanced` looks like two
courses). Drop an **empty marker file** in the folder and the next scan obeys it
instead of guessing:
| File | Effect |
| --- | --- |
| `.course` | this folder is a course; everything under it is a section |
| `.category` | the courses are further down — keep looking one level deeper |
```bash
touch "videos/Cat/Unnumbered Course/.course" # one course, not two
touch "videos/Cat/Exam Bundle/.category" # each child is its own course
```
A marker settles only the folder it sits on. Once a course root is found,
everything below it is a section, so to push a course root *deeper*, mark the
folder above it `.category`. A `.category` on a folder that holds videos itself
is ignored — those videos have to belong to something.
Two consequences worth knowing:
- **Progress means something.** Shelves, the home page's "in progress" strip and
every percentage are per course. Counting a whole category as one course
instead buries twenty watched videos in a denominator of three thousand, and
everything reads 0 % forever.
- **A folder of PDFs is not a course.** Anything with no `.mp4` and no quiz file
anywhere beneath it never appears in the library at all.
### Folder names are case-sensitive
`CCNP Notes` and `CCNP NOtes` are two directories on a Linux filesystem and the
app treats them as two, because `folders.path`, `videos.path` and `quizzes.path`
are stored `utf8mb4_bin`. Under the default case-insensitive collation the
`UNIQUE` key on `path` merged them, and the second tree's videos were never
indexed. The search box still matches without regard to case.
## Setup ## Setup
@@ -106,7 +174,9 @@ Top-level folders become courses on the home page.
Rescan whenever you add/remove files. Re-scans are idempotent: existing Rescan whenever you add/remove files. Re-scans are idempotent: existing
videos keep their IDs (and everyone's progress); files that disappeared are videos keep their IDs (and everyone's progress); files that disappeared are
hidden, not deleted, so progress survives if they come back. hidden, not deleted, so progress survives if they come back. Each scan also
re-derives which folders are courses, and brings an older install's tables up
to date first, so there is no separate migration step.
5. Open the site, create an account, and watch. 5. Open the site, create an account, and watch.
@@ -235,6 +305,10 @@ next scan creates it.
Completion is sticky — rewatching doesn't un-complete it. Completion is sticky — rewatching doesn't un-complete it.
- Folder progress is computed over the folder's entire subtree using a path - Folder progress is computed over the folder's entire subtree using a path
prefix match, with partial credit for videos in progress. prefix match, with partial credit for videos in progress.
- Every course is rolled up in **two queries for the whole library**, grouped by
`folders.course_id`, and a folder's children in two more — not two per card.
A library of a few hundred courses would otherwise spend a page load on
several hundred prefix-matched queries.
- Video duration is read at scan time by parsing the mp4's `mvhd` header in - Video duration is read at scan time by parsing the mp4's `mvhd` header in
pure PHP, so listings show durations before anyone plays the video. If a file pure PHP, so listings show durations before anyone plays the video. If a file
can't be parsed, the browser reports its duration on first play instead. can't be parsed, the browser reports its duration on first play instead.
+61 -28
View File
@@ -1,8 +1,10 @@
<?php <?php
// GET api/library.php?view=meta -> sidebar/header chrome: totals, shelves, last scan // GET api/library.php?view=meta -> sidebar/header chrome: totals, shelves, last scan
// GET api/library.php?view=home -> resume card, stats, in progress, recent additions // GET api/library.php?view=home -> resume card, stats, in progress, recent additions
// GET api/library.php?view=library -> every course, grouped into shelves // GET api/library.php?view=library -> every course, plus the categories above them
// GET api/library.php?view=folder&id=N -> folder contents + progress // GET api/library.php?view=folder&id=N -> folder contents + progress
// (a category answers with its
// courses, a course with its sections)
// GET api/library.php?view=video&id=N -> video + playlist + resume info // GET api/library.php?view=video&id=N -> video + playlist + resume info
// GET api/library.php?view=search&q=text -> matching folders, videos, quizzes // GET api/library.php?view=search&q=text -> matching folders, videos, quizzes
// GET api/library.php?view=settings -> library paths, thresholds, progress summary // GET api/library.php?view=settings -> library paths, thresholds, progress summary
@@ -21,6 +23,21 @@ function folderSummary(array $f, int $userId): array
]; ];
} }
/**
* The sections of a course, as cards. One batched query for the whole level
* instead of a rollup per section, which matters on a course with thirty of
* them; a section with nothing under it still gets a card, showing zeroes.
*/
function sectionCards(int $userId, array $folder): array
{
$rollups = childRollups($userId, $folder['path']);
return array_map(function ($f) use ($rollups) {
$card = summaryFrom($f, $rollups[$f['name']] ?? emptyRollup());
$card['kind'] = folderKind($f);
return $card;
}, childFolders((int)$folder['id']));
}
function videoSummary(array $v): array function videoSummary(array $v): array
{ {
$duration = $v['duration_seconds'] ?? $v['watched_duration'] ?? null; $duration = $v['duration_seconds'] ?? $v['watched_duration'] ?? null;
@@ -57,28 +74,13 @@ function crumbList(array $folder): array
); );
} }
/** The three shelves the library groups courses into. */
function shelveCourses(array $courses): array
{
$shelves = [
['key' => 'progress', 'name' => 'In progress', 'courses' => []],
['key' => 'finished', 'name' => 'Finished', 'courses' => []],
['key' => 'fresh', 'name' => 'Not started', 'courses' => []],
];
foreach ($courses as $c) {
foreach ($shelves as $i => $shelf) {
if ($shelf['key'] === $c['state']) {
$shelves[$i]['courses'][] = $c;
}
}
}
return array_values(array_filter($shelves, fn($s) => $s['courses']));
}
$view = $_GET['view'] ?? 'home'; $view = $_GET['view'] ?? 'home';
switch ($view) { switch ($view) {
case 'meta': case 'meta':
// Shelf counts are over courses, wherever in the tree they turned out
// to live — counting top-level folders instead put a whole vendor's
// catalogue on one shelf and left the other two empty for good.
$courses = courseCards($userId); $courses = courseCards($userId);
$counts = ['progress' => 0, 'finished' => 0, 'fresh' => 0]; $counts = ['progress' => 0, 'finished' => 0, 'fresh' => 0];
foreach ($courses as $c) { foreach ($courses as $c) {
@@ -97,6 +99,7 @@ switch ($view) {
break; break;
case 'home': case 'home':
$rollups = courseRollups($userId);
$courses = courseCards($userId); $courses = courseCards($userId);
$resume = continueWatching($userId, 1); $resume = continueWatching($userId, 1);
$resumeCourseId = null; $resumeCourseId = null;
@@ -123,16 +126,22 @@ switch ($view) {
'duration' => $r['duration_seconds'] !== null ? (float)$r['duration_seconds'] : null, 'duration' => $r['duration_seconds'] !== null ? (float)$r['duration_seconds'] : null,
], continueWatching($userId)), ], continueWatching($userId)),
'in_progress' => array_values(array_filter($courses, fn($c) => $c['state'] === 'progress')), 'in_progress' => array_values(array_filter($courses, fn($c) => $c['state'] === 'progress')),
'recent' => recentCourses($userId), 'recent' => recentCourses($userId, 3, $rollups),
'courses' => $courses,
]); ]);
break; break;
case 'library': case 'library':
// Three ways into the same set: the category tier the folders on disk
// already describe, the progress shelves, or one flat catalogue.
$rollups = courseRollups($userId);
$courses = courseCards($userId); $courses = courseCards($userId);
// Shelving is a partition of `courses` by its own `state`, so it is done
// in the browser: sending it as well would put every course in the
// payload twice.
echo json_encode([ echo json_encode([
'courses' => $courses, 'courses' => $courses,
'shelves' => shelveCourses($courses), 'categories' => categoryCards($userId, null, $rollups),
'top_courses' => courseCardsUnder($userId, null, $rollups),
'totals' => libraryTotals(), 'totals' => libraryTotals(),
]); ]);
break; break;
@@ -146,16 +155,33 @@ switch ($view) {
} }
$crumbs = crumbList($folder); $crumbs = crumbList($folder);
$rollup = folderRollup($userId, $folder['path']); $rollup = folderRollup($userId, $folder['path']);
$kind = folderKind($folder);
// A category's children are courses and further categories, which the
// library shelves; a course's children are its sections, which it just
// lists in order.
$categories = $courses = [];
$subfolders = [];
if ($kind === 'category') {
$rollups = courseRollups($userId);
$categories = categoryCards($userId, (int)$folder['id'], $rollups);
$courses = courseCardsUnder($userId, (int)$folder['id'], $rollups);
} else {
$subfolders = sectionCards($userId, $folder);
}
echo json_encode([ echo json_encode([
'folder' => ['id' => (int)$folder['id'], 'name' => $folder['name'], 'folder' => ['id' => (int)$folder['id'], 'name' => $folder['name'],
'path' => $folder['path']], 'path' => $folder['path'], 'kind' => $kind],
'course_id' => $crumbs ? $crumbs[0]['id'] : (int)$folder['id'], 'kind' => $kind,
'course_id' => courseIdOf($folder),
'breadcrumbs' => $crumbs, 'breadcrumbs' => $crumbs,
'rollup' => $rollup, 'rollup' => $rollup,
'progress' => ['total' => $rollup['total'], 'completed' => $rollup['completed'], 'progress' => ['total' => $rollup['total'], 'completed' => $rollup['completed'],
'percent' => $rollup['percent']], 'percent' => $rollup['percent']],
'subfolders' => array_map(fn($f) => courseSummary($userId, $f), 'categories' => $categories,
childFolders((int)$folder['id'])), 'courses' => $courses,
'subfolders' => $subfolders,
'videos' => array_map('videoSummary', 'videos' => array_map('videoSummary',
folderVideos((int)$folder['id'], $userId)), folderVideos((int)$folder['id'], $userId)),
'quizzes' => array_map('quizSummary', 'quizzes' => array_map('quizSummary',
@@ -197,7 +223,7 @@ switch ($view) {
'duration' => $video['duration_seconds'] !== null 'duration' => $video['duration_seconds'] !== null
? (float)$video['duration_seconds'] : null], ? (float)$video['duration_seconds'] : null],
'folder' => ['id' => (int)$folder['id'], 'name' => $folder['name']], 'folder' => ['id' => (int)$folder['id'], 'name' => $folder['name']],
'course_id' => $crumbs ? $crumbs[0]['id'] : (int)$folder['id'], 'course_id' => courseIdOf($folder),
'breadcrumbs' => $crumbs, 'breadcrumbs' => $crumbs,
'playlist' => array_map('videoSummary', $playlist), 'playlist' => array_map('videoSummary', $playlist),
'quizzes' => array_map('quizSummary', folderQuizzes((int)$folder['id'], $userId)), 'quizzes' => array_map('quizSummary', folderQuizzes((int)$folder['id'], $userId)),
@@ -221,8 +247,10 @@ switch ($view) {
echo json_encode([ echo json_encode([
'query' => $q, 'query' => $q,
// A folder hit can be a category, a course or one section inside
// one — worth saying which, now that they are three different things.
'folders' => array_map( 'folders' => array_map(
fn($f) => courseSummary($userId, $f), fn($f) => courseSummary($userId, $f) + ['kind' => folderKind($f)],
searchFolders($q) searchFolders($q)
), ),
'videos' => array_map( 'videos' => array_map(
@@ -238,6 +266,10 @@ switch ($view) {
case 'settings': case 'settings':
$courses = courseCards($userId); $courses = courseCards($userId);
$counts = ['progress' => 0, 'finished' => 0, 'fresh' => 0];
foreach ($courses as $c) {
$counts[$c['state']]++;
}
$withSome = array_values(array_filter($courses, fn($c) => $c['progress']['percent'] > 0)); $withSome = array_values(array_filter($courses, fn($c) => $c['progress']['percent'] > 0));
echo json_encode([ echo json_encode([
'root' => rtrim(VIDEOS_ROOT, '/'), 'root' => rtrim(VIDEOS_ROOT, '/'),
@@ -249,6 +281,7 @@ switch ($view) {
'username' => currentUsername(), 'username' => currentUsername(),
'progress_courses' => count($withSome), 'progress_courses' => count($withSome),
'courses' => count($courses), 'courses' => count($courses),
'shelf_counts' => $counts,
]); ]);
break; break;
+160 -20
View File
@@ -20,6 +20,7 @@
csrf: null, // session token, echoed back on every POST csrf: null, // session token, echoed back on every POST
meta: null, // totals / shelves / last scan, for the chrome meta: null, // totals / shelves / last scan, for the chrome
suppressSave: false, // set while a reset lands, so teardown doesn't re-save suppressSave: false, // set while a reset lands, so teardown doesn't re-save
playOnLoad: null, // video id a play button asked to start, not just open
scanning: false, scanning: false,
}; };
let cleanupView = null; let cleanupView = null;
@@ -33,6 +34,8 @@
const SHELF_HUE = { progress: '#B85A2E', finished: '#3F7A50', fresh: '#374B5C', all: '#C2410C' }; const SHELF_HUE = { progress: '#B85A2E', finished: '#3F7A50', fresh: '#374B5C', all: '#C2410C' };
const SHELF_NAME = { progress: 'In progress', finished: 'Finished', fresh: 'Not started', all: 'All courses' }; const SHELF_NAME = { progress: 'In progress', finished: 'Finished', fresh: 'Not started', all: 'All courses' };
// What a folder is, for search results: the three now mean different things.
const KIND_NAME = { category: 'Category', course: 'Course', section: 'Section' };
const ICONS = { const ICONS = {
home: '<path d="M3 10.5 12 3l9 7.5"></path><path d="M5 9.5V21h14V9.5"></path>', home: '<path d="M3 10.5 12 3l9 7.5"></path><path d="M5 9.5V21h14V9.5"></path>',
@@ -127,7 +130,7 @@
/* ---------- preferences ---------- */ /* ---------- preferences ---------- */
const PREF_DEFAULTS = { view: 'grid', layout: 'shelves', autoplay: true }; const PREF_DEFAULTS = { view: 'grid', layout: 'categories', autoplay: true };
function pref(key) { function pref(key) {
try { try {
@@ -352,6 +355,46 @@
</a>`; </a>`;
} }
// A category is a folder of courses. It gets the same card as a course so the
// library reads as one grid, with the course count where a course shows its
// videos, and no play affordance — there is nothing to play at this level.
function categoryCard(c) {
const hue = hueOf(c.id);
return `<a class="course-card" href="#/folder/${c.id}">
${thumb(hue, { cat: 'Category', dur: fmtLong(c.duration) })}
<div class="course-card-body">
<div class="course-title">${esc(c.name)}</div>
<div class="course-meta">${c.courses} course${c.courses === 1 ? '' : 's'} · ${esc(courseMeta(c))}</div>
<div class="bar-row">
${bar(c.progress.percent, hue)}
<span class="mono">${c.progress.percent}%</span>
</div>
</div>
</a>`;
}
function categoryRow(c) {
const hue = hueOf(c.id);
return `<a class="row" href="#/folder/${c.id}">
<span class="initial initial-sm" style="--c:${hue}">${esc(initialOf(c.name))}</span>
<div class="row-fixed">
<div class="row-title">${esc(c.name)}</div>
<div class="row-sub">${esc(c.path || '')}</div>
</div>
<span class="row-col">${c.courses} course${c.courses === 1 ? '' : 's'}</span>
<span class="row-num">${c.videos} vids</span>
<span class="row-num">${esc(fmtLong(c.duration))}</span>
<div class="row-bar">
${bar(c.progress.percent, hue)}
<span class="mono">${c.progress.percent}%</span>
</div>
</a>`;
}
const categoryList = (cats, mode) => mode === 'list'
? `<div class="rows">${cats.map(categoryRow).join('')}</div>`
: `<div class="grid">${cats.map(categoryCard).join('')}</div>`;
function courseRow(c) { function courseRow(c) {
const hue = hueOf(c.id); const hue = hueOf(c.id);
return `<div class="row"> return `<div class="row">
@@ -527,7 +570,8 @@
const left = resume.duration ? Math.max(0, resume.duration - resume.position) : 0; const left = resume.duration ? Math.max(0, resume.duration - resume.position) : 0;
const hue = hueOf(resume.course_id ?? resume.id); const hue = hueOf(resume.course_id ?? resume.id);
return `<div class="hero"> return `<div class="hero">
<div class="hero-art">${thumb(hue, { play: true, percent: pct })}</div> <a class="hero-art" href="#/watch/${resume.id}" data-play="${resume.id}"
aria-label="Resume ${esc(resume.name)}">${thumb(hue, { play: true, percent: pct })}</a>
<div class="hero-body"> <div class="hero-body">
<span class="hero-eyebrow">Continue watching</span> <span class="hero-eyebrow">Continue watching</span>
<div class="hero-title">${esc(resume.name)}</div> <div class="hero-title">${esc(resume.name)}</div>
@@ -607,11 +651,38 @@
/* ---------- library ---------- */ /* ---------- library ---------- */
/**
* Split courses onto the three progress shelves. The API sends one flat list
* and this partitions it — shipping the partition as well would put every
* course in the payload twice.
*/
const shelveCourses = (courses) =>
[['progress', 'In progress'], ['finished', 'Finished'], ['fresh', 'Not started']]
.map(([key, name]) => ({ key, name, courses: courses.filter((c) => c.state === key) }))
.filter((s) => s.courses.length);
/**
* A shelf block: a heading, a count and the cards on it. Used by the library
* and by a category page, which shelves its own courses the same way.
*/
const shelfBlock = (shelf, mode) => `
<div style="margin-bottom:34px">
<div class="shelf-head">
<span class="swatch" style="--c:${SHELF_HUE[shelf.key]}"></span>
<h2>${esc(shelf.name)}</h2>
<span class="mono">${shelf.courses.length} course${shelf.courses.length === 1 ? '' : 's'}</span>
<div class="rule"></div>
</div>
${courseList(shelf.courses, mode)}
</div>`;
async function renderLibrary(filter) { async function renderLibrary(filter) {
if (filter && !(filter in SHELF_NAME)) filter = null; // #/library/whatever → every course if (filter && !(filter in SHELF_NAME)) filter = null; // #/library/whatever → every course
spinner(); spinner();
const data = await load('api/library.php?view=library'); const data = await load('api/library.php?view=library');
// A shelf link from the sidebar is always a flat list of matching courses;
// otherwise the saved layout decides.
const layout = filter && filter !== 'all' ? 'catalog' : pref('layout'); const layout = filter && filter !== 'all' ? 'catalog' : pref('layout');
const mode = pref('view'); const mode = pref('view');
const active = filter || 'all'; const active = filter || 'all';
@@ -629,17 +700,19 @@
<p>Add course folders to the videos directory, then scan.</p> <p>Add course folders to the videos directory, then scan.</p>
<button class="btn btn-accent" data-scan>Scan library</button> <button class="btn btn-accent" data-scan>Scan library</button>
</div>`; </div>`;
} else if (layout === 'shelves') { } else if (layout === 'categories' && data.categories.length) {
body = data.shelves.map((s) => ` // The folders above the courses are the library's own shape — a few dozen
<div style="margin-bottom:34px"> // cards to browse instead of a wall of every course at once.
<div class="shelf-head"> body = categoryList(data.categories, mode)
<span class="swatch" style="--c:${SHELF_HUE[s.key]}"></span> + (data.top_courses.length ? `
<h2>${esc(s.name)}</h2> <div class="shelf-head" style="margin-top:34px">
<span class="mono">${s.courses.length} course${s.courses.length === 1 ? '' : 's'}</span> <h2>Courses</h2>
<span class="mono">${data.top_courses.length}</span>
<div class="rule"></div> <div class="rule"></div>
</div> </div>
${courseList(s.courses, mode)} ${courseList(data.top_courses, mode)}` : '');
</div>`).join(''); } else if (layout === 'shelves' || (layout === 'categories' && !data.categories.length)) {
body = shelveCourses(data.courses).map((sh) => shelfBlock(sh, mode)).join('');
} else { } else {
body = `<div class="chips">${chips}</div>` + (shown.length body = `<div class="chips">${chips}</div>` + (shown.length
? courseList(shown, mode) ? courseList(shown, mode)
@@ -649,19 +722,27 @@
} }
const totalVideos = data.courses.reduce((n, c) => n + c.videos, 0); const totalVideos = data.courses.reduce((n, c) => n + c.videos, 0);
const sub = [
`${data.courses.length} course${data.courses.length === 1 ? '' : 's'}`,
data.totals.categories ? `${data.totals.categories} categor${data.totals.categories === 1 ? 'y' : 'ies'}` : '',
`${totalVideos} video${totalVideos === 1 ? '' : 's'} on this machine`,
].filter(Boolean).join(' · ');
const layoutBtn = (key, label) =>
`<button class="${layout === key ? 'is-on' : ''}" data-layout="${key}">${label}</button>`;
view.innerHTML = ` view.innerHTML = `
<div> <div>
<div class="page-head"> <div class="page-head">
<div> <div>
<h1>Library</h1> <h1>Library</h1>
<p class="page-sub">${data.courses.length} course${data.courses.length === 1 ? '' : 's'} · <p class="page-sub">${sub}</p>
${totalVideos} video${totalVideos === 1 ? '' : 's'} on this machine</p>
</div> </div>
<div style="display:flex;align-items:center;gap:10px"> <div style="display:flex;align-items:center;gap:10px">
<div class="seg"> <div class="seg">
<button class="${layout === 'shelves' ? 'is-on' : ''}" data-layout="shelves">Shelves</button> ${data.categories.length ? layoutBtn('categories', 'Categories') : ''}
<button class="${layout === 'catalog' ? 'is-on' : ''}" data-layout="catalog">Catalog</button> ${layoutBtn('shelves', 'Shelves')}
${layoutBtn('catalog', 'Catalog')}
</div> </div>
${viewToggle()} ${viewToggle()}
</div> </div>
@@ -678,27 +759,50 @@
const data = await load(`api/library.php?view=folder&id=${id}`); const data = await load(`api/library.php?view=folder&id=${id}`);
const mode = pref('view'); const mode = pref('view');
const hue = hueOf(data.course_id); const hue = hueOf(data.course_id);
const isCategory = data.kind === 'category';
const sections = data.subfolders.length ? ` // A category holds courses (and possibly more categories), so it reads like
// a small library of its own; a course holds sections, which are just a
// list. Both come back on the same route — only the shape below differs.
const sections = isCategory
? (data.categories.length ? `
<div class="section-head">
<h2 class="h-section">Categories</h2>
${viewToggle()}
</div>
<div style="margin-bottom:32px">${categoryList(data.categories, mode)}</div>` : '')
+ (data.courses.length ? `
<div class="section-head">
<h2 class="h-section">Courses</h2>
${data.categories.length ? '' : viewToggle()}
</div>
${shelveCourses(data.courses).map((sh) => shelfBlock(sh, mode)).join('')}` : '')
: (data.subfolders.length ? `
<div class="section-head"> <div class="section-head">
<h2 class="h-section">Sections</h2> <h2 class="h-section">Sections</h2>
${viewToggle()} ${viewToggle()}
</div> </div>
<div style="margin-bottom:32px">${courseList(data.subfolders, mode)}</div>` : ''; <div style="margin-bottom:32px">${courseList(data.subfolders, mode)}</div>` : '');
const items = [ const items = [
...data.videos.map((v) => ({ ...v, kind: 'video' })), ...data.videos.map((v) => ({ ...v, kind: 'video' })),
...data.quizzes.map((q) => ({ ...q, kind: 'quiz' })), ...data.quizzes.map((q) => ({ ...q, kind: 'quiz' })),
].sort((a, b) => a.filename.localeCompare(b.filename, undefined, { numeric: true, sensitivity: 'base' })); ].sort((a, b) => a.filename.localeCompare(b.filename, undefined, { numeric: true, sensitivity: 'base' }));
const hasChildren = isCategory
? (data.categories.length + data.courses.length) > 0
: data.subfolders.length > 0;
const content = items.length ? ` const content = items.length ? `
<h2 class="h-section" style="margin-bottom:14px">Content</h2> <h2 class="h-section" style="margin-bottom:14px">Content</h2>
<div class="rows">${items.map((it) => it.kind === 'quiz' ? quizRow(it) : videoRow(it)).join('')}</div>` <div class="rows">${items.map((it) => it.kind === 'quiz' ? quizRow(it) : videoRow(it)).join('')}</div>`
: (!data.subfolders.length : (!hasChildren
? `<div class="empty"><h2>This folder is empty</h2> ? `<div class="empty"><h2>This folder is empty</h2>
<p>No videos or quizzes were found here in the last scan.</p></div>` : ''); <p>No videos or quizzes were found here in the last scan.</p></div>` : '');
const courseCount = isCategory ? data.categories.reduce((n, c) => n + c.courses, data.courses.length) : 0;
const meta = [ const meta = [
courseCount ? `${courseCount} course${courseCount === 1 ? '' : 's'}` : '',
data.rollup.videos ? `${data.rollup.videos} video${data.rollup.videos === 1 ? '' : 's'}` : '', data.rollup.videos ? `${data.rollup.videos} video${data.rollup.videos === 1 ? '' : 's'}` : '',
data.rollup.quizzes ? `${data.rollup.quizzes} quiz${data.rollup.quizzes === 1 ? '' : 'zes'}` : '', data.rollup.quizzes ? `${data.rollup.quizzes} quiz${data.rollup.quizzes === 1 ? '' : 'zes'}` : '',
fmtLong(data.rollup.duration), fmtLong(data.rollup.duration),
@@ -710,6 +814,7 @@
${crumbs(data.breadcrumbs)} ${crumbs(data.breadcrumbs)}
<div class="page-head"> <div class="page-head">
<div> <div>
${isCategory ? '<span class="eyebrow eyebrow-sm">Category</span>' : ''}
<h1>${esc(data.folder.name)}</h1> <h1>${esc(data.folder.name)}</h1>
<p class="page-sub">${esc(meta || 'Empty folder')}</p> <p class="page-sub">${esc(meta || 'Empty folder')}</p>
</div> </div>
@@ -758,7 +863,7 @@
<span class="initial initial-lg" style="--c:${hueOf(c.id)}">${esc(initialOf(c.name))}</span> <span class="initial initial-lg" style="--c:${hueOf(c.id)}">${esc(initialOf(c.name))}</span>
<div class="row-main"> <div class="row-main">
<div class="row-title">${esc(c.name)}</div> <div class="row-title">${esc(c.name)}</div>
<div class="row-sub">${esc([courseMeta(c), c.path].filter(Boolean).join(' · '))}</div> <div class="row-sub">${esc([KIND_NAME[c.kind], courseMeta(c), c.path].filter(Boolean).join(' · '))}</div>
</div> </div>
<div class="row-bar" style="flex:none;width:120px"> <div class="row-bar" style="flex:none;width:120px">
${bar(c.progress.percent, hueOf(c.id))} ${bar(c.progress.percent, hueOf(c.id))}
@@ -1034,6 +1139,12 @@
</div> </div>
<div class="fact-row"><span>Indexed</span> <div class="fact-row"><span>Indexed</span>
<span class="mono">${data.totals.videos} videos · ${data.totals.quizzes} quizzes · ${esc(fmtBytes(data.totals.bytes) || '0 mb')}</span></div> <span class="mono">${data.totals.videos} videos · ${data.totals.quizzes} quizzes · ${esc(fmtBytes(data.totals.bytes) || '0 mb')}</span></div>
<div class="fact-row"><span>Courses found</span>
<span class="mono">${data.totals.courses} in ${data.totals.categories} categor${data.totals.categories === 1 ? 'y' : 'ies'}</span></div>
<div class="fact-row"><span>Shelves</span>
<span class="mono">${data.shelf_counts.progress} in progress ·
${data.shelf_counts.finished} finished ·
${data.shelf_counts.fresh} not started</span></div>
<div class="fact-row"><span>Total running time</span> <div class="fact-row"><span>Total running time</span>
<span class="mono">${esc(fmtLong(data.totals.duration) || '—')}</span></div> <span class="mono">${esc(fmtLong(data.totals.duration) || '—')}</span></div>
<div class="fact-row"><span>A video counts as watched at</span> <div class="fact-row"><span>A video counts as watched at</span>
@@ -1109,6 +1220,18 @@
resumed = true; resumed = true;
}); });
// A play button elsewhere in the app (the home hero) asked for the video to
// start, not just for the player to open. Autoplay is only permitted off the
// gesture that got us here, so the flag is consumed once and never survives
// a reload — and it waits for the resume seek above, so play() picks up at
// the saved position rather than at zero.
if (state.playOnLoad === data.video.id) {
state.playOnLoad = null;
const start = () => { const p = el.play(); if (p) p.catch(() => { /* autoplay refused — controls are there */ }); };
if (el.readyState >= 1) start();
else el.addEventListener('loadedmetadata', start, { once: true });
}
let lastSaved = -1; let lastSaved = -1;
let saveTimer = null; let saveTimer = null;
@@ -1149,6 +1272,10 @@
save(true); // the final position always lands, even within 1 s of the last heartbeat save(true); // the final position always lands, even within 1 s of the last heartbeat
if (data.next_id && pref('autoplay') !== false) { if (data.next_id && pref('autoplay') !== false) {
status(`${icon('next', 14, 1.7)}Playing next video…`); status(`${icon('next', 14, 1.7)}Playing next video…`);
// "Autoplay next" has to actually start it — navigating alone just
// opened the next video paused. The gesture that began this playback
// still counts, so the browser lets the next one start.
state.playOnLoad = data.next_id;
nextTimer = setTimeout(() => { location.hash = `#/watch/${data.next_id}`; }, 1500); nextTimer = setTimeout(() => { location.hash = `#/watch/${data.next_id}`; }, 1500);
} }
}); });
@@ -1160,6 +1287,12 @@
return function cleanup() { return function cleanup() {
saveBeacon(); saveBeacon();
clearTimeout(nextTimer); // leaving during the 1.5 s countdown must not jump ahead later clearTimeout(nextTimer); // leaving during the 1.5 s countdown must not jump ahead later
// Autoplay-next arms playOnLoad before it navigates, and route() tears the
// old view down after the hash has already changed — so only disarm when
// we are leaving for somewhere other than the video it queued.
if (state.playOnLoad === data.next_id && location.hash !== `#/watch/${data.next_id}`) {
state.playOnLoad = null;
}
if (saveTimer) clearInterval(saveTimer); if (saveTimer) clearInterval(saveTimer);
document.removeEventListener('visibilitychange', onHide); document.removeEventListener('visibilitychange', onHide);
window.removeEventListener('pagehide', saveBeacon); window.removeEventListener('pagehide', saveBeacon);
@@ -1218,7 +1351,7 @@
try { try {
const res = await api('api/scan.php', { method: 'POST' }); const res = await api('api/scan.php', { method: 'POST' });
const s = res.stats; const s = res.stats;
toast(`Scan finished — ${s.folders} folders, ${s.videos} videos, ${s.quizzes} quizzes indexed.`); toast(`Scan finished — ${s.courses} courses, ${s.videos} videos, ${s.quizzes} quizzes indexed.`);
await loadMeta(); await loadMeta();
route(); route();
} catch (err) { } catch (err) {
@@ -1336,6 +1469,13 @@
/* ---------- delegated actions ---------- */ /* ---------- delegated actions ---------- */
document.addEventListener('click', (e) => { document.addEventListener('click', (e) => {
// Not a preventDefault: the link still navigates to #/watch/N, this only
// tells the player it was reached from a play button and should start.
const playLink = e.target.closest('[data-play]');
if (playLink && !e.metaKey && !e.ctrlKey && !e.shiftKey && e.button === 0) {
state.playOnLoad = +playLink.dataset.play;
}
const scan = e.target.closest('[data-scan]'); const scan = e.target.closest('[data-scan]');
if (scan) { e.preventDefault(); runScan(); return; } if (scan) { e.preventDefault(); runScan(); return; }
+12 -1
View File
@@ -734,7 +734,18 @@ button, input { font-family: inherit; font-size: inherit; }
text-decoration: none; text-decoration: none;
} }
.hero:hover { color: var(--surface); } .hero:hover { color: var(--surface); }
.hero-art { width: 280px; flex: none; position: relative; border-radius: 10px; overflow: hidden; } .hero-art {
display: block;
width: 280px; flex: none;
position: relative;
border-radius: 10px;
overflow: hidden;
}
/* The hero art is a link straight into the player, so its play badge has to
read as a button — the rest of the thumb art is decoration. */
.hero-art .thumb-play span { transition: transform .12s ease, background-color .12s ease; }
.hero-art:hover .thumb-play span { background: var(--surface); transform: scale(1.06); }
.hero-art:focus-visible { outline: 2px solid var(--surface); outline-offset: 2px; }
.hero-body { .hero-body {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
+489 -31
View File
@@ -133,6 +133,169 @@ function parseQuizFile(string $absPath): array
* (keyed by relative path so IDs stay stable across rescans), then flags * (keyed by relative path so IDs stay stable across rescans), then flags
* rows whose files disappeared as is_missing. * 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/<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 function scanLibrary(): array
{ {
$root = rtrim(VIDEOS_ROOT, '/'); $root = rtrim(VIDEOS_ROOT, '/');
@@ -158,11 +321,14 @@ function scanLibrary(): array
/** The scan proper — only ever called by scanLibrary() while it holds the lock. */ /** The scan proper — only ever called by scanLibrary() while it holds the lock. */
function scanLibraryLocked(PDO $db, string $root): array function scanLibraryLocked(PDO $db, string $root): array
{ {
migrateLibrarySchema($db); // DDL: must happen before the transaction opens
$stats = ['folders' => 0, 'videos' => 0, 'quizzes' => 0, $stats = ['folders' => 0, 'videos' => 0, 'quizzes' => 0,
'missing_folders' => 0, 'missing_videos' => 0, 'missing_quizzes' => 0]; 'missing_folders' => 0, 'missing_videos' => 0, 'missing_quizzes' => 0];
$seenFolderIds = []; $seenFolderIds = [];
$seenVideoIds = []; $seenVideoIds = [];
$seenQuizIds = []; $seenQuizIds = [];
$markers = []; // folder path => 'course' | 'category', from marker files
$upsertFolder = $db->prepare( $upsertFolder = $db->prepare(
'INSERT INTO folders (parent_id, name, path, depth, sort_order, is_missing) 'INSERT INTO folders (parent_id, name, path, depth, sort_order, is_missing)
@@ -197,9 +363,19 @@ function scanLibraryLocked(PDO $db, string $root): array
$walk = function (string $dir, ?int $parentId, string $relBase, int $depth) $walk = function (string $dir, ?int $parentId, string $relBase, int $depth)
use (&$walk, $db, $upsertFolder, $upsertVideo, $upsertQuiz, use (&$walk, $db, $upsertFolder, $upsertVideo, $upsertQuiz,
&$stats, &$seenFolderIds, &$seenVideoIds, &$seenQuizIds): void { &$stats, &$seenFolderIds, &$seenVideoIds, &$seenQuizIds, &$markers): void {
$entries = scandir($dir) ?: []; $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] !== '.')); $entries = array_values(array_filter($entries, fn($e) => $e[0] !== '.'));
usort($entries, 'strnatcasecmp'); // "Video2" before "Video10" usort($entries, 'strnatcasecmp'); // "Video2" before "Video10"
@@ -258,6 +434,14 @@ function scanLibraryLocked(PDO $db, string $root): array
$stats['missing_videos'] = $flagMissing('videos', $seenVideoIds); $stats['missing_videos'] = $flagMissing('videos', $seenVideoIds);
$stats['missing_quizzes'] = $flagMissing('quizzes', $seenQuizIds); $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(); $db->commit();
} catch (Throwable $e) { } catch (Throwable $e) {
$db->rollBack(); $db->rollBack();
@@ -341,18 +525,78 @@ function folderRollup(int $userId, string $folderPath): array
$stmt->execute($args); $stmt->execute($args);
$quizzes = $stmt->fetch(); $quizzes = $stmt->fetch();
$total = (int)$videos['total'] + (int)$quizzes['total']; return composeRollup($videos, $quizzes);
$credit = $videos['credit'] + $quizzes['credit']; }
return [ /**
'videos' => (int)$videos['total'], * folderRollup() for every child of one folder, in two queries instead of two
'quizzes' => (int)$quizzes['total'], * per child — a course with thirty sections, or a category with thirty courses,
'duration' => (float)$videos['duration'], * is one page either way.
'bytes' => (int)$videos['bytes'], *
'total' => $total, * Keyed by child folder *name*, which the filesystem already guarantees is
'completed' => (int)$videos['completed'] + (int)$quizzes['completed'], * unique inside a directory. Children with nothing under them are absent.
'percent' => $total > 0 ? (int)round(100 * $credit / $total) : 0, */
]; 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;
} }
/** /**
@@ -366,12 +610,112 @@ function folderProgress(int $userId, string $folderPath): array
return ['total' => $r['total'], 'completed' => $r['completed'], 'percent' => $r['percent']]; return ['total' => $r['total'], 'completed' => $r['completed'], 'percent' => $r['percent']];
} }
/** A course (or section) as the library screens show it: contents + progress. */ /** An empty rollup, for a folder that holds nothing (yet). */
function courseSummary(int $userId, array $folder): array function emptyRollup(): array
{ {
$r = folderRollup($userId, $folder['path']); return ['videos' => 0, 'quizzes' => 0, 'duration' => 0.0, 'bytes' => 0,
$percent = $r['percent']; '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 [ return [
'id' => (int)$folder['id'], 'id' => (int)$folder['id'],
'name' => $folder['name'], 'name' => $folder['name'],
@@ -382,16 +726,85 @@ function courseSummary(int $userId, array $folder): array
'bytes' => $r['bytes'], 'bytes' => $r['bytes'],
'added_at' => $folder['created_at'] ?? null, 'added_at' => $folder['created_at'] ?? null,
'progress' => ['total' => $r['total'], 'completed' => $r['completed'], 'percent' => $percent], 'progress' => ['total' => $r['total'], 'completed' => $r['completed'], 'percent' => $percent],
// The shelves the library groups by — the app has no course categories, // The shelves the library groups by — courses carry no categories of
// so progress is what separates one shelf from another. // their own, so progress is what separates one shelf from another.
'state' => $percent >= 100 ? 'finished' : ($percent > 0 ? 'progress' : 'fresh'), 'state' => $percent >= 100 ? 'finished' : ($percent > 0 ? 'progress' : 'fresh'),
]; ];
} }
/** Every top-level folder as a course card. */ /** 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 function courseCards(int $userId): array
{ {
return array_map(fn($f) => courseSummary($userId, $f), childFolders(null)); $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. */ /** Headline numbers for the home screen, computed from the course cards. */
@@ -439,19 +852,20 @@ function userStats(int $userId, array $courses): array
} }
/** Courses the scanner indexed most recently — the "found in the last scan" strip. */ /** Courses the scanner indexed most recently — the "found in the last scan" strip. */
function recentCourses(int $userId, int $limit = 3): array function recentCourses(int $userId, int $limit = 3, ?array $rollups = null): array
{ {
$rollups ??= courseRollups($userId);
$stmt = db()->prepare( $stmt = db()->prepare(
'SELECT * FROM folders 'SELECT * FROM folders
WHERE parent_id IS NULL AND is_missing = 0 WHERE is_course = 1 AND is_missing = 0
ORDER BY created_at DESC, id DESC ORDER BY created_at DESC, id DESC
LIMIT ' . (int)$limit LIMIT ' . (int)$limit
); );
$stmt->execute(); $stmt->execute();
$week = time() - 7 * 86400; $week = time() - 7 * 86400;
return array_map(function ($f) use ($userId, $week) { return array_map(function ($f) use ($rollups, $week) {
$c = courseSummary($userId, $f); $c = summaryFrom($f, $rollups[(int)$f['id']] ?? emptyRollup());
$c['is_new'] = $f['created_at'] !== null && strtotime($f['created_at']) >= $week; $c['is_new'] = $f['created_at'] !== null && strtotime($f['created_at']) >= $week;
return $c; return $c;
}, $stmt->fetchAll()); }, $stmt->fetchAll());
@@ -461,15 +875,30 @@ function recentCourses(int $userId, int $limit = 3): array
function libraryTotals(): array function libraryTotals(): array
{ {
$row = db()->query( $row = db()->query(
'SELECT (SELECT COUNT(*) FROM folders WHERE parent_id IS NULL AND is_missing = 0) AS courses, '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 videos WHERE is_missing = 0) AS videos,
(SELECT COUNT(*) FROM quizzes WHERE is_missing = 0) AS quizzes, (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(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' (SELECT COALESCE(SUM(duration_seconds), 0) FROM videos WHERE is_missing = 0) AS duration'
)->fetch(); )->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 [ return [
'courses' => (int)$row['courses'], 'courses' => (int)$row['courses'],
'categories' => $categories,
'videos' => (int)$row['videos'], 'videos' => (int)$row['videos'],
'quizzes' => (int)$row['quizzes'], 'quizzes' => (int)$row['quizzes'],
'bytes' => (int)$row['bytes'], 'bytes' => (int)$row['bytes'],
@@ -594,6 +1023,11 @@ function resetProgress(int $userId, string $scope, int $id = 0): array
/* --------------------------------------------------------------------------- /* ---------------------------------------------------------------------------
* Search — substring match on names (and on the relative path, so * Search — substring match on names (and on the relative path, so
* "Course 1/Intro" finds everything under it). * "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 function searchFolders(string $term, int $limit = 25): array
@@ -601,7 +1035,8 @@ function searchFolders(string $term, int $limit = 25): array
$like = likeContains($term); $like = likeContains($term);
$stmt = db()->prepare( $stmt = db()->prepare(
'SELECT * FROM folders 'SELECT * FROM folders
WHERE is_missing = 0 AND (name LIKE ? OR path LIKE ?) WHERE is_missing = 0
AND (name LIKE ? OR path COLLATE utf8mb4_unicode_ci LIKE ?)
ORDER BY depth, path ORDER BY depth, path
LIMIT ' . (int)$limit LIMIT ' . (int)$limit
); );
@@ -618,7 +1053,8 @@ function searchVideos(int $userId, string $term, int $limit = 50): array
FROM videos v FROM videos v
JOIN folders f ON f.id = v.folder_id JOIN folders f ON f.id = v.folder_id
LEFT JOIN video_progress vp ON vp.video_id = v.id AND vp.user_id = ? LEFT JOIN video_progress vp ON vp.video_id = v.id AND vp.user_id = ?
WHERE v.is_missing = 0 AND f.is_missing = 0 AND (v.name LIKE ? OR v.path LIKE ?) 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 ORDER BY f.path, v.sort_order, v.name
LIMIT ' . (int)$limit LIMIT ' . (int)$limit
); );
@@ -635,7 +1071,8 @@ function searchQuizzes(int $userId, string $term, int $limit = 25): array
FROM quizzes q FROM quizzes q
JOIN folders f ON f.id = q.folder_id JOIN folders f ON f.id = q.folder_id
LEFT JOIN quiz_progress qp ON qp.quiz_id = q.id AND qp.user_id = ? LEFT JOIN quiz_progress qp ON qp.quiz_id = q.id AND qp.user_id = ?
WHERE q.is_missing = 0 AND f.is_missing = 0 AND (q.name LIKE ? OR q.path LIKE ?) 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 ORDER BY f.path, q.sort_order, q.name
LIMIT ' . (int)$limit LIMIT ' . (int)$limit
); );
@@ -807,11 +1244,32 @@ function continueWatching(int $userId, int $limit = 6): array
return $stmt->fetchAll(); return $stmt->fetchAll();
} }
/** The top-level course a folder belongs to (itself, if it's already top-level). */ /**
* 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 function courseIdOf(array $folder): int
{ {
$crumbs = folderBreadcrumbs($folder); return $folder['course_id'] !== null ? (int)$folder['course_id'] : (int)$folder['id'];
return (int)$crumbs[0]['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 function formatDuration(?float $seconds): string
+2 -1
View File
@@ -9,6 +9,7 @@ if (PHP_SAPI !== 'cli') {
} }
$stats = scanLibrary(); $stats = scanLibrary();
echo "Scan complete: {$stats['folders']} folders, {$stats['videos']} videos, {$stats['quizzes']} quizzes" echo "Scan complete: {$stats['courses']} courses in {$stats['folders']} folders,"
. " {$stats['videos']} videos, {$stats['quizzes']} quizzes"
. " ({$stats['missing_folders']} folders / {$stats['missing_videos']} videos" . " ({$stats['missing_folders']} folders / {$stats['missing_videos']} videos"
. " / {$stats['missing_quizzes']} quizzes flagged missing)\n"; . " / {$stats['missing_quizzes']} quizzes flagged missing)\n";
+21 -4
View File
@@ -31,18 +31,35 @@ CREATE TABLE IF NOT EXISTS app_meta (
-- One row per directory found under VIDEOS_ROOT. -- One row per directory found under VIDEOS_ROOT.
-- `path` is the relative path from VIDEOS_ROOT, e.g. "Course 1/Intro". -- `path` is the relative path from VIDEOS_ROOT, e.g. "Course 1/Intro".
--
-- Paths are utf8mb4_bin: they identify a file on a case-sensitive filesystem,
-- so "CCNP Notes" and "CCNP NOtes" are two directories, not one. Under the
-- table's default (case-insensitive) collation the UNIQUE key merges them and
-- the second tree silently overwrites the first.
--
-- `is_course` marks the folder the library shows as a course. Course roots sit
-- at whatever depth the material happens to use — a top-level folder may be a
-- category (Networking/Cisco/<course>) or a course in its own right — so the
-- scanner works it out per branch rather than assuming a fixed depth; see
-- markCourseRoots(). `course_id` is the nearest course at-or-above the folder
-- (NULL for the categories above every course), which is what makes a course's
-- rollup one grouped query instead of one query per course.
CREATE TABLE IF NOT EXISTS folders ( CREATE TABLE IF NOT EXISTS folders (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
parent_id INT UNSIGNED NULL, parent_id INT UNSIGNED NULL,
name VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL,
path VARCHAR(700) NOT NULL UNIQUE, path VARCHAR(700) COLLATE utf8mb4_bin NOT NULL UNIQUE,
depth INT UNSIGNED NOT NULL DEFAULT 0, depth INT UNSIGNED NOT NULL DEFAULT 0,
sort_order INT UNSIGNED NOT NULL DEFAULT 0, sort_order INT UNSIGNED NOT NULL DEFAULT 0,
is_course TINYINT(1) NOT NULL DEFAULT 0,
course_id INT UNSIGNED NULL,
is_missing TINYINT(1) NOT NULL DEFAULT 0, is_missing TINYINT(1) NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_folders_parent FOREIGN KEY (parent_id) CONSTRAINT fk_folders_parent FOREIGN KEY (parent_id)
REFERENCES folders(id) ON DELETE CASCADE, REFERENCES folders(id) ON DELETE CASCADE,
INDEX idx_folders_parent (parent_id) INDEX idx_folders_parent (parent_id),
INDEX idx_folders_course (course_id),
INDEX idx_folders_is_course (is_course, is_missing)
) ENGINE=InnoDB; ) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS videos ( CREATE TABLE IF NOT EXISTS videos (
@@ -50,7 +67,7 @@ CREATE TABLE IF NOT EXISTS videos (
folder_id INT UNSIGNED NOT NULL, folder_id INT UNSIGNED NOT NULL,
name VARCHAR(255) NOT NULL, -- display name, filename without .mp4 name VARCHAR(255) NOT NULL, -- display name, filename without .mp4
filename VARCHAR(255) NOT NULL, filename VARCHAR(255) NOT NULL,
path VARCHAR(700) NOT NULL UNIQUE, -- relative path incl. filename path VARCHAR(700) COLLATE utf8mb4_bin NOT NULL UNIQUE, -- relative path incl. filename
size_bytes BIGINT UNSIGNED NOT NULL DEFAULT 0, size_bytes BIGINT UNSIGNED NOT NULL DEFAULT 0,
duration_seconds FLOAT NULL, -- filled in by the player on first load duration_seconds FLOAT NULL, -- filled in by the player on first load
sort_order INT UNSIGNED NOT NULL DEFAULT 0, sort_order INT UNSIGNED NOT NULL DEFAULT 0,
@@ -69,7 +86,7 @@ CREATE TABLE IF NOT EXISTS quizzes (
folder_id INT UNSIGNED NOT NULL, folder_id INT UNSIGNED NOT NULL,
name VARCHAR(255) NOT NULL, -- filename without the _quiz_solved.txt suffix name VARCHAR(255) NOT NULL, -- filename without the _quiz_solved.txt suffix
filename VARCHAR(255) NOT NULL, filename VARCHAR(255) NOT NULL,
path VARCHAR(700) NOT NULL UNIQUE, path VARCHAR(700) COLLATE utf8mb4_bin NOT NULL UNIQUE,
num_questions INT UNSIGNED NOT NULL DEFAULT 0, num_questions INT UNSIGNED NOT NULL DEFAULT 0,
sort_order INT UNSIGNED NOT NULL DEFAULT 0, sort_order INT UNSIGNED NOT NULL DEFAULT 0,
is_missing TINYINT(1) NOT NULL DEFAULT 0, is_missing TINYINT(1) NOT NULL DEFAULT 0,