diff --git a/README.md b/README.md index a47e711..3cabf86 100644 --- a/README.md +++ b/README.md @@ -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/.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 - `` 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 diff --git a/api/progress.php b/api/progress.php index 0bf08dc..5119440 100644 --- a/api/progress.php +++ b/api/progress.php @@ -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]); diff --git a/assets/app.js b/assets/app.js index 51bc233..d38a028 100644 --- a/assets/app.js +++ b/assets/app.js @@ -348,14 +348,10 @@ const bar = (percent, hue) => `
`; - // 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 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 `
- ${courseId ? `` : ''} ${cat ? `${esc(cat)}` : ''} ${dur ? `${esc(dur)}` : ''} ${play ? `${iconFill('play', 20)}` : ''} @@ -367,7 +363,7 @@ function courseCard(c) { const hue = hueOf(c.id); return ` - ${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) })}
${esc(c.name)}
${esc(courseMeta(c))}
@@ -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 `
-
${thumb(hue, { play: true, percent: pct, courseId: resume.course_id })}
+
${thumb(hue, { play: true, percent: pct })}
Continue watching
${esc(resume.name)}
diff --git a/assets/style.css b/assets/style.css index 5d61761..56487f5 100644 --- a/assets/style.css +++ b/assets/style.css @@ -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; diff --git a/config.example.php b/config.example.php index 787b5ca..208ea81 100644 --- a/config.example.php +++ b/config.example.php @@ -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); diff --git a/includes/functions.php b/includes/functions.php index ce85589..380c3ca 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -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 * ------------------------------------------------------------------------- */ diff --git a/scan.php b/scan.php index ff6942b..6f88db7 100644 --- a/scan.php +++ b/scan.php @@ -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"; diff --git a/schema.sql b/schema.sql index f552baf..f7e39d5 100644 --- a/schema.sql +++ b/schema.sql @@ -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) diff --git a/thumb.php b/thumb.php deleted file mode 100644 index 65537d0..0000000 --- a/thumb.php +++ /dev/null @@ -1,43 +0,0 @@ - 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);