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.
@@ -1,7 +1,8 @@
1
1
  import os from 'node:os';
2
2
  import path from 'node:path';
3
3
  import type { MediaType } from '../types';
4
- import { filterString, testMediaType } from './filters';
4
+ import { filterString, testMediaType } from '../utils/filters';
5
+ import { getFileSize } from '../utils/io';
5
6
 
6
7
  interface ICoomerFile {
7
8
  name: string;
@@ -13,18 +14,23 @@ interface ICoomerFile {
13
14
  }
14
15
 
15
16
  export class CoomerFile {
16
- public state: 'downloading' | 'pause' = 'pause';
17
+ public active = false;
17
18
 
18
19
  constructor(
19
20
  public name: string,
20
21
  public url: string,
21
22
  public filepath?: string,
22
23
  public size?: number,
23
- public downloaded?: number,
24
+ public downloaded = 0,
24
25
  public content?: string,
25
26
  ) {}
26
27
 
27
- get textContent() {
28
+ public async getDownloadedSize() {
29
+ this.downloaded = await getFileSize(this.filepath as string);
30
+ return this;
31
+ }
32
+
33
+ public get textContent() {
28
34
  const text = `${this.name || ''} ${this.content || ''}`.toLowerCase();
29
35
  return text;
30
36
  }
@@ -72,4 +78,18 @@ export class CoomerFileList {
72
78
  this.files = this.files.slice(n);
73
79
  return this;
74
80
  }
81
+
82
+ public async calculateFileSizes() {
83
+ for (const file of this.files) {
84
+ await file.getDownloadedSize();
85
+ }
86
+ }
87
+
88
+ public getActiveFiles() {
89
+ return this.files.filter((f) => f.active);
90
+ }
91
+
92
+ public getDownloaded() {
93
+ return this.files.filter((f) => f.size && f.size <= f.downloaded);
94
+ }
75
95
  }
@@ -1 +1,15 @@
1
1
  export type MediaType = 'video' | 'image' | 'all';
2
+
3
+ export type DownloaderSubjectSignal =
4
+ | 'FILES_DOWNLOADING_START'
5
+ | 'FILES_DOWNLOADING_END'
6
+ | 'FILE_DOWNLOADING_START'
7
+ | 'FILE_DOWNLOADING_END'
8
+ | 'FILE_SKIP'
9
+ | 'CHUNK_DOWNLOADING_START'
10
+ | 'CHUNK_DOWNLOADING_UPDATE'
11
+ | 'CHUNK_DOWNLOADING_END';
12
+
13
+ export type DownloaderSubject = {
14
+ type: DownloaderSubjectSignal;
15
+ };
@@ -1,53 +1,3 @@
1
1
  export async function sleep(time: number) {
2
2
  return new Promise((resolve) => setTimeout(resolve, time));
3
3
  }
4
-
5
- type PromiseRetryCallback = (retries: number, error: Error) => void | { newRetries?: number };
6
-
7
- interface PromiseRetryOptions {
8
- retries?: number;
9
- callback?: PromiseRetryCallback;
10
- delay?: number;
11
- }
12
-
13
- export class PromiseRetry {
14
- private retries: number;
15
- private delay: number;
16
- private callback?: PromiseRetryCallback;
17
-
18
- constructor(options: PromiseRetryOptions) {
19
- this.retries = options.retries || 3;
20
- this.delay = options.delay || 1000;
21
- this.callback = options.callback;
22
- }
23
-
24
- async execute(fn: () => Promise<void>) {
25
- let retries = this.retries;
26
-
27
- while (true) {
28
- try {
29
- return await fn();
30
- } catch (error) {
31
- if (retries <= 0) {
32
- throw error;
33
- }
34
-
35
- if (this.callback) {
36
- const res = this.callback(retries, error as Error);
37
- if (res) {
38
- const { newRetries } = res;
39
- if (newRetries === 0) throw error;
40
- this.retries = newRetries || retries;
41
- }
42
- }
43
-
44
- await sleep(this.delay);
45
- retries--;
46
- }
47
- }
48
- }
49
-
50
- static create(options: PromiseRetryOptions) {
51
- return new PromiseRetry(options);
52
- }
53
- }
@@ -29,8 +29,8 @@ export function fetchWithGlobalHeader(url: string) {
29
29
  return fetch(url, { headers: requestHeaders });
30
30
  }
31
31
 
32
- export function fetchByteRange(url: string, downloadedSize: number) {
32
+ export function fetchByteRange(url: string, downloadedSize: number, signal?: AbortSignal) {
33
33
  const requestHeaders = new Headers(HeadersDefault);
34
34
  requestHeaders.set('Range', `bytes=${downloadedSize}-`);
35
- return fetch(url, { headers: requestHeaders });
35
+ return fetch(url, { headers: requestHeaders, signal });
36
36
  }
@@ -1,12 +1,3 @@
1
1
  export function b2mb(bytes: number) {
2
- return Number.parseFloat((bytes / 1048576).toFixed(2));
3
- }
4
-
5
- export function formatNameStdout(pathname: string) {
6
- const name = pathname.split('/').pop() || '';
7
- const consoleWidth = process.stdout.columns;
8
- const width = Math.max((consoleWidth / 2) | 0, 40);
9
- if (name.length < width) return name.trim();
10
- const result = `${name.slice(0, width - 15)} ... ${name.slice(-10)}`.replace(/ +/g, ' ');
11
- return result;
2
+ return (bytes / 1048576).toFixed(2);
12
3
  }
@@ -1,5 +1,7 @@
1
+ import type { Subject } from 'rxjs';
2
+
1
3
  export class Timer {
2
- private timer: NodeJS.Timeout | undefined = undefined;
4
+ private timer: NodeJS.Timeout | undefined;
3
5
 
4
6
  constructor(
5
7
  private timeout = 10_000,
@@ -30,18 +32,17 @@ export class Timer {
30
32
  return this;
31
33
  }
32
34
 
33
- static withSignal(timeout?: number, message?: string) {
34
- const controller = new AbortController();
35
-
35
+ static withAbortController(
36
+ timeout: number,
37
+ abortControllerSubject: Subject<string>,
38
+ message: string = 'Timeout',
39
+ ) {
36
40
  const callback = () => {
37
- controller.abort(message);
41
+ abortControllerSubject.next(message);
38
42
  };
39
43
 
40
44
  const timer = new Timer(timeout, callback).start();
41
45
 
42
- return {
43
- timer,
44
- signal: controller.signal,
45
- };
46
+ return { timer };
46
47
  }
47
48
  }
package/tsconfig.json CHANGED
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "compilerOptions": {
3
+ "jsx": "react",
3
4
  "target": "ESNext",
4
5
  "useDefineForClassFields": true,
5
6
  "module": "esnext",
@@ -9,7 +10,7 @@
9
10
  "moduleResolution": "bundler",
10
11
  "isolatedModules": true,
11
12
  "moduleDetection": "force",
12
-
13
+
13
14
  "declaration": true,
14
15
  "typeRoots": ["./dist/index.d.ts", "./src/types", "./node_modules/@types"],
15
16
  "outDir": "./dist",
@@ -1,108 +0,0 @@
1
- import fs from 'node:fs';
2
- import { Readable, Transform } from 'node:stream';
3
- import { pipeline } from 'node:stream/promises';
4
- import { Subject } from 'rxjs';
5
- import { tryFixCoomerUrl } from '../api/coomer-api';
6
- import type { CoomerFile, CoomerFileList } from './file';
7
- import { getFileSize, mkdir } from './io';
8
- import { PromiseRetry } from './promise';
9
- import { fetchByteRange } from './requests';
10
- import { Timer } from './timer';
11
-
12
- type DownloaderSubject = {
13
- type: string;
14
- file?: CoomerFile;
15
- filesCount?: number;
16
- };
17
-
18
- export class Downloader {
19
- public subject = new Subject<DownloaderSubject>();
20
-
21
- constructor(
22
- public chunkTimeout = 30_000,
23
- public chunkFetchRetries = 5,
24
- public fetchRetries = 7,
25
- ) {}
26
-
27
- async fetchStream(file: CoomerFile, stream: Readable): Promise<void> {
28
- const { subject, chunkTimeout } = this;
29
- const { timer, signal } = Timer.withSignal(chunkTimeout, 'chunkTimeout');
30
-
31
- const fileStream = fs.createWriteStream(file.filepath as string, { flags: 'a' });
32
-
33
- const progressStream = new Transform({
34
- transform(chunk, _encoding, callback) {
35
- this.push(chunk);
36
- file.downloaded += chunk.length;
37
- timer.reset();
38
- subject.next({ type: 'CHUNK_DOWNLOADING_UPDATE', file });
39
- callback();
40
- },
41
- });
42
-
43
- try {
44
- subject.next({ type: 'CHUNK_DOWNLOADING_START', file });
45
- await pipeline(stream, progressStream, fileStream, { signal });
46
- } catch (error) {
47
- console.error((error as Error).name === 'AbortError' ? signal.reason : error);
48
- } finally {
49
- subject.next({ type: 'CHUNK_DOWNLOADING_END', file });
50
- }
51
- }
52
-
53
- async downloadFile(file: CoomerFile): Promise<void> {
54
- file.downloaded = await getFileSize(file.filepath as string);
55
-
56
- const response = await fetchByteRange(file.url, file.downloaded);
57
-
58
- if (!response?.ok && response?.status !== 416) {
59
- throw new Error(`HTTP error! status: ${response?.status}`);
60
- }
61
-
62
- const contentLength = response.headers.get('Content-Length') as string;
63
-
64
- if (!contentLength && file.downloaded > 0) return;
65
-
66
- const restFileSize = parseInt(contentLength);
67
- file.size = restFileSize + file.downloaded;
68
-
69
- if (file.size > file.downloaded && response.body) {
70
- const stream = Readable.fromWeb(response.body);
71
- const sizeOld = file.downloaded;
72
-
73
- await PromiseRetry.create({
74
- retries: this.chunkFetchRetries,
75
- callback: () => {
76
- if (sizeOld !== file.downloaded) {
77
- return { newRetries: 5 };
78
- }
79
- },
80
- }).execute(async () => await this.fetchStream(file, stream));
81
- }
82
-
83
- this.subject.next({ type: 'FILE_DOWNLOADING_END' });
84
- }
85
-
86
- async downloadFiles(filelist: CoomerFileList): Promise<void> {
87
- mkdir(filelist.dirPath as string);
88
-
89
- this.subject.next({ type: 'FILES_DOWNLOADING_START', filesCount: filelist.files.length });
90
-
91
- for (const file of filelist.files) {
92
- this.subject.next({ type: 'FILE_DOWNLOADING_START' });
93
-
94
- await PromiseRetry.create({
95
- retries: this.fetchRetries,
96
- callback: (retries) => {
97
- if (/coomer|kemono/.test(file.url)) {
98
- file.url = tryFixCoomerUrl(file.url, retries);
99
- }
100
- },
101
- }).execute(async () => await this.downloadFile(file));
102
-
103
- this.subject.next({ type: 'FILE_DOWNLOADING_END' });
104
- }
105
-
106
- this.subject.next({ type: 'FILES_DOWNLOADING_END' });
107
- }
108
- }
@@ -1,11 +0,0 @@
1
- export { Downloader } from './downloader';
2
- export { isImage, isVideo, testMediaType } from './filters';
3
- export { getFileSize, mkdir } from './io';
4
- export { createMultibar } from './multibar';
5
- export {
6
- fetchByteRange,
7
- fetchWithGlobalHeader,
8
- HeadersDefault,
9
- setGlobalHeaders,
10
- } from './requests';
11
- export { b2mb } from './strings';
@@ -1,62 +0,0 @@
1
- import { MultiBar, type Options, type SingleBar } from 'cli-progress';
2
- import type { Downloader } from './downloader';
3
- import { b2mb, formatNameStdout } from './strings';
4
-
5
- const config: Options = {
6
- clearOnComplete: true,
7
- gracefulExit: true,
8
- autopadding: true,
9
- hideCursor: true,
10
- format: '{percentage}% | {filename} | {value}/{total}{size}',
11
- };
12
-
13
- export function createMultibar(downloader: Downloader) {
14
- const multibar = new MultiBar(config);
15
- let bar: SingleBar;
16
- let minibar: SingleBar;
17
- let filename: string;
18
- let index = 0;
19
-
20
- downloader.subject.subscribe({
21
- next: ({ type, filesCount, file }) => {
22
- switch (type) {
23
- case 'FILES_DOWNLOADING_START':
24
- bar?.stop();
25
- bar = multibar.create(filesCount as number, 0);
26
- break;
27
-
28
- case 'FILES_DOWNLOADING_END':
29
- bar?.stop();
30
- break;
31
-
32
- case 'FILE_DOWNLOADING_START':
33
- bar?.update(++index, { filename: 'Downloaded files', size: '' });
34
- break;
35
-
36
- case 'FILE_DOWNLOADING_END':
37
- multibar.remove(minibar);
38
- break;
39
-
40
- case 'CHUNK_DOWNLOADING_START':
41
- multibar?.remove(minibar);
42
- filename = formatNameStdout(file?.filepath as string);
43
- minibar = multibar.create(b2mb(file?.size as number), b2mb(file?.downloaded as number));
44
- break;
45
-
46
- case 'CHUNK_DOWNLOADING_UPDATE':
47
- minibar?.update(b2mb(file?.downloaded as number), {
48
- filename: filename as string,
49
- size: 'mb',
50
- });
51
- break;
52
-
53
- case 'CHUNK_DOWNLOADING_END':
54
- multibar?.remove(minibar);
55
- break;
56
-
57
- default:
58
- break;
59
- }
60
- },
61
- });
62
- }
File without changes