diff --git a/README.md b/README.md
index 81089a3..0f1bf39 100644
--- a/README.md
+++ b/README.md
@@ -8,20 +8,22 @@ Multi Hoster Uploader is a Windows desktop application for sending file batches
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.20. Use the release page for the executables and the full English changelog.
+The latest public release is version 2.1.21. 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.
+- 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.
- 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 total, remaining, running, completed, and failed upload activity from the persistent sidebar telemetry.
+- Read remaining, total, running, connection, 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.
diff --git a/lib/config-store.js b/lib/config-store.js
index c9c872f..901ff97 100644
--- a/lib/config-store.js
+++ b/lib/config-store.js
@@ -78,6 +78,12 @@ const DEFAULTS = {
lastBrowseDirectory: '',
removeFromQueueOnDone: false,
deleteSourceAfterSuccessfulUpload: false,
+ filenameFilter: {
+ enabled: false,
+ action: 'include',
+ matchMode: 'all',
+ conditions: []
+ },
showDropTarget: false,
globalMaxSpeedKbs: 0, // 0 = unlimited global speed
pendingQueue: null,
diff --git a/lib/filename-filter.js b/lib/filename-filter.js
new file mode 100644
index 0000000..765e6c4
--- /dev/null
+++ b/lib/filename-filter.js
@@ -0,0 +1,70 @@
+(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
+ };
+});
diff --git a/package-lock.json b/package-lock.json
index 3853433..ec9d9f2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "multi-hoster-uploader",
- "version": "2.1.20",
+ "version": "2.1.21",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "multi-hoster-uploader",
- "version": "2.1.20",
+ "version": "2.1.21",
"dependencies": {
"chokidar": "^3.6.0",
"undici": "^7.29.0",
diff --git a/package.json b/package.json
index 76c140c..6484f7b 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "multi-hoster-uploader",
- "version": "2.1.20",
+ "version": "2.1.21",
"description": "Upload files to doodstream, voe, vidmoly, byse simultaneously",
"main": "main.js",
"scripts": {
diff --git a/renderer/app.js b/renderer/app.js
index 06b9469..991b8dd 100644
--- a/renderer/app.js
+++ b/renderer/app.js
@@ -651,12 +651,14 @@ async function init() {
const name = p.split('\\').pop().split('/').pop();
newFiles.push({ path: p, name, size: null });
}
- if (newFiles.length > 0) {
- const newPaths = new Set(newFiles.map(f => f.path));
+ const admitted = admitFilenameFilter(newFiles);
+ if (admitted.accepted.length > 0) {
+ const newPaths = new Set(admitted.accepted.map(f => f.path));
clearDedupKeysForPaths(newPaths);
- selectedFiles.push(...newFiles);
+ selectedFiles.push(...admitted.accepted);
buildQueuePreview();
updateUploadView();
+ if (admitted.active && admitted.excluded.length > 0) showFilenameFilterResult(admitted);
if (fm.autoStart && !uploading && !healthCheckRunning) {
startUpload();
} else if (uploading) {
@@ -666,6 +668,8 @@ async function init() {
await startSelectedUpload(newJobs);
}
}
+ } else if (admitted.active && admitted.total > 0) {
+ showFilenameFilterResult(admitted);
}
} else {
// No pre-selected hosters: open modal
@@ -1260,6 +1264,12 @@ function renderHosterModal() {
function openHosterModal() {
syncSelectedUploadHosters();
renderHosterModal();
+ const description = document.getElementById('hosterModalDescription');
+ if (description) {
+ description.textContent = _pendingImportSummary
+ ? formatFilenameFilterResult(_pendingImportSummary)
+ : localizeUiText('Dateien wurden hinzugefügt. Wähle jetzt die Hoster für den Upload.');
+ }
modalController.open('hosterModal', {
initialFocus: '#cancelHosterModalBtn',
fallbackFocus: '#addFilesBtn',
@@ -1298,10 +1308,12 @@ async function applyHosterSelection() {
updateUploadView();
persistQueueStateSoon(true); // immediate persist after adding files
closeHosterModal();
+ _pendingImportSummary = null;
}
function cancelHosterModal() {
_pendingFiles = [];
+ _pendingImportSummary = null;
closeHosterModal();
}
@@ -1538,9 +1550,37 @@ function setupDragDrop() {
}
let _pendingFiles = []; // Files waiting for hoster modal confirmation
+let _pendingImportSummary = null;
let _addingDropped = false;
+function admitFilenameFilter(files) {
+ return window.FilenameFilter.applyFilenameFilter(files, config?.globalSettings?.filenameFilter);
+}
+
+function mergePendingImportSummary(result) {
+ if (!result.active) {
+ if (!_pendingImportSummary) _pendingImportSummary = null;
+ return;
+ }
+ const current = _pendingImportSummary || { total: 0, accepted: 0, excluded: 0 };
+ _pendingImportSummary = {
+ total: current.total + result.total,
+ accepted: current.accepted + result.accepted.length,
+ excluded: current.excluded + result.excluded.length
+ };
+}
+
+function formatFilenameFilterResult(result) {
+ const accepted = Array.isArray(result.accepted) ? result.accepted.length : Number(result.accepted) || 0;
+ const excluded = Array.isArray(result.excluded) ? result.excluded.length : Number(result.excluded) || 0;
+ return localizeUiText(`${accepted} von ${result.total} Dateien werden hinzugefügt. ${excluded} durch den Dateinamenfilter ausgeschlossen.`);
+}
+
+function showFilenameFilterResult(result) {
+ showCopyToast(formatFilenameFilterResult(result), 6500);
+}
+
async function addDropTargetEntries(entries) {
const files = [];
for (const entry of Array.isArray(entries) ? entries : []) {
@@ -1563,12 +1603,7 @@ async function addDroppedFiles(fileList) {
_addingDropped = true;
try {
const files = Array.from(fileList);
- const existingPaths = new Set([
- ...selectedFiles.map(f => f.path),
- ..._pendingFiles.map(f => f.path)
- ]);
- const newFiles = [];
- let duplicateCount = 0;
+ const entries = [];
for (const file of files) {
let filePath = '';
@@ -1583,14 +1618,9 @@ async function addDroppedFiles(fileList) {
for (const fp of folderFiles) {
const p = typeof fp === 'string' ? fp : (fp && fp.path);
if (!p) continue;
- if (existingPaths.has(p)) {
- duplicateCount++;
- continue;
- }
const name = typeof fp === 'string' ? p.split('\\').pop().split('/').pop() : (fp.name || p.split('\\').pop().split('/').pop());
const size = typeof fp === 'string' ? null : (fp.size || 0);
- newFiles.push({ path: p, name, size });
- existingPaths.add(p);
+ entries.push({ path: p, name, size });
}
continue;
}
@@ -1599,20 +1629,9 @@ async function addDroppedFiles(fileList) {
// Regular file
const fileName = file.name || '';
- if (!existingPaths.has(filePath)) {
- newFiles.push({ path: filePath, name: fileName, size: file.size });
- existingPaths.add(filePath);
- } else {
- duplicateCount++;
- }
- }
-
- if (newFiles.length > 0) {
- _pendingFiles.push(...newFiles);
- openHosterModal();
- } else if (duplicateCount > 0) {
- showCopyToast('Auswahl ist bereits in den Upload-Aufträgen.');
+ entries.push({ path: filePath, name: fileName, size: file.size });
}
+ addPathsToQueue(entries);
} finally {
_addingDropped = false;
}
@@ -1648,11 +1667,15 @@ function addPathsToQueue(paths) {
newFiles.push({ path: p, name, size });
if (size === null || size === undefined || size === 0) pendingSizeFetch.push(p);
}
- if (newFiles.length > 0) {
- _pendingFiles.push(...newFiles);
+ const admitted = admitFilenameFilter(newFiles);
+ if (admitted.accepted.length > 0) {
+ const acceptedPaths = new Set(admitted.accepted.map(file => file.path));
+ const acceptedSizeFetch = pendingSizeFetch.filter(filePath => acceptedPaths.has(filePath));
+ _pendingFiles.push(...admitted.accepted);
+ mergePendingImportSummary(admitted);
openHosterModal();
- if (pendingSizeFetch.length > 0 && window.api.getFileSizes) {
- window.api.getFileSizes(pendingSizeFetch).then((sizeMap) => {
+ if (acceptedSizeFetch.length > 0 && window.api.getFileSizes) {
+ window.api.getFileSizes(acceptedSizeFetch).then((sizeMap) => {
if (!sizeMap || typeof sizeMap !== 'object') return;
let changed = false;
for (const f of _pendingFiles) {
@@ -1671,6 +1694,10 @@ function addPathsToQueue(paths) {
}
}).catch(() => {});
}
+ } else if (admitted.active && admitted.total > 0) {
+ showFilenameFilterResult(admitted);
+ } else if (Array.isArray(paths) && paths.length > 0) {
+ showCopyToast('Auswahl ist bereits in den Upload-Aufträgen.');
}
}
@@ -4605,6 +4632,79 @@ async function _renderLogPathsList(el) {
}
}
+function filenameFilterConditionRowHtml(condition = {}) {
+ const operator = condition.operator === 'notContains' ? 'notContains' : 'contains';
+ return `
+
@@ -5247,6 +5386,10 @@ function renderSettings() {
document.getElementById('chooseLogFilePathBtn')?.addEventListener('click', chooseLogFilePath);
document.getElementById('openLogFolderBtn')?.addEventListener('click', () => window.api.openLogFolder());
document.getElementById('manualUpdateCheckBtn')?.addEventListener('click', requestUpdateCheck);
+ container.querySelectorAll('[data-filename-filter-condition]').forEach(wireFilenameFilterConditionRow);
+ document.getElementById('addFilenameFilterConditionBtn')?.addEventListener('click', () => appendFilenameFilterCondition());
+ document.getElementById('filenameFilterEnabledInput')?.addEventListener('change', syncFilenameFilterControls);
+ syncFilenameFilterControls();
_syncHeaderUpdateState();
container.querySelectorAll('.settings-autosave').forEach((input) => {
const eventName = input.type === 'checkbox' || input.tagName === 'SELECT' ? 'change' : 'input';
@@ -5393,6 +5536,7 @@ async function performSaveSettings(options = {}) {
scaleParallelUploads: elChk('scaleParallelUploadsInput', !!cur.scaleParallelUploads),
removeFromQueueOnDone: elChk('removeFromQueueOnDoneInput', !!cur.removeFromQueueOnDone),
deleteSourceAfterSuccessfulUpload: elChk('deleteSourceAfterSuccessfulUploadInput', !!cur.deleteSourceAfterSuccessfulUpload),
+ filenameFilter: readFilenameFilterSettings(),
showDropTarget: elChk('showDropTargetInput', !!cur.showDropTarget),
globalMaxSpeedKbs: (() => {
const el = document.getElementById('globalMaxSpeedMbsInput');
diff --git a/renderer/i18n.js b/renderer/i18n.js
index 96204eb..789d07a 100644
--- a/renderer/i18n.js
+++ b/renderer/i18n.js
@@ -37,6 +37,27 @@
['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'],
@@ -663,6 +684,7 @@
[/^(\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)'],
@@ -782,6 +804,7 @@
[/^(\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...'],
diff --git a/renderer/index.html b/renderer/index.html
index 94b8388..e5c87a0 100644
--- a/renderer/index.html
+++ b/renderer/index.html
@@ -209,10 +209,10 @@
-
Gesamt0
-
Verbindungen0
Verbleibend0
+
Gesamt0
Läuft0
+
Verbindungen0
Fertig0
Fehler0
Geschwindigkeit0 B/s
@@ -683,6 +683,7 @@
+
diff --git a/renderer/styles.css b/renderer/styles.css
index 410b2e3..b413c57 100644
--- a/renderer/styles.css
+++ b/renderer/styles.css
@@ -3303,6 +3303,103 @@ 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);
diff --git a/scripts/verify-public-release.mjs b/scripts/verify-public-release.mjs
index d998cdc..ce906ba 100644
--- a/scripts/verify-public-release.mjs
+++ b/scripts/verify-public-release.mjs
@@ -32,6 +32,7 @@ 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',
@@ -114,6 +115,7 @@ 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',
diff --git a/tests/config-store.test.js b/tests/config-store.test.js
index 0e3fece..9f19f1a 100644
--- a/tests/config-store.test.js
+++ b/tests/config-store.test.js
@@ -120,6 +120,12 @@ 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.history, []);
});
diff --git a/tests/filename-filter.test.js b/tests/filename-filter.test.js
new file mode 100644
index 0000000..cfbd3b3
--- /dev/null
+++ b/tests/filename-filter.test.js
@@ -0,0 +1,91 @@
+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']);
+ });
+});
diff --git a/tests/i18n.test.js b/tests/i18n.test.js
index a5bfad8..fd5cb88 100644
--- a/tests/i18n.test.js
+++ b/tests/i18n.test.js
@@ -74,6 +74,17 @@ 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'],
diff --git a/tests/ui-smoke.js b/tests/ui-smoke.js
index a1541f7..c946f0b 100644
--- a/tests/ui-smoke.js
+++ b/tests/ui-smoke.js
@@ -250,7 +250,7 @@ setTimeout(async () => {
const englishSidebarHeadings = await wc.executeJavaScript('[...document.querySelectorAll("#upload-view, #accounts-view, #history-view")].map(view => [view.querySelector(".view-sidebar-kicker")?.textContent?.trim(), view.querySelector(".view-sidebar-title")?.textContent?.trim()].join("|"))');
check('English sidebar hierarchy uses distinct translated kickers', englishSidebarHeadings.join('::') === 'Workspace|Uploads::Manage accounts|Accounts::Archive|History');
const englishTelemetryLabels = await wc.executeJavaScript('[...document.querySelectorAll("#uploadTelemetry .upload-telemetry-label")].map(el => el.textContent.trim()).join("|")');
- check('English upload telemetry is fully localized', englishTelemetryLabels === 'Total|Connections|Remaining|Running|Completed|Failed|Speed|ETA');
+ check('English upload telemetry is fully localized and ordered by relevance', englishTelemetryLabels === 'Remaining|Total|Running|Connections|Completed|Failed|Speed|ETA');
const englishLayoutFits = await wc.executeJavaScript('(() => { const states = [...document.querySelectorAll(".tab")].map(tab => { tab.click(); const view = document.querySelector(".view.active"); return view && view.scrollWidth <= view.clientWidth + 1; }); document.querySelector(".tab[data-view=upload]")?.click(); return states.every(Boolean) && document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1; })()');
check('English labels fit every main view without horizontal overflow', englishLayoutFits === true);
const speedSparklineAcrossTabs = await wc.executeJavaScript('(() => [...document.querySelectorAll(".tab")].map(tab => { tab.click(); const widget = document.getElementById("uploadSpeedSparkline"); const rect = widget?.getBoundingClientRect(); const style = widget && getComputedStyle(widget); return Boolean(widget && !widget.classList.contains("is-hidden") && style.visibility === "visible" && style.opacity === "1" && rect.width > 0 && rect.height > 0); }))()');
@@ -502,7 +502,7 @@ setTimeout(async () => {
check('Recent panel labels are consistently German', localizedRecentTabs === 'Dateien|Statistik');
const localizedTelemetry = await wc.executeJavaScript('[...document.querySelectorAll("#uploadTelemetry .upload-telemetry-label")].map(el => el.textContent.trim()).join("|")');
- check('Upload telemetry exposes all eight German labels', localizedTelemetry === 'Gesamt|Verbindungen|Verbleibend|Läuft|Fertig|Fehler|Geschwindigkeit|ETA');
+ check('Upload telemetry exposes all eight German labels in the requested order', localizedTelemetry === 'Verbleibend|Gesamt|Läuft|Verbindungen|Fertig|Fehler|Geschwindigkeit|ETA');
const initialTelemetryValues = await wc.executeJavaScript('[...document.querySelectorAll("#uploadTelemetry .upload-telemetry-value")].map(el => el.getAttribute("aria-label") || el.textContent.trim()).join("|")');
check('Upload telemetry starts with stable empty values', initialTelemetryValues === '0|0|0|0|0|0|0 B/s|--:--');
@@ -1432,6 +1432,25 @@ setTimeout(async () => {
await wc.executeJavaScript('document.querySelector("[data-settings-page=\\'uploads\\']")?.click()');
const uploadSettingsState = await wc.executeJavaScript('(() => { const activePage = document.querySelector(".settings-subpage.active"); return [activePage?.dataset.subpage, activePage?.querySelector("h3")?.textContent.trim(), document.querySelector("label[for=removeFromQueueOnDoneInput]")?.textContent.trim(), document.getElementById("removeFromQueueOnDoneInput")?.closest(".settings-option")?.querySelector(".settings-option-description")?.textContent.trim()].join("|"); })()');
check('Upload completion behavior is immediately findable', uploadSettingsState === 'uploads|Upload-Verhalten|Nach Abschluss aus der Liste entfernen|Erfolgreich hochgeladene Dateien verschwinden automatisch aus der Upload-Liste.');
+ const filenameFilterControls = await wc.executeJavaScript('(() => ({ api: typeof window.FilenameFilter?.applyFilenameFilter, enabled: document.getElementById("filenameFilterEnabledInput")?.checked, action: document.getElementById("filenameFilterActionInput")?.value, match: document.getElementById("filenameFilterMatchModeInput")?.value, rows: document.querySelectorAll("[data-filename-filter-condition]").length, add: Boolean(document.getElementById("addFilenameFilterConditionBtn")) }))()');
+ check('Filename filter starts disabled with a complete rule builder', filenameFilterControls.api === 'function' && filenameFilterControls.enabled === false && filenameFilterControls.action === 'include' && filenameFilterControls.match === 'all' && filenameFilterControls.rows === 1 && filenameFilterControls.add === true);
+ const filenameFilterGeometry = await wc.executeJavaScript('(() => { const rect = selector => { const r = document.querySelector(selector)?.getBoundingClientRect(); return r ? { left: r.left, right: r.right, top: r.top, bottom: r.bottom, width: r.width, height: r.height } : null; }; const valueLabel = rect("[data-filename-filter-value-label]"); const operatorLabel = rect("[data-filename-filter-operator-label]"); const value = rect("[data-filename-filter-value]"); const operator = rect("[data-filename-filter-operator]"); const remove = rect("[data-filename-filter-remove]"); const modeLabel = rect("label[for=filenameFilterMatchModeInput]"); const mode = rect("#filenameFilterMatchModeInput"); const actionLabel = rect("label[for=filenameFilterActionInput]"); const action = rect("#filenameFilterActionInput"); return { ruleLabelsAbove: valueLabel && operatorLabel && value && operator && valueLabel.bottom <= value.top - 4 && operatorLabel.bottom <= operator.top - 4, inputBeforeOperator: value && operator && value.left < operator.left, compactRule: value && operator && remove && operator.left - value.right >= 6 && operator.left - value.right <= 16 && remove.left - operator.right >= 6 && remove.left - operator.right <= 16, alignedRule: value && operator && remove && Math.abs(value.top - operator.top) <= 2 && Math.abs(operator.top - remove.top) <= 2 && Math.abs(value.height - operator.height) <= 2 && Math.abs(operator.height - remove.height) <= 2, policyBelowRule: value && mode && action && mode.top > value.bottom && action.top > value.bottom, policyOrder: mode && action && mode.left < action.left, policyLabelsAbove: modeLabel && mode && actionLabel && action && modeLabel.bottom <= mode.top - 4 && actionLabel.bottom <= action.top - 4, equalPolicyWidths: mode && action && Math.abs(mode.width - action.width) <= 2 }; })()');
+ check('Filename filter follows value, comparison, conditions, then action', filenameFilterGeometry.ruleLabelsAbove && filenameFilterGeometry.inputBeforeOperator && filenameFilterGeometry.compactRule && filenameFilterGeometry.alignedRule && filenameFilterGeometry.policyBelowRule && filenameFilterGeometry.policyOrder && filenameFilterGeometry.policyLabelsAbove && filenameFilterGeometry.equalPolicyWidths);
+ const filenameFilterPersistence = await wc.executeJavaScript('(async () => { const enabled = document.getElementById("filenameFilterEnabledInput"); enabled.checked = true; enabled.dispatchEvent(new Event("change", { bubbles: true })); const first = document.querySelector("[data-filename-filter-condition]"); first.querySelector("[data-filename-filter-operator]").value = "contains"; first.querySelector("[data-filename-filter-value]").value = "720p"; first.querySelector("[data-filename-filter-value]").dispatchEvent(new Event("input", { bubbles: true })); document.getElementById("addFilenameFilterConditionBtn").click(); const rows = [...document.querySelectorAll("[data-filename-filter-condition]")]; rows[1].querySelector("[data-filename-filter-operator]").value = "notContains"; rows[1].querySelector("[data-filename-filter-value]").value = "sample"; rows[1].querySelector("[data-filename-filter-value]").dispatchEvent(new Event("input", { bubbles: true })); const dirty = document.getElementById("saveSettingsBtn").disabled === false; await saveSettings({ feedbackText: "Gespeichert" }); const saved = (await window.api.getGlobalSettings()).filenameFilter; return { dirty, saved }; })()');
+ check('Filename filter conditions participate in dirty tracking and persist canonically', filenameFilterPersistence.dirty === true && filenameFilterPersistence.saved?.enabled === true && filenameFilterPersistence.saved?.action === 'include' && filenameFilterPersistence.saved?.matchMode === 'all' && JSON.stringify(filenameFilterPersistence.saved?.conditions) === JSON.stringify([{ operator: 'contains', value: '720p' }, { operator: 'notContains', value: 'sample' }]));
+ const filenameFilterImport = await wc.executeJavaScript('(() => { selectedFiles = []; _pendingFiles = []; queueJobs = []; rebuildJobIndex(); addPathsToQueue([{ path: "C:/filter/Episode.720p.mkv", name: "Episode.720p.mkv", size: 10 }, { path: "C:/filter/Episode.720p.Sample.mkv", name: "Episode.720p.Sample.mkv", size: 11 }, { path: "C:/filter/Episode.1080p.mkv", name: "Episode.1080p.mkv", size: 12 }]); return { modal: document.getElementById("hosterModal")?.style.display, description: document.getElementById("hosterModalDescription")?.textContent, pending: _pendingFiles.map(file => file.name) }; })()');
+ check('Filename filter previews accepted and excluded counts before host selection', filenameFilterImport.modal === 'flex' && filenameFilterImport.pending.length === 1 && filenameFilterImport.pending[0] === 'Episode.720p.mkv' && /1 von 3/.test(filenameFilterImport.description || '') && /2/.test(filenameFilterImport.description || ''));
+ const filenameFilterDropPaths = await wc.executeJavaScript('(async () => { cancelHosterModal(); await addDropTargetEntries([{ path: "C:/filter/Floating.720p.mkv" }, { path: "C:/filter/Floating.1080p.mkv" }]); const floating = _pendingFiles.map(file => file.name); cancelHosterModal(); await addDroppedFiles([{ path: "C:/filter/Desktop.720p.mkv", name: "Desktop.720p.mkv", size: 12, type: "video/x-matroska" }, { path: "C:/filter/Desktop.1080p.mkv", name: "Desktop.1080p.mkv", size: 12, 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 }; })()');
+ wc.send('folder-monitor:new-files', ['C:/filter/Watched.720p.mkv', 'C:/filter/Watched.1080p.mkv']);
+ await waitUntil(() => wc.executeJavaScript('selectedFiles.length === 1'));
+ 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('(() => { 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(); addPathsToQueue([{ path: "C:/filter/Only.1080p.mkv", name: "Only.1080p.mkv", size: 10 }]); 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 && /0 von 1/.test(filenameFilterRejectAll.toast || ''));
+ await wc.executeJavaScript('(() => { const enabled = document.getElementById("filenameFilterEnabledInput"); enabled.checked = false; enabled.dispatchEvent(new Event("change", { bubbles: true })); return saveSettings({ feedbackText: "Gespeichert" }); })()');
const plaintextCredentialOverride = await wc.executeJavaScript('(() => ({ control: document.getElementById("allowPlaintextCredentialStorageInput"), copy: document.body.textContent.includes("Unsichere Klartext-Speicherung"), bridge: typeof window.api.getSecretStoreStatus }))()');
check('Settings expose no plaintext credential storage override', plaintextCredentialOverride.control === null && plaintextCredentialOverride.copy === false && plaintextCredentialOverride.bridge === 'undefined');
const settingsTypography = await wc.executeJavaScript('(() => { const size = selector => parseFloat(getComputedStyle(document.querySelector(selector)).fontSize); return { heading: size(".settings-subpage.active .settings-page-header h3"), intro: size(".settings-subpage.active .settings-page-header p"), section: size(".settings-subpage.active .settings-section-label"), rowLabel: size(".settings-subpage.active .settings-row > label"), hint: size(".settings-subpage.active .hint"), optionLabel: size(".settings-subpage.active .settings-option-copy label"), optionDescription: size(".settings-subpage.active .settings-option-description"), navigation: size(".settings-nav-button"), search: size("#settingsSearchInput") }; })()');