diff --git a/src/NzbDrone.Common/TPL/LockByIdPool.cs b/src/NzbDrone.Common/TPL/LockByIdPool.cs new file mode 100644 index 000000000..57b865a85 --- /dev/null +++ b/src/NzbDrone.Common/TPL/LockByIdPool.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; + +namespace NzbDrone.Common.TPL; + +public class LockByIdPool +{ + private readonly Dictionary _locks = new(); + private readonly object _lockObject = new(); + + public object GetLock(int id) + { + lock (_lockObject) + { + if (!_locks.ContainsKey(id)) + { + _locks[id] = new object(); + } + + return _locks[id]; + } + } +} diff --git a/src/Sonarr.Api.V5/ApplyTags.cs b/src/Sonarr.Api.V5/ApplyTags.cs new file mode 100644 index 000000000..8176c4a88 --- /dev/null +++ b/src/Sonarr.Api.V5/ApplyTags.cs @@ -0,0 +1,8 @@ +namespace Sonarr.Api.V5; + +public enum ApplyTags +{ + Add, + Remove, + Replace +} diff --git a/src/Sonarr.Api.V5/SeasonPass/SeasonPassController.cs b/src/Sonarr.Api.V5/SeasonPass/SeasonPassController.cs new file mode 100644 index 000000000..dc55e7d74 --- /dev/null +++ b/src/Sonarr.Api.V5/SeasonPass/SeasonPassController.cs @@ -0,0 +1,57 @@ +using Microsoft.AspNetCore.Mvc; +using NzbDrone.Core.Tv; +using Sonarr.Http; + +namespace Sonarr.Api.V5.SeasonPass; + +[V5ApiController] +public class SeasonPassController : Controller +{ + private readonly ISeriesService _seriesService; + private readonly IEpisodeMonitoredService _episodeMonitoredService; + + public SeasonPassController(ISeriesService seriesService, IEpisodeMonitoredService episodeMonitoredService) + { + _seriesService = seriesService; + _episodeMonitoredService = episodeMonitoredService; + } + + [HttpPost] + [Consumes("application/json")] + public IActionResult UpdateAll([FromBody] SeasonPassResource resource) + { + var seriesToUpdate = _seriesService.GetSeries(resource.Series.Select(s => s.Id)); + + foreach (var s in resource.Series) + { + var series = seriesToUpdate.Single(c => c.Id == s.Id); + + if (s.Monitored.HasValue) + { + series.Monitored = s.Monitored.Value; + } + + if (s.Seasons.Any()) + { + foreach (var seriesSeason in series.Seasons) + { + var season = s.Seasons.FirstOrDefault(c => c.SeasonNumber == seriesSeason.SeasonNumber); + + if (season != null) + { + seriesSeason.Monitored = season.Monitored; + } + } + } + + if (resource.MonitoringOptions != null && resource.MonitoringOptions.Monitor == MonitorTypes.None) + { + series.Monitored = false; + } + + _episodeMonitoredService.SetEpisodeMonitoredStatus(series, resource.MonitoringOptions.ToModel()); + } + + return NoContent(); + } +} diff --git a/src/Sonarr.Api.V5/SeasonPass/SeasonPassResource.cs b/src/Sonarr.Api.V5/SeasonPass/SeasonPassResource.cs new file mode 100644 index 000000000..219c7c80a --- /dev/null +++ b/src/Sonarr.Api.V5/SeasonPass/SeasonPassResource.cs @@ -0,0 +1,30 @@ +using NzbDrone.Core.Tv; + +namespace Sonarr.Api.V5.SeasonPass; + +public class SeasonPassResource +{ + public List Series { get; set; } = []; + public MonitoringOptionsResource? MonitoringOptions { get; set; } +} + +public class MonitoringOptionsResource +{ + public MonitorTypes Monitor { get; set; } +} + +public static class MonitoringOptionsResourceMapper +{ + public static MonitoringOptions ToModel(this MonitoringOptionsResource? resource) + { + if (resource == null) + { + return new MonitoringOptions(); + } + + return new MonitoringOptions + { + Monitor = resource.Monitor + }; + } +} diff --git a/src/Sonarr.Api.V5/SeasonPass/SeasonPassSeriesResource.cs b/src/Sonarr.Api.V5/SeasonPass/SeasonPassSeriesResource.cs new file mode 100644 index 000000000..657981304 --- /dev/null +++ b/src/Sonarr.Api.V5/SeasonPass/SeasonPassSeriesResource.cs @@ -0,0 +1,10 @@ +using Sonarr.Api.V5.Series; + +namespace Sonarr.Api.V5.SeasonPass; + +public class SeasonPassSeriesResource +{ + public int Id { get; set; } + public bool? Monitored { get; set; } + public List Seasons { get; set; } = []; +} diff --git a/src/Sonarr.Api.V5/Series/SeriesController.cs b/src/Sonarr.Api.V5/Series/SeriesController.cs index 4c6c7518a..34e487e7f 100644 --- a/src/Sonarr.Api.V5/Series/SeriesController.cs +++ b/src/Sonarr.Api.V5/Series/SeriesController.cs @@ -1,6 +1,7 @@ using FluentValidation; using Microsoft.AspNetCore.Mvc; using NzbDrone.Common.Extensions; +using NzbDrone.Common.TPL; using NzbDrone.Core.DataAugmentation.Scene; using NzbDrone.Core.Datastore; using NzbDrone.Core.Datastore.Events; @@ -22,360 +23,388 @@ using Sonarr.Http.REST; using Sonarr.Http.REST.Attributes; -namespace Sonarr.Api.V5.Series +namespace Sonarr.Api.V5.Series; + +[V5ApiController] +public class SeriesController : RestControllerWithSignalR, + IHandle, + IHandle, + IHandle, + IHandle, + IHandle, + IHandle, + IHandle, + IHandle { - [V5ApiController] - public class SeriesController : RestControllerWithSignalR, - IHandle, - IHandle, - IHandle, - IHandle, - IHandle, - IHandle, - IHandle, - IHandle + private readonly ISeriesService _seriesService; + private readonly IAddSeriesService _addSeriesService; + private readonly ISeriesStatisticsService _seriesStatisticsService; + private readonly ISceneMappingService _sceneMappingService; + private readonly IMapCoversToLocal _coverMapper; + private readonly IManageCommandQueue _commandQueueManager; + private readonly IRootFolderService _rootFolderService; + + private readonly LockByIdPool _seriesLockPool = new(); + + public SeriesController(IBroadcastSignalRMessage signalRBroadcaster, + ISeriesService seriesService, + IAddSeriesService addSeriesService, + ISeriesStatisticsService seriesStatisticsService, + ISceneMappingService sceneMappingService, + IMapCoversToLocal coverMapper, + IManageCommandQueue commandQueueManager, + IRootFolderService rootFolderService, + RootFolderValidator rootFolderValidator, + MappedNetworkDriveValidator mappedNetworkDriveValidator, + SeriesPathValidator seriesPathValidator, + SeriesExistsValidator seriesExistsValidator, + SeriesAncestorValidator seriesAncestorValidator, + SystemFolderValidator systemFolderValidator, + QualityProfileExistsValidator qualityProfileExistsValidator, + RootFolderExistsValidator rootFolderExistsValidator, + SeriesFolderAsRootFolderValidator seriesFolderAsRootFolderValidator) + : base(signalRBroadcaster) { - private readonly ISeriesService _seriesService; - private readonly IAddSeriesService _addSeriesService; - private readonly ISeriesStatisticsService _seriesStatisticsService; - private readonly ISceneMappingService _sceneMappingService; - private readonly IMapCoversToLocal _coverMapper; - private readonly IManageCommandQueue _commandQueueManager; - private readonly IRootFolderService _rootFolderService; + _seriesService = seriesService; + _addSeriesService = addSeriesService; + _seriesStatisticsService = seriesStatisticsService; + _sceneMappingService = sceneMappingService; - public SeriesController(IBroadcastSignalRMessage signalRBroadcaster, - ISeriesService seriesService, - IAddSeriesService addSeriesService, - ISeriesStatisticsService seriesStatisticsService, - ISceneMappingService sceneMappingService, - IMapCoversToLocal coverMapper, - IManageCommandQueue commandQueueManager, - IRootFolderService rootFolderService, - RootFolderValidator rootFolderValidator, - MappedNetworkDriveValidator mappedNetworkDriveValidator, - SeriesPathValidator seriesPathValidator, - SeriesExistsValidator seriesExistsValidator, - SeriesAncestorValidator seriesAncestorValidator, - SystemFolderValidator systemFolderValidator, - QualityProfileExistsValidator qualityProfileExistsValidator, - RootFolderExistsValidator rootFolderExistsValidator, - SeriesFolderAsRootFolderValidator seriesFolderAsRootFolderValidator) - : base(signalRBroadcaster) + _coverMapper = coverMapper; + _commandQueueManager = commandQueueManager; + _rootFolderService = rootFolderService; + + SharedValidator.RuleFor(s => s.Path).Cascade(CascadeMode.Stop) + .IsValidPath() + .SetValidator(rootFolderValidator) + .SetValidator(mappedNetworkDriveValidator) + .SetValidator(seriesPathValidator) + .SetValidator(seriesAncestorValidator) + .SetValidator(systemFolderValidator) + .When(s => s.Path.IsNotNullOrWhiteSpace()); + + PostValidator.RuleFor(s => s.Path).Cascade(CascadeMode.Stop) + .NotEmpty() + .IsValidPath() + .When(s => s.RootFolderPath.IsNullOrWhiteSpace()); + PostValidator.RuleFor(s => s.RootFolderPath).Cascade(CascadeMode.Stop) + .NotEmpty() + .IsValidPath() + .SetValidator(rootFolderExistsValidator) + .SetValidator(seriesFolderAsRootFolderValidator) + .When(s => s.Path.IsNullOrWhiteSpace()); + + PutValidator.RuleFor(s => s.Path).Cascade(CascadeMode.Stop) + .NotEmpty() + .IsValidPath(); + + SharedValidator.RuleFor(s => s.QualityProfileId).Cascade(CascadeMode.Stop) + .ValidId() + .SetValidator(qualityProfileExistsValidator); + + PostValidator.RuleFor(s => s.Title).NotEmpty(); + PostValidator.RuleFor(s => s.TvdbId).GreaterThan(0).SetValidator(seriesExistsValidator); + } + + [HttpGet] + [Produces("application/json")] + public List AllSeries(int? tvdbId, bool includeSeasonImages = false) + { + var seriesStats = _seriesStatisticsService.SeriesStatistics(); + var seriesResources = new List(); + + if (tvdbId.HasValue) { - _seriesService = seriesService; - _addSeriesService = addSeriesService; - _seriesStatisticsService = seriesStatisticsService; - _sceneMappingService = sceneMappingService; - - _coverMapper = coverMapper; - _commandQueueManager = commandQueueManager; - _rootFolderService = rootFolderService; - - SharedValidator.RuleFor(s => s.Path).Cascade(CascadeMode.Stop) - .IsValidPath() - .SetValidator(rootFolderValidator) - .SetValidator(mappedNetworkDriveValidator) - .SetValidator(seriesPathValidator) - .SetValidator(seriesAncestorValidator) - .SetValidator(systemFolderValidator) - .When(s => s.Path.IsNotNullOrWhiteSpace()); - - PostValidator.RuleFor(s => s.Path).Cascade(CascadeMode.Stop) - .NotEmpty() - .IsValidPath() - .When(s => s.RootFolderPath.IsNullOrWhiteSpace()); - PostValidator.RuleFor(s => s.RootFolderPath).Cascade(CascadeMode.Stop) - .NotEmpty() - .IsValidPath() - .SetValidator(rootFolderExistsValidator) - .SetValidator(seriesFolderAsRootFolderValidator) - .When(s => s.Path.IsNullOrWhiteSpace()); - - PutValidator.RuleFor(s => s.Path).Cascade(CascadeMode.Stop) - .NotEmpty() - .IsValidPath(); - - SharedValidator.RuleFor(s => s.QualityProfileId).Cascade(CascadeMode.Stop) - .ValidId() - .SetValidator(qualityProfileExistsValidator); - - PostValidator.RuleFor(s => s.Title).NotEmpty(); - PostValidator.RuleFor(s => s.TvdbId).GreaterThan(0).SetValidator(seriesExistsValidator); + seriesResources.AddIfNotNull(_seriesService.FindByTvdbId(tvdbId.Value).ToResource(includeSeasonImages)); + } + else + { + seriesResources.AddRange(_seriesService.GetAllSeries().Select(s => s.ToResource(includeSeasonImages))); } - [HttpGet] - [Produces("application/json")] - public List AllSeries(int? tvdbId, bool includeSeasonImages = false) + MapCoversToLocal(seriesResources.ToArray()); + LinkSeriesStatistics(seriesResources, seriesStats.ToDictionary(x => x.SeriesId)); + PopulateAlternateTitles(seriesResources); + seriesResources.ForEach(LinkRootFolderPath); + + return seriesResources; + } + + [NonAction] + public override ActionResult GetResourceByIdWithErrorHandler(int id) + { + return base.GetResourceByIdWithErrorHandler(id); + } + + [RestGetById] + [Produces("application/json")] + public ActionResult GetResourceByIdWithErrorHandler(int id, [FromQuery]bool includeSeasonImages = false) + { + try { - var seriesStats = _seriesStatisticsService.SeriesStatistics(); - var seriesResources = new List(); + var series = GetSeriesResourceById(id, includeSeasonImages); - if (tvdbId.HasValue) + return series == null ? NotFound() : series; + } + catch (ModelNotFoundException) + { + return NotFound(); + } + } + + protected override SeriesResource? GetResourceById(int id) + { + var includeSeasonImages = Request?.GetBooleanQueryParameter("includeSeasonImages", false) ?? false; + + // Parse IncludeImages and use it + return GetSeriesResourceById(id, includeSeasonImages); + } + + private SeriesResource? GetSeriesResourceById(int id, bool includeSeasonImages = false) + { + var series = _seriesService.GetSeries(id); + + // Parse IncludeImages and use it + return GetSeriesResource(series, includeSeasonImages); + } + + [RestPostById] + [Consumes("application/json")] + [Produces("application/json")] + public ActionResult AddSeries([FromBody] SeriesResource seriesResource) + { + var series = _addSeriesService.AddSeries(seriesResource.ToModel()); + + return Created(series.Id); + } + + [RestPutById] + [Consumes("application/json")] + [Produces("application/json")] + public ActionResult UpdateSeries([FromBody] SeriesResource seriesResource, [FromQuery] bool moveFiles = false) + { + var series = _seriesService.GetSeries(seriesResource.Id); + + if (moveFiles) + { + var sourcePath = series.Path; + var destinationPath = seriesResource.Path; + + _commandQueueManager.Push(new MoveSeriesCommand { - seriesResources.AddIfNotNull(_seriesService.FindByTvdbId(tvdbId.Value).ToResource(includeSeasonImages)); - } - else - { - seriesResources.AddRange(_seriesService.GetAllSeries().Select(s => s.ToResource(includeSeasonImages))); - } - - MapCoversToLocal(seriesResources.ToArray()); - LinkSeriesStatistics(seriesResources, seriesStats.ToDictionary(x => x.SeriesId)); - PopulateAlternateTitles(seriesResources); - seriesResources.ForEach(LinkRootFolderPath); - - return seriesResources; + SeriesId = series.Id, + SourcePath = sourcePath, + DestinationPath = destinationPath + }, + trigger: CommandTrigger.Manual); } - [NonAction] - public override ActionResult GetResourceByIdWithErrorHandler(int id) - { - return base.GetResourceByIdWithErrorHandler(id); - } + var model = seriesResource.ToModel(series); - [RestGetById] - [Produces("application/json")] - public ActionResult GetResourceByIdWithErrorHandler(int id, [FromQuery]bool includeSeasonImages = false) - { - try - { - var series = GetSeriesResourceById(id, includeSeasonImages); + _seriesService.UpdateSeries(model); - return series == null ? NotFound() : series; - } - catch (ModelNotFoundException) + BroadcastResourceChange(ModelAction.Updated, seriesResource); + + return Accepted(seriesResource.Id); + } + + [HttpPut("{id}/season")] + [Consumes("application/json")] + [Produces("application/json")] + public ActionResult UpdateSeasonMonitored([FromRoute] int id, [FromBody] SeasonResource seasonResource) + { + lock (_seriesLockPool.GetLock(id)) + { + var series = _seriesService.GetSeries(id); + var season = series.Seasons.FirstOrDefault(s => s.SeasonNumber == seasonResource.SeasonNumber); + + if (season == null) { return NotFound(); } + + season.Monitored = seasonResource.Monitored; + + _seriesService.UpdateSeries(series); + + BroadcastResourceChange(ModelAction.Updated, series.ToResource()); + + return season.ToResource(); + } + } + + [RestDeleteById] + public ActionResult DeleteSeries(int id, bool deleteFiles = false, bool addImportListExclusion = false) + { + _seriesService.DeleteSeries(new List { id }, deleteFiles, addImportListExclusion); + + return NoContent(); + } + + private SeriesResource? GetSeriesResource(NzbDrone.Core.Tv.Series? series, bool includeSeasonImages) + { + if (series == null) + { + return null; } - protected override SeriesResource? GetResourceById(int id) - { - var includeSeasonImages = Request?.GetBooleanQueryParameter("includeSeasonImages", false) ?? false; + var resource = series.ToResource(includeSeasonImages); + MapCoversToLocal(resource); + FetchAndLinkSeriesStatistics(resource); + PopulateAlternateTitles(resource); + LinkRootFolderPath(resource); - // Parse IncludeImages and use it - return GetSeriesResourceById(id, includeSeasonImages); + return resource; + } + + private void MapCoversToLocal(params SeriesResource[] series) + { + foreach (var seriesResource in series) + { + _coverMapper.ConvertToLocalUrls(seriesResource.Id, seriesResource.Images); } + } - private SeriesResource? GetSeriesResourceById(int id, bool includeSeasonImages = false) + private void FetchAndLinkSeriesStatistics(SeriesResource resource) + { + LinkSeriesStatistics(resource, _seriesStatisticsService.SeriesStatistics(resource.Id)); + } + + private void LinkSeriesStatistics(List resources, Dictionary seriesStatistics) + { + foreach (var series in resources) { - var series = _seriesService.GetSeries(id); - - // Parse IncludeImages and use it - return GetSeriesResource(series, includeSeasonImages); - } - - [RestPostById] - [Consumes("application/json")] - [Produces("application/json")] - public ActionResult AddSeries([FromBody] SeriesResource seriesResource) - { - var series = _addSeriesService.AddSeries(seriesResource.ToModel()); - - return Created(series.Id); - } - - [RestPutById] - [Consumes("application/json")] - [Produces("application/json")] - public ActionResult UpdateSeries([FromBody] SeriesResource seriesResource, [FromQuery] bool moveFiles = false) - { - var series = _seriesService.GetSeries(seriesResource.Id); - - if (moveFiles) + if (seriesStatistics.TryGetValue(series.Id, out var stats)) { - var sourcePath = series.Path; - var destinationPath = seriesResource.Path; - - _commandQueueManager.Push(new MoveSeriesCommand - { - SeriesId = series.Id, - SourcePath = sourcePath, - DestinationPath = destinationPath - }, - trigger: CommandTrigger.Manual); - } - - var model = seriesResource.ToModel(series); - - _seriesService.UpdateSeries(model); - - BroadcastResourceChange(ModelAction.Updated, seriesResource); - - return Accepted(seriesResource.Id); - } - - [RestDeleteById] - public void DeleteSeries(int id, bool deleteFiles = false, bool addImportListExclusion = false) - { - _seriesService.DeleteSeries(new List { id }, deleteFiles, addImportListExclusion); - } - - private SeriesResource? GetSeriesResource(NzbDrone.Core.Tv.Series? series, bool includeSeasonImages) - { - if (series == null) - { - return null; - } - - var resource = series.ToResource(includeSeasonImages); - MapCoversToLocal(resource); - FetchAndLinkSeriesStatistics(resource); - PopulateAlternateTitles(resource); - LinkRootFolderPath(resource); - - return resource; - } - - private void MapCoversToLocal(params SeriesResource[] series) - { - foreach (var seriesResource in series) - { - _coverMapper.ConvertToLocalUrls(seriesResource.Id, seriesResource.Images); - } - } - - private void FetchAndLinkSeriesStatistics(SeriesResource resource) - { - LinkSeriesStatistics(resource, _seriesStatisticsService.SeriesStatistics(resource.Id)); - } - - private void LinkSeriesStatistics(List resources, Dictionary seriesStatistics) - { - foreach (var series in resources) - { - if (seriesStatistics.TryGetValue(series.Id, out var stats)) - { - LinkSeriesStatistics(series, stats); - } - } - } - - private void LinkSeriesStatistics(SeriesResource resource, SeriesStatistics seriesStatistics) - { - // Only set last aired from statistics if it's missing from the series itself - resource.LastAired ??= seriesStatistics.LastAired; - - resource.PreviousAiring = seriesStatistics.PreviousAiring; - resource.NextAiring = seriesStatistics.NextAiring; - resource.Statistics = seriesStatistics.ToResource(resource.Seasons); - - if (seriesStatistics.SeasonStatistics != null) - { - foreach (var season in resource.Seasons) - { - season.Statistics = seriesStatistics.SeasonStatistics?.SingleOrDefault(s => s.SeasonNumber == season.SeasonNumber)?.ToResource(); - } - } - } - - private void PopulateAlternateTitles(List resources) - { - foreach (var resource in resources) - { - PopulateAlternateTitles(resource); - } - } - - private void PopulateAlternateTitles(SeriesResource resource) - { - var mappings = _sceneMappingService.FindByTvdbId(resource.TvdbId); - - if (mappings == null) - { - return; - } - - resource.AlternateTitles = mappings.ConvertAll(AlternateTitleResourceMapper.ToResource); - } - - private void LinkRootFolderPath(SeriesResource resource) - { - resource.RootFolderPath = _rootFolderService.GetBestRootFolderPath(resource.Path); - } - - [NonAction] - public void Handle(EpisodeImportedEvent message) - { - BroadcastResourceChange(ModelAction.Updated, message.ImportedEpisode.SeriesId); - } - - [NonAction] - public void Handle(EpisodeFileDeletedEvent message) - { - if (message.Reason == DeleteMediaFileReason.Upgrade) - { - return; - } - - BroadcastResourceChange(ModelAction.Updated, message.EpisodeFile.SeriesId); - } - - [NonAction] - public void Handle(SeriesUpdatedEvent message) - { - BroadcastResourceChange(ModelAction.Updated, message.Series.Id); - } - - [NonAction] - public void Handle(SeriesEditedEvent message) - { - var resource = GetSeriesResource(message.Series, false); - - if (resource == null) - { - return; - } - - resource.EpisodesChanged = message.EpisodesChanged; - BroadcastResourceChange(ModelAction.Updated, resource); - } - - [NonAction] - public void Handle(SeriesDeletedEvent message) - { - foreach (var series in message.Series) - { - var resource = GetSeriesResource(series, false); - - if (resource == null) - { - continue; - } - - BroadcastResourceChange(ModelAction.Deleted, resource); - } - } - - [NonAction] - public void Handle(SeriesRenamedEvent message) - { - BroadcastResourceChange(ModelAction.Updated, message.Series.Id); - } - - [NonAction] - public void Handle(SeriesBulkEditedEvent message) - { - foreach (var series in message.Series) - { - var resource = GetSeriesResource(series, false); - - if (resource == null) - { - continue; - } - - BroadcastResourceChange(ModelAction.Updated, resource); - } - } - - [NonAction] - public void Handle(MediaCoversUpdatedEvent message) - { - if (message.Updated) - { - BroadcastResourceChange(ModelAction.Updated, message.Series.Id); + LinkSeriesStatistics(series, stats); } } } + + private void LinkSeriesStatistics(SeriesResource resource, SeriesStatistics seriesStatistics) + { + // Only set last aired from statistics if it's missing from the series itself + resource.LastAired ??= seriesStatistics.LastAired; + + resource.PreviousAiring = seriesStatistics.PreviousAiring; + resource.NextAiring = seriesStatistics.NextAiring; + resource.Statistics = seriesStatistics.ToResource(resource.Seasons); + + if (seriesStatistics.SeasonStatistics != null) + { + foreach (var season in resource.Seasons) + { + season.Statistics = seriesStatistics.SeasonStatistics?.SingleOrDefault(s => s.SeasonNumber == season.SeasonNumber)?.ToResource(); + } + } + } + + private void PopulateAlternateTitles(List resources) + { + foreach (var resource in resources) + { + PopulateAlternateTitles(resource); + } + } + + private void PopulateAlternateTitles(SeriesResource resource) + { + var mappings = _sceneMappingService.FindByTvdbId(resource.TvdbId); + + if (mappings == null) + { + return; + } + + resource.AlternateTitles = mappings.ConvertAll(AlternateTitleResourceMapper.ToResource); + } + + private void LinkRootFolderPath(SeriesResource resource) + { + resource.RootFolderPath = _rootFolderService.GetBestRootFolderPath(resource.Path); + } + + [NonAction] + public void Handle(EpisodeImportedEvent message) + { + BroadcastResourceChange(ModelAction.Updated, message.ImportedEpisode.SeriesId); + } + + [NonAction] + public void Handle(EpisodeFileDeletedEvent message) + { + if (message.Reason == DeleteMediaFileReason.Upgrade) + { + return; + } + + BroadcastResourceChange(ModelAction.Updated, message.EpisodeFile.SeriesId); + } + + [NonAction] + public void Handle(SeriesUpdatedEvent message) + { + BroadcastResourceChange(ModelAction.Updated, message.Series.Id); + } + + [NonAction] + public void Handle(SeriesEditedEvent message) + { + var resource = GetSeriesResource(message.Series, false); + + if (resource == null) + { + return; + } + + resource.EpisodesChanged = message.EpisodesChanged; + BroadcastResourceChange(ModelAction.Updated, resource); + } + + [NonAction] + public void Handle(SeriesDeletedEvent message) + { + foreach (var series in message.Series) + { + var resource = GetSeriesResource(series, false); + + if (resource == null) + { + continue; + } + + BroadcastResourceChange(ModelAction.Deleted, resource); + } + } + + [NonAction] + public void Handle(SeriesRenamedEvent message) + { + BroadcastResourceChange(ModelAction.Updated, message.Series.Id); + } + + [NonAction] + public void Handle(SeriesBulkEditedEvent message) + { + foreach (var series in message.Series) + { + var resource = GetSeriesResource(series, false); + + if (resource == null) + { + continue; + } + + BroadcastResourceChange(ModelAction.Updated, resource); + } + } + + [NonAction] + public void Handle(MediaCoversUpdatedEvent message) + { + if (message.Updated) + { + BroadcastResourceChange(ModelAction.Updated, message.Series.Id); + } + } } diff --git a/src/Sonarr.Api.V5/Series/SeriesEditorController.cs b/src/Sonarr.Api.V5/Series/SeriesEditorController.cs new file mode 100644 index 000000000..9ce021a3c --- /dev/null +++ b/src/Sonarr.Api.V5/Series/SeriesEditorController.cs @@ -0,0 +1,114 @@ +using FluentValidation; +using Microsoft.AspNetCore.Mvc; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Messaging.Commands; +using NzbDrone.Core.Tv; +using NzbDrone.Core.Tv.Commands; +using Sonarr.Http; + +namespace Sonarr.Api.V5.Series; + +[V5ApiController("series/editor")] +public class SeriesEditorController : Controller +{ + private readonly ISeriesService _seriesService; + private readonly IManageCommandQueue _commandQueueManager; + private readonly SeriesEditorValidator _seriesEditorValidator; + + public SeriesEditorController(ISeriesService seriesService, IManageCommandQueue commandQueueManager, SeriesEditorValidator seriesEditorValidator) + { + _seriesService = seriesService; + _commandQueueManager = commandQueueManager; + _seriesEditorValidator = seriesEditorValidator; + } + + [HttpPut] + public object SaveAll([FromBody] SeriesEditorResource resource) + { + var seriesToUpdate = _seriesService.GetSeries(resource.SeriesIds); + var seriesToMove = new List(); + + foreach (var series in seriesToUpdate) + { + if (resource.Monitored.HasValue) + { + series.Monitored = resource.Monitored.Value; + } + + if (resource.MonitorNewItems.HasValue) + { + series.MonitorNewItems = resource.MonitorNewItems.Value; + } + + if (resource.QualityProfileId.HasValue) + { + series.QualityProfileId = resource.QualityProfileId.Value; + } + + if (resource.SeriesType.HasValue) + { + series.SeriesType = resource.SeriesType.Value; + } + + if (resource.SeasonFolder.HasValue) + { + series.SeasonFolder = resource.SeasonFolder.Value; + } + + if (resource.RootFolderPath.IsNotNullOrWhiteSpace()) + { + series.RootFolderPath = resource.RootFolderPath; + seriesToMove.Add(new BulkMoveSeries + { + SeriesId = series.Id, + SourcePath = series.Path + }); + } + + if (resource.Tags != null) + { + var newTags = resource.Tags; + var applyTags = resource.ApplyTags; + + switch (applyTags) + { + case ApplyTags.Add: + newTags.ForEach(t => series.Tags.Add(t)); + break; + case ApplyTags.Remove: + newTags.ForEach(t => series.Tags.Remove(t)); + break; + case ApplyTags.Replace: + series.Tags = new HashSet(newTags); + break; + } + } + + var validationResult = _seriesEditorValidator.Validate(series); + + if (!validationResult.IsValid) + { + throw new ValidationException(validationResult.Errors); + } + } + + if (resource.MoveFiles && seriesToMove.Any()) + { + _commandQueueManager.Push(new BulkMoveSeriesCommand + { + DestinationRootFolder = resource.RootFolderPath, + Series = seriesToMove + }); + } + + return Accepted(_seriesService.UpdateSeries(seriesToUpdate, !resource.MoveFiles).ToResource()); + } + + [HttpDelete] + public object DeleteSeries([FromBody] SeriesEditorResource resource) + { + _seriesService.DeleteSeries(resource.SeriesIds, resource.DeleteFiles, resource.AddImportListExclusion); + + return new { }; + } +} diff --git a/src/Sonarr.Api.V5/Series/SeriesEditorDeleteResource.cs b/src/Sonarr.Api.V5/Series/SeriesEditorDeleteResource.cs new file mode 100644 index 000000000..cfd2ba986 --- /dev/null +++ b/src/Sonarr.Api.V5/Series/SeriesEditorDeleteResource.cs @@ -0,0 +1,7 @@ +namespace Sonarr.Api.V5.Series; + +public class SeriesEditorDeleteResource +{ + public List SeriesIds { get; set; } = []; + public bool DeleteFiles { get; set; } +} diff --git a/src/Sonarr.Api.V5/Series/SeriesEditorResource.cs b/src/Sonarr.Api.V5/Series/SeriesEditorResource.cs new file mode 100644 index 000000000..79258221e --- /dev/null +++ b/src/Sonarr.Api.V5/Series/SeriesEditorResource.cs @@ -0,0 +1,19 @@ +using NzbDrone.Core.Tv; + +namespace Sonarr.Api.V5.Series; + +public class SeriesEditorResource +{ + public List SeriesIds { get; set; } = []; + public bool? Monitored { get; set; } + public NewItemMonitorTypes? MonitorNewItems { get; set; } + public int? QualityProfileId { get; set; } + public SeriesTypes? SeriesType { get; set; } + public bool? SeasonFolder { get; set; } + public string? RootFolderPath { get; set; } + public List Tags { get; set; } = []; + public ApplyTags ApplyTags { get; set; } + public bool MoveFiles { get; set; } + public bool DeleteFiles { get; set; } + public bool AddImportListExclusion { get; set; } +} diff --git a/src/Sonarr.Api.V5/Series/SeriesEditorValidator.cs b/src/Sonarr.Api.V5/Series/SeriesEditorValidator.cs new file mode 100644 index 000000000..d0c44a73c --- /dev/null +++ b/src/Sonarr.Api.V5/Series/SeriesEditorValidator.cs @@ -0,0 +1,21 @@ +using FluentValidation; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Validation; +using NzbDrone.Core.Validation.Paths; + +namespace Sonarr.Api.V5.Series; + +public class SeriesEditorValidator : AbstractValidator +{ + public SeriesEditorValidator(RootFolderExistsValidator rootFolderExistsValidator, QualityProfileExistsValidator qualityProfileExistsValidator) + { + RuleFor(s => s.RootFolderPath).Cascade(CascadeMode.Stop) + .IsValidPath() + .SetValidator(rootFolderExistsValidator) + .When(s => s.RootFolderPath.IsNotNullOrWhiteSpace()); + + RuleFor(c => c.QualityProfileId).Cascade(CascadeMode.Stop) + .ValidId() + .SetValidator(qualityProfileExistsValidator); + } +} diff --git a/src/Sonarr.Api.V5/Series/SeriesFolderAsRootFolderValidator.cs b/src/Sonarr.Api.V5/Series/SeriesFolderAsRootFolderValidator.cs index 44eb96a52..4167cf522 100644 --- a/src/Sonarr.Api.V5/Series/SeriesFolderAsRootFolderValidator.cs +++ b/src/Sonarr.Api.V5/Series/SeriesFolderAsRootFolderValidator.cs @@ -2,53 +2,52 @@ using NzbDrone.Common.Extensions; using NzbDrone.Core.Organizer; -namespace Sonarr.Api.V5.Series +namespace Sonarr.Api.V5.Series; + +public class SeriesFolderAsRootFolderValidator : PropertyValidator { - public class SeriesFolderAsRootFolderValidator : PropertyValidator + private readonly IBuildFileNames _fileNameBuilder; + + public SeriesFolderAsRootFolderValidator(IBuildFileNames fileNameBuilder) { - private readonly IBuildFileNames _fileNameBuilder; + _fileNameBuilder = fileNameBuilder; + } - public SeriesFolderAsRootFolderValidator(IBuildFileNames fileNameBuilder) + protected override string GetDefaultMessageTemplate() => "Root folder path '{rootFolderPath}' contains series folder '{seriesFolder}'"; + + protected override bool IsValid(PropertyValidatorContext context) + { + if (context.PropertyValue == null) { - _fileNameBuilder = fileNameBuilder; + return true; } - protected override string GetDefaultMessageTemplate() => "Root folder path '{rootFolderPath}' contains series folder '{seriesFolder}'"; - - protected override bool IsValid(PropertyValidatorContext context) + if (context.InstanceToValidate is not SeriesResource seriesResource) { - if (context.PropertyValue == null) - { - return true; - } - - if (context.InstanceToValidate is not SeriesResource seriesResource) - { - return true; - } - - var rootFolderPath = context.PropertyValue.ToString(); - - if (rootFolderPath.IsNullOrWhiteSpace()) - { - return true; - } - - var rootFolder = new DirectoryInfo(rootFolderPath!).Name; - var series = seriesResource.ToModel(); - var seriesFolder = _fileNameBuilder.GetSeriesFolder(series); - - context.MessageFormatter.AppendArgument("rootFolderPath", rootFolderPath); - context.MessageFormatter.AppendArgument("seriesFolder", seriesFolder); - - if (seriesFolder == rootFolder) - { - return false; - } - - var distance = seriesFolder.LevenshteinDistance(rootFolder); - - return distance >= Math.Max(1, seriesFolder.Length * 0.2); + return true; } + + var rootFolderPath = context.PropertyValue.ToString(); + + if (rootFolderPath.IsNullOrWhiteSpace()) + { + return true; + } + + var rootFolder = new DirectoryInfo(rootFolderPath!).Name; + var series = seriesResource.ToModel(); + var seriesFolder = _fileNameBuilder.GetSeriesFolder(series); + + context.MessageFormatter.AppendArgument("rootFolderPath", rootFolderPath); + context.MessageFormatter.AppendArgument("seriesFolder", seriesFolder); + + if (seriesFolder == rootFolder) + { + return false; + } + + var distance = seriesFolder.LevenshteinDistance(rootFolder); + + return distance >= Math.Max(1, seriesFolder.Length * 0.2); } }