Remove course thumbnails and the ffmpeg dependency
Course cards go back to the colour-block art that previously sat under the captured frame, so the UI is unchanged apart from losing the photo. - Drop thumb.php, generateThumbnails(), captureThumbnail() and ensureThumbnailColumns(), plus the first-video tracking the scanner only kept to pick a frame source. - Drop the ffprobe fallback in probeDuration(); the pure-PHP mvhd parse is now the only scan-time probe, with the player's report on first play as the remaining fallback. - Drop THUMBNAILS_DIR/THUMBNAIL_SEEK_PERCENT from config, the thumbnail_* columns from schema.sql, the thumbnail counters from scan output, .thumb img from the stylesheet, and the matching README parts. Existing databases keep two now-unused folders.thumbnail_* columns; they are inert and can be dropped by hand if wanted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014oyi9DMdhALrGz989MNNZJ
This commit is contained in:
@@ -20,8 +20,7 @@ views switch without page reloads and browser back/forward still work:
|
||||
| `#/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.
|
||||
Only `stream.php` (the mp4 bytes) is a plain HTTP resource.
|
||||
|
||||
- Recursively scans a videos directory for `.mp4` files and mirrors the folder
|
||||
hierarchy (courses → sections → videos, any nesting depth).
|
||||
@@ -37,8 +36,6 @@ are plain HTTP resources.
|
||||
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
|
||||
(no path traversal).
|
||||
|
||||
@@ -74,15 +71,9 @@ Top-level folders become courses on the home page.
|
||||
the web server / PHP user).
|
||||
- DB credentials (change the password in production).
|
||||
- `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.
|
||||
No external binaries are needed — PHP with PDO/MySQL is the whole
|
||||
dependency list.
|
||||
|
||||
3. **Run** — with the PHP built-in server for testing:
|
||||
|
||||
@@ -128,29 +119,6 @@ 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
|
||||
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
|
||||
@@ -201,7 +169,7 @@ 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
|
||||
the same across renders and reloads. That hue drives its card art, its initial
|
||||
badge and its progress bar.
|
||||
|
||||
anime.js drives the motion: views fade and stagger in, progress bars grow from
|
||||
@@ -241,9 +209,8 @@ next scan creates it.
|
||||
- Folder progress is computed over the folder's entire subtree using a path
|
||||
prefix match, with partial credit for videos in progress.
|
||||
- Video duration is read at scan time by parsing the mp4's `mvhd` header in
|
||||
pure PHP (ffprobe fallback if the parse fails), so listings show durations
|
||||
before anyone plays the video. The browser still reports the duration on
|
||||
first play as a last-resort fallback.
|
||||
pure PHP, so listings show durations before anyone plays the video. If a file
|
||||
can't be parsed, the browser reports its duration on first play instead.
|
||||
|
||||
## Production notes
|
||||
|
||||
|
||||
+2
-1
@@ -55,7 +55,8 @@ $stmt = $db->prepare(
|
||||
);
|
||||
$stmt->execute([$userId, $videoId, $position, $duration > 0 ? $duration : null, $completed]);
|
||||
|
||||
// Cache the duration on the video row so listings can show it without ffprobe.
|
||||
// Cache the duration on the video row so listings can show it without waiting
|
||||
// for someone to play the video (and for files the scanner couldn't parse).
|
||||
if ($duration > 0) {
|
||||
$db->prepare('UPDATE videos SET duration_seconds = ? WHERE id = ? AND duration_seconds IS NULL')
|
||||
->execute([$duration, $videoId]);
|
||||
|
||||
+5
-9
@@ -348,14 +348,10 @@
|
||||
const bar = (percent, hue) =>
|
||||
`<div class="bar"${hue ? ` style="--c:${hue}"` : ''}><span style="width:${Math.max(0, Math.min(100, percent))}%"></span></div>`;
|
||||
|
||||
// A course thumbnail is a real captured frame when the scanner made one
|
||||
// (see generateThumbnails() server-side); until then, or if the request
|
||||
// 404s, onerror drops the broken <img> and the colour block underneath
|
||||
// (already painted — cat/dur/play/percent all sit above it) shows through.
|
||||
function thumb(hue, { cat, dur, play, percent, courseId } = {}) {
|
||||
// Courses have no cover art, so the card art is a colour block in the
|
||||
// course's own hue, with the cat/dur/play/percent overlays on top.
|
||||
function thumb(hue, { cat, dur, play, percent } = {}) {
|
||||
return `<div class="thumb" style="--c:${hue}">
|
||||
${courseId ? `<img src="thumb.php?course_id=${courseId}" alt="" loading="lazy"
|
||||
onerror="this.remove()">` : ''}
|
||||
${cat ? `<span class="thumb-cat">${esc(cat)}</span>` : ''}
|
||||
${dur ? `<span class="thumb-dur">${esc(dur)}</span>` : ''}
|
||||
${play ? `<span class="thumb-play"><span>${iconFill('play', 20)}</span></span>` : ''}
|
||||
@@ -367,7 +363,7 @@
|
||||
function courseCard(c) {
|
||||
const hue = hueOf(c.id);
|
||||
return `<a class="course-card" href="#/folder/${c.id}">
|
||||
${thumb(hue, { cat: SHELF_NAME[c.state], dur: fmtLong(c.duration), courseId: c.id })}
|
||||
${thumb(hue, { cat: SHELF_NAME[c.state], dur: fmtLong(c.duration) })}
|
||||
<div class="course-card-body">
|
||||
<div class="course-title">${esc(c.name)}</div>
|
||||
<div class="course-meta">${esc(courseMeta(c))}</div>
|
||||
@@ -540,7 +536,7 @@
|
||||
const left = resume.duration ? Math.max(0, resume.duration - resume.position) : 0;
|
||||
const hue = hueOf(resume.course_id ?? resume.id);
|
||||
return `<div class="hero">
|
||||
<div class="hero-art">${thumb(hue, { play: true, percent: pct, courseId: resume.course_id })}</div>
|
||||
<div class="hero-art">${thumb(hue, { play: true, percent: pct })}</div>
|
||||
<div class="hero-body">
|
||||
<span class="hero-eyebrow">Continue watching</span>
|
||||
<div class="hero-title">${esc(resume.name)}</div>
|
||||
|
||||
@@ -568,17 +568,6 @@ button, input { font-family: inherit; font-size: inherit; }
|
||||
}
|
||||
.thumb::before { top: -30%; right: -12%; width: 62%; background: rgba(255, 255, 255, .2); }
|
||||
.thumb::after { left: -8%; bottom: -32%; width: 48%; background: rgba(16, 15, 13, .14); }
|
||||
/* A captured frame sits above the decorative blobs (opaque, so it fully
|
||||
hides them once loaded) but below the text/icon overlays. onerror removes
|
||||
this element on a 404, leaving the colour-block placeholder untouched. */
|
||||
.thumb img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
z-index: 1;
|
||||
}
|
||||
.thumb-cat {
|
||||
position: absolute;
|
||||
left: 10px; top: 8px;
|
||||
|
||||
@@ -18,10 +18,3 @@ define('COMPLETE_THRESHOLD', 0.95);
|
||||
|
||||
// Allow new users to self-register from the login page.
|
||||
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);
|
||||
|
||||
+9
-149
@@ -8,21 +8,14 @@ function h(?string $s): string
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Video duration probing (used by the scanner so listings can show durations
|
||||
* before anyone plays the video). Pure-PHP mp4 parse first, ffprobe fallback.
|
||||
* before anyone plays the video). Pure PHP — no external tools involved; a
|
||||
* file the mp4 parser can't read simply has no duration until the player
|
||||
* reports one on first play.
|
||||
* ------------------------------------------------------------------------- */
|
||||
function probeDuration(string $file): ?float
|
||||
{
|
||||
$d = mp4Duration($file);
|
||||
if ($d !== null && $d > 0) return $d;
|
||||
|
||||
if (function_exists('shell_exec')) {
|
||||
$out = @shell_exec('ffprobe -v error -show_entries format=duration -of csv=p=0 '
|
||||
. escapeshellarg($file) . ' 2>/dev/null');
|
||||
if ($out !== null && is_numeric(trim($out))) {
|
||||
return (float)trim($out);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
return ($d !== null && $d > 0) ? $d : null;
|
||||
}
|
||||
|
||||
/** Read the duration from an mp4's moov/mvhd box. Returns seconds or null. */
|
||||
@@ -147,7 +140,6 @@ 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)
|
||||
@@ -173,14 +165,9 @@ function scanLibrary(): array
|
||||
id = LAST_INSERT_ID(id)'
|
||||
);
|
||||
|
||||
// $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)
|
||||
$walk = function (string $dir, ?int $parentId, string $relBase, int $depth)
|
||||
use (&$walk, $db, $upsertFolder, $upsertVideo, $upsertQuiz,
|
||||
&$stats, &$seenFolderIds, &$seenVideoIds, &$seenQuizIds, &$firstVideo): void {
|
||||
&$stats, &$seenFolderIds, &$seenVideoIds, &$seenQuizIds): void {
|
||||
|
||||
$entries = scandir($dir) ?: [];
|
||||
$entries = array_values(array_filter($entries, fn($e) => $e[0] !== '.'));
|
||||
@@ -198,19 +185,15 @@ function scanLibrary(): array
|
||||
$folderId = (int)$db->lastInsertId();
|
||||
$seenFolderIds[] = $folderId;
|
||||
$stats['folders']++;
|
||||
$walk($full, $folderId, $rel, $depth + 1, $courseId ?? $folderId);
|
||||
$walk($full, $folderId, $rel, $depth + 1);
|
||||
} 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++]);
|
||||
$videoId = (int)$db->lastInsertId();
|
||||
$seenVideoIds[] = $videoId;
|
||||
$seenVideoIds[] = (int)$db->lastInsertId();
|
||||
$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;
|
||||
@@ -229,7 +212,7 @@ function scanLibrary(): array
|
||||
|
||||
$db->beginTransaction();
|
||||
try {
|
||||
$walk($root, null, '', 0, null);
|
||||
$walk($root, null, '', 0);
|
||||
|
||||
// Anything not seen this pass no longer exists on disk.
|
||||
$flagMissing = function (string $table, array $seenIds) use ($db): int {
|
||||
@@ -267,134 +250,11 @@ 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
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
@@ -11,5 +11,4 @@ if (PHP_SAPI !== 'cli') {
|
||||
$stats = scanLibrary();
|
||||
echo "Scan complete: {$stats['folders']} folders, {$stats['videos']} videos, {$stats['quizzes']} quizzes"
|
||||
. " ({$stats['missing_folders']} folders / {$stats['missing_videos']} videos"
|
||||
. " / {$stats['missing_quizzes']} quizzes flagged missing)\n"
|
||||
. "Thumbnails: {$stats['thumbnails_generated']} generated, {$stats['thumbnails_failed']} failed\n";
|
||||
. " / {$stats['missing_quizzes']} quizzes flagged missing)\n";
|
||||
|
||||
+8
-14
@@ -19,21 +19,15 @@ CREATE TABLE IF NOT EXISTS app_meta (
|
||||
|
||||
-- One row per directory found under VIDEOS_ROOT.
|
||||
-- `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 (
|
||||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
parent_id INT UNSIGNED NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
path VARCHAR(700) NOT NULL UNIQUE,
|
||||
depth INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
sort_order INT UNSIGNED 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,
|
||||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
parent_id INT UNSIGNED NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
path VARCHAR(700) NOT NULL UNIQUE,
|
||||
depth INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
sort_order INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
is_missing TINYINT(1) NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_folders_parent FOREIGN KEY (parent_id)
|
||||
REFERENCES folders(id) ON DELETE CASCADE,
|
||||
INDEX idx_folders_parent (parent_id)
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
<?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