From 5a702dec12a24a1b2f7c23a40079c563a31c2573 Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Thu, 13 Nov 2025 20:06:28 -0800 Subject: [PATCH] Add v5 episode, missing and cutoff unmet endpoints --- .../Episodes/EpisodeController.cs | 77 +++++++++++++++++++ .../Episodes/EpisodesMonitoredResource.cs | 7 ++ src/Sonarr.Api.V5/Wanted/CutoffController.cs | 57 ++++++++++++++ src/Sonarr.Api.V5/Wanted/MissingController.cs | 53 +++++++++++++ 4 files changed, 194 insertions(+) create mode 100644 src/Sonarr.Api.V5/Episodes/EpisodeController.cs create mode 100644 src/Sonarr.Api.V5/Episodes/EpisodesMonitoredResource.cs create mode 100644 src/Sonarr.Api.V5/Wanted/CutoffController.cs create mode 100644 src/Sonarr.Api.V5/Wanted/MissingController.cs diff --git a/src/Sonarr.Api.V5/Episodes/EpisodeController.cs b/src/Sonarr.Api.V5/Episodes/EpisodeController.cs new file mode 100644 index 000000000..dc02f0929 --- /dev/null +++ b/src/Sonarr.Api.V5/Episodes/EpisodeController.cs @@ -0,0 +1,77 @@ +using Microsoft.AspNetCore.Mvc; +using NzbDrone.Core.CustomFormats; +using NzbDrone.Core.DecisionEngine.Specifications; +using NzbDrone.Core.Tv; +using NzbDrone.SignalR; +using Sonarr.Http; +using Sonarr.Http.REST; +using Sonarr.Http.REST.Attributes; + +namespace Sonarr.Api.V5.Episodes; + +[V5ApiController] +public class EpisodeController : EpisodeControllerWithSignalR +{ + public EpisodeController(ISeriesService seriesService, + IEpisodeService episodeService, + IUpgradableSpecification upgradableSpecification, + ICustomFormatCalculationService formatCalculator, + IBroadcastSignalRMessage signalRBroadcaster) + : base(episodeService, seriesService, upgradableSpecification, formatCalculator, signalRBroadcaster) + { + } + + [HttpGet] + [Produces("application/json")] + public List GetEpisodes(int? seriesId, int? seasonNumber, [FromQuery]List episodeIds, int? episodeFileId, bool includeSeries = false, bool includeEpisodeFile = false, bool includeImages = false) + { + if (seriesId.HasValue) + { + if (seasonNumber.HasValue) + { + return MapToResource(_episodeService.GetEpisodesBySeason(seriesId.Value, seasonNumber.Value), includeSeries, includeEpisodeFile, includeImages); + } + + return MapToResource(_episodeService.GetEpisodeBySeries(seriesId.Value), includeSeries, includeEpisodeFile, includeImages); + } + else if (episodeIds.Any()) + { + return MapToResource(_episodeService.GetEpisodes(episodeIds), includeSeries, includeEpisodeFile, includeImages); + } + else if (episodeFileId.HasValue) + { + return MapToResource(_episodeService.GetEpisodesByFileId(episodeFileId.Value), includeSeries, includeEpisodeFile, includeImages); + } + + throw new BadRequestException("seriesId or episodeIds must be provided"); + } + + [RestPutById] + [Consumes("application/json")] + public ActionResult SetEpisodeMonitored([FromRoute] int id, [FromBody] EpisodeResource resource) + { + _episodeService.SetEpisodeMonitored(id, resource.Monitored); + + resource = MapToResource(_episodeService.GetEpisode(id), false, false, false); + + return Accepted(resource); + } + + [HttpPut("monitor")] + [Consumes("application/json")] + public IActionResult SetEpisodesMonitored([FromBody] EpisodesMonitoredResource resource, [FromQuery] bool includeImages = false) + { + if (resource.EpisodeIds.Count == 1) + { + _episodeService.SetEpisodeMonitored(resource.EpisodeIds.First(), resource.Monitored); + } + else + { + _episodeService.SetMonitored(resource.EpisodeIds, resource.Monitored); + } + + var resources = MapToResource(_episodeService.GetEpisodes(resource.EpisodeIds), false, false, includeImages); + + return Accepted(resources); + } +} diff --git a/src/Sonarr.Api.V5/Episodes/EpisodesMonitoredResource.cs b/src/Sonarr.Api.V5/Episodes/EpisodesMonitoredResource.cs new file mode 100644 index 000000000..a11dbe977 --- /dev/null +++ b/src/Sonarr.Api.V5/Episodes/EpisodesMonitoredResource.cs @@ -0,0 +1,7 @@ +namespace Sonarr.Api.V5.Episodes; + +public class EpisodesMonitoredResource +{ + public required List EpisodeIds { get; set; } + public bool Monitored { get; set; } +} diff --git a/src/Sonarr.Api.V5/Wanted/CutoffController.cs b/src/Sonarr.Api.V5/Wanted/CutoffController.cs new file mode 100644 index 000000000..866927c0e --- /dev/null +++ b/src/Sonarr.Api.V5/Wanted/CutoffController.cs @@ -0,0 +1,57 @@ +using Microsoft.AspNetCore.Mvc; +using NzbDrone.Core.CustomFormats; +using NzbDrone.Core.Datastore; +using NzbDrone.Core.DecisionEngine.Specifications; +using NzbDrone.Core.Tv; +using NzbDrone.SignalR; +using Sonarr.Api.V5.Episodes; +using Sonarr.Http; +using Sonarr.Http.Extensions; + +namespace Sonarr.Api.V5.Wanted; + +[V5ApiController("wanted/cutoff")] +public class CutoffController : EpisodeControllerWithSignalR +{ + private readonly IEpisodeCutoffService _episodeCutoffService; + + public CutoffController(IEpisodeCutoffService episodeCutoffService, + IEpisodeService episodeService, + ISeriesService seriesService, + IUpgradableSpecification upgradableSpecification, + ICustomFormatCalculationService formatCalculator, + IBroadcastSignalRMessage signalRBroadcaster) + : base(episodeService, seriesService, upgradableSpecification, formatCalculator, signalRBroadcaster) + { + _episodeCutoffService = episodeCutoffService; + } + + [HttpGet] + [Produces("application/json")] + public PagingResource GetCutoffUnmetEpisodes([FromQuery] PagingRequestResource paging, bool includeSeries = false, bool includeEpisodeFile = false, bool includeImages = false, bool monitored = true) + { + var pagingResource = new PagingResource(paging); + var pagingSpec = pagingResource.MapToPagingSpec( + new HashSet(StringComparer.OrdinalIgnoreCase) + { + "episodes.airDateUtc", + "episodes.lastSearchTime", + "series.sortTitle" + }, + "episodes.airDateUtc", + SortDirection.Ascending); + + if (monitored) + { + pagingSpec.FilterExpressions.Add(v => v.Monitored == true && v.Series.Monitored == true); + } + else + { + pagingSpec.FilterExpressions.Add(v => v.Monitored == false || v.Series.Monitored == false); + } + + var resource = pagingSpec.ApplyToPage(_episodeCutoffService.EpisodesWhereCutoffUnmet, v => MapToResource(v, includeSeries, includeEpisodeFile, includeImages)); + + return resource; + } +} diff --git a/src/Sonarr.Api.V5/Wanted/MissingController.cs b/src/Sonarr.Api.V5/Wanted/MissingController.cs new file mode 100644 index 000000000..752d0ac6c --- /dev/null +++ b/src/Sonarr.Api.V5/Wanted/MissingController.cs @@ -0,0 +1,53 @@ +using Microsoft.AspNetCore.Mvc; +using NzbDrone.Core.CustomFormats; +using NzbDrone.Core.Datastore; +using NzbDrone.Core.DecisionEngine.Specifications; +using NzbDrone.Core.Tv; +using NzbDrone.SignalR; +using Sonarr.Api.V5.Episodes; +using Sonarr.Http; +using Sonarr.Http.Extensions; + +namespace Sonarr.Api.V5.Wanted; + +[V5ApiController("wanted/missing")] +public class MissingController : EpisodeControllerWithSignalR +{ + public MissingController(IEpisodeService episodeService, + ISeriesService seriesService, + IUpgradableSpecification upgradableSpecification, + ICustomFormatCalculationService formatCalculator, + IBroadcastSignalRMessage signalRBroadcaster) + : base(episodeService, seriesService, upgradableSpecification, formatCalculator, signalRBroadcaster) + { + } + + [HttpGet] + [Produces("application/json")] + public PagingResource GetMissingEpisodes([FromQuery] PagingRequestResource paging, bool includeSeries = false, bool includeImages = false, bool monitored = true) + { + var pagingResource = new PagingResource(paging); + var pagingSpec = pagingResource.MapToPagingSpec( + new HashSet(StringComparer.OrdinalIgnoreCase) + { + "episodes.airDateUtc", + "episodes.lastSearchTime", + "series.sortTitle" + }, + "episodes.airDateUtc", + SortDirection.Ascending); + + if (monitored) + { + pagingSpec.FilterExpressions.Add(v => v.Monitored == true && v.Series.Monitored == true); + } + else + { + pagingSpec.FilterExpressions.Add(v => v.Monitored == false || v.Series.Monitored == false); + } + + var resource = pagingSpec.ApplyToPage(_episodeService.EpisodesWithoutFiles, v => MapToResource(v, includeSeries, false, includeImages)); + + return resource; + } +}