07b7aa758c
- Add new 'Database Schema' section with detailed table descriptions - Document each table's purpose, columns, and relationships - Explain key constraints, foreign keys, and cascade-delete behavior - List indices and their purposes for performance optimization - Enhance Course Advisor section with advisor_requests table details - Explain the rolling 24-hour daily limit window and token tracking - Clarify where conversation data is stored (sessionStorage) - Improve Settings section with database references - Enhance Search section with specific table and field information - Expand 'How progress works' with database table references - Document the optimization of progress queries for performance - Reference specific functions (courseRollups, advisorProgress) - Clarify when tables are created lazily (login_attempts, app_meta, advisor_requests) This makes the database structure transparent for users, operators, and developers.
551 lines
30 KiB
Markdown
551 lines
30 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 — and a
|
|
course's catalogue tags and blurb, once the catalogue is seeded.
|
|
- **Course details** (difficulty, tags, a blurb, the learning paths a course
|
|
belongs to) from the vendor's catalogue or the downloader's sidecar file —
|
|
see *Course catalogue* below.
|
|
- An optional **AI course advisor**, a bubble in the corner of every screen:
|
|
"I want to learn SD-WAN, where do I start?" answered with courses from your
|
|
own library, in order, with links. Off unless you switch it on — see
|
|
*Course advisor* below.
|
|
- **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. **Seed the course catalogue** (optional, once) — see *Course catalogue*
|
|
below. It gives courses a description, difficulty and tags, and it is what
|
|
the advisor reasons over.
|
|
|
|
6. Open the site, create an account, and watch.
|
|
|
|
## Database Schema
|
|
|
|
CourseHub uses MySQL 8.0+ or MariaDB 10.5+. The schema is applied with:
|
|
|
|
```bash
|
|
mysql coursehub < schema.sql
|
|
```
|
|
|
|
Each table's purpose and columns:
|
|
|
|
| Table | Purpose |
|
|
| --- | --- |
|
|
| **users** | User accounts: username, password hash, registration timestamp |
|
|
| **folders** | Directory hierarchy: every folder under VIDEOS_ROOT with parent, depth, sort order, whether it's a course, and which course owns it (nullable for categories) |
|
|
| **videos** | MP4 files found by the scanner: folder they live in, name, path, filesize, duration (filled in by the player on first play), sort order, and whether they're still on disk |
|
|
| **quizzes** | Quiz files (`*_quiz_solved.txt`): folder they live in, filename, path, question count (parsed from the file), sort order, and whether they're still on disk |
|
|
| **catalog_courses** | The vendor's course catalogue (e.g. INE): vendor uuid, name, slug, folder key (used to match library folders), difficulty, tags, descriptions (short and full), learning paths, categories, duration, and the vendor's page URL. Applied via `seeds/ine_catalog.sql`, not written by the scanner |
|
|
| **course_meta** | What's known about each library course beyond its folder name: source (sidecar or catalogue), which catalogue id matched it, and all the metadata (title, difficulty, tags, descriptions, paths, categories, duration). Rewritten by every scan from sidecars and the catalogue |
|
|
| **video_progress** | Per-user playback state: position (seconds) and completion flag (sticky at ≥95% watched), with an updated timestamp for cleanup |
|
|
| **quiz_progress** | Per-user quiz results: best score, last score, total questions, attempt count, and a completion flag (sticky when all questions answered correctly in one attempt) |
|
|
| **login_attempts** | Failed sign-in attempts (username + IP): timestamp and count. Used by the login throttle (5 failures per username or 20 per IP in 15 minutes = 429 response). Rows older than an hour are purged; created lazily on first failure |
|
|
| **advisor_requests** | Questions put to the AI course advisor: user, timestamp, whether it succeeded, the model name, and token counts (prompt, cache hit, completion). Created lazily on first use. Kept to track the per-user daily question limit and usage cost |
|
|
| **app_meta** | App-wide key/value store: currently just `last_scan_at` (the timestamp of the most recent library scan). Created lazily on first scan; reads tolerate its absence |
|
|
|
|
### Key relationships and constraints
|
|
|
|
- **Folders** reference themselves (`parent_id`) and have a `course_id` pointing to their nearest course ancestor (NULL for top-level categories).
|
|
- **Videos** and **quizzes** reference the folder they sit in (`folder_id`); deleted folders cascade-delete their contents.
|
|
- **course_meta** rows reference the folder they describe (`folder_id`); a course has exactly one row or none.
|
|
- **catalog_courses** is never touched by the scanner; it's applied separately via `seeds/ine_catalog.sql` and matched to library folders by `folder_key`.
|
|
- **Progress tables** (video_progress, quiz_progress, advisor_requests, login_attempts) cascade-delete when a user is deleted.
|
|
- **Paths are case-sensitive** (`utf8mb4_bin` collation) to match the filesystem; the rest of the table uses case-insensitive collation. The `UNIQUE` key on `path` ensures no duplicates.
|
|
|
|
### Indices
|
|
|
|
- `folders`: parent lookup, course_id grouping for rollups, `is_course` + `is_missing` filtering.
|
|
- `videos` and `quizzes`: folder lookup, implicit on foreign key constraints.
|
|
- `login_attempts`: username+time and ip+time for throttling queries.
|
|
- `advisor_requests`: user+time for daily-limit checks.
|
|
- `video_progress`: user+time for the periodic progress save beacon.
|
|
- `catalog_courses`: folder_key for matching against library folders.
|
|
|
|
## 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.
|
|
|
|
## Course catalogue
|
|
|
|
Folder names are all the scanner can see. Two things add more:
|
|
|
|
- **A sidecar file.** The INE downloader (`ine.py`) drops a `<slug>.json` into a
|
|
course folder once every file of the course has been fetched. It carries the
|
|
course's title, difficulty, tags, description and learning paths, and the
|
|
scanner reads it.
|
|
- **The catalogue seed.** The downloader can also export the whole catalogue
|
|
you have access to (`all_courses_with_access.json`). Turn it into an SQL
|
|
patch and apply it to any install, dev or live, before or after a code
|
|
deploy — it creates its own table and upserts by id, so re-running it is
|
|
harmless:
|
|
|
|
```bash
|
|
php tools/build-catalog-seed.php ~/ine-dl/all_courses_with_access.json > seeds/ine_catalog.sql
|
|
mysql coursehub < seeds/ine_catalog.sql # or: sudo mysql coursehub < …
|
|
php scan.php # matches the library to it
|
|
```
|
|
|
|
`seeds/` is git-ignored (the file holds the vendor's course descriptions);
|
|
ship it alongside the code. Rebuild it whenever the export is refreshed.
|
|
|
|
Every scan then resolves one `course_meta` row per course: the sidecar when
|
|
there is one, otherwise the catalogue entry whose **folder key** matches the
|
|
course folder's name. The key is the folder name the downloader would have
|
|
created — spaces and hyphens become underscores, other punctuation is dropped
|
|
(`folderKeyFor()` in `includes/functions.php`) — so `Cisco SD-WAN Use Design,
|
|
Cloud & Advanced Features` finds `Cisco_SD_WAN_Use_Design_Cloud_Advanced_Features`,
|
|
and a folder renamed by hand still matches as long as the words are the same.
|
|
Courses from elsewhere (CBT Nuggets, your own notes) simply have no row and
|
|
look as they always did. The scan summary and the Settings page say how many
|
|
courses got their details from a sidecar, from the catalogue, or from neither.
|
|
|
|
Any other `.json` under the library (the downloader also writes one per lab
|
|
lesson) is ignored.
|
|
|
|
## Course advisor
|
|
|
|
A bubble in the bottom-right corner of every screen — bar the player and a
|
|
quiz, where it would be in the way — opening a chat panel that answers
|
|
questions like *"I want to learn SD-WAN, where do I start?"* or *"what should
|
|
I know before Core MPLS?"* with courses from your own library, in a suggested
|
|
order, each linking to its course page with your progress on it. Courses named
|
|
in the answer itself are links too. When something you need is not on the
|
|
server but is in the seeded catalogue, it says so and links to the vendor's
|
|
page; topics covered nowhere are listed too.
|
|
|
|
The panel stays open as you move around, so you can follow a recommendation
|
|
and keep asking. The conversation lives in the browser tab and ends with it.
|
|
|
|
It is **off by default** and the app never depends on it. To turn it on, set
|
|
in `config.php`:
|
|
|
|
```php
|
|
define('AI_ENABLED', true);
|
|
define('AI_API_KEY', 'sk-…'); // https://platform.deepseek.com/
|
|
define('AI_BASE_URL', 'https://api.deepseek.com'); // any OpenAI-format endpoint
|
|
define('AI_MODEL', 'deepseek-v4-flash');
|
|
define('AI_DAILY_LIMIT', 50); // questions per user per day
|
|
```
|
|
|
|
With `AI_ENABLED` false (or no key) the bubble does not appear,
|
|
`api/advisor.php` answers 503, and the app makes **no outbound requests at
|
|
all**. The one extra requirement when it is on is PHP's curl extension. Each
|
|
user can also hide the button for their browser from Settings.
|
|
|
|
What leaves the server when it is on: the name, category, difficulty, vendor
|
|
and size of every course, a short blurb, tags and learning paths for the
|
|
handful judged relevant to the question, the catalogue titles of courses you
|
|
have access to but have not downloaded, your progress percentage per course,
|
|
and the conversation you type. Never video files, section or lesson names,
|
|
subtitles, or quiz contents.
|
|
|
|
How it works: every course goes out as a one-line index, which is the same
|
|
bytes for every user and every question, so the provider's prefix cache covers
|
|
it and a question costs a fraction of a cent; only the courses that match the
|
|
question are described in any depth. The model replies in JSON; the server
|
|
drops any course id it made up before the browser sees it, and links are built
|
|
only from ids that exist. That applies to the prose as well — an id the model
|
|
writes inline as `#412` is resolved against the same check and becomes a link
|
|
to that course, and anything that does not resolve is left as plain text.
|
|
|
|
Each question (including one that fails) counts toward the per-user daily limit.
|
|
The `advisor_requests` table records: the user, when the question was asked,
|
|
whether it succeeded, the model name used, and the token counts (prompt, cache
|
|
hit, and completion). This lets you audit the advisor's cost and load. The
|
|
daily limit resets in a rolling window: a question asked at midnight counts
|
|
toward the limit until midnight tomorrow.
|
|
|
|
A conversation lives in the browser tab (in `sessionStorage` under the key
|
|
`coursehub.advisor.chat`), survives navigating between pages, and is resent
|
|
with each turn so follow-ups ("I already know MPLS") work. The conversation
|
|
is cleared when the user signs out or closes the tab.
|
|
|
|
Any OpenAI-format chat endpoint can stand in for DeepSeek — point `AI_BASE_URL`
|
|
and `AI_MODEL` at it; a local model server keeps even the advisor offline. If
|
|
the server rejects DeepSeek's `thinking` / `reasoning_effort` fields, add
|
|
`define('AI_REASONING_PARAM', false);` to leave them out.
|
|
|
|
Timeouts: a reply takes a few seconds to tens of seconds. PHP's own limit is
|
|
raised for the call, but PHP-FPM's `request_terminate_timeout`, Nginx's
|
|
`fastcgi_read_timeout` (60 s by default) and Cloudflare's ~100 s cap still
|
|
apply — leave them at a minute or more.
|
|
|
|
## Search
|
|
|
|
The top-bar box searches as you type (from two characters, debounced) and also
|
|
on Enter. It matches against:
|
|
|
|
- **Folder names and paths** — `folders.name` and `folders.path` (case-insensitive despite the `utf8mb4_bin` collation on path, because the search query is lowercased).
|
|
- **Video names and paths** — `videos.name` and `videos.path`.
|
|
- **Quiz names and paths** — `quizzes.name` and `quizzes.path`.
|
|
- **Course metadata** — once the catalogue is seeded, the `course_meta` table's `title`, `tags` (JSON array, searched as text), and `description` fields are indexed.
|
|
|
|
Results are grouped into courses/sections, videos and quizzes, with your progress (from `video_progress` and `quiz_progress` tables) shown on
|
|
each row. Searches live at `#/search/<query>`, so a result page can be
|
|
bookmarked or shared. Only items with `is_missing = 0` are shown (files that still exist on disk).
|
|
|
|
## 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 (from `folders`, `videos`, `quizzes` tables with `is_missing = 0`),
|
|
total running time (summed from `videos.duration_seconds`), the watched threshold (`COMPLETE_THRESHOLD`, defaults to 95%), and when the last scan ran (from `app_meta.last_scan_at`).
|
|
- **Course metadata** — how many courses have descriptions (from `course_meta` table), how many came from a sidecar JSON (`source = 'sidecar'`), and how many from the catalogue (`source = 'catalog'`).
|
|
- **Course advisor** — enabled or off, the model name if on, how many questions you've asked today (count from `advisor_requests` in the rolling 24-hour window), and the daily limit.
|
|
- **Preferences**, stored per browser (in `localStorage`): autoplay next video, open the library in list view, and whether to show the advisor button.
|
|
- **Reset progress** — the account-wide reset, with a count of how many courses currently hold progress (from `video_progress` and `quiz_progress` grouped by course).
|
|
|
|
The scan timestamp lives in the `app_meta` key/value table under the key `last_scan_at`. 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. These updates write to the `video_progress` table, one row per
|
|
user per video, with the position (seconds), reported duration, completion flag,
|
|
and an updated timestamp.
|
|
- A video is marked **completed** (in `video_progress.completed`) at ≥ 95 % watched
|
|
(`COMPLETE_THRESHOLD`). Completion is sticky — rewatching doesn't un-complete it.
|
|
The same logic applies to quizzes: once `quiz_progress.completed` is true
|
|
(every question answered correctly in one attempt), it stays true.
|
|
- Folder progress is computed over the folder's entire subtree using a path
|
|
prefix match against `folders.path`, with partial credit for videos in progress.
|
|
This calculation spans both `video_progress` and `quiz_progress` tables.
|
|
- **Querying for progress is optimized:** every course is rolled up in
|
|
**two queries for the whole library** (one for videos, one for quizzes),
|
|
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. See `courseRollups()` and
|
|
`advisorProgress()` in `includes/functions.php`.
|
|
- 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. The duration
|
|
is stored in `videos.duration_seconds`. If a file can't be parsed at scan time,
|
|
the `duration_seconds` is NULL and the browser reports its duration on first play,
|
|
updating `video_progress.duration_seconds` for that user.
|
|
|
|
## 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/`, `seeds/`, `tools/`, `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).
|
|
- The server makes no outbound requests unless the AI advisor is switched on,
|
|
and then only to `AI_BASE_URL` (see *Course advisor*).
|
|
- 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.
|