Detect course roots per branch and add a category layer above them

The scanner no longer assumes courses sit at a fixed depth. It walks
each branch and marks a folder is_course when it holds media directly
or its children read as chapters (01_, 1. , Module 2, Week 3);
everything above that is a category, and every folder still gets a
course_id pointing at its nearest course for cheap rollups. An empty
.course/.category marker file overrides the heuristic per folder.

Also:
- Roll up progress for the whole library in two grouped queries
  instead of one prefix-matched query per course/folder card.
- Store folders/videos/quizzes paths as utf8mb4_bin so folders that
  differ only in case (CCNP Notes vs CCNP NOtes) aren't merged by a
  case-insensitive UNIQUE key.
- migrateLibrarySchema() brings an older install's tables up to date
  at scan time, so there's no separate migration step.
- Library UI gains a categories view alongside shelves/catalog; the
  continue-watching strip's play button now seeks to the saved
  position on open.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FfMPJK7TQV18JhFv6yTKev
This commit is contained in:
root
2026-09-08 18:51:04 +00:00
parent 2fa7452253
commit 6a09ae3833
7 changed files with 849 additions and 115 deletions
+63 -30
View File
@@ -1,8 +1,10 @@
<?php
// GET api/library.php?view=meta -> 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;