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. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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/
|
||||||
@@ -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).
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<?php
|
||||||
|
// GET api/auth.php -> {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']);
|
||||||
|
}
|
||||||
+119
@@ -0,0 +1,119 @@
|
|||||||
|
<?php
|
||||||
|
// GET api/library.php?view=home -> 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']);
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<?php
|
||||||
|
// GET api/progress.php?video_id=N -> 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]);
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
// POST api/scan.php -> 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()]);
|
||||||
|
}
|
||||||
+426
@@ -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 = `
|
||||||
|
<div class="toast align-items-center text-bg-${kind} border-0" role="alert">
|
||||||
|
<div class="d-flex">
|
||||||
|
<div class="toast-body">${esc(message)}</div>
|
||||||
|
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
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 = '<div class="text-center py-5"><div class="spinner-border text-primary"></div></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function progressBar(percent, cls = 'bg-success') {
|
||||||
|
return `<div class="progress progress-thin">
|
||||||
|
<div class="progress-bar ${cls}" style="width:${percent}%"></div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function breadcrumbs(crumbs, tail = '') {
|
||||||
|
const items = crumbs.map((c, i) =>
|
||||||
|
(i === crumbs.length - 1 && !tail)
|
||||||
|
? `<li class="breadcrumb-item active">${esc(c.name)}</li>`
|
||||||
|
: `<li class="breadcrumb-item"><a href="#/folder/${c.id}">${esc(c.name)}</a></li>`
|
||||||
|
).join('');
|
||||||
|
return `<nav><ol class="breadcrumb">
|
||||||
|
<li class="breadcrumb-item"><a href="#/"><i class="bi bi-house"></i> Courses</a></li>
|
||||||
|
${items}${tail ? `<li class="breadcrumb-item active">${esc(tail)}</li>` : ''}
|
||||||
|
</ol></nav>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 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 = `
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-5 col-lg-4">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<h1 class="h4 mb-3 text-center"><i class="bi bi-collection-play-fill text-primary"></i> CourseHub</h1>
|
||||||
|
<div id="login-error">${error ? `<div class="alert alert-danger py-2">${esc(error)}</div>` : ''}</div>
|
||||||
|
<form id="login-form">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Username</label>
|
||||||
|
<input class="form-control" name="username" required autofocus>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Password</label>
|
||||||
|
<input class="form-control" type="password" name="password" required>
|
||||||
|
</div>
|
||||||
|
<div class="d-grid gap-2">
|
||||||
|
<button class="btn btn-primary" data-action="login">Sign in</button>
|
||||||
|
<button class="btn btn-outline-secondary" data-action="register">Create account</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
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 =
|
||||||
|
`<div class="alert alert-danger py-2">${esc(err.message)}</div>`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- views ---------- */
|
||||||
|
|
||||||
|
async function renderHome() {
|
||||||
|
spinner();
|
||||||
|
const data = await api('api/library.php?view=home');
|
||||||
|
|
||||||
|
const cont = data.continue.length ? `
|
||||||
|
<h2 class="h5 mb-3"><i class="bi bi-play-circle me-1"></i>Continue watching</h2>
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
${data.continue.map((r) => {
|
||||||
|
const pct = r.duration ? Math.min(100, Math.round(100 * r.position / r.duration)) : 0;
|
||||||
|
return `
|
||||||
|
<div class="col-sm-6 col-lg-4">
|
||||||
|
<a href="#/watch/${r.id}" class="card card-hover h-100 text-decoration-none">
|
||||||
|
<div class="card-body py-3">
|
||||||
|
<div class="fw-semibold text-truncate">${esc(r.name)}</div>
|
||||||
|
<div class="small text-secondary text-truncate mb-2">${esc(r.folder_path)}</div>
|
||||||
|
${progressBar(pct, '')}
|
||||||
|
<div class="small text-secondary mt-1">${fmt(r.position)} / ${fmt(r.duration)}</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>`;
|
||||||
|
}).join('')}
|
||||||
|
</div>` : '';
|
||||||
|
|
||||||
|
const courses = data.courses.length ? data.courses.map((c) => `
|
||||||
|
<div class="col-sm-6 col-lg-4">
|
||||||
|
<a href="#/folder/${c.id}" class="card card-hover h-100 text-decoration-none">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center mb-2">
|
||||||
|
<i class="bi bi-folder-fill text-warning fs-3 me-2"></i>
|
||||||
|
<div class="fw-semibold">${esc(c.name)}</div>
|
||||||
|
</div>
|
||||||
|
${progressBar(c.progress.percent)}
|
||||||
|
<div class="small text-secondary mt-1">
|
||||||
|
${c.progress.completed} / ${c.progress.total} videos completed · ${c.progress.percent}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>`).join('')
|
||||||
|
: `<div class="col-12"><div class="alert alert-info">
|
||||||
|
No courses found yet. Add course folders to the videos directory and click
|
||||||
|
<strong>Rescan library</strong>.</div></div>`;
|
||||||
|
|
||||||
|
app.innerHTML = `${cont}
|
||||||
|
<h2 class="h5 mb-3"><i class="bi bi-mortarboard me-1"></i>Courses</h2>
|
||||||
|
<div class="row g-3">${courses}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderFolder(id) {
|
||||||
|
spinner();
|
||||||
|
const data = await api(`api/library.php?view=folder&id=${id}`);
|
||||||
|
|
||||||
|
const subfolders = data.subfolders.length ? `
|
||||||
|
<h2 class="h6 text-secondary text-uppercase">Sections</h2>
|
||||||
|
<div class="list-group mb-4">
|
||||||
|
${data.subfolders.map((f) => `
|
||||||
|
<a href="#/folder/${f.id}" class="list-group-item list-group-item-action d-flex align-items-center gap-3">
|
||||||
|
<i class="bi bi-folder-fill text-warning fs-4"></i>
|
||||||
|
<div class="flex-grow-1 min-w-0">
|
||||||
|
<div class="fw-semibold text-truncate">${esc(f.name)}</div>
|
||||||
|
<div class="mt-1">${progressBar(f.progress.percent)}</div>
|
||||||
|
</div>
|
||||||
|
<span class="badge text-bg-secondary">${f.progress.completed}/${f.progress.total}</span>
|
||||||
|
<i class="bi bi-chevron-right text-secondary"></i>
|
||||||
|
</a>`).join('')}
|
||||||
|
</div>` : '';
|
||||||
|
|
||||||
|
const videos = data.videos.length ? `
|
||||||
|
<h2 class="h6 text-secondary text-uppercase">Videos</h2>
|
||||||
|
<div class="list-group">
|
||||||
|
${data.videos.map((v) => {
|
||||||
|
const icon = v.completed
|
||||||
|
? '<i class="bi bi-check-circle-fill text-success fs-4"></i>'
|
||||||
|
: (v.position > 5
|
||||||
|
? '<i class="bi bi-play-circle-fill text-primary fs-4"></i>'
|
||||||
|
: '<i class="bi bi-circle text-secondary fs-4"></i>');
|
||||||
|
return `
|
||||||
|
<a href="#/watch/${v.id}" class="list-group-item list-group-item-action d-flex align-items-center gap-3">
|
||||||
|
${icon}
|
||||||
|
<div class="flex-grow-1 min-w-0">
|
||||||
|
<div class="text-truncate">${esc(v.name)}</div>
|
||||||
|
${!v.completed && v.percent > 0 ? `<div class="mt-1">${progressBar(v.percent, '')}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
<span class="text-secondary small">${fmt(v.duration)}</span>
|
||||||
|
</a>`;
|
||||||
|
}).join('')}
|
||||||
|
</div>` : (!data.subfolders.length ? '<div class="alert alert-secondary">This folder is empty.</div>' : '');
|
||||||
|
|
||||||
|
app.innerHTML = `
|
||||||
|
${breadcrumbs(data.breadcrumbs)}
|
||||||
|
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-3">
|
||||||
|
<h1 class="h4 mb-0"><i class="bi bi-folder2-open text-warning me-2"></i>${esc(data.folder.name)}</h1>
|
||||||
|
<div class="text-secondary small">${data.progress.completed} / ${data.progress.total} videos completed</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-4">${progressBar(data.progress.percent)}</div>
|
||||||
|
${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
|
||||||
|
? `<i class="bi bi-check-circle-fill ${active ? '' : 'text-success'}"></i>`
|
||||||
|
: (active ? '<i class="bi bi-play-fill"></i>' : '<i class="bi bi-circle text-secondary"></i>');
|
||||||
|
return `
|
||||||
|
<a href="#/watch/${p.id}"
|
||||||
|
class="list-group-item list-group-item-action d-flex align-items-center gap-2 ${active ? 'active' : ''}">
|
||||||
|
${icon}
|
||||||
|
<span class="text-truncate flex-grow-1">${esc(p.name)}</span>
|
||||||
|
<span class="small opacity-75">${fmt(p.duration)}</span>
|
||||||
|
</a>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
app.innerHTML = `
|
||||||
|
${breadcrumbs(data.breadcrumbs, data.video.name)}
|
||||||
|
<div class="row g-4">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="ratio ratio-16x9 bg-black rounded overflow-hidden mb-3">
|
||||||
|
<video id="player" playsinline controls>
|
||||||
|
<source src="${data.stream_url}" type="video/mp4">
|
||||||
|
</video>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2">
|
||||||
|
<h1 class="h5 mb-0">${esc(data.video.name)}</h1>
|
||||||
|
<div class="btn-group">
|
||||||
|
${data.prev_id ? `<a class="btn btn-sm btn-outline-secondary" href="#/watch/${data.prev_id}">
|
||||||
|
<i class="bi bi-skip-backward-fill me-1"></i>Previous</a>` : ''}
|
||||||
|
${data.next_id ? `<a class="btn btn-sm btn-outline-secondary" href="#/watch/${data.next_id}">
|
||||||
|
Next<i class="bi bi-skip-forward-fill ms-1"></i></a>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${data.resume_at > 0
|
||||||
|
? `<div class="text-secondary small mt-2">
|
||||||
|
<i class="bi bi-clock-history me-1"></i>Resuming from ${fmt(data.resume_at)}</div>` : ''}
|
||||||
|
<div id="save-status" class="text-secondary small mt-1"></div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header fw-semibold"><i class="bi bi-list-ol me-1"></i>${esc(data.folder.name)}</div>
|
||||||
|
<div class="list-group list-group-flush playlist-scroll">${playlist}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
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
|
||||||
|
? '<i class="bi bi-check-circle-fill text-success me-1"></i>Completed'
|
||||||
|
: '<i class="bi bi-cloud-check me-1"></i>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 = `<div class="alert alert-danger">${esc(err.message)}</div>
|
||||||
|
<a class="btn btn-primary" href="#/">Back to courses</a>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 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 = '<span class="spinner-border spinner-border-sm me-1"></span>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 = '<i class="bi bi-arrow-repeat me-1"></i>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();
|
||||||
|
})();
|
||||||
|
})();
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
// ---- CourseHub configuration ----
|
||||||
|
|
||||||
|
// Absolute path to the directory that contains your course folders, e.g.
|
||||||
|
// /srv/media/courses/
|
||||||
|
// Course 1/Intro/Video1.mp4
|
||||||
|
// Course 2/Module 3/Advanced/lesson.mp4
|
||||||
|
define('VIDEOS_ROOT', '/home/harwinder/Development/coursehub/videos');
|
||||||
|
|
||||||
|
define('DB_HOST', '127.0.0.1');
|
||||||
|
define('DB_PORT', 3306);
|
||||||
|
define('DB_NAME', 'coursehub');
|
||||||
|
define('DB_USER', 'coursehub');
|
||||||
|
define('DB_PASS', 'change-me');
|
||||||
|
|
||||||
|
// A video counts as "completed" once the user has watched this fraction of it.
|
||||||
|
define('COMPLETE_THRESHOLD', 0.95);
|
||||||
|
|
||||||
|
// Allow new users to self-register from the login page.
|
||||||
|
define('ALLOW_REGISTRATION', true);
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
require_once __DIR__ . '/db.php';
|
||||||
|
|
||||||
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentUserId(): ?int
|
||||||
|
{
|
||||||
|
return isset($_SESSION['user_id']) ? (int)$_SESSION['user_id'] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentUsername(): ?string
|
||||||
|
{
|
||||||
|
return $_SESSION['username'] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireLogin(): int
|
||||||
|
{
|
||||||
|
$uid = currentUserId();
|
||||||
|
if ($uid === null) {
|
||||||
|
header('Location: login.php');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
return $uid;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireLoginApi(): int
|
||||||
|
{
|
||||||
|
$uid = currentUserId();
|
||||||
|
if ($uid === null) {
|
||||||
|
http_response_code(401);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['error' => '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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
require_once __DIR__ . '/../config.php';
|
||||||
|
|
||||||
|
function db(): PDO
|
||||||
|
{
|
||||||
|
static $pdo = null;
|
||||||
|
if ($pdo === null) {
|
||||||
|
$dsn = 'mysql:host=' . DB_HOST . ';port=' . DB_PORT . ';dbname=' . DB_NAME . ';charset=utf8mb4';
|
||||||
|
$pdo = new PDO($dsn, DB_USER, DB_PASS, [
|
||||||
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||||
|
PDO::ATTR_EMULATE_PREPARES => false,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return $pdo;
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
<?php
|
||||||
|
require_once __DIR__ . '/db.php';
|
||||||
|
|
||||||
|
function h(?string $s): string
|
||||||
|
{
|
||||||
|
return htmlspecialchars($s ?? '', ENT_QUOTES, 'UTF-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
* Library scanner
|
||||||
|
* Walks VIDEOS_ROOT recursively, upserts every directory into `folders` and
|
||||||
|
* every *.mp4 into `videos` (keyed by relative path so IDs stay stable across
|
||||||
|
* rescans), then flags rows whose files disappeared as is_missing.
|
||||||
|
* ------------------------------------------------------------------------- */
|
||||||
|
function scanLibrary(): array
|
||||||
|
{
|
||||||
|
$root = rtrim(VIDEOS_ROOT, '/');
|
||||||
|
if (!is_dir($root)) {
|
||||||
|
throw new RuntimeException("VIDEOS_ROOT does not exist: $root");
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = db();
|
||||||
|
$stats = ['folders' => 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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<?php /* CourseHub SPA shell — all data flows through api/ via fetch. */ ?>
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en" data-bs-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>CourseHub</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/plyr@3.7.8/dist/plyr.css" rel="stylesheet">
|
||||||
|
<link href="assets/style.css" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<nav class="navbar navbar-expand-lg bg-body-tertiary border-bottom mb-4">
|
||||||
|
<div class="container">
|
||||||
|
<a class="navbar-brand fw-bold" href="#/">
|
||||||
|
<i class="bi bi-collection-play-fill text-primary me-1"></i>CourseHub
|
||||||
|
</a>
|
||||||
|
<div id="nav-user" class="d-none d-flex align-items-center gap-3">
|
||||||
|
<button id="btn-rescan" class="btn btn-sm btn-outline-secondary">
|
||||||
|
<i class="bi bi-arrow-repeat me-1"></i>Rescan library
|
||||||
|
</button>
|
||||||
|
<span class="text-secondary small">
|
||||||
|
<i class="bi bi-person-circle me-1"></i><span id="nav-username"></span>
|
||||||
|
</span>
|
||||||
|
<button id="btn-logout" class="btn btn-sm btn-outline-danger">Logout</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main class="container pb-5">
|
||||||
|
<div id="app">
|
||||||
|
<div class="text-center py-5">
|
||||||
|
<div class="spinner-border text-primary" role="status"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div class="toast-container position-fixed bottom-0 end-0 p-3" id="toasts"></div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/plyr@3.7.8/dist/plyr.min.js"></script>
|
||||||
|
<script src="assets/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
// CLI library scan (for cron or manual runs): php scan.php
|
||||||
|
// From the browser, use the "Rescan library" button (api/scan.php).
|
||||||
|
require_once __DIR__ . '/includes/functions.php';
|
||||||
|
|
||||||
|
if (PHP_SAPI !== 'cli') {
|
||||||
|
header('Location: index.php');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stats = scanLibrary();
|
||||||
|
echo "Scan complete: {$stats['folders']} folders, {$stats['videos']} videos"
|
||||||
|
. " ({$stats['missing_folders']} folders / {$stats['missing_videos']} videos flagged missing)\n";
|
||||||
+57
@@ -0,0 +1,57 @@
|
|||||||
|
-- CourseHub schema (MySQL / MariaDB)
|
||||||
|
CREATE DATABASE IF NOT EXISTS coursehub CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
USE coursehub;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
username VARCHAR(50) NOT NULL UNIQUE,
|
||||||
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB;
|
||||||
|
|
||||||
|
-- One row per directory found under VIDEOS_ROOT.
|
||||||
|
-- `path` is the relative path from VIDEOS_ROOT, e.g. "Course 1/Intro".
|
||||||
|
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,
|
||||||
|
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)
|
||||||
|
) ENGINE=InnoDB;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS videos (
|
||||||
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
folder_id INT UNSIGNED NOT NULL,
|
||||||
|
name VARCHAR(255) NOT NULL, -- display name, filename without .mp4
|
||||||
|
filename VARCHAR(255) NOT NULL,
|
||||||
|
path VARCHAR(700) NOT NULL UNIQUE, -- relative path incl. filename
|
||||||
|
size_bytes BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
duration_seconds FLOAT NULL, -- filled in by the player on first load
|
||||||
|
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_videos_folder FOREIGN KEY (folder_id)
|
||||||
|
REFERENCES folders(id) ON DELETE CASCADE,
|
||||||
|
INDEX idx_videos_folder (folder_id)
|
||||||
|
) ENGINE=InnoDB;
|
||||||
|
|
||||||
|
-- Per-user playback state: resume position + completion flag.
|
||||||
|
CREATE TABLE IF NOT EXISTS video_progress (
|
||||||
|
user_id INT UNSIGNED NOT NULL,
|
||||||
|
video_id INT UNSIGNED NOT NULL,
|
||||||
|
position_seconds FLOAT NOT NULL DEFAULT 0,
|
||||||
|
duration_seconds FLOAT NULL,
|
||||||
|
completed TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (user_id, video_id),
|
||||||
|
CONSTRAINT fk_vp_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_vp_video FOREIGN KEY (video_id) REFERENCES videos(id) ON DELETE CASCADE,
|
||||||
|
INDEX idx_vp_user_updated (user_id, updated_at)
|
||||||
|
) ENGINE=InnoDB;
|
||||||
+68
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
// Streams an mp4 with HTTP Range support so the player can seek.
|
||||||
|
// The file is looked up by database ID only — the client never supplies a
|
||||||
|
// filesystem path, which rules out path traversal.
|
||||||
|
require_once __DIR__ . '/includes/auth.php';
|
||||||
|
require_once __DIR__ . '/includes/functions.php';
|
||||||
|
|
||||||
|
$userId = currentUserId();
|
||||||
|
if ($userId === null) {
|
||||||
|
http_response_code(403);
|
||||||
|
exit('Forbidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
$video = getVideo((int)($_GET['id'] ?? 0));
|
||||||
|
if (!$video) {
|
||||||
|
http_response_code(404);
|
||||||
|
exit('Not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
$file = realpath(rtrim(VIDEOS_ROOT, '/') . '/' . $video['path']);
|
||||||
|
$root = realpath(VIDEOS_ROOT);
|
||||||
|
if ($file === false || $root === false || !str_starts_with($file, $root . DIRECTORY_SEPARATOR) || !is_file($file)) {
|
||||||
|
http_response_code(404);
|
||||||
|
exit('File missing — rescan the library');
|
||||||
|
}
|
||||||
|
|
||||||
|
$size = filesize($file);
|
||||||
|
$start = 0;
|
||||||
|
$end = $size - 1;
|
||||||
|
|
||||||
|
if (isset($_SERVER['HTTP_RANGE']) && preg_match('/bytes=(\d*)-(\d*)/', $_SERVER['HTTP_RANGE'], $m)) {
|
||||||
|
if ($m[1] !== '') {
|
||||||
|
$start = (int)$m[1];
|
||||||
|
if ($m[2] !== '') $end = min((int)$m[2], $size - 1);
|
||||||
|
} elseif ($m[2] !== '') { // suffix range: last N bytes
|
||||||
|
$start = max(0, $size - (int)$m[2]);
|
||||||
|
}
|
||||||
|
if ($start > $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);
|
||||||
Reference in New Issue
Block a user