Compare commits

...

4 Commits

Author SHA1 Message Date
Thomas (Tom) Piccirello
93a72673d4 WebAPI: send names of missing required params
Small quality of life improvement.

PR #23192.
2025-09-05 20:46:47 +08:00
anikey
56277d5e2b WebUI: add I2P peers to peer list
The WebUI part of the changes for #23061. Now qBittorrent will display I2P peers in WebUI peers tab.
Fixes the second part of #19794 ("i2p peer list does not show in GUI").

PR #23185.
2025-09-05 20:40:25 +08:00
Thomas (Tom) Piccirello
ac31fe52e9 WebUI: Continue polling after network error
These `fetch` calls properly handle 4xx and 5xx errors, but don't handle network errors. In all cases, I've used the same logic as the `!response.ok` branch of each individual fetch function. This should ensure consistent behavior.

PR #23164.
2025-09-05 20:32:43 +08:00
Thomas (Tom) Piccirello
4fa433a728 WebUI/WebAPI: Support downloading torrent via search plugin
This adds support for downloading a torrent via a search plugin's `download_torrent` function. This primarily affects torrents that use a private tracker requiring a login.

Closes #18334.
PR #23163.
2025-09-05 20:24:15 +08:00
13 changed files with 171 additions and 52 deletions

View File

@@ -1,5 +1,10 @@
# WebAPI Changelog
## 2.13.1
* [#23163](https://github.com/qbittorrent/qBittorrent/pull/23163)
* `torrents/add` endpoint now supports downloading from a search plugin via the `downloader` parameter
* `torrents/fetchMetadata` endpoint now supports fetching from a search plugin via the `downloader` parameter
## 2.13.0
* [#23045](https://github.com/qbittorrent/qBittorrent/pull/23045)
* `torrents/trackers` returns three new fields: `next_announce`, `min_announce` and `endpoints`

View File

@@ -35,6 +35,7 @@
#include <QList>
#include <QMetaObject>
#include "base/global.h"
#include "apierror.h"
void APIResult::clear()
@@ -75,14 +76,17 @@ const DataMap &APIController::data() const
void APIController::requireParams(const QList<QString> &requiredParams) const
{
const bool hasAllRequiredParams = std::all_of(requiredParams.cbegin(), requiredParams.cend()
, [this](const QString &requiredParam)
{
return params().contains(requiredParam);
});
QStringList missingParams;
missingParams.reserve(requiredParams.size());
if (!hasAllRequiredParams)
throw APIError(APIErrorType::BadParams);
for (const QString &requiredParam : requiredParams)
{
if (!params().contains(requiredParam))
missingParams.append(requiredParam);
}
if (!missingParams.isEmpty())
throw APIError(APIErrorType::BadParams, tr("Missing required parameters: %1").arg(missingParams.join(u", "_s)));
}
void APIController::setResult(const QString &result)

View File

@@ -58,9 +58,12 @@
#include "base/logger.h"
#include "base/net/downloadmanager.h"
#include "base/preferences.h"
#include "base/search/searchdownloadhandler.h"
#include "base/search/searchpluginmanager.h"
#include "base/torrentfilter.h"
#include "base/utils/datetime.h"
#include "base/utils/fs.h"
#include "base/utils/io.h"
#include "base/utils/sslkey.h"
#include "base/utils/string.h"
#include "apierror.h"
@@ -1057,6 +1060,10 @@ void TorrentsController::addAction()
}
}
const QString downloaderParam = params()[u"downloader"_s];
if (!downloaderParam.isEmpty() && !SearchPluginManager::instance()->allPlugins().contains(downloaderParam))
throw APIError(APIErrorType::BadParams, tr("`downloader` must be a valid search plugin"));
BitTorrent::AddTorrentParams addTorrentParams
{
.name = torrentName,
@@ -1118,10 +1125,24 @@ void TorrentsController::addAction()
partialSuccess |= BitTorrent::Session::instance()->addTorrent(torrentDescr, addTorrentParams);
}
else if (!downloaderParam.isEmpty())
{
if (!filePriorities.isEmpty())
throw APIError(APIErrorType::BadParams, tr("`filePriorities` may only be specified when metadata has already been fetched"));
SearchDownloadHandler *downloadHandler = SearchPluginManager::instance()->downloadTorrent(downloaderParam, url);
connect(downloadHandler, &SearchDownloadHandler::downloadFinished
, this, [this, addTorrentParams](const QString &torrentFilePath)
{
app()->addTorrentManager()->addTorrent(torrentFilePath, addTorrentParams);
});
connect(downloadHandler, &SearchDownloadHandler::downloadFinished, downloadHandler, &SearchDownloadHandler::deleteLater);
partialSuccess = true;
}
else
{
if (!filePriorities.isEmpty())
throw APIError(APIErrorType::BadParams, tr("filePriorities may only be specified when metadata has already been fetched"));
throw APIError(APIErrorType::BadParams, tr("`filePriorities` may only be specified when metadata has already been fetched"));
partialSuccess |= app()->addTorrentManager()->addTorrent(url, addTorrentParams);
}
@@ -2032,6 +2053,10 @@ void TorrentsController::fetchMetadataAction()
if (sourceParam.isEmpty())
throw APIError(APIErrorType::BadParams, tr("Must specify URI or hash"));
const QString downloaderParam = params()[u"downloader"_s];
if (!downloaderParam.isEmpty() && !SearchPluginManager::instance()->allPlugins().contains(downloaderParam))
throw APIError(APIErrorType::BadParams, tr("downloader must be a valid search plugin"));
const QString source = QUrl::fromPercentEncoding(sourceParam.toLatin1());
const auto sourceTorrentDescr = BitTorrent::TorrentDescriptor::parse(source);
@@ -2074,11 +2099,23 @@ void TorrentsController::fetchMetadataAction()
{
if (!m_requestedTorrentSource.contains(source))
{
const auto *pref = Preferences::instance();
Net::DownloadManager::instance()->download(Net::DownloadRequest(source).limit(pref->getTorrentFileSizeLimit())
, pref->useProxyForGeneralPurposes(), this, &TorrentsController::onDownloadFinished);
if (!downloaderParam.isEmpty())
{
SearchDownloadHandler *downloadHandler = SearchPluginManager::instance()->downloadTorrent(downloaderParam, source);
connect(downloadHandler, &SearchDownloadHandler::downloadFinished
, this, [this, source](const QString &data)
{
onSearchPluginTorrentDownloaded(source, data);
});
connect(downloadHandler, &SearchDownloadHandler::downloadFinished, downloadHandler, &SearchDownloadHandler::deleteLater);
}
else
{
const auto *pref = Preferences::instance();
Net::DownloadManager::instance()->download(Net::DownloadRequest(source).limit(pref->getTorrentFileSizeLimit())
, pref->useProxyForGeneralPurposes(), this, &TorrentsController::onDownloadFinished);
}
m_requestedTorrentSource.insert(source);
}
@@ -2156,33 +2193,13 @@ void TorrentsController::onDownloadFinished(const Net::DownloadResult &result)
{
case Net::DownloadStatus::Success:
// use the info directly from the .torrent file
if (const auto loadResult = BitTorrent::TorrentDescriptor::load(result.data))
{
const BitTorrent::TorrentDescriptor &torrentDescr = loadResult.value();
const BitTorrent::InfoHash infoHash = torrentDescr.infoHash();
m_torrentSourceCache.insert(source, infoHash);
m_torrentMetadataCache.insert(infoHash.toTorrentID(), torrentDescr);
}
else
{
LogMsg(tr("Parse torrent failed. URL: \"%1\". Error: \"%2\".").arg(source, loadResult.error()), Log::WARNING);
m_torrentSourceCache.remove(source);
}
cacheTorrentFile(source, result.data);
break;
case Net::DownloadStatus::RedirectedToMagnet:
if (const auto parseResult = BitTorrent::TorrentDescriptor::parse(result.magnetURI))
{
const BitTorrent::TorrentDescriptor &torrentDescr = parseResult.value();
const BitTorrent::InfoHash infoHash = torrentDescr.infoHash();
const BitTorrent::TorrentID torrentID = infoHash.toTorrentID();
m_torrentSourceCache.insert(source, infoHash);
if (!m_torrentMetadataCache.contains(torrentID) && !BitTorrent::Session::instance()->isKnownTorrent(infoHash))
{
if (BitTorrent::Session::instance()->downloadMetadata(torrentDescr))
m_torrentMetadataCache.insert(torrentID, torrentDescr);
}
cacheMagnetURI(source, parseResult.value());
}
else
{
@@ -2215,3 +2232,53 @@ void TorrentsController::onMetadataDownloaded(const BitTorrent::TorrentInfo &inf
iter.value().setTorrentInfo(info);
}
}
void TorrentsController::onSearchPluginTorrentDownloaded(const QString &source, const QString &data)
{
m_requestedTorrentSource.remove(source);
// magnet URI
if (const auto parseResult = BitTorrent::TorrentDescriptor::parse(data))
{
cacheMagnetURI(source, parseResult.value());
}
// path to .torrent file
else if (const auto readResult = Utils::IO::readFile(Path(data), Preferences::instance()->getTorrentFileSizeLimit()))
{
cacheTorrentFile(source, readResult.value());
}
else
{
LogMsg(tr("Reading downloaded torrent data failed. Data: \"%1\". Error: \"%2\".").arg(data, readResult.error().message), Log::WARNING);
m_torrentSourceCache.remove(source);
}
}
void TorrentsController::cacheTorrentFile(const QString &source, const QByteArray &data)
{
if (const auto loadResult = BitTorrent::TorrentDescriptor::load(data))
{
const BitTorrent::TorrentDescriptor &torrentDescr = loadResult.value();
const BitTorrent::InfoHash infoHash = torrentDescr.infoHash();
m_torrentSourceCache.insert(source, infoHash);
m_torrentMetadataCache.insert(infoHash.toTorrentID(), torrentDescr);
}
else
{
LogMsg(tr("Parse torrent failed. URL: \"%1\". Error: \"%2\".").arg(source, loadResult.error()), Log::WARNING);
m_torrentSourceCache.remove(source);
}
}
void TorrentsController::cacheMagnetURI(const QString &source, const BitTorrent::TorrentDescriptor &torrentDescr)
{
const BitTorrent::InfoHash infoHash = torrentDescr.infoHash();
const BitTorrent::TorrentID torrentID = infoHash.toTorrentID();
m_torrentSourceCache.insert(source, infoHash);
if (!m_torrentMetadataCache.contains(torrentID) && !BitTorrent::Session::instance()->isKnownTorrent(infoHash))
{
if (BitTorrent::Session::instance()->downloadMetadata(torrentDescr))
m_torrentMetadataCache.insert(torrentID, torrentDescr);
}
}

View File

@@ -34,6 +34,8 @@
#include "base/bittorrent/torrentdescriptor.h"
#include "apicontroller.h"
class QByteArray;
namespace BitTorrent
{
class InfoHash;
@@ -119,6 +121,9 @@ private slots:
private:
void onDownloadFinished(const Net::DownloadResult &result);
void onMetadataDownloaded(const BitTorrent::TorrentInfo &info);
void onSearchPluginTorrentDownloaded(const QString &source, const QString &data);
void cacheTorrentFile(const QString &source, const QByteArray &data);
void cacheMagnetURI(const QString &source, const BitTorrent::TorrentDescriptor &torrentDescr);
QHash<QString, BitTorrent::InfoHash> m_torrentSourceCache;
QHash<BitTorrent::TorrentID, BitTorrent::TorrentDescriptor> m_torrentMetadataCache;

View File

@@ -53,7 +53,7 @@
#include "base/utils/version.h"
#include "api/isessionmanager.h"
inline const Utils::Version<3, 2> API_VERSION {2, 13, 0};
inline const Utils::Version<3, 2> API_VERSION {2, 13, 1};
class APIController;
class AuthController;

View File

@@ -49,6 +49,13 @@
const windowId = searchParams.get("windowId");
window.qBittorrent.AddTorrent.setWindowId(windowId);
const downloader = searchParams.get("downloader");
if (downloader?.length > 0) {
const downloaderInput = document.getElementById("downloader");
downloaderInput.value = downloader;
downloaderInput.disabled = false;
}
document.getElementById("uploadForm").addEventListener("submit", (event) => {
submitted = true;
window.qBittorrent.AddTorrent.submitForm();
@@ -81,7 +88,7 @@
window.qBittorrent.TorrentContent.init("addTorrentFilesTableDiv", window.qBittorrent.DynamicTable.AddTorrentFilesTable);
if (fetchMetadata)
window.qBittorrent.AddTorrent.loadMetadata(source);
window.qBittorrent.AddTorrent.loadMetadata(source, downloader);
});
</script>
@@ -144,6 +151,7 @@
<form action="api/v2/torrents/add" enctype="multipart/form-data" method="post" id="uploadForm" style="text-align: center; padding: 10px 12px;" target="upload_frame" autocorrect="off" autocapitalize="none">
<input type="hidden" id="urls" name="urls">
<input type="hidden" id="filePriorities" name="filePriorities">
<input type="hidden" id="downloader" name="downloader" disabled>
<div class="container">
<div class="column">
<fieldset class="settings" style="text-align: left;">

View File

@@ -42,6 +42,7 @@ window.qBittorrent.AddTorrent ??= (() => {
let defaultTempPathEnabled = false;
let windowId = "";
let source = "";
let downloader = "";
const localPreferences = new window.qBittorrent.LocalPreferences.LocalPreferences();
@@ -214,14 +215,17 @@ window.qBittorrent.AddTorrent ??= (() => {
};
let loadMetadataTimer = -1;
const loadMetadata = (sourceUrl = undefined) => {
const loadMetadata = (sourceUrl = undefined, downloaderName = undefined) => {
if (sourceUrl !== undefined)
source = sourceUrl;
if (downloaderName !== undefined)
downloader = downloaderName;
fetch("api/v2/torrents/fetchMetadata", {
method: "POST",
body: new URLSearchParams({
source: source
source: source,
downloader: downloader
})
})
.then(async (response) => {
@@ -237,6 +241,10 @@ window.qBittorrent.AddTorrent ??= (() => {
metadataCompleted();
else
loadMetadataTimer = loadMetadata.delay(1000);
}, (error) => {
console.error(error);
loadMetadataTimer = loadMetadata.delay(1000);
});
};

View File

@@ -130,7 +130,7 @@ window.qBittorrent.Client ??= (() => {
return showingLogViewer;
};
const createAddTorrentWindow = (title, source, metadata = undefined) => {
const createAddTorrentWindow = (title, source, metadata = undefined, downloader = undefined) => {
const isFirefox = navigator.userAgent.includes("Firefox");
const isSafari = navigator.userAgent.includes("AppleWebKit") && !navigator.userAgent.includes("Chrome");
let height = 855;
@@ -147,7 +147,8 @@ window.qBittorrent.Client ??= (() => {
v: "${CACHEID}",
source: source,
fetch: metadata === undefined,
windowId: id
windowId: id,
downloader: downloader ?? ""
});
new MochaUI.Window({

View File

@@ -1829,7 +1829,7 @@ window.qBittorrent.DynamicTable ??= (() => {
class TorrentPeersTable extends DynamicTable {
initColumns() {
this.newColumn("country", "", "QBT_TR(Country/Region)QBT_TR[CONTEXT=PeerListWidget]", 22, true);
this.newColumn("ip", "", "QBT_TR(IP)QBT_TR[CONTEXT=PeerListWidget]", 80, true);
this.newColumn("ip", "", "QBT_TR(IP/Address)QBT_TR[CONTEXT=PeerListWidget]", 80, true);
this.newColumn("port", "", "QBT_TR(Port)QBT_TR[CONTEXT=PeerListWidget]", 35, true);
this.newColumn("connection", "", "QBT_TR(Connection)QBT_TR[CONTEXT=PeerListWidget]", 50, true);
this.newColumn("flags", "", "QBT_TR(Flags)QBT_TR[CONTEXT=PeerListWidget]", 50, true);
@@ -1871,15 +1871,7 @@ window.qBittorrent.DynamicTable ??= (() => {
const ip1 = this.getRowValue(row1);
const ip2 = this.getRowValue(row2);
const a = ip1.split(".");
const b = ip2.split(".");
for (let i = 0; i < 4; ++i) {
if (a[i] !== b[i])
return a[i] - b[i];
}
return 0;
return window.qBittorrent.Misc.naturalSortCollator.compare(ip1, ip2);
};
// flags

View File

@@ -234,6 +234,12 @@ window.qBittorrent.PropGeneral ??= (() => {
}
clearTimeout(loadTorrentDataTimer);
loadTorrentDataTimer = loadTorrentData.delay(5000);
}, (error) => {
console.error(error);
document.getElementById("error_div").textContent = "QBT_TR(qBittorrent client is not reachable)QBT_TR[CONTEXT=HttpServer]";
clearTimeout(loadTorrentDataTimer);
loadTorrentDataTimer = loadTorrentData.delay(10000);
});
const pieceStatesURL = new URL("api/v2/torrents/pieceStates", window.location);
@@ -262,6 +268,12 @@ window.qBittorrent.PropGeneral ??= (() => {
clearTimeout(loadTorrentDataTimer);
loadTorrentDataTimer = loadTorrentData.delay(5000);
}, (error) => {
console.error(error);
document.getElementById("error_div").textContent = "QBT_TR(qBittorrent client is not reachable)QBT_TR[CONTEXT=HttpServer]";
clearTimeout(loadTorrentDataTimer);
loadTorrentDataTimer = loadTorrentData.delay(10000);
});
};

View File

@@ -86,6 +86,12 @@ window.qBittorrent.PropPeers ??= (() => {
continue;
responseJSON["peers"][key]["rowId"] = key;
if (Object.hasOwn(responseJSON["peers"][key], "i2p_dest")) {
responseJSON["peers"][key]["ip"] = responseJSON["peers"][key]["i2p_dest"];
responseJSON["peers"][key]["port"] = "N/A";
}
torrentPeersTable.updateRowData(responseJSON["peers"][key]);
}
}

View File

@@ -561,8 +561,8 @@ window.qBittorrent.Search ??= (() => {
const downloadSearchTorrent = () => {
for (const rowID of searchResultsTable.selectedRowsIds()) {
const { fileName, fileUrl } = searchResultsTable.getRow(rowID).full_data;
qBittorrent.Client.createAddTorrentWindow(fileName, fileUrl);
const { engineName, fileName, fileUrl } = searchResultsTable.getRow(rowID).full_data;
qBittorrent.Client.createAddTorrentWindow(fileName, fileUrl, undefined, engineName);
}
};
@@ -917,6 +917,11 @@ window.qBittorrent.Search ??= (() => {
clearTimeout(state.loadResultsTimer);
state.loadResultsTimer = loadSearchResultsData.delay(2000, this, searchId);
}, (error) => {
console.error(error);
clearTimeout(state.loadResultsTimer);
state.loadResultsTimer = loadSearchResultsData.delay(3000, this, searchId);
});
};

View File

@@ -416,6 +416,12 @@
tableInfo[curTab].progress = false;
syncLogWithInterval(getSyncLogDataInterval());
}, (error) => {
console.error(error);
document.getElementById("error_div").textContent = "QBT_TR(qBittorrent client is not reachable)QBT_TR[CONTEXT=HttpServer]";
tableInfo[curTab].progress = false;
syncLogWithInterval(10000);
});
};