mirror of
https://github.com/Sonarr/Sonarr.git
synced 2026-03-05 13:20:20 -05:00
@@ -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,
|
||||
|
||||
53
src/Sonarr.Api.V5/CustomFilters/CustomFilterController.cs
Normal file
53
src/Sonarr.Api.V5/CustomFilters/CustomFilterController.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
43
src/Sonarr.Api.V5/CustomFilters/CustomFilterResource.cs
Normal file
43
src/Sonarr.Api.V5/CustomFilters/CustomFilterResource.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
44
src/Sonarr.Api.V5/Release/ParsedEpisodeInfoResource.cs
Normal file
44
src/Sonarr.Api.V5/Release/ParsedEpisodeInfoResource.cs
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
254
src/Sonarr.Api.V5/Release/ReleaseController.cs
Normal file
254
src/Sonarr.Api.V5/Release/ReleaseController.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
61
src/Sonarr.Api.V5/Release/ReleaseControllerBase.cs
Normal file
61
src/Sonarr.Api.V5/Release/ReleaseControllerBase.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
33
src/Sonarr.Api.V5/Release/ReleaseDecisionResource.cs
Normal file
33
src/Sonarr.Api.V5/Release/ReleaseDecisionResource.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
28
src/Sonarr.Api.V5/Release/ReleaseGrabResource.cs
Normal file
28
src/Sonarr.Api.V5/Release/ReleaseGrabResource.cs
Normal 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; }
|
||||
}
|
||||
64
src/Sonarr.Api.V5/Release/ReleaseInfoResource.cs
Normal file
64
src/Sonarr.Api.V5/Release/ReleaseInfoResource.cs
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
141
src/Sonarr.Api.V5/Release/ReleasePushController.cs
Normal file
141
src/Sonarr.Api.V5/Release/ReleasePushController.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
70
src/Sonarr.Api.V5/Release/ReleasePushResource.cs
Normal file
70
src/Sonarr.Api.V5/Release/ReleasePushResource.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
114
src/Sonarr.Api.V5/Release/ReleaseResource.cs
Normal file
114
src/Sonarr.Api.V5/Release/ReleaseResource.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user