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.
Files changed (43) hide show
  1. package/README.md +14 -3
  2. package/biome.json +6 -4
  3. package/dist/index.js +539 -285
  4. package/docs/images/Screenshot 01.jpg +0 -0
  5. package/package.json +14 -5
  6. package/src/api/bunkr.ts +1 -1
  7. package/src/api/coomer-api.ts +23 -6
  8. package/src/api/gofile.ts +2 -2
  9. package/src/api/index.ts +5 -1
  10. package/src/api/nsfw.xxx.ts +3 -3
  11. package/src/api/plain-curl.ts +1 -1
  12. package/src/{args-handler.ts → cli/args-handler.ts} +17 -12
  13. package/src/cli/ui/app.tsx +40 -0
  14. package/src/cli/ui/components/file.tsx +44 -0
  15. package/src/cli/ui/components/filelist.tsx +52 -0
  16. package/src/cli/ui/components/index.ts +6 -0
  17. package/src/cli/ui/components/keyboardinfo.tsx +41 -0
  18. package/src/cli/ui/components/loading.tsx +20 -0
  19. package/src/cli/ui/components/preview.tsx +32 -0
  20. package/src/cli/ui/components/spinner.tsx +28 -0
  21. package/src/cli/ui/components/titlebar.tsx +15 -0
  22. package/src/cli/ui/hooks/downloader.ts +21 -0
  23. package/src/cli/ui/hooks/input.ts +17 -0
  24. package/src/cli/ui/index.tsx +7 -0
  25. package/src/cli/ui/store/index.ts +19 -0
  26. package/src/index.ts +42 -23
  27. package/src/logger/index.ts +15 -0
  28. package/src/services/downloader.ts +161 -0
  29. package/src/services/file.ts +113 -0
  30. package/src/types/index.ts +16 -1
  31. package/src/utils/duplicates.ts +23 -0
  32. package/src/utils/filters.ts +15 -15
  33. package/src/utils/io.ts +25 -0
  34. package/src/utils/mediatypes.ts +13 -0
  35. package/src/utils/promise.ts +0 -50
  36. package/src/utils/requests.ts +2 -2
  37. package/src/utils/strings.ts +1 -10
  38. package/src/utils/timer.ts +11 -9
  39. package/tsconfig.json +2 -1
  40. package/src/utils/downloader.ts +0 -108
  41. package/src/utils/file.ts +0 -75
  42. package/src/utils/index.ts +0 -11
  43. 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/utils/file.ts
10
+ // src/services/file.ts
11
11
  import os from "node:os";
12
12
  import path from "node:path";
13
13
 
14
- // src/utils/filters.ts
15
- function isImage(name) {
16
- return /\.(jpg|jpeg|png|gif|bmp|tiff|webp|avif)$/i.test(name);
17
- }
18
- function isVideo(name) {
19
- return /\.(mp4|m4v|avi|mov|mkv|webm|flv|wmv|mpeg|mpg|3gp)$/i.test(name);
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 testMediaType(name, type) {
22
- return type === "all" ? true : type === "image" ? isImage(name) : isVideo(name);
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/utils/file.ts
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
- state = "pause";
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
- file.filepath = path.join(this.dirPath, file.name);
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 < 1e3; 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 < 50) break;
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 = `${user.domain}/${f.path}`;
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", "all"],
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
- const { url, dir, media, include, exclude, skip } = argumentHander();
883
+ createReactInk();
884
+ const { url, dir, media, include, exclude, minSize, maxSize, skip, removeDupilicates } = argumentHander();
635
885
  const filelist = await apiHandler(url);
636
- const found = filelist.files.length;
637
- filelist.setDirPath(dir);
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
- ]);
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
- createMultibar(downloader);
652
- await downloader.downloadFiles(filelist);
653
- process2.kill(process2.pid, "SIGINT");
654
- }
655
- run();
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
+ })();