090d7fa94c
Course metadata: a scan now gives every course a course_meta row from the downloader's sidecar JSON when the folder has one, else from catalog_courses matched by the folder name the downloader would create (folderKeyFor, a port of ine.py's sanitize_name). tools/build-catalog-seed.php turns the INE export into seeds/ine_catalog.sql, an idempotent patch that creates its own table and upserts by id, so it can be applied to the live server before or after the code and re-run freely. Course pages show the blurb, difficulty, vendor and tags; search matches tags and blurbs; Settings reports how each course got its details. Against the live library 163 of 179 courses match. Course advisor, off by default: api/advisor.php answers "which course should I take to learn X / what comes before Y" with courses from the library, in order, plus vendor courses not yet downloaded. includes/ai_client.php is a small curl wrapper for any OpenAI-format chat endpoint (DeepSeek V4 Flash by default, no composer); the prompt is a byte-stable catalogue of the library so the provider's prefix cache covers it. Every course id the model returns is validated server-side, a bad JSON reply gets one retry then a plain-text fallback, and a per-user daily cap with token counts lives in advisor_requests. With AI_ENABLED false the app makes no outbound requests and the box does not appear; users can also hide it per browser. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012enquL2RZdjy3D5AcKP5yx
305 lines
13 KiB
PHP
305 lines
13 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';
|
|
require_once __DIR__ . '/../includes/advisor.php'; // aiEnabled() and the usage counters only
|
|
|
|
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(),
|
|
'advisor' => aiEnabled(),
|
|
]);
|
|
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']],
|
|
// What the catalogue or the sidecar file says about a course; null
|
|
// for sections, categories and courses that came from elsewhere.
|
|
'meta' => $kind === 'course' ? courseMetaPublic(courseMeta((int)$folder['id'])) : null,
|
|
'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,
|
|
'catalog_courses' => catalogCourseCount(),
|
|
'course_meta_counts' => json_decode((string)appMetaGet('course_meta_counts'), true) ?: null,
|
|
'advisor' => [
|
|
'enabled' => aiEnabled(),
|
|
'model' => aiEnabled() ? aiModel() : null,
|
|
'used_today' => aiEnabled() ? advisorUsedToday($userId) : 0,
|
|
'limit' => advisorLimit(),
|
|
],
|
|
]);
|
|
break;
|
|
|
|
default:
|
|
http_response_code(400);
|
|
echo json_encode(['error' => 'Unknown view']);
|
|
}
|