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. 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` |
|
| `#/search/Q` | courses, videos and quizzes matching `Q` |
|
||||||
| `#/settings` | library folder, preferences, reset |
|
| `#/settings` | library folder, preferences, reset |
|
||||||
|
|
||||||
Only `stream.php` (the mp4 bytes) and `thumb.php` (cached course thumbnails)
|
Only `stream.php` (the mp4 bytes) is a plain HTTP resource.
|
||||||
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).
|
||||||
@@ -37,8 +36,6 @@ are plain HTTP resources.
|
|||||||
library between **shelves and catalog**; both choices are remembered.
|
library between **shelves and catalog**; both choices are remembered.
|
||||||
- **Shelves** group courses by how far you are: in progress, finished, not
|
- **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.
|
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).
|
||||||
|
|
||||||
@@ -74,15 +71,9 @@ 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
|
No external binaries are needed — PHP with PDO/MySQL is the whole
|
||||||
fallback for video duration if the pure-PHP mp4 parser can't read a file).
|
dependency list.
|
||||||
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:
|
||||||
|
|
||||||
@@ -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
|
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
|
## Search
|
||||||
|
|
||||||
The top-bar box searches as you type (from two characters, debounced) and also
|
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
|
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
|
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.
|
badge and its progress bar.
|
||||||
|
|
||||||
anime.js drives the motion: views fade and stagger in, progress bars grow from
|
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
|
- Folder progress is computed over the folder's entire subtree using a path
|
||||||
prefix match, with partial credit for videos in progress.
|
prefix match, with partial credit for videos in progress.
|
||||||
- Video duration is read at scan time by parsing the mp4's `mvhd` header in
|
- 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
|
pure PHP, so listings show durations before anyone plays the video. If a file
|
||||||
before anyone plays the video. The browser still reports the duration on
|
can't be parsed, the browser reports its duration on first play instead.
|
||||||
first play as a last-resort fallback.
|
|
||||||
|
|
||||||
## Production notes
|
## Production notes
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -55,7 +55,8 @@ $stmt = $db->prepare(
|
|||||||
);
|
);
|
||||||
$stmt->execute([$userId, $videoId, $position, $duration > 0 ? $duration : null, $completed]);
|
$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) {
|
if ($duration > 0) {
|
||||||
$db->prepare('UPDATE videos SET duration_seconds = ? WHERE id = ? AND duration_seconds IS NULL')
|
$db->prepare('UPDATE videos SET duration_seconds = ? WHERE id = ? AND duration_seconds IS NULL')
|
||||||
->execute([$duration, $videoId]);
|
->execute([$duration, $videoId]);
|
||||||
|
|||||||
+5
-9
@@ -348,14 +348,10 @@
|
|||||||
const bar = (percent, hue) =>
|
const bar = (percent, hue) =>
|
||||||
`<div class="bar"${hue ? ` style="--c:${hue}"` : ''}><span style="width:${Math.max(0, Math.min(100, percent))}%"></span></div>`;
|
`<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
|
// Courses have no cover art, so the card art is a colour block in the
|
||||||
// (see generateThumbnails() server-side); until then, or if the request
|
// course's own hue, with the cat/dur/play/percent overlays on top.
|
||||||
// 404s, onerror drops the broken <img> and the colour block underneath
|
function thumb(hue, { cat, dur, play, percent } = {}) {
|
||||||
// (already painted — cat/dur/play/percent all sit above it) shows through.
|
|
||||||
function thumb(hue, { cat, dur, play, percent, courseId } = {}) {
|
|
||||||
return `<div class="thumb" style="--c:${hue}">
|
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>` : ''}
|
${cat ? `<span class="thumb-cat">${esc(cat)}</span>` : ''}
|
||||||
${dur ? `<span class="thumb-dur">${esc(dur)}</span>` : ''}
|
${dur ? `<span class="thumb-dur">${esc(dur)}</span>` : ''}
|
||||||
${play ? `<span class="thumb-play"><span>${iconFill('play', 20)}</span></span>` : ''}
|
${play ? `<span class="thumb-play"><span>${iconFill('play', 20)}</span></span>` : ''}
|
||||||
@@ -367,7 +363,7 @@
|
|||||||
function courseCard(c) {
|
function courseCard(c) {
|
||||||
const hue = hueOf(c.id);
|
const hue = hueOf(c.id);
|
||||||
return `<a class="course-card" href="#/folder/${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-card-body">
|
||||||
<div class="course-title">${esc(c.name)}</div>
|
<div class="course-title">${esc(c.name)}</div>
|
||||||
<div class="course-meta">${esc(courseMeta(c))}</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 left = resume.duration ? Math.max(0, resume.duration - resume.position) : 0;
|
||||||
const hue = hueOf(resume.course_id ?? resume.id);
|
const hue = hueOf(resume.course_id ?? resume.id);
|
||||||
return `<div class="hero">
|
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">
|
<div class="hero-body">
|
||||||
<span class="hero-eyebrow">Continue watching</span>
|
<span class="hero-eyebrow">Continue watching</span>
|
||||||
<div class="hero-title">${esc(resume.name)}</div>
|
<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::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); }
|
.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 {
|
.thumb-cat {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 10px; top: 8px;
|
left: 10px; top: 8px;
|
||||||
|
|||||||
@@ -18,10 +18,3 @@ 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);
|
|
||||||
|
|||||||
+9
-149
@@ -8,21 +8,14 @@ function h(?string $s): string
|
|||||||
|
|
||||||
/* ---------------------------------------------------------------------------
|
/* ---------------------------------------------------------------------------
|
||||||
* Video duration probing (used by the scanner so listings can show durations
|
* 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
|
function probeDuration(string $file): ?float
|
||||||
{
|
{
|
||||||
$d = mp4Duration($file);
|
$d = mp4Duration($file);
|
||||||
if ($d !== null && $d > 0) return $d;
|
return ($d !== null && $d > 0) ? $d : null;
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Read the duration from an mp4's moov/mvhd box. Returns seconds or null. */
|
/** Read the duration from an mp4's moov/mvhd box. Returns seconds or null. */
|
||||||
@@ -147,7 +140,6 @@ 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)
|
||||||
@@ -173,14 +165,9 @@ function scanLibrary(): array
|
|||||||
id = LAST_INSERT_ID(id)'
|
id = LAST_INSERT_ID(id)'
|
||||||
);
|
);
|
||||||
|
|
||||||
// $courseId identifies which top-level course folder the current directory
|
$walk = function (string $dir, ?int $parentId, string $relBase, int $depth)
|
||||||
// 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, &$firstVideo): void {
|
&$stats, &$seenFolderIds, &$seenVideoIds, &$seenQuizIds): 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] !== '.'));
|
||||||
@@ -198,19 +185,15 @@ 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, $courseId ?? $folderId);
|
$walk($full, $folderId, $rel, $depth + 1);
|
||||||
} 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++]);
|
||||||
$videoId = (int)$db->lastInsertId();
|
$seenVideoIds[] = (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;
|
||||||
@@ -229,7 +212,7 @@ function scanLibrary(): array
|
|||||||
|
|
||||||
$db->beginTransaction();
|
$db->beginTransaction();
|
||||||
try {
|
try {
|
||||||
$walk($root, null, '', 0, null);
|
$walk($root, null, '', 0);
|
||||||
|
|
||||||
// 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 {
|
||||||
@@ -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'));
|
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
|
||||||
* ------------------------------------------------------------------------- */
|
* ------------------------------------------------------------------------- */
|
||||||
|
|||||||
@@ -11,5 +11,4 @@ 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";
|
|
||||||
|
|||||||
@@ -19,10 +19,6 @@ CREATE TABLE IF NOT EXISTS app_meta (
|
|||||||
|
|
||||||
-- 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,
|
||||||
@@ -31,8 +27,6 @@ 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,
|
||||||
|
|||||||
@@ -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