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)