0) return $d; if (function_exists('shell_exec')) { $out = @shell_exec('ffprobe -v error -show_entries format=duration -of csv=p=0 ' . escapeshellarg($file) . ' 2>/dev/null'); if ($out !== null && is_numeric(trim($out))) { return (float)trim($out); } } return null; } /** Read the duration from an mp4's moov/mvhd box. Returns seconds or null. */ function mp4Duration(string $path): ?float { $fp = @fopen($path, 'rb'); if (!$fp) return null; $fileSize = filesize($path) ?: 0; $duration = null; $readBox = function (int $pos) use ($fp): ?array { fseek($fp, $pos); $hdr = fread($fp, 8); if (strlen($hdr) < 8) return null; $size = unpack('N', substr($hdr, 0, 4))[1]; $type = substr($hdr, 4, 4); $hdrLen = 8; if ($size === 1) { // 64-bit box size $ext = fread($fp, 8); if (strlen($ext) < 8) return null; $size = unpack('J', $ext)[1]; $hdrLen = 16; } return ($size >= $hdrLen) ? [$size, $type, $hdrLen] : null; }; // Walk top-level boxes to find moov, then its mvhd child. for ($pos = 0; $pos < $fileSize;) { $box = $readBox($pos); if (!$box) break; [$size, $type, $hdrLen] = $box; if ($size === 0) $size = $fileSize - $pos; // box extends to EOF if ($type === 'moov') { for ($p = $pos + $hdrLen, $end = $pos + $size; $p < $end;) { $child = $readBox($p); if (!$child) break; [$cSize, $cType, $cHdrLen] = $child; if ($cType === 'mvhd') { fseek($fp, $p + $cHdrLen); $body = fread($fp, 32); if (strlen($body) >= 20) { if (ord($body[0]) === 1 && strlen($body) >= 32) { // version 1 $timescale = unpack('N', substr($body, 20, 4))[1]; $dur = unpack('J', substr($body, 24, 8))[1]; } else { // version 0 $timescale = unpack('N', substr($body, 12, 4))[1]; $dur = unpack('N', substr($body, 16, 4))[1]; } if ($timescale > 0) $duration = $dur / $timescale; } break 2; } $p += $cSize; } break; } $pos += $size; } fclose($fp); return $duration; } /* --------------------------------------------------------------------------- * Quiz file parser * Format ("*_quiz_solved.txt"): * [&] Question text (Select two) * - wrong option * - right option -- Correct * Returns [['text', 'options' => [...], 'correct' => [optIndex,...], 'multi'], ...] * ------------------------------------------------------------------------- */ function parseQuizFile(string $absPath): array { $raw = @file_get_contents($absPath); if ($raw === false) return []; $questions = []; $cur = null; foreach (preg_split('/\r\n|\r|\n/', $raw) as $line) { $line = trim($line); if ($line === '') continue; if (str_starts_with($line, '[&]')) { if ($cur && $cur['options']) $questions[] = $cur; $cur = ['text' => trim(substr($line, 3)), 'options' => [], 'correct' => []]; } elseif (str_starts_with($line, '-') && $cur !== null) { $opt = trim(substr($line, 1)); if (preg_match('/\s*--\s*correct\s*$/i', $opt)) { $opt = trim(preg_replace('/\s*--\s*correct\s*$/i', '', $opt)); $cur['correct'][] = count($cur['options']); } $cur['options'][] = $opt; } elseif ($cur !== null && !$cur['options']) { $cur['text'] .= ' ' . $line; // wrapped question text } } if ($cur && $cur['options']) $questions[] = $cur; foreach ($questions as &$q) { $q['multi'] = count($q['correct']) > 1; } return $questions; } /* --------------------------------------------------------------------------- * Library scanner * Walks VIDEOS_ROOT recursively, upserts every directory into `folders`, * every *.mp4 into `videos`, and every *_quiz_solved.txt into `quizzes` * (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, 'quizzes' => 0, 'missing_folders' => 0, 'missing_videos' => 0, 'missing_quizzes' => 0]; $seenFolderIds = []; $seenVideoIds = []; $seenQuizIds = []; $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)' ); $upsertQuiz = $db->prepare( 'INSERT INTO quizzes (folder_id, name, filename, path, num_questions, sort_order, is_missing) VALUES (?, ?, ?, ?, ?, ?, 0) ON DUPLICATE KEY UPDATE folder_id = VALUES(folder_id), name = VALUES(name), filename = VALUES(filename), num_questions = VALUES(num_questions), 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, $upsertQuiz, &$stats, &$seenFolderIds, &$seenVideoIds, &$seenQuizIds): void { $entries = scandir($dir) ?: []; $entries = array_values(array_filter($entries, fn($e) => $e[0] !== '.')); usort($entries, 'strnatcasecmp'); // "Video2" before "Video10" $folderSort = 0; $videoSort = 0; $quizSort = 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']++; } elseif (is_file($full) && preg_match('/_quiz_solved\.txt$/i', $entry)) { if ($parentId === null) { continue; } $numQuestions = count(parseQuizFile($full)); if ($numQuestions === 0) { continue; // unparseable file — don't index an empty quiz } $name = trim(preg_replace('/_quiz_solved\.txt$/i', '', $entry)); $upsertQuiz->execute([$parentId, $name, $entry, $rel, $numQuestions, $quizSort++]); $seenQuizIds[] = (int)$db->lastInsertId(); $stats['quizzes']++; } } }; $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); $stats['missing_quizzes'] = $flagMissing('quizzes', $seenQuizIds); $db->commit(); } catch (Throwable $e) { $db->rollBack(); throw $e; } // Fill in durations for videos that don't have one yet (new files, or // indexed before duration probing existed). Only missing ones are probed, // so rescans stay fast. $stats['durations_probed'] = 0; $rows = $db->query( 'SELECT id, path FROM videos WHERE duration_seconds IS NULL AND is_missing = 0' )->fetchAll(); $upd = $db->prepare('UPDATE videos SET duration_seconds = ? WHERE id = ?'); foreach ($rows as $r) { $d = probeDuration($root . '/' . $r['path']); if ($d !== null && $d > 0) { $upd->execute([$d, (int)$r['id']]); $stats['durations_probed']++; } } return $stats; } /* --------------------------------------------------------------------------- * Progress helpers * ------------------------------------------------------------------------- */ /** * Progress for one folder's whole subtree (videos + quizzes), matched by * path prefix. Returns ['total' => n, 'completed' => n, 'percent' => 0-100]. * Percent gives partial credit for half-watched videos and partial scores. */ function folderProgress(int $userId, string $folderPath): array { $args = [$userId, $folderPath, likePrefix($folderPath) . '/%']; $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($args); $videos = $stmt->fetch(); $stmt = db()->prepare( "SELECT COUNT(*) AS total, COALESCE(SUM(qp.completed), 0) AS completed, COALESCE(SUM( CASE WHEN qp.completed = 1 THEN 1 WHEN q.num_questions > 0 THEN LEAST(COALESCE(qp.best_score, 0) / q.num_questions, 1) ELSE 0 END ), 0) AS credit FROM quizzes q JOIN folders f ON f.id = q.folder_id LEFT JOIN quiz_progress qp ON qp.quiz_id = q.id AND qp.user_id = ? WHERE q.is_missing = 0 AND f.is_missing = 0 AND (f.path = ? OR f.path LIKE ?)" ); $stmt->execute($args); $quizzes = $stmt->fetch(); $total = (int)$videos['total'] + (int)$quizzes['total']; $credit = $videos['credit'] + $quizzes['credit']; return [ 'total' => $total, 'completed' => (int)$videos['completed'] + (int)$quizzes['completed'], 'percent' => $total > 0 ? (int)round(100 * $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(); } function getQuiz(int $id): ?array { $stmt = db()->prepare( 'SELECT q.*, f.path AS folder_path, f.name AS folder_name FROM quizzes q JOIN folders f ON f.id = q.folder_id WHERE q.id = ? AND q.is_missing = 0' ); $stmt->execute([$id]); return $stmt->fetch() ?: null; } /** Quizzes in a folder, joined with the user's results. */ function folderQuizzes(int $folderId, int $userId): array { $stmt = db()->prepare( 'SELECT q.*, qp.best_score, qp.last_score, qp.attempts, qp.completed FROM quizzes q LEFT JOIN quiz_progress qp ON qp.quiz_id = q.id AND qp.user_id = ? WHERE q.folder_id = ? AND q.is_missing = 0 ORDER BY q.sort_order, q.name' ); $stmt->execute([$userId, $folderId]); return $stmt->fetchAll(); } /** Absolute, containment-checked path of a quiz file (null if gone). */ function quizFilePath(array $quiz): ?string { $file = realpath(rtrim(VIDEOS_ROOT, '/') . '/' . $quiz['path']); $root = realpath(VIDEOS_ROOT); if ($file === false || $root === false || !str_starts_with($file, $root . DIRECTORY_SEPARATOR) || !is_file($file)) { return null; } return $file; } /** 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); }