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,742 @@
1
+ import { createWriteStream, existsSync, mkdirSync, renameSync, unlinkSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { Readable } from "node:stream";
4
+ import { finished } from "node:stream/promises";
5
+ import delay from "delay";
6
+ import { execa } from "execa";
7
+ import pRetry, { AbortError } from "p-retry";
8
+ import { USER_AGENT } from "../shared/http.js";
9
+ import { extractQueryParams, getBaseUrl } from "../shared/url.js";
10
+
11
+ export interface LoomVideoInfo {
12
+ id: string;
13
+ title: string;
14
+ duration: number;
15
+ width: number;
16
+ height: number;
17
+ hlsUrl: string;
18
+ }
19
+
20
+ export interface LoomFetchResult {
21
+ success: boolean;
22
+ info?: LoomVideoInfo;
23
+ error?: string;
24
+ errorCode?:
25
+ | "EMBED_FETCH_FAILED"
26
+ | "HLS_NOT_FOUND"
27
+ | "RATE_LIMITED"
28
+ | "NETWORK_ERROR"
29
+ | "PARSE_ERROR";
30
+ statusCode?: number;
31
+ details?: string;
32
+ }
33
+
34
+ export interface DownloadProgress {
35
+ percent: number;
36
+ downloaded?: number | undefined;
37
+ total?: number | undefined;
38
+ phase?: "preparing" | "downloading" | "complete" | undefined;
39
+ currentBytes?: number | undefined;
40
+ totalBytes?: number | undefined;
41
+ }
42
+
43
+ /**
44
+ * Extracts the Loom video ID from various URL formats.
45
+ */
46
+ export function extractLoomId(url: string): string | null {
47
+ const match = /loom\.com\/(?:embed|share)\/([a-f0-9]+)/.exec(url);
48
+ return match?.[1] ?? null;
49
+ }
50
+
51
+ // Network I/O and file operations - excluded from coverage
52
+ /* v8 ignore start */
53
+
54
+ /**
55
+ * Error class for Loom fetch failures with structured error info.
56
+ */
57
+ class LoomFetchError extends Error {
58
+ public readonly errorCode: NonNullable<LoomFetchResult["errorCode"]>;
59
+ public readonly statusCode: number | undefined;
60
+ public readonly details: string | undefined;
61
+
62
+ constructor(
63
+ message: string,
64
+ errorCode: NonNullable<LoomFetchResult["errorCode"]>,
65
+ statusCode?: number,
66
+ details?: string
67
+ ) {
68
+ super(message);
69
+ this.name = "LoomFetchError";
70
+ this.errorCode = errorCode;
71
+ this.statusCode = statusCode;
72
+ this.details = details;
73
+ }
74
+
75
+ toResult(): LoomFetchResult {
76
+ const result: LoomFetchResult = {
77
+ success: false,
78
+ error: this.message,
79
+ errorCode: this.errorCode,
80
+ };
81
+ if (this.statusCode !== undefined) {
82
+ result.statusCode = this.statusCode;
83
+ }
84
+ if (this.details !== undefined) {
85
+ result.details = this.details;
86
+ }
87
+ return result;
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Internal function to fetch Loom video info (throws on failure).
93
+ */
94
+ async function fetchLoomVideoInfo(videoId: string): Promise<LoomVideoInfo> {
95
+ const embedUrl = `https://www.loom.com/embed/${videoId}`;
96
+
97
+ const embedResponse = await fetch(embedUrl, {
98
+ headers: {
99
+ "User-Agent": USER_AGENT,
100
+ Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
101
+ "Accept-Language": "en-US,en;q=0.5",
102
+ "Cache-Control": "no-cache",
103
+ },
104
+ });
105
+
106
+ // Check for rate limiting - should retry
107
+ if (embedResponse.status === 429) {
108
+ throw new Error("Rate limited by Loom (429)");
109
+ }
110
+
111
+ // For 4xx errors (except 429), don't retry
112
+ if (embedResponse.status >= 400 && embedResponse.status < 500) {
113
+ throw new AbortError(
114
+ new LoomFetchError(
115
+ `Loom returned HTTP ${embedResponse.status}`,
116
+ "EMBED_FETCH_FAILED",
117
+ embedResponse.status,
118
+ `URL: ${embedUrl}`
119
+ )
120
+ );
121
+ }
122
+
123
+ // For 5xx errors, throw to trigger retry
124
+ if (!embedResponse.ok) {
125
+ throw new Error(`Loom embed request failed with HTTP ${embedResponse.status}`);
126
+ }
127
+
128
+ const embedHtml = await embedResponse.text();
129
+
130
+ // Check for various error states in the HTML - don't retry these
131
+ if (embedHtml.includes("This video is private") || embedHtml.includes("video-not-found")) {
132
+ throw new AbortError(
133
+ new LoomFetchError(
134
+ "Video is private or not found",
135
+ "EMBED_FETCH_FAILED",
136
+ 200,
137
+ "Loom returned a private/not-found page"
138
+ )
139
+ );
140
+ }
141
+
142
+ // Rate limit in HTML - should retry
143
+ if (embedHtml.includes("rate limit") || embedHtml.includes("too many requests")) {
144
+ throw new Error("Rate limited by Loom (detected in HTML)");
145
+ }
146
+
147
+ // Extract HLS URL from the page - try multiple patterns
148
+ const hlsPatterns = [
149
+ /"url":"(https:\/\/luna\.loom\.com\/[^"]+playlist\.m3u8[^"]*)"/,
150
+ /"hlsUrl":"(https:\/\/[^"]+\.m3u8[^"]*)"/,
151
+ /https:\/\/luna\.loom\.com\/[^"'\s]+playlist\.m3u8[^"'\s]*/,
152
+ ];
153
+
154
+ let hlsUrl: string | null = null;
155
+ for (const pattern of hlsPatterns) {
156
+ const match = embedHtml.match(pattern);
157
+ if (match?.[1] || match?.[0]) {
158
+ hlsUrl = (match[1] ?? match[0]).replace(/\\u0026/g, "&").replace(/\\\//g, "/");
159
+ break;
160
+ }
161
+ }
162
+
163
+ if (!hlsUrl) {
164
+ const hasVideoTag = embedHtml.includes("<video");
165
+ const hasLoomPlayer = embedHtml.includes("loom-player") || embedHtml.includes("LoomPlayer");
166
+ const hasEmbedData =
167
+ embedHtml.includes("__NEXT_DATA__") || embedHtml.includes("window.__LOOM__");
168
+ const pageLength = embedHtml.length;
169
+
170
+ throw new AbortError(
171
+ new LoomFetchError(
172
+ "Could not find HLS stream URL in embed page",
173
+ "HLS_NOT_FOUND",
174
+ 200,
175
+ `Page size: ${pageLength} bytes, Has video tag: ${hasVideoTag}, Has Loom player: ${hasLoomPlayer}, Has embed data: ${hasEmbedData}`
176
+ )
177
+ );
178
+ }
179
+
180
+ // Get metadata from OEmbed (non-critical)
181
+ const oembedUrl = `https://www.loom.com/v1/oembed?url=https://www.loom.com/share/${videoId}`;
182
+ let title = "Loom Video";
183
+ let duration = 0;
184
+ let width = 1920;
185
+ let height = 1080;
186
+
187
+ try {
188
+ const oembedResponse = await fetch(oembedUrl, {
189
+ headers: { "User-Agent": USER_AGENT },
190
+ });
191
+
192
+ if (oembedResponse.ok) {
193
+ const data = (await oembedResponse.json()) as {
194
+ title?: string;
195
+ duration?: number;
196
+ width?: number;
197
+ height?: number;
198
+ };
199
+ title = data.title ?? title;
200
+ duration = data.duration ?? duration;
201
+ width = data.width ?? width;
202
+ height = data.height ?? height;
203
+ }
204
+ } catch {
205
+ // OEmbed failure is non-critical
206
+ }
207
+
208
+ return { id: videoId, title, duration, width, height, hlsUrl };
209
+ }
210
+
211
+ /**
212
+ * Fetches video information from Loom's embed page with detailed error reporting.
213
+ * Uses p-retry for automatic retries with exponential backoff.
214
+ */
215
+ export async function getLoomVideoInfoDetailed(
216
+ videoId: string,
217
+ retryCount = 3,
218
+ retryDelayMs = 1000
219
+ ): Promise<LoomFetchResult> {
220
+ try {
221
+ const info = await pRetry(() => fetchLoomVideoInfo(videoId), {
222
+ retries: retryCount,
223
+ minTimeout: retryDelayMs,
224
+ maxTimeout: retryDelayMs * 4,
225
+ onFailedAttempt: (error) => {
226
+ // Only log if not the last attempt
227
+ if (error.retriesLeft > 0) {
228
+ console.log(
229
+ `Loom fetch attempt ${error.attemptNumber} failed, ${error.retriesLeft} retries left`
230
+ );
231
+ }
232
+ },
233
+ });
234
+
235
+ return { success: true, info };
236
+ } catch (error) {
237
+ // Handle LoomFetchError directly
238
+ if (error instanceof LoomFetchError) {
239
+ return error.toResult();
240
+ }
241
+
242
+ // Handle wrapped AbortError (p-retry wraps errors)
243
+ if (error instanceof Error && error.cause instanceof LoomFetchError) {
244
+ return error.cause.toResult();
245
+ }
246
+
247
+ const errorMessage = error instanceof Error ? error.message : String(error);
248
+ return {
249
+ success: false,
250
+ error: `Network error: ${errorMessage}`,
251
+ errorCode: "NETWORK_ERROR",
252
+ details: `Failed after ${retryCount} attempts`,
253
+ };
254
+ }
255
+ }
256
+
257
+ /**
258
+ * Fetches video information from Loom's embed page.
259
+ * @deprecated Use getLoomVideoInfoDetailed for better error reporting
260
+ */
261
+ export async function getLoomVideoInfo(videoId: string): Promise<LoomVideoInfo | null> {
262
+ const result = await getLoomVideoInfoDetailed(videoId);
263
+ return result.success ? (result.info ?? null) : null;
264
+ }
265
+
266
+ /**
267
+ * Parses a master playlist to get video and audio playlist URLs.
268
+ */
269
+ async function parseHlsMasterPlaylist(
270
+ masterUrl: string
271
+ ): Promise<{ videoUrl: string | null; audioUrl: string | null }> {
272
+ try {
273
+ const response = await fetch(masterUrl, {
274
+ headers: { "User-Agent": USER_AGENT },
275
+ });
276
+
277
+ if (!response.ok) {
278
+ return { videoUrl: null, audioUrl: null };
279
+ }
280
+
281
+ const playlist = await response.text();
282
+ const lines = playlist.split("\n");
283
+
284
+ // Get base URL and query params (for signed URLs)
285
+ const baseUrl = getBaseUrl(masterUrl);
286
+ const queryParams = extractQueryParams(masterUrl);
287
+
288
+ let videoUrl: string | null = null;
289
+ let audioUrl: string | null = null;
290
+ let bestBandwidth = 0;
291
+
292
+ for (let i = 0; i < lines.length; i++) {
293
+ const line = lines[i]?.trim();
294
+ if (!line) continue;
295
+
296
+ // Find audio stream
297
+ if (line.startsWith("#EXT-X-MEDIA:") && line.includes("TYPE=AUDIO")) {
298
+ const uriMatch = /URI="([^"]+)"/.exec(line);
299
+ if (uriMatch?.[1]) {
300
+ const uri = uriMatch[1];
301
+ // Append query params for authentication
302
+ audioUrl = (uri.startsWith("http") ? uri : baseUrl + uri) + queryParams;
303
+ }
304
+ }
305
+
306
+ // Find best quality video stream
307
+ if (line.startsWith("#EXT-X-STREAM-INF:")) {
308
+ const bandwidthMatch = /BANDWIDTH=(\d+)/.exec(line);
309
+ const bandwidth = bandwidthMatch?.[1] ? parseInt(bandwidthMatch[1], 10) : 0;
310
+
311
+ const nextLine = lines[i + 1]?.trim();
312
+ if (nextLine && !nextLine.startsWith("#") && bandwidth > bestBandwidth) {
313
+ bestBandwidth = bandwidth;
314
+ // Append query params for authentication
315
+ videoUrl = (nextLine.startsWith("http") ? nextLine : baseUrl + nextLine) + queryParams;
316
+ }
317
+ }
318
+ }
319
+
320
+ return { videoUrl, audioUrl };
321
+ } catch (error) {
322
+ console.error("Failed to parse master playlist:", error);
323
+ return { videoUrl: null, audioUrl: null };
324
+ }
325
+ }
326
+
327
+ /**
328
+ * Gets all segment URLs from a media playlist.
329
+ */
330
+ async function getSegmentUrls(playlistUrl: string): Promise<string[]> {
331
+ try {
332
+ const response = await fetch(playlistUrl, {
333
+ headers: { "User-Agent": USER_AGENT },
334
+ });
335
+
336
+ if (!response.ok) {
337
+ console.error(
338
+ `Failed to fetch playlist: ${response.status} - ${playlistUrl.substring(0, 100)}...`
339
+ );
340
+ return [];
341
+ }
342
+
343
+ const playlist = await response.text();
344
+ const lines = playlist.split("\n");
345
+
346
+ // Get base URL and query params
347
+ const baseUrl = getBaseUrl(playlistUrl);
348
+ const queryParams = extractQueryParams(playlistUrl);
349
+
350
+ const segments: string[] = [];
351
+
352
+ for (const line of lines) {
353
+ const trimmed = line.trim();
354
+ if (
355
+ trimmed &&
356
+ !trimmed.startsWith("#") &&
357
+ (trimmed.endsWith(".ts") || trimmed.includes(".ts?"))
358
+ ) {
359
+ // Construct full URL with auth params
360
+ const segmentUrl = trimmed.startsWith("http") ? trimmed : baseUrl + trimmed;
361
+ // Add query params if segment URL doesn't have them
362
+ const fullUrl = segmentUrl.includes("?") ? segmentUrl : segmentUrl + queryParams;
363
+ segments.push(fullUrl);
364
+ }
365
+ }
366
+
367
+ return segments;
368
+ } catch (error) {
369
+ console.error("Failed to get segments:", error);
370
+ return [];
371
+ }
372
+ }
373
+
374
+ /**
375
+ * Downloads segments and writes them to a file.
376
+ */
377
+ async function downloadSegmentsToFile(
378
+ segments: string[],
379
+ outputPath: string,
380
+ onProgress?: (current: number, total: number) => void
381
+ ): Promise<boolean> {
382
+ const tempPath = `${outputPath}.tmp`;
383
+ const fileStream = createWriteStream(tempPath);
384
+
385
+ try {
386
+ for (let i = 0; i < segments.length; i++) {
387
+ const segmentUrl = segments[i];
388
+ if (!segmentUrl) continue;
389
+
390
+ const response = await fetch(segmentUrl, {
391
+ headers: { "User-Agent": USER_AGENT },
392
+ });
393
+
394
+ if (!response.ok || !response.body) continue;
395
+
396
+ const reader = response.body.getReader();
397
+ while (true) {
398
+ const { done, value } = await reader.read();
399
+ if (done) break;
400
+ fileStream.write(Buffer.from(value));
401
+ }
402
+
403
+ if (onProgress) {
404
+ onProgress(i + 1, segments.length);
405
+ }
406
+ }
407
+
408
+ await new Promise<void>((resolve, reject) => {
409
+ fileStream.end((err: Error | null) => {
410
+ if (err) {
411
+ reject(err);
412
+ } else {
413
+ resolve();
414
+ }
415
+ });
416
+ });
417
+
418
+ renameSync(tempPath, outputPath);
419
+ return true;
420
+ } catch {
421
+ if (existsSync(tempPath)) unlinkSync(tempPath);
422
+ return false;
423
+ }
424
+ }
425
+
426
+ /**
427
+ * Checks if ffmpeg is available.
428
+ */
429
+ async function isFfmpegAvailable(): Promise<boolean> {
430
+ try {
431
+ await execa("ffmpeg", ["-version"]);
432
+ return true;
433
+ } catch {
434
+ return false;
435
+ }
436
+ }
437
+
438
+ /**
439
+ * Merges video and audio files using ffmpeg.
440
+ */
441
+ async function mergeWithFfmpeg(
442
+ videoPath: string,
443
+ audioPath: string,
444
+ outputPath: string
445
+ ): Promise<boolean> {
446
+ try {
447
+ await execa(
448
+ "ffmpeg",
449
+ ["-i", videoPath, "-i", audioPath, "-c:v", "copy", "-c:a", "aac", "-y", outputPath],
450
+ { stdio: "ignore" }
451
+ );
452
+
453
+ // Clean up temp files
454
+ if (existsSync(videoPath)) unlinkSync(videoPath);
455
+ if (existsSync(audioPath)) unlinkSync(audioPath);
456
+ return true;
457
+ } catch {
458
+ // Clean up temp files on failure too
459
+ if (existsSync(videoPath)) unlinkSync(videoPath);
460
+ if (existsSync(audioPath)) unlinkSync(audioPath);
461
+ return false;
462
+ }
463
+ }
464
+
465
+ export interface LoomDownloadResult {
466
+ success: boolean;
467
+ error?: string;
468
+ errorCode?:
469
+ | LoomFetchResult["errorCode"]
470
+ | "INVALID_URL"
471
+ | "NO_VIDEO_STREAM"
472
+ | "NO_SEGMENTS"
473
+ | "DOWNLOAD_FAILED"
474
+ | "MERGE_FAILED";
475
+ details?: string;
476
+ }
477
+
478
+ /**
479
+ * Downloads a Loom video using HLS.
480
+ */
481
+ export async function downloadLoomVideo(
482
+ urlOrId: string,
483
+ outputPath: string,
484
+ onProgress?: (progress: DownloadProgress) => void
485
+ ): Promise<LoomDownloadResult> {
486
+ if (existsSync(outputPath)) {
487
+ return { success: true };
488
+ }
489
+
490
+ let hlsUrl: string;
491
+ let videoUrl: string | null = null;
492
+ let audioUrl: string | null = null;
493
+
494
+ // Check if this is already a direct HLS URL (from previous validation)
495
+ if (urlOrId.includes("luna.loom.com") && urlOrId.includes(".m3u8")) {
496
+ hlsUrl = urlOrId;
497
+
498
+ // Check if this is a media playlist (not master playlist)
499
+ // Media playlists are named: mediaplaylist-video-bitrate*.m3u8 or mediaplaylist-audio.m3u8
500
+ if (hlsUrl.includes("mediaplaylist-video-")) {
501
+ // This is already a video media playlist - use it directly
502
+ videoUrl = hlsUrl;
503
+ // Try to get audio URL by replacing video playlist with audio playlist
504
+ audioUrl = hlsUrl.replace(/mediaplaylist-video-bitrate\d+\.m3u8/, "mediaplaylist-audio.m3u8");
505
+ } else if (hlsUrl.includes("mediaplaylist-audio")) {
506
+ // This is an audio-only playlist - convert to master playlist
507
+ hlsUrl = hlsUrl.replace(/mediaplaylist-audio\.m3u8/, "playlist.m3u8");
508
+ }
509
+ // Otherwise it's a master playlist (playlist.m3u8) - parse it below
510
+ } else {
511
+ // Extract video ID and fetch HLS URL from Loom API
512
+ const videoId = urlOrId.includes("loom.com") ? extractLoomId(urlOrId) : urlOrId;
513
+ if (!videoId) {
514
+ return { success: false, error: "Invalid Loom URL or ID", errorCode: "INVALID_URL" };
515
+ }
516
+
517
+ // Add random delay to avoid concurrent rate limiting (200-800ms)
518
+ await delay(200 + Math.random() * 600);
519
+
520
+ const fetchResult = await getLoomVideoInfoDetailed(videoId);
521
+ if (!fetchResult.success || !fetchResult.info) {
522
+ const result: LoomDownloadResult = {
523
+ success: false,
524
+ error: fetchResult.error ?? "Could not fetch video info from Loom",
525
+ };
526
+ if (fetchResult.errorCode) {
527
+ result.errorCode = fetchResult.errorCode;
528
+ }
529
+ if (fetchResult.details) {
530
+ result.details = fetchResult.details;
531
+ }
532
+ return result;
533
+ }
534
+
535
+ hlsUrl = fetchResult.info.hlsUrl;
536
+ }
537
+
538
+ // Parse master playlist if we don't already have video URL
539
+ if (!videoUrl) {
540
+ const parsed = await parseHlsMasterPlaylist(hlsUrl);
541
+ videoUrl = parsed.videoUrl;
542
+ audioUrl = parsed.audioUrl;
543
+ }
544
+
545
+ if (!videoUrl) {
546
+ return {
547
+ success: false,
548
+ error: "Could not find video stream in HLS playlist",
549
+ errorCode: "NO_VIDEO_STREAM",
550
+ details: `HLS URL: ${hlsUrl.substring(0, 80)}...`,
551
+ };
552
+ }
553
+
554
+ // Get segments
555
+ const videoSegments = await getSegmentUrls(videoUrl);
556
+ if (videoSegments.length === 0) {
557
+ return {
558
+ success: false,
559
+ error: "No video segments found in playlist",
560
+ errorCode: "NO_SEGMENTS",
561
+ details: `Video playlist URL: ${videoUrl.substring(0, 80)}...`,
562
+ };
563
+ }
564
+
565
+ const dir = dirname(outputPath);
566
+ if (!existsSync(dir)) {
567
+ mkdirSync(dir, { recursive: true });
568
+ }
569
+
570
+ // If there's audio, we need ffmpeg to merge
571
+ if (audioUrl) {
572
+ const hasFfmpeg = await isFfmpegAvailable();
573
+
574
+ if (hasFfmpeg) {
575
+ const audioSegments = await getSegmentUrls(audioUrl);
576
+ const tempId = Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
577
+ const tempVideoPath = join(dir, `.temp-video-${tempId}.ts`);
578
+ const tempAudioPath = join(dir, `.temp-audio-${tempId}.ts`);
579
+
580
+ // Download video segments
581
+ const totalSegments = videoSegments.length + audioSegments.length;
582
+ let completed = 0;
583
+
584
+ const videoSuccess = await downloadSegmentsToFile(
585
+ videoSegments,
586
+ tempVideoPath,
587
+ (curr, _total) => {
588
+ completed = curr;
589
+ if (onProgress) {
590
+ onProgress({
591
+ percent: (completed / totalSegments) * 100,
592
+ downloaded: completed,
593
+ total: totalSegments,
594
+ });
595
+ }
596
+ }
597
+ );
598
+
599
+ if (!videoSuccess) {
600
+ return {
601
+ success: false,
602
+ error: "Failed to download video segments",
603
+ errorCode: "DOWNLOAD_FAILED",
604
+ details: `Video had ${videoSegments.length} segments`,
605
+ };
606
+ }
607
+
608
+ // Download audio segments
609
+ const audioSuccess = await downloadSegmentsToFile(
610
+ audioSegments,
611
+ tempAudioPath,
612
+ (curr, _total) => {
613
+ completed = videoSegments.length + curr;
614
+ if (onProgress) {
615
+ onProgress({
616
+ percent: (completed / totalSegments) * 100,
617
+ downloaded: completed,
618
+ total: totalSegments,
619
+ });
620
+ }
621
+ }
622
+ );
623
+
624
+ if (!audioSuccess) {
625
+ if (existsSync(tempVideoPath)) unlinkSync(tempVideoPath);
626
+ return {
627
+ success: false,
628
+ error: "Failed to download audio segments",
629
+ errorCode: "DOWNLOAD_FAILED",
630
+ details: `Audio had ${audioSegments.length} segments`,
631
+ };
632
+ }
633
+
634
+ // Merge with ffmpeg
635
+ const mergeSuccess = await mergeWithFfmpeg(tempVideoPath, tempAudioPath, outputPath);
636
+ if (!mergeSuccess) {
637
+ return {
638
+ success: false,
639
+ error: "Failed to merge video and audio with ffmpeg",
640
+ errorCode: "MERGE_FAILED",
641
+ };
642
+ }
643
+
644
+ return { success: true };
645
+ } else {
646
+ // No ffmpeg - download video only with warning
647
+ console.warn("⚠️ ffmpeg not found - downloading video without audio");
648
+ }
649
+ }
650
+
651
+ // Download video only (no audio or no ffmpeg)
652
+ const success = await downloadSegmentsToFile(videoSegments, outputPath, (curr, total) => {
653
+ if (onProgress) {
654
+ onProgress({ percent: (curr / total) * 100, downloaded: curr, total });
655
+ }
656
+ });
657
+
658
+ if (!success) {
659
+ return {
660
+ success: false,
661
+ error: "Failed to download video segments",
662
+ errorCode: "DOWNLOAD_FAILED",
663
+ details: `Video had ${videoSegments.length} segments`,
664
+ };
665
+ }
666
+
667
+ return { success: true };
668
+ }
669
+
670
+ /**
671
+ * Downloads a file directly.
672
+ */
673
+ export async function downloadFile(
674
+ url: string,
675
+ outputPath: string,
676
+ onProgress?: (progress: DownloadProgress) => void
677
+ ): Promise<{ success: boolean; error?: string }> {
678
+ if (existsSync(outputPath)) {
679
+ return { success: true };
680
+ }
681
+
682
+ const dir = dirname(outputPath);
683
+ if (!existsSync(dir)) {
684
+ mkdirSync(dir, { recursive: true });
685
+ }
686
+
687
+ const tempPath = `${outputPath}.tmp`;
688
+
689
+ try {
690
+ const response = await fetch(url, {
691
+ headers: {
692
+ "User-Agent": USER_AGENT,
693
+ Referer: "https://www.loom.com/",
694
+ },
695
+ });
696
+
697
+ if (!response.ok) {
698
+ return { success: false, error: `HTTP ${response.status}` };
699
+ }
700
+
701
+ const contentLength = response.headers.get("content-length");
702
+ const total = contentLength ? parseInt(contentLength, 10) : 0;
703
+
704
+ if (!response.body) {
705
+ return { success: false, error: "No response body" };
706
+ }
707
+
708
+ const fileStream = createWriteStream(tempPath);
709
+ const reader = response.body.getReader();
710
+ let downloaded = 0;
711
+
712
+ const readable = new Readable({
713
+ read() {
714
+ reader
715
+ .read()
716
+ .then(({ done, value }) => {
717
+ if (done) {
718
+ this.push(null);
719
+ } else {
720
+ downloaded += value.length;
721
+ if (onProgress && total > 0) {
722
+ onProgress({ percent: (downloaded / total) * 100, downloaded, total });
723
+ }
724
+ this.push(Buffer.from(value));
725
+ }
726
+ })
727
+ .catch((err: unknown) => {
728
+ this.destroy(err instanceof Error ? err : new Error(String(err)));
729
+ });
730
+ },
731
+ });
732
+
733
+ await finished(readable.pipe(fileStream));
734
+ renameSync(tempPath, outputPath);
735
+
736
+ return { success: true };
737
+ } catch (error) {
738
+ if (existsSync(tempPath)) unlinkSync(tempPath);
739
+ return { success: false, error: error instanceof Error ? error.message : String(error) };
740
+ }
741
+ }
742
+ /* v8 ignore stop */