feat: Phase 2 Multi-Media Infrastructure - Books/Audiobooks Backend & Frontend (#125)

* feat(api): add Book and Audiobook lookup and editor controllers

Adds search and bulk edit functionality for Books and Audiobooks:
- BookLookupController: search by ISBN, ISBN13, ASIN, ForeignId, title
- AudiobookLookupController: search by ISBN, ASIN, narrator, title
- BookEditorController: bulk update/delete books
- AudiobookEditorController: bulk update/delete audiobooks
- Editor validators and resources for both entity types

* feat(core): add AddBookService and AddAudiobookService

Adds services for adding new books and audiobooks with validation:
- AddBookService: handles book creation with path generation
- AddAudiobookService: handles audiobook creation with narrator-aware paths
- AddBookValidator: validates book additions
- AddAudiobookValidator: validates audiobook additions

* feat(core): add BookFile and AudiobookFile entities and repositories

Adds file tracking infrastructure for books and audiobooks:
- BookFile entity with format tracking
- AudiobookFile entity with audio metadata (duration, bitrate, etc.)
- BookFileRepository for book file queries
- AudiobookFileRepository for audiobook file queries
- Database migration for new tables
- Table mappings for new entities

* feat(metadata): add Book and Audiobook metadata provider interfaces

Adds metadata provider infrastructure for books and audiobooks:
- IProvideBookInfo: interface for book metadata lookups
- IProvideAudiobookInfo: interface for audiobook metadata lookups
- BookMetadata: model for book metadata from external sources
- AudiobookMetadata: model for audiobook metadata with narrator info
- BookInfoProxy: stub implementation (to be replaced with Goodreads, etc.)
- AudiobookInfoProxy: stub implementation (to be replaced with Audible, etc.)

* feat(api): add Author and Series repositories, services, and API controllers

* feat(ui): add Book and Audiobook Redux store and index pages

---------

Co-authored-by: admin <admin@ardentleatherworks.com>
This commit is contained in:
Cody Kickertz 2025-12-21 21:28:14 -06:00 committed by GitHub
parent cc9d8cd4d0
commit 4b7b273683
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
42 changed files with 2416 additions and 12 deletions

View file

@ -1,5 +1,7 @@
import { Error } from './AppSectionState';
import AudiobooksAppState from './AudiobooksAppState';
import BlocklistAppState from './BlocklistAppState';
import BooksAppState from './BooksAppState';
import CalendarAppState from './CalendarAppState';
import CaptchaAppState from './CaptchaAppState';
import CommandAppState from './CommandAppState';
@ -81,7 +83,9 @@ export interface AppSectionState {
interface AppState {
app: AppSectionState;
audiobooks: AudiobooksAppState;
blocklist: BlocklistAppState;
books: BooksAppState;
calendar: CalendarAppState;
captcha: CaptchaAppState;
commands: CommandAppState;

View file

@ -0,0 +1,15 @@
import AppSectionState, {
AppSectionDeleteState,
AppSectionSaveState,
} from 'App/State/AppSectionState';
import Audiobook from 'Audiobook/Audiobook';
interface AudiobooksAppState
extends
AppSectionState<Audiobook>,
AppSectionDeleteState,
AppSectionSaveState {
pendingChanges: Partial<Audiobook>;
}
export default AudiobooksAppState;

View file

@ -0,0 +1,12 @@
import AppSectionState, {
AppSectionDeleteState,
AppSectionSaveState,
} from 'App/State/AppSectionState';
import Book from 'Book/Book';
interface BooksAppState
extends AppSectionState<Book>, AppSectionDeleteState, AppSectionSaveState {
pendingChanges: Partial<Book>;
}
export default BooksAppState;

View file

@ -0,0 +1,28 @@
import ModelBase from 'App/ModelBase';
interface Audiobook extends ModelBase {
title: string;
sortTitle: string;
description: string;
foreignAudiobookId: string;
isbn: string;
asin: string;
narrator: string;
durationMinutes: number;
releaseDate: string;
publisher: string;
language: string;
monitored: boolean;
qualityProfileId: number;
path: string;
rootFolderPath: string;
added: string;
tags: number[];
lastSearchTime?: string;
authorId?: number;
seriesId?: number;
seriesPosition?: number;
isSaving?: boolean;
}
export default Audiobook;

View file

@ -1,17 +1,102 @@
import React from 'react';
import React, { useCallback, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import Alert from 'Components/Alert';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import { icons, kinds } from 'Helpers/Props';
import { fetchAudiobooks } from 'Store/Actions/audiobookActions';
import translate from 'Utilities/String/translate';
import AudiobookIndexRow from './AudiobookIndexRow';
const columns = [
{
name: 'title',
label: () => translate('Title'),
isVisible: true,
},
{
name: 'narrator',
label: () => translate('Narrator'),
isVisible: true,
},
{
name: 'duration',
label: () => translate('Duration'),
isVisible: true,
},
{
name: 'releaseDate',
label: () => translate('ReleaseDate'),
isVisible: true,
},
{
name: 'monitored',
label: () => translate('Monitored'),
isVisible: true,
},
];
function AudiobookIndex() {
const dispatch = useDispatch();
const { isFetching, isPopulated, error, items } = useSelector(
(state: AppState) => state.audiobooks
);
useEffect(() => {
dispatch(fetchAudiobooks());
}, [dispatch]);
const onRefreshPress = useCallback(() => {
dispatch(fetchAudiobooks());
}, [dispatch]);
const hasNoAudiobooks = isPopulated && !items.length;
return (
<PageContent title={translate('Audiobooks')}>
<PageToolbar>
<PageToolbarSection>
<PageToolbarButton
label={translate('RefreshAll')}
iconName={icons.REFRESH}
isSpinning={isFetching}
onPress={onRefreshPress}
/>
</PageToolbarSection>
</PageToolbar>
<PageContentBody>
<div style={{ padding: '20px', textAlign: 'center' }}>
<h1>{translate('Audiobooks')}</h1>
<p>Audiobook management coming soon.</p>
<p>This feature is part of Phase 3 development.</p>
</div>
{isFetching && !isPopulated ? <LoadingIndicator /> : null}
{!isFetching && !!error ? (
<Alert kind={kinds.DANGER}>
{translate('UnableToLoadAudiobooks')}
</Alert>
) : null}
{isPopulated && !error && items.length > 0 ? (
<Table columns={columns}>
<TableBody>
{items.map((audiobook) => (
<AudiobookIndexRow key={audiobook.id} {...audiobook} />
))}
</TableBody>
</Table>
) : null}
{hasNoAudiobooks ? (
<div style={{ padding: '20px', textAlign: 'center' }}>
<p>{translate('NoAudiobooks')}</p>
<p>Add audiobooks to start tracking your library.</p>
</div>
) : null}
</PageContentBody>
</PageContent>
);

View file

@ -0,0 +1,43 @@
import React from 'react';
import Audiobook from 'Audiobook/Audiobook';
import Icon from 'Components/Icon';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableRow from 'Components/Table/TableRow';
import { icons } from 'Helpers/Props';
import formatDate from 'Utilities/Date/formatDate';
function formatDuration(minutes: number | undefined): string {
if (!minutes) {
return '-';
}
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
if (hours > 0) {
return `${hours}h ${mins}m`;
}
return `${mins}m`;
}
function AudiobookIndexRow(props: Audiobook) {
const { title, narrator, durationMinutes, releaseDate, monitored } = props;
return (
<TableRow>
<TableRowCell>{title}</TableRowCell>
<TableRowCell>{narrator || '-'}</TableRowCell>
<TableRowCell>{formatDuration(durationMinutes)}</TableRowCell>
<TableRowCell>{releaseDate ? formatDate(releaseDate) : '-'}</TableRowCell>
<TableRowCell>
<Icon
name={monitored ? icons.MONITORED : icons.UNMONITORED}
title={monitored ? 'Monitored' : 'Unmonitored'}
/>
</TableRowCell>
</TableRow>
);
}
export default AudiobookIndexRow;

28
frontend/src/Book/Book.ts Normal file
View file

@ -0,0 +1,28 @@
import ModelBase from 'App/ModelBase';
interface Book extends ModelBase {
title: string;
sortTitle: string;
description: string;
foreignBookId: string;
isbn: string;
isbn13: string;
asin: string;
pageCount: number;
releaseDate: string;
publisher: string;
language: string;
monitored: boolean;
qualityProfileId: number;
path: string;
rootFolderPath: string;
added: string;
tags: number[];
lastSearchTime?: string;
authorId?: number;
seriesId?: number;
seriesPosition?: number;
isSaving?: boolean;
}
export default Book;

View file

@ -1,17 +1,100 @@
import React from 'react';
import React, { useCallback, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import Alert from 'Components/Alert';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import { icons, kinds } from 'Helpers/Props';
import { fetchBooks } from 'Store/Actions/bookActions';
import translate from 'Utilities/String/translate';
import BookIndexRow from './BookIndexRow';
const columns = [
{
name: 'title',
label: () => translate('Title'),
isVisible: true,
},
{
name: 'author',
label: () => translate('Author'),
isVisible: true,
},
{
name: 'publisher',
label: () => translate('Publisher'),
isVisible: true,
},
{
name: 'releaseDate',
label: () => translate('ReleaseDate'),
isVisible: true,
},
{
name: 'monitored',
label: () => translate('Monitored'),
isVisible: true,
},
];
function BookIndex() {
const dispatch = useDispatch();
const { isFetching, isPopulated, error, items } = useSelector(
(state: AppState) => state.books
);
useEffect(() => {
dispatch(fetchBooks());
}, [dispatch]);
const onRefreshPress = useCallback(() => {
dispatch(fetchBooks());
}, [dispatch]);
const hasNoBooks = isPopulated && !items.length;
return (
<PageContent title={translate('Books')}>
<PageToolbar>
<PageToolbarSection>
<PageToolbarButton
label={translate('RefreshAll')}
iconName={icons.REFRESH}
isSpinning={isFetching}
onPress={onRefreshPress}
/>
</PageToolbarSection>
</PageToolbar>
<PageContentBody>
<div style={{ padding: '20px', textAlign: 'center' }}>
<h1>{translate('Books')}</h1>
<p>Book management coming soon.</p>
<p>This feature is part of Phase 3 development.</p>
</div>
{isFetching && !isPopulated ? <LoadingIndicator /> : null}
{!isFetching && !!error ? (
<Alert kind={kinds.DANGER}>{translate('UnableToLoadBooks')}</Alert>
) : null}
{isPopulated && !error && items.length > 0 ? (
<Table columns={columns}>
<TableBody>
{items.map((book) => (
<BookIndexRow key={book.id} {...book} />
))}
</TableBody>
</Table>
) : null}
{hasNoBooks ? (
<div style={{ padding: '20px', textAlign: 'center' }}>
<p>{translate('NoBooks')}</p>
<p>Add books to start tracking your library.</p>
</div>
) : null}
</PageContentBody>
</PageContent>
);

View file

@ -0,0 +1,28 @@
import React from 'react';
import Book from 'Book/Book';
import Icon from 'Components/Icon';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableRow from 'Components/Table/TableRow';
import { icons } from 'Helpers/Props';
import formatDate from 'Utilities/Date/formatDate';
function BookIndexRow(props: Book) {
const { title, publisher, releaseDate, monitored } = props;
return (
<TableRow>
<TableRowCell>{title}</TableRowCell>
<TableRowCell>-</TableRowCell>
<TableRowCell>{publisher || '-'}</TableRowCell>
<TableRowCell>{releaseDate ? formatDate(releaseDate) : '-'}</TableRowCell>
<TableRowCell>
<Icon
name={monitored ? icons.MONITORED : icons.UNMONITORED}
title={monitored ? 'Monitored' : 'Unmonitored'}
/>
</TableRowCell>
</TableRow>
);
}
export default BookIndexRow;

View file

@ -0,0 +1,57 @@
import { createAction } from 'redux-actions';
import { sortDirections } from 'Helpers/Props';
import { createThunk, handleThunks } from 'Store/thunks';
import createFetchHandler from './Creators/createFetchHandler';
import createHandleActions from './Creators/createHandleActions';
import createRemoveItemHandler from './Creators/createRemoveItemHandler';
import createSaveProviderHandler from './Creators/createSaveProviderHandler';
import createSetSettingValueReducer from './Creators/Reducers/createSetSettingValueReducer';
export const section = 'audiobooks';
export const defaultState = {
isFetching: false,
isPopulated: false,
error: null,
isSaving: false,
saveError: null,
isDeleting: false,
deleteError: null,
items: [],
sortKey: 'sortTitle',
sortDirection: sortDirections.ASCENDING,
pendingChanges: {}
};
export const FETCH_AUDIOBOOKS = 'audiobooks/fetchAudiobooks';
export const SET_AUDIOBOOK_VALUE = 'audiobooks/setAudiobookValue';
export const SAVE_AUDIOBOOK = 'audiobooks/saveAudiobook';
export const DELETE_AUDIOBOOK = 'audiobooks/deleteAudiobook';
export const fetchAudiobooks = createThunk(FETCH_AUDIOBOOKS);
export const saveAudiobook = createThunk(SAVE_AUDIOBOOK);
export const deleteAudiobook = createThunk(DELETE_AUDIOBOOK, (payload) => {
return {
...payload,
queryParams: {
deleteFiles: payload.deleteFiles
}
};
});
export const setAudiobookValue = createAction(SET_AUDIOBOOK_VALUE, (payload) => {
return {
section,
...payload
};
});
export const actionHandlers = handleThunks({
[FETCH_AUDIOBOOKS]: createFetchHandler(section, '/audiobook'),
[SAVE_AUDIOBOOK]: createSaveProviderHandler(section, '/audiobook'),
[DELETE_AUDIOBOOK]: createRemoveItemHandler(section, '/audiobook')
});
export const reducers = createHandleActions({
[SET_AUDIOBOOK_VALUE]: createSetSettingValueReducer(section)
}, defaultState, section);

View file

@ -0,0 +1,57 @@
import { createAction } from 'redux-actions';
import { sortDirections } from 'Helpers/Props';
import { createThunk, handleThunks } from 'Store/thunks';
import createFetchHandler from './Creators/createFetchHandler';
import createHandleActions from './Creators/createHandleActions';
import createRemoveItemHandler from './Creators/createRemoveItemHandler';
import createSaveProviderHandler from './Creators/createSaveProviderHandler';
import createSetSettingValueReducer from './Creators/Reducers/createSetSettingValueReducer';
export const section = 'books';
export const defaultState = {
isFetching: false,
isPopulated: false,
error: null,
isSaving: false,
saveError: null,
isDeleting: false,
deleteError: null,
items: [],
sortKey: 'sortTitle',
sortDirection: sortDirections.ASCENDING,
pendingChanges: {}
};
export const FETCH_BOOKS = 'books/fetchBooks';
export const SET_BOOK_VALUE = 'books/setBookValue';
export const SAVE_BOOK = 'books/saveBook';
export const DELETE_BOOK = 'books/deleteBook';
export const fetchBooks = createThunk(FETCH_BOOKS);
export const saveBook = createThunk(SAVE_BOOK);
export const deleteBook = createThunk(DELETE_BOOK, (payload) => {
return {
...payload,
queryParams: {
deleteFiles: payload.deleteFiles
}
};
});
export const setBookValue = createAction(SET_BOOK_VALUE, (payload) => {
return {
section,
...payload
};
});
export const actionHandlers = handleThunks({
[FETCH_BOOKS]: createFetchHandler(section, '/book'),
[SAVE_BOOK]: createSaveProviderHandler(section, '/book'),
[DELETE_BOOK]: createRemoveItemHandler(section, '/book')
});
export const reducers = createHandleActions({
[SET_BOOK_VALUE]: createSetSettingValueReducer(section)
}, defaultState, section);

View file

@ -1,6 +1,8 @@
import * as addMovie from './addMovieActions';
import * as app from './appActions';
import * as audiobooks from './audiobookActions';
import * as blocklist from './blocklistActions';
import * as books from './bookActions';
import * as calendar from './calendarActions';
import * as captcha from './captchaActions';
import * as commands from './commandActions';
@ -33,7 +35,9 @@ import * as wanted from './wantedActions';
export default [
addMovie,
app,
audiobooks,
blocklist,
books,
calendar,
captcha,
commands,

View file

@ -0,0 +1,124 @@
using System;
using System.Collections.Generic;
using System.IO;
using FluentValidation;
using NLog;
using NzbDrone.Common.EnsureThat;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Organizer;
namespace NzbDrone.Core.Audiobooks
{
public interface IAddAudiobookService
{
Audiobook AddAudiobook(Audiobook newAudiobook);
List<Audiobook> AddAudiobooks(List<Audiobook> newAudiobooks, bool ignoreErrors = false);
}
public class AddAudiobookService : IAddAudiobookService
{
private readonly IAudiobookService _audiobookService;
private readonly IBuildFileNames _fileNameBuilder;
private readonly IAddAudiobookValidator _addAudiobookValidator;
private readonly Logger _logger;
public AddAudiobookService(IAudiobookService audiobookService,
IBuildFileNames fileNameBuilder,
IAddAudiobookValidator addAudiobookValidator,
Logger logger)
{
_audiobookService = audiobookService;
_fileNameBuilder = fileNameBuilder;
_addAudiobookValidator = addAudiobookValidator;
_logger = logger;
}
public Audiobook AddAudiobook(Audiobook newAudiobook)
{
Ensure.That(newAudiobook, () => newAudiobook).IsNotNull();
newAudiobook = SetPropertiesAndValidate(newAudiobook);
_logger.Info("Adding Audiobook {0} Path: [{1}]", newAudiobook, newAudiobook.Path.SanitizeForLog());
_audiobookService.AddAudiobook(newAudiobook);
return newAudiobook;
}
public List<Audiobook> AddAudiobooks(List<Audiobook> newAudiobooks, bool ignoreErrors = false)
{
var added = DateTime.UtcNow;
var audiobooksToAdd = new List<Audiobook>();
foreach (var a in newAudiobooks)
{
if (a.Path.IsNullOrWhiteSpace())
{
_logger.Info("Adding Audiobook {0} Root Folder Path: [{1}]", a, a.RootFolderPath.SanitizeForLog());
}
else
{
_logger.Info("Adding Audiobook {0} Path: [{1}]", a, a.Path.SanitizeForLog());
}
try
{
var audiobook = SetPropertiesAndValidate(a);
audiobook.Added = added;
audiobooksToAdd.Add(audiobook);
}
catch (ValidationException ex)
{
if (!ignoreErrors)
{
throw;
}
_logger.Debug("Audiobook {0} was not added due to validation failures. {1}", a.Title, ex.Message);
}
}
return _audiobookService.AddAudiobooks(audiobooksToAdd);
}
private Audiobook SetPropertiesAndValidate(Audiobook newAudiobook)
{
if (string.IsNullOrWhiteSpace(newAudiobook.Path))
{
var folderName = GetAudiobookFolder(newAudiobook);
newAudiobook.Path = Path.Combine(newAudiobook.RootFolderPath, folderName);
}
if (string.IsNullOrWhiteSpace(newAudiobook.SortTitle))
{
newAudiobook.SortTitle = newAudiobook.Title?.ToLowerInvariant();
}
newAudiobook.Added = DateTime.UtcNow;
var validationResult = _addAudiobookValidator.Validate(newAudiobook);
if (!validationResult.IsValid)
{
throw new ValidationException(validationResult.Errors);
}
return newAudiobook;
}
private string GetAudiobookFolder(Audiobook audiobook)
{
var title = audiobook.Title ?? "Unknown";
var year = audiobook.ReleaseDate?.Year.ToString() ?? "Unknown";
var narrator = audiobook.Narrator;
if (!string.IsNullOrWhiteSpace(narrator))
{
return FileNameBuilder.CleanFileName($"{title} ({year}) [{narrator}]");
}
return FileNameBuilder.CleanFileName($"{title} ({year})");
}
}
}

View file

@ -0,0 +1,25 @@
using FluentValidation;
using FluentValidation.Results;
using NzbDrone.Core.Validation.Paths;
namespace NzbDrone.Core.Audiobooks
{
public interface IAddAudiobookValidator
{
ValidationResult Validate(Audiobook instance);
}
public class AddAudiobookValidator : AbstractValidator<Audiobook>, IAddAudiobookValidator
{
public AddAudiobookValidator(RootFolderValidator rootFolderValidator,
RecycleBinValidator recycleBinValidator)
{
RuleFor(c => c.Path).Cascade(CascadeMode.Stop)
.IsValidPath()
.SetValidator(rootFolderValidator)
.SetValidator(recycleBinValidator);
RuleFor(c => c.Title).NotEmpty();
}
}
}

View file

@ -0,0 +1,43 @@
using System.Collections.Generic;
using System.Linq;
using NzbDrone.Core.Datastore;
using NzbDrone.Core.Messaging.Events;
namespace NzbDrone.Core.Authors
{
public interface IAuthorRepository : IBasicRepository<Author>
{
Author FindByName(string name);
Author FindByForeignId(string foreignAuthorId);
List<Author> GetMonitored();
bool AuthorPathExists(string path);
}
public class AuthorRepository : BasicRepository<Author>, IAuthorRepository
{
public AuthorRepository(IMainDatabase database, IEventAggregator eventAggregator)
: base(database, eventAggregator)
{
}
public Author FindByName(string name)
{
return Query(a => a.Name == name).FirstOrDefault();
}
public Author FindByForeignId(string foreignAuthorId)
{
return Query(a => a.ForeignAuthorId == foreignAuthorId).FirstOrDefault();
}
public List<Author> GetMonitored()
{
return Query(a => a.Monitored);
}
public bool AuthorPathExists(string path)
{
return Query(a => a.Path == path).Any();
}
}
}

View file

@ -0,0 +1,115 @@
using System;
using System.Collections.Generic;
using System.Linq;
using NLog;
using NzbDrone.Core.Messaging.Events;
namespace NzbDrone.Core.Authors
{
public interface IAuthorService
{
Author GetAuthor(int authorId);
List<Author> GetAuthors(IEnumerable<int> authorIds);
Author AddAuthor(Author newAuthor);
List<Author> AddAuthors(List<Author> newAuthors);
Author FindByName(string name);
Author FindByForeignId(string foreignAuthorId);
void DeleteAuthor(int authorId, bool deleteFiles);
void DeleteAuthors(List<int> authorIds, bool deleteFiles);
List<Author> GetAllAuthors();
List<Author> GetMonitoredAuthors();
Author UpdateAuthor(Author author);
List<Author> UpdateAuthors(List<Author> authors);
bool AuthorPathExists(string path);
}
public class AuthorService : IAuthorService
{
private readonly IAuthorRepository _authorRepository;
private readonly IEventAggregator _eventAggregator;
private readonly Logger _logger;
public AuthorService(IAuthorRepository authorRepository,
IEventAggregator eventAggregator,
Logger logger)
{
_authorRepository = authorRepository;
_eventAggregator = eventAggregator;
_logger = logger;
}
public Author GetAuthor(int authorId)
{
return _authorRepository.Get(authorId);
}
public List<Author> GetAuthors(IEnumerable<int> authorIds)
{
return _authorRepository.Get(authorIds).ToList();
}
public Author AddAuthor(Author newAuthor)
{
newAuthor.Added = DateTime.UtcNow;
return _authorRepository.Insert(newAuthor);
}
public List<Author> AddAuthors(List<Author> newAuthors)
{
var now = DateTime.UtcNow;
foreach (var author in newAuthors)
{
author.Added = now;
}
_authorRepository.InsertMany(newAuthors);
return newAuthors;
}
public Author FindByName(string name)
{
return _authorRepository.FindByName(name);
}
public Author FindByForeignId(string foreignAuthorId)
{
return _authorRepository.FindByForeignId(foreignAuthorId);
}
public void DeleteAuthor(int authorId, bool deleteFiles)
{
_authorRepository.Delete(authorId);
}
public void DeleteAuthors(List<int> authorIds, bool deleteFiles)
{
_authorRepository.DeleteMany(authorIds);
}
public List<Author> GetAllAuthors()
{
return _authorRepository.All().ToList();
}
public List<Author> GetMonitoredAuthors()
{
return _authorRepository.GetMonitored();
}
public Author UpdateAuthor(Author author)
{
return _authorRepository.Update(author);
}
public List<Author> UpdateAuthors(List<Author> authors)
{
_authorRepository.UpdateMany(authors);
return authors;
}
public bool AuthorPathExists(string path)
{
return _authorRepository.AuthorPathExists(path);
}
}
}

View file

@ -0,0 +1,117 @@
using System;
using System.Collections.Generic;
using System.IO;
using FluentValidation;
using NLog;
using NzbDrone.Common.EnsureThat;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Organizer;
namespace NzbDrone.Core.Books
{
public interface IAddBookService
{
Book AddBook(Book newBook);
List<Book> AddBooks(List<Book> newBooks, bool ignoreErrors = false);
}
public class AddBookService : IAddBookService
{
private readonly IBookService _bookService;
private readonly IBuildFileNames _fileNameBuilder;
private readonly IAddBookValidator _addBookValidator;
private readonly Logger _logger;
public AddBookService(IBookService bookService,
IBuildFileNames fileNameBuilder,
IAddBookValidator addBookValidator,
Logger logger)
{
_bookService = bookService;
_fileNameBuilder = fileNameBuilder;
_addBookValidator = addBookValidator;
_logger = logger;
}
public Book AddBook(Book newBook)
{
Ensure.That(newBook, () => newBook).IsNotNull();
newBook = SetPropertiesAndValidate(newBook);
_logger.Info("Adding Book {0} Path: [{1}]", newBook, newBook.Path.SanitizeForLog());
_bookService.AddBook(newBook);
return newBook;
}
public List<Book> AddBooks(List<Book> newBooks, bool ignoreErrors = false)
{
var added = DateTime.UtcNow;
var booksToAdd = new List<Book>();
foreach (var b in newBooks)
{
if (b.Path.IsNullOrWhiteSpace())
{
_logger.Info("Adding Book {0} Root Folder Path: [{1}]", b, b.RootFolderPath.SanitizeForLog());
}
else
{
_logger.Info("Adding Book {0} Path: [{1}]", b, b.Path.SanitizeForLog());
}
try
{
var book = SetPropertiesAndValidate(b);
book.Added = added;
booksToAdd.Add(book);
}
catch (ValidationException ex)
{
if (!ignoreErrors)
{
throw;
}
_logger.Debug("Book {0} was not added due to validation failures. {1}", b.Title, ex.Message);
}
}
return _bookService.AddBooks(booksToAdd);
}
private Book SetPropertiesAndValidate(Book newBook)
{
if (string.IsNullOrWhiteSpace(newBook.Path))
{
var folderName = GetBookFolder(newBook);
newBook.Path = Path.Combine(newBook.RootFolderPath, folderName);
}
if (string.IsNullOrWhiteSpace(newBook.SortTitle))
{
newBook.SortTitle = newBook.Title?.ToLowerInvariant();
}
newBook.Added = DateTime.UtcNow;
var validationResult = _addBookValidator.Validate(newBook);
if (!validationResult.IsValid)
{
throw new ValidationException(validationResult.Errors);
}
return newBook;
}
private string GetBookFolder(Book book)
{
var title = book.Title ?? "Unknown";
var year = book.ReleaseDate?.Year.ToString() ?? "Unknown";
return FileNameBuilder.CleanFileName($"{title} ({year})");
}
}
}

View file

@ -0,0 +1,25 @@
using FluentValidation;
using FluentValidation.Results;
using NzbDrone.Core.Validation.Paths;
namespace NzbDrone.Core.Books
{
public interface IAddBookValidator
{
ValidationResult Validate(Book instance);
}
public class AddBookValidator : AbstractValidator<Book>, IAddBookValidator
{
public AddBookValidator(RootFolderValidator rootFolderValidator,
RecycleBinValidator recycleBinValidator)
{
RuleFor(c => c.Path).Cascade(CascadeMode.Stop)
.IsValidPath()
.SetValidator(rootFolderValidator)
.SetValidator(recycleBinValidator);
RuleFor(c => c.Title).NotEmpty();
}
}
}

View file

@ -0,0 +1,41 @@
using FluentMigrator;
using NzbDrone.Core.Datastore.Migration.Framework;
namespace NzbDrone.Core.Datastore.Migration
{
[Migration(247)]
public class add_book_audiobook_files_tables : NzbDroneMigrationBase
{
protected override void MainDbUpgrade()
{
Create.TableForModel("BookFiles")
.WithColumn("BookId").AsInt32().NotNullable()
.WithColumn("RelativePath").AsString().Nullable()
.WithColumn("Size").AsInt64().WithDefaultValue(0)
.WithColumn("DateAdded").AsDateTime().WithDefaultValue(System.DateTime.UtcNow)
.WithColumn("OriginalFilePath").AsString().Nullable()
.WithColumn("SceneName").AsString().Nullable()
.WithColumn("ReleaseGroup").AsString().Nullable()
.WithColumn("Quality").AsString().WithDefaultValue("{}")
.WithColumn("Format").AsString().Nullable();
Create.TableForModel("AudiobookFiles")
.WithColumn("AudiobookId").AsInt32().NotNullable()
.WithColumn("RelativePath").AsString().Nullable()
.WithColumn("Size").AsInt64().WithDefaultValue(0)
.WithColumn("DateAdded").AsDateTime().WithDefaultValue(System.DateTime.UtcNow)
.WithColumn("OriginalFilePath").AsString().Nullable()
.WithColumn("SceneName").AsString().Nullable()
.WithColumn("ReleaseGroup").AsString().Nullable()
.WithColumn("Quality").AsString().WithDefaultValue("{}")
.WithColumn("Format").AsString().Nullable()
.WithColumn("DurationSeconds").AsInt32().Nullable()
.WithColumn("Bitrate").AsInt32().Nullable()
.WithColumn("SampleRate").AsInt32().Nullable()
.WithColumn("Channels").AsInt32().Nullable();
Create.Index("IX_BookFiles_BookId").OnTable("BookFiles").OnColumn("BookId");
Create.Index("IX_AudiobookFiles_AudiobookId").OnTable("AudiobookFiles").OnColumn("AudiobookId");
}
}
}

View file

@ -148,6 +148,12 @@ public static void Map()
Mapper.Entity<Audiobook>("Audiobooks").RegisterModel();
Mapper.Entity<BookFile>("BookFiles").RegisterModel()
.Ignore(f => f.Path);
Mapper.Entity<AudiobookFile>("AudiobookFiles").RegisterModel()
.Ignore(f => f.Path);
Mapper.Entity<QualityDefinition>("QualityDefinitions").RegisterModel()
.Ignore(d => d.GroupName)
.Ignore(d => d.Weight);

View file

@ -0,0 +1,52 @@
using System;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Audiobooks;
using NzbDrone.Core.Datastore;
using NzbDrone.Core.Qualities;
namespace NzbDrone.Core.MediaFiles
{
public class AudiobookFile : ModelBase
{
public int AudiobookId { get; set; }
public string RelativePath { get; set; }
public string Path { get; set; }
public long Size { get; set; }
public DateTime DateAdded { get; set; }
public string OriginalFilePath { get; set; }
public string SceneName { get; set; }
public string ReleaseGroup { get; set; }
public QualityModel Quality { get; set; }
public string Format { get; set; }
public int? DurationSeconds { get; set; }
public int? Bitrate { get; set; }
public int? SampleRate { get; set; }
public int? Channels { get; set; }
public Audiobook Audiobook { get; set; }
public override string ToString()
{
return string.Format("[{0}] {1}", Id, RelativePath);
}
public string GetSceneOrFileName()
{
if (SceneName.IsNotNullOrWhiteSpace())
{
return SceneName;
}
if (RelativePath.IsNotNullOrWhiteSpace())
{
return System.IO.Path.GetFileNameWithoutExtension(RelativePath);
}
if (Path.IsNotNullOrWhiteSpace())
{
return System.IO.Path.GetFileNameWithoutExtension(Path);
}
return string.Empty;
}
}
}

View file

@ -0,0 +1,43 @@
using System.Collections.Generic;
using System.Linq;
using NzbDrone.Core.Datastore;
using NzbDrone.Core.Messaging.Events;
namespace NzbDrone.Core.MediaFiles
{
public interface IAudiobookFileRepository : IBasicRepository<AudiobookFile>
{
List<AudiobookFile> GetFilesByAudiobook(int audiobookId);
List<AudiobookFile> GetFilesByAudiobooks(IEnumerable<int> audiobookIds);
void DeleteForAudiobooks(List<int> audiobookIds);
List<AudiobookFile> GetFilesWithRelativePath(int audiobookId, string relativePath);
}
public class AudiobookFileRepository : BasicRepository<AudiobookFile>, IAudiobookFileRepository
{
public AudiobookFileRepository(IMainDatabase database, IEventAggregator eventAggregator)
: base(database, eventAggregator)
{
}
public List<AudiobookFile> GetFilesByAudiobook(int audiobookId)
{
return Query(x => x.AudiobookId == audiobookId);
}
public List<AudiobookFile> GetFilesByAudiobooks(IEnumerable<int> audiobookIds)
{
return Query(x => audiobookIds.Contains(x.AudiobookId));
}
public void DeleteForAudiobooks(List<int> audiobookIds)
{
Delete(x => audiobookIds.Contains(x.AudiobookId));
}
public List<AudiobookFile> GetFilesWithRelativePath(int audiobookId, string relativePath)
{
return Query(c => c.AudiobookId == audiobookId && c.RelativePath == relativePath);
}
}
}

View file

@ -0,0 +1,48 @@
using System;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Books;
using NzbDrone.Core.Datastore;
using NzbDrone.Core.Qualities;
namespace NzbDrone.Core.MediaFiles
{
public class BookFile : ModelBase
{
public int BookId { get; set; }
public string RelativePath { get; set; }
public string Path { get; set; }
public long Size { get; set; }
public DateTime DateAdded { get; set; }
public string OriginalFilePath { get; set; }
public string SceneName { get; set; }
public string ReleaseGroup { get; set; }
public QualityModel Quality { get; set; }
public string Format { get; set; }
public Book Book { get; set; }
public override string ToString()
{
return string.Format("[{0}] {1}", Id, RelativePath);
}
public string GetSceneOrFileName()
{
if (SceneName.IsNotNullOrWhiteSpace())
{
return SceneName;
}
if (RelativePath.IsNotNullOrWhiteSpace())
{
return System.IO.Path.GetFileNameWithoutExtension(RelativePath);
}
if (Path.IsNotNullOrWhiteSpace())
{
return System.IO.Path.GetFileNameWithoutExtension(Path);
}
return string.Empty;
}
}
}

View file

@ -0,0 +1,43 @@
using System.Collections.Generic;
using System.Linq;
using NzbDrone.Core.Datastore;
using NzbDrone.Core.Messaging.Events;
namespace NzbDrone.Core.MediaFiles
{
public interface IBookFileRepository : IBasicRepository<BookFile>
{
List<BookFile> GetFilesByBook(int bookId);
List<BookFile> GetFilesByBooks(IEnumerable<int> bookIds);
void DeleteForBooks(List<int> bookIds);
List<BookFile> GetFilesWithRelativePath(int bookId, string relativePath);
}
public class BookFileRepository : BasicRepository<BookFile>, IBookFileRepository
{
public BookFileRepository(IMainDatabase database, IEventAggregator eventAggregator)
: base(database, eventAggregator)
{
}
public List<BookFile> GetFilesByBook(int bookId)
{
return Query(x => x.BookId == bookId);
}
public List<BookFile> GetFilesByBooks(IEnumerable<int> bookIds)
{
return Query(x => bookIds.Contains(x.BookId));
}
public void DeleteForBooks(List<int> bookIds)
{
Delete(x => bookIds.Contains(x.BookId));
}
public List<BookFile> GetFilesWithRelativePath(int bookId, string relativePath)
{
return Query(c => c.BookId == bookId && c.RelativePath == relativePath);
}
}
}

View file

@ -0,0 +1,88 @@
using System;
using System.Collections.Generic;
using NLog;
namespace NzbDrone.Core.MetadataSource.Audiobook
{
public class AudiobookInfoProxy : IProvideAudiobookInfo
{
private readonly Logger _logger;
public AudiobookInfoProxy(Logger logger)
{
_logger = logger;
}
public AudiobookMetadata GetByExternalId(string externalId)
{
_logger.Debug("GetByExternalId called for: {0} (stub implementation)", externalId);
return null;
}
public AudiobookMetadata GetById(int providerId)
{
_logger.Debug("GetById called for: {0} (stub implementation)", providerId);
return null;
}
public List<AudiobookMetadata> GetBulkInfo(List<int> providerIds)
{
_logger.Debug("GetBulkInfo called for {0} IDs (stub implementation)", providerIds.Count);
return new List<AudiobookMetadata>();
}
public List<AudiobookMetadata> GetTrending()
{
_logger.Debug("GetTrending called (stub implementation)");
return new List<AudiobookMetadata>();
}
public List<AudiobookMetadata> GetPopular()
{
_logger.Debug("GetPopular called (stub implementation)");
return new List<AudiobookMetadata>();
}
public HashSet<int> GetChangedItems(DateTime startTime)
{
_logger.Debug("GetChangedItems called since {0} (stub implementation)", startTime);
return new HashSet<int>();
}
public List<AudiobookMetadata> SearchByTitle(string title)
{
_logger.Debug("SearchByTitle called for: {0} (stub implementation)", title);
return new List<AudiobookMetadata>();
}
public List<AudiobookMetadata> SearchByTitle(string title, int year)
{
_logger.Debug("SearchByTitle called for: {0} ({1}) (stub implementation)", title, year);
return new List<AudiobookMetadata>();
}
public AudiobookMetadata GetByIsbn(string isbn)
{
_logger.Debug("GetByIsbn called for: {0} (stub implementation)", isbn);
return null;
}
public AudiobookMetadata GetByAsin(string asin)
{
_logger.Debug("GetByAsin called for: {0} (stub implementation)", asin);
return null;
}
public List<AudiobookMetadata> GetByNarrator(string narratorName)
{
_logger.Debug("GetByNarrator called for: {0} (stub implementation)", narratorName);
return new List<AudiobookMetadata>();
}
public List<AudiobookMetadata> GetByAuthor(string authorName)
{
_logger.Debug("GetByAuthor called for: {0} (stub implementation)", authorName);
return new List<AudiobookMetadata>();
}
}
}

View file

@ -0,0 +1,37 @@
using System;
using System.Collections.Generic;
namespace NzbDrone.Core.MetadataSource.Audiobook
{
public interface IProvideAudiobookInfo : IProvideMediaInfo<AudiobookMetadata>, ISearchableMediaProvider<AudiobookMetadata>
{
AudiobookMetadata GetByIsbn(string isbn);
AudiobookMetadata GetByAsin(string asin);
List<AudiobookMetadata> GetByNarrator(string narratorName);
List<AudiobookMetadata> GetByAuthor(string authorName);
}
public class AudiobookMetadata
{
public string ForeignAudiobookId { get; set; }
public string Title { get; set; }
public string SortTitle { get; set; }
public string Description { get; set; }
public string Isbn { get; set; }
public string Isbn13 { get; set; }
public string Asin { get; set; }
public DateTime? ReleaseDate { get; set; }
public string Publisher { get; set; }
public string Language { get; set; }
public string Narrator { get; set; }
public List<string> Narrators { get; set; }
public int? DurationMinutes { get; set; }
public bool IsAbridged { get; set; }
public List<string> Authors { get; set; }
public List<string> Genres { get; set; }
public string CoverUrl { get; set; }
public double? Rating { get; set; }
public int? RatingsCount { get; set; }
public int? BookId { get; set; }
}
}

View file

@ -0,0 +1,88 @@
using System;
using System.Collections.Generic;
using NLog;
namespace NzbDrone.Core.MetadataSource.Book
{
public class BookInfoProxy : IProvideBookInfo
{
private readonly Logger _logger;
public BookInfoProxy(Logger logger)
{
_logger = logger;
}
public BookMetadata GetByExternalId(string externalId)
{
_logger.Debug("GetByExternalId called for: {0} (stub implementation)", externalId);
return null;
}
public BookMetadata GetById(int providerId)
{
_logger.Debug("GetById called for: {0} (stub implementation)", providerId);
return null;
}
public List<BookMetadata> GetBulkInfo(List<int> providerIds)
{
_logger.Debug("GetBulkInfo called for {0} IDs (stub implementation)", providerIds.Count);
return new List<BookMetadata>();
}
public List<BookMetadata> GetTrending()
{
_logger.Debug("GetTrending called (stub implementation)");
return new List<BookMetadata>();
}
public List<BookMetadata> GetPopular()
{
_logger.Debug("GetPopular called (stub implementation)");
return new List<BookMetadata>();
}
public HashSet<int> GetChangedItems(DateTime startTime)
{
_logger.Debug("GetChangedItems called since {0} (stub implementation)", startTime);
return new HashSet<int>();
}
public List<BookMetadata> SearchByTitle(string title)
{
_logger.Debug("SearchByTitle called for: {0} (stub implementation)", title);
return new List<BookMetadata>();
}
public List<BookMetadata> SearchByTitle(string title, int year)
{
_logger.Debug("SearchByTitle called for: {0} ({1}) (stub implementation)", title, year);
return new List<BookMetadata>();
}
public BookMetadata GetByIsbn(string isbn)
{
_logger.Debug("GetByIsbn called for: {0} (stub implementation)", isbn);
return null;
}
public BookMetadata GetByIsbn13(string isbn13)
{
_logger.Debug("GetByIsbn13 called for: {0} (stub implementation)", isbn13);
return null;
}
public BookMetadata GetByAsin(string asin)
{
_logger.Debug("GetByAsin called for: {0} (stub implementation)", asin);
return null;
}
public List<BookMetadata> GetByAuthor(string authorName)
{
_logger.Debug("GetByAuthor called for: {0} (stub implementation)", authorName);
return new List<BookMetadata>();
}
}
}

View file

@ -0,0 +1,33 @@
using System;
using System.Collections.Generic;
namespace NzbDrone.Core.MetadataSource.Book
{
public interface IProvideBookInfo : IProvideMediaInfo<BookMetadata>, ISearchableMediaProvider<BookMetadata>
{
BookMetadata GetByIsbn(string isbn);
BookMetadata GetByIsbn13(string isbn13);
BookMetadata GetByAsin(string asin);
List<BookMetadata> GetByAuthor(string authorName);
}
public class BookMetadata
{
public string ForeignBookId { get; set; }
public string Title { get; set; }
public string SortTitle { get; set; }
public string Description { get; set; }
public string Isbn { get; set; }
public string Isbn13 { get; set; }
public string Asin { get; set; }
public int? PageCount { get; set; }
public DateTime? ReleaseDate { get; set; }
public string Publisher { get; set; }
public string Language { get; set; }
public List<string> Authors { get; set; }
public List<string> Genres { get; set; }
public string CoverUrl { get; set; }
public double? Rating { get; set; }
public int? RatingsCount { get; set; }
}
}

View file

@ -0,0 +1,43 @@
using System.Collections.Generic;
using System.Linq;
using NzbDrone.Core.Datastore;
using NzbDrone.Core.Messaging.Events;
namespace NzbDrone.Core.Series
{
public interface ISeriesRepository : IBasicRepository<Series>
{
Series FindByTitle(string title);
Series FindByForeignId(string foreignSeriesId);
List<Series> FindByAuthorId(int authorId);
List<Series> GetMonitored();
}
public class SeriesRepository : BasicRepository<Series>, ISeriesRepository
{
public SeriesRepository(IMainDatabase database, IEventAggregator eventAggregator)
: base(database, eventAggregator)
{
}
public Series FindByTitle(string title)
{
return Query(s => s.Title == title).FirstOrDefault();
}
public Series FindByForeignId(string foreignSeriesId)
{
return Query(s => s.ForeignSeriesId == foreignSeriesId).FirstOrDefault();
}
public List<Series> FindByAuthorId(int authorId)
{
return Query(s => s.AuthorId == authorId);
}
public List<Series> GetMonitored()
{
return Query(s => s.Monitored);
}
}
}

View file

@ -0,0 +1,107 @@
using System.Collections.Generic;
using System.Linq;
using NLog;
using NzbDrone.Core.Messaging.Events;
namespace NzbDrone.Core.Series
{
public interface ISeriesService
{
Series GetSeries(int seriesId);
List<Series> GetSeriesItems(IEnumerable<int> seriesIds);
Series AddSeries(Series newSeries);
List<Series> AddMultipleSeries(List<Series> newSeries);
Series FindByTitle(string title);
Series FindByForeignId(string foreignSeriesId);
List<Series> FindByAuthorId(int authorId);
void DeleteSeries(int seriesId);
void DeleteMultipleSeries(List<int> seriesIds);
List<Series> GetAllSeries();
List<Series> GetMonitoredSeries();
Series UpdateSeries(Series series);
List<Series> UpdateMultipleSeries(List<Series> series);
}
public class SeriesService : ISeriesService
{
private readonly ISeriesRepository _seriesRepository;
private readonly IEventAggregator _eventAggregator;
private readonly Logger _logger;
public SeriesService(ISeriesRepository seriesRepository,
IEventAggregator eventAggregator,
Logger logger)
{
_seriesRepository = seriesRepository;
_eventAggregator = eventAggregator;
_logger = logger;
}
public Series GetSeries(int seriesId)
{
return _seriesRepository.Get(seriesId);
}
public List<Series> GetSeriesItems(IEnumerable<int> seriesIds)
{
return _seriesRepository.Get(seriesIds).ToList();
}
public Series AddSeries(Series newSeries)
{
return _seriesRepository.Insert(newSeries);
}
public List<Series> AddMultipleSeries(List<Series> newSeries)
{
_seriesRepository.InsertMany(newSeries);
return newSeries;
}
public Series FindByTitle(string title)
{
return _seriesRepository.FindByTitle(title);
}
public Series FindByForeignId(string foreignSeriesId)
{
return _seriesRepository.FindByForeignId(foreignSeriesId);
}
public List<Series> FindByAuthorId(int authorId)
{
return _seriesRepository.FindByAuthorId(authorId);
}
public void DeleteSeries(int seriesId)
{
_seriesRepository.Delete(seriesId);
}
public void DeleteMultipleSeries(List<int> seriesIds)
{
_seriesRepository.DeleteMany(seriesIds);
}
public List<Series> GetAllSeries()
{
return _seriesRepository.All().ToList();
}
public List<Series> GetMonitoredSeries()
{
return _seriesRepository.GetMonitored();
}
public Series UpdateSeries(Series series)
{
return _seriesRepository.Update(series);
}
public List<Series> UpdateMultipleSeries(List<Series> series)
{
_seriesRepository.UpdateMany(series);
return series;
}
}
}

View file

@ -0,0 +1,92 @@
using System.Collections.Generic;
using FluentValidation;
using Microsoft.AspNetCore.Mvc;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Audiobooks;
using Radarr.Http;
namespace Radarr.Api.V3.Audiobooks
{
[V3ApiController("audiobook/editor")]
public class AudiobookEditorController : Controller
{
private readonly IAudiobookService _audiobookService;
private readonly AudiobookEditorValidator _audiobookEditorValidator;
public AudiobookEditorController(IAudiobookService audiobookService,
AudiobookEditorValidator audiobookEditorValidator)
{
_audiobookService = audiobookService;
_audiobookEditorValidator = audiobookEditorValidator;
}
[HttpPut]
public IActionResult SaveAll([FromBody] AudiobookEditorResource resource)
{
var audiobooksToUpdate = _audiobookService.GetAudiobooks(resource.AudiobookIds);
foreach (var audiobook in audiobooksToUpdate)
{
if (resource.Monitored.HasValue)
{
audiobook.Monitored = resource.Monitored.Value;
}
if (resource.QualityProfileId.HasValue)
{
audiobook.QualityProfileId = resource.QualityProfileId.Value;
}
if (resource.RootFolderPath.IsNotNullOrWhiteSpace())
{
audiobook.RootFolderPath = resource.RootFolderPath;
}
if (resource.Tags != null)
{
var newTags = resource.Tags;
var applyTags = resource.ApplyTags;
switch (applyTags)
{
case ApplyTags.Add:
newTags.ForEach(t => audiobook.Tags.Add(t));
break;
case ApplyTags.Remove:
newTags.ForEach(t => audiobook.Tags.Remove(t));
break;
case ApplyTags.Replace:
audiobook.Tags = new HashSet<int>(newTags);
break;
}
}
var validationResult = _audiobookEditorValidator.Validate(audiobook);
if (!validationResult.IsValid)
{
throw new ValidationException(validationResult.Errors);
}
}
var updatedAudiobooks = _audiobookService.UpdateAudiobooks(audiobooksToUpdate);
var audiobooksResources = new List<AudiobookResource>(updatedAudiobooks.Count);
foreach (var audiobook in updatedAudiobooks)
{
audiobooksResources.Add(audiobook.ToResource());
}
return Ok(audiobooksResources);
}
[HttpDelete]
public object DeleteAudiobooks([FromBody] AudiobookEditorResource resource)
{
_audiobookService.DeleteAudiobooks(resource.AudiobookIds, resource.DeleteFiles);
return new { };
}
}
}

View file

@ -0,0 +1,16 @@
using System.Collections.Generic;
namespace Radarr.Api.V3.Audiobooks
{
public class AudiobookEditorResource
{
public List<int> AudiobookIds { get; set; }
public bool? Monitored { get; set; }
public int? QualityProfileId { 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; }
}
}

View file

@ -0,0 +1,23 @@
using FluentValidation;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Audiobooks;
using NzbDrone.Core.Validation;
using NzbDrone.Core.Validation.Paths;
namespace Radarr.Api.V3.Audiobooks
{
public class AudiobookEditorValidator : AbstractValidator<Audiobook>
{
public AudiobookEditorValidator(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

@ -0,0 +1,116 @@
using System;
using System.Collections.Generic;
using Microsoft.AspNetCore.Mvc;
using NzbDrone.Core.Audiobooks;
using Radarr.Http;
using Radarr.Http.REST;
namespace Radarr.Api.V3.Audiobooks
{
[V3ApiController("audiobook/lookup")]
public class AudiobookLookupController : RestController<AudiobookResource>
{
private readonly IAudiobookService _audiobookService;
public AudiobookLookupController(IAudiobookService audiobookService)
{
_audiobookService = audiobookService;
}
[NonAction]
public override ActionResult<AudiobookResource> GetResourceByIdWithErrorHandler(int id)
{
throw new NotImplementedException();
}
protected override AudiobookResource GetResourceById(int id)
{
throw new NotImplementedException();
}
[HttpGet("isbn")]
[Produces("application/json")]
public ActionResult<AudiobookResource> SearchByIsbn(string isbn)
{
var audiobook = _audiobookService.FindByIsbn(isbn);
if (audiobook == null)
{
return NotFound();
}
return audiobook.ToResource();
}
[HttpGet("isbn13")]
[Produces("application/json")]
public ActionResult<AudiobookResource> SearchByIsbn13(string isbn13)
{
var audiobook = _audiobookService.FindByIsbn13(isbn13);
if (audiobook == null)
{
return NotFound();
}
return audiobook.ToResource();
}
[HttpGet("asin")]
[Produces("application/json")]
public ActionResult<AudiobookResource> SearchByAsin(string asin)
{
var audiobook = _audiobookService.FindByAsin(asin);
if (audiobook == null)
{
return NotFound();
}
return audiobook.ToResource();
}
[HttpGet("foreignid")]
[Produces("application/json")]
public ActionResult<AudiobookResource> SearchByForeignId(string foreignId)
{
var audiobook = _audiobookService.FindByForeignId(foreignId);
if (audiobook == null)
{
return NotFound();
}
return audiobook.ToResource();
}
[HttpGet("narrator")]
[Produces("application/json")]
public IEnumerable<AudiobookResource> SearchByNarrator(string narrator)
{
var audiobooks = _audiobookService.FindByNarrator(narrator);
return audiobooks.ToResource();
}
[HttpGet]
[Produces("application/json")]
public IEnumerable<AudiobookResource> Search([FromQuery] string term)
{
// For now, search in local database by title or narrator
// Future: integrate with metadata provider
var allAudiobooks = _audiobookService.GetAllAudiobooks();
var results = new List<AudiobookResource>();
foreach (var audiobook in allAudiobooks)
{
var matchesTitle = audiobook.Title != null &&
audiobook.Title.Contains(term, StringComparison.OrdinalIgnoreCase);
var matchesNarrator = audiobook.Narrator != null &&
audiobook.Narrator.Contains(term, StringComparison.OrdinalIgnoreCase);
if (matchesTitle || matchesNarrator)
{
results.Add(audiobook.ToResource());
}
}
return results;
}
}
}

View file

@ -0,0 +1,130 @@
using System.Collections.Generic;
using FluentValidation;
using Microsoft.AspNetCore.Mvc;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Authors;
using NzbDrone.Core.Datastore.Events;
using NzbDrone.Core.RootFolders;
using NzbDrone.Core.Validation;
using NzbDrone.Core.Validation.Paths;
using NzbDrone.SignalR;
using Radarr.Http;
using Radarr.Http.REST;
using Radarr.Http.REST.Attributes;
namespace Radarr.Api.V3.Authors
{
[V3ApiController]
public class AuthorController : RestControllerWithSignalR<AuthorResource, Author>
{
private readonly IAuthorService _authorService;
private readonly IRootFolderService _rootFolderService;
public AuthorController(IBroadcastSignalRMessage signalRBroadcaster,
IAuthorService authorService,
IRootFolderService rootFolderService,
RootFolderValidator rootFolderValidator,
MappedNetworkDriveValidator mappedNetworkDriveValidator,
RecycleBinValidator recycleBinValidator,
SystemFolderValidator systemFolderValidator,
QualityProfileExistsValidator qualityProfileExistsValidator,
RootFolderExistsValidator rootFolderExistsValidator)
: base(signalRBroadcaster)
{
_authorService = authorService;
_rootFolderService = rootFolderService;
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.Name).NotEmpty();
}
[HttpGet]
public List<AuthorResource> GetAuthors()
{
var authors = _authorService.GetAllAuthors();
var resources = authors.ToResource();
var rootFolders = _rootFolderService.All();
foreach (var resource in resources)
{
resource.RootFolderPath = _rootFolderService.GetBestRootFolderPath(resource.Path, rootFolders);
}
return resources;
}
protected override AuthorResource GetResourceById(int id)
{
var author = _authorService.GetAuthor(id);
return MapToResource(author);
}
private AuthorResource MapToResource(Author author)
{
if (author == null)
{
return null;
}
var resource = author.ToResource();
resource.RootFolderPath = _rootFolderService.GetBestRootFolderPath(resource.Path);
return resource;
}
[RestPostById]
[Consumes("application/json")]
[Produces("application/json")]
public ActionResult<AuthorResource> AddAuthor([FromBody] AuthorResource authorResource)
{
var author = _authorService.AddAuthor(authorResource.ToModel());
return Created(author.Id);
}
[RestPutById]
[Consumes("application/json")]
[Produces("application/json")]
public ActionResult<AuthorResource> UpdateAuthor([FromBody] AuthorResource authorResource)
{
var author = _authorService.GetAuthor(authorResource.Id);
var updatedAuthor = _authorService.UpdateAuthor(authorResource.ToModel(author));
var resource = MapToResource(updatedAuthor);
BroadcastResourceChange(ModelAction.Updated, resource);
return Ok(resource);
}
[RestDeleteById]
public ActionResult DeleteAuthor(int id, bool deleteFiles = false)
{
_authorService.DeleteAuthor(id, deleteFiles);
return NoContent();
}
}
}

View file

@ -0,0 +1,102 @@
using System;
using System.Collections.Generic;
using System.Linq;
using NzbDrone.Core.Authors;
using Radarr.Http.REST;
namespace Radarr.Api.V3.Authors
{
public class AuthorResource : RestResource
{
public AuthorResource()
{
Monitored = true;
}
public string Name { get; set; }
public string SortName { get; set; }
public string Description { get; set; }
public string ForeignAuthorId { get; set; }
public bool Monitored { get; set; }
public string Path { get; set; }
public string RootFolderPath { get; set; }
public int QualityProfileId { get; set; }
public DateTime Added { get; set; }
public HashSet<int> Tags { get; set; }
}
public static class AuthorResourceMapper
{
public static AuthorResource ToResource(this Author model)
{
if (model == null)
{
return null;
}
return new AuthorResource
{
Id = model.Id,
Name = model.Name,
SortName = model.SortName,
Description = model.Description,
ForeignAuthorId = model.ForeignAuthorId,
Monitored = model.Monitored,
Path = model.Path,
RootFolderPath = model.RootFolderPath,
QualityProfileId = model.QualityProfileId,
Added = model.Added,
Tags = model.Tags
};
}
public static Author ToModel(this AuthorResource resource)
{
if (resource == null)
{
return null;
}
return new Author
{
Id = resource.Id,
Name = resource.Name,
SortName = resource.SortName,
Description = resource.Description,
ForeignAuthorId = resource.ForeignAuthorId,
Monitored = resource.Monitored,
Path = resource.Path,
RootFolderPath = resource.RootFolderPath,
QualityProfileId = resource.QualityProfileId,
Tags = resource.Tags ?? new HashSet<int>()
};
}
public static Author ToModel(this AuthorResource resource, Author author)
{
var updatedAuthor = resource.ToModel();
author.Name = updatedAuthor.Name;
author.SortName = updatedAuthor.SortName;
author.Description = updatedAuthor.Description;
author.ForeignAuthorId = updatedAuthor.ForeignAuthorId;
author.Monitored = updatedAuthor.Monitored;
author.Path = updatedAuthor.Path;
author.RootFolderPath = updatedAuthor.RootFolderPath;
author.QualityProfileId = updatedAuthor.QualityProfileId;
author.Tags = updatedAuthor.Tags;
return author;
}
public static List<AuthorResource> ToResource(this IEnumerable<Author> authors)
{
return authors.Select(ToResource).ToList();
}
public static List<Author> ToModel(this IEnumerable<AuthorResource> resources)
{
return resources.Select(ToModel).ToList();
}
}
}

View file

@ -0,0 +1,92 @@
using System.Collections.Generic;
using FluentValidation;
using Microsoft.AspNetCore.Mvc;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Books;
using Radarr.Http;
namespace Radarr.Api.V3.Books
{
[V3ApiController("book/editor")]
public class BookEditorController : Controller
{
private readonly IBookService _bookService;
private readonly BookEditorValidator _bookEditorValidator;
public BookEditorController(IBookService bookService,
BookEditorValidator bookEditorValidator)
{
_bookService = bookService;
_bookEditorValidator = bookEditorValidator;
}
[HttpPut]
public IActionResult SaveAll([FromBody] BookEditorResource resource)
{
var booksToUpdate = _bookService.GetBooks(resource.BookIds);
foreach (var book in booksToUpdate)
{
if (resource.Monitored.HasValue)
{
book.Monitored = resource.Monitored.Value;
}
if (resource.QualityProfileId.HasValue)
{
book.QualityProfileId = resource.QualityProfileId.Value;
}
if (resource.RootFolderPath.IsNotNullOrWhiteSpace())
{
book.RootFolderPath = resource.RootFolderPath;
}
if (resource.Tags != null)
{
var newTags = resource.Tags;
var applyTags = resource.ApplyTags;
switch (applyTags)
{
case ApplyTags.Add:
newTags.ForEach(t => book.Tags.Add(t));
break;
case ApplyTags.Remove:
newTags.ForEach(t => book.Tags.Remove(t));
break;
case ApplyTags.Replace:
book.Tags = new HashSet<int>(newTags);
break;
}
}
var validationResult = _bookEditorValidator.Validate(book);
if (!validationResult.IsValid)
{
throw new ValidationException(validationResult.Errors);
}
}
var updatedBooks = _bookService.UpdateBooks(booksToUpdate);
var booksResources = new List<BookResource>(updatedBooks.Count);
foreach (var book in updatedBooks)
{
booksResources.Add(book.ToResource());
}
return Ok(booksResources);
}
[HttpDelete]
public object DeleteBooks([FromBody] BookEditorResource resource)
{
_bookService.DeleteBooks(resource.BookIds, resource.DeleteFiles);
return new { };
}
}
}

View file

@ -0,0 +1,16 @@
using System.Collections.Generic;
namespace Radarr.Api.V3.Books
{
public class BookEditorResource
{
public List<int> BookIds { get; set; }
public bool? Monitored { get; set; }
public int? QualityProfileId { 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; }
}
}

View file

@ -0,0 +1,23 @@
using FluentValidation;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Books;
using NzbDrone.Core.Validation;
using NzbDrone.Core.Validation.Paths;
namespace Radarr.Api.V3.Books
{
public class BookEditorValidator : AbstractValidator<Book>
{
public BookEditorValidator(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

@ -0,0 +1,104 @@
using System;
using System.Collections.Generic;
using Microsoft.AspNetCore.Mvc;
using NzbDrone.Core.Books;
using Radarr.Http;
using Radarr.Http.REST;
namespace Radarr.Api.V3.Books
{
[V3ApiController("book/lookup")]
public class BookLookupController : RestController<BookResource>
{
private readonly IBookService _bookService;
public BookLookupController(IBookService bookService)
{
_bookService = bookService;
}
[NonAction]
public override ActionResult<BookResource> GetResourceByIdWithErrorHandler(int id)
{
throw new NotImplementedException();
}
protected override BookResource GetResourceById(int id)
{
throw new NotImplementedException();
}
[HttpGet("isbn")]
[Produces("application/json")]
public ActionResult<BookResource> SearchByIsbn(string isbn)
{
var book = _bookService.FindByIsbn(isbn);
if (book == null)
{
return NotFound();
}
return book.ToResource();
}
[HttpGet("isbn13")]
[Produces("application/json")]
public ActionResult<BookResource> SearchByIsbn13(string isbn13)
{
var book = _bookService.FindByIsbn13(isbn13);
if (book == null)
{
return NotFound();
}
return book.ToResource();
}
[HttpGet("asin")]
[Produces("application/json")]
public ActionResult<BookResource> SearchByAsin(string asin)
{
var book = _bookService.FindByAsin(asin);
if (book == null)
{
return NotFound();
}
return book.ToResource();
}
[HttpGet("foreignid")]
[Produces("application/json")]
public ActionResult<BookResource> SearchByForeignId(string foreignId)
{
var book = _bookService.FindByForeignId(foreignId);
if (book == null)
{
return NotFound();
}
return book.ToResource();
}
[HttpGet]
[Produces("application/json")]
public IEnumerable<BookResource> Search([FromQuery] string term)
{
// For now, search in local database by title
// Future: integrate with metadata provider
var allBooks = _bookService.GetAllBooks();
var results = new List<BookResource>();
foreach (var book in allBooks)
{
if (book.Title != null &&
book.Title.Contains(term, StringComparison.OrdinalIgnoreCase))
{
results.Add(book.ToResource());
}
}
return results;
}
}
}

View file

@ -0,0 +1,81 @@
using System.Collections.Generic;
using FluentValidation;
using Microsoft.AspNetCore.Mvc;
using NzbDrone.Core.Datastore.Events;
using NzbDrone.Core.Series;
using NzbDrone.SignalR;
using Radarr.Http;
using Radarr.Http.REST;
using Radarr.Http.REST.Attributes;
using SeriesModel = NzbDrone.Core.Series.Series;
namespace Radarr.Api.V3.Series
{
[V3ApiController]
public class SeriesController : RestControllerWithSignalR<SeriesResource, SeriesModel>
{
private readonly ISeriesService _seriesService;
public SeriesController(IBroadcastSignalRMessage signalRBroadcaster,
ISeriesService seriesService)
: base(signalRBroadcaster)
{
_seriesService = seriesService;
PostValidator.RuleFor(s => s.Title).NotEmpty();
}
[HttpGet]
public List<SeriesResource> GetSeries(int? authorId = null)
{
List<SeriesModel> seriesList;
if (authorId.HasValue)
{
seriesList = _seriesService.FindByAuthorId(authorId.Value);
}
else
{
seriesList = _seriesService.GetAllSeries();
}
return seriesList.ToResource();
}
protected override SeriesResource GetResourceById(int id)
{
var series = _seriesService.GetSeries(id);
return series?.ToResource();
}
[RestPostById]
[Consumes("application/json")]
[Produces("application/json")]
public ActionResult<SeriesResource> AddSeries([FromBody] SeriesResource seriesResource)
{
var series = _seriesService.AddSeries(seriesResource.ToModel());
return Created(series.Id);
}
[RestPutById]
[Consumes("application/json")]
[Produces("application/json")]
public ActionResult<SeriesResource> UpdateSeries([FromBody] SeriesResource seriesResource)
{
var series = _seriesService.GetSeries(seriesResource.Id);
var updatedSeries = _seriesService.UpdateSeries(seriesResource.ToModel(series));
var resource = updatedSeries.ToResource();
BroadcastResourceChange(ModelAction.Updated, resource);
return Ok(resource);
}
[RestDeleteById]
public ActionResult DeleteSeries(int id)
{
_seriesService.DeleteSeries(id);
return NoContent();
}
}
}

View file

@ -0,0 +1,87 @@
using System.Collections.Generic;
using System.Linq;
using Radarr.Http.REST;
using SeriesModel = NzbDrone.Core.Series.Series;
namespace Radarr.Api.V3.Series
{
public class SeriesResource : RestResource
{
public SeriesResource()
{
Monitored = true;
}
public string Title { get; set; }
public string SortTitle { get; set; }
public string Description { get; set; }
public string ForeignSeriesId { get; set; }
public int? AuthorId { get; set; }
public bool Monitored { get; set; }
}
public static class SeriesResourceMapper
{
public static SeriesResource ToResource(this SeriesModel model)
{
if (model == null)
{
return null;
}
return new SeriesResource
{
Id = model.Id,
Title = model.Title,
SortTitle = model.SortTitle,
Description = model.Description,
ForeignSeriesId = model.ForeignSeriesId,
AuthorId = model.AuthorId,
Monitored = model.Monitored
};
}
public static SeriesModel ToModel(this SeriesResource resource)
{
if (resource == null)
{
return null;
}
return new SeriesModel
{
Id = resource.Id,
Title = resource.Title,
SortTitle = resource.SortTitle,
Description = resource.Description,
ForeignSeriesId = resource.ForeignSeriesId,
AuthorId = resource.AuthorId,
Monitored = resource.Monitored
};
}
public static SeriesModel ToModel(this SeriesResource resource, SeriesModel series)
{
var updatedSeries = resource.ToModel();
series.Title = updatedSeries.Title;
series.SortTitle = updatedSeries.SortTitle;
series.Description = updatedSeries.Description;
series.ForeignSeriesId = updatedSeries.ForeignSeriesId;
series.AuthorId = updatedSeries.AuthorId;
series.Monitored = updatedSeries.Monitored;
return series;
}
public static List<SeriesResource> ToResource(this IEnumerable<SeriesModel> seriesList)
{
return seriesList.Select(ToResource).ToList();
}
public static List<SeriesModel> ToModel(this IEnumerable<SeriesResource> resources)
{
return resources.Select(ToModel).ToList();
}
}
}