diff --git a/lib/import-preflight.js b/lib/import-preflight.js index 48ca61d..3efe501 100644 --- a/lib/import-preflight.js +++ b/lib/import-preflight.js @@ -11,8 +11,15 @@ const { applyFilenameFilter } = filenameFilter; function normalizePathValue(value) { - const text = String(value ?? '').trim(); - return text ? path.normalize(text) : ''; + let text = String(value ?? '').trim(); + if (!text) return ''; + const uncNamespace = text.match(/^[\\/]{2}\?[\\/]UNC[\\/]/i); + if (uncNamespace) text = `\\\\${text.slice(uncNamespace[0].length)}`; + else { + const driveNamespace = text.match(/^[\\/]{2}\?[\\/](?=[A-Za-z]:[\\/])/); + if (driveNamespace) text = text.slice(driveNamespace[0].length); + } + return path.normalize(text); } function normalizeEntry(value) { @@ -52,6 +59,25 @@ function unavailableReason(result) { return ''; } +async function inspectReadableImportPath(filePath, openPath) { + let fileHandle = null; + try { + fileHandle = await openPath(filePath, 'r'); + const fileStat = await fileHandle.stat(); + if (!fileStat.isFile()) return { exists: true, readable: false, size: fileStat.size }; + return { exists: true, readable: true, size: fileStat.size }; + } catch (error) { + if (error && (error.code === 'ENOENT' || error.code === 'ENOTDIR')) return { exists: false }; + return { exists: true, readable: false }; + } finally { + if (fileHandle) { + try { + await fileHandle.close(); + } catch {} + } + } +} + async function inspectImportEntries(entries, options = {}) { const input = Array.isArray(entries) ? entries : []; const caseInsensitive = options.caseInsensitive ?? (typeof process === 'object' ? process.platform === 'win32' : true); @@ -111,20 +137,32 @@ async function inspectImportEntries(entries, options = {}) { }; } +function normalizeSelectedHosters(values) { + return Array.from(new Set((Array.isArray(values) ? values : []) + .map(value => String(value ?? '').trim()) + .filter(Boolean))); +} + +function isImportPairEligible(file, hoster, hosterSettings = {}) { + const maxSizeMb = Number(hosterSettings?.[hoster]?.maxSizeMb); + return !(maxSizeMb > 0 && Number(file?.size) > maxSizeMb * 1024 * 1024); +} + +function getEligibleImportHosters(file, selectedHosters, hosterSettings = {}) { + return normalizeSelectedHosters(selectedHosters) + .filter(hoster => isImportPairEligible(file, hoster, hosterSettings)); +} + function summarizeImportPlan(input = {}) { const inspection = input.inspection && typeof input.inspection === 'object' ? input.inspection : {}; const accepted = Array.isArray(inspection.accepted) ? inspection.accepted : []; - const selectedHosters = Array.from(new Set((Array.isArray(input.selectedHosters) ? input.selectedHosters : []) - .map(value => String(value ?? '').trim()) - .filter(Boolean))); + const selectedHosters = normalizeSelectedHosters(input.selectedHosters); const settings = input.hosterSettings && typeof input.hosterSettings === 'object' ? input.hosterSettings : {}; - let sizeLimitedJobCount = 0; + let jobCount = 0; for (const file of accepted) { - for (const hoster of selectedHosters) { - const maxSizeMb = Number(settings[hoster]?.maxSizeMb); - if (maxSizeMb > 0 && Number(file.size) > maxSizeMb * 1024 * 1024) sizeLimitedJobCount++; - } + jobCount += getEligibleImportHosters(file, selectedHosters, settings).length; } + const sizeLimitedJobCount = accepted.length * selectedHosters.length - jobCount; return { candidateCount: Number(inspection.candidateCount) || 0, duplicateCount: Number(inspection.duplicateCount) || 0, @@ -132,13 +170,16 @@ function summarizeImportPlan(input = {}) { unavailableCount: Number(inspection.unavailableCount) || 0, acceptedCount: accepted.length, targetCount: selectedHosters.length, - jobCount: accepted.length * selectedHosters.length - sizeLimitedJobCount, + jobCount, sizeLimitedJobCount }; } return { + getEligibleImportHosters, inspectImportEntries, + inspectReadableImportPath, + isImportPairEligible, summarizeImportPlan }; }); diff --git a/main.js b/main.js index e291414..da14e41 100644 --- a/main.js +++ b/main.js @@ -55,7 +55,7 @@ const { buildFailedUploadSummary, buildTerminalJobSnapshots } = require('./lib/u const { selectPublicUploadUrl } = require('./lib/upload-confirmation'); const { createBatchMutationGate } = require('./lib/batch-mutation-gate'); const { createUploadStartReservation } = require('./lib/upload-start-reservation'); -const { inspectImportEntries } = require('./lib/import-preflight'); +const { inspectImportEntries, inspectReadableImportPath } = require('./lib/import-preflight'); const _eventLoopDelay = monitorEventLoopDelay({ resolution: 10 }); _eventLoopDelay.enable(); @@ -2255,22 +2255,7 @@ ipcMain.handle('inspect-import-files', async (_event, payload) => { existingPaths: input.existingPaths, filenameFilter: currentConfig.globalSettings?.filenameFilter, concurrency: 8, - inspectPath: async filePath => { - let fileStat; - try { - fileStat = await fs.promises.stat(filePath); - } catch (error) { - if (error && error.code === 'ENOENT') return { exists: false }; - return { exists: true, readable: false }; - } - if (!fileStat.isFile()) return { exists: true, readable: false, size: fileStat.size }; - try { - await fs.promises.access(filePath, fs.constants.R_OK); - } catch { - return { exists: true, readable: false, size: fileStat.size }; - } - return { exists: true, readable: true, size: fileStat.size }; - } + inspectPath: filePath => inspectReadableImportPath(filePath, fs.promises.open) }); }); diff --git a/renderer/app.js b/renderer/app.js index 95acca4..945d546 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -1285,14 +1285,17 @@ function closeHosterModal() { } async function applyHosterSelection() { + if (_pendingImportInspections > 0) return false; selectedUploadHosters = Array.from(document.querySelectorAll('input[data-hoster-modal]:checked')) .map(input => input.dataset.hosterModal); // Move pending files to selectedFiles on confirm - const pendingPaths = new Set(_pendingFiles.map(f => f.path)); - if (_pendingFiles.length > 0) { - selectedFiles.push(..._pendingFiles); - _pendingFiles = []; + const admittedFiles = _pendingFiles.filter(file => window.ImportPreflight + .getEligibleImportHosters(file, selectedUploadHosters, hosterSettings).length > 0); + const pendingPaths = new Set(admittedFiles.map(f => f.path)); + if (admittedFiles.length > 0) { + selectedFiles.push(...admittedFiles); } + _pendingFiles = []; clearDedupKeysForPaths(pendingPaths); renderHosterSummary(); @@ -1314,8 +1317,12 @@ async function applyHosterSelection() { } function cancelHosterModal() { + _importGeneration++; + _pendingImportInspections = 0; + _importCoordination = Promise.resolve(); _pendingFiles = []; _pendingImportInspection = null; + syncImportConfirmationState(); closeHosterModal(); } @@ -1554,6 +1561,8 @@ function setupDragDrop() { let _pendingFiles = []; // Files waiting for hoster modal confirmation let _pendingImportInspection = null; let _importCoordination = Promise.resolve(); +let _importGeneration = 0; +let _pendingImportInspections = 0; let _addingDropped = false; @@ -1595,13 +1604,24 @@ function getImportPlanHosters() { : getSelectedHosters(); } -function renderImportPlanSummary() { - if (!_pendingImportInspection || !window.ImportPreflight) return; - const summary = window.ImportPreflight.summarizeImportPlan({ +function getImportPlanSummary() { + if (!_pendingImportInspection || !window.ImportPreflight) return null; + return window.ImportPreflight.summarizeImportPlan({ inspection: _pendingImportInspection, selectedHosters: getImportPlanHosters(), hosterSettings }); +} + +function syncImportConfirmationState() { + const confirmButton = document.getElementById('confirmHosterModalBtn'); + if (confirmButton) confirmButton.disabled = _pendingImportInspections > 0; +} + +function renderImportPlanSummary() { + const summary = getImportPlanSummary(); + syncImportConfirmationState(); + if (!summary) return; const values = { importPlanCandidates: summary.candidateCount, importPlanDuplicates: summary.duplicateCount, @@ -1618,21 +1638,41 @@ function renderImportPlanSummary() { } } +function formatRejectedImportBalance(inspection) { + const values = [ + ['Kandidaten', inspection.candidateCount], + ['Bereits vorhanden / dupliziert', inspection.duplicateCount], + ['Durch Dateinamenfilter ausgeschlossen', inspection.filteredCount], + ['Fehlend / unlesbar / leer', inspection.unavailableCount], + ['Akzeptierte Dateien', inspection.acceptedCount] + ]; + return values.map(([label, value]) => `${localizeUiText(label)}: ${Number(value) || 0}`).join(' · '); +} + function existingImportPaths() { return [...selectedFiles.map(file => file.path), ..._pendingFiles.map(file => file.path), ...queueJobs.map(job => job.file)]; } -function coordinateImportEntries(entries) { +function coordinateImportEntries(entries, inspectEntries) { + const candidates = Array.isArray(entries) ? entries : []; + if (candidates.length === 0) return Promise.resolve(null); + const generation = _importGeneration; + _pendingImportInspections++; + syncImportConfirmationState(); const run = async () => { - const candidates = Array.isArray(entries) ? entries : []; - if (candidates.length === 0) return null; + if (generation !== _importGeneration) return null; let inspection; try { - inspection = await window.api.inspectImportFiles(candidates, existingImportPaths()); + const inspect = typeof inspectEntries === 'function' + ? inspectEntries + : (values, existingPaths) => window.api.inspectImportFiles(values, existingPaths); + inspection = await inspect(candidates, existingImportPaths()); } catch { + if (generation !== _importGeneration) return null; showCopyToast('Vorabprüfung fehlgeschlagen.', 6500); return null; } + if (generation !== _importGeneration) return null; mergePendingImportInspection(inspection); if (inspection.accepted.length > 0) { if (document.getElementById('hosterModal')?.style.display === 'flex') { @@ -1643,23 +1683,24 @@ function coordinateImportEntries(entries) { clearDedupKeysForPaths(acceptedPaths); _pendingFiles.push(...inspection.accepted); if (document.getElementById('hosterModal')?.style.display === 'flex') { - syncSelectedUploadHosters(); renderHosterModal(); } else { openHosterModal(); } } else if (_pendingFiles.length > 0) { renderImportPlanSummary(); - } else if (inspection.candidateCount > 0 && inspection.duplicateCount === inspection.candidateCount) { - showCopyToast('Auswahl ist bereits in den Upload-Aufträgen.'); - _pendingImportInspection = null; } else { - showCopyToast('Keine Dateien wurden akzeptiert.', 6500); + showCopyToast(formatRejectedImportBalance(inspection), 6500); _pendingImportInspection = null; } return inspection; }; - const pending = _importCoordination.then(run, run); + const operation = _importCoordination.then(run, run); + const pending = operation.finally(() => { + if (generation !== _importGeneration) return; + _pendingImportInspections = Math.max(0, _pendingImportInspections - 1); + renderImportPlanSummary(); + }); _importCoordination = pending.catch(() => {}); return pending; } @@ -1858,7 +1899,7 @@ function buildQueuePreview() { for (const file of selectedFiles) { for (const hoster of hosters) { const key = `${file.path}|${hoster}`; - if (!existingKeys.has(key) && !_completedUploadKeys.has(key) && !_suppressedPreviewKeys.has(key)) { + if (window.ImportPreflight.isImportPairEligible(file, hoster, hosterSettings) && !existingKeys.has(key) && !_completedUploadKeys.has(key) && !_suppressedPreviewKeys.has(key)) { const job = { id: `preview-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, file: file.path, fileName: file.name, hoster, diff --git a/tests/import-preflight.test.js b/tests/import-preflight.test.js index 2735434..d9d960b 100644 --- a/tests/import-preflight.test.js +++ b/tests/import-preflight.test.js @@ -2,7 +2,9 @@ const { describe, it } = require('node:test'); const assert = require('node:assert/strict'); const { + getEligibleImportHosters, inspectImportEntries, + inspectReadableImportPath, summarizeImportPlan } = require('../lib/import-preflight'); @@ -81,6 +83,72 @@ describe('import preflight', () => { }); }); + 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, + acceptedCount: 1, + targetCount: 2, + jobCount: 1, + sizeLimitedJobCount: 1 + }); + }); + + 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 }; + } + }); + + 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; diff --git a/tests/ui-smoke.js b/tests/ui-smoke.js index e9735fa..1816951 100644 --- a/tests/ui-smoke.js +++ b/tests/ui-smoke.js @@ -478,7 +478,7 @@ setTimeout(async () => { if (!duplicateDropDebuggerWasAttached && wc.debugger.isAttached()) wc.debugger.detach(); try { fs.unlinkSync(duplicateDropFixture); } catch {} } - check('Dropping a file already in the upload jobs explains the duplicate instead of doing nothing', duplicateDropState?.modal === 'none' && duplicateDropState.pending === 0 && duplicateDropState.shown === true && duplicateDropState.toast === 'Auswahl ist bereits in den Upload-Aufträgen.'); + check('Dropping a file already in the upload jobs explains the exact duplicate balance instead of doing nothing', duplicateDropState?.modal === 'none' && duplicateDropState.pending === 0 && duplicateDropState.shown === true && duplicateDropState.toast === 'Kandidaten: 1 · Bereits vorhanden / dupliziert: 1 · Durch Dateinamenfilter ausgeschlossen: 0 · Fehlend / unlesbar / leer: 0 · Akzeptierte Dateien: 0'); const startDisabled = await wc.executeJavaScript('document.getElementById("startUploadBtn")?.disabled'); check('Start button disabled initially', startDisabled === true); @@ -1577,6 +1577,12 @@ setTimeout(async () => { const importPreflightMinimumFit = await wc.executeJavaScript('(() => { const card = document.querySelector("#hosterModal .modal-card"); const summary = document.getElementById("importPlanSummary"); const cardRect = card?.getBoundingClientRect(); return Boolean(cardRect && summary && cardRect.left >= 0 && cardRect.right <= innerWidth && cardRect.top >= 0 && cardRect.bottom <= innerHeight && summary.scrollWidth <= summary.clientWidth + 1 && [...summary.querySelectorAll("dd")].every(value => value.getBoundingClientRect().width > 0)); })()'); await setWindowBounds(importPreflightBounds); check('Import preflight remains contained and readable at the minimum window size', importPreflightMinimumFit === true); + const importPreflightAdmission = await wc.executeJavaScript('(async () => { await applyHosterSelection(); return { selected: selectedFiles.map(file => file.name), jobs: queueJobs.map(job => ({ file: job.fileName, hoster: job.hoster, status: job.status })) }; })()'); + check('Configured size-limit pairs never create preview jobs and fully ineligible files leave no selected-file residue', importPreflightAdmission.selected.length === 2 && importPreflightAdmission.selected.includes('Existing.720p.bin') && importPreflightAdmission.selected.includes('Episode.720p.mkv') && !importPreflightAdmission.selected.includes('Large.720p.custom') && importPreflightAdmission.jobs.length === 2 && importPreflightAdmission.jobs.every(job => job.file !== 'Large.720p.custom' && job.hoster === filenameFilterImport.available[0] && job.status === 'preview')); + const explicitEmptyHosterSelection = await wc.executeJavaScript('(async () => { const fixtures = ' + JSON.stringify(importPreflightFixtures) + '; cancelHosterModal(); selectedFiles = []; _pendingFiles = []; _pendingImportInspection = null; queueJobs = []; rebuildJobIndex(); selectedUploadHosters = getAvailableHosters().slice(0, 1).map(item => item.name); const inspect = entries => ({ candidateCount: entries.length, duplicateCount: 0, filteredCount: 0, unavailableCount: 0, acceptedCount: entries.length, accepted: entries.map(entry => ({ path: entry.path, name: entry.name, size: entry.size })), duplicates: [], filtered: [], unavailable: [] }); await coordinateImportEntries([{ path: fixtures.floatingAccepted, name: "Floating.720p.mkv", size: 7 }], async entries => inspect(entries)); const selected = document.querySelector("input[data-hoster-modal]:checked"); selected?.click(); let releaseSecond; const second = coordinateImportEntries([{ path: fixtures.desktopAccepted, name: "Desktop.720p.mkv", size: 7 }], async entries => new Promise(resolve => { releaseSecond = () => resolve(inspect(entries)); })); const disabledWhilePending = document.getElementById("confirmHosterModalBtn").disabled; for (let index = 0; index < 20 && !releaseSecond; index++) await Promise.resolve(); releaseSecond?.(); await second; return { disabledWhilePending, checked: [...document.querySelectorAll("input[data-hoster-modal]:checked")].map(input => input.dataset.hosterModal), targets: Number(document.getElementById("importPlanTargets").textContent) }; })()'); + check('A later inspection preserves an explicitly empty hoster selection and confirmation stays disabled while it is pending', explicitEmptyHosterSelection.disabledWhilePending === true && explicitEmptyHosterSelection.checked.length === 0 && explicitEmptyHosterSelection.targets === 0); + const cancelledImportGeneration = await wc.executeJavaScript('(async () => { const fixtures = ' + JSON.stringify(importPreflightFixtures) + '; cancelHosterModal(); selectedFiles = []; _pendingFiles = []; _pendingImportInspection = null; queueJobs = []; rebuildJobIndex(); selectedUploadHosters = getAvailableHosters().slice(0, 1).map(item => item.name); const result = entry => ({ candidateCount: 1, duplicateCount: 0, filteredCount: 0, unavailableCount: 0, acceptedCount: 1, accepted: [{ path: entry.path, name: entry.name, size: entry.size }], duplicates: [], filtered: [], unavailable: [] }); let runningCalls = 0; let queuedCalls = 0; let releaseRunning; const runningEntry = { path: fixtures.floatingAccepted, name: "Floating.720p.mkv", size: 7 }; const queuedEntry = { path: fixtures.desktopAccepted, name: "Desktop.720p.mkv", size: 7 }; const running = coordinateImportEntries([runningEntry], async () => { runningCalls++; return new Promise(resolve => { releaseRunning = () => resolve(result(runningEntry)); }); }); for (let index = 0; index < 20 && !releaseRunning; index++) await Promise.resolve(); const queued = coordinateImportEntries([queuedEntry], async () => { queuedCalls++; return result(queuedEntry); }); const disabledWhilePending = document.getElementById("confirmHosterModalBtn").disabled; cancelHosterModal(); releaseRunning?.(); await Promise.all([running, queued]); await new Promise(resolve => setTimeout(resolve, 25)); return { runningCalls, queuedCalls, disabledWhilePending, modal: document.getElementById("hosterModal").style.display, pending: _pendingFiles.length, inspection: _pendingImportInspection }; })()'); + check('Cancelling invalidates running and queued results from the old import generation', cancelledImportGeneration.runningCalls === 1 && cancelledImportGeneration.queuedCalls === 0 && cancelledImportGeneration.disabledWhilePending === true && cancelledImportGeneration.modal !== 'flex' && cancelledImportGeneration.pending === 0 && cancelledImportGeneration.inspection === null); const filenameFilterDropPaths = await wc.executeJavaScript('(async () => { const fixtures = ' + JSON.stringify(importPreflightFixtures) + '; cancelHosterModal(); await addDropTargetEntries([{ path: fixtures.floatingAccepted }, { path: fixtures.floatingFiltered }]); const floating = _pendingFiles.map(file => file.name); cancelHosterModal(); await addDroppedFiles([{ path: fixtures.desktopAccepted, name: "Desktop.720p.mkv", size: 7, type: "video/x-matroska" }, { path: fixtures.desktopFiltered, name: "Desktop.1080p.mkv", size: 7, type: "video/x-matroska" }]); const desktop = _pendingFiles.map(file => file.name); cancelHosterModal(); return { floating, desktop }; })()'); check('Filename filter applies identically to floating and native desktop drops', JSON.stringify(filenameFilterDropPaths.floating) === JSON.stringify(['Floating.720p.mkv']) && JSON.stringify(filenameFilterDropPaths.desktop) === JSON.stringify(['Desktop.720p.mkv'])); await wc.executeJavaScript('(() => { selectedFiles = []; _pendingFiles = []; queueJobs = []; rebuildJobIndex(); const toast = document.getElementById("copyToast"); toast.textContent = ""; toast.classList.remove("show"); config.globalSettings.folderMonitor = { ...(config.globalSettings.folderMonitor || {}), hosters: ["voe.sx"], autoStart: false }; })()'); @@ -1585,8 +1591,8 @@ setTimeout(async () => { const filenameFilterFolderMonitor = await wc.executeJavaScript('(() => ({ files: selectedFiles.map(file => file.name), jobs: queueJobs.map(job => job.fileName), modal: document.getElementById("hosterModal")?.style.display, toast: document.getElementById("copyToast")?.textContent }))()'); check('Filename filter also applies to monitored folders with preset destinations', JSON.stringify(filenameFilterFolderMonitor.files) === JSON.stringify(['Watched.720p.mkv']) && filenameFilterFolderMonitor.jobs.every(name => name === 'Watched.720p.mkv') && filenameFilterFolderMonitor.modal !== 'flex' && /1 von 2/.test(filenameFilterFolderMonitor.toast || '')); await wc.executeJavaScript('selectedFiles = []; queueJobs = []; rebuildJobIndex(); updateUploadView()'); - const filenameFilterRejectAll = await wc.executeJavaScript('(async () => { const rejected = ' + JSON.stringify(importPreflightFixtures.rejected) + '; cancelHosterModal(); const toast = document.getElementById("copyToast"); toast.textContent = ""; toast.classList.remove("show"); const action = document.getElementById("filenameFilterActionInput"); action.value = "exclude"; action.dispatchEvent(new Event("change", { bubbles: true })); const rows = [...document.querySelectorAll("[data-filename-filter-condition]")]; rows[0].querySelector("[data-filename-filter-value]").value = "1080p"; rows[0].querySelector("[data-filename-filter-value]").dispatchEvent(new Event("input", { bubbles: true })); rows.slice(1).forEach(row => row.querySelector("[data-filename-filter-remove]")?.click()); config.globalSettings.filenameFilter = readFilenameFilterSettings(); await addPathsToQueue([{ path: rejected, name: "Only.1080p.mkv", size: 7 }]); return { modal: document.getElementById("hosterModal")?.style.display, pending: _pendingFiles.length, toast: toast.textContent, shown: toast.classList.contains("show") }; })()'); - check('A fully excluded import stays out of the queue and explains the result', filenameFilterRejectAll.modal !== 'flex' && filenameFilterRejectAll.pending === 0 && filenameFilterRejectAll.shown === true && filenameFilterRejectAll.toast === 'Keine Dateien wurden akzeptiert.'); + const filenameFilterRejectAll = await wc.executeJavaScript('(async () => { const rejected = ' + JSON.stringify(importPreflightFixtures.rejected) + '; cancelHosterModal(); const toast = document.getElementById("copyToast"); toast.textContent = ""; toast.classList.remove("show"); const action = document.getElementById("filenameFilterActionInput"); action.value = "exclude"; action.dispatchEvent(new Event("change", { bubbles: true })); const rows = [...document.querySelectorAll("[data-filename-filter-condition]")]; rows[0].querySelector("[data-filename-filter-value]").value = "1080p"; rows[0].querySelector("[data-filename-filter-value]").dispatchEvent(new Event("input", { bubbles: true })); rows.slice(1).forEach(row => row.querySelector("[data-filename-filter-remove]")?.click()); config.globalSettings.filenameFilter = readFilenameFilterSettings(); await addPathsToQueue([{ path: rejected, name: "Only.1080p.mkv", size: 7 }]); const german = toast.textContent; setUiLanguage("en"); toast.textContent = ""; toast.classList.remove("show"); await addPathsToQueue([{ path: rejected, name: "Only.1080p.mkv", size: 7 }]); const english = toast.textContent; setUiLanguage("de"); return { modal: document.getElementById("hosterModal")?.style.display, pending: _pendingFiles.length, german, english, shown: toast.classList.contains("show") }; })()'); + check('A fully excluded import stays out of the queue and keeps an exact bilingual balance visible', filenameFilterRejectAll.modal !== 'flex' && filenameFilterRejectAll.pending === 0 && filenameFilterRejectAll.shown === true && filenameFilterRejectAll.german === 'Kandidaten: 1 · Bereits vorhanden / dupliziert: 0 · Durch Dateinamenfilter ausgeschlossen: 1 · Fehlend / unlesbar / leer: 0 · Akzeptierte Dateien: 0' && filenameFilterRejectAll.english === 'Candidates: 1 · Already present / duplicated: 0 · Excluded by filename filter: 1 · Missing / unreadable / empty: 0 · Accepted files: 0'); await wc.executeJavaScript('hosterSettings = window.__importPreflightHosterSettings; delete window.__importPreflightHosterSettings; true'); fs.rmSync(importPreflightFolder, { recursive: true, force: true }); await wc.executeJavaScript('(() => { const enabled = document.getElementById("filenameFilterEnabledInput"); enabled.checked = false; enabled.dispatchEvent(new Event("change", { bubbles: true })); return saveSettings({ feedbackText: "Gespeichert" }); })()');