coomer-downloader 3.2.0 → 3.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +388 -265
- package/package.json +13 -5
- package/src/api/bunkr.ts +1 -1
- package/src/api/coomer-api.ts +3 -2
- package/src/api/gofile.ts +2 -2
- package/src/api/index.ts +5 -1
- package/src/api/nsfw.xxx.ts +1 -1
- package/src/api/plain-curl.ts +1 -1
- package/src/cli/ui/app.tsx +40 -0
- package/src/cli/ui/components/file.tsx +44 -0
- package/src/cli/ui/components/filelist.tsx +52 -0
- package/src/cli/ui/components/index.ts +6 -0
- package/src/cli/ui/components/keyboardinfo.tsx +41 -0
- package/src/cli/ui/components/loading.tsx +20 -0
- package/src/cli/ui/components/preview.tsx +32 -0
- package/src/cli/ui/components/spinner.tsx +28 -0
- package/src/cli/ui/components/titlebar.tsx +15 -0
- package/src/cli/ui/hooks/downloader.ts +21 -0
- package/src/cli/ui/hooks/input.ts +17 -0
- package/src/cli/ui/index.tsx +7 -0
- package/src/cli/ui/store/index.ts +19 -0
- package/src/index.ts +16 -20
- package/src/services/downloader.ts +141 -0
- package/src/{utils → services}/file.ts +24 -4
- package/src/types/index.ts +14 -0
- package/src/utils/promise.ts +0 -50
- package/src/utils/requests.ts +2 -2
- package/src/utils/strings.ts +1 -10
- package/src/utils/timer.ts +10 -9
- package/tsconfig.json +2 -1
- package/src/utils/downloader.ts +0 -108
- package/src/utils/index.ts +0 -11
- package/src/utils/multibar.ts +0 -62
- /package/src/{args-handler.ts → cli/args-handler.ts} +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "coomer-downloader",
|
|
3
|
-
"version": "3.2
|
|
3
|
+
"version": "3.3.2",
|
|
4
4
|
"author": "smartacephal",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"description": "Downloads images/videos from Coomer/Kemono, Bunkr, GoFile, Reddit-NSFW user posts",
|
|
@@ -22,10 +22,11 @@
|
|
|
22
22
|
"type": "git",
|
|
23
23
|
"url": "git+https://github.com/smartacephale/coomer-downloader.git"
|
|
24
24
|
},
|
|
25
|
-
"main": "./src/index.
|
|
25
|
+
"main": "./src/index.tsx",
|
|
26
26
|
"type": "module",
|
|
27
27
|
"scripts": {
|
|
28
28
|
"start": "tsx ./src/index.ts",
|
|
29
|
+
"start-watch": "tsx watch ./src/index.ts",
|
|
29
30
|
"build": "node build.js",
|
|
30
31
|
"start-build": "npm run build && node ./dist/index.js"
|
|
31
32
|
},
|
|
@@ -38,18 +39,25 @@
|
|
|
38
39
|
},
|
|
39
40
|
"dependencies": {
|
|
40
41
|
"cheerio": "1.1.0",
|
|
41
|
-
"cli-
|
|
42
|
+
"cli-spinners": "^3.3.0",
|
|
42
43
|
"http-cookie-agent": "^6.0.0",
|
|
44
|
+
"ink": "^6.5.1",
|
|
45
|
+
"ink-picture": "^1.3.3",
|
|
46
|
+
"react": "^19.2.0",
|
|
43
47
|
"rxjs": "^7.8.2",
|
|
44
48
|
"tough-cookie": "5.1.2",
|
|
45
49
|
"undici": "^6.22.0",
|
|
46
|
-
"yargs": "^17.7.2"
|
|
50
|
+
"yargs": "^17.7.2",
|
|
51
|
+
"zustand": "^5.0.8"
|
|
47
52
|
},
|
|
48
53
|
"devDependencies": {
|
|
49
|
-
"@types/cli-progress": "^3.11.6",
|
|
50
54
|
"@types/node": "^24.10.0",
|
|
55
|
+
"@types/react": "^19.2.6",
|
|
51
56
|
"@types/yargs": "^17.0.34",
|
|
52
57
|
"esbuild": "^0.27.0",
|
|
58
|
+
"eslint-config-xo-react": "^0.27.0",
|
|
59
|
+
"eslint-plugin-react": "^7.32.2",
|
|
60
|
+
"eslint-plugin-react-hooks": "^4.6.0",
|
|
53
61
|
"tsx": "^4.20.6"
|
|
54
62
|
}
|
|
55
63
|
}
|
package/src/api/bunkr.ts
CHANGED
package/src/api/coomer-api.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { CoomerFile, CoomerFileList } from '../
|
|
2
|
-
import {
|
|
1
|
+
import { CoomerFile, CoomerFileList } from '../services/file';
|
|
2
|
+
import { isImage } from '../utils/filters';
|
|
3
|
+
import { fetchWithGlobalHeader, setGlobalHeaders } from '../utils/requests';
|
|
3
4
|
|
|
4
5
|
type CoomerAPIUser = { domain: string; service: string; id: string; name?: string };
|
|
5
6
|
type CoomerAPIUserData = { name: string };
|
package/src/api/gofile.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { fetch } from 'undici';
|
|
2
|
-
import { CoomerFile, CoomerFileList } from '../
|
|
3
|
-
import { setGlobalHeaders } from '../utils/
|
|
2
|
+
import { CoomerFile, CoomerFileList } from '../services/file';
|
|
3
|
+
import { setGlobalHeaders } from '../utils/requests';
|
|
4
4
|
|
|
5
5
|
type GoFileAPIToken = { status: string; data: { token: string } };
|
|
6
6
|
type GoFileAPIFilelist = { data: { children: { link: string; name: string }[] } };
|
package/src/api/index.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { CoomerFileList } from '../
|
|
1
|
+
import type { CoomerFileList } from '../services/file';
|
|
2
2
|
import { getBunkrData } from './bunkr';
|
|
3
3
|
import { getCoomerData } from './coomer-api';
|
|
4
4
|
import { getGofileData } from './gofile';
|
|
@@ -11,15 +11,19 @@ export async function apiHandler(url_: string): Promise<CoomerFileList> {
|
|
|
11
11
|
if (/^u\/\w+$/.test(url.origin)) {
|
|
12
12
|
return getRedditData(url.href);
|
|
13
13
|
}
|
|
14
|
+
|
|
14
15
|
if (/coomer|kemono/.test(url.origin)) {
|
|
15
16
|
return getCoomerData(url.href);
|
|
16
17
|
}
|
|
18
|
+
|
|
17
19
|
if (/bunkr/.test(url.origin)) {
|
|
18
20
|
return getBunkrData(url.href);
|
|
19
21
|
}
|
|
22
|
+
|
|
20
23
|
if (/gofile\.io/.test(url.origin)) {
|
|
21
24
|
return getGofileData(url.href);
|
|
22
25
|
}
|
|
26
|
+
|
|
23
27
|
if (/\.\w+/.test(url.pathname)) {
|
|
24
28
|
return getPlainFileData(url.href);
|
|
25
29
|
}
|
package/src/api/nsfw.xxx.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as cheerio from 'cheerio';
|
|
2
2
|
import { fetch } from 'undici';
|
|
3
|
-
import { CoomerFile, CoomerFileList } from '../
|
|
3
|
+
import { CoomerFile, CoomerFileList } from '../services/file';
|
|
4
4
|
|
|
5
5
|
async function getUserPage(user: string, offset: number) {
|
|
6
6
|
const url = `https://nsfw.xxx/page/${offset}?nsfw[]=0&types[]=image&types[]=video&types[]=gallery&slider=1&jsload=1&user=${user}&_=${Date.now()}`;
|
package/src/api/plain-curl.ts
CHANGED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { Box } from 'ink';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { CoomerFileList } from '../../services/file';
|
|
4
|
+
import { FileBox, FileListStateBox, KeyboardControlsInfo, Loading, TitleBar } from './components';
|
|
5
|
+
import { useDownloaderHook } from './hooks/downloader';
|
|
6
|
+
import { useInputHook } from './hooks/input';
|
|
7
|
+
import { useInkStore } from './store';
|
|
8
|
+
|
|
9
|
+
export function App() {
|
|
10
|
+
useInputHook();
|
|
11
|
+
useDownloaderHook();
|
|
12
|
+
|
|
13
|
+
const downloader = useInkStore((state) => state.downloader);
|
|
14
|
+
const filelist = downloader?.filelist;
|
|
15
|
+
const isFilelist = filelist instanceof CoomerFileList;
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<Box borderStyle="single" flexDirection="column" borderColor="blue" width={80}>
|
|
19
|
+
<TitleBar />
|
|
20
|
+
{!isFilelist ? (
|
|
21
|
+
<Loading />
|
|
22
|
+
) : (
|
|
23
|
+
<>
|
|
24
|
+
<Box>
|
|
25
|
+
<Box>
|
|
26
|
+
<FileListStateBox filelist={filelist} />
|
|
27
|
+
</Box>
|
|
28
|
+
<Box flexBasis={29}>
|
|
29
|
+
<KeyboardControlsInfo />
|
|
30
|
+
</Box>
|
|
31
|
+
</Box>
|
|
32
|
+
|
|
33
|
+
{filelist.getActiveFiles().map((file) => {
|
|
34
|
+
return <FileBox file={file} key={file.name} />;
|
|
35
|
+
})}
|
|
36
|
+
</>
|
|
37
|
+
)}
|
|
38
|
+
</Box>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { Box, Spacer, Text } from 'ink';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import type { CoomerFile } from '../../../services/file';
|
|
4
|
+
import { b2mb } from '../../../utils/strings';
|
|
5
|
+
import { Preview } from './preview';
|
|
6
|
+
import { Spinner } from './spinner';
|
|
7
|
+
|
|
8
|
+
interface FileBoxProps {
|
|
9
|
+
file: CoomerFile;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function FileBox({ file }: FileBoxProps) {
|
|
13
|
+
const percentage = Number((file.downloaded / (file.size as number)) * 100).toFixed(2);
|
|
14
|
+
return (
|
|
15
|
+
<>
|
|
16
|
+
<Box
|
|
17
|
+
borderStyle="single"
|
|
18
|
+
borderColor="magentaBright"
|
|
19
|
+
borderDimColor
|
|
20
|
+
paddingX={1}
|
|
21
|
+
flexDirection="column"
|
|
22
|
+
>
|
|
23
|
+
<Box>
|
|
24
|
+
<Text color="blue" dimColor wrap="truncate-middle">
|
|
25
|
+
{file.name}
|
|
26
|
+
</Text>
|
|
27
|
+
</Box>
|
|
28
|
+
<Box flexDirection="row-reverse">
|
|
29
|
+
<Text color="cyan" dimColor>
|
|
30
|
+
{b2mb(file.downloaded)}/{file.size ? b2mb(file.size) : '∞'} MB
|
|
31
|
+
</Text>
|
|
32
|
+
<Text color="redBright" dimColor>
|
|
33
|
+
{file.size ? ` ${percentage}% ` : ''}
|
|
34
|
+
</Text>
|
|
35
|
+
<Spacer />
|
|
36
|
+
<Text color={'green'} dimColor>
|
|
37
|
+
<Spinner></Spinner>
|
|
38
|
+
</Text>
|
|
39
|
+
</Box>
|
|
40
|
+
</Box>
|
|
41
|
+
<Preview file={file} />
|
|
42
|
+
</>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { Box, Text } from 'ink';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import type { CoomerFileList } from '../../../services/file';
|
|
4
|
+
|
|
5
|
+
interface FileListStateBoxProps {
|
|
6
|
+
filelist: CoomerFileList;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function FileListStateBox({ filelist }: FileListStateBoxProps) {
|
|
10
|
+
return (
|
|
11
|
+
<Box
|
|
12
|
+
paddingX={1}
|
|
13
|
+
flexDirection="column"
|
|
14
|
+
borderStyle={'single'}
|
|
15
|
+
borderColor={'magenta'}
|
|
16
|
+
borderDimColor
|
|
17
|
+
>
|
|
18
|
+
<Box>
|
|
19
|
+
<Box marginRight={1}>
|
|
20
|
+
<Text color="cyanBright" dimColor>
|
|
21
|
+
Found:
|
|
22
|
+
</Text>
|
|
23
|
+
</Box>
|
|
24
|
+
<Text color="blue" dimColor wrap="wrap">
|
|
25
|
+
{filelist.files.length}
|
|
26
|
+
</Text>
|
|
27
|
+
</Box>
|
|
28
|
+
<Box>
|
|
29
|
+
<Box marginRight={1}>
|
|
30
|
+
<Text color="cyanBright" dimColor>
|
|
31
|
+
Downloaded:
|
|
32
|
+
</Text>
|
|
33
|
+
</Box>
|
|
34
|
+
<Text color="blue" dimColor wrap="wrap">
|
|
35
|
+
{filelist.getDownloaded().length}
|
|
36
|
+
</Text>
|
|
37
|
+
</Box>
|
|
38
|
+
<Box>
|
|
39
|
+
<Box width={9}>
|
|
40
|
+
<Text color="cyanBright" dimColor>
|
|
41
|
+
Folder:
|
|
42
|
+
</Text>
|
|
43
|
+
</Box>
|
|
44
|
+
<Box flexGrow={1}>
|
|
45
|
+
<Text color="blue" dimColor wrap="truncate-middle">
|
|
46
|
+
{filelist.dirPath}
|
|
47
|
+
</Text>
|
|
48
|
+
</Box>
|
|
49
|
+
</Box>
|
|
50
|
+
</Box>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { Box, Text } from 'ink';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
|
|
4
|
+
const info = {
|
|
5
|
+
's ': 'skip current file',
|
|
6
|
+
p: 'on/off image preview',
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export function KeyboardControlsInfo() {
|
|
10
|
+
const infoRender = Object.entries(info).map(([key, value]) => {
|
|
11
|
+
return (
|
|
12
|
+
<Box key={key}>
|
|
13
|
+
<Box marginRight={2}>
|
|
14
|
+
<Text color={'red'} dimColor bold>
|
|
15
|
+
{key}
|
|
16
|
+
</Text>
|
|
17
|
+
</Box>
|
|
18
|
+
<Text dimColor bold={false}>
|
|
19
|
+
{value}
|
|
20
|
+
</Text>
|
|
21
|
+
</Box>
|
|
22
|
+
);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<Box
|
|
27
|
+
flexDirection="column"
|
|
28
|
+
paddingX={1}
|
|
29
|
+
borderStyle={'single'}
|
|
30
|
+
borderColor={'gray'}
|
|
31
|
+
borderDimColor
|
|
32
|
+
>
|
|
33
|
+
<Box>
|
|
34
|
+
<Text color={'red'} dimColor bold>
|
|
35
|
+
Keyboard controls:
|
|
36
|
+
</Text>
|
|
37
|
+
</Box>
|
|
38
|
+
{infoRender}
|
|
39
|
+
</Box>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Box, Text } from 'ink';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { Spinner } from './spinner';
|
|
4
|
+
|
|
5
|
+
export function Loading() {
|
|
6
|
+
return (
|
|
7
|
+
<Box paddingX={1} borderDimColor flexDirection="column">
|
|
8
|
+
<Box alignSelf="center">
|
|
9
|
+
<Text dimColor color={'redBright'}>
|
|
10
|
+
Fetching Data
|
|
11
|
+
</Text>
|
|
12
|
+
</Box>
|
|
13
|
+
<Box alignSelf="center">
|
|
14
|
+
<Text color={'blueBright'} dimColor>
|
|
15
|
+
<Spinner type="grenade"></Spinner>
|
|
16
|
+
</Text>
|
|
17
|
+
</Box>
|
|
18
|
+
</Box>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { Box } from 'ink';
|
|
2
|
+
import Image, { TerminalInfoProvider } from 'ink-picture';
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import type { CoomerFile } from '../../../services/file';
|
|
5
|
+
import { isImage } from '../../../utils/filters';
|
|
6
|
+
import { useInkStore } from '../store';
|
|
7
|
+
|
|
8
|
+
interface PreviewProps {
|
|
9
|
+
file: CoomerFile;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function Preview({ file }: PreviewProps) {
|
|
13
|
+
const previewEnabled = useInkStore((state) => state.preview);
|
|
14
|
+
const bigEnough = file.downloaded > 50 * 1024;
|
|
15
|
+
const shouldShow = previewEnabled && bigEnough && isImage(file.filepath as string);
|
|
16
|
+
const imgInfo = `
|
|
17
|
+
can't read partial images yet...
|
|
18
|
+
actual size: ${file.size}}
|
|
19
|
+
downloaded: ${file.downloaded}}
|
|
20
|
+
`;
|
|
21
|
+
return (
|
|
22
|
+
shouldShow && (
|
|
23
|
+
<Box paddingX={1}>
|
|
24
|
+
<TerminalInfoProvider>
|
|
25
|
+
<Box width={30} height={15}>
|
|
26
|
+
<Image src={file.filepath as string} alt={imgInfo} />
|
|
27
|
+
</Box>
|
|
28
|
+
</TerminalInfoProvider>
|
|
29
|
+
</Box>
|
|
30
|
+
)
|
|
31
|
+
);
|
|
32
|
+
}
|
|
@@ -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,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,34 +1,30 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
1
|
+
#!/usr/bin/env -S node --no-warnings=ExperimentalWarning
|
|
2
|
+
|
|
2
3
|
import process from 'node:process';
|
|
3
4
|
import { apiHandler } from './api';
|
|
4
|
-
import { argumentHander } from './args-handler';
|
|
5
|
-
import {
|
|
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';
|
|
6
10
|
|
|
7
11
|
async function run() {
|
|
12
|
+
createReactInk();
|
|
13
|
+
|
|
8
14
|
const { url, dir, media, include, exclude, skip } = argumentHander();
|
|
9
15
|
|
|
10
16
|
const filelist = await apiHandler(url);
|
|
11
17
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
filelist.
|
|
15
|
-
filelist.filterByText(include, exclude);
|
|
16
|
-
filelist.filterByMediaType(media);
|
|
17
|
-
|
|
18
|
-
console.table([
|
|
19
|
-
{
|
|
20
|
-
found,
|
|
21
|
-
skip,
|
|
22
|
-
filtered: found - filelist.files.length,
|
|
23
|
-
folder: filelist.dirPath,
|
|
24
|
-
},
|
|
25
|
-
]);
|
|
18
|
+
filelist.setDirPath(dir).skip(skip).filterByText(include, exclude).filterByMediaType(media);
|
|
19
|
+
|
|
20
|
+
await filelist.calculateFileSizes();
|
|
26
21
|
|
|
27
22
|
setGlobalHeaders({ Referer: url });
|
|
28
23
|
|
|
29
|
-
const downloader = new Downloader();
|
|
30
|
-
|
|
31
|
-
|
|
24
|
+
const downloader = new Downloader(filelist);
|
|
25
|
+
useInkStore.getState().setDownloader(downloader);
|
|
26
|
+
|
|
27
|
+
await downloader.downloadFiles();
|
|
32
28
|
|
|
33
29
|
process.kill(process.pid, 'SIGINT');
|
|
34
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
|
+
}
|