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.
- package/.github/workflows/ci.yml +50 -0
- package/.husky/commit-msg +2 -0
- package/.husky/pre-commit +1 -0
- package/.husky/pre-push +3 -0
- package/.prettierrc +8 -0
- package/.release-it.json +23 -0
- package/ARCHITECTURE.md +233 -0
- package/CHANGELOG.md +78 -0
- package/README.md +256 -16
- package/commitlint.config.js +4 -0
- package/dist/ai/openRouter.d.ts +47 -0
- package/dist/ai/openRouter.d.ts.map +1 -0
- package/dist/ai/openRouter.js +116 -0
- package/dist/ai/openRouter.js.map +1 -0
- package/dist/ai/transcriptPolisher.d.ts +24 -0
- package/dist/ai/transcriptPolisher.d.ts.map +1 -0
- package/dist/ai/transcriptPolisher.js +89 -0
- package/dist/ai/transcriptPolisher.js.map +1 -0
- package/dist/cli/commands/config.d.ts +13 -0
- package/dist/cli/commands/config.d.ts.map +1 -0
- package/dist/cli/commands/config.js +66 -0
- package/dist/cli/commands/config.js.map +1 -0
- package/dist/cli/commands/enrich.d.ts +14 -0
- package/dist/cli/commands/enrich.d.ts.map +1 -0
- package/dist/cli/commands/enrich.js +271 -0
- package/dist/cli/commands/enrich.js.map +1 -0
- package/dist/cli/commands/inspect.d.ts +11 -0
- package/dist/cli/commands/inspect.d.ts.map +1 -0
- package/dist/cli/commands/inspect.js +365 -0
- package/dist/cli/commands/inspect.js.map +1 -0
- package/dist/cli/commands/login.d.ts +12 -0
- package/dist/cli/commands/login.d.ts.map +1 -0
- package/dist/cli/commands/login.js +55 -0
- package/dist/cli/commands/login.js.map +1 -0
- package/dist/cli/commands/status.d.ts +15 -0
- package/dist/cli/commands/status.d.ts.map +1 -0
- package/dist/cli/commands/status.js +118 -0
- package/dist/cli/commands/status.js.map +1 -0
- package/dist/cli/commands/sync.d.ts +16 -0
- package/dist/cli/commands/sync.d.ts.map +1 -0
- package/dist/cli/commands/sync.js +922 -0
- package/dist/cli/commands/sync.js.map +1 -0
- package/dist/cli/commands/syncGhl.d.ts +20 -0
- package/dist/cli/commands/syncGhl.d.ts.map +1 -0
- package/dist/cli/commands/syncGhl.js +483 -0
- package/dist/cli/commands/syncGhl.js.map +1 -0
- package/dist/cli/commands/syncHighLevel.d.ts +24 -0
- package/dist/cli/commands/syncHighLevel.d.ts.map +1 -0
- package/dist/cli/commands/syncHighLevel.js +483 -0
- package/dist/cli/commands/syncHighLevel.js.map +1 -0
- package/dist/cli/commands/syncHighLevel.test.d.ts +2 -0
- package/dist/cli/commands/syncHighLevel.test.d.ts.map +1 -0
- package/dist/cli/commands/syncHighLevel.test.js +102 -0
- package/dist/cli/commands/syncHighLevel.test.js.map +1 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +106 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/config/configManager.d.ts +31 -0
- package/dist/config/configManager.d.ts.map +1 -0
- package/dist/config/configManager.js +64 -0
- package/dist/config/configManager.js.map +1 -0
- package/dist/config/paths.d.ts +21 -0
- package/dist/config/paths.d.ts.map +1 -0
- package/dist/config/paths.js +33 -0
- package/dist/config/paths.js.map +1 -0
- package/dist/config/paths.test.d.ts +2 -0
- package/dist/config/paths.test.d.ts.map +1 -0
- package/dist/config/paths.test.js +70 -0
- package/dist/config/paths.test.js.map +1 -0
- package/dist/config/schema.d.ts +60 -0
- package/dist/config/schema.d.ts.map +1 -0
- package/dist/config/schema.js +50 -0
- package/dist/config/schema.js.map +1 -0
- package/dist/config/schema.test.d.ts +2 -0
- package/dist/config/schema.test.d.ts.map +1 -0
- package/dist/config/schema.test.js +151 -0
- package/dist/config/schema.test.js.map +1 -0
- package/dist/downloader/hlsDownloader.d.ts +58 -0
- package/dist/downloader/hlsDownloader.d.ts.map +1 -0
- package/dist/downloader/hlsDownloader.js +254 -0
- package/dist/downloader/hlsDownloader.js.map +1 -0
- package/dist/downloader/hlsDownloader.test.d.ts +2 -0
- package/dist/downloader/hlsDownloader.test.d.ts.map +1 -0
- package/dist/downloader/hlsDownloader.test.js +116 -0
- package/dist/downloader/hlsDownloader.test.js.map +1 -0
- package/dist/downloader/hlsValidator.d.ts +35 -0
- package/dist/downloader/hlsValidator.d.ts.map +1 -0
- package/dist/downloader/hlsValidator.js +148 -0
- package/dist/downloader/hlsValidator.js.map +1 -0
- package/dist/downloader/index.d.ts +26 -0
- package/dist/downloader/index.d.ts.map +1 -0
- package/dist/downloader/index.js +52 -0
- package/dist/downloader/index.js.map +1 -0
- package/dist/downloader/loomDownloader.d.ts +56 -0
- package/dist/downloader/loomDownloader.d.ts.map +1 -0
- package/dist/downloader/loomDownloader.js +559 -0
- package/dist/downloader/loomDownloader.js.map +1 -0
- package/dist/downloader/loomDownloader.test.d.ts +2 -0
- package/dist/downloader/loomDownloader.test.d.ts.map +1 -0
- package/dist/downloader/loomDownloader.test.js +36 -0
- package/dist/downloader/loomDownloader.test.js.map +1 -0
- package/dist/downloader/queue.d.ts +56 -0
- package/dist/downloader/queue.d.ts.map +1 -0
- package/dist/downloader/queue.js +88 -0
- package/dist/downloader/queue.js.map +1 -0
- package/dist/downloader/queue.test.d.ts +2 -0
- package/dist/downloader/queue.test.d.ts.map +1 -0
- package/dist/downloader/queue.test.js +158 -0
- package/dist/downloader/queue.test.js.map +1 -0
- package/dist/downloader/videoDownloader.d.ts +32 -0
- package/dist/downloader/videoDownloader.d.ts.map +1 -0
- package/dist/downloader/videoDownloader.js +173 -0
- package/dist/downloader/videoDownloader.js.map +1 -0
- package/dist/downloader/vimeoDownloader.d.ts +52 -0
- package/dist/downloader/vimeoDownloader.d.ts.map +1 -0
- package/dist/downloader/vimeoDownloader.js +565 -0
- package/dist/downloader/vimeoDownloader.js.map +1 -0
- package/dist/downloader/vimeoDownloader.test.d.ts +2 -0
- package/dist/downloader/vimeoDownloader.test.d.ts.map +1 -0
- package/dist/downloader/vimeoDownloader.test.js +51 -0
- package/dist/downloader/vimeoDownloader.test.js.map +1 -0
- package/dist/scraper/auth.d.ts +29 -0
- package/dist/scraper/auth.d.ts.map +1 -0
- package/dist/scraper/auth.js +115 -0
- package/dist/scraper/auth.js.map +1 -0
- package/dist/scraper/extractor.d.ts +49 -0
- package/dist/scraper/extractor.d.ts.map +1 -0
- package/dist/scraper/extractor.js +627 -0
- package/dist/scraper/extractor.js.map +1 -0
- package/dist/scraper/extractor.test.d.ts +2 -0
- package/dist/scraper/extractor.test.d.ts.map +1 -0
- package/dist/scraper/extractor.test.js +65 -0
- package/dist/scraper/extractor.test.js.map +1 -0
- package/dist/scraper/ghl/auth.d.ts +25 -0
- package/dist/scraper/ghl/auth.d.ts.map +1 -0
- package/dist/scraper/ghl/auth.js +187 -0
- package/dist/scraper/ghl/auth.js.map +1 -0
- package/dist/scraper/ghl/extractor.d.ts +96 -0
- package/dist/scraper/ghl/extractor.d.ts.map +1 -0
- package/dist/scraper/ghl/extractor.js +345 -0
- package/dist/scraper/ghl/extractor.js.map +1 -0
- package/dist/scraper/ghl/index.d.ts +4 -0
- package/dist/scraper/ghl/index.d.ts.map +1 -0
- package/dist/scraper/ghl/index.js +4 -0
- package/dist/scraper/ghl/index.js.map +1 -0
- package/dist/scraper/ghl/navigator.d.ts +93 -0
- package/dist/scraper/ghl/navigator.d.ts.map +1 -0
- package/dist/scraper/ghl/navigator.js +447 -0
- package/dist/scraper/ghl/navigator.js.map +1 -0
- package/dist/scraper/highlevel/auth.d.ts +25 -0
- package/dist/scraper/highlevel/auth.d.ts.map +1 -0
- package/dist/scraper/highlevel/auth.js +189 -0
- package/dist/scraper/highlevel/auth.js.map +1 -0
- package/dist/scraper/highlevel/extractor.d.ts +97 -0
- package/dist/scraper/highlevel/extractor.d.ts.map +1 -0
- package/dist/scraper/highlevel/extractor.js +386 -0
- package/dist/scraper/highlevel/extractor.js.map +1 -0
- package/dist/scraper/highlevel/extractor.test.d.ts +2 -0
- package/dist/scraper/highlevel/extractor.test.d.ts.map +1 -0
- package/dist/scraper/highlevel/extractor.test.js +101 -0
- package/dist/scraper/highlevel/extractor.test.js.map +1 -0
- package/dist/scraper/highlevel/index.d.ts +3 -0
- package/dist/scraper/highlevel/index.d.ts.map +1 -0
- package/dist/scraper/highlevel/index.js +3 -0
- package/dist/scraper/highlevel/index.js.map +1 -0
- package/dist/scraper/highlevel/navigator.d.ts +93 -0
- package/dist/scraper/highlevel/navigator.d.ts.map +1 -0
- package/dist/scraper/highlevel/navigator.js +492 -0
- package/dist/scraper/highlevel/navigator.js.map +1 -0
- package/dist/scraper/highlevel/navigator.test.d.ts +2 -0
- package/dist/scraper/highlevel/navigator.test.d.ts.map +1 -0
- package/dist/scraper/highlevel/navigator.test.js +78 -0
- package/dist/scraper/highlevel/navigator.test.js.map +1 -0
- package/dist/scraper/navigator.d.ts +65 -0
- package/dist/scraper/navigator.d.ts.map +1 -0
- package/dist/scraper/navigator.js +300 -0
- package/dist/scraper/navigator.js.map +1 -0
- package/dist/scraper/navigator.test.d.ts +2 -0
- package/dist/scraper/navigator.test.d.ts.map +1 -0
- package/dist/scraper/navigator.test.js +63 -0
- package/dist/scraper/navigator.test.js.map +1 -0
- package/dist/scraper/skoolApi.d.ts +17 -0
- package/dist/scraper/skoolApi.d.ts.map +1 -0
- package/dist/scraper/skoolApi.js +72 -0
- package/dist/scraper/skoolApi.js.map +1 -0
- package/dist/scraper/videoInterceptor.d.ts +19 -0
- package/dist/scraper/videoInterceptor.d.ts.map +1 -0
- package/dist/scraper/videoInterceptor.js +315 -0
- package/dist/scraper/videoInterceptor.js.map +1 -0
- package/dist/shared/auth.d.ts +58 -0
- package/dist/shared/auth.d.ts.map +1 -0
- package/dist/shared/auth.js +211 -0
- package/dist/shared/auth.js.map +1 -0
- package/dist/shared/fs.d.ts +31 -0
- package/dist/shared/fs.d.ts.map +1 -0
- package/dist/shared/fs.js +73 -0
- package/dist/shared/fs.js.map +1 -0
- package/dist/shared/http.d.ts +15 -0
- package/dist/shared/http.d.ts.map +1 -0
- package/dist/shared/http.js +31 -0
- package/dist/shared/http.js.map +1 -0
- package/dist/shared/index.d.ts +4 -0
- package/dist/shared/index.d.ts.map +1 -0
- package/dist/shared/index.js +4 -0
- package/dist/shared/index.js.map +1 -0
- package/dist/state/database.d.ts +245 -0
- package/dist/state/database.d.ts.map +1 -0
- package/dist/state/database.js +676 -0
- package/dist/state/database.js.map +1 -0
- package/dist/state/database.test.d.ts +2 -0
- package/dist/state/database.test.d.ts.map +1 -0
- package/dist/state/database.test.js +34 -0
- package/dist/state/database.test.js.map +1 -0
- package/dist/state/index.d.ts +2 -0
- package/dist/state/index.d.ts.map +1 -0
- package/dist/state/index.js +2 -0
- package/dist/state/index.js.map +1 -0
- package/dist/storage/fileSystem.d.ts +56 -0
- package/dist/storage/fileSystem.d.ts.map +1 -0
- package/dist/storage/fileSystem.js +121 -0
- package/dist/storage/fileSystem.js.map +1 -0
- package/dist/transcription/whisperService.d.ts +27 -0
- package/dist/transcription/whisperService.d.ts.map +1 -0
- package/dist/transcription/whisperService.js +102 -0
- package/dist/transcription/whisperService.js.map +1 -0
- package/eslint.config.js +55 -0
- package/package.json +68 -11
- package/src/__fixtures__/highlevel-post-response.json +68 -0
- package/src/__fixtures__/hls-master-playlist.m3u8 +24 -0
- package/src/cli/commands/__snapshots__/syncHighLevel.test.ts.snap +38 -0
- package/src/cli/commands/config.ts +74 -0
- package/src/cli/commands/inspect.ts +441 -0
- package/src/cli/commands/login.ts +68 -0
- package/src/cli/commands/status.ts +147 -0
- package/src/cli/commands/sync.ts +1235 -0
- package/src/cli/commands/syncHighLevel.test.ts +144 -0
- package/src/cli/commands/syncHighLevel.ts +639 -0
- package/src/cli/index.ts +121 -0
- package/src/config/configManager.ts +75 -0
- package/src/config/paths.test.ts +83 -0
- package/src/config/paths.ts +36 -0
- package/src/config/schema.test.ts +173 -0
- package/src/config/schema.ts +65 -0
- package/src/downloader/hlsDownloader.test.ts +148 -0
- package/src/downloader/hlsDownloader.ts +327 -0
- package/src/downloader/hlsValidator.ts +196 -0
- package/src/downloader/index.ts +122 -0
- package/src/downloader/loomDownloader.test.ts +43 -0
- package/src/downloader/loomDownloader.ts +742 -0
- package/src/downloader/queue.test.ts +199 -0
- package/src/downloader/queue.ts +118 -0
- package/src/downloader/vimeoDownloader.test.ts +62 -0
- package/src/downloader/vimeoDownloader.ts +722 -0
- package/src/scraper/extractor.test.ts +124 -0
- package/src/scraper/extractor.ts +757 -0
- package/src/scraper/highlevel/__snapshots__/extractor.test.ts.snap +41 -0
- package/src/scraper/highlevel/extractor.test.ts +134 -0
- package/src/scraper/highlevel/extractor.ts +537 -0
- package/src/scraper/highlevel/index.ts +2 -0
- package/src/scraper/highlevel/navigator.test.ts +110 -0
- package/src/scraper/highlevel/navigator.ts +668 -0
- package/src/scraper/highlevel/schemas.ts +183 -0
- package/src/scraper/navigator.test.ts +122 -0
- package/src/scraper/navigator.ts +355 -0
- package/src/scraper/schemas.ts +177 -0
- package/src/scraper/videoInterceptor.ts +435 -0
- package/src/shared/auth.test.ts +58 -0
- package/src/shared/auth.ts +251 -0
- package/src/shared/firebase.ts +151 -0
- package/src/shared/fs.ts +80 -0
- package/src/shared/http.ts +34 -0
- package/src/shared/index.ts +6 -0
- package/src/shared/slug.ts +26 -0
- package/src/shared/url.test.ts +122 -0
- package/src/shared/url.ts +57 -0
- package/src/state/database.test.ts +49 -0
- package/src/state/database.ts +919 -0
- package/src/state/index.ts +14 -0
- package/src/storage/fileSystem.test.ts +64 -0
- package/src/storage/fileSystem.ts +175 -0
- package/tsconfig.json +28 -0
- package/vitest.config.ts +29 -0
- 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 */
|