0) ? $d : 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']++; } } appMetaSet('last_scan_at', gmdate('Y-m-d H:i:s')); return $stats; } /* --------------------------------------------------------------------------- * Progress helpers * ------------------------------------------------------------------------- */ /** * One pass over a folder's whole subtree (videos + quizzes), matched by path * prefix: how much is in there, how big, how long, and how far the user got. * Course cards need all of it, so it is gathered in two queries rather than * one per fact. */ function folderRollup(int $userId, string $folderPath): array { $args = [$userId, $folderPath, likePrefix($folderPath) . '/%']; $stmt = db()->prepare( "SELECT COUNT(*) AS total, COALESCE(SUM(COALESCE(v.duration_seconds, vp.duration_seconds)), 0) AS duration, COALESCE(SUM(v.size_bytes), 0) AS bytes, 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 [ 'videos' => (int)$videos['total'], 'quizzes' => (int)$quizzes['total'], 'duration' => (float)$videos['duration'], 'bytes' => (int)$videos['bytes'], 'total' => $total, 'completed' => (int)$videos['completed'] + (int)$quizzes['completed'], 'percent' => $total > 0 ? (int)round(100 * $credit / $total) : 0, ]; } /** * Progress for one folder's whole subtree. * 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 { $r = folderRollup($userId, $folderPath); return ['total' => $r['total'], 'completed' => $r['completed'], 'percent' => $r['percent']]; } /** A course (or section) as the library screens show it: contents + progress. */ function courseSummary(int $userId, array $folder): array { $r = folderRollup($userId, $folder['path']); $percent = $r['percent']; return [ 'id' => (int)$folder['id'], 'name' => $folder['name'], 'path' => $folder['path'], 'videos' => $r['videos'], 'quizzes' => $r['quizzes'], 'duration' => $r['duration'], 'bytes' => $r['bytes'], 'added_at' => $folder['created_at'] ?? null, 'progress' => ['total' => $r['total'], 'completed' => $r['completed'], 'percent' => $percent], // The shelves the library groups by — the app has no course categories, // so progress is what separates one shelf from another. 'state' => $percent >= 100 ? 'finished' : ($percent > 0 ? 'progress' : 'fresh'), ]; } /** Every top-level folder as a course card. */ function courseCards(int $userId): array { return array_map(fn($f) => courseSummary($userId, $f), childFolders(null)); } /** Headline numbers for the home screen, computed from the course cards. */ function userStats(int $userId, array $courses): array { $inProgress = 0; $finished = 0; foreach ($courses as $c) { if ($c['state'] === 'finished') $finished++; elseif ($c['state'] === 'progress') $inProgress++; } // Watched time: full duration for completed videos, position for the rest. $stmt = db()->prepare( 'SELECT COALESCE(SUM( CASE WHEN vp.completed = 1 THEN COALESCE(v.duration_seconds, vp.duration_seconds, vp.position_seconds) ELSE vp.position_seconds END ), 0) AS watched FROM video_progress vp JOIN videos v ON v.id = vp.video_id AND v.is_missing = 0 WHERE vp.user_id = ?' ); $stmt->execute([$userId]); $watched = (float)$stmt->fetchColumn(); $stmt = db()->prepare( 'SELECT COUNT(*) AS taken, COALESCE(AVG(qp.best_score / NULLIF(q.num_questions, 0)), 0) AS avg_score FROM quiz_progress qp JOIN quizzes q ON q.id = qp.quiz_id AND q.is_missing = 0 WHERE qp.user_id = ? AND qp.attempts > 0' ); $stmt->execute([$userId]); $quiz = $stmt->fetch(); return [ 'in_progress' => $inProgress, 'finished' => $finished, 'courses' => count($courses), 'watched_seconds' => $watched, 'quizzes_taken' => (int)$quiz['taken'], 'quiz_average' => (int)round(100 * (float)$quiz['avg_score']), ]; } /** Courses the scanner indexed most recently — the "found in the last scan" strip. */ function recentCourses(int $userId, int $limit = 3): array { $stmt = db()->prepare( 'SELECT * FROM folders WHERE parent_id IS NULL AND is_missing = 0 ORDER BY created_at DESC, id DESC LIMIT ' . (int)$limit ); $stmt->execute(); $week = time() - 7 * 86400; return array_map(function ($f) use ($userId, $week) { $c = courseSummary($userId, $f); $c['is_new'] = $f['created_at'] !== null && strtotime($f['created_at']) >= $week; return $c; }, $stmt->fetchAll()); } /** Library-wide counts for the sidebar and header. */ function libraryTotals(): array { $row = db()->query( 'SELECT (SELECT COUNT(*) FROM folders WHERE parent_id IS NULL AND is_missing = 0) AS courses, (SELECT COUNT(*) FROM videos WHERE is_missing = 0) AS videos, (SELECT COUNT(*) FROM quizzes WHERE is_missing = 0) AS quizzes, (SELECT COALESCE(SUM(size_bytes), 0) FROM videos WHERE is_missing = 0) AS bytes, (SELECT COALESCE(SUM(duration_seconds), 0) FROM videos WHERE is_missing = 0) AS duration' )->fetch(); return [ 'courses' => (int)$row['courses'], 'videos' => (int)$row['videos'], 'quizzes' => (int)$row['quizzes'], 'bytes' => (int)$row['bytes'], 'duration' => (float)$row['duration'], ]; } /* --------------------------------------------------------------------------- * app_meta — small key/value store (currently just the last scan time). * Reads tolerate the table not existing yet, so an install created before this * table was added keeps working until its next scan creates it. * ------------------------------------------------------------------------- */ function appMetaGet(string $key): ?string { try { $stmt = db()->prepare('SELECT v FROM app_meta WHERE k = ?'); $stmt->execute([$key]); $value = $stmt->fetchColumn(); return $value === false ? null : (string)$value; } catch (Throwable $e) { return null; } } function appMetaSet(string $key, string $value): void { $db = db(); $db->exec( 'CREATE TABLE IF NOT EXISTS app_meta ( k VARCHAR(64) PRIMARY KEY, v VARCHAR(255) NOT NULL, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB' ); $stmt = $db->prepare('INSERT INTO app_meta (k, v) VALUES (?, ?) ON DUPLICATE KEY UPDATE v = VALUES(v)'); $stmt->execute([$key, $value]); } /** Escape LIKE wildcards in a path used as a prefix. */ function likePrefix(string $path): string { return str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $path); } /** Escaped "contains" pattern for LIKE. */ function likeContains(string $term): string { return '%' . likePrefix($term) . '%'; } /** * Wipe the user's progress for a scope: * all — everything the user has watched/answered * folder — the folder's whole subtree (id = folder id) * video — one video (id = video id) * quiz — one quiz (id = quiz id) * Returns ['videos' => n, 'quizzes' => n] rows removed. * Rows for files that went missing are cleared too, so a reset is complete. */ function resetProgress(int $userId, string $scope, int $id = 0): array { $db = db(); switch ($scope) { case 'all': $v = $db->prepare('DELETE FROM video_progress WHERE user_id = ?'); $v->execute([$userId]); $q = $db->prepare('DELETE FROM quiz_progress WHERE user_id = ?'); $q->execute([$userId]); break; case 'folder': $folder = getFolder($id); if (!$folder) { throw new InvalidArgumentException('Folder not found'); } $args = [$userId, $folder['path'], likePrefix($folder['path']) . '/%']; $v = $db->prepare( 'DELETE vp FROM video_progress vp JOIN videos v ON v.id = vp.video_id JOIN folders f ON f.id = v.folder_id WHERE vp.user_id = ? AND (f.path = ? OR f.path LIKE ?)' ); $v->execute($args); $q = $db->prepare( 'DELETE qp FROM quiz_progress qp JOIN quizzes q ON q.id = qp.quiz_id JOIN folders f ON f.id = q.folder_id WHERE qp.user_id = ? AND (f.path = ? OR f.path LIKE ?)' ); $q->execute($args); break; case 'video': if ($id <= 0) { throw new InvalidArgumentException('Video not found'); } $v = $db->prepare('DELETE FROM video_progress WHERE user_id = ? AND video_id = ?'); $v->execute([$userId, $id]); $q = null; break; case 'quiz': if ($id <= 0) { throw new InvalidArgumentException('Quiz not found'); } $q = $db->prepare('DELETE FROM quiz_progress WHERE user_id = ? AND quiz_id = ?'); $q->execute([$userId, $id]); $v = null; break; default: throw new InvalidArgumentException('Unknown reset scope'); } return [ 'videos' => $v ? $v->rowCount() : 0, 'quizzes' => $q ? $q->rowCount() : 0, ]; } /* --------------------------------------------------------------------------- * Search — substring match on names (and on the relative path, so * "Course 1/Intro" finds everything under it). * ------------------------------------------------------------------------- */ function searchFolders(string $term, int $limit = 25): array { $like = likeContains($term); $stmt = db()->prepare( 'SELECT * FROM folders WHERE is_missing = 0 AND (name LIKE ? OR path LIKE ?) ORDER BY depth, path LIMIT ' . (int)$limit ); $stmt->execute([$like, $like]); return $stmt->fetchAll(); } function searchVideos(int $userId, string $term, int $limit = 50): array { $like = likeContains($term); $stmt = db()->prepare( 'SELECT v.*, f.path AS folder_path, vp.position_seconds, vp.duration_seconds AS watched_duration, vp.completed 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 (v.name LIKE ? OR v.path LIKE ?) ORDER BY f.path, v.sort_order, v.name LIMIT ' . (int)$limit ); $stmt->execute([$userId, $like, $like]); return $stmt->fetchAll(); } function searchQuizzes(int $userId, string $term, int $limit = 25): array { $like = likeContains($term); $stmt = db()->prepare( 'SELECT q.*, f.path AS folder_path, qp.best_score, qp.last_score, qp.attempts, qp.completed 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 (q.name LIKE ? OR q.path LIKE ?) ORDER BY f.path, q.sort_order, q.name LIMIT ' . (int)$limit ); $stmt->execute([$userId, $like, $like]); return $stmt->fetchAll(); } 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; } /** * Absolute, containment-checked path of a video's sibling subtitle file * ("Lesson.mp4" -> "Lesson.srt"), or null if there isn't one. */ function srtFilePath(array $video): ?string { $rel = preg_replace('/\.mp4$/i', '.srt', $video['path']); if ($rel === null || $rel === $video['path']) { return null; } $file = realpath(rtrim(VIDEOS_ROOT, '/') . '/' . $rel); $root = realpath(VIDEOS_ROOT); if ($file === false || $root === false || !str_starts_with($file, $root . DIRECTORY_SEPARATOR) || !is_file($file)) { return null; } return $file; } /** * SubRip -> WebVTT (the only caption format browsers take in ). * The formats differ by a header line and the decimal separator in * timestamps; cue numbers are legal VTT cue identifiers and pass through. */ function srtToVtt(string $srt): string { if (str_starts_with($srt, "\xEF\xBB\xBF")) { $srt = substr($srt, 3); } if (!preg_match('//u', $srt)) { // not valid UTF-8 — legacy single-byte file $recoded = function_exists('iconv') ? @iconv('Windows-1252', 'UTF-8//TRANSLIT', $srt) : (function_exists('mb_convert_encoding') ? mb_convert_encoding($srt, 'UTF-8', 'Windows-1252') : false); if (is_string($recoded)) { $srt = $recoded; } } $srt = str_replace(["\r\n", "\r"], "\n", $srt); // Normalise timestamps to HH:MM:SS.mmm. SRT uses a comma; generated files // in the wild also carry single-digit hours and six-digit fractions, and // the WebVTT parsing algorithm rejects any fraction that isn't exactly // three digits — such cues would silently drop. $srt = preg_replace_callback( '/(\d+):(\d{2}):(\d{2})[.,](\d+)/', fn($m) => sprintf('%02d:%s:%s.%s', $m[1], $m[2], $m[3], substr(str_pad($m[4], 3, '0', STR_PAD_RIGHT), 0, 3)), $srt ); // Some ".srt" files are already WebVTT — don't stack a second header. $srt = trim($srt); return (str_starts_with($srt, 'WEBVTT') ? $srt : "WEBVTT\n\n" . $srt) . "\n"; } /** 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, v.folder_id, 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(); } /** The top-level course a folder belongs to (itself, if it's already top-level). */ function courseIdOf(array $folder): int { $crumbs = folderBreadcrumbs($folder); return (int)$crumbs[0]['id']; } 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); }