coomer-downloader 3.4.0 → 3.4.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 CHANGED
@@ -3,28 +3,71 @@
3
3
  // src/index.ts
4
4
  import process2 from "node:process";
5
5
 
6
- // src/api/bunkr.ts
6
+ // src/api/providers/bunkr.ts
7
7
  import * as cheerio from "cheerio";
8
8
  import { fetch } from "undici";
9
9
 
10
- // src/services/file.ts
10
+ // src/utils/io.ts
11
+ import { createHash } from "node:crypto";
12
+ import fs from "node:fs";
13
+ import { access, constants, unlink } from "node:fs/promises";
14
+ import { pipeline } from "node:stream/promises";
15
+ async function getFileSize(filepath) {
16
+ let size = 0;
17
+ if (fs.existsSync(filepath)) {
18
+ size = (await fs.promises.stat(filepath)).size || 0;
19
+ }
20
+ return size;
21
+ }
22
+ async function getFileHash(filepath) {
23
+ const hash = createHash("sha256");
24
+ const filestream = fs.createReadStream(filepath);
25
+ await pipeline(filestream, hash);
26
+ return hash.digest("hex");
27
+ }
28
+ function mkdir(filepath) {
29
+ if (!fs.existsSync(filepath)) {
30
+ fs.mkdirSync(filepath, { recursive: true });
31
+ }
32
+ }
33
+ async function deleteFile(path2) {
34
+ await access(path2, constants.F_OK);
35
+ await unlink(path2);
36
+ }
37
+ function sanitizeFilename(name) {
38
+ if (!name) return name;
39
+ return name.replace(/[<>:"/\\|?*\x00-\x1F]/g, "-").replace(/\s+/g, " ").trim().replace(/[.]+$/, "");
40
+ }
41
+
42
+ // src/core/file.ts
43
+ var CoomerFile = class _CoomerFile {
44
+ constructor(name, url, filepath = "", size, downloaded = 0, content) {
45
+ this.name = name;
46
+ this.url = url;
47
+ this.filepath = filepath;
48
+ this.size = size;
49
+ this.downloaded = downloaded;
50
+ this.content = content;
51
+ }
52
+ active = false;
53
+ hash;
54
+ async calcDownloadedSize() {
55
+ this.downloaded = await getFileSize(this.filepath);
56
+ return this;
57
+ }
58
+ get textContent() {
59
+ const text = `${this.name || ""} ${this.content || ""}`.toLowerCase();
60
+ return text;
61
+ }
62
+ static from(f) {
63
+ return new _CoomerFile(f.name, f.url, f.filepath, f.size, f.downloaded, f.content);
64
+ }
65
+ };
66
+
67
+ // src/core/filelist.ts
11
68
  import os from "node:os";
12
69
  import path from "node:path";
13
70
 
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
71
  // src/utils/duplicates.ts
29
72
  function collectUniquesAndDuplicatesBy(xs, k) {
30
73
  const seen = /* @__PURE__ */ new Set();
@@ -70,38 +113,6 @@ function parseSizeValue(s) {
70
113
  return Math.floor(val * mult);
71
114
  }
72
115
 
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
116
  // src/utils/mediatypes.ts
106
117
  function isImage(name) {
107
118
  return /\.(jpg|jpeg|png|gif|bmp|tiff|webp|avif)$/i.test(name);
@@ -113,36 +124,14 @@ function testMediaType(name, type) {
113
124
  return type === "image" ? isImage(name) : isVideo(name);
114
125
  }
115
126
 
116
- // src/services/file.ts
117
- var CoomerFile = class _CoomerFile {
118
- constructor(name, url, filepath = "", size, downloaded = 0, content) {
119
- this.name = name;
120
- this.url = url;
121
- this.filepath = filepath;
122
- this.size = size;
123
- this.downloaded = downloaded;
124
- this.content = content;
125
- }
126
- active = false;
127
- hash;
128
- async getDownloadedSize() {
129
- this.downloaded = await getFileSize(this.filepath);
130
- return this;
131
- }
132
- get textContent() {
133
- const text = `${this.name || ""} ${this.content || ""}`.toLowerCase();
134
- return text;
135
- }
136
- static from(f) {
137
- return new _CoomerFile(f.name, f.url, f.filepath, f.size, f.downloaded, f.content);
138
- }
139
- };
127
+ // src/core/filelist.ts
140
128
  var CoomerFileList = class {
141
129
  constructor(files = []) {
142
130
  this.files = files;
143
131
  }
144
132
  dirPath;
145
133
  dirName;
134
+ provider;
146
135
  setDirPath(dir, dirName) {
147
136
  dirName = dirName || this.dirName;
148
137
  if (dir === "./") {
@@ -172,7 +161,7 @@ var CoomerFileList = class {
172
161
  }
173
162
  async calculateFileSizes() {
174
163
  for (const file of this.files) {
175
- await file.getDownloadedSize();
164
+ await file.calcDownloadedSize();
176
165
  }
177
166
  return this;
178
167
  }
@@ -187,8 +176,6 @@ var CoomerFileList = class {
187
176
  file.hash = await getFileHash(file.filepath);
188
177
  }
189
178
  const { duplicates } = collectUniquesAndDuplicatesBy(this.files, "hash");
190
- console.log({ duplicates });
191
- logger_default.debug(`duplicates: ${JSON.stringify(duplicates)}`);
192
179
  duplicates.forEach((f) => {
193
180
  deleteFile(f.filepath);
194
181
  });
@@ -199,7 +186,7 @@ var CoomerFileList = class {
199
186
  }
200
187
  };
201
188
 
202
- // src/api/bunkr.ts
189
+ // src/api/providers/bunkr.ts
203
190
  async function getEncryptionData(slug) {
204
191
  const response = await fetch("https://bunkr.cr/api/vs", {
205
192
  method: "POST",
@@ -212,7 +199,9 @@ function decryptEncryptedUrl(encryptionData) {
212
199
  const secretKey = `SECRET_KEY_${Math.floor(encryptionData.timestamp / 3600)}`;
213
200
  const encryptedUrlBuffer = Buffer.from(encryptionData.url, "base64");
214
201
  const secretKeyBuffer = Buffer.from(secretKey, "utf-8");
215
- return Array.from(encryptedUrlBuffer).map((byte, i) => String.fromCharCode(byte ^ secretKeyBuffer[i % secretKeyBuffer.length])).join("");
202
+ return Array.from(encryptedUrlBuffer).map(
203
+ (byte, i) => String.fromCharCode(byte ^ secretKeyBuffer[i % secretKeyBuffer.length])
204
+ ).join("");
216
205
  }
217
206
  async function getFileData(url, name) {
218
207
  const slug = url.split("/").pop();
@@ -244,10 +233,15 @@ async function getGalleryFiles(url) {
244
233
  }
245
234
  return filelist;
246
235
  }
247
- async function getBunkrData(url) {
248
- const filelist = await getGalleryFiles(url);
249
- return filelist;
250
- }
236
+ var BunkrAPI = class {
237
+ testURL(url) {
238
+ return /bunkr/.test(url.origin);
239
+ }
240
+ async getData(url) {
241
+ const filelist = await getGalleryFiles(url);
242
+ return filelist;
243
+ }
244
+ };
251
245
 
252
246
  // src/utils/requests.ts
253
247
  import { CookieAgent } from "http-cookie-agent/undici";
@@ -278,20 +272,7 @@ function fetchByteRange(url, downloadedSize, signal) {
278
272
  return fetch2(url, { headers: requestHeaders, signal });
279
273
  }
280
274
 
281
- // src/api/coomer-api.ts
282
- var SERVERS = ["n1", "n2", "n3", "n4"];
283
- function tryFixCoomerUrl(url, attempts) {
284
- if (attempts < 2 && isImage(url)) {
285
- return url.replace(/\/data\//, "/thumbnail/data/").replace(/n\d\./, "img.");
286
- }
287
- const server = url.match(/n\d\./)?.[0].slice(0, 2);
288
- const i = SERVERS.indexOf(server);
289
- if (i !== -1) {
290
- const newServer = SERVERS[(i + 1) % SERVERS.length];
291
- return url.replace(/n\d./, `${newServer}.`);
292
- }
293
- return url;
294
- }
275
+ // src/api/providers/coomer.ts
295
276
  async function getUserProfileData(user) {
296
277
  const url = `${user.domain}/api/v1/${user.service}/user/${user.id}/profile`;
297
278
  const result = await fetchWithGlobalHeader(url).then((r) => r.json());
@@ -344,15 +325,33 @@ async function parseUser(url) {
344
325
  const { name } = await getUserProfileData({ domain, service, id });
345
326
  return { domain, service, id, name };
346
327
  }
347
- async function getCoomerData(url) {
348
- setGlobalHeaders({ accept: "text/css" });
349
- const user = await parseUser(url);
350
- const filelist = await getUserFiles(user);
351
- filelist.dirName = `${user.name}-${user.service}`;
352
- return filelist;
353
- }
328
+ var CoomerAPI = class _CoomerAPI {
329
+ static SERVERS = ["n1", "n2", "n3", "n4"];
330
+ fixURL(url, retries) {
331
+ if (retries < 2 && isImage(url)) {
332
+ return url.replace(/\/data\//, "/thumbnail/data/").replace(/n\d\./, "img.");
333
+ }
334
+ const server = url.match(/n\d\./)?.[0].slice(0, 2);
335
+ const i = _CoomerAPI.SERVERS.indexOf(server);
336
+ if (i !== -1) {
337
+ const newServer = _CoomerAPI.SERVERS[(i + 1) % _CoomerAPI.SERVERS.length];
338
+ return url.replace(/n\d./, `${newServer}.`);
339
+ }
340
+ return url;
341
+ }
342
+ testURL(url) {
343
+ return /coomer|kemono/.test(url.origin);
344
+ }
345
+ async getData(url) {
346
+ setGlobalHeaders({ accept: "text/css" });
347
+ const user = await parseUser(url);
348
+ const filelist = await getUserFiles(user);
349
+ filelist.dirName = `${user.name}-${user.service}`;
350
+ return filelist;
351
+ }
352
+ };
354
353
 
355
- // src/api/gofile.ts
354
+ // src/api/providers/gofile.ts
356
355
  import { fetch as fetch3 } from "undici";
357
356
  async function getToken() {
358
357
  const response = await fetch3("https://api.gofile.io/accounts", {
@@ -362,7 +361,7 @@ async function getToken() {
362
361
  if (data.status === "ok") {
363
362
  return data.data.token;
364
363
  }
365
- throw new Error("cannot get token");
364
+ throw new Error("Token Not Found");
366
365
  }
367
366
  async function getWebsiteToken() {
368
367
  const response = await fetch3("https://gofile.io/dist/js/global.js");
@@ -392,19 +391,54 @@ async function getFolderFiles(id, token, websiteToken) {
392
391
  );
393
392
  return new CoomerFileList(files);
394
393
  }
395
- async function getGofileData(url) {
396
- const id = url.match(/gofile.io\/d\/(\w+)/)?.[1];
397
- const token = await getToken();
398
- const websiteToken = await getWebsiteToken();
399
- const filelist = await getFolderFiles(id, token, websiteToken);
400
- filelist.dirName = `gofile-${id}`;
401
- setGlobalHeaders({ Cookie: `accountToken=${token}` });
402
- return filelist;
403
- }
394
+ var GofileAPI = class {
395
+ testURL(url) {
396
+ return /gofile\.io/.test(url.origin);
397
+ }
398
+ async getData(url) {
399
+ const id = url.match(/gofile.io\/d\/(\w+)/)?.[1];
400
+ const token = await getToken();
401
+ const websiteToken = await getWebsiteToken();
402
+ const filelist = await getFolderFiles(id, token, websiteToken);
403
+ filelist.dirName = `gofile-${id}`;
404
+ setGlobalHeaders({ Cookie: `accountToken=${token}` });
405
+ return filelist;
406
+ }
407
+ };
408
+
409
+ // src/api/providers/plainfile.ts
410
+ var PlainFileAPI = class {
411
+ testURL(url) {
412
+ return /\.\w+/.test(url.pathname);
413
+ }
414
+ async getData(url) {
415
+ const name = url.split("/").pop();
416
+ const file = CoomerFile.from({ name, url });
417
+ const filelist = new CoomerFileList([file]);
418
+ filelist.dirName = "";
419
+ return filelist;
420
+ }
421
+ };
404
422
 
405
- // src/api/nsfw.xxx.ts
423
+ // src/api/providers/reddit.ts
406
424
  import * as cheerio2 from "cheerio";
407
425
  import { fetch as fetch4 } from "undici";
426
+
427
+ // src/utils/logger.ts
428
+ import pino from "pino";
429
+ var logger = pino(
430
+ {
431
+ level: "debug"
432
+ },
433
+ pino.destination({
434
+ dest: "./debug.log",
435
+ append: false,
436
+ sync: true
437
+ })
438
+ );
439
+ var logger_default = logger;
440
+
441
+ // src/api/providers/reddit.ts
408
442
  async function getUserPage(user, offset) {
409
443
  const url = `https://nsfw.xxx/page/${offset}?nsfw[]=0&types[]=image&types[]=video&types[]=gallery&slider=1&jsload=1&user=${user}&_=${Date.now()}`;
410
444
  return fetch4(url).then((r) => r.text());
@@ -416,6 +450,7 @@ async function getUserPosts(user) {
416
450
  if (page.length < 1) break;
417
451
  const $ = cheerio2.load(page);
418
452
  const newPosts = $("a").map((_, a) => $(a).attr("href")).get().filter((href) => href?.startsWith("https://nsfw.xxx/post"));
453
+ logger_default.debug({ count: posts.length });
419
454
  posts.push(...newPosts);
420
455
  }
421
456
  return posts;
@@ -431,46 +466,35 @@ async function getPostsData(posts) {
431
466
  const date = $(".sh-section .sh-section__passed").first().text().replace(/ /g, "-") || "";
432
467
  const ext = src.split(".").pop();
433
468
  const name = `${slug}-${date}.${ext}`;
469
+ logger_default.debug({ hehe: filelist.files.length, src });
434
470
  filelist.files.push(CoomerFile.from({ name, url: src }));
435
471
  }
436
472
  return filelist;
437
473
  }
438
- async function getRedditData(url) {
439
- const user = url.match(/u\/(\w+)/)?.[1];
440
- console.log("Fetching user posts...");
441
- const posts = await getUserPosts(user);
442
- console.log("Fetching posts data...");
443
- const filelist = await getPostsData(posts);
444
- filelist.dirName = `${user}-reddit`;
445
- return filelist;
446
- }
447
-
448
- // src/api/plain-curl.ts
449
- async function getPlainFileData(url) {
450
- const name = url.split("/").pop();
451
- const file = CoomerFile.from({ name, url });
452
- const filelist = new CoomerFileList([file]);
453
- filelist.dirName = "";
454
- return filelist;
455
- }
456
-
457
- // src/api/index.ts
458
- async function apiHandler(url_) {
459
- const url = new URL(url_);
460
- if (/^u\/\w+$/.test(url.origin)) {
461
- return getRedditData(url.href);
474
+ var RedditAPI = class {
475
+ testURL(url) {
476
+ return /^\/user\/[\w-]+$/.test(url.pathname);
462
477
  }
463
- if (/coomer|kemono/.test(url.origin)) {
464
- return getCoomerData(url.href);
465
- }
466
- if (/bunkr/.test(url.origin)) {
467
- return getBunkrData(url.href);
468
- }
469
- if (/gofile\.io/.test(url.origin)) {
470
- return getGofileData(url.href);
478
+ async getData(url) {
479
+ const user = url.match(/^\/user\/([\w-]+)/)?.[1];
480
+ const posts = await getUserPosts(user);
481
+ const filelist = await getPostsData(posts);
482
+ filelist.dirName = `${user}-reddit`;
483
+ return filelist;
471
484
  }
472
- if (/\.\w+/.test(url.pathname)) {
473
- return getPlainFileData(url.href);
485
+ };
486
+
487
+ // src/api/resolver.ts
488
+ var providers = [RedditAPI, CoomerAPI, BunkrAPI, GofileAPI, PlainFileAPI];
489
+ async function resolveAPI(url_) {
490
+ const url = new URL(url_);
491
+ for (const p of providers) {
492
+ const provider = new p();
493
+ if (provider.testURL(url)) {
494
+ const filelist = await provider.getData(url.toString());
495
+ filelist.provider = provider;
496
+ return filelist;
497
+ }
474
498
  }
475
499
  throw Error("Invalid URL");
476
500
  }
@@ -521,199 +545,13 @@ function argumentHander() {
521
545
 
522
546
  // src/cli/ui/index.tsx
523
547
  import { render } from "ink";
524
- import React9 from "react";
548
+ import React10 from "react";
525
549
 
526
550
  // src/cli/ui/app.tsx
527
551
  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
- }
552
+ import React9 from "react";
715
553
 
716
- // src/services/downloader.ts
554
+ // src/core/downloader.ts
717
555
  import fs2 from "node:fs";
718
556
  import { Readable, Transform } from "node:stream";
719
557
  import { pipeline as pipeline2 } from "node:stream/promises";
@@ -760,7 +598,7 @@ var Timer = class _Timer {
760
598
  }
761
599
  };
762
600
 
763
- // src/services/downloader.ts
601
+ // src/core/downloader.ts
764
602
  var Downloader = class {
765
603
  constructor(filelist, minSize, maxSize, chunkTimeout = 3e4, chunkFetchRetries = 5, fetchRetries = 7) {
766
604
  this.filelist = filelist;
@@ -855,8 +693,8 @@ var Downloader = class {
855
693
  if (signal.reason === "FILE_SKIP") return;
856
694
  }
857
695
  if (retries > 0) {
858
- if (/coomer|kemono/.test(file.url)) {
859
- file.url = tryFixCoomerUrl(file.url, retries);
696
+ if (this.filelist.provider?.fixURL) {
697
+ file.url = this.filelist.provider.fixURL(file.url, retries);
860
698
  }
861
699
  await sleep(1e3);
862
700
  return await this.downloadFile(file, retries - 1);
@@ -878,11 +716,210 @@ var Downloader = class {
878
716
  }
879
717
  };
880
718
 
719
+ // src/cli/ui/components/file.tsx
720
+ import { Box as Box2, Spacer, Text as Text2 } from "ink";
721
+ import React3 from "react";
722
+
723
+ // src/utils/strings.ts
724
+ function b2mb(bytes) {
725
+ return (bytes / 1048576).toFixed(2);
726
+ }
727
+
728
+ // src/cli/ui/hooks/downloader.ts
729
+ import { useRef, useSyncExternalStore } from "react";
730
+
731
+ // src/cli/ui/store/index.ts
732
+ import { create } from "zustand";
733
+ var useInkStore = create((set) => ({
734
+ preview: false,
735
+ switchPreview: () => set((state) => ({
736
+ preview: !state.preview
737
+ })),
738
+ downloader: void 0,
739
+ setDownloader: (downloader) => set({ downloader })
740
+ }));
741
+
742
+ // src/cli/ui/hooks/downloader.ts
743
+ var useDownloaderHook = (subjectEvents) => {
744
+ const downloader = useInkStore((state) => state.downloader);
745
+ const versionRef = useRef(0);
746
+ useSyncExternalStore(
747
+ (onStoreChange) => {
748
+ if (!downloader) return () => {
749
+ };
750
+ const sub = downloader.subject.subscribe(({ type }) => {
751
+ if (subjectEvents.includes(type)) {
752
+ versionRef.current++;
753
+ onStoreChange();
754
+ }
755
+ });
756
+ return () => sub.unsubscribe();
757
+ },
758
+ () => versionRef.current
759
+ );
760
+ return downloader?.filelist;
761
+ };
762
+
763
+ // src/cli/ui/components/preview.tsx
764
+ import { Box } from "ink";
765
+ import Image, { TerminalInfoProvider } from "ink-picture";
766
+ import React from "react";
767
+ function Preview({ file }) {
768
+ const previewEnabled = useInkStore((state) => state.preview);
769
+ const bigEnough = file.downloaded > 50 * 1024;
770
+ const shouldShow = previewEnabled && bigEnough && isImage(file.filepath);
771
+ const imgInfo = `
772
+ can't read partial images yet...
773
+ actual size: ${file.size}}
774
+ downloaded: ${file.downloaded}}
775
+ `;
776
+ 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 }))));
777
+ }
778
+
779
+ // src/cli/ui/components/spinner.tsx
780
+ import spinners from "cli-spinners";
781
+ import { Text } from "ink";
782
+ import React2, { useEffect, useState } from "react";
783
+ function Spinner({ type = "dots" }) {
784
+ const spinner = spinners[type];
785
+ const randomFrame = spinner.frames.length * Math.random() | 0;
786
+ const [frame, setFrame] = useState(randomFrame);
787
+ useEffect(() => {
788
+ const timer = setInterval(() => {
789
+ setFrame((previousFrame) => {
790
+ return (previousFrame + 1) % spinner.frames.length;
791
+ });
792
+ }, spinner.interval);
793
+ return () => {
794
+ clearInterval(timer);
795
+ };
796
+ }, [spinner]);
797
+ return /* @__PURE__ */ React2.createElement(Text, null, spinner.frames[frame]);
798
+ }
799
+
800
+ // src/cli/ui/components/file.tsx
801
+ var FileBox = React3.memo(({ file }) => {
802
+ useDownloaderHook(["CHUNK_DOWNLOADING_UPDATE"]);
803
+ const percentage = Number(file.downloaded / file.size * 100).toFixed(2);
804
+ return /* @__PURE__ */ React3.createElement(React3.Fragment, null, /* @__PURE__ */ React3.createElement(
805
+ Box2,
806
+ {
807
+ borderStyle: "single",
808
+ borderColor: "magentaBright",
809
+ borderDimColor: true,
810
+ paddingX: 1,
811
+ flexDirection: "column"
812
+ },
813
+ /* @__PURE__ */ React3.createElement(Box2, null, /* @__PURE__ */ React3.createElement(Text2, { color: "blue", dimColor: true, wrap: "truncate-middle" }, file.name)),
814
+ /* @__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)))
815
+ ), /* @__PURE__ */ React3.createElement(Preview, { file }));
816
+ });
817
+
818
+ // src/cli/ui/components/filelist.tsx
819
+ import React4 from "react";
820
+ function FileListBox() {
821
+ const filelist = useDownloaderHook(["FILE_DOWNLOADING_START", "FILE_DOWNLOADING_END"]);
822
+ return /* @__PURE__ */ React4.createElement(React4.Fragment, null, filelist?.getActiveFiles().map((file) => {
823
+ return /* @__PURE__ */ React4.createElement(FileBox, { file, key: file.name });
824
+ }));
825
+ }
826
+
827
+ // src/cli/ui/components/filelist-state.tsx
828
+ import { Box as Box3, Text as Text3 } from "ink";
829
+ import React5 from "react";
830
+ function FileListStateBox() {
831
+ const filelist = useDownloaderHook(["FILE_DOWNLOADING_START", "FILE_DOWNLOADING_END"]);
832
+ return /* @__PURE__ */ React5.createElement(
833
+ Box3,
834
+ {
835
+ paddingX: 1,
836
+ flexDirection: "column",
837
+ borderStyle: "single",
838
+ borderColor: "magenta",
839
+ borderDimColor: true
840
+ },
841
+ /* @__PURE__ */ React5.createElement(Box3, null, /* @__PURE__ */ React5.createElement(Box3, { marginRight: 1 }, /* @__PURE__ */ React5.createElement(Text3, { color: "cyanBright", dimColor: true }, "Found:")), /* @__PURE__ */ React5.createElement(Text3, { color: "blue", dimColor: true, wrap: "wrap" }, filelist?.files.length)),
842
+ /* @__PURE__ */ React5.createElement(Box3, null, /* @__PURE__ */ React5.createElement(Box3, { marginRight: 1 }, /* @__PURE__ */ React5.createElement(Text3, { color: "cyanBright", dimColor: true }, "Downloaded:")), /* @__PURE__ */ React5.createElement(Text3, { color: "blue", dimColor: true, wrap: "wrap" })),
843
+ /* @__PURE__ */ React5.createElement(Box3, null, /* @__PURE__ */ React5.createElement(Box3, { width: 9 }, /* @__PURE__ */ React5.createElement(Text3, { color: "cyanBright", dimColor: true }, "Folder:")), /* @__PURE__ */ React5.createElement(Box3, { flexGrow: 1 }, /* @__PURE__ */ React5.createElement(Text3, { color: "blue", dimColor: true, wrap: "truncate-middle" }, filelist?.dirPath)))
844
+ );
845
+ }
846
+
847
+ // src/cli/ui/components/keyboardinfo.tsx
848
+ import { Box as Box4, Text as Text4 } from "ink";
849
+ import React6 from "react";
850
+ var info = {
851
+ "s ": "skip current file",
852
+ p: "on/off image preview"
853
+ };
854
+ function KeyboardControlsInfo() {
855
+ const infoRender = Object.entries(info).map(([key, value]) => {
856
+ return /* @__PURE__ */ React6.createElement(Box4, { key }, /* @__PURE__ */ React6.createElement(Box4, { marginRight: 2 }, /* @__PURE__ */ React6.createElement(Text4, { color: "red", dimColor: true, bold: true }, key)), /* @__PURE__ */ React6.createElement(Text4, { dimColor: true, bold: false }, value));
857
+ });
858
+ return /* @__PURE__ */ React6.createElement(
859
+ Box4,
860
+ {
861
+ flexDirection: "column",
862
+ paddingX: 1,
863
+ borderStyle: "single",
864
+ borderColor: "gray",
865
+ borderDimColor: true
866
+ },
867
+ /* @__PURE__ */ React6.createElement(Box4, null, /* @__PURE__ */ React6.createElement(Text4, { color: "red", dimColor: true, bold: true }, "Keyboard controls:")),
868
+ infoRender
869
+ );
870
+ }
871
+
872
+ // src/cli/ui/components/loading.tsx
873
+ import { Box as Box5, Text as Text5 } from "ink";
874
+ import React7 from "react";
875
+ function Loading() {
876
+ return /* @__PURE__ */ React7.createElement(Box5, { paddingX: 1, borderDimColor: true, flexDirection: "column" }, /* @__PURE__ */ React7.createElement(Box5, { alignSelf: "center" }, /* @__PURE__ */ React7.createElement(Text5, { dimColor: true, color: "redBright" }, "Fetching Data")), /* @__PURE__ */ React7.createElement(Box5, { alignSelf: "center" }, /* @__PURE__ */ React7.createElement(Text5, { color: "blueBright", dimColor: true }, /* @__PURE__ */ React7.createElement(Spinner, { type: "grenade" }))));
877
+ }
878
+
879
+ // src/cli/ui/components/titlebar.tsx
880
+ import { Box as Box6, Spacer as Spacer2, Text as Text6 } from "ink";
881
+ import React8 from "react";
882
+
883
+ // package.json
884
+ var version = "3.4.2";
885
+
886
+ // src/cli/ui/components/titlebar.tsx
887
+ function TitleBar() {
888
+ return /* @__PURE__ */ React8.createElement(Box6, null, /* @__PURE__ */ React8.createElement(Spacer2, null), /* @__PURE__ */ React8.createElement(Box6, { borderColor: "magenta", borderStyle: "arrow" }, /* @__PURE__ */ React8.createElement(Text6, { color: "cyanBright" }, "Coomer-Downloader ", version)), /* @__PURE__ */ React8.createElement(Spacer2, null));
889
+ }
890
+
891
+ // src/cli/ui/hooks/input.ts
892
+ import { useInput } from "ink";
893
+ var useInputHook = () => {
894
+ const downloader = useInkStore((state) => state.downloader);
895
+ const switchPreview = useInkStore((state) => state.switchPreview);
896
+ useInput((input) => {
897
+ if (input === "s") {
898
+ downloader?.skip();
899
+ }
900
+ if (input === "p") {
901
+ switchPreview();
902
+ }
903
+ });
904
+ };
905
+
906
+ // src/cli/ui/app.tsx
907
+ function App() {
908
+ useInputHook();
909
+ const filelist = useDownloaderHook(["FILES_DOWNLOADING_START"]);
910
+ return /* @__PURE__ */ React9.createElement(Box7, { borderStyle: "single", flexDirection: "column", borderColor: "blue", width: 80 }, /* @__PURE__ */ React9.createElement(TitleBar, null), !(filelist instanceof CoomerFileList) ? /* @__PURE__ */ React9.createElement(Loading, null) : /* @__PURE__ */ React9.createElement(React9.Fragment, null, /* @__PURE__ */ React9.createElement(Box7, null, /* @__PURE__ */ React9.createElement(Box7, null, /* @__PURE__ */ React9.createElement(FileListStateBox, null)), /* @__PURE__ */ React9.createElement(Box7, { flexBasis: 30 }, /* @__PURE__ */ React9.createElement(KeyboardControlsInfo, null))), /* @__PURE__ */ React9.createElement(FileListBox, null)));
911
+ }
912
+
913
+ // src/cli/ui/index.tsx
914
+ function createReactInk() {
915
+ return render(/* @__PURE__ */ React10.createElement(App, null));
916
+ }
917
+
881
918
  // src/index.ts
882
919
  async function run() {
883
920
  createReactInk();
884
921
  const { url, dir, media, include, exclude, minSize, maxSize, skip, removeDupilicates } = argumentHander();
885
- const filelist = await apiHandler(url);
922
+ const filelist = await resolveAPI(url);
886
923
  filelist.setDirPath(dir).skip(skip).filterByText(include, exclude).filterByMediaType(media);
887
924
  if (removeDupilicates) {
888
925
  filelist.removeURLDuplicates();