offcourse 0.0.2 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (284) hide show
  1. package/.github/workflows/ci.yml +50 -0
  2. package/.husky/commit-msg +2 -0
  3. package/.husky/pre-commit +1 -0
  4. package/.husky/pre-push +3 -0
  5. package/.prettierrc +8 -0
  6. package/.release-it.json +23 -0
  7. package/ARCHITECTURE.md +233 -0
  8. package/CHANGELOG.md +78 -0
  9. package/README.md +255 -20
  10. package/commitlint.config.js +4 -0
  11. package/dist/ai/openRouter.d.ts +47 -0
  12. package/dist/ai/openRouter.d.ts.map +1 -0
  13. package/dist/ai/openRouter.js +116 -0
  14. package/dist/ai/openRouter.js.map +1 -0
  15. package/dist/ai/transcriptPolisher.d.ts +24 -0
  16. package/dist/ai/transcriptPolisher.d.ts.map +1 -0
  17. package/dist/ai/transcriptPolisher.js +89 -0
  18. package/dist/ai/transcriptPolisher.js.map +1 -0
  19. package/dist/cli/commands/config.d.ts +13 -0
  20. package/dist/cli/commands/config.d.ts.map +1 -0
  21. package/dist/cli/commands/config.js +66 -0
  22. package/dist/cli/commands/config.js.map +1 -0
  23. package/dist/cli/commands/enrich.d.ts +14 -0
  24. package/dist/cli/commands/enrich.d.ts.map +1 -0
  25. package/dist/cli/commands/enrich.js +271 -0
  26. package/dist/cli/commands/enrich.js.map +1 -0
  27. package/dist/cli/commands/inspect.d.ts +11 -0
  28. package/dist/cli/commands/inspect.d.ts.map +1 -0
  29. package/dist/cli/commands/inspect.js +365 -0
  30. package/dist/cli/commands/inspect.js.map +1 -0
  31. package/dist/cli/commands/login.d.ts +12 -0
  32. package/dist/cli/commands/login.d.ts.map +1 -0
  33. package/dist/cli/commands/login.js +55 -0
  34. package/dist/cli/commands/login.js.map +1 -0
  35. package/dist/cli/commands/status.d.ts +15 -0
  36. package/dist/cli/commands/status.d.ts.map +1 -0
  37. package/dist/cli/commands/status.js +118 -0
  38. package/dist/cli/commands/status.js.map +1 -0
  39. package/dist/cli/commands/sync.d.ts +16 -0
  40. package/dist/cli/commands/sync.d.ts.map +1 -0
  41. package/dist/cli/commands/sync.js +922 -0
  42. package/dist/cli/commands/sync.js.map +1 -0
  43. package/dist/cli/commands/syncGhl.d.ts +20 -0
  44. package/dist/cli/commands/syncGhl.d.ts.map +1 -0
  45. package/dist/cli/commands/syncGhl.js +483 -0
  46. package/dist/cli/commands/syncGhl.js.map +1 -0
  47. package/dist/cli/commands/syncHighLevel.d.ts +24 -0
  48. package/dist/cli/commands/syncHighLevel.d.ts.map +1 -0
  49. package/dist/cli/commands/syncHighLevel.js +483 -0
  50. package/dist/cli/commands/syncHighLevel.js.map +1 -0
  51. package/dist/cli/commands/syncHighLevel.test.d.ts +2 -0
  52. package/dist/cli/commands/syncHighLevel.test.d.ts.map +1 -0
  53. package/dist/cli/commands/syncHighLevel.test.js +102 -0
  54. package/dist/cli/commands/syncHighLevel.test.js.map +1 -0
  55. package/dist/cli/index.d.ts +3 -0
  56. package/dist/cli/index.d.ts.map +1 -0
  57. package/dist/cli/index.js +106 -0
  58. package/dist/cli/index.js.map +1 -0
  59. package/dist/config/configManager.d.ts +31 -0
  60. package/dist/config/configManager.d.ts.map +1 -0
  61. package/dist/config/configManager.js +64 -0
  62. package/dist/config/configManager.js.map +1 -0
  63. package/dist/config/paths.d.ts +21 -0
  64. package/dist/config/paths.d.ts.map +1 -0
  65. package/dist/config/paths.js +33 -0
  66. package/dist/config/paths.js.map +1 -0
  67. package/dist/config/paths.test.d.ts +2 -0
  68. package/dist/config/paths.test.d.ts.map +1 -0
  69. package/dist/config/paths.test.js +70 -0
  70. package/dist/config/paths.test.js.map +1 -0
  71. package/dist/config/schema.d.ts +60 -0
  72. package/dist/config/schema.d.ts.map +1 -0
  73. package/dist/config/schema.js +50 -0
  74. package/dist/config/schema.js.map +1 -0
  75. package/dist/config/schema.test.d.ts +2 -0
  76. package/dist/config/schema.test.d.ts.map +1 -0
  77. package/dist/config/schema.test.js +151 -0
  78. package/dist/config/schema.test.js.map +1 -0
  79. package/dist/downloader/hlsDownloader.d.ts +58 -0
  80. package/dist/downloader/hlsDownloader.d.ts.map +1 -0
  81. package/dist/downloader/hlsDownloader.js +254 -0
  82. package/dist/downloader/hlsDownloader.js.map +1 -0
  83. package/dist/downloader/hlsDownloader.test.d.ts +2 -0
  84. package/dist/downloader/hlsDownloader.test.d.ts.map +1 -0
  85. package/dist/downloader/hlsDownloader.test.js +116 -0
  86. package/dist/downloader/hlsDownloader.test.js.map +1 -0
  87. package/dist/downloader/hlsValidator.d.ts +35 -0
  88. package/dist/downloader/hlsValidator.d.ts.map +1 -0
  89. package/dist/downloader/hlsValidator.js +148 -0
  90. package/dist/downloader/hlsValidator.js.map +1 -0
  91. package/dist/downloader/index.d.ts +26 -0
  92. package/dist/downloader/index.d.ts.map +1 -0
  93. package/dist/downloader/index.js +52 -0
  94. package/dist/downloader/index.js.map +1 -0
  95. package/dist/downloader/loomDownloader.d.ts +56 -0
  96. package/dist/downloader/loomDownloader.d.ts.map +1 -0
  97. package/dist/downloader/loomDownloader.js +559 -0
  98. package/dist/downloader/loomDownloader.js.map +1 -0
  99. package/dist/downloader/loomDownloader.test.d.ts +2 -0
  100. package/dist/downloader/loomDownloader.test.d.ts.map +1 -0
  101. package/dist/downloader/loomDownloader.test.js +36 -0
  102. package/dist/downloader/loomDownloader.test.js.map +1 -0
  103. package/dist/downloader/queue.d.ts +56 -0
  104. package/dist/downloader/queue.d.ts.map +1 -0
  105. package/dist/downloader/queue.js +88 -0
  106. package/dist/downloader/queue.js.map +1 -0
  107. package/dist/downloader/queue.test.d.ts +2 -0
  108. package/dist/downloader/queue.test.d.ts.map +1 -0
  109. package/dist/downloader/queue.test.js +158 -0
  110. package/dist/downloader/queue.test.js.map +1 -0
  111. package/dist/downloader/videoDownloader.d.ts +32 -0
  112. package/dist/downloader/videoDownloader.d.ts.map +1 -0
  113. package/dist/downloader/videoDownloader.js +173 -0
  114. package/dist/downloader/videoDownloader.js.map +1 -0
  115. package/dist/downloader/vimeoDownloader.d.ts +52 -0
  116. package/dist/downloader/vimeoDownloader.d.ts.map +1 -0
  117. package/dist/downloader/vimeoDownloader.js +565 -0
  118. package/dist/downloader/vimeoDownloader.js.map +1 -0
  119. package/dist/downloader/vimeoDownloader.test.d.ts +2 -0
  120. package/dist/downloader/vimeoDownloader.test.d.ts.map +1 -0
  121. package/dist/downloader/vimeoDownloader.test.js +51 -0
  122. package/dist/downloader/vimeoDownloader.test.js.map +1 -0
  123. package/dist/scraper/auth.d.ts +29 -0
  124. package/dist/scraper/auth.d.ts.map +1 -0
  125. package/dist/scraper/auth.js +115 -0
  126. package/dist/scraper/auth.js.map +1 -0
  127. package/dist/scraper/extractor.d.ts +49 -0
  128. package/dist/scraper/extractor.d.ts.map +1 -0
  129. package/dist/scraper/extractor.js +627 -0
  130. package/dist/scraper/extractor.js.map +1 -0
  131. package/dist/scraper/extractor.test.d.ts +2 -0
  132. package/dist/scraper/extractor.test.d.ts.map +1 -0
  133. package/dist/scraper/extractor.test.js +65 -0
  134. package/dist/scraper/extractor.test.js.map +1 -0
  135. package/dist/scraper/ghl/auth.d.ts +25 -0
  136. package/dist/scraper/ghl/auth.d.ts.map +1 -0
  137. package/dist/scraper/ghl/auth.js +187 -0
  138. package/dist/scraper/ghl/auth.js.map +1 -0
  139. package/dist/scraper/ghl/extractor.d.ts +96 -0
  140. package/dist/scraper/ghl/extractor.d.ts.map +1 -0
  141. package/dist/scraper/ghl/extractor.js +345 -0
  142. package/dist/scraper/ghl/extractor.js.map +1 -0
  143. package/dist/scraper/ghl/index.d.ts +4 -0
  144. package/dist/scraper/ghl/index.d.ts.map +1 -0
  145. package/dist/scraper/ghl/index.js +4 -0
  146. package/dist/scraper/ghl/index.js.map +1 -0
  147. package/dist/scraper/ghl/navigator.d.ts +93 -0
  148. package/dist/scraper/ghl/navigator.d.ts.map +1 -0
  149. package/dist/scraper/ghl/navigator.js +447 -0
  150. package/dist/scraper/ghl/navigator.js.map +1 -0
  151. package/dist/scraper/highlevel/auth.d.ts +25 -0
  152. package/dist/scraper/highlevel/auth.d.ts.map +1 -0
  153. package/dist/scraper/highlevel/auth.js +189 -0
  154. package/dist/scraper/highlevel/auth.js.map +1 -0
  155. package/dist/scraper/highlevel/extractor.d.ts +97 -0
  156. package/dist/scraper/highlevel/extractor.d.ts.map +1 -0
  157. package/dist/scraper/highlevel/extractor.js +386 -0
  158. package/dist/scraper/highlevel/extractor.js.map +1 -0
  159. package/dist/scraper/highlevel/extractor.test.d.ts +2 -0
  160. package/dist/scraper/highlevel/extractor.test.d.ts.map +1 -0
  161. package/dist/scraper/highlevel/extractor.test.js +101 -0
  162. package/dist/scraper/highlevel/extractor.test.js.map +1 -0
  163. package/dist/scraper/highlevel/index.d.ts +3 -0
  164. package/dist/scraper/highlevel/index.d.ts.map +1 -0
  165. package/dist/scraper/highlevel/index.js +3 -0
  166. package/dist/scraper/highlevel/index.js.map +1 -0
  167. package/dist/scraper/highlevel/navigator.d.ts +93 -0
  168. package/dist/scraper/highlevel/navigator.d.ts.map +1 -0
  169. package/dist/scraper/highlevel/navigator.js +492 -0
  170. package/dist/scraper/highlevel/navigator.js.map +1 -0
  171. package/dist/scraper/highlevel/navigator.test.d.ts +2 -0
  172. package/dist/scraper/highlevel/navigator.test.d.ts.map +1 -0
  173. package/dist/scraper/highlevel/navigator.test.js +78 -0
  174. package/dist/scraper/highlevel/navigator.test.js.map +1 -0
  175. package/dist/scraper/navigator.d.ts +65 -0
  176. package/dist/scraper/navigator.d.ts.map +1 -0
  177. package/dist/scraper/navigator.js +300 -0
  178. package/dist/scraper/navigator.js.map +1 -0
  179. package/dist/scraper/navigator.test.d.ts +2 -0
  180. package/dist/scraper/navigator.test.d.ts.map +1 -0
  181. package/dist/scraper/navigator.test.js +63 -0
  182. package/dist/scraper/navigator.test.js.map +1 -0
  183. package/dist/scraper/skoolApi.d.ts +17 -0
  184. package/dist/scraper/skoolApi.d.ts.map +1 -0
  185. package/dist/scraper/skoolApi.js +72 -0
  186. package/dist/scraper/skoolApi.js.map +1 -0
  187. package/dist/scraper/videoInterceptor.d.ts +19 -0
  188. package/dist/scraper/videoInterceptor.d.ts.map +1 -0
  189. package/dist/scraper/videoInterceptor.js +315 -0
  190. package/dist/scraper/videoInterceptor.js.map +1 -0
  191. package/dist/shared/auth.d.ts +58 -0
  192. package/dist/shared/auth.d.ts.map +1 -0
  193. package/dist/shared/auth.js +211 -0
  194. package/dist/shared/auth.js.map +1 -0
  195. package/dist/shared/fs.d.ts +31 -0
  196. package/dist/shared/fs.d.ts.map +1 -0
  197. package/dist/shared/fs.js +73 -0
  198. package/dist/shared/fs.js.map +1 -0
  199. package/dist/shared/http.d.ts +15 -0
  200. package/dist/shared/http.d.ts.map +1 -0
  201. package/dist/shared/http.js +31 -0
  202. package/dist/shared/http.js.map +1 -0
  203. package/dist/shared/index.d.ts +4 -0
  204. package/dist/shared/index.d.ts.map +1 -0
  205. package/dist/shared/index.js +4 -0
  206. package/dist/shared/index.js.map +1 -0
  207. package/dist/state/database.d.ts +245 -0
  208. package/dist/state/database.d.ts.map +1 -0
  209. package/dist/state/database.js +676 -0
  210. package/dist/state/database.js.map +1 -0
  211. package/dist/state/database.test.d.ts +2 -0
  212. package/dist/state/database.test.d.ts.map +1 -0
  213. package/dist/state/database.test.js +34 -0
  214. package/dist/state/database.test.js.map +1 -0
  215. package/dist/state/index.d.ts +2 -0
  216. package/dist/state/index.d.ts.map +1 -0
  217. package/dist/state/index.js +2 -0
  218. package/dist/state/index.js.map +1 -0
  219. package/dist/storage/fileSystem.d.ts +56 -0
  220. package/dist/storage/fileSystem.d.ts.map +1 -0
  221. package/dist/storage/fileSystem.js +121 -0
  222. package/dist/storage/fileSystem.js.map +1 -0
  223. package/dist/transcription/whisperService.d.ts +27 -0
  224. package/dist/transcription/whisperService.d.ts.map +1 -0
  225. package/dist/transcription/whisperService.js +102 -0
  226. package/dist/transcription/whisperService.js.map +1 -0
  227. package/eslint.config.js +55 -0
  228. package/package.json +68 -11
  229. package/src/__fixtures__/highlevel-post-response.json +68 -0
  230. package/src/__fixtures__/hls-master-playlist.m3u8 +24 -0
  231. package/src/cli/commands/__snapshots__/syncHighLevel.test.ts.snap +38 -0
  232. package/src/cli/commands/config.ts +74 -0
  233. package/src/cli/commands/inspect.ts +441 -0
  234. package/src/cli/commands/login.ts +68 -0
  235. package/src/cli/commands/status.ts +147 -0
  236. package/src/cli/commands/sync.ts +1235 -0
  237. package/src/cli/commands/syncHighLevel.test.ts +144 -0
  238. package/src/cli/commands/syncHighLevel.ts +639 -0
  239. package/src/cli/index.ts +121 -0
  240. package/src/config/configManager.ts +75 -0
  241. package/src/config/paths.test.ts +83 -0
  242. package/src/config/paths.ts +36 -0
  243. package/src/config/schema.test.ts +173 -0
  244. package/src/config/schema.ts +65 -0
  245. package/src/downloader/hlsDownloader.test.ts +148 -0
  246. package/src/downloader/hlsDownloader.ts +327 -0
  247. package/src/downloader/hlsValidator.ts +196 -0
  248. package/src/downloader/index.ts +122 -0
  249. package/src/downloader/loomDownloader.test.ts +43 -0
  250. package/src/downloader/loomDownloader.ts +742 -0
  251. package/src/downloader/queue.test.ts +199 -0
  252. package/src/downloader/queue.ts +118 -0
  253. package/src/downloader/vimeoDownloader.test.ts +62 -0
  254. package/src/downloader/vimeoDownloader.ts +722 -0
  255. package/src/scraper/extractor.test.ts +124 -0
  256. package/src/scraper/extractor.ts +757 -0
  257. package/src/scraper/highlevel/__snapshots__/extractor.test.ts.snap +41 -0
  258. package/src/scraper/highlevel/extractor.test.ts +134 -0
  259. package/src/scraper/highlevel/extractor.ts +537 -0
  260. package/src/scraper/highlevel/index.ts +2 -0
  261. package/src/scraper/highlevel/navigator.test.ts +110 -0
  262. package/src/scraper/highlevel/navigator.ts +668 -0
  263. package/src/scraper/highlevel/schemas.ts +183 -0
  264. package/src/scraper/navigator.test.ts +122 -0
  265. package/src/scraper/navigator.ts +355 -0
  266. package/src/scraper/schemas.ts +177 -0
  267. package/src/scraper/videoInterceptor.ts +435 -0
  268. package/src/shared/auth.test.ts +58 -0
  269. package/src/shared/auth.ts +251 -0
  270. package/src/shared/firebase.ts +151 -0
  271. package/src/shared/fs.ts +80 -0
  272. package/src/shared/http.ts +34 -0
  273. package/src/shared/index.ts +6 -0
  274. package/src/shared/slug.ts +26 -0
  275. package/src/shared/url.test.ts +122 -0
  276. package/src/shared/url.ts +57 -0
  277. package/src/state/database.test.ts +49 -0
  278. package/src/state/database.ts +919 -0
  279. package/src/state/index.ts +14 -0
  280. package/src/storage/fileSystem.test.ts +64 -0
  281. package/src/storage/fileSystem.ts +175 -0
  282. package/tsconfig.json +28 -0
  283. package/vitest.config.ts +29 -0
  284. package/cli.js +0 -45
@@ -0,0 +1,722 @@
1
+ import { createWriteStream, existsSync, mkdirSync, renameSync, unlinkSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import { USER_AGENT } from "../shared/http.js";
4
+ import { getBaseUrl } from "../shared/url.js";
5
+
6
+ export interface VimeoVideoInfo {
7
+ id: string;
8
+ title: string;
9
+ duration: number;
10
+ width: number;
11
+ height: number;
12
+ hlsUrl: string | null;
13
+ progressiveUrl: string | null;
14
+ }
15
+
16
+ export interface VimeoFetchResult {
17
+ success: boolean;
18
+ info?: VimeoVideoInfo;
19
+ error?: string;
20
+ errorCode?:
21
+ | "VIDEO_NOT_FOUND"
22
+ | "DRM_PROTECTED"
23
+ | "PRIVATE_VIDEO"
24
+ | "RATE_LIMITED"
25
+ | "NETWORK_ERROR"
26
+ | "PARSE_ERROR";
27
+ details?: string;
28
+ }
29
+
30
+ export interface DownloadProgress {
31
+ percent: number;
32
+ downloaded: number;
33
+ total: number;
34
+ }
35
+
36
+ export interface VimeoDownloadResult {
37
+ success: boolean;
38
+ error?: string;
39
+ errorCode?: VimeoFetchResult["errorCode"] | "INVALID_URL" | "NO_STREAM" | "DOWNLOAD_FAILED";
40
+ details?: string;
41
+ }
42
+
43
+ /**
44
+ * Extracts the Vimeo video ID from various URL formats.
45
+ */
46
+ export function extractVimeoId(url: string): string | null {
47
+ // Handle various Vimeo URL formats:
48
+ // https://vimeo.com/123456789
49
+ // https://vimeo.com/123456789?share=copy
50
+ // https://player.vimeo.com/video/123456789
51
+ // https://vimeo.com/channels/xxx/123456789
52
+ const patterns = [
53
+ /vimeo\.com\/(?:video\/)?(\d+)/,
54
+ /player\.vimeo\.com\/video\/(\d+)/,
55
+ /vimeo\.com\/channels\/[^/]+\/(\d+)/,
56
+ /vimeo\.com\/groups\/[^/]+\/videos\/(\d+)/,
57
+ ];
58
+
59
+ for (const pattern of patterns) {
60
+ const match = url.match(pattern);
61
+ if (match?.[1]) {
62
+ return match[1];
63
+ }
64
+ }
65
+
66
+ return null;
67
+ }
68
+
69
+ // Network I/O and file operations - excluded from coverage
70
+ /* v8 ignore start */
71
+
72
+ /**
73
+ * Extracts the unlisted hash from a Vimeo URL if present.
74
+ * Unlisted videos require this hash to access.
75
+ */
76
+ function extractUnlistedHash(url: string): string | null {
77
+ // Format: https://vimeo.com/123456789/abcdef1234
78
+ // Or in player: https://player.vimeo.com/video/123456789?h=abcdef1234
79
+ const pathMatch = /vimeo\.com\/\d+\/([a-f0-9]+)/.exec(url);
80
+ if (pathMatch?.[1]) {
81
+ return pathMatch[1];
82
+ }
83
+
84
+ const paramMatch = /[?&]h=([a-f0-9]+)/.exec(url);
85
+ if (paramMatch?.[1]) {
86
+ return paramMatch[1];
87
+ }
88
+
89
+ return null;
90
+ }
91
+
92
+ /**
93
+ * Fetches video information from Vimeo's player config.
94
+ * @param referer - Optional referer URL (e.g., the Skool page URL) for domain-restricted videos
95
+ */
96
+ export async function getVimeoVideoInfo(
97
+ videoId: string,
98
+ unlistedHash?: string | null,
99
+ referer?: string
100
+ ): Promise<VimeoFetchResult> {
101
+ // Try the config endpoint first
102
+ let configUrl = `https://player.vimeo.com/video/${videoId}/config`;
103
+ if (unlistedHash) {
104
+ configUrl += `?h=${unlistedHash}`;
105
+ }
106
+
107
+ // Build headers - use provided referer for domain-restricted videos
108
+ const headers: Record<string, string> = {
109
+ "User-Agent": USER_AGENT,
110
+ Accept: "application/json",
111
+ };
112
+
113
+ // Try with Skool referer first if provided, otherwise use Vimeo's player
114
+ if (referer) {
115
+ headers.Referer = referer;
116
+ headers.Origin = new URL(referer).origin;
117
+ } else {
118
+ headers.Referer = "https://player.vimeo.com/";
119
+ }
120
+
121
+ try {
122
+ let response = await fetch(configUrl, { headers });
123
+
124
+ // If we got 403 with a custom referer, the video might be strictly domain-locked
125
+ // Try with the embed page URL as referer
126
+ if (response.status === 403 && referer) {
127
+ headers.Referer = `https://player.vimeo.com/video/${videoId}`;
128
+ headers.Origin = "https://player.vimeo.com";
129
+ response = await fetch(configUrl, { headers });
130
+ }
131
+
132
+ if (response.status === 404) {
133
+ return {
134
+ success: false,
135
+ error: "Video not found",
136
+ errorCode: "VIDEO_NOT_FOUND",
137
+ details: `Video ID: ${videoId}`,
138
+ };
139
+ }
140
+
141
+ if (response.status === 403) {
142
+ return {
143
+ success: false,
144
+ error: "Video is private or requires authentication",
145
+ errorCode: "PRIVATE_VIDEO",
146
+ details: `Video ID: ${videoId}. This video may require login or is restricted.`,
147
+ };
148
+ }
149
+
150
+ if (response.status === 429) {
151
+ return {
152
+ success: false,
153
+ error: "Rate limited by Vimeo",
154
+ errorCode: "RATE_LIMITED",
155
+ };
156
+ }
157
+
158
+ if (!response.ok) {
159
+ return {
160
+ success: false,
161
+ error: `Vimeo returned HTTP ${response.status}`,
162
+ errorCode: "NETWORK_ERROR",
163
+ details: `Config URL: ${configUrl}`,
164
+ };
165
+ }
166
+
167
+ const config = (await response.json()) as VimeoConfig;
168
+
169
+ // Check for DRM
170
+ if (
171
+ config.request?.files?.dash?.cdns &&
172
+ !config.request?.files?.hls &&
173
+ !config.request?.files?.progressive
174
+ ) {
175
+ // Only DASH with no HLS/progressive usually means DRM
176
+ const hasDrm = config.video?.drm ?? config.request?.drm;
177
+ if (hasDrm) {
178
+ return {
179
+ success: false,
180
+ error: "Video is DRM protected and cannot be downloaded",
181
+ errorCode: "DRM_PROTECTED",
182
+ details: `Video "${config.video?.title ?? videoId}" uses DRM protection. This video cannot be downloaded without the content provider's authorization.`,
183
+ };
184
+ }
185
+ }
186
+
187
+ // Extract HLS URL
188
+ let hlsUrl: string | null = null;
189
+ const hlsCdns = config.request?.files?.hls?.cdns;
190
+ if (hlsCdns) {
191
+ // Prefer akamai_live, then fastly, then any available CDN
192
+ const preferredCdns = ["akfire_interconnect_quic", "akamai_live", "fastly_skyfire", "fastly"];
193
+ for (const cdn of preferredCdns) {
194
+ if (hlsCdns[cdn]?.url) {
195
+ hlsUrl = hlsCdns[cdn].url;
196
+ break;
197
+ }
198
+ }
199
+ // Fallback to any CDN
200
+ if (!hlsUrl) {
201
+ const cdnKeys = Object.keys(hlsCdns);
202
+ const firstCdn = cdnKeys[0];
203
+ if (firstCdn) {
204
+ const cdnUrl = hlsCdns[firstCdn]?.url;
205
+ if (cdnUrl) {
206
+ hlsUrl = cdnUrl;
207
+ }
208
+ }
209
+ }
210
+ }
211
+
212
+ // Extract progressive (direct MP4) URL - prefer highest quality
213
+ let progressiveUrl: string | null = null;
214
+ const progressive = config.request?.files?.progressive;
215
+ if (progressive && Array.isArray(progressive) && progressive.length > 0) {
216
+ // Sort by height descending to get best quality
217
+ const sorted = [...progressive].sort((a, b) => (b.height ?? 0) - (a.height ?? 0));
218
+ progressiveUrl = sorted[0]?.url ?? null;
219
+ }
220
+
221
+ if (!hlsUrl && !progressiveUrl) {
222
+ // Check if this is a DRM-only video
223
+ if (config.request?.files?.dash) {
224
+ return {
225
+ success: false,
226
+ error: "Video only has DRM-protected DASH streams",
227
+ errorCode: "DRM_PROTECTED",
228
+ details: `Video "${config.video?.title ?? videoId}" appears to use DRM protection. No downloadable streams available.`,
229
+ };
230
+ }
231
+
232
+ return {
233
+ success: false,
234
+ error: "No downloadable video streams found",
235
+ errorCode: "PARSE_ERROR",
236
+ details: "Could not find HLS or progressive download URLs in config",
237
+ };
238
+ }
239
+
240
+ return {
241
+ success: true,
242
+ info: {
243
+ id: videoId,
244
+ title: config.video?.title ?? "Vimeo Video",
245
+ duration: config.video?.duration ?? 0,
246
+ width: config.video?.width ?? 1920,
247
+ height: config.video?.height ?? 1080,
248
+ hlsUrl,
249
+ progressiveUrl,
250
+ },
251
+ };
252
+ } catch (error) {
253
+ const errorMessage = error instanceof Error ? error.message : String(error);
254
+ return {
255
+ success: false,
256
+ error: `Network error: ${errorMessage}`,
257
+ errorCode: "NETWORK_ERROR",
258
+ };
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Fetches Vimeo config from within a Playwright browser context.
264
+ * Uses page.request API which runs at browser level (no CORS restrictions)
265
+ * and includes the browser's cookies/session.
266
+ *
267
+ * @param page - Playwright page (should be on the Skool lesson page)
268
+ * @param videoId - Vimeo video ID
269
+ * @param unlistedHash - Optional hash for unlisted videos
270
+ */
271
+ export async function getVimeoVideoInfoFromBrowser(
272
+ page: import("playwright").Page,
273
+ videoId: string,
274
+ unlistedHash?: string | null
275
+ ): Promise<VimeoFetchResult> {
276
+ let configUrl = `https://player.vimeo.com/video/${videoId}/config`;
277
+ if (unlistedHash) {
278
+ configUrl += `?h=${unlistedHash}`;
279
+ }
280
+
281
+ try {
282
+ // Use page.request (Playwright API) - runs at browser level, no CORS issues
283
+ // and includes the browser's cookies/session
284
+ const currentUrl = page.url();
285
+ const response = await page.request.get(configUrl, {
286
+ headers: {
287
+ Accept: "application/json",
288
+ Referer: currentUrl,
289
+ Origin: new URL(currentUrl).origin,
290
+ },
291
+ });
292
+
293
+ if (response.status() === 403) {
294
+ return {
295
+ success: false,
296
+ error: "Video is private or requires authentication",
297
+ errorCode: "PRIVATE_VIDEO",
298
+ details: `Video ID: ${videoId}. Domain-restricted even with browser session.`,
299
+ };
300
+ }
301
+
302
+ if (response.status() === 404) {
303
+ return {
304
+ success: false,
305
+ error: "Video not found",
306
+ errorCode: "VIDEO_NOT_FOUND",
307
+ details: `Video ID: ${videoId}`,
308
+ };
309
+ }
310
+
311
+ if (!response.ok()) {
312
+ return {
313
+ success: false,
314
+ error: `Vimeo returned HTTP ${response.status()}`,
315
+ errorCode: "NETWORK_ERROR",
316
+ details: `Config URL: ${configUrl}`,
317
+ };
318
+ }
319
+
320
+ const config = (await response.json()) as VimeoConfig;
321
+
322
+ // Extract HLS URL (same logic as getVimeoVideoInfo)
323
+ let hlsUrl: string | null = null;
324
+ const hlsCdns = config.request?.files?.hls?.cdns;
325
+ if (hlsCdns) {
326
+ const preferredCdns = ["akfire_interconnect_quic", "akamai_live", "fastly_skyfire", "fastly"];
327
+ for (const cdn of preferredCdns) {
328
+ if (hlsCdns[cdn]?.url) {
329
+ hlsUrl = hlsCdns[cdn].url;
330
+ break;
331
+ }
332
+ }
333
+ if (!hlsUrl) {
334
+ const cdnKeys = Object.keys(hlsCdns);
335
+ const firstCdn = cdnKeys[0];
336
+ if (firstCdn) {
337
+ const cdnUrl = hlsCdns[firstCdn]?.url;
338
+ if (cdnUrl) {
339
+ hlsUrl = cdnUrl;
340
+ }
341
+ }
342
+ }
343
+ }
344
+
345
+ // Extract progressive URL
346
+ let progressiveUrl: string | null = null;
347
+ const progressive = config.request?.files?.progressive;
348
+ if (progressive && Array.isArray(progressive) && progressive.length > 0) {
349
+ const sorted = [...progressive].sort((a, b) => (b.height ?? 0) - (a.height ?? 0));
350
+ progressiveUrl = sorted[0]?.url ?? null;
351
+ }
352
+
353
+ if (!hlsUrl && !progressiveUrl) {
354
+ if (config.request?.files?.dash) {
355
+ return {
356
+ success: false,
357
+ error: "Video only has DRM-protected DASH streams",
358
+ errorCode: "DRM_PROTECTED",
359
+ details: `Video "${config.video?.title ?? videoId}" uses DRM. Cannot download.`,
360
+ };
361
+ }
362
+ return {
363
+ success: false,
364
+ error: "No downloadable streams found",
365
+ errorCode: "PARSE_ERROR",
366
+ };
367
+ }
368
+
369
+ return {
370
+ success: true,
371
+ info: {
372
+ id: videoId,
373
+ title: config.video?.title ?? "Vimeo Video",
374
+ duration: config.video?.duration ?? 0,
375
+ width: config.video?.width ?? 1920,
376
+ height: config.video?.height ?? 1080,
377
+ hlsUrl,
378
+ progressiveUrl,
379
+ },
380
+ };
381
+ } catch (error) {
382
+ return {
383
+ success: false,
384
+ error: error instanceof Error ? error.message : String(error),
385
+ errorCode: "NETWORK_ERROR",
386
+ };
387
+ }
388
+ }
389
+
390
+ /**
391
+ * Downloads a Vimeo video.
392
+ * Prefers progressive (direct MP4) download, falls back to HLS.
393
+ */
394
+ export async function downloadVimeoVideo(
395
+ url: string,
396
+ outputPath: string,
397
+ onProgress?: (progress: DownloadProgress) => void
398
+ ): Promise<VimeoDownloadResult> {
399
+ if (existsSync(outputPath)) {
400
+ return { success: true };
401
+ }
402
+
403
+ // Check if this is already a direct HLS/CDN URL (from previous validation)
404
+ if (url.includes("vimeocdn.com") && url.includes(".m3u8")) {
405
+ // Direct HLS download
406
+ const dir = dirname(outputPath);
407
+ if (!existsSync(dir)) {
408
+ mkdirSync(dir, { recursive: true });
409
+ }
410
+
411
+ return downloadHlsVideo(url, outputPath, onProgress);
412
+ }
413
+
414
+ const videoId = extractVimeoId(url);
415
+ if (!videoId) {
416
+ return {
417
+ success: false,
418
+ error: "Invalid Vimeo URL",
419
+ errorCode: "INVALID_URL",
420
+ details: `Could not extract video ID from: ${url}`,
421
+ };
422
+ }
423
+
424
+ const unlistedHash = extractUnlistedHash(url);
425
+ const fetchResult = await getVimeoVideoInfo(videoId, unlistedHash);
426
+
427
+ if (!fetchResult.success || !fetchResult.info) {
428
+ const result: VimeoDownloadResult = {
429
+ success: false,
430
+ error: fetchResult.error ?? "Could not fetch video info",
431
+ };
432
+ if (fetchResult.errorCode) {
433
+ result.errorCode = fetchResult.errorCode;
434
+ }
435
+ if (fetchResult.details) {
436
+ result.details = fetchResult.details;
437
+ }
438
+ return result;
439
+ }
440
+
441
+ const info = fetchResult.info;
442
+
443
+ // Ensure output directory exists
444
+ const dir = dirname(outputPath);
445
+ if (!existsSync(dir)) {
446
+ mkdirSync(dir, { recursive: true });
447
+ }
448
+
449
+ // Prefer progressive (direct MP4) download - simpler and often better quality
450
+ if (info.progressiveUrl) {
451
+ const result = await downloadProgressiveVideo(info.progressiveUrl, outputPath, onProgress);
452
+ if (result.success) {
453
+ return result;
454
+ }
455
+ // Fall through to HLS if progressive fails
456
+ }
457
+
458
+ // Try HLS download
459
+ if (info.hlsUrl) {
460
+ return downloadHlsVideo(info.hlsUrl, outputPath, onProgress);
461
+ }
462
+
463
+ return {
464
+ success: false,
465
+ error: "No downloadable streams available",
466
+ errorCode: "NO_STREAM",
467
+ };
468
+ }
469
+
470
+ /**
471
+ * Downloads a progressive (direct) video file.
472
+ */
473
+ async function downloadProgressiveVideo(
474
+ url: string,
475
+ outputPath: string,
476
+ onProgress?: (progress: DownloadProgress) => void
477
+ ): Promise<VimeoDownloadResult> {
478
+ const tempPath = `${outputPath}.tmp`;
479
+
480
+ try {
481
+ const response = await fetch(url, {
482
+ headers: {
483
+ "User-Agent": USER_AGENT,
484
+ Referer: "https://player.vimeo.com/",
485
+ },
486
+ });
487
+
488
+ if (!response.ok) {
489
+ return {
490
+ success: false,
491
+ error: `Download failed: HTTP ${response.status}`,
492
+ errorCode: "DOWNLOAD_FAILED",
493
+ };
494
+ }
495
+
496
+ const contentLength = response.headers.get("content-length");
497
+ const total = contentLength ? parseInt(contentLength, 10) : 0;
498
+
499
+ if (!response.body) {
500
+ return {
501
+ success: false,
502
+ error: "No response body",
503
+ errorCode: "DOWNLOAD_FAILED",
504
+ };
505
+ }
506
+
507
+ const fileStream = createWriteStream(tempPath);
508
+ const reader = response.body.getReader();
509
+ let downloaded = 0;
510
+
511
+ while (true) {
512
+ const { done, value } = await reader.read();
513
+ if (done) break;
514
+
515
+ fileStream.write(Buffer.from(value));
516
+ downloaded += value.length;
517
+
518
+ if (onProgress && total > 0) {
519
+ onProgress({
520
+ percent: (downloaded / total) * 100,
521
+ downloaded,
522
+ total,
523
+ });
524
+ }
525
+ }
526
+
527
+ await new Promise<void>((resolve, reject) => {
528
+ fileStream.end((err: Error | null) => {
529
+ if (err) {
530
+ reject(err);
531
+ } else {
532
+ resolve();
533
+ }
534
+ });
535
+ });
536
+
537
+ renameSync(tempPath, outputPath);
538
+ return { success: true };
539
+ } catch (error) {
540
+ if (existsSync(tempPath)) {
541
+ unlinkSync(tempPath);
542
+ }
543
+ return {
544
+ success: false,
545
+ error: error instanceof Error ? error.message : String(error),
546
+ errorCode: "DOWNLOAD_FAILED",
547
+ };
548
+ }
549
+ }
550
+
551
+ /**
552
+ * Downloads an HLS video stream.
553
+ */
554
+ async function downloadHlsVideo(
555
+ masterUrl: string,
556
+ outputPath: string,
557
+ onProgress?: (progress: DownloadProgress) => void
558
+ ): Promise<VimeoDownloadResult> {
559
+ try {
560
+ // Fetch master playlist
561
+ const masterResponse = await fetch(masterUrl, {
562
+ headers: {
563
+ "User-Agent": USER_AGENT,
564
+ Referer: "https://player.vimeo.com/",
565
+ },
566
+ });
567
+
568
+ if (!masterResponse.ok) {
569
+ return {
570
+ success: false,
571
+ error: `Failed to fetch HLS playlist: HTTP ${masterResponse.status}`,
572
+ errorCode: "DOWNLOAD_FAILED",
573
+ };
574
+ }
575
+
576
+ const masterPlaylist = await masterResponse.text();
577
+ const lines = masterPlaylist.split("\n");
578
+ const baseUrl = getBaseUrl(masterUrl);
579
+
580
+ // Find best quality video stream
581
+ let bestBandwidth = 0;
582
+ let videoPlaylistUrl: string | null = null;
583
+
584
+ for (let i = 0; i < lines.length; i++) {
585
+ const line = lines[i]?.trim();
586
+ if (!line) continue;
587
+
588
+ if (line.startsWith("#EXT-X-STREAM-INF:")) {
589
+ const bandwidthMatch = /BANDWIDTH=(\d+)/.exec(line);
590
+ const bandwidth = bandwidthMatch?.[1] ? parseInt(bandwidthMatch[1], 10) : 0;
591
+
592
+ const nextLine = lines[i + 1]?.trim();
593
+ if (nextLine && !nextLine.startsWith("#") && bandwidth > bestBandwidth) {
594
+ bestBandwidth = bandwidth;
595
+ videoPlaylistUrl = nextLine.startsWith("http") ? nextLine : baseUrl + nextLine;
596
+ }
597
+ }
598
+ }
599
+
600
+ if (!videoPlaylistUrl) {
601
+ return {
602
+ success: false,
603
+ error: "Could not find video stream in HLS playlist",
604
+ errorCode: "DOWNLOAD_FAILED",
605
+ };
606
+ }
607
+
608
+ // Fetch video playlist and get segments
609
+ const videoResponse = await fetch(videoPlaylistUrl, {
610
+ headers: { "User-Agent": USER_AGENT },
611
+ });
612
+
613
+ if (!videoResponse.ok) {
614
+ return {
615
+ success: false,
616
+ error: `Failed to fetch video playlist: HTTP ${videoResponse.status}`,
617
+ errorCode: "DOWNLOAD_FAILED",
618
+ };
619
+ }
620
+
621
+ const videoPlaylist = await videoResponse.text();
622
+ const videoBaseUrl = getBaseUrl(videoPlaylistUrl);
623
+ const segments: string[] = [];
624
+
625
+ for (const line of videoPlaylist.split("\n")) {
626
+ const trimmed = line.trim();
627
+ if (trimmed && !trimmed.startsWith("#")) {
628
+ const segmentUrl = trimmed.startsWith("http") ? trimmed : videoBaseUrl + trimmed;
629
+ segments.push(segmentUrl);
630
+ }
631
+ }
632
+
633
+ if (segments.length === 0) {
634
+ return {
635
+ success: false,
636
+ error: "No segments found in video playlist",
637
+ errorCode: "DOWNLOAD_FAILED",
638
+ };
639
+ }
640
+
641
+ // Download all segments
642
+ const tempPath = `${outputPath}.tmp`;
643
+ const fileStream = createWriteStream(tempPath);
644
+
645
+ for (let i = 0; i < segments.length; i++) {
646
+ const segmentUrl = segments[i];
647
+ if (!segmentUrl) continue;
648
+
649
+ const segResponse = await fetch(segmentUrl, {
650
+ headers: { "User-Agent": USER_AGENT },
651
+ });
652
+
653
+ if (!segResponse.ok || !segResponse.body) continue;
654
+
655
+ const reader = segResponse.body.getReader();
656
+ while (true) {
657
+ const { done, value } = await reader.read();
658
+ if (done) break;
659
+ fileStream.write(Buffer.from(value));
660
+ }
661
+
662
+ if (onProgress) {
663
+ onProgress({
664
+ percent: ((i + 1) / segments.length) * 100,
665
+ downloaded: i + 1,
666
+ total: segments.length,
667
+ });
668
+ }
669
+ }
670
+
671
+ await new Promise<void>((resolve, reject) => {
672
+ fileStream.end((err: Error | null) => {
673
+ if (err) {
674
+ reject(err);
675
+ } else {
676
+ resolve();
677
+ }
678
+ });
679
+ });
680
+
681
+ renameSync(tempPath, outputPath);
682
+ return { success: true };
683
+ } catch (error) {
684
+ return {
685
+ success: false,
686
+ error: error instanceof Error ? error.message : String(error),
687
+ errorCode: "DOWNLOAD_FAILED",
688
+ };
689
+ }
690
+ }
691
+
692
+ /**
693
+ * Vimeo config response type (partial).
694
+ */
695
+ interface VimeoConfig {
696
+ video?: {
697
+ id?: number;
698
+ title?: string;
699
+ duration?: number;
700
+ width?: number;
701
+ height?: number;
702
+ drm?: boolean;
703
+ };
704
+ request?: {
705
+ drm?: boolean;
706
+ files?: {
707
+ hls?: {
708
+ cdns?: Record<string, { url?: string }>;
709
+ };
710
+ dash?: {
711
+ cdns?: Record<string, { url?: string }>;
712
+ };
713
+ progressive?: {
714
+ url?: string;
715
+ quality?: string;
716
+ width?: number;
717
+ height?: number;
718
+ }[];
719
+ };
720
+ };
721
+ }
722
+ /* v8 ignore stop */