Compare commits
No commits in common. "master" and "v3.3.36" have entirely different histories.
12
.gitignore
vendored
12
.gitignore
vendored
@ -2,15 +2,3 @@ node_modules/
|
||||
release/
|
||||
__pycache__/
|
||||
*.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
|
||||
|
||||
428
electron-config.json
Normal file
428
electron-config.json
Normal file
@ -0,0 +1,428 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -58,7 +58,6 @@ const DEFAULTS = {
|
||||
shutdownAfterFinish: 'nothing', // nothing | sleep | shutdown | restart
|
||||
logFilePath: '',
|
||||
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
|
||||
|
||||
@ -1,73 +0,0 @@
|
||||
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,27 +499,24 @@ async function _resolveDoodstreamUploadByName(apiKey, fileName, baselineCodes, s
|
||||
return null;
|
||||
}
|
||||
|
||||
async function uploadFile(hosterName, filePath, apiKey, onProgress, signal, throttle, opts) {
|
||||
async function uploadFile(hosterName, filePath, apiKey, onProgress, signal, throttle) {
|
||||
const config = HOSTER_CONFIGS[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;
|
||||
if (hosterName === 'byse.sx') {
|
||||
if (opts && opts.byseBaseline instanceof Set) {
|
||||
byseBaseline = opts.byseBaseline;
|
||||
} else {
|
||||
const baseline = await _fetchByseFileList(apiKey, signal);
|
||||
byseBaseline = new Set(baseline.map(f => f.file_code));
|
||||
}
|
||||
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;
|
||||
if (hosterName === 'doodstream.com') {
|
||||
if (opts && opts.doodBaseline instanceof Set) {
|
||||
doodBaseline = opts.doodBaseline;
|
||||
} else {
|
||||
const baseline = await _fetchDoodstreamFileList(apiKey, signal);
|
||||
doodBaseline = new Set(baseline.map(f => f.file_code));
|
||||
}
|
||||
const baseline = await _fetchDoodstreamFileList(apiKey, signal);
|
||||
doodBaseline = new Set(baseline.map(f => f.file_code));
|
||||
}
|
||||
|
||||
// Step 1: Get upload server
|
||||
@ -583,17 +580,6 @@ async function uploadFile(hosterName, filePath, apiKey, onProgress, signal, thro
|
||||
try {
|
||||
result = config.parseResult(payload);
|
||||
} catch (err) {
|
||||
if (err && typeof err === 'object' && !err.diagnostic) {
|
||||
try {
|
||||
err.diagnostic = {
|
||||
hoster: hosterName,
|
||||
http: statusCode,
|
||||
contentType: (headers && headers['content-type']) || null,
|
||||
payloadSnippet: JSON.stringify(payload).slice(0, 1000),
|
||||
uploadUrl: targetUrl
|
||||
};
|
||||
} catch { /* JSON cycle — skip diagnostic */ }
|
||||
}
|
||||
parseErr = err;
|
||||
}
|
||||
if (result && (result.file_code || result.download_url || result.embed_url)) {
|
||||
@ -650,23 +636,8 @@ async function uploadFile(hosterName, filePath, apiKey, onProgress, signal, thro
|
||||
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 = {
|
||||
uploadFile,
|
||||
prefetchBaseline,
|
||||
HOSTER_CONFIGS,
|
||||
__test: {
|
||||
extractUploadServerUrl,
|
||||
|
||||
@ -75,29 +75,7 @@
|
||||
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 };
|
||||
const api = { normalizeLogMode, resolveLogFileName, formatDateStamp, formatSessionStamp, VALID_MODES };
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = api;
|
||||
|
||||
142
lib/stats.js
142
lib/stats.js
@ -1,142 +0,0 @@
|
||||
(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);
|
||||
@ -1,64 +0,0 @@
|
||||
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 };
|
||||
@ -2,14 +2,13 @@ const { EventEmitter } = require('events');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const crypto = require('crypto');
|
||||
const { uploadFile, prefetchBaseline } = require('./hosters');
|
||||
const { uploadFile } = require('./hosters');
|
||||
const VidmolyUploader = require('./vidmoly-upload');
|
||||
const VoeUploader = require('./voe-upload');
|
||||
const DoodstreamUploader = require('./doodstream-upload');
|
||||
const ClouddropUploader = require('./clouddrop-upload');
|
||||
const Semaphore = require('./semaphore');
|
||||
const Throttle = require('./throttle');
|
||||
const { probeFileHead } = require('./file-probe');
|
||||
|
||||
const DEFAULT_SETTINGS = {
|
||||
retries: 3,
|
||||
@ -42,7 +41,6 @@ class UploadManager extends EventEmitter {
|
||||
this._failedAccounts = new Map(); // hoster -> Set of failed accountIds
|
||||
this._accountOverrides = new Map(); // hoster -> fallback account object
|
||||
this._doodApiKeyCache = new Map(); // accountId/username -> derived doodstream API key ('' = tried, none)
|
||||
this._baselineCache = new Map(); // hoster:apiKey -> Promise<Set<file_code>> (one fetch shared across all jobs in batch)
|
||||
}
|
||||
|
||||
switchAccount(hoster, fallbackAccount) {
|
||||
@ -67,16 +65,6 @@ class UploadManager extends EventEmitter {
|
||||
return this._accountOverrides.get(hoster) || null;
|
||||
}
|
||||
|
||||
clearFailedAccount(hoster, accountId) {
|
||||
return this._failedAccounts.delete(`${hoster}:${accountId}`);
|
||||
}
|
||||
|
||||
clearAllFailedAccounts() {
|
||||
const n = this._failedAccounts.size;
|
||||
this._failedAccounts.clear();
|
||||
return n;
|
||||
}
|
||||
|
||||
// True if the hoster has a usable override stored that differs from the
|
||||
// account currently in the task and isn't itself already marked failed.
|
||||
// Used by the retry loop to decide "retry on same account vs break to
|
||||
@ -279,7 +267,6 @@ class UploadManager extends EventEmitter {
|
||||
this.jobAbortControllers.clear();
|
||||
this.cancelledJobIds.clear();
|
||||
this._doodApiKeyCache.clear(); // re-derive doodstream keys fresh each batch
|
||||
this._baselineCache.clear(); // re-fetch baselines per batch (a long batch could outlast remote-side relevance)
|
||||
this.semaphores = {};
|
||||
this.globalSemaphore = null;
|
||||
this.globalThrottle = null;
|
||||
@ -310,30 +297,18 @@ class UploadManager extends EventEmitter {
|
||||
this._batchResults = results;
|
||||
this._additionalPromises = []; // Track jobs added mid-batch via addJobs()
|
||||
|
||||
const DEDUP_CHUNK = 200;
|
||||
for (let i = 0; i < tasks.length; i += DEDUP_CHUNK) {
|
||||
const end = Math.min(i + DEDUP_CHUNK, tasks.length);
|
||||
for (let j = i; j < end; j++) {
|
||||
const task = tasks[j];
|
||||
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: [] });
|
||||
}
|
||||
for (const task of tasks) {
|
||||
const fileName = path.basename(task.file);
|
||||
if (!results.has(task.file)) {
|
||||
let size = 0;
|
||||
try { size = fs.statSync(task.file).size; } catch {}
|
||||
results.set(task.file, { name: fileName, size, results: [] });
|
||||
}
|
||||
if (end < tasks.length) await new Promise(setImmediate);
|
||||
}
|
||||
|
||||
this._startStatsTimer();
|
||||
|
||||
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);
|
||||
}
|
||||
const promises = tasks.map((task) => this._runJob(task, results, signal));
|
||||
await Promise.allSettled(promises);
|
||||
// Wait for any jobs added mid-batch via addJobs()
|
||||
while (this._additionalPromises.length > 0) {
|
||||
@ -369,12 +344,7 @@ class UploadManager extends EventEmitter {
|
||||
const fileName = path.basename(task.file);
|
||||
let fileSize = 0;
|
||||
let fileNotFound = false;
|
||||
const cachedResult = results && results.get(task.file);
|
||||
if (cachedResult && typeof cachedResult.size === 'number' && cachedResult.size > 0) {
|
||||
fileSize = cachedResult.size;
|
||||
} else {
|
||||
try { fileSize = fs.statSync(task.file).size; } catch { fileNotFound = true; }
|
||||
}
|
||||
try { fileSize = fs.statSync(task.file).size; } catch { fileNotFound = true; }
|
||||
|
||||
const maxAttempts = Math.max(1, (settings.retries || 0) + 1);
|
||||
const jobAbortController = new AbortController();
|
||||
@ -439,30 +409,26 @@ class UploadManager extends EventEmitter {
|
||||
return;
|
||||
}
|
||||
|
||||
// The initial 'queued' emit per job is suppressed: with N=2000+ tasks
|
||||
// it produces 2000+ main→renderer IPCs back-to-back at startBatch and
|
||||
// freezes the renderer event loop for tens of seconds. The renderer
|
||||
// already holds each job in 'queued'/'preview' state from its own
|
||||
// queueJobs array; the first event it actually needs from main is the
|
||||
// 'getting-server' / 'uploading' transition for the jobs that the
|
||||
// semaphore lets through.
|
||||
this._emitProgress(uploadId, fileName, task.hoster, { accountId: task.accountId,
|
||||
jobId,
|
||||
status: 'queued',
|
||||
progress: 0,
|
||||
bytesUploaded: 0,
|
||||
bytesTotal: fileSize,
|
||||
speedKbs: 0,
|
||||
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);
|
||||
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) {
|
||||
await globalSemaphore.acquire(signal);
|
||||
globalSlotAcquired = true;
|
||||
@ -630,23 +596,6 @@ class UploadManager extends EventEmitter {
|
||||
this.activeJobs.delete(uploadId);
|
||||
|
||||
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) {
|
||||
lastError = new Error('Abgebrochen');
|
||||
break;
|
||||
@ -933,9 +882,7 @@ class UploadManager extends EventEmitter {
|
||||
const apiKey = await this._resolveDoodstreamApiKey(task);
|
||||
if (apiKey) {
|
||||
this._rotLog('doodstream-via-api', { accountId: task.accountId, fileName: path.basename(task.file) });
|
||||
return uploadFile('doodstream.com', task.file, apiKey, progressCb, signal, throttle, {
|
||||
doodBaseline: await this._getBaseline('doodstream.com', apiKey, signal)
|
||||
});
|
||||
return uploadFile('doodstream.com', task.file, apiKey, progressCb, signal, throttle);
|
||||
}
|
||||
this._rotLog('doodstream-via-web', { accountId: task.accountId, fileName: path.basename(task.file) });
|
||||
const dood = new DoodstreamUploader();
|
||||
@ -945,23 +892,10 @@ class UploadManager extends EventEmitter {
|
||||
const clouddrop = new ClouddropUploader(task.apiKey);
|
||||
return clouddrop.upload(task.file, progressCb, signal, throttle);
|
||||
} else {
|
||||
const baselineOpts = {};
|
||||
if (task.hoster === 'byse.sx') baselineOpts.byseBaseline = await this._getBaseline('byse.sx', task.apiKey, signal);
|
||||
if (task.hoster === 'doodstream.com') baselineOpts.doodBaseline = await this._getBaseline('doodstream.com', task.apiKey, signal);
|
||||
return uploadFile(task.hoster, task.file, task.apiKey, progressCb, signal, throttle, baselineOpts);
|
||||
return uploadFile(task.hoster, task.file, task.apiKey, progressCb, signal, throttle);
|
||||
}
|
||||
}
|
||||
|
||||
_getBaseline(hosterName, apiKey, signal) {
|
||||
if (!apiKey) return Promise.resolve(null);
|
||||
const key = `${hosterName}:${apiKey}`;
|
||||
let pending = this._baselineCache.get(key);
|
||||
if (pending) return pending;
|
||||
pending = prefetchBaseline(hosterName, apiKey, signal);
|
||||
this._baselineCache.set(key, pending);
|
||||
return pending;
|
||||
}
|
||||
|
||||
// Resolve (and cache per batch) the doodstream API key for a login-only
|
||||
// account by logging in once and scraping+validating it from the session.
|
||||
// Returns the key string, or '' when none could be derived (cached either way
|
||||
|
||||
478
main.js
478
main.js
@ -16,7 +16,6 @@ const FolderMonitor = require('./lib/folder-monitor');
|
||||
const RemoteServer = require('./lib/remote-server');
|
||||
const { maybeRotateLogFile } = require('./lib/log-rotation');
|
||||
const { hosterLogToFileEnabled } = require('./lib/log-policy');
|
||||
const { sanitizeConfig, buildSupportBundleText } = require('./lib/support-bundle');
|
||||
|
||||
let mainWindow;
|
||||
let _lastImportPath = null;
|
||||
@ -116,49 +115,6 @@ function debugLog(msg) {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
let _logVerbose = false;
|
||||
function setLogVerbose(v) { _logVerbose = !!v; }
|
||||
function _ctxTag(ctx) {
|
||||
if (!ctx || typeof ctx !== 'object') return '';
|
||||
const tags = [];
|
||||
if (ctx.batch) tags.push(`b:${String(ctx.batch).slice(0, 8)}`);
|
||||
if (ctx.job) tags.push(`j:${String(ctx.job).slice(-8)}`);
|
||||
if (ctx.hoster) tags.push(ctx.hoster);
|
||||
if (ctx.attempt !== undefined && ctx.attempt !== null) tags.push(`a:${ctx.attempt}`);
|
||||
return tags.length ? `[${tags.join(' ')}] ` : '';
|
||||
}
|
||||
function _split(a, b) {
|
||||
if (typeof a === 'string') return { ctx: null, msg: a, extra: b };
|
||||
return { ctx: a, msg: b, extra: arguments[2] };
|
||||
}
|
||||
function logDebug(a, b) {
|
||||
if (!_logVerbose) return;
|
||||
const s = _split(a, b);
|
||||
debugLog(`[DEBUG] ${_ctxTag(s.ctx)}${s.msg}`);
|
||||
}
|
||||
function logInfo(a, b) {
|
||||
const s = _split(a, b);
|
||||
debugLog(`[INFO ] ${_ctxTag(s.ctx)}${s.msg}`);
|
||||
}
|
||||
function logWarn(a, b) {
|
||||
const s = _split(a, b);
|
||||
debugLog(`[WARN ] ${_ctxTag(s.ctx)}${s.msg}`);
|
||||
}
|
||||
function logError(a, b, c) {
|
||||
let ctx, msg, err;
|
||||
if (typeof a === 'string') { ctx = null; msg = a; err = b; }
|
||||
else { ctx = a; msg = b; err = c; }
|
||||
const errStr = err ? ` :: ${err.stack || err.message || err}` : '';
|
||||
debugLog(`[ERROR] ${_ctxTag(ctx)}${msg}${errStr}`);
|
||||
}
|
||||
function logMarker(label, fields) {
|
||||
let extra = '';
|
||||
if (fields && typeof fields === 'object') {
|
||||
extra = ' ' + Object.entries(fields).map(([k, v]) => `${k}=${v}`).join(' ');
|
||||
}
|
||||
debugLog(`────── ${label}${extra} ──────`);
|
||||
}
|
||||
|
||||
// Dedicated account-rotation log so users can trace fallback decisions
|
||||
// without wading through general debug output. Writes to account-rotation.log
|
||||
// in the same directory as fileuploader.log (honors user's configured path).
|
||||
@ -198,28 +154,27 @@ function _flushRotLog() {
|
||||
write(0);
|
||||
}
|
||||
|
||||
function getAllLogPaths() {
|
||||
const upload = getLogFilePath();
|
||||
const debugPath = getDebugLogPath();
|
||||
const rot = getRotLogPath();
|
||||
const dir = path.dirname(debugPath);
|
||||
return {
|
||||
fileuploader: upload,
|
||||
debug: debugPath,
|
||||
accountRotation: rot,
|
||||
doodstreamDebug: path.join(dir, 'doodstream-debug.log'),
|
||||
logDir: dir
|
||||
};
|
||||
}
|
||||
|
||||
function rotLog(msg, ts) {
|
||||
try {
|
||||
const iso = new Date(ts || Date.now()).toISOString();
|
||||
const line = `[${iso}] ${msg}\n`;
|
||||
_rotLogBuffer.push(line);
|
||||
if (!_rotLogFlushTimer) {
|
||||
_rotLogFlushTimer = setTimeout(() => { _rotLogFlushTimer = null; _flushRotLog(); }, 500);
|
||||
// Write synchronously. Rotation events are rare (a handful per batch) so
|
||||
// the batching optimization from debugLog doesn't buy us anything, and
|
||||
// syncing guarantees the user can refresh the file and see fresh entries
|
||||
// without waiting on a flush timer.
|
||||
const candidates = [
|
||||
getRotLogPath(),
|
||||
path.join(app.getPath('desktop') || app.getPath('userData'), 'account-rotation.log'),
|
||||
path.join(app.getPath('userData'), 'account-rotation.log')
|
||||
];
|
||||
for (const target of candidates) {
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
fs.appendFileSync(target, line, 'utf-8');
|
||||
break;
|
||||
} catch {}
|
||||
}
|
||||
// Mirror into the main debug log for single-file-grep convenience.
|
||||
_debugLogBuffer.push(`[${iso}] [ROT] ${msg}\n`);
|
||||
if (!_debugLogFlushTimer) {
|
||||
_debugLogFlushTimer = setTimeout(() => { _debugLogFlushTimer = null; _flushDebugLog(); }, 500);
|
||||
@ -290,7 +245,7 @@ function getBaseLogFilePath() {
|
||||
// given session lands in the same file. A close→reopen of the app starts a new
|
||||
// main process, so a new SESSION_ID, so a new session file. PID is appended as
|
||||
// a cheap hedge against same-second restart collisions.
|
||||
const { resolveLogFileName, formatSessionStamp, formatDateStamp, stripModeStampFromFileName } = require('./lib/log-mode');
|
||||
const { resolveLogFileName, formatSessionStamp, formatDateStamp } = require('./lib/log-mode');
|
||||
const SESSION_ID = formatSessionStamp(new Date(), process.pid);
|
||||
let _activeLogKey = null; // remembers (mode + date-or-session) so cache rolls correctly
|
||||
let _activeLogPath = null;
|
||||
@ -443,18 +398,16 @@ function _persistFallbackLogPath(workingPath) {
|
||||
try {
|
||||
const cfg = configStore.load();
|
||||
const gs = cfg.globalSettings || {};
|
||||
const mode = gs.logMode || 'single';
|
||||
// Strip the mode-specific suffix so logFilePath stores the BARE base path.
|
||||
// Otherwise daily would compound into "...-2026-06-03-2026-06-04.log" and
|
||||
// session would compound a second session-stamp onto the first — which split
|
||||
// a session's lines across two files (the first few before _persistFallback
|
||||
// ran, the rest after, into the doubly-stamped path). gated on logMode (the
|
||||
// legacy `sessionLog` field is no longer the source of truth).
|
||||
// If daily-log is on, workingPath has a date suffix (fileuploader-YYYY-MM-DD.log).
|
||||
// Strip that before saving so the base path rolls forward to tomorrow's
|
||||
// file correctly — otherwise the next day's getLogFilePath would append
|
||||
// another date onto the already-dated base.
|
||||
let toSave = workingPath;
|
||||
if (mode === 'daily' || mode === 'session') {
|
||||
if (gs.sessionLog) {
|
||||
const dir = path.dirname(workingPath);
|
||||
const base = path.basename(workingPath);
|
||||
toSave = path.join(dir, stripModeStampFromFileName(base));
|
||||
const stripped = base.replace(/-\d{4}-\d{2}-\d{2}(\.[^.]+)$/, '$1');
|
||||
toSave = path.join(dir, stripped);
|
||||
}
|
||||
if (gs.logFilePath === toSave) return;
|
||||
gs.logFilePath = toSave;
|
||||
@ -881,52 +834,35 @@ async function runHosterHealthCheck(config, requestedChecks) {
|
||||
checks = requestedChecks;
|
||||
}
|
||||
|
||||
{
|
||||
const seen = new Set();
|
||||
const cleaned = [];
|
||||
for (const c of checks) {
|
||||
if (!c || !c.hoster) continue;
|
||||
if (!c.accountId) { cleaned.push({ ...c, _invalid: true }); continue; }
|
||||
const key = `${c.hoster}|${c.accountId}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
cleaned.push(c);
|
||||
}
|
||||
checks = cleaned;
|
||||
}
|
||||
|
||||
const runOne = async ({ hoster, accountId, otp, _invalid }) => {
|
||||
if (_invalid) {
|
||||
return { hoster, accountId, status: 'error', message: 'Account-ID fehlt im Check-Payload' };
|
||||
}
|
||||
const results = await Promise.all(checks.map(async ({ hoster, accountId, otp }) => {
|
||||
if (!allowed.includes(hoster)) {
|
||||
return { hoster, accountId, status: 'skipped', message: 'Kein Health-Check fuer diesen Hoster' };
|
||||
}
|
||||
|
||||
// Find specific account
|
||||
const accounts = config.hosters[hoster];
|
||||
const hosterConfig = Array.isArray(accounts) ? accounts.find(a => a.id === accountId) : null;
|
||||
|
||||
try {
|
||||
const result = await _dispatchHealthCheck(hoster, hosterConfig, otp || '');
|
||||
let result;
|
||||
if (hoster === 'doodstream.com') {
|
||||
result = await withTimeout(checkDoodstreamHealth(hosterConfig, otp), HEALTH_CHECK_TIMEOUT, 'Doodstream-Check');
|
||||
} else if (hoster === 'vidmoly.me') {
|
||||
result = await withTimeout(checkVidmolyHealth(hosterConfig), HEALTH_CHECK_TIMEOUT, 'Vidmoly-Check');
|
||||
} else if (hoster === 'voe.sx') {
|
||||
result = await withTimeout(checkVoeHealth(hosterConfig), HEALTH_CHECK_TIMEOUT, 'VOE-Check');
|
||||
} else if (hoster === 'byse.sx') {
|
||||
result = await withTimeout(checkByseHealth(hosterConfig), HEALTH_CHECK_TIMEOUT, 'Byse-Check');
|
||||
} else if (hoster === 'clouddrop.cc') {
|
||||
result = await withTimeout(checkClouddropHealth(hosterConfig), HEALTH_CHECK_TIMEOUT, 'Clouddrop-Check');
|
||||
} else {
|
||||
return { hoster, accountId, status: 'skipped', message: 'Kein Health-Check fuer diesen Hoster' };
|
||||
}
|
||||
return { hoster, accountId, ...result };
|
||||
} catch (err) {
|
||||
return { hoster, accountId, status: 'error', message: err && err.message ? err.message : 'Health-Check fehlgeschlagen' };
|
||||
}
|
||||
};
|
||||
|
||||
const groups = new Map();
|
||||
for (const c of checks) {
|
||||
if (!groups.has(c.hoster)) groups.set(c.hoster, []);
|
||||
groups.get(c.hoster).push(c);
|
||||
}
|
||||
const groupResults = await Promise.all(Array.from(groups.values()).map(async (group) => {
|
||||
const out = [];
|
||||
for (const c of group) {
|
||||
out.push(await runOne(c));
|
||||
}
|
||||
return out;
|
||||
}));
|
||||
const indexByCheck = new Map();
|
||||
groupResults.flat().forEach((r) => { indexByCheck.set(`${r.hoster}|${r.accountId || ''}`, r); });
|
||||
const results = checks.map(c => indexByCheck.get(`${c.hoster}|${c.accountId || ''}`));
|
||||
|
||||
return { checkedAt: new Date().toISOString(), results };
|
||||
}
|
||||
@ -972,19 +908,6 @@ function updateTrayTooltip(text) {
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
try {
|
||||
const _bootCfg = configStore.load();
|
||||
setLogVerbose(!!(_bootCfg.globalSettings && _bootCfg.globalSettings.logVerbose));
|
||||
} catch {}
|
||||
logMarker('APP START', {
|
||||
version: app.getVersion(),
|
||||
electron: process.versions.electron,
|
||||
node: process.versions.node,
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
verbose: _logVerbose,
|
||||
pid: process.pid
|
||||
});
|
||||
createWindow();
|
||||
createTray();
|
||||
|
||||
@ -1001,7 +924,7 @@ app.whenReady().then(() => {
|
||||
if (fs.existsSync(fm.folderPath)) {
|
||||
startFolderMonitor(fm);
|
||||
} else {
|
||||
logWarn(`folder-monitor auto-start skipped: path not found (${fm.folderPath})`);
|
||||
debugLog(`folder-monitor auto-start skipped: path not found (${fm.folderPath})`);
|
||||
// Persist the disable so the user gets a clean state on next launch
|
||||
const gs = { ...launchConfig.globalSettings, folderMonitor: { ...fm, enabled: false } };
|
||||
configStore.save({ globalSettings: gs }).catch(() => {});
|
||||
@ -1035,15 +958,14 @@ app.whenReady().then(() => {
|
||||
// Auto-check for updates after 3 seconds
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
logInfo('update-check: starting');
|
||||
debugLog('update-check: starting');
|
||||
const result = await checkForUpdate();
|
||||
logInfo(`update-check: available=${result && result.available}, remote=${result && result.remoteVersion}`);
|
||||
logDebug(`update-check result: ${JSON.stringify(result)}`);
|
||||
debugLog(`update-check: available=${result && result.available}, remote=${result && result.remoteVersion}`);
|
||||
if (result && result.available && mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('app:update-available', result);
|
||||
}
|
||||
} catch (err) {
|
||||
logError('update-check failed', err);
|
||||
debugLog(`update-check failed: ${err && err.message || err}`);
|
||||
}
|
||||
}, 3000);
|
||||
});
|
||||
@ -1096,11 +1018,6 @@ ipcMain.handle('get-config', () => {
|
||||
|
||||
ipcMain.handle('save-config', async (_event, config) => {
|
||||
await configStore.save(config);
|
||||
try {
|
||||
if (config && config.globalSettings && Object.prototype.hasOwnProperty.call(config.globalSettings, 'logVerbose')) {
|
||||
setLogVerbose(!!config.globalSettings.logVerbose);
|
||||
}
|
||||
} catch {}
|
||||
// If a batch is running and some accounts got marked failed before any
|
||||
// fallback existed, re-resolve now — the user may have just added one.
|
||||
// Without this re-probe, those accounts stay stuck with no override until
|
||||
@ -1196,52 +1113,6 @@ ipcMain.handle('run-health-check', async (_event, payload) => {
|
||||
return runHosterHealthCheck(config, hosters);
|
||||
});
|
||||
|
||||
// Validate ephemeral credentials WITHOUT persisting them to config.hosters.
|
||||
// This is the IPC that backs the two-step "Prüfen → Anlegen" modal flow: the
|
||||
// new account is never on disk until the user confirms after a green check, so
|
||||
// failed/OTP-pending creds can't leak into config (and a double-click on the
|
||||
// Prüfen button cannot create duplicates because nothing is written until the
|
||||
// second, distinct "Anlegen" click). NOTE: this payload carries plaintext creds
|
||||
// across the IPC boundary — same trust level as save-config — DO NOT log it.
|
||||
ipcMain.handle('validate-credentials', async (_event, payload) => {
|
||||
if (!payload || !payload.hoster) {
|
||||
return { status: 'error', message: 'Hoster fehlt' };
|
||||
}
|
||||
const ephemeralHosterConfig = {
|
||||
username: payload.username || '',
|
||||
password: payload.password || '',
|
||||
apiKey: payload.apiKey || '',
|
||||
enabled: true
|
||||
};
|
||||
try {
|
||||
return await _dispatchHealthCheck(payload.hoster, ephemeralHosterConfig, payload.otp || '');
|
||||
} catch (err) {
|
||||
return { status: 'error', message: err && err.message ? err.message : 'Validierung fehlgeschlagen' };
|
||||
}
|
||||
});
|
||||
|
||||
async function _dispatchHealthCheck(hoster, hosterConfig, otp) {
|
||||
// Mirrors the per-hoster switch in runHosterHealthCheck so both code paths
|
||||
// (batch check by accountId and ephemeral validate) go through identical
|
||||
// checkers + timeout wrappers and surface identical result shapes.
|
||||
if (hoster === 'doodstream.com') {
|
||||
return withTimeout(checkDoodstreamHealth(hosterConfig, otp), HEALTH_CHECK_TIMEOUT, 'Doodstream-Check');
|
||||
}
|
||||
if (hoster === 'vidmoly.me') {
|
||||
return withTimeout(checkVidmolyHealth(hosterConfig), HEALTH_CHECK_TIMEOUT, 'Vidmoly-Check');
|
||||
}
|
||||
if (hoster === 'voe.sx') {
|
||||
return withTimeout(checkVoeHealth(hosterConfig), HEALTH_CHECK_TIMEOUT, 'VOE-Check');
|
||||
}
|
||||
if (hoster === 'byse.sx') {
|
||||
return withTimeout(checkByseHealth(hosterConfig), HEALTH_CHECK_TIMEOUT, 'Byse-Check');
|
||||
}
|
||||
if (hoster === 'clouddrop.cc') {
|
||||
return withTimeout(checkClouddropHealth(hosterConfig), HEALTH_CHECK_TIMEOUT, 'Clouddrop-Check');
|
||||
}
|
||||
return { status: 'skipped', message: 'Kein Health-Check fuer diesen Hoster' };
|
||||
}
|
||||
|
||||
ipcMain.handle('select-files', async () => {
|
||||
const result = await dialog.showOpenDialog(mainWindow, {
|
||||
properties: ['openFile', 'multiSelections'],
|
||||
@ -1284,32 +1155,33 @@ ipcMain.handle('select-folder', async () => {
|
||||
});
|
||||
if (result.canceled || !result.filePaths.length) return null;
|
||||
|
||||
// Recursively collect all files from selected folders
|
||||
const files = [];
|
||||
for (const folder of result.filePaths) await walkFolderAsync(folder, files);
|
||||
const walk = (dir) => {
|
||||
try {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) walk(full);
|
||||
else if (entry.isFile()) files.push(full);
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
for (const folder of result.filePaths) walk(folder);
|
||||
return files.length > 0 ? files : null;
|
||||
});
|
||||
|
||||
async function walkFolderAsync(rootDir, outFiles) {
|
||||
const fsp = fs.promises;
|
||||
const stack = [rootDir];
|
||||
let scanned = 0;
|
||||
while (stack.length > 0) {
|
||||
const dir = stack.pop();
|
||||
let entries;
|
||||
try { entries = await fsp.readdir(dir, { withFileTypes: true }); }
|
||||
catch { continue; }
|
||||
for (const entry of entries) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) stack.push(full);
|
||||
else if (entry.isFile()) outFiles.push(full);
|
||||
}
|
||||
if ((++scanned % 8) === 0) await new Promise(setImmediate);
|
||||
}
|
||||
}
|
||||
|
||||
ipcMain.handle('resolve-folder-files', async (_event, folderPath) => {
|
||||
const files = [];
|
||||
await walkFolderAsync(folderPath, files);
|
||||
const walk = (dir) => {
|
||||
try {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) walk(full);
|
||||
else if (entry.isFile()) files.push(full);
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
walk(folderPath);
|
||||
return files;
|
||||
});
|
||||
|
||||
@ -1321,7 +1193,6 @@ ipcMain.handle('start-upload', (_event, payload) => {
|
||||
|
||||
// At 500+ jobs JSON.stringify blew up the debug log with MB-sized lines
|
||||
// per start-upload and added noticeable delay — log counts only.
|
||||
logMarker('BATCH START', { files: files.length, hosters: hosters.length, jobs: jobs.length });
|
||||
debugLog(`start-upload: files=${files.length}, hosters=${hosters.length}, jobs=${jobs.length}`);
|
||||
|
||||
const tasks = jobs.length > 0
|
||||
@ -1369,27 +1240,8 @@ ipcMain.handle('start-upload', (_event, payload) => {
|
||||
// Pass hoster settings to the upload manager
|
||||
uploadManager = new UploadManager(config.hosterSettings || {}, config.globalSettings || {});
|
||||
|
||||
const _progressByJob = new Map();
|
||||
const _progressTerminalQueue = [];
|
||||
let _progressFlushTimer = null;
|
||||
const PROGRESS_BATCH_INTERVAL_MS = 100;
|
||||
function _scheduleProgressFlush() {
|
||||
if (_progressFlushTimer) return;
|
||||
_progressFlushTimer = setTimeout(() => {
|
||||
_progressFlushTimer = null;
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
_progressByJob.clear();
|
||||
_progressTerminalQueue.length = 0;
|
||||
return;
|
||||
}
|
||||
const batch = _progressTerminalQueue.splice(0);
|
||||
for (const v of _progressByJob.values()) batch.push(v);
|
||||
_progressByJob.clear();
|
||||
if (batch.length) mainWindow.webContents.send('upload-progress-batch', batch);
|
||||
}, PROGRESS_BATCH_INTERVAL_MS);
|
||||
}
|
||||
|
||||
uploadManager.on('progress', (data) => {
|
||||
// Only log state changes, not continuous progress updates
|
||||
if (data.status !== 'uploading') {
|
||||
debugLog(`progress: ${data.fileName} ${data.hoster} ${data.status} ${data.error || ''}`);
|
||||
_appendJobLog(data.jobId, {
|
||||
@ -1398,6 +1250,10 @@ ipcMain.handle('start-upload', (_event, payload) => {
|
||||
error: data.error || null, attempt: data.attempt || 0, maxAttempts: data.maxAttempts || 0
|
||||
});
|
||||
}
|
||||
// Write to fileuploader.log immediately when a single upload finishes —
|
||||
// unless the user disabled logging for this hoster (per-hoster toggle).
|
||||
// Read from the live uploadManager.hosterSettings so a mid-batch toggle
|
||||
// (which calls updateSettings) takes effect immediately.
|
||||
if (data.status === 'done' && data.result) {
|
||||
const link = data.result.download_url || data.result.embed_url || data.result.file_code || '';
|
||||
if (link) {
|
||||
@ -1410,16 +1266,9 @@ ipcMain.handle('start-upload', (_event, payload) => {
|
||||
debugLog(`WARNING: done but no link for ${data.fileName} @ ${data.hoster}: ${JSON.stringify(data.result)}`);
|
||||
}
|
||||
}
|
||||
const isTerminal = data.status === 'done' || data.status === 'error' || data.status === 'aborted' || data.status === 'skipped';
|
||||
if (isTerminal) {
|
||||
if (data.jobId) _progressByJob.delete(data.jobId);
|
||||
_progressTerminalQueue.push(data);
|
||||
} else if (data.jobId) {
|
||||
_progressByJob.set(data.jobId, data);
|
||||
} else {
|
||||
_progressTerminalQueue.push(data);
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('upload-progress', data);
|
||||
}
|
||||
_scheduleProgressFlush();
|
||||
});
|
||||
|
||||
uploadManager.on('stats', (data) => {
|
||||
@ -1453,15 +1302,6 @@ ipcMain.handle('start-upload', (_event, payload) => {
|
||||
}
|
||||
});
|
||||
|
||||
const ROT_LOG_RENDERER_EVENTS = new Set([
|
||||
'switchAccount',
|
||||
'pre-job-swap',
|
||||
'try-alternate-after-fail',
|
||||
'mark-failed',
|
||||
'rotation-end',
|
||||
'doodstream-via-api',
|
||||
'doodstream-via-web'
|
||||
]);
|
||||
uploadManager.on('rot-log', (entry) => {
|
||||
const { ts, event, ...rest } = entry;
|
||||
const pairs = Object.entries(rest)
|
||||
@ -1471,7 +1311,7 @@ ipcMain.handle('start-upload', (_event, payload) => {
|
||||
if (entry.jobId) {
|
||||
_appendJobLog(entry.jobId, { ts: ts || Date.now(), kind: 'rot', event, ...rest });
|
||||
}
|
||||
if (mainWindow && !mainWindow.isDestroyed() && ROT_LOG_RENDERER_EVENTS.has(event)) {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('account-rotation-log', entry);
|
||||
}
|
||||
});
|
||||
@ -1485,7 +1325,6 @@ ipcMain.handle('start-upload', (_event, payload) => {
|
||||
const _thisManager = uploadManager;
|
||||
uploadManager.on('batch-done', async (summary) => {
|
||||
debugLog(`batch-done: total=${summary.total} ok=${summary.succeeded} fail=${summary.failed}`);
|
||||
logMarker('BATCH END', { total: summary.total, ok: summary.succeeded, fail: summary.failed });
|
||||
logMemorySnapshot('batch-done');
|
||||
try { await configStore.appendHistory(summary); } catch (err) {
|
||||
debugLog(`appendHistory failed: ${err.message}`);
|
||||
@ -1592,135 +1431,12 @@ ipcMain.handle('finish-after-active', () => {
|
||||
return true;
|
||||
});
|
||||
|
||||
ipcMain.handle('get-session-failed-accounts', () => {
|
||||
return Array.from(_sessionFailedAccounts.keys());
|
||||
});
|
||||
|
||||
ipcMain.handle('reset-session-failed-account', (_event, payload) => {
|
||||
if (!payload || typeof payload !== 'object') return { ok: false };
|
||||
const { hoster, accountId } = payload;
|
||||
if (!hoster || !accountId) return { ok: false };
|
||||
const key = `${hoster}:${accountId}`;
|
||||
const removed = _sessionFailedAccounts.delete(key);
|
||||
if (uploadManager && typeof uploadManager.clearFailedAccount === 'function') {
|
||||
try { uploadManager.clearFailedAccount(hoster, accountId); } catch {}
|
||||
}
|
||||
rotLog(`session-failed: manual reset ${key} (was set: ${removed})`);
|
||||
return { ok: true, removed };
|
||||
});
|
||||
|
||||
ipcMain.handle('reset-all-session-failed-accounts', () => {
|
||||
const count = _sessionFailedAccounts.size;
|
||||
_sessionFailedAccounts.clear();
|
||||
if (uploadManager && typeof uploadManager.clearAllFailedAccounts === 'function') {
|
||||
try { uploadManager.clearAllFailedAccounts(); } catch {}
|
||||
}
|
||||
rotLog(`session-failed: cleared all (${count})`);
|
||||
return { ok: true, count };
|
||||
});
|
||||
|
||||
ipcMain.handle('get-job-log', (_event, jobId) => {
|
||||
if (!jobId || typeof jobId !== 'string') return [];
|
||||
const arr = _jobLogCollector.get(jobId);
|
||||
return Array.isArray(arr) ? arr.slice() : [];
|
||||
});
|
||||
|
||||
ipcMain.handle('get-log-paths', () => {
|
||||
return getAllLogPaths();
|
||||
});
|
||||
|
||||
ipcMain.handle('get-app-info', () => {
|
||||
return {
|
||||
name: app.getName(),
|
||||
version: app.getVersion(),
|
||||
electron: process.versions.electron,
|
||||
node: process.versions.node,
|
||||
chrome: process.versions.chrome,
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
osRelease: require('os').release(),
|
||||
pid: process.pid,
|
||||
isPackaged: app.isPackaged,
|
||||
logVerbose: _logVerbose
|
||||
};
|
||||
});
|
||||
|
||||
ipcMain.handle('reveal-log-file', async (_event, target) => {
|
||||
const { shell } = require('electron');
|
||||
const paths = getAllLogPaths();
|
||||
const file = (target && typeof target === 'string' && paths[target]) || null;
|
||||
try {
|
||||
if (file && fs.existsSync(file)) {
|
||||
shell.showItemInFolder(file);
|
||||
return { ok: true, path: file };
|
||||
}
|
||||
const dir = paths.logDir;
|
||||
if (dir) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
shell.openPath(dir);
|
||||
return { ok: true, path: dir };
|
||||
}
|
||||
return { ok: false, error: 'Kein Log-Pfad gefunden' };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err.message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('set-log-verbose', (_event, enabled) => {
|
||||
setLogVerbose(enabled);
|
||||
logMarker('VERBOSE TOGGLE', { enabled: _logVerbose });
|
||||
return { ok: true, verbose: _logVerbose };
|
||||
});
|
||||
|
||||
ipcMain.handle('create-support-bundle', async () => {
|
||||
const { dialog } = require('electron');
|
||||
try {
|
||||
if (_debugLogBuffer.length) {
|
||||
try { fs.appendFileSync(getDebugLogPath(), _debugLogBuffer.join(''), 'utf-8'); _debugLogBuffer.length = 0; } catch {}
|
||||
}
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
||||
const defaultName = `multi-hoster-support-${stamp}.txt`;
|
||||
const desktop = (() => { try { return app.getPath('desktop'); } catch { return app.getPath('userData'); } })();
|
||||
const res = await dialog.showSaveDialog(mainWindow || undefined, {
|
||||
title: 'Diagnose-Paket speichern',
|
||||
defaultPath: path.join(desktop, defaultName),
|
||||
filters: [{ name: 'Text', extensions: ['txt'] }]
|
||||
});
|
||||
if (res.canceled || !res.filePath) return { ok: false, canceled: true };
|
||||
const paths = getAllLogPaths();
|
||||
const cfg = configStore.load();
|
||||
const text = buildSupportBundleText({
|
||||
header: {
|
||||
App: app.getName(),
|
||||
Version: app.getVersion(),
|
||||
Electron: process.versions.electron,
|
||||
Node: process.versions.node,
|
||||
Chrome: process.versions.chrome,
|
||||
Platform: process.platform,
|
||||
Arch: process.arch,
|
||||
OS: `${require('os').type()} ${require('os').release()}`,
|
||||
Packaged: app.isPackaged,
|
||||
Verbose: _logVerbose,
|
||||
PID: process.pid,
|
||||
CreatedAt: new Date().toISOString()
|
||||
},
|
||||
sanitizedConfig: sanitizeConfig(cfg),
|
||||
files: [
|
||||
{ label: 'debug.log (last 5 MB)', path: paths.debug, maxBytes: 5 * 1024 * 1024 },
|
||||
{ label: 'account-rotation.log (last 2 MB)', path: paths.accountRotation, maxBytes: 2 * 1024 * 1024 },
|
||||
{ label: 'doodstream-debug.log (last 2 MB)', path: paths.doodstreamDebug, maxBytes: 2 * 1024 * 1024 },
|
||||
{ label: 'fileuploader.log (last 1 MB)', path: paths.fileuploader, maxBytes: 1 * 1024 * 1024 }
|
||||
]
|
||||
});
|
||||
fs.writeFileSync(res.filePath, text, 'utf-8');
|
||||
logMarker('SUPPORT BUNDLE', { path: res.filePath, bytes: text.length });
|
||||
return { ok: true, path: res.filePath, bytes: text.length };
|
||||
} catch (err) {
|
||||
debugLog(`create-support-bundle failed: ${err.message}`);
|
||||
return { ok: false, error: err.message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('open-log-folder', async () => {
|
||||
// Reveal the active log file (or its directory) in the OS file manager.
|
||||
// Prefers the configured log path, then the rotation log, then just the
|
||||
@ -1750,19 +1466,12 @@ ipcMain.handle('export-backup', async () => {
|
||||
const { canceled, filePath } = await dialog.showSaveDialog(mainWindow, {
|
||||
title: 'Backup exportieren',
|
||||
defaultPath: `multi-hoster-backup-${new Date().toISOString().slice(0, 10)}.mhu`,
|
||||
filters: [
|
||||
{ name: 'Multi-Hoster Backup (verschlüsselt)', extensions: ['mhu'] },
|
||||
{ name: 'Multi-Hoster Backup (Klartext JSON)', extensions: ['json'] }
|
||||
]
|
||||
filters: [{ name: 'Multi-Hoster Backup', extensions: ['mhu'] }]
|
||||
});
|
||||
if (canceled || !filePath) return { ok: false, canceled: true };
|
||||
const config = configStore.load();
|
||||
if (filePath.toLowerCase().endsWith('.json')) {
|
||||
fs.writeFileSync(filePath, JSON.stringify(config, null, 2), 'utf-8');
|
||||
} else {
|
||||
const encrypted = backupCrypto.encrypt(config);
|
||||
fs.writeFileSync(filePath, encrypted);
|
||||
}
|
||||
const encrypted = backupCrypto.encrypt(config);
|
||||
fs.writeFileSync(filePath, encrypted);
|
||||
return { ok: true, path: filePath };
|
||||
});
|
||||
|
||||
@ -1774,11 +1483,7 @@ ipcMain.handle('import-backup', async (_event, legacyPassword) => {
|
||||
} else {
|
||||
const { canceled, filePaths } = await dialog.showOpenDialog(mainWindow, {
|
||||
title: 'Backup importieren',
|
||||
filters: [
|
||||
{ name: 'Multi-Hoster Backup', extensions: ['mhu', 'json'] },
|
||||
{ name: 'Verschlüsselt (.mhu)', extensions: ['mhu'] },
|
||||
{ name: 'Klartext (.json)', extensions: ['json'] }
|
||||
],
|
||||
filters: [{ name: 'Multi-Hoster Backup', extensions: ['mhu'] }],
|
||||
properties: ['openFile']
|
||||
});
|
||||
if (canceled || !filePaths.length) return { ok: false, canceled: true };
|
||||
@ -1787,25 +1492,14 @@ ipcMain.handle('import-backup', async (_event, legacyPassword) => {
|
||||
_lastImportPath = sourcePath;
|
||||
}
|
||||
let imported;
|
||||
const looksLikeJson = buffer.length >= 1 && (buffer[0] === 0x7B || buffer[0] === 0x20 || buffer[0] === 0x0A || buffer[0] === 0x0D || buffer[0] === 0x09 || buffer[0] === 0xEF);
|
||||
if (looksLikeJson) {
|
||||
try {
|
||||
const text = buffer.toString('utf-8').replace(/^\uFEFF/, '');
|
||||
imported = JSON.parse(text);
|
||||
} catch (err) {
|
||||
_lastImportPath = null;
|
||||
return { ok: false, error: 'Klartext-Backup ist kein gültiges JSON: ' + (err.message || err) };
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
imported = backupCrypto.decrypt(buffer, legacyPassword);
|
||||
} catch (err) {
|
||||
if (err && err.needsPassword) {
|
||||
return { ok: false, needsPassword: true, hint: 'Falls dieses Backup mit der aktuellen Version erzeugt wurde, ist die Datei vermutlich beim Transfer beschädigt worden (z. B. FTP-Text-Modus). Versuch es mit einem Klartext-JSON-Export.' };
|
||||
}
|
||||
_lastImportPath = null;
|
||||
throw err;
|
||||
try {
|
||||
imported = backupCrypto.decrypt(buffer, legacyPassword);
|
||||
} catch (err) {
|
||||
if (err && err.needsPassword) {
|
||||
return { ok: false, needsPassword: true };
|
||||
}
|
||||
_lastImportPath = null;
|
||||
throw err;
|
||||
}
|
||||
_lastImportPath = null;
|
||||
// Validate imported data has required structure
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "multi-hoster-uploader",
|
||||
"version": "3.3.50",
|
||||
"version": "3.3.36",
|
||||
"description": "Upload files to doodstream, voe, vidmoly, byse simultaneously",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
|
||||
12
preload.js
12
preload.js
@ -39,7 +39,6 @@ contextBridge.exposeInMainWorld('api', {
|
||||
addJobsToBatch: (payload) => ipcRenderer.invoke('add-jobs-to-batch', payload),
|
||||
finishAfterActive: () => ipcRenderer.invoke('finish-after-active'),
|
||||
runHealthCheck: (payload) => ipcRenderer.invoke('run-health-check', payload),
|
||||
validateCredentials: (payload) => ipcRenderer.invoke('validate-credentials', payload),
|
||||
|
||||
// Log import
|
||||
readOwnUploadLog: () => ipcRenderer.invoke('read-own-upload-log'),
|
||||
@ -93,9 +92,6 @@ contextBridge.exposeInMainWorld('api', {
|
||||
onUploadProgress: (callback) => {
|
||||
ipcRenderer.on('upload-progress', (_event, data) => callback(data));
|
||||
},
|
||||
onUploadProgressBatch: (callback) => {
|
||||
ipcRenderer.on('upload-progress-batch', (_event, batch) => callback(batch));
|
||||
},
|
||||
onUploadBatchDone: (callback) => {
|
||||
ipcRenderer.on('upload-batch-done', (_event, data) => callback(data));
|
||||
},
|
||||
@ -113,14 +109,6 @@ contextBridge.exposeInMainWorld('api', {
|
||||
},
|
||||
openLogFolder: () => ipcRenderer.invoke('open-log-folder'),
|
||||
getJobLog: (jobId) => ipcRenderer.invoke('get-job-log', jobId),
|
||||
getSessionFailedAccounts: () => ipcRenderer.invoke('get-session-failed-accounts'),
|
||||
resetSessionFailedAccount: (payload) => ipcRenderer.invoke('reset-session-failed-account', payload),
|
||||
resetAllSessionFailedAccounts: () => ipcRenderer.invoke('reset-all-session-failed-accounts'),
|
||||
getLogPaths: () => ipcRenderer.invoke('get-log-paths'),
|
||||
revealLogFile: (target) => ipcRenderer.invoke('reveal-log-file', target),
|
||||
setLogVerbose: (enabled) => ipcRenderer.invoke('set-log-verbose', enabled),
|
||||
createSupportBundle: () => ipcRenderer.invoke('create-support-bundle'),
|
||||
getAppInfo: () => ipcRenderer.invoke('get-app-info'),
|
||||
onLogPathAutoUpdated: (callback) => {
|
||||
ipcRenderer.on('log-path-auto-updated', (_event, data) => callback(data));
|
||||
},
|
||||
|
||||
756
renderer/app.js
756
renderer/app.js
@ -92,7 +92,6 @@ async function init() {
|
||||
setupDragDrop();
|
||||
restoreQueueColumnWidths();
|
||||
loadHistory();
|
||||
_refreshSessionFailedSnapshot();
|
||||
renderRecentUploadsPanel();
|
||||
updateUploadView();
|
||||
updateStatusBar();
|
||||
@ -108,19 +107,24 @@ async function init() {
|
||||
window.api.onUpdateAvailable(showUpdateBanner);
|
||||
window.api.onUpdateProgress(handleUpdateProgress);
|
||||
|
||||
// Upload event listeners — debug log only on state transitions; the 'uploading'
|
||||
// tick fires 4×/sec per active job and an IPC roundtrip per event would
|
||||
// backlog the renderer↔main channel with hundreds of messages/sec.
|
||||
window.api.onUploadProgress((data) => {
|
||||
if (data.status !== 'uploading') {
|
||||
window.api.debugLog('RX upload-progress: ' + data.status + ' ' + data.hoster + ' ' + (data.fileName || ''));
|
||||
}
|
||||
handleProgress(data);
|
||||
});
|
||||
if (window.api.onUploadProgressBatch) {
|
||||
window.api.onUploadProgressBatch((batch) => {
|
||||
if (!Array.isArray(batch)) return;
|
||||
for (let i = 0; i < batch.length; i++) handleProgress(batch[i]);
|
||||
});
|
||||
}
|
||||
window.api.onUploadBatchDone((data) => {
|
||||
window.api.debugLog('RX upload-batch-done');
|
||||
handleBatchDone(data);
|
||||
});
|
||||
window.api.onUploadStats((data) => {
|
||||
// Stats fire every second per upload session — skip while uploading.
|
||||
if (data.state !== 'uploading') {
|
||||
window.api.debugLog('RX upload-stats: state=' + data.state + ' active=' + data.activeJobs);
|
||||
}
|
||||
handleStats(data);
|
||||
});
|
||||
window.api.onShutdownCountdown(handleShutdownCountdown);
|
||||
@ -1443,7 +1447,7 @@ async function doBackupExport() {
|
||||
}
|
||||
}
|
||||
|
||||
function askLegacyBackupPassword(hint) {
|
||||
function askLegacyBackupPassword() {
|
||||
return new Promise((resolve) => {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'modal-overlay';
|
||||
@ -1456,7 +1460,7 @@ function askLegacyBackupPassword(hint) {
|
||||
const header = document.createElement('div');
|
||||
header.className = 'modal-header';
|
||||
const h3 = document.createElement('h3');
|
||||
h3.textContent = 'Backup nicht entschlüsselbar';
|
||||
h3.textContent = 'Passwort erforderlich';
|
||||
header.appendChild(h3);
|
||||
|
||||
const body = document.createElement('div');
|
||||
@ -1464,15 +1468,7 @@ function askLegacyBackupPassword(hint) {
|
||||
const p = document.createElement('p');
|
||||
p.style.margin = '0 0 10px';
|
||||
p.style.fontSize = '13px';
|
||||
p.textContent = 'Wenn das Backup mit der alten Passwort-Option (vor v3.0) erstellt wurde, hier eingeben.';
|
||||
if (hint) {
|
||||
const p2 = document.createElement('p');
|
||||
p2.style.margin = '0 0 10px';
|
||||
p2.style.fontSize = '12px';
|
||||
p2.style.color = 'var(--text-dim)';
|
||||
p2.textContent = hint;
|
||||
body.appendChild(p2);
|
||||
}
|
||||
p.textContent = 'Dieses Backup wurde mit einem Passwort verschlüsselt.';
|
||||
const input = document.createElement('input');
|
||||
input.type = 'password';
|
||||
input.className = 'key-input';
|
||||
@ -1516,7 +1512,7 @@ async function doBackupImport(legacyPassword) {
|
||||
const result = await window.api.importBackup(pw);
|
||||
if (!result || result.canceled) return;
|
||||
if (result.needsPassword) {
|
||||
const entered = await askLegacyBackupPassword(result.hint);
|
||||
const entered = await askLegacyBackupPassword();
|
||||
if (entered) doBackupImport(entered);
|
||||
return;
|
||||
}
|
||||
@ -2060,85 +2056,6 @@ function handleBatchDone(summary) {
|
||||
|
||||
lastUploadStats = { state: 'idle', globalSpeedKbs: 0, totalBytes: lastUploadStats.totalBytes, elapsed: lastUploadStats.elapsed, activeJobs: 0 };
|
||||
updateStatusBar();
|
||||
_maybeShowBatchSummary(summary);
|
||||
_refreshSessionFailedSnapshot();
|
||||
}
|
||||
|
||||
let _sessionFailedKeys = new Set();
|
||||
async function _refreshSessionFailedSnapshot() {
|
||||
if (!window.api || !window.api.getSessionFailedAccounts) return;
|
||||
try {
|
||||
const keys = await window.api.getSessionFailedAccounts();
|
||||
_sessionFailedKeys = new Set(Array.isArray(keys) ? keys : []);
|
||||
renderAccounts();
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function _maybeShowBatchSummary(summary) {
|
||||
if (!window.Stats || !summary) return;
|
||||
const buckets = window.Stats.summarizeBatchErrors(summary);
|
||||
const total = Object.values(buckets).reduce((n, arr) => n + arr.length, 0);
|
||||
if (total === 0) return;
|
||||
|
||||
const modal = document.getElementById('batchSummaryModal');
|
||||
if (!modal) return;
|
||||
const list = modal.querySelector('#batchSummaryList');
|
||||
const retryAllBtn = modal.querySelector('#batchSummaryRetryAll');
|
||||
const retryTransientBtn = modal.querySelector('#batchSummaryRetryTransient');
|
||||
const closeBtn = modal.querySelector('#batchSummaryClose');
|
||||
|
||||
const order = ['hoster-transient', 'network', 'unknown', 'file-rejected', 'account-error', 'aborted'];
|
||||
list.innerHTML = order
|
||||
.filter(cat => buckets[cat].length > 0)
|
||||
.map(cat => {
|
||||
const items = buckets[cat];
|
||||
const sample = items.slice(0, 3).map(i => `<li>${escapeHtml(i.fileName)} → ${escapeHtml(i.hoster)}: <em>${escapeHtml(i.error)}</em></li>`).join('');
|
||||
const more = items.length > 3 ? `<li><em>… +${items.length - 3} weitere</em></li>` : '';
|
||||
const retryable = window.Stats.isRetryableCategory(cat);
|
||||
const tag = retryable ? '<span class="batch-cat-tag retryable">erneut versuchbar</span>' : '<span class="batch-cat-tag">manuell</span>';
|
||||
return `<div class="batch-cat" data-category="${escapeAttr(cat)}">
|
||||
<div class="batch-cat-head"><strong>${escapeHtml(window.Stats.CATEGORY_LABELS[cat] || cat)}</strong> <span class="batch-cat-count">${items.length}</span> ${tag}</div>
|
||||
<ul class="batch-cat-list">${sample}${more}</ul>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
const transientCount = ['hoster-transient', 'network', 'unknown'].reduce((n, c) => n + buckets[c].length, 0);
|
||||
retryTransientBtn.textContent = transientCount > 0 ? `Transiente erneut hochladen (${transientCount})` : 'Keine transienten Fehler';
|
||||
retryTransientBtn.disabled = transientCount === 0;
|
||||
const allRetryable = total - buckets['aborted'].length;
|
||||
retryAllBtn.textContent = `Alle Fehler erneut versuchen (${allRetryable})`;
|
||||
retryAllBtn.disabled = allRetryable === 0;
|
||||
|
||||
const close = () => { modal.style.display = 'none'; };
|
||||
closeBtn.onclick = close;
|
||||
retryAllBtn.onclick = () => { _retryFailedFromBuckets(buckets, false); close(); };
|
||||
retryTransientBtn.onclick = () => { _retryFailedFromBuckets(buckets, true); close(); };
|
||||
modal.style.display = 'flex';
|
||||
}
|
||||
|
||||
function _retryFailedFromBuckets(buckets, transientOnly) {
|
||||
const cats = transientOnly ? ['hoster-transient', 'network', 'unknown'] : ['hoster-transient', 'network', 'unknown', 'file-rejected', 'account-error'];
|
||||
const toRetry = [];
|
||||
for (const cat of cats) {
|
||||
for (const item of (buckets[cat] || [])) toRetry.push(item);
|
||||
}
|
||||
if (toRetry.length === 0) return;
|
||||
const jobsToRetry = [];
|
||||
for (const item of toRetry) {
|
||||
const job = queueJobs.find(j => (j.fileName === item.fileName) && (j.hoster === item.hoster) && (j.status === 'error' || j.status === 'skipped'));
|
||||
if (job) {
|
||||
job.status = 'queued';
|
||||
job.progress = 0;
|
||||
job.bytesUploaded = 0;
|
||||
job.error = null;
|
||||
job.result = null;
|
||||
jobsToRetry.push(job);
|
||||
}
|
||||
}
|
||||
if (jobsToRetry.length === 0) { showCopyToast('Keine passenden Jobs für Retry gefunden.'); return; }
|
||||
renderQueueTable();
|
||||
showCopyToast(`${jobsToRetry.length} Job(s) zum erneuten Upload zurückgesetzt`);
|
||||
if (typeof startUpload === 'function') startUpload();
|
||||
}
|
||||
|
||||
function handleStats(data) {
|
||||
@ -2504,9 +2421,19 @@ function updateStatusBar() {
|
||||
|
||||
// --- Health Check ---
|
||||
|
||||
function renderHealthCheckResults(_results) {
|
||||
function renderHealthCheckResults(results) {
|
||||
const container = document.getElementById('healthCheckResults');
|
||||
if (container) container.innerHTML = '';
|
||||
if (!container) return;
|
||||
if (!results || results.length === 0) { container.innerHTML = ''; return; }
|
||||
|
||||
container.innerHTML = results.map(item => {
|
||||
const status = item.status || 'skipped';
|
||||
return `<div class="health-badge ${status}">
|
||||
<span>${escapeHtml(item.hoster ? getHosterLabel(item.hoster) : '')}</span>
|
||||
<span class="health-tag">[${status.toUpperCase()}]</span>
|
||||
<span>${escapeHtml(item.message || '')}</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function executeHealthCheck(hosters, _mode) {
|
||||
@ -2530,10 +2457,8 @@ async function executeHealthCheck(hosters, _mode) {
|
||||
}
|
||||
|
||||
async function runHealthCheck(mode = 'manual', requestedHosters = null) {
|
||||
if (healthCheckRunning) {
|
||||
if (mode === 'manual') showCopyToast('Account-Check läuft bereits.');
|
||||
return [];
|
||||
}
|
||||
if (healthCheckRunning || (uploading && mode === 'manual')) return [];
|
||||
// Build check list: all enabled accounts with creds
|
||||
let hosters;
|
||||
if (Array.isArray(requestedHosters) && requestedHosters.length > 0) {
|
||||
hosters = requestedHosters;
|
||||
@ -2565,36 +2490,6 @@ async function runHealthCheck(mode = 'manual', requestedHosters = null) {
|
||||
}
|
||||
|
||||
// --- Settings ---
|
||||
async function _renderLogPathsList(el) {
|
||||
if (!el || !window.api || !window.api.getLogPaths) return;
|
||||
try {
|
||||
const paths = await window.api.getLogPaths();
|
||||
if (!paths || typeof paths !== 'object') { el.innerHTML = '<span class="hint">Pfade nicht verfügbar.</span>'; return; }
|
||||
const entries = [
|
||||
['fileuploader', 'fileuploader.log'],
|
||||
['debug', 'debug.log'],
|
||||
['accountRotation', 'account-rotation.log'],
|
||||
['doodstreamDebug', 'doodstream-debug.log']
|
||||
];
|
||||
el.innerHTML = entries.map(([key, label]) => {
|
||||
const p = paths[key] || '';
|
||||
return `<div style="display:flex;gap:6px;align-items:center;font-size:11px">
|
||||
<span style="min-width:160px;color:var(--text-dim)">${escapeHtml(label)}</span>
|
||||
<code style="flex:1;font-size:10px;opacity:0.85;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${escapeAttr(p)}">${escapeHtml(p) || '<nicht gesetzt>'}</code>
|
||||
<button class="btn btn-xs btn-secondary" data-reveal-log="${escapeAttr(key)}" title="Im Explorer zeigen">Zeigen</button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
el.querySelectorAll('[data-reveal-log]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const target = btn.getAttribute('data-reveal-log');
|
||||
if (window.api && window.api.revealLogFile) window.api.revealLogFile(target).catch(() => {});
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
el.innerHTML = `<span class="hint">Fehler: ${escapeHtml(err.message || String(err))}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderSettings() {
|
||||
const container = document.getElementById('settingsHosters');
|
||||
container.innerHTML = '';
|
||||
@ -2665,59 +2560,9 @@ function renderSettings() {
|
||||
</select>
|
||||
<span class="hint">Pro Session = neue Datei bei jedem App-Start; nach komplettem Schließen + erneutem Öffnen beginnt eine neue Session.</span>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<label>Verbose Logging</label>
|
||||
<label class="checkbox-row" style="margin:0">
|
||||
<input type="checkbox" class="settings-autosave" id="logVerboseInput" ${globalSettings.logVerbose ? 'checked' : ''}>
|
||||
<span>DEBUG-Einträge in debug.log schreiben (Performance ↓, Diagnostik ↑)</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="settings-section-label">Diagnose</div>
|
||||
<div class="settings-row" id="logPathsBlock">
|
||||
<label>Log-Dateien</label>
|
||||
<div class="log-paths-list" id="logPathsList" style="flex:1;display:flex;flex-direction:column;gap:4px">
|
||||
<span class="hint">Wird geladen…</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<label>Support-Paket</label>
|
||||
<button class="btn btn-xs btn-secondary" id="createSupportBundleBtn" title="Sammelt alle Logs + sanitierte Config (Credentials maskiert) + App-Versionen in eine einzelne .txt-Datei zum Teilen.">Diagnose-Paket exportieren</button>
|
||||
<span class="hint" id="supportBundleHint">Eine .txt mit Logs + sanitierter Config; Passwörter/API-Keys werden vor dem Speichern maskiert.</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
container.appendChild(generalPanel);
|
||||
_renderLogPathsList(generalPanel.querySelector('#logPathsList'));
|
||||
const verboseInput = generalPanel.querySelector('#logVerboseInput');
|
||||
if (verboseInput) {
|
||||
verboseInput.addEventListener('change', () => {
|
||||
if (window.api && window.api.setLogVerbose) window.api.setLogVerbose(verboseInput.checked).catch(() => {});
|
||||
});
|
||||
}
|
||||
const sbBtn = generalPanel.querySelector('#createSupportBundleBtn');
|
||||
if (sbBtn) {
|
||||
sbBtn.addEventListener('click', async () => {
|
||||
const hint = generalPanel.querySelector('#supportBundleHint');
|
||||
sbBtn.disabled = true;
|
||||
const prevText = sbBtn.textContent;
|
||||
sbBtn.textContent = 'Exportiere…';
|
||||
try {
|
||||
const res = await window.api.createSupportBundle();
|
||||
if (res && res.ok) {
|
||||
if (hint) hint.textContent = `Gespeichert: ${res.path} (${(res.bytes/1024).toFixed(1)} KB)`;
|
||||
} else if (res && res.canceled) {
|
||||
if (hint) hint.textContent = 'Abgebrochen.';
|
||||
} else {
|
||||
if (hint) hint.textContent = `Fehler: ${(res && res.error) || 'unbekannt'}`;
|
||||
}
|
||||
} catch (err) {
|
||||
if (hint) hint.textContent = `Fehler: ${err.message || err}`;
|
||||
} finally {
|
||||
sbBtn.disabled = false;
|
||||
sbBtn.textContent = prevText;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Toggle general panel
|
||||
generalPanel.querySelector('.hoster-panel-header').addEventListener('click', () => {
|
||||
@ -3195,25 +3040,15 @@ function _buildAccountCardHtml(name, account, idx) {
|
||||
const statusLabel = isDisabled ? 'Deaktiviert' : (_STATUS_LABELS[st.status] || 'Nicht geprüft');
|
||||
const statusClass = isDisabled ? 'disabled' : st.status;
|
||||
const credLabel = getCredentialLabel(name, account);
|
||||
const userLabel = account.label && String(account.label).trim();
|
||||
// Subtitle: "Label: XYZ • API: ABC… • <status>" — the user-set label is the
|
||||
// disambiguator for accounts that otherwise look identical (e.g. two byse
|
||||
// API-key accounts where you can't tell what's what from the masked key).
|
||||
const subtitleText = (userLabel ? `Label: ${userLabel} • ` : '') + credLabel;
|
||||
const toggleLabel = isDisabled ? 'Aktivieren' : 'Deaktivieren';
|
||||
const priorityLabel = idx === 0 ? 'Primär' : `Fallback #${idx}`;
|
||||
|
||||
const isSessionPaused = _sessionFailedKeys.has(`${name}:${account.id}`);
|
||||
const sessionPausedBadge = isSessionPaused
|
||||
? `<span class="account-session-paused" title="Account wurde diese Session als fehlerhaft markiert. Klick = Wieder als aktiv markieren.">Pausiert (Session) <button class="account-session-reactivate" data-account-reactivate="${account.id}" data-account-reactivate-hoster="${name}" title="Wieder aktivieren">↻</button></span>`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<div class="account-card${isDisabled ? ' account-disabled' : ''}${isSessionPaused ? ' account-session-paused-card' : ''}" data-account-id="${account.id}" data-account-hoster="${name}" draggable="true">
|
||||
<div class="account-card${isDisabled ? ' account-disabled' : ''}" data-account-id="${account.id}" data-account-hoster="${name}" draggable="true">
|
||||
<div class="account-card-drag-handle" title="Ziehen zum Sortieren">☰</div>
|
||||
<div class="account-card-info">
|
||||
<div class="account-card-title">${escapeHtml(getAccountDisplayName(name, account))} <span class="account-priority-badge">${priorityLabel}</span> ${sessionPausedBadge}</div>
|
||||
<div class="account-card-subtitle" title="${escapeAttr(subtitleText)}">${escapeHtml(subtitleText)}${st.message && !isDisabled ? ` • ${escapeHtml(st.message)}` : ''}</div>
|
||||
<div class="account-card-title">${escapeHtml(getAccountDisplayName(name, account))} <span class="account-priority-badge">${priorityLabel}</span></div>
|
||||
<div class="account-card-subtitle" title="${escapeAttr(credLabel)}">${escapeHtml(credLabel)}${st.message && !isDisabled ? ` • ${escapeHtml(st.message)}` : ''}</div>
|
||||
</div>
|
||||
<span class="account-status status-${statusClass}">
|
||||
<span class="account-status-dot"></span>
|
||||
@ -3244,40 +3079,6 @@ function updateAccountCard(accountId) {
|
||||
const tmp = document.createElement('div');
|
||||
tmp.innerHTML = _buildAccountCardHtml(found.name, found.account, idx);
|
||||
card.replaceWith(tmp.firstElementChild);
|
||||
_refreshHosterGroupHeader(found.name);
|
||||
}
|
||||
|
||||
function _refreshHosterGroupHeader(name) {
|
||||
const container = document.getElementById('accountsList');
|
||||
if (!container) return;
|
||||
const group = container.querySelector(`.account-hoster-group[data-hoster-group="${name}"]`);
|
||||
if (!group) return;
|
||||
const accounts = config.hosters[name] || [];
|
||||
const summary = _summarizeHosterGroup(accounts);
|
||||
let dot = 'unchecked';
|
||||
if (summary.error > 0) dot = 'error';
|
||||
else if (summary.checking > 0) dot = 'checking';
|
||||
else if (summary.ok > 0 && summary.unchecked === 0) dot = 'ok';
|
||||
const dotEl = group.querySelector('.account-hoster-group-header .account-status-dot');
|
||||
if (dotEl) dotEl.className = `account-status-dot status-${dot}`;
|
||||
const countEl = group.querySelector('.account-hoster-group-count');
|
||||
if (countEl) countEl.textContent = `${summary.ok}/${summary.total}`;
|
||||
group.querySelectorAll('.account-hoster-group-meta').forEach(el => el.remove());
|
||||
const header = group.querySelector('.account-hoster-group-header');
|
||||
if (header) {
|
||||
if (summary.disabled) {
|
||||
const meta = document.createElement('span');
|
||||
meta.className = 'account-hoster-group-meta';
|
||||
meta.textContent = `${summary.disabled} deaktiviert`;
|
||||
header.appendChild(meta);
|
||||
}
|
||||
if (summary.error) {
|
||||
const meta = document.createElement('span');
|
||||
meta.className = 'account-hoster-group-meta error';
|
||||
meta.textContent = `${summary.error} Fehler`;
|
||||
header.appendChild(meta);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _accountListenersBound = false;
|
||||
@ -3311,79 +3112,16 @@ function renderAccounts() {
|
||||
for (const name of HOSTERS) {
|
||||
const accounts = byHoster[name];
|
||||
if (!accounts || accounts.length === 0) continue;
|
||||
html += _buildAccountHosterGroupHtml(name, accounts);
|
||||
html += `<div class="account-hoster-group" data-hoster-group="${name}">
|
||||
<div class="account-hoster-group-title">${escapeHtml(getHosterLabel(name))}</div>`;
|
||||
accounts.forEach((account, idx) => { html += _buildAccountCardHtml(name, account, idx); });
|
||||
html += '</div>';
|
||||
}
|
||||
container.innerHTML = html;
|
||||
|
||||
if (!_accountListenersBound) bindAccountListeners(container);
|
||||
}
|
||||
|
||||
function _summarizeHosterGroup(accounts) {
|
||||
let ok = 0, error = 0, checking = 0, unchecked = 0, disabled = 0;
|
||||
for (const a of accounts) {
|
||||
if (a.enabled === false) { disabled++; continue; }
|
||||
const s = (accountStatuses[a.id] && accountStatuses[a.id].status) || 'unchecked';
|
||||
if (s === 'ok' || s === 'warn') ok++;
|
||||
else if (s === 'error') error++;
|
||||
else if (s === 'checking') checking++;
|
||||
else unchecked++;
|
||||
}
|
||||
return { ok, error, checking, unchecked, disabled, total: accounts.length };
|
||||
}
|
||||
|
||||
function _hosterGroupOpenState(name, summary) {
|
||||
const prev = _hosterGroupOpenMemory.get(name);
|
||||
if (prev && typeof prev === 'object') {
|
||||
if (summary.error > (prev.errorsAtClose || 0)) {
|
||||
_hosterGroupOpenMemory.delete(name);
|
||||
return true;
|
||||
}
|
||||
return prev.state === 'open';
|
||||
}
|
||||
return summary.error > 0;
|
||||
}
|
||||
|
||||
const _hosterGroupOpenMemory = new Map();
|
||||
|
||||
function _buildAccountHosterGroupHtml(name, accounts) {
|
||||
const summary = _summarizeHosterGroup(accounts);
|
||||
const isOpen = _hosterGroupOpenState(name, summary);
|
||||
let dot = 'unchecked';
|
||||
if (summary.error > 0) dot = 'error';
|
||||
else if (summary.checking > 0) dot = 'checking';
|
||||
else if (summary.ok > 0 && summary.unchecked === 0) dot = 'ok';
|
||||
const countLabel = `${summary.ok}/${summary.total}`;
|
||||
const arrow = isOpen ? '▼' : '▶';
|
||||
let cardsHtml = '';
|
||||
accounts.forEach((account, idx) => { cardsHtml += _buildAccountCardHtml(name, account, idx); });
|
||||
const bodyStyle = isOpen ? '' : 'style="display:none"';
|
||||
const lifeStat = _hosterLifetimeStat(name);
|
||||
const lifeMeta = lifeStat && lifeStat.total > 0
|
||||
? `<span class="account-hoster-group-meta" title="Erfolgsrate aus den letzten ${lifeStat.total} Uploads dieses Hosters">${Math.round(lifeStat.rate * 100)}% ok (${lifeStat.total})</span>`
|
||||
: '';
|
||||
return `<div class="account-hoster-group" data-hoster-group="${name}">
|
||||
<div class="account-hoster-group-header" data-hoster-toggle="${name}">
|
||||
<span class="panel-arrow">${arrow}</span>
|
||||
<span class="account-status-dot status-${dot}"></span>
|
||||
<span class="account-hoster-group-title">${escapeHtml(getHosterLabel(name))}</span>
|
||||
<span class="account-hoster-group-count">${countLabel}</span>
|
||||
${summary.disabled ? `<span class="account-hoster-group-meta">${summary.disabled} deaktiviert</span>` : ''}
|
||||
${summary.error ? `<span class="account-hoster-group-meta error">${summary.error} Fehler</span>` : ''}
|
||||
${lifeMeta}
|
||||
</div>
|
||||
<div class="account-hoster-group-body" ${bodyStyle}>${cardsHtml}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
let _hosterLifetimeCache = null;
|
||||
function _hosterLifetimeStat(name) {
|
||||
if (!_hosterLifetimeCache && window.Stats && Array.isArray(window._historyForStats)) {
|
||||
_hosterLifetimeCache = window.Stats.summarizePerHoster(window._historyForStats, { lastNBatches: 50 });
|
||||
}
|
||||
return _hosterLifetimeCache ? _hosterLifetimeCache[name] : null;
|
||||
}
|
||||
function _invalidateHosterLifetimeCache() { _hosterLifetimeCache = null; }
|
||||
|
||||
// Single set of delegated listeners on the accounts container. Bound once on
|
||||
// the first render and reused for every subsequent in-place update / card
|
||||
// swap. Previously we rebound 4 × N button listeners + 5 × N drag listeners
|
||||
@ -3392,39 +3130,12 @@ function _invalidateHosterLifetimeCache() { _hosterLifetimeCache = null; }
|
||||
function bindAccountListeners(container) {
|
||||
_accountListenersBound = true;
|
||||
container.addEventListener('click', (e) => {
|
||||
const header = e.target.closest('[data-hoster-toggle]');
|
||||
if (header && !e.target.closest('button')) {
|
||||
const name = header.dataset.hosterToggle;
|
||||
const group = header.closest('.account-hoster-group');
|
||||
const body = group && group.querySelector('.account-hoster-group-body');
|
||||
const arrow = header.querySelector('.panel-arrow');
|
||||
if (body) {
|
||||
const willOpen = body.style.display === 'none';
|
||||
body.style.display = willOpen ? '' : 'none';
|
||||
if (arrow) arrow.innerHTML = willOpen ? '▼' : '▶';
|
||||
const summary = _summarizeHosterGroup(config.hosters[name] || []);
|
||||
_hosterGroupOpenMemory.set(name, { state: willOpen ? 'open' : 'closed', errorsAtClose: summary.error });
|
||||
}
|
||||
return;
|
||||
}
|
||||
const btn = e.target.closest('button');
|
||||
if (!btn) return;
|
||||
if (btn.dataset.accountToggle) return toggleAccount(btn.dataset.accountToggle);
|
||||
if (btn.dataset.accountEdit) return openAccountModal(btn.dataset.accountEdit);
|
||||
if (btn.dataset.accountDelete) return openDeleteAccountModal(btn.dataset.accountDelete);
|
||||
if (btn.dataset.accountCheck) return checkSingleAccount(btn.dataset.accountCheck);
|
||||
if (btn.dataset.accountReactivate) {
|
||||
const accountId = btn.dataset.accountReactivate;
|
||||
const hoster = btn.dataset.accountReactivateHoster;
|
||||
if (!hoster || !accountId) return;
|
||||
e.stopPropagation();
|
||||
window.api.resetSessionFailedAccount({ hoster, accountId }).then(() => {
|
||||
_sessionFailedKeys.delete(`${hoster}:${accountId}`);
|
||||
renderAccounts();
|
||||
showCopyToast(`${getHosterLabel(hoster)} Account wieder aktiv — nächste Batch verwendet ihn`);
|
||||
}).catch(() => {});
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
let draggedCard = null;
|
||||
@ -3557,9 +3268,6 @@ function getCredsFieldsHtml(authType, account, hoster) {
|
||||
|
||||
function openAccountModal(editAccountId) {
|
||||
editingAccountId = editAccountId || null;
|
||||
// Reset the two-step state — any previously validated snapshot from a prior
|
||||
// modal session is stale and must not allow a no-recheck commit.
|
||||
_resetAccountModalState();
|
||||
const modal = document.getElementById('accountModal');
|
||||
const title = document.getElementById('accountModalTitle');
|
||||
const subtitle = document.getElementById('accountModalSubtitle');
|
||||
@ -3568,7 +3276,6 @@ function openAccountModal(editAccountId) {
|
||||
const credsContainer = document.getElementById('accountCredsFields');
|
||||
const statusEl = document.getElementById('accountModalStatus');
|
||||
const saveBtn = document.getElementById('saveAccountBtn');
|
||||
const labelInput = document.getElementById('accField_label');
|
||||
|
||||
statusEl.textContent = '';
|
||||
statusEl.className = 'account-modal-status';
|
||||
@ -3580,20 +3287,18 @@ function openAccountModal(editAccountId) {
|
||||
title.textContent = 'Account bearbeiten';
|
||||
subtitle.textContent = `Zugangsdaten für ${getAccountDisplayName(found.name, found.account)} bearbeiten.`;
|
||||
hosterRow.style.display = 'none';
|
||||
saveBtn.textContent = 'Prüfen';
|
||||
if (labelInput) labelInput.value = found.account.label || '';
|
||||
saveBtn.textContent = 'Speichern & prüfen';
|
||||
credsContainer.innerHTML = getCredsFieldsHtml(found.account.authType || 'login', found.account, found.name);
|
||||
} else {
|
||||
// Add mode — always show all options (multiple accounts per hoster allowed)
|
||||
title.textContent = 'Account hinzufügen';
|
||||
subtitle.textContent = 'Wähle einen Hoster und gib deine Zugangsdaten ein. Erst „Prüfen" klicken; nach grünem Login wird daraus „Anlegen".';
|
||||
subtitle.textContent = 'Wähle einen Hoster und gib deine Zugangsdaten ein.';
|
||||
hosterRow.style.display = 'flex';
|
||||
saveBtn.textContent = 'Prüfen';
|
||||
saveBtn.textContent = 'Anlegen & prüfen';
|
||||
hosterSelect.innerHTML = HOSTER_ADD_OPTIONS.map(opt =>
|
||||
`<option value="${opt.value}">${escapeHtml(opt.label)}</option>`
|
||||
).join('');
|
||||
const firstOpt = HOSTER_ADD_OPTIONS[0];
|
||||
if (labelInput) labelInput.value = '';
|
||||
credsContainer.innerHTML = getCredsFieldsHtml(firstOpt.authType, {}, firstOpt.value);
|
||||
}
|
||||
|
||||
@ -3605,12 +3310,6 @@ function openAccountModal(editAccountId) {
|
||||
});
|
||||
});
|
||||
|
||||
// Wire field invalidation: any change to a cred field after a green check
|
||||
// drops the validated snapshot so the next click is a re-check, not a commit
|
||||
// of unverified creds. Re-wired here every open because credsContainer's HTML
|
||||
// was replaced.
|
||||
_wireCredFieldInvalidation();
|
||||
|
||||
modal.style.display = 'flex';
|
||||
}
|
||||
|
||||
@ -3618,11 +3317,6 @@ function closeAccountModal() {
|
||||
document.getElementById('accountModal').style.display = 'none';
|
||||
_hideOtpField();
|
||||
editingAccountId = null;
|
||||
// Cancel any pending auto-close so a stale timer can't close a future modal
|
||||
// the user reopens within the auto-close window.
|
||||
if (_autoCloseTimer) { clearTimeout(_autoCloseTimer); _autoCloseTimer = null; }
|
||||
_validatedCreds = null;
|
||||
_accountModalBusy = false;
|
||||
}
|
||||
|
||||
function openDeleteAccountModal(accountId) {
|
||||
@ -3648,249 +3342,130 @@ async function deleteAccount(accountId) {
|
||||
config.hosters[found.name] = accounts.filter(a => a.id !== accountId);
|
||||
}
|
||||
delete accountStatuses[accountId];
|
||||
// saveConfig is async — close the modal immediately so the UI feels
|
||||
// responsive instead of waiting for the atomic write + safeStorage encrypt.
|
||||
// The in-memory config already reflects the delete; the IPC just persists it.
|
||||
closeDeleteModal();
|
||||
await window.api.saveConfig({ hosters: config.hosters });
|
||||
config = await window.api.getConfig();
|
||||
ensureAccountStatusEntries();
|
||||
syncSelectedUploadHosters();
|
||||
if (getAllAccountsFlat().length === 0) renderHealthCheckResults([]);
|
||||
renderAccounts();
|
||||
renderHosterSummary();
|
||||
// Fire-and-forget the persist. The earlier `await getConfig()` round-trip
|
||||
// was redundant (we already have the truth in memory) and was the main
|
||||
// source of perceived lag on add/delete.
|
||||
window.api.saveConfig({ hosters: config.hosters }).catch(() => {});
|
||||
renderHosterModal();
|
||||
renderSettings();
|
||||
closeDeleteModal();
|
||||
}
|
||||
|
||||
function readAccountCredsFromModal(authType) {
|
||||
const label = (document.getElementById('accField_label')?.value || '').trim();
|
||||
if (authType === 'login') {
|
||||
const username = (document.getElementById('accField_username')?.value || '').trim();
|
||||
const password = (document.getElementById('accField_password')?.value || '').trim();
|
||||
return { enabled: !!(username && password), authType: 'login', username, password, label };
|
||||
return { enabled: !!(username && password), authType: 'login', username, password };
|
||||
}
|
||||
// API
|
||||
const apiKey = (document.getElementById('accField_apiKey')?.value || '').trim();
|
||||
return { enabled: !!apiKey, authType: 'api', apiKey, label };
|
||||
}
|
||||
|
||||
// --- Two-step account-modal state machine ---
|
||||
//
|
||||
// Goal: never persist invalid/unverified credentials to config.hosters. The
|
||||
// user clicks "Prüfen" → ephemeral validate-credentials IPC runs → on green
|
||||
// the button label flips to "Anlegen" / "Speichern" → the next click commits
|
||||
// to config. Editing any cred field between the two clicks drops the validated
|
||||
// snapshot so the user can't sneak unverified creds through by editing
|
||||
// post-green.
|
||||
//
|
||||
// Invariants enforced here:
|
||||
// 1. Nothing reaches config.hosters until _validatedCreds matches a green
|
||||
// result for the currently-typed creds.
|
||||
// 2. _accountModalBusy is set SYNCHRONOUSLY at the top of the click handler
|
||||
// before any await — guards against double-clicks producing duplicates.
|
||||
// 3. OTP retry stays ephemeral: each retry re-runs validate-credentials with
|
||||
// the new OTP, no config writes until green.
|
||||
// 4. Edit mode hits the same path → bad edits never overwrite known-good
|
||||
// creds on disk.
|
||||
let _accountModalBusy = false;
|
||||
let _validatedCreds = null; // { hosterName, authType, snapshot, status } when green
|
||||
let _autoCloseTimer = null;
|
||||
// Session token used to ignore stale validate-credentials responses: if the
|
||||
// user closes the modal mid-flight and reopens it, the late .then must NOT
|
||||
// stomp the new session's state. Bumped on every modal reset.
|
||||
let _accountModalSession = 0;
|
||||
|
||||
function _resetAccountModalState() {
|
||||
_accountModalBusy = false;
|
||||
_validatedCreds = null;
|
||||
_accountModalSession++;
|
||||
if (_autoCloseTimer) { clearTimeout(_autoCloseTimer); _autoCloseTimer = null; }
|
||||
}
|
||||
|
||||
function _credsSnapshotKey(authType, creds) {
|
||||
// Identity key for the typed creds — used to detect post-validation edits.
|
||||
// Label changes do NOT invalidate (label is metadata, not a credential).
|
||||
if (authType === 'login') return `login:${creds.username || ''}:${creds.password || ''}`;
|
||||
return `api:${creds.apiKey || ''}`;
|
||||
}
|
||||
|
||||
function _wireCredFieldInvalidation() {
|
||||
// Any change to a cred IDENTITY field (username/password/apiKey) clears the
|
||||
// validated snapshot and reverts the button to "Prüfen". Label edits don't
|
||||
// invalidate (label is metadata, not a credential). OTP edits don't either:
|
||||
// OTP is an ephemeral auth challenge — once doodstream returned "ok" for
|
||||
// these username+password+OTP, the resulting trust is on the creds; the user
|
||||
// clearing or fixing the OTP field afterward shouldn't force a re-prompt.
|
||||
const ids = ['accField_username', 'accField_password', 'accField_apiKey'];
|
||||
for (const id of ids) {
|
||||
const el = document.getElementById(id);
|
||||
if (!el || el.dataset.invalidateBound === '1') continue;
|
||||
el.addEventListener('input', () => {
|
||||
if (_validatedCreds) {
|
||||
_validatedCreds = null;
|
||||
const saveBtn = document.getElementById('saveAccountBtn');
|
||||
if (saveBtn) saveBtn.textContent = 'Prüfen';
|
||||
const statusEl = document.getElementById('accountModalStatus');
|
||||
if (statusEl) { statusEl.textContent = ''; statusEl.className = 'account-modal-status'; }
|
||||
}
|
||||
});
|
||||
el.dataset.invalidateBound = '1';
|
||||
}
|
||||
}
|
||||
|
||||
function _determineHosterContext() {
|
||||
if (editingAccountId) {
|
||||
const found = findAccountById(editingAccountId);
|
||||
if (!found) return null;
|
||||
return { hosterName: found.name, authType: found.account.authType || 'login', accountId: editingAccountId, isEdit: true };
|
||||
}
|
||||
const selectValue = document.getElementById('accountHosterSelect')?.value;
|
||||
if (!selectValue) return null;
|
||||
const opt = HOSTER_ADD_OPTIONS.find(o => o.value === selectValue);
|
||||
if (!opt) return null;
|
||||
return { hosterName: opt.hoster, authType: opt.authType, accountId: null, isEdit: false };
|
||||
return { enabled: !!apiKey, authType: 'api', apiKey };
|
||||
}
|
||||
|
||||
async function saveAccount() {
|
||||
// SYNCHRONOUS re-entry guard — must come before any await. Without this a
|
||||
// double-click before the first IPC returns triggers two saveAccount() calls
|
||||
// and (in the old code) two pushes/two IPCs. _accountModalBusy is checked
|
||||
// synchronously and set synchronously, so the second click no-ops cleanly.
|
||||
if (_accountModalBusy) return;
|
||||
let hosterName, authType, accountId;
|
||||
|
||||
const ctx = _determineHosterContext();
|
||||
if (!ctx) return;
|
||||
const creds = readAccountCredsFromModal(ctx.authType);
|
||||
const statusEl = document.getElementById('accountModalStatus');
|
||||
const saveBtn = document.getElementById('saveAccountBtn');
|
||||
if (editingAccountId) {
|
||||
// Edit existing account
|
||||
const found = findAccountById(editingAccountId);
|
||||
if (!found) return;
|
||||
hosterName = found.name;
|
||||
authType = found.account.authType || 'login';
|
||||
accountId = editingAccountId;
|
||||
} else {
|
||||
// Add new account
|
||||
const selectValue = document.getElementById('accountHosterSelect')?.value;
|
||||
if (!selectValue) return;
|
||||
const opt = HOSTER_ADD_OPTIONS.find(o => o.value === selectValue);
|
||||
if (!opt) return;
|
||||
hosterName = opt.hoster;
|
||||
authType = opt.authType;
|
||||
accountId = `${hosterName}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
||||
}
|
||||
|
||||
const creds = readAccountCredsFromModal(authType);
|
||||
if (!creds.enabled) {
|
||||
const statusEl = document.getElementById('accountModalStatus');
|
||||
statusEl.textContent = 'Bitte Zugangsdaten eingeben.';
|
||||
statusEl.className = 'account-modal-status error';
|
||||
return;
|
||||
}
|
||||
|
||||
// STEP 2: commit. Only fires if a previous "Prüfen" already validated the
|
||||
// EXACT same creds (label changes don't break this — label isn't part of the
|
||||
// credential identity).
|
||||
const snapshotKey = _credsSnapshotKey(ctx.authType, creds);
|
||||
if (_validatedCreds &&
|
||||
_validatedCreds.hosterName === ctx.hosterName &&
|
||||
_validatedCreds.authType === ctx.authType &&
|
||||
_validatedCreds.snapshot === snapshotKey) {
|
||||
// Set busy INSIDE the try so a sync throw on the saveBtn deref above can't
|
||||
// leak _accountModalBusy=true and lock the user out for the session.
|
||||
try {
|
||||
_accountModalBusy = true;
|
||||
saveBtn.disabled = true;
|
||||
saveBtn.textContent = ctx.isEdit ? 'Speichere…' : 'Lege an…';
|
||||
await _commitAccount(ctx, creds, _validatedCreds.status, _validatedCreds.message);
|
||||
} finally {
|
||||
_accountModalBusy = false;
|
||||
if (saveBtn) saveBtn.disabled = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// STEP 1: validate ephemerally. NOTHING is written to config.hosters here.
|
||||
// Snapshot the session token so a stale late-arriving response from a
|
||||
// closed-and-reopened modal can't stomp the new session's state.
|
||||
const mySession = _accountModalSession;
|
||||
_accountModalBusy = true;
|
||||
saveBtn.disabled = true;
|
||||
statusEl.textContent = 'Prüfe Login…';
|
||||
statusEl.className = 'account-modal-status checking';
|
||||
|
||||
const otpInput = document.getElementById('accField_otp');
|
||||
const otp = otpInput ? otpInput.value.trim() : '';
|
||||
const payload = {
|
||||
hoster: ctx.hosterName,
|
||||
authType: ctx.authType,
|
||||
username: creds.username || '',
|
||||
password: creds.password || '',
|
||||
apiKey: creds.apiKey || '',
|
||||
otp
|
||||
};
|
||||
|
||||
let row;
|
||||
try {
|
||||
row = await window.api.validateCredentials(payload);
|
||||
} catch (err) {
|
||||
row = { status: 'error', message: err && err.message ? err.message : 'Prüfung fehlgeschlagen' };
|
||||
} finally {
|
||||
if (mySession === _accountModalSession) {
|
||||
_accountModalBusy = false;
|
||||
if (saveBtn) saveBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Stale response — modal was closed/reopened while we awaited. Drop it.
|
||||
if (mySession !== _accountModalSession) return;
|
||||
|
||||
if (row && row.status === 'otp_required') {
|
||||
statusEl.textContent = row.message || 'OTP wurde an deine E-Mail gesendet.';
|
||||
statusEl.className = 'account-modal-status error';
|
||||
_showOtpField();
|
||||
_wireCredFieldInvalidation(); // OTP input now exists — wire its listener too
|
||||
saveBtn.textContent = 'Mit OTP prüfen';
|
||||
return;
|
||||
}
|
||||
if (row && (row.status === 'ok' || row.status === 'warn')) {
|
||||
statusEl.textContent = row.status === 'warn' ? row.message || 'Prüfung mit Warnung abgeschlossen.' : 'Login erfolgreich! Klick „' + (ctx.isEdit ? 'Speichern' : 'Anlegen') + '" zum Übernehmen.';
|
||||
statusEl.className = 'account-modal-status ok';
|
||||
_hideOtpField();
|
||||
_validatedCreds = {
|
||||
hosterName: ctx.hosterName,
|
||||
authType: ctx.authType,
|
||||
snapshot: snapshotKey,
|
||||
status: row.status,
|
||||
message: row.message || ''
|
||||
};
|
||||
saveBtn.textContent = ctx.isEdit ? 'Speichern' : 'Anlegen';
|
||||
return;
|
||||
}
|
||||
// error
|
||||
const msg = (row && row.message) || 'Login fehlgeschlagen';
|
||||
statusEl.textContent = msg;
|
||||
statusEl.className = 'account-modal-status error';
|
||||
}
|
||||
|
||||
async function _commitAccount(ctx, creds, validatedStatus, validatedMessage) {
|
||||
// Persist the validated creds to config.hosters and close the modal. By the
|
||||
// time we reach this function the validate-credentials IPC has already
|
||||
// returned ok/warn for these exact creds, so we skip a redundant re-check.
|
||||
let accountId;
|
||||
if (!Array.isArray(config.hosters[ctx.hosterName])) config.hosters[ctx.hosterName] = [];
|
||||
if (ctx.isEdit) {
|
||||
accountId = ctx.accountId;
|
||||
const idx = config.hosters[ctx.hosterName].findIndex(a => a.id === accountId);
|
||||
// Save credentials
|
||||
if (!Array.isArray(config.hosters[hosterName])) config.hosters[hosterName] = [];
|
||||
if (editingAccountId) {
|
||||
// Update existing account in array
|
||||
const idx = config.hosters[hosterName].findIndex(a => a.id === editingAccountId);
|
||||
if (idx >= 0) {
|
||||
config.hosters[ctx.hosterName][idx] = { ...config.hosters[ctx.hosterName][idx], ...creds };
|
||||
config.hosters[hosterName][idx] = { ...config.hosters[hosterName][idx], ...creds };
|
||||
}
|
||||
} else {
|
||||
accountId = `${ctx.hosterName}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
||||
config.hosters[ctx.hosterName].push({ id: accountId, ...creds });
|
||||
// Add new account
|
||||
config.hosters[hosterName].push({ id: accountId, ...creds });
|
||||
}
|
||||
await window.api.saveConfig({ hosters: config.hosters });
|
||||
// Skip the redundant await getConfig() — the in-memory state is the source
|
||||
// of truth for what we just wrote, decrypted creds didn't change, and the
|
||||
// round-trip was the main lag source on add/delete.
|
||||
accountStatuses[accountId] = { status: validatedStatus, message: validatedMessage || '' };
|
||||
ensureAccountStatusEntries();
|
||||
config = await window.api.getConfig();
|
||||
|
||||
// Show checking status
|
||||
const statusEl = document.getElementById('accountModalStatus');
|
||||
const saveBtn = document.getElementById('saveAccountBtn');
|
||||
statusEl.textContent = 'Prüfe Login...';
|
||||
statusEl.className = 'account-modal-status checking';
|
||||
saveBtn.disabled = true;
|
||||
|
||||
accountStatuses[accountId] = { status: 'checking', message: '' };
|
||||
syncSelectedUploadHosters();
|
||||
// Targeted updates instead of the 4-panel cascade. For add we need a full
|
||||
// accounts-list re-render (new card) and the hoster summary count; for edit
|
||||
// we can update the single card. Settings panel only needs re-render if its
|
||||
// hoster-summary section is visible — that's covered by renderHosterSummary.
|
||||
if (ctx.isEdit) {
|
||||
updateAccountCard(accountId);
|
||||
} else {
|
||||
renderAccounts();
|
||||
}
|
||||
renderAccounts();
|
||||
renderHosterSummary();
|
||||
// Auto-close after a short pause so the user sees the success state.
|
||||
if (_autoCloseTimer) clearTimeout(_autoCloseTimer);
|
||||
_autoCloseTimer = setTimeout(() => { closeAccountModal(); _autoCloseTimer = null; }, 600);
|
||||
renderHosterModal();
|
||||
renderSettings();
|
||||
|
||||
// Check if OTP was entered (for retry after OTP prompt)
|
||||
const otpInput = document.getElementById('accField_otp');
|
||||
const otp = otpInput ? otpInput.value.trim() : '';
|
||||
|
||||
// Run health check for this specific account (include OTP if provided)
|
||||
const checkPayload = { hoster: hosterName, accountId };
|
||||
if (otp) checkPayload.otp = otp;
|
||||
|
||||
try {
|
||||
const result = await window.api.runHealthCheck({ hosters: [checkPayload] });
|
||||
const rows = result && Array.isArray(result.results) ? result.results : [];
|
||||
const row = rows.find(r => r.accountId === accountId);
|
||||
if (row && row.status === 'otp_required') {
|
||||
// Show OTP input field if not already visible
|
||||
accountStatuses[accountId] = { status: 'error', message: row.message || 'OTP erforderlich' };
|
||||
statusEl.textContent = row.message || 'OTP wurde an deine E-Mail gesendet.';
|
||||
statusEl.className = 'account-modal-status error';
|
||||
_showOtpField();
|
||||
saveBtn.textContent = 'OTP bestätigen';
|
||||
} else if (row && (row.status === 'ok' || row.status === 'warn')) {
|
||||
accountStatuses[accountId] = { status: row.status || 'ok', message: row.message || '' };
|
||||
statusEl.textContent = row.status === 'warn' ? row.message || 'Prüfung mit Warnung abgeschlossen.' : 'Login erfolgreich!';
|
||||
statusEl.className = 'account-modal-status ok';
|
||||
_hideOtpField();
|
||||
setTimeout(() => closeAccountModal(), 1200);
|
||||
} else {
|
||||
const msg = (row && row.message) || 'Login fehlgeschlagen';
|
||||
accountStatuses[accountId] = { status: 'error', message: msg };
|
||||
statusEl.textContent = msg;
|
||||
statusEl.className = 'account-modal-status error';
|
||||
}
|
||||
} catch (err) {
|
||||
accountStatuses[accountId] = { status: 'error', message: err.message || 'Prüfung fehlgeschlagen' };
|
||||
statusEl.textContent = err.message || 'Prüfung fehlgeschlagen';
|
||||
statusEl.className = 'account-modal-status error';
|
||||
} finally {
|
||||
saveBtn.disabled = false;
|
||||
ensureAccountStatusEntries();
|
||||
renderAccounts();
|
||||
renderHosterSummary();
|
||||
renderHosterModal();
|
||||
renderSettings();
|
||||
}
|
||||
}
|
||||
|
||||
function _showOtpField() {
|
||||
@ -3914,8 +3489,6 @@ function _hideOtpField() {
|
||||
// --- History ---
|
||||
async function loadHistory() {
|
||||
const history = await window.api.getHistory();
|
||||
window._historyForStats = history || [];
|
||||
_invalidateHosterLifetimeCache();
|
||||
const container = document.getElementById('historyContainer');
|
||||
|
||||
if (!history || history.length === 0) {
|
||||
@ -4113,32 +3686,17 @@ function renderHistoryTable(container) {
|
||||
${headerCell('date', 'Date')}${headerCell('filename', 'Filename')}${headerCell('host', 'Host')}${headerCell('link', 'Link')}
|
||||
</tr></thead><tbody>`;
|
||||
|
||||
const parts = [html];
|
||||
const len = rows.length;
|
||||
for (let i = 0; i < len; i++) {
|
||||
const row = rows[i];
|
||||
const link = row.link || '';
|
||||
const date = escapeHtml(row.date);
|
||||
const filename = escapeHtml(row.filename);
|
||||
const host = escapeHtml(row.host);
|
||||
const linkHtml = escapeHtml(link);
|
||||
const linkAttr = escapeAttr(link);
|
||||
parts.push('<tr class="history-row');
|
||||
if (row.isError) parts.push(' error');
|
||||
parts.push('" data-link="');
|
||||
parts.push(linkAttr);
|
||||
parts.push('"><td class="col-date">');
|
||||
parts.push(date);
|
||||
parts.push('</td><td class="col-filename">');
|
||||
parts.push(filename);
|
||||
parts.push('</td><td class="col-host">');
|
||||
parts.push(host);
|
||||
parts.push('</td><td class="col-link">');
|
||||
parts.push(linkHtml);
|
||||
parts.push('</td></tr>');
|
||||
}
|
||||
parts.push('</tbody></table>');
|
||||
container.innerHTML = parts.join('');
|
||||
rows.forEach(row => {
|
||||
html += `<tr class="history-row${row.isError ? ' error' : ''}" data-link="${escapeAttr(row.link)}">
|
||||
<td class="col-date">${escapeHtml(row.date)}</td>
|
||||
<td class="col-filename">${escapeHtml(row.filename)}</td>
|
||||
<td class="col-host">${escapeHtml(row.host)}</td>
|
||||
<td class="col-link">${escapeHtml(row.link)}</td>
|
||||
</tr>`;
|
||||
});
|
||||
|
||||
html += '</tbody></table>';
|
||||
container.innerHTML = html;
|
||||
|
||||
// Delegated listeners: bind once per render-target instead of once per
|
||||
// row/header. With a 5000-row history the per-row bind path was a
|
||||
@ -4366,14 +3924,6 @@ function setupListeners() {
|
||||
});
|
||||
document.getElementById('accountModalStatus').textContent = '';
|
||||
document.getElementById('accountModalStatus').className = 'account-modal-status';
|
||||
// Hoster changed → any prior validation is stale by construction. Drop the
|
||||
// snapshot and revert the button so the user has to re-Prüfen.
|
||||
_validatedCreds = null;
|
||||
const sb = document.getElementById('saveAccountBtn');
|
||||
if (sb) sb.textContent = 'Prüfen';
|
||||
// The cred inputs were just replaced — rewire invalidation listeners on
|
||||
// the fresh elements so post-validation edits still revert the button.
|
||||
_wireCredFieldInvalidation();
|
||||
});
|
||||
|
||||
// Delete account modal
|
||||
@ -4518,20 +4068,14 @@ async function importUploadLog() {
|
||||
|
||||
// --- Link operations ---
|
||||
function copyAllLinks() {
|
||||
const rows = queueJobs
|
||||
const links = queueJobs
|
||||
.filter(j => j.status === 'done' && j.result)
|
||||
.map(j => ({
|
||||
fileName: j.fileName || '',
|
||||
hoster: j.hoster || '',
|
||||
url: j.result.download_url || j.result.embed_url || ''
|
||||
}))
|
||||
.filter(r => r.url);
|
||||
if (rows.length === 0) return;
|
||||
const formatEl = document.getElementById('linkExportFormat');
|
||||
const fmt = (formatEl && formatEl.value) || 'plain';
|
||||
const text = window.Stats ? window.Stats.formatLinks(rows, fmt) : rows.map(r => r.url).join('\n');
|
||||
window.api.copyToClipboard(text);
|
||||
showCopyToast(`${rows.length} Link${rows.length === 1 ? '' : 's'} als ${fmt.toUpperCase()} kopiert`);
|
||||
.map(j => j.result.download_url || j.result.embed_url || '')
|
||||
.filter(Boolean);
|
||||
if (links.length > 0) {
|
||||
window.api.copyToClipboard(links.join('\n'));
|
||||
showCopyToast(`${links.length} Links kopiert`);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Utilities ---
|
||||
|
||||
@ -93,14 +93,6 @@
|
||||
|
||||
<div class="queue-actions" id="queueActions" style="display:none">
|
||||
<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="importLogBtn" title="Log importieren — bereits hochgeladene aus Queue entfernen">Log importieren</button>
|
||||
</div>
|
||||
@ -195,10 +187,6 @@
|
||||
<label>Hoster</label>
|
||||
<select class="key-input" id="accountHosterSelect" style="max-width:300px"></select>
|
||||
</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 class="account-modal-status" id="accountModalStatus"></div>
|
||||
</div>
|
||||
@ -342,26 +330,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal" id="batchSummaryModal" style="display:none">
|
||||
<div class="modal-content" style="max-width:680px">
|
||||
<div class="modal-header">
|
||||
<h2>Batch-Zusammenfassung</h2>
|
||||
<button class="icon-btn" id="batchSummaryClose" aria-label="Schließen">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="batchSummaryList"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" id="batchSummaryRetryTransient">Transiente erneut hochladen</button>
|
||||
<button class="btn btn-primary" id="batchSummaryRetryAll">Alle Fehler erneut versuchen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="../lib/queue-prune.js"></script>
|
||||
<script src="../lib/queue-dedup.js"></script>
|
||||
<script src="../lib/log-mode.js"></script>
|
||||
<script src="../lib/stats.js"></script>
|
||||
<script src="../lib/throttled-cache.js"></script>
|
||||
<script src="../lib/coalesced-set.js"></script>
|
||||
<script src="app.js"></script>
|
||||
|
||||
@ -727,7 +727,6 @@ body.col-resizing, body.col-resizing * { cursor: col-resize !important; user-sel
|
||||
}
|
||||
.key-input:focus, .hs-input:focus { border-color: var(--accent); outline: none; }
|
||||
.hs-input { max-width: 100px; }
|
||||
select.hs-input { max-width: none; width: auto; min-width: 140px; }
|
||||
.hint { font-size: 10px; color: var(--text-dim); }
|
||||
.settings-section-label {
|
||||
font-size: 10px;
|
||||
@ -876,96 +875,17 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; }
|
||||
|
||||
.account-hoster-group {
|
||||
margin-bottom: 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-card);
|
||||
overflow: hidden;
|
||||
}
|
||||
.account-hoster-group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
background: var(--bg-card);
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.account-hoster-group-header:hover { background: var(--bg-card-hover); }
|
||||
.account-hoster-group-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
flex: 1;
|
||||
}
|
||||
.account-hoster-group-count {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.account-hoster-group-meta {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
background: rgba(255,255,255,0.04);
|
||||
}
|
||||
.account-hoster-group-meta.error {
|
||||
color: var(--danger, #e57373);
|
||||
background: rgba(229, 115, 115, 0.12);
|
||||
}
|
||||
.account-session-paused {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 10px;
|
||||
color: #f0c36c;
|
||||
background: rgba(240, 195, 108, 0.12);
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
margin-left: 6px;
|
||||
}
|
||||
.account-session-reactivate {
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
padding: 0 2px;
|
||||
}
|
||||
.account-session-reactivate:hover { color: #fff; }
|
||||
.account-session-paused-card { opacity: 0.85; }
|
||||
.batch-cat {
|
||||
margin-bottom: 10px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
background: rgba(255,255,255,0.03);
|
||||
}
|
||||
.batch-cat-head { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; font-size: 13px; }
|
||||
.batch-cat-count { color: var(--text-muted); font-variant-numeric: tabular-nums; }
|
||||
.batch-cat-tag { font-size: 10px; padding: 1px 6px; border-radius: 4px; background: rgba(255,255,255,0.06); color: var(--text-muted); }
|
||||
.batch-cat-tag.retryable { background: rgba(76, 175, 80, 0.18); color: #a5d6a7; }
|
||||
.batch-cat-list { margin: 0; padding-left: 18px; font-size: 11px; color: var(--text-muted); }
|
||||
.batch-cat-list em { color: var(--text-muted); font-style: italic; }
|
||||
.account-hoster-group-body {
|
||||
padding: 8px;
|
||||
border-top: 1px solid var(--border);
|
||||
margin-bottom: 6px;
|
||||
padding-left: 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 {
|
||||
text-align: center;
|
||||
|
||||
@ -1,100 +0,0 @@
|
||||
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);
|
||||
});
|
||||
@ -78,50 +78,6 @@ test('resolveLogFileName: unknown mode is treated as single', () => {
|
||||
);
|
||||
});
|
||||
|
||||
// --- 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', () => {
|
||||
|
||||
@ -1,132 +0,0 @@
|
||||
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);
|
||||
});
|
||||
@ -1,94 +0,0 @@
|
||||
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,7 +31,6 @@ describe('UploadManager', () => {
|
||||
const origRequire = module.constructor.prototype.require;
|
||||
const hosters = require('../lib/hosters');
|
||||
hosters.uploadFile = mockUploadFile;
|
||||
hosters.prefetchBaseline = async () => null;
|
||||
|
||||
// Mock fs.statSync for test file paths
|
||||
const fs = require('fs');
|
||||
@ -56,8 +55,8 @@ describe('UploadManager', () => {
|
||||
]);
|
||||
|
||||
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(events.length > 0, 'should emit at least one progress event');
|
||||
});
|
||||
|
||||
it('emits batch-done with correct summary', async () => {
|
||||
|
||||
@ -1,195 +0,0 @@
|
||||
// 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