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:
@@ -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);
|
||||
|
||||
|
@@ -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))));
|
||||
}
|
||||
}
|
||||
}
|
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -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));
|
||||
|
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
110
src/NzbDrone.Core/Queue/ObsoleteQueueService.cs
Normal file
110
src/NzbDrone.Core/Queue/ObsoleteQueueService.cs
Normal 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
|
8
src/NzbDrone.Core/Queue/ObsoleteQueueUpdatedEvent.cs
Normal file
8
src/NzbDrone.Core/Queue/ObsoleteQueueUpdatedEvent.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
using NzbDrone.Common.Messaging;
|
||||
|
||||
namespace NzbDrone.Core.Queue
|
||||
{
|
||||
public class ObsoleteQueueUpdatedEvent : IEvent
|
||||
{
|
||||
}
|
||||
}
|
@@ -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; }
|
||||
|
@@ -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)
|
||||
{
|
||||
|
@@ -9,5 +9,7 @@ namespace NzbDrone.SignalR
|
||||
|
||||
[System.Text.Json.Serialization.JsonIgnore]
|
||||
public ModelAction Action { get; set; }
|
||||
|
||||
public int? Version { get; set; }
|
||||
}
|
||||
}
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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();
|
||||
}
|
||||
|
75
src/Sonarr.Api.V5/CustomFormats/CustomFormatResource.cs
Normal file
75
src/Sonarr.Api.V5/CustomFormats/CustomFormatResource.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
@@ -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)
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
64
src/Sonarr.Api.V5/EpisodeFiles/EpisodeFileResource.cs
Normal file
64
src/Sonarr.Api.V5/EpisodeFiles/EpisodeFileResource.cs
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
68
src/Sonarr.Api.V5/EpisodeFiles/MediaInfoResource.cs
Normal file
68
src/Sonarr.Api.V5/EpisodeFiles/MediaInfoResource.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
84
src/Sonarr.Api.V5/Episodes/EpisodeResource.cs
Normal file
84
src/Sonarr.Api.V5/Episodes/EpisodeResource.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
56
src/Sonarr.Api.V5/Queue/QueueActionController.cs
Normal file
56
src/Sonarr.Api.V5/Queue/QueueActionController.cs
Normal 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 { };
|
||||
}
|
||||
}
|
||||
}
|
7
src/Sonarr.Api.V5/Queue/QueueBulkResource.cs
Normal file
7
src/Sonarr.Api.V5/Queue/QueueBulkResource.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace Sonarr.Api.V5.Queue
|
||||
{
|
||||
public class QueueBulkResource
|
||||
{
|
||||
public required List<int> Ids { get; set; }
|
||||
}
|
||||
}
|
404
src/Sonarr.Api.V5/Queue/QueueController.cs
Normal file
404
src/Sonarr.Api.V5/Queue/QueueController.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
73
src/Sonarr.Api.V5/Queue/QueueDetailsController.cs
Normal file
73
src/Sonarr.Api.V5/Queue/QueueDetailsController.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
95
src/Sonarr.Api.V5/Queue/QueueResource.cs
Normal file
95
src/Sonarr.Api.V5/Queue/QueueResource.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
79
src/Sonarr.Api.V5/Queue/QueueStatusController.cs
Normal file
79
src/Sonarr.Api.V5/Queue/QueueStatusController.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
15
src/Sonarr.Api.V5/Queue/QueueStatusResource.cs
Normal file
15
src/Sonarr.Api.V5/Queue/QueueStatusResource.cs
Normal 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; }
|
||||
}
|
||||
}
|
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -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);
|
||||
|
Reference in New Issue
Block a user