coomer-downloader 3.2.0 → 3.3.2
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/dist/index.js +388 -265
- package/package.json +13 -5
- package/src/api/bunkr.ts +1 -1
- package/src/api/coomer-api.ts +3 -2
- package/src/api/gofile.ts +2 -2
- package/src/api/index.ts +5 -1
- package/src/api/nsfw.xxx.ts +1 -1
- package/src/api/plain-curl.ts +1 -1
- 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 +16 -20
- package/src/services/downloader.ts +141 -0
- package/src/{utils → services}/file.ts +24 -4
- package/src/types/index.ts +14 -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 +10 -9
- package/tsconfig.json +2 -1
- package/src/utils/downloader.ts +0 -108
- package/src/utils/index.ts +0 -11
- package/src/utils/multibar.ts +0 -62
- /package/src/{args-handler.ts → cli/args-handler.ts} +0 -0
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,7 +7,7 @@ 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
|
|
|
@@ -36,9 +36,24 @@ function filterString(text, include, exclude) {
|
|
|
36
36
|
return includesAllWords(text, parseQuery(include)) && includesNoWords(text, parseQuery(exclude));
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
// src/utils/
|
|
39
|
+
// src/utils/io.ts
|
|
40
|
+
import fs from "node:fs";
|
|
41
|
+
async function getFileSize(filepath) {
|
|
42
|
+
let size = 0;
|
|
43
|
+
if (fs.existsSync(filepath)) {
|
|
44
|
+
size = (await fs.promises.stat(filepath)).size || 0;
|
|
45
|
+
}
|
|
46
|
+
return size;
|
|
47
|
+
}
|
|
48
|
+
function mkdir(filepath) {
|
|
49
|
+
if (!fs.existsSync(filepath)) {
|
|
50
|
+
fs.mkdirSync(filepath, { recursive: true });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// src/services/file.ts
|
|
40
55
|
var CoomerFile = class _CoomerFile {
|
|
41
|
-
constructor(name, url, filepath, size, downloaded, content) {
|
|
56
|
+
constructor(name, url, filepath, size, downloaded = 0, content) {
|
|
42
57
|
this.name = name;
|
|
43
58
|
this.url = url;
|
|
44
59
|
this.filepath = filepath;
|
|
@@ -46,7 +61,11 @@ var CoomerFile = class _CoomerFile {
|
|
|
46
61
|
this.downloaded = downloaded;
|
|
47
62
|
this.content = content;
|
|
48
63
|
}
|
|
49
|
-
|
|
64
|
+
active = false;
|
|
65
|
+
async getDownloadedSize() {
|
|
66
|
+
this.downloaded = await getFileSize(this.filepath);
|
|
67
|
+
return this;
|
|
68
|
+
}
|
|
50
69
|
get textContent() {
|
|
51
70
|
const text = `${this.name || ""} ${this.content || ""}`.toLowerCase();
|
|
52
71
|
return text;
|
|
@@ -87,6 +106,17 @@ var CoomerFileList = class {
|
|
|
87
106
|
this.files = this.files.slice(n);
|
|
88
107
|
return this;
|
|
89
108
|
}
|
|
109
|
+
async calculateFileSizes() {
|
|
110
|
+
for (const file of this.files) {
|
|
111
|
+
await file.getDownloadedSize();
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
getActiveFiles() {
|
|
115
|
+
return this.files.filter((f) => f.active);
|
|
116
|
+
}
|
|
117
|
+
getDownloaded() {
|
|
118
|
+
return this.files.filter((f) => f.size && f.size <= f.downloaded);
|
|
119
|
+
}
|
|
90
120
|
};
|
|
91
121
|
|
|
92
122
|
// src/api/bunkr.ts
|
|
@@ -139,67 +169,6 @@ async function getBunkrData(url) {
|
|
|
139
169
|
return filelist;
|
|
140
170
|
}
|
|
141
171
|
|
|
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
172
|
// src/utils/requests.ts
|
|
204
173
|
import { CookieAgent } from "http-cookie-agent/undici";
|
|
205
174
|
import { CookieJar } from "tough-cookie";
|
|
@@ -223,190 +192,10 @@ function fetchWithGlobalHeader(url) {
|
|
|
223
192
|
const requestHeaders = new Headers(HeadersDefault);
|
|
224
193
|
return fetch2(url, { headers: requestHeaders });
|
|
225
194
|
}
|
|
226
|
-
function fetchByteRange(url, downloadedSize) {
|
|
195
|
+
function fetchByteRange(url, downloadedSize, signal) {
|
|
227
196
|
const requestHeaders = new Headers(HeadersDefault);
|
|
228
197
|
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
|
-
});
|
|
198
|
+
return fetch2(url, { headers: requestHeaders, signal });
|
|
410
199
|
}
|
|
411
200
|
|
|
412
201
|
// src/api/coomer-api.ts
|
|
@@ -596,7 +385,7 @@ async function apiHandler(url_) {
|
|
|
596
385
|
throw Error("Invalid URL");
|
|
597
386
|
}
|
|
598
387
|
|
|
599
|
-
// src/args-handler.ts
|
|
388
|
+
// src/cli/args-handler.ts
|
|
600
389
|
import yargs from "yargs";
|
|
601
390
|
import { hideBin } from "yargs/helpers";
|
|
602
391
|
function argumentHander() {
|
|
@@ -629,27 +418,361 @@ function argumentHander() {
|
|
|
629
418
|
}).help().alias("help", "h").parseSync();
|
|
630
419
|
}
|
|
631
420
|
|
|
421
|
+
// src/cli/ui/index.tsx
|
|
422
|
+
import { render } from "ink";
|
|
423
|
+
import React9 from "react";
|
|
424
|
+
|
|
425
|
+
// src/cli/ui/app.tsx
|
|
426
|
+
import { Box as Box7 } from "ink";
|
|
427
|
+
import React8 from "react";
|
|
428
|
+
|
|
429
|
+
// src/cli/ui/components/file.tsx
|
|
430
|
+
import { Box as Box2, Spacer, Text as Text2 } from "ink";
|
|
431
|
+
import React3 from "react";
|
|
432
|
+
|
|
433
|
+
// src/utils/strings.ts
|
|
434
|
+
function b2mb(bytes) {
|
|
435
|
+
return (bytes / 1048576).toFixed(2);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// src/cli/ui/components/preview.tsx
|
|
439
|
+
import { Box } from "ink";
|
|
440
|
+
import Image, { TerminalInfoProvider } from "ink-picture";
|
|
441
|
+
import React from "react";
|
|
442
|
+
|
|
443
|
+
// src/cli/ui/store/index.ts
|
|
444
|
+
import { create } from "zustand";
|
|
445
|
+
var useInkStore = create((set) => ({
|
|
446
|
+
preview: false,
|
|
447
|
+
switchPreview: () => set((state) => ({
|
|
448
|
+
preview: !state.preview
|
|
449
|
+
})),
|
|
450
|
+
downloader: void 0,
|
|
451
|
+
setDownloader: (downloader) => set({ downloader })
|
|
452
|
+
}));
|
|
453
|
+
|
|
454
|
+
// src/cli/ui/components/preview.tsx
|
|
455
|
+
function Preview({ file }) {
|
|
456
|
+
const previewEnabled = useInkStore((state) => state.preview);
|
|
457
|
+
const bigEnough = file.downloaded > 50 * 1024;
|
|
458
|
+
const shouldShow = previewEnabled && bigEnough && isImage(file.filepath);
|
|
459
|
+
const imgInfo = `
|
|
460
|
+
can't read partial images yet...
|
|
461
|
+
actual size: ${file.size}}
|
|
462
|
+
downloaded: ${file.downloaded}}
|
|
463
|
+
`;
|
|
464
|
+
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 }))));
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// src/cli/ui/components/spinner.tsx
|
|
468
|
+
import spinners from "cli-spinners";
|
|
469
|
+
import { Text } from "ink";
|
|
470
|
+
import React2, { useEffect, useState } from "react";
|
|
471
|
+
function Spinner({ type = "dots" }) {
|
|
472
|
+
const spinner = spinners[type];
|
|
473
|
+
const randomFrame = spinner.frames.length * Math.random() | 0;
|
|
474
|
+
const [frame, setFrame] = useState(randomFrame);
|
|
475
|
+
useEffect(() => {
|
|
476
|
+
const timer = setInterval(() => {
|
|
477
|
+
setFrame((previousFrame) => {
|
|
478
|
+
return (previousFrame + 1) % spinner.frames.length;
|
|
479
|
+
});
|
|
480
|
+
}, spinner.interval);
|
|
481
|
+
return () => {
|
|
482
|
+
clearInterval(timer);
|
|
483
|
+
};
|
|
484
|
+
}, [spinner]);
|
|
485
|
+
return /* @__PURE__ */ React2.createElement(Text, null, spinner.frames[frame]);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// src/cli/ui/components/file.tsx
|
|
489
|
+
function FileBox({ file }) {
|
|
490
|
+
const percentage = Number(file.downloaded / file.size * 100).toFixed(2);
|
|
491
|
+
return /* @__PURE__ */ React3.createElement(React3.Fragment, null, /* @__PURE__ */ React3.createElement(
|
|
492
|
+
Box2,
|
|
493
|
+
{
|
|
494
|
+
borderStyle: "single",
|
|
495
|
+
borderColor: "magentaBright",
|
|
496
|
+
borderDimColor: true,
|
|
497
|
+
paddingX: 1,
|
|
498
|
+
flexDirection: "column"
|
|
499
|
+
},
|
|
500
|
+
/* @__PURE__ */ React3.createElement(Box2, null, /* @__PURE__ */ React3.createElement(Text2, { color: "blue", dimColor: true, wrap: "truncate-middle" }, file.name)),
|
|
501
|
+
/* @__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)))
|
|
502
|
+
), /* @__PURE__ */ React3.createElement(Preview, { file }));
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// src/cli/ui/components/filelist.tsx
|
|
506
|
+
import { Box as Box3, Text as Text3 } from "ink";
|
|
507
|
+
import React4 from "react";
|
|
508
|
+
function FileListStateBox({ filelist }) {
|
|
509
|
+
return /* @__PURE__ */ React4.createElement(
|
|
510
|
+
Box3,
|
|
511
|
+
{
|
|
512
|
+
paddingX: 1,
|
|
513
|
+
flexDirection: "column",
|
|
514
|
+
borderStyle: "single",
|
|
515
|
+
borderColor: "magenta",
|
|
516
|
+
borderDimColor: true
|
|
517
|
+
},
|
|
518
|
+
/* @__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)),
|
|
519
|
+
/* @__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)),
|
|
520
|
+
/* @__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)))
|
|
521
|
+
);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// src/cli/ui/components/keyboardinfo.tsx
|
|
525
|
+
import { Box as Box4, Text as Text4 } from "ink";
|
|
526
|
+
import React5 from "react";
|
|
527
|
+
var info = {
|
|
528
|
+
"s ": "skip current file",
|
|
529
|
+
p: "on/off image preview"
|
|
530
|
+
};
|
|
531
|
+
function KeyboardControlsInfo() {
|
|
532
|
+
const infoRender = Object.entries(info).map(([key, value]) => {
|
|
533
|
+
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));
|
|
534
|
+
});
|
|
535
|
+
return /* @__PURE__ */ React5.createElement(
|
|
536
|
+
Box4,
|
|
537
|
+
{
|
|
538
|
+
flexDirection: "column",
|
|
539
|
+
paddingX: 1,
|
|
540
|
+
borderStyle: "single",
|
|
541
|
+
borderColor: "gray",
|
|
542
|
+
borderDimColor: true
|
|
543
|
+
},
|
|
544
|
+
/* @__PURE__ */ React5.createElement(Box4, null, /* @__PURE__ */ React5.createElement(Text4, { color: "red", dimColor: true, bold: true }, "Keyboard controls:")),
|
|
545
|
+
infoRender
|
|
546
|
+
);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// src/cli/ui/components/loading.tsx
|
|
550
|
+
import { Box as Box5, Text as Text5 } from "ink";
|
|
551
|
+
import React6 from "react";
|
|
552
|
+
function Loading() {
|
|
553
|
+
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" }))));
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// src/cli/ui/components/titlebar.tsx
|
|
557
|
+
import { Box as Box6, Spacer as Spacer2, Text as Text6 } from "ink";
|
|
558
|
+
import React7 from "react";
|
|
559
|
+
|
|
560
|
+
// package.json
|
|
561
|
+
var version = "3.3.2";
|
|
562
|
+
|
|
563
|
+
// src/cli/ui/components/titlebar.tsx
|
|
564
|
+
function TitleBar() {
|
|
565
|
+
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));
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// src/cli/ui/hooks/downloader.ts
|
|
569
|
+
import { useEffect as useEffect2, useState as useState2 } from "react";
|
|
570
|
+
var useDownloaderHook = () => {
|
|
571
|
+
const downloader = useInkStore((state) => state.downloader);
|
|
572
|
+
const filelist = downloader?.filelist;
|
|
573
|
+
const [_, setHelper] = useState2(0);
|
|
574
|
+
useEffect2(() => {
|
|
575
|
+
downloader?.subject.subscribe(({ type }) => {
|
|
576
|
+
if (type === "FILE_DOWNLOADING_START" || type === "FILE_DOWNLOADING_END" || type === "CHUNK_DOWNLOADING_UPDATE") {
|
|
577
|
+
setHelper(Date.now());
|
|
578
|
+
}
|
|
579
|
+
});
|
|
580
|
+
});
|
|
581
|
+
};
|
|
582
|
+
|
|
583
|
+
// src/cli/ui/hooks/input.ts
|
|
584
|
+
import { useInput } from "ink";
|
|
585
|
+
var useInputHook = () => {
|
|
586
|
+
const downloader = useInkStore((state) => state.downloader);
|
|
587
|
+
const switchPreview = useInkStore((state) => state.switchPreview);
|
|
588
|
+
useInput((input) => {
|
|
589
|
+
if (input === "s") {
|
|
590
|
+
downloader?.skip();
|
|
591
|
+
}
|
|
592
|
+
if (input === "p") {
|
|
593
|
+
switchPreview();
|
|
594
|
+
}
|
|
595
|
+
});
|
|
596
|
+
};
|
|
597
|
+
|
|
598
|
+
// src/cli/ui/app.tsx
|
|
599
|
+
function App() {
|
|
600
|
+
useInputHook();
|
|
601
|
+
useDownloaderHook();
|
|
602
|
+
const downloader = useInkStore((state) => state.downloader);
|
|
603
|
+
const filelist = downloader?.filelist;
|
|
604
|
+
const isFilelist = filelist instanceof CoomerFileList;
|
|
605
|
+
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: 29 }, /* @__PURE__ */ React8.createElement(KeyboardControlsInfo, null))), filelist.getActiveFiles().map((file) => {
|
|
606
|
+
return /* @__PURE__ */ React8.createElement(FileBox, { file, key: file.name });
|
|
607
|
+
})));
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// src/cli/ui/index.tsx
|
|
611
|
+
function createReactInk() {
|
|
612
|
+
return render(/* @__PURE__ */ React9.createElement(App, null));
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// src/services/downloader.ts
|
|
616
|
+
import fs2 from "node:fs";
|
|
617
|
+
import { Readable, Transform } from "node:stream";
|
|
618
|
+
import { pipeline } from "node:stream/promises";
|
|
619
|
+
import { Subject } from "rxjs";
|
|
620
|
+
|
|
621
|
+
// src/utils/promise.ts
|
|
622
|
+
async function sleep(time) {
|
|
623
|
+
return new Promise((resolve) => setTimeout(resolve, time));
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// src/utils/timer.ts
|
|
627
|
+
var Timer = class _Timer {
|
|
628
|
+
constructor(timeout = 1e4, timeoutCallback) {
|
|
629
|
+
this.timeout = timeout;
|
|
630
|
+
this.timeoutCallback = timeoutCallback;
|
|
631
|
+
this.timeout = timeout;
|
|
632
|
+
}
|
|
633
|
+
timer;
|
|
634
|
+
start() {
|
|
635
|
+
this.timer = setTimeout(() => {
|
|
636
|
+
this.stop();
|
|
637
|
+
this.timeoutCallback();
|
|
638
|
+
}, this.timeout);
|
|
639
|
+
return this;
|
|
640
|
+
}
|
|
641
|
+
stop() {
|
|
642
|
+
if (this.timer) {
|
|
643
|
+
clearTimeout(this.timer);
|
|
644
|
+
this.timer = void 0;
|
|
645
|
+
}
|
|
646
|
+
return this;
|
|
647
|
+
}
|
|
648
|
+
reset() {
|
|
649
|
+
this.stop();
|
|
650
|
+
this.start();
|
|
651
|
+
return this;
|
|
652
|
+
}
|
|
653
|
+
static withAbortController(timeout, abortControllerSubject, message = "Timeout") {
|
|
654
|
+
const callback = () => {
|
|
655
|
+
abortControllerSubject.next(message);
|
|
656
|
+
};
|
|
657
|
+
const timer = new _Timer(timeout, callback).start();
|
|
658
|
+
return { timer };
|
|
659
|
+
}
|
|
660
|
+
};
|
|
661
|
+
|
|
662
|
+
// src/services/downloader.ts
|
|
663
|
+
var Downloader = class {
|
|
664
|
+
constructor(filelist, chunkTimeout = 3e4, chunkFetchRetries = 5, fetchRetries = 7) {
|
|
665
|
+
this.filelist = filelist;
|
|
666
|
+
this.chunkTimeout = chunkTimeout;
|
|
667
|
+
this.chunkFetchRetries = chunkFetchRetries;
|
|
668
|
+
this.fetchRetries = fetchRetries;
|
|
669
|
+
this.setAbortControllerListener();
|
|
670
|
+
}
|
|
671
|
+
subject = new Subject();
|
|
672
|
+
abortController = new AbortController();
|
|
673
|
+
abortControllerSubject = new Subject();
|
|
674
|
+
setAbortControllerListener() {
|
|
675
|
+
this.abortControllerSubject.subscribe((type) => {
|
|
676
|
+
this.abortController.abort(type);
|
|
677
|
+
this.abortController = new AbortController();
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
async fetchStream(file, stream, sizeOld = 0, retries = this.chunkFetchRetries) {
|
|
681
|
+
const signal = this.abortController.signal;
|
|
682
|
+
const subject = this.subject;
|
|
683
|
+
const { timer } = Timer.withAbortController(this.chunkTimeout, this.abortControllerSubject);
|
|
684
|
+
let i;
|
|
685
|
+
try {
|
|
686
|
+
const fileStream = fs2.createWriteStream(file.filepath, { flags: "a" });
|
|
687
|
+
const progressStream = new Transform({
|
|
688
|
+
transform(chunk, _encoding, callback) {
|
|
689
|
+
this.push(chunk);
|
|
690
|
+
file.downloaded += chunk.length;
|
|
691
|
+
timer.reset();
|
|
692
|
+
subject.next({ type: "CHUNK_DOWNLOADING_UPDATE" });
|
|
693
|
+
callback();
|
|
694
|
+
}
|
|
695
|
+
});
|
|
696
|
+
subject.next({ type: "CHUNK_DOWNLOADING_START" });
|
|
697
|
+
await pipeline(stream, progressStream, fileStream, { signal });
|
|
698
|
+
} catch (error) {
|
|
699
|
+
if (signal.aborted) {
|
|
700
|
+
if (signal.reason === "FILE_SKIP") return;
|
|
701
|
+
if (signal.reason === "TIMEOUT") {
|
|
702
|
+
if (retries === 0 && sizeOld < file.downloaded) {
|
|
703
|
+
retries += this.chunkFetchRetries;
|
|
704
|
+
sizeOld = file.downloaded;
|
|
705
|
+
}
|
|
706
|
+
if (retries === 0) return;
|
|
707
|
+
return await this.fetchStream(file, stream, sizeOld, retries - 1);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
throw error;
|
|
711
|
+
} finally {
|
|
712
|
+
subject.next({ type: "CHUNK_DOWNLOADING_END" });
|
|
713
|
+
timer.stop();
|
|
714
|
+
clearInterval(i);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
skip() {
|
|
718
|
+
this.abortControllerSubject.next("FILE_SKIP");
|
|
719
|
+
}
|
|
720
|
+
async downloadFile(file, retries = this.fetchRetries) {
|
|
721
|
+
const signal = this.abortController.signal;
|
|
722
|
+
try {
|
|
723
|
+
file.downloaded = await getFileSize(file.filepath);
|
|
724
|
+
const response = await fetchByteRange(file.url, file.downloaded, signal);
|
|
725
|
+
if (!response?.ok && response?.status !== 416) {
|
|
726
|
+
throw new Error(`HTTP error! status: ${response?.status}`);
|
|
727
|
+
}
|
|
728
|
+
const contentLength = response.headers.get("Content-Length");
|
|
729
|
+
if (!contentLength && file.downloaded > 0) return;
|
|
730
|
+
const restFileSize = parseInt(contentLength);
|
|
731
|
+
file.size = restFileSize + file.downloaded;
|
|
732
|
+
if (file.size > file.downloaded && response.body) {
|
|
733
|
+
const stream = Readable.fromWeb(response.body);
|
|
734
|
+
stream.setMaxListeners(20);
|
|
735
|
+
await this.fetchStream(file, stream, file.downloaded);
|
|
736
|
+
}
|
|
737
|
+
} catch (error) {
|
|
738
|
+
if (signal.aborted) {
|
|
739
|
+
if (signal.reason === "FILE_SKIP") return;
|
|
740
|
+
}
|
|
741
|
+
if (retries > 0) {
|
|
742
|
+
if (/coomer|kemono/.test(file.url)) {
|
|
743
|
+
file.url = tryFixCoomerUrl(file.url, retries);
|
|
744
|
+
}
|
|
745
|
+
await sleep(1e3);
|
|
746
|
+
return await this.downloadFile(file, retries - 1);
|
|
747
|
+
}
|
|
748
|
+
throw error;
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
async downloadFiles() {
|
|
752
|
+
mkdir(this.filelist.dirPath);
|
|
753
|
+
this.subject.next({ type: "FILES_DOWNLOADING_START" });
|
|
754
|
+
for (const file of this.filelist.files) {
|
|
755
|
+
file.active = true;
|
|
756
|
+
this.subject.next({ type: "FILE_DOWNLOADING_START" });
|
|
757
|
+
await this.downloadFile(file);
|
|
758
|
+
file.active = false;
|
|
759
|
+
this.subject.next({ type: "FILE_DOWNLOADING_END" });
|
|
760
|
+
}
|
|
761
|
+
this.subject.next({ type: "FILES_DOWNLOADING_END" });
|
|
762
|
+
}
|
|
763
|
+
};
|
|
764
|
+
|
|
632
765
|
// src/index.ts
|
|
633
766
|
async function run() {
|
|
767
|
+
createReactInk();
|
|
634
768
|
const { url, dir, media, include, exclude, skip } = argumentHander();
|
|
635
769
|
const filelist = await apiHandler(url);
|
|
636
|
-
|
|
637
|
-
filelist.
|
|
638
|
-
filelist.skip(skip);
|
|
639
|
-
filelist.filterByText(include, exclude);
|
|
640
|
-
filelist.filterByMediaType(media);
|
|
641
|
-
console.table([
|
|
642
|
-
{
|
|
643
|
-
found,
|
|
644
|
-
skip,
|
|
645
|
-
filtered: found - filelist.files.length,
|
|
646
|
-
folder: filelist.dirPath
|
|
647
|
-
}
|
|
648
|
-
]);
|
|
770
|
+
filelist.setDirPath(dir).skip(skip).filterByText(include, exclude).filterByMediaType(media);
|
|
771
|
+
await filelist.calculateFileSizes();
|
|
649
772
|
setGlobalHeaders({ Referer: url });
|
|
650
|
-
const downloader = new Downloader();
|
|
651
|
-
|
|
652
|
-
await downloader.downloadFiles(
|
|
773
|
+
const downloader = new Downloader(filelist);
|
|
774
|
+
useInkStore.getState().setDownloader(downloader);
|
|
775
|
+
await downloader.downloadFiles();
|
|
653
776
|
process2.kill(process2.pid, "SIGINT");
|
|
654
777
|
}
|
|
655
778
|
run();
|