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"
35 #include <QDirIterator>
36 #include <QDomDocument>
37 #include <QDomElement>
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"
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
);
76 // python 2: remove "*.pyc" files
77 QDirIterator it
{dir
.data(), {u
"*.pyc"_s
}, QDir::Files
};
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
95 connect(Net::ProxyConfigurationManager::instance(), &Net::ProxyConfigurationManager::proxyConfigurationChanged
96 , this, &SearchPluginManager::applyProxySettings
);
97 connect(Preferences::instance(), &Preferences::changed
98 , this, &SearchPluginManager::applyProxySettings
);
105 SearchPluginManager::~SearchPluginManager()
107 qDeleteAll(m_plugins
);
110 SearchPluginManager
*SearchPluginManager::instance()
113 m_instance
= new SearchPluginManager
;
117 void SearchPluginManager::freeInstance()
122 QStringList
SearchPluginManager::allPlugins() const
124 return m_plugins
.keys();
127 QStringList
SearchPluginManager::enabledPlugins() const
130 for (const PluginInfo
*plugin
: asConst(m_plugins
))
133 plugins
<< plugin
->name
;
139 QStringList
SearchPluginManager::supportedCategories() const
142 for (const PluginInfo
*plugin
: asConst(m_plugins
))
146 for (const QString
&cat
: plugin
->supportedCategories
)
148 if (!result
.contains(cat
))
157 QStringList
SearchPluginManager::getPluginCategories(const QString
&pluginName
) const
160 if (pluginName
== u
"all")
161 plugins
= allPlugins();
162 else if ((pluginName
== u
"enabled") || (pluginName
== u
"multi"))
163 plugins
= enabledPlugins();
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
)
195 void SearchPluginManager::enablePlugin(const QString
&name
, const bool enabled
)
197 PluginInfo
*plugin
= m_plugins
.value(name
, nullptr);
200 plugin
->enabled
= enabled
;
202 Preferences
*const pref
= Preferences::instance();
203 QStringList disabledPlugins
= pref
->getSearchEngDisabled();
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
))
228 DownloadManager::instance()->download(DownloadRequest(source
).saveToFile(true)
229 , Preferences::instance()->useProxyForGeneralPurposes()
230 , this, &SearchPluginManager::pluginDownloadFinished
);
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
);
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."));
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
);
272 Utils::Fs::copyFile(path
, destPath
);
273 // Update supported plugins
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
);
284 Utils::Fs::copyFile(backupPath
, destPath
);
285 Utils::Fs::removeFile(backupPath
);
286 // Update supported plugins
288 emit
pluginUpdateFailed(name
, tr("Plugin is not supported."));
292 emit
pluginInstallationFailed(name
, tr("Plugin is not supported."));
297 // Install was successful, remove backup
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
);
325 void SearchPluginManager::updateIconPath(PluginInfo
*const plugin
)
329 const Path pluginsPath
= pluginsLocation();
330 Path iconPath
= pluginsPath
/ Path(plugin
->name
+ u
".png");
331 if (iconPath
.exists())
333 plugin
->iconPath
= iconPath
;
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
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
"anime"_s
, tr("Anime")},
371 {u
"books"_s
, tr("Books")},
372 {u
"games"_s
, tr("Games")},
373 {u
"movies"_s
, tr("Movies")},
374 {u
"music"_s
, tr("Music")},
375 {u
"pictures"_s
, tr("Pictures")},
376 {u
"software"_s
, tr("Software")},
377 {u
"tv"_s
, tr("TV shows")}
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
);
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
));
423 proxyStrHTTP
= u
"http://%1:%2"_s
.arg(proxyConfig
.ip
, QString::number(proxyConfig
.port
));
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
));
435 proxyStrSOCK
= u
"%1:%2"_s
.arg(proxyConfig
.ip
, QString::number(proxyConfig
.port
));
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
);
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
);
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
));
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
);
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
)))
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()
522 nova
.setProcessEnvironment(QProcessEnvironment::systemEnvironment());
524 const QStringList params
526 Utils::ForeignApps::PYTHON_ISOLATE_MODE_FLAG
,
527 (engineLocation() / Path(u
"/nova2.py"_s
)).toString(),
530 nova
.start(Utils::ForeignApps::pythonInfo().executableName
, params
, QIODevice::ReadOnly
);
531 nova
.waitForFinished();
533 const auto capabilities
= QString::fromUtf8(nova
.readAllStandardOutput());
535 if (!xmlDoc
.setContent(capabilities
))
537 qWarning() << "Could not parse Nova search engine capabilities, msg: " << capabilities
.toLocal8Bit().data();
538 qWarning() << "Error: " << nova
.readAllStandardError().constData();
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();
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
)
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;
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())));
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
))
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())
661 LogMsg(tr("Search plugin '%1' contains invalid version string ('%2')")
662 .arg(filePath
.filename(), versionStr
), Log::MsgType::WARNING
);