The scanner no longer assumes courses sit at a fixed depth. It walks each branch and marks a folder is_course when it holds media directly or its children read as chapters (01_, 1. , Module 2, Week 3); everything above that is a category, and every folder still gets a course_id pointing at its nearest course for cheap rollups. An empty .course/.category marker file overrides the heuristic per folder. Also: - Roll up progress for the whole library in two grouped queries instead of one prefix-matched query per course/folder card. - Store folders/videos/quizzes paths as utf8mb4_bin so folders that differ only in case (CCNP Notes vs CCNP NOtes) aren't merged by a case-insensitive UNIQUE key. - migrateLibrarySchema() brings an older install's tables up to date at scan time, so there's no separate migration step. - Library UI gains a categories view alongside shelves/catalog; the continue-watching strip's play button now seeks to the saved position on open. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FfMPJK7TQV18JhFv6yTKev
CourseHub
Self-hosted course video player: PHP 8.0+ + MySQL/MariaDB, its own CSS design
system and 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
.mp4files 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 (
Video2beforeVideo10). - Subtitles: a video with a sibling
.srtfile (Lesson.mp4+Lesson.srt) gets a CC button in the player — the file is converted to WebVTT on the fly bycaptions.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 |
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
.mp4and 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
-
Database — create the schema and an app user:
sudo mysql < schema.sql sudo mysql -e "CREATE USER IF NOT EXISTS 'coursehub'@'localhost' IDENTIFIED BY 'coursehub'; GRANT ALL ON coursehub.* TO 'coursehub'@'localhost';" -
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: setfalseonce your users have accounts.SESSION_LIFETIME: seconds a sign-in lasts (the example config says 30 days). Unset or0means 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 whoseconfig.phppredates the setting keep that old behaviour. If other PHP apps share the server's session directory, give this app its ownsession.save_pathin the vhost / FPM pool.DEBUG: include the exception type,file:lineand stack trace in API error responses. Errors go to the PHP error log either way, so leave thisfalseon anything publicly reachable. Installs whoseconfig.phppredates the setting behave as if it werefalse.
No external binaries are needed — PHP with PDO/MySQL is the whole dependency list.
-
Run — with the PHP built-in server for testing:
php -S 0.0.0.0:8080 -t /path/to/coursehubor point an Apache/Nginx + PHP-FPM vhost at the directory.
-
Index the library — click Rescan library in the top bar, or:
php scan.phpRescan 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.
-
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}toapi/progress.phpevery 5 s while playing, plus on pause/seek, vianavigator.sendBeaconwhen 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
mvhdheader 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=Laxin code,Secureautomatically 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 anX-CSRF-Tokenheader; thesendBeaconprogress save carries it as acsrfbody field. The token is issued byGET api/auth.phpand 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 answer429until 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/ Nginxreal_ipwithCF-Connecting-IP— the better fix, it also corrects your logs), or setCLIENT_IP_HEADERto'CF-Connecting-IP'inconfig.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 tovideos/,includes/,config.phpandschema.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 equivalentlocationblocks, or keep the videos and includes outside the docroot (the safest choice either way). - Only
.mp4files ever reach the browser, and only throughstream.php. A quiz's exhibit image is the one thing loaded from elsewhere — its URL is whatever the quiz file says, sent withreferrerpolicy="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) orX-Sendfile(Apache) instead of streaming through PHP. - Put the app behind HTTPS — the session cookie is then marked
Secureon its own. When TLS ends at a proxy that talks plain HTTP to the origin, the cookie is still markedSecureprovidedCLIENT_IP_HEADERis set: the same trust that makes the proxy's client-IP header believable makes itsX-Forwarded-Protobelievable. - 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.