Compare commits

..

No commits in common. "master" and "v3.3.38" have entirely different histories.

19 changed files with 705 additions and 1820 deletions

12
.gitignore vendored
View File

@ -2,15 +2,3 @@ node_modules/
release/ release/
__pycache__/ __pycache__/
*.pyc *.pyc
electron-config.json
electron-config.json.bak
electron-config.json.tmp
electron-config.pre-import-*.json
*.log
debug.log
fileuploader.log
account-rotation.log
doodstream-debug.log
upload-debug.log
release-*.log

428
electron-config.json Normal file
View 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"
}
]
}
]
}
]
}

View File

@ -58,7 +58,6 @@ const DEFAULTS = {
shutdownAfterFinish: 'nothing', // nothing | sleep | shutdown | restart shutdownAfterFinish: 'nothing', // nothing | sleep | shutdown | restart
logFilePath: '', logFilePath: '',
sessionLog: false, // legacy boolean (kept for back-compat reads); normalized into logMode on load 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 // 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 // would seed logMode='single' for every load, which would beat (and silently
// erase) the legacy sessionLog:true → "daily" migration. normalizeLogMode in // erase) the legacy sessionLog:true → "daily" migration. normalizeLogMode in

View File

@ -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 };

View File

@ -499,28 +499,25 @@ async function _resolveDoodstreamUploadByName(apiKey, fileName, baselineCodes, s
return null; 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]; const config = HOSTER_CONFIGS[hosterName];
if (!config) throw new Error(`Unbekannter Hoster: ${hosterName}`); if (!config) throw new Error(`Unbekannter Hoster: ${hosterName}`);
// For byse: snapshot the current file-code list so the post-upload poller
// can identify new arrivals even when the initial POST response has an
// empty filecode.
let byseBaseline = null; let byseBaseline = null;
if (hosterName === 'byse.sx') { if (hosterName === 'byse.sx') {
if (opts && opts.byseBaseline instanceof Set) {
byseBaseline = opts.byseBaseline;
} else {
const baseline = await _fetchByseFileList(apiKey, signal); const baseline = await _fetchByseFileList(apiKey, signal);
byseBaseline = new Set(baseline.map(f => f.file_code)); byseBaseline = new Set(baseline.map(f => f.file_code));
} }
} // Doodstream: same snapshot so a codeless upload response can be recovered by
// matching a newly-appeared file in the account by name (see below).
let doodBaseline = null; let doodBaseline = null;
if (hosterName === 'doodstream.com') { if (hosterName === 'doodstream.com') {
if (opts && opts.doodBaseline instanceof Set) {
doodBaseline = opts.doodBaseline;
} else {
const baseline = await _fetchDoodstreamFileList(apiKey, signal); const baseline = await _fetchDoodstreamFileList(apiKey, signal);
doodBaseline = new Set(baseline.map(f => f.file_code)); doodBaseline = new Set(baseline.map(f => f.file_code));
} }
}
// Step 1: Get upload server // Step 1: Get upload server
const uploadUrl = await getUploadServer(hosterName, config, apiKey, signal); const uploadUrl = await getUploadServer(hosterName, config, apiKey, signal);
@ -583,17 +580,6 @@ async function uploadFile(hosterName, filePath, apiKey, onProgress, signal, thro
try { try {
result = config.parseResult(payload); result = config.parseResult(payload);
} catch (err) { } catch (err) {
if (err && typeof err === 'object' && !err.diagnostic) {
try {
err.diagnostic = {
hoster: hosterName,
http: statusCode,
contentType: (headers && headers['content-type']) || null,
payloadSnippet: JSON.stringify(payload).slice(0, 1000),
uploadUrl: targetUrl
};
} catch { /* JSON cycle — skip diagnostic */ }
}
parseErr = err; parseErr = err;
} }
if (result && (result.file_code || result.download_url || result.embed_url)) { if (result && (result.file_code || result.download_url || result.embed_url)) {
@ -650,23 +636,8 @@ async function uploadFile(hosterName, filePath, apiKey, onProgress, signal, thro
throw new Error(msg); throw new Error(msg);
} }
async function prefetchBaseline(hosterName, apiKey, signal) {
try {
if (hosterName === 'byse.sx') {
const baseline = await _fetchByseFileList(apiKey, signal);
return new Set(baseline.map(f => f.file_code));
}
if (hosterName === 'doodstream.com') {
const baseline = await _fetchDoodstreamFileList(apiKey, signal);
return new Set(baseline.map(f => f.file_code));
}
} catch { /* leave caller to fall back to per-job fetch */ }
return null;
}
module.exports = { module.exports = {
uploadFile, uploadFile,
prefetchBaseline,
HOSTER_CONFIGS, HOSTER_CONFIGS,
__test: { __test: {
extractUploadServerUrl, extractUploadServerUrl,

View File

@ -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);

View File

@ -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 };

View File

@ -233,20 +233,7 @@ async function installUpdate(onProgress) {
// Stage: done // Stage: done
if (onProgress) onProgress({ stage: 'done', percent: 100 }); if (onProgress) onProgress({ stage: 'done', percent: 100 });
const _doQuit = () => setTimeout(() => app.quit(), 900); setTimeout(() => app.quit(), 900);
const _getActive = () => {
try { return globalThis._mhuUploadManagerRef && globalThis._mhuUploadManagerRef.getActiveJobCount ? globalThis._mhuUploadManagerRef.getActiveJobCount() : 0; }
catch { return 0; }
};
if (_getActive() > 0) {
const POLL_MS = 3000;
const poller = setInterval(() => {
if (_getActive() === 0) { clearInterval(poller); _doQuit(); }
}, POLL_MS);
setTimeout(() => { try { clearInterval(poller); } catch {} _doQuit(); }, 30 * 60 * 1000);
} else {
_doQuit();
}
} catch (err) { } catch (err) {
if (onProgress) onProgress({ stage: 'error', error: err.message }); if (onProgress) onProgress({ stage: 'error', error: err.message });

View File

@ -2,14 +2,13 @@ const { EventEmitter } = require('events');
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
const crypto = require('crypto'); const crypto = require('crypto');
const { uploadFile, prefetchBaseline } = require('./hosters'); const { uploadFile } = require('./hosters');
const VidmolyUploader = require('./vidmoly-upload'); const VidmolyUploader = require('./vidmoly-upload');
const VoeUploader = require('./voe-upload'); const VoeUploader = require('./voe-upload');
const DoodstreamUploader = require('./doodstream-upload'); const DoodstreamUploader = require('./doodstream-upload');
const ClouddropUploader = require('./clouddrop-upload'); const ClouddropUploader = require('./clouddrop-upload');
const Semaphore = require('./semaphore'); const Semaphore = require('./semaphore');
const Throttle = require('./throttle'); const Throttle = require('./throttle');
const { probeFileHead } = require('./file-probe');
const DEFAULT_SETTINGS = { const DEFAULT_SETTINGS = {
retries: 3, retries: 3,
@ -42,7 +41,6 @@ class UploadManager extends EventEmitter {
this._failedAccounts = new Map(); // hoster -> Set of failed accountIds this._failedAccounts = new Map(); // hoster -> Set of failed accountIds
this._accountOverrides = new Map(); // hoster -> fallback account object this._accountOverrides = new Map(); // hoster -> fallback account object
this._doodApiKeyCache = new Map(); // accountId/username -> derived doodstream API key ('' = tried, none) this._doodApiKeyCache = new Map(); // accountId/username -> derived doodstream API key ('' = tried, none)
this._baselineCache = new Map(); // hoster:apiKey -> Promise<Set<file_code>> (one fetch shared across all jobs in batch)
} }
switchAccount(hoster, fallbackAccount) { switchAccount(hoster, fallbackAccount) {
@ -67,20 +65,6 @@ class UploadManager extends EventEmitter {
return this._accountOverrides.get(hoster) || null; return this._accountOverrides.get(hoster) || null;
} }
getActiveJobCount() {
return this.activeJobs.size;
}
clearFailedAccount(hoster, accountId) {
return this._failedAccounts.delete(`${hoster}:${accountId}`);
}
clearAllFailedAccounts() {
const n = this._failedAccounts.size;
this._failedAccounts.clear();
return n;
}
// True if the hoster has a usable override stored that differs from the // True if the hoster has a usable override stored that differs from the
// account currently in the task and isn't itself already marked failed. // account currently in the task and isn't itself already marked failed.
// Used by the retry loop to decide "retry on same account vs break to // Used by the retry loop to decide "retry on same account vs break to
@ -283,7 +267,6 @@ class UploadManager extends EventEmitter {
this.jobAbortControllers.clear(); this.jobAbortControllers.clear();
this.cancelledJobIds.clear(); this.cancelledJobIds.clear();
this._doodApiKeyCache.clear(); // re-derive doodstream keys fresh each batch this._doodApiKeyCache.clear(); // re-derive doodstream keys fresh each batch
this._baselineCache.clear(); // re-fetch baselines per batch (a long batch could outlast remote-side relevance)
this.semaphores = {}; this.semaphores = {};
this.globalSemaphore = null; this.globalSemaphore = null;
this.globalThrottle = null; this.globalThrottle = null;
@ -314,30 +297,18 @@ class UploadManager extends EventEmitter {
this._batchResults = results; this._batchResults = results;
this._additionalPromises = []; // Track jobs added mid-batch via addJobs() this._additionalPromises = []; // Track jobs added mid-batch via addJobs()
const DEDUP_CHUNK = 200; for (const task of tasks) {
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); const fileName = path.basename(task.file);
if (!results.has(task.file)) {
let size = 0; let size = 0;
try { size = fs.statSync(task.file).size; } catch {} try { size = fs.statSync(task.file).size; } catch {}
results.set(task.file, { name: fileName, size, results: [] }); results.set(task.file, { name: fileName, size, results: [] });
} }
} }
if (end < tasks.length) await new Promise(setImmediate);
}
this._startStatsTimer(); this._startStatsTimer();
const SPAWN_CHUNK = 100; const promises = tasks.map((task) => this._runJob(task, results, signal));
const promises = [];
for (let i = 0; i < tasks.length; i += SPAWN_CHUNK) {
const end = Math.min(i + SPAWN_CHUNK, tasks.length);
for (let j = i; j < end; j++) promises.push(this._runJob(tasks[j], results, signal));
if (end < tasks.length) await new Promise(setImmediate);
}
await Promise.allSettled(promises); await Promise.allSettled(promises);
// Wait for any jobs added mid-batch via addJobs() // Wait for any jobs added mid-batch via addJobs()
while (this._additionalPromises.length > 0) { while (this._additionalPromises.length > 0) {
@ -373,12 +344,7 @@ class UploadManager extends EventEmitter {
const fileName = path.basename(task.file); const fileName = path.basename(task.file);
let fileSize = 0; let fileSize = 0;
let fileNotFound = false; let fileNotFound = false;
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 maxAttempts = Math.max(1, (settings.retries || 0) + 1);
const jobAbortController = new AbortController(); const jobAbortController = new AbortController();
@ -443,30 +409,26 @@ class UploadManager extends EventEmitter {
return; return;
} }
// The initial 'queued' emit per job is suppressed: with N=2000+ tasks this._emitProgress(uploadId, fileName, task.hoster, { accountId: task.accountId,
// it produces 2000+ main→renderer IPCs back-to-back at startBatch and jobId,
// freezes the renderer event loop for tens of seconds. The renderer status: 'queued',
// already holds each job in 'queued'/'preview' state from its own progress: 0,
// queueJobs array; the first event it actually needs from main is the bytesUploaded: 0,
// 'getting-server' / 'uploading' transition for the jobs that the bytesTotal: fileSize,
// semaphore lets through. 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); await hosterSemaphore.acquire(signal);
hosterSlotAcquired = true; hosterSlotAcquired = true;
let fileProbe = null;
try {
fileProbe = await probeFileHead(task.file, 64);
} catch (err) {
fileProbe = { ok: false, error: err && err.message, kind: 'unreadable' };
}
this._rotLog('upload-start', {
jobId, hoster: task.hoster, accountId: task.accountId, fileName,
fileSize,
detectedKind: fileProbe && fileProbe.kind ? fileProbe.kind : 'unknown',
isVideoLike: !!(fileProbe && fileProbe.isVideoLike),
headHex: fileProbe && fileProbe.headHex ? fileProbe.headHex.slice(0, 32) : null
});
if (globalSemaphore) { if (globalSemaphore) {
await globalSemaphore.acquire(signal); await globalSemaphore.acquire(signal);
globalSlotAcquired = true; globalSlotAcquired = true;
@ -558,7 +520,6 @@ class UploadManager extends EventEmitter {
speedAbort = new AbortController(); speedAbort = new AbortController();
uploadSignalBundle = this._combineManySignals([signal, speedAbort.signal]); uploadSignalBundle = this._combineManySignals([signal, speedAbort.signal]);
speedMonitor = setInterval(() => { speedMonitor = setInterval(() => {
try {
if (currentSpeedKbs > 0 && currentSpeedKbs < settings.restartBelowKbs) { if (currentSpeedKbs > 0 && currentSpeedKbs < settings.restartBelowKbs) {
if (!lowSpeedSince) lowSpeedSince = Date.now(); if (!lowSpeedSince) lowSpeedSince = Date.now();
if (Date.now() - lowSpeedSince > 6000) { if (Date.now() - lowSpeedSince > 6000) {
@ -567,7 +528,6 @@ class UploadManager extends EventEmitter {
} else { } else {
lowSpeedSince = 0; lowSpeedSince = 0;
} }
} catch (e) { this._rotLog('speed-monitor-error', { jobId, error: e && e.message }); }
}, 2000); }, 2000);
} }
@ -581,11 +541,10 @@ class UploadManager extends EventEmitter {
const PROGRESS_EMIT_INTERVAL = 250; // ms throttle UI updates const PROGRESS_EMIT_INTERVAL = 250; // ms throttle UI updates
const progressCb = (bytesUploaded, bytesTotal) => { const progressCb = (bytesUploaded, bytesTotal) => {
try {
const now = Date.now(); const now = Date.now();
const elapsed = Math.round((now - jobStart) / 1000); const elapsed = Math.round((now - jobStart) / 1000);
const timeDelta = (now - lastSpeedTime) / 1000; const timeDelta = (now - lastSpeedTime) / 1000;
if (Number.isFinite(timeDelta) && timeDelta >= 1) { if (timeDelta >= 1) {
const bytesDelta = bytesUploaded - lastBytes; const bytesDelta = bytesUploaded - lastBytes;
currentSpeedKbs = Math.round(bytesDelta / timeDelta / 1024); currentSpeedKbs = Math.round(bytesDelta / timeDelta / 1024);
lastBytes = bytesUploaded; lastBytes = bytesUploaded;
@ -595,6 +554,7 @@ class UploadManager extends EventEmitter {
activeEntry.speedKbs = currentSpeedKbs; activeEntry.speedKbs = currentSpeedKbs;
activeEntry.bytesUploaded = bytesUploaded; activeEntry.bytesUploaded = bytesUploaded;
// Throttle progress emissions to reduce IPC + rendering overhead
if (now - lastEmitTime < PROGRESS_EMIT_INTERVAL) return; if (now - lastEmitTime < PROGRESS_EMIT_INTERVAL) return;
lastEmitTime = now; lastEmitTime = now;
@ -616,7 +576,6 @@ class UploadManager extends EventEmitter {
attempt, attempt,
maxAttempts maxAttempts
}); });
} catch { /* progress callbacks must never throw — swallowing is correct, the stream keeps going */ }
}; };
const result = await this._executeUpload(task, progressCb, uploadSignalBundle.signal, throttle); const result = await this._executeUpload(task, progressCb, uploadSignalBundle.signal, throttle);
@ -637,23 +596,6 @@ class UploadManager extends EventEmitter {
this.activeJobs.delete(uploadId); this.activeJobs.delete(uploadId);
const isSpeedRestart = speedAbort && speedAbort.signal.aborted && !signal.aborted; const isSpeedRestart = speedAbort && speedAbort.signal.aborted && !signal.aborted;
if (!signal.aborted && !isSpeedRestart) {
const diag = (err && typeof err === 'object' && err.diagnostic) || {};
this._rotLog('upload-failure', {
jobId, hoster: task.hoster, accountId: task.accountId, fileName,
attempt,
error: err && err.message ? err.message : String(err),
fileRejected: !!(err && err.fileRejected),
accountError: !!(err && err.accountError),
hosterTransient: !!(err && err.hosterTransient),
http: diag.http || null,
contentType: diag.contentType || null,
detectedKind: (typeof fileProbe !== 'undefined' && fileProbe && fileProbe.kind) ? fileProbe.kind : null,
isVideoLike: !!(typeof fileProbe !== 'undefined' && fileProbe && fileProbe.isVideoLike),
headHex: (typeof fileProbe !== 'undefined' && fileProbe && fileProbe.headHex) ? fileProbe.headHex.slice(0, 32) : null,
payloadSnippet: diag.payloadSnippet || null
});
}
if (signal.aborted) { if (signal.aborted) {
lastError = new Error('Abgebrochen'); lastError = new Error('Abgebrochen');
break; break;
@ -940,9 +882,7 @@ class UploadManager extends EventEmitter {
const apiKey = await this._resolveDoodstreamApiKey(task); const apiKey = await this._resolveDoodstreamApiKey(task);
if (apiKey) { if (apiKey) {
this._rotLog('doodstream-via-api', { accountId: task.accountId, fileName: path.basename(task.file) }); this._rotLog('doodstream-via-api', { accountId: task.accountId, fileName: path.basename(task.file) });
return uploadFile('doodstream.com', task.file, apiKey, progressCb, signal, throttle, { return uploadFile('doodstream.com', task.file, apiKey, progressCb, signal, throttle);
doodBaseline: await this._getBaseline('doodstream.com', apiKey, signal)
});
} }
this._rotLog('doodstream-via-web', { accountId: task.accountId, fileName: path.basename(task.file) }); this._rotLog('doodstream-via-web', { accountId: task.accountId, fileName: path.basename(task.file) });
const dood = new DoodstreamUploader(); const dood = new DoodstreamUploader();
@ -952,23 +892,10 @@ class UploadManager extends EventEmitter {
const clouddrop = new ClouddropUploader(task.apiKey); const clouddrop = new ClouddropUploader(task.apiKey);
return clouddrop.upload(task.file, progressCb, signal, throttle); return clouddrop.upload(task.file, progressCb, signal, throttle);
} else { } else {
const baselineOpts = {}; return uploadFile(task.hoster, task.file, task.apiKey, progressCb, signal, throttle);
if (task.hoster === 'byse.sx') baselineOpts.byseBaseline = await this._getBaseline('byse.sx', task.apiKey, signal);
if (task.hoster === 'doodstream.com') baselineOpts.doodBaseline = await this._getBaseline('doodstream.com', task.apiKey, signal);
return uploadFile(task.hoster, task.file, task.apiKey, progressCb, signal, throttle, baselineOpts);
} }
} }
_getBaseline(hosterName, apiKey, signal) {
if (!apiKey) return Promise.resolve(null);
const key = `${hosterName}:${apiKey}`;
let pending = this._baselineCache.get(key);
if (pending) return pending;
pending = prefetchBaseline(hosterName, apiKey, signal);
this._baselineCache.set(key, pending);
return pending;
}
// Resolve (and cache per batch) the doodstream API key for a login-only // Resolve (and cache per batch) the doodstream API key for a login-only
// account by logging in once and scraping+validating it from the session. // account by logging in once and scraping+validating it from the session.
// Returns the key string, or '' when none could be derived (cached either way // Returns the key string, or '' when none could be derived (cached either way
@ -998,7 +925,7 @@ class UploadManager extends EventEmitter {
_startStatsTimer() { _startStatsTimer() {
if (this.statsInterval) clearInterval(this.statsInterval); if (this.statsInterval) clearInterval(this.statsInterval);
this.statsInterval = setInterval(() => { this.statsInterval = setInterval(() => {
try { // Single pass over active jobs instead of two.
let globalSpeedKbs = 0; let globalSpeedKbs = 0;
let activeCount = 0; let activeCount = 0;
let inProgressBytes = 0; let inProgressBytes = 0;
@ -1018,7 +945,6 @@ class UploadManager extends EventEmitter {
activeJobs: activeCount, activeJobs: activeCount,
pendingJobs: Object.values(this.semaphores).reduce((sum, semaphore) => sum + semaphore.pending, 0) pendingJobs: Object.values(this.semaphores).reduce((sum, semaphore) => sum + semaphore.pending, 0)
}); });
} catch { /* never let a stats tick crash the timer + caller */ }
}, 1000); }, 1000);
} }

574
main.js
View File

@ -16,7 +16,6 @@ const FolderMonitor = require('./lib/folder-monitor');
const RemoteServer = require('./lib/remote-server'); const RemoteServer = require('./lib/remote-server');
const { maybeRotateLogFile } = require('./lib/log-rotation'); const { maybeRotateLogFile } = require('./lib/log-rotation');
const { hosterLogToFileEnabled } = require('./lib/log-policy'); const { hosterLogToFileEnabled } = require('./lib/log-policy');
const { sanitizeConfig, buildSupportBundleText } = require('./lib/support-bundle');
let mainWindow; let mainWindow;
let _lastImportPath = null; let _lastImportPath = null;
@ -116,49 +115,6 @@ function debugLog(msg) {
} catch {} } 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 // Dedicated account-rotation log so users can trace fallback decisions
// without wading through general debug output. Writes to account-rotation.log // without wading through general debug output. Writes to account-rotation.log
// in the same directory as fileuploader.log (honors user's configured path). // in the same directory as fileuploader.log (honors user's configured path).
@ -198,29 +154,27 @@ function _flushRotLog() {
write(0); 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'),
crashLog: path.join(dir, 'crash.log'),
logDir: dir
};
}
function rotLog(msg, ts) { function rotLog(msg, ts) {
try { try {
const iso = new Date(ts || Date.now()).toISOString(); const iso = new Date(ts || Date.now()).toISOString();
const line = `[${iso}] ${msg}\n`; const line = `[${iso}] ${msg}\n`;
_rotLogBuffer.push(line); // Write synchronously. Rotation events are rare (a handful per batch) so
if (!_rotLogFlushTimer) { // the batching optimization from debugLog doesn't buy us anything, and
_rotLogFlushTimer = setTimeout(() => { _rotLogFlushTimer = null; _flushRotLog(); }, 500); // 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`); _debugLogBuffer.push(`[${iso}] [ROT] ${msg}\n`);
if (!_debugLogFlushTimer) { if (!_debugLogFlushTimer) {
_debugLogFlushTimer = setTimeout(() => { _debugLogFlushTimer = null; _flushDebugLog(); }, 500); _debugLogFlushTimer = setTimeout(() => { _debugLogFlushTimer = null; _flushDebugLog(); }, 500);
@ -228,62 +182,11 @@ function rotLog(msg, ts) {
} catch {} } catch {}
} }
function safeSend(channel, data) { // Catch unhandled rejections from fire-and-forget async calls
if (!mainWindow || mainWindow.isDestroyed()) return false;
try {
safeSend(channel, data);
return true;
} catch (err) {
debugLog(`safeSend(${channel}) failed: ${err && err.message ? err.message : err}`);
return false;
}
}
function _writeCrashLog(prefix, err, extra) {
try {
const ts = new Date().toISOString();
const line = `[${ts}] ${prefix} ${err && err.stack ? err.stack : (err && err.message) || String(err)}${extra ? ' :: ' + JSON.stringify(extra) : ''}\n`;
try {
const target = getDebugLogPath();
fs.appendFileSync(target, line, 'utf-8');
} catch {}
try {
const crashDir = path.dirname(getDebugLogPath());
fs.appendFileSync(path.join(crashDir, 'crash.log'), line, 'utf-8');
} catch {}
} catch {}
}
process.on('unhandledRejection', (reason) => { process.on('unhandledRejection', (reason) => {
debugLog(`UNHANDLED REJECTION: ${reason && reason.stack ? reason.stack : reason}`); debugLog(`UNHANDLED REJECTION: ${reason && reason.stack ? reason.stack : reason}`);
_writeCrashLog('UNHANDLED REJECTION', reason);
}); });
process.on('uncaughtException', (err, origin) => {
_writeCrashLog('UNCAUGHT EXCEPTION (' + origin + ')', err);
debugLog(`UNCAUGHT EXCEPTION (${origin}): ${err && err.stack ? err.stack : err}`);
});
process.on('exit', (code) => {
try { _writeCrashLog('PROCESS EXIT', new Error('code=' + code)); } catch {}
});
process.on('warning', (warning) => {
debugLog(`PROCESS WARNING: ${warning.name} ${warning.message}`);
});
for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP', 'SIGBREAK']) {
try {
process.on(sig, () => {
_writeCrashLog('SIGNAL ' + sig, new Error('process received ' + sig));
try {
if (_debugLogBuffer.length) fs.appendFileSync(getDebugLogPath(), _debugLogBuffer.join(''), 'utf-8');
} catch {}
process.exit(0);
});
} catch {}
}
function withTimeout(promise, timeoutMs, label) { function withTimeout(promise, timeoutMs, label) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const timer = setTimeout(() => { const timer = setTimeout(() => {
@ -483,7 +386,9 @@ function _flushUploadLog() {
// next session writes here directly (no more fallback ladder) and // next session writes here directly (no more fallback ladder) and
// the Settings input reflects reality. // the Settings input reflects reality.
_persistFallbackLogPath(target.path); _persistFallbackLogPath(target.path);
safeSend('upload-log-fallback', { fallbackPath: target.path }); if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('upload-log-fallback', { fallbackPath: target.path });
}
} }
if (_uploadLogBuffer.length && !_uploadLogFlushTimer) setImmediate(_flushUploadLog); if (_uploadLogBuffer.length && !_uploadLogFlushTimer) setImmediate(_flushUploadLog);
}); });
@ -511,7 +416,9 @@ function _persistFallbackLogPath(workingPath) {
cfg.globalSettings = gs; cfg.globalSettings = gs;
configStore.save({ globalSettings: gs }).catch(() => {}); configStore.save({ globalSettings: gs }).catch(() => {});
_invalidateUploadLogTargetCache(); _invalidateUploadLogTargetCache();
safeSend('log-path-auto-updated', { logFilePath: toSave }); if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('log-path-auto-updated', { logFilePath: toSave });
}
} catch (err) { } catch (err) {
debugLog(`persist fallback logpath failed: ${err.message}`); debugLog(`persist fallback logpath failed: ${err.message}`);
} }
@ -929,52 +836,35 @@ async function runHosterHealthCheck(config, requestedChecks) {
checks = requestedChecks; checks = requestedChecks;
} }
{ const results = await Promise.all(checks.map(async ({ hoster, accountId, otp }) => {
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' };
}
if (!allowed.includes(hoster)) { if (!allowed.includes(hoster)) {
return { hoster, accountId, status: 'skipped', message: 'Kein Health-Check fuer diesen Hoster' }; return { hoster, accountId, status: 'skipped', message: 'Kein Health-Check fuer diesen Hoster' };
} }
// Find specific account
const accounts = config.hosters[hoster]; const accounts = config.hosters[hoster];
const hosterConfig = Array.isArray(accounts) ? accounts.find(a => a.id === accountId) : null; const hosterConfig = Array.isArray(accounts) ? accounts.find(a => a.id === accountId) : null;
try { 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 }; return { hoster, accountId, ...result };
} catch (err) { } catch (err) {
return { hoster, accountId, status: 'error', message: err && err.message ? err.message : 'Health-Check fehlgeschlagen' }; 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 }; return { checkedAt: new Date().toISOString(), results };
} }
@ -995,51 +885,6 @@ function createWindow() {
}); });
mainWindow.webContents.setBackgroundThrottling(false); mainWindow.webContents.setBackgroundThrottling(false);
mainWindow.webContents.on('render-process-gone', (_event, details) => {
_writeCrashLog('RENDER PROCESS GONE', new Error(details.reason || 'unknown'), details);
debugLog(`RENDER PROCESS GONE: reason=${details.reason} exitCode=${details.exitCode}`);
if (mainWindow && !mainWindow.isDestroyed()) {
try {
const choice = dialog.showMessageBoxSync(mainWindow, {
type: 'error',
title: 'Renderer abgestürzt',
message: `Der Renderer-Prozess ist abgestürzt (${details.reason}).`,
detail: 'Bitte Diagnose-Paket exportieren und einsenden. Klick "Neu laden" um die UI wiederherzustellen — laufende Uploads im Main-Process bleiben aktiv.',
buttons: ['Neu laden', 'Beenden'],
defaultId: 0,
cancelId: 1
});
if (choice === 0) {
mainWindow.webContents.reload();
} else {
app.exit(1);
}
} catch {
try { mainWindow.webContents.reload(); } catch {}
}
}
});
mainWindow.webContents.on('unresponsive', () => {
_writeCrashLog('RENDERER UNRESPONSIVE', new Error('webContents unresponsive'));
debugLog('RENDERER UNRESPONSIVE');
});
mainWindow.webContents.on('responsive', () => {
debugLog('RENDERER RESPONSIVE AGAIN');
});
mainWindow.webContents.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL) => {
_writeCrashLog('DID-FAIL-LOAD', new Error(errorDescription), { errorCode, validatedURL });
debugLog(`DID-FAIL-LOAD: ${errorCode} ${errorDescription} url=${validatedURL}`);
});
app.on('child-process-gone', (_event, details) => {
_writeCrashLog('CHILD PROCESS GONE', new Error(details.reason || 'unknown'), details);
debugLog(`CHILD PROCESS GONE: type=${details.type} reason=${details.reason} exitCode=${details.exitCode}`);
});
mainWindow.loadFile(path.join(__dirname, 'renderer', 'index.html')); mainWindow.loadFile(path.join(__dirname, 'renderer', 'index.html'));
} }
@ -1065,19 +910,6 @@ function updateTrayTooltip(text) {
} }
app.whenReady().then(() => { 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(); createWindow();
createTray(); createTray();
@ -1094,7 +926,7 @@ app.whenReady().then(() => {
if (fs.existsSync(fm.folderPath)) { if (fs.existsSync(fm.folderPath)) {
startFolderMonitor(fm); startFolderMonitor(fm);
} else { } 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 // Persist the disable so the user gets a clean state on next launch
const gs = { ...launchConfig.globalSettings, folderMonitor: { ...fm, enabled: false } }; const gs = { ...launchConfig.globalSettings, folderMonitor: { ...fm, enabled: false } };
configStore.save({ globalSettings: gs }).catch(() => {}); configStore.save({ globalSettings: gs }).catch(() => {});
@ -1128,23 +960,19 @@ app.whenReady().then(() => {
// Auto-check for updates after 3 seconds // Auto-check for updates after 3 seconds
setTimeout(async () => { setTimeout(async () => {
try { try {
logInfo('update-check: starting'); debugLog('update-check: starting');
const result = await checkForUpdate(); const result = await checkForUpdate();
logInfo(`update-check: available=${result && result.available}, remote=${result && result.remoteVersion}`); debugLog(`update-check: available=${result && result.available}, remote=${result && result.remoteVersion}`);
logDebug(`update-check result: ${JSON.stringify(result)}`);
if (result && result.available && mainWindow && !mainWindow.isDestroyed()) { if (result && result.available && mainWindow && !mainWindow.isDestroyed()) {
safeSend('app:update-available', result); mainWindow.webContents.send('app:update-available', result);
} }
} catch (err) { } catch (err) {
logError('update-check failed', err); debugLog(`update-check failed: ${err && err.message || err}`);
} }
}, 3000); }, 3000);
}); });
app.on('window-all-closed', () => { app.on('window-all-closed', () => {
const activeJobs = uploadManager && typeof uploadManager.getActiveJobCount === 'function' ? uploadManager.getActiveJobCount() : 0;
debugLog(`window-all-closed: activeJobs=${activeJobs}, uploadManager=${!!uploadManager}`);
_writeCrashLog('WINDOW-ALL-CLOSED', new Error('all windows closed'), { activeJobs, uploadManager: !!uploadManager });
app.quit(); app.quit();
}); });
@ -1192,11 +1020,6 @@ ipcMain.handle('get-config', () => {
ipcMain.handle('save-config', async (_event, config) => { ipcMain.handle('save-config', async (_event, config) => {
await configStore.save(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 // 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. // fallback existed, re-resolve now — the user may have just added one.
// Without this re-probe, those accounts stay stuck with no override until // Without this re-probe, those accounts stay stuck with no override until
@ -1216,11 +1039,13 @@ ipcMain.handle('save-config', async (_event, config) => {
rotLog(`main: config-updated → late fallback ${fallback.id} for ${hoster} (was stuck on ${failedAccountId})`); rotLog(`main: config-updated → late fallback ${fallback.id} for ${hoster} (was stuck on ${failedAccountId})`);
uploadManager.switchAccount(hoster, fallback); uploadManager.switchAccount(hoster, fallback);
_sessionAccountOverrides.set(hoster, fallback); _sessionAccountOverrides.set(hoster, fallback);
safeSend('account-switched', { if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('account-switched', {
hoster, fromAccountId: failedAccountId, toAccountId: fallback.id hoster, fromAccountId: failedAccountId, toAccountId: fallback.id
}); });
} }
} }
}
} catch (err) { } catch (err) {
debugLog(`save-config re-resolve failed: ${err && err.message ? err.message : err}`); debugLog(`save-config re-resolve failed: ${err && err.message ? err.message : err}`);
} }
@ -1378,32 +1203,33 @@ ipcMain.handle('select-folder', async () => {
}); });
if (result.canceled || !result.filePaths.length) return null; if (result.canceled || !result.filePaths.length) return null;
// Recursively collect all files from selected folders
const files = []; 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; 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) => { ipcMain.handle('resolve-folder-files', async (_event, folderPath) => {
const files = []; 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; return files;
}); });
@ -1415,7 +1241,6 @@ ipcMain.handle('start-upload', (_event, payload) => {
// At 500+ jobs JSON.stringify blew up the debug log with MB-sized lines // At 500+ jobs JSON.stringify blew up the debug log with MB-sized lines
// per start-upload and added noticeable delay — log counts only. // 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}`); debugLog(`start-upload: files=${files.length}, hosters=${hosters.length}, jobs=${jobs.length}`);
const tasks = jobs.length > 0 const tasks = jobs.length > 0
@ -1462,29 +1287,9 @@ ipcMain.handle('start-upload', (_event, payload) => {
// Pass hoster settings to the upload manager // Pass hoster settings to the upload manager
uploadManager = new UploadManager(config.hosterSettings || {}, config.globalSettings || {}); uploadManager = new UploadManager(config.hosterSettings || {}, config.globalSettings || {});
globalThis._mhuUploadManagerRef = uploadManager;
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) safeSend('upload-progress-batch', batch);
}, PROGRESS_BATCH_INTERVAL_MS);
}
uploadManager.on('progress', (data) => { uploadManager.on('progress', (data) => {
// Only log state changes, not continuous progress updates
if (data.status !== 'uploading') { if (data.status !== 'uploading') {
debugLog(`progress: ${data.fileName} ${data.hoster} ${data.status} ${data.error || ''}`); debugLog(`progress: ${data.fileName} ${data.hoster} ${data.status} ${data.error || ''}`);
_appendJobLog(data.jobId, { _appendJobLog(data.jobId, {
@ -1493,6 +1298,10 @@ ipcMain.handle('start-upload', (_event, payload) => {
error: data.error || null, attempt: data.attempt || 0, maxAttempts: data.maxAttempts || 0 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) { if (data.status === 'done' && data.result) {
const link = data.result.download_url || data.result.embed_url || data.result.file_code || ''; const link = data.result.download_url || data.result.embed_url || data.result.file_code || '';
if (link) { if (link) {
@ -1505,29 +1314,22 @@ ipcMain.handle('start-upload', (_event, payload) => {
debugLog(`WARNING: done but no link for ${data.fileName} @ ${data.hoster}: ${JSON.stringify(data.result)}`); 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 (mainWindow && !mainWindow.isDestroyed()) {
if (isTerminal) { mainWindow.webContents.send('upload-progress', data);
if (data.jobId) _progressByJob.delete(data.jobId);
_progressTerminalQueue.push(data);
} else if (data.jobId) {
_progressByJob.set(data.jobId, data);
} else {
_progressTerminalQueue.push(data);
} }
_scheduleProgressFlush();
}); });
uploadManager.on('stats', (data) => { uploadManager.on('stats', (data) => {
try { if (mainWindow && !mainWindow.isDestroyed()) {
if (!data || typeof data !== 'object') return; mainWindow.webContents.send('upload-stats', data);
safeSend('upload-stats', data); }
// Update tray tooltip with upload progress
if (data.state === 'uploading' && data.activeJobs > 0) { if (data.state === 'uploading' && data.activeJobs > 0) {
const speedMb = ((Number(data.globalSpeedKbs) || 0) / 1024).toFixed(1); const speedMb = ((data.globalSpeedKbs || 0) / 1024).toFixed(1);
updateTrayTooltip(`Upload: ${data.activeJobs} aktiv - ${speedMb} MB/s`); updateTrayTooltip(`Upload: ${data.activeJobs} aktiv - ${speedMb} MB/s`);
} else { } else {
updateTrayTooltip('Multi-Hoster-Upload'); updateTrayTooltip('Multi-Hoster-Upload');
} }
} catch (e) { debugLog(`stats listener error: ${e && e.message}`); }
}); });
uploadManager.on('account-failed', ({ hoster, accountId }) => { uploadManager.on('account-failed', ({ hoster, accountId }) => {
@ -1540,41 +1342,26 @@ ipcMain.handle('start-upload', (_event, payload) => {
rotLog(`main: account-failed ${hoster} ${accountId} → resolved fallback ${fallback.id}`); rotLog(`main: account-failed ${hoster} ${accountId} → resolved fallback ${fallback.id}`);
uploadManager.switchAccount(hoster, fallback); uploadManager.switchAccount(hoster, fallback);
_sessionAccountOverrides.set(hoster, fallback); _sessionAccountOverrides.set(hoster, fallback);
safeSend('account-switched', { hoster, fromAccountId: accountId, toAccountId: fallback.id }); if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('account-switched', { hoster, fromAccountId: accountId, toAccountId: fallback.id });
}
} else { } else {
rotLog(`main: account-failed ${hoster} ${accountId} → NO fallback available (end of chain)`); rotLog(`main: account-failed ${hoster} ${accountId} → NO fallback available (end of chain)`);
} }
}); });
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) => { uploadManager.on('rot-log', (entry) => {
try {
if (!entry || typeof entry !== 'object') return;
const { ts, event, ...rest } = entry; const { ts, event, ...rest } = entry;
const pairs = Object.entries(rest) const pairs = Object.entries(rest)
.map(([k, v]) => { .map(([k, v]) => `${k}=${typeof v === 'string' ? v : JSON.stringify(v)}`)
let sv;
try { sv = typeof v === 'string' ? v : JSON.stringify(v); }
catch { sv = '<unserializable>'; }
return `${k}=${sv}`;
})
.join(' '); .join(' ');
rotLog(`[${event}] ${pairs}`, ts); rotLog(`[${event}] ${pairs}`, ts);
if (entry.jobId) { if (entry.jobId) {
_appendJobLog(entry.jobId, { ts: ts || Date.now(), kind: 'rot', event, ...rest }); _appendJobLog(entry.jobId, { ts: ts || Date.now(), kind: 'rot', event, ...rest });
} }
if (ROT_LOG_RENDERER_EVENTS.has(event)) { if (mainWindow && !mainWindow.isDestroyed()) {
safeSend('account-rotation-log', entry); mainWindow.webContents.send('account-rotation-log', entry);
} }
} catch (e) { debugLog(`rot-log listener error: ${e && e.message}`); }
}); });
// Capture the manager identity at listener-registration time so the post- // Capture the manager identity at listener-registration time so the post-
@ -1586,16 +1373,17 @@ ipcMain.handle('start-upload', (_event, payload) => {
const _thisManager = uploadManager; const _thisManager = uploadManager;
uploadManager.on('batch-done', async (summary) => { uploadManager.on('batch-done', async (summary) => {
debugLog(`batch-done: total=${summary.total} ok=${summary.succeeded} fail=${summary.failed}`); 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'); logMemorySnapshot('batch-done');
try { await configStore.appendHistory(summary); } catch (err) { try { await configStore.appendHistory(summary); } catch (err) {
debugLog(`appendHistory failed: ${err.message}`); debugLog(`appendHistory failed: ${err.message}`);
} }
safeSend('upload-batch-done', summary); if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('upload-batch-done', summary);
}
// Shutdown after finish // Shutdown after finish
handleShutdownAfterFinish(); handleShutdownAfterFinish();
if (uploadManager === _thisManager) { uploadManager = null; globalThis._mhuUploadManagerRef = null; } if (uploadManager === _thisManager) uploadManager = null;
else debugLog('batch-done: skipping uploadManager null-out — a newer manager replaced this one mid-await'); else debugLog('batch-done: skipping uploadManager null-out — a newer manager replaced this one mid-await');
}); });
@ -1611,7 +1399,8 @@ ipcMain.handle('start-upload', (_event, payload) => {
}).catch((err) => { }).catch((err) => {
debugLog(`startBatch REJECTED: ${err && err.stack ? err.stack : err}`); debugLog(`startBatch REJECTED: ${err && err.stack ? err.stack : err}`);
// Forward error to renderer as batch-done with failure // Forward error to renderer as batch-done with failure
safeSend('upload-batch-done', { if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('upload-batch-done', {
id: 'error', id: 'error',
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
total: tasks.length, total: tasks.length,
@ -1620,6 +1409,7 @@ ipcMain.handle('start-upload', (_event, payload) => {
files: [], files: [],
error: err ? err.message : 'Unbekannter Fehler' error: err ? err.message : 'Unbekannter Fehler'
}); });
}
}); });
}); });
@ -1689,136 +1479,12 @@ ipcMain.handle('finish-after-active', () => {
return true; 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) => { ipcMain.handle('get-job-log', (_event, jobId) => {
if (!jobId || typeof jobId !== 'string') return []; if (!jobId || typeof jobId !== 'string') return [];
const arr = _jobLogCollector.get(jobId); const arr = _jobLogCollector.get(jobId);
return Array.isArray(arr) ? arr.slice() : []; 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: 'crash.log', path: path.join(paths.logDir || path.dirname(paths.debug), 'crash.log'), maxBytes: 1 * 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 () => { ipcMain.handle('open-log-folder', async () => {
// Reveal the active log file (or its directory) in the OS file manager. // 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 // Prefers the configured log path, then the rotation log, then just the
@ -1848,19 +1514,12 @@ ipcMain.handle('export-backup', async () => {
const { canceled, filePath } = await dialog.showSaveDialog(mainWindow, { const { canceled, filePath } = await dialog.showSaveDialog(mainWindow, {
title: 'Backup exportieren', title: 'Backup exportieren',
defaultPath: `multi-hoster-backup-${new Date().toISOString().slice(0, 10)}.mhu`, defaultPath: `multi-hoster-backup-${new Date().toISOString().slice(0, 10)}.mhu`,
filters: [ filters: [{ name: 'Multi-Hoster Backup', extensions: ['mhu'] }]
{ name: 'Multi-Hoster Backup (verschlüsselt)', extensions: ['mhu'] },
{ name: 'Multi-Hoster Backup (Klartext JSON)', extensions: ['json'] }
]
}); });
if (canceled || !filePath) return { ok: false, canceled: true }; if (canceled || !filePath) return { ok: false, canceled: true };
const config = configStore.load(); 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); const encrypted = backupCrypto.encrypt(config);
fs.writeFileSync(filePath, encrypted); fs.writeFileSync(filePath, encrypted);
}
return { ok: true, path: filePath }; return { ok: true, path: filePath };
}); });
@ -1872,11 +1531,7 @@ ipcMain.handle('import-backup', async (_event, legacyPassword) => {
} else { } else {
const { canceled, filePaths } = await dialog.showOpenDialog(mainWindow, { const { canceled, filePaths } = await dialog.showOpenDialog(mainWindow, {
title: 'Backup importieren', title: 'Backup importieren',
filters: [ filters: [{ name: 'Multi-Hoster Backup', extensions: ['mhu'] }],
{ name: 'Multi-Hoster Backup', extensions: ['mhu', 'json'] },
{ name: 'Verschlüsselt (.mhu)', extensions: ['mhu'] },
{ name: 'Klartext (.json)', extensions: ['json'] }
],
properties: ['openFile'] properties: ['openFile']
}); });
if (canceled || !filePaths.length) return { ok: false, canceled: true }; if (canceled || !filePaths.length) return { ok: false, canceled: true };
@ -1885,26 +1540,15 @@ ipcMain.handle('import-backup', async (_event, legacyPassword) => {
_lastImportPath = sourcePath; _lastImportPath = sourcePath;
} }
let imported; 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 { try {
imported = backupCrypto.decrypt(buffer, legacyPassword); imported = backupCrypto.decrypt(buffer, legacyPassword);
} catch (err) { } catch (err) {
if (err && err.needsPassword) { 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.' }; return { ok: false, needsPassword: true };
} }
_lastImportPath = null; _lastImportPath = null;
throw err; throw err;
} }
}
_lastImportPath = null; _lastImportPath = null;
// Validate imported data has required structure // Validate imported data has required structure
if (!imported || typeof imported !== 'object' || !imported.hosters || !imported.hosterSettings || !imported.globalSettings) { if (!imported || typeof imported !== 'object' || !imported.hosters || !imported.hosterSettings || !imported.globalSettings) {
@ -2021,9 +1665,13 @@ ipcMain.handle('app:check-updates', async () => {
ipcMain.handle('app:install-update', () => { ipcMain.handle('app:install-update', () => {
installUpdate((progress) => { installUpdate((progress) => {
safeSend('app:update-progress', progress); if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('app:update-progress', progress);
}
}).catch((err) => { }).catch((err) => {
safeSend('app:update-progress', { stage: 'error', error: err.message }); if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('app:update-progress', { stage: 'error', error: err.message });
}
}); });
return { started: true }; return { started: true };
}); });
@ -2102,7 +1750,9 @@ function startFolderMonitor(settings) {
folderMonitor.removeAllListeners(); folderMonitor.removeAllListeners();
folderMonitor.on('new-files', (files) => { folderMonitor.on('new-files', (files) => {
debugLog(`folder-monitor: ${files.length} new file(s)`); debugLog(`folder-monitor: ${files.length} new file(s)`);
safeSend('folder-monitor:new-files', files); if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('folder-monitor:new-files', files);
}
}); });
folderMonitor.on('error', (err) => { folderMonitor.on('error', (err) => {
debugLog(`folder-monitor error: ${err.message}`); debugLog(`folder-monitor error: ${err.message}`);
@ -2346,7 +1996,9 @@ ipcMain.handle('remote:get-capture-source-id', async () => {
// IPC: Client count updates from capture window // IPC: Client count updates from capture window
ipcMain.on('remote:client-count', (_event, count) => { ipcMain.on('remote:client-count', (_event, count) => {
safeSend('remote:client-count', count); if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('remote:client-count', count);
}
}); });
// IPC: Remote settings // IPC: Remote settings
@ -2451,7 +2103,7 @@ ipcMain.on('drop-target:files', (_event, paths) => {
mainWindow.show(); mainWindow.show();
mainWindow.focus(); mainWindow.focus();
} }
safeSend('drop-target:files', paths); mainWindow.webContents.send('drop-target:files', paths);
} }
}); });
@ -2488,7 +2140,9 @@ function handleShutdownAfterFinish() {
const { exec } = require('child_process'); const { exec } = require('child_process');
// Notify renderer // Notify renderer
safeSend('shutdown-countdown', { mode: shutdownMode, seconds: 60 }); if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('shutdown-countdown', { mode: shutdownMode, seconds: 60 });
}
// Clear any previous countdown to prevent orphaned timers // Clear any previous countdown to prevent orphaned timers
if (shutdownTimer) clearTimeout(shutdownTimer); if (shutdownTimer) clearTimeout(shutdownTimer);

View File

@ -1,6 +1,6 @@
{ {
"name": "multi-hoster-uploader", "name": "multi-hoster-uploader",
"version": "3.3.53", "version": "3.3.38",
"description": "Upload files to doodstream, voe, vidmoly, byse simultaneously", "description": "Upload files to doodstream, voe, vidmoly, byse simultaneously",
"main": "main.js", "main": "main.js",
"scripts": { "scripts": {

View File

@ -93,9 +93,6 @@ contextBridge.exposeInMainWorld('api', {
onUploadProgress: (callback) => { onUploadProgress: (callback) => {
ipcRenderer.on('upload-progress', (_event, data) => callback(data)); ipcRenderer.on('upload-progress', (_event, data) => callback(data));
}, },
onUploadProgressBatch: (callback) => {
ipcRenderer.on('upload-progress-batch', (_event, batch) => callback(batch));
},
onUploadBatchDone: (callback) => { onUploadBatchDone: (callback) => {
ipcRenderer.on('upload-batch-done', (_event, data) => callback(data)); ipcRenderer.on('upload-batch-done', (_event, data) => callback(data));
}, },
@ -113,14 +110,6 @@ contextBridge.exposeInMainWorld('api', {
}, },
openLogFolder: () => ipcRenderer.invoke('open-log-folder'), openLogFolder: () => ipcRenderer.invoke('open-log-folder'),
getJobLog: (jobId) => ipcRenderer.invoke('get-job-log', jobId), getJobLog: (jobId) => ipcRenderer.invoke('get-job-log', jobId),
getSessionFailedAccounts: () => ipcRenderer.invoke('get-session-failed-accounts'),
resetSessionFailedAccount: (payload) => ipcRenderer.invoke('reset-session-failed-account', payload),
resetAllSessionFailedAccounts: () => ipcRenderer.invoke('reset-all-session-failed-accounts'),
getLogPaths: () => ipcRenderer.invoke('get-log-paths'),
revealLogFile: (target) => ipcRenderer.invoke('reveal-log-file', target),
setLogVerbose: (enabled) => ipcRenderer.invoke('set-log-verbose', enabled),
createSupportBundle: () => ipcRenderer.invoke('create-support-bundle'),
getAppInfo: () => ipcRenderer.invoke('get-app-info'),
onLogPathAutoUpdated: (callback) => { onLogPathAutoUpdated: (callback) => {
ipcRenderer.on('log-path-auto-updated', (_event, data) => callback(data)); ipcRenderer.on('log-path-auto-updated', (_event, data) => callback(data));
}, },

View File

@ -63,12 +63,10 @@ const queueSortState = { key: 'filename', direction: 'asc' };
// History state // History state
let historyRowsData = []; let historyRowsData = [];
let historySortState = { key: 'date', direction: 'desc' }; let historySortState = { key: 'date', direction: 'desc' };
let _historySortClicked = false;
// Session-specific files for the "Files" panel (resets each session) // Session-specific files for the "Files" panel (resets each session)
let sessionFilesData = []; let sessionFilesData = [];
const recentSortState = { key: 'date', direction: 'desc' }; const recentSortState = { key: 'date', direction: 'desc' };
let _recentSortClicked = false;
const selectedRecentIds = new Set(); const selectedRecentIds = new Set();
// Maintained incrementally — avoids O(n) filter() scans every 250ms in the status bar. // Maintained incrementally — avoids O(n) filter() scans every 250ms in the status bar.
let _sessionDoneCount = 0; let _sessionDoneCount = 0;
@ -77,19 +75,6 @@ let _sessionErrorCount = 0;
// Huge with thousands of rows × thousands of incoming results. // Huge with thousands of rows × thousands of incoming results.
const _sessionFileKeys = new Set(); const _sessionFileKeys = new Set();
window.addEventListener('error', (e) => {
try {
const msg = `RENDERER ERROR: ${e.message} at ${e.filename}:${e.lineno}:${e.colno}${e.error && e.error.stack ? '\n' + e.error.stack : ''}`;
if (window.api && window.api.debugLog) window.api.debugLog(msg);
} catch {}
});
window.addEventListener('unhandledrejection', (e) => {
try {
const reason = e.reason && e.reason.stack ? e.reason.stack : (e.reason && e.reason.message) || String(e.reason);
if (window.api && window.api.debugLog) window.api.debugLog(`RENDERER UNHANDLED REJECTION: ${reason}`);
} catch {}
});
// --- Init --- // --- Init ---
async function init() { async function init() {
config = await window.api.getConfig(); config = await window.api.getConfig();
@ -107,7 +92,6 @@ async function init() {
setupDragDrop(); setupDragDrop();
restoreQueueColumnWidths(); restoreQueueColumnWidths();
loadHistory(); loadHistory();
_refreshSessionFailedSnapshot();
renderRecentUploadsPanel(); renderRecentUploadsPanel();
updateUploadView(); updateUploadView();
updateStatusBar(); updateStatusBar();
@ -123,19 +107,24 @@ async function init() {
window.api.onUpdateAvailable(showUpdateBanner); window.api.onUpdateAvailable(showUpdateBanner);
window.api.onUpdateProgress(handleUpdateProgress); 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) => { window.api.onUploadProgress((data) => {
if (data.status !== 'uploading') {
window.api.debugLog('RX upload-progress: ' + data.status + ' ' + data.hoster + ' ' + (data.fileName || ''));
}
handleProgress(data); 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.onUploadBatchDone((data) => {
window.api.debugLog('RX upload-batch-done');
handleBatchDone(data); handleBatchDone(data);
}); });
window.api.onUploadStats((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); handleStats(data);
}); });
window.api.onShutdownCountdown(handleShutdownCountdown); window.api.onShutdownCountdown(handleShutdownCountdown);
@ -1458,7 +1447,7 @@ async function doBackupExport() {
} }
} }
function askLegacyBackupPassword(hint) { function askLegacyBackupPassword() {
return new Promise((resolve) => { return new Promise((resolve) => {
const overlay = document.createElement('div'); const overlay = document.createElement('div');
overlay.className = 'modal-overlay'; overlay.className = 'modal-overlay';
@ -1471,7 +1460,7 @@ function askLegacyBackupPassword(hint) {
const header = document.createElement('div'); const header = document.createElement('div');
header.className = 'modal-header'; header.className = 'modal-header';
const h3 = document.createElement('h3'); const h3 = document.createElement('h3');
h3.textContent = 'Backup nicht entschlüsselbar'; h3.textContent = 'Passwort erforderlich';
header.appendChild(h3); header.appendChild(h3);
const body = document.createElement('div'); const body = document.createElement('div');
@ -1479,15 +1468,7 @@ function askLegacyBackupPassword(hint) {
const p = document.createElement('p'); const p = document.createElement('p');
p.style.margin = '0 0 10px'; p.style.margin = '0 0 10px';
p.style.fontSize = '13px'; p.style.fontSize = '13px';
p.textContent = 'Wenn das Backup mit der alten Passwort-Option (vor v3.0) erstellt wurde, hier eingeben.'; p.textContent = 'Dieses Backup wurde mit einem Passwort verschlüsselt.';
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);
}
const input = document.createElement('input'); const input = document.createElement('input');
input.type = 'password'; input.type = 'password';
input.className = 'key-input'; input.className = 'key-input';
@ -1531,7 +1512,7 @@ async function doBackupImport(legacyPassword) {
const result = await window.api.importBackup(pw); const result = await window.api.importBackup(pw);
if (!result || result.canceled) return; if (!result || result.canceled) return;
if (result.needsPassword) { if (result.needsPassword) {
const entered = await askLegacyBackupPassword(result.hint); const entered = await askLegacyBackupPassword();
if (entered) doBackupImport(entered); if (entered) doBackupImport(entered);
return; return;
} }
@ -1896,14 +1877,6 @@ async function cancelUpload() {
// --- Progress handling --- // --- Progress handling ---
function handleProgress(data) { function handleProgress(data) {
try {
if (!data || typeof data !== 'object') return;
_handleProgressImpl(data);
} catch (err) {
if (window.api && window.api.debugLog) window.api.debugLog(`handleProgress error: ${err && err.stack ? err.stack : err}`);
}
}
function _handleProgressImpl(data) {
let job = data.jobId ? _jobIndexById.get(data.jobId) : null; let job = data.jobId ? _jobIndexById.get(data.jobId) : null;
if (!job && data.uploadId) job = _jobIndexByUploadId.get(data.uploadId); if (!job && data.uploadId) job = _jobIndexByUploadId.get(data.uploadId);
if (!job) { if (!job) {
@ -2083,96 +2056,9 @@ function handleBatchDone(summary) {
lastUploadStats = { state: 'idle', globalSpeedKbs: 0, totalBytes: lastUploadStats.totalBytes, elapsed: lastUploadStats.elapsed, activeJobs: 0 }; lastUploadStats = { state: 'idle', globalSpeedKbs: 0, totalBytes: lastUploadStats.totalBytes, elapsed: lastUploadStats.elapsed, activeJobs: 0 };
updateStatusBar(); 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) { function handleStats(data) {
try {
if (!data || typeof data !== 'object') return;
_handleStatsImpl(data);
} catch (err) {
if (window.api && window.api.debugLog) window.api.debugLog(`handleStats error: ${err && err.stack ? err.stack : err}`);
}
}
function _handleStatsImpl(data) {
lastUploadStats = { lastUploadStats = {
state: data.state || 'idle', state: data.state || 'idle',
globalSpeedKbs: data.globalSpeedKbs || 0, globalSpeedKbs: data.globalSpeedKbs || 0,
@ -2535,9 +2421,19 @@ function updateStatusBar() {
// --- Health Check --- // --- Health Check ---
function renderHealthCheckResults(_results) { function renderHealthCheckResults(results) {
const container = document.getElementById('healthCheckResults'); 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) { async function executeHealthCheck(hosters, _mode) {
@ -2561,10 +2457,8 @@ async function executeHealthCheck(hosters, _mode) {
} }
async function runHealthCheck(mode = 'manual', requestedHosters = null) { async function runHealthCheck(mode = 'manual', requestedHosters = null) {
if (healthCheckRunning) { if (healthCheckRunning || (uploading && mode === 'manual')) return [];
if (mode === 'manual') showCopyToast('Account-Check läuft bereits.'); // Build check list: all enabled accounts with creds
return [];
}
let hosters; let hosters;
if (Array.isArray(requestedHosters) && requestedHosters.length > 0) { if (Array.isArray(requestedHosters) && requestedHosters.length > 0) {
hosters = requestedHosters; hosters = requestedHosters;
@ -2596,36 +2490,6 @@ async function runHealthCheck(mode = 'manual', requestedHosters = null) {
} }
// --- Settings --- // --- 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() { function renderSettings() {
const container = document.getElementById('settingsHosters'); const container = document.getElementById('settingsHosters');
container.innerHTML = ''; container.innerHTML = '';
@ -2696,59 +2560,9 @@ function renderSettings() {
</select> </select>
<span class="hint">Pro Session = neue Datei bei jedem App-Start; nach komplettem Schließen + erneutem Öffnen beginnt eine neue Session.</span> <span class="hint">Pro Session = neue Datei bei jedem App-Start; nach komplettem Schließen + erneutem Öffnen beginnt eine neue Session.</span>
</div> </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> </div>
`; `;
container.appendChild(generalPanel); 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 // Toggle general panel
generalPanel.querySelector('.hoster-panel-header').addEventListener('click', () => { generalPanel.querySelector('.hoster-panel-header').addEventListener('click', () => {
@ -3234,16 +3048,11 @@ function _buildAccountCardHtml(name, account, idx) {
const toggleLabel = isDisabled ? 'Aktivieren' : 'Deaktivieren'; const toggleLabel = isDisabled ? 'Aktivieren' : 'Deaktivieren';
const priorityLabel = idx === 0 ? 'Primär' : `Fallback #${idx}`; 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 ` 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">&#9776;</div> <div class="account-card-drag-handle" title="Ziehen zum Sortieren">&#9776;</div>
<div class="account-card-info"> <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-title">${escapeHtml(getAccountDisplayName(name, account))} <span class="account-priority-badge">${priorityLabel}</span></div>
<div class="account-card-subtitle" title="${escapeAttr(subtitleText)}">${escapeHtml(subtitleText)}${st.message && !isDisabled ? `${escapeHtml(st.message)}` : ''}</div> <div class="account-card-subtitle" title="${escapeAttr(subtitleText)}">${escapeHtml(subtitleText)}${st.message && !isDisabled ? `${escapeHtml(st.message)}` : ''}</div>
</div> </div>
<span class="account-status status-${statusClass}"> <span class="account-status status-${statusClass}">
@ -3275,40 +3084,6 @@ function updateAccountCard(accountId) {
const tmp = document.createElement('div'); const tmp = document.createElement('div');
tmp.innerHTML = _buildAccountCardHtml(found.name, found.account, idx); tmp.innerHTML = _buildAccountCardHtml(found.name, found.account, idx);
card.replaceWith(tmp.firstElementChild); 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; let _accountListenersBound = false;
@ -3342,79 +3117,16 @@ function renderAccounts() {
for (const name of HOSTERS) { for (const name of HOSTERS) {
const accounts = byHoster[name]; const accounts = byHoster[name];
if (!accounts || accounts.length === 0) continue; 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; container.innerHTML = html;
if (!_accountListenersBound) bindAccountListeners(container); 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 ? '&#9660;' : '&#9654;';
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 // Single set of delegated listeners on the accounts container. Bound once on
// the first render and reused for every subsequent in-place update / card // the first render and reused for every subsequent in-place update / card
// swap. Previously we rebound 4 × N button listeners + 5 × N drag listeners // swap. Previously we rebound 4 × N button listeners + 5 × N drag listeners
@ -3423,39 +3135,12 @@ function _invalidateHosterLifetimeCache() { _hosterLifetimeCache = null; }
function bindAccountListeners(container) { function bindAccountListeners(container) {
_accountListenersBound = true; _accountListenersBound = true;
container.addEventListener('click', (e) => { 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 ? '&#9660;' : '&#9654;';
const summary = _summarizeHosterGroup(config.hosters[name] || []);
_hosterGroupOpenMemory.set(name, { state: willOpen ? 'open' : 'closed', errorsAtClose: summary.error });
}
return;
}
const btn = e.target.closest('button'); const btn = e.target.closest('button');
if (!btn) return; if (!btn) return;
if (btn.dataset.accountToggle) return toggleAccount(btn.dataset.accountToggle); if (btn.dataset.accountToggle) return toggleAccount(btn.dataset.accountToggle);
if (btn.dataset.accountEdit) return openAccountModal(btn.dataset.accountEdit); if (btn.dataset.accountEdit) return openAccountModal(btn.dataset.accountEdit);
if (btn.dataset.accountDelete) return openDeleteAccountModal(btn.dataset.accountDelete); if (btn.dataset.accountDelete) return openDeleteAccountModal(btn.dataset.accountDelete);
if (btn.dataset.accountCheck) return checkSingleAccount(btn.dataset.accountCheck); 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; let draggedCard = null;
@ -3945,8 +3630,6 @@ function _hideOtpField() {
// --- History --- // --- History ---
async function loadHistory() { async function loadHistory() {
const history = await window.api.getHistory(); const history = await window.api.getHistory();
window._historyForStats = history || [];
_invalidateHosterLifetimeCache();
const container = document.getElementById('historyContainer'); const container = document.getElementById('historyContainer');
if (!history || history.length === 0) { if (!history || history.length === 0) {
@ -4144,32 +3827,17 @@ function renderHistoryTable(container) {
${headerCell('date', 'Date')}${headerCell('filename', 'Filename')}${headerCell('host', 'Host')}${headerCell('link', 'Link')} ${headerCell('date', 'Date')}${headerCell('filename', 'Filename')}${headerCell('host', 'Host')}${headerCell('link', 'Link')}
</tr></thead><tbody>`; </tr></thead><tbody>`;
const parts = [html]; rows.forEach(row => {
const len = rows.length; html += `<tr class="history-row${row.isError ? ' error' : ''}" data-link="${escapeAttr(row.link)}">
for (let i = 0; i < len; i++) { <td class="col-date">${escapeHtml(row.date)}</td>
const row = rows[i]; <td class="col-filename">${escapeHtml(row.filename)}</td>
const link = row.link || ''; <td class="col-host">${escapeHtml(row.host)}</td>
const date = escapeHtml(row.date); <td class="col-link">${escapeHtml(row.link)}</td>
const filename = escapeHtml(row.filename); </tr>`;
const host = escapeHtml(row.host); });
const linkHtml = escapeHtml(link);
const linkAttr = escapeAttr(link); html += '</tbody></table>';
parts.push('<tr class="history-row'); container.innerHTML = html;
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('');
// Delegated listeners: bind once per render-target instead of once per // 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 // row/header. With a 5000-row history the per-row bind path was a
@ -4181,14 +3849,8 @@ function renderHistoryTable(container) {
const th = e.target.closest('th.sortable'); const th = e.target.closest('th.sortable');
if (th && container.contains(th)) { if (th && container.contains(th)) {
const key = th.dataset.historySort; const key = th.dataset.historySort;
const defaultDir = key === 'date' ? 'desc' : 'asc'; if (historySortState.key === key) historySortState.direction = historySortState.direction === 'asc' ? 'desc' : 'asc';
if (!_historySortClicked || historySortState.key !== key) { else { historySortState.key = key; historySortState.direction = key === 'date' ? 'desc' : 'asc'; }
_historySortClicked = true;
historySortState.key = key;
historySortState.direction = defaultDir;
} else {
historySortState.direction = historySortState.direction === 'asc' ? 'desc' : 'asc';
}
renderHistoryTable(container); renderHistoryTable(container);
return; return;
} }
@ -4244,13 +3906,11 @@ function setupListeners() {
const th = e.target.closest('th[data-recent-sort]'); const th = e.target.closest('th[data-recent-sort]');
if (!th) return; if (!th) return;
const key = th.dataset.recentSort; const key = th.dataset.recentSort;
const defaultDir = key === 'date' ? 'desc' : 'asc'; if (recentSortState.key === key) {
if (!_recentSortClicked || recentSortState.key !== key) {
_recentSortClicked = true;
recentSortState.key = key;
recentSortState.direction = defaultDir;
} else {
recentSortState.direction = recentSortState.direction === 'desc' ? 'asc' : 'desc'; recentSortState.direction = recentSortState.direction === 'desc' ? 'asc' : 'desc';
} else {
recentSortState.key = key;
recentSortState.direction = key === 'date' ? 'desc' : 'asc';
} }
renderRecentUploadsPanel(); renderRecentUploadsPanel();
}); });
@ -4557,20 +4217,14 @@ async function importUploadLog() {
// --- Link operations --- // --- Link operations ---
function copyAllLinks() { function copyAllLinks() {
const rows = queueJobs const links = queueJobs
.filter(j => j.status === 'done' && j.result) .filter(j => j.status === 'done' && j.result)
.map(j => ({ .map(j => j.result.download_url || j.result.embed_url || '')
fileName: j.fileName || '', .filter(Boolean);
hoster: j.hoster || '', if (links.length > 0) {
url: j.result.download_url || j.result.embed_url || '' window.api.copyToClipboard(links.join('\n'));
})) showCopyToast(`${links.length} Links kopiert`);
.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`);
} }
// --- Utilities --- // --- Utilities ---

View File

@ -93,14 +93,6 @@
<div class="queue-actions" id="queueActions" style="display:none"> <div class="queue-actions" id="queueActions" style="display:none">
<button class="btn btn-xs btn-primary" id="copyAllLinksBtn">Alle Links kopieren</button> <button class="btn btn-xs btn-primary" id="copyAllLinksBtn">Alle Links kopieren</button>
<select class="hs-input" id="linkExportFormat" title="Ausgabe-Format der kopierten Links" style="max-width:none;width:auto;min-width:130px">
<option value="plain">Plaintext</option>
<option value="bbcode">BBCode</option>
<option value="markdown">Markdown</option>
<option value="html">HTML</option>
<option value="csv">CSV</option>
<option value="json">JSON</option>
</select>
<button class="btn btn-xs btn-secondary" id="retryFailedBtn" style="display:none">Fehlgeschlagene erneut</button> <button class="btn btn-xs btn-secondary" id="retryFailedBtn" style="display:none">Fehlgeschlagene erneut</button>
<button class="btn btn-xs btn-secondary" id="importLogBtn" title="Log importieren — bereits hochgeladene aus Queue entfernen">Log importieren</button> <button class="btn btn-xs btn-secondary" id="importLogBtn" title="Log importieren — bereits hochgeladene aus Queue entfernen">Log importieren</button>
</div> </div>
@ -342,26 +334,9 @@
</div> </div>
</div> </div>
<div class="modal" id="batchSummaryModal" style="display:none">
<div class="modal-content" style="max-width:680px">
<div class="modal-header">
<h2>Batch-Zusammenfassung</h2>
<button class="icon-btn" id="batchSummaryClose" aria-label="Schließen">&times;</button>
</div>
<div class="modal-body">
<div id="batchSummaryList"></div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" id="batchSummaryRetryTransient">Transiente erneut hochladen</button>
<button class="btn btn-primary" id="batchSummaryRetryAll">Alle Fehler erneut versuchen</button>
</div>
</div>
</div>
<script src="../lib/queue-prune.js"></script> <script src="../lib/queue-prune.js"></script>
<script src="../lib/queue-dedup.js"></script> <script src="../lib/queue-dedup.js"></script>
<script src="../lib/log-mode.js"></script> <script src="../lib/log-mode.js"></script>
<script src="../lib/stats.js"></script>
<script src="../lib/throttled-cache.js"></script> <script src="../lib/throttled-cache.js"></script>
<script src="../lib/coalesced-set.js"></script> <script src="../lib/coalesced-set.js"></script>
<script src="app.js"></script> <script src="app.js"></script>

View File

@ -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; } .key-input:focus, .hs-input:focus { border-color: var(--accent); outline: none; }
.hs-input { max-width: 100px; } .hs-input { max-width: 100px; }
select.hs-input { max-width: none; width: auto; min-width: 140px; }
.hint { font-size: 10px; color: var(--text-dim); } .hint { font-size: 10px; color: var(--text-dim); }
.settings-section-label { .settings-section-label {
font-size: 10px; font-size: 10px;
@ -876,96 +875,17 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; }
.account-hoster-group { .account-hoster-group {
margin-bottom: 12px; margin-bottom: 12px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-card);
overflow: hidden;
} }
.account-hoster-group-header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
cursor: pointer;
user-select: none;
background: var(--bg-card);
transition: background 0.1s;
}
.account-hoster-group-header:hover { background: var(--bg-card-hover); }
.account-hoster-group-title { .account-hoster-group-title {
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
color: var(--text); color: var(--text-muted);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.5px; letter-spacing: 0.5px;
flex: 1; margin-bottom: 6px;
} padding-left: 4px;
.account-hoster-group-count {
font-size: 12px;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
.account-hoster-group-meta {
font-size: 11px;
color: var(--text-muted);
padding: 1px 6px;
border-radius: 4px;
background: rgba(255,255,255,0.04);
}
.account-hoster-group-meta.error {
color: var(--danger, #e57373);
background: rgba(229, 115, 115, 0.12);
}
.account-session-paused {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 10px;
color: #f0c36c;
background: rgba(240, 195, 108, 0.12);
padding: 1px 6px;
border-radius: 4px;
margin-left: 6px;
}
.account-session-reactivate {
background: none;
border: none;
color: inherit;
cursor: pointer;
font-size: 12px;
line-height: 1;
padding: 0 2px;
}
.account-session-reactivate:hover { color: #fff; }
.account-session-paused-card { opacity: 0.85; }
.batch-cat {
margin-bottom: 10px;
padding: 6px 8px;
border-radius: 6px;
background: rgba(255,255,255,0.03);
}
.batch-cat-head { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; font-size: 13px; }
.batch-cat-count { color: var(--text-muted); font-variant-numeric: tabular-nums; }
.batch-cat-tag { font-size: 10px; padding: 1px 6px; border-radius: 4px; background: rgba(255,255,255,0.06); color: var(--text-muted); }
.batch-cat-tag.retryable { background: rgba(76, 175, 80, 0.18); color: #a5d6a7; }
.batch-cat-list { margin: 0; padding-left: 18px; font-size: 11px; color: var(--text-muted); }
.batch-cat-list em { color: var(--text-muted); font-style: italic; }
.account-hoster-group-body {
padding: 8px;
border-top: 1px solid var(--border);
} }
.account-hoster-group .account-card { margin-bottom: 4px; } .account-hoster-group .account-card { margin-bottom: 4px; }
.account-hoster-group .account-card:last-child { margin-bottom: 0; }
.account-status-dot.status-ok { background: #4caf50; }
.account-status-dot.status-error { background: #e57373; }
.account-status-dot.status-checking { background: #f0c36c; }
.account-status-dot.status-unchecked { background: #6c757d; }
.account-hoster-group-header .account-status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
}
.accounts-empty { .accounts-empty {
text-align: center; text-align: center;

View File

@ -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);
});

View File

@ -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);
});

View File

@ -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/);
});

View File

@ -31,7 +31,6 @@ describe('UploadManager', () => {
const origRequire = module.constructor.prototype.require; const origRequire = module.constructor.prototype.require;
const hosters = require('../lib/hosters'); const hosters = require('../lib/hosters');
hosters.uploadFile = mockUploadFile; hosters.uploadFile = mockUploadFile;
hosters.prefetchBaseline = async () => null;
// Mock fs.statSync for test file paths // Mock fs.statSync for test file paths
const fs = require('fs'); const fs = require('fs');
@ -56,8 +55,8 @@ describe('UploadManager', () => {
]); ]);
const statuses = events.map(e => e.status); const statuses = events.map(e => e.status);
assert.ok(statuses.includes('queued'), 'should have queued status');
assert.ok(statuses.includes('done'), 'should have done status'); assert.ok(statuses.includes('done'), 'should have done status');
assert.ok(events.length > 0, 'should emit at least one progress event');
}); });
it('emits batch-done with correct summary', async () => { it('emits batch-done with correct summary', async () => {