diff --git a/src/NzbDrone.Core.Test/Download/Pending/PendingReleaseServiceTests/RemovePendingFixture.cs b/src/NzbDrone.Core.Test/Download/Pending/PendingReleaseServiceTests/RemovePendingFixture.cs index bf853554a..37cd090a4 100644 --- a/src/NzbDrone.Core.Test/Download/Pending/PendingReleaseServiceTests/RemovePendingFixture.cs +++ b/src/NzbDrone.Core.Test/Download/Pending/PendingReleaseServiceTests/RemovePendingFixture.cs @@ -67,7 +67,7 @@ public void should_remove_same_release() { AddPending(id: 1, seasonNumber: 2, episodes: new[] { 3 }); - var queueId = HashConverter.GetHashInt31(string.Format("pending-{0}-ep{1}", 1, _episode.Id)); + var queueId = HashConverter.GetHashInt31($"pending-{1}"); Subject.RemovePendingQueueItems(queueId); @@ -82,7 +82,7 @@ public void should_remove_multiple_releases_release() AddPending(id: 3, seasonNumber: 2, episodes: new[] { 3 }); AddPending(id: 4, seasonNumber: 2, episodes: new[] { 3 }); - var queueId = HashConverter.GetHashInt31(string.Format("pending-{0}-ep{1}", 3, _episode.Id)); + var queueId = HashConverter.GetHashInt31($"pending-{3}"); Subject.RemovePendingQueueItems(queueId); @@ -97,7 +97,7 @@ public void should_not_remove_different_season() AddPending(id: 3, seasonNumber: 3, episodes: new[] { 1 }); AddPending(id: 4, seasonNumber: 3, episodes: new[] { 1 }); - var queueId = HashConverter.GetHashInt31(string.Format("pending-{0}-ep{1}", 1, _episode.Id)); + var queueId = HashConverter.GetHashInt31($"pending-{1}"); Subject.RemovePendingQueueItems(queueId); @@ -112,7 +112,7 @@ public void should_not_remove_different_episodes() AddPending(id: 3, seasonNumber: 2, episodes: new[] { 2 }); AddPending(id: 4, seasonNumber: 2, episodes: new[] { 3 }); - var queueId = HashConverter.GetHashInt31(string.Format("pending-{0}-ep{1}", 1, _episode.Id)); + var queueId = HashConverter.GetHashInt31($"pending-{1}"); Subject.RemovePendingQueueItems(queueId); @@ -125,7 +125,7 @@ public void should_not_remove_multiepisodes() AddPending(id: 1, seasonNumber: 2, episodes: new[] { 1 }); AddPending(id: 2, seasonNumber: 2, episodes: new[] { 1, 2 }); - var queueId = HashConverter.GetHashInt31(string.Format("pending-{0}-ep{1}", 1, _episode.Id)); + var queueId = HashConverter.GetHashInt31($"pending-{1}"); Subject.RemovePendingQueueItems(queueId); @@ -138,7 +138,7 @@ public void should_not_remove_singleepisodes() AddPending(id: 1, seasonNumber: 2, episodes: new[] { 1 }); AddPending(id: 2, seasonNumber: 2, episodes: new[] { 1, 2 }); - var queueId = HashConverter.GetHashInt31(string.Format("pending-{0}-ep{1}", 2, _episode.Id)); + var queueId = HashConverter.GetHashInt31($"pending-{2}"); Subject.RemovePendingQueueItems(queueId); diff --git a/src/NzbDrone.Core.Test/Download/Pending/PendingReleaseServiceTests/RemovePendingObsoleteFixture.cs b/src/NzbDrone.Core.Test/Download/Pending/PendingReleaseServiceTests/RemovePendingObsoleteFixture.cs new file mode 100644 index 000000000..dc2eb9f4d --- /dev/null +++ b/src/NzbDrone.Core.Test/Download/Pending/PendingReleaseServiceTests/RemovePendingObsoleteFixture.cs @@ -0,0 +1,153 @@ +using System.Collections.Generic; +using System.Linq; +using FizzWare.NBuilder; +using Moq; +using NUnit.Framework; +using NzbDrone.Common.Crypto; +using NzbDrone.Core.Download.Pending; +using NzbDrone.Core.Parser; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Tv; + +namespace NzbDrone.Core.Test.Download.Pending.PendingReleaseServiceTests +{ + [TestFixture] + public class RemovePendingObsoleteFixture : CoreTest + { + private List _pending; + private Episode _episode; + + [SetUp] + public void Setup() + { + _pending = new List(); + + _episode = Builder.CreateNew() + .Build(); + + Mocker.GetMock() + .Setup(s => s.AllBySeriesId(It.IsAny())) + .Returns(_pending); + + Mocker.GetMock() + .Setup(s => s.All()) + .Returns(_pending); + + Mocker.GetMock() + .Setup(s => s.GetSeries(It.IsAny())) + .Returns(new Series()); + + Mocker.GetMock() + .Setup(s => s.GetSeries(It.IsAny>())) + .Returns(new List { new Series() }); + + Mocker.GetMock() + .Setup(s => s.Map(It.IsAny(), It.IsAny())) + .Returns(new RemoteEpisode { Episodes = new List { _episode } }); + + Mocker.GetMock() + .Setup(s => s.GetEpisodes(It.IsAny(), It.IsAny(), It.IsAny(), null)) + .Returns(new List { _episode }); + } + + private void AddPending(int id, int seasonNumber, int[] episodes) + { + _pending.Add(new PendingRelease + { + Id = id, + Title = "Series.Title.S01E05.abc-Sonarr", + ParsedEpisodeInfo = new ParsedEpisodeInfo { SeasonNumber = seasonNumber, EpisodeNumbers = episodes }, + Release = Builder.CreateNew().Build() + }); + } + + [Test] + public void should_remove_same_release() + { + AddPending(id: 1, seasonNumber: 2, episodes: new[] { 3 }); + + var queueId = HashConverter.GetHashInt31(string.Format("pending-{0}-ep{1}", 1, _episode.Id)); + + Subject.RemovePendingQueueItemsObsolete(queueId); + + AssertRemoved(1); + } + + [Test] + public void should_remove_multiple_releases_release() + { + AddPending(id: 1, seasonNumber: 2, episodes: new[] { 1 }); + AddPending(id: 2, seasonNumber: 2, episodes: new[] { 2 }); + AddPending(id: 3, seasonNumber: 2, episodes: new[] { 3 }); + AddPending(id: 4, seasonNumber: 2, episodes: new[] { 3 }); + + var queueId = HashConverter.GetHashInt31(string.Format("pending-{0}-ep{1}", 3, _episode.Id)); + + Subject.RemovePendingQueueItemsObsolete(queueId); + + AssertRemoved(3, 4); + } + + [Test] + public void should_not_remove_different_season() + { + AddPending(id: 1, seasonNumber: 2, episodes: new[] { 1 }); + AddPending(id: 2, seasonNumber: 2, episodes: new[] { 1 }); + AddPending(id: 3, seasonNumber: 3, episodes: new[] { 1 }); + AddPending(id: 4, seasonNumber: 3, episodes: new[] { 1 }); + + var queueId = HashConverter.GetHashInt31(string.Format("pending-{0}-ep{1}", 1, _episode.Id)); + + Subject.RemovePendingQueueItemsObsolete(queueId); + + AssertRemoved(1, 2); + } + + [Test] + public void should_not_remove_different_episodes() + { + AddPending(id: 1, seasonNumber: 2, episodes: new[] { 1 }); + AddPending(id: 2, seasonNumber: 2, episodes: new[] { 1 }); + AddPending(id: 3, seasonNumber: 2, episodes: new[] { 2 }); + AddPending(id: 4, seasonNumber: 2, episodes: new[] { 3 }); + + var queueId = HashConverter.GetHashInt31(string.Format("pending-{0}-ep{1}", 1, _episode.Id)); + + Subject.RemovePendingQueueItemsObsolete(queueId); + + AssertRemoved(1, 2); + } + + [Test] + public void should_not_remove_multiepisodes() + { + AddPending(id: 1, seasonNumber: 2, episodes: new[] { 1 }); + AddPending(id: 2, seasonNumber: 2, episodes: new[] { 1, 2 }); + + var queueId = HashConverter.GetHashInt31(string.Format("pending-{0}-ep{1}", 1, _episode.Id)); + + Subject.RemovePendingQueueItemsObsolete(queueId); + + AssertRemoved(1); + } + + [Test] + public void should_not_remove_singleepisodes() + { + AddPending(id: 1, seasonNumber: 2, episodes: new[] { 1 }); + AddPending(id: 2, seasonNumber: 2, episodes: new[] { 1, 2 }); + + var queueId = HashConverter.GetHashInt31(string.Format("pending-{0}-ep{1}", 2, _episode.Id)); + + Subject.RemovePendingQueueItemsObsolete(queueId); + + AssertRemoved(2); + } + + private void AssertRemoved(params int[] ids) + { + Mocker.GetMock().Verify(c => c.DeleteMany(It.Is>(s => s.SequenceEqual(ids)))); + } + } +} diff --git a/src/NzbDrone.Core.Test/QueueTests/ObsoleteQueueServiceFixture.cs b/src/NzbDrone.Core.Test/QueueTests/ObsoleteQueueServiceFixture.cs new file mode 100644 index 000000000..07a57209f --- /dev/null +++ b/src/NzbDrone.Core.Test/QueueTests/ObsoleteQueueServiceFixture.cs @@ -0,0 +1,70 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FizzWare.NBuilder; +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Core.Download; +using NzbDrone.Core.Download.TrackedDownloads; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Queue; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Tv; + +namespace NzbDrone.Core.Test.QueueTests +{ + [TestFixture] + public class ObsoleteQueueServiceFixture : CoreTest + { + private List _trackedDownloads; + + [SetUp] + public void SetUp() + { + var downloadClientInfo = Builder.CreateNew().Build(); + + var downloadItem = Builder.CreateNew() + .With(v => v.RemainingTime = TimeSpan.FromSeconds(10)) + .With(v => v.DownloadClientInfo = downloadClientInfo) + .Build(); + + var series = Builder.CreateNew() + .Build(); + + var episodes = Builder.CreateListOfSize(3) + .All() + .With(e => e.SeriesId = series.Id) + .Build(); + + var remoteEpisode = Builder.CreateNew() + .With(r => r.Series = series) + .With(r => r.Episodes = new List(episodes)) + .With(r => r.ParsedEpisodeInfo = new ParsedEpisodeInfo()) + .Build(); + + _trackedDownloads = Builder.CreateListOfSize(1) + .All() + .With(v => v.IsTrackable = true) + .With(v => v.DownloadItem = downloadItem) + .With(v => v.RemoteEpisode = remoteEpisode) + .Build() + .ToList(); + } + + [Test] + public void queue_items_should_have_id() + { + Subject.Handle(new TrackedDownloadRefreshedEvent(_trackedDownloads)); + + var queue = Subject.GetQueue(); + + queue.Should().HaveCount(3); + + queue.All(v => v.Id > 0).Should().BeTrue(); + + var distinct = queue.Select(v => v.Id).Distinct().ToArray(); + + distinct.Should().HaveCount(3); + } + } +} diff --git a/src/NzbDrone.Core.Test/QueueTests/QueueServiceFixture.cs b/src/NzbDrone.Core.Test/QueueTests/QueueServiceFixture.cs index 0d205e2c9..6b82fb5ce 100644 --- a/src/NzbDrone.Core.Test/QueueTests/QueueServiceFixture.cs +++ b/src/NzbDrone.Core.Test/QueueTests/QueueServiceFixture.cs @@ -58,13 +58,9 @@ public void queue_items_should_have_id() var queue = Subject.GetQueue(); - queue.Should().HaveCount(3); + queue.Should().HaveCount(1); queue.All(v => v.Id > 0).Should().BeTrue(); - - var distinct = queue.Select(v => v.Id).Distinct().ToArray(); - - distinct.Should().HaveCount(3); } } } diff --git a/src/NzbDrone.Core/Download/Pending/PendingReleaseService.cs b/src/NzbDrone.Core/Download/Pending/PendingReleaseService.cs index 67cc05842..ed12ab3cc 100644 --- a/src/NzbDrone.Core/Download/Pending/PendingReleaseService.cs +++ b/src/NzbDrone.Core/Download/Pending/PendingReleaseService.cs @@ -31,6 +31,9 @@ public interface IPendingReleaseService Queue.Queue FindPendingQueueItem(int queueId); void RemovePendingQueueItems(int queueId); RemoteEpisode OldestPendingRelease(int seriesId, int[] episodeIds); + List GetPendingQueueObsolete(); + Queue.Queue FindPendingQueueItemObsolete(int queueId); + void RemovePendingQueueItemsObsolete(int queueId); } public class PendingReleaseService : IPendingReleaseService, @@ -187,7 +190,44 @@ public List GetPendingRemoteEpisodes(int seriesId) { if (pendingRelease.RemoteEpisode.Episodes.Empty()) { - var noEpisodeItem = GetQueueItem(pendingRelease, nextRssSync, null); + var noEpisodeItem = GetQueueItem(pendingRelease, nextRssSync, []); + + noEpisodeItem.ErrorMessage = "Unable to find matching episode(s)"; + + queued.Add(noEpisodeItem); + + continue; + } + + queued.Add(GetQueueItem(pendingRelease, nextRssSync, pendingRelease.RemoteEpisode.Episodes)); + } + + // Return best quality release for each episode group, this may result in multiple for the same episode if the episodes in each release differ + var deduped = queued.Where(q => q.Episodes.Any()).GroupBy(q => q.Episodes.Select(e => e.Id)).Select(g => + { + var series = g.First().Series; + + return g.OrderByDescending(e => e.Quality, new QualityModelComparer(series.QualityProfile)) + .ThenBy(q => PrioritizeDownloadProtocol(q.Series, q.Protocol)) + .First(); + }); + + return deduped.ToList(); + } + + public List GetPendingQueueObsolete() + { + var queued = new List(); + + var nextRssSync = new Lazy(() => _taskManager.GetNextExecution(typeof(RssSyncCommand))); + + var pendingReleases = IncludeRemoteEpisodes(_repository.WithoutFallback()); + + foreach (var pendingRelease in pendingReleases) + { + if (pendingRelease.RemoteEpisode.Episodes.Empty()) + { + var noEpisodeItem = GetQueueItem(pendingRelease, nextRssSync, (Episode)null); noEpisodeItem.ErrorMessage = "Unable to find matching episode(s)"; @@ -202,15 +242,18 @@ public List GetPendingRemoteEpisodes(int seriesId) } } +#pragma warning disable CS0612 + // Return best quality release for each episode var deduped = queued.Where(q => q.Episode != null).GroupBy(q => q.Episode.Id).Select(g => { var series = g.First().Series; return g.OrderByDescending(e => e.Quality, new QualityModelComparer(series.QualityProfile)) - .ThenBy(q => PrioritizeDownloadProtocol(q.Series, q.Protocol)) - .First(); + .ThenBy(q => PrioritizeDownloadProtocol(q.Series, q.Protocol)) + .First(); }); +#pragma warning restore CS0612 return deduped.ToList(); } @@ -220,6 +263,11 @@ public Queue.Queue FindPendingQueueItem(int queueId) return GetPendingQueue().SingleOrDefault(p => p.Id == queueId); } + public Queue.Queue FindPendingQueueItemObsolete(int queueId) + { + return GetPendingQueue().SingleOrDefault(p => p.Id == queueId); + } + public void RemovePendingQueueItems(int queueId) { var targetItem = FindPendingRelease(queueId); @@ -232,6 +280,18 @@ public void RemovePendingQueueItems(int queueId) _repository.DeleteMany(releasesToRemove.Select(c => c.Id)); } + public void RemovePendingQueueItemsObsolete(int queueId) + { + var targetItem = FindPendingReleaseObsolete(queueId); + var seriesReleases = _repository.AllBySeriesId(targetItem.SeriesId); + + var releasesToRemove = seriesReleases.Where( + c => c.ParsedEpisodeInfo.SeasonNumber == targetItem.ParsedEpisodeInfo.SeasonNumber && + c.ParsedEpisodeInfo.EpisodeNumbers.SequenceEqual(targetItem.ParsedEpisodeInfo.EpisodeNumbers)); + + _repository.DeleteMany(releasesToRemove.Select(c => c.Id)); + } + public RemoteEpisode OldestPendingRelease(int seriesId, int[] episodeIds) { var seriesReleases = GetPendingReleases(seriesId); @@ -346,6 +406,59 @@ private List IncludeRemoteEpisodes(List releases return result; } + private Queue.Queue GetQueueItem(PendingRelease pendingRelease, Lazy nextRssSync, List episodes) + { + var ect = pendingRelease.Release.PublishDate.AddMinutes(GetDelay(pendingRelease.RemoteEpisode)); + + if (ect < nextRssSync.Value) + { + ect = nextRssSync.Value; + } + else + { + ect = ect.AddMinutes(_configService.RssSyncInterval); + } + + var timeLeft = ect.Subtract(DateTime.UtcNow); + + if (timeLeft.TotalSeconds < 0) + { + timeLeft = TimeSpan.Zero; + } + + string downloadClientName = null; + var indexer = _indexerFactory.Find(pendingRelease.Release.IndexerId); + + if (indexer is { DownloadClientId: > 0 }) + { + var downloadClient = _downloadClientFactory.Find(indexer.DownloadClientId); + + downloadClientName = downloadClient?.Name; + } + + var queue = new Queue.Queue + { + Id = GetQueueId(pendingRelease), + Series = pendingRelease.RemoteEpisode.Series, + Episodes = episodes, + Languages = pendingRelease.RemoteEpisode.Languages, + Quality = pendingRelease.RemoteEpisode.ParsedEpisodeInfo.Quality, + Title = pendingRelease.Title, + Size = pendingRelease.RemoteEpisode.Release.Size, + SizeLeft = pendingRelease.RemoteEpisode.Release.Size, + RemoteEpisode = pendingRelease.RemoteEpisode, + TimeLeft = timeLeft, + EstimatedCompletionTime = ect, + Added = pendingRelease.Added, + Status = Enum.TryParse(pendingRelease.Reason.ToString(), out QueueStatus outValue) ? outValue : QueueStatus.Unknown, + Protocol = pendingRelease.RemoteEpisode.Release.DownloadProtocol, + Indexer = pendingRelease.RemoteEpisode.Release.Indexer, + DownloadClient = downloadClientName + }; + + return queue; + } + private Queue.Queue GetQueueItem(PendingRelease pendingRelease, Lazy nextRssSync, Episode episode) { var ect = pendingRelease.Release.PublishDate.AddMinutes(GetDelay(pendingRelease.RemoteEpisode)); @@ -380,7 +493,11 @@ private Queue.Queue GetQueueItem(PendingRelease pendingRelease, Lazy n { Id = GetQueueId(pendingRelease, episode), Series = pendingRelease.RemoteEpisode.Series, + +#pragma warning disable CS0612 Episode = episode, +#pragma warning restore CS0612 + Languages = pendingRelease.RemoteEpisode.Languages, Quality = pendingRelease.RemoteEpisode.ParsedEpisodeInfo.Quality, Title = pendingRelease.Title, @@ -484,10 +601,20 @@ private void RemoveRejected(List rejected) } private PendingRelease FindPendingRelease(int queueId) + { + return GetPendingReleases().First(p => GetQueueId(p) == queueId); + } + + private PendingRelease FindPendingReleaseObsolete(int queueId) { return GetPendingReleases().First(p => p.RemoteEpisode.Episodes.Any(e => queueId == GetQueueId(p, e))); } + private int GetQueueId(PendingRelease pendingRelease) + { + return HashConverter.GetHashInt31(string.Format("pending-{0}", pendingRelease.Id)); + } + private int GetQueueId(PendingRelease pendingRelease, Episode episode) { return HashConverter.GetHashInt31(string.Format("pending-{0}-ep{1}", pendingRelease.Id, episode?.Id ?? 0)); diff --git a/src/NzbDrone.Core/IndexerSearch/EpisodeSearchService.cs b/src/NzbDrone.Core/IndexerSearch/EpisodeSearchService.cs index b4ee4932f..c4931b53c 100644 --- a/src/NzbDrone.Core/IndexerSearch/EpisodeSearchService.cs +++ b/src/NzbDrone.Core/IndexerSearch/EpisodeSearchService.cs @@ -154,7 +154,7 @@ public void Execute(MissingEpisodeSearchCommand message) episodes = _episodeService.EpisodesWithoutFiles(pagingSpec).Records.ToList(); } - var queue = _queueService.GetQueue().Where(q => q.Episode != null).Select(q => q.Episode.Id); + var queue = GetQueuedEpisodeIds(); var missing = episodes.Where(e => !queue.Contains(e.Id)).ToList(); SearchForBulkEpisodes(missing, monitored, message.Trigger == CommandTrigger.Manual).GetAwaiter().GetResult(); @@ -188,10 +188,18 @@ public void Execute(CutoffUnmetEpisodeSearchCommand message) } var episodes = _episodeCutoffService.EpisodesWhereCutoffUnmet(pagingSpec).Records.ToList(); - var queue = _queueService.GetQueue().Where(q => q.Episode != null).Select(q => q.Episode.Id); + var queue = GetQueuedEpisodeIds(); var cutoffUnmet = episodes.Where(e => !queue.Contains(e.Id)).ToList(); SearchForBulkEpisodes(cutoffUnmet, monitored, message.Trigger == CommandTrigger.Manual).GetAwaiter().GetResult(); } + + private List GetQueuedEpisodeIds() + { + return _queueService.GetQueue() + .Where(q => q.Episodes.Any()) + .SelectMany(q => q.Episodes.Select(e => e.Id)) + .ToList(); + } } } diff --git a/src/NzbDrone.Core/Queue/ObsoleteQueueService.cs b/src/NzbDrone.Core/Queue/ObsoleteQueueService.cs new file mode 100644 index 000000000..e7054f969 --- /dev/null +++ b/src/NzbDrone.Core/Queue/ObsoleteQueueService.cs @@ -0,0 +1,110 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using NzbDrone.Common.Crypto; +using NzbDrone.Core.Download.TrackedDownloads; +using NzbDrone.Core.Languages; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.Qualities; +using NzbDrone.Core.Tv; + +#pragma warning disable CS0612 +namespace NzbDrone.Core.Queue +{ + public interface IObsoleteQueueService + { + List GetQueue(); + Queue Find(int id); + void Remove(int id); + } + + public class ObsoleteQueueService : IObsoleteQueueService, IHandle + { + private readonly IEventAggregator _eventAggregator; + private static List _queue = new(); + + public ObsoleteQueueService(IEventAggregator eventAggregator) + { + _eventAggregator = eventAggregator; + } + + public List GetQueue() + { + return _queue; + } + + public Queue Find(int id) + { + return _queue.SingleOrDefault(q => q.Id == id); + } + + public void Remove(int id) + { + _queue.Remove(Find(id)); + } + + private IEnumerable MapQueue(TrackedDownload trackedDownload) + { + if (trackedDownload.RemoteEpisode?.Episodes != null && trackedDownload.RemoteEpisode.Episodes.Any()) + { + foreach (var episode in trackedDownload.RemoteEpisode.Episodes) + { + yield return MapQueueItem(trackedDownload, episode); + } + } + else + { + yield return MapQueueItem(trackedDownload, null); + } + } + + private Queue MapQueueItem(TrackedDownload trackedDownload, Episode episode) + { + var queue = new Queue + { + Series = trackedDownload.RemoteEpisode?.Series, + Episode = episode, + Languages = trackedDownload.RemoteEpisode?.Languages ?? new List { Language.Unknown }, + Quality = trackedDownload.RemoteEpisode?.ParsedEpisodeInfo.Quality ?? new QualityModel(Quality.Unknown), + Title = Parser.Parser.RemoveFileExtension(trackedDownload.DownloadItem.Title), + Size = trackedDownload.DownloadItem.TotalSize, + SizeLeft = trackedDownload.DownloadItem.RemainingSize, + TimeLeft = trackedDownload.DownloadItem.RemainingTime, + Status = Enum.TryParse(trackedDownload.DownloadItem.Status.ToString(), out QueueStatus outValue) ? outValue : QueueStatus.Unknown, + TrackedDownloadStatus = trackedDownload.Status, + TrackedDownloadState = trackedDownload.State, + StatusMessages = trackedDownload.StatusMessages.ToList(), + ErrorMessage = trackedDownload.DownloadItem.Message, + RemoteEpisode = trackedDownload.RemoteEpisode, + DownloadId = trackedDownload.DownloadItem.DownloadId, + Protocol = trackedDownload.Protocol, + DownloadClient = trackedDownload.DownloadItem.DownloadClientInfo.Name, + Indexer = trackedDownload.Indexer, + OutputPath = trackedDownload.DownloadItem.OutputPath.ToString(), + Added = trackedDownload.Added, + DownloadClientHasPostImportCategory = trackedDownload.DownloadItem.DownloadClientInfo.HasPostImportCategory + }; + + queue.Id = HashConverter.GetHashInt31($"trackedDownload-{trackedDownload.DownloadClient}-{trackedDownload.DownloadItem.DownloadId}-ep{episode?.Id ?? 0}"); + + if (queue.TimeLeft.HasValue) + { + queue.EstimatedCompletionTime = DateTime.UtcNow.Add(queue.TimeLeft.Value); + } + + return queue; + } + + public void Handle(TrackedDownloadRefreshedEvent message) + { + _queue = message.TrackedDownloads + .Where(t => t.IsTrackable) + .OrderBy(c => c.DownloadItem.RemainingTime) + .SelectMany(MapQueue) + .ToList(); + + _eventAggregator.PublishEvent(new ObsoleteQueueUpdatedEvent()); + } + } +} +#pragma warning restore CS0612 diff --git a/src/NzbDrone.Core/Queue/ObsoleteQueueUpdatedEvent.cs b/src/NzbDrone.Core/Queue/ObsoleteQueueUpdatedEvent.cs new file mode 100644 index 000000000..ee860cf4c --- /dev/null +++ b/src/NzbDrone.Core/Queue/ObsoleteQueueUpdatedEvent.cs @@ -0,0 +1,8 @@ +using NzbDrone.Common.Messaging; + +namespace NzbDrone.Core.Queue +{ + public class ObsoleteQueueUpdatedEvent : IEvent + { + } +} diff --git a/src/NzbDrone.Core/Queue/Queue.cs b/src/NzbDrone.Core/Queue/Queue.cs index c52fac49f..c5ea1a2cc 100644 --- a/src/NzbDrone.Core/Queue/Queue.cs +++ b/src/NzbDrone.Core/Queue/Queue.cs @@ -13,7 +13,13 @@ namespace NzbDrone.Core.Queue public class Queue : ModelBase { public Series Series { get; set; } + + public int? SeasonNumber { get; set; } + + [Obsolete] public Episode Episode { get; set; } + + public List Episodes { get; set; } public List Languages { get; set; } public QualityModel Quality { get; set; } public decimal Size { get; set; } diff --git a/src/NzbDrone.Core/Queue/QueueService.cs b/src/NzbDrone.Core/Queue/QueueService.cs index 4e7d39f7a..2d4f96d46 100644 --- a/src/NzbDrone.Core/Queue/QueueService.cs +++ b/src/NzbDrone.Core/Queue/QueueService.cs @@ -47,10 +47,7 @@ private IEnumerable MapQueue(TrackedDownload trackedDownload) { if (trackedDownload.RemoteEpisode?.Episodes != null && trackedDownload.RemoteEpisode.Episodes.Any()) { - foreach (var episode in trackedDownload.RemoteEpisode.Episodes) - { - yield return MapQueueItem(trackedDownload, episode); - } + yield return MapQueueItem(trackedDownload, trackedDownload.RemoteEpisode.Episodes); } else { @@ -58,12 +55,13 @@ private IEnumerable MapQueue(TrackedDownload trackedDownload) } } - private Queue MapQueueItem(TrackedDownload trackedDownload, Episode episode) + private Queue MapQueueItem(TrackedDownload trackedDownload, List episodes) { var queue = new Queue { Series = trackedDownload.RemoteEpisode?.Series, - Episode = episode, + SeasonNumber = trackedDownload.RemoteEpisode?.MappedSeasonNumber, + Episodes = episodes, Languages = trackedDownload.RemoteEpisode?.Languages ?? new List { Language.Unknown }, Quality = trackedDownload.RemoteEpisode?.ParsedEpisodeInfo.Quality ?? new QualityModel(Quality.Unknown), Title = FileExtensions.RemoveFileExtension(trackedDownload.DownloadItem.Title), @@ -85,7 +83,7 @@ private Queue MapQueueItem(TrackedDownload trackedDownload, Episode episode) DownloadClientHasPostImportCategory = trackedDownload.DownloadItem.DownloadClientInfo.HasPostImportCategory }; - queue.Id = HashConverter.GetHashInt31($"trackedDownload-{trackedDownload.DownloadClient}-{trackedDownload.DownloadItem.DownloadId}-ep{episode?.Id ?? 0}"); + queue.Id = HashConverter.GetHashInt31($"trackedDownload-{trackedDownload.DownloadClient}-{trackedDownload.DownloadItem.DownloadId}"); if (queue.TimeLeft.HasValue) { diff --git a/src/NzbDrone.SignalR/SignalRMessage.cs b/src/NzbDrone.SignalR/SignalRMessage.cs index 4b468477c..ba884a7dd 100644 --- a/src/NzbDrone.SignalR/SignalRMessage.cs +++ b/src/NzbDrone.SignalR/SignalRMessage.cs @@ -9,5 +9,7 @@ public class SignalRMessage [System.Text.Json.Serialization.JsonIgnore] public ModelAction Action { get; set; } + + public int? Version { get; set; } } } diff --git a/src/Sonarr.Api.V3/Queue/QueueController.cs b/src/Sonarr.Api.V3/Queue/QueueController.cs index df440c3a5..633424fda 100644 --- a/src/Sonarr.Api.V3/Queue/QueueController.cs +++ b/src/Sonarr.Api.V3/Queue/QueueController.cs @@ -21,13 +21,14 @@ using Sonarr.Http.REST; using Sonarr.Http.REST.Attributes; +#pragma warning disable CS0612 namespace Sonarr.Api.V3.Queue { [V3ApiController] public class QueueController : RestControllerWithSignalR, - IHandle, IHandle + IHandle, IHandle { - private readonly IQueueService _queueService; + private readonly IObsoleteQueueService _queueService; private readonly IPendingReleaseService _pendingReleaseService; private readonly QualityModelComparer _qualityComparer; @@ -38,7 +39,7 @@ public class QueueController : RestControllerWithSignalR GetQueue([FromQuery] PagingRequestResource var queue = _queueService.GetQueue(); var filteredQueue = includeUnknownSeriesItems ? queue : queue.Where(q => q.Series != null); - var pending = _pendingReleaseService.GetPendingQueue(); + var pending = _pendingReleaseService.GetPendingQueueObsolete(); var hasSeriesIdFilter = seriesIds is { Count: > 0 }; var hasLanguageFilter = languages is { Count: > 0 }; @@ -325,7 +326,7 @@ private void Remove(NzbDrone.Core.Queue.Queue pendingRelease, bool blocklist) _blocklistService.Block(pendingRelease.RemoteEpisode, "Pending release manually blocklisted"); } - _pendingReleaseService.RemovePendingQueueItems(pendingRelease.Id); + _pendingReleaseService.RemovePendingQueueItemsObsolete(pendingRelease.Id); } private TrackedDownload Remove(TrackedDownload trackedDownload, bool removeFromClient, bool blocklist, bool skipRedownload, bool changeCategory) @@ -394,7 +395,7 @@ private QueueResource MapToResource(NzbDrone.Core.Queue.Queue queueItem, bool in } [NonAction] - public void Handle(QueueUpdatedEvent message) + public void Handle(ObsoleteQueueUpdatedEvent message) { BroadcastResourceChange(ModelAction.Sync); } @@ -406,3 +407,4 @@ public void Handle(PendingReleasesUpdatedEvent message) } } } +#pragma warning restore CS0612 diff --git a/src/Sonarr.Api.V3/Queue/QueueDetailsController.cs b/src/Sonarr.Api.V3/Queue/QueueDetailsController.cs index b9c7cb9c2..152b4b733 100644 --- a/src/Sonarr.Api.V3/Queue/QueueDetailsController.cs +++ b/src/Sonarr.Api.V3/Queue/QueueDetailsController.cs @@ -10,16 +10,17 @@ using Sonarr.Http; using Sonarr.Http.REST; +#pragma warning disable CS0612 namespace Sonarr.Api.V3.Queue { [V3ApiController("queue/details")] public class QueueDetailsController : RestControllerWithSignalR, - IHandle, IHandle + IHandle, IHandle { - private readonly IQueueService _queueService; + private readonly IObsoleteQueueService _queueService; private readonly IPendingReleaseService _pendingReleaseService; - public QueueDetailsController(IBroadcastSignalRMessage broadcastSignalRMessage, IQueueService queueService, IPendingReleaseService pendingReleaseService) + public QueueDetailsController(IBroadcastSignalRMessage broadcastSignalRMessage, IObsoleteQueueService queueService, IPendingReleaseService pendingReleaseService) : base(broadcastSignalRMessage) { _queueService = queueService; @@ -59,7 +60,7 @@ public List GetQueue(int? seriesId, [FromQuery]List episodeI } [NonAction] - public void Handle(QueueUpdatedEvent message) + public void Handle(ObsoleteQueueUpdatedEvent message) { BroadcastResourceChange(ModelAction.Sync); } @@ -71,3 +72,4 @@ public void Handle(PendingReleasesUpdatedEvent message) } } } +#pragma warning restore CS0612 diff --git a/src/Sonarr.Api.V3/Queue/QueueResource.cs b/src/Sonarr.Api.V3/Queue/QueueResource.cs index f209a3ccc..6c0acf611 100644 --- a/src/Sonarr.Api.V3/Queue/QueueResource.cs +++ b/src/Sonarr.Api.V3/Queue/QueueResource.cs @@ -11,6 +11,7 @@ using Sonarr.Api.V3.Series; using Sonarr.Http.REST; +#pragma warning disable CS0612 namespace Sonarr.Api.V3.Queue { public class QueueResource : RestResource @@ -112,3 +113,4 @@ public static List ToResource(this IEnumerable, - IHandle, IHandle + IHandle, IHandle { - private readonly IQueueService _queueService; + private readonly IObsoleteQueueService _queueService; private readonly IPendingReleaseService _pendingReleaseService; private readonly Debouncer _broadcastDebounce; - public QueueStatusController(IBroadcastSignalRMessage broadcastSignalRMessage, IQueueService queueService, IPendingReleaseService pendingReleaseService) + public QueueStatusController(IBroadcastSignalRMessage broadcastSignalRMessage, IObsoleteQueueService queueService, IPendingReleaseService pendingReleaseService) : base(broadcastSignalRMessage) { _queueService = queueService; @@ -72,7 +72,7 @@ private void BroadcastChange() } [NonAction] - public void Handle(QueueUpdatedEvent message) + public void Handle(ObsoleteQueueUpdatedEvent message) { _broadcastDebounce.Execute(); } diff --git a/src/Sonarr.Api.V5/CustomFormats/CustomFormatResource.cs b/src/Sonarr.Api.V5/CustomFormats/CustomFormatResource.cs new file mode 100644 index 000000000..c894e00a5 --- /dev/null +++ b/src/Sonarr.Api.V5/CustomFormats/CustomFormatResource.cs @@ -0,0 +1,75 @@ +using System.Text.Json.Serialization; +using NzbDrone.Core.CustomFormats; +using Sonarr.Http.ClientSchema; +using Sonarr.Http.REST; + +namespace Sonarr.Api.V5.CustomFormats +{ + public class CustomFormatResource : RestResource + { + [JsonIgnore(Condition = JsonIgnoreCondition.Never)] + public override int Id { get; set; } + public required string Name { get; set; } + public bool? IncludeCustomFormatWhenRenaming { get; set; } + public List? Specifications { get; set; } + } + + public static class CustomFormatResourceMapper + { + public static CustomFormatResource ToResource(this CustomFormat model, bool includeDetails) + { + var resource = new CustomFormatResource + { + Id = model.Id, + Name = model.Name + }; + + if (includeDetails) + { + resource.IncludeCustomFormatWhenRenaming = model.IncludeCustomFormatWhenRenaming; + resource.Specifications = model.Specifications.Select(x => x.ToSchema()).ToList(); + } + + return resource; + } + + public static List ToResource(this IEnumerable models, bool includeDetails) + { + return models.Select(m => m.ToResource(includeDetails)).ToList(); + } + + public static CustomFormat ToModel(this CustomFormatResource resource, List specifications) + { + return new CustomFormat + { + Id = resource.Id, + Name = resource.Name, + IncludeCustomFormatWhenRenaming = resource.IncludeCustomFormatWhenRenaming ?? false, + Specifications = resource.Specifications?.Select(x => MapSpecification(x, specifications)).ToList() ?? new List() + }; + } + + private static ICustomFormatSpecification MapSpecification(CustomFormatSpecificationSchema resource, List specifications) + { + var matchingSpec = + specifications.SingleOrDefault(x => x.GetType().Name == resource.Implementation); + + if (matchingSpec is null) + { + throw new ArgumentException( + $"{resource.Implementation} is not a valid specification implementation"); + } + + var type = matchingSpec.GetType(); + + // Finding the exact current specification isn't possible given the dynamic nature of them and the possibility that multiple + // of the same type exist within the same format. Passing in null is safe as long as there never exists a specification that + // relies on additional privacy. + var spec = (ICustomFormatSpecification)SchemaBuilder.ReadFromSchema(resource.Fields, type, null); + spec.Name = resource.Name; + spec.Negate = resource.Negate; + spec.Required = resource.Required; + return spec; + } + } +} diff --git a/src/Sonarr.Api.V5/CustomFormats/CustomFormatSpecificationSchema.cs b/src/Sonarr.Api.V5/CustomFormats/CustomFormatSpecificationSchema.cs new file mode 100644 index 000000000..cbca7e554 --- /dev/null +++ b/src/Sonarr.Api.V5/CustomFormats/CustomFormatSpecificationSchema.cs @@ -0,0 +1,35 @@ +using NzbDrone.Core.CustomFormats; +using Sonarr.Http.ClientSchema; +using Sonarr.Http.REST; + +namespace Sonarr.Api.V5.CustomFormats +{ + public class CustomFormatSpecificationSchema : RestResource + { + public required string Name { get; set; } + public required string Implementation { get; set; } + public required string ImplementationName { get; set; } + public required string InfoLink { get; set; } + public bool Negate { get; set; } + public bool Required { get; set; } + public required List Fields { get; set; } + public List? Presets { get; set; } + } + + public static class CustomFormatSpecificationSchemaMapper + { + public static CustomFormatSpecificationSchema ToSchema(this ICustomFormatSpecification model) + { + return new CustomFormatSpecificationSchema + { + Name = model.Name, + Implementation = model.GetType().Name, + ImplementationName = model.ImplementationName, + InfoLink = model.InfoLink, + Negate = model.Negate, + Required = model.Required, + Fields = SchemaBuilder.ToSchema(model) + }; + } + } +} diff --git a/src/Sonarr.Api.V5/EpisodeFiles/EpisodeFileResource.cs b/src/Sonarr.Api.V5/EpisodeFiles/EpisodeFileResource.cs new file mode 100644 index 000000000..c103c17bf --- /dev/null +++ b/src/Sonarr.Api.V5/EpisodeFiles/EpisodeFileResource.cs @@ -0,0 +1,64 @@ +using NzbDrone.Core.CustomFormats; +using NzbDrone.Core.DecisionEngine.Specifications; +using NzbDrone.Core.Languages; +using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Qualities; +using Sonarr.Api.V5.CustomFormats; +using Sonarr.Http.REST; + +namespace Sonarr.Api.V5.EpisodeFiles +{ + public class EpisodeFileResource : RestResource + { + public int SeriesId { get; set; } + public int SeasonNumber { get; set; } + public string? RelativePath { get; set; } + public string? Path { get; set; } + public long Size { get; set; } + public DateTime DateAdded { get; set; } + public string? SceneName { get; set; } + public string? ReleaseGroup { get; set; } + public required List Languages { get; set; } + public required QualityModel Quality { get; set; } + public required List CustomFormats { get; set; } + public int CustomFormatScore { get; set; } + public int? IndexerFlags { get; set; } + public ReleaseType? ReleaseType { get; set; } + public MediaInfoResource? MediaInfo { get; set; } + + public bool QualityCutoffNotMet { get; set; } + } + + public static class EpisodeFileResourceMapper + { + public static EpisodeFileResource ToResource(this EpisodeFile model, NzbDrone.Core.Tv.Series series, IUpgradableSpecification upgradableSpecification, ICustomFormatCalculationService formatCalculationService) + { + model.Series = series; + var customFormats = formatCalculationService?.ParseCustomFormat(model, model.Series) ?? []; + var customFormatScore = series.QualityProfile?.Value?.CalculateCustomFormatScore(customFormats) ?? 0; + + return new EpisodeFileResource + { + Id = model.Id, + + SeriesId = model.SeriesId, + SeasonNumber = model.SeasonNumber, + RelativePath = model.RelativePath, + Path = Path.Combine(series.Path, model.RelativePath), + Size = model.Size, + DateAdded = model.DateAdded, + SceneName = model.SceneName, + ReleaseGroup = model.ReleaseGroup, + Languages = model.Languages, + Quality = model.Quality, + MediaInfo = model.MediaInfo.ToResource(model.SceneName), + QualityCutoffNotMet = upgradableSpecification.QualityCutoffNotMet(series.QualityProfile!.Value, model.Quality), + CustomFormats = customFormats.ToResource(false), + CustomFormatScore = customFormatScore, + IndexerFlags = (int)model.IndexerFlags, + ReleaseType = model.ReleaseType, + }; + } + } +} diff --git a/src/Sonarr.Api.V5/EpisodeFiles/MediaInfoResource.cs b/src/Sonarr.Api.V5/EpisodeFiles/MediaInfoResource.cs new file mode 100644 index 000000000..323198955 --- /dev/null +++ b/src/Sonarr.Api.V5/EpisodeFiles/MediaInfoResource.cs @@ -0,0 +1,68 @@ +using NzbDrone.Common.Extensions; +using NzbDrone.Core.MediaFiles.MediaInfo; +using Sonarr.Http.REST; + +namespace Sonarr.Api.V5.EpisodeFiles +{ + public class MediaInfoResource : RestResource + { + public long AudioBitrate { get; set; } + public decimal AudioChannels { get; set; } + public string? AudioCodec { get; set; } + public string? AudioLanguages { get; set; } + public int AudioStreamCount { get; set; } + public int VideoBitDepth { get; set; } + public long VideoBitrate { get; set; } + public string? VideoCodec { get; set; } + public decimal VideoFps { get; set; } + public string? VideoDynamicRange { get; set; } + public string? VideoDynamicRangeType { get; set; } + public string? Resolution { get; set; } + public string? RunTime { get; set; } + public string? ScanType { get; set; } + public string? Subtitles { get; set; } + } + + public static class MediaInfoResourceMapper + { + public static MediaInfoResource ToResource(this MediaInfoModel model, string sceneName) + { + return new MediaInfoResource + { + AudioBitrate = model.AudioBitrate, + AudioChannels = MediaInfoFormatter.FormatAudioChannels(model), + AudioLanguages = model.AudioLanguages.ConcatToString("/"), + AudioStreamCount = model.AudioStreamCount, + AudioCodec = MediaInfoFormatter.FormatAudioCodec(model, sceneName), + VideoBitDepth = model.VideoBitDepth, + VideoBitrate = model.VideoBitrate, + VideoCodec = MediaInfoFormatter.FormatVideoCodec(model, sceneName), + VideoFps = Math.Round(model.VideoFps, 3), + VideoDynamicRange = MediaInfoFormatter.FormatVideoDynamicRange(model), + VideoDynamicRangeType = MediaInfoFormatter.FormatVideoDynamicRangeType(model), + Resolution = $"{model.Width}x{model.Height}", + RunTime = FormatRuntime(model.RunTime), + ScanType = model.ScanType, + Subtitles = model.Subtitles.ConcatToString("/") + }; + } + + private static string FormatRuntime(TimeSpan runTime) + { + var formattedRuntime = ""; + + if (runTime.Hours > 0) + { + formattedRuntime += $"{runTime.Hours}:{runTime.Minutes:00}:"; + } + else + { + formattedRuntime += $"{runTime.Minutes}:"; + } + + formattedRuntime += $"{runTime.Seconds:00}"; + + return formattedRuntime; + } + } +} diff --git a/src/Sonarr.Api.V5/Episodes/EpisodeResource.cs b/src/Sonarr.Api.V5/Episodes/EpisodeResource.cs new file mode 100644 index 000000000..a69942009 --- /dev/null +++ b/src/Sonarr.Api.V5/Episodes/EpisodeResource.cs @@ -0,0 +1,84 @@ +using System.Text.Json.Serialization; +using NzbDrone.Core.MediaCover; +using NzbDrone.Core.Tv; +using Sonarr.Api.V5.EpisodeFiles; +using Sonarr.Api.V5.Series; +using Sonarr.Http.REST; +using Swashbuckle.AspNetCore.Annotations; + +namespace Sonarr.Api.V5.Episodes +{ + public class EpisodeResource : RestResource + { + public int SeriesId { get; set; } + public int TvdbId { get; set; } + public int EpisodeFileId { get; set; } + public int SeasonNumber { get; set; } + public int EpisodeNumber { get; set; } + public required string Title { get; set; } + public string? AirDate { get; set; } + public DateTime? AirDateUtc { get; set; } + public DateTime? LastSearchTime { get; set; } + public int Runtime { get; set; } + public string? FinaleType { get; set; } + public string? Overview { get; set; } + public EpisodeFileResource? EpisodeFile { get; set; } + public bool HasFile { get; set; } + public bool Monitored { get; set; } + public int? AbsoluteEpisodeNumber { get; set; } + public int? SceneAbsoluteEpisodeNumber { get; set; } + public int? SceneEpisodeNumber { get; set; } + public int? SceneSeasonNumber { get; set; } + public bool UnverifiedSceneNumbering { get; set; } + public DateTime? EndTime { get; set; } + public DateTime? GrabDate { get; set; } + public SeriesResource? Series { get; set; } + public List? Images { get; set; } + + // Hiding this so people don't think its usable (only used to set the initial state) + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + [SwaggerIgnore] + public bool Grabbed { get; set; } + } + + public static class EpisodeResourceMapper + { + public static EpisodeResource ToResource(this Episode model) + { + return new EpisodeResource + { + Id = model.Id, + + SeriesId = model.SeriesId, + TvdbId = model.TvdbId, + EpisodeFileId = model.EpisodeFileId, + SeasonNumber = model.SeasonNumber, + EpisodeNumber = model.EpisodeNumber, + Title = model.Title, + AirDate = model.AirDate, + AirDateUtc = model.AirDateUtc, + Runtime = model.Runtime, + FinaleType = model.FinaleType, + Overview = model.Overview, + LastSearchTime = model.LastSearchTime, + + // EpisodeFile + + HasFile = model.HasFile, + Monitored = model.Monitored, + AbsoluteEpisodeNumber = model.AbsoluteEpisodeNumber, + SceneAbsoluteEpisodeNumber = model.SceneAbsoluteEpisodeNumber, + SceneEpisodeNumber = model.SceneEpisodeNumber, + SceneSeasonNumber = model.SceneSeasonNumber, + UnverifiedSceneNumbering = model.UnverifiedSceneNumbering, + + // Series = model.Series.MapToResource(), + }; + } + + public static List ToResource(this IEnumerable models) + { + return models.Select(ToResource).ToList(); + } + } +} diff --git a/src/Sonarr.Api.V5/Queue/QueueActionController.cs b/src/Sonarr.Api.V5/Queue/QueueActionController.cs new file mode 100644 index 000000000..959cebc7a --- /dev/null +++ b/src/Sonarr.Api.V5/Queue/QueueActionController.cs @@ -0,0 +1,56 @@ +using Microsoft.AspNetCore.Mvc; +using NzbDrone.Core.Download; +using NzbDrone.Core.Download.Pending; +using Sonarr.Http; +using Sonarr.Http.REST; + +namespace Sonarr.Api.V5.Queue +{ + [V5ApiController("queue")] + public class QueueActionController : Controller + { + private readonly IPendingReleaseService _pendingReleaseService; + private readonly IDownloadService _downloadService; + + public QueueActionController(IPendingReleaseService pendingReleaseService, + IDownloadService downloadService) + { + _pendingReleaseService = pendingReleaseService; + _downloadService = downloadService; + } + + [HttpPost("grab/{id:int}")] + public async Task Grab([FromRoute] int id) + { + var pendingRelease = _pendingReleaseService.FindPendingQueueItem(id); + + if (pendingRelease == null) + { + throw new NotFoundException(); + } + + await _downloadService.DownloadReport(pendingRelease.RemoteEpisode, null); + + return new { }; + } + + [HttpPost("grab/bulk")] + [Consumes("application/json")] + public async Task Grab([FromBody] QueueBulkResource resource) + { + foreach (var id in resource.Ids) + { + var pendingRelease = _pendingReleaseService.FindPendingQueueItem(id); + + if (pendingRelease == null) + { + throw new NotFoundException(); + } + + await _downloadService.DownloadReport(pendingRelease.RemoteEpisode, null); + } + + return new { }; + } + } +} diff --git a/src/Sonarr.Api.V5/Queue/QueueBulkResource.cs b/src/Sonarr.Api.V5/Queue/QueueBulkResource.cs new file mode 100644 index 000000000..f78d547bc --- /dev/null +++ b/src/Sonarr.Api.V5/Queue/QueueBulkResource.cs @@ -0,0 +1,7 @@ +namespace Sonarr.Api.V5.Queue +{ + public class QueueBulkResource + { + public required List Ids { get; set; } + } +} diff --git a/src/Sonarr.Api.V5/Queue/QueueController.cs b/src/Sonarr.Api.V5/Queue/QueueController.cs new file mode 100644 index 000000000..67bdca9b6 --- /dev/null +++ b/src/Sonarr.Api.V5/Queue/QueueController.cs @@ -0,0 +1,404 @@ +using Microsoft.AspNetCore.Mvc; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Blocklisting; +using NzbDrone.Core.Datastore; +using NzbDrone.Core.Datastore.Events; +using NzbDrone.Core.Download; +using NzbDrone.Core.Download.Pending; +using NzbDrone.Core.Download.TrackedDownloads; +using NzbDrone.Core.Indexers; +using NzbDrone.Core.Languages; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.Profiles.Qualities; +using NzbDrone.Core.Qualities; +using NzbDrone.Core.Queue; +using NzbDrone.SignalR; +using Sonarr.Http; +using Sonarr.Http.Extensions; +using Sonarr.Http.REST; +using Sonarr.Http.REST.Attributes; + +namespace Sonarr.Api.V5.Queue +{ + [V5ApiController] + public class QueueController : RestControllerWithSignalR, + IHandle, IHandle + { + private readonly IQueueService _queueService; + private readonly IPendingReleaseService _pendingReleaseService; + + private readonly QualityModelComparer _qualityComparer; + private readonly ITrackedDownloadService _trackedDownloadService; + private readonly IFailedDownloadService _failedDownloadService; + private readonly IIgnoredDownloadService _ignoredDownloadService; + private readonly IProvideDownloadClient _downloadClientProvider; + private readonly IBlocklistService _blocklistService; + + public QueueController(IBroadcastSignalRMessage broadcastSignalRMessage, + IQueueService queueService, + IPendingReleaseService pendingReleaseService, + IQualityProfileService qualityProfileService, + ITrackedDownloadService trackedDownloadService, + IFailedDownloadService failedDownloadService, + IIgnoredDownloadService ignoredDownloadService, + IProvideDownloadClient downloadClientProvider, + IBlocklistService blocklistService) + : base(broadcastSignalRMessage) + { + _queueService = queueService; + _pendingReleaseService = pendingReleaseService; + _trackedDownloadService = trackedDownloadService; + _failedDownloadService = failedDownloadService; + _ignoredDownloadService = ignoredDownloadService; + _downloadClientProvider = downloadClientProvider; + _blocklistService = blocklistService; + + _qualityComparer = new QualityModelComparer(qualityProfileService.GetDefaultProfile(string.Empty)); + } + + [NonAction] + public override ActionResult GetResourceByIdWithErrorHandler(int id) + { + return base.GetResourceByIdWithErrorHandler(id); + } + + protected override QueueResource GetResourceById(int id) + { + throw new NotImplementedException(); + } + + [RestDeleteById] + public ActionResult RemoveAction(int id, bool removeFromClient = true, bool blocklist = false, bool skipRedownload = false, bool changeCategory = false) + { + var pendingRelease = _pendingReleaseService.FindPendingQueueItem(id); + + if (pendingRelease != null) + { + Remove(pendingRelease, blocklist); + + return Deleted(); + } + + var trackedDownload = GetTrackedDownload(id); + + if (trackedDownload == null) + { + throw new NotFoundException(); + } + + Remove(trackedDownload, removeFromClient, blocklist, skipRedownload, changeCategory); + _trackedDownloadService.StopTracking(trackedDownload.DownloadItem.DownloadId); + + return Deleted(); + } + + [HttpDelete("bulk")] + public object RemoveMany([FromBody] QueueBulkResource resource, [FromQuery] bool removeFromClient = true, [FromQuery] bool blocklist = false, [FromQuery] bool skipRedownload = false, [FromQuery] bool changeCategory = false) + { + var trackedDownloadIds = new List(); + var pendingToRemove = new List(); + var trackedToRemove = new List(); + + foreach (var id in resource.Ids) + { + var pendingRelease = _pendingReleaseService.FindPendingQueueItem(id); + + if (pendingRelease != null) + { + pendingToRemove.Add(pendingRelease); + continue; + } + + var trackedDownload = GetTrackedDownload(id); + + if (trackedDownload != null) + { + trackedToRemove.Add(trackedDownload); + } + } + + foreach (var pendingRelease in pendingToRemove.DistinctBy(p => p.Id)) + { + Remove(pendingRelease, blocklist); + } + + foreach (var trackedDownload in trackedToRemove.DistinctBy(t => t.DownloadItem.DownloadId)) + { + Remove(trackedDownload, removeFromClient, blocklist, skipRedownload, changeCategory); + trackedDownloadIds.Add(trackedDownload.DownloadItem.DownloadId); + } + + _trackedDownloadService.StopTracking(trackedDownloadIds); + + return new { }; + } + + [HttpGet] + [Produces("application/json")] + public PagingResource GetQueue([FromQuery] PagingRequestResource paging, bool includeUnknownSeriesItems = false, bool includeSeries = false, bool includeEpisodes = false, [FromQuery] int[]? seriesIds = null, DownloadProtocol? protocol = null, [FromQuery] int[]? languages = null, [FromQuery] int[]? quality = null, [FromQuery] QueueStatus[]? status = null) + { + var pagingResource = new PagingResource(paging); + var pagingSpec = pagingResource.MapToPagingSpec( + new HashSet(StringComparer.OrdinalIgnoreCase) + { + "added", + "downloadClient", + "episode", + "episode.airDateUtc", + "episode.title", + "episodes.airDateUtc", + "episodes.title", + "estimatedCompletionTime", + "indexer", + "language", + "languages", + "progress", + "protocol", + "quality", + "series.sortTitle", + "size", + "status", + "timeleft", + "title" + }, + "timeleft", + SortDirection.Ascending); + + return pagingSpec.ApplyToPage((spec) => GetQueue(spec, seriesIds?.ToHashSet() ?? [], protocol, languages?.ToHashSet() ?? [], quality?.ToHashSet() ?? [], status?.ToHashSet() ?? [], includeUnknownSeriesItems), (q) => MapToResource(q, includeSeries, includeEpisodes)); + } + + private PagingSpec GetQueue(PagingSpec pagingSpec, HashSet seriesIds, DownloadProtocol? protocol, HashSet languages, HashSet quality, HashSet status, bool includeUnknownSeriesItems) + { + var ascending = pagingSpec.SortDirection == SortDirection.Ascending; + var orderByFunc = GetOrderByFunc(pagingSpec); + var queue = _queueService.GetQueue(); + var filteredQueue = includeUnknownSeriesItems ? queue : queue.Where(q => q.Series != null); + var pending = _pendingReleaseService.GetPendingQueue(); + var hasSeriesIdFilter = seriesIds is { Count: > 0 }; + var hasLanguageFilter = languages is { Count: > 0 }; + var hasQualityFilter = quality is { Count: > 0 }; + var hasStatusFilter = status is { Count: > 0 }; + + var fullQueue = filteredQueue.Concat(pending).Where(q => + { + var include = true; + + if (hasSeriesIdFilter) + { + include &= q.Series != null && seriesIds.Contains(q.Series.Id); + } + + if (include && protocol.HasValue) + { + include &= q.Protocol == protocol.Value; + } + + if (include && hasLanguageFilter) + { + include &= q.Languages.Any(l => languages.Contains(l.Id)); + } + + if (include && hasQualityFilter) + { + include &= quality.Contains(q.Quality.Quality.Id); + } + + if (include && hasStatusFilter) + { + include &= status.Contains(q.Status); + } + + return include; + }).ToList(); + + IOrderedEnumerable ordered; + + if (pagingSpec.SortKey == "timeleft") + { + ordered = ascending + ? fullQueue.OrderBy(q => q.TimeLeft, new TimeleftComparer()) + : fullQueue.OrderByDescending(q => q.TimeLeft, new TimeleftComparer()); + } + else if (pagingSpec.SortKey == "estimatedCompletionTime") + { + ordered = ascending + ? fullQueue.OrderBy(q => q.EstimatedCompletionTime, new DatetimeComparer()) + : fullQueue.OrderByDescending(q => q.EstimatedCompletionTime, + new DatetimeComparer()); + } + else if (pagingSpec.SortKey == "added") + { + ordered = ascending + ? fullQueue.OrderBy(q => q.Added, new DatetimeComparer()) + : fullQueue.OrderByDescending(q => q.Added, + new DatetimeComparer()); + } + else if (pagingSpec.SortKey == "protocol") + { + ordered = ascending + ? fullQueue.OrderBy(q => q.Protocol) + : fullQueue.OrderByDescending(q => q.Protocol); + } + else if (pagingSpec.SortKey == "indexer") + { + ordered = ascending + ? fullQueue.OrderBy(q => q.Indexer, StringComparer.InvariantCultureIgnoreCase) + : fullQueue.OrderByDescending(q => q.Indexer, StringComparer.InvariantCultureIgnoreCase); + } + else if (pagingSpec.SortKey == "downloadClient") + { + ordered = ascending + ? fullQueue.OrderBy(q => q.DownloadClient, StringComparer.InvariantCultureIgnoreCase) + : fullQueue.OrderByDescending(q => q.DownloadClient, StringComparer.InvariantCultureIgnoreCase); + } + else if (pagingSpec.SortKey == "quality") + { + ordered = ascending + ? fullQueue.OrderBy(q => q.Quality, _qualityComparer) + : fullQueue.OrderByDescending(q => q.Quality, _qualityComparer); + } + else if (pagingSpec.SortKey == "languages") + { + ordered = ascending + ? fullQueue.OrderBy(q => q.Languages, new LanguagesComparer()) + : fullQueue.OrderByDescending(q => q.Languages, new LanguagesComparer()); + } + else + { + ordered = ascending ? fullQueue.OrderBy(orderByFunc) : fullQueue.OrderByDescending(orderByFunc); + } + + ordered = ordered.ThenByDescending(q => q.Size == 0 ? 0 : 100 - (q.SizeLeft / q.Size * 100)); + pagingSpec.Records = ordered.Skip((pagingSpec.Page - 1) * pagingSpec.PageSize).Take(pagingSpec.PageSize).ToList(); + pagingSpec.TotalRecords = fullQueue.Count; + + if (pagingSpec.Records.Empty() && pagingSpec.Page > 1) + { + pagingSpec.Page = (int)Math.Max(Math.Ceiling((decimal)(pagingSpec.TotalRecords / pagingSpec.PageSize)), 1); + pagingSpec.Records = ordered.Skip((pagingSpec.Page - 1) * pagingSpec.PageSize).Take(pagingSpec.PageSize).ToList(); + } + + return pagingSpec; + } + + private Func GetOrderByFunc(PagingSpec pagingSpec) + { + switch (pagingSpec.SortKey) + { + case "status": + return q => q.Status.ToString(); + case "series.sortTitle": + return q => q.Series?.SortTitle ?? q.Title; + case "title": + return q => q.Title; + case "episode": + return q => q.Episodes.FirstOrDefault(); + case "episode.airDateUtc": + case "episodes.airDateUtc": + return q => q.Episodes.FirstOrDefault()?.AirDateUtc ?? DateTime.MinValue; + case "episode.title": + case "episodes.title": + return q => q.Episodes.FirstOrDefault()?.Title ?? string.Empty; + case "language": + case "languages": + return q => q.Languages; + case "quality": + return q => q.Quality; + case "size": + return q => q.Size; + case "progress": + // Avoid exploding if a download's size is 0 + return q => 100 - (q.SizeLeft / Math.Max(q.Size * 100, 1)); + default: + return q => q.TimeLeft; + } + } + + private void Remove(NzbDrone.Core.Queue.Queue pendingRelease, bool blocklist) + { + if (blocklist) + { + _blocklistService.Block(pendingRelease.RemoteEpisode, "Pending release manually blocklisted"); + } + + _pendingReleaseService.RemovePendingQueueItems(pendingRelease.Id); + } + + private TrackedDownload? Remove(TrackedDownload trackedDownload, bool removeFromClient, bool blocklist, bool skipRedownload, bool changeCategory) + { + if (removeFromClient) + { + var downloadClient = _downloadClientProvider.Get(trackedDownload.DownloadClient); + + if (downloadClient == null) + { + throw new BadRequestException(); + } + + downloadClient.RemoveItem(trackedDownload.DownloadItem, true); + } + else if (changeCategory) + { + var downloadClient = _downloadClientProvider.Get(trackedDownload.DownloadClient); + + if (downloadClient == null) + { + throw new BadRequestException(); + } + + downloadClient.MarkItemAsImported(trackedDownload.DownloadItem); + } + + if (blocklist) + { + _failedDownloadService.MarkAsFailed(trackedDownload, skipRedownload); + } + + if (!removeFromClient && !blocklist && !changeCategory) + { + if (!_ignoredDownloadService.IgnoreDownload(trackedDownload)) + { + return null; + } + } + + return trackedDownload; + } + + private TrackedDownload GetTrackedDownload(int queueId) + { + var queueItem = _queueService.Find(queueId); + + if (queueItem == null) + { + throw new NotFoundException(); + } + + var trackedDownload = _trackedDownloadService.Find(queueItem.DownloadId); + + if (trackedDownload == null) + { + throw new NotFoundException(); + } + + return trackedDownload; + } + + private QueueResource MapToResource(NzbDrone.Core.Queue.Queue queueItem, bool includeSeries, bool includeEpisodes) + { + return queueItem.ToResource(includeSeries, includeEpisodes); + } + + [NonAction] + public void Handle(QueueUpdatedEvent message) + { + BroadcastResourceChange(ModelAction.Sync); + } + + [NonAction] + public void Handle(PendingReleasesUpdatedEvent message) + { + BroadcastResourceChange(ModelAction.Sync); + } + } +} diff --git a/src/Sonarr.Api.V5/Queue/QueueDetailsController.cs b/src/Sonarr.Api.V5/Queue/QueueDetailsController.cs new file mode 100644 index 000000000..3d2afc427 --- /dev/null +++ b/src/Sonarr.Api.V5/Queue/QueueDetailsController.cs @@ -0,0 +1,73 @@ +using Microsoft.AspNetCore.Mvc; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Datastore.Events; +using NzbDrone.Core.Download.Pending; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.Queue; +using NzbDrone.SignalR; +using Sonarr.Http; +using Sonarr.Http.REST; + +namespace Sonarr.Api.V5.Queue +{ + [V5ApiController("queue/details")] + public class QueueDetailsController : RestControllerWithSignalR, + IHandle, IHandle + { + private readonly IQueueService _queueService; + private readonly IPendingReleaseService _pendingReleaseService; + + public QueueDetailsController(IBroadcastSignalRMessage broadcastSignalRMessage, IQueueService queueService, IPendingReleaseService pendingReleaseService) + : base(broadcastSignalRMessage) + { + _queueService = queueService; + _pendingReleaseService = pendingReleaseService; + } + + [NonAction] + public override ActionResult GetResourceByIdWithErrorHandler(int id) + { + return base.GetResourceByIdWithErrorHandler(id); + } + + protected override QueueResource GetResourceById(int id) + { + throw new NotImplementedException(); + } + + [HttpGet] + [Produces("application/json")] + public List GetQueue(int? seriesId, [FromQuery]List episodeIds, bool includeSeries = false, bool includeEpisodes = false) + { + var queue = _queueService.GetQueue(); + var pending = _pendingReleaseService.GetPendingQueue(); + var fullQueue = queue.Concat(pending); + + if (seriesId.HasValue) + { + return fullQueue.Where(q => q.Series?.Id == seriesId).ToResource(includeSeries, includeEpisodes); + } + + if (episodeIds.Any()) + { + return fullQueue.Where(q => q.Episodes.Any() && + episodeIds.IntersectBy(e => e, q.Episodes, e => e.Id, null).Any()) + .ToResource(includeSeries, includeEpisodes); + } + + return fullQueue.ToResource(includeSeries, includeEpisodes); + } + + [NonAction] + public void Handle(QueueUpdatedEvent message) + { + BroadcastResourceChange(ModelAction.Sync); + } + + [NonAction] + public void Handle(PendingReleasesUpdatedEvent message) + { + BroadcastResourceChange(ModelAction.Sync); + } + } +} diff --git a/src/Sonarr.Api.V5/Queue/QueueResource.cs b/src/Sonarr.Api.V5/Queue/QueueResource.cs new file mode 100644 index 000000000..28ae34b21 --- /dev/null +++ b/src/Sonarr.Api.V5/Queue/QueueResource.cs @@ -0,0 +1,95 @@ +using NzbDrone.Core.Download.TrackedDownloads; +using NzbDrone.Core.Indexers; +using NzbDrone.Core.Languages; +using NzbDrone.Core.Qualities; +using NzbDrone.Core.Queue; +using Sonarr.Api.V5.CustomFormats; +using Sonarr.Api.V5.Episodes; +using Sonarr.Api.V5.Series; +using Sonarr.Http.REST; + +namespace Sonarr.Api.V5.Queue +{ + public class QueueResource : RestResource + { + public int? SeriesId { get; set; } + public IEnumerable EpisodeIds { get; set; } = []; + public List SeasonNumbers { get; set; } = []; + public SeriesResource? Series { get; set; } + public List? Episodes { get; set; } + public List Languages { get; set; } = []; + public QualityModel Quality { get; set; } = new(NzbDrone.Core.Qualities.Quality.Unknown); + public List CustomFormats { get; set; } = []; + public int CustomFormatScore { get; set; } + public decimal Size { get; set; } + public string? Title { get; set; } + + // Collides with existing properties due to case-insensitive deserialization + // public decimal SizeLeft { get; set; } + // public TimeSpan? TimeLeft { get; set; } + + public DateTime? EstimatedCompletionTime { get; set; } + public DateTime? Added { get; set; } + public QueueStatus Status { get; set; } + public TrackedDownloadStatus? TrackedDownloadStatus { get; set; } + public TrackedDownloadState? TrackedDownloadState { get; set; } + public List? StatusMessages { get; set; } + public string? ErrorMessage { get; set; } + public string? DownloadId { get; set; } + public DownloadProtocol Protocol { get; set; } + public string? DownloadClient { get; set; } + public bool DownloadClientHasPostImportCategory { get; set; } + public string? Indexer { get; set; } + public string? OutputPath { get; set; } + public int EpisodesWithFilesCount { get; set; } + } + + public static class QueueResourceMapper + { + public static QueueResource ToResource(this NzbDrone.Core.Queue.Queue model, bool includeSeries, bool includeEpisodes) + { + var customFormats = model.RemoteEpisode?.CustomFormats; + var customFormatScore = model.Series?.QualityProfile?.Value?.CalculateCustomFormatScore(customFormats) ?? 0; + + return new QueueResource + { + Id = model.Id, + SeriesId = model.Series?.Id, + EpisodeIds = model.Episodes.Select(e => e.Id).ToList(), + SeasonNumbers = model.SeasonNumber.HasValue ? new List { model.SeasonNumber.Value } : new List(), + Series = includeSeries && model.Series != null ? model.Series.ToResource() : null, + Episodes = includeEpisodes ? model.Episodes.ToResource() : null, + Languages = model.Languages, + Quality = model.Quality, + CustomFormats = customFormats?.ToResource(false) ?? [], + CustomFormatScore = customFormatScore, + Size = model.Size, + Title = model.Title, + + // Collides with existing properties due to case-insensitive deserialization + // SizeLeft = model.SizeLeft, + // TimeLeft = model.TimeLeft, + + EstimatedCompletionTime = model.EstimatedCompletionTime, + Added = model.Added, + Status = model.Status, + TrackedDownloadStatus = model.TrackedDownloadStatus, + TrackedDownloadState = model.TrackedDownloadState, + StatusMessages = model.StatusMessages, + ErrorMessage = model.ErrorMessage, + DownloadId = model.DownloadId, + Protocol = model.Protocol, + DownloadClient = model.DownloadClient, + DownloadClientHasPostImportCategory = model.DownloadClientHasPostImportCategory, + Indexer = model.Indexer, + OutputPath = model.OutputPath, + EpisodesWithFilesCount = model.Episodes.Count(e => e.HasFile) + }; + } + + public static List ToResource(this IEnumerable models, bool includeSeries, bool includeEpisode) + { + return models.Select((m) => ToResource(m, includeSeries, includeEpisode)).ToList(); + } + } +} diff --git a/src/Sonarr.Api.V5/Queue/QueueStatusController.cs b/src/Sonarr.Api.V5/Queue/QueueStatusController.cs new file mode 100644 index 000000000..10139e8c5 --- /dev/null +++ b/src/Sonarr.Api.V5/Queue/QueueStatusController.cs @@ -0,0 +1,79 @@ +using Microsoft.AspNetCore.Mvc; +using NzbDrone.Common.TPL; +using NzbDrone.Core.Datastore.Events; +using NzbDrone.Core.Download.Pending; +using NzbDrone.Core.Download.TrackedDownloads; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.Queue; +using NzbDrone.SignalR; +using Sonarr.Http; +using Sonarr.Http.REST; + +namespace Sonarr.Api.V5.Queue +{ + [V5ApiController("queue/status")] + public class QueueStatusController : RestControllerWithSignalR, + IHandle, IHandle + { + private readonly IQueueService _queueService; + private readonly IPendingReleaseService _pendingReleaseService; + private readonly Debouncer _broadcastDebounce; + + public QueueStatusController(IBroadcastSignalRMessage broadcastSignalRMessage, IQueueService queueService, IPendingReleaseService pendingReleaseService) + : base(broadcastSignalRMessage) + { + _queueService = queueService; + _pendingReleaseService = pendingReleaseService; + + _broadcastDebounce = new Debouncer(BroadcastChange, TimeSpan.FromSeconds(5)); + } + + [NonAction] + public override ActionResult GetResourceByIdWithErrorHandler(int id) + { + return base.GetResourceByIdWithErrorHandler(id); + } + + [HttpGet] + [Produces("application/json")] + public QueueStatusResource GetQueueStatus() + { + _broadcastDebounce.Pause(); + + var queue = _queueService.GetQueue(); + var pending = _pendingReleaseService.GetPendingQueue(); + + var resource = new QueueStatusResource + { + TotalCount = queue.Count + pending.Count, + Count = queue.Count(q => q.Series != null) + pending.Count, + UnknownCount = queue.Count(q => q.Series == null), + Errors = queue.Any(q => q.Series != null && q.TrackedDownloadStatus == TrackedDownloadStatus.Error), + Warnings = queue.Any(q => q.Series != null && q.TrackedDownloadStatus == TrackedDownloadStatus.Warning), + UnknownErrors = queue.Any(q => q.Series == null && q.TrackedDownloadStatus == TrackedDownloadStatus.Error), + UnknownWarnings = queue.Any(q => q.Series == null && q.TrackedDownloadStatus == TrackedDownloadStatus.Warning) + }; + + _broadcastDebounce.Resume(); + + return resource; + } + + private void BroadcastChange() + { + BroadcastResourceChange(ModelAction.Updated, GetQueueStatus()); + } + + [NonAction] + public void Handle(QueueUpdatedEvent message) + { + _broadcastDebounce.Execute(); + } + + [NonAction] + public void Handle(PendingReleasesUpdatedEvent message) + { + _broadcastDebounce.Execute(); + } + } +} diff --git a/src/Sonarr.Api.V5/Queue/QueueStatusResource.cs b/src/Sonarr.Api.V5/Queue/QueueStatusResource.cs new file mode 100644 index 000000000..4852bb629 --- /dev/null +++ b/src/Sonarr.Api.V5/Queue/QueueStatusResource.cs @@ -0,0 +1,15 @@ +using Sonarr.Http.REST; + +namespace Sonarr.Api.V5.Queue +{ + public class QueueStatusResource : RestResource + { + public int TotalCount { get; set; } + public int Count { get; set; } + public int UnknownCount { get; set; } + public bool Errors { get; set; } + public bool Warnings { get; set; } + public bool UnknownErrors { get; set; } + public bool UnknownWarnings { get; set; } + } +} diff --git a/src/Sonarr.Http/REST/RestController.cs b/src/Sonarr.Http/REST/RestController.cs index ee32c6a09..7b118b072 100644 --- a/src/Sonarr.Http/REST/RestController.cs +++ b/src/Sonarr.Http/REST/RestController.cs @@ -167,5 +167,10 @@ protected ActionResult Created(int id) var result = GetResourceById(id); return CreatedAtAction(nameof(GetResourceByIdWithErrorHandler), new { id = id }, result); } + + protected ActionResult Deleted() + { + return NoContent(); + } } } diff --git a/src/Sonarr.Http/REST/RestControllerWithSignalR.cs b/src/Sonarr.Http/REST/RestControllerWithSignalR.cs index 408a97005..5b6bd5c8d 100644 --- a/src/Sonarr.Http/REST/RestControllerWithSignalR.cs +++ b/src/Sonarr.Http/REST/RestControllerWithSignalR.cs @@ -12,6 +12,8 @@ public abstract class RestControllerWithSignalR : RestControl where TModel : ModelBase, new() { protected string Resource { get; } + protected int? Version { get; } + private readonly IBroadcastSignalRMessage _signalRBroadcaster; protected RestControllerWithSignalR(IBroadcastSignalRMessage signalRBroadcaster) @@ -22,10 +24,12 @@ protected RestControllerWithSignalR(IBroadcastSignalRMessage signalRBroadcaster) if (apiAttribute != null && apiAttribute.Resource != VersionedApiControllerAttribute.CONTROLLER_RESOURCE) { Resource = apiAttribute.Resource; + Version = apiAttribute.Version; } else { Resource = new TResource().ResourceName.Trim('/'); + Version = apiAttribute?.Version; } } @@ -70,13 +74,16 @@ protected void BroadcastResourceChange(ModelAction action, TResource resource) return; } - if (GetType().Namespace.Contains("V3")) + var ns = GetType().Namespace; + + if (ns.Contains("V3") || ns.Contains("V5")) { var signalRMessage = new SignalRMessage { Name = Resource, Body = new ResourceChangeMessage(resource, action), - Action = action + Action = action, + Version = Version }; _signalRBroadcaster.BroadcastMessage(signalRMessage); @@ -90,13 +97,16 @@ protected void BroadcastResourceChange(ModelAction action) return; } - if (GetType().Namespace.Contains("V3")) + var ns = GetType().Namespace; + + if (ns.Contains("V3") || ns.Contains("V5")) { var signalRMessage = new SignalRMessage { Name = Resource, Body = new ResourceChangeMessage(action), - Action = action + Action = action, + Version = Version }; _signalRBroadcaster.BroadcastMessage(signalRMessage);