Sync translations from Transifex and run lupdate
[qBittorrent.git] / src / base / search / searchpluginmanager.cpp
blob214a329ab3cfa90a35a79e423960f6ce559249ca
1 /*
2 * Bittorrent Client using Qt and libtorrent.
3 * Copyright (C) 2015, 2018 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 <QPointer>
40 #include <QProcess>
42 #include "base/global.h"
43 #include "base/logger.h"
44 #include "base/net/downloadmanager.h"
45 #include "base/preferences.h"
46 #include "base/profile.h"
47 #include "base/utils/bytearray.h"
48 #include "base/utils/foreignapps.h"
49 #include "base/utils/fs.h"
50 #include "searchdownloadhandler.h"
51 #include "searchhandler.h"
53 namespace
55 void clearPythonCache(const QString &path)
57 // remove python cache artifacts in `path` and subdirs
59 QStringList dirs = {path};
60 QDirIterator iter {path, (QDir::AllDirs | QDir::NoDotAndDotDot), QDirIterator::Subdirectories};
61 while (iter.hasNext())
62 dirs += iter.next();
64 for (const QString &dir : asConst(dirs))
66 // python 3: remove "__pycache__" folders
67 if (dir.endsWith("/__pycache__"))
69 Utils::Fs::removeDirRecursive(dir);
70 continue;
73 // python 2: remove "*.pyc" files
74 const QStringList files = QDir(dir).entryList(QDir::Files);
75 for (const QString &file : files)
77 if (file.endsWith(".pyc"))
78 Utils::Fs::forceRemove(file);
84 QPointer<SearchPluginManager> SearchPluginManager::m_instance = nullptr;
86 SearchPluginManager::SearchPluginManager()
87 : m_updateUrl(QLatin1String("http://searchplugins.qbittorrent.org/nova3/engines/"))
89 Q_ASSERT(!m_instance); // only one instance is allowed
90 m_instance = this;
92 updateNova();
93 update();
96 SearchPluginManager::~SearchPluginManager()
98 qDeleteAll(m_plugins);
101 SearchPluginManager *SearchPluginManager::instance()
103 if (!m_instance)
104 m_instance = new SearchPluginManager;
105 return m_instance;
108 void SearchPluginManager::freeInstance()
110 delete m_instance;
113 QStringList SearchPluginManager::allPlugins() const
115 return m_plugins.keys();
118 QStringList SearchPluginManager::enabledPlugins() const
120 QStringList plugins;
121 for (const PluginInfo *plugin : asConst(m_plugins))
123 if (plugin->enabled)
124 plugins << plugin->name;
127 return plugins;
130 QStringList SearchPluginManager::supportedCategories() const
132 QStringList result;
133 for (const PluginInfo *plugin : asConst(m_plugins))
135 if (plugin->enabled)
137 for (const QString &cat : plugin->supportedCategories)
139 if (!result.contains(cat))
140 result << cat;
145 return result;
148 QStringList SearchPluginManager::getPluginCategories(const QString &pluginName) const
150 QStringList plugins;
151 if (pluginName == "all")
152 plugins = allPlugins();
153 else if ((pluginName == "enabled") || (pluginName == "multi"))
154 plugins = enabledPlugins();
155 else
156 plugins << pluginName.trimmed();
158 QSet<QString> categories;
159 for (const QString &name : asConst(plugins))
161 const PluginInfo *plugin = pluginInfo(name);
162 if (!plugin) continue; // plugin wasn't found
163 for (const QString &category : plugin->supportedCategories)
164 categories << category;
167 return categories.values();
170 PluginInfo *SearchPluginManager::pluginInfo(const QString &name) const
172 return m_plugins.value(name);
175 void SearchPluginManager::enablePlugin(const QString &name, const bool enabled)
177 PluginInfo *plugin = m_plugins.value(name, nullptr);
178 if (plugin)
180 plugin->enabled = enabled;
181 // Save to Hard disk
182 Preferences *const pref = Preferences::instance();
183 QStringList disabledPlugins = pref->getSearchEngDisabled();
184 if (enabled)
185 disabledPlugins.removeAll(name);
186 else if (!disabledPlugins.contains(name))
187 disabledPlugins.append(name);
188 pref->setSearchEngDisabled(disabledPlugins);
190 emit pluginEnabled(name, enabled);
194 // Updates shipped plugin
195 void SearchPluginManager::updatePlugin(const QString &name)
197 installPlugin(QString::fromLatin1("%1%2.py").arg(m_updateUrl, name));
200 // Install or update plugin from file or url
201 void SearchPluginManager::installPlugin(const QString &source)
203 clearPythonCache(engineLocation());
205 if (Net::DownloadManager::hasSupportedScheme(source))
207 using namespace Net;
208 DownloadManager::instance()->download(DownloadRequest(source).saveToFile(true)
209 , this, &SearchPluginManager::pluginDownloadFinished);
211 else
213 QString path = source;
214 if (path.startsWith("file:", Qt::CaseInsensitive))
215 path = QUrl(path).toLocalFile();
217 QString pluginName = Utils::Fs::fileName(path);
218 pluginName.chop(pluginName.size() - pluginName.lastIndexOf('.'));
220 if (!path.endsWith(".py", Qt::CaseInsensitive))
221 emit pluginInstallationFailed(pluginName, tr("Unknown search engine plugin file format."));
222 else
223 installPlugin_impl(pluginName, path);
227 void SearchPluginManager::installPlugin_impl(const QString &name, const QString &path)
229 const PluginVersion newVersion = getPluginVersion(path);
230 const PluginInfo *plugin = pluginInfo(name);
231 if (plugin && !(plugin->version < newVersion))
233 LogMsg(tr("Plugin already at version %1, which is greater than %2").arg(plugin->version, newVersion), Log::INFO);
234 emit pluginUpdateFailed(name, tr("A more recent version of this plugin is already installed."));
235 return;
238 // Process with install
239 const QString destPath = pluginPath(name);
240 bool updated = false;
241 if (QFile::exists(destPath))
243 // Backup in case install fails
244 QFile::copy(destPath, destPath + ".bak");
245 Utils::Fs::forceRemove(destPath);
246 updated = true;
248 // Copy the plugin
249 QFile::copy(path, destPath);
250 // Update supported plugins
251 update();
252 // Check if this was correctly installed
253 if (!m_plugins.contains(name))
255 // Remove broken file
256 Utils::Fs::forceRemove(destPath);
257 LogMsg(tr("Plugin %1 is not supported.").arg(name), Log::INFO);
258 if (updated)
260 // restore backup
261 QFile::copy(destPath + ".bak", destPath);
262 Utils::Fs::forceRemove(destPath + ".bak");
263 // Update supported plugins
264 update();
265 emit pluginUpdateFailed(name, tr("Plugin is not supported."));
267 else
269 emit pluginInstallationFailed(name, tr("Plugin is not supported."));
272 else
274 // Install was successful, remove backup
275 if (updated)
277 LogMsg(tr("Plugin %1 has been successfully updated.").arg(name), Log::INFO);
278 Utils::Fs::forceRemove(destPath + ".bak");
283 bool SearchPluginManager::uninstallPlugin(const QString &name)
285 clearPythonCache(engineLocation());
287 // remove it from hard drive
288 const QDir pluginsFolder(pluginsLocation());
289 QStringList filters;
290 filters << name + ".*";
291 const QStringList files = pluginsFolder.entryList(filters, QDir::Files, QDir::Unsorted);
292 for (const QString &file : files)
293 Utils::Fs::forceRemove(pluginsFolder.absoluteFilePath(file));
294 // Remove it from supported engines
295 delete m_plugins.take(name);
297 emit pluginUninstalled(name);
298 return true;
301 void SearchPluginManager::updateIconPath(PluginInfo *const plugin)
303 if (!plugin) return;
304 QString iconPath = QString::fromLatin1("%1/%2.png").arg(pluginsLocation(), plugin->name);
305 if (QFile::exists(iconPath))
307 plugin->iconPath = iconPath;
309 else
311 iconPath = QString::fromLatin1("%1/%2.ico").arg(pluginsLocation(), plugin->name);
312 if (QFile::exists(iconPath))
313 plugin->iconPath = iconPath;
317 void SearchPluginManager::checkForUpdates()
319 // Download version file from update server
320 using namespace Net;
321 DownloadManager::instance()->download({m_updateUrl + "versions.txt"}
322 , this, &SearchPluginManager::versionInfoDownloadFinished);
325 SearchDownloadHandler *SearchPluginManager::downloadTorrent(const QString &siteUrl, const QString &url)
327 return new SearchDownloadHandler {siteUrl, url, this};
330 SearchHandler *SearchPluginManager::startSearch(const QString &pattern, const QString &category, const QStringList &usedPlugins)
332 // No search pattern entered
333 Q_ASSERT(!pattern.isEmpty());
335 return new SearchHandler {pattern, category, usedPlugins, this};
338 QString SearchPluginManager::categoryFullName(const QString &categoryName)
340 const QHash<QString, QString> categoryTable
342 {"all", tr("All categories")},
343 {"movies", tr("Movies")},
344 {"tv", tr("TV shows")},
345 {"music", tr("Music")},
346 {"games", tr("Games")},
347 {"anime", tr("Anime")},
348 {"software", tr("Software")},
349 {"pictures", tr("Pictures")},
350 {"books", tr("Books")}
352 return categoryTable.value(categoryName);
355 QString SearchPluginManager::pluginFullName(const QString &pluginName)
357 return pluginInfo(pluginName) ? pluginInfo(pluginName)->fullName : QString();
360 QString SearchPluginManager::pluginsLocation()
362 return QString::fromLatin1("%1/engines").arg(engineLocation());
365 QString SearchPluginManager::engineLocation()
367 static QString location;
368 if (location.isEmpty())
370 location = Utils::Fs::expandPathAbs(specialFolderLocation(SpecialFolder::Data) + "nova3");
372 const QDir locationDir(location);
373 locationDir.mkpath(locationDir.absolutePath());
376 return location;
379 void SearchPluginManager::versionInfoDownloadFinished(const Net::DownloadResult &result)
381 if (result.status == Net::DownloadStatus::Success)
382 parseVersionInfo(result.data);
383 else
384 emit checkForUpdatesFailed(tr("Update server is temporarily unavailable. %1").arg(result.errorString));
387 void SearchPluginManager::pluginDownloadFinished(const Net::DownloadResult &result)
389 if (result.status == Net::DownloadStatus::Success)
391 const QString filePath = Utils::Fs::toUniformPath(result.filePath);
393 QString pluginName = Utils::Fs::fileName(result.url);
394 pluginName.chop(pluginName.size() - pluginName.lastIndexOf('.')); // Remove extension
395 installPlugin_impl(pluginName, filePath);
396 Utils::Fs::forceRemove(filePath);
398 else
400 const QString url = result.url;
401 QString pluginName = url.mid(url.lastIndexOf('/') + 1);
402 pluginName.replace(".py", "", Qt::CaseInsensitive);
404 if (pluginInfo(pluginName))
405 emit pluginUpdateFailed(pluginName, tr("Failed to download the plugin file. %1").arg(result.errorString));
406 else
407 emit pluginInstallationFailed(pluginName, tr("Failed to download the plugin file. %1").arg(result.errorString));
411 // Update nova.py search plugin if necessary
412 void SearchPluginManager::updateNova()
414 // create nova directory if necessary
415 const QDir searchDir(engineLocation());
417 QFile packageFile(searchDir.absoluteFilePath("__init__.py"));
418 packageFile.open(QIODevice::WriteOnly);
419 packageFile.close();
421 searchDir.mkdir("engines");
423 QFile packageFile2(searchDir.absolutePath() + "/engines/__init__.py");
424 packageFile2.open(QIODevice::WriteOnly);
425 packageFile2.close();
427 // Copy search plugin files (if necessary)
428 const auto updateFile = [](const QString &filename, const bool compareVersion)
430 const QString filePathBundled = ":/searchengine/nova3/" + filename;
431 const QString filePathDisk = QDir(engineLocation()).absoluteFilePath(filename);
433 if (compareVersion && (getPluginVersion(filePathBundled) <= getPluginVersion(filePathDisk)))
434 return;
436 Utils::Fs::forceRemove(filePathDisk);
437 QFile::copy(filePathBundled, filePathDisk);
440 updateFile("helpers.py", true);
441 updateFile("nova2.py", true);
442 updateFile("nova2dl.py", true);
443 updateFile("novaprinter.py", true);
444 updateFile("sgmllib3.py", false);
445 updateFile("socks.py", false);
448 void SearchPluginManager::update()
450 QProcess nova;
451 nova.setProcessEnvironment(QProcessEnvironment::systemEnvironment());
453 const QStringList params {Utils::Fs::toNativePath(engineLocation() + "/nova2.py"), "--capabilities"};
454 nova.start(Utils::ForeignApps::pythonInfo().executableName, params, QIODevice::ReadOnly);
455 nova.waitForFinished();
457 const QString capabilities = nova.readAll();
458 QDomDocument xmlDoc;
459 if (!xmlDoc.setContent(capabilities))
461 qWarning() << "Could not parse Nova search engine capabilities, msg: " << capabilities.toLocal8Bit().data();
462 qWarning() << "Error: " << nova.readAllStandardError().constData();
463 return;
466 const QDomElement root = xmlDoc.documentElement();
467 if (root.tagName() != "capabilities")
469 qWarning() << "Invalid XML file for Nova search engine capabilities, msg: " << capabilities.toLocal8Bit().data();
470 return;
473 for (QDomNode engineNode = root.firstChild(); !engineNode.isNull(); engineNode = engineNode.nextSibling())
475 const QDomElement engineElem = engineNode.toElement();
476 if (!engineElem.isNull())
478 const QString pluginName = engineElem.tagName();
480 auto plugin = std::make_unique<PluginInfo>();
481 plugin->name = pluginName;
482 plugin->version = getPluginVersion(pluginPath(pluginName));
483 plugin->fullName = engineElem.elementsByTagName("name").at(0).toElement().text();
484 plugin->url = engineElem.elementsByTagName("url").at(0).toElement().text();
486 const QStringList categories = engineElem.elementsByTagName("categories").at(0).toElement().text().split(' ');
487 for (QString cat : categories)
489 cat = cat.trimmed();
490 if (!cat.isEmpty())
491 plugin->supportedCategories << cat;
494 const QStringList disabledEngines = Preferences::instance()->getSearchEngDisabled();
495 plugin->enabled = !disabledEngines.contains(pluginName);
497 updateIconPath(plugin.get());
499 if (!m_plugins.contains(pluginName))
501 m_plugins[pluginName] = plugin.release();
502 emit pluginInstalled(pluginName);
504 else if (m_plugins[pluginName]->version != plugin->version)
506 delete m_plugins.take(pluginName);
507 m_plugins[pluginName] = plugin.release();
508 emit pluginUpdated(pluginName);
514 void SearchPluginManager::parseVersionInfo(const QByteArray &info)
516 QHash<QString, PluginVersion> updateInfo;
517 int numCorrectData = 0;
519 const QVector<QByteArray> lines = Utils::ByteArray::splitToViews(info, "\n", Qt::SkipEmptyParts);
520 for (QByteArray line : lines)
522 line = line.trimmed();
523 if (line.isEmpty()) continue;
524 if (line.startsWith('#')) continue;
526 const QVector<QByteArray> list = Utils::ByteArray::splitToViews(line, ":", Qt::SkipEmptyParts);
527 if (list.size() != 2) continue;
529 const QString pluginName = list.first().trimmed();
530 const PluginVersion version = PluginVersion::tryParse(list.last().trimmed(), {});
532 if (!version.isValid()) continue;
534 ++numCorrectData;
535 if (isUpdateNeeded(pluginName, version))
537 LogMsg(tr("Plugin \"%1\" is outdated, updating to version %2").arg(pluginName, version), Log::INFO);
538 updateInfo[pluginName] = version;
542 if (numCorrectData < lines.size())
544 emit checkForUpdatesFailed(tr("Incorrect update info received for %1 out of %2 plugins.")
545 .arg(QString::number(lines.size() - numCorrectData), QString::number(lines.size())));
547 else
549 emit checkForUpdatesFinished(updateInfo);
553 bool SearchPluginManager::isUpdateNeeded(const QString &pluginName, const PluginVersion newVersion) const
555 const PluginInfo *plugin = pluginInfo(pluginName);
556 if (!plugin) return true;
558 PluginVersion oldVersion = plugin->version;
559 return (newVersion > oldVersion);
562 QString SearchPluginManager::pluginPath(const QString &name)
564 return QString::fromLatin1("%1/%2.py").arg(pluginsLocation(), name);
567 PluginVersion SearchPluginManager::getPluginVersion(const QString &filePath)
569 QFile pluginFile(filePath);
570 if (!pluginFile.open(QIODevice::ReadOnly | QIODevice::Text))
571 return {};
573 while (!pluginFile.atEnd())
575 const QString line = QString(pluginFile.readLine()).remove(' ');
576 if (!line.startsWith("#VERSION:", Qt::CaseInsensitive)) continue;
578 const QString versionStr = line.mid(9);
579 const PluginVersion version = PluginVersion::tryParse(versionStr, {});
580 if (version.isValid())
581 return version;
583 LogMsg(tr("Search plugin '%1' contains invalid version string ('%2')")
584 .arg(Utils::Fs::fileName(filePath), versionStr), Log::MsgType::WARNING);
585 break;
588 return {};