Compare commits
350 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ddf2710fc6 | ||
|
|
0f57aef7c7 | ||
|
|
f0608dcda1 | ||
|
|
9b10a4356f | ||
|
|
d159ac484a | ||
|
|
f4b5fadc5f | ||
|
|
169817f707 | ||
|
|
1418c2bc17 | ||
|
|
8d33141294 | ||
|
|
35341b522a | ||
|
|
f9aa7f4168 | ||
|
|
d9199f8aaf | ||
|
|
ba4642e09a | ||
|
|
d59c5c1df8 | ||
|
|
4bb18f7abc | ||
|
|
125e5f55ea | ||
|
|
79fe3037eb | ||
|
|
d280765feb | ||
|
|
b0b86e5016 | ||
|
|
cf35f4401d | ||
|
|
98eba0447d | ||
|
|
5fb313273d | ||
|
|
c44dde5396 | ||
|
|
f42c55c521 | ||
|
|
9af65ce2a9 | ||
|
|
4f41218a92 | ||
|
|
32d35fe336 | ||
|
|
ce0bbb8b7e | ||
|
|
89d29c7a2a | ||
|
|
a97fe69cff | ||
|
|
2e8e8a3819 | ||
|
|
d9e858febd | ||
|
|
e26b7ea8ed | ||
|
|
a7ac8c85f3 | ||
|
|
ca35c2a6a4 | ||
|
|
c1585ed09a | ||
|
|
b5ff9b1a0b | ||
|
|
72d3fe1e4e | ||
|
|
d720ba295a | ||
|
|
1c8514e127 | ||
|
|
1f622c5cc2 | ||
|
|
61853e7d4d | ||
|
|
287ebde1f5 | ||
|
|
9ae5d312e1 | ||
|
|
d24fd54e83 | ||
|
|
d2f903b8ba | ||
|
|
fc48f20db5 | ||
|
|
6286bca7c6 | ||
|
|
84c48ad7d6 | ||
|
|
329f768e2b | ||
|
|
35314ee3ed | ||
|
|
76c56cf13b | ||
|
|
a8d81cbf0d | ||
|
|
13de55253b | ||
|
|
166b04c526 | ||
|
|
f0f1564322 | ||
|
|
af51bebaf7 | ||
|
|
3ef3e074e6 | ||
|
|
f237d0f97a | ||
|
|
8f500c590e | ||
|
|
18a875a764 | ||
|
|
1e6bb27404 | ||
|
|
3a23d76f24 | ||
|
|
52751df735 | ||
|
|
9794efde46 | ||
|
|
ce5f20b1e1 | ||
|
|
996fc5aa17 | ||
|
|
bd42c86796 | ||
|
|
042f3d0ef9 | ||
|
|
ceab155a6c | ||
|
|
fb5c1caf43 | ||
|
|
57f8f0876e | ||
|
|
4c88c0a756 | ||
|
|
2208632154 | ||
|
|
c741503665 | ||
|
|
950a322022 | ||
|
|
c995d090a5 | ||
|
|
166a49dd0c | ||
|
|
f5256c437f | ||
|
|
d650a7395a | ||
|
|
bd41aff769 | ||
|
|
2ea26f4b64 | ||
|
|
b1fe0cfefb | ||
|
|
6e68748ca0 | ||
|
|
10ae46c44d | ||
|
|
7267adfd03 | ||
|
|
0ba8bd3a2c | ||
|
|
74d7f8ce5a | ||
|
|
a6958f1418 | ||
|
|
3626978250 | ||
|
|
04e535c709 | ||
|
|
794e4162e1 | ||
|
|
f1a3d7d468 | ||
|
|
f9cc5305f6 | ||
|
|
95ad35eab9 | ||
|
|
a212b31b08 | ||
|
|
cf34353036 | ||
|
|
8965983e0c | ||
|
|
f83fdabea3 | ||
|
|
7269504d3d | ||
|
|
2c46430492 | ||
|
|
3ece93c363 | ||
|
|
3865a0fe33 | ||
|
|
1c03a3f2e7 | ||
|
|
d9c3a00016 | ||
|
|
79fe41c774 | ||
|
|
678c9ce3c5 | ||
|
|
0df8557f06 | ||
|
|
4575b5ac26 | ||
|
|
0b306221d4 | ||
|
|
d96c6afce0 | ||
|
|
3487bc8fcf | ||
|
|
38ecc6a4cb | ||
|
|
4af89d7aa3 | ||
|
|
66f8b47b6d | ||
|
|
e49f5493fe | ||
|
|
58a21ed321 | ||
|
|
5afb56b987 | ||
|
|
3553666d9d | ||
|
|
c70f105685 | ||
|
|
0c301c8182 | ||
|
|
65f8d9a0e8 | ||
|
|
b96ccf851a | ||
|
|
329f501a6e | ||
|
|
8680ae6467 | ||
|
|
141bfd3658 | ||
|
|
05e6d654c4 | ||
|
|
1616ee8f14 | ||
|
|
d49fe136f2 | ||
|
|
22356864c3 | ||
|
|
058c8a2674 | ||
|
|
4bf159eda2 | ||
|
|
a6ff2dd587 | ||
|
|
f5a5cfdf2c | ||
|
|
bf806cb069 | ||
|
|
e5f9f91f4e | ||
|
|
17e9a419b2 | ||
|
|
e3a785d4a7 | ||
|
|
f3b1c25d8b | ||
|
|
187eff2429 | ||
|
|
d8821a46ee | ||
|
|
1e449e3d67 | ||
|
|
927fbc5895 | ||
|
|
7ed227a76e | ||
|
|
c696b0cb0e | ||
|
|
0ea92ad6d0 | ||
|
|
22869df8a5 | ||
|
|
bb89de3c93 | ||
|
|
530fd03c22 | ||
|
|
f6b1ef96b7 | ||
|
|
90ba69d1b0 | ||
|
|
c5c31aa323 | ||
|
|
7a5278012b | ||
|
|
63f87a0310 | ||
|
|
b7336eefb8 | ||
|
|
655fb6230b | ||
|
|
796aeb520d | ||
|
|
126b1e569a | ||
|
|
9b5184f76f | ||
|
|
9c679bd442 | ||
|
|
00a46dee2e | ||
|
|
6a40fdd435 | ||
|
|
5d43923217 | ||
|
|
0dcd62ac26 | ||
|
|
7ea718ee27 | ||
|
|
0405c28245 | ||
|
|
c8aeaf1de0 | ||
|
|
da4ac95c3c | ||
|
|
961d59f8b8 | ||
|
|
5c7bfb48b9 | ||
|
|
d0c9df7656 | ||
|
|
0e7ae5ee7b | ||
|
|
8e49733241 | ||
|
|
a0eae7f380 | ||
|
|
bf39b6c180 | ||
|
|
2dc94084ab | ||
|
|
976be2f566 | ||
|
|
edf35e9636 | ||
|
|
880537dcfb | ||
|
|
5265bcd77a | ||
|
|
4f2d462754 | ||
|
|
b4c26f8106 | ||
|
|
2d8b3f1bf9 | ||
|
|
c73108afff | ||
|
|
f16dd9ffa6 | ||
|
|
1bcd7a2078 | ||
|
|
879f6ade0e | ||
|
|
73e7190913 | ||
|
|
8f304f91d8 | ||
|
|
ae46d90dc2 | ||
|
|
9158949480 | ||
|
|
571d507889 | ||
|
|
85287aa620 | ||
|
|
7dc68c7615 | ||
|
|
60ceea41d7 | ||
|
|
b80ca7238d | ||
|
|
415162e058 | ||
|
|
fdac28040d | ||
|
|
3472f4e1ed | ||
|
|
62a459353a | ||
|
|
9a7354fc55 | ||
|
|
299fa8a4e5 | ||
|
|
161357522e | ||
|
|
e3c8ccdca4 | ||
|
|
edb614f985 | ||
|
|
90c7fe297d | ||
|
|
3e9483e222 | ||
|
|
43433cbc00 | ||
|
|
6780cf3261 | ||
|
|
0bf3061852 | ||
|
|
79cf8ad002 | ||
|
|
215a10186e | ||
|
|
f955064524 | ||
|
|
c6c59ce868 | ||
|
|
cba69a7806 | ||
|
|
bc32f4dc95 | ||
|
|
7db08a6ab3 | ||
|
|
237da99523 | ||
|
|
ff8b0799e0 | ||
|
|
84f117584c | ||
|
|
1164da37ea | ||
|
|
eba85fc924 | ||
|
|
849b1e340b | ||
|
|
6af0463425 | ||
|
|
e7dd91ae59 | ||
|
|
e02926e849 | ||
|
|
31d157b695 | ||
|
|
cb6d61a406 | ||
|
|
00bf6f126d | ||
|
|
c197a004c8 | ||
|
|
29ab989cbe | ||
|
|
b75930cb29 | ||
|
|
ad46c48c64 | ||
|
|
f288ced84b | ||
|
|
a3e956e085 | ||
|
|
8b68a7a07e | ||
|
|
a75aa85712 | ||
|
|
a5b07c0f73 | ||
|
|
ead6f97115 | ||
|
|
f642122726 | ||
|
|
6bd49d80b1 | ||
|
|
bf7f35d06c | ||
|
|
a7b24ec363 | ||
|
|
24cb096ba9 | ||
|
|
9b493c7aab | ||
|
|
e07db0532a | ||
|
|
ad9299e74c | ||
|
|
ff6f7f8612 | ||
|
|
3c84679df1 | ||
|
|
e1b03605fa | ||
|
|
a1a3e87de8 | ||
|
|
17fbb98c13 | ||
|
|
c7343175ca | ||
|
|
d538c7da4f | ||
|
|
f4073a7ada | ||
|
|
94c3c5e4ac | ||
|
|
39b3971bbe | ||
|
|
d02d6944d3 | ||
|
|
3a890301a5 | ||
|
|
68fc064999 | ||
|
|
f6afdad5ba | ||
|
|
d7f9cd510f | ||
|
|
7fe4a92b66 | ||
|
|
79bce36057 | ||
|
|
26fabaa5c1 | ||
|
|
e229df97f0 | ||
|
|
9a32a554e4 | ||
|
|
c82edc8d9e | ||
|
|
f2fdeef5d1 | ||
|
|
7e0d4e0b8f | ||
|
|
ac7ed316f3 | ||
|
|
cb70b47242 | ||
|
|
8803f09974 | ||
|
|
1d35f024f2 | ||
|
|
789d5bf555 | ||
|
|
55d6892963 | ||
|
|
1a07b2d712 | ||
|
|
4761d6406c | ||
|
|
4dfe88a565 | ||
|
|
9c04426950 | ||
|
|
0fd8dd0634 | ||
|
|
e22784cef8 | ||
|
|
ede5a192ea | ||
|
|
cd07f52916 | ||
|
|
8c2a83ecee | ||
|
|
765bec03c0 | ||
|
|
a56594b1df | ||
|
|
9305d806b0 | ||
|
|
a92147939d | ||
|
|
816f675d90 | ||
|
|
54daaf0410 | ||
|
|
5dabd44b53 | ||
|
|
f38b3d6a53 | ||
|
|
a4a2eaa736 | ||
|
|
ae318d2c62 | ||
|
|
ada3b31ad1 | ||
|
|
ffc8fb4026 | ||
|
|
f6c9979ac5 | ||
|
|
b5a853b8d4 | ||
|
|
4ecf406660 | ||
|
|
f6d4a7de3d | ||
|
|
27905d66de | ||
|
|
af48a485e8 | ||
|
|
9600195954 | ||
|
|
55eee8a42e | ||
|
|
61e458b8ea | ||
|
|
0fb9d2f62b | ||
|
|
b0a2eda131 | ||
|
|
389be8f0fc | ||
|
|
6d3b2d3a86 | ||
|
|
d601bd7986 | ||
|
|
7ba2c63d51 | ||
|
|
9ea9212637 | ||
|
|
a64ebd1587 | ||
|
|
ccfb7c18ba | ||
|
|
176cadc2dd | ||
|
|
5569c690a1 | ||
|
|
beba96c21b | ||
|
|
8f077868cc | ||
|
|
f19d883a69 | ||
|
|
b4211a7d50 | ||
|
|
c9d038d588 | ||
|
|
82b597506b | ||
|
|
6b47181572 | ||
|
|
d8a2ec6443 | ||
|
|
efcaa760df | ||
|
|
e19c36b1fb | ||
|
|
a5c5041ec8 | ||
|
|
7b9362756d | ||
|
|
ad9b866afe | ||
|
|
f13bf7f5bc | ||
|
|
d1513a58b3 | ||
|
|
90bb298dbe | ||
|
|
9fa047b399 | ||
|
|
c2932a1577 | ||
|
|
92e94b1e8a | ||
|
|
23dd010a95 | ||
|
|
0c945e21b8 | ||
|
|
6233b192ab | ||
|
|
fecf773caf | ||
|
|
68e05503f6 | ||
|
|
3d8979797c | ||
|
|
261463bbe5 | ||
|
|
5aaa1ef578 | ||
|
|
127d3fd830 | ||
|
|
c79f61b4b5 | ||
|
|
3d8e81560c | ||
|
|
cd3493e52c | ||
|
|
d53eea443e | ||
|
|
2c9726a33d |
12
.gitignore
vendored
12
.gitignore
vendored
@ -2,3 +2,15 @@ node_modules/
|
|||||||
release/
|
release/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
|
|
||||||
|
electron-config.json
|
||||||
|
electron-config.json.bak
|
||||||
|
electron-config.json.tmp
|
||||||
|
electron-config.pre-import-*.json
|
||||||
|
*.log
|
||||||
|
debug.log
|
||||||
|
fileuploader.log
|
||||||
|
account-rotation.log
|
||||||
|
doodstream-debug.log
|
||||||
|
upload-debug.log
|
||||||
|
release-*.log
|
||||||
|
|||||||
@ -1,428 +0,0 @@
|
|||||||
{
|
|
||||||
"hosters": {
|
|
||||||
"doodstream.com": {
|
|
||||||
"enabled": false,
|
|
||||||
"apiKey": "",
|
|
||||||
"username": "",
|
|
||||||
"password": ""
|
|
||||||
},
|
|
||||||
"voe.sx": {
|
|
||||||
"enabled": true,
|
|
||||||
"apiKey": "exZEXqkwEnb8eLR79eUI6WVt3JYGFzAfuPsjuGp2nAn7NATGaYhY86NVK5EX1PzD"
|
|
||||||
},
|
|
||||||
"vidmoly.me": {
|
|
||||||
"enabled": true,
|
|
||||||
"authType": "login",
|
|
||||||
"username": "bariusgariusdi",
|
|
||||||
"password": "Paluffel123!"
|
|
||||||
},
|
|
||||||
"byse.sx": {
|
|
||||||
"enabled": true,
|
|
||||||
"apiKey": "83124r74v61t9dmojm4gz"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"hosterSettings": {
|
|
||||||
"doodstream.com": {
|
|
||||||
"retries": 3,
|
|
||||||
"maxSpeedKbs": 0,
|
|
||||||
"parallelCount": 2,
|
|
||||||
"restartBelowKbs": 0,
|
|
||||||
"timeIntervalSec": 0,
|
|
||||||
"maxSizeMb": 0
|
|
||||||
},
|
|
||||||
"voe.sx": {
|
|
||||||
"retries": 3,
|
|
||||||
"maxSpeedKbs": 0,
|
|
||||||
"parallelCount": 2,
|
|
||||||
"restartBelowKbs": 0,
|
|
||||||
"timeIntervalSec": 0,
|
|
||||||
"maxSizeMb": 0
|
|
||||||
},
|
|
||||||
"vidmoly.me": {
|
|
||||||
"retries": 25,
|
|
||||||
"maxSpeedKbs": 0,
|
|
||||||
"parallelCount": 2,
|
|
||||||
"restartBelowKbs": 0,
|
|
||||||
"timeIntervalSec": 1,
|
|
||||||
"maxSizeMb": 0
|
|
||||||
},
|
|
||||||
"byse.sx": {
|
|
||||||
"retries": 3,
|
|
||||||
"maxSpeedKbs": 0,
|
|
||||||
"parallelCount": 2,
|
|
||||||
"restartBelowKbs": 0,
|
|
||||||
"timeIntervalSec": 0,
|
|
||||||
"maxSizeMb": 0
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"globalSettings": {
|
|
||||||
"alwaysOnTop": false,
|
|
||||||
"shutdownAfterFinish": "nothing",
|
|
||||||
"logFilePath": "",
|
|
||||||
"sessionLog": false,
|
|
||||||
"resumeQueueOnLaunch": true,
|
|
||||||
"parallelUploadCount": 0,
|
|
||||||
"scaleParallelUploads": true,
|
|
||||||
"removeFromQueueOnDone": false,
|
|
||||||
"globalMaxSpeedKbs": 0,
|
|
||||||
"pendingQueue": {
|
|
||||||
"selectedUploadHosters": [
|
|
||||||
"doodstream.com",
|
|
||||||
"voe.sx",
|
|
||||||
"vidmoly.me",
|
|
||||||
"byse.sx"
|
|
||||||
],
|
|
||||||
"selectedFiles": [
|
|
||||||
{
|
|
||||||
"path": "C:\\Users\\ploet\\Downloads\\Einfach mal die Fresse halten!!!.mp4",
|
|
||||||
"name": "Einfach mal die Fresse halten!!!.mp4",
|
|
||||||
"size": 0
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"queueJobs": [
|
|
||||||
{
|
|
||||||
"id": "preview-1773271047205-k8l83r",
|
|
||||||
"file": "C:\\Users\\ploet\\Downloads\\Einfach mal die Fresse halten!!!.mp4",
|
|
||||||
"fileName": "Einfach mal die Fresse halten!!!.mp4",
|
|
||||||
"hoster": "doodstream.com",
|
|
||||||
"status": "preview",
|
|
||||||
"bytesTotal": 0,
|
|
||||||
"error": null,
|
|
||||||
"result": null,
|
|
||||||
"maxAttempts": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "preview-1773271047206-npnpph",
|
|
||||||
"file": "C:\\Users\\ploet\\Downloads\\Einfach mal die Fresse halten!!!.mp4",
|
|
||||||
"fileName": "Einfach mal die Fresse halten!!!.mp4",
|
|
||||||
"hoster": "voe.sx",
|
|
||||||
"status": "preview",
|
|
||||||
"bytesTotal": 0,
|
|
||||||
"error": null,
|
|
||||||
"result": null,
|
|
||||||
"maxAttempts": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "preview-1773271047206-q2skl1",
|
|
||||||
"file": "C:\\Users\\ploet\\Downloads\\Einfach mal die Fresse halten!!!.mp4",
|
|
||||||
"fileName": "Einfach mal die Fresse halten!!!.mp4",
|
|
||||||
"hoster": "vidmoly.me",
|
|
||||||
"status": "preview",
|
|
||||||
"bytesTotal": 0,
|
|
||||||
"error": null,
|
|
||||||
"result": null,
|
|
||||||
"maxAttempts": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "preview-1773271047206-cek27b",
|
|
||||||
"file": "C:\\Users\\ploet\\Downloads\\Einfach mal die Fresse halten!!!.mp4",
|
|
||||||
"fileName": "Einfach mal die Fresse halten!!!.mp4",
|
|
||||||
"hoster": "byse.sx",
|
|
||||||
"status": "preview",
|
|
||||||
"bytesTotal": 0,
|
|
||||||
"error": null,
|
|
||||||
"result": null,
|
|
||||||
"maxAttempts": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"scramble": {
|
|
||||||
"active": false,
|
|
||||||
"prefix": "",
|
|
||||||
"suffix": "",
|
|
||||||
"chars": "both",
|
|
||||||
"length": 0
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"history": [
|
|
||||||
{
|
|
||||||
"id": "batch-1771639560711",
|
|
||||||
"timestamp": "2026-02-21T02:06:04.634Z",
|
|
||||||
"total": 3,
|
|
||||||
"succeeded": 1,
|
|
||||||
"failed": 2,
|
|
||||||
"files": [
|
|
||||||
{
|
|
||||||
"name": "ssstwitter.com_1770829061540.mp4",
|
|
||||||
"size": 7799235,
|
|
||||||
"results": [
|
|
||||||
{
|
|
||||||
"hoster": "doodstream.com",
|
|
||||||
"status": "error",
|
|
||||||
"error": "Invalid URL",
|
|
||||||
"download_url": null,
|
|
||||||
"embed_url": null,
|
|
||||||
"file_code": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hoster": "byse.sx",
|
|
||||||
"status": "error",
|
|
||||||
"error": "Kein Upload-Server erhalten. API-Key pruefen.",
|
|
||||||
"download_url": null,
|
|
||||||
"embed_url": null,
|
|
||||||
"file_code": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hoster": "voe.sx",
|
|
||||||
"status": "done",
|
|
||||||
"download_url": "https://voe.sx/nnxl9k1bsmpj",
|
|
||||||
"embed_url": "https://voe.sx/e/nnxl9k1bsmpj",
|
|
||||||
"file_code": "nnxl9k1bsmpj"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "batch-1771639617785",
|
|
||||||
"timestamp": "2026-02-21T02:07:01.083Z",
|
|
||||||
"total": 4,
|
|
||||||
"succeeded": 1,
|
|
||||||
"failed": 3,
|
|
||||||
"files": [
|
|
||||||
{
|
|
||||||
"name": "ssstwitter.com_1770829061540.mp4",
|
|
||||||
"size": 7799235,
|
|
||||||
"results": [
|
|
||||||
{
|
|
||||||
"hoster": "vidmoly.me",
|
|
||||||
"status": "error",
|
|
||||||
"error": "maxRedirections is not supported, use the redirect interceptor",
|
|
||||||
"download_url": null,
|
|
||||||
"embed_url": null,
|
|
||||||
"file_code": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hoster": "byse.sx",
|
|
||||||
"status": "error",
|
|
||||||
"error": "Kein Upload-Server erhalten. API-Key pruefen.",
|
|
||||||
"download_url": null,
|
|
||||||
"embed_url": null,
|
|
||||||
"file_code": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hoster": "doodstream.com",
|
|
||||||
"status": "error",
|
|
||||||
"error": "Invalid URL",
|
|
||||||
"download_url": null,
|
|
||||||
"embed_url": null,
|
|
||||||
"file_code": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hoster": "voe.sx",
|
|
||||||
"status": "done",
|
|
||||||
"download_url": "https://voe.sx/ujoqyizmrayw",
|
|
||||||
"embed_url": "https://voe.sx/e/ujoqyizmrayw",
|
|
||||||
"file_code": "ujoqyizmrayw"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "batch-1771639907565",
|
|
||||||
"timestamp": "2026-02-21T02:13:33.560Z",
|
|
||||||
"total": 4,
|
|
||||||
"succeeded": 3,
|
|
||||||
"failed": 1,
|
|
||||||
"files": [
|
|
||||||
{
|
|
||||||
"name": "video_1770829348221_0hmfi8.mp4",
|
|
||||||
"size": 107220796,
|
|
||||||
"results": [
|
|
||||||
{
|
|
||||||
"hoster": "vidmoly.me",
|
|
||||||
"status": "error",
|
|
||||||
"error": "Vidmoly Upload-Ergebnis: Kein Download-Link gefunden",
|
|
||||||
"download_url": null,
|
|
||||||
"embed_url": null,
|
|
||||||
"file_code": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hoster": "voe.sx",
|
|
||||||
"status": "done",
|
|
||||||
"download_url": "https://voe.sx/f38bgbhvia4x",
|
|
||||||
"embed_url": "https://voe.sx/e/f38bgbhvia4x",
|
|
||||||
"file_code": "f38bgbhvia4x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hoster": "byse.sx",
|
|
||||||
"status": "done",
|
|
||||||
"download_url": "https://byse.sx/zwbsud9yjxks",
|
|
||||||
"embed_url": "https://byse.sx/e/zwbsud9yjxks",
|
|
||||||
"file_code": "zwbsud9yjxks"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hoster": "doodstream.com",
|
|
||||||
"status": "done",
|
|
||||||
"download_url": "https://dsvplay.com/d/cv1y50vfrf7f",
|
|
||||||
"embed_url": "https://dsvplay.com/e/cv1y50vfrf7f",
|
|
||||||
"file_code": "cv1y50vfrf7f"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "batch-1771640325234",
|
|
||||||
"timestamp": "2026-02-21T02:18:52.471Z",
|
|
||||||
"total": 4,
|
|
||||||
"succeeded": 2,
|
|
||||||
"failed": 2,
|
|
||||||
"files": [
|
|
||||||
{
|
|
||||||
"name": "ssstwitter.com_1770829061540.mp4",
|
|
||||||
"size": 7799235,
|
|
||||||
"results": [
|
|
||||||
{
|
|
||||||
"hoster": "doodstream.com",
|
|
||||||
"status": "error",
|
|
||||||
"error": "Invalid URL",
|
|
||||||
"download_url": null,
|
|
||||||
"embed_url": null,
|
|
||||||
"file_code": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hoster": "voe.sx",
|
|
||||||
"status": "done",
|
|
||||||
"download_url": "https://voe.sx/y4zhied9n4f5",
|
|
||||||
"embed_url": "https://voe.sx/e/y4zhied9n4f5",
|
|
||||||
"file_code": "y4zhied9n4f5"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hoster": "vidmoly.me",
|
|
||||||
"status": "error",
|
|
||||||
"error": "Vidmoly Upload-Ergebnis: Kein Download-Link gefunden",
|
|
||||||
"download_url": null,
|
|
||||||
"embed_url": null,
|
|
||||||
"file_code": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hoster": "byse.sx",
|
|
||||||
"status": "done",
|
|
||||||
"download_url": "https://byse.sx/3caubwbj6jxu",
|
|
||||||
"embed_url": "https://byse.sx/e/3caubwbj6jxu",
|
|
||||||
"file_code": "3caubwbj6jxu"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "batch-1771643316134",
|
|
||||||
"timestamp": "2026-02-21T03:09:10.532Z",
|
|
||||||
"total": 4,
|
|
||||||
"succeeded": 4,
|
|
||||||
"failed": 0,
|
|
||||||
"files": [
|
|
||||||
{
|
|
||||||
"name": "ssstwitter.com_1770829061540.mp4",
|
|
||||||
"size": 7799235,
|
|
||||||
"results": [
|
|
||||||
{
|
|
||||||
"hoster": "voe.sx",
|
|
||||||
"status": "done",
|
|
||||||
"download_url": "https://voe.sx/juoamb17cdea",
|
|
||||||
"embed_url": "https://voe.sx/e/juoamb17cdea",
|
|
||||||
"file_code": "juoamb17cdea"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hoster": "byse.sx",
|
|
||||||
"status": "done",
|
|
||||||
"download_url": "https://byse.sx/mu8p6ikpsabf",
|
|
||||||
"embed_url": "https://byse.sx/e/mu8p6ikpsabf",
|
|
||||||
"file_code": "mu8p6ikpsabf"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hoster": "vidmoly.me",
|
|
||||||
"status": "done",
|
|
||||||
"download_url": "https://vidmoly.me/w/7460ei78oj22",
|
|
||||||
"embed_url": "https://vidmoly.me/embed-7460ei78oj22.html",
|
|
||||||
"file_code": "7460ei78oj22"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hoster": "doodstream.com",
|
|
||||||
"status": "done",
|
|
||||||
"download_url": "https://dsvplay.com/d/l4rm1kbpkgt0",
|
|
||||||
"embed_url": "https://dsvplay.com/e/l4rm1kbpkgt0",
|
|
||||||
"file_code": "l4rm1kbpkgt0"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "batch-1773173725103",
|
|
||||||
"timestamp": "2026-03-10T20:15:25.339Z",
|
|
||||||
"total": 1,
|
|
||||||
"succeeded": 1,
|
|
||||||
"failed": 0,
|
|
||||||
"files": [
|
|
||||||
{
|
|
||||||
"name": "test-e2e-upload.txt",
|
|
||||||
"size": 22,
|
|
||||||
"results": [
|
|
||||||
{
|
|
||||||
"hoster": "voe.sx",
|
|
||||||
"status": "done",
|
|
||||||
"download_url": null,
|
|
||||||
"embed_url": null,
|
|
||||||
"file_code": null
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "batch-1773176124038",
|
|
||||||
"timestamp": "2026-03-10T20:55:24.931Z",
|
|
||||||
"total": 1,
|
|
||||||
"succeeded": 1,
|
|
||||||
"failed": 0,
|
|
||||||
"files": [
|
|
||||||
{
|
|
||||||
"name": "Einfach mal die Fresse halten!!!.mp4",
|
|
||||||
"size": 172248,
|
|
||||||
"results": [
|
|
||||||
{
|
|
||||||
"hoster": "voe.sx",
|
|
||||||
"status": "done",
|
|
||||||
"download_url": "https://voe.sx/nlvswooic50v",
|
|
||||||
"embed_url": "https://voe.sx/e/nlvswooic50v",
|
|
||||||
"file_code": "nlvswooic50v"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "batch-1773176209320",
|
|
||||||
"timestamp": "2026-03-10T20:56:59.349Z",
|
|
||||||
"total": 2,
|
|
||||||
"succeeded": 2,
|
|
||||||
"failed": 0,
|
|
||||||
"files": [
|
|
||||||
{
|
|
||||||
"name": "export_1771588307185.mov",
|
|
||||||
"size": 7330963,
|
|
||||||
"results": [
|
|
||||||
{
|
|
||||||
"hoster": "voe.sx",
|
|
||||||
"status": "done",
|
|
||||||
"download_url": "https://voe.sx/qh1jriyz5up7",
|
|
||||||
"embed_url": "https://voe.sx/e/qh1jriyz5up7",
|
|
||||||
"file_code": "qh1jriyz5up7"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hoster": "doodstream.com",
|
|
||||||
"status": "done",
|
|
||||||
"download_url": "https://dsvplay.com/d/q5tib39woqq4",
|
|
||||||
"embed_url": "https://dsvplay.com/e/q5tib39woqq4",
|
|
||||||
"file_code": "q5tib39woqq4"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
85
eslint.config.mjs
Normal file
85
eslint.config.mjs
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
import security from 'eslint-plugin-security';
|
||||||
|
|
||||||
|
export default [
|
||||||
|
{
|
||||||
|
files: ['**/*.js'],
|
||||||
|
ignores: ['node_modules/**', 'release/**', 'tests/**'],
|
||||||
|
plugins: { security },
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2022,
|
||||||
|
sourceType: 'commonjs',
|
||||||
|
globals: {
|
||||||
|
require: 'readonly',
|
||||||
|
module: 'readonly',
|
||||||
|
exports: 'readonly',
|
||||||
|
__dirname: 'readonly',
|
||||||
|
__filename: 'readonly',
|
||||||
|
process: 'readonly',
|
||||||
|
console: 'readonly',
|
||||||
|
setTimeout: 'readonly',
|
||||||
|
clearTimeout: 'readonly',
|
||||||
|
setInterval: 'readonly',
|
||||||
|
clearInterval: 'readonly',
|
||||||
|
setImmediate: 'readonly',
|
||||||
|
Buffer: 'readonly',
|
||||||
|
URL: 'readonly',
|
||||||
|
fetch: 'readonly',
|
||||||
|
AbortController: 'readonly',
|
||||||
|
AbortSignal: 'readonly',
|
||||||
|
navigator: 'readonly',
|
||||||
|
document: 'readonly',
|
||||||
|
window: 'readonly',
|
||||||
|
localStorage: 'readonly',
|
||||||
|
HTMLElement: 'readonly',
|
||||||
|
alert: 'readonly',
|
||||||
|
confirm: 'readonly',
|
||||||
|
requestAnimationFrame: 'readonly',
|
||||||
|
queueMicrotask: 'readonly',
|
||||||
|
Intl: 'readonly',
|
||||||
|
crypto: 'readonly',
|
||||||
|
URLSearchParams: 'readonly',
|
||||||
|
EventSource: 'readonly',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
// Security rules
|
||||||
|
// detect-object-injection disabled: 78 false positives from config lookups like obj[hosterName]
|
||||||
|
'security/detect-object-injection': 'off',
|
||||||
|
'security/detect-non-literal-regexp': 'warn',
|
||||||
|
'security/detect-unsafe-regex': 'warn',
|
||||||
|
'security/detect-buffer-noassert': 'warn',
|
||||||
|
'security/detect-eval-with-expression': 'error',
|
||||||
|
'security/detect-no-csrf-before-method-override': 'warn',
|
||||||
|
'security/detect-possible-timing-attacks': 'warn',
|
||||||
|
'security/detect-pseudoRandomBytes': 'warn',
|
||||||
|
// Code quality
|
||||||
|
'no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
|
||||||
|
'no-undef': 'error',
|
||||||
|
'no-constant-condition': 'warn',
|
||||||
|
'no-debugger': 'error',
|
||||||
|
'no-duplicate-case': 'error',
|
||||||
|
'no-empty': ['warn', { allowEmptyCatch: true }],
|
||||||
|
'no-ex-assign': 'error',
|
||||||
|
'no-extra-boolean-cast': 'warn',
|
||||||
|
'no-func-assign': 'error',
|
||||||
|
'no-inner-declarations': 'error',
|
||||||
|
'no-irregular-whitespace': 'error',
|
||||||
|
'no-unreachable': 'error',
|
||||||
|
'use-isnan': 'error',
|
||||||
|
'valid-typeof': 'error',
|
||||||
|
'eqeqeq': ['warn', 'always'],
|
||||||
|
'no-caller': 'error',
|
||||||
|
'no-eval': 'error',
|
||||||
|
'no-implied-eval': 'error',
|
||||||
|
'no-new-func': 'error',
|
||||||
|
'no-throw-literal': 'warn',
|
||||||
|
'no-self-assign': 'error',
|
||||||
|
'no-self-compare': 'error',
|
||||||
|
'no-loss-of-precision': 'error',
|
||||||
|
'no-dupe-keys': 'error',
|
||||||
|
'no-unsafe-finally': 'error',
|
||||||
|
'no-unmodified-loop-condition': 'warn',
|
||||||
|
'no-template-curly-in-string': 'warn',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
36
lib/account-auth.js
Normal file
36
lib/account-auth.js
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
// Decides which credential an upload task should use for a given hoster.
|
||||||
|
// Extracted from main.js buildTaskFromAccount so the routing can be unit-tested
|
||||||
|
// without Electron.
|
||||||
|
//
|
||||||
|
// DOODSTREAM SPECIAL CASE: prefer the official doodapi.co API key whenever the
|
||||||
|
// account has one. The web-login path (username/password) drives doodstream's
|
||||||
|
// browser upload flow, which hands the filecode back inside an XFileSharing
|
||||||
|
// HTML form. On long/large uploads that form comes back empty (no fn) because a
|
||||||
|
// per-page-load sess_id token ages out over the multi-minute upload and/or the
|
||||||
|
// server-side file-registration callback times out — the upload then "succeeds"
|
||||||
|
// (bytes sent, HTTP 200) but yields no link. The JSON API returns the filecode
|
||||||
|
// directly in result[0].filecode and authenticates with a persistent api_key,
|
||||||
|
// so it has no empty-form failure mode for result retrieval. The API path was
|
||||||
|
// doodstream's ORIGINAL upload path (present since the initial commit); web
|
||||||
|
// login was added later only as an alternative for keyless accounts — so
|
||||||
|
// preferring the key here restores the intended primary path, it doesn't fight
|
||||||
|
// a deliberate choice. Keyless accounts keep using web login unchanged.
|
||||||
|
function selectUploadAuth(hoster, account) {
|
||||||
|
if (!account || typeof account !== 'object') return {};
|
||||||
|
|
||||||
|
if (hoster === 'doodstream.com' && account.apiKey) {
|
||||||
|
return { apiKey: account.apiKey };
|
||||||
|
}
|
||||||
|
if (account.authType === 'api' && account.apiKey) {
|
||||||
|
return { apiKey: account.apiKey };
|
||||||
|
}
|
||||||
|
if (account.username && account.password) {
|
||||||
|
return { username: account.username, password: account.password };
|
||||||
|
}
|
||||||
|
if (account.apiKey) {
|
||||||
|
return { apiKey: account.apiKey };
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { selectUploadAuth };
|
||||||
@ -9,19 +9,24 @@ const ITERATIONS = 100_000;
|
|||||||
const DIGEST = 'sha512';
|
const DIGEST = 'sha512';
|
||||||
const ALGO = 'aes-256-gcm';
|
const ALGO = 'aes-256-gcm';
|
||||||
|
|
||||||
function deriveKey(password, salt) {
|
// Fixed app-internal passphrase — backups are opaque without the app, which is
|
||||||
return crypto.pbkdf2Sync(password, salt, ITERATIONS, KEY_LEN, DIGEST);
|
// enough protection for API keys stored locally. We keep the AES-GCM envelope
|
||||||
|
// (with random salt/iv) so each export is still distinct and authenticated.
|
||||||
|
const APP_PASSPHRASE = 'multi-hoster-upload::backup::v1';
|
||||||
|
|
||||||
|
function deriveKey(passphrase, salt) {
|
||||||
|
return crypto.pbkdf2Sync(passphrase, salt, ITERATIONS, KEY_LEN, DIGEST);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Encrypt a config object with a password.
|
* Encrypt a config object.
|
||||||
* Returns a Buffer: MHU1 | salt(16) | iv(12) | tag(16) | ciphertext
|
* Returns a Buffer: MHU1 | salt(16) | iv(12) | tag(16) | ciphertext
|
||||||
*/
|
*/
|
||||||
function encrypt(config, password) {
|
function encrypt(config) {
|
||||||
const plaintext = Buffer.from(JSON.stringify(config), 'utf-8');
|
const plaintext = Buffer.from(JSON.stringify(config), 'utf-8');
|
||||||
const salt = crypto.randomBytes(SALT_LEN);
|
const salt = crypto.randomBytes(SALT_LEN);
|
||||||
const iv = crypto.randomBytes(IV_LEN);
|
const iv = crypto.randomBytes(IV_LEN);
|
||||||
const key = deriveKey(password, salt);
|
const key = deriveKey(APP_PASSPHRASE, salt);
|
||||||
|
|
||||||
const cipher = crypto.createCipheriv(ALGO, key, iv);
|
const cipher = crypto.createCipheriv(ALGO, key, iv);
|
||||||
const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
||||||
@ -33,10 +38,13 @@ function encrypt(config, password) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decrypt a .mhu buffer with a password.
|
* Decrypt a .mhu buffer.
|
||||||
* Returns the config object or throws on invalid password/data.
|
* Tries the app's built-in key first; if that fails and a user password is
|
||||||
|
* provided, falls back to legacy password-based decryption. Throws a special
|
||||||
|
* error with `needsPassword = true` if the app key fails and no password was
|
||||||
|
* given, so callers can prompt the user for the legacy password.
|
||||||
*/
|
*/
|
||||||
function decrypt(buffer, password) {
|
function decrypt(buffer, userPassword) {
|
||||||
if (buffer.length < MAGIC.length + SALT_LEN + IV_LEN + TAG_LEN + 1) {
|
if (buffer.length < MAGIC.length + SALT_LEN + IV_LEN + TAG_LEN + 1) {
|
||||||
throw new Error('Ungültiges Backup-Format');
|
throw new Error('Ungültiges Backup-Format');
|
||||||
}
|
}
|
||||||
@ -52,25 +60,36 @@ function decrypt(buffer, password) {
|
|||||||
const tag = buffer.subarray(offset, offset += TAG_LEN);
|
const tag = buffer.subarray(offset, offset += TAG_LEN);
|
||||||
const ciphertext = buffer.subarray(offset);
|
const ciphertext = buffer.subarray(offset);
|
||||||
|
|
||||||
const key = deriveKey(password, salt);
|
const tryPassphrase = (passphrase) => {
|
||||||
const decipher = crypto.createDecipheriv(ALGO, key, iv);
|
const key = deriveKey(passphrase, salt);
|
||||||
decipher.setAuthTag(tag);
|
const decipher = crypto.createDecipheriv(ALGO, key, iv);
|
||||||
|
decipher.setAuthTag(tag);
|
||||||
|
try {
|
||||||
|
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
||||||
|
const result = JSON.parse(decrypted.toString('utf-8'));
|
||||||
|
decrypted.fill(0);
|
||||||
|
key.fill(0);
|
||||||
|
return result;
|
||||||
|
} catch {
|
||||||
|
key.fill(0);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let decrypted;
|
// 1) Try the app-internal key (new format, no password required).
|
||||||
try {
|
const fromApp = tryPassphrase(APP_PASSPHRASE);
|
||||||
decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
if (fromApp) return fromApp;
|
||||||
} catch {
|
|
||||||
|
// 2) Legacy format: user had set their own password.
|
||||||
|
if (userPassword) {
|
||||||
|
const fromUser = tryPassphrase(userPassword);
|
||||||
|
if (fromUser) return fromUser;
|
||||||
throw new Error('Falsches Passwort oder beschädigte Datei');
|
throw new Error('Falsches Passwort oder beschädigte Datei');
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const err = new Error('Dieses Backup wurde mit einem Passwort verschlüsselt');
|
||||||
return JSON.parse(decrypted.toString('utf-8'));
|
err.needsPassword = true;
|
||||||
} catch {
|
throw err;
|
||||||
throw new Error('Entschlüsselte Daten sind kein gültiges JSON');
|
|
||||||
} finally {
|
|
||||||
decrypted.fill(0);
|
|
||||||
key.fill(0);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { encrypt, decrypt };
|
module.exports = { encrypt, decrypt };
|
||||||
|
|||||||
239
lib/clouddrop-upload.js
Normal file
239
lib/clouddrop-upload.js
Normal file
@ -0,0 +1,239 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const { request, Agent } = require('undici');
|
||||||
|
|
||||||
|
const BASE_URL = 'https://clouddrop.cc';
|
||||||
|
const API_BASE = `${BASE_URL}/api/cloud`;
|
||||||
|
const CHUNK_UPLOAD_BASE = 'https://upload.clouddrop.cc/api/cloud';
|
||||||
|
const USER_AGENT = 'multi-hoster-uploader/1.0';
|
||||||
|
|
||||||
|
const SIMPLE_UPLOAD_LIMIT = 16 * 1024 * 1024; // 16 MB
|
||||||
|
const CHUNK_SIZE = 16 * 1024 * 1024; // 16 MB — server's fixed chunk size
|
||||||
|
const INIT_TIMEOUT = 60_000;
|
||||||
|
const CHUNK_TIMEOUT = 30 * 60_000; // 30 min per chunk
|
||||||
|
const COMPLETE_TIMEOUT = 5 * 60_000;
|
||||||
|
const SIMPLE_UPLOAD_TIMEOUT = 30 * 60_000;
|
||||||
|
|
||||||
|
// Cap concurrent TCP connections to clouddrop.cc at 50 to stay well under
|
||||||
|
// the server's per-IP limit of 100 concurrent connections (cd_conn).
|
||||||
|
// Shared across all ClouddropUploader instances via module-level agent.
|
||||||
|
const clouddropAgent = new Agent({
|
||||||
|
connections: 50,
|
||||||
|
pipelining: 1,
|
||||||
|
keepAliveTimeout: 30_000,
|
||||||
|
keepAliveMaxTimeout: 60_000
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clouddrop.cc uploader — uses API Key (Bearer) authentication.
|
||||||
|
* Files > 16 MB use the chunked protocol, smaller files use simple upload.
|
||||||
|
* After upload, a share link is created and returned as download_url.
|
||||||
|
*/
|
||||||
|
class ClouddropUploader {
|
||||||
|
constructor(apiKey) {
|
||||||
|
this.apiKey = String(apiKey || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
_headers(extra) {
|
||||||
|
return {
|
||||||
|
'Authorization': `Bearer ${this.apiKey}`,
|
||||||
|
'User-Agent': USER_AGENT,
|
||||||
|
'Accept': 'application/json',
|
||||||
|
...(extra || {})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async _parseJsonResponse(res) {
|
||||||
|
const text = await res.body.text();
|
||||||
|
let payload = null;
|
||||||
|
try { payload = text ? JSON.parse(text) : {}; } catch {
|
||||||
|
throw new Error(`Clouddrop: API-Antwort war kein JSON (HTTP ${res.statusCode}): ${text.slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||||||
|
const msg = (payload && (payload.error || payload.message))
|
||||||
|
|| `HTTP ${res.statusCode}`;
|
||||||
|
const err = new Error(`Clouddrop: ${msg}`);
|
||||||
|
err.status = res.statusCode;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload a file. Returns { download_url, embed_url, file_code }.
|
||||||
|
*/
|
||||||
|
async upload(filePath, progressCb, signal, throttle) {
|
||||||
|
if (!this.apiKey) throw new Error('Clouddrop: API-Key fehlt');
|
||||||
|
const fileName = path.basename(filePath);
|
||||||
|
let fileSize = 0;
|
||||||
|
try { fileSize = fs.statSync(filePath).size; }
|
||||||
|
catch { throw new Error(`Clouddrop: Datei nicht lesbar: ${fileName}`); }
|
||||||
|
if (fileSize <= 0) throw new Error('Clouddrop: Datei ist leer');
|
||||||
|
|
||||||
|
let fileId;
|
||||||
|
if (fileSize <= SIMPLE_UPLOAD_LIMIT) {
|
||||||
|
fileId = await this._uploadSimple(filePath, fileName, fileSize, progressCb, signal, throttle);
|
||||||
|
} else {
|
||||||
|
fileId = await this._uploadChunked(filePath, fileName, fileSize, progressCb, signal, throttle);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
download_url: `${BASE_URL}/share/${fileId}`,
|
||||||
|
embed_url: null,
|
||||||
|
file_code: fileId
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple upload for files < 16 MB — single multipart POST.
|
||||||
|
*/
|
||||||
|
async _uploadSimple(filePath, fileName, fileSize, progressCb, signal, throttle) {
|
||||||
|
const boundary = '----FormBoundary' + crypto.randomBytes(16).toString('hex');
|
||||||
|
const safeFileName = fileName.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||||
|
|
||||||
|
const preamble =
|
||||||
|
`--${boundary}\r\n` +
|
||||||
|
`Content-Disposition: form-data; name="file"; filename="${safeFileName}"\r\n` +
|
||||||
|
`Content-Type: application/octet-stream\r\n\r\n`;
|
||||||
|
const epilogue = `\r\n--${boundary}--\r\n`;
|
||||||
|
|
||||||
|
const preambleBuf = Buffer.from(preamble, 'utf-8');
|
||||||
|
const epilogueBuf = Buffer.from(epilogue, 'utf-8');
|
||||||
|
const totalSize = preambleBuf.length + fileSize + epilogueBuf.length;
|
||||||
|
|
||||||
|
let bytesRead = 0;
|
||||||
|
async function* generate() {
|
||||||
|
yield preambleBuf;
|
||||||
|
const fileStream = fs.createReadStream(filePath, { highWaterMark: 256 * 1024 });
|
||||||
|
for await (const chunk of fileStream) {
|
||||||
|
if (signal && signal.aborted) throw new Error('Aborted');
|
||||||
|
if (throttle) await throttle.consume(chunk.length, signal);
|
||||||
|
bytesRead += chunk.length;
|
||||||
|
yield chunk;
|
||||||
|
if (progressCb) progressCb(bytesRead, fileSize);
|
||||||
|
}
|
||||||
|
yield epilogueBuf;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await request(`${API_BASE}/upload?mode=rename`, {
|
||||||
|
method: 'POST',
|
||||||
|
dispatcher: clouddropAgent,
|
||||||
|
body: generate(),
|
||||||
|
signal,
|
||||||
|
headers: this._headers({
|
||||||
|
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
||||||
|
'Content-Length': String(totalSize)
|
||||||
|
}),
|
||||||
|
headersTimeout: SIMPLE_UPLOAD_TIMEOUT,
|
||||||
|
bodyTimeout: SIMPLE_UPLOAD_TIMEOUT
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = await this._parseJsonResponse(res);
|
||||||
|
if (!payload.fileId) throw new Error(`Clouddrop: Keine fileId in Upload-Antwort`);
|
||||||
|
return payload.fileId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chunked upload for files > 16 MB.
|
||||||
|
* Flow: POST /upload/init → PUT /upload/:sessionId/chunk/:n (0-based) → POST /upload/:sessionId/complete
|
||||||
|
*/
|
||||||
|
async _uploadChunked(filePath, fileName, fileSize, progressCb, signal, throttle) {
|
||||||
|
// 1. Init session
|
||||||
|
const initRes = await request(`${API_BASE}/upload/init`, {
|
||||||
|
method: 'POST',
|
||||||
|
dispatcher: clouddropAgent,
|
||||||
|
signal,
|
||||||
|
headers: this._headers({ 'Content-Type': 'application/json' }),
|
||||||
|
body: JSON.stringify({ filename: fileName, size: fileSize, parentId: null }),
|
||||||
|
headersTimeout: INIT_TIMEOUT,
|
||||||
|
bodyTimeout: INIT_TIMEOUT
|
||||||
|
});
|
||||||
|
const initPayload = await this._parseJsonResponse(initRes);
|
||||||
|
const sessionId = initPayload.sessionId;
|
||||||
|
const chunkSize = initPayload.chunkSize || CHUNK_SIZE;
|
||||||
|
const totalChunks = initPayload.totalChunks || Math.ceil(fileSize / chunkSize);
|
||||||
|
if (!sessionId) throw new Error('Clouddrop: Keine sessionId von /upload/init');
|
||||||
|
|
||||||
|
// 2. Read file and PUT chunks sequentially.
|
||||||
|
// Reuse a single buffer for all chunks (only the last chunk may be smaller,
|
||||||
|
// in which case we slice a view). Avoids 64× 16 MB allocations on a 1 GB
|
||||||
|
// file — real GC pressure during busy uploads.
|
||||||
|
const fd = fs.openSync(filePath, 'r');
|
||||||
|
let bytesSent = 0;
|
||||||
|
const reusableBuf = Buffer.allocUnsafe(chunkSize);
|
||||||
|
try {
|
||||||
|
for (let i = 0; i < totalChunks; i++) {
|
||||||
|
if (signal && signal.aborted) throw new Error('Aborted');
|
||||||
|
|
||||||
|
const offset = i * chunkSize;
|
||||||
|
const remaining = fileSize - offset;
|
||||||
|
const thisChunkSize = Math.min(chunkSize, remaining);
|
||||||
|
fs.readSync(fd, reusableBuf, 0, thisChunkSize, offset);
|
||||||
|
const body = thisChunkSize === chunkSize
|
||||||
|
? reusableBuf
|
||||||
|
: reusableBuf.subarray(0, thisChunkSize);
|
||||||
|
|
||||||
|
if (throttle) await throttle.consume(thisChunkSize, signal);
|
||||||
|
|
||||||
|
const chunkRes = await request(`${CHUNK_UPLOAD_BASE}/upload/${sessionId}/chunk/${i}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
dispatcher: clouddropAgent,
|
||||||
|
signal,
|
||||||
|
body,
|
||||||
|
headers: this._headers({
|
||||||
|
'Content-Type': 'application/octet-stream',
|
||||||
|
'Content-Length': String(thisChunkSize)
|
||||||
|
}),
|
||||||
|
headersTimeout: CHUNK_TIMEOUT,
|
||||||
|
bodyTimeout: CHUNK_TIMEOUT
|
||||||
|
});
|
||||||
|
await this._parseJsonResponse(chunkRes);
|
||||||
|
|
||||||
|
bytesSent += thisChunkSize;
|
||||||
|
if (progressCb) progressCb(bytesSent, fileSize);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
try { fs.closeSync(fd); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Complete session — all bytes are already on the server at this point.
|
||||||
|
// We MUST NOT throw here, otherwise the upload-manager would retry the entire
|
||||||
|
// multi-GB upload. Any failure (timeout, non-JSON, missing fileId, server still
|
||||||
|
// post-processing) is swallowed and we fall back to sessionId as file_code.
|
||||||
|
try {
|
||||||
|
const completeRes = await request(`${API_BASE}/upload/${sessionId}/complete`, {
|
||||||
|
method: 'POST',
|
||||||
|
dispatcher: clouddropAgent,
|
||||||
|
signal,
|
||||||
|
headers: this._headers({ 'Content-Type': 'application/json' }),
|
||||||
|
body: '{}',
|
||||||
|
headersTimeout: COMPLETE_TIMEOUT,
|
||||||
|
bodyTimeout: COMPLETE_TIMEOUT
|
||||||
|
});
|
||||||
|
const completePayload = await this._parseJsonResponse(completeRes).catch(() => ({}));
|
||||||
|
return completePayload.fileId || completePayload.id || sessionId;
|
||||||
|
} catch {
|
||||||
|
return sessionId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight auth check — GET /api/cloud/files (list root, small response).
|
||||||
|
*/
|
||||||
|
async checkAuth(signal) {
|
||||||
|
if (!this.apiKey) throw new Error('Clouddrop: API-Key fehlt');
|
||||||
|
const res = await request(`${API_BASE}/files/?limit=1`, {
|
||||||
|
method: 'GET',
|
||||||
|
dispatcher: clouddropAgent,
|
||||||
|
signal,
|
||||||
|
headers: this._headers(),
|
||||||
|
headersTimeout: 15_000,
|
||||||
|
bodyTimeout: 15_000
|
||||||
|
});
|
||||||
|
await this._parseJsonResponse(res);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = ClouddropUploader;
|
||||||
73
lib/coalesced-set.js
Normal file
73
lib/coalesced-set.js
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
// Microtask-coalesced set. Adds are O(1); the apply callback runs once per
|
||||||
|
// scheduler tick with every id collected since the last flush.
|
||||||
|
//
|
||||||
|
// Used by the renderer to merge a burst of done-jobs (e.g. 500 jobs all
|
||||||
|
// finishing within milliseconds) into a single queueJobs.filter() pass —
|
||||||
|
// without this each event was its own O(N) sweep, so 500 finishes were
|
||||||
|
// O(N²) and visibly froze the UI on completion.
|
||||||
|
//
|
||||||
|
// Loaded both as a CommonJS module (Node tests) and as a browser global
|
||||||
|
// (renderer/app.js via index.html script tag).
|
||||||
|
|
||||||
|
(function (root) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a coalesced set.
|
||||||
|
* @param {{ apply: (Set) => void, scheduler?: (cb: () => void) => void }} opts
|
||||||
|
* apply: called once per scheduler tick with the accumulated ids.
|
||||||
|
* scheduler: defaults to queueMicrotask. Tests can pass a synchronous
|
||||||
|
* stand-in to avoid async waits.
|
||||||
|
*/
|
||||||
|
function makeCoalescedSet(opts) {
|
||||||
|
if (!opts || typeof opts.apply !== 'function') {
|
||||||
|
throw new TypeError('makeCoalescedSet: { apply: fn } required');
|
||||||
|
}
|
||||||
|
const apply = opts.apply;
|
||||||
|
const scheduler = typeof opts.scheduler === 'function'
|
||||||
|
? opts.scheduler
|
||||||
|
: (typeof queueMicrotask === 'function' ? queueMicrotask : (cb) => Promise.resolve().then(cb));
|
||||||
|
let pending = new Set();
|
||||||
|
let scheduled = false;
|
||||||
|
|
||||||
|
function flush() {
|
||||||
|
scheduled = false;
|
||||||
|
if (pending.size === 0) return;
|
||||||
|
const drop = pending;
|
||||||
|
pending = new Set();
|
||||||
|
try { apply(drop); } catch (e) {
|
||||||
|
// Don't let a failing apply lock out the next batch — surface it
|
||||||
|
// but keep the coalescer usable.
|
||||||
|
if (typeof console !== 'undefined' && console.error) console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
add(id) {
|
||||||
|
pending.add(id);
|
||||||
|
if (!scheduled) {
|
||||||
|
scheduled = true;
|
||||||
|
scheduler(flush);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Synchronously consume any pending ids. Used by beforeunload paths
|
||||||
|
* where we can't wait for the next microtask before persisting.
|
||||||
|
*/
|
||||||
|
drainSync() {
|
||||||
|
if (pending.size === 0) return;
|
||||||
|
const drop = pending;
|
||||||
|
pending = new Set();
|
||||||
|
scheduled = false;
|
||||||
|
apply(drop);
|
||||||
|
},
|
||||||
|
/** Introspection for tests + diagnostics. */
|
||||||
|
pendingSize() { return pending.size; },
|
||||||
|
isScheduled() { return scheduled; }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const api = { makeCoalescedSet };
|
||||||
|
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||||
|
else if (root) root.CoalescedSet = api;
|
||||||
|
})(typeof window !== 'undefined' ? window : this);
|
||||||
@ -1,5 +1,7 @@
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
const secretStore = require('./secret-store');
|
||||||
|
const { normalizeLogMode } = require('./log-mode');
|
||||||
|
|
||||||
const HOSTER_SETTINGS_DEFAULTS = {
|
const HOSTER_SETTINGS_DEFAULTS = {
|
||||||
retries: 3,
|
retries: 3,
|
||||||
@ -7,27 +9,60 @@ const HOSTER_SETTINGS_DEFAULTS = {
|
|||||||
parallelCount: 2, // 1-100
|
parallelCount: 2, // 1-100
|
||||||
restartBelowKbs: 0, // 0 = off
|
restartBelowKbs: 0, // 0 = off
|
||||||
timeIntervalSec: 0, // delay between jobs
|
timeIntervalSec: 0, // delay between jobs
|
||||||
maxSizeMb: 0 // 0 = unlimited
|
maxSizeMb: 0, // 0 = unlimited
|
||||||
|
logToFile: true // write this hoster's successful links to fileuploader.log
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Template for each hoster type (used as defaults for new accounts)
|
||||||
|
const HOSTER_ACCOUNT_TEMPLATES = {
|
||||||
|
'doodstream.com': { enabled: true, authType: 'login', username: '', password: '' },
|
||||||
|
'doodstream.com:api': { enabled: true, authType: 'api', apiKey: '' },
|
||||||
|
'voe.sx': { enabled: true, authType: 'login', username: '', password: '' },
|
||||||
|
'voe.sx:api': { enabled: true, authType: 'api', apiKey: '' },
|
||||||
|
'vidmoly.me': { enabled: true, authType: 'login', username: '', password: '' },
|
||||||
|
'byse.sx': { enabled: true, authType: 'api', apiKey: '' },
|
||||||
|
'clouddrop.cc': { enabled: true, authType: 'api', apiKey: '' }
|
||||||
|
};
|
||||||
|
|
||||||
|
// All known hoster names (used for iteration)
|
||||||
|
const HOSTER_NAMES = ['doodstream.com', 'voe.sx', 'vidmoly.me', 'byse.sx', 'clouddrop.cc'];
|
||||||
|
|
||||||
|
// Dropdown options for "Add Account" modal: value -> label
|
||||||
|
const HOSTER_ADD_OPTIONS = [
|
||||||
|
{ value: 'doodstream.com', label: 'Doodstream (Web Login)', hoster: 'doodstream.com', authType: 'login' },
|
||||||
|
{ value: 'doodstream.com:api', label: 'Doodstream (API)', hoster: 'doodstream.com', authType: 'api' },
|
||||||
|
{ value: 'voe.sx', label: 'Voe (Web Login)', hoster: 'voe.sx', authType: 'login' },
|
||||||
|
{ value: 'voe.sx:api', label: 'Voe (API)', hoster: 'voe.sx', authType: 'api' },
|
||||||
|
{ value: 'vidmoly.me', label: 'Vidmoly (Web Login)', hoster: 'vidmoly.me', authType: 'login' },
|
||||||
|
{ value: 'byse.sx', label: 'Byse (API)', hoster: 'byse.sx', authType: 'api' },
|
||||||
|
{ value: 'clouddrop.cc', label: 'Clouddrop (API)', hoster: 'clouddrop.cc', authType: 'api' }
|
||||||
|
];
|
||||||
|
|
||||||
const DEFAULTS = {
|
const DEFAULTS = {
|
||||||
hosters: {
|
hosters: {
|
||||||
'doodstream.com': { enabled: true, apiKey: '', username: '', password: '' },
|
'doodstream.com': [],
|
||||||
'voe.sx': { enabled: true, apiKey: '' },
|
'voe.sx': [],
|
||||||
'vidmoly.me': { enabled: true, authType: 'login', username: '', password: '' },
|
'vidmoly.me': [],
|
||||||
'byse.sx': { enabled: true, apiKey: '' }
|
'byse.sx': [],
|
||||||
|
'clouddrop.cc': []
|
||||||
},
|
},
|
||||||
hosterSettings: {
|
hosterSettings: {
|
||||||
'doodstream.com': { ...HOSTER_SETTINGS_DEFAULTS },
|
'doodstream.com': { ...HOSTER_SETTINGS_DEFAULTS },
|
||||||
'voe.sx': { ...HOSTER_SETTINGS_DEFAULTS },
|
'voe.sx': { ...HOSTER_SETTINGS_DEFAULTS },
|
||||||
'vidmoly.me': { ...HOSTER_SETTINGS_DEFAULTS },
|
'vidmoly.me': { ...HOSTER_SETTINGS_DEFAULTS },
|
||||||
'byse.sx': { ...HOSTER_SETTINGS_DEFAULTS }
|
'byse.sx': { ...HOSTER_SETTINGS_DEFAULTS },
|
||||||
|
'clouddrop.cc': { ...HOSTER_SETTINGS_DEFAULTS }
|
||||||
},
|
},
|
||||||
globalSettings: {
|
globalSettings: {
|
||||||
alwaysOnTop: false,
|
alwaysOnTop: false,
|
||||||
shutdownAfterFinish: 'nothing', // nothing | sleep | shutdown | restart
|
shutdownAfterFinish: 'nothing', // nothing | sleep | shutdown | restart
|
||||||
logFilePath: '',
|
logFilePath: '',
|
||||||
sessionLog: false,
|
sessionLog: false, // legacy boolean (kept for back-compat reads); normalized into logMode on load
|
||||||
|
logVerbose: false, // when true, [DEBUG] level entries are written to debug.log
|
||||||
|
// NOTE: logMode is intentionally NOT in DEFAULTS. If it were, the deep-merge
|
||||||
|
// would seed logMode='single' for every load, which would beat (and silently
|
||||||
|
// erase) the legacy sessionLog:true → "daily" migration. normalizeLogMode in
|
||||||
|
// load() sets logMode after the merge, looking at the saved-only data.
|
||||||
resumeQueueOnLaunch: true,
|
resumeQueueOnLaunch: true,
|
||||||
parallelUploadCount: 0, // 0 = use per-hoster limits only
|
parallelUploadCount: 0, // 0 = use per-hoster limits only
|
||||||
scaleParallelUploads: false,
|
scaleParallelUploads: false,
|
||||||
@ -52,19 +87,24 @@ const DEFAULTS = {
|
|||||||
delaySec: 3,
|
delaySec: 3,
|
||||||
autoStart: true,
|
autoStart: true,
|
||||||
hosters: [] // pre-selected hosters, empty = ask via modal
|
hosters: [] // pre-selected hosters, empty = ask via modal
|
||||||
|
},
|
||||||
|
remote: {
|
||||||
|
enabled: false,
|
||||||
|
port: 9100,
|
||||||
|
token: '',
|
||||||
|
allowInput: true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
history: []
|
history: []
|
||||||
};
|
};
|
||||||
|
|
||||||
const MAX_HISTORY = 100;
|
|
||||||
|
|
||||||
class ConfigStore {
|
class ConfigStore {
|
||||||
constructor(app) {
|
constructor(app) {
|
||||||
const dir = app && app.isPackaged
|
const dir = app && app.isPackaged
|
||||||
? app.getPath('userData')
|
? app.getPath('userData')
|
||||||
: path.join(__dirname, '..');
|
: path.join(__dirname, '..');
|
||||||
this.filePath = path.join(dir, 'electron-config.json');
|
this.filePath = path.join(dir, 'electron-config.json');
|
||||||
|
this._writeQueue = Promise.resolve(); // Serializes all writes to prevent race conditions
|
||||||
|
|
||||||
// Migrate config from old location if current doesn't exist
|
// Migrate config from old location if current doesn't exist
|
||||||
if (!fs.existsSync(this.filePath) && app && app.isPackaged) {
|
if (!fs.existsSync(this.filePath) && app && app.isPackaged) {
|
||||||
@ -111,15 +151,52 @@ class ConfigStore {
|
|||||||
const backupPath = this.filePath + '.bak';
|
const backupPath = this.filePath + '.bak';
|
||||||
try { data = this._readAndParse(backupPath); } catch {}
|
try { data = this._readAndParse(backupPath); } catch {}
|
||||||
}
|
}
|
||||||
if (!data) return JSON.parse(JSON.stringify(DEFAULTS));
|
if (!data) {
|
||||||
|
const fresh = JSON.parse(JSON.stringify(DEFAULTS));
|
||||||
|
fresh.globalSettings.logMode = normalizeLogMode(fresh.globalSettings);
|
||||||
|
return fresh;
|
||||||
|
}
|
||||||
|
|
||||||
// Merge with defaults so new hosters are always present
|
// Migrate old single-object format to array format
|
||||||
const hosters = { ...DEFAULTS.hosters };
|
|
||||||
for (const [name, val] of Object.entries(data.hosters || {})) {
|
for (const [name, val] of Object.entries(data.hosters || {})) {
|
||||||
if (hosters[name]) {
|
if (val && !Array.isArray(val)) {
|
||||||
hosters[name] = { ...hosters[name], ...val };
|
if (!val.id) val.id = `${name}-migrated-${Date.now()}`;
|
||||||
|
// Infer authType for old format accounts
|
||||||
|
if (!val.authType) {
|
||||||
|
if (name === 'byse.sx') val.authType = 'api';
|
||||||
|
else if (name === 'vidmoly.me') val.authType = 'login';
|
||||||
|
else if (val.username && val.password) val.authType = 'login';
|
||||||
|
else if (val.apiKey) val.authType = 'api';
|
||||||
|
else val.authType = 'login';
|
||||||
|
}
|
||||||
|
data.hosters[name] = [val];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Merge hosters: ensure all known hosters exist as arrays
|
||||||
|
const hosters = {};
|
||||||
|
for (const name of HOSTER_NAMES) {
|
||||||
|
const saved = data.hosters && data.hosters[name];
|
||||||
|
if (Array.isArray(saved) && saved.length > 0) {
|
||||||
|
hosters[name] = saved.map((acc, i) => {
|
||||||
|
// Ensure authType is set on every account
|
||||||
|
if (!acc.authType) {
|
||||||
|
if (name === 'byse.sx') acc.authType = 'api';
|
||||||
|
else if (name === 'vidmoly.me') acc.authType = 'login';
|
||||||
|
else if (acc.username && acc.password) acc.authType = 'login';
|
||||||
|
else if (acc.apiKey) acc.authType = 'api';
|
||||||
|
else acc.authType = 'login';
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...acc,
|
||||||
|
id: acc.id || `${name}-${Date.now()}-${i}`
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
hosters[name] = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Merge hoster settings with defaults
|
// Merge hoster settings with defaults
|
||||||
const hosterSettings = {};
|
const hosterSettings = {};
|
||||||
for (const name of Object.keys(DEFAULTS.hosterSettings)) {
|
for (const name of Object.keys(DEFAULTS.hosterSettings)) {
|
||||||
@ -140,18 +217,44 @@ class ConfigStore {
|
|||||||
globalSettings[key] = { ...def, ...(savedGlobal[key] || {}) };
|
globalSettings[key] = { ...def, ...(savedGlobal[key] || {}) };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { hosters, hosterSettings, globalSettings, history: data.history || [] };
|
// Normalize logMode at this single boundary. Legacy sessionLog: true
|
||||||
|
// means *daily* (the old field was named after a misnomer); see log-mode.js.
|
||||||
|
// Downstream readers consume logMode only and must NOT derive from
|
||||||
|
// sessionLog at call sites.
|
||||||
|
globalSettings.logMode = normalizeLogMode(globalSettings);
|
||||||
|
const result = { hosters, hosterSettings, globalSettings, history: data.history || [] };
|
||||||
|
// Decrypt credentials stored with safeStorage so the rest of the app
|
||||||
|
// keeps working with plaintext in memory.
|
||||||
|
secretStore.decryptCredentials(result);
|
||||||
|
return result;
|
||||||
} catch {
|
} catch {
|
||||||
return JSON.parse(JSON.stringify(DEFAULTS));
|
const fresh = JSON.parse(JSON.stringify(DEFAULTS));
|
||||||
|
fresh.globalSettings.logMode = normalizeLogMode(fresh.globalSettings);
|
||||||
|
return fresh;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deep-clone a config and encrypt its credential fields. Never mutate the
|
||||||
|
// caller's object — the rest of the app holds plaintext references.
|
||||||
|
_serializeForDisk(config) {
|
||||||
|
const clone = JSON.parse(JSON.stringify(config));
|
||||||
|
secretStore.encryptCredentials(clone);
|
||||||
|
return JSON.stringify(clone, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
_enqueueWrite(fn) {
|
||||||
|
this._writeQueue = this._writeQueue.then(fn, fn);
|
||||||
|
return this._writeQueue;
|
||||||
|
}
|
||||||
|
|
||||||
save(config) {
|
save(config) {
|
||||||
const current = this.load();
|
return this._enqueueWrite(() => {
|
||||||
if (config.hosters) current.hosters = config.hosters;
|
const current = this.load();
|
||||||
if (config.hosterSettings) current.hosterSettings = config.hosterSettings;
|
if (config.hosters) current.hosters = config.hosters;
|
||||||
if (config.globalSettings) current.globalSettings = config.globalSettings;
|
if (config.hosterSettings) current.hosterSettings = config.hosterSettings;
|
||||||
return this._atomicWrite(JSON.stringify(current, null, 2));
|
if (config.globalSettings) current.globalSettings = config.globalSettings;
|
||||||
|
return this._atomicWrite(this._serializeForDisk(current));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
loadHistory() {
|
loadHistory() {
|
||||||
@ -166,12 +269,18 @@ class ConfigStore {
|
|||||||
fs.writeFile(tmpPath, data, 'utf-8', (err) => {
|
fs.writeFile(tmpPath, data, 'utf-8', (err) => {
|
||||||
if (err) return reject(err);
|
if (err) return reject(err);
|
||||||
try {
|
try {
|
||||||
if (fs.existsSync(this.filePath)) {
|
// Refresh .bak from the previous live file. Wrapped in try/catch
|
||||||
const existing = fs.readFileSync(this.filePath, 'utf-8');
|
// so an AV/indexer briefly locking the file doesn't fail the whole
|
||||||
if (existing && existing.trim().length > 2) {
|
// save — the rename to the live path is the part that matters,
|
||||||
fs.writeFileSync(backupPath, existing, 'utf-8');
|
// a stale .bak is preferable to losing the new write entirely.
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(this.filePath)) {
|
||||||
|
const existing = fs.readFileSync(this.filePath, 'utf-8');
|
||||||
|
if (existing && existing.trim().length > 2) {
|
||||||
|
fs.writeFileSync(backupPath, existing, 'utf-8');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
} catch {}
|
||||||
fs.renameSync(tmpPath, this.filePath);
|
fs.renameSync(tmpPath, this.filePath);
|
||||||
} catch (e) { return reject(e); }
|
} catch (e) { return reject(e); }
|
||||||
resolve();
|
resolve();
|
||||||
@ -180,19 +289,23 @@ class ConfigStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
appendHistory(entry) {
|
appendHistory(entry) {
|
||||||
const config = this.load();
|
return this._enqueueWrite(() => {
|
||||||
config.history.push(entry);
|
const config = this.load();
|
||||||
if (config.history.length > MAX_HISTORY) {
|
config.history.push(entry);
|
||||||
config.history = config.history.slice(-MAX_HISTORY);
|
return this._atomicWrite(this._serializeForDisk(config));
|
||||||
}
|
});
|
||||||
return this._atomicWrite(JSON.stringify(config, null, 2));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
clearHistory() {
|
clearHistory() {
|
||||||
const config = this.load();
|
return this._enqueueWrite(() => {
|
||||||
config.history = [];
|
const config = this.load();
|
||||||
return this._atomicWrite(JSON.stringify(config, null, 2));
|
config.history = [];
|
||||||
|
return this._atomicWrite(this._serializeForDisk(config));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = ConfigStore;
|
module.exports = ConfigStore;
|
||||||
|
module.exports.HOSTER_ACCOUNT_TEMPLATES = HOSTER_ACCOUNT_TEMPLATES;
|
||||||
|
module.exports.HOSTER_NAMES = HOSTER_NAMES;
|
||||||
|
module.exports.HOSTER_ADD_OPTIONS = HOSTER_ADD_OPTIONS;
|
||||||
|
|||||||
@ -7,10 +7,32 @@ const BASE_URL = 'https://doodstream.com';
|
|||||||
const USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';
|
const USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';
|
||||||
const UPLOAD_TIMEOUT = 1800000; // 30 min
|
const UPLOAD_TIMEOUT = 1800000; // 30 min
|
||||||
|
|
||||||
|
// Cap doodstream's per-hoster debug log alongside the main log files so
|
||||||
|
// dev-mode sessions don't accumulate gigabytes of upload trace.
|
||||||
|
const { maybeRotateLogFile } = require('./log-rotation');
|
||||||
|
const _DOODSTREAM_LOG_MAX_BYTES = 10 * 1024 * 1024;
|
||||||
|
const _DOODSTREAM_LOG_MAX_BACKUPS = 1;
|
||||||
|
|
||||||
|
// Resolve the log path at write-time. In a packaged build __dirname lives
|
||||||
|
// inside app.asar (read-only) — writing there fails silently and we lose every
|
||||||
|
// production trace. Prefer Electron's writable userData dir, fall back to the
|
||||||
|
// repo root only when running outside Electron (tests / plain node).
|
||||||
|
function _doodstreamLogPath() {
|
||||||
|
try {
|
||||||
|
const { app } = require('electron');
|
||||||
|
if (app && typeof app.getPath === 'function') {
|
||||||
|
return path.join(app.getPath('userData'), 'doodstream-debug.log');
|
||||||
|
}
|
||||||
|
} catch { /* not running under Electron */ }
|
||||||
|
return path.join(__dirname, '..', 'doodstream-debug.log');
|
||||||
|
}
|
||||||
|
|
||||||
function _debugLog(msg) {
|
function _debugLog(msg) {
|
||||||
try {
|
try {
|
||||||
|
const logPath = _doodstreamLogPath();
|
||||||
|
maybeRotateLogFile(logPath, _DOODSTREAM_LOG_MAX_BYTES, _DOODSTREAM_LOG_MAX_BACKUPS);
|
||||||
const ts = new Date().toISOString();
|
const ts = new Date().toISOString();
|
||||||
fs.appendFileSync(path.join(__dirname, '..', 'doodstream-debug.log'), `[${ts}] ${msg}\n`);
|
fs.appendFileSync(logPath, `[${ts}] ${msg}\n`);
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -18,6 +40,7 @@ class DoodstreamUploader {
|
|||||||
constructor() {
|
constructor() {
|
||||||
this.cookies = new Map();
|
this.cookies = new Map();
|
||||||
this.sessId = '';
|
this.sessId = '';
|
||||||
|
this.apiKey = ''; // optionally derived from the logged-in session (deriveApiKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
_cookieHeader() {
|
_cookieHeader() {
|
||||||
@ -54,11 +77,27 @@ class DoodstreamUploader {
|
|||||||
headers['Cookie'] = this._cookieHeader();
|
headers['Cookie'] = this._cookieHeader();
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await fetch(url, {
|
// The small discovery/result requests that bookend a multi-minute upload
|
||||||
...opts,
|
// occasionally hit a transient blip ("fetch failed", ECONNRESET, a hung TLS
|
||||||
headers,
|
// handshake). A blip here shouldn't throw away the whole upload, so retry a
|
||||||
redirect: 'manual'
|
// few times with short backoff. Each attempt gets its own 20s timeout —
|
||||||
});
|
// Node's fetch has none by default, and a hung socket would otherwise stall
|
||||||
|
// the attempt for minutes. The big file upload (undici) is retried at the
|
||||||
|
// upload-manager level, not here.
|
||||||
|
let res;
|
||||||
|
for (let attempt = 1; ; attempt++) {
|
||||||
|
const timeoutSignal = AbortSignal.timeout(20000);
|
||||||
|
const signal = opts.signal ? AbortSignal.any([opts.signal, timeoutSignal]) : timeoutSignal;
|
||||||
|
try {
|
||||||
|
res = await fetch(url, { ...opts, headers, redirect: 'manual', signal });
|
||||||
|
break;
|
||||||
|
} catch (err) {
|
||||||
|
if (opts.signal && opts.signal.aborted) throw err; // caller abort: don't retry
|
||||||
|
if (attempt >= 3) throw err;
|
||||||
|
_debugLog(`_fetch transient (${attempt}/3) ${url}: ${err && err.message}; retry`);
|
||||||
|
await new Promise(r => setTimeout(r, 400 * attempt));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this._parseCookiesFromHeaders(res.headers);
|
this._parseCookiesFromHeaders(res.headers);
|
||||||
|
|
||||||
@ -78,7 +117,7 @@ class DoodstreamUploader {
|
|||||||
/**
|
/**
|
||||||
* Login to DoodStream via web form
|
* Login to DoodStream via web form
|
||||||
*/
|
*/
|
||||||
async login(username, password) {
|
async login(username, password, otp) {
|
||||||
// GET homepage first to collect cookies
|
// GET homepage first to collect cookies
|
||||||
const homeRes = await this._fetch(BASE_URL);
|
const homeRes = await this._fetch(BASE_URL);
|
||||||
await homeRes.text();
|
await homeRes.text();
|
||||||
@ -88,7 +127,7 @@ class DoodstreamUploader {
|
|||||||
op: 'login_ajax',
|
op: 'login_ajax',
|
||||||
login: username,
|
login: username,
|
||||||
password: password,
|
password: password,
|
||||||
loginotp: ''
|
loginotp: otp || ''
|
||||||
});
|
});
|
||||||
|
|
||||||
// Use raw fetch with redirect: 'manual' to detect success redirects
|
// Use raw fetch with redirect: 'manual' to detect success redirects
|
||||||
@ -122,6 +161,11 @@ class DoodstreamUploader {
|
|||||||
|
|
||||||
if (json && json.status === 'success') {
|
if (json && json.status === 'success') {
|
||||||
// Explicit success response
|
// Explicit success response
|
||||||
|
} else if (json && json.message && /otp/i.test(json.message)) {
|
||||||
|
// OTP required — signal caller to collect OTP from user
|
||||||
|
const err = new Error(`Doodstream Login: ${json.message}`);
|
||||||
|
err.otpRequired = true;
|
||||||
|
throw err;
|
||||||
} else if (json && json.status === 'fail') {
|
} else if (json && json.status === 'fail') {
|
||||||
throw new Error(`Doodstream Login: ${json.message || 'Login fehlgeschlagen'}`);
|
throw new Error(`Doodstream Login: ${json.message || 'Login fehlgeschlagen'}`);
|
||||||
} else if (body.includes('Dashboard')) {
|
} else if (body.includes('Dashboard')) {
|
||||||
@ -171,6 +215,8 @@ class DoodstreamUploader {
|
|||||||
// Use the standard upload server endpoint
|
// Use the standard upload server endpoint
|
||||||
const res = await this._fetch(BASE_URL + '/?op=upload_server');
|
const res = await this._fetch(BASE_URL + '/?op=upload_server');
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
|
const ctype = (res.headers && res.headers.get) ? (res.headers.get('content-type') || '') : '';
|
||||||
|
_debugLog(`upload_server: status=${res.status} ctype=${ctype} body(800)=${(text || '').slice(0, 800)}`);
|
||||||
let json;
|
let json;
|
||||||
try { json = JSON.parse(text); } catch { json = null; }
|
try { json = JSON.parse(text); } catch { json = null; }
|
||||||
|
|
||||||
@ -181,11 +227,78 @@ class DoodstreamUploader {
|
|||||||
// Fallback: try fetching from upload page HTML
|
// Fallback: try fetching from upload page HTML
|
||||||
const pageRes = await this._fetch(BASE_URL + '/?op=upload');
|
const pageRes = await this._fetch(BASE_URL + '/?op=upload');
|
||||||
const html = await pageRes.text();
|
const html = await pageRes.text();
|
||||||
|
|
||||||
|
// Current doodstream format: the upload server is the action of the
|
||||||
|
// multipart upload form, e.g.
|
||||||
|
// <form name="file" enctype="multipart/form-data"
|
||||||
|
// action="https://xxx.cloudatacdn.com/upload/01?SESSID" ...>
|
||||||
|
// <input type="hidden" name="sess_id" value="SESSID">
|
||||||
|
// The node is assigned per page-load and the action carries a session token
|
||||||
|
// in its query string that matches the page's hidden sess_id. We refresh
|
||||||
|
// this.sessId from THIS page so the multipart sess_id field matches the node
|
||||||
|
// URL — login-time and node tokens otherwise diverge and the upload comes
|
||||||
|
// back with an empty filecode.
|
||||||
|
const actionMatch = html.match(/action=["'](https?:\/\/[^"']+\/upload\/[^"']*)["']/i);
|
||||||
|
if (actionMatch) {
|
||||||
|
const url = actionMatch[1].replace(/&/g, '&'); // un-escape HTML entities in query
|
||||||
|
const freshSess = html.match(/name=["']sess_id["'][^>]*value=["']([a-zA-Z0-9]+)["']/);
|
||||||
|
if (freshSess) {
|
||||||
|
this.sessId = freshSess[1];
|
||||||
|
} else {
|
||||||
|
_debugLog('upload_server: form action found but no sess_id on page; keeping existing sessId');
|
||||||
|
}
|
||||||
|
// Capture the form's real fields so upload() submits exactly what the
|
||||||
|
// browser would (file_title, submit_btn, …) instead of stale hardcoded ones.
|
||||||
|
this._uploadFormFields = this._parseUploadFormFields(html);
|
||||||
|
_debugLog(`upload_server: using form action node=${url} sess=${this.sessId} fields=${Object.keys(this._uploadFormFields).join(',')}`);
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy fallback: srv_url JS variable (older doodstream theme).
|
||||||
const srvMatch = html.match(/srv_url['":\s]+['"]?(https?:\/\/[^'">\s]+)['"]?/i);
|
const srvMatch = html.match(/srv_url['":\s]+['"]?(https?:\/\/[^'">\s]+)['"]?/i);
|
||||||
if (srvMatch) return srvMatch[1];
|
if (srvMatch) return srvMatch[1];
|
||||||
|
|
||||||
// Last resort fallback
|
// No upload server could be extracted. We MUST NOT silently fall back to a
|
||||||
return 'https://tr1128ve.cloudatacdn.com/upload/01';
|
// hardcoded node: that node is stale and accepts the bytes but returns an
|
||||||
|
// empty form (no filecode) — so the user wastes ~90s uploading 95 MB into a
|
||||||
|
// dead end and gets a cryptic "kein Filecode" 90s later. Fail fast and put
|
||||||
|
// the raw responses in the error so the real format change is diagnosable.
|
||||||
|
const urlHints = (html.match(/https?:\/\/[^'">\s]+/g) || []).slice(0, 4).join(' , ');
|
||||||
|
_debugLog(`upload_server: NO SERVER. upload-page html(2000)=${(html || '').slice(0, 2000)}`);
|
||||||
|
throw new Error(
|
||||||
|
`Doodstream: konnte Upload-Server nicht ermitteln (Endpoint geaendert?). ` +
|
||||||
|
`op=upload_server status=${res.status} ctype=${ctype} body=${(text || '').slice(0, 300)} ` +
|
||||||
|
`| upload-page URL-Treffer: ${urlHints || 'keine'}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replicate the non-file fields of doodstream's CURRENT upload form so our
|
||||||
|
* POST matches what the browser actually submits. Doodstream dropped the old
|
||||||
|
* `utype` field and added file_title / fakefilepc / submit_btn; submitting a
|
||||||
|
* stale/incomplete field set can make the node accept the bytes but skip
|
||||||
|
* registration (→ empty result form). We parse the live form rather than
|
||||||
|
* hardcode, so we track whatever fields doodstream uses now. The file input
|
||||||
|
* (type=file) is excluded — the file is streamed separately.
|
||||||
|
*/
|
||||||
|
_parseUploadFormFields(html) {
|
||||||
|
const fields = {};
|
||||||
|
if (!html) return fields;
|
||||||
|
// Narrow to the upload form (its action points at a /upload/ node).
|
||||||
|
const formMatch = html.match(/<form[^>]*\baction=["'][^"']*\/upload\/[^"']*["'][\s\S]*?<\/form>/i);
|
||||||
|
const scope = formMatch ? formMatch[0] : html;
|
||||||
|
const re = /<(?:input|button)\b([^>]*)>/gi;
|
||||||
|
let m;
|
||||||
|
while ((m = re.exec(scope)) !== null) {
|
||||||
|
const attrs = m[1];
|
||||||
|
const typeM = attrs.match(/\btype=["']([^"']*)["']/i);
|
||||||
|
if (typeM && typeM[1].toLowerCase() === 'file') continue;
|
||||||
|
const nameM = attrs.match(/\bname=["']([^"']+)["']/i);
|
||||||
|
if (!nameM) continue;
|
||||||
|
const valM = attrs.match(/\bvalue=["']([^"']*)["']/i);
|
||||||
|
fields[nameM[1]] = valM ? valM[1] : '';
|
||||||
|
}
|
||||||
|
return fields;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -197,15 +310,25 @@ class DoodstreamUploader {
|
|||||||
|
|
||||||
// Get upload server
|
// Get upload server
|
||||||
const uploadUrl = await this._getUploadServer();
|
const uploadUrl = await this._getUploadServer();
|
||||||
|
// Remember which CDN node handled this upload so a later parse failure can
|
||||||
|
// report it — failures sometimes correlate with a specific node.
|
||||||
|
this._lastUploadUrl = uploadUrl;
|
||||||
|
|
||||||
// Build multipart form
|
// Build multipart form
|
||||||
const boundary = `----WebKitFormBoundary${crypto.randomBytes(16).toString('hex')}`;
|
const boundary = `----WebKitFormBoundary${crypto.randomBytes(16).toString('hex')}`;
|
||||||
|
|
||||||
// Build form parts
|
// Build form parts. Submit the live form's fields (parsed in
|
||||||
|
// _getUploadServer) so our POST matches the browser; merge in sess_id (the
|
||||||
|
// fresh node token) and keep utype=reg as a harmless compatibility extra.
|
||||||
|
// Falls back to the minimal known-good set if the form wasn't parsed.
|
||||||
|
const formFields = { utype: 'reg', ...(this._uploadFormFields || {}) };
|
||||||
|
formFields.sess_id = this.sessId;
|
||||||
let preamble = '';
|
let preamble = '';
|
||||||
preamble += `--${boundary}\r\nContent-Disposition: form-data; name="sess_id"\r\n\r\n${this.sessId}\r\n`;
|
for (const [name, value] of Object.entries(formFields)) {
|
||||||
preamble += `--${boundary}\r\nContent-Disposition: form-data; name="utype"\r\n\r\nreg\r\n`;
|
preamble += `--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n${value}\r\n`;
|
||||||
preamble += `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${fileName}"\r\nContent-Type: application/octet-stream\r\n\r\n`;
|
}
|
||||||
|
const safeFileName = fileName.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||||
|
preamble += `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${safeFileName}"\r\nContent-Type: application/octet-stream\r\n\r\n`;
|
||||||
|
|
||||||
const epilogue = `\r\n--${boundary}--\r\n`;
|
const epilogue = `\r\n--${boundary}--\r\n`;
|
||||||
const preambleBuf = Buffer.from(preamble, 'utf-8');
|
const preambleBuf = Buffer.from(preamble, 'utf-8');
|
||||||
@ -215,7 +338,6 @@ class DoodstreamUploader {
|
|||||||
const CHUNK_SIZE = 256 * 1024;
|
const CHUNK_SIZE = 256 * 1024;
|
||||||
let bytesRead = 0;
|
let bytesRead = 0;
|
||||||
|
|
||||||
const self = this;
|
|
||||||
async function* generate() {
|
async function* generate() {
|
||||||
yield preambleBuf;
|
yield preambleBuf;
|
||||||
const fileStream = fs.createReadStream(filePath, { highWaterMark: CHUNK_SIZE });
|
const fileStream = fs.createReadStream(filePath, { highWaterMark: CHUNK_SIZE });
|
||||||
@ -229,19 +351,31 @@ class DoodstreamUploader {
|
|||||||
yield epilogueBuf;
|
yield epilogueBuf;
|
||||||
}
|
}
|
||||||
|
|
||||||
const uploadRes = await request(uploadUrl, {
|
let uploadRes;
|
||||||
method: 'POST',
|
try {
|
||||||
headers: {
|
uploadRes = await request(uploadUrl, {
|
||||||
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
method: 'POST',
|
||||||
'Content-Length': String(totalSize),
|
headers: {
|
||||||
'User-Agent': USER_AGENT,
|
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
||||||
'Cookie': this._cookieHeader()
|
'Content-Length': String(totalSize),
|
||||||
},
|
'User-Agent': USER_AGENT,
|
||||||
body: generate(),
|
'Cookie': this._cookieHeader()
|
||||||
signal,
|
},
|
||||||
bodyTimeout: UPLOAD_TIMEOUT,
|
body: generate(),
|
||||||
headersTimeout: 60000
|
signal,
|
||||||
});
|
bodyTimeout: UPLOAD_TIMEOUT,
|
||||||
|
headersTimeout: 60000
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// Label which phase failed so a future "fetch failed"/"terminated" is
|
||||||
|
// attributable to the big upload POST vs the small bookend requests. The
|
||||||
|
// original message is preserved as a substring so upload-manager's
|
||||||
|
// transient classification still matches. NOTE: undici may surface
|
||||||
|
// "terminated"/"other side closed", which are not yet in that transient
|
||||||
|
// list — revisit if logs show them.
|
||||||
|
const mb = Math.round(bytesRead / 1048576);
|
||||||
|
throw new Error(`Doodstream Upload-POST (${mb} MB an ${uploadUrl}): ${err && err.message ? err.message : err}`);
|
||||||
|
}
|
||||||
|
|
||||||
const statusCode = uploadRes.statusCode;
|
const statusCode = uploadRes.statusCode;
|
||||||
_debugLog(`Upload response status: ${statusCode}`);
|
_debugLog(`Upload response status: ${statusCode}`);
|
||||||
@ -338,15 +472,28 @@ class DoodstreamUploader {
|
|||||||
|
|
||||||
_debugLog(`Submitting upload_result to ${BASE_URL}/ with fields: ${JSON.stringify(hiddenFields)}`);
|
_debugLog(`Submitting upload_result to ${BASE_URL}/ with fields: ${JSON.stringify(hiddenFields)}`);
|
||||||
const formData = new URLSearchParams(hiddenFields);
|
const formData = new URLSearchParams(hiddenFields);
|
||||||
const followRes = await this._fetch(BASE_URL + '/', {
|
let followText = '';
|
||||||
method: 'POST',
|
try {
|
||||||
headers: {
|
const followRes = await this._fetch(BASE_URL + '/', {
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
method: 'POST',
|
||||||
'Referer': BASE_URL + '/'
|
headers: {
|
||||||
},
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
body: formData.toString()
|
'Referer': BASE_URL + '/'
|
||||||
});
|
},
|
||||||
const followText = await followRes.text();
|
body: formData.toString()
|
||||||
|
});
|
||||||
|
followText = await followRes.text();
|
||||||
|
} catch (err) {
|
||||||
|
// The file already uploaded to the CDN; this POST only registers it on
|
||||||
|
// doodstream's side. If it fails transiently (even after _fetch's own
|
||||||
|
// retries) but we already hold the filecode, the upload succeeded from
|
||||||
|
// the user's view — return it rather than discarding a done upload.
|
||||||
|
if (fnCode && fnCode.length >= 8) {
|
||||||
|
_debugLog(`upload_result submit failed (${err && err.message}); using fn ${fnCode}`);
|
||||||
|
return this._buildResult(fnCode);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
_debugLog(`upload_result response (first 500): ${followText.slice(0, 500)}`);
|
_debugLog(`upload_result response (first 500): ${followText.slice(0, 500)}`);
|
||||||
|
|
||||||
// Try to find filecode in result page
|
// Try to find filecode in result page
|
||||||
@ -366,7 +513,28 @@ class DoodstreamUploader {
|
|||||||
return this._buildResult(dlMatch[1]);
|
return this._buildResult(dlMatch[1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(`Doodstream Upload: upload_result Seite hat keinen filecode (${followText.slice(0, 150)})`);
|
// No filecode anywhere. Surface WHY: XFileSharing puts the real reason
|
||||||
|
// in the `st` field (anything other than "OK" means the backend refused
|
||||||
|
// the file — copyright/hash match, duplicate, size, quota, …). The
|
||||||
|
// download link being empty while the page structure is unchanged points
|
||||||
|
// at doodstream's backend, not at a parsing bug on our side.
|
||||||
|
const st = hiddenFields.st || '';
|
||||||
|
const fnInfo = fnCode ? `"${fnCode}"(len ${fnCode.length})` : 'fehlt/leer';
|
||||||
|
const node = this._lastUploadUrl || '?';
|
||||||
|
_debugLog(`No filecode. st=${st} fn=${fnInfo} node=${node} CDN-body=${(resText || '').slice(0, 400)}`);
|
||||||
|
if (st && st !== 'OK') {
|
||||||
|
throw new Error(`Doodstream lehnt Datei ab (Server-Status: ${st}). CDN=${node}`);
|
||||||
|
}
|
||||||
|
// Empty form (no fn, no st) is a doodstream-side processing flake — same
|
||||||
|
// account + same file works on a later attempt. Tag it explicitly so the
|
||||||
|
// upload-manager classifies this as a hoster-transient error and does NOT
|
||||||
|
// blacklist the account (otherwise one of these flakes poisons the whole
|
||||||
|
// session and later batches hit `pre-job-swap-blocked` for no fault of
|
||||||
|
// the account). The flag is the primary signal; the message text is a
|
||||||
|
// belt-and-suspenders regex fallback in the classifier.
|
||||||
|
const emptyLinkErr = new Error(`Doodstream Upload: kein Filecode — Server gab leeren Link zurueck (st=${st || '?'}, fn=${fnInfo}, CDN=${node}). CDN-Antwort: ${(resText || '').slice(0, 200)}`);
|
||||||
|
emptyLinkErr.hosterTransient = true;
|
||||||
|
throw emptyLinkErr;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Fallback: follow form action as-is (for non-XFS forms)
|
// 4. Fallback: follow form action as-is (for non-XFS forms)
|
||||||
@ -448,6 +616,85 @@ class DoodstreamUploader {
|
|||||||
file_code: fileCode
|
file_code: fileCode
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pull candidate API-key tokens out of a logged-in settings page. We do NOT
|
||||||
|
* rely on knowing doodstream's exact (cookie-gated, unseen) settings DOM —
|
||||||
|
* instead we gather every plausible long token from form-field values and
|
||||||
|
* element contents, ranked so tokens near an "api" mention are tried first.
|
||||||
|
* The caller validates each against the official API, so a wrong guess is
|
||||||
|
* harmless (it just fails validation). Returned newest-/most-likely-first.
|
||||||
|
*/
|
||||||
|
_extractApiKeyCandidates(html) {
|
||||||
|
if (!html) return [];
|
||||||
|
const cands = new Set();
|
||||||
|
const patterns = [
|
||||||
|
/value=["']([A-Za-z0-9]{20,})["']/gi, // <input value="KEY">
|
||||||
|
/<(?:textarea|code|span|pre|input)[^>]*>\s*([A-Za-z0-9]{20,})\s*</gi, // <textarea>KEY</textarea>
|
||||||
|
/\b(?:api[_-]?key|apikey)\b["':\s=>]*["']?([A-Za-z0-9]{20,})/gi // api_key: "KEY"
|
||||||
|
];
|
||||||
|
for (const re of patterns) {
|
||||||
|
let m;
|
||||||
|
while ((m = re.exec(html)) !== null) cands.add(m[1]);
|
||||||
|
}
|
||||||
|
// Rank tokens whose preceding context mentions "api" ahead of the rest.
|
||||||
|
return [...cands]
|
||||||
|
.map(t => {
|
||||||
|
const idx = html.indexOf(t);
|
||||||
|
const ctx = html.slice(Math.max(0, idx - 160), idx).toLowerCase();
|
||||||
|
return { t, near: /api/.test(ctx) ? 0 : 1 };
|
||||||
|
})
|
||||||
|
.sort((a, b) => a.near - b.near)
|
||||||
|
.map(s => s.t);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a candidate key against the official API. Only the account's real
|
||||||
|
* key returns status 200, so this is what makes the brute-force extraction
|
||||||
|
* safe regardless of the settings-page markup.
|
||||||
|
*/
|
||||||
|
async _validateApiKey(key) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`https://doodapi.co/api/account/info?key=${encodeURIComponent(key)}`, {
|
||||||
|
method: 'GET', redirect: 'follow', signal: AbortSignal.timeout(15000)
|
||||||
|
});
|
||||||
|
const json = await res.json().catch(() => null);
|
||||||
|
return !!(json && Number(json.status) === 200);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive the account's doodapi API key from the logged-in web session, so a
|
||||||
|
* login-only account can upload via the reliable JSON API (which returns the
|
||||||
|
* filecode directly) instead of the fragile web upload form. Best-effort:
|
||||||
|
* returns null if no valid key can be found, and the caller falls back to the
|
||||||
|
* web-form upload. Requires login() to have run first (needs the cookies).
|
||||||
|
*/
|
||||||
|
async deriveApiKey() {
|
||||||
|
if (this.apiKey) return this.apiKey;
|
||||||
|
let html = '';
|
||||||
|
for (const page of ['/?op=my_account', '/settings', '/?op=profile']) {
|
||||||
|
try {
|
||||||
|
const res = await this._fetch(BASE_URL + page);
|
||||||
|
const text = await res.text();
|
||||||
|
if (text && /api[\s_-]?key/i.test(text)) { html = text; break; }
|
||||||
|
if (text && !html) html = text;
|
||||||
|
} catch { /* try next page */ }
|
||||||
|
}
|
||||||
|
const candidates = this._extractApiKeyCandidates(html);
|
||||||
|
// Cap validation calls (rate limit 10/s; settings page yields few tokens).
|
||||||
|
for (const key of candidates.slice(0, 15)) {
|
||||||
|
if (await this._validateApiKey(key)) {
|
||||||
|
this.apiKey = key;
|
||||||
|
_debugLog(`api-key derive: validated key (len ${key.length})`);
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_debugLog(`api-key derive: ${candidates.length} candidate(s), none validated. settings html(2500)=${(html || '').slice(0, 2500)}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = DoodstreamUploader;
|
module.exports = DoodstreamUploader;
|
||||||
|
|||||||
73
lib/file-probe.js
Normal file
73
lib/file-probe.js
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
const SIGNATURES = [
|
||||||
|
{ kind: 'mp4-iso', test: (b) => b.length >= 12 && b.slice(4, 8).toString('ascii') === 'ftyp' },
|
||||||
|
{ kind: 'matroska', test: (b) => b.length >= 4 && b[0] === 0x1A && b[1] === 0x45 && b[2] === 0xDF && b[3] === 0xA3 },
|
||||||
|
{ kind: 'avi', test: (b) => b.length >= 12 && b.slice(0, 4).toString('ascii') === 'RIFF' && b.slice(8, 12).toString('ascii') === 'AVI ' },
|
||||||
|
{ kind: 'wav', test: (b) => b.length >= 12 && b.slice(0, 4).toString('ascii') === 'RIFF' && b.slice(8, 12).toString('ascii') === 'WAVE' },
|
||||||
|
{ kind: 'flv', test: (b) => b.length >= 3 && b.slice(0, 3).toString('ascii') === 'FLV' },
|
||||||
|
{ kind: 'asf-wmv', test: (b) => b.length >= 4 && b[0] === 0x30 && b[1] === 0x26 && b[2] === 0xB2 && b[3] === 0x75 },
|
||||||
|
{ kind: 'mpeg-ps', test: (b) => b.length >= 4 && b[0] === 0x00 && b[1] === 0x00 && b[2] === 0x01 && (b[3] === 0xBA || b[3] === 0xB3) },
|
||||||
|
{ kind: 'mpeg-ts', test: (b) => b.length >= 1 && b[0] === 0x47 },
|
||||||
|
{ kind: 'mp3', test: (b) => b.length >= 3 && (b.slice(0, 3).toString('ascii') === 'ID3' || (b[0] === 0xFF && (b[1] & 0xE0) === 0xE0)) },
|
||||||
|
{ kind: 'ogg', test: (b) => b.length >= 4 && b.slice(0, 4).toString('ascii') === 'OggS' },
|
||||||
|
{ kind: 'jpeg', test: (b) => b.length >= 3 && b[0] === 0xFF && b[1] === 0xD8 && b[2] === 0xFF },
|
||||||
|
{ kind: 'png', test: (b) => b.length >= 8 && b[0] === 0x89 && b.slice(1, 4).toString('ascii') === 'PNG' },
|
||||||
|
{ kind: 'pdf', test: (b) => b.length >= 5 && b.slice(0, 5).toString('ascii') === '%PDF-' },
|
||||||
|
{ kind: 'zip', test: (b) => b.length >= 4 && b[0] === 0x50 && b[1] === 0x4B && (b[2] === 0x03 || b[2] === 0x05 || b[2] === 0x07) },
|
||||||
|
{ kind: 'html', test: (b) => {
|
||||||
|
const s = b.toString('ascii', 0, Math.min(b.length, 64)).trimStart().toLowerCase();
|
||||||
|
return s.startsWith('<!doctype html') || s.startsWith('<html');
|
||||||
|
} }
|
||||||
|
];
|
||||||
|
|
||||||
|
const VIDEO_KINDS = new Set(['mp4-iso', 'matroska', 'avi', 'flv', 'asf-wmv', 'mpeg-ps', 'mpeg-ts']);
|
||||||
|
|
||||||
|
function detectKind(buf) {
|
||||||
|
if (!buf || buf.length === 0) return 'empty';
|
||||||
|
for (const sig of SIGNATURES) {
|
||||||
|
try { if (sig.test(buf)) return sig.kind; } catch { /* ignore malformed buffer slice */ }
|
||||||
|
}
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isVideoLikeKind(kind) {
|
||||||
|
return VIDEO_KINDS.has(kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
function probeFileHead(filePath, bytes) {
|
||||||
|
const want = Number.isFinite(bytes) && bytes > 0 ? bytes : 64;
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
fs.open(filePath, 'r', (err, fd) => {
|
||||||
|
if (err) return resolve({ ok: false, error: err.message, kind: 'unreadable' });
|
||||||
|
const buf = Buffer.alloc(want);
|
||||||
|
fs.read(fd, buf, 0, want, 0, (rerr, bytesRead) => {
|
||||||
|
fs.close(fd, () => {});
|
||||||
|
if (rerr) return resolve({ ok: false, error: rerr.message, kind: 'unreadable' });
|
||||||
|
const slice = buf.slice(0, bytesRead);
|
||||||
|
resolve({
|
||||||
|
ok: true,
|
||||||
|
bytesRead,
|
||||||
|
kind: detectKind(slice),
|
||||||
|
isVideoLike: isVideoLikeKind(detectKind(slice)),
|
||||||
|
headHex: slice.toString('hex')
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeFileStat(filePath) {
|
||||||
|
try {
|
||||||
|
const st = fs.statSync(filePath);
|
||||||
|
return {
|
||||||
|
size: st.size,
|
||||||
|
mtime: st.mtime.toISOString(),
|
||||||
|
isFile: st.isFile()
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return { error: err.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { detectKind, isVideoLikeKind, probeFileHead, summarizeFileStat, VIDEO_KINDS, SIGNATURES };
|
||||||
@ -35,6 +35,11 @@ class FolderMonitor extends EventEmitter {
|
|||||||
|
|
||||||
this._watcher = chokidar.watch(folderPath, watchOptions);
|
this._watcher = chokidar.watch(folderPath, watchOptions);
|
||||||
this._watcher.on('add', (filePath) => this._onNewFile(filePath));
|
this._watcher.on('add', (filePath) => this._onNewFile(filePath));
|
||||||
|
this._watcher.on('unlink', (filePath) => {
|
||||||
|
// Allow re-added files (e.g. re-encoded) to be detected again
|
||||||
|
const normalized = filePath.replace(/\\/g, '/').toLowerCase();
|
||||||
|
this._seenFiles.delete(normalized);
|
||||||
|
});
|
||||||
this._watcher.on('error', (err) => this.emit('error', err));
|
this._watcher.on('error', (err) => this.emit('error', err));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
287
lib/hosters.js
287
lib/hosters.js
@ -34,7 +34,10 @@ const HOSTER_CONFIGS = {
|
|||||||
'doodstream.com': {
|
'doodstream.com': {
|
||||||
apiBase: 'https://doodapi.co',
|
apiBase: 'https://doodapi.co',
|
||||||
serverEndpoints: ['/api/upload/server'],
|
serverEndpoints: ['/api/upload/server'],
|
||||||
fallbackUploadServers: ['https://tr1128ve.cloudatacdn.com/upload/01'],
|
// No hardcoded fallback node: that stale CDN host (tr1128ve.cloudatacdn.com)
|
||||||
|
// accepts the bytes but returns an empty result form with no filecode, so a
|
||||||
|
// failed server lookup must throw cleanly rather than upload ~1 GB into a
|
||||||
|
// dead end. (Same reasoning as the web-session path's fail-fast.)
|
||||||
buildUploadUrl: (url, key) => appendRawQuery(url, key),
|
buildUploadUrl: (url, key) => appendRawQuery(url, key),
|
||||||
formFields: (key) => ({ api_key: key }),
|
formFields: (key) => ({ api_key: key }),
|
||||||
parseResult: parseDoodstreamResult
|
parseResult: parseDoodstreamResult
|
||||||
@ -160,7 +163,9 @@ function sleep(ms, signal) {
|
|||||||
// Doodstream: { result: [{ download_url, protected_embed, filecode, protected_dl }] }
|
// Doodstream: { result: [{ download_url, protected_embed, filecode, protected_dl }] }
|
||||||
function parseDoodstreamResult(payload) {
|
function parseDoodstreamResult(payload) {
|
||||||
let item = {};
|
let item = {};
|
||||||
const result = payload.result;
|
// Defensive: also handle direct callers that bypass uploadFile's payload
|
||||||
|
// normalisation (e.g. unit tests, future callers).
|
||||||
|
const result = payload && payload.result;
|
||||||
if (Array.isArray(result) && result.length > 0) {
|
if (Array.isArray(result) && result.length > 0) {
|
||||||
item = result[0];
|
item = result[0];
|
||||||
} else if (result && typeof result === 'object') {
|
} else if (result && typeof result === 'object') {
|
||||||
@ -195,11 +200,22 @@ function parseVoeResult(payload) {
|
|||||||
|
|
||||||
// Byse: { files: [{ filecode, filename, status }] }
|
// Byse: { files: [{ filecode, filename, status }] }
|
||||||
function parseByseResult(payload) {
|
function parseByseResult(payload) {
|
||||||
|
// Defensive: bypass-callers may pass null/non-object directly.
|
||||||
|
if (!payload || typeof payload !== 'object') payload = {};
|
||||||
let file_code = null;
|
let file_code = null;
|
||||||
|
let perFileError = null;
|
||||||
|
|
||||||
// Primary: files array (per official Byse API docs)
|
// Primary: files array (per official Byse API docs)
|
||||||
if (Array.isArray(payload.files) && payload.files.length > 0) {
|
if (Array.isArray(payload.files) && payload.files.length > 0) {
|
||||||
file_code = payload.files[0].filecode || payload.files[0].file_code;
|
const f = payload.files[0];
|
||||||
|
file_code = f && (f.filecode || f.file_code) || null;
|
||||||
|
// Byse returns HTTP 200 + msg=OK even when a specific file was rejected
|
||||||
|
// ("Not video file format", "Duplicate", "File too small", ...). When
|
||||||
|
// filecode is empty and status carries a non-OK message, that IS the
|
||||||
|
// actual per-file error, not a server problem.
|
||||||
|
if (!file_code && f && f.status && !/^(ok|success|done)$/i.test(String(f.status))) {
|
||||||
|
perFileError = String(f.status).trim();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Fallback: result object
|
// Fallback: result object
|
||||||
if (!file_code && payload.result) {
|
if (!file_code && payload.result) {
|
||||||
@ -211,6 +227,19 @@ function parseByseResult(payload) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!file_code && perFileError) {
|
||||||
|
// Distinguish account-level from file-level failure. "not enough disk
|
||||||
|
// space", "quota exceeded", "storage full" etc. mean the ACCOUNT is
|
||||||
|
// exhausted — every further file on the same account will hit the same
|
||||||
|
// wall, so we must rotate. File-specific rejections (Duplicate, wrong
|
||||||
|
// format, too small/large) ARE per-file and rotation is pointless.
|
||||||
|
const accountLevel = /(not enough (disk )?(space|storage)|insufficient (disk )?space|disk (space )?full|storage (exhausted|full|voll|limit)|quota (exceeded|voll|überschritten)|account (full|voll|suspended|banned))/i.test(perFileError);
|
||||||
|
const err = new Error(`Byse lehnte Datei ab: ${perFileError}`);
|
||||||
|
if (accountLevel) err.accountError = true;
|
||||||
|
else err.fileRejected = true;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
download_url: file_code ? `https://byse.sx/d/${file_code}` : null,
|
download_url: file_code ? `https://byse.sx/d/${file_code}` : null,
|
||||||
embed_url: file_code ? `https://byse.sx/e/${file_code}` : null,
|
embed_url: file_code ? `https://byse.sx/e/${file_code}` : null,
|
||||||
@ -232,7 +261,8 @@ function buildMultipart(filePath, formFields) {
|
|||||||
preamble += `${value}\r\n`;
|
preamble += `${value}\r\n`;
|
||||||
}
|
}
|
||||||
preamble += `--${boundary}\r\n`;
|
preamble += `--${boundary}\r\n`;
|
||||||
preamble += `Content-Disposition: form-data; name="file"; filename="${fileName}"\r\n`;
|
const safeFileName = fileName.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||||
|
preamble += `Content-Disposition: form-data; name="file"; filename="${safeFileName}"\r\n`;
|
||||||
preamble += `Content-Type: application/octet-stream\r\n\r\n`;
|
preamble += `Content-Type: application/octet-stream\r\n\r\n`;
|
||||||
|
|
||||||
const epilogue = `\r\n--${boundary}--\r\n`;
|
const epilogue = `\r\n--${boundary}--\r\n`;
|
||||||
@ -280,7 +310,13 @@ async function apiGet(url, signal) {
|
|||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
redirect: 'follow'
|
redirect: 'follow'
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const text = await res.text();
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
data = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
throw new Error(`API-Antwort war kein JSON (HTTP ${res.status}): ${(text || '').slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
|
||||||
if (data.status && [401, 403, 429, 500].includes(data.status)) {
|
if (data.status && [401, 403, 429, 500].includes(data.status)) {
|
||||||
throw new Error(data.msg || data.message || JSON.stringify(data));
|
throw new Error(data.msg || data.message || JSON.stringify(data));
|
||||||
@ -299,7 +335,7 @@ async function getUploadServer(hosterName, hosterConfig, apiKey, signal) {
|
|||||||
|
|
||||||
for (let attempt = 1; attempt <= SERVER_RETRY_ATTEMPTS; attempt++) {
|
for (let attempt = 1; attempt <= SERVER_RETRY_ATTEMPTS; attempt++) {
|
||||||
for (const endpoint of hosterConfig.serverEndpoints) {
|
for (const endpoint of hosterConfig.serverEndpoints) {
|
||||||
const url = `${hosterConfig.apiBase}${endpoint}?key=${apiKey}`;
|
const url = `${hosterConfig.apiBase}${endpoint}?key=${encodeURIComponent(apiKey)}`;
|
||||||
try {
|
try {
|
||||||
const data = await apiGet(url, signal);
|
const data = await apiGet(url, signal);
|
||||||
const uploadUrl = extractUploadServerUrl(data, hosterConfig.apiBase);
|
const uploadUrl = extractUploadServerUrl(data, hosterConfig.apiBase);
|
||||||
@ -342,15 +378,150 @@ async function getUploadServer(hosterName, hosterConfig, apiKey, signal) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (lastMessage) {
|
if (lastMessage) {
|
||||||
throw new Error(`Kein Upload-Server erhalten: ${lastMessage}`);
|
const e = new Error(`Kein Upload-Server erhalten: ${lastMessage}`);
|
||||||
|
// "no servers available" / busy / try-again is a transient hoster-side
|
||||||
|
// condition, not an account fault — tag it so the account isn't blacklisted.
|
||||||
|
// Genuine auth failures (invalid key / unauthorized / forbidden) make
|
||||||
|
// shouldRetryServerLookup return false and stay classified as account errors.
|
||||||
|
if (shouldRetryServerLookup(lastMessage)) e.hosterTransient = true;
|
||||||
|
throw e;
|
||||||
}
|
}
|
||||||
throw new Error('Kein Upload-Server erhalten. API-Key pruefen.');
|
throw new Error('Kein Upload-Server erhalten. API-Key pruefen.');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function uploadFile(hosterName, filePath, apiKey, onProgress, signal, throttle) {
|
async function _fetchByseFileList(apiKey, signal) {
|
||||||
|
// Byse's file-list endpoint. Returns up to 100 most-recent files — enough
|
||||||
|
// to match the upload we just did against what the server has. The API
|
||||||
|
// shape is typical XFS: { status, msg, result: { files: [...] } } or
|
||||||
|
// { status, msg, files: [...] }.
|
||||||
|
const url = `https://api.byse.sx/api/file/list?key=${encodeURIComponent(apiKey)}&per_page=100&sort=date&order=desc`;
|
||||||
|
try {
|
||||||
|
const { body, statusCode } = await request(url, {
|
||||||
|
method: 'GET', signal,
|
||||||
|
headers: { 'Accept': 'application/json', 'User-Agent': 'multi-hoster-uploader/1.1' },
|
||||||
|
headersTimeout: 30_000, bodyTimeout: 30_000
|
||||||
|
});
|
||||||
|
const text = await body.text();
|
||||||
|
if (statusCode < 200 || statusCode >= 300) return [];
|
||||||
|
const data = JSON.parse(text);
|
||||||
|
const src = Array.isArray(data.files) ? data.files
|
||||||
|
: (data.result && Array.isArray(data.result.files) ? data.result.files
|
||||||
|
: (Array.isArray(data.result) ? data.result : []));
|
||||||
|
return src.map(f => ({
|
||||||
|
file_code: String(f.file_code || f.filecode || '').trim(),
|
||||||
|
file_name: String(f.title || f.name || f.file_name || '').trim()
|
||||||
|
})).filter(f => f.file_code);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _normalizeFileTitle(s) {
|
||||||
|
return String(s || '').toLowerCase().replace(/\.[a-z0-9]+$/i, '').replace(/[^a-z0-9]+/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _resolveByseUploadByName(apiKey, fileName, baselineCodes, signal) {
|
||||||
|
const expected = _normalizeFileTitle(fileName);
|
||||||
|
const POLL_ATTEMPTS = 15;
|
||||||
|
const POLL_DELAY_MS = 2000;
|
||||||
|
for (let i = 0; i < POLL_ATTEMPTS; i++) {
|
||||||
|
if (signal && signal.aborted) return null;
|
||||||
|
const list = await _fetchByseFileList(apiKey, signal);
|
||||||
|
const newFiles = list.filter(f => !baselineCodes.has(f.file_code));
|
||||||
|
// Exact-normalized filename match ONLY. The old fallback ("only one new
|
||||||
|
// file → take it") was unsafe during parallel byse uploads: job A's
|
||||||
|
// poller could claim job B's newly appeared file and return the wrong
|
||||||
|
// URL. At the cost of a few false-negatives when byse mangles the
|
||||||
|
// filename beyond our normalizer, correctness for parallel uploads wins.
|
||||||
|
const match = newFiles.find(f => _normalizeFileTitle(f.file_name) === expected);
|
||||||
|
if (match) {
|
||||||
|
return {
|
||||||
|
download_url: `https://byse.sx/d/${match.file_code}`,
|
||||||
|
embed_url: `https://byse.sx/e/${match.file_code}`,
|
||||||
|
file_code: match.file_code
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (i < POLL_ATTEMPTS - 1) await sleep(POLL_DELAY_MS, signal);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _fetchDoodstreamFileList(apiKey, signal) {
|
||||||
|
// doodapi.co file list: { msg, status:200, result: { files: [{ file_code, title, uploaded, ... }] } }
|
||||||
|
// sort=created&order=desc forces newest-first — VERIFIED against a real 90k-file
|
||||||
|
// account, where a single page without it could miss a just-uploaded file. The
|
||||||
|
// recovery only needs the most recent uploads, so page 1 newest-first suffices.
|
||||||
|
const url = `https://doodapi.co/api/file/list?key=${encodeURIComponent(apiKey)}&per_page=200&sort=created&order=desc`;
|
||||||
|
try {
|
||||||
|
const { body, statusCode } = await request(url, {
|
||||||
|
method: 'GET', signal,
|
||||||
|
headers: { 'Accept': 'application/json', 'User-Agent': 'multi-hoster-uploader/1.1' },
|
||||||
|
headersTimeout: 30_000, bodyTimeout: 30_000
|
||||||
|
});
|
||||||
|
const text = await body.text();
|
||||||
|
if (statusCode < 200 || statusCode >= 300) return [];
|
||||||
|
const data = JSON.parse(text);
|
||||||
|
const files = data && data.result && Array.isArray(data.result.files) ? data.result.files : [];
|
||||||
|
return files.map(f => ({
|
||||||
|
file_code: String(f.file_code || f.filecode || '').trim(),
|
||||||
|
file_name: String(f.title || f.file_name || f.name || '').trim()
|
||||||
|
})).filter(f => f.file_code);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const DOODSTREAM_POLL = { attempts: 12, delayMs: 2500 }; // test-tunable via __test
|
||||||
|
|
||||||
|
async function _resolveDoodstreamUploadByName(apiKey, fileName, baselineCodes, signal) {
|
||||||
|
// Same recovery byse uses: the upload POST returned no filecode, but the file
|
||||||
|
// may register in the account a little later. Poll the list for a NEW file
|
||||||
|
// whose normalized title matches what we uploaded. Exact-name match only
|
||||||
|
// (never "take the only new one") so parallel doodstream uploads can't claim
|
||||||
|
// each other's files.
|
||||||
|
const expected = _normalizeFileTitle(fileName);
|
||||||
|
const POLL_ATTEMPTS = DOODSTREAM_POLL.attempts;
|
||||||
|
const POLL_DELAY_MS = DOODSTREAM_POLL.delayMs;
|
||||||
|
for (let i = 0; i < POLL_ATTEMPTS; i++) {
|
||||||
|
if (signal && signal.aborted) return null;
|
||||||
|
const list = await _fetchDoodstreamFileList(apiKey, signal);
|
||||||
|
const fresh = list.filter(f => !baselineCodes.has(f.file_code));
|
||||||
|
const match = fresh.find(f => _normalizeFileTitle(f.file_name) === expected);
|
||||||
|
if (match) {
|
||||||
|
return {
|
||||||
|
download_url: `https://doodstream.com/d/${match.file_code}`,
|
||||||
|
embed_url: `https://doodstream.com/e/${match.file_code}`,
|
||||||
|
file_code: match.file_code
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (i < POLL_ATTEMPTS - 1) await sleep(POLL_DELAY_MS, signal);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadFile(hosterName, filePath, apiKey, onProgress, signal, throttle, opts) {
|
||||||
const config = HOSTER_CONFIGS[hosterName];
|
const config = HOSTER_CONFIGS[hosterName];
|
||||||
if (!config) throw new Error(`Unbekannter Hoster: ${hosterName}`);
|
if (!config) throw new Error(`Unbekannter Hoster: ${hosterName}`);
|
||||||
|
|
||||||
|
let byseBaseline = null;
|
||||||
|
if (hosterName === 'byse.sx') {
|
||||||
|
if (opts && opts.byseBaseline instanceof Set) {
|
||||||
|
byseBaseline = opts.byseBaseline;
|
||||||
|
} else {
|
||||||
|
const baseline = await _fetchByseFileList(apiKey, signal);
|
||||||
|
byseBaseline = new Set(baseline.map(f => f.file_code));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let doodBaseline = null;
|
||||||
|
if (hosterName === 'doodstream.com') {
|
||||||
|
if (opts && opts.doodBaseline instanceof Set) {
|
||||||
|
doodBaseline = opts.doodBaseline;
|
||||||
|
} else {
|
||||||
|
const baseline = await _fetchDoodstreamFileList(apiKey, signal);
|
||||||
|
doodBaseline = new Set(baseline.map(f => f.file_code));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Step 1: Get upload server
|
// Step 1: Get upload server
|
||||||
const uploadUrl = await getUploadServer(hosterName, config, apiKey, signal);
|
const uploadUrl = await getUploadServer(hosterName, config, apiKey, signal);
|
||||||
|
|
||||||
@ -384,6 +555,16 @@ async function uploadFile(hosterName, filePath, apiKey, onProgress, signal, thro
|
|||||||
`Upload-Antwort von ${hosterName} war kein JSON (HTTP ${statusCode}${snippet ? `): ${snippet}` : ')'}`
|
`Upload-Antwort von ${hosterName} war kein JSON (HTTP ${statusCode}${snippet ? `): ${snippet}` : ')'}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// Normalize valid-but-not-object JSON (JSON.parse('null') → null;
|
||||||
|
// JSON.parse('"foo"') → string; JSON.parse('[1]') → array). Without this
|
||||||
|
// the downstream `payload.msg` / `payload.status` / parseResult(payload)
|
||||||
|
// calls crash with a confusing TypeError instead of letting the existing
|
||||||
|
// fallback defaults kick in. Arrays from servers that return a top-level
|
||||||
|
// list (rare but seen in the wild) are kept addressable as `payload.X`
|
||||||
|
// → undefined, which the parsers already handle.
|
||||||
|
if (payload === null || typeof payload !== 'object') {
|
||||||
|
payload = {};
|
||||||
|
}
|
||||||
|
|
||||||
if (statusCode < 200 || statusCode >= 300) {
|
if (statusCode < 200 || statusCode >= 300) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@ -397,27 +578,101 @@ async function uploadFile(hosterName, filePath, apiKey, onProgress, signal, thro
|
|||||||
throw new Error(payload.msg || payload.message || JSON.stringify(payload));
|
throw new Error(payload.msg || payload.message || JSON.stringify(payload));
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = config.parseResult(payload);
|
let result = null;
|
||||||
if (result?.file_code || result?.download_url || result?.embed_url) {
|
let parseErr = null;
|
||||||
|
try {
|
||||||
|
result = config.parseResult(payload);
|
||||||
|
} catch (err) {
|
||||||
|
if (err && typeof err === 'object' && !err.diagnostic) {
|
||||||
|
try {
|
||||||
|
err.diagnostic = {
|
||||||
|
hoster: hosterName,
|
||||||
|
http: statusCode,
|
||||||
|
contentType: (headers && headers['content-type']) || null,
|
||||||
|
payloadSnippet: JSON.stringify(payload).slice(0, 1000),
|
||||||
|
uploadUrl: targetUrl
|
||||||
|
};
|
||||||
|
} catch { /* JSON cycle — skip diagnostic */ }
|
||||||
|
}
|
||||||
|
parseErr = err;
|
||||||
|
}
|
||||||
|
if (result && (result.file_code || result.download_url || result.embed_url)) {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Byse-specific async handling: server accepts the file but responds with
|
||||||
|
// filecode="" + misleading status ("Not video file format"). The file shows
|
||||||
|
// up in the account shortly after — poll the list to claim it. User observed
|
||||||
|
// this with 2+ GB MKV uploads that appeared as "OK" on the byse dashboard
|
||||||
|
// even after our uploader gave up.
|
||||||
|
if (hosterName === 'byse.sx' && byseBaseline) {
|
||||||
|
const fileName = path.basename(filePath);
|
||||||
|
const polled = await _resolveByseUploadByName(apiKey, fileName, byseBaseline, signal);
|
||||||
|
if (polled) return polled;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Doodstream: the doodapi upload POST returned no filecode (the same backend
|
||||||
|
// hiccup that empties the web form). Poll the account file list by name — if
|
||||||
|
// the file did register, claim its code instead of failing the upload.
|
||||||
|
if (hosterName === 'doodstream.com' && doodBaseline) {
|
||||||
|
const fileName = path.basename(filePath);
|
||||||
|
const polled = await _resolveDoodstreamUploadByName(apiKey, fileName, doodBaseline, signal);
|
||||||
|
if (polled) return polled;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parseErr) throw parseErr;
|
||||||
|
|
||||||
if (payload.success === false) {
|
if (payload.success === false) {
|
||||||
throw new Error(payload.msg || payload.message || `Upload zu ${hosterName} wurde vom Server abgelehnt.`);
|
throw new Error(payload.msg || payload.message || `Upload zu ${hosterName} wurde vom Server abgelehnt.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(
|
// Avoid throwing a bare "OK" / "SUCCESS" as the error message — that happens
|
||||||
payload.msg
|
// when the server says "msg: OK" but ships no file_code anywhere we know
|
||||||
|| payload.message
|
// about, typically an API change. Surface the full (trimmed) payload so
|
||||||
|| `Upload zu ${hosterName} lieferte keine verwendbaren Dateidaten zurueck.`
|
// future logs actually show what the server returned.
|
||||||
);
|
const msg = String(payload.msg || payload.message || '').trim();
|
||||||
|
const isOkishNoPayload = /^(ok|success|done|accepted)$/i.test(msg);
|
||||||
|
if (isOkishNoPayload || !msg) {
|
||||||
|
const snippet = JSON.stringify(payload).slice(0, 400);
|
||||||
|
// 2xx with no filecode: the hoster accepted the upload (bytes sent, status
|
||||||
|
// OK) but returned no usable link. For doodstream this is the API-path
|
||||||
|
// analog of the web empty-form — the backend file-registration timing out
|
||||||
|
// under large-file load. It's a hoster-side flake, NOT an account problem,
|
||||||
|
// so tag it hosterTransient: the upload-manager then fails this file WITHOUT
|
||||||
|
// blacklisting the account (same protection the web path got in 3.3.29) and
|
||||||
|
// the account stays usable for the next retry/batch.
|
||||||
|
const err = new Error(
|
||||||
|
`Upload zu ${hosterName} lieferte keine file_code-Antwort (Payload: ${snippet})`
|
||||||
|
);
|
||||||
|
err.hosterTransient = true;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prefetchBaseline(hosterName, apiKey, signal) {
|
||||||
|
try {
|
||||||
|
if (hosterName === 'byse.sx') {
|
||||||
|
const baseline = await _fetchByseFileList(apiKey, signal);
|
||||||
|
return new Set(baseline.map(f => f.file_code));
|
||||||
|
}
|
||||||
|
if (hosterName === 'doodstream.com') {
|
||||||
|
const baseline = await _fetchDoodstreamFileList(apiKey, signal);
|
||||||
|
return new Set(baseline.map(f => f.file_code));
|
||||||
|
}
|
||||||
|
} catch { /* leave caller to fall back to per-job fetch */ }
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
uploadFile,
|
uploadFile,
|
||||||
|
prefetchBaseline,
|
||||||
HOSTER_CONFIGS,
|
HOSTER_CONFIGS,
|
||||||
__test: {
|
__test: {
|
||||||
extractUploadServerUrl,
|
extractUploadServerUrl,
|
||||||
parseVoeResult
|
parseVoeResult,
|
||||||
|
parseDoodstreamResult,
|
||||||
|
parseByseResult,
|
||||||
|
DOODSTREAM_POLL
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
107
lib/log-mode.js
Normal file
107
lib/log-mode.js
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
// Log-file mode resolution for fileuploader.log:
|
||||||
|
// - "single" → one file: fileuploader.log
|
||||||
|
// - "daily" → per-day: fileuploader-YYYY-MM-DD.log
|
||||||
|
// - "session" → per-launch: fileuploader-session-YYYY-MM-DD_HH-MM-SS-<pid>.log
|
||||||
|
//
|
||||||
|
// Pure functions only — no fs, no Date.now() at call time — so they unit-test
|
||||||
|
// cleanly and the main.js call sites pass in `new Date()` + the session stamp.
|
||||||
|
//
|
||||||
|
// MIGRATION TRAP this lib protects against: the legacy boolean was named
|
||||||
|
// `sessionLog` but actually toggled *daily* mode. A naive rename would silently
|
||||||
|
// flip every per-day user onto per-session. normalizeLogMode below maps the
|
||||||
|
// legacy `sessionLog: true` to "daily", NOT "session". Read logMode everywhere
|
||||||
|
// downstream; do not derive from sessionLog at call sites.
|
||||||
|
//
|
||||||
|
// Loaded both as CommonJS (main.js, tests) and as a browser global
|
||||||
|
// (renderer/app.js via index.html script tag) so a single implementation backs
|
||||||
|
// runtime and tests — same pattern as queue-prune.js / queue-dedup.js.
|
||||||
|
|
||||||
|
(function (root) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const VALID_MODES = new Set(['single', 'daily', 'session']);
|
||||||
|
|
||||||
|
function normalizeLogMode(globalSettings) {
|
||||||
|
const gs = globalSettings && typeof globalSettings === 'object' ? globalSettings : {};
|
||||||
|
if (typeof gs.logMode === 'string' && VALID_MODES.has(gs.logMode)) {
|
||||||
|
return gs.logMode;
|
||||||
|
}
|
||||||
|
// Legacy boolean migration: sessionLog *named* like "session" but actually
|
||||||
|
// implemented "daily" — preserve daily users on the migration path.
|
||||||
|
if (gs.sessionLog === true) return 'daily';
|
||||||
|
return 'single';
|
||||||
|
}
|
||||||
|
|
||||||
|
function _two(n) { return String(n).padStart(2, '0'); }
|
||||||
|
|
||||||
|
function formatDateStamp(date) {
|
||||||
|
return `${date.getFullYear()}-${_two(date.getMonth() + 1)}-${_two(date.getDate())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSessionStamp(date, pid) {
|
||||||
|
const d = `${date.getFullYear()}-${_two(date.getMonth() + 1)}-${_two(date.getDate())}`;
|
||||||
|
const t = `${_two(date.getHours())}-${_two(date.getMinutes())}-${_two(date.getSeconds())}`;
|
||||||
|
// PID disambiguates a same-second close→reopen — a human can't but two
|
||||||
|
// automated runs might. Cheap belt to a suspenders-not-required problem.
|
||||||
|
const pidStr = pid !== undefined && pid !== null ? `-${pid}` : '';
|
||||||
|
return `${d}_${t}${pidStr}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the log filename for the given mode + clock.
|
||||||
|
* @param {Object} args
|
||||||
|
* @param {string} args.baseName e.g. "fileuploader"
|
||||||
|
* @param {string} args.ext e.g. ".log"
|
||||||
|
* @param {string} args.mode "single" | "daily" | "session"
|
||||||
|
* @param {Date} args.date current timestamp
|
||||||
|
* @param {string} [args.sessionId] required when mode === "session"
|
||||||
|
* @returns {string} the bare filename (no directory)
|
||||||
|
*/
|
||||||
|
function resolveLogFileName(args) {
|
||||||
|
const a = args || {};
|
||||||
|
const base = String(a.baseName || 'fileuploader');
|
||||||
|
const ext = String(a.ext || '.log');
|
||||||
|
const mode = VALID_MODES.has(a.mode) ? a.mode : 'single';
|
||||||
|
if (mode === 'single') return `${base}${ext}`;
|
||||||
|
if (mode === 'daily') {
|
||||||
|
const date = a.date instanceof Date ? a.date : new Date();
|
||||||
|
return `${base}-${formatDateStamp(date)}${ext}`;
|
||||||
|
}
|
||||||
|
// session
|
||||||
|
const sid = a.sessionId && String(a.sessionId).trim();
|
||||||
|
if (sid) return `${base}-session-${sid}${ext}`;
|
||||||
|
// Defensive: if a session-id wasn't passed, fall back to single rather
|
||||||
|
// than emit a malformed name. main.js always supplies one.
|
||||||
|
return `${base}${ext}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse of resolveLogFileName: given a full filename like
|
||||||
|
* "fileuploader-2026-06-03.log" or
|
||||||
|
* "fileuploader-session-2026-06-03_18-16-20-8132.log", strip the mode-stamp
|
||||||
|
* so the bare base ("fileuploader.log") remains. Used when persisting an
|
||||||
|
* auto-resolved fallback path back into config — otherwise the saved path
|
||||||
|
* would keep growing a new stamp on every reload.
|
||||||
|
*/
|
||||||
|
function stripModeStampFromFileName(fileName) {
|
||||||
|
if (!fileName || typeof fileName !== 'string') return fileName;
|
||||||
|
// Order matters: session first (longer, more specific) before daily.
|
||||||
|
// Both regexes are anchored to $ with no nested/ambiguous quantifiers, so
|
||||||
|
// matching is linear — the eslint security warning is precautionary.
|
||||||
|
// eslint-disable-next-line security/detect-unsafe-regex
|
||||||
|
const sessionRe = /-session-\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}(?:-\d+)?(\.[^.]+)?$/;
|
||||||
|
// eslint-disable-next-line security/detect-unsafe-regex
|
||||||
|
const dailyRe = /-\d{4}-\d{2}-\d{2}(\.[^.]+)?$/;
|
||||||
|
let out = fileName.replace(sessionRe, (m, ext) => ext || '');
|
||||||
|
out = out.replace(dailyRe, (m, ext) => ext || '');
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const api = { normalizeLogMode, resolveLogFileName, formatDateStamp, formatSessionStamp, stripModeStampFromFileName, VALID_MODES };
|
||||||
|
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = api;
|
||||||
|
} else if (root) {
|
||||||
|
root.LogMode = api;
|
||||||
|
}
|
||||||
|
})(typeof window !== 'undefined' ? window : this);
|
||||||
17
lib/log-policy.js
Normal file
17
lib/log-policy.js
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
// Per-hoster upload-log policy. Decides whether a hoster's successful upload
|
||||||
|
// links get written to fileuploader.log. Pure + dependency-free so it's
|
||||||
|
// trivially unit-testable and shared between the runtime decision and tests.
|
||||||
|
//
|
||||||
|
// Contract: logging is ON unless the hoster's settings explicitly set
|
||||||
|
// logToFile === false. Missing settings / missing hoster / malformed input
|
||||||
|
// all default to ON, so the feature is strictly opt-out and never silently
|
||||||
|
// drops links because a config key wasn't present.
|
||||||
|
|
||||||
|
function hosterLogToFileEnabled(hosterSettings, hoster) {
|
||||||
|
if (!hosterSettings || typeof hosterSettings !== 'object') return true;
|
||||||
|
const hs = hosterSettings[hoster];
|
||||||
|
if (!hs || typeof hs !== 'object') return true;
|
||||||
|
return hs.logToFile !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { hosterLogToFileEnabled };
|
||||||
52
lib/log-rotation.js
Normal file
52
lib/log-rotation.js
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
// Generic numbered-backup log rotation. Used by the upload log + can be
|
||||||
|
// reused by other long-lived log files (debug log, account-rotation log).
|
||||||
|
//
|
||||||
|
// Behaviour:
|
||||||
|
// - File missing → no-op, returns false.
|
||||||
|
// - File ≤ maxBytes → no-op, returns false.
|
||||||
|
// - File > maxBytes → drop oldest .N backup, shift .K → .K+1, rename live
|
||||||
|
// file to .1, return true. Caller (or the next append) creates a fresh
|
||||||
|
// primary on demand.
|
||||||
|
//
|
||||||
|
// Errors are reported via `log` (e.g. debugLog) but never thrown — rotation
|
||||||
|
// is best-effort; the caller's append happens anyway.
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
function maybeRotateLogFile(filePath, maxBytes, maxBackups = 3, log = () => {}) {
|
||||||
|
if (!filePath || !Number.isFinite(maxBytes) || maxBytes <= 0) return false;
|
||||||
|
let size = 0;
|
||||||
|
try {
|
||||||
|
const st = fs.statSync(filePath);
|
||||||
|
size = st.size;
|
||||||
|
} catch (err) {
|
||||||
|
// ENOENT is normal — nothing to rotate yet.
|
||||||
|
if (err && err.code !== 'ENOENT') {
|
||||||
|
log(`logRotation: stat ${filePath} failed: ${err.message}`);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (size <= maxBytes) return false;
|
||||||
|
|
||||||
|
const ext = path.extname(filePath);
|
||||||
|
const base = filePath.slice(0, filePath.length - ext.length);
|
||||||
|
|
||||||
|
// Drop the oldest backup if it exists, then shift each numbered backup up
|
||||||
|
// one slot. Errors are ignored: missing intermediate backups are normal,
|
||||||
|
// failed renames just mean we'll rotate again next time.
|
||||||
|
try { fs.unlinkSync(`${base}.${maxBackups}${ext}`); } catch {}
|
||||||
|
for (let i = maxBackups - 1; i >= 1; i--) {
|
||||||
|
try { fs.renameSync(`${base}.${i}${ext}`, `${base}.${i + 1}${ext}`); } catch {}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
fs.renameSync(filePath, `${base}.1${ext}`);
|
||||||
|
log(`logRotation: rotated ${filePath} (${(size / 1024 / 1024).toFixed(1)} MB) → ${base}.1${ext}`);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
log(`logRotation: rename ${filePath} → ${base}.1${ext} failed: ${err.message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { maybeRotateLogFile };
|
||||||
65
lib/queue-dedup.js
Normal file
65
lib/queue-dedup.js
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
// Startup queue auto-dedup logic. Extracted from renderer/app.js
|
||||||
|
// _autoDeduplicateFromLog so the decision can be unit-tested without a DOM or
|
||||||
|
// the renderer's module-level state.
|
||||||
|
//
|
||||||
|
// Loaded both as a CommonJS module (Node tests) and as a browser global
|
||||||
|
// (renderer/app.js via index.html script tag) so a single implementation backs
|
||||||
|
// runtime and tests — no drift.
|
||||||
|
//
|
||||||
|
// Behaviour: on launch the restored queue is compared against the lifetime
|
||||||
|
// upload log. ONLY genuinely-completed ('done') jobs that also appear in the
|
||||||
|
// log are dropped — that's pure decluttering of work that already finished.
|
||||||
|
//
|
||||||
|
// Pending jobs (preview / queued) and failed ones (error / aborted) are NEVER
|
||||||
|
// dropped here, even if a same-name+hoster line exists in the log. Those are
|
||||||
|
// work the user intentionally has queued (often a deliberate re-upload of a
|
||||||
|
// file that was uploaded before). The old code filtered on log-presence alone,
|
||||||
|
// regardless of status, so the ENTIRE restored queue vanished on the next
|
||||||
|
// restart/update whenever the files had been uploaded previously — surfacing as
|
||||||
|
// an empty "Dateien hierhin ziehen oder klicken" queue. Manual log import
|
||||||
|
// (importUploadLog) stays separate and explicit for users who do want bulk
|
||||||
|
// dedup of pending jobs.
|
||||||
|
|
||||||
|
(function (root) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
function _key(fileName, hoster) {
|
||||||
|
return `${String(fileName).toLowerCase()}|${String(hoster).toLowerCase()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Partition restored queue jobs into kept vs removed, given lifetime log
|
||||||
|
* entries. Removes only 'done' jobs whose fileName|hoster is in the log.
|
||||||
|
* @param {Array<{status:string,fileName:string,hoster:string}>} jobs
|
||||||
|
* @param {Array<{fileName:string,hoster:string}>} logEntries
|
||||||
|
* @returns {{ kept: Array, removed: Array }}
|
||||||
|
*/
|
||||||
|
function partitionRestoredJobsByLog(jobs, logEntries) {
|
||||||
|
const kept = [];
|
||||||
|
const removed = [];
|
||||||
|
if (!Array.isArray(jobs) || jobs.length === 0) return { kept, removed };
|
||||||
|
|
||||||
|
const logKeys = new Set();
|
||||||
|
for (const e of (Array.isArray(logEntries) ? logEntries : [])) {
|
||||||
|
if (e && e.fileName && e.hoster) logKeys.add(_key(e.fileName, e.hoster));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const job of jobs) {
|
||||||
|
const isDone = job && job.status === 'done' && job.fileName && job.hoster;
|
||||||
|
if (isDone && logKeys.has(_key(job.fileName, job.hoster))) {
|
||||||
|
removed.push(job);
|
||||||
|
} else {
|
||||||
|
kept.push(job);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { kept, removed };
|
||||||
|
}
|
||||||
|
|
||||||
|
const api = { partitionRestoredJobsByLog };
|
||||||
|
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = api;
|
||||||
|
} else if (root) {
|
||||||
|
root.QueueDedup = api;
|
||||||
|
}
|
||||||
|
})(typeof window !== 'undefined' ? window : this);
|
||||||
59
lib/queue-prune.js
Normal file
59
lib/queue-prune.js
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
// Queue auto-prune logic. Extracted from renderer/app.js handleBatchDone so
|
||||||
|
// the algorithm can be unit-tested without needing a DOM or the renderer's
|
||||||
|
// module-level state (queueJobs, _jobIndexById).
|
||||||
|
//
|
||||||
|
// Loaded both as a CommonJS module (Node tests) and as a browser global
|
||||||
|
// (renderer/app.js via index.html script tag) so the same single
|
||||||
|
// implementation backs both runtime and tests — no drift between them.
|
||||||
|
//
|
||||||
|
// Behaviour: when the number of terminal-status jobs (done / skipped /
|
||||||
|
// error / aborted) in the queue exceeds `limit`, drop the oldest terminal
|
||||||
|
// jobs (insertion order) until we're back at the limit. Non-terminal jobs
|
||||||
|
// (queued / preview / uploading / retrying / getting-server) are always
|
||||||
|
// kept — those are work the user can still act on. Without this cap a
|
||||||
|
// long session accumulates thousands of done rows and every render becomes
|
||||||
|
// O(N) on a perpetually-growing N.
|
||||||
|
|
||||||
|
(function (root) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const TERMINAL_STATUSES = new Set(['done', 'skipped', 'error', 'aborted']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute which jobs to keep vs drop, given a queue and a terminal-jobs cap.
|
||||||
|
* @param {Array<{id: string, status: string}>} jobs the current queue
|
||||||
|
* @param {number} limit max terminal jobs to keep
|
||||||
|
* @returns {null | { kept: Array, dropped: Array }} null when nothing changed
|
||||||
|
*/
|
||||||
|
function pruneOldestTerminalJobs(jobs, limit) {
|
||||||
|
if (!Array.isArray(jobs) || jobs.length === 0) return null;
|
||||||
|
if (!Number.isFinite(limit) || limit < 0) return null;
|
||||||
|
|
||||||
|
// Walk once, record indices of terminal jobs in insertion order.
|
||||||
|
const terminalIdxs = [];
|
||||||
|
for (let i = 0; i < jobs.length; i++) {
|
||||||
|
const j = jobs[i];
|
||||||
|
if (j && TERMINAL_STATUSES.has(j.status)) terminalIdxs.push(i);
|
||||||
|
}
|
||||||
|
if (terminalIdxs.length <= limit) return null;
|
||||||
|
|
||||||
|
const dropCount = terminalIdxs.length - limit;
|
||||||
|
const dropSet = new Set(terminalIdxs.slice(0, dropCount));
|
||||||
|
|
||||||
|
const kept = [];
|
||||||
|
const dropped = [];
|
||||||
|
for (let i = 0; i < jobs.length; i++) {
|
||||||
|
if (dropSet.has(i)) dropped.push(jobs[i]);
|
||||||
|
else kept.push(jobs[i]);
|
||||||
|
}
|
||||||
|
return { kept, dropped };
|
||||||
|
}
|
||||||
|
|
||||||
|
const api = { pruneOldestTerminalJobs, TERMINAL_STATUSES };
|
||||||
|
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = api;
|
||||||
|
} else if (root) {
|
||||||
|
root.QueuePrune = api;
|
||||||
|
}
|
||||||
|
})(typeof window !== 'undefined' ? window : this);
|
||||||
23
lib/remote-capture-preload.js
Normal file
23
lib/remote-capture-preload.js
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
const { contextBridge, ipcRenderer } = require('electron');
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld('capture', {
|
||||||
|
// Get capture source ID from main process (desktopCapturer runs in main)
|
||||||
|
getSourceId: () => ipcRenderer.invoke('remote:get-capture-source-id'),
|
||||||
|
|
||||||
|
// Signaling: receive offer/ICE from main process (relayed from dashboard)
|
||||||
|
onSignaling: (callback) => {
|
||||||
|
ipcRenderer.on('remote:signaling-to-capture', (_event, data) => callback(data));
|
||||||
|
},
|
||||||
|
|
||||||
|
// Signaling: send answer/ICE back to main process (relayed to dashboard)
|
||||||
|
sendSignaling: (data) => ipcRenderer.send('remote:signaling-from-capture', data),
|
||||||
|
|
||||||
|
// Input: forward input events from DataChannel to main process
|
||||||
|
sendInput: (data) => ipcRenderer.send('remote:input-event', data),
|
||||||
|
|
||||||
|
// Notify main process of client connection/disconnection
|
||||||
|
notifyClientCount: (count) => ipcRenderer.send('remote:client-count', count),
|
||||||
|
|
||||||
|
// Debug logging to main process
|
||||||
|
log: (...args) => ipcRenderer.send('remote:capture-log', args.join(' '))
|
||||||
|
});
|
||||||
150
lib/remote-capture.html
Normal file
150
lib/remote-capture.html
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head><title>Remote Capture</title></head>
|
||||||
|
<body>
|
||||||
|
<script>
|
||||||
|
// Maps clientId -> { pc: RTCPeerConnection, dc: RTCDataChannel }
|
||||||
|
const clients = new Map();
|
||||||
|
let captureStream = null;
|
||||||
|
|
||||||
|
async function getCaptureStream() {
|
||||||
|
if (captureStream) return captureStream;
|
||||||
|
|
||||||
|
// desktopCapturer runs in main process (Electron 33+), we get the source ID via IPC
|
||||||
|
const sourceId = await window.capture.getSourceId();
|
||||||
|
window.capture.log('getSourceId returned:', sourceId || 'NULL');
|
||||||
|
if (!sourceId) throw new Error('No capture source ID from main process');
|
||||||
|
|
||||||
|
try {
|
||||||
|
captureStream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
audio: false,
|
||||||
|
video: {
|
||||||
|
mandatory: {
|
||||||
|
chromeMediaSource: 'desktop',
|
||||||
|
chromeMediaSourceId: sourceId,
|
||||||
|
maxFrameRate: 15
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const tracks = captureStream.getTracks();
|
||||||
|
window.capture.log('getUserMedia OK, tracks:', tracks.length, tracks.map(t => `${t.kind}:${t.readyState}`).join(','));
|
||||||
|
return captureStream;
|
||||||
|
} catch (err) {
|
||||||
|
window.capture.log('getUserMedia FAILED:', err.message);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleOffer(clientId, offer, role) {
|
||||||
|
window.capture.log('handleOffer called for', clientId);
|
||||||
|
let stream;
|
||||||
|
try {
|
||||||
|
stream = await getCaptureStream();
|
||||||
|
} catch (err) {
|
||||||
|
window.capture.log('FATAL: getCaptureStream failed:', err.message);
|
||||||
|
// Send diagnostic back to dashboard
|
||||||
|
window.capture.sendSignaling({ type: 'capture-error', clientId, error: err.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pc = new RTCPeerConnection({
|
||||||
|
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
|
||||||
|
});
|
||||||
|
clients.set(clientId, { pc, role });
|
||||||
|
|
||||||
|
// Add video tracks
|
||||||
|
const tracks = stream.getTracks();
|
||||||
|
window.capture.log('Adding', tracks.length, 'tracks to peer connection');
|
||||||
|
for (const track of tracks) {
|
||||||
|
window.capture.log('addTrack:', track.kind, track.label, track.readyState);
|
||||||
|
pc.addTrack(track, stream);
|
||||||
|
}
|
||||||
|
window.capture.log('Senders after addTrack:', pc.getSenders().length);
|
||||||
|
|
||||||
|
// Handle DataChannel from dashboard (dashboard creates it as offerer)
|
||||||
|
pc.ondatachannel = (event) => {
|
||||||
|
const dc = event.channel;
|
||||||
|
clients.get(clientId).dc = dc;
|
||||||
|
dc.onmessage = (msg) => {
|
||||||
|
try {
|
||||||
|
const input = JSON.parse(msg.data);
|
||||||
|
input.clientId = clientId;
|
||||||
|
input.role = role;
|
||||||
|
window.capture.sendInput(input);
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// ICE candidates — serialize to plain object (WebRTC objects don't survive IPC)
|
||||||
|
pc.onicecandidate = (event) => {
|
||||||
|
if (event.candidate) {
|
||||||
|
window.capture.sendSignaling({
|
||||||
|
type: 'ice-candidate',
|
||||||
|
clientId,
|
||||||
|
candidate: {
|
||||||
|
candidate: event.candidate.candidate,
|
||||||
|
sdpMid: event.candidate.sdpMid,
|
||||||
|
sdpMLineIndex: event.candidate.sdpMLineIndex,
|
||||||
|
usernameFragment: event.candidate.usernameFragment
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
pc.onconnectionstatechange = () => {
|
||||||
|
if (pc.connectionState === 'disconnected' || pc.connectionState === 'failed') {
|
||||||
|
removeClient(clientId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await pc.setRemoteDescription(new RTCSessionDescription(offer));
|
||||||
|
const answer = await pc.createAnswer();
|
||||||
|
await pc.setLocalDescription(answer);
|
||||||
|
|
||||||
|
// Serialize to plain object (RTCSessionDescription doesn't survive IPC)
|
||||||
|
window.capture.sendSignaling({
|
||||||
|
type: 'answer',
|
||||||
|
clientId,
|
||||||
|
answer: { type: pc.localDescription.type, sdp: pc.localDescription.sdp }
|
||||||
|
});
|
||||||
|
|
||||||
|
window.capture.notifyClientCount(clients.size);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleIceCandidate(clientId, candidate) {
|
||||||
|
const client = clients.get(clientId);
|
||||||
|
if (client && client.pc) {
|
||||||
|
client.pc.addIceCandidate(new RTCIceCandidate(candidate)).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeClient(clientId) {
|
||||||
|
const client = clients.get(clientId);
|
||||||
|
if (client) {
|
||||||
|
if (client.dc) client.dc.close();
|
||||||
|
client.pc.close();
|
||||||
|
clients.delete(clientId);
|
||||||
|
window.capture.notifyClientCount(clients.size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for signaling messages from main process
|
||||||
|
window.capture.onSignaling((data) => {
|
||||||
|
switch (data.type) {
|
||||||
|
case 'offer':
|
||||||
|
handleOffer(data.clientId, data.offer, data.role).catch(err => {
|
||||||
|
console.error('Failed to handle offer:', err);
|
||||||
|
window.capture.sendSignaling({ type: 'error', clientId: data.clientId, error: err.message });
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'ice-candidate':
|
||||||
|
handleIceCandidate(data.clientId, data.candidate);
|
||||||
|
break;
|
||||||
|
case 'client-disconnected':
|
||||||
|
removeClient(data.clientId);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
184
lib/remote-server.js
Normal file
184
lib/remote-server.js
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
const { WebSocketServer } = require('ws');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
class RemoteServer {
|
||||||
|
constructor() {
|
||||||
|
this._wss = null;
|
||||||
|
this._clients = new Map(); // ws -> { id, role, authenticated }
|
||||||
|
this._config = null;
|
||||||
|
this._failedAttempts = new Map(); // ip -> { count, blockedUntil }
|
||||||
|
}
|
||||||
|
|
||||||
|
start(opts) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this._config = opts;
|
||||||
|
|
||||||
|
this._wss = new WebSocketServer({ port: opts.port }, () => {
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
this._wss.on('error', (err) => {
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
|
||||||
|
this._wss.on('connection', (ws, req) => {
|
||||||
|
this._handleConnection(ws, req);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
if (this._wss) {
|
||||||
|
for (const [ws] of this._clients) {
|
||||||
|
ws.close(1000, 'Server shutting down');
|
||||||
|
}
|
||||||
|
this._clients.clear();
|
||||||
|
this._wss.close();
|
||||||
|
this._wss = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getClientCount() {
|
||||||
|
let count = 0;
|
||||||
|
for (const [, client] of this._clients) {
|
||||||
|
if (client.authenticated) count++;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
getPort() {
|
||||||
|
if (this._wss && this._wss.address()) {
|
||||||
|
return this._wss.address().port;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
_handleConnection(ws, req) {
|
||||||
|
const ip = req.socket.remoteAddress || 'unknown';
|
||||||
|
|
||||||
|
if (this._isBlocked(ip)) {
|
||||||
|
ws.close(4003, 'Too many failed attempts');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientId = crypto.randomUUID();
|
||||||
|
this._clients.set(ws, { id: clientId, role: null, authenticated: false });
|
||||||
|
|
||||||
|
let authReceived = false;
|
||||||
|
const authTimeout = setTimeout(() => {
|
||||||
|
if (!authReceived) {
|
||||||
|
ws.close(4001, 'Auth timeout');
|
||||||
|
this._clients.delete(ws);
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
ws.on('message', (raw) => {
|
||||||
|
let msg;
|
||||||
|
try { msg = JSON.parse(raw); } catch { return; }
|
||||||
|
|
||||||
|
const client = this._clients.get(ws);
|
||||||
|
if (!client) return;
|
||||||
|
|
||||||
|
if (!client.authenticated) {
|
||||||
|
authReceived = true;
|
||||||
|
clearTimeout(authTimeout);
|
||||||
|
|
||||||
|
if (msg.type === 'auth' && msg.token === this._config.token) {
|
||||||
|
client.authenticated = true;
|
||||||
|
client.role = msg.role || 'viewer';
|
||||||
|
ws.send(JSON.stringify({ type: 'auth-ok', clientId }));
|
||||||
|
|
||||||
|
if (this.getClientCount() === 1) {
|
||||||
|
this._config.onCreateCaptureWindow();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this._recordFailedAttempt(ip);
|
||||||
|
ws.close(4002, 'Invalid token');
|
||||||
|
this._clients.delete(ws);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === 'offer' || msg.type === 'ice-candidate') {
|
||||||
|
msg.clientId = client.id;
|
||||||
|
msg.role = client.role;
|
||||||
|
this._config.onSignalingToCapture(msg);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('close', () => {
|
||||||
|
clearTimeout(authTimeout);
|
||||||
|
const client = this._clients.get(ws);
|
||||||
|
const wasAuthenticated = client && client.authenticated;
|
||||||
|
this._clients.delete(ws);
|
||||||
|
|
||||||
|
if (wasAuthenticated) {
|
||||||
|
this._config.onSignalingToCapture({
|
||||||
|
type: 'client-disconnected',
|
||||||
|
clientId: client.id
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.getClientCount() === 0) {
|
||||||
|
this._config.onDestroyCaptureWindow();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('error', () => {
|
||||||
|
clearTimeout(authTimeout);
|
||||||
|
const client = this._clients.get(ws);
|
||||||
|
const wasAuthenticated = client && client.authenticated;
|
||||||
|
this._clients.delete(ws);
|
||||||
|
|
||||||
|
if (wasAuthenticated) {
|
||||||
|
this._config.onSignalingToCapture({
|
||||||
|
type: 'client-disconnected',
|
||||||
|
clientId: client.id
|
||||||
|
});
|
||||||
|
if (this.getClientCount() === 0) {
|
||||||
|
this._config.onDestroyCaptureWindow();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
sendToClient(clientId, data) {
|
||||||
|
for (const [ws, client] of this._clients) {
|
||||||
|
if (client.id === clientId && client.authenticated) {
|
||||||
|
ws.send(JSON.stringify(data));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcast(data) {
|
||||||
|
const msg = JSON.stringify(data);
|
||||||
|
for (const [ws, client] of this._clients) {
|
||||||
|
if (client.authenticated && ws.readyState === 1) {
|
||||||
|
ws.send(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_isBlocked(ip) {
|
||||||
|
const entry = this._failedAttempts.get(ip);
|
||||||
|
if (!entry) return false;
|
||||||
|
if (entry.blockedUntil && Date.now() < entry.blockedUntil) return true;
|
||||||
|
if (entry.blockedUntil && Date.now() >= entry.blockedUntil) {
|
||||||
|
this._failedAttempts.delete(ip);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_recordFailedAttempt(ip) {
|
||||||
|
const entry = this._failedAttempts.get(ip) || { count: 0, blockedUntil: null };
|
||||||
|
entry.count++;
|
||||||
|
if (entry.count >= 5) {
|
||||||
|
entry.blockedUntil = Date.now() + 60000;
|
||||||
|
}
|
||||||
|
this._failedAttempts.set(ip, entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = RemoteServer;
|
||||||
75
lib/secret-store.js
Normal file
75
lib/secret-store.js
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
// Wraps Electron's safeStorage (OS-level credential encryption: DPAPI on
|
||||||
|
// Windows, Keychain on macOS, libsecret on Linux) to keep hoster passwords and
|
||||||
|
// API keys out of the plaintext electron-config.json.
|
||||||
|
//
|
||||||
|
// On Windows the DPAPI key is tied to the current user profile, so credentials
|
||||||
|
// encrypted here are only readable by the same Windows user. For backups we
|
||||||
|
// export to plaintext (the .mhu envelope has its own AES-GCM layer) so moving
|
||||||
|
// between machines/users works transparently.
|
||||||
|
|
||||||
|
const SENTINEL = 'enc:v1:';
|
||||||
|
const CRED_FIELDS = ['password', 'apiKey'];
|
||||||
|
|
||||||
|
let _safeStorageCache = undefined;
|
||||||
|
function getSafeStorage() {
|
||||||
|
if (_safeStorageCache !== undefined) return _safeStorageCache;
|
||||||
|
try {
|
||||||
|
const { safeStorage } = require('electron');
|
||||||
|
if (safeStorage && typeof safeStorage.isEncryptionAvailable === 'function'
|
||||||
|
&& safeStorage.isEncryptionAvailable()) {
|
||||||
|
_safeStorageCache = safeStorage;
|
||||||
|
return _safeStorageCache;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
_safeStorageCache = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEncrypted(value) {
|
||||||
|
return typeof value === 'string' && value.startsWith(SENTINEL);
|
||||||
|
}
|
||||||
|
|
||||||
|
function encryptField(value) {
|
||||||
|
if (!value || typeof value !== 'string') return value;
|
||||||
|
if (isEncrypted(value)) return value;
|
||||||
|
const ss = getSafeStorage();
|
||||||
|
if (!ss) return value;
|
||||||
|
try {
|
||||||
|
const buf = ss.encryptString(value);
|
||||||
|
return SENTINEL + buf.toString('base64');
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function decryptField(value) {
|
||||||
|
if (!value || typeof value !== 'string') return value;
|
||||||
|
if (!isEncrypted(value)) return value;
|
||||||
|
const ss = getSafeStorage();
|
||||||
|
if (!ss) return '';
|
||||||
|
try {
|
||||||
|
const buf = Buffer.from(value.slice(SENTINEL.length), 'base64');
|
||||||
|
return ss.decryptString(buf);
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapHosterAccounts(config, fn) {
|
||||||
|
if (!config || !config.hosters || typeof config.hosters !== 'object') return config;
|
||||||
|
for (const accounts of Object.values(config.hosters)) {
|
||||||
|
if (!Array.isArray(accounts)) continue;
|
||||||
|
for (const acc of accounts) {
|
||||||
|
if (!acc || typeof acc !== 'object') continue;
|
||||||
|
for (const f of CRED_FIELDS) {
|
||||||
|
if (acc[f]) acc[f] = fn(acc[f]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
function encryptCredentials(config) { return mapHosterAccounts(config, encryptField); }
|
||||||
|
function decryptCredentials(config) { return mapHosterAccounts(config, decryptField); }
|
||||||
|
|
||||||
|
module.exports = { encryptField, decryptField, encryptCredentials, decryptCredentials, isEncrypted };
|
||||||
142
lib/stats.js
Normal file
142
lib/stats.js
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
(function (root) {
|
||||||
|
function summarizePerHoster(history, opts) {
|
||||||
|
const out = {};
|
||||||
|
if (!Array.isArray(history)) return out;
|
||||||
|
const cutoff = opts && Number.isFinite(opts.sinceMs) ? opts.sinceMs : null;
|
||||||
|
const limitBatches = opts && Number.isFinite(opts.lastNBatches) && opts.lastNBatches > 0 ? opts.lastNBatches : null;
|
||||||
|
|
||||||
|
const entries = [...history];
|
||||||
|
entries.sort((a, b) => {
|
||||||
|
const ta = a && a.timestamp ? Date.parse(a.timestamp) : 0;
|
||||||
|
const tb = b && b.timestamp ? Date.parse(b.timestamp) : 0;
|
||||||
|
return tb - ta;
|
||||||
|
});
|
||||||
|
const sliced = limitBatches ? entries.slice(0, limitBatches) : entries;
|
||||||
|
|
||||||
|
for (const batch of sliced) {
|
||||||
|
if (!batch || !Array.isArray(batch.files)) continue;
|
||||||
|
if (cutoff !== null) {
|
||||||
|
const ts = batch.timestamp ? Date.parse(batch.timestamp) : 0;
|
||||||
|
if (!ts || ts < cutoff) continue;
|
||||||
|
}
|
||||||
|
for (const file of batch.files) {
|
||||||
|
if (!file || !Array.isArray(file.results)) continue;
|
||||||
|
for (const r of file.results) {
|
||||||
|
if (!r || !r.hoster) continue;
|
||||||
|
const bucket = out[r.hoster] || (out[r.hoster] = { ok: 0, fail: 0, total: 0 });
|
||||||
|
bucket.total++;
|
||||||
|
if (r.status === 'done') bucket.ok++;
|
||||||
|
else bucket.fail++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const h of Object.keys(out)) {
|
||||||
|
const b = out[h];
|
||||||
|
b.rate = b.total > 0 ? b.ok / b.total : null;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function classifyErrorCategory(err) {
|
||||||
|
if (!err || typeof err !== 'string') return 'unknown';
|
||||||
|
const s = err.toLowerCase();
|
||||||
|
if (/abgebrochen|aborted|cancel/.test(s)) return 'aborted';
|
||||||
|
if (/not video file format|kein videoformat|invalid file|wrong format|duplicate|already exists|file too (small|big|large)|datei zu (gro|klein)/.test(s)) return 'file-rejected';
|
||||||
|
if (/quota|storage (full|exhausted|voll)|account (full|banned|suspended)|disk (space )?full|insufficient (disk )?space|not enough (disk )?(space|storage)/.test(s)) return 'account-error';
|
||||||
|
if (/csrf|kein upload-server|server.*?(busy|unavailable|try again)|no servers available|filecode|kein filecode|empty.*?(form|response)/.test(s)) return 'hoster-transient';
|
||||||
|
if (/timeout|econnreset|enotfound|fetch failed|network|socket hang up|abort/.test(s)) return 'network';
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeBatchErrors(batchSummary) {
|
||||||
|
const buckets = {
|
||||||
|
'file-rejected': [],
|
||||||
|
'account-error': [],
|
||||||
|
'hoster-transient': [],
|
||||||
|
'network': [],
|
||||||
|
'unknown': [],
|
||||||
|
'aborted': []
|
||||||
|
};
|
||||||
|
if (!batchSummary || !Array.isArray(batchSummary.files)) return buckets;
|
||||||
|
for (const f of batchSummary.files) {
|
||||||
|
if (!f || !Array.isArray(f.results)) continue;
|
||||||
|
for (const r of f.results) {
|
||||||
|
if (!r || r.status === 'done') continue;
|
||||||
|
const cat = classifyErrorCategory(r.error);
|
||||||
|
buckets[cat].push({
|
||||||
|
fileName: f.name || f.fileName || '',
|
||||||
|
hoster: r.hoster || '',
|
||||||
|
error: r.error || '',
|
||||||
|
jobId: r.jobId || null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return buckets;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RETRYABLE_CATEGORIES = new Set(['hoster-transient', 'network', 'unknown']);
|
||||||
|
function isRetryableCategory(cat) {
|
||||||
|
return RETRYABLE_CATEGORIES.has(cat);
|
||||||
|
}
|
||||||
|
|
||||||
|
const CATEGORY_LABELS = {
|
||||||
|
'file-rejected': 'Datei abgelehnt',
|
||||||
|
'account-error': 'Account-Problem',
|
||||||
|
'hoster-transient': 'Hoster-Flake',
|
||||||
|
'network': 'Netzwerk',
|
||||||
|
'unknown': 'Unbekannt',
|
||||||
|
'aborted': 'Abgebrochen'
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatLinks(rows, format) {
|
||||||
|
if (!Array.isArray(rows)) return '';
|
||||||
|
const safe = rows.filter(r => r && r.url);
|
||||||
|
if (safe.length === 0) return '';
|
||||||
|
switch (format) {
|
||||||
|
case 'plain':
|
||||||
|
return safe.map(r => r.url).join('\n');
|
||||||
|
case 'bbcode':
|
||||||
|
return safe.map(r => {
|
||||||
|
const label = r.fileName || r.hoster || r.url;
|
||||||
|
return `[url=${r.url}]${label}[/url]`;
|
||||||
|
}).join('\n');
|
||||||
|
case 'markdown':
|
||||||
|
return safe.map(r => {
|
||||||
|
const label = r.fileName || r.hoster || r.url;
|
||||||
|
return `- [${label}](${r.url})`;
|
||||||
|
}).join('\n');
|
||||||
|
case 'html':
|
||||||
|
return safe.map(r => {
|
||||||
|
const label = r.fileName || r.hoster || r.url;
|
||||||
|
return `<a href="${r.url}">${label}</a>`;
|
||||||
|
}).join('\n');
|
||||||
|
case 'csv': {
|
||||||
|
const head = 'fileName,hoster,url\n';
|
||||||
|
return head + safe.map(r => {
|
||||||
|
const esc = (v) => `"${String(v || '').replace(/"/g, '""')}"`;
|
||||||
|
return [esc(r.fileName), esc(r.hoster), esc(r.url)].join(',');
|
||||||
|
}).join('\n');
|
||||||
|
}
|
||||||
|
case 'json':
|
||||||
|
return JSON.stringify(safe.map(r => ({ fileName: r.fileName || '', hoster: r.hoster || '', url: r.url })), null, 2);
|
||||||
|
default:
|
||||||
|
return safe.map(r => r.url).join('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const api = {
|
||||||
|
summarizePerHoster,
|
||||||
|
classifyErrorCategory,
|
||||||
|
summarizeBatchErrors,
|
||||||
|
isRetryableCategory,
|
||||||
|
RETRYABLE_CATEGORIES,
|
||||||
|
CATEGORY_LABELS,
|
||||||
|
formatLinks
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = api;
|
||||||
|
} else if (root) {
|
||||||
|
root.Stats = api;
|
||||||
|
}
|
||||||
|
})(typeof window !== 'undefined' ? window : this);
|
||||||
64
lib/support-bundle.js
Normal file
64
lib/support-bundle.js
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
const CRED_KEYS = new Set(['password', 'apiKey', 'token', 'cookie', 'sessionId']);
|
||||||
|
const REDACTED = '<redacted>';
|
||||||
|
|
||||||
|
function sanitizeConfig(config) {
|
||||||
|
if (!config || typeof config !== 'object') return config;
|
||||||
|
const clone = JSON.parse(JSON.stringify(config));
|
||||||
|
(function walk(o) {
|
||||||
|
if (!o) return;
|
||||||
|
if (Array.isArray(o)) { for (const e of o) walk(e); return; }
|
||||||
|
if (typeof o !== 'object') return;
|
||||||
|
for (const k of Object.keys(o)) {
|
||||||
|
if (CRED_KEYS.has(k) && typeof o[k] === 'string' && o[k]) o[k] = REDACTED;
|
||||||
|
else walk(o[k]);
|
||||||
|
}
|
||||||
|
})(clone);
|
||||||
|
return clone;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectFile(filePath, label, maxBytes) {
|
||||||
|
if (!filePath) return `=== ${label} ===\n<no path configured>\n\n`;
|
||||||
|
let stat;
|
||||||
|
try { stat = fs.statSync(filePath); }
|
||||||
|
catch (err) {
|
||||||
|
if (err && err.code === 'ENOENT') return `=== ${label} (${filePath}) ===\n<file does not exist yet>\n\n`;
|
||||||
|
return `=== ${label} (${filePath}) ===\n<stat error: ${err.message}>\n\n`;
|
||||||
|
}
|
||||||
|
const cap = Number.isFinite(maxBytes) && maxBytes > 0 ? maxBytes : 5 * 1024 * 1024;
|
||||||
|
let content;
|
||||||
|
try {
|
||||||
|
if (stat.size > cap) {
|
||||||
|
const fd = fs.openSync(filePath, 'r');
|
||||||
|
const buf = Buffer.alloc(cap);
|
||||||
|
fs.readSync(fd, buf, 0, cap, stat.size - cap);
|
||||||
|
fs.closeSync(fd);
|
||||||
|
const skipped = stat.size - cap;
|
||||||
|
content = `<truncated: skipped first ${skipped} bytes; showing last ${cap} bytes of ${stat.size}>\n` + buf.toString('utf-8');
|
||||||
|
} else {
|
||||||
|
content = fs.readFileSync(filePath, 'utf-8');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
content = `<read error: ${err.message}>`;
|
||||||
|
}
|
||||||
|
return `=== ${label} (${filePath}, size=${stat.size} bytes) ===\n${content}\n\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSupportBundleText({ header, sanitizedConfig, files }) {
|
||||||
|
const parts = [];
|
||||||
|
parts.push('=== Multi-Hoster-Upload Support Bundle ===\n');
|
||||||
|
if (header && typeof header === 'object') {
|
||||||
|
for (const [k, v] of Object.entries(header)) parts.push(`${k}: ${v}\n`);
|
||||||
|
}
|
||||||
|
parts.push('\n');
|
||||||
|
parts.push('=== Config (sanitized — password/apiKey/token/cookie/sessionId redacted) ===\n');
|
||||||
|
parts.push(JSON.stringify(sanitizedConfig, null, 2));
|
||||||
|
parts.push('\n\n');
|
||||||
|
for (const f of (files || [])) {
|
||||||
|
parts.push(collectFile(f.path, f.label || f.path, f.maxBytes));
|
||||||
|
}
|
||||||
|
return parts.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { sanitizeConfig, collectFile, buildSupportBundleText, CRED_KEYS, REDACTED };
|
||||||
51
lib/throttled-cache.js
Normal file
51
lib/throttled-cache.js
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
// Time-windowed memoization. Reuses a previously-computed value if the
|
||||||
|
// signature + input identity match AND the cached entry is younger than
|
||||||
|
// `refreshMs`. Used by the renderer's dynamic-key sort throttle (every
|
||||||
|
// progress tick re-sorts a 5000-row queue → reuse for 200 ms, the user
|
||||||
|
// can't perceive sub-200 ms reorder lag).
|
||||||
|
//
|
||||||
|
// Loaded both as a CommonJS module (Node tests) and as a browser global
|
||||||
|
// (renderer/app.js via index.html script tag) — same single implementation
|
||||||
|
// across runtime and tests.
|
||||||
|
|
||||||
|
(function (root) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a throttled cache. The clock is injected so tests don't have to
|
||||||
|
* sleep — pass `() => fakeClock.value` from tests.
|
||||||
|
*
|
||||||
|
* @param {number} refreshMs cache TTL in milliseconds
|
||||||
|
* @param {() => number} [now] clock source, defaults to Date.now
|
||||||
|
*/
|
||||||
|
function makeThrottledCache(refreshMs, now) {
|
||||||
|
if (!Number.isFinite(refreshMs) || refreshMs < 0) {
|
||||||
|
throw new TypeError('refreshMs must be a non-negative finite number');
|
||||||
|
}
|
||||||
|
const clock = typeof now === 'function' ? now : () => Date.now();
|
||||||
|
let entry = null;
|
||||||
|
return {
|
||||||
|
get(sig, input) {
|
||||||
|
if (!entry) return undefined;
|
||||||
|
if (entry.sig !== sig) return undefined;
|
||||||
|
if (entry.input !== input) return undefined;
|
||||||
|
if (clock() - entry.ts >= refreshMs) return undefined;
|
||||||
|
return entry.value;
|
||||||
|
},
|
||||||
|
set(sig, input, value) {
|
||||||
|
entry = { sig, input, value, ts: clock() };
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
clear() { entry = null; },
|
||||||
|
// Introspection (mainly for tests/debug). Returns null when empty.
|
||||||
|
peek() {
|
||||||
|
if (!entry) return null;
|
||||||
|
return { sig: entry.sig, ts: entry.ts, age: clock() - entry.ts };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const api = { makeThrottledCache };
|
||||||
|
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||||
|
else if (root) root.ThrottledCache = api;
|
||||||
|
})(typeof window !== 'undefined' ? window : this);
|
||||||
@ -65,7 +65,12 @@ async function fetchJson(url, signal) {
|
|||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
redirect: 'follow'
|
redirect: 'follow'
|
||||||
});
|
});
|
||||||
return await res.json();
|
const text = await res.text();
|
||||||
|
try {
|
||||||
|
return JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
throw new Error(`Update-Server Antwort war kein JSON (HTTP ${res.status}): ${text.slice(0, 200)}`);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
if (signal) signal.removeEventListener('abort', onAbort);
|
if (signal) signal.removeEventListener('abort', onAbort);
|
||||||
@ -152,6 +157,9 @@ async function installUpdate(onProgress) {
|
|||||||
if (!check || !check.available) {
|
if (!check || !check.available) {
|
||||||
throw new Error('Kein Update verfuegbar');
|
throw new Error('Kein Update verfuegbar');
|
||||||
}
|
}
|
||||||
|
if (!check.assetUrl || !check.assetName) {
|
||||||
|
throw new Error('Update-Asset unvollstaendig (URL oder Name fehlt)');
|
||||||
|
}
|
||||||
|
|
||||||
// Stage: downloading
|
// Stage: downloading
|
||||||
const tmpDir = app.getPath('temp');
|
const tmpDir = app.getPath('temp');
|
||||||
@ -225,7 +233,20 @@ async function installUpdate(onProgress) {
|
|||||||
// Stage: done
|
// Stage: done
|
||||||
if (onProgress) onProgress({ stage: 'done', percent: 100 });
|
if (onProgress) onProgress({ stage: 'done', percent: 100 });
|
||||||
|
|
||||||
setTimeout(() => app.quit(), 900);
|
const _doQuit = () => setTimeout(() => app.quit(), 900);
|
||||||
|
const _getActive = () => {
|
||||||
|
try { return globalThis._mhuUploadManagerRef && globalThis._mhuUploadManagerRef.getActiveJobCount ? globalThis._mhuUploadManagerRef.getActiveJobCount() : 0; }
|
||||||
|
catch { return 0; }
|
||||||
|
};
|
||||||
|
if (_getActive() > 0) {
|
||||||
|
const POLL_MS = 3000;
|
||||||
|
const poller = setInterval(() => {
|
||||||
|
if (_getActive() === 0) { clearInterval(poller); _doQuit(); }
|
||||||
|
}, POLL_MS);
|
||||||
|
setTimeout(() => { try { clearInterval(poller); } catch {} _doQuit(); }, 30 * 60 * 1000);
|
||||||
|
} else {
|
||||||
|
_doQuit();
|
||||||
|
}
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (onProgress) onProgress({ stage: 'error', error: err.message });
|
if (onProgress) onProgress({ stage: 'error', error: err.message });
|
||||||
|
|||||||
@ -2,12 +2,14 @@ const { EventEmitter } = require('events');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const { uploadFile } = require('./hosters');
|
const { uploadFile, prefetchBaseline } = require('./hosters');
|
||||||
const VidmolyUploader = require('./vidmoly-upload');
|
const VidmolyUploader = require('./vidmoly-upload');
|
||||||
const VoeUploader = require('./voe-upload');
|
const VoeUploader = require('./voe-upload');
|
||||||
const DoodstreamUploader = require('./doodstream-upload');
|
const DoodstreamUploader = require('./doodstream-upload');
|
||||||
|
const ClouddropUploader = require('./clouddrop-upload');
|
||||||
const Semaphore = require('./semaphore');
|
const Semaphore = require('./semaphore');
|
||||||
const Throttle = require('./throttle');
|
const Throttle = require('./throttle');
|
||||||
|
const { probeFileHead } = require('./file-probe');
|
||||||
|
|
||||||
const DEFAULT_SETTINGS = {
|
const DEFAULT_SETTINGS = {
|
||||||
retries: 3,
|
retries: 3,
|
||||||
@ -37,6 +39,165 @@ class UploadManager extends EventEmitter {
|
|||||||
this.lastStartTime = {}; // hoster -> timestamp of last upload start
|
this.lastStartTime = {}; // hoster -> timestamp of last upload start
|
||||||
this.intervalLocks = {}; // hoster -> Promise chain for serialized interval waits
|
this.intervalLocks = {}; // hoster -> Promise chain for serialized interval waits
|
||||||
this.globalThrottle = null;
|
this.globalThrottle = null;
|
||||||
|
this._failedAccounts = new Map(); // hoster -> Set of failed accountIds
|
||||||
|
this._accountOverrides = new Map(); // hoster -> fallback account object
|
||||||
|
this._doodApiKeyCache = new Map(); // accountId/username -> derived doodstream API key ('' = tried, none)
|
||||||
|
this._baselineCache = new Map(); // hoster:apiKey -> Promise<Set<file_code>> (one fetch shared across all jobs in batch)
|
||||||
|
}
|
||||||
|
|
||||||
|
switchAccount(hoster, fallbackAccount) {
|
||||||
|
const prev = this._accountOverrides.get(hoster);
|
||||||
|
this._accountOverrides.set(hoster, fallbackAccount);
|
||||||
|
this._rotLog('switchAccount', {
|
||||||
|
hoster,
|
||||||
|
prevOverrideId: prev ? prev.id : null,
|
||||||
|
toAccountId: fallbackAccount ? fallbackAccount.id : null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Introspection helpers used by main.js to re-resolve fallbacks when the
|
||||||
|
// config changes mid-batch (e.g. user adds a new account after their only
|
||||||
|
// one ran out of space). Without this, an account that got marked failed
|
||||||
|
// before a fallback existed stays stuck until the app restarts.
|
||||||
|
getFailedAccountKeys() {
|
||||||
|
return Array.from(this._failedAccounts.keys());
|
||||||
|
}
|
||||||
|
|
||||||
|
getOverride(hoster) {
|
||||||
|
return this._accountOverrides.get(hoster) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getActiveJobCount() {
|
||||||
|
return this.activeJobs.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearFailedAccount(hoster, accountId) {
|
||||||
|
return this._failedAccounts.delete(`${hoster}:${accountId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearAllFailedAccounts() {
|
||||||
|
const n = this._failedAccounts.size;
|
||||||
|
this._failedAccounts.clear();
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
// True if the hoster has a usable override stored that differs from the
|
||||||
|
// account currently in the task and isn't itself already marked failed.
|
||||||
|
// Used by the retry loop to decide "retry on same account vs break to
|
||||||
|
// rotation" — skipping wasted attempts on a likely-bad primary when a
|
||||||
|
// pre-resolved fallback is ready to try.
|
||||||
|
_hasPendingOverride(hoster, currentAccountId) {
|
||||||
|
const override = this._accountOverrides.get(hoster);
|
||||||
|
if (!override) return false;
|
||||||
|
if (override.id === currentAccountId) return false;
|
||||||
|
if (this._failedAccounts.has(hoster + ':' + override.id)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
_rotLog(event, data) {
|
||||||
|
this.emit('rot-log', { ts: Date.now(), event, ...data });
|
||||||
|
}
|
||||||
|
|
||||||
|
// File-specific rejections from the hoster: the same file will get rejected
|
||||||
|
// on any account, so rotation is pointless. Matches the `err.fileRejected`
|
||||||
|
// flag set by parsers plus known rejection phrases.
|
||||||
|
// NOTE: We deliberately do NOT match the generic "lehnte Datei ab" prefix
|
||||||
|
// here — that phrase is used by the Byse parser for both file- AND
|
||||||
|
// account-level errors. Account-level ones set err.accountError instead,
|
||||||
|
// which takes priority in _shouldSkipRetryOnAccountError.
|
||||||
|
_isFileRejectedError(err) {
|
||||||
|
if (!err) return false;
|
||||||
|
if (err.accountError === true) return false; // explicit account-level wins
|
||||||
|
if (err.fileRejected === true) return true;
|
||||||
|
if (!err.message) return false;
|
||||||
|
const m = String(err.message);
|
||||||
|
return /(Not video file format|Duplicate|Datei zu (klein|gross|groß)|File too (small|large)|Invalid file|Unsupported format)/i.test(m);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hoster-side transient flake — the hoster's backend accepted the upload but
|
||||||
|
// returned a malformed/empty result (e.g. doodstream CDN form with no fn/no
|
||||||
|
// st). Same account + same file works on a later attempt; this is NOT an
|
||||||
|
// account problem. Treated exactly like a transient network error: skip
|
||||||
|
// remaining in-batch retries (the flake won't clear in 3s and a re-upload of
|
||||||
|
// 95 MB is expensive), don't blacklist the account, fail this file cleanly.
|
||||||
|
// The user's next manual retry — or a later batch — can use the same account.
|
||||||
|
_isHosterTransientError(err) {
|
||||||
|
if (!err) return false;
|
||||||
|
if (err.hosterTransient === true) return true; // explicit flag — primary
|
||||||
|
if (!err.message) return false;
|
||||||
|
// Defensive fallback: catch the same class of error if it bubbles up
|
||||||
|
// wrapped (e.g. through a different code path) without the flag set.
|
||||||
|
return /Server gab leeren Link zurueck|kein Filecode/i.test(String(err.message));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transient network errors — the account is fine, the network or the
|
||||||
|
// hoster's own backend hiccuped. Retrying on the SAME account is the right
|
||||||
|
// move; marking it failed would wrongly poison the fallback chain. If all
|
||||||
|
// retries on the current account still hit this class of error, we bail
|
||||||
|
// out for this file without blacklisting the account, so other jobs in the
|
||||||
|
// batch still get a fresh chance on it.
|
||||||
|
_isTransientNetworkError(err) {
|
||||||
|
if (!err || !err.message) return false;
|
||||||
|
const m = String(err.message);
|
||||||
|
const TRANSIENT = [
|
||||||
|
/ENOTFOUND/i,
|
||||||
|
/ECONNRESET/i,
|
||||||
|
/ECONNREFUSED/i,
|
||||||
|
/ETIMEDOUT/i,
|
||||||
|
/EAI_AGAIN/i,
|
||||||
|
/EHOSTUNREACH/i,
|
||||||
|
/ENETUNREACH/i,
|
||||||
|
/EPIPE/i,
|
||||||
|
/socket hang up/i,
|
||||||
|
/network (error|failure|problem)/i,
|
||||||
|
/dns (lookup|error|failed)/i,
|
||||||
|
/getaddrinfo/i,
|
||||||
|
/fetch failed/i,
|
||||||
|
/\bconnect (ETIMEDOUT|ECONN)/i
|
||||||
|
];
|
||||||
|
return TRANSIENT.some(p => p.test(m));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error classes that mean "this account is the problem, retrying on it won't
|
||||||
|
// help" — we skip the remaining retries and go straight to the fallback
|
||||||
|
// account. Keeps single runs fast when an account is rate-limited, banned,
|
||||||
|
// or out of quota.
|
||||||
|
_shouldSkipRetryOnAccountError(err) {
|
||||||
|
if (!err) return false;
|
||||||
|
// Explicit account-level flag from hoster parsers — highest priority.
|
||||||
|
if (err.accountError === true) return true;
|
||||||
|
if (!err.message) return false;
|
||||||
|
const m = String(err.message);
|
||||||
|
const PATTERNS = [
|
||||||
|
/Kein Upload-Server/i,
|
||||||
|
/No upload server/i,
|
||||||
|
/kein server/i,
|
||||||
|
/quota/i,
|
||||||
|
/limit (reached|exceeded|überschritten)/i,
|
||||||
|
/rate[- ]?limit/i,
|
||||||
|
/too many requests/i,
|
||||||
|
/\b(401|403|429)\b/,
|
||||||
|
/Falscher (User|Username|Passwort)/i,
|
||||||
|
/Incorrect (Login|Password)/i,
|
||||||
|
/invalid (credentials|api[- ]?key|token|session)/i,
|
||||||
|
/(account|user) (banned|suspended|disabled|gesperrt)/i,
|
||||||
|
/not authorized/i,
|
||||||
|
/forbidden/i,
|
||||||
|
/session (expired|abgelaufen)/i,
|
||||||
|
// Session/CSRF hints — the account's server session went stale, which
|
||||||
|
// no amount of retrying will fix. Re-login happens on the next account.
|
||||||
|
/CSRF[- ]?Token nicht gefunden/i,
|
||||||
|
/CSRF[- ]?token not found/i,
|
||||||
|
/Bist du eingeloggt/i,
|
||||||
|
/not logged in/i,
|
||||||
|
// Storage exhaustion — account is full. Rotate instead of hammering it.
|
||||||
|
/not enough (disk )?(space|storage)/i,
|
||||||
|
/insufficient (disk )?space/i,
|
||||||
|
/disk (space )?full/i,
|
||||||
|
/storage (exhausted|full|voll|limit)/i,
|
||||||
|
/account (full|voll)/i
|
||||||
|
];
|
||||||
|
return PATTERNS.some(p => p.test(m));
|
||||||
}
|
}
|
||||||
|
|
||||||
updateSettings(hosterSettings, globalSettings) {
|
updateSettings(hosterSettings, globalSettings) {
|
||||||
@ -69,7 +230,7 @@ class UploadManager extends EventEmitter {
|
|||||||
const settings = { ...DEFAULT_SETTINGS, ...(this.hosterSettings[hoster] || {}) };
|
const settings = { ...DEFAULT_SETTINGS, ...(this.hosterSettings[hoster] || {}) };
|
||||||
const globalLimit = this._getGlobalParallelLimit();
|
const globalLimit = this._getGlobalParallelLimit();
|
||||||
if (this.globalSettings.scaleParallelUploads && globalLimit > 0) {
|
if (this.globalSettings.scaleParallelUploads && globalLimit > 0) {
|
||||||
settings.parallelCount = Math.max(settings.parallelCount || 1, globalLimit);
|
settings.parallelCount = Math.min(settings.parallelCount || 1, globalLimit);
|
||||||
}
|
}
|
||||||
return settings;
|
return settings;
|
||||||
}
|
}
|
||||||
@ -112,7 +273,7 @@ class UploadManager extends EventEmitter {
|
|||||||
return this.semaphores[hoster];
|
return this.semaphores[hoster];
|
||||||
}
|
}
|
||||||
|
|
||||||
async startBatch(tasks) {
|
async startBatch(tasks, opts = {}) {
|
||||||
this.running = true;
|
this.running = true;
|
||||||
this.stopAfterActive = false;
|
this.stopAfterActive = false;
|
||||||
this.abortController = new AbortController();
|
this.abortController = new AbortController();
|
||||||
@ -121,28 +282,68 @@ class UploadManager extends EventEmitter {
|
|||||||
this.activeJobs.clear();
|
this.activeJobs.clear();
|
||||||
this.jobAbortControllers.clear();
|
this.jobAbortControllers.clear();
|
||||||
this.cancelledJobIds.clear();
|
this.cancelledJobIds.clear();
|
||||||
|
this._doodApiKeyCache.clear(); // re-derive doodstream keys fresh each batch
|
||||||
|
this._baselineCache.clear(); // re-fetch baselines per batch (a long batch could outlast remote-side relevance)
|
||||||
this.semaphores = {};
|
this.semaphores = {};
|
||||||
this.globalSemaphore = null;
|
this.globalSemaphore = null;
|
||||||
this.globalThrottle = null;
|
this.globalThrottle = null;
|
||||||
this.lastStartTime = {};
|
this.lastStartTime = {};
|
||||||
|
// Reset account-rotation state each batch — but optionally re-prime from
|
||||||
|
// app-session memory so a "Retry failed" right after batch-done doesn't
|
||||||
|
// burn 5 retries on the account we already know is dead. Caller (main.js)
|
||||||
|
// passes the session-scoped failed/override state.
|
||||||
|
this._failedAccounts.clear();
|
||||||
|
this._accountOverrides.clear();
|
||||||
|
if (Array.isArray(opts.primeFailedAccounts)) {
|
||||||
|
for (const key of opts.primeFailedAccounts) this._failedAccounts.set(key, true);
|
||||||
|
}
|
||||||
|
if (Array.isArray(opts.primeOverrides)) {
|
||||||
|
for (const entry of opts.primeOverrides) {
|
||||||
|
if (Array.isArray(entry) && entry.length === 2) this._accountOverrides.set(entry[0], entry[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this._rotLog('batch-start', {
|
||||||
|
taskCount: tasks.length,
|
||||||
|
primedFailed: this._failedAccounts.size,
|
||||||
|
primedOverrides: this._accountOverrides.size
|
||||||
|
});
|
||||||
|
|
||||||
const { signal } = this.abortController;
|
const { signal } = this.abortController;
|
||||||
const batchId = `batch-${Date.now()}`;
|
const batchId = `batch-${Date.now()}`;
|
||||||
const results = new Map(); // filePath -> { name, size, results: [] }
|
const results = new Map(); // filePath -> { name, size, results: [] }
|
||||||
|
this._batchResults = results;
|
||||||
|
this._additionalPromises = []; // Track jobs added mid-batch via addJobs()
|
||||||
|
|
||||||
for (const task of tasks) {
|
const DEDUP_CHUNK = 200;
|
||||||
const fileName = path.basename(task.file);
|
for (let i = 0; i < tasks.length; i += DEDUP_CHUNK) {
|
||||||
if (!results.has(task.file)) {
|
const end = Math.min(i + DEDUP_CHUNK, tasks.length);
|
||||||
let size = 0;
|
for (let j = i; j < end; j++) {
|
||||||
try { size = fs.statSync(task.file).size; } catch {}
|
const task = tasks[j];
|
||||||
results.set(task.file, { name: fileName, size, results: [] });
|
if (!results.has(task.file)) {
|
||||||
|
const fileName = path.basename(task.file);
|
||||||
|
let size = 0;
|
||||||
|
try { size = fs.statSync(task.file).size; } catch {}
|
||||||
|
results.set(task.file, { name: fileName, size, results: [] });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
if (end < tasks.length) await new Promise(setImmediate);
|
||||||
}
|
}
|
||||||
|
|
||||||
this._startStatsTimer();
|
this._startStatsTimer();
|
||||||
|
|
||||||
const promises = tasks.map((task) => this._runJob(task, results, signal));
|
const SPAWN_CHUNK = 100;
|
||||||
|
const promises = [];
|
||||||
|
for (let i = 0; i < tasks.length; i += SPAWN_CHUNK) {
|
||||||
|
const end = Math.min(i + SPAWN_CHUNK, tasks.length);
|
||||||
|
for (let j = i; j < end; j++) promises.push(this._runJob(tasks[j], results, signal));
|
||||||
|
if (end < tasks.length) await new Promise(setImmediate);
|
||||||
|
}
|
||||||
await Promise.allSettled(promises);
|
await Promise.allSettled(promises);
|
||||||
|
// Wait for any jobs added mid-batch via addJobs()
|
||||||
|
while (this._additionalPromises.length > 0) {
|
||||||
|
const batch = this._additionalPromises.splice(0);
|
||||||
|
await Promise.allSettled(batch);
|
||||||
|
}
|
||||||
|
|
||||||
this._stopStatsTimer();
|
this._stopStatsTimer();
|
||||||
this.running = false;
|
this.running = false;
|
||||||
@ -171,7 +372,13 @@ class UploadManager extends EventEmitter {
|
|||||||
const jobId = task.jobId || uploadId;
|
const jobId = task.jobId || uploadId;
|
||||||
const fileName = path.basename(task.file);
|
const fileName = path.basename(task.file);
|
||||||
let fileSize = 0;
|
let fileSize = 0;
|
||||||
try { fileSize = fs.statSync(task.file).size; } catch {}
|
let fileNotFound = false;
|
||||||
|
const cachedResult = results && results.get(task.file);
|
||||||
|
if (cachedResult && typeof cachedResult.size === 'number' && cachedResult.size > 0) {
|
||||||
|
fileSize = cachedResult.size;
|
||||||
|
} else {
|
||||||
|
try { fileSize = fs.statSync(task.file).size; } catch { fileNotFound = true; }
|
||||||
|
}
|
||||||
|
|
||||||
const maxAttempts = Math.max(1, (settings.retries || 0) + 1);
|
const maxAttempts = Math.max(1, (settings.retries || 0) + 1);
|
||||||
const jobAbortController = new AbortController();
|
const jobAbortController = new AbortController();
|
||||||
@ -200,7 +407,7 @@ class UploadManager extends EventEmitter {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const emitFinalStatus = (status, payload = {}) => {
|
const emitFinalStatus = (status, payload = {}) => {
|
||||||
this._emitProgress(uploadId, fileName, task.hoster, {
|
this._emitProgress(uploadId, fileName, task.hoster, { accountId: task.accountId,
|
||||||
jobId,
|
jobId,
|
||||||
status,
|
status,
|
||||||
progress: status === 'done' ? 1 : 0,
|
progress: status === 'done' ? 1 : 0,
|
||||||
@ -217,6 +424,18 @@ class UploadManager extends EventEmitter {
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (fileNotFound) {
|
||||||
|
const error = 'Datei nicht gefunden';
|
||||||
|
emitFinalStatus('skipped', { error, attempt: 0 });
|
||||||
|
recordFinalResult('error', { error });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (fileSize <= 0) {
|
||||||
|
const error = 'Datei ist leer (0 Bytes)';
|
||||||
|
emitFinalStatus('skipped', { error, attempt: 0 });
|
||||||
|
recordFinalResult('error', { error });
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (settings.maxSizeMb > 0 && fileSize > settings.maxSizeMb * 1024 * 1024) {
|
if (settings.maxSizeMb > 0 && fileSize > settings.maxSizeMb * 1024 * 1024) {
|
||||||
const error = `Datei zu groß (Max: ${settings.maxSizeMb} MB)`;
|
const error = `Datei zu groß (Max: ${settings.maxSizeMb} MB)`;
|
||||||
emitFinalStatus('skipped', { error, attempt: 0 });
|
emitFinalStatus('skipped', { error, attempt: 0 });
|
||||||
@ -224,26 +443,30 @@ class UploadManager extends EventEmitter {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this._emitProgress(uploadId, fileName, task.hoster, {
|
// The initial 'queued' emit per job is suppressed: with N=2000+ tasks
|
||||||
jobId,
|
// it produces 2000+ main→renderer IPCs back-to-back at startBatch and
|
||||||
status: 'queued',
|
// freezes the renderer event loop for tens of seconds. The renderer
|
||||||
progress: 0,
|
// already holds each job in 'queued'/'preview' state from its own
|
||||||
bytesUploaded: 0,
|
// queueJobs array; the first event it actually needs from main is the
|
||||||
bytesTotal: fileSize,
|
// 'getting-server' / 'uploading' transition for the jobs that the
|
||||||
speedKbs: 0,
|
// semaphore lets through.
|
||||||
elapsed: 0,
|
|
||||||
remaining: 0,
|
|
||||||
error: null,
|
|
||||||
result: null,
|
|
||||||
attempt: 0,
|
|
||||||
maxAttempts
|
|
||||||
});
|
|
||||||
|
|
||||||
// Acquire hoster semaphore first so jobs waiting for a hoster slot
|
|
||||||
// don't waste global slots (prevents underutilization)
|
|
||||||
await hosterSemaphore.acquire(signal);
|
await hosterSemaphore.acquire(signal);
|
||||||
hosterSlotAcquired = true;
|
hosterSlotAcquired = true;
|
||||||
|
|
||||||
|
let fileProbe = null;
|
||||||
|
try {
|
||||||
|
fileProbe = await probeFileHead(task.file, 64);
|
||||||
|
} catch (err) {
|
||||||
|
fileProbe = { ok: false, error: err && err.message, kind: 'unreadable' };
|
||||||
|
}
|
||||||
|
this._rotLog('upload-start', {
|
||||||
|
jobId, hoster: task.hoster, accountId: task.accountId, fileName,
|
||||||
|
fileSize,
|
||||||
|
detectedKind: fileProbe && fileProbe.kind ? fileProbe.kind : 'unknown',
|
||||||
|
isVideoLike: !!(fileProbe && fileProbe.isVideoLike),
|
||||||
|
headHex: fileProbe && fileProbe.headHex ? fileProbe.headHex.slice(0, 32) : null
|
||||||
|
});
|
||||||
|
|
||||||
if (globalSemaphore) {
|
if (globalSemaphore) {
|
||||||
await globalSemaphore.acquire(signal);
|
await globalSemaphore.acquire(signal);
|
||||||
globalSlotAcquired = true;
|
globalSlotAcquired = true;
|
||||||
@ -253,11 +476,35 @@ class UploadManager extends EventEmitter {
|
|||||||
await this._waitForInterval(task.hoster, settings.timeIntervalSec * 1000, signal);
|
await this._waitForInterval(task.hoster, settings.timeIntervalSec * 1000, signal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pre-job-swap: if this account was marked failed WHILE this task was
|
||||||
|
// waiting in the semaphore queue, jump straight to the override instead
|
||||||
|
// of burning a guaranteed-to-fail upload attempt. Critical at scale:
|
||||||
|
// with 500 queued jobs and 1 parallel slot, without this check every
|
||||||
|
// job still hits the original dead account first.
|
||||||
|
if (task.accountId && this._failedAccounts.has(task.hoster + ':' + task.accountId)) {
|
||||||
|
const override = this._accountOverrides.get(task.hoster);
|
||||||
|
if (override && !this._failedAccounts.has(task.hoster + ':' + override.id)) {
|
||||||
|
this._rotLog('pre-job-swap', {
|
||||||
|
jobId, hoster: task.hoster, fileName, fromAccountId: task.accountId, toAccountId: override.id
|
||||||
|
});
|
||||||
|
task.accountId = override.id;
|
||||||
|
task.username = override.username;
|
||||||
|
task.password = override.password;
|
||||||
|
task.apiKey = override.apiKey;
|
||||||
|
} else {
|
||||||
|
this._rotLog('pre-job-swap-blocked', {
|
||||||
|
jobId, hoster: task.hoster, fileName, accountId: task.accountId,
|
||||||
|
hasOverride: !!override,
|
||||||
|
overrideAlsoFailed: override ? this._failedAccounts.has(task.hoster + ':' + override.id) : false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||||
if (signal.aborted || this.stopAfterActive) break;
|
if (signal.aborted || this.stopAfterActive) break;
|
||||||
|
|
||||||
if (attempt > 1) {
|
if (attempt > 1) {
|
||||||
this._emitProgress(uploadId, fileName, task.hoster, {
|
this._emitProgress(uploadId, fileName, task.hoster, { accountId: task.accountId,
|
||||||
jobId,
|
jobId,
|
||||||
status: 'retrying',
|
status: 'retrying',
|
||||||
progress: 0,
|
progress: 0,
|
||||||
@ -284,7 +531,7 @@ class UploadManager extends EventEmitter {
|
|||||||
let uploadSignalBundle = { signal, cleanup() {} };
|
let uploadSignalBundle = { signal, cleanup() {} };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this._emitProgress(uploadId, fileName, task.hoster, {
|
this._emitProgress(uploadId, fileName, task.hoster, { accountId: task.accountId,
|
||||||
jobId,
|
jobId,
|
||||||
status: 'getting-server',
|
status: 'getting-server',
|
||||||
progress: 0,
|
progress: 0,
|
||||||
@ -311,75 +558,68 @@ class UploadManager extends EventEmitter {
|
|||||||
speedAbort = new AbortController();
|
speedAbort = new AbortController();
|
||||||
uploadSignalBundle = this._combineManySignals([signal, speedAbort.signal]);
|
uploadSignalBundle = this._combineManySignals([signal, speedAbort.signal]);
|
||||||
speedMonitor = setInterval(() => {
|
speedMonitor = setInterval(() => {
|
||||||
if (currentSpeedKbs > 0 && currentSpeedKbs < settings.restartBelowKbs) {
|
try {
|
||||||
if (!lowSpeedSince) lowSpeedSince = Date.now();
|
if (currentSpeedKbs > 0 && currentSpeedKbs < settings.restartBelowKbs) {
|
||||||
if (Date.now() - lowSpeedSince > 6000) {
|
if (!lowSpeedSince) lowSpeedSince = Date.now();
|
||||||
speedAbort.abort();
|
if (Date.now() - lowSpeedSince > 6000) {
|
||||||
|
speedAbort.abort();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
lowSpeedSince = 0;
|
||||||
}
|
}
|
||||||
} else {
|
} catch (e) { this._rotLog('speed-monitor-error', { jobId, error: e && e.message }); }
|
||||||
lowSpeedSince = 0;
|
|
||||||
}
|
|
||||||
}, 2000);
|
}, 2000);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.activeJobs.set(uploadId, { jobId, speedKbs: 0, bytesUploaded: 0 });
|
// Mutate this single object on each progress callback instead of
|
||||||
|
// allocating a fresh one — callback fires on every stream chunk
|
||||||
|
// (hundreds/sec per active job).
|
||||||
|
const activeEntry = { jobId, speedKbs: 0, bytesUploaded: 0 };
|
||||||
|
this.activeJobs.set(uploadId, activeEntry);
|
||||||
|
|
||||||
let lastEmitTime = 0;
|
let lastEmitTime = 0;
|
||||||
const PROGRESS_EMIT_INTERVAL = 250; // ms – throttle UI updates
|
const PROGRESS_EMIT_INTERVAL = 250; // ms – throttle UI updates
|
||||||
|
|
||||||
const progressCb = (bytesUploaded, bytesTotal) => {
|
const progressCb = (bytesUploaded, bytesTotal) => {
|
||||||
const now = Date.now();
|
try {
|
||||||
const elapsed = Math.round((now - jobStart) / 1000);
|
const now = Date.now();
|
||||||
const timeDelta = (now - lastSpeedTime) / 1000;
|
const elapsed = Math.round((now - jobStart) / 1000);
|
||||||
if (timeDelta >= 1) {
|
const timeDelta = (now - lastSpeedTime) / 1000;
|
||||||
const bytesDelta = bytesUploaded - lastBytes;
|
if (Number.isFinite(timeDelta) && timeDelta >= 1) {
|
||||||
currentSpeedKbs = Math.round(bytesDelta / timeDelta / 1024);
|
const bytesDelta = bytesUploaded - lastBytes;
|
||||||
lastBytes = bytesUploaded;
|
currentSpeedKbs = Math.round(bytesDelta / timeDelta / 1024);
|
||||||
lastSpeedTime = now;
|
lastBytes = bytesUploaded;
|
||||||
}
|
lastSpeedTime = now;
|
||||||
|
}
|
||||||
|
|
||||||
this.activeJobs.set(uploadId, { jobId, speedKbs: currentSpeedKbs, bytesUploaded });
|
activeEntry.speedKbs = currentSpeedKbs;
|
||||||
|
activeEntry.bytesUploaded = bytesUploaded;
|
||||||
|
|
||||||
// Throttle progress emissions to reduce IPC + rendering overhead
|
if (now - lastEmitTime < PROGRESS_EMIT_INTERVAL) return;
|
||||||
if (now - lastEmitTime < PROGRESS_EMIT_INTERVAL) return;
|
lastEmitTime = now;
|
||||||
lastEmitTime = now;
|
|
||||||
|
|
||||||
const remaining = currentSpeedKbs > 0
|
const remaining = currentSpeedKbs > 0
|
||||||
? Math.round((bytesTotal - bytesUploaded) / (currentSpeedKbs * 1024))
|
? Math.round((bytesTotal - bytesUploaded) / (currentSpeedKbs * 1024))
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
this._emitProgress(uploadId, fileName, task.hoster, {
|
this._emitProgress(uploadId, fileName, task.hoster, { accountId: task.accountId,
|
||||||
jobId,
|
jobId,
|
||||||
status: 'uploading',
|
status: 'uploading',
|
||||||
progress: bytesTotal > 0 ? Math.min(1, bytesUploaded / bytesTotal) : 0,
|
progress: bytesTotal > 0 ? Math.min(1, bytesUploaded / bytesTotal) : 0,
|
||||||
bytesUploaded,
|
bytesUploaded,
|
||||||
bytesTotal,
|
bytesTotal,
|
||||||
speedKbs: currentSpeedKbs,
|
speedKbs: currentSpeedKbs,
|
||||||
elapsed,
|
elapsed,
|
||||||
remaining,
|
remaining,
|
||||||
error: null,
|
error: null,
|
||||||
result: null,
|
result: null,
|
||||||
attempt,
|
attempt,
|
||||||
maxAttempts
|
maxAttempts
|
||||||
});
|
});
|
||||||
|
} catch { /* progress callbacks must never throw — swallowing is correct, the stream keeps going */ }
|
||||||
};
|
};
|
||||||
|
|
||||||
let result;
|
const result = await this._executeUpload(task, progressCb, uploadSignalBundle.signal, throttle);
|
||||||
if (task.hoster === 'vidmoly.me' && task.username) {
|
|
||||||
const vidmoly = new VidmolyUploader();
|
|
||||||
await vidmoly.login(task.username, task.password);
|
|
||||||
result = await vidmoly.upload(task.file, progressCb, uploadSignalBundle.signal, throttle);
|
|
||||||
} else if (task.hoster === 'voe.sx' && task.username) {
|
|
||||||
const voe = new VoeUploader();
|
|
||||||
await voe.login(task.username, task.password);
|
|
||||||
result = await voe.upload(task.file, progressCb, uploadSignalBundle.signal, throttle);
|
|
||||||
} else if (task.hoster === 'doodstream.com' && task.username) {
|
|
||||||
const dood = new DoodstreamUploader();
|
|
||||||
await dood.login(task.username, task.password);
|
|
||||||
result = await dood.upload(task.file, progressCb, uploadSignalBundle.signal, throttle);
|
|
||||||
} else {
|
|
||||||
result = await uploadFile(task.hoster, task.file, task.apiKey, progressCb, uploadSignalBundle.signal, throttle);
|
|
||||||
}
|
|
||||||
|
|
||||||
const elapsed = Math.round((Date.now() - jobStart) / 1000);
|
const elapsed = Math.round((Date.now() - jobStart) / 1000);
|
||||||
this.sessionBytes += fileSize;
|
this.sessionBytes += fileSize;
|
||||||
@ -397,6 +637,23 @@ class UploadManager extends EventEmitter {
|
|||||||
this.activeJobs.delete(uploadId);
|
this.activeJobs.delete(uploadId);
|
||||||
|
|
||||||
const isSpeedRestart = speedAbort && speedAbort.signal.aborted && !signal.aborted;
|
const isSpeedRestart = speedAbort && speedAbort.signal.aborted && !signal.aborted;
|
||||||
|
if (!signal.aborted && !isSpeedRestart) {
|
||||||
|
const diag = (err && typeof err === 'object' && err.diagnostic) || {};
|
||||||
|
this._rotLog('upload-failure', {
|
||||||
|
jobId, hoster: task.hoster, accountId: task.accountId, fileName,
|
||||||
|
attempt,
|
||||||
|
error: err && err.message ? err.message : String(err),
|
||||||
|
fileRejected: !!(err && err.fileRejected),
|
||||||
|
accountError: !!(err && err.accountError),
|
||||||
|
hosterTransient: !!(err && err.hosterTransient),
|
||||||
|
http: diag.http || null,
|
||||||
|
contentType: diag.contentType || null,
|
||||||
|
detectedKind: (typeof fileProbe !== 'undefined' && fileProbe && fileProbe.kind) ? fileProbe.kind : null,
|
||||||
|
isVideoLike: !!(typeof fileProbe !== 'undefined' && fileProbe && fileProbe.isVideoLike),
|
||||||
|
headHex: (typeof fileProbe !== 'undefined' && fileProbe && fileProbe.headHex) ? fileProbe.headHex.slice(0, 32) : null,
|
||||||
|
payloadSnippet: diag.payloadSnippet || null
|
||||||
|
});
|
||||||
|
}
|
||||||
if (signal.aborted) {
|
if (signal.aborted) {
|
||||||
lastError = new Error('Abgebrochen');
|
lastError = new Error('Abgebrochen');
|
||||||
break;
|
break;
|
||||||
@ -414,6 +671,43 @@ class UploadManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
lastError = err;
|
lastError = err;
|
||||||
|
// File-specific rejection — re-uploading won't change the server's
|
||||||
|
// mind. Break out immediately; the outer file-rejected branch then
|
||||||
|
// records the final error without burning through 5 × 3s retries.
|
||||||
|
if (this._isFileRejectedError(err)) break;
|
||||||
|
// Hoster-side transient flake (e.g. doodstream empty CDN form). Server
|
||||||
|
// flake won't clear in 3s and re-uploading the whole file 4× is pure
|
||||||
|
// bandwidth waste; bail out of the retry loop so the post-loop branch
|
||||||
|
// can fail this file without blacklisting the account.
|
||||||
|
if (this._isHosterTransientError(err)) {
|
||||||
|
this._rotLog('hoster-transient', {
|
||||||
|
jobId, hoster: task.hoster, fileName, accountId: task.accountId,
|
||||||
|
attempt, error: err && err.message ? err.message : String(err)
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Account-specific errors — don't waste retries on the same account,
|
||||||
|
// jump straight to rotation.
|
||||||
|
if (this._shouldSkipRetryOnAccountError(err)) {
|
||||||
|
this._rotLog('fast-fail', {
|
||||||
|
jobId, hoster: task.hoster, fileName, accountId: task.accountId,
|
||||||
|
attempt, error: err && err.message ? err.message : String(err)
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Generic non-transient error AND a fallback is already resolved for
|
||||||
|
// this hoster: bail to rotation instead of burning more retries on a
|
||||||
|
// possibly-dead primary. The fallback (pre-resolved at batch-start)
|
||||||
|
// deserves a real shot. Transient network errors stay on the same
|
||||||
|
// account — the network is the issue, not the account.
|
||||||
|
if (!this._isTransientNetworkError(err) &&
|
||||||
|
this._hasPendingOverride(task.hoster, task.accountId)) {
|
||||||
|
this._rotLog('try-alternate-after-fail', {
|
||||||
|
jobId, hoster: task.hoster, fileName, accountId: task.accountId,
|
||||||
|
attempt, error: err && err.message ? err.message : String(err)
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
if (attempt >= maxAttempts) break;
|
if (attempt >= maxAttempts) break;
|
||||||
// Wait 3 seconds before retry
|
// Wait 3 seconds before retry
|
||||||
await this._sleep(3000, signal);
|
await this._sleep(3000, signal);
|
||||||
@ -432,7 +726,181 @@ class UploadManager extends EventEmitter {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Account rotation: mark the current account failed (if not already),
|
||||||
|
// wait for main to resolve the next fallback, then retry. Loops so
|
||||||
|
// A → B → C → ... works for hosters with 3+ accounts.
|
||||||
|
//
|
||||||
|
// CRITICAL: we must ALWAYS check for an existing override, even if this
|
||||||
|
// account is already in _failedAccounts (e.g. another concurrent job
|
||||||
|
// already marked it failed). Otherwise the second job falls straight
|
||||||
|
// through to final-error instead of using the already-resolved fallback.
|
||||||
|
this._rotLog('retries-exhausted', {
|
||||||
|
jobId, hoster: task.hoster, fileName, accountId: task.accountId,
|
||||||
|
lastError: lastError ? lastError.message : null
|
||||||
|
});
|
||||||
|
// File-specific rejection → same file will get the same verdict on
|
||||||
|
// every other account, rotation is pointless. Don't blacklist, don't
|
||||||
|
// retry siblings, just fail this file cleanly.
|
||||||
|
if (this._isFileRejectedError(lastError)) {
|
||||||
|
this._rotLog('skip-rotation-file-rejected', {
|
||||||
|
jobId, hoster: task.hoster, fileName, accountId: task.accountId,
|
||||||
|
lastError: lastError ? lastError.message : null
|
||||||
|
});
|
||||||
|
const error = lastError.message || 'Datei abgelehnt';
|
||||||
|
emitFinalStatus('error', { error });
|
||||||
|
recordFinalResult('error', { error });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Hoster-side transient flake → identical handling to network-transient:
|
||||||
|
// the account is fine, don't blacklist it, just fail this file. Critical
|
||||||
|
// to keep the account usable across batches — otherwise one empty-form
|
||||||
|
// response poisons every subsequent batch with `pre-job-swap-blocked`.
|
||||||
|
if (this._isHosterTransientError(lastError)) {
|
||||||
|
this._rotLog('skip-rotation-hoster-transient', {
|
||||||
|
jobId, hoster: task.hoster, fileName, accountId: task.accountId,
|
||||||
|
lastError: lastError ? lastError.message : null
|
||||||
|
});
|
||||||
|
const error = lastError.message || 'Hoster-Backend lieferte leeres Ergebnis';
|
||||||
|
emitFinalStatus('error', { error });
|
||||||
|
recordFinalResult('error', { error });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// If the reason for failure was a transient network error we do NOT
|
||||||
|
// blacklist the account. Other jobs on the same account in this batch
|
||||||
|
// can still try fresh. This file just errors out for now.
|
||||||
|
if (this._isTransientNetworkError(lastError)) {
|
||||||
|
this._rotLog('skip-rotation-transient', {
|
||||||
|
jobId, hoster: task.hoster, fileName, accountId: task.accountId,
|
||||||
|
lastError: lastError ? lastError.message : null
|
||||||
|
});
|
||||||
|
const error = lastError.message || 'Netzwerkfehler';
|
||||||
|
emitFinalStatus('error', { error });
|
||||||
|
recordFinalResult('error', { error });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
while (task.accountId) {
|
||||||
|
if (signal.aborted || this.stopAfterActive) break;
|
||||||
|
const alreadyMarked = this._failedAccounts.has(task.hoster + ':' + task.accountId);
|
||||||
|
if (!alreadyMarked) {
|
||||||
|
this._failedAccounts.set(task.hoster + ':' + task.accountId, true);
|
||||||
|
this._rotLog('mark-failed', {
|
||||||
|
jobId, hoster: task.hoster, fileName, accountId: task.accountId,
|
||||||
|
lastError: lastError ? lastError.message : null
|
||||||
|
});
|
||||||
|
this.emit('account-failed', { hoster: task.hoster, accountId: task.accountId });
|
||||||
|
await this._sleep(800, signal);
|
||||||
|
// Re-check after the await: the user could have cancelled while
|
||||||
|
// we were waiting for main.js to resolve the fallback. Without
|
||||||
|
// this, rotation proceeds another full attempt-loop's worth of
|
||||||
|
// work before the next signal-check inside _executeUpload notices.
|
||||||
|
if (signal.aborted || this.stopAfterActive) break;
|
||||||
|
} else {
|
||||||
|
this._rotLog('already-marked', {
|
||||||
|
jobId, hoster: task.hoster, fileName, accountId: task.accountId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const override = this._accountOverrides.get(task.hoster);
|
||||||
|
if (!override) {
|
||||||
|
this._rotLog('rotation-end', {
|
||||||
|
jobId, hoster: task.hoster, fileName, reason: 'no-override-set',
|
||||||
|
lastFailedAccountId: task.accountId
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (this._failedAccounts.has(task.hoster + ':' + override.id)) {
|
||||||
|
this._rotLog('rotation-end', {
|
||||||
|
jobId, hoster: task.hoster, fileName, reason: 'override-already-failed',
|
||||||
|
overrideId: override.id, lastFailedAccountId: task.accountId
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (override.id === task.accountId) {
|
||||||
|
this._rotLog('rotation-end', {
|
||||||
|
jobId, hoster: task.hoster, fileName, reason: 'override-same-as-current',
|
||||||
|
lastFailedAccountId: task.accountId
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Switch to fallback account and retry this file
|
||||||
|
this._rotLog('rotate', {
|
||||||
|
jobId, hoster: task.hoster, fileName,
|
||||||
|
fromAccountId: task.accountId, toAccountId: override.id
|
||||||
|
});
|
||||||
|
task.accountId = override.id;
|
||||||
|
task.username = override.username;
|
||||||
|
task.password = override.password;
|
||||||
|
task.apiKey = override.apiKey;
|
||||||
|
this._emitProgress(uploadId, fileName, task.hoster, { accountId: task.accountId,
|
||||||
|
jobId, status: 'retrying', progress: 0, bytesUploaded: 0, bytesTotal: fileSize,
|
||||||
|
speedKbs: 0, elapsed: 0, remaining: 0,
|
||||||
|
error: 'Account-Wechsel zu Fallback', result: null, attempt: 1, maxAttempts
|
||||||
|
});
|
||||||
|
// Retry loop with the new account. On exhausted failure, the while
|
||||||
|
// loop iterates: marks this account failed too, asks main for the next
|
||||||
|
// fallback, and so on.
|
||||||
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||||
|
if (signal.aborted || this.stopAfterActive) break;
|
||||||
|
if (attempt > 1) {
|
||||||
|
this._emitProgress(uploadId, fileName, task.hoster, { accountId: task.accountId,
|
||||||
|
jobId, status: 'retrying', progress: 0, bytesUploaded: 0, bytesTotal: fileSize,
|
||||||
|
speedKbs: 0, elapsed: 0, remaining: 0,
|
||||||
|
error: lastError ? lastError.message : '', result: null, attempt, maxAttempts
|
||||||
|
});
|
||||||
|
await this._sleep(3000, signal);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const jobStart = Date.now();
|
||||||
|
let lastBytes = 0;
|
||||||
|
let lastSpeedTime = jobStart;
|
||||||
|
let currentSpeedKbs = 0;
|
||||||
|
const activeEntry = { jobId, speedKbs: 0, bytesUploaded: 0 };
|
||||||
|
this.activeJobs.set(uploadId, activeEntry);
|
||||||
|
|
||||||
|
const progressCb = (bytesUploaded, bytesTotal) => {
|
||||||
|
const now = Date.now();
|
||||||
|
const timeDelta = (now - lastSpeedTime) / 1000;
|
||||||
|
if (timeDelta >= 1) {
|
||||||
|
currentSpeedKbs = Math.round((bytesUploaded - lastBytes) / timeDelta / 1024);
|
||||||
|
lastBytes = bytesUploaded;
|
||||||
|
lastSpeedTime = now;
|
||||||
|
}
|
||||||
|
activeEntry.speedKbs = currentSpeedKbs;
|
||||||
|
activeEntry.bytesUploaded = bytesUploaded;
|
||||||
|
const elapsed = Math.round((now - jobStart) / 1000);
|
||||||
|
const remaining = currentSpeedKbs > 0 ? Math.round((bytesTotal - bytesUploaded) / (currentSpeedKbs * 1024)) : 0;
|
||||||
|
this._emitProgress(uploadId, fileName, task.hoster, { accountId: task.accountId,
|
||||||
|
jobId, status: 'uploading',
|
||||||
|
progress: bytesTotal > 0 ? Math.min(1, bytesUploaded / bytesTotal) : 0,
|
||||||
|
bytesUploaded, bytesTotal, speedKbs: currentSpeedKbs,
|
||||||
|
elapsed, remaining, error: null, result: null, attempt, maxAttempts
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const hosterThrottle = settings.maxSpeedKbs > 0 ? new Throttle(settings.maxSpeedKbs * 1024) : null;
|
||||||
|
const globalThrottle = this._getGlobalThrottle();
|
||||||
|
const throttle = hosterThrottle && globalThrottle
|
||||||
|
? { consume: async (bytes, sig) => { await hosterThrottle.consume(bytes, sig); await globalThrottle.consume(bytes, sig); } }
|
||||||
|
: hosterThrottle || globalThrottle;
|
||||||
|
|
||||||
|
const result = await this._executeUpload(task, progressCb, signal, throttle);
|
||||||
|
this.activeJobs.delete(uploadId);
|
||||||
|
this.sessionBytes += fileSize;
|
||||||
|
emitFinalStatus('done', { result, speedKbs: currentSpeedKbs, elapsed: Math.round((Date.now() - jobStart) / 1000), attempt });
|
||||||
|
recordFinalResult('done', { result });
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
this.activeJobs.delete(uploadId);
|
||||||
|
lastError = err;
|
||||||
|
if (signal.aborted || this.stopAfterActive) break;
|
||||||
|
if (attempt >= maxAttempts) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const error = lastError && lastError.message ? lastError.message : 'Unbekannter Fehler';
|
const error = lastError && lastError.message ? lastError.message : 'Unbekannter Fehler';
|
||||||
|
this._rotLog('final-error', {
|
||||||
|
jobId, hoster: task.hoster, fileName, lastFailedAccountId: task.accountId, error
|
||||||
|
});
|
||||||
emitFinalStatus('error', { error });
|
emitFinalStatus('error', { error });
|
||||||
recordFinalResult('error', { error });
|
recordFinalResult('error', { error });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -453,24 +921,94 @@ class UploadManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async _executeUpload(task, progressCb, signal, throttle) {
|
||||||
|
if (task.hoster === 'vidmoly.me' && task.username) {
|
||||||
|
const vidmoly = new VidmolyUploader();
|
||||||
|
await vidmoly.login(task.username, task.password);
|
||||||
|
return vidmoly.upload(task.file, progressCb, signal, throttle);
|
||||||
|
} else if (task.hoster === 'voe.sx' && task.username) {
|
||||||
|
const voe = new VoeUploader();
|
||||||
|
await voe.login(task.username, task.password);
|
||||||
|
return voe.upload(task.file, progressCb, signal, throttle);
|
||||||
|
} else if (task.hoster === 'doodstream.com' && task.username) {
|
||||||
|
// Login-path reliability fix: the web-form upload returns the filecode in
|
||||||
|
// an HTML form that comes back empty for large files (doodstream backend
|
||||||
|
// registration timeout). Derive the account's API key from the logged-in
|
||||||
|
// session ONCE per batch and upload via the official API instead — it
|
||||||
|
// returns result[0].filecode directly and has no empty-form failure mode.
|
||||||
|
// Falls back to the web-form upload if no valid key can be derived.
|
||||||
|
const apiKey = await this._resolveDoodstreamApiKey(task);
|
||||||
|
if (apiKey) {
|
||||||
|
this._rotLog('doodstream-via-api', { accountId: task.accountId, fileName: path.basename(task.file) });
|
||||||
|
return uploadFile('doodstream.com', task.file, apiKey, progressCb, signal, throttle, {
|
||||||
|
doodBaseline: await this._getBaseline('doodstream.com', apiKey, signal)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this._rotLog('doodstream-via-web', { accountId: task.accountId, fileName: path.basename(task.file) });
|
||||||
|
const dood = new DoodstreamUploader();
|
||||||
|
await dood.login(task.username, task.password);
|
||||||
|
return dood.upload(task.file, progressCb, signal, throttle);
|
||||||
|
} else if (task.hoster === 'clouddrop.cc') {
|
||||||
|
const clouddrop = new ClouddropUploader(task.apiKey);
|
||||||
|
return clouddrop.upload(task.file, progressCb, signal, throttle);
|
||||||
|
} else {
|
||||||
|
const baselineOpts = {};
|
||||||
|
if (task.hoster === 'byse.sx') baselineOpts.byseBaseline = await this._getBaseline('byse.sx', task.apiKey, signal);
|
||||||
|
if (task.hoster === 'doodstream.com') baselineOpts.doodBaseline = await this._getBaseline('doodstream.com', task.apiKey, signal);
|
||||||
|
return uploadFile(task.hoster, task.file, task.apiKey, progressCb, signal, throttle, baselineOpts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_getBaseline(hosterName, apiKey, signal) {
|
||||||
|
if (!apiKey) return Promise.resolve(null);
|
||||||
|
const key = `${hosterName}:${apiKey}`;
|
||||||
|
let pending = this._baselineCache.get(key);
|
||||||
|
if (pending) return pending;
|
||||||
|
pending = prefetchBaseline(hosterName, apiKey, signal);
|
||||||
|
this._baselineCache.set(key, pending);
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve (and cache per batch) the doodstream API key for a login-only
|
||||||
|
// account by logging in once and scraping+validating it from the session.
|
||||||
|
// Returns the key string, or '' when none could be derived (cached either way
|
||||||
|
// so a 40-file batch logs in + derives ONCE, not per file). The empty-string
|
||||||
|
// sentinel distinguishes "tried, none" from "not yet tried" (undefined).
|
||||||
|
async _resolveDoodstreamApiKey(task) {
|
||||||
|
const cacheKey = task.accountId || task.username;
|
||||||
|
const cached = this._doodApiKeyCache.get(cacheKey);
|
||||||
|
if (cached !== undefined) return cached || null;
|
||||||
|
|
||||||
|
let key = '';
|
||||||
|
try {
|
||||||
|
const probe = new DoodstreamUploader();
|
||||||
|
await probe.login(task.username, task.password);
|
||||||
|
key = (await probe.deriveApiKey()) || '';
|
||||||
|
} catch {
|
||||||
|
key = '';
|
||||||
|
}
|
||||||
|
this._doodApiKeyCache.set(cacheKey, key);
|
||||||
|
return key || null;
|
||||||
|
}
|
||||||
|
|
||||||
_emitProgress(uploadId, fileName, hoster, data) {
|
_emitProgress(uploadId, fileName, hoster, data) {
|
||||||
this.emit('progress', { uploadId, fileName, hoster, ...data });
|
this.emit('progress', { uploadId, fileName, hoster, ...data });
|
||||||
}
|
}
|
||||||
|
|
||||||
_startStatsTimer() {
|
_startStatsTimer() {
|
||||||
|
if (this.statsInterval) clearInterval(this.statsInterval);
|
||||||
this.statsInterval = setInterval(() => {
|
this.statsInterval = setInterval(() => {
|
||||||
|
try {
|
||||||
let globalSpeedKbs = 0;
|
let globalSpeedKbs = 0;
|
||||||
let activeCount = 0;
|
let activeCount = 0;
|
||||||
|
let inProgressBytes = 0;
|
||||||
for (const job of this.activeJobs.values()) {
|
for (const job of this.activeJobs.values()) {
|
||||||
globalSpeedKbs += job.speedKbs || 0;
|
globalSpeedKbs += job.speedKbs || 0;
|
||||||
|
inProgressBytes += job.bytesUploaded || 0;
|
||||||
activeCount++;
|
activeCount++;
|
||||||
}
|
}
|
||||||
|
|
||||||
const elapsed = Math.round((Date.now() - this.startTime) / 1000);
|
const elapsed = Math.round((Date.now() - this.startTime) / 1000);
|
||||||
let inProgressBytes = 0;
|
|
||||||
for (const job of this.activeJobs.values()) {
|
|
||||||
inProgressBytes += job.bytesUploaded || 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.emit('stats', {
|
this.emit('stats', {
|
||||||
state: this.running ? (this.stopAfterActive ? 'stopping' : 'uploading') : 'idle',
|
state: this.running ? (this.stopAfterActive ? 'stopping' : 'uploading') : 'idle',
|
||||||
@ -480,6 +1018,7 @@ class UploadManager extends EventEmitter {
|
|||||||
activeJobs: activeCount,
|
activeJobs: activeCount,
|
||||||
pendingJobs: Object.values(this.semaphores).reduce((sum, semaphore) => sum + semaphore.pending, 0)
|
pendingJobs: Object.values(this.semaphores).reduce((sum, semaphore) => sum + semaphore.pending, 0)
|
||||||
});
|
});
|
||||||
|
} catch { /* never let a stats tick crash the timer + caller */ }
|
||||||
}, 1000);
|
}, 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -578,6 +1117,31 @@ class UploadManager extends EventEmitter {
|
|||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
addJobs(tasks) {
|
||||||
|
if (!this.running || !tasks || tasks.length === 0) {
|
||||||
|
return { added: 0, alreadyInBatchJobIds: [] };
|
||||||
|
}
|
||||||
|
const { signal } = this.abortController;
|
||||||
|
const results = this._batchResults || new Map();
|
||||||
|
const addResult = { added: 0, alreadyInBatchJobIds: [] };
|
||||||
|
for (const task of tasks) {
|
||||||
|
// Skip if this job is already being processed (prevent duplicates)
|
||||||
|
if (task.jobId && this.jobAbortControllers.has(task.jobId)) {
|
||||||
|
addResult.alreadyInBatchJobIds.push(task.jobId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const fileName = path.basename(task.file);
|
||||||
|
if (!results.has(task.file)) {
|
||||||
|
let size = 0;
|
||||||
|
try { size = fs.statSync(task.file).size; } catch {}
|
||||||
|
results.set(task.file, { name: fileName, size, results: [] });
|
||||||
|
}
|
||||||
|
this._additionalPromises.push(this._runJob(task, results, signal));
|
||||||
|
addResult.added++;
|
||||||
|
}
|
||||||
|
return addResult;
|
||||||
|
}
|
||||||
|
|
||||||
cancelJobs(jobIds) {
|
cancelJobs(jobIds) {
|
||||||
for (const jobId of jobIds || []) {
|
for (const jobId of jobIds || []) {
|
||||||
if (!jobId) continue;
|
if (!jobId) continue;
|
||||||
|
|||||||
@ -81,95 +81,71 @@ class VidmolyUploader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Login to Vidmoly
|
* Login to Vidmoly via the new JSON API (replaces the old XFS form POST
|
||||||
|
* at `/` with `op=login`, which the SPA redesign deprecated). The response
|
||||||
|
* sets a `vidmoly_session` HttpOnly cookie that the upload API checks.
|
||||||
*/
|
*/
|
||||||
async login(username, password) {
|
async login(username, password) {
|
||||||
// First GET the main page to get initial cookies
|
// Warm up — get baseline cookies (cf_clearance etc.)
|
||||||
const initRes = await this._fetch(BASE_URL);
|
try {
|
||||||
await initRes.text();
|
const initRes = await this._fetch(BASE_URL);
|
||||||
|
await initRes.text();
|
||||||
|
} catch {}
|
||||||
|
|
||||||
// POST login
|
const res = await this._fetch(`${BASE_URL}/api/auth/login`, {
|
||||||
const loginData = new URLSearchParams({
|
|
||||||
op: 'login',
|
|
||||||
login: username,
|
|
||||||
password: password,
|
|
||||||
redirect: ''
|
|
||||||
});
|
|
||||||
|
|
||||||
const res = await this._fetch(BASE_URL, {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: loginData.toString(),
|
body: JSON.stringify({ login: username, password }),
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
'Content-Type': 'application/json',
|
||||||
'Referer': BASE_URL
|
'Accept': 'application/json',
|
||||||
|
'Origin': BASE_URL,
|
||||||
|
'Referer': `${BASE_URL}/login`
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const body = await res.text();
|
const body = await res.text();
|
||||||
|
if (res.status === 401 || res.status === 403 || /incorrect|invalid|wrong/i.test(body)) {
|
||||||
if (body.includes('Incorrect Login or Password')) {
|
|
||||||
throw new Error('Vidmoly Login fehlgeschlagen: Falscher Username oder Passwort');
|
throw new Error('Vidmoly Login fehlgeschlagen: Falscher Username oder Passwort');
|
||||||
}
|
}
|
||||||
|
if (res.status < 200 || res.status >= 300) {
|
||||||
|
throw new Error(`Vidmoly Login fehlgeschlagen: HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
if (!this.cookies.has('vidmoly_session')) {
|
||||||
|
throw new Error('Vidmoly Login fehlgeschlagen: Keine Session erhalten (vidmoly_session fehlt)');
|
||||||
|
}
|
||||||
|
|
||||||
// Check for login cookie
|
// Probe the upload API so downstream getUploadParams() has a warm path.
|
||||||
const hasSession = this.cookies.has('login') || this.cookies.has('xfsts') ||
|
const probe = await this._fetch(`${BASE_URL}/api/upload/config`);
|
||||||
this.cookies.size > 1;
|
const probeBody = await probe.text();
|
||||||
if (!hasSession) {
|
let probeJson = null;
|
||||||
throw new Error('Vidmoly Login fehlgeschlagen: Keine Session erhalten');
|
try { probeJson = JSON.parse(probeBody); } catch {}
|
||||||
|
if (!probeJson || !probeJson.sess_id || !probeJson.upload_url) {
|
||||||
|
throw new Error('Vidmoly Login fehlgeschlagen: Session konnte nicht verifiziert werden (API-Probe)');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get upload form parameters from the upload page
|
* Fetch the upload session config from Vidmoly's new SPA API.
|
||||||
|
* Replaces the old HTML-form scrape at /?op=upload which the redesign
|
||||||
|
* removed. Returns an XFS-style session token + a transit-server URL.
|
||||||
*/
|
*/
|
||||||
async getUploadParams() {
|
async getUploadParams() {
|
||||||
const res = await this._fetch(`${BASE_URL}/?op=upload`);
|
const res = await this._fetch(`${BASE_URL}/api/upload/config`);
|
||||||
const html = await res.text();
|
const body = await res.text();
|
||||||
|
let payload = null;
|
||||||
// Parse hidden form fields from XFS upload form
|
try { payload = JSON.parse(body); } catch {
|
||||||
const params = {};
|
throw new Error('Vidmoly: /api/upload/config lieferte kein JSON — evtl. nicht eingeloggt?');
|
||||||
|
|
||||||
const inputRegex = /<input[^>]*type=["']hidden["'][^>]*>/gi;
|
|
||||||
let match;
|
|
||||||
while ((match = inputRegex.exec(html)) !== null) {
|
|
||||||
const tag = match[0];
|
|
||||||
const nameMatch = tag.match(/name=["']([^"']+)["']/);
|
|
||||||
const valueMatch = tag.match(/value=["']([^"']*?)["']/);
|
|
||||||
if (nameMatch) {
|
|
||||||
params[nameMatch[1]] = valueMatch ? valueMatch[1] : '';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if (!payload || !payload.sess_id || !payload.upload_url) {
|
||||||
// Extract form action
|
throw new Error('Vidmoly: /api/upload/config unvollständig (sess_id/upload_url fehlt)');
|
||||||
const formMatch = html.match(/<form[^>]*id=["']?file_upload["']?[^>]*action=["']([^"']+)["']/i)
|
|
||||||
|| html.match(/<form[^>]*enctype=["']multipart\/form-data["'][^>]*action=["']([^"']+)["']/i)
|
|
||||||
|| html.match(/<form[^>]*action=["']([^"']+)["'][^>]*enctype=["']multipart\/form-data["']/i);
|
|
||||||
|
|
||||||
let uploadUrl = null;
|
|
||||||
if (formMatch) {
|
|
||||||
uploadUrl = formMatch[1];
|
|
||||||
} else if (params.srv_tmp_url) {
|
|
||||||
uploadUrl = params.srv_tmp_url;
|
|
||||||
}
|
}
|
||||||
|
return {
|
||||||
if (!uploadUrl) {
|
uploadUrl: payload.upload_url,
|
||||||
const cgiMatch = html.match(/(https?:\/\/[^"'\s]+\/cgi-bin\/upload\.cgi[^"'\s]*)/i)
|
// Fields verified from a real browser POST capture.
|
||||||
|| html.match(/(https?:\/\/[^"'\s]+\/upload\/\d+)/i);
|
// to_json=1 forces a JSON response instead of an HTML redirect page.
|
||||||
if (cgiMatch) uploadUrl = cgiMatch[1];
|
params: { sess_id: payload.sess_id, to_json: '1', fld_id: '0' },
|
||||||
}
|
fileFieldName: 'file'
|
||||||
|
};
|
||||||
if (!uploadUrl) {
|
|
||||||
throw new Error('Vidmoly Upload-URL nicht gefunden. Bist du eingeloggt?');
|
|
||||||
}
|
|
||||||
|
|
||||||
let fileFieldName = 'file';
|
|
||||||
const fileInputMatch = html.match(/<input[^>]*type=["']file["'][^>]*name=["']([^"']+)["']/i)
|
|
||||||
|| html.match(/<input[^>]*name=["']([^"']+)["'][^>]*type=["']file["']/i);
|
|
||||||
if (fileInputMatch && fileInputMatch[1]) {
|
|
||||||
fileFieldName = fileInputMatch[1].trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
return { uploadUrl, params, fileFieldName };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -187,7 +163,7 @@ class VidmolyUploader {
|
|||||||
// XFS form fields
|
// XFS form fields
|
||||||
const formFields = {};
|
const formFields = {};
|
||||||
for (const [k, v] of Object.entries(params)) {
|
for (const [k, v] of Object.entries(params)) {
|
||||||
if (!/^file(?:_\d+)?$/i.test(k)) {
|
if (!/^file(?:_\d+)?$/i.test(k)) { // eslint-disable-line security/detect-unsafe-regex -- safe: no backtracking
|
||||||
formFields[k] = v;
|
formFields[k] = v;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -200,7 +176,8 @@ class VidmolyUploader {
|
|||||||
preamble += `${value}\r\n`;
|
preamble += `${value}\r\n`;
|
||||||
}
|
}
|
||||||
preamble += `--${boundary}\r\n`;
|
preamble += `--${boundary}\r\n`;
|
||||||
preamble += `Content-Disposition: form-data; name="${fileFieldName || 'file'}"; filename="${fileName}"\r\n`;
|
const safeFileName = fileName.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||||
|
preamble += `Content-Disposition: form-data; name="${fileFieldName || 'file'}"; filename="${safeFileName}"\r\n`;
|
||||||
preamble += `Content-Type: application/octet-stream\r\n\r\n`;
|
preamble += `Content-Type: application/octet-stream\r\n\r\n`;
|
||||||
|
|
||||||
const epilogue = `\r\n--${boundary}--\r\n`;
|
const epilogue = `\r\n--${boundary}--\r\n`;
|
||||||
@ -216,6 +193,7 @@ class VidmolyUploader {
|
|||||||
yield preambleBuf;
|
yield preambleBuf;
|
||||||
const fileStream = fs.createReadStream(filePath, { highWaterMark: CHUNK_SIZE });
|
const fileStream = fs.createReadStream(filePath, { highWaterMark: CHUNK_SIZE });
|
||||||
for await (const chunk of fileStream) {
|
for await (const chunk of fileStream) {
|
||||||
|
if (signal && signal.aborted) throw new Error('Aborted');
|
||||||
if (throttle) await throttle.consume(chunk.length, signal);
|
if (throttle) await throttle.consume(chunk.length, signal);
|
||||||
bytesRead += chunk.length;
|
bytesRead += chunk.length;
|
||||||
yield chunk;
|
yield chunk;
|
||||||
@ -224,17 +202,26 @@ class VidmolyUploader {
|
|||||||
yield epilogueBuf;
|
yield epilogueBuf;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use undici.request for the upload (streaming body for progress)
|
// Transit server lives on a different domain (*.vmwesa.online) and runs
|
||||||
const { body, statusCode, headers } = await request(uploadUrl, {
|
// the nginx-upload-progress module. It requires an X-Progress-ID query
|
||||||
|
// parameter on the POST URL — without it the upload hangs at the final
|
||||||
|
// byte because the module can't finalize the session. Browsers append it
|
||||||
|
// automatically before submitting the form.
|
||||||
|
const progressId = Date.now().toString() + Math.floor(Math.random() * 1e6).toString().padStart(6, '0');
|
||||||
|
const targetUrl = uploadUrl + (uploadUrl.includes('?') ? '&' : '?') + 'X-Progress-ID=' + progressId;
|
||||||
|
|
||||||
|
// Browsers don't send vidmoly.me cookies across origins, so we don't either.
|
||||||
|
const { body, statusCode, headers } = await request(targetUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: generate(),
|
body: generate(),
|
||||||
signal,
|
signal,
|
||||||
headers: {
|
headers: {
|
||||||
'User-Agent': USER_AGENT,
|
'User-Agent': USER_AGENT,
|
||||||
'Cookie': this._cookieHeader(),
|
'Accept': '*/*',
|
||||||
|
'Origin': BASE_URL,
|
||||||
|
'Referer': `${BASE_URL}/`,
|
||||||
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
||||||
'Content-Length': String(totalSize),
|
'Content-Length': String(totalSize)
|
||||||
'Referer': `${BASE_URL}/upload.html`
|
|
||||||
},
|
},
|
||||||
headersTimeout: UPLOAD_TIMEOUT,
|
headersTimeout: UPLOAD_TIMEOUT,
|
||||||
bodyTimeout: UPLOAD_TIMEOUT
|
bodyTimeout: UPLOAD_TIMEOUT
|
||||||
@ -258,28 +245,33 @@ class VidmolyUploader {
|
|||||||
resultHtml = await body.text();
|
resultHtml = await body.text();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try JSON first (some XFS versions return JSON)
|
// Try JSON first. The current transit server returns
|
||||||
|
// { status: "OK", file_code: "...", msg: "Upload Completed" }.
|
||||||
|
// Legacy XFS shapes (json.files / json.result) are kept as fallback.
|
||||||
try {
|
try {
|
||||||
const json = JSON.parse(resultHtml);
|
const json = JSON.parse(resultHtml);
|
||||||
|
if (json.status && /ok/i.test(json.status) && json.file_code) {
|
||||||
|
return this._buildUrlsFromCode(json.file_code);
|
||||||
|
}
|
||||||
|
if (json.file_code || json.filecode) {
|
||||||
|
return this._buildUrlsFromCode(json.file_code || json.filecode);
|
||||||
|
}
|
||||||
if (json.files && json.files.length > 0) {
|
if (json.files && json.files.length > 0) {
|
||||||
const f = json.files[0];
|
const f = json.files[0];
|
||||||
const code = f.filecode || f.file_code;
|
return this._buildUrlsFromCode(f.filecode || f.file_code);
|
||||||
return {
|
|
||||||
download_url: code ? `${BASE_URL}/w/${code}` : null,
|
|
||||||
embed_url: code ? `${BASE_URL}/embed-${code}.html` : null,
|
|
||||||
file_code: code
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
if (json.result) {
|
if (json.result) {
|
||||||
const r = Array.isArray(json.result) ? json.result[0] : json.result;
|
const r = Array.isArray(json.result) ? json.result[0] : json.result;
|
||||||
const code = r.filecode || r.file_code;
|
const code = r.filecode || r.file_code;
|
||||||
return {
|
const urls = this._buildUrlsFromCode(code);
|
||||||
download_url: r.download_url || (code ? `${BASE_URL}/w/${code}` : null),
|
if (urls) return urls;
|
||||||
embed_url: r.embed_url || (code ? `${BASE_URL}/embed-${code}.html` : null),
|
|
||||||
file_code: code
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
} catch {}
|
if (json.status && !/ok/i.test(json.status) && json.msg) {
|
||||||
|
throw new Error(`Vidmoly Upload abgelehnt: ${json.msg}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err && /Vidmoly Upload abgelehnt/.test(err.message)) throw err;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return this._parseUploadResult(resultHtml);
|
return this._parseUploadResult(resultHtml);
|
||||||
@ -447,7 +439,7 @@ class VidmolyUploader {
|
|||||||
let embed_url = null;
|
let embed_url = null;
|
||||||
let file_code = null;
|
let file_code = null;
|
||||||
|
|
||||||
const fnMatch = html.match(/<(?:input|textarea)[^>]*name=["']fn["'][^>]*(?:value=["']([^"']+)["'])?[^>]*>([^<]*)/i);
|
const fnMatch = html.match(/<(?:input|textarea)[^>]*name=["']fn["'][^>]*(?:value=["']([^"']+)["'])?[^>]*>([^<]*)/i); // eslint-disable-line security/detect-unsafe-regex -- parses trusted hoster HTML only
|
||||||
if (fnMatch) {
|
if (fnMatch) {
|
||||||
const codeFromFn = (fnMatch[1] || fnMatch[2] || '').trim();
|
const codeFromFn = (fnMatch[1] || fnMatch[2] || '').trim();
|
||||||
if (/^[a-z0-9]{8,16}$/i.test(codeFromFn)) {
|
if (/^[a-z0-9]{8,16}$/i.test(codeFromFn)) {
|
||||||
|
|||||||
@ -168,7 +168,10 @@ class VoeUploader {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
const body = await res.text();
|
const body = await res.text();
|
||||||
const data = JSON.parse(body);
|
let data;
|
||||||
|
try { data = JSON.parse(body); } catch {
|
||||||
|
throw new Error(`VOE: Upload-Server Antwort war kein JSON: ${body.slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
|
||||||
if (!data || !data.success || !data.server) {
|
if (!data || !data.success || !data.server) {
|
||||||
throw new Error('VOE: Kein Upload-Server erhalten von delivery-node');
|
throw new Error('VOE: Kein Upload-Server erhalten von delivery-node');
|
||||||
@ -228,7 +231,8 @@ class VoeUploader {
|
|||||||
preamble += `${sessionId}\r\n`;
|
preamble += `${sessionId}\r\n`;
|
||||||
}
|
}
|
||||||
preamble += `--${boundary}\r\n`;
|
preamble += `--${boundary}\r\n`;
|
||||||
preamble += `Content-Disposition: form-data; name="file"; filename="${fileName}"\r\n`;
|
const safeFileName = fileName.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||||
|
preamble += `Content-Disposition: form-data; name="file"; filename="${safeFileName}"\r\n`;
|
||||||
preamble += `Content-Type: application/octet-stream\r\n\r\n`;
|
preamble += `Content-Type: application/octet-stream\r\n\r\n`;
|
||||||
|
|
||||||
const epilogue = `\r\n--${boundary}--\r\n`;
|
const epilogue = `\r\n--${boundary}--\r\n`;
|
||||||
@ -244,6 +248,7 @@ class VoeUploader {
|
|||||||
yield preambleBuf;
|
yield preambleBuf;
|
||||||
const fileStream = fs.createReadStream(filePath, { highWaterMark: CHUNK_SIZE });
|
const fileStream = fs.createReadStream(filePath, { highWaterMark: CHUNK_SIZE });
|
||||||
for await (const chunk of fileStream) {
|
for await (const chunk of fileStream) {
|
||||||
|
if (signal && signal.aborted) throw new Error('Aborted');
|
||||||
if (throttle) await throttle.consume(chunk.length, signal);
|
if (throttle) await throttle.consume(chunk.length, signal);
|
||||||
bytesRead += chunk.length;
|
bytesRead += chunk.length;
|
||||||
yield chunk;
|
yield chunk;
|
||||||
@ -253,7 +258,7 @@ class VoeUploader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: POST file to CDN upload server
|
// Step 3: POST file to CDN upload server
|
||||||
const { body, statusCode, headers } = await request(uploadServer, {
|
const { body, headers } = await request(uploadServer, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: generate(),
|
body: generate(),
|
||||||
signal,
|
signal,
|
||||||
|
|||||||
3154
package-lock.json
generated
3154
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
11
package.json
11
package.json
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "multi-hoster-uploader",
|
"name": "multi-hoster-uploader",
|
||||||
"version": "1.9.8",
|
"version": "3.3.53",
|
||||||
"description": "Upload files to doodstream, voe, vidmoly, byse simultaneously",
|
"description": "Upload files to doodstream, voe, vidmoly, byse simultaneously",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@ -12,11 +12,14 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"chokidar": "^3.6.0",
|
"chokidar": "^3.6.0",
|
||||||
"undici": "^7.16.0"
|
"undici": "^7.16.0",
|
||||||
|
"ws": "^8.19.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"electron": "^33.0.0",
|
"electron": "^41.3.0",
|
||||||
"electron-builder": "^25.0.0",
|
"electron-builder": "^26.8.1",
|
||||||
|
"eslint": "^10.1.0",
|
||||||
|
"eslint-plugin-security": "^4.0.0",
|
||||||
"rcedit": "^4.0.1"
|
"rcedit": "^4.0.1"
|
||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
|
|||||||
51
preload.js
51
preload.js
@ -6,6 +6,8 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
saveConfig: (config) => ipcRenderer.invoke('save-config', config),
|
saveConfig: (config) => ipcRenderer.invoke('save-config', config),
|
||||||
getHistory: () => ipcRenderer.invoke('get-history'),
|
getHistory: () => ipcRenderer.invoke('get-history'),
|
||||||
clearHistory: () => ipcRenderer.invoke('clear-history'),
|
clearHistory: () => ipcRenderer.invoke('clear-history'),
|
||||||
|
exportHistory: (format) => ipcRenderer.invoke('export-history', format),
|
||||||
|
saveTextFile: (defaultName, content, filters) => ipcRenderer.invoke('save-text-file', defaultName, content, filters),
|
||||||
|
|
||||||
// Hoster settings
|
// Hoster settings
|
||||||
getHosterSettings: () => ipcRenderer.invoke('get-hoster-settings'),
|
getHosterSettings: () => ipcRenderer.invoke('get-hoster-settings'),
|
||||||
@ -14,6 +16,7 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
// Global settings
|
// Global settings
|
||||||
getGlobalSettings: () => ipcRenderer.invoke('get-global-settings'),
|
getGlobalSettings: () => ipcRenderer.invoke('get-global-settings'),
|
||||||
saveGlobalSettings: (settings) => ipcRenderer.invoke('save-global-settings', settings),
|
saveGlobalSettings: (settings) => ipcRenderer.invoke('save-global-settings', settings),
|
||||||
|
saveGlobalSettingsSync: (settings) => ipcRenderer.sendSync('save-global-settings-sync', settings),
|
||||||
|
|
||||||
// Always on top
|
// Always on top
|
||||||
setAlwaysOnTop: (value) => ipcRenderer.invoke('set-always-on-top', value),
|
setAlwaysOnTop: (value) => ipcRenderer.invoke('set-always-on-top', value),
|
||||||
@ -33,8 +36,14 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
startUpload: (payload) => ipcRenderer.invoke('start-upload', payload),
|
startUpload: (payload) => ipcRenderer.invoke('start-upload', payload),
|
||||||
cancelUpload: () => ipcRenderer.invoke('cancel-upload'),
|
cancelUpload: () => ipcRenderer.invoke('cancel-upload'),
|
||||||
cancelSelectedJobs: (jobIds) => ipcRenderer.invoke('cancel-selected-jobs', jobIds),
|
cancelSelectedJobs: (jobIds) => ipcRenderer.invoke('cancel-selected-jobs', jobIds),
|
||||||
|
addJobsToBatch: (payload) => ipcRenderer.invoke('add-jobs-to-batch', payload),
|
||||||
finishAfterActive: () => ipcRenderer.invoke('finish-after-active'),
|
finishAfterActive: () => ipcRenderer.invoke('finish-after-active'),
|
||||||
runHealthCheck: (payload) => ipcRenderer.invoke('run-health-check', payload),
|
runHealthCheck: (payload) => ipcRenderer.invoke('run-health-check', payload),
|
||||||
|
validateCredentials: (payload) => ipcRenderer.invoke('validate-credentials', payload),
|
||||||
|
|
||||||
|
// Log import
|
||||||
|
readOwnUploadLog: () => ipcRenderer.invoke('read-own-upload-log'),
|
||||||
|
importUploadLog: () => ipcRenderer.invoke('import-upload-log'),
|
||||||
|
|
||||||
// Clipboard
|
// Clipboard
|
||||||
copyToClipboard: (text) => ipcRenderer.invoke('copy-to-clipboard', text),
|
copyToClipboard: (text) => ipcRenderer.invoke('copy-to-clipboard', text),
|
||||||
@ -52,8 +61,8 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Backup
|
// Backup
|
||||||
exportBackup: (password) => ipcRenderer.invoke('export-backup', password),
|
exportBackup: () => ipcRenderer.invoke('export-backup'),
|
||||||
importBackup: (password) => ipcRenderer.invoke('import-backup', password),
|
importBackup: (legacyPassword) => ipcRenderer.invoke('import-backup', legacyPassword),
|
||||||
|
|
||||||
// Folder Monitor
|
// Folder Monitor
|
||||||
folderMonitorStart: (settings) => ipcRenderer.invoke('folder-monitor:start', settings),
|
folderMonitorStart: (settings) => ipcRenderer.invoke('folder-monitor:start', settings),
|
||||||
@ -64,6 +73,11 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
ipcRenderer.on('folder-monitor:new-files', (_event, data) => callback(data));
|
ipcRenderer.on('folder-monitor:new-files', (_event, data) => callback(data));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Account switched event
|
||||||
|
onAccountSwitched: (callback) => {
|
||||||
|
ipcRenderer.on('account-switched', (_event, data) => callback(data));
|
||||||
|
},
|
||||||
|
|
||||||
// Drop Target
|
// Drop Target
|
||||||
showDropTarget: () => ipcRenderer.invoke('show-drop-target'),
|
showDropTarget: () => ipcRenderer.invoke('show-drop-target'),
|
||||||
hideDropTarget: () => ipcRenderer.invoke('hide-drop-target'),
|
hideDropTarget: () => ipcRenderer.invoke('hide-drop-target'),
|
||||||
@ -79,6 +93,9 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
onUploadProgress: (callback) => {
|
onUploadProgress: (callback) => {
|
||||||
ipcRenderer.on('upload-progress', (_event, data) => callback(data));
|
ipcRenderer.on('upload-progress', (_event, data) => callback(data));
|
||||||
},
|
},
|
||||||
|
onUploadProgressBatch: (callback) => {
|
||||||
|
ipcRenderer.on('upload-progress-batch', (_event, batch) => callback(batch));
|
||||||
|
},
|
||||||
onUploadBatchDone: (callback) => {
|
onUploadBatchDone: (callback) => {
|
||||||
ipcRenderer.on('upload-batch-done', (_event, data) => callback(data));
|
ipcRenderer.on('upload-batch-done', (_event, data) => callback(data));
|
||||||
},
|
},
|
||||||
@ -88,6 +105,34 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
onShutdownCountdown: (callback) => {
|
onShutdownCountdown: (callback) => {
|
||||||
ipcRenderer.on('shutdown-countdown', (_event, data) => callback(data));
|
ipcRenderer.on('shutdown-countdown', (_event, data) => callback(data));
|
||||||
},
|
},
|
||||||
|
onUploadLogFallback: (callback) => {
|
||||||
|
ipcRenderer.on('upload-log-fallback', (_event, data) => callback(data));
|
||||||
|
},
|
||||||
|
onAccountRotationLog: (callback) => {
|
||||||
|
ipcRenderer.on('account-rotation-log', (_event, data) => callback(data));
|
||||||
|
},
|
||||||
|
openLogFolder: () => ipcRenderer.invoke('open-log-folder'),
|
||||||
|
getJobLog: (jobId) => ipcRenderer.invoke('get-job-log', jobId),
|
||||||
|
getSessionFailedAccounts: () => ipcRenderer.invoke('get-session-failed-accounts'),
|
||||||
|
resetSessionFailedAccount: (payload) => ipcRenderer.invoke('reset-session-failed-account', payload),
|
||||||
|
resetAllSessionFailedAccounts: () => ipcRenderer.invoke('reset-all-session-failed-accounts'),
|
||||||
|
getLogPaths: () => ipcRenderer.invoke('get-log-paths'),
|
||||||
|
revealLogFile: (target) => ipcRenderer.invoke('reveal-log-file', target),
|
||||||
|
setLogVerbose: (enabled) => ipcRenderer.invoke('set-log-verbose', enabled),
|
||||||
|
createSupportBundle: () => ipcRenderer.invoke('create-support-bundle'),
|
||||||
|
getAppInfo: () => ipcRenderer.invoke('get-app-info'),
|
||||||
|
onLogPathAutoUpdated: (callback) => {
|
||||||
|
ipcRenderer.on('log-path-auto-updated', (_event, data) => callback(data));
|
||||||
|
},
|
||||||
|
// Remote Control
|
||||||
|
remoteGetSettings: () => ipcRenderer.invoke('remote:get-settings'),
|
||||||
|
remoteSaveSettings: (settings) => ipcRenderer.invoke('remote:save-settings', settings),
|
||||||
|
remoteGenerateToken: () => ipcRenderer.invoke('remote:generate-token'),
|
||||||
|
remoteStatus: () => ipcRenderer.invoke('remote:status'),
|
||||||
|
onRemoteClientCount: (callback) => {
|
||||||
|
ipcRenderer.on('remote:client-count', (_event, count) => callback(count));
|
||||||
|
},
|
||||||
|
|
||||||
// File path from drag & drop (Electron 33+ compatible)
|
// File path from drag & drop (Electron 33+ compatible)
|
||||||
getPathForFile: (file) => webUtils.getPathForFile(file),
|
getPathForFile: (file) => webUtils.getPathForFile(file),
|
||||||
removeAllListeners: () => {
|
removeAllListeners: () => {
|
||||||
@ -99,5 +144,7 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
ipcRenderer.removeAllListeners('shutdown-countdown');
|
ipcRenderer.removeAllListeners('shutdown-countdown');
|
||||||
ipcRenderer.removeAllListeners('folder-monitor:new-files');
|
ipcRenderer.removeAllListeners('folder-monitor:new-files');
|
||||||
ipcRenderer.removeAllListeners('drop-target:files');
|
ipcRenderer.removeAllListeners('drop-target:files');
|
||||||
|
ipcRenderer.removeAllListeners('account-switched');
|
||||||
|
ipcRenderer.removeAllListeners('remote:client-count');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
3107
renderer/app.js
3107
renderer/app.js
File diff suppressed because it is too large
Load Diff
@ -77,14 +77,14 @@
|
|||||||
<table class="queue-table" id="queueTable">
|
<table class="queue-table" id="queueTable">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="col-filename sortable" data-sort="filename">Filename</th>
|
<th class="col-filename sortable" data-col="filename" data-sort="filename">Filename<span class="col-resizer"></span></th>
|
||||||
<th class="col-size sortable" data-sort="size">Uploaded / Size</th>
|
<th class="col-size sortable" data-col="size" data-sort="size">Uploaded / Size<span class="col-resizer"></span></th>
|
||||||
<th class="col-host sortable" data-sort="host">Host</th>
|
<th class="col-host sortable" data-col="host" data-sort="host">Host<span class="col-resizer"></span></th>
|
||||||
<th class="col-status sortable" data-sort="status">Status</th>
|
<th class="col-status sortable" data-col="status" data-sort="status">Status<span class="col-resizer"></span></th>
|
||||||
<th class="col-elapsed">Zeit</th>
|
<th class="col-elapsed" data-col="elapsed">Zeit<span class="col-resizer"></span></th>
|
||||||
<th class="col-remaining">Rest</th>
|
<th class="col-remaining" data-col="remaining">Rest<span class="col-resizer"></span></th>
|
||||||
<th class="col-speed sortable" data-sort="speed">Speed</th>
|
<th class="col-speed sortable" data-col="speed" data-sort="speed">Speed<span class="col-resizer"></span></th>
|
||||||
<th class="col-progress sortable" data-sort="progress">Progress</th>
|
<th class="col-progress sortable" data-col="progress" data-sort="progress">Progress</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="queueBody"></tbody>
|
<tbody id="queueBody"></tbody>
|
||||||
@ -93,7 +93,16 @@
|
|||||||
|
|
||||||
<div class="queue-actions" id="queueActions" style="display:none">
|
<div class="queue-actions" id="queueActions" style="display:none">
|
||||||
<button class="btn btn-xs btn-primary" id="copyAllLinksBtn">Alle Links kopieren</button>
|
<button class="btn btn-xs btn-primary" id="copyAllLinksBtn">Alle Links kopieren</button>
|
||||||
|
<select class="hs-input" id="linkExportFormat" title="Ausgabe-Format der kopierten Links" style="max-width:none;width:auto;min-width:130px">
|
||||||
|
<option value="plain">Plaintext</option>
|
||||||
|
<option value="bbcode">BBCode</option>
|
||||||
|
<option value="markdown">Markdown</option>
|
||||||
|
<option value="html">HTML</option>
|
||||||
|
<option value="csv">CSV</option>
|
||||||
|
<option value="json">JSON</option>
|
||||||
|
</select>
|
||||||
<button class="btn btn-xs btn-secondary" id="retryFailedBtn" style="display:none">Fehlgeschlagene erneut</button>
|
<button class="btn btn-xs btn-secondary" id="retryFailedBtn" style="display:none">Fehlgeschlagene erneut</button>
|
||||||
|
<button class="btn btn-xs btn-secondary" id="importLogBtn" title="Log importieren — bereits hochgeladene aus Queue entfernen">Log importieren</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="resize-handle" id="recentFilesResizer"></div>
|
<div class="resize-handle" id="recentFilesResizer"></div>
|
||||||
@ -104,6 +113,8 @@
|
|||||||
<button class="recent-tab" data-panel="statsTab">Stats</button>
|
<button class="recent-tab" data-panel="statsTab">Stats</button>
|
||||||
</div>
|
</div>
|
||||||
<span class="recent-files-hint" id="recentFilesHint">Zuletzt erzeugte Upload-Links</span>
|
<span class="recent-files-hint" id="recentFilesHint">Zuletzt erzeugte Upload-Links</span>
|
||||||
|
<button class="btn btn-xs btn-secondary" id="exportRecentFilesBtn" title="Alle Zeilen als Datei exportieren (Zeit, Hoster, Link, Dateiname)">Exportieren</button>
|
||||||
|
<button class="btn btn-xs btn-danger" id="clearRecentFilesBtn" title="Alle Links aus diesem Panel entfernen">Alle entfernen</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="recent-tab-body active" id="filesTab">
|
<div class="recent-tab-body active" id="filesTab">
|
||||||
<div class="recent-files-table-wrap">
|
<div class="recent-files-table-wrap">
|
||||||
@ -184,6 +195,10 @@
|
|||||||
<label>Hoster</label>
|
<label>Hoster</label>
|
||||||
<select class="key-input" id="accountHosterSelect" style="max-width:300px"></select>
|
<select class="key-input" id="accountHosterSelect" style="max-width:300px"></select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="settings-row">
|
||||||
|
<label>Label (optional)</label>
|
||||||
|
<input type="text" class="key-input" id="accField_label" placeholder="z.B. Hauptaccount, Premium, Kunde XY" maxlength="60">
|
||||||
|
</div>
|
||||||
<div id="accountCredsFields"></div>
|
<div id="accountCredsFields"></div>
|
||||||
<div class="account-modal-status" id="accountModalStatus"></div>
|
<div class="account-modal-status" id="accountModalStatus"></div>
|
||||||
</div>
|
</div>
|
||||||
@ -194,6 +209,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-overlay" id="jobLogModal" style="display:none">
|
||||||
|
<div class="modal-card" style="width:min(820px,96%);max-height:80vh;display:flex;flex-direction:column">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div><h3 id="jobLogTitle">Upload-Log</h3></div>
|
||||||
|
<button class="icon-btn" id="closeJobLogBtn" aria-label="Schließen">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" style="flex:1 1 auto;overflow:auto">
|
||||||
|
<pre id="jobLogBody" style="white-space:pre-wrap;font-family:ui-monospace,Consolas,Menlo,monospace;font-size:12px;line-height:1.4;margin:0">Keine Einträge.</pre>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-secondary" id="copyJobLogBtn">In Zwischenablage</button>
|
||||||
|
<button class="btn btn-primary" id="closeJobLogBtn2">Schließen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="modal-overlay" id="deleteAccountModal" style="display:none">
|
<div class="modal-overlay" id="deleteAccountModal" style="display:none">
|
||||||
<div class="modal-card" style="width:min(400px,100%)">
|
<div class="modal-card" style="width:min(400px,100%)">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
@ -226,45 +257,29 @@
|
|||||||
<div class="history-container">
|
<div class="history-container">
|
||||||
<div class="history-header">
|
<div class="history-header">
|
||||||
<h2>Upload-Verlauf</h2>
|
<h2>Upload-Verlauf</h2>
|
||||||
<button class="btn btn-secondary" id="clearHistoryBtn">Verlauf löschen</button>
|
<div style="display:flex; gap:8px">
|
||||||
|
<button class="btn btn-secondary" id="exportHistoryBtn">Verlauf exportieren</button>
|
||||||
|
<button class="btn btn-secondary" id="clearHistoryBtn">Verlauf löschen</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="historyContainer"></div>
|
<div id="historyContainer"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal-overlay" id="backupPasswordModal" style="display:none">
|
|
||||||
<div class="modal-card" style="width:min(400px,100%)">
|
|
||||||
<div class="modal-header">
|
|
||||||
<div><h3 id="backupModalTitle">Passwort</h3></div>
|
|
||||||
<button class="icon-btn" id="closeBackupModalBtn" aria-label="Schließen">×</button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<div class="settings-row">
|
|
||||||
<label for="backupPassword">Passwort</label>
|
|
||||||
<input type="password" class="key-input" id="backupPassword" placeholder="Passwort eingeben" autocomplete="off">
|
|
||||||
</div>
|
|
||||||
<div class="settings-row" id="backupConfirmRow" style="display:none">
|
|
||||||
<label for="backupPasswordConfirm">Bestätigen</label>
|
|
||||||
<input type="password" class="key-input" id="backupPasswordConfirm" placeholder="Passwort wiederholen" autocomplete="off">
|
|
||||||
</div>
|
|
||||||
<div class="account-modal-status" id="backupModalStatus"></div>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button class="btn btn-secondary" id="cancelBackupModalBtn">Abbrechen</button>
|
|
||||||
<button class="btn btn-primary" id="confirmBackupBtn">OK</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="context-menu" id="contextMenu" style="display:none">
|
<div class="context-menu" id="contextMenu" style="display:none">
|
||||||
<div class="ctx-item" data-action="start-selected">Ausgewählte starten</div>
|
<div class="ctx-item" data-action="start-selected">Ausgewählte starten</div>
|
||||||
<div class="ctx-item" data-action="retry-selected">Erneut versuchen</div>
|
<div class="ctx-item" data-action="retry-selected">Erneut versuchen</div>
|
||||||
|
<div class="ctx-item" data-action="show-log">Log anzeigen</div>
|
||||||
<div class="ctx-separator"></div>
|
<div class="ctx-separator"></div>
|
||||||
<div class="ctx-item" data-action="copy-links">Links kopieren</div>
|
<div class="ctx-item" data-action="copy-links">Links kopieren</div>
|
||||||
<div class="ctx-item" data-action="copy-all-links">Alle Links kopieren</div>
|
<div class="ctx-item" data-action="copy-all-links">Alle Links kopieren</div>
|
||||||
<div class="ctx-separator"></div>
|
<div class="ctx-separator"></div>
|
||||||
<div class="ctx-item" data-action="delete-selected">Entfernen</div>
|
<div class="ctx-item" data-action="delete-selected">Entfernen</div>
|
||||||
<div class="ctx-item" data-action="delete-all">Alle entfernen</div>
|
<div class="ctx-item" data-action="delete-all">Alle entfernen</div>
|
||||||
|
<div class="ctx-submenu ctx-hoster-delete-submenu" style="display:none">
|
||||||
|
<div class="ctx-item ctx-item-danger">Hoster entfernen ▸</div>
|
||||||
|
<div class="ctx-submenu-items ctx-hoster-delete-items"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="context-menu" id="recentContextMenu" style="display:none">
|
<div class="context-menu" id="recentContextMenu" style="display:none">
|
||||||
@ -327,6 +342,28 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="modal" id="batchSummaryModal" style="display:none">
|
||||||
|
<div class="modal-content" style="max-width:680px">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>Batch-Zusammenfassung</h2>
|
||||||
|
<button class="icon-btn" id="batchSummaryClose" aria-label="Schließen">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div id="batchSummaryList"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-secondary" id="batchSummaryRetryTransient">Transiente erneut hochladen</button>
|
||||||
|
<button class="btn btn-primary" id="batchSummaryRetryAll">Alle Fehler erneut versuchen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="../lib/queue-prune.js"></script>
|
||||||
|
<script src="../lib/queue-dedup.js"></script>
|
||||||
|
<script src="../lib/log-mode.js"></script>
|
||||||
|
<script src="../lib/stats.js"></script>
|
||||||
|
<script src="../lib/throttled-cache.js"></script>
|
||||||
|
<script src="../lib/coalesced-set.js"></script>
|
||||||
<script src="app.js"></script>
|
<script src="app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -267,6 +267,23 @@ body {
|
|||||||
}
|
}
|
||||||
.queue-table th.sortable { cursor: pointer; }
|
.queue-table th.sortable { cursor: pointer; }
|
||||||
.queue-table th.sortable:hover { color: var(--text); }
|
.queue-table th.sortable:hover { color: var(--text); }
|
||||||
|
.queue-table th { position: relative; }
|
||||||
|
|
||||||
|
.col-resizer {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 6px;
|
||||||
|
height: 100%;
|
||||||
|
cursor: col-resize;
|
||||||
|
user-select: none;
|
||||||
|
z-index: 6;
|
||||||
|
background: transparent;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.col-resizer:hover { background: rgba(102, 126, 234, 0.4); }
|
||||||
|
.col-resizer.dragging { background: rgba(102, 126, 234, 0.6); }
|
||||||
|
body.col-resizing, body.col-resizing * { cursor: col-resize !important; user-select: none !important; }
|
||||||
|
|
||||||
.col-filename { width: 30%; }
|
.col-filename { width: 30%; }
|
||||||
.col-size { width: 12%; }
|
.col-size { width: 12%; }
|
||||||
@ -288,8 +305,12 @@ body {
|
|||||||
.virtual-spacer td { padding: 0 !important; border: none !important; }
|
.virtual-spacer td { padding: 0 !important; border: none !important; }
|
||||||
|
|
||||||
/* Queue Row States */
|
/* Queue Row States */
|
||||||
.queue-row { transition: background 0.15s; cursor: pointer; }
|
/* Transition only on hover-enter/leave so that status flips during a busy
|
||||||
.queue-row:hover { background: rgba(255, 255, 255, 0.04); }
|
upload (queued→getting-server→uploading→done) don't trigger compositor
|
||||||
|
repaints with 150ms tweens for every visible row. With 30+ rows flipping
|
||||||
|
simultaneously the overlapping transitions cost real GPU time. */
|
||||||
|
.queue-row { cursor: pointer; }
|
||||||
|
.queue-row:hover { background: rgba(255, 255, 255, 0.04); transition: background 0.15s; }
|
||||||
.queue-row.selected { background: rgba(102, 126, 234, 0.12) !important; }
|
.queue-row.selected { background: rgba(102, 126, 234, 0.12) !important; }
|
||||||
|
|
||||||
.queue-row.status-uploading { background: rgba(102, 126, 234, 0.08); }
|
.queue-row.status-uploading { background: rgba(102, 126, 234, 0.08); }
|
||||||
@ -409,6 +430,10 @@ body {
|
|||||||
.recent-files-hint {
|
.recent-files-hint {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
.recent-files-header #clearRecentFilesBtn {
|
||||||
|
margin-left: 8px;
|
||||||
}
|
}
|
||||||
.stats-grid {
|
.stats-grid {
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -605,6 +630,8 @@ body {
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
.ctx-item:hover { background: rgba(102, 126, 234, 0.2); }
|
.ctx-item:hover { background: rgba(102, 126, 234, 0.2); }
|
||||||
|
.ctx-item-danger { color: var(--danger); }
|
||||||
|
.ctx-item-danger:hover { background: rgba(231, 76, 60, 0.2); }
|
||||||
.ctx-separator { height: 1px; margin: 4px 8px; background: var(--border); }
|
.ctx-separator { height: 1px; margin: 4px 8px; background: var(--border); }
|
||||||
.ctx-submenu { position: relative; }
|
.ctx-submenu { position: relative; }
|
||||||
.ctx-submenu-items {
|
.ctx-submenu-items {
|
||||||
@ -686,8 +713,21 @@ body {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
max-width: 300px;
|
max-width: 300px;
|
||||||
}
|
}
|
||||||
|
/* Checkbox-type .hs-input (e.g. per-hoster "Links in Log schreiben") must
|
||||||
|
not inherit the stretched text-input box styling above — render it as a
|
||||||
|
normal small checkbox sitting next to its label. */
|
||||||
|
.hs-input[type="checkbox"] {
|
||||||
|
flex: none;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
max-width: none;
|
||||||
|
padding: 0;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
.key-input:focus, .hs-input:focus { border-color: var(--accent); outline: none; }
|
.key-input:focus, .hs-input:focus { border-color: var(--accent); outline: none; }
|
||||||
.hs-input { max-width: 100px; }
|
.hs-input { max-width: 100px; }
|
||||||
|
select.hs-input { max-width: none; width: auto; min-width: 140px; }
|
||||||
.hint { font-size: 10px; color: var(--text-dim); }
|
.hint { font-size: 10px; color: var(--text-dim); }
|
||||||
.settings-section-label {
|
.settings-section-label {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
@ -810,6 +850,123 @@ body {
|
|||||||
.account-modal-status.ok { color: var(--success); }
|
.account-modal-status.ok { color: var(--success); }
|
||||||
.account-modal-status.error { color: var(--danger); }
|
.account-modal-status.error { color: var(--danger); }
|
||||||
|
|
||||||
|
/* Multi-account: drag handle, priority badge, hoster group */
|
||||||
|
.account-card-drag-handle {
|
||||||
|
cursor: grab;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
padding: 2px 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.account-card-drag-handle:hover { color: var(--text-muted); }
|
||||||
|
.account-card.dragging { opacity: 0.4; }
|
||||||
|
.account-card.drag-over-above { border-top: 2px solid var(--accent); }
|
||||||
|
.account-card.drag-over-below { border-bottom: 2px solid var(--accent); }
|
||||||
|
|
||||||
|
.account-priority-badge {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-dim);
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-hoster-group {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.account-hoster-group-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
background: var(--bg-card);
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
.account-hoster-group-header:hover { background: var(--bg-card-hover); }
|
||||||
|
.account-hoster-group-title {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.account-hoster-group-count {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.account-hoster-group-meta {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(255,255,255,0.04);
|
||||||
|
}
|
||||||
|
.account-hoster-group-meta.error {
|
||||||
|
color: var(--danger, #e57373);
|
||||||
|
background: rgba(229, 115, 115, 0.12);
|
||||||
|
}
|
||||||
|
.account-session-paused {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: #f0c36c;
|
||||||
|
background: rgba(240, 195, 108, 0.12);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
.account-session-reactivate {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0 2px;
|
||||||
|
}
|
||||||
|
.account-session-reactivate:hover { color: #fff; }
|
||||||
|
.account-session-paused-card { opacity: 0.85; }
|
||||||
|
.batch-cat {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(255,255,255,0.03);
|
||||||
|
}
|
||||||
|
.batch-cat-head { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; font-size: 13px; }
|
||||||
|
.batch-cat-count { color: var(--text-muted); font-variant-numeric: tabular-nums; }
|
||||||
|
.batch-cat-tag { font-size: 10px; padding: 1px 6px; border-radius: 4px; background: rgba(255,255,255,0.06); color: var(--text-muted); }
|
||||||
|
.batch-cat-tag.retryable { background: rgba(76, 175, 80, 0.18); color: #a5d6a7; }
|
||||||
|
.batch-cat-list { margin: 0; padding-left: 18px; font-size: 11px; color: var(--text-muted); }
|
||||||
|
.batch-cat-list em { color: var(--text-muted); font-style: italic; }
|
||||||
|
.account-hoster-group-body {
|
||||||
|
padding: 8px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.account-hoster-group .account-card { margin-bottom: 4px; }
|
||||||
|
.account-hoster-group .account-card:last-child { margin-bottom: 0; }
|
||||||
|
.account-status-dot.status-ok { background: #4caf50; }
|
||||||
|
.account-status-dot.status-error { background: #e57373; }
|
||||||
|
.account-status-dot.status-checking { background: #f0c36c; }
|
||||||
|
.account-status-dot.status-unchecked { background: #6c757d; }
|
||||||
|
.account-hoster-group-header .account-status-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
.accounts-empty {
|
.accounts-empty {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 48px 16px;
|
padding: 48px 16px;
|
||||||
|
|||||||
64
tasks/lessons.md
Normal file
64
tasks/lessons.md
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
# Lessons
|
||||||
|
|
||||||
|
## 2026-04-21 — DOM-Doppelrender bei Bulk-State-Changes
|
||||||
|
**Symptom:** User klickt auf "Erneut versuchen" mit 500+ Jobs → App hängt sekundenlang.
|
||||||
|
**Root cause:** `retrySelectedJobs()` ruft `renderQueueTable + updateQueueActionButtons + updateStatusBar` auf, `startSelectedUpload()` ruft direkt danach genau dieselben Funktionen nochmal auf.
|
||||||
|
**Regel:** Wenn ein Click-Handler `await anotherHandler()` aufruft und der innere Handler seinen eigenen kompletten Render-Zyklus hat, NIEMALS noch einen davor. Einmal ist genug — der folgende innere Render sieht die frischen State-Mutationen ohnehin.
|
||||||
|
**Wie anwenden:** Vor jeder `await fn()`-Folge in einem Handler prüfen: macht `fn` schon `renderQueueTable()`? Wenn ja, äußere Render-Calls löschen.
|
||||||
|
|
||||||
|
## 2026-04-21 — State-Checks MÜSSEN hinter die Semaphore-Queue
|
||||||
|
**Symptom:** Pre-Job-Swap prüfte `_failedAccounts` vor `semaphore.acquire`. Bei N parallelen Workers war der Check zum Start für ALLE leer — niemand hat geswapt. Erst nachdem alle im Semaphore ordentlich gewartet hatten und einer fehlschlug, wurde _failedAccounts befüllt, aber die anderen hatten ihren Check längst hinter sich.
|
||||||
|
**Regel:** State-basierte Entscheidungen (failed accounts, overrides, cached stats) gehören direkt vor die Aktion die sie betreffen — **nach** jeder async `await` die die Position in der Queue bestimmt. Nicht am Task-Start für später wichtigen State abfragen.
|
||||||
|
**Wie anwenden:** Bei Queue-basierten Pipelines prüfen: "Was kann sich zwischen Task-Start und dem tatsächlichen Execute ändern?" Alles was sich ändern kann, muss direkt vor dem Execute geprüft werden, nicht davor.
|
||||||
|
|
||||||
|
## 2026-04-21 — Reaktive Config-Updates für laufende State-Maschinen
|
||||||
|
**Symptom:** User fügt mid-batch einen neuen Account hinzu, aber der UploadManager merkt nicht dass die Config sich geändert hat. `account-failed` Event feuert nur einmal pro Account → keine zweite Re-Resolve-Chance.
|
||||||
|
**Regel:** Wenn ein State nur bei Events neu evaluiert wird und Events "nur einmal" feuern, muss jede externe Zustandsänderung (Config-Save, User-Action) den State explizit triggern.
|
||||||
|
**Wie anwenden:** Save-Handler müssen aktive State-Maschinen informieren. Lieber einen überflüssigen Re-Resolve-Call als einen verpassten. Für Upload-Manager: nach saveConfig → re-evaluate failed accounts ohne Override.
|
||||||
|
|
||||||
|
## 2026-04-21 — Error-Klassifikation: fileRejected vs accountError
|
||||||
|
**Symptom:** Voller Byse-Account wurde nicht rotiert — `skip-rotation-file-rejected` geloggt für jede Datei.
|
||||||
|
**Root cause:** Generisches Match auf Prefix-String (`"lehnte Datei ab"`) klassifizierte ALLE Byse-Errors als file-level, inklusive Account-voll-Meldungen.
|
||||||
|
**Regel:** Hoster-Parser setzen den **spezifischen Flag** (`fileRejected` ODER `accountError`), nicht beide nie. Classifier matcht **konkrete Phrasen** (Duplicate, Not video format, …), niemals generische Wrapper-Strings die für mehrere Fehlerarten benutzt werden.
|
||||||
|
**Wie anwenden:**
|
||||||
|
- Bei neuen Hostern: per-status-Klassifikation bereits im Parser, nicht erst im Upload-Manager.
|
||||||
|
- Classifier-Regexes auf Rejection-Kernphrasen, nicht auf UI-Prefix.
|
||||||
|
- Defensive: `accountError === true` gewinnt immer gegen `fileRejected` — Account-Rotation ist weniger schlimm als endlose Fails auf einem toten Account.
|
||||||
|
|
||||||
|
## 2026-04-21 — Keine fake Build-ETAs
|
||||||
|
**Symptom:** User wartet 5+ min auf Tauri-Build den ich mit "1-2min" angekündigt habe.
|
||||||
|
**Regel:** Tauri-Release-Builds brauchen real 3-6 min (Rust + NSIS + MSI). Keine Zeitangabe oder ehrlich "kann 3-6min dauern" schreiben.
|
||||||
|
**Wie anwenden:** Wenn User nach Status fragt: sofort `tail` des Logs + `ls` des Bundle-Ordners zitieren, nicht raten.
|
||||||
|
|
||||||
|
## 2026-05-24 — Packaged-Electron Log-Pfade: nie __dirname/.. zum Schreiben
|
||||||
|
**Symptom:** doodstream-debug.log hatte auf dem Server null aktuelle Einträge; nur alte Dev-Logs. Fehler "kein Filecode" war nicht diagnostizierbar.
|
||||||
|
**Root cause:** `path.join(__dirname, '..', 'x.log')` zeigt im gepackten Build in `resources/app.asar` (read-only). `fs.appendFileSync` wirft EACCES, der `try/catch` schluckt es → null Production-Logs.
|
||||||
|
**Regel:** Schreibbare Pfade IMMER über `app.getPath('userData')` (lazy `require('electron')`, Fallback `__dirname/..` nur für Tests/plain-node). Gilt für jede Datei die der gepackte App schreibt.
|
||||||
|
**Wie anwenden:** Bei jedem neuen Log/Cache/State-File prüfen: wohin schreibt das im NSIS-Build? Nicht ins Install-Verzeichnis, nicht in asar.
|
||||||
|
|
||||||
|
## 2026-05-24 — Hoster-Fehler: echten Status surfacen, nicht generisch schlucken
|
||||||
|
**Symptom:** "upload_result Seite hat keinen filecode (<leeres textarea>)" — nichtssagend; User dachte doodstream-Format geändert.
|
||||||
|
**Root cause:** XFileSharing liefert den echten Grund im `st`-Feld (Error: duplicate / file too big / …). Code ignorierte `st` komplett und warf nur den leeren Body.
|
||||||
|
**Regel:** Bei Hoster-Parsefehlern immer die Server-Statusfelder (st/msg/code) + Kontext (welcher CDN-Node, war filecode da) in die Fehlermeldung packen. Format-Struktur unverändert + leerer Inhalt = Backend-Ablehnung, kein Parsing-Bug.
|
||||||
|
|
||||||
|
## 2026-05-25 — Queue leer nach Update: Auto-Dedup zu aggressiv (nicht Save/Restore)
|
||||||
|
**Symptom:** Queue gestoppt, App-Update -> nach Neustart Queue leer ("Dateien hierhin ziehen"). User dachte Save/Restore kaputt.
|
||||||
|
**Root cause:** Queue WIRD korrekt gespeichert (pendingQueue) + restored. ABER `_autoDeduplicateFromLog` (läuft bei init nach restore) entfernte Jobs per `fileName|hoster`-Match gegen das GESAMTE Lifetime-fileuploader.log — UNABHÄNGIG vom Status. Pending 'preview'-Jobs, deren Datei früher mal hochgeladen wurde, flogen alle raus -> komplette Queue weg. "Update-spezifisch" nur weil der Server-App nur beim Update neustartet (normaler Restart hätte dasselbe getan).
|
||||||
|
**Verifiziert:** Reale electron-config.json: 4 preview-Jobs, alle 4 Keys im Log -> alte Logik entfernt 4/4. Neue Logik (nur status==='done' droppen) entfernt 0/4.
|
||||||
|
**Regel:** Auto-Cleanup/Dedup darf NIE pending/actionable User-Arbeit löschen. Nur genuin abgeschlossene ('done') Jobs decluttern. Lifetime-Logs sind Historie, nicht Session-Fortschritt — nicht als "schon erledigt"-Quelle für pending Jobs missbrauchen.
|
||||||
|
**Wie anwenden:** Bei jeder Filter/Remove-Logik auf User-State: nach Status gaten, nicht nur nach Identitäts-Match gegen historische Daten.
|
||||||
|
|
||||||
|
## 2026-05-28 — Doodstream "kein Filecode": Web-Scraping ist die falsche Ebene, API ist der Fix
|
||||||
|
**Symptom:** Wiederkehrend "kein Filecode — Server gab leeren Link zurueck" bei großen Dateien (~1GB/7min Upload), trotz 3.3.26-3.3.29. Queue voll roter Fehler.
|
||||||
|
**Root cause (recherchiert + verifiziert):** Der Web-Upload holt den Filecode aus einem XFileSharing-HTML-Formular. Bei langen Uploads kommt das Formular leer zurück, weil (a) der per-Seitenaufruf sess_id-Token über den 7min-Upload altert UND (b) der server-seitige File-Registration-Callback (cgi-bin/fs.cgi-Äquivalent) unter Last timeoutet → kein file_code gemintet. Wichtig: Das ist KEIN async-delay — die Datei taucht NICHT später in der Liste auf (die Registrierung, die sie listen würde, ist genau das was failt). File-list-Polling (wie Byse) hilft hier also kaum.
|
||||||
|
**Fix:** Die offizielle doodapi.co JSON-API nutzen, wenn ein API-Key da ist — sie liefert result[0].filecode DIREKT in JSON (kein HTML-Formular) und nutzt einen persistenten api_key (kein alternder sess_id). Git-Historie: die API war der ORIGINAL-Pfad (initial commit); Web-Login kam später nur "als Alternative zum API-Key" — Key-Bevorzugung stellt also den gedachten Primärpfad wieder her, kämpft nicht gegen eine bewusste Entscheidung.
|
||||||
|
**Regel:** Bei Hoster-Integrationen die offizielle API der Web-Scraping-Ebene vorziehen wo möglich. Empty-form/codeless-2xx = Hoster-Backend-Flake (hosterTransient), Account NICHT als tot markieren — auf BEIDEN Pfaden (Web + API) gleich klassifizieren.
|
||||||
|
**Voraussetzung:** Engagiert nur wenn der Doodstream-Account einen gültigen API-Key hat (doodstream.com/settings). Keyless-Accounts bleiben beim Web-Pfad.
|
||||||
|
|
||||||
|
## 2026-05-28 — Doodstream empty-form: live diagnosis confirmed API path is the fix
|
||||||
|
**Verifiziert mit echtem Account-Key (read-only API-Calls):**
|
||||||
|
- account/info → status 200, Key gültig, Storage unlimited. Premium ABGELAUFEN (2025-10-03) — Uploads gehen TROTZDEM.
|
||||||
|
- upload/server → liefert gültigen Node (cv1130ed.cloudatacdn.com) auch ohne Premium → API-Upload-Pfad nutzbar.
|
||||||
|
- file/list → 90.548 Dateien; Uploads landen server-seitig INTERMITTIEREND (viele Burn-Notice-Folgen genau im "Fehler"-Zeitfenster vorhanden). Das leere Formular ist also nicht "immer kaputt", sondern manchmal — der Web-Form-Registrierungs-Callback (fs-public.intconnect.net) timeoutet sporadisch.
|
||||||
|
**Konsequenz:** API-Weg (result[0].filecode inline) umgeht den failenden Callback → richtiger Fix. file/list-Recovery ist NICHT tote Last (Dateien erscheinen ja) — aber bei 90k-Accounts MUSS man sort=created&order=desc erzwingen, sonst ist die frische Datei nicht auf Seite 1.
|
||||||
|
**Regel:** Bei "geht manchmal/manchmal nicht" + Hoster mit offizieller API: erst per read-only API-Call (account/info, file/list) gegen den ECHTEN Account verifizieren statt am Client weiterzuraten. Das beendet Spekulations-Schleifen.
|
||||||
22
tasks/todo.md
Normal file
22
tasks/todo.md
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
# Feature: Per-Hoster Toggle "Links in fileuploader.log schreiben"
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Pro Hoster ein-/ausschaltbar machen ob dessen erfolgreiche Upload-Links in die fileuploader.log geschrieben werden.
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
- [x] `lib/config-store.js` — `logToFile: true` zu `HOSTER_SETTINGS_DEFAULTS` (default an).
|
||||||
|
- [x] `renderer/app.js renderSettings` — Checkbox "Links in Log schreiben" pro Hoster-Panel (`data-hs="logToFile"`, type=checkbox).
|
||||||
|
- [x] `renderer/app.js saveSettings` — collection-loop erweitert: checkbox → boolean.
|
||||||
|
- [x] `lib/log-policy.js` (neu, testbar) — `hosterLogToFileEnabled(hosterSettings, hoster)`, opt-out semantics.
|
||||||
|
- [x] `main.js` — `shouldLogHosterToFile(hoster)` liest live uploadManager.hosterSettings, fallback configStore, dann default true. Guard vor appendUploadLog im done-handler.
|
||||||
|
- [x] Tests: 8 log-policy + 2 config-store (default true, persist false). 147/147 grün.
|
||||||
|
- [x] ESLint clean. Backup-Import robust (default-true bei fehlendem key).
|
||||||
|
|
||||||
|
## Verifikation
|
||||||
|
- logToFile default true → bestehendes Verhalten unverändert für alle die's nicht togglen.
|
||||||
|
- Toggle off für Hoster X → uploads von X werden NICHT geloggt, andere Hoster weiter schon.
|
||||||
|
- Live-Wirkung: `uploadManager.hosterSettings` wird via updateSettings aktualisiert → greift auch mid-batch nach save.
|
||||||
|
|
||||||
|
## Seiteneffekte zu prüfen
|
||||||
|
- Backup-Import/Export: hosterSettings inkl. logToFile mitnehmen (sollte automatisch da generisches Objekt).
|
||||||
|
- Settings-autosave (checkbox change-event ist bereits gehandhabt in der bind-loop).
|
||||||
44
tests/account-auth.test.js
Normal file
44
tests/account-auth.test.js
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const { selectUploadAuth } = require('../lib/account-auth');
|
||||||
|
|
||||||
|
test('doodstream prefers the API key even when username/password are also set', () => {
|
||||||
|
const auth = selectUploadAuth('doodstream.com', {
|
||||||
|
apiKey: 'KEY123', username: 'u', password: 'p'
|
||||||
|
});
|
||||||
|
assert.deepEqual(auth, { apiKey: 'KEY123' }); // API path — no username leaks through
|
||||||
|
});
|
||||||
|
|
||||||
|
test('doodstream with only username/password uses web login (keyless fallback)', () => {
|
||||||
|
const auth = selectUploadAuth('doodstream.com', { username: 'u', password: 'p' });
|
||||||
|
assert.deepEqual(auth, { username: 'u', password: 'p' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('doodstream with empty apiKey + creds falls back to web login (no false API route)', () => {
|
||||||
|
const auth = selectUploadAuth('doodstream.com', { apiKey: '', username: 'u', password: 'p' });
|
||||||
|
assert.deepEqual(auth, { username: 'u', password: 'p' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('doodstream with nothing usable returns empty', () => {
|
||||||
|
assert.deepEqual(selectUploadAuth('doodstream.com', { apiKey: '', username: '', password: '' }), {});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('voe.sx is unaffected by the doodstream special-case: username/password wins', () => {
|
||||||
|
// voe also supports both, but the empty-form bug is doodstream-specific; do
|
||||||
|
// not change voe routing.
|
||||||
|
const auth = selectUploadAuth('voe.sx', { apiKey: 'VKEY', username: 'u', password: 'p' });
|
||||||
|
assert.deepEqual(auth, { username: 'u', password: 'p' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('authType=api forces the API key for any hoster', () => {
|
||||||
|
assert.deepEqual(selectUploadAuth('voe.sx', { authType: 'api', apiKey: 'K', username: 'u', password: 'p' }), { apiKey: 'K' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('api-key-only account (no creds) uses the key', () => {
|
||||||
|
assert.deepEqual(selectUploadAuth('byse.sx', { apiKey: 'BKEY' }), { apiKey: 'BKEY' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('null / non-object account does not throw', () => {
|
||||||
|
assert.deepEqual(selectUploadAuth('doodstream.com', null), {});
|
||||||
|
assert.deepEqual(selectUploadAuth('doodstream.com', undefined), {});
|
||||||
|
});
|
||||||
@ -10,50 +10,64 @@ describe('backup-crypto', () => {
|
|||||||
history: [{ file: 'test.mkv', link: 'https://example.com/abc' }]
|
history: [{ file: 'test.mkv', link: 'https://example.com/abc' }]
|
||||||
};
|
};
|
||||||
|
|
||||||
it('encrypt then decrypt round-trips with correct password', () => {
|
it('encrypt then decrypt round-trips', () => {
|
||||||
const buf = encrypt(sampleConfig, 'mySecret!');
|
const buf = encrypt(sampleConfig);
|
||||||
const result = decrypt(buf, 'mySecret!');
|
const result = decrypt(buf);
|
||||||
assert.deepStrictEqual(result, sampleConfig);
|
assert.deepStrictEqual(result, sampleConfig);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('decrypt with wrong password throws', () => {
|
|
||||||
const buf = encrypt(sampleConfig, 'correct');
|
|
||||||
assert.throws(() => decrypt(buf, 'wrong'), /Falsches Passwort/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('decrypt with corrupted data throws', () => {
|
it('decrypt with corrupted data throws', () => {
|
||||||
const buf = encrypt(sampleConfig, 'pw');
|
const buf = encrypt(sampleConfig);
|
||||||
buf[buf.length - 1] ^= 0xff; // flip last byte
|
buf[buf.length - 1] ^= 0xff; // flip last byte
|
||||||
assert.throws(() => decrypt(buf, 'pw'), /Falsches Passwort/);
|
// With no password: app-key fails → needsPassword surfaces.
|
||||||
|
assert.throws(() => decrypt(buf), (err) => err.needsPassword === true);
|
||||||
|
// With a password: both app-key and password fail → Falsches Passwort.
|
||||||
|
assert.throws(() => decrypt(buf, 'anything'), /Falsches Passwort/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('decrypt with invalid magic throws', () => {
|
it('decrypt with invalid magic throws', () => {
|
||||||
// Buffer must be long enough to pass the length check (>= 4+16+12+16+1 = 49)
|
// Buffer must be long enough to pass the length check (>= 4+16+12+16+1 = 49)
|
||||||
const buf = Buffer.alloc(60, 0x41); // 60 bytes of 'A'
|
const buf = Buffer.alloc(60, 0x41); // 60 bytes of 'A'
|
||||||
assert.throws(() => decrypt(buf, 'pw'), /Keine gültige/);
|
assert.throws(() => decrypt(buf), /Keine gültige/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('decrypt with too-short buffer throws', () => {
|
it('decrypt with too-short buffer throws', () => {
|
||||||
assert.throws(() => decrypt(Buffer.alloc(10), 'pw'), /Ungültiges Backup-Format/);
|
assert.throws(() => decrypt(Buffer.alloc(10)), /Ungültiges Backup-Format/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles empty config gracefully', () => {
|
it('handles empty config gracefully', () => {
|
||||||
const empty = { hosters: {}, hosterSettings: {}, globalSettings: {}, history: [] };
|
const empty = { hosters: {}, hosterSettings: {}, globalSettings: {}, history: [] };
|
||||||
const buf = encrypt(empty, 'pw');
|
const buf = encrypt(empty);
|
||||||
assert.deepStrictEqual(decrypt(buf, 'pw'), empty);
|
assert.deepStrictEqual(decrypt(buf), empty);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles unicode passwords', () => {
|
it('decrypts legacy password-encrypted buffer when password is provided', () => {
|
||||||
const buf = encrypt(sampleConfig, 'Pässwört🔑');
|
// Reproduce the old format: same envelope, but key derived from user password.
|
||||||
const result = decrypt(buf, 'Pässwört🔑');
|
const crypto = require('crypto');
|
||||||
assert.deepStrictEqual(result, sampleConfig);
|
const plaintext = Buffer.from(JSON.stringify(sampleConfig), 'utf-8');
|
||||||
|
const salt = crypto.randomBytes(16);
|
||||||
|
const iv = crypto.randomBytes(12);
|
||||||
|
const key = crypto.pbkdf2Sync('oldUserPw', salt, 100_000, 32, 'sha512');
|
||||||
|
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
||||||
|
const enc = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
||||||
|
const tag = cipher.getAuthTag();
|
||||||
|
const legacyBuf = Buffer.concat([Buffer.from('MHU1'), salt, iv, tag, enc]);
|
||||||
|
|
||||||
|
// Without password → should throw needsPassword
|
||||||
|
assert.throws(() => decrypt(legacyBuf), (err) => err.needsPassword === true);
|
||||||
|
|
||||||
|
// With correct password → should decrypt
|
||||||
|
assert.deepStrictEqual(decrypt(legacyBuf, 'oldUserPw'), sampleConfig);
|
||||||
|
|
||||||
|
// With wrong password → should throw (not needsPassword)
|
||||||
|
assert.throws(() => decrypt(legacyBuf, 'wrongPw'), /Falsches Passwort/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('each encryption produces different output (random salt/iv)', () => {
|
it('each encryption produces different output (random salt/iv)', () => {
|
||||||
const a = encrypt(sampleConfig, 'same');
|
const a = encrypt(sampleConfig);
|
||||||
const b = encrypt(sampleConfig, 'same');
|
const b = encrypt(sampleConfig);
|
||||||
assert.ok(!a.equals(b), 'two encryptions should differ');
|
assert.ok(!a.equals(b), 'two encryptions should differ');
|
||||||
// but both decrypt to same result
|
// but both decrypt to same result
|
||||||
assert.deepStrictEqual(decrypt(a, 'same'), decrypt(b, 'same'));
|
assert.deepStrictEqual(decrypt(a), decrypt(b));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
144
tests/coalesced-set.test.js
Normal file
144
tests/coalesced-set.test.js
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const { makeCoalescedSet } = require('../lib/coalesced-set');
|
||||||
|
|
||||||
|
// Synchronous scheduler stand-in: collects callbacks instead of running
|
||||||
|
// them, so tests can drive the timing explicitly.
|
||||||
|
function makeManualScheduler() {
|
||||||
|
const queue = [];
|
||||||
|
const fn = (cb) => queue.push(cb);
|
||||||
|
fn.flush = () => {
|
||||||
|
while (queue.length) {
|
||||||
|
const cb = queue.shift();
|
||||||
|
cb();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fn.queueLength = () => queue.length;
|
||||||
|
return fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('throws if apply callback missing', () => {
|
||||||
|
assert.throws(() => makeCoalescedSet());
|
||||||
|
assert.throws(() => makeCoalescedSet({}));
|
||||||
|
assert.throws(() => makeCoalescedSet({ apply: 'not-a-fn' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('multiple adds in one tick coalesce into one apply call', () => {
|
||||||
|
const sched = makeManualScheduler();
|
||||||
|
const calls = [];
|
||||||
|
const cs = makeCoalescedSet({
|
||||||
|
apply: (drop) => calls.push([...drop].sort()),
|
||||||
|
scheduler: sched
|
||||||
|
});
|
||||||
|
|
||||||
|
cs.add('a'); cs.add('b'); cs.add('c');
|
||||||
|
assert.equal(sched.queueLength(), 1, 'only one microtask scheduled');
|
||||||
|
assert.equal(cs.pendingSize(), 3);
|
||||||
|
|
||||||
|
sched.flush();
|
||||||
|
assert.deepEqual(calls, [['a', 'b', 'c']]);
|
||||||
|
assert.equal(cs.pendingSize(), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('duplicate adds are deduplicated', () => {
|
||||||
|
const sched = makeManualScheduler();
|
||||||
|
const calls = [];
|
||||||
|
const cs = makeCoalescedSet({ apply: (d) => calls.push([...d]), scheduler: sched });
|
||||||
|
cs.add('a'); cs.add('a'); cs.add('a');
|
||||||
|
sched.flush();
|
||||||
|
assert.deepEqual(calls, [['a']]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('two batches in series stay independent', () => {
|
||||||
|
const sched = makeManualScheduler();
|
||||||
|
const calls = [];
|
||||||
|
const cs = makeCoalescedSet({ apply: (d) => calls.push([...d]), scheduler: sched });
|
||||||
|
|
||||||
|
cs.add('x'); cs.add('y');
|
||||||
|
sched.flush();
|
||||||
|
cs.add('z');
|
||||||
|
sched.flush();
|
||||||
|
|
||||||
|
assert.deepEqual(calls, [['x', 'y'], ['z']]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('add after flush re-schedules a new microtask', () => {
|
||||||
|
const sched = makeManualScheduler();
|
||||||
|
const cs = makeCoalescedSet({ apply: () => {}, scheduler: sched });
|
||||||
|
cs.add('a');
|
||||||
|
assert.equal(sched.queueLength(), 1);
|
||||||
|
sched.flush();
|
||||||
|
assert.equal(sched.queueLength(), 0);
|
||||||
|
assert.equal(cs.isScheduled(), false);
|
||||||
|
cs.add('b');
|
||||||
|
assert.equal(sched.queueLength(), 1, 'new add → new microtask');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('drainSync flushes synchronously without waiting for scheduler', () => {
|
||||||
|
const sched = makeManualScheduler();
|
||||||
|
const calls = [];
|
||||||
|
const cs = makeCoalescedSet({ apply: (d) => calls.push([...d]), scheduler: sched });
|
||||||
|
cs.add('p'); cs.add('q');
|
||||||
|
cs.drainSync();
|
||||||
|
assert.deepEqual(calls, [['p', 'q']]);
|
||||||
|
assert.equal(cs.pendingSize(), 0);
|
||||||
|
|
||||||
|
// Pending microtask was for the same ids — when it runs, pending is empty
|
||||||
|
// → apply NOT called twice.
|
||||||
|
sched.flush();
|
||||||
|
assert.equal(calls.length, 1, 'queued microtask is a no-op after drainSync');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('drainSync on empty set is a no-op', () => {
|
||||||
|
let called = 0;
|
||||||
|
const cs = makeCoalescedSet({ apply: () => called++ });
|
||||||
|
cs.drainSync();
|
||||||
|
assert.equal(called, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('throwing apply does not lock out subsequent batches', () => {
|
||||||
|
const sched = makeManualScheduler();
|
||||||
|
let attempt = 0;
|
||||||
|
const cs = makeCoalescedSet({
|
||||||
|
apply: () => { attempt++; if (attempt === 1) throw new Error('boom'); },
|
||||||
|
scheduler: sched
|
||||||
|
});
|
||||||
|
cs.add('a');
|
||||||
|
// First flush throws inside apply but is swallowed; coalescer must still work.
|
||||||
|
sched.flush();
|
||||||
|
cs.add('b');
|
||||||
|
sched.flush();
|
||||||
|
assert.equal(attempt, 2, 'second batch still ran despite first throwing');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('default scheduler is queueMicrotask (or Promise fallback) — runs eventually', async () => {
|
||||||
|
const calls = [];
|
||||||
|
const cs = makeCoalescedSet({ apply: (d) => calls.push([...d]) });
|
||||||
|
cs.add('z');
|
||||||
|
// Wait one microtask
|
||||||
|
await Promise.resolve();
|
||||||
|
assert.deepEqual(calls, [['z']]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no-op tick: scheduler fires while pending is empty (e.g. drained)', () => {
|
||||||
|
const sched = makeManualScheduler();
|
||||||
|
let called = 0;
|
||||||
|
const cs = makeCoalescedSet({ apply: () => called++, scheduler: sched });
|
||||||
|
cs.add('a');
|
||||||
|
cs.drainSync();
|
||||||
|
assert.equal(called, 1);
|
||||||
|
// Pending microtask still in queue → flush; pending is empty → apply NOT called again.
|
||||||
|
sched.flush();
|
||||||
|
assert.equal(called, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('large burst of 5000 adds coalesces to one apply call', () => {
|
||||||
|
const sched = makeManualScheduler();
|
||||||
|
const calls = [];
|
||||||
|
const cs = makeCoalescedSet({ apply: (d) => calls.push(d.size), scheduler: sched });
|
||||||
|
for (let i = 0; i < 5000; i++) cs.add('id-' + i);
|
||||||
|
assert.equal(sched.queueLength(), 1);
|
||||||
|
sched.flush();
|
||||||
|
assert.deepEqual(calls, [5000]);
|
||||||
|
});
|
||||||
@ -51,22 +51,46 @@ describe('ConfigStore', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('save then load round-trips', async () => {
|
it('save then load round-trips', async () => {
|
||||||
await store.save({ hosters: { 'doodstream.com': { enabled: true, apiKey: 'test-key-123' } } });
|
await store.save({ hosters: { 'doodstream.com': [{ id: 'test-1', enabled: true, authType: 'api', apiKey: 'test-key-123' }] } });
|
||||||
const config = store.load();
|
const config = store.load();
|
||||||
assert.equal(config.hosters['doodstream.com'].apiKey, 'test-key-123');
|
assert.equal(config.hosters['doodstream.com'][0].apiKey, 'test-key-123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('default logMode is "single"', () => {
|
||||||
|
const config = store.load();
|
||||||
|
assert.equal(config.globalSettings.logMode, 'single');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('regression: legacy sessionLog:true on disk normalizes to logMode "daily" (NOT "session")', async () => {
|
||||||
|
// Write a config with the legacy boolean only (what an existing user has).
|
||||||
|
await store.save({ globalSettings: { sessionLog: true } });
|
||||||
|
const config = store.load();
|
||||||
|
// The misnamed legacy field MUST map to daily — mapping to "session" would
|
||||||
|
// silently change every per-day user's behaviour on upgrade.
|
||||||
|
assert.equal(config.globalSettings.logMode, 'daily');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logMode round-trips for all three values', async () => {
|
||||||
|
for (const mode of ['single', 'daily', 'session']) {
|
||||||
|
await store.save({ globalSettings: { logMode: mode } });
|
||||||
|
const config = store.load();
|
||||||
|
assert.equal(config.globalSettings.logMode, mode, `mode ${mode}`);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('load merges with defaults for missing hosters', () => {
|
it('load merges with defaults for missing hosters', () => {
|
||||||
// Write partial config
|
// Write partial config in old single-object format (triggers migration)
|
||||||
fs.writeFileSync(store.filePath, JSON.stringify({
|
fs.writeFileSync(store.filePath, JSON.stringify({
|
||||||
hosters: { 'doodstream.com': { apiKey: 'abc' } }
|
hosters: { 'doodstream.com': { apiKey: 'abc' } }
|
||||||
}), 'utf-8');
|
}), 'utf-8');
|
||||||
|
|
||||||
const config = store.load();
|
const config = store.load();
|
||||||
assert.equal(config.hosters['doodstream.com'].apiKey, 'abc');
|
// Old format is migrated to array
|
||||||
// Other hosters should still have defaults
|
assert.ok(Array.isArray(config.hosters['doodstream.com']));
|
||||||
assert.equal(config.hosters['voe.sx'].enabled, true);
|
assert.equal(config.hosters['doodstream.com'][0].apiKey, 'abc');
|
||||||
assert.equal(config.hosters['voe.sx'].apiKey, '');
|
// Other hosters should still have defaults (empty arrays)
|
||||||
|
assert.ok(Array.isArray(config.hosters['voe.sx']));
|
||||||
|
assert.equal(config.hosters['voe.sx'].length, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('hosterSettings merge fills gaps with defaults', () => {
|
it('hosterSettings merge fills gaps with defaults', () => {
|
||||||
@ -78,27 +102,42 @@ describe('ConfigStore', () => {
|
|||||||
assert.equal(config.hosterSettings['voe.sx'].retries, 5);
|
assert.equal(config.hosterSettings['voe.sx'].retries, 5);
|
||||||
assert.equal(config.hosterSettings['voe.sx'].parallelCount, 2); // default
|
assert.equal(config.hosterSettings['voe.sx'].parallelCount, 2); // default
|
||||||
assert.equal(config.hosterSettings['voe.sx'].maxSpeedKbs, 0); // default
|
assert.equal(config.hosterSettings['voe.sx'].maxSpeedKbs, 0); // default
|
||||||
|
assert.equal(config.hosterSettings['voe.sx'].logToFile, true); // default on
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logToFile defaults to true for every hoster', () => {
|
||||||
|
const config = store.load();
|
||||||
|
for (const name of ['doodstream.com', 'voe.sx', 'vidmoly.me', 'byse.sx', 'clouddrop.cc']) {
|
||||||
|
assert.equal(config.hosterSettings[name].logToFile, true, `${name} should default logToFile=true`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logToFile=false persists and survives reload', async () => {
|
||||||
|
await store.save({ hosterSettings: { 'voe.sx': { logToFile: false } } });
|
||||||
|
const config = store.load();
|
||||||
|
assert.equal(config.hosterSettings['voe.sx'].logToFile, false, 'explicit false preserved');
|
||||||
|
assert.equal(config.hosterSettings['byse.sx'].logToFile, true, 'other hoster still defaults on');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('save only updates provided sections', async () => {
|
it('save only updates provided sections', async () => {
|
||||||
// Save hoster settings first
|
// Save hoster settings first
|
||||||
await store.save({ hosterSettings: { 'doodstream.com': { retries: 10, maxSpeedKbs: 0, parallelCount: 2, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 } } });
|
await store.save({ hosterSettings: { 'doodstream.com': { retries: 10, maxSpeedKbs: 0, parallelCount: 2, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 } } });
|
||||||
// Save hosters credentials separately
|
// Save hosters credentials separately (array format)
|
||||||
await store.save({ hosters: { 'doodstream.com': { enabled: true, apiKey: 'key123' } } });
|
await store.save({ hosters: { 'doodstream.com': [{ id: 'test-1', enabled: true, authType: 'api', apiKey: 'key123' }] } });
|
||||||
|
|
||||||
const config = store.load();
|
const config = store.load();
|
||||||
assert.equal(config.hosters['doodstream.com'].apiKey, 'key123');
|
assert.equal(config.hosters['doodstream.com'][0].apiKey, 'key123');
|
||||||
assert.equal(config.hosterSettings['doodstream.com'].retries, 10); // preserved
|
assert.equal(config.hosterSettings['doodstream.com'].retries, 10); // preserved
|
||||||
});
|
});
|
||||||
|
|
||||||
it('appendHistory adds entries and caps at 100', async () => {
|
it('appendHistory keeps complete history without truncation', async () => {
|
||||||
for (let i = 0; i < 105; i++) {
|
for (let i = 0; i < 105; i++) {
|
||||||
await store.appendHistory({ id: `batch-${i}`, timestamp: new Date().toISOString(), files: [] });
|
await store.appendHistory({ id: `batch-${i}`, timestamp: new Date().toISOString(), files: [] });
|
||||||
}
|
}
|
||||||
const history = store.loadHistory();
|
const history = store.loadHistory();
|
||||||
assert.equal(history.length, 100);
|
assert.equal(history.length, 105);
|
||||||
assert.equal(history[0].id, 'batch-5'); // first 5 dropped
|
assert.equal(history[0].id, 'batch-0');
|
||||||
assert.equal(history[99].id, 'batch-104');
|
assert.equal(history[104].id, 'batch-104');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('clearHistory empties the array', async () => {
|
it('clearHistory empties the array', async () => {
|
||||||
@ -129,4 +168,27 @@ describe('ConfigStore', () => {
|
|||||||
assert.equal(config.globalSettings.scaleParallelUploads, false);
|
assert.equal(config.globalSettings.scaleParallelUploads, false);
|
||||||
assert.equal(config.globalSettings.logFilePath, '');
|
assert.equal(config.globalSettings.logFilePath, '');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('concurrent saves preserve both sections', async () => {
|
||||||
|
const save1 = store.save({ hosters: { 'doodstream.com': [{ id: 'c1', enabled: true, authType: 'api', apiKey: 'concurrent-key' }] } });
|
||||||
|
const save2 = store.save({ globalSettings: { alwaysOnTop: true } });
|
||||||
|
await Promise.all([save1, save2]);
|
||||||
|
const config = store.load();
|
||||||
|
assert.equal(config.hosters['doodstream.com'][0].apiKey, 'concurrent-key');
|
||||||
|
assert.equal(config.globalSettings.alwaysOnTop, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('backup recovery when main file is corrupted', () => {
|
||||||
|
// Write valid config first
|
||||||
|
fs.writeFileSync(store.filePath, JSON.stringify({
|
||||||
|
hosters: { 'doodstream.com': [{ id: 'bak-1', authType: 'api', apiKey: 'from-backup' }] },
|
||||||
|
hosterSettings: {}, globalSettings: {}, history: []
|
||||||
|
}), 'utf-8');
|
||||||
|
// Copy to backup
|
||||||
|
fs.copyFileSync(store.filePath, store.filePath + '.bak');
|
||||||
|
// Corrupt main file
|
||||||
|
fs.writeFileSync(store.filePath, 'CORRUPTED!!!', 'utf-8');
|
||||||
|
const config = store.load();
|
||||||
|
assert.equal(config.hosters['doodstream.com'][0].apiKey, 'from-backup');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
105
tests/doodstream-api-upload.test.js
Normal file
105
tests/doodstream-api-upload.test.js
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
const { test, before, after } = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
// Mock the undici transport BEFORE requiring hosters so the destructured
|
||||||
|
// `request` picks up our stub. apiGet (getUploadServer) uses global fetch, which
|
||||||
|
// we override per-test. This exercises the FULL doodstream API upload + recovery
|
||||||
|
// orchestration against the doc-verified response shapes — the gap between the
|
||||||
|
// already-tested parseDoodstreamResult helper and the real uploadFile path.
|
||||||
|
// (mock.module needs an experimental flag npm test doesn't pass, so we reassign
|
||||||
|
// undici.request on the module object and refresh the hosters cache instead.)
|
||||||
|
let requestRouter = async () => ({ statusCode: 200, headers: {}, body: { text: async () => '{}' } });
|
||||||
|
const undici = require('undici');
|
||||||
|
const _origUndiciRequest = undici.request;
|
||||||
|
undici.request = (...a) => requestRouter(...a);
|
||||||
|
delete require.cache[require.resolve('../lib/hosters')];
|
||||||
|
const hostersMod = require('../lib/hosters');
|
||||||
|
const { uploadFile } = hostersMod;
|
||||||
|
|
||||||
|
let tmpFile;
|
||||||
|
let origFetch;
|
||||||
|
before(() => {
|
||||||
|
tmpFile = path.join(os.tmpdir(), `dood-itest-${process.pid}.mkv`);
|
||||||
|
fs.writeFileSync(tmpFile, Buffer.alloc(2048, 7));
|
||||||
|
origFetch = global.fetch;
|
||||||
|
// Keep the "never appears" recovery test fast (real default is 12 × 2.5 s).
|
||||||
|
hostersMod.__test.DOODSTREAM_POLL.attempts = 3;
|
||||||
|
hostersMod.__test.DOODSTREAM_POLL.delayMs = 5;
|
||||||
|
});
|
||||||
|
after(() => {
|
||||||
|
global.fetch = origFetch;
|
||||||
|
undici.request = _origUndiciRequest; // restore real transport for other test files
|
||||||
|
delete require.cache[require.resolve('../lib/hosters')];
|
||||||
|
try { fs.unlinkSync(tmpFile); } catch {}
|
||||||
|
});
|
||||||
|
|
||||||
|
// getUploadServer hits /api/upload/server via global fetch.
|
||||||
|
function stubUploadServer() {
|
||||||
|
global.fetch = async (url) => {
|
||||||
|
if (/upload\/server/.test(String(url))) {
|
||||||
|
return { status: 200, text: async () => JSON.stringify({ status: 200, result: 'https://node1.cloudatacdn.com/upload/01' }) };
|
||||||
|
}
|
||||||
|
return { status: 200, text: async () => '{"status":200}' };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build an undici-style router. uploadBody is the POST result; listBodies is a
|
||||||
|
// queue consumed by successive /api/file/list calls (baseline, then polls).
|
||||||
|
function routeWith(uploadBody, listBodies = []) {
|
||||||
|
return async (url, opts) => {
|
||||||
|
const u = String(url);
|
||||||
|
if (/\/api\/file\/list/.test(u)) {
|
||||||
|
const body = listBodies.length ? listBodies.shift() : '{"status":200,"result":{"files":[]}}';
|
||||||
|
return { statusCode: 200, headers: {}, body: { text: async () => body } };
|
||||||
|
}
|
||||||
|
// Upload POST: drain the streamed body so the file handle closes.
|
||||||
|
if (opts && opts.body && typeof opts.body[Symbol.asyncIterator] === 'function') {
|
||||||
|
for await (const chunk of opts.body) { if (chunk && chunk.length === -1) break; }
|
||||||
|
}
|
||||||
|
return { statusCode: uploadBody.status, headers: { 'content-type': 'application/json' }, body: { text: async () => uploadBody.body } };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('doodstream API upload: filecode returned directly is used', async () => {
|
||||||
|
stubUploadServer();
|
||||||
|
requestRouter = routeWith({
|
||||||
|
status: 200,
|
||||||
|
body: JSON.stringify({ status: 200, result: [{ filecode: 'DOODCODE1234', download_url: 'https://doodstream.com/d/DOODCODE1234', protected_embed: 'https://doodstream.com/e/DOODCODE1234' }] })
|
||||||
|
});
|
||||||
|
const res = await uploadFile('doodstream.com', tmpFile, 'VALIDKEY', null, null, null);
|
||||||
|
assert.equal(res.file_code, 'DOODCODE1234');
|
||||||
|
assert.equal(res.download_url, 'https://doodstream.com/d/DOODCODE1234');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('doodstream API upload: codeless result recovered via file-list name match', async () => {
|
||||||
|
stubUploadServer();
|
||||||
|
const fileName = path.basename(tmpFile).replace(/\.[^.]+$/, ''); // title doodstream stores
|
||||||
|
requestRouter = routeWith(
|
||||||
|
{ status: 200, body: JSON.stringify({ status: 200, msg: 'OK' }) }, // codeless upload
|
||||||
|
[
|
||||||
|
'{"status":200,"result":{"files":[]}}', // baseline (pre-upload)
|
||||||
|
`{"status":200,"result":{"files":[{"file_code":"RECOVER9999","title":"${fileName}"}]}}` // poll finds it
|
||||||
|
]
|
||||||
|
);
|
||||||
|
const res = await uploadFile('doodstream.com', tmpFile, 'VALIDKEY', null, null, null);
|
||||||
|
assert.equal(res.file_code, 'RECOVER9999');
|
||||||
|
assert.equal(res.download_url, 'https://doodstream.com/d/RECOVER9999');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('doodstream API upload: codeless + file never appears → throws hosterTransient (no account poison)', async () => {
|
||||||
|
stubUploadServer();
|
||||||
|
requestRouter = routeWith(
|
||||||
|
{ status: 200, body: JSON.stringify({ status: 200, msg: 'OK' }) },
|
||||||
|
[] // every file/list returns empty
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
() => uploadFile('doodstream.com', tmpFile, 'VALIDKEY', null, null, null),
|
||||||
|
(err) => {
|
||||||
|
assert.equal(err.hosterTransient, true, 'codeless result must be tagged hosterTransient');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
214
tests/doodstream-upload.test.js
Normal file
214
tests/doodstream-upload.test.js
Normal file
@ -0,0 +1,214 @@
|
|||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const DoodstreamUploader = require('../lib/doodstream-upload');
|
||||||
|
|
||||||
|
// The CDN hands back an XFileSharing form. `fn` is the filecode, `st` is the
|
||||||
|
// status ("OK" on success, an error string when the backend refuses the file).
|
||||||
|
// These tests pin the parse/error behaviour of _parseUploadResponse without
|
||||||
|
// touching the network — _fetch is stubbed to return the upload_result page.
|
||||||
|
function cdnForm({ fn = '', st = 'OK' } = {}) {
|
||||||
|
return `<HTML><BODY><Form name='F1' action='https://cdn.example/' method='POST'>` +
|
||||||
|
`<textarea name="op">upload_result</textarea>` +
|
||||||
|
`<textarea name="fn">${fn}</textarea>` +
|
||||||
|
`<textarea name="st">${st}</textarea>` +
|
||||||
|
`</Form></BODY></HTML>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_RESULT = '<textarea id="copy_dl" readonly class="form-control" rows="5"></textarea>';
|
||||||
|
const LINK_RESULT = (code) => `<textarea id="copy_dl" readonly class="form-control" rows="5">https://myvidplay.com/d/${code}</textarea>`;
|
||||||
|
|
||||||
|
function uploaderWithResult(resultHtml) {
|
||||||
|
const up = new DoodstreamUploader();
|
||||||
|
up._lastUploadUrl = 'https://cdn.example/upload/01';
|
||||||
|
// Stub the second-step submit so no real request goes out.
|
||||||
|
up._fetch = async () => ({ text: async () => resultHtml });
|
||||||
|
return up;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('rejected file: empty fn + non-OK st surfaces the real status', async () => {
|
||||||
|
const up = uploaderWithResult(EMPTY_RESULT);
|
||||||
|
await assert.rejects(
|
||||||
|
() => up._parseUploadResponse(cdnForm({ fn: '', st: 'Error: file already exists' })),
|
||||||
|
(err) => {
|
||||||
|
assert.match(err.message, /lehnt Datei ab/);
|
||||||
|
assert.match(err.message, /file already exists/);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty fn + st OK: generic error still reports st, fn-state and CDN node', async () => {
|
||||||
|
const up = uploaderWithResult(EMPTY_RESULT);
|
||||||
|
await assert.rejects(
|
||||||
|
() => up._parseUploadResponse(cdnForm({ fn: '', st: 'OK' })),
|
||||||
|
(err) => {
|
||||||
|
assert.match(err.message, /kein Filecode/);
|
||||||
|
assert.match(err.message, /st=OK/);
|
||||||
|
assert.match(err.message, /fehlt\/leer/);
|
||||||
|
assert.match(err.message, /cdn\.example/);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('valid fn but empty result page: still resolves via fn (no regression)', async () => {
|
||||||
|
const up = uploaderWithResult(EMPTY_RESULT);
|
||||||
|
const res = await up._parseUploadResponse(cdnForm({ fn: '7mnp8xna3123', st: 'OK' }));
|
||||||
|
assert.equal(res.file_code, '7mnp8xna3123');
|
||||||
|
assert.equal(res.download_url, 'https://doodstream.com/d/7mnp8xna3123');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('happy path: link in result page wins', async () => {
|
||||||
|
const up = uploaderWithResult(LINK_RESULT('jjsuhr931ds9'));
|
||||||
|
const res = await up._parseUploadResponse(cdnForm({ fn: 'jjsuhr931ds9', st: 'OK' }));
|
||||||
|
assert.equal(res.file_code, 'jjsuhr931ds9');
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- _parseUploadFormFields: replicate the current upload form faithfully ---
|
||||||
|
test('_parseUploadFormFields extracts the real form fields and excludes the file input', () => {
|
||||||
|
const up = new DoodstreamUploader();
|
||||||
|
const html = `
|
||||||
|
<form name="file" enctype="multipart/form-data" action="https://uxg.cloudatacdn.com/upload/01?TOK" method="post">
|
||||||
|
<input type="hidden" name="sess_id" value="TOK">
|
||||||
|
<input name="file" type="file" size="30" id="filepc">
|
||||||
|
<input name="fakefilepc" class="d-none" type="text" id="fakefilepc">
|
||||||
|
<input type="text" name="file_title" class="form-control">
|
||||||
|
<button type="submit" name="submit_btn" class="btn">Upload</button>
|
||||||
|
</form>`;
|
||||||
|
const f = up._parseUploadFormFields(html);
|
||||||
|
assert.equal(f.sess_id, 'TOK');
|
||||||
|
assert.equal(f.fakefilepc, '');
|
||||||
|
assert.equal(f.file_title, '');
|
||||||
|
assert.ok('submit_btn' in f);
|
||||||
|
assert.ok(!('file' in f), 'the file input must be excluded (streamed separately)');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('_parseUploadFormFields returns {} for markup without a form', () => {
|
||||||
|
const up = new DoodstreamUploader();
|
||||||
|
assert.deepEqual(up._parseUploadFormFields('<div>no form here</div>'), {});
|
||||||
|
assert.deepEqual(up._parseUploadFormFields(''), {});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- deriveApiKey: pull + validate the account API key from the web session ---
|
||||||
|
test('_extractApiKeyCandidates finds the key in an input value and ranks api-context first', () => {
|
||||||
|
const up = new DoodstreamUploader();
|
||||||
|
const html = `
|
||||||
|
<input type="text" name="csrf" value="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa">
|
||||||
|
<div class="panel">API Key <input readonly value="bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"></div>
|
||||||
|
`;
|
||||||
|
const cands = up._extractApiKeyCandidates(html);
|
||||||
|
// The token whose preceding context mentions "API" must rank first.
|
||||||
|
assert.equal(cands[0], 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb');
|
||||||
|
assert.ok(cands.includes('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('_extractApiKeyCandidates handles textarea + api_key: "x" shapes and empty input', () => {
|
||||||
|
const up = new DoodstreamUploader();
|
||||||
|
assert.deepEqual(up._extractApiKeyCandidates(''), []);
|
||||||
|
const ta = up._extractApiKeyCandidates('<textarea id="k">cccccccccccccccccccccccccccccccc</textarea>');
|
||||||
|
assert.ok(ta.includes('cccccccccccccccccccccccccccccccc'));
|
||||||
|
const js = up._extractApiKeyCandidates('var x = {"api_key":"dddddddddddddddddddddddddddddddd"};');
|
||||||
|
assert.ok(js.includes('dddddddddddddddddddddddddddddddd'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deriveApiKey returns the candidate that validates against the API', async () => {
|
||||||
|
const up = new DoodstreamUploader();
|
||||||
|
up._fetch = async () => ({ text: async () => '<div>API Key <input value="REALKEY1234567890abcdefGHIJK"></div><input value="notthekey000000000000000000">' });
|
||||||
|
up._validateApiKey = async (key) => key === 'REALKEY1234567890abcdefGHIJK';
|
||||||
|
const key = await up.deriveApiKey();
|
||||||
|
assert.equal(key, 'REALKEY1234567890abcdefGHIJK');
|
||||||
|
assert.equal(up.apiKey, 'REALKEY1234567890abcdefGHIJK'); // cached on the instance
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deriveApiKey returns null when no candidate validates (→ caller uses web fallback)', async () => {
|
||||||
|
const up = new DoodstreamUploader();
|
||||||
|
up._fetch = async () => ({ text: async () => '<input value="bogustoken0000000000000000000">' });
|
||||||
|
up._validateApiKey = async () => false;
|
||||||
|
assert.equal(await up.deriveApiKey(), null);
|
||||||
|
assert.equal(up.apiKey, '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deriveApiKey short-circuits when a key is already set', async () => {
|
||||||
|
const up = new DoodstreamUploader();
|
||||||
|
up.apiKey = 'PRESET';
|
||||||
|
let fetched = false;
|
||||||
|
up._fetch = async () => { fetched = true; return { text: async () => '' }; };
|
||||||
|
assert.equal(await up.deriveApiKey(), 'PRESET');
|
||||||
|
assert.equal(fetched, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- _fetch: transient network blips on the small requests self-heal ---
|
||||||
|
test('_fetch retries a transient network failure then succeeds', async () => {
|
||||||
|
const up = new DoodstreamUploader();
|
||||||
|
const origFetch = globalThis.fetch;
|
||||||
|
let calls = 0;
|
||||||
|
globalThis.fetch = async () => {
|
||||||
|
calls++;
|
||||||
|
if (calls === 1) throw new TypeError('fetch failed');
|
||||||
|
return { status: 200, headers: { getSetCookie: () => [], get: () => null }, text: async () => 'ok' };
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const res = await up._fetch('https://example.test/x');
|
||||||
|
assert.equal(calls, 2); // failed once, retried, succeeded
|
||||||
|
assert.equal(await res.text(), 'ok');
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = origFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- _getUploadServer: discovery must never fall back to a hardcoded node ---
|
||||||
|
function fakeRes(body, { status = 200, ctype = 'text/html' } = {}) {
|
||||||
|
return { status, headers: { get: (h) => (h.toLowerCase() === 'content-type' ? ctype : null) }, text: async () => body };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('getUploadServer: returns JSON result when present', async () => {
|
||||||
|
const up = new DoodstreamUploader();
|
||||||
|
up._fetch = async (url) => {
|
||||||
|
assert.match(url, /op=upload_server/);
|
||||||
|
return fakeRes(JSON.stringify({ result: 'https://node42.cloudatacdn.com/upload/01' }), { ctype: 'application/json' });
|
||||||
|
};
|
||||||
|
assert.equal(await up._getUploadServer(), 'https://node42.cloudatacdn.com/upload/01');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getUploadServer: falls back to srv_url in upload-page HTML', async () => {
|
||||||
|
const up = new DoodstreamUploader();
|
||||||
|
up._fetch = async (url) => {
|
||||||
|
if (/op=upload_server/.test(url)) return fakeRes('<html>not json</html>');
|
||||||
|
return fakeRes('<script>var srv_url: "https://node7.cloudatacdn.com/upload/01";</script>');
|
||||||
|
};
|
||||||
|
assert.equal(await up._getUploadServer(), 'https://node7.cloudatacdn.com/upload/01');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getUploadServer: parses current form-action node and refreshes sess_id from the same page', async () => {
|
||||||
|
const up = new DoodstreamUploader();
|
||||||
|
up.sessId = 'stale-from-login';
|
||||||
|
up._fetch = async (url) => {
|
||||||
|
if (/op=upload_server/.test(url)) return fakeRes('<html>not json</html>');
|
||||||
|
return fakeRes('<form name="file" enctype="multipart/form-data" action="https://n9.cloudatacdn.com/upload/01?FRESH123" method="post"><input type="hidden" name="sess_id" value="FRESH123"></form>');
|
||||||
|
};
|
||||||
|
const url = await up._getUploadServer();
|
||||||
|
assert.equal(url, 'https://n9.cloudatacdn.com/upload/01?FRESH123');
|
||||||
|
assert.equal(up.sessId, 'FRESH123'); // critical: form-field token must match the node URL token
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getUploadServer: un-escapes & in the form-action query string', async () => {
|
||||||
|
const up = new DoodstreamUploader();
|
||||||
|
up._fetch = async (url) => {
|
||||||
|
if (/op=upload_server/.test(url)) return fakeRes('<html>not json</html>');
|
||||||
|
return fakeRes('<form name="file" enctype="multipart/form-data" action="https://n9.cloudatacdn.com/upload/01?a=1&b=2" method="post"></form>');
|
||||||
|
};
|
||||||
|
assert.equal(await up._getUploadServer(), 'https://n9.cloudatacdn.com/upload/01?a=1&b=2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getUploadServer: throws (no silent dead fallback) when discovery fails', async () => {
|
||||||
|
const up = new DoodstreamUploader();
|
||||||
|
up._fetch = async () => fakeRes('<html><body>login required</body></html>', { status: 200 });
|
||||||
|
await assert.rejects(
|
||||||
|
() => up._getUploadServer(),
|
||||||
|
(err) => {
|
||||||
|
assert.match(err.message, /konnte Upload-Server nicht ermitteln/);
|
||||||
|
assert.doesNotMatch(err.message, /tr1128ve\.cloudatacdn\.com/); // never the hardcoded node
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
100
tests/file-probe.test.js
Normal file
100
tests/file-probe.test.js
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
const { detectKind, isVideoLikeKind, probeFileHead, summarizeFileStat } = require('../lib/file-probe');
|
||||||
|
|
||||||
|
function tmpWrite(name, buf) {
|
||||||
|
const p = path.join(os.tmpdir(), `mhu-probe-${Date.now()}-${name}`);
|
||||||
|
fs.writeFileSync(p, buf);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('detectKind recognizes ISO-MP4 (ftyp box at offset 4)', () => {
|
||||||
|
const buf = Buffer.concat([Buffer.from([0x00, 0x00, 0x00, 0x20]), Buffer.from('ftypisom', 'ascii'), Buffer.alloc(8, 0)]);
|
||||||
|
assert.strictEqual(detectKind(buf), 'mp4-iso');
|
||||||
|
assert.strictEqual(isVideoLikeKind('mp4-iso'), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('detectKind recognizes Matroska / WebM EBML header', () => {
|
||||||
|
const buf = Buffer.from([0x1A, 0x45, 0xDF, 0xA3, 0x01, 0x00]);
|
||||||
|
assert.strictEqual(detectKind(buf), 'matroska');
|
||||||
|
assert.strictEqual(isVideoLikeKind('matroska'), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('detectKind recognizes AVI (RIFF...AVI )', () => {
|
||||||
|
const buf = Buffer.concat([Buffer.from('RIFF', 'ascii'), Buffer.from([0x00, 0x00, 0x00, 0x00]), Buffer.from('AVI ', 'ascii')]);
|
||||||
|
assert.strictEqual(detectKind(buf), 'avi');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('detectKind recognizes FLV', () => {
|
||||||
|
const buf = Buffer.concat([Buffer.from('FLV', 'ascii'), Buffer.from([0x01])]);
|
||||||
|
assert.strictEqual(detectKind(buf), 'flv');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('detectKind recognizes ASF (WMV)', () => {
|
||||||
|
const buf = Buffer.from([0x30, 0x26, 0xB2, 0x75, 0x00, 0x00]);
|
||||||
|
assert.strictEqual(detectKind(buf), 'asf-wmv');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('detectKind recognizes MPEG-PS (00 00 01 BA)', () => {
|
||||||
|
const buf = Buffer.from([0x00, 0x00, 0x01, 0xBA, 0x00]);
|
||||||
|
assert.strictEqual(detectKind(buf), 'mpeg-ps');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('detectKind recognizes JPEG (non-video)', () => {
|
||||||
|
const buf = Buffer.from([0xFF, 0xD8, 0xFF, 0xE0]);
|
||||||
|
assert.strictEqual(detectKind(buf), 'jpeg');
|
||||||
|
assert.strictEqual(isVideoLikeKind('jpeg'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('detectKind recognizes HTML response (non-video)', () => {
|
||||||
|
const buf = Buffer.from('<!DOCTYPE html><html><head>', 'ascii');
|
||||||
|
assert.strictEqual(detectKind(buf), 'html');
|
||||||
|
assert.strictEqual(isVideoLikeKind('html'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('detectKind returns empty for zero-length and unknown for noise', () => {
|
||||||
|
assert.strictEqual(detectKind(Buffer.alloc(0)), 'empty');
|
||||||
|
assert.strictEqual(detectKind(Buffer.from([0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF])), 'unknown');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('probeFileHead reads first bytes and returns hex + kind for an MP4-like file', async () => {
|
||||||
|
const mp4Head = Buffer.concat([Buffer.from([0x00, 0x00, 0x00, 0x20]), Buffer.from('ftypisom', 'ascii'), Buffer.alloc(16, 0xAA)]);
|
||||||
|
const p = tmpWrite('fake.mp4', mp4Head);
|
||||||
|
try {
|
||||||
|
const res = await probeFileHead(p, 64);
|
||||||
|
assert.strictEqual(res.ok, true);
|
||||||
|
assert.strictEqual(res.kind, 'mp4-iso');
|
||||||
|
assert.strictEqual(res.isVideoLike, true);
|
||||||
|
assert.ok(res.headHex.startsWith('0000002066747970'));
|
||||||
|
assert.strictEqual(res.bytesRead, mp4Head.length);
|
||||||
|
} finally {
|
||||||
|
fs.unlinkSync(p);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('probeFileHead returns ok:false with kind=unreadable for missing file', async () => {
|
||||||
|
const res = await probeFileHead(path.join(os.tmpdir(), `does-not-exist-${Date.now()}.mp4`), 32);
|
||||||
|
assert.strictEqual(res.ok, false);
|
||||||
|
assert.strictEqual(res.kind, 'unreadable');
|
||||||
|
assert.ok(res.error);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('summarizeFileStat returns size + mtime for a real file', () => {
|
||||||
|
const p = tmpWrite('stat.bin', Buffer.alloc(123, 0xCC));
|
||||||
|
try {
|
||||||
|
const stat = summarizeFileStat(p);
|
||||||
|
assert.strictEqual(stat.size, 123);
|
||||||
|
assert.strictEqual(stat.isFile, true);
|
||||||
|
assert.ok(stat.mtime);
|
||||||
|
} finally {
|
||||||
|
fs.unlinkSync(p);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('summarizeFileStat returns error for missing file', () => {
|
||||||
|
const stat = summarizeFileStat(path.join(os.tmpdir(), `does-not-exist-${Date.now()}.bin`));
|
||||||
|
assert.ok(stat.error);
|
||||||
|
});
|
||||||
@ -31,4 +31,65 @@ describe('hosters helpers', () => {
|
|||||||
|
|
||||||
assert.equal(url, 'https://delivery-hydra.voe-network.net/upload/01');
|
assert.equal(url, 'https://delivery-hydra.voe-network.net/upload/01');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('parseDoodstreamResult tolerates null/non-object payload without throwing', () => {
|
||||||
|
// Direct callers may bypass uploadFile's normalisation. The parser must
|
||||||
|
// never throw on bad input — empty fields are the contract.
|
||||||
|
for (const bad of [null, undefined, 'string', 42, true]) {
|
||||||
|
const r = __test.parseDoodstreamResult(bad);
|
||||||
|
assert.equal(r.file_code, null);
|
||||||
|
assert.equal(r.download_url, null);
|
||||||
|
assert.equal(r.embed_url, null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parseDoodstreamResult handles result-as-array and result-as-object', () => {
|
||||||
|
const arr = __test.parseDoodstreamResult({ result: [{ filecode: 'AB1', protected_dl: 'https://x/1', protected_embed: 'https://x/e/1' }] });
|
||||||
|
assert.equal(arr.file_code, 'AB1');
|
||||||
|
assert.equal(arr.download_url, 'https://x/1');
|
||||||
|
assert.equal(arr.embed_url, 'https://x/e/1');
|
||||||
|
|
||||||
|
const obj = __test.parseDoodstreamResult({ result: { filecode: 'OBJ1', download_url: 'https://x/2' } });
|
||||||
|
assert.equal(obj.file_code, 'OBJ1');
|
||||||
|
assert.equal(obj.download_url, 'https://x/2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parseByseResult tolerates null/non-object payload without throwing', () => {
|
||||||
|
for (const bad of [null, undefined, 'string', 42, []]) {
|
||||||
|
const r = __test.parseByseResult(bad);
|
||||||
|
assert.equal(r.file_code, null);
|
||||||
|
assert.equal(r.download_url, null);
|
||||||
|
assert.equal(r.embed_url, null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parseByseResult handles malformed files entries (null, missing fields)', () => {
|
||||||
|
// Files array with a null first element (server returned [null])
|
||||||
|
const a = __test.parseByseResult({ files: [null] });
|
||||||
|
assert.equal(a.file_code, null);
|
||||||
|
// Files array with object missing both filecode and status
|
||||||
|
const b = __test.parseByseResult({ files: [{}] });
|
||||||
|
assert.equal(b.file_code, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parseByseResult throws fileRejected for non-OK status with empty filecode', () => {
|
||||||
|
assert.throws(
|
||||||
|
() => __test.parseByseResult({ files: [{ status: 'Not video file format' }] }),
|
||||||
|
(err) => err.fileRejected === true && /Not video file format/i.test(err.message)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parseByseResult flips to accountError for storage-exhausted phrasing', () => {
|
||||||
|
assert.throws(
|
||||||
|
() => __test.parseByseResult({ files: [{ status: 'not enough disk space on your account' }] }),
|
||||||
|
(err) => err.accountError === true
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parseByseResult succeeds with valid filecode in files[0]', () => {
|
||||||
|
const r = __test.parseByseResult({ files: [{ filecode: 'GOOD123', status: 'OK' }] });
|
||||||
|
assert.equal(r.file_code, 'GOOD123');
|
||||||
|
assert.equal(r.download_url, 'https://byse.sx/d/GOOD123');
|
||||||
|
assert.equal(r.embed_url, 'https://byse.sx/e/GOOD123');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
140
tests/log-mode.test.js
Normal file
140
tests/log-mode.test.js
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const { normalizeLogMode, resolveLogFileName, formatDateStamp, formatSessionStamp } = require('../lib/log-mode');
|
||||||
|
|
||||||
|
// --- normalizeLogMode ---
|
||||||
|
|
||||||
|
test('normalizeLogMode: default for empty/null/undefined is "single"', () => {
|
||||||
|
assert.equal(normalizeLogMode(), 'single');
|
||||||
|
assert.equal(normalizeLogMode(null), 'single');
|
||||||
|
assert.equal(normalizeLogMode({}), 'single');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizeLogMode: explicit logMode wins for all three valid values', () => {
|
||||||
|
assert.equal(normalizeLogMode({ logMode: 'single' }), 'single');
|
||||||
|
assert.equal(normalizeLogMode({ logMode: 'daily' }), 'daily');
|
||||||
|
assert.equal(normalizeLogMode({ logMode: 'session' }), 'session');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('regression: legacy sessionLog:true maps to "daily", NOT "session"', () => {
|
||||||
|
// The legacy boolean field was named after a misnomer — it actually toggled
|
||||||
|
// per-day logging. Mapping it to "session" would silently flip every existing
|
||||||
|
// per-day user onto per-session, which is exactly the bug the migration trap
|
||||||
|
// exists to prevent.
|
||||||
|
assert.equal(normalizeLogMode({ sessionLog: true }), 'daily');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizeLogMode: sessionLog:false / missing maps to "single"', () => {
|
||||||
|
assert.equal(normalizeLogMode({ sessionLog: false }), 'single');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizeLogMode: explicit logMode beats the legacy sessionLog field', () => {
|
||||||
|
// Once a user picks a mode in 3.3.35+, the legacy boolean must NOT override.
|
||||||
|
assert.equal(normalizeLogMode({ logMode: 'session', sessionLog: true }), 'session');
|
||||||
|
assert.equal(normalizeLogMode({ logMode: 'single', sessionLog: true }), 'single');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizeLogMode: invalid logMode strings fall through to single (or legacy if present)', () => {
|
||||||
|
assert.equal(normalizeLogMode({ logMode: 'lolnope' }), 'single');
|
||||||
|
assert.equal(normalizeLogMode({ logMode: '' }), 'single');
|
||||||
|
assert.equal(normalizeLogMode({ logMode: 'lolnope', sessionLog: true }), 'daily');
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- resolveLogFileName ---
|
||||||
|
|
||||||
|
test('resolveLogFileName: single mode → bare basename + ext', () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveLogFileName({ baseName: 'fileuploader', ext: '.log', mode: 'single' }),
|
||||||
|
'fileuploader.log'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveLogFileName: daily mode → fileuploader-YYYY-MM-DD.log', () => {
|
||||||
|
const d = new Date(2026, 4, 28); // May 28, 2026 — month is 0-indexed
|
||||||
|
assert.equal(
|
||||||
|
resolveLogFileName({ baseName: 'fileuploader', ext: '.log', mode: 'daily', date: d }),
|
||||||
|
'fileuploader-2026-05-28.log'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveLogFileName: session mode → fileuploader-session-<id>.log', () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveLogFileName({ baseName: 'fileuploader', ext: '.log', mode: 'session', sessionId: '2026-05-28_22-44-52-12345' }),
|
||||||
|
'fileuploader-session-2026-05-28_22-44-52-12345.log'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveLogFileName: session mode with missing sessionId falls back to single (never emits malformed name)', () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveLogFileName({ baseName: 'fileuploader', ext: '.log', mode: 'session' }),
|
||||||
|
'fileuploader.log'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveLogFileName: unknown mode is treated as single', () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveLogFileName({ baseName: 'fileuploader', ext: '.log', mode: 'lolnope' }),
|
||||||
|
'fileuploader.log'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- stripModeStampFromFileName ---
|
||||||
|
|
||||||
|
const { stripModeStampFromFileName } = require('../lib/log-mode');
|
||||||
|
|
||||||
|
test('stripModeStampFromFileName: leaves bare names alone', () => {
|
||||||
|
assert.equal(stripModeStampFromFileName('fileuploader.log'), 'fileuploader.log');
|
||||||
|
assert.equal(stripModeStampFromFileName('fileuploader'), 'fileuploader');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stripModeStampFromFileName: strips a daily YYYY-MM-DD suffix', () => {
|
||||||
|
assert.equal(stripModeStampFromFileName('fileuploader-2026-06-03.log'), 'fileuploader.log');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stripModeStampFromFileName: strips a session-stamp suffix (with and without pid)', () => {
|
||||||
|
assert.equal(
|
||||||
|
stripModeStampFromFileName('fileuploader-session-2026-06-03_18-16-20-8132.log'),
|
||||||
|
'fileuploader.log'
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
stripModeStampFromFileName('fileuploader-session-2026-06-03_18-16-20.log'),
|
||||||
|
'fileuploader.log'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('regression: resolveLogFileName(stripModeStampFromFileName(...)) is idempotent — persisting then re-resolving never compounds stamps', () => {
|
||||||
|
// This is the exact bug shape: persist the resolved path, then on next call
|
||||||
|
// re-resolve from the saved base — must produce the same file, not a doubled
|
||||||
|
// session-stamped one. The fix is the strip; this test guards against
|
||||||
|
// regressing _persistFallbackLogPath into the 3.3.35 bug.
|
||||||
|
const sessionId = '2026-06-03_18-16-20-8132';
|
||||||
|
const dailyDate = new Date(2026, 5, 3);
|
||||||
|
for (const mode of ['daily', 'session']) {
|
||||||
|
const date = mode === 'daily' ? dailyDate : new Date();
|
||||||
|
const initial = resolveLogFileName({ baseName: 'fileuploader', ext: '.log', mode, date, sessionId });
|
||||||
|
const stripped = stripModeStampFromFileName(initial);
|
||||||
|
// After strip, the base should be back to the bare name.
|
||||||
|
assert.equal(stripped, 'fileuploader.log', `${mode}: strip should produce bare base`);
|
||||||
|
// Re-resolving from the bare base gives the same final filename — no doubling.
|
||||||
|
const reBase = stripped.replace(/\.log$/, '');
|
||||||
|
const second = resolveLogFileName({ baseName: reBase, ext: '.log', mode, date, sessionId });
|
||||||
|
assert.equal(second, initial, `${mode}: round-trip must be idempotent`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- format helpers ---
|
||||||
|
|
||||||
|
test('formatDateStamp: zero-pads month and day', () => {
|
||||||
|
assert.equal(formatDateStamp(new Date(2026, 0, 3)), '2026-01-03');
|
||||||
|
assert.equal(formatDateStamp(new Date(2026, 11, 31)), '2026-12-31');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formatSessionStamp: produces YYYY-MM-DD_HH-MM-SS-pid', () => {
|
||||||
|
const d = new Date(2026, 4, 28, 7, 9, 5);
|
||||||
|
assert.equal(formatSessionStamp(d, 12345), '2026-05-28_07-09-05-12345');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formatSessionStamp: omits the pid suffix when none provided', () => {
|
||||||
|
const d = new Date(2026, 4, 28, 22, 44, 52);
|
||||||
|
assert.equal(formatSessionStamp(d), '2026-05-28_22-44-52');
|
||||||
|
});
|
||||||
52
tests/log-policy.test.js
Normal file
52
tests/log-policy.test.js
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const { hosterLogToFileEnabled } = require('../lib/log-policy');
|
||||||
|
|
||||||
|
test('enabled by default when settings missing entirely', () => {
|
||||||
|
assert.equal(hosterLogToFileEnabled(null, 'voe.sx'), true);
|
||||||
|
assert.equal(hosterLogToFileEnabled(undefined, 'voe.sx'), true);
|
||||||
|
assert.equal(hosterLogToFileEnabled('not-an-object', 'voe.sx'), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('enabled when hoster has no settings entry', () => {
|
||||||
|
assert.equal(hosterLogToFileEnabled({}, 'voe.sx'), true);
|
||||||
|
assert.equal(hosterLogToFileEnabled({ 'byse.sx': { logToFile: false } }, 'voe.sx'), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('enabled when hoster entry has no logToFile key (back-compat with old configs)', () => {
|
||||||
|
assert.equal(hosterLogToFileEnabled({ 'voe.sx': { retries: 3 } }, 'voe.sx'), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('enabled when logToFile is explicitly true', () => {
|
||||||
|
assert.equal(hosterLogToFileEnabled({ 'voe.sx': { logToFile: true } }, 'voe.sx'), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DISABLED only when logToFile is explicitly false', () => {
|
||||||
|
assert.equal(hosterLogToFileEnabled({ 'voe.sx': { logToFile: false } }, 'voe.sx'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('truthy-but-not-true values do not accidentally disable', () => {
|
||||||
|
// Only the strict boolean false disables — guards against e.g. a stored 0/""
|
||||||
|
assert.equal(hosterLogToFileEnabled({ 'voe.sx': { logToFile: 0 } }, 'voe.sx'), true);
|
||||||
|
assert.equal(hosterLogToFileEnabled({ 'voe.sx': { logToFile: '' } }, 'voe.sx'), true);
|
||||||
|
assert.equal(hosterLogToFileEnabled({ 'voe.sx': { logToFile: null } }, 'voe.sx'), true);
|
||||||
|
assert.equal(hosterLogToFileEnabled({ 'voe.sx': { logToFile: undefined } }, 'voe.sx'), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('per-hoster independence: one off, others on', () => {
|
||||||
|
const settings = {
|
||||||
|
'voe.sx': { logToFile: false },
|
||||||
|
'byse.sx': { logToFile: true },
|
||||||
|
'doodstream.com': { retries: 3 }
|
||||||
|
};
|
||||||
|
assert.equal(hosterLogToFileEnabled(settings, 'voe.sx'), false);
|
||||||
|
assert.equal(hosterLogToFileEnabled(settings, 'byse.sx'), true);
|
||||||
|
assert.equal(hosterLogToFileEnabled(settings, 'doodstream.com'), true);
|
||||||
|
assert.equal(hosterLogToFileEnabled(settings, 'clouddrop.cc'), true); // not present → on
|
||||||
|
});
|
||||||
|
|
||||||
|
test('malformed hoster entry (string/number) defaults to on', () => {
|
||||||
|
assert.equal(hosterLogToFileEnabled({ 'voe.sx': 'broken' }, 'voe.sx'), true);
|
||||||
|
assert.equal(hosterLogToFileEnabled({ 'voe.sx': 42 }, 'voe.sx'), true);
|
||||||
|
});
|
||||||
134
tests/log-rotation.test.js
Normal file
134
tests/log-rotation.test.js
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
const { test, beforeEach, afterEach } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const os = require('os');
|
||||||
|
|
||||||
|
const { maybeRotateLogFile } = require('../lib/log-rotation');
|
||||||
|
|
||||||
|
let tmpDir;
|
||||||
|
let logFile;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-log-rotation-'));
|
||||||
|
logFile = path.join(tmpDir, 'fileuploader.log');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
|
||||||
|
});
|
||||||
|
|
||||||
|
function writeBytes(p, n, fill = 'a') {
|
||||||
|
fs.writeFileSync(p, fill.repeat(n), 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
test('returns false and skips rotation when file does not exist', () => {
|
||||||
|
const result = maybeRotateLogFile(logFile, 100);
|
||||||
|
assert.equal(result, false);
|
||||||
|
assert.equal(fs.existsSync(logFile), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns false when file is below the size cap', () => {
|
||||||
|
writeBytes(logFile, 50);
|
||||||
|
const result = maybeRotateLogFile(logFile, 100);
|
||||||
|
assert.equal(result, false);
|
||||||
|
assert.equal(fs.statSync(logFile).size, 50, 'live file untouched');
|
||||||
|
assert.equal(fs.existsSync(logFile + '.1'), false, 'no .1 created');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rotates live file to .1 when over cap', () => {
|
||||||
|
writeBytes(logFile, 200, 'X');
|
||||||
|
const result = maybeRotateLogFile(logFile, 100, 3);
|
||||||
|
assert.equal(result, true);
|
||||||
|
assert.equal(fs.existsSync(logFile), false, 'live file moved away');
|
||||||
|
const expectedBackup = path.join(tmpDir, 'fileuploader.1.log');
|
||||||
|
assert.equal(fs.existsSync(expectedBackup), true, '.1 backup exists');
|
||||||
|
assert.equal(fs.statSync(expectedBackup).size, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shifts existing backups up: .1 → .2, .2 → .3 on rotation', () => {
|
||||||
|
writeBytes(path.join(tmpDir, 'fileuploader.2.log'), 10, 'B');
|
||||||
|
writeBytes(path.join(tmpDir, 'fileuploader.1.log'), 20, 'A');
|
||||||
|
writeBytes(logFile, 200, 'L');
|
||||||
|
|
||||||
|
const result = maybeRotateLogFile(logFile, 100, 3);
|
||||||
|
assert.equal(result, true);
|
||||||
|
|
||||||
|
// Live file → .1 (latest live data)
|
||||||
|
assert.equal(fs.statSync(path.join(tmpDir, 'fileuploader.1.log')).size, 200);
|
||||||
|
// Old .1 → .2
|
||||||
|
assert.equal(fs.statSync(path.join(tmpDir, 'fileuploader.2.log')).size, 20);
|
||||||
|
// Old .2 → .3
|
||||||
|
assert.equal(fs.statSync(path.join(tmpDir, 'fileuploader.3.log')).size, 10);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('drops oldest backup when at maxBackups limit', () => {
|
||||||
|
// Pre-populate all three backup slots.
|
||||||
|
writeBytes(path.join(tmpDir, 'fileuploader.3.log'), 5, 'C'); // oldest, will be dropped
|
||||||
|
writeBytes(path.join(tmpDir, 'fileuploader.2.log'), 10, 'B');
|
||||||
|
writeBytes(path.join(tmpDir, 'fileuploader.1.log'), 20, 'A');
|
||||||
|
writeBytes(logFile, 200, 'L');
|
||||||
|
|
||||||
|
const result = maybeRotateLogFile(logFile, 100, 3);
|
||||||
|
assert.equal(result, true);
|
||||||
|
|
||||||
|
// Old .3 (5 bytes 'C') gone, replaced by old .2.
|
||||||
|
const f3 = fs.statSync(path.join(tmpDir, 'fileuploader.3.log'));
|
||||||
|
assert.equal(f3.size, 10, 'old .2 became new .3 (the C-file was dropped)');
|
||||||
|
// .2 = old .1
|
||||||
|
assert.equal(fs.statSync(path.join(tmpDir, 'fileuploader.2.log')).size, 20);
|
||||||
|
// .1 = the live file we just rotated
|
||||||
|
assert.equal(fs.statSync(path.join(tmpDir, 'fileuploader.1.log')).size, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('is idempotent — second call on still-large file rotates again', () => {
|
||||||
|
writeBytes(logFile, 200, 'X');
|
||||||
|
maybeRotateLogFile(logFile, 100, 3);
|
||||||
|
// Simulate fresh writes after the first rotation
|
||||||
|
writeBytes(logFile, 200, 'Y');
|
||||||
|
const result = maybeRotateLogFile(logFile, 100, 3);
|
||||||
|
assert.equal(result, true);
|
||||||
|
// The .Y file is now .1, the .X file moved to .2
|
||||||
|
assert.equal(fs.readFileSync(path.join(tmpDir, 'fileuploader.1.log'), 'utf-8')[0], 'Y');
|
||||||
|
assert.equal(fs.readFileSync(path.join(tmpDir, 'fileuploader.2.log'), 'utf-8')[0], 'X');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('maxBackups=1: only keeps a single .1 backup, never .2', () => {
|
||||||
|
writeBytes(logFile, 200, 'L');
|
||||||
|
maybeRotateLogFile(logFile, 100, 1);
|
||||||
|
writeBytes(logFile, 200, 'M');
|
||||||
|
maybeRotateLogFile(logFile, 100, 1);
|
||||||
|
|
||||||
|
// .1 holds the latest rotated content (M)
|
||||||
|
assert.equal(fs.readFileSync(path.join(tmpDir, 'fileuploader.1.log'), 'utf-8')[0], 'M');
|
||||||
|
// .2 must NOT exist
|
||||||
|
assert.equal(fs.existsSync(path.join(tmpDir, 'fileuploader.2.log')), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('invalid maxBytes (0, negative, NaN) is a no-op', () => {
|
||||||
|
writeBytes(logFile, 1000, 'X');
|
||||||
|
for (const max of [0, -1, NaN]) {
|
||||||
|
const r = maybeRotateLogFile(logFile, max);
|
||||||
|
assert.equal(r, false, `maxBytes=${max} should be no-op`);
|
||||||
|
}
|
||||||
|
assert.equal(fs.existsSync(logFile), true);
|
||||||
|
assert.equal(fs.existsSync(logFile + '.1'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('logs through provided debug callback on rotation', () => {
|
||||||
|
writeBytes(logFile, 200, 'X');
|
||||||
|
const messages = [];
|
||||||
|
maybeRotateLogFile(logFile, 100, 3, (m) => messages.push(m));
|
||||||
|
assert.ok(messages.length >= 1, 'at least one log message');
|
||||||
|
assert.ok(messages.some(m => m.includes('rotated')), `expected "rotated" in: ${messages.join(' | ')}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles file without extension correctly', () => {
|
||||||
|
const noExtFile = path.join(tmpDir, 'plainlog');
|
||||||
|
writeBytes(noExtFile, 200, 'P');
|
||||||
|
const result = maybeRotateLogFile(noExtFile, 100, 3);
|
||||||
|
assert.equal(result, true);
|
||||||
|
// base = the full path, ext = '', so backup name is "plainlog.1"
|
||||||
|
assert.equal(fs.existsSync(path.join(tmpDir, 'plainlog.1')), true);
|
||||||
|
assert.equal(fs.existsSync(noExtFile), false);
|
||||||
|
});
|
||||||
72
tests/queue-dedup.test.js
Normal file
72
tests/queue-dedup.test.js
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const { partitionRestoredJobsByLog } = require('../lib/queue-dedup');
|
||||||
|
|
||||||
|
function job(status, fileName, hoster) {
|
||||||
|
return { status, fileName, hoster, file: `C:/dl/${fileName}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('regression: pending preview jobs are NEVER dropped, even when all match the log', () => {
|
||||||
|
// Exact shape of the reproduced bug: 4 preview jobs for one file across 4
|
||||||
|
// hosters, every fileName|hoster present in the lifetime upload log.
|
||||||
|
const jobs = [
|
||||||
|
job('preview', 'Einfach mal die Fresse halten!!!.mp4', 'doodstream.com'),
|
||||||
|
job('preview', 'Einfach mal die Fresse halten!!!.mp4', 'voe.sx'),
|
||||||
|
job('preview', 'Einfach mal die Fresse halten!!!.mp4', 'vidmoly.me'),
|
||||||
|
job('preview', 'Einfach mal die Fresse halten!!!.mp4', 'byse.sx')
|
||||||
|
];
|
||||||
|
const log = [
|
||||||
|
{ fileName: 'Einfach mal die Fresse halten!!!.mp4', hoster: 'doodstream.com' },
|
||||||
|
{ fileName: 'Einfach mal die Fresse halten!!!.mp4', hoster: 'voe.sx' },
|
||||||
|
{ fileName: 'Einfach mal die Fresse halten!!!.mp4', hoster: 'vidmoly.me' },
|
||||||
|
{ fileName: 'Einfach mal die Fresse halten!!!.mp4', hoster: 'byse.sx' }
|
||||||
|
];
|
||||||
|
const { kept, removed } = partitionRestoredJobsByLog(jobs, log);
|
||||||
|
assert.equal(removed.length, 0, 'no pending job may be removed');
|
||||||
|
assert.equal(kept.length, 4, 'all 4 pending jobs survive restart/update');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('done jobs in the log are dropped (declutter); pending/error/aborted kept', () => {
|
||||||
|
const jobs = [
|
||||||
|
job('done', 'a.mkv', 'doodstream.com'),
|
||||||
|
job('preview', 'a.mkv', 'voe.sx'),
|
||||||
|
job('error', 'b.mkv', 'doodstream.com'),
|
||||||
|
job('aborted', 'c.mkv', 'doodstream.com')
|
||||||
|
];
|
||||||
|
const log = [
|
||||||
|
{ fileName: 'a.mkv', hoster: 'doodstream.com' },
|
||||||
|
{ fileName: 'a.mkv', hoster: 'voe.sx' },
|
||||||
|
{ fileName: 'b.mkv', hoster: 'doodstream.com' },
|
||||||
|
{ fileName: 'c.mkv', hoster: 'doodstream.com' }
|
||||||
|
];
|
||||||
|
const { kept, removed } = partitionRestoredJobsByLog(jobs, log);
|
||||||
|
assert.equal(removed.length, 1);
|
||||||
|
assert.equal(removed[0].status, 'done');
|
||||||
|
assert.equal(removed[0].hoster, 'doodstream.com');
|
||||||
|
// The preview a.mkv|voe.sx, error b.mkv, aborted c.mkv all survive.
|
||||||
|
assert.equal(kept.length, 3);
|
||||||
|
assert.ok(kept.some(j => j.status === 'preview' && j.hoster === 'voe.sx'));
|
||||||
|
assert.ok(kept.some(j => j.status === 'error'));
|
||||||
|
assert.ok(kept.some(j => j.status === 'aborted'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('done job NOT in the log is kept (e.g. hoster had logToFile disabled)', () => {
|
||||||
|
const jobs = [job('done', 'd.mkv', 'doodstream.com')];
|
||||||
|
const { kept, removed } = partitionRestoredJobsByLog(jobs, []);
|
||||||
|
assert.equal(removed.length, 0);
|
||||||
|
assert.equal(kept.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('case-insensitive match on fileName and hoster', () => {
|
||||||
|
const jobs = [job('done', 'Movie.MKV', 'DoodStream.com')];
|
||||||
|
const log = [{ fileName: 'movie.mkv', hoster: 'doodstream.com' }];
|
||||||
|
const { removed } = partitionRestoredJobsByLog(jobs, log);
|
||||||
|
assert.equal(removed.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty/missing inputs do not throw', () => {
|
||||||
|
assert.deepEqual(partitionRestoredJobsByLog([], []), { kept: [], removed: [] });
|
||||||
|
assert.deepEqual(partitionRestoredJobsByLog(null, null), { kept: [], removed: [] });
|
||||||
|
const jobs = [job('done', 'x.mkv', 'voe.sx')];
|
||||||
|
assert.equal(partitionRestoredJobsByLog(jobs, undefined).kept.length, 1);
|
||||||
|
});
|
||||||
115
tests/queue-prune.test.js
Normal file
115
tests/queue-prune.test.js
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const { pruneOldestTerminalJobs, TERMINAL_STATUSES } = require('../lib/queue-prune');
|
||||||
|
|
||||||
|
const j = (id, status) => ({ id, status });
|
||||||
|
|
||||||
|
test('returns null on empty / non-array input', () => {
|
||||||
|
assert.equal(pruneOldestTerminalJobs([], 5), null);
|
||||||
|
assert.equal(pruneOldestTerminalJobs(null, 5), null);
|
||||||
|
assert.equal(pruneOldestTerminalJobs(undefined, 5), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null when all jobs are non-terminal regardless of limit', () => {
|
||||||
|
const jobs = [j('a', 'queued'), j('b', 'uploading'), j('c', 'preview')];
|
||||||
|
assert.equal(pruneOldestTerminalJobs(jobs, 0), null);
|
||||||
|
assert.equal(pruneOldestTerminalJobs(jobs, 100), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null when terminal count is at or under the limit', () => {
|
||||||
|
const jobs = [j('a', 'done'), j('b', 'done'), j('c', 'queued')];
|
||||||
|
assert.equal(pruneOldestTerminalJobs(jobs, 2), null, 'terminal=2, limit=2 → no-op');
|
||||||
|
assert.equal(pruneOldestTerminalJobs(jobs, 3), null, 'terminal=2, limit=3 → no-op');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('drops oldest terminal jobs when over the limit, keeps non-terminal', () => {
|
||||||
|
const jobs = [
|
||||||
|
j('t1', 'done'), // oldest terminal — should be dropped
|
||||||
|
j('t2', 'done'), // should be dropped
|
||||||
|
j('queued1', 'queued'),
|
||||||
|
j('t3', 'error'), // newest of the dropped block
|
||||||
|
j('uploading1', 'uploading'),
|
||||||
|
j('t4', 'done'), // kept (within limit window)
|
||||||
|
j('t5', 'skipped'), // kept
|
||||||
|
j('t6', 'aborted'), // kept
|
||||||
|
];
|
||||||
|
// 6 terminal, limit 3 → drop 3 oldest (t1, t2, t3)
|
||||||
|
const result = pruneOldestTerminalJobs(jobs, 3);
|
||||||
|
assert.notEqual(result, null);
|
||||||
|
const droppedIds = result.dropped.map(x => x.id).sort();
|
||||||
|
assert.deepEqual(droppedIds, ['t1', 't2', 't3']);
|
||||||
|
// Non-terminal jobs always kept; surviving terminals are the newest 3
|
||||||
|
const keptIds = result.kept.map(x => x.id);
|
||||||
|
assert.deepEqual(keptIds, ['queued1', 'uploading1', 't4', 't5', 't6']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('respects insertion order (oldest by index, not by status)', () => {
|
||||||
|
const jobs = [
|
||||||
|
j('older-error', 'error'),
|
||||||
|
j('newer-done', 'done'),
|
||||||
|
j('newest-aborted', 'aborted'),
|
||||||
|
];
|
||||||
|
const result = pruneOldestTerminalJobs(jobs, 1);
|
||||||
|
assert.deepEqual(result.dropped.map(x => x.id), ['older-error', 'newer-done']);
|
||||||
|
assert.deepEqual(result.kept.map(x => x.id), ['newest-aborted']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('drops everything terminal when limit is 0', () => {
|
||||||
|
const jobs = [
|
||||||
|
j('q', 'queued'),
|
||||||
|
j('d1', 'done'),
|
||||||
|
j('d2', 'done'),
|
||||||
|
j('e1', 'error'),
|
||||||
|
];
|
||||||
|
const result = pruneOldestTerminalJobs(jobs, 0);
|
||||||
|
assert.deepEqual(result.dropped.map(x => x.id), ['d1', 'd2', 'e1']);
|
||||||
|
assert.deepEqual(result.kept.map(x => x.id), ['q']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects negative or non-finite limits', () => {
|
||||||
|
const jobs = [j('a', 'done'), j('b', 'done')];
|
||||||
|
assert.equal(pruneOldestTerminalJobs(jobs, -1), null);
|
||||||
|
assert.equal(pruneOldestTerminalJobs(jobs, NaN), null);
|
||||||
|
assert.equal(pruneOldestTerminalJobs(jobs, Infinity), null,
|
||||||
|
'Infinity is technically not finite; safer to treat as no-op');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('TERMINAL_STATUSES set covers all 4 terminal kinds', () => {
|
||||||
|
assert.ok(TERMINAL_STATUSES.has('done'));
|
||||||
|
assert.ok(TERMINAL_STATUSES.has('skipped'));
|
||||||
|
assert.ok(TERMINAL_STATUSES.has('error'));
|
||||||
|
assert.ok(TERMINAL_STATUSES.has('aborted'));
|
||||||
|
assert.equal(TERMINAL_STATUSES.size, 4);
|
||||||
|
// Non-terminal must not be in the set
|
||||||
|
for (const s of ['queued', 'preview', 'uploading', 'retrying', 'getting-server']) {
|
||||||
|
assert.equal(TERMINAL_STATUSES.has(s), false, `${s} must not be terminal`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles malformed entries (null / missing status) without throwing', () => {
|
||||||
|
const jobs = [
|
||||||
|
null,
|
||||||
|
j('a', 'done'),
|
||||||
|
{ id: 'no-status' }, // no status
|
||||||
|
j('b', 'done'),
|
||||||
|
];
|
||||||
|
// 2 terminal, limit 1 → drop oldest (a). null and no-status entries stay
|
||||||
|
// because they aren't terminal. The function must not throw on them.
|
||||||
|
const result = pruneOldestTerminalJobs(jobs, 1);
|
||||||
|
assert.notEqual(result, null);
|
||||||
|
assert.deepEqual(result.dropped.map(x => x && x.id), ['a']);
|
||||||
|
assert.equal(result.kept.length, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('large queue: keeps the newest `limit` terminals', () => {
|
||||||
|
const jobs = [];
|
||||||
|
for (let i = 0; i < 5000; i++) jobs.push(j(`done-${i}`, 'done'));
|
||||||
|
const result = pruneOldestTerminalJobs(jobs, 500);
|
||||||
|
assert.notEqual(result, null);
|
||||||
|
assert.equal(result.dropped.length, 4500);
|
||||||
|
assert.equal(result.kept.length, 500);
|
||||||
|
// First kept = done-4500 (the 4501st original entry)
|
||||||
|
assert.equal(result.kept[0].id, 'done-4500');
|
||||||
|
assert.equal(result.kept[result.kept.length - 1].id, 'done-4999');
|
||||||
|
});
|
||||||
53
tests/remote-config.test.js
Normal file
53
tests/remote-config.test.js
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
const { describe, it } = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
|
||||||
|
// Minimal app mock for ConfigStore
|
||||||
|
function createTestConfigStore() {
|
||||||
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-test-'));
|
||||||
|
const mockApp = {
|
||||||
|
isPackaged: false,
|
||||||
|
getPath: (name) => tmpDir,
|
||||||
|
getPath: () => tmpDir
|
||||||
|
};
|
||||||
|
const ConfigStore = require('../lib/config-store');
|
||||||
|
const store = new ConfigStore(mockApp);
|
||||||
|
store.filePath = path.join(tmpDir, 'test-config.json');
|
||||||
|
return { store, tmpDir };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('remote config defaults', () => {
|
||||||
|
it('should include remote settings in defaults', () => {
|
||||||
|
const { store } = createTestConfigStore();
|
||||||
|
const config = store.load();
|
||||||
|
const remote = config.globalSettings.remote;
|
||||||
|
|
||||||
|
assert.strictEqual(remote.enabled, false);
|
||||||
|
assert.strictEqual(remote.port, 9100);
|
||||||
|
assert.strictEqual(typeof remote.token, 'string');
|
||||||
|
assert.strictEqual(remote.token, '');
|
||||||
|
assert.strictEqual(remote.allowInput, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should deep-merge remote settings with existing config', async () => {
|
||||||
|
const { store } = createTestConfigStore();
|
||||||
|
// Save config with partial remote settings
|
||||||
|
await store.save({
|
||||||
|
globalSettings: {
|
||||||
|
remote: { enabled: true, port: 9200 }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const config = store.load();
|
||||||
|
const remote = config.globalSettings.remote;
|
||||||
|
|
||||||
|
// Saved values preserved
|
||||||
|
assert.strictEqual(remote.enabled, true);
|
||||||
|
assert.strictEqual(remote.port, 9200);
|
||||||
|
// Defaults merged in
|
||||||
|
assert.strictEqual(remote.allowInput, true);
|
||||||
|
assert.strictEqual(remote.token, '');
|
||||||
|
});
|
||||||
|
});
|
||||||
41
tests/remote-server.test.js
Normal file
41
tests/remote-server.test.js
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
const { describe, it, beforeEach, afterEach } = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
|
||||||
|
// Test the module can be required and has the expected API
|
||||||
|
describe('RemoteServer', () => {
|
||||||
|
it('should export a class with start/stop methods', () => {
|
||||||
|
const RemoteServer = require('../lib/remote-server');
|
||||||
|
assert.strictEqual(typeof RemoteServer, 'function');
|
||||||
|
assert.strictEqual(typeof RemoteServer.prototype.start, 'function');
|
||||||
|
assert.strictEqual(typeof RemoteServer.prototype.stop, 'function');
|
||||||
|
assert.strictEqual(typeof RemoteServer.prototype.getClientCount, 'function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should start and stop without errors', async () => {
|
||||||
|
const RemoteServer = require('../lib/remote-server');
|
||||||
|
const server = new RemoteServer();
|
||||||
|
|
||||||
|
// Mock mainWindow
|
||||||
|
const mockMainWindow = {
|
||||||
|
isDestroyed: () => false,
|
||||||
|
getTitle: () => 'Test Window',
|
||||||
|
getContentBounds: () => ({ x: 0, y: 0, width: 1920, height: 1080 }),
|
||||||
|
webContents: {
|
||||||
|
sendInputEvent: () => {}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await server.start({
|
||||||
|
port: 0, // random available port
|
||||||
|
token: 'test-token-123',
|
||||||
|
allowInput: true,
|
||||||
|
mainWindow: mockMainWindow,
|
||||||
|
onSignalingToCapture: () => {},
|
||||||
|
onCreateCaptureWindow: () => {},
|
||||||
|
onDestroyCaptureWindow: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(server.getClientCount(), 0);
|
||||||
|
server.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -162,4 +162,13 @@ describe('Semaphore', () => {
|
|||||||
await new Promise(r => setTimeout(r, 5));
|
await new Promise(r => setTimeout(r, 5));
|
||||||
assert.equal(sem.pending, 1);
|
assert.equal(sem.pending, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('release without acquire clamps active to 0', () => {
|
||||||
|
const sem = new Semaphore(2);
|
||||||
|
assert.equal(sem.active, 0);
|
||||||
|
sem.release();
|
||||||
|
assert.equal(sem.active, 0, 'should not go negative');
|
||||||
|
sem.release();
|
||||||
|
assert.equal(sem.active, 0, 'should still be 0');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
132
tests/stats.test.js
Normal file
132
tests/stats.test.js
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const {
|
||||||
|
summarizePerHoster,
|
||||||
|
classifyErrorCategory,
|
||||||
|
summarizeBatchErrors,
|
||||||
|
isRetryableCategory
|
||||||
|
} = require('../lib/stats');
|
||||||
|
|
||||||
|
function makeBatch(timestamp, results) {
|
||||||
|
return {
|
||||||
|
id: 'b-' + timestamp,
|
||||||
|
timestamp: new Date(timestamp).toISOString(),
|
||||||
|
files: [{ name: 'foo.mp4', size: 1, results }]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('summarizePerHoster counts ok and fail per hoster across all batches', () => {
|
||||||
|
const history = [
|
||||||
|
makeBatch(1, [
|
||||||
|
{ hoster: 'voe.sx', status: 'done' },
|
||||||
|
{ hoster: 'byse.sx', status: 'error', error: 'Not video file format' }
|
||||||
|
]),
|
||||||
|
makeBatch(2, [
|
||||||
|
{ hoster: 'voe.sx', status: 'done' },
|
||||||
|
{ hoster: 'voe.sx', status: 'error', error: 'CSRF' },
|
||||||
|
{ hoster: 'byse.sx', status: 'done' }
|
||||||
|
])
|
||||||
|
];
|
||||||
|
const s = summarizePerHoster(history);
|
||||||
|
assert.strictEqual(s['voe.sx'].ok, 2);
|
||||||
|
assert.strictEqual(s['voe.sx'].fail, 1);
|
||||||
|
assert.strictEqual(s['voe.sx'].total, 3);
|
||||||
|
assert.strictEqual(Math.round(s['voe.sx'].rate * 100), 67);
|
||||||
|
assert.strictEqual(s['byse.sx'].ok, 1);
|
||||||
|
assert.strictEqual(s['byse.sx'].fail, 1);
|
||||||
|
assert.strictEqual(s['byse.sx'].rate, 0.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('summarizePerHoster honors sinceMs cutoff', () => {
|
||||||
|
const history = [
|
||||||
|
makeBatch(1000, [{ hoster: 'voe.sx', status: 'done' }]),
|
||||||
|
makeBatch(5000, [{ hoster: 'voe.sx', status: 'error', error: 'x' }])
|
||||||
|
];
|
||||||
|
const s = summarizePerHoster(history, { sinceMs: 3000 });
|
||||||
|
assert.strictEqual(s['voe.sx'].ok, 0);
|
||||||
|
assert.strictEqual(s['voe.sx'].fail, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('summarizePerHoster honors lastNBatches (newest first)', () => {
|
||||||
|
const history = [
|
||||||
|
makeBatch(1000, [{ hoster: 'voe.sx', status: 'done' }]),
|
||||||
|
makeBatch(2000, [{ hoster: 'voe.sx', status: 'done' }]),
|
||||||
|
makeBatch(3000, [{ hoster: 'voe.sx', status: 'error', error: 'x' }])
|
||||||
|
];
|
||||||
|
const s = summarizePerHoster(history, { lastNBatches: 1 });
|
||||||
|
assert.strictEqual(s['voe.sx'].ok, 0);
|
||||||
|
assert.strictEqual(s['voe.sx'].fail, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('summarizePerHoster handles empty / malformed input', () => {
|
||||||
|
assert.deepStrictEqual(summarizePerHoster(null), {});
|
||||||
|
assert.deepStrictEqual(summarizePerHoster([]), {});
|
||||||
|
assert.deepStrictEqual(summarizePerHoster([{ id: 'x', files: null }]), {});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('classifyErrorCategory: file-rejected phrases', () => {
|
||||||
|
assert.strictEqual(classifyErrorCategory('Byse lehnte Datei ab: Not video file format'), 'file-rejected');
|
||||||
|
assert.strictEqual(classifyErrorCategory('Duplicate file already exists'), 'file-rejected');
|
||||||
|
assert.strictEqual(classifyErrorCategory('Datei zu groß (Max: 5 GB)'), 'file-rejected');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('classifyErrorCategory: account-error phrases', () => {
|
||||||
|
assert.strictEqual(classifyErrorCategory('Quota exceeded'), 'account-error');
|
||||||
|
assert.strictEqual(classifyErrorCategory('account banned'), 'account-error');
|
||||||
|
assert.strictEqual(classifyErrorCategory('not enough disk space'), 'account-error');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('classifyErrorCategory: hoster-transient phrases', () => {
|
||||||
|
assert.strictEqual(classifyErrorCategory('CSRF-Token nicht gefunden'), 'hoster-transient');
|
||||||
|
assert.strictEqual(classifyErrorCategory('Kein Upload-Server erhalten: server busy'), 'hoster-transient');
|
||||||
|
assert.strictEqual(classifyErrorCategory('Kein Filecode'), 'hoster-transient');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('classifyErrorCategory: network phrases', () => {
|
||||||
|
assert.strictEqual(classifyErrorCategory('socket hang up'), 'network');
|
||||||
|
assert.strictEqual(classifyErrorCategory('fetch failed'), 'network');
|
||||||
|
assert.strictEqual(classifyErrorCategory('Timeout while reading'), 'network');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('classifyErrorCategory: aborted is its own bucket (not retryable)', () => {
|
||||||
|
assert.strictEqual(classifyErrorCategory('Abgebrochen'), 'aborted');
|
||||||
|
assert.strictEqual(isRetryableCategory('aborted'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('classifyErrorCategory: unknown for everything else', () => {
|
||||||
|
assert.strictEqual(classifyErrorCategory(''), 'unknown');
|
||||||
|
assert.strictEqual(classifyErrorCategory(null), 'unknown');
|
||||||
|
assert.strictEqual(classifyErrorCategory('Some weird thing'), 'unknown');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('summarizeBatchErrors buckets results by category', () => {
|
||||||
|
const summary = {
|
||||||
|
files: [
|
||||||
|
{ name: 'a.mp4', results: [
|
||||||
|
{ hoster: 'voe.sx', status: 'done' },
|
||||||
|
{ hoster: 'byse.sx', status: 'error', error: 'Not video file format' }
|
||||||
|
] },
|
||||||
|
{ name: 'b.mp4', results: [
|
||||||
|
{ hoster: 'voe.sx', status: 'error', error: 'CSRF' },
|
||||||
|
{ hoster: 'doodstream.com', status: 'error', error: 'socket hang up' }
|
||||||
|
] }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
const buckets = summarizeBatchErrors(summary);
|
||||||
|
assert.strictEqual(buckets['file-rejected'].length, 1);
|
||||||
|
assert.strictEqual(buckets['file-rejected'][0].hoster, 'byse.sx');
|
||||||
|
assert.strictEqual(buckets['hoster-transient'].length, 1);
|
||||||
|
assert.strictEqual(buckets['hoster-transient'][0].hoster, 'voe.sx');
|
||||||
|
assert.strictEqual(buckets['network'].length, 1);
|
||||||
|
assert.strictEqual(buckets['network'][0].hoster, 'doodstream.com');
|
||||||
|
assert.strictEqual(buckets['account-error'].length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isRetryableCategory: only transient + network + unknown retry-worthy', () => {
|
||||||
|
assert.strictEqual(isRetryableCategory('hoster-transient'), true);
|
||||||
|
assert.strictEqual(isRetryableCategory('network'), true);
|
||||||
|
assert.strictEqual(isRetryableCategory('unknown'), true);
|
||||||
|
assert.strictEqual(isRetryableCategory('file-rejected'), false);
|
||||||
|
assert.strictEqual(isRetryableCategory('account-error'), false);
|
||||||
|
assert.strictEqual(isRetryableCategory('aborted'), false);
|
||||||
|
});
|
||||||
94
tests/support-bundle.test.js
Normal file
94
tests/support-bundle.test.js
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
const { sanitizeConfig, collectFile, buildSupportBundleText, REDACTED } = require('../lib/support-bundle');
|
||||||
|
|
||||||
|
test('sanitizeConfig redacts known credential keys at any nesting depth', () => {
|
||||||
|
const input = {
|
||||||
|
hosters: {
|
||||||
|
'voe.sx': [{ username: 'u', password: 'p1', apiKey: 'k1', enabled: true }],
|
||||||
|
'byse.sx': [{ apiKey: 'k2' }, { apiKey: 'k3', token: 't1', label: 'main' }]
|
||||||
|
},
|
||||||
|
globalSettings: { remote: { token: 'remT' }, scramble: { active: false } }
|
||||||
|
};
|
||||||
|
const out = sanitizeConfig(input);
|
||||||
|
assert.strictEqual(out.hosters['voe.sx'][0].password, REDACTED);
|
||||||
|
assert.strictEqual(out.hosters['voe.sx'][0].apiKey, REDACTED);
|
||||||
|
assert.strictEqual(out.hosters['voe.sx'][0].username, 'u');
|
||||||
|
assert.strictEqual(out.hosters['voe.sx'][0].enabled, true);
|
||||||
|
assert.strictEqual(out.hosters['byse.sx'][1].apiKey, REDACTED);
|
||||||
|
assert.strictEqual(out.hosters['byse.sx'][1].token, REDACTED);
|
||||||
|
assert.strictEqual(out.hosters['byse.sx'][1].label, 'main');
|
||||||
|
assert.strictEqual(out.globalSettings.remote.token, REDACTED);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sanitizeConfig does not mutate input', () => {
|
||||||
|
const input = { hosters: { 'voe.sx': [{ password: 'secret' }] } };
|
||||||
|
const clone = JSON.parse(JSON.stringify(input));
|
||||||
|
sanitizeConfig(input);
|
||||||
|
assert.deepStrictEqual(input, clone);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sanitizeConfig leaves empty/missing credentials alone', () => {
|
||||||
|
const input = { hosters: { 'voe.sx': [{ password: '', apiKey: null }] } };
|
||||||
|
const out = sanitizeConfig(input);
|
||||||
|
assert.strictEqual(out.hosters['voe.sx'][0].password, '');
|
||||||
|
assert.strictEqual(out.hosters['voe.sx'][0].apiKey, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sanitizeConfig handles null/undefined input', () => {
|
||||||
|
assert.strictEqual(sanitizeConfig(null), null);
|
||||||
|
assert.strictEqual(sanitizeConfig(undefined), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('collectFile tails when file exceeds maxBytes', () => {
|
||||||
|
const tmp = path.join(os.tmpdir(), `mhu-bundle-${Date.now()}.log`);
|
||||||
|
const bigLine = 'x'.repeat(1000) + '\n';
|
||||||
|
fs.writeFileSync(tmp, bigLine.repeat(100));
|
||||||
|
try {
|
||||||
|
const section = collectFile(tmp, 'big.log', 5000);
|
||||||
|
assert.match(section, /truncated: skipped first \d+ bytes/);
|
||||||
|
assert.ok(section.length < bigLine.length * 100, 'section should be truncated');
|
||||||
|
} finally {
|
||||||
|
fs.unlinkSync(tmp);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('collectFile returns placeholder for missing file', () => {
|
||||||
|
const section = collectFile(path.join(os.tmpdir(), `does-not-exist-${Date.now()}.log`), 'missing');
|
||||||
|
assert.match(section, /<file does not exist yet>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('collectFile returns placeholder for null path', () => {
|
||||||
|
const section = collectFile(null, 'no-path');
|
||||||
|
assert.match(section, /<no path configured>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildSupportBundleText produces structured output with header + config + file sections', () => {
|
||||||
|
const tmp = path.join(os.tmpdir(), `mhu-bundle-text-${Date.now()}.log`);
|
||||||
|
fs.writeFileSync(tmp, 'line one\nline two\n');
|
||||||
|
try {
|
||||||
|
const text = buildSupportBundleText({
|
||||||
|
header: { Version: '3.3.41', Platform: 'win32' },
|
||||||
|
sanitizedConfig: { hosters: { 'voe.sx': [{ apiKey: '<redacted>' }] } },
|
||||||
|
files: [{ label: 'debug.log', path: tmp }]
|
||||||
|
});
|
||||||
|
assert.match(text, /^=== Multi-Hoster-Upload Support Bundle ===/);
|
||||||
|
assert.match(text, /Version: 3\.3\.41/);
|
||||||
|
assert.match(text, /Platform: win32/);
|
||||||
|
assert.match(text, /=== Config \(sanitized/);
|
||||||
|
assert.match(text, /"apiKey": "<redacted>"/);
|
||||||
|
assert.match(text, /=== debug\.log/);
|
||||||
|
assert.match(text, /line one\nline two/);
|
||||||
|
} finally {
|
||||||
|
fs.unlinkSync(tmp);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildSupportBundleText handles empty file list and missing header', () => {
|
||||||
|
const text = buildSupportBundleText({ sanitizedConfig: {}, files: [] });
|
||||||
|
assert.match(text, /=== Multi-Hoster-Upload Support Bundle ===/);
|
||||||
|
assert.match(text, /=== Config/);
|
||||||
|
});
|
||||||
@ -83,4 +83,19 @@ describe('Throttle', () => {
|
|||||||
const elapsed = Date.now() - start2;
|
const elapsed = Date.now() - start2;
|
||||||
assert.ok(elapsed >= 150, `third consume should wait for refill, took ${elapsed}ms`);
|
assert.ok(elapsed >= 150, `third consume should wait for refill, took ${elapsed}ms`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('consume(0) resolves immediately', async () => {
|
||||||
|
const t = new Throttle(100);
|
||||||
|
const start = Date.now();
|
||||||
|
await t.consume(0);
|
||||||
|
assert.ok(Date.now() - start < 50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updateRate to unlimited (0) makes consume instant', async () => {
|
||||||
|
const t = new Throttle(100); // very slow
|
||||||
|
t.updateRate(0); // unlimited
|
||||||
|
const start = Date.now();
|
||||||
|
await t.consume(1_000_000);
|
||||||
|
assert.ok(Date.now() - start < 50, 'unlimited rate should be instant');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
113
tests/throttled-cache.test.js
Normal file
113
tests/throttled-cache.test.js
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const { makeThrottledCache } = require('../lib/throttled-cache');
|
||||||
|
|
||||||
|
function fakeClock(start = 0) {
|
||||||
|
let t = start;
|
||||||
|
const fn = () => t;
|
||||||
|
fn.advance = (ms) => { t += ms; };
|
||||||
|
fn.set = (ms) => { t = ms; };
|
||||||
|
return fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('returns undefined when empty', () => {
|
||||||
|
const c = makeThrottledCache(100);
|
||||||
|
assert.equal(c.get('any', {}), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns the set value within the window', () => {
|
||||||
|
const clock = fakeClock();
|
||||||
|
const c = makeThrottledCache(100, clock);
|
||||||
|
const input = [1, 2, 3];
|
||||||
|
c.set('sig-a', input, 'value-1');
|
||||||
|
assert.equal(c.get('sig-a', input), 'value-1');
|
||||||
|
clock.advance(50);
|
||||||
|
assert.equal(c.get('sig-a', input), 'value-1', 'still valid at 50/100 ms');
|
||||||
|
clock.advance(49);
|
||||||
|
assert.equal(c.get('sig-a', input), 'value-1', 'still valid at 99/100 ms');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('expires exactly at refreshMs boundary', () => {
|
||||||
|
const clock = fakeClock();
|
||||||
|
const c = makeThrottledCache(100, clock);
|
||||||
|
c.set('s', {}, 'v');
|
||||||
|
clock.advance(100);
|
||||||
|
assert.equal(c.get('s', {}), undefined, '>= refreshMs is a miss');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('miss on different signature', () => {
|
||||||
|
const c = makeThrottledCache(1000, fakeClock());
|
||||||
|
const input = {};
|
||||||
|
c.set('sig-a', input, 'v');
|
||||||
|
assert.equal(c.get('sig-b', input), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('miss on different input identity even with same signature', () => {
|
||||||
|
const c = makeThrottledCache(1000, fakeClock());
|
||||||
|
c.set('sig-a', { a: 1 }, 'v');
|
||||||
|
// Different object identity — the cache compares by ===, not by contents
|
||||||
|
assert.equal(c.get('sig-a', { a: 1 }), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('overwrite by re-setting same signature', () => {
|
||||||
|
const clock = fakeClock();
|
||||||
|
const c = makeThrottledCache(100, clock);
|
||||||
|
const input = [];
|
||||||
|
c.set('s', input, 'old');
|
||||||
|
clock.advance(50);
|
||||||
|
c.set('s', input, 'new');
|
||||||
|
// The new entry has a fresh timestamp → still valid for another 100 ms
|
||||||
|
clock.advance(99);
|
||||||
|
assert.equal(c.get('s', input), 'new');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clear empties the cache', () => {
|
||||||
|
const c = makeThrottledCache(1000, fakeClock());
|
||||||
|
c.set('s', {}, 'v');
|
||||||
|
c.clear();
|
||||||
|
assert.equal(c.get('s', {}), undefined);
|
||||||
|
assert.equal(c.peek(), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('peek reports age and signature', () => {
|
||||||
|
const clock = fakeClock();
|
||||||
|
const c = makeThrottledCache(1000, clock);
|
||||||
|
c.set('mysig', {}, 'v');
|
||||||
|
clock.advance(42);
|
||||||
|
const p = c.peek();
|
||||||
|
assert.equal(p.sig, 'mysig');
|
||||||
|
assert.equal(p.age, 42);
|
||||||
|
assert.equal(p.ts, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('throws on invalid refreshMs', () => {
|
||||||
|
assert.throws(() => makeThrottledCache(-1));
|
||||||
|
assert.throws(() => makeThrottledCache(NaN));
|
||||||
|
assert.throws(() => makeThrottledCache('100'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refreshMs=0 means every call misses', () => {
|
||||||
|
const clock = fakeClock();
|
||||||
|
const c = makeThrottledCache(0, clock);
|
||||||
|
const input = {};
|
||||||
|
c.set('s', input, 'v');
|
||||||
|
// Same tick: 0 - 0 = 0 → not less than refreshMs (0) → miss
|
||||||
|
assert.equal(c.get('s', input), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('default clock is Date.now when none provided', () => {
|
||||||
|
const c = makeThrottledCache(10000);
|
||||||
|
const input = {}; // single ref — get and set must use the SAME identity
|
||||||
|
c.set('x', input, 'v');
|
||||||
|
assert.equal(c.get('x', input), 'v');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('large input arrays are tracked by identity, not value', () => {
|
||||||
|
const c = makeThrottledCache(1000, fakeClock());
|
||||||
|
const arr1 = new Array(10000).fill(0);
|
||||||
|
const arr2 = new Array(10000).fill(0);
|
||||||
|
c.set('s', arr1, 'cached');
|
||||||
|
assert.equal(c.get('s', arr1), 'cached');
|
||||||
|
assert.equal(c.get('s', arr2), undefined, 'different array → miss');
|
||||||
|
});
|
||||||
@ -31,6 +31,7 @@ describe('UploadManager', () => {
|
|||||||
const origRequire = module.constructor.prototype.require;
|
const origRequire = module.constructor.prototype.require;
|
||||||
const hosters = require('../lib/hosters');
|
const hosters = require('../lib/hosters');
|
||||||
hosters.uploadFile = mockUploadFile;
|
hosters.uploadFile = mockUploadFile;
|
||||||
|
hosters.prefetchBaseline = async () => null;
|
||||||
|
|
||||||
// Mock fs.statSync for test file paths
|
// Mock fs.statSync for test file paths
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
@ -55,8 +56,8 @@ describe('UploadManager', () => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const statuses = events.map(e => e.status);
|
const statuses = events.map(e => e.status);
|
||||||
assert.ok(statuses.includes('queued'), 'should have queued status');
|
|
||||||
assert.ok(statuses.includes('done'), 'should have done status');
|
assert.ok(statuses.includes('done'), 'should have done status');
|
||||||
|
assert.ok(events.length > 0, 'should emit at least one progress event');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('emits batch-done with correct summary', async () => {
|
it('emits batch-done with correct summary', async () => {
|
||||||
@ -242,6 +243,55 @@ describe('UploadManager', () => {
|
|||||||
assert.ok(statuses.some((entry) => entry.jobId === 'selected-job' && entry.status === 'aborted'));
|
assert.ok(statuses.some((entry) => entry.jobId === 'selected-job' && entry.status === 'aborted'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('addJobs returns duplicate info and still runs newly queued jobs', async () => {
|
||||||
|
let releaseFirst = null;
|
||||||
|
|
||||||
|
mockUploadFile.mock.mockImplementation(async (hoster, filePath, apiKey, onProgress, signal) => {
|
||||||
|
if (filePath.endsWith('/first.mp4')) {
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
releaseFirst = resolve;
|
||||||
|
if (signal) {
|
||||||
|
signal.addEventListener('abort', () => reject(new Error('Aborted')), { once: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||||
|
}
|
||||||
|
if (onProgress) onProgress(fakeFileSize, fakeFileSize);
|
||||||
|
return { download_url: 'https://test/ok', embed_url: null, file_code: 'ok' };
|
||||||
|
});
|
||||||
|
|
||||||
|
const mgr = new UploadManager({
|
||||||
|
'doodstream.com': { retries: 0, parallelCount: 1, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 }
|
||||||
|
});
|
||||||
|
const statuses = [];
|
||||||
|
mgr.on('progress', (data) => statuses.push({ jobId: data.jobId, status: data.status }));
|
||||||
|
|
||||||
|
const batchPromise = mgr.startBatch([
|
||||||
|
{ jobId: 'job-first', file: '/test/first.mp4', hoster: 'doodstream.com', apiKey: 'key1' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
for (let i = 0; i < 50 && !releaseFirst; i++) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
}
|
||||||
|
assert.equal(typeof releaseFirst, 'function', 'first job should be running before addJobs');
|
||||||
|
|
||||||
|
const addResult = mgr.addJobs([
|
||||||
|
{ jobId: 'job-first', file: '/test/first.mp4', hoster: 'doodstream.com', apiKey: 'key1' },
|
||||||
|
{ jobId: 'job-second', file: '/test/second.mp4', hoster: 'doodstream.com', apiKey: 'key1' },
|
||||||
|
{ jobId: 'job-third', file: '/test/third.mp4', hoster: 'doodstream.com', apiKey: 'key1' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.equal(addResult.added, 2);
|
||||||
|
assert.deepEqual(addResult.alreadyInBatchJobIds, ['job-first']);
|
||||||
|
|
||||||
|
releaseFirst();
|
||||||
|
await batchPromise;
|
||||||
|
|
||||||
|
assert.ok(statuses.some((entry) => entry.jobId === 'job-second' && entry.status === 'done'));
|
||||||
|
assert.ok(statuses.some((entry) => entry.jobId === 'job-third' && entry.status === 'done'));
|
||||||
|
});
|
||||||
|
|
||||||
it('_combineSignals propagates abort from either source', () => {
|
it('_combineSignals propagates abort from either source', () => {
|
||||||
const mgr = new UploadManager({});
|
const mgr = new UploadManager({});
|
||||||
const ac1 = new AbortController();
|
const ac1 = new AbortController();
|
||||||
@ -280,6 +330,155 @@ describe('UploadManager', () => {
|
|||||||
await assert.rejects(mgr._sleep(5000, ac.signal), /Aborted/);
|
await assert.rejects(mgr._sleep(5000, ac.signal), /Aborted/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('file not found produces descriptive error', async () => {
|
||||||
|
// Override fs.statSync to throw ENOENT for a specific path
|
||||||
|
const fs = require('fs');
|
||||||
|
const origStat = fs.statSync;
|
||||||
|
fs.statSync = function(p) {
|
||||||
|
if (p === '/test/deleted.mp4') throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
|
||||||
|
return origStat.call(this, p);
|
||||||
|
};
|
||||||
|
|
||||||
|
const mgr = new UploadManager({});
|
||||||
|
const errors = [];
|
||||||
|
mgr.on('progress', (d) => { if (d.error) errors.push(d.error); });
|
||||||
|
|
||||||
|
await mgr.startBatch([
|
||||||
|
{ file: '/test/deleted.mp4', hoster: 'doodstream.com', apiKey: 'key1' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
fs.statSync = origStat;
|
||||||
|
assert.ok(errors.some(e => e.includes('nicht gefunden')), `expected "nicht gefunden" error, got: ${errors.join(', ')}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zero-byte file produces descriptive error', async () => {
|
||||||
|
fakeFileSize = 0;
|
||||||
|
|
||||||
|
const mgr = new UploadManager({});
|
||||||
|
const errors = [];
|
||||||
|
mgr.on('progress', (d) => { if (d.error) errors.push(d.error); });
|
||||||
|
|
||||||
|
await mgr.startBatch([
|
||||||
|
{ file: '/test/empty.mp4', hoster: 'doodstream.com', apiKey: 'key1' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.ok(errors.some(e => e.includes('0 Bytes')), `expected "0 Bytes" error, got: ${errors.join(', ')}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('empty batch completes immediately with zero counts', async () => {
|
||||||
|
const mgr = new UploadManager({});
|
||||||
|
let summary = null;
|
||||||
|
mgr.on('batch-done', (s) => { summary = s; });
|
||||||
|
|
||||||
|
const start = Date.now();
|
||||||
|
await mgr.startBatch([]);
|
||||||
|
const elapsed = Date.now() - start;
|
||||||
|
|
||||||
|
assert.ok(summary, 'batch-done should be emitted');
|
||||||
|
assert.equal(summary.total, 0);
|
||||||
|
assert.equal(summary.succeeded, 0);
|
||||||
|
assert.equal(summary.failed, 0);
|
||||||
|
assert.ok(elapsed < 200, `empty batch should complete fast, took ${elapsed}ms`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scaleParallelUploads limits per-hoster count to global limit', async () => {
|
||||||
|
let concurrent = 0;
|
||||||
|
let maxConcurrent = 0;
|
||||||
|
|
||||||
|
mockUploadFile.mock.mockImplementation(async (hoster, filePath, apiKey, onProgress) => {
|
||||||
|
concurrent++;
|
||||||
|
maxConcurrent = Math.max(maxConcurrent, concurrent);
|
||||||
|
await new Promise(r => setTimeout(r, 40));
|
||||||
|
concurrent--;
|
||||||
|
if (onProgress) onProgress(fakeFileSize, fakeFileSize);
|
||||||
|
return { download_url: 'ok', embed_url: null, file_code: 'ok' };
|
||||||
|
});
|
||||||
|
|
||||||
|
const mgr = new UploadManager(
|
||||||
|
{ 'doodstream.com': { retries: 0, parallelCount: 10, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 } },
|
||||||
|
{ parallelUploadCount: 2, scaleParallelUploads: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
await mgr.startBatch([
|
||||||
|
{ file: '/test/a.mp4', hoster: 'doodstream.com', apiKey: 'k' },
|
||||||
|
{ file: '/test/b.mp4', hoster: 'doodstream.com', apiKey: 'k' },
|
||||||
|
{ file: '/test/c.mp4', hoster: 'doodstream.com', apiKey: 'k' },
|
||||||
|
{ file: '/test/d.mp4', hoster: 'doodstream.com', apiKey: 'k' },
|
||||||
|
{ file: '/test/e.mp4', hoster: 'doodstream.com', apiKey: 'k' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.ok(maxConcurrent <= 2, `scaleParallelUploads should cap at 2, was ${maxConcurrent}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('addJobs injects new tasks into running batch', async () => {
|
||||||
|
let started = 0;
|
||||||
|
mockUploadFile.mock.mockImplementation(async (hoster, filePath, apiKey, onProgress) => {
|
||||||
|
started++;
|
||||||
|
await new Promise(r => setTimeout(r, 100));
|
||||||
|
if (onProgress) onProgress(fakeFileSize, fakeFileSize);
|
||||||
|
return { download_url: 'ok', embed_url: null, file_code: 'ok' };
|
||||||
|
});
|
||||||
|
|
||||||
|
const mgr = new UploadManager({});
|
||||||
|
let summary = null;
|
||||||
|
mgr.on('batch-done', (s) => { summary = s; });
|
||||||
|
|
||||||
|
// Start batch with 2 tasks
|
||||||
|
const batchPromise = mgr.startBatch([
|
||||||
|
{ jobId: 'job-1', file: '/test/a.mp4', hoster: 'doodstream.com', apiKey: 'k' },
|
||||||
|
{ jobId: 'job-2', file: '/test/b.mp4', hoster: 'doodstream.com', apiKey: 'k' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
// After 30ms (during upload), inject 2 more tasks
|
||||||
|
await new Promise(r => setTimeout(r, 30));
|
||||||
|
const result = mgr.addJobs([
|
||||||
|
{ jobId: 'job-3', file: '/test/c.mp4', hoster: 'doodstream.com', apiKey: 'k' },
|
||||||
|
{ jobId: 'job-4', file: '/test/d.mp4', hoster: 'doodstream.com', apiKey: 'k' }
|
||||||
|
]);
|
||||||
|
assert.equal(result.added, 2, 'should add 2 new jobs');
|
||||||
|
assert.equal(result.alreadyInBatchJobIds.length, 0);
|
||||||
|
|
||||||
|
await batchPromise;
|
||||||
|
assert.ok(summary);
|
||||||
|
assert.equal(started, 4, 'all 4 jobs should have run');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('addJobs rejects duplicates already in running batch', async () => {
|
||||||
|
mockUploadFile.mock.mockImplementation(async (hoster, filePath, apiKey, onProgress, signal) => {
|
||||||
|
// Slow upload so we can add jobs while it's running
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const timer = setTimeout(resolve, 200);
|
||||||
|
if (signal) signal.addEventListener('abort', () => { clearTimeout(timer); reject(new Error('Aborted')); });
|
||||||
|
});
|
||||||
|
if (onProgress) onProgress(fakeFileSize, fakeFileSize);
|
||||||
|
return { download_url: 'ok', embed_url: null, file_code: 'ok' };
|
||||||
|
});
|
||||||
|
|
||||||
|
const mgr = new UploadManager({});
|
||||||
|
const batchPromise = mgr.startBatch([
|
||||||
|
{ jobId: 'job-A', file: '/test/x.mp4', hoster: 'doodstream.com', apiKey: 'k' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Try to add the SAME jobId while it's running
|
||||||
|
await new Promise(r => setTimeout(r, 50));
|
||||||
|
const result = mgr.addJobs([
|
||||||
|
{ jobId: 'job-A', file: '/test/x.mp4', hoster: 'doodstream.com', apiKey: 'k' },
|
||||||
|
{ jobId: 'job-B', file: '/test/y.mp4', hoster: 'doodstream.com', apiKey: 'k' }
|
||||||
|
]);
|
||||||
|
assert.equal(result.added, 1, 'should skip duplicate jobId, add only the new one');
|
||||||
|
assert.deepEqual(result.alreadyInBatchJobIds, ['job-A']);
|
||||||
|
|
||||||
|
await batchPromise;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('addJobs returns added=0 when not running', () => {
|
||||||
|
const mgr = new UploadManager({});
|
||||||
|
const result = mgr.addJobs([
|
||||||
|
{ jobId: 'job-1', file: '/test/a.mp4', hoster: 'doodstream.com', apiKey: 'k' }
|
||||||
|
]);
|
||||||
|
assert.equal(result.added, 0);
|
||||||
|
});
|
||||||
|
|
||||||
it('stats event contains expected fields', async () => {
|
it('stats event contains expected fields', async () => {
|
||||||
// Make upload take long enough for stats interval to fire
|
// Make upload take long enough for stats interval to fire
|
||||||
mockUploadFile.mock.mockImplementation(async (hoster, filePath, apiKey, onProgress, signal) => {
|
mockUploadFile.mock.mockImplementation(async (hoster, filePath, apiKey, onProgress, signal) => {
|
||||||
@ -303,4 +502,474 @@ describe('UploadManager', () => {
|
|||||||
assert.ok('elapsed' in stat);
|
assert.ok('elapsed' in stat);
|
||||||
assert.ok('activeJobs' in stat);
|
assert.ok('activeJobs' in stat);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('error classification', () => {
|
||||||
|
it('treats "not enough disk space" as account-level, not file-rejected', () => {
|
||||||
|
const mgr = new UploadManager({});
|
||||||
|
// Shape matches what lib/hosters.js attaches for byse account-storage-full
|
||||||
|
const err = new Error('Byse lehnte Datei ab: 0:0:0:not enough disk space on your account');
|
||||||
|
err.accountError = true;
|
||||||
|
assert.equal(mgr._isFileRejectedError(err), false,
|
||||||
|
'account-level error must NOT be classified as file-rejected');
|
||||||
|
assert.equal(mgr._shouldSkipRetryOnAccountError(err), true,
|
||||||
|
'account-storage-full must trigger account rotation');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('classifies disk-space errors by message alone (safety net)', () => {
|
||||||
|
const mgr = new UploadManager({});
|
||||||
|
const err = new Error('Byse lehnte Datei ab: not enough disk space');
|
||||||
|
// No flag set — regex alone must catch it.
|
||||||
|
assert.equal(mgr._shouldSkipRetryOnAccountError(err), true);
|
||||||
|
assert.equal(mgr._isFileRejectedError(err), false,
|
||||||
|
'must not match generic "lehnte Datei ab" as file-rejected');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps true file rejections as file-rejected', () => {
|
||||||
|
const mgr = new UploadManager({});
|
||||||
|
const err = new Error('Byse lehnte Datei ab: Duplicate');
|
||||||
|
err.fileRejected = true;
|
||||||
|
assert.equal(mgr._isFileRejectedError(err), true);
|
||||||
|
assert.equal(mgr._shouldSkipRetryOnAccountError(err), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('file-rejected regex still matches known phrases without flag', () => {
|
||||||
|
const mgr = new UploadManager({});
|
||||||
|
for (const msg of [
|
||||||
|
'Not video file format',
|
||||||
|
'Duplicate',
|
||||||
|
'Datei zu klein',
|
||||||
|
'File too large',
|
||||||
|
'Invalid file'
|
||||||
|
]) {
|
||||||
|
assert.equal(mgr._isFileRejectedError(new Error(msg)), true, `should match: ${msg}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accountError flag beats fileRejected if both set (defensive)', () => {
|
||||||
|
const mgr = new UploadManager({});
|
||||||
|
const err = new Error('weird');
|
||||||
|
err.fileRejected = true;
|
||||||
|
err.accountError = true;
|
||||||
|
assert.equal(mgr._isFileRejectedError(err), false,
|
||||||
|
'account-level always wins — rotation must happen');
|
||||||
|
assert.equal(mgr._shouldSkipRetryOnAccountError(err), true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('session-level account memory', () => {
|
||||||
|
// Scenario: user has 2 byse accounts. Account 1 is full ("not enough
|
||||||
|
// disk space"). First job fails on acc1 → rotation to acc2. Second job
|
||||||
|
// must NOT re-probe acc1; pre-job-swap has to kick in.
|
||||||
|
it('after account is marked failed, next job swaps straight to override without retrying acc1', async () => {
|
||||||
|
// Only acc1 throws disk-space; acc2 succeeds. Mock decides by apiKey.
|
||||||
|
mockUploadFile.mock.mockImplementation(async (hoster, filePath, apiKey, onProgress) => {
|
||||||
|
if (apiKey === 'acc1-key') {
|
||||||
|
const err = new Error('Byse lehnte Datei ab: 0:0:0:not enough disk space on your account');
|
||||||
|
err.accountError = true;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
if (onProgress) onProgress(fakeFileSize, fakeFileSize);
|
||||||
|
return { download_url: 'https://byse.sx/ok', embed_url: null, file_code: 'ok' };
|
||||||
|
});
|
||||||
|
|
||||||
|
const mgr = new UploadManager(
|
||||||
|
{ 'byse.sx': { retries: 3, parallelCount: 1, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 } }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Simulate main.js: on account-failed, resolve fallback → switchAccount
|
||||||
|
mgr.on('account-failed', ({ hoster, accountId }) => {
|
||||||
|
mgr.switchAccount(hoster, { id: 'acc2', username: 'u2', password: 'p2', apiKey: 'acc2-key' });
|
||||||
|
});
|
||||||
|
|
||||||
|
const rotEvents = [];
|
||||||
|
mgr.on('rot-log', (e) => rotEvents.push(e));
|
||||||
|
const progress = [];
|
||||||
|
mgr.on('progress', (d) => progress.push({ fileName: d.fileName, status: d.status, error: d.error }));
|
||||||
|
|
||||||
|
await mgr.startBatch([
|
||||||
|
{ file: '/test/a.mp4', hoster: 'byse.sx', apiKey: 'acc1-key', accountId: 'acc1', username: 'u1', password: 'p1' },
|
||||||
|
{ file: '/test/b.mp4', hoster: 'byse.sx', apiKey: 'acc1-key', accountId: 'acc1', username: 'u1', password: 'p1' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Event sequence we expect:
|
||||||
|
// - job A: fast-fail on acc1 → mark-failed → switchAccount → rotate → upload with acc2 → done
|
||||||
|
// - job B: pre-job-swap from acc1 → acc2 (no attempts on acc1!) → done
|
||||||
|
const events = rotEvents.map(e => e.event);
|
||||||
|
assert.ok(events.includes('fast-fail'), `expected fast-fail, got: ${events.join(',')}`);
|
||||||
|
assert.ok(events.includes('mark-failed'), `expected mark-failed, got: ${events.join(',')}`);
|
||||||
|
assert.ok(events.includes('switchAccount'), `expected switchAccount, got: ${events.join(',')}`);
|
||||||
|
assert.ok(events.includes('pre-job-swap'), `expected pre-job-swap for 2nd job, got: ${events.join(',')}`);
|
||||||
|
|
||||||
|
// job B's pre-job-swap MUST predate any upload attempt for /test/b.mp4.
|
||||||
|
// If acc1 was probed for B, the mock would have thrown and we'd see
|
||||||
|
// another fast-fail or retrying event for b.mp4.
|
||||||
|
const bProgressErrors = progress
|
||||||
|
.filter(p => p.fileName && p.fileName.includes('b.mp4') && p.error)
|
||||||
|
.map(p => p.error);
|
||||||
|
assert.equal(bProgressErrors.length, 0,
|
||||||
|
`job B should never have touched acc1; got errors: ${bProgressErrors.join(' | ')}`);
|
||||||
|
|
||||||
|
// Both jobs should be done at the end.
|
||||||
|
const doneFiles = progress.filter(p => p.status === 'done').map(p => p.fileName);
|
||||||
|
assert.ok(doneFiles.some(f => f && f.includes('a.mp4')), 'a.mp4 should finish via rotation');
|
||||||
|
assert.ok(doneFiles.some(f => f && f.includes('b.mp4')), 'b.mp4 should finish via pre-job-swap');
|
||||||
|
|
||||||
|
// Sanity: mock was called with acc2-key more often than acc1-key.
|
||||||
|
const byKey = { acc1: 0, acc2: 0 };
|
||||||
|
for (const call of mockUploadFile.mock.calls) {
|
||||||
|
if (call.arguments[2] === 'acc1-key') byKey.acc1++;
|
||||||
|
else if (call.arguments[2] === 'acc2-key') byKey.acc2++;
|
||||||
|
}
|
||||||
|
assert.ok(byKey.acc1 <= 1, `acc1 should only be tried once (for job A); got ${byKey.acc1}`);
|
||||||
|
assert.ok(byKey.acc2 >= 2, `acc2 should handle both jobs after rotation; got ${byKey.acc2}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('on fresh UploadManager (simulates app restart), failed-account memory is gone', () => {
|
||||||
|
const mgr1 = new UploadManager({});
|
||||||
|
mgr1._failedAccounts.set('byse.sx:acc1', true);
|
||||||
|
mgr1.switchAccount('byse.sx', { id: 'acc2' });
|
||||||
|
assert.equal(mgr1._failedAccounts.size, 1);
|
||||||
|
assert.equal(mgr1._accountOverrides.size, 1);
|
||||||
|
|
||||||
|
const mgr2 = new UploadManager({});
|
||||||
|
assert.equal(mgr2._failedAccounts.size, 0, 'new manager must start clean');
|
||||||
|
assert.equal(mgr2._accountOverrides.size, 0, 'override map must be empty on fresh manager');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes failed-account introspection (for main.js mid-batch re-resolve)', () => {
|
||||||
|
const mgr = new UploadManager({});
|
||||||
|
assert.deepEqual(mgr.getFailedAccountKeys(), []);
|
||||||
|
assert.equal(mgr.getOverride('byse.sx'), null);
|
||||||
|
|
||||||
|
mgr._failedAccounts.set('byse.sx:acc1', true);
|
||||||
|
mgr._failedAccounts.set('voe.sx:other', true);
|
||||||
|
assert.deepEqual(mgr.getFailedAccountKeys().sort(), ['byse.sx:acc1', 'voe.sx:other']);
|
||||||
|
assert.equal(mgr.getOverride('byse.sx'), null, 'no override yet');
|
||||||
|
|
||||||
|
mgr.switchAccount('byse.sx', { id: 'acc2', apiKey: 'k2' });
|
||||||
|
assert.equal(mgr.getOverride('byse.sx').id, 'acc2');
|
||||||
|
assert.equal(mgr.getOverride('voe.sx'), null, 'unrelated hoster still has no override');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('startBatch primes failed-accounts + overrides — retry after batch-done skips dead account', async () => {
|
||||||
|
// acc1 fails with disk-space; acc2 succeeds.
|
||||||
|
mockUploadFile.mock.mockImplementation(async (hoster, filePath, apiKey, onProgress) => {
|
||||||
|
if (apiKey === 'acc1-key') {
|
||||||
|
const err = new Error('Byse lehnte Datei ab: not enough disk space');
|
||||||
|
err.accountError = true;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
if (onProgress) onProgress(fakeFileSize, fakeFileSize);
|
||||||
|
return { download_url: 'ok', embed_url: null, file_code: 'ok' };
|
||||||
|
});
|
||||||
|
|
||||||
|
const mgr = new UploadManager(
|
||||||
|
{ 'byse.sx': { retries: 3, parallelCount: 1, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 } }
|
||||||
|
);
|
||||||
|
const rotEvents = [];
|
||||||
|
mgr.on('rot-log', (e) => rotEvents.push(e));
|
||||||
|
|
||||||
|
// Simulate a retry-after-batch-done: main.js would pass the
|
||||||
|
// session-cached failed-accounts + overrides from the previous batch.
|
||||||
|
await mgr.startBatch([
|
||||||
|
{ file: '/test/a.mp4', hoster: 'byse.sx', apiKey: 'acc1-key', accountId: 'acc1', username: 'u1', password: 'p1' }
|
||||||
|
], {
|
||||||
|
primeFailedAccounts: ['byse.sx:acc1'],
|
||||||
|
primeOverrides: [['byse.sx', { id: 'acc2', username: 'u2', password: 'p2', apiKey: 'acc2-key' }]]
|
||||||
|
});
|
||||||
|
|
||||||
|
const events = rotEvents.map(e => e.event);
|
||||||
|
// pre-job-swap should fire on the very first attempt — no fast-fail
|
||||||
|
// because acc1 was never touched.
|
||||||
|
assert.ok(events.includes('pre-job-swap'),
|
||||||
|
`expected pre-job-swap from primed state; got: ${events.join(',')}`);
|
||||||
|
assert.ok(!events.includes('fast-fail'),
|
||||||
|
`must NOT burn a fast-fail on primed-dead acc1; got: ${events.join(',')}`);
|
||||||
|
assert.ok(!events.includes('mark-failed'),
|
||||||
|
`acc1 was already marked failed (primed); must not emit mark-failed again; got: ${events.join(',')}`);
|
||||||
|
|
||||||
|
// acc1 must not be touched at all.
|
||||||
|
const acc1Calls = mockUploadFile.mock.calls.filter(c => c.arguments[2] === 'acc1-key').length;
|
||||||
|
assert.equal(acc1Calls, 0, 'primed-dead acc1 must not receive any upload attempts');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generic error + pre-resolved override: rotates after 1 attempt (no more 5x on primary)', async () => {
|
||||||
|
// acc1 throws a generic non-transient, non-account-specific error.
|
||||||
|
// acc2 succeeds. With a pre-resolved override (from main.js at batch
|
||||||
|
// start), the retry loop must break after 1 attempt on acc1 and rotate.
|
||||||
|
let acc1Calls = 0;
|
||||||
|
let acc2Calls = 0;
|
||||||
|
mockUploadFile.mock.mockImplementation(async (hoster, filePath, apiKey, onProgress) => {
|
||||||
|
if (apiKey === 'acc1-key') {
|
||||||
|
acc1Calls++;
|
||||||
|
throw new Error('VOE Upload: irgendein generischer Fehler');
|
||||||
|
}
|
||||||
|
acc2Calls++;
|
||||||
|
if (onProgress) onProgress(fakeFileSize, fakeFileSize);
|
||||||
|
return { download_url: 'ok', embed_url: null, file_code: 'ok' };
|
||||||
|
});
|
||||||
|
|
||||||
|
const mgr = new UploadManager(
|
||||||
|
{ 'voe.sx': { retries: 5, parallelCount: 1, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 } }
|
||||||
|
);
|
||||||
|
const events = [];
|
||||||
|
mgr.on('rot-log', (e) => events.push(e.event));
|
||||||
|
|
||||||
|
await mgr.startBatch([
|
||||||
|
{ file: '/test/a.mp4', hoster: 'voe.sx', apiKey: 'acc1-key', accountId: 'acc1' }
|
||||||
|
], {
|
||||||
|
// Main.js pre-resolves the fallback at batch start.
|
||||||
|
primeOverrides: [['voe.sx', { id: 'acc2', apiKey: 'acc2-key' }]]
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(acc1Calls, 1, 'acc1 must get exactly 1 attempt before rotation kicks in');
|
||||||
|
assert.ok(acc2Calls >= 1, 'acc2 must take over');
|
||||||
|
assert.ok(events.includes('try-alternate-after-fail'),
|
||||||
|
`expected try-alternate-after-fail; got: ${events.join(',')}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('transient error + pre-resolved override: retries SAME acc (network, not acc, is the issue)', async () => {
|
||||||
|
let acc1Calls = 0;
|
||||||
|
let acc2Calls = 0;
|
||||||
|
mockUploadFile.mock.mockImplementation(async (hoster, filePath, apiKey, onProgress) => {
|
||||||
|
if (apiKey === 'acc1-key') {
|
||||||
|
acc1Calls++;
|
||||||
|
if (acc1Calls <= 2) throw new Error('connect ECONNRESET 1.2.3.4:443');
|
||||||
|
if (onProgress) onProgress(fakeFileSize, fakeFileSize);
|
||||||
|
return { download_url: 'ok-on-acc1-retry', embed_url: null, file_code: 'ok' };
|
||||||
|
}
|
||||||
|
acc2Calls++;
|
||||||
|
if (onProgress) onProgress(fakeFileSize, fakeFileSize);
|
||||||
|
return { download_url: 'ok', embed_url: null, file_code: 'ok' };
|
||||||
|
});
|
||||||
|
|
||||||
|
const mgr = new UploadManager(
|
||||||
|
{ 'voe.sx': { retries: 5, parallelCount: 1, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 } }
|
||||||
|
);
|
||||||
|
const events = [];
|
||||||
|
mgr.on('rot-log', (e) => events.push(e.event));
|
||||||
|
|
||||||
|
await mgr.startBatch([
|
||||||
|
{ file: '/test/a.mp4', hoster: 'voe.sx', apiKey: 'acc1-key', accountId: 'acc1' }
|
||||||
|
], {
|
||||||
|
primeOverrides: [['voe.sx', { id: 'acc2', apiKey: 'acc2-key' }]]
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(acc1Calls, 3, 'transient must retry same acc until success');
|
||||||
|
assert.equal(acc2Calls, 0, 'must NOT rotate away on transient network errors');
|
||||||
|
assert.ok(!events.includes('try-alternate-after-fail'),
|
||||||
|
`transient should NOT trigger try-alternate-after-fail; got: ${events.join(',')}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generic error + NO override: falls back to classic retry on same account', async () => {
|
||||||
|
let acc1Calls = 0;
|
||||||
|
mockUploadFile.mock.mockImplementation(async (hoster, filePath, apiKey, onProgress) => {
|
||||||
|
acc1Calls++;
|
||||||
|
throw new Error('something generic');
|
||||||
|
});
|
||||||
|
|
||||||
|
const mgr = new UploadManager(
|
||||||
|
{ 'voe.sx': { retries: 3, parallelCount: 1, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 } }
|
||||||
|
);
|
||||||
|
|
||||||
|
await mgr.startBatch([
|
||||||
|
{ file: '/test/a.mp4', hoster: 'voe.sx', apiKey: 'acc1-key', accountId: 'acc1' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
// retries=3 → maxAttempts=4 (retries + 1). Without an override to rotate
|
||||||
|
// to, must exhaust all 4 attempts on acc1.
|
||||||
|
assert.equal(acc1Calls, 4, 'single-account hoster must retry N+1 times on same account');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('startBatch without prime opts still clears state (back-compat)', async () => {
|
||||||
|
const mgr = new UploadManager({});
|
||||||
|
mgr._failedAccounts.set('byse.sx:acc1', true);
|
||||||
|
mgr._accountOverrides.set('byse.sx', { id: 'leftover' });
|
||||||
|
|
||||||
|
mockUploadFile.mock.mockImplementation(async (hoster, filePath, apiKey, onProgress) => {
|
||||||
|
if (onProgress) onProgress(fakeFileSize, fakeFileSize);
|
||||||
|
return { download_url: 'ok', embed_url: null, file_code: 'ok' };
|
||||||
|
});
|
||||||
|
|
||||||
|
await mgr.startBatch([
|
||||||
|
{ file: '/test/a.mp4', hoster: 'doodstream.com', apiKey: 'k' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.equal(mgr._failedAccounts.size, 0, 'legacy callers still get a clean slate');
|
||||||
|
assert.equal(mgr._accountOverrides.size, 0, 'legacy callers still get a clean slate for overrides');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('transient network errors skip rotation (account stays fine)', () => {
|
||||||
|
const mgr = new UploadManager({});
|
||||||
|
const cases = [
|
||||||
|
'getaddrinfo ENOTFOUND api.byse.sx',
|
||||||
|
'connect ECONNRESET 104.18.10.10:443',
|
||||||
|
'connect ETIMEDOUT 1.2.3.4:443',
|
||||||
|
'socket hang up',
|
||||||
|
'request to https://voe.sx failed, reason: getaddrinfo EAI_AGAIN',
|
||||||
|
'fetch failed',
|
||||||
|
'connect ECONNREFUSED 127.0.0.1:443',
|
||||||
|
'network error'
|
||||||
|
];
|
||||||
|
for (const msg of cases) {
|
||||||
|
const err = new Error(msg);
|
||||||
|
assert.equal(mgr._isTransientNetworkError(err), true, `should mark transient: ${msg}`);
|
||||||
|
assert.equal(mgr._isFileRejectedError(err), false, `transient must NOT be file-rejected: ${msg}`);
|
||||||
|
assert.equal(mgr._shouldSkipRetryOnAccountError(err), false, `transient must NOT be account-specific: ${msg}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('transient classification does not swallow real account failures', () => {
|
||||||
|
const mgr = new UploadManager({});
|
||||||
|
const notTransient = [
|
||||||
|
'HTTP 429 Too Many Requests',
|
||||||
|
'quota exceeded',
|
||||||
|
'account suspended',
|
||||||
|
'Byse lehnte Datei ab: Duplicate',
|
||||||
|
'Falscher Passwort',
|
||||||
|
'Session expired'
|
||||||
|
];
|
||||||
|
for (const msg of notTransient) {
|
||||||
|
const err = new Error(msg);
|
||||||
|
assert.equal(mgr._isTransientNetworkError(err), false,
|
||||||
|
`must NOT be transient: "${msg}"`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hoster-transient flag is recognised (primary path)', () => {
|
||||||
|
const mgr = new UploadManager({});
|
||||||
|
const err = new Error('whatever');
|
||||||
|
err.hosterTransient = true;
|
||||||
|
assert.equal(mgr._isHosterTransientError(err), true);
|
||||||
|
// Must not be confused with other classes.
|
||||||
|
assert.equal(mgr._isFileRejectedError(err), false);
|
||||||
|
assert.equal(mgr._isTransientNetworkError(err), false);
|
||||||
|
assert.equal(mgr._shouldSkipRetryOnAccountError(err), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hoster-transient regex fallback catches wrapped doodstream empty-form errors', () => {
|
||||||
|
const mgr = new UploadManager({});
|
||||||
|
const cases = [
|
||||||
|
'Doodstream Upload: kein Filecode — Server gab leeren Link zurueck (st=?, fn=fehlt/leer ...)',
|
||||||
|
'wrapper: Server gab leeren Link zurueck while parsing'
|
||||||
|
];
|
||||||
|
for (const msg of cases) {
|
||||||
|
assert.equal(mgr._isHosterTransientError(new Error(msg)), true, `should match: ${msg}`);
|
||||||
|
}
|
||||||
|
// Plain network and account errors must NOT match the hoster-transient class.
|
||||||
|
const negatives = [
|
||||||
|
'fetch failed',
|
||||||
|
'getaddrinfo ENOTFOUND',
|
||||||
|
'HTTP 429',
|
||||||
|
'quota exceeded',
|
||||||
|
'Byse lehnte Datei ab: Duplicate'
|
||||||
|
];
|
||||||
|
for (const msg of negatives) {
|
||||||
|
assert.equal(mgr._isHosterTransientError(new Error(msg)), false, `must NOT match: ${msg}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('regression: hoster-transient does NOT blacklist the account (account stays usable across batches)', async () => {
|
||||||
|
// Simulate doodstream-upload throwing the tagged empty-form error.
|
||||||
|
mockUploadFile.mock.mockImplementation(async () => {
|
||||||
|
const err = new Error('Doodstream Upload: kein Filecode — Server gab leeren Link zurueck (st=?, fn=fehlt/leer ...)');
|
||||||
|
err.hosterTransient = true;
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
|
||||||
|
const mgr = new UploadManager(
|
||||||
|
{ 'doodstream.com': { retries: 3, parallelCount: 1, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 } }
|
||||||
|
);
|
||||||
|
const rotEvents = [];
|
||||||
|
mgr.on('rot-log', (e) => rotEvents.push(e));
|
||||||
|
|
||||||
|
// No username/password so the manager routes through the mocked
|
||||||
|
// hosters.uploadFile (instead of DoodstreamUploader directly).
|
||||||
|
await mgr.startBatch([
|
||||||
|
{ file: '/test/Arrested.Development.mkv', hoster: 'doodstream.com', apiKey: 'acc1-key', accountId: 'acc1' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
const events = rotEvents.map(e => e.event);
|
||||||
|
// Must NOT poison the account — that's the entire point of this fix.
|
||||||
|
assert.equal(mgr._failedAccounts.size, 0, `account must NOT be blacklisted; _failedAccounts=${JSON.stringify(mgr.getFailedAccountKeys())}`);
|
||||||
|
assert.ok(!events.includes('mark-failed'), `must NOT mark-failed for hoster-transient; got: ${events.join(',')}`);
|
||||||
|
// The in-loop fast-break and the post-loop classification must both fire.
|
||||||
|
assert.ok(events.includes('hoster-transient'),
|
||||||
|
`expected hoster-transient (in-loop break, no wasted retries); got: ${events.join(',')}`);
|
||||||
|
assert.ok(events.includes('skip-rotation-hoster-transient'),
|
||||||
|
`expected skip-rotation-hoster-transient (post-loop branch); got: ${events.join(',')}`);
|
||||||
|
// And the retry loop must NOT burn the full retries=3 -> only 1 attempt on this account.
|
||||||
|
assert.equal(mockUploadFile.mock.calls.length, 1,
|
||||||
|
`must fail fast on hoster-transient, not re-upload the file 4× wasting bandwidth; got ${mockUploadFile.mock.calls.length} calls`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('late-resolved override is honored by subsequent jobs (simulates mid-batch config add)', async () => {
|
||||||
|
// Only acc1 throws; acc2 succeeds.
|
||||||
|
mockUploadFile.mock.mockImplementation(async (hoster, filePath, apiKey, onProgress) => {
|
||||||
|
if (apiKey === 'acc1-key') {
|
||||||
|
const err = new Error('Byse lehnte Datei ab: not enough disk space');
|
||||||
|
err.accountError = true;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
if (onProgress) onProgress(fakeFileSize, fakeFileSize);
|
||||||
|
return { download_url: 'ok', embed_url: null, file_code: 'ok' };
|
||||||
|
});
|
||||||
|
|
||||||
|
const mgr = new UploadManager(
|
||||||
|
{ 'byse.sx': { retries: 1, parallelCount: 1, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 } }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Scenario: initially config has ONLY acc1. No account-failed listener
|
||||||
|
// resolves a fallback (because none exists in config yet). Job A fails
|
||||||
|
// with rotation-end.
|
||||||
|
const rotEvents = [];
|
||||||
|
mgr.on('rot-log', (e) => rotEvents.push(e));
|
||||||
|
|
||||||
|
await mgr.startBatch([
|
||||||
|
{ file: '/test/a.mp4', hoster: 'byse.sx', apiKey: 'acc1-key', accountId: 'acc1', username: 'u1', password: 'p1' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Job A should have ended with rotation-end (no fallback available).
|
||||||
|
const eventsA = rotEvents.map(e => e.event);
|
||||||
|
assert.ok(eventsA.includes('mark-failed'), 'acc1 must be marked failed');
|
||||||
|
assert.ok(eventsA.includes('rotation-end'), 'expected rotation-end without a fallback');
|
||||||
|
assert.equal(mgr.getFailedAccountKeys().length, 1);
|
||||||
|
assert.equal(mgr.getOverride('byse.sx'), null, 'no override set during first batch');
|
||||||
|
|
||||||
|
// --- Simulate: user adds acc2 in Settings → save-config handler finds
|
||||||
|
// that byse.sx:acc1 is failed without an override → resolves + switches.
|
||||||
|
mgr.switchAccount('byse.sx', { id: 'acc2', username: 'u2', password: 'p2', apiKey: 'acc2-key' });
|
||||||
|
|
||||||
|
// Now a follow-up batch (same running session — in production, addJobs
|
||||||
|
// or a new startBatch without clearing maps would reach this state).
|
||||||
|
// We need to ALSO clear _failedAccounts manually here because startBatch
|
||||||
|
// resets it — so we poke the inner state to emulate "still mid-batch
|
||||||
|
// with late config". The switchAccount-after-fail path is what matters.
|
||||||
|
rotEvents.length = 0;
|
||||||
|
// Re-run just the _runJob path by manually setting up state and using
|
||||||
|
// addJobs — simulates mid-batch job add after config change.
|
||||||
|
mgr.running = true;
|
||||||
|
mgr._batchResults = new Map();
|
||||||
|
mgr._batchResults.set('/test/b.mp4', { name: 'b.mp4', size: fakeFileSize, results: [] });
|
||||||
|
mgr._failedAccounts.set('byse.sx:acc1', true); // re-establish failed state
|
||||||
|
mgr._additionalPromises = [];
|
||||||
|
|
||||||
|
// Spawn a new job through addJobs() path (uses _runJob internally)
|
||||||
|
const addResult = await mgr.addJobs([
|
||||||
|
{ file: '/test/b.mp4', hoster: 'byse.sx', apiKey: 'acc1-key', accountId: 'acc1', username: 'u1', password: 'p1', jobId: 'jb' }
|
||||||
|
]);
|
||||||
|
assert.ok(addResult.added >= 1 || addResult.alreadyInBatch === 0,
|
||||||
|
`addJobs should accept new job: ${JSON.stringify(addResult)}`);
|
||||||
|
await Promise.allSettled(mgr._additionalPromises);
|
||||||
|
|
||||||
|
const eventsB = rotEvents.map(e => e.event);
|
||||||
|
assert.ok(eventsB.includes('pre-job-swap'),
|
||||||
|
`job B should have pre-job-swap after late override was set; got: ${eventsB.join(',')}`);
|
||||||
|
|
||||||
|
// Mock must have been called with acc2-key for the new job (not acc1-key again)
|
||||||
|
const acc1ForB = mockUploadFile.mock.calls.filter(c =>
|
||||||
|
c.arguments[1] === '/test/b.mp4' && c.arguments[2] === 'acc1-key').length;
|
||||||
|
assert.equal(acc1ForB, 0, 'job B must never touch acc1-key after late fallback was set');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
195
tests/validate-credentials.test.js
Normal file
195
tests/validate-credentials.test.js
Normal file
@ -0,0 +1,195 @@
|
|||||||
|
// Pure unit tests for the validate-credentials shape contract — does NOT spin
|
||||||
|
// up Electron or the real per-hoster checkers. Those need network. We verify
|
||||||
|
// the SHAPE the ephemeral hosterConfig is built into (which the per-hoster
|
||||||
|
// checkers consume) plus the snapshot-key/invalidation invariants that the
|
||||||
|
// renderer relies on to enforce "validated creds only".
|
||||||
|
//
|
||||||
|
// The three assertions the advisor called out as the regression guard for the
|
||||||
|
// user's "mehrfach angelegt" complaint:
|
||||||
|
// (a) failed validation persists nothing to config.hosters
|
||||||
|
// (b) a second "Anlegen" click with the guard set persists exactly one entry
|
||||||
|
// (c) OTP-required path persists nothing
|
||||||
|
// are exercised at the state-machine level by simulating the renderer's logic
|
||||||
|
// (re-implemented here as pure functions for testability — the real ones live
|
||||||
|
// in renderer/app.js which can't run under node:test).
|
||||||
|
|
||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
|
||||||
|
// ---- Re-implementations of the renderer's pure helpers ----
|
||||||
|
// These mirror the production code exactly so the tests serve as both a guard
|
||||||
|
// and executable spec for what saveAccount() must do.
|
||||||
|
|
||||||
|
function credsSnapshotKey(authType, creds) {
|
||||||
|
if (authType === 'login') return `login:${creds.username || ''}:${creds.password || ''}`;
|
||||||
|
return `api:${creds.apiKey || ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEphemeralHosterConfig(payload) {
|
||||||
|
return {
|
||||||
|
username: payload.username || '',
|
||||||
|
password: payload.password || '',
|
||||||
|
apiKey: payload.apiKey || '',
|
||||||
|
enabled: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// State-machine simulator that mirrors saveAccount() WITHOUT DOM/IPC.
|
||||||
|
function makeStateMachine({ validateImpl, persistImpl }) {
|
||||||
|
let busy = false;
|
||||||
|
let validated = null; // { hosterName, authType, snapshot, status }
|
||||||
|
const log = []; // log of every persist call, for assertions
|
||||||
|
|
||||||
|
async function click(ctx, creds, otp = '') {
|
||||||
|
if (busy) { log.push({ type: 'click-ignored-busy' }); return; }
|
||||||
|
const snapshot = credsSnapshotKey(ctx.authType, creds);
|
||||||
|
|
||||||
|
// STEP 2: commit if validated matches.
|
||||||
|
if (validated &&
|
||||||
|
validated.hosterName === ctx.hosterName &&
|
||||||
|
validated.authType === ctx.authType &&
|
||||||
|
validated.snapshot === snapshot) {
|
||||||
|
busy = true;
|
||||||
|
try {
|
||||||
|
await persistImpl(ctx, creds);
|
||||||
|
log.push({ type: 'persisted', accountId: ctx.accountId || `${ctx.hosterName}-NEW` });
|
||||||
|
} finally { busy = false; }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// STEP 1: ephemeral validate.
|
||||||
|
busy = true;
|
||||||
|
let row;
|
||||||
|
try {
|
||||||
|
row = await validateImpl({ hoster: ctx.hosterName, authType: ctx.authType, ...creds, otp });
|
||||||
|
} finally { busy = false; }
|
||||||
|
if (row && (row.status === 'ok' || row.status === 'warn')) {
|
||||||
|
validated = { hosterName: ctx.hosterName, authType: ctx.authType, snapshot, status: row.status };
|
||||||
|
log.push({ type: 'validated', status: row.status });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (row && row.status === 'otp_required') {
|
||||||
|
log.push({ type: 'otp-required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
log.push({ type: 'validation-failed', message: row && row.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
function editField() { validated = null; log.push({ type: 'invalidated-by-edit' }); }
|
||||||
|
return { click, editField, log: () => log.slice(), getValidated: () => validated };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Tests ----
|
||||||
|
|
||||||
|
test('regression (a): failed validation persists NOTHING to config.hosters', async () => {
|
||||||
|
const persistCalls = [];
|
||||||
|
const sm = makeStateMachine({
|
||||||
|
validateImpl: async () => ({ status: 'error', message: 'Falsches Passwort' }),
|
||||||
|
persistImpl: async (ctx, creds) => persistCalls.push({ ctx, creds })
|
||||||
|
});
|
||||||
|
await sm.click({ hosterName: 'doodstream.com', authType: 'login', isEdit: false }, { username: 'u', password: 'wrong' });
|
||||||
|
assert.equal(persistCalls.length, 0, 'no persist should happen on failed validation');
|
||||||
|
assert.equal(sm.getValidated(), null);
|
||||||
|
assert.deepEqual(sm.log().map(e => e.type), ['validation-failed']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('regression (b): second click with guard set persists exactly ONE entry — no duplication', async () => {
|
||||||
|
const persistCalls = [];
|
||||||
|
let validateCount = 0;
|
||||||
|
const sm = makeStateMachine({
|
||||||
|
validateImpl: async () => { validateCount++; return { status: 'ok' }; },
|
||||||
|
persistImpl: async (ctx, creds) => persistCalls.push({ ctx, creds })
|
||||||
|
});
|
||||||
|
const ctx = { hosterName: 'doodstream.com', authType: 'login', isEdit: false };
|
||||||
|
const creds = { username: 'u', password: 'p' };
|
||||||
|
// Click 1 = validate → green.
|
||||||
|
await sm.click(ctx, creds);
|
||||||
|
// Click 2 = commit (same creds, validated snapshot matches).
|
||||||
|
await sm.click(ctx, creds);
|
||||||
|
// Click 3 = guard prevents a second commit because after persistImpl the
|
||||||
|
// state-machine in real code closes the modal. In this simulator the
|
||||||
|
// validated snapshot is still set — but a real double-click WHILE persistImpl
|
||||||
|
// is in flight would be caught by busy. Simulate that:
|
||||||
|
const sm2 = makeStateMachine({
|
||||||
|
validateImpl: async () => ({ status: 'ok' }),
|
||||||
|
persistImpl: () => new Promise(r => setTimeout(() => { persistCalls.push('slow'); r(); }, 30))
|
||||||
|
});
|
||||||
|
await sm2.click(ctx, creds); // validate
|
||||||
|
const p1 = sm2.click(ctx, creds); // start commit
|
||||||
|
const p2 = sm2.click(ctx, creds); // racing click — must be ignored
|
||||||
|
await Promise.all([p1, p2]);
|
||||||
|
|
||||||
|
assert.equal(persistCalls.length, 2, 'one persist from the deliberate two-step flow + one from sm2; racing click ignored');
|
||||||
|
assert.equal(validateCount, 1, 'second click reused the validated snapshot — no re-validate');
|
||||||
|
// The racing click MUST have been ignored by the busy guard.
|
||||||
|
assert.ok(sm2.log().some(e => e.type === 'click-ignored-busy'), 'busy guard fired on racing click');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('regression (c): OTP-required persists NOTHING — and a follow-up click with OTP re-validates ephemerally', async () => {
|
||||||
|
const persistCalls = [];
|
||||||
|
let calls = 0;
|
||||||
|
const sm = makeStateMachine({
|
||||||
|
validateImpl: async (payload) => {
|
||||||
|
calls++;
|
||||||
|
if (!payload.otp) return { status: 'otp_required', message: 'OTP sent' };
|
||||||
|
if (payload.otp === '123456') return { status: 'ok' };
|
||||||
|
return { status: 'error', message: 'Bad OTP' };
|
||||||
|
},
|
||||||
|
persistImpl: async (ctx, creds) => persistCalls.push({ ctx, creds })
|
||||||
|
});
|
||||||
|
const ctx = { hosterName: 'doodstream.com', authType: 'login', isEdit: false };
|
||||||
|
const creds = { username: 'u', password: 'p' };
|
||||||
|
await sm.click(ctx, creds, ''); // first click → otp_required
|
||||||
|
await sm.click(ctx, creds, '123456'); // retry with otp → ok
|
||||||
|
await sm.click(ctx, creds); // final click → commit
|
||||||
|
assert.equal(persistCalls.length, 1, 'exactly one persist after OTP confirmed');
|
||||||
|
assert.equal(calls, 2, 'validate ran twice (initial + OTP) before commit');
|
||||||
|
assert.deepEqual(
|
||||||
|
sm.log().map(e => e.type),
|
||||||
|
['otp-required', 'validated', 'persisted']
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('field edit after green check invalidates the snapshot — next click is a re-Prüfen, not a commit', async () => {
|
||||||
|
const persistCalls = [];
|
||||||
|
let validateCount = 0;
|
||||||
|
const sm = makeStateMachine({
|
||||||
|
validateImpl: async () => { validateCount++; return { status: 'ok' }; },
|
||||||
|
persistImpl: async (ctx, creds) => persistCalls.push({ ctx, creds })
|
||||||
|
});
|
||||||
|
const ctx = { hosterName: 'doodstream.com', authType: 'login', isEdit: false };
|
||||||
|
await sm.click(ctx, { username: 'u', password: 'p' }); // validate → green
|
||||||
|
sm.editField(); // user edits cred field → snapshot dropped
|
||||||
|
await sm.click(ctx, { username: 'u', password: 'newpw' }); // creds differ → re-validate
|
||||||
|
await sm.click(ctx, { username: 'u', password: 'newpw' }); // now commit the NEW creds
|
||||||
|
assert.equal(persistCalls.length, 1, 'one persist of the new (re-validated) creds');
|
||||||
|
assert.equal(persistCalls[0].creds.password, 'newpw', 'persisted creds match the re-validated set');
|
||||||
|
assert.equal(validateCount, 2, 'second validate was forced by the edit-induced invalidation');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('snapshot key is identical for same creds and DIFFERENT for any cred change (excluding label)', () => {
|
||||||
|
// Label changes must NOT invalidate validation — label is metadata, not a credential.
|
||||||
|
assert.equal(credsSnapshotKey('login', { username: 'u', password: 'p' }),
|
||||||
|
credsSnapshotKey('login', { username: 'u', password: 'p', label: 'XYZ' }));
|
||||||
|
assert.notEqual(credsSnapshotKey('login', { username: 'u', password: 'p' }),
|
||||||
|
credsSnapshotKey('login', { username: 'u', password: 'P' })); // password char-case
|
||||||
|
assert.notEqual(credsSnapshotKey('login', { username: 'u', password: 'p' }),
|
||||||
|
credsSnapshotKey('login', { username: 'U', password: 'p' })); // username diff
|
||||||
|
assert.equal(credsSnapshotKey('api', { apiKey: 'KEY' }),
|
||||||
|
credsSnapshotKey('api', { apiKey: 'KEY', label: 'mein key' }));
|
||||||
|
assert.notEqual(credsSnapshotKey('api', { apiKey: 'KEY' }),
|
||||||
|
credsSnapshotKey('api', { apiKey: 'KEY2' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ephemeral hosterConfig shape matches what per-hoster checkers expect', () => {
|
||||||
|
// The per-hoster checkers in main.js read .username/.password/.apiKey directly.
|
||||||
|
// This guards the validate-credentials IPC contract from drifting.
|
||||||
|
const cfg = buildEphemeralHosterConfig({ hoster: 'doodstream.com', username: 'u', password: 'p' });
|
||||||
|
assert.equal(cfg.username, 'u');
|
||||||
|
assert.equal(cfg.password, 'p');
|
||||||
|
assert.equal(cfg.apiKey, '');
|
||||||
|
assert.equal(cfg.enabled, true);
|
||||||
|
const cfg2 = buildEphemeralHosterConfig({ hoster: 'byse.sx', apiKey: 'K' });
|
||||||
|
assert.equal(cfg2.apiKey, 'K');
|
||||||
|
assert.equal(cfg2.username, '');
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user