From 4fd5e9610a05c8929c14fff7cf6e60e45c59727e Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Tue, 30 Sep 2025 21:12:23 -0700 Subject: [PATCH] Add v5 History endpoints --- .../History/HistoryController.cs | 134 ++++++++++++++++++ src/Sonarr.Api.V5/History/HistoryResource.cs | 53 +++++++ 2 files changed, 187 insertions(+) create mode 100644 src/Sonarr.Api.V5/History/HistoryController.cs create mode 100644 src/Sonarr.Api.V5/History/HistoryResource.cs diff --git a/src/Sonarr.Api.V5/History/HistoryController.cs b/src/Sonarr.Api.V5/History/HistoryController.cs new file mode 100644 index 000000000..ef8383792 --- /dev/null +++ b/src/Sonarr.Api.V5/History/HistoryController.cs @@ -0,0 +1,134 @@ +using Microsoft.AspNetCore.Mvc; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.CustomFormats; +using NzbDrone.Core.Datastore; +using NzbDrone.Core.DecisionEngine.Specifications; +using NzbDrone.Core.Download; +using NzbDrone.Core.History; +using NzbDrone.Core.Tv; +using Sonarr.Api.V5.Episodes; +using Sonarr.Api.V5.Series; +using Sonarr.Http; +using Sonarr.Http.Extensions; + +namespace Sonarr.Api.V5.History; + +[V5ApiController] +public class HistoryController : Controller +{ + private readonly IHistoryService _historyService; + private readonly ICustomFormatCalculationService _formatCalculator; + private readonly IUpgradableSpecification _upgradableSpecification; + private readonly IFailedDownloadService _failedDownloadService; + private readonly ISeriesService _seriesService; + + public HistoryController(IHistoryService historyService, + ICustomFormatCalculationService formatCalculator, + IUpgradableSpecification upgradableSpecification, + IFailedDownloadService failedDownloadService, + ISeriesService seriesService) + { + _historyService = historyService; + _formatCalculator = formatCalculator; + _upgradableSpecification = upgradableSpecification; + _failedDownloadService = failedDownloadService; + _seriesService = seriesService; + } + + protected HistoryResource MapToResource(EpisodeHistory model, bool includeSeries, bool includeEpisode) + { + var resource = model.ToResource(_formatCalculator); + + if (includeSeries) + { + resource.Series = model.Series.ToResource(); + } + + if (includeEpisode) + { + resource.Episode = model.Episode.ToResource(); + } + + if (model.Series != null) + { + resource.QualityCutoffNotMet = _upgradableSpecification.QualityCutoffNotMet(model.Series.QualityProfile.Value, model.Quality); + } + + return resource; + } + + [HttpGet] + [Produces("application/json")] + public PagingResource GetHistory([FromQuery] PagingRequestResource paging, bool includeSeries, bool includeEpisode, [FromQuery(Name = "eventType")] int[]? eventTypes, int? episodeId, string? downloadId, [FromQuery] int[]? seriesIds = null, [FromQuery] int[]? languages = null, [FromQuery] int[]? quality = null) + { + var pagingResource = new PagingResource(paging); + var pagingSpec = pagingResource.MapToPagingSpec( + new HashSet(StringComparer.OrdinalIgnoreCase) + { + "date", + "series.sortTitle" + }, + "date", + SortDirection.Descending); + + if (eventTypes != null && eventTypes.Any()) + { + pagingSpec.FilterExpressions.Add(v => eventTypes.Contains((int)v.EventType)); + } + + if (episodeId.HasValue) + { + pagingSpec.FilterExpressions.Add(h => h.EpisodeId == episodeId); + } + + if (downloadId.IsNotNullOrWhiteSpace()) + { + pagingSpec.FilterExpressions.Add(h => h.DownloadId == downloadId); + } + + if (seriesIds != null && seriesIds.Any()) + { + pagingSpec.FilterExpressions.Add(h => seriesIds.Contains(h.SeriesId)); + } + + return pagingSpec.ApplyToPage(h => _historyService.Paged(pagingSpec, languages, quality), h => MapToResource(h, includeSeries, includeEpisode)); + } + + [HttpGet("since")] + [Produces("application/json")] + public List GetHistorySince(DateTime date, EpisodeHistoryEventType? eventType = null, bool includeSeries = false, bool includeEpisode = false) + { + return _historyService.Since(date, eventType).Select(h => MapToResource(h, includeSeries, includeEpisode)).ToList(); + } + + [HttpGet("series")] + [Produces("application/json")] + public List GetSeriesHistory(int seriesId, int? seasonNumber, EpisodeHistoryEventType? eventType = null, bool includeSeries = false, bool includeEpisode = false) + { + var series = _seriesService.GetSeries(seriesId); + + if (seasonNumber.HasValue) + { + return _historyService.GetBySeason(seriesId, seasonNumber.Value, eventType).Select(h => + { + h.Series = series; + + return MapToResource(h, includeSeries, includeEpisode); + }).ToList(); + } + + return _historyService.GetBySeries(seriesId, eventType).Select(h => + { + h.Series = series; + + return MapToResource(h, includeSeries, includeEpisode); + }).ToList(); + } + + [HttpPost("failed/{id}")] + public ActionResult MarkAsFailed([FromRoute] int id) + { + _failedDownloadService.MarkAsFailed(id); + return NoContent(); + } +} diff --git a/src/Sonarr.Api.V5/History/HistoryResource.cs b/src/Sonarr.Api.V5/History/HistoryResource.cs new file mode 100644 index 000000000..cedca5241 --- /dev/null +++ b/src/Sonarr.Api.V5/History/HistoryResource.cs @@ -0,0 +1,53 @@ +using NzbDrone.Core.CustomFormats; +using NzbDrone.Core.History; +using NzbDrone.Core.Languages; +using NzbDrone.Core.Qualities; +using Sonarr.Api.V5.CustomFormats; +using Sonarr.Api.V5.Episodes; +using Sonarr.Api.V5.Series; +using Sonarr.Http.REST; + +namespace Sonarr.Api.V5.History; + +public class HistoryResource : RestResource +{ + public int EpisodeId { get; set; } + public int SeriesId { get; set; } + public required string SourceTitle { get; set; } + public required List Languages { get; set; } + public required QualityModel Quality { get; set; } + public required List CustomFormats { get; set; } + public int CustomFormatScore { get; set; } + public bool QualityCutoffNotMet { get; set; } + public DateTime Date { get; set; } + public string? DownloadId { get; set; } + public EpisodeHistoryEventType EventType { get; set; } + public required Dictionary Data { get; set; } + public EpisodeResource? Episode { get; set; } + public SeriesResource? Series { get; set; } +} + +public static class HistoryResourceMapper +{ + public static HistoryResource ToResource(this EpisodeHistory model, ICustomFormatCalculationService formatCalculator) + { + var customFormats = formatCalculator.ParseCustomFormat(model, model.Series); + var customFormatScore = model.Series.QualityProfile.Value.CalculateCustomFormatScore(customFormats); + + return new HistoryResource + { + Id = model.Id, + EpisodeId = model.EpisodeId, + SeriesId = model.SeriesId, + SourceTitle = model.SourceTitle, + Languages = model.Languages, + Quality = model.Quality, + CustomFormats = customFormats.ToResource(false), + CustomFormatScore = customFormatScore, + Date = model.Date, + DownloadId = model.DownloadId, + EventType = model.EventType, + Data = model.Data + }; + } +}