Compare commits
40 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 |
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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@ -1,6 +1,7 @@
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const secretStore = require('./secret-store');
|
const secretStore = require('./secret-store');
|
||||||
|
const { normalizeLogMode } = require('./log-mode');
|
||||||
|
|
||||||
const HOSTER_SETTINGS_DEFAULTS = {
|
const HOSTER_SETTINGS_DEFAULTS = {
|
||||||
retries: 3,
|
retries: 3,
|
||||||
@ -56,7 +57,12 @@ const DEFAULTS = {
|
|||||||
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,
|
||||||
@ -145,7 +151,11 @@ 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;
|
||||||
|
}
|
||||||
|
|
||||||
// Migrate old single-object format to array format
|
// Migrate old single-object format to array format
|
||||||
for (const [name, val] of Object.entries(data.hosters || {})) {
|
for (const [name, val] of Object.entries(data.hosters || {})) {
|
||||||
@ -207,13 +217,20 @@ class ConfigStore {
|
|||||||
globalSettings[key] = { ...def, ...(savedGlobal[key] || {}) };
|
globalSettings[key] = { ...def, ...(savedGlobal[key] || {}) };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 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 || [] };
|
const result = { hosters, hosterSettings, globalSettings, history: data.history || [] };
|
||||||
// Decrypt credentials stored with safeStorage so the rest of the app
|
// Decrypt credentials stored with safeStorage so the rest of the app
|
||||||
// keeps working with plaintext in memory.
|
// keeps working with plaintext in memory.
|
||||||
secretStore.decryptCredentials(result);
|
secretStore.decryptCredentials(result);
|
||||||
return 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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 };
|
||||||
@ -499,24 +499,27 @@ async function _resolveDoodstreamUploadByName(apiKey, fileName, baselineCodes, s
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function uploadFile(hosterName, filePath, apiKey, onProgress, signal, throttle) {
|
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}`);
|
||||||
|
|
||||||
// For byse: snapshot the current file-code list so the post-upload poller
|
|
||||||
// can identify new arrivals even when the initial POST response has an
|
|
||||||
// empty filecode.
|
|
||||||
let byseBaseline = null;
|
let byseBaseline = null;
|
||||||
if (hosterName === 'byse.sx') {
|
if (hosterName === 'byse.sx') {
|
||||||
const baseline = await _fetchByseFileList(apiKey, signal);
|
if (opts && opts.byseBaseline instanceof Set) {
|
||||||
byseBaseline = new Set(baseline.map(f => f.file_code));
|
byseBaseline = opts.byseBaseline;
|
||||||
|
} else {
|
||||||
|
const baseline = await _fetchByseFileList(apiKey, signal);
|
||||||
|
byseBaseline = new Set(baseline.map(f => f.file_code));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Doodstream: same snapshot so a codeless upload response can be recovered by
|
|
||||||
// matching a newly-appeared file in the account by name (see below).
|
|
||||||
let doodBaseline = null;
|
let doodBaseline = null;
|
||||||
if (hosterName === 'doodstream.com') {
|
if (hosterName === 'doodstream.com') {
|
||||||
const baseline = await _fetchDoodstreamFileList(apiKey, signal);
|
if (opts && opts.doodBaseline instanceof Set) {
|
||||||
doodBaseline = new Set(baseline.map(f => f.file_code));
|
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
|
||||||
@ -580,6 +583,17 @@ async function uploadFile(hosterName, filePath, apiKey, onProgress, signal, thro
|
|||||||
try {
|
try {
|
||||||
result = config.parseResult(payload);
|
result = config.parseResult(payload);
|
||||||
} catch (err) {
|
} 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;
|
parseErr = err;
|
||||||
}
|
}
|
||||||
if (result && (result.file_code || result.download_url || result.embed_url)) {
|
if (result && (result.file_code || result.download_url || result.embed_url)) {
|
||||||
@ -636,8 +650,23 @@ async function uploadFile(hosterName, filePath, apiKey, onProgress, signal, thro
|
|||||||
throw new Error(msg);
|
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,
|
||||||
|
|||||||
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);
|
||||||
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 };
|
||||||
@ -233,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,13 +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 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,
|
||||||
@ -41,6 +42,7 @@ class UploadManager extends EventEmitter {
|
|||||||
this._failedAccounts = new Map(); // hoster -> Set of failed accountIds
|
this._failedAccounts = new Map(); // hoster -> Set of failed accountIds
|
||||||
this._accountOverrides = new Map(); // hoster -> fallback account object
|
this._accountOverrides = new Map(); // hoster -> fallback account object
|
||||||
this._doodApiKeyCache = new Map(); // accountId/username -> derived doodstream API key ('' = tried, none)
|
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) {
|
switchAccount(hoster, fallbackAccount) {
|
||||||
@ -65,6 +67,20 @@ class UploadManager extends EventEmitter {
|
|||||||
return this._accountOverrides.get(hoster) || null;
|
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
|
// 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.
|
// 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
|
// Used by the retry loop to decide "retry on same account vs break to
|
||||||
@ -267,6 +283,7 @@ class UploadManager extends EventEmitter {
|
|||||||
this.jobAbortControllers.clear();
|
this.jobAbortControllers.clear();
|
||||||
this.cancelledJobIds.clear();
|
this.cancelledJobIds.clear();
|
||||||
this._doodApiKeyCache.clear(); // re-derive doodstream keys fresh each batch
|
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;
|
||||||
@ -297,18 +314,30 @@ class UploadManager extends EventEmitter {
|
|||||||
this._batchResults = results;
|
this._batchResults = results;
|
||||||
this._additionalPromises = []; // Track jobs added mid-batch via addJobs()
|
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()
|
// Wait for any jobs added mid-batch via addJobs()
|
||||||
while (this._additionalPromises.length > 0) {
|
while (this._additionalPromises.length > 0) {
|
||||||
@ -344,7 +373,12 @@ class UploadManager extends EventEmitter {
|
|||||||
const fileName = path.basename(task.file);
|
const fileName = path.basename(task.file);
|
||||||
let fileSize = 0;
|
let fileSize = 0;
|
||||||
let fileNotFound = false;
|
let fileNotFound = false;
|
||||||
try { fileSize = fs.statSync(task.file).size; } catch { fileNotFound = true; }
|
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();
|
||||||
@ -409,26 +443,30 @@ class UploadManager extends EventEmitter {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this._emitProgress(uploadId, fileName, task.hoster, { accountId: task.accountId,
|
// 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;
|
||||||
@ -520,14 +558,16 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -541,41 +581,42 @@ class UploadManager extends EventEmitter {
|
|||||||
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;
|
||||||
|
}
|
||||||
|
|
||||||
activeEntry.speedKbs = currentSpeedKbs;
|
activeEntry.speedKbs = currentSpeedKbs;
|
||||||
activeEntry.bytesUploaded = bytesUploaded;
|
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, { accountId: task.accountId,
|
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 */ }
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await this._executeUpload(task, progressCb, uploadSignalBundle.signal, throttle);
|
const result = await this._executeUpload(task, progressCb, uploadSignalBundle.signal, throttle);
|
||||||
@ -596,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;
|
||||||
@ -882,7 +940,9 @@ class UploadManager extends EventEmitter {
|
|||||||
const apiKey = await this._resolveDoodstreamApiKey(task);
|
const apiKey = await this._resolveDoodstreamApiKey(task);
|
||||||
if (apiKey) {
|
if (apiKey) {
|
||||||
this._rotLog('doodstream-via-api', { accountId: task.accountId, fileName: path.basename(task.file) });
|
this._rotLog('doodstream-via-api', { accountId: task.accountId, fileName: path.basename(task.file) });
|
||||||
return uploadFile('doodstream.com', task.file, apiKey, progressCb, signal, throttle);
|
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) });
|
this._rotLog('doodstream-via-web', { accountId: task.accountId, fileName: path.basename(task.file) });
|
||||||
const dood = new DoodstreamUploader();
|
const dood = new DoodstreamUploader();
|
||||||
@ -892,10 +952,23 @@ class UploadManager extends EventEmitter {
|
|||||||
const clouddrop = new ClouddropUploader(task.apiKey);
|
const clouddrop = new ClouddropUploader(task.apiKey);
|
||||||
return clouddrop.upload(task.file, progressCb, signal, throttle);
|
return clouddrop.upload(task.file, progressCb, signal, throttle);
|
||||||
} else {
|
} else {
|
||||||
return uploadFile(task.hoster, task.file, task.apiKey, progressCb, signal, throttle);
|
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
|
// 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.
|
// 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
|
// Returns the key string, or '' when none could be derived (cached either way
|
||||||
@ -925,7 +998,7 @@ class UploadManager extends EventEmitter {
|
|||||||
_startStatsTimer() {
|
_startStatsTimer() {
|
||||||
if (this.statsInterval) clearInterval(this.statsInterval);
|
if (this.statsInterval) clearInterval(this.statsInterval);
|
||||||
this.statsInterval = setInterval(() => {
|
this.statsInterval = setInterval(() => {
|
||||||
// Single pass over active jobs instead of two.
|
try {
|
||||||
let globalSpeedKbs = 0;
|
let globalSpeedKbs = 0;
|
||||||
let activeCount = 0;
|
let activeCount = 0;
|
||||||
let inProgressBytes = 0;
|
let inProgressBytes = 0;
|
||||||
@ -945,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "multi-hoster-uploader",
|
"name": "multi-hoster-uploader",
|
||||||
"version": "3.3.34",
|
"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
preload.js
12
preload.js
@ -39,6 +39,7 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
addJobsToBatch: (payload) => ipcRenderer.invoke('add-jobs-to-batch', payload),
|
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
|
// Log import
|
||||||
readOwnUploadLog: () => ipcRenderer.invoke('read-own-upload-log'),
|
readOwnUploadLog: () => ipcRenderer.invoke('read-own-upload-log'),
|
||||||
@ -92,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));
|
||||||
},
|
},
|
||||||
@ -109,6 +113,14 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
},
|
},
|
||||||
openLogFolder: () => ipcRenderer.invoke('open-log-folder'),
|
openLogFolder: () => ipcRenderer.invoke('open-log-folder'),
|
||||||
getJobLog: (jobId) => ipcRenderer.invoke('get-job-log', jobId),
|
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) => {
|
onLogPathAutoUpdated: (callback) => {
|
||||||
ipcRenderer.on('log-path-auto-updated', (_event, data) => callback(data));
|
ipcRenderer.on('log-path-auto-updated', (_event, data) => callback(data));
|
||||||
},
|
},
|
||||||
|
|||||||
894
renderer/app.js
894
renderer/app.js
File diff suppressed because it is too large
Load Diff
@ -93,6 +93,14 @@
|
|||||||
|
|
||||||
<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>
|
<button class="btn btn-xs btn-secondary" id="importLogBtn" title="Log importieren — bereits hochgeladene aus Queue entfernen">Log importieren</button>
|
||||||
</div>
|
</div>
|
||||||
@ -187,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>
|
||||||
@ -330,8 +342,26 @@
|
|||||||
</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-prune.js"></script>
|
||||||
<script src="../lib/queue-dedup.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/throttled-cache.js"></script>
|
||||||
<script src="../lib/coalesced-set.js"></script>
|
<script src="../lib/coalesced-set.js"></script>
|
||||||
<script src="app.js"></script>
|
<script src="app.js"></script>
|
||||||
|
|||||||
@ -727,6 +727,7 @@ body.col-resizing, body.col-resizing * { cursor: col-resize !important; user-sel
|
|||||||
}
|
}
|
||||||
.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;
|
||||||
@ -875,17 +876,96 @@ body.col-resizing, body.col-resizing * { cursor: col-resize !important; user-sel
|
|||||||
|
|
||||||
.account-hoster-group {
|
.account-hoster-group {
|
||||||
margin-bottom: 12px;
|
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 {
|
.account-hoster-group-title {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-muted);
|
color: var(--text);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
margin-bottom: 6px;
|
flex: 1;
|
||||||
padding-left: 4px;
|
}
|
||||||
|
.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 { 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;
|
||||||
|
|||||||
@ -54,3 +54,11 @@
|
|||||||
**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.
|
**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.
|
**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.
|
**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.
|
||||||
|
|||||||
@ -56,6 +56,28 @@ describe('ConfigStore', () => {
|
|||||||
assert.equal(config.hosters['doodstream.com'][0].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 in old single-object format (triggers migration)
|
// Write partial config in old single-object format (triggers migration)
|
||||||
fs.writeFileSync(store.filePath, JSON.stringify({
|
fs.writeFileSync(store.filePath, JSON.stringify({
|
||||||
|
|||||||
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);
|
||||||
|
});
|
||||||
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');
|
||||||
|
});
|
||||||
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/);
|
||||||
|
});
|
||||||
@ -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 () => {
|
||||||
|
|||||||
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