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.
|
||||
|
||||
Reference in New Issue
Block a user