offcourse 0.0.1 → 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 +256 -16
  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,1235 @@
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, type VideoDownloadTask, 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 {
12
+ createCourseDirectory,
13
+ createModuleDirectory,
14
+ downloadFile,
15
+ getDownloadFilePath,
16
+ getMarkdownPath,
17
+ getVideoPath,
18
+ isLessonSynced,
19
+ saveMarkdown,
20
+ } from "../../storage/fileSystem.js";
21
+ import {
22
+ CourseDatabase,
23
+ extractCommunitySlug,
24
+ LessonStatus,
25
+ type LessonWithModule,
26
+ } from "../../state/index.js";
27
+
28
+ /**
29
+ * Tracks if shutdown has been requested (Ctrl+C).
30
+ */
31
+ let isShuttingDown = false;
32
+
33
+ /**
34
+ * Resources to clean up on shutdown.
35
+ */
36
+ interface CleanupResources {
37
+ browser?: import("playwright").Browser;
38
+ db?: CourseDatabase;
39
+ }
40
+
41
+ const cleanupResources: CleanupResources = {};
42
+
43
+ /**
44
+ * Graceful shutdown handler.
45
+ */
46
+ function setupShutdownHandlers(): void {
47
+ const shutdown = async (signal: string) => {
48
+ if (isShuttingDown) {
49
+ // Force exit on second signal
50
+ console.log(chalk.red("\n\nāš ļø Force exit"));
51
+ process.exit(1);
52
+ }
53
+
54
+ isShuttingDown = true;
55
+ console.log(chalk.yellow(`\n\nā¹ļø ${signal} received, shutting down gracefully...`));
56
+
57
+ try {
58
+ if (cleanupResources.browser) {
59
+ await cleanupResources.browser.close();
60
+ }
61
+ if (cleanupResources.db) {
62
+ cleanupResources.db.close();
63
+ }
64
+ console.log(chalk.gray(" Cleanup complete. State saved."));
65
+ } catch {
66
+ // Ignore cleanup errors during shutdown
67
+ }
68
+
69
+ process.exit(0);
70
+ };
71
+
72
+ process.on("SIGINT", () => void shutdown("SIGINT"));
73
+ process.on("SIGTERM", () => void shutdown("SIGTERM"));
74
+ }
75
+
76
+ /**
77
+ * Check if we should continue processing or stop due to shutdown.
78
+ */
79
+ function shouldContinue(): boolean {
80
+ return !isShuttingDown;
81
+ }
82
+
83
+ interface DownloadAttempt {
84
+ lessonName: string;
85
+ videoUrl: string;
86
+ videoType: string | null;
87
+ success: boolean;
88
+ error?: string | undefined;
89
+ errorCode?: string | undefined;
90
+ details?: string | undefined;
91
+ timestamp: string;
92
+ }
93
+
94
+ const SKOOL_DOMAIN = "www.skool.com";
95
+ const SKOOL_LOGIN_URL = "https://www.skool.com/login";
96
+
97
+ export interface SyncOptions {
98
+ skipVideos?: boolean;
99
+ skipContent?: boolean;
100
+ dryRun?: boolean;
101
+ limit?: number;
102
+ force?: boolean;
103
+ retryFailed?: boolean;
104
+ visible?: boolean;
105
+ }
106
+
107
+ /**
108
+ * Handles the sync command.
109
+ * Downloads all content from a Skool course with incremental state tracking.
110
+ */
111
+ export async function syncCommand(url: string, options: SyncOptions): Promise<void> {
112
+ // Setup graceful shutdown handlers
113
+ setupShutdownHandlers();
114
+
115
+ console.log(chalk.blue("\nšŸ“š Course Sync\n"));
116
+
117
+ // Validate URL
118
+ if (!url.includes("skool.com")) {
119
+ console.log(chalk.red("āŒ Invalid URL. Please provide a Skool URL."));
120
+ console.log(chalk.gray(" Example: https://www.skool.com/your-community/classroom\n"));
121
+ process.exit(1);
122
+ }
123
+
124
+ // Ensure URL points to classroom
125
+ if (!url.includes("/classroom")) {
126
+ url = url.replace(/\/?$/, "/classroom");
127
+ }
128
+
129
+ const config = loadConfig();
130
+ const communitySlug = extractCommunitySlug(url);
131
+
132
+ // Initialize database
133
+ const db = new CourseDatabase(communitySlug);
134
+ cleanupResources.db = db;
135
+ console.log(chalk.gray(` State: ~/.offcourse/cache/${communitySlug}.db`));
136
+
137
+ // Force mode: reset all lessons to pending for full rescan
138
+ if (options.force) {
139
+ const resetCount = db.resetAllLessonsToPending();
140
+ if (resetCount > 0) {
141
+ console.log(chalk.yellow(` Force mode: reset ${resetCount} lessons for rescan`));
142
+ }
143
+ }
144
+
145
+ // Check existing state
146
+ const existingMeta = db.getCourseMetadata();
147
+ const hasExistingData = existingMeta.totalLessons > 0;
148
+
149
+ // Check what work needs to be done BEFORE opening browser
150
+ const initialSummary = hasExistingData ? db.getStatusSummary() : null;
151
+
152
+ if (hasExistingData && initialSummary) {
153
+ console.log(
154
+ chalk.gray(
155
+ ` Found: ${existingMeta.totalModules} modules, ${existingMeta.totalLessons} lessons`
156
+ )
157
+ );
158
+ const lockedInfo = initialSummary.locked > 0 ? `, ${initialSummary.locked} locked` : "";
159
+ console.log(
160
+ chalk.gray(
161
+ ` Status: ${initialSummary.downloaded} downloaded, ${initialSummary.validated} ready, ${initialSummary.error} failed, ${initialSummary.pending} to scan${lockedInfo}`
162
+ )
163
+ );
164
+ }
165
+
166
+ const needsScan = !hasExistingData || (initialSummary?.pending ?? 0) > 0;
167
+ const needsValidation = hasExistingData ? db.getLessonsToValidate().length > 0 : true;
168
+ const needsDownload = hasExistingData ? db.getLessonsToDownload().length > 0 : true;
169
+ const courseDir = await createCourseDirectory(config.outputDir, communitySlug);
170
+
171
+ // Quick exit if nothing to do (and not retry-failed or dry-run)
172
+ if (
173
+ hasExistingData &&
174
+ !needsScan &&
175
+ !needsValidation &&
176
+ !needsDownload &&
177
+ !options.dryRun &&
178
+ !options.retryFailed
179
+ ) {
180
+ console.log(chalk.green("\nāœ… Already complete! Nothing to do.\n"));
181
+ printStatusSummary(db);
182
+ console.log(chalk.gray(` Output: ${courseDir}\n`));
183
+ db.close();
184
+ return;
185
+ }
186
+
187
+ // Get authenticated session (only if we have work to do)
188
+ // --visible flag overrides headless config
189
+ const useHeadless = options.visible ? false : config.headless;
190
+ const spinner = ora("Connecting to Skool...").start();
191
+
192
+ let browser;
193
+ let session;
194
+
195
+ try {
196
+ const result = await getAuthenticatedSession(
197
+ {
198
+ domain: SKOOL_DOMAIN,
199
+ loginUrl: SKOOL_LOGIN_URL,
200
+ isLoginPage: isSkoolLoginPage,
201
+ },
202
+ { headless: useHeadless }
203
+ );
204
+ browser = result.browser;
205
+ session = result.session;
206
+ cleanupResources.browser = browser;
207
+ spinner.succeed("Connected to Skool");
208
+ } catch {
209
+ spinner.fail("Failed to connect");
210
+ db.close();
211
+ console.log(chalk.red("\nāŒ Authentication failed. Please run: offcourse login\n"));
212
+ process.exit(1);
213
+ }
214
+
215
+ try {
216
+ // Check if shutdown was requested during connection
217
+ if (!shouldContinue()) {
218
+ return;
219
+ }
220
+
221
+ // Retry-failed mode: only process lessons that previously failed
222
+ if (options.retryFailed) {
223
+ await retryFailedLessons(session.page, db, courseDir, config, options);
224
+ await browser.close();
225
+ db.close();
226
+ return;
227
+ }
228
+
229
+ // Phase 1: Scan course structure (only if needed)
230
+ if (needsScan || options.dryRun) {
231
+ await scanCourseStructure(session.page, url, db, options);
232
+ } else {
233
+ console.log(chalk.gray("\n ā­ļø Scan skipped (already complete)"));
234
+ }
235
+
236
+ if (options.dryRun) {
237
+ printStatusSummary(db);
238
+ await browser.close();
239
+ db.close();
240
+ return;
241
+ }
242
+
243
+ console.log(chalk.gray(`\nšŸ“ Output: ${courseDir}\n`));
244
+
245
+ // Phase 2: Validate videos (only lessons that need it)
246
+ const lessonsToValidate = db.getLessonsToValidate();
247
+ if (lessonsToValidate.length > 0) {
248
+ await validateVideos(session.page, db, options);
249
+ } else {
250
+ console.log(chalk.gray(" ā­ļø Validation skipped (already complete)"));
251
+ }
252
+
253
+ // Phase 3: Extract content and queue downloads
254
+ let videoTasks = await extractContentAndQueueVideos(session.page, db, courseDir, options);
255
+
256
+ // Phase 4: Download videos with auto-retry
257
+ const MAX_RETRIES = 3;
258
+ let retryRound = 0;
259
+
260
+ while (!options.skipVideos && videoTasks.length > 0) {
261
+ await downloadVideos(db, videoTasks, courseDir, config);
262
+
263
+ // Check for retryable failures
264
+ const retryable = db.getLessonsToRetry(MAX_RETRIES);
265
+ if (retryable.length === 0 || retryRound >= MAX_RETRIES) {
266
+ break;
267
+ }
268
+
269
+ retryRound++;
270
+ console.log(
271
+ chalk.yellow(
272
+ `\nšŸ”„ Auto-retry round ${retryRound}: ${retryable.length} lesson(s) to retry\n`
273
+ )
274
+ );
275
+
276
+ // Queue them for re-validation and re-download
277
+ for (const lesson of retryable) {
278
+ db.incrementRetryCount(lesson.id);
279
+ // If lesson has HLS URL, just re-queue for download
280
+ if (lesson.hlsUrl) {
281
+ db.queueForRetry(lesson.id, LessonStatus.VALIDATED);
282
+ } else {
283
+ // Need to re-validate
284
+ db.queueForRetry(lesson.id, LessonStatus.PENDING);
285
+ }
286
+ }
287
+
288
+ // Re-validate lessons that need it
289
+ const needsValidation = db.getLessonsByStatus(LessonStatus.PENDING);
290
+ if (needsValidation.length > 0) {
291
+ await validateVideos(session.page, db, options);
292
+ }
293
+
294
+ // Get new download tasks
295
+ videoTasks = await buildDownloadTasksFromDb(db, courseDir);
296
+ }
297
+
298
+ // Summary
299
+ printStatusSummary(db);
300
+ console.log(chalk.green("\nāœ… Sync complete!\n"));
301
+ console.log(chalk.gray(` Output: ${courseDir}\n`));
302
+ } finally {
303
+ await browser.close();
304
+ db.close();
305
+ }
306
+ }
307
+
308
+ /**
309
+ * Phase 1: Scan course structure and populate database.
310
+ */
311
+ async function scanCourseStructure(
312
+ page: import("playwright").Page,
313
+ url: string,
314
+ db: CourseDatabase,
315
+ options: SyncOptions
316
+ ): Promise<void> {
317
+ console.log(chalk.blue("\nšŸ“š Phase 1: Scanning course structure...\n"));
318
+
319
+ let progressBar: cliProgress.SingleBar | undefined;
320
+ let courseName = "";
321
+ let totalModules = 0;
322
+ let lockedModules = 0;
323
+
324
+ try {
325
+ const courseStructure = await buildCourseStructure(page, url, (progress) => {
326
+ if (progress.phase === "init" && progress.courseName) {
327
+ courseName = progress.courseName;
328
+ console.log(chalk.white(` Course: ${courseName}\n`));
329
+ } else if (progress.phase === "modules" && progress.totalModules) {
330
+ totalModules = progress.totalModules;
331
+ progressBar = new cliProgress.SingleBar(
332
+ {
333
+ format: " {bar} {percentage}% | {value}/{total} | {status}",
334
+ barCompleteChar: "ā–ˆ",
335
+ barIncompleteChar: "ā–‘",
336
+ barsize: 30,
337
+ hideCursor: true,
338
+ },
339
+ cliProgress.Presets.shades_grey
340
+ );
341
+ progressBar.start(totalModules, 0, { status: "Starting..." });
342
+ } else if (progress.phase === "lessons" && progress.currentModule !== undefined) {
343
+ if (progress.skippedLocked) {
344
+ lockedModules++;
345
+ progressBar?.increment({ status: `šŸ”’ ${progress.currentModule}` });
346
+ } else if (progress.lessonsFound !== undefined) {
347
+ progressBar?.increment({
348
+ status: `${progress.currentModule} (${progress.lessonsFound} lessons)`,
349
+ });
350
+ } else {
351
+ const shortName =
352
+ progress.currentModule.length > 35
353
+ ? progress.currentModule.substring(0, 32) + "..."
354
+ : progress.currentModule;
355
+ progressBar?.update(progress.currentModuleIndex ?? 0, { status: shortName });
356
+ }
357
+ } else if (progress.phase === "done") {
358
+ progressBar?.stop();
359
+ }
360
+ });
361
+
362
+ // Update metadata
363
+ db.updateCourseMetadata(courseStructure.name, courseStructure.url);
364
+
365
+ // Track new lessons found
366
+ let newLessons = 0;
367
+
368
+ for (let moduleIndex = 0; moduleIndex < courseStructure.modules.length; moduleIndex++) {
369
+ const module = courseStructure.modules[moduleIndex];
370
+ if (!module) continue;
371
+
372
+ // Check if module exists
373
+ const existingModule = db.getModuleBySlug(module.slug);
374
+ const moduleRecord = db.upsertModule(module.slug, module.name, moduleIndex, module.isLocked);
375
+
376
+ // Track new modules (existingModule is null for new ones)
377
+ void existingModule;
378
+
379
+ for (let lessonIndex = 0; lessonIndex < module.lessons.length; lessonIndex++) {
380
+ const lesson = module.lessons[lessonIndex];
381
+ if (!lesson) continue;
382
+
383
+ // Check if lesson exists
384
+ const existingLesson = db.getLessonByUrl(lesson.url);
385
+ db.upsertLesson(
386
+ moduleRecord.id,
387
+ lesson.slug,
388
+ lesson.name,
389
+ lesson.url,
390
+ lessonIndex,
391
+ lesson.isLocked
392
+ );
393
+
394
+ if (!existingLesson) {
395
+ newLessons++;
396
+ }
397
+
398
+ // Check limit
399
+ if (options.limit && db.getLessonCount() >= options.limit) {
400
+ break;
401
+ }
402
+ }
403
+
404
+ if (options.limit && db.getLessonCount() >= options.limit) {
405
+ break;
406
+ }
407
+ }
408
+
409
+ const meta = db.getCourseMetadata();
410
+ console.log();
411
+ const parts: string[] = [];
412
+ parts.push(`${meta.totalModules} modules`);
413
+ parts.push(`${meta.totalLessons} lessons`);
414
+ if (lockedModules > 0) parts.push(chalk.yellow(`${lockedModules} locked`));
415
+ if (newLessons > 0) parts.push(chalk.green(`+${newLessons} new`));
416
+ console.log(` Found: ${parts.join(", ")}`);
417
+ } catch (error) {
418
+ progressBar?.stop();
419
+ console.log(chalk.red(" Failed to scan course structure"));
420
+ throw error;
421
+ }
422
+ }
423
+
424
+ /**
425
+ * Phase 2: Validate videos and get HLS URLs.
426
+ */
427
+ async function validateVideos(
428
+ page: import("playwright").Page,
429
+ db: CourseDatabase,
430
+ _options: SyncOptions
431
+ ): Promise<void> {
432
+ // Get lessons that need scanning
433
+ const lessonsToScan = db.getLessonsToScan();
434
+
435
+ if (lessonsToScan.length === 0) {
436
+ console.log(chalk.gray(" No new lessons to validate"));
437
+ return;
438
+ }
439
+
440
+ console.log(chalk.blue(`\nšŸ” Phase 2: Validating ${lessonsToScan.length} videos...\n`));
441
+
442
+ // Create progress bar
443
+ const progressBar = new cliProgress.SingleBar(
444
+ {
445
+ format: " {bar} {percentage}% | {value}/{total} | {status}",
446
+ barCompleteChar: "ā–ˆ",
447
+ barIncompleteChar: "ā–‘",
448
+ barsize: 30,
449
+ hideCursor: true,
450
+ },
451
+ cliProgress.Presets.shades_grey
452
+ );
453
+
454
+ progressBar.start(lessonsToScan.length, 0, { status: "Starting..." });
455
+
456
+ let validated = 0;
457
+ let errors = 0;
458
+ let skipped = 0;
459
+ let currentModule = "";
460
+ let processed = 0;
461
+
462
+ for (const lesson of lessonsToScan) {
463
+ // Check for graceful shutdown
464
+ if (!shouldContinue()) {
465
+ progressBar.stop();
466
+ console.log(chalk.yellow("\n Stopping validation (shutdown requested)"));
467
+ break;
468
+ }
469
+
470
+ // Update module in status
471
+ if (lesson.moduleName !== currentModule) {
472
+ currentModule = lesson.moduleName;
473
+ }
474
+
475
+ // Update progress bar with current lesson
476
+ const shortName = lesson.name.length > 40 ? lesson.name.substring(0, 37) + "..." : lesson.name;
477
+ progressBar.update(processed, { status: shortName });
478
+
479
+ try {
480
+ // Navigate to lesson and extract video URL
481
+ await page.goto(lesson.url, { timeout: 30000 });
482
+ await page.waitForLoadState("domcontentloaded");
483
+ // Wait for iframes to potentially load (Skool lazy-loads video iframes)
484
+ try {
485
+ await page.waitForSelector(
486
+ 'iframe[src*="loom.com"], iframe[src*="vimeo"], iframe[src*="youtube"], video',
487
+ {
488
+ timeout: 3000,
489
+ }
490
+ );
491
+ } catch {
492
+ // No video element appeared - might not have one, will check below
493
+ }
494
+ await page.waitForTimeout(500);
495
+
496
+ const { url: videoUrl, type: videoType } = await extractVideoUrl(page);
497
+
498
+ if (!videoUrl || !videoType) {
499
+ // No video on this lesson
500
+ db.updateLessonScan(lesson.id, null, null, null, LessonStatus.SKIPPED);
501
+ skipped++;
502
+ } else if (videoType === "youtube" || videoType === "wistia") {
503
+ // Handle unsupported video types
504
+ db.updateLessonScan(
505
+ lesson.id,
506
+ videoType,
507
+ videoUrl,
508
+ null,
509
+ LessonStatus.ERROR,
510
+ `${videoType.charAt(0).toUpperCase() + videoType.slice(1)} videos are not yet supported`,
511
+ "UNSUPPORTED_PROVIDER"
512
+ );
513
+ errors++;
514
+ } else if (videoType === "loom" || videoType === "vimeo") {
515
+ // Validate HLS for video types that support it
516
+ if (page.url() !== lesson.url) {
517
+ await page.goto(lesson.url, { timeout: 30000 });
518
+ await page.waitForLoadState("domcontentloaded");
519
+ await page.waitForTimeout(1000);
520
+ }
521
+
522
+ const validation = await validateVideoHls(videoUrl, videoType, page, lesson.url);
523
+
524
+ if (validation.isValid) {
525
+ db.updateLessonScan(
526
+ lesson.id,
527
+ videoType,
528
+ videoUrl,
529
+ validation.hlsUrl,
530
+ LessonStatus.VALIDATED
531
+ );
532
+ validated++;
533
+ } else {
534
+ db.updateLessonScan(
535
+ lesson.id,
536
+ videoType,
537
+ videoUrl,
538
+ null,
539
+ LessonStatus.ERROR,
540
+ validation.error,
541
+ validation.errorCode
542
+ );
543
+ errors++;
544
+ }
545
+ } else {
546
+ // For native/unknown video types, mark as validated
547
+ db.updateLessonScan(lesson.id, videoType, videoUrl, null, LessonStatus.VALIDATED);
548
+ validated++;
549
+ }
550
+ } catch (error) {
551
+ const errorMessage = error instanceof Error ? error.message : String(error);
552
+ db.updateLessonScan(
553
+ lesson.id,
554
+ null,
555
+ null,
556
+ null,
557
+ LessonStatus.ERROR,
558
+ errorMessage,
559
+ "SCAN_ERROR"
560
+ );
561
+ errors++;
562
+ }
563
+
564
+ processed++;
565
+ progressBar.update(processed, { status: shortName });
566
+ }
567
+
568
+ progressBar.stop();
569
+
570
+ // Print summary
571
+ console.log();
572
+ const parts: string[] = [];
573
+ if (validated > 0) parts.push(chalk.green(`${validated} ready`));
574
+ if (skipped > 0) parts.push(chalk.gray(`${skipped} no video`));
575
+ if (errors > 0) parts.push(chalk.red(`${errors} errors`));
576
+ console.log(` Validation: ${parts.join(", ")}`);
577
+ }
578
+
579
+ /**
580
+ * Phase 3: Extract content and queue video downloads.
581
+ */
582
+ async function extractContentAndQueueVideos(
583
+ page: import("playwright").Page,
584
+ db: CourseDatabase,
585
+ courseDir: string,
586
+ options: SyncOptions
587
+ ): Promise<VideoDownloadTask[]> {
588
+ // Get lessons ready for download
589
+ const lessonsToProcess = db.getLessonsByStatus(LessonStatus.VALIDATED);
590
+
591
+ if (lessonsToProcess.length === 0) {
592
+ console.log(chalk.gray(" No videos ready for download"));
593
+ return [];
594
+ }
595
+
596
+ console.log(
597
+ chalk.blue(`\nšŸ“ Phase 3: Extracting content for ${lessonsToProcess.length} lessons...\n`)
598
+ );
599
+
600
+ // Create progress bar
601
+ const progressBar = new cliProgress.SingleBar(
602
+ {
603
+ format: " {bar} {percentage}% | {value}/{total} | {status}",
604
+ barCompleteChar: "ā–ˆ",
605
+ barIncompleteChar: "ā–‘",
606
+ barsize: 30,
607
+ hideCursor: true,
608
+ },
609
+ cliProgress.Presets.shades_grey
610
+ );
611
+
612
+ progressBar.start(lessonsToProcess.length, 0, { status: "Starting..." });
613
+
614
+ const videoTasks: VideoDownloadTask[] = [];
615
+ let contentExtracted = 0;
616
+ let contentSkipped = 0;
617
+ let filesDownloadedTotal = 0;
618
+ let processed = 0;
619
+
620
+ // Group lessons by module for directory creation
621
+ const lessonsByModule = new Map<string, LessonWithModule[]>();
622
+ for (const lesson of lessonsToProcess) {
623
+ const key = `${lesson.modulePosition}-${lesson.moduleSlug}`;
624
+ const moduleLessons = lessonsByModule.get(key) ?? [];
625
+ moduleLessons.push(lesson);
626
+ lessonsByModule.set(key, moduleLessons);
627
+ }
628
+
629
+ for (const [, lessons] of lessonsByModule) {
630
+ // Check for graceful shutdown
631
+ if (!shouldContinue()) {
632
+ progressBar.stop();
633
+ console.log(chalk.yellow("\n Stopping content extraction (shutdown requested)"));
634
+ break;
635
+ }
636
+
637
+ const firstLesson = lessons[0];
638
+ if (!firstLesson) continue;
639
+ const moduleDir = await createModuleDirectory(
640
+ courseDir,
641
+ firstLesson.modulePosition,
642
+ firstLesson.moduleName
643
+ );
644
+
645
+ for (const lesson of lessons) {
646
+ // Check for graceful shutdown
647
+ if (!shouldContinue()) {
648
+ break;
649
+ }
650
+
651
+ const shortName =
652
+ lesson.name.length > 40 ? lesson.name.substring(0, 37) + "..." : lesson.name;
653
+ progressBar.update(processed, { status: shortName });
654
+
655
+ const syncStatus = await isLessonSynced(moduleDir, lesson.position, lesson.name);
656
+
657
+ // Check if content already exists
658
+ if (!options.skipContent && !syncStatus.content) {
659
+ try {
660
+ const content = await extractLessonContent(page, lesson.url);
661
+ const markdown = formatMarkdown(
662
+ content.title,
663
+ content.markdownContent,
664
+ lesson.videoUrl,
665
+ lesson.videoType
666
+ );
667
+ const mdPath = getMarkdownPath(moduleDir, lesson.position, lesson.name);
668
+ await saveMarkdown(dirname(mdPath), basename(mdPath), markdown);
669
+
670
+ // Download any linked files (PDFs, Office documents, etc.)
671
+ if (content.downloadableFiles.length > 0) {
672
+ for (const file of content.downloadableFiles) {
673
+ const filePath = getDownloadFilePath(
674
+ moduleDir,
675
+ lesson.position,
676
+ lesson.name,
677
+ file.filename
678
+ );
679
+ const result = await downloadFile(file.url, filePath);
680
+ if (result.success) {
681
+ filesDownloadedTotal++;
682
+ }
683
+ }
684
+ }
685
+ contentExtracted++;
686
+ } catch {
687
+ // Error extracting content, continue with next lesson
688
+ }
689
+ } else {
690
+ contentSkipped++;
691
+ }
692
+
693
+ // Queue video for download if not already downloaded
694
+ if (!options.skipVideos && !syncStatus.video && lesson.videoUrl && lesson.videoType) {
695
+ videoTasks.push({
696
+ lessonId: lesson.id,
697
+ lessonName: lesson.name,
698
+ videoUrl: lesson.hlsUrl ?? lesson.videoUrl,
699
+ videoType: lesson.videoType as VideoDownloadTask["videoType"],
700
+ outputPath: getVideoPath(moduleDir, lesson.position, lesson.name),
701
+ });
702
+ }
703
+
704
+ processed++;
705
+ progressBar.update(processed, { status: shortName });
706
+ }
707
+ }
708
+
709
+ progressBar.stop();
710
+
711
+ // Print summary
712
+ console.log();
713
+ const parts: string[] = [];
714
+ if (contentExtracted > 0) parts.push(chalk.green(`${contentExtracted} extracted`));
715
+ if (contentSkipped > 0) parts.push(chalk.gray(`${contentSkipped} cached`));
716
+ if (filesDownloadedTotal > 0) parts.push(chalk.blue(`${filesDownloadedTotal} files`));
717
+ console.log(` Content: ${parts.join(", ")}`);
718
+
719
+ return videoTasks;
720
+ }
721
+
722
+ /**
723
+ * Phase 4: Download videos with multi-progress display.
724
+ */
725
+ async function downloadVideos(
726
+ db: CourseDatabase,
727
+ videoTasks: VideoDownloadTask[],
728
+ courseDir: string,
729
+ config: { concurrency: number; retryAttempts: number },
730
+ _options?: SyncOptions
731
+ ): Promise<void> {
732
+ const total = videoTasks.length;
733
+ console.log(chalk.blue(`\nšŸŽ¬ Phase 4: Downloading ${total} videos...\n`));
734
+
735
+ // Create multi-bar container with auto-clear
736
+ const multibar = new cliProgress.MultiBar(
737
+ {
738
+ clearOnComplete: true,
739
+ hideCursor: true,
740
+ format: " {typeTag} {bar} {percentage}% | {lessonName}",
741
+ barCompleteChar: "ā–ˆ",
742
+ barIncompleteChar: "ā–‘",
743
+ barsize: 25,
744
+ autopadding: true,
745
+ },
746
+ cliProgress.Presets.shades_grey
747
+ );
748
+
749
+ // Overall progress bar at the top
750
+ const overallBar = multibar.create(total, 0, {
751
+ typeTag: "[TOTAL]".padEnd(8),
752
+ lessonName: `0/${total} completed`,
753
+ });
754
+
755
+ // Track results
756
+ const downloadAttempts: DownloadAttempt[] = [];
757
+ const errors: { id: string; error: string }[] = [];
758
+ let completed = 0;
759
+ let failed = 0;
760
+
761
+ // Active downloads map: lessonName -> bar
762
+ const activeBars = new Map<string, cliProgress.SingleBar>();
763
+
764
+ // Process downloads with controlled concurrency
765
+ const taskQueue = [...videoTasks];
766
+ const activePromises = new Set<Promise<void>>();
767
+
768
+ const processTask = async (task: VideoDownloadTask): Promise<void> => {
769
+ const typeTag = task.videoType ? `[${task.videoType.toUpperCase()}]` : "[VIDEO]";
770
+ const shortName =
771
+ task.lessonName.length > 40 ? task.lessonName.substring(0, 37) + "..." : task.lessonName;
772
+
773
+ // Create progress bar for this download
774
+ const bar = multibar.create(100, 0, {
775
+ typeTag: typeTag.padEnd(8),
776
+ lessonName: shortName,
777
+ });
778
+ activeBars.set(task.lessonName, bar);
779
+
780
+ try {
781
+ const downloadResult = await downloadVideo(task, (progress) => {
782
+ bar.update(Math.round(progress.percent));
783
+ });
784
+
785
+ // Record the attempt
786
+ const attempt: DownloadAttempt = {
787
+ lessonName: task.lessonName,
788
+ videoUrl: task.videoUrl,
789
+ videoType: task.videoType,
790
+ success: downloadResult.success,
791
+ timestamp: new Date().toISOString(),
792
+ };
793
+
794
+ if (!downloadResult.success) {
795
+ attempt.error = downloadResult.error;
796
+ attempt.errorCode = downloadResult.errorCode;
797
+ attempt.details = downloadResult.details;
798
+
799
+ db.markLessonError(
800
+ task.lessonId,
801
+ downloadResult.error ?? "Download failed",
802
+ downloadResult.errorCode
803
+ );
804
+
805
+ errors.push({
806
+ id: task.lessonName,
807
+ error: downloadResult.error ?? "Download failed",
808
+ });
809
+ failed++;
810
+ } else {
811
+ // Update database with success
812
+ const fileSize = await getFileSize(task.outputPath);
813
+ db.markLessonDownloaded(task.lessonId, fileSize ?? undefined);
814
+ completed++;
815
+ }
816
+
817
+ downloadAttempts.push(attempt);
818
+ } catch (error) {
819
+ failed++;
820
+ const errorMsg = error instanceof Error ? error.message : String(error);
821
+ errors.push({ id: task.lessonName, error: errorMsg });
822
+ db.markLessonError(task.lessonId, errorMsg);
823
+ } finally {
824
+ // Remove the bar when done (key fix!)
825
+ multibar.remove(bar);
826
+ activeBars.delete(task.lessonName);
827
+
828
+ // Update overall progress
829
+ const done = completed + failed;
830
+ overallBar.update(done, {
831
+ lessonName: `${done}/${total} completed (${failed} failed)`,
832
+ });
833
+ }
834
+ };
835
+
836
+ // Run downloads with controlled concurrency
837
+ while (taskQueue.length > 0 || activePromises.size > 0) {
838
+ // Start new downloads up to concurrency limit
839
+ while (taskQueue.length > 0 && activePromises.size < config.concurrency) {
840
+ const task = taskQueue.shift();
841
+ if (task) {
842
+ const promise = processTask(task).finally(() => {
843
+ activePromises.delete(promise);
844
+ });
845
+ activePromises.add(promise);
846
+ }
847
+ }
848
+
849
+ // Wait for at least one to complete
850
+ if (activePromises.size > 0) {
851
+ await Promise.race(activePromises);
852
+ }
853
+ }
854
+
855
+ // Stop multibar
856
+ multibar.stop();
857
+
858
+ // Print summary
859
+ console.log();
860
+ if (failed === 0) {
861
+ console.log(chalk.green(` āœ“ ${completed} videos downloaded successfully`));
862
+ } else {
863
+ console.log(chalk.yellow(` Videos: ${completed} downloaded, ${failed} failed`));
864
+ }
865
+
866
+ if (errors.length > 0) {
867
+ console.log(chalk.yellow("\n Failed downloads:"));
868
+ for (const error of errors) {
869
+ const task = videoTasks.find((t) => t.lessonName === error.id);
870
+ const typeTag = task?.videoType ? `[${task.videoType.toUpperCase()}]` : "";
871
+ console.log(chalk.red(` - ${typeTag} ${error.id}: ${error.error}`));
872
+ }
873
+
874
+ // Save diagnostic log
875
+ const failedAttempts = downloadAttempts.filter((a) => !a.success);
876
+ if (failedAttempts.length > 0) {
877
+ const logPath = join(courseDir, `download-errors-${Date.now()}.json`);
878
+ const logData = {
879
+ timestamp: new Date().toISOString(),
880
+ totalAttempts: videoTasks.length,
881
+ successful: completed,
882
+ failed,
883
+ concurrency: config.concurrency,
884
+ retryAttempts: config.retryAttempts,
885
+ failures: failedAttempts,
886
+ };
887
+ await outputFile(logPath, JSON.stringify(logData, null, 2));
888
+ console.log(chalk.gray(`\n šŸ“‹ Detailed error log saved: ${logPath}`));
889
+ }
890
+ }
891
+ }
892
+
893
+ /**
894
+ * Build download tasks from database (for --resume mode).
895
+ * Skips lessons that are already downloaded.
896
+ */
897
+ async function buildDownloadTasksFromDb(
898
+ db: CourseDatabase,
899
+ courseDir: string
900
+ ): Promise<VideoDownloadTask[]> {
901
+ const lessons = db.getLessonsToDownload();
902
+ const videoTasks: VideoDownloadTask[] = [];
903
+ let alreadyOnDisk = 0;
904
+
905
+ console.log(chalk.blue(`\nšŸ“¦ Building download list from ${lessons.length} ready lessons...\n`));
906
+
907
+ for (const lesson of lessons) {
908
+ // Create module directory (flat structure - no lesson subdirectories)
909
+ const moduleDir = await createModuleDirectory(
910
+ courseDir,
911
+ lesson.modulePosition,
912
+ lesson.moduleName
913
+ );
914
+
915
+ // Check if already downloaded
916
+ const syncStatus = await isLessonSynced(moduleDir, lesson.position, lesson.name);
917
+ if (syncStatus.video) {
918
+ // File exists on disk but DB not updated - fix DB state
919
+ db.markLessonDownloaded(lesson.id);
920
+ alreadyOnDisk++;
921
+ continue;
922
+ }
923
+
924
+ if (lesson.hlsUrl && lesson.videoType) {
925
+ videoTasks.push({
926
+ lessonId: lesson.id,
927
+ lessonName: lesson.name,
928
+ videoUrl: lesson.hlsUrl,
929
+ videoType: lesson.videoType as VideoDownloadTask["videoType"],
930
+ outputPath: getVideoPath(moduleDir, lesson.position, lesson.name),
931
+ });
932
+ }
933
+ }
934
+
935
+ if (alreadyOnDisk > 0) {
936
+ console.log(chalk.green(` āœ… ${alreadyOnDisk} already on disk (DB updated)`));
937
+ }
938
+ console.log(chalk.gray(` ā¬‡ļø ${videoTasks.length} videos to download`));
939
+ return videoTasks;
940
+ }
941
+
942
+ /**
943
+ * Retry failed lessons with detailed diagnostics.
944
+ */
945
+ async function retryFailedLessons(
946
+ page: import("playwright").Page,
947
+ db: CourseDatabase,
948
+ courseDir: string,
949
+ _config: { concurrency: number; retryAttempts: number },
950
+ _options: SyncOptions
951
+ ): Promise<void> {
952
+ const errorLessons = db.getLessonsByStatus(LessonStatus.ERROR);
953
+
954
+ if (errorLessons.length === 0) {
955
+ console.log(chalk.green("\nāœ… No failed lessons to retry!\n"));
956
+ printStatusSummary(db);
957
+ return;
958
+ }
959
+
960
+ console.log(chalk.yellow(`\nšŸ”„ Retry Failed Mode: ${errorLessons.length} lesson(s) to retry\n`));
961
+
962
+ // Group by error type for summary
963
+ const byErrorCode = new Map<string, typeof errorLessons>();
964
+ for (const lesson of errorLessons) {
965
+ const code = lesson.errorCode ?? "UNKNOWN";
966
+ const codeLessons = byErrorCode.get(code) ?? [];
967
+ codeLessons.push(lesson);
968
+ byErrorCode.set(code, codeLessons);
969
+ }
970
+
971
+ console.log(chalk.gray(" Error breakdown:"));
972
+ for (const [code, lessons] of byErrorCode) {
973
+ console.log(chalk.gray(` ${code}: ${lessons.length}`));
974
+ }
975
+ console.log();
976
+
977
+ // Results tracking
978
+ const results: {
979
+ lesson: LessonWithModule;
980
+ success: boolean;
981
+ newStatus: string;
982
+ details: string;
983
+ }[] = [];
984
+
985
+ // Progress bar
986
+ const progressBar = new cliProgress.SingleBar(
987
+ {
988
+ format: " {bar} {percentage}% | {value}/{total} | {status}",
989
+ barCompleteChar: "ā–ˆ",
990
+ barIncompleteChar: "ā–‘",
991
+ barsize: 30,
992
+ hideCursor: true,
993
+ },
994
+ cliProgress.Presets.shades_grey
995
+ );
996
+
997
+ progressBar.start(errorLessons.length, 0, { status: "Starting..." });
998
+
999
+ for (let i = 0; i < errorLessons.length; i++) {
1000
+ const lesson = errorLessons[i];
1001
+ if (!lesson) continue;
1002
+
1003
+ const shortName = lesson.name.length > 30 ? lesson.name.substring(0, 27) + "..." : lesson.name;
1004
+
1005
+ progressBar.update(i, { status: shortName });
1006
+
1007
+ try {
1008
+ // Navigate to the lesson page
1009
+ await page.goto(lesson.url, { waitUntil: "domcontentloaded", timeout: 30000 });
1010
+ await page.waitForTimeout(2000);
1011
+
1012
+ // Try to extract video URL
1013
+ const videoInfo = await extractVideoUrl(page);
1014
+
1015
+ if (!videoInfo.url) {
1016
+ // No video found - mark as skipped (no video) or keep error
1017
+ if (lesson.errorCode === "UNSUPPORTED_PROVIDER") {
1018
+ results.push({
1019
+ lesson,
1020
+ success: false,
1021
+ newStatus: "error",
1022
+ details: `Unsupported provider: ${lesson.videoType ?? "unknown"}`,
1023
+ });
1024
+ } else {
1025
+ db.markLessonSkipped(lesson.id, "No video found on retry");
1026
+ results.push({
1027
+ lesson,
1028
+ success: true,
1029
+ newStatus: "skipped",
1030
+ details: "No video on page",
1031
+ });
1032
+ }
1033
+ continue;
1034
+ }
1035
+
1036
+ // Check for unsupported providers
1037
+ if (videoInfo.type === "youtube" || videoInfo.type === "wistia") {
1038
+ db.markLessonError(
1039
+ lesson.id,
1040
+ `${videoInfo.type} videos are not yet supported`,
1041
+ "UNSUPPORTED_PROVIDER"
1042
+ );
1043
+ db.updateLessonVideoType(lesson.id, videoInfo.type);
1044
+ results.push({
1045
+ lesson,
1046
+ success: false,
1047
+ newStatus: "error",
1048
+ details: `Unsupported: ${videoInfo.type}`,
1049
+ });
1050
+ continue;
1051
+ }
1052
+
1053
+ // Validate and get HLS URL
1054
+ const validation = await validateVideoHls(
1055
+ videoInfo.url,
1056
+ videoInfo.type ?? "native",
1057
+ page,
1058
+ lesson.url
1059
+ );
1060
+
1061
+ if (!validation.isValid || !validation.hlsUrl) {
1062
+ db.markLessonError(
1063
+ lesson.id,
1064
+ validation.error ?? "Validation failed",
1065
+ validation.errorCode ?? "VALIDATION_FAILED"
1066
+ );
1067
+ results.push({
1068
+ lesson,
1069
+ success: false,
1070
+ newStatus: "error",
1071
+ details: validation.error ?? "Could not validate video",
1072
+ });
1073
+ continue;
1074
+ }
1075
+
1076
+ // Update lesson with HLS URL
1077
+ db.updateLessonScan(
1078
+ lesson.id,
1079
+ videoInfo.type ?? null,
1080
+ videoInfo.url,
1081
+ validation.hlsUrl,
1082
+ LessonStatus.VALIDATED
1083
+ );
1084
+
1085
+ // Try to download
1086
+ const moduleDir = await createModuleDirectory(
1087
+ courseDir,
1088
+ lesson.modulePosition,
1089
+ lesson.moduleName
1090
+ );
1091
+ const outputPath = getVideoPath(moduleDir, lesson.position, lesson.name);
1092
+
1093
+ const downloadResult = await downloadVideo({
1094
+ lessonId: lesson.id,
1095
+ lessonName: lesson.name,
1096
+ videoUrl: validation.hlsUrl,
1097
+ videoType: videoInfo.type as VideoDownloadTask["videoType"],
1098
+ outputPath,
1099
+ });
1100
+
1101
+ if (downloadResult.success) {
1102
+ const fileSize = await getFileSize(outputPath);
1103
+ db.markLessonDownloaded(lesson.id, fileSize ?? undefined);
1104
+ results.push({
1105
+ lesson,
1106
+ success: true,
1107
+ newStatus: "downloaded",
1108
+ details: fileSize ? `Downloaded ${(fileSize / 1024 / 1024).toFixed(1)} MB` : "Downloaded",
1109
+ });
1110
+ } else {
1111
+ db.markLessonError(
1112
+ lesson.id,
1113
+ downloadResult.error ?? "Download failed",
1114
+ downloadResult.errorCode ?? "DOWNLOAD_FAILED"
1115
+ );
1116
+ results.push({
1117
+ lesson,
1118
+ success: false,
1119
+ newStatus: "error",
1120
+ details: downloadResult.error ?? "Download failed",
1121
+ });
1122
+ }
1123
+ } catch (error) {
1124
+ const errorMsg = error instanceof Error ? error.message : String(error);
1125
+ db.markLessonError(lesson.id, errorMsg, "RETRY_ERROR");
1126
+ results.push({
1127
+ lesson,
1128
+ success: false,
1129
+ newStatus: "error",
1130
+ details: errorMsg.substring(0, 100),
1131
+ });
1132
+ }
1133
+ }
1134
+
1135
+ progressBar.update(errorLessons.length, { status: "Complete" });
1136
+ progressBar.stop();
1137
+
1138
+ // Detailed results
1139
+ console.log(chalk.cyan("\nšŸ“‹ Retry Results\n"));
1140
+
1141
+ const successful = results.filter((r) => r.success);
1142
+ const failed = results.filter((r) => !r.success);
1143
+
1144
+ if (successful.length > 0) {
1145
+ console.log(chalk.green(` āœ… Fixed: ${successful.length}`));
1146
+ for (const r of successful) {
1147
+ console.log(chalk.gray(` • ${r.lesson.name} → ${r.newStatus} (${r.details})`));
1148
+ }
1149
+ }
1150
+
1151
+ if (failed.length > 0) {
1152
+ console.log(chalk.red(`\n āŒ Still failing: ${failed.length}\n`));
1153
+ for (const r of failed) {
1154
+ const typeTag = r.lesson.videoType ? `[${r.lesson.videoType.toUpperCase()}]` : "";
1155
+ console.log(chalk.red(` ${typeTag} ${r.lesson.name}`));
1156
+ console.log(chalk.gray(` Module: ${r.lesson.moduleName}`));
1157
+ console.log(chalk.gray(` URL: ${r.lesson.url}`));
1158
+ console.log(chalk.gray(` Error: ${r.details}`));
1159
+ console.log();
1160
+ }
1161
+ }
1162
+
1163
+ printStatusSummary(db);
1164
+ }
1165
+
1166
+ /**
1167
+ * Print status summary from database.
1168
+ */
1169
+ function printStatusSummary(db: CourseDatabase): void {
1170
+ const meta = db.getCourseMetadata();
1171
+ const summary = db.getStatusSummary();
1172
+ const videoTypes = db.getVideoTypeSummary();
1173
+
1174
+ console.log(chalk.cyan("\nšŸ“Š Status Summary\n"));
1175
+ console.log(chalk.white(` Course: ${meta.name}`));
1176
+ console.log(chalk.gray(` Modules: ${meta.totalModules}`));
1177
+ console.log(chalk.gray(` Lessons: ${meta.totalLessons}`));
1178
+ console.log();
1179
+
1180
+ // Clear status labels
1181
+ console.log(chalk.green(` āœ… Downloaded: ${summary.downloaded}`));
1182
+ if (summary.validated > 0) {
1183
+ console.log(chalk.blue(` ā¬‡ļø Ready to download: ${summary.validated}`));
1184
+ }
1185
+ if (summary.pending > 0) {
1186
+ console.log(chalk.gray(` šŸ” Not scanned yet: ${summary.pending}`));
1187
+ }
1188
+ if (summary.skipped > 0) {
1189
+ console.log(chalk.gray(` āž– No video: ${summary.skipped}`));
1190
+ }
1191
+ if (summary.locked > 0) {
1192
+ console.log(chalk.yellow(` šŸ”’ Locked: ${summary.locked}`));
1193
+ }
1194
+
1195
+ if (summary.error > 0) {
1196
+ console.log(chalk.red(` āŒ Failed: ${summary.error}`));
1197
+
1198
+ // Show unsupported providers if any
1199
+ const unsupported = db.getLessonsByErrorCode("UNSUPPORTED_PROVIDER");
1200
+ if (unsupported.length > 0) {
1201
+ console.log(chalk.yellow(`\n ⚠ Unsupported video providers:`));
1202
+
1203
+ // Group by video type
1204
+ const byType = new Map<string, typeof unsupported>();
1205
+ for (const lesson of unsupported) {
1206
+ const type = lesson.videoType ?? "unknown";
1207
+ const typeLessons = byType.get(type) ?? [];
1208
+ typeLessons.push(lesson);
1209
+ byType.set(type, typeLessons);
1210
+ }
1211
+
1212
+ for (const [type, lessons] of byType) {
1213
+ console.log(chalk.yellow(` ${type.toUpperCase()}: ${lessons.length} video(s)`));
1214
+ for (const lesson of lessons.slice(0, 3)) {
1215
+ console.log(chalk.gray(` - ${lesson.moduleName} → ${lesson.name}`));
1216
+ }
1217
+ if (lessons.length > 3) {
1218
+ console.log(chalk.gray(` ... and ${lessons.length - 3} more`));
1219
+ }
1220
+ }
1221
+ console.log(chalk.gray(`\n šŸ’” Tip: Install yt-dlp to download YouTube/Wistia videos`));
1222
+ }
1223
+ }
1224
+
1225
+ // Show video type breakdown
1226
+ if (Object.keys(videoTypes).length > 0) {
1227
+ console.log(chalk.gray(`\n Video types found:`));
1228
+ for (const [type, count] of Object.entries(videoTypes)) {
1229
+ const supported = type === "loom" || type === "vimeo" || type === "native";
1230
+ const icon = supported ? "āœ“" : "āœ—";
1231
+ const color = supported ? chalk.green : chalk.yellow;
1232
+ console.log(color(` ${icon} ${type}: ${count}`));
1233
+ }
1234
+ }
1235
+ }