coomer-downloader 3.2.0 → 3.4.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/README.md +14 -3
- package/biome.json +6 -4
- package/dist/index.js +539 -285
- package/docs/images/Screenshot 01.jpg +0 -0
- package/package.json +14 -5
- package/src/api/bunkr.ts +1 -1
- package/src/api/coomer-api.ts +23 -6
- package/src/api/gofile.ts +2 -2
- package/src/api/index.ts +5 -1
- package/src/api/nsfw.xxx.ts +3 -3
- package/src/api/plain-curl.ts +1 -1
- package/src/{args-handler.ts → cli/args-handler.ts} +17 -12
- package/src/cli/ui/app.tsx +40 -0
- package/src/cli/ui/components/file.tsx +44 -0
- package/src/cli/ui/components/filelist.tsx +52 -0
- package/src/cli/ui/components/index.ts +6 -0
- package/src/cli/ui/components/keyboardinfo.tsx +41 -0
- package/src/cli/ui/components/loading.tsx +20 -0
- package/src/cli/ui/components/preview.tsx +32 -0
- package/src/cli/ui/components/spinner.tsx +28 -0
- package/src/cli/ui/components/titlebar.tsx +15 -0
- package/src/cli/ui/hooks/downloader.ts +21 -0
- package/src/cli/ui/hooks/input.ts +17 -0
- package/src/cli/ui/index.tsx +7 -0
- package/src/cli/ui/store/index.ts +19 -0
- package/src/index.ts +42 -23
- package/src/logger/index.ts +15 -0
- package/src/services/downloader.ts +161 -0
- package/src/services/file.ts +113 -0
- package/src/types/index.ts +16 -1
- package/src/utils/duplicates.ts +23 -0
- package/src/utils/filters.ts +15 -15
- package/src/utils/io.ts +25 -0
- package/src/utils/mediatypes.ts +13 -0
- package/src/utils/promise.ts +0 -50
- package/src/utils/requests.ts +2 -2
- package/src/utils/strings.ts +1 -10
- package/src/utils/timer.ts +11 -9
- package/tsconfig.json +2 -1
- package/src/utils/downloader.ts +0 -108
- package/src/utils/file.ts +0 -75
- package/src/utils/index.ts +0 -11
- package/src/utils/multibar.ts +0 -62
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
1
|
+
#!/usr/bin/env -S node --no-warnings=ExperimentalWarning
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
4
|
import process2 from "node:process";
|
|
@@ -7,20 +7,45 @@ import process2 from "node:process";
|
|
|
7
7
|
import * as cheerio from "cheerio";
|
|
8
8
|
import { fetch } from "undici";
|
|
9
9
|
|
|
10
|
-
// src/
|
|
10
|
+
// src/services/file.ts
|
|
11
11
|
import os from "node:os";
|
|
12
12
|
import path from "node:path";
|
|
13
13
|
|
|
14
|
-
// src/
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
14
|
+
// src/logger/index.ts
|
|
15
|
+
import pino from "pino";
|
|
16
|
+
var logger = pino(
|
|
17
|
+
{
|
|
18
|
+
level: "debug"
|
|
19
|
+
},
|
|
20
|
+
pino.destination({
|
|
21
|
+
dest: "./debug.log",
|
|
22
|
+
append: false,
|
|
23
|
+
sync: true
|
|
24
|
+
})
|
|
25
|
+
);
|
|
26
|
+
var logger_default = logger;
|
|
27
|
+
|
|
28
|
+
// src/utils/duplicates.ts
|
|
29
|
+
function collectUniquesAndDuplicatesBy(xs, k) {
|
|
30
|
+
const seen = /* @__PURE__ */ new Set();
|
|
31
|
+
return xs.reduce(
|
|
32
|
+
(acc, item) => {
|
|
33
|
+
if (seen.has(item[k])) {
|
|
34
|
+
acc.duplicates.push(item);
|
|
35
|
+
} else {
|
|
36
|
+
seen.add(item[k]);
|
|
37
|
+
acc.uniques.push(item);
|
|
38
|
+
}
|
|
39
|
+
return acc;
|
|
40
|
+
},
|
|
41
|
+
{ uniques: [], duplicates: [] }
|
|
42
|
+
);
|
|
20
43
|
}
|
|
21
|
-
function
|
|
22
|
-
return
|
|
44
|
+
function removeDuplicatesBy(xs, k) {
|
|
45
|
+
return [...new Map(xs.map((x) => [x[k], x])).values()];
|
|
23
46
|
}
|
|
47
|
+
|
|
48
|
+
// src/utils/filters.ts
|
|
24
49
|
function includesAllWords(str, words) {
|
|
25
50
|
if (!words.length) return true;
|
|
26
51
|
return words.every((w) => str.includes(w));
|
|
@@ -35,10 +60,62 @@ function parseQuery(query) {
|
|
|
35
60
|
function filterString(text, include, exclude) {
|
|
36
61
|
return includesAllWords(text, parseQuery(include)) && includesNoWords(text, parseQuery(exclude));
|
|
37
62
|
}
|
|
63
|
+
function parseSizeValue(s) {
|
|
64
|
+
if (!s) return NaN;
|
|
65
|
+
const m = s.match(/^([0-9]+(?:\.[0-9]+)?)(b|kb|mb|gb)?$/i);
|
|
66
|
+
if (!m) return NaN;
|
|
67
|
+
const val = parseFloat(m[1]);
|
|
68
|
+
const unit = (m[2] || "b").toLowerCase();
|
|
69
|
+
const mult = unit === "kb" ? 1024 : unit === "mb" ? 1024 ** 2 : unit === "gb" ? 1024 ** 3 : 1;
|
|
70
|
+
return Math.floor(val * mult);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// src/utils/io.ts
|
|
74
|
+
import { createHash } from "node:crypto";
|
|
75
|
+
import fs from "node:fs";
|
|
76
|
+
import { access, constants, unlink } from "node:fs/promises";
|
|
77
|
+
import { pipeline } from "node:stream/promises";
|
|
78
|
+
async function getFileSize(filepath) {
|
|
79
|
+
let size = 0;
|
|
80
|
+
if (fs.existsSync(filepath)) {
|
|
81
|
+
size = (await fs.promises.stat(filepath)).size || 0;
|
|
82
|
+
}
|
|
83
|
+
return size;
|
|
84
|
+
}
|
|
85
|
+
async function getFileHash(filepath) {
|
|
86
|
+
const hash = createHash("sha256");
|
|
87
|
+
const filestream = fs.createReadStream(filepath);
|
|
88
|
+
await pipeline(filestream, hash);
|
|
89
|
+
return hash.digest("hex");
|
|
90
|
+
}
|
|
91
|
+
function mkdir(filepath) {
|
|
92
|
+
if (!fs.existsSync(filepath)) {
|
|
93
|
+
fs.mkdirSync(filepath, { recursive: true });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
async function deleteFile(path2) {
|
|
97
|
+
await access(path2, constants.F_OK);
|
|
98
|
+
await unlink(path2);
|
|
99
|
+
}
|
|
100
|
+
function sanitizeFilename(name) {
|
|
101
|
+
if (!name) return name;
|
|
102
|
+
return name.replace(/[<>"/\\|?*\x00-\x1F]/g, "-").replace(/\s+/g, " ").trim().replace(/[.]+$/, "");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// src/utils/mediatypes.ts
|
|
106
|
+
function isImage(name) {
|
|
107
|
+
return /\.(jpg|jpeg|png|gif|bmp|tiff|webp|avif)$/i.test(name);
|
|
108
|
+
}
|
|
109
|
+
function isVideo(name) {
|
|
110
|
+
return /\.(mp4|m4v|avi|mov|mkv|webm|flv|wmv|mpeg|mpg|3gp)$/i.test(name);
|
|
111
|
+
}
|
|
112
|
+
function testMediaType(name, type) {
|
|
113
|
+
return type === "image" ? isImage(name) : isVideo(name);
|
|
114
|
+
}
|
|
38
115
|
|
|
39
|
-
// src/
|
|
116
|
+
// src/services/file.ts
|
|
40
117
|
var CoomerFile = class _CoomerFile {
|
|
41
|
-
constructor(name, url, filepath, size, downloaded, content) {
|
|
118
|
+
constructor(name, url, filepath = "", size, downloaded = 0, content) {
|
|
42
119
|
this.name = name;
|
|
43
120
|
this.url = url;
|
|
44
121
|
this.filepath = filepath;
|
|
@@ -46,7 +123,12 @@ var CoomerFile = class _CoomerFile {
|
|
|
46
123
|
this.downloaded = downloaded;
|
|
47
124
|
this.content = content;
|
|
48
125
|
}
|
|
49
|
-
|
|
126
|
+
active = false;
|
|
127
|
+
hash;
|
|
128
|
+
async getDownloadedSize() {
|
|
129
|
+
this.downloaded = await getFileSize(this.filepath);
|
|
130
|
+
return this;
|
|
131
|
+
}
|
|
50
132
|
get textContent() {
|
|
51
133
|
const text = `${this.name || ""} ${this.content || ""}`.toLowerCase();
|
|
52
134
|
return text;
|
|
@@ -69,7 +151,8 @@ var CoomerFileList = class {
|
|
|
69
151
|
this.dirPath = path.join(os.homedir(), path.join(dir, dirName));
|
|
70
152
|
}
|
|
71
153
|
this.files.forEach((file) => {
|
|
72
|
-
|
|
154
|
+
const safeName = sanitizeFilename(file.name) || file.name;
|
|
155
|
+
file.filepath = path.join(this.dirPath, safeName);
|
|
73
156
|
});
|
|
74
157
|
return this;
|
|
75
158
|
}
|
|
@@ -87,6 +170,33 @@ var CoomerFileList = class {
|
|
|
87
170
|
this.files = this.files.slice(n);
|
|
88
171
|
return this;
|
|
89
172
|
}
|
|
173
|
+
async calculateFileSizes() {
|
|
174
|
+
for (const file of this.files) {
|
|
175
|
+
await file.getDownloadedSize();
|
|
176
|
+
}
|
|
177
|
+
return this;
|
|
178
|
+
}
|
|
179
|
+
getActiveFiles() {
|
|
180
|
+
return this.files.filter((f) => f.active);
|
|
181
|
+
}
|
|
182
|
+
getDownloaded() {
|
|
183
|
+
return this.files.filter((f) => f.size && f.size <= f.downloaded);
|
|
184
|
+
}
|
|
185
|
+
async removeDuplicatesByHash() {
|
|
186
|
+
for (const file of this.files) {
|
|
187
|
+
file.hash = await getFileHash(file.filepath);
|
|
188
|
+
}
|
|
189
|
+
const { duplicates } = collectUniquesAndDuplicatesBy(this.files, "hash");
|
|
190
|
+
console.log({ duplicates });
|
|
191
|
+
logger_default.debug(`duplicates: ${JSON.stringify(duplicates)}`);
|
|
192
|
+
duplicates.forEach((f) => {
|
|
193
|
+
deleteFile(f.filepath);
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
removeURLDuplicates() {
|
|
197
|
+
this.files = removeDuplicatesBy(this.files, "url");
|
|
198
|
+
return this;
|
|
199
|
+
}
|
|
90
200
|
};
|
|
91
201
|
|
|
92
202
|
// src/api/bunkr.ts
|
|
@@ -139,67 +249,6 @@ async function getBunkrData(url) {
|
|
|
139
249
|
return filelist;
|
|
140
250
|
}
|
|
141
251
|
|
|
142
|
-
// src/utils/downloader.ts
|
|
143
|
-
import fs2 from "node:fs";
|
|
144
|
-
import { Readable, Transform } from "node:stream";
|
|
145
|
-
import { pipeline } from "node:stream/promises";
|
|
146
|
-
import { Subject } from "rxjs";
|
|
147
|
-
|
|
148
|
-
// src/utils/io.ts
|
|
149
|
-
import fs from "node:fs";
|
|
150
|
-
async function getFileSize(filepath) {
|
|
151
|
-
let size = 0;
|
|
152
|
-
if (fs.existsSync(filepath)) {
|
|
153
|
-
size = (await fs.promises.stat(filepath)).size || 0;
|
|
154
|
-
}
|
|
155
|
-
return size;
|
|
156
|
-
}
|
|
157
|
-
function mkdir(filepath) {
|
|
158
|
-
if (!fs.existsSync(filepath)) {
|
|
159
|
-
fs.mkdirSync(filepath, { recursive: true });
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
// src/utils/promise.ts
|
|
164
|
-
async function sleep(time) {
|
|
165
|
-
return new Promise((resolve) => setTimeout(resolve, time));
|
|
166
|
-
}
|
|
167
|
-
var PromiseRetry = class _PromiseRetry {
|
|
168
|
-
retries;
|
|
169
|
-
delay;
|
|
170
|
-
callback;
|
|
171
|
-
constructor(options) {
|
|
172
|
-
this.retries = options.retries || 3;
|
|
173
|
-
this.delay = options.delay || 1e3;
|
|
174
|
-
this.callback = options.callback;
|
|
175
|
-
}
|
|
176
|
-
async execute(fn) {
|
|
177
|
-
let retries = this.retries;
|
|
178
|
-
while (true) {
|
|
179
|
-
try {
|
|
180
|
-
return await fn();
|
|
181
|
-
} catch (error) {
|
|
182
|
-
if (retries <= 0) {
|
|
183
|
-
throw error;
|
|
184
|
-
}
|
|
185
|
-
if (this.callback) {
|
|
186
|
-
const res = this.callback(retries, error);
|
|
187
|
-
if (res) {
|
|
188
|
-
const { newRetries } = res;
|
|
189
|
-
if (newRetries === 0) throw error;
|
|
190
|
-
this.retries = newRetries || retries;
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
await sleep(this.delay);
|
|
194
|
-
retries--;
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
static create(options) {
|
|
199
|
-
return new _PromiseRetry(options);
|
|
200
|
-
}
|
|
201
|
-
};
|
|
202
|
-
|
|
203
252
|
// src/utils/requests.ts
|
|
204
253
|
import { CookieAgent } from "http-cookie-agent/undici";
|
|
205
254
|
import { CookieJar } from "tough-cookie";
|
|
@@ -223,190 +272,10 @@ function fetchWithGlobalHeader(url) {
|
|
|
223
272
|
const requestHeaders = new Headers(HeadersDefault);
|
|
224
273
|
return fetch2(url, { headers: requestHeaders });
|
|
225
274
|
}
|
|
226
|
-
function fetchByteRange(url, downloadedSize) {
|
|
275
|
+
function fetchByteRange(url, downloadedSize, signal) {
|
|
227
276
|
const requestHeaders = new Headers(HeadersDefault);
|
|
228
277
|
requestHeaders.set("Range", `bytes=${downloadedSize}-`);
|
|
229
|
-
return fetch2(url, { headers: requestHeaders });
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
// src/utils/timer.ts
|
|
233
|
-
var Timer = class _Timer {
|
|
234
|
-
constructor(timeout = 1e4, timeoutCallback) {
|
|
235
|
-
this.timeout = timeout;
|
|
236
|
-
this.timeoutCallback = timeoutCallback;
|
|
237
|
-
this.timeout = timeout;
|
|
238
|
-
}
|
|
239
|
-
timer = void 0;
|
|
240
|
-
start() {
|
|
241
|
-
this.timer = setTimeout(() => {
|
|
242
|
-
this.stop();
|
|
243
|
-
this.timeoutCallback();
|
|
244
|
-
}, this.timeout);
|
|
245
|
-
return this;
|
|
246
|
-
}
|
|
247
|
-
stop() {
|
|
248
|
-
if (this.timer) {
|
|
249
|
-
clearTimeout(this.timer);
|
|
250
|
-
this.timer = void 0;
|
|
251
|
-
}
|
|
252
|
-
return this;
|
|
253
|
-
}
|
|
254
|
-
reset() {
|
|
255
|
-
this.stop();
|
|
256
|
-
this.start();
|
|
257
|
-
return this;
|
|
258
|
-
}
|
|
259
|
-
static withSignal(timeout, message) {
|
|
260
|
-
const controller = new AbortController();
|
|
261
|
-
const callback = () => {
|
|
262
|
-
controller.abort(message);
|
|
263
|
-
};
|
|
264
|
-
const timer = new _Timer(timeout, callback).start();
|
|
265
|
-
return {
|
|
266
|
-
timer,
|
|
267
|
-
signal: controller.signal
|
|
268
|
-
};
|
|
269
|
-
}
|
|
270
|
-
};
|
|
271
|
-
|
|
272
|
-
// src/utils/downloader.ts
|
|
273
|
-
var Downloader = class {
|
|
274
|
-
constructor(chunkTimeout = 3e4, chunkFetchRetries = 5, fetchRetries = 7) {
|
|
275
|
-
this.chunkTimeout = chunkTimeout;
|
|
276
|
-
this.chunkFetchRetries = chunkFetchRetries;
|
|
277
|
-
this.fetchRetries = fetchRetries;
|
|
278
|
-
}
|
|
279
|
-
subject = new Subject();
|
|
280
|
-
async fetchStream(file, stream) {
|
|
281
|
-
const { subject, chunkTimeout } = this;
|
|
282
|
-
const { timer, signal } = Timer.withSignal(chunkTimeout, "chunkTimeout");
|
|
283
|
-
const fileStream = fs2.createWriteStream(file.filepath, { flags: "a" });
|
|
284
|
-
const progressStream = new Transform({
|
|
285
|
-
transform(chunk, _encoding, callback) {
|
|
286
|
-
this.push(chunk);
|
|
287
|
-
file.downloaded += chunk.length;
|
|
288
|
-
timer.reset();
|
|
289
|
-
subject.next({ type: "CHUNK_DOWNLOADING_UPDATE", file });
|
|
290
|
-
callback();
|
|
291
|
-
}
|
|
292
|
-
});
|
|
293
|
-
try {
|
|
294
|
-
subject.next({ type: "CHUNK_DOWNLOADING_START", file });
|
|
295
|
-
await pipeline(stream, progressStream, fileStream, { signal });
|
|
296
|
-
} catch (error) {
|
|
297
|
-
console.error(error.name === "AbortError" ? signal.reason : error);
|
|
298
|
-
} finally {
|
|
299
|
-
subject.next({ type: "CHUNK_DOWNLOADING_END", file });
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
async downloadFile(file) {
|
|
303
|
-
file.downloaded = await getFileSize(file.filepath);
|
|
304
|
-
const response = await fetchByteRange(file.url, file.downloaded);
|
|
305
|
-
if (!response?.ok && response?.status !== 416) {
|
|
306
|
-
throw new Error(`HTTP error! status: ${response?.status}`);
|
|
307
|
-
}
|
|
308
|
-
const contentLength = response.headers.get("Content-Length");
|
|
309
|
-
if (!contentLength && file.downloaded > 0) return;
|
|
310
|
-
const restFileSize = parseInt(contentLength);
|
|
311
|
-
file.size = restFileSize + file.downloaded;
|
|
312
|
-
if (file.size > file.downloaded && response.body) {
|
|
313
|
-
const stream = Readable.fromWeb(response.body);
|
|
314
|
-
const sizeOld = file.downloaded;
|
|
315
|
-
await PromiseRetry.create({
|
|
316
|
-
retries: this.chunkFetchRetries,
|
|
317
|
-
callback: () => {
|
|
318
|
-
if (sizeOld !== file.downloaded) {
|
|
319
|
-
return { newRetries: 5 };
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
}).execute(async () => await this.fetchStream(file, stream));
|
|
323
|
-
}
|
|
324
|
-
this.subject.next({ type: "FILE_DOWNLOADING_END" });
|
|
325
|
-
}
|
|
326
|
-
async downloadFiles(filelist) {
|
|
327
|
-
mkdir(filelist.dirPath);
|
|
328
|
-
this.subject.next({ type: "FILES_DOWNLOADING_START", filesCount: filelist.files.length });
|
|
329
|
-
for (const file of filelist.files) {
|
|
330
|
-
this.subject.next({ type: "FILE_DOWNLOADING_START" });
|
|
331
|
-
await PromiseRetry.create({
|
|
332
|
-
retries: this.fetchRetries,
|
|
333
|
-
callback: (retries) => {
|
|
334
|
-
if (/coomer|kemono/.test(file.url)) {
|
|
335
|
-
file.url = tryFixCoomerUrl(file.url, retries);
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
}).execute(async () => await this.downloadFile(file));
|
|
339
|
-
this.subject.next({ type: "FILE_DOWNLOADING_END" });
|
|
340
|
-
}
|
|
341
|
-
this.subject.next({ type: "FILES_DOWNLOADING_END" });
|
|
342
|
-
}
|
|
343
|
-
};
|
|
344
|
-
|
|
345
|
-
// src/utils/multibar.ts
|
|
346
|
-
import { MultiBar } from "cli-progress";
|
|
347
|
-
|
|
348
|
-
// src/utils/strings.ts
|
|
349
|
-
function b2mb(bytes) {
|
|
350
|
-
return Number.parseFloat((bytes / 1048576).toFixed(2));
|
|
351
|
-
}
|
|
352
|
-
function formatNameStdout(pathname) {
|
|
353
|
-
const name = pathname.split("/").pop() || "";
|
|
354
|
-
const consoleWidth = process.stdout.columns;
|
|
355
|
-
const width = Math.max(consoleWidth / 2 | 0, 40);
|
|
356
|
-
if (name.length < width) return name.trim();
|
|
357
|
-
const result = `${name.slice(0, width - 15)} ... ${name.slice(-10)}`.replace(/ +/g, " ");
|
|
358
|
-
return result;
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
// src/utils/multibar.ts
|
|
362
|
-
var config = {
|
|
363
|
-
clearOnComplete: true,
|
|
364
|
-
gracefulExit: true,
|
|
365
|
-
autopadding: true,
|
|
366
|
-
hideCursor: true,
|
|
367
|
-
format: "{percentage}% | {filename} | {value}/{total}{size}"
|
|
368
|
-
};
|
|
369
|
-
function createMultibar(downloader) {
|
|
370
|
-
const multibar = new MultiBar(config);
|
|
371
|
-
let bar;
|
|
372
|
-
let minibar;
|
|
373
|
-
let filename;
|
|
374
|
-
let index = 0;
|
|
375
|
-
downloader.subject.subscribe({
|
|
376
|
-
next: ({ type, filesCount, file }) => {
|
|
377
|
-
switch (type) {
|
|
378
|
-
case "FILES_DOWNLOADING_START":
|
|
379
|
-
bar?.stop();
|
|
380
|
-
bar = multibar.create(filesCount, 0);
|
|
381
|
-
break;
|
|
382
|
-
case "FILES_DOWNLOADING_END":
|
|
383
|
-
bar?.stop();
|
|
384
|
-
break;
|
|
385
|
-
case "FILE_DOWNLOADING_START":
|
|
386
|
-
bar?.update(++index, { filename: "Downloaded files", size: "" });
|
|
387
|
-
break;
|
|
388
|
-
case "FILE_DOWNLOADING_END":
|
|
389
|
-
multibar.remove(minibar);
|
|
390
|
-
break;
|
|
391
|
-
case "CHUNK_DOWNLOADING_START":
|
|
392
|
-
multibar?.remove(minibar);
|
|
393
|
-
filename = formatNameStdout(file?.filepath);
|
|
394
|
-
minibar = multibar.create(b2mb(file?.size), b2mb(file?.downloaded));
|
|
395
|
-
break;
|
|
396
|
-
case "CHUNK_DOWNLOADING_UPDATE":
|
|
397
|
-
minibar?.update(b2mb(file?.downloaded), {
|
|
398
|
-
filename,
|
|
399
|
-
size: "mb"
|
|
400
|
-
});
|
|
401
|
-
break;
|
|
402
|
-
case "CHUNK_DOWNLOADING_END":
|
|
403
|
-
multibar?.remove(minibar);
|
|
404
|
-
break;
|
|
405
|
-
default:
|
|
406
|
-
break;
|
|
407
|
-
}
|
|
408
|
-
}
|
|
409
|
-
});
|
|
278
|
+
return fetch2(url, { headers: requestHeaders, signal });
|
|
410
279
|
}
|
|
411
280
|
|
|
412
281
|
// src/api/coomer-api.ts
|
|
@@ -436,10 +305,10 @@ async function getUserPostsAPI(user, offset) {
|
|
|
436
305
|
async function getUserFiles(user) {
|
|
437
306
|
const userPosts = [];
|
|
438
307
|
const offset = 50;
|
|
439
|
-
for (let i = 0; i <
|
|
308
|
+
for (let i = 0; i < 1e4; i++) {
|
|
440
309
|
const posts = await getUserPostsAPI(user, i * offset);
|
|
441
310
|
userPosts.push(...posts);
|
|
442
|
-
if (posts.length <
|
|
311
|
+
if (posts.length < offset) break;
|
|
443
312
|
}
|
|
444
313
|
const filelist = new CoomerFileList();
|
|
445
314
|
for (const p of userPosts) {
|
|
@@ -450,13 +319,23 @@ async function getUserFiles(user) {
|
|
|
450
319
|
const postFiles = [...p.attachments, p.file].filter((f) => f.path).map((f, i) => {
|
|
451
320
|
const ext = f.name.split(".").pop();
|
|
452
321
|
const name = `${datentitle} ${i + 1}.${ext}`;
|
|
453
|
-
const url =
|
|
322
|
+
const url = getUrl(f, user);
|
|
454
323
|
return CoomerFile.from({ name, url, content });
|
|
455
324
|
});
|
|
456
325
|
filelist.files.push(...postFiles);
|
|
457
326
|
}
|
|
458
327
|
return filelist;
|
|
459
328
|
}
|
|
329
|
+
function getUrl(f, user) {
|
|
330
|
+
const normalizedPath = f.path.replace(/^\/+/, "/");
|
|
331
|
+
let url = "";
|
|
332
|
+
try {
|
|
333
|
+
url = new URL(normalizedPath, user.domain).toString();
|
|
334
|
+
} catch (_) {
|
|
335
|
+
url = `${user.domain}/${normalizedPath.replace(/^\//, "")}`;
|
|
336
|
+
}
|
|
337
|
+
return url;
|
|
338
|
+
}
|
|
460
339
|
async function parseUser(url) {
|
|
461
340
|
const [_, domain, service, id] = url.match(
|
|
462
341
|
/(https:\/\/\w+\.\w+)\/(\w+)\/user\/([\w|.|-]+)/
|
|
@@ -531,7 +410,6 @@ async function getUserPage(user, offset) {
|
|
|
531
410
|
return fetch4(url).then((r) => r.text());
|
|
532
411
|
}
|
|
533
412
|
async function getUserPosts(user) {
|
|
534
|
-
console.log("Fetching user posts...");
|
|
535
413
|
const posts = [];
|
|
536
414
|
for (let i = 1; i < 1e5; i++) {
|
|
537
415
|
const page = await getUserPage(user, i);
|
|
@@ -543,7 +421,6 @@ async function getUserPosts(user) {
|
|
|
543
421
|
return posts;
|
|
544
422
|
}
|
|
545
423
|
async function getPostsData(posts) {
|
|
546
|
-
console.log("Fetching posts data...");
|
|
547
424
|
const filelist = new CoomerFileList();
|
|
548
425
|
for (const post of posts) {
|
|
549
426
|
const page = await fetch4(post).then((r) => r.text());
|
|
@@ -560,7 +437,9 @@ async function getPostsData(posts) {
|
|
|
560
437
|
}
|
|
561
438
|
async function getRedditData(url) {
|
|
562
439
|
const user = url.match(/u\/(\w+)/)?.[1];
|
|
440
|
+
console.log("Fetching user posts...");
|
|
563
441
|
const posts = await getUserPosts(user);
|
|
442
|
+
console.log("Fetching posts data...");
|
|
564
443
|
const filelist = await getPostsData(posts);
|
|
565
444
|
filelist.dirName = `${user}-reddit`;
|
|
566
445
|
return filelist;
|
|
@@ -596,7 +475,7 @@ async function apiHandler(url_) {
|
|
|
596
475
|
throw Error("Invalid URL");
|
|
597
476
|
}
|
|
598
477
|
|
|
599
|
-
// src/args-handler.ts
|
|
478
|
+
// src/cli/args-handler.ts
|
|
600
479
|
import yargs from "yargs";
|
|
601
480
|
import { hideBin } from "yargs/helpers";
|
|
602
481
|
function argumentHander() {
|
|
@@ -611,8 +490,7 @@ function argumentHander() {
|
|
|
611
490
|
default: "./"
|
|
612
491
|
}).option("media", {
|
|
613
492
|
type: "string",
|
|
614
|
-
choices: ["video", "image"
|
|
615
|
-
default: "all",
|
|
493
|
+
choices: ["video", "image"],
|
|
616
494
|
description: "The type of media to download: 'video', 'image', or 'all'. 'all' is the default."
|
|
617
495
|
}).option("include", {
|
|
618
496
|
type: "string",
|
|
@@ -622,34 +500,410 @@ function argumentHander() {
|
|
|
622
500
|
type: "string",
|
|
623
501
|
default: "",
|
|
624
502
|
description: "Filter file names by a comma-separated list of keywords to exclude"
|
|
503
|
+
}).option("min-size", {
|
|
504
|
+
type: "string",
|
|
505
|
+
default: "",
|
|
506
|
+
description: 'Minimum file size to download. Example: "1mb" or "500kb"'
|
|
507
|
+
}).option("max-size", {
|
|
508
|
+
type: "string",
|
|
509
|
+
default: "",
|
|
510
|
+
description: 'Maximum file size to download. Example: "1mb" or "500kb"'
|
|
625
511
|
}).option("skip", {
|
|
626
512
|
type: "number",
|
|
627
513
|
default: 0,
|
|
628
514
|
description: "Skips the first N files in the download queue"
|
|
515
|
+
}).option("remove-dupilicates", {
|
|
516
|
+
type: "boolean",
|
|
517
|
+
default: true,
|
|
518
|
+
description: "removes duplicates by url and file hash"
|
|
629
519
|
}).help().alias("help", "h").parseSync();
|
|
630
520
|
}
|
|
631
521
|
|
|
522
|
+
// src/cli/ui/index.tsx
|
|
523
|
+
import { render } from "ink";
|
|
524
|
+
import React9 from "react";
|
|
525
|
+
|
|
526
|
+
// src/cli/ui/app.tsx
|
|
527
|
+
import { Box as Box7 } from "ink";
|
|
528
|
+
import React8 from "react";
|
|
529
|
+
|
|
530
|
+
// src/cli/ui/components/file.tsx
|
|
531
|
+
import { Box as Box2, Spacer, Text as Text2 } from "ink";
|
|
532
|
+
import React3 from "react";
|
|
533
|
+
|
|
534
|
+
// src/utils/strings.ts
|
|
535
|
+
function b2mb(bytes) {
|
|
536
|
+
return (bytes / 1048576).toFixed(2);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// src/cli/ui/components/preview.tsx
|
|
540
|
+
import { Box } from "ink";
|
|
541
|
+
import Image, { TerminalInfoProvider } from "ink-picture";
|
|
542
|
+
import React from "react";
|
|
543
|
+
|
|
544
|
+
// src/cli/ui/store/index.ts
|
|
545
|
+
import { create } from "zustand";
|
|
546
|
+
var useInkStore = create((set) => ({
|
|
547
|
+
preview: false,
|
|
548
|
+
switchPreview: () => set((state) => ({
|
|
549
|
+
preview: !state.preview
|
|
550
|
+
})),
|
|
551
|
+
downloader: void 0,
|
|
552
|
+
setDownloader: (downloader) => set({ downloader })
|
|
553
|
+
}));
|
|
554
|
+
|
|
555
|
+
// src/cli/ui/components/preview.tsx
|
|
556
|
+
function Preview({ file }) {
|
|
557
|
+
const previewEnabled = useInkStore((state) => state.preview);
|
|
558
|
+
const bigEnough = file.downloaded > 50 * 1024;
|
|
559
|
+
const shouldShow = previewEnabled && bigEnough && isImage(file.filepath);
|
|
560
|
+
const imgInfo = `
|
|
561
|
+
can't read partial images yet...
|
|
562
|
+
actual size: ${file.size}}
|
|
563
|
+
downloaded: ${file.downloaded}}
|
|
564
|
+
`;
|
|
565
|
+
return shouldShow && /* @__PURE__ */ React.createElement(Box, { paddingX: 1 }, /* @__PURE__ */ React.createElement(TerminalInfoProvider, null, /* @__PURE__ */ React.createElement(Box, { width: 30, height: 15 }, /* @__PURE__ */ React.createElement(Image, { src: file.filepath, alt: imgInfo }))));
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// src/cli/ui/components/spinner.tsx
|
|
569
|
+
import spinners from "cli-spinners";
|
|
570
|
+
import { Text } from "ink";
|
|
571
|
+
import React2, { useEffect, useState } from "react";
|
|
572
|
+
function Spinner({ type = "dots" }) {
|
|
573
|
+
const spinner = spinners[type];
|
|
574
|
+
const randomFrame = spinner.frames.length * Math.random() | 0;
|
|
575
|
+
const [frame, setFrame] = useState(randomFrame);
|
|
576
|
+
useEffect(() => {
|
|
577
|
+
const timer = setInterval(() => {
|
|
578
|
+
setFrame((previousFrame) => {
|
|
579
|
+
return (previousFrame + 1) % spinner.frames.length;
|
|
580
|
+
});
|
|
581
|
+
}, spinner.interval);
|
|
582
|
+
return () => {
|
|
583
|
+
clearInterval(timer);
|
|
584
|
+
};
|
|
585
|
+
}, [spinner]);
|
|
586
|
+
return /* @__PURE__ */ React2.createElement(Text, null, spinner.frames[frame]);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// src/cli/ui/components/file.tsx
|
|
590
|
+
function FileBox({ file }) {
|
|
591
|
+
const percentage = Number(file.downloaded / file.size * 100).toFixed(2);
|
|
592
|
+
return /* @__PURE__ */ React3.createElement(React3.Fragment, null, /* @__PURE__ */ React3.createElement(
|
|
593
|
+
Box2,
|
|
594
|
+
{
|
|
595
|
+
borderStyle: "single",
|
|
596
|
+
borderColor: "magentaBright",
|
|
597
|
+
borderDimColor: true,
|
|
598
|
+
paddingX: 1,
|
|
599
|
+
flexDirection: "column"
|
|
600
|
+
},
|
|
601
|
+
/* @__PURE__ */ React3.createElement(Box2, null, /* @__PURE__ */ React3.createElement(Text2, { color: "blue", dimColor: true, wrap: "truncate-middle" }, file.name)),
|
|
602
|
+
/* @__PURE__ */ React3.createElement(Box2, { flexDirection: "row-reverse" }, /* @__PURE__ */ React3.createElement(Text2, { color: "cyan", dimColor: true }, b2mb(file.downloaded), "/", file.size ? b2mb(file.size) : "\u221E", " MB"), /* @__PURE__ */ React3.createElement(Text2, { color: "redBright", dimColor: true }, file.size ? ` ${percentage}% ` : ""), /* @__PURE__ */ React3.createElement(Spacer, null), /* @__PURE__ */ React3.createElement(Text2, { color: "green", dimColor: true }, /* @__PURE__ */ React3.createElement(Spinner, null)))
|
|
603
|
+
), /* @__PURE__ */ React3.createElement(Preview, { file }));
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// src/cli/ui/components/filelist.tsx
|
|
607
|
+
import { Box as Box3, Text as Text3 } from "ink";
|
|
608
|
+
import React4 from "react";
|
|
609
|
+
function FileListStateBox({ filelist }) {
|
|
610
|
+
return /* @__PURE__ */ React4.createElement(
|
|
611
|
+
Box3,
|
|
612
|
+
{
|
|
613
|
+
paddingX: 1,
|
|
614
|
+
flexDirection: "column",
|
|
615
|
+
borderStyle: "single",
|
|
616
|
+
borderColor: "magenta",
|
|
617
|
+
borderDimColor: true
|
|
618
|
+
},
|
|
619
|
+
/* @__PURE__ */ React4.createElement(Box3, null, /* @__PURE__ */ React4.createElement(Box3, { marginRight: 1 }, /* @__PURE__ */ React4.createElement(Text3, { color: "cyanBright", dimColor: true }, "Found:")), /* @__PURE__ */ React4.createElement(Text3, { color: "blue", dimColor: true, wrap: "wrap" }, filelist.files.length)),
|
|
620
|
+
/* @__PURE__ */ React4.createElement(Box3, null, /* @__PURE__ */ React4.createElement(Box3, { marginRight: 1 }, /* @__PURE__ */ React4.createElement(Text3, { color: "cyanBright", dimColor: true }, "Downloaded:")), /* @__PURE__ */ React4.createElement(Text3, { color: "blue", dimColor: true, wrap: "wrap" }, filelist.getDownloaded().length)),
|
|
621
|
+
/* @__PURE__ */ React4.createElement(Box3, null, /* @__PURE__ */ React4.createElement(Box3, { width: 9 }, /* @__PURE__ */ React4.createElement(Text3, { color: "cyanBright", dimColor: true }, "Folder:")), /* @__PURE__ */ React4.createElement(Box3, { flexGrow: 1 }, /* @__PURE__ */ React4.createElement(Text3, { color: "blue", dimColor: true, wrap: "truncate-middle" }, filelist.dirPath)))
|
|
622
|
+
);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// src/cli/ui/components/keyboardinfo.tsx
|
|
626
|
+
import { Box as Box4, Text as Text4 } from "ink";
|
|
627
|
+
import React5 from "react";
|
|
628
|
+
var info = {
|
|
629
|
+
"s ": "skip current file",
|
|
630
|
+
p: "on/off image preview"
|
|
631
|
+
};
|
|
632
|
+
function KeyboardControlsInfo() {
|
|
633
|
+
const infoRender = Object.entries(info).map(([key, value]) => {
|
|
634
|
+
return /* @__PURE__ */ React5.createElement(Box4, { key }, /* @__PURE__ */ React5.createElement(Box4, { marginRight: 2 }, /* @__PURE__ */ React5.createElement(Text4, { color: "red", dimColor: true, bold: true }, key)), /* @__PURE__ */ React5.createElement(Text4, { dimColor: true, bold: false }, value));
|
|
635
|
+
});
|
|
636
|
+
return /* @__PURE__ */ React5.createElement(
|
|
637
|
+
Box4,
|
|
638
|
+
{
|
|
639
|
+
flexDirection: "column",
|
|
640
|
+
paddingX: 1,
|
|
641
|
+
borderStyle: "single",
|
|
642
|
+
borderColor: "gray",
|
|
643
|
+
borderDimColor: true
|
|
644
|
+
},
|
|
645
|
+
/* @__PURE__ */ React5.createElement(Box4, null, /* @__PURE__ */ React5.createElement(Text4, { color: "red", dimColor: true, bold: true }, "Keyboard controls:")),
|
|
646
|
+
infoRender
|
|
647
|
+
);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// src/cli/ui/components/loading.tsx
|
|
651
|
+
import { Box as Box5, Text as Text5 } from "ink";
|
|
652
|
+
import React6 from "react";
|
|
653
|
+
function Loading() {
|
|
654
|
+
return /* @__PURE__ */ React6.createElement(Box5, { paddingX: 1, borderDimColor: true, flexDirection: "column" }, /* @__PURE__ */ React6.createElement(Box5, { alignSelf: "center" }, /* @__PURE__ */ React6.createElement(Text5, { dimColor: true, color: "redBright" }, "Fetching Data")), /* @__PURE__ */ React6.createElement(Box5, { alignSelf: "center" }, /* @__PURE__ */ React6.createElement(Text5, { color: "blueBright", dimColor: true }, /* @__PURE__ */ React6.createElement(Spinner, { type: "grenade" }))));
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// src/cli/ui/components/titlebar.tsx
|
|
658
|
+
import { Box as Box6, Spacer as Spacer2, Text as Text6 } from "ink";
|
|
659
|
+
import React7 from "react";
|
|
660
|
+
|
|
661
|
+
// package.json
|
|
662
|
+
var version = "3.4.0";
|
|
663
|
+
|
|
664
|
+
// src/cli/ui/components/titlebar.tsx
|
|
665
|
+
function TitleBar() {
|
|
666
|
+
return /* @__PURE__ */ React7.createElement(Box6, null, /* @__PURE__ */ React7.createElement(Spacer2, null), /* @__PURE__ */ React7.createElement(Box6, { borderColor: "magenta", borderStyle: "arrow" }, /* @__PURE__ */ React7.createElement(Text6, { color: "cyanBright" }, "Coomer-Downloader ", version)), /* @__PURE__ */ React7.createElement(Spacer2, null));
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// src/cli/ui/hooks/downloader.ts
|
|
670
|
+
import { useEffect as useEffect2, useState as useState2 } from "react";
|
|
671
|
+
var useDownloaderHook = () => {
|
|
672
|
+
const downloader = useInkStore((state) => state.downloader);
|
|
673
|
+
const filelist = downloader?.filelist;
|
|
674
|
+
const [_, setHelper] = useState2(0);
|
|
675
|
+
useEffect2(() => {
|
|
676
|
+
downloader?.subject.subscribe(({ type }) => {
|
|
677
|
+
if (type === "FILE_DOWNLOADING_START" || type === "FILE_DOWNLOADING_END" || type === "CHUNK_DOWNLOADING_UPDATE") {
|
|
678
|
+
setHelper(Date.now());
|
|
679
|
+
}
|
|
680
|
+
});
|
|
681
|
+
});
|
|
682
|
+
};
|
|
683
|
+
|
|
684
|
+
// src/cli/ui/hooks/input.ts
|
|
685
|
+
import { useInput } from "ink";
|
|
686
|
+
var useInputHook = () => {
|
|
687
|
+
const downloader = useInkStore((state) => state.downloader);
|
|
688
|
+
const switchPreview = useInkStore((state) => state.switchPreview);
|
|
689
|
+
useInput((input) => {
|
|
690
|
+
if (input === "s") {
|
|
691
|
+
downloader?.skip();
|
|
692
|
+
}
|
|
693
|
+
if (input === "p") {
|
|
694
|
+
switchPreview();
|
|
695
|
+
}
|
|
696
|
+
});
|
|
697
|
+
};
|
|
698
|
+
|
|
699
|
+
// src/cli/ui/app.tsx
|
|
700
|
+
function App() {
|
|
701
|
+
useInputHook();
|
|
702
|
+
useDownloaderHook();
|
|
703
|
+
const downloader = useInkStore((state) => state.downloader);
|
|
704
|
+
const filelist = downloader?.filelist;
|
|
705
|
+
const isFilelist = filelist instanceof CoomerFileList;
|
|
706
|
+
return /* @__PURE__ */ React8.createElement(Box7, { borderStyle: "single", flexDirection: "column", borderColor: "blue", width: 80 }, /* @__PURE__ */ React8.createElement(TitleBar, null), !isFilelist ? /* @__PURE__ */ React8.createElement(Loading, null) : /* @__PURE__ */ React8.createElement(React8.Fragment, null, /* @__PURE__ */ React8.createElement(Box7, null, /* @__PURE__ */ React8.createElement(Box7, null, /* @__PURE__ */ React8.createElement(FileListStateBox, { filelist })), /* @__PURE__ */ React8.createElement(Box7, { flexBasis: 30 }, /* @__PURE__ */ React8.createElement(KeyboardControlsInfo, null))), filelist.getActiveFiles().map((file) => {
|
|
707
|
+
return /* @__PURE__ */ React8.createElement(FileBox, { file, key: file.name });
|
|
708
|
+
})));
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// src/cli/ui/index.tsx
|
|
712
|
+
function createReactInk() {
|
|
713
|
+
return render(/* @__PURE__ */ React9.createElement(App, null));
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// src/services/downloader.ts
|
|
717
|
+
import fs2 from "node:fs";
|
|
718
|
+
import { Readable, Transform } from "node:stream";
|
|
719
|
+
import { pipeline as pipeline2 } from "node:stream/promises";
|
|
720
|
+
import { Subject } from "rxjs";
|
|
721
|
+
|
|
722
|
+
// src/utils/promise.ts
|
|
723
|
+
async function sleep(time) {
|
|
724
|
+
return new Promise((resolve) => setTimeout(resolve, time));
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// src/utils/timer.ts
|
|
728
|
+
var Timer = class _Timer {
|
|
729
|
+
constructor(timeout = 1e4, timeoutCallback) {
|
|
730
|
+
this.timeout = timeout;
|
|
731
|
+
this.timeoutCallback = timeoutCallback;
|
|
732
|
+
this.timeout = timeout;
|
|
733
|
+
}
|
|
734
|
+
timer;
|
|
735
|
+
start() {
|
|
736
|
+
this.timer = setTimeout(() => {
|
|
737
|
+
this.stop();
|
|
738
|
+
this.timeoutCallback();
|
|
739
|
+
}, this.timeout);
|
|
740
|
+
return this;
|
|
741
|
+
}
|
|
742
|
+
stop() {
|
|
743
|
+
if (this.timer) {
|
|
744
|
+
clearTimeout(this.timer);
|
|
745
|
+
this.timer = void 0;
|
|
746
|
+
}
|
|
747
|
+
return this;
|
|
748
|
+
}
|
|
749
|
+
reset() {
|
|
750
|
+
this.stop();
|
|
751
|
+
this.start();
|
|
752
|
+
return this;
|
|
753
|
+
}
|
|
754
|
+
static withAbortController(timeout, abortControllerSubject, message = "TIMEOUT") {
|
|
755
|
+
const callback = () => {
|
|
756
|
+
abortControllerSubject.next(message);
|
|
757
|
+
};
|
|
758
|
+
const timer = new _Timer(timeout, callback).start();
|
|
759
|
+
return { timer };
|
|
760
|
+
}
|
|
761
|
+
};
|
|
762
|
+
|
|
763
|
+
// src/services/downloader.ts
|
|
764
|
+
var Downloader = class {
|
|
765
|
+
constructor(filelist, minSize, maxSize, chunkTimeout = 3e4, chunkFetchRetries = 5, fetchRetries = 7) {
|
|
766
|
+
this.filelist = filelist;
|
|
767
|
+
this.minSize = minSize;
|
|
768
|
+
this.maxSize = maxSize;
|
|
769
|
+
this.chunkTimeout = chunkTimeout;
|
|
770
|
+
this.chunkFetchRetries = chunkFetchRetries;
|
|
771
|
+
this.fetchRetries = fetchRetries;
|
|
772
|
+
this.setAbortControllerListener();
|
|
773
|
+
}
|
|
774
|
+
subject = new Subject();
|
|
775
|
+
abortController = new AbortController();
|
|
776
|
+
abortControllerSubject = new Subject();
|
|
777
|
+
setAbortControllerListener() {
|
|
778
|
+
this.abortControllerSubject.subscribe((type) => {
|
|
779
|
+
this.abortController.abort(type);
|
|
780
|
+
this.abortController = new AbortController();
|
|
781
|
+
});
|
|
782
|
+
}
|
|
783
|
+
async fetchStream(file, stream, sizeOld = 0, retries = this.chunkFetchRetries) {
|
|
784
|
+
const signal = this.abortController.signal;
|
|
785
|
+
const subject = this.subject;
|
|
786
|
+
const { timer } = Timer.withAbortController(
|
|
787
|
+
this.chunkTimeout,
|
|
788
|
+
this.abortControllerSubject
|
|
789
|
+
);
|
|
790
|
+
try {
|
|
791
|
+
const fileStream = fs2.createWriteStream(file.filepath, { flags: "a" });
|
|
792
|
+
const progressStream = new Transform({
|
|
793
|
+
transform(chunk, _encoding, callback) {
|
|
794
|
+
this.push(chunk);
|
|
795
|
+
file.downloaded += chunk.length;
|
|
796
|
+
timer.reset();
|
|
797
|
+
subject.next({ type: "CHUNK_DOWNLOADING_UPDATE" });
|
|
798
|
+
callback();
|
|
799
|
+
}
|
|
800
|
+
});
|
|
801
|
+
subject.next({ type: "CHUNK_DOWNLOADING_START" });
|
|
802
|
+
await pipeline2(stream, progressStream, fileStream, { signal });
|
|
803
|
+
} catch (error) {
|
|
804
|
+
if (signal.aborted) {
|
|
805
|
+
if (signal.reason === "FILE_SKIP") return;
|
|
806
|
+
if (signal.reason === "TIMEOUT") {
|
|
807
|
+
if (retries === 0 && sizeOld < file.downloaded) {
|
|
808
|
+
retries += this.chunkFetchRetries;
|
|
809
|
+
sizeOld = file.downloaded;
|
|
810
|
+
}
|
|
811
|
+
if (retries === 0) return;
|
|
812
|
+
return await this.fetchStream(file, stream, sizeOld, retries - 1);
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
throw error;
|
|
816
|
+
} finally {
|
|
817
|
+
subject.next({ type: "CHUNK_DOWNLOADING_END" });
|
|
818
|
+
timer.stop();
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
skip() {
|
|
822
|
+
this.abortControllerSubject.next("FILE_SKIP");
|
|
823
|
+
}
|
|
824
|
+
filterFileSize(file) {
|
|
825
|
+
if (!file.size) return;
|
|
826
|
+
if (this.minSize && file.size < this.minSize || this.maxSize && file.size > this.maxSize) {
|
|
827
|
+
try {
|
|
828
|
+
deleteFile(file.filepath);
|
|
829
|
+
} catch {
|
|
830
|
+
}
|
|
831
|
+
this.skip();
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
async downloadFile(file, retries = this.fetchRetries) {
|
|
836
|
+
const signal = this.abortController.signal;
|
|
837
|
+
try {
|
|
838
|
+
file.downloaded = await getFileSize(file.filepath);
|
|
839
|
+
const response = await fetchByteRange(file.url, file.downloaded, signal);
|
|
840
|
+
if (!response?.ok && response?.status !== 416) {
|
|
841
|
+
throw new Error(`HTTP error! status: ${response?.status}`);
|
|
842
|
+
}
|
|
843
|
+
const contentLength = response.headers.get("Content-Length");
|
|
844
|
+
if (!contentLength && file.downloaded > 0) return;
|
|
845
|
+
const restFileSize = parseInt(contentLength);
|
|
846
|
+
file.size = restFileSize + file.downloaded;
|
|
847
|
+
this.filterFileSize(file);
|
|
848
|
+
if (file.size > file.downloaded && response.body) {
|
|
849
|
+
const stream = Readable.fromWeb(response.body);
|
|
850
|
+
stream.setMaxListeners(20);
|
|
851
|
+
await this.fetchStream(file, stream, file.downloaded);
|
|
852
|
+
}
|
|
853
|
+
} catch (error) {
|
|
854
|
+
if (signal.aborted) {
|
|
855
|
+
if (signal.reason === "FILE_SKIP") return;
|
|
856
|
+
}
|
|
857
|
+
if (retries > 0) {
|
|
858
|
+
if (/coomer|kemono/.test(file.url)) {
|
|
859
|
+
file.url = tryFixCoomerUrl(file.url, retries);
|
|
860
|
+
}
|
|
861
|
+
await sleep(1e3);
|
|
862
|
+
return await this.downloadFile(file, retries - 1);
|
|
863
|
+
}
|
|
864
|
+
throw error;
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
async downloadFiles() {
|
|
868
|
+
mkdir(this.filelist.dirPath);
|
|
869
|
+
this.subject.next({ type: "FILES_DOWNLOADING_START" });
|
|
870
|
+
for (const file of this.filelist.files) {
|
|
871
|
+
file.active = true;
|
|
872
|
+
this.subject.next({ type: "FILE_DOWNLOADING_START" });
|
|
873
|
+
await this.downloadFile(file);
|
|
874
|
+
file.active = false;
|
|
875
|
+
this.subject.next({ type: "FILE_DOWNLOADING_END" });
|
|
876
|
+
}
|
|
877
|
+
this.subject.next({ type: "FILES_DOWNLOADING_END" });
|
|
878
|
+
}
|
|
879
|
+
};
|
|
880
|
+
|
|
632
881
|
// src/index.ts
|
|
633
882
|
async function run() {
|
|
634
|
-
|
|
883
|
+
createReactInk();
|
|
884
|
+
const { url, dir, media, include, exclude, minSize, maxSize, skip, removeDupilicates } = argumentHander();
|
|
635
885
|
const filelist = await apiHandler(url);
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
found,
|
|
644
|
-
skip,
|
|
645
|
-
filtered: found - filelist.files.length,
|
|
646
|
-
folder: filelist.dirPath
|
|
647
|
-
}
|
|
648
|
-
]);
|
|
886
|
+
filelist.setDirPath(dir).skip(skip).filterByText(include, exclude).filterByMediaType(media);
|
|
887
|
+
if (removeDupilicates) {
|
|
888
|
+
filelist.removeURLDuplicates();
|
|
889
|
+
}
|
|
890
|
+
const minSizeBytes = minSize ? parseSizeValue(minSize) : void 0;
|
|
891
|
+
const maxSizeBytes = maxSize ? parseSizeValue(maxSize) : void 0;
|
|
892
|
+
await filelist.calculateFileSizes();
|
|
649
893
|
setGlobalHeaders({ Referer: url });
|
|
650
|
-
const downloader = new Downloader();
|
|
651
|
-
|
|
652
|
-
await downloader.downloadFiles(
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
894
|
+
const downloader = new Downloader(filelist, minSizeBytes, maxSizeBytes);
|
|
895
|
+
useInkStore.getState().setDownloader(downloader);
|
|
896
|
+
await downloader.downloadFiles();
|
|
897
|
+
if (removeDupilicates) {
|
|
898
|
+
await filelist.removeDuplicatesByHash();
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
(async () => {
|
|
902
|
+
try {
|
|
903
|
+
await run();
|
|
904
|
+
process2.exit(0);
|
|
905
|
+
} catch (err) {
|
|
906
|
+
console.error("Fatal error:", err);
|
|
907
|
+
process2.exit(1);
|
|
908
|
+
}
|
|
909
|
+
})();
|