coomer-downloader 3.1.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.
@@ -0,0 +1,28 @@
1
+ import type { SpinnerName } from 'cli-spinners';
2
+ import spinners from 'cli-spinners';
3
+ import { Text } from 'ink';
4
+ import React, { useEffect, useState } from 'react';
5
+
6
+ interface SpinnerProps {
7
+ type?: SpinnerName;
8
+ }
9
+
10
+ export function Spinner({ type = 'dots' }: SpinnerProps) {
11
+ const spinner = spinners[type];
12
+ const randomFrame = (spinner.frames.length * Math.random()) | 0;
13
+ const [frame, setFrame] = useState(randomFrame);
14
+
15
+ useEffect(() => {
16
+ const timer = setInterval(() => {
17
+ setFrame((previousFrame) => {
18
+ return (previousFrame + 1) % spinner.frames.length;
19
+ });
20
+ }, spinner.interval);
21
+
22
+ return () => {
23
+ clearInterval(timer);
24
+ };
25
+ }, [spinner]);
26
+
27
+ return <Text>{spinner.frames[frame]}</Text>;
28
+ }
@@ -0,0 +1,15 @@
1
+ import { Box, Spacer, Text } from 'ink';
2
+ import React from 'react';
3
+ import { version } from '../../../../package.json';
4
+
5
+ export function TitleBar() {
6
+ return (
7
+ <Box>
8
+ <Spacer></Spacer>
9
+ <Box borderColor={'magenta'} borderStyle={'arrow'}>
10
+ <Text color={'cyanBright'}>Coomer-Downloader {version}</Text>
11
+ </Box>
12
+ <Spacer></Spacer>
13
+ </Box>
14
+ );
15
+ }
@@ -0,0 +1,21 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { useInkStore } from '../store';
3
+
4
+ export const useDownloaderHook = () => {
5
+ const downloader = useInkStore((state) => state.downloader);
6
+ const filelist = downloader?.filelist;
7
+
8
+ const [_, setHelper] = useState(0);
9
+
10
+ useEffect(() => {
11
+ downloader?.subject.subscribe(({ type }) => {
12
+ if (
13
+ type === 'FILE_DOWNLOADING_START' ||
14
+ type === 'FILE_DOWNLOADING_END' ||
15
+ type === 'CHUNK_DOWNLOADING_UPDATE'
16
+ ) {
17
+ setHelper(Date.now());
18
+ }
19
+ });
20
+ });
21
+ };
@@ -0,0 +1,17 @@
1
+ import { useInput } from 'ink';
2
+ import { useInkStore } from '../store';
3
+
4
+ // problems with tsx watch
5
+ export const useInputHook = () => {
6
+ const downloader = useInkStore((state) => state.downloader);
7
+ const switchPreview = useInkStore((state) => state.switchPreview);
8
+
9
+ useInput((input) => {
10
+ if (input === 's') {
11
+ downloader?.skip();
12
+ }
13
+ if (input === 'p') {
14
+ switchPreview();
15
+ }
16
+ });
17
+ };
@@ -0,0 +1,7 @@
1
+ import { render } from 'ink';
2
+ import React from 'react';
3
+ import { App } from './app';
4
+
5
+ export function createReactInk() {
6
+ return render(<App />);
7
+ }
@@ -0,0 +1,19 @@
1
+ import { create } from 'zustand';
2
+ import type { Downloader } from '../../../services/downloader';
3
+
4
+ interface InkState {
5
+ preview: boolean;
6
+ switchPreview: () => void;
7
+ downloader?: Downloader;
8
+ setDownloader: (downloader: Downloader) => void;
9
+ }
10
+
11
+ export const useInkStore = create<InkState>((set) => ({
12
+ preview: false,
13
+ switchPreview: () =>
14
+ set((state) => ({
15
+ preview: !state.preview,
16
+ })),
17
+ downloader: undefined,
18
+ setDownloader: (downloader: Downloader) => set({ downloader }),
19
+ }));
package/src/index.ts CHANGED
@@ -1,36 +1,30 @@
1
- #!/usr/bin/env node
2
- import os from 'node:os';
3
- import path from 'node:path';
1
+ #!/usr/bin/env -S node --no-warnings=ExperimentalWarning
2
+
4
3
  import process from 'node:process';
5
4
  import { apiHandler } from './api';
6
- import { argumentHander } from './args-handler';
7
- import type { ApiResult, MediaType } from './types';
8
- import { createMultibar, downloadFiles, filterKeywords, setGlobalHeaders } from './utils';
5
+ import { argumentHander } from './cli/args-handler';
6
+ import { createReactInk } from './cli/ui';
7
+ import { useInkStore } from './cli/ui/store';
8
+ import { Downloader } from './services/downloader';
9
+ import { setGlobalHeaders } from './utils/requests';
9
10
 
10
11
  async function run() {
11
- const { url, dir, media, include, exclude, skip } = argumentHander();
12
+ createReactInk();
12
13
 
13
- const { dirName, files } = (await apiHandler(url, media as MediaType)) as ApiResult;
14
+ const { url, dir, media, include, exclude, skip } = argumentHander();
14
15
 
15
- const downloadDir =
16
- dir === './' ? path.resolve(dir, dirName) : path.join(os.homedir(), path.join(dir, dirName));
16
+ const filelist = await apiHandler(url);
17
17
 
18
- const filteredFiles = filterKeywords(files.slice(skip), include, exclude);
18
+ filelist.setDirPath(dir).skip(skip).filterByText(include, exclude).filterByMediaType(media);
19
19
 
20
- console.table([
21
- {
22
- found: files.length,
23
- skip,
24
- filtered: files.length - filteredFiles.length - skip,
25
- folder: downloadDir,
26
- },
27
- ]);
20
+ await filelist.calculateFileSizes();
28
21
 
29
22
  setGlobalHeaders({ Referer: url });
30
23
 
31
- createMultibar();
24
+ const downloader = new Downloader(filelist);
25
+ useInkStore.getState().setDownloader(downloader);
32
26
 
33
- await downloadFiles(filteredFiles, downloadDir);
27
+ await downloader.downloadFiles();
34
28
 
35
29
  process.kill(process.pid, 'SIGINT');
36
30
  }
@@ -0,0 +1,141 @@
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 { DownloaderSubject } from '../types';
7
+ import { getFileSize, mkdir } from '../utils/io';
8
+ import { sleep } from '../utils/promise';
9
+ import { fetchByteRange } from '../utils/requests';
10
+ import { Timer } from '../utils/timer';
11
+ import type { CoomerFile, CoomerFileList } from './file';
12
+
13
+ export class Downloader {
14
+ public subject = new Subject<DownloaderSubject>();
15
+
16
+ private abortController = new AbortController();
17
+ public abortControllerSubject = new Subject<string>();
18
+
19
+ setAbortControllerListener() {
20
+ this.abortControllerSubject.subscribe((type) => {
21
+ this.abortController.abort(type);
22
+ this.abortController = new AbortController();
23
+ });
24
+ }
25
+
26
+ constructor(
27
+ public filelist: CoomerFileList,
28
+ public chunkTimeout = 30_000,
29
+ public chunkFetchRetries = 5,
30
+ public fetchRetries = 7,
31
+ ) {
32
+ this.setAbortControllerListener();
33
+ }
34
+
35
+ async fetchStream(
36
+ file: CoomerFile,
37
+ stream: Readable,
38
+ sizeOld = 0,
39
+ retries = this.chunkFetchRetries,
40
+ ): Promise<void> {
41
+ const signal = this.abortController.signal;
42
+ const subject = this.subject;
43
+ const { timer } = Timer.withAbortController(this.chunkTimeout, this.abortControllerSubject);
44
+ let i: NodeJS.Timeout | undefined;
45
+
46
+ try {
47
+ const fileStream = fs.createWriteStream(file.filepath as string, { flags: 'a' });
48
+
49
+ const progressStream = new Transform({
50
+ transform(chunk, _encoding, callback) {
51
+ this.push(chunk);
52
+ file.downloaded += chunk.length;
53
+ timer.reset();
54
+ subject.next({ type: 'CHUNK_DOWNLOADING_UPDATE' });
55
+ callback();
56
+ },
57
+ });
58
+
59
+ subject.next({ type: 'CHUNK_DOWNLOADING_START' });
60
+ await pipeline(stream, progressStream, fileStream, { signal });
61
+ } catch (error) {
62
+ if (signal.aborted) {
63
+ if (signal.reason === 'FILE_SKIP') return;
64
+ if (signal.reason === 'TIMEOUT') {
65
+ if (retries === 0 && sizeOld < file.downloaded) {
66
+ retries += this.chunkFetchRetries;
67
+ sizeOld = file.downloaded;
68
+ }
69
+ if (retries === 0) return;
70
+ return await this.fetchStream(file, stream, sizeOld, retries - 1);
71
+ }
72
+ }
73
+ throw error;
74
+ } finally {
75
+ subject.next({ type: 'CHUNK_DOWNLOADING_END' });
76
+ timer.stop();
77
+ clearInterval(i);
78
+ }
79
+ }
80
+
81
+ public skip() {
82
+ this.abortControllerSubject.next('FILE_SKIP');
83
+ }
84
+
85
+ async downloadFile(file: CoomerFile, retries = this.fetchRetries): Promise<void> {
86
+ const signal = this.abortController.signal;
87
+ try {
88
+ file.downloaded = await getFileSize(file.filepath as string);
89
+
90
+ const response = await fetchByteRange(file.url, file.downloaded, signal);
91
+
92
+ if (!response?.ok && response?.status !== 416) {
93
+ throw new Error(`HTTP error! status: ${response?.status}`);
94
+ }
95
+
96
+ const contentLength = response.headers.get('Content-Length') as string;
97
+
98
+ if (!contentLength && file.downloaded > 0) return;
99
+
100
+ const restFileSize = parseInt(contentLength);
101
+ file.size = restFileSize + file.downloaded;
102
+
103
+ if (file.size > file.downloaded && response.body) {
104
+ const stream = Readable.fromWeb(response.body);
105
+ stream.setMaxListeners(20);
106
+ await this.fetchStream(file, stream, file.downloaded);
107
+ }
108
+ } catch (error) {
109
+ if (signal.aborted) {
110
+ if (signal.reason === 'FILE_SKIP') return;
111
+ }
112
+ if (retries > 0) {
113
+ if (/coomer|kemono/.test(file.url)) {
114
+ file.url = tryFixCoomerUrl(file.url, retries);
115
+ }
116
+ await sleep(1000);
117
+ return await this.downloadFile(file, retries - 1);
118
+ }
119
+ throw error;
120
+ }
121
+ }
122
+
123
+ async downloadFiles(): Promise<void> {
124
+ mkdir(this.filelist.dirPath as string);
125
+
126
+ this.subject.next({ type: 'FILES_DOWNLOADING_START' });
127
+ for (const file of this.filelist.files) {
128
+ file.active = true;
129
+
130
+ this.subject.next({ type: 'FILE_DOWNLOADING_START' });
131
+
132
+ await this.downloadFile(file);
133
+
134
+ file.active = false;
135
+
136
+ this.subject.next({ type: 'FILE_DOWNLOADING_END' });
137
+ }
138
+
139
+ this.subject.next({ type: 'FILES_DOWNLOADING_END' });
140
+ }
141
+ }
@@ -0,0 +1,95 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+ import type { MediaType } from '../types';
4
+ import { filterString, testMediaType } from '../utils/filters';
5
+ import { getFileSize } from '../utils/io';
6
+
7
+ interface ICoomerFile {
8
+ name: string;
9
+ url: string;
10
+ filepath?: string;
11
+ size?: number;
12
+ downloaded?: number;
13
+ content?: string;
14
+ }
15
+
16
+ export class CoomerFile {
17
+ public active = false;
18
+
19
+ constructor(
20
+ public name: string,
21
+ public url: string,
22
+ public filepath?: string,
23
+ public size?: number,
24
+ public downloaded = 0,
25
+ public content?: string,
26
+ ) {}
27
+
28
+ public async getDownloadedSize() {
29
+ this.downloaded = await getFileSize(this.filepath as string);
30
+ return this;
31
+ }
32
+
33
+ public get textContent() {
34
+ const text = `${this.name || ''} ${this.content || ''}`.toLowerCase();
35
+ return text;
36
+ }
37
+
38
+ public static from(f: ICoomerFile) {
39
+ return new CoomerFile(f.name, f.url, f.filepath, f.size, f.downloaded, f.content);
40
+ }
41
+ }
42
+
43
+ export class CoomerFileList {
44
+ public dirPath?: string;
45
+ public dirName?: string;
46
+
47
+ constructor(public files: CoomerFile[] = []) {}
48
+
49
+ public setDirPath(dir: string, dirName?: string) {
50
+ dirName = dirName || (this.dirName as string);
51
+
52
+ if (dir === './') {
53
+ this.dirPath = path.resolve(dir, dirName);
54
+ } else {
55
+ this.dirPath = path.join(os.homedir(), path.join(dir, dirName));
56
+ }
57
+
58
+ this.files.forEach((file) => {
59
+ file.filepath = path.join(this.dirPath as string, file.name);
60
+ });
61
+
62
+ return this;
63
+ }
64
+
65
+ public filterByText(include: string, exclude: string) {
66
+ this.files = this.files.filter((f) => filterString(f.textContent, include, exclude));
67
+ return this;
68
+ }
69
+
70
+ public filterByMediaType(media?: string) {
71
+ if (media) {
72
+ this.files = this.files.filter((f) => testMediaType(f.name, media as MediaType));
73
+ }
74
+ return this;
75
+ }
76
+
77
+ public skip(n: number) {
78
+ this.files = this.files.slice(n);
79
+ return this;
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
+ }
95
+ }
@@ -1,23 +1,15 @@
1
- export type File = {
2
- name: string;
3
- url: string;
4
- filepath?: string;
5
- size?: number;
6
- downloaded?: number;
7
- content?: string;
8
- };
1
+ export type MediaType = 'video' | 'image' | 'all';
9
2
 
10
- export type DownloaderSubject = {
11
- type: string;
12
- file?: File;
13
- index?: number;
14
- filesCount?: number;
15
- };
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';
16
12
 
17
- export type ApiResult = {
18
- files: File[];
19
- // should merge into filepaths?
20
- dirName: string;
13
+ export type DownloaderSubject = {
14
+ type: DownloaderSubjectSignal;
21
15
  };
22
-
23
- export type MediaType = 'video' | 'image' | 'all';
@@ -1,12 +1,16 @@
1
- import type { File, MediaType } from '../types';
1
+ import type { MediaType } from '../types';
2
2
 
3
- export const isImage = (name: string) => /\.(jpg|jpeg|png|gif|bmp|tiff|webp|avif)$/i.test(name);
3
+ export function isImage(name: string) {
4
+ return /\.(jpg|jpeg|png|gif|bmp|tiff|webp|avif)$/i.test(name);
5
+ }
4
6
 
5
- export const isVideo = (name: string) =>
6
- /\.(mp4|m4v|avi|mov|mkv|webm|flv|wmv|mpeg|mpg|3gp)$/i.test(name);
7
+ export function isVideo(name: string) {
8
+ return /\.(mp4|m4v|avi|mov|mkv|webm|flv|wmv|mpeg|mpg|3gp)$/i.test(name);
9
+ }
7
10
 
8
- export const testMediaType = (name: string, type: MediaType) =>
9
- type === 'all' ? true : type === 'image' ? isImage(name) : isVideo(name);
11
+ export function testMediaType(name: string, type: MediaType) {
12
+ return type === 'all' ? true : type === 'image' ? isImage(name) : isVideo(name);
13
+ }
10
14
 
11
15
  function includesAllWords(str: string, words: string[]) {
12
16
  if (!words.length) return true;
@@ -25,13 +29,6 @@ function parseQuery(query: string) {
25
29
  .filter((_) => _);
26
30
  }
27
31
 
28
- function filterString(text: string, include: string, exclude: string): boolean {
32
+ export function filterString(text: string, include: string, exclude: string): boolean {
29
33
  return includesAllWords(text, parseQuery(include)) && includesNoWords(text, parseQuery(exclude));
30
34
  }
31
-
32
- export function filterKeywords(files: File[], include: string, exclude: string) {
33
- return files.filter((f) => {
34
- const text = `${f.name || ''} ${f.content || ''}`.toLowerCase();
35
- return filterString(text, include, exclude);
36
- });
37
- }
@@ -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,21 +1,3 @@
1
1
  export function b2mb(bytes: number) {
2
- return Number.parseFloat((bytes / 1048576).toFixed(2));
3
- }
4
-
5
- export function sanitizeString(str: string) {
6
- return (
7
- str
8
- .match(/(\w| |-)/g)
9
- ?.join('')
10
- .replace(/ +/g, ' ') || ''
11
- );
12
- }
13
-
14
- export function formatNameStdout(pathname: string) {
15
- const name = pathname.split('/').pop() || '';
16
- const consoleWidth = process.stdout.columns;
17
- const width = Math.max((consoleWidth / 2) | 0, 40);
18
- if (name.length < width) return name.trim();
19
- const result = `${name.slice(0, width - 15)} ... ${name.slice(-10)}`.replace(/ +/g, ' ');
20
- return result;
2
+ return (bytes / 1048576).toFixed(2);
21
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",