UI improved

This commit is contained in:
Harwinder Sekhon
2026-07-27 13:58:47 -04:00
parent e78f2a81e0
commit 31b01a1a18
11 changed files with 3257 additions and 412 deletions
+442 -10
View File
@@ -147,6 +147,7 @@ function scanLibrary(): array
$seenFolderIds = [];
$seenVideoIds = [];
$seenQuizIds = [];
$firstVideo = []; // courseFolderId => ['id' => videoId, 'path' => relPath]
$upsertFolder = $db->prepare(
'INSERT INTO folders (parent_id, name, path, depth, sort_order, is_missing)
@@ -172,9 +173,14 @@ function scanLibrary(): array
id = LAST_INSERT_ID(id)'
);
$walk = function (string $dir, ?int $parentId, string $relBase, int $depth)
// $courseId identifies which top-level course folder the current directory
// sits under (null while walking VIDEOS_ROOT itself); it's set the moment
// we descend into a depth-0 folder and passed unchanged to every
// descendant call, so the first mp4 found anywhere in a course's subtree
// — in true directory-listing order — is recorded exactly once.
$walk = function (string $dir, ?int $parentId, string $relBase, int $depth, ?int $courseId)
use (&$walk, $db, $upsertFolder, $upsertVideo, $upsertQuiz,
&$stats, &$seenFolderIds, &$seenVideoIds, &$seenQuizIds): void {
&$stats, &$seenFolderIds, &$seenVideoIds, &$seenQuizIds, &$firstVideo): void {
$entries = scandir($dir) ?: [];
$entries = array_values(array_filter($entries, fn($e) => $e[0] !== '.'));
@@ -192,15 +198,19 @@ function scanLibrary(): array
$folderId = (int)$db->lastInsertId();
$seenFolderIds[] = $folderId;
$stats['folders']++;
$walk($full, $folderId, $rel, $depth + 1);
$walk($full, $folderId, $rel, $depth + 1, $courseId ?? $folderId);
} elseif (is_file($full) && strtolower(pathinfo($entry, PATHINFO_EXTENSION)) === 'mp4') {
if ($parentId === null) {
continue; // mp4 directly in the root is skipped; use course folders
}
$name = pathinfo($entry, PATHINFO_FILENAME);
$upsertVideo->execute([$parentId, $name, $entry, $rel, filesize($full) ?: 0, $videoSort++]);
$seenVideoIds[] = (int)$db->lastInsertId();
$videoId = (int)$db->lastInsertId();
$seenVideoIds[] = $videoId;
$stats['videos']++;
if ($courseId !== null && !isset($firstVideo[$courseId])) {
$firstVideo[$courseId] = ['id' => $videoId, 'path' => $rel];
}
} elseif (is_file($full) && preg_match('/_quiz_solved\.txt$/i', $entry)) {
if ($parentId === null) {
continue;
@@ -219,7 +229,7 @@ function scanLibrary(): array
$db->beginTransaction();
try {
$walk($root, null, '', 0);
$walk($root, null, '', 0, null);
// Anything not seen this pass no longer exists on disk.
$flagMissing = function (string $table, array $seenIds) use ($db): int {
@@ -257,24 +267,152 @@ function scanLibrary(): array
}
}
// One thumbnail per course, captured from its first video (in the same
// order $firstVideo was built above) at THUMBNAIL_SEEK_PERCENT. Courses
// whose first video hasn't changed since last time are skipped.
$thumbStats = generateThumbnails($firstVideo);
$stats['thumbnails_generated'] = $thumbStats['generated'];
$stats['thumbnails_failed'] = $thumbStats['failed'];
appMetaSet('last_scan_at', gmdate('Y-m-d H:i:s'));
return $stats;
}
/* ---------------------------------------------------------------------------
* Course thumbnails — one frame captured from each course's first video (in
* scan order) at THUMBNAIL_SEEK_PERCENT of that video's duration, cached as a
* jpg named by folder id and served by thumb.php.
* ------------------------------------------------------------------------- */
/**
* folders.thumbnail_video_id/thumbnail_generated_at may not exist yet on a
* database created before this feature — self-heal the same way appMetaSet()
* lazily creates app_meta, so a git pull doesn't require a manual migration.
*/
function ensureThumbnailColumns(PDO $db): void
{
static $checked = false;
if ($checked) return;
try {
$db->query('SELECT thumbnail_video_id, thumbnail_generated_at FROM folders LIMIT 1');
} catch (Throwable $e) {
$db->exec('ALTER TABLE folders
ADD COLUMN thumbnail_video_id INT UNSIGNED NULL,
ADD COLUMN thumbnail_generated_at TIMESTAMP NULL');
}
$checked = true;
}
/**
* Generate (or refresh) each course's thumbnail from $firstVideo, the
* courseFolderId => ['id' => videoId, 'path' => relPath] map scanLibrary()
* builds while walking the tree. Returns ['generated' => n, 'skipped' => n,
* 'failed' => n].
*/
function generateThumbnails(array $firstVideo): array
{
$stats = ['generated' => 0, 'skipped' => 0, 'failed' => 0];
if (!$firstVideo) return $stats;
$dir = rtrim(THUMBNAILS_DIR, '/');
if (!is_dir($dir) && !@mkdir($dir, 0775, true) && !is_dir($dir)) {
error_log("generateThumbnails: cannot create THUMBNAILS_DIR ($dir) — skipping");
$stats['failed'] = count($firstVideo);
return $stats;
}
$db = db();
ensureThumbnailColumns($db);
$upd = $db->prepare(
'UPDATE folders SET thumbnail_video_id = ?, thumbnail_generated_at = UTC_TIMESTAMP() WHERE id = ?'
);
foreach ($firstVideo as $courseId => $pick) {
$course = getFolder($courseId);
if (!$course) continue; // course itself went missing this scan
$thumbFile = "$dir/$courseId.jpg";
$alreadyCurrent = (int)($course['thumbnail_video_id'] ?? 0) === $pick['id'] && is_file($thumbFile);
if ($alreadyCurrent) {
$stats['skipped']++;
continue;
}
$abs = realpath(rtrim(VIDEOS_ROOT, '/') . '/' . $pick['path']);
$videoRoot = realpath(VIDEOS_ROOT);
if ($abs === false || $videoRoot === false
|| !str_starts_with($abs, $videoRoot . DIRECTORY_SEPARATOR)) {
$stats['failed']++;
continue;
}
$duration = probeDuration($abs);
$seekAt = ($duration !== null && $duration > 0) ? $duration * THUMBNAIL_SEEK_PERCENT : 1.0;
if (captureThumbnail($abs, $seekAt, $thumbFile)) {
$upd->execute([$pick['id'], $courseId]);
$stats['generated']++;
} else {
$stats['failed']++;
}
}
return $stats;
}
/**
* Grab a single frame from $videoPath at $seekSeconds via ffmpeg, cropped to
* a fixed 640x360 cover, and write it to $outFile. Written to a temp file
* first and renamed into place, so a request racing the write never sees a
* partial jpg. Returns false (leaving any existing thumbnail untouched) if
* ffmpeg is unavailable or the capture fails.
*/
function captureThumbnail(string $videoPath, float $seekSeconds, string $outFile): bool
{
if (!function_exists('shell_exec')) return false;
// The temp file has no recognizable extension, so the output format is
// forced explicitly with -f mjpeg rather than left for ffmpeg to guess
// from the filename (it can't, and fails outright without this).
$tmp = $outFile . '.tmp-' . bin2hex(random_bytes(4));
$cmd = sprintf(
'ffmpeg -y -ss %s -i %s -frames:v 1 -f mjpeg -vf %s -q:v 3 %s 2>&1',
escapeshellarg(number_format(max(0, $seekSeconds), 2, '.', '')),
escapeshellarg($videoPath),
escapeshellarg('scale=640:360:force_original_aspect_ratio=increase,crop=640:360'),
escapeshellarg($tmp)
);
$out = @shell_exec($cmd);
if (!is_file($tmp) || filesize($tmp) < 512) {
if (is_file($tmp)) @unlink($tmp);
if ($out !== null) {
error_log("captureThumbnail: ffmpeg produced no usable output for $videoPath\n$out");
}
return false;
}
return @rename($tmp, $outFile);
}
/* ---------------------------------------------------------------------------
* Progress helpers
* ------------------------------------------------------------------------- */
/**
* Progress for one folder's whole subtree (videos + quizzes), matched by
* path prefix. Returns ['total' => n, 'completed' => n, 'percent' => 0-100].
* Percent gives partial credit for half-watched videos and partial scores.
* One pass over a folder's whole subtree (videos + quizzes), matched by path
* prefix: how much is in there, how big, how long, and how far the user got.
* Course cards need all of it, so it is gathered in two queries rather than
* one per fact.
*/
function folderProgress(int $userId, string $folderPath): array
function folderRollup(int $userId, string $folderPath): array
{
$args = [$userId, $folderPath, likePrefix($folderPath) . '/%'];
$stmt = db()->prepare(
"SELECT 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
@@ -315,19 +453,306 @@ function folderProgress(int $userId, string $folderPath): array
$total = (int)$videos['total'] + (int)$quizzes['total'];
$credit = $videos['credit'] + $quizzes['credit'];
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,
];
}
/**
* Progress for one folder's whole subtree.
* Returns ['total' => n, 'completed' => n, 'percent' => 0-100].
* Percent gives partial credit for half-watched videos and partial scores.
*/
function folderProgress(int $userId, string $folderPath): array
{
$r = folderRollup($userId, $folderPath);
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
{
$r = folderRollup($userId, $folder['path']);
$percent = $r['percent'];
return [
'id' => (int)$folder['id'],
'name' => $folder['name'],
'path' => $folder['path'],
'videos' => $r['videos'],
'quizzes' => $r['quizzes'],
'duration' => $r['duration'],
'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.
'state' => $percent >= 100 ? 'finished' : ($percent > 0 ? 'progress' : 'fresh'),
];
}
/** Every top-level folder as a course card. */
function courseCards(int $userId): array
{
return array_map(fn($f) => courseSummary($userId, $f), childFolders(null));
}
/** Headline numbers for the home screen, computed from the course cards. */
function userStats(int $userId, array $courses): array
{
$inProgress = 0;
$finished = 0;
foreach ($courses as $c) {
if ($c['state'] === 'finished') $finished++;
elseif ($c['state'] === 'progress') $inProgress++;
}
// Watched time: full duration for completed videos, position for the rest.
$stmt = db()->prepare(
'SELECT COALESCE(SUM(
CASE WHEN vp.completed = 1
THEN COALESCE(v.duration_seconds, vp.duration_seconds, vp.position_seconds)
ELSE vp.position_seconds END
), 0) AS watched
FROM video_progress vp
JOIN videos v ON v.id = vp.video_id AND v.is_missing = 0
WHERE vp.user_id = ?'
);
$stmt->execute([$userId]);
$watched = (float)$stmt->fetchColumn();
$stmt = db()->prepare(
'SELECT COUNT(*) AS taken,
COALESCE(AVG(qp.best_score / NULLIF(q.num_questions, 0)), 0) AS avg_score
FROM quiz_progress qp
JOIN quizzes q ON q.id = qp.quiz_id AND q.is_missing = 0
WHERE qp.user_id = ? AND qp.attempts > 0'
);
$stmt->execute([$userId]);
$quiz = $stmt->fetch();
return [
'in_progress' => $inProgress,
'finished' => $finished,
'courses' => count($courses),
'watched_seconds' => $watched,
'quizzes_taken' => (int)$quiz['taken'],
'quiz_average' => (int)round(100 * (float)$quiz['avg_score']),
];
}
/** Courses the scanner indexed most recently — the "found in the last scan" strip. */
function recentCourses(int $userId, int $limit = 3): array
{
$stmt = db()->prepare(
'SELECT * FROM folders
WHERE parent_id IS NULL 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);
$c['is_new'] = $f['created_at'] !== null && strtotime($f['created_at']) >= $week;
return $c;
}, $stmt->fetchAll());
}
/** Library-wide counts for the sidebar and header. */
function libraryTotals(): array
{
$row = db()->query(
'SELECT (SELECT COUNT(*) FROM folders WHERE parent_id IS NULL 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();
return [
'courses' => (int)$row['courses'],
'videos' => (int)$row['videos'],
'quizzes' => (int)$row['quizzes'],
'bytes' => (int)$row['bytes'],
'duration' => (float)$row['duration'],
];
}
/* ---------------------------------------------------------------------------
* app_meta — small key/value store (currently just the last scan time).
* Reads tolerate the table not existing yet, so an install created before this
* table was added keeps working until its next scan creates it.
* ------------------------------------------------------------------------- */
function appMetaGet(string $key): ?string
{
try {
$stmt = db()->prepare('SELECT v FROM app_meta WHERE k = ?');
$stmt->execute([$key]);
$value = $stmt->fetchColumn();
return $value === false ? null : (string)$value;
} catch (Throwable $e) {
return null;
}
}
function appMetaSet(string $key, string $value): void
{
$db = db();
$db->exec(
'CREATE TABLE IF NOT EXISTS app_meta (
k VARCHAR(64) PRIMARY KEY,
v VARCHAR(255) NOT NULL,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB'
);
$stmt = $db->prepare('INSERT INTO app_meta (k, v) VALUES (?, ?) ON DUPLICATE KEY UPDATE v = VALUES(v)');
$stmt->execute([$key, $value]);
}
/** Escape LIKE wildcards in a path used as a prefix. */
function likePrefix(string $path): string
{
return str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $path);
}
/** Escaped "contains" pattern for LIKE. */
function likeContains(string $term): string
{
return '%' . likePrefix($term) . '%';
}
/**
* Wipe the user's progress for a scope:
* all — everything the user has watched/answered
* folder — the folder's whole subtree (id = folder id)
* video — one video (id = video id)
* quiz — one quiz (id = quiz id)
* Returns ['videos' => n, 'quizzes' => n] rows removed.
* Rows for files that went missing are cleared too, so a reset is complete.
*/
function resetProgress(int $userId, string $scope, int $id = 0): array
{
$db = db();
switch ($scope) {
case 'all':
$v = $db->prepare('DELETE FROM video_progress WHERE user_id = ?');
$v->execute([$userId]);
$q = $db->prepare('DELETE FROM quiz_progress WHERE user_id = ?');
$q->execute([$userId]);
break;
case 'folder':
$folder = getFolder($id);
if (!$folder) {
throw new InvalidArgumentException('Folder not found');
}
$args = [$userId, $folder['path'], likePrefix($folder['path']) . '/%'];
$v = $db->prepare(
'DELETE vp FROM video_progress vp
JOIN videos v ON v.id = vp.video_id
JOIN folders f ON f.id = v.folder_id
WHERE vp.user_id = ? AND (f.path = ? OR f.path LIKE ?)'
);
$v->execute($args);
$q = $db->prepare(
'DELETE qp FROM quiz_progress qp
JOIN quizzes q ON q.id = qp.quiz_id
JOIN folders f ON f.id = q.folder_id
WHERE qp.user_id = ? AND (f.path = ? OR f.path LIKE ?)'
);
$q->execute($args);
break;
case 'video':
if ($id <= 0) {
throw new InvalidArgumentException('Video not found');
}
$v = $db->prepare('DELETE FROM video_progress WHERE user_id = ? AND video_id = ?');
$v->execute([$userId, $id]);
$q = null;
break;
case 'quiz':
if ($id <= 0) {
throw new InvalidArgumentException('Quiz not found');
}
$q = $db->prepare('DELETE FROM quiz_progress WHERE user_id = ? AND quiz_id = ?');
$q->execute([$userId, $id]);
$v = null;
break;
default:
throw new InvalidArgumentException('Unknown reset scope');
}
return [
'videos' => $v ? $v->rowCount() : 0,
'quizzes' => $q ? $q->rowCount() : 0,
];
}
/* ---------------------------------------------------------------------------
* Search — substring match on names (and on the relative path, so
* "Course 1/Intro" finds everything under it).
* ------------------------------------------------------------------------- */
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 ?)
ORDER BY depth, path
LIMIT ' . (int)$limit
);
$stmt->execute([$like, $like]);
return $stmt->fetchAll();
}
function searchVideos(int $userId, string $term, int $limit = 50): array
{
$like = likeContains($term);
$stmt = db()->prepare(
'SELECT v.*, f.path AS folder_path,
vp.position_seconds, vp.duration_seconds AS watched_duration, vp.completed
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 ?)
ORDER BY f.path, v.sort_order, v.name
LIMIT ' . (int)$limit
);
$stmt->execute([$userId, $like, $like]);
return $stmt->fetchAll();
}
function searchQuizzes(int $userId, string $term, int $limit = 25): array
{
$like = likeContains($term);
$stmt = db()->prepare(
'SELECT q.*, f.path AS folder_path,
qp.best_score, qp.last_score, qp.attempts, qp.completed
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 ?)
ORDER BY f.path, q.sort_order, q.name
LIMIT ' . (int)$limit
);
$stmt->execute([$userId, $like, $like]);
return $stmt->fetchAll();
}
function getFolder(int $id): ?array
{
$stmt = db()->prepare('SELECT * FROM folders WHERE id = ? AND is_missing = 0');
@@ -423,7 +848,7 @@ function folderBreadcrumbs(array $folder): array
function continueWatching(int $userId, int $limit = 6): array
{
$stmt = db()->prepare(
'SELECT v.id, v.name, f.path AS folder_path,
'SELECT v.id, v.name, v.folder_id, f.path AS folder_path,
vp.position_seconds, vp.duration_seconds, vp.updated_at
FROM video_progress vp
JOIN videos v ON v.id = vp.video_id AND v.is_missing = 0
@@ -436,6 +861,13 @@ 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). */
function courseIdOf(array $folder): int
{
$crumbs = folderBreadcrumbs($folder);
return (int)$crumbs[0]['id'];
}
function formatDuration(?float $seconds): string
{
if ($seconds === null || $seconds <= 0) return '';