offcourse 0.0.2 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (284) hide show
  1. package/.github/workflows/ci.yml +50 -0
  2. package/.husky/commit-msg +2 -0
  3. package/.husky/pre-commit +1 -0
  4. package/.husky/pre-push +3 -0
  5. package/.prettierrc +8 -0
  6. package/.release-it.json +23 -0
  7. package/ARCHITECTURE.md +233 -0
  8. package/CHANGELOG.md +78 -0
  9. package/README.md +255 -20
  10. package/commitlint.config.js +4 -0
  11. package/dist/ai/openRouter.d.ts +47 -0
  12. package/dist/ai/openRouter.d.ts.map +1 -0
  13. package/dist/ai/openRouter.js +116 -0
  14. package/dist/ai/openRouter.js.map +1 -0
  15. package/dist/ai/transcriptPolisher.d.ts +24 -0
  16. package/dist/ai/transcriptPolisher.d.ts.map +1 -0
  17. package/dist/ai/transcriptPolisher.js +89 -0
  18. package/dist/ai/transcriptPolisher.js.map +1 -0
  19. package/dist/cli/commands/config.d.ts +13 -0
  20. package/dist/cli/commands/config.d.ts.map +1 -0
  21. package/dist/cli/commands/config.js +66 -0
  22. package/dist/cli/commands/config.js.map +1 -0
  23. package/dist/cli/commands/enrich.d.ts +14 -0
  24. package/dist/cli/commands/enrich.d.ts.map +1 -0
  25. package/dist/cli/commands/enrich.js +271 -0
  26. package/dist/cli/commands/enrich.js.map +1 -0
  27. package/dist/cli/commands/inspect.d.ts +11 -0
  28. package/dist/cli/commands/inspect.d.ts.map +1 -0
  29. package/dist/cli/commands/inspect.js +365 -0
  30. package/dist/cli/commands/inspect.js.map +1 -0
  31. package/dist/cli/commands/login.d.ts +12 -0
  32. package/dist/cli/commands/login.d.ts.map +1 -0
  33. package/dist/cli/commands/login.js +55 -0
  34. package/dist/cli/commands/login.js.map +1 -0
  35. package/dist/cli/commands/status.d.ts +15 -0
  36. package/dist/cli/commands/status.d.ts.map +1 -0
  37. package/dist/cli/commands/status.js +118 -0
  38. package/dist/cli/commands/status.js.map +1 -0
  39. package/dist/cli/commands/sync.d.ts +16 -0
  40. package/dist/cli/commands/sync.d.ts.map +1 -0
  41. package/dist/cli/commands/sync.js +922 -0
  42. package/dist/cli/commands/sync.js.map +1 -0
  43. package/dist/cli/commands/syncGhl.d.ts +20 -0
  44. package/dist/cli/commands/syncGhl.d.ts.map +1 -0
  45. package/dist/cli/commands/syncGhl.js +483 -0
  46. package/dist/cli/commands/syncGhl.js.map +1 -0
  47. package/dist/cli/commands/syncHighLevel.d.ts +24 -0
  48. package/dist/cli/commands/syncHighLevel.d.ts.map +1 -0
  49. package/dist/cli/commands/syncHighLevel.js +483 -0
  50. package/dist/cli/commands/syncHighLevel.js.map +1 -0
  51. package/dist/cli/commands/syncHighLevel.test.d.ts +2 -0
  52. package/dist/cli/commands/syncHighLevel.test.d.ts.map +1 -0
  53. package/dist/cli/commands/syncHighLevel.test.js +102 -0
  54. package/dist/cli/commands/syncHighLevel.test.js.map +1 -0
  55. package/dist/cli/index.d.ts +3 -0
  56. package/dist/cli/index.d.ts.map +1 -0
  57. package/dist/cli/index.js +106 -0
  58. package/dist/cli/index.js.map +1 -0
  59. package/dist/config/configManager.d.ts +31 -0
  60. package/dist/config/configManager.d.ts.map +1 -0
  61. package/dist/config/configManager.js +64 -0
  62. package/dist/config/configManager.js.map +1 -0
  63. package/dist/config/paths.d.ts +21 -0
  64. package/dist/config/paths.d.ts.map +1 -0
  65. package/dist/config/paths.js +33 -0
  66. package/dist/config/paths.js.map +1 -0
  67. package/dist/config/paths.test.d.ts +2 -0
  68. package/dist/config/paths.test.d.ts.map +1 -0
  69. package/dist/config/paths.test.js +70 -0
  70. package/dist/config/paths.test.js.map +1 -0
  71. package/dist/config/schema.d.ts +60 -0
  72. package/dist/config/schema.d.ts.map +1 -0
  73. package/dist/config/schema.js +50 -0
  74. package/dist/config/schema.js.map +1 -0
  75. package/dist/config/schema.test.d.ts +2 -0
  76. package/dist/config/schema.test.d.ts.map +1 -0
  77. package/dist/config/schema.test.js +151 -0
  78. package/dist/config/schema.test.js.map +1 -0
  79. package/dist/downloader/hlsDownloader.d.ts +58 -0
  80. package/dist/downloader/hlsDownloader.d.ts.map +1 -0
  81. package/dist/downloader/hlsDownloader.js +254 -0
  82. package/dist/downloader/hlsDownloader.js.map +1 -0
  83. package/dist/downloader/hlsDownloader.test.d.ts +2 -0
  84. package/dist/downloader/hlsDownloader.test.d.ts.map +1 -0
  85. package/dist/downloader/hlsDownloader.test.js +116 -0
  86. package/dist/downloader/hlsDownloader.test.js.map +1 -0
  87. package/dist/downloader/hlsValidator.d.ts +35 -0
  88. package/dist/downloader/hlsValidator.d.ts.map +1 -0
  89. package/dist/downloader/hlsValidator.js +148 -0
  90. package/dist/downloader/hlsValidator.js.map +1 -0
  91. package/dist/downloader/index.d.ts +26 -0
  92. package/dist/downloader/index.d.ts.map +1 -0
  93. package/dist/downloader/index.js +52 -0
  94. package/dist/downloader/index.js.map +1 -0
  95. package/dist/downloader/loomDownloader.d.ts +56 -0
  96. package/dist/downloader/loomDownloader.d.ts.map +1 -0
  97. package/dist/downloader/loomDownloader.js +559 -0
  98. package/dist/downloader/loomDownloader.js.map +1 -0
  99. package/dist/downloader/loomDownloader.test.d.ts +2 -0
  100. package/dist/downloader/loomDownloader.test.d.ts.map +1 -0
  101. package/dist/downloader/loomDownloader.test.js +36 -0
  102. package/dist/downloader/loomDownloader.test.js.map +1 -0
  103. package/dist/downloader/queue.d.ts +56 -0
  104. package/dist/downloader/queue.d.ts.map +1 -0
  105. package/dist/downloader/queue.js +88 -0
  106. package/dist/downloader/queue.js.map +1 -0
  107. package/dist/downloader/queue.test.d.ts +2 -0
  108. package/dist/downloader/queue.test.d.ts.map +1 -0
  109. package/dist/downloader/queue.test.js +158 -0
  110. package/dist/downloader/queue.test.js.map +1 -0
  111. package/dist/downloader/videoDownloader.d.ts +32 -0
  112. package/dist/downloader/videoDownloader.d.ts.map +1 -0
  113. package/dist/downloader/videoDownloader.js +173 -0
  114. package/dist/downloader/videoDownloader.js.map +1 -0
  115. package/dist/downloader/vimeoDownloader.d.ts +52 -0
  116. package/dist/downloader/vimeoDownloader.d.ts.map +1 -0
  117. package/dist/downloader/vimeoDownloader.js +565 -0
  118. package/dist/downloader/vimeoDownloader.js.map +1 -0
  119. package/dist/downloader/vimeoDownloader.test.d.ts +2 -0
  120. package/dist/downloader/vimeoDownloader.test.d.ts.map +1 -0
  121. package/dist/downloader/vimeoDownloader.test.js +51 -0
  122. package/dist/downloader/vimeoDownloader.test.js.map +1 -0
  123. package/dist/scraper/auth.d.ts +29 -0
  124. package/dist/scraper/auth.d.ts.map +1 -0
  125. package/dist/scraper/auth.js +115 -0
  126. package/dist/scraper/auth.js.map +1 -0
  127. package/dist/scraper/extractor.d.ts +49 -0
  128. package/dist/scraper/extractor.d.ts.map +1 -0
  129. package/dist/scraper/extractor.js +627 -0
  130. package/dist/scraper/extractor.js.map +1 -0
  131. package/dist/scraper/extractor.test.d.ts +2 -0
  132. package/dist/scraper/extractor.test.d.ts.map +1 -0
  133. package/dist/scraper/extractor.test.js +65 -0
  134. package/dist/scraper/extractor.test.js.map +1 -0
  135. package/dist/scraper/ghl/auth.d.ts +25 -0
  136. package/dist/scraper/ghl/auth.d.ts.map +1 -0
  137. package/dist/scraper/ghl/auth.js +187 -0
  138. package/dist/scraper/ghl/auth.js.map +1 -0
  139. package/dist/scraper/ghl/extractor.d.ts +96 -0
  140. package/dist/scraper/ghl/extractor.d.ts.map +1 -0
  141. package/dist/scraper/ghl/extractor.js +345 -0
  142. package/dist/scraper/ghl/extractor.js.map +1 -0
  143. package/dist/scraper/ghl/index.d.ts +4 -0
  144. package/dist/scraper/ghl/index.d.ts.map +1 -0
  145. package/dist/scraper/ghl/index.js +4 -0
  146. package/dist/scraper/ghl/index.js.map +1 -0
  147. package/dist/scraper/ghl/navigator.d.ts +93 -0
  148. package/dist/scraper/ghl/navigator.d.ts.map +1 -0
  149. package/dist/scraper/ghl/navigator.js +447 -0
  150. package/dist/scraper/ghl/navigator.js.map +1 -0
  151. package/dist/scraper/highlevel/auth.d.ts +25 -0
  152. package/dist/scraper/highlevel/auth.d.ts.map +1 -0
  153. package/dist/scraper/highlevel/auth.js +189 -0
  154. package/dist/scraper/highlevel/auth.js.map +1 -0
  155. package/dist/scraper/highlevel/extractor.d.ts +97 -0
  156. package/dist/scraper/highlevel/extractor.d.ts.map +1 -0
  157. package/dist/scraper/highlevel/extractor.js +386 -0
  158. package/dist/scraper/highlevel/extractor.js.map +1 -0
  159. package/dist/scraper/highlevel/extractor.test.d.ts +2 -0
  160. package/dist/scraper/highlevel/extractor.test.d.ts.map +1 -0
  161. package/dist/scraper/highlevel/extractor.test.js +101 -0
  162. package/dist/scraper/highlevel/extractor.test.js.map +1 -0
  163. package/dist/scraper/highlevel/index.d.ts +3 -0
  164. package/dist/scraper/highlevel/index.d.ts.map +1 -0
  165. package/dist/scraper/highlevel/index.js +3 -0
  166. package/dist/scraper/highlevel/index.js.map +1 -0
  167. package/dist/scraper/highlevel/navigator.d.ts +93 -0
  168. package/dist/scraper/highlevel/navigator.d.ts.map +1 -0
  169. package/dist/scraper/highlevel/navigator.js +492 -0
  170. package/dist/scraper/highlevel/navigator.js.map +1 -0
  171. package/dist/scraper/highlevel/navigator.test.d.ts +2 -0
  172. package/dist/scraper/highlevel/navigator.test.d.ts.map +1 -0
  173. package/dist/scraper/highlevel/navigator.test.js +78 -0
  174. package/dist/scraper/highlevel/navigator.test.js.map +1 -0
  175. package/dist/scraper/navigator.d.ts +65 -0
  176. package/dist/scraper/navigator.d.ts.map +1 -0
  177. package/dist/scraper/navigator.js +300 -0
  178. package/dist/scraper/navigator.js.map +1 -0
  179. package/dist/scraper/navigator.test.d.ts +2 -0
  180. package/dist/scraper/navigator.test.d.ts.map +1 -0
  181. package/dist/scraper/navigator.test.js +63 -0
  182. package/dist/scraper/navigator.test.js.map +1 -0
  183. package/dist/scraper/skoolApi.d.ts +17 -0
  184. package/dist/scraper/skoolApi.d.ts.map +1 -0
  185. package/dist/scraper/skoolApi.js +72 -0
  186. package/dist/scraper/skoolApi.js.map +1 -0
  187. package/dist/scraper/videoInterceptor.d.ts +19 -0
  188. package/dist/scraper/videoInterceptor.d.ts.map +1 -0
  189. package/dist/scraper/videoInterceptor.js +315 -0
  190. package/dist/scraper/videoInterceptor.js.map +1 -0
  191. package/dist/shared/auth.d.ts +58 -0
  192. package/dist/shared/auth.d.ts.map +1 -0
  193. package/dist/shared/auth.js +211 -0
  194. package/dist/shared/auth.js.map +1 -0
  195. package/dist/shared/fs.d.ts +31 -0
  196. package/dist/shared/fs.d.ts.map +1 -0
  197. package/dist/shared/fs.js +73 -0
  198. package/dist/shared/fs.js.map +1 -0
  199. package/dist/shared/http.d.ts +15 -0
  200. package/dist/shared/http.d.ts.map +1 -0
  201. package/dist/shared/http.js +31 -0
  202. package/dist/shared/http.js.map +1 -0
  203. package/dist/shared/index.d.ts +4 -0
  204. package/dist/shared/index.d.ts.map +1 -0
  205. package/dist/shared/index.js +4 -0
  206. package/dist/shared/index.js.map +1 -0
  207. package/dist/state/database.d.ts +245 -0
  208. package/dist/state/database.d.ts.map +1 -0
  209. package/dist/state/database.js +676 -0
  210. package/dist/state/database.js.map +1 -0
  211. package/dist/state/database.test.d.ts +2 -0
  212. package/dist/state/database.test.d.ts.map +1 -0
  213. package/dist/state/database.test.js +34 -0
  214. package/dist/state/database.test.js.map +1 -0
  215. package/dist/state/index.d.ts +2 -0
  216. package/dist/state/index.d.ts.map +1 -0
  217. package/dist/state/index.js +2 -0
  218. package/dist/state/index.js.map +1 -0
  219. package/dist/storage/fileSystem.d.ts +56 -0
  220. package/dist/storage/fileSystem.d.ts.map +1 -0
  221. package/dist/storage/fileSystem.js +121 -0
  222. package/dist/storage/fileSystem.js.map +1 -0
  223. package/dist/transcription/whisperService.d.ts +27 -0
  224. package/dist/transcription/whisperService.d.ts.map +1 -0
  225. package/dist/transcription/whisperService.js +102 -0
  226. package/dist/transcription/whisperService.js.map +1 -0
  227. package/eslint.config.js +55 -0
  228. package/package.json +68 -11
  229. package/src/__fixtures__/highlevel-post-response.json +68 -0
  230. package/src/__fixtures__/hls-master-playlist.m3u8 +24 -0
  231. package/src/cli/commands/__snapshots__/syncHighLevel.test.ts.snap +38 -0
  232. package/src/cli/commands/config.ts +74 -0
  233. package/src/cli/commands/inspect.ts +441 -0
  234. package/src/cli/commands/login.ts +68 -0
  235. package/src/cli/commands/status.ts +147 -0
  236. package/src/cli/commands/sync.ts +1235 -0
  237. package/src/cli/commands/syncHighLevel.test.ts +144 -0
  238. package/src/cli/commands/syncHighLevel.ts +639 -0
  239. package/src/cli/index.ts +121 -0
  240. package/src/config/configManager.ts +75 -0
  241. package/src/config/paths.test.ts +83 -0
  242. package/src/config/paths.ts +36 -0
  243. package/src/config/schema.test.ts +173 -0
  244. package/src/config/schema.ts +65 -0
  245. package/src/downloader/hlsDownloader.test.ts +148 -0
  246. package/src/downloader/hlsDownloader.ts +327 -0
  247. package/src/downloader/hlsValidator.ts +196 -0
  248. package/src/downloader/index.ts +122 -0
  249. package/src/downloader/loomDownloader.test.ts +43 -0
  250. package/src/downloader/loomDownloader.ts +742 -0
  251. package/src/downloader/queue.test.ts +199 -0
  252. package/src/downloader/queue.ts +118 -0
  253. package/src/downloader/vimeoDownloader.test.ts +62 -0
  254. package/src/downloader/vimeoDownloader.ts +722 -0
  255. package/src/scraper/extractor.test.ts +124 -0
  256. package/src/scraper/extractor.ts +757 -0
  257. package/src/scraper/highlevel/__snapshots__/extractor.test.ts.snap +41 -0
  258. package/src/scraper/highlevel/extractor.test.ts +134 -0
  259. package/src/scraper/highlevel/extractor.ts +537 -0
  260. package/src/scraper/highlevel/index.ts +2 -0
  261. package/src/scraper/highlevel/navigator.test.ts +110 -0
  262. package/src/scraper/highlevel/navigator.ts +668 -0
  263. package/src/scraper/highlevel/schemas.ts +183 -0
  264. package/src/scraper/navigator.test.ts +122 -0
  265. package/src/scraper/navigator.ts +355 -0
  266. package/src/scraper/schemas.ts +177 -0
  267. package/src/scraper/videoInterceptor.ts +435 -0
  268. package/src/shared/auth.test.ts +58 -0
  269. package/src/shared/auth.ts +251 -0
  270. package/src/shared/firebase.ts +151 -0
  271. package/src/shared/fs.ts +80 -0
  272. package/src/shared/http.ts +34 -0
  273. package/src/shared/index.ts +6 -0
  274. package/src/shared/slug.ts +26 -0
  275. package/src/shared/url.test.ts +122 -0
  276. package/src/shared/url.ts +57 -0
  277. package/src/state/database.test.ts +49 -0
  278. package/src/state/database.ts +919 -0
  279. package/src/state/index.ts +14 -0
  280. package/src/storage/fileSystem.test.ts +64 -0
  281. package/src/storage/fileSystem.ts +175 -0
  282. package/tsconfig.json +28 -0
  283. package/vitest.config.ts +29 -0
  284. package/cli.js +0 -45
@@ -0,0 +1,922 @@
1
+ import chalk from "chalk";
2
+ import cliProgress from "cli-progress";
3
+ import ora from "ora";
4
+ import { basename, dirname, join } from "node:path";
5
+ import { loadConfig } from "../../config/configManager.js";
6
+ import { downloadVideo, validateVideoHls } from "../../downloader/index.js";
7
+ import { getAuthenticatedSession, isSkoolLoginPage } from "../../shared/auth.js";
8
+ import { getFileSize, outputFile } from "../../shared/fs.js";
9
+ import { extractLessonContent, formatMarkdown, extractVideoUrl } from "../../scraper/extractor.js";
10
+ import { buildCourseStructure } from "../../scraper/navigator.js";
11
+ import { createCourseDirectory, createModuleDirectory, downloadFile, getDownloadFilePath, getMarkdownPath, getVideoPath, isLessonSynced, saveMarkdown, } from "../../storage/fileSystem.js";
12
+ import { CourseDatabase, extractCommunitySlug, LessonStatus, } from "../../state/index.js";
13
+ /**
14
+ * Tracks if shutdown has been requested (Ctrl+C).
15
+ */
16
+ let isShuttingDown = false;
17
+ const cleanupResources = {};
18
+ /**
19
+ * Graceful shutdown handler.
20
+ */
21
+ function setupShutdownHandlers() {
22
+ const shutdown = async (signal) => {
23
+ if (isShuttingDown) {
24
+ // Force exit on second signal
25
+ console.log(chalk.red("\n\nāš ļø Force exit"));
26
+ process.exit(1);
27
+ }
28
+ isShuttingDown = true;
29
+ console.log(chalk.yellow(`\n\nā¹ļø ${signal} received, shutting down gracefully...`));
30
+ try {
31
+ if (cleanupResources.browser) {
32
+ await cleanupResources.browser.close();
33
+ }
34
+ if (cleanupResources.db) {
35
+ cleanupResources.db.close();
36
+ }
37
+ console.log(chalk.gray(" Cleanup complete. State saved."));
38
+ }
39
+ catch (error) {
40
+ // Ignore cleanup errors during shutdown
41
+ }
42
+ process.exit(0);
43
+ };
44
+ process.on("SIGINT", () => void shutdown("SIGINT"));
45
+ process.on("SIGTERM", () => void shutdown("SIGTERM"));
46
+ }
47
+ /**
48
+ * Check if we should continue processing or stop due to shutdown.
49
+ */
50
+ function shouldContinue() {
51
+ return !isShuttingDown;
52
+ }
53
+ const SKOOL_DOMAIN = "www.skool.com";
54
+ const SKOOL_LOGIN_URL = "https://www.skool.com/login";
55
+ /**
56
+ * Handles the sync command.
57
+ * Downloads all content from a Skool course with incremental state tracking.
58
+ */
59
+ export async function syncCommand(url, options) {
60
+ // Setup graceful shutdown handlers
61
+ setupShutdownHandlers();
62
+ console.log(chalk.blue("\nšŸ“š Course Sync\n"));
63
+ // Validate URL
64
+ if (!url.includes("skool.com")) {
65
+ console.log(chalk.red("āŒ Invalid URL. Please provide a Skool URL."));
66
+ console.log(chalk.gray(" Example: https://www.skool.com/your-community/classroom\n"));
67
+ process.exit(1);
68
+ }
69
+ // Ensure URL points to classroom
70
+ if (!url.includes("/classroom")) {
71
+ url = url.replace(/\/?$/, "/classroom");
72
+ }
73
+ const config = loadConfig();
74
+ const communitySlug = extractCommunitySlug(url);
75
+ // Initialize database
76
+ const db = new CourseDatabase(communitySlug);
77
+ cleanupResources.db = db;
78
+ console.log(chalk.gray(` State: ~/.offcourse/cache/${communitySlug}.db`));
79
+ // Force mode: reset all lessons to pending for full rescan
80
+ if (options.force) {
81
+ const resetCount = db.resetAllLessonsToPending();
82
+ if (resetCount > 0) {
83
+ console.log(chalk.yellow(` Force mode: reset ${resetCount} lessons for rescan`));
84
+ }
85
+ }
86
+ // Check existing state
87
+ const existingMeta = db.getCourseMetadata();
88
+ const hasExistingData = existingMeta.totalLessons > 0;
89
+ // Check what work needs to be done BEFORE opening browser
90
+ const initialSummary = hasExistingData ? db.getStatusSummary() : null;
91
+ if (hasExistingData && initialSummary) {
92
+ console.log(chalk.gray(` Found: ${existingMeta.totalModules} modules, ${existingMeta.totalLessons} lessons`));
93
+ const lockedInfo = initialSummary.locked > 0 ? `, ${initialSummary.locked} locked` : "";
94
+ console.log(chalk.gray(` Status: ${initialSummary.downloaded} downloaded, ${initialSummary.validated} ready, ${initialSummary.error} failed, ${initialSummary.pending} to scan${lockedInfo}`));
95
+ }
96
+ const needsScan = !hasExistingData || (initialSummary?.pending ?? 0) > 0;
97
+ const needsValidation = hasExistingData ? db.getLessonsToValidate().length > 0 : true;
98
+ const needsDownload = hasExistingData ? db.getLessonsToDownload().length > 0 : true;
99
+ const courseDir = await createCourseDirectory(config.outputDir, communitySlug);
100
+ // Quick exit if nothing to do (and not retry-failed or dry-run)
101
+ if (hasExistingData &&
102
+ !needsScan &&
103
+ !needsValidation &&
104
+ !needsDownload &&
105
+ !options.dryRun &&
106
+ !options.retryFailed) {
107
+ console.log(chalk.green("\nāœ… Already complete! Nothing to do.\n"));
108
+ printStatusSummary(db);
109
+ console.log(chalk.gray(` Output: ${courseDir}\n`));
110
+ db.close();
111
+ return;
112
+ }
113
+ // Get authenticated session (only if we have work to do)
114
+ // --visible flag overrides headless config
115
+ const useHeadless = options.visible ? false : config.headless;
116
+ const spinner = ora("Connecting to Skool...").start();
117
+ let browser;
118
+ let session;
119
+ try {
120
+ const result = await getAuthenticatedSession({
121
+ domain: SKOOL_DOMAIN,
122
+ loginUrl: SKOOL_LOGIN_URL,
123
+ isLoginPage: isSkoolLoginPage,
124
+ }, { headless: useHeadless });
125
+ browser = result.browser;
126
+ session = result.session;
127
+ cleanupResources.browser = browser;
128
+ spinner.succeed("Connected to Skool");
129
+ }
130
+ catch (error) {
131
+ spinner.fail("Failed to connect");
132
+ db.close();
133
+ console.log(chalk.red("\nāŒ Authentication failed. Please run: offcourse login\n"));
134
+ process.exit(1);
135
+ }
136
+ try {
137
+ // Check if shutdown was requested during connection
138
+ if (!shouldContinue()) {
139
+ return;
140
+ }
141
+ // Retry-failed mode: only process lessons that previously failed
142
+ if (options.retryFailed) {
143
+ await retryFailedLessons(session.page, db, courseDir, config, options);
144
+ await browser.close();
145
+ db.close();
146
+ return;
147
+ }
148
+ // Phase 1: Scan course structure (only if needed)
149
+ if (needsScan || options.dryRun) {
150
+ await scanCourseStructure(session.page, url, db, options);
151
+ }
152
+ else {
153
+ console.log(chalk.gray("\n ā­ļø Scan skipped (already complete)"));
154
+ }
155
+ if (options.dryRun) {
156
+ printStatusSummary(db);
157
+ await browser.close();
158
+ db.close();
159
+ return;
160
+ }
161
+ console.log(chalk.gray(`\nšŸ“ Output: ${courseDir}\n`));
162
+ // Phase 2: Validate videos (only lessons that need it)
163
+ const lessonsToValidate = db.getLessonsToValidate();
164
+ if (lessonsToValidate.length > 0) {
165
+ await validateVideos(session.page, db, options);
166
+ }
167
+ else {
168
+ console.log(chalk.gray(" ā­ļø Validation skipped (already complete)"));
169
+ }
170
+ // Phase 3: Extract content and queue downloads
171
+ let videoTasks = await extractContentAndQueueVideos(session.page, db, courseDir, options);
172
+ // Phase 4: Download videos with auto-retry
173
+ const MAX_RETRIES = 3;
174
+ let retryRound = 0;
175
+ while (!options.skipVideos && videoTasks.length > 0) {
176
+ await downloadVideos(db, videoTasks, courseDir, config);
177
+ // Check for retryable failures
178
+ const retryable = db.getLessonsToRetry(MAX_RETRIES);
179
+ if (retryable.length === 0 || retryRound >= MAX_RETRIES) {
180
+ break;
181
+ }
182
+ retryRound++;
183
+ console.log(chalk.yellow(`\nšŸ”„ Auto-retry round ${retryRound}: ${retryable.length} lesson(s) to retry\n`));
184
+ // Queue them for re-validation and re-download
185
+ for (const lesson of retryable) {
186
+ db.incrementRetryCount(lesson.id);
187
+ // If lesson has HLS URL, just re-queue for download
188
+ if (lesson.hlsUrl) {
189
+ db.queueForRetry(lesson.id, LessonStatus.VALIDATED);
190
+ }
191
+ else {
192
+ // Need to re-validate
193
+ db.queueForRetry(lesson.id, LessonStatus.PENDING);
194
+ }
195
+ }
196
+ // Re-validate lessons that need it
197
+ const needsValidation = db.getLessonsByStatus(LessonStatus.PENDING);
198
+ if (needsValidation.length > 0) {
199
+ await validateVideos(session.page, db, options);
200
+ }
201
+ // Get new download tasks
202
+ videoTasks = await buildDownloadTasksFromDb(db, courseDir);
203
+ }
204
+ // Summary
205
+ printStatusSummary(db);
206
+ console.log(chalk.green("\nāœ… Sync complete!\n"));
207
+ console.log(chalk.gray(` Output: ${courseDir}\n`));
208
+ }
209
+ finally {
210
+ await browser.close();
211
+ db.close();
212
+ }
213
+ }
214
+ /**
215
+ * Phase 1: Scan course structure and populate database.
216
+ */
217
+ async function scanCourseStructure(page, url, db, options) {
218
+ console.log(chalk.blue("\nšŸ“š Phase 1: Scanning course structure...\n"));
219
+ let progressBar;
220
+ let courseName = "";
221
+ let totalModules = 0;
222
+ let lockedModules = 0;
223
+ try {
224
+ const courseStructure = await buildCourseStructure(page, url, (progress) => {
225
+ if (progress.phase === "init" && progress.courseName) {
226
+ courseName = progress.courseName;
227
+ console.log(chalk.white(` Course: ${courseName}\n`));
228
+ }
229
+ else if (progress.phase === "modules" && progress.totalModules) {
230
+ totalModules = progress.totalModules;
231
+ progressBar = new cliProgress.SingleBar({
232
+ format: " {bar} {percentage}% | {value}/{total} | {status}",
233
+ barCompleteChar: "ā–ˆ",
234
+ barIncompleteChar: "ā–‘",
235
+ barsize: 30,
236
+ hideCursor: true,
237
+ }, cliProgress.Presets.shades_grey);
238
+ progressBar.start(totalModules, 0, { status: "Starting..." });
239
+ }
240
+ else if (progress.phase === "lessons" && progress.currentModule !== undefined) {
241
+ if (progress.skippedLocked) {
242
+ lockedModules++;
243
+ progressBar?.increment({ status: `šŸ”’ ${progress.currentModule}` });
244
+ }
245
+ else if (progress.lessonsFound !== undefined) {
246
+ progressBar?.increment({
247
+ status: `${progress.currentModule} (${progress.lessonsFound} lessons)`,
248
+ });
249
+ }
250
+ else {
251
+ const shortName = progress.currentModule.length > 35
252
+ ? progress.currentModule.substring(0, 32) + "..."
253
+ : progress.currentModule;
254
+ progressBar?.update(progress.currentModuleIndex ?? 0, { status: shortName });
255
+ }
256
+ }
257
+ else if (progress.phase === "done") {
258
+ progressBar?.stop();
259
+ }
260
+ });
261
+ // Update metadata
262
+ db.updateCourseMetadata(courseStructure.name, courseStructure.url);
263
+ // Track new lessons found
264
+ let newLessons = 0;
265
+ for (let moduleIndex = 0; moduleIndex < courseStructure.modules.length; moduleIndex++) {
266
+ const module = courseStructure.modules[moduleIndex];
267
+ if (!module)
268
+ continue;
269
+ // Check if module exists
270
+ const existingModule = db.getModuleBySlug(module.slug);
271
+ const moduleRecord = db.upsertModule(module.slug, module.name, moduleIndex, module.isLocked);
272
+ // Track new modules (existingModule is null for new ones)
273
+ void existingModule;
274
+ for (let lessonIndex = 0; lessonIndex < module.lessons.length; lessonIndex++) {
275
+ const lesson = module.lessons[lessonIndex];
276
+ if (!lesson)
277
+ continue;
278
+ // Check if lesson exists
279
+ const existingLesson = db.getLessonByUrl(lesson.url);
280
+ db.upsertLesson(moduleRecord.id, lesson.slug, lesson.name, lesson.url, lessonIndex, lesson.isLocked);
281
+ if (!existingLesson) {
282
+ newLessons++;
283
+ }
284
+ // Check limit
285
+ if (options.limit && db.getLessonCount() >= options.limit) {
286
+ break;
287
+ }
288
+ }
289
+ if (options.limit && db.getLessonCount() >= options.limit) {
290
+ break;
291
+ }
292
+ }
293
+ const meta = db.getCourseMetadata();
294
+ console.log();
295
+ const parts = [];
296
+ parts.push(`${meta.totalModules} modules`);
297
+ parts.push(`${meta.totalLessons} lessons`);
298
+ if (lockedModules > 0)
299
+ parts.push(chalk.yellow(`${lockedModules} locked`));
300
+ if (newLessons > 0)
301
+ parts.push(chalk.green(`+${newLessons} new`));
302
+ console.log(` Found: ${parts.join(", ")}`);
303
+ }
304
+ catch (error) {
305
+ progressBar?.stop();
306
+ console.log(chalk.red(" Failed to scan course structure"));
307
+ throw error;
308
+ }
309
+ }
310
+ /**
311
+ * Phase 2: Validate videos and get HLS URLs.
312
+ */
313
+ async function validateVideos(page, db, _options) {
314
+ // Get lessons that need scanning
315
+ const lessonsToScan = db.getLessonsToScan();
316
+ if (lessonsToScan.length === 0) {
317
+ console.log(chalk.gray(" No new lessons to validate"));
318
+ return;
319
+ }
320
+ console.log(chalk.blue(`\nšŸ” Phase 2: Validating ${lessonsToScan.length} videos...\n`));
321
+ // Create progress bar
322
+ const progressBar = new cliProgress.SingleBar({
323
+ format: " {bar} {percentage}% | {value}/{total} | {status}",
324
+ barCompleteChar: "ā–ˆ",
325
+ barIncompleteChar: "ā–‘",
326
+ barsize: 30,
327
+ hideCursor: true,
328
+ }, cliProgress.Presets.shades_grey);
329
+ progressBar.start(lessonsToScan.length, 0, { status: "Starting..." });
330
+ let validated = 0;
331
+ let errors = 0;
332
+ let skipped = 0;
333
+ let currentModule = "";
334
+ let processed = 0;
335
+ for (const lesson of lessonsToScan) {
336
+ // Check for graceful shutdown
337
+ if (!shouldContinue()) {
338
+ progressBar.stop();
339
+ console.log(chalk.yellow("\n Stopping validation (shutdown requested)"));
340
+ break;
341
+ }
342
+ // Update module in status
343
+ if (lesson.moduleName !== currentModule) {
344
+ currentModule = lesson.moduleName;
345
+ }
346
+ // Update progress bar with current lesson
347
+ const shortName = lesson.name.length > 40 ? lesson.name.substring(0, 37) + "..." : lesson.name;
348
+ progressBar.update(processed, { status: shortName });
349
+ try {
350
+ // Navigate to lesson and extract video URL
351
+ await page.goto(lesson.url, { timeout: 30000 });
352
+ await page.waitForLoadState("domcontentloaded");
353
+ // Wait for iframes to potentially load (Skool lazy-loads video iframes)
354
+ try {
355
+ await page.waitForSelector('iframe[src*="loom.com"], iframe[src*="vimeo"], iframe[src*="youtube"], video', {
356
+ timeout: 3000,
357
+ });
358
+ }
359
+ catch {
360
+ // No video element appeared - might not have one, will check below
361
+ }
362
+ await page.waitForTimeout(500);
363
+ const { url: videoUrl, type: videoType } = await extractVideoUrl(page);
364
+ if (!videoUrl || !videoType) {
365
+ // No video on this lesson
366
+ db.updateLessonScan(lesson.id, null, null, null, LessonStatus.SKIPPED);
367
+ skipped++;
368
+ }
369
+ else if (videoType === "youtube" || videoType === "wistia") {
370
+ // Handle unsupported video types
371
+ db.updateLessonScan(lesson.id, videoType, videoUrl, null, LessonStatus.ERROR, `${videoType.charAt(0).toUpperCase() + videoType.slice(1)} videos are not yet supported`, "UNSUPPORTED_PROVIDER");
372
+ errors++;
373
+ }
374
+ else if (videoType === "loom" || videoType === "vimeo") {
375
+ // Validate HLS for video types that support it
376
+ if (page.url() !== lesson.url) {
377
+ await page.goto(lesson.url, { timeout: 30000 });
378
+ await page.waitForLoadState("domcontentloaded");
379
+ await page.waitForTimeout(1000);
380
+ }
381
+ const validation = await validateVideoHls(videoUrl, videoType, page, lesson.url);
382
+ if (validation.isValid) {
383
+ db.updateLessonScan(lesson.id, videoType, videoUrl, validation.hlsUrl, LessonStatus.VALIDATED);
384
+ validated++;
385
+ }
386
+ else {
387
+ db.updateLessonScan(lesson.id, videoType, videoUrl, null, LessonStatus.ERROR, validation.error, validation.errorCode);
388
+ errors++;
389
+ }
390
+ }
391
+ else {
392
+ // For native/unknown video types, mark as validated
393
+ db.updateLessonScan(lesson.id, videoType, videoUrl, null, LessonStatus.VALIDATED);
394
+ validated++;
395
+ }
396
+ }
397
+ catch (error) {
398
+ const errorMessage = error instanceof Error ? error.message : String(error);
399
+ db.updateLessonScan(lesson.id, null, null, null, LessonStatus.ERROR, errorMessage, "SCAN_ERROR");
400
+ errors++;
401
+ }
402
+ processed++;
403
+ progressBar.update(processed, { status: shortName });
404
+ }
405
+ progressBar.stop();
406
+ // Print summary
407
+ console.log();
408
+ const parts = [];
409
+ if (validated > 0)
410
+ parts.push(chalk.green(`${validated} ready`));
411
+ if (skipped > 0)
412
+ parts.push(chalk.gray(`${skipped} no video`));
413
+ if (errors > 0)
414
+ parts.push(chalk.red(`${errors} errors`));
415
+ console.log(` Validation: ${parts.join(", ")}`);
416
+ }
417
+ /**
418
+ * Phase 3: Extract content and queue video downloads.
419
+ */
420
+ async function extractContentAndQueueVideos(page, db, courseDir, options) {
421
+ // Get lessons ready for download
422
+ const lessonsToProcess = db.getLessonsByStatus(LessonStatus.VALIDATED);
423
+ if (lessonsToProcess.length === 0) {
424
+ console.log(chalk.gray(" No videos ready for download"));
425
+ return [];
426
+ }
427
+ console.log(chalk.blue(`\nšŸ“ Phase 3: Extracting content for ${lessonsToProcess.length} lessons...\n`));
428
+ // Create progress bar
429
+ const progressBar = new cliProgress.SingleBar({
430
+ format: " {bar} {percentage}% | {value}/{total} | {status}",
431
+ barCompleteChar: "ā–ˆ",
432
+ barIncompleteChar: "ā–‘",
433
+ barsize: 30,
434
+ hideCursor: true,
435
+ }, cliProgress.Presets.shades_grey);
436
+ progressBar.start(lessonsToProcess.length, 0, { status: "Starting..." });
437
+ const videoTasks = [];
438
+ let contentExtracted = 0;
439
+ let contentSkipped = 0;
440
+ let filesDownloadedTotal = 0;
441
+ let processed = 0;
442
+ // Group lessons by module for directory creation
443
+ const lessonsByModule = new Map();
444
+ for (const lesson of lessonsToProcess) {
445
+ const key = `${lesson.modulePosition}-${lesson.moduleSlug}`;
446
+ if (!lessonsByModule.has(key)) {
447
+ lessonsByModule.set(key, []);
448
+ }
449
+ lessonsByModule.get(key).push(lesson);
450
+ }
451
+ for (const [, lessons] of lessonsByModule) {
452
+ // Check for graceful shutdown
453
+ if (!shouldContinue()) {
454
+ progressBar.stop();
455
+ console.log(chalk.yellow("\n Stopping content extraction (shutdown requested)"));
456
+ break;
457
+ }
458
+ const firstLesson = lessons[0];
459
+ const moduleDir = await createModuleDirectory(courseDir, firstLesson.modulePosition, firstLesson.moduleName);
460
+ for (const lesson of lessons) {
461
+ // Check for graceful shutdown
462
+ if (!shouldContinue()) {
463
+ break;
464
+ }
465
+ const shortName = lesson.name.length > 40 ? lesson.name.substring(0, 37) + "..." : lesson.name;
466
+ progressBar.update(processed, { status: shortName });
467
+ const syncStatus = await isLessonSynced(moduleDir, lesson.position, lesson.name);
468
+ // Check if content already exists
469
+ if (!options.skipContent && !syncStatus.content) {
470
+ try {
471
+ const content = await extractLessonContent(page, lesson.url);
472
+ const markdown = formatMarkdown(content.title, content.markdownContent, lesson.videoUrl, lesson.videoType);
473
+ const mdPath = getMarkdownPath(moduleDir, lesson.position, lesson.name);
474
+ await saveMarkdown(dirname(mdPath), basename(mdPath), markdown);
475
+ // Download any linked files (PDFs, Office documents, etc.)
476
+ if (content.downloadableFiles.length > 0) {
477
+ for (const file of content.downloadableFiles) {
478
+ const filePath = getDownloadFilePath(moduleDir, lesson.position, lesson.name, file.filename);
479
+ const result = await downloadFile(file.url, filePath);
480
+ if (result.success) {
481
+ filesDownloadedTotal++;
482
+ }
483
+ }
484
+ }
485
+ contentExtracted++;
486
+ }
487
+ catch {
488
+ // Error extracting content, continue with next lesson
489
+ }
490
+ }
491
+ else {
492
+ contentSkipped++;
493
+ }
494
+ // Queue video for download if not already downloaded
495
+ if (!options.skipVideos && !syncStatus.video && lesson.videoUrl && lesson.videoType) {
496
+ videoTasks.push({
497
+ lessonId: lesson.id,
498
+ lessonName: lesson.name,
499
+ videoUrl: lesson.hlsUrl ?? lesson.videoUrl,
500
+ videoType: lesson.videoType,
501
+ outputPath: getVideoPath(moduleDir, lesson.position, lesson.name),
502
+ });
503
+ }
504
+ processed++;
505
+ progressBar.update(processed, { status: shortName });
506
+ }
507
+ }
508
+ progressBar.stop();
509
+ // Print summary
510
+ console.log();
511
+ const parts = [];
512
+ if (contentExtracted > 0)
513
+ parts.push(chalk.green(`${contentExtracted} extracted`));
514
+ if (contentSkipped > 0)
515
+ parts.push(chalk.gray(`${contentSkipped} cached`));
516
+ if (filesDownloadedTotal > 0)
517
+ parts.push(chalk.blue(`${filesDownloadedTotal} files`));
518
+ console.log(` Content: ${parts.join(", ")}`);
519
+ return videoTasks;
520
+ }
521
+ /**
522
+ * Phase 4: Download videos with multi-progress display.
523
+ */
524
+ async function downloadVideos(db, videoTasks, courseDir, config, _options) {
525
+ const total = videoTasks.length;
526
+ console.log(chalk.blue(`\nšŸŽ¬ Phase 4: Downloading ${total} videos...\n`));
527
+ // Create multi-bar container with auto-clear
528
+ const multibar = new cliProgress.MultiBar({
529
+ clearOnComplete: true,
530
+ hideCursor: true,
531
+ format: " {typeTag} {bar} {percentage}% | {lessonName}",
532
+ barCompleteChar: "ā–ˆ",
533
+ barIncompleteChar: "ā–‘",
534
+ barsize: 25,
535
+ autopadding: true,
536
+ }, cliProgress.Presets.shades_grey);
537
+ // Overall progress bar at the top
538
+ const overallBar = multibar.create(total, 0, {
539
+ typeTag: "[TOTAL]".padEnd(8),
540
+ lessonName: `0/${total} completed`,
541
+ });
542
+ // Track results
543
+ const downloadAttempts = [];
544
+ const errors = [];
545
+ let completed = 0;
546
+ let failed = 0;
547
+ // Active downloads map: lessonName -> bar
548
+ const activeBars = new Map();
549
+ // Process downloads with controlled concurrency
550
+ const taskQueue = [...videoTasks];
551
+ const activePromises = new Set();
552
+ const processTask = async (task) => {
553
+ const typeTag = task.videoType ? `[${task.videoType.toUpperCase()}]` : "[VIDEO]";
554
+ const shortName = task.lessonName.length > 40 ? task.lessonName.substring(0, 37) + "..." : task.lessonName;
555
+ // Create progress bar for this download
556
+ const bar = multibar.create(100, 0, {
557
+ typeTag: typeTag.padEnd(8),
558
+ lessonName: shortName,
559
+ });
560
+ activeBars.set(task.lessonName, bar);
561
+ try {
562
+ const downloadResult = await downloadVideo(task, (progress) => {
563
+ bar.update(Math.round(progress.percent));
564
+ });
565
+ // Record the attempt
566
+ const attempt = {
567
+ lessonName: task.lessonName,
568
+ videoUrl: task.videoUrl,
569
+ videoType: task.videoType,
570
+ success: downloadResult.success,
571
+ timestamp: new Date().toISOString(),
572
+ };
573
+ if (!downloadResult.success) {
574
+ attempt.error = downloadResult.error;
575
+ attempt.errorCode = downloadResult.errorCode;
576
+ attempt.details = downloadResult.details;
577
+ db.markLessonError(task.lessonId, downloadResult.error ?? "Download failed", downloadResult.errorCode);
578
+ errors.push({
579
+ id: task.lessonName,
580
+ error: downloadResult.error ?? "Download failed",
581
+ });
582
+ failed++;
583
+ }
584
+ else {
585
+ // Update database with success
586
+ const fileSize = await getFileSize(task.outputPath);
587
+ db.markLessonDownloaded(task.lessonId, fileSize ?? undefined);
588
+ completed++;
589
+ }
590
+ downloadAttempts.push(attempt);
591
+ }
592
+ catch (error) {
593
+ failed++;
594
+ const errorMsg = error instanceof Error ? error.message : String(error);
595
+ errors.push({ id: task.lessonName, error: errorMsg });
596
+ db.markLessonError(task.lessonId, errorMsg);
597
+ }
598
+ finally {
599
+ // Remove the bar when done (key fix!)
600
+ multibar.remove(bar);
601
+ activeBars.delete(task.lessonName);
602
+ // Update overall progress
603
+ const done = completed + failed;
604
+ overallBar.update(done, {
605
+ lessonName: `${done}/${total} completed (${failed} failed)`,
606
+ });
607
+ }
608
+ };
609
+ // Run downloads with controlled concurrency
610
+ while (taskQueue.length > 0 || activePromises.size > 0) {
611
+ // Start new downloads up to concurrency limit
612
+ while (taskQueue.length > 0 && activePromises.size < config.concurrency) {
613
+ const task = taskQueue.shift();
614
+ if (task) {
615
+ const promise = processTask(task).finally(() => {
616
+ activePromises.delete(promise);
617
+ });
618
+ activePromises.add(promise);
619
+ }
620
+ }
621
+ // Wait for at least one to complete
622
+ if (activePromises.size > 0) {
623
+ await Promise.race(activePromises);
624
+ }
625
+ }
626
+ // Stop multibar
627
+ multibar.stop();
628
+ // Print summary
629
+ console.log();
630
+ if (failed === 0) {
631
+ console.log(chalk.green(` āœ“ ${completed} videos downloaded successfully`));
632
+ }
633
+ else {
634
+ console.log(chalk.yellow(` Videos: ${completed} downloaded, ${failed} failed`));
635
+ }
636
+ if (errors.length > 0) {
637
+ console.log(chalk.yellow("\n Failed downloads:"));
638
+ for (const error of errors) {
639
+ const task = videoTasks.find((t) => t.lessonName === error.id);
640
+ const typeTag = task?.videoType ? `[${task.videoType.toUpperCase()}]` : "";
641
+ console.log(chalk.red(` - ${typeTag} ${error.id}: ${error.error}`));
642
+ }
643
+ // Save diagnostic log
644
+ const failedAttempts = downloadAttempts.filter((a) => !a.success);
645
+ if (failedAttempts.length > 0) {
646
+ const logPath = join(courseDir, `download-errors-${Date.now()}.json`);
647
+ const logData = {
648
+ timestamp: new Date().toISOString(),
649
+ totalAttempts: videoTasks.length,
650
+ successful: completed,
651
+ failed,
652
+ concurrency: config.concurrency,
653
+ retryAttempts: config.retryAttempts,
654
+ failures: failedAttempts,
655
+ };
656
+ await outputFile(logPath, JSON.stringify(logData, null, 2));
657
+ console.log(chalk.gray(`\n šŸ“‹ Detailed error log saved: ${logPath}`));
658
+ }
659
+ }
660
+ }
661
+ /**
662
+ * Build download tasks from database (for --resume mode).
663
+ * Skips lessons that are already downloaded.
664
+ */
665
+ async function buildDownloadTasksFromDb(db, courseDir) {
666
+ const lessons = db.getLessonsToDownload();
667
+ const videoTasks = [];
668
+ let alreadyOnDisk = 0;
669
+ console.log(chalk.blue(`\nšŸ“¦ Building download list from ${lessons.length} ready lessons...\n`));
670
+ for (const lesson of lessons) {
671
+ // Create module directory (flat structure - no lesson subdirectories)
672
+ const moduleDir = await createModuleDirectory(courseDir, lesson.modulePosition, lesson.moduleName);
673
+ // Check if already downloaded
674
+ const syncStatus = await isLessonSynced(moduleDir, lesson.position, lesson.name);
675
+ if (syncStatus.video) {
676
+ // File exists on disk but DB not updated - fix DB state
677
+ db.markLessonDownloaded(lesson.id);
678
+ alreadyOnDisk++;
679
+ continue;
680
+ }
681
+ if (lesson.hlsUrl && lesson.videoType) {
682
+ videoTasks.push({
683
+ lessonId: lesson.id,
684
+ lessonName: lesson.name,
685
+ videoUrl: lesson.hlsUrl,
686
+ videoType: lesson.videoType,
687
+ outputPath: getVideoPath(moduleDir, lesson.position, lesson.name),
688
+ });
689
+ }
690
+ }
691
+ if (alreadyOnDisk > 0) {
692
+ console.log(chalk.green(` āœ… ${alreadyOnDisk} already on disk (DB updated)`));
693
+ }
694
+ console.log(chalk.gray(` ā¬‡ļø ${videoTasks.length} videos to download`));
695
+ return videoTasks;
696
+ }
697
+ /**
698
+ * Retry failed lessons with detailed diagnostics.
699
+ */
700
+ async function retryFailedLessons(page, db, courseDir, _config, _options) {
701
+ const errorLessons = db.getLessonsByStatus(LessonStatus.ERROR);
702
+ if (errorLessons.length === 0) {
703
+ console.log(chalk.green("\nāœ… No failed lessons to retry!\n"));
704
+ printStatusSummary(db);
705
+ return;
706
+ }
707
+ console.log(chalk.yellow(`\nšŸ”„ Retry Failed Mode: ${errorLessons.length} lesson(s) to retry\n`));
708
+ // Group by error type for summary
709
+ const byErrorCode = new Map();
710
+ for (const lesson of errorLessons) {
711
+ const code = lesson.errorCode ?? "UNKNOWN";
712
+ if (!byErrorCode.has(code)) {
713
+ byErrorCode.set(code, []);
714
+ }
715
+ byErrorCode.get(code).push(lesson);
716
+ }
717
+ console.log(chalk.gray(" Error breakdown:"));
718
+ for (const [code, lessons] of byErrorCode) {
719
+ console.log(chalk.gray(` ${code}: ${lessons.length}`));
720
+ }
721
+ console.log();
722
+ // Results tracking
723
+ const results = [];
724
+ // Progress bar
725
+ const progressBar = new cliProgress.SingleBar({
726
+ format: " {bar} {percentage}% | {value}/{total} | {status}",
727
+ barCompleteChar: "ā–ˆ",
728
+ barIncompleteChar: "ā–‘",
729
+ barsize: 30,
730
+ hideCursor: true,
731
+ }, cliProgress.Presets.shades_grey);
732
+ progressBar.start(errorLessons.length, 0, { status: "Starting..." });
733
+ for (let i = 0; i < errorLessons.length; i++) {
734
+ const lesson = errorLessons[i];
735
+ if (!lesson)
736
+ continue;
737
+ const shortName = lesson.name.length > 30 ? lesson.name.substring(0, 27) + "..." : lesson.name;
738
+ progressBar.update(i, { status: shortName });
739
+ try {
740
+ // Navigate to the lesson page
741
+ await page.goto(lesson.url, { waitUntil: "domcontentloaded", timeout: 30000 });
742
+ await page.waitForTimeout(2000);
743
+ // Try to extract video URL
744
+ const videoInfo = await extractVideoUrl(page);
745
+ if (!videoInfo.url) {
746
+ // No video found - mark as skipped (no video) or keep error
747
+ if (lesson.errorCode === "UNSUPPORTED_PROVIDER") {
748
+ results.push({
749
+ lesson,
750
+ success: false,
751
+ newStatus: "error",
752
+ details: `Unsupported provider: ${lesson.videoType ?? "unknown"}`,
753
+ });
754
+ }
755
+ else {
756
+ db.markLessonSkipped(lesson.id, "No video found on retry");
757
+ results.push({
758
+ lesson,
759
+ success: true,
760
+ newStatus: "skipped",
761
+ details: "No video on page",
762
+ });
763
+ }
764
+ continue;
765
+ }
766
+ // Check for unsupported providers
767
+ if (videoInfo.type === "youtube" || videoInfo.type === "wistia") {
768
+ db.markLessonError(lesson.id, `${videoInfo.type} videos are not yet supported`, "UNSUPPORTED_PROVIDER");
769
+ db.updateLessonVideoType(lesson.id, videoInfo.type);
770
+ results.push({
771
+ lesson,
772
+ success: false,
773
+ newStatus: "error",
774
+ details: `Unsupported: ${videoInfo.type}`,
775
+ });
776
+ continue;
777
+ }
778
+ // Validate and get HLS URL
779
+ const validation = await validateVideoHls(videoInfo.url, videoInfo.type ?? "native", page, lesson.url);
780
+ if (!validation.isValid || !validation.hlsUrl) {
781
+ db.markLessonError(lesson.id, validation.error ?? "Validation failed", validation.errorCode ?? "VALIDATION_FAILED");
782
+ results.push({
783
+ lesson,
784
+ success: false,
785
+ newStatus: "error",
786
+ details: validation.error ?? "Could not validate video",
787
+ });
788
+ continue;
789
+ }
790
+ // Update lesson with HLS URL
791
+ db.updateLessonScan(lesson.id, videoInfo.type ?? null, videoInfo.url, validation.hlsUrl, LessonStatus.VALIDATED);
792
+ // Try to download
793
+ const moduleDir = await createModuleDirectory(courseDir, lesson.modulePosition, lesson.moduleName);
794
+ const outputPath = getVideoPath(moduleDir, lesson.position, lesson.name);
795
+ const downloadResult = await downloadVideo({
796
+ lessonId: lesson.id,
797
+ lessonName: lesson.name,
798
+ videoUrl: validation.hlsUrl,
799
+ videoType: videoInfo.type,
800
+ outputPath,
801
+ });
802
+ if (downloadResult.success) {
803
+ const fileSize = await getFileSize(outputPath);
804
+ db.markLessonDownloaded(lesson.id, fileSize ?? undefined);
805
+ results.push({
806
+ lesson,
807
+ success: true,
808
+ newStatus: "downloaded",
809
+ details: fileSize ? `Downloaded ${(fileSize / 1024 / 1024).toFixed(1)} MB` : "Downloaded",
810
+ });
811
+ }
812
+ else {
813
+ db.markLessonError(lesson.id, downloadResult.error ?? "Download failed", downloadResult.errorCode ?? "DOWNLOAD_FAILED");
814
+ results.push({
815
+ lesson,
816
+ success: false,
817
+ newStatus: "error",
818
+ details: downloadResult.error ?? "Download failed",
819
+ });
820
+ }
821
+ }
822
+ catch (error) {
823
+ const errorMsg = error instanceof Error ? error.message : String(error);
824
+ db.markLessonError(lesson.id, errorMsg, "RETRY_ERROR");
825
+ results.push({
826
+ lesson,
827
+ success: false,
828
+ newStatus: "error",
829
+ details: errorMsg.substring(0, 100),
830
+ });
831
+ }
832
+ }
833
+ progressBar.update(errorLessons.length, { status: "Complete" });
834
+ progressBar.stop();
835
+ // Detailed results
836
+ console.log(chalk.cyan("\nšŸ“‹ Retry Results\n"));
837
+ const successful = results.filter((r) => r.success);
838
+ const failed = results.filter((r) => !r.success);
839
+ if (successful.length > 0) {
840
+ console.log(chalk.green(` āœ… Fixed: ${successful.length}`));
841
+ for (const r of successful) {
842
+ console.log(chalk.gray(` • ${r.lesson.name} → ${r.newStatus} (${r.details})`));
843
+ }
844
+ }
845
+ if (failed.length > 0) {
846
+ console.log(chalk.red(`\n āŒ Still failing: ${failed.length}\n`));
847
+ for (const r of failed) {
848
+ const typeTag = r.lesson.videoType ? `[${r.lesson.videoType.toUpperCase()}]` : "";
849
+ console.log(chalk.red(` ${typeTag} ${r.lesson.name}`));
850
+ console.log(chalk.gray(` Module: ${r.lesson.moduleName}`));
851
+ console.log(chalk.gray(` URL: ${r.lesson.url}`));
852
+ console.log(chalk.gray(` Error: ${r.details}`));
853
+ console.log();
854
+ }
855
+ }
856
+ printStatusSummary(db);
857
+ }
858
+ /**
859
+ * Print status summary from database.
860
+ */
861
+ function printStatusSummary(db) {
862
+ const meta = db.getCourseMetadata();
863
+ const summary = db.getStatusSummary();
864
+ const videoTypes = db.getVideoTypeSummary();
865
+ console.log(chalk.cyan("\nšŸ“Š Status Summary\n"));
866
+ console.log(chalk.white(` Course: ${meta.name}`));
867
+ console.log(chalk.gray(` Modules: ${meta.totalModules}`));
868
+ console.log(chalk.gray(` Lessons: ${meta.totalLessons}`));
869
+ console.log();
870
+ // Clear status labels
871
+ console.log(chalk.green(` āœ… Downloaded: ${summary.downloaded}`));
872
+ if (summary.validated > 0) {
873
+ console.log(chalk.blue(` ā¬‡ļø Ready to download: ${summary.validated}`));
874
+ }
875
+ if (summary.pending > 0) {
876
+ console.log(chalk.gray(` šŸ” Not scanned yet: ${summary.pending}`));
877
+ }
878
+ if (summary.skipped > 0) {
879
+ console.log(chalk.gray(` āž– No video: ${summary.skipped}`));
880
+ }
881
+ if (summary.locked > 0) {
882
+ console.log(chalk.yellow(` šŸ”’ Locked: ${summary.locked}`));
883
+ }
884
+ if (summary.error > 0) {
885
+ console.log(chalk.red(` āŒ Failed: ${summary.error}`));
886
+ // Show unsupported providers if any
887
+ const unsupported = db.getLessonsByErrorCode("UNSUPPORTED_PROVIDER");
888
+ if (unsupported.length > 0) {
889
+ console.log(chalk.yellow(`\n ⚠ Unsupported video providers:`));
890
+ // Group by video type
891
+ const byType = new Map();
892
+ for (const lesson of unsupported) {
893
+ const type = lesson.videoType ?? "unknown";
894
+ if (!byType.has(type)) {
895
+ byType.set(type, []);
896
+ }
897
+ byType.get(type).push(lesson);
898
+ }
899
+ for (const [type, lessons] of byType) {
900
+ console.log(chalk.yellow(` ${type.toUpperCase()}: ${lessons.length} video(s)`));
901
+ for (const lesson of lessons.slice(0, 3)) {
902
+ console.log(chalk.gray(` - ${lesson.moduleName} → ${lesson.name}`));
903
+ }
904
+ if (lessons.length > 3) {
905
+ console.log(chalk.gray(` ... and ${lessons.length - 3} more`));
906
+ }
907
+ }
908
+ console.log(chalk.gray(`\n šŸ’” Tip: Install yt-dlp to download YouTube/Wistia videos`));
909
+ }
910
+ }
911
+ // Show video type breakdown
912
+ if (Object.keys(videoTypes).length > 0) {
913
+ console.log(chalk.gray(`\n Video types found:`));
914
+ for (const [type, count] of Object.entries(videoTypes)) {
915
+ const supported = type === "loom" || type === "vimeo" || type === "native";
916
+ const icon = supported ? "āœ“" : "āœ—";
917
+ const color = supported ? chalk.green : chalk.yellow;
918
+ console.log(color(` ${icon} ${type}: ${count}`));
919
+ }
920
+ }
921
+ }
922
+ //# sourceMappingURL=sync.js.map