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); }