mirror of
https://github.com/Sonarr/Sonarr
synced 2026-01-25 08:52:19 +01:00
Add v5 season pass and series editor endpoints
This commit is contained in:
parent
1df3b116c1
commit
49db4a1d76
11 changed files with 686 additions and 370 deletions
22
src/NzbDrone.Common/TPL/LockByIdPool.cs
Normal file
22
src/NzbDrone.Common/TPL/LockByIdPool.cs
Normal 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];
|
||||
}
|
||||
}
|
||||
}
|
||||
8
src/Sonarr.Api.V5/ApplyTags.cs
Normal file
8
src/Sonarr.Api.V5/ApplyTags.cs
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
namespace Sonarr.Api.V5;
|
||||
|
||||
public enum ApplyTags
|
||||
{
|
||||
Add,
|
||||
Remove,
|
||||
Replace
|
||||
}
|
||||
57
src/Sonarr.Api.V5/SeasonPass/SeasonPassController.cs
Normal file
57
src/Sonarr.Api.V5/SeasonPass/SeasonPassController.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
30
src/Sonarr.Api.V5/SeasonPass/SeasonPassResource.cs
Normal file
30
src/Sonarr.Api.V5/SeasonPass/SeasonPassResource.cs
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
10
src/Sonarr.Api.V5/SeasonPass/SeasonPassSeriesResource.cs
Normal file
10
src/Sonarr.Api.V5/SeasonPass/SeasonPassSeriesResource.cs
Normal 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; } = [];
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
114
src/Sonarr.Api.V5/Series/SeriesEditorController.cs
Normal file
114
src/Sonarr.Api.V5/Series/SeriesEditorController.cs
Normal 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 { };
|
||||
}
|
||||
}
|
||||
7
src/Sonarr.Api.V5/Series/SeriesEditorDeleteResource.cs
Normal file
7
src/Sonarr.Api.V5/Series/SeriesEditorDeleteResource.cs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
namespace Sonarr.Api.V5.Series;
|
||||
|
||||
public class SeriesEditorDeleteResource
|
||||
{
|
||||
public List<int> SeriesIds { get; set; } = [];
|
||||
public bool DeleteFiles { get; set; }
|
||||
}
|
||||
19
src/Sonarr.Api.V5/Series/SeriesEditorResource.cs
Normal file
19
src/Sonarr.Api.V5/Series/SeriesEditorResource.cs
Normal 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; }
|
||||
}
|
||||
21
src/Sonarr.Api.V5/Series/SeriesEditorValidator.cs
Normal file
21
src/Sonarr.Api.V5/Series/SeriesEditorValidator.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue