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,919 @@
1
+ import Database from "better-sqlite3";
2
+ import { existsSync, mkdirSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { CACHE_DIR } from "../config/paths.js";
5
+
6
+ /**
7
+ * Lesson sync status.
8
+ */
9
+ export const LessonStatus = {
10
+ PENDING: "pending",
11
+ SCANNED: "scanned",
12
+ VALIDATED: "validated",
13
+ DOWNLOADED: "downloaded",
14
+ ERROR: "error",
15
+ SKIPPED: "skipped",
16
+ } as const;
17
+
18
+ export type LessonStatusType = (typeof LessonStatus)[keyof typeof LessonStatus];
19
+
20
+ /**
21
+ * Video types supported by the tool.
22
+ */
23
+ export const VideoType = {
24
+ LOOM: "loom",
25
+ VIMEO: "vimeo",
26
+ YOUTUBE: "youtube",
27
+ WISTIA: "wistia",
28
+ NATIVE: "native",
29
+ UNKNOWN: "unknown",
30
+ } as const;
31
+
32
+ export type VideoTypeValue = (typeof VideoType)[keyof typeof VideoType];
33
+
34
+ /**
35
+ * Module record from database.
36
+ */
37
+ export interface ModuleRecord {
38
+ id: number;
39
+ slug: string;
40
+ name: string;
41
+ position: number;
42
+ isLocked: boolean;
43
+ createdAt: string;
44
+ updatedAt: string;
45
+ }
46
+
47
+ /**
48
+ * Lesson record from database.
49
+ */
50
+ export interface LessonRecord {
51
+ id: number;
52
+ moduleId: number;
53
+ slug: string;
54
+ name: string;
55
+ url: string;
56
+ position: number;
57
+ isLocked: boolean;
58
+ status: LessonStatusType;
59
+ videoType: VideoTypeValue | null;
60
+ videoUrl: string | null;
61
+ hlsUrl: string | null;
62
+ errorMessage: string | null;
63
+ errorCode: string | null;
64
+ retryCount: number;
65
+ lastScannedAt: string | null;
66
+ lastDownloadedAt: string | null;
67
+ videoFileSize: number | null;
68
+ createdAt: string;
69
+ updatedAt: string;
70
+ }
71
+
72
+ /**
73
+ * Lesson with module info for display.
74
+ */
75
+ export interface LessonWithModule extends LessonRecord {
76
+ moduleName: string;
77
+ moduleSlug: string;
78
+ modulePosition: number;
79
+ }
80
+
81
+ /**
82
+ * Course metadata stored in the database.
83
+ */
84
+ export interface CourseMetadata {
85
+ name: string;
86
+ url: string;
87
+ lastSyncAt: string | null;
88
+ totalModules: number;
89
+ totalLessons: number;
90
+ }
91
+
92
+ /**
93
+ * Get the database directory path.
94
+ */
95
+ export function getDbDir(): string {
96
+ return CACHE_DIR;
97
+ }
98
+
99
+ /**
100
+ * Get the database file path for a course.
101
+ */
102
+ export function getDbPath(communitySlug: string): string {
103
+ const safeSlug = communitySlug.replace(/[^a-zA-Z0-9-]/g, "_");
104
+ return join(getDbDir(), `${safeSlug}.db`);
105
+ }
106
+
107
+ /**
108
+ * Extract community slug from a Skool URL.
109
+ */
110
+ export function extractCommunitySlug(url: string): string {
111
+ const match = /skool\.com\/([^/]+)/.exec(url);
112
+ return match?.[1] ?? "unknown";
113
+ }
114
+
115
+ /**
116
+ * Database manager for course state persistence.
117
+ * SQLite operations - not unit testable without mocking.
118
+ */
119
+ /* v8 ignore start */
120
+ export class CourseDatabase {
121
+ private db: Database.Database;
122
+
123
+ constructor(communitySlug: string) {
124
+ const dbPath = getDbPath(communitySlug);
125
+
126
+ // Ensure directory exists
127
+ const dir = dirname(dbPath);
128
+ if (!existsSync(dir)) {
129
+ mkdirSync(dir, { recursive: true });
130
+ }
131
+
132
+ this.db = new Database(dbPath);
133
+ this.db.pragma("journal_mode = WAL");
134
+ this.initSchema();
135
+ }
136
+
137
+ /**
138
+ * Initialize database schema.
139
+ */
140
+ private initSchema(): void {
141
+ this.db.exec(`
142
+ CREATE TABLE IF NOT EXISTS metadata (
143
+ key TEXT PRIMARY KEY,
144
+ value TEXT NOT NULL
145
+ );
146
+
147
+ CREATE TABLE IF NOT EXISTS modules (
148
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
149
+ slug TEXT UNIQUE NOT NULL,
150
+ name TEXT NOT NULL,
151
+ position INTEGER NOT NULL,
152
+ is_locked INTEGER DEFAULT 0,
153
+ created_at TEXT DEFAULT (datetime('now')),
154
+ updated_at TEXT DEFAULT (datetime('now'))
155
+ );
156
+
157
+ CREATE TABLE IF NOT EXISTS lessons (
158
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
159
+ module_id INTEGER NOT NULL,
160
+ slug TEXT NOT NULL,
161
+ name TEXT NOT NULL,
162
+ url TEXT NOT NULL,
163
+ position INTEGER NOT NULL,
164
+ is_locked INTEGER DEFAULT 0,
165
+ status TEXT DEFAULT 'pending',
166
+ video_type TEXT,
167
+ video_url TEXT,
168
+ hls_url TEXT,
169
+ error_message TEXT,
170
+ error_code TEXT,
171
+ last_scanned_at TEXT,
172
+ last_downloaded_at TEXT,
173
+ video_file_size INTEGER,
174
+ created_at TEXT DEFAULT (datetime('now')),
175
+ updated_at TEXT DEFAULT (datetime('now')),
176
+ FOREIGN KEY (module_id) REFERENCES modules(id),
177
+ UNIQUE(module_id, slug)
178
+ );
179
+
180
+ CREATE INDEX IF NOT EXISTS idx_lessons_status ON lessons(status);
181
+ CREATE INDEX IF NOT EXISTS idx_lessons_module ON lessons(module_id);
182
+ CREATE INDEX IF NOT EXISTS idx_lessons_locked ON lessons(is_locked);
183
+ `);
184
+
185
+ // Run migrations for existing databases
186
+ this.runMigrations();
187
+ }
188
+
189
+ /**
190
+ * Run database migrations for schema updates.
191
+ */
192
+ private runMigrations(): void {
193
+ const tableInfo = this.db.prepare("PRAGMA table_info(lessons)").all() as {
194
+ name: string;
195
+ }[];
196
+
197
+ // Migration: Add is_locked column if it doesn't exist
198
+ const hasIsLocked = tableInfo.some((col) => col.name === "is_locked");
199
+ if (!hasIsLocked) {
200
+ this.db.exec("ALTER TABLE lessons ADD COLUMN is_locked INTEGER DEFAULT 0");
201
+ }
202
+
203
+ // Migration: Add retry_count column if it doesn't exist
204
+ const hasRetryCount = tableInfo.some((col) => col.name === "retry_count");
205
+ if (!hasRetryCount) {
206
+ this.db.exec("ALTER TABLE lessons ADD COLUMN retry_count INTEGER DEFAULT 0");
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Close the database connection.
212
+ */
213
+ close(): void {
214
+ this.db.close();
215
+ }
216
+
217
+ // ============================================
218
+ // Metadata Operations
219
+ // ============================================
220
+
221
+ /**
222
+ * Set a metadata value.
223
+ */
224
+ setMetadata(key: string, value: string): void {
225
+ const stmt = this.db.prepare(`
226
+ INSERT INTO metadata (key, value) VALUES (?, ?)
227
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value
228
+ `);
229
+ stmt.run(key, value);
230
+ }
231
+
232
+ /**
233
+ * Get a metadata value.
234
+ */
235
+ getMetadata(key: string): string | null {
236
+ const stmt = this.db.prepare("SELECT value FROM metadata WHERE key = ?");
237
+ const row = stmt.get(key) as { value: string } | undefined;
238
+ return row?.value ?? null;
239
+ }
240
+
241
+ /**
242
+ * Get all course metadata.
243
+ */
244
+ getCourseMetadata(): CourseMetadata {
245
+ return {
246
+ name: this.getMetadata("course_name") ?? "Unknown Course",
247
+ url: this.getMetadata("course_url") ?? "",
248
+ lastSyncAt: this.getMetadata("last_sync_at"),
249
+ totalModules: this.getModuleCount(),
250
+ totalLessons: this.getLessonCount(),
251
+ };
252
+ }
253
+
254
+ /**
255
+ * Update course metadata after sync.
256
+ */
257
+ updateCourseMetadata(name: string, url: string): void {
258
+ this.setMetadata("course_name", name);
259
+ this.setMetadata("course_url", url);
260
+ this.setMetadata("last_sync_at", new Date().toISOString());
261
+ }
262
+
263
+ // ============================================
264
+ // Module Operations
265
+ // ============================================
266
+
267
+ /**
268
+ * Upsert a module (insert or update).
269
+ */
270
+ upsertModule(slug: string, name: string, position: number, isLocked = false): ModuleRecord {
271
+ const stmt = this.db.prepare(`
272
+ INSERT INTO modules (slug, name, position, is_locked, updated_at)
273
+ VALUES (?, ?, ?, ?, datetime('now'))
274
+ ON CONFLICT(slug) DO UPDATE SET
275
+ name = excluded.name,
276
+ position = excluded.position,
277
+ is_locked = excluded.is_locked,
278
+ updated_at = datetime('now')
279
+ RETURNING *
280
+ `);
281
+ const row = stmt.get(slug, name, position, isLocked ? 1 : 0) as {
282
+ id: number;
283
+ slug: string;
284
+ name: string;
285
+ position: number;
286
+ is_locked: number;
287
+ created_at: string;
288
+ updated_at: string;
289
+ };
290
+
291
+ return this.mapModuleRow(row);
292
+ }
293
+
294
+ /**
295
+ * Get all modules.
296
+ */
297
+ getModules(): ModuleRecord[] {
298
+ const stmt = this.db.prepare("SELECT * FROM modules ORDER BY position");
299
+ const rows = stmt.all() as {
300
+ id: number;
301
+ slug: string;
302
+ name: string;
303
+ position: number;
304
+ is_locked: number;
305
+ created_at: string;
306
+ updated_at: string;
307
+ }[];
308
+ return rows.map((row) => this.mapModuleRow(row));
309
+ }
310
+
311
+ /**
312
+ * Get module count.
313
+ */
314
+ getModuleCount(): number {
315
+ const stmt = this.db.prepare("SELECT COUNT(*) as count FROM modules");
316
+ const row = stmt.get() as { count: number };
317
+ return row.count;
318
+ }
319
+
320
+ /**
321
+ * Get module by slug.
322
+ */
323
+ getModuleBySlug(slug: string): ModuleRecord | null {
324
+ const stmt = this.db.prepare("SELECT * FROM modules WHERE slug = ?");
325
+ const row = stmt.get(slug) as
326
+ | {
327
+ id: number;
328
+ slug: string;
329
+ name: string;
330
+ position: number;
331
+ is_locked: number;
332
+ created_at: string;
333
+ updated_at: string;
334
+ }
335
+ | undefined;
336
+ return row ? this.mapModuleRow(row) : null;
337
+ }
338
+
339
+ private mapModuleRow(row: {
340
+ id: number;
341
+ slug: string;
342
+ name: string;
343
+ position: number;
344
+ is_locked: number;
345
+ created_at: string;
346
+ updated_at: string;
347
+ }): ModuleRecord {
348
+ return {
349
+ id: row.id,
350
+ slug: row.slug,
351
+ name: row.name,
352
+ position: row.position,
353
+ isLocked: row.is_locked === 1,
354
+ createdAt: row.created_at,
355
+ updatedAt: row.updated_at,
356
+ };
357
+ }
358
+
359
+ // ============================================
360
+ // Lesson Operations
361
+ // ============================================
362
+
363
+ /**
364
+ * Upsert a lesson (insert or update).
365
+ */
366
+ upsertLesson(
367
+ moduleId: number,
368
+ slug: string,
369
+ name: string,
370
+ url: string,
371
+ position: number,
372
+ isLocked = false
373
+ ): LessonRecord {
374
+ const stmt = this.db.prepare(`
375
+ INSERT INTO lessons (module_id, slug, name, url, position, is_locked, updated_at)
376
+ VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
377
+ ON CONFLICT(module_id, slug) DO UPDATE SET
378
+ name = excluded.name,
379
+ url = excluded.url,
380
+ position = excluded.position,
381
+ is_locked = excluded.is_locked,
382
+ updated_at = datetime('now')
383
+ RETURNING *
384
+ `);
385
+ const row = stmt.get(moduleId, slug, name, url, position, isLocked ? 1 : 0) as RawLessonRow;
386
+ return this.mapLessonRow(row);
387
+ }
388
+
389
+ /**
390
+ * Update lesson scan results.
391
+ */
392
+ updateLessonScan(
393
+ lessonId: number,
394
+ videoType: VideoTypeValue | null,
395
+ videoUrl: string | null,
396
+ hlsUrl: string | null,
397
+ status: LessonStatusType,
398
+ errorMessage?: string,
399
+ errorCode?: string
400
+ ): void {
401
+ const stmt = this.db.prepare(`
402
+ UPDATE lessons SET
403
+ video_type = ?,
404
+ video_url = ?,
405
+ hls_url = ?,
406
+ status = ?,
407
+ error_message = ?,
408
+ error_code = ?,
409
+ last_scanned_at = datetime('now'),
410
+ updated_at = datetime('now')
411
+ WHERE id = ?
412
+ `);
413
+ stmt.run(
414
+ videoType,
415
+ videoUrl,
416
+ hlsUrl,
417
+ status,
418
+ errorMessage ?? null,
419
+ errorCode ?? null,
420
+ lessonId
421
+ );
422
+ }
423
+
424
+ /**
425
+ * Mark lesson as downloaded.
426
+ */
427
+ markLessonDownloaded(lessonId: number, fileSize?: number): void {
428
+ const stmt = this.db.prepare(`
429
+ UPDATE lessons SET
430
+ status = 'downloaded',
431
+ last_downloaded_at = datetime('now'),
432
+ video_file_size = ?,
433
+ error_message = NULL,
434
+ error_code = NULL,
435
+ updated_at = datetime('now')
436
+ WHERE id = ?
437
+ `);
438
+ stmt.run(fileSize ?? null, lessonId);
439
+ }
440
+
441
+ /**
442
+ * Mark lesson as error.
443
+ */
444
+ markLessonError(lessonId: number, errorMessage: string, errorCode?: string): void {
445
+ const stmt = this.db.prepare(`
446
+ UPDATE lessons SET
447
+ status = 'error',
448
+ error_message = ?,
449
+ error_code = ?,
450
+ updated_at = datetime('now')
451
+ WHERE id = ?
452
+ `);
453
+ stmt.run(errorMessage, errorCode ?? null, lessonId);
454
+ }
455
+
456
+ /**
457
+ * Mark lesson as skipped (no video).
458
+ */
459
+ markLessonSkipped(lessonId: number, reason?: string): void {
460
+ const stmt = this.db.prepare(`
461
+ UPDATE lessons SET
462
+ status = 'skipped',
463
+ error_message = ?,
464
+ error_code = NULL,
465
+ updated_at = datetime('now')
466
+ WHERE id = ?
467
+ `);
468
+ stmt.run(reason ?? null, lessonId);
469
+ }
470
+
471
+ /**
472
+ * Update lesson video type.
473
+ */
474
+ updateLessonVideoType(lessonId: number, videoType: string): void {
475
+ const stmt = this.db.prepare(`
476
+ UPDATE lessons SET
477
+ video_type = ?,
478
+ updated_at = datetime('now')
479
+ WHERE id = ?
480
+ `);
481
+ stmt.run(videoType, lessonId);
482
+ }
483
+
484
+ /**
485
+ * Increment retry count for a lesson.
486
+ */
487
+ incrementRetryCount(lessonId: number): number {
488
+ const stmt = this.db.prepare(`
489
+ UPDATE lessons SET
490
+ retry_count = retry_count + 1,
491
+ updated_at = datetime('now')
492
+ WHERE id = ?
493
+ `);
494
+ stmt.run(lessonId);
495
+
496
+ // Return the new retry count
497
+ const getStmt = this.db.prepare("SELECT retry_count FROM lessons WHERE id = ?");
498
+ const row = getStmt.get(lessonId) as { retry_count: number } | undefined;
499
+ return row?.retry_count ?? 0;
500
+ }
501
+
502
+ /**
503
+ * Reset retry count for a lesson.
504
+ */
505
+ resetRetryCount(lessonId: number): void {
506
+ const stmt = this.db.prepare(`
507
+ UPDATE lessons SET
508
+ retry_count = 0,
509
+ updated_at = datetime('now')
510
+ WHERE id = ?
511
+ `);
512
+ stmt.run(lessonId);
513
+ }
514
+
515
+ /**
516
+ * Get lessons that failed but can still be retried (retry_count < maxRetries).
517
+ * Only returns retryable errors (not UNSUPPORTED_PROVIDER).
518
+ */
519
+ getLessonsToRetry(maxRetries = 3): LessonWithModule[] {
520
+ const stmt = this.db.prepare(`
521
+ SELECT
522
+ l.*,
523
+ m.name as module_name,
524
+ m.slug as module_slug,
525
+ m.position as module_position
526
+ FROM lessons l
527
+ JOIN modules m ON l.module_id = m.id
528
+ WHERE l.status = 'error'
529
+ AND l.retry_count < ?
530
+ AND (l.error_code IS NULL OR l.error_code NOT IN ('UNSUPPORTED_PROVIDER'))
531
+ ORDER BY m.position, l.position
532
+ `);
533
+ const rows = stmt.all(maxRetries) as (RawLessonRow & {
534
+ module_name: string;
535
+ module_slug: string;
536
+ module_position: number;
537
+ })[];
538
+
539
+ return rows.map((row) => ({
540
+ ...this.mapLessonRow(row),
541
+ moduleName: row.module_name,
542
+ moduleSlug: row.module_slug,
543
+ modulePosition: row.module_position,
544
+ }));
545
+ }
546
+
547
+ /**
548
+ * Mark a lesson for retry by setting it back to pending/validated status.
549
+ */
550
+ queueForRetry(lessonId: number, targetStatus: LessonStatusType = LessonStatus.PENDING): void {
551
+ const stmt = this.db.prepare(`
552
+ UPDATE lessons SET
553
+ status = ?,
554
+ error_message = NULL,
555
+ error_code = NULL,
556
+ updated_at = datetime('now')
557
+ WHERE id = ?
558
+ `);
559
+ stmt.run(targetStatus, lessonId);
560
+ }
561
+
562
+ /**
563
+ * Get all lessons.
564
+ */
565
+ getLessons(): LessonRecord[] {
566
+ const stmt = this.db.prepare("SELECT * FROM lessons ORDER BY module_id, position");
567
+ const rows = stmt.all() as RawLessonRow[];
568
+ return rows.map((row) => this.mapLessonRow(row));
569
+ }
570
+
571
+ /**
572
+ * Get lessons with module info.
573
+ */
574
+ getLessonsWithModules(): LessonWithModule[] {
575
+ const stmt = this.db.prepare(`
576
+ SELECT
577
+ l.*,
578
+ m.name as module_name,
579
+ m.slug as module_slug,
580
+ m.position as module_position
581
+ FROM lessons l
582
+ JOIN modules m ON l.module_id = m.id
583
+ ORDER BY m.position, l.position
584
+ `);
585
+ const rows = stmt.all() as (RawLessonRow & {
586
+ module_name: string;
587
+ module_slug: string;
588
+ module_position: number;
589
+ })[];
590
+
591
+ return rows.map((row) => ({
592
+ ...this.mapLessonRow(row),
593
+ moduleName: row.module_name,
594
+ moduleSlug: row.module_slug,
595
+ modulePosition: row.module_position,
596
+ }));
597
+ }
598
+
599
+ /**
600
+ * Get lessons by status.
601
+ */
602
+ getLessonsByStatus(status: LessonStatusType): LessonWithModule[] {
603
+ const stmt = this.db.prepare(`
604
+ SELECT
605
+ l.*,
606
+ m.name as module_name,
607
+ m.slug as module_slug,
608
+ m.position as module_position
609
+ FROM lessons l
610
+ JOIN modules m ON l.module_id = m.id
611
+ WHERE l.status = ?
612
+ ORDER BY m.position, l.position
613
+ `);
614
+ const rows = stmt.all(status) as (RawLessonRow & {
615
+ module_name: string;
616
+ module_slug: string;
617
+ module_position: number;
618
+ })[];
619
+
620
+ return rows.map((row) => ({
621
+ ...this.mapLessonRow(row),
622
+ moduleName: row.module_name,
623
+ moduleSlug: row.module_slug,
624
+ modulePosition: row.module_position,
625
+ }));
626
+ }
627
+
628
+ /**
629
+ * Get lessons that need scanning (pending or never scanned).
630
+ */
631
+ getLessonsToScan(): LessonWithModule[] {
632
+ const stmt = this.db.prepare(`
633
+ SELECT
634
+ l.*,
635
+ m.name as module_name,
636
+ m.slug as module_slug,
637
+ m.position as module_position
638
+ FROM lessons l
639
+ JOIN modules m ON l.module_id = m.id
640
+ WHERE (l.status = 'pending' OR l.last_scanned_at IS NULL)
641
+ AND l.is_locked = 0
642
+ ORDER BY m.position, l.position
643
+ `);
644
+ const rows = stmt.all() as (RawLessonRow & {
645
+ module_name: string;
646
+ module_slug: string;
647
+ module_position: number;
648
+ })[];
649
+
650
+ return rows.map((row) => ({
651
+ ...this.mapLessonRow(row),
652
+ moduleName: row.module_name,
653
+ moduleSlug: row.module_slug,
654
+ modulePosition: row.module_position,
655
+ }));
656
+ }
657
+
658
+ /**
659
+ * Get lessons that need validation (scanned but not validated, with video).
660
+ */
661
+ getLessonsToValidate(): LessonWithModule[] {
662
+ const stmt = this.db.prepare(`
663
+ SELECT
664
+ l.*,
665
+ m.name as module_name,
666
+ m.slug as module_slug,
667
+ m.position as module_position
668
+ FROM lessons l
669
+ JOIN modules m ON l.module_id = m.id
670
+ WHERE l.status = 'scanned'
671
+ AND l.video_url IS NOT NULL
672
+ AND l.is_locked = 0
673
+ ORDER BY m.position, l.position
674
+ `);
675
+ const rows = stmt.all() as (RawLessonRow & {
676
+ module_name: string;
677
+ module_slug: string;
678
+ module_position: number;
679
+ })[];
680
+
681
+ return rows.map((row) => ({
682
+ ...this.mapLessonRow(row),
683
+ moduleName: row.module_name,
684
+ moduleSlug: row.module_slug,
685
+ modulePosition: row.module_position,
686
+ }));
687
+ }
688
+
689
+ /**
690
+ * Get lessons that are ready for download (validated with HLS URL).
691
+ */
692
+ getLessonsToDownload(): LessonWithModule[] {
693
+ const stmt = this.db.prepare(`
694
+ SELECT
695
+ l.*,
696
+ m.name as module_name,
697
+ m.slug as module_slug,
698
+ m.position as module_position
699
+ FROM lessons l
700
+ JOIN modules m ON l.module_id = m.id
701
+ WHERE l.status = 'validated' AND l.hls_url IS NOT NULL
702
+ ORDER BY m.position, l.position
703
+ `);
704
+ const rows = stmt.all() as (RawLessonRow & {
705
+ module_name: string;
706
+ module_slug: string;
707
+ module_position: number;
708
+ })[];
709
+
710
+ return rows.map((row) => ({
711
+ ...this.mapLessonRow(row),
712
+ moduleName: row.module_name,
713
+ moduleSlug: row.module_slug,
714
+ modulePosition: row.module_position,
715
+ }));
716
+ }
717
+
718
+ /**
719
+ * Get lesson count.
720
+ */
721
+ getLessonCount(): number {
722
+ const stmt = this.db.prepare("SELECT COUNT(*) as count FROM lessons");
723
+ const row = stmt.get() as { count: number };
724
+ return row.count;
725
+ }
726
+
727
+ /**
728
+ * Get lesson by URL.
729
+ */
730
+ getLessonByUrl(url: string): LessonRecord | null {
731
+ const stmt = this.db.prepare("SELECT * FROM lessons WHERE url = ?");
732
+ const row = stmt.get(url) as RawLessonRow | undefined;
733
+ return row ? this.mapLessonRow(row) : null;
734
+ }
735
+
736
+ /**
737
+ * Get status summary.
738
+ */
739
+ getStatusSummary(): Record<LessonStatusType, number> & { locked: number } {
740
+ const stmt = this.db.prepare(`
741
+ SELECT status, COUNT(*) as count FROM lessons GROUP BY status
742
+ `);
743
+ const rows = stmt.all() as { status: LessonStatusType; count: number }[];
744
+
745
+ const summary: Record<LessonStatusType, number> & { locked: number } = {
746
+ pending: 0,
747
+ scanned: 0,
748
+ validated: 0,
749
+ downloaded: 0,
750
+ error: 0,
751
+ skipped: 0,
752
+ locked: 0,
753
+ };
754
+
755
+ for (const row of rows) {
756
+ summary[row.status] = row.count;
757
+ }
758
+
759
+ // Count locked lessons separately
760
+ const lockedStmt = this.db.prepare(`SELECT COUNT(*) as count FROM lessons WHERE is_locked = 1`);
761
+ const lockedRow = lockedStmt.get() as { count: number };
762
+ summary.locked = lockedRow.count;
763
+
764
+ return summary;
765
+ }
766
+
767
+ /**
768
+ * Reset all error lessons to pending for retry.
769
+ */
770
+ resetErrorLessons(): number {
771
+ const stmt = this.db.prepare(`
772
+ UPDATE lessons SET
773
+ status = 'pending',
774
+ error_message = NULL,
775
+ error_code = NULL,
776
+ updated_at = datetime('now')
777
+ WHERE status = 'error'
778
+ `);
779
+ const result = stmt.run();
780
+ return result.changes;
781
+ }
782
+
783
+ /**
784
+ * Reset ALL lessons to pending (for --force full rescan).
785
+ * Preserves locked status.
786
+ */
787
+ resetAllLessonsToPending(): number {
788
+ const stmt = this.db.prepare(`
789
+ UPDATE lessons SET
790
+ status = 'pending',
791
+ video_type = NULL,
792
+ video_url = NULL,
793
+ hls_url = NULL,
794
+ error_message = NULL,
795
+ error_code = NULL,
796
+ retry_count = 0,
797
+ updated_at = datetime('now')
798
+ WHERE is_locked = 0
799
+ `);
800
+ const result = stmt.run();
801
+ return result.changes;
802
+ }
803
+
804
+ /**
805
+ * Reset error lessons to validated (for --resume --retry-errors).
806
+ * Only resets lessons that already have an HLS URL.
807
+ */
808
+ resetErrorLessonsForResume(): number {
809
+ const stmt = this.db.prepare(`
810
+ UPDATE lessons SET
811
+ status = 'validated',
812
+ error_message = NULL,
813
+ error_code = NULL,
814
+ updated_at = datetime('now')
815
+ WHERE status = 'error' AND hls_url IS NOT NULL
816
+ `);
817
+ const result = stmt.run();
818
+ return result.changes;
819
+ }
820
+
821
+ /**
822
+ * Get lessons by error code.
823
+ */
824
+ getLessonsByErrorCode(errorCode: string): LessonWithModule[] {
825
+ const stmt = this.db.prepare(`
826
+ SELECT
827
+ l.*,
828
+ m.name as module_name,
829
+ m.slug as module_slug,
830
+ m.position as module_position
831
+ FROM lessons l
832
+ JOIN modules m ON l.module_id = m.id
833
+ WHERE l.error_code = ?
834
+ ORDER BY m.position, l.position
835
+ `);
836
+ const rows = stmt.all(errorCode) as (RawLessonRow & {
837
+ module_name: string;
838
+ module_slug: string;
839
+ module_position: number;
840
+ })[];
841
+
842
+ return rows.map((row) => ({
843
+ ...this.mapLessonRow(row),
844
+ moduleName: row.module_name,
845
+ moduleSlug: row.module_slug,
846
+ modulePosition: row.module_position,
847
+ }));
848
+ }
849
+
850
+ /**
851
+ * Get count of lessons grouped by video type.
852
+ */
853
+ getVideoTypeSummary(): Record<string, number> {
854
+ const stmt = this.db.prepare(`
855
+ SELECT video_type, COUNT(*) as count
856
+ FROM lessons
857
+ WHERE video_type IS NOT NULL
858
+ GROUP BY video_type
859
+ `);
860
+ const rows = stmt.all() as { video_type: string; count: number }[];
861
+
862
+ const summary: Record<string, number> = {};
863
+ for (const row of rows) {
864
+ summary[row.video_type] = row.count;
865
+ }
866
+
867
+ return summary;
868
+ }
869
+
870
+ private mapLessonRow(row: RawLessonRow): LessonRecord {
871
+ return {
872
+ id: row.id,
873
+ moduleId: row.module_id,
874
+ slug: row.slug,
875
+ name: row.name,
876
+ url: row.url,
877
+ position: row.position,
878
+ isLocked: row.is_locked === 1,
879
+ status: row.status as LessonStatusType,
880
+ videoType: row.video_type as VideoTypeValue | null,
881
+ videoUrl: row.video_url,
882
+ hlsUrl: row.hls_url,
883
+ errorMessage: row.error_message,
884
+ errorCode: row.error_code,
885
+ retryCount: row.retry_count ?? 0,
886
+ lastScannedAt: row.last_scanned_at,
887
+ lastDownloadedAt: row.last_downloaded_at,
888
+ videoFileSize: row.video_file_size,
889
+ createdAt: row.created_at,
890
+ updatedAt: row.updated_at,
891
+ };
892
+ }
893
+ }
894
+
895
+ /**
896
+ * Raw lesson row from SQLite.
897
+ */
898
+ interface RawLessonRow {
899
+ id: number;
900
+ module_id: number;
901
+ slug: string;
902
+ name: string;
903
+ url: string;
904
+ position: number;
905
+ is_locked: number;
906
+ status: string;
907
+ video_type: string | null;
908
+ video_url: string | null;
909
+ hls_url: string | null;
910
+ error_message: string | null;
911
+ error_code: string | null;
912
+ retry_count: number;
913
+ last_scanned_at: string | null;
914
+ last_downloaded_at: string | null;
915
+ video_file_size: number | null;
916
+ created_at: string;
917
+ updated_at: string;
918
+ }
919
+ /* v8 ignore stop */