mirror of
https://github.com/Sonarr/Sonarr.git
synced 2026-04-18 21:35:27 -04:00
@@ -104,7 +104,6 @@ namespace Sonarr.Api.V3.Indexers
|
|||||||
var torrentInfo = (model.RemoteEpisode.Release as TorrentInfo) ?? new TorrentInfo();
|
var torrentInfo = (model.RemoteEpisode.Release as TorrentInfo) ?? new TorrentInfo();
|
||||||
var indexerFlags = torrentInfo.IndexerFlags;
|
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
|
return new ReleaseResource
|
||||||
{
|
{
|
||||||
Guid = releaseInfo.Guid,
|
Guid = releaseInfo.Guid,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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