From 73b40849b47baeab8eb75989d87805051e7318fa Mon Sep 17 00:00:00 2001 From: Thomas Piccirello Date: Sat, 6 Jul 2024 22:38:33 -0700 Subject: [PATCH] Add WebAPI for fetching torrent metadata --- src/webui/api/torrentscontroller.cpp | 295 +++++++++++++++++++++++++++ src/webui/api/torrentscontroller.h | 13 ++ 2 files changed, 308 insertions(+) diff --git a/src/webui/api/torrentscontroller.cpp b/src/webui/api/torrentscontroller.cpp index 0ad56b0941c6..a89235ed6f85 100644 --- a/src/webui/api/torrentscontroller.cpp +++ b/src/webui/api/torrentscontroller.cpp @@ -31,6 +31,7 @@ #include #include +#include #include #include #include @@ -54,6 +55,7 @@ #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" @@ -127,6 +129,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; @@ -250,6 +257,89 @@ namespace idList << BitTorrent::TorrentID::fromString(hash); return idList; } + + QJsonObject serializeTorrentInfo(const BitTorrent::TorrentInfo &info, const QList &trackers) + { + QJsonArray trackersArr; + for (const auto &tracker : trackers) + { + trackersArr << QJsonObject + { + {KEY_TRACKER_URL, tracker.url}, + {KEY_TRACKER_TIER, tracker.tier} + }; + } + + QJsonArray files; + for (int fileIndex = 0; fileIndex < info.filesCount(); ++fileIndex) + { + files << QJsonObject + { + {KEY_FILE_INDEX, fileIndex}, + {KEY_FILE_NAME, info.filePath(fileIndex).toString()}, + {KEY_FILE_SIZE, info.fileSize(fileIndex)} + }; + } + + QJsonArray webseeds; + for (const QUrl &webseed : info.urlSeeds()) + { + webseeds << QJsonObject + { + {KEY_WEBSEED_URL, webseed.toString()} + }; + } + + 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_TORRENTINFO_TRACKERS, trackersArr}, + {KEY_PROP_TOTAL_SIZE, info.totalSize()}, + {KEY_PROP_PIECES_NUM, info.piecesCount()}, + {KEY_PROP_PIECE_SIZE, info.pieceLength()}, + {KEY_PROP_CREATED_BY, info.creator()}, + {KEY_PROP_PRIVATE, info.isPrivate()}, + {KEY_PROP_CREATION_DATE, Utils::DateTime::toSecsSinceEpoch(info.creationDate())}, + {KEY_PROP_COMMENT, info.comment()}, + {KEY_TORRENTINFO_FILES, files}, + {KEY_TORRENTINFO_WEBSEEDS, webseeds} + }; + } + + QJsonObject serializeTorrentInfo(const BitTorrent::TorrentDescriptor &torrentDescr) + { + const BitTorrent::TorrentInfo &info = torrentDescr.info().value(); + + QList trackers = torrentDescr.trackers(); + // these fields are inconsistent - sometimes trackers are stored in TorrentInfo, sometimes in TorrentDescriptor + if (info.trackers().size() > trackers.size()) + trackers = info.trackers(); + + return serializeTorrentInfo(info, trackers); + } + + QJsonObject serializeTorrentInfo(const BitTorrent::Torrent &torrent) + { + // the TorrentInfo returned by Torrent::info() contains an empty list of trackers + // we must fetch the trackers directly via Torrent::trackers() + QList trackers; + for (const BitTorrent::TrackerEntryStatus &tracker : torrent.trackers()) + trackers << BitTorrent::TrackerEntry + { + .url = tracker.url, + .tier = tracker.tier + }; + + return serializeTorrentInfo(torrent.info(), trackers); + } +} + +TorrentsController::TorrentsController(IApplication *app, QObject *parent) + : APIController(app, parent) +{ + connect(BitTorrent::Session::instance(), &BitTorrent::Session::metadataDownloaded, this, &TorrentsController::onMetadataDownloaded); } void TorrentsController::countAction() @@ -1523,3 +1613,208 @@ void TorrentsController::setSSLParametersAction() torrent->setSSLParameters(sslParams); } + +void TorrentsController::metadataAction() +{ + const QStringList sources = params()[u"sources"_s].split(u',', Qt::SkipEmptyParts); + const DataMap &uploadedTorrents = data(); + // must provide some value to parse + if (sources.size() == 0 && uploadedTorrents.size() == 0) + throw APIError(APIErrorType::BadParams, tr("Must specify 'sources' or torrent file")); + + QList cookies; + const QString cookie = params()[u"cookie"_s]; + if (!cookie.isEmpty()) + { + for (QString cookieStr : cookie.split(u"; "_s)) + { + cookieStr = cookieStr.trimmed(); + int index = cookieStr.indexOf(u'='); + if (index > 1) + { + QByteArray name = cookieStr.left(index).toLatin1(); + QByteArray value = cookieStr.right(cookieStr.length() - index - 1).toLatin1(); + cookies += QNetworkCookie(name, value); + } + } + } + + QJsonObject result; + for (const QString &sourceStr : sources) + { + const QString source = sourceStr.trimmed(); + + const auto iter = m_torrentSource.find(source); + // metadata has already been requested + if (iter != m_torrentSource.end()) + { + const BitTorrent::InfoHash &infoHash = iter.value(); + // metadata has already been downloaded + if (m_torrentMetadata.contains(infoHash)) + { + const BitTorrent::TorrentDescriptor &torrentDescr = m_torrentMetadata.value(infoHash); + result.insert(source, serializeTorrentInfo(torrentDescr)); + } + + continue; + } + + // http(s) url + if (Net::DownloadManager::hasSupportedScheme(source)) + { + qDebug("Fetching torrent %s", qUtf8Printable(source)); + const auto *pref = Preferences::instance(); + + if (cookies.size() > 0) + Net::DownloadManager::instance()->setCookiesFromUrl(cookies, QUrl::fromEncoded(source.toUtf8())); + + Net::DownloadManager::instance()->download(Net::DownloadRequest(source).limit(pref->getTorrentFileSizeLimit()) + , pref->useProxyForGeneralPurposes(), this, &TorrentsController::onDownloadFinished); + + m_torrentSource.insert(source, {}); + + continue; + } + + // magnet link or info hash + if (const auto parseResult = BitTorrent::TorrentDescriptor::parse(source)) + { + const BitTorrent::TorrentDescriptor &torrentDescr = parseResult.value(); + const BitTorrent::InfoHash &infoHash = torrentDescr.infoHash(); + + auto *session = BitTorrent::Session::instance(); + const BitTorrent::Torrent *const torrent = session->findTorrent(infoHash); + // torrent already exists in transfer list + if (torrent) + { + // torrent's metadata may not have downloaded yet + if (torrent->info().isValid()) + result.insert(source, serializeTorrentInfo(*torrent)); + + continue; + } + + + // metadata may have already been downloaded under a different source URI + if (m_torrentMetadata.contains(infoHash)) + { + // make torrent available for download (via /add API) using this + m_torrentSource.insert(source, infoHash); + + const BitTorrent::TorrentDescriptor &torrentDescr = m_torrentMetadata.value(infoHash); + result.insert(source, serializeTorrentInfo(torrentDescr)); + + continue; + } + + if (!session->isKnownTorrent(infoHash)) + { + qDebug("Fetching metadata for %s", qUtf8Printable(infoHash.toTorrentID().toString())); + if (!session->downloadMetadata(torrentDescr)) [[unlikely]] + { + // our checks above should prevent ever hitting this + 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_torrentSource.insert(source, infoHash); + } + + continue; + } + + throw APIError(APIErrorType::BadParams, tr("Unable to parse '%1'").arg(source)); + } + + // process uploaded .torrent files + for (auto it = uploadedTorrents.constBegin(); it != uploadedTorrents.constEnd(); ++it) + { + if (const auto loadResult = BitTorrent::TorrentDescriptor::load(it.value())) + { + const BitTorrent::TorrentDescriptor &torrentDescr = loadResult.value(); + const BitTorrent::InfoHash &infoHash = torrentDescr.infoHash(); + + const QString &fileName = it.key(); + m_torrentSource.insert(fileName, infoHash); + m_torrentMetadata.insert(infoHash, torrentDescr); + + result.insert(fileName, serializeTorrentInfo(torrentDescr)); + continue; + } + + throw APIError(APIErrorType::BadData, tr("'%1' is not a valid torrent file.").arg(it.key())); + } + + if (result.size() < sources.size()) + setStatus(202); + + setResult(result); +} + +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_torrentSource.insert(source, infoHash); + m_torrentMetadata.insert(infoHash, torrentDescr); + + return; + } + + qDebug("Unable to parse torrent from %s", qUtf8Printable(source)); + m_torrentSource.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(); + + if (!m_torrentSource.contains(source)) + { + m_torrentSource.insert(source, infoHash); + + if (!BitTorrent::Session::instance()->isKnownTorrent(infoHash) && !m_torrentMetadata.contains(infoHash)) + { + qDebug("Fetching metadata for %s", qUtf8Printable(infoHash.toTorrentID().toString())); + if (!BitTorrent::Session::instance()->downloadMetadata(torrentDescr)) [[unlikely]] + // our checks above should prevent ever hitting this + qDebug("Unable to fetch metadata for %s", qUtf8Printable(infoHash.toTorrentID().toString())); + } + } + + return; + } + + qDebug("Unable to parse magnet URI %s", qUtf8Printable(result.magnetURI)); + m_torrentSource.remove(source); + break; + default: + // allow metadata to be re-downloaded on next request + m_torrentSource.remove(source); + } +} + +void TorrentsController::onMetadataDownloaded(const BitTorrent::TorrentInfo &info) +{ + if (!info.isValid()) [[unlikely]] + return; + + const BitTorrent::InfoHash &infoHash = info.infoHash(); + // only process if a lookup was explicitly initiated via API + if (m_torrentSource.values().contains(infoHash)) + { + BitTorrent::TorrentDescriptor torrentDescr; + torrentDescr.setTorrentInfo(info); + m_torrentMetadata.insert(infoHash, torrentDescr); + } +} diff --git a/src/webui/api/torrentscontroller.h b/src/webui/api/torrentscontroller.h index d731b0de8f5c..06c1646b4a96 100644 --- a/src/webui/api/torrentscontroller.h +++ b/src/webui/api/torrentscontroller.h @@ -28,6 +28,9 @@ #pragma once +#include "base/bittorrent/torrentdescriptor.h" +#include "base/bittorrent/torrentinfo.h" +#include "base/net/downloadmanager.h" #include "apicontroller.h" class TorrentsController : public APIController @@ -38,6 +41,8 @@ class TorrentsController : public APIController public: using APIController::APIController; + explicit TorrentsController(IApplication *app, QObject *parent = nullptr); + private slots: void countAction(); void infoAction(); @@ -91,4 +96,12 @@ private slots: void exportAction(); void SSLParametersAction(); void setSSLParametersAction(); + void metadataAction(); + +private: + void onMetadataDownloaded(const BitTorrent::TorrentInfo &info); + void onDownloadFinished(const Net::DownloadResult &result); + + QHash m_torrentSource; + QHash m_torrentMetadata; };