Skip to content

Commit 9ff82ec

Browse files
committed
Add opt-in deterministic hash directory for torrent payloads
When enabled, newly added torrents are stored under a stable top-level directory named from the torrent and a short torrent-ID hash (for example "Show [qb-a19f83c275d1]"). This avoids same-name path collisions without scanning other torrents. Existing torrents are unchanged until the user applies the conversion from the transfer list. If the target hash directory already exists, it is replaced entirely after confirmation. Per-torrent preserve original name remains available for intentional cross-seeding. Closes #12842
1 parent 78bf5f0 commit 9ff82ec

24 files changed

Lines changed: 694 additions & 3 deletions

src/base/CMakeLists.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ add_library(qbt_base STATIC
4747
bittorrent/torrentdescriptor.h
4848
bittorrent/torrentimpl.h
4949
bittorrent/torrentinfo.h
50+
bittorrent/toplevelpayload.h
5051
bittorrent/tracker.h
5152
bittorrent/trackerentry.h
5253
bittorrent/trackerentrystatus.h
@@ -165,6 +166,7 @@ add_library(qbt_base STATIC
165166
bittorrent/torrentdescriptor.cpp
166167
bittorrent/torrentimpl.cpp
167168
bittorrent/torrentinfo.cpp
169+
bittorrent/toplevelpayload.cpp
168170
bittorrent/tracker.cpp
169171
bittorrent/trackerentry.cpp
170172
bittorrent/trackerentrystatus.cpp

src/base/bittorrent/addtorrentparams.cpp

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ BitTorrent::AddTorrentParams BitTorrent::parseAddTorrentParams(const QJsonObject
122122
.filePaths = {},
123123
.filePriorities = {},
124124
.skipChecking = jsonObj.value(PARAM_SKIPCHECKING).toBool(),
125+
.preserveOriginalPayloadName = jsonObj.value(u"preserveOriginalPayloadName"_s).toBool(),
125126
.contentLayout = getOptionalEnum<TorrentContentLayout>(jsonObj, PARAM_CONTENTLAYOUT),
126127
.useAutoTMM = getOptionalBool(jsonObj, PARAM_AUTOTMM),
127128
.uploadLimit = jsonObj.value(PARAM_UPLOADLIMIT).toInt(-1),
@@ -155,6 +156,7 @@ QJsonObject BitTorrent::serializeAddTorrentParams(const AddTorrentParams &params
155156
{PARAM_OPERATINGMODE, Utils::String::fromEnum(params.addForced
156157
? TorrentOperatingMode::Forced : TorrentOperatingMode::AutoManaged)},
157158
{PARAM_SKIPCHECKING, params.skipChecking},
159+
{u"preserveOriginalPayloadName"_s, params.preserveOriginalPayloadName},
158160
{PARAM_UPLOADLIMIT, params.uploadLimit},
159161
{PARAM_DOWNLOADLIMIT, params.downloadLimit},
160162
{PARAM_RATIOLIMIT, params.shareLimits.ratioLimit},

src/base/bittorrent/addtorrentparams.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@ namespace BitTorrent
6464
PathList filePaths; // used if TorrentInfo is set
6565
QList<DownloadPriority> filePriorities; // used if TorrentInfo is set
6666
bool skipChecking = false;
67+
// When Session::isAppendHashToPayloadNamesEnabled(), skip deterministic hash suffix for this torrent.
68+
bool preserveOriginalPayloadName = false;
6769
std::optional<BitTorrent::TorrentContentLayout> contentLayout;
6870
std::optional<bool> useAutoTMM;
6971
int uploadLimit = -1;

src/base/bittorrent/bencoderesumedatastorage.cpp

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,7 @@ BitTorrent::LoadResumeDataResult BitTorrent::BencodeResumeDataStorage::loadTorre
245245
torrentParams.comment = fromLTString(resumeDataRoot.dict_find_string_value("qBt-comment"));
246246
torrentParams.hasFinishedStatus = resumeDataRoot.dict_find_int_value("qBt-seedStatus");
247247
torrentParams.firstLastPiecePriority = resumeDataRoot.dict_find_int_value("qBt-firstLastPiecePriority");
248+
torrentParams.appendHashToPayloadName = resumeDataRoot.dict_find_int_value("qBt-appendHashToPayloadName");
248249

249250
const lt::string_view ratioLimitString = resumeDataRoot.dict_find_string_value("qBt-ratioLimit");
250251
torrentParams.shareLimits = {
@@ -465,6 +466,7 @@ void BitTorrent::BencodeResumeDataStorage::Worker::store(const TorrentID &id, co
465466
data["qBt-seedStatus"] = resumeData.hasFinishedStatus;
466467
data["qBt-contentLayout"] = Utils::String::fromEnum(resumeData.contentLayout).toStdString();
467468
data["qBt-firstLastPiecePriority"] = resumeData.firstLastPiecePriority;
469+
data["qBt-appendHashToPayloadName"] = resumeData.appendHashToPayloadName;
468470
data["qBt-stopCondition"] = Utils::String::fromEnum(resumeData.stopCondition).toStdString();
469471

470472
if (!resumeData.sslParameters.certificate.isNull())

src/base/bittorrent/dbresumedatastorage.cpp

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ namespace
7272
{
7373
const QString DB_CONNECTION_NAME = u"ResumeDataStorage"_s;
7474

75-
const int DB_VERSION = 10;
75+
const int DB_VERSION = 11;
7676

7777
const QString DB_TABLE_META = u"meta"_s;
7878
const QString DB_TABLE_TORRENTS = u"torrents"_s;
@@ -147,6 +147,7 @@ namespace
147147
const Column DB_COLUMN_SHARE_LIMIT_ACTION = makeColumn(u"share_limit_action"_s);
148148
const Column DB_COLUMN_HAS_OUTER_PIECES_PRIORITY = makeColumn(u"has_outer_pieces_priority"_s);
149149
const Column DB_COLUMN_HAS_SEED_STATUS = makeColumn(u"has_seed_status"_s);
150+
const Column DB_COLUMN_APPEND_HASH_TO_PAYLOAD_NAME = makeColumn(u"append_hash_to_payload_name"_s);
150151
const Column DB_COLUMN_OPERATING_MODE = makeColumn(u"operating_mode"_s);
151152
const Column DB_COLUMN_STOPPED = makeColumn(u"stopped"_s);
152153
const Column DB_COLUMN_STOP_CONDITION = makeColumn(u"stop_condition"_s);
@@ -479,6 +480,7 @@ void BitTorrent::DBResumeDataStorage::createDB() const
479480
makeColumnDefinition(DB_COLUMN_SHARE_LIMIT_ACTION, u"TEXT NOT NULL DEFAULT `Default`"_s),
480481
makeColumnDefinition(DB_COLUMN_HAS_OUTER_PIECES_PRIORITY, u"INTEGER NOT NULL"_s),
481482
makeColumnDefinition(DB_COLUMN_HAS_SEED_STATUS, u"INTEGER NOT NULL"_s),
483+
makeColumnDefinition(DB_COLUMN_APPEND_HASH_TO_PAYLOAD_NAME, u"INTEGER NOT NULL DEFAULT 0"_s),
482484
makeColumnDefinition(DB_COLUMN_OPERATING_MODE, u"TEXT NOT NULL"_s),
483485
makeColumnDefinition(DB_COLUMN_STOPPED, u"INTEGER NOT NULL"_s),
484486
makeColumnDefinition(DB_COLUMN_STOP_CONDITION, u"TEXT NOT NULL DEFAULT `None`"_s),
@@ -593,6 +595,9 @@ void BitTorrent::DBResumeDataStorage::updateDB(const int fromVersion) const
593595
if (fromVersion <= 9)
594596
addColumn(DB_TABLE_TORRENTS, DB_COLUMN_SHARE_LIMITS_MODE, u"TEXT NOT NULL DEFAULT `Default`"_s);
595597

598+
if (fromVersion <= 10)
599+
addColumn(DB_TABLE_TORRENTS, DB_COLUMN_APPEND_HASH_TO_PAYLOAD_NAME, u"INTEGER NOT NULL DEFAULT 0"_s);
600+
596601
const QString updateMetaVersionQuery = makeUpdateStatement(DB_TABLE_META, {DB_COLUMN_NAME, DB_COLUMN_VALUE});
597602
if (!query.prepare(updateMetaVersionQuery))
598603
throw RuntimeError(query.lastError().text());
@@ -643,6 +648,7 @@ LoadResumeDataResult DBResumeDataStorage::parseQueryResultRow(const QSqlQuery &q
643648
}
644649
resumeData.hasFinishedStatus = query.value(DB_COLUMN_HAS_SEED_STATUS.name).toBool();
645650
resumeData.firstLastPiecePriority = query.value(DB_COLUMN_HAS_OUTER_PIECES_PRIORITY.name).toBool();
651+
resumeData.appendHashToPayloadName = query.value(DB_COLUMN_APPEND_HASH_TO_PAYLOAD_NAME.name).toBool();
646652
resumeData.shareLimits = {
647653
.ratioLimit = query.value(DB_COLUMN_RATIO_LIMIT.name).toInt() / 1000.0,
648654
.seedingTimeLimit = query.value(DB_COLUMN_SEEDING_TIME_LIMIT.name).toInt(),
@@ -873,6 +879,7 @@ StoreJob::StoreJob(const TorrentID &torrentID, LoadTorrentParams resumeData)
873879
DB_COLUMN_SHARE_LIMIT_ACTION,
874880
DB_COLUMN_HAS_OUTER_PIECES_PRIORITY,
875881
DB_COLUMN_HAS_SEED_STATUS,
882+
DB_COLUMN_APPEND_HASH_TO_PAYLOAD_NAME,
876883
DB_COLUMN_OPERATING_MODE,
877884
DB_COLUMN_STOPPED,
878885
DB_COLUMN_STOP_CONDITION,
@@ -938,6 +945,7 @@ StoreJob::StoreJob(const TorrentID &torrentID, LoadTorrentParams resumeData)
938945
query.bindValue(DB_COLUMN_SHARE_LIMIT_ACTION.placeholder, Utils::String::fromEnum(m_resumeData.shareLimits.action));
939946
query.bindValue(DB_COLUMN_HAS_OUTER_PIECES_PRIORITY.placeholder, m_resumeData.firstLastPiecePriority);
940947
query.bindValue(DB_COLUMN_HAS_SEED_STATUS.placeholder, m_resumeData.hasFinishedStatus);
948+
query.bindValue(DB_COLUMN_APPEND_HASH_TO_PAYLOAD_NAME.placeholder, m_resumeData.appendHashToPayloadName);
941949
query.bindValue(DB_COLUMN_OPERATING_MODE.placeholder, Utils::String::fromEnum(m_resumeData.operatingMode));
942950
query.bindValue(DB_COLUMN_STOPPED.placeholder, m_resumeData.stopped);
943951
query.bindValue(DB_COLUMN_STOP_CONDITION.placeholder, Utils::String::fromEnum(m_resumeData.stopCondition));

src/base/bittorrent/loadtorrentparams.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,9 @@ namespace BitTorrent
5656
bool useAutoTMM = false;
5757
bool firstLastPiecePriority = false;
5858
bool hasFinishedStatus = false;
59+
// Set at add time from Session option + per-torrent preserve override.
60+
// Not re-evaluated later so existing torrents (and pending magnets) are not renamed when the option changes.
61+
bool appendHashToPayloadName = false;
5962
bool stopped = false;
6063
Torrent::StopCondition stopCondition = Torrent::StopCondition::None;
6164

src/base/bittorrent/session.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,8 @@ namespace BitTorrent
220220
virtual void setTorrentStopCondition(Torrent::StopCondition stopCondition) = 0;
221221
virtual TorrentContentLayout torrentContentLayout() const = 0;
222222
virtual void setTorrentContentLayout(TorrentContentLayout value) = 0;
223+
virtual bool isAppendHashToPayloadNamesEnabled() const = 0;
224+
virtual void setAppendHashToPayloadNamesEnabled(bool enabled) = 0;
223225
virtual bool isTrackerEnabled() const = 0;
224226
virtual void setTrackerEnabled(bool enabled) = 0;
225227
virtual bool isAppendExtensionEnabled() const = 0;

src/base/bittorrent/sessionimpl.cpp

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,9 @@
9898
#include "base/version.h"
9999
#include "bandwidthscheduler.h"
100100
#include "bencoderesumedatastorage.h"
101+
#include "common.h"
101102
#include "customstorage.h"
103+
#include "toplevelpayload.h"
102104
#include "dbresumedatastorage.h"
103105
#include "downloadpriority.h"
104106
#include "extensiondata.h"
@@ -565,6 +567,7 @@ SessionImpl::SessionImpl(QObject *parent)
565567
, m_isAddTorrentStopped(BITTORRENT_SESSION_KEY(u"AddTorrentStopped"_s), false)
566568
, m_torrentStopCondition(BITTORRENT_SESSION_KEY(u"TorrentStopCondition"_s), Torrent::StopCondition::None)
567569
, m_torrentContentLayout(BITTORRENT_SESSION_KEY(u"TorrentContentLayout"_s), TorrentContentLayout::Original)
570+
, m_appendHashToPayloadNames(BITTORRENT_SESSION_KEY(u"AppendHashToPayloadNames"_s), false)
568571
, m_isAppendExtensionEnabled(BITTORRENT_SESSION_KEY(u"AddExtensionToIncompleteFiles"_s), false)
569572
, m_isUnwantedFolderEnabled(BITTORRENT_SESSION_KEY(u"UseUnwantedFolder"_s), false)
570573
, m_refreshInterval(BITTORRENT_SESSION_KEY(u"RefreshInterval"_s), 1500)
@@ -2605,7 +2608,6 @@ bool SessionImpl::removeTorrent(const TorrentID &id, const TorrentRemoveOption d
26052608

26062609
const TorrentID torrentID = torrent->id();
26072610
const QString torrentName = torrent->name();
2608-
26092611
qDebug("Deleting torrent with ID: %s", qUtf8Printable(torrentID.toString()));
26102612
emit torrentAboutToBeRemoved(torrent);
26112613

@@ -2788,6 +2790,8 @@ LoadTorrentParams SessionImpl::initLoadTorrentParams(const AddTorrentParams &add
27882790
loadTorrentParams.name = addTorrentParams.name;
27892791
loadTorrentParams.firstLastPiecePriority = addTorrentParams.firstLastPiecePriority;
27902792
loadTorrentParams.hasFinishedStatus = addTorrentParams.skipChecking; // do not react on 'torrent_finished_alert' when skipping
2793+
loadTorrentParams.appendHashToPayloadName = isAppendHashToPayloadNamesEnabled()
2794+
&& !addTorrentParams.preserveOriginalPayloadName;
27912795
loadTorrentParams.contentLayout = addTorrentParams.contentLayout.value_or(torrentContentLayout());
27922796
loadTorrentParams.operatingMode = (addTorrentParams.addForced ? TorrentOperatingMode::Forced : TorrentOperatingMode::AutoManaged);
27932797
loadTorrentParams.stopped = addTorrentParams.addStopped.value_or(isAddTorrentStopped());
@@ -2957,6 +2961,11 @@ bool SessionImpl::addTorrent_impl(const TorrentDescriptor &torrentDescr, const A
29572961
loadTorrentParams.name = contentName;
29582962
}
29592963

2964+
// Optional deterministic payload hash naming (decision fixed at add time).
2965+
// Always use the torrent info name so magnet dn= / display name cannot diverge.
2966+
if (loadTorrentParams.appendHashToPayloadName)
2967+
filePaths = applyPayloadHashNaming(std::move(filePaths), id, torrentInfo.name());
2968+
29602969
const auto nativeIndexes = torrentInfo.nativeIndexes();
29612970

29622971
Q_ASSERT(addTorrentParams.filePriorities.isEmpty() || (addTorrentParams.filePriorities.size() == nativeIndexes.size()));
@@ -3080,7 +3089,7 @@ bool SessionImpl::addTorrent_impl(const TorrentDescriptor &torrentDescr, const A
30803089
};
30813090

30823091
resolveFileNames().then(this
3083-
, [this, id, torrentDescr, loadTorrentParams = std::move(loadTorrentParams)](const FileSearchResult &result) mutable
3092+
, [this, torrentDescr, loadTorrentParams = std::move(loadTorrentParams)](const FileSearchResult &result) mutable
30843093
{
30853094
lt::add_torrent_params &p = loadTorrentParams.ltAddTorrentParams;
30863095

@@ -5982,6 +5991,16 @@ void SessionImpl::setTorrentContentLayout(const TorrentContentLayout value)
59825991
m_torrentContentLayout = value;
59835992
}
59845993

5994+
bool SessionImpl::isAppendHashToPayloadNamesEnabled() const
5995+
{
5996+
return m_appendHashToPayloadNames;
5997+
}
5998+
5999+
void SessionImpl::setAppendHashToPayloadNamesEnabled(const bool enabled)
6000+
{
6001+
m_appendHashToPayloadNames = enabled;
6002+
}
6003+
59856004
// Read alerts sent by libtorrent session
59866005
void SessionImpl::readAlerts()
59876006
{

src/base/bittorrent/sessionimpl.h

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131

3232
#include <chrono>
3333
#include <functional>
34+
#include <optional>
3435
#include <utility>
3536
#include <vector>
3637

@@ -54,6 +55,7 @@
5455
#include "addtorrentparams.h"
5556
#include "cachestatus.h"
5657
#include "categoryoptions.h"
58+
#include "infohash.h"
5759
#include "session.h"
5860
#include "sessionstatus.h"
5961
#include "torrentinfo.h"
@@ -203,6 +205,8 @@ namespace BitTorrent
203205
void setTorrentStopCondition(Torrent::StopCondition stopCondition) override;
204206
TorrentContentLayout torrentContentLayout() const override;
205207
void setTorrentContentLayout(TorrentContentLayout value) override;
208+
bool isAppendHashToPayloadNamesEnabled() const override;
209+
void setAppendHashToPayloadNamesEnabled(bool enabled) override;
206210
bool isTrackerEnabled() const override;
207211
void setTrackerEnabled(bool enabled) override;
208212
bool isAppendExtensionEnabled() const override;
@@ -738,6 +742,7 @@ namespace BitTorrent
738742
CachedSettingValue<bool> m_isAddTorrentStopped;
739743
CachedSettingValue<Torrent::StopCondition> m_torrentStopCondition;
740744
CachedSettingValue<TorrentContentLayout> m_torrentContentLayout;
745+
CachedSettingValue<bool> m_appendHashToPayloadNames;
741746
CachedSettingValue<bool> m_isAppendExtensionEnabled;
742747
CachedSettingValue<bool> m_isUnwantedFolderEnabled;
743748
CachedSettingValue<int> m_refreshInterval;
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
/*
2+
* Bittorrent Client using Qt and libtorrent.
3+
* Copyright (C) 2026 The qBittorrent project
4+
*
5+
* This program is free software; you can redistribute it and/or
6+
* modify it under the terms of the GNU General Public License
7+
* as published by the Free Software Foundation; either version 2
8+
* of the License, or (at your option) any later version.
9+
*
10+
* This program is distributed in the hope that it will be useful,
11+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
* GNU General Public License for more details.
14+
*
15+
* You should have received a copy of the GNU General Public License
16+
* along with this program; if not, write to the Free Software
17+
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18+
*
19+
* In addition, as a special exception, the copyright holders give permission to
20+
* link this program with the OpenSSL project's "OpenSSL" library (or with
21+
* modified versions of it that use the same license as the "OpenSSL" library),
22+
* and distribute the linked executables. You must obey the GNU General Public
23+
* License in all respects for all of the code used other than "OpenSSL". If you
24+
* modify file(s), you may extend this exception to your version of the file(s),
25+
* but you are not obligated to do so. If you do not wish to do so, delete this
26+
* exception statement from your version.
27+
*/
28+
29+
#include "toplevelpayload.h"
30+
31+
#include <algorithm>
32+
33+
#include "base/global.h"
34+
#include "base/utils/fs.h"
35+
36+
namespace
37+
{
38+
constexpr int HASH_TAG_HEX_LEN = 12;
39+
constexpr int MAX_COMPONENT_LEN = 255;
40+
41+
PathList renameRootFolder(PathList filePaths, const QString &oldName, const QString &newName)
42+
{
43+
const Path oldRoot {oldName};
44+
const Path newRoot {newName};
45+
for (Path &filePath : filePaths)
46+
{
47+
if (filePath == oldRoot)
48+
filePath = newRoot;
49+
else if (filePath.hasAncestor(oldRoot))
50+
filePath = newRoot / oldRoot.relativePathOf(filePath);
51+
}
52+
return filePaths;
53+
}
54+
55+
// Sanitize and append tag, leaving room for the tag within MAX_COMPONENT_LEN.
56+
QString hashedComponent(const QString &name, const QString &tag)
57+
{
58+
QString base = Utils::Fs::toValidFileName(name.trimmed());
59+
if (base.isEmpty())
60+
base = u"Torrent"_s;
61+
62+
if (base.endsWith(tag))
63+
return base;
64+
65+
const int maxBaseLen = std::max(1, MAX_COMPONENT_LEN - static_cast<int>(tag.size()));
66+
if (base.size() > maxBaseLen)
67+
base = base.left(maxBaseLen);
68+
69+
return base + tag;
70+
}
71+
72+
QString originalNameForHashDir(const PathList &filePaths, const QString &torrentName, const QString &tag)
73+
{
74+
QString base = torrentName.trimmed();
75+
if (base.isEmpty())
76+
{
77+
const Path root = Path::findRootFolder(filePaths);
78+
if (!root.isEmpty())
79+
base = root.toString();
80+
else if (!filePaths.isEmpty())
81+
base = filePaths.at(0).filename();
82+
}
83+
if (base.isEmpty())
84+
base = u"Torrent"_s;
85+
86+
// Avoid "Name [qb-… ] [qb-…]" if the display/root name already carries this tag.
87+
if (base.endsWith(tag))
88+
base.chop(tag.size());
89+
90+
return base;
91+
}
92+
}
93+
94+
QString BitTorrent::payloadHashTag(const TorrentID &id)
95+
{
96+
return u" [qb-"_s + id.toString().left(HASH_TAG_HEX_LEN) + u']';
97+
}
98+
99+
QString BitTorrent::payloadHashDirectoryName(const TorrentID &id, const QString &originalName)
100+
{
101+
return hashedComponent(originalName, payloadHashTag(id));
102+
}
103+
104+
PathList BitTorrent::applyPayloadHashNaming(PathList filePaths, const TorrentID &id, const QString &torrentName)
105+
{
106+
if (filePaths.isEmpty())
107+
return filePaths;
108+
109+
const QString tag = payloadHashTag(id);
110+
const QString hashDir = payloadHashDirectoryName(id, originalNameForHashDir(filePaths, torrentName, tag));
111+
112+
const Path rootFolder = Path::findRootFolder(filePaths);
113+
if (!rootFolder.isEmpty())
114+
{
115+
// Already under the correct hash directory.
116+
if (rootFolder.toString() == hashDir)
117+
return filePaths;
118+
// Rename existing top-level folder (e.g. Show/ → Show [qb-HASH]/).
119+
return renameRootFolder(std::move(filePaths), rootFolder.toString(), hashDir);
120+
}
121+
122+
// Single file or rootless multi-file: wrap under the hash directory.
123+
Path::addRootFolder(filePaths, Path(hashDir));
124+
return filePaths;
125+
}

0 commit comments

Comments
 (0)