UI improved
This commit is contained in:
@@ -1,13 +1,27 @@
|
|||||||
# CourseHub
|
# CourseHub
|
||||||
|
|
||||||
Self-hosted course video player: PHP 8.0+ + MySQL/MariaDB + Bootstrap 5 + [Plyr](https://github.com/sampotts/plyr).
|
Self-hosted course video player: PHP 8.0+ + MySQL/MariaDB, its own CSS design
|
||||||
|
system, [Plyr](https://github.com/sampotts/plyr) and
|
||||||
|
[anime.js](https://animejs.com/). No CSS framework.
|
||||||
|
|
||||||
Single-page app: `index.php` is a static shell; all navigation, login, browsing,
|
Single-page app: `index.php` is a static shell (sidebar, search header, mobile
|
||||||
playback, and scanning happen over AJAX against the JSON API in `api/`
|
tab bar); all navigation, login, browsing, playback, scanning and settings
|
||||||
(`auth.php`, `library.php`, `progress.php`, `scan.php`), with hash-based routes
|
happen over AJAX against the JSON API in `api/` (`auth.php`, `library.php`,
|
||||||
(`#/`, `#/folder/N`, `#/watch/N`) so views switch without page reloads and
|
`progress.php`, `scan.php`, `quiz.php`, `reset.php`), with hash-based routes so
|
||||||
browser back/forward still work. Only `stream.php` (the mp4 bytes) is a plain
|
views switch without page reloads and browser back/forward still work:
|
||||||
HTTP resource.
|
|
||||||
|
| Route | Screen |
|
||||||
|
| --- | --- |
|
||||||
|
| `#/` | home — resume card, stats, in progress, last scan |
|
||||||
|
| `#/library`, `#/library/<shelf>` | every course, as shelves or a filtered catalog |
|
||||||
|
| `#/folder/N` | a course or section: sections + content |
|
||||||
|
| `#/watch/N` | player, playlist, section quizzes |
|
||||||
|
| `#/quiz/N` | take and grade a quiz |
|
||||||
|
| `#/search/Q` | courses, videos and quizzes matching `Q` |
|
||||||
|
| `#/settings` | library folder, preferences, reset |
|
||||||
|
|
||||||
|
Only `stream.php` (the mp4 bytes) and `thumb.php` (cached course thumbnails)
|
||||||
|
are plain HTTP resources.
|
||||||
|
|
||||||
- Recursively scans a videos directory for `.mp4` files and mirrors the folder
|
- Recursively scans a videos directory for `.mp4` files and mirrors the folder
|
||||||
hierarchy (courses → sections → videos, any nesting depth).
|
hierarchy (courses → sections → videos, any nesting depth).
|
||||||
@@ -16,6 +30,15 @@ HTTP resource.
|
|||||||
percentage bar computed over its whole subtree.
|
percentage bar computed over its whole subtree.
|
||||||
- "Continue watching" strip, auto-play next video, playback speed, keyboard
|
- "Continue watching" strip, auto-play next video, playback speed, keyboard
|
||||||
shortcuts (Plyr), completed checkmarks, natural sorting (`Video2` before `Video10`).
|
shortcuts (Plyr), completed checkmarks, natural sorting (`Video2` before `Video10`).
|
||||||
|
- **Search** courses, sections, videos and quizzes from the top bar.
|
||||||
|
- **Reset progress** for one video, one quiz, a whole course/section, or the
|
||||||
|
entire account.
|
||||||
|
- Course and section listings switch between **grid and list** layout, and the
|
||||||
|
library between **shelves and catalog**; both choices are remembered.
|
||||||
|
- **Shelves** group courses by how far you are: in progress, finished, not
|
||||||
|
started. The app has no course categories, so progress is what sorts them.
|
||||||
|
- **Course thumbnails** — a real frame captured from each course's first
|
||||||
|
video, 40% of the way through it.
|
||||||
- Seekable streaming via HTTP Range requests; files are addressed by DB id only
|
- Seekable streaming via HTTP Range requests; files are addressed by DB id only
|
||||||
(no path traversal).
|
(no path traversal).
|
||||||
|
|
||||||
@@ -51,6 +74,15 @@ Top-level folders become courses on the home page.
|
|||||||
the web server / PHP user).
|
the web server / PHP user).
|
||||||
- DB credentials (change the password in production).
|
- DB credentials (change the password in production).
|
||||||
- `ALLOW_REGISTRATION`: set `false` once your users have accounts.
|
- `ALLOW_REGISTRATION`: set `false` once your users have accounts.
|
||||||
|
- `THUMBNAILS_DIR`: where generated course thumbnails are cached — must be
|
||||||
|
*writable* by the web server / PHP user (default: `cache/thumbnails`
|
||||||
|
next to `config.php`).
|
||||||
|
- `THUMBNAIL_SEEK_PERCENT`: how far into a course's first video to capture
|
||||||
|
its thumbnail (default `0.4`, i.e. 40%).
|
||||||
|
|
||||||
|
An `ffmpeg` binary on `$PATH` is required for thumbnails (and is the
|
||||||
|
fallback for video duration if the pure-PHP mp4 parser can't read a file).
|
||||||
|
Without it, courses just keep their colour-block placeholder.
|
||||||
|
|
||||||
3. **Run** — with the PHP built-in server for testing:
|
3. **Run** — with the PHP built-in server for testing:
|
||||||
|
|
||||||
@@ -96,6 +128,108 @@ correct set. Best score, last score, and attempt count are stored per user;
|
|||||||
a quiz is *completed* once every question is answered correctly in one
|
a quiz is *completed* once every question is answered correctly in one
|
||||||
attempt, and quiz scores count toward folder progress alongside videos.
|
attempt, and quiz scores count toward folder progress alongside videos.
|
||||||
|
|
||||||
|
## Thumbnails
|
||||||
|
|
||||||
|
Each course's card is a real frame, not just a colour block: the scanner finds
|
||||||
|
the course's first video — in the same order you'd browse to it (subfolders in
|
||||||
|
listing order, then that folder's videos) — and asks `ffmpeg` for a frame
|
||||||
|
`THUMBNAIL_SEEK_PERCENT` (default 40%) of the way through it, scaled and
|
||||||
|
cropped to 640×360. The result is cached at `THUMBNAILS_DIR/<course id>.jpg`
|
||||||
|
and served by `thumb.php?course_id=N`, which looks the file up by that ID only
|
||||||
|
(same containment check as `stream.php`) and sends normal `ETag`/
|
||||||
|
`Last-Modified` caching headers.
|
||||||
|
|
||||||
|
- Regeneration only happens when a course's first video has changed since the
|
||||||
|
last scan (tracked via `folders.thumbnail_video_id`), so rescans stay fast.
|
||||||
|
- If `ffmpeg` is missing, a video's duration can't be read, or generation
|
||||||
|
otherwise fails, the course simply keeps its colour-block placeholder — the
|
||||||
|
`<img>` 404s and the client removes it (`onerror="this.remove()"`), no error
|
||||||
|
is shown to the user. Failures are logged (`error_log`) and rolled up into
|
||||||
|
the scan's `thumbnails_failed` count.
|
||||||
|
- `thumbnail_video_id`/`thumbnail_generated_at` on `folders` are added
|
||||||
|
automatically on first use (`ensureThumbnailColumns()`) if a database
|
||||||
|
created before this feature doesn't have them yet — no manual migration
|
||||||
|
needed, the same self-healing `app_meta` uses.
|
||||||
|
|
||||||
|
## Search
|
||||||
|
|
||||||
|
The top-bar box searches as you type (from two characters, debounced) and also
|
||||||
|
on Enter. It matches folder, video and quiz **names** and their relative
|
||||||
|
**paths**, so `Course 1/Intro` lists everything under that section. Results are
|
||||||
|
grouped into courses/sections, videos and quizzes, with your progress shown on
|
||||||
|
each row. Searches live at `#/search/<query>`, so a result page can be
|
||||||
|
bookmarked or shared.
|
||||||
|
|
||||||
|
## Resetting progress
|
||||||
|
|
||||||
|
`api/reset.php` deletes progress rows for the logged-in user only — never for
|
||||||
|
anyone else, and never any video, quiz or folder itself. Four scopes:
|
||||||
|
|
||||||
|
| Where | Scope | Clears |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Player page → **Reset** | `video` | that video's position + completion |
|
||||||
|
| Quiz page → **Reset attempts** | `quiz` | that quiz's attempts, best/last score |
|
||||||
|
| Folder page → **Reset progress** | `folder` | everything in that folder's subtree |
|
||||||
|
| Library list row → ↺ | `folder` | that one course's subtree |
|
||||||
|
| Settings / avatar menu → **Reset all progress** | `all` | every video and quiz on the account |
|
||||||
|
|
||||||
|
Each one asks for confirmation first, and the reply reports how many video and
|
||||||
|
quiz rows were removed. Resetting the video you are watching also suppresses
|
||||||
|
the player's teardown save, so the position you just cleared isn't written back.
|
||||||
|
|
||||||
|
## Look and feel
|
||||||
|
|
||||||
|
The UI implements the **Course Library** design (a Claude Design document).
|
||||||
|
`assets/style.css` is the whole design system — tokens first, then layout, then
|
||||||
|
components. There is no CSS framework under it, and the icons are inline SVG
|
||||||
|
from the design rather than an icon font.
|
||||||
|
|
||||||
|
| Token | Hex | Used for |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Paper | `#EFEAE1` | outermost ground |
|
||||||
|
| Surface | `#FAF8F5` | app background |
|
||||||
|
| Sidebar | `#F5F1EA` | left rail |
|
||||||
|
| Card | `#FFFFFF` | cards, rows, dialogs |
|
||||||
|
| Ink | `#1A1814` → `#B8B0A2` | four text weights (`--ink` … `--ink-4`) |
|
||||||
|
| Accent | `#1F6F5C` | primary action, links, progress, completion |
|
||||||
|
| Danger | `#B84A3C` | reset and errors |
|
||||||
|
|
||||||
|
Type: **Inter** throughout — headings, interface text, and counts/durations/
|
||||||
|
paths (the design's own 'Elms Sans' ships only inside the design document).
|
||||||
|
Numeric text uses `tabular-nums` so columns of counts and durations still
|
||||||
|
line up without a separate monospace face.
|
||||||
|
|
||||||
|
Courses have no cover art, so each one gets a hue from the design's six-colour
|
||||||
|
set (clay, sage, slate, sand, plum, sky), picked from its folder id so it stays
|
||||||
|
the same across renders and reloads. That hue drives its thumbnail, its initial
|
||||||
|
badge and its progress bar.
|
||||||
|
|
||||||
|
anime.js drives the motion: views fade and stagger in, progress bars grow from
|
||||||
|
zero (so a reset is visible), correct quiz answers settle and wrong ones shake.
|
||||||
|
Everything is skipped when the CDN script is unavailable, when the visitor has
|
||||||
|
`prefers-reduced-motion: reduce` set, or when **Animate transitions** is turned
|
||||||
|
off in settings — the views then render in their final state.
|
||||||
|
|
||||||
|
Below 900 px the layout follows the design's mobile frame: the sidebar gives way
|
||||||
|
to a bottom tab bar, the search field becomes a pill with a round scan button
|
||||||
|
next to it, and grids drop to two columns.
|
||||||
|
|
||||||
|
## Settings
|
||||||
|
|
||||||
|
`#/settings` shows what the server actually knows and what this browser
|
||||||
|
remembers:
|
||||||
|
|
||||||
|
- **Library folder** — `VIDEOS_ROOT`, whether it is readable, indexed counts,
|
||||||
|
total running time, the watched threshold, and when the last scan ran.
|
||||||
|
- **Preferences**, stored per browser: autoplay next video, animate
|
||||||
|
transitions, open the library in list view.
|
||||||
|
- **Reset progress** — the account-wide reset, with a count of how many courses
|
||||||
|
currently hold progress.
|
||||||
|
|
||||||
|
Scan time lives in an `app_meta` key/value table. Reads tolerate the table not
|
||||||
|
existing, so an install created before it was added keeps working until its
|
||||||
|
next scan creates it.
|
||||||
|
|
||||||
## How progress works
|
## How progress works
|
||||||
|
|
||||||
- The player POSTs `{video_id, position, duration}` to `api/progress.php`
|
- The player POSTs `{video_id, position, duration}` to `api/progress.php`
|
||||||
|
|||||||
+127
-8
@@ -1,7 +1,11 @@
|
|||||||
<?php
|
<?php
|
||||||
// GET api/library.php?view=home -> courses + continue-watching
|
// 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=folder&id=N -> folder contents + progress
|
// GET api/library.php?view=folder&id=N -> folder contents + progress
|
||||||
// GET api/library.php?view=video&id=N -> video + playlist + resume info
|
// 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/auth.php';
|
||||||
require_once __DIR__ . '/../includes/functions.php';
|
require_once __DIR__ . '/../includes/functions.php';
|
||||||
|
|
||||||
@@ -53,12 +57,64 @@ 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';
|
$view = $_GET['view'] ?? 'home';
|
||||||
|
|
||||||
switch ($view) {
|
switch ($view) {
|
||||||
case 'home':
|
case 'meta':
|
||||||
|
$courses = courseCards($userId);
|
||||||
|
$counts = ['progress' => 0, 'finished' => 0, 'fresh' => 0];
|
||||||
|
foreach ($courses as $c) {
|
||||||
|
$counts[$c['state']]++;
|
||||||
|
}
|
||||||
echo json_encode([
|
echo json_encode([
|
||||||
'courses' => array_map(fn($c) => folderSummary($c, $userId), childFolders(null)),
|
'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':
|
||||||
|
$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) => [
|
'continue' => array_map(fn($r) => [
|
||||||
'id' => (int)$r['id'],
|
'id' => (int)$r['id'],
|
||||||
'name' => $r['name'],
|
'name' => $r['name'],
|
||||||
@@ -66,6 +122,18 @@ switch ($view) {
|
|||||||
'position' => (float)$r['position_seconds'],
|
'position' => (float)$r['position_seconds'],
|
||||||
'duration' => $r['duration_seconds'] !== null ? (float)$r['duration_seconds'] : null,
|
'duration' => $r['duration_seconds'] !== null ? (float)$r['duration_seconds'] : null,
|
||||||
], continueWatching($userId)),
|
], continueWatching($userId)),
|
||||||
|
'in_progress' => array_values(array_filter($courses, fn($c) => $c['state'] === 'progress')),
|
||||||
|
'recent' => recentCourses($userId),
|
||||||
|
'courses' => $courses,
|
||||||
|
]);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'library':
|
||||||
|
$courses = courseCards($userId);
|
||||||
|
echo json_encode([
|
||||||
|
'courses' => $courses,
|
||||||
|
'shelves' => shelveCourses($courses),
|
||||||
|
'totals' => libraryTotals(),
|
||||||
]);
|
]);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -76,11 +144,15 @@ switch ($view) {
|
|||||||
echo json_encode(['error' => 'Folder not found']);
|
echo json_encode(['error' => 'Folder not found']);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
$crumbs = crumbList($folder);
|
||||||
echo json_encode([
|
echo json_encode([
|
||||||
'folder' => ['id' => (int)$folder['id'], 'name' => $folder['name']],
|
'folder' => ['id' => (int)$folder['id'], 'name' => $folder['name'],
|
||||||
'breadcrumbs' => crumbList($folder),
|
'path' => $folder['path']],
|
||||||
|
'course_id' => $crumbs ? $crumbs[0]['id'] : (int)$folder['id'],
|
||||||
|
'breadcrumbs' => $crumbs,
|
||||||
|
'rollup' => folderRollup($userId, $folder['path']),
|
||||||
'progress' => folderProgress($userId, $folder['path']),
|
'progress' => folderProgress($userId, $folder['path']),
|
||||||
'subfolders' => array_map(fn($f) => folderSummary($f, $userId),
|
'subfolders' => array_map(fn($f) => courseSummary($userId, $f),
|
||||||
childFolders((int)$folder['id'])),
|
childFolders((int)$folder['id'])),
|
||||||
'videos' => array_map('videoSummary',
|
'videos' => array_map('videoSummary',
|
||||||
folderVideos((int)$folder['id'], $userId)),
|
folderVideos((int)$folder['id'], $userId)),
|
||||||
@@ -98,6 +170,7 @@ switch ($view) {
|
|||||||
}
|
}
|
||||||
$folder = getFolder((int)$video['folder_id']);
|
$folder = getFolder((int)$video['folder_id']);
|
||||||
$playlist = folderVideos((int)$folder['id'], $userId);
|
$playlist = folderVideos((int)$folder['id'], $userId);
|
||||||
|
$crumbs = crumbList($folder);
|
||||||
|
|
||||||
$stmt = db()->prepare(
|
$stmt = db()->prepare(
|
||||||
'SELECT position_seconds, completed FROM video_progress
|
'SELECT position_seconds, completed FROM video_progress
|
||||||
@@ -118,10 +191,14 @@ switch ($view) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
echo json_encode([
|
echo json_encode([
|
||||||
'video' => ['id' => (int)$video['id'], 'name' => $video['name']],
|
'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']],
|
'folder' => ['id' => (int)$folder['id'], 'name' => $folder['name']],
|
||||||
'breadcrumbs' => crumbList($folder),
|
'course_id' => $crumbs ? $crumbs[0]['id'] : (int)$folder['id'],
|
||||||
|
'breadcrumbs' => $crumbs,
|
||||||
'playlist' => array_map('videoSummary', $playlist),
|
'playlist' => array_map('videoSummary', $playlist),
|
||||||
|
'quizzes' => array_map('quizSummary', folderQuizzes((int)$folder['id'], $userId)),
|
||||||
'resume_at' => $resumeAt,
|
'resume_at' => $resumeAt,
|
||||||
'prev_id' => $prevId,
|
'prev_id' => $prevId,
|
||||||
'next_id' => $nextId,
|
'next_id' => $nextId,
|
||||||
@@ -129,6 +206,48 @@ switch ($view) {
|
|||||||
]);
|
]);
|
||||||
break;
|
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,
|
||||||
|
'folders' => array_map(
|
||||||
|
fn($f) => courseSummary($userId, $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);
|
||||||
|
$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),
|
||||||
|
]);
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
echo json_encode(['error' => 'Unknown view']);
|
echo json_encode(['error' => 'Unknown view']);
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
// POST api/reset.php -> delete the logged-in user's progress for a scope.
|
||||||
|
// JSON body: {scope: all|folder|video|quiz, id?}
|
||||||
|
// Only ever touches the current user's rows; the library itself is untouched.
|
||||||
|
require_once __DIR__ . '/../includes/auth.php';
|
||||||
|
require_once __DIR__ . '/../includes/functions.php';
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
$userId = requireLoginApi();
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
http_response_code(405);
|
||||||
|
echo json_encode(['error' => 'Method not allowed']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||||
|
$scope = (string)($data['scope'] ?? '');
|
||||||
|
$id = (int)($data['id'] ?? 0);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$counts = resetProgress($userId, $scope, $id);
|
||||||
|
} catch (InvalidArgumentException $e) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['error' => $e->getMessage()]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
echo json_encode(['ok' => true, 'reset' => $counts]);
|
||||||
+1176
-304
File diff suppressed because it is too large
Load Diff
+1176
-21
File diff suppressed because it is too large
Load Diff
@@ -18,3 +18,10 @@ define('COMPLETE_THRESHOLD', 0.95);
|
|||||||
|
|
||||||
// Allow new users to self-register from the login page.
|
// Allow new users to self-register from the login page.
|
||||||
define('ALLOW_REGISTRATION', true);
|
define('ALLOW_REGISTRATION', true);
|
||||||
|
|
||||||
|
// Where generated course thumbnails are cached. Must be writable by the PHP
|
||||||
|
// process; created automatically on first scan if missing.
|
||||||
|
define('THUMBNAILS_DIR', __DIR__ . '/cache/thumbnails');
|
||||||
|
|
||||||
|
// Capture each course's thumbnail this far into its first video (0.0-1.0).
|
||||||
|
define('THUMBNAIL_SEEK_PERCENT', 0.4);
|
||||||
|
|||||||
+442
-10
@@ -147,6 +147,7 @@ function scanLibrary(): array
|
|||||||
$seenFolderIds = [];
|
$seenFolderIds = [];
|
||||||
$seenVideoIds = [];
|
$seenVideoIds = [];
|
||||||
$seenQuizIds = [];
|
$seenQuizIds = [];
|
||||||
|
$firstVideo = []; // courseFolderId => ['id' => videoId, 'path' => relPath]
|
||||||
|
|
||||||
$upsertFolder = $db->prepare(
|
$upsertFolder = $db->prepare(
|
||||||
'INSERT INTO folders (parent_id, name, path, depth, sort_order, is_missing)
|
'INSERT INTO folders (parent_id, name, path, depth, sort_order, is_missing)
|
||||||
@@ -172,9 +173,14 @@ function scanLibrary(): array
|
|||||||
id = LAST_INSERT_ID(id)'
|
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,
|
use (&$walk, $db, $upsertFolder, $upsertVideo, $upsertQuiz,
|
||||||
&$stats, &$seenFolderIds, &$seenVideoIds, &$seenQuizIds): void {
|
&$stats, &$seenFolderIds, &$seenVideoIds, &$seenQuizIds, &$firstVideo): void {
|
||||||
|
|
||||||
$entries = scandir($dir) ?: [];
|
$entries = scandir($dir) ?: [];
|
||||||
$entries = array_values(array_filter($entries, fn($e) => $e[0] !== '.'));
|
$entries = array_values(array_filter($entries, fn($e) => $e[0] !== '.'));
|
||||||
@@ -192,15 +198,19 @@ function scanLibrary(): array
|
|||||||
$folderId = (int)$db->lastInsertId();
|
$folderId = (int)$db->lastInsertId();
|
||||||
$seenFolderIds[] = $folderId;
|
$seenFolderIds[] = $folderId;
|
||||||
$stats['folders']++;
|
$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') {
|
} elseif (is_file($full) && strtolower(pathinfo($entry, PATHINFO_EXTENSION)) === 'mp4') {
|
||||||
if ($parentId === null) {
|
if ($parentId === null) {
|
||||||
continue; // mp4 directly in the root is skipped; use course folders
|
continue; // mp4 directly in the root is skipped; use course folders
|
||||||
}
|
}
|
||||||
$name = pathinfo($entry, PATHINFO_FILENAME);
|
$name = pathinfo($entry, PATHINFO_FILENAME);
|
||||||
$upsertVideo->execute([$parentId, $name, $entry, $rel, filesize($full) ?: 0, $videoSort++]);
|
$upsertVideo->execute([$parentId, $name, $entry, $rel, filesize($full) ?: 0, $videoSort++]);
|
||||||
$seenVideoIds[] = (int)$db->lastInsertId();
|
$videoId = (int)$db->lastInsertId();
|
||||||
|
$seenVideoIds[] = $videoId;
|
||||||
$stats['videos']++;
|
$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)) {
|
} elseif (is_file($full) && preg_match('/_quiz_solved\.txt$/i', $entry)) {
|
||||||
if ($parentId === null) {
|
if ($parentId === null) {
|
||||||
continue;
|
continue;
|
||||||
@@ -219,7 +229,7 @@ function scanLibrary(): array
|
|||||||
|
|
||||||
$db->beginTransaction();
|
$db->beginTransaction();
|
||||||
try {
|
try {
|
||||||
$walk($root, null, '', 0);
|
$walk($root, null, '', 0, null);
|
||||||
|
|
||||||
// Anything not seen this pass no longer exists on disk.
|
// Anything not seen this pass no longer exists on disk.
|
||||||
$flagMissing = function (string $table, array $seenIds) use ($db): int {
|
$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;
|
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 helpers
|
||||||
* ------------------------------------------------------------------------- */
|
* ------------------------------------------------------------------------- */
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Progress for one folder's whole subtree (videos + quizzes), matched by
|
* One pass over a folder's whole subtree (videos + quizzes), matched by path
|
||||||
* path prefix. Returns ['total' => n, 'completed' => n, 'percent' => 0-100].
|
* prefix: how much is in there, how big, how long, and how far the user got.
|
||||||
* Percent gives partial credit for half-watched videos and partial scores.
|
* 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) . '/%'];
|
$args = [$userId, $folderPath, likePrefix($folderPath) . '/%'];
|
||||||
|
|
||||||
$stmt = db()->prepare(
|
$stmt = db()->prepare(
|
||||||
"SELECT COUNT(*) AS total,
|
"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(vp.completed), 0) AS completed,
|
||||||
COALESCE(SUM(
|
COALESCE(SUM(
|
||||||
CASE
|
CASE
|
||||||
@@ -315,19 +453,306 @@ function folderProgress(int $userId, string $folderPath): array
|
|||||||
|
|
||||||
$total = (int)$videos['total'] + (int)$quizzes['total'];
|
$total = (int)$videos['total'] + (int)$quizzes['total'];
|
||||||
$credit = $videos['credit'] + $quizzes['credit'];
|
$credit = $videos['credit'] + $quizzes['credit'];
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
'videos' => (int)$videos['total'],
|
||||||
|
'quizzes' => (int)$quizzes['total'],
|
||||||
|
'duration' => (float)$videos['duration'],
|
||||||
|
'bytes' => (int)$videos['bytes'],
|
||||||
'total' => $total,
|
'total' => $total,
|
||||||
'completed' => (int)$videos['completed'] + (int)$quizzes['completed'],
|
'completed' => (int)$videos['completed'] + (int)$quizzes['completed'],
|
||||||
'percent' => $total > 0 ? (int)round(100 * $credit / $total) : 0,
|
'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. */
|
/** Escape LIKE wildcards in a path used as a prefix. */
|
||||||
function likePrefix(string $path): string
|
function likePrefix(string $path): string
|
||||||
{
|
{
|
||||||
return str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $path);
|
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
|
function getFolder(int $id): ?array
|
||||||
{
|
{
|
||||||
$stmt = db()->prepare('SELECT * FROM folders WHERE id = ? AND is_missing = 0');
|
$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
|
function continueWatching(int $userId, int $limit = 6): array
|
||||||
{
|
{
|
||||||
$stmt = db()->prepare(
|
$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
|
vp.position_seconds, vp.duration_seconds, vp.updated_at
|
||||||
FROM video_progress vp
|
FROM video_progress vp
|
||||||
JOIN videos v ON v.id = vp.video_id AND v.is_missing = 0
|
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();
|
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
|
function formatDuration(?float $seconds): string
|
||||||
{
|
{
|
||||||
if ($seconds === null || $seconds <= 0) return '';
|
if ($seconds === null || $seconds <= 0) return '';
|
||||||
|
|||||||
@@ -1,46 +1,85 @@
|
|||||||
<?php /* CourseHub SPA shell — all data flows through api/ via fetch. */ ?>
|
<?php /* CourseHub SPA shell — all data flows through api/ via fetch. */ ?>
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en" data-bs-theme="dark">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="theme-color" content="#F2F0EA">
|
||||||
<title>CourseHub</title>
|
<title>CourseHub</title>
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
<link href="https://cdn.jsdelivr.net/npm/plyr@3.7.8/dist/plyr.css" rel="stylesheet">
|
<link href="https://cdn.jsdelivr.net/npm/plyr@3.7.8/dist/plyr.css" rel="stylesheet">
|
||||||
<link href="assets/style.css" rel="stylesheet">
|
<link href="assets/style.css" rel="stylesheet">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<nav class="navbar navbar-expand-lg bg-body-tertiary border-bottom mb-4">
|
<div class="app" id="app-shell" hidden>
|
||||||
<div class="container">
|
<aside class="sidebar">
|
||||||
<a class="navbar-brand fw-bold" href="#/">
|
<a class="brand" href="#/">
|
||||||
<i class="bi bi-collection-play-fill text-primary me-1"></i>CourseHub
|
<span class="brand-mark">
|
||||||
</a>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"></polygon></svg>
|
||||||
<div id="nav-user" class="d-none d-flex align-items-center gap-3">
|
|
||||||
<button id="btn-rescan" class="btn btn-sm btn-outline-secondary">
|
|
||||||
<i class="bi bi-arrow-repeat me-1"></i>Rescan library
|
|
||||||
</button>
|
|
||||||
<span class="text-secondary small">
|
|
||||||
<i class="bi bi-person-circle me-1"></i><span id="nav-username"></span>
|
|
||||||
</span>
|
</span>
|
||||||
<button id="btn-logout" class="btn btn-sm btn-outline-danger">Logout</button>
|
<span class="brand-name">CourseHub</span>
|
||||||
</div>
|
</a>
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<main class="container pb-5">
|
<nav class="nav" id="nav-main"></nav>
|
||||||
<div id="app">
|
|
||||||
<div class="text-center py-5">
|
|
||||||
<div class="spinner-border text-primary" role="status"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<div class="toast-container position-fixed bottom-0 end-0 p-3" id="toasts"></div>
|
<div class="sidebar-rule"></div>
|
||||||
|
<div class="sidebar-label">Shelves</div>
|
||||||
|
<div class="shelf-list" id="nav-shelves"></div>
|
||||||
|
|
||||||
|
<div class="sidebar-spacer"></div>
|
||||||
|
<div class="scan-card">
|
||||||
|
<div class="scan-card-head">
|
||||||
|
<span>Library scanned</span>
|
||||||
|
<span class="mono" id="scan-when">—</span>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-accent btn-block" id="btn-scan" data-scan>
|
||||||
|
<span class="scan-spin" id="scan-spin">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M21 12a9 9 0 1 1-3-6.7"></path><path d="M21 4v5h-5"></path></svg>
|
||||||
|
</span><span id="scan-label">Scan library</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="main">
|
||||||
|
<header class="topbar">
|
||||||
|
<label class="search">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#807A70" stroke-width="1.7" stroke-linecap="round"><circle cx="11" cy="11" r="7"></circle><path d="m20 20-3.2-3.2"></path></svg>
|
||||||
|
<input id="search-input" type="search" autocomplete="off" placeholder="Search courses and videos" aria-label="Search the library">
|
||||||
|
<button id="btn-search-clear" class="icon-btn" type="button" title="Clear search" hidden>
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"></path></svg>
|
||||||
|
</button>
|
||||||
|
</label>
|
||||||
|
<button class="icon-btn scan-mobile" id="btn-scan-mobile" data-scan title="Scan library">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M21 12a9 9 0 1 1-3-6.7"></path><path d="M21 4v5h-5"></path></svg>
|
||||||
|
</button>
|
||||||
|
<div class="topbar-end">
|
||||||
|
<span class="mono topbar-totals" id="topbar-totals"></span>
|
||||||
|
<button class="avatar" id="btn-account" title="Account"></button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="view" id="view">
|
||||||
|
<div class="loading"><span class="spinner"></span></div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<nav class="tabbar" id="nav-tabs"></nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Holds the boot spinner until we know whether there is a session, then
|
||||||
|
becomes either the sign-in card or is hidden in favour of the shell. -->
|
||||||
|
<div class="auth" id="auth">
|
||||||
|
<div class="loading"><span class="spinner"></span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toasts" id="toasts"></div>
|
||||||
|
<div id="overlay-root"></div>
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/plyr@3.7.8/dist/plyr.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/plyr@3.7.8/dist/plyr.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/animejs@3.2.2/lib/anime.min.js"></script>
|
||||||
<script src="assets/app.js"></script>
|
<script src="assets/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -11,4 +11,5 @@ if (PHP_SAPI !== 'cli') {
|
|||||||
$stats = scanLibrary();
|
$stats = scanLibrary();
|
||||||
echo "Scan complete: {$stats['folders']} folders, {$stats['videos']} videos, {$stats['quizzes']} quizzes"
|
echo "Scan complete: {$stats['folders']} folders, {$stats['videos']} videos, {$stats['quizzes']} quizzes"
|
||||||
. " ({$stats['missing_folders']} folders / {$stats['missing_videos']} videos"
|
. " ({$stats['missing_folders']} folders / {$stats['missing_videos']} videos"
|
||||||
. " / {$stats['missing_quizzes']} quizzes flagged missing)\n";
|
. " / {$stats['missing_quizzes']} quizzes flagged missing)\n"
|
||||||
|
. "Thumbnails: {$stats['thumbnails_generated']} generated, {$stats['thumbnails_failed']} failed\n";
|
||||||
|
|||||||
+14
@@ -9,8 +9,20 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
) ENGINE=InnoDB;
|
) ENGINE=InnoDB;
|
||||||
|
|
||||||
|
-- Small key/value store for app-wide facts (currently: when the last scan ran).
|
||||||
|
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;
|
||||||
|
|
||||||
-- One row per directory found under VIDEOS_ROOT.
|
-- One row per directory found under VIDEOS_ROOT.
|
||||||
-- `path` is the relative path from VIDEOS_ROOT, e.g. "Course 1/Intro".
|
-- `path` is the relative path from VIDEOS_ROOT, e.g. "Course 1/Intro".
|
||||||
|
-- thumbnail_video_id/thumbnail_generated_at are only ever set on top-level
|
||||||
|
-- (course) folders — see generateThumbnails() — and have no FK to videos
|
||||||
|
-- (that would make folders<->videos a circular reference); a stale pointer
|
||||||
|
-- is harmless and self-corrects on the next scan.
|
||||||
CREATE TABLE IF NOT EXISTS folders (
|
CREATE TABLE IF NOT EXISTS folders (
|
||||||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
parent_id INT UNSIGNED NULL,
|
parent_id INT UNSIGNED NULL,
|
||||||
@@ -19,6 +31,8 @@ CREATE TABLE IF NOT EXISTS folders (
|
|||||||
depth INT UNSIGNED NOT NULL DEFAULT 0,
|
depth INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
sort_order INT UNSIGNED NOT NULL DEFAULT 0,
|
sort_order INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
is_missing TINYINT(1) NOT NULL DEFAULT 0,
|
is_missing TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
thumbnail_video_id INT UNSIGNED NULL,
|
||||||
|
thumbnail_generated_at TIMESTAMP NULL,
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
CONSTRAINT fk_folders_parent FOREIGN KEY (parent_id)
|
CONSTRAINT fk_folders_parent FOREIGN KEY (parent_id)
|
||||||
REFERENCES folders(id) ON DELETE CASCADE,
|
REFERENCES folders(id) ON DELETE CASCADE,
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
// Serves a cached course thumbnail — a frame captured from the course's first
|
||||||
|
// video at THUMBNAIL_SEEK_PERCENT, generated by the scanner (see
|
||||||
|
// generateThumbnails() in includes/functions.php). Looked up by folder ID
|
||||||
|
// only, mirroring stream.php's containment check; 404s (no thumbnail yet, or
|
||||||
|
// the id isn't a course) are expected and handled by the client falling back
|
||||||
|
// to its colour-block placeholder.
|
||||||
|
require_once __DIR__ . '/includes/auth.php';
|
||||||
|
require_once __DIR__ . '/includes/functions.php';
|
||||||
|
|
||||||
|
$userId = currentUserId();
|
||||||
|
if ($userId === null) {
|
||||||
|
http_response_code(403);
|
||||||
|
exit('Forbidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
$courseId = (int)($_GET['course_id'] ?? 0);
|
||||||
|
$dir = realpath(THUMBNAILS_DIR);
|
||||||
|
$file = ($courseId > 0 && $dir !== false)
|
||||||
|
? realpath($dir . '/' . $courseId . '.jpg')
|
||||||
|
: false;
|
||||||
|
|
||||||
|
if ($dir === false || $file === false || !str_starts_with($file, $dir . DIRECTORY_SEPARATOR)
|
||||||
|
|| !is_file($file)) {
|
||||||
|
http_response_code(404);
|
||||||
|
exit('No thumbnail');
|
||||||
|
}
|
||||||
|
|
||||||
|
$mtime = filemtime($file);
|
||||||
|
$etag = '"' . md5($courseId . '-' . $mtime) . '"';
|
||||||
|
|
||||||
|
header('Cache-Control: private, max-age=86400');
|
||||||
|
header('ETag: ' . $etag);
|
||||||
|
header('Last-Modified: ' . gmdate('D, d M Y H:i:s', $mtime) . ' GMT');
|
||||||
|
|
||||||
|
if (($_SERVER['HTTP_IF_NONE_MATCH'] ?? null) === $etag) {
|
||||||
|
http_response_code(304);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
header('Content-Type: image/jpeg');
|
||||||
|
header('Content-Length: ' . filesize($file));
|
||||||
|
readfile($file);
|
||||||
Reference in New Issue
Block a user