UI improved
This commit is contained in:
+442
-10
@@ -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 '';
|
||||
|
||||
Reference in New Issue
Block a user