04314327db
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
376 lines
18 KiB
Markdown
376 lines
18 KiB
Markdown
# CourseHub
|
|
|
|
Self-hosted course video player: PHP 8.0+ + MySQL/MariaDB, its own CSS design
|
|
system and [Plyr](https://github.com/sampotts/plyr). No CSS framework, no build
|
|
step, and no CDN — Plyr and the Inter font are vendored in `assets/vendor/`,
|
|
so the app works with no internet access at all.
|
|
|
|
Single-page app: `index.php` is a static shell (sidebar, search header, mobile
|
|
tab bar); all navigation, login, browsing, playback, scanning and settings
|
|
happen over AJAX against the JSON API in `api/` (`auth.php`, `library.php`,
|
|
`progress.php`, `scan.php`, `quiz.php`, `reset.php`), with hash-based routes so
|
|
views switch without page reloads and browser back/forward still work:
|
|
|
|
| Route | Screen |
|
|
| --- | --- |
|
|
| `#/` | home — resume card, stats, in progress, last scan |
|
|
| `#/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` |
|
|
| `#/settings` | library folder, preferences, reset |
|
|
|
|
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 (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 — 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
|
|
WebVTT on the fly by `captions.php`, addressed by DB id like the stream.
|
|
- **Search** courses, sections, videos and quizzes from the top bar.
|
|
- **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 **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 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/
|
|
├── 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
|
|
```
|
|
|
|
### 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
|
|
|
|
1. **Database** — create the schema and an app user:
|
|
|
|
```bash
|
|
sudo mysql < schema.sql
|
|
sudo mysql -e "CREATE USER IF NOT EXISTS 'coursehub'@'localhost' IDENTIFIED BY 'coursehub';
|
|
GRANT ALL ON coursehub.* TO 'coursehub'@'localhost';"
|
|
```
|
|
|
|
2. **Configure** — edit `config.php`:
|
|
- `VIDEOS_ROOT`: absolute path to your course folders (must be readable by
|
|
the web server / PHP user).
|
|
- DB credentials (change the password in production).
|
|
- `ALLOW_REGISTRATION`: set `false` once your users have accounts.
|
|
- `SESSION_LIFETIME`: seconds a sign-in lasts (the example config says 30
|
|
days). Unset or `0` means a browser-session cookie that also dies after
|
|
PHP's idle limit — 24 minutes by default, which signs people out over a
|
|
long pause. Installs whose `config.php` predates the setting keep that
|
|
old behaviour. If other PHP apps share the server's session directory,
|
|
give this app its own `session.save_path` in the vhost / FPM pool.
|
|
- `DEBUG`: include the exception type, `file:line` and stack trace in API
|
|
error responses. Errors go to the PHP error log either way, so leave this
|
|
`false` on anything publicly reachable. Installs whose `config.php`
|
|
predates the setting behave as if it were `false`.
|
|
|
|
No external binaries are needed — PHP with PDO/MySQL is the whole
|
|
dependency list.
|
|
|
|
3. **Run** — with the PHP built-in server for testing:
|
|
|
|
```bash
|
|
php -S 0.0.0.0:8080 -t /path/to/coursehub
|
|
```
|
|
|
|
or point an Apache/Nginx + PHP-FPM vhost at the directory.
|
|
|
|
4. **Index the library** — click **Rescan library** in the top bar, or:
|
|
|
|
```bash
|
|
php scan.php
|
|
```
|
|
|
|
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. 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.
|
|
|
|
## Quizzes
|
|
|
|
Course folders may contain quiz text files next to the videos. Files named
|
|
`*_quiz_solved.txt` are indexed (a `*_quiz_notsolved.txt` twin is ignored —
|
|
it's the same content without answers). Format:
|
|
|
|
```
|
|
[&] Which two are supported CLI connectivity methods? (Select two)
|
|
- RDP
|
|
- rlogin
|
|
- GUI widget -- Correct
|
|
- FortiExplorer -- Correct
|
|
```
|
|
|
|
Questions start with `[&]`, options with `- `, and correct options end with
|
|
`-- Correct`. Questions with more than one correct option automatically render
|
|
as checkboxes instead of radios.
|
|
|
|
A question that refers to a diagram carries it as a Markdown image on its own
|
|
line, anywhere between the `[&]` line and the next question:
|
|
|
|
```
|
|
[&] Based on the diagram shown, which of the following are true?
|
|

|
|
- PE1 imposes a label -- Correct
|
|
```
|
|
|
|
The image is shown under the question text. It is fetched by the browser from
|
|
wherever the URL points (usually the course provider's server), so an install
|
|
with no internet access sees a note and a link instead of the picture.
|
|
|
|
Quizzes are graded server-side (answers are never sent to the browser before
|
|
submission). A question counts only if the selected set exactly matches the
|
|
correct set. Best score, last score, and attempt count are stored per user;
|
|
a quiz is *completed* once every question is answered correctly in one
|
|
attempt, and quiz scores count toward folder progress alongside videos.
|
|
|
|
## Search
|
|
|
|
The top-bar box searches as you type (from two characters, debounced) and also
|
|
on Enter. It matches folder, video and quiz **names** and their relative
|
|
**paths**, so `Course 1/Intro` lists everything under that section. Results are
|
|
grouped into courses/sections, videos and quizzes, with your progress shown on
|
|
each row. Searches live at `#/search/<query>`, so a result page can be
|
|
bookmarked or shared.
|
|
|
|
## Resetting progress
|
|
|
|
`api/reset.php` deletes progress rows for the logged-in user only — never for
|
|
anyone else, and never any video, quiz or folder itself. Four scopes:
|
|
|
|
| Where | Scope | Clears |
|
|
| --- | --- | --- |
|
|
| Player page → **Reset** | `video` | that video's position + completion |
|
|
| Quiz page → **Reset attempts** | `quiz` | that quiz's attempts, best/last score |
|
|
| Folder page → **Reset progress** | `folder` | everything in that folder's subtree |
|
|
| Library list row → ↺ | `folder` | that one course's subtree |
|
|
| Settings / avatar menu → **Reset all progress** | `all` | every video and quiz on the account |
|
|
|
|
Each one asks for confirmation first, and the reply reports how many video and
|
|
quiz rows were removed. Resetting the video you are watching also suppresses
|
|
the player's teardown save, so the position you just cleared isn't written back.
|
|
|
|
## Look and feel
|
|
|
|
The UI implements the **Course Library** design (a Claude Design document).
|
|
`assets/style.css` is the whole design system — tokens first, then layout, then
|
|
components. There is no CSS framework under it, and the icons are inline SVG
|
|
from the design rather than an icon font.
|
|
|
|
| Token | Hex | Used for |
|
|
| --- | --- | --- |
|
|
| Paper | `#EFEAE1` | outermost ground |
|
|
| Surface | `#FAF8F5` | app background |
|
|
| Sidebar | `#F5F1EA` | left rail |
|
|
| Card | `#FFFFFF` | cards, rows, dialogs |
|
|
| Ink | `#1A1814` → `#B8B0A2` | four text weights (`--ink` … `--ink-4`) |
|
|
| Accent | `#1F6F5C` | primary action, links, progress, completion |
|
|
| Danger | `#B84A3C` | reset and errors |
|
|
|
|
Type: **Inter** throughout — headings, interface text, and counts/durations/
|
|
paths (the design's own 'Elms Sans' ships only inside the design document).
|
|
The font is self-hosted from `assets/vendor/` (latin + latin-ext subsets).
|
|
Numeric text uses `tabular-nums` so columns of counts and durations still
|
|
line up without a separate monospace face.
|
|
|
|
Courses have no cover art, so each one gets a hue from the design's six-colour
|
|
set (clay, sage, slate, sand, plum, sky), picked from its folder id so it stays
|
|
the same across renders and reloads. That hue drives its card art, its initial
|
|
badge and its progress bar.
|
|
|
|
There is no scripted motion — views render directly in their final state; the
|
|
only movement is the CSS the components themselves carry (hover states, the
|
|
loading spinner).
|
|
|
|
Below 900 px the layout follows the design's mobile frame: the sidebar gives way
|
|
to a bottom tab bar, the search field becomes a pill with a round scan button
|
|
next to it, and grids drop to two columns.
|
|
|
|
## Settings
|
|
|
|
`#/settings` shows what the server actually knows and what this browser
|
|
remembers:
|
|
|
|
- **Library folder** — `VIDEOS_ROOT`, whether it is readable, indexed counts,
|
|
total running time, the watched threshold, and when the last scan ran.
|
|
- **Preferences**, stored per browser: autoplay next video, open the library
|
|
in list view.
|
|
- **Reset progress** — the account-wide reset, with a count of how many courses
|
|
currently hold progress.
|
|
|
|
Scan time lives in an `app_meta` key/value table. Reads tolerate the table not
|
|
existing, so an install created before it was added keeps working until its
|
|
next scan creates it.
|
|
|
|
## How progress works
|
|
|
|
- The player POSTs `{video_id, position, duration}` to `api/progress.php`
|
|
every 5 s while playing, plus on pause/seek, via `navigator.sendBeacon`
|
|
when the tab is hidden or closed, and when navigating to another view
|
|
in the SPA.
|
|
- A video is marked **completed** at ≥ 95 % watched (`COMPLETE_THRESHOLD`).
|
|
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.
|
|
|
|
## When something breaks
|
|
|
|
`index.php` stamps `assets/*.css|js` with the file's mtime (`asset()`), so a
|
|
deploy is always a fresh URL. Without that, a browser or CDN holding a stale
|
|
`app.js` runs it against a newer shell, it grabs element IDs that no longer
|
|
exist, and it throws before unhiding the shell — a blank page whose only clue
|
|
is in the console. If you ever do see a blank page, hard-refresh and purge the
|
|
CDN cache first.
|
|
|
|
A small handler installed ahead of every other script catches uncaught errors,
|
|
unhandled rejections and failed script/stylesheet loads, and paints them into a
|
|
`#boot-error` panel — so a failure that stops the app is visible on the page
|
|
rather than silent. Server-side, uncaught exceptions *and* fatals (via
|
|
`register_shutdown_function`, which `set_exception_handler` never sees) are
|
|
returned as JSON, with detail gated behind `DEBUG`.
|
|
|
|
## Security
|
|
|
|
- **Sessions**: cookies are set `HttpOnly` + `SameSite=Lax` in code, `Secure`
|
|
automatically when the request came over HTTPS, and strict session-ID mode
|
|
is on (fixation protection).
|
|
- **CSRF**: every state-changing request must echo the session's token —
|
|
`fetch()` sends it as an `X-CSRF-Token` header; the `sendBeacon` progress
|
|
save carries it as a `csrf` body field. The token is issued by
|
|
`GET api/auth.php` and rotated at login.
|
|
- **Login throttling**: failed sign-ins are recorded per username + IP
|
|
(`login_attempts`, created automatically on first failure, so existing
|
|
installs need no migration). Five failures for a username, or twenty for an
|
|
IP, inside 15 minutes answer `429` until 15 minutes have passed since the
|
|
last failure. A successful login clears the username's failures; stale rows
|
|
are purged as new ones are recorded.
|
|
- **Behind Cloudflare** (or any proxy that hides the visitor), the IP the
|
|
throttle sees is the proxy's. Either restore real client IPs at the web
|
|
server (Apache `mod_remoteip` / Nginx `real_ip` with `CF-Connecting-IP` —
|
|
the better fix, it also corrects your logs), or set
|
|
`CLIENT_IP_HEADER` to `'CF-Connecting-IP'` in `config.php`. Only do either
|
|
if the origin is unreachable except through the proxy; otherwise the header
|
|
can be spoofed to dodge the per-IP limit.
|
|
- The shipped `.htaccess` (Apache) denies direct access to `videos/`,
|
|
`includes/`, `config.php` and `schema.sql` — without it, video URLs would
|
|
bypass login entirely. The rules match those folder names anywhere in the
|
|
URL path, so they hold whether the app lives at `/` or under a prefix such as
|
|
`/coursehub/`. Nginx setups need the equivalent `location` blocks, or keep
|
|
the videos and includes outside the docroot (the safest choice either way).
|
|
- Only `.mp4` files ever reach the browser, and only through `stream.php`.
|
|
A quiz's exhibit image is the one thing loaded from elsewhere — its URL is
|
|
whatever the quiz file says, sent with `referrerpolicy="no-referrer"`.
|
|
|
|
## Production notes
|
|
|
|
- PHP streams the video in 512 KB chunks with Range support, which is fine for
|
|
a handful of users. For heavier use, serve files with `X-Accel-Redirect`
|
|
(Nginx) or `X-Sendfile` (Apache) instead of streaming through PHP.
|
|
- Put the app behind HTTPS — the session cookie is then marked `Secure` on its
|
|
own. When TLS ends at a proxy that talks plain HTTP to the origin, the
|
|
cookie is still marked `Secure` provided `CLIENT_IP_HEADER` is set: the same
|
|
trust that makes the proxy's client-IP header believable makes its
|
|
`X-Forwarded-Proto` believable.
|
|
- A rescan is one-at-a-time: a second **Scan library** while one is running
|
|
(from any user) answers "already running" instead of racing it.
|