diff --git a/README.md b/README.md index e23827e..0f1d817 100644 --- a/README.md +++ b/README.md @@ -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/` | every course, as shelves or a filtered catalog | -| `#/folder/N` | a course or section: sections + content | +| `#/library`, `#/library/` | 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/`, `Category/Vendor/` and +`Category/Vendor/Exam/` 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. diff --git a/api/library.php b/api/library.php index dfb7439..8161e9c 100644 --- a/api/library.php +++ b/api/library.php @@ -1,8 +1,10 @@ sidebar/header chrome: totals, shelves, last scan // GET api/library.php?view=home -> resume card, stats, in progress, recent additions -// GET api/library.php?view=library -> every course, grouped into shelves +// GET api/library.php?view=library -> every course, plus the categories above them // GET api/library.php?view=folder&id=N -> folder contents + progress +// (a category answers with its +// courses, a course with its sections) // GET api/library.php?view=video&id=N -> video + playlist + resume info // GET api/library.php?view=search&q=text -> matching folders, videos, quizzes // GET api/library.php?view=settings -> library paths, thresholds, progress summary @@ -21,6 +23,21 @@ function folderSummary(array $f, int $userId): array ]; } +/** + * The sections of a course, as cards. One batched query for the whole level + * instead of a rollup per section, which matters on a course with thirty of + * them; a section with nothing under it still gets a card, showing zeroes. + */ +function sectionCards(int $userId, array $folder): array +{ + $rollups = childRollups($userId, $folder['path']); + return array_map(function ($f) use ($rollups) { + $card = summaryFrom($f, $rollups[$f['name']] ?? emptyRollup()); + $card['kind'] = folderKind($f); + return $card; + }, childFolders((int)$folder['id'])); +} + function videoSummary(array $v): array { $duration = $v['duration_seconds'] ?? $v['watched_duration'] ?? null; @@ -57,28 +74,13 @@ function crumbList(array $folder): array ); } -/** The three shelves the library groups courses into. */ -function shelveCourses(array $courses): array -{ - $shelves = [ - ['key' => 'progress', 'name' => 'In progress', 'courses' => []], - ['key' => 'finished', 'name' => 'Finished', 'courses' => []], - ['key' => 'fresh', 'name' => 'Not started', 'courses' => []], - ]; - foreach ($courses as $c) { - foreach ($shelves as $i => $shelf) { - if ($shelf['key'] === $c['state']) { - $shelves[$i]['courses'][] = $c; - } - } - } - return array_values(array_filter($shelves, fn($s) => $s['courses'])); -} - $view = $_GET['view'] ?? 'home'; switch ($view) { case 'meta': + // Shelf counts are over courses, wherever in the tree they turned out + // to live — counting top-level folders instead put a whole vendor's + // catalogue on one shelf and left the other two empty for good. $courses = courseCards($userId); $counts = ['progress' => 0, 'finished' => 0, 'fresh' => 0]; foreach ($courses as $c) { @@ -97,6 +99,7 @@ switch ($view) { break; case 'home': + $rollups = courseRollups($userId); $courses = courseCards($userId); $resume = continueWatching($userId, 1); $resumeCourseId = null; @@ -123,17 +126,23 @@ switch ($view) { 'duration' => $r['duration_seconds'] !== null ? (float)$r['duration_seconds'] : null, ], continueWatching($userId)), 'in_progress' => array_values(array_filter($courses, fn($c) => $c['state'] === 'progress')), - 'recent' => recentCourses($userId), - 'courses' => $courses, + 'recent' => recentCourses($userId, 3, $rollups), ]); break; case 'library': + // Three ways into the same set: the category tier the folders on disk + // already describe, the progress shelves, or one flat catalogue. + $rollups = courseRollups($userId); $courses = courseCards($userId); + // Shelving is a partition of `courses` by its own `state`, so it is done + // in the browser: sending it as well would put every course in the + // payload twice. echo json_encode([ - 'courses' => $courses, - 'shelves' => shelveCourses($courses), - 'totals' => libraryTotals(), + 'courses' => $courses, + 'categories' => categoryCards($userId, null, $rollups), + 'top_courses' => courseCardsUnder($userId, null, $rollups), + 'totals' => libraryTotals(), ]); break; @@ -146,16 +155,33 @@ switch ($view) { } $crumbs = crumbList($folder); $rollup = folderRollup($userId, $folder['path']); + $kind = folderKind($folder); + + // A category's children are courses and further categories, which the + // library shelves; a course's children are its sections, which it just + // lists in order. + $categories = $courses = []; + $subfolders = []; + if ($kind === 'category') { + $rollups = courseRollups($userId); + $categories = categoryCards($userId, (int)$folder['id'], $rollups); + $courses = courseCardsUnder($userId, (int)$folder['id'], $rollups); + } else { + $subfolders = sectionCards($userId, $folder); + } + echo json_encode([ 'folder' => ['id' => (int)$folder['id'], 'name' => $folder['name'], - 'path' => $folder['path']], - 'course_id' => $crumbs ? $crumbs[0]['id'] : (int)$folder['id'], + 'path' => $folder['path'], 'kind' => $kind], + 'kind' => $kind, + 'course_id' => courseIdOf($folder), 'breadcrumbs' => $crumbs, 'rollup' => $rollup, 'progress' => ['total' => $rollup['total'], 'completed' => $rollup['completed'], 'percent' => $rollup['percent']], - 'subfolders' => array_map(fn($f) => courseSummary($userId, $f), - childFolders((int)$folder['id'])), + 'categories' => $categories, + 'courses' => $courses, + 'subfolders' => $subfolders, 'videos' => array_map('videoSummary', folderVideos((int)$folder['id'], $userId)), 'quizzes' => array_map('quizSummary', @@ -197,7 +223,7 @@ switch ($view) { 'duration' => $video['duration_seconds'] !== null ? (float)$video['duration_seconds'] : null], 'folder' => ['id' => (int)$folder['id'], 'name' => $folder['name']], - 'course_id' => $crumbs ? $crumbs[0]['id'] : (int)$folder['id'], + 'course_id' => courseIdOf($folder), 'breadcrumbs' => $crumbs, 'playlist' => array_map('videoSummary', $playlist), 'quizzes' => array_map('quizSummary', folderQuizzes((int)$folder['id'], $userId)), @@ -221,8 +247,10 @@ switch ($view) { echo json_encode([ 'query' => $q, + // A folder hit can be a category, a course or one section inside + // one — worth saying which, now that they are three different things. 'folders' => array_map( - fn($f) => courseSummary($userId, $f), + fn($f) => courseSummary($userId, $f) + ['kind' => folderKind($f)], searchFolders($q) ), 'videos' => array_map( @@ -238,6 +266,10 @@ switch ($view) { case 'settings': $courses = courseCards($userId); + $counts = ['progress' => 0, 'finished' => 0, 'fresh' => 0]; + foreach ($courses as $c) { + $counts[$c['state']]++; + } $withSome = array_values(array_filter($courses, fn($c) => $c['progress']['percent'] > 0)); echo json_encode([ 'root' => rtrim(VIDEOS_ROOT, '/'), @@ -249,6 +281,7 @@ switch ($view) { 'username' => currentUsername(), 'progress_courses' => count($withSome), 'courses' => count($courses), + 'shelf_counts' => $counts, ]); break; diff --git a/assets/app.js b/assets/app.js index d972be7..872662b 100644 --- a/assets/app.js +++ b/assets/app.js @@ -20,6 +20,7 @@ csrf: null, // session token, echoed back on every POST meta: null, // totals / shelves / last scan, for the chrome suppressSave: false, // set while a reset lands, so teardown doesn't re-save + playOnLoad: null, // video id a play button asked to start, not just open scanning: false, }; let cleanupView = null; @@ -33,6 +34,8 @@ const SHELF_HUE = { progress: '#B85A2E', finished: '#3F7A50', fresh: '#374B5C', all: '#C2410C' }; const SHELF_NAME = { progress: 'In progress', finished: 'Finished', fresh: 'Not started', all: 'All courses' }; + // What a folder is, for search results: the three now mean different things. + const KIND_NAME = { category: 'Category', course: 'Course', section: 'Section' }; const ICONS = { home: '', @@ -127,7 +130,7 @@ /* ---------- preferences ---------- */ - const PREF_DEFAULTS = { view: 'grid', layout: 'shelves', autoplay: true }; + const PREF_DEFAULTS = { view: 'grid', layout: 'categories', autoplay: true }; function pref(key) { try { @@ -352,6 +355,46 @@ `; } + // A category is a folder of courses. It gets the same card as a course so the + // library reads as one grid, with the course count where a course shows its + // videos, and no play affordance — there is nothing to play at this level. + function categoryCard(c) { + const hue = hueOf(c.id); + return ` + ${thumb(hue, { cat: 'Category', dur: fmtLong(c.duration) })} +
+
${esc(c.name)}
+
${c.courses} course${c.courses === 1 ? '' : 's'} · ${esc(courseMeta(c))}
+
+ ${bar(c.progress.percent, hue)} + ${c.progress.percent}% +
+
+
`; + } + + function categoryRow(c) { + const hue = hueOf(c.id); + return ` + ${esc(initialOf(c.name))} +
+
${esc(c.name)}
+
${esc(c.path || '')}
+
+ ${c.courses} course${c.courses === 1 ? '' : 's'} + ${c.videos} vids + ${esc(fmtLong(c.duration))} +
+ ${bar(c.progress.percent, hue)} + ${c.progress.percent}% +
+
`; + } + + const categoryList = (cats, mode) => mode === 'list' + ? `
${cats.map(categoryRow).join('')}
` + : `
${cats.map(categoryCard).join('')}
`; + function courseRow(c) { const hue = hueOf(c.id); return `
@@ -527,7 +570,8 @@ const left = resume.duration ? Math.max(0, resume.duration - resume.position) : 0; const hue = hueOf(resume.course_id ?? resume.id); return `
-
${thumb(hue, { play: true, percent: pct })}
+ ${thumb(hue, { play: true, percent: pct })}
Continue watching
${esc(resume.name)}
@@ -607,11 +651,38 @@ /* ---------- library ---------- */ + /** + * Split courses onto the three progress shelves. The API sends one flat list + * and this partitions it — shipping the partition as well would put every + * course in the payload twice. + */ + const shelveCourses = (courses) => + [['progress', 'In progress'], ['finished', 'Finished'], ['fresh', 'Not started']] + .map(([key, name]) => ({ key, name, courses: courses.filter((c) => c.state === key) })) + .filter((s) => s.courses.length); + + /** + * A shelf block: a heading, a count and the cards on it. Used by the library + * and by a category page, which shelves its own courses the same way. + */ + const shelfBlock = (shelf, mode) => ` +
+
+ +

${esc(shelf.name)}

+ ${shelf.courses.length} course${shelf.courses.length === 1 ? '' : 's'} +
+
+ ${courseList(shelf.courses, mode)} +
`; + async function renderLibrary(filter) { if (filter && !(filter in SHELF_NAME)) filter = null; // #/library/whatever → every course spinner(); const data = await load('api/library.php?view=library'); + // A shelf link from the sidebar is always a flat list of matching courses; + // otherwise the saved layout decides. const layout = filter && filter !== 'all' ? 'catalog' : pref('layout'); const mode = pref('view'); const active = filter || 'all'; @@ -629,17 +700,19 @@

Add course folders to the videos directory, then scan.

`; - } else if (layout === 'shelves') { - body = data.shelves.map((s) => ` -
-
- -

${esc(s.name)}

- ${s.courses.length} course${s.courses.length === 1 ? '' : 's'} -
-
- ${courseList(s.courses, mode)} -
`).join(''); + } else if (layout === 'categories' && data.categories.length) { + // The folders above the courses are the library's own shape — a few dozen + // cards to browse instead of a wall of every course at once. + body = categoryList(data.categories, mode) + + (data.top_courses.length ? ` +
+

Courses

+ ${data.top_courses.length} +
+
+ ${courseList(data.top_courses, mode)}` : ''); + } else if (layout === 'shelves' || (layout === 'categories' && !data.categories.length)) { + body = shelveCourses(data.courses).map((sh) => shelfBlock(sh, mode)).join(''); } else { body = `
${chips}
` + (shown.length ? courseList(shown, mode) @@ -649,19 +722,27 @@ } const totalVideos = data.courses.reduce((n, c) => n + c.videos, 0); + const sub = [ + `${data.courses.length} course${data.courses.length === 1 ? '' : 's'}`, + data.totals.categories ? `${data.totals.categories} categor${data.totals.categories === 1 ? 'y' : 'ies'}` : '', + `${totalVideos} video${totalVideos === 1 ? '' : 's'} on this machine`, + ].filter(Boolean).join(' · '); + + const layoutBtn = (key, label) => + ``; view.innerHTML = `

Library

-

${data.courses.length} course${data.courses.length === 1 ? '' : 's'} · - ${totalVideos} video${totalVideos === 1 ? '' : 's'} on this machine

+

${sub}

- - + ${data.categories.length ? layoutBtn('categories', 'Categories') : ''} + ${layoutBtn('shelves', 'Shelves')} + ${layoutBtn('catalog', 'Catalog')}
${viewToggle()}
@@ -678,27 +759,50 @@ const data = await load(`api/library.php?view=folder&id=${id}`); const mode = pref('view'); const hue = hueOf(data.course_id); + const isCategory = data.kind === 'category'; - const sections = data.subfolders.length ? ` -
-

Sections

- ${viewToggle()} -
-
${courseList(data.subfolders, mode)}
` : ''; + // A category holds courses (and possibly more categories), so it reads like + // a small library of its own; a course holds sections, which are just a + // list. Both come back on the same route — only the shape below differs. + const sections = isCategory + ? (data.categories.length ? ` +
+

Categories

+ ${viewToggle()} +
+
${categoryList(data.categories, mode)}
` : '') + + (data.courses.length ? ` +
+

Courses

+ ${data.categories.length ? '' : viewToggle()} +
+ ${shelveCourses(data.courses).map((sh) => shelfBlock(sh, mode)).join('')}` : '') + : (data.subfolders.length ? ` +
+

Sections

+ ${viewToggle()} +
+
${courseList(data.subfolders, mode)}
` : ''); const items = [ ...data.videos.map((v) => ({ ...v, kind: 'video' })), ...data.quizzes.map((q) => ({ ...q, kind: 'quiz' })), ].sort((a, b) => a.filename.localeCompare(b.filename, undefined, { numeric: true, sensitivity: 'base' })); + const hasChildren = isCategory + ? (data.categories.length + data.courses.length) > 0 + : data.subfolders.length > 0; + const content = items.length ? `

Content

${items.map((it) => it.kind === 'quiz' ? quizRow(it) : videoRow(it)).join('')}
` - : (!data.subfolders.length + : (!hasChildren ? `

This folder is empty

No videos or quizzes were found here in the last scan.

` : ''); + const courseCount = isCategory ? data.categories.reduce((n, c) => n + c.courses, data.courses.length) : 0; const meta = [ + courseCount ? `${courseCount} course${courseCount === 1 ? '' : 's'}` : '', data.rollup.videos ? `${data.rollup.videos} video${data.rollup.videos === 1 ? '' : 's'}` : '', data.rollup.quizzes ? `${data.rollup.quizzes} quiz${data.rollup.quizzes === 1 ? '' : 'zes'}` : '', fmtLong(data.rollup.duration), @@ -710,6 +814,7 @@ ${crumbs(data.breadcrumbs)}
+ ${isCategory ? 'Category' : ''}

${esc(data.folder.name)}

${esc(meta || 'Empty folder')}

@@ -758,7 +863,7 @@ ${esc(initialOf(c.name))}
${esc(c.name)}
-
${esc([courseMeta(c), c.path].filter(Boolean).join(' · '))}
+
${esc([KIND_NAME[c.kind], courseMeta(c), c.path].filter(Boolean).join(' · '))}
${bar(c.progress.percent, hueOf(c.id))} @@ -1034,6 +1139,12 @@
Indexed ${data.totals.videos} videos · ${data.totals.quizzes} quizzes · ${esc(fmtBytes(data.totals.bytes) || '0 mb')}
+
Courses found + ${data.totals.courses} in ${data.totals.categories} categor${data.totals.categories === 1 ? 'y' : 'ies'}
+
Shelves + ${data.shelf_counts.progress} in progress · + ${data.shelf_counts.finished} finished · + ${data.shelf_counts.fresh} not started
Total running time ${esc(fmtLong(data.totals.duration) || '—')}
A video counts as watched at @@ -1109,6 +1220,18 @@ resumed = true; }); + // A play button elsewhere in the app (the home hero) asked for the video to + // start, not just for the player to open. Autoplay is only permitted off the + // gesture that got us here, so the flag is consumed once and never survives + // a reload — and it waits for the resume seek above, so play() picks up at + // the saved position rather than at zero. + if (state.playOnLoad === data.video.id) { + state.playOnLoad = null; + const start = () => { const p = el.play(); if (p) p.catch(() => { /* autoplay refused — controls are there */ }); }; + if (el.readyState >= 1) start(); + else el.addEventListener('loadedmetadata', start, { once: true }); + } + let lastSaved = -1; let saveTimer = null; @@ -1149,6 +1272,10 @@ save(true); // the final position always lands, even within 1 s of the last heartbeat if (data.next_id && pref('autoplay') !== false) { status(`${icon('next', 14, 1.7)}Playing next video…`); + // "Autoplay next" has to actually start it — navigating alone just + // opened the next video paused. The gesture that began this playback + // still counts, so the browser lets the next one start. + state.playOnLoad = data.next_id; nextTimer = setTimeout(() => { location.hash = `#/watch/${data.next_id}`; }, 1500); } }); @@ -1160,6 +1287,12 @@ return function cleanup() { saveBeacon(); clearTimeout(nextTimer); // leaving during the 1.5 s countdown must not jump ahead later + // Autoplay-next arms playOnLoad before it navigates, and route() tears the + // old view down after the hash has already changed — so only disarm when + // we are leaving for somewhere other than the video it queued. + if (state.playOnLoad === data.next_id && location.hash !== `#/watch/${data.next_id}`) { + state.playOnLoad = null; + } if (saveTimer) clearInterval(saveTimer); document.removeEventListener('visibilitychange', onHide); window.removeEventListener('pagehide', saveBeacon); @@ -1218,7 +1351,7 @@ try { const res = await api('api/scan.php', { method: 'POST' }); const s = res.stats; - toast(`Scan finished — ${s.folders} folders, ${s.videos} videos, ${s.quizzes} quizzes indexed.`); + toast(`Scan finished — ${s.courses} courses, ${s.videos} videos, ${s.quizzes} quizzes indexed.`); await loadMeta(); route(); } catch (err) { @@ -1336,6 +1469,13 @@ /* ---------- delegated actions ---------- */ document.addEventListener('click', (e) => { + // Not a preventDefault: the link still navigates to #/watch/N, this only + // tells the player it was reached from a play button and should start. + const playLink = e.target.closest('[data-play]'); + if (playLink && !e.metaKey && !e.ctrlKey && !e.shiftKey && e.button === 0) { + state.playOnLoad = +playLink.dataset.play; + } + const scan = e.target.closest('[data-scan]'); if (scan) { e.preventDefault(); runScan(); return; } diff --git a/assets/style.css b/assets/style.css index 30d2b8e..64857f7 100644 --- a/assets/style.css +++ b/assets/style.css @@ -734,7 +734,18 @@ button, input { font-family: inherit; font-size: inherit; } text-decoration: none; } .hero:hover { color: var(--surface); } -.hero-art { width: 280px; flex: none; position: relative; border-radius: 10px; overflow: hidden; } +.hero-art { + display: block; + width: 280px; flex: none; + position: relative; + border-radius: 10px; + overflow: hidden; +} +/* The hero art is a link straight into the player, so its play badge has to + read as a button — the rest of the thumb art is decoration. */ +.hero-art .thumb-play span { transition: transform .12s ease, background-color .12s ease; } +.hero-art:hover .thumb-play span { background: var(--surface); transform: scale(1.06); } +.hero-art:focus-visible { outline: 2px solid var(--surface); outline-offset: 2px; } .hero-body { flex: 1; min-width: 0; diff --git a/includes/functions.php b/includes/functions.php index 90040b3..da49f7f 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -133,6 +133,169 @@ function parseQuizFile(string $absPath): array * (keyed by relative path so IDs stay stable across rescans), then flags * rows whose files disappeared as is_missing. * ------------------------------------------------------------------------- */ +/** + * Bring an older install's tables up to date. DDL commits implicitly in + * MariaDB, so this has to run before the scan opens its transaction — never + * from inside one. + */ +function migrateLibrarySchema(PDO $db): void +{ + // Where a course starts is worked out per branch at scan time; see + // markCourseRoots(). Both columns are rewritten by every scan. + $db->exec( + 'ALTER TABLE folders + ADD COLUMN IF NOT EXISTS is_course TINYINT(1) NOT NULL DEFAULT 0 AFTER sort_order, + ADD COLUMN IF NOT EXISTS course_id INT UNSIGNED NULL AFTER is_course' + ); + $db->exec('ALTER TABLE folders ADD INDEX IF NOT EXISTS idx_folders_course (course_id)'); + $db->exec('ALTER TABLE folders ADD INDEX IF NOT EXISTS idx_folders_is_course (is_course, is_missing)'); + + // A path names a file on a case-sensitive filesystem, so "CCNP Notes" and + // "CCNP NOtes" are two directories. Under the schema's default + // (case-insensitive) collation the UNIQUE key on `path` treats them as one + // and the second tree quietly overwrites the first — its videos never get + // indexed. Tightening the collation can only remove collisions, never + // create them, so it is safe to run against existing rows. + foreach (['folders', 'videos', 'quizzes'] as $table) { + $col = $db->query("SHOW FULL COLUMNS FROM `$table` WHERE Field = 'path'")->fetch(); + if ($col && $col['Collation'] !== 'utf8mb4_bin') { + $db->exec("ALTER TABLE `$table` MODIFY path VARCHAR(700) COLLATE utf8mb4_bin NOT NULL"); + } + } +} + +/** + * Does this folder name read as a chapter rather than as a title of its own? + * "01_Welcome", "02 - Creating a VPC", "3. Introduction", "Module 2", "Week 1". + * This is the one signal that separates a course from a category: a folder + * whose children are numbered is the thing those numbers belong to. + */ +function looksOrdered(string $name): bool +{ + return (bool)preg_match( + '/^\s*\d{1,3}\s*[-._)\]]' + . '|^\s*\d{1,3}\s+\S' + . '|^\s*(?:module|section|part|week|chapter|day|lesson|lab|unit|vol)[\s_-]*\d+/i', + $name + ); +} + +/** + * Work out which folders are courses and stamp `is_course` / `course_id`. + * + * Course roots do not sit at a fixed depth. One library can hold + * "System Admin/", "Firewalls/Fortinet/" and + * "Networking/Cisco/CCIE Data Center/DC-Core/" at once, so the level is + * decided per branch, walking down from the top: + * + * - a ".course" / ".category" marker file -> whatever it says + * - nothing to watch in the subtree -> neither, and stop + * - videos or quizzes sitting right here -> course + * - children read as chapters (looksOrdered) -> course + * - otherwise -> category, descend + * + * `$markers` maps a folder path to 'course' or 'category' for the folders that + * carry a marker file. A marker only settles the folder it sits on: once a + * course root is found, everything under it is one of its sections, so to push + * a course root deeper, mark the folder above it ".category". + * + * Everything below a course root carries that root's id in `course_id`, which + * is what lets one grouped query roll up every course at once. Categories above + * every course keep a NULL `course_id` — they are navigation, not material. + */ +function markCourseRoots(PDO $db, array $markers = []): void +{ + $folders = []; + $byDepth = []; // deepest first, so a subtree total is complete before its parent needs it + $roots = []; + $stmt = $db->query('SELECT id, parent_id, name, depth, path FROM folders WHERE is_missing = 0 + ORDER BY depth DESC'); + foreach ($stmt as $r) { + $id = (int)$r['id']; + $folders[$id] = [ + 'parent' => $r['parent_id'] !== null ? (int)$r['parent_id'] : null, + 'name' => $r['name'], + 'marker' => $markers[$r['path']] ?? null, + 'direct' => 0, // videos + quizzes in this folder itself + 'subtree' => 0, // ... and in everything under it + 'kids' => [], + ]; + $byDepth[] = $id; + } + foreach (['videos', 'quizzes'] as $table) { + $rows = $db->query("SELECT folder_id, COUNT(*) AS n FROM `$table` + WHERE is_missing = 0 GROUP BY folder_id"); + foreach ($rows as $r) { + $id = (int)$r['folder_id']; + if (isset($folders[$id])) { + $folders[$id]['direct'] += (int)$r['n']; + } + } + } + foreach ($folders as $id => $f) { + if ($f['parent'] !== null && isset($folders[$f['parent']])) { + $folders[$f['parent']]['kids'][] = $id; + } else { + $roots[] = $id; + } + } + foreach ($byDepth as $id) { + $folders[$id]['subtree'] += $folders[$id]['direct']; + $parent = $folders[$id]['parent']; + if ($parent !== null && isset($folders[$parent])) { + $folders[$parent]['subtree'] += $folders[$id]['subtree']; + } + } + + $isCourse = []; + $courseOf = []; + $stack = array_map(fn($id) => [$id, null], $roots); + while ($stack) { + [$id, $course] = array_pop($stack); + $f = $folders[$id]; + + if ($course !== null) { // already inside a course: a section + $isCourse[$id] = 0; + $courseOf[$id] = $course; + } elseif ($f['subtree'] === 0) { // no videos, no quizzes, nothing to show + $isCourse[$id] = 0; + $courseOf[$id] = null; + } elseif ($f['marker'] === 'course') { // told, not guessed + $isCourse[$id] = 1; + $courseOf[$id] = $id; + } else { + $live = array_values(array_filter($f['kids'], fn($k) => $folders[$k]['subtree'] > 0)); + $chaptered = false; + foreach ($live as $k) { + if (looksOrdered($folders[$k]['name'])) { $chaptered = true; break; } + } + // ".category" says "the courses are further down" — but a folder + // holding videos itself has to be a course or they vanish. + $forcedCategory = $f['marker'] === 'category' && $f['direct'] === 0 && $live; + if (!$forcedCategory && ($f['direct'] > 0 || $chaptered || !$live)) { + $isCourse[$id] = 1; + $courseOf[$id] = $id; // and everything below inherits it + } else { + $isCourse[$id] = 0; + $courseOf[$id] = null; + } + } + foreach ($f['kids'] as $k) { + $stack[] = [$k, $courseOf[$id]]; + } + } + + $db->exec('UPDATE folders SET is_course = 0, course_id = NULL + WHERE is_course <> 0 OR course_id IS NOT NULL'); + $upd = $db->prepare('UPDATE folders SET is_course = ?, course_id = ? WHERE id = ?'); + foreach ($courseOf as $id => $course) { + if ($course === null && !$isCourse[$id]) { + continue; // already reset above + } + $upd->execute([$isCourse[$id], $course, $id]); + } +} + function scanLibrary(): array { $root = rtrim(VIDEOS_ROOT, '/'); @@ -158,11 +321,14 @@ function scanLibrary(): array /** The scan proper — only ever called by scanLibrary() while it holds the lock. */ function scanLibraryLocked(PDO $db, string $root): array { + migrateLibrarySchema($db); // DDL: must happen before the transaction opens + $stats = ['folders' => 0, 'videos' => 0, 'quizzes' => 0, 'missing_folders' => 0, 'missing_videos' => 0, 'missing_quizzes' => 0]; $seenFolderIds = []; $seenVideoIds = []; $seenQuizIds = []; + $markers = []; // folder path => 'course' | 'category', from marker files $upsertFolder = $db->prepare( 'INSERT INTO folders (parent_id, name, path, depth, sort_order, is_missing) @@ -197,9 +363,19 @@ function scanLibraryLocked(PDO $db, string $root): array $walk = function (string $dir, ?int $parentId, string $relBase, int $depth) use (&$walk, $db, $upsertFolder, $upsertVideo, $upsertQuiz, - &$stats, &$seenFolderIds, &$seenVideoIds, &$seenQuizIds): void { + &$stats, &$seenFolderIds, &$seenVideoIds, &$seenQuizIds, &$markers): void { $entries = scandir($dir) ?: []; + // An empty ".course" / ".category" file overrides what markCourseRoots() + // would have guessed for this folder — the escape hatch for material the + // chapter-name heuristic reads wrong. Dot files are skipped below, so + // they are looked for before the filter. + foreach (['course', 'category'] as $marker) { + if ($relBase !== '' && is_file($dir . '/.' . $marker)) { + $markers[$relBase] = $marker; + break; // ".course" wins if somebody wrote both + } + } $entries = array_values(array_filter($entries, fn($e) => $e[0] !== '.')); usort($entries, 'strnatcasecmp'); // "Video2" before "Video10" @@ -258,6 +434,14 @@ function scanLibraryLocked(PDO $db, string $root): array $stats['missing_videos'] = $flagMissing('videos', $seenVideoIds); $stats['missing_quizzes'] = $flagMissing('quizzes', $seenQuizIds); + // Which folders are courses can only be decided once the whole tree and + // its contents are known, so it runs last — inside the same transaction, + // so no request ever sees a library with no courses in it. + markCourseRoots($db, $markers); + $stats['courses'] = (int)$db->query( + 'SELECT COUNT(*) FROM folders WHERE is_course = 1 AND is_missing = 0' + )->fetchColumn(); + $db->commit(); } catch (Throwable $e) { $db->rollBack(); @@ -341,18 +525,78 @@ function folderRollup(int $userId, string $folderPath): array $stmt->execute($args); $quizzes = $stmt->fetch(); - $total = (int)$videos['total'] + (int)$quizzes['total']; - $credit = $videos['credit'] + $quizzes['credit']; + return composeRollup($videos, $quizzes); +} - return [ - 'videos' => (int)$videos['total'], - 'quizzes' => (int)$quizzes['total'], - 'duration' => (float)$videos['duration'], - 'bytes' => (int)$videos['bytes'], - 'total' => $total, - 'completed' => (int)$videos['completed'] + (int)$quizzes['completed'], - 'percent' => $total > 0 ? (int)round(100 * $credit / $total) : 0, - ]; +/** + * folderRollup() for every child of one folder, in two queries instead of two + * per child — a course with thirty sections, or a category with thirty courses, + * is one page either way. + * + * Keyed by child folder *name*, which the filesystem already guarantees is + * unique inside a directory. Children with nothing under them are absent. + */ +function childRollups(int $userId, string $folderPath): array +{ + // The first path segment below $folderPath: "Course/Sec/Lesson" -> "Sec". + $seg = "SUBSTRING_INDEX(SUBSTRING(f.path, CHAR_LENGTH(?) + 2), '/', 1)"; + $args = fn() => [$folderPath, $userId, $folderPath, likePrefix($folderPath) . '/%']; + + $stmt = db()->prepare( + "SELECT $seg AS seg, + COUNT(*) AS total, + COALESCE(SUM(COALESCE(v.duration_seconds, vp.duration_seconds)), 0) AS duration, + COALESCE(SUM(v.size_bytes), 0) AS bytes, + COALESCE(SUM(vp.completed), 0) AS completed, + COALESCE(SUM( + CASE + WHEN vp.completed = 1 THEN 1 + WHEN vp.duration_seconds > 0 + THEN LEAST(vp.position_seconds / vp.duration_seconds, 1) + ELSE 0 + END + ), 0) AS credit + FROM videos v + JOIN folders f ON f.id = v.folder_id + LEFT JOIN video_progress vp ON vp.video_id = v.id AND vp.user_id = ? + WHERE v.is_missing = 0 AND f.is_missing = 0 + AND f.path <> ? AND f.path LIKE ? + GROUP BY seg" + ); + $stmt->execute($args()); + $videos = []; + foreach ($stmt as $r) { $videos[$r['seg']] = $r; } + + $stmt = db()->prepare( + "SELECT $seg AS seg, + COUNT(*) AS total, + 0 AS duration, 0 AS bytes, + COALESCE(SUM(qp.completed), 0) AS completed, + COALESCE(SUM( + CASE + WHEN qp.completed = 1 THEN 1 + WHEN q.num_questions > 0 + THEN LEAST(COALESCE(qp.best_score, 0) / q.num_questions, 1) + ELSE 0 + END + ), 0) AS credit + FROM quizzes q + JOIN folders f ON f.id = q.folder_id + LEFT JOIN quiz_progress qp ON qp.quiz_id = q.id AND qp.user_id = ? + WHERE q.is_missing = 0 AND f.is_missing = 0 + AND f.path <> ? AND f.path LIKE ? + GROUP BY seg" + ); + $stmt->execute($args()); + $quizzes = []; + foreach ($stmt as $r) { $quizzes[$r['seg']] = $r; } + + $blank = ['total' => 0, 'duration' => 0, 'bytes' => 0, 'completed' => 0, 'credit' => 0]; + $out = []; + foreach (array_keys($videos + $quizzes) as $name) { + $out[$name] = composeRollup($videos[$name] ?? $blank, $quizzes[$name] ?? $blank); + } + return $out; } /** @@ -366,12 +610,112 @@ function folderProgress(int $userId, string $folderPath): array return ['total' => $r['total'], 'completed' => $r['completed'], 'percent' => $r['percent']]; } -/** A course (or section) as the library screens show it: contents + progress. */ -function courseSummary(int $userId, array $folder): array +/** An empty rollup, for a folder that holds nothing (yet). */ +function emptyRollup(): array { - $r = folderRollup($userId, $folder['path']); - $percent = $r['percent']; + return ['videos' => 0, 'quizzes' => 0, 'duration' => 0.0, 'bytes' => 0, + 'total' => 0, 'completed' => 0, 'percent' => 0]; +} +/** Fold the two halves of a rollup (videos, quizzes) into the public shape. */ +function composeRollup(array $v, array $q): array +{ + $total = (int)$v['total'] + (int)$q['total']; + $credit = (float)$v['credit'] + (float)$q['credit']; + return [ + 'videos' => (int)$v['total'], + 'quizzes' => (int)$q['total'], + 'duration' => (float)$v['duration'], + 'bytes' => (int)$v['bytes'], + 'total' => $total, + 'completed' => (int)$v['completed'] + (int)$q['completed'], + 'percent' => $total > 0 ? (int)round(100 * $credit / $total) : 0, + ]; +} + +/** Add one rollup into another (used to total a category from its courses). */ +function addRollup(array $a, array $b): array +{ + foreach (['videos', 'quizzes', 'duration', 'bytes', 'total', 'completed'] as $k) { + $a[$k] += $b[$k]; + } + $a['_credit'] = ($a['_credit'] ?? 0) + ($b['_credit'] ?? 0); + $a['percent'] = $a['total'] > 0 ? (int)round(100 * $a['_credit'] / $a['total']) : 0; + return $a; +} + +/** + * folderRollup() for every course at once, keyed by course id. + * + * A per-course card used to cost two prefix-matched queries. That was fine when + * a course meant a top-level folder and there were a handful; with course roots + * found at their real depth there are hundreds, and 2N queries per page load is + * not a thing worth doing. `folders.course_id` turns the prefix match into a + * GROUP BY, so the whole library is two queries. + */ +function courseRollups(int $userId): array +{ + $videos = db()->prepare( + "SELECT f.course_id AS cid, + COUNT(*) AS total, + COALESCE(SUM(COALESCE(v.duration_seconds, vp.duration_seconds)), 0) AS duration, + COALESCE(SUM(v.size_bytes), 0) AS bytes, + COALESCE(SUM(vp.completed), 0) AS completed, + COALESCE(SUM( + CASE + WHEN vp.completed = 1 THEN 1 + WHEN vp.duration_seconds > 0 + THEN LEAST(vp.position_seconds / vp.duration_seconds, 1) + ELSE 0 + END + ), 0) AS credit + FROM videos v + JOIN folders f ON f.id = v.folder_id + LEFT JOIN video_progress vp ON vp.video_id = v.id AND vp.user_id = ? + WHERE v.is_missing = 0 AND f.is_missing = 0 AND f.course_id IS NOT NULL + GROUP BY f.course_id" + ); + $videos->execute([$userId]); + + $quizzes = db()->prepare( + "SELECT f.course_id AS cid, + COUNT(*) AS total, + 0 AS duration, 0 AS bytes, + COALESCE(SUM(qp.completed), 0) AS completed, + COALESCE(SUM( + CASE + WHEN qp.completed = 1 THEN 1 + WHEN q.num_questions > 0 + THEN LEAST(COALESCE(qp.best_score, 0) / q.num_questions, 1) + ELSE 0 + END + ), 0) AS credit + FROM quizzes q + JOIN folders f ON f.id = q.folder_id + LEFT JOIN quiz_progress qp ON qp.quiz_id = q.id AND qp.user_id = ? + WHERE q.is_missing = 0 AND f.is_missing = 0 AND f.course_id IS NOT NULL + GROUP BY f.course_id" + ); + $quizzes->execute([$userId]); + + $blank = ['total' => 0, 'duration' => 0, 'bytes' => 0, 'completed' => 0, 'credit' => 0]; + $v = $q = []; + foreach ($videos as $r) { $v[(int)$r['cid']] = $r; } + foreach ($quizzes as $r) { $q[(int)$r['cid']] = $r; } + + $out = []; + foreach (array_keys($v + $q) as $cid) { + $half = composeRollup($v[$cid] ?? $blank, $q[$cid] ?? $blank); + $half['_credit'] = (float)($v[$cid]['credit'] ?? 0) + (float)($q[$cid]['credit'] ?? 0); + $out[$cid] = $half; + } + return $out; +} + +/** The card shape the library screens render, from a folder row and its rollup. */ +function summaryFrom(array $folder, array $r): array +{ + $percent = $r['percent']; return [ 'id' => (int)$folder['id'], 'name' => $folder['name'], @@ -382,16 +726,85 @@ function courseSummary(int $userId, array $folder): array 'bytes' => $r['bytes'], 'added_at' => $folder['created_at'] ?? null, 'progress' => ['total' => $r['total'], 'completed' => $r['completed'], 'percent' => $percent], - // The shelves the library groups by — the app has no course categories, - // so progress is what separates one shelf from another. + // The shelves the library groups by — courses carry no categories of + // their own, so progress is what separates one shelf from another. 'state' => $percent >= 100 ? 'finished' : ($percent > 0 ? 'progress' : 'fresh'), ]; } -/** Every top-level folder as a course card. */ +/** A course (or section) as the library screens show it: contents + progress. */ +function courseSummary(int $userId, array $folder): array +{ + return summaryFrom($folder, folderRollup($userId, $folder['path'])); +} + +/** Every folder the scanner marked as a course root, shallowest first. */ +function courseFolders(): array +{ + return db()->query( + 'SELECT * FROM folders WHERE is_course = 1 AND is_missing = 0 ORDER BY depth, path' + )->fetchAll(); +} + +/** Every course in the library as a card. Two queries for the lot. */ function courseCards(int $userId): array { - return array_map(fn($f) => courseSummary($userId, $f), childFolders(null)); + $rollups = courseRollups($userId); + return array_map( + fn($f) => summaryFrom($f, $rollups[(int)$f['id']] ?? emptyRollup()), + courseFolders() + ); +} + +/** + * The folders that sit above courses: the library's navigation tier. + * `$parentId` null lists the top of the tree. Each card totals the courses + * beneath it, so a category shows real progress rather than a flat 0 %. + */ +function categoryCards(int $userId, ?int $parentId = null, ?array $rollups = null): array +{ + $rollups ??= courseRollups($userId); + $courses = courseFolders(); + + $out = []; + foreach (childFolders($parentId) as $f) { + if ((int)$f['is_course'] === 1) { + continue; // a course, not a category + } + $prefix = $f['path'] . '/'; + $total = emptyRollup(); + $count = 0; + foreach ($courses as $c) { + if (str_starts_with($c['path'], $prefix)) { + $total = addRollup($total, $rollups[(int)$c['id']] ?? emptyRollup()); + $count++; + } + } + if ($count === 0) { + continue; // nothing watchable under it + } + $card = summaryFrom($f, $total); + $card['kind'] = 'category'; + $card['courses'] = $count; + $out[] = $card; + } + return $out; +} + +/** Courses that live directly under $parentId (null = the top of the tree). */ +function courseCardsUnder(int $userId, ?int $parentId = null, ?array $rollups = null): array +{ + $rollups ??= courseRollups($userId); + $out = []; + foreach (childFolders($parentId) as $f) { + if ((int)$f['is_course'] !== 1) { + continue; + } + $card = summaryFrom($f, $rollups[(int)$f['id']] ?? emptyRollup()); + $card['kind'] = 'course'; + $out[] = $card; + } + return $out; } /** Headline numbers for the home screen, computed from the course cards. */ @@ -439,19 +852,20 @@ function userStats(int $userId, array $courses): array } /** Courses the scanner indexed most recently — the "found in the last scan" strip. */ -function recentCourses(int $userId, int $limit = 3): array +function recentCourses(int $userId, int $limit = 3, ?array $rollups = null): array { + $rollups ??= courseRollups($userId); $stmt = db()->prepare( 'SELECT * FROM folders - WHERE parent_id IS NULL AND is_missing = 0 + WHERE is_course = 1 AND is_missing = 0 ORDER BY created_at DESC, id DESC LIMIT ' . (int)$limit ); $stmt->execute(); $week = time() - 7 * 86400; - return array_map(function ($f) use ($userId, $week) { - $c = courseSummary($userId, $f); + return array_map(function ($f) use ($rollups, $week) { + $c = summaryFrom($f, $rollups[(int)$f['id']] ?? emptyRollup()); $c['is_new'] = $f['created_at'] !== null && strtotime($f['created_at']) >= $week; return $c; }, $stmt->fetchAll()); @@ -461,19 +875,34 @@ function recentCourses(int $userId, int $limit = 3): array function libraryTotals(): array { $row = db()->query( - 'SELECT (SELECT COUNT(*) FROM folders WHERE parent_id IS NULL AND is_missing = 0) AS courses, + 'SELECT (SELECT COUNT(*) FROM folders WHERE is_course = 1 AND is_missing = 0) AS courses, (SELECT COUNT(*) FROM videos WHERE is_missing = 0) AS videos, (SELECT COUNT(*) FROM quizzes WHERE is_missing = 0) AS quizzes, (SELECT COALESCE(SUM(size_bytes), 0) FROM videos WHERE is_missing = 0) AS bytes, (SELECT COALESCE(SUM(duration_seconds), 0) FROM videos WHERE is_missing = 0) AS duration' )->fetch(); + // Only the categories that actually lead somewhere: a folder with no course + // anywhere beneath it (a stray "Books" of PDFs) is not part of the library. + $categories = (int)db()->query( + 'WITH RECURSIVE ancestors AS ( + SELECT parent_id AS id FROM folders + WHERE is_course = 1 AND is_missing = 0 AND parent_id IS NOT NULL + UNION + SELECT f.parent_id FROM folders f + JOIN ancestors a ON f.id = a.id + WHERE f.parent_id IS NOT NULL + ) + SELECT COUNT(DISTINCT id) FROM ancestors' + )->fetchColumn(); + return [ - 'courses' => (int)$row['courses'], - 'videos' => (int)$row['videos'], - 'quizzes' => (int)$row['quizzes'], - 'bytes' => (int)$row['bytes'], - 'duration' => (float)$row['duration'], + 'courses' => (int)$row['courses'], + 'categories' => $categories, + 'videos' => (int)$row['videos'], + 'quizzes' => (int)$row['quizzes'], + 'bytes' => (int)$row['bytes'], + 'duration' => (float)$row['duration'], ]; } @@ -594,6 +1023,11 @@ function resetProgress(int $userId, string $scope, int $id = 0): array /* --------------------------------------------------------------------------- * Search — substring match on names (and on the relative path, so * "Course 1/Intro" finds everything under it). + * + * `path` is stored utf8mb4_bin so that two folders differing only in case stay + * two folders. That makes LIKE on it case-sensitive, which is right for + * identifying a file and wrong for a search box, so the search — and only the + * search — asks for the case-insensitive collation back. * ------------------------------------------------------------------------- */ function searchFolders(string $term, int $limit = 25): array @@ -601,7 +1035,8 @@ function searchFolders(string $term, int $limit = 25): array $like = likeContains($term); $stmt = db()->prepare( 'SELECT * FROM folders - WHERE is_missing = 0 AND (name LIKE ? OR path LIKE ?) + WHERE is_missing = 0 + AND (name LIKE ? OR path COLLATE utf8mb4_unicode_ci LIKE ?) ORDER BY depth, path LIMIT ' . (int)$limit ); @@ -618,7 +1053,8 @@ function searchVideos(int $userId, string $term, int $limit = 50): array FROM videos v JOIN folders f ON f.id = v.folder_id LEFT JOIN video_progress vp ON vp.video_id = v.id AND vp.user_id = ? - WHERE v.is_missing = 0 AND f.is_missing = 0 AND (v.name LIKE ? OR v.path LIKE ?) + WHERE v.is_missing = 0 AND f.is_missing = 0 + AND (v.name LIKE ? OR v.path COLLATE utf8mb4_unicode_ci LIKE ?) ORDER BY f.path, v.sort_order, v.name LIMIT ' . (int)$limit ); @@ -635,7 +1071,8 @@ function searchQuizzes(int $userId, string $term, int $limit = 25): array FROM quizzes q JOIN folders f ON f.id = q.folder_id LEFT JOIN quiz_progress qp ON qp.quiz_id = q.id AND qp.user_id = ? - WHERE q.is_missing = 0 AND f.is_missing = 0 AND (q.name LIKE ? OR q.path LIKE ?) + WHERE q.is_missing = 0 AND f.is_missing = 0 + AND (q.name LIKE ? OR q.path COLLATE utf8mb4_unicode_ci LIKE ?) ORDER BY f.path, q.sort_order, q.name LIMIT ' . (int)$limit ); @@ -807,11 +1244,32 @@ function continueWatching(int $userId, int $limit = 6): array return $stmt->fetchAll(); } -/** The top-level course a folder belongs to (itself, if it's already top-level). */ +/** + * The course a folder belongs to — itself if it is one, otherwise the course + * root above it. Stamped on every folder by the scanner, so this is a lookup + * rather than a walk; a category above every course has none, and answers with + * its own id so callers always get something to link to. + */ function courseIdOf(array $folder): int { - $crumbs = folderBreadcrumbs($folder); - return (int)$crumbs[0]['id']; + return $folder['course_id'] !== null ? (int)$folder['course_id'] : (int)$folder['id']; +} + +/** Is this folder one the library presents as a course? */ +function isCourseFolder(array $folder): bool +{ + return (int)($folder['is_course'] ?? 0) === 1; +} + +/** + * What a folder is, for the screen that has to draw it: a course root, a + * section inside one, or a category of courses above them. + */ +function folderKind(array $folder): string +{ + if (isCourseFolder($folder)) return 'course'; + if ($folder['course_id'] !== null) return 'section'; + return 'category'; } function formatDuration(?float $seconds): string diff --git a/scan.php b/scan.php index 6f88db7..ecff088 100644 --- a/scan.php +++ b/scan.php @@ -9,6 +9,7 @@ if (PHP_SAPI !== 'cli') { } $stats = scanLibrary(); -echo "Scan complete: {$stats['folders']} folders, {$stats['videos']} videos, {$stats['quizzes']} quizzes" +echo "Scan complete: {$stats['courses']} courses in {$stats['folders']} folders," + . " {$stats['videos']} videos, {$stats['quizzes']} quizzes" . " ({$stats['missing_folders']} folders / {$stats['missing_videos']} videos" . " / {$stats['missing_quizzes']} quizzes flagged missing)\n"; diff --git a/schema.sql b/schema.sql index bb47d28..7fc1252 100644 --- a/schema.sql +++ b/schema.sql @@ -31,18 +31,35 @@ CREATE TABLE IF NOT EXISTS app_meta ( -- One row per directory found under VIDEOS_ROOT. -- `path` is the relative path from VIDEOS_ROOT, e.g. "Course 1/Intro". +-- +-- Paths are utf8mb4_bin: they identify a file on a case-sensitive filesystem, +-- so "CCNP Notes" and "CCNP NOtes" are two directories, not one. Under the +-- table's default (case-insensitive) collation the UNIQUE key merges them and +-- the second tree silently overwrites the first. +-- +-- `is_course` marks the folder the library shows as a course. Course roots sit +-- at whatever depth the material happens to use — a top-level folder may be a +-- category (Networking/Cisco/) or a course in its own right — so the +-- scanner works it out per branch rather than assuming a fixed depth; see +-- markCourseRoots(). `course_id` is the nearest course at-or-above the folder +-- (NULL for the categories above every course), which is what makes a course's +-- rollup one grouped query instead of one query per course. CREATE TABLE IF NOT EXISTS folders ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, parent_id INT UNSIGNED NULL, name VARCHAR(255) NOT NULL, - path VARCHAR(700) NOT NULL UNIQUE, + path VARCHAR(700) COLLATE utf8mb4_bin NOT NULL UNIQUE, depth INT UNSIGNED NOT NULL DEFAULT 0, sort_order INT UNSIGNED NOT NULL DEFAULT 0, + is_course TINYINT(1) NOT NULL DEFAULT 0, + course_id INT UNSIGNED NULL, is_missing TINYINT(1) NOT NULL DEFAULT 0, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_folders_parent FOREIGN KEY (parent_id) REFERENCES folders(id) ON DELETE CASCADE, - INDEX idx_folders_parent (parent_id) + INDEX idx_folders_parent (parent_id), + INDEX idx_folders_course (course_id), + INDEX idx_folders_is_course (is_course, is_missing) ) ENGINE=InnoDB; CREATE TABLE IF NOT EXISTS videos ( @@ -50,7 +67,7 @@ CREATE TABLE IF NOT EXISTS videos ( folder_id INT UNSIGNED NOT NULL, name VARCHAR(255) NOT NULL, -- display name, filename without .mp4 filename VARCHAR(255) NOT NULL, - path VARCHAR(700) NOT NULL UNIQUE, -- relative path incl. filename + path VARCHAR(700) COLLATE utf8mb4_bin NOT NULL UNIQUE, -- relative path incl. filename size_bytes BIGINT UNSIGNED NOT NULL DEFAULT 0, duration_seconds FLOAT NULL, -- filled in by the player on first load sort_order INT UNSIGNED NOT NULL DEFAULT 0, @@ -69,7 +86,7 @@ CREATE TABLE IF NOT EXISTS quizzes ( folder_id INT UNSIGNED NOT NULL, name VARCHAR(255) NOT NULL, -- filename without the _quiz_solved.txt suffix filename VARCHAR(255) NOT NULL, - path VARCHAR(700) NOT NULL UNIQUE, + path VARCHAR(700) COLLATE utf8mb4_bin NOT NULL UNIQUE, num_questions INT UNSIGNED NOT NULL DEFAULT 0, sort_order INT UNSIGNED NOT NULL DEFAULT 0, is_missing TINYINT(1) NOT NULL DEFAULT 0,