1
0
mirror of https://github.com/Sonarr/Sonarr.git synced 2026-03-05 13:20:20 -05:00

Add v5 release endpoints

Towards #6960
This commit is contained in:
Mark McDowall
2025-11-15 16:20:40 -08:00
parent 8e537cb626
commit 9b756df4bf
12 changed files with 905 additions and 1 deletions

View File

@@ -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,

View File

@@ -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<CustomFilterResource>
{
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<CustomFilterResource> GetCustomFilters()
{
return _customFilterService.All().ToResource();
}
[RestPostById]
[Consumes("application/json")]
public ActionResult<CustomFilterResource> AddCustomFilter([FromBody] CustomFilterResource resource)
{
var customFilter = _customFilterService.Add(resource.ToModel());
return Created(customFilter.Id);
}
[RestPutById]
[Consumes("application/json")]
public ActionResult<CustomFilterResource> UpdateCustomFilter([FromBody] CustomFilterResource resource)
{
_customFilterService.Update(resource.ToModel());
return Accepted(resource.Id);
}
[RestDeleteById]
public void DeleteCustomResource(int id)
{
_customFilterService.Delete(id);
}
}

View File

@@ -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<ExpandoObject> 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<List<ExpandoObject>>(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<CustomFilterResource> ToResource(this IEnumerable<CustomFilter> filters)
{
return filters.Select(ToResource).ToList();
}
}

View File

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

View File

@@ -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<RemoteEpisode> _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<RemoteEpisode>(GetType(), "remoteEpisodes");
}
[HttpPost]
[Consumes("application/json")]
public async Task<object> 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> { 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> { 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<List<ReleaseResource>> 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<List<ReleaseResource>> 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<List<ReleaseResource>> 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<List<ReleaseResource>> 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);
}
}

View File

@@ -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<ReleaseResource>
{
private readonly QualityProfile _qualityProfile;
public ReleaseControllerBase(IQualityProfileService qualityProfileService)
{
_qualityProfile = qualityProfileService.GetDefaultProfile(string.Empty);
}
[NonAction]
public override ActionResult<ReleaseResource> GetResourceByIdWithErrorHandler(int id)
{
return base.GetResourceByIdWithErrorHandler(id);
}
protected override ReleaseResource GetResourceById(int id)
{
throw new NotImplementedException();
}
protected virtual List<ReleaseResource> MapDecisions(IEnumerable<DownloadDecision> decisions)
{
var result = new List<ReleaseResource>();
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;
}
}

View File

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

View File

@@ -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<int> EpisodeIds { get; set; } = [];
public int? DownloadClientId { get; set; }
public QualityModel? Quality { get; set; }
public List<Language> Languages { get; set; } = [];
}
public class SearchInfoResource
{
public int? SeriesId { get; set; }
public int? SeasonNumber { get; set; }
public int? EpisodeId { get; set; }
}

View File

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

View File

@@ -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<ReleasePushResource>
{
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<ReleaseResource> 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<ReleaseInfo> { info }, true);
decision = decisions.FirstOrDefault();
_downloadDecisionProcessor.ProcessDecision(decision, downloadClientId).GetAwaiter().GetResult();
}
if (decision?.RemoteEpisode.ParsedEpisodeInfo == null)
{
throw new ValidationException(new List<ValidationFailure> { 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;
}
}

View File

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

View File

@@ -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<Language> 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<ReleaseEpisodeResource> MappedEpisodeInfo { get; set; } = [];
public bool EpisodeRequested { get; set; }
public bool DownloadAllowed { get; set; }
public int ReleaseWeight { get; set; }
public List<CustomFormatResource>? 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<ReleaseResource> MapDecisions(this IEnumerable<DownloadDecision> decisions, QualityProfile profile)
{
var result = new List<ReleaseResource>();
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;
}
}