Restore the v2.1.19 application baseline and retain only the focused import preflight summary with duplicate, unavailable, destination, job, and size-limit visibility.
This commit is contained in:
@@ -1,188 +0,0 @@
|
||||
const { classifyErrorCategory } = require('./stats');
|
||||
const { redactLogText } = require('./support-bundle');
|
||||
|
||||
function number(value) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
||||
}
|
||||
|
||||
function integer(value) {
|
||||
return Math.max(0, Math.trunc(number(value)));
|
||||
}
|
||||
|
||||
function iso(value, fallback) {
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? fallback : date.toISOString();
|
||||
}
|
||||
|
||||
function text(value, secrets, limit = 500) {
|
||||
const source = value instanceof Error ? value.message : String(value ?? '');
|
||||
return String(redactLogText(source, secrets) || '').slice(0, limit);
|
||||
}
|
||||
|
||||
function redactPosixPaths(value) {
|
||||
let output = '';
|
||||
let index = 0;
|
||||
while (index < value.length) {
|
||||
const previous = value[index - 1] || '';
|
||||
if (value[index] !== '/' || (index > 0 && !/[\s=:([{]/.test(previous))) {
|
||||
output += value[index++];
|
||||
continue;
|
||||
}
|
||||
let end = index + 1;
|
||||
while (end < value.length && !/[\s"'<>|]/.test(value[end])) end++;
|
||||
const candidate = value.slice(index, end);
|
||||
if (candidate.slice(1).includes('/')) {
|
||||
output += '<redacted-path>';
|
||||
index = end;
|
||||
continue;
|
||||
}
|
||||
output += value[index++];
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function errorText(value, secrets) {
|
||||
return redactPosixPaths(text(value, secrets)
|
||||
.replace(/https?:\/\/[^\s"'<>]+/gi, '<redacted-url>'))
|
||||
.replace(/\b[A-Za-z0-9_-]{24,}\b/g, '<redacted>');
|
||||
}
|
||||
|
||||
function fileName(value, secrets) {
|
||||
const name = String(value ?? '').split(/[\\/]/).pop() || '';
|
||||
return text(name, secrets, 260);
|
||||
}
|
||||
|
||||
function createJobTotals() {
|
||||
return { total: 0, succeeded: 0, failed: 0, skipped: 0, aborted: 0 };
|
||||
}
|
||||
|
||||
function createHostTotals() {
|
||||
return { ...createJobTotals(), successfulBytes: 0 };
|
||||
}
|
||||
|
||||
function addStatus(target, status) {
|
||||
target.total++;
|
||||
if (status === 'done') target.succeeded++;
|
||||
else if (status === 'skipped') target.skipped++;
|
||||
else if (status === 'aborted') target.aborted++;
|
||||
else target.failed++;
|
||||
}
|
||||
|
||||
function buildCleanupTotals(outcomes) {
|
||||
const totals = { requested: 0, deleted: 0, blocked: 0, failed: 0 };
|
||||
for (const value of Array.isArray(outcomes) ? outcomes : []) {
|
||||
const outcome = String(value || 'failed');
|
||||
if (outcome === 'setting-disabled') continue;
|
||||
totals.requested++;
|
||||
if (outcome === 'deleted') totals.deleted++;
|
||||
else if (outcome === 'blocked' || outcome === 'source-changed' || outcome === 'source-missing' || outcome === 'unsafe-source-type') totals.blocked++;
|
||||
else totals.failed++;
|
||||
}
|
||||
return totals;
|
||||
}
|
||||
|
||||
function deepFreeze(value) {
|
||||
if (!value || typeof value !== 'object' || Object.isFrozen(value)) return value;
|
||||
Object.values(value).forEach(deepFreeze);
|
||||
return Object.freeze(value);
|
||||
}
|
||||
|
||||
function buildBatchCompletionReport(input = {}) {
|
||||
const summary = input.summary && typeof input.summary === 'object' ? input.summary : {};
|
||||
const secrets = Array.isArray(input.secrets) ? input.secrets : [];
|
||||
const completedAt = iso(input.completedAt, new Date().toISOString());
|
||||
const startedAt = iso(input.startedAt ?? summary.timestamp, completedAt);
|
||||
const durationSec = Math.max(0, (new Date(completedAt).getTime() - new Date(startedAt).getTime()) / 1000);
|
||||
const files = { total: 0, fullySucceeded: 0, partiallySucceeded: 0, failed: 0 };
|
||||
const jobs = createJobTotals();
|
||||
const hostMap = new Map();
|
||||
const errors = [];
|
||||
let successfulBytes = 0;
|
||||
|
||||
for (const file of Array.isArray(summary.files) ? summary.files : []) {
|
||||
const results = Array.isArray(file?.results) ? file.results : [];
|
||||
if (results.length === 0) continue;
|
||||
files.total++;
|
||||
const size = number(file?.size);
|
||||
const successful = results.filter(result => result?.status === 'done').length;
|
||||
if (successful === results.length) files.fullySucceeded++;
|
||||
else if (successful > 0) files.partiallySucceeded++;
|
||||
else files.failed++;
|
||||
const safeFileName = fileName(file?.name ?? file?.fileName, secrets);
|
||||
|
||||
for (const result of results) {
|
||||
const status = String(result?.status || 'error');
|
||||
const hoster = text(result?.hoster || 'unknown', secrets, 120) || 'unknown';
|
||||
if (!hostMap.has(hoster)) hostMap.set(hoster, createHostTotals());
|
||||
const host = hostMap.get(hoster);
|
||||
addStatus(jobs, status);
|
||||
addStatus(host, status);
|
||||
if (status === 'done') {
|
||||
successfulBytes += size;
|
||||
host.successfulBytes += size;
|
||||
}
|
||||
if (status === 'error' || result?.remoteCommitUncertain === true) {
|
||||
const message = errorText(result?.error || 'Unknown error', secrets);
|
||||
errors.push({
|
||||
jobId: text(result?.jobId, secrets, 160),
|
||||
fileName: safeFileName,
|
||||
hoster,
|
||||
status,
|
||||
category: classifyErrorCategory(message),
|
||||
attempt: integer(result?.attempt),
|
||||
maxAttempts: integer(result?.maxAttempts),
|
||||
remoteCommitUncertain: result?.remoteCommitUncertain === true,
|
||||
message
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hosters = Object.fromEntries([...hostMap.entries()].sort(([left], [right]) => left.localeCompare(right)));
|
||||
const batchId = text(summary.id, secrets, 160);
|
||||
const report = {
|
||||
reportId: text(input.reportId || `report-${batchId || completedAt}`, secrets, 200),
|
||||
batchId,
|
||||
startedAt,
|
||||
completedAt,
|
||||
generatedAt: completedAt,
|
||||
durationSec,
|
||||
files,
|
||||
jobs,
|
||||
cleanup: buildCleanupTotals(input.cleanupOutcomes),
|
||||
transfer: {
|
||||
successfulBytes,
|
||||
averageBytesPerSecond: durationSec > 0 ? successfulBytes / durationSec : 0
|
||||
},
|
||||
hosters,
|
||||
errors
|
||||
};
|
||||
return deepFreeze(report);
|
||||
}
|
||||
|
||||
function csvCell(value) {
|
||||
let output = value === null || value === undefined ? '' : String(value);
|
||||
if (/^[\u0000-\u0020]*[=+\-@]/.test(output)) output = `'${output}`;
|
||||
return /[",\r\n]/.test(output) ? `"${output.replace(/"/g, '""')}"` : output;
|
||||
}
|
||||
|
||||
function buildBatchErrorCsv(report) {
|
||||
const rows = [['Job ID', 'File name', 'Host', 'Status', 'Category', 'Attempt', 'Max attempts', 'Remote commit uncertain', 'Message']];
|
||||
for (const error of Array.isArray(report?.errors) ? report.errors : []) {
|
||||
rows.push([
|
||||
error.jobId,
|
||||
error.fileName,
|
||||
error.hoster,
|
||||
error.status,
|
||||
error.category,
|
||||
integer(error.attempt),
|
||||
integer(error.maxAttempts),
|
||||
error.remoteCommitUncertain === true ? 'true' : 'false',
|
||||
error.message
|
||||
]);
|
||||
}
|
||||
return `${rows.map(row => row.map(csvCell).join(',')).join('\n')}\n`;
|
||||
}
|
||||
|
||||
module.exports = { buildBatchCompletionReport, buildBatchErrorCsv };
|
||||
@@ -1,55 +0,0 @@
|
||||
function createBatchMutationGate() {
|
||||
let activeLeaseCount = 0;
|
||||
let sealed = false;
|
||||
let activeAtSeal = false;
|
||||
let drainPromise = null;
|
||||
let resolveDrain = null;
|
||||
|
||||
function acquire() {
|
||||
if (sealed) return null;
|
||||
|
||||
activeLeaseCount += 1;
|
||||
let open = true;
|
||||
|
||||
return Object.freeze({
|
||||
finish() {
|
||||
if (!open) return false;
|
||||
|
||||
open = false;
|
||||
activeLeaseCount -= 1;
|
||||
|
||||
if (sealed && activeLeaseCount === 0 && resolveDrain) {
|
||||
const resolve = resolveDrain;
|
||||
resolveDrain = null;
|
||||
resolve(activeAtSeal);
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
isOpen() {
|
||||
return open;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function sealAndDrain() {
|
||||
if (drainPromise) return drainPromise;
|
||||
|
||||
sealed = true;
|
||||
activeAtSeal = activeLeaseCount > 0;
|
||||
|
||||
if (!activeAtSeal) {
|
||||
drainPromise = Promise.resolve(false);
|
||||
return drainPromise;
|
||||
}
|
||||
|
||||
drainPromise = new Promise((resolve) => {
|
||||
resolveDrain = resolve;
|
||||
});
|
||||
return drainPromise;
|
||||
}
|
||||
|
||||
return Object.freeze({ acquire, sealAndDrain });
|
||||
}
|
||||
|
||||
module.exports = { createBatchMutationGate };
|
||||
+22
-157
@@ -2,7 +2,6 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
const secretStore = require('./secret-store');
|
||||
const { normalizeLogMode } = require('./log-mode');
|
||||
const { normalizeUploadSchedule } = require('./upload-schedule');
|
||||
|
||||
const HOSTER_SETTINGS_DEFAULTS = {
|
||||
retries: 3,
|
||||
@@ -79,18 +78,6 @@ const DEFAULTS = {
|
||||
lastBrowseDirectory: '',
|
||||
removeFromQueueOnDone: false,
|
||||
deleteSourceAfterSuccessfulUpload: false,
|
||||
filenameFilter: {
|
||||
enabled: false,
|
||||
action: 'include',
|
||||
matchMode: 'all',
|
||||
conditions: []
|
||||
},
|
||||
uploadSchedule: {
|
||||
enabled: false,
|
||||
weekdays: [1, 2, 3, 4, 5, 6, 0],
|
||||
start: '00:00',
|
||||
end: '23:59'
|
||||
},
|
||||
showDropTarget: false,
|
||||
globalMaxSpeedKbs: 0, // 0 = unlimited global speed
|
||||
pendingQueue: null,
|
||||
@@ -145,20 +132,6 @@ const HISTORY_RETENTION_OPTIONS = [
|
||||
{ value: '100', label: 'Letzte 100 Uploads' }
|
||||
];
|
||||
|
||||
const DIAGNOSTIC_ERROR_MESSAGES = Object.freeze({
|
||||
DIAGNOSTIC_CONFIG_READ_FAILED: 'Die Diagnosekonfiguration konnte nicht gelesen werden',
|
||||
DIAGNOSTIC_CONFIG_INVALID: 'Die Diagnosekonfiguration ist ungültig',
|
||||
DIAGNOSTIC_HISTORY_NOT_FOUND: 'Die Diagnoseverlaufsdatei wurde nicht gefunden',
|
||||
DIAGNOSTIC_HISTORY_READ_FAILED: 'Die Diagnoseverlaufsdatei konnte nicht gelesen werden',
|
||||
DIAGNOSTIC_HISTORY_INVALID: 'Die Diagnoseverlaufsdatei ist ungültig'
|
||||
});
|
||||
|
||||
function diagnosticStoreError(code) {
|
||||
const error = new Error(DIAGNOSTIC_ERROR_MESSAGES[code]);
|
||||
error.code = code;
|
||||
return error;
|
||||
}
|
||||
|
||||
function batchTimestampMs(batch) {
|
||||
const raw = batch && batch.timestamp;
|
||||
if (raw === null || raw === undefined || raw === '') return null;
|
||||
@@ -251,30 +224,6 @@ class ConfigStore {
|
||||
}
|
||||
}
|
||||
|
||||
_readHistoryFileStrict() {
|
||||
let raw;
|
||||
try {
|
||||
raw = fs.readFileSync(this.historyPath, 'utf-8');
|
||||
} catch (error) {
|
||||
if (error && error.code === 'ENOENT') {
|
||||
throw diagnosticStoreError('DIAGNOSTIC_HISTORY_NOT_FOUND');
|
||||
}
|
||||
throw diagnosticStoreError('DIAGNOSTIC_HISTORY_READ_FAILED');
|
||||
}
|
||||
if (!raw || raw.trim().length < 2) {
|
||||
throw diagnosticStoreError('DIAGNOSTIC_HISTORY_INVALID');
|
||||
}
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
throw diagnosticStoreError('DIAGNOSTIC_HISTORY_INVALID');
|
||||
}
|
||||
if (Array.isArray(parsed)) return parsed;
|
||||
if (parsed && Array.isArray(parsed.history)) return parsed.history;
|
||||
throw diagnosticStoreError('DIAGNOSTIC_HISTORY_INVALID');
|
||||
}
|
||||
|
||||
_writeHistoryFileDurable(arr) {
|
||||
const tmp = this.historyPath + '.tmp';
|
||||
const fd = fs.openSync(tmp, 'w');
|
||||
@@ -287,35 +236,15 @@ class ConfigStore {
|
||||
fs.renameSync(tmp, this.historyPath);
|
||||
}
|
||||
|
||||
async _writeHistoryFileAtomic(arr) {
|
||||
const tmp = this.historyPath + '.tmp';
|
||||
let handle;
|
||||
let operationError;
|
||||
try {
|
||||
handle = await fs.promises.open(tmp, 'w');
|
||||
await handle.writeFile(JSON.stringify(arr), 'utf-8');
|
||||
await handle.sync();
|
||||
} catch (error) {
|
||||
operationError = error;
|
||||
}
|
||||
if (handle) {
|
||||
try {
|
||||
await handle.close();
|
||||
} catch (error) {
|
||||
if (!operationError) operationError = error;
|
||||
}
|
||||
}
|
||||
if (operationError) throw operationError;
|
||||
await fs.promises.rename(tmp, this.historyPath);
|
||||
let directoryHandle;
|
||||
try {
|
||||
directoryHandle = await fs.promises.open(path.dirname(this.historyPath), 'r');
|
||||
await directoryHandle.sync();
|
||||
} catch (error) {
|
||||
if (!['EINVAL', 'EISDIR', 'EPERM', 'ENOTSUP'].includes(error.code)) throw error;
|
||||
} finally {
|
||||
if (directoryHandle) await directoryHandle.close();
|
||||
}
|
||||
_writeHistoryFileAtomic(arr) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const tmp = this.historyPath + '.tmp';
|
||||
fs.writeFile(tmp, JSON.stringify(arr), 'utf-8', (err) => {
|
||||
if (err) return reject(err);
|
||||
try { fs.renameSync(tmp, this.historyPath); } catch (e) { return reject(e); }
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
_quiescedWriteError() {
|
||||
@@ -393,31 +322,6 @@ class ConfigStore {
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
|
||||
_readConfigFileStrict() {
|
||||
let raw;
|
||||
try {
|
||||
raw = fs.readFileSync(this.filePath, 'utf-8');
|
||||
} catch {
|
||||
throw diagnosticStoreError('DIAGNOSTIC_CONFIG_READ_FAILED');
|
||||
}
|
||||
if (!raw || raw.trim().length < 2) {
|
||||
throw diagnosticStoreError('DIAGNOSTIC_CONFIG_INVALID');
|
||||
}
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(raw);
|
||||
} catch {
|
||||
throw diagnosticStoreError('DIAGNOSTIC_CONFIG_INVALID');
|
||||
}
|
||||
if (!data || typeof data !== 'object' || Array.isArray(data) ||
|
||||
!data.hosters || typeof data.hosters !== 'object' || Array.isArray(data.hosters) ||
|
||||
!data.globalSettings || typeof data.globalSettings !== 'object' || Array.isArray(data.globalSettings) ||
|
||||
(data.history !== undefined && !Array.isArray(data.history))) {
|
||||
throw diagnosticStoreError('DIAGNOSTIC_CONFIG_INVALID');
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
_clone(obj) {
|
||||
try { return structuredClone(obj); }
|
||||
catch { return JSON.parse(JSON.stringify(obj)); }
|
||||
@@ -456,11 +360,7 @@ class ConfigStore {
|
||||
return r;
|
||||
}
|
||||
|
||||
loadDiagnosticsConfig() {
|
||||
return this._loadImpl(true);
|
||||
}
|
||||
|
||||
_loadImpl(strict = false) {
|
||||
_loadImpl() {
|
||||
try {
|
||||
// In-memory cache keyed on the file's mtime+size. The processed config
|
||||
// (merged + credential-decrypted) is reparsed/re-decrypted from disk ONLY
|
||||
@@ -472,27 +372,21 @@ class ConfigStore {
|
||||
// long-running main-thread drag. load() always returns a CLONE so callers
|
||||
// can mutate the result without corrupting the cache.
|
||||
let stat = null;
|
||||
if (!strict) {
|
||||
try { stat = fs.statSync(this.filePath); } catch {}
|
||||
}
|
||||
try { stat = fs.statSync(this.filePath); } catch {}
|
||||
const statKey = stat ? `${stat.mtimeMs}:${stat.size}` : '';
|
||||
if (!strict && stat && this._cache && this._cacheKey === statKey) {
|
||||
if (stat && this._cache && this._cacheKey === statKey) {
|
||||
return this._clone(this._cache);
|
||||
}
|
||||
|
||||
let data = null;
|
||||
if (strict) {
|
||||
data = this._readConfigFileStrict();
|
||||
} else {
|
||||
// Try main config
|
||||
try { data = this._readAndParse(this.filePath); } catch {}
|
||||
// Fallback to backup if main is empty/corrupt
|
||||
if (!data) {
|
||||
try { data = this._readAndParse(this.filePath + '.bak'); } catch {}
|
||||
}
|
||||
if (!data) {
|
||||
try { data = this._readAndParse(this.filePath + '.pre-history-split.bak'); } catch {}
|
||||
}
|
||||
// Try main config
|
||||
try { data = this._readAndParse(this.filePath); } catch {}
|
||||
// Fallback to backup if main is empty/corrupt
|
||||
if (!data) {
|
||||
try { data = this._readAndParse(this.filePath + '.bak'); } catch {}
|
||||
}
|
||||
if (!data) {
|
||||
try { data = this._readAndParse(this.filePath + '.pre-history-split.bak'); } catch {}
|
||||
}
|
||||
if (!data) {
|
||||
const fresh = JSON.parse(JSON.stringify(DEFAULTS));
|
||||
@@ -566,7 +460,6 @@ class ConfigStore {
|
||||
// Downstream readers consume logMode only and must NOT derive from
|
||||
// sessionLog at call sites.
|
||||
globalSettings.logMode = normalizeLogMode(globalSettings);
|
||||
globalSettings.uploadSchedule = normalizeUploadSchedule(globalSettings.uploadSchedule);
|
||||
const rotationCursors = (data.rotationCursors && typeof data.rotationCursors === 'object' && !Array.isArray(data.rotationCursors))
|
||||
? data.rotationCursors
|
||||
: {};
|
||||
@@ -574,13 +467,13 @@ class ConfigStore {
|
||||
// Decrypt credentials stored with safeStorage so the rest of the app
|
||||
// keeps working with plaintext in memory.
|
||||
secretStore.decryptCredentials(result);
|
||||
if (!strict && stat) {
|
||||
if (stat) {
|
||||
this._cache = result;
|
||||
this._cacheKey = statKey;
|
||||
}
|
||||
return this._clone(result);
|
||||
} catch (error) {
|
||||
if (strict || error instanceof secretStore.SecretStoreError) throw error;
|
||||
if (error instanceof secretStore.SecretStoreError) throw error;
|
||||
const fresh = JSON.parse(JSON.stringify(DEFAULTS));
|
||||
fresh.globalSettings.logMode = normalizeLogMode(fresh.globalSettings);
|
||||
return fresh;
|
||||
@@ -596,7 +489,6 @@ class ConfigStore {
|
||||
const hosters = this._clone(config.hosters || {});
|
||||
const globalSettings = this._clone(config.globalSettings || {});
|
||||
delete globalSettings.allowPlaintextCredentialStorage;
|
||||
globalSettings.uploadSchedule = normalizeUploadSchedule(globalSettings.uploadSchedule);
|
||||
secretStore.encryptCredentials({ hosters });
|
||||
return JSON.stringify({ ...config, globalSettings, hosters }, null, 2);
|
||||
}
|
||||
@@ -719,19 +611,6 @@ class ConfigStore {
|
||||
});
|
||||
}
|
||||
|
||||
saveFallbackLogPath(logFilePath) {
|
||||
const snapshot = String(logFilePath || '').trim();
|
||||
return this._enqueueWrite(() => {
|
||||
const current = this.load();
|
||||
current.globalSettings = {
|
||||
...(current.globalSettings || {}),
|
||||
logFilePath: snapshot
|
||||
};
|
||||
this._guardHosters(current, false);
|
||||
return this._commit(current);
|
||||
});
|
||||
}
|
||||
|
||||
saveRendererGlobalSettings(globalSettings) {
|
||||
const snapshot = this._clone(globalSettings || {});
|
||||
return this._enqueueWrite(() => {
|
||||
@@ -795,20 +674,6 @@ class ConfigStore {
|
||||
return config.history || [];
|
||||
}
|
||||
|
||||
loadDiagnosticsHistory() {
|
||||
if (this._historyMigrated) return this._readHistoryFileStrict();
|
||||
try {
|
||||
return this._readHistoryFileStrict();
|
||||
} catch (error) {
|
||||
if (!error || error.code !== 'DIAGNOSTIC_HISTORY_NOT_FOUND') throw error;
|
||||
}
|
||||
const config = this.loadDiagnosticsConfig();
|
||||
if (!Array.isArray(config.history)) {
|
||||
throw diagnosticStoreError('DIAGNOSTIC_HISTORY_INVALID');
|
||||
}
|
||||
return config.history;
|
||||
}
|
||||
|
||||
_atomicWrite(data) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const tmpPath = this.filePath + '.tmp';
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
const { valueScrub } = require('./support-bundle');
|
||||
|
||||
function createAgent(collectors) {
|
||||
const OPS = {
|
||||
get_system_info: (a) => collectors.getSystemInfo(a),
|
||||
@@ -16,28 +14,15 @@ function createAgent(collectors) {
|
||||
get_health: () => collectors.getHealth()
|
||||
};
|
||||
|
||||
function redactResponse(value) {
|
||||
try {
|
||||
const redacted = typeof collectors.redactResponse === 'function'
|
||||
? collectors.redactResponse(value)
|
||||
: valueScrub(value, []);
|
||||
const response = valueScrub(redacted, []);
|
||||
if (!response || typeof response !== 'object' || Array.isArray(response)) throw new Error('invalid redaction result');
|
||||
return response;
|
||||
} catch {
|
||||
return { ok: false, error: 'diagnostic response could not be safely returned' };
|
||||
}
|
||||
}
|
||||
|
||||
function handle(op, args) {
|
||||
const fn = (typeof op === 'string' && Object.prototype.hasOwnProperty.call(OPS, op)) ? OPS[op] : null;
|
||||
if (typeof fn !== 'function') return redactResponse({ ok: false, error: `unknown or non-readonly op: ${op}` });
|
||||
if (typeof fn !== 'function') return { ok: false, error: `unknown or non-readonly op: ${op}` };
|
||||
try {
|
||||
const data = fn(args || {});
|
||||
if (data && data.ok === false) return redactResponse(data);
|
||||
return redactResponse({ ok: true, data });
|
||||
if (data && data.ok === false) return data;
|
||||
return { ok: true, data };
|
||||
} catch (e) {
|
||||
return redactResponse({ ok: false, error: String((e && e.message) || e) });
|
||||
return { ok: false, error: String((e && e.message) || e) };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,30 +16,22 @@ function createCollectors(deps) {
|
||||
const { loadConfig, loadHistory, getAllLogPaths, support, stats, appInfo, systemInfo, agentInfo } = deps;
|
||||
|
||||
function _secrets() {
|
||||
return support.collectSecretValues(loadConfig());
|
||||
}
|
||||
|
||||
function _currentHistory() {
|
||||
if (typeof loadHistory === 'function') {
|
||||
const history = loadHistory();
|
||||
if (!Array.isArray(history)) throw new Error('History reader returned invalid data');
|
||||
return history;
|
||||
}
|
||||
const cfg = loadConfig();
|
||||
return Array.isArray(cfg && cfg.history) ? cfg.history : [];
|
||||
}
|
||||
|
||||
function _historyContext() {
|
||||
const secrets = _secrets();
|
||||
return { history: _currentHistory(), secrets };
|
||||
try { return support.collectSecretValues(loadConfig()); } catch { return []; }
|
||||
}
|
||||
|
||||
function _deepRedact(value, secrets) {
|
||||
return support.valueScrub(value, secrets || _secrets());
|
||||
}
|
||||
|
||||
function redactResponse(value) {
|
||||
return _deepRedact(value);
|
||||
const s = secrets || _secrets();
|
||||
const walk = (v) => {
|
||||
if (typeof v === 'string') return support.redactLogText(v, s);
|
||||
if (Array.isArray(v)) return v.map(walk);
|
||||
if (v && typeof v === 'object') {
|
||||
const o = {};
|
||||
for (const k of Object.keys(v)) o[k] = walk(v[k]);
|
||||
return o;
|
||||
}
|
||||
return v;
|
||||
};
|
||||
try { return walk(value); } catch { return value; }
|
||||
}
|
||||
|
||||
function _resolveLogPath(name, backup) {
|
||||
@@ -87,10 +79,10 @@ function createCollectors(deps) {
|
||||
readableNames.add(path.basename(fp));
|
||||
try {
|
||||
const st = fs.statSync(fp);
|
||||
variants.push({ id: backup === 0 ? name : `${name}:${backup}`, backup, fileName: path.basename(fp), sizeBytes: st.size, mtime: st.mtime.toISOString() });
|
||||
variants.push({ backup, sizeBytes: st.size, mtime: st.mtime.toISOString() });
|
||||
} catch {}
|
||||
}
|
||||
files.push({ id: name, name, fileName: path.basename(base), readable: true, present: variants.length > 0, variants });
|
||||
files.push({ name, path: base, readable: true, present: variants.length > 0, variants });
|
||||
}
|
||||
let siblings = [];
|
||||
try {
|
||||
@@ -103,16 +95,16 @@ function createCollectors(deps) {
|
||||
return { name: f, readable: false, sizeBytes: size, mtime };
|
||||
});
|
||||
} catch {}
|
||||
return { files, otherLogs: siblings };
|
||||
return { dir, files, otherLogs: siblings };
|
||||
}
|
||||
|
||||
function readLog(args) {
|
||||
const a = args || {};
|
||||
const name = a.name;
|
||||
const p = _resolveLogPath(name, a.backup);
|
||||
if (!p) return { ok: false, error: 'unknown or non-readable log identifier' };
|
||||
if (!p) return { ok: false, error: `unknown or non-readable log: ${name}` };
|
||||
const tailKb = Math.min(Math.max(Number(a.tailKb) || 256, 1), 1024);
|
||||
const raw = support.collectFile(p, name, tailKb * 1024, { includePath: false });
|
||||
const raw = support.collectFile(p, name, tailKb * 1024);
|
||||
let content = support.redactLogText(raw, _secrets());
|
||||
let matchedLines;
|
||||
if (a.grep && typeof a.grep === 'string' && a.grep.length <= 200) {
|
||||
@@ -128,7 +120,7 @@ function createCollectors(deps) {
|
||||
}
|
||||
let sizeBytes = null;
|
||||
try { sizeBytes = fs.statSync(p).size; } catch {}
|
||||
return { id: name, name, fileName: path.basename(p), sizeBytes, returnedBytes: Buffer.byteLength(content), tailKb, matchedLines, content };
|
||||
return { name, path: p, sizeBytes, returnedBytes: Buffer.byteLength(content), tailKb, matchedLines, content };
|
||||
}
|
||||
|
||||
function getAppEvents(args) {
|
||||
@@ -145,9 +137,10 @@ function createCollectors(deps) {
|
||||
return { events: out.slice(-limit), truncated: out.length > limit };
|
||||
}
|
||||
|
||||
function _historyErrors(history, opts, secrets) {
|
||||
function _historyErrors(history, opts) {
|
||||
const o = opts || {};
|
||||
const sinceMs = Number.isFinite(o.sinceMs) ? o.sinceMs : null;
|
||||
const secrets = _secrets();
|
||||
const errors = [];
|
||||
const byCategory = {};
|
||||
for (const batch of (Array.isArray(history) ? history : [])) {
|
||||
@@ -176,19 +169,15 @@ function createCollectors(deps) {
|
||||
return { errors, byCategory };
|
||||
}
|
||||
|
||||
function _listErrors(args, history, secrets) {
|
||||
function listErrors(args) {
|
||||
const a = args || {};
|
||||
const { errors, byCategory } = _historyErrors(history, a, secrets);
|
||||
const cfg = loadConfig();
|
||||
const { errors, byCategory } = _historyErrors(cfg.history, a);
|
||||
const limit = Math.min(Math.max(Number(a.limit) || 100, 1), 1000);
|
||||
const window = Number.isFinite(a.sinceMs) ? `since ${new Date(a.sinceMs).toISOString()}` : 'all history';
|
||||
return { window, total: errors.length, byCategory, errors: errors.slice(-limit) };
|
||||
}
|
||||
|
||||
function listErrors(args) {
|
||||
const { history, secrets } = _historyContext();
|
||||
return _listErrors(args, history, secrets);
|
||||
}
|
||||
|
||||
function getQueueState(args) {
|
||||
const a = args || {};
|
||||
const cfg = loadConfig();
|
||||
@@ -218,11 +207,15 @@ function createCollectors(deps) {
|
||||
return result;
|
||||
}
|
||||
|
||||
function _getHistory(args, history, secrets) {
|
||||
function getHistory(args) {
|
||||
const a = args || {};
|
||||
const history = typeof loadHistory === 'function'
|
||||
? (loadHistory() || [])
|
||||
: (Array.isArray(loadConfig().history) ? loadConfig().history : []);
|
||||
const limit = Math.min(Math.max(Number(a.limit) || 20, 1), 200);
|
||||
const perHoster = stats.summarizePerHoster([...history]);
|
||||
const perHoster = stats.summarizePerHoster(history);
|
||||
const recent = [...history].slice(-limit).reverse();
|
||||
const secrets = _secrets();
|
||||
const batches = recent.map(b => {
|
||||
const out = { timestamp: b.timestamp || null, fileCount: Array.isArray(b.files) ? b.files.length : 0 };
|
||||
if (a.includeFiles) {
|
||||
@@ -241,11 +234,6 @@ function createCollectors(deps) {
|
||||
return { totalBatches: history.length, returned: batches.length, perHoster, batches };
|
||||
}
|
||||
|
||||
function getHistory(args) {
|
||||
const { history, secrets } = _historyContext();
|
||||
return _getHistory(args, history, secrets);
|
||||
}
|
||||
|
||||
function getRotationState() {
|
||||
const cfg = loadConfig();
|
||||
return { rotationCursors: _deepRedact(cfg.rotationCursors || {}) };
|
||||
@@ -265,10 +253,9 @@ function createCollectors(deps) {
|
||||
const a = args || {};
|
||||
const errorLimit = Math.min(Math.max(Number(a.errorLimit) || 20, 1), 200);
|
||||
const errArgs = Number.isFinite(a.errorSinceMs) ? { sinceMs: a.errorSinceMs, limit: errorLimit } : { limit: errorLimit };
|
||||
const { history: currentHistory, secrets } = _historyContext();
|
||||
const errors = _listErrors(errArgs, currentHistory, secrets);
|
||||
const errors = listErrors(errArgs);
|
||||
const queue = getQueueState({ includeJobs: false });
|
||||
const history = _getHistory({ limit: 5 }, currentHistory, secrets);
|
||||
const history = getHistory({ limit: 5 });
|
||||
const warnings = [];
|
||||
if (queue.source === 'persisted' && queue.stale) warnings.push('queue state is from the persisted snapshot (may lag live state; UploadManager not introspected in this build).');
|
||||
if (errors.total > 0) warnings.push(`${errors.total} non-success result(s) in the error window.`);
|
||||
@@ -287,7 +274,7 @@ function createCollectors(deps) {
|
||||
return {
|
||||
getSystemInfo, getConfigRedacted, listLogs, readLog, getAppEvents,
|
||||
listErrors, getQueueState, getHistory, getRotationState, getHealth, serverHealth,
|
||||
redactResponse, READABLE_LOGS
|
||||
READABLE_LOGS
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+70
-209
@@ -2,12 +2,6 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const { request } = require('undici');
|
||||
const {
|
||||
createTransportError,
|
||||
safeEndpoint,
|
||||
sanitizeRemoteText,
|
||||
summarizeResponse
|
||||
} = require('./hoster-transport-error');
|
||||
|
||||
const BASE_URL = 'https://doodstream.com';
|
||||
const USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';
|
||||
@@ -103,15 +97,8 @@ class DoodstreamUploader {
|
||||
break;
|
||||
} catch (err) {
|
||||
if (opts.signal && opts.signal.aborted) throw err; // caller abort: don't retry
|
||||
if (attempt >= 3) {
|
||||
throw createTransportError('Doodstream: Webanfrage fehlgeschlagen', {
|
||||
phase: 'web-request',
|
||||
endpoint: url,
|
||||
retryable: true,
|
||||
transientNetwork: true
|
||||
});
|
||||
}
|
||||
_debugLog(`_fetch transient (${attempt}/3) ${safeEndpoint(url) || 'unknown endpoint'}: ${err && err.name ? err.name : 'network error'}; retry`);
|
||||
if (attempt >= 3) throw err;
|
||||
_debugLog(`_fetch transient (${attempt}/3) ${url}: ${err && err.message}; retry`);
|
||||
await new Promise(r => setTimeout(r, 400 * attempt));
|
||||
}
|
||||
}
|
||||
@@ -180,35 +167,16 @@ class DoodstreamUploader {
|
||||
// Explicit success response
|
||||
} else if (json && json.message && /otp/i.test(json.message)) {
|
||||
// OTP required — signal caller to collect OTP from user
|
||||
const err = createTransportError(`Doodstream Login: ${sanitizeRemoteText(json.message)}`, {
|
||||
phase: 'login',
|
||||
endpoint: BASE_URL,
|
||||
httpStatus: res.status,
|
||||
contentType: res.headers && res.headers.get ? res.headers.get('content-type') : null,
|
||||
body
|
||||
});
|
||||
const err = new Error(`Doodstream Login: ${json.message}`);
|
||||
err.otpRequired = true;
|
||||
throw err;
|
||||
} else if (json && json.status === 'fail') {
|
||||
throw createTransportError(`Doodstream Login: ${sanitizeRemoteText(json.message) || 'Login fehlgeschlagen'}`, {
|
||||
phase: 'login',
|
||||
endpoint: BASE_URL,
|
||||
httpStatus: res.status,
|
||||
contentType: res.headers && res.headers.get ? res.headers.get('content-type') : null,
|
||||
body,
|
||||
accountError: true
|
||||
});
|
||||
throw new Error(`Doodstream Login: ${json.message || 'Login fehlgeschlagen'}`);
|
||||
} else if (body.includes('Dashboard')) {
|
||||
// Got dashboard HTML directly — login worked
|
||||
} else {
|
||||
const msg = sanitizeRemoteText(json && json.message) || 'Login fehlgeschlagen';
|
||||
throw createTransportError(`Doodstream Login: ${msg}`, {
|
||||
phase: 'login',
|
||||
endpoint: BASE_URL,
|
||||
httpStatus: res.status,
|
||||
contentType: res.headers && res.headers.get ? res.headers.get('content-type') : null,
|
||||
body
|
||||
});
|
||||
const msg = (json && json.message) || 'Login fehlgeschlagen';
|
||||
throw new Error(`Doodstream Login: ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -252,7 +220,7 @@ class DoodstreamUploader {
|
||||
const res = await this._fetch(BASE_URL + '/?op=upload_server');
|
||||
const text = await res.text();
|
||||
const ctype = (res.headers && res.headers.get) ? (res.headers.get('content-type') || '') : '';
|
||||
_debugLog(`upload_server: status=${res.status} ctype=${ctype} response=${summarizeResponse(text, ctype)}`);
|
||||
_debugLog(`upload_server: status=${res.status} ctype=${ctype} body(800)=${(text || '').slice(0, 800)}`);
|
||||
let json;
|
||||
try { json = JSON.parse(text); } catch { json = null; }
|
||||
|
||||
@@ -286,7 +254,7 @@ class DoodstreamUploader {
|
||||
// Capture the form's real fields so upload() submits exactly what the
|
||||
// browser would (file_title, submit_btn, …) instead of stale hardcoded ones.
|
||||
this._uploadFormFields = this._parseUploadFormFields(html);
|
||||
_debugLog(`upload_server: using form action node=${safeEndpoint(url)} sessLength=${this.sessId.length} fields=${Object.keys(this._uploadFormFields).join(',')}`);
|
||||
_debugLog(`upload_server: using form action node=${url} sess=${this.sessId} fields=${Object.keys(this._uploadFormFields).join(',')}`);
|
||||
return url;
|
||||
}
|
||||
|
||||
@@ -297,19 +265,15 @@ class DoodstreamUploader {
|
||||
// No upload server could be extracted. We MUST NOT silently fall back to a
|
||||
// hardcoded node: that node is stale and accepts the bytes but returns an
|
||||
// empty form (no filecode) — so the user wastes ~90s uploading 95 MB into a
|
||||
// dead end and gets a cryptic "kein Filecode" 90s later. Fail fast with
|
||||
// safe structured diagnostics.
|
||||
_debugLog(`upload_server: no server response=${summarizeResponse(text, ctype)} page=${summarizeResponse(html, pageRes.headers && pageRes.headers.get ? pageRes.headers.get('content-type') : '')}`);
|
||||
throw createTransportError('Doodstream: konnte Upload-Server nicht ermitteln', {
|
||||
phase: 'upload-server',
|
||||
endpoint: BASE_URL + '/?op=upload_server',
|
||||
httpStatus: res.status,
|
||||
contentType: ctype,
|
||||
body: text,
|
||||
retryable: res.status >= 500,
|
||||
transientNetwork: res.status >= 500,
|
||||
hosterTransient: res.status >= 500
|
||||
});
|
||||
// dead end and gets a cryptic "kein Filecode" 90s later. Fail fast and put
|
||||
// the raw responses in the error so the real format change is diagnosable.
|
||||
const urlHints = (html.match(/https?:\/\/[^'">\s]+/g) || []).slice(0, 4).join(' , ');
|
||||
_debugLog(`upload_server: NO SERVER. upload-page html(2000)=${(html || '').slice(0, 2000)}`);
|
||||
throw new Error(
|
||||
`Doodstream: konnte Upload-Server nicht ermitteln (Endpoint geändert?). ` +
|
||||
`op=upload_server status=${res.status} ctype=${ctype} body=${(text || '').slice(0, 300)} ` +
|
||||
`| upload-page URL-Treffer: ${urlHints || 'keine'}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -393,7 +357,7 @@ class DoodstreamUploader {
|
||||
|
||||
let uploadRes;
|
||||
try {
|
||||
uploadRes = await this._requestUpload(uploadUrl, {
|
||||
uploadRes = await request(uploadUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
||||
@@ -406,15 +370,15 @@ class DoodstreamUploader {
|
||||
bodyTimeout: UPLOAD_TIMEOUT,
|
||||
headersTimeout: 60000
|
||||
});
|
||||
} catch {
|
||||
} catch (err) {
|
||||
// Label which phase failed so a future "fetch failed"/"terminated" is
|
||||
// attributable to the big upload POST vs the small bookend requests. The
|
||||
// original message is preserved as a substring so upload-manager's
|
||||
// transient classification still matches. NOTE: undici may surface
|
||||
// "terminated"/"other side closed", which are not yet in that transient
|
||||
// list — revisit if logs show them.
|
||||
const mb = Math.round(bytesRead / 1048576);
|
||||
throw createTransportError(`Doodstream Upload-POST nach ${mb} MB fehlgeschlagen`, {
|
||||
phase: 'upload-request',
|
||||
endpoint: uploadUrl,
|
||||
retryable: true,
|
||||
transientNetwork: true,
|
||||
remoteCommitUncertain: true
|
||||
});
|
||||
throw new Error(`Doodstream Upload-POST (${mb} MB an ${uploadUrl}): ${err && err.message ? err.message : err}`);
|
||||
}
|
||||
|
||||
const statusCode = uploadRes.statusCode;
|
||||
@@ -430,69 +394,27 @@ class DoodstreamUploader {
|
||||
}
|
||||
}
|
||||
|
||||
let resText;
|
||||
try {
|
||||
resText = await uploadRes.body.text();
|
||||
} catch {
|
||||
throw createTransportError('Doodstream Upload-Antwort konnte nicht gelesen werden', {
|
||||
phase: 'upload-response-read',
|
||||
endpoint: uploadUrl,
|
||||
retryable: true,
|
||||
transientNetwork: true,
|
||||
remoteCommitUncertain: true
|
||||
});
|
||||
}
|
||||
const uploadContentType = uploadRes.headers && uploadRes.headers['content-type'];
|
||||
_debugLog(`Upload response: ${summarizeResponse(resText, uploadContentType)}`);
|
||||
const resText = await uploadRes.body.text();
|
||||
_debugLog(`Upload response body (first 500): ${resText.slice(0, 500)}`);
|
||||
|
||||
if (statusCode >= 400) {
|
||||
let payload;
|
||||
try { payload = JSON.parse(resText); } catch {}
|
||||
const msg = payload && payload.msg ? sanitizeRemoteText(payload.msg) : '';
|
||||
throw createTransportError(`Doodstream Upload fehlgeschlagen${msg ? `: ${msg}` : ''}`, {
|
||||
phase: 'upload-response',
|
||||
endpoint: uploadUrl,
|
||||
httpStatus: statusCode,
|
||||
contentType: uploadContentType,
|
||||
body: resText,
|
||||
retryable: statusCode === 429 || statusCode >= 500,
|
||||
transientNetwork: statusCode >= 500
|
||||
});
|
||||
const msg = payload && payload.msg ? payload.msg : resText.slice(0, 200);
|
||||
throw new Error(`Doodstream Upload HTTP ${statusCode}: ${msg}`);
|
||||
}
|
||||
|
||||
return this._parseUploadResponse(resText);
|
||||
}
|
||||
|
||||
_requestUpload(url, options) {
|
||||
return request(url, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Follow a redirect URL from upload server and extract filecode
|
||||
*/
|
||||
async _handleUploadResult(url) {
|
||||
_debugLog(`Following upload result URL: ${safeEndpoint(url) || 'unknown endpoint'}`);
|
||||
let res;
|
||||
try {
|
||||
res = await this._fetch(url);
|
||||
} catch (error) {
|
||||
if (error && typeof error === 'object') error.remoteCommitUncertain = true;
|
||||
throw error;
|
||||
}
|
||||
let html;
|
||||
try {
|
||||
html = await res.text();
|
||||
} catch {
|
||||
throw createTransportError('Doodstream Ergebnis-Antwort konnte nicht gelesen werden', {
|
||||
phase: 'upload-response-read',
|
||||
endpoint: url,
|
||||
retryable: true,
|
||||
transientNetwork: true,
|
||||
remoteCommitUncertain: true
|
||||
});
|
||||
}
|
||||
const contentType = res.headers && typeof res.headers.get === 'function' ? res.headers.get('content-type') : '';
|
||||
_debugLog(`Result page: ${summarizeResponse(html, contentType)}`);
|
||||
_debugLog(`Following upload result URL: ${url}`);
|
||||
const res = await this._fetch(url);
|
||||
const html = await res.text();
|
||||
_debugLog(`Result page (first 500): ${html.slice(0, 500)}`);
|
||||
return this._parseUploadResponse(html);
|
||||
}
|
||||
|
||||
@@ -536,12 +458,12 @@ class DoodstreamUploader {
|
||||
|
||||
// 3. Parse HTML form (XFileSharing two-step upload)
|
||||
const hiddenFields = this._extractHiddenFields(resText);
|
||||
_debugLog(`Hidden fields: ${Object.keys(hiddenFields).join(',')}`);
|
||||
_debugLog(`Hidden fields: ${JSON.stringify(hiddenFields)}`);
|
||||
|
||||
// Check if filecode is already in hidden fields
|
||||
const fnCode = hiddenFields.fn || hiddenFields.filecode || hiddenFields.file_code;
|
||||
if (fnCode && fnCode.length >= 8) {
|
||||
_debugLog(`Filecode from hidden field 'fn': length ${fnCode.length}`);
|
||||
_debugLog(`Filecode from hidden field 'fn': ${fnCode}`);
|
||||
// We still need to submit the form so doodstream registers the file
|
||||
// But the filecode is the 'fn' value
|
||||
}
|
||||
@@ -552,7 +474,7 @@ class DoodstreamUploader {
|
||||
// Ensure op=upload_result is set
|
||||
if (!hiddenFields.op) hiddenFields.op = 'upload_result';
|
||||
|
||||
_debugLog(`Submitting upload_result fields: ${Object.keys(hiddenFields).join(',')}`);
|
||||
_debugLog(`Submitting upload_result to ${BASE_URL}/ with fields: ${JSON.stringify(hiddenFields)}`);
|
||||
const formData = new URLSearchParams(hiddenFields);
|
||||
let followText = '';
|
||||
try {
|
||||
@@ -565,23 +487,18 @@ class DoodstreamUploader {
|
||||
body: formData.toString()
|
||||
});
|
||||
followText = await followRes.text();
|
||||
} catch {
|
||||
} catch (err) {
|
||||
// The file already uploaded to the CDN; this POST only registers it on
|
||||
// doodstream's side. If it fails transiently (even after _fetch's own
|
||||
// retries) but we already hold the filecode, the upload succeeded from
|
||||
// the user's view — return it rather than discarding a done upload.
|
||||
if (fnCode && fnCode.length >= 8) {
|
||||
_debugLog(`upload_result submit failed; using existing filecode length ${fnCode.length}`);
|
||||
_debugLog(`upload_result submit failed (${err && err.message}); using fn ${fnCode}`);
|
||||
return this._buildResult(fnCode);
|
||||
}
|
||||
throw createTransportError('Doodstream Upload: Ergebnis konnte nicht registriert werden', {
|
||||
phase: 'upload-result-submit',
|
||||
endpoint: BASE_URL,
|
||||
retryable: true,
|
||||
transientNetwork: true
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
_debugLog(`upload_result response: ${summarizeResponse(followText, '')}`);
|
||||
_debugLog(`upload_result response (first 500): ${followText.slice(0, 500)}`);
|
||||
|
||||
// Try to find filecode in result page
|
||||
const resultCode = this._findFilecodeInHtml(followText);
|
||||
@@ -606,17 +523,11 @@ class DoodstreamUploader {
|
||||
// download link being empty while the page structure is unchanged points
|
||||
// at doodstream's backend, not at a parsing bug on our side.
|
||||
const st = hiddenFields.st || '';
|
||||
const safeStatus = sanitizeRemoteText(st, 100);
|
||||
const fnInfo = fnCode ? `vorhanden(len ${fnCode.length})` : 'fehlt/leer';
|
||||
const node = safeEndpoint(this._lastUploadUrl) || 'unbekannt';
|
||||
_debugLog(`No filecode. st=${safeStatus || '?'} fn=${fnInfo} node=${node} response=${summarizeResponse(resText, 'text/html')}`);
|
||||
const fnInfo = fnCode ? `"${fnCode}"(len ${fnCode.length})` : 'fehlt/leer';
|
||||
const node = this._lastUploadUrl || '?';
|
||||
_debugLog(`No filecode. st=${st} fn=${fnInfo} node=${node} CDN-body=${(resText || '').slice(0, 400)}`);
|
||||
if (st && st !== 'OK') {
|
||||
throw createTransportError(`Doodstream lehnt Datei ab (Server-Status: ${safeStatus || 'unbekannt'})`, {
|
||||
phase: 'upload-result',
|
||||
endpoint: this._lastUploadUrl || BASE_URL,
|
||||
contentType: 'text/html',
|
||||
body: resText
|
||||
});
|
||||
throw new Error(`Doodstream lehnt Datei ab (Server-Status: ${st}). CDN=${node}`);
|
||||
}
|
||||
// Empty form (no fn, no st) is a doodstream-side processing flake — same
|
||||
// account + same file works on a later attempt. Tag it explicitly so the
|
||||
@@ -625,42 +536,26 @@ class DoodstreamUploader {
|
||||
// session and later batches hit `pre-job-swap-blocked` for no fault of
|
||||
// the account). The flag is the primary signal; the message text is a
|
||||
// belt-and-suspenders regex fallback in the classifier.
|
||||
throw createTransportError(`Doodstream Upload: kein Filecode (st=${safeStatus || '?'}, fn=${fnInfo}, CDN=${node})`, {
|
||||
phase: 'upload-result',
|
||||
endpoint: this._lastUploadUrl || BASE_URL,
|
||||
contentType: 'text/html',
|
||||
body: resText,
|
||||
retryable: true,
|
||||
hosterTransient: true
|
||||
});
|
||||
const emptyLinkErr = new Error(`Doodstream Upload: kein Filecode — Server gab leeren Link zurück (st=${st || '?'}, fn=${fnInfo}, CDN=${node}). CDN-Antwort: ${(resText || '').slice(0, 200)}`);
|
||||
emptyLinkErr.hosterTransient = true;
|
||||
throw emptyLinkErr;
|
||||
}
|
||||
|
||||
// 4. Fallback: follow form action as-is (for non-XFS forms)
|
||||
const formAction = resText.match(/<form[^>]*action=['"]([^'"]+)['"]/i);
|
||||
if (formAction) {
|
||||
_debugLog(`Fallback: following form action ${safeEndpoint(formAction[1]) || 'unknown endpoint'}`);
|
||||
_debugLog(`Fallback: following form action ${formAction[1]}`);
|
||||
const formData = new URLSearchParams(hiddenFields);
|
||||
let followText;
|
||||
try {
|
||||
const followRes = await this._fetch(formAction[1], {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Referer': BASE_URL + '/'
|
||||
},
|
||||
body: formData.toString()
|
||||
});
|
||||
followText = await followRes.text();
|
||||
} catch {
|
||||
throw createTransportError('Doodstream Upload: Redirect-Antwort konnte nicht gelesen werden', {
|
||||
phase: 'upload-result-submit',
|
||||
endpoint: formAction[1],
|
||||
retryable: true,
|
||||
transientNetwork: true,
|
||||
remoteCommitUncertain: true
|
||||
});
|
||||
}
|
||||
_debugLog(`Fallback response: ${summarizeResponse(followText, '')}`);
|
||||
const followRes = await this._fetch(formAction[1], {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Referer': BASE_URL + '/'
|
||||
},
|
||||
body: formData.toString()
|
||||
});
|
||||
const followText = await followRes.text();
|
||||
_debugLog(`Fallback response (first 500): ${followText.slice(0, 500)}`);
|
||||
|
||||
const fallbackCode = this._findFilecodeInHtml(followText);
|
||||
if (fallbackCode) return this._buildResult(fallbackCode);
|
||||
@@ -668,23 +563,10 @@ class DoodstreamUploader {
|
||||
// Check if fn was in original hidden fields
|
||||
if (fnCode && fnCode.length >= 8) return this._buildResult(fnCode);
|
||||
|
||||
throw createTransportError('Doodstream Upload: Redirect-Antwort ungültig', {
|
||||
phase: 'upload-result',
|
||||
endpoint: formAction[1],
|
||||
body: followText,
|
||||
hosterTransient: true,
|
||||
retryable: true
|
||||
});
|
||||
throw new Error(`Doodstream Upload: Redirect-Antwort ungültig (${followText.slice(0, 150)})`);
|
||||
}
|
||||
|
||||
throw createTransportError('Doodstream Upload: Keine gültige Antwort', {
|
||||
phase: 'upload-result',
|
||||
endpoint: this._lastUploadUrl || BASE_URL,
|
||||
contentType: /<\s*(?:!doctype|html|body|form|input)\b/i.test(resText) ? 'text/html' : 'text/plain',
|
||||
body: resText,
|
||||
hosterTransient: true,
|
||||
retryable: true
|
||||
});
|
||||
throw new Error(`Doodstream Upload: Keine gültige Antwort (Body: ${resText.slice(0, 150)})`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -708,15 +590,7 @@ class DoodstreamUploader {
|
||||
*/
|
||||
_extractFromJson(payload) {
|
||||
if (payload.status && Number(payload.status) !== 200 && payload.msg) {
|
||||
throw createTransportError(`Doodstream Upload: ${sanitizeRemoteText(payload.msg) || 'Antwort wurde abgelehnt'}`, {
|
||||
phase: 'upload-result',
|
||||
endpoint: this._lastUploadUrl || BASE_URL,
|
||||
httpStatus: Number(payload.status),
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(payload),
|
||||
retryable: Number(payload.status) === 429 || Number(payload.status) >= 500,
|
||||
transientNetwork: Number(payload.status) >= 500
|
||||
});
|
||||
throw new Error(`Doodstream Upload: ${payload.msg}`);
|
||||
}
|
||||
|
||||
let item = null;
|
||||
@@ -728,28 +602,15 @@ class DoodstreamUploader {
|
||||
}
|
||||
|
||||
if (!item) {
|
||||
throw createTransportError('Doodstream Upload: Antwort enthielt kein Ergebnis', {
|
||||
phase: 'upload-result',
|
||||
endpoint: this._lastUploadUrl || BASE_URL,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(payload),
|
||||
hosterTransient: true,
|
||||
retryable: true
|
||||
});
|
||||
throw new Error(`Doodstream Upload fehlgeschlagen: ${payload.msg || JSON.stringify(payload).slice(0, 150)}`);
|
||||
}
|
||||
|
||||
const fileCode = String(item.filecode || item.file_code || '').trim();
|
||||
if (!fileCode) {
|
||||
throw createTransportError('Doodstream Upload: Antwort enthielt keinen Filecode', {
|
||||
phase: 'upload-result',
|
||||
endpoint: this._lastUploadUrl || BASE_URL,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(payload),
|
||||
hosterTransient: true,
|
||||
retryable: true
|
||||
});
|
||||
}
|
||||
return this._buildResult(fileCode);
|
||||
const fileCode = item.filecode || item.file_code || '';
|
||||
return {
|
||||
download_url: item.download_url || item.protected_dl || (fileCode ? `https://doodstream.com/d/${fileCode}` : null),
|
||||
embed_url: item.protected_embed || (fileCode ? `https://doodstream.com/e/${fileCode}` : null),
|
||||
file_code: fileCode
|
||||
};
|
||||
}
|
||||
|
||||
_buildResult(fileCode) {
|
||||
@@ -835,7 +696,7 @@ class DoodstreamUploader {
|
||||
return key;
|
||||
}
|
||||
}
|
||||
_debugLog(`api-key derive: ${candidates.length} candidate(s), none validated. response=${summarizeResponse(html, 'text/html')}`);
|
||||
_debugLog(`api-key derive: ${candidates.length} candidate(s), none validated. settings html(2500)=${(html || '').slice(0, 2500)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
(function initFilenameFilter(root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = api;
|
||||
if (root) root.FilenameFilter = api;
|
||||
})(typeof window !== 'undefined' ? window : globalThis, function createFilenameFilter() {
|
||||
function normalizeFilenameFilter(value) {
|
||||
const source = value && typeof value === 'object' ? value : {};
|
||||
const conditions = Array.isArray(source.conditions)
|
||||
? source.conditions.flatMap(condition => {
|
||||
if (!condition || typeof condition !== 'object') return [];
|
||||
const text = String(condition.value ?? '').trim();
|
||||
if (!text) return [];
|
||||
return [{
|
||||
operator: condition.operator === 'notContains' ? 'notContains' : 'contains',
|
||||
value: text
|
||||
}];
|
||||
})
|
||||
: [];
|
||||
return {
|
||||
enabled: source.enabled === true,
|
||||
action: source.action === 'exclude' ? 'exclude' : 'include',
|
||||
matchMode: source.matchMode === 'any' ? 'any' : 'all',
|
||||
conditions
|
||||
};
|
||||
}
|
||||
|
||||
function getFilename(entry) {
|
||||
if (entry && typeof entry === 'object' && entry.name) return String(entry.name);
|
||||
const source = entry && typeof entry === 'object' ? entry.path : entry;
|
||||
return String(source ?? '').split(/[\\/]/).pop() || '';
|
||||
}
|
||||
|
||||
function evaluateFilenameFilter(filename, value) {
|
||||
const filter = normalizeFilenameFilter(value);
|
||||
const active = filter.enabled && filter.conditions.length > 0;
|
||||
if (!active) return { accepted: true, matched: false, active, filter };
|
||||
const normalizedName = String(filename ?? '').toLowerCase();
|
||||
const results = filter.conditions.map(condition => {
|
||||
const contains = normalizedName.includes(condition.value.toLowerCase());
|
||||
return condition.operator === 'notContains' ? !contains : contains;
|
||||
});
|
||||
const matched = filter.matchMode === 'any' ? results.some(Boolean) : results.every(Boolean);
|
||||
const accepted = filter.action === 'exclude' ? !matched : matched;
|
||||
return { accepted, matched, active, filter };
|
||||
}
|
||||
|
||||
function applyFilenameFilter(entries, value) {
|
||||
const filter = normalizeFilenameFilter(value);
|
||||
const accepted = [];
|
||||
const excluded = [];
|
||||
for (const entry of Array.isArray(entries) ? entries : []) {
|
||||
const evaluation = evaluateFilenameFilter(getFilename(entry), filter);
|
||||
if (evaluation.accepted) accepted.push(entry);
|
||||
else excluded.push(entry);
|
||||
}
|
||||
return {
|
||||
total: accepted.length + excluded.length,
|
||||
accepted,
|
||||
excluded,
|
||||
active: filter.enabled && filter.conditions.length > 0,
|
||||
filter
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
normalizeFilenameFilter,
|
||||
evaluateFilenameFilter,
|
||||
applyFilenameFilter
|
||||
};
|
||||
});
|
||||
@@ -1,91 +0,0 @@
|
||||
function normalizeContentType(value) {
|
||||
const contentType = String(value || '').trim().slice(0, 120);
|
||||
const parts = contentType.split(';').map(part => part.trim());
|
||||
if (parts.length < 1 || parts.length > 2) return null;
|
||||
const slashIndex = parts[0].indexOf('/');
|
||||
if (slashIndex <= 0 || slashIndex === parts[0].length - 1) return null;
|
||||
const tokenCharacters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789.+-_';
|
||||
const validToken = token => Array.from(token).every(char => tokenCharacters.includes(char));
|
||||
if (!validToken(parts[0].slice(0, slashIndex)) || !validToken(parts[0].slice(slashIndex + 1))) return null;
|
||||
if (parts.length === 2) {
|
||||
const charsetPrefix = 'charset=';
|
||||
if (!parts[1].toLowerCase().startsWith(charsetPrefix)) return null;
|
||||
const charset = parts[1].slice(charsetPrefix.length);
|
||||
if (!charset || !validToken(charset)) return null;
|
||||
}
|
||||
return contentType;
|
||||
}
|
||||
|
||||
function safeEndpoint(value) {
|
||||
try {
|
||||
const url = new URL(String(value || ''));
|
||||
return `${url.hostname.toLowerCase()}${url.pathname}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function safeEndpointHost(value) {
|
||||
try {
|
||||
return new URL(String(value || '')).hostname.toLowerCase();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function responseKind(body, contentType) {
|
||||
const text = String(body || '').trim();
|
||||
if (!text) return 'empty';
|
||||
const type = String(contentType || '').toLowerCase();
|
||||
if (type.includes('json') || /^[\[{]/.test(text)) return 'json';
|
||||
if (type.includes('html') || /<\s*(?:!doctype|html|body|form|input)\b/i.test(text)) return 'html';
|
||||
return 'text';
|
||||
}
|
||||
|
||||
function summarizeResponse(body, contentType) {
|
||||
const text = String(body || '');
|
||||
const kind = responseKind(text, contentType);
|
||||
return `${kind} response (${Buffer.byteLength(text, 'utf8')} bytes)`;
|
||||
}
|
||||
|
||||
function sanitizeRemoteText(value, limit = 180) {
|
||||
let text = String(value || '').replace(/[\r\n\t]+/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
text = text.replace(/https?:\/\/[^\s"'<>]+/gi, (raw) => safeEndpoint(raw) || '[URL]');
|
||||
text = text.replace(/\b(?:authorization|proxy-authorization|cookie|set-cookie)\s*[:=]\s*[^,]+/gi, '[redacted]');
|
||||
text = text.replace(/((?:api[_-]?key|token|password|secret|session|sess[_-]?id|csrf)["']?\s*[:=]\s*["']?)[^\s,;"'<>]+/gi, '$1[redacted]');
|
||||
text = text.replace(/(<(?:input|textarea)[^>]*(?:name|id)=["'][^"']*(?:key|token|password|secret|session|sess|csrf)[^"']*["'][^>]*(?:value=["']))[^"']*(["'])/gi, '$1[redacted]$2');
|
||||
text = text.replace(/\b[A-Za-z0-9_-]{20,}\b/g, '[redacted]');
|
||||
return text.slice(0, limit);
|
||||
}
|
||||
|
||||
function createTransportError(message, options = {}) {
|
||||
const httpStatus = Number(options.httpStatus);
|
||||
const hasHttpStatus = Number.isInteger(httpStatus) && httpStatus >= 100 && httpStatus <= 599;
|
||||
const contentType = normalizeContentType(options.contentType);
|
||||
const endpointHost = safeEndpointHost(options.endpoint);
|
||||
const kind = responseKind(options.body, contentType);
|
||||
const suffix = hasHttpStatus ? ` (HTTP ${httpStatus})` : '';
|
||||
const error = new Error(`${sanitizeRemoteText(message, 220)}${suffix}`);
|
||||
error.diagnostic = {
|
||||
phase: String(options.phase || 'transport').slice(0, 80),
|
||||
http: hasHttpStatus ? httpStatus : null,
|
||||
contentType,
|
||||
safeEndpointHost: endpointHost,
|
||||
responseKind: kind,
|
||||
retryable: options.retryable === true,
|
||||
payloadSnippet: summarizeResponse(options.body, contentType)
|
||||
};
|
||||
if (options.transientNetwork === true) error.transientNetwork = true;
|
||||
if (options.hosterTransient === true) error.hosterTransient = true;
|
||||
if (options.accountError === true) error.accountError = true;
|
||||
if (options.fileRejected === true) error.fileRejected = true;
|
||||
if (options.remoteCommitUncertain === true) error.remoteCommitUncertain = true;
|
||||
return error;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createTransportError,
|
||||
safeEndpoint,
|
||||
sanitizeRemoteText,
|
||||
summarizeResponse
|
||||
};
|
||||
+133
-516
@@ -2,7 +2,6 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const { request } = require('undici');
|
||||
const { createTransportError, sanitizeRemoteText } = require('./hoster-transport-error');
|
||||
|
||||
const UPLOAD_TIMEOUT = 1800000; // 30 minutes
|
||||
const API_TIMEOUT = 45000; // 45 seconds
|
||||
@@ -173,11 +172,10 @@ function parseDoodstreamResult(payload) {
|
||||
item = result;
|
||||
}
|
||||
|
||||
const fileCode = item.filecode || item.file_code || null;
|
||||
return {
|
||||
download_url: fileCode ? `https://doodstream.com/d/${fileCode}` : null,
|
||||
embed_url: fileCode ? `https://doodstream.com/e/${fileCode}` : null,
|
||||
file_code: fileCode
|
||||
download_url: item.download_url || item.protected_dl || null,
|
||||
embed_url: item.protected_embed || null,
|
||||
file_code: item.filecode || item.file_code || null
|
||||
};
|
||||
}
|
||||
|
||||
@@ -236,7 +234,7 @@ function parseByseResult(payload) {
|
||||
// wall, so we must rotate. File-specific rejections (Duplicate, wrong
|
||||
// format, too small/large) ARE per-file and rotation is pointless.
|
||||
const accountLevel = /(not enough (disk )?(space|storage)|insufficient (disk )?space|disk (space )?full|storage (exhausted|full|voll|limit)|quota (exceeded|voll|überschritten)|account (full|voll|suspended|banned))/i.test(perFileError);
|
||||
const err = new Error(`Byse lehnte Datei ab: ${sanitizeRemoteText(perFileError)}`);
|
||||
const err = new Error(`Byse lehnte Datei ab: ${perFileError}`);
|
||||
if (accountLevel) {
|
||||
err.accountError = true;
|
||||
} else {
|
||||
@@ -310,64 +308,32 @@ function createUploadBody(filePath, formFields, onProgress, throttle, signal) {
|
||||
|
||||
// --- API helper using built-in fetch (follows redirects automatically) ---
|
||||
|
||||
async function apiGet(url, signal, hosterName) {
|
||||
async function apiGet(url, signal) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), API_TIMEOUT);
|
||||
const onAbort = () => controller.abort();
|
||||
if (signal) signal.addEventListener('abort', onAbort);
|
||||
|
||||
try {
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(url, {
|
||||
method: 'GET',
|
||||
signal: controller.signal,
|
||||
redirect: 'follow'
|
||||
});
|
||||
} catch (err) {
|
||||
if (signal && signal.aborted) throw err;
|
||||
throw createTransportError(`${hosterName}: Upload-Server-Abfrage fehlgeschlagen`, {
|
||||
phase: 'upload-server',
|
||||
endpoint: url,
|
||||
retryable: true,
|
||||
transientNetwork: true
|
||||
});
|
||||
}
|
||||
const res = await fetch(url, {
|
||||
method: 'GET',
|
||||
signal: controller.signal,
|
||||
redirect: 'follow'
|
||||
});
|
||||
const text = await res.text();
|
||||
const contentType = res.headers && typeof res.headers.get === 'function'
|
||||
? res.headers.get('content-type')
|
||||
: null;
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch {
|
||||
throw createTransportError(`${hosterName}: Upload-Server-Antwort war kein JSON`, {
|
||||
phase: 'upload-server',
|
||||
endpoint: url,
|
||||
httpStatus: res.status,
|
||||
contentType,
|
||||
body: text,
|
||||
retryable: res.status >= 500,
|
||||
transientNetwork: res.status >= 500
|
||||
});
|
||||
const err = new Error(`API-Antwort war kein JSON (HTTP ${res.status}): ${(text || '').slice(0, 200)}`);
|
||||
if (res.status >= 500) err.transientNetwork = true;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const apiStatus = Number(data && data.status);
|
||||
const effectiveStatus = res.status < 200 || res.status >= 300
|
||||
? res.status
|
||||
: (apiStatus >= 400 ? apiStatus : null);
|
||||
if (effectiveStatus) {
|
||||
const retryable = effectiveStatus === 429 || effectiveStatus >= 500;
|
||||
throw createTransportError(`${hosterName}: Upload-Server-Abfrage wurde abgelehnt`, {
|
||||
phase: 'upload-server',
|
||||
endpoint: url,
|
||||
httpStatus: effectiveStatus,
|
||||
contentType,
|
||||
body: text,
|
||||
retryable,
|
||||
transientNetwork: effectiveStatus >= 500,
|
||||
accountError: effectiveStatus === 401 || effectiveStatus === 403
|
||||
});
|
||||
if (data.status && [401, 403, 429, 500].includes(data.status)) {
|
||||
const err = new Error(data.msg || data.message || JSON.stringify(data));
|
||||
if (data.status === 500) err.transientNetwork = true;
|
||||
throw err;
|
||||
}
|
||||
return data;
|
||||
} finally {
|
||||
@@ -381,14 +347,12 @@ async function apiGet(url, signal, hosterName) {
|
||||
async function getUploadServer(hosterName, hosterConfig, apiKey, signal) {
|
||||
let lastMessage = '';
|
||||
let lastTransient = false;
|
||||
let lastError = null;
|
||||
|
||||
for (let attempt = 1; attempt <= SERVER_RETRY_ATTEMPTS; attempt++) {
|
||||
for (const endpoint of hosterConfig.serverEndpoints) {
|
||||
const url = `${hosterConfig.apiBase}${endpoint}?key=${encodeURIComponent(apiKey)}`;
|
||||
try {
|
||||
const data = await apiGet(url, signal, hosterName);
|
||||
lastError = null;
|
||||
const data = await apiGet(url, signal);
|
||||
const uploadUrl = extractUploadServerUrl(data, hosterConfig.apiBase);
|
||||
if (uploadUrl) {
|
||||
LAST_UPLOAD_SERVERS.set(hosterName, uploadUrl);
|
||||
@@ -401,16 +365,12 @@ async function getUploadServer(hosterName, hosterConfig, apiKey, signal) {
|
||||
if (apiMessage) lastMessage = apiMessage;
|
||||
} catch (err) {
|
||||
if (err.name === 'AbortError') throw err;
|
||||
lastError = err;
|
||||
if (err.message) lastMessage = err.message;
|
||||
if (err.transientNetwork === true) lastTransient = true;
|
||||
}
|
||||
}
|
||||
|
||||
const retryable = lastError && lastError.diagnostic
|
||||
? lastError.diagnostic.retryable === true
|
||||
: shouldRetryServerLookup(lastMessage);
|
||||
if (attempt < SERVER_RETRY_ATTEMPTS && retryable) {
|
||||
if (attempt < SERVER_RETRY_ATTEMPTS && shouldRetryServerLookup(lastMessage)) {
|
||||
await sleep(SERVER_RETRY_DELAY_MS, signal);
|
||||
continue;
|
||||
}
|
||||
@@ -419,14 +379,11 @@ async function getUploadServer(hosterName, hosterConfig, apiKey, signal) {
|
||||
}
|
||||
|
||||
const cachedServer = LAST_UPLOAD_SERVERS.get(hosterName);
|
||||
const retryable = lastError && lastError.diagnostic
|
||||
? lastError.diagnostic.retryable === true
|
||||
: shouldRetryServerLookup(lastMessage);
|
||||
if (cachedServer && retryable) {
|
||||
if (cachedServer && shouldRetryServerLookup(lastMessage)) {
|
||||
return cachedServer;
|
||||
}
|
||||
|
||||
if (retryable && Array.isArray(hosterConfig.fallbackUploadServers)) {
|
||||
if (shouldRetryServerLookup(lastMessage) && Array.isArray(hosterConfig.fallbackUploadServers)) {
|
||||
for (const fallback of hosterConfig.fallbackUploadServers) {
|
||||
const normalized = normalizeAbsoluteUrl(fallback, hosterConfig.apiBase);
|
||||
if (normalized) {
|
||||
@@ -437,291 +394,50 @@ async function getUploadServer(hosterName, hosterConfig, apiKey, signal) {
|
||||
}
|
||||
|
||||
if (lastMessage) {
|
||||
const e = lastError || createTransportError(`Kein Upload-Server für ${hosterName} erhalten`, {
|
||||
phase: 'upload-server',
|
||||
endpoint: hosterConfig.apiBase,
|
||||
retryable
|
||||
});
|
||||
if (retryable) e.hosterTransient = true;
|
||||
const e = new Error(`Kein Upload-Server erhalten: ${lastMessage}`);
|
||||
// "no servers available" / busy / try-again is a transient hoster-side
|
||||
// condition, not an account fault — tag it so the account isn't blacklisted.
|
||||
// Genuine auth failures (invalid key / unauthorized / forbidden) make
|
||||
// shouldRetryServerLookup return false and stay classified as account errors.
|
||||
if (shouldRetryServerLookup(lastMessage)) e.hosterTransient = true;
|
||||
if (lastTransient) e.transientNetwork = true;
|
||||
throw e;
|
||||
}
|
||||
throw createTransportError(`Kein Upload-Server für ${hosterName} erhalten`, {
|
||||
phase: 'upload-server',
|
||||
endpoint: hosterConfig.apiBase
|
||||
});
|
||||
throw new Error('Kein Upload-Server erhalten. API-Key prüfen.');
|
||||
}
|
||||
|
||||
async function _requestFileList(url, signal, phase, hosterName) {
|
||||
let response;
|
||||
try {
|
||||
response = await request(url, {
|
||||
method: 'GET', signal,
|
||||
headers: { 'Accept': 'application/json', 'User-Agent': 'multi-hoster-uploader/1.1' },
|
||||
headersTimeout: 30_000, bodyTimeout: 30_000
|
||||
});
|
||||
} catch (err) {
|
||||
if (signal && signal.aborted) throw err;
|
||||
throw createTransportError(`${hosterName}: Dateiliste konnte nicht geladen werden`, {
|
||||
phase,
|
||||
endpoint: url,
|
||||
retryable: true,
|
||||
transientNetwork: true
|
||||
});
|
||||
}
|
||||
|
||||
const contentType = response.headers && response.headers['content-type'];
|
||||
let text;
|
||||
try {
|
||||
text = await response.body.text();
|
||||
} catch (err) {
|
||||
if (signal && signal.aborted) throw err;
|
||||
throw createTransportError(`${hosterName}: Dateiliste konnte nicht gelesen werden`, {
|
||||
phase,
|
||||
endpoint: url,
|
||||
httpStatus: response.statusCode,
|
||||
contentType,
|
||||
retryable: true,
|
||||
transientNetwork: true
|
||||
});
|
||||
}
|
||||
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
const retryable = response.statusCode === 429 || response.statusCode >= 500;
|
||||
throw createTransportError(`${hosterName}: Dateiliste konnte nicht geladen werden`, {
|
||||
phase,
|
||||
endpoint: url,
|
||||
httpStatus: response.statusCode,
|
||||
contentType,
|
||||
body: text,
|
||||
retryable,
|
||||
transientNetwork: response.statusCode >= 500
|
||||
});
|
||||
}
|
||||
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch {
|
||||
throw createTransportError(`${hosterName}: Dateiliste war kein JSON`, {
|
||||
phase,
|
||||
endpoint: url,
|
||||
httpStatus: response.statusCode,
|
||||
contentType,
|
||||
body: text
|
||||
});
|
||||
}
|
||||
|
||||
if (!data || typeof data !== 'object') {
|
||||
throw createTransportError(`${hosterName}: Dateiliste hatte ein ungültiges Format`, {
|
||||
phase,
|
||||
endpoint: url,
|
||||
httpStatus: response.statusCode,
|
||||
contentType,
|
||||
body: text
|
||||
});
|
||||
}
|
||||
|
||||
const apiStatus = Number(data && data.status);
|
||||
const statusText = typeof data.status === 'string' ? data.status.trim().toLowerCase() : '';
|
||||
const semanticFailure = data.success === false
|
||||
|| data.ok === false
|
||||
|| data.status === false
|
||||
|| /^(?:error|failed|failure|denied|invalid|rejected)$/.test(statusText);
|
||||
if (apiStatus >= 400 || semanticFailure) {
|
||||
const retryable = apiStatus === 429 || apiStatus >= 500;
|
||||
throw createTransportError(`${hosterName}: Dateiliste wurde abgelehnt`, {
|
||||
phase,
|
||||
endpoint: url,
|
||||
httpStatus: apiStatus >= 100 ? apiStatus : response.statusCode,
|
||||
contentType,
|
||||
body: text,
|
||||
retryable,
|
||||
transientNetwork: apiStatus >= 500
|
||||
});
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
function _requireFileList(data, candidates, phase, hosterName, url) {
|
||||
for (const candidate of candidates) {
|
||||
if (Array.isArray(candidate)) return candidate;
|
||||
}
|
||||
throw createTransportError(`${hosterName}: Dateiliste hatte ein ungültiges Format`, {
|
||||
phase,
|
||||
endpoint: url,
|
||||
httpStatus: 200,
|
||||
contentType: 'application/json'
|
||||
});
|
||||
}
|
||||
|
||||
async function _fetchByseFileList(apiKey, signal, phase = 'recovery-poll') {
|
||||
async function _fetchByseFileList(apiKey, signal) {
|
||||
// Byse's file-list endpoint. Returns up to 100 most-recent files — enough
|
||||
// to match the upload we just did against what the server has. The API
|
||||
// shape is typical XFS: { status, msg, result: { files: [...] } } or
|
||||
// { status, msg, files: [...] }.
|
||||
const url = `https://api.byse.sx/file/list?key=${encodeURIComponent(apiKey)}&per_page=100&sort=date&order=desc`;
|
||||
const data = await _requestFileList(url, signal, phase, 'Byse');
|
||||
const src = _requireFileList(data, [
|
||||
data.files,
|
||||
data.result && data.result.files,
|
||||
data.result
|
||||
], phase, 'Byse', url);
|
||||
return src.map(f => ({
|
||||
file_code: String(f.file_code || f.filecode || '').trim(),
|
||||
file_name: String(f.title || f.name || f.file_name || '').trim()
|
||||
})).filter(f => f.file_code);
|
||||
try {
|
||||
const { body, statusCode } = await request(url, {
|
||||
method: 'GET', signal,
|
||||
headers: { 'Accept': 'application/json', 'User-Agent': 'multi-hoster-uploader/1.1' },
|
||||
headersTimeout: 30_000, bodyTimeout: 30_000
|
||||
});
|
||||
const text = await body.text();
|
||||
if (statusCode < 200 || statusCode >= 300) return [];
|
||||
const data = JSON.parse(text);
|
||||
const src = Array.isArray(data.files) ? data.files
|
||||
: (data.result && Array.isArray(data.result.files) ? data.result.files
|
||||
: (Array.isArray(data.result) ? data.result : []));
|
||||
return src.map(f => ({
|
||||
file_code: String(f.file_code || f.filecode || '').trim(),
|
||||
file_name: String(f.title || f.name || f.file_name || '').trim()
|
||||
})).filter(f => f.file_code);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function _normalizeFileTitle(s) {
|
||||
const normalized = String(s || '')
|
||||
.normalize('NFKD')
|
||||
.toLowerCase()
|
||||
.replace(/\.[\p{Letter}\p{Number}]+$/u, '')
|
||||
.replace(/\p{Variation_Selector}+/gu, '');
|
||||
const alphanumeric = normalized
|
||||
.replace(/\p{Mark}+/gu, '')
|
||||
.replace(/[^\p{Letter}\p{Number}]+/gu, '');
|
||||
if (alphanumeric) return alphanumeric;
|
||||
const codePoints = Array.from(normalized, value => value.codePointAt(0).toString(16)).join('-');
|
||||
return `symbols:${codePoints}`;
|
||||
return String(s || '').toLowerCase().replace(/\.[a-z0-9]+$/i, '').replace(/[^a-z0-9]+/g, '');
|
||||
}
|
||||
|
||||
function _normalizeRecoveryHoster(value) {
|
||||
return String(value || '').normalize('NFKC').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function _normalizeRecoveryAccount(value) {
|
||||
return String(value || '').normalize('NFKC').trim();
|
||||
}
|
||||
|
||||
function _createRecoveryUncertainError() {
|
||||
const error = new Error('Upload-Ergebnis für diesen Titel ist wegen eines möglichen Remote-Commits unsicher');
|
||||
error.remoteCommitUncertain = true;
|
||||
error.hosterTransient = true;
|
||||
return error;
|
||||
}
|
||||
|
||||
function _markRecoveryUncertain(recoveryClaim, error) {
|
||||
if (!recoveryClaim) return error;
|
||||
if (typeof recoveryClaim.markUncertain === 'function') {
|
||||
return recoveryClaim.markUncertain(error);
|
||||
}
|
||||
const uncertainError = error && typeof error === 'object'
|
||||
? error
|
||||
: _createRecoveryUncertainError();
|
||||
uncertainError.remoteCommitUncertain = true;
|
||||
uncertainError.hosterTransient = true;
|
||||
return uncertainError;
|
||||
}
|
||||
|
||||
function _createAbortError() {
|
||||
const error = new Error('Operation aborted');
|
||||
error.name = 'AbortError';
|
||||
return error;
|
||||
}
|
||||
|
||||
function _waitForRecoveryTurn(predecessor, signal) {
|
||||
if (!signal) return predecessor;
|
||||
if (signal.aborted) return Promise.reject(_createAbortError());
|
||||
return new Promise((resolve, reject) => {
|
||||
const onAbort = () => {
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
reject(_createAbortError());
|
||||
};
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
predecessor.then(
|
||||
() => {
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
resolve();
|
||||
},
|
||||
error => {
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
reject(error);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function createRecoveryClaimRegistry() {
|
||||
const accounts = new Map();
|
||||
let nextClaimId = 1;
|
||||
return {
|
||||
forUpload(hosterName, apiKey, fileName) {
|
||||
const accountIdentity = crypto.createHash('sha256')
|
||||
.update(`${_normalizeRecoveryHoster(hosterName)}\0${_normalizeRecoveryAccount(apiKey)}`)
|
||||
.digest('hex');
|
||||
let account = accounts.get(accountIdentity);
|
||||
if (!account) {
|
||||
account = {
|
||||
codes: new Map(),
|
||||
titles: new Map()
|
||||
};
|
||||
accounts.set(accountIdentity, account);
|
||||
}
|
||||
const titleIdentity = _normalizeFileTitle(fileName);
|
||||
let title = account.titles.get(titleIdentity);
|
||||
if (!title) {
|
||||
title = {
|
||||
tail: Promise.resolve(),
|
||||
uncertain: false
|
||||
};
|
||||
account.titles.set(titleIdentity, title);
|
||||
}
|
||||
const claimId = nextClaimId++;
|
||||
return {
|
||||
has(code) {
|
||||
return account.codes.has(String(code || '').trim());
|
||||
},
|
||||
reserve(code) {
|
||||
const normalized = String(code || '').trim();
|
||||
if (!normalized) return false;
|
||||
if (account.codes.has(normalized)) {
|
||||
return account.codes.get(normalized) === claimId;
|
||||
}
|
||||
account.codes.set(normalized, claimId);
|
||||
return true;
|
||||
},
|
||||
markUncertain(error) {
|
||||
title.uncertain = true;
|
||||
const uncertainError = error && typeof error === 'object'
|
||||
? error
|
||||
: _createRecoveryUncertainError();
|
||||
uncertainError.remoteCommitUncertain = true;
|
||||
uncertainError.hosterTransient = true;
|
||||
return uncertainError;
|
||||
},
|
||||
isUncertain() {
|
||||
return title.uncertain;
|
||||
},
|
||||
async runExclusive(operation, signal) {
|
||||
const predecessor = title.tail;
|
||||
let release;
|
||||
const current = new Promise(resolve => {
|
||||
release = resolve;
|
||||
});
|
||||
title.tail = predecessor.then(() => current, () => current);
|
||||
try {
|
||||
await _waitForRecoveryTurn(predecessor, signal);
|
||||
if (title.uncertain) throw _createRecoveryUncertainError();
|
||||
const result = await operation();
|
||||
if (title.uncertain) throw _createRecoveryUncertainError();
|
||||
return result;
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
clear() {
|
||||
accounts.clear();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function _resolveByseUploadByName(apiKey, fileName, baselineCodes, signal, recoveryClaim) {
|
||||
if (!(baselineCodes instanceof Set)) return null;
|
||||
async function _resolveByseUploadByName(apiKey, fileName, baselineCodes, signal) {
|
||||
const expected = _normalizeFileTitle(fileName);
|
||||
const POLL_ATTEMPTS = 15;
|
||||
const POLL_DELAY_MS = 2000;
|
||||
@@ -734,13 +450,8 @@ async function _resolveByseUploadByName(apiKey, fileName, baselineCodes, signal,
|
||||
// poller could claim job B's newly appeared file and return the wrong
|
||||
// URL. At the cost of a few false-negatives when byse mangles the
|
||||
// filename beyond our normalizer, correctness for parallel uploads wins.
|
||||
const matches = newFiles
|
||||
.filter(f => _normalizeFileTitle(f.file_name) === expected)
|
||||
.filter(f => !recoveryClaim || typeof recoveryClaim.has !== 'function' || !recoveryClaim.has(f.file_code));
|
||||
if (matches.length > 1) return null;
|
||||
if (matches.length === 1) {
|
||||
const match = matches[0];
|
||||
if (recoveryClaim && typeof recoveryClaim.reserve === 'function' && !recoveryClaim.reserve(match.file_code)) return null;
|
||||
const match = newFiles.find(f => _normalizeFileTitle(f.file_name) === expected);
|
||||
if (match) {
|
||||
return {
|
||||
download_url: `https://byse.sx/d/${match.file_code}`,
|
||||
embed_url: `https://byse.sx/e/${match.file_code}`,
|
||||
@@ -758,24 +469,34 @@ async function _resolveByseUploadByName(apiKey, fileName, baselineCodes, signal,
|
||||
return null;
|
||||
}
|
||||
|
||||
async function _fetchDoodstreamFileList(apiKey, signal, phase = 'recovery-poll') {
|
||||
async function _fetchDoodstreamFileList(apiKey, signal) {
|
||||
// doodapi.co file list: { msg, status:200, result: { files: [{ file_code, title, uploaded, ... }] } }
|
||||
// sort=created&order=desc forces newest-first — VERIFIED against a real 90k-file
|
||||
// account, where a single page without it could miss a just-uploaded file. The
|
||||
// recovery only needs the most recent uploads, so page 1 newest-first suffices.
|
||||
const url = `https://doodapi.co/api/file/list?key=${encodeURIComponent(apiKey)}&per_page=200&sort=created&order=desc`;
|
||||
const data = await _requestFileList(url, signal, phase, 'Doodstream');
|
||||
const files = _requireFileList(data, [data && data.result && data.result.files], phase, 'Doodstream', url);
|
||||
return files.map(f => ({
|
||||
file_code: String(f.file_code || f.filecode || '').trim(),
|
||||
file_name: String(f.title || f.file_name || f.name || '').trim()
|
||||
})).filter(f => f.file_code);
|
||||
try {
|
||||
const { body, statusCode } = await request(url, {
|
||||
method: 'GET', signal,
|
||||
headers: { 'Accept': 'application/json', 'User-Agent': 'multi-hoster-uploader/1.1' },
|
||||
headersTimeout: 30_000, bodyTimeout: 30_000
|
||||
});
|
||||
const text = await body.text();
|
||||
if (statusCode < 200 || statusCode >= 300) return [];
|
||||
const data = JSON.parse(text);
|
||||
const files = data && data.result && Array.isArray(data.result.files) ? data.result.files : [];
|
||||
return files.map(f => ({
|
||||
file_code: String(f.file_code || f.filecode || '').trim(),
|
||||
file_name: String(f.title || f.file_name || f.name || '').trim()
|
||||
})).filter(f => f.file_code);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const DOODSTREAM_POLL = { attempts: 12, delayMs: 2500 }; // test-tunable via __test
|
||||
|
||||
async function _resolveDoodstreamUploadByName(apiKey, fileName, baselineCodes, signal, recoveryClaim) {
|
||||
if (!(baselineCodes instanceof Set)) return null;
|
||||
async function _resolveDoodstreamUploadByName(apiKey, fileName, baselineCodes, signal) {
|
||||
// Same recovery byse uses: the upload POST returned no filecode, but the file
|
||||
// may register in the account a little later. Poll the list for a NEW file
|
||||
// whose normalized title matches what we uploaded. Exact-name match only
|
||||
@@ -788,13 +509,8 @@ async function _resolveDoodstreamUploadByName(apiKey, fileName, baselineCodes, s
|
||||
if (signal && signal.aborted) return null;
|
||||
const list = await _fetchDoodstreamFileList(apiKey, signal);
|
||||
const fresh = list.filter(f => !baselineCodes.has(f.file_code));
|
||||
const matches = fresh
|
||||
.filter(f => _normalizeFileTitle(f.file_name) === expected)
|
||||
.filter(f => !recoveryClaim || typeof recoveryClaim.has !== 'function' || !recoveryClaim.has(f.file_code));
|
||||
if (matches.length > 1) return null;
|
||||
if (matches.length === 1) {
|
||||
const match = matches[0];
|
||||
if (recoveryClaim && typeof recoveryClaim.reserve === 'function' && !recoveryClaim.reserve(match.file_code)) return null;
|
||||
const match = fresh.find(f => _normalizeFileTitle(f.file_name) === expected);
|
||||
if (match) {
|
||||
return {
|
||||
download_url: `https://doodstream.com/d/${match.file_code}`,
|
||||
embed_url: `https://doodstream.com/e/${match.file_code}`,
|
||||
@@ -817,33 +533,21 @@ async function uploadFile(hosterName, filePath, apiKey, onProgress, signal, thro
|
||||
if (!config) throw new Error(`Unbekannter Hoster: ${hosterName}`);
|
||||
|
||||
let byseBaseline = null;
|
||||
let byseBaselineError = null;
|
||||
if (hosterName === 'byse.sx') {
|
||||
if (opts && opts.byseBaseline instanceof Set) {
|
||||
byseBaseline = opts.byseBaseline;
|
||||
} else {
|
||||
try {
|
||||
const baseline = await _fetchByseFileList(apiKey, signal, 'recovery-baseline');
|
||||
byseBaseline = new Set(baseline.map(f => f.file_code));
|
||||
} catch (err) {
|
||||
if (signal && signal.aborted) throw err;
|
||||
byseBaselineError = err;
|
||||
}
|
||||
const baseline = await _fetchByseFileList(apiKey, signal);
|
||||
byseBaseline = new Set(baseline.map(f => f.file_code));
|
||||
}
|
||||
}
|
||||
let doodBaseline = null;
|
||||
let doodBaselineError = null;
|
||||
if (hosterName === 'doodstream.com') {
|
||||
if (opts && opts.doodBaseline instanceof Set) {
|
||||
doodBaseline = opts.doodBaseline;
|
||||
} else {
|
||||
try {
|
||||
const baseline = await _fetchDoodstreamFileList(apiKey, signal, 'recovery-baseline');
|
||||
doodBaseline = new Set(baseline.map(f => f.file_code));
|
||||
} catch (err) {
|
||||
if (signal && signal.aborted) throw err;
|
||||
doodBaselineError = err;
|
||||
}
|
||||
const baseline = await _fetchDoodstreamFileList(apiKey, signal);
|
||||
doodBaseline = new Set(baseline.map(f => f.file_code));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -856,52 +560,31 @@ async function uploadFile(hosterName, filePath, apiKey, onProgress, signal, thro
|
||||
|
||||
const { iterable, boundary, totalSize } = createUploadBody(filePath, formFields, onProgress, throttle, signal);
|
||||
|
||||
let uploadResponse;
|
||||
try {
|
||||
uploadResponse = await request(targetUrl, {
|
||||
method: 'POST',
|
||||
body: iterable,
|
||||
signal,
|
||||
headers: {
|
||||
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
||||
'Content-Length': String(totalSize),
|
||||
'Accept': 'application/json, text/plain;q=0.9, */*;q=0.8',
|
||||
'User-Agent': 'multi-hoster-uploader/1.1'
|
||||
},
|
||||
headersTimeout: UPLOAD_TIMEOUT,
|
||||
bodyTimeout: UPLOAD_TIMEOUT
|
||||
});
|
||||
} catch (err) {
|
||||
const error = signal && signal.aborted ? err : createTransportError(`Upload zu ${hosterName} konnte nicht übertragen werden`, {
|
||||
phase: 'upload-request',
|
||||
endpoint: targetUrl,
|
||||
retryable: true,
|
||||
transientNetwork: true
|
||||
});
|
||||
throw _markRecoveryUncertain(opts && opts.recoveryClaim, error);
|
||||
}
|
||||
const { body, statusCode, headers } = await request(targetUrl, {
|
||||
method: 'POST',
|
||||
body: iterable,
|
||||
signal,
|
||||
headers: {
|
||||
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
||||
'Content-Length': String(totalSize),
|
||||
'Accept': 'application/json, text/plain;q=0.9, */*;q=0.8',
|
||||
'User-Agent': 'multi-hoster-uploader/1.1'
|
||||
},
|
||||
headersTimeout: UPLOAD_TIMEOUT,
|
||||
bodyTimeout: UPLOAD_TIMEOUT
|
||||
});
|
||||
|
||||
const { body, statusCode, headers } = uploadResponse;
|
||||
|
||||
let rawBody;
|
||||
try {
|
||||
rawBody = await body.text();
|
||||
} catch (err) {
|
||||
throw _markRecoveryUncertain(opts && opts.recoveryClaim, err);
|
||||
}
|
||||
const rawBody = await body.text();
|
||||
let payload = null;
|
||||
try {
|
||||
payload = rawBody ? JSON.parse(rawBody) : {};
|
||||
} catch {
|
||||
throw _markRecoveryUncertain(opts && opts.recoveryClaim, createTransportError(`Upload-Antwort von ${hosterName} war kein JSON`, {
|
||||
phase: 'upload-response',
|
||||
endpoint: targetUrl,
|
||||
httpStatus: statusCode,
|
||||
contentType: headers && headers['content-type'],
|
||||
body: rawBody,
|
||||
retryable: statusCode >= 500,
|
||||
transientNetwork: statusCode >= 500
|
||||
}));
|
||||
const snippet = rawBody ? rawBody.slice(0, 240).replace(/\s+/g, ' ').trim() : '';
|
||||
const err = new Error(
|
||||
`Upload-Antwort von ${hosterName} war kein JSON (HTTP ${statusCode}${snippet ? `): ${snippet}` : ')'}`
|
||||
);
|
||||
if (statusCode >= 500) err.transientNetwork = true;
|
||||
throw err;
|
||||
}
|
||||
// Normalize valid-but-not-object JSON (JSON.parse('null') → null;
|
||||
// JSON.parse('"foo"') → string; JSON.parse('[1]') → array). Without this
|
||||
@@ -915,33 +598,19 @@ async function uploadFile(hosterName, filePath, apiKey, onProgress, signal, thro
|
||||
}
|
||||
|
||||
if (statusCode < 200 || statusCode >= 300) {
|
||||
const error = createTransportError(`Upload zu ${hosterName} fehlgeschlagen`, {
|
||||
phase: 'upload-response',
|
||||
endpoint: targetUrl,
|
||||
httpStatus: statusCode,
|
||||
contentType: headers && headers['content-type'],
|
||||
body: rawBody,
|
||||
retryable: statusCode === 429 || statusCode >= 500,
|
||||
transientNetwork: statusCode >= 500
|
||||
});
|
||||
throw statusCode >= 500
|
||||
? _markRecoveryUncertain(opts && opts.recoveryClaim, error)
|
||||
: error;
|
||||
const err = new Error(
|
||||
payload.msg
|
||||
|| payload.message
|
||||
|| `Upload fehlgeschlagen (HTTP ${statusCode}${headers?.['content-type'] ? `, ${headers['content-type']}` : ''})`
|
||||
);
|
||||
if (statusCode >= 500) err.transientNetwork = true;
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (payload.status && [401, 403, 429, 500].includes(payload.status)) {
|
||||
const error = createTransportError(`Upload zu ${hosterName} wurde abgelehnt`, {
|
||||
phase: 'upload-response',
|
||||
endpoint: targetUrl,
|
||||
httpStatus: Number(payload.status),
|
||||
contentType: headers && headers['content-type'],
|
||||
body: rawBody,
|
||||
retryable: Number(payload.status) === 429 || Number(payload.status) >= 500,
|
||||
transientNetwork: Number(payload.status) >= 500
|
||||
});
|
||||
throw Number(payload.status) >= 500
|
||||
? _markRecoveryUncertain(opts && opts.recoveryClaim, error)
|
||||
: error;
|
||||
const err = new Error(payload.msg || payload.message || JSON.stringify(payload));
|
||||
if (payload.status === 500) err.transientNetwork = true;
|
||||
throw err;
|
||||
}
|
||||
|
||||
let result = null;
|
||||
@@ -950,32 +619,19 @@ async function uploadFile(hosterName, filePath, apiKey, onProgress, signal, thro
|
||||
result = config.parseResult(payload);
|
||||
} catch (err) {
|
||||
if (err && typeof err === 'object' && !err.diagnostic) {
|
||||
err.diagnostic = createTransportError(`Upload zu ${hosterName} konnte nicht ausgewertet werden`, {
|
||||
phase: 'upload-result',
|
||||
endpoint: targetUrl,
|
||||
httpStatus: statusCode,
|
||||
contentType: headers && headers['content-type'],
|
||||
body: rawBody
|
||||
}).diagnostic;
|
||||
try {
|
||||
err.diagnostic = {
|
||||
hoster: hosterName,
|
||||
http: statusCode,
|
||||
contentType: (headers && headers['content-type']) || null,
|
||||
payloadSnippet: JSON.stringify(payload).slice(0, 1000),
|
||||
uploadUrl: targetUrl
|
||||
};
|
||||
} catch { /* JSON cycle — skip diagnostic */ }
|
||||
}
|
||||
parseErr = err;
|
||||
}
|
||||
if (result && (result.file_code || result.download_url || result.embed_url)) {
|
||||
if (result.file_code && opts && opts.recoveryClaim && typeof opts.recoveryClaim.reserve === 'function') {
|
||||
if (!opts.recoveryClaim.reserve(result.file_code)) {
|
||||
const error = createTransportError(`Upload zu ${hosterName} lieferte eine bereits zugeordnete file_code-Antwort`, {
|
||||
phase: 'upload-result',
|
||||
endpoint: targetUrl,
|
||||
httpStatus: statusCode,
|
||||
contentType: headers && headers['content-type'],
|
||||
body: rawBody,
|
||||
retryable: true,
|
||||
hosterTransient: true
|
||||
});
|
||||
error.remoteIdentityClaimed = true;
|
||||
throw _markRecoveryUncertain(opts.recoveryClaim, error);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -1001,12 +657,8 @@ async function uploadFile(hosterName, filePath, apiKey, onProgress, signal, thro
|
||||
// even after our uploader gave up.
|
||||
if (hosterName === 'byse.sx' && byseBaseline && !explicitlyRejected) {
|
||||
const fileName = path.basename(filePath);
|
||||
try {
|
||||
const polled = await _resolveByseUploadByName(apiKey, fileName, byseBaseline, signal, opts && opts.recoveryClaim);
|
||||
if (polled) return polled;
|
||||
} catch (err) {
|
||||
throw _markRecoveryUncertain(opts && opts.recoveryClaim, err);
|
||||
}
|
||||
const polled = await _resolveByseUploadByName(apiKey, fileName, byseBaseline, signal);
|
||||
if (polled) return polled;
|
||||
}
|
||||
|
||||
// Doodstream: the doodapi upload POST returned no filecode (the same backend
|
||||
@@ -1014,47 +666,24 @@ async function uploadFile(hosterName, filePath, apiKey, onProgress, signal, thro
|
||||
// the file did register, claim its code instead of failing the upload.
|
||||
if (hosterName === 'doodstream.com' && doodBaseline && !explicitlyRejected) {
|
||||
const fileName = path.basename(filePath);
|
||||
try {
|
||||
const polled = await _resolveDoodstreamUploadByName(apiKey, fileName, doodBaseline, signal, opts && opts.recoveryClaim);
|
||||
if (polled) return polled;
|
||||
} catch (err) {
|
||||
throw _markRecoveryUncertain(opts && opts.recoveryClaim, err);
|
||||
}
|
||||
const polled = await _resolveDoodstreamUploadByName(apiKey, fileName, doodBaseline, signal);
|
||||
if (polled) return polled;
|
||||
}
|
||||
|
||||
if (hosterName === 'byse.sx' && byseBaselineError && !explicitlyRejected) {
|
||||
byseBaselineError.hosterTransient = true;
|
||||
throw _markRecoveryUncertain(opts && opts.recoveryClaim, byseBaselineError);
|
||||
}
|
||||
|
||||
if (hosterName === 'doodstream.com' && doodBaselineError && !explicitlyRejected) {
|
||||
doodBaselineError.hosterTransient = true;
|
||||
throw _markRecoveryUncertain(opts && opts.recoveryClaim, doodBaselineError);
|
||||
}
|
||||
|
||||
if (parseErr) {
|
||||
throw explicitlyRejected
|
||||
? parseErr
|
||||
: _markRecoveryUncertain(opts && opts.recoveryClaim, parseErr);
|
||||
}
|
||||
if (parseErr) throw parseErr;
|
||||
|
||||
if (payload.success === false) {
|
||||
throw createTransportError(`Upload zu ${hosterName} wurde vom Server abgelehnt`, {
|
||||
phase: 'upload-result',
|
||||
endpoint: targetUrl,
|
||||
httpStatus: statusCode,
|
||||
contentType: headers && headers['content-type'],
|
||||
body: rawBody
|
||||
});
|
||||
throw new Error(payload.msg || payload.message || `Upload zu ${hosterName} wurde vom Server abgelehnt.`);
|
||||
}
|
||||
|
||||
// Avoid throwing a bare "OK" / "SUCCESS" as the error message — that happens
|
||||
// when the server says "msg: OK" but ships no file_code anywhere we know
|
||||
// about, typically an API change. Surface safe structured response metadata
|
||||
// so future logs show what kind of response the server returned.
|
||||
// about, typically an API change. Surface the full (trimmed) payload so
|
||||
// future logs actually show what the server returned.
|
||||
const msg = String(payload.msg || payload.message || '').trim();
|
||||
const isOkishNoPayload = /^(ok|success|done|accepted)$/i.test(msg);
|
||||
if (isOkishNoPayload || !msg) {
|
||||
const snippet = JSON.stringify(payload).slice(0, 400);
|
||||
// 2xx with no filecode: the hoster accepted the upload (bytes sent, status
|
||||
// OK) but returned no usable link. For doodstream this is the API-path
|
||||
// analog of the web empty-form — the backend file-registration timing out
|
||||
@@ -1062,33 +691,23 @@ async function uploadFile(hosterName, filePath, apiKey, onProgress, signal, thro
|
||||
// so tag it hosterTransient: the upload-manager then fails this file WITHOUT
|
||||
// blacklisting the account (same protection the web path got in 3.3.29) and
|
||||
// the account stays usable for the next retry/batch.
|
||||
throw _markRecoveryUncertain(opts && opts.recoveryClaim, createTransportError(`Upload zu ${hosterName} lieferte keine file_code-Antwort`, {
|
||||
phase: 'upload-result',
|
||||
endpoint: targetUrl,
|
||||
httpStatus: statusCode,
|
||||
contentType: headers && headers['content-type'],
|
||||
body: rawBody,
|
||||
retryable: true,
|
||||
hosterTransient: true
|
||||
}));
|
||||
const err = new Error(
|
||||
`Upload zu ${hosterName} lieferte keine file_code-Antwort (Payload: ${snippet})`
|
||||
);
|
||||
err.hosterTransient = true;
|
||||
throw err;
|
||||
}
|
||||
throw createTransportError(`Upload zu ${hosterName} wurde abgelehnt`, {
|
||||
phase: 'upload-result',
|
||||
endpoint: targetUrl,
|
||||
httpStatus: statusCode,
|
||||
contentType: headers && headers['content-type'],
|
||||
body: rawBody
|
||||
});
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
async function prefetchBaseline(hosterName, apiKey, signal) {
|
||||
try {
|
||||
if (hosterName === 'byse.sx') {
|
||||
const baseline = await _fetchByseFileList(apiKey, signal, 'recovery-baseline');
|
||||
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, 'recovery-baseline');
|
||||
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 */ }
|
||||
@@ -1098,8 +717,6 @@ async function prefetchBaseline(hosterName, apiKey, signal) {
|
||||
module.exports = {
|
||||
uploadFile,
|
||||
prefetchBaseline,
|
||||
createRecoveryClaimRegistry,
|
||||
normalizeRecoveryTitle: _normalizeFileTitle,
|
||||
HOSTER_CONFIGS,
|
||||
__test: {
|
||||
extractUploadServerUrl,
|
||||
|
||||
+158
-167
@@ -1,185 +1,176 @@
|
||||
(function initImportPreflight(root, factory) {
|
||||
const api = typeof module === 'object' && module.exports
|
||||
? factory(require('path'), require('./filename-filter'))
|
||||
? factory(require('path'))
|
||||
: factory({
|
||||
normalize: value => String(value).replace(/[\\/]+/g, '/'),
|
||||
basename: value => String(value).split(/[\\/]/).pop() || ''
|
||||
}, root.FilenameFilter);
|
||||
});
|
||||
if (typeof module === 'object' && module.exports) module.exports = api;
|
||||
if (root) root.ImportPreflight = api;
|
||||
})(typeof window !== 'undefined' ? window : globalThis, function createImportPreflight(path, filenameFilter) {
|
||||
const { applyFilenameFilter } = filenameFilter;
|
||||
|
||||
function normalizePathValue(value) {
|
||||
let text = String(value ?? '').trim();
|
||||
if (!text) return '';
|
||||
const uncNamespace = text.match(/^[\\/]{2}\?[\\/]UNC[\\/]/i);
|
||||
if (uncNamespace) text = `\\\\${text.slice(uncNamespace[0].length)}`;
|
||||
else {
|
||||
const driveNamespace = text.match(/^[\\/]{2}\?[\\/](?=[A-Za-z]:[\\/])/);
|
||||
if (driveNamespace) text = text.slice(driveNamespace[0].length);
|
||||
}
|
||||
return path.normalize(text);
|
||||
}
|
||||
|
||||
function normalizeEntry(value) {
|
||||
const source = value && typeof value === 'object' ? value : {};
|
||||
const filePath = normalizePathValue(typeof value === 'string' ? value : source.path);
|
||||
const sourceName = typeof value === 'string' ? '' : String(source.name ?? '').trim();
|
||||
return {
|
||||
path: filePath,
|
||||
name: sourceName || path.basename(filePath),
|
||||
size: Number.isFinite(Number(source.size)) ? Number(source.size) : null
|
||||
};
|
||||
}
|
||||
|
||||
function createPathKey(value, caseInsensitive) {
|
||||
const normalized = normalizePathValue(value);
|
||||
return caseInsensitive ? normalized.toLocaleLowerCase('en-US') : normalized;
|
||||
}
|
||||
|
||||
async function mapWithConcurrency(items, concurrency, operation) {
|
||||
const results = new Array(items.length);
|
||||
let cursor = 0;
|
||||
async function worker() {
|
||||
while (cursor < items.length) {
|
||||
const index = cursor++;
|
||||
results[index] = await operation(items[index], index);
|
||||
})(typeof window !== 'undefined' ? window : globalThis, function createImportPreflight(path) {
|
||||
function normalizePathValue(value) {
|
||||
let text = String(value ?? '').trim();
|
||||
if (!text) return '';
|
||||
const uncNamespace = text.match(/^[\\/]{2}\?[\\/]UNC[\\/]/i);
|
||||
if (uncNamespace) text = `\\\\${text.slice(uncNamespace[0].length)}`;
|
||||
else {
|
||||
const driveNamespace = text.match(/^[\\/]{2}\?[\\/](?=[A-Za-z]:[\\/])/);
|
||||
if (driveNamespace) text = text.slice(driveNamespace[0].length);
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, worker));
|
||||
return results;
|
||||
}
|
||||
|
||||
function unavailableReason(result) {
|
||||
if (!result || result.exists === false) return 'missing';
|
||||
if (result.readable === false) return 'unreadable';
|
||||
const size = Number(result.size);
|
||||
if (!Number.isFinite(size) || size <= 0) return 'empty';
|
||||
return '';
|
||||
}
|
||||
|
||||
async function inspectReadableImportPath(filePath, openPath) {
|
||||
let fileHandle = null;
|
||||
try {
|
||||
fileHandle = await openPath(filePath, 'r');
|
||||
const fileStat = await fileHandle.stat();
|
||||
if (!fileStat.isFile()) return { exists: true, readable: false, size: fileStat.size };
|
||||
return { exists: true, readable: true, size: fileStat.size };
|
||||
} catch (error) {
|
||||
if (error && (error.code === 'ENOENT' || error.code === 'ENOTDIR')) return { exists: false };
|
||||
return { exists: true, readable: false };
|
||||
} finally {
|
||||
if (fileHandle) {
|
||||
try {
|
||||
await fileHandle.close();
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function inspectImportEntries(entries, options = {}) {
|
||||
const input = Array.isArray(entries) ? entries : [];
|
||||
const caseInsensitive = options.caseInsensitive ?? (typeof process === 'object' ? process.platform === 'win32' : true);
|
||||
const existing = new Set((Array.isArray(options.existingPaths) ? options.existingPaths : [])
|
||||
.map(value => createPathKey(value && typeof value === 'object' ? value.path : value, caseInsensitive))
|
||||
.filter(Boolean));
|
||||
const duplicates = [];
|
||||
const unavailable = [];
|
||||
const unique = [];
|
||||
|
||||
for (const value of input) {
|
||||
const entry = normalizeEntry(value);
|
||||
if (!entry.path) {
|
||||
unavailable.push({ ...entry, reason: 'missing' });
|
||||
continue;
|
||||
}
|
||||
const key = createPathKey(entry.path, caseInsensitive);
|
||||
if (existing.has(key)) {
|
||||
duplicates.push(entry);
|
||||
continue;
|
||||
}
|
||||
existing.add(key);
|
||||
unique.push(entry);
|
||||
return path.normalize(text);
|
||||
}
|
||||
|
||||
const filtered = applyFilenameFilter(unique, options.filenameFilter);
|
||||
const concurrency = Math.max(1, Math.min(32, Math.trunc(Number(options.concurrency)) || 8));
|
||||
const inspectPath = typeof options.inspectPath === 'function'
|
||||
? options.inspectPath
|
||||
: async (_entryPath, entry) => ({ exists: true, readable: true, size: entry.size });
|
||||
const inspected = await mapWithConcurrency(filtered.accepted, concurrency, async entry => {
|
||||
function normalizeEntry(value) {
|
||||
const source = value && typeof value === 'object' ? value : {};
|
||||
const filePath = normalizePathValue(typeof value === 'string' ? value : source.path);
|
||||
const sourceName = typeof value === 'string' ? '' : String(source.name ?? '').trim();
|
||||
return {
|
||||
path: filePath,
|
||||
name: sourceName || path.basename(filePath),
|
||||
size: Number.isFinite(Number(source.size)) ? Number(source.size) : null
|
||||
};
|
||||
}
|
||||
|
||||
function createPathKey(value, caseInsensitive) {
|
||||
const normalized = normalizePathValue(value);
|
||||
return caseInsensitive ? normalized.toLocaleLowerCase('en-US') : normalized;
|
||||
}
|
||||
|
||||
async function mapWithConcurrency(items, concurrency, operation) {
|
||||
const results = new Array(items.length);
|
||||
let cursor = 0;
|
||||
async function worker() {
|
||||
while (cursor < items.length) {
|
||||
const index = cursor++;
|
||||
results[index] = await operation(items[index], index);
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, worker));
|
||||
return results;
|
||||
}
|
||||
|
||||
function unavailableReason(result) {
|
||||
if (!result || result.exists === false) return 'missing';
|
||||
if (result.readable === false) return 'unreadable';
|
||||
const size = Number(result.size);
|
||||
if (!Number.isFinite(size) || size <= 0) return 'empty';
|
||||
return '';
|
||||
}
|
||||
|
||||
async function inspectReadableImportPath(filePath, openPath) {
|
||||
let fileHandle = null;
|
||||
try {
|
||||
const result = await inspectPath(entry.path, entry);
|
||||
const reason = unavailableReason(result);
|
||||
if (reason) return { entry: { ...entry, size: Number(result?.size) || 0 }, reason };
|
||||
return { entry: { ...entry, size: Number(result.size) }, reason: '' };
|
||||
fileHandle = await openPath(filePath, 'r');
|
||||
const fileStat = await fileHandle.stat();
|
||||
if (!fileStat.isFile()) return { exists: true, readable: false, size: fileStat.size };
|
||||
return { exists: true, readable: true, size: fileStat.size };
|
||||
} catch (error) {
|
||||
return { entry, reason: error && error.code === 'ENOENT' ? 'missing' : 'unreadable' };
|
||||
if (error && (error.code === 'ENOENT' || error.code === 'ENOTDIR')) return { exists: false };
|
||||
return { exists: true, readable: false };
|
||||
} finally {
|
||||
if (fileHandle) {
|
||||
try {
|
||||
await fileHandle.close();
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
});
|
||||
const accepted = [];
|
||||
for (const result of inspected) {
|
||||
if (result.reason) unavailable.push({ ...result.entry, reason: result.reason });
|
||||
else accepted.push(result.entry);
|
||||
}
|
||||
|
||||
async function inspectImportEntries(entries, options = {}) {
|
||||
const input = Array.isArray(entries) ? entries : [];
|
||||
const caseInsensitive = options.caseInsensitive ?? (typeof process === 'object' ? process.platform === 'win32' : true);
|
||||
const existing = new Set((Array.isArray(options.existingPaths) ? options.existingPaths : [])
|
||||
.map(value => createPathKey(value && typeof value === 'object' ? value.path : value, caseInsensitive))
|
||||
.filter(Boolean));
|
||||
const duplicates = [];
|
||||
const unavailable = [];
|
||||
const unique = [];
|
||||
|
||||
for (const value of input) {
|
||||
const entry = normalizeEntry(value);
|
||||
if (!entry.path) {
|
||||
unavailable.push({ ...entry, reason: 'missing' });
|
||||
continue;
|
||||
}
|
||||
const key = createPathKey(entry.path, caseInsensitive);
|
||||
if (existing.has(key)) {
|
||||
duplicates.push(entry);
|
||||
continue;
|
||||
}
|
||||
existing.add(key);
|
||||
unique.push(entry);
|
||||
}
|
||||
|
||||
const concurrency = Math.max(1, Math.min(32, Math.trunc(Number(options.concurrency)) || 8));
|
||||
const inspectPath = typeof options.inspectPath === 'function'
|
||||
? options.inspectPath
|
||||
: async (_entryPath, entry) => ({ exists: true, readable: true, size: entry.size });
|
||||
const inspected = await mapWithConcurrency(unique, concurrency, async entry => {
|
||||
try {
|
||||
const result = await inspectPath(entry.path, entry);
|
||||
const reason = unavailableReason(result);
|
||||
if (reason) return { entry: { ...entry, size: Number(result?.size) || 0 }, reason };
|
||||
return { entry: { ...entry, size: Number(result.size) }, reason: '' };
|
||||
} catch (error) {
|
||||
return { entry, reason: error && error.code === 'ENOENT' ? 'missing' : 'unreadable' };
|
||||
}
|
||||
});
|
||||
const accepted = [];
|
||||
for (const result of inspected) {
|
||||
if (result.reason) unavailable.push({ ...result.entry, reason: result.reason });
|
||||
else accepted.push(result.entry);
|
||||
}
|
||||
|
||||
return {
|
||||
candidateCount: input.length,
|
||||
duplicateCount: duplicates.length,
|
||||
unavailableCount: unavailable.length,
|
||||
acceptedCount: accepted.length,
|
||||
accepted,
|
||||
duplicates,
|
||||
unavailable
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSelectedHosters(values) {
|
||||
return Array.from(new Set((Array.isArray(values) ? values : [])
|
||||
.map(value => String(value ?? '').trim())
|
||||
.filter(Boolean)));
|
||||
}
|
||||
|
||||
function isImportPairEligible(file, hoster, hosterSettings = {}) {
|
||||
const maxSizeMb = Number(hosterSettings?.[hoster]?.maxSizeMb);
|
||||
return !(maxSizeMb > 0 && Number(file?.size) > maxSizeMb * 1024 * 1024);
|
||||
}
|
||||
|
||||
function getEligibleImportHosters(file, selectedHosters, hosterSettings = {}) {
|
||||
return normalizeSelectedHosters(selectedHosters)
|
||||
.filter(hoster => isImportPairEligible(file, hoster, hosterSettings));
|
||||
}
|
||||
|
||||
function summarizeImportPlan(input = {}) {
|
||||
const inspection = input.inspection && typeof input.inspection === 'object' ? input.inspection : {};
|
||||
const accepted = Array.isArray(inspection.accepted) ? inspection.accepted : [];
|
||||
const selectedHosters = normalizeSelectedHosters(input.selectedHosters);
|
||||
const settings = input.hosterSettings && typeof input.hosterSettings === 'object' ? input.hosterSettings : {};
|
||||
let jobCount = 0;
|
||||
for (const file of accepted) jobCount += getEligibleImportHosters(file, selectedHosters, settings).length;
|
||||
return {
|
||||
candidateCount: Number(inspection.candidateCount) || 0,
|
||||
duplicateCount: Number(inspection.duplicateCount) || 0,
|
||||
unavailableCount: Number(inspection.unavailableCount) || 0,
|
||||
acceptedCount: accepted.length,
|
||||
targetCount: selectedHosters.length,
|
||||
jobCount,
|
||||
sizeLimitedJobCount: accepted.length * selectedHosters.length - jobCount
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
candidateCount: input.length,
|
||||
duplicateCount: duplicates.length,
|
||||
filteredCount: filtered.excluded.length,
|
||||
unavailableCount: unavailable.length,
|
||||
acceptedCount: accepted.length,
|
||||
accepted,
|
||||
duplicates,
|
||||
filtered: filtered.excluded,
|
||||
unavailable
|
||||
getEligibleImportHosters,
|
||||
inspectImportEntries,
|
||||
inspectReadableImportPath,
|
||||
isImportPairEligible,
|
||||
summarizeImportPlan
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSelectedHosters(values) {
|
||||
return Array.from(new Set((Array.isArray(values) ? values : [])
|
||||
.map(value => String(value ?? '').trim())
|
||||
.filter(Boolean)));
|
||||
}
|
||||
|
||||
function isImportPairEligible(file, hoster, hosterSettings = {}) {
|
||||
const maxSizeMb = Number(hosterSettings?.[hoster]?.maxSizeMb);
|
||||
return !(maxSizeMb > 0 && Number(file?.size) > maxSizeMb * 1024 * 1024);
|
||||
}
|
||||
|
||||
function getEligibleImportHosters(file, selectedHosters, hosterSettings = {}) {
|
||||
return normalizeSelectedHosters(selectedHosters)
|
||||
.filter(hoster => isImportPairEligible(file, hoster, hosterSettings));
|
||||
}
|
||||
|
||||
function summarizeImportPlan(input = {}) {
|
||||
const inspection = input.inspection && typeof input.inspection === 'object' ? input.inspection : {};
|
||||
const accepted = Array.isArray(inspection.accepted) ? inspection.accepted : [];
|
||||
const selectedHosters = normalizeSelectedHosters(input.selectedHosters);
|
||||
const settings = input.hosterSettings && typeof input.hosterSettings === 'object' ? input.hosterSettings : {};
|
||||
let jobCount = 0;
|
||||
for (const file of accepted) {
|
||||
jobCount += getEligibleImportHosters(file, selectedHosters, settings).length;
|
||||
}
|
||||
const sizeLimitedJobCount = accepted.length * selectedHosters.length - jobCount;
|
||||
return {
|
||||
candidateCount: Number(inspection.candidateCount) || 0,
|
||||
duplicateCount: Number(inspection.duplicateCount) || 0,
|
||||
filteredCount: Number(inspection.filteredCount) || 0,
|
||||
unavailableCount: Number(inspection.unavailableCount) || 0,
|
||||
acceptedCount: accepted.length,
|
||||
targetCount: selectedHosters.length,
|
||||
jobCount,
|
||||
sizeLimitedJobCount
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
getEligibleImportHosters,
|
||||
inspectImportEntries,
|
||||
inspectReadableImportPath,
|
||||
isImportPairEligible,
|
||||
summarizeImportPlan
|
||||
};
|
||||
});
|
||||
|
||||
+1
-2
@@ -76,8 +76,7 @@
|
||||
const keyUnambiguous = k !== null && filesPerKey.get(k).size <= 1;
|
||||
const uploadedAfterSnapshot = savedAtFloor !== null && k !== null && keyUnambiguous
|
||||
&& logMaxTs.has(k) && logMaxTs.get(k) >= savedAtFloor;
|
||||
const hasTerminalResult = job && job.status === 'done' && job.result && typeof job.result === 'object';
|
||||
if (!hasTerminalResult && (doneInLog || uploadedAfterSnapshot)) {
|
||||
if (doneInLog || uploadedAfterSnapshot) {
|
||||
removed.push(job);
|
||||
} else {
|
||||
kept.push(job);
|
||||
|
||||
+2
-17
@@ -21,12 +21,8 @@ class RemoteServer {
|
||||
return new Promise((resolve, reject) => {
|
||||
this._config = opts;
|
||||
|
||||
const host = opts.host || '127.0.0.1';
|
||||
if (host !== '127.0.0.1' && host !== '::1') {
|
||||
reject(new Error('Remote server requires a loopback host'));
|
||||
return;
|
||||
}
|
||||
const wssOpts = { port: opts.port, host, maxPayload: 256 * 1024 };
|
||||
const wssOpts = { port: opts.port, maxPayload: 256 * 1024 };
|
||||
if (opts.host) wssOpts.host = opts.host;
|
||||
this._wss = new WebSocketServer(wssOpts, () => {
|
||||
resolve();
|
||||
});
|
||||
@@ -98,17 +94,6 @@ class RemoteServer {
|
||||
const client = this._clients.get(ws);
|
||||
if (!client) return;
|
||||
|
||||
if (!msg || typeof msg !== 'object' || Array.isArray(msg)) {
|
||||
if (!client.authenticated) {
|
||||
authReceived = true;
|
||||
clearTimeout(authTimeout);
|
||||
this._recordFailedAttempt(ip);
|
||||
ws.close(4002, 'Invalid token');
|
||||
this._clients.delete(ws);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!client.authenticated) {
|
||||
authReceived = true;
|
||||
clearTimeout(authTimeout);
|
||||
|
||||
+23
-125
@@ -2,7 +2,7 @@
|
||||
'use strict';
|
||||
|
||||
const pathApi = typeof require === 'function' ? require('path') : null;
|
||||
const metadataVersion = 2;
|
||||
const protectedStatuses = new Set(['done', 'error', 'aborted', 'skipped']);
|
||||
|
||||
function normalizeFile(file, platform) {
|
||||
const value = typeof file === 'string' ? file.trim() : '';
|
||||
@@ -59,39 +59,18 @@
|
||||
return uniqueHosters(values);
|
||||
}
|
||||
|
||||
function confirmedHosters(jobs, requiredHosters) {
|
||||
function completedHosters(jobs, requiredHosters) {
|
||||
const values = [];
|
||||
for (const job of jobs) {
|
||||
if (job.sourceCleanupMetadataVersion === metadataVersion && Array.isArray(job.sourceCleanupConfirmedHosters)) {
|
||||
values.push(...job.sourceCleanupConfirmedHosters);
|
||||
if (Array.isArray(job.sourceCleanupCompletedHosters)) {
|
||||
values.push(...job.sourceCleanupCompletedHosters);
|
||||
}
|
||||
}
|
||||
const confirmed = new Set(uniqueHosters(values));
|
||||
return requiredHosters.filter((hoster) => confirmed.has(hoster));
|
||||
}
|
||||
|
||||
function provisionalHosters(jobs, requiredHosters) {
|
||||
const values = [];
|
||||
for (const job of jobs) {
|
||||
if (job.sourceCleanupMetadataVersion === metadataVersion && Array.isArray(job.sourceCleanupProvisionalHosters)) {
|
||||
values.push(...job.sourceCleanupProvisionalHosters);
|
||||
}
|
||||
if (job.status === 'done') values.push(job.hoster);
|
||||
}
|
||||
const provisional = new Set(uniqueHosters(values));
|
||||
return requiredHosters.filter((hoster) => provisional.has(hoster));
|
||||
}
|
||||
|
||||
function startedHosters(jobs, requiredHosters) {
|
||||
const values = [];
|
||||
let legacyMetadata = false;
|
||||
for (const job of jobs) {
|
||||
if (job.sourceCleanupMetadataVersion !== metadataVersion) continue;
|
||||
if (!Array.isArray(job.sourceCleanupStartedHosters)) legacyMetadata = true;
|
||||
else values.push(...job.sourceCleanupStartedHosters);
|
||||
}
|
||||
if (legacyMetadata) return [...requiredHosters];
|
||||
const started = new Set(uniqueHosters(values));
|
||||
return requiredHosters.filter((hoster) => started.has(hoster));
|
||||
const completed = new Set(uniqueHosters(values));
|
||||
return requiredHosters.filter((hoster) => completed.has(hoster));
|
||||
}
|
||||
|
||||
function storedToken(jobs) {
|
||||
@@ -111,16 +90,12 @@
|
||||
return null;
|
||||
}
|
||||
|
||||
function assignMetadata(jobs, token, requiredHosters, confirmed, provisional, started, fingerprint, touchedJobs, touchedSet) {
|
||||
function assignMetadata(jobs, token, requiredHosters, completed, fingerprint, touchedJobs, touchedSet) {
|
||||
for (const job of jobs) {
|
||||
job.sourceCleanupMetadataVersion = metadataVersion;
|
||||
job.sourceCleanupToken = token;
|
||||
job.sourceCleanupRequiredHosters = [...requiredHosters];
|
||||
job.sourceCleanupConfirmedHosters = [...confirmed];
|
||||
job.sourceCleanupProvisionalHosters = [...provisional];
|
||||
job.sourceCleanupStartedHosters = [...started];
|
||||
job.sourceCleanupCompletedHosters = [...completed];
|
||||
job.sourceCleanupFingerprint = cloneFingerprint(fingerprint);
|
||||
delete job.sourceCleanupCompletedHosters;
|
||||
if (!touchedSet.has(job)) {
|
||||
touchedSet.add(job);
|
||||
touchedJobs.push(job);
|
||||
@@ -133,10 +108,7 @@
|
||||
const touchedJobs = [];
|
||||
const touchedSet = new Set();
|
||||
const preparedFiles = new Set();
|
||||
const revokedHosters = [];
|
||||
const revokedSet = new Set();
|
||||
if (!Array.isArray(queueJobs) || !Array.isArray(jobsToStart)) return { groups, touchedJobs, revokedHosters };
|
||||
const currentRoundJobs = new Set(jobsToStart);
|
||||
if (!Array.isArray(queueJobs) || !Array.isArray(jobsToStart)) return { groups, touchedJobs };
|
||||
|
||||
for (const selectedJob of jobsToStart) {
|
||||
const file = normalizeFile(selectedJob && selectedJob.file, platform);
|
||||
@@ -152,28 +124,14 @@
|
||||
...persistedRequired,
|
||||
...siblings.map((job) => job.hoster)
|
||||
]);
|
||||
const currentStartedHosters = new Set(uniqueHosters(
|
||||
siblings.filter((job) => currentRoundJobs.has(job)).map((job) => job.hoster)
|
||||
));
|
||||
const started = uniqueHosters([...startedHosters(siblings, requiredHosters), ...currentStartedHosters]);
|
||||
const storedConfirmed = confirmedHosters(siblings, requiredHosters);
|
||||
const confirmed = storedConfirmed.filter((hoster) => !currentStartedHosters.has(hoster));
|
||||
const provisional = provisionalHosters(siblings, requiredHosters)
|
||||
.filter((hoster) => !currentStartedHosters.has(hoster));
|
||||
for (const hoster of storedConfirmed) {
|
||||
if (!currentStartedHosters.has(hoster) || revokedSet.has(hoster)) continue;
|
||||
revokedSet.add(hoster);
|
||||
revokedHosters.push(hoster);
|
||||
}
|
||||
const completed = completedHosters(siblings, requiredHosters);
|
||||
const fingerprint = storedFingerprint(siblings);
|
||||
|
||||
assignMetadata(
|
||||
siblings,
|
||||
token,
|
||||
requiredHosters,
|
||||
confirmed,
|
||||
provisional,
|
||||
started,
|
||||
completed,
|
||||
fingerprint,
|
||||
touchedJobs,
|
||||
touchedSet
|
||||
@@ -183,107 +141,48 @@
|
||||
token,
|
||||
file: selectedJob.file,
|
||||
requiredHosters: [...requiredHosters],
|
||||
confirmedHosters: [...confirmed],
|
||||
completedHosters: [...completed],
|
||||
fingerprint: cloneFingerprint(fingerprint),
|
||||
jobs: siblings.map((job) => ({
|
||||
jobId: job.id,
|
||||
hoster: normalizeHoster(job.hoster),
|
||||
status: job.status,
|
||||
currentRound: currentRoundJobs.has(job)
|
||||
status: job.status
|
||||
}))
|
||||
});
|
||||
}
|
||||
|
||||
return { groups, touchedJobs, revokedHosters };
|
||||
return { groups, touchedJobs };
|
||||
}
|
||||
|
||||
function markCompleted(queueJobs, job, platform) {
|
||||
const siblings = relatedJobs(queueJobs, job, platform);
|
||||
if (siblings.length === 0) return [];
|
||||
const requiredHosters = storedRequiredHosters(siblings);
|
||||
const provisional = new Set(provisionalHosters(siblings, requiredHosters));
|
||||
const completed = new Set(completedHosters(siblings, requiredHosters));
|
||||
const hoster = normalizeHoster(job.hoster);
|
||||
if (requiredHosters.includes(hoster)) provisional.add(hoster);
|
||||
const orderedConfirmed = confirmedHosters(siblings, requiredHosters);
|
||||
const orderedProvisional = requiredHosters.filter((required) => provisional.has(required));
|
||||
if (requiredHosters.includes(hoster)) completed.add(hoster);
|
||||
const orderedCompleted = requiredHosters.filter((required) => completed.has(required));
|
||||
for (const sibling of siblings) {
|
||||
sibling.sourceCleanupMetadataVersion = metadataVersion;
|
||||
sibling.sourceCleanupConfirmedHosters = [...orderedConfirmed];
|
||||
sibling.sourceCleanupProvisionalHosters = [...orderedProvisional];
|
||||
delete sibling.sourceCleanupCompletedHosters;
|
||||
sibling.sourceCleanupCompletedHosters = [...orderedCompleted];
|
||||
}
|
||||
return siblings;
|
||||
}
|
||||
|
||||
async function persistRoundCompletions(queueJobs, options = {}) {
|
||||
if (!Array.isArray(queueJobs) || typeof options.persist !== 'function') return false;
|
||||
const historyPersisted = options.historyPersisted === true;
|
||||
const groupsByToken = new Map();
|
||||
for (const job of queueJobs) {
|
||||
if (!job || typeof job.sourceCleanupToken !== 'string' || !job.sourceCleanupToken) continue;
|
||||
if (!groupsByToken.has(job.sourceCleanupToken)) groupsByToken.set(job.sourceCleanupToken, []);
|
||||
groupsByToken.get(job.sourceCleanupToken).push(job);
|
||||
}
|
||||
const snapshots = [];
|
||||
for (const jobs of groupsByToken.values()) {
|
||||
const requiredHosters = storedRequiredHosters(jobs);
|
||||
const confirmed = confirmedHosters(jobs, requiredHosters);
|
||||
const provisional = provisionalHosters(jobs, requiredHosters);
|
||||
const promoted = historyPersisted
|
||||
? uniqueHosters([...confirmed, ...provisional])
|
||||
: confirmed;
|
||||
const orderedPromoted = requiredHosters.filter((hoster) => promoted.includes(hoster));
|
||||
for (const job of jobs) {
|
||||
snapshots.push({
|
||||
job,
|
||||
confirmedHosters: job.sourceCleanupMetadataVersion === metadataVersion
|
||||
? uniqueHosters(job.sourceCleanupConfirmedHosters)
|
||||
: []
|
||||
});
|
||||
job.sourceCleanupMetadataVersion = metadataVersion;
|
||||
job.sourceCleanupConfirmedHosters = [...orderedPromoted];
|
||||
job.sourceCleanupProvisionalHosters = [];
|
||||
delete job.sourceCleanupCompletedHosters;
|
||||
}
|
||||
}
|
||||
let persisted = false;
|
||||
try {
|
||||
persisted = (await options.persist()) === true;
|
||||
} catch {}
|
||||
if (!persisted) {
|
||||
for (const snapshot of snapshots) {
|
||||
snapshot.job.sourceCleanupMetadataVersion = metadataVersion;
|
||||
snapshot.job.sourceCleanupConfirmedHosters = [...snapshot.confirmedHosters];
|
||||
snapshot.job.sourceCleanupProvisionalHosters = [];
|
||||
delete snapshot.job.sourceCleanupCompletedHosters;
|
||||
}
|
||||
}
|
||||
return persisted;
|
||||
}
|
||||
|
||||
function removeRequirement(queueJobs, job, platform) {
|
||||
if (!job || job.status !== 'preview' || job.interrupted) return [];
|
||||
if (!job || protectedStatuses.has(job.status)) return [];
|
||||
const siblings = relatedJobs(queueJobs, job, platform);
|
||||
const removedHoster = normalizeHoster(job.hoster);
|
||||
if (startedHosters(siblings, storedRequiredHosters(siblings)).includes(removedHoster)) return [];
|
||||
for (const sibling of siblings) {
|
||||
const required = Array.isArray(sibling.sourceCleanupRequiredHosters)
|
||||
? sibling.sourceCleanupRequiredHosters
|
||||
: [];
|
||||
const confirmed = sibling.sourceCleanupMetadataVersion === metadataVersion && Array.isArray(sibling.sourceCleanupConfirmedHosters)
|
||||
? sibling.sourceCleanupConfirmedHosters
|
||||
const completed = Array.isArray(sibling.sourceCleanupCompletedHosters)
|
||||
? sibling.sourceCleanupCompletedHosters
|
||||
: [];
|
||||
const provisional = sibling.sourceCleanupMetadataVersion === metadataVersion && Array.isArray(sibling.sourceCleanupProvisionalHosters)
|
||||
? sibling.sourceCleanupProvisionalHosters
|
||||
: [];
|
||||
sibling.sourceCleanupMetadataVersion = metadataVersion;
|
||||
sibling.sourceCleanupRequiredHosters = uniqueHosters(required)
|
||||
.filter((hoster) => hoster !== removedHoster);
|
||||
sibling.sourceCleanupConfirmedHosters = uniqueHosters(confirmed)
|
||||
sibling.sourceCleanupCompletedHosters = uniqueHosters(completed)
|
||||
.filter((hoster) => hoster !== removedHoster);
|
||||
sibling.sourceCleanupProvisionalHosters = uniqueHosters(provisional)
|
||||
.filter((hoster) => hoster !== removedHoster);
|
||||
delete sibling.sourceCleanupCompletedHosters;
|
||||
}
|
||||
return siblings;
|
||||
}
|
||||
@@ -312,7 +211,6 @@
|
||||
const api = {
|
||||
prepareGroups,
|
||||
markCompleted,
|
||||
persistRoundCompletions,
|
||||
removeRequirement,
|
||||
applyFingerprints
|
||||
};
|
||||
|
||||
+23
-43
@@ -83,29 +83,30 @@ function createSourceFileCleanup(options) {
|
||||
|
||||
function createManifest(input, canonicalFile) {
|
||||
const requiredHosters = uniqueStrings(input.requiredHosters);
|
||||
const confirmedHosters = new Set(
|
||||
uniqueStrings(input.confirmedHosters).filter((hoster) => requiredHosters.includes(hoster))
|
||||
);
|
||||
const completedHosters = new Set(uniqueStrings(input.completedHosters));
|
||||
const jobs = new Map();
|
||||
|
||||
for (const job of Array.isArray(input.jobs) ? input.jobs : []) {
|
||||
if (!job || typeof job.jobId !== 'string' || typeof job.hoster !== 'string') continue;
|
||||
const currentRound = job.currentRound !== false;
|
||||
const status = currentRound ? 'pending' : normalizeStatus(job.status);
|
||||
const status = completedHosters.has(job.hoster) ? 'done' : normalizeStatus(job.status);
|
||||
jobs.set(job.jobId, Object.freeze({
|
||||
jobId: job.jobId,
|
||||
hoster: job.hoster,
|
||||
status,
|
||||
currentRound
|
||||
status
|
||||
}));
|
||||
}
|
||||
|
||||
for (const hoster of completedHosters) {
|
||||
if (requiredHosters.includes(hoster)) continue;
|
||||
completedHosters.delete(hoster);
|
||||
}
|
||||
|
||||
return {
|
||||
token: input.token || input.sourceCleanupToken,
|
||||
file: path.resolve(input.file),
|
||||
canonicalFile,
|
||||
requiredHosters: Object.freeze(requiredHosters),
|
||||
confirmedHosters,
|
||||
completedHosters,
|
||||
jobs,
|
||||
suppliedFingerprint: isFingerprint(input.fingerprint) ? cloneFingerprint(input.fingerprint) : null,
|
||||
fingerprint: null,
|
||||
@@ -151,32 +152,16 @@ function createSourceFileCleanup(options) {
|
||||
...existing.requiredHosters,
|
||||
...input.requiredHosters
|
||||
]));
|
||||
const incomingJobs = Array.isArray(input.jobs) ? input.jobs : [];
|
||||
const currentRoundHosters = new Set(
|
||||
incomingJobs
|
||||
.filter((job) => job && typeof job.hoster === 'string' && job.currentRound !== false)
|
||||
.map((job) => job.hoster)
|
||||
);
|
||||
for (const hoster of currentRoundHosters) existing.confirmedHosters.delete(hoster);
|
||||
for (const hoster of uniqueStrings(input.confirmedHosters)) {
|
||||
if (existing.requiredHosters.includes(hoster) && !currentRoundHosters.has(hoster)) {
|
||||
existing.confirmedHosters.add(hoster);
|
||||
}
|
||||
for (const hoster of uniqueStrings(input.completedHosters)) {
|
||||
if (existing.requiredHosters.includes(hoster)) existing.completedHosters.add(hoster);
|
||||
}
|
||||
for (const job of incomingJobs) {
|
||||
for (const job of Array.isArray(input.jobs) ? input.jobs : []) {
|
||||
if (!job || typeof job.jobId !== 'string' || typeof job.hoster !== 'string') continue;
|
||||
const previous = existing.jobs.get(job.jobId);
|
||||
const incomingCurrentRound = job.currentRound !== false;
|
||||
const currentRound = Boolean((previous && previous.currentRound) || incomingCurrentRound);
|
||||
const status = incomingCurrentRound
|
||||
? 'pending'
|
||||
const status = existing.completedHosters.has(job.hoster)
|
||||
? 'done'
|
||||
: normalizeStatus(previous ? previous.status : job.status);
|
||||
existing.jobs.set(job.jobId, Object.freeze({
|
||||
jobId: job.jobId,
|
||||
hoster: job.hoster,
|
||||
status,
|
||||
currentRound
|
||||
}));
|
||||
existing.jobs.set(job.jobId, Object.freeze({ jobId: job.jobId, hoster: job.hoster, status }));
|
||||
}
|
||||
fingerprints[token] = cloneFingerprint(existing.fingerprint);
|
||||
continue;
|
||||
@@ -209,9 +194,10 @@ function createSourceFileCleanup(options) {
|
||||
const manifest = groups.get(token);
|
||||
if (!manifest || manifest.finalizationPromise) return false;
|
||||
const job = manifest.jobs.get(event.jobId);
|
||||
if (!job || !job.currentRound || job.hoster !== event.hoster) return false;
|
||||
if (!job || job.hoster !== event.hoster) return false;
|
||||
if (typeof event.file === 'string' && canonicalize(event.file) !== manifest.canonicalFile) return false;
|
||||
manifest.jobs.set(event.jobId, Object.freeze({ ...job, status: event.status }));
|
||||
if (event.status === 'done') manifest.completedHosters.add(event.hoster);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -220,7 +206,7 @@ function createSourceFileCleanup(options) {
|
||||
for (const manifest of groups.values()) {
|
||||
if (manifest.finalizationPromise) continue;
|
||||
const job = manifest.jobs.get(jobId);
|
||||
if (!job || !job.currentRound) continue;
|
||||
if (!job) continue;
|
||||
manifest.jobs.set(jobId, Object.freeze({ ...job, status: 'skipped' }));
|
||||
changed = true;
|
||||
}
|
||||
@@ -230,17 +216,11 @@ function createSourceFileCleanup(options) {
|
||||
function blockingStatuses(manifest) {
|
||||
const blocking = [];
|
||||
for (const hoster of manifest.requiredHosters) {
|
||||
const jobs = [...manifest.jobs.values()].filter((job) => job.hoster === hoster);
|
||||
const currentJobs = jobs.filter((job) => job.currentRound);
|
||||
if (currentJobs.length > 0) {
|
||||
const currentBlocker = currentJobs.find((job) => job.status !== 'done');
|
||||
if (!currentBlocker) continue;
|
||||
const status = currentJobs.map((job) => job.status).find((value) => value !== 'pending') || 'pending';
|
||||
blocking.push({ hoster, status });
|
||||
continue;
|
||||
}
|
||||
if (manifest.confirmedHosters.has(hoster)) continue;
|
||||
const statuses = jobs.map((job) => job.status).filter((status) => status !== 'done');
|
||||
if (manifest.completedHosters.has(hoster)) continue;
|
||||
const statuses = [...manifest.jobs.values()]
|
||||
.filter((job) => job.hoster === hoster)
|
||||
.map((job) => job.status);
|
||||
if (statuses.includes('done')) continue;
|
||||
const status = statuses.find((value) => value !== 'pending') || 'pending';
|
||||
blocking.push({ hoster, status });
|
||||
}
|
||||
|
||||
+5
-528
@@ -1,10 +1,6 @@
|
||||
function configureStartupRenderer(app, env = process.env, platform = process.platform, argv = process.argv) {
|
||||
function configureStartupRenderer(app, env = process.env, platform = process.platform) {
|
||||
const sessionName = String(env && env.SESSIONNAME || '');
|
||||
if (platform === 'win32' && /^RDP-/i.test(sessionName)) app.disableHardwareAcceleration();
|
||||
if (Array.isArray(argv) && argv.includes('--dev')) {
|
||||
app.commandLine.appendSwitch('force-prefers-no-reduced-motion');
|
||||
app.commandLine.appendSwitch('user-data-dir', app.getPath('userData'));
|
||||
}
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -12,518 +8,10 @@ function resolveStartupLanguage(config) {
|
||||
return config && config.globalSettings && config.globalSettings.language === 'de' ? 'de' : 'en';
|
||||
}
|
||||
|
||||
function createStartupFailureDocument(language) {
|
||||
const german = language === 'de';
|
||||
const title = german ? 'Oberfläche konnte nicht geladen werden' : 'The interface could not load';
|
||||
const detail = german
|
||||
? 'Multi Hoster Uploader konnte die Oberfläche nach einem sicheren Wiederherstellungsversuch nicht laden.'
|
||||
: 'Multi Hoster Uploader could not load the interface after a safe recovery attempt.';
|
||||
const close = german ? 'Schließen' : 'Close';
|
||||
return `<!doctype html><html lang="${german ? 'de' : 'en'}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Multi Hoster Uploader</title><style>html,body{height:100%;margin:0;background:#1f1f1f;color:#f4f4f4;font:15px system-ui,sans-serif}body{display:grid;place-items:center}.card{width:min(520px,calc(100% - 48px));padding:28px;border:1px solid #444;border-radius:12px;background:#292929;box-shadow:0 18px 50px #0008}h1{margin:0 0 12px;font-size:22px}p{margin:0 0 22px;color:#c8c8c8;line-height:1.5}button{min-height:38px;padding:0 18px;border:1px solid #555;border-radius:7px;background:#363636;color:#fff;font-weight:650;cursor:pointer}button:hover{background:#414141}</style></head><body><main class="card"><h1>${title}</h1><p>${detail}</p><button type="button" onclick="window.close()">${close}</button></main></body></html>`;
|
||||
}
|
||||
|
||||
function createStartupNavigationLoader(window, target, options = {}) {
|
||||
let startupDocument = 0;
|
||||
return function loadStartupDocument() {
|
||||
startupDocument++;
|
||||
return window.loadFile(target, {
|
||||
...options,
|
||||
query: {
|
||||
...options.query,
|
||||
startupDocument: String(startupDocument)
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function createStartupRevealGate(window, { onBlock } = {}) {
|
||||
let blocked = true;
|
||||
let activationPending = false;
|
||||
|
||||
function hasWindow() {
|
||||
return !!window && (typeof window.isDestroyed !== 'function' || !window.isDestroyed());
|
||||
}
|
||||
|
||||
function showAuthorizedSurface(activate) {
|
||||
if (!hasWindow()) return false;
|
||||
if (activate && window.isMinimized()) window.restore();
|
||||
if (!window.isVisible()) window.show();
|
||||
if (activate) window.focus();
|
||||
activationPending = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
const block = () => {
|
||||
blocked = true;
|
||||
if (typeof onBlock === 'function') onBlock();
|
||||
return true;
|
||||
};
|
||||
|
||||
const request = () => {
|
||||
if (!hasWindow()) return false;
|
||||
activationPending = true;
|
||||
if (!blocked) showAuthorizedSurface(true);
|
||||
return true;
|
||||
};
|
||||
|
||||
const reveal = () => {
|
||||
blocked = false;
|
||||
return showAuthorizedSurface(activationPending);
|
||||
};
|
||||
|
||||
const navigate = (operation, ...args) => {
|
||||
block();
|
||||
return operation(...args);
|
||||
};
|
||||
|
||||
return { block, navigate, request, reveal };
|
||||
}
|
||||
|
||||
function createStartupExternalRevealBindings({
|
||||
getWindow,
|
||||
getRevealGate,
|
||||
sendDroppedFiles,
|
||||
maxPendingDropPayloads = 32
|
||||
}) {
|
||||
const pendingDropPayloads = [];
|
||||
const pendingDropLimit = Number.isSafeInteger(maxPendingDropPayloads) && maxPendingDropPayloads > 0
|
||||
? maxPendingDropPayloads
|
||||
: 32;
|
||||
let pendingWindow = null;
|
||||
let readyWindow = null;
|
||||
|
||||
function clearPendingDropPayloads(window) {
|
||||
if (window && pendingWindow !== window) return false;
|
||||
pendingDropPayloads.length = 0;
|
||||
pendingWindow = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
function clearWindowState(window) {
|
||||
clearPendingDropPayloads(window);
|
||||
if (!window || readyWindow === window) readyWindow = null;
|
||||
}
|
||||
|
||||
function getActiveWindow() {
|
||||
const window = getWindow();
|
||||
if (!window || (typeof window.isDestroyed === 'function' && window.isDestroyed())) {
|
||||
clearWindowState();
|
||||
return null;
|
||||
}
|
||||
if (pendingWindow && pendingWindow !== window) clearPendingDropPayloads();
|
||||
if (readyWindow && readyWindow !== window) readyWindow = null;
|
||||
return window;
|
||||
}
|
||||
|
||||
function requestReveal() {
|
||||
const activeWindow = getActiveWindow();
|
||||
if (!activeWindow) return false;
|
||||
const revealGate = getRevealGate();
|
||||
if (!revealGate || typeof revealGate.request !== 'function') return false;
|
||||
revealGate.request();
|
||||
return true;
|
||||
}
|
||||
|
||||
function queueDropPayload(window, paths) {
|
||||
if (pendingWindow && pendingWindow !== window) clearPendingDropPayloads();
|
||||
pendingWindow = window;
|
||||
if (pendingDropPayloads.length >= pendingDropLimit) pendingDropPayloads.shift();
|
||||
pendingDropPayloads.push(paths);
|
||||
}
|
||||
|
||||
function handleDropTargetFiles(_event, paths) {
|
||||
const window = getActiveWindow();
|
||||
if (!window) return false;
|
||||
if (!window.isVisible() || window.isMinimized()) requestReveal();
|
||||
if (readyWindow === window) sendDroppedFiles(paths);
|
||||
else queueDropPayload(window, paths);
|
||||
return true;
|
||||
}
|
||||
|
||||
function rendererBlocked(window) {
|
||||
const activeWindow = getActiveWindow();
|
||||
if (!activeWindow || activeWindow !== window) {
|
||||
clearWindowState(window);
|
||||
return false;
|
||||
}
|
||||
readyWindow = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
function rendererReady(window) {
|
||||
const activeWindow = getActiveWindow();
|
||||
if (!activeWindow || activeWindow !== window) {
|
||||
clearWindowState(window);
|
||||
return false;
|
||||
}
|
||||
readyWindow = window;
|
||||
if (pendingWindow !== window) return true;
|
||||
const payloads = pendingDropPayloads.splice(0);
|
||||
pendingWindow = null;
|
||||
for (const paths of payloads) sendDroppedFiles(paths);
|
||||
return true;
|
||||
}
|
||||
|
||||
function windowClosed(window) {
|
||||
clearWindowState(window);
|
||||
}
|
||||
|
||||
return {
|
||||
bindSecondInstance(app) {
|
||||
app.on('second-instance', requestReveal);
|
||||
},
|
||||
bindTrayClick(tray) {
|
||||
tray.on('click', requestReveal);
|
||||
},
|
||||
createTrayMenuItem(label) {
|
||||
return { label, click: requestReveal };
|
||||
},
|
||||
bindDropTargetFiles(ipcMain) {
|
||||
ipcMain.on('drop-target:files', handleDropTargetFiles);
|
||||
},
|
||||
rendererBlocked,
|
||||
rendererReady,
|
||||
windowClosed
|
||||
};
|
||||
}
|
||||
|
||||
function createStartupCloseHandler({ window, shouldPrepareClose, requestClosePreparation }) {
|
||||
return function handleStartupClose(event) {
|
||||
if (!shouldPrepareClose() || window.webContents.isDestroyed()) return false;
|
||||
event.preventDefault();
|
||||
requestClosePreparation();
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
function createStartupRecoveryCoordinator({
|
||||
load,
|
||||
reload,
|
||||
reveal,
|
||||
showFailure,
|
||||
close,
|
||||
readyTimeoutMs = 15000,
|
||||
scheduleReadyDeadline = setTimeout,
|
||||
cancelReadyDeadline = clearTimeout
|
||||
}) {
|
||||
let initialLoad;
|
||||
let initialLoadPending = false;
|
||||
let navigation;
|
||||
let recoveryNavigations = 0;
|
||||
let terminalFailure;
|
||||
let recovery;
|
||||
let queuedRecovery;
|
||||
let readyDeadline;
|
||||
let rendererGeneration = 0;
|
||||
let awaitingGeneration = null;
|
||||
let stopped = false;
|
||||
|
||||
function clearRendererDeadline() {
|
||||
if (readyDeadline !== undefined) cancelReadyDeadline(readyDeadline);
|
||||
readyDeadline = undefined;
|
||||
}
|
||||
|
||||
function clearRendererDocument() {
|
||||
awaitingGeneration = null;
|
||||
clearRendererDeadline();
|
||||
}
|
||||
|
||||
function acceptRendererDocument(generation) {
|
||||
if (!Number.isInteger(generation) || awaitingGeneration !== generation) return false;
|
||||
clearRendererDocument();
|
||||
return true;
|
||||
}
|
||||
|
||||
async function runNavigation(operation, args = []) {
|
||||
if (navigation) return navigation;
|
||||
const currentNavigation = Promise.resolve().then(() => operation(...args));
|
||||
navigation = currentNavigation;
|
||||
try {
|
||||
return await currentNavigation;
|
||||
} finally {
|
||||
if (navigation === currentNavigation) navigation = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function endWithFailure(failure) {
|
||||
if (stopped) return Promise.resolve(false);
|
||||
if (!terminalFailure) {
|
||||
clearRendererDocument();
|
||||
terminalFailure = Promise.resolve().then(async () => {
|
||||
if (typeof showFailure !== 'function') {
|
||||
await close(failure);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await showFailure(failure);
|
||||
reveal();
|
||||
} catch (surfaceError) {
|
||||
await close({ ...failure, surfaceError });
|
||||
}
|
||||
});
|
||||
}
|
||||
return terminalFailure;
|
||||
}
|
||||
|
||||
function trackRecovery(currentRecovery) {
|
||||
recovery = currentRecovery;
|
||||
currentRecovery.then(
|
||||
() => {
|
||||
if (recovery === currentRecovery) recovery = undefined;
|
||||
},
|
||||
() => {
|
||||
if (recovery === currentRecovery) recovery = undefined;
|
||||
}
|
||||
);
|
||||
return currentRecovery;
|
||||
}
|
||||
|
||||
async function performRecovery(phase, details) {
|
||||
if (stopped) return false;
|
||||
if (terminalFailure) return terminalFailure;
|
||||
if (recoveryNavigations >= 1) {
|
||||
return endWithFailure({ phase, attempt: recoveryNavigations + 1, details });
|
||||
}
|
||||
recoveryNavigations++;
|
||||
try {
|
||||
await runNavigation(reload);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (stopped) return false;
|
||||
return endWithFailure({ phase: 'renderer-reload', attempt: recoveryNavigations, details, error });
|
||||
}
|
||||
}
|
||||
|
||||
function recoverRenderer(phase, details) {
|
||||
if (stopped) return Promise.resolve(false);
|
||||
if (terminalFailure) return terminalFailure;
|
||||
clearRendererDocument();
|
||||
if (initialLoadPending) {
|
||||
queuedRecovery = { phase, details, recoveryNavigations };
|
||||
if (recovery) return recovery;
|
||||
const currentRecovery = Promise.resolve(initialLoad).then(() => {
|
||||
const pendingRecovery = queuedRecovery;
|
||||
queuedRecovery = undefined;
|
||||
if (stopped) return false;
|
||||
if (terminalFailure) return terminalFailure;
|
||||
if (!pendingRecovery || recoveryNavigations > pendingRecovery.recoveryNavigations) return true;
|
||||
return performRecovery(pendingRecovery.phase, pendingRecovery.details);
|
||||
});
|
||||
return trackRecovery(currentRecovery);
|
||||
}
|
||||
if (recovery) return recovery;
|
||||
return trackRecovery(performRecovery(phase, details));
|
||||
}
|
||||
|
||||
return {
|
||||
loadInitial(...args) {
|
||||
if (stopped) return Promise.resolve(false);
|
||||
if (terminalFailure) return terminalFailure;
|
||||
if (!initialLoad) {
|
||||
initialLoadPending = true;
|
||||
const currentInitialLoad = (async () => {
|
||||
for (let attempt = 1; attempt <= 2; attempt++) {
|
||||
if (stopped) return false;
|
||||
if (attempt === 2) recoveryNavigations = Math.max(recoveryNavigations, 1);
|
||||
try {
|
||||
return await runNavigation(load, args);
|
||||
} catch (error) {
|
||||
if (stopped) return false;
|
||||
if (attempt === 2) {
|
||||
await endWithFailure({ phase: 'initial-load', attempt, error });
|
||||
}
|
||||
}
|
||||
}
|
||||
})();
|
||||
initialLoad = currentInitialLoad;
|
||||
currentInitialLoad.then(
|
||||
() => {
|
||||
initialLoadPending = false;
|
||||
},
|
||||
() => {
|
||||
initialLoadPending = false;
|
||||
}
|
||||
);
|
||||
}
|
||||
return initialLoad;
|
||||
},
|
||||
rendererCrashed(details) {
|
||||
return recoverRenderer('renderer-crash', details);
|
||||
},
|
||||
rendererInitializationFailed(details) {
|
||||
return recoverRenderer('renderer-initialization', details);
|
||||
},
|
||||
rendererLoadStarted() {
|
||||
if (stopped || terminalFailure) return false;
|
||||
clearRendererDocument();
|
||||
rendererGeneration++;
|
||||
awaitingGeneration = rendererGeneration;
|
||||
return rendererGeneration;
|
||||
},
|
||||
rendererLoaded(generation) {
|
||||
if (stopped || terminalFailure || awaitingGeneration !== generation) return false;
|
||||
clearRendererDeadline();
|
||||
readyDeadline = scheduleReadyDeadline(() => {
|
||||
if (stopped || terminalFailure || awaitingGeneration !== generation) return false;
|
||||
readyDeadline = undefined;
|
||||
return recoverRenderer('renderer-ready-timeout', { timeoutMs: readyTimeoutMs });
|
||||
}, readyTimeoutMs);
|
||||
if (readyDeadline && typeof readyDeadline.unref === 'function') readyDeadline.unref();
|
||||
return true;
|
||||
},
|
||||
rendererReady(generation) {
|
||||
if (stopped || terminalFailure || !acceptRendererDocument(generation)) return false;
|
||||
recoveryNavigations = 0;
|
||||
reveal();
|
||||
return true;
|
||||
},
|
||||
dispose() {
|
||||
if (stopped) return false;
|
||||
stopped = true;
|
||||
queuedRecovery = undefined;
|
||||
clearRendererDocument();
|
||||
return true;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function createStartupRendererHandlers({
|
||||
window,
|
||||
ipcMain,
|
||||
coordinator,
|
||||
onDocumentLoadStarted,
|
||||
onRendererCrashed,
|
||||
onReady,
|
||||
onInitializationFailed
|
||||
}) {
|
||||
let disposed = false;
|
||||
let generation = null;
|
||||
let activeFrame = null;
|
||||
let pendingDocumentUrl = null;
|
||||
let pendingFrameAddress = null;
|
||||
const webContents = window && window.webContents;
|
||||
|
||||
function frameAddress(frame) {
|
||||
if (!frame || frame.detached) return null;
|
||||
if (typeof frame.isDestroyed === 'function' && frame.isDestroyed()) return null;
|
||||
if (!Number.isInteger(frame.processId) || typeof frame.frameToken !== 'string' || !frame.frameToken) return null;
|
||||
return `${frame.processId}:${frame.frameToken}`;
|
||||
}
|
||||
|
||||
function frameIdentity(frame) {
|
||||
const address = frameAddress(frame);
|
||||
if (!address) return null;
|
||||
if (typeof frame.url !== 'string' || !frame.url) return null;
|
||||
return `${address}:${frame.url}`;
|
||||
}
|
||||
|
||||
function startDocument(url, frame) {
|
||||
if (disposed) return false;
|
||||
generation = coordinator.rendererLoadStarted();
|
||||
activeFrame = null;
|
||||
pendingDocumentUrl = typeof url === 'string' && url ? url : null;
|
||||
pendingFrameAddress = frameAddress(frame);
|
||||
if (generation !== false && typeof onDocumentLoadStarted === 'function') onDocumentLoadStarted();
|
||||
return generation;
|
||||
}
|
||||
|
||||
function finishDocument() {
|
||||
if (disposed || generation === null) return false;
|
||||
activeFrame = frameIdentity(webContents && webContents.mainFrame);
|
||||
return coordinator.rendererLoaded(generation);
|
||||
}
|
||||
|
||||
function resolveEventFrame(event) {
|
||||
if (disposed || !window || window.isDestroyed() || !event || event.sender !== webContents) return null;
|
||||
if (!frameIdentity(event.senderFrame)) return null;
|
||||
return event.senderFrame;
|
||||
}
|
||||
|
||||
function resolveReadyGeneration(event) {
|
||||
const senderFrame = resolveEventFrame(event);
|
||||
if (!senderFrame || !Number.isInteger(generation) || !activeFrame || frameIdentity(senderFrame) !== activeFrame) return null;
|
||||
return generation;
|
||||
}
|
||||
|
||||
function resolveInitializationGeneration(event) {
|
||||
const senderFrame = resolveEventFrame(event);
|
||||
if (!senderFrame || !Number.isInteger(generation)) return null;
|
||||
if (activeFrame) return frameIdentity(senderFrame) === activeFrame ? generation : null;
|
||||
if (!pendingDocumentUrl || !pendingFrameAddress) return null;
|
||||
if (senderFrame.url !== pendingDocumentUrl || frameAddress(senderFrame) !== pendingFrameAddress) return null;
|
||||
return generation;
|
||||
}
|
||||
|
||||
function handleNavigation(details, _url, isInPlace, isMainFrame) {
|
||||
const sameDocument = details && typeof details.isSameDocument === 'boolean' ? details.isSameDocument : isInPlace;
|
||||
const mainFrame = details && typeof details.isMainFrame === 'boolean' ? details.isMainFrame : isMainFrame;
|
||||
if (sameDocument || mainFrame === false) return false;
|
||||
const url = details && typeof details.url === 'string' ? details.url : _url;
|
||||
const frame = details && details.frame;
|
||||
return startDocument(url, frame);
|
||||
}
|
||||
|
||||
function handleRendererCrash(_event, details) {
|
||||
if (disposed) return false;
|
||||
if (typeof onRendererCrashed === 'function') onRendererCrashed(details);
|
||||
return coordinator.rendererCrashed(details);
|
||||
}
|
||||
|
||||
function handleRendererInitializationFailed(event, details) {
|
||||
if (resolveInitializationGeneration(event) === null) return false;
|
||||
if (typeof onInitializationFailed === 'function') onInitializationFailed(details);
|
||||
return coordinator.rendererInitializationFailed(details);
|
||||
}
|
||||
|
||||
function handleRendererReady(event) {
|
||||
const eventGeneration = resolveReadyGeneration(event);
|
||||
if (eventGeneration === null) return false;
|
||||
const ready = coordinator.rendererReady(eventGeneration);
|
||||
if (ready && typeof onReady === 'function') onReady();
|
||||
return ready;
|
||||
}
|
||||
|
||||
if (webContents && typeof webContents.on === 'function') {
|
||||
webContents.on('did-start-navigation', handleNavigation);
|
||||
webContents.on('did-finish-load', finishDocument);
|
||||
webContents.on('render-process-gone', handleRendererCrash);
|
||||
}
|
||||
if (ipcMain && typeof ipcMain.on === 'function') {
|
||||
ipcMain.on('app:close-handshake-ready', handleRendererReady);
|
||||
ipcMain.on('app:renderer-initialization-failed', handleRendererInitializationFailed);
|
||||
}
|
||||
|
||||
return {
|
||||
documentLoadStarted: startDocument,
|
||||
documentLoaded: finishDocument,
|
||||
rendererCrashed(details) {
|
||||
if (disposed) return false;
|
||||
return coordinator.rendererCrashed(details);
|
||||
},
|
||||
rendererInitializationFailed: handleRendererInitializationFailed,
|
||||
rendererReady: handleRendererReady,
|
||||
dispose() {
|
||||
if (disposed) return false;
|
||||
disposed = true;
|
||||
if (webContents && typeof webContents.removeListener === 'function') {
|
||||
webContents.removeListener('did-start-navigation', handleNavigation);
|
||||
webContents.removeListener('did-finish-load', finishDocument);
|
||||
webContents.removeListener('render-process-gone', handleRendererCrash);
|
||||
}
|
||||
if (ipcMain && typeof ipcMain.removeListener === 'function') {
|
||||
ipcMain.removeListener('app:close-handshake-ready', handleRendererReady);
|
||||
ipcMain.removeListener('app:renderer-initialization-failed', handleRendererInitializationFailed);
|
||||
}
|
||||
return coordinator.dispose();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function createStartupWindow(BrowserWindow, options) {
|
||||
const window = new BrowserWindow({
|
||||
...options,
|
||||
show: false,
|
||||
disableAutoHideCursor: true
|
||||
const window = new BrowserWindow({ ...options, show: false });
|
||||
window.once('ready-to-show', () => {
|
||||
window.show();
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -534,15 +22,4 @@ function createStartupWindow(BrowserWindow, options) {
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
configureStartupRenderer,
|
||||
createStartupCloseHandler,
|
||||
createStartupExternalRevealBindings,
|
||||
createStartupFailureDocument,
|
||||
createStartupNavigationLoader,
|
||||
createStartupRecoveryCoordinator,
|
||||
createStartupRevealGate,
|
||||
createStartupRendererHandlers,
|
||||
createStartupWindow,
|
||||
resolveStartupLanguage
|
||||
};
|
||||
module.exports = { configureStartupRenderer, createStartupWindow, resolveStartupLanguage };
|
||||
|
||||
+1
-117
@@ -39,116 +39,6 @@
|
||||
return out;
|
||||
}
|
||||
|
||||
function summarizeHosterHealth(history, options = {}) {
|
||||
const out = {};
|
||||
const hosters = options.hosters && typeof options.hosters === 'object' ? options.hosters : {};
|
||||
const accountStatuses = options.accountStatuses && typeof options.accountStatuses === 'object' ? options.accountStatuses : {};
|
||||
const failedKeys = new Set(options.sessionFailedKeys instanceof Set
|
||||
? options.sessionFailedKeys
|
||||
: (Array.isArray(options.sessionFailedKeys) ? options.sessionFailedKeys : []));
|
||||
const nowCandidate = options.now instanceof Date
|
||||
? options.now.getTime()
|
||||
: (Number.isFinite(options.now) ? Number(options.now) : Date.parse(options.now));
|
||||
const nowMs = Number.isFinite(nowCandidate) ? nowCandidate : Date.now();
|
||||
const recentCutoff = nowMs - 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
const ensure = (name) => out[name] || (out[name] = {
|
||||
sampleSize: 0,
|
||||
successful: 0,
|
||||
failed: 0,
|
||||
skipped: 0,
|
||||
successRate: null,
|
||||
effectiveBytes: 0,
|
||||
effectiveDurationSec: 0,
|
||||
effectiveBytesPerSecond: null,
|
||||
lastSuccessAt: null,
|
||||
failuresLast7Days: 0,
|
||||
configuredAccounts: 0,
|
||||
accountProblems: 0,
|
||||
uncheckedAccounts: 0,
|
||||
checkingAccounts: 0
|
||||
});
|
||||
|
||||
const hasCredentials = (account) => {
|
||||
if (!account || typeof account !== 'object' || !account.id) return false;
|
||||
if (account.authType === 'api') return Boolean(String(account.apiKey || '').trim());
|
||||
if (account.authType === 'login') return Boolean(String(account.username || '').trim() && String(account.password || '').trim());
|
||||
return Boolean(String(account.apiKey || '').trim() || (String(account.username || '').trim() && String(account.password || '').trim()));
|
||||
};
|
||||
|
||||
for (const [name, accountsValue] of Object.entries(hosters)) {
|
||||
const bucket = ensure(name);
|
||||
const accounts = Array.isArray(accountsValue) ? accountsValue : [];
|
||||
bucket.configuredAccounts = accounts.length;
|
||||
for (const account of accounts) {
|
||||
if (account?.enabled === false) continue;
|
||||
const status = accountStatuses[account?.id]?.status || 'unchecked';
|
||||
const unavailable = !hasCredentials(account);
|
||||
const problem = unavailable || failedKeys.has(`${name}:${account?.id || ''}`) || ['error', 'warn', 'otp_required'].includes(status);
|
||||
if (problem) bucket.accountProblems++;
|
||||
if (!unavailable && status === 'unchecked') bucket.uncheckedAccounts++;
|
||||
if (!unavailable && status === 'checking') bucket.checkingAccounts++;
|
||||
}
|
||||
}
|
||||
|
||||
const validBatches = (Array.isArray(history) ? history : [])
|
||||
.map((batch, index) => {
|
||||
const timestampMs = batch?.timestamp ? Date.parse(batch.timestamp) : NaN;
|
||||
return { batch, index, timestampMs };
|
||||
})
|
||||
.filter(({ timestampMs }) => Number.isFinite(timestampMs) && timestampMs <= nowMs);
|
||||
const batches = [...validBatches]
|
||||
.sort((a, b) => b.timestampMs - a.timestampMs || b.index - a.index)
|
||||
.slice(0, 50);
|
||||
|
||||
for (const { batch, timestampMs } of batches) {
|
||||
if (!batch || !Array.isArray(batch.files)) continue;
|
||||
for (const file of batch.files) {
|
||||
if (!file || !Array.isArray(file.results)) continue;
|
||||
const fileSize = Number(file.size);
|
||||
for (const result of file.results) {
|
||||
if (!result?.hoster) continue;
|
||||
const bucket = ensure(result.hoster);
|
||||
bucket.sampleSize++;
|
||||
if (result.status === 'done') {
|
||||
bucket.successful++;
|
||||
const previous = bucket.lastSuccessAt ? Date.parse(bucket.lastSuccessAt) : -Infinity;
|
||||
if (timestampMs > previous) bucket.lastSuccessAt = new Date(timestampMs).toISOString();
|
||||
const durationSec = Number(result.durationSec);
|
||||
if (Number.isFinite(fileSize) && fileSize > 0 && Number.isFinite(durationSec) && durationSec > 0) {
|
||||
bucket.effectiveBytes += fileSize;
|
||||
bucket.effectiveDurationSec += durationSec;
|
||||
}
|
||||
} else if (result.status === 'skipped') {
|
||||
bucket.skipped++;
|
||||
} else {
|
||||
bucket.failed++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const { batch, timestampMs } of validBatches) {
|
||||
if (timestampMs < recentCutoff || !Array.isArray(batch?.files)) continue;
|
||||
for (const file of batch.files) {
|
||||
if (!Array.isArray(file?.results)) continue;
|
||||
for (const result of file.results) {
|
||||
if (!result?.hoster || result.status === 'done' || result.status === 'skipped') continue;
|
||||
ensure(result.hoster).failuresLast7Days++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const bucket of Object.values(out)) {
|
||||
const attempted = bucket.successful + bucket.failed;
|
||||
bucket.successRate = attempted > 0 ? bucket.successful / attempted : null;
|
||||
bucket.effectiveBytesPerSecond = bucket.effectiveDurationSec > 0
|
||||
? bucket.effectiveBytes / bucket.effectiveDurationSec
|
||||
: null;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function classifyErrorCategory(err) {
|
||||
if (!err || typeof err !== 'string') return 'unknown';
|
||||
const s = err.toLowerCase();
|
||||
@@ -196,10 +86,8 @@
|
||||
};
|
||||
const existingJobIds = new Set();
|
||||
const filesByName = new Map();
|
||||
const filesByKey = new Map();
|
||||
for (const file of merged.files) {
|
||||
filesByName.set(String(file.name || file.fileName || ''), file);
|
||||
if (file.fileKey) filesByKey.set(String(file.fileKey), file);
|
||||
for (const result of file.results) {
|
||||
if (result?.jobId) existingJobIds.add(result.jobId);
|
||||
}
|
||||
@@ -208,14 +96,11 @@
|
||||
for (const skipped of Array.isArray(skippedJobs) ? skippedJobs : []) {
|
||||
if (!skipped || (skipped.jobId && existingJobIds.has(skipped.jobId))) continue;
|
||||
const fileName = String(skipped.fileName || skipped.file || '').split(/[\\/]/).pop() || '';
|
||||
const fileKey = String(skipped.fileKey || '');
|
||||
let file = fileKey ? filesByKey.get(fileKey) : filesByName.get(fileName);
|
||||
let file = filesByName.get(fileName);
|
||||
if (!file) {
|
||||
file = { name: fileName, size: Number(skipped.size) || 0, results: [] };
|
||||
if (fileKey) file.fileKey = fileKey;
|
||||
merged.files.push(file);
|
||||
filesByName.set(fileName, file);
|
||||
if (fileKey) filesByKey.set(fileKey, file);
|
||||
}
|
||||
file.results.push({
|
||||
jobId: skipped.jobId || null,
|
||||
@@ -285,7 +170,6 @@
|
||||
|
||||
const api = {
|
||||
summarizePerHoster,
|
||||
summarizeHosterHealth,
|
||||
classifyErrorCategory,
|
||||
summarizeBatchErrors,
|
||||
mergeSkippedIntoSummary,
|
||||
|
||||
+28
-128
@@ -26,147 +26,47 @@ function collectSecretValues(config) {
|
||||
if (typeof o !== 'object') return;
|
||||
for (const k of Object.keys(o)) {
|
||||
const v = o[k];
|
||||
if (CRED_KEYS.has(k) && typeof v === 'string' && v.length > 0) out.add(v);
|
||||
if (CRED_KEYS.has(k) && typeof v === 'string' && v.length >= 6) out.add(v);
|
||||
else walk(v);
|
||||
}
|
||||
})(config);
|
||||
return Array.from(out);
|
||||
}
|
||||
|
||||
function redactConfiguredSecrets(text, secrets) {
|
||||
if (!Array.isArray(secrets)) return text;
|
||||
const values = Array.from(new Set(secrets
|
||||
.filter(value => typeof value === 'string' && value.length > 0)
|
||||
.flatMap(value => {
|
||||
const variants = [value];
|
||||
for (let index = 0; index < 3; index++) {
|
||||
const escaped = JSON.stringify(variants[variants.length - 1]).slice(1, -1);
|
||||
if (escaped === variants[variants.length - 1]) break;
|
||||
variants.push(escaped);
|
||||
}
|
||||
return variants;
|
||||
})))
|
||||
.sort((a, b) => b.length - a.length);
|
||||
let out = text;
|
||||
for (const value of values) {
|
||||
let offset = 0;
|
||||
while (offset < out.length) {
|
||||
const index = out.indexOf(value, offset);
|
||||
if (index < 0) break;
|
||||
const before = index > 0 ? out[index - 1] : '';
|
||||
const after = index + value.length < out.length ? out[index + value.length] : '';
|
||||
const continuation = character => /[A-Za-z0-9_.]/.test(character);
|
||||
if (!continuation(before) && !continuation(after)) {
|
||||
out = `${out.slice(0, index)}${REDACTED}${out.slice(index + value.length)}`;
|
||||
offset = index + REDACTED.length;
|
||||
} else {
|
||||
offset = index + value.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function redactHtmlCredentialFields(text) {
|
||||
let out = '';
|
||||
let offset = 0;
|
||||
const lower = text.toLowerCase();
|
||||
while (offset < text.length) {
|
||||
const start = lower.indexOf('<input', offset);
|
||||
if (start < 0) {
|
||||
out += text.slice(offset);
|
||||
break;
|
||||
}
|
||||
out += text.slice(offset, start);
|
||||
let quote = '';
|
||||
let end = start + 6;
|
||||
for (; end < text.length; end++) {
|
||||
const character = text[end];
|
||||
if (quote) {
|
||||
if (character === quote) quote = '';
|
||||
} else if (character === '"' || character === "'") {
|
||||
quote = character;
|
||||
} else if (character === '>') {
|
||||
end++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const input = text.slice(start, end);
|
||||
const sensitive = /\btype\s*=\s*["']?password\b/i.test(input)
|
||||
|| /\b(?:name|id)\s*=\s*["']?(?:password|passwd|api[_-]?(?:key|token)|token|secret|authorization|cookie|session(?:[_-]?id)?)\b/i.test(input);
|
||||
out += sensitive
|
||||
? input
|
||||
.replace(/(\bvalue\s*=\s*)(["'])([\s\S]*?)\2/gi, `$1$2${REDACTED}$2`)
|
||||
.replace(/(\bvalue\s*=\s*)(?!["'])([^\s>]+)/gi, `$1${REDACTED}`)
|
||||
: input;
|
||||
offset = end;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function redactAbsolutePaths(text) {
|
||||
const isDriveStart = (value, index) => /[A-Za-z]/.test(value[index] || '')
|
||||
&& !/[A-Za-z0-9]/.test(value[index - 1] || '')
|
||||
&& value[index + 1] === ':'
|
||||
&& /[\\/]/.test(value[index + 2] || '');
|
||||
const isBackslashUncStart = (value, index) => {
|
||||
if (value[index] !== '\\' || value[index + 1] !== '\\' || value[index - 1] === '\\') return false;
|
||||
let cursor = index + 2;
|
||||
while (value[cursor] === '\\') cursor++;
|
||||
if (value[cursor] === '?') return true;
|
||||
const separator = value.indexOf('\\', cursor);
|
||||
return separator > cursor;
|
||||
};
|
||||
const isSlashUncStart = (value, index) => value[index] === '/'
|
||||
&& value[index + 1] === '/'
|
||||
&& !/[:/]/.test(value[index - 1] || '')
|
||||
&& !/[\/]/.test(value[index + 2] || '')
|
||||
&& value.indexOf('/', index + 2) > index + 2;
|
||||
let out = '';
|
||||
let index = 0;
|
||||
while (index < text.length) {
|
||||
if (!isDriveStart(text, index) && !isBackslashUncStart(text, index) && !isSlashUncStart(text, index)) {
|
||||
out += text[index];
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
let end = index;
|
||||
while (end < text.length && !/[\r\n"'<>|]/.test(text[end])) end++;
|
||||
const candidate = text.slice(index, end).replace(/\s+(?:trigger|error|outcome|hoster|attempt|status|code)=.*$/i, '');
|
||||
out += '<redacted-path>';
|
||||
index += candidate.length;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function redactLogText(text, secrets) {
|
||||
if (typeof text !== 'string' || !text) return text;
|
||||
let out = redactConfiguredSecrets(text, secrets);
|
||||
out = redactHtmlCredentialFields(out)
|
||||
.replace(/("(?:file|fileName|stagedFile|sourceFile|targetFile|path|[A-Za-z0-9_]*Path)"\s*:\s*")[^"]*(")/gi, '$1<redacted-path>$2');
|
||||
out = redactAbsolutePaths(out)
|
||||
let out = text;
|
||||
if (Array.isArray(secrets)) {
|
||||
for (const s of secrets) {
|
||||
if (typeof s === 'string' && s.length >= 6) out = out.split(s).join(REDACTED);
|
||||
}
|
||||
}
|
||||
out = out
|
||||
.replace(/("(?:file|fileName|stagedFile|sourceFile|targetFile|path|[A-Za-z0-9_]*Path)"\s*:\s*")[^"]*(")/gi, '$1<redacted-path>$2')
|
||||
.replace(/\b[A-Za-z]:(?:\\+|\/+)[^\r\n"'<>|]*?(?=\s+(?:trigger|error|outcome|hoster|attempt|status|code)=|\r?\n|$|["'])/gi, '<redacted-path>')
|
||||
.replace(/\\{2,}[A-Za-z0-9._$-]+\\+[^\r\n"'<>|]*?(?=\s+(?:trigger|error|outcome|hoster|attempt|status|code)=|\r?\n|$|["'])/g, '<redacted-path>')
|
||||
.replace(/https?:\/\/(?:ptb\.|canary\.)?discord(?:app)?\.com\/api\/webhooks\/\d+\/[\w-]+/gi, 'https://discord.com/api/webhooks/' + REDACTED)
|
||||
.replace(/(\/\/[^\s/:@]+:)[^\s/@]+(@)/g, '$1' + REDACTED + '$2')
|
||||
.replace(/(\b(?:proxy-)?authorization\s*:\s*)[^\r\n]*/gi, '$1' + REDACTED)
|
||||
.replace(/(\b(?:set-cookie|cookie)\s*:\s*)[^\r\n]*/gi, '$1' + REDACTED)
|
||||
.replace(/(\b(?:bearer|basic)\s+)[A-Za-z0-9._~+\-/=]+/gi, '$1' + REDACTED)
|
||||
.replace(/(authorization:\s*(?:bearer|basic)\s+)\S+/gi, '$1' + REDACTED)
|
||||
.replace(/\bbearer\s+[A-Za-z0-9._\-/+]{16,}/gi, 'bearer ' + REDACTED)
|
||||
.replace(/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{6,}/g, REDACTED)
|
||||
.replace(/([?&](?:api[_-]?key|key|token|access[_-]?token|refresh[_-]?token|auth|authorization|password|pass|cookie|session(?:[_-]?id)?)=)[^\s&#"'`]+/gi, '$1' + REDACTED)
|
||||
.replace(/("?\b(?:api[_-]?key|apikey|password|passwd|secret|authorization|cookie|(?:access|refresh|auth|session)[_-]?token|token|session[_-]?id|sessionid|session|sess[_-]?id|sessid|sess)"?\s*[:=]\s*)(["'])(.*?)\2/gi, `$1$2${REDACTED}$2`)
|
||||
.replace(/("?\b(?:api[_-]?key|apikey|password|passwd|secret|authorization|cookie|(?:access|refresh|auth|session)[_-]?token|token|session[_-]?id|sessionid|session|sess[_-]?id|sessid|sess)"?\s*[:=]\s*)(?!["'])([^\s,;}\]\r\n]+)/gi, '$1' + REDACTED);
|
||||
.replace(/([?&](?:api_?key|key|token|access_token|password|pass)=)[^\s&"'`]+/gi, '$1' + REDACTED)
|
||||
.replace(/("?\b(?:api[_-]?key|apikey|password|passwd|secret|(?:access|refresh|auth|session)[_-]?token|token|sessionid|session)"?\s*[:=]\s*"?)[A-Za-z0-9._\-/+]{8,}/gi, '$1' + REDACTED)
|
||||
.replace(/(\bset-cookie:|\bcookie:)\s*\S[^\n]*/gi, '$1 ' + REDACTED)
|
||||
.replace(/(\bsess(?:_?id)?\b["'=:\s]+)[A-Za-z0-9._\-]{8,}/gi, '$1' + REDACTED);
|
||||
return out;
|
||||
}
|
||||
|
||||
function valueScrub(value, secrets) {
|
||||
if (value === null || value === undefined) return value;
|
||||
if (typeof value === 'string') return redactLogText(value, secrets);
|
||||
if (Array.isArray(value)) return value.map(entry => valueScrub(entry, secrets));
|
||||
if (typeof value === 'object') {
|
||||
const out = {};
|
||||
for (const [key, entry] of Object.entries(value)) out[redactLogText(key, secrets)] = valueScrub(entry, secrets);
|
||||
return out;
|
||||
const json = JSON.stringify(value);
|
||||
let scrubbed = json;
|
||||
if (Array.isArray(secrets)) {
|
||||
for (const s of secrets) {
|
||||
if (typeof s === 'string' && s.length >= 6) scrubbed = scrubbed.split(s).join(REDACTED);
|
||||
}
|
||||
}
|
||||
return value;
|
||||
return JSON.parse(scrubbed);
|
||||
}
|
||||
|
||||
function collectFile(filePath, label, maxBytes, options) {
|
||||
@@ -201,18 +101,18 @@ function collectFile(filePath, label, maxBytes, options) {
|
||||
|
||||
function buildSupportBundleText({ header, sanitizedConfig, files, secrets }) {
|
||||
const parts = [];
|
||||
parts.push('=== Multi Hoster Uploader Support Bundle ===\n');
|
||||
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(redactLogText(JSON.stringify(sanitizedConfig, null, 2), secrets));
|
||||
parts.push('\n\n');
|
||||
for (const f of (files || [])) {
|
||||
parts.push(collectFile(f.path, f.label || 'log', f.maxBytes, { includePath: false }));
|
||||
parts.push(redactLogText(collectFile(f.path, f.label || f.path, f.maxBytes, { includePath: false }), secrets));
|
||||
}
|
||||
return redactLogText(parts.join(''), secrets);
|
||||
return parts.join('');
|
||||
}
|
||||
|
||||
module.exports = { sanitizeConfig, collectSecretValues, redactLogText, valueScrub, collectFile, buildSupportBundleText, CRED_KEYS, REDACTED };
|
||||
|
||||
+2
-6
@@ -61,10 +61,6 @@ function findLatestYml(assets) {
|
||||
return assets.find(a => /^latest\.yml$/i.test(a.name)) || null;
|
||||
}
|
||||
|
||||
function normalizeInstallerName(value) {
|
||||
return path.basename(String(value || '')).toLowerCase().replace(/[ ._]+/g, '');
|
||||
}
|
||||
|
||||
async function fetchJson(url, signal) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), CHECK_TIMEOUT);
|
||||
@@ -102,7 +98,7 @@ async function fetchGithubReleaseNotes(remoteVersion, fallback = '', fetchImpl =
|
||||
headers: {
|
||||
Accept: 'application/vnd.github+json',
|
||||
'X-GitHub-Api-Version': '2022-11-28',
|
||||
'User-Agent': 'Multi-Hoster-Uploader'
|
||||
'User-Agent': 'Multi-Hoster-Upload'
|
||||
}
|
||||
});
|
||||
if (!response.ok) return String(fallback || '');
|
||||
@@ -184,7 +180,7 @@ async function parseLatestYml(url, expected = {}, fetchImpl = fetch) {
|
||||
if (expected.version && version !== expected.version) {
|
||||
throw new Error('Prüfsummen-Metadaten gehören zu einer anderen Version');
|
||||
}
|
||||
if (expected.assetName && normalizeInstallerName(assetPath) !== normalizeInstallerName(expected.assetName)) {
|
||||
if (expected.assetName && path.basename(assetPath) !== path.basename(expected.assetName)) {
|
||||
throw new Error('Prüfsummen-Metadaten gehören nicht zum ausgewählten Installer');
|
||||
}
|
||||
if (expected.assetSize && size !== Number(expected.assetSize)) {
|
||||
|
||||
+10
-62
@@ -1,31 +1,10 @@
|
||||
const nodePath = require('path');
|
||||
const { formatUploadPlanLogLine } = require('./upload-log');
|
||||
|
||||
function getUploadAuditLogPath(uploadLogPath, pathApi = nodePath) {
|
||||
if (typeof uploadLogPath !== 'string' || !uploadLogPath.trim()) return null;
|
||||
return pathApi.join(pathApi.dirname(uploadLogPath), 'upload-audit.log');
|
||||
}
|
||||
|
||||
async function appendDurably(fs, targetPath, line) {
|
||||
const handle = await fs.promises.open(targetPath, 'a');
|
||||
let appendError = null;
|
||||
let closeError = null;
|
||||
try {
|
||||
await handle.appendFile(line, 'utf-8');
|
||||
await handle.sync();
|
||||
} catch (error) {
|
||||
appendError = error;
|
||||
}
|
||||
try {
|
||||
await handle.close();
|
||||
} catch (error) {
|
||||
closeError = error;
|
||||
}
|
||||
if (appendError && closeError) throw new AggregateError([appendError, closeError], 'Audit append and close failed');
|
||||
if (appendError) throw appendError;
|
||||
if (closeError) throw closeError;
|
||||
}
|
||||
|
||||
function createUploadAuditWriter(options) {
|
||||
const source = options && typeof options === 'object' ? options : {};
|
||||
const fs = source.fs;
|
||||
@@ -33,47 +12,39 @@ function createUploadAuditWriter(options) {
|
||||
const resolveUploadLogTarget = source.resolveUploadLogTarget;
|
||||
const rotateLogFile = typeof source.rotateLogFile === 'function' ? source.rotateLogFile : () => {};
|
||||
const invalidateUploadLogTarget = typeof source.invalidateUploadLogTarget === 'function' ? source.invalidateUploadLogTarget : () => {};
|
||||
const persistFallbackLogPath = typeof source.persistFallbackLogPath === 'function' ? source.persistFallbackLogPath : async () => false;
|
||||
const persistFallbackLogPath = typeof source.persistFallbackLogPath === 'function' ? source.persistFallbackLogPath : async () => {};
|
||||
const reportError = typeof source.reportError === 'function' ? source.reportError : () => {};
|
||||
const retryDelays = Array.isArray(source.retryDelays) && source.retryDelays.length > 0 ? source.retryDelays : [0, 100, 250];
|
||||
const maxBytes = Number.isFinite(source.maxBytes) ? source.maxBytes : 10 * 1024 * 1024;
|
||||
const maxBackups = Number.isFinite(source.maxBackups) ? source.maxBackups : 2;
|
||||
let activePath = null;
|
||||
|
||||
if (!fs || !fs.promises || typeof fs.promises.open !== 'function' || typeof resolveUploadLogTarget !== 'function') {
|
||||
if (!fs || !fs.promises || typeof fs.promises.appendFile !== 'function' || typeof resolveUploadLogTarget !== 'function') {
|
||||
throw new TypeError('createUploadAuditWriter requires fs and resolveUploadLogTarget');
|
||||
}
|
||||
|
||||
async function append(line, label) {
|
||||
const excludedPaths = new Set();
|
||||
let excludedPath = null;
|
||||
for (const delay of retryDelays) {
|
||||
if (delay) await new Promise(resolve => setTimeout(resolve, delay));
|
||||
const uploadTarget = resolveUploadLogTarget(excludedPaths);
|
||||
if (!uploadTarget || excludedPaths.has(uploadTarget.path)) continue;
|
||||
const uploadTarget = resolveUploadLogTarget(excludedPath);
|
||||
const targetPath = uploadTarget && getUploadAuditLogPath(uploadTarget.path, path);
|
||||
if (!targetPath) continue;
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
||||
rotateLogFile(targetPath, maxBytes, maxBackups);
|
||||
await fs.promises.appendFile(targetPath, line, 'utf-8');
|
||||
activePath = targetPath;
|
||||
if (uploadTarget.isFallback) {
|
||||
let persisted = false;
|
||||
try {
|
||||
persisted = await persistFallbackLogPath(uploadTarget.path);
|
||||
await persistFallbackLogPath(uploadTarget.path);
|
||||
} catch (error) {
|
||||
reportError('audit-fallback-persist', error);
|
||||
}
|
||||
if (persisted !== true) {
|
||||
excludedPaths.add(uploadTarget.path);
|
||||
invalidateUploadLogTarget();
|
||||
reportError('audit-fallback-persist', new Error('Fallback log path could not be persisted'));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
rotateLogFile(targetPath, maxBytes, maxBackups);
|
||||
await appendDurably(fs, targetPath, line);
|
||||
activePath = targetPath;
|
||||
return true;
|
||||
} catch (error) {
|
||||
excludedPaths.add(uploadTarget.path);
|
||||
excludedPath = uploadTarget.path;
|
||||
invalidateUploadLogTarget();
|
||||
reportError(label, error);
|
||||
}
|
||||
@@ -84,27 +55,4 @@ function createUploadAuditWriter(options) {
|
||||
return { append, getActivePath: () => activePath };
|
||||
}
|
||||
|
||||
function createUploadAuditEvents(writer, now = () => new Date()) {
|
||||
if (!writer || typeof writer.append !== 'function') throw new TypeError('createUploadAuditEvents requires an audit writer');
|
||||
return {
|
||||
appendSourceCleanup: event => writer.append(`# SOURCE-CLEANUP ${JSON.stringify(event)}\r\n`, 'source-cleanup'),
|
||||
appendUploadPlan: (plan, mode) => writer.append(formatUploadPlanLogLine(now(), plan, mode), 'upload-plan')
|
||||
};
|
||||
}
|
||||
|
||||
async function runAfterDurableAudit(audit, action) {
|
||||
let persisted = false;
|
||||
try {
|
||||
persisted = await audit();
|
||||
} catch {}
|
||||
if (persisted !== true) return { ok: false };
|
||||
return { ok: true, value: await action() };
|
||||
}
|
||||
|
||||
function getUploadAuditFailureMessage(language) {
|
||||
return language === 'de'
|
||||
? 'Der Uploadplan konnte nicht dauerhaft protokolliert werden. Bitte prüfe den Log-Pfad und versuche es erneut.'
|
||||
: 'The upload plan could not be recorded durably. Check the log path and try again.';
|
||||
}
|
||||
|
||||
module.exports = { getUploadAuditLogPath, createUploadAuditWriter, createUploadAuditEvents, runAfterDurableAudit, getUploadAuditFailureMessage };
|
||||
module.exports = { getUploadAuditLogPath, createUploadAuditWriter };
|
||||
|
||||
+26
-24
@@ -11,13 +11,13 @@ const HOSTER_RESULT_DOMAINS = {
|
||||
'doodstream.com': ['doodstream.com', 'dood.to', 'dood.la', 'dood.so', 'dsvplay.com']
|
||||
};
|
||||
|
||||
function isExpectedHostUrl(value, expectedHost, allowHttp = false) {
|
||||
function isExpectedHostUrl(value, expectedHost) {
|
||||
if (typeof value !== 'string' || value.trim() === '') return false;
|
||||
try {
|
||||
const url = new URL(value);
|
||||
const hostname = url.hostname.toLowerCase();
|
||||
const acceptedDomains = HOSTER_RESULT_DOMAINS[expectedHost] || [expectedHost];
|
||||
return (url.protocol === 'https:' || (allowHttp && url.protocol === 'http:'))
|
||||
return (url.protocol === 'http:' || url.protocol === 'https:')
|
||||
&& acceptedDomains.some(domain => hostname === domain || hostname.endsWith(`.${domain}`));
|
||||
} catch {
|
||||
return false;
|
||||
@@ -32,15 +32,25 @@ function getUrlHost(value) {
|
||||
}
|
||||
}
|
||||
|
||||
function selectPublicUploadUrl(result) {
|
||||
for (const value of [result?.download_url, result?.embed_url]) {
|
||||
if (typeof value !== 'string' || value.trim() === '') continue;
|
||||
try {
|
||||
const url = new URL(value.trim());
|
||||
if (url.protocol === 'https:') return url.href;
|
||||
} catch {}
|
||||
}
|
||||
return '';
|
||||
function normalizeDoodstreamUrl(value) {
|
||||
if (typeof value !== 'string' || value.trim() === '') return value;
|
||||
try {
|
||||
const url = new URL(value);
|
||||
const hostname = url.hostname.toLowerCase();
|
||||
if (hostname !== 'doodstream.com' && HOSTER_RESULT_DOMAINS['doodstream.com'].includes(hostname)) {
|
||||
url.hostname = 'doodstream.com';
|
||||
return url.toString();
|
||||
}
|
||||
} catch {}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeConfirmedResult(result, hoster) {
|
||||
if (hoster !== 'doodstream.com') return result;
|
||||
const downloadUrl = normalizeDoodstreamUrl(result.download_url);
|
||||
const embedUrl = normalizeDoodstreamUrl(result.embed_url);
|
||||
if (downloadUrl === result.download_url && embedUrl === result.embed_url) return result;
|
||||
return { ...result, download_url: downloadUrl, embed_url: embedUrl };
|
||||
}
|
||||
|
||||
function assertUploadConfirmation(result, hoster) {
|
||||
@@ -51,18 +61,10 @@ function assertUploadConfirmation(result, hoster) {
|
||||
&& value !== undefined
|
||||
&& !(typeof value === 'string' && value.trim() === '')
|
||||
));
|
||||
if (SUPPORTED_HOSTERS.has(expectedHost) && FILE_CODE_PATTERN.test(fileCode)) {
|
||||
if (expectedHost === 'doodstream.com' && urls.every(value => isExpectedHostUrl(value, expectedHost, true))) {
|
||||
return {
|
||||
...result,
|
||||
file_code: fileCode,
|
||||
download_url: `https://doodstream.com/d/${fileCode}`,
|
||||
embed_url: `https://doodstream.com/e/${fileCode}`
|
||||
};
|
||||
}
|
||||
if (urls.length > 0 && urls.every(value => isExpectedHostUrl(value, expectedHost))) {
|
||||
return fileCode === result.file_code ? result : { ...result, file_code: fileCode };
|
||||
}
|
||||
if (SUPPORTED_HOSTERS.has(expectedHost)
|
||||
&& FILE_CODE_PATTERN.test(fileCode)
|
||||
&& urls.every(value => isExpectedHostUrl(value, expectedHost))) {
|
||||
return normalizeConfirmedResult(result, expectedHost);
|
||||
}
|
||||
const error = new Error(`Upload zu ${hoster || 'unbekanntem Hoster'} wurde nicht bestätigt`);
|
||||
error.diagnostic = {
|
||||
@@ -74,4 +76,4 @@ function assertUploadConfirmation(result, hoster) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
module.exports = { assertUploadConfirmation, selectPublicUploadUrl };
|
||||
module.exports = { assertUploadConfirmation };
|
||||
|
||||
@@ -9,28 +9,13 @@ function cleanText(value, limit = 320) {
|
||||
return '[URL]';
|
||||
}
|
||||
});
|
||||
text = text.replace(/\b(authorization|proxy-authorization|cookie|set-cookie)\s*[:=]\s*[^,]+/gi, '$1=[redacted]');
|
||||
text = text.replace(/(["']?(?:api[_-]?key|token|password|cookie|authorization|session)["']?\s*[:=]\s*)[^,;\s}"']+/gi, '$1[redacted]');
|
||||
if (/^(?:bearer|basic)\s+/i.test(text)) {
|
||||
text = '[redacted authorization]';
|
||||
}
|
||||
text = text.replace(/\b[A-Za-z0-9_-]{20,}\b/g, '[redacted]');
|
||||
return text.slice(0, limit);
|
||||
}
|
||||
|
||||
function normalizeEndpointHost(value) {
|
||||
const host = String(value || '').trim().toLowerCase();
|
||||
if (!host || host.length > 253 || !/^[a-z0-9.-]+$/.test(host)) return '';
|
||||
if (host.startsWith('.') || host.endsWith('.') || host.includes('..')) return '';
|
||||
return host;
|
||||
}
|
||||
|
||||
function normalizePhase(value) {
|
||||
const phase = String(value || '').trim();
|
||||
if (/^[a-z0-9._:-]{1,80}$/i.test(phase)) return phase;
|
||||
return cleanText(phase, 80);
|
||||
}
|
||||
|
||||
function normalizeFailureDetails(diagnostic) {
|
||||
if (!diagnostic || typeof diagnostic !== 'object') return null;
|
||||
const httpStatus = Number.isInteger(Number(diagnostic.http)) && Number(diagnostic.http) >= 100 && Number(diagnostic.http) <= 599
|
||||
@@ -38,18 +23,9 @@ function normalizeFailureDetails(diagnostic) {
|
||||
: null;
|
||||
const contentType = cleanText(diagnostic.contentType, 120);
|
||||
const responseSnippet = cleanText(diagnostic.payloadSnippet, 320);
|
||||
const phase = normalizePhase(diagnostic.phase);
|
||||
const endpointHost = normalizeEndpointHost(diagnostic.safeEndpointHost);
|
||||
const responseKind = ['empty', 'json', 'html', 'text'].includes(diagnostic.responseKind)
|
||||
? diagnostic.responseKind
|
||||
: '';
|
||||
const details = {};
|
||||
if (phase) details.phase = phase;
|
||||
if (httpStatus !== null) details.httpStatus = httpStatus;
|
||||
if (contentType) details.contentType = contentType;
|
||||
if (endpointHost) details.endpointHost = endpointHost;
|
||||
if (responseKind) details.responseKind = responseKind;
|
||||
if (typeof diagnostic.retryable === 'boolean') details.retryable = diagnostic.retryable;
|
||||
if (responseSnippet) details.responseSnippet = responseSnippet;
|
||||
return Object.keys(details).length > 0 ? details : null;
|
||||
}
|
||||
|
||||
+101
-510
@@ -3,7 +3,7 @@ const path = require('path');
|
||||
const { assertUploadConfirmation } = require('./upload-confirmation');
|
||||
const fs = require('fs');
|
||||
const crypto = require('crypto');
|
||||
const { uploadFile, prefetchBaseline, createRecoveryClaimRegistry, normalizeRecoveryTitle } = require('./hosters');
|
||||
const { uploadFile, prefetchBaseline } = require('./hosters');
|
||||
const VidmolyUploader = require('./vidmoly-upload');
|
||||
const VoeUploader = require('./voe-upload');
|
||||
const DoodstreamUploader = require('./doodstream-upload');
|
||||
@@ -12,7 +12,6 @@ const Semaphore = require('./semaphore');
|
||||
const Throttle = require('./throttle');
|
||||
const { probeFileHead } = require('./file-probe');
|
||||
const { normalizeFailureDetails } = require('./upload-diagnostics');
|
||||
const { createUploadScheduleGate } = require('./upload-schedule');
|
||||
|
||||
const DEFAULT_SETTINGS = {
|
||||
retries: 3,
|
||||
@@ -29,7 +28,6 @@ class UploadManager extends EventEmitter {
|
||||
super();
|
||||
this.hosterSettings = hosterSettings || {};
|
||||
this.globalSettings = globalSettings || {};
|
||||
this.uploadScheduleGate = createUploadScheduleGate(this.globalSettings.uploadSchedule);
|
||||
this.accountPools = accountPools || {};
|
||||
this.semaphores = {};
|
||||
this.globalSemaphore = null;
|
||||
@@ -54,11 +52,6 @@ class UploadManager extends EventEmitter {
|
||||
this._suspectGoodAccounts = new Map(); // hoster -> accountId that accepted a suspect-class file
|
||||
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)
|
||||
this._recoveryClaims = createRecoveryClaimRegistry();
|
||||
this._recoveryAuthModes = new Map();
|
||||
this._suspectResolutionGates = new Map();
|
||||
this._batchJobIds = new Set();
|
||||
this._batchTotal = 0;
|
||||
}
|
||||
|
||||
updateAccountPools(accountPools) {
|
||||
@@ -75,9 +68,6 @@ class UploadManager extends EventEmitter {
|
||||
this._suspectGoodAccounts.clear();
|
||||
this._doodApiKeyCache.clear();
|
||||
this._baselineCache.clear();
|
||||
if (!this.running) this._recoveryClaims.clear();
|
||||
if (!this.running) this._recoveryAuthModes.clear();
|
||||
if (!this.running) this._clearSuspectResolutionGates();
|
||||
}
|
||||
|
||||
switchAccount(hoster, fallbackAccount) {
|
||||
@@ -140,41 +130,6 @@ class UploadManager extends EventEmitter {
|
||||
return true;
|
||||
}
|
||||
|
||||
_swapFailedAccount(task, jobId, fileName) {
|
||||
if (!task.accountId || !this._failedAccounts.has(task.hoster + ':' + task.accountId)) return false;
|
||||
const override = this._accountOverrides.get(task.hoster);
|
||||
if (override && !this._failedAccounts.has(task.hoster + ':' + override.id)) {
|
||||
this._rotLog('pre-job-swap', {
|
||||
jobId, hoster: task.hoster, fileName, fromAccountId: task.accountId, toAccountId: override.id
|
||||
});
|
||||
task.accountId = override.id;
|
||||
task.username = override.username;
|
||||
task.password = override.password;
|
||||
task.apiKey = override.apiKey;
|
||||
return true;
|
||||
}
|
||||
this._rotLog('pre-job-swap-blocked', {
|
||||
jobId, hoster: task.hoster, fileName, accountId: task.accountId,
|
||||
hasOverride: !!override,
|
||||
overrideAlsoFailed: override ? this._failedAccounts.has(task.hoster + ':' + override.id) : false
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
async _coordinateAccountFailure(task, err, signal, jobId) {
|
||||
if (!task.accountId || (err && err.remoteCommitUncertain === true)) return;
|
||||
if (!this._shouldSkipRetryOnAccountError(err)) return;
|
||||
const key = task.hoster + ':' + task.accountId;
|
||||
if (this._failedAccounts.has(key)) return;
|
||||
this._failedAccounts.set(key, true);
|
||||
this._rotLog('mark-failed', {
|
||||
jobId, hoster: task.hoster, fileName: path.basename(task.file),
|
||||
accountId: task.accountId, lastError: err && err.message ? err.message : String(err)
|
||||
});
|
||||
this.emit('account-failed', { hoster: task.hoster, accountId: task.accountId });
|
||||
await this._sleep(800, signal);
|
||||
}
|
||||
|
||||
_rotLog(event, data) {
|
||||
this.emit('rot-log', { ts: Date.now(), event, ...data });
|
||||
}
|
||||
@@ -202,7 +157,6 @@ class UploadManager extends EventEmitter {
|
||||
// which takes priority in _shouldSkipRetryOnAccountError.
|
||||
_isFileRejectedError(err) {
|
||||
if (!err) return false;
|
||||
if (err.remoteCommitUncertain === true) return false;
|
||||
if (err.transientNetwork === true) return false;
|
||||
if (err.accountError === true) return false; // explicit account-level wins
|
||||
if (err.fileRejected === true) return true;
|
||||
@@ -307,7 +261,6 @@ class UploadManager extends EventEmitter {
|
||||
updateSettings(hosterSettings, globalSettings) {
|
||||
this.hosterSettings = hosterSettings || this.hosterSettings;
|
||||
this.globalSettings = globalSettings || this.globalSettings;
|
||||
this.uploadScheduleGate.update(this.globalSettings.uploadSchedule);
|
||||
// Live-update semaphores for running uploads
|
||||
for (const [hoster, sem] of Object.entries(this.semaphores)) {
|
||||
const settings = this._getSettings(hoster);
|
||||
@@ -381,8 +334,6 @@ class UploadManager extends EventEmitter {
|
||||
async startBatch(tasks, opts = {}) {
|
||||
const pendingCancelledJobIds = new Set(this.pendingCancelledJobIds);
|
||||
const pendingCancelAll = this.pendingCancelAll;
|
||||
this._batchJobIds = new Set(tasks.map((task) => task.jobId).filter(Boolean));
|
||||
this._batchTotal = tasks.length;
|
||||
this.pendingCancelledJobIds.clear();
|
||||
this.pendingCancelAll = false;
|
||||
this.running = true;
|
||||
@@ -397,10 +348,6 @@ class UploadManager extends EventEmitter {
|
||||
for (const jobId of pendingCancelledJobIds) this.cancelledJobIds.add(jobId);
|
||||
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._recoveryClaims.clear();
|
||||
this._recoveryClaims = createRecoveryClaimRegistry();
|
||||
this._recoveryAuthModes.clear();
|
||||
this._clearSuspectResolutionGates();
|
||||
this.semaphores = {};
|
||||
this.globalSemaphore = null;
|
||||
this.globalThrottle = null;
|
||||
@@ -441,7 +388,7 @@ class UploadManager extends EventEmitter {
|
||||
for (let j = i; j < end; j++) {
|
||||
const task = tasks[j];
|
||||
if (!results.has(task.file)) {
|
||||
results.set(task.file, { name: path.basename(task.file), fileKey: task.fileKey || null, size: 0, results: [] });
|
||||
results.set(task.file, { name: path.basename(task.file), size: 0, results: [] });
|
||||
toStat.push(task.file);
|
||||
}
|
||||
}
|
||||
@@ -472,7 +419,7 @@ class UploadManager extends EventEmitter {
|
||||
this._emitStats();
|
||||
|
||||
const files = Array.from(results.values());
|
||||
const total = this._batchTotal;
|
||||
const total = tasks.length;
|
||||
const succeeded = files.reduce((count, file) => count + file.results.filter((result) => result.status === 'done').length, 0);
|
||||
const skipped = files.reduce((count, file) => count + file.results.filter((result) => result.status === 'skipped').length, 0);
|
||||
|
||||
@@ -481,21 +428,18 @@ class UploadManager extends EventEmitter {
|
||||
timestamp: new Date().toISOString(),
|
||||
total,
|
||||
succeeded,
|
||||
failed: Math.max(0, total - succeeded - skipped),
|
||||
failed: total - succeeded - skipped,
|
||||
skipped,
|
||||
files
|
||||
};
|
||||
|
||||
this._recoveryClaims.clear();
|
||||
this._recoveryAuthModes.clear();
|
||||
this._clearSuspectResolutionGates();
|
||||
this._doodApiKeyCache.clear();
|
||||
this._baselineCache.clear();
|
||||
this.emit('batch-done', summary);
|
||||
}
|
||||
|
||||
async _runJob(task, results, batchSignal) {
|
||||
const settings = this._getSettings(task.hoster);
|
||||
const hosterSemaphore = this._getSemaphore(task.hoster);
|
||||
const globalSemaphore = this._getGlobalSemaphore();
|
||||
const uploadId = crypto.randomBytes(8).toString('hex');
|
||||
const jobId = task.jobId || uploadId;
|
||||
const fileName = path.basename(task.file);
|
||||
@@ -515,6 +459,8 @@ class UploadManager extends EventEmitter {
|
||||
const { signal, cleanup: cleanupSignals } = this._combineSignals(batchSignal, jobAbortController.signal);
|
||||
this.jobAbortControllers.set(jobId, jobAbortController);
|
||||
|
||||
let hosterSlotAcquired = false;
|
||||
let globalSlotAcquired = false;
|
||||
let finalResultRecorded = false;
|
||||
let finalStatus = 'error';
|
||||
let lastError = null;
|
||||
@@ -527,7 +473,6 @@ class UploadManager extends EventEmitter {
|
||||
finalStatus = status;
|
||||
|
||||
const result = {
|
||||
jobId,
|
||||
hoster: task.hoster,
|
||||
status,
|
||||
error: payload.error || null,
|
||||
@@ -540,16 +485,12 @@ class UploadManager extends EventEmitter {
|
||||
embed_url: payload.result ? payload.result.embed_url || null : null,
|
||||
file_code: payload.result ? payload.result.file_code || null : null
|
||||
};
|
||||
if (payload.remoteCommitUncertain === true || payload.error?.remoteCommitUncertain === true || lastError?.remoteCommitUncertain === true) {
|
||||
result.remoteCommitUncertain = true;
|
||||
}
|
||||
|
||||
results.get(task.file).results.push(result);
|
||||
};
|
||||
|
||||
const emitFinalStatus = (status, payload = {}) => {
|
||||
if (status === 'aborted' && this.abortController.signal.aborted) return;
|
||||
const remoteCommitUncertain = payload.remoteCommitUncertain === true || payload.error?.remoteCommitUncertain === true || lastError?.remoteCommitUncertain === true;
|
||||
this._emitProgress(uploadId, fileName, task.hoster, { accountId: task.accountId,
|
||||
jobId,
|
||||
status,
|
||||
@@ -563,8 +504,7 @@ class UploadManager extends EventEmitter {
|
||||
failureDetails: payload.failureDetails || lastFailureDetails,
|
||||
result: payload.result || null,
|
||||
attempt: payload.attempt || maxAttempts,
|
||||
maxAttempts,
|
||||
remoteCommitUncertain
|
||||
maxAttempts
|
||||
});
|
||||
};
|
||||
|
||||
@@ -601,6 +541,9 @@ class UploadManager extends EventEmitter {
|
||||
// queueJobs array; the first event it actually needs from main is the
|
||||
// 'getting-server' / 'uploading' transition for the jobs that the
|
||||
// semaphore lets through.
|
||||
await hosterSemaphore.acquire(signal);
|
||||
hosterSlotAcquired = true;
|
||||
|
||||
let fileProbe = null;
|
||||
try {
|
||||
fileProbe = await probeFileHead(task.file, 512);
|
||||
@@ -615,19 +558,48 @@ class UploadManager extends EventEmitter {
|
||||
headHex: fileProbe && fileProbe.headHex ? fileProbe.headHex.slice(0, 32) : null
|
||||
});
|
||||
|
||||
if (globalSemaphore) {
|
||||
await globalSemaphore.acquire(signal);
|
||||
globalSlotAcquired = true;
|
||||
}
|
||||
|
||||
if (settings.timeIntervalSec > 0) {
|
||||
await this._waitForInterval(task.hoster, settings.timeIntervalSec * 1000, signal);
|
||||
}
|
||||
|
||||
// Pre-job-swap: if this account was marked failed WHILE this task was
|
||||
// waiting in the semaphore queue, jump straight to the override instead
|
||||
// of burning a guaranteed-to-fail upload attempt. Critical at scale:
|
||||
// with 500 queued jobs and 1 parallel slot, without this check every
|
||||
// job still hits the original dead account first.
|
||||
this._swapFailedAccount(task, jobId, fileName);
|
||||
if (task.accountId && this._failedAccounts.has(task.hoster + ':' + task.accountId)) {
|
||||
const override = this._accountOverrides.get(task.hoster);
|
||||
if (override && !this._failedAccounts.has(task.hoster + ':' + override.id)) {
|
||||
this._rotLog('pre-job-swap', {
|
||||
jobId, hoster: task.hoster, fileName, fromAccountId: task.accountId, toAccountId: override.id
|
||||
});
|
||||
task.accountId = override.id;
|
||||
task.username = override.username;
|
||||
task.password = override.password;
|
||||
task.apiKey = override.apiKey;
|
||||
} else {
|
||||
this._rotLog('pre-job-swap-blocked', {
|
||||
jobId, hoster: task.hoster, fileName, accountId: task.accountId,
|
||||
hasOverride: !!override,
|
||||
overrideAlsoFailed: override ? this._failedAccounts.has(task.hoster + ':' + override.id) : false
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// A previous file of at least this size already got a suspect rejection
|
||||
// on this exact account — skip the guaranteed-to-fail multi-GB upload
|
||||
// and go straight to the alternate-account walk below.
|
||||
let memoSuspect = this._createSuspectMemoError(task, fileProbe, fileSize);
|
||||
if (memoSuspect) {
|
||||
this._beginSuspectResolution(task.hoster, jobId);
|
||||
let memoSuspect = null;
|
||||
if (fileProbe && fileProbe.isVideoLike === true && task.accountId
|
||||
&& this._suspectMemoBlocks(task.hoster, task.accountId, fileSize)) {
|
||||
memoSuspect = new Error('Bekanntes Größen-Limit auf diesem Account (frühere verdächtige Ablehnung)');
|
||||
memoSuspect.fileRejected = true;
|
||||
memoSuspect.suspectReject = true;
|
||||
lastError = memoSuspect;
|
||||
this._rotLog('suspect-memo-skip', {
|
||||
jobId, hoster: task.hoster, fileName, accountId: task.accountId, fileSize
|
||||
@@ -636,8 +608,6 @@ class UploadManager extends EventEmitter {
|
||||
|
||||
const attemptsAllowed = memoSuspect ? 0 : maxAttempts;
|
||||
for (let attempt = 1; attempt <= attemptsAllowed; attempt++) {
|
||||
await this._waitForUploadSchedule(signal);
|
||||
this._throwIfUploadStartBlocked(signal);
|
||||
finalAttempt = attempt;
|
||||
if (signal.aborted || this.stopAfterActive) break;
|
||||
|
||||
@@ -757,7 +727,9 @@ class UploadManager extends EventEmitter {
|
||||
} catch { /* progress callbacks must never throw — swallowing is correct, the stream keeps going */ }
|
||||
};
|
||||
|
||||
const result = await this._executeUploadWithAdmission(task, progressCb, uploadSignalBundle.signal, throttle, fileProbe, fileSize, true, jobId);
|
||||
const result = await this._executeUpload(task, progressCb, uploadSignalBundle.signal, throttle, fileProbe);
|
||||
|
||||
if (signal.aborted || this.cancelledJobIds.has(jobId)) throw new Error('Aborted');
|
||||
|
||||
const elapsed = Math.round((Date.now() - jobStart) / 1000);
|
||||
this.sessionBytes += fileSize;
|
||||
@@ -794,11 +766,6 @@ class UploadManager extends EventEmitter {
|
||||
payloadSnippet: lastFailureDetails ? lastFailureDetails.responseSnippet || null : null
|
||||
});
|
||||
}
|
||||
if (err && err.remoteCommitUncertain === true) {
|
||||
lastError = err;
|
||||
break;
|
||||
}
|
||||
|
||||
if (signal.aborted) {
|
||||
lastError = new Error('Abgebrochen');
|
||||
break;
|
||||
@@ -864,16 +831,6 @@ class UploadManager extends EventEmitter {
|
||||
|
||||
const wasStopped = this.stopAfterActive && !signal.aborted;
|
||||
const wasAborted = signal.aborted || this.cancelledJobIds.has(jobId);
|
||||
if (lastError && lastError.remoteCommitUncertain === true) {
|
||||
const cancelledUncertain = lastError.cancelledAfterUploadStart === true;
|
||||
const status = cancelledUncertain ? 'aborted' : 'error';
|
||||
const error = cancelledUncertain
|
||||
? 'Abgebrochen; Remote-Status konnte nicht bestätigt werden'
|
||||
: (lastError.message || 'Remote-Upload konnte nicht eindeutig bestätigt werden');
|
||||
emitFinalStatus(status, { error, remoteCommitUncertain: true });
|
||||
recordFinalResult(status, { error, remoteCommitUncertain: true });
|
||||
return;
|
||||
}
|
||||
if (wasStopped || wasAborted) {
|
||||
const error = wasStopped ? 'Warteschlange angehalten' : 'Abgebrochen';
|
||||
emitFinalStatus('aborted', { error });
|
||||
@@ -907,6 +864,12 @@ class UploadManager extends EventEmitter {
|
||||
this._noteSuspectReject(task.hoster, task.accountId, fileSize);
|
||||
const alt = await this._trySuspectRejectAlternates(task, { uploadId, jobId, fileName, fileSize, settings, signal, fileProbe });
|
||||
if (alt) {
|
||||
if (signal.aborted || this.cancelledJobIds.has(jobId)) {
|
||||
const error = 'Abgebrochen';
|
||||
emitFinalStatus('aborted', { error });
|
||||
recordFinalResult('aborted', { error });
|
||||
return;
|
||||
}
|
||||
emitFinalStatus('done', { result: alt.result, speedKbs: alt.speedKbs, elapsed: alt.elapsed, attempt: 1 });
|
||||
recordFinalResult('done', { result: alt.result });
|
||||
return;
|
||||
@@ -1028,8 +991,6 @@ class UploadManager extends EventEmitter {
|
||||
// loop iterates: marks this account failed too, asks main for the next
|
||||
// fallback, and so on.
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
await this._waitForUploadSchedule(signal);
|
||||
this._throwIfUploadStartBlocked(signal);
|
||||
finalAttempt = attempt;
|
||||
if (signal.aborted || this.stopAfterActive) break;
|
||||
if (attempt > 1) {
|
||||
@@ -1078,7 +1039,8 @@ class UploadManager extends EventEmitter {
|
||||
? { consume: async (bytes, sig) => { await hosterThrottle.consume(bytes, sig); await globalThrottle.consume(bytes, sig); } }
|
||||
: hosterThrottle || globalThrottle;
|
||||
|
||||
const result = await this._executeUploadWithAdmission(task, progressCb, signal, throttle, fileProbe, fileSize, true, jobId);
|
||||
const result = await this._executeUpload(task, progressCb, signal, throttle, fileProbe);
|
||||
if (signal.aborted || this.cancelledJobIds.has(jobId)) throw new Error('Aborted');
|
||||
this.activeJobs.delete(uploadId);
|
||||
this.sessionBytes += fileSize;
|
||||
emitFinalStatus('done', { result, speedKbs: currentSpeedKbs, elapsed: Math.round((Date.now() - jobStart) / 1000), attempt });
|
||||
@@ -1099,7 +1061,6 @@ class UploadManager extends EventEmitter {
|
||||
rotationRetry: true
|
||||
});
|
||||
}
|
||||
if (err && err.remoteCommitUncertain === true) break;
|
||||
if (signal.aborted || this.stopAfterActive) break;
|
||||
if (this._isFileRejectedError(err)) break;
|
||||
if (this._isHosterTransientError(err)) break;
|
||||
@@ -1111,16 +1072,6 @@ class UploadManager extends EventEmitter {
|
||||
|
||||
const stoppedLate = this.stopAfterActive && !signal.aborted;
|
||||
const abortedLate = signal.aborted || this.cancelledJobIds.has(jobId);
|
||||
if (lastError && lastError.remoteCommitUncertain === true) {
|
||||
const cancelledUncertain = lastError.cancelledAfterUploadStart === true;
|
||||
const status = cancelledUncertain ? 'aborted' : 'error';
|
||||
const error = cancelledUncertain
|
||||
? 'Abgebrochen; Remote-Status konnte nicht bestätigt werden'
|
||||
: (lastError.message || 'Remote-Upload konnte nicht eindeutig bestätigt werden');
|
||||
emitFinalStatus(status, { error, remoteCommitUncertain: true });
|
||||
recordFinalResult(status, { error, remoteCommitUncertain: true });
|
||||
return;
|
||||
}
|
||||
if (stoppedLate || abortedLate) {
|
||||
const error = stoppedLate ? 'Warteschlange angehalten' : 'Abgebrochen';
|
||||
emitFinalStatus('aborted', { error });
|
||||
@@ -1134,25 +1085,20 @@ class UploadManager extends EventEmitter {
|
||||
emitFinalStatus('error', { error });
|
||||
recordFinalResult('error', { error });
|
||||
} catch (err) {
|
||||
if (err && err.remoteCommitUncertain === true) lastError = err;
|
||||
const wasStopped = this.stopAfterActive && !signal.aborted;
|
||||
const remoteCommitUncertain = !!(err && err.remoteCommitUncertain === true);
|
||||
const cancelledUncertain = remoteCommitUncertain && err.cancelledAfterUploadStart === true;
|
||||
const error = remoteCommitUncertain
|
||||
? (cancelledUncertain ? 'Abgebrochen; Remote-Status konnte nicht bestätigt werden' : (err.message || 'Remote-Upload konnte nicht eindeutig bestätigt werden'))
|
||||
: wasStopped
|
||||
const error = wasStopped
|
||||
? 'Warteschlange angehalten'
|
||||
: (signal.aborted || this.cancelledJobIds.has(jobId) ? 'Abgebrochen' : (err && err.message ? err.message : 'Unbekannter Fehler'));
|
||||
const status = remoteCommitUncertain
|
||||
? (cancelledUncertain ? 'aborted' : 'error')
|
||||
: (signal.aborted || this.cancelledJobIds.has(jobId) || wasStopped ? 'aborted' : 'error');
|
||||
emitFinalStatus(status, { error, remoteCommitUncertain });
|
||||
recordFinalResult(status, { error, remoteCommitUncertain });
|
||||
const status = signal.aborted || this.cancelledJobIds.has(jobId) || wasStopped ? 'aborted' : 'error';
|
||||
emitFinalStatus(status, { error });
|
||||
recordFinalResult(status === 'error' ? 'error' : 'aborted', { error });
|
||||
} finally {
|
||||
this._endSuspectResolution(task.hoster, jobId);
|
||||
this.activeJobs.delete(uploadId);
|
||||
this.jobAbortControllers.delete(jobId);
|
||||
cleanupSignals();
|
||||
// Release in reverse order of acquire (global first, then hoster)
|
||||
if (globalSlotAcquired && globalSemaphore) globalSemaphore.release();
|
||||
if (hosterSlotAcquired) hosterSemaphore.release();
|
||||
this.emit('job-settled', {
|
||||
jobId,
|
||||
sourceCleanupToken: task.sourceCleanupToken || null,
|
||||
@@ -1188,8 +1134,6 @@ class UploadManager extends EventEmitter {
|
||||
});
|
||||
continue;
|
||||
}
|
||||
await this._waitForUploadSchedule(signal);
|
||||
this._throwIfUploadStartBlocked(signal);
|
||||
attempted += 1;
|
||||
this._rotLog('suspect-reject-alt', {
|
||||
jobId, hoster: task.hoster, fileName, fromAccountId: task.accountId, toAccountId: account.id
|
||||
@@ -1238,7 +1182,8 @@ class UploadManager extends EventEmitter {
|
||||
? { consume: async (bytes, sig) => { await hosterThrottle.consume(bytes, sig); await globalThrottle.consume(bytes, sig); } }
|
||||
: hosterThrottle || globalThrottle;
|
||||
try {
|
||||
const result = await this._executeUploadWithAdmission(task, progressCb, signal, throttle, fileProbe, fileSize, false, jobId);
|
||||
const result = await this._executeUpload(task, progressCb, signal, throttle, fileProbe);
|
||||
if (signal.aborted || this.cancelledJobIds.has(jobId)) throw new Error('Aborted');
|
||||
this.activeJobs.delete(uploadId);
|
||||
this.sessionBytes += fileSize;
|
||||
this._suspectGoodAccounts.set(task.hoster, account.id);
|
||||
@@ -1256,7 +1201,6 @@ class UploadManager extends EventEmitter {
|
||||
suspectAlternate: true
|
||||
});
|
||||
}
|
||||
if (err && err.remoteCommitUncertain === true) throw err;
|
||||
if (signal.aborted || this.stopAfterActive) break;
|
||||
if (err && err.suspectReject === true) {
|
||||
this._noteSuspectReject(task.hoster, account.id, fileSize);
|
||||
@@ -1289,325 +1233,20 @@ class UploadManager extends EventEmitter {
|
||||
return null;
|
||||
}
|
||||
|
||||
async _executeUploadWithAdmission(task, progressCb, signal, throttle, fileProbe, fileSize, coordinateAccountFailure = true, jobId = task.jobId) {
|
||||
while (true) {
|
||||
await this._waitForUploadSchedule(signal);
|
||||
this._throwIfUploadStartBlocked(signal);
|
||||
const context = await this._createRecoveryContext(task);
|
||||
this._throwIfUploadStartBlocked(signal);
|
||||
let retryAdmission = false;
|
||||
const operation = async () => {
|
||||
this._throwIfUploadStartBlocked(signal);
|
||||
const hosterSemaphore = this._getSemaphore(task.hoster);
|
||||
const globalSemaphore = this._getGlobalSemaphore();
|
||||
let hosterSlotAcquired = false;
|
||||
let globalSlotAcquired = false;
|
||||
const releaseSlots = () => {
|
||||
if (globalSlotAcquired && globalSemaphore) globalSemaphore.release();
|
||||
if (hosterSlotAcquired) hosterSemaphore.release();
|
||||
globalSlotAcquired = false;
|
||||
hosterSlotAcquired = false;
|
||||
};
|
||||
const acquireSlots = async () => {
|
||||
await hosterSemaphore.acquire(signal);
|
||||
hosterSlotAcquired = true;
|
||||
this._throwIfUploadStartBlocked(signal);
|
||||
if (globalSemaphore) {
|
||||
await globalSemaphore.acquire(signal);
|
||||
globalSlotAcquired = true;
|
||||
this._throwIfUploadStartBlocked(signal);
|
||||
}
|
||||
};
|
||||
try {
|
||||
await this._waitForSuspectResolution(task.hoster, jobId, signal);
|
||||
this._throwIfUploadStartBlocked(signal);
|
||||
const accountFailed = !!task.accountId && this._failedAccounts.has(task.hoster + ':' + task.accountId);
|
||||
if (accountFailed) {
|
||||
if (this._swapFailedAccount(task, jobId, path.basename(task.file))) {
|
||||
retryAdmission = true;
|
||||
return null;
|
||||
}
|
||||
if (!coordinateAccountFailure) {
|
||||
const error = new Error('Account became unavailable before upload');
|
||||
error.accountUnavailable = true;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const settings = this._getSettings(task.hoster);
|
||||
if (settings.timeIntervalSec > 0) {
|
||||
await this._waitForInterval(task.hoster, settings.timeIntervalSec * 1000, signal, acquireSlots);
|
||||
} else {
|
||||
await acquireSlots();
|
||||
}
|
||||
this._throwIfUploadStartBlocked(signal);
|
||||
if (this._hasForeignSuspectResolution(task.hoster, jobId)) {
|
||||
releaseSlots();
|
||||
await this._waitForSuspectResolution(task.hoster, jobId, signal);
|
||||
this._throwIfUploadStartBlocked(signal);
|
||||
retryAdmission = true;
|
||||
return null;
|
||||
}
|
||||
const lateMemoSuspect = coordinateAccountFailure
|
||||
? this._createSuspectMemoError(task, fileProbe, fileSize)
|
||||
: null;
|
||||
if (lateMemoSuspect) {
|
||||
this._rotLog('suspect-memo-skip', {
|
||||
jobId,
|
||||
hoster: task.hoster,
|
||||
fileName: path.basename(task.file),
|
||||
accountId: task.accountId,
|
||||
fileSize
|
||||
});
|
||||
this._beginSuspectResolution(task.hoster, jobId);
|
||||
throw lateMemoSuspect;
|
||||
}
|
||||
if (!this.uploadScheduleGate.evaluate().allowed) {
|
||||
releaseSlots();
|
||||
await this._waitForUploadSchedule(signal);
|
||||
this._throwIfUploadStartBlocked(signal);
|
||||
retryAdmission = true;
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
this._throwIfUploadStartBlocked(signal);
|
||||
return await this._executeUpload(task, progressCb, signal, throttle, fileProbe, context);
|
||||
} catch (err) {
|
||||
if (coordinateAccountFailure && err && err.suspectReject === true) {
|
||||
this._beginSuspectResolution(task.hoster, jobId);
|
||||
}
|
||||
if (err && err.remoteCommitUncertain === true) {
|
||||
if (context.recoveryClaim) throw context.recoveryClaim.markUncertain(err);
|
||||
throw err;
|
||||
}
|
||||
if (coordinateAccountFailure) await this._coordinateAccountFailure(task, err, signal, jobId);
|
||||
this._throwIfUploadStartBlocked(signal);
|
||||
throw err;
|
||||
}
|
||||
} finally {
|
||||
releaseSlots();
|
||||
}
|
||||
};
|
||||
const result = context.recoveryClaim
|
||||
? await context.recoveryClaim.runExclusive(operation, signal)
|
||||
: await operation();
|
||||
if (retryAdmission) {
|
||||
this._throwIfUploadStartBlocked(signal);
|
||||
continue;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
async _executeUpload(task, progressCb, signal, throttle, fileProbe) {
|
||||
const result = await this._executeUploadUnchecked(task, progressCb, signal, throttle, fileProbe);
|
||||
return assertUploadConfirmation(result, task.hoster);
|
||||
}
|
||||
|
||||
_throwIfUploadStartBlocked(signal) {
|
||||
if (signal && signal.aborted) {
|
||||
const error = new Error('Aborted');
|
||||
error.name = 'AbortError';
|
||||
throw error;
|
||||
}
|
||||
if (this.stopAfterActive) {
|
||||
const error = new Error('Warteschlange angehalten');
|
||||
error.stopAfterActive = true;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
_waitForUploadSchedule(signal) {
|
||||
return this.uploadScheduleGate.wait(signal, () => this._throwIfUploadStartBlocked(signal));
|
||||
}
|
||||
|
||||
_createSuspectMemoError(task, fileProbe, fileSize) {
|
||||
if (!fileProbe || fileProbe.isVideoLike !== true || !task.accountId) return null;
|
||||
if (!this._suspectMemoBlocks(task.hoster, task.accountId, fileSize)) return null;
|
||||
const error = new Error('Bekanntes Größen-Limit auf diesem Account (frühere verdächtige Ablehnung)');
|
||||
error.fileRejected = true;
|
||||
error.suspectReject = true;
|
||||
return error;
|
||||
}
|
||||
|
||||
_beginSuspectResolution(hoster, jobId) {
|
||||
const existing = this._suspectResolutionGates.get(hoster);
|
||||
if (existing) return existing.ownerJobId === jobId;
|
||||
let release;
|
||||
const promise = new Promise(resolve => {
|
||||
release = resolve;
|
||||
});
|
||||
this._suspectResolutionGates.set(hoster, { ownerJobId: jobId, promise, release });
|
||||
return true;
|
||||
}
|
||||
|
||||
_hasForeignSuspectResolution(hoster, jobId) {
|
||||
const gate = this._suspectResolutionGates.get(hoster);
|
||||
return !!gate && gate.ownerJobId !== jobId;
|
||||
}
|
||||
|
||||
_waitForSuspectResolution(hoster, jobId, signal) {
|
||||
const gate = this._suspectResolutionGates.get(hoster);
|
||||
if (!gate || gate.ownerJobId === jobId) return Promise.resolve();
|
||||
if (!signal) return gate.promise;
|
||||
if (signal.aborted) {
|
||||
const error = new Error('Aborted');
|
||||
error.name = 'AbortError';
|
||||
return Promise.reject(error);
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const onAbort = () => {
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
const error = new Error('Aborted');
|
||||
error.name = 'AbortError';
|
||||
reject(error);
|
||||
};
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
gate.promise.then(() => {
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
_endSuspectResolution(hoster, jobId) {
|
||||
const gate = this._suspectResolutionGates.get(hoster);
|
||||
if (!gate || gate.ownerJobId !== jobId) return false;
|
||||
this._suspectResolutionGates.delete(hoster);
|
||||
gate.release();
|
||||
return true;
|
||||
}
|
||||
|
||||
_clearSuspectResolutionGates() {
|
||||
for (const gate of this._suspectResolutionGates.values()) gate.release();
|
||||
this._suspectResolutionGates.clear();
|
||||
}
|
||||
|
||||
async _executeUpload(task, progressCb, signal, throttle, fileProbe, context) {
|
||||
const result = await this._executeUploadUnchecked(task, progressCb, signal, throttle, fileProbe, context);
|
||||
let confirmed;
|
||||
try {
|
||||
confirmed = assertUploadConfirmation(result, task.hoster);
|
||||
} catch (err) {
|
||||
if (context.recoveryClaim) throw context.recoveryClaim.markUncertain(err);
|
||||
throw err;
|
||||
}
|
||||
return confirmed;
|
||||
}
|
||||
|
||||
async _awaitUploadResult(resultPromise, signal) {
|
||||
if (!signal) return resultPromise;
|
||||
const createAbortUncertainty = () => {
|
||||
const error = new Error('Abgebrochen; Remote-Status konnte nicht bestätigt werden');
|
||||
error.name = 'AbortError';
|
||||
error.remoteCommitUncertain = true;
|
||||
error.cancelledAfterUploadStart = true;
|
||||
return error;
|
||||
};
|
||||
if (signal.aborted) {
|
||||
void Promise.resolve(resultPromise).catch(() => {});
|
||||
throw createAbortUncertainty();
|
||||
}
|
||||
let onAbort;
|
||||
const abortPromise = new Promise((resolve, reject) => {
|
||||
onAbort = () => {
|
||||
reject(createAbortUncertainty());
|
||||
};
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
});
|
||||
try {
|
||||
return await Promise.race([resultPromise, abortPromise]);
|
||||
} catch (error) {
|
||||
if (!signal.aborted) throw error;
|
||||
if (error && error.remoteCommitUncertain === true) {
|
||||
error.cancelledAfterUploadStart = true;
|
||||
throw error;
|
||||
}
|
||||
throw createAbortUncertainty();
|
||||
} finally {
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
}
|
||||
}
|
||||
|
||||
async _createRecoveryContext(task) {
|
||||
const fileName = path.basename(task.file);
|
||||
if ((task.hoster === 'vidmoly.me' || task.hoster === 'voe.sx') && task.username) {
|
||||
const accountIdentity = this._recoveryAccountIdentity(task);
|
||||
return {
|
||||
recoveryClaim: this._createRecoveryClaim(task, accountIdentity, fileName),
|
||||
doodApiKey: null
|
||||
};
|
||||
}
|
||||
if (task.hoster === 'doodstream.com' && task.username) {
|
||||
const doodApiKey = await this._resolveDoodstreamApiKey(task);
|
||||
const accountIdentity = doodApiKey || this._recoveryAccountIdentity(task);
|
||||
return {
|
||||
recoveryClaim: this._createRecoveryClaim(task, accountIdentity, fileName),
|
||||
doodApiKey
|
||||
};
|
||||
}
|
||||
if (task.hoster === 'byse.sx' || task.hoster === 'doodstream.com' || task.hoster === 'voe.sx') {
|
||||
const accountIdentity = task.hoster === 'byse.sx'
|
||||
? task.apiKey
|
||||
: (task.hoster === 'doodstream.com' ? task.apiKey : this._recoveryAccountIdentity(task));
|
||||
return {
|
||||
recoveryClaim: this._createRecoveryClaim(task, accountIdentity, fileName),
|
||||
doodApiKey: null
|
||||
};
|
||||
}
|
||||
return { recoveryClaim: null, doodApiKey: null };
|
||||
}
|
||||
|
||||
_recoveryAccountIdentity(task, fallbackIdentity = null) {
|
||||
for (const value of [task.accountId, task.apiKey, fallbackIdentity, task.username]) {
|
||||
if (value !== null && value !== undefined && String(value).trim()) return value;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
_createRecoveryClaim(task, accountIdentity, fileName) {
|
||||
const accountClaim = this._recoveryClaims.forUpload(task.hoster, accountIdentity, fileName);
|
||||
if (task.hoster !== 'doodstream.com' && task.hoster !== 'voe.sx') return accountClaim;
|
||||
const hosterClaim = this._recoveryClaims.forUpload(task.hoster, 'mixed-auth-recovery-boundary', fileName);
|
||||
const modeKey = `${task.hoster}\0${normalizeRecoveryTitle(fileName)}`;
|
||||
let modeState = this._recoveryAuthModes.get(modeKey);
|
||||
if (!modeState) {
|
||||
modeState = new Set();
|
||||
this._recoveryAuthModes.set(modeKey, modeState);
|
||||
}
|
||||
const authMode = task.username ? 'login' : 'api';
|
||||
return {
|
||||
has(code) {
|
||||
return accountClaim.has(code);
|
||||
},
|
||||
reserve(code) {
|
||||
return accountClaim.reserve(code);
|
||||
},
|
||||
markUncertain(error) {
|
||||
hosterClaim.markUncertain(error);
|
||||
return accountClaim.markUncertain(error);
|
||||
},
|
||||
isUncertain() {
|
||||
return hosterClaim.isUncertain() || accountClaim.isUncertain();
|
||||
},
|
||||
runExclusive(operation, signal) {
|
||||
return hosterClaim.runExclusive(
|
||||
async () => {
|
||||
if (Array.from(modeState).some(mode => mode !== authMode)) {
|
||||
const error = new Error('Gemischte Upload-Anmeldungen für denselben Remote-Titel wurden sicher blockiert');
|
||||
error.remoteCommitUncertain = true;
|
||||
error.hosterTransient = true;
|
||||
throw error;
|
||||
}
|
||||
const result = await accountClaim.runExclusive(operation, signal);
|
||||
if (result !== null && result !== undefined) modeState.add(authMode);
|
||||
return result;
|
||||
},
|
||||
signal
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async _executeUploadUnchecked(task, progressCb, signal, throttle, fileProbe, context) {
|
||||
async _executeUploadUnchecked(task, progressCb, signal, throttle, fileProbe) {
|
||||
if (task.hoster === 'vidmoly.me' && task.username) {
|
||||
return this._executeRecoveryAwareLoginUpload(task, VidmolyUploader, progressCb, signal, throttle, context.recoveryClaim);
|
||||
const vidmoly = new VidmolyUploader();
|
||||
await vidmoly.login(task.username, task.password);
|
||||
return vidmoly.upload(task.file, progressCb, signal, throttle);
|
||||
} else if (task.hoster === 'voe.sx' && task.username) {
|
||||
return this._executeRecoveryAwareLoginUpload(task, VoeUploader, progressCb, signal, throttle, context.recoveryClaim);
|
||||
const voe = new VoeUploader();
|
||||
await voe.login(task.username, task.password);
|
||||
return voe.upload(task.file, progressCb, signal, throttle);
|
||||
} else if (task.hoster === 'doodstream.com' && task.username) {
|
||||
// Login-path reliability fix: the web-form upload returns the filecode in
|
||||
// an HTML form that comes back empty for large files (doodstream backend
|
||||
@@ -1615,67 +1254,31 @@ class UploadManager extends EventEmitter {
|
||||
// session ONCE per batch and upload via the official API instead — it
|
||||
// returns result[0].filecode directly and has no empty-form failure mode.
|
||||
// Falls back to the web-form upload if no valid key can be derived.
|
||||
const apiKey = context.doodApiKey;
|
||||
const apiKey = await this._resolveDoodstreamApiKey(task);
|
||||
if (apiKey) {
|
||||
this._rotLog('doodstream-via-api', { accountId: task.accountId, fileName: path.basename(task.file) });
|
||||
return this._executeRecoveryAwareApiUpload('doodstream.com', task.file, apiKey, progressCb, signal, throttle, fileProbe, context.recoveryClaim);
|
||||
return uploadFile('doodstream.com', task.file, apiKey, progressCb, signal, throttle, {
|
||||
doodBaseline: await this._getBaseline('doodstream.com', apiKey, signal)
|
||||
});
|
||||
}
|
||||
this._rotLog('doodstream-via-web', { accountId: task.accountId, fileName: path.basename(task.file) });
|
||||
const dood = new DoodstreamUploader();
|
||||
await dood.login(task.username, task.password);
|
||||
let result;
|
||||
try {
|
||||
result = await this._awaitUploadResult(dood.upload(task.file, progressCb, signal, throttle), signal);
|
||||
} catch (err) {
|
||||
if (context.recoveryClaim && this._isDoodstreamRemoteCommitUncertain(err)) {
|
||||
throw context.recoveryClaim.markUncertain(err);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
if (result && result.file_code && !context.recoveryClaim.reserve(result.file_code)) {
|
||||
const error = new Error('Doodstream Upload lieferte eine bereits zugeordnete Remote-Identität');
|
||||
error.remoteIdentityClaimed = true;
|
||||
throw context.recoveryClaim.markUncertain(error);
|
||||
}
|
||||
return result;
|
||||
return dood.upload(task.file, progressCb, signal, throttle);
|
||||
} else if (task.hoster === 'clouddrop.cc') {
|
||||
const clouddrop = new ClouddropUploader(task.apiKey);
|
||||
return this._awaitUploadResult(clouddrop.upload(task.file, progressCb, signal, throttle), signal);
|
||||
return clouddrop.upload(task.file, progressCb, signal, throttle);
|
||||
} else {
|
||||
if (task.hoster === 'byse.sx' || task.hoster === 'doodstream.com' || task.hoster === 'voe.sx') {
|
||||
return this._executeRecoveryAwareApiUpload(task.hoster, task.file, task.apiKey, progressCb, signal, throttle, fileProbe, context.recoveryClaim);
|
||||
const baselineOpts = {};
|
||||
if (task.hoster === 'byse.sx') {
|
||||
baselineOpts.byseBaseline = await this._getBaseline('byse.sx', task.apiKey, signal);
|
||||
if (fileProbe && fileProbe.ok !== false) baselineOpts.probeIsVideoLike = fileProbe.isVideoLike === true;
|
||||
}
|
||||
return this._awaitUploadResult(uploadFile(task.hoster, task.file, task.apiKey, progressCb, signal, throttle, {}), 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);
|
||||
}
|
||||
}
|
||||
|
||||
async _executeRecoveryAwareLoginUpload(task, UploaderClass, progressCb, signal, throttle, recoveryClaim) {
|
||||
const uploader = new UploaderClass(recoveryClaim);
|
||||
await uploader.login(task.username, task.password);
|
||||
return this._awaitUploadResult(uploader.upload(task.file, progressCb, signal, throttle), signal);
|
||||
}
|
||||
|
||||
async _executeRecoveryAwareApiUpload(hosterName, filePath, apiKey, progressCb, signal, throttle, fileProbe, recoveryClaim) {
|
||||
const options = { recoveryClaim };
|
||||
if (hosterName === 'byse.sx') {
|
||||
options.byseBaseline = await this._getBaseline(hosterName, apiKey, signal);
|
||||
if (fileProbe && fileProbe.ok !== false) options.probeIsVideoLike = fileProbe.isVideoLike === true;
|
||||
} else if (hosterName === 'doodstream.com') {
|
||||
options.doodBaseline = await this._getBaseline(hosterName, apiKey, signal);
|
||||
}
|
||||
return this._awaitUploadResult(uploadFile(hosterName, filePath, apiKey, progressCb, signal, throttle, options), signal);
|
||||
}
|
||||
|
||||
_isDoodstreamRemoteCommitUncertain(error) {
|
||||
if (!error || typeof error !== 'object') return false;
|
||||
if (error.remoteCommitUncertain === true) return true;
|
||||
if (error.accountError === true || error.fileRejected === true) return false;
|
||||
const phase = error.diagnostic && error.diagnostic.phase;
|
||||
if (phase === 'upload-request') return true;
|
||||
return (phase === 'upload-response' || phase === 'upload-result-submit' || phase === 'upload-result')
|
||||
&& (error.hosterTransient === true || error.transientNetwork === true);
|
||||
}
|
||||
|
||||
_getBaseline(hosterName, apiKey, signal) {
|
||||
if (!apiKey) return Promise.resolve(null);
|
||||
const key = `${hosterName}:${apiKey}`;
|
||||
@@ -1692,27 +1295,19 @@ class UploadManager extends EventEmitter {
|
||||
// so a 40-file batch logs in + derives ONCE, not per file). The empty-string
|
||||
// sentinel distinguishes "tried, none" from "not yet tried" (undefined).
|
||||
async _resolveDoodstreamApiKey(task) {
|
||||
const accountId = task.accountId !== null && task.accountId !== undefined
|
||||
? String(task.accountId).normalize('NFKC').trim()
|
||||
: '';
|
||||
const cacheKey = accountId
|
||||
? `account:${accountId}`
|
||||
: `username:${String(task.username || '').normalize('NFKC').trim().toLowerCase()}`;
|
||||
const cacheKey = task.accountId || task.username;
|
||||
const cached = this._doodApiKeyCache.get(cacheKey);
|
||||
if (cached !== undefined) return (await cached) || null;
|
||||
if (cached !== undefined) return cached || null;
|
||||
|
||||
const pending = (async () => {
|
||||
try {
|
||||
const probe = new DoodstreamUploader();
|
||||
await probe.login(task.username, task.password);
|
||||
return (await probe.deriveApiKey()) || '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
})();
|
||||
this._doodApiKeyCache.set(cacheKey, pending);
|
||||
const key = await pending;
|
||||
if (this._doodApiKeyCache.get(cacheKey) === pending) this._doodApiKeyCache.set(cacheKey, key);
|
||||
let key = '';
|
||||
try {
|
||||
const probe = new DoodstreamUploader();
|
||||
await probe.login(task.username, task.password);
|
||||
key = (await probe.deriveApiKey()) || '';
|
||||
} catch {
|
||||
key = '';
|
||||
}
|
||||
this._doodApiKeyCache.set(cacheKey, key);
|
||||
return key || null;
|
||||
}
|
||||
|
||||
@@ -1828,7 +1423,7 @@ class UploadManager extends EventEmitter {
|
||||
});
|
||||
}
|
||||
|
||||
_waitForInterval(hoster, intervalMs, signal, acquireSlots) {
|
||||
_waitForInterval(hoster, intervalMs, signal) {
|
||||
// Serialize interval waits per hoster so concurrent jobs queue up properly
|
||||
const prev = this.intervalLocks[hoster] || Promise.resolve();
|
||||
const next = prev.then(async () => {
|
||||
@@ -1838,7 +1433,6 @@ class UploadManager extends EventEmitter {
|
||||
if (elapsed < intervalMs) {
|
||||
await this._sleep(intervalMs - elapsed, signal);
|
||||
}
|
||||
await acquireSlots();
|
||||
this.lastStartTime[hoster] = Date.now();
|
||||
});
|
||||
this.intervalLocks[hoster] = next.catch(() => {});
|
||||
@@ -1854,18 +1448,16 @@ class UploadManager extends EventEmitter {
|
||||
const addResult = { added: 0, alreadyInBatchJobIds: [] };
|
||||
for (const task of tasks) {
|
||||
// Skip if this job is already being processed (prevent duplicates)
|
||||
if (task.jobId && this._batchJobIds.has(task.jobId)) {
|
||||
if (task.jobId && this.jobAbortControllers.has(task.jobId)) {
|
||||
addResult.alreadyInBatchJobIds.push(task.jobId);
|
||||
continue;
|
||||
}
|
||||
if (task.jobId) this._batchJobIds.add(task.jobId);
|
||||
const fileName = path.basename(task.file);
|
||||
if (!results.has(task.file)) {
|
||||
let size = 0;
|
||||
try { size = fs.statSync(task.file).size; } catch {}
|
||||
results.set(task.file, { name: fileName, fileKey: task.fileKey || null, size, results: [] });
|
||||
results.set(task.file, { name: fileName, size, results: [] });
|
||||
}
|
||||
this._batchTotal++;
|
||||
this._additionalPromises.push(this._runJob(task, results, signal));
|
||||
addResult.added++;
|
||||
}
|
||||
@@ -1886,7 +1478,6 @@ class UploadManager extends EventEmitter {
|
||||
|
||||
finishAfterActive() {
|
||||
this.stopAfterActive = true;
|
||||
this.uploadScheduleGate.wake();
|
||||
}
|
||||
|
||||
cancel() {
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
(function exposeUploadRecovery(root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||
if (root) root.UploadRecovery = api;
|
||||
})(typeof window !== 'undefined' ? window : globalThis, () => {
|
||||
const terminalStatuses = new Set(['done', 'error', 'skipped', 'aborted']);
|
||||
|
||||
function buildTerminalJobSnapshots(summary) {
|
||||
const snapshots = new Map();
|
||||
for (const file of Array.isArray(summary?.files) ? summary.files : []) {
|
||||
for (const result of Array.isArray(file?.results) ? file.results : []) {
|
||||
const jobId = typeof result?.jobId === 'string' ? result.jobId : '';
|
||||
const status = typeof result?.status === 'string' ? result.status : '';
|
||||
if (!jobId || !terminalStatuses.has(status)) continue;
|
||||
const uploadResult = result.download_url || result.embed_url || result.file_code
|
||||
? {
|
||||
download_url: result.download_url || null,
|
||||
embed_url: result.embed_url || null,
|
||||
file_code: result.file_code || null
|
||||
}
|
||||
: null;
|
||||
snapshots.set(jobId, {
|
||||
jobId,
|
||||
status,
|
||||
error: result.error || null,
|
||||
failureDetails: result.failureDetails || null,
|
||||
...(result.remoteCommitUncertain === true ? { remoteCommitUncertain: true } : {}),
|
||||
result: uploadResult
|
||||
});
|
||||
}
|
||||
}
|
||||
return Array.from(snapshots.values());
|
||||
}
|
||||
|
||||
function buildFailedUploadSummary(tasks, message, now = Date.now()) {
|
||||
const files = new Map();
|
||||
for (const [index, task] of (Array.isArray(tasks) ? tasks : []).entries()) {
|
||||
if (!task || typeof task !== 'object') continue;
|
||||
const filePath = typeof task.file === 'string' ? task.file : '';
|
||||
const fileName = filePath.split(/[\\/]/).pop() || `upload-${index + 1}`;
|
||||
const key = filePath || `${fileName}\0${index}`;
|
||||
if (!files.has(key)) files.set(key, {
|
||||
name: fileName,
|
||||
...(typeof task.fileKey === 'string' && task.fileKey ? { fileKey: task.fileKey } : {}),
|
||||
size: 0,
|
||||
results: []
|
||||
});
|
||||
files.get(key).results.push({
|
||||
jobId: typeof task.jobId === 'string' ? task.jobId : '',
|
||||
hoster: typeof task.hoster === 'string' ? task.hoster : '',
|
||||
status: 'error',
|
||||
error: message,
|
||||
failureDetails: null,
|
||||
download_url: null,
|
||||
embed_url: null,
|
||||
file_code: null
|
||||
});
|
||||
}
|
||||
const grouped = Array.from(files.values());
|
||||
const failed = grouped.reduce((count, file) => count + file.results.length, 0);
|
||||
return {
|
||||
id: `start-error-${now}`,
|
||||
timestamp: new Date(now).toISOString(),
|
||||
total: failed,
|
||||
succeeded: 0,
|
||||
failed,
|
||||
skipped: 0,
|
||||
files: grouped,
|
||||
error: message
|
||||
};
|
||||
}
|
||||
|
||||
function getRecoveryOutcome(job, recovery) {
|
||||
const status = typeof job?.status === 'string' ? job.status : 'preview';
|
||||
const jobId = typeof job?.id === 'string' ? job.id : '';
|
||||
const terminal = Array.isArray(recovery?.terminalJobs)
|
||||
? recovery.terminalJobs.find(entry => entry?.jobId === jobId && terminalStatuses.has(entry.status))
|
||||
: null;
|
||||
if (terminal) {
|
||||
return {
|
||||
status: terminal.status,
|
||||
error: terminal.error || null,
|
||||
failureDetails: terminal.failureDetails || null,
|
||||
...(terminal.remoteCommitUncertain === true ? { remoteCommitUncertain: true } : {}),
|
||||
result: terminal.result || null,
|
||||
...(recovery?.historyPending === true ? { historyPending: true } : {}),
|
||||
interrupted: false
|
||||
};
|
||||
}
|
||||
const interruptedIds = new Set(Array.isArray(recovery?.jobIds) ? recovery.jobIds.filter(Boolean) : []);
|
||||
return { status, interrupted: interruptedIds.has(jobId) && !terminalStatuses.has(status) };
|
||||
}
|
||||
|
||||
function resolveRemoteCommitUncertainty(previous, progress) {
|
||||
if (progress?.remoteCommitUncertain === true) return true;
|
||||
if (progress?.status === 'done') return false;
|
||||
return previous === true;
|
||||
}
|
||||
|
||||
return { buildFailedUploadSummary, buildTerminalJobSnapshots, getRecoveryOutcome, resolveRemoteCommitUncertainty };
|
||||
});
|
||||
@@ -1,167 +0,0 @@
|
||||
;(function initUploadSchedule(root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = api;
|
||||
if (root) root.UploadSchedule = api;
|
||||
})(typeof window !== 'undefined' ? window : globalThis, function createUploadSchedule() {
|
||||
const WEEKDAY_ORDER = Object.freeze([1, 2, 3, 4, 5, 6, 0]);
|
||||
const DEFAULT_UPLOAD_SCHEDULE = Object.freeze({
|
||||
enabled: false,
|
||||
weekdays: Object.freeze([...WEEKDAY_ORDER]),
|
||||
start: '00:00',
|
||||
end: '23:59'
|
||||
});
|
||||
|
||||
function normalizeTime(value) {
|
||||
const text = typeof value === 'string' ? value.trim() : '';
|
||||
return /^(?:[01]\d|2[0-3]):[0-5]\d$/.test(text) ? text : '';
|
||||
}
|
||||
|
||||
function timeMinutes(value) {
|
||||
const normalized = normalizeTime(value);
|
||||
if (!normalized) return null;
|
||||
const [hours, minutes] = normalized.split(':').map(Number);
|
||||
return hours * 60 + minutes;
|
||||
}
|
||||
|
||||
function normalizeUploadSchedule(value) {
|
||||
const source = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
||||
const rawWeekdays = Array.isArray(source.weekdays) ? source.weekdays : DEFAULT_UPLOAD_SCHEDULE.weekdays;
|
||||
const selected = new Set(rawWeekdays.map(Number).filter(day => Number.isInteger(day) && day >= 0 && day <= 6));
|
||||
return {
|
||||
enabled: source.enabled === true,
|
||||
weekdays: WEEKDAY_ORDER.filter(day => selected.has(day)),
|
||||
start: normalizeTime(source.start ?? DEFAULT_UPLOAD_SCHEDULE.start),
|
||||
end: normalizeTime(source.end ?? DEFAULT_UPLOAD_SCHEDULE.end)
|
||||
};
|
||||
}
|
||||
|
||||
function scheduleValidity(schedule) {
|
||||
const startMinutes = timeMinutes(schedule.start);
|
||||
const endMinutes = timeMinutes(schedule.end);
|
||||
if (schedule.weekdays.length === 0) return { valid: false, reason: 'weekdays', startMinutes, endMinutes };
|
||||
if (startMinutes === null || endMinutes === null) return { valid: false, reason: 'time', startMinutes, endMinutes };
|
||||
if (startMinutes === endMinutes) return { valid: false, reason: 'equal-times', startMinutes, endMinutes };
|
||||
return { valid: true, reason: null, startMinutes, endMinutes };
|
||||
}
|
||||
|
||||
function nextStartDate(schedule, now, startMinutes) {
|
||||
const selected = new Set(schedule.weekdays);
|
||||
for (let dayOffset = 0; dayOffset <= 7; dayOffset++) {
|
||||
const candidate = new Date(now.getFullYear(), now.getMonth(), now.getDate() + dayOffset, 0, 0, 0, 0);
|
||||
if (!selected.has(candidate.getDay())) continue;
|
||||
candidate.setMinutes(startMinutes);
|
||||
if (candidate.getTime() > now.getTime()) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function evaluateUploadSchedule(value, now = new Date()) {
|
||||
const schedule = normalizeUploadSchedule(value);
|
||||
const current = now instanceof Date ? new Date(now.getTime()) : new Date(now);
|
||||
if (Number.isNaN(current.getTime())) throw new TypeError('Invalid schedule evaluation date');
|
||||
if (!schedule.enabled) {
|
||||
return { schedule, enabled: false, valid: true, allowed: true, reason: null, nextStart: null };
|
||||
}
|
||||
const validity = scheduleValidity(schedule);
|
||||
if (!validity.valid) {
|
||||
return { schedule, enabled: true, valid: false, allowed: false, reason: validity.reason, nextStart: null };
|
||||
}
|
||||
const currentMinutes = current.getHours() * 60 + current.getMinutes();
|
||||
const currentDay = current.getDay();
|
||||
const selected = new Set(schedule.weekdays);
|
||||
const { startMinutes, endMinutes } = validity;
|
||||
const overnight = startMinutes > endMinutes;
|
||||
const previousDay = (currentDay + 6) % 7;
|
||||
const allowed = overnight
|
||||
? (selected.has(currentDay) && currentMinutes >= startMinutes) || (selected.has(previousDay) && currentMinutes < endMinutes)
|
||||
: selected.has(currentDay) && currentMinutes >= startMinutes && currentMinutes < endMinutes;
|
||||
return {
|
||||
schedule,
|
||||
enabled: true,
|
||||
valid: true,
|
||||
allowed,
|
||||
reason: allowed ? null : 'closed',
|
||||
nextStart: allowed ? null : nextStartDate(schedule, current, startMinutes)
|
||||
};
|
||||
}
|
||||
|
||||
function createAbortError() {
|
||||
const error = new Error('Aborted');
|
||||
error.name = 'AbortError';
|
||||
return error;
|
||||
}
|
||||
|
||||
function createUploadScheduleGate(initial, options = {}) {
|
||||
const now = typeof options.now === 'function' ? options.now : () => new Date();
|
||||
const setTimer = typeof options.setTimeout === 'function' ? options.setTimeout : setTimeout;
|
||||
const clearTimer = typeof options.clearTimeout === 'function' ? options.clearTimeout : clearTimeout;
|
||||
let schedule = normalizeUploadSchedule(initial);
|
||||
let disposed = false;
|
||||
const waiters = new Set();
|
||||
|
||||
const wake = () => {
|
||||
for (const waiter of [...waiters]) waiter();
|
||||
};
|
||||
|
||||
const waitForWake = (state, signal) => new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
let timer = null;
|
||||
const finish = (error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (timer !== null) clearTimer(timer);
|
||||
waiters.delete(onWake);
|
||||
signal?.removeEventListener('abort', onAbort);
|
||||
if (error) reject(error);
|
||||
else resolve();
|
||||
};
|
||||
const onWake = () => finish();
|
||||
const onAbort = () => finish(createAbortError());
|
||||
if (disposed || signal?.aborted) {
|
||||
finish(createAbortError());
|
||||
return;
|
||||
}
|
||||
waiters.add(onWake);
|
||||
signal?.addEventListener('abort', onAbort, { once: true });
|
||||
if (state.nextStart) {
|
||||
const delay = Math.max(1, Math.min(2147483647, state.nextStart.getTime() - now().getTime()));
|
||||
timer = setTimer(onWake, delay);
|
||||
}
|
||||
});
|
||||
|
||||
return Object.freeze({
|
||||
evaluate() {
|
||||
return evaluateUploadSchedule(schedule, now());
|
||||
},
|
||||
update(value) {
|
||||
schedule = normalizeUploadSchedule(value);
|
||||
wake();
|
||||
return this.evaluate();
|
||||
},
|
||||
async wait(signal, check) {
|
||||
while (true) {
|
||||
if (typeof check === 'function') check();
|
||||
if (disposed || signal?.aborted) throw createAbortError();
|
||||
const state = evaluateUploadSchedule(schedule, now());
|
||||
if (state.allowed) return state;
|
||||
await waitForWake(state, signal);
|
||||
}
|
||||
},
|
||||
wake,
|
||||
dispose() {
|
||||
disposed = true;
|
||||
wake();
|
||||
},
|
||||
get schedule() {
|
||||
return normalizeUploadSchedule(schedule);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
DEFAULT_UPLOAD_SCHEDULE,
|
||||
normalizeUploadSchedule,
|
||||
evaluateUploadSchedule,
|
||||
createUploadScheduleGate
|
||||
};
|
||||
});
|
||||
@@ -1,28 +0,0 @@
|
||||
function createUploadStartReservation() {
|
||||
let active = null;
|
||||
|
||||
return {
|
||||
acquire() {
|
||||
if (active) return null;
|
||||
const state = { cancelled: false, released: false };
|
||||
const lease = {
|
||||
isCancelled: () => state.cancelled,
|
||||
release() {
|
||||
if (state.released) return;
|
||||
state.released = true;
|
||||
if (active && active.lease === lease) active = null;
|
||||
}
|
||||
};
|
||||
active = { lease, state };
|
||||
return lease;
|
||||
},
|
||||
cancel() {
|
||||
if (!active) return false;
|
||||
active.state.cancelled = true;
|
||||
return true;
|
||||
},
|
||||
isActive: () => active !== null
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createUploadStartReservation };
|
||||
+99
-224
@@ -2,8 +2,6 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const { request } = require('undici');
|
||||
const { createTransportError, sanitizeRemoteText } = require('./hoster-transport-error');
|
||||
const { normalizeRecoveryTitle } = require('./hosters');
|
||||
|
||||
const BASE_URL = 'https://vidmoly.me';
|
||||
const USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';
|
||||
@@ -15,9 +13,8 @@ const RESULT_POLL_DELAY_MS = 2000;
|
||||
* XFileSharing-based upload for Vidmoly (login + form upload)
|
||||
*/
|
||||
class VidmolyUploader {
|
||||
constructor(recoveryClaim = null) {
|
||||
constructor() {
|
||||
this.cookies = new Map();
|
||||
this.recoveryClaim = recoveryClaim;
|
||||
}
|
||||
|
||||
_cookieHeader() {
|
||||
@@ -133,51 +130,14 @@ class VidmolyUploader {
|
||||
* removed. Returns an XFS-style session token + a transit-server URL.
|
||||
*/
|
||||
async getUploadParams() {
|
||||
const endpoint = `${BASE_URL}/api/upload/config`;
|
||||
let res;
|
||||
try {
|
||||
res = await this._fetch(endpoint);
|
||||
} catch {
|
||||
throw createTransportError('Vidmoly: Upload-Konfiguration konnte nicht geladen werden', {
|
||||
phase: 'upload-config',
|
||||
endpoint,
|
||||
retryable: true,
|
||||
transientNetwork: true
|
||||
});
|
||||
}
|
||||
const res = await this._fetch(`${BASE_URL}/api/upload/config`);
|
||||
const body = await res.text();
|
||||
const contentType = res.headers && typeof res.headers.get === 'function'
|
||||
? res.headers.get('content-type')
|
||||
: null;
|
||||
if (res.status < 200 || res.status >= 300) {
|
||||
throw createTransportError('Vidmoly: Upload-Konfiguration konnte nicht geladen werden', {
|
||||
phase: 'upload-config',
|
||||
endpoint,
|
||||
httpStatus: res.status,
|
||||
contentType,
|
||||
body,
|
||||
retryable: res.status === 429 || res.status >= 500,
|
||||
transientNetwork: res.status >= 500
|
||||
});
|
||||
}
|
||||
let payload = null;
|
||||
try { payload = JSON.parse(body); } catch {
|
||||
throw createTransportError('Vidmoly: Upload-Konfiguration war kein JSON', {
|
||||
phase: 'upload-config',
|
||||
endpoint,
|
||||
httpStatus: res.status,
|
||||
contentType,
|
||||
body
|
||||
});
|
||||
throw new Error('Vidmoly: /api/upload/config lieferte kein JSON — evtl. nicht eingeloggt?');
|
||||
}
|
||||
if (!payload || !payload.sess_id || !payload.upload_url) {
|
||||
throw createTransportError('Vidmoly: Upload-Konfiguration war unvollständig', {
|
||||
phase: 'upload-config',
|
||||
endpoint,
|
||||
httpStatus: res.status,
|
||||
contentType,
|
||||
body
|
||||
});
|
||||
throw new Error('Vidmoly: /api/upload/config unvollständig (sess_id/upload_url fehlt)');
|
||||
}
|
||||
return {
|
||||
uploadUrl: payload.upload_url,
|
||||
@@ -194,14 +154,7 @@ class VidmolyUploader {
|
||||
async upload(filePath, onProgress, signal, throttle) {
|
||||
const fileName = path.basename(filePath);
|
||||
const fileSize = fs.statSync(filePath).size;
|
||||
let baselineCodes = null;
|
||||
let baselineError = null;
|
||||
try {
|
||||
baselineCodes = await this._captureVmFileCodes();
|
||||
} catch (err) {
|
||||
if (signal && signal.aborted) throw err;
|
||||
baselineError = err;
|
||||
}
|
||||
const baselineCodes = await this._captureVmFileCodes();
|
||||
|
||||
const { uploadUrl, params, fileFieldName } = await this.getUploadParams();
|
||||
|
||||
@@ -258,34 +211,21 @@ class VidmolyUploader {
|
||||
const targetUrl = uploadUrl + (uploadUrl.includes('?') ? '&' : '?') + 'X-Progress-ID=' + progressId;
|
||||
|
||||
// Browsers don't send vidmoly.me cookies across origins, so we don't either.
|
||||
let uploadResponse;
|
||||
try {
|
||||
uploadResponse = await request(targetUrl, {
|
||||
method: 'POST',
|
||||
body: generate(),
|
||||
signal,
|
||||
headers: {
|
||||
'User-Agent': USER_AGENT,
|
||||
'Accept': '*/*',
|
||||
'Origin': BASE_URL,
|
||||
'Referer': `${BASE_URL}/`,
|
||||
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
||||
'Content-Length': String(totalSize)
|
||||
},
|
||||
headersTimeout: UPLOAD_TIMEOUT,
|
||||
bodyTimeout: UPLOAD_TIMEOUT
|
||||
});
|
||||
} catch (err) {
|
||||
const error = signal && signal.aborted ? err : createTransportError('Vidmoly Upload konnte nicht übertragen werden', {
|
||||
phase: 'upload-request',
|
||||
endpoint: targetUrl,
|
||||
retryable: true,
|
||||
transientNetwork: true
|
||||
});
|
||||
throw this._markRemoteCommitUncertain(error);
|
||||
}
|
||||
|
||||
const { body, statusCode, headers } = uploadResponse;
|
||||
const { body, statusCode, headers } = await request(targetUrl, {
|
||||
method: 'POST',
|
||||
body: generate(),
|
||||
signal,
|
||||
headers: {
|
||||
'User-Agent': USER_AGENT,
|
||||
'Accept': '*/*',
|
||||
'Origin': BASE_URL,
|
||||
'Referer': `${BASE_URL}/`,
|
||||
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
||||
'Content-Length': String(totalSize)
|
||||
},
|
||||
headersTimeout: UPLOAD_TIMEOUT,
|
||||
bodyTimeout: UPLOAD_TIMEOUT
|
||||
});
|
||||
|
||||
this._parseCookiesFromHeaders(headers || {});
|
||||
|
||||
@@ -296,34 +236,13 @@ class VidmolyUploader {
|
||||
// Always drain the original body to prevent connection leak
|
||||
try { await body.text(); } catch {}
|
||||
if (location) {
|
||||
try {
|
||||
const resultRes = await this._fetch(new URL(location, uploadUrl).href);
|
||||
resultHtml = await resultRes.text();
|
||||
} catch (err) {
|
||||
throw this._markRemoteCommitUncertain(err);
|
||||
}
|
||||
const resultRes = await this._fetch(new URL(location, uploadUrl).href);
|
||||
resultHtml = await resultRes.text();
|
||||
} else {
|
||||
resultHtml = '';
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
resultHtml = await body.text();
|
||||
} catch (err) {
|
||||
throw this._markRemoteCommitUncertain(err);
|
||||
}
|
||||
}
|
||||
|
||||
if (statusCode >= 400) {
|
||||
const error = createTransportError('Vidmoly Upload fehlgeschlagen', {
|
||||
phase: 'upload-response',
|
||||
endpoint: targetUrl,
|
||||
httpStatus: statusCode,
|
||||
contentType: headers && headers['content-type'],
|
||||
body: resultHtml,
|
||||
retryable: statusCode === 429 || statusCode >= 500,
|
||||
transientNetwork: statusCode >= 500
|
||||
});
|
||||
throw statusCode >= 500 ? this._markRemoteCommitUncertain(error) : error;
|
||||
resultHtml = await body.text();
|
||||
}
|
||||
|
||||
// Try JSON first. The current transit server returns
|
||||
@@ -348,69 +267,43 @@ class VidmolyUploader {
|
||||
if (urls) return urls;
|
||||
}
|
||||
if (json.status && !/ok/i.test(json.status) && json.msg) {
|
||||
throw createTransportError(`Vidmoly Upload abgelehnt: ${sanitizeRemoteText(json.msg)}`, {
|
||||
phase: 'upload-result',
|
||||
endpoint: targetUrl,
|
||||
httpStatus: statusCode,
|
||||
contentType: 'application/json',
|
||||
body: resultHtml
|
||||
});
|
||||
throw new Error(`Vidmoly Upload abgelehnt: ${json.msg}`);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err && err.diagnostic) throw err;
|
||||
if (err && /Vidmoly Upload abgelehnt/.test(err.message)) throw err;
|
||||
}
|
||||
|
||||
try {
|
||||
return this._parseUploadResult(resultHtml);
|
||||
} catch (primaryErr) {
|
||||
if (primaryErr && primaryErr.remoteIdentityClaimed === true) throw primaryErr;
|
||||
if (baselineCodes) {
|
||||
try {
|
||||
const fallback = await this._resolveUploadedFileFromVmApi(fileName, baselineCodes, signal);
|
||||
if (fallback) return fallback;
|
||||
} catch (err) {
|
||||
throw this._markRemoteCommitUncertain(err);
|
||||
}
|
||||
}
|
||||
if (baselineError) {
|
||||
baselineError.hosterTransient = true;
|
||||
throw this._markRemoteCommitUncertain(baselineError);
|
||||
}
|
||||
throw this._markRemoteCommitUncertain(primaryErr);
|
||||
const fallback = await this._resolveUploadedFileFromVmApi(fileName, baselineCodes, signal);
|
||||
if (fallback) return fallback;
|
||||
throw primaryErr;
|
||||
}
|
||||
}
|
||||
|
||||
_normalizeTitle(value) {
|
||||
return normalizeRecoveryTitle(value);
|
||||
return String(value || '')
|
||||
.toLowerCase()
|
||||
.normalize('NFKD')
|
||||
.replace(/[^a-z0-9]+/g, '');
|
||||
}
|
||||
|
||||
_markRemoteCommitUncertain(error) {
|
||||
if (this.recoveryClaim && typeof this.recoveryClaim.markUncertain === 'function') {
|
||||
return this.recoveryClaim.markUncertain(error);
|
||||
}
|
||||
const uncertainError = error && typeof error === 'object'
|
||||
? error
|
||||
: new Error('Vidmoly Upload-Ergebnis ist unsicher');
|
||||
uncertainError.remoteCommitUncertain = true;
|
||||
uncertainError.hosterTransient = true;
|
||||
return uncertainError;
|
||||
_scoreVmCandidate(file, expectedTitle) {
|
||||
if (!file || !file.file_code) return -1;
|
||||
if (!expectedTitle) return 0;
|
||||
|
||||
const title = this._normalizeTitle(file.full_title || file.title_txt || '');
|
||||
if (!title) return -1;
|
||||
if (title === expectedTitle) return 120;
|
||||
if (title.startsWith(expectedTitle) || expectedTitle.startsWith(title)) return 90;
|
||||
if (title.includes(expectedTitle) || expectedTitle.includes(title)) return 70;
|
||||
return 0;
|
||||
}
|
||||
|
||||
_buildUrlsFromCode(fileCode, phase = 'upload-result') {
|
||||
_buildUrlsFromCode(fileCode) {
|
||||
const code = String(fileCode || '').trim();
|
||||
if (!code) return null;
|
||||
if (this.recoveryClaim
|
||||
&& typeof this.recoveryClaim.reserve === 'function'
|
||||
&& !this.recoveryClaim.reserve(code)) {
|
||||
const error = createTransportError('Vidmoly Upload-Ergebnis ist bereits einem anderen Upload zugeordnet', {
|
||||
phase,
|
||||
endpoint: BASE_URL,
|
||||
retryable: true,
|
||||
hosterTransient: true
|
||||
});
|
||||
error.remoteIdentityClaimed = true;
|
||||
throw this._markRemoteCommitUncertain(error);
|
||||
}
|
||||
|
||||
return {
|
||||
download_url: `${BASE_URL}/w/${code}`,
|
||||
@@ -420,15 +313,19 @@ class VidmolyUploader {
|
||||
}
|
||||
|
||||
async _captureVmFileCodes() {
|
||||
const files = await this._fetchVmList('recovery-baseline');
|
||||
return new Set(
|
||||
files
|
||||
.map((f) => String(f.file_code || '').trim())
|
||||
.filter(Boolean)
|
||||
);
|
||||
try {
|
||||
const files = await this._fetchVmList();
|
||||
return new Set(
|
||||
files
|
||||
.map((f) => String(f.file_code || '').trim())
|
||||
.filter(Boolean)
|
||||
);
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
async _fetchVmList(phase = 'recovery-poll') {
|
||||
async _fetchVmList() {
|
||||
const params = new URLSearchParams({
|
||||
op: 'vm',
|
||||
api: 'list',
|
||||
@@ -439,46 +336,14 @@ class VidmolyUploader {
|
||||
fld_id: '0'
|
||||
});
|
||||
|
||||
const endpoint = `${BASE_URL}/?${params.toString()}`;
|
||||
let res;
|
||||
try {
|
||||
res = await this._fetch(endpoint);
|
||||
} catch {
|
||||
throw createTransportError('Vidmoly: Dateiliste konnte nicht geladen werden', {
|
||||
phase,
|
||||
endpoint,
|
||||
retryable: true,
|
||||
transientNetwork: true
|
||||
});
|
||||
}
|
||||
const res = await this._fetch(`${BASE_URL}/?${params.toString()}`);
|
||||
const body = await res.text();
|
||||
const contentType = res.headers && typeof res.headers.get === 'function'
|
||||
? res.headers.get('content-type')
|
||||
: null;
|
||||
|
||||
if (res.status < 200 || res.status >= 300) {
|
||||
throw createTransportError('Vidmoly: Dateiliste konnte nicht geladen werden', {
|
||||
phase,
|
||||
endpoint,
|
||||
httpStatus: res.status,
|
||||
contentType,
|
||||
body,
|
||||
retryable: res.status === 429 || res.status >= 500,
|
||||
transientNetwork: res.status >= 500
|
||||
});
|
||||
}
|
||||
|
||||
let payload;
|
||||
try {
|
||||
payload = JSON.parse(body);
|
||||
} catch {
|
||||
throw createTransportError('Vidmoly: Dateiliste war kein JSON', {
|
||||
phase,
|
||||
endpoint,
|
||||
httpStatus: res.status,
|
||||
contentType,
|
||||
body
|
||||
});
|
||||
throw new Error('Vidmoly VM API lieferte kein JSON');
|
||||
}
|
||||
|
||||
if (!payload || !Array.isArray(payload.files)) return [];
|
||||
@@ -486,10 +351,7 @@ class VidmolyUploader {
|
||||
}
|
||||
|
||||
async _resolveUploadedFileFromVmApi(fileName, baselineCodes, signal) {
|
||||
if (!(baselineCodes instanceof Set)) return null;
|
||||
const expectedTitle = this._normalizeTitle(path.parse(fileName).name);
|
||||
let lastPollError = null;
|
||||
let successfulPoll = false;
|
||||
|
||||
for (let attempt = 0; attempt < RESULT_POLL_ATTEMPTS; attempt++) {
|
||||
if (signal && signal.aborted) {
|
||||
@@ -500,27 +362,46 @@ class VidmolyUploader {
|
||||
|
||||
let files = [];
|
||||
try {
|
||||
files = await this._fetchVmList('recovery-poll');
|
||||
successfulPoll = true;
|
||||
} catch (err) {
|
||||
if (err && err.name === 'AbortError') throw err;
|
||||
lastPollError = err;
|
||||
files = await this._fetchVmList();
|
||||
} catch {
|
||||
files = [];
|
||||
}
|
||||
|
||||
const withCode = files.filter((f) => f && typeof f.file_code === 'string' && f.file_code.trim());
|
||||
const newFiles = withCode.filter((f) => !baselineCodes.has(f.file_code.trim()));
|
||||
const matches = newFiles
|
||||
.filter((file) => {
|
||||
const title = this._normalizeTitle(file.full_title || file.title_txt || '');
|
||||
return expectedTitle && title === expectedTitle;
|
||||
})
|
||||
.filter((file) => !this.recoveryClaim
|
||||
|| typeof this.recoveryClaim.has !== 'function'
|
||||
|| !this.recoveryClaim.has(file.file_code.trim()));
|
||||
const newFiles = withCode.filter((f) => !baselineCodes.has(f.file_code));
|
||||
|
||||
if (matches.length > 1) return null;
|
||||
if (matches.length === 1) {
|
||||
return this._buildUrlsFromCode(matches[0].file_code, 'recovery-poll');
|
||||
if (newFiles.length > 0) {
|
||||
let best = null;
|
||||
let bestScore = -1;
|
||||
|
||||
for (const file of newFiles) {
|
||||
const score = this._scoreVmCandidate(file, expectedTitle);
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
best = file;
|
||||
}
|
||||
}
|
||||
|
||||
if (best && bestScore > 0) {
|
||||
return this._buildUrlsFromCode(best.file_code);
|
||||
}
|
||||
}
|
||||
|
||||
if (expectedTitle) {
|
||||
let bestMatch = null;
|
||||
let bestScore = -1;
|
||||
|
||||
for (const file of withCode) {
|
||||
const score = this._scoreVmCandidate(file, expectedTitle);
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
bestMatch = file;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestMatch && bestScore >= 90) {
|
||||
return this._buildUrlsFromCode(bestMatch.file_code);
|
||||
}
|
||||
}
|
||||
|
||||
if (attempt < RESULT_POLL_ATTEMPTS - 1) {
|
||||
@@ -528,7 +409,6 @@ class VidmolyUploader {
|
||||
}
|
||||
}
|
||||
|
||||
if (!successfulPoll && lastPollError) throw lastPollError;
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -617,23 +497,18 @@ class VidmolyUploader {
|
||||
if (codeInPage) file_code = codeInPage[1];
|
||||
}
|
||||
|
||||
if (file_code) {
|
||||
const urls = this._buildUrlsFromCode(file_code);
|
||||
if (!download_url) download_url = urls.download_url;
|
||||
if (!embed_url) embed_url = urls.embed_url;
|
||||
// Build URLs from file_code
|
||||
if (file_code && !download_url) {
|
||||
download_url = `${BASE_URL}/w/${file_code}`;
|
||||
}
|
||||
if (file_code && !embed_url) {
|
||||
embed_url = `${BASE_URL}/embed-${file_code}.html`;
|
||||
}
|
||||
|
||||
if (!download_url && !file_code) {
|
||||
const errMatch = html.match(/class=["']err["'][^>]*>([^<]+)/i);
|
||||
const errMsg = errMatch ? errMatch[1].trim() : 'Kein Download-Link gefunden';
|
||||
throw createTransportError(`Vidmoly Upload-Ergebnis: ${sanitizeRemoteText(errMsg)}`, {
|
||||
phase: 'upload-result',
|
||||
endpoint: BASE_URL,
|
||||
contentType: 'text/html',
|
||||
body: html,
|
||||
hosterTransient: true,
|
||||
retryable: true
|
||||
});
|
||||
throw new Error(`Vidmoly Upload-Ergebnis: ${errMsg}`);
|
||||
}
|
||||
|
||||
return { download_url, embed_url, file_code };
|
||||
|
||||
+82
-225
@@ -2,8 +2,6 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const { request } = require('undici');
|
||||
const { createTransportError, sanitizeRemoteText } = require('./hoster-transport-error');
|
||||
const { normalizeRecoveryTitle } = require('./hosters');
|
||||
|
||||
const BASE_URL = 'https://voe.sx';
|
||||
const USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';
|
||||
@@ -16,9 +14,8 @@ const RESULT_POLL_DELAY_MS = 2000;
|
||||
* Fallback when API-based upload fails or is unavailable.
|
||||
*/
|
||||
class VoeUploader {
|
||||
constructor(recoveryClaim = null) {
|
||||
constructor() {
|
||||
this.cookies = new Map();
|
||||
this.recoveryClaim = recoveryClaim;
|
||||
}
|
||||
|
||||
_cookieHeader() {
|
||||
@@ -163,58 +160,21 @@ class VoeUploader {
|
||||
* Returns { server: "https://cdn-xxx.edgeon-bandwidth.com/node/u/01", session_id: "..." }
|
||||
*/
|
||||
async _getDeliveryNode(csrfToken) {
|
||||
const endpoint = `${BASE_URL}/engine/delivery-node`;
|
||||
let res;
|
||||
try {
|
||||
res = await this._fetch(endpoint, {
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': csrfToken,
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
throw createTransportError('VOE: Upload-Server konnte nicht geladen werden', {
|
||||
phase: 'upload-server',
|
||||
endpoint,
|
||||
retryable: true,
|
||||
transientNetwork: true
|
||||
});
|
||||
}
|
||||
const res = await this._fetch(`${BASE_URL}/engine/delivery-node`, {
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': csrfToken,
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
const body = await res.text();
|
||||
const contentType = res.headers && typeof res.headers.get === 'function'
|
||||
? res.headers.get('content-type')
|
||||
: null;
|
||||
if (res.status < 200 || res.status >= 300) {
|
||||
throw createTransportError('VOE: Upload-Server konnte nicht geladen werden', {
|
||||
phase: 'upload-server',
|
||||
endpoint,
|
||||
httpStatus: res.status,
|
||||
contentType,
|
||||
body,
|
||||
retryable: res.status === 429 || res.status >= 500,
|
||||
transientNetwork: res.status >= 500
|
||||
});
|
||||
}
|
||||
let data;
|
||||
try { data = JSON.parse(body); } catch {
|
||||
throw createTransportError('VOE: Upload-Server Antwort war kein JSON', {
|
||||
phase: 'upload-server',
|
||||
endpoint,
|
||||
httpStatus: res.status,
|
||||
contentType,
|
||||
body
|
||||
});
|
||||
throw new Error(`VOE: Upload-Server Antwort war kein JSON: ${body.slice(0, 200)}`);
|
||||
}
|
||||
|
||||
if (!data || !data.success || !data.server) {
|
||||
throw createTransportError('VOE: Kein Upload-Server erhalten von delivery-node', {
|
||||
phase: 'upload-server',
|
||||
endpoint,
|
||||
httpStatus: res.status,
|
||||
contentType,
|
||||
body
|
||||
});
|
||||
throw new Error('VOE: Kein Upload-Server erhalten von delivery-node');
|
||||
}
|
||||
|
||||
return { uploadServer: data.server, sessionId: data.session_id || '' };
|
||||
@@ -223,54 +183,26 @@ class VoeUploader {
|
||||
/**
|
||||
* List current files via VOE API (for result polling fallback)
|
||||
*/
|
||||
async _fetchFileList(phase = 'recovery-poll') {
|
||||
const endpoint = `${BASE_URL}/api2/my-files?sort=date&order=dsc&page=1&per_page=50`;
|
||||
let res;
|
||||
async _fetchFileList() {
|
||||
try {
|
||||
res = await this._fetch(endpoint);
|
||||
const res = await this._fetch(`${BASE_URL}/api2/my-files?sort=date&order=dsc&page=1&per_page=50`);
|
||||
const body = await res.text();
|
||||
const data = JSON.parse(body);
|
||||
if (data && Array.isArray(data.data)) return data.data;
|
||||
if (data && Array.isArray(data.files)) return data.files;
|
||||
return [];
|
||||
} catch {
|
||||
throw createTransportError('VOE: Dateiliste konnte nicht geladen werden', {
|
||||
phase,
|
||||
endpoint,
|
||||
retryable: true,
|
||||
transientNetwork: true
|
||||
});
|
||||
return [];
|
||||
}
|
||||
const body = await res.text();
|
||||
const contentType = res.headers && typeof res.headers.get === 'function'
|
||||
? res.headers.get('content-type')
|
||||
: null;
|
||||
if (res.status < 200 || res.status >= 300) {
|
||||
throw createTransportError('VOE: Dateiliste konnte nicht geladen werden', {
|
||||
phase,
|
||||
endpoint,
|
||||
httpStatus: res.status,
|
||||
contentType,
|
||||
body,
|
||||
retryable: res.status === 429 || res.status >= 500,
|
||||
transientNetwork: res.status >= 500
|
||||
});
|
||||
}
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(body);
|
||||
} catch {
|
||||
throw createTransportError('VOE: Dateiliste war kein JSON', {
|
||||
phase,
|
||||
endpoint,
|
||||
httpStatus: res.status,
|
||||
contentType,
|
||||
body
|
||||
});
|
||||
}
|
||||
if (data && Array.isArray(data.data)) return data.data;
|
||||
if (data && Array.isArray(data.files)) return data.files;
|
||||
return [];
|
||||
}
|
||||
|
||||
async _captureFileCodes() {
|
||||
const files = await this._fetchFileList('recovery-baseline');
|
||||
return new Set(files.map(f => String(f.file_code || f.slug || '').trim()).filter(Boolean));
|
||||
try {
|
||||
const files = await this._fetchFileList();
|
||||
return new Set(files.map(f => String(f.file_code || f.slug || '').trim()).filter(Boolean));
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -280,14 +212,7 @@ class VoeUploader {
|
||||
async upload(filePath, onProgress, signal, throttle) {
|
||||
const fileName = path.basename(filePath);
|
||||
const fileSize = fs.statSync(filePath).size;
|
||||
let baselineCodes = null;
|
||||
let baselineError = null;
|
||||
try {
|
||||
baselineCodes = await this._captureFileCodes();
|
||||
} catch (err) {
|
||||
if (signal && signal.aborted) throw err;
|
||||
baselineError = err;
|
||||
}
|
||||
const baselineCodes = await this._captureFileCodes();
|
||||
|
||||
// Step 1: Get CSRF token from upload page
|
||||
const { csrfToken } = await this._getUploadParams();
|
||||
@@ -333,57 +258,27 @@ class VoeUploader {
|
||||
}
|
||||
|
||||
// Step 3: POST file to CDN upload server
|
||||
let uploadResponse;
|
||||
try {
|
||||
uploadResponse = await request(uploadServer, {
|
||||
method: 'POST',
|
||||
body: generate(),
|
||||
signal,
|
||||
headers: {
|
||||
'User-Agent': USER_AGENT,
|
||||
'Cookie': this._cookieHeader(),
|
||||
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
||||
'Content-Length': String(totalSize),
|
||||
'X-CSRF-TOKEN': csrfToken,
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Referer': `${BASE_URL}/file-upload`,
|
||||
'Origin': BASE_URL
|
||||
},
|
||||
headersTimeout: UPLOAD_TIMEOUT,
|
||||
bodyTimeout: UPLOAD_TIMEOUT
|
||||
});
|
||||
} catch (err) {
|
||||
const error = signal && signal.aborted ? err : createTransportError('VOE Upload konnte nicht übertragen werden', {
|
||||
phase: 'upload-request',
|
||||
endpoint: uploadServer,
|
||||
retryable: true,
|
||||
transientNetwork: true
|
||||
});
|
||||
throw this._markRemoteCommitUncertain(error);
|
||||
}
|
||||
|
||||
const { body, headers, statusCode } = uploadResponse;
|
||||
const { body, headers } = await request(uploadServer, {
|
||||
method: 'POST',
|
||||
body: generate(),
|
||||
signal,
|
||||
headers: {
|
||||
'User-Agent': USER_AGENT,
|
||||
'Cookie': this._cookieHeader(),
|
||||
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
||||
'Content-Length': String(totalSize),
|
||||
'X-CSRF-TOKEN': csrfToken,
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Referer': `${BASE_URL}/file-upload`,
|
||||
'Origin': BASE_URL
|
||||
},
|
||||
headersTimeout: UPLOAD_TIMEOUT,
|
||||
bodyTimeout: UPLOAD_TIMEOUT
|
||||
});
|
||||
|
||||
this._parseCookiesFromHeaders(headers || {});
|
||||
|
||||
let rawBody;
|
||||
try {
|
||||
rawBody = await body.text();
|
||||
} catch (err) {
|
||||
throw this._markRemoteCommitUncertain(err);
|
||||
}
|
||||
if (statusCode < 200 || statusCode >= 300) {
|
||||
const error = createTransportError('VOE Upload fehlgeschlagen', {
|
||||
phase: 'upload-response',
|
||||
endpoint: uploadServer,
|
||||
httpStatus: statusCode,
|
||||
contentType: headers && headers['content-type'],
|
||||
body: rawBody,
|
||||
retryable: statusCode === 429 || statusCode >= 500,
|
||||
transientNetwork: statusCode >= 500
|
||||
});
|
||||
throw statusCode >= 500 ? this._markRemoteCommitUncertain(error) : error;
|
||||
}
|
||||
const rawBody = await body.text();
|
||||
|
||||
// Try JSON response
|
||||
try {
|
||||
@@ -400,48 +295,22 @@ class VoeUploader {
|
||||
|
||||
// Check for error
|
||||
if (json.error || json.message) {
|
||||
throw createTransportError(`VOE Upload-Fehler: ${sanitizeRemoteText(json.error || json.message)}`, {
|
||||
phase: 'upload-result',
|
||||
endpoint: uploadServer,
|
||||
contentType: 'application/json',
|
||||
body: rawBody
|
||||
});
|
||||
throw new Error(`VOE Upload-Fehler: ${json.error || json.message}`);
|
||||
}
|
||||
} catch (parseErr) {
|
||||
if (parseErr && parseErr.diagnostic) throw parseErr;
|
||||
if (parseErr.message.startsWith('VOE Upload-Fehler')) throw parseErr;
|
||||
// Not JSON - might be a redirect or HTML response
|
||||
}
|
||||
|
||||
// Fallback: poll the file list to find the newly uploaded file
|
||||
if (baselineCodes) {
|
||||
try {
|
||||
const result = await this._resolveUploadedFile(fileName, baselineCodes, signal);
|
||||
if (result) return result;
|
||||
} catch (err) {
|
||||
throw this._markRemoteCommitUncertain(err);
|
||||
}
|
||||
}
|
||||
const result = await this._resolveUploadedFile(fileName, baselineCodes, signal);
|
||||
if (result) return result;
|
||||
|
||||
if (baselineError) {
|
||||
baselineError.hosterTransient = true;
|
||||
throw this._markRemoteCommitUncertain(baselineError);
|
||||
}
|
||||
|
||||
throw this._markRemoteCommitUncertain(createTransportError('VOE Upload: Kein file_code in der Antwort gefunden', {
|
||||
phase: 'upload-result',
|
||||
endpoint: uploadServer,
|
||||
contentType: headers && headers['content-type'],
|
||||
body: rawBody,
|
||||
hosterTransient: true,
|
||||
retryable: true
|
||||
}));
|
||||
throw new Error('VOE Upload: Kein file_code in der Antwort gefunden');
|
||||
}
|
||||
|
||||
async _resolveUploadedFile(fileName, baselineCodes, signal) {
|
||||
if (!(baselineCodes instanceof Set)) return null;
|
||||
const expectedTitle = this._normalizeTitle(path.parse(fileName).name);
|
||||
let lastPollError = null;
|
||||
let successfulPoll = false;
|
||||
|
||||
for (let attempt = 0; attempt < RESULT_POLL_ATTEMPTS; attempt++) {
|
||||
if (signal && signal.aborted) {
|
||||
@@ -452,31 +321,29 @@ class VoeUploader {
|
||||
|
||||
let files = [];
|
||||
try {
|
||||
files = await this._fetchFileList('recovery-poll');
|
||||
successfulPoll = true;
|
||||
} catch (err) {
|
||||
if (err && err.name === 'AbortError') throw err;
|
||||
lastPollError = err;
|
||||
}
|
||||
files = await this._fetchFileList();
|
||||
} catch { files = []; }
|
||||
|
||||
const withCode = files.filter(f => f && (f.file_code || f.slug));
|
||||
const newFiles = withCode.filter(f => !baselineCodes.has(String(f.file_code || f.slug || '').trim()));
|
||||
const matches = newFiles
|
||||
.filter(file => {
|
||||
const title = this._normalizeTitle(file.title || file.name || '');
|
||||
return expectedTitle && title === expectedTitle;
|
||||
})
|
||||
.filter(file => {
|
||||
const code = String(file.file_code || file.slug || '').trim();
|
||||
return !this.recoveryClaim
|
||||
|| typeof this.recoveryClaim.has !== 'function'
|
||||
|| !this.recoveryClaim.has(code);
|
||||
});
|
||||
|
||||
if (matches.length > 1) return null;
|
||||
if (matches.length === 1) {
|
||||
const code = matches[0].file_code || matches[0].slug;
|
||||
return this._buildUrls(code, 'recovery-poll');
|
||||
if (newFiles.length > 0) {
|
||||
// Try to match by title
|
||||
let best = null;
|
||||
let bestScore = -1;
|
||||
|
||||
for (const file of newFiles) {
|
||||
const score = this._scoreCandidate(file, expectedTitle);
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
best = file;
|
||||
}
|
||||
}
|
||||
|
||||
if (best && (bestScore > 0 || newFiles.length === 1)) {
|
||||
const code = best.file_code || best.slug;
|
||||
return this._buildUrls(code);
|
||||
}
|
||||
}
|
||||
|
||||
if (attempt < RESULT_POLL_ATTEMPTS - 1) {
|
||||
@@ -484,41 +351,31 @@ class VoeUploader {
|
||||
}
|
||||
}
|
||||
|
||||
if (!successfulPoll && lastPollError) throw lastPollError;
|
||||
return null;
|
||||
}
|
||||
|
||||
_normalizeTitle(value) {
|
||||
return normalizeRecoveryTitle(value);
|
||||
return String(value || '')
|
||||
.toLowerCase()
|
||||
.normalize('NFKD')
|
||||
.replace(/[^a-z0-9]+/g, '');
|
||||
}
|
||||
|
||||
_markRemoteCommitUncertain(error) {
|
||||
if (this.recoveryClaim && typeof this.recoveryClaim.markUncertain === 'function') {
|
||||
return this.recoveryClaim.markUncertain(error);
|
||||
}
|
||||
const uncertainError = error && typeof error === 'object'
|
||||
? error
|
||||
: new Error('VOE Upload-Ergebnis ist unsicher');
|
||||
uncertainError.remoteCommitUncertain = true;
|
||||
uncertainError.hosterTransient = true;
|
||||
return uncertainError;
|
||||
_scoreCandidate(file, expectedTitle) {
|
||||
if (!file || !(file.file_code || file.slug)) return -1;
|
||||
if (!expectedTitle) return 0;
|
||||
|
||||
const title = this._normalizeTitle(file.title || file.name || '');
|
||||
if (!title) return -1;
|
||||
if (title === expectedTitle) return 120;
|
||||
if (title.startsWith(expectedTitle) || expectedTitle.startsWith(title)) return 90;
|
||||
if (title.includes(expectedTitle) || expectedTitle.includes(title)) return 70;
|
||||
return 0;
|
||||
}
|
||||
|
||||
_buildUrls(fileCode, phase = 'upload-result') {
|
||||
_buildUrls(fileCode) {
|
||||
const code = String(fileCode || '').trim();
|
||||
if (!code) return null;
|
||||
if (this.recoveryClaim
|
||||
&& typeof this.recoveryClaim.reserve === 'function'
|
||||
&& !this.recoveryClaim.reserve(code)) {
|
||||
const error = createTransportError('VOE Upload-Ergebnis ist bereits einem anderen Upload zugeordnet', {
|
||||
phase,
|
||||
endpoint: BASE_URL,
|
||||
retryable: true,
|
||||
hosterTransient: true
|
||||
});
|
||||
error.remoteIdentityClaimed = true;
|
||||
throw this._markRemoteCommitUncertain(error);
|
||||
}
|
||||
return {
|
||||
download_url: `${BASE_URL}/${code}`,
|
||||
embed_url: `${BASE_URL}/e/${code}`,
|
||||
|
||||
@@ -78,7 +78,7 @@ function buildWebhookRequest(url, summary, meta) {
|
||||
.join(' · ');
|
||||
if (hosterEntries.length > MAX_HOSTER_LINES) hosterLines += ` · …+${hosterEntries.length - MAX_HOSTER_LINES}`;
|
||||
const lines = [
|
||||
`**Multi Hoster Uploader — ${headline}**${m.machineName ? ` (${m.machineName})` : ''}`,
|
||||
`**Multi-Hoster-Upload — ${headline}**${m.machineName ? ` (${m.machineName})` : ''}`,
|
||||
language === 'de'
|
||||
? `✅ ${succeeded} ok · ❌ ${failed} Fehler${skipped > 0 ? ` · ⏭ ${skipped} übersprungen` : ''} · 📦 ${total} gesamt · ⏱ ${duration}`
|
||||
: `✅ ${succeeded} succeeded · ❌ ${failed} failed${skipped > 0 ? ` · ⏭ ${skipped} skipped` : ''} · 📦 ${total} total · ⏱ ${duration}`
|
||||
@@ -92,7 +92,7 @@ function buildWebhookRequest(url, summary, meta) {
|
||||
} else {
|
||||
body = JSON.stringify({
|
||||
event: 'batch-done',
|
||||
app: 'multi-hoster-uploader',
|
||||
app: 'multi-hoster-upload',
|
||||
version: m.appVersion || null,
|
||||
machine: m.machineName || null,
|
||||
total,
|
||||
|
||||
Reference in New Issue
Block a user