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:
Harwinder Sekhon
2026-07-27 14:06:06 -04:00
parent c4c88e52da
commit 91edba56d6
9 changed files with 31 additions and 275 deletions
+9 -149
View File
@@ -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
* ------------------------------------------------------------------------- */