coomer-downloader 3.3.2 → 3.4.1
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/README.md +14 -3
- package/biome.json +6 -4
- package/dist/index.js +196 -68
- package/docs/images/Screenshot 01.jpg +0 -0
- package/package.json +2 -1
- package/src/api/bunkr.ts +5 -2
- package/src/api/coomer-api.ts +23 -6
- package/src/api/gofile.ts +2 -1
- package/src/api/index.ts +1 -1
- package/src/api/nsfw.xxx.ts +6 -4
- package/src/api/plain-curl.ts +2 -1
- package/src/cli/args-handler.ts +17 -12
- package/src/cli/ui/app.tsx +12 -11
- package/src/cli/ui/components/preview.tsx +1 -1
- package/src/cli/ui/hooks/downloader.ts +24 -14
- package/src/index.ts +28 -5
- package/src/logger/index.ts +15 -0
- package/src/services/downloader.ts +28 -7
- package/src/services/file.ts +4 -70
- package/src/services/filelist.ts +86 -0
- package/src/types/index.ts +3 -2
- package/src/utils/duplicates.ts +23 -0
- package/src/utils/filters.ts +15 -15
- package/src/utils/io.ts +25 -0
- package/src/utils/mediatypes.ts +13 -0
- package/src/utils/timer.ts +3 -2
package/src/api/nsfw.xxx.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as cheerio from 'cheerio';
|
|
2
2
|
import { fetch } from 'undici';
|
|
3
|
-
import { CoomerFile
|
|
3
|
+
import { CoomerFile } from '../services/file';
|
|
4
|
+
import { CoomerFileList } from '../services/filelist';
|
|
4
5
|
|
|
5
6
|
async function getUserPage(user: string, offset: number) {
|
|
6
7
|
const url = `https://nsfw.xxx/page/${offset}?nsfw[]=0&types[]=image&types[]=video&types[]=gallery&slider=1&jsload=1&user=${user}&_=${Date.now()}`;
|
|
@@ -8,7 +9,6 @@ async function getUserPage(user: string, offset: number) {
|
|
|
8
9
|
}
|
|
9
10
|
|
|
10
11
|
async function getUserPosts(user: string): Promise<string[]> {
|
|
11
|
-
console.log('Fetching user posts...');
|
|
12
12
|
const posts = [];
|
|
13
13
|
for (let i = 1; i < 100000; i++) {
|
|
14
14
|
const page = await getUserPage(user, i);
|
|
@@ -26,7 +26,6 @@ async function getUserPosts(user: string): Promise<string[]> {
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
async function getPostsData(posts: string[]): Promise<CoomerFileList> {
|
|
29
|
-
console.log('Fetching posts data...');
|
|
30
29
|
const filelist = new CoomerFileList();
|
|
31
30
|
for (const post of posts) {
|
|
32
31
|
const page = await fetch(post).then((r) => r.text());
|
|
@@ -40,7 +39,8 @@ async function getPostsData(posts: string[]): Promise<CoomerFileList> {
|
|
|
40
39
|
if (!src) continue;
|
|
41
40
|
|
|
42
41
|
const slug = post.split('post/')[1].split('?')[0];
|
|
43
|
-
const date =
|
|
42
|
+
const date =
|
|
43
|
+
$('.sh-section .sh-section__passed').first().text().replace(/ /g, '-') || '';
|
|
44
44
|
|
|
45
45
|
const ext = src.split('.').pop();
|
|
46
46
|
const name = `${slug}-${date}.${ext}`;
|
|
@@ -53,7 +53,9 @@ async function getPostsData(posts: string[]): Promise<CoomerFileList> {
|
|
|
53
53
|
|
|
54
54
|
export async function getRedditData(url: string): Promise<CoomerFileList> {
|
|
55
55
|
const user = url.match(/u\/(\w+)/)?.[1] as string;
|
|
56
|
+
console.log('Fetching user posts...');
|
|
56
57
|
const posts = await getUserPosts(user);
|
|
58
|
+
console.log('Fetching posts data...');
|
|
57
59
|
const filelist = await getPostsData(posts);
|
|
58
60
|
filelist.dirName = `${user}-reddit`;
|
|
59
61
|
return filelist;
|
package/src/api/plain-curl.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { CoomerFile
|
|
1
|
+
import { CoomerFile } from '../services/file';
|
|
2
|
+
import { CoomerFileList } from '../services/filelist';
|
|
2
3
|
|
|
3
4
|
export async function getPlainFileData(url: string): Promise<CoomerFileList> {
|
|
4
5
|
const name = url.split('/').pop() as string;
|
package/src/cli/args-handler.ts
CHANGED
|
@@ -1,16 +1,7 @@
|
|
|
1
1
|
import yargs from 'yargs';
|
|
2
2
|
import { hideBin } from 'yargs/helpers';
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
url: string;
|
|
6
|
-
dir: string;
|
|
7
|
-
media: string;
|
|
8
|
-
include: string;
|
|
9
|
-
exclude: string;
|
|
10
|
-
skip: number;
|
|
11
|
-
};
|
|
12
|
-
|
|
13
|
-
export function argumentHander(): ArgumentHandlerResult {
|
|
4
|
+
export function argumentHander() {
|
|
14
5
|
return yargs(hideBin(process.argv))
|
|
15
6
|
.option('url', {
|
|
16
7
|
alias: 'u',
|
|
@@ -26,8 +17,7 @@ export function argumentHander(): ArgumentHandlerResult {
|
|
|
26
17
|
})
|
|
27
18
|
.option('media', {
|
|
28
19
|
type: 'string',
|
|
29
|
-
choices: ['video', 'image'
|
|
30
|
-
default: 'all',
|
|
20
|
+
choices: ['video', 'image'],
|
|
31
21
|
description:
|
|
32
22
|
"The type of media to download: 'video', 'image', or 'all'. 'all' is the default.",
|
|
33
23
|
})
|
|
@@ -41,11 +31,26 @@ export function argumentHander(): ArgumentHandlerResult {
|
|
|
41
31
|
default: '',
|
|
42
32
|
description: 'Filter file names by a comma-separated list of keywords to exclude',
|
|
43
33
|
})
|
|
34
|
+
.option('min-size', {
|
|
35
|
+
type: 'string',
|
|
36
|
+
default: '',
|
|
37
|
+
description: 'Minimum file size to download. Example: "1mb" or "500kb"',
|
|
38
|
+
})
|
|
39
|
+
.option('max-size', {
|
|
40
|
+
type: 'string',
|
|
41
|
+
default: '',
|
|
42
|
+
description: 'Maximum file size to download. Example: "1mb" or "500kb"',
|
|
43
|
+
})
|
|
44
44
|
.option('skip', {
|
|
45
45
|
type: 'number',
|
|
46
46
|
default: 0,
|
|
47
47
|
description: 'Skips the first N files in the download queue',
|
|
48
48
|
})
|
|
49
|
+
.option('remove-dupilicates', {
|
|
50
|
+
type: 'boolean',
|
|
51
|
+
default: true,
|
|
52
|
+
description: 'removes duplicates by url and file hash',
|
|
53
|
+
})
|
|
49
54
|
.help()
|
|
50
55
|
.alias('help', 'h')
|
|
51
56
|
.parseSync();
|
package/src/cli/ui/app.tsx
CHANGED
|
@@ -1,23 +1,24 @@
|
|
|
1
1
|
import { Box } from 'ink';
|
|
2
2
|
import React from 'react';
|
|
3
|
-
import { CoomerFileList } from '../../services/
|
|
4
|
-
import {
|
|
3
|
+
import { CoomerFileList } from '../../services/filelist';
|
|
4
|
+
import {
|
|
5
|
+
FileBox,
|
|
6
|
+
FileListStateBox,
|
|
7
|
+
KeyboardControlsInfo,
|
|
8
|
+
Loading,
|
|
9
|
+
TitleBar,
|
|
10
|
+
} from './components';
|
|
5
11
|
import { useDownloaderHook } from './hooks/downloader';
|
|
6
12
|
import { useInputHook } from './hooks/input';
|
|
7
|
-
import { useInkStore } from './store';
|
|
8
13
|
|
|
9
14
|
export function App() {
|
|
10
15
|
useInputHook();
|
|
11
|
-
useDownloaderHook();
|
|
12
|
-
|
|
13
|
-
const downloader = useInkStore((state) => state.downloader);
|
|
14
|
-
const filelist = downloader?.filelist;
|
|
15
|
-
const isFilelist = filelist instanceof CoomerFileList;
|
|
16
|
+
const filelist = useDownloaderHook();
|
|
16
17
|
|
|
17
18
|
return (
|
|
18
19
|
<Box borderStyle="single" flexDirection="column" borderColor="blue" width={80}>
|
|
19
20
|
<TitleBar />
|
|
20
|
-
{!
|
|
21
|
+
{!(filelist instanceof CoomerFileList) ? (
|
|
21
22
|
<Loading />
|
|
22
23
|
) : (
|
|
23
24
|
<>
|
|
@@ -25,12 +26,12 @@ export function App() {
|
|
|
25
26
|
<Box>
|
|
26
27
|
<FileListStateBox filelist={filelist} />
|
|
27
28
|
</Box>
|
|
28
|
-
<Box flexBasis={
|
|
29
|
+
<Box flexBasis={30}>
|
|
29
30
|
<KeyboardControlsInfo />
|
|
30
31
|
</Box>
|
|
31
32
|
</Box>
|
|
32
33
|
|
|
33
|
-
{filelist
|
|
34
|
+
{filelist?.getActiveFiles().map((file) => {
|
|
34
35
|
return <FileBox file={file} key={file.name} />;
|
|
35
36
|
})}
|
|
36
37
|
</>
|
|
@@ -2,7 +2,7 @@ import { Box } from 'ink';
|
|
|
2
2
|
import Image, { TerminalInfoProvider } from 'ink-picture';
|
|
3
3
|
import React from 'react';
|
|
4
4
|
import type { CoomerFile } from '../../../services/file';
|
|
5
|
-
import { isImage } from '../../../utils/
|
|
5
|
+
import { isImage } from '../../../utils/mediatypes';
|
|
6
6
|
import { useInkStore } from '../store';
|
|
7
7
|
|
|
8
8
|
interface PreviewProps {
|
|
@@ -1,21 +1,31 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useRef, useSyncExternalStore } from 'react';
|
|
2
2
|
import { useInkStore } from '../store';
|
|
3
3
|
|
|
4
4
|
export const useDownloaderHook = () => {
|
|
5
5
|
const downloader = useInkStore((state) => state.downloader);
|
|
6
|
-
const filelist = downloader?.filelist;
|
|
7
6
|
|
|
8
|
-
const
|
|
7
|
+
const versionRef = useRef(0);
|
|
9
8
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
if (
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
9
|
+
useSyncExternalStore(
|
|
10
|
+
(onStoreChange) => {
|
|
11
|
+
if (!downloader) return () => {};
|
|
12
|
+
|
|
13
|
+
const sub = downloader.subject.subscribe(({ type }) => {
|
|
14
|
+
const targets = [
|
|
15
|
+
'FILE_DOWNLOADING_START',
|
|
16
|
+
'FILE_DOWNLOADING_END',
|
|
17
|
+
'CHUNK_DOWNLOADING_UPDATE',
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
if (targets.includes(type)) {
|
|
21
|
+
versionRef.current++;
|
|
22
|
+
onStoreChange();
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
return () => sub.unsubscribe();
|
|
26
|
+
},
|
|
27
|
+
() => versionRef.current,
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
return downloader?.filelist;
|
|
21
31
|
};
|
package/src/index.ts
CHANGED
|
@@ -6,27 +6,50 @@ import { argumentHander } from './cli/args-handler';
|
|
|
6
6
|
import { createReactInk } from './cli/ui';
|
|
7
7
|
import { useInkStore } from './cli/ui/store';
|
|
8
8
|
import { Downloader } from './services/downloader';
|
|
9
|
+
import { parseSizeValue } from './utils/filters';
|
|
9
10
|
import { setGlobalHeaders } from './utils/requests';
|
|
10
11
|
|
|
11
12
|
async function run() {
|
|
12
13
|
createReactInk();
|
|
13
14
|
|
|
14
|
-
const { url, dir, media, include, exclude, skip } =
|
|
15
|
+
const { url, dir, media, include, exclude, minSize, maxSize, skip, removeDupilicates } =
|
|
16
|
+
argumentHander();
|
|
15
17
|
|
|
16
18
|
const filelist = await apiHandler(url);
|
|
17
19
|
|
|
18
|
-
filelist
|
|
20
|
+
filelist
|
|
21
|
+
.setDirPath(dir)
|
|
22
|
+
.skip(skip)
|
|
23
|
+
.filterByText(include, exclude)
|
|
24
|
+
.filterByMediaType(media);
|
|
25
|
+
|
|
26
|
+
if (removeDupilicates) {
|
|
27
|
+
filelist.removeURLDuplicates();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const minSizeBytes = minSize ? parseSizeValue(minSize) : undefined;
|
|
31
|
+
const maxSizeBytes = maxSize ? parseSizeValue(maxSize) : undefined;
|
|
19
32
|
|
|
20
33
|
await filelist.calculateFileSizes();
|
|
21
34
|
|
|
22
35
|
setGlobalHeaders({ Referer: url });
|
|
23
36
|
|
|
24
|
-
const downloader = new Downloader(filelist);
|
|
37
|
+
const downloader = new Downloader(filelist, minSizeBytes, maxSizeBytes);
|
|
25
38
|
useInkStore.getState().setDownloader(downloader);
|
|
26
39
|
|
|
27
40
|
await downloader.downloadFiles();
|
|
28
41
|
|
|
29
|
-
|
|
42
|
+
if (removeDupilicates) {
|
|
43
|
+
await filelist.removeDuplicatesByHash();
|
|
44
|
+
}
|
|
30
45
|
}
|
|
31
46
|
|
|
32
|
-
|
|
47
|
+
(async () => {
|
|
48
|
+
try {
|
|
49
|
+
await run();
|
|
50
|
+
process.exit(0);
|
|
51
|
+
} catch (err) {
|
|
52
|
+
console.error('Fatal error:', err);
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
})();
|
|
@@ -3,18 +3,19 @@ import { Readable, Transform } from 'node:stream';
|
|
|
3
3
|
import { pipeline } from 'node:stream/promises';
|
|
4
4
|
import { Subject } from 'rxjs';
|
|
5
5
|
import { tryFixCoomerUrl } from '../api/coomer-api';
|
|
6
|
-
import type { DownloaderSubject } from '../types';
|
|
7
|
-
import { getFileSize, mkdir } from '../utils/io';
|
|
6
|
+
import type { AbortControllerSubject, DownloaderSubject } from '../types';
|
|
7
|
+
import { deleteFile, getFileSize, mkdir } from '../utils/io';
|
|
8
8
|
import { sleep } from '../utils/promise';
|
|
9
9
|
import { fetchByteRange } from '../utils/requests';
|
|
10
10
|
import { Timer } from '../utils/timer';
|
|
11
|
-
import type { CoomerFile
|
|
11
|
+
import type { CoomerFile } from './file';
|
|
12
|
+
import type { CoomerFileList } from './filelist';
|
|
12
13
|
|
|
13
14
|
export class Downloader {
|
|
14
15
|
public subject = new Subject<DownloaderSubject>();
|
|
15
16
|
|
|
16
17
|
private abortController = new AbortController();
|
|
17
|
-
public abortControllerSubject = new Subject<
|
|
18
|
+
public abortControllerSubject = new Subject<AbortControllerSubject>();
|
|
18
19
|
|
|
19
20
|
setAbortControllerListener() {
|
|
20
21
|
this.abortControllerSubject.subscribe((type) => {
|
|
@@ -25,6 +26,8 @@ export class Downloader {
|
|
|
25
26
|
|
|
26
27
|
constructor(
|
|
27
28
|
public filelist: CoomerFileList,
|
|
29
|
+
public minSize?: number,
|
|
30
|
+
public maxSize?: number,
|
|
28
31
|
public chunkTimeout = 30_000,
|
|
29
32
|
public chunkFetchRetries = 5,
|
|
30
33
|
public fetchRetries = 7,
|
|
@@ -40,8 +43,10 @@ export class Downloader {
|
|
|
40
43
|
): Promise<void> {
|
|
41
44
|
const signal = this.abortController.signal;
|
|
42
45
|
const subject = this.subject;
|
|
43
|
-
const { timer } = Timer.withAbortController(
|
|
44
|
-
|
|
46
|
+
const { timer } = Timer.withAbortController(
|
|
47
|
+
this.chunkTimeout,
|
|
48
|
+
this.abortControllerSubject,
|
|
49
|
+
);
|
|
45
50
|
|
|
46
51
|
try {
|
|
47
52
|
const fileStream = fs.createWriteStream(file.filepath as string, { flags: 'a' });
|
|
@@ -74,7 +79,6 @@ export class Downloader {
|
|
|
74
79
|
} finally {
|
|
75
80
|
subject.next({ type: 'CHUNK_DOWNLOADING_END' });
|
|
76
81
|
timer.stop();
|
|
77
|
-
clearInterval(i);
|
|
78
82
|
}
|
|
79
83
|
}
|
|
80
84
|
|
|
@@ -82,6 +86,20 @@ export class Downloader {
|
|
|
82
86
|
this.abortControllerSubject.next('FILE_SKIP');
|
|
83
87
|
}
|
|
84
88
|
|
|
89
|
+
private filterFileSize(file: CoomerFile) {
|
|
90
|
+
if (!file.size) return;
|
|
91
|
+
if (
|
|
92
|
+
(this.minSize && file.size < this.minSize) ||
|
|
93
|
+
(this.maxSize && file.size > this.maxSize)
|
|
94
|
+
) {
|
|
95
|
+
try {
|
|
96
|
+
deleteFile(file.filepath);
|
|
97
|
+
} catch {}
|
|
98
|
+
this.skip();
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
85
103
|
async downloadFile(file: CoomerFile, retries = this.fetchRetries): Promise<void> {
|
|
86
104
|
const signal = this.abortController.signal;
|
|
87
105
|
try {
|
|
@@ -100,6 +118,8 @@ export class Downloader {
|
|
|
100
118
|
const restFileSize = parseInt(contentLength);
|
|
101
119
|
file.size = restFileSize + file.downloaded;
|
|
102
120
|
|
|
121
|
+
this.filterFileSize(file);
|
|
122
|
+
|
|
103
123
|
if (file.size > file.downloaded && response.body) {
|
|
104
124
|
const stream = Readable.fromWeb(response.body);
|
|
105
125
|
stream.setMaxListeners(20);
|
|
@@ -124,6 +144,7 @@ export class Downloader {
|
|
|
124
144
|
mkdir(this.filelist.dirPath as string);
|
|
125
145
|
|
|
126
146
|
this.subject.next({ type: 'FILES_DOWNLOADING_START' });
|
|
147
|
+
|
|
127
148
|
for (const file of this.filelist.files) {
|
|
128
149
|
file.active = true;
|
|
129
150
|
|
package/src/services/file.ts
CHANGED
|
@@ -1,31 +1,19 @@
|
|
|
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
1
|
import { getFileSize } from '../utils/io';
|
|
6
2
|
|
|
7
|
-
interface ICoomerFile {
|
|
8
|
-
name: string;
|
|
9
|
-
url: string;
|
|
10
|
-
filepath?: string;
|
|
11
|
-
size?: number;
|
|
12
|
-
downloaded?: number;
|
|
13
|
-
content?: string;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
3
|
export class CoomerFile {
|
|
17
4
|
public active = false;
|
|
5
|
+
public hash?: string;
|
|
18
6
|
|
|
19
7
|
constructor(
|
|
20
8
|
public name: string,
|
|
21
9
|
public url: string,
|
|
22
|
-
public filepath
|
|
10
|
+
public filepath = '',
|
|
23
11
|
public size?: number,
|
|
24
12
|
public downloaded = 0,
|
|
25
13
|
public content?: string,
|
|
26
14
|
) {}
|
|
27
15
|
|
|
28
|
-
public async
|
|
16
|
+
public async calcDownloadedSize() {
|
|
29
17
|
this.downloaded = await getFileSize(this.filepath as string);
|
|
30
18
|
return this;
|
|
31
19
|
}
|
|
@@ -35,61 +23,7 @@ export class CoomerFile {
|
|
|
35
23
|
return text;
|
|
36
24
|
}
|
|
37
25
|
|
|
38
|
-
public static from(f:
|
|
26
|
+
public static from(f: Pick<CoomerFile, 'name' | 'url'> & Partial<CoomerFile>) {
|
|
39
27
|
return new CoomerFile(f.name, f.url, f.filepath, f.size, f.downloaded, f.content);
|
|
40
28
|
}
|
|
41
29
|
}
|
|
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
|
-
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import logger from '../logger';
|
|
4
|
+
import type { MediaType } from '../types';
|
|
5
|
+
import { collectUniquesAndDuplicatesBy, removeDuplicatesBy } from '../utils/duplicates';
|
|
6
|
+
import { filterString } from '../utils/filters';
|
|
7
|
+
import { deleteFile, getFileHash, sanitizeFilename } from '../utils/io';
|
|
8
|
+
import { testMediaType } from '../utils/mediatypes';
|
|
9
|
+
import type { CoomerFile } from './file';
|
|
10
|
+
|
|
11
|
+
export class CoomerFileList {
|
|
12
|
+
public dirPath?: string;
|
|
13
|
+
public dirName?: string;
|
|
14
|
+
|
|
15
|
+
constructor(public files: CoomerFile[] = []) {}
|
|
16
|
+
|
|
17
|
+
public setDirPath(dir: string, dirName?: string) {
|
|
18
|
+
dirName = dirName || (this.dirName as string);
|
|
19
|
+
|
|
20
|
+
if (dir === './') {
|
|
21
|
+
this.dirPath = path.resolve(dir, dirName);
|
|
22
|
+
} else {
|
|
23
|
+
this.dirPath = path.join(os.homedir(), path.join(dir, dirName));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
this.files.forEach((file) => {
|
|
27
|
+
const safeName = sanitizeFilename(file.name) || file.name;
|
|
28
|
+
file.filepath = path.join(this.dirPath as string, safeName);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
return this;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
public filterByText(include: string, exclude: string) {
|
|
35
|
+
this.files = this.files.filter((f) => filterString(f.textContent, include, exclude));
|
|
36
|
+
return this;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
public filterByMediaType(media?: string) {
|
|
40
|
+
if (media) {
|
|
41
|
+
this.files = this.files.filter((f) => testMediaType(f.name, media as MediaType));
|
|
42
|
+
}
|
|
43
|
+
return this;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
public skip(n: number) {
|
|
47
|
+
this.files = this.files.slice(n);
|
|
48
|
+
return this;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
public async calculateFileSizes() {
|
|
52
|
+
for (const file of this.files) {
|
|
53
|
+
await file.calcDownloadedSize();
|
|
54
|
+
}
|
|
55
|
+
return this;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
public getActiveFiles() {
|
|
59
|
+
return this.files.filter((f) => f.active);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
public getDownloaded() {
|
|
63
|
+
return this.files.filter((f) => f.size && f.size <= f.downloaded);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
public async removeDuplicatesByHash() {
|
|
67
|
+
for (const file of this.files) {
|
|
68
|
+
file.hash = await getFileHash(file.filepath);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const { duplicates } = collectUniquesAndDuplicatesBy(this.files, 'hash');
|
|
72
|
+
|
|
73
|
+
// console.log({ duplicates });
|
|
74
|
+
|
|
75
|
+
// logger.debug(`duplicates: ${JSON.stringify(duplicates)}`);
|
|
76
|
+
|
|
77
|
+
duplicates.forEach((f) => {
|
|
78
|
+
deleteFile(f.filepath);
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
public removeURLDuplicates() {
|
|
83
|
+
this.files = removeDuplicatesBy(this.files, 'url');
|
|
84
|
+
return this;
|
|
85
|
+
}
|
|
86
|
+
}
|
package/src/types/index.ts
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
|
-
export type MediaType = 'video' | 'image'
|
|
1
|
+
export type MediaType = 'video' | 'image';
|
|
2
2
|
|
|
3
3
|
export type DownloaderSubjectSignal =
|
|
4
4
|
| 'FILES_DOWNLOADING_START'
|
|
5
5
|
| 'FILES_DOWNLOADING_END'
|
|
6
6
|
| 'FILE_DOWNLOADING_START'
|
|
7
7
|
| 'FILE_DOWNLOADING_END'
|
|
8
|
-
| 'FILE_SKIP'
|
|
9
8
|
| 'CHUNK_DOWNLOADING_START'
|
|
10
9
|
| 'CHUNK_DOWNLOADING_UPDATE'
|
|
11
10
|
| 'CHUNK_DOWNLOADING_END';
|
|
12
11
|
|
|
12
|
+
export type AbortControllerSubject = 'FILE_SKIP' | 'TIMEOUT';
|
|
13
|
+
|
|
13
14
|
export type DownloaderSubject = {
|
|
14
15
|
type: DownloaderSubjectSignal;
|
|
15
16
|
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export function collectUniquesAndDuplicatesBy<T extends {}, K extends keyof T>(
|
|
2
|
+
xs: T[],
|
|
3
|
+
k: K,
|
|
4
|
+
): { uniques: T[]; duplicates: T[] } {
|
|
5
|
+
const seen = new Set<T[K]>();
|
|
6
|
+
|
|
7
|
+
return xs.reduce(
|
|
8
|
+
(acc, item) => {
|
|
9
|
+
if (seen.has(item[k])) {
|
|
10
|
+
acc.duplicates.push(item);
|
|
11
|
+
} else {
|
|
12
|
+
seen.add(item[k]);
|
|
13
|
+
acc.uniques.push(item);
|
|
14
|
+
}
|
|
15
|
+
return acc;
|
|
16
|
+
},
|
|
17
|
+
{ uniques: [] as T[], duplicates: [] as T[] },
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function removeDuplicatesBy<T extends {}, K extends keyof T>(xs: T[], k: K) {
|
|
22
|
+
return [...new Map(xs.map((x) => [x[k], x])).values()];
|
|
23
|
+
}
|
package/src/utils/filters.ts
CHANGED
|
@@ -1,17 +1,3 @@
|
|
|
1
|
-
import type { MediaType } from '../types';
|
|
2
|
-
|
|
3
|
-
export function isImage(name: string) {
|
|
4
|
-
return /\.(jpg|jpeg|png|gif|bmp|tiff|webp|avif)$/i.test(name);
|
|
5
|
-
}
|
|
6
|
-
|
|
7
|
-
export function isVideo(name: string) {
|
|
8
|
-
return /\.(mp4|m4v|avi|mov|mkv|webm|flv|wmv|mpeg|mpg|3gp)$/i.test(name);
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export function testMediaType(name: string, type: MediaType) {
|
|
12
|
-
return type === 'all' ? true : type === 'image' ? isImage(name) : isVideo(name);
|
|
13
|
-
}
|
|
14
|
-
|
|
15
1
|
function includesAllWords(str: string, words: string[]) {
|
|
16
2
|
if (!words.length) return true;
|
|
17
3
|
return words.every((w) => str.includes(w));
|
|
@@ -30,5 +16,19 @@ function parseQuery(query: string) {
|
|
|
30
16
|
}
|
|
31
17
|
|
|
32
18
|
export function filterString(text: string, include: string, exclude: string): boolean {
|
|
33
|
-
return
|
|
19
|
+
return (
|
|
20
|
+
includesAllWords(text, parseQuery(include)) &&
|
|
21
|
+
includesNoWords(text, parseQuery(exclude))
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function parseSizeValue(s: string) {
|
|
26
|
+
if (!s) return NaN;
|
|
27
|
+
const m = s.match(/^([0-9]+(?:\.[0-9]+)?)(b|kb|mb|gb)?$/i);
|
|
28
|
+
if (!m) return NaN;
|
|
29
|
+
const val = parseFloat(m[1]);
|
|
30
|
+
const unit = (m[2] || 'b').toLowerCase();
|
|
31
|
+
const mult =
|
|
32
|
+
unit === 'kb' ? 1024 : unit === 'mb' ? 1024 ** 2 : unit === 'gb' ? 1024 ** 3 : 1;
|
|
33
|
+
return Math.floor(val * mult);
|
|
34
34
|
}
|
package/src/utils/io.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
1
2
|
import fs from 'node:fs';
|
|
3
|
+
import { access, constants, unlink } from 'node:fs/promises';
|
|
4
|
+
import { pipeline } from 'node:stream/promises';
|
|
2
5
|
|
|
3
6
|
export async function getFileSize(filepath: string) {
|
|
4
7
|
let size = 0;
|
|
@@ -8,8 +11,30 @@ export async function getFileSize(filepath: string) {
|
|
|
8
11
|
return size;
|
|
9
12
|
}
|
|
10
13
|
|
|
14
|
+
export async function getFileHash(filepath: string) {
|
|
15
|
+
const hash = createHash('sha256');
|
|
16
|
+
const filestream = fs.createReadStream(filepath);
|
|
17
|
+
await pipeline(filestream, hash);
|
|
18
|
+
return hash.digest('hex');
|
|
19
|
+
}
|
|
20
|
+
|
|
11
21
|
export function mkdir(filepath: string) {
|
|
12
22
|
if (!fs.existsSync(filepath)) {
|
|
13
23
|
fs.mkdirSync(filepath, { recursive: true });
|
|
14
24
|
}
|
|
15
25
|
}
|
|
26
|
+
|
|
27
|
+
export async function deleteFile(path: string) {
|
|
28
|
+
await access(path, constants.F_OK);
|
|
29
|
+
await unlink(path);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function sanitizeFilename(name: string) {
|
|
33
|
+
if (!name) return name;
|
|
34
|
+
|
|
35
|
+
return name
|
|
36
|
+
.replace(/[<>:"/\\|?*\x00-\x1F]/g, '-') // Newlines (\r \n) are caught here
|
|
37
|
+
.replace(/\s+/g, ' ') // Turn tabs/multiple spaces into one space
|
|
38
|
+
.trim()
|
|
39
|
+
.replace(/[.]+$/, ''); // Remove trailing dots
|
|
40
|
+
}
|