diff --git a/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.js b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.js index b9485b45..2178e19c 100644 --- a/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.js +++ b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.js @@ -180,6 +180,7 @@ class EditQualityProfileModalContent extends Component { upgradeAllowed, preferCustomFormatsOverQuality, convertToQualityId, + mergeMultiPartFiles, cutoff, minFormatScore, cutoffFormatScore, @@ -190,6 +191,7 @@ class EditQualityProfileModalContent extends Component { const upgradeAllowedField = fieldWithDefault(upgradeAllowed, false); const preferCustomFormatsOverQualityField = fieldWithDefault(preferCustomFormatsOverQuality, false); const convertToQualityIdField = fieldWithDefault(convertToQualityId, 0); + const mergeMultiPartFilesField = fieldWithDefault(mergeMultiPartFiles, false); const cutoffField = fieldWithDefault(cutoff, 0); const minFormatScoreField = fieldWithDefault(minFormatScore, 0); const cutoffFormatScoreField = fieldWithDefault(cutoffFormatScore, 0); @@ -339,6 +341,23 @@ class EditQualityProfileModalContent extends Component { } + { + isAudiobookProfile && Number(convertToQualityIdField.value) > 0 && + + + {translate('MergeMultiPartFiles')} + + + + + } + { isAudiobookProfile && Number(convertToQualityIdField.value) > 0 && Items { get; set; } public int MinFormatScore { get; set; } @@ -79,6 +80,7 @@ public static QualityProfileResource ToResource(this QualityProfile model, bool PreferCustomFormatsOverQuality = model.ProfileType == ProfileType.Audiobook && model.PreferCustomFormatsOverQuality, ConvertMp3ToM4b = model.ConvertMp3ToM4b, ConvertToQualityId = convertToQualityId, + MergeMultiPartFiles = model.MergeMultiPartFiles, Cutoff = cutoff, Items = items.ConvertAll(ToResource), MinFormatScore = model.MinFormatScore, @@ -140,6 +142,7 @@ public static QualityProfile ToModel(this QualityProfileResource resource) // toggle. ConvertToQualityId is the canonical conversion target. ConvertMp3ToM4b = convertToQualityId == NzbDrone.Core.Qualities.Quality.M4B.Id, ConvertToQualityId = convertToQualityId, + MergeMultiPartFiles = resource.ProfileType == ProfileType.Audiobook && resource.MergeMultiPartFiles, Cutoff = resource.Cutoff, Items = (resource.Items ?? new List()).ConvertAll(ToModel), MinFormatScore = resource.MinFormatScore, diff --git a/src/Chaptarr.Api.V1/openapi.json b/src/Chaptarr.Api.V1/openapi.json index 50f2407f..2f35d682 100644 --- a/src/Chaptarr.Api.V1/openapi.json +++ b/src/Chaptarr.Api.V1/openapi.json @@ -18337,6 +18337,9 @@ "format": "int32", "nullable": true }, + "mergeMultiPartFiles": { + "type": "boolean" + }, "cutoff": { "type": "integer", "format": "int32" diff --git a/src/Chaptarr.Core.Test/MediaFiles/ConversionJobServiceFixture.cs b/src/Chaptarr.Core.Test/MediaFiles/ConversionJobServiceFixture.cs index 35b3b26f..df855d85 100644 --- a/src/Chaptarr.Core.Test/MediaFiles/ConversionJobServiceFixture.cs +++ b/src/Chaptarr.Core.Test/MediaFiles/ConversionJobServiceFixture.cs @@ -39,6 +39,7 @@ public ConversionResult ConvertToM4b(string[] inputFiles, string outputFile, Con public bool CanConvert(string[] inputFiles) => true; public ConversionEstimate EstimateConversion(string[] inputFiles) => new() { CanConvert = true }; + public M4bMergeSourceInfo ProbeMergeSources(string[] inputFiles) => new(); } private sealed class BlockingConversionService : IM4bConversionService @@ -59,6 +60,7 @@ public ConversionResult ConvertToM4b(string[] inputFiles, string outputFile, Con public bool CanConvert(string[] inputFiles) => true; public ConversionEstimate EstimateConversion(string[] inputFiles) => new() { CanConvert = true }; + public M4bMergeSourceInfo ProbeMergeSources(string[] inputFiles) => new(); } private sealed class TokenRecordingConversionService : IM4bConversionService @@ -90,6 +92,7 @@ public ConversionResult ConvertToM4b(string[] inputFiles, string outputFile, Con public bool CanConvert(string[] inputFiles) => true; public ConversionEstimate EstimateConversion(string[] inputFiles) => new() { CanConvert = true }; + public M4bMergeSourceInfo ProbeMergeSources(string[] inputFiles) => new(); } private class RecordingCommandQueueProxy : DispatchProxy diff --git a/src/Chaptarr.Core.Test/MediaFiles/ImportApprovedBooksAdditionalCopyFixture.cs b/src/Chaptarr.Core.Test/MediaFiles/ImportApprovedBooksAdditionalCopyFixture.cs index 77715e8b..68239b27 100644 --- a/src/Chaptarr.Core.Test/MediaFiles/ImportApprovedBooksAdditionalCopyFixture.cs +++ b/src/Chaptarr.Core.Test/MediaFiles/ImportApprovedBooksAdditionalCopyFixture.cs @@ -231,6 +231,8 @@ private sealed class FailingM4bConversionService : IM4bConversionService EstimatedOutputSize = 1024 }; + public M4bMergeSourceInfo ProbeMergeSources(string[] inputFiles) => new(); + public ConversionResult ConvertToM4b(string[] inputFiles, string outputFile, ConversionOptions options = null) { ConvertCalls++; @@ -262,6 +264,10 @@ private sealed class SuccessfulM4bConversionService : IM4bConversionService EstimatedOutputSize = 1024 }; + public M4bMergeSourceInfo MergeSources { get; set; } = new(); + + public M4bMergeSourceInfo ProbeMergeSources(string[] inputFiles) => MergeSources; + public ConversionResult ConvertToM4b(string[] inputFiles, string outputFile, ConversionOptions options = null) { ConvertCalls++; @@ -2596,6 +2602,185 @@ public void should_stage_matched_provider_chapters_when_source_duration_matches( } } + [Test] + public void should_merge_multi_part_m4b_with_embedded_chapters_without_reencoding() + { + var tempDir = Path.Combine(TestContext.CurrentContext.WorkDirectory, $"m4b-merge-{Guid.NewGuid():N}"); + var destinationDir = Path.Combine(TestContext.CurrentContext.WorkDirectory, $"conversion-destination-{Guid.NewGuid():N}"); + var destinationPath = Path.Combine(destinationDir, "Black Sheep.m4b"); + Directory.CreateDirectory(tempDir); + Directory.CreateDirectory(destinationDir); + + var part1 = Path.Combine(tempDir, "Black Sheep (1).m4b"); + var part2 = Path.Combine(tempDir, "Black Sheep (2).m4b"); + File.WriteAllText(part1, "fake m4b"); + File.WriteAllText(part2, "fake m4b"); + + try + { + var (qualityProfile, author, book, edition) = CreateAudiobookConversionGraph(26); + qualityProfile.MergeMultiPartFiles = true; + + var conversion = new SuccessfulM4bConversionService + { + MergeSources = new M4bMergeSourceInfo + { + CanCopyAudio = true, + EmbeddedChapterCount = 2, + ChaptersTxtContent = "## total-length 00:20:00.000\n00:00:00.000 Opening\n00:10:00.000 Chapter 1" + } + }; + + var service = new ImportApprovedBooks( + new StubMediaFileService(), + new StubMetadataTagService(), + new StubMediaInfoExtractor(), + Proxy(), + Proxy(), + CreateEditionService(new List { edition }), + Proxy(), + Proxy(), + new StubMoveBookFiles { DestinationPath = destinationPath }, + Proxy(), + Proxy(), + new NoOpEventAggregator(), + Proxy(), + Proxy(), + Proxy(), + Proxy(), + conversion, + LogManager.GetLogger("ImportApprovedBooksAdditionalCopyFixture")); + + LocalBook LocalBookFor(string path) => new() + { + Path = path, + Book = book, + Author = author, + Edition = edition, + Quality = new QualityModel { Quality = Quality.M4B, Revision = new Revision() }, + RawTags = new RawFileTags + { + AllTags = new Dictionary> + { + ["title"] = new() { "Black Sheep" }, + ["album"] = new() { "Black Sheep (Unabridged)" } + } + } + }; + + service.Import( + new List> + { + new(LocalBookFor(part1)), + new(LocalBookFor(part2)) + }, + replaceExisting: false, + downloadClientItem: new DownloadClientItem { DownloadId = "m4b-merge" }, + importMode: ImportMode.Move, + cancellationToken: CancellationToken.None); + + Assert.That(conversion.ConvertCalls, Is.EqualTo(1)); + Assert.That(conversion.LastOptions.NoConversion, Is.True); + Assert.That(conversion.LastOptions.TagOptions.Name, Is.EqualTo("Black Sheep")); + Assert.That(conversion.LastOptions.TagOptions.RemoveTags, Is.EquivalentTo(new[] { "track", "tracks", "disk", "disks" })); + Assert.That(conversion.LastOptions.TagOptions.UseFilenamesAsChapters, Is.False); + + var chaptersPath = Path.Combine(Path.GetDirectoryName(conversion.LastInputFiles[0]), "chapters.txt"); + Assert.That(File.ReadAllText(chaptersPath), Does.Contain("00:10:00.000 Chapter 1")); + } + finally + { + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir, recursive: true); + } + + if (Directory.Exists(destinationDir)) + { + Directory.Delete(destinationDir, recursive: true); + } + } + } + + [Test] + public void should_import_parts_unmerged_when_optional_m4b_merge_fails() + { + var tempDir = Path.Combine(TestContext.CurrentContext.WorkDirectory, $"m4b-merge-fails-{Guid.NewGuid():N}"); + var destinationDir = Path.Combine(TestContext.CurrentContext.WorkDirectory, $"conversion-destination-{Guid.NewGuid():N}"); + var destinationPath = Path.Combine(destinationDir, "Black Sheep.m4b"); + Directory.CreateDirectory(tempDir); + Directory.CreateDirectory(destinationDir); + + var part1 = Path.Combine(tempDir, "Black Sheep (1).m4b"); + var part2 = Path.Combine(tempDir, "Black Sheep (2).m4b"); + File.WriteAllText(part1, "fake m4b"); + File.WriteAllText(part2, "fake m4b"); + + try + { + var (qualityProfile, author, book, edition) = CreateAudiobookConversionGraph(27); + qualityProfile.MergeMultiPartFiles = true; + + var conversion = new FailingM4bConversionService(); + var service = new ImportApprovedBooks( + new StubMediaFileService(), + new StubMetadataTagService(), + new StubMediaInfoExtractor(), + Proxy(), + Proxy(), + CreateEditionService(new List { edition }), + Proxy(), + Proxy(), + new StubMoveBookFiles { DestinationPath = destinationPath }, + Proxy(), + Proxy(), + new NoOpEventAggregator(), + Proxy(), + Proxy(), + Proxy(), + Proxy(), + conversion, + LogManager.GetLogger("ImportApprovedBooksAdditionalCopyFixture")); + + LocalBook LocalBookFor(string path) => new() + { + Path = path, + Book = book, + Author = author, + Edition = edition, + Quality = new QualityModel { Quality = Quality.M4B, Revision = new Revision() } + }; + + var results = service.Import( + new List> + { + new(LocalBookFor(part1)), + new(LocalBookFor(part2)) + }, + replaceExisting: false, + downloadClientItem: new DownloadClientItem { DownloadId = "m4b-merge-fails" }, + importMode: ImportMode.Move, + cancellationToken: CancellationToken.None); + + Assert.That(conversion.ConvertCalls, Is.EqualTo(1)); + Assert.That(results.SelectMany(r => r.Errors), Does.Not.Contain("synthetic conversion failure")); + Assert.That(results.Select(r => r.ImportDecision.Item.Path), Is.EquivalentTo(new[] { part1, part2 })); + Assert.That(Directory.Exists(Path.Combine(destinationDir, ".chaptarr-conversions")), Is.False); + } + finally + { + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir, recursive: true); + } + + if (Directory.Exists(destinationDir)) + { + Directory.Delete(destinationDir, recursive: true); + } + } + } + [Test] public void should_not_stage_provider_chapters_when_source_duration_does_not_match() { diff --git a/src/Chaptarr.Core.Test/MediaFiles/M4bConversionServiceFixture.cs b/src/Chaptarr.Core.Test/MediaFiles/M4bConversionServiceFixture.cs index 67bc5aa2..e248853c 100644 --- a/src/Chaptarr.Core.Test/MediaFiles/M4bConversionServiceFixture.cs +++ b/src/Chaptarr.Core.Test/MediaFiles/M4bConversionServiceFixture.cs @@ -27,6 +27,7 @@ private sealed class StreamingExternalToolsService : IExternalToolsService public Dictionary Durations { get; } = new(StringComparer.OrdinalIgnoreCase); public HashSet AudioFiles { get; } = new(StringComparer.OrdinalIgnoreCase); public Dictionary StreamLayouts { get; } = new(StringComparer.OrdinalIgnoreCase); + public Dictionary ProbeJson { get; } = new(StringComparer.OrdinalIgnoreCase); public List> FFmpegCalls { get; } = new(); public Action> OnFFmpegExecute { get; set; } @@ -40,6 +41,11 @@ private sealed class StreamingExternalToolsService : IExternalToolsService public string ExecuteFFprobe(IReadOnlyList arguments, int timeoutMs = 10000) { var path = arguments.LastOrDefault(); + if (arguments.Contains("-show_chapters")) + { + return path != null && ProbeJson.TryGetValue(path, out var json) ? json : string.Empty; + } + if (arguments.Any(a => a.Contains("stream_disposition=attached_pic", StringComparison.Ordinal))) { if (path != null && StreamLayouts.TryGetValue(path, out var layout)) @@ -210,6 +216,93 @@ public void should_pass_source_preserving_tag_arguments() Assert.That(externalTools.M4bToolArguments, Does.Not.Contain("--ignore-source-tags")); } + [Test] + public void should_copy_audio_and_remove_part_tags_when_merging_without_conversion() + { + var externalTools = new StreamingExternalToolsService(); + var diskProvider = DispatchProxy.Create(); + var diskProxy = (DiskProviderProxy)(object)diskProvider; + diskProxy.ExistingFiles.Add("/input/part-01.m4b"); + diskProxy.ExistingFiles.Add("/input/part-02.m4b"); + + var subject = new M4bConversionService( + externalTools, + diskProvider, + DispatchProxy.Create>(), + LogManager.GetCurrentClassLogger()); + + subject.ConvertToM4b( + new[] { "/input/part-01.m4b", "/input/part-02.m4b" }, + "/output/book.m4b", + new ConversionOptions + { + AudioBitrate = 64, + AudioChannels = 1, + NoConversion = true, + TagOptions = new ConversionTagOptions + { + RemoveTags = new List { "track", "tracks", "disk", "disks" } + } + }); + + Assert.That(externalTools.M4bToolArguments, Does.Contain("--no-conversion")); + Assert.That(externalTools.M4bToolArguments.Any(a => a.StartsWith("--audio-", StringComparison.Ordinal)), Is.False); + Assert.That(externalTools.M4bToolArguments, Does.Contain("--remove=track,tracks,disk,disks")); + } + + [Test] + public void should_offset_embedded_part_chapters_when_probing_merge_sources() + { + var externalTools = new StreamingExternalToolsService(); + externalTools.ProbeJson["/input/part-01.m4b"] = """ + {"streams":[{"codec_name":"aac","sample_rate":"22050","channels":1}], + "chapters":[{"start_time":"0.000000","tags":{"title":"Opening"}},{"start_time":"600.500000","tags":{"title":"Chapter 1"}}], + "format":{"duration":"1200.250000"}} + """; + externalTools.ProbeJson["/input/part-02.m4b"] = """ + {"streams":[{"codec_name":"aac","sample_rate":"22050","channels":1}], + "chapters":[], + "format":{"duration":"900.000000"}} + """; + + var subject = new M4bConversionService( + externalTools, + DispatchProxy.Create(), + DispatchProxy.Create>(), + LogManager.GetCurrentClassLogger()); + + var info = subject.ProbeMergeSources(new[] { "/input/part-01.m4b", "/input/part-02.m4b" }); + + Assert.That(info.CanCopyAudio, Is.True); + Assert.That(info.EmbeddedChapterCount, Is.EqualTo(2)); + Assert.That(info.ChaptersTxtContent.Split(Environment.NewLine), Is.EqualTo(new[] + { + "## total-length 00:35:00.250", + "00:00:00.000 Opening", + "00:10:00.500 Chapter 1", + "00:20:00.250 part-02" + })); + } + + [Test] + public void should_not_copy_audio_when_merge_sources_differ_in_sample_rate() + { + var externalTools = new StreamingExternalToolsService(); + externalTools.ProbeJson["/input/part-01.m4b"] = """{"streams":[{"codec_name":"aac","sample_rate":"22050","channels":1}],"format":{"duration":"60"}}"""; + externalTools.ProbeJson["/input/part-02.m4b"] = """{"streams":[{"codec_name":"aac","sample_rate":"44100","channels":1}],"format":{"duration":"60"}}"""; + + var subject = new M4bConversionService( + externalTools, + DispatchProxy.Create(), + DispatchProxy.Create>(), + LogManager.GetCurrentClassLogger()); + + var info = subject.ProbeMergeSources(new[] { "/input/part-01.m4b", "/input/part-02.m4b" }); + + Assert.That(info.CanCopyAudio, Is.False); + Assert.That(info.ChaptersTxtContent, Is.Null); + } + [Test] public void should_pass_clean_tag_arguments_with_source_tag_inheritance_disabled() { diff --git a/src/NzbDrone.Core/Configuration/SettingsBackups/SettingsBackupModels.cs b/src/NzbDrone.Core/Configuration/SettingsBackups/SettingsBackupModels.cs index af172a75..f6318645 100644 --- a/src/NzbDrone.Core/Configuration/SettingsBackups/SettingsBackupModels.cs +++ b/src/NzbDrone.Core/Configuration/SettingsBackups/SettingsBackupModels.cs @@ -222,6 +222,7 @@ internal class QualityProfileBackup public bool PreferCustomFormatsOverQuality { get; set; } public bool ConvertMp3ToM4b { get; set; } public int? ConvertToQualityId { get; set; } + public bool MergeMultiPartFiles { get; set; } public int Cutoff { get; set; } public int MinFormatScore { get; set; } public int CutoffFormatScore { get; set; } diff --git a/src/NzbDrone.Core/Configuration/SettingsBackups/SettingsBackupService.cs b/src/NzbDrone.Core/Configuration/SettingsBackups/SettingsBackupService.cs index add65eda..06e21a74 100644 --- a/src/NzbDrone.Core/Configuration/SettingsBackups/SettingsBackupService.cs +++ b/src/NzbDrone.Core/Configuration/SettingsBackups/SettingsBackupService.cs @@ -626,6 +626,7 @@ private static QualityProfileBackup ToBackup(QualityProfile profile) CutoffFormatScore = profile.CutoffFormatScore, ConvertMp3ToM4b = profile.ConvertMp3ToM4b, ConvertToQualityId = profile.ConvertToQualityId, + MergeMultiPartFiles = profile.MergeMultiPartFiles, UpgradeAllowed = profile.UpgradeAllowed, PreferCustomFormatsOverQuality = profile.ProfileType == ProfileType.Audiobook && profile.PreferCustomFormatsOverQuality, FormatItems = (profile.FormatItems ?? new List()) @@ -1735,6 +1736,7 @@ private static QualityProfile CloneQualityProfile(QualityProfileBackup profile, CutoffFormatScore = profile.CutoffFormatScore, ConvertMp3ToM4b = profile.ConvertMp3ToM4b, ConvertToQualityId = profile.ConvertToQualityId, + MergeMultiPartFiles = profile.MergeMultiPartFiles, UpgradeAllowed = profile.UpgradeAllowed, PreferCustomFormatsOverQuality = profile.ProfileType == ProfileType.Audiobook && profile.PreferCustomFormatsOverQuality, FormatItems = new List() diff --git a/src/NzbDrone.Core/Datastore/Migration/001_chaptarr_complete_schema.cs b/src/NzbDrone.Core/Datastore/Migration/001_chaptarr_complete_schema.cs index 9c219946..86d89a32 100644 --- a/src/NzbDrone.Core/Datastore/Migration/001_chaptarr_complete_schema.cs +++ b/src/NzbDrone.Core/Datastore/Migration/001_chaptarr_complete_schema.cs @@ -819,7 +819,8 @@ private void CreateQualityTables() .WithColumn("FormatItems").AsString().NotNullable() .WithColumn("SearchCriteriaProfileId").AsInt32().Nullable() .WithColumn("ConvertMp3ToM4b").AsBoolean().NotNullable().WithDefaultValue(false) - .WithColumn("ConvertToQualityId").AsInt32().Nullable(); + .WithColumn("ConvertToQualityId").AsInt32().Nullable() + .WithColumn("MergeMultiPartFiles").AsBoolean().NotNullable().WithDefaultValue(false); // Metadata profiles Create.TableForModel("MetadataProfiles") diff --git a/src/NzbDrone.Core/Datastore/Migration/108_add_quality_profile_merge_multi_part_files.cs b/src/NzbDrone.Core/Datastore/Migration/108_add_quality_profile_merge_multi_part_files.cs new file mode 100644 index 00000000..d01ec5e4 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/108_add_quality_profile_merge_multi_part_files.cs @@ -0,0 +1,21 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(108)] + public class add_quality_profile_merge_multi_part_files : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + if (!Schema.Table("QualityProfiles").Column("MergeMultiPartFiles").Exists()) + { + Alter.Table("QualityProfiles") + .AddColumn("MergeMultiPartFiles") + .AsBoolean() + .NotNullable() + .WithDefaultValue(false); + } + } + } +} diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index 12c5600a..a75e0a64 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -1129,6 +1129,8 @@ "MediaManagementSettingsSummary": "Naming, file management settings and root folders", "Medium": "Medium", "Member": "Member", + "MergeMultiPartFiles": "Create Single File", + "MergeMultiPartFilesHelpText": "Merge multi-part M4B downloads into a single M4B file on import", "Message": "Message", "Metadata": "Metadata", "MetadataConsumers": "Metadata Consumers", diff --git a/src/NzbDrone.Core/MediaFiles/BookImport/ConversionTagProposalBuilder.cs b/src/NzbDrone.Core/MediaFiles/BookImport/ConversionTagProposalBuilder.cs index 730589c0..6fe0d940 100644 --- a/src/NzbDrone.Core/MediaFiles/BookImport/ConversionTagProposalBuilder.cs +++ b/src/NzbDrone.Core/MediaFiles/BookImport/ConversionTagProposalBuilder.cs @@ -16,6 +16,7 @@ internal static class ConversionTagProposalBuilder private const int MaxManifestTagFieldsPerFile = 48; private const int MaxManifestValuesPerField = 4; private const int MaxManifestValueLength = 500; + private static readonly string[] TitleTagKeys = { "TITLE", "MP4:©nam", "ID3v2:TIT2", "XIPH:TITLE", "APE:Title" }; public static ConversionTagOptions BuildOptions( IEnumerable sourceBooks, @@ -39,6 +40,46 @@ public static ConversionTagOptions BuildOptions( return options; } + public static void ApplyMultiPartMergeTags(ConversionTagOptions options, IEnumerable sourceBooks) + { + if (options == null) + { + return; + } + + options.RemoveTags = new List { "track", "tracks", "disk", "disks" }; + + if (options.IgnoreSourceTags) + { + return; + } + + var titles = (sourceBooks ?? Enumerable.Empty()) + .Where(source => source != null) + .Select(source => GetSingleTitleTag(ToCaseInsensitiveTags(source.RawTags?.AllTags))) + .ToList(); + + if (titles.Count > 1 && + titles.All(title => title.IsNotNullOrWhiteSpace()) && + titles.Distinct(StringComparer.OrdinalIgnoreCase).Count() == 1) + { + options.Name = titles[0]; + } + } + + private static string GetSingleTitleTag(Dictionary> tags) + { + foreach (var key in TitleTagKeys) + { + if (tags.TryGetValue(key, out var values)) + { + return values.Count == 1 ? values[0] : null; + } + } + + return null; + } + public static void RefreshManifestJson(ConversionTagOptions options, IEnumerable sourceBooks) { if (options == null) diff --git a/src/NzbDrone.Core/MediaFiles/BookImport/ImportApprovedBooks.cs b/src/NzbDrone.Core/MediaFiles/BookImport/ImportApprovedBooks.cs index 9a955cfa..34b74f39 100644 --- a/src/NzbDrone.Core/MediaFiles/BookImport/ImportApprovedBooks.cs +++ b/src/NzbDrone.Core/MediaFiles/BookImport/ImportApprovedBooks.cs @@ -1532,7 +1532,8 @@ private string GetCustomFormatImportRejectionReason(LocalBook localBook, Author var sourceQuality = first?.Quality?.Quality ?? Qualities.Quality.Unknown; var qualityProfile = author.GetQualityProfileForQuality(sourceQuality); var targetQuality = QualityConversionHelper.GetPlannedConversionTarget(author, first?.Quality); - if (targetQuality != Qualities.Quality.M4B) + var mergeMultiPartM4b = QualityConversionHelper.ShouldMergeMultiPartM4b(qualityProfile, first?.Quality); + if (targetQuality != Qualities.Quality.M4B && !mergeMultiPartM4b) { return (bookDecisions, null, false, null); } @@ -1549,7 +1550,28 @@ private string GetCustomFormatImportRejectionReason(LocalBook localBook, Author return (bookDecisions, null, false, null); } - if (inputFiles.All(p => Path.GetExtension(p).Equals(".m4b", StringComparison.OrdinalIgnoreCase))) + if (inputFiles.All(p => Path.GetExtension(p).Equals(".m4b", StringComparison.OrdinalIgnoreCase)) && + (inputFiles.Length == 1 || !mergeMultiPartM4b)) + { + return (bookDecisions, null, false, null); + } + + var isOptionalMerge = targetQuality != Qualities.Quality.M4B; + + (List> Decisions, string WorkFolder, bool Failed, string Error) ConversionFailed(string error, string fallbackCleanupRoot = null) + { + if (!isOptionalMerge) + { + return (bookDecisions, null, true, error); + } + + _logger.Warn("[CONVERSION] Could not merge multi-part M4B for '{0}'; importing the parts unmerged. {1}", book.Title, error); + _conversionTrackingService?.Clear(downloadClientItem.DownloadId); + CleanupConversionWorkFolder(fallbackCleanupRoot); + return (bookDecisions, null, false, null); + } + + if (hasRejectedTrackedDownloadDecisions && isOptionalMerge) { return (bookDecisions, null, false, null); } @@ -1570,7 +1592,7 @@ private string GetCustomFormatImportRejectionReason(LocalBook localBook, Author var error = "Conversion to M4B is enabled, but one or more files are not compatible with the M4B converter."; _conversionTrackingService?.Fail(downloadClientItem.DownloadId, error); PublishConversionFailed(first, inputFiles, book, author, convertedQuality, null, error, downloadClientItem); - return (bookDecisions, null, true, error); + return ConversionFailed(error); } var outputName = GetSafeConvertedFileName(inputFiles.Length == 1 ? Path.GetFileNameWithoutExtension(inputFiles[0]) : book.Title); @@ -1587,7 +1609,7 @@ private string GetCustomFormatImportRejectionReason(LocalBook localBook, Author var error = "Unable to determine destination folder for converted M4B."; _conversionTrackingService?.Fail(downloadClientItem.DownloadId, error); PublishConversionFailed(first, inputFiles, book, author, convertedQuality, null, error, downloadClientItem); - return (bookDecisions, null, true, error); + return ConversionFailed(error); } var destinationConflict = GetConversionDestinationConflictReason(finalDestinationPath, book, qualityProfile, replaceExisting, downloadForced); @@ -1595,7 +1617,7 @@ private string GetCustomFormatImportRejectionReason(LocalBook localBook, Author { _conversionTrackingService?.Fail(downloadClientItem.DownloadId, destinationConflict); PublishConversionFailed(first, inputFiles, book, author, convertedQuality, null, destinationConflict, downloadClientItem); - return (bookDecisions, null, true, destinationConflict); + return ConversionFailed(destinationConflict); } var downloadFolderName = GetSafeConvertedFileName(downloadClientItem.DownloadId ?? Guid.NewGuid().ToString("N")); @@ -1619,13 +1641,14 @@ private string GetCustomFormatImportRejectionReason(LocalBook localBook, Author _configService?.AudiobookConversionTagMode); var sourceDuration = GetAudiobookConversionSourceDuration(inputFiles); TryApplyMatchedEditionChapters(tagOptions, first.Edition, sourceDuration, book); + var noConversion = isOptionalMerge && TryApplyMultiPartM4bMergeSources(tagOptions, bookDecisions, inputFiles, book); TryApplySourceSidecarCover(tagOptions, inputFiles, book, first.Edition); ApplyConversionCoverFallback(tagOptions, book, first.Edition); ConversionTagProposalBuilder.RefreshManifestJson(tagOptions, bookDecisions.Select(d => d.Item)); var audioBitrate = GetAudiobookConversionBitrate(inputFiles); var audioChannels = GetAudiobookConversionAudioChannels(); - if (TryFindReusableConversionArtifact(workRoot, inputFiles, convertedQuality, tagOptions, audioBitrate, audioChannels, out var reusableOutputPath)) + if (TryFindReusableConversionArtifact(workRoot, inputFiles, convertedQuality, tagOptions, audioBitrate, audioChannels, noConversion, out var reusableOutputPath)) { _conversionTrackingService?.Start(downloadClientItem.DownloadId, Qualities.Quality.M4B.Id, Qualities.Quality.M4B.Name, "Using retained M4B"); _conversionTrackingService?.Progress(downloadClientItem.DownloadId, 97m, "Using retained M4B"); @@ -1650,14 +1673,16 @@ private string GetCustomFormatImportRejectionReason(LocalBook localBook, Author var error = "The completed conversion artifact is no longer valid for the current source files. Retry import to rebuild it."; _conversionJobService.Fail(downloadClientItem.DownloadId, error); PublishConversionFailed(first, inputFiles, book, author, convertedQuality, existingJob.OutputPath, error, downloadClientItem); - return (bookDecisions, null, true, error); + return ConversionFailed(error, existingJob.WorkRoot ?? workRoot); } if (existingJob.Status == ConversionJobStatus.Failed || existingJob.Status == ConversionJobStatus.Cancelled) { var error = existingJob.Error ?? existingJob.Message ?? "M4B conversion did not complete."; PublishConversionFailed(first, inputFiles, book, author, convertedQuality, existingJob.OutputPath, error, downloadClientItem); - return (bookDecisions, null, true, error); + return existingJob.Status == ConversionJobStatus.Failed + ? ConversionFailed(error, existingJob.WorkRoot ?? workRoot) + : (bookDecisions, null, true, error); } } @@ -1666,7 +1691,7 @@ private string GetCustomFormatImportRejectionReason(LocalBook localBook, Author { _conversionTrackingService?.Fail(downloadClientItem.DownloadId, freeSpaceError); PublishConversionFailed(first, inputFiles, book, author, convertedQuality, outputPath, freeSpaceError, downloadClientItem); - return (bookDecisions, null, true, freeSpaceError); + return ConversionFailed(freeSpaceError); } CleanupConversionWorkFolder(workRoot); @@ -1681,7 +1706,7 @@ private string GetCustomFormatImportRejectionReason(LocalBook localBook, Author { var error = "Unable to capture stable source-file identities for M4B conversion."; _conversionJobService.Fail(downloadClientItem.DownloadId, error); - return (bookDecisions, workRoot, true, error); + return isOptionalMerge ? ConversionFailed(error, workRoot) : (bookDecisions, workRoot, true, error); } _conversionJobService.Enqueue(new ConversionJobRequest @@ -1702,6 +1727,7 @@ private string GetCustomFormatImportRejectionReason(LocalBook localBook, Author TargetQualityName = convertedQuality.Quality.Name, AudioBitrate = audioBitrate, AudioChannels = audioChannels, + NoConversion = noConversion, ExpectedSourceDurationTicks = sourceDuration.Ticks, TagSignature = GetConversionTagSignature(tagOptions), TagOptions = tagOptions @@ -1729,6 +1755,7 @@ private string GetCustomFormatImportRejectionReason(LocalBook localBook, Author TempDirectory = workFolder, AudioBitrate = audioBitrate, AudioChannels = audioChannels, + NoConversion = noConversion, ExpectedSourceDuration = sourceDuration, Jobs = threadPlan.ParallelFiles, FfmpegThreads = threadPlan.FfmpegThreads, @@ -1760,7 +1787,7 @@ private string GetCustomFormatImportRejectionReason(LocalBook localBook, Author if (result.RetainOutputOnFailure && DestinationFileExists(outputPath)) { - WriteConversionArtifactManifest(workFolder, outputPath, inputFiles, convertedQuality, tagOptions, audioBitrate, audioChannels); + WriteConversionArtifactManifest(workFolder, outputPath, inputFiles, convertedQuality, tagOptions, audioBitrate, audioChannels, noConversion); error = $"{error} Converted file retained at: {outputPath}"; } @@ -1771,12 +1798,12 @@ private string GetCustomFormatImportRejectionReason(LocalBook localBook, Author CleanupConversionWorkFolder(workRoot); } - return (bookDecisions, null, true, error); + return ConversionFailed(error, workRoot); } _conversionTrackingService?.Progress(downloadClientItem.DownloadId, 97m, "Finalizing M4B"); - WriteConversionArtifactManifest(workFolder, outputPath, inputFiles, convertedQuality, tagOptions, audioBitrate, audioChannels); + WriteConversionArtifactManifest(workFolder, outputPath, inputFiles, convertedQuality, tagOptions, audioBitrate, audioChannels, noConversion); var convertedLocalBook = CreateGeneratedConversionLocalBook(first, bookDecisions, outputPath, inputFiles, convertedQuality, tagOptions); _conversionTrackingService?.Progress(downloadClientItem.DownloadId, 98m, "Preparing import"); @@ -1799,7 +1826,7 @@ private string GetCustomFormatImportRejectionReason(LocalBook localBook, Author _conversionTrackingService?.Fail(downloadClientItem.DownloadId, ex.Message); PublishConversionFailed(first, inputFiles, book, author, convertedQuality, outputPath, "M4B conversion failed: " + ex.Message, downloadClientItem); CleanupConversionWorkFolder(workRoot ?? workFolder); - return (bookDecisions, null, true, "M4B conversion failed: " + ex.Message); + return ConversionFailed("M4B conversion failed: " + ex.Message); } } @@ -1857,6 +1884,27 @@ private void TryApplyMatchedEditionChapters(ConversionTagOptions tagOptions, Edi _logger.Debug("[CONVERSION] Matched provider chapters will be stamped into converted M4B for '{0}' ({1} chapters).", book?.Title ?? edition.Title, chapters.Count); } + private bool TryApplyMultiPartM4bMergeSources(ConversionTagOptions tagOptions, IEnumerable> bookDecisions, string[] inputFiles, Book book) + { + ConversionTagProposalBuilder.ApplyMultiPartMergeTags(tagOptions, bookDecisions.Select(d => d.Item)); + + var mergeSources = _m4bConversionService.ProbeMergeSources(inputFiles); + if (tagOptions != null && mergeSources.ChaptersTxtContent.IsNotNullOrWhiteSpace()) + { + tagOptions.ChaptersTxtContent = mergeSources.ChaptersTxtContent; + tagOptions.ProviderChapterCount = null; + tagOptions.UseFilenamesAsChapters = false; + _logger.Debug("[CONVERSION] Embedded chapters from {0} parts will be carried into the merged M4B for '{1}' ({2} chapters).", inputFiles.Length, book?.Title, mergeSources.EmbeddedChapterCount); + } + + if (!mergeSources.CanCopyAudio) + { + _logger.Debug("[CONVERSION] Multi-part M4B parts for '{0}' differ in codec, sample rate or channels; re-encoding while merging.", book?.Title); + } + + return mergeSources.CanCopyAudio; + } + private void ApplyConversionCoverFallback(ConversionTagOptions tagOptions, Book book, Edition matchedEdition) { if (tagOptions == null) @@ -2459,7 +2507,7 @@ private bool CanReplaceExistingConversionDestination(BookFile existingTracked, B return existingEdition?.BookId == book.Id; } - private bool TryFindReusableConversionArtifact(string workRoot, string[] inputFiles, QualityModel targetQuality, ConversionTagOptions tagOptions, int audioBitrate, int audioChannels, out string outputPath) + private bool TryFindReusableConversionArtifact(string workRoot, string[] inputFiles, QualityModel targetQuality, ConversionTagOptions tagOptions, int audioBitrate, int audioChannels, bool noConversion, out string outputPath) { outputPath = null; if (workRoot.IsNullOrWhiteSpace() || !Directory.Exists(workRoot)) @@ -2497,6 +2545,7 @@ private bool TryFindReusableConversionArtifact(string workRoot, string[] inputFi manifest.TargetQualityId != targetQuality?.Quality?.Id || manifest.AudioBitrate != audioBitrate || manifest.AudioChannels != audioChannels || + manifest.NoConversion != noConversion || !string.Equals(manifest.TagSignature, tagSignature, StringComparison.Ordinal) || !ConversionArtifactSourcesMatch(currentSources, manifest.Sources)) { @@ -2529,7 +2578,7 @@ private bool TryFindReusableConversionArtifact(string workRoot, string[] inputFi return false; } - private void WriteConversionArtifactManifest(string workFolder, string outputPath, string[] inputFiles, QualityModel targetQuality, ConversionTagOptions tagOptions, int audioBitrate, int audioChannels) + private void WriteConversionArtifactManifest(string workFolder, string outputPath, string[] inputFiles, QualityModel targetQuality, ConversionTagOptions tagOptions, int audioBitrate, int audioChannels, bool noConversion) { try { @@ -2547,6 +2596,7 @@ private void WriteConversionArtifactManifest(string workFolder, string outputPat TargetQualityName = targetQuality?.Quality?.Name, AudioBitrate = audioBitrate, AudioChannels = audioChannels, + NoConversion = noConversion, TagMode = tagOptions?.Mode, TagSignature = GetConversionTagSignature(tagOptions), Sources = sources @@ -2608,7 +2658,7 @@ private static string GetConversionTagSignature(ConversionTagOptions tagOptions) return string.Empty; } - return string.Join("\u001f", + var signature = string.Join("\u001f", tagOptions.Mode, tagOptions.Name, tagOptions.Album, @@ -2630,6 +2680,10 @@ private static string GetConversionTagSignature(ConversionTagOptions tagOptions) tagOptions.ProviderChapterCount?.ToString(CultureInfo.InvariantCulture), tagOptions.CoverPolicySignature, HashString(tagOptions.ChaptersTxtContent)); + + return tagOptions.RemoveTags?.Count > 0 + ? signature + "\u001f" + string.Join(",", tagOptions.RemoveTags) + : signature; } private static string HashString(string value) diff --git a/src/NzbDrone.Core/MediaFiles/ConversionJob.cs b/src/NzbDrone.Core/MediaFiles/ConversionJob.cs index 912d04ae..31a04da8 100644 --- a/src/NzbDrone.Core/MediaFiles/ConversionJob.cs +++ b/src/NzbDrone.Core/MediaFiles/ConversionJob.cs @@ -49,6 +49,7 @@ public class ConversionJobRequest public string TargetQualityName { get; set; } public int AudioBitrate { get; set; } public int AudioChannels { get; set; } + public bool NoConversion { get; set; } public long ExpectedSourceDurationTicks { get; set; } public string TagSignature { get; set; } public ConversionTagOptions TagOptions { get; set; } @@ -69,6 +70,7 @@ public class ConversionArtifactManifest public string TargetQualityName { get; set; } public int AudioBitrate { get; set; } public int AudioChannels { get; set; } + public bool NoConversion { get; set; } public string TagMode { get; set; } public string TagSignature { get; set; } public List Sources { get; set; } = new(); diff --git a/src/NzbDrone.Core/MediaFiles/ConversionJobService.cs b/src/NzbDrone.Core/MediaFiles/ConversionJobService.cs index 5a6174b7..7e1e7e4d 100644 --- a/src/NzbDrone.Core/MediaFiles/ConversionJobService.cs +++ b/src/NzbDrone.Core/MediaFiles/ConversionJobService.cs @@ -489,6 +489,7 @@ private void ExecuteJob(ConversionJob job, ActiveConversion active) TempDirectory = request.WorkFolder, AudioBitrate = request.AudioBitrate, AudioChannels = request.AudioChannels, + NoConversion = request.NoConversion, ExpectedSourceDuration = TimeSpan.FromTicks(request.ExpectedSourceDurationTicks), Jobs = threadPlan.ParallelFiles, FfmpegThreads = threadPlan.FfmpegThreads, @@ -801,6 +802,7 @@ private static bool HasReusableArtifact(ConversionJobRequest request) manifest.TargetQualityId == request.TargetQualityId && manifest.AudioBitrate == request.AudioBitrate && manifest.AudioChannels == request.AudioChannels && + manifest.NoConversion == request.NoConversion && string.Equals(manifest.TagSignature, request.TagSignature, StringComparison.Ordinal) && manifest.Sources.Count == request.Sources.Count && manifest.Sources.Zip(request.Sources, SourcesMatch).All(matches => matches); @@ -830,6 +832,7 @@ private static void WriteArtifactManifest(ConversionJobRequest request) TargetQualityName = request.TargetQualityName, AudioBitrate = request.AudioBitrate, AudioChannels = request.AudioChannels, + NoConversion = request.NoConversion, TagMode = request.TagOptions?.Mode, TagSignature = request.TagSignature, Sources = request.Sources diff --git a/src/NzbDrone.Core/MediaFiles/M4bConversionService.cs b/src/NzbDrone.Core/MediaFiles/M4bConversionService.cs index eb134747..c8f270a3 100644 --- a/src/NzbDrone.Core/MediaFiles/M4bConversionService.cs +++ b/src/NzbDrone.Core/MediaFiles/M4bConversionService.cs @@ -4,6 +4,7 @@ using System.IO; using System.Linq; using System.Text; +using System.Text.Json; using System.Text.RegularExpressions; using System.Threading; using NLog; @@ -18,6 +19,7 @@ public interface IM4bConversionService ConversionResult ConvertToM4b(string[] inputFiles, string outputFile, ConversionOptions options = null); bool CanConvert(string[] inputFiles); ConversionEstimate EstimateConversion(string[] inputFiles); + M4bMergeSourceInfo ProbeMergeSources(string[] inputFiles); } public enum ConversionFailureCategory @@ -257,6 +259,197 @@ public ConversionEstimate EstimateConversion(string[] inputFiles) return estimate; } + public M4bMergeSourceInfo ProbeMergeSources(string[] inputFiles) + { + var info = new M4bMergeSourceInfo(); + if (inputFiles == null || inputFiles.Length < 2) + { + return info; + } + + var formats = new HashSet(StringComparer.OrdinalIgnoreCase); + var chapters = new List<(TimeSpan Start, string Title)>(); + var offset = TimeSpan.Zero; + var embeddedChapterCount = 0; + + foreach (var inputFile in inputFiles) + { + var source = ProbeMergeSource(inputFile); + if (source == null || source.Duration <= TimeSpan.Zero) + { + return new M4bMergeSourceInfo(); + } + + formats.Add(source.AudioFormat ?? string.Empty); + + var partChapters = source.Chapters.Where(chapter => chapter.Start < source.Duration).ToList(); + if (partChapters.Count > 0) + { + embeddedChapterCount += partChapters.Count; + chapters.AddRange(partChapters.Select(chapter => + (offset + chapter.Start, chapter.Title.IsNullOrWhiteSpace() ? Path.GetFileNameWithoutExtension(inputFile) : chapter.Title))); + } + else + { + chapters.Add((offset, Path.GetFileNameWithoutExtension(inputFile))); + } + + offset += source.Duration; + } + + info.CanCopyAudio = formats.Count == 1 && formats.Single().IsNotNullOrWhiteSpace(); + + if (embeddedChapterCount > 0) + { + info.EmbeddedChapterCount = embeddedChapterCount; + info.ChaptersTxtContent = BuildChaptersTxt(chapters, offset); + } + + return info; + } + + private MergeSourceProbe ProbeMergeSource(string inputFile) + { + try + { + var output = _externalTools.ExecuteFFprobe( + new[] + { + "-v", "error", + "-select_streams", "a:0", + "-show_entries", "format=duration:stream=codec_name,sample_rate,channels", + "-show_chapters", + "-of", "json", + inputFile + }, + timeoutMs: 30000); + + if (output.IsNullOrWhiteSpace()) + { + return null; + } + + using var document = JsonDocument.Parse(output); + var root = document.RootElement; + var probe = new MergeSourceProbe(); + + if (root.TryGetProperty("format", out var format) && + TryGetSeconds(format, "duration", out var duration)) + { + probe.Duration = duration; + } + + if (root.TryGetProperty("streams", out var streams) && + streams.ValueKind == JsonValueKind.Array && + streams.GetArrayLength() > 0) + { + var stream = streams[0]; + var codec = GetJsonString(stream, "codec_name"); + var sampleRate = GetJsonString(stream, "sample_rate"); + var channels = GetJsonString(stream, "channels"); + if (codec.IsNotNullOrWhiteSpace() && sampleRate.IsNotNullOrWhiteSpace() && channels.IsNotNullOrWhiteSpace()) + { + probe.AudioFormat = $"{codec}/{sampleRate}/{channels}"; + } + } + + if (root.TryGetProperty("chapters", out var chapters) && + chapters.ValueKind == JsonValueKind.Array) + { + foreach (var chapter in chapters.EnumerateArray()) + { + if (!TryGetSeconds(chapter, "start_time", out var start) || start < TimeSpan.Zero) + { + continue; + } + + var title = chapter.TryGetProperty("tags", out var tags) ? GetJsonString(tags, "title") : null; + probe.Chapters.Add((start, SanitizeChapterTitle(title))); + } + + probe.Chapters.Sort((left, right) => left.Start.CompareTo(right.Start)); + } + + return probe; + } + catch (Exception ex) + { + _logger.Debug(ex, "Unable to probe multi-part M4B source {0}", inputFile); + return null; + } + } + + private static bool TryGetSeconds(JsonElement element, string propertyName, out TimeSpan value) + { + value = TimeSpan.Zero; + var raw = GetJsonString(element, propertyName); + if (raw.IsNullOrWhiteSpace() || + !double.TryParse(raw, NumberStyles.Float, CultureInfo.InvariantCulture, out var seconds) || + double.IsNaN(seconds) || + double.IsInfinity(seconds)) + { + return false; + } + + value = TimeSpan.FromSeconds(seconds); + return true; + } + + private static string GetJsonString(JsonElement element, string propertyName) + { + if (element.ValueKind != JsonValueKind.Object || + !element.TryGetProperty(propertyName, out var property)) + { + return null; + } + + return property.ValueKind switch + { + JsonValueKind.String => property.GetString(), + JsonValueKind.Number => property.GetRawText(), + _ => null + }; + } + + private static string BuildChaptersTxt(IEnumerable<(TimeSpan Start, string Title)> chapters, TimeSpan totalLength) + { + var lines = new List + { + "## total-length " + FormatChapterTime(totalLength) + }; + + lines.AddRange(chapters.Select(chapter => FormatChapterTime(chapter.Start) + " " + SanitizeChapterTitle(chapter.Title))); + + return string.Join(Environment.NewLine, lines); + } + + private static string FormatChapterTime(TimeSpan value) + { + return string.Format( + CultureInfo.InvariantCulture, + "{0:D2}:{1:D2}:{2:D2}.{3:D3}", + (int)Math.Floor(value.TotalHours), + value.Minutes, + value.Seconds, + value.Milliseconds); + } + + private static string SanitizeChapterTitle(string title) + { + return string.Join(" ", (title ?? string.Empty) + .Replace('\r', ' ') + .Replace('\n', ' ') + .Replace('\t', ' ') + .Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries)); + } + + private sealed class MergeSourceProbe + { + public TimeSpan Duration { get; set; } + public string AudioFormat { get; set; } + public List<(TimeSpan Start, string Title)> Chapters { get; } = new(); + } + private IReadOnlyList BuildM4bToolArguments(string[] inputFiles, string outputFile, ConversionOptions options, ConversionTagOptions tagOptions = null) { var args = new List @@ -278,25 +471,32 @@ private IReadOnlyList BuildM4bToolArguments(string[] inputFiles, string args.Add($"--tmp-dir={options.TempDirectory}"); } - // Audio quality settings - if (options.AudioBitrate > 0) + if (options.NoConversion) { - args.Add($"--audio-bitrate={options.AudioBitrate}k"); + args.Add("--no-conversion"); } else { - // Default to 64kbps for audiobooks - args.Add("--audio-bitrate=64k"); - } + // Audio quality settings + if (options.AudioBitrate > 0) + { + args.Add($"--audio-bitrate={options.AudioBitrate}k"); + } + else + { + // Default to 64kbps for audiobooks + args.Add("--audio-bitrate=64k"); + } - if (options.AudioSampleRate > 0) - { - args.Add($"--audio-samplerate={options.AudioSampleRate}"); - } + if (options.AudioSampleRate > 0) + { + args.Add($"--audio-samplerate={options.AudioSampleRate}"); + } - if (options.AudioChannels > 0) - { - args.Add($"--audio-channels={options.AudioChannels}"); + if (options.AudioChannels > 0) + { + args.Add($"--audio-channels={options.AudioChannels}"); + } } // Performance settings @@ -430,6 +630,7 @@ private static ConversionTagOptions CloneTagOptions(ConversionTagOptions source) EncodedBy = source.EncodedBy, UseFilenamesAsChapters = source.UseFilenamesAsChapters, IgnoreSourceTags = source.IgnoreSourceTags, + RemoveTags = source.RemoveTags?.ToList(), ChaptersTxtContent = source.ChaptersTxtContent, ProviderChapterCount = source.ProviderChapterCount, CoverPolicySignature = source.CoverPolicySignature, @@ -468,6 +669,11 @@ private static void AddTagArguments(List args, ConversionTagOptions tagO { args.Add("--ignore-source-tags"); } + + if (tagOptions.RemoveTags?.Count > 0) + { + args.Add("--remove=" + string.Join(",", tagOptions.RemoveTags)); + } } private static void AddOptionalArgument(List args, string name, string value) @@ -1194,6 +1400,7 @@ public class ConversionOptions public int FfmpegThreads { get; set; } = 0; // 0 = ffmpeg default public bool SkipCover { get; set; } = false; public bool Force { get; set; } = true; // Overwrite existing + public bool NoConversion { get; set; } public string TempDirectory { get; set; } public TimeSpan ExpectedSourceDuration { get; set; } public int TimeoutMs { get; set; } @@ -1222,12 +1429,20 @@ public class ConversionTagOptions public string EncodedBy { get; set; } public bool UseFilenamesAsChapters { get; set; } public bool IgnoreSourceTags { get; set; } + public List RemoveTags { get; set; } public string ChaptersTxtContent { get; set; } public int? ProviderChapterCount { get; set; } public string CoverPolicySignature { get; set; } public string ManifestJson { get; set; } } + public class M4bMergeSourceInfo + { + public bool CanCopyAudio { get; set; } + public string ChaptersTxtContent { get; set; } + public int EmbeddedChapterCount { get; set; } + } + public class ConversionProgressUpdate { public decimal? Progress { get; set; } diff --git a/src/NzbDrone.Core/Profiles/Qualities/QualityProfile.cs b/src/NzbDrone.Core/Profiles/Qualities/QualityProfile.cs index 17e8a237..7e04c4ee 100644 --- a/src/NzbDrone.Core/Profiles/Qualities/QualityProfile.cs +++ b/src/NzbDrone.Core/Profiles/Qualities/QualityProfile.cs @@ -27,6 +27,7 @@ public QualityProfile() public bool PreferCustomFormatsOverQuality { get; set; } public bool ConvertMp3ToM4b { get; set; } // Only applies to audiobook profiles public int? ConvertToQualityId { get; set; } + public bool MergeMultiPartFiles { get; set; } public int Cutoff { get; set; } public int MinFormatScore { get; set; } public int CutoffFormatScore { get; set; } diff --git a/src/NzbDrone.Core/Qualities/QualityConversionHelper.cs b/src/NzbDrone.Core/Qualities/QualityConversionHelper.cs index 5f2d4ae2..d485e149 100644 --- a/src/NzbDrone.Core/Qualities/QualityConversionHelper.cs +++ b/src/NzbDrone.Core/Qualities/QualityConversionHelper.cs @@ -68,6 +68,21 @@ public static Quality GetPlannedConversionTarget(QualityProfile profile, Quality return null; } + public static bool ShouldMergeMultiPartM4b(QualityProfile profile, QualityModel sourceQuality) + { + if (profile == null || !profile.MergeMultiPartFiles || profile.ProfileType != ProfileType.Audiobook) + { + return false; + } + + if (GetConversionTargetQualityId(profile) != Quality.M4B.Id) + { + return false; + } + + return (sourceQuality?.Quality ?? Quality.Unknown) == Quality.M4B; + } + private static int? GetConversionTargetQualityId(QualityProfile profile) { if (profile.ConvertToQualityId.HasValue && profile.ConvertToQualityId.Value > 0)