From 3ae6c1ae2ed285ad5815fc7245b8f8eed68f45eb Mon Sep 17 00:00:00 2001 From: Harwinder Sekhon Date: Sat, 11 Jul 2026 10:25:35 -0400 Subject: [PATCH] CourseHub: AJAX video course player (PHP + MySQL + Bootstrap + Plyr) Scans nested course folders for mp4s, tracks per-user resume position and per-folder progress. SPA frontend with hash routing over a JSON API; range-request streaming via stream.php. --- .gitignore | 5 + README.md | 95 +++++++++ api/auth.php | 64 +++++++ api/library.php | 119 ++++++++++++ api/progress.php | 64 +++++++ api/scan.php | 20 ++ assets/app.js | 426 +++++++++++++++++++++++++++++++++++++++++ assets/style.css | 24 +++ config.example.php | 20 ++ includes/auth.php | 70 +++++++ includes/db.php | 16 ++ includes/functions.php | 221 +++++++++++++++++++++ index.php | 46 +++++ scan.php | 13 ++ schema.sql | 57 ++++++ stream.php | 68 +++++++ 16 files changed, 1328 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 api/auth.php create mode 100644 api/library.php create mode 100644 api/progress.php create mode 100644 api/scan.php create mode 100644 assets/app.js create mode 100644 assets/style.css create mode 100644 config.example.php create mode 100644 includes/auth.php create mode 100644 includes/db.php create mode 100644 includes/functions.php create mode 100644 index.php create mode 100644 scan.php create mode 100644 schema.sql create mode 100644 stream.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d8834ab --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +# Local configuration (contains DB credentials) — copy config.example.php to config.php +config.php + +# Video library lives outside version control +videos/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..f69c3af --- /dev/null +++ b/README.md @@ -0,0 +1,95 @@ +# CourseHub + +Self-hosted course video player: PHP 8 + MySQL/MariaDB + Bootstrap 5 + [Plyr](https://github.com/sampotts/plyr). + +Single-page app: `index.php` is a static shell; all navigation, login, browsing, +playback, and scanning happen over AJAX against the JSON API in `api/` +(`auth.php`, `library.php`, `progress.php`, `scan.php`), with hash-based routes +(`#/`, `#/folder/N`, `#/watch/N`) so views switch without page reloads and +browser back/forward still work. 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). +- Per-user **resume**: log in from any device and continue where you left off. +- **Progress tracking at every level** — each folder shows completed/total and a + percentage bar computed over its whole subtree. +- "Continue watching" strip, auto-play next video, playback speed, keyboard + shortcuts (Plyr), completed checkmarks, natural sorting (`Video2` before `Video10`). +- Seekable streaming via HTTP Range requests; files are addressed by DB id only + (no path traversal). + +## Layout on disk + +``` +videos/ +├── Course 1/ +│ ├── Intro/ +│ │ ├── Video1.mp4 +│ │ └── Video2.mp4 +│ └── Module 2/ +│ └── Advanced/ +│ └── lesson.mp4 +└── Course 2/ + └── ... +``` + +Top-level folders become courses on the home page. + +## Setup + +1. **Database** — create the schema and an app user: + + ```bash + sudo mysql < schema.sql + sudo mysql -e "CREATE USER IF NOT EXISTS 'coursehub'@'localhost' IDENTIFIED BY 'coursehub'; + GRANT ALL ON coursehub.* TO 'coursehub'@'localhost';" + ``` + +2. **Configure** — edit `config.php`: + - `VIDEOS_ROOT`: absolute path to your course folders (must be readable by + the web server / PHP user). + - DB credentials (change the password in production). + - `ALLOW_REGISTRATION`: set `false` once your users have accounts. + +3. **Run** — with the PHP built-in server for testing: + + ```bash + php -S 0.0.0.0:8080 -t /path/to/coursehub + ``` + + or point an Apache/Nginx + PHP-FPM vhost at the directory. + +4. **Index the library** — click **Rescan library** in the top bar, or: + + ```bash + php scan.php + ``` + + Rescan whenever you add/remove files. Re-scans are idempotent: existing + videos keep their IDs (and everyone's progress); files that disappeared are + hidden, not deleted, so progress survives if they come back. + +5. Open the site, create an account, and watch. + +## How progress works + +- The player POSTs `{video_id, position, duration}` to `api/progress.php` + every 5 s while playing, plus on pause/seek, via `navigator.sendBeacon` + when the tab is hidden or closed, and when navigating to another view + in the SPA. +- A video is marked **completed** at ≥ 95 % watched (`COMPLETE_THRESHOLD`). + Completion is sticky — rewatching doesn't un-complete 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 captured from the browser on first play (no ffprobe + dependency) and cached on the `videos` row. + +## Production notes + +- PHP streams the video in 512 KB chunks with Range support, which is fine for + a handful of users. For heavier use, serve files with `X-Accel-Redirect` + (Nginx) or `X-Sendfile` (Apache) instead of streaming through PHP. +- Put the app behind HTTPS and set `session.cookie_secure=1`. +- Deny direct web access to `includes/`, `config.php`, and `schema.sql` + (or place the videos and includes outside the docroot). diff --git a/api/auth.php b/api/auth.php new file mode 100644 index 0000000..8d6dd99 --- /dev/null +++ b/api/auth.php @@ -0,0 +1,64 @@ + {logged_in, username} +// POST api/auth.php {action, username?, password?} +// action: login | register | logout +require_once __DIR__ . '/../includes/auth.php'; + +header('Content-Type: application/json'); + +if ($_SERVER['REQUEST_METHOD'] === 'GET') { + echo json_encode([ + 'logged_in' => currentUserId() !== null, + 'username' => currentUsername(), + 'allow_registration' => ALLOW_REGISTRATION, + ]); + exit; +} + +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) ?: []; +$action = $data['action'] ?? ''; +$username = trim($data['username'] ?? ''); +$password = $data['password'] ?? ''; + +switch ($action) { + case 'login': + if (attemptLogin($username, $password)) { + echo json_encode(['ok' => true, 'username' => currentUsername()]); + } else { + http_response_code(401); + echo json_encode(['error' => 'Invalid username or password.']); + } + break; + + case 'register': + if (!ALLOW_REGISTRATION) { + http_response_code(403); + echo json_encode(['error' => 'Registration is disabled.']); + break; + } + $result = registerUser($username, $password); + if ($result === true) { + attemptLogin($username, $password); + echo json_encode(['ok' => true, 'username' => currentUsername()]); + } else { + http_response_code(422); + echo json_encode(['error' => $result]); + } + break; + + case 'logout': + $_SESSION = []; + session_destroy(); + echo json_encode(['ok' => true]); + break; + + default: + http_response_code(400); + echo json_encode(['error' => 'Unknown action']); +} diff --git a/api/library.php b/api/library.php new file mode 100644 index 0000000..bf890c2 --- /dev/null +++ b/api/library.php @@ -0,0 +1,119 @@ + courses + continue-watching +// GET api/library.php?view=folder&id=N -> folder contents + progress +// GET api/library.php?view=video&id=N -> video + playlist + resume info +require_once __DIR__ . '/../includes/auth.php'; +require_once __DIR__ . '/../includes/functions.php'; + +header('Content-Type: application/json'); +$userId = requireLoginApi(); + +function folderSummary(array $f, int $userId): array +{ + return [ + 'id' => (int)$f['id'], + 'name' => $f['name'], + 'progress' => folderProgress($userId, $f['path']), + ]; +} + +function videoSummary(array $v): array +{ + $duration = $v['duration_seconds'] ?? $v['watched_duration'] ?? null; + $position = (float)($v['position_seconds'] ?? 0); + return [ + 'id' => (int)$v['id'], + 'name' => $v['name'], + 'duration' => $duration !== null ? (float)$duration : null, + 'position' => $position, + 'completed' => (bool)($v['completed'] ?? false), + 'percent' => ($duration ?? 0) > 0 ? min(100, (int)round(100 * $position / $duration)) : 0, + ]; +} + +function crumbList(array $folder): array +{ + return array_map( + fn($c) => ['id' => (int)$c['id'], 'name' => $c['name']], + folderBreadcrumbs($folder) + ); +} + +$view = $_GET['view'] ?? 'home'; + +switch ($view) { + case 'home': + echo json_encode([ + 'courses' => array_map(fn($c) => folderSummary($c, $userId), childFolders(null)), + 'continue' => array_map(fn($r) => [ + 'id' => (int)$r['id'], + 'name' => $r['name'], + 'folder_path' => $r['folder_path'], + 'position' => (float)$r['position_seconds'], + 'duration' => $r['duration_seconds'] !== null ? (float)$r['duration_seconds'] : null, + ], continueWatching($userId)), + ]); + break; + + case 'folder': + $folder = getFolder((int)($_GET['id'] ?? 0)); + if (!$folder) { + http_response_code(404); + echo json_encode(['error' => 'Folder not found']); + break; + } + echo json_encode([ + 'folder' => ['id' => (int)$folder['id'], 'name' => $folder['name']], + 'breadcrumbs' => crumbList($folder), + 'progress' => folderProgress($userId, $folder['path']), + 'subfolders' => array_map(fn($f) => folderSummary($f, $userId), + childFolders((int)$folder['id'])), + 'videos' => array_map('videoSummary', + folderVideos((int)$folder['id'], $userId)), + ]); + break; + + case 'video': + $video = getVideo((int)($_GET['id'] ?? 0)); + if (!$video) { + http_response_code(404); + echo json_encode(['error' => 'Video not found']); + break; + } + $folder = getFolder((int)$video['folder_id']); + $playlist = folderVideos((int)$folder['id'], $userId); + + $stmt = db()->prepare( + 'SELECT position_seconds, completed FROM video_progress + WHERE user_id = ? AND video_id = ?' + ); + $stmt->execute([$userId, (int)$video['id']]); + $saved = $stmt->fetch() ?: null; + $resumeAt = ($saved && !$saved['completed'] && $saved['position_seconds'] > 5) + ? (float)$saved['position_seconds'] : 0; + + $prevId = $nextId = null; + foreach ($playlist as $i => $p) { + if ((int)$p['id'] === (int)$video['id']) { + $prevId = isset($playlist[$i - 1]) ? (int)$playlist[$i - 1]['id'] : null; + $nextId = isset($playlist[$i + 1]) ? (int)$playlist[$i + 1]['id'] : null; + break; + } + } + + echo json_encode([ + 'video' => ['id' => (int)$video['id'], 'name' => $video['name']], + 'folder' => ['id' => (int)$folder['id'], 'name' => $folder['name']], + 'breadcrumbs' => crumbList($folder), + 'playlist' => array_map('videoSummary', $playlist), + 'resume_at' => $resumeAt, + 'prev_id' => $prevId, + 'next_id' => $nextId, + 'stream_url' => 'stream.php?id=' . (int)$video['id'], + ]); + break; + + default: + http_response_code(400); + echo json_encode(['error' => 'Unknown view']); +} diff --git a/api/progress.php b/api/progress.php new file mode 100644 index 0000000..0bf08dc --- /dev/null +++ b/api/progress.php @@ -0,0 +1,64 @@ + saved progress for this user/video +// POST api/progress.php -> save progress +// JSON body: {video_id, position, duration} +// (also accepts form-encoded fields; used by navigator.sendBeacon) +require_once __DIR__ . '/../includes/auth.php'; +require_once __DIR__ . '/../includes/functions.php'; + +header('Content-Type: application/json'); +$userId = requireLoginApi(); + +if ($_SERVER['REQUEST_METHOD'] === 'GET') { + $videoId = (int)($_GET['video_id'] ?? 0); + $stmt = db()->prepare( + 'SELECT position_seconds, duration_seconds, completed + FROM video_progress WHERE user_id = ? AND video_id = ?' + ); + $stmt->execute([$userId, $videoId]); + echo json_encode($stmt->fetch() ?: ['position_seconds' => 0, 'duration_seconds' => null, 'completed' => 0]); + exit; +} + +if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + http_response_code(405); + echo json_encode(['error' => 'Method not allowed']); + exit; +} + +$raw = file_get_contents('php://input'); +$data = json_decode($raw, true); +if (!is_array($data)) { + parse_str($raw, $data); // sendBeacon posts form-encoded +} + +$videoId = (int)($data['video_id'] ?? 0); +$position = max(0, (float)($data['position'] ?? 0)); +$duration = (float)($data['duration'] ?? 0); + +if ($videoId <= 0 || !getVideo($videoId)) { + http_response_code(400); + echo json_encode(['error' => 'Unknown video']); + exit; +} + +$completed = ($duration > 0 && $position / $duration >= COMPLETE_THRESHOLD) ? 1 : 0; + +$db = db(); +$stmt = $db->prepare( + 'INSERT INTO video_progress (user_id, video_id, position_seconds, duration_seconds, completed) + VALUES (?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + position_seconds = VALUES(position_seconds), + duration_seconds = VALUES(duration_seconds), + completed = GREATEST(completed, VALUES(completed))' // once complete, stays complete +); +$stmt->execute([$userId, $videoId, $position, $duration > 0 ? $duration : null, $completed]); + +// Cache the duration on the video row so listings can show it without ffprobe. +if ($duration > 0) { + $db->prepare('UPDATE videos SET duration_seconds = ? WHERE id = ? AND duration_seconds IS NULL') + ->execute([$duration, $videoId]); +} + +echo json_encode(['ok' => true, 'completed' => (bool)$completed]); diff --git a/api/scan.php b/api/scan.php new file mode 100644 index 0000000..1e622b2 --- /dev/null +++ b/api/scan.php @@ -0,0 +1,20 @@ + rescan the library, returns stats. +require_once __DIR__ . '/../includes/auth.php'; +require_once __DIR__ . '/../includes/functions.php'; + +header('Content-Type: application/json'); +requireLoginApi(); + +if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + http_response_code(405); + echo json_encode(['error' => 'Method not allowed']); + exit; +} + +try { + echo json_encode(['ok' => true, 'stats' => scanLibrary()]); +} catch (Throwable $e) { + http_response_code(500); + echo json_encode(['error' => $e->getMessage()]); +} diff --git a/assets/app.js b/assets/app.js new file mode 100644 index 0000000..d4da139 --- /dev/null +++ b/assets/app.js @@ -0,0 +1,426 @@ +/* CourseHub SPA — hash router + fetch API + Plyr player. + * Routes: #/ home (courses, continue watching) + * #/folder/N browse a folder + * #/watch/N video player + */ +(function () { + 'use strict'; + + const app = document.getElementById('app'); + const state = { user: null }; + let cleanupView = null; // teardown hook for the active view (player etc.) + + /* ---------- helpers ---------- */ + + const esc = (s) => String(s ?? '').replace(/[&<>"']/g, + (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); + + function fmt(seconds) { + if (!seconds || seconds <= 0) return ''; + const s = Math.round(seconds); + const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = s % 60; + return h ? `${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}` + : `${m}:${String(sec).padStart(2, '0')}`; + } + + function toast(message, kind = 'success') { + const wrap = document.createElement('div'); + wrap.innerHTML = ` + `; + const el = wrap.firstElementChild; + document.getElementById('toasts').appendChild(el); + const t = new bootstrap.Toast(el, { delay: 4000 }); + el.addEventListener('hidden.bs.toast', () => el.remove()); + t.show(); + } + + async function api(url, options = {}) { + if (options.json) { + options.method = options.method || 'POST'; + options.headers = { 'Content-Type': 'application/json' }; + options.body = JSON.stringify(options.json); + delete options.json; + } + const res = await fetch(url, options); + const data = await res.json().catch(() => ({})); + if (res.status === 401) { + state.user = null; + updateNav(); + renderLogin(); + throw new Error(data.error || 'Not logged in'); + } + if (!res.ok) throw new Error(data.error || `Request failed (${res.status})`); + return data; + } + + function spinner() { + app.innerHTML = '
'; + } + + function progressBar(percent, cls = 'bg-success') { + return `
+
+
`; + } + + function breadcrumbs(crumbs, tail = '') { + const items = crumbs.map((c, i) => + (i === crumbs.length - 1 && !tail) + ? `` + : `` + ).join(''); + return ``; + } + + /* ---------- nav / auth ---------- */ + + function updateNav() { + const navUser = document.getElementById('nav-user'); + navUser.classList.toggle('d-none', !state.user); + document.getElementById('nav-username').textContent = state.user || ''; + } + + function renderLogin(error = '') { + if (cleanupView) { cleanupView(); cleanupView = null; } + app.innerHTML = ` +
+
+
+
+

CourseHub

+
${error ? `
${esc(error)}
` : ''}
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+
`; + + const form = document.getElementById('login-form'); + let action = 'login'; + form.querySelectorAll('button[data-action]').forEach((b) => + b.addEventListener('click', () => { action = b.dataset.action; })); + form.addEventListener('submit', async (e) => { + e.preventDefault(); + const fd = new FormData(form); + try { + const data = await api('api/auth.php', { + json: { action, username: fd.get('username'), password: fd.get('password') }, + }); + state.user = data.username; + updateNav(); + toast(action === 'register' ? 'Account created — welcome!' : `Welcome back, ${data.username}!`); + route(); + } catch (err) { + document.getElementById('login-error').innerHTML = + `
${esc(err.message)}
`; + } + }); + } + + /* ---------- views ---------- */ + + async function renderHome() { + spinner(); + const data = await api('api/library.php?view=home'); + + const cont = data.continue.length ? ` +

Continue watching

+
+ ${data.continue.map((r) => { + const pct = r.duration ? Math.min(100, Math.round(100 * r.position / r.duration)) : 0; + return ` + `; + }).join('')} +
` : ''; + + const courses = data.courses.length ? data.courses.map((c) => ` +
+ +
+
+ +
${esc(c.name)}
+
+ ${progressBar(c.progress.percent)} +
+ ${c.progress.completed} / ${c.progress.total} videos completed · ${c.progress.percent}% +
+
+
+
`).join('') + : `
+ No courses found yet. Add course folders to the videos directory and click + Rescan library.
`; + + app.innerHTML = `${cont} +

Courses

+
${courses}
`; + } + + async function renderFolder(id) { + spinner(); + const data = await api(`api/library.php?view=folder&id=${id}`); + + const subfolders = data.subfolders.length ? ` +

Sections

+
+ ${data.subfolders.map((f) => ` + + +
+
${esc(f.name)}
+
${progressBar(f.progress.percent)}
+
+ ${f.progress.completed}/${f.progress.total} + +
`).join('')} +
` : ''; + + const videos = data.videos.length ? ` +

Videos

+
+ ${data.videos.map((v) => { + const icon = v.completed + ? '' + : (v.position > 5 + ? '' + : ''); + return ` + + ${icon} +
+
${esc(v.name)}
+ ${!v.completed && v.percent > 0 ? `
${progressBar(v.percent, '')}
` : ''} +
+ ${fmt(v.duration)} +
`; + }).join('')} +
` : (!data.subfolders.length ? '
This folder is empty.
' : ''); + + app.innerHTML = ` + ${breadcrumbs(data.breadcrumbs)} +
+

${esc(data.folder.name)}

+
${data.progress.completed} / ${data.progress.total} videos completed
+
+
${progressBar(data.progress.percent)}
+ ${subfolders}${videos}`; + } + + async function renderWatch(id) { + spinner(); + const data = await api(`api/library.php?view=video&id=${id}`); + + const playlist = data.playlist.map((p) => { + const active = p.id === data.video.id; + const icon = p.completed + ? `` + : (active ? '' : ''); + return ` + + ${icon} + ${esc(p.name)} + ${fmt(p.duration)} + `; + }).join(''); + + app.innerHTML = ` + ${breadcrumbs(data.breadcrumbs, data.video.name)} +
+
+
+ +
+
+

${esc(data.video.name)}

+
+ ${data.prev_id ? ` + Previous` : ''} + ${data.next_id ? ` + Next` : ''} +
+
+ ${data.resume_at > 0 + ? `
+ Resuming from ${fmt(data.resume_at)}
` : ''} +
+
+
+
+
${esc(data.folder.name)}
+
${playlist}
+
+
+
`; + + cleanupView = initPlayer(data); + } + + /* ---------- player + progress tracking ---------- */ + + function initPlayer(data) { + const el = document.getElementById('player'); + const statusEl = document.getElementById('save-status'); + const player = new Plyr(el, { + keyboard: { focused: true, global: true }, + seekTime: 10, + speed: { selected: 1, options: [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2] }, + controls: ['play-large', 'rewind', 'play', 'fast-forward', 'progress', + 'current-time', 'duration', 'mute', 'volume', 'settings', + 'pip', 'fullscreen'], + }); + + let resumed = data.resume_at <= 0; + el.addEventListener('loadedmetadata', () => { + if (!resumed && data.resume_at < el.duration - 2) el.currentTime = data.resume_at; + resumed = true; + }); + + let lastSaved = -1; + let saveTimer = null; + + const payload = () => ({ + video_id: data.video.id, + position: el.currentTime || 0, + duration: el.duration || 0, + }); + + async function save() { + const p = payload(); + if (!p.duration || Math.abs(p.position - lastSaved) < 1) return; + lastSaved = p.position; + try { + const res = await api('api/progress.php', { json: p, keepalive: true }); + if (statusEl) { + statusEl.innerHTML = res.completed + ? 'Completed' + : 'Progress saved'; + } + } catch (e) { /* transient — next heartbeat retries */ } + } + + function saveBeacon() { + const p = payload(); + if (!p.duration) return; + navigator.sendBeacon('api/progress.php', + new URLSearchParams(Object.entries(p).map(([k, v]) => [k, String(v)]))); + } + + player.on('playing', () => { if (!saveTimer) saveTimer = setInterval(save, 5000); }); + player.on('pause', save); + player.on('seeked', save); + player.on('ended', () => { + save(); + if (data.next_id) { + if (statusEl) statusEl.textContent = 'Playing next video…'; + setTimeout(() => { location.hash = `#/watch/${data.next_id}`; }, 1500); + } + }); + + const onHide = () => { if (document.visibilityState === 'hidden') saveBeacon(); }; + document.addEventListener('visibilitychange', onHide); + window.addEventListener('pagehide', saveBeacon); + + return function cleanup() { // called when navigating away + saveBeacon(); + if (saveTimer) clearInterval(saveTimer); + document.removeEventListener('visibilitychange', onHide); + window.removeEventListener('pagehide', saveBeacon); + try { player.destroy(); } catch (e) { /* already gone */ } + }; + } + + /* ---------- router ---------- */ + + async function route() { + if (cleanupView) { cleanupView(); cleanupView = null; } + if (!state.user) { renderLogin(); return; } + + const hash = location.hash || '#/'; + let m; + try { + if ((m = hash.match(/^#\/folder\/(\d+)$/))) await renderFolder(+m[1]); + else if ((m = hash.match(/^#\/watch\/(\d+)$/))) await renderWatch(+m[1]); + else await renderHome(); + } catch (err) { + if (state.user) { + app.innerHTML = `
${esc(err.message)}
+ Back to courses`; + } + } + } + + /* ---------- global buttons ---------- */ + + document.getElementById('btn-logout').addEventListener('click', async () => { + try { await api('api/auth.php', { json: { action: 'logout' } }); } catch (e) { /* session already gone */ } + state.user = null; + updateNav(); + location.hash = '#/'; + renderLogin(); + }); + + document.getElementById('btn-rescan').addEventListener('click', async (e) => { + const btn = e.currentTarget; + btn.disabled = true; + btn.innerHTML = 'Scanning…'; + try { + const res = await api('api/scan.php', { method: 'POST' }); + toast(`Scan complete: ${res.stats.folders} folders, ${res.stats.videos} videos indexed.`); + route(); // refresh current view with new data + } catch (err) { + toast(err.message, 'danger'); + } finally { + btn.disabled = false; + btn.innerHTML = 'Rescan library'; + } + }); + + /* ---------- boot ---------- */ + + window.addEventListener('hashchange', route); + + (async function boot() { + try { + const me = await fetch('api/auth.php').then((r) => r.json()); + state.user = me.logged_in ? me.username : null; + } catch (e) { + state.user = null; + } + updateNav(); + route(); + })(); +})(); diff --git a/assets/style.css b/assets/style.css new file mode 100644 index 0000000..9706b0c --- /dev/null +++ b/assets/style.css @@ -0,0 +1,24 @@ +.card-hover { + transition: transform 0.12s ease, border-color 0.12s ease; +} +.card-hover:hover { + transform: translateY(-2px); + border-color: var(--bs-primary); +} + +.progress-thin { + height: 6px; +} + +.playlist-scroll { + max-height: 60vh; + overflow-y: auto; +} + +.min-w-0 { + min-width: 0; +} + +:root { + --plyr-color-main: var(--bs-primary); +} diff --git a/config.example.php b/config.example.php new file mode 100644 index 0000000..6ed3f93 --- /dev/null +++ b/config.example.php @@ -0,0 +1,20 @@ + 'Not logged in']); + exit; + } + return $uid; +} + +function attemptLogin(string $username, string $password): bool +{ + $stmt = db()->prepare('SELECT id, username, password_hash FROM users WHERE username = ?'); + $stmt->execute([$username]); + $user = $stmt->fetch(); + if ($user && password_verify($password, $user['password_hash'])) { + session_regenerate_id(true); + $_SESSION['user_id'] = (int)$user['id']; + $_SESSION['username'] = $user['username']; + return true; + } + return false; +} + +function registerUser(string $username, string $password): string|true +{ + if (!preg_match('/^[A-Za-z0-9_.-]{3,50}$/', $username)) { + return 'Username must be 3-50 characters (letters, digits, _ . -).'; + } + if (strlen($password) < 6) { + return 'Password must be at least 6 characters.'; + } + $stmt = db()->prepare('SELECT id FROM users WHERE username = ?'); + $stmt->execute([$username]); + if ($stmt->fetch()) { + return 'That username is already taken.'; + } + $stmt = db()->prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)'); + $stmt->execute([$username, password_hash($password, PASSWORD_DEFAULT)]); + return true; +} diff --git a/includes/db.php b/includes/db.php new file mode 100644 index 0000000..f72bfe1 --- /dev/null +++ b/includes/db.php @@ -0,0 +1,16 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ]); + } + return $pdo; +} diff --git a/includes/functions.php b/includes/functions.php new file mode 100644 index 0000000..86de09f --- /dev/null +++ b/includes/functions.php @@ -0,0 +1,221 @@ + 0, 'videos' => 0, 'missing_folders' => 0, 'missing_videos' => 0]; + $seenFolderIds = []; + $seenVideoIds = []; + + $upsertFolder = $db->prepare( + 'INSERT INTO folders (parent_id, name, path, depth, sort_order, is_missing) + VALUES (?, ?, ?, ?, ?, 0) + ON DUPLICATE KEY UPDATE parent_id = VALUES(parent_id), name = VALUES(name), + depth = VALUES(depth), sort_order = VALUES(sort_order), is_missing = 0, + id = LAST_INSERT_ID(id)' + ); + $upsertVideo = $db->prepare( + 'INSERT INTO videos (folder_id, name, filename, path, size_bytes, sort_order, is_missing) + VALUES (?, ?, ?, ?, ?, ?, 0) + ON DUPLICATE KEY UPDATE folder_id = VALUES(folder_id), name = VALUES(name), + filename = VALUES(filename), size_bytes = VALUES(size_bytes), + sort_order = VALUES(sort_order), is_missing = 0, + id = LAST_INSERT_ID(id)' + ); + + $walk = function (string $dir, ?int $parentId, string $relBase, int $depth) + use (&$walk, $db, $upsertFolder, $upsertVideo, &$stats, &$seenFolderIds, &$seenVideoIds): void { + + $entries = scandir($dir) ?: []; + $entries = array_values(array_filter($entries, fn($e) => $e[0] !== '.')); + usort($entries, 'strnatcasecmp'); // "Video2" before "Video10" + + $folderSort = 0; + $videoSort = 0; + foreach ($entries as $entry) { + $full = $dir . '/' . $entry; + $rel = $relBase === '' ? $entry : $relBase . '/' . $entry; + + if (is_dir($full)) { + $upsertFolder->execute([$parentId, $entry, $rel, $depth, $folderSort++]); + $folderId = (int)$db->lastInsertId(); + $seenFolderIds[] = $folderId; + $stats['folders']++; + $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++]); + $seenVideoIds[] = (int)$db->lastInsertId(); + $stats['videos']++; + } + } + }; + + $db->beginTransaction(); + try { + $walk($root, null, '', 0); + + // Anything not seen this pass no longer exists on disk. + $flagMissing = function (string $table, array $seenIds) use ($db): int { + if ($seenIds) { + $in = implode(',', array_map('intval', $seenIds)); + $sql = "UPDATE $table SET is_missing = 1 WHERE is_missing = 0 AND id NOT IN ($in)"; + } else { + $sql = "UPDATE $table SET is_missing = 1 WHERE is_missing = 0"; + } + return $db->exec($sql); + }; + $stats['missing_folders'] = $flagMissing('folders', $seenFolderIds); + $stats['missing_videos'] = $flagMissing('videos', $seenVideoIds); + + $db->commit(); + } catch (Throwable $e) { + $db->rollBack(); + throw $e; + } + return $stats; +} + +/* --------------------------------------------------------------------------- + * Progress helpers + * ------------------------------------------------------------------------- */ + +/** + * Progress for one folder's whole subtree, matched by path prefix. + * Returns ['total' => n, 'completed' => n, 'percent' => 0-100]. + * Percent gives partial credit for videos that are started but not finished. + */ +function folderProgress(int $userId, string $folderPath): array +{ + $stmt = db()->prepare( + "SELECT COUNT(*) AS total, + COALESCE(SUM(vp.completed), 0) AS completed, + COALESCE(SUM( + CASE + WHEN vp.completed = 1 THEN 1 + WHEN vp.duration_seconds > 0 + THEN LEAST(vp.position_seconds / vp.duration_seconds, 1) + ELSE 0 + END + ), 0) AS credit + 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 (f.path = ? OR f.path LIKE ?)" + ); + $stmt->execute([$userId, $folderPath, likePrefix($folderPath) . '/%']); + $row = $stmt->fetch(); + $total = (int)$row['total']; + return [ + 'total' => $total, + 'completed' => (int)$row['completed'], + 'percent' => $total > 0 ? (int)round(100 * $row['credit'] / $total) : 0, + ]; +} + +/** Escape LIKE wildcards in a path used as a prefix. */ +function likePrefix(string $path): string +{ + return str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $path); +} + +function getFolder(int $id): ?array +{ + $stmt = db()->prepare('SELECT * FROM folders WHERE id = ? AND is_missing = 0'); + $stmt->execute([$id]); + return $stmt->fetch() ?: null; +} + +function getVideo(int $id): ?array +{ + $stmt = db()->prepare( + 'SELECT v.*, f.path AS folder_path, f.name AS folder_name + FROM videos v JOIN folders f ON f.id = v.folder_id + WHERE v.id = ? AND v.is_missing = 0' + ); + $stmt->execute([$id]); + return $stmt->fetch() ?: null; +} + +function childFolders(?int $parentId): array +{ + $sql = 'SELECT * FROM folders WHERE is_missing = 0 AND parent_id ' + . ($parentId === null ? 'IS NULL' : '= ?') + . ' ORDER BY sort_order, name'; + $stmt = db()->prepare($sql); + $stmt->execute($parentId === null ? [] : [$parentId]); + return $stmt->fetchAll(); +} + +/** Videos in a folder, joined with the user's progress. */ +function folderVideos(int $folderId, int $userId): array +{ + $stmt = db()->prepare( + 'SELECT v.*, vp.position_seconds, vp.duration_seconds AS watched_duration, vp.completed + FROM videos v + LEFT JOIN video_progress vp ON vp.video_id = v.id AND vp.user_id = ? + WHERE v.folder_id = ? AND v.is_missing = 0 + ORDER BY v.sort_order, v.name' + ); + $stmt->execute([$userId, $folderId]); + return $stmt->fetchAll(); +} + +/** Breadcrumb chain root -> ... -> folder. */ +function folderBreadcrumbs(array $folder): array +{ + $crumbs = [$folder]; + while ($folder['parent_id'] !== null) { + $folder = getFolder((int)$folder['parent_id']); + if (!$folder) break; + array_unshift($crumbs, $folder); + } + return $crumbs; +} + +/** Most recently watched, unfinished videos for the "Continue watching" strip. */ +function continueWatching(int $userId, int $limit = 6): array +{ + $stmt = db()->prepare( + 'SELECT v.id, v.name, f.path AS folder_path, + vp.position_seconds, vp.duration_seconds, vp.updated_at + FROM video_progress vp + JOIN videos v ON v.id = vp.video_id AND v.is_missing = 0 + JOIN folders f ON f.id = v.folder_id + WHERE vp.user_id = ? AND vp.completed = 0 AND vp.position_seconds > 5 + ORDER BY vp.updated_at DESC + LIMIT ' . (int)$limit + ); + $stmt->execute([$userId]); + return $stmt->fetchAll(); +} + +function formatDuration(?float $seconds): string +{ + if ($seconds === null || $seconds <= 0) return ''; + $s = (int)round($seconds); + return $s >= 3600 + ? sprintf('%d:%02d:%02d', intdiv($s, 3600), intdiv($s % 3600, 60), $s % 60) + : sprintf('%d:%02d', intdiv($s, 60), $s % 60); +} diff --git a/index.php b/index.php new file mode 100644 index 0000000..e89e5aa --- /dev/null +++ b/index.php @@ -0,0 +1,46 @@ + + + + + + +CourseHub + + + + + + + + + +
+
+
+
+
+
+
+ +
+ + + + + + diff --git a/scan.php b/scan.php new file mode 100644 index 0000000..b652d35 --- /dev/null +++ b/scan.php @@ -0,0 +1,13 @@ + $end || $start >= $size) { + http_response_code(416); + header("Content-Range: bytes */$size"); + exit; + } + http_response_code(206); + header("Content-Range: bytes $start-$end/$size"); +} else { + http_response_code(200); +} + +session_write_close(); // don't block other requests from this user +ignore_user_abort(false); +set_time_limit(0); + +header('Content-Type: video/mp4'); +header('Accept-Ranges: bytes'); +header('Content-Length: ' . ($end - $start + 1)); +header('Cache-Control: private, max-age=3600'); + +$fp = fopen($file, 'rb'); +fseek($fp, $start); +$remaining = $end - $start + 1; +while ($remaining > 0 && !feof($fp) && !connection_aborted()) { + $chunk = fread($fp, min(1024 * 512, $remaining)); + if ($chunk === false) break; + echo $chunk; + flush(); + $remaining -= strlen($chunk); +} +fclose($fp);