refactor: extract BaseMediaCrudController for Book/Audiobook (#139)

- Create BaseMediaCrudController with common CRUD patterns
- Extract shared validation setup (path, quality, title)
- Move Create, Update, Delete endpoints to base class
- BookController: 219 -> 168 lines (-51)
- AudiobookController: 227 -> 177 lines (-50)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: admin <admin@ardentleatherworks.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Cody Kickertz 2025-12-22 18:37:15 -06:00 committed by GitHub
parent 209fd28062
commit 2b42b33d3a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 181 additions and 165 deletions

View file

@ -1,26 +1,25 @@
using System.Collections.Generic;
using System.Linq;
using FluentValidation;
using Microsoft.AspNetCore.Mvc;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Audiobooks;
using NzbDrone.Core.Audiobooks.Events;
using NzbDrone.Core.AudiobookStats;
using NzbDrone.Core.Datastore.Events;
using NzbDrone.Core.MediaItems;
using NzbDrone.Core.Messaging.Events;
using NzbDrone.Core.Monitoring;
using NzbDrone.Core.RootFolders;
using NzbDrone.Core.Validation;
using NzbDrone.Core.Validation.Paths;
using NzbDrone.SignalR;
using Radarr.Api.V3.MediaItems;
using Radarr.Http;
using Radarr.Http.REST;
using Radarr.Http.REST.Attributes;
namespace Radarr.Api.V3.Audiobooks
{
[V3ApiController]
public class AudiobookController : RestControllerWithSignalR<AudiobookResource, Audiobook>,
public class AudiobookController : BaseMediaCrudController<AudiobookResource, Audiobook>,
IHandle<AudiobookAddedEvent>,
IHandle<AudiobookEditedEvent>,
IHandle<AudiobooksDeletedEvent>,
@ -31,6 +30,9 @@ public class AudiobookController : RestControllerWithSignalR<AudiobookResource,
private readonly IHierarchicalMonitoringService _monitoringService;
private readonly IAudiobookStatisticsService _audiobookStatisticsService;
protected override IBaseMediaService<Audiobook> MediaService => _audiobookService;
protected override IRootFolderService RootFolderService => _rootFolderService;
public AudiobookController(IBroadcastSignalRMessage signalRBroadcaster,
IAudiobookService audiobookService,
IRootFolderService rootFolderService,
@ -49,35 +51,34 @@ public AudiobookController(IBroadcastSignalRMessage signalRBroadcaster,
_monitoringService = monitoringService;
_audiobookStatisticsService = audiobookStatisticsService;
SharedValidator.RuleFor(s => s.Path).Cascade(CascadeMode.Stop)
.IsValidPath()
.SetValidator(rootFolderValidator)
.SetValidator(mappedNetworkDriveValidator)
.SetValidator(recycleBinValidator)
.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)
.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();
SetupPathValidation(rootFolderValidator, mappedNetworkDriveValidator, recycleBinValidator, systemFolderValidator, rootFolderExistsValidator);
SetupQualityValidation(qualityProfileExistsValidator);
SetupTitleValidation();
}
protected override string GetPath(AudiobookResource resource) => resource.Path;
protected override string GetRootFolderPath(AudiobookResource resource) => resource.RootFolderPath;
protected override int GetQualityProfileId(AudiobookResource resource) => resource.QualityProfileId;
protected override string GetTitle(AudiobookResource resource) => resource.Title;
protected override AudiobookResource MapToResource(Audiobook audiobook)
{
if (audiobook == null)
{
return null;
}
var resource = audiobook.ToResource();
resource.RootFolderPath = _rootFolderService.GetBestRootFolderPath(resource.Path);
resource.EffectivelyMonitored = _monitoringService.IsEffectivelyMonitored(audiobook);
FetchAndLinkAudiobookStatistics(resource);
return resource;
}
protected override Audiobook ResourceToModel(AudiobookResource resource) => resource.ToModel();
protected override Audiobook ApplyResourceToModel(AudiobookResource resource, Audiobook audiobook) => resource.ToModel(audiobook);
[HttpGet]
public List<AudiobookResource> GetAudiobooks(int? authorId = null, int? seriesId = null, int? bookId = null, string narrator = null)
{
@ -120,27 +121,6 @@ public List<AudiobookResource> GetAudiobooks(int? authorId = null, int? seriesId
return resources;
}
protected override AudiobookResource GetResourceById(int id)
{
var audiobook = _audiobookService.GetAudiobook(id);
return MapToResource(audiobook);
}
private AudiobookResource MapToResource(Audiobook audiobook)
{
if (audiobook == null)
{
return null;
}
var resource = audiobook.ToResource();
resource.RootFolderPath = _rootFolderService.GetBestRootFolderPath(resource.Path);
resource.EffectivelyMonitored = _monitoringService.IsEffectivelyMonitored(audiobook);
FetchAndLinkAudiobookStatistics(resource);
return resource;
}
private void FetchAndLinkAudiobookStatistics(AudiobookResource resource)
{
LinkAudiobookStatistics(resource, _audiobookStatisticsService.AudiobookStatistics(resource.Id));
@ -164,36 +144,6 @@ private static void LinkAudiobookStatistics(AudiobookResource resource, Audioboo
resource.SizeOnDisk = audiobookStatistics.SizeOnDisk;
}
[RestPostById]
[Consumes("application/json")]
[Produces("application/json")]
public ActionResult<AudiobookResource> AddAudiobook([FromBody] AudiobookResource audiobookResource)
{
var audiobook = _audiobookService.AddAudiobook(audiobookResource.ToModel());
return Created(audiobook.Id);
}
[RestPutById]
[Consumes("application/json")]
[Produces("application/json")]
public ActionResult<AudiobookResource> UpdateAudiobook([FromBody] AudiobookResource audiobookResource)
{
var audiobook = _audiobookService.GetAudiobook(audiobookResource.Id);
var updatedAudiobook = _audiobookService.UpdateAudiobook(audiobookResource.ToModel(audiobook));
var resource = MapToResource(updatedAudiobook);
BroadcastResourceChange(ModelAction.Updated, resource);
return Ok(resource);
}
[RestDeleteById]
public ActionResult DeleteAudiobook(int id, bool deleteFiles = false)
{
_audiobookService.DeleteAudiobook(id, deleteFiles);
return NoContent();
}
[NonAction]
public void Handle(AudiobookAddedEvent message)
{

View file

@ -1,26 +1,24 @@
using System.Collections.Generic;
using System.Linq;
using FluentValidation;
using Microsoft.AspNetCore.Mvc;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Books;
using NzbDrone.Core.Books.Events;
using NzbDrone.Core.BookStats;
using NzbDrone.Core.Datastore.Events;
using NzbDrone.Core.MediaItems;
using NzbDrone.Core.Messaging.Events;
using NzbDrone.Core.Monitoring;
using NzbDrone.Core.RootFolders;
using NzbDrone.Core.Validation;
using NzbDrone.Core.Validation.Paths;
using NzbDrone.SignalR;
using Radarr.Api.V3.MediaItems;
using Radarr.Http;
using Radarr.Http.REST;
using Radarr.Http.REST.Attributes;
namespace Radarr.Api.V3.Books
{
[V3ApiController]
public class BookController : RestControllerWithSignalR<BookResource, Book>,
public class BookController : BaseMediaCrudController<BookResource, Book>,
IHandle<BookAddedEvent>,
IHandle<BookEditedEvent>,
IHandle<BooksDeletedEvent>,
@ -31,6 +29,9 @@ public class BookController : RestControllerWithSignalR<BookResource, Book>,
private readonly IHierarchicalMonitoringService _monitoringService;
private readonly IBookStatisticsService _bookStatisticsService;
protected override IBaseMediaService<Book> MediaService => _bookService;
protected override IRootFolderService RootFolderService => _rootFolderService;
public BookController(IBroadcastSignalRMessage signalRBroadcaster,
IBookService bookService,
IRootFolderService rootFolderService,
@ -49,35 +50,34 @@ public BookController(IBroadcastSignalRMessage signalRBroadcaster,
_monitoringService = monitoringService;
_bookStatisticsService = bookStatisticsService;
SharedValidator.RuleFor(s => s.Path).Cascade(CascadeMode.Stop)
.IsValidPath()
.SetValidator(rootFolderValidator)
.SetValidator(mappedNetworkDriveValidator)
.SetValidator(recycleBinValidator)
.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)
.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();
SetupPathValidation(rootFolderValidator, mappedNetworkDriveValidator, recycleBinValidator, systemFolderValidator, rootFolderExistsValidator);
SetupQualityValidation(qualityProfileExistsValidator);
SetupTitleValidation();
}
protected override string GetPath(BookResource resource) => resource.Path;
protected override string GetRootFolderPath(BookResource resource) => resource.RootFolderPath;
protected override int GetQualityProfileId(BookResource resource) => resource.QualityProfileId;
protected override string GetTitle(BookResource resource) => resource.Title;
protected override BookResource MapToResource(Book book)
{
if (book == null)
{
return null;
}
var resource = book.ToResource();
resource.RootFolderPath = _rootFolderService.GetBestRootFolderPath(resource.Path);
resource.EffectivelyMonitored = _monitoringService.IsEffectivelyMonitored(book);
FetchAndLinkBookStatistics(resource);
return resource;
}
protected override Book ResourceToModel(BookResource resource) => resource.ToModel();
protected override Book ApplyResourceToModel(BookResource resource, Book book) => resource.ToModel(book);
[HttpGet]
public List<BookResource> GetBooks(int? authorId = null, int? seriesId = null)
{
@ -112,27 +112,6 @@ public List<BookResource> GetBooks(int? authorId = null, int? seriesId = null)
return resources;
}
protected override BookResource GetResourceById(int id)
{
var book = _bookService.GetBook(id);
return MapToResource(book);
}
private BookResource MapToResource(Book book)
{
if (book == null)
{
return null;
}
var resource = book.ToResource();
resource.RootFolderPath = _rootFolderService.GetBestRootFolderPath(resource.Path);
resource.EffectivelyMonitored = _monitoringService.IsEffectivelyMonitored(book);
FetchAndLinkBookStatistics(resource);
return resource;
}
private void FetchAndLinkBookStatistics(BookResource resource)
{
LinkBookStatistics(resource, _bookStatisticsService.BookStatistics(resource.Id));
@ -156,36 +135,6 @@ private static void LinkBookStatistics(BookResource resource, BookStatistics boo
resource.SizeOnDisk = bookStatistics.SizeOnDisk;
}
[RestPostById]
[Consumes("application/json")]
[Produces("application/json")]
public ActionResult<BookResource> AddBook([FromBody] BookResource bookResource)
{
var book = _bookService.AddBook(bookResource.ToModel());
return Created(book.Id);
}
[RestPutById]
[Consumes("application/json")]
[Produces("application/json")]
public ActionResult<BookResource> UpdateBook([FromBody] BookResource bookResource)
{
var book = _bookService.GetBook(bookResource.Id);
var updatedBook = _bookService.UpdateBook(bookResource.ToModel(book));
var resource = MapToResource(updatedBook);
BroadcastResourceChange(ModelAction.Updated, resource);
return Ok(resource);
}
[RestDeleteById]
public ActionResult DeleteBook(int id, bool deleteFiles = false)
{
_bookService.DeleteBook(id, deleteFiles);
return NoContent();
}
[NonAction]
public void Handle(BookAddedEvent message)
{

View file

@ -0,0 +1,117 @@
using FluentValidation;
using Microsoft.AspNetCore.Mvc;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Datastore;
using NzbDrone.Core.Datastore.Events;
using NzbDrone.Core.MediaItems;
using NzbDrone.Core.RootFolders;
using NzbDrone.Core.Validation;
using NzbDrone.Core.Validation.Paths;
using NzbDrone.SignalR;
using Radarr.Http.REST;
using Radarr.Http.REST.Attributes;
namespace Radarr.Api.V3.MediaItems
{
public abstract class BaseMediaCrudController<TResource, TModel> : RestControllerWithSignalR<TResource, TModel>
where TResource : RestResource, new()
where TModel : ModelBase, new()
{
protected abstract IBaseMediaService<TModel> MediaService { get; }
protected abstract IRootFolderService RootFolderService { get; }
protected BaseMediaCrudController(IBroadcastSignalRMessage signalRBroadcaster)
: base(signalRBroadcaster)
{
}
protected void SetupPathValidation(
RootFolderValidator rootFolderValidator,
MappedNetworkDriveValidator mappedNetworkDriveValidator,
RecycleBinValidator recycleBinValidator,
SystemFolderValidator systemFolderValidator,
RootFolderExistsValidator rootFolderExistsValidator)
{
SharedValidator.RuleFor(s => GetPath(s)).Cascade(CascadeMode.Stop)
.IsValidPath()
.SetValidator(rootFolderValidator)
.SetValidator(mappedNetworkDriveValidator)
.SetValidator(recycleBinValidator)
.SetValidator(systemFolderValidator)
.When(s => GetPath(s).IsNotNullOrWhiteSpace());
PostValidator.RuleFor(s => GetPath(s)).Cascade(CascadeMode.Stop)
.NotEmpty()
.IsValidPath()
.When(s => GetRootFolderPath(s).IsNullOrWhiteSpace());
PostValidator.RuleFor(s => GetRootFolderPath(s)).Cascade(CascadeMode.Stop)
.NotEmpty()
.IsValidPath()
.SetValidator(rootFolderExistsValidator)
.When(s => GetPath(s).IsNullOrWhiteSpace());
PutValidator.RuleFor(s => GetPath(s)).Cascade(CascadeMode.Stop)
.NotEmpty()
.IsValidPath();
}
protected void SetupQualityValidation(QualityProfileExistsValidator qualityProfileExistsValidator)
{
SharedValidator.RuleFor(s => GetQualityProfileId(s)).Cascade(CascadeMode.Stop)
.ValidId()
.SetValidator(qualityProfileExistsValidator);
}
protected void SetupTitleValidation()
{
PostValidator.RuleFor(s => GetTitle(s)).NotEmpty();
}
protected abstract string GetPath(TResource resource);
protected abstract string GetRootFolderPath(TResource resource);
protected abstract int GetQualityProfileId(TResource resource);
protected abstract string GetTitle(TResource resource);
protected abstract TResource MapToResource(TModel model);
protected abstract TModel ResourceToModel(TResource resource);
protected abstract TModel ApplyResourceToModel(TResource resource, TModel model);
protected override TResource GetResourceById(int id)
{
var model = MediaService.Get(id);
return MapToResource(model);
}
[RestPostById]
[Consumes("application/json")]
[Produces("application/json")]
public virtual ActionResult<TResource> CreateResource([FromBody] TResource resource)
{
var model = ResourceToModel(resource);
var added = MediaService.Add(model);
return Created(added.Id);
}
[RestPutById]
[Consumes("application/json")]
[Produces("application/json")]
public virtual ActionResult<TResource> UpdateResource([FromBody] TResource resource)
{
var existingModel = MediaService.Get(resource.Id);
var updatedModel = ApplyResourceToModel(resource, existingModel);
var result = MediaService.Update(updatedModel);
var resultResource = MapToResource(result);
BroadcastResourceChange(ModelAction.Updated, resultResource);
return Ok(resultResource);
}
[RestDeleteById]
public virtual ActionResult DeleteResource(int id, bool deleteFiles = false)
{
MediaService.Delete(id, deleteFiles);
return NoContent();
}
}
}