Persist successful watched-file uploads in a dedicated fsync-backed ledger before exposing completion to the renderer. Match entries by normalized full path, hoster, size, and modification time so restart reconciliation skips unchanged completed files while changed files and explicit manual retries remain available. Remove restored queue ghosts from the ledger even when history and user upload logging are unavailable. Preserve per-hoster partial completion, capture missing file metadata asynchronously, fail closed on corrupted or unwritable evidence, and keep local persistence failures outside automatic upload retries. Stream managed upload logs with bounded lines, bytes, files, directories, and result counts. Include numbered rotations, reject unconfirmed rows, share concurrent scans through a generation-safe cache, invalidate after successful appends, avoid synchronous configuration and directory reads, and close streams on every path.
This commit is contained in:
+141
-1
@@ -147,10 +147,143 @@
|
||||
return String(value || '').replace(/\\/g, '/').toLowerCase();
|
||||
}
|
||||
|
||||
function isPathWithinAutomationFolder(filePath, folderPath, recursive = true) {
|
||||
const file = normalizePath(filePath).replace(/\/+$/, '');
|
||||
const folder = normalizePath(folderPath).replace(/\/+$/, '');
|
||||
if (!file || !folder || !file.startsWith(`${folder}/`)) return false;
|
||||
const relative = file.slice(folder.length + 1);
|
||||
return Boolean(relative) && (recursive === true || !relative.includes('/'));
|
||||
}
|
||||
|
||||
function baseName(value) {
|
||||
return String(value || '').split(/[\\/]/).pop().toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeAutomationCompletion(value) {
|
||||
const row = asObject(value);
|
||||
const path = String(row.path || '').trim();
|
||||
const hoster = String(row.hoster || '').trim().toLowerCase();
|
||||
const size = Number(row.size);
|
||||
const mtimeMs = Number(row.mtimeMs);
|
||||
const completedAt = Number(row.completedAt);
|
||||
if (!path || !hoster || !Number.isFinite(size) || size < 0 || !Number.isFinite(mtimeMs) || mtimeMs < 0) return null;
|
||||
return {
|
||||
path,
|
||||
size,
|
||||
mtimeMs: Math.trunc(mtimeMs),
|
||||
hoster,
|
||||
completedAt: Number.isFinite(completedAt) && completedAt >= 0 ? Math.trunc(completedAt) : 0
|
||||
};
|
||||
}
|
||||
|
||||
function automationCompletionKey(value) {
|
||||
const row = normalizeAutomationCompletion(value);
|
||||
return row ? `${normalizePath(row.path)}\u0000${row.hoster}` : '';
|
||||
}
|
||||
|
||||
function mergeAutomationCompletions(existing, incoming, maxEntries = 250000) {
|
||||
const merged = new Map();
|
||||
for (const source of [asArray(existing), asArray(incoming)]) {
|
||||
for (const value of source) {
|
||||
const row = normalizeAutomationCompletion(value);
|
||||
const key = automationCompletionKey(row);
|
||||
if (key) {
|
||||
merged.delete(key);
|
||||
merged.set(key, row);
|
||||
}
|
||||
}
|
||||
}
|
||||
const limit = Number.isFinite(Number(maxEntries)) ? Math.max(1, Math.floor(Number(maxEntries))) : 250000;
|
||||
if (merged.size > limit) throw new Error('Automatik-Abschlussdatei enthält zu viele Einträge');
|
||||
return [...merged.values()];
|
||||
}
|
||||
|
||||
function removeAutomationCompletions(existing, removals) {
|
||||
const rules = asArray(removals).map(value => ({
|
||||
path: normalizePath(value?.path),
|
||||
hoster: String(value?.hoster || '').trim().toLowerCase()
|
||||
})).filter(value => value.path);
|
||||
if (rules.length === 0) return mergeAutomationCompletions(existing, []);
|
||||
return mergeAutomationCompletions(existing, []).filter(row => {
|
||||
const path = normalizePath(row.path);
|
||||
return !rules.some(rule => rule.path === path && (!rule.hoster || rule.hoster === row.hoster));
|
||||
});
|
||||
}
|
||||
|
||||
function classifyAutomationCompletionLedger(input = {}) {
|
||||
const value = asObject(input);
|
||||
const rows = new Map();
|
||||
for (const entry of asArray(value.completionRows)) {
|
||||
const row = normalizeAutomationCompletion(entry);
|
||||
const key = automationCompletionKey(row);
|
||||
if (key) rows.set(key, row);
|
||||
}
|
||||
const processedPaths = [];
|
||||
const completedByPath = [];
|
||||
const remainingByPath = [];
|
||||
for (const candidate of asArray(value.candidates)) {
|
||||
const path = String(candidate?.path || '');
|
||||
const size = Number(candidate?.size);
|
||||
const mtimeMs = Number(candidate?.mtimeMs);
|
||||
const hosters = [...new Set(asArray(candidate?.eligibleHosters).map(hoster => String(hoster || '').trim().toLowerCase()).filter(Boolean))];
|
||||
const completed = [];
|
||||
const remaining = [];
|
||||
for (const hoster of hosters) {
|
||||
const row = rows.get(`${normalizePath(path)}\u0000${hoster}`);
|
||||
if (row && Number.isFinite(size) && size === row.size && Number.isFinite(mtimeMs) && Math.trunc(mtimeMs) === row.mtimeMs) completed.push(hoster);
|
||||
else remaining.push(hoster);
|
||||
}
|
||||
if (hosters.length > 0 && remaining.length === 0) processedPaths.push(path);
|
||||
completedByPath.push({ path, hosters: completed });
|
||||
remainingByPath.push({ path, hosters: remaining });
|
||||
}
|
||||
return { processedPaths, completedByPath, remainingByPath };
|
||||
}
|
||||
|
||||
function createAutomationCompletionWriter(options = {}) {
|
||||
const settings = asObject(options);
|
||||
if (typeof settings.save !== 'function') throw new TypeError('save is required');
|
||||
const schedule = typeof settings.schedule === 'function' ? settings.schedule : queueMicrotask;
|
||||
const pending = new Map();
|
||||
let scheduled = false;
|
||||
let tail = Promise.resolve();
|
||||
const flush = () => {
|
||||
scheduled = false;
|
||||
if (pending.size === 0) return tail;
|
||||
const snapshot = [...pending.entries()];
|
||||
const rows = snapshot.map(([, row]) => row);
|
||||
const operation = tail.then(async () => {
|
||||
try {
|
||||
await settings.save(rows);
|
||||
for (const [key, row] of snapshot) {
|
||||
if (pending.get(key) === row) pending.delete(key);
|
||||
}
|
||||
try { settings.onPersisted?.(rows); } catch {}
|
||||
} catch (error) {
|
||||
try { settings.onError?.(error, rows); } catch {}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
tail = operation.catch(() => {});
|
||||
return operation;
|
||||
};
|
||||
return {
|
||||
add(value) {
|
||||
const row = normalizeAutomationCompletion(value);
|
||||
const key = automationCompletionKey(row);
|
||||
if (!key) return false;
|
||||
pending.set(key, row);
|
||||
if (!scheduled) {
|
||||
scheduled = true;
|
||||
schedule(() => { flush().catch(() => {}); });
|
||||
}
|
||||
return true;
|
||||
},
|
||||
flush,
|
||||
pendingCount: () => pending.size
|
||||
};
|
||||
}
|
||||
|
||||
function classifyProcessedCandidates(input = {}) {
|
||||
const value = asObject(input);
|
||||
const candidates = asArray(value.candidates);
|
||||
@@ -191,6 +324,13 @@
|
||||
rollDailyTelemetry,
|
||||
applyTelemetryDelta,
|
||||
deriveAutomationState,
|
||||
classifyProcessedCandidates
|
||||
isPathWithinAutomationFolder,
|
||||
classifyProcessedCandidates,
|
||||
classifyAutomationCompletionLedger,
|
||||
automationCompletionKey,
|
||||
createAutomationCompletionWriter,
|
||||
normalizeAutomationCompletion,
|
||||
mergeAutomationCompletions,
|
||||
removeAutomationCompletions
|
||||
};
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
const secretStore = require('./secret-store');
|
||||
const { normalizeLogMode } = require('./log-mode');
|
||||
const { mergeAutomationCompletions, normalizeAutomationCompletion, removeAutomationCompletions } = require('./automation-control');
|
||||
|
||||
const HOSTER_SETTINGS_DEFAULTS = {
|
||||
retries: 3,
|
||||
@@ -196,8 +197,11 @@ class ConfigStore {
|
||||
: path.join(__dirname, '..');
|
||||
this.filePath = path.join(dir, 'electron-config.json');
|
||||
this.historyPath = path.join(dir, 'electron-history.json');
|
||||
this.automationCompletionPath = path.join(dir, 'automation-completions.json');
|
||||
this._writeQueue = Promise.resolve(); // Serializes all writes to prevent race conditions
|
||||
this._historyWriteQueue = Promise.resolve();
|
||||
this._automationCompletionWriteQueue = Promise.resolve();
|
||||
this._automationCompletionCache = null;
|
||||
this._pendingWriteOperations = new Set();
|
||||
this._writesQuiesced = false;
|
||||
this._historyMigrated = false;
|
||||
@@ -622,6 +626,71 @@ class ConfigStore {
|
||||
}, options);
|
||||
}
|
||||
|
||||
async _readAutomationCompletionFile() {
|
||||
try {
|
||||
const raw = await fs.promises.readFile(this.automationCompletionPath, 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || parsed.version !== 1 || !Array.isArray(parsed.entries)) throw new Error('Automatik-Abschlussdatei ist ungültig');
|
||||
if (parsed.entries.some(entry => !normalizeAutomationCompletion(entry))) throw new Error('Automatik-Abschlussdatei ist ungültig');
|
||||
return mergeAutomationCompletions([], parsed.entries);
|
||||
} catch (error) {
|
||||
if (error?.code === 'ENOENT') return [];
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async _writeAutomationCompletionFile(entries) {
|
||||
const target = this.automationCompletionPath;
|
||||
const temporary = `${target}.tmp`;
|
||||
await fs.promises.mkdir(path.dirname(target), { recursive: true });
|
||||
const handle = await fs.promises.open(temporary, 'w');
|
||||
try {
|
||||
await handle.writeFile(JSON.stringify({ version: 1, entries }), 'utf8');
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
await fs.promises.rename(temporary, target);
|
||||
}
|
||||
|
||||
_enqueueAutomationCompletionWrite(operation) {
|
||||
const result = this._automationCompletionWriteQueue.then(operation);
|
||||
this._automationCompletionWriteQueue = result.catch(() => {});
|
||||
return result;
|
||||
}
|
||||
|
||||
async loadAutomationCompletions() {
|
||||
await this._automationCompletionWriteQueue;
|
||||
if (!this._automationCompletionCache) this._automationCompletionCache = await this._readAutomationCompletionFile();
|
||||
return this._clone(this._automationCompletionCache);
|
||||
}
|
||||
|
||||
saveAutomationCompletions(entries) {
|
||||
const incoming = this._clone(Array.isArray(entries) ? entries : []);
|
||||
return this._enqueueAutomationCompletionWrite(async () => {
|
||||
const current = this._automationCompletionCache || await this._readAutomationCompletionFile();
|
||||
const next = mergeAutomationCompletions(current, incoming);
|
||||
await this._writeAutomationCompletionFile(next);
|
||||
this._automationCompletionCache = next;
|
||||
return this._clone(next);
|
||||
});
|
||||
}
|
||||
|
||||
clearAutomationCompletions(removals) {
|
||||
const requested = this._clone(Array.isArray(removals) ? removals : []);
|
||||
return this._enqueueAutomationCompletionWrite(async () => {
|
||||
const current = this._automationCompletionCache || await this._readAutomationCompletionFile();
|
||||
const next = removeAutomationCompletions(current, requested);
|
||||
await this._writeAutomationCompletionFile(next);
|
||||
this._automationCompletionCache = next;
|
||||
return this._clone(next);
|
||||
});
|
||||
}
|
||||
|
||||
async drainAutomationCompletionWrites() {
|
||||
await this._automationCompletionWriteQueue;
|
||||
}
|
||||
|
||||
saveLastBrowseDirectory(directory) {
|
||||
const snapshot = String(directory || '').trim();
|
||||
return this._enqueueWrite(() => {
|
||||
|
||||
@@ -27,7 +27,8 @@
|
||||
return {
|
||||
path: filePath,
|
||||
name: sourceName || path.basename(filePath),
|
||||
size: Number.isFinite(Number(source.size)) ? Number(source.size) : null
|
||||
size: Number.isFinite(Number(source.size)) ? Number(source.size) : null,
|
||||
mtimeMs: Number.isFinite(Number(source.mtimeMs)) ? Number(source.mtimeMs) : null
|
||||
};
|
||||
}
|
||||
|
||||
@@ -62,8 +63,8 @@
|
||||
try {
|
||||
fileHandle = await openPath(filePath, 'r');
|
||||
const fileStat = await fileHandle.stat();
|
||||
if (!fileStat.isFile()) return { exists: true, readable: false, size: fileStat.size };
|
||||
return { exists: true, readable: true, size: fileStat.size };
|
||||
if (!fileStat.isFile()) return { exists: true, readable: false, size: fileStat.size, mtimeMs: fileStat.mtimeMs };
|
||||
return { exists: true, readable: true, size: fileStat.size, mtimeMs: fileStat.mtimeMs };
|
||||
} catch (error) {
|
||||
if (error && (error.code === 'ENOENT' || error.code === 'ENOTDIR')) return { exists: false };
|
||||
return { exists: true, readable: false };
|
||||
@@ -109,8 +110,9 @@
|
||||
try {
|
||||
const result = await inspectPath(entry.path, entry);
|
||||
const reason = unavailableReason(result);
|
||||
if (reason) return { entry: { ...entry, size: Number(result?.size) || 0 }, reason };
|
||||
return { entry: { ...entry, size: Number(result.size) }, reason: '' };
|
||||
const mtimeMs = Number.isFinite(Number(result?.mtimeMs)) ? Number(result.mtimeMs) : entry.mtimeMs;
|
||||
if (reason) return { entry: { ...entry, size: Number(result?.size) || 0, mtimeMs }, reason };
|
||||
return { entry: { ...entry, size: Number(result.size), mtimeMs }, reason: '' };
|
||||
} catch (error) {
|
||||
return { entry, reason: error && error.code === 'ENOENT' ? 'missing' : 'unreadable' };
|
||||
}
|
||||
|
||||
+4
-2
@@ -106,9 +106,11 @@
|
||||
const normalizedBaseName = baseName.toLowerCase();
|
||||
const normalizedExt = ext.toLowerCase();
|
||||
if (!normalizedExt || !normalizedValue.endsWith(normalizedExt)) return false;
|
||||
if (stripModeStampFromFileName(normalizedValue) === `${normalizedBaseName}${normalizedExt}`) return true;
|
||||
const stem = normalizedValue.slice(0, -normalizedExt.length);
|
||||
return /^\d{2}-\d{2}-\d{4}-mdu-session-\d{2}-\d{2}(?:-\d+)?$/.test(stem);
|
||||
const managedStem = stem.replace(/\.\d+$/, '');
|
||||
const managedValue = `${managedStem}${normalizedExt}`;
|
||||
if (stripModeStampFromFileName(managedValue) === `${normalizedBaseName}${normalizedExt}`) return true;
|
||||
return /^\d{2}-\d{2}-\d{4}-mdu-session-\d{2}-\d{2}(?:-\d+)?$/.test(managedStem);
|
||||
}
|
||||
|
||||
const api = { normalizeLogMode, resolveLogFileName, formatDateStamp, formatSessionStamp, stripModeStampFromFileName, isManagedUploadLogFileName, VALID_MODES };
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
function classifyErrorCategory(err) {
|
||||
if (!err || typeof err !== 'string') return 'unknown';
|
||||
const s = err.toLowerCase();
|
||||
if (/automatik-abschlussnachweis.*nicht gespeichert|automation completion evidence.*not saved/.test(s)) return 'local-persistence';
|
||||
if (/abgebrochen|aborted|cancel/.test(s)) return 'aborted';
|
||||
if (/not video file format|kein videoformat|invalid file|wrong format|duplicate|already exists|file too (small|big|large)|datei zu (gro|klein)/.test(s)) return 'file-rejected';
|
||||
if (/quota|storage (full|exhausted|voll)|account (full|banned|suspended)|disk (space )?full|insufficient (disk )?space|not enough (disk )?(space|storage)/.test(s)) return 'account-error';
|
||||
@@ -57,6 +58,7 @@
|
||||
'hoster-transient': [],
|
||||
'network': [],
|
||||
'unknown': [],
|
||||
'local-persistence': [],
|
||||
'aborted': []
|
||||
};
|
||||
if (!batchSummary || !Array.isArray(batchSummary.files)) return buckets;
|
||||
@@ -129,6 +131,7 @@
|
||||
'hoster-transient': 'Hoster-Flake',
|
||||
'network': 'Netzwerk',
|
||||
'unknown': 'Unbekannt',
|
||||
'local-persistence': 'Lokale Speicherung',
|
||||
'aborted': 'Abgebrochen'
|
||||
};
|
||||
|
||||
|
||||
+63
-3
@@ -18,14 +18,74 @@
|
||||
if (parts.length < 5) return null;
|
||||
const hoster = (parts[1] || '').trim();
|
||||
let fileName = '';
|
||||
let fileNameIndex = -1;
|
||||
for (let i = parts.length - 1; i >= 4; i--) {
|
||||
if (parts[i].trim() !== '') { fileName = parts[i]; break; }
|
||||
if (parts[i].trim() !== '') { fileName = parts[i]; fileNameIndex = i; break; }
|
||||
}
|
||||
if (!hoster || !fileName) return null;
|
||||
const confirmed = parts.slice(2, fileNameIndex).some(value => value.trim() !== '');
|
||||
const tsStr = (parts[0] || '').trim();
|
||||
const tsParsed = tsStr ? Date.parse(tsStr.replace(' ', 'T')) : NaN;
|
||||
const ts = isNaN(tsParsed) ? undefined : tsParsed;
|
||||
return { hoster, fileName, ts };
|
||||
return { hoster, fileName, ts, confirmed };
|
||||
}
|
||||
|
||||
async function* iterateBoundedUploadLogLines(chunks, maxLineLength) {
|
||||
let buffer = '';
|
||||
for await (const chunk of chunks) {
|
||||
buffer += String(chunk);
|
||||
for (;;) {
|
||||
const separator = buffer.indexOf('\n');
|
||||
if (separator < 0) break;
|
||||
const line = buffer.slice(0, separator).replace(/\r$/, '');
|
||||
if (line.length > maxLineLength) throw new Error('Upload-Log-Zeile ist zu lang');
|
||||
yield line;
|
||||
buffer = buffer.slice(separator + 1);
|
||||
}
|
||||
if (buffer.length > maxLineLength) throw new Error('Upload-Log-Zeile ist zu lang');
|
||||
}
|
||||
if (buffer) yield buffer.replace(/\r$/, '');
|
||||
}
|
||||
|
||||
async function* iterateBoundedUploadLogChunks(chunks, maxBytes, onBytes) {
|
||||
const BufferImpl = typeof require === 'function' ? require('node:buffer').Buffer : null;
|
||||
let total = 0;
|
||||
for await (const chunk of chunks) {
|
||||
const bytes = BufferImpl ? BufferImpl.byteLength(String(chunk), 'utf8') : String(chunk).length;
|
||||
total += bytes;
|
||||
if (total > maxBytes) throw new Error('Upload-Log überschreitet das Leselimit');
|
||||
if (typeof onBytes === 'function') onBytes(bytes);
|
||||
yield chunk;
|
||||
}
|
||||
}
|
||||
|
||||
async function* iterateUploadLogEntries(filePath, options = {}) {
|
||||
const fsImpl = options.fs || (typeof require === 'function' ? require('node:fs') : null);
|
||||
if (!options.lines && !fsImpl?.createReadStream) throw new Error('Upload-Log-Stream ist nicht verfügbar');
|
||||
const yieldEvery = Number.isFinite(Number(options.yieldEvery)) ? Math.max(1, Math.floor(Number(options.yieldEvery))) : 1000;
|
||||
const maxLineLength = Number.isFinite(Number(options.maxLineLength)) ? Math.max(1, Math.floor(Number(options.maxLineLength))) : 65536;
|
||||
const maxBytes = Number.isFinite(Number(options.maxBytes)) ? Math.max(1, Math.floor(Number(options.maxBytes))) : 256 * 1024 * 1024;
|
||||
const yieldFn = typeof options.yieldFn === 'function' ? options.yieldFn : (() => new Promise(resolve => setImmediate(resolve)));
|
||||
const input = options.lines ? null : fsImpl.createReadStream(filePath, { encoding: 'utf8', highWaterMark: 32768 });
|
||||
const lines = options.lines || iterateBoundedUploadLogLines(iterateBoundedUploadLogChunks(input, maxBytes, options.onBytes), maxLineLength);
|
||||
let count = 0;
|
||||
try {
|
||||
for await (const line of lines) {
|
||||
if (String(line).length > maxLineLength) throw new Error('Upload-Log-Zeile ist zu lang');
|
||||
const parsed = parseUploadLogLine(line);
|
||||
if (parsed) yield parsed;
|
||||
count++;
|
||||
if (count % yieldEvery === 0) await yieldFn();
|
||||
}
|
||||
} finally {
|
||||
if (input && !input.destroyed && typeof input.destroy === 'function') input.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
async function readUploadLogEntries(filePath, options = {}) {
|
||||
const entries = [];
|
||||
for await (const entry of iterateUploadLogEntries(filePath, options)) entries.push(entry);
|
||||
return entries;
|
||||
}
|
||||
|
||||
function summarizeBatchPlan(payload) {
|
||||
@@ -73,7 +133,7 @@
|
||||
})}\r\n`;
|
||||
}
|
||||
|
||||
const api = { formatUploadLogLine, parseUploadLogLine, summarizeBatchPlan, formatUploadPlanLogLine };
|
||||
const api = { formatUploadLogLine, parseUploadLogLine, iterateUploadLogEntries, readUploadLogEntries, summarizeBatchPlan, formatUploadPlanLogLine };
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||
else if (root) root.UploadLog = api;
|
||||
})(typeof window !== 'undefined' ? window : this);
|
||||
|
||||
@@ -489,6 +489,7 @@ class UploadManager extends EventEmitter {
|
||||
finalStatus = status;
|
||||
|
||||
const result = {
|
||||
jobId,
|
||||
hoster: task.hoster,
|
||||
status,
|
||||
error: payload.error || null,
|
||||
|
||||
Reference in New Issue
Block a user