diff --git a/src/base/CMakeLists.txt b/src/base/CMakeLists.txt index e116209353dd..91e3db412cae 100644 --- a/src/base/CMakeLists.txt +++ b/src/base/CMakeLists.txt @@ -43,6 +43,7 @@ add_library(qbt_base STATIC bittorrent/torrentdescriptor.h bittorrent/torrentimpl.h bittorrent/torrentinfo.h + bittorrent/torrentparamrules.h bittorrent/tracker.h bittorrent/trackerentry.h concepts/explicitlyconvertibleto.h @@ -148,6 +149,7 @@ add_library(qbt_base STATIC bittorrent/torrentdescriptor.cpp bittorrent/torrentimpl.cpp bittorrent/torrentinfo.cpp + bittorrent/torrentparamrules.cpp bittorrent/tracker.cpp bittorrent/trackerentry.cpp exceptions.cpp diff --git a/src/base/addtorrentmanager.cpp b/src/base/addtorrentmanager.cpp index a094dbd85acc..830b3ea129b7 100644 --- a/src/base/addtorrentmanager.cpp +++ b/src/base/addtorrentmanager.cpp @@ -32,6 +32,7 @@ #include "base/bittorrent/infohash.h" #include "base/bittorrent/session.h" #include "base/bittorrent/torrentdescriptor.h" +#include "base/bittorrent/torrentparamrules.h" #include "base/logger.h" #include "base/net/downloadmanager.h" #include "base/preferences.h" @@ -177,15 +178,15 @@ void AddTorrentManager::releaseTorrentFileGuard(const QString &source) } bool AddTorrentManager::processTorrent(const QString &source, const BitTorrent::TorrentDescriptor &torrentDescr - , const BitTorrent::AddTorrentParams &addTorrentParams) + , BitTorrent::AddTorrentParams addTorrentParams) { const BitTorrent::InfoHash infoHash = torrentDescr.infoHash(); + const bool hasMetadata = torrentDescr.info().has_value(); if (BitTorrent::Torrent *torrent = btSession()->findTorrent(infoHash)) { // a duplicate torrent is being added - const bool hasMetadata = torrentDescr.info().has_value(); if (hasMetadata) { // Trying to set metadata to existing torrent in case if it has none @@ -213,5 +214,9 @@ bool AddTorrentManager::processTorrent(const QString &source, const BitTorrent:: return false; } + // If metadata is not available now, rules will be applied after it is downloaded. + if (hasMetadata) + btSession()->applyTorrentParamRules(torrentDescr, &addTorrentParams); + return addTorrentToSession(source, torrentDescr, addTorrentParams); } diff --git a/src/base/addtorrentmanager.h b/src/base/addtorrentmanager.h index ef31ae4da12f..3a5d4ba97eae 100644 --- a/src/base/addtorrentmanager.h +++ b/src/base/addtorrentmanager.h @@ -81,7 +81,7 @@ class AddTorrentManager : public ApplicationComponent void onSessionTorrentAdded(BitTorrent::Torrent *torrent); void onSessionAddTorrentFailed(const BitTorrent::InfoHash &infoHash, const QString &reason); bool processTorrent(const QString &source, const BitTorrent::TorrentDescriptor &torrentDescr - , const BitTorrent::AddTorrentParams &addTorrentParams); + , BitTorrent::AddTorrentParams addTorrentParams); BitTorrent::Session *m_btSession = nullptr; QHash m_downloadedTorrents; diff --git a/src/base/bittorrent/session.h b/src/base/bittorrent/session.h index 54296562d73e..56b9cba439ec 100644 --- a/src/base/bittorrent/session.h +++ b/src/base/bittorrent/session.h @@ -32,6 +32,7 @@ #include #include +#include "base/bittorrent/torrentparamrules.h" #include "base/pathfwd.h" #include "base/tagset.h" #include "addtorrentparams.h" @@ -452,6 +453,7 @@ namespace BitTorrent virtual void banIP(const QString &ip) = 0; virtual bool isKnownTorrent(const InfoHash &infoHash) const = 0; + virtual void applyTorrentParamRules(const TorrentDescriptor &torrentDescr, AddTorrentParams *params) const = 0; virtual bool addTorrent(const TorrentDescriptor &torrentDescr, const AddTorrentParams ¶ms = {}) = 0; virtual bool deleteTorrent(const TorrentID &id, DeleteOption deleteOption = DeleteOption::DeleteTorrent) = 0; virtual bool downloadMetadata(const TorrentDescriptor &torrentDescr) = 0; diff --git a/src/base/bittorrent/sessionimpl.cpp b/src/base/bittorrent/sessionimpl.cpp index 356a2f13e5d1..a768ca773b71 100644 --- a/src/base/bittorrent/sessionimpl.cpp +++ b/src/base/bittorrent/sessionimpl.cpp @@ -107,6 +107,7 @@ using namespace std::chrono_literals; using namespace BitTorrent; const Path CATEGORIES_FILE_NAME {u"categories.json"_s}; +const Path TORRENT_PARAM_RULES_FILE_NAME {u"auto_torrent_customizer_rules.json"_s}; const int MAX_PROCESSING_RESUMEDATA_COUNT = 50; const int STATISTICS_SAVE_INTERVAL = std::chrono::milliseconds(15min).count(); @@ -580,6 +581,9 @@ SessionImpl::SessionImpl(QObject *parent) connect(m_ioThread.get(), &QThread::finished, m_fileSearcher, &QObject::deleteLater); connect(m_fileSearcher, &FileSearcher::searchFinished, this, &SessionImpl::fileSearchFinished); + m_torrentParamRules = new TorrentParamRules(this); + loadTorrentParamRules(); + m_ioThread->start(); initMetrics(); @@ -4842,6 +4846,11 @@ void SessionImpl::setMaxRatioAction(const MaxRatioAction act) m_maxRatioAction = static_cast(act); } +void SessionImpl::applyTorrentParamRules(const TorrentDescriptor &torrentDescr, AddTorrentParams *params) const +{ + m_torrentParamRules->apply(torrentDescr, params); +} + bool SessionImpl::isKnownTorrent(const InfoHash &infoHash) const { const bool isHybrid = infoHash.isHybrid(); @@ -4939,6 +4948,8 @@ void SessionImpl::handleTorrentUrlSeedsRemoved(TorrentImpl *const torrent, const void SessionImpl::handleTorrentMetadataReceived(TorrentImpl *const torrent) { + qDebug() << "Metadata received for " << torrent->name(); + m_torrentParamRules->apply(torrent); if (!torrentExportDirectory().isEmpty()) exportTorrentFile(torrent, torrentExportDirectory()); @@ -5200,6 +5211,45 @@ void SessionImpl::loadCategories() } } +void SessionImpl::loadTorrentParamRules() +{ + const Path path = specialFolderLocation(SpecialFolder::Config) / TORRENT_PARAM_RULES_FILE_NAME; + const QString pathStr = path.toString(); + if (!path.exists()) + { + LogMsg(tr("Auto torrent customizer rules not found at \"%1\"").arg(pathStr), Log::INFO); + return; + } + + constexpr int fileMaxSize = 1024 * 1024; + const auto readResult = Utils::IO::readFile(path, fileMaxSize); + if (!readResult) + { + LogMsg(tr("Failed to read auto torrent customizer rules file \"%1\"").arg(readResult.error().message), Log::WARNING); + return; + } + + QJsonParseError jsonError; + const auto jsonDoc = QJsonDocument::fromJson(readResult.value(), &jsonError); + if (jsonError.error != QJsonParseError::NoError) + { + LogMsg(tr("Failed to parse auto torrent customizer rules file: \"%1\". Error: \"%2\"") + .arg(pathStr, jsonError.errorString()), Log::WARNING); + return; + } + + if (!jsonDoc.isObject()) + { + LogMsg(tr("Failed to load auto torrent customizer rules from \"%1\". Error: \"Invalid data format\"") + .arg(pathStr), Log::WARNING); + return; + } + + m_torrentParamRules->clearRules(); + const size_t numRules = m_torrentParamRules->loadRulesFromJson(jsonDoc.object()); + LogMsg(tr("Loaded %1 auto torrent customizer rule(s) from \"%2\"", nullptr, numRules).arg(numRules).arg(pathStr), Log::INFO); +} + bool SessionImpl::hasPerTorrentRatioLimit() const { return std::any_of(m_torrents.cbegin(), m_torrents.cend(), [](const TorrentImpl *torrent) diff --git a/src/base/bittorrent/sessionimpl.h b/src/base/bittorrent/sessionimpl.h index d99a46af5112..d9fb1ab25a6d 100644 --- a/src/base/bittorrent/sessionimpl.h +++ b/src/base/bittorrent/sessionimpl.h @@ -419,6 +419,7 @@ namespace BitTorrent void banIP(const QString &ip) override; bool isKnownTorrent(const InfoHash &infoHash) const override; + void applyTorrentParamRules(const TorrentDescriptor &torrentDescr, AddTorrentParams *params) const override; bool addTorrent(const TorrentDescriptor &torrentDescr, const AddTorrentParams ¶ms = {}) override; bool deleteTorrent(const TorrentID &id, DeleteOption deleteOption = DeleteTorrent) override; bool downloadMetadata(const TorrentDescriptor &torrentDescr) override; @@ -584,6 +585,8 @@ namespace BitTorrent void upgradeCategories(); DownloadPathOption resolveCategoryDownloadPathOption(const QString &categoryName, const std::optional &option) const; + void loadTorrentParamRules(); + void saveStatistics() const; void loadStatistics(); @@ -753,6 +756,7 @@ namespace BitTorrent QThreadPool *m_asyncWorker = nullptr; ResumeDataStorage *m_resumeDataStorage = nullptr; FileSearcher *m_fileSearcher = nullptr; + TorrentParamRules *m_torrentParamRules = nullptr; QHash m_downloadedMetadata; diff --git a/src/base/bittorrent/torrentparamrules.cpp b/src/base/bittorrent/torrentparamrules.cpp new file mode 100644 index 000000000000..6d5341e3dd55 --- /dev/null +++ b/src/base/bittorrent/torrentparamrules.cpp @@ -0,0 +1,726 @@ +/* + * Bittorrent Client using Qt and libtorrent. + * Copyright (C) 2024 Tony Gregerson + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * In addition, as a special exception, the copyright holders give permission to + * link this program with the OpenSSL project's "OpenSSL" library (or with + * modified versions of it that use the same license as the "OpenSSL" library), + * and distribute the linked executables. You must obey the GNU General Public + * License in all respects for all of the code used other than "OpenSSL". If you + * modify file(s), you may extend this exception to your version of the file(s), + * but you are not obligated to do so. If you do not wish to do so, delete this + * exception statement from your version. + */ + +#include "torrentparamrules.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "base/bittorrent/session.h" +#include "base/bittorrent/trackerentry.h" +#include "base/global.h" +#include "base/logger.h" +#include "base/path.h" +#include "base/tag.h" + +using namespace BitTorrent; + +namespace +{ + using TorrentParamConditions = QList; + using TorrentParamModifiers = QList; + + // Keys used in JSON. Changing existing strings may break compatibility. + const QString KEY_CATEGORY = u"Category"_s; + const QString KEY_CONDITION = u"Condition"_s; + const QString KEY_CONDITIONS = u"Conditions"_s; + const QString KEY_MODIFIER = u"Modifier"_s; + const QString KEY_MODIFIERS = u"Modifiers"_s; + const QString KEY_PATH = u"Path"_s; + const QString KEY_REGEX_PATTERN = u"RegexPattern"_s; + const QString KEY_RULES = u"Rules"_s; + const QString KEY_TAG = u"Tag"_s; + const QString KEY_TYPE = u"Type"_s; + + // Supported conditions. + enum class ConditionType + { + // Combine multiple subconditions as a logical AND. + AllOf, + // Combine multiple subconditions as a logical OR. + AnyOf, + // Negates a condition + Not, + // Require at least one file's path matches a regex. + AnyFilePathRegex, + // Require at least one tracker's URL matches a regex. + AnyTrackerUrlRegex, + }; + + // Supported modifiers. + enum class ModifierType + { + // Combine multiple modifiers. + Compound, + // Insert a tag in the torrent's set of tags. + AddTag, + // Set a torrent's category. Adds it to the category repository if it + // did not already exist. + SetCategory, + // Set the save path for a torrent. Disables AutoTMM if enabled. + // Path must already exist. + SetSavePath, + }; + + // Values used in JSON. Changing existing strings may break compatibility. + QString conditionTypeStr(const ConditionType t) + { + switch (t) + { + case ConditionType::AllOf: + return u"AllOf"_s; + case ConditionType::AnyOf: + return u"AnyOf"_s; + case ConditionType::Not: + return u"Not"_s; + case ConditionType::AnyFilePathRegex: + return u"AnyFilePathRegex"_s; + case ConditionType::AnyTrackerUrlRegex: + return u"AnyTrackerUrlRegex"_s; + } + Q_ASSERT(false); + return u""_s; + } + + // Values used in JSON. Changing existing strings may break compatibility. + QString ModifierTypeStr(const ModifierType t) + { + switch (t) + { + case ModifierType::Compound: + return u"Compound"_s; + case ModifierType::AddTag: + return u"AddTag"_s; + case ModifierType::SetCategory: + return u"SetCategory"_s; + case ModifierType::SetSavePath: + return u"SetSavePath"_s; + } + Q_ASSERT(false); + return u""_s; + } + + std::optional decodeConditionType(const QString &type) + { + static const QMap map + { + {conditionTypeStr(ConditionType::AllOf), ConditionType::AllOf}, + {conditionTypeStr(ConditionType::AnyOf), ConditionType::AnyOf}, + {conditionTypeStr(ConditionType::Not), ConditionType::Not}, + {conditionTypeStr(ConditionType::AnyFilePathRegex), ConditionType::AnyFilePathRegex}, + {conditionTypeStr(ConditionType::AnyTrackerUrlRegex), ConditionType::AnyTrackerUrlRegex}, + }; + if (auto it = map.find(type); it != map.end()) + return *it; + return std::nullopt; + } + + std::optional decodeModifierType(const QString &type) + { + static const QMap map + { + {ModifierTypeStr(ModifierType::Compound), ModifierType::Compound}, + {ModifierTypeStr(ModifierType::AddTag), ModifierType::AddTag}, + {ModifierTypeStr(ModifierType::SetCategory), ModifierType::SetCategory}, + {ModifierTypeStr(ModifierType::SetSavePath), ModifierType::SetSavePath}, + }; + if (auto it = map.find(type); it != map.end()) + return *it; + return std::nullopt; + } + + bool checkRequiredField(const QJsonObject &obj, const QString &key, const QJsonValue::Type type) + { + auto field = obj.find(key); + if ((field == obj.end()) || (field.value().type() != type)) + { + QString objStr = QString::fromUtf8(QJsonDocument(obj).toJson()); + static constexpr char kMsg[] = "\"%1\" field missing or invalid type when parsing " + "auto torrent customizer rules. Parent JSON object:\n%2"; + LogMsg(QCoreApplication::translate("TorrentParamRules", kMsg).arg(key, objStr), Log::WARNING); + return false; + } + return true; + } + + template + T *parseCompoundCondition(const QJsonObject &condition, QObject *parent) + { + if (!checkRequiredField(condition, KEY_CONDITIONS, QJsonValue::Array)) + return nullptr; + QJsonArray conditions = condition.value(KEY_CONDITIONS).toArray(); + TorrentParamConditions subConditions; + for (const auto &subCondition : conditions) + if (auto *parsed = TorrentParamCondition::fromJson(subCondition.toObject(), parent)) + subConditions.push_back(parsed); + return new T(std::move(subConditions), parent); + } + + template + T *parseRegexCondition(const QJsonObject &condition, QObject *parent) + { + if (!checkRequiredField(condition, KEY_REGEX_PATTERN, QJsonValue::String)) + return nullptr; + QString pattern = condition.value(KEY_REGEX_PATTERN).toString(); + return new T(QRegularExpression(std::move(pattern)), parent); + } + + class AllOfCondition : public TorrentParamCondition + { + public: + static AllOfCondition *fromJson(const QJsonObject &json, QObject *parent) + { + return parseCompoundCondition(json, parent); + } + + AllOfCondition(TorrentParamConditions conditions, QObject *parent) + : TorrentParamCondition(parent) + , m_conditions(std::move(conditions)) {} + + bool isSatisfied(const TorrentDescriptor &descriptor) const override + { + return satisfiesAll(m_conditions, descriptor); + } + + bool isSatisfied(const Torrent &torrent) const override + { + return satisfiesAll(m_conditions, torrent); + } + + QJsonValue toJson() const override + { + QJsonObject json; + QJsonArray conditions; + json[KEY_TYPE] = conditionTypeStr(ConditionType::AllOf); + for (const auto &condition : m_conditions) + conditions.push_back(condition->toJson()); + json[KEY_CONDITIONS] = std::move(conditions); + return json; + } + + private: + template + bool satisfiesAll(const TorrentParamConditions &conditions, const T &t) const + { + if (conditions.empty()) + return false; + for (const auto &condition : conditions) + if (!condition->isSatisfied(t)) + return false; + return true; + } + + TorrentParamConditions m_conditions; + }; + + class AnyOfCondition : public TorrentParamCondition + { + public: + static AnyOfCondition *fromJson(const QJsonObject &json, QObject *parent) + { + return parseCompoundCondition(json, parent); + } + + AnyOfCondition(TorrentParamConditions conditions, QObject *parent) + : TorrentParamCondition(parent) + , m_conditions(std::move(conditions)) {} + + bool isSatisfied(const TorrentDescriptor &descriptor) const override + { + return satisfiesAny(m_conditions, descriptor); + } + + bool isSatisfied(const Torrent &torrent) const override + { + return satisfiesAny(m_conditions, torrent); + } + + QJsonValue toJson() const override + { + QJsonObject json; + QJsonArray conditions; + json[KEY_TYPE] = conditionTypeStr(ConditionType::AnyOf); + for (const auto &condition : m_conditions) + conditions.push_back(condition->toJson()); + json[KEY_CONDITIONS] = std::move(conditions); + return json; + } + + private: + template + bool satisfiesAny(const TorrentParamConditions &conditions, const T &t) const + { + for (const auto &condition : conditions) + if (condition->isSatisfied(t)) + return true; + return false; + } + + TorrentParamConditions m_conditions; + }; + + class NotCondition : public TorrentParamCondition + { + public: + static NotCondition *fromJson(const QJsonObject &json, QObject *parent) + { + if (checkRequiredField(json, KEY_CONDITION, QJsonValue::Object)) + if (auto *condition = TorrentParamCondition::fromJson(json.value(KEY_CONDITION).toObject(), parent)) + return new NotCondition(condition, parent); + return nullptr; + } + + NotCondition(TorrentParamCondition *condition, QObject *parent) + : TorrentParamCondition(parent) + , m_condition(condition) {} + + bool isSatisfied(const TorrentDescriptor &descriptor) const override + { + return !m_condition->isSatisfied(descriptor); + } + + bool isSatisfied(const Torrent &torrent) const override + { + return !m_condition->isSatisfied(torrent); + } + + QJsonValue toJson() const override + { + QJsonObject json; + json[KEY_TYPE] = conditionTypeStr(ConditionType::Not); + json[KEY_CONDITION] = m_condition->toJson(); + return json; + } + + private: + TorrentParamCondition *m_condition; + }; + + class AnyTrackerUrlRegexCondition : public TorrentParamCondition + { + public: + static AnyTrackerUrlRegexCondition *fromJson(const QJsonObject &json, QObject *parent) + { + return parseRegexCondition(json, parent); + } + + AnyTrackerUrlRegexCondition(QRegularExpression regex, QObject *parent) + : TorrentParamCondition(parent) + , m_regex(std::move(regex)) {} + + bool isSatisfied(const TorrentDescriptor &descriptor) const override + { + if (matchTracker(descriptor.trackers(), m_regex)) + return true; + // Trackers in the descriptor and in info() may not be the same. + else if (descriptor.info() && matchTracker(descriptor.info()->trackers(), m_regex)) + return true; + return false; + } + + bool isSatisfied(const Torrent &torrent) const override + { + return matchTracker(torrent.trackers(), m_regex); + } + + QJsonValue toJson() const override + { + QJsonObject json; + json[KEY_TYPE] = conditionTypeStr(ConditionType::AnyTrackerUrlRegex); + json[KEY_REGEX_PATTERN] = m_regex.pattern(); + return json; + } + + private: + bool matchTracker(const QVector &trackers, const QRegularExpression ®ex) const + { + for (const auto &tracker : trackers) + if (regex.match(tracker.url).hasMatch()) + return true; + return false; + } + + QRegularExpression m_regex; + }; + + class AnyFilePathRegexCondition : public TorrentParamCondition + { + public: + static AnyFilePathRegexCondition *fromJson(const QJsonObject &json, QObject *parent) + { + return parseRegexCondition(json, parent); + } + + AnyFilePathRegexCondition(QRegularExpression regex, QObject *parent) + : TorrentParamCondition(parent) + , m_regex(std::move(regex)) {} + + bool isSatisfied(const TorrentDescriptor &descriptor) const override + { + if (!descriptor.info() || !descriptor.info()->isValid()) + return false; + return matchFilePath(descriptor.info()->filePaths(), m_regex); + } + + bool isSatisfied(const Torrent &torrent) const override + { + return matchFilePath(torrent.filePaths(), m_regex); + } + + QJsonValue toJson() const override + { + QJsonObject json; + json[KEY_TYPE] = conditionTypeStr(ConditionType::AnyFilePathRegex); + json[KEY_REGEX_PATTERN] = m_regex.pattern(); + return json; + } + + private: + bool matchFilePath(const PathList &filePaths, const QRegularExpression ®ex) const + { + for (const Path &path : filePaths) + if (regex.match(path.toString()).hasMatch()) + return true; + return false; + } + + QRegularExpression m_regex; + }; + + class CompoundModifier : public TorrentParamModifier + { + public: + static CompoundModifier *fromJson(const QJsonObject &json, QObject *parent) + { + if (!checkRequiredField(json, KEY_MODIFIERS, QJsonValue::Array)) + return nullptr; + QJsonArray modifiers = json.value(KEY_MODIFIERS).toArray(); + TorrentParamModifiers subModifiers; + for (const auto &subModifier : modifiers) + if (auto *parsed = TorrentParamModifier::fromJson(subModifier.toObject(), parent)) + subModifiers.push_back(parsed); + return new CompoundModifier(std::move(subModifiers), parent); + } + + CompoundModifier(TorrentParamModifiers modifiers, QObject *parent) + : TorrentParamModifier(parent) + , m_modifiers(std::move(modifiers)) {} + + void modify(AddTorrentParams *params) const override + { + for (const auto &modifier : m_modifiers) + modifier->modify(params); + } + + void modify(Torrent *torrent) const override + { + for (const auto &modifier : m_modifiers) + modifier->modify(torrent); + } + + QJsonValue toJson() const override + { + QJsonObject json; + QJsonArray modifiers; + json[KEY_TYPE] = ModifierTypeStr(ModifierType::Compound); + for (const auto &modifier : m_modifiers) + modifiers.push_back(modifier->toJson()); + json[KEY_MODIFIERS] = std::move(modifiers); + return json; + } + + private: + TorrentParamModifiers m_modifiers; + }; + + class AddTagModifier : public TorrentParamModifier + { + public: + static AddTagModifier *fromJson(const QJsonObject &json, QObject *parent) + { + if (!checkRequiredField(json, KEY_TAG, QJsonValue::String)) + return nullptr; + QString tag = json.value(KEY_TAG).toString(); + return new AddTagModifier(std::move(tag), parent); + } + + AddTagModifier(QString tag, QObject *parent) + : TorrentParamModifier(parent) + , m_tag(std::move(tag)) {} + + void modify(AddTorrentParams *params) const override + { + params->tags.insert(Tag{m_tag}); + } + + void modify(Torrent *torrent) const override + { + torrent->addTag(Tag{m_tag}); + } + + QJsonValue toJson() const override + { + QJsonObject json; + json[KEY_TYPE] = ModifierTypeStr(ModifierType::AddTag); + json[KEY_TAG] = m_tag; + return json; + } + + private: + QString m_tag; + }; + + class SetCategoryModifier : public TorrentParamModifier + { + public: + static SetCategoryModifier *fromJson(const QJsonObject &json, QObject *parent) + { + if (!checkRequiredField(json, KEY_CATEGORY, QJsonValue::String)) + return nullptr; + QString category = json.value(KEY_CATEGORY).toString(); + return new SetCategoryModifier(std::move(category), parent); + } + + SetCategoryModifier(QString category, QObject *parent) + : TorrentParamModifier(parent) + , m_category(std::move(category)) {} + + void modify(AddTorrentParams *params) const override + { + params->category = m_category; + } + + void modify(Torrent *torrent) const override + { + // When setting a category after a torrent has been added, it must be + // be considered valid by the session to be accepted. + torrent->session()->addCategory(m_category); + torrent->setCategory(m_category); + } + + QJsonValue toJson() const override + { + QJsonObject json; + json[KEY_TYPE] = ModifierTypeStr(ModifierType::SetCategory); + json[KEY_CATEGORY] = m_category; + return json; + } + + private: + QString m_category; + }; + + class SetSavePathModifier : public TorrentParamModifier + { + public: + static SetSavePathModifier *fromJson(const QJsonObject &json, QObject *parent) + { + if (!checkRequiredField(json, KEY_PATH, QJsonValue::String)) + return nullptr; + Path path(json.value(KEY_PATH).toString()); + if (!checkPath(path)) + return nullptr; + return new SetSavePathModifier(std::move(path), parent); + } + + SetSavePathModifier(Path path, QObject *parent) + : TorrentParamModifier(parent) + , m_path(std::move(path)) {} + + void modify(AddTorrentParams *params) const override + { + params->useAutoTMM = false; + params->savePath = m_path; + } + + void modify(Torrent *torrent) const override + { + torrent->setAutoTMMEnabled(false); + torrent->setSavePath(m_path); + } + + QJsonValue toJson() const override + { + QJsonObject json; + json[KEY_TYPE] = ModifierTypeStr(ModifierType::SetSavePath); + json[KEY_PATH] = m_path.toString(); + return json; + } + + private: + static bool checkPath(const Path &path) + { + if (path.isValid()) + return true; + LogMsg(tr("Save path \"%1\" is invalid.").arg(path.toString()), Log::WARNING); + return false; + } + + Path m_path; + }; +} + +TorrentParamCondition *TorrentParamCondition::fromJson(const QJsonObject &json, QObject *parent) +{ + if (!checkRequiredField(json, KEY_TYPE, QJsonValue::String)) + return nullptr; + QString typeStr = json.value(KEY_TYPE).toString(); + std::optional type = decodeConditionType(typeStr); + if (type) { + switch (type.value()) + { + case ConditionType::AllOf: + return AllOfCondition::fromJson(json, parent); + case ConditionType::AnyOf: + return AnyOfCondition::fromJson(json, parent); + case ConditionType::Not: + return NotCondition::fromJson(json, parent); + case ConditionType::AnyTrackerUrlRegex: + return AnyTrackerUrlRegexCondition::fromJson(json, parent); + case ConditionType::AnyFilePathRegex: + return AnyFilePathRegexCondition::fromJson(json, parent); + } + } + LogMsg(tr("Unsupported \"%1\" value: \"%2\"").arg(KEY_CONDITION, typeStr), Log::WARNING); + return nullptr; +} + +TorrentParamModifier *TorrentParamModifier::fromJson(const QJsonObject &json, QObject *parent) +{ + if (!checkRequiredField(json, KEY_TYPE, QJsonValue::String)) + return nullptr; + QString typeStr = json.value(KEY_TYPE).toString(); + std::optional type = decodeModifierType(typeStr); + if (type) { + switch (type.value()) + { + case ModifierType::Compound: + return CompoundModifier::fromJson(json, parent); + case ModifierType::AddTag: + return AddTagModifier::fromJson(json, parent); + case ModifierType::SetCategory: + return SetCategoryModifier::fromJson(json, parent); + case ModifierType::SetSavePath: + return SetSavePathModifier::fromJson(json, parent); + } + } + LogMsg(tr("Unsupported \"%1\" value: \"%2\"").arg(KEY_MODIFIER, typeStr), Log::WARNING); + return nullptr; +} + +TorrentParamRule::TorrentParamRule(TorrentParamCondition *condition, TorrentParamModifier *modifier, QObject *parent) + : TorrentParamRuleComponent(parent) + , m_condition(std::move(condition)) + , m_modifier(std::move(modifier)) +{ +} + +void TorrentParamRule::apply(const TorrentDescriptor &descriptor, AddTorrentParams *params) const +{ + if (m_condition->isSatisfied(descriptor)) + m_modifier->modify(params); +} + +void TorrentParamRule::apply(Torrent *torrent) const +{ + if (m_condition->isSatisfied(*torrent)) + m_modifier->modify(torrent); +} + +QJsonValue TorrentParamRule::toJson() const +{ + QJsonObject json; + json[KEY_CONDITION] = m_condition->toJson(); + json[KEY_MODIFIER] = m_modifier->toJson(); + return json; +} + +TorrentParamRule *TorrentParamRule::fromJson(const QJsonObject &json, QObject *parent) +{ + if (!checkRequiredField(json, KEY_CONDITION, QJsonValue::Object) || + !checkRequiredField(json, KEY_MODIFIER, QJsonValue::Object)) + return nullptr; + auto *condition = TorrentParamCondition::fromJson(json.value(KEY_CONDITION).toObject(), parent); + auto *modifier = TorrentParamModifier::fromJson(json.value(KEY_MODIFIER).toObject(), parent); + if (condition && modifier) + return new TorrentParamRule(condition, modifier, parent); + return nullptr; +} + +void TorrentParamRules::apply(const TorrentDescriptor &descriptor, AddTorrentParams *params) const +{ + for (const auto &rule : m_rules) + rule->apply(descriptor, params); +} + +void TorrentParamRules::apply(Torrent *torrent) const +{ + for (const auto &rule : m_rules) + rule->apply(torrent); +} + +void TorrentParamRules::addRule(TorrentParamRule *rule) +{ + if (rule) + m_rules.push_back(rule); +} + +void TorrentParamRules::clearRules() +{ + m_rules.clear(); +} + +QJsonObject TorrentParamRules::toJson() const +{ + QJsonObject json; + QJsonArray rules; + for (const auto &rule : m_rules) + rules.push_back(rule->toJson()); + json[KEY_RULES] = std::move(rules); + return json; +} + +size_t TorrentParamRules::loadRulesFromJson(const QJsonObject &json) +{ + if (checkRequiredField(json, KEY_RULES, QJsonValue::Array)) + { + const QJsonArray rules = json.value(KEY_RULES).toArray(); + for (const QJsonValue &rule : rules) + addRule(TorrentParamRule::fromJson(rule.toObject(), this)); + } + return m_rules.size(); +} diff --git a/src/base/bittorrent/torrentparamrules.h b/src/base/bittorrent/torrentparamrules.h new file mode 100644 index 000000000000..510d13081700 --- /dev/null +++ b/src/base/bittorrent/torrentparamrules.h @@ -0,0 +1,129 @@ +/* + * Bittorrent Client using Qt and libtorrent. + * Copyright (C) 2024 Tony Gregerson + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * In addition, as a special exception, the copyright holders give permission to + * link this program with the OpenSSL project's "OpenSSL" library (or with + * modified versions of it that use the same license as the "OpenSSL" library), + * and distribute the linked executables. You must obey the GNU General Public + * License in all respects for all of the code used other than "OpenSSL". If you + * modify file(s), you may extend this exception to your version of the file(s), + * but you are not obligated to do so. If you do not wish to do so, delete this + * exception statement from your version. + */ + +#pragma once + +#include +#include +#include +#include +#include + +#include "addtorrentparams.h" +#include "torrent.h" +#include "torrentdescriptor.h" + +// This library defines a framework for modifying torrent parameters (e.g., +// category, tags) based on a set of rules related to the torrent's metadata. +// +// Rules consist of a set of conditions and a set of modifications. If the +// conditions are satisfied, the modifications are applied. If multiple rules +// match a given torrent, modifications are applied in sequential order. +// +// Metadata may or may not be available when initially adding the torrent, and +// there are two API variants to address each of these cases. The first operates +// on a TorrentDescriptor and AddTorrentParams, and is used when metadata is +// available prior to adding a torrent to the session. The second operates on a +// Torrent object, and is used when metadata arrives after adding the torrent. + +namespace BitTorrent +{ + class TorrentParamRuleComponent : public QObject + { + public: + using QObject::QObject; + + virtual QJsonValue toJson() const = 0; + }; + + class TorrentParamCondition : public TorrentParamRuleComponent + { + public: + static TorrentParamCondition *fromJson(const QJsonObject &json, QObject *parent); + + virtual ~TorrentParamCondition() = default; + + virtual bool isSatisfied(const TorrentDescriptor &descriptor) const = 0; + virtual bool isSatisfied(const Torrent &torrent) const = 0; + + protected: + using TorrentParamRuleComponent::TorrentParamRuleComponent; + }; + + class TorrentParamModifier : public TorrentParamRuleComponent + { + public: + static TorrentParamModifier *fromJson(const QJsonObject &json, QObject *parent); + + virtual ~TorrentParamModifier() = default; + + virtual void modify(AddTorrentParams *params) const = 0; + virtual void modify(Torrent *torrent) const = 0; + + protected: + using TorrentParamRuleComponent::TorrentParamRuleComponent; + }; + + class TorrentParamRule : public TorrentParamRuleComponent + { + public: + using TorrentParamRuleComponent::TorrentParamRuleComponent; + + static TorrentParamRule *fromJson(const QJsonObject &json, QObject *parent); + + TorrentParamRule(TorrentParamCondition *condition, TorrentParamModifier *modifier, QObject *parent); + + void apply(const TorrentDescriptor &descriptor, AddTorrentParams *params) const; + void apply(Torrent *torrent) const; + + QJsonValue toJson() const override; + + private: + TorrentParamCondition *m_condition; + TorrentParamModifier *m_modifier; + }; + + class TorrentParamRules : public QObject + { + public: + using QObject::QObject; + + void addRule(TorrentParamRule *rule); + + void clearRules(); + + void apply(const TorrentDescriptor &descriptor, AddTorrentParams *params) const; + void apply(Torrent *torrent) const; + + QJsonObject toJson() const; + size_t loadRulesFromJson(const QJsonObject &json); + + private: + QList m_rules; + }; +} diff --git a/src/gui/addnewtorrentdialog.cpp b/src/gui/addnewtorrentdialog.cpp index f9c6862dedd7..e279c3fb5019 100644 --- a/src/gui/addnewtorrentdialog.cpp +++ b/src/gui/addnewtorrentdialog.cpp @@ -305,8 +305,6 @@ AddNewTorrentDialog::AddNewTorrentDialog(const BitTorrent::TorrentDescriptor &to m_ui->downloadPath->setDialogCaption(tr("Choose save path")); m_ui->downloadPath->setMaxVisibleItems(20); - m_ui->addToQueueTopCheckBox->setChecked(m_torrentParams.addToQueueTop.value_or(session->isAddTorrentToQueueTop())); - m_ui->stopConditionComboBox->setToolTip( u"

" + tr("None") + u" - " + tr("No stop condition is set.") + u"

" + tr("Metadata received") + u" - " + tr("Torrent will stop after metadata is received.") + @@ -317,19 +315,6 @@ AddNewTorrentDialog::AddNewTorrentDialog(const BitTorrent::TorrentDescriptor &to if (!hasMetadata()) m_ui->stopConditionComboBox->addItem(tr("Metadata received"), QVariant::fromValue(BitTorrent::Torrent::StopCondition::MetadataReceived)); m_ui->stopConditionComboBox->addItem(tr("Files checked"), QVariant::fromValue(BitTorrent::Torrent::StopCondition::FilesChecked)); - const auto stopCondition = m_torrentParams.stopCondition.value_or(session->torrentStopCondition()); - if (hasMetadata() && (stopCondition == BitTorrent::Torrent::StopCondition::MetadataReceived)) - { - m_ui->startTorrentCheckBox->setChecked(false); - m_ui->stopConditionComboBox->setCurrentIndex(m_ui->stopConditionComboBox->findData(QVariant::fromValue(BitTorrent::Torrent::StopCondition::None))); - } - else - { - m_ui->startTorrentCheckBox->setChecked(!m_torrentParams.addPaused.value_or(session->isAddTorrentPaused())); - m_ui->stopConditionComboBox->setCurrentIndex(m_ui->stopConditionComboBox->findData(QVariant::fromValue(stopCondition))); - } - m_ui->stopConditionLabel->setEnabled(m_ui->startTorrentCheckBox->isChecked()); - m_ui->stopConditionComboBox->setEnabled(m_ui->startTorrentCheckBox->isChecked()); connect(m_ui->startTorrentCheckBox, &QCheckBox::toggled, this, [this](const bool checked) { m_ui->stopConditionLabel->setEnabled(checked); @@ -337,7 +322,11 @@ AddNewTorrentDialog::AddNewTorrentDialog(const BitTorrent::TorrentDescriptor &to }); m_ui->comboTTM->blockSignals(true); // the TreeView size isn't correct if the slot does its job at this point - m_ui->comboTTM->setCurrentIndex(session->isAutoTMMDisabledByDefault() ? 0 : 1); + + // Any UI elements that depend on m_torrentParams should be configured in this method to + // allow them to be updated if the params change after metadata download. + loadAddTorrentParams(); + m_ui->comboTTM->blockSignals(false); connect(m_ui->comboTTM, &QComboBox::currentIndexChanged, this, &AddNewTorrentDialog::TMMChanged); @@ -347,23 +336,14 @@ AddNewTorrentDialog::AddNewTorrentDialog(const BitTorrent::TorrentDescriptor &to m_ui->checkBoxRememberLastSavePath->setChecked(m_storeRememberLastSavePath); - m_ui->contentLayoutComboBox->setCurrentIndex( - static_cast(m_torrentParams.contentLayout.value_or(session->torrentContentLayout()))); connect(m_ui->contentLayoutComboBox, &QComboBox::currentIndexChanged, this, &AddNewTorrentDialog::contentLayoutChanged); - m_ui->sequentialCheckBox->setChecked(m_torrentParams.sequential); - m_ui->firstLastCheckBox->setChecked(m_torrentParams.firstLastPiecePriority); - - m_ui->skipCheckingCheckBox->setChecked(m_torrentParams.skipChecking); m_ui->doNotDeleteTorrentCheckBox->setVisible(TorrentFileGuard::autoDeleteMode() != TorrentFileGuard::Never); // Load categories QStringList categories = session->categories(); std::sort(categories.begin(), categories.end(), Utils::Compare::NaturalLessThan()); const QString defaultCategory = m_storeDefaultCategory; - - if (!m_torrentParams.category.isEmpty()) - m_ui->categoryComboBox->addItem(m_torrentParams.category); if (!defaultCategory.isEmpty()) m_ui->categoryComboBox->addItem(defaultCategory); m_ui->categoryComboBox->addItem(u""_s); @@ -376,7 +356,6 @@ AddNewTorrentDialog::AddNewTorrentDialog(const BitTorrent::TorrentDescriptor &to connect(m_ui->categoryComboBox, &QComboBox::currentIndexChanged, this, &AddNewTorrentDialog::categoryChanged); - m_ui->tagsLineEdit->setText(Utils::String::joinIntoString(m_torrentParams.tags, u", "_s)); connect(m_ui->tagsEditButton, &QAbstractButton::clicked, this, [this] { auto *dlg = new TorrentTagsDialog(m_torrentParams.tags, this); @@ -612,6 +591,8 @@ void AddNewTorrentDialog::populateSavePaths() m_ui->savePath->blockSignals(true); m_ui->savePath->clear(); + if (!m_torrentParams.savePath.isEmpty()) + m_ui->savePath->addItem(m_torrentParams.savePath); const auto savePathHistory = settings()->loadValue(KEY_SAVEPATHHISTORY); if (savePathHistory.size() > 0) { @@ -757,8 +738,11 @@ void AddNewTorrentDialog::updateMetadata(const BitTorrent::TorrentInfo &metadata m_torrentDescr.setTorrentInfo(metadata); setMetadataProgressIndicator(true, tr("Parsing metadata...")); + BitTorrent::Session::instance()->applyTorrentParamRules(m_torrentDescr, &m_torrentParams); + // Update UI setupTreeview(); + loadAddTorrentParams(); setMetadataProgressIndicator(false, tr("Metadata retrieval complete")); if (const auto stopCondition = m_ui->stopConditionComboBox->currentData().value() @@ -780,6 +764,46 @@ void AddNewTorrentDialog::updateMetadata(const BitTorrent::TorrentInfo &metadata } } +void AddNewTorrentDialog::loadAddTorrentParams() +{ + const auto *session = BitTorrent::Session::instance(); + + m_ui->addToQueueTopCheckBox->setChecked(m_torrentParams.addToQueueTop.value_or(session->isAddTorrentToQueueTop())); + + const auto stopCondition = m_torrentParams.stopCondition.value_or(session->torrentStopCondition()); + if (hasMetadata() && (stopCondition == BitTorrent::Torrent::StopCondition::MetadataReceived)) + { + m_ui->startTorrentCheckBox->setChecked(false); + m_ui->stopConditionComboBox->setCurrentIndex(m_ui->stopConditionComboBox->findData(QVariant::fromValue(BitTorrent::Torrent::StopCondition::None))); + } + else + { + m_ui->startTorrentCheckBox->setChecked(!m_torrentParams.addPaused.value_or(session->isAddTorrentPaused())); + m_ui->stopConditionComboBox->setCurrentIndex(m_ui->stopConditionComboBox->findData(QVariant::fromValue(stopCondition))); + } + m_ui->stopConditionLabel->setEnabled(m_ui->startTorrentCheckBox->isChecked()); + m_ui->stopConditionComboBox->setEnabled(m_ui->startTorrentCheckBox->isChecked()); + + m_ui->contentLayoutComboBox->setCurrentIndex( + static_cast(m_torrentParams.contentLayout.value_or(session->torrentContentLayout()))); + + m_ui->sequentialCheckBox->setChecked(m_torrentParams.sequential); + m_ui->firstLastCheckBox->setChecked(m_torrentParams.firstLastPiecePriority); + + m_ui->skipCheckingCheckBox->setChecked(m_torrentParams.skipChecking); + + if (!m_torrentParams.category.isEmpty()) + { + m_ui->categoryComboBox->addItem(m_torrentParams.category); + m_ui->categoryComboBox->setCurrentIndex(m_ui->categoryComboBox->findText(m_torrentParams.category)); + } + + m_ui->tagsLineEdit->setText(Utils::String::joinIntoString(m_torrentParams.tags, u", "_s)); + + const bool useTMM = m_torrentParams.useAutoTMM.value_or(!session->isAutoTMMDisabledByDefault()); + m_ui->comboTTM->setCurrentIndex(useTMM ? 1 : 0); +} + void AddNewTorrentDialog::setMetadataProgressIndicator(bool visibleIndicator, const QString &labelText) { // Always show info label when waiting for metadata diff --git a/src/gui/addnewtorrentdialog.h b/src/gui/addnewtorrentdialog.h index 112c17e40b8a..989c98bef47f 100644 --- a/src/gui/addnewtorrentdialog.h +++ b/src/gui/addnewtorrentdialog.h @@ -76,6 +76,7 @@ private slots: private: class TorrentContentAdaptor; + void loadAddTorrentParams(); void populateSavePaths(); void loadState(); void saveState(); diff --git a/src/gui/guiaddtorrentmanager.cpp b/src/gui/guiaddtorrentmanager.cpp index 19cc5ce1bec6..b7332908b970 100644 --- a/src/gui/guiaddtorrentmanager.cpp +++ b/src/gui/guiaddtorrentmanager.cpp @@ -33,6 +33,7 @@ #include "base/bittorrent/session.h" #include "base/bittorrent/torrentdescriptor.h" +#include "base/bittorrent/torrentparamrules.h" #include "base/logger.h" #include "base/net/downloadmanager.h" #include "base/preferences.h" @@ -174,7 +175,7 @@ void GUIAddTorrentManager::onMetadataDownloaded(const BitTorrent::TorrentInfo &m } } -bool GUIAddTorrentManager::processTorrent(const QString &source, const BitTorrent::TorrentDescriptor &torrentDescr, const BitTorrent::AddTorrentParams ¶ms) +bool GUIAddTorrentManager::processTorrent(const QString &source, const BitTorrent::TorrentDescriptor &torrentDescr, BitTorrent::AddTorrentParams params) { const bool hasMetadata = torrentDescr.info().has_value(); const BitTorrent::InfoHash infoHash = torrentDescr.infoHash(); @@ -213,7 +214,11 @@ bool GUIAddTorrentManager::processTorrent(const QString &source, const BitTorren return false; } - if (!hasMetadata) + // If metadata is available now, apply rules so they are reflected in the UI. + // Otherwise they will be applied once metadata download finishes. + if (hasMetadata) + btSession()->applyTorrentParamRules(torrentDescr, ¶ms); + else btSession()->downloadMetadata(torrentDescr); // By not setting a parent to the "AddNewTorrentDialog", all those dialogs diff --git a/src/gui/guiaddtorrentmanager.h b/src/gui/guiaddtorrentmanager.h index fa48dda30b51..c9107d2a6533 100644 --- a/src/gui/guiaddtorrentmanager.h +++ b/src/gui/guiaddtorrentmanager.h @@ -67,7 +67,7 @@ class GUIAddTorrentManager : public GUIApplicationComponent private: void onDownloadFinished(const Net::DownloadResult &result); void onMetadataDownloaded(const BitTorrent::TorrentInfo &metadata); - bool processTorrent(const QString &source, const BitTorrent::TorrentDescriptor &torrentDescr, const BitTorrent::AddTorrentParams ¶ms); + bool processTorrent(const QString &source, const BitTorrent::TorrentDescriptor &torrentDescr, BitTorrent::AddTorrentParams params); QHash m_downloadedTorrents; QHash m_dialogs;