From f6a04f7890b9bec0daef65f26d6269b2192290db Mon Sep 17 00:00:00 2001 From: ta264 Date: Wed, 26 May 2021 22:09:31 +0100 Subject: [PATCH] New: Basic audiobook support --- .../MetadataProvider/MetadataProvider.js | 42 ++++++++- .../TrackImport/ImportDecisionMakerFixture.cs | 4 + .../FileNameBuilderFixture.cs | 2 + .../GetAudioFilesFixture.cs | 4 +- .../Migration/010_add_bookfile_part.cs | 14 +++ src/NzbDrone.Core/Datastore/TableMapping.cs | 1 + .../NewznabCategoryFieldOptionsConverter.cs | 11 ++- .../Indexers/Newznab/NewznabSettings.cs | 2 +- src/NzbDrone.Core/Localization/Core/en.json | 10 +- .../MediaFiles/AudioTagService.cs | 79 +++++++++++++--- src/NzbDrone.Core/MediaFiles/BookFile.cs | 4 + .../BookImport/ImportApprovedBooks.cs | 4 +- .../BookImport/ImportDecisionMaker.cs | 15 +-- .../BookImport/Manual/ManualImportService.cs | 13 +-- .../MediaFiles/DiskScanService.cs | 2 + .../MediaFiles/EbookTagService.cs | 22 ++--- .../MediaFiles/MediaFileExtensions.cs | 16 ++++ .../MediaFiles/MetadataTagService.cs | 92 +++++++++++++++++++ .../MediaFiles/RenameBookFileService.cs | 7 +- .../Organizer/FileNameBuilder.cs | 5 + src/NzbDrone.Core/Parser/Model/LocalBook.cs | 2 + .../Parser/Model/LocalEdition.cs | 1 + .../BookFiles/BookFileController.cs | 8 +- .../Books/RetagBookController.cs | 10 +- .../Config/MetadataProviderConfigResource.cs | 4 + 25 files changed, 316 insertions(+), 58 deletions(-) create mode 100644 src/NzbDrone.Core/Datastore/Migration/010_add_bookfile_part.cs create mode 100644 src/NzbDrone.Core/MediaFiles/MetadataTagService.cs diff --git a/frontend/src/Settings/Metadata/MetadataProvider/MetadataProvider.js b/frontend/src/Settings/Metadata/MetadataProvider/MetadataProvider.js index 99e631463..1e80619c9 100644 --- a/frontend/src/Settings/Metadata/MetadataProvider/MetadataProvider.js +++ b/frontend/src/Settings/Metadata/MetadataProvider/MetadataProvider.js @@ -9,10 +9,17 @@ import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import { inputTypes } from 'Helpers/Props'; import translate from 'Utilities/String/translate'; +const writeAudioTagOptions = [ + { key: 'no', value: translate('WriteTagsNo') }, + { key: 'sync', value: translate('WriteTagsSync') }, + { key: 'allFiles', value: translate('WriteTagsAll') }, + { key: 'newFiles', value: translate('WriteTagsNew') } +]; + const writeBookTagOptions = [ - { key: 'sync', value: 'All files; keep in sync with Goodreads' }, - { key: 'allFiles', value: 'All files; initial import only' }, - { key: 'newFiles', value: 'For new downloads only' } + { key: 'sync', value: translate('WriteTagsSync') }, + { key: 'allFiles', value: translate('WriteTagsAll') }, + { key: 'newFiles', value: translate('WriteTagsNew') } ]; function MetadataProvider(props) { @@ -88,6 +95,35 @@ function MetadataProvider(props) { + +
+ + {translate('WriteAudioTags')} + + + + + + {translate('WriteAudioTagsScrub')} + + + + +
} diff --git a/src/NzbDrone.Core.Test/MediaFiles/TrackImport/ImportDecisionMakerFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/TrackImport/ImportDecisionMakerFixture.cs index 09d384670..1a5be8274 100644 --- a/src/NzbDrone.Core.Test/MediaFiles/TrackImport/ImportDecisionMakerFixture.cs +++ b/src/NzbDrone.Core.Test/MediaFiles/TrackImport/ImportDecisionMakerFixture.cs @@ -130,6 +130,10 @@ namespace NzbDrone.Core.Test.MediaFiles.BookImport .Setup(c => c.FilterUnchangedFiles(It.IsAny>(), It.IsAny())) .Returns((List files, FilterFilesType filter) => files); + Mocker.GetMock() + .Setup(s => s.ReadTags(It.IsAny())) + .Returns(new ParsedTrackInfo()); + GivenSpecifications(_bookpass1); } diff --git a/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/FileNameBuilderFixture.cs b/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/FileNameBuilderFixture.cs index 181ad5cb1..bb02f7bda 100644 --- a/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/FileNameBuilderFixture.cs +++ b/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/FileNameBuilderFixture.cs @@ -54,6 +54,8 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests .Setup(c => c.GetConfig()).Returns(_namingConfig); _trackFile = Builder.CreateNew() + .With(e => e.Part = 1) + .With(e => e.PartCount = 1) .With(e => e.Quality = new QualityModel(Quality.MP3_320)) .With(e => e.ReleaseGroup = "ReadarrTest") .With(e => e.MediaInfo = new Parser.Model.MediaInfoModel diff --git a/src/NzbDrone.Core.Test/ProviderTests/DiskScanProviderTests/GetAudioFilesFixture.cs b/src/NzbDrone.Core.Test/ProviderTests/DiskScanProviderTests/GetAudioFilesFixture.cs index 719eb3544..f578ee770 100644 --- a/src/NzbDrone.Core.Test/ProviderTests/DiskScanProviderTests/GetAudioFilesFixture.cs +++ b/src/NzbDrone.Core.Test/ProviderTests/DiskScanProviderTests/GetAudioFilesFixture.cs @@ -83,11 +83,11 @@ namespace NzbDrone.Core.Test.ProviderTests.DiskScanProviderTests } [Test] - public void should_return_audio_files_only() + public void should_return_book_files_only() { GivenFiles(GetFiles(_path)); - Subject.GetBookFiles(_path).Should().HaveCount(3); + Subject.GetBookFiles(_path).Should().HaveCount(5); } [TestCase("Extras")] diff --git a/src/NzbDrone.Core/Datastore/Migration/010_add_bookfile_part.cs b/src/NzbDrone.Core/Datastore/Migration/010_add_bookfile_part.cs new file mode 100644 index 000000000..e10b51a23 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/010_add_bookfile_part.cs @@ -0,0 +1,14 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(010)] + public class add_bookfile_part : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Alter.Table("BookFiles").AddColumn("Part").AsInt32().NotNullable().WithDefaultValue(1); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/TableMapping.cs b/src/NzbDrone.Core/Datastore/TableMapping.cs index dc1ba9d33..c1d7e7320 100644 --- a/src/NzbDrone.Core/Datastore/TableMapping.cs +++ b/src/NzbDrone.Core/Datastore/TableMapping.cs @@ -150,6 +150,7 @@ namespace NzbDrone.Core.Datastore b => b.Id > 0); Mapper.Entity("BookFiles").RegisterModel() + .Ignore(x => x.PartCount) .HasOne(f => f.Edition, f => f.EditionId) .LazyLoad(x => x.Author, (db, f) => AuthorRepository.Query(db, diff --git a/src/NzbDrone.Core/Indexers/Newznab/NewznabCategoryFieldOptionsConverter.cs b/src/NzbDrone.Core/Indexers/Newznab/NewznabCategoryFieldOptionsConverter.cs index b717e06e0..3082f02de 100644 --- a/src/NzbDrone.Core/Indexers/Newznab/NewznabCategoryFieldOptionsConverter.cs +++ b/src/NzbDrone.Core/Indexers/Newznab/NewznabCategoryFieldOptionsConverter.cs @@ -10,7 +10,7 @@ namespace NzbDrone.Core.Indexers.Newznab public static List GetFieldSelectOptions(List categories) { // Ignore categories not relevant for Readarr - var ignoreCategories = new[] { 1000, 2000, 3000, 4000, 5000, 6000 }; + var ignoreCategories = new[] { 1000, 2000, 4000, 5000, 6000 }; // And maybe relevant for specific users var unimportantCategories = new[] { 0, 8000 }; @@ -22,6 +22,15 @@ namespace NzbDrone.Core.Indexers.Newznab // Fetching categories failed, use default Newznab categories categories = new List(); categories.Add(new NewznabCategory + { + Id = 3000, + Name = "Audio", + Subcategories = new List + { + new NewznabCategory { Id = 3030, Name = "Audiobook" } + } + }); + categories.Add(new NewznabCategory { Id = 7000, Name = "Books", diff --git a/src/NzbDrone.Core/Indexers/Newznab/NewznabSettings.cs b/src/NzbDrone.Core/Indexers/Newznab/NewznabSettings.cs index e81b6d1dd..984c8d4cc 100644 --- a/src/NzbDrone.Core/Indexers/Newznab/NewznabSettings.cs +++ b/src/NzbDrone.Core/Indexers/Newznab/NewznabSettings.cs @@ -58,7 +58,7 @@ namespace NzbDrone.Core.Indexers.Newznab public NewznabSettings() { ApiPath = "/api"; - Categories = new[] { 7020, 8010 }; + Categories = new[] { 3030, 7020, 8010 }; } [FieldDefinition(0, Label = "URL")] diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index 1d68fd762..5c89a1e7e 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -38,6 +38,7 @@ "ApplyTagsHelpTexts2": "Add: Add the tags the existing list of tags", "ApplyTagsHelpTexts3": "Remove: Remove the entered tags", "ApplyTagsHelpTexts4": "Replace: Replace the tags with the entered tags (enter no tags to clear all tags)", + "AudioFileMetadata": "Write Metadata to Audio Files", "Authentication": "Authentication", "AuthenticationMethodHelpText": "Require Username and Password to access Readarr", "Author": "Author", @@ -351,7 +352,7 @@ "MinimumFreeSpaceWhenImportingHelpText": "Prevent import if it would leave less than this amount of disk space available", "MinimumLimits": "Minimum Limits", "MinimumPages": "Minimum Pages", - "MinPagesHelpText": "Ignore books with fewer pages than this", + "MinPagesHelpText": "Ignore books with fewer pages than this", "MinimumPopularity": "Minimum Popularity", "Missing": "Missing", "MissingBooks": "Missing Books", @@ -691,7 +692,14 @@ "WatchLibraryForChangesHelpText": "Rescan automatically when files change in a root folder", "WatchRootFoldersForFileChanges": "Watch Root Folders for file changes", "WeekColumnHeader": "Week Column Header", + "WriteAudioTags": "Tag Audio Files with Metadata", + "WriteAudioTagsScrub": "Scrub Existing Tags", + "WriteAudioTagsScrubHelp": "Remove existing tags from files, leaving only those added by Readarr.", "WriteBookTagsHelpTextWarning": "Selecting 'All files' will alter existing files when they are imported.", + "WriteTagsAll": "All files; initial import only", + "WriteTagsNew": "For new downloads only", + "WriteTagsNo": "Never", + "WriteTagsSync": "All files; keep in sync with Goodreads", "Year": "Year", "YesCancel": "Yes, Cancel" } diff --git a/src/NzbDrone.Core/MediaFiles/AudioTagService.cs b/src/NzbDrone.Core/MediaFiles/AudioTagService.cs index b4ab5cbd1..2b9740c68 100644 --- a/src/NzbDrone.Core/MediaFiles/AudioTagService.cs +++ b/src/NzbDrone.Core/MediaFiles/AudioTagService.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.IO; using System.Linq; using NLog; using NLog.Fluent; @@ -24,12 +25,12 @@ namespace NzbDrone.Core.MediaFiles void WriteTags(BookFile trackfile, bool newDownload, bool force = false); void SyncTags(List tracks); List GetRetagPreviewsByAuthor(int authorId); - List GetRetagPreviewsByBook(int authorId); + List GetRetagPreviewsByBook(int bookId); + void RetagFiles(RetagFilesCommand message); + void RetagAuthor(RetagAuthorCommand message); } - public class AudioTagService : IAudioTagService, - IExecute, - IExecute + public class AudioTagService : IAudioTagService { private readonly IConfigService _configService; private readonly IMediaFileService _mediaFileService; @@ -71,7 +72,52 @@ namespace NzbDrone.Core.MediaFiles public AudioTag GetTrackMetadata(BookFile trackfile) { - return new AudioTag(); + var edition = trackfile.Edition.Value; + var book = edition.Book.Value; + var author = book.Author.Value; + + var fileTags = ReadAudioTag(trackfile.Path); + + var cover = edition.Images.FirstOrDefault(x => x.CoverType == MediaCoverTypes.Cover); + string imageFile = null; + long imageSize = 0; + if (cover != null) + { + imageFile = _mediaCoverService.GetCoverPath(book.Id, MediaCoverEntity.Book, cover.CoverType, cover.Extension, null); + _logger.Trace($"Embedding: {imageFile}"); + var fileInfo = _diskProvider.GetFileInfo(imageFile); + if (fileInfo.Exists) + { + imageSize = fileInfo.Length; + } + else + { + imageFile = null; + } + } + + return new AudioTag + { + Title = edition.Title, + Performers = new[] { author.Name }, + BookAuthors = new[] { author.Name }, + Track = fileTags.Track, + TrackCount = fileTags.TrackCount, + Book = book.Title, + Disc = fileTags.Disc, + DiscCount = fileTags.DiscCount, + + // We may have omitted media so index in the list isn't the same as medium number + Media = fileTags.Media, + Date = edition.ReleaseDate, + Year = (uint)edition.ReleaseDate?.Year, + OriginalReleaseDate = book.ReleaseDate, + OriginalYear = (uint)book.ReleaseDate?.Year, + Publisher = edition.Publisher, + Genres = new string[0], + ImageFile = imageFile, + ImageSize = imageSize, + }; } private void UpdateTrackfileSizeAndModified(BookFile trackfile, string path) @@ -187,7 +233,7 @@ namespace NzbDrone.Core.MediaFiles private IEnumerable GetPreviews(List files) { - foreach (var f in files.OrderBy(x => x.Edition.Value.Title)) + foreach (var f in files.Where(x => MediaFileExtensions.AudioExtensions.Contains(Path.GetExtension(x.Path))).OrderBy(x => x.Edition.Value.Title)) { var file = f; @@ -215,35 +261,38 @@ namespace NzbDrone.Core.MediaFiles } } - public void Execute(RetagFilesCommand message) + public void RetagFiles(RetagFilesCommand message) { var author = _authorService.GetAuthor(message.AuthorId); var bookFiles = _mediaFileService.Get(message.Files); + var audioFiles = bookFiles.Where(x => MediaFileExtensions.AudioExtensions.Contains(Path.GetExtension(x.Path))).ToList(); - _logger.ProgressInfo("Re-tagging {0} files for {1}", bookFiles.Count, author.Name); - foreach (var file in bookFiles) + _logger.ProgressInfo("Re-tagging {0} audio files for {1}", audioFiles.Count, author.Name); + foreach (var file in audioFiles) { WriteTags(file, false, force: true); } - _logger.ProgressInfo("Selected track files re-tagged for {0}", author.Name); + _logger.ProgressInfo("Selected audio files re-tagged for {0}", author.Name); } - public void Execute(RetagAuthorCommand message) + public void RetagAuthor(RetagAuthorCommand message) { - _logger.Debug("Re-tagging all files for selected authors"); + _logger.Debug("Re-tagging all audio files for selected authors"); var authorToRename = _authorService.GetAuthors(message.AuthorIds); foreach (var author in authorToRename) { var bookFiles = _mediaFileService.GetFilesByAuthor(author.Id); - _logger.ProgressInfo("Re-tagging all files for author: {0}", author.Name); - foreach (var file in bookFiles) + var audioFiles = bookFiles.Where(x => MediaFileExtensions.AudioExtensions.Contains(Path.GetExtension(x.Path))).ToList(); + + _logger.ProgressInfo("Re-tagging all audio files for author: {0}", author.Name); + foreach (var file in audioFiles) { WriteTags(file, false, force: true); } - _logger.ProgressInfo("All track files re-tagged for {0}", author.Name); + _logger.ProgressInfo("All audio files re-tagged for {0}", author.Name); } } } diff --git a/src/NzbDrone.Core/MediaFiles/BookFile.cs b/src/NzbDrone.Core/MediaFiles/BookFile.cs index 7fcea767c..56f2ee6c3 100644 --- a/src/NzbDrone.Core/MediaFiles/BookFile.cs +++ b/src/NzbDrone.Core/MediaFiles/BookFile.cs @@ -21,11 +21,15 @@ namespace NzbDrone.Core.MediaFiles public MediaInfoModel MediaInfo { get; set; } public int EditionId { get; set; } public int CalibreId { get; set; } + public int Part { get; set; } // These are queried from the database public LazyLoaded Author { get; set; } public LazyLoaded Edition { get; set; } + // Calculated manually + public int PartCount { get; set; } + public override string ToString() { return string.Format("[{0}] {1}", Id, Path); diff --git a/src/NzbDrone.Core/MediaFiles/BookImport/ImportApprovedBooks.cs b/src/NzbDrone.Core/MediaFiles/BookImport/ImportApprovedBooks.cs index 6a2a01262..18acb5e6f 100644 --- a/src/NzbDrone.Core/MediaFiles/BookImport/ImportApprovedBooks.cs +++ b/src/NzbDrone.Core/MediaFiles/BookImport/ImportApprovedBooks.cs @@ -153,7 +153,7 @@ namespace NzbDrone.Core.MediaFiles.BookImport try { //check if already imported - if (importResults.Select(r => r.ImportDecision.Item.Book.Id).Contains(localTrack.Book.Id)) + if (importResults.Where(r => r.ImportDecision.Item.Book.Id == localTrack.Book.Id).Any(r => r.ImportDecision.Item.Part == localTrack.Part)) { importResults.Add(new ImportResult(importDecision, "Book has already been imported")); continue; @@ -165,6 +165,8 @@ namespace NzbDrone.Core.MediaFiles.BookImport { Path = localTrack.Path.CleanFilePath(), CalibreId = localTrack.CalibreId, + Part = localTrack.Part, + PartCount = localTrack.PartCount, Size = localTrack.Size, Modified = localTrack.Modified, DateAdded = DateTime.UtcNow, diff --git a/src/NzbDrone.Core/MediaFiles/BookImport/ImportDecisionMaker.cs b/src/NzbDrone.Core/MediaFiles/BookImport/ImportDecisionMaker.cs index a9d742fe2..3a83dc367 100644 --- a/src/NzbDrone.Core/MediaFiles/BookImport/ImportDecisionMaker.cs +++ b/src/NzbDrone.Core/MediaFiles/BookImport/ImportDecisionMaker.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.IO; using System.IO.Abstractions; using System.Linq; using NLog; @@ -48,8 +49,7 @@ namespace NzbDrone.Core.MediaFiles.BookImport private readonly IEnumerable> _trackSpecifications; private readonly IEnumerable> _bookSpecifications; private readonly IMediaFileService _mediaFileService; - private readonly IEBookTagService _eBookTagService; - private readonly IAudioTagService _audioTagService; + private readonly IMetadataTagService _metadataTagService; private readonly IAugmentingService _augmentingService; private readonly IIdentificationService _identificationService; private readonly IRootFolderService _rootFolderService; @@ -59,8 +59,7 @@ namespace NzbDrone.Core.MediaFiles.BookImport public ImportDecisionMaker(IEnumerable> trackSpecifications, IEnumerable> bookSpecifications, IMediaFileService mediaFileService, - IEBookTagService eBookTagService, - IAudioTagService audioTagService, + IMetadataTagService metadataTagService, IAugmentingService augmentingService, IIdentificationService identificationService, IRootFolderService rootFolderService, @@ -70,8 +69,7 @@ namespace NzbDrone.Core.MediaFiles.BookImport _trackSpecifications = trackSpecifications; _bookSpecifications = bookSpecifications; _mediaFileService = mediaFileService; - _eBookTagService = eBookTagService; - _audioTagService = audioTagService; + _metadataTagService = metadataTagService; _augmentingService = augmentingService; _identificationService = identificationService; _rootFolderService = rootFolderService; @@ -108,14 +106,17 @@ namespace NzbDrone.Core.MediaFiles.BookImport { _logger.ProgressInfo($"Reading file {i++}/{files.Count}"); + var fileTrackInfo = _metadataTagService.ReadTags(file); + var localTrack = new LocalBook { DownloadClientBookInfo = downloadClientItemInfo, FolderTrackInfo = folderInfo, Path = file.FullName, + Part = fileTrackInfo.TrackNumbers.Any() ? fileTrackInfo.TrackNumbers.First() : 1, Size = file.Length, Modified = file.LastWriteTimeUtc, - FileTrackInfo = _eBookTagService.ReadTags(file), + FileTrackInfo = fileTrackInfo, AdditionalFile = false }; diff --git a/src/NzbDrone.Core/MediaFiles/BookImport/Manual/ManualImportService.cs b/src/NzbDrone.Core/MediaFiles/BookImport/Manual/ManualImportService.cs index c059c435f..9de8f0906 100644 --- a/src/NzbDrone.Core/MediaFiles/BookImport/Manual/ManualImportService.cs +++ b/src/NzbDrone.Core/MediaFiles/BookImport/Manual/ManualImportService.cs @@ -40,7 +40,7 @@ namespace NzbDrone.Core.MediaFiles.BookImport.Manual private readonly IBookService _bookService; private readonly IEditionService _editionService; private readonly IProvideBookInfo _bookInfo; - private readonly IAudioTagService _audioTagService; + private readonly IMetadataTagService _metadataTagService; private readonly IImportApprovedBooks _importApprovedBooks; private readonly ITrackedDownloadService _trackedDownloadService; private readonly IDownloadedBooksImportService _downloadedTracksImportService; @@ -57,7 +57,7 @@ namespace NzbDrone.Core.MediaFiles.BookImport.Manual IBookService bookService, IEditionService editionService, IProvideBookInfo bookInfo, - IAudioTagService audioTagService, + IMetadataTagService metadataTagService, IImportApprovedBooks importApprovedBooks, ITrackedDownloadService trackedDownloadService, IDownloadedBooksImportService downloadedTracksImportService, @@ -74,7 +74,7 @@ namespace NzbDrone.Core.MediaFiles.BookImport.Manual _bookService = bookService; _editionService = editionService; _bookInfo = bookInfo; - _audioTagService = audioTagService; + _metadataTagService = metadataTagService; _importApprovedBooks = importApprovedBooks; _trackedDownloadService = trackedDownloadService; _downloadedTracksImportService = downloadedTracksImportService; @@ -312,16 +312,17 @@ namespace NzbDrone.Core.MediaFiles.BookImport.Manual edition = tuple.Item2.Editions.Value.SingleOrDefault(x => x.ForeignEditionId == file.ForeignEditionId); } - var fileTrackInfo = _audioTagService.ReadTags(file.Path) ?? new ParsedTrackInfo(); - var fileInfo = _diskProvider.GetFileInfo(file.Path); - var fileRootFolder = _rootFolderService.GetBestRootFolder(file.Path); + var fileInfo = _diskProvider.GetFileInfo(file.Path); + var fileTrackInfo = _metadataTagService.ReadTags(fileInfo) ?? new ParsedTrackInfo(); var localTrack = new LocalBook { ExistingFile = fileRootFolder != null, FileTrackInfo = fileTrackInfo, Path = file.Path, + Part = fileTrackInfo.TrackNumbers.Any() ? fileTrackInfo.TrackNumbers.First() : 1, + PartCount = importBookId.Count(), Size = fileInfo.Length, Modified = fileInfo.LastWriteTimeUtc, Quality = file.Quality, diff --git a/src/NzbDrone.Core/MediaFiles/DiskScanService.cs b/src/NzbDrone.Core/MediaFiles/DiskScanService.cs index 670631f85..6ec75457c 100644 --- a/src/NzbDrone.Core/MediaFiles/DiskScanService.cs +++ b/src/NzbDrone.Core/MediaFiles/DiskScanService.cs @@ -181,6 +181,8 @@ namespace NzbDrone.Core.MediaFiles { Path = decision.Item.Path, CalibreId = decision.Item.CalibreId, + Part = decision.Item.Part, + PartCount = decision.Item.PartCount, Size = decision.Item.Size, Modified = decision.Item.Modified, DateAdded = DateTime.UtcNow, diff --git a/src/NzbDrone.Core/MediaFiles/EbookTagService.cs b/src/NzbDrone.Core/MediaFiles/EbookTagService.cs index 69222027a..92286ba41 100644 --- a/src/NzbDrone.Core/MediaFiles/EbookTagService.cs +++ b/src/NzbDrone.Core/MediaFiles/EbookTagService.cs @@ -28,12 +28,12 @@ namespace NzbDrone.Core.MediaFiles void WriteTags(BookFile trackfile, bool newDownload, bool force = false); void SyncTags(List books); List GetRetagPreviewsByAuthor(int authorId); - List GetRetagPreviewsByBook(int authorId); + List GetRetagPreviewsByBook(int bookId); + void RetagFiles(RetagFilesCommand message); + void RetagAuthor(RetagAuthorCommand message); } - public class EBookTagService : IEBookTagService, - IExecute, - IExecute + public class EBookTagService : IEBookTagService { private readonly IAuthorService _authorService; private readonly IMediaFileService _mediaFileService; @@ -132,38 +132,38 @@ namespace NzbDrone.Core.MediaFiles return GetPreviews(files).ToList(); } - public void Execute(RetagFilesCommand message) + public void RetagFiles(RetagFilesCommand message) { var author = _authorService.GetAuthor(message.AuthorId); var files = _mediaFileService.Get(message.Files); - _logger.ProgressInfo("Re-tagging {0} files for {1}", files.Count, author.Name); + _logger.ProgressInfo("Re-tagging {0} ebook files for {1}", files.Count, author.Name); foreach (var file in files.Where(x => x.CalibreId != 0)) { WriteTagsInternal(file, message.UpdateCovers, message.EmbedMetadata); } - _logger.ProgressInfo("Selected files re-tagged for {0}", author.Name); + _logger.ProgressInfo("Selected ebook files re-tagged for {0}", author.Name); } - public void Execute(RetagAuthorCommand message) + public void RetagAuthor(RetagAuthorCommand message) { - _logger.Debug("Re-tagging all files for selected authors"); + _logger.Debug("Re-tagging all ebook files for selected authors"); var authorsToRename = _authorService.GetAuthors(message.AuthorIds); foreach (var author in authorsToRename) { var files = _mediaFileService.GetFilesByAuthor(author.Id); - _logger.ProgressInfo("Re-tagging all files for author: {0}", author.Name); + _logger.ProgressInfo("Re-tagging all ebook files for author: {0}", author.Name); foreach (var file in files.Where(x => x.CalibreId != 0)) { WriteTagsInternal(file, message.UpdateCovers, message.EmbedMetadata); } - _logger.ProgressInfo("All files re-tagged for {0}", author.Name); + _logger.ProgressInfo("All ebook files re-tagged for {0}", author.Name); } } diff --git a/src/NzbDrone.Core/MediaFiles/MediaFileExtensions.cs b/src/NzbDrone.Core/MediaFiles/MediaFileExtensions.cs index 0cfa7bd28..29346de64 100644 --- a/src/NzbDrone.Core/MediaFiles/MediaFileExtensions.cs +++ b/src/NzbDrone.Core/MediaFiles/MediaFileExtensions.cs @@ -23,6 +23,22 @@ namespace NzbDrone.Core.MediaFiles _audioExtensions = new Dictionary(StringComparer.OrdinalIgnoreCase) { + { ".flac", Quality.FLAC }, + { ".ape", Quality.FLAC }, + { ".wavpack", Quality.FLAC }, + { ".wav", Quality.FLAC }, + { ".alac", Quality.FLAC }, + { ".mp2", Quality.MP3_320 }, + { ".mp3", Quality.MP3_320 }, + { ".wma", Quality.MP3_320 }, + { ".m4a", Quality.MP3_320 }, + { ".m4p", Quality.MP3_320 }, + { ".m4b", Quality.MP3_320 }, + { ".aac", Quality.MP3_320 }, + { ".mp4a", Quality.MP3_320 }, + { ".ogg", Quality.MP3_320 }, + { ".oga", Quality.MP3_320 }, + { ".vorbis", Quality.MP3_320 }, }; } diff --git a/src/NzbDrone.Core/MediaFiles/MetadataTagService.cs b/src/NzbDrone.Core/MediaFiles/MetadataTagService.cs new file mode 100644 index 000000000..b122b3286 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/MetadataTagService.cs @@ -0,0 +1,92 @@ +using System.Collections.Generic; +using System.IO; +using System.IO.Abstractions; +using System.Linq; +using NLog; +using NzbDrone.Common.Instrumentation.Extensions; +using NzbDrone.Core.MediaFiles.Commands; +using NzbDrone.Core.Messaging.Commands; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.MediaFiles +{ + public interface IMetadataTagService + { + ParsedTrackInfo ReadTags(IFileInfo file); + void WriteTags(BookFile trackfile, bool newDownload, bool force = false); + List GetRetagPreviewsByAuthor(int authorId); + List GetRetagPreviewsByBook(int authorId); + } + + public class MetadataTagService : IMetadataTagService, + IExecute, + IExecute + { + private readonly IAudioTagService _audioTagService; + private readonly IEBookTagService _eBookTagService; + private readonly Logger _logger; + + public MetadataTagService(IAudioTagService audioTagService, + IEBookTagService eBookTagService, + Logger logger) + { + _audioTagService = audioTagService; + _eBookTagService = eBookTagService; + + _logger = logger; + } + + public ParsedTrackInfo ReadTags(IFileInfo file) + { + if (MediaFileExtensions.AudioExtensions.Contains(file.Extension)) + { + return _audioTagService.ReadTags(file.FullName); + } + else + { + return _eBookTagService.ReadTags(file); + } + } + + public void WriteTags(BookFile bookFile, bool newDownload, bool force = false) + { + var extension = Path.GetExtension(bookFile.Path); + if (MediaFileExtensions.AudioExtensions.Contains(extension)) + { + _audioTagService.WriteTags(bookFile, newDownload, force); + } + else + { + _eBookTagService.WriteTags(bookFile, newDownload, force); + } + } + + public List GetRetagPreviewsByAuthor(int authorId) + { + var previews = _audioTagService.GetRetagPreviewsByAuthor(authorId); + previews.AddRange(_eBookTagService.GetRetagPreviewsByAuthor(authorId)); + + return previews; + } + + public List GetRetagPreviewsByBook(int bookId) + { + var previews = _audioTagService.GetRetagPreviewsByBook(bookId); + previews.AddRange(_eBookTagService.GetRetagPreviewsByBook(bookId)); + + return previews; + } + + public void Execute(RetagFilesCommand message) + { + _eBookTagService.RetagFiles(message); + _audioTagService.RetagFiles(message); + } + + public void Execute(RetagAuthorCommand message) + { + _eBookTagService.RetagAuthor(message); + _audioTagService.RetagAuthor(message); + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/RenameBookFileService.cs b/src/NzbDrone.Core/MediaFiles/RenameBookFileService.cs index b17c28493..7d182bc6d 100644 --- a/src/NzbDrone.Core/MediaFiles/RenameBookFileService.cs +++ b/src/NzbDrone.Core/MediaFiles/RenameBookFileService.cs @@ -57,6 +57,7 @@ namespace NzbDrone.Core.MediaFiles return GetPreviews(author, files) .OrderByDescending(e => e.BookId) + .ThenBy(e => e.ExistingPath) .ToList(); } @@ -66,15 +67,19 @@ namespace NzbDrone.Core.MediaFiles var files = _mediaFileService.GetFilesByBook(bookId); return GetPreviews(author, files) - .OrderByDescending(e => e.TrackNumbers.First()).ToList(); + .OrderBy(e => e.ExistingPath).ToList(); } private IEnumerable GetPreviews(Author author, List files) { + var counts = files.GroupBy(x => x.EditionId).ToDictionary(g => g.Key, g => g.Count()); + // Don't rename Calibre files foreach (var f in files.Where(x => x.CalibreId == 0)) { var file = f; + file.PartCount = counts[file.EditionId]; + var book = file.Edition.Value; var bookFilePath = file.Path; diff --git a/src/NzbDrone.Core/Organizer/FileNameBuilder.cs b/src/NzbDrone.Core/Organizer/FileNameBuilder.cs index eb12735ce..a5c53e9db 100644 --- a/src/NzbDrone.Core/Organizer/FileNameBuilder.cs +++ b/src/NzbDrone.Core/Organizer/FileNameBuilder.cs @@ -113,6 +113,11 @@ namespace NzbDrone.Core.Organizer fileName = FileNameCleanupRegex.Replace(fileName, match => match.Captures[0].Value[0].ToString()); fileName = TrimSeparatorsRegex.Replace(fileName, string.Empty); + if (bookFile.PartCount > 1) + { + fileName = fileName + " (" + bookFile.Part + ")"; + } + return fileName; } diff --git a/src/NzbDrone.Core/Parser/Model/LocalBook.cs b/src/NzbDrone.Core/Parser/Model/LocalBook.cs index 0f9133b85..65f74e9e6 100644 --- a/src/NzbDrone.Core/Parser/Model/LocalBook.cs +++ b/src/NzbDrone.Core/Parser/Model/LocalBook.cs @@ -10,6 +10,8 @@ namespace NzbDrone.Core.Parser.Model { public string Path { get; set; } public int CalibreId { get; set; } + public int Part { get; set; } + public int PartCount { get; set; } public long Size { get; set; } public DateTime Modified { get; set; } public ParsedTrackInfo FileTrackInfo { get; set; } diff --git a/src/NzbDrone.Core/Parser/Model/LocalEdition.cs b/src/NzbDrone.Core/Parser/Model/LocalEdition.cs index 4122f2b1d..ca40b82ac 100644 --- a/src/NzbDrone.Core/Parser/Model/LocalEdition.cs +++ b/src/NzbDrone.Core/Parser/Model/LocalEdition.cs @@ -45,6 +45,7 @@ namespace NzbDrone.Core.Parser.Model localTrack.Edition = Edition; localTrack.Book = Edition.Book.Value; localTrack.Author = Edition.Book.Value.Author.Value; + localTrack.PartCount = LocalBooks.Count; } } } diff --git a/src/Readarr.Api.V1/BookFiles/BookFileController.cs b/src/Readarr.Api.V1/BookFiles/BookFileController.cs index 71db5a9fb..d633fc0ae 100644 --- a/src/Readarr.Api.V1/BookFiles/BookFileController.cs +++ b/src/Readarr.Api.V1/BookFiles/BookFileController.cs @@ -26,7 +26,7 @@ namespace Readarr.Api.V1.BookFiles { private readonly IMediaFileService _mediaFileService; private readonly IDeleteMediaFiles _mediaFileDeletionService; - private readonly IEBookTagService _eBookTagService; + private readonly IMetadataTagService _metadataTagService; private readonly IAuthorService _authorService; private readonly IBookService _bookService; private readonly IUpgradableSpecification _upgradableSpecification; @@ -34,7 +34,7 @@ namespace Readarr.Api.V1.BookFiles public BookFileController(IBroadcastSignalRMessage signalRBroadcaster, IMediaFileService mediaFileService, IDeleteMediaFiles mediaFileDeletionService, - IEBookTagService eBookTagService, + IMetadataTagService metadataTagService, IAuthorService authorService, IBookService bookService, IUpgradableSpecification upgradableSpecification) @@ -42,7 +42,7 @@ namespace Readarr.Api.V1.BookFiles { _mediaFileService = mediaFileService; _mediaFileDeletionService = mediaFileDeletionService; - _eBookTagService = eBookTagService; + _metadataTagService = metadataTagService; _authorService = authorService; _bookService = bookService; _upgradableSpecification = upgradableSpecification; @@ -63,7 +63,7 @@ namespace Readarr.Api.V1.BookFiles public override BookFileResource GetResourceById(int id) { var resource = MapToResource(_mediaFileService.Get(id)); - resource.AudioTags = _eBookTagService.ReadTags((FileInfoBase)new FileInfo(resource.Path)); + resource.AudioTags = _metadataTagService.ReadTags((FileInfoBase)new FileInfo(resource.Path)); return resource; } diff --git a/src/Readarr.Api.V1/Books/RetagBookController.cs b/src/Readarr.Api.V1/Books/RetagBookController.cs index c3737a0af..0b6d77b1b 100644 --- a/src/Readarr.Api.V1/Books/RetagBookController.cs +++ b/src/Readarr.Api.V1/Books/RetagBookController.cs @@ -10,11 +10,11 @@ namespace Readarr.Api.V1.Books [V1ApiController("retag")] public class RetagBookController : Controller { - private readonly IEBookTagService _eBookTagService; + private readonly IMetadataTagService _metadataTagService; - public RetagBookController(IEBookTagService eBookTagService) + public RetagBookController(IMetadataTagService metadataTagService) { - _eBookTagService = eBookTagService; + _metadataTagService = metadataTagService; } [HttpGet] @@ -22,11 +22,11 @@ namespace Readarr.Api.V1.Books { if (bookId.HasValue) { - return _eBookTagService.GetRetagPreviewsByBook(bookId.Value).Where(x => x.Changes.Any()).ToResource(); + return _metadataTagService.GetRetagPreviewsByBook(bookId.Value).Where(x => x.Changes.Any()).ToResource(); } else if (authorId.HasValue) { - return _eBookTagService.GetRetagPreviewsByAuthor(authorId.Value).Where(x => x.Changes.Any()).ToResource(); + return _metadataTagService.GetRetagPreviewsByAuthor(authorId.Value).Where(x => x.Changes.Any()).ToResource(); } else { diff --git a/src/Readarr.Api.V1/Config/MetadataProviderConfigResource.cs b/src/Readarr.Api.V1/Config/MetadataProviderConfigResource.cs index 03f112d9d..20ae164b3 100644 --- a/src/Readarr.Api.V1/Config/MetadataProviderConfigResource.cs +++ b/src/Readarr.Api.V1/Config/MetadataProviderConfigResource.cs @@ -5,6 +5,8 @@ namespace Readarr.Api.V1.Config { public class MetadataProviderConfigResource : RestResource { + public WriteAudioTagsType WriteAudioTags { get; set; } + public bool ScrubAudioTags { get; set; } public WriteBookTagsType WriteBookTags { get; set; } public bool UpdateCovers { get; set; } public bool EmbedMetadata { get; set; } @@ -16,6 +18,8 @@ namespace Readarr.Api.V1.Config { return new MetadataProviderConfigResource { + WriteAudioTags = model.WriteAudioTags, + ScrubAudioTags = model.ScrubAudioTags, WriteBookTags = model.WriteBookTags, UpdateCovers = model.UpdateCovers, EmbedMetadata = model.EmbedMetadata