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. Claude-Session: https://claude.ai/code/session_01FfMPJK7TQV18JhFv6yTKev
This commit is contained in:
@@ -14,8 +14,8 @@ views switch without page reloads and browser back/forward still work:
|
||||
| Route | Screen |
|
||||
| --- | --- |
|
||||
| `#/` | home — resume card, stats, in progress, last scan |
|
||||
| `#/library`, `#/library/<shelf>` | every course, as shelves or a filtered catalog |
|
||||
| `#/folder/N` | a course or section: sections + content |
|
||||
| `#/library`, `#/library/<shelf>` | categories, shelves or a flat catalog of every course |
|
||||
| `#/folder/N` | a category (its courses), or a course/section (its contents) |
|
||||
| `#/watch/N` | player, playlist, section quizzes |
|
||||
| `#/quiz/N` | take and grade a quiz |
|
||||
| `#/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.
|
||||
|
||||
- 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.
|
||||
- **Progress tracking at every level** — each folder shows completed/total and a
|
||||
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`).
|
||||
- **Subtitles**: a video with a sibling `.srt` file (`Lesson.mp4` +
|
||||
`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
|
||||
entire account.
|
||||
- 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
|
||||
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
|
||||
(no path traversal).
|
||||
|
||||
## 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/
|
||||
├── Course 1/
|
||||
│ ├── Intro/
|
||||
│ │ ├── Video1.mp4
|
||||
│ │ └── Video2.mp4
|
||||
│ └── Module 2/
|
||||
│ └── Advanced/
|
||||
│ └── lesson.mp4
|
||||
└── Course 2/
|
||||
└── ...
|
||||
├── Cloud/ category
|
||||
│ └── AWS Networking/ course (children are numbered)
|
||||
│ ├── 01 - Welcome/
|
||||
│ │ └── Intro.mp4
|
||||
│ └── 02 - Creating a VPC/
|
||||
│ └── Subnets.mp4
|
||||
├── Firewalls/ category
|
||||
│ └── 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
|
||||
|
||||
@@ -106,7 +174,9 @@ Top-level folders become courses on the home page.
|
||||
|
||||
Rescan whenever you add/remove files. Re-scans are idempotent: existing
|
||||
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.
|
||||
|
||||
@@ -235,6 +305,10 @@ next scan creates it.
|
||||
Completion is sticky — rewatching doesn't un-complete it.
|
||||
- Folder progress is computed over the folder's entire subtree using a path
|
||||
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
|
||||
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.
|
||||
|
||||
+63
-30
@@ -1,8 +1,10 @@
|
||||
<?php
|
||||
// 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=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
|
||||
// (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=search&q=text -> matching folders, videos, quizzes
|
||||
// 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
|
||||
{
|
||||
$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';
|
||||
|
||||
switch ($view) {
|
||||
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);
|
||||
$counts = ['progress' => 0, 'finished' => 0, 'fresh' => 0];
|
||||
foreach ($courses as $c) {
|
||||
@@ -97,6 +99,7 @@ switch ($view) {
|
||||
break;
|
||||
|
||||
case 'home':
|
||||
$rollups = courseRollups($userId);
|
||||
$courses = courseCards($userId);
|
||||
$resume = continueWatching($userId, 1);
|
||||
$resumeCourseId = null;
|
||||
@@ -123,17 +126,23 @@ switch ($view) {
|
||||
'duration' => $r['duration_seconds'] !== null ? (float)$r['duration_seconds'] : null,
|
||||
], continueWatching($userId)),
|
||||
'in_progress' => array_values(array_filter($courses, fn($c) => $c['state'] === 'progress')),
|
||||
'recent' => recentCourses($userId),
|
||||
'courses' => $courses,
|
||||
'recent' => recentCourses($userId, 3, $rollups),
|
||||
]);
|
||||
break;
|
||||
|
||||
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);
|
||||
// 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([
|
||||
'courses' => $courses,
|
||||
'shelves' => shelveCourses($courses),
|
||||
'totals' => libraryTotals(),
|
||||
'courses' => $courses,
|
||||
'categories' => categoryCards($userId, null, $rollups),
|
||||
'top_courses' => courseCardsUnder($userId, null, $rollups),
|
||||
'totals' => libraryTotals(),
|
||||
]);
|
||||
break;
|
||||
|
||||
@@ -146,16 +155,33 @@ switch ($view) {
|
||||
}
|
||||
$crumbs = crumbList($folder);
|
||||
$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([
|
||||
'folder' => ['id' => (int)$folder['id'], 'name' => $folder['name'],
|
||||
'path' => $folder['path']],
|
||||
'course_id' => $crumbs ? $crumbs[0]['id'] : (int)$folder['id'],
|
||||
'path' => $folder['path'], 'kind' => $kind],
|
||||
'kind' => $kind,
|
||||
'course_id' => courseIdOf($folder),
|
||||
'breadcrumbs' => $crumbs,
|
||||
'rollup' => $rollup,
|
||||
'progress' => ['total' => $rollup['total'], 'completed' => $rollup['completed'],
|
||||
'percent' => $rollup['percent']],
|
||||
'subfolders' => array_map(fn($f) => courseSummary($userId, $f),
|
||||
childFolders((int)$folder['id'])),
|
||||
'categories' => $categories,
|
||||
'courses' => $courses,
|
||||
'subfolders' => $subfolders,
|
||||
'videos' => array_map('videoSummary',
|
||||
folderVideos((int)$folder['id'], $userId)),
|
||||
'quizzes' => array_map('quizSummary',
|
||||
@@ -197,7 +223,7 @@ switch ($view) {
|
||||
'duration' => $video['duration_seconds'] !== null
|
||||
? (float)$video['duration_seconds'] : null],
|
||||
'folder' => ['id' => (int)$folder['id'], 'name' => $folder['name']],
|
||||
'course_id' => $crumbs ? $crumbs[0]['id'] : (int)$folder['id'],
|
||||
'course_id' => courseIdOf($folder),
|
||||
'breadcrumbs' => $crumbs,
|
||||
'playlist' => array_map('videoSummary', $playlist),
|
||||
'quizzes' => array_map('quizSummary', folderQuizzes((int)$folder['id'], $userId)),
|
||||
@@ -221,8 +247,10 @@ switch ($view) {
|
||||
|
||||
echo json_encode([
|
||||
'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(
|
||||
fn($f) => courseSummary($userId, $f),
|
||||
fn($f) => courseSummary($userId, $f) + ['kind' => folderKind($f)],
|
||||
searchFolders($q)
|
||||
),
|
||||
'videos' => array_map(
|
||||
@@ -238,6 +266,10 @@ switch ($view) {
|
||||
|
||||
case 'settings':
|
||||
$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));
|
||||
echo json_encode([
|
||||
'root' => rtrim(VIDEOS_ROOT, '/'),
|
||||
@@ -249,6 +281,7 @@ switch ($view) {
|
||||
'username' => currentUsername(),
|
||||
'progress_courses' => count($withSome),
|
||||
'courses' => count($courses),
|
||||
'shelf_counts' => $counts,
|
||||
]);
|
||||
break;
|
||||
|
||||
|
||||
+166
-26
@@ -20,6 +20,7 @@
|
||||
csrf: null, // session token, echoed back on every POST
|
||||
meta: null, // totals / shelves / last scan, for the chrome
|
||||
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,
|
||||
};
|
||||
let cleanupView = null;
|
||||
@@ -33,6 +34,8 @@
|
||||
|
||||
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' };
|
||||
// What a folder is, for search results: the three now mean different things.
|
||||
const KIND_NAME = { category: 'Category', course: 'Course', section: 'Section' };
|
||||
|
||||
const ICONS = {
|
||||
home: '<path d="M3 10.5 12 3l9 7.5"></path><path d="M5 9.5V21h14V9.5"></path>',
|
||||
@@ -127,7 +130,7 @@
|
||||
|
||||
/* ---------- preferences ---------- */
|
||||
|
||||
const PREF_DEFAULTS = { view: 'grid', layout: 'shelves', autoplay: true };
|
||||
const PREF_DEFAULTS = { view: 'grid', layout: 'categories', autoplay: true };
|
||||
|
||||
function pref(key) {
|
||||
try {
|
||||
@@ -352,6 +355,46 @@
|
||||
</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) {
|
||||
const hue = hueOf(c.id);
|
||||
return `<div class="row">
|
||||
@@ -527,7 +570,8 @@
|
||||
const left = resume.duration ? Math.max(0, resume.duration - resume.position) : 0;
|
||||
const hue = hueOf(resume.course_id ?? resume.id);
|
||||
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">
|
||||
<span class="hero-eyebrow">Continue watching</span>
|
||||
<div class="hero-title">${esc(resume.name)}</div>
|
||||
@@ -607,11 +651,38 @@
|
||||
|
||||
/* ---------- 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) {
|
||||
if (filter && !(filter in SHELF_NAME)) filter = null; // #/library/whatever → every course
|
||||
spinner();
|
||||
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 mode = pref('view');
|
||||
const active = filter || 'all';
|
||||
@@ -629,17 +700,19 @@
|
||||
<p>Add course folders to the videos directory, then scan.</p>
|
||||
<button class="btn btn-accent" data-scan>Scan library</button>
|
||||
</div>`;
|
||||
} else if (layout === 'shelves') {
|
||||
body = data.shelves.map((s) => `
|
||||
<div style="margin-bottom:34px">
|
||||
<div class="shelf-head">
|
||||
<span class="swatch" style="--c:${SHELF_HUE[s.key]}"></span>
|
||||
<h2>${esc(s.name)}</h2>
|
||||
<span class="mono">${s.courses.length} course${s.courses.length === 1 ? '' : 's'}</span>
|
||||
<div class="rule"></div>
|
||||
</div>
|
||||
${courseList(s.courses, mode)}
|
||||
</div>`).join('');
|
||||
} else if (layout === 'categories' && data.categories.length) {
|
||||
// The folders above the courses are the library's own shape — a few dozen
|
||||
// cards to browse instead of a wall of every course at once.
|
||||
body = categoryList(data.categories, mode)
|
||||
+ (data.top_courses.length ? `
|
||||
<div class="shelf-head" style="margin-top:34px">
|
||||
<h2>Courses</h2>
|
||||
<span class="mono">${data.top_courses.length}</span>
|
||||
<div class="rule"></div>
|
||||
</div>
|
||||
${courseList(data.top_courses, mode)}` : '');
|
||||
} else if (layout === 'shelves' || (layout === 'categories' && !data.categories.length)) {
|
||||
body = shelveCourses(data.courses).map((sh) => shelfBlock(sh, mode)).join('');
|
||||
} else {
|
||||
body = `<div class="chips">${chips}</div>` + (shown.length
|
||||
? courseList(shown, mode)
|
||||
@@ -649,19 +722,27 @@
|
||||
}
|
||||
|
||||
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 = `
|
||||
<div>
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h1>Library</h1>
|
||||
<p class="page-sub">${data.courses.length} course${data.courses.length === 1 ? '' : 's'} ·
|
||||
${totalVideos} video${totalVideos === 1 ? '' : 's'} on this machine</p>
|
||||
<p class="page-sub">${sub}</p>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:10px">
|
||||
<div class="seg">
|
||||
<button class="${layout === 'shelves' ? 'is-on' : ''}" data-layout="shelves">Shelves</button>
|
||||
<button class="${layout === 'catalog' ? 'is-on' : ''}" data-layout="catalog">Catalog</button>
|
||||
${data.categories.length ? layoutBtn('categories', 'Categories') : ''}
|
||||
${layoutBtn('shelves', 'Shelves')}
|
||||
${layoutBtn('catalog', 'Catalog')}
|
||||
</div>
|
||||
${viewToggle()}
|
||||
</div>
|
||||
@@ -678,27 +759,50 @@
|
||||
const data = await load(`api/library.php?view=folder&id=${id}`);
|
||||
const mode = pref('view');
|
||||
const hue = hueOf(data.course_id);
|
||||
const isCategory = data.kind === 'category';
|
||||
|
||||
const sections = data.subfolders.length ? `
|
||||
<div class="section-head">
|
||||
<h2 class="h-section">Sections</h2>
|
||||
${viewToggle()}
|
||||
</div>
|
||||
<div style="margin-bottom:32px">${courseList(data.subfolders, mode)}</div>` : '';
|
||||
// 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">
|
||||
<h2 class="h-section">Sections</h2>
|
||||
${viewToggle()}
|
||||
</div>
|
||||
<div style="margin-bottom:32px">${courseList(data.subfolders, mode)}</div>` : '');
|
||||
|
||||
const items = [
|
||||
...data.videos.map((v) => ({ ...v, kind: 'video' })),
|
||||
...data.quizzes.map((q) => ({ ...q, kind: 'quiz' })),
|
||||
].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 ? `
|
||||
<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>`
|
||||
: (!data.subfolders.length
|
||||
: (!hasChildren
|
||||
? `<div class="empty"><h2>This folder is empty</h2>
|
||||
<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 = [
|
||||
courseCount ? `${courseCount} course${courseCount === 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'}` : '',
|
||||
fmtLong(data.rollup.duration),
|
||||
@@ -710,6 +814,7 @@
|
||||
${crumbs(data.breadcrumbs)}
|
||||
<div class="page-head">
|
||||
<div>
|
||||
${isCategory ? '<span class="eyebrow eyebrow-sm">Category</span>' : ''}
|
||||
<h1>${esc(data.folder.name)}</h1>
|
||||
<p class="page-sub">${esc(meta || 'Empty folder')}</p>
|
||||
</div>
|
||||
@@ -758,7 +863,7 @@
|
||||
<span class="initial initial-lg" style="--c:${hueOf(c.id)}">${esc(initialOf(c.name))}</span>
|
||||
<div class="row-main">
|
||||
<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 class="row-bar" style="flex:none;width:120px">
|
||||
${bar(c.progress.percent, hueOf(c.id))}
|
||||
@@ -1034,6 +1139,12 @@
|
||||
</div>
|
||||
<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>
|
||||
<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>
|
||||
<span class="mono">${esc(fmtLong(data.totals.duration) || '—')}</span></div>
|
||||
<div class="fact-row"><span>A video counts as watched at</span>
|
||||
@@ -1109,6 +1220,18 @@
|
||||
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 saveTimer = null;
|
||||
|
||||
@@ -1149,6 +1272,10 @@
|
||||
save(true); // the final position always lands, even within 1 s of the last heartbeat
|
||||
if (data.next_id && pref('autoplay') !== false) {
|
||||
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);
|
||||
}
|
||||
});
|
||||
@@ -1160,6 +1287,12 @@
|
||||
return function cleanup() {
|
||||
saveBeacon();
|
||||
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);
|
||||
document.removeEventListener('visibilitychange', onHide);
|
||||
window.removeEventListener('pagehide', saveBeacon);
|
||||
@@ -1218,7 +1351,7 @@
|
||||
try {
|
||||
const res = await api('api/scan.php', { method: 'POST' });
|
||||
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();
|
||||
route();
|
||||
} catch (err) {
|
||||
@@ -1336,6 +1469,13 @@
|
||||
/* ---------- delegated actions ---------- */
|
||||
|
||||
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]');
|
||||
if (scan) { e.preventDefault(); runScan(); return; }
|
||||
|
||||
|
||||
+12
-1
@@ -734,7 +734,18 @@ button, input { font-family: inherit; font-size: inherit; }
|
||||
text-decoration: none;
|
||||
}
|
||||
.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 {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
+494
-36
@@ -133,6 +133,169 @@ function parseQuizFile(string $absPath): array
|
||||
* (keyed by relative path so IDs stay stable across rescans), then flags
|
||||
* rows whose files disappeared as is_missing.
|
||||
* ------------------------------------------------------------------------- */
|
||||
/**
|
||||
* Bring an older install's tables up to date. DDL commits implicitly in
|
||||
* MariaDB, so this has to run before the scan opens its transaction — never
|
||||
* from inside one.
|
||||
*/
|
||||
function migrateLibrarySchema(PDO $db): void
|
||||
{
|
||||
// Where a course starts is worked out per branch at scan time; see
|
||||
// markCourseRoots(). Both columns are rewritten by every scan.
|
||||
$db->exec(
|
||||
'ALTER TABLE folders
|
||||
ADD COLUMN IF NOT EXISTS is_course TINYINT(1) NOT NULL DEFAULT 0 AFTER sort_order,
|
||||
ADD COLUMN IF NOT EXISTS course_id INT UNSIGNED NULL AFTER is_course'
|
||||
);
|
||||
$db->exec('ALTER TABLE folders ADD INDEX IF NOT EXISTS idx_folders_course (course_id)');
|
||||
$db->exec('ALTER TABLE folders ADD INDEX IF NOT EXISTS idx_folders_is_course (is_course, is_missing)');
|
||||
|
||||
// A path names a file on a case-sensitive filesystem, so "CCNP Notes" and
|
||||
// "CCNP NOtes" are two directories. Under the schema's default
|
||||
// (case-insensitive) collation the UNIQUE key on `path` treats them as one
|
||||
// and the second tree quietly overwrites the first — its videos never get
|
||||
// indexed. Tightening the collation can only remove collisions, never
|
||||
// create them, so it is safe to run against existing rows.
|
||||
foreach (['folders', 'videos', 'quizzes'] as $table) {
|
||||
$col = $db->query("SHOW FULL COLUMNS FROM `$table` WHERE Field = 'path'")->fetch();
|
||||
if ($col && $col['Collation'] !== 'utf8mb4_bin') {
|
||||
$db->exec("ALTER TABLE `$table` MODIFY path VARCHAR(700) COLLATE utf8mb4_bin NOT NULL");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Does this folder name read as a chapter rather than as a title of its own?
|
||||
* "01_Welcome", "02 - Creating a VPC", "3. Introduction", "Module 2", "Week 1".
|
||||
* This is the one signal that separates a course from a category: a folder
|
||||
* whose children are numbered is the thing those numbers belong to.
|
||||
*/
|
||||
function looksOrdered(string $name): bool
|
||||
{
|
||||
return (bool)preg_match(
|
||||
'/^\s*\d{1,3}\s*[-._)\]]'
|
||||
. '|^\s*\d{1,3}\s+\S'
|
||||
. '|^\s*(?:module|section|part|week|chapter|day|lesson|lab|unit|vol)[\s_-]*\d+/i',
|
||||
$name
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Work out which folders are courses and stamp `is_course` / `course_id`.
|
||||
*
|
||||
* Course roots do not sit at a fixed depth. One library can hold
|
||||
* "System Admin/<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, '/');
|
||||
@@ -158,11 +321,14 @@ function scanLibrary(): array
|
||||
/** The scan proper — only ever called by scanLibrary() while it holds the lock. */
|
||||
function scanLibraryLocked(PDO $db, string $root): array
|
||||
{
|
||||
migrateLibrarySchema($db); // DDL: must happen before the transaction opens
|
||||
|
||||
$stats = ['folders' => 0, 'videos' => 0, 'quizzes' => 0,
|
||||
'missing_folders' => 0, 'missing_videos' => 0, 'missing_quizzes' => 0];
|
||||
$seenFolderIds = [];
|
||||
$seenVideoIds = [];
|
||||
$seenQuizIds = [];
|
||||
$markers = []; // folder path => 'course' | 'category', from marker files
|
||||
|
||||
$upsertFolder = $db->prepare(
|
||||
'INSERT INTO folders (parent_id, name, path, depth, sort_order, is_missing)
|
||||
@@ -197,9 +363,19 @@ function scanLibraryLocked(PDO $db, string $root): array
|
||||
|
||||
$walk = function (string $dir, ?int $parentId, string $relBase, int $depth)
|
||||
use (&$walk, $db, $upsertFolder, $upsertVideo, $upsertQuiz,
|
||||
&$stats, &$seenFolderIds, &$seenVideoIds, &$seenQuizIds): void {
|
||||
&$stats, &$seenFolderIds, &$seenVideoIds, &$seenQuizIds, &$markers): void {
|
||||
|
||||
$entries = scandir($dir) ?: [];
|
||||
// An empty ".course" / ".category" file overrides what markCourseRoots()
|
||||
// would have guessed for this folder — the escape hatch for material the
|
||||
// chapter-name heuristic reads wrong. Dot files are skipped below, so
|
||||
// they are looked for before the filter.
|
||||
foreach (['course', 'category'] as $marker) {
|
||||
if ($relBase !== '' && is_file($dir . '/.' . $marker)) {
|
||||
$markers[$relBase] = $marker;
|
||||
break; // ".course" wins if somebody wrote both
|
||||
}
|
||||
}
|
||||
$entries = array_values(array_filter($entries, fn($e) => $e[0] !== '.'));
|
||||
usort($entries, 'strnatcasecmp'); // "Video2" before "Video10"
|
||||
|
||||
@@ -258,6 +434,14 @@ function scanLibraryLocked(PDO $db, string $root): array
|
||||
$stats['missing_videos'] = $flagMissing('videos', $seenVideoIds);
|
||||
$stats['missing_quizzes'] = $flagMissing('quizzes', $seenQuizIds);
|
||||
|
||||
// Which folders are courses can only be decided once the whole tree and
|
||||
// its contents are known, so it runs last — inside the same transaction,
|
||||
// so no request ever sees a library with no courses in it.
|
||||
markCourseRoots($db, $markers);
|
||||
$stats['courses'] = (int)$db->query(
|
||||
'SELECT COUNT(*) FROM folders WHERE is_course = 1 AND is_missing = 0'
|
||||
)->fetchColumn();
|
||||
|
||||
$db->commit();
|
||||
} catch (Throwable $e) {
|
||||
$db->rollBack();
|
||||
@@ -341,18 +525,78 @@ function folderRollup(int $userId, string $folderPath): array
|
||||
$stmt->execute($args);
|
||||
$quizzes = $stmt->fetch();
|
||||
|
||||
$total = (int)$videos['total'] + (int)$quizzes['total'];
|
||||
$credit = $videos['credit'] + $quizzes['credit'];
|
||||
return composeRollup($videos, $quizzes);
|
||||
}
|
||||
|
||||
return [
|
||||
'videos' => (int)$videos['total'],
|
||||
'quizzes' => (int)$quizzes['total'],
|
||||
'duration' => (float)$videos['duration'],
|
||||
'bytes' => (int)$videos['bytes'],
|
||||
'total' => $total,
|
||||
'completed' => (int)$videos['completed'] + (int)$quizzes['completed'],
|
||||
'percent' => $total > 0 ? (int)round(100 * $credit / $total) : 0,
|
||||
];
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -366,12 +610,112 @@ function folderProgress(int $userId, string $folderPath): array
|
||||
return ['total' => $r['total'], 'completed' => $r['completed'], 'percent' => $r['percent']];
|
||||
}
|
||||
|
||||
/** A course (or section) as the library screens show it: contents + progress. */
|
||||
function courseSummary(int $userId, array $folder): array
|
||||
/** An empty rollup, for a folder that holds nothing (yet). */
|
||||
function emptyRollup(): array
|
||||
{
|
||||
$r = folderRollup($userId, $folder['path']);
|
||||
$percent = $r['percent'];
|
||||
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'],
|
||||
@@ -382,16 +726,85 @@ function courseSummary(int $userId, array $folder): array
|
||||
'bytes' => $r['bytes'],
|
||||
'added_at' => $folder['created_at'] ?? null,
|
||||
'progress' => ['total' => $r['total'], 'completed' => $r['completed'], 'percent' => $percent],
|
||||
// The shelves the library groups by — the app has no course categories,
|
||||
// so progress is what separates one shelf from another.
|
||||
// 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'),
|
||||
];
|
||||
}
|
||||
|
||||
/** 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
|
||||
{
|
||||
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. */
|
||||
@@ -439,19 +852,20 @@ function userStats(int $userId, array $courses): array
|
||||
}
|
||||
|
||||
/** 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(
|
||||
'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
|
||||
LIMIT ' . (int)$limit
|
||||
);
|
||||
$stmt->execute();
|
||||
|
||||
$week = time() - 7 * 86400;
|
||||
return array_map(function ($f) use ($userId, $week) {
|
||||
$c = courseSummary($userId, $f);
|
||||
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());
|
||||
@@ -461,19 +875,34 @@ function recentCourses(int $userId, int $limit = 3): array
|
||||
function libraryTotals(): array
|
||||
{
|
||||
$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 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'],
|
||||
'videos' => (int)$row['videos'],
|
||||
'quizzes' => (int)$row['quizzes'],
|
||||
'bytes' => (int)$row['bytes'],
|
||||
'duration' => (float)$row['duration'],
|
||||
'courses' => (int)$row['courses'],
|
||||
'categories' => $categories,
|
||||
'videos' => (int)$row['videos'],
|
||||
'quizzes' => (int)$row['quizzes'],
|
||||
'bytes' => (int)$row['bytes'],
|
||||
'duration' => (float)$row['duration'],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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
|
||||
* "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
|
||||
@@ -601,7 +1035,8 @@ function searchFolders(string $term, int $limit = 25): array
|
||||
$like = likeContains($term);
|
||||
$stmt = db()->prepare(
|
||||
'SELECT * FROM folders
|
||||
WHERE is_missing = 0 AND (name LIKE ? OR path LIKE ?)
|
||||
WHERE is_missing = 0
|
||||
AND (name LIKE ? OR path COLLATE utf8mb4_unicode_ci LIKE ?)
|
||||
ORDER BY depth, path
|
||||
LIMIT ' . (int)$limit
|
||||
);
|
||||
@@ -618,7 +1053,8 @@ function searchVideos(int $userId, string $term, int $limit = 50): array
|
||||
FROM videos v
|
||||
JOIN folders f ON f.id = v.folder_id
|
||||
LEFT JOIN video_progress vp ON vp.video_id = v.id AND vp.user_id = ?
|
||||
WHERE v.is_missing = 0 AND f.is_missing = 0 AND (v.name LIKE ? OR v.path LIKE ?)
|
||||
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
|
||||
);
|
||||
@@ -635,7 +1071,8 @@ function searchQuizzes(int $userId, string $term, int $limit = 25): array
|
||||
FROM quizzes q
|
||||
JOIN folders f ON f.id = q.folder_id
|
||||
LEFT JOIN quiz_progress qp ON qp.quiz_id = q.id AND qp.user_id = ?
|
||||
WHERE q.is_missing = 0 AND f.is_missing = 0 AND (q.name LIKE ? OR q.path LIKE ?)
|
||||
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
|
||||
);
|
||||
@@ -807,11 +1244,32 @@ function continueWatching(int $userId, int $limit = 6): array
|
||||
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
|
||||
{
|
||||
$crumbs = folderBreadcrumbs($folder);
|
||||
return (int)$crumbs[0]['id'];
|
||||
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
|
||||
|
||||
@@ -9,6 +9,7 @@ if (PHP_SAPI !== 'cli') {
|
||||
}
|
||||
|
||||
$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_quizzes']} quizzes flagged missing)\n";
|
||||
|
||||
+21
-4
@@ -31,18 +31,35 @@ CREATE TABLE IF NOT EXISTS app_meta (
|
||||
|
||||
-- One row per directory found under VIDEOS_ROOT.
|
||||
-- `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 (
|
||||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
parent_id INT UNSIGNED 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,
|
||||
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,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_folders_parent FOREIGN KEY (parent_id)
|
||||
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;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS videos (
|
||||
@@ -50,7 +67,7 @@ CREATE TABLE IF NOT EXISTS videos (
|
||||
folder_id INT UNSIGNED NOT NULL,
|
||||
name VARCHAR(255) NOT NULL, -- display name, filename without .mp4
|
||||
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,
|
||||
duration_seconds FLOAT NULL, -- filled in by the player on first load
|
||||
sort_order INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
@@ -69,7 +86,7 @@ CREATE TABLE IF NOT EXISTS quizzes (
|
||||
folder_id INT UNSIGNED NOT NULL,
|
||||
name VARCHAR(255) NOT NULL, -- filename without the _quiz_solved.txt suffix
|
||||
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,
|
||||
sort_order INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
is_missing TINYINT(1) NOT NULL DEFAULT 0,
|
||||
|
||||
Reference in New Issue
Block a user