Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
169817f707 | ||
|
|
1418c2bc17 | ||
|
|
8d33141294 | ||
|
|
35341b522a | ||
|
|
f9aa7f4168 | ||
|
|
d9199f8aaf | ||
|
|
ba4642e09a | ||
|
|
d59c5c1df8 | ||
|
|
4bb18f7abc | ||
|
|
125e5f55ea | ||
|
|
79fe3037eb | ||
|
|
d280765feb | ||
|
|
b0b86e5016 | ||
|
|
cf35f4401d |
@ -499,24 +499,27 @@ async function _resolveDoodstreamUploadByName(apiKey, fileName, baselineCodes, s
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function uploadFile(hosterName, filePath, apiKey, onProgress, signal, throttle) {
|
async function uploadFile(hosterName, filePath, apiKey, onProgress, signal, throttle, opts) {
|
||||||
const config = HOSTER_CONFIGS[hosterName];
|
const config = HOSTER_CONFIGS[hosterName];
|
||||||
if (!config) throw new Error(`Unbekannter Hoster: ${hosterName}`);
|
if (!config) throw new Error(`Unbekannter Hoster: ${hosterName}`);
|
||||||
|
|
||||||
// For byse: snapshot the current file-code list so the post-upload poller
|
|
||||||
// can identify new arrivals even when the initial POST response has an
|
|
||||||
// empty filecode.
|
|
||||||
let byseBaseline = null;
|
let byseBaseline = null;
|
||||||
if (hosterName === 'byse.sx') {
|
if (hosterName === 'byse.sx') {
|
||||||
const baseline = await _fetchByseFileList(apiKey, signal);
|
if (opts && opts.byseBaseline instanceof Set) {
|
||||||
byseBaseline = new Set(baseline.map(f => f.file_code));
|
byseBaseline = opts.byseBaseline;
|
||||||
|
} else {
|
||||||
|
const baseline = await _fetchByseFileList(apiKey, signal);
|
||||||
|
byseBaseline = new Set(baseline.map(f => f.file_code));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Doodstream: same snapshot so a codeless upload response can be recovered by
|
|
||||||
// matching a newly-appeared file in the account by name (see below).
|
|
||||||
let doodBaseline = null;
|
let doodBaseline = null;
|
||||||
if (hosterName === 'doodstream.com') {
|
if (hosterName === 'doodstream.com') {
|
||||||
const baseline = await _fetchDoodstreamFileList(apiKey, signal);
|
if (opts && opts.doodBaseline instanceof Set) {
|
||||||
doodBaseline = new Set(baseline.map(f => f.file_code));
|
doodBaseline = opts.doodBaseline;
|
||||||
|
} else {
|
||||||
|
const baseline = await _fetchDoodstreamFileList(apiKey, signal);
|
||||||
|
doodBaseline = new Set(baseline.map(f => f.file_code));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 1: Get upload server
|
// Step 1: Get upload server
|
||||||
@ -647,8 +650,23 @@ async function uploadFile(hosterName, filePath, apiKey, onProgress, signal, thro
|
|||||||
throw new Error(msg);
|
throw new Error(msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function prefetchBaseline(hosterName, apiKey, signal) {
|
||||||
|
try {
|
||||||
|
if (hosterName === 'byse.sx') {
|
||||||
|
const baseline = await _fetchByseFileList(apiKey, signal);
|
||||||
|
return new Set(baseline.map(f => f.file_code));
|
||||||
|
}
|
||||||
|
if (hosterName === 'doodstream.com') {
|
||||||
|
const baseline = await _fetchDoodstreamFileList(apiKey, signal);
|
||||||
|
return new Set(baseline.map(f => f.file_code));
|
||||||
|
}
|
||||||
|
} catch { /* leave caller to fall back to per-job fetch */ }
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
uploadFile,
|
uploadFile,
|
||||||
|
prefetchBaseline,
|
||||||
HOSTER_CONFIGS,
|
HOSTER_CONFIGS,
|
||||||
__test: {
|
__test: {
|
||||||
extractUploadServerUrl,
|
extractUploadServerUrl,
|
||||||
|
|||||||
142
lib/stats.js
Normal file
142
lib/stats.js
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
(function (root) {
|
||||||
|
function summarizePerHoster(history, opts) {
|
||||||
|
const out = {};
|
||||||
|
if (!Array.isArray(history)) return out;
|
||||||
|
const cutoff = opts && Number.isFinite(opts.sinceMs) ? opts.sinceMs : null;
|
||||||
|
const limitBatches = opts && Number.isFinite(opts.lastNBatches) && opts.lastNBatches > 0 ? opts.lastNBatches : null;
|
||||||
|
|
||||||
|
const entries = [...history];
|
||||||
|
entries.sort((a, b) => {
|
||||||
|
const ta = a && a.timestamp ? Date.parse(a.timestamp) : 0;
|
||||||
|
const tb = b && b.timestamp ? Date.parse(b.timestamp) : 0;
|
||||||
|
return tb - ta;
|
||||||
|
});
|
||||||
|
const sliced = limitBatches ? entries.slice(0, limitBatches) : entries;
|
||||||
|
|
||||||
|
for (const batch of sliced) {
|
||||||
|
if (!batch || !Array.isArray(batch.files)) continue;
|
||||||
|
if (cutoff !== null) {
|
||||||
|
const ts = batch.timestamp ? Date.parse(batch.timestamp) : 0;
|
||||||
|
if (!ts || ts < cutoff) continue;
|
||||||
|
}
|
||||||
|
for (const file of batch.files) {
|
||||||
|
if (!file || !Array.isArray(file.results)) continue;
|
||||||
|
for (const r of file.results) {
|
||||||
|
if (!r || !r.hoster) continue;
|
||||||
|
const bucket = out[r.hoster] || (out[r.hoster] = { ok: 0, fail: 0, total: 0 });
|
||||||
|
bucket.total++;
|
||||||
|
if (r.status === 'done') bucket.ok++;
|
||||||
|
else bucket.fail++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const h of Object.keys(out)) {
|
||||||
|
const b = out[h];
|
||||||
|
b.rate = b.total > 0 ? b.ok / b.total : null;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function classifyErrorCategory(err) {
|
||||||
|
if (!err || typeof err !== 'string') return 'unknown';
|
||||||
|
const s = err.toLowerCase();
|
||||||
|
if (/abgebrochen|aborted|cancel/.test(s)) return 'aborted';
|
||||||
|
if (/not video file format|kein videoformat|invalid file|wrong format|duplicate|already exists|file too (small|big|large)|datei zu (gro|klein)/.test(s)) return 'file-rejected';
|
||||||
|
if (/quota|storage (full|exhausted|voll)|account (full|banned|suspended)|disk (space )?full|insufficient (disk )?space|not enough (disk )?(space|storage)/.test(s)) return 'account-error';
|
||||||
|
if (/csrf|kein upload-server|server.*?(busy|unavailable|try again)|no servers available|filecode|kein filecode|empty.*?(form|response)/.test(s)) return 'hoster-transient';
|
||||||
|
if (/timeout|econnreset|enotfound|fetch failed|network|socket hang up|abort/.test(s)) return 'network';
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeBatchErrors(batchSummary) {
|
||||||
|
const buckets = {
|
||||||
|
'file-rejected': [],
|
||||||
|
'account-error': [],
|
||||||
|
'hoster-transient': [],
|
||||||
|
'network': [],
|
||||||
|
'unknown': [],
|
||||||
|
'aborted': []
|
||||||
|
};
|
||||||
|
if (!batchSummary || !Array.isArray(batchSummary.files)) return buckets;
|
||||||
|
for (const f of batchSummary.files) {
|
||||||
|
if (!f || !Array.isArray(f.results)) continue;
|
||||||
|
for (const r of f.results) {
|
||||||
|
if (!r || r.status === 'done') continue;
|
||||||
|
const cat = classifyErrorCategory(r.error);
|
||||||
|
buckets[cat].push({
|
||||||
|
fileName: f.name || f.fileName || '',
|
||||||
|
hoster: r.hoster || '',
|
||||||
|
error: r.error || '',
|
||||||
|
jobId: r.jobId || null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return buckets;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RETRYABLE_CATEGORIES = new Set(['hoster-transient', 'network', 'unknown']);
|
||||||
|
function isRetryableCategory(cat) {
|
||||||
|
return RETRYABLE_CATEGORIES.has(cat);
|
||||||
|
}
|
||||||
|
|
||||||
|
const CATEGORY_LABELS = {
|
||||||
|
'file-rejected': 'Datei abgelehnt',
|
||||||
|
'account-error': 'Account-Problem',
|
||||||
|
'hoster-transient': 'Hoster-Flake',
|
||||||
|
'network': 'Netzwerk',
|
||||||
|
'unknown': 'Unbekannt',
|
||||||
|
'aborted': 'Abgebrochen'
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatLinks(rows, format) {
|
||||||
|
if (!Array.isArray(rows)) return '';
|
||||||
|
const safe = rows.filter(r => r && r.url);
|
||||||
|
if (safe.length === 0) return '';
|
||||||
|
switch (format) {
|
||||||
|
case 'plain':
|
||||||
|
return safe.map(r => r.url).join('\n');
|
||||||
|
case 'bbcode':
|
||||||
|
return safe.map(r => {
|
||||||
|
const label = r.fileName || r.hoster || r.url;
|
||||||
|
return `[url=${r.url}]${label}[/url]`;
|
||||||
|
}).join('\n');
|
||||||
|
case 'markdown':
|
||||||
|
return safe.map(r => {
|
||||||
|
const label = r.fileName || r.hoster || r.url;
|
||||||
|
return `- [${label}](${r.url})`;
|
||||||
|
}).join('\n');
|
||||||
|
case 'html':
|
||||||
|
return safe.map(r => {
|
||||||
|
const label = r.fileName || r.hoster || r.url;
|
||||||
|
return `<a href="${r.url}">${label}</a>`;
|
||||||
|
}).join('\n');
|
||||||
|
case 'csv': {
|
||||||
|
const head = 'fileName,hoster,url\n';
|
||||||
|
return head + safe.map(r => {
|
||||||
|
const esc = (v) => `"${String(v || '').replace(/"/g, '""')}"`;
|
||||||
|
return [esc(r.fileName), esc(r.hoster), esc(r.url)].join(',');
|
||||||
|
}).join('\n');
|
||||||
|
}
|
||||||
|
case 'json':
|
||||||
|
return JSON.stringify(safe.map(r => ({ fileName: r.fileName || '', hoster: r.hoster || '', url: r.url })), null, 2);
|
||||||
|
default:
|
||||||
|
return safe.map(r => r.url).join('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const api = {
|
||||||
|
summarizePerHoster,
|
||||||
|
classifyErrorCategory,
|
||||||
|
summarizeBatchErrors,
|
||||||
|
isRetryableCategory,
|
||||||
|
RETRYABLE_CATEGORIES,
|
||||||
|
CATEGORY_LABELS,
|
||||||
|
formatLinks
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = api;
|
||||||
|
} else if (root) {
|
||||||
|
root.Stats = api;
|
||||||
|
}
|
||||||
|
})(typeof window !== 'undefined' ? window : this);
|
||||||
@ -2,14 +2,14 @@ const { EventEmitter } = require('events');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const { uploadFile } = require('./hosters');
|
const { uploadFile, prefetchBaseline } = require('./hosters');
|
||||||
const VidmolyUploader = require('./vidmoly-upload');
|
const VidmolyUploader = require('./vidmoly-upload');
|
||||||
const VoeUploader = require('./voe-upload');
|
const VoeUploader = require('./voe-upload');
|
||||||
const DoodstreamUploader = require('./doodstream-upload');
|
const DoodstreamUploader = require('./doodstream-upload');
|
||||||
const ClouddropUploader = require('./clouddrop-upload');
|
const ClouddropUploader = require('./clouddrop-upload');
|
||||||
const Semaphore = require('./semaphore');
|
const Semaphore = require('./semaphore');
|
||||||
const Throttle = require('./throttle');
|
const Throttle = require('./throttle');
|
||||||
const { probeFileHead, summarizeFileStat } = require('./file-probe');
|
const { probeFileHead } = require('./file-probe');
|
||||||
|
|
||||||
const DEFAULT_SETTINGS = {
|
const DEFAULT_SETTINGS = {
|
||||||
retries: 3,
|
retries: 3,
|
||||||
@ -42,6 +42,7 @@ class UploadManager extends EventEmitter {
|
|||||||
this._failedAccounts = new Map(); // hoster -> Set of failed accountIds
|
this._failedAccounts = new Map(); // hoster -> Set of failed accountIds
|
||||||
this._accountOverrides = new Map(); // hoster -> fallback account object
|
this._accountOverrides = new Map(); // hoster -> fallback account object
|
||||||
this._doodApiKeyCache = new Map(); // accountId/username -> derived doodstream API key ('' = tried, none)
|
this._doodApiKeyCache = new Map(); // accountId/username -> derived doodstream API key ('' = tried, none)
|
||||||
|
this._baselineCache = new Map(); // hoster:apiKey -> Promise<Set<file_code>> (one fetch shared across all jobs in batch)
|
||||||
}
|
}
|
||||||
|
|
||||||
switchAccount(hoster, fallbackAccount) {
|
switchAccount(hoster, fallbackAccount) {
|
||||||
@ -66,6 +67,16 @@ class UploadManager extends EventEmitter {
|
|||||||
return this._accountOverrides.get(hoster) || null;
|
return this._accountOverrides.get(hoster) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clearFailedAccount(hoster, accountId) {
|
||||||
|
return this._failedAccounts.delete(`${hoster}:${accountId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearAllFailedAccounts() {
|
||||||
|
const n = this._failedAccounts.size;
|
||||||
|
this._failedAccounts.clear();
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
// True if the hoster has a usable override stored that differs from the
|
// 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
|
||||||
@ -268,6 +279,7 @@ class UploadManager extends EventEmitter {
|
|||||||
this.jobAbortControllers.clear();
|
this.jobAbortControllers.clear();
|
||||||
this.cancelledJobIds.clear();
|
this.cancelledJobIds.clear();
|
||||||
this._doodApiKeyCache.clear(); // re-derive doodstream keys fresh each batch
|
this._doodApiKeyCache.clear(); // re-derive doodstream keys fresh each batch
|
||||||
|
this._baselineCache.clear(); // re-fetch baselines per batch (a long batch could outlast remote-side relevance)
|
||||||
this.semaphores = {};
|
this.semaphores = {};
|
||||||
this.globalSemaphore = null;
|
this.globalSemaphore = null;
|
||||||
this.globalThrottle = null;
|
this.globalThrottle = null;
|
||||||
@ -298,18 +310,30 @@ class UploadManager extends EventEmitter {
|
|||||||
this._batchResults = results;
|
this._batchResults = results;
|
||||||
this._additionalPromises = []; // Track jobs added mid-batch via addJobs()
|
this._additionalPromises = []; // Track jobs added mid-batch via addJobs()
|
||||||
|
|
||||||
for (const task of tasks) {
|
const DEDUP_CHUNK = 200;
|
||||||
const fileName = path.basename(task.file);
|
for (let i = 0; i < tasks.length; i += DEDUP_CHUNK) {
|
||||||
if (!results.has(task.file)) {
|
const end = Math.min(i + DEDUP_CHUNK, tasks.length);
|
||||||
let size = 0;
|
for (let j = i; j < end; j++) {
|
||||||
try { size = fs.statSync(task.file).size; } catch {}
|
const task = tasks[j];
|
||||||
results.set(task.file, { name: fileName, size, results: [] });
|
if (!results.has(task.file)) {
|
||||||
|
const fileName = path.basename(task.file);
|
||||||
|
let size = 0;
|
||||||
|
try { size = fs.statSync(task.file).size; } catch {}
|
||||||
|
results.set(task.file, { name: fileName, size, results: [] });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
if (end < tasks.length) await new Promise(setImmediate);
|
||||||
}
|
}
|
||||||
|
|
||||||
this._startStatsTimer();
|
this._startStatsTimer();
|
||||||
|
|
||||||
const promises = tasks.map((task) => this._runJob(task, results, signal));
|
const SPAWN_CHUNK = 100;
|
||||||
|
const promises = [];
|
||||||
|
for (let i = 0; i < tasks.length; i += SPAWN_CHUNK) {
|
||||||
|
const end = Math.min(i + SPAWN_CHUNK, tasks.length);
|
||||||
|
for (let j = i; j < end; j++) promises.push(this._runJob(tasks[j], results, signal));
|
||||||
|
if (end < tasks.length) await new Promise(setImmediate);
|
||||||
|
}
|
||||||
await Promise.allSettled(promises);
|
await Promise.allSettled(promises);
|
||||||
// Wait for any jobs added mid-batch via addJobs()
|
// Wait for any jobs added mid-batch via addJobs()
|
||||||
while (this._additionalPromises.length > 0) {
|
while (this._additionalPromises.length > 0) {
|
||||||
@ -345,7 +369,12 @@ class UploadManager extends EventEmitter {
|
|||||||
const fileName = path.basename(task.file);
|
const fileName = path.basename(task.file);
|
||||||
let fileSize = 0;
|
let fileSize = 0;
|
||||||
let fileNotFound = false;
|
let fileNotFound = false;
|
||||||
try { fileSize = fs.statSync(task.file).size; } catch { fileNotFound = true; }
|
const cachedResult = results && results.get(task.file);
|
||||||
|
if (cachedResult && typeof cachedResult.size === 'number' && cachedResult.size > 0) {
|
||||||
|
fileSize = cachedResult.size;
|
||||||
|
} else {
|
||||||
|
try { fileSize = fs.statSync(task.file).size; } catch { fileNotFound = true; }
|
||||||
|
}
|
||||||
|
|
||||||
const maxAttempts = Math.max(1, (settings.retries || 0) + 1);
|
const maxAttempts = Math.max(1, (settings.retries || 0) + 1);
|
||||||
const jobAbortController = new AbortController();
|
const jobAbortController = new AbortController();
|
||||||
@ -410,36 +439,30 @@ class UploadManager extends EventEmitter {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this._emitProgress(uploadId, fileName, task.hoster, { accountId: task.accountId,
|
// The initial 'queued' emit per job is suppressed: with N=2000+ tasks
|
||||||
jobId,
|
// it produces 2000+ main→renderer IPCs back-to-back at startBatch and
|
||||||
status: 'queued',
|
// freezes the renderer event loop for tens of seconds. The renderer
|
||||||
progress: 0,
|
// already holds each job in 'queued'/'preview' state from its own
|
||||||
bytesUploaded: 0,
|
// queueJobs array; the first event it actually needs from main is the
|
||||||
bytesTotal: fileSize,
|
// 'getting-server' / 'uploading' transition for the jobs that the
|
||||||
speedKbs: 0,
|
// semaphore lets through.
|
||||||
elapsed: 0,
|
await hosterSemaphore.acquire(signal);
|
||||||
remaining: 0,
|
hosterSlotAcquired = true;
|
||||||
error: null,
|
|
||||||
result: null,
|
|
||||||
attempt: 0,
|
|
||||||
maxAttempts
|
|
||||||
});
|
|
||||||
|
|
||||||
const fileStat = summarizeFileStat(task.file);
|
let fileProbe = null;
|
||||||
const fileProbe = await probeFileHead(task.file, 64).catch((err) => ({ ok: false, error: err.message, kind: 'unreadable' }));
|
try {
|
||||||
|
fileProbe = await probeFileHead(task.file, 64);
|
||||||
|
} catch (err) {
|
||||||
|
fileProbe = { ok: false, error: err && err.message, kind: 'unreadable' };
|
||||||
|
}
|
||||||
this._rotLog('upload-start', {
|
this._rotLog('upload-start', {
|
||||||
jobId, hoster: task.hoster, accountId: task.accountId, fileName,
|
jobId, hoster: task.hoster, accountId: task.accountId, fileName,
|
||||||
fileSize: fileStat.size, fileMtime: fileStat.mtime,
|
fileSize,
|
||||||
detectedKind: fileProbe && fileProbe.kind ? fileProbe.kind : 'unknown',
|
detectedKind: fileProbe && fileProbe.kind ? fileProbe.kind : 'unknown',
|
||||||
isVideoLike: !!(fileProbe && fileProbe.isVideoLike),
|
isVideoLike: !!(fileProbe && fileProbe.isVideoLike),
|
||||||
headHex: fileProbe && fileProbe.headHex ? fileProbe.headHex.slice(0, 32) : null
|
headHex: fileProbe && fileProbe.headHex ? fileProbe.headHex.slice(0, 32) : null
|
||||||
});
|
});
|
||||||
|
|
||||||
// Acquire hoster semaphore first so jobs waiting for a hoster slot
|
|
||||||
// don't waste global slots (prevents underutilization)
|
|
||||||
await hosterSemaphore.acquire(signal);
|
|
||||||
hosterSlotAcquired = true;
|
|
||||||
|
|
||||||
if (globalSemaphore) {
|
if (globalSemaphore) {
|
||||||
await globalSemaphore.acquire(signal);
|
await globalSemaphore.acquire(signal);
|
||||||
globalSlotAcquired = true;
|
globalSlotAcquired = true;
|
||||||
@ -910,7 +933,9 @@ class UploadManager extends EventEmitter {
|
|||||||
const apiKey = await this._resolveDoodstreamApiKey(task);
|
const apiKey = await this._resolveDoodstreamApiKey(task);
|
||||||
if (apiKey) {
|
if (apiKey) {
|
||||||
this._rotLog('doodstream-via-api', { accountId: task.accountId, fileName: path.basename(task.file) });
|
this._rotLog('doodstream-via-api', { accountId: task.accountId, fileName: path.basename(task.file) });
|
||||||
return uploadFile('doodstream.com', task.file, apiKey, progressCb, signal, throttle);
|
return uploadFile('doodstream.com', task.file, apiKey, progressCb, signal, throttle, {
|
||||||
|
doodBaseline: await this._getBaseline('doodstream.com', apiKey, signal)
|
||||||
|
});
|
||||||
}
|
}
|
||||||
this._rotLog('doodstream-via-web', { accountId: task.accountId, fileName: path.basename(task.file) });
|
this._rotLog('doodstream-via-web', { accountId: task.accountId, fileName: path.basename(task.file) });
|
||||||
const dood = new DoodstreamUploader();
|
const dood = new DoodstreamUploader();
|
||||||
@ -920,10 +945,23 @@ class UploadManager extends EventEmitter {
|
|||||||
const clouddrop = new ClouddropUploader(task.apiKey);
|
const clouddrop = new ClouddropUploader(task.apiKey);
|
||||||
return clouddrop.upload(task.file, progressCb, signal, throttle);
|
return clouddrop.upload(task.file, progressCb, signal, throttle);
|
||||||
} else {
|
} else {
|
||||||
return uploadFile(task.hoster, task.file, task.apiKey, progressCb, signal, throttle);
|
const baselineOpts = {};
|
||||||
|
if (task.hoster === 'byse.sx') baselineOpts.byseBaseline = await this._getBaseline('byse.sx', task.apiKey, signal);
|
||||||
|
if (task.hoster === 'doodstream.com') baselineOpts.doodBaseline = await this._getBaseline('doodstream.com', task.apiKey, signal);
|
||||||
|
return uploadFile(task.hoster, task.file, task.apiKey, progressCb, signal, throttle, baselineOpts);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_getBaseline(hosterName, apiKey, signal) {
|
||||||
|
if (!apiKey) return Promise.resolve(null);
|
||||||
|
const key = `${hosterName}:${apiKey}`;
|
||||||
|
let pending = this._baselineCache.get(key);
|
||||||
|
if (pending) return pending;
|
||||||
|
pending = prefetchBaseline(hosterName, apiKey, signal);
|
||||||
|
this._baselineCache.set(key, pending);
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
|
|
||||||
// Resolve (and cache per batch) the doodstream API key for a login-only
|
// Resolve (and cache per batch) the doodstream API key for a login-only
|
||||||
// account by logging in once and scraping+validating it from the session.
|
// account by logging in once and scraping+validating it from the session.
|
||||||
// Returns the key string, or '' when none could be derived (cached either way
|
// Returns the key string, or '' when none could be derived (cached either way
|
||||||
|
|||||||
178
main.js
178
main.js
@ -216,23 +216,10 @@ 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`;
|
||||||
// Write synchronously. Rotation events are rare (a handful per batch) so
|
_rotLogBuffer.push(line);
|
||||||
// the batching optimization from debugLog doesn't buy us anything, and
|
if (!_rotLogFlushTimer) {
|
||||||
// syncing guarantees the user can refresh the file and see fresh entries
|
_rotLogFlushTimer = setTimeout(() => { _rotLogFlushTimer = null; _flushRotLog(); }, 500);
|
||||||
// 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);
|
||||||
@ -1297,33 +1284,32 @@ 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 = [];
|
||||||
const walk = (dir) => {
|
for (const folder of result.filePaths) await walkFolderAsync(folder, files);
|
||||||
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 = [];
|
||||||
const walk = (dir) => {
|
await walkFolderAsync(folderPath, files);
|
||||||
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;
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -1383,8 +1369,27 @@ 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 || {});
|
||||||
|
|
||||||
|
const _progressByJob = new Map();
|
||||||
|
const _progressTerminalQueue = [];
|
||||||
|
let _progressFlushTimer = null;
|
||||||
|
const PROGRESS_BATCH_INTERVAL_MS = 100;
|
||||||
|
function _scheduleProgressFlush() {
|
||||||
|
if (_progressFlushTimer) return;
|
||||||
|
_progressFlushTimer = setTimeout(() => {
|
||||||
|
_progressFlushTimer = null;
|
||||||
|
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||||
|
_progressByJob.clear();
|
||||||
|
_progressTerminalQueue.length = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const batch = _progressTerminalQueue.splice(0);
|
||||||
|
for (const v of _progressByJob.values()) batch.push(v);
|
||||||
|
_progressByJob.clear();
|
||||||
|
if (batch.length) mainWindow.webContents.send('upload-progress-batch', batch);
|
||||||
|
}, PROGRESS_BATCH_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
uploadManager.on('progress', (data) => {
|
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, {
|
||||||
@ -1393,10 +1398,6 @@ 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) {
|
||||||
@ -1409,9 +1410,16 @@ 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)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
const isTerminal = data.status === 'done' || data.status === 'error' || data.status === 'aborted' || data.status === 'skipped';
|
||||||
mainWindow.webContents.send('upload-progress', data);
|
if (isTerminal) {
|
||||||
|
if (data.jobId) _progressByJob.delete(data.jobId);
|
||||||
|
_progressTerminalQueue.push(data);
|
||||||
|
} else if (data.jobId) {
|
||||||
|
_progressByJob.set(data.jobId, data);
|
||||||
|
} else {
|
||||||
|
_progressTerminalQueue.push(data);
|
||||||
}
|
}
|
||||||
|
_scheduleProgressFlush();
|
||||||
});
|
});
|
||||||
|
|
||||||
uploadManager.on('stats', (data) => {
|
uploadManager.on('stats', (data) => {
|
||||||
@ -1445,6 +1453,15 @@ ipcMain.handle('start-upload', (_event, payload) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const ROT_LOG_RENDERER_EVENTS = new Set([
|
||||||
|
'switchAccount',
|
||||||
|
'pre-job-swap',
|
||||||
|
'try-alternate-after-fail',
|
||||||
|
'mark-failed',
|
||||||
|
'rotation-end',
|
||||||
|
'doodstream-via-api',
|
||||||
|
'doodstream-via-web'
|
||||||
|
]);
|
||||||
uploadManager.on('rot-log', (entry) => {
|
uploadManager.on('rot-log', (entry) => {
|
||||||
const { ts, event, ...rest } = entry;
|
const { ts, event, ...rest } = entry;
|
||||||
const pairs = Object.entries(rest)
|
const pairs = Object.entries(rest)
|
||||||
@ -1454,7 +1471,7 @@ ipcMain.handle('start-upload', (_event, payload) => {
|
|||||||
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 (mainWindow && !mainWindow.isDestroyed()) {
|
if (mainWindow && !mainWindow.isDestroyed() && ROT_LOG_RENDERER_EVENTS.has(event)) {
|
||||||
mainWindow.webContents.send('account-rotation-log', entry);
|
mainWindow.webContents.send('account-rotation-log', entry);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -1575,6 +1592,33 @@ 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);
|
||||||
@ -1706,12 +1750,19 @@ 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: [{ name: 'Multi-Hoster Backup', extensions: ['mhu'] }]
|
filters: [
|
||||||
|
{ 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();
|
||||||
const encrypted = backupCrypto.encrypt(config);
|
if (filePath.toLowerCase().endsWith('.json')) {
|
||||||
fs.writeFileSync(filePath, encrypted);
|
fs.writeFileSync(filePath, JSON.stringify(config, null, 2), 'utf-8');
|
||||||
|
} else {
|
||||||
|
const encrypted = backupCrypto.encrypt(config);
|
||||||
|
fs.writeFileSync(filePath, encrypted);
|
||||||
|
}
|
||||||
return { ok: true, path: filePath };
|
return { ok: true, path: filePath };
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -1723,7 +1774,11 @@ 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: [{ name: 'Multi-Hoster Backup', extensions: ['mhu'] }],
|
filters: [
|
||||||
|
{ 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 };
|
||||||
@ -1732,14 +1787,25 @@ ipcMain.handle('import-backup', async (_event, legacyPassword) => {
|
|||||||
_lastImportPath = sourcePath;
|
_lastImportPath = sourcePath;
|
||||||
}
|
}
|
||||||
let imported;
|
let imported;
|
||||||
try {
|
const looksLikeJson = buffer.length >= 1 && (buffer[0] === 0x7B || buffer[0] === 0x20 || buffer[0] === 0x0A || buffer[0] === 0x0D || buffer[0] === 0x09 || buffer[0] === 0xEF);
|
||||||
imported = backupCrypto.decrypt(buffer, legacyPassword);
|
if (looksLikeJson) {
|
||||||
} catch (err) {
|
try {
|
||||||
if (err && err.needsPassword) {
|
const text = buffer.toString('utf-8').replace(/^\uFEFF/, '');
|
||||||
return { ok: false, needsPassword: true };
|
imported = JSON.parse(text);
|
||||||
|
} catch (err) {
|
||||||
|
_lastImportPath = null;
|
||||||
|
return { ok: false, error: 'Klartext-Backup ist kein gültiges JSON: ' + (err.message || err) };
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
imported = backupCrypto.decrypt(buffer, legacyPassword);
|
||||||
|
} catch (err) {
|
||||||
|
if (err && err.needsPassword) {
|
||||||
|
return { ok: false, needsPassword: true, hint: 'Falls dieses Backup mit der aktuellen Version erzeugt wurde, ist die Datei vermutlich beim Transfer beschädigt worden (z. B. FTP-Text-Modus). Versuch es mit einem Klartext-JSON-Export.' };
|
||||||
|
}
|
||||||
|
_lastImportPath = null;
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
_lastImportPath = null;
|
|
||||||
throw err;
|
|
||||||
}
|
}
|
||||||
_lastImportPath = null;
|
_lastImportPath = null;
|
||||||
// Validate imported data has required structure
|
// Validate imported data has required structure
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "multi-hoster-uploader",
|
"name": "multi-hoster-uploader",
|
||||||
"version": "3.3.43",
|
"version": "3.3.50",
|
||||||
"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": {
|
||||||
|
|||||||
@ -93,6 +93,9 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
onUploadProgress: (callback) => {
|
onUploadProgress: (callback) => {
|
||||||
ipcRenderer.on('upload-progress', (_event, data) => callback(data));
|
ipcRenderer.on('upload-progress', (_event, data) => callback(data));
|
||||||
},
|
},
|
||||||
|
onUploadProgressBatch: (callback) => {
|
||||||
|
ipcRenderer.on('upload-progress-batch', (_event, batch) => callback(batch));
|
||||||
|
},
|
||||||
onUploadBatchDone: (callback) => {
|
onUploadBatchDone: (callback) => {
|
||||||
ipcRenderer.on('upload-batch-done', (_event, data) => callback(data));
|
ipcRenderer.on('upload-batch-done', (_event, data) => callback(data));
|
||||||
},
|
},
|
||||||
@ -110,6 +113,9 @@ 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'),
|
getLogPaths: () => ipcRenderer.invoke('get-log-paths'),
|
||||||
revealLogFile: (target) => ipcRenderer.invoke('reveal-log-file', target),
|
revealLogFile: (target) => ipcRenderer.invoke('reveal-log-file', target),
|
||||||
setLogVerbose: (enabled) => ipcRenderer.invoke('set-log-verbose', enabled),
|
setLogVerbose: (enabled) => ipcRenderer.invoke('set-log-verbose', enabled),
|
||||||
|
|||||||
213
renderer/app.js
213
renderer/app.js
@ -92,6 +92,7 @@ async function init() {
|
|||||||
setupDragDrop();
|
setupDragDrop();
|
||||||
restoreQueueColumnWidths();
|
restoreQueueColumnWidths();
|
||||||
loadHistory();
|
loadHistory();
|
||||||
|
_refreshSessionFailedSnapshot();
|
||||||
renderRecentUploadsPanel();
|
renderRecentUploadsPanel();
|
||||||
updateUploadView();
|
updateUploadView();
|
||||||
updateStatusBar();
|
updateStatusBar();
|
||||||
@ -107,24 +108,19 @@ 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);
|
||||||
@ -1447,7 +1443,7 @@ async function doBackupExport() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function askLegacyBackupPassword() {
|
function askLegacyBackupPassword(hint) {
|
||||||
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';
|
||||||
@ -1460,7 +1456,7 @@ function askLegacyBackupPassword() {
|
|||||||
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 = 'Passwort erforderlich';
|
h3.textContent = 'Backup nicht entschlüsselbar';
|
||||||
header.appendChild(h3);
|
header.appendChild(h3);
|
||||||
|
|
||||||
const body = document.createElement('div');
|
const body = document.createElement('div');
|
||||||
@ -1468,7 +1464,15 @@ function askLegacyBackupPassword() {
|
|||||||
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 = 'Dieses Backup wurde mit einem Passwort verschlüsselt.';
|
p.textContent = 'Wenn das Backup mit der alten Passwort-Option (vor v3.0) erstellt wurde, hier eingeben.';
|
||||||
|
if (hint) {
|
||||||
|
const p2 = document.createElement('p');
|
||||||
|
p2.style.margin = '0 0 10px';
|
||||||
|
p2.style.fontSize = '12px';
|
||||||
|
p2.style.color = 'var(--text-dim)';
|
||||||
|
p2.textContent = hint;
|
||||||
|
body.appendChild(p2);
|
||||||
|
}
|
||||||
const input = document.createElement('input');
|
const input = document.createElement('input');
|
||||||
input.type = 'password';
|
input.type = 'password';
|
||||||
input.className = 'key-input';
|
input.className = 'key-input';
|
||||||
@ -1512,7 +1516,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();
|
const entered = await askLegacyBackupPassword(result.hint);
|
||||||
if (entered) doBackupImport(entered);
|
if (entered) doBackupImport(entered);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -2056,6 +2060,85 @@ 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) {
|
||||||
@ -2447,8 +2530,10 @@ async function executeHealthCheck(hosters, _mode) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function runHealthCheck(mode = 'manual', requestedHosters = null) {
|
async function runHealthCheck(mode = 'manual', requestedHosters = null) {
|
||||||
if (healthCheckRunning || (uploading && mode === 'manual')) return [];
|
if (healthCheckRunning) {
|
||||||
// Build check list: all enabled accounts with creds
|
if (mode === 'manual') showCopyToast('Account-Check läuft bereits.');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
let hosters;
|
let hosters;
|
||||||
if (Array.isArray(requestedHosters) && requestedHosters.length > 0) {
|
if (Array.isArray(requestedHosters) && requestedHosters.length > 0) {
|
||||||
hosters = requestedHosters;
|
hosters = requestedHosters;
|
||||||
@ -3118,11 +3203,16 @@ 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' : ''}" data-account-id="${account.id}" data-account-hoster="${name}" draggable="true">
|
<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-drag-handle" title="Ziehen zum Sortieren">☰</div>
|
<div class="account-card-drag-handle" title="Ziehen zum Sortieren">☰</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></div>
|
<div class="account-card-title">${escapeHtml(getAccountDisplayName(name, account))} <span class="account-priority-badge">${priorityLabel}</span> ${sessionPausedBadge}</div>
|
||||||
<div class="account-card-subtitle" title="${escapeAttr(subtitleText)}">${escapeHtml(subtitleText)}${st.message && !isDisabled ? ` • ${escapeHtml(st.message)}` : ''}</div>
|
<div class="account-card-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}">
|
||||||
@ -3267,6 +3357,10 @@ function _buildAccountHosterGroupHtml(name, accounts) {
|
|||||||
let cardsHtml = '';
|
let cardsHtml = '';
|
||||||
accounts.forEach((account, idx) => { cardsHtml += _buildAccountCardHtml(name, account, idx); });
|
accounts.forEach((account, idx) => { cardsHtml += _buildAccountCardHtml(name, account, idx); });
|
||||||
const bodyStyle = isOpen ? '' : 'style="display:none"';
|
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}">
|
return `<div class="account-hoster-group" data-hoster-group="${name}">
|
||||||
<div class="account-hoster-group-header" data-hoster-toggle="${name}">
|
<div class="account-hoster-group-header" data-hoster-toggle="${name}">
|
||||||
<span class="panel-arrow">${arrow}</span>
|
<span class="panel-arrow">${arrow}</span>
|
||||||
@ -3275,11 +3369,21 @@ function _buildAccountHosterGroupHtml(name, accounts) {
|
|||||||
<span class="account-hoster-group-count">${countLabel}</span>
|
<span class="account-hoster-group-count">${countLabel}</span>
|
||||||
${summary.disabled ? `<span class="account-hoster-group-meta">${summary.disabled} deaktiviert</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>` : ''}
|
${summary.error ? `<span class="account-hoster-group-meta error">${summary.error} Fehler</span>` : ''}
|
||||||
|
${lifeMeta}
|
||||||
</div>
|
</div>
|
||||||
<div class="account-hoster-group-body" ${bodyStyle}>${cardsHtml}</div>
|
<div class="account-hoster-group-body" ${bodyStyle}>${cardsHtml}</div>
|
||||||
</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
|
||||||
@ -3309,6 +3413,18 @@ function bindAccountListeners(container) {
|
|||||||
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;
|
||||||
@ -3798,6 +3914,8 @@ 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) {
|
||||||
@ -3995,17 +4113,32 @@ 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>`;
|
||||||
|
|
||||||
rows.forEach(row => {
|
const parts = [html];
|
||||||
html += `<tr class="history-row${row.isError ? ' error' : ''}" data-link="${escapeAttr(row.link)}">
|
const len = rows.length;
|
||||||
<td class="col-date">${escapeHtml(row.date)}</td>
|
for (let i = 0; i < len; i++) {
|
||||||
<td class="col-filename">${escapeHtml(row.filename)}</td>
|
const row = rows[i];
|
||||||
<td class="col-host">${escapeHtml(row.host)}</td>
|
const link = row.link || '';
|
||||||
<td class="col-link">${escapeHtml(row.link)}</td>
|
const date = escapeHtml(row.date);
|
||||||
</tr>`;
|
const filename = escapeHtml(row.filename);
|
||||||
});
|
const host = escapeHtml(row.host);
|
||||||
|
const linkHtml = escapeHtml(link);
|
||||||
html += '</tbody></table>';
|
const linkAttr = escapeAttr(link);
|
||||||
container.innerHTML = html;
|
parts.push('<tr class="history-row');
|
||||||
|
if (row.isError) parts.push(' error');
|
||||||
|
parts.push('" data-link="');
|
||||||
|
parts.push(linkAttr);
|
||||||
|
parts.push('"><td class="col-date">');
|
||||||
|
parts.push(date);
|
||||||
|
parts.push('</td><td class="col-filename">');
|
||||||
|
parts.push(filename);
|
||||||
|
parts.push('</td><td class="col-host">');
|
||||||
|
parts.push(host);
|
||||||
|
parts.push('</td><td class="col-link">');
|
||||||
|
parts.push(linkHtml);
|
||||||
|
parts.push('</td></tr>');
|
||||||
|
}
|
||||||
|
parts.push('</tbody></table>');
|
||||||
|
container.innerHTML = parts.join('');
|
||||||
|
|
||||||
// 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
|
||||||
@ -4385,14 +4518,20 @@ async function importUploadLog() {
|
|||||||
|
|
||||||
// --- Link operations ---
|
// --- Link operations ---
|
||||||
function copyAllLinks() {
|
function copyAllLinks() {
|
||||||
const links = queueJobs
|
const rows = queueJobs
|
||||||
.filter(j => j.status === 'done' && j.result)
|
.filter(j => j.status === 'done' && j.result)
|
||||||
.map(j => j.result.download_url || j.result.embed_url || '')
|
.map(j => ({
|
||||||
.filter(Boolean);
|
fileName: j.fileName || '',
|
||||||
if (links.length > 0) {
|
hoster: j.hoster || '',
|
||||||
window.api.copyToClipboard(links.join('\n'));
|
url: j.result.download_url || j.result.embed_url || ''
|
||||||
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 ---
|
||||||
|
|||||||
@ -93,6 +93,14 @@
|
|||||||
|
|
||||||
<div class="queue-actions" id="queueActions" style="display:none">
|
<div class="queue-actions" id="queueActions" style="display:none">
|
||||||
<button class="btn btn-xs btn-primary" id="copyAllLinksBtn">Alle Links kopieren</button>
|
<button class="btn btn-xs btn-primary" id="copyAllLinksBtn">Alle Links kopieren</button>
|
||||||
|
<select class="hs-input" id="linkExportFormat" title="Ausgabe-Format der kopierten Links" style="max-width:none;width:auto;min-width:130px">
|
||||||
|
<option value="plain">Plaintext</option>
|
||||||
|
<option value="bbcode">BBCode</option>
|
||||||
|
<option value="markdown">Markdown</option>
|
||||||
|
<option value="html">HTML</option>
|
||||||
|
<option value="csv">CSV</option>
|
||||||
|
<option value="json">JSON</option>
|
||||||
|
</select>
|
||||||
<button class="btn btn-xs btn-secondary" id="retryFailedBtn" style="display:none">Fehlgeschlagene erneut</button>
|
<button class="btn btn-xs btn-secondary" id="retryFailedBtn" style="display:none">Fehlgeschlagene erneut</button>
|
||||||
<button class="btn btn-xs btn-secondary" id="importLogBtn" title="Log importieren — bereits hochgeladene aus Queue entfernen">Log importieren</button>
|
<button class="btn btn-xs btn-secondary" id="importLogBtn" title="Log importieren — bereits hochgeladene aus Queue entfernen">Log importieren</button>
|
||||||
</div>
|
</div>
|
||||||
@ -334,9 +342,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="modal" id="batchSummaryModal" style="display:none">
|
||||||
|
<div class="modal-content" style="max-width:680px">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>Batch-Zusammenfassung</h2>
|
||||||
|
<button class="icon-btn" id="batchSummaryClose" aria-label="Schließen">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div id="batchSummaryList"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-secondary" id="batchSummaryRetryTransient">Transiente erneut hochladen</button>
|
||||||
|
<button class="btn btn-primary" id="batchSummaryRetryAll">Alle Fehler erneut versuchen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script src="../lib/queue-prune.js"></script>
|
<script src="../lib/queue-prune.js"></script>
|
||||||
<script src="../lib/queue-dedup.js"></script>
|
<script src="../lib/queue-dedup.js"></script>
|
||||||
<script src="../lib/log-mode.js"></script>
|
<script src="../lib/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>
|
||||||
|
|||||||
@ -916,6 +916,40 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; }
|
|||||||
color: var(--danger, #e57373);
|
color: var(--danger, #e57373);
|
||||||
background: rgba(229, 115, 115, 0.12);
|
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 {
|
.account-hoster-group-body {
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
|
|||||||
132
tests/stats.test.js
Normal file
132
tests/stats.test.js
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const {
|
||||||
|
summarizePerHoster,
|
||||||
|
classifyErrorCategory,
|
||||||
|
summarizeBatchErrors,
|
||||||
|
isRetryableCategory
|
||||||
|
} = require('../lib/stats');
|
||||||
|
|
||||||
|
function makeBatch(timestamp, results) {
|
||||||
|
return {
|
||||||
|
id: 'b-' + timestamp,
|
||||||
|
timestamp: new Date(timestamp).toISOString(),
|
||||||
|
files: [{ name: 'foo.mp4', size: 1, results }]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('summarizePerHoster counts ok and fail per hoster across all batches', () => {
|
||||||
|
const history = [
|
||||||
|
makeBatch(1, [
|
||||||
|
{ hoster: 'voe.sx', status: 'done' },
|
||||||
|
{ hoster: 'byse.sx', status: 'error', error: 'Not video file format' }
|
||||||
|
]),
|
||||||
|
makeBatch(2, [
|
||||||
|
{ hoster: 'voe.sx', status: 'done' },
|
||||||
|
{ hoster: 'voe.sx', status: 'error', error: 'CSRF' },
|
||||||
|
{ hoster: 'byse.sx', status: 'done' }
|
||||||
|
])
|
||||||
|
];
|
||||||
|
const s = summarizePerHoster(history);
|
||||||
|
assert.strictEqual(s['voe.sx'].ok, 2);
|
||||||
|
assert.strictEqual(s['voe.sx'].fail, 1);
|
||||||
|
assert.strictEqual(s['voe.sx'].total, 3);
|
||||||
|
assert.strictEqual(Math.round(s['voe.sx'].rate * 100), 67);
|
||||||
|
assert.strictEqual(s['byse.sx'].ok, 1);
|
||||||
|
assert.strictEqual(s['byse.sx'].fail, 1);
|
||||||
|
assert.strictEqual(s['byse.sx'].rate, 0.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('summarizePerHoster honors sinceMs cutoff', () => {
|
||||||
|
const history = [
|
||||||
|
makeBatch(1000, [{ hoster: 'voe.sx', status: 'done' }]),
|
||||||
|
makeBatch(5000, [{ hoster: 'voe.sx', status: 'error', error: 'x' }])
|
||||||
|
];
|
||||||
|
const s = summarizePerHoster(history, { sinceMs: 3000 });
|
||||||
|
assert.strictEqual(s['voe.sx'].ok, 0);
|
||||||
|
assert.strictEqual(s['voe.sx'].fail, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('summarizePerHoster honors lastNBatches (newest first)', () => {
|
||||||
|
const history = [
|
||||||
|
makeBatch(1000, [{ hoster: 'voe.sx', status: 'done' }]),
|
||||||
|
makeBatch(2000, [{ hoster: 'voe.sx', status: 'done' }]),
|
||||||
|
makeBatch(3000, [{ hoster: 'voe.sx', status: 'error', error: 'x' }])
|
||||||
|
];
|
||||||
|
const s = summarizePerHoster(history, { lastNBatches: 1 });
|
||||||
|
assert.strictEqual(s['voe.sx'].ok, 0);
|
||||||
|
assert.strictEqual(s['voe.sx'].fail, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('summarizePerHoster handles empty / malformed input', () => {
|
||||||
|
assert.deepStrictEqual(summarizePerHoster(null), {});
|
||||||
|
assert.deepStrictEqual(summarizePerHoster([]), {});
|
||||||
|
assert.deepStrictEqual(summarizePerHoster([{ id: 'x', files: null }]), {});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('classifyErrorCategory: file-rejected phrases', () => {
|
||||||
|
assert.strictEqual(classifyErrorCategory('Byse lehnte Datei ab: Not video file format'), 'file-rejected');
|
||||||
|
assert.strictEqual(classifyErrorCategory('Duplicate file already exists'), 'file-rejected');
|
||||||
|
assert.strictEqual(classifyErrorCategory('Datei zu groß (Max: 5 GB)'), 'file-rejected');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('classifyErrorCategory: account-error phrases', () => {
|
||||||
|
assert.strictEqual(classifyErrorCategory('Quota exceeded'), 'account-error');
|
||||||
|
assert.strictEqual(classifyErrorCategory('account banned'), 'account-error');
|
||||||
|
assert.strictEqual(classifyErrorCategory('not enough disk space'), 'account-error');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('classifyErrorCategory: hoster-transient phrases', () => {
|
||||||
|
assert.strictEqual(classifyErrorCategory('CSRF-Token nicht gefunden'), 'hoster-transient');
|
||||||
|
assert.strictEqual(classifyErrorCategory('Kein Upload-Server erhalten: server busy'), 'hoster-transient');
|
||||||
|
assert.strictEqual(classifyErrorCategory('Kein Filecode'), 'hoster-transient');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('classifyErrorCategory: network phrases', () => {
|
||||||
|
assert.strictEqual(classifyErrorCategory('socket hang up'), 'network');
|
||||||
|
assert.strictEqual(classifyErrorCategory('fetch failed'), 'network');
|
||||||
|
assert.strictEqual(classifyErrorCategory('Timeout while reading'), 'network');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('classifyErrorCategory: aborted is its own bucket (not retryable)', () => {
|
||||||
|
assert.strictEqual(classifyErrorCategory('Abgebrochen'), 'aborted');
|
||||||
|
assert.strictEqual(isRetryableCategory('aborted'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('classifyErrorCategory: unknown for everything else', () => {
|
||||||
|
assert.strictEqual(classifyErrorCategory(''), 'unknown');
|
||||||
|
assert.strictEqual(classifyErrorCategory(null), 'unknown');
|
||||||
|
assert.strictEqual(classifyErrorCategory('Some weird thing'), 'unknown');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('summarizeBatchErrors buckets results by category', () => {
|
||||||
|
const summary = {
|
||||||
|
files: [
|
||||||
|
{ name: 'a.mp4', results: [
|
||||||
|
{ hoster: 'voe.sx', status: 'done' },
|
||||||
|
{ hoster: 'byse.sx', status: 'error', error: 'Not video file format' }
|
||||||
|
] },
|
||||||
|
{ name: 'b.mp4', results: [
|
||||||
|
{ hoster: 'voe.sx', status: 'error', error: 'CSRF' },
|
||||||
|
{ hoster: 'doodstream.com', status: 'error', error: 'socket hang up' }
|
||||||
|
] }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
const buckets = summarizeBatchErrors(summary);
|
||||||
|
assert.strictEqual(buckets['file-rejected'].length, 1);
|
||||||
|
assert.strictEqual(buckets['file-rejected'][0].hoster, 'byse.sx');
|
||||||
|
assert.strictEqual(buckets['hoster-transient'].length, 1);
|
||||||
|
assert.strictEqual(buckets['hoster-transient'][0].hoster, 'voe.sx');
|
||||||
|
assert.strictEqual(buckets['network'].length, 1);
|
||||||
|
assert.strictEqual(buckets['network'][0].hoster, 'doodstream.com');
|
||||||
|
assert.strictEqual(buckets['account-error'].length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isRetryableCategory: only transient + network + unknown retry-worthy', () => {
|
||||||
|
assert.strictEqual(isRetryableCategory('hoster-transient'), true);
|
||||||
|
assert.strictEqual(isRetryableCategory('network'), true);
|
||||||
|
assert.strictEqual(isRetryableCategory('unknown'), true);
|
||||||
|
assert.strictEqual(isRetryableCategory('file-rejected'), false);
|
||||||
|
assert.strictEqual(isRetryableCategory('account-error'), false);
|
||||||
|
assert.strictEqual(isRetryableCategory('aborted'), false);
|
||||||
|
});
|
||||||
@ -31,6 +31,7 @@ describe('UploadManager', () => {
|
|||||||
const origRequire = module.constructor.prototype.require;
|
const origRequire = module.constructor.prototype.require;
|
||||||
const hosters = require('../lib/hosters');
|
const hosters = require('../lib/hosters');
|
||||||
hosters.uploadFile = mockUploadFile;
|
hosters.uploadFile = mockUploadFile;
|
||||||
|
hosters.prefetchBaseline = async () => null;
|
||||||
|
|
||||||
// Mock fs.statSync for test file paths
|
// Mock fs.statSync for test file paths
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
@ -55,8 +56,8 @@ describe('UploadManager', () => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const statuses = events.map(e => e.status);
|
const statuses = events.map(e => e.status);
|
||||||
assert.ok(statuses.includes('queued'), 'should have queued status');
|
|
||||||
assert.ok(statuses.includes('done'), 'should have done status');
|
assert.ok(statuses.includes('done'), 'should have done status');
|
||||||
|
assert.ok(events.length > 0, 'should emit at least one progress event');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('emits batch-done with correct summary', async () => {
|
it('emits batch-done with correct summary', async () => {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user