From 9cdf1bf721c70c448897b31598f9ce099e5a25f9 Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Mon, 17 Nov 2025 21:12:54 -0800 Subject: [PATCH] Add v5 episode file endpoints --- .../EpisodeFiles/EpisodeFileController.cs | 201 ++++++++++++++++++ .../EpisodeFiles/EpisodeFileListResource.cs | 7 + .../EpisodeFiles/EpisodeFileResource.cs | 6 +- .../EpisodeFiles/MediaInfoResource.cs | 7 +- 4 files changed, 217 insertions(+), 4 deletions(-) create mode 100644 src/Sonarr.Api.V5/EpisodeFiles/EpisodeFileController.cs create mode 100644 src/Sonarr.Api.V5/EpisodeFiles/EpisodeFileListResource.cs diff --git a/src/Sonarr.Api.V5/EpisodeFiles/EpisodeFileController.cs b/src/Sonarr.Api.V5/EpisodeFiles/EpisodeFileController.cs new file mode 100644 index 000000000..aed000529 --- /dev/null +++ b/src/Sonarr.Api.V5/EpisodeFiles/EpisodeFileController.cs @@ -0,0 +1,201 @@ +using System.Net; +using Microsoft.AspNetCore.Mvc; +using NzbDrone.Core.CustomFormats; +using NzbDrone.Core.Datastore.Events; +using NzbDrone.Core.DecisionEngine.Specifications; +using NzbDrone.Core.Exceptions; +using NzbDrone.Core.Languages; +using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.MediaFiles.Events; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.Parser; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Tv; +using NzbDrone.SignalR; +using Sonarr.Http; +using Sonarr.Http.REST; +using Sonarr.Http.REST.Attributes; +using BadRequestException = Sonarr.Http.REST.BadRequestException; + +namespace Sonarr.Api.V5.EpisodeFiles; + +[V5ApiController] +public class EpisodeFileController : RestControllerWithSignalR, + IHandle, + IHandle +{ + private readonly IMediaFileService _mediaFileService; + private readonly IDeleteMediaFiles _mediaFileDeletionService; + private readonly ISeriesService _seriesService; + private readonly ICustomFormatCalculationService _formatCalculator; + private readonly IUpgradableSpecification _upgradableSpecification; + + public EpisodeFileController(IBroadcastSignalRMessage signalRBroadcaster, + IMediaFileService mediaFileService, + IDeleteMediaFiles mediaFileDeletionService, + ISeriesService seriesService, + ICustomFormatCalculationService formatCalculator, + IUpgradableSpecification upgradableSpecification) + : base(signalRBroadcaster) + { + _mediaFileService = mediaFileService; + _mediaFileDeletionService = mediaFileDeletionService; + _seriesService = seriesService; + _formatCalculator = formatCalculator; + _upgradableSpecification = upgradableSpecification; + } + + protected override EpisodeFileResource GetResourceById(int id) + { + var episodeFile = _mediaFileService.Get(id); + var series = _seriesService.GetSeries(episodeFile.SeriesId); + + var resource = episodeFile.ToResource(series, _upgradableSpecification, _formatCalculator); + + return resource; + } + + [HttpGet] + [Produces("application/json")] + public List GetEpisodeFiles(int? seriesId, [FromQuery] List? episodeFileIds) + { + if (!seriesId.HasValue && episodeFileIds?.Any() == false) + { + throw new BadRequestException("seriesId or episodeFileIds must be provided"); + } + + if (seriesId.HasValue) + { + var series = _seriesService.GetSeries(seriesId.Value); + var files = _mediaFileService.GetFilesBySeries(seriesId.Value); + + if (files == null) + { + return new List(); + } + + return files.ConvertAll(e => e.ToResource(series, _upgradableSpecification, _formatCalculator)); + } + else + { + var episodeFiles = _mediaFileService.Get(episodeFileIds); + + return episodeFiles.GroupBy(e => e.SeriesId) + .SelectMany(f => f.ToList() + .ConvertAll(e => e.ToResource(_seriesService.GetSeries(f.Key), _upgradableSpecification, _formatCalculator))) + .ToList(); + } + } + + [RestPutById] + [Consumes("application/json")] + public ActionResult SetQuality([FromBody] EpisodeFileResource episodeFileResource) + { + var episodeFile = _mediaFileService.Get(episodeFileResource.Id); + episodeFile.Quality = episodeFileResource.Quality; + + if (episodeFileResource.SceneName != null && SceneChecker.IsSceneTitle(episodeFileResource.SceneName)) + { + episodeFile.SceneName = episodeFileResource.SceneName; + } + + if (episodeFileResource.ReleaseGroup != null) + { + episodeFile.ReleaseGroup = episodeFileResource.ReleaseGroup; + } + + _mediaFileService.Update(episodeFile); + return Accepted(episodeFile.Id); + } + + [RestDeleteById] + public void DeleteEpisodeFile(int id) + { + var episodeFile = _mediaFileService.Get(id); + + if (episodeFile == null) + { + throw new NzbDroneClientException(HttpStatusCode.NotFound, "Episode file not found"); + } + + var series = _seriesService.GetSeries(episodeFile.SeriesId); + + _mediaFileDeletionService.DeleteEpisodeFile(series, episodeFile); + } + + [HttpDelete("bulk")] + [Consumes("application/json")] + public object DeleteEpisodeFiles([FromBody] EpisodeFileListResource resource) + { + var episodeFiles = _mediaFileService.GetFiles(resource.EpisodeFileIds); + var series = _seriesService.GetSeries(episodeFiles.First().SeriesId); + + foreach (var episodeFile in episodeFiles) + { + _mediaFileDeletionService.DeleteEpisodeFile(series, episodeFile); + } + + return new { }; + } + + [HttpPut("bulk")] + [Consumes("application/json")] + public object SetPropertiesBulk([FromBody] List resources) + { + var episodeFiles = _mediaFileService.GetFiles(resources.Select(r => r.Id)); + + foreach (var episodeFile in episodeFiles) + { + var resourceEpisodeFile = resources.Single(r => r.Id == episodeFile.Id); + + if (resourceEpisodeFile.Languages != null) + { + // Don't allow user to set files with 'Original' language + episodeFile.Languages = resourceEpisodeFile.Languages.Where(l => l != null && l != Language.Original).ToList(); + } + + if (resourceEpisodeFile.Quality != null) + { + episodeFile.Quality = resourceEpisodeFile.Quality; + } + + if (resourceEpisodeFile.SceneName != null && SceneChecker.IsSceneTitle(resourceEpisodeFile.SceneName)) + { + episodeFile.SceneName = resourceEpisodeFile.SceneName; + } + + if (resourceEpisodeFile.ReleaseGroup != null) + { + episodeFile.ReleaseGroup = resourceEpisodeFile.ReleaseGroup; + } + + if (resourceEpisodeFile.IndexerFlags.HasValue) + { + episodeFile.IndexerFlags = (IndexerFlags)resourceEpisodeFile.IndexerFlags; + } + + if (resourceEpisodeFile.ReleaseType != null) + { + episodeFile.ReleaseType = (ReleaseType)resourceEpisodeFile.ReleaseType; + } + } + + _mediaFileService.Update(episodeFiles); + + var series = _seriesService.GetSeries(episodeFiles.First().SeriesId); + + return Accepted(episodeFiles.ConvertAll(f => f.ToResource(series, _upgradableSpecification, _formatCalculator))); + } + + [NonAction] + public void Handle(EpisodeFileAddedEvent message) + { + BroadcastResourceChange(ModelAction.Updated, message.EpisodeFile.Id); + } + + [NonAction] + public void Handle(EpisodeFileDeletedEvent message) + { + BroadcastResourceChange(ModelAction.Deleted, message.EpisodeFile.Id); + } +} diff --git a/src/Sonarr.Api.V5/EpisodeFiles/EpisodeFileListResource.cs b/src/Sonarr.Api.V5/EpisodeFiles/EpisodeFileListResource.cs new file mode 100644 index 000000000..ae8fd5883 --- /dev/null +++ b/src/Sonarr.Api.V5/EpisodeFiles/EpisodeFileListResource.cs @@ -0,0 +1,7 @@ +namespace Sonarr.Api.V5.EpisodeFiles +{ + public class EpisodeFileListResource + { + public List EpisodeFileIds { get; set; } = []; + } +} diff --git a/src/Sonarr.Api.V5/EpisodeFiles/EpisodeFileResource.cs b/src/Sonarr.Api.V5/EpisodeFiles/EpisodeFileResource.cs index c103c17bf..40a6f5de4 100644 --- a/src/Sonarr.Api.V5/EpisodeFiles/EpisodeFileResource.cs +++ b/src/Sonarr.Api.V5/EpisodeFiles/EpisodeFileResource.cs @@ -19,9 +19,9 @@ public class EpisodeFileResource : RestResource public DateTime DateAdded { get; set; } public string? SceneName { get; set; } public string? ReleaseGroup { get; set; } - public required List Languages { get; set; } - public required QualityModel Quality { get; set; } - public required List CustomFormats { get; set; } + public List Languages { get; set; } = []; + public QualityModel? Quality { get; set; } + public List CustomFormats { get; set; } = []; public int CustomFormatScore { get; set; } public int? IndexerFlags { get; set; } public ReleaseType? ReleaseType { get; set; } diff --git a/src/Sonarr.Api.V5/EpisodeFiles/MediaInfoResource.cs b/src/Sonarr.Api.V5/EpisodeFiles/MediaInfoResource.cs index 323198955..e131fafaa 100644 --- a/src/Sonarr.Api.V5/EpisodeFiles/MediaInfoResource.cs +++ b/src/Sonarr.Api.V5/EpisodeFiles/MediaInfoResource.cs @@ -25,8 +25,13 @@ public class MediaInfoResource : RestResource public static class MediaInfoResourceMapper { - public static MediaInfoResource ToResource(this MediaInfoModel model, string sceneName) + public static MediaInfoResource? ToResource(this MediaInfoModel? model, string sceneName) { + if (model == null) + { + return null; + } + return new MediaInfoResource { AudioBitrate = model.AudioBitrate,