diff --git a/src/Sonarr.Api.V3/Indexers/ReleaseResource.cs b/src/Sonarr.Api.V3/Indexers/ReleaseResource.cs index d9b12f633..7382daac9 100644 --- a/src/Sonarr.Api.V3/Indexers/ReleaseResource.cs +++ b/src/Sonarr.Api.V3/Indexers/ReleaseResource.cs @@ -104,7 +104,6 @@ namespace Sonarr.Api.V3.Indexers var torrentInfo = (model.RemoteEpisode.Release as TorrentInfo) ?? new TorrentInfo(); var indexerFlags = torrentInfo.IndexerFlags; - // TODO: Clean this mess up. don't mix data from multiple classes, use sub-resources instead? (Got a huge Deja Vu, didn't we talk about this already once?) return new ReleaseResource { Guid = releaseInfo.Guid, diff --git a/src/Sonarr.Api.V5/CustomFilters/CustomFilterController.cs b/src/Sonarr.Api.V5/CustomFilters/CustomFilterController.cs new file mode 100644 index 000000000..c5ac59bcb --- /dev/null +++ b/src/Sonarr.Api.V5/CustomFilters/CustomFilterController.cs @@ -0,0 +1,53 @@ +using Microsoft.AspNetCore.Mvc; +using NzbDrone.Core.CustomFilters; +using Sonarr.Http; +using Sonarr.Http.REST; +using Sonarr.Http.REST.Attributes; + +namespace Sonarr.Api.V5.CustomFilters; + +[V5ApiController] +public class CustomFilterController : RestController +{ + private readonly ICustomFilterService _customFilterService; + + public CustomFilterController(ICustomFilterService customFilterService) + { + _customFilterService = customFilterService; + } + + protected override CustomFilterResource GetResourceById(int id) + { + return _customFilterService.Get(id).ToResource(); + } + + [HttpGet] + [Produces("application/json")] + public List GetCustomFilters() + { + return _customFilterService.All().ToResource(); + } + + [RestPostById] + [Consumes("application/json")] + public ActionResult AddCustomFilter([FromBody] CustomFilterResource resource) + { + var customFilter = _customFilterService.Add(resource.ToModel()); + + return Created(customFilter.Id); + } + + [RestPutById] + [Consumes("application/json")] + public ActionResult UpdateCustomFilter([FromBody] CustomFilterResource resource) + { + _customFilterService.Update(resource.ToModel()); + return Accepted(resource.Id); + } + + [RestDeleteById] + public void DeleteCustomResource(int id) + { + _customFilterService.Delete(id); + } +} diff --git a/src/Sonarr.Api.V5/CustomFilters/CustomFilterResource.cs b/src/Sonarr.Api.V5/CustomFilters/CustomFilterResource.cs new file mode 100644 index 000000000..5fb9ce6ec --- /dev/null +++ b/src/Sonarr.Api.V5/CustomFilters/CustomFilterResource.cs @@ -0,0 +1,43 @@ +using System.Dynamic; +using NzbDrone.Common.Serializer; +using NzbDrone.Core.CustomFilters; +using Sonarr.Http.REST; + +namespace Sonarr.Api.V5.CustomFilters; + +public class CustomFilterResource : RestResource +{ + public string? Type { get; set; } + public string? Label { get; set; } + public List Filters { get; set; } = []; +} + +public static class CustomFilterResourceMapper +{ + public static CustomFilterResource ToResource(this CustomFilter model) + { + return new CustomFilterResource + { + Id = model.Id, + Type = model.Type, + Label = model.Label, + Filters = STJson.Deserialize>(model.Filters) + }; + } + + public static CustomFilter ToModel(this CustomFilterResource resource) + { + return new CustomFilter + { + Id = resource.Id, + Type = resource.Type, + Label = resource.Label, + Filters = STJson.ToJson(resource.Filters) + }; + } + + public static List ToResource(this IEnumerable filters) + { + return filters.Select(ToResource).ToList(); + } +} diff --git a/src/Sonarr.Api.V5/Release/ParsedEpisodeInfoResource.cs b/src/Sonarr.Api.V5/Release/ParsedEpisodeInfoResource.cs new file mode 100644 index 000000000..1fca48acd --- /dev/null +++ b/src/Sonarr.Api.V5/Release/ParsedEpisodeInfoResource.cs @@ -0,0 +1,44 @@ +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Qualities; + +namespace Sonarr.Api.V5.Release; + +public class ParsedEpisodeInfoResource +{ + public QualityModel? Quality { get; set; } + public string? ReleaseGroup { get; set; } + public string? ReleaseHash { get; set; } + public bool FullSeason { get; set; } + public int SeasonNumber { get; set; } + public string? AirDate { get; set; } + public string? SeriesTitle { get; set; } + public int[] EpisodeNumbers { get; set; } = []; + public int[] AbsoluteEpisodeNumbers { get; set; } = []; + public bool IsDaily { get; set; } + public bool IsAbsoluteNumbering { get; set; } + public bool IsPossibleSpecialEpisode { get; set; } + public bool Special { get; set; } +} + +public static class ParsedEpisodeInfoResourceMapper +{ + public static ParsedEpisodeInfoResource ToResource(this ParsedEpisodeInfo parsedEpisodeInfo) + { + return new ParsedEpisodeInfoResource + { + Quality = parsedEpisodeInfo.Quality, + ReleaseGroup = parsedEpisodeInfo.ReleaseGroup, + ReleaseHash = parsedEpisodeInfo.ReleaseHash, + FullSeason = parsedEpisodeInfo.FullSeason, + SeasonNumber = parsedEpisodeInfo.SeasonNumber, + AirDate = parsedEpisodeInfo.AirDate, + SeriesTitle = parsedEpisodeInfo.SeriesTitle, + EpisodeNumbers = parsedEpisodeInfo.EpisodeNumbers, + AbsoluteEpisodeNumbers = parsedEpisodeInfo.AbsoluteEpisodeNumbers, + IsDaily = parsedEpisodeInfo.IsDaily, + IsAbsoluteNumbering = parsedEpisodeInfo.IsAbsoluteNumbering, + IsPossibleSpecialEpisode = parsedEpisodeInfo.IsPossibleSpecialEpisode, + Special = parsedEpisodeInfo.Special, + }; + } +} diff --git a/src/Sonarr.Api.V5/Release/ReleaseController.cs b/src/Sonarr.Api.V5/Release/ReleaseController.cs new file mode 100644 index 000000000..91e833454 --- /dev/null +++ b/src/Sonarr.Api.V5/Release/ReleaseController.cs @@ -0,0 +1,254 @@ +using FluentValidation; +using Microsoft.AspNetCore.Mvc; +using NLog; +using NzbDrone.Common.Cache; +using NzbDrone.Common.EnsureThat; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.DecisionEngine; +using NzbDrone.Core.Download; +using NzbDrone.Core.Exceptions; +using NzbDrone.Core.Indexers; +using NzbDrone.Core.IndexerSearch; +using NzbDrone.Core.Parser; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Profiles.Qualities; +using NzbDrone.Core.Tv; +using NzbDrone.Core.Validation; +using Sonarr.Http; +using HttpStatusCode = System.Net.HttpStatusCode; + +namespace Sonarr.Api.V5.Release; + +[V5ApiController] +public class ReleaseController : ReleaseControllerBase +{ + private readonly IFetchAndParseRss _rssFetcherAndParser; + private readonly ISearchForReleases _releaseSearchService; + private readonly IMakeDownloadDecision _downloadDecisionMaker; + private readonly IPrioritizeDownloadDecision _prioritizeDownloadDecision; + private readonly IDownloadService _downloadService; + private readonly ISeriesService _seriesService; + private readonly IEpisodeService _episodeService; + private readonly IParsingService _parsingService; + private readonly Logger _logger; + + private readonly ICached _remoteEpisodeCache; + + public ReleaseController(IFetchAndParseRss rssFetcherAndParser, + ISearchForReleases releaseSearchService, + IMakeDownloadDecision downloadDecisionMaker, + IPrioritizeDownloadDecision prioritizeDownloadDecision, + IDownloadService downloadService, + ISeriesService seriesService, + IEpisodeService episodeService, + IParsingService parsingService, + ICacheManager cacheManager, + IQualityProfileService qualityProfileService, + Logger logger) + : base(qualityProfileService) + { + _rssFetcherAndParser = rssFetcherAndParser; + _releaseSearchService = releaseSearchService; + _downloadDecisionMaker = downloadDecisionMaker; + _prioritizeDownloadDecision = prioritizeDownloadDecision; + _downloadService = downloadService; + _seriesService = seriesService; + _episodeService = episodeService; + _parsingService = parsingService; + _logger = logger; + + PostValidator.RuleFor(s => s.Release).NotNull(); + PostValidator.RuleFor(s => s.Release!.IndexerId).ValidId(); + PostValidator.RuleFor(s => s.Release!.Guid).NotEmpty(); + + _remoteEpisodeCache = cacheManager.GetCache(GetType(), "remoteEpisodes"); + } + + [HttpPost] + [Consumes("application/json")] + public async Task DownloadRelease([FromBody] ReleaseGrabResource release) + { + var remoteEpisode = _remoteEpisodeCache.Find(GetCacheKey(release)); + + if (remoteEpisode == null) + { + _logger.Debug("Couldn't find requested release in cache, cache timeout probably expired."); + + throw new NzbDroneClientException(HttpStatusCode.NotFound, "Couldn't find requested release in cache, try searching again"); + } + + try + { + if (release.Override != null) + { + var overrideInfo = release.Override; + + Ensure.That(overrideInfo.SeriesId, () => release.Override.SeriesId).IsNotNull(); + Ensure.That(overrideInfo.EpisodeIds, () => overrideInfo.EpisodeIds).IsNotNull(); + Ensure.That(overrideInfo.EpisodeIds, () => overrideInfo.EpisodeIds).HasItems(); + Ensure.That(overrideInfo.Quality, () => overrideInfo.Quality).IsNotNull(); + Ensure.That(overrideInfo.Languages, () => overrideInfo.Languages).IsNotNull(); + + // Clone the remote episode so we don't overwrite anything on the original + remoteEpisode = new RemoteEpisode + { + Release = remoteEpisode.Release, + ParsedEpisodeInfo = remoteEpisode.ParsedEpisodeInfo.JsonClone(), + SceneMapping = remoteEpisode.SceneMapping, + MappedSeasonNumber = remoteEpisode.MappedSeasonNumber, + EpisodeRequested = remoteEpisode.EpisodeRequested, + DownloadAllowed = remoteEpisode.DownloadAllowed, + SeedConfiguration = remoteEpisode.SeedConfiguration, + CustomFormats = remoteEpisode.CustomFormats, + CustomFormatScore = remoteEpisode.CustomFormatScore, + SeriesMatchType = remoteEpisode.SeriesMatchType, + ReleaseSource = remoteEpisode.ReleaseSource + }; + + remoteEpisode.Series = _seriesService.GetSeries(overrideInfo.SeriesId!.Value); + remoteEpisode.Episodes = _episodeService.GetEpisodes(overrideInfo.EpisodeIds); + remoteEpisode.ParsedEpisodeInfo.Quality = overrideInfo.Quality; + remoteEpisode.Languages = overrideInfo.Languages; + } + + if (remoteEpisode.Series == null) + { + if (release.SearchInfo?.EpisodeId.HasValue == true) + { + var episode = _episodeService.GetEpisode(release.SearchInfo.EpisodeId.Value); + + remoteEpisode.Series = _seriesService.GetSeries(episode.SeriesId); + remoteEpisode.Episodes = new List { episode }; + } + else if (release.SearchInfo?.SeriesId.HasValue == true) + { + var series = _seriesService.GetSeries(release.SearchInfo.SeriesId.Value); + var episodes = _parsingService.GetEpisodes(remoteEpisode.ParsedEpisodeInfo, series, true); + + if (episodes.Empty()) + { + throw new NzbDroneClientException(HttpStatusCode.NotFound, "Unable to parse episodes in the release, will need to be manually provided"); + } + + remoteEpisode.Series = series; + remoteEpisode.Episodes = episodes; + } + else + { + throw new NzbDroneClientException(HttpStatusCode.NotFound, "Unable to find matching series and episodes, will need to be manually provided"); + } + } + else if (remoteEpisode.Episodes.Empty()) + { + var episodes = _parsingService.GetEpisodes(remoteEpisode.ParsedEpisodeInfo, remoteEpisode.Series, true); + + if (episodes.Empty() && release.SearchInfo?.EpisodeId.HasValue == true) + { + var episode = _episodeService.GetEpisode(release.SearchInfo.EpisodeId.Value); + + episodes = new List { episode }; + } + + remoteEpisode.Episodes = episodes; + } + + if (remoteEpisode.Episodes.Empty()) + { + throw new NzbDroneClientException(HttpStatusCode.NotFound, "Unable to parse episodes in the release, will need to be manually provided"); + } + + await _downloadService.DownloadReport(remoteEpisode, release.Override?.DownloadClientId); + } + catch (ReleaseDownloadException ex) + { + _logger.Error(ex, ex.Message); + throw new NzbDroneClientException(HttpStatusCode.Conflict, "Getting release from indexer failed"); + } + + return release; + } + + [HttpGet] + [Produces("application/json")] + public async Task> GetReleases(int? seriesId, int? episodeId, int? seasonNumber) + { + if (episodeId.HasValue) + { + return await GetEpisodeReleases(episodeId.Value); + } + + if (seriesId.HasValue && seasonNumber.HasValue) + { + return await GetSeasonReleases(seriesId.Value, seasonNumber.Value); + } + + return await GetRss(); + } + + private async Task> GetEpisodeReleases(int episodeId) + { + try + { + var decisions = await _releaseSearchService.EpisodeSearch(episodeId, true, true); + var prioritizedDecisions = _prioritizeDownloadDecision.PrioritizeDecisions(decisions); + + return MapDecisions(prioritizedDecisions); + } + catch (SearchFailedException ex) + { + throw new NzbDroneClientException(HttpStatusCode.BadRequest, ex.Message); + } + catch (Exception ex) + { + _logger.Error(ex, "Episode search failed: " + ex.Message); + throw new NzbDroneClientException(HttpStatusCode.InternalServerError, ex.Message); + } + } + + private async Task> GetSeasonReleases(int seriesId, int seasonNumber) + { + try + { + var decisions = await _releaseSearchService.SeasonSearch(seriesId, seasonNumber, false, false, true, true); + var prioritizedDecisions = _prioritizeDownloadDecision.PrioritizeDecisions(decisions); + + return MapDecisions(prioritizedDecisions); + } + catch (SearchFailedException ex) + { + throw new NzbDroneClientException(HttpStatusCode.BadRequest, ex.Message); + } + catch (Exception ex) + { + _logger.Error(ex, "Season search failed: " + ex.Message); + throw new NzbDroneClientException(HttpStatusCode.InternalServerError, ex.Message); + } + } + + private async Task> GetRss() + { + var reports = await _rssFetcherAndParser.Fetch(); + var decisions = _downloadDecisionMaker.GetRssDecision(reports); + var prioritizedDecisions = _prioritizeDownloadDecision.PrioritizeDecisions(decisions); + + return MapDecisions(prioritizedDecisions); + } + + protected override ReleaseResource MapDecision(DownloadDecision decision, int initialWeight) + { + var resource = base.MapDecision(decision, initialWeight); + _remoteEpisodeCache.Set(GetCacheKey(resource), decision.RemoteEpisode, TimeSpan.FromMinutes(30)); + + return resource; + } + + private string GetCacheKey(ReleaseResource resource) + { + return string.Concat(resource.Release!.IndexerId, "_", resource.Release!.Guid); + } + + private string GetCacheKey(ReleaseGrabResource resource) + { + return string.Concat(resource.IndexerId, "_", resource.Guid); + } +} diff --git a/src/Sonarr.Api.V5/Release/ReleaseControllerBase.cs b/src/Sonarr.Api.V5/Release/ReleaseControllerBase.cs new file mode 100644 index 000000000..d437a531f --- /dev/null +++ b/src/Sonarr.Api.V5/Release/ReleaseControllerBase.cs @@ -0,0 +1,61 @@ +using Microsoft.AspNetCore.Mvc; +using NzbDrone.Core.DecisionEngine; +using NzbDrone.Core.Profiles.Qualities; +using Sonarr.Http.REST; + +namespace Sonarr.Api.V5.Release; + +public abstract class ReleaseControllerBase : RestController +{ + private readonly QualityProfile _qualityProfile; + + public ReleaseControllerBase(IQualityProfileService qualityProfileService) + { + _qualityProfile = qualityProfileService.GetDefaultProfile(string.Empty); + } + + [NonAction] + public override ActionResult GetResourceByIdWithErrorHandler(int id) + { + return base.GetResourceByIdWithErrorHandler(id); + } + + protected override ReleaseResource GetResourceById(int id) + { + throw new NotImplementedException(); + } + + protected virtual List MapDecisions(IEnumerable decisions) + { + var result = new List(); + + foreach (var downloadDecision in decisions) + { + var release = MapDecision(downloadDecision, result.Count); + + result.Add(release); + } + + return result; + } + + protected virtual ReleaseResource MapDecision(DownloadDecision decision, int initialWeight) + { + var release = decision.ToResource(); + + release.ReleaseWeight = initialWeight; + + if (release.ParsedInfo?.Quality == null) + { + release.QualityWeight = 0; + } + else + { + release.QualityWeight = _qualityProfile.GetIndex(release.ParsedInfo.Quality.Quality).Index * 100; + release.QualityWeight += release.ParsedInfo.Quality.Revision.Real * 10; + release.QualityWeight += release.ParsedInfo.Quality.Revision.Version; + } + + return release; + } +} diff --git a/src/Sonarr.Api.V5/Release/ReleaseDecisionResource.cs b/src/Sonarr.Api.V5/Release/ReleaseDecisionResource.cs new file mode 100644 index 000000000..e0219ac59 --- /dev/null +++ b/src/Sonarr.Api.V5/Release/ReleaseDecisionResource.cs @@ -0,0 +1,33 @@ +using NzbDrone.Core.DecisionEngine; + +namespace Sonarr.Api.V5.Release; + +public class ReleaseDecisionResource +{ + public bool Approved { get; set; } + public bool TemporarilyRejected { get; set; } + public bool Rejected { get; set; } + public IEnumerable Rejections { get; set; } = []; + + public ReleaseDecisionResource(DownloadDecision downloadDecision) + { + Approved = downloadDecision.Approved; + TemporarilyRejected = downloadDecision.TemporarilyRejected; + Rejected = downloadDecision.Rejected; + Rejections = downloadDecision.Rejections.Select(r => new DownloadRejectionResource(r)).ToList(); + } +} + +public class DownloadRejectionResource +{ + public string Message { get; set; } + public DownloadRejectionReason Reason { get; set; } + public RejectionType Type { get; set; } + + public DownloadRejectionResource(DownloadRejection rejection) + { + Message = rejection.Message; + Reason = rejection.Reason; + Type = rejection.Type; + } +} diff --git a/src/Sonarr.Api.V5/Release/ReleaseGrabResource.cs b/src/Sonarr.Api.V5/Release/ReleaseGrabResource.cs new file mode 100644 index 000000000..50ac0ed42 --- /dev/null +++ b/src/Sonarr.Api.V5/Release/ReleaseGrabResource.cs @@ -0,0 +1,28 @@ +using NzbDrone.Core.Languages; +using NzbDrone.Core.Qualities; + +namespace Sonarr.Api.V5.Release; + +public class ReleaseGrabResource +{ + public required string Guid { get; set; } + public required int IndexerId { get; set; } + public OverrideReleaseResource? Override { get; set; } + public SearchInfoResource? SearchInfo { get; set; } +} + +public class OverrideReleaseResource +{ + public int? SeriesId { get; set; } + public List EpisodeIds { get; set; } = []; + public int? DownloadClientId { get; set; } + public QualityModel? Quality { get; set; } + public List Languages { get; set; } = []; +} + +public class SearchInfoResource +{ + public int? SeriesId { get; set; } + public int? SeasonNumber { get; set; } + public int? EpisodeId { get; set; } +} diff --git a/src/Sonarr.Api.V5/Release/ReleaseInfoResource.cs b/src/Sonarr.Api.V5/Release/ReleaseInfoResource.cs new file mode 100644 index 000000000..968417ea4 --- /dev/null +++ b/src/Sonarr.Api.V5/Release/ReleaseInfoResource.cs @@ -0,0 +1,64 @@ +using NzbDrone.Core.Indexers; +using NzbDrone.Core.Parser.Model; + +namespace Sonarr.Api.V5.Release; + +public class ReleaseInfoResource +{ + public string? Guid { get; set; } + public int Age { get; set; } + public double AgeHours { get; set; } + public double AgeMinutes { get; set; } + public long Size { get; set; } + public int IndexerId { get; set; } + public string? Indexer { get; set; } + public string? Title { get; set; } + public int TvdbId { get; set; } + public int TvRageId { get; set; } + public string? ImdbId { get; set; } + public IEnumerable Rejections { get; set; } = []; + public DateTime PublishDate { get; set; } + public string? CommentUrl { get; set; } + public string? DownloadUrl { get; set; } + public string? InfoUrl { get; set; } + public string? MagnetUrl { get; set; } + public string? InfoHash { get; set; } + public int? Seeders { get; set; } + public int? Leechers { get; set; } + public DownloadProtocol Protocol { get; set; } + public int IndexerFlags { get; set; } +} + +public static class ReleaseInfoResourceMapper +{ + public static ReleaseInfoResource ToResource(this ReleaseInfo releaseInfo) + { + var torrentInfo = releaseInfo as TorrentInfo ?? new TorrentInfo(); + var indexerFlags = torrentInfo.IndexerFlags; + + return new ReleaseInfoResource + { + Guid = releaseInfo.Guid, + Age = releaseInfo.Age, + AgeHours = releaseInfo.AgeHours, + AgeMinutes = releaseInfo.AgeMinutes, + Size = releaseInfo.Size, + IndexerId = releaseInfo.IndexerId, + Indexer = releaseInfo.Indexer, + Title = releaseInfo.Title, + TvdbId = releaseInfo.TvdbId, + TvRageId = releaseInfo.TvRageId, + ImdbId = releaseInfo.ImdbId, + PublishDate = releaseInfo.PublishDate, + CommentUrl = releaseInfo.CommentUrl, + DownloadUrl = releaseInfo.DownloadUrl, + InfoUrl = releaseInfo.InfoUrl, + MagnetUrl = torrentInfo.MagnetUrl, + InfoHash = torrentInfo.InfoHash, + Seeders = torrentInfo.Seeders, + Leechers = (torrentInfo.Peers.HasValue && torrentInfo.Seeders.HasValue) ? (torrentInfo.Peers.Value - torrentInfo.Seeders.Value) : (int?)null, + Protocol = releaseInfo.DownloadProtocol, + IndexerFlags = (int)indexerFlags, + }; + } +} diff --git a/src/Sonarr.Api.V5/Release/ReleasePushController.cs b/src/Sonarr.Api.V5/Release/ReleasePushController.cs new file mode 100644 index 000000000..1ab3552a8 --- /dev/null +++ b/src/Sonarr.Api.V5/Release/ReleasePushController.cs @@ -0,0 +1,141 @@ +using FluentValidation; +using FluentValidation.Results; +using Microsoft.AspNetCore.Mvc; +using NLog; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Datastore; +using NzbDrone.Core.DecisionEngine; +using NzbDrone.Core.Download; +using NzbDrone.Core.Indexers; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Profiles.Qualities; +using Sonarr.Http; +using Sonarr.Http.REST; + +namespace Sonarr.Api.V5.Release; + +[V5ApiController("release/push")] +public class ReleasePushController : RestController +{ + private readonly IMakeDownloadDecision _downloadDecisionMaker; + private readonly IProcessDownloadDecisions _downloadDecisionProcessor; + private readonly IIndexerFactory _indexerFactory; + private readonly IDownloadClientFactory _downloadClientFactory; + private readonly IQualityProfileService _qualityProfileService; + private readonly Logger _logger; + + private static readonly object PushLock = new object(); + + public ReleasePushController(IMakeDownloadDecision downloadDecisionMaker, + IProcessDownloadDecisions downloadDecisionProcessor, + IIndexerFactory indexerFactory, + IDownloadClientFactory downloadClientFactory, + IQualityProfileService qualityProfileService, + Logger logger) + { + _downloadDecisionMaker = downloadDecisionMaker; + _downloadDecisionProcessor = downloadDecisionProcessor; + _indexerFactory = indexerFactory; + _downloadClientFactory = downloadClientFactory; + _qualityProfileService = qualityProfileService; + _logger = logger; + + PostValidator.RuleFor(s => s.Title).NotEmpty(); + PostValidator.RuleFor(s => s.DownloadUrl).NotEmpty().When(s => s.MagnetUrl.IsNullOrWhiteSpace()); + PostValidator.RuleFor(s => s.MagnetUrl).NotEmpty().When(s => s.DownloadUrl.IsNullOrWhiteSpace()); + PostValidator.RuleFor(s => s.Protocol).NotEmpty(); + PostValidator.RuleFor(s => s.PublishDate).NotEmpty(); + } + + [HttpPost] + [Consumes("application/json")] + public ActionResult Create([FromBody] ReleasePushResource release) + { + _logger.Info("Release pushed: {0} - {1}", release.Title, release.DownloadUrl ?? release.MagnetUrl); + + ValidateResource(release); + + var info = release.ToModel(); + + info.Guid = "PUSH-" + info.DownloadUrl; + + ResolveIndexer(info); + + var downloadClientId = ResolveDownloadClientId(release); + + DownloadDecision? decision; + + lock (PushLock) + { + var decisions = _downloadDecisionMaker.GetRssDecision(new List { info }, true); + + decision = decisions.FirstOrDefault(); + + _downloadDecisionProcessor.ProcessDecision(decision, downloadClientId).GetAwaiter().GetResult(); + } + + if (decision?.RemoteEpisode.ParsedEpisodeInfo == null) + { + throw new ValidationException(new List { new("Title", "Unable to parse", release.Title) }); + } + + return decision.MapDecision(1, _qualityProfileService.GetDefaultProfile(string.Empty)); + } + + private void ResolveIndexer(ReleaseInfo release) + { + if (release.IndexerId == 0 && release.Indexer.IsNotNullOrWhiteSpace()) + { + var indexer = _indexerFactory.All().FirstOrDefault(v => v.Name.EqualsIgnoreCase(release.Indexer)); + + if (indexer != null) + { + release.IndexerId = indexer.Id; + _logger.Debug("Push Release {0} associated with indexer {1} - {2}.", release.Title, release.IndexerId, release.Indexer); + } + else + { + _logger.Debug("Push Release {0} not associated with known indexer {1}.", release.Title, release.Indexer); + } + } + else if (release.IndexerId != 0 && release.Indexer.IsNullOrWhiteSpace()) + { + try + { + var indexer = _indexerFactory.Get(release.IndexerId); + release.Indexer = indexer.Name; + _logger.Debug("Push Release {0} associated with indexer {1} - {2}.", release.Title, release.IndexerId, release.Indexer); + } + catch (ModelNotFoundException) + { + _logger.Debug("Push Release {0} not associated with known indexer {1}.", release.Title, release.IndexerId); + release.IndexerId = 0; + } + } + else + { + _logger.Debug("Push Release {0} not associated with an indexer.", release.Title); + } + } + + private int? ResolveDownloadClientId(ReleasePushResource release) + { + var downloadClientId = release.DownloadClientId.GetValueOrDefault(); + + if (downloadClientId == 0 && release.DownloadClientName.IsNotNullOrWhiteSpace()) + { + var downloadClient = _downloadClientFactory.All().FirstOrDefault(v => v.Name.EqualsIgnoreCase(release.DownloadClientName)); + + if (downloadClient != null) + { + _logger.Debug("Push Release {0} associated with download client {1} - {2}.", release.Title, downloadClientId, release.DownloadClientName); + + return downloadClient.Id; + } + + _logger.Debug("Push Release {0} not associated with known download client {1}.", release.Title, release.DownloadClientName); + } + + return release.DownloadClientId; + } +} diff --git a/src/Sonarr.Api.V5/Release/ReleasePushResource.cs b/src/Sonarr.Api.V5/Release/ReleasePushResource.cs new file mode 100644 index 000000000..7482f0cd5 --- /dev/null +++ b/src/Sonarr.Api.V5/Release/ReleasePushResource.cs @@ -0,0 +1,70 @@ +using NzbDrone.Core.Indexers; +using NzbDrone.Core.Parser.Model; +using Sonarr.Http.REST; + +namespace Sonarr.Api.V5.Release; + +public class ReleasePushResource : RestResource +{ + public string? Guid { get; set; } + public long Size { get; set; } + public int IndexerId { get; set; } + public string? Indexer { get; set; } + public string? Title { get; set; } + public int TvdbId { get; set; } + public int TvRageId { get; set; } + public string? ImdbId { get; set; } + public IEnumerable Rejections { get; set; } = []; + public DateTime PublishDate { get; set; } + public string? CommentUrl { get; set; } + public string? DownloadUrl { get; set; } + public string? InfoUrl { get; set; } + public string? MagnetUrl { get; set; } + public string? InfoHash { get; set; } + public int? Seeders { get; set; } + public int? Leechers { get; set; } + public DownloadProtocol Protocol { get; set; } + public int IndexerFlags { get; set; } + public int? DownloadClientId { get; set; } + public string? DownloadClientName { get; set; } +} + +public static class ReleasePushResourceMapper +{ + public static ReleaseInfo ToModel(this ReleasePushResource resource) + { + ReleaseInfo model; + + if (resource.Protocol == DownloadProtocol.Torrent) + { + model = new TorrentInfo + { + MagnetUrl = resource.MagnetUrl, + InfoHash = resource.InfoHash, + Seeders = resource.Seeders, + Peers = (resource.Seeders.HasValue && resource.Leechers.HasValue) ? (resource.Seeders + resource.Leechers) : null, + }; + } + else + { + model = new ReleaseInfo(); + } + + model.Guid = resource.Guid; + model.Title = resource.Title; + model.Size = resource.Size; + model.DownloadUrl = resource.DownloadUrl; + model.InfoUrl = resource.InfoUrl; + model.CommentUrl = resource.CommentUrl; + model.IndexerId = resource.IndexerId; + model.Indexer = resource.Indexer; + model.DownloadProtocol = resource.Protocol; + model.TvdbId = resource.TvdbId; + model.TvRageId = resource.TvRageId; + model.ImdbId = resource.ImdbId; + model.PublishDate = resource.PublishDate.ToUniversalTime(); + model.IndexerFlags = (IndexerFlags)resource.IndexerFlags; + + return model; + } +} diff --git a/src/Sonarr.Api.V5/Release/ReleaseResource.cs b/src/Sonarr.Api.V5/Release/ReleaseResource.cs new file mode 100644 index 000000000..33e1ad35c --- /dev/null +++ b/src/Sonarr.Api.V5/Release/ReleaseResource.cs @@ -0,0 +1,114 @@ +using NzbDrone.Core.DecisionEngine; +using NzbDrone.Core.Languages; +using NzbDrone.Core.Profiles.Qualities; +using NzbDrone.Core.Tv; +using Sonarr.Api.V5.CustomFormats; +using Sonarr.Api.V5.Series; +using Sonarr.Http.REST; + +namespace Sonarr.Api.V5.Release; + +public class ReleaseResource : RestResource +{ + public ParsedEpisodeInfoResource? ParsedInfo { get; set; } + public ReleaseInfoResource? Release { get; set; } + public ReleaseDecisionResource? Decision { get; set; } + public int QualityWeight { get; set; } + public List Languages { get; set; } = []; + public int? MappedSeasonNumber { get; set; } + public int[] MappedEpisodeNumbers { get; set; } = []; + public int[] MappedAbsoluteEpisodeNumbers { get; set; } = []; + public int? MappedSeriesId { get; set; } + public IEnumerable MappedEpisodeInfo { get; set; } = []; + public bool EpisodeRequested { get; set; } + public bool DownloadAllowed { get; set; } + public int ReleaseWeight { get; set; } + public List? CustomFormats { get; set; } + public int CustomFormatScore { get; set; } + public AlternateTitleResource? SceneMapping { get; set; } +} + +public static class ReleaseResourceMapper +{ + public static ReleaseResource ToResource(this DownloadDecision model) + { + var releaseInfo = model.RemoteEpisode.Release; + var parsedEpisodeInfo = model.RemoteEpisode.ParsedEpisodeInfo; + var remoteEpisode = model.RemoteEpisode; + + return new ReleaseResource + { + ParsedInfo = parsedEpisodeInfo.ToResource(), + Release = releaseInfo.ToResource(), + Decision = new ReleaseDecisionResource(model), + + Languages = remoteEpisode.Languages, + MappedSeriesId = remoteEpisode.Series?.Id, + MappedSeasonNumber = remoteEpisode.Episodes.FirstOrDefault()?.SeasonNumber, + MappedEpisodeNumbers = remoteEpisode.Episodes.Select(v => v.EpisodeNumber).ToArray(), + MappedAbsoluteEpisodeNumbers = remoteEpisode.Episodes.Where(v => v.AbsoluteEpisodeNumber.HasValue).Select(v => v.AbsoluteEpisodeNumber!.Value).ToArray(), + MappedEpisodeInfo = remoteEpisode.Episodes.Select(v => new ReleaseEpisodeResource(v)), + EpisodeRequested = remoteEpisode.EpisodeRequested, + DownloadAllowed = remoteEpisode.DownloadAllowed, + CustomFormatScore = remoteEpisode.CustomFormatScore, + CustomFormats = remoteEpisode.CustomFormats?.ToResource(false), + SceneMapping = remoteEpisode.SceneMapping?.ToResource(), + }; + } + + public static List MapDecisions(this IEnumerable decisions, QualityProfile profile) + { + var result = new List(); + + foreach (var downloadDecision in decisions) + { + var release = MapDecision(downloadDecision, result.Count, profile); + + result.Add(release); + } + + return result; + } + + public static ReleaseResource MapDecision(this DownloadDecision decision, int initialWeight, QualityProfile profile) + { + var release = decision.ToResource(); + + release.ReleaseWeight = initialWeight; + + if (release.ParsedInfo?.Quality == null) + { + release.QualityWeight = 0; + } + else + { + release.QualityWeight = profile.GetIndex(release.ParsedInfo.Quality.Quality).Index * 100; + release.QualityWeight += release.ParsedInfo.Quality.Revision.Real * 10; + release.QualityWeight += release.ParsedInfo.Quality.Revision.Version; + } + + return release; + } +} + +public class ReleaseEpisodeResource +{ + public int Id { get; set; } + public int SeasonNumber { get; set; } + public int EpisodeNumber { get; set; } + public int? AbsoluteEpisodeNumber { get; set; } + public string? Title { get; set; } + + public ReleaseEpisodeResource() + { + } + + public ReleaseEpisodeResource(Episode episode) + { + Id = episode.Id; + SeasonNumber = episode.SeasonNumber; + EpisodeNumber = episode.EpisodeNumber; + AbsoluteEpisodeNumber = episode.AbsoluteEpisodeNumber; + Title = episode.Title; + } +}