diff --git a/src/base/bittorrent/session.h b/src/base/bittorrent/session.h index cae03ba8342d..94f71e91b295 100644 --- a/src/base/bittorrent/session.h +++ b/src/base/bittorrent/session.h @@ -441,6 +441,19 @@ namespace BitTorrent virtual QStringList excludedFileNames() const = 0; virtual void setExcludedFileNames(const QStringList &newList) = 0; virtual void applyFilenameFilter(const PathList &files, QList &priorities) = 0; + virtual bool isAdvancedFilterEnabled() const = 0; + virtual void setAdvancedFilterEnabled(bool enabled) = 0; + virtual Tag advancedFilterTargetTag() const = 0; + virtual void setAdvancedFilterTargetTag(const Tag &tag) = 0; + virtual qint64 advancedFilterMinFileSize() const = 0; + virtual void setAdvancedFilterMinFileSize(qint64 size) = 0; + virtual qint64 advancedFilterMaxFileSize() const = 0; + virtual void setAdvancedFilterMaxFileSize(qint64 size) = 0; + virtual QString advancedFilterWhitelistPatterns() const = 0; + virtual void setAdvancedFilterWhitelistPatterns(const QString &patterns) = 0; + virtual QString advancedFilterBlacklistPatterns() const = 0; + virtual void setAdvancedFilterBlacklistPatterns(const QString &patterns) = 0; + virtual void applyAdvancedFilter(const TagSet &tags, const TorrentInfo &torrentInfo, const PathList &files, QList &priorities) = 0; virtual QStringList bannedIPs() const = 0; virtual void setBannedIPs(const QStringList &newList) = 0; virtual ResumeDataStorageType resumeDataStorageType() const = 0; diff --git a/src/base/bittorrent/sessionimpl.cpp b/src/base/bittorrent/sessionimpl.cpp index 708258e93885..2be182082ac9 100644 --- a/src/base/bittorrent/sessionimpl.cpp +++ b/src/base/bittorrent/sessionimpl.cpp @@ -568,6 +568,12 @@ SessionImpl::SessionImpl(QObject *parent) , m_requestQueueSize(BITTORRENT_SESSION_KEY(u"RequestQueueSize"_s), 500) , m_isExcludedFileNamesEnabled(BITTORRENT_KEY(u"ExcludedFileNamesEnabled"_s), false) , m_excludedFileNames(BITTORRENT_SESSION_KEY(u"ExcludedFileNames"_s)) + , m_isAdvancedFilterEnabled(BITTORRENT_KEY(u"AdvancedFilterEnabled"_s), false) + , m_advancedFilterTargetTag(BITTORRENT_SESSION_KEY(u"AdvancedFilterTargetTag"_s)) + , m_advancedFilterMinFileSize(BITTORRENT_SESSION_KEY(u"AdvancedFilterMinFileSize"_s), 0) + , m_advancedFilterMaxFileSize(BITTORRENT_SESSION_KEY(u"AdvancedFilterMaxFileSize"_s), 0) + , m_advancedFilterWhitelistPatterns(BITTORRENT_SESSION_KEY(u"AdvancedFilterWhitelistPatterns"_s)) + , m_advancedFilterBlacklistPatterns(BITTORRENT_SESSION_KEY(u"AdvancedFilterBlacklistPatterns"_s)) , m_bannedIPs(u"State/BannedIPs"_s, QStringList(), Algorithm::sorted) , m_resumeDataStorageType(BITTORRENT_SESSION_KEY(u"ResumeDataStorageType"_s), ResumeDataStorageType::Legacy) , m_isMergeTrackersEnabled(BITTORRENT_KEY(u"MergeTrackersEnabled"_s), false) @@ -2828,10 +2834,17 @@ bool SessionImpl::addTorrent_impl(const TorrentDescriptor &source, const AddTorr QList filePriorities = addTorrentParams.filePriorities; // Filename filter should be applied before `findIncompleteFiles()` is called. - if (filePriorities.isEmpty() && isExcludedFileNamesEnabled()) + if (filePriorities.isEmpty()) { // Check file name blacklist when priorities are not explicitly set - applyFilenameFilter(filePaths, filePriorities); + if (isExcludedFileNamesEnabled() && isAdvancedFilterEnabled()) + { + applyAdvancedFilter(addTorrentParams.tags, torrentInfo, filePaths, filePriorities); + } + else if (isExcludedFileNamesEnabled()) + { + applyFilenameFilter(filePaths, filePriorities); + } } if (!loadTorrentParams.hasFinishedStatus) @@ -4156,6 +4169,237 @@ void SessionImpl::applyFilenameFilter(const PathList &files, QList &priorities) +{ + if (!isAdvancedFilterEnabled()) + return; + + // Check if target tag is set and matches + const Tag targetTag = advancedFilterTargetTag(); + if (!targetTag.toString().isEmpty()) + { + if (!tags.contains(targetTag)) + return; + } + + if (!torrentInfo.isValid()) + return; + + const qint64 minSize = advancedFilterMinFileSize(); + const qint64 maxSize = advancedFilterMaxFileSize(); + + const auto isFilenameWhitelisted = [patterns = m_advancedFilterWhitelistRegExpList](const Path &fileName) + { + if (patterns.isEmpty()) + return true; // No whitelist means all files are whitelisted + + return std::ranges::any_of(patterns, [&fileName](const QRegularExpression &re) + { + return re.match(fileName.data()).hasMatch(); + }); + }; + + const auto isFilenameBlacklisted = [patterns = m_advancedFilterBlacklistRegExpList](const Path &fileName) + { + if (patterns.isEmpty()) + return false; // No blacklist means no files are blacklisted + + return std::ranges::any_of(patterns, [&fileName](const QRegularExpression &re) + { + return re.match(fileName.data()).hasMatch(); + }); + }; + + priorities.resize(files.count(), DownloadPriority::Normal); + for (qsizetype i = 0; i < priorities.size(); ++i) + { + if (priorities[i] == BitTorrent::DownloadPriority::Ignored) + continue; + + const Path &filePath = files.at(i); + const qint64 fileSize = torrentInfo.fileSize(i); + + // Logic from auto.sh: + // exclude = (if whitelist exists then (NOT match whitelist) else true) AND (size < min OR blacklist) + // When whitelist is empty: exclude if (size < min OR size > max OR blacklisted) + // When whitelist is set: exclude if (NOT whitelisted) AND (size < min OR size > max OR blacklisted) + // This means files matching whitelist are kept regardless of size/blacklist + + const bool hasWhitelist = !m_advancedFilterWhitelistRegExpList.isEmpty(); + const bool matchesWhitelist = isFilenameWhitelisted(filePath); + const bool isTooSmall = (minSize > 0 && fileSize < minSize); + const bool isTooLarge = (maxSize > 0 && fileSize > maxSize); + const bool matchesBlacklist = isFilenameBlacklisted(filePath); + + bool shouldExclude = false; + if (hasWhitelist) + { + // With whitelist: exclude if (NOT whitelisted) AND (size issue OR blacklisted) + shouldExclude = !matchesWhitelist && (isTooSmall || isTooLarge || matchesBlacklist); + } + else + { + // Without whitelist: exclude if (size issue OR blacklisted) + shouldExclude = isTooSmall || isTooLarge || matchesBlacklist; + } + + if (shouldExclude) + { + priorities[i] = BitTorrent::DownloadPriority::Ignored; + } + } +} + void SessionImpl::setBannedIPs(const QStringList &newList) { if (newList == m_bannedIPs) diff --git a/src/base/bittorrent/sessionimpl.h b/src/base/bittorrent/sessionimpl.h index bd2c836cab65..0db30b30b0c8 100644 --- a/src/base/bittorrent/sessionimpl.h +++ b/src/base/bittorrent/sessionimpl.h @@ -415,6 +415,19 @@ namespace BitTorrent QStringList excludedFileNames() const override; void setExcludedFileNames(const QStringList &excludedFileNames) override; void applyFilenameFilter(const PathList &files, QList &priorities) override; + bool isAdvancedFilterEnabled() const override; + void setAdvancedFilterEnabled(bool enabled) override; + Tag advancedFilterTargetTag() const override; + void setAdvancedFilterTargetTag(const Tag &tag) override; + qint64 advancedFilterMinFileSize() const override; + void setAdvancedFilterMinFileSize(qint64 size) override; + qint64 advancedFilterMaxFileSize() const override; + void setAdvancedFilterMaxFileSize(qint64 size) override; + QString advancedFilterWhitelistPatterns() const override; + void setAdvancedFilterWhitelistPatterns(const QString &patterns) override; + QString advancedFilterBlacklistPatterns() const override; + void setAdvancedFilterBlacklistPatterns(const QString &patterns) override; + void applyAdvancedFilter(const TagSet &tags, const TorrentInfo &torrentInfo, const PathList &files, QList &priorities) override; QStringList bannedIPs() const override; void setBannedIPs(const QStringList &newList) override; ResumeDataStorageType resumeDataStorageType() const override; @@ -567,6 +580,7 @@ namespace BitTorrent void disableIPFilter(); void processTorrentShareLimits(TorrentImpl *torrent); void populateExcludedFileNamesRegExpList(); + void populateAdvancedFilterRegExpLists(); void prepareStartup(); void handleLoadedResumeData(ResumeSessionContext *context); void processNextResumeData(ResumeSessionContext *context); @@ -767,6 +781,12 @@ namespace BitTorrent CachedSettingValue m_requestQueueSize; CachedSettingValue m_isExcludedFileNamesEnabled; CachedSettingValue m_excludedFileNames; + CachedSettingValue m_isAdvancedFilterEnabled; + CachedSettingValue m_advancedFilterTargetTag; + CachedSettingValue m_advancedFilterMinFileSize; + CachedSettingValue m_advancedFilterMaxFileSize; + CachedSettingValue m_advancedFilterWhitelistPatterns; + CachedSettingValue m_advancedFilterBlacklistPatterns; CachedSettingValue m_bannedIPs; CachedSettingValue m_resumeDataStorageType; CachedSettingValue m_isMergeTrackersEnabled; @@ -803,6 +823,8 @@ namespace BitTorrent QList m_additionalTrackerEntries; QList m_additionalTrackerEntriesFromURL; QList m_excludedFileNamesRegExpList; + QList m_advancedFilterWhitelistRegExpList; + QList m_advancedFilterBlacklistRegExpList; // Statistics mutable QElapsedTimer m_statisticsLastUpdateTimer; diff --git a/src/gui/optionsdialog.cpp b/src/gui/optionsdialog.cpp index 526582a7992b..c5f20317df61 100644 --- a/src/gui/optionsdialog.cpp +++ b/src/gui/optionsdialog.cpp @@ -661,6 +661,73 @@ void OptionsDialog::loadDownloadsTabOptions() m_ui->groupExcludedFileNames->setChecked(session->isExcludedFileNamesEnabled()); m_ui->textExcludedFileNames->setPlainText(session->excludedFileNames().join(u'\n')); + // Load advanced filter settings + m_ui->groupAdvancedFilter->setChecked(session->isAdvancedFilterEnabled()); + + // Populate tag combo box + m_ui->comboAdvancedFilterTag->clear(); + m_ui->comboAdvancedFilterTag->addItem(tr("(None)"), QString()); + const TagSet tags = session->tags(); + for (const Tag &tag : tags) + { + m_ui->comboAdvancedFilterTag->addItem(tag.toString(), tag.toString()); + } + + const Tag currentTag = session->advancedFilterTargetTag(); + const int tagIndex = m_ui->comboAdvancedFilterTag->findData(currentTag.toString()); + m_ui->comboAdvancedFilterTag->setCurrentIndex(tagIndex >= 0 ? tagIndex : 0); + + // Load file size settings + const qint64 minSize = session->advancedFilterMinFileSize(); + const qint64 maxSize = session->advancedFilterMaxFileSize(); + + // Convert bytes to appropriate units for min size + if (minSize == 0) + { + m_ui->spinAdvancedFilterMinSize->setValue(0); + m_ui->comboAdvancedFilterMinSizeUnit->setCurrentIndex(1); // MB + } + else if (minSize % (1024LL * 1024LL * 1024LL) == 0) + { + m_ui->spinAdvancedFilterMinSize->setValue(minSize / (1024LL * 1024LL * 1024LL)); + m_ui->comboAdvancedFilterMinSizeUnit->setCurrentIndex(2); // GB + } + else if (minSize % (1024LL * 1024LL) == 0) + { + m_ui->spinAdvancedFilterMinSize->setValue(minSize / (1024LL * 1024LL)); + m_ui->comboAdvancedFilterMinSizeUnit->setCurrentIndex(1); // MB + } + else + { + m_ui->spinAdvancedFilterMinSize->setValue(minSize / 1024LL); + m_ui->comboAdvancedFilterMinSizeUnit->setCurrentIndex(0); // KB + } + + // Convert bytes to appropriate units for max size + if (maxSize == 0) + { + m_ui->spinAdvancedFilterMaxSize->setValue(0); + m_ui->comboAdvancedFilterMaxSizeUnit->setCurrentIndex(1); // MB + } + else if (maxSize % (1024LL * 1024LL * 1024LL) == 0) + { + m_ui->spinAdvancedFilterMaxSize->setValue(maxSize / (1024LL * 1024LL * 1024LL)); + m_ui->comboAdvancedFilterMaxSizeUnit->setCurrentIndex(2); // GB + } + else if (maxSize % (1024LL * 1024LL) == 0) + { + m_ui->spinAdvancedFilterMaxSize->setValue(maxSize / (1024LL * 1024LL)); + m_ui->comboAdvancedFilterMaxSizeUnit->setCurrentIndex(1); // MB + } + else + { + m_ui->spinAdvancedFilterMaxSize->setValue(maxSize / 1024LL); + m_ui->comboAdvancedFilterMaxSizeUnit->setCurrentIndex(0); // KB + } + + m_ui->textAdvancedFilterWhitelist->setPlainText(session->advancedFilterWhitelistPatterns()); + m_ui->textAdvancedFilterBlacklist->setPlainText(session->advancedFilterBlacklistPatterns()); + m_ui->groupMailNotification->setChecked(pref->isMailNotificationEnabled()); m_ui->senderEmailTxt->setText(pref->getMailNotificationSender()); m_ui->lineEditDestEmail->setText(pref->getMailNotificationEmail()); @@ -746,6 +813,14 @@ void OptionsDialog::loadDownloadsTabOptions() connect(m_ui->groupExcludedFileNames, &QGroupBox::toggled, this, &ThisType::enableApplyButton); connect(m_ui->textExcludedFileNames, &QPlainTextEdit::textChanged, this, &ThisType::enableApplyButton); + connect(m_ui->groupAdvancedFilter, &QGroupBox::toggled, this, &ThisType::enableApplyButton); + connect(m_ui->comboAdvancedFilterTag, &QComboBox::currentIndexChanged, this, &ThisType::enableApplyButton); + connect(m_ui->spinAdvancedFilterMinSize, &QSpinBox::valueChanged, this, &ThisType::enableApplyButton); + connect(m_ui->comboAdvancedFilterMinSizeUnit, &QComboBox::currentIndexChanged, this, &ThisType::enableApplyButton); + connect(m_ui->spinAdvancedFilterMaxSize, &QSpinBox::valueChanged, this, &ThisType::enableApplyButton); + connect(m_ui->comboAdvancedFilterMaxSizeUnit, &QComboBox::currentIndexChanged, this, &ThisType::enableApplyButton); + connect(m_ui->textAdvancedFilterWhitelist, &QPlainTextEdit::textChanged, this, &ThisType::enableApplyButton); + connect(m_ui->textAdvancedFilterBlacklist, &QPlainTextEdit::textChanged, this, &ThisType::enableApplyButton); connect(m_ui->removeWatchedFolderButton, &QAbstractButton::clicked, this, &ThisType::enableApplyButton); connect(m_ui->groupMailNotification, &QGroupBox::toggled, this, &ThisType::enableApplyButton); @@ -814,6 +889,22 @@ void OptionsDialog::saveDownloadsTabOptions() const session->setExcludedFileNamesEnabled(m_ui->groupExcludedFileNames->isChecked()); session->setExcludedFileNames(m_ui->textExcludedFileNames->toPlainText().split(u'\n', Qt::SkipEmptyParts)); + // Save advanced filter settings + session->setAdvancedFilterEnabled(m_ui->groupAdvancedFilter->isChecked()); + + const QString selectedTagData = m_ui->comboAdvancedFilterTag->currentData().toString(); + session->setAdvancedFilterTargetTag(Tag(selectedTagData)); + + const qint64 minSizeBytes = getFileSizeInBytes(m_ui->spinAdvancedFilterMinSize->value(), + m_ui->comboAdvancedFilterMinSizeUnit->currentIndex()); + const qint64 maxSizeBytes = getFileSizeInBytes(m_ui->spinAdvancedFilterMaxSize->value(), + m_ui->comboAdvancedFilterMaxSizeUnit->currentIndex()); + + session->setAdvancedFilterMinFileSize(minSizeBytes); + session->setAdvancedFilterMaxFileSize(maxSizeBytes); + session->setAdvancedFilterWhitelistPatterns(m_ui->textAdvancedFilterWhitelist->toPlainText()); + session->setAdvancedFilterBlacklistPatterns(m_ui->textAdvancedFilterBlacklist->toPlainText()); + pref->setMailNotificationEnabled(m_ui->groupMailNotification->isChecked()); pref->setMailNotificationSender(m_ui->senderEmailTxt->text()); pref->setMailNotificationEmail(m_ui->lineEditDestEmail->text()); @@ -1736,6 +1827,11 @@ bool OptionsDialog::applySettings() m_ui->tabSelection->setCurrentRow(TAB_SPEED); return false; } + if (!advancedFilterSettingsOk()) + { + m_ui->tabSelection->setCurrentRow(TAB_DOWNLOADS); + return false; + } #ifndef DISABLE_WEBUI if (isWebUIEnabled() && !webUIAuthenticationOk()) { @@ -2177,6 +2273,40 @@ bool OptionsDialog::schedTimesOk() return true; } +qint64 OptionsDialog::getFileSizeInBytes(const int sizeValue, const int unitIndex) const +{ + if (sizeValue <= 0) + return 0; + + if (unitIndex == 0) // KB + return static_cast(sizeValue) * 1024LL; + if (unitIndex == 1) // MB + return static_cast(sizeValue) * 1024LL * 1024LL; + // GB + return static_cast(sizeValue) * 1024LL * 1024LL * 1024LL; +} + +bool OptionsDialog::advancedFilterSettingsOk() +{ + if (!m_ui->groupAdvancedFilter->isChecked()) + return true; + + const qint64 minSizeBytes = getFileSizeInBytes(m_ui->spinAdvancedFilterMinSize->value(), + m_ui->comboAdvancedFilterMinSizeUnit->currentIndex()); + const qint64 maxSizeBytes = getFileSizeInBytes(m_ui->spinAdvancedFilterMaxSize->value(), + m_ui->comboAdvancedFilterMaxSizeUnit->currentIndex()); + + // Validate: min size should not be greater than max size (if both are set) + if (minSizeBytes > 0 && maxSizeBytes > 0 && minSizeBytes > maxSizeBytes) + { + QMessageBox::warning(this, tr("Invalid Configuration"), + tr("Minimum file size cannot be greater than maximum file size.")); + return false; + } + + return true; +} + void OptionsDialog::on_banListButton_clicked() { auto *dialog = new BanListOptionsDialog(this); diff --git a/src/gui/optionsdialog.h b/src/gui/optionsdialog.h index 2a62661985da..8ff1b111ef14 100644 --- a/src/gui/optionsdialog.h +++ b/src/gui/optionsdialog.h @@ -203,6 +203,8 @@ private slots: #endif bool schedTimesOk(); + bool advancedFilterSettingsOk(); + qint64 getFileSizeInBytes(int sizeValue, int unitIndex) const; Ui::OptionsDialog *m_ui = nullptr; SettingValue m_storeDialogSize; diff --git a/src/gui/optionsdialog.ui b/src/gui/optionsdialog.ui index fe427909f498..26f3462b0360 100644 --- a/src/gui/optionsdialog.ui +++ b/src/gui/optionsdialog.ui @@ -1566,6 +1566,192 @@ readme[0-9].txt: filter 'readme1.txt', 'readme2.txt' but not 'readme10.txt'. + + + + Advanced filtering mode + + + Advanced file filtering with tag-based targeting, size limits, and pattern matching. + +⚠ Only applies to automatically added torrents (Watched Folder, RSS). Manually added torrents are unaffected. + +How it works: +1. If target tag is set → only filters torrents with that tag (others skip filtering) +2. For each file in filtered torrents: + • If whitelist patterns exist: Files matching whitelist are always kept; all other files are excluded if they are too small, too large, or match blacklist + • If whitelist is empty: Files are excluded if they are too small, too large, or match blacklist + +In short: Whitelist protects files from all other filters. Size and blacklist filters apply to non-whitelisted files. + + + true + + + false + + + + + + Target tag: + + + + + + + Select a tag to filter. Leave empty to apply filter to all torrents. + + + + + + + Min file size: + + + + + + + Minimum file size. Files smaller than this will be excluded. Set to 0 to disable. + + + + + + 0 + + + 999999 + + + + + + + + KB + + + + + MB + + + + + GB + + + + + + + + Max file size: + + + + + + + Maximum file size. Files larger than this will be excluded. Set to 0 to disable. + + + + + + 0 + + + 999999 + + + + + + + + KB + + + + + MB + + + + + GB + + + + + + + + Whitelist patterns: + + + + + + + Regex patterns for whitelisted file names (one per line). +Files matching these patterns are protected and will always be downloaded, bypassing size and blacklist filters. +Patterns are automatically anchored to match the complete filename. +Example: .*\.exe matches test.exe but not test.exea +Leave empty to apply size and blacklist filters to all files. + + + + 16777215 + 80 + + + + true + + + QPlainTextEdit::LineWrapMode::NoWrap + + + + + + + Blacklist patterns: + + + + + + + Regex patterns for blacklisted file names (one per line). +Files matching these patterns will be excluded from download (unless they match whitelist patterns). +Patterns are automatically anchored to match the complete filename. +Example: .*\.zip matches test.zip but not test.zipa +Leave empty to only apply size filters. + + + + 16777215 + 80 + + + + true + + + QPlainTextEdit::LineWrapMode::NoWrap + + + + + + diff --git a/src/webui/api/appcontroller.cpp b/src/webui/api/appcontroller.cpp index 11d6bee7e12d..deb6c2d97df0 100644 --- a/src/webui/api/appcontroller.cpp +++ b/src/webui/api/appcontroller.cpp @@ -205,6 +205,14 @@ void AppController::preferencesAction() data[u"excluded_file_names_enabled"_s] = session->isExcludedFileNamesEnabled(); data[u"excluded_file_names"_s] = session->excludedFileNames().join(u'\n'); + // Advanced filter + data[u"advanced_filter_enabled"_s] = session->isAdvancedFilterEnabled(); + data[u"advanced_filter_target_tag"_s] = session->advancedFilterTargetTag().toString(); + data[u"advanced_filter_min_file_size"_s] = session->advancedFilterMinFileSize(); + data[u"advanced_filter_max_file_size"_s] = session->advancedFilterMaxFileSize(); + data[u"advanced_filter_whitelist_patterns"_s] = session->advancedFilterWhitelistPatterns(); + data[u"advanced_filter_blacklist_patterns"_s] = session->advancedFilterBlacklistPatterns(); + // Email notification upon download completion data[u"mail_notification_enabled"_s] = pref->isMailNotificationEnabled(); data[u"mail_notification_sender"_s] = pref->getMailNotificationSender(); @@ -664,6 +672,20 @@ void AppController::setPreferencesAction() if (hasKey(u"excluded_file_names"_s)) session->setExcludedFileNames(it.value().toString().split(u'\n')); + // Advanced filter + if (hasKey(u"advanced_filter_enabled"_s)) + session->setAdvancedFilterEnabled(it.value().toBool()); + if (hasKey(u"advanced_filter_target_tag"_s)) + session->setAdvancedFilterTargetTag(Tag(it.value().toString())); + if (hasKey(u"advanced_filter_min_file_size"_s)) + session->setAdvancedFilterMinFileSize(it.value().toLongLong()); + if (hasKey(u"advanced_filter_max_file_size"_s)) + session->setAdvancedFilterMaxFileSize(it.value().toLongLong()); + if (hasKey(u"advanced_filter_whitelist_patterns"_s)) + session->setAdvancedFilterWhitelistPatterns(it.value().toString()); + if (hasKey(u"advanced_filter_blacklist_patterns"_s)) + session->setAdvancedFilterBlacklistPatterns(it.value().toString()); + // Email notification upon download completion if (hasKey(u"mail_notification_enabled"_s)) pref->setMailNotificationEnabled(it.value().toBool()); diff --git a/src/webui/www/private/views/preferences.html b/src/webui/www/private/views/preferences.html index 76257e9bbe40..fb68d394af1b 100644 --- a/src/webui/www/private/views/preferences.html +++ b/src/webui/www/private/views/preferences.html @@ -297,6 +297,78 @@ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + +
+ + + + +
+ + + + +
+ + + +
+ + + +
+
@@ -1797,6 +1869,7 @@ updateExportDirFinEnabled: updateExportDirFinEnabled, addWatchFolder: addWatchFolder, updateExcludedFileNamesEnabled: updateExcludedFileNamesEnabled, + updateAdvancedFilterEnabled: updateAdvancedFilterEnabled, changeWatchFolderSelect: changeWatchFolderSelect, updateMailNotification: updateMailNotification, updateMailAuthSettings: updateMailAuthSettings, @@ -1835,6 +1908,34 @@ const localPreferences = new window.qBittorrent.LocalPreferences.LocalPreferences(); + // Constants for file size units + const BYTES_PER_KB = 1024; + const BYTES_PER_MB = 1048576; + const BYTES_PER_GB = 1073741824; + + // Helper function to convert bytes to display unit + const convertBytesToDisplayUnit = (bytes, inputId, unitId) => { + const input = document.getElementById(inputId); + const unitSelect = document.getElementById(unitId); + + if (bytes === 0) { + input.value = 0; + unitSelect.value = BYTES_PER_MB.toString(); + } + else if (bytes % BYTES_PER_GB === 0) { + input.value = bytes / BYTES_PER_GB; + unitSelect.value = BYTES_PER_GB.toString(); + } + else if (bytes % BYTES_PER_MB === 0) { + input.value = bytes / BYTES_PER_MB; + unitSelect.value = BYTES_PER_MB.toString(); + } + else { + input.value = bytes / BYTES_PER_KB; + unitSelect.value = BYTES_PER_KB.toString(); + } + }; + // Behavior tab const numberInputLimiter = (input) => { const inputNumber = Number(input.value); @@ -1950,6 +2051,26 @@ const updateExcludedFileNamesEnabled = () => { const isAExcludedFileNamesEnabled = document.getElementById("excludedFileNamesCheckbox").checked; document.getElementById("excludedFileNamesTextarea").disabled = !isAExcludedFileNamesEnabled; + + // Advanced filter requires excluded file names to be enabled + const advancedFilterCheckbox = document.getElementById("advancedFilterCheckbox"); + advancedFilterCheckbox.disabled = !isAExcludedFileNamesEnabled; + if (!isAExcludedFileNamesEnabled) { + advancedFilterCheckbox.checked = false; + updateAdvancedFilterEnabled(); + } + }; + + const updateAdvancedFilterEnabled = () => { + const isExcludedFileNamesEnabled = document.getElementById("excludedFileNamesCheckbox").checked; + const isAdvancedFilterEnabled = document.getElementById("advancedFilterCheckbox").checked && isExcludedFileNamesEnabled; + document.getElementById("advancedFilterTagSelect").disabled = !isAdvancedFilterEnabled; + document.getElementById("advancedFilterMinSize").disabled = !isAdvancedFilterEnabled; + document.getElementById("advancedFilterMinSizeUnit").disabled = !isAdvancedFilterEnabled; + document.getElementById("advancedFilterMaxSize").disabled = !isAdvancedFilterEnabled; + document.getElementById("advancedFilterMaxSizeUnit").disabled = !isAdvancedFilterEnabled; + document.getElementById("advancedFilterWhitelist").disabled = !isAdvancedFilterEnabled; + document.getElementById("advancedFilterBlacklist").disabled = !isAdvancedFilterEnabled; }; const updateExportDirEnabled = () => { @@ -2377,6 +2498,52 @@ document.getElementById("excludedFileNamesCheckbox").checked = pref.excluded_file_names_enabled; document.getElementById("excludedFileNamesTextarea").value = pref.excluded_file_names; + // Advanced filter + document.getElementById("advancedFilterCheckbox").checked = pref.advanced_filter_enabled || false; + updateExcludedFileNamesEnabled(); + + // Populate tag dropdown - need to fetch tags first + const tagSelect = document.getElementById("advancedFilterTagSelect"); + tagSelect.innerHTML = ''; + // We'll populate tags dynamically when preferences are loaded + + // Set target tag + const targetTag = pref.advanced_filter_target_tag || ""; + // Will be set after tags are loaded + + // Load file sizes and convert from bytes + const minSizeBytes = pref.advanced_filter_min_file_size || 0; + const maxSizeBytes = pref.advanced_filter_max_file_size || 0; + + // Convert sizes to appropriate units using helper function + convertBytesToDisplayUnit(minSizeBytes, "advancedFilterMinSize", "advancedFilterMinSizeUnit"); + convertBytesToDisplayUnit(maxSizeBytes, "advancedFilterMaxSize", "advancedFilterMaxSizeUnit"); + + document.getElementById("advancedFilterWhitelist").value = pref.advanced_filter_whitelist_patterns || ""; + document.getElementById("advancedFilterBlacklist").value = pref.advanced_filter_blacklist_patterns || ""; + + // Fetch and populate tags + fetch("api/v2/torrents/tags") + .then(response => response.json()) + .then(tags => { + const tagSelect = document.getElementById("advancedFilterTagSelect"); + tagSelect.innerHTML = ''; + for (const tag of tags) { + const option = document.createElement("option"); + option.value = tag; + option.textContent = tag; + if (tag === targetTag) + option.selected = true; + tagSelect.appendChild(option); + } + }) + .catch((error) => { + // Log error if tag fetching fails + console.error("Failed to fetch tags for advanced filter:", error); + }); + + updateAdvancedFilterEnabled(); + // Email notification upon download completion document.getElementById("mail_notification_checkbox").checked = pref.mail_notification_enabled; document.getElementById("src_email_txt").value = pref.mail_notification_sender; @@ -2775,6 +2942,30 @@ settings["excluded_file_names_enabled"] = document.getElementById("excludedFileNamesCheckbox").checked; settings["excluded_file_names"] = document.getElementById("excludedFileNamesTextarea").value; + // Advanced filter + settings["advanced_filter_enabled"] = document.getElementById("advancedFilterCheckbox").checked; + settings["advanced_filter_target_tag"] = document.getElementById("advancedFilterTagSelect").value; + + // Convert file sizes to bytes + const minSizeValue = Number.parseInt(document.getElementById("advancedFilterMinSize").value, 10) || 0; + const minSizeUnit = Number.parseInt(document.getElementById("advancedFilterMinSizeUnit").value, 10); + const minSizeBytes = minSizeValue * minSizeUnit; + settings["advanced_filter_min_file_size"] = minSizeBytes; + + const maxSizeValue = Number.parseInt(document.getElementById("advancedFilterMaxSize").value, 10) || 0; + const maxSizeUnit = Number.parseInt(document.getElementById("advancedFilterMaxSizeUnit").value, 10); + const maxSizeBytes = maxSizeValue * maxSizeUnit; + settings["advanced_filter_max_file_size"] = maxSizeBytes; + + // Validate: min size should not be greater than max size (if both are set) + if (settings["advanced_filter_enabled"] && (minSizeBytes > 0) && (maxSizeBytes > 0) && (minSizeBytes > maxSizeBytes)) { + alert("QBT_TR(Minimum file size cannot be greater than maximum file size.)QBT_TR[CONTEXT=OptionsDialog]"); + return; + } + + settings["advanced_filter_whitelist_patterns"] = document.getElementById("advancedFilterWhitelist").value; + settings["advanced_filter_blacklist_patterns"] = document.getElementById("advancedFilterBlacklist").value; + // Email notification upon download completion settings["mail_notification_enabled"] = document.getElementById("mail_notification_checkbox").checked; settings["mail_notification_sender"] = document.getElementById("src_email_txt").value;