From ff3d638dc412a160a592489bf16141f4f186b945 Mon Sep 17 00:00:00 2001 From: benjitobz Date: Tue, 1 Sep 2026 11:00:27 -0400 Subject: [PATCH 1/2] Adopt an existing qBittorrent torrent instead of failing the grab Re-grabbing a release whose torrent already sits in qBittorrent made the add call fail with the clients bare Fails response and the grab errored out. When the add is refused, check whether that info-hash already exists in the client and, if so, continue with the existing download so the completed torrent imports normally. Genuine add failures still throw. --- .../Clients/QBittorrent/QBittorrent.cs | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrent.cs b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrent.cs index fa2b9151..a92cfa97 100644 --- a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrent.cs +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrent.cs @@ -91,6 +91,24 @@ public bool ShouldPreserveItemAfterImport(DownloadClientItem downloadClientItem) Settings.EbookImportedCategory); } + private bool TorrentExistsInClient(string hash) + { + if (hash.IsNullOrWhiteSpace()) + { + return false; + } + + try + { + return Proxy.GetTorrents(Settings).Any(t => hash.Equals(t.Hash, StringComparison.OrdinalIgnoreCase)); + } + catch (Exception ex) + { + _logger.Debug(ex, "Unable to check whether torrent {0} already exists in qBittorrent", hash); + return false; + } + } + protected override string AddFromMagnetLink(RemoteBook remoteBook, string hash, string magnetLink) { if (!Proxy.GetConfig(Settings).DhtEnabled && !magnetLink.Contains("&tr=")) @@ -113,6 +131,10 @@ protected override string AddFromMagnetLink(RemoteBook remoteBook, string hash, { throw new DownloadClientRejectedReleaseException(remoteBook.Release, "qBittorrent rejected the magnet link due to a conflict", ex); } + catch (DownloadClientException) when (TorrentExistsInClient(hash)) + { + _logger.Info("qBittorrent already has torrent {0}; adopting the existing download instead of failing the grab", hash); + } if ((!addHasSetShareLimits && setShareLimits) || moveToTop || forceStart) { @@ -178,6 +200,10 @@ protected override string AddFromTorrentFile(RemoteBook remoteBook, string hash, { throw new DownloadClientRejectedReleaseException(remoteBook.Release, "qBittorrent rejected the torrent file due to a conflict", ex); } + catch (DownloadClientException) when (TorrentExistsInClient(hash)) + { + _logger.Info("qBittorrent already has torrent {0}; adopting the existing download instead of failing the grab", hash); + } if ((!addHasSetShareLimits && setShareLimits) || moveToTop || forceStart) { From 5cea68a2d3902cb5818102bcb92518ae30bd4b9b Mon Sep 17 00:00:00 2001 From: Benjamin Tobalt Date: Sat, 3 Oct 2026 17:59:20 -0400 Subject: [PATCH 2/2] Only continue with existing torrents already in Chaptarr's categories Addresses review feedback: adopting any torrent with a matching hash brought back the behaviour removed in 3337821 for #24, where a torrent added outside Chaptarr was treated as Chaptarr's own grab and its files were moved. When qBittorrent refuses an add: - if the torrent already sits in one of Chaptarr's configured categories (grab or post-import), continue with it, moving it back to the grab category when it was in a post-import one; - if it exists anywhere else in the client, reject the release with DownloadClientRejectedReleaseException and leave the torrent untouched; - otherwise surface the original error as before. 409 conflict handling is unchanged. --- .../QBittorrentDuplicateHandlingFixture.cs | 84 ++++++++++++++++++- .../Clients/QBittorrent/QBittorrent.cs | 51 +++++++++-- 2 files changed, 127 insertions(+), 8 deletions(-) diff --git a/src/Chaptarr.Core.Test/Download/QBittorrentDuplicateHandlingFixture.cs b/src/Chaptarr.Core.Test/Download/QBittorrentDuplicateHandlingFixture.cs index 239a4824..c5b96cb3 100644 --- a/src/Chaptarr.Core.Test/Download/QBittorrentDuplicateHandlingFixture.cs +++ b/src/Chaptarr.Core.Test/Download/QBittorrentDuplicateHandlingFixture.cs @@ -43,12 +43,19 @@ private class TestProxy : IQBittorrentProxy public QBittorrentPreferences GetConfig(QBittorrentSettings settings) => new QBittorrentPreferences { DhtEnabled = true }; - public List GetTorrents(QBittorrentSettings settings, string category = null) => throw new NotImplementedException(); + public Dictionary> HashesByCategory { get; } = new(StringComparer.OrdinalIgnoreCase); + public bool TorrentLoaded { get; set; } + + public List GetTorrents(QBittorrentSettings settings, string category = null) + { + var hashes = category != null && HashesByCategory.TryGetValue(category, out var found) ? found : new List(); + return hashes.ConvertAll(hash => new QBittorrentTorrent { Hash = hash }); + } public bool IsTorrentLoaded(string hash, QBittorrentSettings settings) { LoadedHashes.Add(hash); - return false; + return TorrentLoaded; } public QBittorrentTorrentProperties GetTorrentProperties(string hash, QBittorrentSettings settings) => throw new NotImplementedException(); @@ -242,11 +249,84 @@ public void should_surface_non_conflict_errors_unchanged() Assert.Multiple(() => { Assert.That(exception, Is.SameAs(original)); + Assert.That(proxy.Labels, Is.Empty); + }); + } + + [Test] + public void should_continue_with_existing_torrent_already_in_a_chaptarr_category() + { + var proxy = new TestProxy { AddException = new DownloadClientException("qBittorrent rejected the torrent add request (response: Fails.).") }; + proxy.HashesByCategory["ebooks"] = new List { "abcdef1234" }; + var client = CreateClient(proxy); + + var result = client.AddTorrentFile(CreateRemoteBook(), "ABCDEF1234"); + + Assert.Multiple(() => + { + Assert.That(result, Is.EqualTo("ABCDEF1234")); Assert.That(proxy.LoadedHashes, Is.Empty); Assert.That(proxy.Labels, Is.Empty); }); } + [Test] + public void should_move_existing_torrent_back_from_post_import_category() + { + var proxy = new TestProxy { AddException = new DownloadClientException("qBittorrent rejected the torrent add request (response: Fails.).") }; + proxy.HashesByCategory["ebooks-imported"] = new List { "abcdef1234" }; + var client = CreateClient(proxy); + + client.AddMagnet(CreateRemoteBook(), "ABCDEF1234"); + + Assert.That(proxy.Labels, Is.EqualTo(new[] { ("abcdef1234", "ebooks") })); + } + + [Test] + public void should_reject_without_adopting_when_existing_torrent_is_outside_chaptarr_categories() + { + var proxy = new TestProxy + { + AddException = new DownloadClientException("qBittorrent rejected the torrent add request (response: Fails.)."), + TorrentLoaded = true + }; + var client = CreateClient(proxy); + + var exception = Assert.Throws(() => client.AddTorrentFile(CreateRemoteBook(), "ABCDEF1234")); + + Assert.Multiple(() => + { + Assert.That(exception.Message, Is.EqualTo("qBittorrent already has this torrent outside Chaptarr's categories")); + Assert.That(proxy.Labels, Is.Empty); + }); + } + + private static TestQBittorrent CreateClient(TestProxy proxy) + { + return new TestQBittorrent(new TestProxySelector(proxy, new Version(2, 11, 0)), new CacheManager(), LogManager.GetCurrentClassLogger()) + { + Definition = new DownloadClientDefinition + { + Name = "qBittorrent", + Settings = new QBittorrentSettings + { + EbookCategory = "ebooks", + AudiobookCategory = "audiobooks", + EbookImportedCategory = "ebooks-imported" + } + } + }; + } + + private static RemoteBook CreateRemoteBook() + { + return new RemoteBook + { + Books = new List { new Book { MediaType = BookMediaType.Ebook } }, + Release = new TorrentInfo() + }; + } + private static DownloadClientException ConflictException() { var request = new HttpRequest("http://localhost/api/v2/torrents/add"); diff --git a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrent.cs b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrent.cs index a92cfa97..14d8feb6 100644 --- a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrent.cs +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrent.cs @@ -91,22 +91,55 @@ public bool ShouldPreserveItemAfterImport(DownloadClientItem downloadClientItem) Settings.EbookImportedCategory); } - private bool TorrentExistsInClient(string hash) + private bool TryAdoptExistingTorrent(RemoteBook remoteBook, string hash, string category, DownloadClientException addException) { if (hash.IsNullOrWhiteSpace()) { return false; } + string existingCategory; + bool existsElsewhere; + try { - return Proxy.GetTorrents(Settings).Any(t => hash.Equals(t.Hash, StringComparison.OrdinalIgnoreCase)); + existingCategory = new[] { Settings.AudiobookCategory, Settings.EbookCategory, Settings.AudiobookImportedCategory, Settings.EbookImportedCategory } + .Where(c => c.IsNotNullOrWhiteSpace()) + .Distinct(StringComparer.InvariantCultureIgnoreCase) + .FirstOrDefault(c => Proxy.GetTorrents(Settings, c).Any(t => hash.Equals(t.Hash, StringComparison.OrdinalIgnoreCase))); + + existsElsewhere = existingCategory == null && Proxy.IsTorrentLoaded(hash.ToLower(), Settings); } catch (Exception ex) { _logger.Debug(ex, "Unable to check whether torrent {0} already exists in qBittorrent", hash); return false; } + + if (existsElsewhere) + { + throw new DownloadClientRejectedReleaseException(remoteBook.Release, "qBittorrent already has this torrent outside Chaptarr's categories", addException); + } + + if (existingCategory == null) + { + return false; + } + + if (category.IsNotNullOrWhiteSpace() && !category.Equals(existingCategory, StringComparison.InvariantCultureIgnoreCase)) + { + try + { + Proxy.SetTorrentLabel(hash.ToLower(), category, Settings); + } + catch (DownloadClientException ex) + { + _logger.Warn(ex, "Failed to move existing torrent {0} from category \"{1}\" to \"{2}\" in qBittorrent.", hash, existingCategory, category); + } + } + + _logger.Info("qBittorrent already has torrent {0} in Chaptarr category \"{1}\"; continuing with the existing download instead of failing the grab", hash, existingCategory); + return true; } protected override string AddFromMagnetLink(RemoteBook remoteBook, string hash, string magnetLink) @@ -131,9 +164,12 @@ protected override string AddFromMagnetLink(RemoteBook remoteBook, string hash, { throw new DownloadClientRejectedReleaseException(remoteBook.Release, "qBittorrent rejected the magnet link due to a conflict", ex); } - catch (DownloadClientException) when (TorrentExistsInClient(hash)) + catch (DownloadClientException ex) { - _logger.Info("qBittorrent already has torrent {0}; adopting the existing download instead of failing the grab", hash); + if (!TryAdoptExistingTorrent(remoteBook, hash, category, ex)) + { + throw; + } } if ((!addHasSetShareLimits && setShareLimits) || moveToTop || forceStart) @@ -200,9 +236,12 @@ protected override string AddFromTorrentFile(RemoteBook remoteBook, string hash, { throw new DownloadClientRejectedReleaseException(remoteBook.Release, "qBittorrent rejected the torrent file due to a conflict", ex); } - catch (DownloadClientException) when (TorrentExistsInClient(hash)) + catch (DownloadClientException ex) { - _logger.Info("qBittorrent already has torrent {0}; adopting the existing download instead of failing the grab", hash); + if (!TryAdoptExistingTorrent(remoteBook, hash, category, ex)) + { + throw; + } } if ((!addHasSetShareLimits && setShareLimits) || moveToTop || forceStart)