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:
root
2026-09-08 18:51:04 +00:00
parent 6a4c972c47
commit 04314327db
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 |
| --- | --- |
| `#/` | 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.