diff --git a/src/base/bittorrent/torrentdescriptor.cpp b/src/base/bittorrent/torrentdescriptor.cpp index 42e969b0d597..0ce08b39fd05 100644 --- a/src/base/bittorrent/torrentdescriptor.cpp +++ b/src/base/bittorrent/torrentdescriptor.cpp @@ -143,6 +143,22 @@ catch (const lt::system_error &err) return nonstd::make_unexpected(QString::fromLocal8Bit(err.what())); } +nonstd::expected BitTorrent::TorrentDescriptor::saveToBuffer() const +try +{ + const lt::entry torrentEntry = lt::write_torrent_file(m_ltAddTorrentParams); + // usually torrent size should be smaller than 1 MB, + // however there are >100 MB v2/hybrid torrent files out in the wild + QByteArray buffer; + buffer.reserve(1024 * 1024); + lt::bencode(std::back_inserter(buffer), torrentEntry); + return buffer; +} +catch (const lt::system_error &err) +{ + return nonstd::make_unexpected(QString::fromLocal8Bit(err.what())); +} + BitTorrent::TorrentDescriptor::TorrentDescriptor(lt::add_torrent_params ltAddTorrentParams) : m_ltAddTorrentParams {std::move(ltAddTorrentParams)} { diff --git a/src/base/bittorrent/torrentdescriptor.h b/src/base/bittorrent/torrentdescriptor.h index 54248ae5768f..b62dba945f49 100644 --- a/src/base/bittorrent/torrentdescriptor.h +++ b/src/base/bittorrent/torrentdescriptor.h @@ -69,6 +69,7 @@ namespace BitTorrent static nonstd::expected loadFromFile(const Path &path) noexcept; static nonstd::expected parse(const QString &str) noexcept; nonstd::expected saveToFile(const Path &path) const; + nonstd::expected saveToBuffer() const; const lt::add_torrent_params <AddTorrentParams() const; diff --git a/src/webui/CMakeLists.txt b/src/webui/CMakeLists.txt index 9dfc12831cef..0c87bf7686d8 100644 --- a/src/webui/CMakeLists.txt +++ b/src/webui/CMakeLists.txt @@ -2,6 +2,7 @@ add_library(qbt_webui STATIC # headers api/apicontroller.h api/apierror.h + api/apistatus.h api/appcontroller.h api/authcontroller.h api/isessionmanager.h @@ -14,6 +15,8 @@ add_library(qbt_webui STATIC api/transfercontroller.h api/serialize/serialize_torrent.h freediskspacechecker.h + torrentmetadatacache.h + torrentsourcecache.h webapplication.h webui.h @@ -31,6 +34,8 @@ add_library(qbt_webui STATIC api/transfercontroller.cpp api/serialize/serialize_torrent.cpp freediskspacechecker.cpp + torrentmetadatacache.cpp + torrentsourcecache.cpp webapplication.cpp webui.cpp ) diff --git a/src/webui/api/apicontroller.cpp b/src/webui/api/apicontroller.cpp index ca2c318f42be..2d81b123749b 100644 --- a/src/webui/api/apicontroller.cpp +++ b/src/webui/api/apicontroller.cpp @@ -36,12 +36,14 @@ #include #include "apierror.h" +#include "apistatus.h" void APIResult::clear() { data.clear(); mimeType.clear(); filename.clear(); + status = APIStatus::Ok; } APIController::APIController(IApplication *app, QObject *parent) @@ -105,3 +107,8 @@ void APIController::setResult(const QByteArray &result, const QString &mimeType, m_result.mimeType = mimeType; m_result.filename = filename; } + +void APIController::setStatus(const APIStatus status) +{ + m_result.status = status; +} diff --git a/src/webui/api/apicontroller.h b/src/webui/api/apicontroller.h index edcfc16fc3d7..898ad3fdced9 100644 --- a/src/webui/api/apicontroller.h +++ b/src/webui/api/apicontroller.h @@ -34,6 +34,7 @@ #include #include "base/applicationcomponent.h" +#include "apistatus.h" using DataMap = QHash; using StringMap = QHash; @@ -43,6 +44,7 @@ struct APIResult QVariant data; QString mimeType; QString filename; + APIStatus status = APIStatus::Ok; void clear(); }; @@ -67,6 +69,8 @@ class APIController : public ApplicationComponent void setResult(const QJsonObject &result); void setResult(const QByteArray &result, const QString &mimeType = {}, const QString &filename = {}); + void setStatus(APIStatus status); + private: StringMap m_params; DataMap m_data; diff --git a/src/webui/api/apistatus.h b/src/webui/api/apistatus.h new file mode 100644 index 000000000000..b88a5c724178 --- /dev/null +++ b/src/webui/api/apistatus.h @@ -0,0 +1,35 @@ +/* + * Bittorrent Client using Qt and libtorrent. + * Copyright (C) 2024 Thomas Piccirello + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * In addition, as a special exception, the copyright holders give permission to + * link this program with the OpenSSL project's "OpenSSL" library (or with + * modified versions of it that use the same license as the "OpenSSL" library), + * and distribute the linked executables. You must obey the GNU General Public + * License in all respects for all of the code used other than "OpenSSL". If you + * modify file(s), you may extend this exception to your version of the file(s), + * but you are not obligated to do so. If you do not wish to do so, delete this + * exception statement from your version. + */ + +#pragma once + +enum class APIStatus +{ + Ok, + Async +}; diff --git a/src/webui/api/torrentscontroller.cpp b/src/webui/api/torrentscontroller.cpp index 15407cf00b87..bfab97b9b74a 100644 --- a/src/webui/api/torrentscontroller.cpp +++ b/src/webui/api/torrentscontroller.cpp @@ -32,6 +32,7 @@ #include #include +#include #include #include #include @@ -47,18 +48,20 @@ #include "base/bittorrent/session.h" #include "base/bittorrent/sslparameters.h" #include "base/bittorrent/torrent.h" -#include "base/bittorrent/torrentdescriptor.h" #include "base/bittorrent/trackerentry.h" #include "base/bittorrent/trackerentrystatus.h" #include "base/interfaces/iapplication.h" #include "base/global.h" #include "base/logger.h" +#include "base/net/downloadmanager.h" +#include "base/preferences.h" #include "base/torrentfilter.h" #include "base/utils/datetime.h" #include "base/utils/fs.h" #include "base/utils/sslkey.h" #include "base/utils/string.h" #include "apierror.h" +#include "apistatus.h" #include "serialize/serialize_torrent.h" // Tracker keys @@ -128,6 +131,11 @@ const QString KEY_FILE_IS_SEED = u"is_seed"_s; const QString KEY_FILE_PIECE_RANGE = u"piece_range"_s; const QString KEY_FILE_AVAILABILITY = u"availability"_s; +// Torrent info +const QString KEY_TORRENTINFO_TRACKERS = u"trackers"_s; +const QString KEY_TORRENTINFO_FILES = u"files"_s; +const QString KEY_TORRENTINFO_WEBSEEDS = u"webseeds"_s; + namespace { using Utils::String::parseBool; @@ -269,6 +277,115 @@ namespace return url; } + + QJsonObject serializeInfoHash(const BitTorrent::InfoHash &infoHash) + { + return QJsonObject { + {KEY_TORRENT_INFOHASHV1, infoHash.v1().toString()}, + {KEY_TORRENT_INFOHASHV2, infoHash.v2().toString()}, + {KEY_TORRENT_ID, infoHash.toTorrentID().toString()}, + }; + } + + QJsonObject serializeTorrentInfo(const BitTorrent::TorrentInfo &info) + { + qlonglong torrentSize = 0; + QJsonArray files; + for (int fileIndex = 0; fileIndex < info.filesCount(); ++fileIndex) + { + const qlonglong fileSize = info.fileSize(fileIndex); + torrentSize += fileSize; + files << QJsonObject + { + {KEY_FILE_INDEX, fileIndex}, + // use platform-independent separators + {KEY_FILE_NAME, info.filePath(fileIndex).data()}, + {KEY_FILE_SIZE, fileSize} + }; + } + + return QJsonObject { + {KEY_TORRENT_INFOHASHV1, info.infoHash().v1().toString()}, + {KEY_TORRENT_INFOHASHV2, info.infoHash().v2().toString()}, + {KEY_TORRENT_NAME, info.name()}, + {KEY_TORRENT_ID, info.infoHash().toTorrentID().toString()}, + {KEY_PROP_TOTAL_SIZE, torrentSize}, + {KEY_PROP_PIECES_NUM, info.piecesCount()}, + {KEY_PROP_PIECE_SIZE, info.pieceLength()}, + {KEY_PROP_PRIVATE, info.isPrivate()}, + {KEY_TORRENTINFO_FILES, files}, + }; + } + + QJsonObject serializeTorrentInfo(const BitTorrent::TorrentDescriptor &torrentDescr) + { + QJsonObject info = serializeTorrentInfo(torrentDescr.info().value()); + + QJsonArray trackers; + for (const BitTorrent::TrackerEntry &tracker : torrentDescr.trackers()) + { + trackers << QJsonObject + { + {KEY_TRACKER_URL, tracker.url}, + {KEY_TRACKER_TIER, tracker.tier} + }; + } + info.insert(KEY_TORRENTINFO_TRACKERS, trackers); + + QJsonArray webseeds; + for (const QUrl &webseed : torrentDescr.urlSeeds()) + { + webseeds << QJsonObject + { + {KEY_WEBSEED_URL, webseed.toString()} + }; + } + info.insert(KEY_TORRENTINFO_WEBSEEDS, webseeds); + + info.insert(KEY_PROP_CREATED_BY, torrentDescr.creator()); + info.insert(KEY_PROP_CREATION_DATE, Utils::DateTime::toSecsSinceEpoch(torrentDescr.creationDate())); + info.insert(KEY_PROP_COMMENT, torrentDescr.comment()); + + return info; + } + + QJsonObject serializeTorrentInfo(const BitTorrent::Torrent &torrent) + { + QJsonObject info = serializeTorrentInfo(torrent.info()); + + QJsonArray trackers; + for (const BitTorrent::TrackerEntryStatus &tracker : torrent.trackers()) + { + trackers << QJsonObject + { + {KEY_TRACKER_URL, tracker.url}, + {KEY_TRACKER_TIER, tracker.tier} + }; + } + info.insert(KEY_TORRENTINFO_TRACKERS, trackers); + + QJsonArray webseeds; + for (const QUrl &webseed : torrent.urlSeeds()) + { + webseeds << QJsonObject + { + {KEY_WEBSEED_URL, webseed.toString()} + }; + } + info.insert(KEY_TORRENTINFO_WEBSEEDS, webseeds); + + info.insert(KEY_PROP_CREATED_BY, torrent.creator()); + info.insert(KEY_PROP_CREATION_DATE, Utils::DateTime::toSecsSinceEpoch(torrent.creationDate())); + info.insert(KEY_PROP_COMMENT, torrent.comment()); + + return info; + } +} + +TorrentsController::TorrentsController(IApplication *app, QObject *parent) + : APIController(app, parent) +{ + connect(BitTorrent::Session::instance(), &BitTorrent::Session::metadataDownloaded, this, &TorrentsController::onMetadataDownloaded); } void TorrentsController::countAction() @@ -787,7 +904,7 @@ void TorrentsController::pieceStatesAction() void TorrentsController::addAction() { - const QString urls = params()[u"urls"_s]; + const QStringList urls = params()[u"urls"_s].split(u'\n', Qt::SkipEmptyParts); const bool skipChecking = parseBool(params()[u"skip_checking"_s]).value_or(false); const bool seqDownload = parseBool(params()[u"sequentialDownload"_s]).value_or(false); @@ -818,7 +935,31 @@ void TorrentsController::addAction() ? Utils::String::toEnum(contentLayoutParam, BitTorrent::TorrentContentLayout::Original) : std::optional {}); - const BitTorrent::AddTorrentParams addTorrentParams + const DataMap &torrents = data(); + + QList filePriorities; + const QStringList filePrioritiesParam = params()[u"filePriorities"_s].split(u',', Qt::SkipEmptyParts); + if ((urls.size() > 1) && !filePrioritiesParam.isEmpty()) + throw APIError(APIErrorType::BadParams, tr("You cannot specify filePriorities when adding multiple torrents")); + if (!torrents.isEmpty() && !filePrioritiesParam.isEmpty()) + throw APIError(APIErrorType::BadParams, tr("You cannot specify filePriorities when uploading torrent files")); + if (!filePrioritiesParam.isEmpty()) + { + filePriorities.reserve(filePrioritiesParam.size()); + for (const QString &priorityStr : filePrioritiesParam) + { + bool ok = false; + const auto priority = static_cast(priorityStr.toInt(&ok)); + if (!ok) + throw APIError(APIErrorType::BadParams, tr("Priority must be an integer")); + if (!BitTorrent::isValidDownloadPriority(priority)) + throw APIError(APIErrorType::BadParams, tr("Priority is not valid")); + + filePriorities << priority; + } + } + + BitTorrent::AddTorrentParams addTorrentParams { // TODO: Check if destination actually exists .name = torrentName, @@ -852,17 +993,45 @@ void TorrentsController::addAction() } }; + bool partialSuccess = false; - for (QString url : asConst(urls.split(u'\n'))) + for (QString url : urls) { url = url.trimmed(); - if (!url.isEmpty()) + if (url.isEmpty()) + continue; + + BitTorrent::InfoHash infoHash; + if (const auto sourceTorrentDescr = BitTorrent::TorrentDescriptor::parse(url)) + infoHash = sourceTorrentDescr.value().infoHash(); + else if (const auto cachedInfoHash = m_torrentSourceCache.get(url)) + infoHash = cachedInfoHash.value(); + + if (const auto torrentDescr = m_torrentMetadataCache.get(infoHash)) { + if (!filePriorities.isEmpty()) + { + const BitTorrent::TorrentInfo &info = torrentDescr.value().info().value(); + if (filePriorities.size() != info.filesCount()) + throw APIError(APIErrorType::BadParams, tr("Length of filePriorities must equal number of files in torrent")); + + addTorrentParams.filePriorities = filePriorities; + } + + partialSuccess |= BitTorrent::Session::instance()->addTorrent(torrentDescr.value(), addTorrentParams); + } + else + { + if (!filePriorities.isEmpty()) + throw APIError(APIErrorType::BadParams, tr("filePriorities may only be specified when metadata has already been fetched")); + partialSuccess |= app()->addTorrentManager()->addTorrent(url, addTorrentParams); } + m_torrentSourceCache.remove(url); + m_torrentMetadataCache.remove(infoHash); } - const DataMap &torrents = data(); + // process uploaded .torrent files for (auto it = torrents.constBegin(); it != torrents.constEnd(); ++it) { if (const auto loadResult = BitTorrent::TorrentDescriptor::load(it.value())) @@ -1622,3 +1791,204 @@ void TorrentsController::setSSLParametersAction() torrent->setSSLParameters(sslParams); } + +void TorrentsController::fetchMetadataAction() +{ + requireParams({u"source"_s}); + + const QString sourceParam = params()[u"source"_s].trimmed(); + // must provide some value to parse + if (sourceParam.isEmpty()) + throw APIError(APIErrorType::BadParams, tr("Must specify URI or hash")); + + const QString source = QUrl::fromPercentEncoding(sourceParam.toLatin1()); + const auto sourceTorrentDescr = BitTorrent::TorrentDescriptor::parse(source); + + BitTorrent::InfoHash infoHash; + if (sourceTorrentDescr) + infoHash = sourceTorrentDescr.value().infoHash(); + else if (const auto cachedInfoHash = m_torrentSourceCache.get(source)) + infoHash = cachedInfoHash.value(); + + if (infoHash != BitTorrent::InfoHash {}) + { + // check metadata cache + if (const auto torrentDescr = m_torrentMetadataCache.get(infoHash)) + { + setResult(serializeTorrentInfo(torrentDescr.value())); + } + // check transfer list + else if (const BitTorrent::Torrent *const torrent = BitTorrent::Session::instance()->findTorrent(infoHash); torrent && torrent->info().isValid()) + { + setResult(serializeTorrentInfo(*torrent)); + } + // check request cache + else if (BitTorrent::Session::instance()->isKnownTorrent(infoHash)) + { + setResult(serializeInfoHash(infoHash)); + setStatus(APIStatus::Async); + } + // request torrent's metadata + else + { + qDebug("Fetching metadata for %s", qUtf8Printable(infoHash.toTorrentID().toString())); + + if (!BitTorrent::Session::instance()->downloadMetadata(sourceTorrentDescr.value())) [[unlikely]] + { + qDebug("Unable to fetch metadata for %s", qUtf8Printable(infoHash.toTorrentID().toString())); + throw APIError(APIErrorType::BadParams, tr("Unable to download metadata for '%1'").arg(infoHash.toTorrentID().toString())); + } + + m_torrentMetadataCache.add(infoHash, sourceTorrentDescr.value()); + + setResult(serializeInfoHash(infoHash)); + setStatus(APIStatus::Async); + } + } + // http(s) url + else if (Net::DownloadManager::hasSupportedScheme(source)) + { + if (!m_torrentSourceCache.contains(source)) + { + qDebug("Fetching torrent %s", qUtf8Printable(source)); + const auto *pref = Preferences::instance(); + + Net::DownloadManager::instance()->download(Net::DownloadRequest(source).limit(pref->getTorrentFileSizeLimit()) + , pref->useProxyForGeneralPurposes(), this, &TorrentsController::onDownloadFinished); + + m_torrentSourceCache.add(source); + } + + setResult(QJsonObject {}); + setStatus(APIStatus::Async); + } + else + { + throw APIError(APIErrorType::BadParams, tr("Unable to parse '%1'").arg(source)); + } +} + +void TorrentsController::parseMetadataAction() +{ + const DataMap &uploadedTorrents = data(); + // must provide some value to parse + if (uploadedTorrents.isEmpty()) + throw APIError(APIErrorType::BadParams, tr("Must specify torrent file(s)")); + + QJsonObject result; + for (auto it = uploadedTorrents.constBegin(); it != uploadedTorrents.constEnd(); ++it) + { + if (const auto loadResult = BitTorrent::TorrentDescriptor::load(it.value())) + { + const BitTorrent::TorrentDescriptor &torrentDescr = loadResult.value(); + m_torrentMetadataCache.add(torrentDescr.infoHash(), torrentDescr); + + const QString &fileName = it.key(); + result.insert(fileName, serializeTorrentInfo(torrentDescr)); + } + else + { + throw APIError(APIErrorType::BadData, tr("'%1' is not a valid torrent file.").arg(it.key())); + } + } + + setResult(result); +} + +void TorrentsController::saveMetadataAction() +{ + requireParams({u"source"_s}); + + const QString sourceParam = params()[u"source"_s].trimmed(); + if (sourceParam.isEmpty()) + throw APIError(APIErrorType::BadParams, tr("Must specify URI or hash")); + + const QString source = QUrl::fromPercentEncoding(sourceParam.toLatin1()); + + BitTorrent::InfoHash infoHash; + if (const auto sourceTorrentDescr = BitTorrent::TorrentDescriptor::parse(source)) + infoHash = sourceTorrentDescr.value().infoHash(); + else if (const auto cachedInfoHash = m_torrentSourceCache.get(source)) + infoHash = cachedInfoHash.value(); + + if (infoHash != BitTorrent::InfoHash {}) + { + if (const auto torrentDescr = m_torrentMetadataCache.get(infoHash)) + { + const nonstd::expected result = torrentDescr.value().saveToBuffer(); + if (!result) + throw APIError(APIErrorType::Conflict, tr("Unable to export torrent metadata. Error: %1").arg(result.error())); + + setResult(result.value(), u"application/x-bittorrent"_s, (infoHash.toTorrentID().toString() + u".torrent")); + } + else + { + throw APIError(APIErrorType::Conflict, tr("Metadata is not yet available")); + } + } + else + { + throw APIError(APIErrorType::NotFound); + } +} + +void TorrentsController::onDownloadFinished(const Net::DownloadResult &result) +{ + const QString source = result.url; + + switch (result.status) + { + case Net::DownloadStatus::Success: + qDebug("Received torrent from %s", qUtf8Printable(source)); + + // 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.update(source, infoHash); + m_torrentMetadataCache.add(infoHash, torrentDescr); + } + else + { + qDebug("Unable to parse torrent from %s", qUtf8Printable(source)); + m_torrentSourceCache.remove(source); + } + 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(); + m_torrentSourceCache.update(source, infoHash); + + if (!m_torrentMetadataCache.contains(infoHash) && !BitTorrent::Session::instance()->isKnownTorrent(infoHash)) + { + qDebug("Fetching metadata for %s", qUtf8Printable(infoHash.toTorrentID().toString())); + if (BitTorrent::Session::instance()->downloadMetadata(torrentDescr)) + m_torrentMetadataCache.add(infoHash, torrentDescr); + else [[unlikely]] + qDebug("Unable to fetch metadata for %s", qUtf8Printable(infoHash.toTorrentID().toString())); + } + } + else { + qDebug("Unable to parse magnet URI %s", qUtf8Printable(result.magnetURI)); + m_torrentSourceCache.remove(source); + } + break; + default: + // allow metadata to be re-downloaded on next request + m_torrentSourceCache.remove(source); + break; + } +} + +void TorrentsController::onMetadataDownloaded(const BitTorrent::TorrentInfo &info) +{ + Q_ASSERT(info.isValid()); + if (!info.isValid()) [[unlikely]] + return; + + const BitTorrent::InfoHash infoHash = info.infoHash(); + m_torrentMetadataCache.update(infoHash, info); +} diff --git a/src/webui/api/torrentscontroller.h b/src/webui/api/torrentscontroller.h index 82e6e01a6b4d..48adda94dda1 100644 --- a/src/webui/api/torrentscontroller.h +++ b/src/webui/api/torrentscontroller.h @@ -28,15 +28,27 @@ #pragma once +#include "webui/torrentmetadatacache.h" +#include "webui/torrentsourcecache.h" #include "apicontroller.h" +namespace BitTorrent +{ + class TorrentInfo; +} + +namespace Net +{ + class DownloadResult; +} + class TorrentsController : public APIController { Q_OBJECT Q_DISABLE_COPY_MOVE(TorrentsController) public: - using APIController::APIController; + explicit TorrentsController(IApplication *app, QObject *parent = nullptr); private slots: void countAction(); @@ -94,4 +106,14 @@ private slots: void exportAction(); void SSLParametersAction(); void setSSLParametersAction(); + void fetchMetadataAction(); + void parseMetadataAction(); + void saveMetadataAction(); + +private: + void onDownloadFinished(const Net::DownloadResult &result); + void onMetadataDownloaded(const BitTorrent::TorrentInfo &info); + + TorrentSourceCache m_torrentSourceCache; + TorrentMetadataCache m_torrentMetadataCache; }; diff --git a/src/webui/torrentmetadatacache.cpp b/src/webui/torrentmetadatacache.cpp new file mode 100644 index 000000000000..3ed78d2d4a6a --- /dev/null +++ b/src/webui/torrentmetadatacache.cpp @@ -0,0 +1,66 @@ +/* + * Bittorrent Client using Qt and libtorrent. + * Copyright (C) 2024 Thomas Piccirello + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * In addition, as a special exception, the copyright holders give permission to + * link this program with the OpenSSL project's "OpenSSL" library (or with + * modified versions of it that use the same license as the "OpenSSL" library), + * and distribute the linked executables. You must obey the GNU General Public + * License in all respects for all of the code used other than "OpenSSL". If you + * modify file(s), you may extend this exception to your version of the file(s), + * but you are not obligated to do so. If you do not wish to do so, delete this + * exception statement from your version. + */ + +#include "torrentmetadatacache.h" + +std::optional TorrentMetadataCache::get(const BitTorrent::InfoHash &infoHash) +{ + const BitTorrent::TorrentDescriptor torrentDescr = m_torrentMetadata[infoHash]; + if (isValid(torrentDescr)) + return torrentDescr; + + return std::nullopt; +} + +bool TorrentMetadataCache::contains(const BitTorrent::InfoHash &infoHash) const +{ + // we don't need to check for existence in the map, the default constructed info hash won't exist in it + return isValid(m_torrentMetadata[infoHash]); +} + +void TorrentMetadataCache::add(const BitTorrent::InfoHash &infoHash, const BitTorrent::TorrentDescriptor &torrentDescr) +{ + m_torrentMetadata.insert(infoHash, torrentDescr); +} + +void TorrentMetadataCache::update(const BitTorrent::InfoHash &infoHash, const BitTorrent::TorrentInfo &info) +{ + if (auto torrentDescr = m_torrentMetadata.find(infoHash); torrentDescr != m_torrentMetadata.end()) + torrentDescr.value().setTorrentInfo(info); +} + +void TorrentMetadataCache::remove(const BitTorrent::InfoHash &infoHash) +{ + m_torrentMetadata.remove(infoHash); +} + +bool TorrentMetadataCache::isValid(const BitTorrent::TorrentDescriptor &torrentDescr) const +{ + const auto &info = torrentDescr.info(); + return info.has_value(); +} diff --git a/src/webui/torrentmetadatacache.h b/src/webui/torrentmetadatacache.h new file mode 100644 index 000000000000..49ca147c807f --- /dev/null +++ b/src/webui/torrentmetadatacache.h @@ -0,0 +1,58 @@ +/* + * Bittorrent Client using Qt and libtorrent. + * Copyright (C) 2024 Thomas Piccirello + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * In addition, as a special exception, the copyright holders give permission to + * link this program with the OpenSSL project's "OpenSSL" library (or with + * modified versions of it that use the same license as the "OpenSSL" library), + * and distribute the linked executables. You must obey the GNU General Public + * License in all respects for all of the code used other than "OpenSSL". If you + * modify file(s), you may extend this exception to your version of the file(s), + * but you are not obligated to do so. If you do not wish to do so, delete this + * exception statement from your version. + */ + +#pragma once + +#include + +#include +#include +#include + +#include "base/bittorrent/infohash.h" +#include "base/bittorrent/torrentdescriptor.h" + +class TorrentMetadataCache : public QObject +{ + Q_OBJECT + Q_DISABLE_COPY_MOVE(TorrentMetadataCache) + +public: + using QObject::QObject; + + std::optional get(const BitTorrent::InfoHash &infoHash); + bool contains(const BitTorrent::InfoHash &infoHash) const; + void add(const BitTorrent::InfoHash &infoHash, const BitTorrent::TorrentDescriptor &torrentDescr); + void update(const BitTorrent::InfoHash &infoHash, const BitTorrent::TorrentInfo &info); + void remove(const BitTorrent::InfoHash &infoHash); + +private: + bool isValid(const BitTorrent::TorrentDescriptor &torrentDescr) const; + + QHash m_torrentMetadata; +}; diff --git a/src/webui/torrentsourcecache.cpp b/src/webui/torrentsourcecache.cpp new file mode 100644 index 000000000000..bb18ff82650f --- /dev/null +++ b/src/webui/torrentsourcecache.cpp @@ -0,0 +1,62 @@ +/* + * Bittorrent Client using Qt and libtorrent. + * Copyright (C) 2024 Thomas Piccirello + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * In addition, as a special exception, the copyright holders give permission to + * link this program with the OpenSSL project's "OpenSSL" library (or with + * modified versions of it that use the same license as the "OpenSSL" library), + * and distribute the linked executables. You must obey the GNU General Public + * License in all respects for all of the code used other than "OpenSSL". If you + * modify file(s), you may extend this exception to your version of the file(s), + * but you are not obligated to do so. If you do not wish to do so, delete this + * exception statement from your version. + */ + +#include "torrentsourcecache.h" + +std::optional TorrentSourceCache::get(const QString &source) +{ + if (const auto iter = m_torrentSource.constFind(source); iter != m_torrentSource.constEnd()) + return iter.value(); + else + return std::nullopt; +} + +bool TorrentSourceCache::contains(const QString &source) const +{ + return m_torrentSource.contains(source) || m_torrentSourcesWithoutInfoHash.contains(source); +} + +void TorrentSourceCache::add(const QString &source) +{ + m_torrentSourcesWithoutInfoHash.insert(source); +} + +void TorrentSourceCache::update(const QString &source, const BitTorrent::InfoHash &infoHash) +{ + if (m_torrentSourcesWithoutInfoHash.contains(source)) + { + m_torrentSource.insert(source, infoHash); + m_torrentSourcesWithoutInfoHash.remove(source); + } +} + +void TorrentSourceCache::remove(const QString &source) +{ + m_torrentSource.remove(source); + m_torrentSourcesWithoutInfoHash.remove(source); +} diff --git a/src/webui/torrentsourcecache.h b/src/webui/torrentsourcecache.h new file mode 100644 index 000000000000..95e6e5df9515 --- /dev/null +++ b/src/webui/torrentsourcecache.h @@ -0,0 +1,56 @@ +/* + * Bittorrent Client using Qt and libtorrent. + * Copyright (C) 2024 Thomas Piccirello + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * In addition, as a special exception, the copyright holders give permission to + * link this program with the OpenSSL project's "OpenSSL" library (or with + * modified versions of it that use the same license as the "OpenSSL" library), + * and distribute the linked executables. You must obey the GNU General Public + * License in all respects for all of the code used other than "OpenSSL". If you + * modify file(s), you may extend this exception to your version of the file(s), + * but you are not obligated to do so. If you do not wish to do so, delete this + * exception statement from your version. + */ + +#pragma once + +#include + +#include +#include +#include + +#include "base/bittorrent/infohash.h" + +class TorrentSourceCache : public QObject +{ + Q_OBJECT + Q_DISABLE_COPY_MOVE(TorrentSourceCache) + +public: + using QObject::QObject; + + std::optional get(const QString &source); + bool contains(const QString &source) const; + void add(const QString &source); + void update(const QString &source, const BitTorrent::InfoHash &infoHash); + void remove(const QString &source); + +private: + QHash m_torrentSource; + QSet m_torrentSourcesWithoutInfoHash; +}; diff --git a/src/webui/webapplication.cpp b/src/webui/webapplication.cpp index 5125c09acf0b..b0709911a915 100644 --- a/src/webui/webapplication.cpp +++ b/src/webui/webapplication.cpp @@ -359,6 +359,17 @@ void WebApplication::doProcessRequest() try { const APIResult result = controller->run(action, m_params, data); + switch (result.status) + { + case APIStatus::Async: + status(202); + break; + case APIStatus::Ok: + default: + status(200); + break; + } + switch (result.data.userType()) { case QMetaType::QJsonDocument: @@ -466,7 +477,7 @@ void WebApplication::configure() const QString contentSecurityPolicy = (m_isAltUIUsed ? QString() - : u"default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; script-src 'self' 'unsafe-inline'; object-src 'none'; form-action 'self';"_s) + : u"default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; script-src 'self' 'unsafe-inline'; object-src 'none'; form-action 'self'; frame-src 'self' blob:;"_s) + (isClickjackingProtectionEnabled ? u" frame-ancestors 'self';"_s : QString()) + (m_isHttpsEnabled ? u" upgrade-insecure-requests;"_s : QString()); if (!contentSecurityPolicy.isEmpty()) diff --git a/src/webui/www/private/addtorrent.html b/src/webui/www/private/addtorrent.html new file mode 100644 index 000000000000..9c6853299160 --- /dev/null +++ b/src/webui/www/private/addtorrent.html @@ -0,0 +1,361 @@ + + + + + + QBT_TR(Add torrent)QBT_TR[CONTEXT=HttpServer] + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+
+ QBT_TR(Save at)QBT_TR[CONTEXT=AddNewTorrentDialog] + + + + + + + + + + + +
+ + + +
+ + + +
+
+ + + + +
+ + +
+
+
+
+ QBT_TR(Torrent settings)QBT_TR[CONTEXT=AddNewTorrentDialog] + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + +
+ + +
+ + +
+
+ + +
+ + +
+
+ + + + +
+ + + +
+ + + +
+ + + +
+ + + +
+ + + +
+ + + +
+ + + + +
+ + + + +
+
+ +
+ QBT_TR(Torrent information)QBT_TR[CONTEXT=AddNewTorrentDialog] + + + + + + + + + + + + + + + + + + + + + + + +
+ + + QBT_TR(Not available)QBT_TR[CONTEXT=AddNewTorrentDialog] +
+ + + QBT_TR(Not available)QBT_TR[CONTEXT=AddNewTorrentDialog] +
+ + + QBT_TR(Not available)QBT_TR[CONTEXT=AddNewTorrentDialog] +
+ + + QBT_TR(Not available)QBT_TR[CONTEXT=AddNewTorrentDialog] +
+ + + +
+
+
+
+
+ QBT_TR(Files)QBT_TR[CONTEXT=AddNewTorrentDialog] +
+ +
+
+
+ + + + +
+
+
+ + + + + +
+
+
+
+
+
+
+
+ + Retrieving metadata + + +   +
+ +
+
+
+ + + + + diff --git a/src/webui/www/private/css/Layout.css b/src/webui/www/private/css/Layout.css index fb940cbf1408..457a33e0e928 100644 --- a/src/webui/www/private/css/Layout.css +++ b/src/webui/www/private/css/Layout.css @@ -155,7 +155,8 @@ Required by: width: 5px; } -#desktopNavbar li ul li a { +#desktopNavbar li ul li a, +#desktopNavbar li ul li div.anchor { color: var(--color-text-default); font-weight: normal; min-width: 155px; @@ -163,7 +164,8 @@ Required by: position: relative; } -#desktopNavbar li ul li a:hover { +#desktopNavbar li ul li a:hover, +#desktopNavbar li ul li div.anchor:hover { background-color: var(--color-background-hover); color: var(--color-text-white); } diff --git a/src/webui/www/private/css/Window.css b/src/webui/www/private/css/Window.css index 2546a9dcb733..eb40a9cfbc07 100644 --- a/src/webui/www/private/css/Window.css +++ b/src/webui/www/private/css/Window.css @@ -184,6 +184,16 @@ div.mochaToolbarWrapper.bottom { width: 16px; } +.mochaErrorIcon { + background: url("../images/error.svg") no-repeat; + background-size: 16px; + bottom: 7px; + height: 16px; + left: 6px; + position: absolute; + width: 16px; +} + .mochaIframe { width: 100%; } diff --git a/src/webui/www/private/css/style.css b/src/webui/www/private/css/style.css index 6872d6766fd8..5080ad67aab3 100644 --- a/src/webui/www/private/css/style.css +++ b/src/webui/www/private/css/style.css @@ -673,6 +673,17 @@ td.generalLabel { width: 1px; } +td.fullWidth { + box-sizing: border-box; + max-width: none; + width: 100%; + word-break: break-all; +} + +td.noWrap { + white-space: nowrap; +} + #tristate_cb { margin-bottom: 0; margin-top: 0; @@ -837,7 +848,8 @@ td.statusBarSeparator { cursor: pointer; } -#torrentFilesTableDiv .dynamicTable tr.nonAlt:hover { +#torrentFilesTableDiv .dynamicTable tr.nonAlt:hover, +#addTorrentFilesTableDiv .dynamicTable tr.nonAlt:hover { background-color: var(--color-background-hover); color: var(--color-text-white); } diff --git a/src/webui/www/private/download.html b/src/webui/www/private/download.html index d59d0e5abc77..5d6d0cb582f6 100644 --- a/src/webui/www/private/download.html +++ b/src/webui/www/private/download.html @@ -3,164 +3,31 @@ - QBT_TR(Add Torrent Links)QBT_TR[CONTEXT=downloadFromURL] + QBT_TR(Add Torrent Links)QBT_TR[CONTEXT=DownloadFromURLDialog] - - -
-
-
-

- -

QBT_TR(One link per line (HTTP links, Magnet links and info-hashes are supported))QBT_TR[CONTEXT=AddNewTorrentDialog]

-
- QBT_TR(Torrent options)QBT_TR[CONTEXT=AddNewTorrentDialog] - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - - -
- - - -
- - - -
- - -
- - -
-
- - - - -
- - - -
- - - -
- - - -
- - - -
- - - -
- - - -
- - - - -
- - - - -
-
- -
-
+
+
+

QBT_TR(Add torrent links)QBT_TR[CONTEXT=DownloadFromURLDialog]

+ +

QBT_TR(One link per line (HTTP links, Magnet links and info-hashes are supported))QBT_TR[CONTEXT=DownloadFromURLDialog]

+
+
- -
+
diff --git a/src/webui/www/private/index.html b/src/webui/www/private/index.html index 59cac3b67f9f..ce8fab8c4e89 100644 --- a/src/webui/www/private/index.html +++ b/src/webui/www/private/index.html @@ -37,6 +37,7 @@ + @@ -56,8 +57,16 @@

qBittorrent Web User Interface QBT_TR(File)QBT_TR[CONTEXT=MainWindow] @@ -110,7 +119,12 @@

qBittorrent Web User Interface    QBT_TR(Add Torrent Link...)QBT_TR[CONTEXT=MainWindow] - QBT_TR(Add Torrent File...)QBT_TR[CONTEXT=MainWindow] +
+ + +
QBT_TR(Remove)QBT_TR[CONTEXT=TransferListWidget] QBT_TR(Start)QBT_TR[CONTEXT=TransferListWidget] QBT_TR(Stop)QBT_TR[CONTEXT=TransferListWidget] diff --git a/src/webui/www/private/scripts/addtorrent.js b/src/webui/www/private/scripts/addtorrent.js new file mode 100644 index 000000000000..5358cb26efa7 --- /dev/null +++ b/src/webui/www/private/scripts/addtorrent.js @@ -0,0 +1,285 @@ +/* + * MIT License + * Copyright (c) 2008 Ishan Arora + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +"use strict"; + +window.qBittorrent ??= {}; +window.qBittorrent.AddTorrent ??= (() => { + const exports = () => { + return { + changeCategorySelect: changeCategorySelect, + changeTMM: changeTMM, + loadMetadata: loadMetadata, + metadataCompleted: metadataCompleted, + populateMetadata: populateMetadata, + setWindowId: setWindowId + }; + }; + + let categories = {}; + let tags = []; + let defaultSavePath = ""; + let windowId = ""; + let source; + + const getCategories = () => { + new Request.JSON({ + url: "api/v2/torrents/categories", + method: "get", + noCache: true, + onSuccess: (data) => { + if (data) { + categories = data; + for (const i in data) { + if (!Object.hasOwn(data, i)) + continue; + + const category = data[i]; + const option = new Element("option"); + option.value = category.name; + option.textContent = category.name; + $("categorySelect").appendChild(option); + } + } + } + }).send(); + }; + + const getTags = () => { + new Request.JSON({ + url: "api/v2/torrents/tags", + method: "get", + noCache: true, + onSuccess: (data) => { + if (data) { + tags = data; + + const tagsSelect = document.getElementById("tagsSelect"); + for (const tag of tags) { + const option = document.createElement("option"); + option.value = tag; + option.textContent = tag; + tagsSelect.appendChild(option); + } + + new vanillaSelectBox("#tagsSelect", { + maxHeight: 200, + search: false, + disableSelectAll: true, + translations: { + all: tags.length === 0 ? "" : "QBT_TR(All)QBT_TR[CONTEXT=AddNewTorrentDialog]", + }, + keepInlineStyles: false + }); + } + } + }).send(); + }; + + const getPreferences = () => { + const pref = window.parent.qBittorrent.Cache.preferences.get(); + + defaultSavePath = pref.save_path; + $("savepath").value = defaultSavePath; + $("startTorrent").checked = !pref.add_stopped_enabled; + $("addToTopOfQueue").checked = pref.add_to_top_of_queue; + + if (pref.auto_tmm_enabled) { + $("autoTMM").selectedIndex = 1; + $("savepath").disabled = true; + } + else { + $("autoTMM").selectedIndex = 0; + } + + if (pref.torrent_stop_condition === "MetadataReceived") + $("stopCondition").selectedIndex = 1; + else if (pref.torrent_stop_condition === "FilesChecked") + $("stopCondition").selectedIndex = 2; + else + $("stopCondition").selectedIndex = 0; + + if (pref.torrent_content_layout === "Subfolder") + $("contentLayout").selectedIndex = 1; + else if (pref.torrent_content_layout === "NoSubfolder") + $("contentLayout").selectedIndex = 2; + else + $("contentLayout").selectedIndex = 0; + }; + + const changeCategorySelect = (item) => { + if (item.value === "\\other") { + item.nextElementSibling.hidden = false; + item.nextElementSibling.value = ""; + item.nextElementSibling.select(); + + if ($("autoTMM").selectedIndex === 1) + $("savepath").value = defaultSavePath; + } + else { + item.nextElementSibling.hidden = true; + const text = item.options[item.selectedIndex].textContent; + item.nextElementSibling.value = text; + + if ($("autoTMM").selectedIndex === 1) { + const categoryName = item.value; + const category = categories[categoryName]; + let savePath = defaultSavePath; + if (category !== undefined) + savePath = (category["savePath"] !== "") ? category["savePath"] : `${defaultSavePath}/${categoryName}`; + $("savepath").value = savePath; + } + } + }; + + const changeTagsSelect = (element) => { + const tags = [...element.options].filter(opt => opt.selected).map(opt => opt.value); + document.getElementById("tags").value = tags.join(","); + }; + + const changeTMM = (item) => { + const savepath = document.getElementById("savepath"); + const useDownloadPath = document.getElementById("useDownloadPath"); + if (item.selectedIndex === 1) { + savepath.disabled = true; + + const categorySelect = $("categorySelect"); + const categoryName = categorySelect.options[categorySelect.selectedIndex].value; + const category = categories[categoryName]; + savepath.value = (category === undefined) ? "" : category["savePath"]; + + useDownloadPath.disabled = true; + useDownloadPath.checked = false; + changeUseDownloadPath(useDownloadPath); + } + else { + savepath.disabled = false; + savepath.value = defaultSavePath; + + useDownloadPath.disabled = false; + } + + changeUseDownloadPath(useDownloadPath); + }; + + const changeUseDownloadPath = (elem) => { + const downloadPath = document.getElementById("downloadPath"); + if (elem.checked) { + downloadPath.disabled = false; + downloadPath.value = defaultSavePath; + } + else { + downloadPath.disabled = true; + downloadPath.value = ""; + } + }; + + let loadMetadataTimer; + const loadMetadata = (sourceUrl = undefined) => { + if (sourceUrl) + source = sourceUrl; + + const request = new Request.JSON({ + url: "api/v2/torrents/fetchMetadata", + method: "post", + noCache: true, + data: { + source: source, + }, + onFailure: () => { + metadataFailed(); + }, + onSuccess: (response) => { + populateMetadata(response); + + if (request.status === 200) + metadataCompleted(); + else + loadMetadataTimer = loadMetadata.delay(1000); + } + }).send(); + }; + + const metadataCompleted = (showDownloadButton = true) => { + clearTimeout(loadMetadataTimer); + + document.getElementById("metadataStatus").destroy(); + document.getElementById("loading_spinner").style.display = "none"; + + if (showDownloadButton) + document.getElementById("saveTorrent").classList.remove("invisible"); + }; + + const metadataFailed = () => { + clearTimeout(loadMetadataTimer); + + document.getElementById("metadataStatus").textContent = "Metadata retrieval failed"; + document.getElementById("metadataStatus").classList.add("red"); + document.getElementById("loading_spinner").style.display = "none"; + document.getElementById("error_icon").classList.remove("invisible"); + }; + + const populateMetadata = (metadata) => { + // update window title + if (metadata.name) + window.parent.$(`${windowId}_title`).textContent = metadata.name; + + document.getElementById("infoHashV1").textContent = metadata.infohash_v1 || "N/A"; + document.getElementById("infoHashV2").textContent = metadata.infohash_v2 || "N/A"; + + if (metadata.total_size) + document.getElementById("size").textContent = window.qBittorrent.Misc.friendlyUnit(metadata.total_size, false); + if (metadata.creation_date && (metadata.creation_date > 1)) + document.getElementById("createdDate").textContent = new Date(metadata.creation_date * 1000).toLocaleString();; + if (metadata.comment) + document.getElementById("comment").textContent = metadata.comment; + + if (metadata.files) { + const files = metadata.files.map(file => ({ + index: file.index, + name: file.name, + size: file.size, + priority: window.qBittorrent.FileTree.FilePriority.Normal, + })); + window.qBittorrent.TorrentContent.updateData(files); + } + }; + + const setWindowId = (id) => { + windowId = id; + }; + + window.addEventListener("load", () => { + getPreferences(); + getCategories(); + getTags(); + }); + + window.addEventListener("DOMContentLoaded", () => { + document.getElementById("useDownloadPath").addEventListener("change", (e) => changeUseDownloadPath(e.target)); + document.getElementById("tagsSelect").addEventListener("change", (e) => changeTagsSelect(e.target)); + }); + + return exports(); +})(); +Object.freeze(window.qBittorrent.AddTorrent); diff --git a/src/webui/www/private/scripts/client.js b/src/webui/www/private/scripts/client.js index ed6eb51fdbd4..a873aafb20e5 100644 --- a/src/webui/www/private/scripts/client.js +++ b/src/webui/www/private/scripts/client.js @@ -40,7 +40,9 @@ window.qBittorrent.Client ??= (() => { showLogViewer: showLogViewer, isShowSearchEngine: isShowSearchEngine, isShowRssReader: isShowRssReader, - isShowLogViewer: isShowLogViewer + isShowLogViewer: isShowLogViewer, + createDownloadWindow: createDownloadWindow, + uploadTorrentFiles: uploadTorrentFiles }; }; @@ -100,6 +102,76 @@ window.qBittorrent.Client ??= (() => { return showingLogViewer; }; + const createDownloadWindow = (title, source, metadata = undefined) => { + const staticId = "uploadPage"; + const id = `${staticId}-${encodeURIComponent(source)}`; + new MochaUI.Window({ + id: id, + icon: "images/qbittorrent-tray.svg", + title: title, + loadMethod: "iframe", + contentURL: new URI("addtorrent.html").setData("source", source).setData("fetch", metadata === undefined).setData("windowId", id).toString(), + addClass: "windowFrame", // fixes iframe scrolling on iOS Safari + scrollbars: true, + maximizable: false, + paddingVertical: 0, + paddingHorizontal: 0, + width: loadWindowWidth(staticId, 980), + height: loadWindowHeight(staticId, 730), + onResize: window.qBittorrent.Misc.createDebounceHandler(500, (e) => { + saveWindowSize(staticId, $(id).getSize()); + }), + onContentLoaded: () => { + if (metadata !== undefined) + document.getElementById(`${id}_iframe`).contentWindow.postMessage(metadata, window.origin); + } + }); + + }; + + const uploadTorrentFiles = (files) => { + const fileNames = []; + const formData = new FormData(); + for (const file of files) { + fileNames.push(file.name); + formData.append("file", file); + } + + const xhr = new XMLHttpRequest(); + xhr.open("POST", "api/v2/torrents/parseMetadata"); + xhr.addEventListener("readystatechange", () => { + if (xhr.readyState === 4) { // DONE state + if ((xhr.status >= 200) && (xhr.status < 300)) { + let response; + try { + response = JSON.parse(xhr.responseText); + } + catch (error) { + alert("QBT_TR(Unable to parse response)QBT_TR[CONTEXT=HttpServer]"); + return; + } + + for (const fileName of fileNames) { + let title = fileName; + const metadata = response[fileName]; + if (metadata !== undefined) + title = metadata.name; + + createDownloadWindow(title, metadata.hash, metadata); + } + } + else if (xhr.responseText) { + alert(xhr.responseText); + } + } + }); + xhr.addEventListener("error", () => { + if (xhr.responseText) + alert(xhr.responseText); + }); + xhr.send(formData); + }; + return exports(); })(); Object.freeze(window.qBittorrent.Client); @@ -1609,32 +1681,11 @@ window.addEventListener("DOMContentLoaded", () => { // can't handle folder due to cannot put the filelist (from dropped folder) // to `files` field for (const item of ev.dataTransfer.items) { - if (item.webkitGetAsEntry().isDirectory) + if ((item.kind !== "file") || (item.webkitGetAsEntry().isDirectory)) return; } - const id = "uploadPage"; - new MochaUI.Window({ - id: id, - icon: "images/qbittorrent-tray.svg", - title: "QBT_TR(Upload local torrent)QBT_TR[CONTEXT=HttpServer]", - loadMethod: "iframe", - contentURL: new URI("upload.html").toString(), - addClass: "windowFrame", // fixes iframe scrolling on iOS Safari - scrollbars: true, - maximizable: false, - paddingVertical: 0, - paddingHorizontal: 0, - width: loadWindowWidth(id, 500), - height: loadWindowHeight(id, 460), - onResize: window.qBittorrent.Misc.createDebounceHandler(500, (e) => { - saveWindowSize(id); - }), - onContentLoaded: () => { - const fileInput = $(`${id}_iframe`).contentDocument.getElementById("fileselect"); - fileInput.files = droppedFiles; - } - }); + window.qBittorrent.Client.uploadTorrentFiles(droppedFiles); } const droppedText = ev.dataTransfer.getData("text"); @@ -1652,29 +1703,8 @@ window.addEventListener("DOMContentLoaded", () => { || ((str.length === 32) && !(/[^2-7A-Z]/i.test(str))); // v1 Base32 encoded SHA-1 info-hash }); - if (urls.length <= 0) - return; - - const id = "downloadPage"; - const contentURI = new URI("download.html").setData("urls", urls.map(encodeURIComponent).join("|")); - new MochaUI.Window({ - id: id, - icon: "images/qbittorrent-tray.svg", - title: "QBT_TR(Download from URLs)QBT_TR[CONTEXT=downloadFromURL]", - loadMethod: "iframe", - contentURL: contentURI.toString(), - addClass: "windowFrame", // fixes iframe scrolling on iOS Safari - scrollbars: true, - maximizable: false, - closable: true, - paddingVertical: 0, - paddingHorizontal: 0, - width: loadWindowWidth(id, 500), - height: loadWindowHeight(id, 600), - onResize: window.qBittorrent.Misc.createDebounceHandler(500, (e) => { - saveWindowSize(id); - }) - }); + for (const url of urls) + qBittorrent.Client.createDownloadWindow(url, url); } }); }; diff --git a/src/webui/www/private/scripts/download.js b/src/webui/www/private/scripts/download.js deleted file mode 100644 index 491b627a631d..000000000000 --- a/src/webui/www/private/scripts/download.js +++ /dev/null @@ -1,139 +0,0 @@ -/* - * MIT License - * Copyright (c) 2008 Ishan Arora - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - * THE SOFTWARE. - */ - -"use strict"; - -window.qBittorrent ??= {}; -window.qBittorrent.Download ??= (() => { - const exports = () => { - return { - changeCategorySelect: changeCategorySelect, - changeTMM: changeTMM - }; - }; - - let categories = {}; - let defaultSavePath = ""; - - const getCategories = () => { - new Request.JSON({ - url: "api/v2/torrents/categories", - method: "get", - noCache: true, - onSuccess: (data) => { - if (data) { - categories = data; - for (const i in data) { - if (!Object.hasOwn(data, i)) - continue; - - const category = data[i]; - const option = new Element("option"); - option.value = category.name; - option.textContent = category.name; - $("categorySelect").appendChild(option); - } - } - } - }).send(); - }; - - const getPreferences = () => { - const pref = window.parent.qBittorrent.Cache.preferences.get(); - - defaultSavePath = pref.save_path; - $("savepath").value = defaultSavePath; - $("startTorrent").checked = !pref.add_stopped_enabled; - $("addToTopOfQueue").checked = pref.add_to_top_of_queue; - - if (pref.auto_tmm_enabled) { - $("autoTMM").selectedIndex = 1; - $("savepath").disabled = true; - } - else { - $("autoTMM").selectedIndex = 0; - } - - if (pref.torrent_stop_condition === "MetadataReceived") - $("stopCondition").selectedIndex = 1; - else if (pref.torrent_stop_condition === "FilesChecked") - $("stopCondition").selectedIndex = 2; - else - $("stopCondition").selectedIndex = 0; - - if (pref.torrent_content_layout === "Subfolder") - $("contentLayout").selectedIndex = 1; - else if (pref.torrent_content_layout === "NoSubfolder") - $("contentLayout").selectedIndex = 2; - else - $("contentLayout").selectedIndex = 0; - }; - - const changeCategorySelect = (item) => { - if (item.value === "\\other") { - item.nextElementSibling.hidden = false; - item.nextElementSibling.value = ""; - item.nextElementSibling.select(); - - if ($("autoTMM").selectedIndex === 1) - $("savepath").value = defaultSavePath; - } - else { - item.nextElementSibling.hidden = true; - const text = item.options[item.selectedIndex].textContent; - item.nextElementSibling.value = text; - - if ($("autoTMM").selectedIndex === 1) { - const categoryName = item.value; - const category = categories[categoryName]; - let savePath = defaultSavePath; - if (category !== undefined) - savePath = (category["savePath"] !== "") ? category["savePath"] : `${defaultSavePath}/${categoryName}`; - $("savepath").value = savePath; - } - } - }; - - const changeTMM = (item) => { - if (item.selectedIndex === 1) { - $("savepath").disabled = true; - - const categorySelect = $("categorySelect"); - const categoryName = categorySelect.options[categorySelect.selectedIndex].value; - const category = categories[categoryName]; - $("savepath").value = (category === undefined) ? "" : category["savePath"]; - } - else { - $("savepath").disabled = false; - $("savepath").value = defaultSavePath; - } - }; - - $(window).addEventListener("load", () => { - getPreferences(); - getCategories(); - }); - - return exports(); -})(); -Object.freeze(window.qBittorrent.Download); diff --git a/src/webui/www/private/scripts/dynamicTable.js b/src/webui/www/private/scripts/dynamicTable.js index a505d274de9e..064c7b02e513 100644 --- a/src/webui/www/private/scripts/dynamicTable.js +++ b/src/webui/www/private/scripts/dynamicTable.js @@ -44,6 +44,7 @@ window.qBittorrent.DynamicTable ??= (() => { TorrentTrackersTable: TorrentTrackersTable, BulkRenameTorrentFilesTable: BulkRenameTorrentFilesTable, TorrentFilesTable: TorrentFilesTable, + AddTorrentFilesTable: AddTorrentFilesTable, LogMessageTable: LogMessageTable, LogPeerTable: LogPeerTable, RssFeedTable: RssFeedTable, @@ -2041,12 +2042,13 @@ window.qBittorrent.DynamicTable ??= (() => { populateTable: function(root) { this.fileTree.setRoot(root); root.children.each((node) => { - this._addNodeToTable(node, 0); + this._addNodeToTable(node, 0, root); }); }, - _addNodeToTable: function(node, depth) { + _addNodeToTable: function(node, depth, parent) { node.depth = depth; + node.parent = parent; if (node.isFolder) { const data = { @@ -2069,7 +2071,7 @@ window.qBittorrent.DynamicTable ??= (() => { } node.children.each((child) => { - this._addNodeToTable(child, depth + 1); + this._addNodeToTable(child, depth + 1, node); }); }, @@ -2421,10 +2423,10 @@ window.qBittorrent.DynamicTable ??= (() => { tr.addEventListener("keydown", function(event) { switch (event.key) { case "ArrowLeft": - qBittorrent.PropFiles.collapseFolder(this._this.getSelectedRowId()); + qBittorrent.TorrentContent.collapseFolder(this._this.getSelectedRowId()); return false; case "ArrowRight": - qBittorrent.PropFiles.expandFolder(this._this.getSelectedRowId()); + qBittorrent.TorrentContent.expandFolder(this._this.getSelectedRowId()); return false; } }); @@ -2445,12 +2447,13 @@ window.qBittorrent.DynamicTable ??= (() => { populateTable: function(root) { this.fileTree.setRoot(root); root.children.each((node) => { - this._addNodeToTable(node, 0); + this._addNodeToTable(node, 0, root); }); }, - _addNodeToTable: function(node, depth) { + _addNodeToTable: function(node, depth, parent) { node.depth = depth; + node.parent = parent; if (node.isFolder) { const data = { @@ -2459,7 +2462,7 @@ window.qBittorrent.DynamicTable ??= (() => { checked: node.checked, remaining: node.remaining, progress: node.progress, - priority: window.qBittorrent.PropFiles.normalizePriority(node.priority), + priority: window.qBittorrent.TorrentContent.normalizePriority(node.priority), availability: node.availability, fileId: -1, name: node.name @@ -2476,7 +2479,7 @@ window.qBittorrent.DynamicTable ??= (() => { } node.children.each((child) => { - this._addNodeToTable(child, depth + 1); + this._addNodeToTable(child, depth + 1, node); }); }, @@ -2523,8 +2526,8 @@ window.qBittorrent.DynamicTable ??= (() => { const id = row.rowId; const value = this.getRowValue(row); - if (window.qBittorrent.PropFiles.isDownloadCheckboxExists(id)) { - window.qBittorrent.PropFiles.updateDownloadCheckbox(id, value); + if (window.qBittorrent.TorrentContent.isDownloadCheckboxExists(id)) { + window.qBittorrent.TorrentContent.updateDownloadCheckbox(id, value); } else { const treeImg = new Element("img", { @@ -2533,7 +2536,7 @@ window.qBittorrent.DynamicTable ??= (() => { "margin-bottom": -2 } }); - td.adopt(treeImg, window.qBittorrent.PropFiles.createDownloadCheckbox(id, row.full_data.fileId, value)); + td.adopt(treeImg, window.qBittorrent.TorrentContent.createDownloadCheckbox(id, row.full_data.fileId, value)); } }; this.columns["checked"].staticWidth = 50; @@ -2561,7 +2564,7 @@ window.qBittorrent.DynamicTable ??= (() => { class: "filesTableCollapseIcon", id: collapseIconId, "data-id": id, - onclick: "qBittorrent.PropFiles.collapseIconClicked(this)" + onclick: "qBittorrent.TorrentContent.collapseIconClicked(this)" }); const span = new Element("span", { text: value, @@ -2602,38 +2605,42 @@ window.qBittorrent.DynamicTable ??= (() => { this.columns["size"].updateTd = displaySize; // progress - this.columns["progress"].updateTd = function(td, row) { - const id = row.rowId; - const value = this.getRowValue(row); + if (this.columns["progress"]) { + this.columns["progress"].updateTd = function(td, row) { + const id = row.rowId; + const value = this.getRowValue(row); - const progressBar = $("pbf_" + id); - if (progressBar === null) { - td.adopt(new window.qBittorrent.ProgressBar.ProgressBar(value.toFloat(), { - id: "pbf_" + id, - width: 80 - })); - } - else { - progressBar.setValue(value.toFloat()); - } - }; - this.columns["progress"].staticWidth = 100; + const progressBar = $("pbf_" + id); + if (progressBar === null) { + td.adopt(new window.qBittorrent.ProgressBar.ProgressBar(value.toFloat(), { + id: "pbf_" + id, + width: 80 + })); + } + else { + progressBar.setValue(value.toFloat()); + } + }; + this.columns["progress"].staticWidth = 100; + } // priority this.columns["priority"].updateTd = function(td, row) { const id = row.rowId; const value = this.getRowValue(row); - if (window.qBittorrent.PropFiles.isPriorityComboExists(id)) - window.qBittorrent.PropFiles.updatePriorityCombo(id, value); + if (window.qBittorrent.TorrentContent.isPriorityComboExists(id)) + window.qBittorrent.TorrentContent.updatePriorityCombo(id, value); else - td.adopt(window.qBittorrent.PropFiles.createPriorityCombo(id, row.full_data.fileId, value)); + td.adopt(window.qBittorrent.TorrentContent.createPriorityCombo(id, row.full_data.fileId, value)); }; this.columns["priority"].staticWidth = 140; // remaining, availability - this.columns["remaining"].updateTd = displaySize; - this.columns["availability"].updateTd = displayPercentage; + if (this.columns["remaining"]) + this.columns["remaining"].updateTd = displaySize; + if (this.columns["availability"]) + this.columns["availability"].updateTd = displayPercentage; }, _sortNodesByColumn: function(nodes, column) { @@ -2759,16 +2766,30 @@ window.qBittorrent.DynamicTable ??= (() => { tr.addEventListener("keydown", function(event) { switch (event.key) { case "ArrowLeft": - qBittorrent.PropFiles.collapseFolder(this._this.getSelectedRowId()); + qBittorrent.TorrentContent.collapseFolder(this._this.getSelectedRowId()); return false; case "ArrowRight": - qBittorrent.PropFiles.expandFolder(this._this.getSelectedRowId()); + qBittorrent.TorrentContent.expandFolder(this._this.getSelectedRowId()); return false; } }); } }); + const AddTorrentFilesTable = new Class({ + Extends: TorrentFilesTable, + + initColumns: function() { + this.newColumn("checked", "", "", 50, true); + this.newColumn("name", "", "QBT_TR(Name)QBT_TR[CONTEXT=TrackerListWidget]", 190, true); + this.newColumn("size", "", "QBT_TR(Total Size)QBT_TR[CONTEXT=TrackerListWidget]", 75, true); + this.newColumn("priority", "", "QBT_TR(Download Priority)QBT_TR[CONTEXT=TrackerListWidget]", 140, true); + + this.initColumnsFunctions(); + }, + + }); + const RssFeedTable = new Class({ Extends: DynamicTable, initColumns: function() { @@ -2941,7 +2962,8 @@ window.qBittorrent.DynamicTable ??= (() => { }, setupTr: function(tr) { tr.addEventListener("dblclick", function(e) { - showDownloadPage([this._this.rows.get(this.rowId).full_data.torrentURL]); + const { name, torrentURL } = this._this.rows.get(this.rowId).full_data; + qBittorrent.Client.createDownloadWindow(name, torrentURL); return true; }); tr.addClass("torrentsTableContextMenuTarget"); diff --git a/src/webui/www/private/scripts/misc.js b/src/webui/www/private/scripts/misc.js index 3010c8f42b73..189e6e32ff9d 100644 --- a/src/webui/www/private/scripts/misc.js +++ b/src/webui/www/private/scripts/misc.js @@ -47,6 +47,7 @@ window.qBittorrent.Misc ??= (() => { toFixedPointString: toFixedPointString, containsAllTerms: containsAllTerms, sleep: sleep, + downloadFile: downloadFile, // variables FILTER_INPUT_DELAY: 400, MAX_ETA: 8640000 @@ -275,6 +276,36 @@ window.qBittorrent.Misc ??= (() => { }); }; + const downloadFile = (url, defaultFileName, errorMessage = undefined) => { + const xhr = new XMLHttpRequest(); + xhr.open("GET", url, true); + xhr.responseType = "blob"; + xhr.onload = function(event) { + if ((xhr.status >= 200) && (xhr.status < 300)) { + const blob = xhr.response; + let fileName = defaultFileName; + const prefix = "filename="; + for (const part of (xhr.getResponseHeader("content-disposition") ?? "").split(";").map(s => s.trim())) { + if (part.startsWith(prefix)) { + fileName = part.substring(prefix.length); + if (fileName.startsWith("\"") && fileName.endsWith("\"")) + fileName = fileName.substring(1, fileName.length - 1); + break; + } + } + + const link = document.createElement("a"); + link.href = window.URL.createObjectURL(blob); + link.download = fileName; + link.click(); + } + else { + alert(errorMessage ?? "QBT_TR(Unable to download file)QBT_TR[CONTEXT=HttpServer]"); + } + }; + xhr.send(); + }; + return exports(); })(); Object.freeze(window.qBittorrent.Misc); diff --git a/src/webui/www/private/scripts/mocha-init.js b/src/webui/www/private/scripts/mocha-init.js index 75ae8edd828b..14cfc02d2f88 100644 --- a/src/webui/www/private/scripts/mocha-init.js +++ b/src/webui/www/private/scripts/mocha-init.js @@ -150,8 +150,9 @@ let setQueuePositionFN = () => {}; let exportTorrentFN = () => {}; const initializeWindows = () => { - saveWindowSize = (windowId) => { - const size = $(windowId).getSize(); + saveWindowSize = (windowId, size = undefined) => { + if (size === undefined) + size = $(windowId).getSize(); LocalPreferences.set("window_" + windowId + "_width", size.x); LocalPreferences.set("window_" + windowId + "_height", size.y); }; @@ -197,7 +198,7 @@ const initializeWindows = () => { paddingVertical: 0, paddingHorizontal: 0, width: loadWindowWidth(id, 500), - height: loadWindowHeight(id, 600), + height: loadWindowHeight(id, 300), onResize: window.qBittorrent.Misc.createDebounceHandler(500, (e) => { saveWindowSize(id); }) @@ -254,31 +255,31 @@ const initializeWindows = () => { }); }); - addClickEvent("upload", (e) => { - e.preventDefault(); - e.stopPropagation(); + document.querySelector("#uploadButton #fileselectButton").addEventListener("click", function() { + // clear the value so that reselecting the same file(s) still triggers the 'change' event + this.value = null; + }); - const id = "uploadPage"; - new MochaUI.Window({ - id: id, - icon: "images/qbittorrent-tray.svg", - title: "QBT_TR(Upload local torrent)QBT_TR[CONTEXT=HttpServer]", - loadMethod: "iframe", - contentURL: new URI("upload.html").toString(), - addClass: "windowFrame", // fixes iframe scrolling on iOS Safari - scrollbars: true, - maximizable: false, - paddingVertical: 0, - paddingHorizontal: 0, - width: loadWindowWidth(id, 500), - height: loadWindowHeight(id, 460), - onResize: window.qBittorrent.Misc.createDebounceHandler(500, (e) => { - saveWindowSize(id); - }) - }); - updateMainData(); + // make the entire anchor tag trigger the input, despite the input's label not spanning the entire anchor + document.querySelector("#uploadLink").addEventListener("click", (e) => { + // clear the value so that reselecting the same file(s) still triggers the 'change' event + // $("fileselectLink").value = null; + if (e.target === $("fileselectLink")) { + e.target.value = null; + } + else { + e.preventDefault(); + document.getElementById("fileselectLink").click(); + } }); + document.querySelectorAll("#uploadButton #fileselectButton, #uploadLink #fileselectLink").forEach((element) => element.addEventListener("change", () => { + if (element.files.length === 0) + return; + + window.qBittorrent.Client.uploadTorrentFiles(element.files); + })); + globalUploadLimitFN = () => { new MochaUI.Window({ id: "uploadLimitPage", @@ -1115,16 +1116,10 @@ const initializeWindows = () => { continue; const name = row.full_data.name; - const url = new URI("api/v2/torrents/export"); - url.setData("hash", hash); + const url = new URI("api/v2/torrents/export").setData("hash", hash).toString(); // download response to file - const element = document.createElement("a"); - element.href = url; - element.download = (name + ".torrent"); - document.body.appendChild(element); - element.click(); - document.body.removeChild(element); + window.qBittorrent.Misc.downloadFile(url, `${name}.torrent`, "QBT_TR(Unable to export torrent file)QBT_TR[CONTEXT=MainWindow]"); // https://stackoverflow.com/questions/53560991/automatic-file-downloads-limited-to-10-files-on-chrome-browser await window.qBittorrent.Misc.sleep(200); @@ -1270,4 +1265,7 @@ const initializeWindows = () => { e.stopPropagation(); }); }); + + if ((Browser.platform === "ios") || ((Browser.platform === "mac") && (navigator.maxTouchPoints > 1))) + document.getElementById("fileselect").accept = ".torrent"; }; diff --git a/src/webui/www/private/scripts/prop-files.js b/src/webui/www/private/scripts/prop-files.js index a00ece320f2c..1ae6f48d8163 100644 --- a/src/webui/www/private/scripts/prop-files.js +++ b/src/webui/www/private/scripts/prop-files.js @@ -32,278 +32,16 @@ window.qBittorrent ??= {}; window.qBittorrent.PropFiles ??= (() => { const exports = () => { return { - normalizePriority: normalizePriority, - isDownloadCheckboxExists: isDownloadCheckboxExists, - createDownloadCheckbox: createDownloadCheckbox, - updateDownloadCheckbox: updateDownloadCheckbox, - isPriorityComboExists: isPriorityComboExists, - createPriorityCombo: createPriorityCombo, - updatePriorityCombo: updatePriorityCombo, updateData: updateData, - collapseIconClicked: collapseIconClicked, - expandFolder: expandFolder, - collapseFolder: collapseFolder, clear: clear }; }; - const torrentFilesTable = new window.qBittorrent.DynamicTable.TorrentFilesTable(); - const FilePriority = window.qBittorrent.FileTree.FilePriority; - const TriState = window.qBittorrent.FileTree.TriState; - let is_seed = true; let current_hash = ""; - const normalizePriority = (priority) => { - switch (priority) { - case FilePriority.Ignored: - case FilePriority.Normal: - case FilePriority.High: - case FilePriority.Maximum: - case FilePriority.Mixed: - return priority; - default: - return FilePriority.Normal; - } - }; - - const getAllChildren = (id, fileId) => { - const node = torrentFilesTable.getNode(id); - if (!node.isFolder) { - return { - rowIds: [id], - fileIds: [fileId] - }; - } - - const rowIds = []; - const fileIds = []; - - const getChildFiles = (node) => { - if (node.isFolder) { - node.children.each((child) => { - getChildFiles(child); - }); - } - else { - rowIds.push(node.data.rowId); - fileIds.push(node.data.fileId); - } - }; - - node.children.each((child) => { - getChildFiles(child); - }); - - return { - rowIds: rowIds, - fileIds: fileIds - }; - }; - - const fileCheckboxClicked = (e) => { - e.stopPropagation(); - - const checkbox = e.target; - const priority = checkbox.checked ? FilePriority.Normal : FilePriority.Ignored; - const id = checkbox.getAttribute("data-id"); - const fileId = checkbox.getAttribute("data-file-id"); - - const rows = getAllChildren(id, fileId); - - setFilePriority(rows.rowIds, rows.fileIds, priority); - updateGlobalCheckbox(); - }; - - const fileComboboxChanged = (e) => { - const combobox = e.target; - const priority = combobox.value; - const id = combobox.getAttribute("data-id"); - const fileId = combobox.getAttribute("data-file-id"); - - const rows = getAllChildren(id, fileId); - - setFilePriority(rows.rowIds, rows.fileIds, priority); - updateGlobalCheckbox(); - }; - - const isDownloadCheckboxExists = (id) => { - return $("cbPrio" + id) !== null; - }; - - const createDownloadCheckbox = (id, fileId, checked) => { - const checkbox = new Element("input"); - checkbox.type = "checkbox"; - checkbox.id = "cbPrio" + id; - checkbox.setAttribute("data-id", id); - checkbox.setAttribute("data-file-id", fileId); - checkbox.className = "DownloadedCB"; - checkbox.addEventListener("click", fileCheckboxClicked); - - updateCheckbox(checkbox, checked); - return checkbox; - }; - - const updateDownloadCheckbox = (id, checked) => { - const checkbox = $("cbPrio" + id); - updateCheckbox(checkbox, checked); - }; - - const updateCheckbox = (checkbox, checked) => { - switch (checked) { - case TriState.Checked: - setCheckboxChecked(checkbox); - break; - case TriState.Unchecked: - setCheckboxUnchecked(checkbox); - break; - case TriState.Partial: - setCheckboxPartial(checkbox); - break; - } - }; - - const isPriorityComboExists = (id) => { - return $("comboPrio" + id) !== null; - }; - - const createPriorityCombo = (id, fileId, selectedPriority) => { - const createOption = (priority, isSelected, text) => { - const option = document.createElement("option"); - option.value = priority.toString(); - option.selected = isSelected; - option.textContent = text; - return option; - }; - - const select = document.createElement("select"); - select.id = "comboPrio" + id; - select.setAttribute("data-id", id); - select.setAttribute("data-file-id", fileId); - select.addClass("combo_priority"); - select.addEventListener("change", fileComboboxChanged); - - select.appendChild(createOption(FilePriority.Ignored, (FilePriority.Ignored === selectedPriority), "QBT_TR(Do not download)QBT_TR[CONTEXT=PropListDelegate]")); - select.appendChild(createOption(FilePriority.Normal, (FilePriority.Normal === selectedPriority), "QBT_TR(Normal)QBT_TR[CONTEXT=PropListDelegate]")); - select.appendChild(createOption(FilePriority.High, (FilePriority.High === selectedPriority), "QBT_TR(High)QBT_TR[CONTEXT=PropListDelegate]")); - select.appendChild(createOption(FilePriority.Maximum, (FilePriority.Maximum === selectedPriority), "QBT_TR(Maximum)QBT_TR[CONTEXT=PropListDelegate]")); - - // "Mixed" priority is for display only; it shouldn't be selectable - const mixedPriorityOption = createOption(FilePriority.Mixed, (FilePriority.Mixed === selectedPriority), "QBT_TR(Mixed)QBT_TR[CONTEXT=PropListDelegate]"); - mixedPriorityOption.disabled = true; - select.appendChild(mixedPriorityOption); - - return select; - }; - - const updatePriorityCombo = (id, selectedPriority) => { - const combobox = $("comboPrio" + id); - if (parseInt(combobox.value, 10) !== selectedPriority) - selectComboboxPriority(combobox, selectedPriority); - }; - - const selectComboboxPriority = (combobox, priority) => { - const options = combobox.options; - for (let i = 0; i < options.length; ++i) { - const option = options[i]; - if (parseInt(option.value, 10) === priority) - option.selected = true; - else - option.selected = false; - } - - combobox.value = priority; - }; - - const switchCheckboxState = (e) => { - e.stopPropagation(); - - const rowIds = []; - const fileIds = []; - let priority = FilePriority.Ignored; - const checkbox = $("tristate_cb"); - - if (checkbox.state === "checked") { - setCheckboxUnchecked(checkbox); - // set file priority for all checked to Ignored - torrentFilesTable.getFilteredAndSortedRows().forEach((row) => { - const rowId = row.rowId; - const fileId = row.full_data.fileId; - const isChecked = (row.full_data.checked === TriState.Checked); - const isFolder = (fileId === -1); - if (!isFolder && isChecked) { - rowIds.push(rowId); - fileIds.push(fileId); - } - }); - } - else { - setCheckboxChecked(checkbox); - priority = FilePriority.Normal; - // set file priority for all unchecked to Normal - torrentFilesTable.getFilteredAndSortedRows().forEach((row) => { - const rowId = row.rowId; - const fileId = row.full_data.fileId; - const isUnchecked = (row.full_data.checked === TriState.Unchecked); - const isFolder = (fileId === -1); - if (!isFolder && isUnchecked) { - rowIds.push(rowId); - fileIds.push(fileId); - } - }); - } - - if (rowIds.length > 0) - setFilePriority(rowIds, fileIds, priority); - }; - - const updateGlobalCheckbox = () => { - const checkbox = $("tristate_cb"); - if (isAllCheckboxesChecked()) - setCheckboxChecked(checkbox); - else if (isAllCheckboxesUnchecked()) - setCheckboxUnchecked(checkbox); - else - setCheckboxPartial(checkbox); - }; - - const setCheckboxChecked = (checkbox) => { - checkbox.state = "checked"; - checkbox.indeterminate = false; - checkbox.checked = true; - }; - - const setCheckboxUnchecked = (checkbox) => { - checkbox.state = "unchecked"; - checkbox.indeterminate = false; - checkbox.checked = false; - }; - - const setCheckboxPartial = (checkbox) => { - checkbox.state = "partial"; - checkbox.indeterminate = true; - }; - - const isAllCheckboxesChecked = () => { - const checkboxes = $$("input.DownloadedCB"); - for (let i = 0; i < checkboxes.length; ++i) { - if (!checkboxes[i].checked) - return false; - } - return true; - }; - - const isAllCheckboxesUnchecked = () => { - const checkboxes = $$("input.DownloadedCB"); - for (let i = 0; i < checkboxes.length; ++i) { - if (checkboxes[i].checked) - return false; - } - return true; - }; - - const setFilePriority = (ids, fileIds, priority) => { - if (current_hash === "") - return; + const onFilePriorityChanged = (fileIds, priority) => { + // ignore folders + fileIds = fileIds.map(id => parseInt(id, 10)).filter(id => !window.qBittorrent.TorrentContent.isFolder(id)); clearTimeout(loadTorrentFilesDataTimer); loadTorrentFilesDataTimer = -1; @@ -320,17 +58,6 @@ window.qBittorrent.PropFiles ??= (() => { loadTorrentFilesDataTimer = loadTorrentFilesData.delay(1000); } }).send(); - - const ignore = (priority === FilePriority.Ignored); - ids.forEach((_id) => { - torrentFilesTable.setIgnored(_id, ignore); - - const combobox = $("comboPrio" + _id); - if (combobox !== null) - selectComboboxPriority(combobox, priority); - }); - - torrentFilesTable.updateTable(false); }; let loadTorrentFilesDataTimer = -1; @@ -362,16 +89,15 @@ window.qBittorrent.PropFiles ??= (() => { loadTorrentFilesDataTimer = loadTorrentFilesData.delay(5000); }, onSuccess: (files) => { - clearTimeout(torrentFilesFilterInputTimer); - torrentFilesFilterInputTimer = -1; + window.qBittorrent.TorrentContent.clearFilterInputTimer(); if (files.length === 0) { torrentFilesTable.clear(); } else { - handleNewTorrentFiles(files); + window.qBittorrent.TorrentContent.updateData(files); if (loadedNewTorrent) - collapseAllNodes(); + window.qBittorrent.TorrentContent.collapseAllNodes(); } } }).send(); @@ -383,164 +109,7 @@ window.qBittorrent.PropFiles ??= (() => { loadTorrentFilesData(); }; - const handleNewTorrentFiles = (files) => { - is_seed = (files.length > 0) ? files[0].is_seed : true; - - const rows = files.map((file, index) => { - let progress = (file.progress * 100).round(1); - if ((progress === 100) && (file.progress < 1)) - progress = 99.9; - - const ignore = (file.priority === FilePriority.Ignored); - const checked = (ignore ? TriState.Unchecked : TriState.Checked); - const remaining = (ignore ? 0 : (file.size * (1.0 - file.progress))); - const row = { - fileId: index, - checked: checked, - fileName: file.name, - name: window.qBittorrent.Filesystem.fileName(file.name), - size: file.size, - progress: progress, - priority: normalizePriority(file.priority), - remaining: remaining, - availability: file.availability - }; - - return row; - }); - - addRowsToTable(rows); - updateGlobalCheckbox(); - }; - - const addRowsToTable = (rows) => { - const selectedFiles = torrentFilesTable.selectedRowsIds(); - let rowId = 0; - - const rootNode = new window.qBittorrent.FileTree.FolderNode(); - - rows.forEach((row) => { - const pathItems = row.fileName.split(window.qBittorrent.Filesystem.PathSeparator); - - pathItems.pop(); // remove last item (i.e. file name) - let parent = rootNode; - pathItems.forEach((folderName) => { - if (folderName === ".unwanted") - return; - - let folderNode = null; - if (parent.children !== null) { - for (let i = 0; i < parent.children.length; ++i) { - const childFolder = parent.children[i]; - if (childFolder.name === folderName) { - folderNode = childFolder; - break; - } - } - } - - if (folderNode === null) { - folderNode = new window.qBittorrent.FileTree.FolderNode(); - folderNode.path = (parent.path === "") - ? folderName - : [parent.path, folderName].join(window.qBittorrent.Filesystem.PathSeparator); - folderNode.name = folderName; - folderNode.rowId = rowId; - folderNode.root = parent; - parent.addChild(folderNode); - - ++rowId; - } - - parent = folderNode; - }); - - const isChecked = row.checked ? TriState.Checked : TriState.Unchecked; - const remaining = (row.priority === FilePriority.Ignored) ? 0 : row.remaining; - const childNode = new window.qBittorrent.FileTree.FileNode(); - childNode.name = row.name; - childNode.path = row.fileName; - childNode.rowId = rowId; - childNode.size = row.size; - childNode.checked = isChecked; - childNode.remaining = remaining; - childNode.progress = row.progress; - childNode.priority = row.priority; - childNode.availability = row.availability; - childNode.root = parent; - childNode.data = row; - parent.addChild(childNode); - - ++rowId; - }); - - torrentFilesTable.populateTable(rootNode); - torrentFilesTable.updateTable(false); - - if (selectedFiles.length > 0) - torrentFilesTable.reselectRows(selectedFiles); - }; - - const collapseIconClicked = (event) => { - const id = event.getAttribute("data-id"); - const node = torrentFilesTable.getNode(id); - const isCollapsed = (event.parentElement.getAttribute("data-collapsed") === "true"); - - if (isCollapsed) - expandNode(node); - else - collapseNode(node); - }; - - const expandFolder = (id) => { - const node = torrentFilesTable.getNode(id); - if (node.isFolder) - expandNode(node); - }; - - const collapseFolder = (id) => { - const node = torrentFilesTable.getNode(id); - if (node.isFolder) - collapseNode(node); - }; - - const filesPriorityMenuClicked = (priority) => { - const selectedRows = torrentFilesTable.selectedRowsIds(); - if (selectedRows.length === 0) - return; - - const rowIds = []; - const fileIds = []; - selectedRows.forEach((rowId) => { - const elem = $("comboPrio" + rowId); - rowIds.push(rowId); - fileIds.push(elem.getAttribute("data-file-id")); - }); - - const uniqueRowIds = {}; - const uniqueFileIds = {}; - for (let i = 0; i < rowIds.length; ++i) { - const rows = getAllChildren(rowIds[i], fileIds[i]); - rows.rowIds.forEach((rowId) => { - uniqueRowIds[rowId] = true; - }); - rows.fileIds.forEach((fileId) => { - uniqueFileIds[fileId] = true; - }); - } - - setFilePriority(Object.keys(uniqueRowIds), Object.keys(uniqueFileIds), priority); - }; - - const singleFileRename = (hash) => { - const rowId = torrentFilesTable.selectedRowsIds()[0]; - if (rowId === undefined) - return; - const row = torrentFilesTable.rows.get(rowId); - if (!row) - return; - - const node = torrentFilesTable.getNode(rowId); + const singleFileRename = (hash, node) => { const path = node.path; new MochaUI.Window({ @@ -556,16 +125,19 @@ window.qBittorrent.PropFiles ??= (() => { paddingVertical: 0, paddingHorizontal: 0, width: 400, - height: 100 + height: 100, + onCloseComplete: () => { + updateData(); + } }); }; - const multiFileRename = (hash) => { + const multiFileRename = (hash, selectedRows) => { new MochaUI.Window({ id: "multiRenamePage", icon: "images/qbittorrent-tray.svg", title: "QBT_TR(Renaming)QBT_TR[CONTEXT=TorrentContentTreeView]", - data: { hash: hash, selectedRows: torrentFilesTable.selectedRows }, + data: { hash: hash, selectedRows: selectedRows }, loadMethod: "xhr", contentURL: "rename_files.html", scrollbars: false, @@ -575,194 +147,21 @@ window.qBittorrent.PropFiles ??= (() => { paddingHorizontal: 0, width: 800, height: 420, - resizeLimit: { "x": [800], "y": [420] } - }); - }; - - const torrentFilesContextMenu = new window.qBittorrent.ContextMenu.ContextMenu({ - targets: "#torrentFilesTableDiv tr", - menu: "torrentFilesMenu", - actions: { - Rename: (element, ref) => { - const hash = torrentsTable.getCurrentTorrentID(); - if (!hash) - return; - - if (torrentFilesTable.selectedRowsIds().length > 1) - multiFileRename(hash); - else - singleFileRename(hash); - }, - - FilePrioIgnore: (element, ref) => { - filesPriorityMenuClicked(FilePriority.Ignored); - }, - FilePrioNormal: (element, ref) => { - filesPriorityMenuClicked(FilePriority.Normal); - }, - FilePrioHigh: (element, ref) => { - filesPriorityMenuClicked(FilePriority.High); - }, - FilePrioMaximum: (element, ref) => { - filesPriorityMenuClicked(FilePriority.Maximum); + resizeLimit: { "x": [800], "y": [420] }, + onCloseComplete: () => { + updateData(); } - }, - offsets: { - x: 0, - y: 2 - }, - onShow: function() { - if (is_seed) - this.hideItem("FilePrio"); - else - this.showItem("FilePrio"); - } - }); - - torrentFilesTable.setup("torrentFilesTableDiv", "torrentFilesTableFixedHeaderDiv", torrentFilesContextMenu); - // inject checkbox into table header - const tableHeaders = $$("#torrentFilesTableFixedHeaderDiv .dynamicTableHeader th"); - if (tableHeaders.length > 0) { - const checkbox = new Element("input"); - checkbox.type = "checkbox"; - checkbox.id = "tristate_cb"; - checkbox.addEventListener("click", switchCheckboxState); - - const checkboxTH = tableHeaders[0]; - checkbox.injectInside(checkboxTH); - } - - // default sort by name column - if (torrentFilesTable.getSortedColumn() === null) - torrentFilesTable.setSortedColumn("name"); - - // listen for changes to torrentFilesFilterInput - let torrentFilesFilterInputTimer = -1; - $("torrentFilesFilterInput").addEventListener("input", () => { - clearTimeout(torrentFilesFilterInputTimer); - - const value = $("torrentFilesFilterInput").value; - torrentFilesTable.setFilter(value); - - torrentFilesFilterInputTimer = setTimeout(() => { - torrentFilesFilterInputTimer = -1; - - if (current_hash === "") - return; - - torrentFilesTable.updateTable(); - - if (value.trim() === "") - collapseAllNodes(); - else - expandAllNodes(); - }, window.qBittorrent.Misc.FILTER_INPUT_DELAY); - }); - - /** - * Show/hide a node's row - */ - const _hideNode = (node, shouldHide) => { - const span = $("filesTablefileName" + node.rowId); - // span won't exist if row has been filtered out - if (span === null) - return; - const rowElem = span.parentElement.parentElement; - if (shouldHide) - rowElem.addClass("invisible"); - else - rowElem.removeClass("invisible"); - }; - - /** - * Update a node's collapsed state and icon - */ - const _updateNodeState = (node, isCollapsed) => { - const span = $("filesTablefileName" + node.rowId); - // span won't exist if row has been filtered out - if (span === null) - return; - const td = span.parentElement; - - // store collapsed state - td.setAttribute("data-collapsed", isCollapsed); - - // rotate the collapse icon - const collapseIcon = td.getElementsByClassName("filesTableCollapseIcon")[0]; - if (isCollapsed) - collapseIcon.addClass("rotate"); - else - collapseIcon.removeClass("rotate"); - }; - - const _isCollapsed = (node) => { - const span = $("filesTablefileName" + node.rowId); - if (span === null) - return true; - - const td = span.parentElement; - return td.getAttribute("data-collapsed") === "true"; - }; - - const expandNode = (node) => { - _collapseNode(node, false, false, false); - }; - - const collapseNode = (node) => { - _collapseNode(node, true, false, false); - }; - - const expandAllNodes = () => { - const root = torrentFilesTable.getRoot(); - root.children.each((node) => { - node.children.each((child) => { - _collapseNode(child, false, true, false); - }); }); }; - const collapseAllNodes = () => { - const root = torrentFilesTable.getRoot(); - root.children.each((node) => { - node.children.each((child) => { - _collapseNode(child, true, true, false); - }); - }); + const onFileRenameHandler = (selectedRows, selectedNodes) => { + if (selectedNodes.length === 1) + singleFileRename(current_hash, selectedNodes[0]); + else if (selectedNodes.length > 1) + multiFileRename(current_hash, selectedRows); }; - /** - * Collapses a folder node with the option to recursively collapse all children - * @param {FolderNode} node the node to collapse/expand - * @param {boolean} shouldCollapse true if the node should be collapsed, false if it should be expanded - * @param {boolean} applyToChildren true if the node's children should also be collapsed, recursively - * @param {boolean} isChildNode true if the current node is a child of the original node we collapsed/expanded - */ - const _collapseNode = (node, shouldCollapse, applyToChildren, isChildNode) => { - if (!node.isFolder) - return; - - const shouldExpand = !shouldCollapse; - const isNodeCollapsed = _isCollapsed(node); - const nodeInCorrectState = ((shouldCollapse && isNodeCollapsed) || (shouldExpand && !isNodeCollapsed)); - const canSkipNode = (isChildNode && (!applyToChildren || nodeInCorrectState)); - if (!isChildNode || applyToChildren || !canSkipNode) - _updateNodeState(node, shouldCollapse); - - node.children.each((child) => { - _hideNode(child, shouldCollapse); - - if (!child.isFolder) - return; - - // don't expand children that have been independently collapsed, unless applyToChildren is true - const shouldExpandChildren = (shouldExpand && applyToChildren); - const isChildCollapsed = _isCollapsed(child); - if (!shouldExpandChildren && isChildCollapsed) - return; - - _collapseNode(child, shouldCollapse, applyToChildren, true); - }); - }; + const torrentFilesTable = window.qBittorrent.TorrentContent.init("torrentFilesTableDiv", window.qBittorrent.DynamicTable.TorrentFilesTable, onFilePriorityChanged, onFileRenameHandler); const clear = () => { torrentFilesTable.clear(); diff --git a/src/webui/www/private/scripts/search.js b/src/webui/www/private/scripts/search.js index eb29a5dc1f20..acf413c194a8 100644 --- a/src/webui/www/private/scripts/search.js +++ b/src/webui/www/private/scripts/search.js @@ -493,15 +493,10 @@ window.qBittorrent.Search ??= (() => { }; const downloadSearchTorrent = () => { - const urls = []; - for (const rowID of searchResultsTable.selectedRowsIds()) - urls.push(searchResultsTable.getRow(rowID).full_data.fileUrl); - - // only proceed if at least 1 row was selected - if (!urls.length) - return; - - showDownloadPage(urls); + for (const rowID of searchResultsTable.selectedRowsIds()) { + const { fileName, fileUrl } = searchResultsTable.getRow(rowID).full_data; + qBittorrent.Client.createDownloadWindow(fileName, fileUrl); + } }; const manageSearchPlugins = () => { diff --git a/src/webui/www/private/scripts/torrent-content.js b/src/webui/www/private/scripts/torrent-content.js new file mode 100644 index 000000000000..122312183be0 --- /dev/null +++ b/src/webui/www/private/scripts/torrent-content.js @@ -0,0 +1,730 @@ +/* + * Bittorrent Client using Qt and libtorrent. + * Copyright (C) 2024 Thomas Piccirello + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * In addition, as a special exception, the copyright holders give permission to + * link this program with the OpenSSL project's "OpenSSL" library (or with + * modified versions of it that use the same license as the "OpenSSL" library), + * and distribute the linked executables. You must obey the GNU General Public + * License in all respects for all of the code used other than "OpenSSL". If you + * modify file(s), you may extend this exception to your version of the file(s), + * but you are not obligated to do so. If you do not wish to do so, delete this + * exception statement from your version. + */ + +"use strict"; + +window.qBittorrent ??= {}; +window.qBittorrent.TorrentContent ??= (() => { + const exports = () => { + return { + init: init, + normalizePriority: normalizePriority, + isFolder: isFolder, + isDownloadCheckboxExists: isDownloadCheckboxExists, + createDownloadCheckbox: createDownloadCheckbox, + updateDownloadCheckbox: updateDownloadCheckbox, + isPriorityComboExists: isPriorityComboExists, + createPriorityCombo: createPriorityCombo, + updatePriorityCombo: updatePriorityCombo, + updateData: updateData, + collapseIconClicked: collapseIconClicked, + expandFolder: expandFolder, + collapseFolder: collapseFolder, + collapseAllNodes: collapseAllNodes, + clearFilterInputTimer: clearFilterInputTimer + }; + }; + + let torrentFilesTable; + const FilePriority = window.qBittorrent.FileTree.FilePriority; + const TriState = window.qBittorrent.FileTree.TriState; + let torrentFilesFilterInputTimer = -1; + let onFilePriorityChanged; + + const normalizePriority = (priority) => { + priority = parseInt(priority, 10); + + switch (priority) { + case FilePriority.Ignored: + case FilePriority.Normal: + case FilePriority.High: + case FilePriority.Maximum: + case FilePriority.Mixed: + return priority; + default: + return FilePriority.Normal; + } + }; + + const triStateFromPriority = (priority) => { + switch (normalizePriority(priority)) { + case FilePriority.Ignored: + return TriState.Unchecked; + case FilePriority.Normal: + case FilePriority.High: + case FilePriority.Maximum: + return TriState.Checked; + case FilePriority.Mixed: + return TriState.Partial; + } + }; + + const isFolder = (fileId) => { + return fileId === -1; + }; + + const getAllChildren = (id, fileId) => { + const getChildFiles = (node) => { + rowIds.push(node.data.rowId); + fileIds.push(node.data.fileId); + + if (node.isFolder) { + node.children.each((child) => { + getChildFiles(child); + }); + } + }; + + const node = torrentFilesTable.getNode(id); + const rowIds = [node.data.rowId]; + const fileIds = [node.data.fileId]; + + node.children.each((child) => { + getChildFiles(child); + }); + + return { + rowIds: rowIds, + fileIds: fileIds + }; + }; + + const fileCheckboxClicked = (e) => { + e.stopPropagation(); + + const checkbox = e.target; + const priority = checkbox.checked ? FilePriority.Normal : FilePriority.Ignored; + const id = checkbox.getAttribute("data-id"); + const fileId = parseInt(checkbox.getAttribute("data-file-id"), 10); + + const rows = getAllChildren(id, fileId); + + setFilePriority(rows.rowIds, rows.fileIds, priority); + updateParentFolder(id); + }; + + const fileComboboxChanged = (e) => { + const combobox = e.target; + const priority = combobox.value; + const id = combobox.getAttribute("data-id"); + const fileId = parseInt(combobox.getAttribute("data-file-id"), 10); + + const rows = getAllChildren(id, fileId); + + setFilePriority(rows.rowIds, rows.fileIds, priority); + updateParentFolder(id); + }; + + const isDownloadCheckboxExists = (id) => { + return ($("cbPrio" + id) !== null); + }; + + const createDownloadCheckbox = (id, fileId, checked) => { + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.id = "cbPrio" + id; + checkbox.setAttribute("data-id", id); + checkbox.setAttribute("data-file-id", fileId); + checkbox.className = "DownloadedCB"; + checkbox.addEventListener("click", fileCheckboxClicked); + + updateCheckbox(checkbox, checked); + return checkbox; + }; + + const updateDownloadCheckbox = (id, checked) => { + const checkbox = $("cbPrio" + id); + updateCheckbox(checkbox, checked); + }; + + const updateCheckbox = (checkbox, checked) => { + switch (checked) { + case TriState.Checked: + setCheckboxChecked(checkbox); + break; + case TriState.Unchecked: + setCheckboxUnchecked(checkbox); + break; + case TriState.Partial: + setCheckboxPartial(checkbox); + break; + } + }; + + const isPriorityComboExists = (id) => { + return ($("comboPrio" + id) !== null); + }; + + const createPriorityCombo = (id, fileId, selectedPriority) => { + const createOption = (priority, isSelected, text) => { + const option = document.createElement("option"); + option.value = priority.toString(); + option.selected = isSelected; + option.textContent = text; + return option; + }; + + const select = document.createElement("select"); + select.id = "comboPrio" + id; + select.setAttribute("data-id", id); + select.setAttribute("data-file-id", fileId); + select.addClass("combo_priority"); + select.addEventListener("change", fileComboboxChanged); + + select.appendChild(createOption(FilePriority.Ignored, (FilePriority.Ignored === selectedPriority), "QBT_TR(Do not download)QBT_TR[CONTEXT=PropListDelegate]")); + select.appendChild(createOption(FilePriority.Normal, (FilePriority.Normal === selectedPriority), "QBT_TR(Normal)QBT_TR[CONTEXT=PropListDelegate]")); + select.appendChild(createOption(FilePriority.High, (FilePriority.High === selectedPriority), "QBT_TR(High)QBT_TR[CONTEXT=PropListDelegate]")); + select.appendChild(createOption(FilePriority.Maximum, (FilePriority.Maximum === selectedPriority), "QBT_TR(Maximum)QBT_TR[CONTEXT=PropListDelegate]")); + + // "Mixed" priority is for display only; it shouldn't be selectable + const mixedPriorityOption = createOption(FilePriority.Mixed, (FilePriority.Mixed === selectedPriority), "QBT_TR(Mixed)QBT_TR[CONTEXT=PropListDelegate]"); + mixedPriorityOption.disabled = true; + select.appendChild(mixedPriorityOption); + + return select; + }; + + const updatePriorityCombo = (id, selectedPriority) => { + const combobox = $("comboPrio" + id); + if (normalizePriority(combobox.value) !== selectedPriority) + selectComboboxPriority(combobox, normalizePriority(selectedPriority)); + }; + + const selectComboboxPriority = (combobox, priority) => { + const options = combobox.options; + for (let i = 0; i < options.length; ++i) { + const option = options[i]; + if (normalizePriority(option.value) === priority) + option.selected = true; + else + option.selected = false; + } + + combobox.value = priority; + }; + + const getComboboxPriority = (id) => { + const row = torrentFilesTable.rows.get(id.toString()); + return normalizePriority(row.full_data.priority, 10); + }; + + const switchGlobalCheckboxState = (e) => { + e.stopPropagation(); + + const rowIds = []; + const fileIds = []; + const checkbox = $("tristate_cb"); + const priority = (checkbox.state === TriState.Checked) ? FilePriority.Ignored : FilePriority.Normal; + + if (checkbox.state === TriState.Checked) { + setCheckboxUnchecked(checkbox); + torrentFilesTable.rows.forEach((row) => { + const rowId = row.rowId; + const fileId = row.full_data.fileId; + const isChecked = (getCheckboxState(rowId) === TriState.Checked); + if (isChecked) { + rowIds.push(rowId); + fileIds.push(fileId); + } + }); + } + else { + setCheckboxChecked(checkbox); + torrentFilesTable.rows.forEach((row) => { + const rowId = row.rowId; + const fileId = row.full_data.fileId; + const isUnchecked = (getCheckboxState(rowId) === TriState.Unchecked); + if (isUnchecked) { + rowIds.push(rowId); + fileIds.push(fileId); + } + }); + } + + if (rowIds.length > 0) { + setFilePriority(rowIds, fileIds, priority); + for (const id of rowIds) + updateParentFolder(id); + } + }; + + const updateGlobalCheckbox = () => { + const checkbox = $("tristate_cb"); + if (isAllCheckboxesChecked()) + setCheckboxChecked(checkbox); + else if (isAllCheckboxesUnchecked()) + setCheckboxUnchecked(checkbox); + else + setCheckboxPartial(checkbox); + }; + + const setCheckboxChecked = (checkbox) => { + checkbox.state = TriState.Checked; + checkbox.indeterminate = false; + checkbox.checked = true; + }; + + const setCheckboxUnchecked = (checkbox) => { + checkbox.state = TriState.Unchecked; + checkbox.indeterminate = false; + checkbox.checked = false; + }; + + const setCheckboxPartial = (checkbox) => { + checkbox.state = TriState.Partial; + checkbox.indeterminate = true; + }; + + const getCheckboxState = (id) => { + const row = torrentFilesTable.rows.get(id.toString()); + return parseInt(row.full_data.checked, 10); + }; + + const isAllCheckboxesChecked = () => { + return [...torrentFilesTable.rows.values()].every(row => (getCheckboxState(row.rowId) !== TriState.Unchecked)); + }; + + const isAllCheckboxesUnchecked = () => { + return [...torrentFilesTable.rows.values()].every(row => (getCheckboxState(row.rowId) === TriState.Unchecked)); + }; + + const setFilePriority = (ids, fileIds, priority) => { + priority = normalizePriority(priority); + + if (onFilePriorityChanged) + onFilePriorityChanged(fileIds, priority); + + const ignore = (priority === FilePriority.Ignored); + ids.forEach((_id) => { + _id = _id.toString(); + torrentFilesTable.setIgnored(_id, ignore); + + const row = torrentFilesTable.rows.get(_id); + row.full_data.priority = priority; + row.full_data.checked = triStateFromPriority(priority); + }); + }; + + const updateData = (files) => { + const rows = files.map((file, index) => { + let progress = (file.progress * 100).round(1); + if ((progress === 100) && (file.progress < 1)) + progress = 99.9; + + const ignore = (file.priority === FilePriority.Ignored); + const checked = (ignore ? TriState.Unchecked : TriState.Checked); + const remaining = (ignore ? 0 : (file.size * (1.0 - file.progress))); + const row = { + fileId: index, + checked: checked, + fileName: file.name, + name: window.qBittorrent.Filesystem.fileName(file.name), + size: file.size, + progress: progress, + priority: normalizePriority(file.priority), + remaining: remaining, + availability: file.availability + }; + + return row; + }); + + addRowsToTable(rows); + updateGlobalCheckbox(); + }; + + const addRowsToTable = (rows) => { + const selectedFiles = torrentFilesTable.selectedRowsIds(); + let rowId = 0; + + const rootNode = new window.qBittorrent.FileTree.FolderNode(); + + rows.forEach((row) => { + const pathItems = row.fileName.split(window.qBittorrent.Filesystem.PathSeparator); + + pathItems.pop(); // remove last item (i.e. file name) + let parent = rootNode; + pathItems.forEach((folderName) => { + if (folderName === ".unwanted") + return; + + let folderNode = null; + if (parent.children !== null) { + for (let i = 0; i < parent.children.length; ++i) { + const childFolder = parent.children[i]; + if (childFolder.name === folderName) { + folderNode = childFolder; + break; + } + } + } + + if (folderNode === null) { + folderNode = new window.qBittorrent.FileTree.FolderNode(); + folderNode.path = (parent.path === "") + ? folderName + : [parent.path, folderName].join(window.qBittorrent.Filesystem.PathSeparator); + folderNode.name = folderName; + folderNode.rowId = rowId; + folderNode.root = parent; + parent.addChild(folderNode); + + ++rowId; + } + + parent = folderNode; + }); + + const isChecked = row.checked ? TriState.Checked : TriState.Unchecked; + const remaining = (row.priority === FilePriority.Ignored) ? 0 : row.remaining; + const childNode = new window.qBittorrent.FileTree.FileNode(); + childNode.name = row.name; + childNode.path = row.fileName; + childNode.rowId = rowId; + childNode.size = row.size; + childNode.checked = isChecked; + childNode.remaining = remaining; + childNode.progress = row.progress; + childNode.priority = row.priority; + childNode.availability = row.availability; + childNode.root = parent; + childNode.data = row; + parent.addChild(childNode); + + ++rowId; + }); + + torrentFilesTable.populateTable(rootNode); + torrentFilesTable.updateTable(); + + if (selectedFiles.length > 0) + torrentFilesTable.reselectRows(selectedFiles); + }; + + const collapseIconClicked = (event) => { + const id = event.getAttribute("data-id"); + const node = torrentFilesTable.getNode(id); + const isCollapsed = (event.parentElement.getAttribute("data-collapsed") === "true"); + + if (isCollapsed) + expandNode(node); + else + collapseNode(node); + }; + + const expandFolder = (id) => { + const node = torrentFilesTable.getNode(id); + if (node.isFolder) + expandNode(node); + }; + + const collapseFolder = (id) => { + const node = torrentFilesTable.getNode(id); + if (node.isFolder) + collapseNode(node); + }; + + const filesPriorityMenuClicked = (priority) => { + const selectedRows = torrentFilesTable.selectedRowsIds(); + if (selectedRows.length === 0) + return; + + const rowIds = []; + const fileIds = []; + selectedRows.forEach((rowId) => { + const elem = $("comboPrio" + rowId); + rowIds.push(rowId); + fileIds.push(parseInt(elem.getAttribute("data-file-id"), 10)); + }); + + const uniqueRowIds = {}; + const uniqueFileIds = {}; + for (let i = 0; i < rowIds.length; ++i) { + const rows = getAllChildren(rowIds[i], fileIds[i]); + rows.rowIds.forEach((rowId) => { + uniqueRowIds[rowId] = true; + }); + rows.fileIds.forEach((fileId) => { + uniqueFileIds[fileId] = true; + }); + } + + setFilePriority(Object.keys(uniqueRowIds), Object.keys(uniqueFileIds), priority); + for (const id of rowIds) + updateParentFolder(id); + }; + + const updateParentFolder = (id) => { + const updateComplete = () => { + // we've finished recursing + updateGlobalCheckbox(); + torrentFilesTable.updateTable(true); + }; + + const node = torrentFilesTable.getNode(id); + const parent = node.parent; + if (parent === torrentFilesTable.getRoot()) { + updateComplete(); + return; + } + + const siblings = parent.children; + + let checkedCount = 0; + let uncheckedCount = 0; + let indeterminateCount = 0; + let desiredComboboxPriority = null; + for (const sibling of siblings) { + switch (getCheckboxState(sibling.rowId)) { + case TriState.Checked: + checkedCount++; + break; + case TriState.Unchecked: + uncheckedCount++; + break; + case TriState.Partial: + indeterminateCount++; + break; + } + + if (desiredComboboxPriority === null) + desiredComboboxPriority = getComboboxPriority(sibling.rowId); + else if (desiredComboboxPriority !== getComboboxPriority(sibling.rowId)) + desiredComboboxPriority = FilePriority.Mixed; + } + + const currentCheckboxState = getCheckboxState(parent.rowId); + let desiredCheckboxState; + if ((indeterminateCount > 0) || ((checkedCount > 0) && (uncheckedCount > 0))) + desiredCheckboxState = TriState.Partial; + else if (checkedCount > 0) + desiredCheckboxState = TriState.Checked; + else + desiredCheckboxState = TriState.Unchecked; + + const currentComboboxPriority = getComboboxPriority(parent.rowId); + if ((currentCheckboxState !== desiredCheckboxState) || (currentComboboxPriority !== desiredComboboxPriority)) { + const row = torrentFilesTable.rows.get(parent.rowId.toString()); + row.full_data.priority = desiredComboboxPriority; + row.full_data.checked = desiredCheckboxState; + + updateParentFolder(parent.rowId); + } + else { + updateComplete(); + } + }; + + const init = (tableId, tableClass, onFilePriorityChangedHandler = undefined, onFileRenameHandler = undefined) => { + if (onFilePriorityChangedHandler !== undefined) + onFilePriorityChanged = onFilePriorityChangedHandler; + + torrentFilesTable = new tableClass(); + + const torrentFilesContextMenu = new window.qBittorrent.ContextMenu.ContextMenu({ + targets: `#${tableId} tr`, + menu: "torrentFilesMenu", + actions: { + Rename: (element, ref) => { + if (onFileRenameHandler !== undefined) { + const nodes = torrentFilesTable.selectedRowsIds().map(row => torrentFilesTable.getNode(row)); + onFileRenameHandler(torrentFilesTable.selectedRows, nodes); + } + }, + + FilePrioIgnore: (element, ref) => { + filesPriorityMenuClicked(FilePriority.Ignored); + }, + FilePrioNormal: (element, ref) => { + filesPriorityMenuClicked(FilePriority.Normal); + }, + FilePrioHigh: (element, ref) => { + filesPriorityMenuClicked(FilePriority.High); + }, + FilePrioMaximum: (element, ref) => { + filesPriorityMenuClicked(FilePriority.Maximum); + } + }, + offsets: { + x: 0, + y: 2 + }, + }); + + torrentFilesTable.setup(tableId, "torrentFilesTableFixedHeaderDiv", torrentFilesContextMenu); + // inject checkbox into table header + const tableHeaders = document.querySelectorAll("#torrentFilesTableFixedHeaderDiv .dynamicTableHeader th"); + if (tableHeaders.length > 0) { + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.id = "tristate_cb"; + checkbox.addEventListener("click", switchGlobalCheckboxState); + + const checkboxTH = tableHeaders[0]; + checkboxTH.appendChild(checkbox); + } + + // default sort by name column + if (torrentFilesTable.getSortedColumn() === null) + torrentFilesTable.setSortedColumn("name"); + + // listen for changes to torrentFilesFilterInput + $("torrentFilesFilterInput").addEventListener("input", () => { + clearTimeout(torrentFilesFilterInputTimer); + + const value = $("torrentFilesFilterInput").value; + torrentFilesTable.setFilter(value); + + torrentFilesFilterInputTimer = setTimeout(() => { + torrentFilesFilterInputTimer = -1; + + torrentFilesTable.updateTable(); + + if (value.trim() === "") + collapseAllNodes(); + else + expandAllNodes(); + }, window.qBittorrent.Misc.FILTER_INPUT_DELAY); + }); + + return torrentFilesTable; + }; + + const clearFilterInputTimer = () => { + clearTimeout(torrentFilesFilterInputTimer); + torrentFilesFilterInputTimer = -1; + }; + + /** + * Show/hide a node's row + */ + const _hideNode = (node, shouldHide) => { + const span = $("filesTablefileName" + node.rowId); + // span won't exist if row has been filtered out + if (span === null) + return; + const rowElem = span.parentElement.parentElement; + if (shouldHide) + rowElem.addClass("invisible"); + else + rowElem.removeClass("invisible"); + }; + + /** + * Update a node's collapsed state and icon + */ + const _updateNodeState = (node, isCollapsed) => { + const span = $("filesTablefileName" + node.rowId); + // span won't exist if row has been filtered out + if (span === null) + return; + const td = span.parentElement; + + // store collapsed state + td.setAttribute("data-collapsed", isCollapsed); + + // rotate the collapse icon + const collapseIcon = td.getElementsByClassName("filesTableCollapseIcon")[0]; + if (isCollapsed) + collapseIcon.addClass("rotate"); + else + collapseIcon.removeClass("rotate"); + }; + + const _isCollapsed = (node) => { + const span = $("filesTablefileName" + node.rowId); + if (span === null) + return true; + + const td = span.parentElement; + return td.getAttribute("data-collapsed") === "true"; + }; + + const expandNode = (node) => { + _collapseNode(node, false, false, false); + }; + + const collapseNode = (node) => { + _collapseNode(node, true, false, false); + }; + + const expandAllNodes = () => { + const root = torrentFilesTable.getRoot(); + root.children.each((node) => { + node.children.each((child) => { + _collapseNode(child, false, true, false); + }); + }); + }; + + const collapseAllNodes = () => { + const root = torrentFilesTable.getRoot(); + root.children.each((node) => { + node.children.each((child) => { + _collapseNode(child, true, true, false); + }); + }); + }; + + /** + * Collapses a folder node with the option to recursively collapse all children + * @param {FolderNode} node the node to collapse/expand + * @param {boolean} shouldCollapse true if the node should be collapsed, false if it should be expanded + * @param {boolean} applyToChildren true if the node's children should also be collapsed, recursively + * @param {boolean} isChildNode true if the current node is a child of the original node we collapsed/expanded + */ + const _collapseNode = (node, shouldCollapse, applyToChildren, isChildNode) => { + if (!node.isFolder) + return; + + const shouldExpand = !shouldCollapse; + const isNodeCollapsed = _isCollapsed(node); + const nodeInCorrectState = ((shouldCollapse && isNodeCollapsed) || (shouldExpand && !isNodeCollapsed)); + const canSkipNode = (isChildNode && (!applyToChildren || nodeInCorrectState)); + if (!isChildNode || applyToChildren || !canSkipNode) + _updateNodeState(node, shouldCollapse); + + node.children.each((child) => { + _hideNode(child, shouldCollapse); + + if (!child.isFolder) + return; + + // don't expand children that have been independently collapsed, unless applyToChildren is true + const shouldExpandChildren = (shouldExpand && applyToChildren); + const isChildCollapsed = _isCollapsed(child); + if (!shouldExpandChildren && isChildCollapsed) + return; + + _collapseNode(child, shouldCollapse, applyToChildren, true); + }); + }; + + return exports(); +})(); +Object.freeze(window.qBittorrent.TorrentContent); diff --git a/src/webui/www/private/upload.html b/src/webui/www/private/upload.html deleted file mode 100644 index 87f03871dd1f..000000000000 --- a/src/webui/www/private/upload.html +++ /dev/null @@ -1,183 +0,0 @@ - - - - - - QBT_TR(Upload local torrent)QBT_TR[CONTEXT=HttpServer] - - - - - - - - - -
-
- -
-
- QBT_TR(Torrent options)QBT_TR[CONTEXT=AddNewTorrentDialog] - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - - -
- - - -
- - - -
- - -
- - -
-
- - - - -
- - - -
- - - -
- - - -
- - - -
- - - -
- - - -
- - - - -
- - - - -
-
- -
-
-
-
- - - - - diff --git a/src/webui/www/private/views/rss.html b/src/webui/www/private/views/rss.html index 2fc0cd12fe73..39c27e89f398 100644 --- a/src/webui/www/private/views/rss.html +++ b/src/webui/www/private/views/rss.html @@ -292,10 +292,10 @@ menu: "rssArticleMenu", actions: { Download: (el) => { - let dlString = ""; - for (const rowID of rssArticleTable.selectedRows) - dlString += `${rssArticleTable.getRow(rowID).full_data.torrentURL}\n`; - showDownloadPage([dlString]); + for (const rowID of rssArticleTable.selectedRows) { + const { name, torrentURL } = rssArticleTable.getRow(rowID).full_data; + window.qBittorrent.Client.createDownloadWindow(name, torrentURL); + } }, OpenNews: (el) => { for (const rowID of rssArticleTable.selectedRows) diff --git a/src/webui/www/webui.qrc b/src/webui/www/webui.qrc index ae1ddfeee5de..b45be351217e 100644 --- a/src/webui/www/webui.qrc +++ b/src/webui/www/webui.qrc @@ -1,6 +1,7 @@ private/addpeers.html + private/addtorrent.html private/addtrackers.html private/addwebseeds.html private/confirmfeeddeletion.html @@ -391,10 +392,10 @@ private/rename_file.html private/rename_files.html private/rename_rule.html + private/scripts/addtorrent.js private/scripts/cache.js private/scripts/client.js private/scripts/contextmenu.js - private/scripts/download.js private/scripts/dynamicTable.js private/scripts/file-tree.js private/scripts/filesystem.js @@ -417,9 +418,9 @@ private/scripts/rename-files.js private/scripts/search.js private/scripts/speedslider.js + private/scripts/torrent-content.js private/setlocation.html private/shareratio.html - private/upload.html private/uploadlimit.html private/views/about.html private/views/aboutToolbar.html