WebAPI: Add a way to download .torrent file using search plugin
[qBittorrent.git] / src / base / search / searchpluginmanager.cpp
blob095e3d57a389351cda881a2964cf2eb41e1dc4d8
1 /*
2 * Bittorrent Client using Qt and libtorrent.
3 * Copyright (C) 2015-2024 Vladimir Golovnev <glassez@yandex.ru>
4 * Copyright (C) 2006 Christophe Dumez <chris@qbittorrent.org>
6 * This program is free software; you can redistribute it and/or
7 * modify it under the terms of the GNU General Public License
8 * as published by the Free Software Foundation; either version 2
9 * of the License, or (at your option) any later version.
11 * This program is distributed in the hope that it will be useful,
12 * but WITHOUT ANY WARRANTY; without even the implied warranty of
13 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 * GNU General Public License for more details.
16 * You should have received a copy of the GNU General Public License
17 * along with this program; if not, write to the Free Software
18 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
20 * In addition, as a special exception, the copyright holders give permission to
21 * link this program with the OpenSSL project's "OpenSSL" library (or with
22 * modified versions of it that use the same license as the "OpenSSL" library),
23 * and distribute the linked executables. You must obey the GNU General Public
24 * License in all respects for all of the code used other than "OpenSSL". If you
25 * modify file(s), you may extend this exception to your version of the file(s),
26 * but you are not obligated to do so. If you do not wish to do so, delete this
27 * exception statement from your version.
30 #include "searchpluginmanager.h"
32 #include <memory>
34 #include <QDir>
35 #include <QDirIterator>
36 #include <QDomDocument>
37 #include <QDomElement>
38 #include <QDomNode>
39 #include <QFile>
40 #include <QPointer>
41 #include <QProcess>
42 #include <QUrl>
44 #include "base/global.h"
45 #include "base/logger.h"
46 #include "base/net/downloadmanager.h"
47 #include "base/net/proxyconfigurationmanager.h"
48 #include "base/preferences.h"
49 #include "base/profile.h"
50 #include "base/utils/bytearray.h"
51 #include "base/utils/foreignapps.h"
52 #include "base/utils/fs.h"
53 #include "searchdownloadhandler.h"
54 #include "searchhandler.h"
56 namespace
58 void clearPythonCache(const Path &path)
60 // remove python cache artifacts in `path` and subdirs
62 PathList dirs = {path};
63 QDirIterator iter {path.data(), (QDir::AllDirs | QDir::NoDotAndDotDot), QDirIterator::Subdirectories};
64 while (iter.hasNext())
65 dirs += Path(iter.next());
67 for (const Path &dir : asConst(dirs))
69 // python 3: remove "__pycache__" folders
70 if (dir.filename() == u"__pycache__")
72 Utils::Fs::removeDirRecursively(dir);
73 continue;
76 // python 2: remove "*.pyc" files
77 QDirIterator it {dir.data(), {u"*.pyc"_s}, QDir::Files};
78 while (it.hasNext())
80 const QString filePath = it.next();
81 Utils::Fs::removeFile(Path(filePath));
87 QPointer<SearchPluginManager> SearchPluginManager::m_instance = nullptr;
89 SearchPluginManager::SearchPluginManager()
90 : m_updateUrl(u"https://searchplugins.qbittorrent.org/nova3/engines/"_s)
92 Q_ASSERT(!m_instance); // only one instance is allowed
93 m_instance = this;
95 connect(Net::ProxyConfigurationManager::instance(), &Net::ProxyConfigurationManager::proxyConfigurationChanged
96 , this, &SearchPluginManager::applyProxySettings);
97 connect(Preferences::instance(), &Preferences::changed
98 , this, &SearchPluginManager::applyProxySettings);
99 applyProxySettings();
101 updateNova();
102 update();
105 SearchPluginManager::~SearchPluginManager()
107 qDeleteAll(m_plugins);
110 SearchPluginManager *SearchPluginManager::instance()
112 if (!m_instance)
113 m_instance = new SearchPluginManager;
114 return m_instance;
117 void SearchPluginManager::freeInstance()
119 delete m_instance;
122 QStringList SearchPluginManager::allPlugins() const
124 return m_plugins.keys();
127 QStringList SearchPluginManager::enabledPlugins() const
129 QStringList plugins;
130 for (const PluginInfo *plugin : asConst(m_plugins))
132 if (plugin->enabled)
133 plugins << plugin->name;
136 return plugins;
139 QStringList SearchPluginManager::supportedCategories() const
141 QStringList result;
142 for (const PluginInfo *plugin : asConst(m_plugins))
144 if (plugin->enabled)
146 for (const QString &cat : plugin->supportedCategories)
148 if (!result.contains(cat))
149 result << cat;
154 return result;
157 QStringList SearchPluginManager::getPluginCategories(const QString &pluginName) const
159 QStringList plugins;
160 if (pluginName == u"all")
161 plugins = allPlugins();
162 else if ((pluginName == u"enabled") || (pluginName == u"multi"))
163 plugins = enabledPlugins();
164 else
165 plugins << pluginName.trimmed();
167 QSet<QString> categories;
168 for (const QString &name : asConst(plugins))
170 const PluginInfo *plugin = pluginInfo(name);
171 if (!plugin) continue; // plugin wasn't found
172 for (const QString &category : plugin->supportedCategories)
173 categories << category;
176 return categories.values();
179 PluginInfo *SearchPluginManager::pluginInfo(const QString &name) const
181 return m_plugins.value(name);
184 QString SearchPluginManager::pluginNameBySiteURL(const QString &siteURL) const
186 for (const PluginInfo *plugin : asConst(m_plugins))
188 if (plugin->url == siteURL)
189 return plugin->name;
192 return {};
195 void SearchPluginManager::enablePlugin(const QString &name, const bool enabled)
197 PluginInfo *plugin = m_plugins.value(name, nullptr);
198 if (plugin)
200 plugin->enabled = enabled;
201 // Save to Hard disk
202 Preferences *const pref = Preferences::instance();
203 QStringList disabledPlugins = pref->getSearchEngDisabled();
204 if (enabled)
205 disabledPlugins.removeAll(name);
206 else if (!disabledPlugins.contains(name))
207 disabledPlugins.append(name);
208 pref->setSearchEngDisabled(disabledPlugins);
210 emit pluginEnabled(name, enabled);
214 // Updates shipped plugin
215 void SearchPluginManager::updatePlugin(const QString &name)
217 installPlugin(u"%1%2.py"_s.arg(m_updateUrl, name));
220 // Install or update plugin from file or url
221 void SearchPluginManager::installPlugin(const QString &source)
223 clearPythonCache(engineLocation());
225 if (Net::DownloadManager::hasSupportedScheme(source))
227 using namespace Net;
228 DownloadManager::instance()->download(DownloadRequest(source).saveToFile(true)
229 , Preferences::instance()->useProxyForGeneralPurposes()
230 , this, &SearchPluginManager::pluginDownloadFinished);
232 else
234 const Path path {source.startsWith(u"file:", Qt::CaseInsensitive) ? QUrl(source).toLocalFile() : source};
236 QString pluginName = path.filename();
237 if (pluginName.endsWith(u".py", Qt::CaseInsensitive))
239 pluginName.chop(pluginName.size() - pluginName.lastIndexOf(u'.'));
240 installPlugin_impl(pluginName, path);
242 else
244 emit pluginInstallationFailed(pluginName, tr("Unknown search engine plugin file format."));
249 void SearchPluginManager::installPlugin_impl(const QString &name, const Path &path)
251 const PluginVersion newVersion = getPluginVersion(path);
252 const PluginInfo *plugin = pluginInfo(name);
253 if (plugin && !(plugin->version < newVersion))
255 LogMsg(tr("Plugin already at version %1, which is greater than %2").arg(plugin->version.toString(), newVersion.toString()), Log::INFO);
256 emit pluginUpdateFailed(name, tr("A more recent version of this plugin is already installed."));
257 return;
260 // Process with install
261 const Path destPath = pluginPath(name);
262 const Path backupPath = destPath + u".bak";
263 bool updated = false;
264 if (destPath.exists())
266 // Backup in case install fails
267 Utils::Fs::copyFile(destPath, backupPath);
268 Utils::Fs::removeFile(destPath);
269 updated = true;
271 // Copy the plugin
272 Utils::Fs::copyFile(path, destPath);
273 // Update supported plugins
274 update();
275 // Check if this was correctly installed
276 if (!m_plugins.contains(name))
278 // Remove broken file
279 Utils::Fs::removeFile(destPath);
280 LogMsg(tr("Plugin %1 is not supported.").arg(name), Log::INFO);
281 if (updated)
283 // restore backup
284 Utils::Fs::copyFile(backupPath, destPath);
285 Utils::Fs::removeFile(backupPath);
286 // Update supported plugins
287 update();
288 emit pluginUpdateFailed(name, tr("Plugin is not supported."));
290 else
292 emit pluginInstallationFailed(name, tr("Plugin is not supported."));
295 else
297 // Install was successful, remove backup
298 if (updated)
300 LogMsg(tr("Plugin %1 has been successfully updated.").arg(name), Log::INFO);
301 Utils::Fs::removeFile(backupPath);
306 bool SearchPluginManager::uninstallPlugin(const QString &name)
308 clearPythonCache(engineLocation());
310 // remove it from hard drive
311 QDirIterator iter {pluginsLocation().data(), {name + u".*"}, QDir::Files};
312 while (iter.hasNext())
314 const QString filePath = iter.next();
315 Utils::Fs::removeFile(Path(filePath));
318 // Remove it from supported engines
319 delete m_plugins.take(name);
321 emit pluginUninstalled(name);
322 return true;
325 void SearchPluginManager::updateIconPath(PluginInfo *const plugin)
327 if (!plugin) return;
329 const Path pluginsPath = pluginsLocation();
330 Path iconPath = pluginsPath / Path(plugin->name + u".png");
331 if (iconPath.exists())
333 plugin->iconPath = iconPath;
335 else
337 iconPath = pluginsPath / Path(plugin->name + u".ico");
338 if (iconPath.exists())
339 plugin->iconPath = iconPath;
343 void SearchPluginManager::checkForUpdates()
345 // Download version file from update server
346 using namespace Net;
347 DownloadManager::instance()->download({m_updateUrl + u"versions.txt"}
348 , Preferences::instance()->useProxyForGeneralPurposes()
349 , this, &SearchPluginManager::versionInfoDownloadFinished);
352 SearchDownloadHandler *SearchPluginManager::downloadTorrent(const QString &pluginName, const QString &url)
354 return new SearchDownloadHandler(pluginName, url, this);
357 SearchHandler *SearchPluginManager::startSearch(const QString &pattern, const QString &category, const QStringList &usedPlugins)
359 // No search pattern entered
360 Q_ASSERT(!pattern.isEmpty());
362 return new SearchHandler {pattern, category, usedPlugins, this};
365 QString SearchPluginManager::categoryFullName(const QString &categoryName)
367 const QHash<QString, QString> categoryTable
369 {u"all"_s, tr("All categories")},
370 {u"movies"_s, tr("Movies")},
371 {u"tv"_s, tr("TV shows")},
372 {u"music"_s, tr("Music")},
373 {u"games"_s, tr("Games")},
374 {u"anime"_s, tr("Anime")},
375 {u"software"_s, tr("Software")},
376 {u"pictures"_s, tr("Pictures")},
377 {u"books"_s, tr("Books")}
379 return categoryTable.value(categoryName);
382 QString SearchPluginManager::pluginFullName(const QString &pluginName) const
384 return pluginInfo(pluginName) ? pluginInfo(pluginName)->fullName : QString();
387 Path SearchPluginManager::pluginsLocation()
389 return (engineLocation() / Path(u"engines"_s));
392 Path SearchPluginManager::engineLocation()
394 static Path location;
395 if (location.isEmpty())
397 location = specialFolderLocation(SpecialFolder::Data) / Path(u"nova3"_s);
398 Utils::Fs::mkpath(location);
401 return location;
404 void SearchPluginManager::applyProxySettings()
406 const auto *proxyManager = Net::ProxyConfigurationManager::instance();
407 const Net::ProxyConfiguration proxyConfig = proxyManager->proxyConfiguration();
409 // Define environment variables for urllib in search engine plugins
410 QString proxyStrHTTP, proxyStrSOCK;
411 if ((proxyConfig.type != Net::ProxyType::None) && Preferences::instance()->useProxyForGeneralPurposes())
413 switch (proxyConfig.type)
415 case Net::ProxyType::HTTP:
416 if (proxyConfig.authEnabled)
418 proxyStrHTTP = u"http://%1:%2@%3:%4"_s.arg(proxyConfig.username
419 , proxyConfig.password, proxyConfig.ip, QString::number(proxyConfig.port));
421 else
423 proxyStrHTTP = u"http://%1:%2"_s.arg(proxyConfig.ip, QString::number(proxyConfig.port));
425 break;
427 case Net::ProxyType::SOCKS5:
428 if (proxyConfig.authEnabled)
430 proxyStrSOCK = u"%1:%2@%3:%4"_s.arg(proxyConfig.username
431 , proxyConfig.password, proxyConfig.ip, QString::number(proxyConfig.port));
433 else
435 proxyStrSOCK = u"%1:%2"_s.arg(proxyConfig.ip, QString::number(proxyConfig.port));
437 break;
439 default:
440 qDebug("Disabling HTTP communications proxy");
443 qDebug("HTTP communications proxy string: %s"
444 , qUtf8Printable((proxyConfig.type == Net::ProxyType::SOCKS5) ? proxyStrSOCK : proxyStrHTTP));
447 qputenv("http_proxy", proxyStrHTTP.toLocal8Bit());
448 qputenv("https_proxy", proxyStrHTTP.toLocal8Bit());
449 qputenv("sock_proxy", proxyStrSOCK.toLocal8Bit());
452 void SearchPluginManager::versionInfoDownloadFinished(const Net::DownloadResult &result)
454 if (result.status == Net::DownloadStatus::Success)
455 parseVersionInfo(result.data);
456 else
457 emit checkForUpdatesFailed(tr("Update server is temporarily unavailable. %1").arg(result.errorString));
460 void SearchPluginManager::pluginDownloadFinished(const Net::DownloadResult &result)
462 if (result.status == Net::DownloadStatus::Success)
464 const Path filePath = result.filePath;
466 const auto pluginPath = Path(QUrl(result.url).path()).removedExtension();
467 installPlugin_impl(pluginPath.filename(), filePath);
468 Utils::Fs::removeFile(filePath);
470 else
472 const QString url = result.url;
473 QString pluginName = url.mid(url.lastIndexOf(u'/') + 1);
474 pluginName.replace(u".py"_s, u""_s, Qt::CaseInsensitive);
476 if (pluginInfo(pluginName))
477 emit pluginUpdateFailed(pluginName, tr("Failed to download the plugin file. %1").arg(result.errorString));
478 else
479 emit pluginInstallationFailed(pluginName, tr("Failed to download the plugin file. %1").arg(result.errorString));
483 // Update nova.py search plugin if necessary
484 void SearchPluginManager::updateNova()
486 // create nova directory if necessary
487 const Path enginePath = engineLocation();
489 QFile packageFile {(enginePath / Path(u"__init__.py"_s)).data()};
490 packageFile.open(QIODevice::WriteOnly);
491 packageFile.close();
493 Utils::Fs::mkdir(enginePath / Path(u"engines"_s));
495 QFile packageFile2 {(enginePath / Path(u"engines/__init__.py"_s)).data()};
496 packageFile2.open(QIODevice::WriteOnly);
497 packageFile2.close();
499 // Copy search plugin files (if necessary)
500 const auto updateFile = [&enginePath](const Path &filename, const bool compareVersion)
502 const Path filePathBundled = Path(u":/searchengine/nova3"_s) / filename;
503 const Path filePathDisk = enginePath / filename;
505 if (compareVersion && (getPluginVersion(filePathBundled) <= getPluginVersion(filePathDisk)))
506 return;
508 Utils::Fs::removeFile(filePathDisk);
509 Utils::Fs::copyFile(filePathBundled, filePathDisk);
512 updateFile(Path(u"helpers.py"_s), true);
513 updateFile(Path(u"nova2.py"_s), true);
514 updateFile(Path(u"nova2dl.py"_s), true);
515 updateFile(Path(u"novaprinter.py"_s), true);
516 updateFile(Path(u"socks.py"_s), false);
519 void SearchPluginManager::update()
521 QProcess nova;
522 nova.setProcessEnvironment(QProcessEnvironment::systemEnvironment());
524 const QStringList params
526 Utils::ForeignApps::PYTHON_ISOLATE_MODE_FLAG,
527 (engineLocation() / Path(u"/nova2.py"_s)).toString(),
528 u"--capabilities"_s
530 nova.start(Utils::ForeignApps::pythonInfo().executableName, params, QIODevice::ReadOnly);
531 nova.waitForFinished();
533 const auto capabilities = QString::fromUtf8(nova.readAllStandardOutput());
534 QDomDocument xmlDoc;
535 if (!xmlDoc.setContent(capabilities))
537 qWarning() << "Could not parse Nova search engine capabilities, msg: " << capabilities.toLocal8Bit().data();
538 qWarning() << "Error: " << nova.readAllStandardError().constData();
539 return;
542 const QDomElement root = xmlDoc.documentElement();
543 if (root.tagName() != u"capabilities")
545 qWarning() << "Invalid XML file for Nova search engine capabilities, msg: " << capabilities.toLocal8Bit().data();
546 return;
549 for (QDomNode engineNode = root.firstChild(); !engineNode.isNull(); engineNode = engineNode.nextSibling())
551 const QDomElement engineElem = engineNode.toElement();
552 if (!engineElem.isNull())
554 const QString pluginName = engineElem.tagName();
556 auto plugin = std::make_unique<PluginInfo>();
557 plugin->name = pluginName;
558 plugin->version = getPluginVersion(pluginPath(pluginName));
559 plugin->fullName = engineElem.elementsByTagName(u"name"_s).at(0).toElement().text();
560 plugin->url = engineElem.elementsByTagName(u"url"_s).at(0).toElement().text();
562 const QStringList categories = engineElem.elementsByTagName(u"categories"_s).at(0).toElement().text().split(u' ');
563 for (QString cat : categories)
565 cat = cat.trimmed();
566 if (!cat.isEmpty())
567 plugin->supportedCategories << cat;
570 const QStringList disabledEngines = Preferences::instance()->getSearchEngDisabled();
571 plugin->enabled = !disabledEngines.contains(pluginName);
573 updateIconPath(plugin.get());
575 if (!m_plugins.contains(pluginName))
577 m_plugins[pluginName] = plugin.release();
578 emit pluginInstalled(pluginName);
580 else if (m_plugins[pluginName]->version != plugin->version)
582 delete m_plugins.take(pluginName);
583 m_plugins[pluginName] = plugin.release();
584 emit pluginUpdated(pluginName);
590 void SearchPluginManager::parseVersionInfo(const QByteArray &info)
592 QHash<QString, PluginVersion> updateInfo;
593 int numCorrectData = 0;
595 const QList<QByteArrayView> lines = Utils::ByteArray::splitToViews(info, "\n", Qt::SkipEmptyParts);
596 for (QByteArrayView line : lines)
598 line = line.trimmed();
599 if (line.isEmpty()) continue;
600 if (line.startsWith('#')) continue;
602 const QList<QByteArrayView> list = Utils::ByteArray::splitToViews(line, ":", Qt::SkipEmptyParts);
603 if (list.size() != 2) continue;
605 const auto pluginName = QString::fromUtf8(list.first().trimmed());
606 const auto version = PluginVersion::fromString(QString::fromLatin1(list.last().trimmed()));
608 if (!version.isValid()) continue;
610 ++numCorrectData;
611 if (isUpdateNeeded(pluginName, version))
613 LogMsg(tr("Plugin \"%1\" is outdated, updating to version %2").arg(pluginName, version.toString()), Log::INFO);
614 updateInfo[pluginName] = version;
618 if (numCorrectData < lines.size())
620 emit checkForUpdatesFailed(tr("Incorrect update info received for %1 out of %2 plugins.")
621 .arg(QString::number(lines.size() - numCorrectData), QString::number(lines.size())));
623 else
625 emit checkForUpdatesFinished(updateInfo);
629 bool SearchPluginManager::isUpdateNeeded(const QString &pluginName, const PluginVersion &newVersion) const
631 const PluginInfo *plugin = pluginInfo(pluginName);
632 if (!plugin) return true;
634 PluginVersion oldVersion = plugin->version;
635 return (newVersion > oldVersion);
638 Path SearchPluginManager::pluginPath(const QString &name)
640 return (pluginsLocation() / Path(name + u".py"));
643 PluginVersion SearchPluginManager::getPluginVersion(const Path &filePath)
645 const int lineMaxLength = 16;
647 QFile pluginFile {filePath.data()};
648 if (!pluginFile.open(QIODevice::ReadOnly | QIODevice::Text))
649 return {};
651 while (!pluginFile.atEnd())
653 const auto line = QString::fromUtf8(pluginFile.readLine(lineMaxLength)).remove(u' ');
654 if (!line.startsWith(u"#VERSION:", Qt::CaseInsensitive)) continue;
656 const QString versionStr = line.mid(9);
657 const auto version = PluginVersion::fromString(versionStr);
658 if (version.isValid())
659 return version;
661 LogMsg(tr("Search plugin '%1' contains invalid version string ('%2')")
662 .arg(filePath.filename(), versionStr), Log::MsgType::WARNING);
663 break;
666 return {};