Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ class EditQualityProfileModalContent extends Component {
upgradeAllowed,
preferCustomFormatsOverQuality,
convertToQualityId,
mergeMultiPartFiles,
cutoff,
minFormatScore,
cutoffFormatScore,
Expand All @@ -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);
Expand Down Expand Up @@ -339,6 +341,23 @@ class EditQualityProfileModalContent extends Component {
</FormGroup>
}

{
isAudiobookProfile && Number(convertToQualityIdField.value) > 0 &&
<FormGroup size={sizes.EXTRA_SMALL}>
<FormLabel size={sizes.SMALL}>
{translate('MergeMultiPartFiles')}
</FormLabel>

<FormInputGroup
type={inputTypes.CHECK}
name="mergeMultiPartFiles"
{...mergeMultiPartFilesField}
helpText={translate('MergeMultiPartFilesHelpText')}
onChange={onInputChange}
/>
</FormGroup>
}

{
isAudiobookProfile && Number(convertToQualityIdField.value) > 0 &&
<FormGroup
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ public class QualityProfileResource : RestResource
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 List<QualityProfileQualityItemResource> Items { get; set; }
public int MinFormatScore { get; set; }
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<QualityProfileQualityItemResource>()).ConvertAll(ToModel),
MinFormatScore = resource.MinFormatScore,
Expand Down
3 changes: 3 additions & 0 deletions src/Chaptarr.Api.V1/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -18337,6 +18337,9 @@
"format": "int32",
"nullable": true
},
"mergeMultiPartFiles": {
"type": "boolean"
},
"cutoff": {
"type": "integer",
"format": "int32"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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++;
Expand Down Expand Up @@ -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++;
Expand Down Expand Up @@ -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<IAuthorService>(),
Proxy<IBookService>(),
CreateEditionService(new List<Edition> { edition }),
Proxy<IRecycleBinProvider>(),
Proxy<IExtraService>(),
new StubMoveBookFiles { DestinationPath = destinationPath },
Proxy<IHistoryService>(),
Proxy<NzbDrone.Core.Download.History.IDownloadHistoryService>(),
new NoOpEventAggregator(),
Proxy<IManageCommandQueue>(),
Proxy<ISeriesBookLinkService>(),
Proxy<ISeriesService>(),
Proxy<IQualityProfileService>(),
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<string, List<string>>
{
["title"] = new() { "Black Sheep" },
["album"] = new() { "Black Sheep (Unabridged)" }
}
}
};

service.Import(
new List<ImportDecision<LocalBook>>
{
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<IAuthorService>(),
Proxy<IBookService>(),
CreateEditionService(new List<Edition> { edition }),
Proxy<IRecycleBinProvider>(),
Proxy<IExtraService>(),
new StubMoveBookFiles { DestinationPath = destinationPath },
Proxy<IHistoryService>(),
Proxy<NzbDrone.Core.Download.History.IDownloadHistoryService>(),
new NoOpEventAggregator(),
Proxy<IManageCommandQueue>(),
Proxy<ISeriesBookLinkService>(),
Proxy<ISeriesService>(),
Proxy<IQualityProfileService>(),
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<ImportDecision<LocalBook>>
{
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()
{
Expand Down
Loading