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:
+63
-30
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user