Files
coursehub/api/library.php
T
root 04314327db 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.

Claude-Session: https://claude.ai/code/session_01FfMPJK7TQV18JhFv6yTKev
2026-09-08 18:51:04 +00:00

292 lines
12 KiB
PHP

<?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, 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
require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/functions.php';
header('Content-Type: application/json');
$userId = requireLoginApi();
function folderSummary(array $f, int $userId): array
{
return [
'id' => (int)$f['id'],
'name' => $f['name'],
'progress' => folderProgress($userId, $f['path']),
];
}
/**
* 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;
$position = (float)($v['position_seconds'] ?? 0);
return [
'id' => (int)$v['id'],
'name' => $v['name'],
'filename' => $v['filename'],
'duration' => $duration !== null ? (float)$duration : null,
'position' => $position,
'completed' => (bool)($v['completed'] ?? false),
'percent' => ($duration ?? 0) > 0 ? min(100, (int)round(100 * $position / $duration)) : 0,
];
}
function quizSummary(array $q): array
{
return [
'id' => (int)$q['id'],
'name' => $q['name'],
'filename' => $q['filename'],
'questions' => (int)$q['num_questions'],
'attempts' => (int)($q['attempts'] ?? 0),
'best_score' => (int)($q['best_score'] ?? 0),
'completed' => (bool)($q['completed'] ?? false),
];
}
function crumbList(array $folder): array
{
return array_map(
fn($c) => ['id' => (int)$c['id'], 'name' => $c['name']],
folderBreadcrumbs($folder)
);
}
$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) {
$counts[$c['state']]++;
}
echo json_encode([
'totals' => libraryTotals(),
'shelves' => [
['key' => 'progress', 'name' => 'In progress', 'count' => $counts['progress']],
['key' => 'finished', 'name' => 'Finished', 'count' => $counts['finished']],
['key' => 'fresh', 'name' => 'Not started', 'count' => $counts['fresh']],
],
'last_scan' => appMetaGet('last_scan_at'),
'username' => currentUsername(),
]);
break;
case 'home':
$rollups = courseRollups($userId);
$courses = courseCards($userId);
$resume = continueWatching($userId, 1);
$resumeCourseId = null;
if ($resume) {
$resumeFolder = getFolder((int)$resume[0]['folder_id']);
if ($resumeFolder) $resumeCourseId = courseIdOf($resumeFolder);
}
echo json_encode([
'stats' => userStats($userId, $courses),
'resume' => $resume ? [
'id' => (int)$resume[0]['id'],
'course_id' => $resumeCourseId,
'name' => $resume[0]['name'],
'folder_path' => $resume[0]['folder_path'],
'position' => (float)$resume[0]['position_seconds'],
'duration' => $resume[0]['duration_seconds'] !== null
? (float)$resume[0]['duration_seconds'] : null,
] : null,
'continue' => array_map(fn($r) => [
'id' => (int)$r['id'],
'name' => $r['name'],
'folder_path' => $r['folder_path'],
'position' => (float)$r['position_seconds'],
'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, 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,
'categories' => categoryCards($userId, null, $rollups),
'top_courses' => courseCardsUnder($userId, null, $rollups),
'totals' => libraryTotals(),
]);
break;
case 'folder':
$folder = getFolder((int)($_GET['id'] ?? 0));
if (!$folder) {
http_response_code(404);
echo json_encode(['error' => 'Folder not found']);
break;
}
$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'], 'kind' => $kind],
'kind' => $kind,
'course_id' => courseIdOf($folder),
'breadcrumbs' => $crumbs,
'rollup' => $rollup,
'progress' => ['total' => $rollup['total'], 'completed' => $rollup['completed'],
'percent' => $rollup['percent']],
'categories' => $categories,
'courses' => $courses,
'subfolders' => $subfolders,
'videos' => array_map('videoSummary',
folderVideos((int)$folder['id'], $userId)),
'quizzes' => array_map('quizSummary',
folderQuizzes((int)$folder['id'], $userId)),
]);
break;
case 'video':
$video = getVideo((int)($_GET['id'] ?? 0));
if (!$video) {
http_response_code(404);
echo json_encode(['error' => 'Video not found']);
break;
}
$folder = getFolder((int)$video['folder_id']);
$playlist = folderVideos((int)$folder['id'], $userId);
$crumbs = crumbList($folder);
$stmt = db()->prepare(
'SELECT position_seconds, completed FROM video_progress
WHERE user_id = ? AND video_id = ?'
);
$stmt->execute([$userId, (int)$video['id']]);
$saved = $stmt->fetch() ?: null;
$resumeAt = ($saved && !$saved['completed'] && $saved['position_seconds'] > 5)
? (float)$saved['position_seconds'] : 0;
$prevId = $nextId = null;
foreach ($playlist as $i => $p) {
if ((int)$p['id'] === (int)$video['id']) {
$prevId = isset($playlist[$i - 1]) ? (int)$playlist[$i - 1]['id'] : null;
$nextId = isset($playlist[$i + 1]) ? (int)$playlist[$i + 1]['id'] : null;
break;
}
}
echo json_encode([
'video' => ['id' => (int)$video['id'], 'name' => $video['name'],
'duration' => $video['duration_seconds'] !== null
? (float)$video['duration_seconds'] : null],
'folder' => ['id' => (int)$folder['id'], 'name' => $folder['name']],
'course_id' => courseIdOf($folder),
'breadcrumbs' => $crumbs,
'playlist' => array_map('videoSummary', $playlist),
'quizzes' => array_map('quizSummary', folderQuizzes((int)$folder['id'], $userId)),
'resume_at' => $resumeAt,
'prev_id' => $prevId,
'next_id' => $nextId,
'stream_url' => 'stream.php?id=' . (int)$video['id'],
'captions_url' => srtFilePath($video) !== null
? 'captions.php?id=' . (int)$video['id'] : null,
]);
break;
case 'search':
$q = trim((string)($_GET['q'] ?? ''));
if ($q === '') {
echo json_encode(['query' => '', 'folders' => [], 'videos' => [], 'quizzes' => []]);
break;
}
$withPath = fn(array $row, array $summary) =>
$summary + ['folder_path' => $row['folder_path']];
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) + ['kind' => folderKind($f)],
searchFolders($q)
),
'videos' => array_map(
fn($v) => $withPath($v, videoSummary($v)),
searchVideos($userId, $q)
),
'quizzes' => array_map(
fn($q2) => $withPath($q2, quizSummary($q2)),
searchQuizzes($userId, $q)
),
]);
break;
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, '/'),
'root_exists' => is_dir(VIDEOS_ROOT),
'totals' => libraryTotals(),
'last_scan' => appMetaGet('last_scan_at'),
'complete_threshold' => (int)round(100 * COMPLETE_THRESHOLD),
'registration_open' => (bool)ALLOW_REGISTRATION,
'username' => currentUsername(),
'progress_courses' => count($withSome),
'courses' => count($courses),
'shelf_counts' => $counts,
]);
break;
default:
http_response_code(400);
echo json_encode(['error' => 'Unknown view']);
}