getraw 0.1.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 (105) hide show
  1. package/.gitattributes +4 -0
  2. package/CLAUDE.md +57 -0
  3. package/README.md +166 -0
  4. package/RESEARCH.md +109 -0
  5. package/STATUS.md +23 -0
  6. package/bun.lock +50 -0
  7. package/bunfig.toml +3 -0
  8. package/docs/plugin-guide.md +166 -0
  9. package/docs/supported-sites.md +41 -0
  10. package/package.json +30 -0
  11. package/src/cli/index.ts +52 -0
  12. package/src/cli/options.ts +97 -0
  13. package/src/core/format-sorter.ts +208 -0
  14. package/src/core/logger.ts +101 -0
  15. package/src/core/orchestrator.ts +140 -0
  16. package/src/core/output-template.ts +58 -0
  17. package/src/core/types.ts +237 -0
  18. package/src/downloaders/base.ts +25 -0
  19. package/src/downloaders/dash.ts +287 -0
  20. package/src/downloaders/fragment.ts +226 -0
  21. package/src/downloaders/hls.ts +170 -0
  22. package/src/downloaders/http.ts +260 -0
  23. package/src/extractors/archive-org.ts +126 -0
  24. package/src/extractors/bandcamp.ts +130 -0
  25. package/src/extractors/base.ts +29 -0
  26. package/src/extractors/bilibili/bangumi.ts +205 -0
  27. package/src/extractors/bilibili/index.ts +233 -0
  28. package/src/extractors/bilibili/wbi.ts +60 -0
  29. package/src/extractors/coub.ts +137 -0
  30. package/src/extractors/dailymotion.ts +99 -0
  31. package/src/extractors/dropbox.ts +52 -0
  32. package/src/extractors/generic.ts +118 -0
  33. package/src/extractors/google-drive.ts +106 -0
  34. package/src/extractors/imgur.ts +156 -0
  35. package/src/extractors/instagram/index.ts +263 -0
  36. package/src/extractors/instagram/reels.ts +166 -0
  37. package/src/extractors/kick/clips.ts +91 -0
  38. package/src/extractors/kick/index.ts +118 -0
  39. package/src/extractors/kick/live.ts +89 -0
  40. package/src/extractors/niconico/index.ts +209 -0
  41. package/src/extractors/odysee.ts +126 -0
  42. package/src/extractors/peertube.ts +143 -0
  43. package/src/extractors/reddit/gallery.ts +124 -0
  44. package/src/extractors/reddit/index.ts +203 -0
  45. package/src/extractors/rumble.ts +127 -0
  46. package/src/extractors/soundcloud/index.ts +161 -0
  47. package/src/extractors/soundcloud/playlist.ts +129 -0
  48. package/src/extractors/spotify.ts +97 -0
  49. package/src/extractors/streamable.ts +121 -0
  50. package/src/extractors/ted.ts +151 -0
  51. package/src/extractors/tiktok/index.ts +207 -0
  52. package/src/extractors/tiktok/user.ts +176 -0
  53. package/src/extractors/twitch/clips.ts +125 -0
  54. package/src/extractors/twitch/index.ts +136 -0
  55. package/src/extractors/twitch/live.ts +132 -0
  56. package/src/extractors/twitter/index.ts +140 -0
  57. package/src/extractors/twitter/spaces.ts +200 -0
  58. package/src/extractors/vimeo/index.ts +187 -0
  59. package/src/extractors/youtube/captions.ts +111 -0
  60. package/src/extractors/youtube/index.ts +252 -0
  61. package/src/extractors/youtube/innertube.ts +364 -0
  62. package/src/extractors/youtube/nsig.ts +105 -0
  63. package/src/extractors/youtube/playlist.ts +227 -0
  64. package/src/extractors/youtube/signature.ts +163 -0
  65. package/src/networking/client.ts +311 -0
  66. package/src/networking/cookies.ts +138 -0
  67. package/src/networking/proxy.ts +132 -0
  68. package/src/networking/tls.ts +67 -0
  69. package/src/networking/user-agents.ts +88 -0
  70. package/src/postprocessors/base.ts +44 -0
  71. package/src/postprocessors/extract-audio.ts +98 -0
  72. package/src/postprocessors/ffmpeg.ts +146 -0
  73. package/src/postprocessors/merge.ts +102 -0
  74. package/src/postprocessors/metadata.ts +73 -0
  75. package/src/postprocessors/sponsorblock.ts +162 -0
  76. package/src/postprocessors/subtitles.ts +285 -0
  77. package/src/postprocessors/thumbnails.ts +194 -0
  78. package/src/utils/sanitize.ts +36 -0
  79. package/src/utils/traverse.ts +68 -0
  80. package/tests/core/format-sorter.test.ts +96 -0
  81. package/tests/core/output-template.test.ts +56 -0
  82. package/tests/core/types.test.ts +79 -0
  83. package/tests/unit/downloaders/dash.test.ts +57 -0
  84. package/tests/unit/downloaders/hls.test.ts +120 -0
  85. package/tests/unit/downloaders/http.test.ts +114 -0
  86. package/tests/unit/extractors/bilibili.test.ts +83 -0
  87. package/tests/unit/extractors/instagram.test.ts +273 -0
  88. package/tests/unit/extractors/kick.test.ts +85 -0
  89. package/tests/unit/extractors/misc.test.ts +942 -0
  90. package/tests/unit/extractors/niconico.test.ts +61 -0
  91. package/tests/unit/extractors/reddit.test.ts +222 -0
  92. package/tests/unit/extractors/soundcloud.test.ts +299 -0
  93. package/tests/unit/extractors/tiktok.test.ts +260 -0
  94. package/tests/unit/extractors/twitch.test.ts +250 -0
  95. package/tests/unit/extractors/twitter.test.ts +181 -0
  96. package/tests/unit/extractors/vimeo.test.ts +253 -0
  97. package/tests/unit/extractors/youtube.test.ts +259 -0
  98. package/tests/unit/networking/client.test.ts +272 -0
  99. package/tests/unit/networking/cookies.test.ts +256 -0
  100. package/tests/unit/networking/proxy.test.ts +137 -0
  101. package/tests/unit/postprocessors/extract-audio.test.ts +63 -0
  102. package/tests/unit/postprocessors/merge.test.ts +61 -0
  103. package/tests/unit/postprocessors/subtitles.test.ts +89 -0
  104. package/tools/dashboard.ts +112 -0
  105. package/tsconfig.json +17 -0
@@ -0,0 +1,226 @@
1
+ import { DownloadError } from "../core/types";
2
+ import type { DownloadOptions, DownloadProgress } from "../core/types";
3
+ import { logger } from "../core/logger";
4
+
5
+ export interface Segment {
6
+ url: string;
7
+ index: number;
8
+ byteRange?: { start: number; end: number };
9
+ key?: { method: string; uri: string; iv?: string };
10
+ isInit?: boolean;
11
+ }
12
+
13
+ export interface FragmentDownloadOptions extends DownloadOptions {
14
+ concurrency?: number;
15
+ tempDir?: string;
16
+ }
17
+
18
+ export class FragmentDownloader {
19
+ private completedSegments = new Set<number>();
20
+
21
+ async downloadSegments(
22
+ segments: Segment[],
23
+ outputPath: string,
24
+ options: FragmentDownloadOptions,
25
+ ): Promise<void> {
26
+ const concurrency = options.concurrency ?? 8;
27
+ const tempDir = options.tempDir ?? "/tmp/dlpx-fragments";
28
+
29
+ await Bun.$`mkdir -p ${tempDir}`.quiet();
30
+
31
+ const stateFile = `${tempDir}/completed.json`;
32
+ try {
33
+ const stateData = await Bun.file(stateFile).text();
34
+ const state = JSON.parse(stateData) as { completed: number[] };
35
+ this.completedSegments = new Set(state.completed);
36
+ } catch {
37
+ this.completedSegments = new Set();
38
+ }
39
+
40
+ const pending = segments.filter((s) => !this.completedSegments.has(s.index));
41
+ const total = segments.length;
42
+ let done = this.completedSegments.size;
43
+
44
+ const keyCache = new Map<string, Uint8Array>();
45
+
46
+ const downloadSegment = async (seg: Segment): Promise<void> => {
47
+ const segPath = `${tempDir}/seg-${seg.index.toString().padStart(6, "0")}`;
48
+ const retries = options.retries ?? 3;
49
+
50
+ for (let attempt = 1; attempt <= retries; attempt++) {
51
+ try {
52
+ const headers: Record<string, string> = { ...options.headers };
53
+ if (seg.byteRange) {
54
+ headers["Range"] = `bytes=${seg.byteRange.start}-${seg.byteRange.end}`;
55
+ }
56
+
57
+ const resp = await fetch(seg.url, { headers });
58
+ if (!resp.ok) {
59
+ throw new DownloadError(`HTTP ${resp.status} on segment ${seg.index}`);
60
+ }
61
+
62
+ let data = new Uint8Array(await resp.arrayBuffer());
63
+
64
+ if (seg.key && seg.key.method === "AES-128") {
65
+ data = await this.decryptAes128(data, seg.key, seg.index, keyCache, options.headers);
66
+ }
67
+
68
+ await Bun.write(segPath, data);
69
+ this.completedSegments.add(seg.index);
70
+ await Bun.write(
71
+ stateFile,
72
+ JSON.stringify({ completed: Array.from(this.completedSegments) }),
73
+ );
74
+
75
+ done++;
76
+ const percent = (done / total) * 100;
77
+ if (options.onProgress) {
78
+ const progress: DownloadProgress = {
79
+ downloaded_bytes: done,
80
+ total_bytes: total,
81
+ speed: null,
82
+ eta: null,
83
+ percent,
84
+ status: "downloading",
85
+ filename: outputPath,
86
+ };
87
+ options.onProgress(progress);
88
+ }
89
+
90
+ return;
91
+ } catch (err) {
92
+ if (attempt === retries) throw err;
93
+ const delay = 1000 * Math.pow(2, attempt - 1);
94
+ logger.warn(`Segment ${seg.index} attempt ${attempt} failed, retrying in ${delay}ms`);
95
+ await sleep(delay);
96
+ }
97
+ }
98
+ };
99
+
100
+ await runConcurrent(pending, concurrency, downloadSegment);
101
+
102
+ await this.concatenateSegments(segments, tempDir, outputPath);
103
+
104
+ try {
105
+ await Bun.$`rm -rf ${tempDir}`.quiet();
106
+ } catch {
107
+ // ignore cleanup errors
108
+ }
109
+
110
+ if (options.onProgress) {
111
+ options.onProgress({
112
+ downloaded_bytes: total,
113
+ total_bytes: total,
114
+ speed: null,
115
+ eta: null,
116
+ percent: 100,
117
+ status: "finished",
118
+ filename: outputPath,
119
+ });
120
+ }
121
+ }
122
+
123
+ private async concatenateSegments(
124
+ segments: Segment[],
125
+ tempDir: string,
126
+ outputPath: string,
127
+ ): Promise<void> {
128
+ const writer = Bun.file(outputPath).writer();
129
+ const ordered = segments.slice().sort((a, b) => a.index - b.index);
130
+
131
+ for (const seg of ordered) {
132
+ const segPath = `${tempDir}/seg-${seg.index.toString().padStart(6, "0")}`;
133
+ try {
134
+ const data = await Bun.file(segPath).arrayBuffer();
135
+ writer.write(new Uint8Array(data));
136
+ } catch (err) {
137
+ throw new DownloadError(`Missing segment ${seg.index}: ${err instanceof Error ? err.message : String(err)}`);
138
+ }
139
+ }
140
+
141
+ await writer.end();
142
+ }
143
+
144
+ private async decryptAes128(
145
+ data: Uint8Array,
146
+ key: { method: string; uri: string; iv?: string },
147
+ segmentIndex: number,
148
+ keyCache: Map<string, Uint8Array>,
149
+ headers?: Record<string, string>,
150
+ ): Promise<Uint8Array> {
151
+ let keyBytes = keyCache.get(key.uri);
152
+ if (!keyBytes) {
153
+ const resp = await fetch(key.uri, { headers: headers ?? {} });
154
+ if (!resp.ok) {
155
+ throw new DownloadError(`Failed to fetch AES key: HTTP ${resp.status}`);
156
+ }
157
+ keyBytes = new Uint8Array(await resp.arrayBuffer());
158
+ keyCache.set(key.uri, keyBytes);
159
+ }
160
+
161
+ const iv = key.iv
162
+ ? hexToBytes(key.iv.replace("0x", "").replace("0X", ""))
163
+ : segmentIndexToIv(segmentIndex);
164
+
165
+ const cryptoKey = await crypto.subtle.importKey(
166
+ "raw",
167
+ keyBytes,
168
+ { name: "AES-CBC" },
169
+ false,
170
+ ["decrypt"],
171
+ );
172
+
173
+ const decrypted = await crypto.subtle.decrypt(
174
+ { name: "AES-CBC", iv },
175
+ cryptoKey,
176
+ data,
177
+ );
178
+
179
+ return new Uint8Array(decrypted);
180
+ }
181
+ }
182
+
183
+ function hexToBytes(hex: string): Uint8Array {
184
+ const bytes = new Uint8Array(hex.length / 2);
185
+ for (let i = 0; i < hex.length; i += 2) {
186
+ bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16);
187
+ }
188
+ return bytes;
189
+ }
190
+
191
+ function segmentIndexToIv(index: number): Uint8Array {
192
+ const iv = new Uint8Array(16);
193
+ const view = new DataView(iv.buffer);
194
+ view.setUint32(12, index, false);
195
+ return iv;
196
+ }
197
+
198
+ async function runConcurrent<T>(
199
+ items: T[],
200
+ concurrency: number,
201
+ fn: (item: T) => Promise<void>,
202
+ ): Promise<void> {
203
+ const queue = [...items];
204
+ const workers: Promise<void>[] = [];
205
+
206
+ const worker = async (): Promise<void> => {
207
+ while (queue.length > 0) {
208
+ const item = queue.shift();
209
+ if (item !== undefined) {
210
+ await fn(item);
211
+ }
212
+ }
213
+ };
214
+
215
+ for (let i = 0; i < Math.min(concurrency, items.length); i++) {
216
+ workers.push(worker());
217
+ }
218
+
219
+ await Promise.all(workers);
220
+ }
221
+
222
+ function sleep(ms: number): Promise<void> {
223
+ return new Promise((resolve) => setTimeout(resolve, ms));
224
+ }
225
+
226
+ export { runConcurrent, sleep };
@@ -0,0 +1,170 @@
1
+ import { Downloader, DownloadError } from "../core/types";
2
+ import type { DownloadOptions } from "../core/types";
3
+ import { FragmentDownloader } from "./fragment";
4
+ import type { Segment } from "./fragment";
5
+ import { logger } from "../core/logger";
6
+
7
+ interface HlsKey {
8
+ method: string;
9
+ uri: string;
10
+ iv?: string;
11
+ }
12
+
13
+ interface HlsSegmentRaw {
14
+ uri: string;
15
+ key?: HlsKey;
16
+ byteRange?: { length: number; offset?: number };
17
+ map?: { uri: string; byteRange?: { length: number; offset?: number } };
18
+ }
19
+
20
+ interface HlsVariant {
21
+ uri: string;
22
+ bandwidth?: number;
23
+ resolution?: { width: number; height: number };
24
+ codecs?: string;
25
+ }
26
+
27
+ interface HlsMasterPlaylist {
28
+ isMasterPlaylist: true;
29
+ variants: HlsVariant[];
30
+ }
31
+
32
+ interface HlsMediaPlaylist {
33
+ isMasterPlaylist: false;
34
+ segments: HlsSegmentRaw[];
35
+ endList?: boolean;
36
+ }
37
+
38
+ type HlsPlaylist = HlsMasterPlaylist | HlsMediaPlaylist;
39
+
40
+ export class HlsDownloader extends Downloader {
41
+ readonly protocol = "m3u8";
42
+
43
+ canHandle(protocol: string): boolean {
44
+ return protocol === "m3u8" || protocol === "hls";
45
+ }
46
+
47
+ async download(
48
+ url: string,
49
+ filepath: string,
50
+ options: DownloadOptions,
51
+ ): Promise<void> {
52
+ const retries = options.retries ?? 3;
53
+ let lastError: Error | null = null;
54
+
55
+ for (let attempt = 1; attempt <= retries; attempt++) {
56
+ try {
57
+ await this.downloadHls(url, filepath, options);
58
+ return;
59
+ } catch (err) {
60
+ lastError = err instanceof Error ? err : new Error(String(err));
61
+ if (attempt < retries) {
62
+ const delay = 1000 * Math.pow(2, attempt - 1);
63
+ logger.warn(`HLS download failed (attempt ${attempt}/${retries}): ${lastError.message}`);
64
+ await new Promise((r) => setTimeout(r, delay));
65
+ }
66
+ }
67
+ }
68
+
69
+ throw new DownloadError(`HLS download failed after ${retries} attempts: ${lastError?.message}`);
70
+ }
71
+
72
+ private async fetchManifest(url: string, headers: Record<string, string>): Promise<string> {
73
+ const resp = await fetch(url, { headers });
74
+ if (!resp.ok) {
75
+ throw new DownloadError(`Failed to fetch manifest: HTTP ${resp.status}`);
76
+ }
77
+ return resp.text();
78
+ }
79
+
80
+ private async downloadHls(url: string, filepath: string, options: DownloadOptions): Promise<void> {
81
+ const { parse } = await import("hls-parser");
82
+ const headers = { ...options.headers };
83
+
84
+ const manifestText = await this.fetchManifest(url, headers);
85
+ const parsed = parse(manifestText) as HlsPlaylist;
86
+
87
+ if (parsed.isMasterPlaylist) {
88
+ const master = parsed as HlsMasterPlaylist;
89
+ if (master.variants.length === 0) {
90
+ throw new DownloadError("HLS master playlist has no variants");
91
+ }
92
+ const best = master.variants.reduce((a, b) =>
93
+ (b.bandwidth ?? 0) > (a.bandwidth ?? 0) ? b : a,
94
+ );
95
+ const mediaUrl = resolveUrl(url, best.uri);
96
+ logger.debug(`HLS: selected variant ${mediaUrl} (bandwidth: ${best.bandwidth ?? "unknown"})`);
97
+
98
+ const mediaText = await this.fetchManifest(mediaUrl, headers);
99
+ const mediaParsed = parse(mediaText) as HlsMediaPlaylist;
100
+ await this.downloadMediaPlaylist(mediaParsed, mediaUrl, filepath, options);
101
+ } else {
102
+ await this.downloadMediaPlaylist(parsed as HlsMediaPlaylist, url, filepath, options);
103
+ }
104
+ }
105
+
106
+ private async downloadMediaPlaylist(
107
+ playlist: HlsMediaPlaylist,
108
+ baseUrl: string,
109
+ filepath: string,
110
+ options: DownloadOptions,
111
+ ): Promise<void> {
112
+ const rawSegments = playlist.segments;
113
+ if (!rawSegments || rawSegments.length === 0) {
114
+ throw new DownloadError("HLS media playlist has no segments");
115
+ }
116
+
117
+ const segments: Segment[] = [];
118
+ let idx = 0;
119
+
120
+ for (const raw of rawSegments) {
121
+ if (raw.map) {
122
+ const mapUrl = resolveUrl(baseUrl, raw.map.uri);
123
+ segments.push({
124
+ url: mapUrl,
125
+ index: idx++,
126
+ isInit: true,
127
+ byteRange: raw.map.byteRange
128
+ ? { start: raw.map.byteRange.offset ?? 0, end: (raw.map.byteRange.offset ?? 0) + raw.map.byteRange.length - 1 }
129
+ : undefined,
130
+ });
131
+ }
132
+
133
+ const segUrl = resolveUrl(baseUrl, raw.uri);
134
+ const seg: Segment = {
135
+ url: segUrl,
136
+ index: idx++,
137
+ };
138
+
139
+ if (raw.key) {
140
+ seg.key = {
141
+ method: raw.key.method,
142
+ uri: resolveUrl(baseUrl, raw.key.uri),
143
+ iv: raw.key.iv,
144
+ };
145
+ }
146
+
147
+ if (raw.byteRange) {
148
+ seg.byteRange = {
149
+ start: raw.byteRange.offset ?? 0,
150
+ end: (raw.byteRange.offset ?? 0) + raw.byteRange.length - 1,
151
+ };
152
+ }
153
+
154
+ segments.push(seg);
155
+ }
156
+
157
+ const tempDir = `/tmp/dlpx-hls-${Date.now()}`;
158
+ const fragmenter = new FragmentDownloader();
159
+ await fragmenter.downloadSegments(segments, filepath, {
160
+ ...options,
161
+ concurrency: 8,
162
+ tempDir,
163
+ });
164
+ }
165
+ }
166
+
167
+ function resolveUrl(base: string, relative: string): string {
168
+ if (/^https?:\/\//i.test(relative)) return relative;
169
+ return new URL(relative, base).toString();
170
+ }
@@ -0,0 +1,260 @@
1
+ import { Downloader, DownloadError } from "../core/types";
2
+ import type { DownloadOptions, DownloadProgress } from "../core/types";
3
+ import { logger } from "../core/logger";
4
+
5
+ const CHUNK_SIZE = 8 * 1024 * 1024;
6
+ const DEFAULT_CONCURRENCY = 4;
7
+
8
+ export class HttpDownloader extends Downloader {
9
+ readonly protocol = "https";
10
+
11
+ canHandle(protocol: string): boolean {
12
+ return protocol === "http" || protocol === "https";
13
+ }
14
+
15
+ async download(
16
+ url: string,
17
+ filepath: string,
18
+ options: DownloadOptions,
19
+ ): Promise<void> {
20
+ const retries = options.retries ?? 3;
21
+ let lastError: Error | null = null;
22
+
23
+ for (let attempt = 1; attempt <= retries; attempt++) {
24
+ try {
25
+ await this.downloadAttempt(url, filepath, options, attempt > 1);
26
+ return;
27
+ } catch (err) {
28
+ lastError = err instanceof Error ? err : new Error(String(err));
29
+ if (attempt < retries) {
30
+ const delay = 1000 * Math.pow(2, attempt - 1);
31
+ logger.warn(`Download failed (attempt ${attempt}/${retries}): ${lastError.message}, retrying in ${delay}ms`);
32
+ await sleep(delay);
33
+ }
34
+ }
35
+ }
36
+
37
+ throw new DownloadError(
38
+ `Download failed after ${retries} attempts: ${lastError?.message}`,
39
+ );
40
+ }
41
+
42
+ private buildHeaders(options: DownloadOptions): Record<string, string> {
43
+ return { ...options.headers };
44
+ }
45
+
46
+ private async getContentLength(url: string, headers: Record<string, string>): Promise<number | null> {
47
+ try {
48
+ const resp = await fetch(url, { method: "HEAD", headers });
49
+ if (resp.ok) {
50
+ const cl = resp.headers.get("content-length");
51
+ const acceptRanges = resp.headers.get("accept-ranges");
52
+ if (cl && acceptRanges === "bytes") {
53
+ return parseInt(cl, 10);
54
+ }
55
+ }
56
+ } catch {
57
+ // fall through
58
+ }
59
+ return null;
60
+ }
61
+
62
+ private async downloadAttempt(
63
+ url: string,
64
+ filepath: string,
65
+ options: DownloadOptions,
66
+ isResume: boolean,
67
+ ): Promise<void> {
68
+ const headers = this.buildHeaders(options);
69
+
70
+ let existingBytes = 0;
71
+ if (isResume) {
72
+ try {
73
+ const file = Bun.file(filepath);
74
+ existingBytes = file.size;
75
+ if (existingBytes > 0) {
76
+ headers["Range"] = `bytes=${existingBytes}-`;
77
+ }
78
+ } catch {
79
+ existingBytes = 0;
80
+ }
81
+ }
82
+
83
+ const headHeaders = this.buildHeaders(options);
84
+ const contentLength = isResume ? null : await this.getContentLength(url, headHeaders);
85
+
86
+ if (contentLength && contentLength > CHUNK_SIZE * 2 && !isResume) {
87
+ await this.downloadConcurrent(url, filepath, contentLength, options);
88
+ return;
89
+ }
90
+
91
+ const response = await fetch(url, { headers });
92
+ if (!response.ok && response.status !== 206) {
93
+ throw new DownloadError(`HTTP ${response.status}: ${response.statusText}`);
94
+ }
95
+
96
+ const totalStr = response.headers.get("content-length");
97
+ const totalBytes = totalStr ? parseInt(totalStr, 10) + existingBytes : null;
98
+
99
+ const body = response.body;
100
+ if (!body) {
101
+ throw new DownloadError("Empty response body");
102
+ }
103
+
104
+ const writer = Bun.file(filepath).writer();
105
+ const reader = body.getReader();
106
+ let downloadedBytes = existingBytes;
107
+ const startTime = Date.now();
108
+ const rateLimit = options.rateLimit ?? null;
109
+
110
+ try {
111
+ while (true) {
112
+ const { done, value } = await reader.read();
113
+ if (done) break;
114
+
115
+ writer.write(value);
116
+ downloadedBytes += value.byteLength;
117
+
118
+ if (rateLimit && rateLimit > 0) {
119
+ const elapsed = (Date.now() - startTime) / 1000;
120
+ const expectedTime = (downloadedBytes - existingBytes) / rateLimit;
121
+ if (elapsed < expectedTime) {
122
+ await sleep((expectedTime - elapsed) * 1000);
123
+ }
124
+ }
125
+
126
+ if (options.onProgress) {
127
+ const elapsed = (Date.now() - startTime) / 1000;
128
+ const speed = elapsed > 0 ? (downloadedBytes - existingBytes) / elapsed : 0;
129
+ const remaining = totalBytes ? (totalBytes - downloadedBytes) / (speed || 1) : null;
130
+ const percent = totalBytes ? (downloadedBytes / totalBytes) * 100 : null;
131
+
132
+ const progress: DownloadProgress = {
133
+ downloaded_bytes: downloadedBytes,
134
+ total_bytes: totalBytes,
135
+ speed,
136
+ eta: remaining,
137
+ percent,
138
+ status: "downloading",
139
+ filename: filepath,
140
+ };
141
+ options.onProgress(progress);
142
+ }
143
+ }
144
+
145
+ await writer.end();
146
+
147
+ if (options.onProgress) {
148
+ options.onProgress({
149
+ downloaded_bytes: downloadedBytes,
150
+ total_bytes: totalBytes,
151
+ speed: null,
152
+ eta: null,
153
+ percent: 100,
154
+ status: "finished",
155
+ filename: filepath,
156
+ });
157
+ }
158
+ } catch (err) {
159
+ await writer.end();
160
+ throw err;
161
+ }
162
+ }
163
+
164
+ private async downloadConcurrent(
165
+ url: string,
166
+ filepath: string,
167
+ totalBytes: number,
168
+ options: DownloadOptions,
169
+ ): Promise<void> {
170
+ const concurrency = DEFAULT_CONCURRENCY;
171
+ const chunks: Array<{ start: number; end: number; index: number }> = [];
172
+
173
+ for (let i = 0, idx = 0; i < totalBytes; i += CHUNK_SIZE, idx++) {
174
+ chunks.push({ start: i, end: Math.min(i + CHUNK_SIZE - 1, totalBytes - 1), index: idx });
175
+ }
176
+
177
+ const tempDir = `/tmp/dlpx-http-${Date.now()}`;
178
+ await Bun.$`mkdir -p ${tempDir}`.quiet();
179
+
180
+ const baseHeaders = this.buildHeaders(options);
181
+ let downloadedBytes = 0;
182
+ const startTime = Date.now();
183
+
184
+ const downloadChunk = async (chunk: { start: number; end: number; index: number }): Promise<void> => {
185
+ const headers = { ...baseHeaders, Range: `bytes=${chunk.start}-${chunk.end}` };
186
+ const retries = options.retries ?? 3;
187
+
188
+ for (let attempt = 1; attempt <= retries; attempt++) {
189
+ try {
190
+ const resp = await fetch(url, { headers });
191
+ if (!resp.ok && resp.status !== 206) {
192
+ throw new DownloadError(`HTTP ${resp.status} on chunk ${chunk.index}`);
193
+ }
194
+ const data = new Uint8Array(await resp.arrayBuffer());
195
+ await Bun.write(`${tempDir}/chunk-${chunk.index.toString().padStart(6, "0")}`, data);
196
+
197
+ downloadedBytes += data.byteLength;
198
+
199
+ if (options.onProgress) {
200
+ const elapsed = (Date.now() - startTime) / 1000;
201
+ const speed = elapsed > 0 ? downloadedBytes / elapsed : 0;
202
+ const remaining = speed > 0 ? (totalBytes - downloadedBytes) / speed : null;
203
+
204
+ options.onProgress({
205
+ downloaded_bytes: downloadedBytes,
206
+ total_bytes: totalBytes,
207
+ speed,
208
+ eta: remaining,
209
+ percent: (downloadedBytes / totalBytes) * 100,
210
+ status: "downloading",
211
+ filename: filepath,
212
+ });
213
+ }
214
+ return;
215
+ } catch (err) {
216
+ if (attempt === retries) throw err;
217
+ await sleep(1000 * Math.pow(2, attempt - 1));
218
+ }
219
+ }
220
+ };
221
+
222
+ const queue = [...chunks];
223
+ const workers: Promise<void>[] = [];
224
+ const worker = async (): Promise<void> => {
225
+ while (queue.length > 0) {
226
+ const chunk = queue.shift();
227
+ if (chunk !== undefined) await downloadChunk(chunk);
228
+ }
229
+ };
230
+ for (let i = 0; i < Math.min(concurrency, chunks.length); i++) {
231
+ workers.push(worker());
232
+ }
233
+ await Promise.all(workers);
234
+
235
+ const writer = Bun.file(filepath).writer();
236
+ for (const chunk of chunks) {
237
+ const data = await Bun.file(`${tempDir}/chunk-${chunk.index.toString().padStart(6, "0")}`).arrayBuffer();
238
+ writer.write(new Uint8Array(data));
239
+ }
240
+ await writer.end();
241
+
242
+ await Bun.$`rm -rf ${tempDir}`.quiet();
243
+
244
+ if (options.onProgress) {
245
+ options.onProgress({
246
+ downloaded_bytes: totalBytes,
247
+ total_bytes: totalBytes,
248
+ speed: null,
249
+ eta: null,
250
+ percent: 100,
251
+ status: "finished",
252
+ filename: filepath,
253
+ });
254
+ }
255
+ }
256
+ }
257
+
258
+ function sleep(ms: number): Promise<void> {
259
+ return new Promise((resolve) => setTimeout(resolve, ms));
260
+ }