Skip to content

Commit 8964c2b

Browse files
committed
Allow merge when unique subfolder already exists
On convert, if the destination unique folder is already present, ask for confirmation then proceed with normal per-file renames (matching paths may be overwritten; unrelated files stay). Do not delete the destination directory. Layout is still set only after all renames succeed.
1 parent ceb7aa1 commit 8964c2b

6 files changed

Lines changed: 219 additions & 41 deletions

File tree

src/base/bittorrent/toplevelpayload.cpp

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@
3030

3131
#include <algorithm>
3232

33+
#include <QCoreApplication>
34+
3335
#include "base/global.h"
3436
#include "base/utils/fs.h"
3537

@@ -141,3 +143,46 @@ PathList BitTorrent::applyUniqueSubfolderLayout(PathList filePaths, const Torren
141143
Path::addRootFolder(filePaths, Path(folderName));
142144
return filePaths;
143145
}
146+
147+
BitTorrent::UniqueSubfolderMigrationPlan BitTorrent::makeUniqueSubfolderMigrationPlan(
148+
const PathList &currentPaths, const TorrentID &id, const QString &torrentName
149+
, const Path &storageRoot)
150+
{
151+
UniqueSubfolderMigrationPlan plan;
152+
if (currentPaths.isEmpty())
153+
return plan;
154+
155+
const PathList targetPaths = applyUniqueSubfolderLayout(currentPaths, id, torrentName);
156+
if (targetPaths == currentPaths)
157+
return plan;
158+
159+
if (storageRoot.isEmpty())
160+
{
161+
plan.blocked = true;
162+
plan.blockReason = QCoreApplication::translate("BitTorrent", "Storage location is unknown.");
163+
return plan;
164+
}
165+
166+
const Path uniqueDir = Path::findRootFolder(targetPaths);
167+
if (uniqueDir.isEmpty())
168+
{
169+
plan.blocked = true;
170+
plan.blockReason = QCoreApplication::translate("BitTorrent", "Target unique subfolder is invalid.");
171+
return plan;
172+
}
173+
174+
for (int i = 0; i < targetPaths.size(); ++i)
175+
{
176+
if (targetPaths.at(i) != currentPaths.at(i))
177+
plan.renames.append({.fileIndex = i, .to = targetPaths.at(i)});
178+
}
179+
if (plan.renames.isEmpty())
180+
return plan;
181+
182+
// Existing destination: do not block. UI may confirm a merge (overwrite matching paths only).
183+
const Path destAbs = storageRoot / uniqueDir;
184+
if (destAbs.exists())
185+
plan.existingUniqueFolder = destAbs;
186+
187+
return plan;
188+
}

src/base/bittorrent/toplevelpayload.h

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,16 +46,32 @@ namespace BitTorrent
4646
Path to;
4747
};
4848

49-
// Preflight only. If blocked, show blockReason and do nothing.
49+
// Preflight only — no disk changes.
50+
// When existingUniqueFolder is set, the UI should confirm a merge before start.
5051
struct UniqueSubfolderMigrationPlan
5152
{
5253
QList<UniqueSubfolderRename> renames;
54+
55+
// Absolute path of an existing unique destination (empty if none).
56+
Path existingUniqueFolder;
57+
5358
bool blocked = false;
5459
QString blockReason;
5560

5661
bool isEmpty() const
5762
{
5863
return renames.isEmpty() && !blocked;
5964
}
65+
66+
bool needsConfirmation() const
67+
{
68+
return !existingUniqueFolder.isEmpty();
69+
}
6070
};
71+
72+
// Pure preflight used by TorrentImpl and unit tests.
73+
// storageRoot: absolute save/download location for this torrent.
74+
UniqueSubfolderMigrationPlan makeUniqueSubfolderMigrationPlan(
75+
const PathList &currentPaths, const TorrentID &id, const QString &torrentName
76+
, const Path &storageRoot);
6177
}

src/base/bittorrent/torrentimpl.cpp

Lines changed: 11 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -2897,45 +2897,10 @@ nonstd::expected<void, QString> TorrentImpl::exportToFile(const Path &path) cons
28972897

28982898
UniqueSubfolderMigrationPlan TorrentImpl::planUniqueSubfolderMigration() const
28992899
{
2900-
UniqueSubfolderMigrationPlan plan;
29012900
if (!hasMetadata())
2902-
return plan;
2903-
2904-
const PathList currentPaths = filePaths();
2905-
const PathList targetPaths = applyUniqueSubfolderLayout(currentPaths, id(), info().name());
2906-
if (targetPaths == currentPaths)
2907-
return plan;
2908-
2909-
const Path storageRoot = actualStorageLocation();
2910-
if (storageRoot.isEmpty())
2911-
{
2912-
plan.blocked = true;
2913-
plan.blockReason = tr("Storage location is unknown.");
2914-
return plan;
2915-
}
2916-
2917-
const Path uniqueDir = Path::findRootFolder(targetPaths);
2918-
if (uniqueDir.isEmpty())
2919-
{
2920-
plan.blocked = true;
2921-
plan.blockReason = tr("Target unique subfolder is invalid.");
2922-
return plan;
2923-
}
2924-
2925-
// Bare minimum: refuse if the unique folder already exists. No wipe / use-existing.
2926-
if ((storageRoot / uniqueDir).exists())
2927-
{
2928-
plan.blocked = true;
2929-
plan.blockReason = tr("Unique subfolder already exists: \"%1\".").arg(uniqueDir.toString());
2930-
return plan;
2931-
}
2901+
return {};
29322902

2933-
for (int i = 0; i < targetPaths.size(); ++i)
2934-
{
2935-
if (targetPaths.at(i) != currentPaths.at(i))
2936-
plan.renames.append({.fileIndex = i, .to = targetPaths.at(i)});
2937-
}
2938-
return plan;
2903+
return makeUniqueSubfolderMigrationPlan(filePaths(), id(), info().name(), actualStorageLocation());
29392904
}
29402905

29412906
void TorrentImpl::startUniqueSubfolderMigration(const UniqueSubfolderMigrationPlan &plan)
@@ -3000,7 +2965,15 @@ void TorrentImpl::finishUniqueSubfolderMigration()
30002965
}
30012966

30022967
forceRecheck();
3003-
emit uniqueSubfolderMigrationFinished(false, tr("Rename failed. Layout was not updated."));
2968+
2969+
QStringList failed;
2970+
failed.reserve(job.failedFileIndexes.size());
2971+
for (const int index : asConst(job.failedFileIndexes))
2972+
failed.append(QString::number(index));
2973+
2974+
emit uniqueSubfolderMigrationFinished(false
2975+
, tr("Rename failed for file index(es): %1. Layout was not updated. A recheck was started.")
2976+
.arg(failed.join(u", "_s)));
30042977
}
30052978

30062979
QFuture<QList<PeerInfo>> TorrentImpl::fetchPeerInfo() const

src/gui/optionsdialog.cpp

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -587,7 +587,8 @@ void OptionsDialog::loadDownloadsTabOptions()
587587
+ u"</p><p><b>" + tr("Create unique subfolder") + u"</b> - "
588588
+ tr("Put content in a unique folder with a short hash suffix (e.g. Show a19f83c275d1). "
589589
"Avoids name collisions; breaks path-based cross-seeding. "
590-
"Existing torrents stay as-is until converted via right-click.")
590+
"Existing torrents stay as-is until converted via right-click "
591+
"(if the folder already exists, you can confirm a merge that replaces matching files).")
591592
+ u"</p><p><b>" + tr("Don't create subfolder") + u"</b> - "
592593
+ tr("Put files directly in the save path.")
593594
+ u"</p></body></html>");

src/gui/transferlistwidget.cpp

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -982,6 +982,24 @@ void TransferListWidget::createUniqueSubfolderForSelectedTorrents()
982982
if (plan.isEmpty())
983983
return;
984984

985+
// Merge if the unique folder already exists (overwrite matching paths only).
986+
if (plan.needsConfirmation())
987+
{
988+
const QString text = tr(
989+
"Torrent: \"%1\"\n\n"
990+
"The unique subfolder already exists. Continuing will merge the torrent into it "
991+
"and replace files with matching paths. Other files will not be removed.")
992+
.arg(torrent->name());
993+
994+
const QMessageBox::StandardButton answer = QMessageBox::warning(this
995+
, tr("Create unique subfolder")
996+
, text
997+
, (QMessageBox::Ok | QMessageBox::Cancel)
998+
, QMessageBox::Cancel);
999+
if (answer != QMessageBox::Ok)
1000+
return;
1001+
}
1002+
9851003
connect(torrent, &BitTorrent::Torrent::uniqueSubfolderMigrationFinished, this
9861004
, [this](const bool success, const QString &message)
9871005
{
@@ -1076,7 +1094,8 @@ void TransferListWidget::displayListMenu()
10761094
auto *actionCreateUniqueSubfolder = new QAction(UIThemeManager::instance()->getIcon(u"edit-rename"_s)
10771095
, tr("Create &unique subfolder"), listMenu);
10781096
actionCreateUniqueSubfolder->setToolTip(tr(
1079-
"Move selected torrents into a unique folder (e.g. Show a19f83c275d1)."));
1097+
"Move selected torrents into a unique folder (e.g. Show a19f83c275d1). "
1098+
"If that folder already exists, matching files may be overwritten after confirmation."));
10801099
connect(actionCreateUniqueSubfolder, &QAction::triggered, this, &TransferListWidget::createUniqueSubfolderForSelectedTorrents);
10811100
auto *actionSequentialDownload = new TriStateAction(tr("Download in sequential order"), listMenu);
10821101
connect(actionSequentialDownload, &QAction::triggered, this, &TransferListWidget::setSelectedTorrentsSequentialDownload);

test/testtoplevelpayload.cpp

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,10 @@
2626
* exception statement from your version.
2727
*/
2828

29+
#include <QDir>
30+
#include <QFile>
2931
#include <QObject>
32+
#include <QTemporaryDir>
3033
#include <QTest>
3134

3235
#include "base/bittorrent/toplevelpayload.h"
@@ -150,6 +153,127 @@ private slots:
150153
const PathList out = applyUniqueSubfolderLayout(partial, sampleId, u"Show"_s);
151154
QCOMPARE(out, partial);
152155
}
156+
157+
void testPlanDestAbsent() const
158+
{
159+
QTemporaryDir tmp;
160+
QVERIFY(tmp.isValid());
161+
const Path storageRoot {tmp.path()};
162+
163+
const PathList current {Path(u"Show/ep.mkv"_s)};
164+
const UniqueSubfolderMigrationPlan plan = makeUniqueSubfolderMigrationPlan(
165+
current, sampleId, u"Show"_s, storageRoot);
166+
167+
QVERIFY(!plan.blocked);
168+
QVERIFY(!plan.needsConfirmation());
169+
QCOMPARE(plan.renames.size(), 1);
170+
QCOMPARE(plan.renames.at(0).to, Path(u"Show a19f83c275d1/ep.mkv"_s));
171+
}
172+
173+
void testPlanDestExistsNeedsConfirmation() const
174+
{
175+
QTemporaryDir tmp;
176+
QVERIFY(tmp.isValid());
177+
const Path storageRoot {tmp.path()};
178+
const Path dest = storageRoot / Path(u"Show a19f83c275d1"_s);
179+
QVERIFY(Utils::Fs::mkpath(dest));
180+
181+
const PathList current {Path(u"Show/ep.mkv"_s)};
182+
const UniqueSubfolderMigrationPlan plan = makeUniqueSubfolderMigrationPlan(
183+
current, sampleId, u"Show"_s, storageRoot);
184+
185+
QVERIFY(!plan.blocked);
186+
QVERIFY(plan.needsConfirmation());
187+
QCOMPARE(plan.existingUniqueFolder, dest);
188+
QCOMPARE(plan.renames.size(), 1);
189+
QCOMPARE(plan.renames.at(0).to, Path(u"Show a19f83c275d1/ep.mkv"_s));
190+
}
191+
192+
void testPlanConflictingTorrentPathsStillRenamed() const
193+
{
194+
QTemporaryDir tmp;
195+
QVERIFY(tmp.isValid());
196+
const Path storageRoot {tmp.path()};
197+
const Path dest = storageRoot / Path(u"Show a19f83c275d1"_s);
198+
QVERIFY(Utils::Fs::mkpath(dest));
199+
// Existing file that conflicts with a torrent path — plan still renames onto it (overwrite).
200+
QFile conflict {(dest / Path(u"ep.mkv"_s)).data()};
201+
QVERIFY(conflict.open(QIODevice::WriteOnly));
202+
conflict.write("old");
203+
conflict.close();
204+
205+
const PathList current {Path(u"Show/ep.mkv"_s), Path(u"Show/extra.mkv"_s)};
206+
const UniqueSubfolderMigrationPlan plan = makeUniqueSubfolderMigrationPlan(
207+
current, sampleId, u"Show"_s, storageRoot);
208+
209+
QVERIFY(plan.needsConfirmation());
210+
QCOMPARE(plan.renames.size(), 2);
211+
QCOMPARE(plan.renames.at(0).to, Path(u"Show a19f83c275d1/ep.mkv"_s));
212+
QCOMPARE(plan.renames.at(1).to, Path(u"Show a19f83c275d1/extra.mkv"_s));
213+
}
214+
215+
void testPlanUnrelatedDestFilesNotInRenameList() const
216+
{
217+
QTemporaryDir tmp;
218+
QVERIFY(tmp.isValid());
219+
const Path storageRoot {tmp.path()};
220+
const Path dest = storageRoot / Path(u"Show a19f83c275d1"_s);
221+
QVERIFY(Utils::Fs::mkpath(dest));
222+
QFile unrelated {(dest / Path(u"notes.txt"_s)).data()};
223+
QVERIFY(unrelated.open(QIODevice::WriteOnly));
224+
unrelated.write("keep me");
225+
unrelated.close();
226+
227+
const PathList current {Path(u"Show/ep.mkv"_s)};
228+
const UniqueSubfolderMigrationPlan plan = makeUniqueSubfolderMigrationPlan(
229+
current, sampleId, u"Show"_s, storageRoot);
230+
231+
QVERIFY(plan.needsConfirmation());
232+
// Only torrent file paths are planned — unrelated notes.txt is never a rename target.
233+
QCOMPARE(plan.renames.size(), 1);
234+
QCOMPARE(plan.renames.at(0).to, Path(u"Show a19f83c275d1/ep.mkv"_s));
235+
QVERIFY(QFile::exists((dest / Path(u"notes.txt"_s)).data()));
236+
}
237+
238+
void testPlanEmptyWhenAlreadyUnique() const
239+
{
240+
QTemporaryDir tmp;
241+
QVERIFY(tmp.isValid());
242+
const Path storageRoot {tmp.path()};
243+
244+
const PathList current {Path(u"Show a19f83c275d1/ep.mkv"_s)};
245+
const UniqueSubfolderMigrationPlan plan = makeUniqueSubfolderMigrationPlan(
246+
current, sampleId, u"Show"_s, storageRoot);
247+
248+
QVERIFY(plan.isEmpty());
249+
QVERIFY(!plan.needsConfirmation());
250+
}
251+
252+
void testPlanBlockedWhenStorageUnknown() const
253+
{
254+
const PathList current {Path(u"Show/ep.mkv"_s)};
255+
const UniqueSubfolderMigrationPlan plan = makeUniqueSubfolderMigrationPlan(
256+
current, sampleId, u"Show"_s, {});
257+
258+
QVERIFY(plan.blocked);
259+
QVERIFY(!plan.blockReason.isEmpty());
260+
QVERIFY(plan.renames.isEmpty());
261+
}
262+
263+
// Layout flag is only set by finishUniqueSubfolderMigration when all renames succeed
264+
// (TorrentImpl). Plan itself never changes content layout — it only lists renames.
265+
void testPlanDoesNotImplyLayoutChange() const
266+
{
267+
QTemporaryDir tmp;
268+
QVERIFY(tmp.isValid());
269+
const PathList current {Path(u"Show/ep.mkv"_s)};
270+
const UniqueSubfolderMigrationPlan plan = makeUniqueSubfolderMigrationPlan(
271+
current, sampleId, u"Show"_s, Path(tmp.path()));
272+
273+
QVERIFY(!plan.renames.isEmpty());
274+
// No layout field on the plan — conversion is deferred until renames complete.
275+
QVERIFY(!plan.blocked);
276+
}
153277
};
154278

155279
QTEST_APPLESS_MAIN(TestTopLevelPayload)

0 commit comments

Comments
 (0)