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:
@@ -3,7 +3,6 @@ name: CI
|
||||
on:
|
||||
push:
|
||||
branches: ['**']
|
||||
tags: ['v*']
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
@@ -16,9 +15,6 @@ jobs:
|
||||
with:
|
||||
node-version: 24
|
||||
cache: npm
|
||||
- run: npm run verify:public-source
|
||||
- run: npm ci
|
||||
- run: npm run verify
|
||||
env:
|
||||
RUN_UI_SMOKE: '1'
|
||||
- run: npm run dist
|
||||
|
||||
@@ -3,7 +3,6 @@ name: CI
|
||||
on:
|
||||
push:
|
||||
branches: ['**']
|
||||
tags: ['v*']
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
@@ -16,9 +15,6 @@ jobs:
|
||||
with:
|
||||
node-version: 24
|
||||
cache: npm
|
||||
- run: npm run verify:public-source
|
||||
- run: npm ci
|
||||
- run: npm run verify
|
||||
env:
|
||||
RUN_UI_SMOKE: '1'
|
||||
- run: npm run dist
|
||||
|
||||
@@ -20,12 +20,3 @@ account-rotation.log
|
||||
doodstream-debug.log
|
||||
upload-debug.log
|
||||
release-*.log
|
||||
tasks/
|
||||
.cl[a]ude/
|
||||
.c[o]dex/
|
||||
AGENTS.md
|
||||
C[L]AUDE.md
|
||||
MEMORY.md
|
||||
MEMORY_SUMMARY.md
|
||||
raw_memories.md
|
||||
prompts/
|
||||
|
||||
@@ -1,30 +1,28 @@
|
||||
# Multi Hoster Uploader
|
||||
# Multi-Hoster-Upload
|
||||
|
||||
Multi Hoster Uploader is a Windows desktop application for sending file batches to several supported video hosts from one queue. Add files or folders, choose one or more destinations, manage multiple accounts, and follow every upload from preparation to its final link.
|
||||
Multi-Hoster-Upload is a Windows desktop application for sending file batches to several supported video hosts from one queue. Add files or folders, choose one or more destinations, manage multiple accounts, and follow every upload from preparation to its final link.
|
||||
|
||||

|
||||

|
||||
|
||||
## Download
|
||||
|
||||
Download the current Setup or Portable build from the [latest GitHub release](https://github.com/Sucukdeluxe/Multi-Hoster-Upload/releases/latest).
|
||||
|
||||
The latest public release is version 2.1.23. Use the release page for the executables and the full English changelog.
|
||||
The latest public release is version 2.1.24. Use the release page for the executables and the full English changelog.
|
||||
|
||||
## Features
|
||||
|
||||
### Upload workspace
|
||||
|
||||
- Add individual files, complete folders, or files by drag and drop.
|
||||
- Inspect imports before queue creation and review readable, duplicate, filtered, and host-size-limited files in one summary.
|
||||
- Filter new imports by file name with reusable include or exclude conditions before upload jobs are created.
|
||||
- Review how many selected files were accepted or excluded before choosing upload destinations.
|
||||
- Review candidates, duplicates, unavailable files, accepted files, destinations, resulting jobs, and configured size-limit omissions before confirming an import.
|
||||
- Build one job per selected file and destination.
|
||||
- Upload to several supported hosts from the same queue.
|
||||
- Filter the workspace by all, active, queued, completed, or failed jobs.
|
||||
- Search and filter queue entries by file name, host, and status.
|
||||
- Open per-upload diagnostics with the selected account, retry count, and safe error details.
|
||||
- Track status, smoothly interpolated progress, transferred size, speed, and the selected host account.
|
||||
- Read remaining, total, running, connection, completed, and failed upload activity from the persistent sidebar telemetry.
|
||||
- Read total, remaining, running, completed, and failed upload activity from the persistent sidebar telemetry.
|
||||
- Follow current upload speed in the sidebar and the synchronized header graph.
|
||||
- Reorder selected jobs, start selected jobs, retry finished jobs, or stop active work.
|
||||
- Copy completed links individually or together.
|
||||
@@ -35,14 +33,12 @@ The latest public release is version 2.1.23. Use the release page for the execut
|
||||
- Keep multiple named accounts for each host.
|
||||
- Validate credentials before a new or edited account is saved.
|
||||
- Run health checks for one account or all configured accounts.
|
||||
- Review recent host reliability, throughput, last success, and account availability in a dedicated health overview.
|
||||
- Complete an OTP check in the account view when a host requests it.
|
||||
- Enable, disable, prioritize, and reorder accounts.
|
||||
- Rotate files across enabled accounts or keep the first enabled account as the primary account.
|
||||
- Switch to an available fallback account when an account-specific upload error is detected.
|
||||
- Apply retries, concurrency, bandwidth, file-size, and pacing settings per host.
|
||||
- Monitor a folder for new files and start matching uploads automatically.
|
||||
- Restrict new upload starts to configurable weekday and local-time windows while allowing active transfers to finish.
|
||||
|
||||
### History, transfer, and updates
|
||||
|
||||
@@ -51,7 +47,6 @@ The latest public release is version 2.1.23. Use the release page for the execut
|
||||
- Retain all history, a time window, or the latest 100 or 1,000 uploads.
|
||||
- Export history as CSV or JSON.
|
||||
- Export a per-session CSV or JSON report with host success rates, duration, bytes, attempts, and errors.
|
||||
- Review a final post-cleanup batch report with file, job, host, transfer, and source-cleanup totals, then export the complete report as JSON or sanitized errors as CSV.
|
||||
- Clearly mark interrupted uploads after a restart so they can be resumed deliberately.
|
||||
- Use the complete interface in English or German and switch at runtime.
|
||||
- Export settings locally or transfer them with an encrypted online backup key.
|
||||
@@ -131,7 +126,7 @@ Successful jobs can remain visible or be removed automatically. History is store
|
||||
|
||||
**Settings > Uploads** can permanently delete a source file after every destination selected for that file has completed successfully. This option is disabled by default and requires an explicit warning confirmation before it can be enabled.
|
||||
|
||||
Cleanup remains blocked while any required destination is unfinished, failed, canceled, skipped, or missing valid confirmation. The application records the original file identity, preserves successful destinations across retries and restarts, waits for active file handles to close, and refuses deletion if the source was replaced or modified. Every cleanup decision and result is written to `upload-audit.log`, while the FileUploader and MDU session logs remain limited to successful links. Deletion bypasses the Recycle Bin and cannot be undone.
|
||||
Cleanup remains blocked while any required destination is unfinished, failed, canceled, skipped, or missing valid confirmation. The application records the original file identity, preserves successful destinations across retries and restarts, waits for active file handles to close, and refuses deletion if the source was replaced or modified. Every cleanup decision and result is written to the upload log. Deletion bypasses the Recycle Bin and cannot be undone.
|
||||
|
||||
## Folder monitoring
|
||||
|
||||
@@ -200,7 +195,7 @@ Treat the generated key like a password: anyone with it can restore the encrypte
|
||||
|
||||
Open **Settings > General** or **Help** and select **Check for updates**. When a newer version is available, the application shows the installed and available versions, download progress, and release notes. The update dialog requests the changelog for the matching version from the public GitHub release and uses the release's fallback description if that changelog is unavailable.
|
||||
|
||||
Installation is refused unless `latest.yml` contains the matching version, installer name, file size, and a valid SHA-512 checksum. The downloaded Setup executable must match those values and contain a valid Windows executable header before it can be launched.
|
||||
The downloaded Setup executable is checked as a Windows executable. When `latest.yml` contains a SHA-512 value, the downloaded file is also checked against it before installation starts.
|
||||
|
||||
You can always install manually from the [GitHub releases page](https://github.com/Sucukdeluxe/Multi-Hoster-Upload/releases).
|
||||
|
||||
|
||||
@@ -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 };
|
||||
+12
-147
@@ -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) {
|
||||
_writeHistoryFileAtomic(arr) {
|
||||
return new Promise((resolve, reject) => {
|
||||
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();
|
||||
}
|
||||
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,18 +372,13 @@ 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 {}
|
||||
}
|
||||
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
|
||||
@@ -493,7 +388,6 @@ class ConfigStore {
|
||||
if (!data) {
|
||||
try { data = this._readAndParse(this.filePath + '.pre-history-split.bak'); } catch {}
|
||||
}
|
||||
}
|
||||
if (!data) {
|
||||
const fresh = JSON.parse(JSON.stringify(DEFAULTS));
|
||||
fresh.globalSettings.logMode = normalizeLogMode(fresh.globalSettings);
|
||||
@@ -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());
|
||||
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;
|
||||
}
|
||||
|
||||
function redactResponse(value) {
|
||||
return _deepRedact(value);
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+62
-201
@@ -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,23 +536,16 @@ 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: {
|
||||
@@ -650,17 +554,8 @@ class DoodstreamUploader {
|
||||
},
|
||||
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 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
|
||||
};
|
||||
+105
-488
@@ -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, {
|
||||
const 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 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);
|
||||
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);
|
||||
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');
|
||||
const baseline = await _fetchByseFileList(apiKey, signal);
|
||||
byseBaseline = new Set(baseline.map(f => f.file_code));
|
||||
} catch (err) {
|
||||
if (signal && signal.aborted) throw err;
|
||||
byseBaselineError = err;
|
||||
}
|
||||
}
|
||||
}
|
||||
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');
|
||||
const baseline = await _fetchDoodstreamFileList(apiKey, signal);
|
||||
doodBaseline = new Set(baseline.map(f => f.file_code));
|
||||
} catch (err) {
|
||||
if (signal && signal.aborted) throw err;
|
||||
doodBaselineError = err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -856,9 +560,7 @@ 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, {
|
||||
const { body, statusCode, headers } = await request(targetUrl, {
|
||||
method: 'POST',
|
||||
body: iterable,
|
||||
signal,
|
||||
@@ -871,37 +573,18 @@ async function uploadFile(hosterName, filePath, apiKey, onProgress, signal, thro
|
||||
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 } = 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);
|
||||
const polled = await _resolveByseUploadByName(apiKey, fileName, byseBaseline, signal);
|
||||
if (polled) return polled;
|
||||
} catch (err) {
|
||||
throw _markRecoveryUncertain(opts && opts.recoveryClaim, err);
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
const polled = await _resolveDoodstreamUploadByName(apiKey, fileName, doodBaseline, signal);
|
||||
if (polled) return polled;
|
||||
} catch (err) {
|
||||
throw _markRecoveryUncertain(opts && opts.recoveryClaim, err);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
+6
-15
@@ -1,15 +1,13 @@
|
||||
(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;
|
||||
|
||||
})(typeof window !== 'undefined' ? window : globalThis, function createImportPreflight(path) {
|
||||
function normalizePathValue(value) {
|
||||
let text = String(value ?? '').trim();
|
||||
if (!text) return '';
|
||||
@@ -103,12 +101,11 @@ async function inspectImportEntries(entries, options = {}) {
|
||||
unique.push(entry);
|
||||
}
|
||||
|
||||
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 => {
|
||||
const inspected = await mapWithConcurrency(unique, concurrency, async entry => {
|
||||
try {
|
||||
const result = await inspectPath(entry.path, entry);
|
||||
const reason = unavailableReason(result);
|
||||
@@ -127,12 +124,10 @@ async function inspectImportEntries(entries, options = {}) {
|
||||
return {
|
||||
candidateCount: input.length,
|
||||
duplicateCount: duplicates.length,
|
||||
filteredCount: filtered.excluded.length,
|
||||
unavailableCount: unavailable.length,
|
||||
acceptedCount: accepted.length,
|
||||
accepted,
|
||||
duplicates,
|
||||
filtered: filtered.excluded,
|
||||
unavailable
|
||||
};
|
||||
}
|
||||
@@ -159,19 +154,15 @@ function summarizeImportPlan(input = {}) {
|
||||
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;
|
||||
for (const file of accepted) jobCount += getEligibleImportHosters(file, selectedHosters, settings).length;
|
||||
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
|
||||
sizeLimitedJobCount: accepted.length * selectedHosters.length - jobCount
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+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 };
|
||||
|
||||
+24
-22
@@ -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;
|
||||
function normalizeDoodstreamUrl(value) {
|
||||
if (typeof value !== 'string' || value.trim() === '') return value;
|
||||
try {
|
||||
const url = new URL(value.trim());
|
||||
if (url.protocol === 'https:') return url.href;
|
||||
} catch {}
|
||||
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();
|
||||
}
|
||||
return '';
|
||||
} 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;
|
||||
}
|
||||
|
||||
+96
-505
@@ -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 () => {
|
||||
let key = '';
|
||||
try {
|
||||
const probe = new DoodstreamUploader();
|
||||
await probe.login(task.username, task.password);
|
||||
return (await probe.deriveApiKey()) || '';
|
||||
key = (await probe.deriveApiKey()) || '';
|
||||
} catch {
|
||||
return '';
|
||||
key = '';
|
||||
}
|
||||
})();
|
||||
this._doodApiKeyCache.set(cacheKey, pending);
|
||||
const key = await pending;
|
||||
if (this._doodApiKeyCache.get(cacheKey) === pending) this._doodApiKeyCache.set(cacheKey, 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 };
|
||||
+75
-200
@@ -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,9 +211,7 @@ 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, {
|
||||
const { body, statusCode, headers } = await request(targetUrl, {
|
||||
method: 'POST',
|
||||
body: generate(),
|
||||
signal,
|
||||
@@ -275,17 +226,6 @@ class VidmolyUploader {
|
||||
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;
|
||||
|
||||
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);
|
||||
}
|
||||
} 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;
|
||||
}
|
||||
|
||||
// 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);
|
||||
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');
|
||||
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 };
|
||||
|
||||
+55
-198
@@ -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, {
|
||||
const res = await this._fetch(`${BASE_URL}/engine/delivery-node`, {
|
||||
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 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);
|
||||
} catch {
|
||||
throw createTransportError('VOE: Dateiliste konnte nicht geladen werden', {
|
||||
phase,
|
||||
endpoint,
|
||||
retryable: true,
|
||||
transientNetwork: true
|
||||
});
|
||||
}
|
||||
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 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
|
||||
});
|
||||
}
|
||||
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 {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async _captureFileCodes() {
|
||||
const files = await this._fetchFileList('recovery-baseline');
|
||||
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,9 +258,7 @@ class VoeUploader {
|
||||
}
|
||||
|
||||
// Step 3: POST file to CDN upload server
|
||||
let uploadResponse;
|
||||
try {
|
||||
uploadResponse = await request(uploadServer, {
|
||||
const { body, headers } = await request(uploadServer, {
|
||||
method: 'POST',
|
||||
body: generate(),
|
||||
signal,
|
||||
@@ -352,38 +275,10 @@ class VoeUploader {
|
||||
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;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "multi-hoster-uploader",
|
||||
"version": "2.1.23",
|
||||
"version": "2.1.24",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "multi-hoster-uploader",
|
||||
"version": "2.1.23",
|
||||
"version": "2.1.24",
|
||||
"dependencies": {
|
||||
"chokidar": "^3.6.0",
|
||||
"undici": "^7.29.0",
|
||||
|
||||
+3
-11
@@ -1,16 +1,13 @@
|
||||
{
|
||||
"name": "multi-hoster-uploader",
|
||||
"version": "2.1.23",
|
||||
"version": "2.1.24",
|
||||
"description": "Upload files to doodstream, voe, vidmoly, byse simultaneously",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"start": "electron .",
|
||||
"dev": "node scripts/dev-runner.cjs",
|
||||
"test": "npm run test:unit && npm run test:ui",
|
||||
"test:unit": "node --test tests/*.test.js",
|
||||
"test:ui": "node --test tests/ui-smoke.js",
|
||||
"test": "node --test tests/*.test.js tests/ui-smoke.js",
|
||||
"test:backup-api": "npm --prefix services/backup-api test",
|
||||
"verify:public-source": "node scripts/verify-public-release.mjs --source-only --tracked --package-version",
|
||||
"verify": "npm run lint && npm test && npm run test:backup-api && npm audit --omit=dev",
|
||||
"lint": "eslint .",
|
||||
"dist": "electron-builder --publish never --win",
|
||||
@@ -30,7 +27,7 @@
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.multihoster.uploader",
|
||||
"productName": "Multi Hoster Uploader",
|
||||
"productName": "Multi-Hoster-Upload",
|
||||
"directories": {
|
||||
"buildResources": "assets",
|
||||
"output": "release"
|
||||
@@ -50,19 +47,14 @@
|
||||
"portable"
|
||||
],
|
||||
"icon": "assets/app_icon.ico",
|
||||
"executableName": "Multi Hoster Uploader",
|
||||
"signAndEditExecutable": false
|
||||
},
|
||||
"nsis": {
|
||||
"artifactName": "Multi-Hoster-Upload Setup ${version}.${ext}",
|
||||
"oneClick": false,
|
||||
"perMachine": false,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"createDesktopShortcut": true
|
||||
},
|
||||
"portable": {
|
||||
"artifactName": "Multi-Hoster-Upload ${version}.${ext}"
|
||||
},
|
||||
"afterPack": "scripts/afterPack.cjs"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
const { contextBridge, ipcRenderer, webUtils } = require('electron');
|
||||
const { contextBridge, ipcRenderer } = require('electron');
|
||||
|
||||
contextBridge.exposeInMainWorld('dropTargetApi', {
|
||||
sendFiles: (paths) => ipcRenderer.send('drop-target:files', paths),
|
||||
getPathForFile: (file) => webUtils.getPathForFile(file)
|
||||
sendFiles: (paths) => ipcRenderer.send('drop-target:files', paths)
|
||||
});
|
||||
|
||||
@@ -9,8 +9,6 @@ contextBridge.exposeInMainWorld('api', {
|
||||
pruneHistory: (retention, opts) => ipcRenderer.invoke('prune-history', { retention, dryRun: !!(opts && opts.dryRun) }),
|
||||
exportHistory: (format) => ipcRenderer.invoke('export-history', format),
|
||||
exportSessionReport: (format) => ipcRenderer.invoke('export-session-report', format),
|
||||
getLastBatchCompletionReport: () => ipcRenderer.invoke('get-last-batch-completion-report'),
|
||||
exportBatchCompletionReport: (reportId, format) => ipcRenderer.invoke('export-batch-completion-report', reportId, format),
|
||||
saveTextFile: (defaultName, content, filters) => ipcRenderer.invoke('save-text-file', defaultName, content, filters),
|
||||
|
||||
// Hoster settings
|
||||
@@ -29,7 +27,6 @@ contextBridge.exposeInMainWorld('api', {
|
||||
});
|
||||
},
|
||||
signalCloseHandshakeReady: () => ipcRenderer.send('app:close-handshake-ready'),
|
||||
signalRendererInitializationFailed: (details) => ipcRenderer.send('app:renderer-initialization-failed', details),
|
||||
|
||||
// Always on top
|
||||
setAlwaysOnTop: (value) => ipcRenderer.invoke('set-always-on-top', value),
|
||||
@@ -120,9 +117,6 @@ contextBridge.exposeInMainWorld('api', {
|
||||
onUploadBatchDone: (callback) => {
|
||||
ipcRenderer.on('upload-batch-done', (_event, data) => callback(data));
|
||||
},
|
||||
onUploadBatchReport: (callback) => {
|
||||
ipcRenderer.on('upload-batch-report', (_event, data) => callback(data));
|
||||
},
|
||||
onUploadStats: (callback) => {
|
||||
ipcRenderer.on('upload-stats', (_event, data) => callback(data));
|
||||
},
|
||||
@@ -169,7 +163,6 @@ contextBridge.exposeInMainWorld('api', {
|
||||
removeAllListeners: () => {
|
||||
ipcRenderer.removeAllListeners('upload-progress');
|
||||
ipcRenderer.removeAllListeners('upload-batch-done');
|
||||
ipcRenderer.removeAllListeners('upload-batch-report');
|
||||
ipcRenderer.removeAllListeners('upload-stats');
|
||||
ipcRenderer.removeAllListeners('app:update-available');
|
||||
ipcRenderer.removeAllListeners('app:update-progress');
|
||||
|
||||
+589
-1307
File diff suppressed because it is too large
Load Diff
@@ -79,20 +79,12 @@
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
target.classList.remove('drag-over');
|
||||
const files = [];
|
||||
const paths = [];
|
||||
for (const file of e.dataTransfer.files) {
|
||||
let filePath = '';
|
||||
try { filePath = window.dropTargetApi.getPathForFile(file); } catch {}
|
||||
if (!filePath) continue;
|
||||
files.push({
|
||||
path: filePath,
|
||||
name: file.name || '',
|
||||
size: Number.isFinite(file.size) ? file.size : 0,
|
||||
isDirectory: file.type === '' && file.size === 0
|
||||
});
|
||||
if (file.path) paths.push(file.path);
|
||||
}
|
||||
if (files.length > 0) {
|
||||
window.dropTargetApi.sendFiles(files);
|
||||
if (paths.length > 0) {
|
||||
window.dropTargetApi.sendFiles(paths);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
+15
-155
@@ -24,7 +24,6 @@
|
||||
['Maximale Größe (MB)', 'Maximum size (MB)'],
|
||||
['Bildschirm und Eingabesteuerung bleiben gesperrt.', 'Screen and input control remain locked.'],
|
||||
['Bindet nur an', 'Binds only to'],
|
||||
['. Fernzugriff nur über einen Tunnel (z.B. Tailscale/SSH).', '. Remote access is only available through a tunnel (for example, Tailscale/SSH).'],
|
||||
['Bindet an', 'Binds to'],
|
||||
['. Nur IPs/CIDRs aus der Allowlist dürfen verbinden (Loopback immer) — zusätzlich zum Token. Über Tailscale: trage deinen Tailnet-Bereich ein (z.B.', '. Only IPs/CIDRs from the allowlist may connect (loopback is always allowed), in addition to the token. For Tailscale, enter your tailnet range, e.g.'],
|
||||
[') und die Tailscale-IP/MagicDNS oben als Code-Adresse. Transport ist plaintext über den Tunnel — Tailscale/WireGuard verschlüsselt.', ') and enter the Tailscale IP or MagicDNS name above as the code address. Transport is plaintext through the tunnel; Tailscale/WireGuard provides encryption.'],
|
||||
@@ -37,58 +36,20 @@
|
||||
['Fensterverhalten, Drop-Target und Programmupdates.', 'Window behavior, drop target, and application updates.'],
|
||||
['Gesamt:', 'Total:'],
|
||||
['Globales Speed-Limit', 'Global speed limit'],
|
||||
['Dateinamenfilter', 'Filename filter'],
|
||||
['Dateinamen beim Hinzufügen filtern', 'Filter filenames when adding files'],
|
||||
['Prüft neue Dateien aus Auswahl, Ordnern, Drag-and-drop und Ordnerüberwachung, bevor sie in die Upload-Liste gelangen.', 'Checks new files from selections, folders, drag and drop, and folder monitoring before they enter the upload list.'],
|
||||
['Treffer', 'Matches'],
|
||||
['Wenn passend', 'When matched'],
|
||||
['Bedingungen', 'Conditions'],
|
||||
['nur hinzufügen', 'add only'],
|
||||
['nicht hinzufügen', 'do not add'],
|
||||
['Dateien hinzufügen', 'Add files'],
|
||||
['Dateien nicht hinzufügen', 'Do not add files'],
|
||||
['alle müssen passen', 'all must match'],
|
||||
['mindestens eine muss passen', 'at least one must match'],
|
||||
['Dateinamen-Bedingung', 'Filename condition'],
|
||||
['Vergleich', 'Comparison'],
|
||||
['enthält', 'contains'],
|
||||
['enthält nicht', 'does not contain'],
|
||||
['Dateiname filtern', 'Filter file name'],
|
||||
['z. B. 720p', 'e.g. 720p'],
|
||||
['Bedingung entfernen', 'Remove condition'],
|
||||
['+ Bedingung', '+ Condition'],
|
||||
['Groß- und Kleinschreibung werden ignoriert. Leere Bedingungen werden nicht gespeichert.', 'Matching ignores letter case. Empty conditions are not saved.'],
|
||||
['Hauptbereiche', 'Main sections'],
|
||||
['Hinweis', 'Notice'],
|
||||
['Hoster-Limits automatisch hochskalieren', 'Automatically scale host limits'],
|
||||
['Hoster', 'Host'],
|
||||
['Versuch', 'Attempt'],
|
||||
['Batch abgeschlossen', 'Batch complete'],
|
||||
['Der Upload-Batch wurde abgeschlossen.', 'The upload batch has completed.'],
|
||||
['Mit Problemen', 'Completed with issues'],
|
||||
['Übertragung', 'Transfer'],
|
||||
['Dauer', 'Duration'],
|
||||
['Erfolgreich übertragen', 'Successfully transferred'],
|
||||
['Durchschnitt', 'Average'],
|
||||
['Vollständig erfolgreich', 'Fully successful'],
|
||||
['Teilweise erfolgreich', 'Partially successful'],
|
||||
['Auftrag', 'Job'],
|
||||
['Aufträge', 'Jobs'],
|
||||
['Angefordert', 'Requested'],
|
||||
['Gelöscht', 'Deleted'],
|
||||
['Blockiert', 'Blocked'],
|
||||
['Hosterübersicht', 'Host overview'],
|
||||
['Übertragen', 'Transferred'],
|
||||
['Fehlerbeispiele', 'Error examples'],
|
||||
['Fehler-CSV exportieren', 'Export error CSV'],
|
||||
['Temporärer Hosterfehler', 'Temporary host error'],
|
||||
['Datei abgelehnt', 'File rejected'],
|
||||
['Account-Fehler', 'Account error'],
|
||||
['Remote-Abschluss unklar', 'Remote completion uncertain'],
|
||||
['1 weiterer Fehler', '1 more error'],
|
||||
['JSON-Bericht exportiert', 'JSON report exported'],
|
||||
['Fehler-CSV exportiert', 'Error CSV exported'],
|
||||
['Batch-Bericht konnte nicht exportiert werden.', 'The batch report could not be exported.'],
|
||||
['Import-Vorabprüfung', 'Import preflight'],
|
||||
['Kandidaten', 'Candidates'],
|
||||
['Bereits vorhanden / dupliziert', 'Already present / duplicated'],
|
||||
['Fehlend / unlesbar / leer', 'Missing / unreadable / empty'],
|
||||
['Akzeptierte Dateien', 'Accepted files'],
|
||||
['Ausgewählte Ziele', 'Selected destinations'],
|
||||
['Entstehende Jobs', 'Resulting jobs'],
|
||||
['Durch konfigurierte Größenlimits entfallene Jobs', 'Jobs omitted by configured size limits'],
|
||||
['Vorabprüfung abgeschlossen. Wähle jetzt die Hoster für den Upload.', 'Preflight complete. Choose upload destinations.'],
|
||||
['Vorabprüfung fehlgeschlagen.', 'Preflight failed.'],
|
||||
['Importieren', 'Import'],
|
||||
['In Zwischenablage', 'To clipboard'],
|
||||
['In diesem Lauf hochgeladen:', 'Uploaded during this run:'],
|
||||
@@ -202,20 +163,6 @@
|
||||
['Hoster-Zugangsdaten verwalten und prüfen', 'Manage and verify host account credentials'],
|
||||
['Accounts prüfen', 'Check accounts'],
|
||||
['Account hinzufügen', 'Add account'],
|
||||
['Hoster-Gesundheit', 'Host health'],
|
||||
['Hoster-Gesundheit aus lokalem Verlauf und aktuellem Accountstatus', 'Host health from local history and current account status'],
|
||||
['Lokale Werte aus höchstens 50 Batches. Der effektive historische Durchsatz kann Wartezeiten und Wiederholungen enthalten.', 'Local values from up to 50 batches. Effective historical throughput can include waits and retries.'],
|
||||
['Stichprobe', 'Sample'],
|
||||
['Erfolg / Fehler / Übersprungen', 'Success / Failed / Skipped'],
|
||||
['Erfolgsrate', 'Success rate'],
|
||||
['Effektiver historischer Durchsatz', 'Effective historical throughput'],
|
||||
['Letzter Erfolg', 'Last success'],
|
||||
['Fehler (7 Tage)', 'Failed (7 days)'],
|
||||
['Account-Probleme', 'Account issues'],
|
||||
['Noch keine Hoster-Daten.', 'No host data yet.'],
|
||||
['Verlauf nicht verfügbar.', 'History unavailable.'],
|
||||
['Prüfung läuft', 'Check in progress'],
|
||||
['Nie', 'Never'],
|
||||
['Noch keine Hoster', 'No hosts yet'],
|
||||
['Füge deinen ersten Hoster-Account hinzu. Die Zugangsdaten werden vor dem Speichern geprüft.', 'Add your first host account. Credentials are verified before saving.'],
|
||||
['Alle ausklappen', 'Expand all'],
|
||||
@@ -277,45 +224,18 @@
|
||||
['Hoster-Einstellungen', 'Host settings'],
|
||||
['Upload-Einstellungen', 'Upload settings'],
|
||||
['Erfolgreiche Links in fileuploader.log.', 'Successful links in fileuploader.log.'],
|
||||
['Verteilt Dateien abwechselnd auf alle aktiven Accounts dieses Hosters. Bei nur einem Account hat die Option keinen Effekt.', 'Distributes files alternately across all active accounts for this host. The option has no effect with only one account.'],
|
||||
['Verteilt die Dateien reihum auf alle aktiven Accounts dieses Hosters (Datei 1 → Account 1, Datei 2 → Account 2 …). Hält z. B. byse-Accounts aktiv. Nur ein Account = kein Effekt.', 'Distributes files across all active accounts for this host in round-robin order (file 1 → account 1, file 2 → account 2, and so on). Keeps accounts such as byse active. Has no effect with only one account.'],
|
||||
['Größen-Limit merken', 'Remember size limit'],
|
||||
['Überspringt nach zwei verdächtigen Ablehnungen auf einem Account größere Dateien dort vorab ("Bekanntes Größen-Limit"). Abschalten = jede Datei wird immer wirklich versucht.', 'After two suspicious rejections on an account, larger files are skipped there in advance ("Known size limit"). When disabled, every file is always attempted.'],
|
||||
['Einstellungen einzelner Hoster', 'Settings for individual hosts'],
|
||||
['wie Wiederholungen, Geschwindigkeit, Parallelität, Dateigröße und Logging findest du im', 'such as retries, speed, parallelism, file size, and logging are available in'],
|
||||
['Accounts-Tab.', 'the Accounts tab.'],
|
||||
['Wiederholungen und überwachte Ordner für unbeaufsichtigte Uploads.', 'Retries and watched folders for unattended uploads.'],
|
||||
['Wiederholungen, Upload-Zeitfenster und überwachte Ordner für unbeaufsichtigte Uploads.', 'Retries, upload schedules, and watched folders for unattended uploads.'],
|
||||
['Unbeaufsichtigter Betrieb', 'Unattended operation'],
|
||||
['Automatische Wiederholungsrunden', 'Automatic retry rounds'],
|
||||
['0 = aus. Nach Batch-Ende werden transiente Fehler (Netzwerk, Hoster-Flake) automatisch bis zu N Runden neu versucht.', '0 = off. After a batch ends, transient errors (network or host issues) are retried automatically for up to N rounds.'],
|
||||
['Wartezeit zwischen Runden', 'Delay between rounds'],
|
||||
['Minuten · jede weitere Runde wartet entsprechend länger', 'Minutes · each additional round waits proportionally longer'],
|
||||
['Upload-Zeitfenster', 'Upload schedule'],
|
||||
['Neue Uploads nur im Zeitfenster starten', 'Start new uploads only during the schedule'],
|
||||
['Laufende Uploads dürfen fertig werden. Wartende Uploads starten automatisch bei der nächsten Öffnung.', 'Active uploads may finish. Waiting uploads start automatically when the schedule opens again.'],
|
||||
['Erlaubte Wochentage', 'Allowed weekdays'],
|
||||
['Mo', 'Mon'],
|
||||
['Di', 'Tue'],
|
||||
['Mi', 'Wed'],
|
||||
['Do', 'Thu'],
|
||||
['Fr', 'Fri'],
|
||||
['Sa', 'Sat'],
|
||||
['So', 'Sun'],
|
||||
['Start', 'Start'],
|
||||
['Ende', 'End'],
|
||||
['Lokale Systemzeit · Zeitfenster über Mitternacht werden unterstützt', 'Local system time · overnight schedules are supported'],
|
||||
['Deaktiviert. Neue Uploads starten sofort.', 'Disabled. New uploads start immediately.'],
|
||||
['Ungültig. Wähle mindestens einen Wochentag aus.', 'Invalid. Select at least one weekday.'],
|
||||
['Ungültig. Start und Ende müssen unterschiedlich sein.', 'Invalid. Start and end must be different.'],
|
||||
['Ungültig. Prüfe Start- und Endzeit.', 'Invalid. Check the start and end time.'],
|
||||
['Geöffnet. Neue Uploads dürfen starten.', 'Open. New uploads may start.'],
|
||||
['Geschlossen. Nächster erlaubter Start:', 'Closed. Next allowed start:'],
|
||||
['Geöffnet', 'Open'],
|
||||
['Geschlossen', 'Closed'],
|
||||
['Ungültig', 'Invalid'],
|
||||
['Wähle mindestens einen Wochentag für das Upload-Zeitfenster aus.', 'Select at least one weekday for the upload schedule.'],
|
||||
['Start und Ende des Upload-Zeitfensters müssen unterschiedlich sein.', 'The upload schedule start and end must be different.'],
|
||||
['Prüfe Start und Ende des Upload-Zeitfensters.', 'Check the upload schedule start and end.'],
|
||||
['Ordnerüberwachung', 'Folder monitoring'],
|
||||
['Inaktiv', 'Inactive'],
|
||||
['Ordnerpfad', 'Folder path'],
|
||||
@@ -406,18 +326,6 @@
|
||||
['Ausgewählte starten', 'Start selected'],
|
||||
['Upload-Ziele auswählen', 'Choose upload destinations'],
|
||||
['Dateien wurden hinzugefügt. Wähle jetzt die Hoster für den Upload.', 'Files were added. Now choose the hosts for the upload.'],
|
||||
['Vorabprüfung abgeschlossen. Wähle jetzt die Hoster für den Upload.', 'Preflight complete. Now choose the hosts for the upload.'],
|
||||
['Import-Vorabprüfung', 'Import preflight'],
|
||||
['Kandidaten', 'Candidates'],
|
||||
['Bereits vorhanden / dupliziert', 'Already present / duplicated'],
|
||||
['Durch Dateinamenfilter ausgeschlossen', 'Excluded by filename filter'],
|
||||
['Fehlend / unlesbar / leer', 'Missing / unreadable / empty'],
|
||||
['Akzeptierte Dateien', 'Accepted files'],
|
||||
['Ausgewählte Ziele', 'Selected destinations'],
|
||||
['Entstehende Jobs', 'Resulting jobs'],
|
||||
['Durch konfigurierte Größenlimits entfallene Jobs', 'Jobs omitted by configured size limits'],
|
||||
['Keine Dateien wurden akzeptiert.', 'No files were accepted.'],
|
||||
['Vorabprüfung fehlgeschlagen.', 'Preflight failed.'],
|
||||
['Alle', 'All'],
|
||||
['Keine', 'None'],
|
||||
['Keine Hoster mit Zugangsdaten vorhanden. Bitte zuerst in den Accounts einen Login oder API-Key hinterlegen.', 'No hosts with credentials are available. Add a login or API key under Accounts first.'],
|
||||
@@ -517,7 +425,7 @@
|
||||
['Keine Antwort vom Hoster erhalten', 'No response received from the host'],
|
||||
['Keine Einträge im Log gefunden', 'No log entries found'],
|
||||
['Keine Einträge zum Exportieren.', 'No entries to export.'],
|
||||
['Keine Jobs hinzugefügt', 'No jobs added'],
|
||||
['Keine Jobs hinzugefuegt', 'No jobs added'],
|
||||
['Keine Log-Einträge für diesen Job (entweder noch nichts passiert oder aus vorherigem Batch und schon geräumt).', 'No log entries for this job. Nothing has happened yet, or entries from a previous batch were already cleared.'],
|
||||
['Keine passenden Jobs für Retry gefunden.', 'No matching jobs found for retry.'],
|
||||
['Keine startbaren Jobs ausgewählt (alle laufen schon oder sind fertig).', 'No startable jobs selected because all are already running or completed.'],
|
||||
@@ -573,13 +481,7 @@
|
||||
['Ein Upload wird bereits ausgeführt oder abgeschlossen', 'An upload is already running or completed'],
|
||||
['Kein gültiger Account für diesen Hoster', 'No valid account is available for this host'],
|
||||
['Keine gültigen Zugangsdaten für die gewählten Hoster.', 'No valid credentials are available for the selected hosts.'],
|
||||
['Upload-Ergebnis für diesen Titel ist wegen eines möglichen Remote-Commits unsicher', 'The upload result for this title is uncertain because of a possible remote commit'],
|
||||
['Gemischte Upload-Anmeldungen für denselben Remote-Titel wurden sicher blockiert', 'Mixed upload authentication profiles for the same remote title were safely blocked'],
|
||||
['Doodstream Upload lieferte eine bereits zugeordnete Remote-Identität', 'Doodstream upload returned an already claimed remote identity'],
|
||||
['Unbekannter Fehler', 'Unknown error'],
|
||||
['Zugriff verweigert', 'Access denied'],
|
||||
['Datei beschädigt', 'File is damaged'],
|
||||
['Konfiguration fehlt', 'Configuration is missing'],
|
||||
['Kein Log-Pfad gefunden', 'No log path was found'],
|
||||
['Ungültige URL (muss mit http(s):// beginnen)', 'Invalid URL (must start with http(s)://)'],
|
||||
['Backup-Datei ist zu groß oder ungültig', 'The backup file is too large or invalid'],
|
||||
@@ -657,10 +559,6 @@
|
||||
['Backup importiert', 'Backup imported'],
|
||||
['Import fehlgeschlagen', 'Import failed'],
|
||||
['Upload-Start fehlgeschlagen', 'Failed to start upload'],
|
||||
['Initialisierung fehlgeschlagen', 'Initialization failed'],
|
||||
['Import übernommen. Warteschlange konnte nicht vollständig gespeichert werden', 'Import applied. The queue could not be saved completely'],
|
||||
['Jobs konnten nicht hinzugefügt werden', 'Jobs could not be added'],
|
||||
['Test fehlgeschlagen', 'Test failed'],
|
||||
['erneut versuchbar', 'retryable'],
|
||||
['manuell', 'manual'],
|
||||
['Abgebrochen.', 'Canceled.'],
|
||||
@@ -690,18 +588,9 @@
|
||||
['Update wird installiert. Die Anwendung startet neu…', 'Installing update. The application is restarting…'],
|
||||
['Updateprüfung fehlgeschlagen', 'Update check failed'],
|
||||
['Upload läuft...', 'Uploading...'],
|
||||
['Upload konnte nicht gestartet werden', 'The upload could not be started'],
|
||||
['Upload wurde abgebrochen', 'The upload was canceled'],
|
||||
['Upload-Log', 'Upload log'],
|
||||
['Upload-Status', 'Upload status'],
|
||||
['Uploadstatus konnte nicht bestätigt werden', 'The upload status could not be confirmed'],
|
||||
['Abgebrochen; Remote-Status konnte nicht bestätigt werden', 'Cancelled; the remote upload status could not be confirmed'],
|
||||
['Der Upload könnte beim Hoster bereits abgeschlossen sein. Ein erneuter Upload kann ein Duplikat erzeugen.', 'The upload may already be complete on the host. Uploading it again can create a duplicate.'],
|
||||
['Trotzdem erneut hochladen', 'Upload again anyway'],
|
||||
['Upload-Übersicht', 'Upload overview'],
|
||||
['Upload-Wiederherstellung konnte nicht gespeichert werden', 'Upload recovery could not be saved'],
|
||||
['Sitzungsbericht konnte nicht exportiert werden.', 'The session report could not be exported.'],
|
||||
['Warteschlange konnte vor dem Upload-Start nicht gespeichert werden', 'The queue could not be saved before starting the upload'],
|
||||
['Verlauf als CSV exportieren?\n\nOK = CSV\nAbbrechen = JSON', 'Export history as CSV?\n\nOK = CSV\nCancel = JSON'],
|
||||
['Verlauf wirklich löschen?', 'Are you sure you want to delete the history?'],
|
||||
['Verschlüssele und speichere Einstellungen…', 'Encrypting and saving settings…'],
|
||||
@@ -750,27 +639,15 @@
|
||||
const core = text.slice(leading.length, text.length - trailing.length);
|
||||
const exact = target === 'en' ? deToEn.get(core) : enToDe.get(core);
|
||||
if (exact) return `${leading}${exact}${trailing}`;
|
||||
const translateErrorDetail = (detail) => {
|
||||
const translated = target === 'en' ? deToEn.get(detail) : enToDe.get(detail);
|
||||
return translated || (target === 'en' ? 'Unknown error' : 'Unbekannter Fehler');
|
||||
};
|
||||
const patterns = target === 'en'
|
||||
? [
|
||||
[/^Update v(.+) verfügbar$/, 'Update v$1 available'],
|
||||
[/^1 unterbrochener Upload kann fortgesetzt werden\.$/, '1 interrupted upload can be resumed.'],
|
||||
[/^(\d+) unterbrochene Uploads können fortgesetzt werden\.$/, '$1 interrupted uploads can be resumed.'],
|
||||
[/^Wiederhergestellte Warteschlange startet in (.+) s \((.+) Jobs\)\.$/, 'Restored queue starts in $1 s ($2 jobs).'],
|
||||
[/^(\d+) hinzugefügt$/, '$1 added'],
|
||||
[/^(\d+) bereits im Batch$/, '$1 already in the batch'],
|
||||
[/^(\d+) ohne gültigen Account$/, '$1 without a valid account'],
|
||||
[/^(\d+) von (\d+) Dateien werden hinzugefügt\. (\d+) durch den Dateinamenfilter ausgeschlossen\.$/, '$1 of $2 files will be added. $3 excluded by the filename filter.'],
|
||||
[/^Sitzungsbericht mit 1 Upload exportiert$/, 'Session report with 1 upload exported'],
|
||||
[/^Sitzungsbericht mit (\d+) Uploads exportiert$/, 'Session report with $1 uploads exported'],
|
||||
[/^Login ok, Upload-Form bereit \(Dateifeld: (.+)\)$/, 'Login successful, upload form ready (file field: $1)'],
|
||||
[/^Klartext-Backup ist kein gültiges JSON: (.+)$/, 'Plain JSON backup is not valid JSON: $1'],
|
||||
[/^Export fehlgeschlagen: (.+)$/, (_, detail) => `Export failed: ${translateErrorDetail(detail)}`],
|
||||
[/^Import fehlgeschlagen: (.+)$/, (_, detail) => `Import failed: ${translateErrorDetail(detail)}`],
|
||||
[/^Initialisierung fehlgeschlagen: (.+)$/, (_, detail) => `Initialization failed: ${translateErrorDetail(detail)}`],
|
||||
[/^Export fehlgeschlagen: (.+)$/, 'Export failed: $1'],
|
||||
[/^Import fehlgeschlagen: (.+)$/, 'Import failed: $1'],
|
||||
[/^Initialisierung fehlgeschlagen: (.+)$/, 'Initialization failed: $1'],
|
||||
[/^Quelldatei-Schutz konnte nicht vorbereitet werden: (.+)$/, 'Source file protection could not be prepared: $1'],
|
||||
[/^Clouddrop: API-Antwort war kein JSON (.+)$/, 'Clouddrop: API response was not JSON $1'],
|
||||
[/^Clouddrop: Datei nicht lesbar: (.+)$/, 'Clouddrop: File cannot be read: $1'],
|
||||
@@ -798,7 +675,6 @@
|
||||
[/^Update-Server Antwort war kein JSON (.+)$/, 'Update server response was not JSON $1'],
|
||||
[/^(\d+) Links kopiert$/, '$1 links copied'],
|
||||
[/^(\d+) Link kopiert$/, '$1 link copied'],
|
||||
[/^(\d+) weitere Fehler$/, '$1 more errors'],
|
||||
[/^Wirklich alle (\d+) Links aus diesem Panel entfernen\?$/, 'Remove all $1 links from this panel?'],
|
||||
[/^Ein ausgewählter Eintrag wird aus diesem Panel entfernt\.$/, 'One selected entry will be removed from this panel.'],
|
||||
[/^(\d+) ausgewählte Einträge werden aus diesem Panel entfernt\.$/, '$1 selected entries will be removed from this panel.'],
|
||||
@@ -813,7 +689,6 @@
|
||||
[/^Neustart in (\d+)s\.\.\.$/, 'Restart in $1s...'],
|
||||
[/^(\d+) Job zum erneuten Upload zurückgesetzt$/, '$1 job reset for upload'],
|
||||
[/^(\d+) Jobs zum erneuten Upload zurückgesetzt$/, '$1 jobs reset for upload'],
|
||||
[/^(\d+) Uploads könnten bei den Hostern bereits abgeschlossen sein\. Erneute Uploads können Duplikate erzeugen\.$/, '$1 uploads may already be complete on their hosts. Uploading them again can create duplicates.'],
|
||||
[/^(\d+) Verlaufseintrag wird dauerhaft entfernt\.$/, '$1 history entry will be permanently removed.'],
|
||||
[/^(\d+) Verlaufseinträge werden dauerhaft entfernt\.$/, '$1 history entries will be permanently removed.'],
|
||||
[/^Aktives Ziel: (.+)$/, 'Active destination: $1'],
|
||||
@@ -872,27 +747,12 @@
|
||||
[/^Update v(.+) verfügbar\. Klicken zum Installieren\.$/, 'Update v$1 available. Click to install.']
|
||||
]
|
||||
: [
|
||||
[/^Login successful, upload form ready \(file field: (.+)\)$/, 'Login ok, Upload-Form bereit (Dateifeld: $1)'],
|
||||
[/^Plain JSON backup is not valid JSON: (.+)$/, 'Klartext-Backup ist kein gültiges JSON: $1'],
|
||||
[/^Export failed: (.+)$/, (_, detail) => `Export fehlgeschlagen: ${translateErrorDetail(detail)}`],
|
||||
[/^Import failed: (.+)$/, (_, detail) => `Import fehlgeschlagen: ${translateErrorDetail(detail)}`],
|
||||
[/^Initialization failed: (.+)$/, (_, detail) => `Initialisierung fehlgeschlagen: ${translateErrorDetail(detail)}`],
|
||||
[/^Update v(.+) available$/, 'Update v$1 verfügbar'],
|
||||
[/^1 interrupted upload can be resumed\.$/, '1 unterbrochener Upload kann fortgesetzt werden.'],
|
||||
[/^(\d+) interrupted uploads can be resumed\.$/, '$1 unterbrochene Uploads können fortgesetzt werden.'],
|
||||
[/^Restored queue starts in (.+) s \((.+) jobs\)\.$/, 'Wiederhergestellte Warteschlange startet in $1 s ($2 Jobs).'],
|
||||
[/^(\d+) added$/, '$1 hinzugefügt'],
|
||||
[/^(\d+) already in the batch$/, '$1 bereits im Batch'],
|
||||
[/^(\d+) without a valid account$/, '$1 ohne gültigen Account'],
|
||||
[/^(\d+) of (\d+) files will be added\. (\d+) excluded by the filename filter\.$/, '$1 von $2 Dateien werden hinzugefügt. $3 durch den Dateinamenfilter ausgeschlossen.'],
|
||||
[/^Session report with 1 upload exported$/, 'Sitzungsbericht mit 1 Upload exportiert'],
|
||||
[/^Session report with (\d+) uploads exported$/, 'Sitzungsbericht mit $1 Uploads exportiert'],
|
||||
[/^Sleep in (\d+)s\.\.\.$/, 'Ruhezustand in $1s...'],
|
||||
[/^Shut down in (\d+)s\.\.\.$/, 'Herunterfahren in $1s...'],
|
||||
[/^Restart in (\d+)s\.\.\.$/, 'Neustart in $1s...'],
|
||||
[/^(\d+) job reset for upload$/, '$1 Job zum erneuten Upload zurückgesetzt'],
|
||||
[/^(\d+) jobs reset for upload$/, '$1 Jobs zum erneuten Upload zurückgesetzt'],
|
||||
[/^(\d+) more errors$/, '$1 weitere Fehler'],
|
||||
[/^(\d+) history entry will be permanently removed\.$/, '$1 Verlaufseintrag wird dauerhaft entfernt.'],
|
||||
[/^(\d+) history entries will be permanently removed\.$/, '$1 Verlaufseinträge werden dauerhaft entfernt.'],
|
||||
[/^Active on port (\d+) — 1 client connected$/, 'Aktiv auf Port $1 — 1 Client verbunden'],
|
||||
|
||||
+13
-132
@@ -148,7 +148,7 @@
|
||||
</div>
|
||||
<div class="update-dialog-copy">
|
||||
<h2 id="updateDialogTitle">Eine neue Version ist verfügbar</h2>
|
||||
<p id="updateMessage" aria-live="polite"></p>
|
||||
<p id="updateMessage"></p>
|
||||
</div>
|
||||
<div class="update-release-notes" id="updateReleaseNotes" hidden>
|
||||
<div class="update-release-notes-title">Changelog</div>
|
||||
@@ -209,10 +209,10 @@
|
||||
<div class="view-sidebar-summary view-sidebar-summary-block hoster-summary" id="hosterSummary">Keine Upload-Ziele ausgewählt</div>
|
||||
</div>
|
||||
<div class="upload-telemetry" id="uploadTelemetry" aria-label="Upload-Statistik">
|
||||
<div class="upload-telemetry-row"><span class="upload-telemetry-label">Verbleibend</span><strong class="upload-telemetry-value upload-rolling-value" id="uploadTelemetryRemaining" data-numeric-value="0" aria-label="0"><span>0</span></strong></div>
|
||||
<div class="upload-telemetry-row"><span class="upload-telemetry-label">Gesamt</span><strong class="upload-telemetry-value upload-rolling-value" id="uploadTelemetryTotal" data-numeric-value="0" aria-label="0"><span>0</span></strong></div>
|
||||
<div class="upload-telemetry-row"><span class="upload-telemetry-label">Läuft</span><strong class="upload-telemetry-value upload-rolling-value" id="uploadTelemetryRunning" data-numeric-value="0" aria-label="0"><span>0</span></strong></div>
|
||||
<div class="upload-telemetry-row"><span class="upload-telemetry-label">Verbindungen</span><strong class="upload-telemetry-value upload-rolling-value" id="uploadTelemetryConnections" data-numeric-value="0" aria-label="0"><span>0</span></strong></div>
|
||||
<div class="upload-telemetry-row"><span class="upload-telemetry-label">Verbleibend</span><strong class="upload-telemetry-value upload-rolling-value" id="uploadTelemetryRemaining" data-numeric-value="0" aria-label="0"><span>0</span></strong></div>
|
||||
<div class="upload-telemetry-row"><span class="upload-telemetry-label">Läuft</span><strong class="upload-telemetry-value upload-rolling-value" id="uploadTelemetryRunning" data-numeric-value="0" aria-label="0"><span>0</span></strong></div>
|
||||
<div class="upload-telemetry-row"><span class="upload-telemetry-label">Fertig</span><strong class="upload-telemetry-value upload-rolling-value" id="uploadTelemetryCompleted" data-numeric-value="0" aria-label="0"><span>0</span></strong></div>
|
||||
<div class="upload-telemetry-row"><span class="upload-telemetry-label">Fehler</span><strong class="upload-telemetry-value upload-rolling-value" id="uploadTelemetryFailed" data-numeric-value="0" aria-label="0"><span>0</span></strong></div>
|
||||
<div class="upload-telemetry-row"><span class="upload-telemetry-label">Geschwindigkeit</span><strong class="upload-telemetry-value" id="uploadTelemetrySpeed" aria-label="0 B/s">0 B/s</strong></div>
|
||||
@@ -423,32 +423,6 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="health-check-results account-health-results" id="healthCheckResults"></div>
|
||||
<section class="hoster-health-overview" id="hosterHealthOverview" role="region" aria-labelledby="hosterHealthTitle" aria-describedby="hosterHealthHint">
|
||||
<div class="hoster-health-heading">
|
||||
<h3 id="hosterHealthTitle">Hoster-Gesundheit</h3>
|
||||
<p id="hosterHealthHint">Lokale Werte aus höchstens 50 Batches. Der effektive historische Durchsatz kann Wartezeiten und Wiederholungen enthalten.</p>
|
||||
</div>
|
||||
<div class="hoster-health-scroll" tabindex="0">
|
||||
<table>
|
||||
<caption class="hoster-health-caption">Hoster-Gesundheit aus lokalem Verlauf und aktuellem Accountstatus</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Hoster</th>
|
||||
<th scope="col">Stichprobe</th>
|
||||
<th scope="col">Erfolg / Fehler / Übersprungen</th>
|
||||
<th scope="col">Erfolgsrate</th>
|
||||
<th scope="col" data-health-column="throughput">Effektiver historischer Durchsatz</th>
|
||||
<th scope="col">Letzter Erfolg</th>
|
||||
<th scope="col">Fehler (7 Tage)</th>
|
||||
<th scope="col">Account-Probleme</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="hosterHealthBody">
|
||||
<tr><td colspan="8" data-hoster-health-empty>Wird geladen…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
<div class="accounts-list" id="accountsList"></div>
|
||||
<div class="accounts-list-footer" id="accountsListFooter" style="display:none">
|
||||
<button class="btn btn-secondary" id="toggleAllAccountsBtn">Alle ausklappen</button>
|
||||
@@ -456,8 +430,8 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="accountModal" style="display:none" aria-hidden="true">
|
||||
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="accountModalTitle" aria-describedby="accountModalSubtitle" tabindex="-1">
|
||||
<div class="modal-overlay" id="accountModal" style="display:none">
|
||||
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="accountModalTitle" aria-describedby="accountModalSubtitle">
|
||||
<div class="modal-header">
|
||||
<div>
|
||||
<h3 id="accountModalTitle">Account hinzufügen</h3>
|
||||
@@ -484,8 +458,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="jobLogModal" style="display:none" aria-hidden="true">
|
||||
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="jobLogTitle" tabindex="-1" style="width:min(820px,96%);max-height:80vh;display:flex;flex-direction:column">
|
||||
<div class="modal-overlay" id="jobLogModal" style="display:none">
|
||||
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="jobLogTitle" style="width:min(820px,96%);max-height:80vh;display:flex;flex-direction:column">
|
||||
<div class="modal-header">
|
||||
<div><h3 id="jobLogTitle">Upload-Log</h3></div>
|
||||
<button class="icon-btn" id="closeJobLogBtn" aria-label="Schließen">×</button>
|
||||
@@ -500,8 +474,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="deleteAccountModal" style="display:none" aria-hidden="true">
|
||||
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="deleteAccountTitle" aria-describedby="deleteAccountMessage" tabindex="-1" style="width:min(400px,100%)">
|
||||
<div class="modal-overlay" id="deleteAccountModal" style="display:none">
|
||||
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="deleteAccountTitle" aria-describedby="deleteAccountMessage" style="width:min(400px,100%)">
|
||||
<div class="modal-header">
|
||||
<div><h3 id="deleteAccountTitle">Account löschen?</h3></div>
|
||||
<button class="icon-btn" id="closeDeleteModalBtn" aria-label="Schließen">×</button>
|
||||
@@ -517,7 +491,7 @@
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay app-alert-modal" id="appAlertModal" style="display:none" aria-hidden="true">
|
||||
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="appAlertTitle" aria-describedby="appAlertMessage" tabindex="-1" style="width:min(420px,100%)">
|
||||
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="appAlertTitle" aria-describedby="appAlertMessage" style="width:min(420px,100%)">
|
||||
<div class="modal-header">
|
||||
<div><h3 id="appAlertTitle">Hinweis</h3></div>
|
||||
<button class="icon-btn" id="appAlertCloseBtn" aria-label="Schließen">×</button>
|
||||
@@ -534,7 +508,7 @@
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="historyClearModal" style="display:none" aria-hidden="true">
|
||||
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="historyClearModalTitle" aria-describedby="historyClearModalMessage" tabindex="-1" style="width:min(440px,100%)">
|
||||
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="historyClearModalTitle" aria-describedby="historyClearModalMessage" style="width:min(440px,100%)">
|
||||
<div class="modal-header">
|
||||
<div><h3 id="historyClearModalTitle">Verlauf löschen?</h3></div>
|
||||
<button class="icon-btn" id="closeHistoryClearModalBtn" aria-label="Schließen">×</button>
|
||||
@@ -667,104 +641,15 @@
|
||||
<button class="btn btn-xs btn-secondary" id="cancelStartupResumeBtn">Abbrechen</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="batchCompletionModal" style="display:none" aria-hidden="true">
|
||||
<div class="modal-card batch-completion-card" role="dialog" aria-modal="true" aria-labelledby="batchCompletionTitle" aria-describedby="batchCompletionSummary" tabindex="-1">
|
||||
<div class="modal-header batch-completion-header">
|
||||
<div class="batch-completion-heading">
|
||||
<span class="batch-completion-outcome" id="batchCompletionOutcome">Erfolgreich</span>
|
||||
<h3 id="batchCompletionTitle">Batch abgeschlossen</h3>
|
||||
<p id="batchCompletionSummary">Der Upload-Batch wurde abgeschlossen.</p>
|
||||
</div>
|
||||
<button class="icon-btn" id="batchCompletionHeaderCloseBtn" aria-label="Schließen">×</button>
|
||||
</div>
|
||||
<div class="modal-body batch-completion-body">
|
||||
<dl class="batch-completion-transfer" aria-label="Übertragung">
|
||||
<div><dt>Dauer</dt><dd id="batchCompletionDuration">00:00:00</dd></div>
|
||||
<div><dt>Erfolgreich übertragen</dt><dd id="batchCompletionSuccessfulBytes">0 B</dd></div>
|
||||
<div><dt>Durchschnitt</dt><dd id="batchCompletionAverageSpeed">0 B/s</dd></div>
|
||||
</dl>
|
||||
|
||||
<div class="batch-completion-metric-sections">
|
||||
<section class="batch-completion-section" aria-labelledby="batchCompletionFilesTitle">
|
||||
<h4 id="batchCompletionFilesTitle">Dateien</h4>
|
||||
<dl class="batch-completion-metrics">
|
||||
<div><dt>Gesamt</dt><dd id="batchCompletionFilesTotal">0</dd></div>
|
||||
<div><dt>Vollständig erfolgreich</dt><dd id="batchCompletionFilesFullySucceeded">0</dd></div>
|
||||
<div><dt>Teilweise erfolgreich</dt><dd id="batchCompletionFilesPartiallySucceeded">0</dd></div>
|
||||
<div><dt>Fehlgeschlagen</dt><dd id="batchCompletionFilesFailed">0</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="batch-completion-section" aria-labelledby="batchCompletionJobsTitle">
|
||||
<h4 id="batchCompletionJobsTitle">Aufträge</h4>
|
||||
<dl class="batch-completion-metrics batch-completion-job-metrics">
|
||||
<div><dt>Gesamt</dt><dd id="batchCompletionJobsTotal">0</dd></div>
|
||||
<div><dt>Erfolgreich</dt><dd id="batchCompletionJobsSucceeded">0</dd></div>
|
||||
<div><dt>Fehlgeschlagen</dt><dd id="batchCompletionJobsFailed">0</dd></div>
|
||||
<div><dt>Übersprungen</dt><dd id="batchCompletionJobsSkipped">0</dd></div>
|
||||
<div><dt>Abgebrochen</dt><dd id="batchCompletionJobsAborted">0</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="batch-completion-section" aria-labelledby="batchCompletionCleanupTitle">
|
||||
<h4 id="batchCompletionCleanupTitle">Quelldateien</h4>
|
||||
<dl class="batch-completion-metrics">
|
||||
<div><dt>Angefordert</dt><dd id="batchCompletionCleanupRequested">0</dd></div>
|
||||
<div><dt>Gelöscht</dt><dd id="batchCompletionCleanupDeleted">0</dd></div>
|
||||
<div><dt>Blockiert</dt><dd id="batchCompletionCleanupBlocked">0</dd></div>
|
||||
<div><dt>Fehlgeschlagen</dt><dd id="batchCompletionCleanupFailed">0</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="batch-completion-section batch-completion-hosters" aria-labelledby="batchCompletionHostersTitle">
|
||||
<h4 id="batchCompletionHostersTitle">Hosterübersicht</h4>
|
||||
<div class="batch-completion-table-scroll" tabindex="0">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Hoster</th>
|
||||
<th scope="col">Aufträge</th>
|
||||
<th scope="col">Erfolgreich</th>
|
||||
<th scope="col">Fehlgeschlagen</th>
|
||||
<th scope="col">Übersprungen</th>
|
||||
<th scope="col">Abgebrochen</th>
|
||||
<th scope="col">Übertragen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="batchCompletionHostersBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="batch-completion-section batch-completion-errors" id="batchCompletionErrorsSection" aria-labelledby="batchCompletionErrorsTitle" hidden>
|
||||
<div class="batch-completion-section-heading">
|
||||
<h4 id="batchCompletionErrorsTitle">Fehlerbeispiele</h4>
|
||||
<span id="batchCompletionErrorsCount">0</span>
|
||||
</div>
|
||||
<ol id="batchCompletionErrorsList"></ol>
|
||||
<p id="batchCompletionErrorsMore" hidden></p>
|
||||
</section>
|
||||
</div>
|
||||
<div class="modal-footer batch-completion-footer">
|
||||
<div class="batch-completion-export-actions">
|
||||
<button class="btn btn-secondary" id="batchCompletionExportJsonBtn">JSON exportieren</button>
|
||||
<button class="btn btn-secondary" id="batchCompletionExportCsvBtn">Fehler-CSV exportieren</button>
|
||||
</div>
|
||||
<button class="btn btn-primary" id="batchCompletionCloseBtn">Schließen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="shutdown-overlay" id="shutdownOverlay" style="display:none" aria-hidden="true">
|
||||
<div class="shutdown-box" role="dialog" aria-modal="true" aria-labelledby="shutdownMessage" tabindex="-1">
|
||||
<div class="shutdown-box" role="dialog" aria-modal="true" aria-labelledby="shutdownMessage">
|
||||
<p id="shutdownMessage">System wird heruntergefahren in <span id="shutdownSeconds">60</span>s...</p>
|
||||
<button class="btn btn-danger" id="cancelShutdownBtn">Abbrechen</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="hosterModal" style="display:none" aria-hidden="true">
|
||||
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="hosterModalTitle" aria-describedby="hosterModalDescription" tabindex="-1">
|
||||
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="hosterModalTitle" aria-describedby="hosterModalDescription">
|
||||
<div class="modal-header">
|
||||
<div>
|
||||
<h3 id="hosterModalTitle">Upload-Ziele auswählen</h3>
|
||||
@@ -781,7 +666,6 @@
|
||||
<dl class="import-plan-summary" id="importPlanSummary" aria-label="Import-Vorabprüfung">
|
||||
<div><dt>Kandidaten</dt><dd id="importPlanCandidates">0</dd></div>
|
||||
<div><dt>Bereits vorhanden / dupliziert</dt><dd id="importPlanDuplicates">0</dd></div>
|
||||
<div><dt>Durch Dateinamenfilter ausgeschlossen</dt><dd id="importPlanFiltered">0</dd></div>
|
||||
<div><dt>Fehlend / unlesbar / leer</dt><dd id="importPlanUnavailable">0</dd></div>
|
||||
<div><dt>Akzeptierte Dateien</dt><dd id="importPlanAccepted">0</dd></div>
|
||||
<div><dt>Ausgewählte Ziele</dt><dd id="importPlanTargets">0</dd></div>
|
||||
@@ -807,10 +691,7 @@
|
||||
<script src="../lib/throttle-timer.js"></script>
|
||||
<script src="../lib/serialized-runner.js"></script>
|
||||
<script src="../lib/speed-history.js"></script>
|
||||
<script src="../lib/upload-recovery.js"></script>
|
||||
<script src="../lib/filename-filter.js"></script>
|
||||
<script src="../lib/import-preflight.js"></script>
|
||||
<script src="../lib/upload-schedule.js"></script>
|
||||
<script src="account-submit.js"></script>
|
||||
<script src="account-status.js"></script>
|
||||
<script src="history-status.js"></script>
|
||||
|
||||
+50
-575
@@ -29,7 +29,6 @@
|
||||
body {
|
||||
font-family: 'Aptos', 'Segoe UI Variable Text', 'Bahnschrift', sans-serif;
|
||||
background: var(--bg-primary);
|
||||
cursor: default;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
color: var(--text);
|
||||
@@ -499,7 +498,7 @@ body.col-resizing, body.col-resizing * { cursor: col-resize !important; user-sel
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.status-badge.status-preview { color: var(--success); background: rgba(0, 184, 148, 0.15); }
|
||||
.status-badge.status-preview { color: var(--text-muted); }
|
||||
.status-badge.status-queued { color: var(--text-muted); background: rgba(255,255,255,0.05); }
|
||||
.status-badge.status-getting-server { color: var(--accent); }
|
||||
.status-badge.status-uploading { color: #5dabf7; background: rgba(93, 171, 247, 0.15); }
|
||||
@@ -735,239 +734,6 @@ body.col-resizing, body.col-resizing * { cursor: col-resize !important; user-sel
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.batch-completion-card {
|
||||
width: min(920px, 100%);
|
||||
max-height: min(calc(100vh - 48px), 820px);
|
||||
}
|
||||
.batch-completion-header {
|
||||
align-items: flex-start;
|
||||
}
|
||||
.batch-completion-heading {
|
||||
min-width: 0;
|
||||
}
|
||||
.batch-completion-outcome {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 22px;
|
||||
margin-bottom: 7px;
|
||||
padding: 3px 8px;
|
||||
border: 1px solid rgba(52, 211, 153, .38);
|
||||
border-radius: 999px;
|
||||
background: rgba(52, 211, 153, .12);
|
||||
color: var(--success);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: .04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
#batchCompletionModal[data-outcome="mixed"] .batch-completion-outcome {
|
||||
border-color: rgba(245, 158, 11, .42);
|
||||
background: rgba(245, 158, 11, .12);
|
||||
color: var(--warning);
|
||||
}
|
||||
.batch-completion-body {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
.batch-completion-transfer,
|
||||
.batch-completion-metrics {
|
||||
margin: 0;
|
||||
}
|
||||
.batch-completion-transfer {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.batch-completion-transfer > div,
|
||||
.batch-completion-metrics > div {
|
||||
min-width: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, .025);
|
||||
}
|
||||
.batch-completion-transfer > div {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.batch-completion-transfer dt,
|
||||
.batch-completion-metrics dt {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
.batch-completion-transfer dd,
|
||||
.batch-completion-metrics dd {
|
||||
margin: 4px 0 0;
|
||||
color: var(--text-primary);
|
||||
font-size: 16px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 700;
|
||||
}
|
||||
.batch-completion-metric-sections {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.batch-completion-section {
|
||||
min-width: 0;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, .018);
|
||||
}
|
||||
.batch-completion-section h4 {
|
||||
margin: 0 0 9px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.batch-completion-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 7px;
|
||||
}
|
||||
.batch-completion-metrics > div {
|
||||
padding: 8px 9px;
|
||||
}
|
||||
.batch-completion-job-metrics > div:first-child {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.batch-completion-table-scroll {
|
||||
max-width: 100%;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.batch-completion-table-scroll:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.batch-completion-table-scroll table {
|
||||
width: 100%;
|
||||
min-width: 650px;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.batch-completion-table-scroll th,
|
||||
.batch-completion-table-scroll td {
|
||||
padding: 7px 9px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.batch-completion-table-scroll th {
|
||||
color: var(--text-muted);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.batch-completion-table-scroll th:first-child,
|
||||
.batch-completion-table-scroll td:first-child {
|
||||
text-align: left;
|
||||
}
|
||||
.batch-completion-table-scroll tbody tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.batch-completion-section-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.batch-completion-section-heading h4 {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.batch-completion-section-heading span {
|
||||
min-width: 24px;
|
||||
padding: 2px 7px;
|
||||
border-radius: 999px;
|
||||
background: rgba(239, 68, 68, .14);
|
||||
color: var(--danger);
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.batch-completion-errors ol {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin: 10px 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.batch-completion-errors li {
|
||||
min-width: 0;
|
||||
padding: 9px 10px;
|
||||
border: 1px solid rgba(239, 68, 68, .22);
|
||||
border-radius: 8px;
|
||||
background: rgba(239, 68, 68, .055);
|
||||
}
|
||||
.batch-completion-error-head,
|
||||
.batch-completion-error-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 10px;
|
||||
}
|
||||
.batch-completion-error-file {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.batch-completion-error-meta {
|
||||
margin-top: 4px;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
.batch-completion-error-message {
|
||||
margin: 7px 0 0;
|
||||
color: var(--text-secondary);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.batch-completion-error-uncertain {
|
||||
color: var(--warning);
|
||||
font-weight: 700;
|
||||
}
|
||||
#batchCompletionErrorsMore {
|
||||
margin: 9px 0 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.batch-completion-footer,
|
||||
.batch-completion-export-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.batch-completion-footer {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.batch-completion-export-actions {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
@media (max-width: 820px) {
|
||||
.batch-completion-card {
|
||||
max-height: calc(100vh - 24px);
|
||||
}
|
||||
.batch-completion-metric-sections {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.batch-completion-metric-sections > :last-child {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
@media (max-width: 620px) {
|
||||
.batch-completion-transfer,
|
||||
.batch-completion-metric-sections {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.batch-completion-metric-sections > :last-child {
|
||||
grid-column: auto;
|
||||
}
|
||||
.batch-completion-footer,
|
||||
.batch-completion-export-actions {
|
||||
align-items: stretch;
|
||||
width: 100%;
|
||||
}
|
||||
.batch-completion-footer > .btn,
|
||||
.batch-completion-export-actions .btn {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
}
|
||||
.hoster-modal-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
@@ -981,13 +747,13 @@ body.col-resizing, body.col-resizing * { cursor: col-resize !important; user-sel
|
||||
.import-plan-summary > div {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
align-items: start;
|
||||
gap: 8px;
|
||||
min-height: 38px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.025);
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
.import-plan-summary dt {
|
||||
min-width: 0;
|
||||
@@ -1002,6 +768,9 @@ body.col-resizing, body.col-resizing * { cursor: col-resize !important; user-sel
|
||||
color: var(--text);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.hoster-modal-list + .import-plan-summary + .modal-hint {
|
||||
margin: 10px 0 2px;
|
||||
}
|
||||
.hoster-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1266,7 +1035,6 @@ body.col-resizing, body.col-resizing * { cursor: col-resize !important; user-sel
|
||||
.panel-status { font-size: 10px; padding: 2px 8px; border-radius: 3px; }
|
||||
.panel-status.active { background: rgba(0, 184, 148, 0.2); color: var(--success); }
|
||||
.panel-status.inactive { background: rgba(255, 255, 255, 0.05); color: var(--text-dim); }
|
||||
.panel-status.warning { background: rgba(245, 166, 35, 0.16); color: var(--warning); }
|
||||
|
||||
.hoster-panel-body { padding: 0 14px 14px; }
|
||||
.settings-divider { height: 1px; background: var(--border); margin: 12px 0; }
|
||||
@@ -1426,61 +1194,6 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; }
|
||||
}
|
||||
.settings-subpage .settings-grid-mini .checkbox-row label { min-width: 0; cursor: pointer; }
|
||||
.settings-subpage .settings-grid-mini .checkbox-row input[type="checkbox"] { order: 2; width: 18px; height: 18px; accent-color: var(--accent); cursor: pointer; }
|
||||
.upload-schedule-panel {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
border: 1px solid color-mix(in srgb, var(--border) 90%, transparent);
|
||||
border-radius: 9px;
|
||||
background: color-mix(in srgb, var(--bg-input) 62%, transparent);
|
||||
}
|
||||
.upload-schedule-panel .settings-option { margin: 0; }
|
||||
.upload-schedule-days {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(48px, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
.upload-schedule-days label { min-width: 0; cursor: pointer; }
|
||||
.upload-schedule-days input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.upload-schedule-days span {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 34px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 7px;
|
||||
background: var(--bg-input);
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
transition: background-color 160ms ease, border-color 160ms ease, color 160ms ease, opacity 160ms ease;
|
||||
}
|
||||
.upload-schedule-days input:checked + span {
|
||||
border-color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 24%, var(--bg-input));
|
||||
color: var(--text);
|
||||
}
|
||||
.upload-schedule-days input:focus-visible + span { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.upload-schedule-days input:disabled + span { opacity: 0.42; cursor: default; }
|
||||
.upload-schedule-times {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(150px, 1fr) minmax(150px, 1fr) minmax(220px, 1.5fr);
|
||||
gap: 10px;
|
||||
align-items: end;
|
||||
}
|
||||
.upload-schedule-times label { display: grid; gap: 6px; color: var(--text); font-size: 12px; font-weight: 600; }
|
||||
.upload-schedule-times .hs-input { width: 100%; max-width: none; }
|
||||
.upload-schedule-times .hint { align-self: center; line-height: 1.45; }
|
||||
.upload-schedule-status {
|
||||
min-height: 18px;
|
||||
margin: 0;
|
||||
color: var(--text-dim);
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.settings-hoster-pointer {
|
||||
margin-top: 16px;
|
||||
padding: 11px 13px;
|
||||
@@ -1607,29 +1320,6 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; }
|
||||
.account-hoster-settings-header .panel-arrow { font-size: 10px; color: var(--text-dim); }
|
||||
.account-hoster-settings-body-inner { padding: 4px 12px 10px; }
|
||||
|
||||
.account-hoster-settings-body-inner .account-hoster-option-row {
|
||||
grid-column: 1 / -1;
|
||||
display: grid;
|
||||
grid-template-columns: 150px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
column-gap: 12px;
|
||||
}
|
||||
|
||||
.account-hoster-settings-body-inner .account-hoster-option-row label {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.account-hoster-settings-body-inner .account-hoster-option-row .hint {
|
||||
grid-column: 2;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.account-hoster-settings-body-inner .account-hoster-option-row input[type="checkbox"] {
|
||||
grid-column: 3;
|
||||
grid-row: 1;
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.settings-hoster-pointer {
|
||||
margin-top: 12px;
|
||||
padding: 10px 14px;
|
||||
@@ -2079,6 +1769,40 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; }
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
}
|
||||
|
||||
.tab-indicator {
|
||||
transition-duration: 280ms !important;
|
||||
}
|
||||
|
||||
.view-sidebar-indicator,
|
||||
.settings-nav-indicator {
|
||||
transition-duration: 220ms !important;
|
||||
}
|
||||
|
||||
.language-picker-indicator {
|
||||
transition-duration: 240ms !important;
|
||||
}
|
||||
|
||||
.progress-bar-fill {
|
||||
transition-duration: 360ms !important;
|
||||
}
|
||||
|
||||
.menu-opening {
|
||||
animation-duration: 180ms !important;
|
||||
}
|
||||
|
||||
.menu-closing {
|
||||
animation-duration: 160ms !important;
|
||||
}
|
||||
|
||||
.account-collapse {
|
||||
transition-duration: 220ms, 160ms, 0s !important;
|
||||
}
|
||||
|
||||
.account-hoster-group-header .panel-arrow,
|
||||
.account-hoster-settings-header .panel-arrow {
|
||||
transition-duration: 180ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
.online-backup-panel {
|
||||
@@ -2162,9 +1886,6 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; }
|
||||
.settings-navigation { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.settings-content { overflow: visible; }
|
||||
.settings-grid-mini { grid-template-columns: 1fr; }
|
||||
.upload-schedule-days { grid-template-columns: repeat(4, minmax(48px, 1fr)); }
|
||||
.upload-schedule-times { grid-template-columns: 1fr 1fr; }
|
||||
.upload-schedule-times .hint { grid-column: 1 / -1; }
|
||||
.online-backup-key-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@@ -2683,10 +2404,6 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; }
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.update-progress[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.update-release-notes {
|
||||
grid-column: 1 / -1;
|
||||
max-height: 150px;
|
||||
@@ -3405,7 +3122,8 @@ input[type="checkbox"] {
|
||||
.settings-nav-button {
|
||||
min-height: 36px;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border: 1px solid transparent;
|
||||
border-left: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
@@ -3649,103 +3367,6 @@ input[type="checkbox"] {
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
.filename-filter-panel {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.filename-filter-builder {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-card);
|
||||
transition: opacity 160ms ease, border-color 160ms ease;
|
||||
}
|
||||
|
||||
.filename-filter-builder.disabled {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.filename-filter-policy {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.filename-filter-field {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.filename-filter-field label {
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.filename-filter-policy .hs-input,
|
||||
.filename-filter-condition .hs-input,
|
||||
.filename-filter-condition .key-input {
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.filename-filter-conditions {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.filename-filter-condition {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(240px, 1.35fr) minmax(150px, 0.65fr) auto;
|
||||
gap: 8px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.filename-filter-rule-field {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.filename-filter-rule-label {
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.filename-filter-footer {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.filename-filter-footer .hint {
|
||||
margin-left: auto;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.filename-filter-policy,
|
||||
.filename-filter-condition {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.filename-filter-footer {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.filename-filter-footer .hint {
|
||||
margin-left: 0;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-option-description,
|
||||
.hint {
|
||||
color: var(--text-dim);
|
||||
@@ -3802,7 +3423,7 @@ input[type="checkbox"] {
|
||||
}
|
||||
|
||||
#settingsSearchInput {
|
||||
font-size: 11px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.settings-nav-button {
|
||||
@@ -3973,115 +3594,6 @@ input[type="checkbox"] {
|
||||
padding: 10px 16px 0;
|
||||
}
|
||||
|
||||
.hoster-health-overview {
|
||||
flex: 0 0 auto;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
padding: 10px 16px 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hoster-health-heading {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 7px;
|
||||
}
|
||||
|
||||
.hoster-health-heading h3 {
|
||||
flex: 0 0 auto;
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.hoster-health-heading p {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
color: var(--text-dim);
|
||||
font-size: 10px;
|
||||
line-height: 1.35;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.hoster-health-scroll {
|
||||
max-width: 100%;
|
||||
max-height: 190px;
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.hoster-health-scroll:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.hoster-health-scroll table {
|
||||
width: 100%;
|
||||
min-width: 880px;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
font-size: 10px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.hoster-health-caption {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.hoster-health-scroll th,
|
||||
.hoster-health-scroll td {
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--text-muted);
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.hoster-health-scroll thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: var(--bg-raised);
|
||||
color: var(--text-dim);
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.hoster-health-scroll tbody th {
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.hoster-health-scroll tbody tr:last-child > * {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.hoster-health-scroll tbody tr:hover > * {
|
||||
background: var(--bg-raised);
|
||||
}
|
||||
|
||||
.hoster-health-scroll [data-hoster-health-empty] {
|
||||
height: 38px;
|
||||
color: var(--text-dim);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.accounts-list {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
@@ -4208,47 +3720,6 @@ input[type="checkbox"] {
|
||||
box-shadow: 0 24px 64px rgba(0, 0, 0, .48);
|
||||
}
|
||||
|
||||
#accountModal.account-modal-opening {
|
||||
animation: account-modal-dim-in 360ms cubic-bezier(.16, 1, .3, 1) both;
|
||||
}
|
||||
|
||||
#accountModal.account-modal-opening > .modal-card {
|
||||
transform-origin: top center;
|
||||
animation: account-modal-unfold 360ms cubic-bezier(.16, 1, .3, 1) both;
|
||||
}
|
||||
|
||||
#accountModal.account-modal-closing {
|
||||
pointer-events: none;
|
||||
animation: account-modal-dim-out 300ms cubic-bezier(.4, 0, .2, 1) both;
|
||||
}
|
||||
|
||||
#accountModal.account-modal-closing > .modal-card {
|
||||
transform-origin: top center;
|
||||
animation: account-modal-fold 300ms cubic-bezier(.4, 0, .2, 1) both;
|
||||
}
|
||||
|
||||
@keyframes account-modal-dim-in {
|
||||
from { background: rgba(0, 0, 0, 0); }
|
||||
to { background: rgba(0, 0, 0, .6); }
|
||||
}
|
||||
|
||||
@keyframes account-modal-dim-out {
|
||||
from { background: rgba(0, 0, 0, .6); }
|
||||
to { background: rgba(0, 0, 0, 0); }
|
||||
}
|
||||
|
||||
@keyframes account-modal-unfold {
|
||||
0% { opacity: 0; clip-path: inset(0 0 18% 0 round 12px); transform: translateY(-16px) scale(.985); }
|
||||
55% { opacity: .96; }
|
||||
100% { opacity: 1; clip-path: inset(0 round 12px); transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
@keyframes account-modal-fold {
|
||||
0% { opacity: 1; clip-path: inset(0 round 12px); transform: translateY(0) scale(1); }
|
||||
45% { opacity: .9; }
|
||||
100% { opacity: 0; clip-path: inset(0 0 18% 0 round 12px); transform: translateY(-16px) scale(.985); }
|
||||
}
|
||||
|
||||
.modal-header,
|
||||
.modal-footer {
|
||||
padding: 16px 18px;
|
||||
@@ -4566,9 +4037,13 @@ input[type="checkbox"] {
|
||||
}
|
||||
|
||||
.upload-speed-sparkline {
|
||||
width: 118px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) max-content;
|
||||
width: 64px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.upload-speed-sparkline canvas {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.settings-layout {
|
||||
|
||||
+16
-4
@@ -1,20 +1,29 @@
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
|
||||
module.exports = async function afterPack(context) {
|
||||
const rcedit = require("rcedit");
|
||||
let rcedit;
|
||||
try {
|
||||
rcedit = require("rcedit");
|
||||
} catch {
|
||||
console.warn(" rcedit: skipped - rcedit not installed");
|
||||
return;
|
||||
}
|
||||
|
||||
const productFilename = context.packager?.appInfo?.productFilename;
|
||||
const version = context.packager?.appInfo?.version;
|
||||
if (!productFilename || !version) {
|
||||
throw new Error("Application metadata is unavailable");
|
||||
console.warn(" rcedit: skipped - application metadata not available");
|
||||
return;
|
||||
}
|
||||
|
||||
const exePath = path.join(context.appOutDir, `${productFilename}.exe`);
|
||||
const iconPath = path.resolve(__dirname, "..", "assets", "app_icon.ico");
|
||||
|
||||
try {
|
||||
const fs = require("fs");
|
||||
if (!fs.existsSync(iconPath)) {
|
||||
throw new Error("Application icon is unavailable");
|
||||
console.warn(" rcedit: skipped - app_icon.ico not found");
|
||||
return;
|
||||
}
|
||||
console.log(` rcedit: branding executable -> ${exePath}`);
|
||||
await rcedit(exePath, {
|
||||
@@ -29,4 +38,7 @@ module.exports = async function afterPack(context) {
|
||||
ProductName: "Multi Hoster Uploader"
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn(` rcedit: failed - ${String(error)}`);
|
||||
}
|
||||
};
|
||||
|
||||
+41
-167
@@ -6,100 +6,18 @@ const chokidar = require('chokidar');
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const electron = require('electron');
|
||||
const lockPath = path.join(root, '.dev-runner.lock');
|
||||
let lockHandle = null;
|
||||
|
||||
function createWatchedPaths(projectRoot) {
|
||||
return [
|
||||
const watched = [
|
||||
'main.js',
|
||||
'preload.js',
|
||||
'preload-drop-target.js',
|
||||
'lib',
|
||||
'renderer'
|
||||
].map(target => path.join(projectRoot, target));
|
||||
}
|
||||
path.join(root, 'lib'),
|
||||
path.join(root, 'renderer')
|
||||
].map(target => path.isAbsolute(target) ? target : path.join(root, target));
|
||||
|
||||
function formatChangeMessage(file) {
|
||||
return `[hotdev] change detected: ${file}\n`;
|
||||
}
|
||||
|
||||
function createRestartController({ startChild, stopChild, onUnexpectedExit = () => {} }) {
|
||||
let child = null;
|
||||
let terminatingChild = null;
|
||||
let restartScheduled = false;
|
||||
let restartWaiters = [];
|
||||
let cycleDone = Promise.resolve();
|
||||
let restartTimer = null;
|
||||
let stopping = false;
|
||||
let shutdownPromise = null;
|
||||
|
||||
function start() {
|
||||
if (stopping || child) return child;
|
||||
const startedChild = startChild();
|
||||
child = startedChild;
|
||||
startedChild.once('exit', (code, signal) => {
|
||||
if (child !== startedChild) return;
|
||||
const expectedExit = stopping || terminatingChild === startedChild;
|
||||
child = null;
|
||||
if (!expectedExit && code !== 0 && signal !== 'SIGTERM') onUnexpectedExit(code, signal);
|
||||
});
|
||||
return startedChild;
|
||||
}
|
||||
|
||||
async function stopCurrentChild() {
|
||||
const target = child;
|
||||
if (!target || !target.pid) return;
|
||||
terminatingChild = target;
|
||||
try {
|
||||
await stopChild(target);
|
||||
} catch (error) {
|
||||
if (child === target) throw error;
|
||||
} finally {
|
||||
if (terminatingChild === target) terminatingChild = null;
|
||||
}
|
||||
if (child === target) child = null;
|
||||
}
|
||||
|
||||
async function runRestartCycle() {
|
||||
const waiters = restartWaiters;
|
||||
restartWaiters = [];
|
||||
try {
|
||||
await stopCurrentChild();
|
||||
waiters.push(...restartWaiters);
|
||||
restartWaiters = [];
|
||||
if (!stopping) start();
|
||||
for (const waiter of waiters) waiter.resolve();
|
||||
} catch (error) {
|
||||
waiters.push(...restartWaiters);
|
||||
restartWaiters = [];
|
||||
for (const waiter of waiters) waiter.reject(error);
|
||||
} finally {
|
||||
restartScheduled = false;
|
||||
if (restartWaiters.length > 0 && !stopping) beginRestartCycle();
|
||||
}
|
||||
}
|
||||
|
||||
function beginRestartCycle() {
|
||||
restartScheduled = true;
|
||||
cycleDone = runRestartCycle();
|
||||
}
|
||||
|
||||
function restart() {
|
||||
if (stopping) return shutdownPromise || Promise.resolve();
|
||||
const requested = new Promise((resolve, reject) => {
|
||||
restartWaiters.push({ resolve, reject });
|
||||
});
|
||||
if (!restartScheduled) beginRestartCycle();
|
||||
return requested;
|
||||
}
|
||||
|
||||
function shutdown() {
|
||||
if (shutdownPromise) return shutdownPromise;
|
||||
stopping = true;
|
||||
shutdownPromise = cycleDone.then(stopCurrentChild);
|
||||
return shutdownPromise;
|
||||
}
|
||||
|
||||
return { restart, shutdown, start };
|
||||
}
|
||||
let lockHandle = null;
|
||||
|
||||
function processExists(pid) {
|
||||
if (!Number.isInteger(pid) || pid <= 0) return false;
|
||||
@@ -138,115 +56,71 @@ function releaseLock() {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function startElectron() {
|
||||
return spawn(electron, ['.', '--dev'], {
|
||||
function startApp() {
|
||||
const startedChild = spawn(electron, ['.', '--dev'], {
|
||||
cwd: root,
|
||||
stdio: 'inherit',
|
||||
windowsHide: false
|
||||
});
|
||||
child = startedChild;
|
||||
startedChild.once('exit', (code, signal) => {
|
||||
if (child !== startedChild) return;
|
||||
child = null;
|
||||
if (!stopping && code !== 0 && signal !== 'SIGTERM') process.exitCode = code || 1;
|
||||
});
|
||||
}
|
||||
|
||||
function stopProcessTree(child) {
|
||||
if (process.platform === 'win32') {
|
||||
return new Promise((resolve, reject) => {
|
||||
const killer = spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], {
|
||||
stdio: 'ignore',
|
||||
windowsHide: true
|
||||
});
|
||||
killer.once('error', reject);
|
||||
killer.once('close', (code, signal) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
function stopApp(done) {
|
||||
if (!child || !child.pid) {
|
||||
done();
|
||||
return;
|
||||
}
|
||||
reject(new Error(`taskkill failed for Electron PID ${child.pid}: ${code ?? signal ?? 'unknown'}`));
|
||||
});
|
||||
});
|
||||
const pid = child.pid;
|
||||
child = null;
|
||||
if (process.platform === 'win32') {
|
||||
const killer = spawn('taskkill', ['/pid', String(pid), '/t', '/f'], { stdio: 'ignore', windowsHide: true });
|
||||
killer.once('close', done);
|
||||
return;
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const handleExit = () => resolve();
|
||||
child.once('exit', handleExit);
|
||||
try {
|
||||
process.kill(child.pid, 'SIGTERM');
|
||||
} catch (error) {
|
||||
child.removeListener('exit', handleExit);
|
||||
reject(error);
|
||||
process.kill(pid, 'SIGTERM');
|
||||
done();
|
||||
}
|
||||
});
|
||||
|
||||
function restartApp() {
|
||||
if (stopping) return;
|
||||
stopApp(startApp);
|
||||
}
|
||||
|
||||
function scheduleRestart() {
|
||||
clearTimeout(restartTimer);
|
||||
restartTimer = setTimeout(restartApp, 180);
|
||||
}
|
||||
|
||||
function runDevRunner() {
|
||||
if (!acquireLock()) {
|
||||
process.stderr.write('A Multi-Hoster hot-dev runner is already active.\n');
|
||||
process.exit(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = createRestartController({
|
||||
startChild: startElectron,
|
||||
stopChild: stopProcessTree,
|
||||
onUnexpectedExit(code) {
|
||||
process.exitCode = code || 1;
|
||||
}
|
||||
});
|
||||
const watcher = chokidar.watch(createWatchedPaths(root), {
|
||||
const watcher = chokidar.watch(watched, {
|
||||
ignoreInitial: true,
|
||||
usePolling: true,
|
||||
interval: 100,
|
||||
awaitWriteFinish: { stabilityThreshold: 250, pollInterval: 50 }
|
||||
});
|
||||
let restartTimer = null;
|
||||
let shutdownPromise = null;
|
||||
|
||||
function reportRestartFailure(error) {
|
||||
process.stderr.write(`[hotdev] restart failed: ${error.message}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
function scheduleRestart() {
|
||||
clearTimeout(restartTimer);
|
||||
restartTimer = setTimeout(() => {
|
||||
restartTimer = null;
|
||||
controller.restart().catch(reportRestartFailure);
|
||||
}, 180);
|
||||
}
|
||||
|
||||
watcher.on('all', (_event, file) => {
|
||||
process.stdout.write(formatChangeMessage(file));
|
||||
process.stdout.write(`[hotdev] renderer change detected: ${file}\n`);
|
||||
scheduleRestart();
|
||||
});
|
||||
|
||||
function shutdown() {
|
||||
if (shutdownPromise) return shutdownPromise;
|
||||
if (stopping) return;
|
||||
stopping = true;
|
||||
clearTimeout(restartTimer);
|
||||
const appShutdown = controller.shutdown();
|
||||
shutdownPromise = Promise.allSettled([
|
||||
Promise.resolve().then(() => watcher.close()),
|
||||
appShutdown
|
||||
]).then(results => {
|
||||
const failure = results.find(result => result.status === 'rejected');
|
||||
releaseLock();
|
||||
if (failure) {
|
||||
process.stderr.write(`[hotdev] shutdown failed: ${failure.reason.message}\n`);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
process.exit(0);
|
||||
});
|
||||
return shutdownPromise;
|
||||
watcher.close().finally(() => stopApp(() => { releaseLock(); process.exit(0); }));
|
||||
}
|
||||
|
||||
process.once('SIGINT', shutdown);
|
||||
process.once('SIGTERM', shutdown);
|
||||
process.once('exit', () => {
|
||||
clearTimeout(restartTimer);
|
||||
controller.shutdown().catch(() => {});
|
||||
releaseLock();
|
||||
});
|
||||
process.once('exit', () => { stopping = true; releaseLock(); });
|
||||
|
||||
controller.start();
|
||||
}
|
||||
|
||||
module.exports = { createRestartController, createWatchedPaths, formatChangeMessage };
|
||||
|
||||
if (require.main === module) runDevRunner();
|
||||
startApp();
|
||||
|
||||
+13
-77
@@ -1,111 +1,47 @@
|
||||
const PRODUCT_NAME = 'Multi Hoster Uploader';
|
||||
const ARTIFACT_NAME = 'Multi-Hoster-Upload';
|
||||
const NON_ENGLISH_RELEASE_PATTERN = /\b(?:das|dies(?:e|er|es)|fehler|für|hinzugefügt|jetzt|korrigiert|laufen|mit|nach|probleme|programm|sicherheit|stabil|und|verbessert|verbesserungen|werden|wieder|wurde|wurden|zuverlässig\w*|amélior\w*|après|avec|corrig\w*|et|les|mises|pour|reprennent|réseau|sécurité|téléversements)\b/iu;
|
||||
const ENGLISH_RELEASE_PATTERN = /\b(?:added|after|and|are|bridge|cancel|changes|credential|files?|faster|fixed|hardened|improved|installation|more|new|notes?|now|recovery|release|reliable|resume|security|safer|smoother|source|the|updates?|uploads?|with|without)\b/i;
|
||||
|
||||
function requireEnglishReleaseNotes(value) {
|
||||
const notes = typeof value === 'string' ? value.trim() : '';
|
||||
const words = notes.match(/[A-Za-z]+(?:['’-][A-Za-z]+)*/g) || [];
|
||||
if (words.length < 2 || /(?![A-Za-z])\p{L}/u.test(notes) || NON_ENGLISH_RELEASE_PATTERN.test(notes) || !ENGLISH_RELEASE_PATTERN.test(notes)) {
|
||||
throw new Error('English release notes are required');
|
||||
}
|
||||
return notes;
|
||||
}
|
||||
const PRODUCT_NAME = 'Multi-Hoster-Upload';
|
||||
|
||||
export function parseReleaseArgs(args) {
|
||||
const version = Array.isArray(args) ? args[0] : '';
|
||||
if (!/^\d+\.\d+\.\d+$/.test(version || '')) {
|
||||
throw new Error('Usage: <version> --transport-tag <vX.Y.Z> --notes <English release notes> [--dry-run]');
|
||||
}
|
||||
|
||||
let transportTag = '';
|
||||
let dryRun = false;
|
||||
let notes = '';
|
||||
const legacyNoteParts = [];
|
||||
for (let index = 1; index < args.length; index++) {
|
||||
const arg = args[index];
|
||||
if (arg === '--transport-tag') {
|
||||
if (transportTag) {
|
||||
throw new Error('Duplicate option: --transport-tag');
|
||||
}
|
||||
transportTag = args[++index] || '';
|
||||
if (!/^v\d+\.\d+\.\d+$/.test(transportTag)) {
|
||||
throw new Error('--transport-tag must match vX.Y.Z');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (arg === '--notes') {
|
||||
if (notes) {
|
||||
throw new Error('Duplicate option: --notes');
|
||||
}
|
||||
const value = args[++index];
|
||||
if (typeof value !== 'string' || !value.trim() || value.startsWith('-')) {
|
||||
throw new Error('--notes requires English release notes');
|
||||
}
|
||||
notes = value;
|
||||
continue;
|
||||
}
|
||||
if (arg === '--dry-run') {
|
||||
if (dryRun) {
|
||||
throw new Error('Duplicate option: --dry-run');
|
||||
}
|
||||
dryRun = true;
|
||||
continue;
|
||||
}
|
||||
if (typeof arg !== 'string' || arg.startsWith('-')) {
|
||||
throw new Error(`Unknown option: ${String(arg)}`);
|
||||
}
|
||||
if (notes) {
|
||||
throw new Error(`Unexpected release note argument: ${arg}`);
|
||||
}
|
||||
legacyNoteParts.push(arg);
|
||||
throw new Error('Usage: <version> --transport-tag <vX.Y.Z> [release notes] [--dry-run]');
|
||||
}
|
||||
|
||||
const transportTagIndex = args.indexOf('--transport-tag');
|
||||
const transportTag = transportTagIndex >= 0 ? args[transportTagIndex + 1] : '';
|
||||
if (!/^v\d+\.\d+\.\d+$/.test(transportTag)) {
|
||||
throw new Error('--transport-tag must match vX.Y.Z');
|
||||
}
|
||||
|
||||
const releaseNotes = requireEnglishReleaseNotes(notes || legacyNoteParts.join(' '));
|
||||
return { version, transportTag, notes: releaseNotes, dryRun };
|
||||
const excludedIndexes = new Set([0, transportTagIndex, transportTagIndex + 1]);
|
||||
const notes = args.filter((arg, index) => !excludedIndexes.has(index) && arg !== '--dry-run').join(' ');
|
||||
return { version, transportTag, notes, dryRun: args.includes('--dry-run') };
|
||||
}
|
||||
|
||||
export function createReleasePlan(options) {
|
||||
const releaseBody = requireEnglishReleaseNotes(options.notes);
|
||||
const releaseTitle = `${PRODUCT_NAME} v${options.version}`;
|
||||
const setupName = `${ARTIFACT_NAME} Setup ${options.version}.exe`;
|
||||
const portableName = `${ARTIFACT_NAME} ${options.version}.exe`;
|
||||
const setupName = `${PRODUCT_NAME} Setup ${options.version}.exe`;
|
||||
const portableName = `${PRODUCT_NAME} ${options.version}.exe`;
|
||||
const blockmapName = `${setupName}.blockmap`;
|
||||
const githubSetupName = setupName.replaceAll(' ', '.');
|
||||
const githubPortableName = portableName.replaceAll(' ', '.');
|
||||
const githubBlockmapName = blockmapName.replaceAll(' ', '.');
|
||||
return {
|
||||
...options,
|
||||
tag: options.transportTag,
|
||||
releaseTitle,
|
||||
releaseBody,
|
||||
releaseBody: options.notes || releaseTitle,
|
||||
setupName,
|
||||
portableName,
|
||||
blockmapName,
|
||||
githubSetupName,
|
||||
githubPortableName,
|
||||
githubBlockmapName,
|
||||
expectedArtifacts: [setupName, portableName, blockmapName, 'latest.yml'],
|
||||
githubExpectedArtifacts: [githubSetupName, githubPortableName, githubBlockmapName, 'latest.yml']
|
||||
expectedArtifacts: [setupName, portableName, blockmapName, 'latest.yml']
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveExistingReleaseId(plan, release) {
|
||||
const existingTitle = typeof release?.name === 'string' ? release.name : '';
|
||||
const existingTag = typeof release?.tag_name === 'string' ? release.tag_name : '';
|
||||
if (existingTag !== plan.tag) {
|
||||
throw new Error(`Refusing recovery for ${plan.tag}: existing release tag "${existingTag}" does not match "${plan.tag}"`);
|
||||
}
|
||||
if (existingTitle !== plan.releaseTitle) {
|
||||
throw new Error(`Refusing recovery for ${plan.tag}: existing release title "${existingTitle}" does not match "${plan.releaseTitle}"`);
|
||||
}
|
||||
return release.id;
|
||||
}
|
||||
|
||||
export function renderLatestYml(plan, sha, size, releaseDate = new Date().toISOString(), setupName = plan.setupName) {
|
||||
return `version: ${plan.version}\nfiles:\n - url: ${setupName}\n sha512: ${sha}\n size: ${size}\npath: ${setupName}\nsha512: ${sha}\nreleaseDate: '${releaseDate}'\n`;
|
||||
export function renderLatestYml(plan, sha, size, releaseDate = new Date().toISOString()) {
|
||||
return `version: ${plan.version}\nfiles:\n - url: ${plan.setupName}\n sha512: ${sha}\n size: ${size}\npath: ${plan.setupName}\nsha512: ${sha}\nreleaseDate: '${releaseDate}'\n`;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import { lstat, readFile, readdir } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { promisify } from 'node:util';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const args = process.argv.slice(2);
|
||||
const failures = new Map();
|
||||
const execFileAsync = promisify(execFile);
|
||||
const publicActionsDir = `.${['git', 'hub'].join('')}`;
|
||||
const privateActionsDir = `.${['gi', 'tea'].join('')}`;
|
||||
const sourceFiles = [
|
||||
@@ -23,8 +20,6 @@ const sourceFiles = [
|
||||
'lib/account-auth.js',
|
||||
'lib/account-rotation.js',
|
||||
'lib/backup-crypto.js',
|
||||
'lib/batch-completion-report.js',
|
||||
'lib/batch-mutation-gate.js',
|
||||
'lib/clouddrop-upload.js',
|
||||
'lib/coalesced-set.js',
|
||||
'lib/config-store.js',
|
||||
@@ -33,10 +28,8 @@ const sourceFiles = [
|
||||
'lib/doodstream-upload.js',
|
||||
'lib/file-probe.js',
|
||||
'lib/file-discovery.js',
|
||||
'lib/filename-filter.js',
|
||||
'lib/folder-monitor.js',
|
||||
'lib/hosters.js',
|
||||
'lib/hoster-transport-error.js',
|
||||
'lib/import-preflight.js',
|
||||
'lib/ip-allowlist.js',
|
||||
'lib/log-mode.js',
|
||||
@@ -71,9 +64,6 @@ const sourceFiles = [
|
||||
'lib/upload-confirmation.js',
|
||||
'lib/upload-diagnostics.js',
|
||||
'lib/upload-manager.js',
|
||||
'lib/upload-recovery.js',
|
||||
'lib/upload-schedule.js',
|
||||
'lib/upload-start-reservation.js',
|
||||
'lib/vidmoly-upload.js',
|
||||
'lib/voe-upload.js',
|
||||
'lib/webhook-notify.js',
|
||||
@@ -106,9 +96,6 @@ const sourceFiles = [
|
||||
'tests/account-status.test.js',
|
||||
'tests/auto-resume.test.js',
|
||||
'tests/backup-crypto.test.js',
|
||||
'tests/batch-completion-main.test.js',
|
||||
'tests/batch-completion-report.test.js',
|
||||
'tests/batch-mutation-gate.test.js',
|
||||
'tests/byse-reject-recovery.test.js',
|
||||
'tests/coalesced-set.test.js',
|
||||
'tests/config-store.test.js',
|
||||
@@ -120,27 +107,21 @@ const sourceFiles = [
|
||||
'tests/doodstream-upload.test.js',
|
||||
'tests/file-probe.test.js',
|
||||
'tests/file-discovery.test.js',
|
||||
'tests/filename-filter.test.js',
|
||||
'tests/folder-monitor.test.js',
|
||||
'tests/history-status.test.js',
|
||||
'tests/history-retention.test.js',
|
||||
'tests/import-preflight.test.js',
|
||||
'tests/hidden-electron-window.test.js',
|
||||
'tests/hosters.test.js',
|
||||
'tests/hoster-recovery-provenance.test.js',
|
||||
'tests/hoster-recovery-safety.test.js',
|
||||
'tests/i18n.test.js',
|
||||
'tests/ip-allowlist.test.js',
|
||||
'tests/log-mode.test.js',
|
||||
'tests/log-policy.test.js',
|
||||
'tests/log-rotation.test.js',
|
||||
'tests/main-remote-input.test.js',
|
||||
'tests/online-backup-service.test.js',
|
||||
'tests/online-backup.test.js',
|
||||
'tests/orphan-tmp.test.js',
|
||||
'tests/package-build-files.test.js',
|
||||
'tests/public-release-verifier.test.js',
|
||||
'tests/release-plan.test.js',
|
||||
'tests/queue-dedup-property.test.js',
|
||||
'tests/queue-dedup.test.js',
|
||||
'tests/queue-persistence-scenario.test.js',
|
||||
@@ -160,7 +141,6 @@ const sourceFiles = [
|
||||
'tests/startup-renderer.test.js',
|
||||
'tests/stats.test.js',
|
||||
'tests/support-bundle.test.js',
|
||||
'tests/support/hidden-electron-window.js',
|
||||
'tests/support/ui-network-safety.js',
|
||||
'tests/suspect-reject-alternates.test.js',
|
||||
'tests/throttle-timer.test.js',
|
||||
@@ -173,12 +153,7 @@ const sourceFiles = [
|
||||
'tests/upload-log.test.js',
|
||||
'tests/upload-confirmation.test.js',
|
||||
'tests/upload-diagnostics.test.js',
|
||||
'tests/upload-finalization-coordinator.test.js',
|
||||
'tests/upload-manager.test.js',
|
||||
'tests/upload-manager-recovery-claims.test.js',
|
||||
'tests/upload-recovery.test.js',
|
||||
'tests/upload-schedule.test.js',
|
||||
'tests/upload-start-reservation.test.js',
|
||||
'tests/session-report.test.js',
|
||||
'tests/validate-credentials.test.js',
|
||||
'tests/webhook-notify.test.js'
|
||||
@@ -196,11 +171,8 @@ const binaryExtensions = new Set(['.ico', '.png']);
|
||||
const expectedScripts = {
|
||||
start: 'electron .',
|
||||
dev: 'node scripts/dev-runner.cjs',
|
||||
test: 'npm run test:unit && npm run test:ui',
|
||||
'test:unit': 'node --test tests/*.test.js',
|
||||
'test:ui': 'node --test tests/ui-smoke.js',
|
||||
test: 'node --test tests/*.test.js tests/ui-smoke.js',
|
||||
'test:backup-api': 'npm --prefix services/backup-api test',
|
||||
'verify:public-source': 'node scripts/verify-public-release.mjs --source-only --tracked --package-version',
|
||||
verify: 'npm run lint && npm test && npm run test:backup-api && npm audit --omit=dev',
|
||||
lint: 'eslint .',
|
||||
dist: 'electron-builder --publish never --win',
|
||||
@@ -248,22 +220,11 @@ const internalTerms = [
|
||||
const forbiddenAiPattern = new RegExp(`\\b(?:${aiTerms}|multi[\\s-]+agents?)\\b`, 'i');
|
||||
const forbiddenPersonalPattern = new RegExp(`(?:[a-z]:[\\\\/]+users[\\\\/]+|\\b(?:${personalTerms})\\b|\\bdesktop-[a-z0-9-]+\\b)`, 'i');
|
||||
const forbiddenInternalPattern = new RegExp(`\\b(?:${internalTerms})\\b`, 'i');
|
||||
const credentialPatterns = [
|
||||
new RegExp(`\\b${['gh', '[pousr]_'].join('')}[A-Za-z0-9]{36,}\\b`),
|
||||
/\bgithub_pat_[A-Za-z0-9_]{60,}\b/,
|
||||
/\bnpm_[A-Za-z0-9]{36,}\b/,
|
||||
/\bxox[baprs]-[A-Za-z0-9-]{24,}\b/,
|
||||
/\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/,
|
||||
/\bBearer\s+[A-Za-z0-9._~+/=-]{16,}(?=$|[\s"'`,;}\]])/i,
|
||||
new RegExp(['-----BEGIN ', '(?:RSA |EC |DSA |OPENSSH )?', 'PRIVATE KEY-----'].join('')),
|
||||
new RegExp(`\\b${['aws', '_secret_access_key'].join('')}\\b\\s*[:=]\\s*["']?[A-Za-z0-9+/]{40}["']?`, 'i')
|
||||
];
|
||||
const credentialAssignmentPattern = /\b(?:password|passwd|api[_-]?key|access[_-]?token|auth[_-]?token|token|secret|cookie|session(?:id|token)?|authorization)\b\s*[:=]\s*["'`]([^"'`\r\n]{8,})["'`]/giu;
|
||||
const unquotedCredentialAssignmentPattern = /\b(?:password|passwd|api[_-]?key|access[_-]?token|auth[_-]?token|token|secret|cookie|session(?:id|token)?|authorization)\b\s*[:=]\s*([A-Za-z0-9._~+/=-]{12,})\s*(?:#.*)?$/gimu;
|
||||
const updaterOnlyTerms = [
|
||||
'ed.cisum-42.tig'.split('').reverse().join('').replace(/[^a-z0-9]/g, ''),
|
||||
'daolpu-retsoh-itlum/rotartsinimda'.split('').reverse().join('').replace(/[^a-z0-9]/g, '')
|
||||
];
|
||||
const updaterOnlyPattern = new RegExp([
|
||||
['gi', 'tea'].join(''),
|
||||
['git', '24-music', 'de'].join('\\.'),
|
||||
[['Admin', 'istrator'].join(''), 'Multi-Hoster-Upload'].join('\\/')
|
||||
].join('|'), 'i');
|
||||
|
||||
function addFailure(file, rule) {
|
||||
if (!failures.has(file)) failures.set(file, new Set());
|
||||
@@ -295,26 +256,18 @@ function isDeniedBasename(basename) {
|
||||
|| /\.(?:bak|db|log|sqlite|sqlite3|tmp)$/i.test(basename);
|
||||
}
|
||||
|
||||
function parseArguments(packageVersion) {
|
||||
function parseArguments() {
|
||||
const sourceOnlyCount = args.filter((arg) => arg === '--source-only').length;
|
||||
const trackedCount = args.filter((arg) => arg === '--tracked').length;
|
||||
const packageVersionCount = args.filter((arg) => arg === '--package-version').length;
|
||||
const versionFlagIndexes = args.map((arg, index) => arg === '--version' ? index : -1).filter((index) => index >= 0);
|
||||
const versionIndex = versionFlagIndexes[0] ?? -1;
|
||||
const explicitVersion = versionIndex >= 0 ? args[versionIndex + 1] : '';
|
||||
const expectedVersion = packageVersionCount === 1 ? packageVersion : explicitVersion;
|
||||
const expectedVersion = versionIndex >= 0 ? args[versionIndex + 1] : '';
|
||||
const consumed = new Set();
|
||||
|
||||
if (sourceOnlyCount === 1) consumed.add(args.indexOf('--source-only'));
|
||||
if (sourceOnlyCount > 1) addFailure('scripts/verify-public-release.mjs', 'duplicate-source-only');
|
||||
if (trackedCount === 1) consumed.add(args.indexOf('--tracked'));
|
||||
if (trackedCount > 1 || (trackedCount === 1 && sourceOnlyCount !== 1)) {
|
||||
addFailure('scripts/verify-public-release.mjs', 'tracked-source-argument');
|
||||
}
|
||||
if (packageVersionCount === 1) consumed.add(args.indexOf('--package-version'));
|
||||
if (packageVersionCount > 1 || packageVersionCount + versionFlagIndexes.length !== 1 || !/^\d+\.\d+\.\d+$/.test(expectedVersion || '')) {
|
||||
if (versionFlagIndexes.length !== 1 || !/^\d+\.\d+\.\d+$/.test(expectedVersion || '')) {
|
||||
addFailure('scripts/verify-public-release.mjs', 'expected-version-argument');
|
||||
} else if (versionFlagIndexes.length === 1) {
|
||||
} else {
|
||||
consumed.add(versionIndex);
|
||||
consumed.add(versionIndex + 1);
|
||||
}
|
||||
@@ -323,7 +276,7 @@ function parseArguments(packageVersion) {
|
||||
if (!consumed.has(index)) addFailure('scripts/verify-public-release.mjs', 'argument-allowlist');
|
||||
}
|
||||
|
||||
return { sourceOnly: sourceOnlyCount === 1, tracked: trackedCount === 1, expectedVersion };
|
||||
return { sourceOnly: sourceOnlyCount === 1, expectedVersion };
|
||||
}
|
||||
|
||||
async function enumerate(directory = root, relativeDirectory = '') {
|
||||
@@ -363,69 +316,6 @@ async function enumerate(directory = root, relativeDirectory = '') {
|
||||
return files;
|
||||
}
|
||||
|
||||
async function enumerateTracked() {
|
||||
let stdout = '';
|
||||
try {
|
||||
({ stdout } = await execFileAsync('git', ['ls-files', '-z'], {
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 8 * 1024 * 1024
|
||||
}));
|
||||
} catch {
|
||||
addFailure('.git', 'tracked-source-enumeration');
|
||||
return [];
|
||||
}
|
||||
|
||||
const files = new Set(stdout.split('\0').filter(Boolean).map(normalizeRelative));
|
||||
const buildDirectories = ['lib', 'renderer'];
|
||||
for (const relativeDirectory of buildDirectories) {
|
||||
const buildFiles = await enumerateBuildDirectory(path.join(root, relativeDirectory), relativeDirectory);
|
||||
for (const relativePath of buildFiles) files.add(relativePath);
|
||||
}
|
||||
|
||||
const result = [...files];
|
||||
for (const relativePath of result) {
|
||||
let stats;
|
||||
try {
|
||||
stats = await lstat(path.join(root, relativePath));
|
||||
} catch {
|
||||
addFailure(relativePath, 'required-source-file');
|
||||
continue;
|
||||
}
|
||||
if (!stats.isFile() || stats.isSymbolicLink()) addFailure(relativePath, 'unsupported-file-type');
|
||||
if (isDeniedBasename(path.basename(relativePath))) addFailure(relativePath, 'denied-basename');
|
||||
if (!allowedFiles.has(relativePath)) addFailure(relativePath, 'source-layout-allowlist');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function enumerateBuildDirectory(directory, relativeDirectory) {
|
||||
const files = [];
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(directory, { withFileTypes: true });
|
||||
} catch {
|
||||
addFailure(relativeDirectory, 'build-input-enumeration');
|
||||
return files;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const relativePath = normalizeRelative(path.join(relativeDirectory, entry.name));
|
||||
const absolutePath = path.join(directory, entry.name);
|
||||
const stats = await lstat(absolutePath);
|
||||
if (stats.isSymbolicLink()) {
|
||||
addFailure(relativePath, 'unsupported-file-type');
|
||||
} else if (entry.isDirectory()) {
|
||||
files.push(...await enumerateBuildDirectory(absolutePath, relativePath));
|
||||
} else if (entry.isFile()) {
|
||||
files.push(relativePath);
|
||||
} else {
|
||||
addFailure(relativePath, 'unsupported-file-type');
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
async function readJson(relativePath, rule) {
|
||||
try {
|
||||
return JSON.parse(await readFile(path.join(root, relativePath), 'utf8'));
|
||||
@@ -456,39 +346,8 @@ async function validateTextFiles(files) {
|
||||
if (forbiddenPersonalPattern.test(value)) addFailure(relativePath, 'forbidden-personal-term');
|
||||
if (forbiddenAiPattern.test(value)) addFailure(relativePath, 'forbidden-ai-term');
|
||||
if (forbiddenInternalPattern.test(value)) addFailure(relativePath, 'forbidden-internal-term');
|
||||
credentialAssignmentPattern.lastIndex = 0;
|
||||
unquotedCredentialAssignmentPattern.lastIndex = 0;
|
||||
const credentialAssignment = [...value.matchAll(credentialAssignmentPattern)]
|
||||
.some((match) => !isCredentialPlaceholder(match[1], relativePath))
|
||||
|| [...value.matchAll(unquotedCredentialAssignmentPattern)]
|
||||
.some((match) => !isCredentialPlaceholder(match[1], relativePath));
|
||||
if (credentialPatterns.some((pattern) => pattern.test(value)) || credentialAssignment) {
|
||||
addFailure(relativePath, 'credential-pattern');
|
||||
if (relativePath !== 'lib/updater.js' && updaterOnlyPattern.test(value)) addFailure(relativePath, 'updater-endpoint-scope');
|
||||
}
|
||||
const normalizedValue = value.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
if (relativePath !== 'lib/updater.js' && updaterOnlyTerms.some((term) => normalizedValue.includes(term))) {
|
||||
addFailure(relativePath, 'updater-endpoint-scope');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isCredentialPlaceholder(value, relativePath) {
|
||||
const normalized = String(value || '').trim().toLowerCase();
|
||||
if (!normalized
|
||||
|| /^(?:\[?redacted\]?|none|null|undefined)$/i.test(normalized)
|
||||
|| /(?:dummy|example|fake|invalid|mock|notreal|placeholder|synthetic|test)[-_ ]?/i.test(normalized)
|
||||
|| /\$\{[^}]+\}/.test(normalized)
|
||||
|| /^<[^>]+>$/.test(normalized)) {
|
||||
return true;
|
||||
}
|
||||
if (!relativePath.startsWith('tests/')) return false;
|
||||
const longHex = /^[a-f0-9]{32,}$/i.test(normalized);
|
||||
const opaqueLetters = /^[a-z]{16,}$/i.test(normalized);
|
||||
const highEntropy = normalized.length >= 24
|
||||
&& /[a-z]/.test(normalized)
|
||||
&& /\d/.test(normalized)
|
||||
&& !/[-_ ](?:key|token|secret|password|session|account|fixture)(?:[-_ ]|$)/i.test(normalized);
|
||||
return !longHex && !opaqueLetters && !highEntropy;
|
||||
}
|
||||
|
||||
function validatePackage(packageJson, packageLock, files, expectedVersion) {
|
||||
@@ -562,15 +421,15 @@ function printFailures() {
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const packageJson = await readJson('package.json', 'package-json');
|
||||
const { sourceOnly, tracked, expectedVersion } = parseArguments(packageJson?.version);
|
||||
const files = tracked ? await enumerateTracked() : await enumerate();
|
||||
const { sourceOnly, expectedVersion } = parseArguments();
|
||||
const files = await enumerate();
|
||||
const requiredFiles = sourceOnly ? sourceFiles : [...sourceFiles, ...screenshotFiles];
|
||||
for (const requiredFile of requiredFiles) {
|
||||
if (!files.includes(requiredFile)) addFailure(requiredFile, 'required-source-file');
|
||||
}
|
||||
|
||||
await validateTextFiles(files);
|
||||
const packageJson = await readJson('package.json', 'package-json');
|
||||
const packageLock = await readJson('package-lock.json', 'package-lock-json');
|
||||
const servicePackage = await readJson('services/backup-api/package.json', 'service-package-json');
|
||||
const serviceLock = await readJson('services/backup-api/package-lock.json', 'service-package-lock-json');
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const mainSource = fs.readFileSync(path.join(__dirname, '..', 'main.js'), 'utf8');
|
||||
const preloadSource = fs.readFileSync(path.join(__dirname, '..', 'preload.js'), 'utf8');
|
||||
|
||||
test('publishes the authoritative batch report only after finalization and source cleanup', () => {
|
||||
const handler = mainSource.slice(mainSource.indexOf("uploadManager.on('batch-done'"), mainSource.indexOf('// Shutdown after finish'));
|
||||
const finalization = handler.indexOf('uploadFinalizationBarrier.finalize');
|
||||
const cleanup = handler.indexOf('sourceCleanup.finishBatch');
|
||||
const report = handler.indexOf('publishBatchCompletionReport');
|
||||
|
||||
assert.ok(finalization >= 0);
|
||||
assert.ok(cleanup > finalization);
|
||||
assert.ok(report > cleanup);
|
||||
});
|
||||
|
||||
test('keeps initial and live admission skips in the final batch summary', () => {
|
||||
assert.match(mainSource, /const uploadBatchAdmissionSkips = new WeakMap\(\)/);
|
||||
assert.match(mainSource, /uploadBatchAdmissionSkips\.set\(_thisManager, batchAdmissionSkippedJobs\)/);
|
||||
assert.match(mainSource, /uploadBatchAdmissionSkips\.get\(batchManager\).*push\(\.\.\.skippedJobs\)/s);
|
||||
assert.match(mainSource, /stats\.mergeSkippedIntoSummary\(summary, batchAdmissionSkippedJobs\)/);
|
||||
assert.match(mainSource, /fileName: j\.fileName \|\| path\.basename\(j\.file \|\| ''\)/);
|
||||
assert.match(mainSource, /fileKey: buildBatchFileKey\(j\.file\)/);
|
||||
assert.match(mainSource, /size: Number\(j\.bytesTotal\) \|\| 0/);
|
||||
});
|
||||
|
||||
test('finalizes cleanup and reports skipped-only and rejected-start batches', () => {
|
||||
const skippedOnly = mainSource.slice(mainSource.indexOf('if (tasks.length === 0)'), mainSource.indexOf('uploadManager = new UploadManager'));
|
||||
const rejectedStart = mainSource.slice(mainSource.indexOf('}).catch(async (err) =>'), mainSource.indexOf('logMemorySnapshot(\'batch-start\')'));
|
||||
|
||||
assert.match(skippedOnly, /sourceCleanup\.finishBatch/);
|
||||
assert.match(skippedOnly, /publishBatchCompletionReport/);
|
||||
assert.match(rejectedStart, /sourceCleanup\.finishBatch/);
|
||||
assert.match(rejectedStart, /publishBatchCompletionReport/);
|
||||
});
|
||||
|
||||
test('preload exposes report recovery and report-bound exports', () => {
|
||||
assert.match(preloadSource, /onUploadBatchReport/);
|
||||
assert.match(preloadSource, /getLastBatchCompletionReport/);
|
||||
assert.match(preloadSource, /exportBatchCompletionReport/);
|
||||
assert.match(preloadSource, /removeAllListeners\('upload-batch-report'\)/);
|
||||
assert.match(mainSource, /shellText\('Der Batch-Bericht ist nicht mehr verfügbar', 'The batch report is no longer available'\)/);
|
||||
assert.match(mainSource, /shellText\('Ungültiges Exportformat', 'Invalid export format'\)/);
|
||||
});
|
||||
|
||||
test('does not cache or publish a fully aborted batch report', () => {
|
||||
const publisher = mainSource.slice(
|
||||
mainSource.indexOf('function publishBatchCompletionReport'),
|
||||
mainSource.indexOf('function shouldLogHosterToFile')
|
||||
);
|
||||
|
||||
assert.match(publisher, /if \(isAllAborted\(summary\)\) return null/);
|
||||
assert.ok(publisher.indexOf('isAllAborted(summary)') < publisher.indexOf('batchCompletionReports.set'));
|
||||
assert.ok(publisher.indexOf('isAllAborted(summary)') < publisher.indexOf("safeSend('upload-batch-report'"));
|
||||
});
|
||||
@@ -1,130 +0,0 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
test('builds immutable file, job, transfer, cleanup, host and error totals', () => {
|
||||
const { buildBatchCompletionReport } = require('../lib/batch-completion-report');
|
||||
const report = buildBatchCompletionReport({
|
||||
reportId: 'report-1',
|
||||
startedAt: '2026-08-16T10:00:00.000Z',
|
||||
completedAt: '2026-08-16T10:00:10.000Z',
|
||||
cleanupOutcomes: ['deleted', 'blocked', 'source-changed', 'source-missing', 'unsafe-source-type', 'failed', 'setting-disabled'],
|
||||
summary: {
|
||||
id: 'batch-1',
|
||||
files: [
|
||||
{
|
||||
name: 'complete.mkv',
|
||||
size: 100,
|
||||
results: [
|
||||
{ jobId: 'done-a', hoster: 'doodstream.com', status: 'done', attempt: 1, maxAttempts: 3 },
|
||||
{ jobId: 'done-b', hoster: 'voe.sx', status: 'done', attempt: 1, maxAttempts: 2 }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'partial.mkv',
|
||||
size: 200,
|
||||
results: [
|
||||
{ jobId: 'done-c', hoster: 'doodstream.com', status: 'done', attempt: 2, maxAttempts: 3 },
|
||||
{ jobId: 'error-a', hoster: 'voe.sx', status: 'error', error: 'network timeout', attempt: 2, maxAttempts: 2 }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'failed.mkv',
|
||||
size: 300,
|
||||
results: [
|
||||
{ jobId: 'skip-a', hoster: 'doodstream.com', status: 'skipped', error: 'No account', attempt: 0, maxAttempts: 0 },
|
||||
{ jobId: 'abort-a', hoster: 'voe.sx', status: 'aborted', error: 'Aborted', attempt: 0, maxAttempts: 2 },
|
||||
{ jobId: 'error-b', hoster: 'byse.sx', status: 'error', error: 'account full', attempt: 1, maxAttempts: 1, remoteCommitUncertain: true }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
assert.deepEqual(report.files, { total: 3, fullySucceeded: 1, partiallySucceeded: 1, failed: 1 });
|
||||
assert.deepEqual(report.jobs, { total: 7, succeeded: 3, failed: 2, skipped: 1, aborted: 1 });
|
||||
assert.deepEqual(report.cleanup, { requested: 6, deleted: 1, blocked: 4, failed: 1 });
|
||||
assert.deepEqual(report.transfer, { successfulBytes: 400, averageBytesPerSecond: 40 });
|
||||
assert.deepEqual(report.hosters['doodstream.com'], { total: 3, succeeded: 2, failed: 0, skipped: 1, aborted: 0, successfulBytes: 300 });
|
||||
assert.equal(report.errors.length, 2);
|
||||
assert.deepEqual(report.errors.map(error => error.category), ['network', 'account-error']);
|
||||
assert.equal(report.errors[1].remoteCommitUncertain, true);
|
||||
assert.equal(report.batchId, 'batch-1');
|
||||
assert.equal(report.durationSec, 10);
|
||||
assert.equal(Object.isFrozen(report), true);
|
||||
assert.equal(Object.isFrozen(report.errors), true);
|
||||
assert.equal(Object.isFrozen(report.errors[0]), true);
|
||||
});
|
||||
|
||||
test('counts duplicate basenames as separate summary files without exposing local paths', () => {
|
||||
const { buildBatchCompletionReport } = require('../lib/batch-completion-report');
|
||||
const report = buildBatchCompletionReport({
|
||||
summary: {
|
||||
files: [
|
||||
{ name: 'C:\\private\\one\\same.mkv', size: 10, results: [{ jobId: 'one', hoster: 'voe.sx', status: 'done' }] },
|
||||
{ name: '/private/two/same.mkv', size: 10, results: [{ jobId: 'two', hoster: 'voe.sx', status: 'error', error: 'failed at C:\\private\\two\\same.mkv' }] }
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
assert.equal(report.files.total, 2);
|
||||
assert.equal(report.files.fullySucceeded, 1);
|
||||
assert.equal(report.files.failed, 1);
|
||||
assert.equal(report.errors[0].fileName, 'same.mkv');
|
||||
assert.doesNotMatch(JSON.stringify(report), /private[\\/](?:one|two)/i);
|
||||
});
|
||||
|
||||
test('redacts configured secrets, opaque tokens, URLs and local paths from errors', () => {
|
||||
const { buildBatchCompletionReport } = require('../lib/batch-completion-report');
|
||||
const secret = 'private-api-value';
|
||||
const opaqueToken = 'a1b2c3d4e5f6g7h8i9j0k1l2m3n4';
|
||||
const report = buildBatchCompletionReport({
|
||||
secrets: [secret],
|
||||
summary: {
|
||||
files: [{
|
||||
name: 'secret.mkv',
|
||||
size: 1,
|
||||
results: [{
|
||||
jobId: 'error-secret',
|
||||
hoster: 'doodstream.com',
|
||||
status: 'error',
|
||||
error: `token=${secret} path=D:\\private\\secret.mkv /home/private/customer/file.mkv https://private.example.test/upload/${opaqueToken}`
|
||||
}]
|
||||
}]
|
||||
}
|
||||
});
|
||||
const serialized = JSON.stringify(report);
|
||||
|
||||
assert.doesNotMatch(serialized, new RegExp(secret));
|
||||
assert.doesNotMatch(serialized, /D:\\\\private/);
|
||||
assert.doesNotMatch(serialized, /\/home\/private/);
|
||||
assert.doesNotMatch(serialized, /private\.example\.test/);
|
||||
assert.doesNotMatch(serialized, new RegExp(opaqueToken));
|
||||
assert.match(report.errors[0].message, /<redacted>/);
|
||||
assert.match(report.errors[0].message, /<redacted-path>/);
|
||||
});
|
||||
|
||||
test('builds a formula-safe English error CSV and handles zero duration', () => {
|
||||
const { buildBatchCompletionReport, buildBatchErrorCsv } = require('../lib/batch-completion-report');
|
||||
const report = buildBatchCompletionReport({
|
||||
startedAt: '2026-08-16T10:00:00.000Z',
|
||||
completedAt: '2026-08-16T10:00:00.000Z',
|
||||
summary: {
|
||||
files: [{
|
||||
name: '=danger.csv',
|
||||
size: 8,
|
||||
results: [{ jobId: '+job', hoster: '@host', status: 'error', error: '-CMD()', attempt: 1, maxAttempts: 1 }]
|
||||
}]
|
||||
}
|
||||
});
|
||||
const csv = buildBatchErrorCsv(report);
|
||||
|
||||
assert.equal(report.transfer.averageBytesPerSecond, 0);
|
||||
assert.match(csv, /^Job ID,File name,Host,Status,Category,Attempt,Max attempts,Remote commit uncertain,Message\n/);
|
||||
assert.match(csv, /'\+job/);
|
||||
assert.match(csv, /'=danger\.csv/);
|
||||
assert.match(csv, /'@host/);
|
||||
assert.match(csv, /'-CMD\(\)/);
|
||||
const prefixed = buildBatchErrorCsv({ errors: [{ message: '\t=HYPERLINK("https://example.test")' }] });
|
||||
assert.match(prefixed, /'\t=HYPERLINK/);
|
||||
assert.equal(csv.endsWith('\n'), true);
|
||||
});
|
||||
@@ -1,113 +0,0 @@
|
||||
const { describe, it } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const { createBatchMutationGate } = require('../lib/batch-mutation-gate');
|
||||
|
||||
it('drains main-process batch mutations before source cleanup finalization', () => {
|
||||
const source = fs.readFileSync(path.join(__dirname, '..', 'main.js'), 'utf8');
|
||||
const batchDoneStart = source.indexOf("uploadManager.on('batch-done'");
|
||||
const sealAndDrain = source.indexOf('batchMutationGate.sealAndDrain()', batchDoneStart);
|
||||
const cleanupFinish = source.indexOf('sourceCleanup.finishBatch', batchDoneStart);
|
||||
assert.ok(batchDoneStart >= 0);
|
||||
assert.ok(sealAndDrain > batchDoneStart);
|
||||
assert.ok(cleanupFinish > sealAndDrain);
|
||||
assert.match(source.slice(sealAndDrain, cleanupFinish + 300), /!hadActiveBatchMutation/);
|
||||
assert.match(source, /batchMutationGate\.acquire\(\)/);
|
||||
assert.match(source, /finally\s*{\s*batchMutationLease\.finish\(\)/);
|
||||
});
|
||||
|
||||
describe('batch mutation gate', () => {
|
||||
it('keeps a seal pending until every lease active at seal has finished', async () => {
|
||||
const gate = createBatchMutationGate();
|
||||
const first = gate.acquire();
|
||||
const second = gate.acquire();
|
||||
|
||||
const drain = gate.sealAndDrain();
|
||||
let drained = false;
|
||||
drain.then(() => {
|
||||
drained = true;
|
||||
});
|
||||
|
||||
assert.equal(gate.acquire(), null);
|
||||
assert.equal(first.isOpen(), true);
|
||||
assert.equal(second.isOpen(), true);
|
||||
|
||||
first.finish();
|
||||
await Promise.resolve();
|
||||
assert.equal(drained, false);
|
||||
|
||||
second.finish();
|
||||
assert.equal(await drain, true);
|
||||
assert.equal(drained, true);
|
||||
});
|
||||
|
||||
it('reports no active mutation when sealing an idle gate', async () => {
|
||||
const gate = createBatchMutationGate();
|
||||
|
||||
assert.equal(await gate.sealAndDrain(), false);
|
||||
assert.equal(gate.acquire(), null);
|
||||
});
|
||||
|
||||
it('returns the same drain promise and preserves the first seal snapshot', async () => {
|
||||
const gate = createBatchMutationGate();
|
||||
const lease = gate.acquire();
|
||||
|
||||
const firstDrain = gate.sealAndDrain();
|
||||
lease.finish();
|
||||
const secondDrain = gate.sealAndDrain();
|
||||
|
||||
assert.equal(secondDrain, firstDrain);
|
||||
assert.equal(await firstDrain, true);
|
||||
assert.equal(await gate.sealAndDrain(), true);
|
||||
});
|
||||
|
||||
it('makes lease completion idempotent without affecting other leases', async () => {
|
||||
const gate = createBatchMutationGate();
|
||||
const first = gate.acquire();
|
||||
const second = gate.acquire();
|
||||
const drain = gate.sealAndDrain();
|
||||
|
||||
assert.equal(first.finish(), true);
|
||||
assert.equal(first.finish(), false);
|
||||
assert.equal(first.isOpen(), false);
|
||||
assert.equal(second.isOpen(), true);
|
||||
|
||||
let drained = false;
|
||||
drain.then(() => {
|
||||
drained = true;
|
||||
});
|
||||
await Promise.resolve();
|
||||
assert.equal(drained, false);
|
||||
|
||||
assert.equal(second.finish(), true);
|
||||
assert.equal(await drain, true);
|
||||
});
|
||||
|
||||
it('does not let a rejected caller block drain when the lease finishes in finally', async () => {
|
||||
const gate = createBatchMutationGate();
|
||||
const lease = gate.acquire();
|
||||
const caller = (async () => {
|
||||
try {
|
||||
await Promise.reject(new Error('audit failed'));
|
||||
} finally {
|
||||
lease.finish();
|
||||
}
|
||||
})();
|
||||
const drain = gate.sealAndDrain();
|
||||
|
||||
await assert.rejects(caller, /audit failed/);
|
||||
assert.equal(await drain, true);
|
||||
assert.equal(lease.isOpen(), false);
|
||||
});
|
||||
|
||||
it('does not count a lease finished before sealing as active at seal', async () => {
|
||||
const gate = createBatchMutationGate();
|
||||
const lease = gate.acquire();
|
||||
|
||||
lease.finish();
|
||||
|
||||
assert.equal(await gate.sealAndDrain(), false);
|
||||
});
|
||||
});
|
||||
@@ -171,94 +171,6 @@ test('byse empty filecode WITHOUT explicit rejection still polls recovery', asyn
|
||||
assert.ok(listCalls >= 2, 'recovery polling must run when there is no explicit rejection');
|
||||
});
|
||||
|
||||
test('byse never recovers an old file after a failed baseline', async () => {
|
||||
stubByseUploadServer();
|
||||
const abort = new AbortController();
|
||||
const fileName = path.basename(tmpFile);
|
||||
let listCalls = 0;
|
||||
requestRouter = async (url, opts) => {
|
||||
if (/\/file\/list/.test(String(url))) {
|
||||
listCalls++;
|
||||
if (listCalls === 1) {
|
||||
return {
|
||||
statusCode: 503,
|
||||
headers: { 'content-type': 'text/html' },
|
||||
body: { text: async () => '<html>baseline-token=SYNTHETIC_BYSE_BASELINE</html>' }
|
||||
};
|
||||
}
|
||||
abort.abort();
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: { text: async () => JSON.stringify({ status: 200, result: { files: [{ file_code: 'OLD_BYSE_123', title: fileName }] } }) }
|
||||
};
|
||||
}
|
||||
if (opts && opts.body && typeof opts.body[Symbol.asyncIterator] === 'function') {
|
||||
for await (const chunk of opts.body) { if (chunk && chunk.length === -1) break; }
|
||||
}
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: { text: async () => JSON.stringify({ status: 200, msg: 'OK' }) }
|
||||
};
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
() => uploadFile('byse.sx', tmpFile, 'VALIDKEY', null, abort.signal, null),
|
||||
(err) => {
|
||||
assert.doesNotMatch(err.message, /SYNTHETIC_BYSE_BASELINE/);
|
||||
assert.equal(err.diagnostic.phase, 'recovery-baseline');
|
||||
assert.equal(err.diagnostic.http, 503);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
assert.equal(listCalls, 1);
|
||||
});
|
||||
|
||||
test('byse recovery rejects ambiguous same-title candidates', async () => {
|
||||
stubByseUploadServer();
|
||||
const abort = new AbortController();
|
||||
const fileName = path.basename(tmpFile);
|
||||
let listCalls = 0;
|
||||
requestRouter = async (url, opts) => {
|
||||
if (/\/file\/list/.test(String(url))) {
|
||||
listCalls++;
|
||||
if (listCalls === 1) {
|
||||
return { statusCode: 200, headers: {}, body: { text: async () => '{"status":200,"result":{"files":[]}}' } };
|
||||
}
|
||||
abort.abort();
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers: {},
|
||||
body: {
|
||||
text: async () => JSON.stringify({
|
||||
status: 200,
|
||||
result: {
|
||||
files: [
|
||||
{ file_code: 'PARALLEL_A', title: fileName },
|
||||
{ file_code: 'PARALLEL_B', title: fileName }
|
||||
]
|
||||
}
|
||||
})
|
||||
}
|
||||
};
|
||||
}
|
||||
if (opts && opts.body && typeof opts.body[Symbol.asyncIterator] === 'function') {
|
||||
for await (const chunk of opts.body) { if (chunk && chunk.length === -1) break; }
|
||||
}
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: { text: async () => JSON.stringify({ status: 200, msg: 'OK' }) }
|
||||
};
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
() => uploadFile('byse.sx', tmpFile, 'VALIDKEY', null, abort.signal, null),
|
||||
(err) => err.hosterTransient === true
|
||||
);
|
||||
});
|
||||
|
||||
function stubBysePost(response) {
|
||||
requestRouter = async (url, opts) => {
|
||||
const u = String(url);
|
||||
|
||||
@@ -17,36 +17,10 @@ Module._load = function load(request, parent, isMain) {
|
||||
const ConfigStore = require('../lib/config-store');
|
||||
require('../lib/secret-store').encryptField('test-initialization');
|
||||
Module._load = originalLoad;
|
||||
const { createCollectors } = require('../lib/diagnostics-collectors');
|
||||
const { createAgent } = require('../lib/diagnostics-agent');
|
||||
const support = require('../lib/support-bundle');
|
||||
const stats = require('../lib/stats');
|
||||
|
||||
let tmpDir;
|
||||
let store;
|
||||
|
||||
function thrownBy(fn) {
|
||||
try {
|
||||
fn();
|
||||
} catch (error) {
|
||||
return error;
|
||||
}
|
||||
assert.fail('Expected function to throw');
|
||||
}
|
||||
|
||||
function createDiagnosticsAgent(configStore, logDir) {
|
||||
return createAgent(createCollectors({
|
||||
loadConfig: () => configStore.loadDiagnosticsConfig(),
|
||||
loadHistory: () => configStore.loadDiagnosticsHistory(),
|
||||
getAllLogPaths: () => ({ logDir }),
|
||||
support,
|
||||
stats,
|
||||
appInfo: () => ({}),
|
||||
systemInfo: () => ({}),
|
||||
agentInfo: () => ({})
|
||||
}));
|
||||
}
|
||||
|
||||
function createStore() {
|
||||
const fakeApp = {
|
||||
isPackaged: false,
|
||||
@@ -120,18 +94,6 @@ describe('ConfigStore', () => {
|
||||
assert.equal(config.globalSettings.scaleParallelUploads, false);
|
||||
assert.equal(config.globalSettings.lastBrowseDirectory, '');
|
||||
assert.equal(config.globalSettings.pendingQueue, null);
|
||||
assert.deepEqual(config.globalSettings.filenameFilter, {
|
||||
enabled: false,
|
||||
action: 'include',
|
||||
matchMode: 'all',
|
||||
conditions: []
|
||||
});
|
||||
assert.deepEqual(config.globalSettings.uploadSchedule, {
|
||||
enabled: false,
|
||||
weekdays: [1, 2, 3, 4, 5, 6, 0],
|
||||
start: '00:00',
|
||||
end: '23:59'
|
||||
});
|
||||
assert.deepEqual(config.history, []);
|
||||
});
|
||||
|
||||
@@ -299,39 +261,6 @@ describe('ConfigStore', () => {
|
||||
assert.equal(history[104].id, 'batch-104');
|
||||
});
|
||||
|
||||
it('durably syncs migrated history before replacing the live file', async () => {
|
||||
store._historyMigrated = true;
|
||||
fs.writeFileSync(store.historyPath, '[]', 'utf-8');
|
||||
const originalOpen = fs.promises.open;
|
||||
const originalRename = fs.promises.rename;
|
||||
let synced = false;
|
||||
let renamed = false;
|
||||
fs.promises.open = async (...args) => {
|
||||
const handle = await originalOpen(...args);
|
||||
const originalSync = handle.sync.bind(handle);
|
||||
handle.sync = async () => {
|
||||
await originalSync();
|
||||
synced = true;
|
||||
};
|
||||
return handle;
|
||||
};
|
||||
fs.promises.rename = async (...args) => {
|
||||
assert.equal(synced, true);
|
||||
renamed = true;
|
||||
return originalRename(...args);
|
||||
};
|
||||
|
||||
try {
|
||||
await store.appendHistory({ id: 'durable', files: [] });
|
||||
} finally {
|
||||
fs.promises.open = originalOpen;
|
||||
fs.promises.rename = originalRename;
|
||||
}
|
||||
|
||||
assert.equal(renamed, true);
|
||||
assert.deepEqual(store.loadHistory().map(entry => entry.id), ['durable']);
|
||||
});
|
||||
|
||||
it('clearHistory empties the array', async () => {
|
||||
await store.appendHistory({ id: 'test', files: [] });
|
||||
assert.equal(store.loadHistory().length, 1);
|
||||
@@ -389,36 +318,6 @@ describe('ConfigStore', () => {
|
||||
assert.equal(config.globalSettings.logFilePath, '');
|
||||
});
|
||||
|
||||
it('normalizes upload schedules across load and save boundaries', async () => {
|
||||
fs.writeFileSync(store.filePath, JSON.stringify({
|
||||
globalSettings: {
|
||||
uploadSchedule: { enabled: true, weekdays: [0, 1, 1, 9], start: ' 22:00 ', end: '06:00' }
|
||||
}
|
||||
}), 'utf-8');
|
||||
|
||||
assert.deepEqual(store.load().globalSettings.uploadSchedule, {
|
||||
enabled: true,
|
||||
weekdays: [1, 0],
|
||||
start: '22:00',
|
||||
end: '06:00'
|
||||
});
|
||||
|
||||
const current = store.load();
|
||||
await store.save({
|
||||
globalSettings: {
|
||||
...current.globalSettings,
|
||||
uploadSchedule: { enabled: true, weekdays: [], start: '08:00', end: '08:00' }
|
||||
}
|
||||
});
|
||||
|
||||
assert.deepEqual(store.load().globalSettings.uploadSchedule, {
|
||||
enabled: true,
|
||||
weekdays: [],
|
||||
start: '08:00',
|
||||
end: '08:00'
|
||||
});
|
||||
});
|
||||
|
||||
it('concurrent saves preserve both sections', async () => {
|
||||
const save1 = store.save({ hosters: { 'doodstream.com': [{ id: 'c1', enabled: true, authType: 'api', apiKey: 'concurrent-key' }] } });
|
||||
const save2 = store.save({ globalSettings: { alwaysOnTop: true } });
|
||||
@@ -614,51 +513,6 @@ describe('ConfigStore', () => {
|
||||
assert.equal(store.load().globalSettings.lastBrowseDirectory, selectedDirectory);
|
||||
});
|
||||
|
||||
it('merges the fallback log path after earlier queued settings without reverting them', async () => {
|
||||
assert.equal(typeof store.saveFallbackLogPath, 'function');
|
||||
await store.save({
|
||||
globalSettings: {
|
||||
alwaysOnTop: false,
|
||||
webhookUrl: 'https://before.invalid',
|
||||
logFilePath: ''
|
||||
}
|
||||
});
|
||||
|
||||
const originalAtomicWrite = store._atomicWrite.bind(store);
|
||||
let releaseSettingsWrite;
|
||||
let signalSettingsWriteStarted;
|
||||
const settingsWriteStarted = new Promise(resolve => { signalSettingsWriteStarted = resolve; });
|
||||
store._atomicWrite = (data) => {
|
||||
const settings = JSON.parse(data).globalSettings;
|
||||
if (!releaseSettingsWrite && settings.webhookUrl === 'https://concurrent.invalid') {
|
||||
signalSettingsWriteStarted();
|
||||
return new Promise((resolve, reject) => {
|
||||
releaseSettingsWrite = () => originalAtomicWrite(data).then(resolve, reject);
|
||||
});
|
||||
}
|
||||
return originalAtomicWrite(data);
|
||||
};
|
||||
|
||||
const current = store.load();
|
||||
const settingsSave = store.save({
|
||||
globalSettings: {
|
||||
...current.globalSettings,
|
||||
alwaysOnTop: true,
|
||||
webhookUrl: 'https://concurrent.invalid'
|
||||
}
|
||||
});
|
||||
await settingsWriteStarted;
|
||||
const fallbackPath = path.join(tmpDir, 'fallback', 'fileuploader.log');
|
||||
const fallbackSave = store.saveFallbackLogPath(fallbackPath);
|
||||
releaseSettingsWrite();
|
||||
await Promise.all([settingsSave, fallbackSave]);
|
||||
|
||||
const saved = store.load().globalSettings;
|
||||
assert.equal(saved.alwaysOnTop, true);
|
||||
assert.equal(saved.webhookUrl, 'https://concurrent.invalid');
|
||||
assert.equal(saved.logFilePath, fallbackPath);
|
||||
});
|
||||
|
||||
it('merges remote settings in the write queue and returns the canonical token', async () => {
|
||||
await store.save({
|
||||
globalSettings: {
|
||||
@@ -734,68 +588,6 @@ describe('ConfigStore', () => {
|
||||
assert.equal(config.hosters['doodstream.com'][0].apiKey, 'from-backup');
|
||||
});
|
||||
|
||||
it('strict diagnostics config loading rejects primary failures while normal loading keeps recovery', () => {
|
||||
assert.equal(typeof store.loadDiagnosticsConfig, 'function');
|
||||
|
||||
fs.writeFileSync(store.filePath + '.bak', JSON.stringify({
|
||||
hosters: { 'doodstream.com': [{ id: 'bak-1', authType: 'api', apiKey: 'from-backup' }] },
|
||||
hosterSettings: {},
|
||||
globalSettings: {},
|
||||
history: []
|
||||
}), 'utf-8');
|
||||
fs.writeFileSync(store.filePath, '{broken-config', 'utf-8');
|
||||
|
||||
assert.equal(store.load().hosters['doodstream.com'][0].apiKey, 'from-backup');
|
||||
assert.throws(() => store.loadDiagnosticsConfig());
|
||||
|
||||
fs.rmSync(store.filePath);
|
||||
assert.equal(store.load().globalSettings.language, 'en');
|
||||
assert.throws(() => store.loadDiagnosticsConfig());
|
||||
});
|
||||
|
||||
it('strict diagnostics config errors never expose malformed JSON or file paths', () => {
|
||||
const opaque = 'opaque-config-42';
|
||||
fs.writeFileSync(store.filePath, opaque, 'utf-8');
|
||||
|
||||
let error = thrownBy(() => store.loadDiagnosticsConfig());
|
||||
assert.equal(error.code, 'DIAGNOSTIC_CONFIG_INVALID');
|
||||
assert.equal(error.message, 'Die Diagnosekonfiguration ist ungültig');
|
||||
assert.ok(!error.message.includes(opaque));
|
||||
|
||||
fs.writeFileSync(store.filePath, '{}', 'utf-8');
|
||||
error = thrownBy(() => store.loadDiagnosticsConfig());
|
||||
assert.equal(error.code, 'DIAGNOSTIC_CONFIG_INVALID');
|
||||
assert.equal(error.message, 'Die Diagnosekonfiguration ist ungültig');
|
||||
|
||||
fs.rmSync(store.filePath);
|
||||
error = thrownBy(() => store.loadDiagnosticsConfig());
|
||||
assert.equal(error.code, 'DIAGNOSTIC_CONFIG_READ_FAILED');
|
||||
assert.equal(error.message, 'Die Diagnosekonfiguration konnte nicht gelesen werden');
|
||||
assert.ok(!error.message.includes(store.filePath));
|
||||
});
|
||||
|
||||
it('strict diagnostics config loading decrypts current credentials and propagates decryption failures', () => {
|
||||
assert.equal(typeof store.loadDiagnosticsConfig, 'function');
|
||||
|
||||
const encrypted = `enc:v1:${Buffer.from('test-protected:diagnostic-secret').toString('base64')}`;
|
||||
fs.writeFileSync(store.filePath, JSON.stringify({
|
||||
hosters: { 'doodstream.com': [{ id: 'diag-1', authType: 'api', apiKey: encrypted }] },
|
||||
hosterSettings: {},
|
||||
globalSettings: {},
|
||||
history: []
|
||||
}), 'utf-8');
|
||||
|
||||
assert.equal(store.loadDiagnosticsConfig().hosters['doodstream.com'][0].apiKey, 'diagnostic-secret');
|
||||
|
||||
const originalDecryptString = safeStorage.decryptString;
|
||||
safeStorage.decryptString = () => { throw new Error('diagnostic decrypt failure'); };
|
||||
try {
|
||||
assert.throws(() => store.loadDiagnosticsConfig(), /Gespeicherte Zugangsdaten konnten nicht entschlüsselt werden/);
|
||||
} finally {
|
||||
safeStorage.decryptString = originalDecryptString;
|
||||
}
|
||||
});
|
||||
|
||||
it('wipe-guard: a settings-only save recovers accounts from .bak when the live config validly has none', async () => {
|
||||
// Post-wipe state: live config parses fine but has empty hosters; a backup still holds the accounts.
|
||||
fs.writeFileSync(store.filePath, JSON.stringify({ hosters: {}, hosterSettings: {}, globalSettings: {}, history: [] }), 'utf-8');
|
||||
@@ -860,65 +652,6 @@ describe('ConfigStore history split (electron-history.json)', () => {
|
||||
assert.equal(s.loadHistory().length, 30);
|
||||
});
|
||||
|
||||
it('strict diagnostics history accepts valid empty history and rejects failed or corrupt dedicated reads', () => {
|
||||
assert.equal(typeof s.loadDiagnosticsHistory, 'function');
|
||||
writeConfigWithHistory(7);
|
||||
s._migrateHistory();
|
||||
|
||||
fs.writeFileSync(s.historyPath, '[]', 'utf-8');
|
||||
assert.deepEqual(s.loadDiagnosticsHistory(), []);
|
||||
|
||||
for (const invalidHistory of ['null', '{}', '{"history":null}', '{broken-history']) {
|
||||
fs.writeFileSync(s.historyPath, invalidHistory, 'utf-8');
|
||||
const error = thrownBy(() => s.loadDiagnosticsHistory());
|
||||
assert.equal(error.code, 'DIAGNOSTIC_HISTORY_INVALID');
|
||||
assert.equal(error.message, 'Die Diagnoseverlaufsdatei ist ungültig');
|
||||
assert.ok(!error.message.includes(invalidHistory));
|
||||
}
|
||||
|
||||
fs.rmSync(s.historyPath);
|
||||
fs.mkdirSync(s.historyPath);
|
||||
const error = thrownBy(() => s.loadDiagnosticsHistory());
|
||||
assert.equal(error.code, 'DIAGNOSTIC_HISTORY_READ_FAILED');
|
||||
assert.equal(error.message, 'Die Diagnoseverlaufsdatei konnte nicht gelesen werden');
|
||||
assert.ok(!error.message.includes(s.historyPath));
|
||||
});
|
||||
|
||||
it('real diagnostics agent never returns opaque corrupt dedicated history content', () => {
|
||||
const opaque = 'opaque42';
|
||||
writeConfigWithHistory(0);
|
||||
fs.writeFileSync(s.historyPath, opaque, 'utf-8');
|
||||
const agent = createDiagnosticsAgent(s, dir);
|
||||
|
||||
for (const operation of ['get_history', 'list_errors', 'server_health']) {
|
||||
const response = agent.handle(operation, {});
|
||||
assert.deepEqual(response, { ok: false, error: 'Die Diagnoseverlaufsdatei ist ungültig' });
|
||||
assert.ok(!JSON.stringify(response).includes(opaque));
|
||||
}
|
||||
});
|
||||
|
||||
it('strict diagnostics history never falls back to stale config history when a dedicated file exists', () => {
|
||||
assert.equal(typeof s.loadDiagnosticsHistory, 'function');
|
||||
writeConfigWithHistory(7);
|
||||
fs.writeFileSync(s.historyPath, 'null', 'utf-8');
|
||||
|
||||
assert.equal(s._historyMigrated, false);
|
||||
assert.equal(s.loadHistory().length, 7);
|
||||
assert.throws(() => s.loadDiagnosticsHistory());
|
||||
});
|
||||
|
||||
it('strict diagnostics history preserves the valid pre-migration history path', () => {
|
||||
assert.equal(typeof s.loadDiagnosticsHistory, 'function');
|
||||
writeConfigWithHistory(7);
|
||||
|
||||
assert.equal(s.loadDiagnosticsHistory().length, 7);
|
||||
|
||||
const config = JSON.parse(fs.readFileSync(s.filePath, 'utf-8'));
|
||||
config.history = null;
|
||||
fs.writeFileSync(s.filePath, JSON.stringify(config), 'utf-8');
|
||||
assert.throws(() => s.loadDiagnosticsHistory());
|
||||
});
|
||||
|
||||
it('appendHistory writes to history.json; the next config write strips stale history from the config file', async () => {
|
||||
writeConfigWithHistory(10);
|
||||
s._migrateHistory();
|
||||
|
||||
+5
-118
@@ -1,124 +1,11 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { EventEmitter } = require('node:events');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { createRestartController, createWatchedPaths, formatChangeMessage } = require('../scripts/dev-runner.cjs');
|
||||
|
||||
function createDeferred() {
|
||||
let resolve;
|
||||
let reject;
|
||||
const promise = new Promise((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
test('dev runner cannot let an old child exit clear the current Electron process', () => {
|
||||
const source = fs.readFileSync(path.join(__dirname, '..', 'scripts', 'dev-runner.cjs'), 'utf8');
|
||||
|
||||
function createHarness(stopChild) {
|
||||
const started = [];
|
||||
const stopped = [];
|
||||
const unexpectedExits = [];
|
||||
let nextPid = 1;
|
||||
const controller = createRestartController({
|
||||
startChild() {
|
||||
const child = new EventEmitter();
|
||||
child.pid = nextPid;
|
||||
nextPid += 1;
|
||||
started.push(child);
|
||||
return child;
|
||||
},
|
||||
stopChild(child) {
|
||||
stopped.push(child);
|
||||
return stopChild(child);
|
||||
},
|
||||
onUnexpectedExit(code, signal) {
|
||||
unexpectedExits.push({ code, signal });
|
||||
}
|
||||
});
|
||||
return { controller, started, stopped, unexpectedExits };
|
||||
}
|
||||
|
||||
test('three changes during an open kill produce exactly one replacement Electron tree', async () => {
|
||||
const kill = createDeferred();
|
||||
const harness = createHarness(() => kill.promise);
|
||||
const original = harness.controller.start();
|
||||
const firstRestart = harness.controller.restart();
|
||||
|
||||
const pendingChanges = [
|
||||
harness.controller.restart(),
|
||||
harness.controller.restart(),
|
||||
harness.controller.restart()
|
||||
];
|
||||
|
||||
assert.equal(harness.stopped.length, 1);
|
||||
assert.strictEqual(harness.stopped[0], original);
|
||||
assert.strictEqual(harness.controller.start(), original);
|
||||
assert.equal(harness.started.length, 1);
|
||||
|
||||
kill.resolve();
|
||||
await Promise.all([firstRestart, ...pendingChanges]);
|
||||
|
||||
assert.equal(harness.stopped.length, 1);
|
||||
assert.equal(harness.started.length, 2);
|
||||
assert.notStrictEqual(harness.started[1], original);
|
||||
});
|
||||
|
||||
test('a failed kill cannot start Electron over the still-running tree', async () => {
|
||||
const harness = createHarness(async () => {
|
||||
throw new Error('taskkill failed');
|
||||
});
|
||||
const original = harness.controller.start();
|
||||
|
||||
await assert.rejects(harness.controller.restart(), /taskkill failed/u);
|
||||
|
||||
assert.equal(harness.stopped.length, 1);
|
||||
assert.equal(harness.started.length, 1);
|
||||
assert.strictEqual(harness.controller.start(), original);
|
||||
});
|
||||
|
||||
test('shutdown during an open restart kill prevents its completion from starting Electron', async () => {
|
||||
const kill = createDeferred();
|
||||
const harness = createHarness(() => kill.promise);
|
||||
harness.controller.start();
|
||||
const restart = harness.controller.restart();
|
||||
const shutdown = harness.controller.shutdown();
|
||||
|
||||
assert.equal(harness.stopped.length, 1);
|
||||
assert.equal(harness.started.length, 1);
|
||||
|
||||
kill.resolve();
|
||||
await Promise.all([restart, shutdown]);
|
||||
|
||||
assert.equal(harness.stopped.length, 1);
|
||||
assert.equal(harness.started.length, 1);
|
||||
assert.equal(harness.controller.start(), null);
|
||||
});
|
||||
|
||||
test('an old child exit cannot clear the replacement Electron child', async () => {
|
||||
const harness = createHarness(async () => {});
|
||||
const original = harness.controller.start();
|
||||
|
||||
await harness.controller.restart();
|
||||
const replacement = harness.started[1];
|
||||
original.emit('exit', 0, 'SIGTERM');
|
||||
|
||||
assert.equal(harness.started.length, 2);
|
||||
assert.strictEqual(harness.controller.start(), replacement);
|
||||
assert.deepEqual(harness.unexpectedExits, []);
|
||||
});
|
||||
|
||||
test('watch paths still cover main, preloads, lib and renderer', () => {
|
||||
const projectRoot = path.resolve('C:\\project');
|
||||
|
||||
assert.deepEqual([...createWatchedPaths(projectRoot)], [
|
||||
path.join(projectRoot, 'main.js'),
|
||||
path.join(projectRoot, 'preload.js'),
|
||||
path.join(projectRoot, 'preload-drop-target.js'),
|
||||
path.join(projectRoot, 'lib'),
|
||||
path.join(projectRoot, 'renderer')
|
||||
]);
|
||||
});
|
||||
|
||||
test('change log identifies every watched file without calling it a renderer change', () => {
|
||||
assert.equal(formatChangeMessage('C:\\project\\lib\\hosters.js'), '[hotdev] change detected: C:\\project\\lib\\hosters.js\n');
|
||||
assert.match(source, /const startedChild = spawn\(electron/u);
|
||||
assert.match(source, /if \(child !== startedChild\) return;\s*child = null;/u);
|
||||
});
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { createAgent } = require('../lib/diagnostics-agent');
|
||||
const { valueScrub } = require('../lib/support-bundle');
|
||||
|
||||
function stubCollectors() {
|
||||
const calls = [];
|
||||
@@ -31,9 +28,6 @@ test('agent rejects unknown ops and any write/exec-shaped op', () => {
|
||||
assert.equal(r.ok, false, `${bad} must be rejected`);
|
||||
assert.match(r.error, /unknown or non-readonly/);
|
||||
}
|
||||
const pathShaped = agent.handle(['C:', 'Users', 'PrivateProfile', 'operation'].join('\\'), {});
|
||||
assert.ok(!pathShaped.error.includes('PrivateProfile'));
|
||||
assert.match(pathShaped.error, /<redacted-path>/);
|
||||
});
|
||||
|
||||
test('agent rejects inherited Object.prototype members (no whitelist bypass via the prototype chain)', () => {
|
||||
@@ -59,54 +53,10 @@ test('agent maps each whitelisted op to its collector and is read-only only', ()
|
||||
for (const op of agent.ops) assert.ok(!/write|delete|set_|exec|restart|cancel|retry/.test(op), `${op} must be read-only`);
|
||||
});
|
||||
|
||||
test('agent redacts collector failures and thrown errors at the response boundary', () => {
|
||||
const drivePath = ['C:', 'Users', 'PrivateProfile', 'secret.log'].join('\\');
|
||||
const uncPath = '\\\\?\\UNC\\private-server\\secret-share\\secret.log';
|
||||
const agent = createAgent({
|
||||
readLog: () => ({ ok: false, error: `cannot read ${drivePath}` }),
|
||||
getSystemInfo: () => { throw new Error(`boom at ${uncPath}`); }
|
||||
});
|
||||
test('agent surfaces a collector ok:false verbatim and never throws', () => {
|
||||
const agent = createAgent({ readLog: () => ({ ok: false, error: 'unknown or non-readable log: x' }), getSystemInfo: () => { throw new Error('boom'); } });
|
||||
assert.equal(agent.handle('read_log', { name: 'x' }).ok, false);
|
||||
assert.ok(!JSON.stringify(agent.handle('read_log', { name: 'x' })).includes('PrivateProfile'));
|
||||
const thrown = agent.handle('get_system_info', {});
|
||||
assert.equal(thrown.ok, false);
|
||||
assert.match(thrown.error, /boom/);
|
||||
assert.ok(!thrown.error.includes('private-server'));
|
||||
assert.match(thrown.error, /<redacted-path>/);
|
||||
});
|
||||
|
||||
test('agent redacts every successful response with configured secrets at the boundary', () => {
|
||||
const secret = 'configured-secret-123';
|
||||
const slashUnc = '//private-server/secret-share/secret.log';
|
||||
const collectors = stubCollectors();
|
||||
collectors.getSystemInfo = () => ({ nested: { message: `token ${secret}`, path: slashUnc } });
|
||||
collectors.redactResponse = value => valueScrub(value, [secret]);
|
||||
const result = createAgent(collectors).handle('get_system_info', {});
|
||||
const json = JSON.stringify(result);
|
||||
assert.equal(result.ok, true);
|
||||
assert.ok(!json.includes(secret));
|
||||
assert.ok(!json.includes('private-server'));
|
||||
assert.match(json, /<redacted>/);
|
||||
assert.match(json, /<redacted-path>/);
|
||||
});
|
||||
|
||||
test('agent fails closed when response redaction fails', () => {
|
||||
const agent = createAgent({
|
||||
getSystemInfo: () => ({ token: 'must-not-leak' }),
|
||||
redactResponse: () => { throw new Error('redactor unavailable'); }
|
||||
});
|
||||
const result = agent.handle('get_system_info', {});
|
||||
assert.deepEqual(result, { ok: false, error: 'diagnostic response could not be safely returned' });
|
||||
assert.ok(!JSON.stringify(result).includes('must-not-leak'));
|
||||
});
|
||||
|
||||
test('main process keeps diagnostics local and fails closed at its final reply boundary', () => {
|
||||
const source = fs.readFileSync(path.join(__dirname, '..', 'main.js'), 'utf8');
|
||||
assert.match(source, /function _diagBindHost\(\)\s*{\s*return '127\.0\.0\.1'/);
|
||||
assert.match(source, /function _diagPublicHost\(\)\s*{\s*return '127\.0\.0\.1'/);
|
||||
assert.match(source, /function _diagAllowlist\(\)\s*{\s*return \[\]/);
|
||||
assert.match(source, /bindMode: 'local'/);
|
||||
assert.match(source, /catch\s*{\s*result = { ok: false, error: 'diagnostic response could not be safely returned' }/);
|
||||
assert.match(source, /loadConfig:\s*\(\)\s*=>\s*configStore\.loadDiagnosticsConfig\(\)/);
|
||||
assert.match(source, /loadHistory:\s*\(\)\s*=>\s*configStore\.loadDiagnosticsHistory\(\)/);
|
||||
});
|
||||
|
||||
@@ -25,7 +25,7 @@ function makeFixture() {
|
||||
crashLog: path.join(dir, 'crash.log'),
|
||||
logDir: dir
|
||||
};
|
||||
fs.writeFileSync(paths.debug, `boot ok\nsource ${path.join(dir, 'private-source.mkv')}\nuploading file with token ${fixtureAlpha} inline\nAuthorization: Bearer ${fixtureBeta}\n`);
|
||||
fs.writeFileSync(paths.debug, `boot ok\nuploading file with token ${fixtureAlpha} inline\nAuthorization: Bearer ${fixtureBeta}\n`);
|
||||
fs.writeFileSync(paths.uploadAudit, `# SOURCE-CLEANUP {"token":"${fixtureAlpha}"}\n`);
|
||||
fs.writeFileSync(paths.doodstreamDebug, `api_key=${fixtureGamma} sess=abc\n`);
|
||||
fs.writeFileSync(paths.crashLog, 'CRASH at 12:00\n');
|
||||
@@ -87,134 +87,8 @@ test('getHistory falls back to loadConfig().history when loadHistory is absent (
|
||||
assert.equal(c.getHistory({ limit: 10 }).totalBatches, 1, 'legacy path reads load().history when loadHistory not injected');
|
||||
});
|
||||
|
||||
test('getHistory, listErrors and serverHealth share loadHistory after migration', () => {
|
||||
let historyRevision = 0;
|
||||
const c = createCollectors({
|
||||
loadConfig: () => ({ hosters: {}, globalSettings: {}, history: [] }),
|
||||
loadHistory: () => {
|
||||
historyRevision++;
|
||||
const timestamp = `2026-01-${String(historyRevision).padStart(2, '0')}T00:00:00.000Z`;
|
||||
return [{ timestamp, files: [{ name: `failed-${historyRevision}.mkv`, results: [{ hoster: 'voe.sx', status: 'error', error: 'Not video file format' }] }] }];
|
||||
},
|
||||
getAllLogPaths: () => ({ logDir: os.tmpdir() }),
|
||||
support, stats,
|
||||
appInfo: () => ({}), systemInfo: () => ({}), agentInfo: () => ({})
|
||||
});
|
||||
const health = c.serverHealth({ errorLimit: 5 });
|
||||
const history = c.getHistory({ limit: 5 });
|
||||
const errors = c.listErrors({ limit: 5 });
|
||||
assert.deepEqual({
|
||||
historyBatches: history.totalBatches,
|
||||
listedErrors: errors.total,
|
||||
healthBatches: health.recentBatches.length,
|
||||
healthErrors: health.errors.total
|
||||
}, {
|
||||
historyBatches: 1,
|
||||
listedErrors: 1,
|
||||
healthBatches: 1,
|
||||
healthErrors: 1
|
||||
});
|
||||
assert.equal(health.recentBatches[0].timestamp, health.errors.errors[0].ts, 'serverHealth must summarize one history snapshot');
|
||||
});
|
||||
|
||||
test('getHistory, listErrors and serverHealth leave the supplied history snapshot unchanged', () => {
|
||||
const original = [
|
||||
{ timestamp: '2026-01-02T00:00:00.000Z', files: [{ name: 'newer.mkv', results: [{ hoster: 'voe.sx', status: 'done' }] }] },
|
||||
{ timestamp: '2026-01-01T00:00:00.000Z', files: [{ name: 'older.mkv', results: [{ hoster: 'voe.sx', status: 'error', error: 'Not video file format' }] }] }
|
||||
];
|
||||
const sortingStats = {
|
||||
...stats,
|
||||
summarizePerHoster: history => {
|
||||
history.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp));
|
||||
return stats.summarizePerHoster(history);
|
||||
}
|
||||
};
|
||||
for (const [name, invoke] of [
|
||||
['listErrors', c => c.listErrors({})],
|
||||
['getHistory', c => c.getHistory({ includeFiles: true })],
|
||||
['serverHealth', c => c.serverHealth({})]
|
||||
]) {
|
||||
const snapshot = JSON.parse(JSON.stringify(original));
|
||||
const c = createCollectors({
|
||||
loadConfig: () => ({ hosters: {}, globalSettings: {}, history: [] }),
|
||||
loadHistory: () => snapshot,
|
||||
getAllLogPaths: () => ({ logDir: os.tmpdir() }),
|
||||
support, stats: sortingStats,
|
||||
appInfo: () => ({}), systemInfo: () => ({}), agentInfo: () => ({})
|
||||
});
|
||||
invoke(c);
|
||||
assert.deepEqual(snapshot, original, `${name} must not mutate the supplied history snapshot`);
|
||||
}
|
||||
});
|
||||
|
||||
test('dedicated history reader failures never report healthy empty history or use stale config history', () => {
|
||||
const sensitive = 'reader-private-value-38152';
|
||||
const config = {
|
||||
hosters: { 'voe.sx': [{ apiKey: sensitive }] },
|
||||
globalSettings: {},
|
||||
history: [{ timestamp: '2025-12-31T00:00:00.000Z', files: [{ name: 'stale.mkv', results: [{ hoster: 'voe.sx', status: 'error', error: 'stale error' }] }] }]
|
||||
};
|
||||
for (const [scenario, loadHistory] of [
|
||||
['throwing reader', () => { throw new Error(`history reader failed with ${sensitive}`); }],
|
||||
['invalid reader result', () => ({ history: [] })]
|
||||
]) {
|
||||
const c = createCollectors({
|
||||
loadConfig: () => JSON.parse(JSON.stringify(config)),
|
||||
loadHistory,
|
||||
getAllLogPaths: () => ({ logDir: os.tmpdir() }),
|
||||
support, stats,
|
||||
appInfo: () => ({}), systemInfo: () => ({}), agentInfo: () => ({})
|
||||
});
|
||||
for (const [name, invoke] of [
|
||||
['getHistory', () => c.getHistory({ includeFiles: true })],
|
||||
['listErrors', () => c.listErrors({})],
|
||||
['serverHealth', () => c.serverHealth({})]
|
||||
]) {
|
||||
assert.throws(invoke, /history/i, `${name} must reject a ${scenario}`);
|
||||
}
|
||||
const agent = createAgent(c);
|
||||
for (const op of ['get_history', 'list_errors', 'server_health']) {
|
||||
const response = agent.handle(op, { includeFiles: true });
|
||||
const json = JSON.stringify(response);
|
||||
assert.equal(response.ok, false, `${op} must report the ${scenario} as unhealthy`);
|
||||
assert.match(response.error, /history/i);
|
||||
assert.ok(!json.includes(sensitive));
|
||||
assert.ok(!json.includes('stale.mkv'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('history operations fail closed when configured secrets cannot be loaded', () => {
|
||||
const sensitive = 'history-private-value-27491';
|
||||
let historyReads = 0;
|
||||
const c = createCollectors({
|
||||
loadConfig: () => { throw new Error(`secret decryption failed near ${sensitive}`); },
|
||||
loadHistory: () => {
|
||||
historyReads++;
|
||||
return [{ timestamp: '2026-01-04T00:00:00.000Z', files: [{ name: `${sensitive}.mkv`, results: [{ hoster: 'voe.sx', status: 'error', error: `opaque ${sensitive}` }] }] }];
|
||||
},
|
||||
getAllLogPaths: () => ({ logDir: os.tmpdir() }),
|
||||
support, stats,
|
||||
appInfo: () => ({}), systemInfo: () => ({}), agentInfo: () => ({})
|
||||
});
|
||||
for (const [name, invoke] of [
|
||||
['getHistory', () => c.getHistory({ includeFiles: true })],
|
||||
['listErrors', () => c.listErrors({})],
|
||||
['serverHealth', () => c.serverHealth({})]
|
||||
]) {
|
||||
assert.throws(invoke, /secret decryption failed/, `${name} must fail without the configured redaction secrets`);
|
||||
}
|
||||
assert.equal(historyReads, 0, 'history must not be read before the redaction secrets are available');
|
||||
const agent = createAgent(c);
|
||||
for (const op of ['get_history', 'list_errors', 'server_health']) {
|
||||
const response = agent.handle(op, { includeFiles: true });
|
||||
assert.deepEqual(response, { ok: false, error: 'diagnostic response could not be safely returned' });
|
||||
assert.ok(!JSON.stringify(response).includes(sensitive));
|
||||
}
|
||||
});
|
||||
|
||||
test('readLog redacts a planted token and a Bearer line; doodstream is NOT readable; unknown name rejected', () => {
|
||||
const { collectors, dir, paths } = makeFixture();
|
||||
const { collectors } = makeFixture();
|
||||
const dbg = collectors.readLog({ name: 'debug', tailKb: 64 });
|
||||
const audit = collectors.readLog({ name: 'uploadAudit', tailKb: 64 });
|
||||
assert.ok(!dbg.content.includes('SECRETTOKEN123456'), 'value-scrub removes the live diag token from logs');
|
||||
@@ -223,10 +97,6 @@ test('readLog redacts a planted token and a Bearer line; doodstream is NOT reada
|
||||
assert.ok(!audit.content.includes('SECRETTOKEN123456'), 'source cleanup audit is readable only through the redacted diagnostics path');
|
||||
assert.equal(collectors.readLog({ name: 'doodstreamDebug' }).ok, false, 'doodstream-debug.log is not in the readable allowlist');
|
||||
assert.equal(collectors.readLog({ name: '../../etc/passwd' }).ok, false, 'arbitrary names are rejected (no path traversal)');
|
||||
assert.ok(!JSON.stringify(dbg).includes(dir), 'read log content and metadata must not expose its absolute directory');
|
||||
const rejectedAbsolutePath = collectors.readLog({ name: paths.debug });
|
||||
assert.ok(!rejectedAbsolutePath.error.includes(paths.debug), 'rejected log identifiers must not be echoed as absolute paths');
|
||||
assert.ok(!rejectedAbsolutePath.error.includes(path.basename(paths.debug)), 'rejected log identifiers must not echo path components');
|
||||
assert.equal(collectors.readLog({ name: 'crash' }).name, 'crash');
|
||||
});
|
||||
|
||||
@@ -234,21 +104,11 @@ test('rotated audit backups are listed and readable with the rotation naming con
|
||||
const { collectors, paths, fixtureAlpha } = makeFixture();
|
||||
const backupPath = path.join(path.dirname(paths.uploadAudit), 'upload-audit.1.log');
|
||||
fs.writeFileSync(backupPath, `# SOURCE-CLEANUP {"token":"${fixtureAlpha}"}\n`);
|
||||
const logList = collectors.listLogs();
|
||||
const listed = logList.files.find(file => file.name === 'uploadAudit');
|
||||
assert.equal(logList.dir, undefined);
|
||||
assert.equal(listed.id, 'uploadAudit');
|
||||
assert.equal(listed.fileName, 'upload-audit.log');
|
||||
assert.equal(listed.path, undefined);
|
||||
const listed = collectors.listLogs().files.find(file => file.name === 'uploadAudit');
|
||||
assert.ok(listed.variants.some(variant => variant.backup === 1));
|
||||
assert.ok(listed.variants.every(variant => variant.fileName && !Object.hasOwn(variant, 'path')));
|
||||
assert.ok(!collectors.listLogs().otherLogs.some(file => file.name === 'upload-audit.1.log'));
|
||||
const backup = collectors.readLog({ name: 'uploadAudit', backup: 1, tailKb: 64 });
|
||||
assert.equal(backup.id, 'uploadAudit');
|
||||
assert.equal(backup.name, 'uploadAudit');
|
||||
assert.equal(backup.fileName, 'upload-audit.1.log');
|
||||
assert.equal(backup.path, undefined);
|
||||
assert.ok(!JSON.stringify({ logList, backup }).includes(path.dirname(paths.uploadAudit)));
|
||||
assert.equal(backup.path, backupPath);
|
||||
assert.ok(!backup.content.includes(fixtureAlpha));
|
||||
});
|
||||
|
||||
@@ -311,26 +171,9 @@ test('listErrors classifies via stats.classifyErrorCategory and redacts error te
|
||||
});
|
||||
|
||||
test('serverHealth assembles the one-shot hub without leaking secrets', () => {
|
||||
const { collectors, dir, paths } = makeFixture();
|
||||
const { collectors } = makeFixture();
|
||||
const h = collectors.serverHealth({});
|
||||
const json = JSON.stringify(h);
|
||||
assert.ok(h.server && h.queue && h.errors && h.logs, 'hub has all sections');
|
||||
assert.ok(!json.includes('HUNTER2SECRET') && !json.includes('SECRETTOKEN123456') && !json.includes('WBHOOKSECRETTOKEN'), 'no secret leaks in server_health');
|
||||
assert.ok(!json.includes(dir) && !json.includes(paths.debug), 'server_health must not expose absolute log paths');
|
||||
});
|
||||
|
||||
test('redactResponse scrubs configured secrets and absolute paths from arbitrary nested output', () => {
|
||||
const { collectors, fixtureAlpha } = makeFixture();
|
||||
const privatePath = ['C:', 'Users', 'PrivateProfile', 'secret.log'].join('\\');
|
||||
const value = {
|
||||
error: `token ${fixtureAlpha} at ${privatePath}`,
|
||||
nested: [{ source: '\\\\?\\UNC\\private-server\\secret-share\\secret.log' }]
|
||||
};
|
||||
const out = collectors.redactResponse(value);
|
||||
const json = JSON.stringify(out);
|
||||
assert.ok(!json.includes(fixtureAlpha));
|
||||
assert.ok(!json.includes('PrivateProfile'));
|
||||
assert.ok(!json.includes('private-server'));
|
||||
assert.match(json, /<redacted>/);
|
||||
assert.match(json, /<redacted-path>/);
|
||||
});
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const os = require('os');
|
||||
const WebSocket = require('ws');
|
||||
const RemoteServer = require('../lib/remote-server');
|
||||
|
||||
const TOKEN = 'a'.repeat(64);
|
||||
|
||||
function firstLanIpv4() {
|
||||
for (const entry of Object.values(os.networkInterfaces())) {
|
||||
for (const net of (entry || [])) {
|
||||
if (net && net.family === 'IPv4' && !net.internal && net.address) return net.address;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function startAgent(onDiagnosticRequest, extra) {
|
||||
const srv = new RemoteServer();
|
||||
return srv.start({ port: 0, host: '127.0.0.1', token: TOKEN, diagnosticMode: true, onDiagnosticRequest, ...(extra || {}) })
|
||||
@@ -78,11 +88,19 @@ test('a loopback diagnostic client connects even with a non-matching allowlist (
|
||||
ws.close(); agent.stop();
|
||||
});
|
||||
|
||||
test('diagnostic server rejects wildcard and non-loopback binds before opening a listener', async () => {
|
||||
for (const host of ['0.0.0.0', '::', '192.0.2.10']) {
|
||||
const server = new RemoteServer();
|
||||
await assert.rejects(server.start({ port: 0, host, token: TOKEN, diagnosticMode: true }), /requires a loopback host/);
|
||||
assert.equal(server._wss, null);
|
||||
test('network bind (0.0.0.0): an allowlisted non-loopback peer connects over a real socket (the Tailscale path)', async (t) => {
|
||||
const lan = firstLanIpv4();
|
||||
if (!lan) { t.skip('no non-internal IPv4 interface available'); return; }
|
||||
const agent = await startAgent(() => {}, { host: '0.0.0.0', allowlist: [lan] });
|
||||
const port = agent.getPort();
|
||||
const ws = new WebSocket(`ws://${lan}:${port}`);
|
||||
try {
|
||||
await new Promise((resolve, reject) => { ws.on('open', resolve); ws.on('error', reject); });
|
||||
ws.send(JSON.stringify({ type: 'auth', token: TOKEN, role: 'diagnostic' }));
|
||||
const ok = await once(ws, 'auth-ok');
|
||||
assert.ok(ok.clientId, 'allowlisted LAN peer authed over the 0.0.0.0 bind');
|
||||
} finally {
|
||||
ws.close(); agent.stop();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ function routeWith(uploadBody, listBodies = []) {
|
||||
if (opts && opts.body && typeof opts.body[Symbol.asyncIterator] === 'function') {
|
||||
for await (const chunk of opts.body) { if (chunk && chunk.length === -1) break; }
|
||||
}
|
||||
return { statusCode: uploadBody.status, headers: { 'content-type': uploadBody.contentType || 'application/json' }, body: { text: async () => uploadBody.body } };
|
||||
return { statusCode: uploadBody.status, headers: { 'content-type': 'application/json' }, body: { text: async () => uploadBody.body } };
|
||||
};
|
||||
}
|
||||
|
||||
@@ -103,95 +103,3 @@ test('doodstream API upload: codeless + file never appears → throws hosterTran
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test('doodstream API upload never recovers an old file after a failed baseline', async () => {
|
||||
stubUploadServer();
|
||||
const abort = new AbortController();
|
||||
const fileName = path.basename(tmpFile);
|
||||
let listCalls = 0;
|
||||
requestRouter = async (url, opts) => {
|
||||
if (/\/api\/file\/list/.test(String(url))) {
|
||||
listCalls++;
|
||||
if (listCalls === 1) {
|
||||
return {
|
||||
statusCode: 503,
|
||||
headers: { 'content-type': 'text/html' },
|
||||
body: { text: async () => '<html>baseline-token=SYNTHETIC_BASELINE_SECRET</html>' }
|
||||
};
|
||||
}
|
||||
abort.abort();
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: { text: async () => JSON.stringify({ status: 200, result: { files: [{ file_code: 'OLD_DOOD_123', title: fileName }] } }) }
|
||||
};
|
||||
}
|
||||
if (opts && opts.body && typeof opts.body[Symbol.asyncIterator] === 'function') {
|
||||
for await (const chunk of opts.body) { if (chunk && chunk.length === -1) break; }
|
||||
}
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: { text: async () => JSON.stringify({ status: 200, msg: 'OK' }) }
|
||||
};
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
() => uploadFile('doodstream.com', tmpFile, 'VALIDKEY', null, abort.signal, null),
|
||||
(err) => {
|
||||
assert.doesNotMatch(err.message, /SYNTHETIC_BASELINE_SECRET/);
|
||||
assert.equal(err.diagnostic.phase, 'recovery-baseline');
|
||||
assert.equal(err.diagnostic.http, 503);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
assert.equal(listCalls, 1);
|
||||
});
|
||||
|
||||
test('doodstream API recovery rejects ambiguous same-title candidates', async () => {
|
||||
stubUploadServer();
|
||||
const fileName = path.basename(tmpFile);
|
||||
requestRouter = routeWith(
|
||||
{ status: 200, body: JSON.stringify({ status: 200, msg: 'OK' }) },
|
||||
[
|
||||
'{"status":200,"result":{"files":[]}}',
|
||||
JSON.stringify({
|
||||
status: 200,
|
||||
result: {
|
||||
files: [
|
||||
{ file_code: 'PARALLEL_A', title: fileName },
|
||||
{ file_code: 'PARALLEL_B', title: fileName }
|
||||
]
|
||||
}
|
||||
})
|
||||
]
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
() => uploadFile('doodstream.com', tmpFile, 'VALIDKEY', null, null, null),
|
||||
(err) => err.hosterTransient === true
|
||||
);
|
||||
});
|
||||
|
||||
test('doodstream API upload errors expose safe structured diagnostics', async () => {
|
||||
stubUploadServer();
|
||||
requestRouter = routeWith({
|
||||
status: 502,
|
||||
contentType: 'text/html; charset=utf-8',
|
||||
body: '<html>upstream-token=SYNTHETIC_UPLOAD_SECRET https://node.invalid/upload?session=SYNTHETIC_SESSION</html>'
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => uploadFile('doodstream.com', tmpFile, 'VALIDKEY', null, null, null),
|
||||
(err) => {
|
||||
assert.equal(err.transientNetwork, true);
|
||||
assert.doesNotMatch(err.message, /SYNTHETIC_UPLOAD_SECRET|SYNTHETIC_SESSION|<html>/);
|
||||
assert.equal(err.diagnostic.phase, 'upload-response');
|
||||
assert.equal(err.diagnostic.http, 502);
|
||||
assert.equal(err.diagnostic.contentType, 'text/html; charset=utf-8');
|
||||
assert.equal(err.diagnostic.responseKind, 'html');
|
||||
assert.doesNotMatch(err.diagnostic.payloadSnippet, /SYNTHETIC_UPLOAD_SECRET|SYNTHETIC_SESSION/);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const DoodstreamUploader = require('../lib/doodstream-upload');
|
||||
|
||||
// The CDN hands back an XFileSharing form. `fn` is the filecode, `st` is the
|
||||
@@ -67,39 +64,6 @@ test('happy path: link in result page wins', async () => {
|
||||
assert.equal(res.file_code, 'jjsuhr931ds9');
|
||||
});
|
||||
|
||||
test('JSON results rebuild canonical Doodstream URLs from the file code', () => {
|
||||
const up = new DoodstreamUploader();
|
||||
assert.deepEqual(
|
||||
up._extractFromJson({
|
||||
status: 200,
|
||||
result: {
|
||||
filecode: 'CANONICAL123',
|
||||
download_url: 'http://edge.dsvplay.com/result/CANONICAL123?token=SYNTHETIC_SECRET',
|
||||
protected_embed: 'https://dood.to/arbitrary/CANONICAL123'
|
||||
}
|
||||
}),
|
||||
{
|
||||
file_code: 'CANONICAL123',
|
||||
download_url: 'https://doodstream.com/d/CANONICAL123',
|
||||
embed_url: 'https://doodstream.com/e/CANONICAL123'
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test('invalid web upload results expose safe structured diagnostics', async () => {
|
||||
const up = new DoodstreamUploader();
|
||||
await assert.rejects(
|
||||
() => up._parseUploadResponse('<html><input name="api_key" value="SYNTHETIC_WEB_SECRET"> https://doodstream.com/?session=SYNTHETIC_WEB_SESSION</html>'),
|
||||
(err) => {
|
||||
assert.doesNotMatch(err.message, /SYNTHETIC_WEB_SECRET|SYNTHETIC_WEB_SESSION|<html>/);
|
||||
assert.equal(err.diagnostic.phase, 'upload-result');
|
||||
assert.equal(err.diagnostic.responseKind, 'html');
|
||||
assert.doesNotMatch(err.diagnostic.payloadSnippet, /SYNTHETIC_WEB_SECRET|SYNTHETIC_WEB_SESSION/);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// --- _parseUploadFormFields: replicate the current upload form faithfully ---
|
||||
test('_parseUploadFormFields extracts the real form fields and excludes the file input', () => {
|
||||
const up = new DoodstreamUploader();
|
||||
@@ -248,116 +212,3 @@ test('getUploadServer: throws (no silent dead fallback) when discovery fails', a
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test('getUploadServer: failures expose safe structured diagnostics without response secrets', async () => {
|
||||
const up = new DoodstreamUploader();
|
||||
up._fetch = async (url) => {
|
||||
if (/op=upload_server/.test(url)) {
|
||||
return fakeRes('<html>upstream-token=SYNTHETIC_DISCOVERY_SECRET</html>', { status: 503, ctype: 'text/html; charset=utf-8' });
|
||||
}
|
||||
return fakeRes('<input name="sess_id" value="SYNTHETIC_DISCOVERY_SESSION"><a href="https://node.invalid/upload?token=SYNTHETIC_QUERY">x</a>');
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
() => up._getUploadServer(),
|
||||
(err) => {
|
||||
assert.doesNotMatch(err.message, /SYNTHETIC_DISCOVERY_SECRET|SYNTHETIC_DISCOVERY_SESSION|SYNTHETIC_QUERY|<html>/);
|
||||
assert.equal(err.diagnostic.phase, 'upload-server');
|
||||
assert.equal(err.diagnostic.http, 503);
|
||||
assert.equal(err.diagnostic.contentType, 'text/html; charset=utf-8');
|
||||
assert.equal(err.diagnostic.safeEndpointHost, 'doodstream.com');
|
||||
assert.equal(err.diagnostic.responseKind, 'html');
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test('upload response read failure is marked as an uncertain remote commit', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-dood-response-'));
|
||||
const file = path.join(root, 'episode.mkv');
|
||||
fs.writeFileSync(file, Buffer.alloc(16, 1));
|
||||
const up = new DoodstreamUploader();
|
||||
up.sessId = 'SESSION';
|
||||
up._getUploadServer = async () => 'https://node.example/upload/01';
|
||||
up._requestUpload = async () => ({
|
||||
statusCode: 200,
|
||||
headers: {},
|
||||
body: { text: async () => { throw new Error('socket closed'); } }
|
||||
});
|
||||
try {
|
||||
await assert.rejects(
|
||||
() => up.upload(file),
|
||||
(err) => {
|
||||
assert.equal(err.remoteCommitUncertain, true);
|
||||
assert.equal(err.diagnostic.phase, 'upload-response-read');
|
||||
return true;
|
||||
}
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('redirect fetch failure after upload is marked as an uncertain remote commit', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-dood-redirect-'));
|
||||
const file = path.join(root, 'episode.mkv');
|
||||
fs.writeFileSync(file, Buffer.alloc(16, 1));
|
||||
const up = new DoodstreamUploader();
|
||||
up.sessId = 'SESSION';
|
||||
up._getUploadServer = async () => 'https://node.example/upload/01';
|
||||
up._requestUpload = async () => ({
|
||||
statusCode: 302,
|
||||
headers: { location: 'https://doodstream.com/upload-result' },
|
||||
body: { text: async () => '' }
|
||||
});
|
||||
up._fetch = async () => {
|
||||
const error = new Error('redirect fetch failed');
|
||||
error.diagnostic = { phase: 'web-request' };
|
||||
error.transientNetwork = true;
|
||||
throw error;
|
||||
};
|
||||
try {
|
||||
await assert.rejects(
|
||||
() => up.upload(file),
|
||||
(err) => {
|
||||
assert.equal(err.remoteCommitUncertain, true);
|
||||
assert.equal(err.diagnostic.phase, 'web-request');
|
||||
return true;
|
||||
}
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('fallback form fetch failure after upload is marked as an uncertain remote commit', async () => {
|
||||
const up = new DoodstreamUploader();
|
||||
up._fetch = async () => {
|
||||
throw new Error('fallback request failed');
|
||||
};
|
||||
await assert.rejects(
|
||||
() => up._parseUploadResponse('<form action="https://doodstream.com/result"></form>'),
|
||||
(err) => {
|
||||
assert.equal(err.remoteCommitUncertain, true);
|
||||
assert.equal(err.diagnostic.phase, 'upload-result-submit');
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test('fallback form body failure after upload is marked as an uncertain remote commit', async () => {
|
||||
const up = new DoodstreamUploader();
|
||||
up._fetch = async () => ({
|
||||
text: async () => {
|
||||
throw new Error('fallback body failed');
|
||||
}
|
||||
});
|
||||
await assert.rejects(
|
||||
() => up._parseUploadResponse('<form action="https://doodstream.com/result"></form>'),
|
||||
(err) => {
|
||||
assert.equal(err.remoteCommitUncertain, true);
|
||||
assert.equal(err.diagnostic.phase, 'upload-result-submit');
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
const { describe, it } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const {
|
||||
normalizeFilenameFilter,
|
||||
evaluateFilenameFilter,
|
||||
applyFilenameFilter
|
||||
} = require('../lib/filename-filter');
|
||||
|
||||
describe('filename filter', () => {
|
||||
it('accepts every file when the filter is disabled or has no usable conditions', () => {
|
||||
const disabled = applyFilenameFilter(['Episode.1080p.mkv'], {
|
||||
enabled: false,
|
||||
action: 'exclude',
|
||||
conditions: [{ operator: 'contains', value: '1080p' }]
|
||||
});
|
||||
const empty = applyFilenameFilter(['Episode.1080p.mkv'], {
|
||||
enabled: true,
|
||||
action: 'include',
|
||||
conditions: [{ operator: 'contains', value: ' ' }]
|
||||
});
|
||||
|
||||
assert.deepEqual(disabled.accepted, ['Episode.1080p.mkv']);
|
||||
assert.deepEqual(disabled.excluded, []);
|
||||
assert.equal(disabled.active, false);
|
||||
assert.deepEqual(empty.accepted, ['Episode.1080p.mkv']);
|
||||
assert.equal(empty.active, false);
|
||||
});
|
||||
|
||||
it('includes only filenames that satisfy every condition without case sensitivity', () => {
|
||||
const filter = {
|
||||
enabled: true,
|
||||
action: 'include',
|
||||
matchMode: 'all',
|
||||
conditions: [
|
||||
{ operator: 'contains', value: '720P' },
|
||||
{ operator: 'notContains', value: 'sample' }
|
||||
]
|
||||
};
|
||||
|
||||
const result = applyFilenameFilter([
|
||||
{ path: 'C:/Shows/Episode.720p.mkv', name: 'Episode.720p.mkv' },
|
||||
{ path: 'C:/Shows/Episode.720p.Sample.mkv', name: 'Episode.720p.Sample.mkv' },
|
||||
{ path: 'C:/Shows/Episode.1080p.mkv', name: 'Episode.1080p.mkv' }
|
||||
], filter);
|
||||
|
||||
assert.deepEqual(result.accepted.map(file => file.name), ['Episode.720p.mkv']);
|
||||
assert.deepEqual(result.excluded.map(file => file.name), ['Episode.720p.Sample.mkv', 'Episode.1080p.mkv']);
|
||||
assert.equal(result.total, 3);
|
||||
assert.equal(result.active, true);
|
||||
});
|
||||
|
||||
it('supports matching any condition and excluding matching filenames', () => {
|
||||
const filter = {
|
||||
enabled: true,
|
||||
action: 'exclude',
|
||||
matchMode: 'any',
|
||||
conditions: [
|
||||
{ operator: 'contains', value: '1080p' },
|
||||
{ operator: 'contains', value: 'sample' }
|
||||
]
|
||||
};
|
||||
|
||||
assert.equal(evaluateFilenameFilter('Episode.720p.mkv', filter).accepted, true);
|
||||
assert.equal(evaluateFilenameFilter('Episode.1080p.mkv', filter).accepted, false);
|
||||
assert.equal(evaluateFilenameFilter('Episode.720p.Sample.mkv', filter).accepted, false);
|
||||
});
|
||||
|
||||
it('normalizes unsupported values and derives names from paths', () => {
|
||||
const normalized = normalizeFilenameFilter({
|
||||
enabled: true,
|
||||
action: 'unknown',
|
||||
matchMode: 'unknown',
|
||||
conditions: [
|
||||
{ operator: 'unknown', value: ' 720p ' },
|
||||
null,
|
||||
{ operator: 'contains', value: '' }
|
||||
]
|
||||
});
|
||||
const result = applyFilenameFilter(['C:\\Shows\\Episode.720p.mkv', '/shows/Episode.1080p.mkv'], normalized);
|
||||
|
||||
assert.deepEqual(normalized, {
|
||||
enabled: true,
|
||||
action: 'include',
|
||||
matchMode: 'all',
|
||||
conditions: [{ operator: 'contains', value: '720p' }]
|
||||
});
|
||||
assert.deepEqual(result.accepted, ['C:\\Shows\\Episode.720p.mkv']);
|
||||
assert.deepEqual(result.excluded, ['/shows/Episode.1080p.mkv']);
|
||||
});
|
||||
});
|
||||
@@ -1,79 +0,0 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { installHiddenElectronWindowHarness } = require('./support/hidden-electron-window');
|
||||
|
||||
test('every Electron UI smoke window stays offscreen without native reveal or focus paths', () => {
|
||||
const nativeCalls = [];
|
||||
class TestBrowserWindow {
|
||||
constructor(options) {
|
||||
this.options = options;
|
||||
}
|
||||
show() { nativeCalls.push('show'); }
|
||||
showInactive() { nativeCalls.push('showInactive'); }
|
||||
focus() { nativeCalls.push('focus'); }
|
||||
restore() { nativeCalls.push('restore'); }
|
||||
moveTop() { nativeCalls.push('moveTop'); }
|
||||
setAlwaysOnTop(value) { nativeCalls.push(`setAlwaysOnTop:${value}`); }
|
||||
setIgnoreMouseEvents(value) { this.ignoresMouse = value; }
|
||||
isVisible() { return false; }
|
||||
isFocused() { return false; }
|
||||
}
|
||||
|
||||
const targetGlobal = {};
|
||||
const harness = installHiddenElectronWindowHarness({
|
||||
BrowserWindow: TestBrowserWindow,
|
||||
targetGlobal
|
||||
});
|
||||
const windows = [
|
||||
new targetGlobal.__mhuBrowserWindowConstructor({
|
||||
show: true,
|
||||
focusable: true,
|
||||
skipTaskbar: false,
|
||||
alwaysOnTop: true,
|
||||
webPreferences: { contextIsolation: true }
|
||||
}),
|
||||
new targetGlobal.__mhuBrowserWindowConstructor({ alwaysOnTop: true }),
|
||||
new targetGlobal.__mhuBrowserWindowConstructor({ show: true })
|
||||
];
|
||||
|
||||
windows[0].show();
|
||||
windows[1].showInactive();
|
||||
windows[2].focus();
|
||||
windows[0].restore();
|
||||
windows[1].moveTop();
|
||||
windows[2].setAlwaysOnTop(true);
|
||||
|
||||
assert.deepEqual(windows[0].options, {
|
||||
show: false,
|
||||
focusable: false,
|
||||
skipTaskbar: true,
|
||||
alwaysOnTop: false,
|
||||
paintWhenInitiallyHidden: true,
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
offscreen: true,
|
||||
backgroundThrottling: false
|
||||
}
|
||||
});
|
||||
assert.equal(windows.every(window => window.options.show === false && window.options.alwaysOnTop === false && window.ignoresMouse === true), true);
|
||||
assert.deepEqual(nativeCalls, []);
|
||||
assert.equal(harness.isAlwaysOnTopRequested(windows[2]), true);
|
||||
assert.equal(harness.isNativeSurfaceSuppressed({ isVisible: () => false, isFocused: () => false }), true);
|
||||
assert.equal(harness.areNativeSurfacesSuppressed(windows), true);
|
||||
assert.deepEqual(harness.getWindows(), windows);
|
||||
});
|
||||
|
||||
test('Main routes every BrowserWindow construction through the hidden test constructor', () => {
|
||||
const source = fs.readFileSync(path.join(__dirname, '..', 'main.js'), 'utf8');
|
||||
assert.doesNotMatch(source, /new BrowserWindow\s*\(/u);
|
||||
assert.equal((source.match(/RuntimeBrowserWindow/g) || []).length >= 4, true);
|
||||
});
|
||||
|
||||
test('UI smoke never constructs an original BrowserWindow', () => {
|
||||
const source = fs.readFileSync(path.join(__dirname, 'ui-smoke.js'), 'utf8');
|
||||
assert.doesNotMatch(source, /new BrowserWindow\s*\(/u);
|
||||
assert.doesNotMatch(source, /BrowserWindow\.getAllWindows\(\)/u);
|
||||
assert.equal((source.match(/areNativeSurfacesSuppressed/g) || []).length >= 2, true);
|
||||
});
|
||||
@@ -1,232 +0,0 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const VoeUploader = require('../lib/voe-upload');
|
||||
const VidmolyUploader = require('../lib/vidmoly-upload');
|
||||
const { createRecoveryClaimRegistry } = require('../lib/hosters');
|
||||
|
||||
function response(body, status = 200, contentType = 'application/json') {
|
||||
return {
|
||||
status,
|
||||
headers: { get: (name) => name.toLowerCase() === 'content-type' ? contentType : null },
|
||||
text: async () => body
|
||||
};
|
||||
}
|
||||
|
||||
test('VOE recovery rejects an unrelated singleton candidate', async () => {
|
||||
const uploader = new VoeUploader();
|
||||
uploader._fetchFileList = async () => [{ file_code: 'OTHER999', title: 'foreign-upload' }];
|
||||
uploader._sleep = async () => {};
|
||||
|
||||
assert.equal(await uploader._resolveUploadedFile('wanted-video.mkv', new Set(), null), null);
|
||||
});
|
||||
|
||||
test('VOE recovery rejects ambiguous exact-title candidates', async () => {
|
||||
const uploader = new VoeUploader();
|
||||
uploader._fetchFileList = async () => [
|
||||
{ file_code: 'VOE_FIRST', title: 'wanted-video' },
|
||||
{ file_code: 'VOE_SECOND', title: 'wanted-video' }
|
||||
];
|
||||
uploader._sleep = async () => {};
|
||||
|
||||
assert.equal(await uploader._resolveUploadedFile('wanted-video.mkv', new Set(), null), null);
|
||||
});
|
||||
|
||||
test('VOE recovery accepts one new exact-title candidate with a file extension', async () => {
|
||||
const uploader = new VoeUploader();
|
||||
uploader._fetchFileList = async () => [{ file_code: 'VOE_EXACT', title: 'wanted-video.mkv' }];
|
||||
uploader._sleep = async () => {};
|
||||
|
||||
assert.deepEqual(await uploader._resolveUploadedFile('wanted-video.mkv', new Set(), null), {
|
||||
file_code: 'VOE_EXACT',
|
||||
download_url: 'https://voe.sx/VOE_EXACT',
|
||||
embed_url: 'https://voe.sx/e/VOE_EXACT'
|
||||
});
|
||||
});
|
||||
|
||||
test('VOE preserves a failed recovery baseline as a safe structured error', async () => {
|
||||
const uploader = new VoeUploader();
|
||||
uploader._fetch = async () => response(
|
||||
'<html>api_key=SYNTHETIC_VOE_SECRET https://voe.sx/list?session=SYNTHETIC_VOE_SESSION</html>',
|
||||
503,
|
||||
'text/html'
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
() => uploader._captureFileCodes(),
|
||||
(err) => {
|
||||
assert.doesNotMatch(err.message, /SYNTHETIC_VOE_SECRET|SYNTHETIC_VOE_SESSION|<html>/);
|
||||
assert.equal(err.diagnostic.phase, 'recovery-baseline');
|
||||
assert.equal(err.diagnostic.http, 503);
|
||||
assert.equal(err.diagnostic.responseKind, 'html');
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test('Vidmoly recovery rejects a matching code already present in the baseline', async () => {
|
||||
const uploader = new VidmolyUploader();
|
||||
uploader._fetchVmList = async () => [{ file_code: ' OLDVID123456 ', full_title: 'wanted-video' }];
|
||||
uploader._sleep = async () => {};
|
||||
|
||||
assert.equal(
|
||||
await uploader._resolveUploadedFileFromVmApi('wanted-video.mkv', new Set(['OLDVID123456']), null),
|
||||
null
|
||||
);
|
||||
});
|
||||
|
||||
test('Vidmoly recovery rejects ambiguous exact-title candidates', async () => {
|
||||
const uploader = new VidmolyUploader();
|
||||
uploader._fetchVmList = async () => [
|
||||
{ file_code: 'NEWVID123456', full_title: 'wanted-video' },
|
||||
{ file_code: 'NEWVID654321', full_title: 'wanted-video' }
|
||||
];
|
||||
uploader._sleep = async () => {};
|
||||
|
||||
assert.equal(await uploader._resolveUploadedFileFromVmApi('wanted-video.mkv', new Set(), null), null);
|
||||
});
|
||||
|
||||
test('Vidmoly recovery accepts one new exact-title candidate with a file extension', async () => {
|
||||
const uploader = new VidmolyUploader();
|
||||
uploader._fetchVmList = async () => [{ file_code: 'NEWVID123456', full_title: 'wanted-video.mkv' }];
|
||||
uploader._sleep = async () => {};
|
||||
|
||||
assert.deepEqual(await uploader._resolveUploadedFileFromVmApi('wanted-video.mkv', new Set(), null), {
|
||||
file_code: 'NEWVID123456',
|
||||
download_url: 'https://vidmoly.me/w/NEWVID123456',
|
||||
embed_url: 'https://vidmoly.me/embed-NEWVID123456.html'
|
||||
});
|
||||
});
|
||||
|
||||
test('Vidmoly preserves a failed recovery baseline as a safe structured error', async () => {
|
||||
const uploader = new VidmolyUploader();
|
||||
uploader._fetch = async () => response(
|
||||
'<html>sess_id=SYNTHETIC_VIDMOLY_SECRET https://vidmoly.me/?token=SYNTHETIC_VIDMOLY_SESSION</html>',
|
||||
503,
|
||||
'text/html'
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
() => uploader._captureVmFileCodes(),
|
||||
(err) => {
|
||||
assert.doesNotMatch(err.message, /SYNTHETIC_VIDMOLY_SECRET|SYNTHETIC_VIDMOLY_SESSION|<html>/);
|
||||
assert.equal(err.diagnostic.phase, 'recovery-baseline');
|
||||
assert.equal(err.diagnostic.http, 503);
|
||||
assert.equal(err.diagnostic.responseKind, 'html');
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test('VOE concurrent same-name recovery claims one remote entry only once', async () => {
|
||||
const registry = createRecoveryClaimRegistry();
|
||||
const first = new VoeUploader(registry.forUpload('voe.sx', 'ACCOUNT', 'Shared Episode.mkv'));
|
||||
const second = new VoeUploader(registry.forUpload('voe.sx', 'ACCOUNT', 'shared-episode.mp4'));
|
||||
const remoteFiles = [{ file_code: 'VOE_SHARED', title: 'shared episode' }];
|
||||
first._fetchFileList = async () => remoteFiles;
|
||||
second._fetchFileList = async () => remoteFiles;
|
||||
first._sleep = async () => {};
|
||||
second._sleep = async () => {};
|
||||
|
||||
const results = await Promise.all([
|
||||
first._resolveUploadedFile('C:\\source-a\\Shared Episode.mkv', new Set(), null),
|
||||
second._resolveUploadedFile('D:\\source-b\\shared-episode.mp4', new Set(), null)
|
||||
]);
|
||||
|
||||
assert.deepEqual(results.filter(Boolean).map(result => result.file_code), ['VOE_SHARED']);
|
||||
});
|
||||
|
||||
test('VOE concurrent same-name recovery accepts distinct remote codes', async () => {
|
||||
const registry = createRecoveryClaimRegistry();
|
||||
const first = new VoeUploader(registry.forUpload('voe.sx', 'ACCOUNT', 'Shared Episode.mkv'));
|
||||
const second = new VoeUploader(registry.forUpload('voe.sx', 'ACCOUNT', 'shared-episode.mp4'));
|
||||
first._fetchFileList = async () => [{ file_code: 'VOE_FIRST', title: 'shared episode' }];
|
||||
second._fetchFileList = async () => [{ file_code: 'VOE_SECOND', title: 'shared episode' }];
|
||||
first._sleep = async () => {};
|
||||
second._sleep = async () => {};
|
||||
|
||||
const results = await Promise.all([
|
||||
first._resolveUploadedFile('C:\\source-a\\Shared Episode.mkv', new Set(), null),
|
||||
second._resolveUploadedFile('D:\\source-b\\shared-episode.mp4', new Set(), null)
|
||||
]);
|
||||
|
||||
assert.deepEqual(results.map(result => result.file_code), ['VOE_FIRST', 'VOE_SECOND']);
|
||||
});
|
||||
|
||||
test('Vidmoly concurrent same-name recovery claims one remote entry only once', async () => {
|
||||
const registry = createRecoveryClaimRegistry();
|
||||
const first = new VidmolyUploader(registry.forUpload('vidmoly.me', 'ACCOUNT', 'Shared Episode.mkv'));
|
||||
const second = new VidmolyUploader(registry.forUpload('vidmoly.me', 'ACCOUNT', 'shared-episode.mp4'));
|
||||
const remoteFiles = [{ file_code: 'VIDSHARED001', full_title: 'shared episode' }];
|
||||
first._fetchVmList = async () => remoteFiles;
|
||||
second._fetchVmList = async () => remoteFiles;
|
||||
first._sleep = async () => {};
|
||||
second._sleep = async () => {};
|
||||
|
||||
const results = await Promise.all([
|
||||
first._resolveUploadedFileFromVmApi('C:\\source-a\\Shared Episode.mkv', new Set(), null),
|
||||
second._resolveUploadedFileFromVmApi('D:\\source-b\\shared-episode.mp4', new Set(), null)
|
||||
]);
|
||||
|
||||
assert.deepEqual(results.filter(Boolean).map(result => result.file_code), ['VIDSHARED001']);
|
||||
});
|
||||
|
||||
test('Vidmoly concurrent same-name recovery accepts distinct remote codes', async () => {
|
||||
const registry = createRecoveryClaimRegistry();
|
||||
const first = new VidmolyUploader(registry.forUpload('vidmoly.me', 'ACCOUNT', 'Shared Episode.mkv'));
|
||||
const second = new VidmolyUploader(registry.forUpload('vidmoly.me', 'ACCOUNT', 'shared-episode.mp4'));
|
||||
first._fetchVmList = async () => [{ file_code: 'VIDFIRST0001', full_title: 'shared episode' }];
|
||||
second._fetchVmList = async () => [{ file_code: 'VIDSECOND001', full_title: 'shared episode' }];
|
||||
first._sleep = async () => {};
|
||||
second._sleep = async () => {};
|
||||
|
||||
const results = await Promise.all([
|
||||
first._resolveUploadedFileFromVmApi('C:\\source-a\\Shared Episode.mkv', new Set(), null),
|
||||
second._resolveUploadedFileFromVmApi('D:\\source-b\\shared-episode.mp4', new Set(), null)
|
||||
]);
|
||||
|
||||
assert.deepEqual(results.map(result => result.file_code), ['VIDFIRST0001', 'VIDSECOND001']);
|
||||
});
|
||||
|
||||
for (const scenario of [
|
||||
{
|
||||
label: 'VOE',
|
||||
hoster: 'voe.sx',
|
||||
Uploader: VoeUploader,
|
||||
sharedCode: 'VOE_UNCERTAIN',
|
||||
lateCode: 'VOE_LATE_CODE',
|
||||
build(uploader, code) {
|
||||
return uploader._buildUrls(code);
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Vidmoly',
|
||||
hoster: 'vidmoly.me',
|
||||
Uploader: VidmolyUploader,
|
||||
sharedCode: 'VIDUNCERTAIN',
|
||||
lateCode: 'VIDLATECODE1',
|
||||
build(uploader, code) {
|
||||
return uploader._buildUrlsFromCode(code);
|
||||
}
|
||||
}
|
||||
]) {
|
||||
test(`${scenario.label} marks a duplicate direct identity uncertain and blocks a later title match`, async () => {
|
||||
const registry = createRecoveryClaimRegistry();
|
||||
const firstClaim = registry.forUpload(scenario.hoster, 'ACCOUNT', 'First Episode.mkv');
|
||||
const uncertainClaim = registry.forUpload(scenario.hoster, 'ACCOUNT', 'Second Episode.mkv');
|
||||
const first = new scenario.Uploader(firstClaim);
|
||||
const uncertain = new scenario.Uploader(uncertainClaim);
|
||||
|
||||
scenario.build(first, scenario.sharedCode);
|
||||
assert.throws(
|
||||
() => scenario.build(uncertain, scenario.sharedCode),
|
||||
err => err.remoteIdentityClaimed === true && err.remoteCommitUncertain === true
|
||||
);
|
||||
|
||||
const laterClaim = registry.forUpload(scenario.hoster, 'ACCOUNT', 'Second Episode.mp4');
|
||||
await assert.rejects(
|
||||
() => laterClaim.runExclusive(async () => scenario.build(new scenario.Uploader(laterClaim), scenario.lateCode)),
|
||||
err => err.remoteCommitUncertain === true
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
const { after, before, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
|
||||
let requestRouter = async () => ({ statusCode: 200, headers: {}, body: { text: async () => '{}' } });
|
||||
const undici = require('undici');
|
||||
const originalRequest = undici.request;
|
||||
undici.request = (...args) => requestRouter(...args);
|
||||
delete require.cache[require.resolve('../lib/hosters')];
|
||||
const hosters = require('../lib/hosters');
|
||||
|
||||
let tempRoot;
|
||||
let uploadPath;
|
||||
let originalFetch;
|
||||
|
||||
before(() => {
|
||||
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-recovery-safety-'));
|
||||
uploadPath = path.join(tempRoot, 'Shared Episode.mkv');
|
||||
fs.writeFileSync(uploadPath, Buffer.alloc(2048, 7));
|
||||
originalFetch = global.fetch;
|
||||
hosters.__test.DOODSTREAM_POLL.attempts = 1;
|
||||
hosters.__test.DOODSTREAM_POLL.delayMs = 0;
|
||||
});
|
||||
|
||||
after(() => {
|
||||
global.fetch = originalFetch;
|
||||
undici.request = originalRequest;
|
||||
delete require.cache[require.resolve('../lib/hosters')];
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function stubUploadServer() {
|
||||
global.fetch = async () => ({
|
||||
status: 200,
|
||||
text: async () => JSON.stringify({ status: 200, result: 'https://node1.cloudatacdn.com/upload/01' })
|
||||
});
|
||||
}
|
||||
|
||||
function response(body, statusCode = 200) {
|
||||
return {
|
||||
statusCode,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: { text: async () => typeof body === 'string' ? body : JSON.stringify(body) }
|
||||
};
|
||||
}
|
||||
|
||||
async function drain(body) {
|
||||
if (!body || typeof body[Symbol.asyncIterator] !== 'function') return;
|
||||
for await (const chunk of body) {
|
||||
if (chunk && chunk.length === -1) break;
|
||||
}
|
||||
}
|
||||
|
||||
function createClaim() {
|
||||
const codes = new Set();
|
||||
return {
|
||||
has: (code) => codes.has(String(code)),
|
||||
reserve(code) {
|
||||
const normalized = String(code || '').trim();
|
||||
if (!normalized || codes.has(normalized)) return false;
|
||||
codes.add(normalized);
|
||||
return true;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
test('parallel same-name recovery cannot reuse a directly confirmed remote file', async () => {
|
||||
stubUploadServer();
|
||||
const recoveryClaim = createClaim();
|
||||
let uploadCalls = 0;
|
||||
requestRouter = async (url, options) => {
|
||||
if (/\/api\/file\/list/.test(String(url))) {
|
||||
return response({
|
||||
status: 200,
|
||||
result: { files: [{ file_code: 'SHARED_REMOTE_CODE', title: path.basename(uploadPath) }] }
|
||||
});
|
||||
}
|
||||
await drain(options && options.body);
|
||||
uploadCalls++;
|
||||
if (uploadCalls === 1) {
|
||||
return response({
|
||||
status: 200,
|
||||
result: [{ filecode: 'SHARED_REMOTE_CODE', download_url: 'https://doodstream.com/d/SHARED_REMOTE_CODE' }]
|
||||
});
|
||||
}
|
||||
return response({ status: 200, msg: 'OK' });
|
||||
};
|
||||
|
||||
const results = await Promise.allSettled([
|
||||
hosters.uploadFile('doodstream.com', uploadPath, 'VALIDKEY', null, null, null, {
|
||||
doodBaseline: new Set(),
|
||||
recoveryClaim
|
||||
}),
|
||||
hosters.uploadFile('doodstream.com', uploadPath, 'VALIDKEY', null, null, null, {
|
||||
doodBaseline: new Set(),
|
||||
recoveryClaim
|
||||
})
|
||||
]);
|
||||
|
||||
assert.equal(results.filter(result => result.status === 'fulfilled').length, 1);
|
||||
assert.equal(results.filter(result => result.status === 'rejected').length, 1);
|
||||
assert.equal(results.find(result => result.status === 'fulfilled').value.file_code, 'SHARED_REMOTE_CODE');
|
||||
assert.equal(results.find(result => result.status === 'rejected').reason.hosterTransient, true);
|
||||
});
|
||||
|
||||
test('a direct response rejects a remote code already reserved in its recovery scope', async () => {
|
||||
stubUploadServer();
|
||||
const recoveryClaim = createClaim();
|
||||
recoveryClaim.reserve('ALREADY_RESERVED_CODE');
|
||||
requestRouter = async (url, options) => {
|
||||
if (/\/api\/file\/list/.test(String(url))) {
|
||||
return response({ status: 200, result: { files: [] } });
|
||||
}
|
||||
await drain(options && options.body);
|
||||
return response({
|
||||
status: 200,
|
||||
result: [{
|
||||
filecode: 'ALREADY_RESERVED_CODE',
|
||||
download_url: 'https://doodstream.com/d/ALREADY_RESERVED_CODE'
|
||||
}]
|
||||
});
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
() => hosters.uploadFile('doodstream.com', uploadPath, 'VALIDKEY', null, null, null, {
|
||||
doodBaseline: new Set(),
|
||||
recoveryClaim
|
||||
}),
|
||||
error => {
|
||||
assert.equal(error.hosterTransient, true);
|
||||
assert.equal(error.diagnostic.phase, 'upload-result');
|
||||
assert.equal(error.diagnostic.http, 200);
|
||||
assert.doesNotMatch(error.message, /ALREADY_RESERVED_CODE/);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
for (const fixture of [
|
||||
{ name: 'semantic error payload', payload: { status: 'error', msg: 'invalid key' } },
|
||||
{ name: 'missing files list', payload: { status: 200, result: {} } }
|
||||
]) {
|
||||
test(`doodstream rejects a ${fixture.name} as a recovery baseline`, async () => {
|
||||
stubUploadServer();
|
||||
let listCalls = 0;
|
||||
requestRouter = async (url, options) => {
|
||||
if (/\/api\/file\/list/.test(String(url))) {
|
||||
listCalls++;
|
||||
return response(fixture.payload);
|
||||
}
|
||||
await drain(options && options.body);
|
||||
return response({ status: 200, msg: 'OK' });
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
() => hosters.uploadFile('doodstream.com', uploadPath, 'VALIDKEY', null, null, null),
|
||||
error => {
|
||||
assert.equal(error.diagnostic.phase, 'recovery-baseline');
|
||||
assert.equal(error.diagnostic.http, 200);
|
||||
assert.equal(error.hosterTransient, true);
|
||||
assert.doesNotMatch(error.message, /invalid key/i);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
assert.equal(listCalls, 1);
|
||||
});
|
||||
}
|
||||
|
||||
test('byse rejects a missing files list as a recovery baseline', async () => {
|
||||
global.fetch = async () => ({
|
||||
status: 200,
|
||||
text: async () => JSON.stringify({ status: 200, result: 'https://byse-upload.invalid/upload/01' })
|
||||
});
|
||||
let listCalls = 0;
|
||||
requestRouter = async (url, options) => {
|
||||
if (/\/file\/list/.test(String(url))) {
|
||||
listCalls++;
|
||||
return response({ status: 200, result: {} });
|
||||
}
|
||||
await drain(options && options.body);
|
||||
return response({ status: 200, msg: 'OK' });
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
() => hosters.uploadFile('byse.sx', uploadPath, 'VALIDKEY', null, null, null),
|
||||
error => {
|
||||
assert.equal(error.diagnostic.phase, 'recovery-baseline');
|
||||
assert.equal(error.diagnostic.http, 200);
|
||||
assert.equal(error.hosterTransient, true);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
assert.equal(listCalls, 1);
|
||||
});
|
||||
|
||||
test('a cancelled recovery lock waiter exits before the active lease finishes', async () => {
|
||||
const registry = hosters.createRecoveryClaimRegistry();
|
||||
const claim = registry.forUpload('doodstream.com', 'ACCOUNT_KEY', 'Shared Episode.mkv');
|
||||
let releaseFirst;
|
||||
const firstBlocked = new Promise(resolve => {
|
||||
releaseFirst = resolve;
|
||||
});
|
||||
let markFirstEntered;
|
||||
const firstEntered = new Promise(resolve => {
|
||||
markFirstEntered = resolve;
|
||||
});
|
||||
const first = claim.runExclusive(async () => {
|
||||
markFirstEntered();
|
||||
await firstBlocked;
|
||||
});
|
||||
await firstEntered;
|
||||
const abortController = new AbortController();
|
||||
const second = claim.runExclusive(async () => 'unexpected', abortController.signal);
|
||||
abortController.abort();
|
||||
|
||||
try {
|
||||
const outcome = await Promise.race([
|
||||
second.then(value => ({ status: 'fulfilled', value }), error => ({ status: 'rejected', error })),
|
||||
new Promise(resolve => setTimeout(() => resolve({ status: 'timeout' }), 100))
|
||||
]);
|
||||
assert.equal(outcome.status, 'rejected');
|
||||
assert.equal(outcome.error.name, 'AbortError');
|
||||
} finally {
|
||||
releaseFirst();
|
||||
await first;
|
||||
await second.catch(() => {});
|
||||
}
|
||||
});
|
||||
+4
-89
@@ -1,7 +1,7 @@
|
||||
const { describe, it } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { __test, createRecoveryClaimRegistry, normalizeRecoveryTitle } = require('../lib/hosters');
|
||||
const { __test } = require('../lib/hosters');
|
||||
|
||||
describe('hosters helpers', () => {
|
||||
it('extracts VOE file_code from nested result payloads', () => {
|
||||
@@ -46,13 +46,12 @@ describe('hosters helpers', () => {
|
||||
it('parseDoodstreamResult handles result-as-array and result-as-object', () => {
|
||||
const arr = __test.parseDoodstreamResult({ result: [{ filecode: 'AB1', protected_dl: 'https://x/1', protected_embed: 'https://x/e/1' }] });
|
||||
assert.equal(arr.file_code, 'AB1');
|
||||
assert.equal(arr.download_url, 'https://doodstream.com/d/AB1');
|
||||
assert.equal(arr.embed_url, 'https://doodstream.com/e/AB1');
|
||||
assert.equal(arr.download_url, 'https://x/1');
|
||||
assert.equal(arr.embed_url, 'https://x/e/1');
|
||||
|
||||
const obj = __test.parseDoodstreamResult({ result: { filecode: 'OBJ1', download_url: 'https://x/2' } });
|
||||
assert.equal(obj.file_code, 'OBJ1');
|
||||
assert.equal(obj.download_url, 'https://doodstream.com/d/OBJ1');
|
||||
assert.equal(obj.embed_url, 'https://doodstream.com/e/OBJ1');
|
||||
assert.equal(obj.download_url, 'https://x/2');
|
||||
});
|
||||
|
||||
it('parseByseResult tolerates null/non-object payload without throwing', () => {
|
||||
@@ -94,87 +93,3 @@ describe('hosters helpers', () => {
|
||||
assert.equal(r.embed_url, 'https://byse.sx/e/GOOD123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('recovery claim registry', () => {
|
||||
it('keeps symbol-only titles distinct while matching Unicode-equivalent forms', () => {
|
||||
const gear = normalizeRecoveryTitle('⚙.mkv');
|
||||
const emojiGear = normalizeRecoveryTitle('⚙️.mp4');
|
||||
const fire = normalizeRecoveryTitle('🔥.mkv');
|
||||
const joined = normalizeRecoveryTitle('👩💻.mkv');
|
||||
const unjoined = normalizeRecoveryTitle('👩💻.mkv');
|
||||
|
||||
assert.ok(gear);
|
||||
assert.equal(emojiGear, gear);
|
||||
assert.notEqual(fire, gear);
|
||||
assert.notEqual(joined, unjoined);
|
||||
});
|
||||
|
||||
it('claims remote codes across every title of one normalized hoster account', () => {
|
||||
const registry = createRecoveryClaimRegistry();
|
||||
const first = registry.forUpload(' VOE.SX ', 'ACCOUNT', 'First Episode.mkv');
|
||||
const differentTitle = registry.forUpload('voe.sx', 'ACCOUNT', 'Second Episode.mp4');
|
||||
const differentAccount = registry.forUpload('voe.sx', 'ACCOUNT-B', 'Second Episode.mp4');
|
||||
|
||||
assert.equal(first.reserve('REMOTE-CODE'), true);
|
||||
assert.equal(differentTitle.reserve('REMOTE-CODE'), false);
|
||||
assert.equal(differentAccount.reserve('REMOTE-CODE'), true);
|
||||
});
|
||||
|
||||
it('serializes canonically equivalent titles without blocking an independent title', async () => {
|
||||
const registry = createRecoveryClaimRegistry();
|
||||
const composed = registry.forUpload('voe.sx', 'ACCOUNT', 'Café.mkv');
|
||||
const decomposed = registry.forUpload('voe.sx', 'ACCOUNT', 'Cafe\u0301.mp4');
|
||||
const independent = registry.forUpload('voe.sx', 'ACCOUNT', 'Other Episode.mkv');
|
||||
const events = [];
|
||||
let releaseFirst;
|
||||
const firstGate = new Promise(resolve => {
|
||||
releaseFirst = resolve;
|
||||
});
|
||||
assert.equal(composed.reserve('UNICODE-CODE'), true);
|
||||
assert.equal(decomposed.reserve('UNICODE-CODE'), false);
|
||||
|
||||
const first = composed.runExclusive(async () => {
|
||||
events.push('first-started');
|
||||
await firstGate;
|
||||
events.push('first-finished');
|
||||
});
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
const equivalent = decomposed.runExclusive(async () => {
|
||||
events.push('equivalent-started');
|
||||
});
|
||||
const other = independent.runExclusive(async () => {
|
||||
events.push('independent-started');
|
||||
});
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
|
||||
assert.deepEqual(events, ['first-started', 'independent-started']);
|
||||
releaseFirst();
|
||||
await Promise.all([first, equivalent, other]);
|
||||
assert.deepEqual(events, ['first-started', 'independent-started', 'first-finished', 'equivalent-started']);
|
||||
});
|
||||
|
||||
it('fails closed for later jobs after a title becomes uncertain', async () => {
|
||||
const registry = createRecoveryClaimRegistry();
|
||||
const first = registry.forUpload('vidmoly.me', 'ACCOUNT', 'Shared Episode.mkv');
|
||||
const later = registry.forUpload('vidmoly.me', 'ACCOUNT', 'shared-episode.mp4');
|
||||
const error = first.markUncertain(new Error('Remote commit could not be confirmed'));
|
||||
|
||||
assert.equal(error.remoteCommitUncertain, true);
|
||||
assert.equal(error.hosterTransient, true);
|
||||
await assert.rejects(
|
||||
() => later.runExclusive(async () => 'unsafe-success'),
|
||||
err => err.remoteCommitUncertain === true && err.hosterTransient === true
|
||||
);
|
||||
});
|
||||
|
||||
it('drops every claim when the registry is cleared', () => {
|
||||
const registry = createRecoveryClaimRegistry();
|
||||
const first = registry.forUpload('voe.sx', 'ACCOUNT', 'Episode.mkv');
|
||||
assert.equal(first.reserve('REMOTE-CODE'), true);
|
||||
|
||||
registry.clear();
|
||||
|
||||
const nextBatch = registry.forUpload('voe.sx', 'ACCOUNT', 'Episode.mkv');
|
||||
assert.equal(nextBatch.reserve('REMOTE-CODE'), true);
|
||||
});
|
||||
});
|
||||
|
||||
+4
-48
@@ -74,17 +74,6 @@ test('translates duplicate desktop drop feedback to English', () => {
|
||||
assert.equal(translateText('Auswahl ist bereits in den Upload-Aufträgen.', 'en'), 'The selection is already in the upload jobs.');
|
||||
});
|
||||
|
||||
test('translates filename filter controls and import counts in both directions', () => {
|
||||
const german = '1 von 3 Dateien werden hinzugefügt. 2 durch den Dateinamenfilter ausgeschlossen.';
|
||||
const english = '1 of 3 files will be added. 2 excluded by the filename filter.';
|
||||
|
||||
assert.equal(translateText('Dateinamen beim Hinzufügen filtern', 'en'), 'Filter filenames when adding files');
|
||||
assert.equal(translateText('Dateiname filtern', 'en'), 'Filter file name');
|
||||
assert.equal(translateText('enthält nicht', 'en'), 'does not contain');
|
||||
assert.equal(translateText(german, 'en'), english);
|
||||
assert.equal(translateText(english, 'de'), german);
|
||||
});
|
||||
|
||||
test('rare account, backup, update, and confirmation states translate in both directions', () => {
|
||||
const cases = [
|
||||
['Einstellungen konnten vor dem Update nicht gespeichert werden', 'Settings could not be saved before the update'],
|
||||
@@ -130,11 +119,6 @@ test('rare account, backup, update, and confirmation states translate in both di
|
||||
['Backup importiert', 'Backup imported'],
|
||||
['Import fehlgeschlagen', 'Import failed'],
|
||||
['Upload-Start fehlgeschlagen', 'Failed to start upload'],
|
||||
['Initialisierung fehlgeschlagen', 'Initialization failed'],
|
||||
['Import übernommen. Warteschlange konnte nicht vollständig gespeichert werden', 'Import applied. The queue could not be saved completely'],
|
||||
['Jobs konnten nicht hinzugefügt werden', 'Jobs could not be added'],
|
||||
['Test fehlgeschlagen', 'Test failed'],
|
||||
['. Fernzugriff nur über einen Tunnel (z.B. Tailscale/SSH).', '. Remote access is only available through a tunnel (for example, Tailscale/SSH).'],
|
||||
['erneut versuchbar', 'retryable'],
|
||||
['manuell', 'manual'],
|
||||
['Abgebrochen.', 'Canceled.'],
|
||||
@@ -160,40 +144,12 @@ test('interpolated rare errors translate without leaking German copy', () => {
|
||||
const cases = [
|
||||
['Login ok, Upload-Form bereit (Dateifeld: file)', 'Login successful, upload form ready (file field: file)'],
|
||||
['Klartext-Backup ist kein gültiges JSON: Unexpected token', 'Plain JSON backup is not valid JSON: Unexpected token'],
|
||||
['Export fehlgeschlagen: Zugriff verweigert', 'Export failed: Access denied'],
|
||||
['Import fehlgeschlagen: Datei beschädigt', 'Import failed: File is damaged'],
|
||||
['Initialisierung fehlgeschlagen: Konfiguration fehlt', 'Initialization failed: Configuration is missing']
|
||||
['Export fehlgeschlagen: Zugriff verweigert', 'Export failed: Zugriff verweigert'],
|
||||
['Import fehlgeschlagen: Datei beschädigt', 'Import failed: Datei beschädigt'],
|
||||
['Initialisierung fehlgeschlagen: Konfiguration fehlt', 'Initialization failed: Konfiguration fehlt']
|
||||
];
|
||||
|
||||
for (const [german, english] of cases) {
|
||||
assert.equal(translateText(german, 'en'), english, german);
|
||||
assert.equal(translateText(english, 'de'), german, english);
|
||||
}
|
||||
});
|
||||
|
||||
test('resume, queue, job log, and session report feedback translate in both languages', () => {
|
||||
const cases = [
|
||||
['1 unterbrochener Upload kann fortgesetzt werden.', '1 interrupted upload can be resumed.'],
|
||||
['3 unterbrochene Uploads können fortgesetzt werden.', '3 interrupted uploads can be resumed.'],
|
||||
['Wiederhergestellte Warteschlange startet in 5 s (3 Jobs).', 'Restored queue starts in 5 s (3 jobs).'],
|
||||
['2 hinzugefügt', '2 added'],
|
||||
['1 bereits im Batch', '1 already in the batch'],
|
||||
['3 ohne gültigen Account', '3 without a valid account'],
|
||||
['Keine Jobs hinzugefügt', 'No jobs added'],
|
||||
['Keine startbaren Jobs ausgewählt (alle laufen schon oder sind fertig).', 'No startable jobs selected because all are already running or completed.'],
|
||||
['Hoster', 'Host'],
|
||||
['Versuch', 'Attempt'],
|
||||
['Diagnose', 'Diagnostics'],
|
||||
['Sitzungsbericht mit 1 Upload exportiert', 'Session report with 1 upload exported'],
|
||||
['Sitzungsbericht mit 4 Uploads exportiert', 'Session report with 4 uploads exported'],
|
||||
['Sitzungsbericht konnte nicht exportiert werden.', 'The session report could not be exported.'],
|
||||
['Warteschlange konnte vor dem Upload-Start nicht gespeichert werden', 'The queue could not be saved before starting the upload']
|
||||
];
|
||||
|
||||
for (const [german, english] of cases) {
|
||||
assert.equal(translateText(german, 'en'), english, german);
|
||||
assert.equal(translateText(english, 'de'), german, english);
|
||||
}
|
||||
for (const [german, english] of cases) assert.equal(translateText(german, 'en'), english, german);
|
||||
});
|
||||
|
||||
test('main-process user-facing copy contains no mojibake', () => {
|
||||
|
||||
+42
-152
@@ -1,106 +1,42 @@
|
||||
const { describe, it } = require('node:test');
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const {
|
||||
getEligibleImportHosters,
|
||||
inspectImportEntries,
|
||||
inspectReadableImportPath,
|
||||
summarizeImportPlan
|
||||
} = require('../lib/import-preflight');
|
||||
|
||||
describe('import preflight', () => {
|
||||
it('accounts for candidates, existing and repeated paths, filename filters, unreadable entries and accepted files', async () => {
|
||||
const sizes = new Map([
|
||||
['C:\\incoming\\duplicate-new.bin', 2 * 1024 * 1024],
|
||||
['C:\\incoming\\missing.bin', null],
|
||||
['C:\\incoming\\unreadable.bin', 'unreadable'],
|
||||
['C:\\incoming\\empty.bin', 0],
|
||||
['C:\\incoming\\accepted.xyz', 5 * 1024 * 1024]
|
||||
]);
|
||||
test('inspects duplicates, unavailable files, accepted files, and configured size-limit pairs', async () => {
|
||||
const { inspectImportEntries, summarizeImportPlan } = require('../lib/import-preflight');
|
||||
const inspection = await inspectImportEntries([
|
||||
'C:/queue/existing.bin',
|
||||
'C:/incoming/duplicate-new.bin',
|
||||
'C:\\incoming\\duplicate-new.bin',
|
||||
'C:/incoming/skip.sample.bin',
|
||||
'C:/incoming/missing.bin',
|
||||
'C:/incoming/unreadable.bin',
|
||||
'C:/incoming/empty.bin',
|
||||
'C:/incoming/accepted.xyz'
|
||||
{ path: 'C:\\queue\\duplicate.mkv', name: 'duplicate.mkv' },
|
||||
{ path: 'C:\\queue\\accepted.mkv', name: 'accepted.mkv' },
|
||||
{ path: 'C:\\queue\\empty.mkv', name: 'empty.mkv' },
|
||||
{ path: 'C:\\queue\\missing.mkv', name: 'missing.mkv' }
|
||||
], {
|
||||
existingPaths: ['C:\\QUEUE\\existing.bin'],
|
||||
filenameFilter: {
|
||||
enabled: true,
|
||||
action: 'exclude',
|
||||
conditions: [{ operator: 'contains', value: '.sample.' }]
|
||||
},
|
||||
existingPaths: ['C:\\queue\\duplicate.mkv'],
|
||||
inspectPath: async filePath => {
|
||||
const value = sizes.get(filePath);
|
||||
if (value === null) return { exists: false };
|
||||
if (value === 'unreadable') return { exists: true, readable: false, size: 10 };
|
||||
return { exists: true, readable: true, size: value };
|
||||
if (filePath.endsWith('accepted.mkv')) return { exists: true, readable: true, size: 2 * 1024 * 1024 };
|
||||
if (filePath.endsWith('empty.mkv')) return { exists: true, readable: true, size: 0 };
|
||||
return { exists: false };
|
||||
}
|
||||
});
|
||||
|
||||
assert.equal(inspection.candidateCount, 8);
|
||||
assert.equal(inspection.duplicateCount, 2);
|
||||
assert.equal(inspection.filteredCount, 1);
|
||||
assert.equal(inspection.unavailableCount, 3);
|
||||
assert.equal(inspection.acceptedCount, 2);
|
||||
assert.deepEqual(inspection.unavailable.map(entry => entry.reason).sort(), ['empty', 'missing', 'unreadable']);
|
||||
assert.deepEqual(inspection.accepted.map(entry => entry.name).sort(), ['accepted.xyz', 'duplicate-new.bin']);
|
||||
assert.equal(inspection.accepted.find(entry => entry.name === 'accepted.xyz').size, 5 * 1024 * 1024);
|
||||
});
|
||||
|
||||
it('counts jobs and only removes jobs blocked by configured host maximum sizes', () => {
|
||||
const summary = summarizeImportPlan({
|
||||
inspection: {
|
||||
candidateCount: 8,
|
||||
duplicateCount: 2,
|
||||
filteredCount: 1,
|
||||
unavailableCount: 3,
|
||||
accepted: [
|
||||
{ path: 'C:\\incoming\\small.bin', name: 'small.bin', size: 2 * 1024 * 1024 },
|
||||
{ path: 'C:\\incoming\\large.custom', name: 'large.custom', size: 5 * 1024 * 1024 }
|
||||
]
|
||||
},
|
||||
selectedHosters: ['unlimited.example', 'limited.example', 'unlimited.example'],
|
||||
const plan = summarizeImportPlan({
|
||||
inspection,
|
||||
selectedHosters: ['doodstream.com', 'voe.sx'],
|
||||
hosterSettings: {
|
||||
'unlimited.example': { maxSizeMb: 0 },
|
||||
'limited.example': { maxSizeMb: 3 },
|
||||
'unknown.example': { maxSizeMb: 1 }
|
||||
'doodstream.com': { maxSizeMb: 1 },
|
||||
'voe.sx': { maxSizeMb: 0 }
|
||||
}
|
||||
});
|
||||
|
||||
assert.deepEqual(summary, {
|
||||
candidateCount: 8,
|
||||
duplicateCount: 2,
|
||||
filteredCount: 1,
|
||||
unavailableCount: 3,
|
||||
acceptedCount: 2,
|
||||
targetCount: 2,
|
||||
jobCount: 3,
|
||||
sizeLimitedJobCount: 1
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the same configured size eligibility for summaries and queue admission', () => {
|
||||
const file = { path: 'C:\\incoming\\large.custom', name: 'large.custom', size: 5 * 1024 * 1024 };
|
||||
const selectedHosters = ['limited.example', 'unlimited.example', 'limited.example'];
|
||||
const hosterSettings = {
|
||||
'limited.example': { maxSizeMb: 3 },
|
||||
'unlimited.example': { maxSizeMb: 0 }
|
||||
};
|
||||
|
||||
assert.deepEqual(getEligibleImportHosters(file, selectedHosters, hosterSettings), ['unlimited.example']);
|
||||
assert.deepEqual(summarizeImportPlan({
|
||||
inspection: { candidateCount: 1, accepted: [file] },
|
||||
selectedHosters,
|
||||
hosterSettings
|
||||
}), {
|
||||
candidateCount: 1,
|
||||
duplicateCount: 0,
|
||||
filteredCount: 0,
|
||||
unavailableCount: 0,
|
||||
assert.deepEqual({
|
||||
candidates: inspection.candidateCount,
|
||||
duplicates: inspection.duplicateCount,
|
||||
unavailable: inspection.unavailableCount,
|
||||
accepted: inspection.acceptedCount
|
||||
}, { candidates: 4, duplicates: 1, unavailable: 2, accepted: 1 });
|
||||
assert.deepEqual(plan, {
|
||||
candidateCount: 4,
|
||||
duplicateCount: 1,
|
||||
unavailableCount: 2,
|
||||
acceptedCount: 1,
|
||||
targetCount: 2,
|
||||
jobCount: 1,
|
||||
@@ -108,65 +44,19 @@ describe('import preflight', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('deduplicates Windows drive and UNC namespace aliases canonically', async () => {
|
||||
const inspectedPaths = [];
|
||||
const inspection = await inspectImportEntries([
|
||||
'\\\\?\\C:\\incoming\\same.bin',
|
||||
'C:\\incoming\\same.bin',
|
||||
'\\\\?\\UNC\\server\\share\\same.bin',
|
||||
'\\\\server\\share\\same.bin'
|
||||
], {
|
||||
caseInsensitive: true,
|
||||
inspectPath: async filePath => {
|
||||
inspectedPaths.push(filePath);
|
||||
return { exists: true, readable: true, size: 1 };
|
||||
}
|
||||
});
|
||||
test('connects the import preflight through the main process, preload, renderer, and hoster dialog', () => {
|
||||
const root = path.join(__dirname, '..');
|
||||
const main = fs.readFileSync(path.join(root, 'main.js'), 'utf8');
|
||||
const preload = fs.readFileSync(path.join(root, 'preload.js'), 'utf8');
|
||||
const renderer = fs.readFileSync(path.join(root, 'renderer', 'app.js'), 'utf8');
|
||||
const html = fs.readFileSync(path.join(root, 'renderer', 'index.html'), 'utf8');
|
||||
const css = fs.readFileSync(path.join(root, 'renderer', 'styles.css'), 'utf8');
|
||||
|
||||
assert.equal(inspection.acceptedCount, 2);
|
||||
assert.equal(inspection.duplicateCount, 2);
|
||||
assert.deepEqual(inspectedPaths, ['C:\\incoming\\same.bin', '\\\\server\\share\\same.bin']);
|
||||
});
|
||||
|
||||
it('inspects type and size through one opened read handle and closes it', async () => {
|
||||
const calls = [];
|
||||
const fileHandle = {
|
||||
stat: async () => {
|
||||
calls.push('stat');
|
||||
return { isFile: () => true, size: 42 };
|
||||
},
|
||||
close: async () => {
|
||||
calls.push('close');
|
||||
}
|
||||
};
|
||||
|
||||
const result = await inspectReadableImportPath('C:\\incoming\\readable.bin', async (filePath, flags) => {
|
||||
calls.push(['open', filePath, flags]);
|
||||
return fileHandle;
|
||||
});
|
||||
|
||||
assert.deepEqual(result, { exists: true, readable: true, size: 42 });
|
||||
assert.deepEqual(calls, [['open', 'C:\\incoming\\readable.bin', 'r'], 'stat', 'close']);
|
||||
});
|
||||
|
||||
it('limits concurrent file inspections', async () => {
|
||||
let active = 0;
|
||||
let maximumActive = 0;
|
||||
const inspection = await inspectImportEntries(
|
||||
Array.from({ length: 12 }, (_, index) => `C:/incoming/file-${index}.bin`),
|
||||
{
|
||||
concurrency: 3,
|
||||
inspectPath: async () => {
|
||||
active++;
|
||||
maximumActive = Math.max(maximumActive, active);
|
||||
await new Promise(resolve => setTimeout(resolve, 5));
|
||||
active--;
|
||||
return { exists: true, readable: true, size: 1 };
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(inspection.acceptedCount, 12);
|
||||
assert.equal(maximumActive, 3);
|
||||
});
|
||||
assert.match(main, /ipcMain\.handle\('inspect-import-files'/);
|
||||
assert.match(preload, /inspectImportFiles/);
|
||||
assert.match(renderer, /coordinateImportEntries/);
|
||||
assert.match(renderer, /isImportPairEligible/);
|
||||
assert.match(renderer, /toLocaleString\(getUiLocale\(\)\)/);
|
||||
assert.match(html, /id="importPlanSummary"/);
|
||||
assert.match(css, /hoster-modal-list \+ \.import-plan-summary \+ \.modal-hint/);
|
||||
});
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
function loadRemoteInputHandler(sendInputEvent, debugLog = () => {}) {
|
||||
const source = fs.readFileSync(path.join(__dirname, '..', 'main.js'), 'utf8');
|
||||
const handlerStart = source.indexOf("ipcMain.on('remote:input-event'");
|
||||
const handlerEnd = source.indexOf('\nfunction buildModifiers', handlerStart);
|
||||
const modifiersEnd = source.indexOf('\n// IPC: Get capture source ID', handlerEnd);
|
||||
assert.notEqual(handlerStart, -1);
|
||||
assert.notEqual(handlerEnd, -1);
|
||||
assert.notEqual(modifiersEnd, -1);
|
||||
|
||||
let inputHandler;
|
||||
const mainWindow = {
|
||||
isDestroyed: () => false,
|
||||
getBounds: () => ({ x: 0, y: 0, width: 1100, height: 750 }),
|
||||
getContentBounds: () => ({ x: 7, y: 30, width: 1086, height: 713 }),
|
||||
webContents: { sendInputEvent }
|
||||
};
|
||||
const context = vm.createContext({
|
||||
ipcMain: {
|
||||
on(channel, handler) {
|
||||
if (channel === 'remote:input-event') inputHandler = handler;
|
||||
}
|
||||
},
|
||||
mainWindow,
|
||||
configStore: {
|
||||
load: () => ({ globalSettings: { remote: { allowInput: true } } })
|
||||
},
|
||||
debugLog,
|
||||
process: { platform: 'win32' },
|
||||
isFinite
|
||||
});
|
||||
|
||||
vm.runInContext(source.slice(handlerStart, handlerEnd) + source.slice(handlerEnd, modifiersEnd), context);
|
||||
assert.equal(typeof inputHandler, 'function');
|
||||
return inputHandler;
|
||||
}
|
||||
|
||||
test('authenticated keyboard input without a string key is discarded without throwing', () => {
|
||||
const sent = [];
|
||||
const logs = [];
|
||||
const handler = loadRemoteInputHandler(event => sent.push(event), (...args) => logs.push(args));
|
||||
const invalidPayloads = [
|
||||
{ role: 'admin', type: 'keydown' },
|
||||
{ role: 'admin', type: 'keydown', key: null },
|
||||
{ role: 'admin', type: 'keydown', key: 1 },
|
||||
{ role: 'admin', type: 'keydown', key: '' },
|
||||
{ role: 'admin', type: 'keyup' },
|
||||
{ role: 'admin', type: 'keyup', key: {} }
|
||||
];
|
||||
|
||||
for (const payload of invalidPayloads) {
|
||||
assert.doesNotThrow(() => handler({}, payload));
|
||||
}
|
||||
|
||||
assert.deepEqual(sent, []);
|
||||
assert.deepEqual(logs, []);
|
||||
});
|
||||
|
||||
test('authenticated keyboard input with a string key keeps normal keydown and keyup behavior', () => {
|
||||
const sent = [];
|
||||
const handler = loadRemoteInputHandler(event => sent.push(event));
|
||||
|
||||
handler({}, { role: 'admin', type: 'keydown', key: 'a', ctrl: true });
|
||||
handler({}, { role: 'admin', type: 'keyup', key: 'a', ctrl: true });
|
||||
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(sent)), [
|
||||
{ type: 'keyDown', keyCode: 'a', modifiers: ['control'] },
|
||||
{ type: 'char', keyCode: 'a', modifiers: ['control'] },
|
||||
{ type: 'keyUp', keyCode: 'a', modifiers: ['control'] }
|
||||
]);
|
||||
});
|
||||
@@ -1,7 +1,6 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const Module = require('node:module');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const packageJson = require('../package.json');
|
||||
|
||||
@@ -11,60 +10,6 @@ test('packages every Electron preload referenced by the main process', () => {
|
||||
assert.equal(packageJson.build.win.signAndEditExecutable, false);
|
||||
});
|
||||
|
||||
test('both release repositories run the real Electron UI audit in CI', () => {
|
||||
const forgeWorkflow = [['.gi', 'tea'].join(''), 'workflows', 'ci.yml'].join('/');
|
||||
for (const relativePath of ['.github/workflows/ci.yml', forgeWorkflow]) {
|
||||
const workflow = fs.readFileSync(path.join(__dirname, '..', relativePath), 'utf8');
|
||||
assert.match(workflow, /RUN_UI_SMOKE:\s*['"]1['"]/);
|
||||
assert.match(workflow, /run:\s*npm run verify/);
|
||||
}
|
||||
});
|
||||
|
||||
test('packaged identity is exact while public artifact routing remains stable', () => {
|
||||
assert.equal(packageJson.build.productName, 'Multi Hoster Uploader');
|
||||
assert.equal(packageJson.build.win.executableName, 'Multi Hoster Uploader');
|
||||
assert.equal(packageJson.build.nsis.artifactName, 'Multi-Hoster-Upload Setup ${version}.${ext}');
|
||||
assert.equal(packageJson.build.portable.artifactName, 'Multi-Hoster-Upload ${version}.${ext}');
|
||||
|
||||
const source = fs.readFileSync(path.join(__dirname, '..', 'main.js'), 'utf8');
|
||||
assert.match(source, /app\.setPath\('userData', path\.join\(app\.getPath\('appData'\), 'multi-hoster-uploader'\)\)/);
|
||||
assert.match(source, /app\.setName\('Multi Hoster Uploader'\)/);
|
||||
assert.match(source, /app\.setAppUserModelId\('com\.multihoster\.uploader'\)/);
|
||||
assert.doesNotMatch(source, /updateTrayTooltip\('Multi-Hoster-Upload'\)/);
|
||||
});
|
||||
|
||||
test('floating drop target resolves native paths through Electron webUtils', () => {
|
||||
let exposedApi = null;
|
||||
const nativeFile = { name: 'fixture.mkv' };
|
||||
const electronMock = {
|
||||
contextBridge: {
|
||||
exposeInMainWorld: (_name, api) => { exposedApi = api; }
|
||||
},
|
||||
ipcRenderer: {
|
||||
send: () => {}
|
||||
},
|
||||
webUtils: {
|
||||
getPathForFile: file => file === nativeFile ? 'C:\\fixtures\\fixture.mkv' : ''
|
||||
}
|
||||
};
|
||||
const originalLoad = Module._load;
|
||||
const preloadPath = require.resolve('../preload-drop-target');
|
||||
delete require.cache[preloadPath];
|
||||
Module._load = function (request, parent, isMain) {
|
||||
if (request === 'electron') return electronMock;
|
||||
return originalLoad.call(this, request, parent, isMain);
|
||||
};
|
||||
try {
|
||||
require(preloadPath);
|
||||
} finally {
|
||||
Module._load = originalLoad;
|
||||
delete require.cache[preloadPath];
|
||||
}
|
||||
|
||||
assert.equal(typeof exposedApi.getPathForFile, 'function');
|
||||
assert.equal(exposedApi.getPathForFile(nativeFile), 'C:\\fixtures\\fixture.mkv');
|
||||
});
|
||||
|
||||
test('afterPack brands the executable metadata shown by Windows', async () => {
|
||||
let editCall = null;
|
||||
const originalLoad = Module._load;
|
||||
@@ -80,45 +25,25 @@ test('afterPack brands the executable metadata shown by Windows', async () => {
|
||||
const afterPack = require(afterPackPath);
|
||||
await afterPack({
|
||||
appOutDir: 'C:\\release',
|
||||
packager: { appInfo: { productFilename: 'Multi Hoster Uploader', version: '9.8.7' } }
|
||||
packager: { appInfo: { productFilename: 'Multi-Hoster-Upload', version: '9.8.7' } }
|
||||
});
|
||||
} finally {
|
||||
Module._load = originalLoad;
|
||||
delete require.cache[afterPackPath];
|
||||
}
|
||||
|
||||
assert.equal(editCall.exePath, path.join('C:\\release', 'Multi Hoster Uploader.exe'));
|
||||
assert.equal(editCall.exePath, path.join('C:\\release', 'Multi-Hoster-Upload.exe'));
|
||||
assert.equal(editCall.options['file-version'], '9.8.7');
|
||||
assert.equal(editCall.options['product-version'], '9.8.7');
|
||||
assert.deepEqual(editCall.options['version-string'], {
|
||||
CompanyName: 'Sucukdeluxe',
|
||||
FileDescription: 'Multi Hoster Uploader',
|
||||
InternalName: 'Multi Hoster Uploader',
|
||||
OriginalFilename: 'Multi Hoster Uploader.exe',
|
||||
InternalName: 'Multi-Hoster-Upload',
|
||||
OriginalFilename: 'Multi-Hoster-Upload.exe',
|
||||
ProductName: 'Multi Hoster Uploader'
|
||||
});
|
||||
});
|
||||
|
||||
test('afterPack fails the build when executable branding fails', async () => {
|
||||
const originalLoad = Module._load;
|
||||
const afterPackPath = require.resolve('../scripts/afterPack.cjs');
|
||||
delete require.cache[afterPackPath];
|
||||
Module._load = function (request, parent, isMain) {
|
||||
if (request === 'rcedit') return async () => { throw new Error('branding failed'); };
|
||||
return originalLoad.call(this, request, parent, isMain);
|
||||
};
|
||||
try {
|
||||
const afterPack = require(afterPackPath);
|
||||
await assert.rejects(afterPack({
|
||||
appOutDir: 'C:\\release',
|
||||
packager: { appInfo: { productFilename: 'Multi Hoster Uploader', version: '9.8.7' } }
|
||||
}), /branding failed/);
|
||||
} finally {
|
||||
Module._load = originalLoad;
|
||||
delete require.cache[afterPackPath];
|
||||
}
|
||||
});
|
||||
|
||||
test('close readiness is signaled only after the renderer explicitly finishes initialization', () => {
|
||||
const listeners = new Map();
|
||||
const sent = [];
|
||||
@@ -159,10 +84,8 @@ test('close readiness is signaled only after the renderer explicitly finishes in
|
||||
assert.deepEqual(sent, [['app:close-preparation-started', 7]]);
|
||||
|
||||
exposedApi.signalCloseHandshakeReady();
|
||||
exposedApi.signalRendererInitializationFailed({ message: 'init failed' });
|
||||
assert.deepEqual(sent, [
|
||||
['app:close-preparation-started', 7],
|
||||
['app:close-handshake-ready'],
|
||||
['app:renderer-initialization-failed', { message: 'init failed' }]
|
||||
['app:close-handshake-ready']
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -6,6 +6,19 @@ const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const rootFiles = [
|
||||
'.gitignore',
|
||||
'README.md',
|
||||
'SECURITY.md',
|
||||
'eslint.config.mjs',
|
||||
'main.js',
|
||||
'package-lock.json',
|
||||
'package.json',
|
||||
'preload-drop-target.js',
|
||||
'preload.js'
|
||||
];
|
||||
const directoryRoots = [`.${['gi', 'tea'].join('')}`, `.${['git', 'hub'].join('')}`, 'assets', 'docs', 'lib', 'renderer', 'services/backup-api', 'tests'];
|
||||
const scriptFiles = ['scripts/afterPack.cjs', 'scripts/dev-runner.cjs', 'scripts/release-plan.mjs', 'scripts/verify-public-release.mjs'];
|
||||
const screenshotFiles = [
|
||||
'assets/product-overview.png',
|
||||
'docs/screenshots/upload-workspace.png',
|
||||
@@ -15,19 +28,42 @@ const screenshotFiles = [
|
||||
];
|
||||
const currentVersion = require('../package.json').version;
|
||||
|
||||
function copyDirectory(source, destination) {
|
||||
fs.mkdirSync(destination, { recursive: true });
|
||||
for (const entry of fs.readdirSync(source, { withFileTypes: true })) {
|
||||
if (/^_ui-inject\..+\.tmp\.js$/.test(entry.name)) continue;
|
||||
const sourcePath = path.join(source, entry.name);
|
||||
const destinationPath = path.join(destination, entry.name);
|
||||
if (entry.isDirectory()) copyDirectory(sourcePath, destinationPath);
|
||||
else if (entry.isFile()) fs.copyFileSync(sourcePath, destinationPath);
|
||||
}
|
||||
}
|
||||
|
||||
function copyDocumentationScreenshots(stage) {
|
||||
for (const relativePath of screenshotFiles.slice(1)) {
|
||||
const destination = path.join(stage, relativePath);
|
||||
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
||||
fs.copyFileSync(path.join(root, screenshotFiles[0]), destination);
|
||||
}
|
||||
}
|
||||
|
||||
function createStage() {
|
||||
const stage = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-public-verifier-'));
|
||||
const tracked = spawnSync('git', ['ls-files', '-z'], {
|
||||
cwd: root,
|
||||
encoding: 'buffer'
|
||||
});
|
||||
assert.equal(tracked.status, 0, tracked.stderr?.toString('utf8'));
|
||||
const trackedFiles = tracked.stdout.toString('utf8').split('\0').filter(Boolean);
|
||||
for (const relativePath of trackedFiles) {
|
||||
for (const relativePath of rootFiles) {
|
||||
const destination = path.join(stage, relativePath);
|
||||
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
||||
fs.copyFileSync(path.join(root, relativePath), destination);
|
||||
}
|
||||
for (const relativePath of directoryRoots) {
|
||||
if (relativePath === 'docs') copyDocumentationScreenshots(stage);
|
||||
else copyDirectory(path.join(root, relativePath), path.join(stage, relativePath));
|
||||
}
|
||||
for (const relativePath of scriptFiles) {
|
||||
const destination = path.join(stage, relativePath);
|
||||
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
||||
fs.copyFileSync(path.join(root, relativePath), destination);
|
||||
}
|
||||
fs.rmSync(path.join(stage, 'assets', 'product-overview.png'), { force: true });
|
||||
return stage;
|
||||
}
|
||||
|
||||
@@ -41,26 +77,6 @@ function verify(stage, version = currentVersion, sourceOnly = true) {
|
||||
});
|
||||
}
|
||||
|
||||
function verifyTracked(stage) {
|
||||
return spawnSync(process.execPath, [
|
||||
'scripts/verify-public-release.mjs',
|
||||
'--source-only',
|
||||
'--tracked',
|
||||
'--package-version'
|
||||
], {
|
||||
cwd: stage,
|
||||
encoding: 'utf8'
|
||||
});
|
||||
}
|
||||
|
||||
function git(stage, args) {
|
||||
const result = spawnSync('git', args, {
|
||||
cwd: stage,
|
||||
encoding: 'utf8'
|
||||
});
|
||||
assert.equal(result.status, 0, result.error?.message || result.stderr || result.stdout);
|
||||
}
|
||||
|
||||
test('public release verifier accepts only the exact source manifest and target version', (t) => {
|
||||
const stage = createStage();
|
||||
t.after(() => fs.rmSync(stage, { recursive: true, force: true }));
|
||||
@@ -86,102 +102,6 @@ test('public release verifier accepts only the exact source manifest and target
|
||||
assert.match(wrongVersion.stderr, /package\.json\tpackage-version-target/);
|
||||
});
|
||||
|
||||
test('public source verification runs against the actual checkout at its package version', () => {
|
||||
const executable = process.platform === 'win32' ? process.env.ComSpec : 'npm';
|
||||
const args = process.platform === 'win32'
|
||||
? ['/d', '/s', '/c', 'npm run --silent verify:public-source']
|
||||
: ['run', '--silent', 'verify:public-source'];
|
||||
const result = spawnSync(executable, args, {
|
||||
cwd: root,
|
||||
encoding: 'utf8'
|
||||
});
|
||||
|
||||
assert.equal(result.status, 0, result.error?.message || result.stderr || result.stdout);
|
||||
assert.match(result.stdout, new RegExp(`version=${currentVersion.replaceAll('.', '\\.')}\\b`));
|
||||
assert.match(result.stdout, /layout=exact/);
|
||||
});
|
||||
|
||||
test('public release verifier rejects known credential patterns in approved text files', async (t) => {
|
||||
const stage = createStage();
|
||||
t.after(() => fs.rmSync(stage, { recursive: true, force: true }));
|
||||
const target = path.join(stage, 'README.md');
|
||||
const original = fs.readFileSync(target, 'utf8');
|
||||
const assignment = (name, value) => `${name} = "${value}"`;
|
||||
const fixtures = [
|
||||
['access token', ['gh', 'p_'].join('') + 'SYNTHETICNOTREAL'.padEnd(36, '0')],
|
||||
['fine-grained access token', ['github', 'pat'].join('_') + '_' + 'A'.repeat(82)],
|
||||
['forge token assignment', assignment(['access', 'token'].join('_'), 'a'.repeat(40))],
|
||||
['npm token', ['npm', ''].join('_') + 'A'.repeat(36)],
|
||||
['Slack token', ['xox', 'b'].join('') + '-' + ['1'.repeat(12), '2'.repeat(12), 'A'.repeat(24)].join('-')],
|
||||
['JWT', ['eyJ', 'hbGciOiJIUzI1NiJ9'].join('') + '.' + ['eyJ', 'zdWIiOiIxMjM0NTY3ODkwIn0'].join('') + '.signaturevalue'],
|
||||
['Bearer token', ['Bear', 'er '].join('') + 'A'.repeat(32)],
|
||||
['password assignment', assignment(['pass', 'word'].join(''), 'CorrectHorseBatteryStaple')],
|
||||
['API key assignment', assignment(['api', 'key'].join('_'), 'A1b2C3d4E5f6G7h8')],
|
||||
['unquoted password assignment', ['pass', 'word'].join('') + ': CorrectHorseBatteryStaple'],
|
||||
['unquoted API key assignment', ['api', 'key'].join('_') + ': abcdefghijklmnop'],
|
||||
['cookie assignment', assignment(['cook', 'ie'].join(''), 'session-value-123456789')],
|
||||
['session assignment', assignment(['sess', 'ion'].join(''), 'session-value-123456789')],
|
||||
['private key', ['-----BEGIN ', 'PRIVATE KEY-----'].join('') + '\nSYNTHETICNOTAKEY\n-----END PRIVATE KEY-----'],
|
||||
['secret', ['aws', '_secret_access_key'].join('') + ' = ' + 'SYNTHETICNOTREALSECRET'.padEnd(40, '0')]
|
||||
];
|
||||
|
||||
for (const [name, fixture] of fixtures) {
|
||||
await t.test(name, () => {
|
||||
fs.writeFileSync(target, `${original}\n${fixture}\n`);
|
||||
const result = verify(stage);
|
||||
assert.equal(result.status, 1, result.stderr);
|
||||
assert.match(result.stderr, /README\.md\tcredential-pattern/);
|
||||
fs.writeFileSync(target, original);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('public release verifier rejects realistic credentials inside approved test files', (t) => {
|
||||
const stage = createStage();
|
||||
t.after(() => fs.rmSync(stage, { recursive: true, force: true }));
|
||||
const target = path.join(stage, 'tests', 'account-status.test.js');
|
||||
const original = fs.readFileSync(target, 'utf8');
|
||||
const fixture = ['pass', 'word'].join('') + ': CorrectHorseBatteryStaple';
|
||||
fs.writeFileSync(target, `${original}\n${fixture}\n`);
|
||||
|
||||
const result = verify(stage);
|
||||
assert.equal(result.status, 1, result.stderr);
|
||||
assert.match(result.stderr, /tests\/account-status\.test\.js\tcredential-pattern/);
|
||||
});
|
||||
|
||||
test('--tracked validates tracked source and every untracked Electron build input', (t) => {
|
||||
const stage = createStage();
|
||||
t.after(() => fs.rmSync(stage, { recursive: true, force: true }));
|
||||
git(stage, ['init', '--quiet']);
|
||||
git(stage, ['add', '--all']);
|
||||
|
||||
fs.writeFileSync(path.join(stage, 'tests', 'unexpected.json'), '{}');
|
||||
fs.writeFileSync(path.join(stage, 'tests', 'untracked.json'), '{}');
|
||||
fs.writeFileSync(path.join(stage, 'renderer', 'private-config.js'), 'module.exports = {};');
|
||||
git(stage, ['add', '--', 'tests/unexpected.json']);
|
||||
const result = verifyTracked(stage);
|
||||
assert.equal(result.status, 1, result.stderr);
|
||||
assert.match(result.stderr, /tests\/unexpected\.json\tsource-layout-allowlist/);
|
||||
assert.match(result.stderr, /renderer\/private-config\.js\tsource-layout-allowlist/);
|
||||
assert.doesNotMatch(result.stderr, /tests\/untracked\.json/);
|
||||
});
|
||||
|
||||
test('public release verifier detects private updater endpoints hidden by string concatenation', (t) => {
|
||||
const stage = createStage();
|
||||
t.after(() => fs.rmSync(stage, { recursive: true, force: true }));
|
||||
const target = path.join(stage, 'README.md');
|
||||
const original = fs.readFileSync(target, 'utf8');
|
||||
const hiddenEndpoint = String.fromCharCode(
|
||||
99, 111, 110, 115, 116, 32, 104, 111, 115, 116, 32, 61, 32, 39, 103, 105, 116, 39, 32, 43, 32, 39, 46,
|
||||
50, 52, 45, 39, 32, 43, 32, 39, 109, 117, 115, 105, 99, 46, 100, 101, 39, 59
|
||||
);
|
||||
fs.writeFileSync(target, `${original}\n${hiddenEndpoint}\n`);
|
||||
|
||||
const result = verify(stage);
|
||||
assert.equal(result.status, 1, result.stderr);
|
||||
assert.match(result.stderr, /README\.md\tupdater-endpoint-scope/);
|
||||
});
|
||||
|
||||
test('public release verifier requires and validates every approved screenshot', (t) => {
|
||||
const stage = createStage();
|
||||
t.after(() => fs.rmSync(stage, { recursive: true, force: true }));
|
||||
|
||||
@@ -73,32 +73,6 @@ test('empty/missing inputs do not throw', () => {
|
||||
|
||||
const T = (s) => Date.parse(s.replace(' ', 'T'));
|
||||
|
||||
test('history fallback keeps a serialized successful terminal result through log deduplication', () => {
|
||||
const result = {
|
||||
download_url: 'https://voe.sx/e/exact-link',
|
||||
embed_url: 'https://voe.sx/e/exact-embed',
|
||||
file_code: 'exact-code'
|
||||
};
|
||||
const restored = JSON.parse(JSON.stringify({
|
||||
savedAt: T('2026-08-13 12:00:00'),
|
||||
queueJobs: [{
|
||||
id: 'history-fallback-done',
|
||||
file: 'C:/dl/episode.mkv',
|
||||
fileName: 'episode.mkv',
|
||||
hoster: 'voe.sx',
|
||||
status: 'done',
|
||||
result
|
||||
}]
|
||||
}));
|
||||
const log = [{ fileName: 'episode.mkv', hoster: 'voe.sx', ts: T('2026-08-13 12:00:05') }];
|
||||
|
||||
const { kept, removed } = partitionRestoredJobsByLog(restored.queueJobs, log, restored.savedAt);
|
||||
|
||||
assert.deepEqual(removed, []);
|
||||
assert.deepEqual(kept, restored.queueJobs);
|
||||
assert.deepEqual(kept[0].result, result);
|
||||
});
|
||||
|
||||
test('ts-gate: preview job uploaded AFTER the snapshot is dropped (the ghost bug)', () => {
|
||||
const jobs = [job('preview', 'a.mkv', 'voe.sx')];
|
||||
const log = [{ fileName: 'a.mkv', hoster: 'voe.sx', ts: T('2026-06-19 12:00:05') }];
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('node:path');
|
||||
const { pathToFileURL } = require('node:url');
|
||||
|
||||
const releasePlanUrl = pathToFileURL(path.resolve(__dirname, '../scripts/release-plan.mjs')).href;
|
||||
|
||||
test('release planning rejects omitted or blank English release notes', async () => {
|
||||
const { parseReleaseArgs } = await import(releasePlanUrl);
|
||||
|
||||
assert.throws(
|
||||
() => parseReleaseArgs(['2.1.20', '--transport-tag', 'v2.1.20']),
|
||||
/English release notes are required/
|
||||
);
|
||||
assert.throws(
|
||||
() => parseReleaseArgs(['2.1.20', '--transport-tag', 'v2.1.20', ' ']),
|
||||
/English release notes are required/
|
||||
);
|
||||
});
|
||||
|
||||
test('release planning preserves dual-host asset names with English release notes', async () => {
|
||||
const { createReleasePlan, parseReleaseArgs } = await import(releasePlanUrl);
|
||||
const plan = createReleasePlan(parseReleaseArgs([
|
||||
'2.1.20',
|
||||
'--transport-tag',
|
||||
'v2.1.20',
|
||||
'Security hardening and reliability fixes.'
|
||||
]));
|
||||
|
||||
assert.equal(plan.releaseBody, 'Security hardening and reliability fixes.');
|
||||
assert.deepEqual(plan.expectedArtifacts, [
|
||||
'Multi-Hoster-Upload Setup 2.1.20.exe',
|
||||
'Multi-Hoster-Upload 2.1.20.exe',
|
||||
'Multi-Hoster-Upload Setup 2.1.20.exe.blockmap',
|
||||
'latest.yml'
|
||||
]);
|
||||
assert.deepEqual(plan.githubExpectedArtifacts, [
|
||||
'Multi-Hoster-Upload.Setup.2.1.20.exe',
|
||||
'Multi-Hoster-Upload.2.1.20.exe',
|
||||
'Multi-Hoster-Upload.Setup.2.1.20.exe.blockmap',
|
||||
'latest.yml'
|
||||
]);
|
||||
});
|
||||
@@ -1,7 +1,5 @@
|
||||
const { describe, it, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
// Test the module can be required and has the expected API
|
||||
describe('RemoteServer', () => {
|
||||
@@ -41,54 +39,4 @@ describe('RemoteServer', () => {
|
||||
assert.strictEqual(server.getClientCount(), 0);
|
||||
server.stop();
|
||||
});
|
||||
|
||||
it('binds to loopback when no host is supplied', async () => {
|
||||
const RemoteServer = require('../lib/remote-server');
|
||||
const server = new RemoteServer();
|
||||
try {
|
||||
await server.start({
|
||||
port: 0,
|
||||
token: 'test-token-123',
|
||||
onSignalingToCapture: () => {},
|
||||
onCreateCaptureWindow: () => {},
|
||||
onDestroyCaptureWindow: () => {}
|
||||
});
|
||||
assert.strictEqual(server._wss.address().address, '127.0.0.1');
|
||||
} finally {
|
||||
server.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the application remote-control listener on loopback', () => {
|
||||
const source = fs.readFileSync(path.join(__dirname, '..', 'main.js'), 'utf8');
|
||||
const start = source.indexOf('async function startRemoteServer()');
|
||||
const end = source.indexOf("ipcMain.on('remote:signaling-from-capture'", start);
|
||||
assert.notStrictEqual(start, -1);
|
||||
assert.notStrictEqual(end, -1);
|
||||
assert.match(source.slice(start, end), /host:\s*'127\.0\.0\.1'/);
|
||||
});
|
||||
|
||||
it('rejects non-object JSON before authentication without throwing', () => {
|
||||
const { EventEmitter } = require('node:events');
|
||||
const RemoteServer = require('../lib/remote-server');
|
||||
const server = new RemoteServer();
|
||||
const socket = new EventEmitter();
|
||||
socket.close = (code) => {
|
||||
socket.closeCode = code;
|
||||
};
|
||||
socket.send = () => {};
|
||||
server._config = {
|
||||
token: 'test-token-123',
|
||||
allowlist: [],
|
||||
diagnosticMode: false,
|
||||
onCreateCaptureWindow: () => {},
|
||||
onDestroyCaptureWindow: () => {},
|
||||
onSignalingToCapture: () => {}
|
||||
};
|
||||
|
||||
server._handleConnection(socket, { socket: { remoteAddress: '127.0.0.1' } });
|
||||
assert.doesNotThrow(() => socket.emit('message', Buffer.from('null')));
|
||||
assert.strictEqual(socket.closeCode, 4002);
|
||||
socket.emit('close');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,13 +48,13 @@ test('prepareGroups creates one Windows manifest with a stable token and immutab
|
||||
token: 'cleanup-1',
|
||||
file: 'C:\\Uploads\\Movie.MKV',
|
||||
requiredHosters: ['doodstream.com', 'voe.sx', 'vidmoly.me', 'byse.sx'],
|
||||
confirmedHosters: [],
|
||||
completedHosters: ['doodstream.com', 'voe.sx'],
|
||||
fingerprint: null,
|
||||
jobs: [
|
||||
{ jobId: 'job-doodstream', hoster: 'doodstream.com', status: 'done', currentRound: false },
|
||||
{ jobId: 'job-voe', hoster: 'voe.sx', status: 'done', currentRound: false },
|
||||
{ jobId: 'job-vidmoly', hoster: 'vidmoly.me', status: 'error', currentRound: true },
|
||||
{ jobId: 'job-byse', hoster: 'byse.sx', status: 'preview', currentRound: false }
|
||||
{ jobId: 'job-doodstream', hoster: 'doodstream.com', status: 'done' },
|
||||
{ jobId: 'job-voe', hoster: 'voe.sx', status: 'done' },
|
||||
{ jobId: 'job-vidmoly', hoster: 'vidmoly.me', status: 'error' },
|
||||
{ jobId: 'job-byse', hoster: 'byse.sx', status: 'preview' }
|
||||
]
|
||||
});
|
||||
assert.deepEqual(queueJobs.map((job) => job.sourceCleanupToken), [
|
||||
@@ -74,13 +74,11 @@ test('prepareGroups creates one Windows manifest with a stable token and immutab
|
||||
assert.doesNotThrow(() => JSON.stringify(prepared.groups));
|
||||
});
|
||||
|
||||
test('prepareGroups reuses confirmed metadata across a partial retry', () => {
|
||||
test('prepareGroups reuses persisted metadata across a partial retry', () => {
|
||||
const queueJobs = queueFixture();
|
||||
policy.prepareGroups(queueJobs, queueJobs, () => 'cleanup-1', 'win32');
|
||||
for (const job of queueJobs) {
|
||||
job.sourceCleanupMetadataVersion = 2;
|
||||
job.sourceCleanupConfirmedHosters = ['doodstream.com', 'voe.sx'];
|
||||
}
|
||||
queueJobs[0].sourceCleanupCompletedHosters = ['doodstream.com', 'voe.sx'];
|
||||
queueJobs[1].sourceCleanupCompletedHosters = ['doodstream.com', 'voe.sx'];
|
||||
queueJobs[0].status = 'preview';
|
||||
queueJobs[1].status = 'preview';
|
||||
|
||||
@@ -95,7 +93,7 @@ test('prepareGroups reuses confirmed metadata across a partial retry', () => {
|
||||
'vidmoly.me',
|
||||
'byse.sx'
|
||||
]);
|
||||
assert.deepEqual(prepared.groups[0].confirmedHosters, ['doodstream.com', 'voe.sx']);
|
||||
assert.deepEqual(prepared.groups[0].completedHosters, ['doodstream.com', 'voe.sx']);
|
||||
assert.equal(prepared.groups[0].jobs.find((job) => job.hoster === 'vidmoly.me').status, 'error');
|
||||
});
|
||||
|
||||
@@ -151,7 +149,7 @@ test('removing a failed job never relaxes the stored requirements', () => {
|
||||
|
||||
test('removeRequirement drops only an explicitly discarded unstarted hoster', () => {
|
||||
const queueJobs = queueFixture();
|
||||
policy.prepareGroups(queueJobs, queueJobs.slice(0, 3), () => 'cleanup-1', 'win32');
|
||||
policy.prepareGroups(queueJobs, queueJobs, () => 'cleanup-1', 'win32');
|
||||
|
||||
const touchedJobs = policy.removeRequirement(queueJobs, queueJobs[3], 'win32');
|
||||
|
||||
@@ -165,7 +163,7 @@ test('removeRequirement drops only an explicitly discarded unstarted hoster', ()
|
||||
}
|
||||
});
|
||||
|
||||
test('markCompleted keeps successful hosters provisional for the current round', () => {
|
||||
test('markCompleted preserves successful hosters for later retries', () => {
|
||||
const queueJobs = queueFixture();
|
||||
policy.prepareGroups(queueJobs, queueJobs, () => 'cleanup-1', 'win32');
|
||||
|
||||
@@ -173,108 +171,10 @@ test('markCompleted keeps successful hosters provisional for the current round',
|
||||
policy.markCompleted(queueJobs, queueJobs[2], 'win32');
|
||||
|
||||
for (const job of queueJobs) {
|
||||
assert.deepEqual(job.sourceCleanupConfirmedHosters, []);
|
||||
assert.deepEqual(job.sourceCleanupProvisionalHosters, ['vidmoly.me']);
|
||||
}
|
||||
});
|
||||
|
||||
test('a started cleanup requirement survives abort finalization persistence and restart', async () => {
|
||||
const queueJobs = [
|
||||
{ id: 'job-complete', file: 'C:\\Uploads\\Protected.mkv', hoster: 'doodstream.com', status: 'preview' },
|
||||
{ id: 'job-started', file: 'C:\\Uploads\\Protected.mkv', hoster: 'voe.sx', status: 'preview' }
|
||||
];
|
||||
policy.prepareGroups(queueJobs, queueJobs, () => 'cleanup-protected', 'win32');
|
||||
queueJobs[0].status = 'done';
|
||||
policy.markCompleted(queueJobs, queueJobs[0], 'win32');
|
||||
queueJobs[1].status = 'aborted';
|
||||
await policy.persistRoundCompletions(queueJobs, { historyPersisted: true, persist: async () => true });
|
||||
|
||||
const restored = structuredClone(queueJobs);
|
||||
restored[1].status = 'preview';
|
||||
restored[1].interrupted = false;
|
||||
const touchedJobs = policy.removeRequirement(restored, restored[1], 'win32');
|
||||
|
||||
assert.deepEqual(touchedJobs, []);
|
||||
assert.deepEqual(restored.map(job => job.sourceCleanupStartedHosters), [
|
||||
['doodstream.com', 'voe.sx'],
|
||||
['doodstream.com', 'voe.sx']
|
||||
]);
|
||||
assert.deepEqual(restored.map(job => job.sourceCleanupRequiredHosters), [
|
||||
['doodstream.com', 'voe.sx'],
|
||||
['doodstream.com', 'voe.sx']
|
||||
]);
|
||||
});
|
||||
|
||||
test('legacy version two cleanup metadata without started hosters fails closed', () => {
|
||||
const queueJobs = [
|
||||
{
|
||||
id: 'legacy-complete',
|
||||
file: 'C:\\Uploads\\Legacy.mkv',
|
||||
hoster: 'doodstream.com',
|
||||
status: 'done',
|
||||
sourceCleanupMetadataVersion: 2,
|
||||
sourceCleanupToken: 'cleanup-legacy',
|
||||
sourceCleanupRequiredHosters: ['doodstream.com', 'voe.sx'],
|
||||
sourceCleanupConfirmedHosters: ['doodstream.com']
|
||||
},
|
||||
{
|
||||
id: 'legacy-candidate',
|
||||
file: 'C:\\Uploads\\Legacy.mkv',
|
||||
hoster: 'voe.sx',
|
||||
status: 'preview',
|
||||
sourceCleanupMetadataVersion: 2,
|
||||
sourceCleanupToken: 'cleanup-legacy',
|
||||
sourceCleanupRequiredHosters: ['doodstream.com', 'voe.sx'],
|
||||
sourceCleanupConfirmedHosters: ['doodstream.com']
|
||||
}
|
||||
];
|
||||
|
||||
const touchedJobs = policy.removeRequirement(queueJobs, queueJobs[1], 'win32');
|
||||
|
||||
assert.deepEqual(touchedJobs, []);
|
||||
assert.deepEqual(queueJobs.map(job => job.sourceCleanupRequiredHosters), [
|
||||
['doodstream.com', 'voe.sx'],
|
||||
['doodstream.com', 'voe.sx']
|
||||
]);
|
||||
});
|
||||
|
||||
test('removeRequirement never relaxes requirements for started or interrupted jobs', () => {
|
||||
for (const candidate of [
|
||||
{ status: 'queued' },
|
||||
{ status: 'getting-server' },
|
||||
{ status: 'uploading' },
|
||||
{ status: 'retrying' },
|
||||
{ status: 'preview', interrupted: true }
|
||||
]) {
|
||||
const queueJobs = [
|
||||
{
|
||||
id: 'job-complete',
|
||||
file: 'C:\\Uploads\\Protected.mkv',
|
||||
hoster: 'doodstream.com',
|
||||
status: 'done',
|
||||
sourceCleanupMetadataVersion: 2,
|
||||
sourceCleanupToken: 'cleanup-protected',
|
||||
sourceCleanupRequiredHosters: ['doodstream.com', 'voe.sx'],
|
||||
sourceCleanupConfirmedHosters: ['doodstream.com']
|
||||
},
|
||||
{
|
||||
id: 'job-candidate',
|
||||
file: 'C:\\Uploads\\Protected.mkv',
|
||||
hoster: 'voe.sx',
|
||||
sourceCleanupMetadataVersion: 2,
|
||||
sourceCleanupToken: 'cleanup-protected',
|
||||
sourceCleanupRequiredHosters: ['doodstream.com', 'voe.sx'],
|
||||
sourceCleanupConfirmedHosters: ['doodstream.com'],
|
||||
...candidate
|
||||
}
|
||||
];
|
||||
|
||||
const touchedJobs = policy.removeRequirement(queueJobs, queueJobs[1], 'win32');
|
||||
|
||||
assert.deepEqual(touchedJobs, []);
|
||||
assert.deepEqual(queueJobs.map(job => job.sourceCleanupRequiredHosters), [
|
||||
['doodstream.com', 'voe.sx'],
|
||||
['doodstream.com', 'voe.sx']
|
||||
assert.deepEqual(job.sourceCleanupCompletedHosters, [
|
||||
'doodstream.com',
|
||||
'voe.sx',
|
||||
'vidmoly.me'
|
||||
]);
|
||||
}
|
||||
});
|
||||
@@ -303,127 +203,3 @@ test('applyFingerprints attaches Main fingerprints by token and includes them in
|
||||
assert.notEqual(job.sourceCleanupFingerprint, fingerprint);
|
||||
}
|
||||
});
|
||||
|
||||
test('keeps round successes provisional until history and queue persistence succeed', async () => {
|
||||
const queueJobs = [
|
||||
{ id: 'job-voe', file: 'C:\\Uploads\\Round.bin', hoster: 'voe.sx', status: 'preview' },
|
||||
{ id: 'job-byse', file: 'C:\\Uploads\\Round.bin', hoster: 'byse.sx', status: 'error' }
|
||||
];
|
||||
policy.prepareGroups(queueJobs, queueJobs, () => 'cleanup-round', 'win32');
|
||||
queueJobs[0].status = 'done';
|
||||
|
||||
policy.markCompleted(queueJobs, queueJobs[0], 'win32');
|
||||
|
||||
for (const job of queueJobs) {
|
||||
assert.deepEqual(job.sourceCleanupConfirmedHosters, []);
|
||||
assert.deepEqual(job.sourceCleanupProvisionalHosters, ['voe.sx']);
|
||||
}
|
||||
|
||||
let persistedHosters = null;
|
||||
const queuePersisted = await policy.persistRoundCompletions(queueJobs, {
|
||||
historyPersisted: true,
|
||||
persist: async () => {
|
||||
persistedHosters = queueJobs.map((job) => [...job.sourceCleanupConfirmedHosters]);
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
assert.equal(queuePersisted, true);
|
||||
assert.deepEqual(persistedHosters, [['voe.sx'], ['voe.sx']]);
|
||||
for (const job of queueJobs) {
|
||||
assert.deepEqual(job.sourceCleanupConfirmedHosters, ['voe.sx']);
|
||||
assert.deepEqual(job.sourceCleanupProvisionalHosters, []);
|
||||
}
|
||||
});
|
||||
|
||||
test('rolls back provisional promotion when the final queue save fails', async () => {
|
||||
const queueJobs = [
|
||||
{
|
||||
id: 'job-voe',
|
||||
file: 'C:\\Uploads\\Partial.bin',
|
||||
hoster: 'voe.sx',
|
||||
status: 'done',
|
||||
sourceCleanupMetadataVersion: 2,
|
||||
sourceCleanupToken: 'cleanup-partial',
|
||||
sourceCleanupRequiredHosters: ['voe.sx', 'byse.sx'],
|
||||
sourceCleanupConfirmedHosters: ['voe.sx']
|
||||
},
|
||||
{
|
||||
id: 'job-byse',
|
||||
file: 'C:\\Uploads\\Partial.bin',
|
||||
hoster: 'byse.sx',
|
||||
status: 'preview',
|
||||
sourceCleanupMetadataVersion: 2,
|
||||
sourceCleanupToken: 'cleanup-partial',
|
||||
sourceCleanupRequiredHosters: ['voe.sx', 'byse.sx'],
|
||||
sourceCleanupConfirmedHosters: ['voe.sx']
|
||||
}
|
||||
];
|
||||
policy.prepareGroups(queueJobs, [queueJobs[1]], () => 'unused', 'win32');
|
||||
queueJobs[1].status = 'done';
|
||||
policy.markCompleted(queueJobs, queueJobs[1], 'win32');
|
||||
|
||||
const queuePersisted = await policy.persistRoundCompletions(queueJobs, {
|
||||
historyPersisted: true,
|
||||
persist: async () => false
|
||||
});
|
||||
|
||||
assert.equal(queuePersisted, false);
|
||||
for (const job of queueJobs) {
|
||||
assert.deepEqual(job.sourceCleanupConfirmedHosters, ['voe.sx']);
|
||||
assert.deepEqual(job.sourceCleanupProvisionalHosters, []);
|
||||
}
|
||||
});
|
||||
|
||||
test('ignores legacy v2.1.19 completed markers when preparing a retry', () => {
|
||||
const queueJobs = [{
|
||||
id: 'job-voe',
|
||||
file: 'C:\\Uploads\\Legacy.bin',
|
||||
hoster: 'voe.sx',
|
||||
status: 'error',
|
||||
sourceCleanupToken: 'cleanup-legacy',
|
||||
sourceCleanupRequiredHosters: ['voe.sx'],
|
||||
sourceCleanupCompletedHosters: ['voe.sx']
|
||||
}];
|
||||
|
||||
const prepared = policy.prepareGroups(queueJobs, queueJobs, () => 'unused', 'win32');
|
||||
|
||||
assert.deepEqual(prepared.groups[0].confirmedHosters, []);
|
||||
assert.equal(queueJobs[0].sourceCleanupMetadataVersion, 2);
|
||||
assert.deepEqual(queueJobs[0].sourceCleanupConfirmedHosters, []);
|
||||
assert.equal(Object.prototype.hasOwnProperty.call(queueJobs[0], 'sourceCleanupCompletedHosters'), false);
|
||||
});
|
||||
|
||||
test('selecting a confirmed hoster for retry revokes its durable completion', () => {
|
||||
const queueJobs = [
|
||||
{
|
||||
id: 'job-voe',
|
||||
file: 'C:\\Uploads\\Retry.bin',
|
||||
hoster: 'voe.sx',
|
||||
status: 'preview',
|
||||
sourceCleanupMetadataVersion: 2,
|
||||
sourceCleanupToken: 'cleanup-retry',
|
||||
sourceCleanupRequiredHosters: ['voe.sx', 'byse.sx'],
|
||||
sourceCleanupConfirmedHosters: ['voe.sx', 'byse.sx']
|
||||
},
|
||||
{
|
||||
id: 'job-byse',
|
||||
file: 'C:\\Uploads\\Retry.bin',
|
||||
hoster: 'byse.sx',
|
||||
status: 'done',
|
||||
sourceCleanupMetadataVersion: 2,
|
||||
sourceCleanupToken: 'cleanup-retry',
|
||||
sourceCleanupRequiredHosters: ['voe.sx', 'byse.sx'],
|
||||
sourceCleanupConfirmedHosters: ['voe.sx', 'byse.sx']
|
||||
}
|
||||
];
|
||||
|
||||
const prepared = policy.prepareGroups(queueJobs, [queueJobs[0]], () => 'unused', 'win32');
|
||||
|
||||
assert.deepEqual(prepared.groups[0].confirmedHosters, ['byse.sx']);
|
||||
assert.equal(prepared.groups[0].jobs[0].currentRound, true);
|
||||
assert.equal(prepared.groups[0].jobs[1].currentRound, false);
|
||||
for (const job of queueJobs) {
|
||||
assert.deepEqual(job.sourceCleanupConfirmedHosters, ['byse.sx']);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -19,7 +19,7 @@ function group(file, overrides = {}) {
|
||||
token: 'cleanup-1',
|
||||
file,
|
||||
requiredHosters: ['voe.sx', 'byse.sx'],
|
||||
confirmedHosters: [],
|
||||
completedHosters: [],
|
||||
jobs: [
|
||||
{ jobId: 'job-voe', file, hoster: 'voe.sx', status: 'pending' },
|
||||
{ jobId: 'job-byse', file, hoster: 'byse.sx', status: 'pending' }
|
||||
@@ -81,7 +81,7 @@ test('fingerprints a regular file and keeps the registered manifest immutable',
|
||||
assert.equal(typeof fingerprints['cleanup-1'].ino, 'number');
|
||||
|
||||
manifest.requiredHosters.splice(1, 1);
|
||||
manifest.confirmedHosters.push('byse.sx');
|
||||
manifest.completedHosters.push('byse.sx');
|
||||
manifest.jobs[1].hoster = 'voe.sx';
|
||||
await cleanup.settle({
|
||||
token: 'cleanup-1',
|
||||
@@ -182,13 +182,7 @@ test('combines previous successes with a successful retry without relaxing other
|
||||
await t.test('last retry completes the immutable group', async (subtest) => {
|
||||
const { file } = await makeSource(subtest, 'retry-completes.bin');
|
||||
const { cleanup } = makeCleanup();
|
||||
const manifest = group(file, {
|
||||
confirmedHosters: ['voe.sx'],
|
||||
jobs: [
|
||||
{ jobId: 'job-voe', file, hoster: 'voe.sx', status: 'pending', currentRound: false },
|
||||
{ jobId: 'job-byse', file, hoster: 'byse.sx', status: 'pending', currentRound: true }
|
||||
]
|
||||
});
|
||||
const manifest = group(file, { completedHosters: ['voe.sx'] });
|
||||
await cleanup.registerGroups([manifest]);
|
||||
await cleanup.settle({ token: 'cleanup-1', jobId: 'job-byse', file, hoster: 'byse.sx', status: 'done' });
|
||||
|
||||
@@ -202,11 +196,11 @@ test('combines previous successes with a successful retry without relaxing other
|
||||
const { cleanup, audits } = makeCleanup();
|
||||
const manifest = group(file, {
|
||||
requiredHosters: ['voe.sx', 'byse.sx', 'vidmoly.me'],
|
||||
confirmedHosters: ['voe.sx'],
|
||||
completedHosters: ['voe.sx'],
|
||||
jobs: [
|
||||
{ jobId: 'job-voe', file, hoster: 'voe.sx', status: 'pending', currentRound: false },
|
||||
{ jobId: 'job-byse', file, hoster: 'byse.sx', status: 'pending', currentRound: true },
|
||||
{ jobId: 'job-vidmoly', file, hoster: 'vidmoly.me', status: 'error', currentRound: false }
|
||||
{ jobId: 'job-voe', file, hoster: 'voe.sx', status: 'done' },
|
||||
{ jobId: 'job-byse', file, hoster: 'byse.sx', status: 'pending' },
|
||||
{ jobId: 'job-vidmoly', file, hoster: 'vidmoly.me', status: 'error' }
|
||||
]
|
||||
});
|
||||
await cleanup.registerGroups([manifest]);
|
||||
@@ -473,118 +467,3 @@ test('rechecks the staged file and restores a replacement without deleting it',
|
||||
assert.equal((await fs.promises.readFile(file, 'utf-8')), 'replacement data');
|
||||
assert.equal(audits.at(-1).outcome, 'source-changed');
|
||||
});
|
||||
|
||||
test('keeps the source after done, failed history persistence, and an aborted retry of the same hoster', async (t) => {
|
||||
const { file } = await makeSource(t, 'history-barrier-retry.bin');
|
||||
const firstRound = makeCleanup();
|
||||
const firstManifest = group(file, {
|
||||
requiredHosters: ['voe.sx'],
|
||||
jobs: [{ jobId: 'job-voe', file, hoster: 'voe.sx', status: 'pending', currentRound: true }]
|
||||
});
|
||||
await firstRound.cleanup.registerGroups([firstManifest]);
|
||||
await firstRound.cleanup.settle({ token: 'cleanup-1', jobId: 'job-voe', file, hoster: 'voe.sx', status: 'done' });
|
||||
|
||||
assert.deepEqual(await firstRound.cleanup.finishBatch({ historyPersisted: false, queuePersisted: true }), ['blocked']);
|
||||
assert.equal(await exists(file), true);
|
||||
|
||||
const retryRound = makeCleanup();
|
||||
const retryManifest = group(file, {
|
||||
requiredHosters: ['voe.sx'],
|
||||
completedHosters: ['voe.sx'],
|
||||
jobs: [{ jobId: 'job-voe', file, hoster: 'voe.sx', status: 'pending', currentRound: true }]
|
||||
});
|
||||
await retryRound.cleanup.registerGroups([retryManifest]);
|
||||
await retryRound.cleanup.settle({ token: 'cleanup-1', jobId: 'job-voe', file, hoster: 'voe.sx', status: 'aborted' });
|
||||
|
||||
assert.deepEqual(await retryRound.cleanup.finishBatch({ historyPersisted: true, queuePersisted: true }), ['blocked']);
|
||||
assert.equal(await exists(file), true);
|
||||
});
|
||||
|
||||
test('deletes after a persisted partial round and a successful retry of the remaining hoster', async (t) => {
|
||||
const { file } = await makeSource(t, 'partial-round-retry.bin');
|
||||
const firstRound = makeCleanup();
|
||||
const firstManifest = group(file, {
|
||||
jobs: [
|
||||
{ jobId: 'job-voe', file, hoster: 'voe.sx', status: 'pending', currentRound: true },
|
||||
{ jobId: 'job-byse', file, hoster: 'byse.sx', status: 'pending', currentRound: true }
|
||||
]
|
||||
});
|
||||
await firstRound.cleanup.registerGroups([firstManifest]);
|
||||
await firstRound.cleanup.settle({ token: 'cleanup-1', jobId: 'job-voe', file, hoster: 'voe.sx', status: 'done' });
|
||||
await firstRound.cleanup.settle({ token: 'cleanup-1', jobId: 'job-byse', file, hoster: 'byse.sx', status: 'error' });
|
||||
|
||||
assert.deepEqual(await firstRound.cleanup.finishBatch({ historyPersisted: true, queuePersisted: true }), ['blocked']);
|
||||
assert.equal(await exists(file), true);
|
||||
|
||||
const retryRound = makeCleanup();
|
||||
const retryManifest = group(file, {
|
||||
confirmedHosters: ['voe.sx'],
|
||||
jobs: [
|
||||
{ jobId: 'job-voe', file, hoster: 'voe.sx', status: 'pending', currentRound: false },
|
||||
{ jobId: 'job-byse', file, hoster: 'byse.sx', status: 'pending', currentRound: true }
|
||||
]
|
||||
});
|
||||
await retryRound.cleanup.registerGroups([retryManifest]);
|
||||
await retryRound.cleanup.settle({ token: 'cleanup-1', jobId: 'job-byse', file, hoster: 'byse.sx', status: 'done' });
|
||||
|
||||
assert.deepEqual(await retryRound.cleanup.finishBatch({ historyPersisted: true, queuePersisted: true }), ['deleted']);
|
||||
assert.equal(await exists(file), false);
|
||||
});
|
||||
|
||||
test('does not trust a legacy completion after a failed queue barrier and restart', async (t) => {
|
||||
const { file } = await makeSource(t, 'queue-barrier-restart.bin');
|
||||
const firstRound = makeCleanup();
|
||||
const firstManifest = group(file, {
|
||||
requiredHosters: ['voe.sx'],
|
||||
jobs: [{ jobId: 'job-voe', file, hoster: 'voe.sx', status: 'pending', currentRound: true }]
|
||||
});
|
||||
await firstRound.cleanup.registerGroups([firstManifest]);
|
||||
await firstRound.cleanup.settle({ token: 'cleanup-1', jobId: 'job-voe', file, hoster: 'voe.sx', status: 'done' });
|
||||
|
||||
assert.deepEqual(await firstRound.cleanup.finishBatch({ historyPersisted: true, queuePersisted: false }), ['blocked']);
|
||||
assert.equal(await exists(file), true);
|
||||
|
||||
const restartedRound = makeCleanup();
|
||||
const restartedManifest = group(file, {
|
||||
requiredHosters: ['voe.sx'],
|
||||
completedHosters: ['voe.sx'],
|
||||
jobs: [{ jobId: 'job-voe', file, hoster: 'voe.sx', status: 'pending', currentRound: true }]
|
||||
});
|
||||
await restartedRound.cleanup.registerGroups([restartedManifest]);
|
||||
await restartedRound.cleanup.settle({ token: 'cleanup-1', jobId: 'job-voe', file, hoster: 'voe.sx', status: 'error' });
|
||||
|
||||
assert.deepEqual(await restartedRound.cleanup.finishBatch({ historyPersisted: true, queuePersisted: true }), ['blocked']);
|
||||
assert.equal(await exists(file), true);
|
||||
});
|
||||
|
||||
test('lets a current non-done retry override an earlier confirmed completion', async (t) => {
|
||||
const { file } = await makeSource(t, 'confirmed-hoster-retry.bin');
|
||||
const { cleanup, audits } = makeCleanup();
|
||||
const manifest = group(file, {
|
||||
requiredHosters: ['voe.sx'],
|
||||
confirmedHosters: ['voe.sx'],
|
||||
jobs: [{ jobId: 'job-voe', file, hoster: 'voe.sx', status: 'done', currentRound: true }]
|
||||
});
|
||||
await cleanup.registerGroups([manifest]);
|
||||
await cleanup.settle({ token: 'cleanup-1', jobId: 'job-voe', file, hoster: 'voe.sx', status: 'aborted' });
|
||||
|
||||
assert.deepEqual(await cleanup.finishBatch({ historyPersisted: true, queuePersisted: true }), ['blocked']);
|
||||
assert.equal(await exists(file), true);
|
||||
assert.deepEqual(audits[0].blockingStatuses, [{ hoster: 'voe.sx', status: 'aborted' }]);
|
||||
});
|
||||
|
||||
test('re-registering a hoster for the current round invalidates its earlier current success', async (t) => {
|
||||
const { file } = await makeSource(t, 'same-batch-retry.bin');
|
||||
const { cleanup, audits } = makeCleanup();
|
||||
const manifest = group(file, {
|
||||
requiredHosters: ['voe.sx'],
|
||||
jobs: [{ jobId: 'job-voe', file, hoster: 'voe.sx', status: 'pending', currentRound: true }]
|
||||
});
|
||||
await cleanup.registerGroups([manifest]);
|
||||
await cleanup.settle({ token: 'cleanup-1', jobId: 'job-voe', file, hoster: 'voe.sx', status: 'done' });
|
||||
await cleanup.registerGroups([manifest]);
|
||||
|
||||
assert.deepEqual(await cleanup.finishBatch({ historyPersisted: true, queuePersisted: true }), ['blocked']);
|
||||
assert.equal(await exists(file), true);
|
||||
assert.deepEqual(audits[0].blockingStatuses, [{ hoster: 'voe.sx', status: 'pending' }]);
|
||||
});
|
||||
|
||||
+7
-1233
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,6 @@ const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const {
|
||||
summarizePerHoster,
|
||||
summarizeHosterHealth,
|
||||
classifyErrorCategory,
|
||||
summarizeBatchErrors,
|
||||
isRetryableCategory,
|
||||
@@ -77,258 +76,6 @@ test('summarizePerHoster reports skipped uploads without lowering the host succe
|
||||
assert.strictEqual(summary.rate, 0.5);
|
||||
});
|
||||
|
||||
test('summarizeHosterHealth keeps skipped results outside the success rate and calculates effective historical throughput', () => {
|
||||
const now = new Date('2026-08-16T12:00:00.000Z');
|
||||
const history = [{
|
||||
timestamp: '2026-08-16T10:00:00.000Z',
|
||||
files: [
|
||||
{ name: 'one.bin', size: 1024 * 1024, results: [{ hoster: 'voe.sx', status: 'done', durationSec: 2 }] },
|
||||
{ name: 'two.bin', size: 2 * 1024 * 1024, results: [{ hoster: 'voe.sx', status: 'done', durationSec: 2 }] },
|
||||
{ name: 'failed.bin', size: 8 * 1024 * 1024, results: [{ hoster: 'voe.sx', status: 'error', durationSec: 1 }] },
|
||||
{ name: 'skipped.bin', size: 16 * 1024 * 1024, results: [{ hoster: 'voe.sx', status: 'skipped', durationSec: 1 }] }
|
||||
]
|
||||
}];
|
||||
|
||||
const summary = summarizeHosterHealth(history, { now })['voe.sx'];
|
||||
|
||||
assert.deepStrictEqual({
|
||||
sampleSize: summary.sampleSize,
|
||||
successful: summary.successful,
|
||||
failed: summary.failed,
|
||||
skipped: summary.skipped,
|
||||
successRate: summary.successRate,
|
||||
effectiveBytes: summary.effectiveBytes,
|
||||
effectiveDurationSec: summary.effectiveDurationSec,
|
||||
effectiveBytesPerSecond: summary.effectiveBytesPerSecond
|
||||
}, {
|
||||
sampleSize: 4,
|
||||
successful: 2,
|
||||
failed: 1,
|
||||
skipped: 1,
|
||||
successRate: 2 / 3,
|
||||
effectiveBytes: 3 * 1024 * 1024,
|
||||
effectiveDurationSec: 4,
|
||||
effectiveBytesPerSecond: 786432
|
||||
});
|
||||
});
|
||||
|
||||
test('summarizeHosterHealth reports the newest successful batch and failures in the current seven-day window', () => {
|
||||
const now = new Date('2026-08-16T12:00:00.000Z');
|
||||
const history = [
|
||||
makeBatch(Date.parse('2026-08-09T11:59:59.999Z'), [{ hoster: 'byse.sx', status: 'error' }]),
|
||||
makeBatch(Date.parse('2026-08-09T12:00:00.000Z'), [{ hoster: 'byse.sx', status: 'error' }]),
|
||||
makeBatch(Date.parse('2026-08-15T09:00:00.000Z'), [{ hoster: 'byse.sx', status: 'done', durationSec: 4 }]),
|
||||
makeBatch(Date.parse('2026-08-16T11:00:00.000Z'), [{ hoster: 'byse.sx', status: 'error' }]),
|
||||
makeBatch(Date.parse('2026-08-16T13:00:00.000Z'), [{ hoster: 'byse.sx', status: 'error' }])
|
||||
];
|
||||
|
||||
const summary = summarizeHosterHealth(history, { now })['byse.sx'];
|
||||
|
||||
assert.strictEqual(summary.lastSuccessAt, '2026-08-15T09:00:00.000Z');
|
||||
assert.strictEqual(summary.failuresLast7Days, 2);
|
||||
});
|
||||
|
||||
test('summarizeHosterHealth uses only the 50 chronologically newest batches', () => {
|
||||
const history = [makeBatch(1, [{ hoster: 'doodstream.com', status: 'done', durationSec: 1 }])];
|
||||
for (let timestamp = 2; timestamp <= 51; timestamp++) {
|
||||
history.push(makeBatch(timestamp, [{ hoster: 'doodstream.com', status: 'error' }]));
|
||||
}
|
||||
history.reverse();
|
||||
|
||||
const summary = summarizeHosterHealth(history, { now: new Date(100000) })['doodstream.com'];
|
||||
|
||||
assert.deepStrictEqual({
|
||||
sampleSize: summary.sampleSize,
|
||||
successful: summary.successful,
|
||||
failed: summary.failed,
|
||||
lastSuccessAt: summary.lastSuccessAt
|
||||
}, {
|
||||
sampleSize: 50,
|
||||
successful: 0,
|
||||
failed: 50,
|
||||
lastSuccessAt: null
|
||||
});
|
||||
});
|
||||
|
||||
test('summarizeHosterHealth counts seven-day failures across all valid batches outside the 50-batch sample', () => {
|
||||
const now = new Date('2026-08-16T12:00:00.000Z');
|
||||
const history = Array.from({ length: 60 }, (_, index) => makeBatch(
|
||||
now.getTime() - (index + 1) * 60 * 60 * 1000,
|
||||
[{ hoster: 'voe.sx', status: 'error' }]
|
||||
));
|
||||
|
||||
const summary = summarizeHosterHealth(history, { now })['voe.sx'];
|
||||
|
||||
assert.deepStrictEqual({
|
||||
sampleSize: summary.sampleSize,
|
||||
failed: summary.failed,
|
||||
failuresLast7Days: summary.failuresLast7Days
|
||||
}, {
|
||||
sampleSize: 50,
|
||||
failed: 50,
|
||||
failuresLast7Days: 60
|
||||
});
|
||||
});
|
||||
|
||||
test('summarizeHosterHealth excludes disabled accounts from every problem-state counter', () => {
|
||||
const summary = summarizeHosterHealth([], {
|
||||
hosters: {
|
||||
'voe.sx': [
|
||||
{ id: 'disabled-error', enabled: false, authType: 'api', apiKey: 'key' },
|
||||
{ id: 'disabled-unchecked', enabled: false, authType: 'api', apiKey: 'key' },
|
||||
{ id: 'disabled-checking', enabled: false, authType: 'api', apiKey: 'key' }
|
||||
]
|
||||
},
|
||||
accountStatuses: {
|
||||
'disabled-error': { status: 'error' },
|
||||
'disabled-unchecked': { status: 'unchecked' },
|
||||
'disabled-checking': { status: 'checking' }
|
||||
},
|
||||
sessionFailedKeys: new Set(['voe.sx:disabled-error'])
|
||||
})['voe.sx'];
|
||||
|
||||
assert.deepStrictEqual({
|
||||
configuredAccounts: summary.configuredAccounts,
|
||||
accountProblems: summary.accountProblems,
|
||||
uncheckedAccounts: summary.uncheckedAccounts,
|
||||
checkingAccounts: summary.checkingAccounts
|
||||
}, {
|
||||
configuredAccounts: 3,
|
||||
accountProblems: 0,
|
||||
uncheckedAccounts: 0,
|
||||
checkingAccounts: 0
|
||||
});
|
||||
});
|
||||
|
||||
test('summarizeHosterHealth selects the newest batches without mutating the full loaded snapshot', () => {
|
||||
const now = new Date('2026-08-16T12:00:00.000Z');
|
||||
const history = Array.from({ length: 60 }, (_, index) => ({
|
||||
...makeBatch(now.getTime() - (index + 1) * 60 * 60 * 1000, [{ hoster: 'voe.sx', status: 'error' }]),
|
||||
id: `loaded-${index}`
|
||||
}));
|
||||
const completed = {
|
||||
...makeBatch(now.getTime(), [{ hoster: 'voe.sx', status: 'done', durationSec: 1 }]),
|
||||
id: 'completed'
|
||||
};
|
||||
|
||||
history.push(completed);
|
||||
const loadedOrder = [...history];
|
||||
const summary = summarizeHosterHealth(history, { now })['voe.sx'];
|
||||
|
||||
assert.deepStrictEqual(history, loadedOrder);
|
||||
assert.deepStrictEqual({
|
||||
sampleSize: summary.sampleSize,
|
||||
successful: summary.successful,
|
||||
failed: summary.failed
|
||||
}, {
|
||||
sampleSize: 50,
|
||||
successful: 1,
|
||||
failed: 49
|
||||
});
|
||||
});
|
||||
|
||||
test('summarizeHosterHealth preserves account health while history is unavailable', () => {
|
||||
const options = {
|
||||
hosters: {
|
||||
'voe.sx': [{ id: 'unavailable-history', enabled: true, authType: 'api', apiKey: 'key' }]
|
||||
},
|
||||
accountStatuses: {
|
||||
'unavailable-history': { status: 'error' }
|
||||
}
|
||||
};
|
||||
|
||||
for (const history of [null, undefined]) {
|
||||
const summary = summarizeHosterHealth(history, options)['voe.sx'];
|
||||
assert.deepStrictEqual({
|
||||
sampleSize: summary.sampleSize,
|
||||
configuredAccounts: summary.configuredAccounts,
|
||||
accountProblems: summary.accountProblems
|
||||
}, {
|
||||
sampleSize: 0,
|
||||
configuredAccounts: 1,
|
||||
accountProblems: 1
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('summarizeHosterHealth excludes invalid and future timestamps from every time statistic', () => {
|
||||
const now = new Date('2026-08-16T12:00:00.000Z');
|
||||
const history = [
|
||||
{
|
||||
id: 'invalid',
|
||||
timestamp: 'not-a-date',
|
||||
files: [{ name: 'invalid.bin', size: 1024, results: [{ hoster: 'voe.sx', status: 'done', durationSec: 1 }] }]
|
||||
},
|
||||
makeBatch(Date.parse('2026-08-16T12:00:00.001Z'), [{ hoster: 'voe.sx', status: 'error' }])
|
||||
];
|
||||
|
||||
const summary = summarizeHosterHealth(history, { now, hosters: { 'voe.sx': [] } })['voe.sx'];
|
||||
|
||||
assert.deepStrictEqual({
|
||||
sampleSize: summary.sampleSize,
|
||||
successful: summary.successful,
|
||||
failed: summary.failed,
|
||||
skipped: summary.skipped,
|
||||
lastSuccessAt: summary.lastSuccessAt,
|
||||
failuresLast7Days: summary.failuresLast7Days
|
||||
}, {
|
||||
sampleSize: 0,
|
||||
successful: 0,
|
||||
failed: 0,
|
||||
skipped: 0,
|
||||
lastSuccessAt: null,
|
||||
failuresLast7Days: 0
|
||||
});
|
||||
});
|
||||
|
||||
test('summarizeHosterHealth combines configured accounts, current statuses, and session failures without double counting', () => {
|
||||
const hosters = {
|
||||
'voe.sx': [
|
||||
{ id: 'ready', enabled: true, authType: 'login', username: 'ready@example.invalid', password: 'secret' },
|
||||
{ id: 'failed', enabled: true, authType: 'login', username: 'failed@example.invalid', password: 'secret' },
|
||||
{ id: 'unchecked', enabled: true, authType: 'login', username: 'unchecked@example.invalid', password: 'secret' },
|
||||
{ id: 'disabled', enabled: false, authType: 'login', username: 'disabled@example.invalid', password: 'secret' },
|
||||
{ id: 'session', enabled: true, authType: 'login', username: 'session@example.invalid', password: 'secret' }
|
||||
],
|
||||
'clouddrop.cc': []
|
||||
};
|
||||
const accountStatuses = {
|
||||
ready: { status: 'ok' },
|
||||
failed: { status: 'error' },
|
||||
unchecked: { status: 'unchecked' },
|
||||
disabled: { status: 'error' },
|
||||
session: { status: 'ok' }
|
||||
};
|
||||
|
||||
const summary = summarizeHosterHealth([], {
|
||||
now: new Date('2026-08-16T12:00:00.000Z'),
|
||||
hosters,
|
||||
accountStatuses,
|
||||
sessionFailedKeys: new Set(['voe.sx:failed', 'voe.sx:session'])
|
||||
});
|
||||
|
||||
assert.deepStrictEqual({
|
||||
configuredAccounts: summary['voe.sx'].configuredAccounts,
|
||||
accountProblems: summary['voe.sx'].accountProblems,
|
||||
uncheckedAccounts: summary['voe.sx'].uncheckedAccounts,
|
||||
checkingAccounts: summary['voe.sx'].checkingAccounts
|
||||
}, {
|
||||
configuredAccounts: 5,
|
||||
accountProblems: 2,
|
||||
uncheckedAccounts: 1,
|
||||
checkingAccounts: 0
|
||||
});
|
||||
assert.deepStrictEqual({
|
||||
sampleSize: summary['clouddrop.cc'].sampleSize,
|
||||
configuredAccounts: summary['clouddrop.cc'].configuredAccounts,
|
||||
successRate: summary['clouddrop.cc'].successRate
|
||||
}, {
|
||||
sampleSize: 0,
|
||||
configuredAccounts: 0,
|
||||
successRate: null
|
||||
});
|
||||
});
|
||||
|
||||
test('classifyErrorCategory: file-rejected phrases', () => {
|
||||
assert.strictEqual(classifyErrorCategory('Byse lehnte Datei ab: Not video file format'), 'file-rejected');
|
||||
assert.strictEqual(classifyErrorCategory('Duplicate file already exists'), 'file-rejected');
|
||||
@@ -422,27 +169,6 @@ test('mergeSkippedIntoSummary adds skipped jobs to totals and history files', ()
|
||||
});
|
||||
});
|
||||
|
||||
test('mergeSkippedIntoSummary keeps duplicate basenames separated by file key', () => {
|
||||
const summary = {
|
||||
total: 2,
|
||||
succeeded: 2,
|
||||
failed: 0,
|
||||
skipped: 0,
|
||||
files: [
|
||||
{ name: 'same.mkv', fileKey: 'file-one', size: 10, results: [{ jobId: 'done-one', hoster: 'voe.sx', status: 'done' }] },
|
||||
{ name: 'same.mkv', fileKey: 'file-two', size: 20, results: [{ jobId: 'done-two', hoster: 'voe.sx', status: 'done' }] }
|
||||
]
|
||||
};
|
||||
const merged = mergeSkippedIntoSummary(summary, [
|
||||
{ jobId: 'skip-one', fileName: 'same.mkv', fileKey: 'file-one', hoster: 'byse.sx', reason: 'Kein Account' },
|
||||
{ jobId: 'skip-two', fileName: 'same.mkv', fileKey: 'file-two', hoster: 'doodstream.com', reason: 'Kein Account' }
|
||||
]);
|
||||
|
||||
assert.strictEqual(merged.files.length, 2);
|
||||
assert.deepStrictEqual(merged.files[0].results.map(result => result.jobId), ['done-one', 'skip-one']);
|
||||
assert.deepStrictEqual(merged.files[1].results.map(result => result.jobId), ['done-two', 'skip-two']);
|
||||
});
|
||||
|
||||
test('isRetryableCategory: only transient + network + unknown retry-worthy', () => {
|
||||
assert.strictEqual(isRetryableCategory('hoster-transient'), true);
|
||||
assert.strictEqual(isRetryableCategory('network'), true);
|
||||
|
||||
@@ -3,7 +3,7 @@ const assert = require('node:assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { sanitizeConfig, collectSecretValues, collectFile, buildSupportBundleText, redactLogText, REDACTED } = require('../lib/support-bundle');
|
||||
const { sanitizeConfig, collectFile, buildSupportBundleText, redactLogText, REDACTED } = require('../lib/support-bundle');
|
||||
|
||||
test('sanitizeConfig redacts known credential keys at any nesting depth', () => {
|
||||
const input = {
|
||||
@@ -85,74 +85,6 @@ test('redactLogText leaves a normal "session" word in prose alone', () => {
|
||||
assert.equal(redactLogText(benign, []), benign);
|
||||
});
|
||||
|
||||
test('redactLogText removes complete authorization, cookie, session, HTML credential, and query values', () => {
|
||||
const authorization = 'Digest username="private-user", realm="private-realm", response="private-response"';
|
||||
const cookie = 'sid=private-cookie; preferences=private-preferences';
|
||||
const sessionId = 's3';
|
||||
const htmlPassword = 'private-html-password';
|
||||
const htmlToken = 'private-html-token';
|
||||
const queryToken = 'q1';
|
||||
const input = [
|
||||
`Authorization: ${authorization}`,
|
||||
`Cookie: ${cookie}`,
|
||||
`session_id=${sessionId}`,
|
||||
`<input type="password" name="password" value="${htmlPassword}">`,
|
||||
`<input value="${htmlToken}" name="api_token" type="text">`,
|
||||
`https://example.invalid/upload?token=${queryToken}&next=ok`
|
||||
].join('\n');
|
||||
const out = redactLogText(input, []);
|
||||
for (const value of [authorization, 'private-user', 'private-realm', 'private-response', cookie, 'private-cookie', 'private-preferences', sessionId, htmlPassword, htmlToken, queryToken]) {
|
||||
assert.ok(!out.includes(value), `sensitive value survived: ${value}`);
|
||||
}
|
||||
assert.ok((out.match(/<redacted>/g) || []).length >= 6);
|
||||
});
|
||||
|
||||
test('redactLogText masks a one-character configured secret only as a complete sensitive value', () => {
|
||||
const out = redactLogText('status=diagnostics available\npassword=x\nfile=xylophone.mkv\nmarker=x', ['x']);
|
||||
assert.ok(out.includes('status=diagnostics available'));
|
||||
assert.ok(out.includes('file=xylophone.mkv'));
|
||||
assert.ok(!out.includes('password=x'));
|
||||
assert.ok(!out.includes('marker=x'));
|
||||
assert.ok(out.includes(`password=${REDACTED}`));
|
||||
assert.ok(out.includes(`marker=${REDACTED}`));
|
||||
});
|
||||
|
||||
test('redactLogText replaces configured secrets only as complete values', () => {
|
||||
const out = redactLogText([
|
||||
'password=orange',
|
||||
'configured token orange accepted',
|
||||
'file=orangejuice',
|
||||
'file=orange.mkv',
|
||||
'password=.',
|
||||
'version=2.1.20',
|
||||
'sentence finished.'
|
||||
].join('\n'), ['orange', '.']);
|
||||
assert.ok(!out.includes('password=orange'));
|
||||
assert.ok(!out.includes('token orange'));
|
||||
assert.ok(!out.includes('password=.'));
|
||||
assert.ok(out.includes('file=orangejuice'));
|
||||
assert.ok(out.includes('file=orange.mkv'));
|
||||
assert.ok(out.includes('version=2.1.20'));
|
||||
assert.ok(out.includes('sentence finished.'));
|
||||
});
|
||||
|
||||
test('redactLogText removes JSON-escaped configured secrets and quoted HTML credential values', () => {
|
||||
const jsonSecret = 'alpha"beta\\gamma';
|
||||
const password = 'abc>secret';
|
||||
const token = 'token>quoted';
|
||||
const input = [
|
||||
JSON.stringify({ note: jsonSecret, token: jsonSecret }),
|
||||
`<input type="password" value="${password}">`,
|
||||
`<input value='${token}' name='api_token' type='text'>`
|
||||
].join('\n');
|
||||
const out = redactLogText(input, [jsonSecret]);
|
||||
assert.ok(!out.includes(jsonSecret));
|
||||
assert.ok(!out.includes('alpha\\"beta\\\\gamma'));
|
||||
assert.ok(!out.includes(password));
|
||||
assert.ok(!out.includes(token));
|
||||
assert.ok((out.match(/<redacted>/g) || []).length >= 3);
|
||||
});
|
||||
|
||||
test('redactLogText removes complete local paths from structured and free-form log text', () => {
|
||||
const profilePath = ['C:', 'Users', 'ProfileFixture', 'Private Folder', 'episode.mkv'].join('\\');
|
||||
const drivePath = ['D:', 'Archive', 'Private Folder', 'source.mkv'].join('\\');
|
||||
@@ -171,23 +103,6 @@ test('redactLogText removes complete local paths from structured and free-form l
|
||||
assert.ok((out.match(/<redacted-path>/g) || []).length >= 4);
|
||||
});
|
||||
|
||||
test('redactLogText removes extended UNC, extended drive, UNC and slash-UNC paths', () => {
|
||||
const extendedUnc = '\\\\?\\UNC\\private-server\\secret-share\\hidden.log';
|
||||
const extendedDrive = ['\\\\?\\C:', 'Users', 'PrivateProfile', 'hidden.log'].join('\\');
|
||||
const unc = '\\\\private-server\\secret-share\\hidden.log';
|
||||
const slashUnc = '//private-server/secret-share/hidden.log';
|
||||
const out = redactLogText([
|
||||
`extended UNC failure: ${extendedUnc}`,
|
||||
`extended drive failure: ${extendedDrive}`,
|
||||
`UNC failure: ${unc}`,
|
||||
`slash UNC failure: ${slashUnc}`
|
||||
].join('\n'), []);
|
||||
for (const fragment of ['private-server', 'secret-share', 'PrivateProfile', 'hidden.log']) {
|
||||
assert.ok(!out.includes(fragment), `private path fragment survived: ${fragment}`);
|
||||
}
|
||||
assert.equal((out.match(/<redacted-path>/g) || []).length, 4);
|
||||
});
|
||||
|
||||
test('sanitizeConfig does not mutate input', () => {
|
||||
const input = { hosters: { 'voe.sx': [{ password: 'secret' }] } };
|
||||
const clone = JSON.parse(JSON.stringify(input));
|
||||
@@ -239,7 +154,7 @@ test('buildSupportBundleText produces structured output with header + config + f
|
||||
sanitizedConfig: { hosters: { 'voe.sx': [{ apiKey: '<redacted>' }] } },
|
||||
files: [{ label: 'debug.log', path: tmp }]
|
||||
});
|
||||
assert.match(text, /^=== Multi Hoster Uploader Support Bundle ===/);
|
||||
assert.match(text, /^=== Multi-Hoster-Upload Support Bundle ===/);
|
||||
assert.match(text, /Version: 3\.3\.41/);
|
||||
assert.match(text, /Platform: win32/);
|
||||
assert.match(text, /=== Config \(sanitized/);
|
||||
@@ -253,22 +168,10 @@ test('buildSupportBundleText produces structured output with header + config + f
|
||||
|
||||
test('buildSupportBundleText handles empty file list and missing header', () => {
|
||||
const text = buildSupportBundleText({ sanitizedConfig: {}, files: [] });
|
||||
assert.match(text, /=== Multi Hoster Uploader Support Bundle ===/);
|
||||
assert.match(text, /=== Multi-Hoster-Upload Support Bundle ===/);
|
||||
assert.match(text, /=== Config/);
|
||||
});
|
||||
|
||||
test('buildSupportBundleText never uses an absolute source path as a section label', () => {
|
||||
const tmp = path.join(os.tmpdir(), `mhu-bundle-unlabeled-${Date.now()}.log`);
|
||||
fs.writeFileSync(tmp, 'safe content\n');
|
||||
try {
|
||||
const text = buildSupportBundleText({ sanitizedConfig: {}, files: [{ path: tmp }], secrets: [] });
|
||||
assert.ok(!text.includes(tmp));
|
||||
assert.ok(text.includes('=== log (size='));
|
||||
} finally {
|
||||
fs.unlinkSync(tmp);
|
||||
}
|
||||
});
|
||||
|
||||
test('buildSupportBundleText redacts configured and pattern-detected secrets from included logs', () => {
|
||||
const tmp = path.join(os.tmpdir(), `mhu-bundle-secrets-${Date.now()}.log`);
|
||||
const configuredSecret = ['configured', 'Secret', '123456'].join('');
|
||||
@@ -297,73 +200,3 @@ test('buildSupportBundleText redacts configured and pattern-detected secrets fro
|
||||
fs.unlinkSync(tmp);
|
||||
}
|
||||
});
|
||||
|
||||
test('buildSupportBundleText removes configured secrets of every non-empty length', () => {
|
||||
const tmp = path.join(os.tmpdir(), `mhu-bundle-short-secrets-${Date.now()}.log`);
|
||||
const config = {
|
||||
hosters: { 'voe.sx': [{ password: 'p1', apiKey: 'k2' }] },
|
||||
globalSettings: { diagnostics: { token: 't3' }, cookie: '', sessionId: null }
|
||||
};
|
||||
fs.writeFileSync(tmp, 'password=p1\napiKey=k2\ntoken=t3\n');
|
||||
try {
|
||||
const secrets = collectSecretValues(config);
|
||||
assert.deepEqual(new Set(secrets), new Set(['p1', 'k2', 't3']));
|
||||
const text = buildSupportBundleText({
|
||||
header: { Marker: 'p1-k2-t3' },
|
||||
sanitizedConfig: sanitizeConfig(config),
|
||||
secrets,
|
||||
files: [{ label: 'short-secrets.log', path: tmp }]
|
||||
});
|
||||
for (const secret of ['p1', 'k2', 't3']) assert.ok(!text.includes(secret), `configured secret survived: ${secret}`);
|
||||
} finally {
|
||||
fs.unlinkSync(tmp);
|
||||
}
|
||||
});
|
||||
|
||||
test('buildSupportBundleText removes a one-character configured secret', () => {
|
||||
const tmp = path.join(os.tmpdir(), `mhu-bundle-one-character-secret-${Date.now()}.log`);
|
||||
const config = { hosters: { 'voe.sx': [{ password: 'x' }] } };
|
||||
fs.writeFileSync(tmp, 'password=x\n');
|
||||
try {
|
||||
const text = buildSupportBundleText({
|
||||
header: { Marker: 'secret:x' },
|
||||
sanitizedConfig: sanitizeConfig(config),
|
||||
secrets: collectSecretValues(config),
|
||||
files: [{ label: 'one-character.log', path: tmp }]
|
||||
});
|
||||
assert.ok(!text.includes('secret:x'));
|
||||
assert.ok(!text.includes('password=x'));
|
||||
assert.ok(text.includes('one-character.log'));
|
||||
} finally {
|
||||
fs.unlinkSync(tmp);
|
||||
}
|
||||
});
|
||||
|
||||
test('buildSupportBundleText contains no escaped secrets, credential HTML or absolute path variants', () => {
|
||||
const tmp = path.join(os.tmpdir(), `mhu-bundle-hard-redaction-${Date.now()}.log`);
|
||||
const secret = 'alpha"beta\\gamma';
|
||||
const paths = [
|
||||
'\\\\?\\UNC\\private-server\\secret-share\\hidden.log',
|
||||
'\\\\private-server\\secret-share\\hidden.log',
|
||||
'//private-server/secret-share/hidden.log',
|
||||
['C:', 'Users', 'PrivateProfile', 'hidden.log'].join('\\')
|
||||
];
|
||||
fs.writeFileSync(tmp, [
|
||||
JSON.stringify({ token: secret, path: paths[0] }),
|
||||
'<input type="password" value="abc>secret">',
|
||||
...paths
|
||||
].join('\n'));
|
||||
try {
|
||||
const text = buildSupportBundleText({
|
||||
header: { Source: paths[3] },
|
||||
sanitizedConfig: { marker: JSON.stringify(secret), path: paths[1] },
|
||||
secrets: [secret],
|
||||
files: [{ label: paths[2], path: tmp }]
|
||||
});
|
||||
for (const value of ['alpha', 'beta', 'gamma', 'abc>secret', 'private-server', 'secret-share', 'PrivateProfile', 'hidden.log', tmp]) {
|
||||
assert.ok(!text.includes(value), `support bundle leak survived: ${value}`);
|
||||
}
|
||||
} finally {
|
||||
fs.unlinkSync(tmp);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
function installHiddenElectronWindowHarness({ BrowserWindow, targetGlobal = globalThis }) {
|
||||
const requestedAlwaysOnTop = new WeakMap();
|
||||
const createdWindows = new Set();
|
||||
|
||||
class HiddenBrowserWindow extends BrowserWindow {
|
||||
constructor(options = {}) {
|
||||
super({
|
||||
...options,
|
||||
show: false,
|
||||
focusable: false,
|
||||
skipTaskbar: true,
|
||||
alwaysOnTop: false,
|
||||
paintWhenInitiallyHidden: true,
|
||||
webPreferences: {
|
||||
...(options.webPreferences || {}),
|
||||
offscreen: true,
|
||||
backgroundThrottling: false
|
||||
}
|
||||
});
|
||||
createdWindows.add(this);
|
||||
if (typeof this.setIgnoreMouseEvents === 'function') this.setIgnoreMouseEvents(true);
|
||||
}
|
||||
|
||||
show() {}
|
||||
showInactive() {}
|
||||
focus() {}
|
||||
restore() {}
|
||||
moveTop() {}
|
||||
|
||||
setAlwaysOnTop(value) {
|
||||
requestedAlwaysOnTop.set(this, Boolean(value));
|
||||
}
|
||||
|
||||
isAlwaysOnTop() {
|
||||
return requestedAlwaysOnTop.get(this) === true;
|
||||
}
|
||||
}
|
||||
|
||||
targetGlobal.__mhuBrowserWindowConstructor = HiddenBrowserWindow;
|
||||
|
||||
return {
|
||||
getWindows() {
|
||||
return [...createdWindows].filter(window => typeof window.isDestroyed !== 'function' || !window.isDestroyed());
|
||||
},
|
||||
isAlwaysOnTopRequested(window) {
|
||||
return requestedAlwaysOnTop.get(window) === true;
|
||||
},
|
||||
isNativeSurfaceSuppressed(window) {
|
||||
return window.isVisible() === false && window.isFocused() === false;
|
||||
},
|
||||
areNativeSurfacesSuppressed(windows) {
|
||||
return windows.every(window => window.isVisible() === false && window.isFocused() === false);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { installHiddenElectronWindowHarness };
|
||||
+93
-1599
File diff suppressed because it is too large
Load Diff
@@ -23,75 +23,6 @@ test('release arguments reject a malformed transport tag', async () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('release notes accept English and reject German or French', async () => {
|
||||
const { createReleasePlan, parseReleaseArgs } = await import(releasePlanUrl);
|
||||
for (const notes of [
|
||||
'Security improvements and more reliable updates.',
|
||||
'Uploads now resume correctly after network interruptions.',
|
||||
'Faster uploads, smoother recovery.',
|
||||
'Hardened credential redaction.'
|
||||
]) {
|
||||
assert.equal(
|
||||
createReleasePlan(parseReleaseArgs(['2.1.21', '--transport-tag', 'v2.1.21', notes])).releaseBody,
|
||||
notes
|
||||
);
|
||||
}
|
||||
for (const notes of [
|
||||
'Sicherheitsverbesserungen und zuverlässigere Updates.',
|
||||
'Uploads werden nach Netzwerkunterbrechungen jetzt korrekt fortgesetzt.',
|
||||
'Améliorations de sécurité et mises à jour plus fiables.',
|
||||
'Les téléversements reprennent correctement après les interruptions réseau.',
|
||||
'Security update et corrections.',
|
||||
'Das Programm korrigiert Probleme im update.',
|
||||
'Uploads laufen wieder stabil.'
|
||||
]) {
|
||||
assert.throws(
|
||||
() => createReleasePlan(parseReleaseArgs(['2.1.21', '--transport-tag', 'v2.1.21', notes])),
|
||||
/English release notes are required/
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('release arguments reject unknown, misspelled, and duplicate options', async () => {
|
||||
const { parseReleaseArgs } = await import(releasePlanUrl);
|
||||
assert.deepEqual(
|
||||
parseReleaseArgs(['2.1.21', '--transport-tag', 'v2.1.21', '--notes', 'Hardened credential redaction.', '--dry-run']),
|
||||
{
|
||||
version: '2.1.21',
|
||||
transportTag: 'v2.1.21',
|
||||
notes: 'Hardened credential redaction.',
|
||||
dryRun: true
|
||||
}
|
||||
);
|
||||
const invalidArgs = [
|
||||
['2.1.21', '--transport-tag', 'v2.1.21', '--notes', 'Release notes', '--publish'],
|
||||
['2.1.21', '--transport-tag', 'v2.1.21', '--notes', 'Release notes', '--dryrun'],
|
||||
['2.1.21', '--transport-tag', 'v2.1.21', '--notes', 'Release notes', '-dry-run'],
|
||||
['2.1.21', '--transport-tag', 'v2.1.21', '-notes', 'Release notes'],
|
||||
['2.1.21', '--transport-tag', 'v2.1.21', '--notes', 'Release notes', '--dry-run', '--dry-run'],
|
||||
['2.1.21', '--transport-tag', 'v2.1.21', '--notes', 'Release notes', '--notes', 'Other notes'],
|
||||
['2.1.21', '--transport-tag', 'v2.1.21', '--transport-tag', 'v2.1.22', '--notes', 'Release notes']
|
||||
];
|
||||
|
||||
for (const args of invalidArgs) {
|
||||
assert.throws(() => parseReleaseArgs(args), /option/i);
|
||||
}
|
||||
});
|
||||
|
||||
test(['GitHub and ', ['Gi', 'tea'].join(''), ' CI verify version tag pushes'].join(''), () => {
|
||||
const workflowPaths = ['.github/workflows/ci.yml', `${['.', ['gi', 'tea'].join('')].join('')}/workflows/ci.yml`];
|
||||
for (const relativePath of workflowPaths) {
|
||||
const workflow = fs.readFileSync(path.resolve(__dirname, '..', relativePath), 'utf8');
|
||||
const lines = workflow.split(/\r?\n/);
|
||||
const pushIndex = lines.indexOf(' push:');
|
||||
const pushTrigger = [];
|
||||
for (let index = pushIndex + 1; index < lines.length && lines[index].startsWith(' '); index++) {
|
||||
pushTrigger.push(lines[index]);
|
||||
}
|
||||
assert.ok(pushTrigger.includes(" tags: ['v*']"), relativePath);
|
||||
}
|
||||
});
|
||||
|
||||
test('matching GitHub release notes replace the private release body', async () => {
|
||||
const calls = [];
|
||||
const notes = await fetchGithubReleaseNotes('2.1.0', 'Private fallback', async (url, options) => {
|
||||
@@ -259,7 +190,7 @@ test('release plan keeps product artifacts separate from the transport tag', asy
|
||||
}, {
|
||||
version: '2.0.7',
|
||||
transportTag: 'v3.3.115',
|
||||
releaseTitle: 'Multi Hoster Uploader v2.0.7',
|
||||
releaseTitle: 'Multi-Hoster-Upload v2.0.7',
|
||||
releaseBody: 'Update visibility',
|
||||
expectedArtifacts: [
|
||||
'Multi-Hoster-Upload Setup 2.0.7.exe',
|
||||
@@ -271,29 +202,13 @@ test('release plan keeps product artifacts separate from the transport tag', asy
|
||||
});
|
||||
});
|
||||
|
||||
test('GitHub release metadata uses the normalized uploaded asset name', async () => {
|
||||
const { createReleasePlan, parseReleaseArgs, renderLatestYml } = await import(releasePlanUrl);
|
||||
const plan = createReleasePlan(parseReleaseArgs(['2.1.20', '--transport-tag', 'v2.1.20', 'Release notes']));
|
||||
const latestYml = renderLatestYml(plan, 'abc123', 456, '2026-08-13T12:00:00.000Z', plan.githubSetupName);
|
||||
|
||||
assert.deepEqual(plan.githubExpectedArtifacts, [
|
||||
'Multi-Hoster-Upload.Setup.2.1.20.exe',
|
||||
'Multi-Hoster-Upload.2.1.20.exe',
|
||||
'Multi-Hoster-Upload.Setup.2.1.20.exe.blockmap',
|
||||
'latest.yml'
|
||||
]);
|
||||
assert.match(latestYml, /url: Multi-Hoster-Upload\.Setup\.2\.1\.20\.exe/);
|
||||
assert.match(latestYml, /path: Multi-Hoster-Upload\.Setup\.2\.1\.20\.exe/);
|
||||
assert.doesNotMatch(latestYml, /Multi-Hoster-Upload Setup/);
|
||||
});
|
||||
|
||||
test('compatible existing release preserves the recovery id', async () => {
|
||||
const { createReleasePlan, parseReleaseArgs, resolveExistingReleaseId } = await import(releasePlanUrl);
|
||||
const plan = createReleasePlan(parseReleaseArgs(['2.0.1', '--transport-tag', 'v3.3.109', 'Bridge notes']));
|
||||
const release = {
|
||||
id: 81,
|
||||
tag_name: 'v3.3.109',
|
||||
name: 'Multi Hoster Uploader v2.0.1',
|
||||
name: 'Multi-Hoster-Upload v2.0.1',
|
||||
body: 'Bridge notes',
|
||||
draft: false,
|
||||
prerelease: false,
|
||||
@@ -318,36 +233,6 @@ test('incompatible existing release title fails closed', async () => {
|
||||
|
||||
assert.throws(
|
||||
() => resolveExistingReleaseId(plan, release),
|
||||
/Refusing recovery for v3\.3\.109: existing release title "Multi-Hoster-Upload v3\.3\.109" does not match "Multi Hoster Uploader v2\.0\.1"/
|
||||
/Refusing recovery for v3\.3\.109: existing release title "Multi-Hoster-Upload v3\.3\.109" does not match "Multi-Hoster-Upload v2\.0\.1"/
|
||||
);
|
||||
});
|
||||
|
||||
test('existing release recovery rejects a mismatched transport tag', async () => {
|
||||
const { createReleasePlan, parseReleaseArgs, resolveExistingReleaseId } = await import(releasePlanUrl);
|
||||
const plan = createReleasePlan(parseReleaseArgs(['2.1.20', '--transport-tag', 'v2.1.20', 'Release notes']));
|
||||
const release = {
|
||||
id: 82,
|
||||
tag_name: 'v9.9.9',
|
||||
name: 'Multi Hoster Uploader v2.1.20'
|
||||
};
|
||||
|
||||
assert.throws(
|
||||
() => resolveExistingReleaseId(plan, release),
|
||||
/existing release tag "v9\.9\.9" does not match "v2\.1\.20"/
|
||||
);
|
||||
});
|
||||
|
||||
test('checksum metadata accepts equivalent transport-specific installer separators', async () => {
|
||||
const sha = crypto.randomBytes(64).toString('base64');
|
||||
const metadata = await parseLatestYml('https://update.invalid/latest.yml', {
|
||||
version: '2.1.22',
|
||||
assetName: 'Multi-Hoster-Upload Setup 2.1.22.exe',
|
||||
assetSize: 456
|
||||
}, async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () => `version: 2.1.22\npath: Multi-Hoster-Upload.Setup.2.1.22.exe\nsha512: ${sha}\nsize: 456\n`
|
||||
}));
|
||||
|
||||
assert.equal(metadata.path, 'Multi-Hoster-Upload.Setup.2.1.22.exe');
|
||||
});
|
||||
|
||||
+5
-228
@@ -6,17 +6,13 @@ const path = require('path');
|
||||
|
||||
test('internal audit records never contaminate the MDU session link log', async () => {
|
||||
let createUploadAuditWriter;
|
||||
let createUploadAuditEvents;
|
||||
try {
|
||||
({ createUploadAuditWriter, createUploadAuditEvents } = require('../lib/upload-audit'));
|
||||
({ createUploadAuditWriter } = require('../lib/upload-audit'));
|
||||
} catch {}
|
||||
assert.equal(typeof createUploadAuditWriter, 'function');
|
||||
assert.equal(typeof createUploadAuditEvents, 'function');
|
||||
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-upload-audit-'));
|
||||
const sessionLog = path.join(directory, '13-08-2026-mdu-session-14-20-123456.log');
|
||||
const debugLog = path.join(directory, 'upload-debug.log');
|
||||
fs.writeFileSync(debugLog, 'debug-before\r\n');
|
||||
const writer = createUploadAuditWriter({
|
||||
fs,
|
||||
path,
|
||||
@@ -26,15 +22,13 @@ test('internal audit records never contaminate the MDU session link log', async
|
||||
reportError: () => {},
|
||||
retryDelays: [0]
|
||||
});
|
||||
const events = createUploadAuditEvents(writer, () => new Date('2026-08-13T12:00:00.000Z'));
|
||||
|
||||
await events.appendSourceCleanup({ outcome: 'deleted' });
|
||||
await events.appendUploadPlan({ fileCount: 2, destinationCount: 2, plannedUploadCount: 4 }, 'start');
|
||||
await writer.append('# SOURCE-CLEANUP {"outcome":"deleted"}\r\n', 'source-cleanup');
|
||||
await writer.append('# UPLOAD-PLAN {"plannedUploadCount":4}\r\n', 'upload-plan');
|
||||
|
||||
const auditLog = path.join(directory, 'upload-audit.log');
|
||||
assert.equal(fs.existsSync(sessionLog), false);
|
||||
assert.equal(fs.readFileSync(debugLog, 'utf8'), 'debug-before\r\n');
|
||||
assert.equal(fs.readFileSync(auditLog, 'utf8'), '# SOURCE-CLEANUP {"outcome":"deleted"}\r\n# UPLOAD-PLAN {"timestamp":"2026-08-13T12:00:00.000Z","mode":"start","fileCount":2,"destinationCount":2,"plannedUploadCount":4}\r\n');
|
||||
assert.equal(fs.readFileSync(auditLog, 'utf8'), '# SOURCE-CLEANUP {"outcome":"deleted"}\r\n# UPLOAD-PLAN {"plannedUploadCount":4}\r\n');
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
@@ -57,7 +51,7 @@ test('audit writer reports the actual fallback file after a failed primary write
|
||||
}),
|
||||
rotateLogFile: () => {},
|
||||
invalidateUploadLogTarget: () => {},
|
||||
persistFallbackLogPath: async targetPath => { persistedFallbacks.push(targetPath); return true; },
|
||||
persistFallbackLogPath: async targetPath => { persistedFallbacks.push(targetPath); },
|
||||
reportError: () => {},
|
||||
retryDelays: [0, 0]
|
||||
});
|
||||
@@ -68,220 +62,3 @@ test('audit writer reports the actual fallback file after a failed primary write
|
||||
assert.equal(fs.readFileSync(writer.getActivePath(), 'utf8'), '# UPLOAD-PLAN {}\r\n');
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('audit writer retries a safe target after sync or close durability failures', async (t) => {
|
||||
const { createUploadAuditWriter } = require('../lib/upload-audit');
|
||||
for (const failedStage of ['sync', 'close']) {
|
||||
await t.test(failedStage, async () => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), `mhu-upload-audit-${failedStage}-`));
|
||||
const primaryLog = path.join(directory, 'primary', 'fileuploader.log');
|
||||
const fallbackLog = path.join(directory, 'fallback', 'fileuploader.log');
|
||||
const primaryAudit = path.join(directory, 'primary', 'upload-audit.log');
|
||||
const fallbackAudit = path.join(directory, 'fallback', 'upload-audit.log');
|
||||
const syncCalls = [];
|
||||
const closeCalls = [];
|
||||
const reports = [];
|
||||
const durabilityFs = {
|
||||
...fs,
|
||||
promises: {
|
||||
...fs.promises,
|
||||
open: async (targetPath, flags) => {
|
||||
const handle = await fs.promises.open(targetPath, flags);
|
||||
return {
|
||||
appendFile: handle.appendFile.bind(handle),
|
||||
sync: async () => {
|
||||
syncCalls.push(targetPath);
|
||||
if (targetPath === primaryAudit && failedStage === 'sync') throw new Error('controlled sync failure');
|
||||
return handle.sync();
|
||||
},
|
||||
close: async () => {
|
||||
closeCalls.push(targetPath);
|
||||
await handle.close();
|
||||
if (targetPath === primaryAudit && failedStage === 'close') throw new Error('controlled close failure');
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
const writer = createUploadAuditWriter({
|
||||
fs: durabilityFs,
|
||||
path,
|
||||
resolveUploadLogTarget: excluded => {
|
||||
if (!excluded.has(primaryLog)) return { path: primaryLog, isFallback: false };
|
||||
if (!excluded.has(fallbackLog)) return { path: fallbackLog, isFallback: true };
|
||||
return null;
|
||||
},
|
||||
rotateLogFile: () => {},
|
||||
invalidateUploadLogTarget: () => {},
|
||||
persistFallbackLogPath: async () => true,
|
||||
reportError: (label, error) => reports.push({ label, message: error.message }),
|
||||
retryDelays: [0, 0]
|
||||
});
|
||||
|
||||
assert.equal(await writer.append('# UPLOAD-PLAN {}\r\n', 'upload-plan'), true);
|
||||
assert.equal(writer.getActivePath(), fallbackAudit);
|
||||
assert.deepEqual(syncCalls, [primaryAudit, fallbackAudit]);
|
||||
assert.deepEqual(closeCalls, [primaryAudit, fallbackAudit]);
|
||||
assert.equal(fs.readFileSync(fallbackAudit, 'utf8'), '# UPLOAD-PLAN {}\r\n');
|
||||
assert.equal(reports.some(report => report.label === 'upload-plan' && report.message.includes(failedStage)), true);
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('audit writer fails closed when no target can sync the appended bytes', async () => {
|
||||
const { createUploadAuditWriter } = require('../lib/upload-audit');
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-upload-audit-sync-exhausted-'));
|
||||
const targets = ['first', 'second'].map(name => path.join(directory, name, 'fileuploader.log'));
|
||||
const durabilityFs = {
|
||||
...fs,
|
||||
promises: {
|
||||
...fs.promises,
|
||||
open: async (targetPath, flags) => {
|
||||
const handle = await fs.promises.open(targetPath, flags);
|
||||
return {
|
||||
appendFile: handle.appendFile.bind(handle),
|
||||
sync: async () => { throw new Error(`controlled sync failure: ${targetPath}`); },
|
||||
close: handle.close.bind(handle)
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
const writer = createUploadAuditWriter({
|
||||
fs: durabilityFs,
|
||||
path,
|
||||
resolveUploadLogTarget: excluded => {
|
||||
const targetPath = targets.find(candidate => !excluded.has(candidate));
|
||||
return targetPath ? { path: targetPath, isFallback: true } : null;
|
||||
},
|
||||
rotateLogFile: () => {},
|
||||
invalidateUploadLogTarget: () => {},
|
||||
persistFallbackLogPath: async () => true,
|
||||
reportError: () => {},
|
||||
retryDelays: [0, 0]
|
||||
});
|
||||
|
||||
assert.equal(await writer.append('# UPLOAD-PLAN {}\r\n', 'upload-plan'), false);
|
||||
assert.equal(writer.getActivePath(), null);
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('audit writer rejects false and thrown fallback persistence before trying the next safe target', async (t) => {
|
||||
const { createUploadAuditWriter } = require('../lib/upload-audit');
|
||||
for (const rejection of ['false', 'throw']) {
|
||||
await t.test(rejection, async () => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), `mhu-upload-audit-${rejection}-`));
|
||||
const first = path.join(directory, 'first', 'fileuploader.log');
|
||||
const second = path.join(directory, 'second', 'fileuploader.log');
|
||||
const targets = [first, second].map(targetPath => ({ path: targetPath, isFallback: true }));
|
||||
const persistedFallbacks = [];
|
||||
const writer = createUploadAuditWriter({
|
||||
fs,
|
||||
path,
|
||||
resolveUploadLogTarget: excluded => {
|
||||
const excludedPaths = excluded instanceof Set ? excluded : new Set(excluded ? [excluded] : []);
|
||||
return targets.find(target => !excludedPaths.has(target.path)) || null;
|
||||
},
|
||||
rotateLogFile: () => {},
|
||||
invalidateUploadLogTarget: () => {},
|
||||
persistFallbackLogPath: async targetPath => {
|
||||
persistedFallbacks.push(targetPath);
|
||||
if (targetPath === first) {
|
||||
if (rejection === 'throw') throw new Error('settings write failed');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
reportError: () => {},
|
||||
retryDelays: [0, 0, 0]
|
||||
});
|
||||
|
||||
assert.equal(await writer.append('# UPLOAD-PLAN {}\r\n', 'upload-plan'), true);
|
||||
assert.equal(writer.getActivePath(), path.join(directory, 'second', 'upload-audit.log'));
|
||||
assert.deepEqual(persistedFallbacks, [first, second]);
|
||||
assert.equal(fs.existsSync(path.join(directory, 'first', 'upload-audit.log')), false);
|
||||
assert.equal(fs.readFileSync(writer.getActivePath(), 'utf8'), '# UPLOAD-PLAN {}\r\n');
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('audit writer returns false only after every allowed fallback target is rejected', async () => {
|
||||
const { createUploadAuditWriter } = require('../lib/upload-audit');
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-upload-audit-exhausted-'));
|
||||
const targets = ['first', 'second'].map(name => ({ path: path.join(directory, name, 'fileuploader.log'), isFallback: true }));
|
||||
const persistedFallbacks = [];
|
||||
const writer = createUploadAuditWriter({
|
||||
fs,
|
||||
path,
|
||||
resolveUploadLogTarget: excluded => {
|
||||
const excludedPaths = excluded instanceof Set ? excluded : new Set(excluded ? [excluded] : []);
|
||||
return targets.find(target => !excludedPaths.has(target.path)) || null;
|
||||
},
|
||||
rotateLogFile: () => {},
|
||||
invalidateUploadLogTarget: () => {},
|
||||
persistFallbackLogPath: async targetPath => {
|
||||
persistedFallbacks.push(targetPath);
|
||||
if (persistedFallbacks.length === 2) throw new Error('settings write failed');
|
||||
return false;
|
||||
},
|
||||
reportError: () => {},
|
||||
retryDelays: [0, 0, 0]
|
||||
});
|
||||
|
||||
assert.equal(await writer.append('# UPLOAD-PLAN {}\r\n', 'upload-plan'), false);
|
||||
assert.deepEqual(persistedFallbacks, targets.map(target => target.path));
|
||||
assert.equal(writer.getActivePath(), null);
|
||||
assert.equal(fs.existsSync(path.join(directory, 'first', 'upload-audit.log')), false);
|
||||
assert.equal(fs.existsSync(path.join(directory, 'second', 'upload-audit.log')), false);
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('durable audit gate never creates a manager or adds jobs after a false or thrown audit', async () => {
|
||||
const { runAfterDurableAudit } = require('../lib/upload-audit');
|
||||
assert.equal(typeof runAfterDurableAudit, 'function');
|
||||
for (const actionName of ['manager', 'addJobs']) {
|
||||
for (const audit of [async () => false, async () => { throw new Error('audit failed'); }]) {
|
||||
let actions = 0;
|
||||
const result = await runAfterDurableAudit(audit, () => { actions += 1; return actionName; });
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(actions, 0);
|
||||
}
|
||||
}
|
||||
|
||||
let actions = 0;
|
||||
const result = await runAfterDurableAudit(async () => true, () => { actions += 1; return 'started'; });
|
||||
assert.deepEqual(result, { ok: true, value: 'started' });
|
||||
assert.equal(actions, 1);
|
||||
});
|
||||
|
||||
test('audit failure message is localized and tells the user how to retry', () => {
|
||||
const { getUploadAuditFailureMessage } = require('../lib/upload-audit');
|
||||
const german = getUploadAuditFailureMessage('de');
|
||||
const english = getUploadAuditFailureMessage('en');
|
||||
assert.match(german, /Log-Pfad/);
|
||||
assert.match(german, /erneut/);
|
||||
assert.match(english, /log path/i);
|
||||
assert.match(english, /try again/i);
|
||||
assert.notEqual(german, english);
|
||||
});
|
||||
|
||||
test('main process audits batch plans before creating or mutating upload work', () => {
|
||||
const mainSource = fs.readFileSync(path.join(__dirname, '..', 'main.js'), 'utf8');
|
||||
const startHandler = mainSource.slice(
|
||||
mainSource.indexOf("ipcMain.handle('start-upload'"),
|
||||
mainSource.indexOf("ipcMain.handle('cancel-upload'")
|
||||
);
|
||||
const addHandler = mainSource.slice(
|
||||
mainSource.indexOf("ipcMain.handle('add-jobs-to-batch'"),
|
||||
mainSource.indexOf("ipcMain.handle('finish-after-active'")
|
||||
);
|
||||
|
||||
assert.ok(startHandler.indexOf("appendUploadPlanAudit(batchPlan, 'start')") < startHandler.indexOf('new UploadManager('));
|
||||
assert.ok(startHandler.indexOf("appendUploadPlanAudit(batchPlan, 'start')") < startHandler.indexOf('persistRotation(pick)'));
|
||||
assert.ok(addHandler.indexOf("appendUploadPlanAudit(summarizeBatchPlan({ jobs }), 'add')") < addHandler.indexOf('persistRotation(pick)'));
|
||||
assert.ok(addHandler.indexOf("appendUploadPlanAudit(summarizeBatchPlan({ jobs }), 'add')") < addHandler.indexOf('registerGroups(sourceCleanupGroups)'));
|
||||
assert.ok(addHandler.indexOf("appendUploadPlanAudit(summarizeBatchPlan({ jobs }), 'add')") < addHandler.indexOf('batchManager.addJobs(tasks)'));
|
||||
assert.doesNotMatch(mainSource, /debugLog\(`source-cleanup:/);
|
||||
assert.doesNotMatch(mainSource, /debugLog\(`upload-plan:/);
|
||||
});
|
||||
|
||||
@@ -1,24 +1,11 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { assertUploadConfirmation, selectPublicUploadUrl } = require('../lib/upload-confirmation');
|
||||
const { assertUploadConfirmation } = require('../lib/upload-confirmation');
|
||||
|
||||
test('selects only a real public URL for logs and exports', () => {
|
||||
assert.equal(selectPublicUploadUrl({ download_url: 'https://doodstream.com/d/abc123', file_code: 'abc123' }), 'https://doodstream.com/d/abc123');
|
||||
assert.equal(selectPublicUploadUrl({ embed_url: 'https://voe.sx/e/abc123', file_code: 'abc123' }), 'https://voe.sx/e/abc123');
|
||||
assert.equal(selectPublicUploadUrl({ download_url: 'javascript:alert(1)', file_code: 'abc123' }), '');
|
||||
assert.equal(selectPublicUploadUrl({ file_code: 'abc123' }), '');
|
||||
});
|
||||
|
||||
test('materializes canonical Doodstream URLs from a confirmed file code', () => {
|
||||
assert.deepEqual(
|
||||
assertUploadConfirmation({ file_code: 'AB1', download_url: null, embed_url: null }, 'doodstream.com'),
|
||||
{
|
||||
file_code: 'AB1',
|
||||
download_url: 'https://doodstream.com/d/AB1',
|
||||
embed_url: 'https://doodstream.com/e/AB1'
|
||||
}
|
||||
);
|
||||
test('accepts a host-confirmed file code without a public URL', () => {
|
||||
const result = { file_code: 'AB1', download_url: null, embed_url: null };
|
||||
assert.equal(assertUploadConfirmation(result, 'doodstream.com'), result);
|
||||
});
|
||||
|
||||
test('accepts upload URLs for every supported hoster and its subdomains', () => {
|
||||
@@ -31,15 +18,7 @@ test('accepts upload URLs for every supported hoster and its subdomains', () =>
|
||||
];
|
||||
for (const [hoster, downloadUrl] of cases) {
|
||||
const result = { file_code: 'abc123', download_url: downloadUrl };
|
||||
const confirmed = assertUploadConfirmation(result, hoster);
|
||||
if (hoster === 'doodstream.com') {
|
||||
assert.deepEqual(confirmed, {
|
||||
...result,
|
||||
embed_url: 'https://doodstream.com/e/abc123'
|
||||
});
|
||||
} else {
|
||||
assert.equal(confirmed, result);
|
||||
}
|
||||
assert.equal(assertUploadConfirmation(result, hoster), result);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -69,40 +48,6 @@ test('accepts the Doodstream result domain returned by the current upload servic
|
||||
});
|
||||
});
|
||||
|
||||
test('rebuilds every accepted Doodstream transport URL from the file code', () => {
|
||||
const variants = [
|
||||
'http://dsvplay.com/d/DOODCODE1234?token=SYNTHETIC_SECRET#fragment',
|
||||
'https://edge.dsvplay.com/result/DOODCODE1234?session=SYNTHETIC_SESSION',
|
||||
'https://dood.to/e/DOODCODE1234',
|
||||
'https://dood.la/arbitrary/DOODCODE1234'
|
||||
];
|
||||
|
||||
for (const downloadUrl of variants) {
|
||||
assert.deepEqual(
|
||||
assertUploadConfirmation({ file_code: 'DOODCODE1234', download_url: downloadUrl }, 'doodstream.com'),
|
||||
{
|
||||
file_code: 'DOODCODE1234',
|
||||
download_url: 'https://doodstream.com/d/DOODCODE1234',
|
||||
embed_url: 'https://doodstream.com/e/DOODCODE1234'
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects code-only confirmations for hosters without canonical materialization', () => {
|
||||
assert.throws(
|
||||
() => assertUploadConfirmation({ file_code: 'BYSE123' }, 'byse.sx'),
|
||||
/Upload zu byse\.sx wurde nicht bestätigt/
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects non-HTTPS public URLs outside Doodstream transport normalization', () => {
|
||||
assert.throws(
|
||||
() => assertUploadConfirmation({ file_code: 'VOE123', download_url: 'http://voe.sx/VOE123' }, 'voe.sx'),
|
||||
/Upload zu voe\.sx wurde nicht bestätigt/
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects an upload URL from a different domain', () => {
|
||||
assert.throws(
|
||||
() => assertUploadConfirmation({ file_code: 'abc123', download_url: 'https://attacker.invalid/file/abc123' }, 'voe.sx'),
|
||||
|
||||
@@ -25,40 +25,3 @@ test('bereinigt Zugangsdaten und mehrere URLs aus Hosterantworten', () => {
|
||||
|
||||
assert.equal(result.responseSnippet, 'token=[redacted] one.example/a password: [redacted] two.example/b');
|
||||
});
|
||||
|
||||
test('behält sichere Transportdetails für eine konkrete Fehleranalyse', () => {
|
||||
const result = normalizeFailureDetails({
|
||||
phase: 'web-upload-confirmation',
|
||||
http: 502,
|
||||
contentType: 'application/json',
|
||||
safeEndpointHost: 'upload.doodstream.com',
|
||||
responseKind: 'json',
|
||||
retryable: true,
|
||||
payloadSnippet: 'json response (148 bytes)'
|
||||
});
|
||||
|
||||
assert.deepEqual(result, {
|
||||
phase: 'web-upload-confirmation',
|
||||
httpStatus: 502,
|
||||
contentType: 'application/json',
|
||||
endpointHost: 'upload.doodstream.com',
|
||||
responseKind: 'json',
|
||||
retryable: true,
|
||||
responseSnippet: 'json response (148 bytes)'
|
||||
});
|
||||
});
|
||||
|
||||
test('verwirft unsichere Transportfelder und unbenannte lange Geheimnisse', () => {
|
||||
const secret = 'SYNTHETIC_UNNAMED_SECRET_1234567890';
|
||||
const result = normalizeFailureDetails({
|
||||
phase: `upload ${secret}`,
|
||||
safeEndpointHost: `evil.example/${secret}`,
|
||||
responseKind: `json-${secret}`,
|
||||
payloadSnippet: `Authorization: Bearer ${secret}`
|
||||
});
|
||||
|
||||
assert.equal(result.phase, 'upload [redacted]');
|
||||
assert.equal(result.endpointHost, undefined);
|
||||
assert.equal(result.responseKind, undefined);
|
||||
assert.doesNotMatch(JSON.stringify(result), /SYNTHETIC_UNNAMED_SECRET/);
|
||||
});
|
||||
|
||||
@@ -1,402 +0,0 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const mainSource = fs.readFileSync(path.join(__dirname, '..', 'main.js'), 'utf8');
|
||||
|
||||
function loadMainFunction(name, nextName) {
|
||||
const start = mainSource.indexOf(`function ${name}(`);
|
||||
assert.notEqual(start, -1, `${name} must exist`);
|
||||
const end = mainSource.indexOf(`\nfunction ${nextName}(`, start + name.length);
|
||||
assert.notEqual(end, -1, `${nextName} must follow ${name}`);
|
||||
const context = vm.createContext({});
|
||||
vm.runInContext(`${mainSource.slice(start, end)}\nthis.loaded = ${name};`, context);
|
||||
return context.loaded;
|
||||
}
|
||||
|
||||
test('terminal finalization is delivered once per ready renderer generation and rejects stale acknowledgements', async () => {
|
||||
const createCoordinator = loadMainFunction('createUploadFinalizationCoordinator', 'createUploadFinalizationBarrier');
|
||||
const sent = [];
|
||||
const savedQueues = [];
|
||||
const scheduled = new Set();
|
||||
let deliverySequence = 0;
|
||||
const coordinator = createCoordinator({
|
||||
send: payload => {
|
||||
sent.push(JSON.parse(JSON.stringify(payload)));
|
||||
return true;
|
||||
},
|
||||
saveQueue: async pendingQueue => {
|
||||
savedQueues.push(JSON.parse(JSON.stringify(pendingQueue)));
|
||||
},
|
||||
schedule: callback => {
|
||||
const token = { callback };
|
||||
scheduled.add(token);
|
||||
return token;
|
||||
},
|
||||
cancelSchedule: token => scheduled.delete(token),
|
||||
createFinalizationId: () => 'finalization-1',
|
||||
createDeliveryId: () => `delivery-${++deliverySequence}`,
|
||||
timeoutMs: 15000
|
||||
});
|
||||
|
||||
const completion = coordinator.request({ id: 'batch-1', files: [] }, true);
|
||||
let settled = false;
|
||||
completion.then(() => { settled = true; });
|
||||
|
||||
assert.equal(sent.length, 0);
|
||||
assert.equal(coordinator.rendererReady(), 1);
|
||||
assert.equal(coordinator.rendererReady(), 1);
|
||||
assert.equal(sent.length, 1);
|
||||
assert.equal(sent[0].finalizationId, 'finalization-1');
|
||||
assert.equal(sent[0].deliveryId, 'delivery-1');
|
||||
assert.equal(sent[0].historyPersisted, true);
|
||||
|
||||
coordinator.rendererBlocked();
|
||||
assert.equal(coordinator.rendererReady(), 2);
|
||||
assert.equal(sent.length, 2);
|
||||
assert.equal(sent[1].deliveryId, 'delivery-2');
|
||||
|
||||
const stale = await coordinator.complete({
|
||||
finalizationId: 'finalization-1',
|
||||
deliveryId: 'delivery-1',
|
||||
pendingQueue: { queueJobs: [{ id: 'stale' }] }
|
||||
});
|
||||
await Promise.resolve();
|
||||
|
||||
assert.equal(stale, false);
|
||||
assert.equal(settled, false);
|
||||
assert.equal(savedQueues.length, 0);
|
||||
|
||||
const accepted = await coordinator.complete({
|
||||
finalizationId: 'finalization-1',
|
||||
deliveryId: 'delivery-2',
|
||||
pendingQueue: { queueJobs: [{ id: 'terminal' }] }
|
||||
});
|
||||
|
||||
assert.equal(accepted, true);
|
||||
assert.equal(await completion, true);
|
||||
assert.deepEqual(savedQueues, [{ queueJobs: [{ id: 'terminal' }] }]);
|
||||
assert.equal(scheduled.size, 0);
|
||||
|
||||
coordinator.rendererBlocked();
|
||||
coordinator.rendererReady();
|
||||
assert.equal(sent.length, 2);
|
||||
});
|
||||
|
||||
test('history failure acknowledgement requires every terminal job in the durable queue snapshot', async () => {
|
||||
const createCoordinator = loadMainFunction('createUploadFinalizationCoordinator', 'createUploadFinalizationBarrier');
|
||||
const savedQueues = [];
|
||||
let deliveryId = null;
|
||||
const coordinator = createCoordinator({
|
||||
send: payload => {
|
||||
deliveryId = payload.deliveryId;
|
||||
return true;
|
||||
},
|
||||
saveQueue: async pendingQueue => savedQueues.push(pendingQueue),
|
||||
schedule: () => null,
|
||||
cancelSchedule: () => {},
|
||||
createFinalizationId: () => 'history-failure-finalization',
|
||||
createDeliveryId: () => 'history-failure-delivery'
|
||||
});
|
||||
coordinator.rendererReady();
|
||||
const completion = coordinator.request({
|
||||
files: [{ results: [
|
||||
{ jobId: 'done-a', status: 'done' },
|
||||
{ jobId: 'error-b', status: 'error' }
|
||||
] }]
|
||||
}, false);
|
||||
|
||||
const accepted = await coordinator.complete({
|
||||
finalizationId: 'history-failure-finalization',
|
||||
deliveryId,
|
||||
pendingQueue: { queueJobs: [{ id: 'done-a', status: 'done' }] }
|
||||
});
|
||||
|
||||
assert.equal(accepted, false);
|
||||
assert.equal(await completion, false);
|
||||
assert.deepEqual(savedQueues, []);
|
||||
});
|
||||
|
||||
test('history failure acknowledgement requires exact terminal failure evidence', async () => {
|
||||
const createCoordinator = loadMainFunction('createUploadFinalizationCoordinator', 'createUploadFinalizationBarrier');
|
||||
const cases = [
|
||||
{
|
||||
name: 'error',
|
||||
summary: { error: 'upload rejected', failureDetails: { code: 429, reason: 'rate-limit' }, remoteCommitUncertain: true },
|
||||
queue: { error: 'different error', failureDetails: { reason: 'rate-limit', code: 429 }, remoteCommitUncertain: true }
|
||||
},
|
||||
{
|
||||
name: 'failureDetails',
|
||||
summary: { error: 'upload rejected', failureDetails: { code: 429, reason: 'rate-limit' }, remoteCommitUncertain: true },
|
||||
queue: { error: 'upload rejected', failureDetails: { code: 500, reason: 'rate-limit' }, remoteCommitUncertain: true }
|
||||
},
|
||||
{
|
||||
name: 'remoteCommitUncertain',
|
||||
summary: { error: 'upload rejected', failureDetails: { code: 429, reason: 'rate-limit' }, remoteCommitUncertain: true },
|
||||
queue: { error: 'upload rejected', failureDetails: { reason: 'rate-limit', code: 429 }, remoteCommitUncertain: false }
|
||||
}
|
||||
];
|
||||
|
||||
for (const entry of cases) {
|
||||
let deliveryId = null;
|
||||
const savedQueues = [];
|
||||
const coordinator = createCoordinator({
|
||||
send: payload => {
|
||||
deliveryId = payload.deliveryId;
|
||||
return true;
|
||||
},
|
||||
saveQueue: async pendingQueue => savedQueues.push(pendingQueue),
|
||||
schedule: () => null,
|
||||
cancelSchedule: () => {},
|
||||
createFinalizationId: () => `failure-${entry.name}`,
|
||||
createDeliveryId: () => `delivery-${entry.name}`
|
||||
});
|
||||
coordinator.rendererReady();
|
||||
const completion = coordinator.request({
|
||||
files: [{ results: [{ jobId: 'error-job', status: 'error', ...entry.summary }] }]
|
||||
}, false);
|
||||
|
||||
const accepted = await coordinator.complete({
|
||||
finalizationId: `failure-${entry.name}`,
|
||||
deliveryId,
|
||||
pendingQueue: { queueJobs: [{ id: 'error-job', status: 'error', ...entry.queue }] }
|
||||
});
|
||||
|
||||
assert.equal(accepted, false, entry.name);
|
||||
assert.equal(await completion, false, entry.name);
|
||||
assert.deepEqual(savedQueues, [], entry.name);
|
||||
}
|
||||
});
|
||||
|
||||
test('history failure acknowledgement accepts complete terminal failure evidence', async () => {
|
||||
const createCoordinator = loadMainFunction('createUploadFinalizationCoordinator', 'createUploadFinalizationBarrier');
|
||||
let deliveryId = null;
|
||||
const savedQueues = [];
|
||||
const coordinator = createCoordinator({
|
||||
send: payload => {
|
||||
deliveryId = payload.deliveryId;
|
||||
return true;
|
||||
},
|
||||
saveQueue: async pendingQueue => savedQueues.push(pendingQueue),
|
||||
schedule: () => null,
|
||||
cancelSchedule: () => {},
|
||||
createFinalizationId: () => 'complete-error-finalization',
|
||||
createDeliveryId: () => 'complete-error-delivery'
|
||||
});
|
||||
coordinator.rendererReady();
|
||||
const completion = coordinator.request({
|
||||
files: [{ results: [{
|
||||
jobId: 'error-job',
|
||||
status: 'error',
|
||||
error: 'upload rejected',
|
||||
failureDetails: { code: 429, reason: 'rate-limit' },
|
||||
remoteCommitUncertain: true
|
||||
}] }]
|
||||
}, false);
|
||||
const pendingQueue = { queueJobs: [{
|
||||
id: 'error-job',
|
||||
status: 'error',
|
||||
error: 'upload rejected',
|
||||
failureDetails: { reason: 'rate-limit', code: 429 },
|
||||
remoteCommitUncertain: true
|
||||
}] };
|
||||
|
||||
const accepted = await coordinator.complete({
|
||||
finalizationId: 'complete-error-finalization',
|
||||
deliveryId,
|
||||
pendingQueue
|
||||
});
|
||||
|
||||
assert.equal(accepted, true);
|
||||
assert.equal(await completion, true);
|
||||
assert.deepEqual(savedQueues, [pendingQueue]);
|
||||
});
|
||||
|
||||
test('history failure acknowledgement compares complete done results independent of object key order', async () => {
|
||||
const createCoordinator = loadMainFunction('createUploadFinalizationCoordinator', 'createUploadFinalizationBarrier');
|
||||
let deliveryId = null;
|
||||
const savedQueues = [];
|
||||
const coordinator = createCoordinator({
|
||||
send: payload => {
|
||||
deliveryId = payload.deliveryId;
|
||||
return true;
|
||||
},
|
||||
saveQueue: async pendingQueue => savedQueues.push(pendingQueue),
|
||||
schedule: () => null,
|
||||
cancelSchedule: () => {},
|
||||
createFinalizationId: () => 'done-result-finalization',
|
||||
createDeliveryId: () => 'done-result-delivery'
|
||||
});
|
||||
coordinator.rendererReady();
|
||||
const completion = coordinator.request({
|
||||
files: [{ results: [{
|
||||
jobId: 'done-job',
|
||||
status: 'done',
|
||||
download_url: 'https://doodstream.com/d/abc123',
|
||||
embed_url: 'https://doodstream.com/e/abc123',
|
||||
file_code: 'abc123'
|
||||
}] }]
|
||||
}, false);
|
||||
const pendingQueue = { queueJobs: [{
|
||||
id: 'done-job',
|
||||
status: 'done',
|
||||
result: {
|
||||
file_code: 'abc123',
|
||||
embed_url: 'https://doodstream.com/e/abc123',
|
||||
download_url: 'https://doodstream.com/d/abc123'
|
||||
}
|
||||
}] };
|
||||
|
||||
const accepted = await coordinator.complete({
|
||||
finalizationId: 'done-result-finalization',
|
||||
deliveryId,
|
||||
pendingQueue
|
||||
});
|
||||
|
||||
assert.equal(accepted, true);
|
||||
assert.equal(await completion, true);
|
||||
assert.deepEqual(savedQueues, [pendingQueue]);
|
||||
});
|
||||
|
||||
test('history failure acknowledgement rejects incomplete done results', async () => {
|
||||
const createCoordinator = loadMainFunction('createUploadFinalizationCoordinator', 'createUploadFinalizationBarrier');
|
||||
let deliveryId = null;
|
||||
const savedQueues = [];
|
||||
const coordinator = createCoordinator({
|
||||
send: payload => {
|
||||
deliveryId = payload.deliveryId;
|
||||
return true;
|
||||
},
|
||||
saveQueue: async pendingQueue => savedQueues.push(pendingQueue),
|
||||
schedule: () => null,
|
||||
cancelSchedule: () => {},
|
||||
createFinalizationId: () => 'incomplete-done-finalization',
|
||||
createDeliveryId: () => 'incomplete-done-delivery'
|
||||
});
|
||||
coordinator.rendererReady();
|
||||
const completion = coordinator.request({
|
||||
files: [{ results: [{
|
||||
jobId: 'done-job',
|
||||
status: 'done',
|
||||
download_url: 'https://doodstream.com/d/abc123',
|
||||
embed_url: 'https://doodstream.com/e/abc123',
|
||||
file_code: 'abc123'
|
||||
}] }]
|
||||
}, false);
|
||||
|
||||
const accepted = await coordinator.complete({
|
||||
finalizationId: 'incomplete-done-finalization',
|
||||
deliveryId,
|
||||
pendingQueue: { queueJobs: [{
|
||||
id: 'done-job',
|
||||
status: 'done',
|
||||
result: { download_url: 'https://doodstream.com/d/abc123', embed_url: null, file_code: 'abc123' }
|
||||
}] }
|
||||
});
|
||||
|
||||
assert.equal(accepted, false);
|
||||
assert.equal(await completion, false);
|
||||
assert.deepEqual(savedQueues, []);
|
||||
});
|
||||
|
||||
test('history failure remains visible to the durable terminal finalization barrier', async () => {
|
||||
const createBarrier = loadMainFunction('createUploadFinalizationBarrier', 'requestUploadFinalization');
|
||||
const recoveries = [];
|
||||
const finalizationCalls = [];
|
||||
const errors = [];
|
||||
const barrier = createBarrier({
|
||||
appendHistory: async () => { throw new Error('history unavailable'); },
|
||||
saveRecovery: async value => { recoveries.push(value === null ? null : JSON.parse(JSON.stringify(value))); },
|
||||
requestFinalization: async (summary, historyPersisted) => {
|
||||
finalizationCalls.push({ summary: JSON.parse(JSON.stringify(summary)), historyPersisted });
|
||||
return false;
|
||||
},
|
||||
buildTerminalSnapshots: summary => summary.files[0].results.map(result => ({ jobId: result.jobId, status: result.status })),
|
||||
now: () => '2026-08-13T12:00:00.000Z',
|
||||
onError: (phase, error) => errors.push([phase, error.message])
|
||||
});
|
||||
const summary = {
|
||||
id: 'skipped-batch',
|
||||
files: [{ name: 'missing-account.bin', results: [{ jobId: 'skip-1', status: 'skipped' }] }]
|
||||
};
|
||||
|
||||
const result = await barrier.finalize(summary, {
|
||||
id: 'recovery-skipped',
|
||||
startedAt: '2026-08-13T11:59:59.000Z',
|
||||
jobIds: ['skip-1']
|
||||
});
|
||||
|
||||
assert.equal(result.historyPersisted, false);
|
||||
assert.equal(result.queuePersisted, false);
|
||||
assert.equal(result.terminalRecoveryPersisted, true);
|
||||
assert.deepEqual(finalizationCalls, [{ summary, historyPersisted: false }]);
|
||||
assert.deepEqual(recoveries, [{
|
||||
id: 'recovery-skipped',
|
||||
startedAt: '2026-08-13T11:59:59.000Z',
|
||||
jobIds: ['skip-1'],
|
||||
settledAt: '2026-08-13T12:00:00.000Z',
|
||||
historyPending: true,
|
||||
terminalJobs: [{ jobId: 'skip-1', status: 'skipped' }]
|
||||
}]);
|
||||
assert.deepEqual(errors, [['history', 'history unavailable']]);
|
||||
});
|
||||
|
||||
test('terminal recovery clears after history and queue are durable', async () => {
|
||||
const createBarrier = loadMainFunction('createUploadFinalizationBarrier', 'requestUploadFinalization');
|
||||
const recoveries = [];
|
||||
const barrier = createBarrier({
|
||||
appendHistory: async () => true,
|
||||
saveRecovery: async value => { recoveries.push(value === null ? null : JSON.parse(JSON.stringify(value))); },
|
||||
requestFinalization: async (_summary, historyPersisted) => historyPersisted,
|
||||
buildTerminalSnapshots: () => [{ jobId: 'done-1', status: 'done' }],
|
||||
now: () => '2026-08-13T12:01:00.000Z',
|
||||
onError: () => {}
|
||||
});
|
||||
|
||||
const result = await barrier.finalize({ id: 'done-batch', files: [] }, {
|
||||
id: 'recovery-done',
|
||||
startedAt: '2026-08-13T12:00:00.000Z',
|
||||
jobIds: ['done-1']
|
||||
});
|
||||
|
||||
assert.equal(result.historyPersisted, true);
|
||||
assert.equal(result.queuePersisted, true);
|
||||
assert.equal(result.terminalRecoveryPersisted, true);
|
||||
assert.equal(result.recoveryCleared, true);
|
||||
assert.equal(recoveries.length, 2);
|
||||
assert.equal(recoveries[1], null);
|
||||
});
|
||||
|
||||
test('terminal recovery remains when queue is durable but history is pending', async () => {
|
||||
const createBarrier = loadMainFunction('createUploadFinalizationBarrier', 'requestUploadFinalization');
|
||||
const recoveries = [];
|
||||
const barrier = createBarrier({
|
||||
appendHistory: async () => { throw new Error('history unavailable'); },
|
||||
saveRecovery: async value => { recoveries.push(value === null ? null : JSON.parse(JSON.stringify(value))); },
|
||||
requestFinalization: async () => true,
|
||||
buildTerminalSnapshots: () => [{ jobId: 'done-1', status: 'done' }],
|
||||
now: () => '2026-08-13T12:02:00.000Z',
|
||||
onError: () => {}
|
||||
});
|
||||
|
||||
const result = await barrier.finalize({ id: 'history-pending-batch', files: [] }, {
|
||||
id: 'recovery-history-pending',
|
||||
startedAt: '2026-08-13T12:01:00.000Z',
|
||||
jobIds: ['done-1']
|
||||
});
|
||||
|
||||
assert.equal(result.historyPersisted, false);
|
||||
assert.equal(result.queuePersisted, true);
|
||||
assert.equal(result.terminalRecoveryPersisted, true);
|
||||
assert.equal(result.recoveryCleared, false);
|
||||
assert.deepEqual(recoveries, [{
|
||||
id: 'recovery-history-pending',
|
||||
startedAt: '2026-08-13T12:01:00.000Z',
|
||||
jobIds: ['done-1'],
|
||||
settledAt: '2026-08-13T12:02:00.000Z',
|
||||
historyPending: true,
|
||||
terminalJobs: [{ jobId: 'done-1', status: 'done' }]
|
||||
}]);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -70,119 +70,6 @@ describe('UploadManager', () => {
|
||||
assert.ok(events.length > 0, 'should emit at least one progress event');
|
||||
});
|
||||
|
||||
it('waits outside the upload schedule and starts on a live settings update without consuming an attempt', async () => {
|
||||
const mgr = new UploadManager({}, {
|
||||
uploadSchedule: { enabled: true, weekdays: [], start: '08:00', end: '09:00' }
|
||||
});
|
||||
const events = [];
|
||||
mgr.on('progress', event => events.push(event));
|
||||
const done = new Promise(resolve => mgr.once('batch-done', resolve));
|
||||
|
||||
const batch = mgr.startBatch([{
|
||||
file: '/test/scheduled.mp4',
|
||||
hoster: 'doodstream.com',
|
||||
apiKey: 'key1',
|
||||
jobId: 'scheduled-job'
|
||||
}]);
|
||||
await new Promise(setImmediate);
|
||||
await new Promise(setImmediate);
|
||||
|
||||
assert.equal(mockUploadFile.mock.calls.length, 0);
|
||||
assert.equal(events.some(event => event.status === 'getting-server' || event.status === 'uploading'), false);
|
||||
|
||||
mgr.updateSettings(null, { uploadSchedule: { enabled: false } });
|
||||
await batch;
|
||||
const summary = await done;
|
||||
const result = summary.files[0].results[0];
|
||||
|
||||
assert.equal(mockUploadFile.mock.calls.length, 1);
|
||||
assert.equal(result.status, 'done');
|
||||
assert.equal(result.attempt, 1);
|
||||
});
|
||||
|
||||
it('cancels a schedule waiter without starting transport or recording a failure', async () => {
|
||||
const mgr = new UploadManager({}, {
|
||||
uploadSchedule: { enabled: true, weekdays: [], start: '08:00', end: '09:00' }
|
||||
});
|
||||
const done = new Promise(resolve => mgr.once('batch-done', resolve));
|
||||
const batch = mgr.startBatch([{
|
||||
file: '/test/scheduled-cancel.mp4',
|
||||
hoster: 'doodstream.com',
|
||||
apiKey: 'key1',
|
||||
jobId: 'scheduled-cancel-job'
|
||||
}]);
|
||||
await new Promise(setImmediate);
|
||||
mgr.cancel();
|
||||
await batch;
|
||||
const summary = await done;
|
||||
const result = summary.files[0].results[0];
|
||||
|
||||
assert.equal(mockUploadFile.mock.calls.length, 0);
|
||||
assert.equal(result.status, 'aborted');
|
||||
assert.equal(result.attempt, 0);
|
||||
});
|
||||
|
||||
it('finishAfterActive wakes a closed schedule waiter without starting transport', async () => {
|
||||
const mgr = new UploadManager({}, {
|
||||
uploadSchedule: { enabled: true, weekdays: [], start: '08:00', end: '09:00' }
|
||||
});
|
||||
const done = new Promise(resolve => mgr.once('batch-done', resolve));
|
||||
const batch = mgr.startBatch([{
|
||||
jobId: 'scheduled-stop',
|
||||
file: '/test/scheduled-stop.mp4',
|
||||
hoster: 'doodstream.com',
|
||||
apiKey: 'key1'
|
||||
}]);
|
||||
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
mgr.finishAfterActive();
|
||||
await batch;
|
||||
const summary = await done;
|
||||
const result = summary.files[0].results[0];
|
||||
|
||||
assert.equal(mockUploadFile.mock.calls.length, 0);
|
||||
assert.equal(result.status, 'aborted');
|
||||
assert.equal(result.attempt, 0);
|
||||
});
|
||||
|
||||
it('releases an acquired slot when the schedule closes and re-admits after reopening', async () => {
|
||||
let releaseFirst;
|
||||
mockUploadFile.mock.mockImplementation(async (hoster, filePath) => {
|
||||
if (filePath === '/test/schedule-first.mp4') {
|
||||
return new Promise(resolve => { releaseFirst = () => resolve({ download_url: `https://${hoster}/first`, file_code: 'first' }); });
|
||||
}
|
||||
return { download_url: `https://${hoster}/second`, file_code: 'second' };
|
||||
});
|
||||
const mgr = new UploadManager({
|
||||
'doodstream.com': { retries: 0, parallelCount: 1 }
|
||||
}, {
|
||||
uploadSchedule: { enabled: false }
|
||||
});
|
||||
const done = new Promise(resolve => mgr.once('batch-done', resolve));
|
||||
const batch = mgr.startBatch([
|
||||
{ file: '/test/schedule-first.mp4', hoster: 'doodstream.com', apiKey: 'key1', jobId: 'schedule-first' },
|
||||
{ file: '/test/schedule-second.mp4', hoster: 'doodstream.com', apiKey: 'key1', jobId: 'schedule-second' }
|
||||
]);
|
||||
for (let index = 0; index < 50 && typeof releaseFirst !== 'function'; index++) await new Promise(setImmediate);
|
||||
assert.equal(typeof releaseFirst, 'function');
|
||||
|
||||
mgr.updateSettings(null, {
|
||||
uploadSchedule: { enabled: true, weekdays: [], start: '08:00', end: '09:00' }
|
||||
});
|
||||
releaseFirst();
|
||||
for (let index = 0; index < 10; index++) await new Promise(setImmediate);
|
||||
|
||||
assert.equal(mockUploadFile.mock.calls.length, 1);
|
||||
assert.equal(mgr._getSemaphore('doodstream.com').active, 0);
|
||||
|
||||
mgr.updateSettings(null, { uploadSchedule: { enabled: false } });
|
||||
await batch;
|
||||
const summary = await done;
|
||||
|
||||
assert.equal(mockUploadFile.mock.calls.length, 2);
|
||||
assert.equal(summary.succeeded, 2);
|
||||
});
|
||||
|
||||
it('emits job-settled after releasing job resources', async () => {
|
||||
const mgr = new UploadManager({});
|
||||
let settled;
|
||||
@@ -264,8 +151,8 @@ describe('UploadManager', () => {
|
||||
mgr.on('batch-done', (s) => { summary = s; });
|
||||
|
||||
await mgr.startBatch([
|
||||
{ file: '/test/video1.mp4', fileKey: 'video-one', hoster: 'doodstream.com', apiKey: 'key1' },
|
||||
{ file: '/test/video2.mp4', fileKey: 'video-two', hoster: 'doodstream.com', apiKey: 'key1' }
|
||||
{ file: '/test/video1.mp4', hoster: 'doodstream.com', apiKey: 'key1' },
|
||||
{ file: '/test/video2.mp4', hoster: 'doodstream.com', apiKey: 'key1' }
|
||||
]);
|
||||
|
||||
assert.ok(summary);
|
||||
@@ -273,8 +160,6 @@ describe('UploadManager', () => {
|
||||
assert.equal(summary.succeeded, 2);
|
||||
assert.equal(summary.failed, 0);
|
||||
assert.equal(summary.files.length, 2);
|
||||
assert.deepEqual(summary.files.map(file => file.fileKey), ['video-one', 'video-two']);
|
||||
assert.ok(summary.files.flatMap(file => file.results).every(result => typeof result.jobId === 'string' && result.jobId.length > 0));
|
||||
});
|
||||
|
||||
it('emits a final idle stats snapshot after a normal batch', async () => {
|
||||
@@ -417,50 +302,6 @@ describe('UploadManager', () => {
|
||||
assert.equal(statuses.filter((status) => status === 'aborted').length, 0);
|
||||
});
|
||||
|
||||
it('cancels one selected job followed by all 100 active uploads without retaining resources', async () => {
|
||||
let active = 0;
|
||||
let started = 0;
|
||||
mockUploadFile.mock.mockImplementation(async (hoster, filePath, apiKey, onProgress, signal) => {
|
||||
started++;
|
||||
active++;
|
||||
await new Promise((resolve, reject) => {
|
||||
signal.addEventListener('abort', () => {
|
||||
active--;
|
||||
reject(new Error('Aborted'));
|
||||
}, { once: true });
|
||||
});
|
||||
});
|
||||
|
||||
const mgr = new UploadManager({
|
||||
'doodstream.com': { retries: 0, parallelCount: 100, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 }
|
||||
}, { parallelUploadCount: 100 });
|
||||
const tasks = Array.from({ length: 100 }, (_, index) => ({
|
||||
jobId: `active-cancel-${index}`,
|
||||
file: `/test/active-cancel-${index}.mp4`,
|
||||
hoster: 'doodstream.com',
|
||||
apiKey: 'key1'
|
||||
}));
|
||||
const batchPromise = mgr.startBatch(tasks);
|
||||
|
||||
for (let attempt = 0; attempt < 200 && started < 100; attempt++) {
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
}
|
||||
assert.equal(started, 100);
|
||||
assert.equal(active, 100);
|
||||
|
||||
const cancelStartedAt = performance.now();
|
||||
mgr.cancelJobs(['active-cancel-0']);
|
||||
mgr.cancel();
|
||||
await batchPromise;
|
||||
const cancelDuration = performance.now() - cancelStartedAt;
|
||||
|
||||
assert.ok(cancelDuration < 1000, `cancel took ${cancelDuration.toFixed(1)}ms`);
|
||||
assert.equal(active, 0);
|
||||
assert.equal(mgr.running, false);
|
||||
assert.equal(mgr.jobAbortControllers.size, 0);
|
||||
assert.equal(mgr.statsInterval, null);
|
||||
});
|
||||
|
||||
it('maxSizeMb filter skips oversized files', async () => {
|
||||
fakeFileSize = 5 * 1024 * 1024; // 5 MB
|
||||
|
||||
@@ -652,269 +493,7 @@ describe('UploadManager', () => {
|
||||
|
||||
assert.equal(settled.at(-1).status, 'aborted');
|
||||
assert.equal(progress.some(event => event.status === 'done'), false);
|
||||
assert.equal(progress.at(-1).remoteCommitUncertain, true);
|
||||
assert.equal(summary.succeeded, 0);
|
||||
assert.equal(summary.files[0].results[0].remoteCommitUncertain, true);
|
||||
});
|
||||
|
||||
it('keeps a confirmed result when cancellation arrives after confirmation', async () => {
|
||||
const mgr = new UploadManager({});
|
||||
const originalExecuteUpload = mgr._executeUpload.bind(mgr);
|
||||
mgr._executeUpload = async (...args) => {
|
||||
const result = await originalExecuteUpload(...args);
|
||||
queueMicrotask(() => mgr.cancelJobs(['confirmed-before-cancel']));
|
||||
return result;
|
||||
};
|
||||
const settled = [];
|
||||
let summary = null;
|
||||
mgr.on('job-settled', event => settled.push(event));
|
||||
mgr.on('batch-done', value => { summary = value; });
|
||||
|
||||
await mgr.startBatch([{
|
||||
jobId: 'confirmed-before-cancel',
|
||||
file: '/test/confirmed-before-cancel.mp4',
|
||||
hoster: 'doodstream.com',
|
||||
apiKey: 'key1'
|
||||
}]);
|
||||
|
||||
assert.equal(settled.at(-1).status, 'done');
|
||||
assert.equal(summary.succeeded, 1);
|
||||
});
|
||||
|
||||
it('finishAfterActive prevents a semaphore waiter from starting', async () => {
|
||||
let releaseFirst;
|
||||
let markFirstStarted;
|
||||
const firstGate = new Promise(resolve => { releaseFirst = resolve; });
|
||||
const firstStarted = new Promise(resolve => { markFirstStarted = resolve; });
|
||||
const uploads = [];
|
||||
mockUploadFile.mock.mockImplementation(async (hoster, filePath) => {
|
||||
uploads.push(filePath);
|
||||
if (filePath === '/test/active-before-stop.mp4') {
|
||||
markFirstStarted();
|
||||
await firstGate;
|
||||
}
|
||||
return { download_url: `https://${hoster}/d/ok123`, embed_url: null, file_code: 'ok123' };
|
||||
});
|
||||
const mgr = new UploadManager({
|
||||
'doodstream.com': { retries: 0, parallelCount: 1, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 }
|
||||
});
|
||||
const settled = new Map();
|
||||
mgr.on('job-settled', event => settled.set(event.jobId, event.status));
|
||||
const batchPromise = mgr.startBatch([
|
||||
{ jobId: 'active-before-stop', file: '/test/active-before-stop.mp4', hoster: 'doodstream.com', apiKey: 'key1' },
|
||||
{ jobId: 'semaphore-waiter', file: '/test/semaphore-waiter.mp4', hoster: 'doodstream.com', apiKey: 'key1' }
|
||||
]);
|
||||
|
||||
await firstStarted;
|
||||
for (let index = 0; index < 50 && mgr._getSemaphore('doodstream.com').pending === 0; index++) {
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
}
|
||||
assert.equal(mgr._getSemaphore('doodstream.com').pending, 1);
|
||||
mgr.finishAfterActive();
|
||||
releaseFirst();
|
||||
await batchPromise;
|
||||
|
||||
assert.deepEqual(uploads, ['/test/active-before-stop.mp4']);
|
||||
assert.equal(settled.get('active-before-stop'), 'done');
|
||||
assert.equal(settled.get('semaphore-waiter'), 'aborted');
|
||||
});
|
||||
|
||||
it('finishAfterActive prevents an interval waiter from starting', async () => {
|
||||
let releaseInterval;
|
||||
let markIntervalEntered;
|
||||
const intervalGate = new Promise(resolve => { releaseInterval = resolve; });
|
||||
const intervalEntered = new Promise(resolve => { markIntervalEntered = resolve; });
|
||||
const mgr = new UploadManager({
|
||||
'doodstream.com': { retries: 0, parallelCount: 1, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 1, maxSizeMb: 0 }
|
||||
});
|
||||
mgr._waitForInterval = async (hoster, intervalMs, signal, acquireSlots) => {
|
||||
markIntervalEntered();
|
||||
await intervalGate;
|
||||
await acquireSlots();
|
||||
};
|
||||
let settled;
|
||||
mgr.on('job-settled', event => { settled = event; });
|
||||
const batchPromise = mgr.startBatch([{
|
||||
jobId: 'interval-waiter-stop',
|
||||
file: '/test/interval-waiter-stop.mp4',
|
||||
hoster: 'doodstream.com',
|
||||
apiKey: 'key1'
|
||||
}]);
|
||||
|
||||
await intervalEntered;
|
||||
mgr.finishAfterActive();
|
||||
releaseInterval();
|
||||
await batchPromise;
|
||||
|
||||
assert.equal(mockUploadFile.mock.calls.length, 0);
|
||||
assert.equal(settled.status, 'aborted');
|
||||
});
|
||||
|
||||
it('finishAfterActive prevents a suspect-resolution waiter from starting', async () => {
|
||||
let releaseSuspect;
|
||||
let markSuspectEntered;
|
||||
const suspectGate = new Promise(resolve => { releaseSuspect = resolve; });
|
||||
const suspectEntered = new Promise(resolve => { markSuspectEntered = resolve; });
|
||||
const mgr = new UploadManager({});
|
||||
mgr._waitForSuspectResolution = async () => {
|
||||
markSuspectEntered();
|
||||
await suspectGate;
|
||||
};
|
||||
let settled;
|
||||
mgr.on('job-settled', event => { settled = event; });
|
||||
const batchPromise = mgr.startBatch([{
|
||||
jobId: 'suspect-waiter-stop',
|
||||
file: '/test/suspect-waiter-stop.mp4',
|
||||
hoster: 'doodstream.com',
|
||||
apiKey: 'key1'
|
||||
}]);
|
||||
|
||||
await suspectEntered;
|
||||
mgr.finishAfterActive();
|
||||
releaseSuspect();
|
||||
await batchPromise;
|
||||
|
||||
assert.equal(mockUploadFile.mock.calls.length, 0);
|
||||
assert.equal(settled.status, 'aborted');
|
||||
});
|
||||
|
||||
it('does not start a suspect alternate that became a failed account while waiting', async () => {
|
||||
const mgr = new UploadManager(
|
||||
{ 'byse.sx': { retries: 0, parallelCount: 2, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 } },
|
||||
{},
|
||||
{ 'byse.sx': [{ id: 'alternate', apiKey: 'alternate-key' }] }
|
||||
);
|
||||
const controller = new AbortController();
|
||||
const task = {
|
||||
file: '/test/suspect-waiter.mkv',
|
||||
hoster: 'byse.sx',
|
||||
accountId: 'alternate',
|
||||
apiKey: 'alternate-key',
|
||||
jobId: 'suspect-waiter'
|
||||
};
|
||||
mgr._beginSuspectResolution('byse.sx', 'suspect-owner');
|
||||
|
||||
const attempt = mgr._executeUploadWithAdmission(
|
||||
task,
|
||||
() => {},
|
||||
controller.signal,
|
||||
null,
|
||||
{ ok: true, isVideoLike: true },
|
||||
fakeFileSize,
|
||||
false,
|
||||
task.jobId
|
||||
);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
mgr._failedAccounts.set('byse.sx:alternate', true);
|
||||
mgr._endSuspectResolution('byse.sx', 'suspect-owner');
|
||||
|
||||
await assert.rejects(attempt, error => error.accountUnavailable === true);
|
||||
assert.equal(mockUploadFile.mock.calls.length, 0);
|
||||
});
|
||||
|
||||
it('finishAfterActive prevents a recovery-admission waiter from starting', async () => {
|
||||
let releaseFirst;
|
||||
let markFirstStarted;
|
||||
const firstGate = new Promise(resolve => { releaseFirst = resolve; });
|
||||
const firstStarted = new Promise(resolve => { markFirstStarted = resolve; });
|
||||
const uploads = [];
|
||||
mockUploadFile.mock.mockImplementation(async (hoster, filePath) => {
|
||||
uploads.push(filePath);
|
||||
if (filePath === '/test/first/shared-title.mp4') {
|
||||
markFirstStarted();
|
||||
await firstGate;
|
||||
}
|
||||
return { download_url: `https://${hoster}/d/ok123`, embed_url: null, file_code: `ok-${uploads.length}` };
|
||||
});
|
||||
const mgr = new UploadManager({
|
||||
'doodstream.com': { retries: 0, parallelCount: 2, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 }
|
||||
});
|
||||
const settled = new Map();
|
||||
mgr.on('job-settled', event => settled.set(event.jobId, event.status));
|
||||
const batchPromise = mgr.startBatch([
|
||||
{ jobId: 'recovery-active', file: '/test/first/shared-title.mp4', hoster: 'doodstream.com', apiKey: 'key1' },
|
||||
{ jobId: 'recovery-waiter', file: '/test/second/shared-title.mp4', hoster: 'doodstream.com', apiKey: 'key1' }
|
||||
]);
|
||||
|
||||
await firstStarted;
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
mgr.finishAfterActive();
|
||||
releaseFirst();
|
||||
await batchPromise;
|
||||
|
||||
assert.deepEqual(uploads, ['/test/first/shared-title.mp4']);
|
||||
assert.equal(settled.get('recovery-active'), 'done');
|
||||
assert.equal(settled.get('recovery-waiter'), 'aborted');
|
||||
});
|
||||
|
||||
it('finishAfterActive remains enforced after account-failure coordination', async () => {
|
||||
let releaseFailureGate;
|
||||
let markFailureGateEntered;
|
||||
const failureGate = new Promise(resolve => { releaseFailureGate = resolve; });
|
||||
const failureGateEntered = new Promise(resolve => { markFailureGateEntered = resolve; });
|
||||
const error = new Error('Account rejected upload');
|
||||
error.accountError = true;
|
||||
mockUploadFile.mock.mockImplementation(async () => { throw error; });
|
||||
const mgr = new UploadManager({
|
||||
'doodstream.com': { retries: 2, parallelCount: 1, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 }
|
||||
});
|
||||
mgr._coordinateAccountFailure = async () => {
|
||||
markFailureGateEntered();
|
||||
await failureGate;
|
||||
};
|
||||
let settled;
|
||||
mgr.on('job-settled', event => { settled = event; });
|
||||
const batchPromise = mgr.startBatch([{
|
||||
jobId: 'failure-gate-stop',
|
||||
file: '/test/failure-gate-stop.mp4',
|
||||
hoster: 'doodstream.com',
|
||||
accountId: 'ACCOUNT_A',
|
||||
apiKey: 'key1'
|
||||
}]);
|
||||
|
||||
await failureGateEntered;
|
||||
mgr.finishAfterActive();
|
||||
releaseFailureGate();
|
||||
await batchPromise;
|
||||
|
||||
assert.equal(mockUploadFile.mock.calls.length, 1);
|
||||
assert.equal(settled.status, 'aborted');
|
||||
});
|
||||
|
||||
it('remote commit uncertainty remains terminal when finishAfterActive arrives simultaneously', async () => {
|
||||
let releaseUpload;
|
||||
let markUploadStarted;
|
||||
const uploadGate = new Promise(resolve => { releaseUpload = resolve; });
|
||||
const uploadStarted = new Promise(resolve => { markUploadStarted = resolve; });
|
||||
mockUploadFile.mock.mockImplementation(async () => {
|
||||
markUploadStarted();
|
||||
await uploadGate;
|
||||
const error = new Error('Remote commit could not be confirmed');
|
||||
error.remoteCommitUncertain = true;
|
||||
throw error;
|
||||
});
|
||||
const mgr = new UploadManager({
|
||||
'example.test': { retries: 2, parallelCount: 1, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 }
|
||||
});
|
||||
let summary;
|
||||
const batchPromise = mgr.startBatch([{
|
||||
jobId: 'uncertain-during-stop',
|
||||
file: '/test/uncertain-during-stop.mp4',
|
||||
hoster: 'example.test',
|
||||
accountId: 'ACCOUNT_A',
|
||||
apiKey: 'key1'
|
||||
}]);
|
||||
mgr.on('batch-done', value => { summary = value; });
|
||||
|
||||
await uploadStarted;
|
||||
mgr.finishAfterActive();
|
||||
releaseUpload();
|
||||
await batchPromise;
|
||||
const result = summary.files[0].results[0];
|
||||
|
||||
assert.equal(mockUploadFile.mock.calls.length, 1);
|
||||
assert.equal(result.status, 'error');
|
||||
assert.equal(result.remoteCommitUncertain, true);
|
||||
});
|
||||
|
||||
it('late success after cancellation stays blocked by the real source cleanup gate', async (t) => {
|
||||
@@ -1024,70 +603,6 @@ describe('UploadManager', () => {
|
||||
assert.ok(statuses.some((entry) => entry.jobId === 'job-third' && entry.status === 'done'));
|
||||
});
|
||||
|
||||
it('accepts each job ID only once across concurrent addJobs calls', async () => {
|
||||
const anchorPath = '/race/anchor.mp4';
|
||||
const racePath = '/race/concurrent.mp4';
|
||||
const originalStatSync = fs.statSync;
|
||||
const originalStat = fs.promises.stat;
|
||||
const pendingStats = [];
|
||||
let concurrentStarts = 0;
|
||||
let batchPromise;
|
||||
|
||||
fs.statSync = function(p) {
|
||||
if (p === anchorPath || p === racePath) return { size: 0 };
|
||||
return originalStatSync.call(this, p);
|
||||
};
|
||||
fs.promises.stat = function(p) {
|
||||
if (p === anchorPath || p === racePath) {
|
||||
return new Promise((resolve) => pendingStats.push({ path: p, resolve }));
|
||||
}
|
||||
return originalStat.call(this, p);
|
||||
};
|
||||
|
||||
try {
|
||||
mockUploadFile.mock.mockImplementation(async (hoster, filePath, apiKey, onProgress) => {
|
||||
if (filePath === racePath) concurrentStarts++;
|
||||
if (onProgress) onProgress(fakeFileSize, fakeFileSize);
|
||||
return { download_url: `https://${hoster}/d/ok123`, embed_url: null, file_code: 'ok123' };
|
||||
});
|
||||
|
||||
const mgr = new UploadManager({
|
||||
'doodstream.com': { retries: 0, parallelCount: 3, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 }
|
||||
});
|
||||
const controllerRegistrations = [];
|
||||
const registerController = mgr.jobAbortControllers.set;
|
||||
mgr.jobAbortControllers.set = function(jobId, controller) {
|
||||
if (jobId === 'job-concurrent') controllerRegistrations.push(controller);
|
||||
return registerController.call(this, jobId, controller);
|
||||
};
|
||||
|
||||
batchPromise = mgr.startBatch([
|
||||
{ jobId: 'job-anchor', file: anchorPath, hoster: 'doodstream.com', apiKey: 'k' }
|
||||
]);
|
||||
assert.equal(mgr.running, true);
|
||||
|
||||
const task = { jobId: 'job-concurrent', file: racePath, hoster: 'doodstream.com', apiKey: 'k' };
|
||||
const addResults = await Promise.all([
|
||||
Promise.resolve().then(() => mgr.addJobs([task])),
|
||||
Promise.resolve().then(() => mgr.addJobs([{ ...task }]))
|
||||
]);
|
||||
const concurrentStats = pendingStats.filter((entry) => entry.path === racePath);
|
||||
for (const entry of pendingStats) entry.resolve({ size: fakeFileSize });
|
||||
await batchPromise;
|
||||
|
||||
assert.equal(addResults.reduce((total, result) => total + result.added, 0), 1);
|
||||
assert.deepEqual(addResults.flatMap((result) => result.alreadyInBatchJobIds), ['job-concurrent']);
|
||||
assert.equal(concurrentStats.length, 1);
|
||||
assert.equal(controllerRegistrations.length, 1);
|
||||
assert.equal(concurrentStarts, 1);
|
||||
} finally {
|
||||
for (const entry of pendingStats) entry.resolve({ size: fakeFileSize });
|
||||
if (batchPromise) await batchPromise.catch(() => {});
|
||||
fs.statSync = originalStatSync;
|
||||
fs.promises.stat = originalStat;
|
||||
}
|
||||
});
|
||||
|
||||
it('_combineSignals propagates abort from either source', () => {
|
||||
const mgr = new UploadManager({});
|
||||
const ac1 = new AbortController();
|
||||
@@ -1211,7 +726,7 @@ describe('UploadManager', () => {
|
||||
assert.ok(maxConcurrent <= 2, `scaleParallelUploads should cap at 2, was ${maxConcurrent}`);
|
||||
});
|
||||
|
||||
it('addJobs includes newly injected tasks in the batch summary', async () => {
|
||||
it('addJobs injects new tasks into running batch', async () => {
|
||||
let started = 0;
|
||||
mockUploadFile.mock.mockImplementation(async (hoster, filePath, apiKey, onProgress) => {
|
||||
started++;
|
||||
@@ -1242,9 +757,6 @@ describe('UploadManager', () => {
|
||||
await batchPromise;
|
||||
assert.ok(summary);
|
||||
assert.equal(started, 4, 'all 4 jobs should have run');
|
||||
assert.equal(summary.total, 4);
|
||||
assert.equal(summary.succeeded, 4);
|
||||
assert.equal(summary.failed, 0);
|
||||
});
|
||||
|
||||
it('addJobs rejects duplicates already in running batch', async () => {
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
test('catastrophic batch starts retain exact terminal outcomes for every job', () => {
|
||||
const { buildFailedUploadSummary, buildTerminalJobSnapshots } = require('../lib/upload-recovery');
|
||||
const summary = buildFailedUploadSummary([
|
||||
{ jobId: 'job-a', file: 'C:\\one\\same.mkv', hoster: 'doodstream.com' },
|
||||
{ jobId: 'job-b', file: 'D:\\two\\same.mkv', hoster: 'voe.sx' }
|
||||
], 'Upload konnte nicht gestartet werden', Date.UTC(2026, 7, 13));
|
||||
assert.equal(summary.total, 2);
|
||||
assert.equal(summary.failed, 2);
|
||||
assert.deepEqual(buildTerminalJobSnapshots(summary).map(entry => [entry.jobId, entry.status]), [
|
||||
['job-a', 'error'],
|
||||
['job-b', 'error']
|
||||
]);
|
||||
});
|
||||
|
||||
test('catastrophic batch summaries preserve safe file keys for later skipped-job merging', () => {
|
||||
const { buildFailedUploadSummary } = require('../lib/upload-recovery');
|
||||
const summary = buildFailedUploadSummary([
|
||||
{ jobId: 'job-a', file: 'C:\\one\\same.mkv', fileKey: 'safe-file-key', hoster: 'doodstream.com' }
|
||||
], 'Upload konnte nicht gestartet werden');
|
||||
|
||||
assert.equal(summary.files[0].fileKey, 'safe-file-key');
|
||||
});
|
||||
|
||||
test('terminal recovery snapshots retain exact job outcomes and canonical links', () => {
|
||||
const { buildTerminalJobSnapshots } = require('../lib/upload-recovery');
|
||||
const snapshots = buildTerminalJobSnapshots({
|
||||
files: [{
|
||||
name: 'episode.mkv',
|
||||
results: [
|
||||
{ jobId: 'done-job', hoster: 'doodstream.com', status: 'done', download_url: 'https://doodstream.com/d/abc123', file_code: 'abc123' },
|
||||
{ jobId: 'error-job', hoster: 'voe.sx', status: 'error', error: 'rejected', failureDetails: { kind: 'hoster' }, remoteCommitUncertain: true },
|
||||
{ hoster: 'byse.sx', status: 'done', download_url: 'https://byse.sx/d/no-id' }
|
||||
]
|
||||
}]
|
||||
});
|
||||
|
||||
assert.deepEqual(snapshots, [
|
||||
{
|
||||
jobId: 'done-job',
|
||||
status: 'done',
|
||||
error: null,
|
||||
failureDetails: null,
|
||||
result: { download_url: 'https://doodstream.com/d/abc123', embed_url: null, file_code: 'abc123' }
|
||||
},
|
||||
{
|
||||
jobId: 'error-job',
|
||||
status: 'error',
|
||||
error: 'rejected',
|
||||
failureDetails: { kind: 'hoster' },
|
||||
remoteCommitUncertain: true,
|
||||
result: null
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
test('recovery markers affect only their exact job IDs and never restart terminal outcomes', () => {
|
||||
const { getRecoveryOutcome } = require('../lib/upload-recovery');
|
||||
const recovery = {
|
||||
jobIds: ['done-job', 'active-job'],
|
||||
historyPending: true,
|
||||
terminalJobs: [{
|
||||
jobId: 'done-job',
|
||||
status: 'done',
|
||||
error: null,
|
||||
failureDetails: null,
|
||||
result: { download_url: 'https://doodstream.com/d/abc123', embed_url: null, file_code: 'abc123' }
|
||||
}, {
|
||||
jobId: 'uncertain-job',
|
||||
status: 'error',
|
||||
error: 'remote result unknown',
|
||||
failureDetails: null,
|
||||
remoteCommitUncertain: true,
|
||||
result: null
|
||||
}]
|
||||
};
|
||||
|
||||
assert.deepEqual(getRecoveryOutcome({ id: 'done-job', status: 'preview' }, recovery), {
|
||||
status: 'done',
|
||||
error: null,
|
||||
failureDetails: null,
|
||||
result: { download_url: 'https://doodstream.com/d/abc123', embed_url: null, file_code: 'abc123' },
|
||||
historyPending: true,
|
||||
interrupted: false
|
||||
});
|
||||
assert.deepEqual(getRecoveryOutcome({ id: 'active-job', status: 'queued' }, recovery), { status: 'queued', interrupted: true });
|
||||
assert.deepEqual(getRecoveryOutcome({ id: 'uncertain-job', status: 'preview' }, recovery), {
|
||||
status: 'error',
|
||||
error: 'remote result unknown',
|
||||
failureDetails: null,
|
||||
remoteCommitUncertain: true,
|
||||
result: null,
|
||||
historyPending: true,
|
||||
interrupted: false
|
||||
});
|
||||
assert.deepEqual(getRecoveryOutcome({ id: 'foreign-job', status: 'queued' }, recovery), { status: 'queued', interrupted: false });
|
||||
assert.deepEqual(getRecoveryOutcome({ id: 'already-done', status: 'done' }, recovery), { status: 'done', interrupted: false });
|
||||
});
|
||||
|
||||
test('remote commit uncertainty survives acknowledgements and failed retries until confirmed success', () => {
|
||||
const { resolveRemoteCommitUncertainty } = require('../lib/upload-recovery');
|
||||
assert.equal(resolveRemoteCommitUncertainty(true, { status: 'queued' }), true);
|
||||
assert.equal(resolveRemoteCommitUncertainty(true, { status: 'getting-server' }), true);
|
||||
assert.equal(resolveRemoteCommitUncertainty(true, { status: 'uploading' }), true);
|
||||
assert.equal(resolveRemoteCommitUncertainty(true, { status: 'error' }), true);
|
||||
assert.equal(resolveRemoteCommitUncertainty(true, { status: 'aborted' }), true);
|
||||
assert.equal(resolveRemoteCommitUncertainty(true, { status: 'done' }), false);
|
||||
assert.equal(resolveRemoteCommitUncertainty(false, { status: 'error', remoteCommitUncertain: true }), true);
|
||||
assert.equal(resolveRemoteCommitUncertainty(false, { status: 'error' }), false);
|
||||
});
|
||||
|
||||
test('main and renderer keep recovery evidence until final queue persistence succeeds', () => {
|
||||
const root = path.join(__dirname, '..');
|
||||
const mainSource = fs.readFileSync(path.join(root, 'main.js'), 'utf8');
|
||||
const rendererSource = fs.readFileSync(path.join(root, 'renderer', 'app.js'), 'utf8');
|
||||
const indexSource = fs.readFileSync(path.join(root, 'renderer', 'index.html'), 'utf8');
|
||||
const batchDone = mainSource.slice(mainSource.indexOf("uploadManager.on('batch-done'"), mainSource.indexOf("ipcMain.handle('cancel-upload'"));
|
||||
const barrier = mainSource.slice(mainSource.indexOf('function createUploadFinalizationBarrier'), mainSource.indexOf('function requestUploadFinalization'));
|
||||
|
||||
assert.match(mainSource, /buildTerminalSnapshots: buildTerminalJobSnapshots/);
|
||||
assert.ok(barrier.indexOf('appendHistory(summary)') < barrier.indexOf('saveRecovery(terminalRecovery)'));
|
||||
assert.ok(barrier.indexOf('saveRecovery(terminalRecovery)') < barrier.indexOf('requestFinalization(summary, historyPersisted)'));
|
||||
assert.match(barrier, /if \(historyPersisted && queuePersisted && terminalRecoveryPersisted\)[\s\S]*saveRecovery\(null\)/);
|
||||
assert.match(batchDone, /uploadFinalizationBarrier\.finalize\(summary, recovery\)/);
|
||||
const startFailure = batchDone.slice(batchDone.indexOf('startBatch(tasks'));
|
||||
assert.match(startFailure, /buildFailedUploadSummary\(tasks/);
|
||||
assert.match(startFailure, /uploadFinalizationBarrier\.finalize\(errorSummary, recovery\)/);
|
||||
const startHandler = mainSource.slice(mainSource.indexOf("ipcMain.handle('start-upload'"), mainSource.indexOf("ipcMain.handle('cancel-upload'"));
|
||||
assert.match(startHandler, /await configStore\.saveUploadRecovery\(recovery\)/);
|
||||
assert.match(startHandler, /uploadFinalizationBarrier\.finalize\(skippedSummary/);
|
||||
assert.match(startHandler, /finalized: true/);
|
||||
assert.ok(startHandler.indexOf('sourceCleanup.registerGroups(sourceCleanupGroups)') < startHandler.indexOf('saveUploadRecovery(recovery)'));
|
||||
assert.match(startHandler, /catch \(error\)[\s\S]*return { error: 'Upload-Wiederherstellung konnte nicht gespeichert werden' }/);
|
||||
const addHandler = mainSource.slice(mainSource.indexOf("ipcMain.handle('add-jobs-to-batch'"), mainSource.indexOf("ipcMain.handle('finish-after-active'"));
|
||||
assert.match(addHandler, /await configStore\.saveUploadRecovery\(nextRecovery\)/);
|
||||
assert.ok(addHandler.indexOf('saveUploadRecovery(nextRecovery)') < addHandler.indexOf('batchManager.addJobs(tasks)'));
|
||||
assert.match(rendererSource, /window\.UploadRecovery\.getRecoveryOutcome/);
|
||||
assert.match(rendererSource, /data\.historyPersisted !== true/);
|
||||
assert.match(rendererSource, /deliveryId: data\.deliveryId/);
|
||||
assert.match(rendererSource, /remoteCommitUncertain: Object\.hasOwn\(recoveryOutcome, 'remoteCommitUncertain'\)/);
|
||||
assert.match(rendererSource, /remoteCommitUncertain: job\.remoteCommitUncertain === true/);
|
||||
assert.match(rendererSource, /j\.remoteCommitUncertain !== true/);
|
||||
assert.equal((rendererSource.match(/window\.UploadRecovery\.resolveRemoteCommitUncertainty/g) || []).length >= 2, true);
|
||||
assert.doesNotMatch(rendererSource, /acknowledgeRemoteCommitRetry/);
|
||||
assert.ok(indexSource.indexOf('../lib/upload-recovery.js') < indexSource.indexOf('app.js'));
|
||||
});
|
||||
@@ -1,125 +0,0 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const {
|
||||
normalizeUploadSchedule,
|
||||
evaluateUploadSchedule,
|
||||
createUploadScheduleGate
|
||||
} = require('../lib/upload-schedule');
|
||||
|
||||
function at(iso) {
|
||||
return new Date(iso);
|
||||
}
|
||||
|
||||
test('normalizes weekdays and valid local times into a stable Monday-first shape', () => {
|
||||
assert.deepEqual(normalizeUploadSchedule({
|
||||
enabled: true,
|
||||
weekdays: [0, 1, 1, 8, '2'],
|
||||
start: ' 08:15 ',
|
||||
end: '17:45'
|
||||
}), {
|
||||
enabled: true,
|
||||
weekdays: [1, 2, 0],
|
||||
start: '08:15',
|
||||
end: '17:45'
|
||||
});
|
||||
});
|
||||
|
||||
test('allows a selected daytime window with an inclusive start and exclusive end', () => {
|
||||
const schedule = { enabled: true, weekdays: [1], start: '08:00', end: '10:00' };
|
||||
assert.equal(evaluateUploadSchedule(schedule, at('2026-08-17T08:00:00')).allowed, true);
|
||||
assert.equal(evaluateUploadSchedule(schedule, at('2026-08-17T09:59:59')).allowed, true);
|
||||
assert.equal(evaluateUploadSchedule(schedule, at('2026-08-17T10:00:00')).allowed, false);
|
||||
});
|
||||
|
||||
test('attributes the after-midnight half of an overnight window to the originating weekday', () => {
|
||||
const schedule = { enabled: true, weekdays: [1], start: '22:00', end: '06:00' };
|
||||
assert.equal(evaluateUploadSchedule(schedule, at('2026-08-17T22:00:00')).allowed, true);
|
||||
assert.equal(evaluateUploadSchedule(schedule, at('2026-08-18T05:59:59')).allowed, true);
|
||||
assert.equal(evaluateUploadSchedule(schedule, at('2026-08-18T06:00:00')).allowed, false);
|
||||
assert.equal(evaluateUploadSchedule(schedule, at('2026-08-19T05:00:00')).allowed, false);
|
||||
});
|
||||
|
||||
test('finds the next selected start across the week boundary', () => {
|
||||
const result = evaluateUploadSchedule(
|
||||
{ enabled: true, weekdays: [1], start: '08:30', end: '09:30' },
|
||||
at('2026-08-23T12:00:00')
|
||||
);
|
||||
assert.equal(result.allowed, false);
|
||||
assert.equal(result.nextStart.getDay(), 1);
|
||||
assert.equal(result.nextStart.getHours(), 8);
|
||||
assert.equal(result.nextStart.getMinutes(), 30);
|
||||
assert.equal(result.nextStart.getDate(), 24);
|
||||
});
|
||||
|
||||
test('reports enabled schedules with equal times, missing times, or no weekdays as invalid', () => {
|
||||
assert.deepEqual(
|
||||
evaluateUploadSchedule({ enabled: true, weekdays: [1], start: '08:00', end: '08:00' }, at('2026-08-17T08:00:00')).reason,
|
||||
'equal-times'
|
||||
);
|
||||
assert.equal(evaluateUploadSchedule({ enabled: true, weekdays: [], start: '08:00', end: '09:00' }, at('2026-08-17T08:00:00')).reason, 'weekdays');
|
||||
assert.equal(evaluateUploadSchedule({ enabled: true, weekdays: [1], start: 'bad', end: '09:00' }, at('2026-08-17T08:00:00')).reason, 'time');
|
||||
});
|
||||
|
||||
test('disabled schedules always allow uploads', () => {
|
||||
const result = evaluateUploadSchedule({ enabled: false, weekdays: [], start: '', end: '' }, at('2026-08-17T08:00:00'));
|
||||
assert.equal(result.valid, true);
|
||||
assert.equal(result.allowed, true);
|
||||
assert.equal(result.nextStart, null);
|
||||
});
|
||||
|
||||
test('gate wakes all waiting jobs when settings are updated', async () => {
|
||||
const gate = createUploadScheduleGate({ enabled: true, weekdays: [], start: '08:00', end: '09:00' });
|
||||
const first = gate.wait();
|
||||
const second = gate.wait();
|
||||
gate.update({ enabled: false });
|
||||
const results = await Promise.all([first, second]);
|
||||
assert.equal(results.every(result => result.allowed), true);
|
||||
});
|
||||
|
||||
test('gate rejects a waiting job immediately when its signal is aborted', async () => {
|
||||
const gate = createUploadScheduleGate({ enabled: true, weekdays: [], start: '08:00', end: '09:00' });
|
||||
const controller = new AbortController();
|
||||
const waiting = gate.wait(controller.signal);
|
||||
controller.abort();
|
||||
await assert.rejects(waiting, error => error?.name === 'AbortError');
|
||||
});
|
||||
|
||||
test('gate rechecks an external stop condition when explicitly woken', async () => {
|
||||
const gate = createUploadScheduleGate({ enabled: true, weekdays: [], start: '08:00', end: '09:00' });
|
||||
let stopped = false;
|
||||
const waiting = gate.wait(undefined, () => {
|
||||
if (!stopped) return;
|
||||
const error = new Error('Stopped');
|
||||
error.stopAfterActive = true;
|
||||
throw error;
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
stopped = true;
|
||||
gate.wake();
|
||||
|
||||
await assert.rejects(waiting, error => error.stopAfterActive === true);
|
||||
});
|
||||
|
||||
test('gate schedules a wake for the exact next opening', async () => {
|
||||
let current = at('2026-08-17T07:30:00');
|
||||
let timerDelay = null;
|
||||
let timerCallback = null;
|
||||
const gate = createUploadScheduleGate(
|
||||
{ enabled: true, weekdays: [1], start: '08:00', end: '09:00' },
|
||||
{
|
||||
now: () => current,
|
||||
setTimeout(callback, delay) {
|
||||
timerCallback = callback;
|
||||
timerDelay = delay;
|
||||
return 1;
|
||||
},
|
||||
clearTimeout() {}
|
||||
}
|
||||
);
|
||||
const waiting = gate.wait();
|
||||
assert.equal(timerDelay, 30 * 60 * 1000);
|
||||
current = at('2026-08-17T08:00:00');
|
||||
timerCallback();
|
||||
assert.equal((await waiting).allowed, true);
|
||||
});
|
||||
@@ -1,57 +0,0 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { createUploadStartReservation } = require('../lib/upload-start-reservation');
|
||||
|
||||
test('only one upload start can hold the reservation across asynchronous work', () => {
|
||||
const reservation = createUploadStartReservation();
|
||||
const first = reservation.acquire();
|
||||
|
||||
assert.ok(first);
|
||||
assert.equal(reservation.isActive(), true);
|
||||
assert.equal(reservation.acquire(), null);
|
||||
|
||||
first.release();
|
||||
const second = reservation.acquire();
|
||||
assert.ok(second);
|
||||
assert.notStrictEqual(second, first);
|
||||
});
|
||||
|
||||
test('cancelling a reserved start is visible until its owner releases it', () => {
|
||||
const reservation = createUploadStartReservation();
|
||||
const lease = reservation.acquire();
|
||||
|
||||
assert.equal(reservation.cancel(), true);
|
||||
assert.equal(lease.isCancelled(), true);
|
||||
assert.equal(reservation.acquire(), null);
|
||||
lease.release();
|
||||
assert.equal(reservation.isActive(), false);
|
||||
assert.equal(reservation.cancel(), false);
|
||||
});
|
||||
|
||||
test('stale and repeated releases cannot clear a newer reservation', () => {
|
||||
const reservation = createUploadStartReservation();
|
||||
const first = reservation.acquire();
|
||||
first.release();
|
||||
const second = reservation.acquire();
|
||||
|
||||
first.release();
|
||||
assert.equal(reservation.isActive(), true);
|
||||
assert.equal(reservation.acquire(), null);
|
||||
second.release();
|
||||
assert.equal(reservation.isActive(), false);
|
||||
});
|
||||
|
||||
test('main process reserves starts before audit and exposes cancellation during the wait', () => {
|
||||
const source = fs.readFileSync(path.join(__dirname, '..', 'main.js'), 'utf8');
|
||||
const start = source.slice(
|
||||
source.indexOf("ipcMain.handle('start-upload'"),
|
||||
source.indexOf("ipcMain.handle('cancel-selected-jobs'")
|
||||
);
|
||||
|
||||
assert.ok(start.indexOf('uploadStartReservation.acquire()') < start.indexOf('appendUploadPlanAudit(batchPlan'));
|
||||
assert.match(start, /executeReservedUploadStart\(payload, startLease\)\.finally\(\(\) => startLease\.release\(\)\)/);
|
||||
assert.match(start, /uploadStartReservation\.cancel\(\)/);
|
||||
assert.match(source, /createSettingsImportGate\(\(\) => !!uploadManager \|\| uploadStartReservation\.isActive\(\)\)/);
|
||||
});
|
||||
Reference in New Issue
Block a user