1
0
mirror of https://github.com/Sonarr/Sonarr synced 2025-10-05 23:52:45 +02:00

Add v5 queue endpoints

This commit is contained in:
Mark McDowall
2025-04-28 15:58:41 -07:00
parent 37cb978f18
commit 642f4f97bc
29 changed files with 1597 additions and 43 deletions

View File

@@ -67,7 +67,7 @@ namespace NzbDrone.Core.Test.Download.Pending.PendingReleaseServiceTests
{
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 @@ namespace NzbDrone.Core.Test.Download.Pending.PendingReleaseServiceTests
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 @@ namespace NzbDrone.Core.Test.Download.Pending.PendingReleaseServiceTests
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 @@ namespace NzbDrone.Core.Test.Download.Pending.PendingReleaseServiceTests
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 @@ namespace NzbDrone.Core.Test.Download.Pending.PendingReleaseServiceTests
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 @@ namespace NzbDrone.Core.Test.Download.Pending.PendingReleaseServiceTests
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);

View File

@@ -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<PendingReleaseService>
{
private List<PendingRelease> _pending;
private Episode _episode;
[SetUp]
public void Setup()
{
_pending = new List<PendingRelease>();
_episode = Builder<Episode>.CreateNew()
.Build();
Mocker.GetMock<IPendingReleaseRepository>()
.Setup(s => s.AllBySeriesId(It.IsAny<int>()))
.Returns(_pending);
Mocker.GetMock<IPendingReleaseRepository>()
.Setup(s => s.All())
.Returns(_pending);
Mocker.GetMock<ISeriesService>()
.Setup(s => s.GetSeries(It.IsAny<int>()))
.Returns(new Series());
Mocker.GetMock<ISeriesService>()
.Setup(s => s.GetSeries(It.IsAny<IEnumerable<int>>()))
.Returns(new List<Series> { new Series() });
Mocker.GetMock<IParsingService>()
.Setup(s => s.Map(It.IsAny<ParsedEpisodeInfo>(), It.IsAny<Series>()))
.Returns(new RemoteEpisode { Episodes = new List<Episode> { _episode } });
Mocker.GetMock<IParsingService>()
.Setup(s => s.GetEpisodes(It.IsAny<ParsedEpisodeInfo>(), It.IsAny<Series>(), It.IsAny<bool>(), null))
.Returns(new List<Episode> { _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<ReleaseInfo>.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<IPendingReleaseRepository>().Verify(c => c.DeleteMany(It.Is<IEnumerable<int>>(s => s.SequenceEqual(ids))));
}
}
}

View File

@@ -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<ObsoleteQueueService>
{
private List<TrackedDownload> _trackedDownloads;
[SetUp]
public void SetUp()
{
var downloadClientInfo = Builder<DownloadClientItemClientInfo>.CreateNew().Build();
var downloadItem = Builder<NzbDrone.Core.Download.DownloadClientItem>.CreateNew()
.With(v => v.RemainingTime = TimeSpan.FromSeconds(10))
.With(v => v.DownloadClientInfo = downloadClientInfo)
.Build();
var series = Builder<Series>.CreateNew()
.Build();
var episodes = Builder<Episode>.CreateListOfSize(3)
.All()
.With(e => e.SeriesId = series.Id)
.Build();
var remoteEpisode = Builder<RemoteEpisode>.CreateNew()
.With(r => r.Series = series)
.With(r => r.Episodes = new List<Episode>(episodes))
.With(r => r.ParsedEpisodeInfo = new ParsedEpisodeInfo())
.Build();
_trackedDownloads = Builder<TrackedDownload>.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);
}
}
}

View File

@@ -58,13 +58,9 @@ namespace NzbDrone.Core.Test.QueueTests
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);
}
}
}

View File

@@ -31,6 +31,9 @@ namespace NzbDrone.Core.Download.Pending
Queue.Queue FindPendingQueueItem(int queueId);
void RemovePendingQueueItems(int queueId);
RemoteEpisode OldestPendingRelease(int seriesId, int[] episodeIds);
List<Queue.Queue> GetPendingQueueObsolete();
Queue.Queue FindPendingQueueItemObsolete(int queueId);
void RemovePendingQueueItemsObsolete(int queueId);
}
public class PendingReleaseService : IPendingReleaseService,
@@ -187,7 +190,44 @@ namespace NzbDrone.Core.Download.Pending
{
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<Queue.Queue> GetPendingQueueObsolete()
{
var queued = new List<Queue.Queue>();
var nextRssSync = new Lazy<DateTime>(() => _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 @@ namespace NzbDrone.Core.Download.Pending
}
}
#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 @@ namespace NzbDrone.Core.Download.Pending
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 @@ namespace NzbDrone.Core.Download.Pending
_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 @@ namespace NzbDrone.Core.Download.Pending
return result;
}
private Queue.Queue GetQueueItem(PendingRelease pendingRelease, Lazy<DateTime> nextRssSync, List<Episode> 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<DateTime> nextRssSync, Episode episode)
{
var ect = pendingRelease.Release.PublishDate.AddMinutes(GetDelay(pendingRelease.RemoteEpisode));
@@ -380,7 +493,11 @@ namespace NzbDrone.Core.Download.Pending
{
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 @@ namespace NzbDrone.Core.Download.Pending
}
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));

View File

@@ -154,7 +154,7 @@ namespace NzbDrone.Core.IndexerSearch
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 @@ namespace NzbDrone.Core.IndexerSearch
}
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<int> GetQueuedEpisodeIds()
{
return _queueService.GetQueue()
.Where(q => q.Episodes.Any())
.SelectMany(q => q.Episodes.Select(e => e.Id))
.ToList();
}
}
}

View File

@@ -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<Queue> GetQueue();
Queue Find(int id);
void Remove(int id);
}
public class ObsoleteQueueService : IObsoleteQueueService, IHandle<TrackedDownloadRefreshedEvent>
{
private readonly IEventAggregator _eventAggregator;
private static List<Queue> _queue = new();
public ObsoleteQueueService(IEventAggregator eventAggregator)
{
_eventAggregator = eventAggregator;
}
public List<Queue> 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<Queue> 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> { 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

View File

@@ -0,0 +1,8 @@
using NzbDrone.Common.Messaging;
namespace NzbDrone.Core.Queue
{
public class ObsoleteQueueUpdatedEvent : IEvent
{
}
}

View File

@@ -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<Episode> Episodes { get; set; }
public List<Language> Languages { get; set; }
public QualityModel Quality { get; set; }
public decimal Size { get; set; }

View File

@@ -47,10 +47,7 @@ namespace NzbDrone.Core.Queue
{
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 @@ namespace NzbDrone.Core.Queue
}
}
private Queue MapQueueItem(TrackedDownload trackedDownload, Episode episode)
private Queue MapQueueItem(TrackedDownload trackedDownload, List<Episode> episodes)
{
var queue = new Queue
{
Series = trackedDownload.RemoteEpisode?.Series,
Episode = episode,
SeasonNumber = trackedDownload.RemoteEpisode?.MappedSeasonNumber,
Episodes = episodes,
Languages = trackedDownload.RemoteEpisode?.Languages ?? new List<Language> { Language.Unknown },
Quality = trackedDownload.RemoteEpisode?.ParsedEpisodeInfo.Quality ?? new QualityModel(Quality.Unknown),
Title = FileExtensions.RemoveFileExtension(trackedDownload.DownloadItem.Title),
@@ -85,7 +83,7 @@ namespace NzbDrone.Core.Queue
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)
{

View File

@@ -9,5 +9,7 @@ namespace NzbDrone.SignalR
[System.Text.Json.Serialization.JsonIgnore]
public ModelAction Action { get; set; }
public int? Version { get; set; }
}
}

View File

@@ -21,13 +21,14 @@ using Sonarr.Http.Extensions;
using Sonarr.Http.REST;
using Sonarr.Http.REST.Attributes;
#pragma warning disable CS0612
namespace Sonarr.Api.V3.Queue
{
[V3ApiController]
public class QueueController : RestControllerWithSignalR<QueueResource, NzbDrone.Core.Queue.Queue>,
IHandle<QueueUpdatedEvent>, IHandle<PendingReleasesUpdatedEvent>
IHandle<ObsoleteQueueUpdatedEvent>, IHandle<PendingReleasesUpdatedEvent>
{
private readonly IQueueService _queueService;
private readonly IObsoleteQueueService _queueService;
private readonly IPendingReleaseService _pendingReleaseService;
private readonly QualityModelComparer _qualityComparer;
@@ -38,7 +39,7 @@ namespace Sonarr.Api.V3.Queue
private readonly IBlocklistService _blocklistService;
public QueueController(IBroadcastSignalRMessage broadcastSignalRMessage,
IQueueService queueService,
IObsoleteQueueService queueService,
IPendingReleaseService pendingReleaseService,
IQualityProfileService qualityProfileService,
ITrackedDownloadService trackedDownloadService,
@@ -73,7 +74,7 @@ namespace Sonarr.Api.V3.Queue
[RestDeleteById]
public void RemoveAction(int id, bool removeFromClient = true, bool blocklist = false, bool skipRedownload = false, bool changeCategory = false)
{
var pendingRelease = _pendingReleaseService.FindPendingQueueItem(id);
var pendingRelease = _pendingReleaseService.FindPendingQueueItemObsolete(id);
if (pendingRelease != null)
{
@@ -102,7 +103,7 @@ namespace Sonarr.Api.V3.Queue
foreach (var id in resource.Ids)
{
var pendingRelease = _pendingReleaseService.FindPendingQueueItem(id);
var pendingRelease = _pendingReleaseService.FindPendingQueueItemObsolete(id);
if (pendingRelease != null)
{
@@ -175,7 +176,7 @@ namespace Sonarr.Api.V3.Queue
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 @@ namespace Sonarr.Api.V3.Queue
_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 @@ namespace Sonarr.Api.V3.Queue
}
[NonAction]
public void Handle(QueueUpdatedEvent message)
public void Handle(ObsoleteQueueUpdatedEvent message)
{
BroadcastResourceChange(ModelAction.Sync);
}
@@ -406,3 +407,4 @@ namespace Sonarr.Api.V3.Queue
}
}
}
#pragma warning restore CS0612

View File

@@ -10,16 +10,17 @@ using NzbDrone.SignalR;
using Sonarr.Http;
using Sonarr.Http.REST;
#pragma warning disable CS0612
namespace Sonarr.Api.V3.Queue
{
[V3ApiController("queue/details")]
public class QueueDetailsController : RestControllerWithSignalR<QueueResource, NzbDrone.Core.Queue.Queue>,
IHandle<QueueUpdatedEvent>, IHandle<PendingReleasesUpdatedEvent>
IHandle<ObsoleteQueueUpdatedEvent>, IHandle<PendingReleasesUpdatedEvent>
{
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 @@ namespace Sonarr.Api.V3.Queue
}
[NonAction]
public void Handle(QueueUpdatedEvent message)
public void Handle(ObsoleteQueueUpdatedEvent message)
{
BroadcastResourceChange(ModelAction.Sync);
}
@@ -71,3 +72,4 @@ namespace Sonarr.Api.V3.Queue
}
}
}
#pragma warning restore CS0612

View File

@@ -11,6 +11,7 @@ using Sonarr.Api.V3.Episodes;
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 @@ namespace Sonarr.Api.V3.Queue
}
}
}
#pragma warning restore CS0612

View File

@@ -15,13 +15,13 @@ namespace Sonarr.Api.V3.Queue
{
[V3ApiController("queue/status")]
public class QueueStatusController : RestControllerWithSignalR<QueueStatusResource, NzbDrone.Core.Queue.Queue>,
IHandle<QueueUpdatedEvent>, IHandle<PendingReleasesUpdatedEvent>
IHandle<ObsoleteQueueUpdatedEvent>, IHandle<PendingReleasesUpdatedEvent>
{
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 @@ namespace Sonarr.Api.V3.Queue
}
[NonAction]
public void Handle(QueueUpdatedEvent message)
public void Handle(ObsoleteQueueUpdatedEvent message)
{
_broadcastDebounce.Execute();
}

View File

@@ -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<CustomFormatSpecificationSchema>? 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<CustomFormatResource> ToResource(this IEnumerable<CustomFormat> models, bool includeDetails)
{
return models.Select(m => m.ToResource(includeDetails)).ToList();
}
public static CustomFormat ToModel(this CustomFormatResource resource, List<ICustomFormatSpecification> 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<ICustomFormatSpecification>()
};
}
private static ICustomFormatSpecification MapSpecification(CustomFormatSpecificationSchema resource, List<ICustomFormatSpecification> 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;
}
}
}

View File

@@ -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<Field> Fields { get; set; }
public List<CustomFormatSpecificationSchema>? 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)
};
}
}
}

View File

@@ -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<Language> Languages { get; set; }
public required QualityModel Quality { get; set; }
public required List<CustomFormatResource> 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,
};
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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<MediaCover>? 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<EpisodeResource> ToResource(this IEnumerable<Episode> models)
{
return models.Select(ToResource).ToList();
}
}
}

View File

@@ -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<object> 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<object> 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 { };
}
}
}

View File

@@ -0,0 +1,7 @@
namespace Sonarr.Api.V5.Queue
{
public class QueueBulkResource
{
public required List<int> Ids { get; set; }
}
}

View File

@@ -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<QueueResource, NzbDrone.Core.Queue.Queue>,
IHandle<QueueUpdatedEvent>, IHandle<PendingReleasesUpdatedEvent>
{
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<QueueResource> 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<string>();
var pendingToRemove = new List<NzbDrone.Core.Queue.Queue>();
var trackedToRemove = new List<TrackedDownload>();
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<QueueResource> 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<QueueResource>(paging);
var pagingSpec = pagingResource.MapToPagingSpec<QueueResource, NzbDrone.Core.Queue.Queue>(
new HashSet<string>(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<NzbDrone.Core.Queue.Queue> GetQueue(PagingSpec<NzbDrone.Core.Queue.Queue> pagingSpec, HashSet<int> seriesIds, DownloadProtocol? protocol, HashSet<int> languages, HashSet<int> quality, HashSet<QueueStatus> 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<NzbDrone.Core.Queue.Queue> 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<NzbDrone.Core.Queue.Queue, object?> GetOrderByFunc(PagingSpec<NzbDrone.Core.Queue.Queue> 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);
}
}
}

View File

@@ -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<QueueResource, NzbDrone.Core.Queue.Queue>,
IHandle<QueueUpdatedEvent>, IHandle<PendingReleasesUpdatedEvent>
{
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<QueueResource> GetResourceByIdWithErrorHandler(int id)
{
return base.GetResourceByIdWithErrorHandler(id);
}
protected override QueueResource GetResourceById(int id)
{
throw new NotImplementedException();
}
[HttpGet]
[Produces("application/json")]
public List<QueueResource> GetQueue(int? seriesId, [FromQuery]List<int> 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);
}
}
}

View File

@@ -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<int> EpisodeIds { get; set; } = [];
public List<int> SeasonNumbers { get; set; } = [];
public SeriesResource? Series { get; set; }
public List<EpisodeResource>? Episodes { get; set; }
public List<Language> Languages { get; set; } = [];
public QualityModel Quality { get; set; } = new(NzbDrone.Core.Qualities.Quality.Unknown);
public List<CustomFormatResource> 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<TrackedDownloadStatusMessage>? 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<int> { model.SeasonNumber.Value } : new List<int>(),
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<QueueResource> ToResource(this IEnumerable<NzbDrone.Core.Queue.Queue> models, bool includeSeries, bool includeEpisode)
{
return models.Select((m) => ToResource(m, includeSeries, includeEpisode)).ToList();
}
}
}

View File

@@ -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<QueueStatusResource, NzbDrone.Core.Queue.Queue>,
IHandle<QueueUpdatedEvent>, IHandle<PendingReleasesUpdatedEvent>
{
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<QueueStatusResource> 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();
}
}
}

View File

@@ -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; }
}
}

View File

@@ -167,5 +167,10 @@ namespace Sonarr.Http.REST
var result = GetResourceById(id);
return CreatedAtAction(nameof(GetResourceByIdWithErrorHandler), new { id = id }, result);
}
protected ActionResult Deleted()
{
return NoContent();
}
}
}

View File

@@ -12,6 +12,8 @@ namespace Sonarr.Http.REST
where TModel : ModelBase, new()
{
protected string Resource { get; }
protected int? Version { get; }
private readonly IBroadcastSignalRMessage _signalRBroadcaster;
protected RestControllerWithSignalR(IBroadcastSignalRMessage signalRBroadcaster)
@@ -22,10 +24,12 @@ namespace Sonarr.Http.REST
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 @@ namespace Sonarr.Http.REST
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<TResource>(resource, action),
Action = action
Action = action,
Version = Version
};
_signalRBroadcaster.BroadcastMessage(signalRMessage);
@@ -90,13 +97,16 @@ namespace Sonarr.Http.REST
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<TResource>(action),
Action = action
Action = action,
Version = Version
};
_signalRBroadcaster.BroadcastMessage(signalRMessage);