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,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);
|
||||
}
|
||||
Reference in New Issue
Block a user