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.
This commit is contained in:
Harwinder Sekhon
2026-07-11 10:25:35 -04:00
commit 3ae6c1ae2e
16 changed files with 1328 additions and 0 deletions
+70
View File
@@ -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;
}
+16
View File
@@ -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;
}
+221
View File
@@ -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);
}