Add v5 season pass and series editor endpoints

This commit is contained in:
Mark McDowall 2025-11-28 21:57:05 -08:00
parent 1df3b116c1
commit 49db4a1d76
11 changed files with 686 additions and 370 deletions

View file

@ -0,0 +1,22 @@
using System.Collections.Generic;
namespace NzbDrone.Common.TPL;
public class LockByIdPool
{
private readonly Dictionary<int, object> _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];
}
}
}

View file

@ -0,0 +1,8 @@
namespace Sonarr.Api.V5;
public enum ApplyTags
{
Add,
Remove,
Replace
}

View file

@ -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();
}
}

View file

@ -0,0 +1,30 @@
using NzbDrone.Core.Tv;
namespace Sonarr.Api.V5.SeasonPass;
public class SeasonPassResource
{
public List<SeasonPassSeriesResource> 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
};
}
}

View file

@ -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<SeasonResource> Seasons { get; set; } = [];
}

View file

@ -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<SeriesResource, NzbDrone.Core.Tv.Series>,
IHandle<EpisodeImportedEvent>,
IHandle<EpisodeFileDeletedEvent>,
IHandle<SeriesUpdatedEvent>,
IHandle<SeriesEditedEvent>,
IHandle<SeriesDeletedEvent>,
IHandle<SeriesRenamedEvent>,
IHandle<SeriesBulkEditedEvent>,
IHandle<MediaCoversUpdatedEvent>
{
[V5ApiController]
public class SeriesController : RestControllerWithSignalR<SeriesResource, NzbDrone.Core.Tv.Series>,
IHandle<EpisodeImportedEvent>,
IHandle<EpisodeFileDeletedEvent>,
IHandle<SeriesUpdatedEvent>,
IHandle<SeriesEditedEvent>,
IHandle<SeriesDeletedEvent>,
IHandle<SeriesRenamedEvent>,
IHandle<SeriesBulkEditedEvent>,
IHandle<MediaCoversUpdatedEvent>
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<SeriesResource> AllSeries(int? tvdbId, bool includeSeasonImages = false)
{
var seriesStats = _seriesStatisticsService.SeriesStatistics();
var seriesResources = new List<SeriesResource>();
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<SeriesResource> 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<SeriesResource> GetResourceByIdWithErrorHandler(int id)
{
return base.GetResourceByIdWithErrorHandler(id);
}
[RestGetById]
[Produces("application/json")]
public ActionResult<SeriesResource> GetResourceByIdWithErrorHandler(int id, [FromQuery]bool includeSeasonImages = false)
{
try
{
var seriesStats = _seriesStatisticsService.SeriesStatistics();
var seriesResources = new List<SeriesResource>();
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<SeriesResource> AddSeries([FromBody] SeriesResource seriesResource)
{
var series = _addSeriesService.AddSeries(seriesResource.ToModel());
return Created(series.Id);
}
[RestPutById]
[Consumes("application/json")]
[Produces("application/json")]
public ActionResult<SeriesResource> 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<SeriesResource> GetResourceByIdWithErrorHandler(int id)
{
return base.GetResourceByIdWithErrorHandler(id);
}
var model = seriesResource.ToModel(series);
[RestGetById]
[Produces("application/json")]
public ActionResult<SeriesResource> 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<SeasonResource> 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<int> { 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<SeriesResource> resources, Dictionary<int, SeriesStatistics> 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<SeriesResource> AddSeries([FromBody] SeriesResource seriesResource)
{
var series = _addSeriesService.AddSeries(seriesResource.ToModel());
return Created(series.Id);
}
[RestPutById]
[Consumes("application/json")]
[Produces("application/json")]
public ActionResult<SeriesResource> 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<int> { 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<SeriesResource> resources, Dictionary<int, SeriesStatistics> 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<SeriesResource> 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<SeriesResource> 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);
}
}
}

View file

@ -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<BulkMoveSeries>();
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<int>(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 { };
}
}

View file

@ -0,0 +1,7 @@
namespace Sonarr.Api.V5.Series;
public class SeriesEditorDeleteResource
{
public List<int> SeriesIds { get; set; } = [];
public bool DeleteFiles { get; set; }
}

View file

@ -0,0 +1,19 @@
using NzbDrone.Core.Tv;
namespace Sonarr.Api.V5.Series;
public class SeriesEditorResource
{
public List<int> 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<int> Tags { get; set; } = [];
public ApplyTags ApplyTags { get; set; }
public bool MoveFiles { get; set; }
public bool DeleteFiles { get; set; }
public bool AddImportListExclusion { get; set; }
}

View file

@ -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<NzbDrone.Core.Tv.Series>
{
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);
}
}

View file

@ -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);
}
}