From 0fbf57db594ec2b07338ef2ab9ad7b29d5591bef Mon Sep 17 00:00:00 2001 From: Mika Cohen Date: Wed, 22 Apr 2026 16:16:32 -0600 Subject: [PATCH 1/4] Reuse stable waiting tracked downloads --- .../TrackedDownloadServiceFixture.cs | 137 ++++++++++++++++++ .../TrackedDownloadService.cs | 28 +++- 2 files changed, 164 insertions(+), 1 deletion(-) diff --git a/src/NzbDrone.Core.Test/Download/TrackedDownloads/TrackedDownloadServiceFixture.cs b/src/NzbDrone.Core.Test/Download/TrackedDownloads/TrackedDownloadServiceFixture.cs index 0c07b6386..d0373b280 100644 --- a/src/NzbDrone.Core.Test/Download/TrackedDownloads/TrackedDownloadServiceFixture.cs +++ b/src/NzbDrone.Core.Test/Download/TrackedDownloads/TrackedDownloadServiceFixture.cs @@ -36,6 +36,143 @@ private void GivenDownloadHistory() }); } + private static DownloadClientDefinition CreateDownloadClient() + { + return new DownloadClientDefinition() + { + Id = 1, + Protocol = DownloadProtocol.Usenet + }; + } + + private static DownloadClientItem CreateDownloadItem(DownloadItemStatus status) + { + return new DownloadClientItem() + { + Title = "TV Series S01E01", + DownloadId = "35238", + Category = "sonarr", + TotalSize = 1000, + RemainingSize = 500, + Status = status, + DownloadClientInfo = new DownloadClientItemClientInfo + { + Id = 1, + Type = "NZBGet", + Name = "NZBGet", + Protocol = DownloadProtocol.Usenet + } + }; + } + + private void GivenTrackedDownloadCanBeMapped() + { + Mocker.GetMock() + .Setup(s => s.FindByDownloadId(It.IsAny())) + .Returns(new List()); + + Mocker.GetMock() + .Setup(s => s.Map(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), null)) + .Returns(new RemoteEpisode + { + Release = new ReleaseInfo { Title = "TV Series S01E01" }, + Series = new Series() { Id = 5 }, + Episodes = new List { new Episode { Id = 4 } }, + ParsedEpisodeInfo = new ParsedEpisodeInfo() + { + SeriesTitle = "TV Series", + SeasonNumber = 1, + EpisodeNumbers = new[] { 1 } + }, + MappedSeasonNumber = 1 + }); + } + + [Test] + public void should_reuse_stable_queued_downloading_tracked_download() + { + GivenTrackedDownloadCanBeMapped(); + + var client = CreateDownloadClient(); + var item = CreateDownloadItem(DownloadItemStatus.Queued); + var updatedItem = CreateDownloadItem(DownloadItemStatus.Queued); + updatedItem.RemainingSize = 250; + + var trackedDownload = Subject.TrackDownload(client, item); + var refreshedTrackedDownload = Subject.TrackDownload(client, updatedItem); + + refreshedTrackedDownload.Should().BeSameAs(trackedDownload); + refreshedTrackedDownload.DownloadItem.Should().BeSameAs(updatedItem); + + Mocker.GetMock() + .Verify(s => s.FindByDownloadId(It.IsAny()), Times.Once()); + + Mocker.GetMock() + .Verify(s => s.Map(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), null), Times.Once()); + } + + [Test] + public void should_reuse_stable_paused_downloading_tracked_download() + { + GivenTrackedDownloadCanBeMapped(); + + var client = CreateDownloadClient(); + var item = CreateDownloadItem(DownloadItemStatus.Paused); + var updatedItem = CreateDownloadItem(DownloadItemStatus.Paused); + updatedItem.RemainingSize = 250; + + var trackedDownload = Subject.TrackDownload(client, item); + var refreshedTrackedDownload = Subject.TrackDownload(client, updatedItem); + + refreshedTrackedDownload.Should().BeSameAs(trackedDownload); + refreshedTrackedDownload.DownloadItem.Should().BeSameAs(updatedItem); + + Mocker.GetMock() + .Verify(s => s.FindByDownloadId(It.IsAny()), Times.Once()); + + Mocker.GetMock() + .Verify(s => s.Map(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), null), Times.Once()); + } + + [Test] + public void should_reprocess_when_waiting_download_starts_downloading() + { + GivenTrackedDownloadCanBeMapped(); + + var client = CreateDownloadClient(); + var item = CreateDownloadItem(DownloadItemStatus.Queued); + var updatedItem = CreateDownloadItem(DownloadItemStatus.Downloading); + + Subject.TrackDownload(client, item); + Subject.TrackDownload(client, updatedItem); + + Mocker.GetMock() + .Verify(s => s.FindByDownloadId(It.IsAny()), Times.Exactly(2)); + + Mocker.GetMock() + .Verify(s => s.Map(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), null), Times.Exactly(2)); + } + + [Test] + public void should_reprocess_when_waiting_download_identity_changes() + { + GivenTrackedDownloadCanBeMapped(); + + var client = CreateDownloadClient(); + var item = CreateDownloadItem(DownloadItemStatus.Queued); + var updatedItem = CreateDownloadItem(DownloadItemStatus.Queued); + updatedItem.TotalSize = 2000; + + Subject.TrackDownload(client, item); + Subject.TrackDownload(client, updatedItem); + + Mocker.GetMock() + .Verify(s => s.FindByDownloadId(It.IsAny()), Times.Exactly(2)); + + Mocker.GetMock() + .Verify(s => s.Map(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), null), Times.Exactly(2)); + } + [Test] public void should_track_downloads_using_the_source_title_if_it_cannot_be_found_using_the_download_title() { diff --git a/src/NzbDrone.Core/Download/TrackedDownloads/TrackedDownloadService.cs b/src/NzbDrone.Core/Download/TrackedDownloads/TrackedDownloadService.cs index cc54dc1cb..b3c712932 100644 --- a/src/NzbDrone.Core/Download/TrackedDownloads/TrackedDownloadService.cs +++ b/src/NzbDrone.Core/Download/TrackedDownloads/TrackedDownloadService.cs @@ -90,7 +90,7 @@ public TrackedDownload TrackDownload(DownloadClientDefinition downloadClient, Do { var existingItem = Find(downloadItem.DownloadId); - if (existingItem != null && existingItem.State != TrackedDownloadState.Downloading) + if (existingItem != null && CanReuseTrackedDownload(existingItem, downloadItem)) { LogItemChange(existingItem, existingItem.DownloadItem, downloadItem); @@ -222,6 +222,32 @@ public void UpdateTrackable(List trackedDownloads) } } + private static bool CanReuseTrackedDownload(TrackedDownload existingItem, DownloadClientItem downloadItem) + { + if (existingItem.State != TrackedDownloadState.Downloading) + { + return true; + } + + return IsStableWaitingDownload(downloadItem) && + HasSameDownloadIdentity(existingItem.DownloadItem, downloadItem); + } + + private static bool IsStableWaitingDownload(DownloadClientItem downloadItem) + { + return downloadItem.Status == DownloadItemStatus.Queued || + downloadItem.Status == DownloadItemStatus.Paused; + } + + private static bool HasSameDownloadIdentity(DownloadClientItem existingItem, DownloadClientItem downloadItem) + { + return existingItem.DownloadId == downloadItem.DownloadId && + existingItem.Title == downloadItem.Title && + existingItem.Category == downloadItem.Category && + existingItem.TotalSize == downloadItem.TotalSize && + existingItem.DownloadClientInfo?.Id == downloadItem.DownloadClientInfo?.Id; + } + private void LogItemChange(TrackedDownload trackedDownload, DownloadClientItem existingItem, DownloadClientItem downloadItem) { if (existingItem == null || From c87c96ff1ec36dedf27950119de13a9aaa994229 Mon Sep 17 00:00:00 2001 From: Mika Cohen Date: Wed, 22 Apr 2026 16:23:44 -0600 Subject: [PATCH 2/4] Assert tracked download state in reuse tests --- .../Download/TrackedDownloads/TrackedDownloadServiceFixture.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/NzbDrone.Core.Test/Download/TrackedDownloads/TrackedDownloadServiceFixture.cs b/src/NzbDrone.Core.Test/Download/TrackedDownloads/TrackedDownloadServiceFixture.cs index d0373b280..51f8d122e 100644 --- a/src/NzbDrone.Core.Test/Download/TrackedDownloads/TrackedDownloadServiceFixture.cs +++ b/src/NzbDrone.Core.Test/Download/TrackedDownloads/TrackedDownloadServiceFixture.cs @@ -101,6 +101,7 @@ public void should_reuse_stable_queued_downloading_tracked_download() var trackedDownload = Subject.TrackDownload(client, item); var refreshedTrackedDownload = Subject.TrackDownload(client, updatedItem); + trackedDownload.State.Should().Be(TrackedDownloadState.Downloading); refreshedTrackedDownload.Should().BeSameAs(trackedDownload); refreshedTrackedDownload.DownloadItem.Should().BeSameAs(updatedItem); @@ -124,6 +125,7 @@ public void should_reuse_stable_paused_downloading_tracked_download() var trackedDownload = Subject.TrackDownload(client, item); var refreshedTrackedDownload = Subject.TrackDownload(client, updatedItem); + trackedDownload.State.Should().Be(TrackedDownloadState.Downloading); refreshedTrackedDownload.Should().BeSameAs(trackedDownload); refreshedTrackedDownload.DownloadItem.Should().BeSameAs(updatedItem); From 7cfbc88fb8ccc2ffbcb36a12a8da73d8d34b9b02 Mon Sep 17 00:00:00 2001 From: Mika Cohen Date: Thu, 23 Apr 2026 12:16:32 -0600 Subject: [PATCH 3/4] Only reuse healthy waiting tracked downloads --- .../TrackedDownloadServiceFixture.cs | 60 +++++++++++++++++++ .../TrackedDownloadService.cs | 10 +++- 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/src/NzbDrone.Core.Test/Download/TrackedDownloads/TrackedDownloadServiceFixture.cs b/src/NzbDrone.Core.Test/Download/TrackedDownloads/TrackedDownloadServiceFixture.cs index 51f8d122e..adad1d4ad 100644 --- a/src/NzbDrone.Core.Test/Download/TrackedDownloads/TrackedDownloadServiceFixture.cs +++ b/src/NzbDrone.Core.Test/Download/TrackedDownloads/TrackedDownloadServiceFixture.cs @@ -175,6 +175,66 @@ public void should_reprocess_when_waiting_download_identity_changes() .Verify(s => s.Map(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), null), Times.Exactly(2)); } + [Test] + public void should_reprocess_when_waiting_download_has_warning_status() + { + GivenTrackedDownloadCanBeMapped(); + + var client = CreateDownloadClient(); + var item = CreateDownloadItem(DownloadItemStatus.Queued); + var updatedItem = CreateDownloadItem(DownloadItemStatus.Queued); + updatedItem.RemainingSize = 250; + + var trackedDownload = Subject.TrackDownload(client, item); + trackedDownload.Warn("Temporary warning"); + + var refreshedTrackedDownload = Subject.TrackDownload(client, updatedItem); + + refreshedTrackedDownload.Should().NotBeSameAs(trackedDownload); + + Mocker.GetMock() + .Verify(s => s.FindByDownloadId(It.IsAny()), Times.Exactly(2)); + + Mocker.GetMock() + .Verify(s => s.Map(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), null), Times.Exactly(2)); + } + + [Test] + public void should_reprocess_when_waiting_download_is_not_mapped() + { + Mocker.GetMock() + .Setup(s => s.FindByDownloadId(It.IsAny())) + .Returns(new List()); + + Mocker.GetMock() + .Setup(s => s.Map(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), null)) + .Returns(new RemoteEpisode + { + ParsedEpisodeInfo = new ParsedEpisodeInfo + { + SeriesTitle = "TV Series", + SeasonNumber = 1, + EpisodeNumbers = new[] { 1 } + } + }); + + var client = CreateDownloadClient(); + var item = CreateDownloadItem(DownloadItemStatus.Queued); + var updatedItem = CreateDownloadItem(DownloadItemStatus.Queued); + updatedItem.RemainingSize = 250; + + var trackedDownload = Subject.TrackDownload(client, item); + var refreshedTrackedDownload = Subject.TrackDownload(client, updatedItem); + + refreshedTrackedDownload.Should().NotBeSameAs(trackedDownload); + + Mocker.GetMock() + .Verify(s => s.FindByDownloadId(It.IsAny()), Times.Exactly(2)); + + Mocker.GetMock() + .Verify(s => s.Map(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), null), Times.Exactly(2)); + } + [Test] public void should_track_downloads_using_the_source_title_if_it_cannot_be_found_using_the_download_title() { diff --git a/src/NzbDrone.Core/Download/TrackedDownloads/TrackedDownloadService.cs b/src/NzbDrone.Core/Download/TrackedDownloads/TrackedDownloadService.cs index b3c712932..71780f4ce 100644 --- a/src/NzbDrone.Core/Download/TrackedDownloads/TrackedDownloadService.cs +++ b/src/NzbDrone.Core/Download/TrackedDownloads/TrackedDownloadService.cs @@ -230,7 +230,8 @@ private static bool CanReuseTrackedDownload(TrackedDownload existingItem, Downlo } return IsStableWaitingDownload(downloadItem) && - HasSameDownloadIdentity(existingItem.DownloadItem, downloadItem); + HasSameDownloadIdentity(existingItem.DownloadItem, downloadItem) && + HasHealthyWaitingCache(existingItem); } private static bool IsStableWaitingDownload(DownloadClientItem downloadItem) @@ -239,6 +240,13 @@ private static bool IsStableWaitingDownload(DownloadClientItem downloadItem) downloadItem.Status == DownloadItemStatus.Paused; } + private static bool HasHealthyWaitingCache(TrackedDownload existingItem) + { + return existingItem.Status == TrackedDownloadStatus.Ok && + existingItem.RemoteEpisode?.Series != null && + existingItem.RemoteEpisode.Episodes?.Any() == true; + } + private static bool HasSameDownloadIdentity(DownloadClientItem existingItem, DownloadClientItem downloadItem) { return existingItem.DownloadId == downloadItem.DownloadId && From cce09c916a050f5ef802ce206a8a22f59158fea1 Mon Sep 17 00:00:00 2001 From: Mika Cohen Date: Thu, 23 Apr 2026 15:15:29 -0600 Subject: [PATCH 4/4] Refresh tracked downloads on edit events --- .../TrackedDownloadServiceFixture.cs | 101 +++++++++++++----- .../TrackedDownloadService.cs | 36 ++++--- 2 files changed, 97 insertions(+), 40 deletions(-) diff --git a/src/NzbDrone.Core.Test/Download/TrackedDownloads/TrackedDownloadServiceFixture.cs b/src/NzbDrone.Core.Test/Download/TrackedDownloads/TrackedDownloadServiceFixture.cs index adad1d4ad..5c7db283a 100644 --- a/src/NzbDrone.Core.Test/Download/TrackedDownloads/TrackedDownloadServiceFixture.cs +++ b/src/NzbDrone.Core.Test/Download/TrackedDownloads/TrackedDownloadServiceFixture.cs @@ -88,38 +88,15 @@ private void GivenTrackedDownloadCanBeMapped() }); } - [Test] - public void should_reuse_stable_queued_downloading_tracked_download() + [TestCase(DownloadItemStatus.Queued)] + [TestCase(DownloadItemStatus.Paused)] + public void should_reuse_stable_waiting_downloading_tracked_download(DownloadItemStatus status) { GivenTrackedDownloadCanBeMapped(); var client = CreateDownloadClient(); - var item = CreateDownloadItem(DownloadItemStatus.Queued); - var updatedItem = CreateDownloadItem(DownloadItemStatus.Queued); - updatedItem.RemainingSize = 250; - - var trackedDownload = Subject.TrackDownload(client, item); - var refreshedTrackedDownload = Subject.TrackDownload(client, updatedItem); - - trackedDownload.State.Should().Be(TrackedDownloadState.Downloading); - refreshedTrackedDownload.Should().BeSameAs(trackedDownload); - refreshedTrackedDownload.DownloadItem.Should().BeSameAs(updatedItem); - - Mocker.GetMock() - .Verify(s => s.FindByDownloadId(It.IsAny()), Times.Once()); - - Mocker.GetMock() - .Verify(s => s.Map(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), null), Times.Once()); - } - - [Test] - public void should_reuse_stable_paused_downloading_tracked_download() - { - GivenTrackedDownloadCanBeMapped(); - - var client = CreateDownloadClient(); - var item = CreateDownloadItem(DownloadItemStatus.Paused); - var updatedItem = CreateDownloadItem(DownloadItemStatus.Paused); + var item = CreateDownloadItem(status); + var updatedItem = CreateDownloadItem(status); updatedItem.RemainingSize = 250; var trackedDownload = Subject.TrackDownload(client, item); @@ -543,6 +520,74 @@ public void should_not_throw_when_processing_deleted_episodes() trackedDownloads.First().RemoteEpisode.Should().BeNull(); } + [Test] + public void should_update_tracked_download_when_series_edited() + { + var originalSeries = new Series { Id = 5, TvdbId = 10, Title = "TV Series" }; + var updatedSeries = new Series { Id = 5, TvdbId = 10, Title = "TV Series Updated" }; + + var remoteEpisode = new RemoteEpisode + { + Series = originalSeries, + Episodes = new List { new Episode { Id = 4 } }, + ParsedEpisodeInfo = new ParsedEpisodeInfo + { + SeriesTitle = "TV Series", + SeasonNumber = 1, + EpisodeNumbers = new[] { 1 } + } + }; + + var updatedRemoteEpisode = new RemoteEpisode + { + Series = updatedSeries, + Episodes = new List { new Episode { Id = 4 } }, + ParsedEpisodeInfo = new ParsedEpisodeInfo + { + SeriesTitle = "TV Series", + SeasonNumber = 1, + EpisodeNumbers = new[] { 1 } + } + }; + + Mocker.GetMock() + .SetupSequence(s => s.Map(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), null)) + .Returns(remoteEpisode) + .Returns(updatedRemoteEpisode); + + Mocker.GetMock() + .Setup(s => s.FindByDownloadId(It.IsAny())) + .Returns(new List()); + + var client = new DownloadClientDefinition + { + Id = 1, + Protocol = DownloadProtocol.Torrent + }; + + var item = new DownloadClientItem + { + Title = "TV Series - S01E01", + DownloadId = "12345", + DownloadClientInfo = new DownloadClientItemClientInfo + { + Id = 1, + Type = "Blackhole", + Name = "Blackhole Client", + Protocol = DownloadProtocol.Torrent + } + }; + + Subject.TrackDownload(client, item); + + Subject.Handle(new SeriesEditedEvent(updatedSeries, originalSeries)); + + var trackedDownloads = Subject.GetTrackedDownloads(); + trackedDownloads.Should().HaveCount(1); + trackedDownloads.First().RemoteEpisode.Should().BeSameAs(updatedRemoteEpisode); + trackedDownloads.First().RemoteEpisode.Series.Title.Should().Be("TV Series Updated"); + } + [Test] public void should_not_throw_when_processing_deleted_series() { diff --git a/src/NzbDrone.Core/Download/TrackedDownloads/TrackedDownloadService.cs b/src/NzbDrone.Core/Download/TrackedDownloads/TrackedDownloadService.cs index 71780f4ce..6f072b8d3 100644 --- a/src/NzbDrone.Core/Download/TrackedDownloads/TrackedDownloadService.cs +++ b/src/NzbDrone.Core/Download/TrackedDownloads/TrackedDownloadService.cs @@ -28,6 +28,7 @@ public interface ITrackedDownloadService public class TrackedDownloadService : ITrackedDownloadService, IHandle, + IHandle, IHandle, IHandle { @@ -283,6 +284,16 @@ private void UpdateCachedItem(TrackedDownload trackedDownload) _aggregationService.Augment(trackedDownload.RemoteEpisode); } + private void RefreshCachedItems(List cachedItems) + { + if (cachedItems.Any()) + { + cachedItems.ForEach(UpdateCachedItem); + + _eventAggregator.PublishEvent(new TrackedDownloadRefreshedEvent(GetTrackedDownloads())); + } + } + private static TrackedDownloadState GetStateFromHistory(DownloadHistoryEventType eventType) { switch (eventType) @@ -323,6 +334,17 @@ public void Handle(EpisodeInfoRefreshedEvent message) } } + public void Handle(SeriesEditedEvent message) + { + var cachedItems = _cache.Values + .Where(t => + t.RemoteEpisode?.Series != null && + (t.RemoteEpisode.Series.Id == message.Series?.Id || t.RemoteEpisode.Series.TvdbId == message.Series?.TvdbId)) + .ToList(); + + RefreshCachedItems(cachedItems); + } + public void Handle(SeriesAddedEvent message) { var cachedItems = _cache.Values @@ -331,12 +353,7 @@ public void Handle(SeriesAddedEvent message) message.Series?.TvdbId == t.RemoteEpisode.Series.TvdbId) .ToList(); - if (cachedItems.Any()) - { - cachedItems.ForEach(UpdateCachedItem); - - _eventAggregator.PublishEvent(new TrackedDownloadRefreshedEvent(GetTrackedDownloads())); - } + RefreshCachedItems(cachedItems); } public void Handle(SeriesDeletedEvent message) @@ -347,12 +364,7 @@ public void Handle(SeriesDeletedEvent message) message.Series.Any(s => s.Id == t.RemoteEpisode.Series.Id || s.TvdbId == t.RemoteEpisode.Series.TvdbId)) .ToList(); - if (cachedItems.Any()) - { - cachedItems.ForEach(UpdateCachedItem); - - _eventAggregator.PublishEvent(new TrackedDownloadRefreshedEvent(GetTrackedDownloads())); - } + RefreshCachedItems(cachedItems); } } }