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"
35 #include <QDirIterator>
36 #include <QDomDocument>
37 #include <QDomElement>
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"
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())
64 for (const QString
&dir
: asConst(dirs
))
66 // python 3: remove "__pycache__" folders
67 if (dir
.endsWith("/__pycache__"))
69 Utils::Fs::removeDirRecursive(dir
);
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
96 SearchPluginManager::~SearchPluginManager()
98 qDeleteAll(m_plugins
);
101 SearchPluginManager
*SearchPluginManager::instance()
104 m_instance
= new SearchPluginManager
;
108 void SearchPluginManager::freeInstance()
113 QStringList
SearchPluginManager::allPlugins() const
115 return m_plugins
.keys();
118 QStringList
SearchPluginManager::enabledPlugins() const
121 for (const PluginInfo
*plugin
: asConst(m_plugins
))
124 plugins
<< plugin
->name
;
130 QStringList
SearchPluginManager::supportedCategories() const
133 for (const PluginInfo
*plugin
: asConst(m_plugins
))
137 for (const QString
&cat
: plugin
->supportedCategories
)
139 if (!result
.contains(cat
))
148 QStringList
SearchPluginManager::getPluginCategories(const QString
&pluginName
) const
151 if (pluginName
== "all")
152 plugins
= allPlugins();
153 else if ((pluginName
== "enabled") || (pluginName
== "multi"))
154 plugins
= enabledPlugins();
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);
180 plugin
->enabled
= enabled
;
182 Preferences
*const pref
= Preferences::instance();
183 QStringList disabledPlugins
= pref
->getSearchEngDisabled();
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
))
208 DownloadManager::instance()->download(DownloadRequest(source
).saveToFile(true)
209 , this, &SearchPluginManager::pluginDownloadFinished
);
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."));
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."));
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
);
249 QFile::copy(path
, destPath
);
250 // Update supported plugins
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
);
261 QFile::copy(destPath
+ ".bak", destPath
);
262 Utils::Fs::forceRemove(destPath
+ ".bak");
263 // Update supported plugins
265 emit
pluginUpdateFailed(name
, tr("Plugin is not supported."));
269 emit
pluginInstallationFailed(name
, tr("Plugin is not supported."));
274 // Install was successful, remove backup
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());
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
);
301 void SearchPluginManager::updateIconPath(PluginInfo
*const plugin
)
304 QString iconPath
= QString::fromLatin1("%1/%2.png").arg(pluginsLocation(), plugin
->name
);
305 if (QFile::exists(iconPath
))
307 plugin
->iconPath
= iconPath
;
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
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());
379 void SearchPluginManager::versionInfoDownloadFinished(const Net::DownloadResult
&result
)
381 if (result
.status
== Net::DownloadStatus::Success
)
382 parseVersionInfo(result
.data
);
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
);
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
));
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
);
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
)))
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()
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();
459 if (!xmlDoc
.setContent(capabilities
))
461 qWarning() << "Could not parse Nova search engine capabilities, msg: " << capabilities
.toLocal8Bit().data();
462 qWarning() << "Error: " << nova
.readAllStandardError().constData();
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();
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
)
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;
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())));
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
))
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())
583 LogMsg(tr("Search plugin '%1' contains invalid version string ('%2')")
584 .arg(Utils::Fs::fileName(filePath
), versionStr
), Log::MsgType::WARNING
);