diff --git a/Source/FileLiberator/DownloadOptions.Factory.cs b/Source/FileLiberator/DownloadOptions.Factory.cs index 508470ea..4ed0c438 100644 --- a/Source/FileLiberator/DownloadOptions.Factory.cs +++ b/Source/FileLiberator/DownloadOptions.Factory.cs @@ -194,7 +194,11 @@ var chapters stripBranding(chapters, licInfo.ContentMetadata.ChapterInfo.BrandIntroDurationMs, licInfo.ContentMetadata.ChapterInfo.BrandOutroDurationMs); if (config.SplitFilesByChapter) + { combineShortChapters(chapters, config.MinimumFileDuration * 1000); + if (config.MaximumFileDuration > 0) + splitLongChapters(chapters, config.MaximumFileDuration * 1000); + } var dlOptions = new DownloadOptions(config, libraryBook, licInfo) { @@ -390,6 +394,39 @@ public static void combineShortChapters(List chapters, long minChapterL } } + public static void splitLongChapters(List chapters, long maxChapterLengthMs) + { + for (int i = 0; i < chapters.Count; i++) + { + if (chapters[i].LengthMs <= maxChapterLengthMs) + continue; + + var original = chapters[i]; + int totalParts = (int)Math.Ceiling((double)original.LengthMs / maxChapterLengthMs); + var parts = new List(totalParts); + long offset = original.StartOffsetMs; + long remaining = original.LengthMs; + + for (int part = 1; part <= totalParts; part++) + { + long partLength = Math.Min(remaining, maxChapterLengthMs); + parts.Add(new Chapter + { + Title = $"{original.Title} (Part {part} of {totalParts})", + StartOffsetMs = offset, + StartOffsetSec = offset / 1000, + LengthMs = partLength, + }); + offset += partLength; + remaining -= partLength; + } + + chapters.RemoveAt(i); + chapters.InsertRange(i, parts); + i += parts.Count - 1; + } + } + public static void stripBranding(List chapters, long introMs, long outroMs) { chapters[0].LengthMs -= introMs; diff --git a/Source/LibationAvalonia/Controls/Settings/Audio.axaml b/Source/LibationAvalonia/Controls/Settings/Audio.axaml index 74d0b4b6..0ac3085d 100644 --- a/Source/LibationAvalonia/Controls/Settings/Audio.axaml +++ b/Source/LibationAvalonia/Controls/Settings/Audio.axaml @@ -154,14 +154,14 @@ - - - + Grid.RowDefinitions="Auto,Auto,Auto"> + + + - + + + + + field; set => this.RaiseAndSetIfChanged(ref field, value); } public string DecryptToLossyTip => Configuration.GetHelpText(nameof(DecryptToLossy)); public string MinimumFileDurationTip => Configuration.GetHelpText(nameof(MinimumFileDuration)); + public string MaximumFileDurationTip => Configuration.GetHelpText(nameof(MaximumFileDuration)); public bool MoveMoovToBeginning { get; set; } public bool LameDownsampleMono { get; set; } = Design.IsDesignMode; @@ -158,6 +162,7 @@ public void SaveSettings(Configuration config) public bool SplitFilesByChapter { get => field; set { this.RaiseAndSetIfChanged(ref field, value); } } public int MinimumFileDuration { get => field; set { this.RaiseAndSetIfChanged(ref field, value); } } + public int MaximumFileDuration { get => field; set { this.RaiseAndSetIfChanged(ref field, value); } } public bool LameTargetBitrate { get => field; set { this.RaiseAndSetIfChanged(ref field, value); } } public bool LameMatchSource { get => field; set { this.RaiseAndSetIfChanged(ref field, value); } } public int LameBitrate { get => field; set { this.RaiseAndSetIfChanged(ref field, value); } } diff --git a/Source/LibationFileManager/Configuration.HelpText.cs b/Source/LibationFileManager/Configuration.HelpText.cs index 28808002..055b236e 100644 --- a/Source/LibationFileManager/Configuration.HelpText.cs +++ b/Source/LibationFileManager/Configuration.HelpText.cs @@ -88,6 +88,13 @@ this duration will be merged with the following chapter. Merged chapter titles will be joined with a space between them. """ }, + {nameof(MaximumFileDuration), """ + The maximum duration (in minutes) for a chapter to + be split into its own file. Chapters longer than + this duration will be divided into equal-length + parts. Part titles are suffixed with "(Part N of M)". + Set to 0 to disable. + """ }, {nameof(SpatialAudioCodec), """ The Dolby Digital Plus (E-AC-3) codec is more widely supported than the AC-4 codec, but E-AC-3 files are diff --git a/Source/LibationFileManager/Configuration.PersistentSettings.cs b/Source/LibationFileManager/Configuration.PersistentSettings.cs index 293e204d..91984748 100644 --- a/Source/LibationFileManager/Configuration.PersistentSettings.cs +++ b/Source/LibationFileManager/Configuration.PersistentSettings.cs @@ -173,6 +173,9 @@ public string InProgress [Description("Minimum file duration (seconds)")] public int MinimumFileDuration { get => Math.Max(0, GetNonString(defaultValue: 3)); set => SetNonString(value); } + [Description("Maximum file duration (seconds)")] + public int MaximumFileDuration { get => Math.Max(0, GetNonString(defaultValue: 0)); set => SetNonString(value); } + [Description("Merge Opening/End Credits into the following/preceding chapters")] public bool MergeOpeningAndEndCredits { get => GetNonString(defaultValue: false); set => SetNonString(value); } diff --git a/Source/LibationWinForms/Dialogs/SettingsDialog.AudioSettings.cs b/Source/LibationWinForms/Dialogs/SettingsDialog.AudioSettings.cs index 492ccca7..169dd1ee 100644 --- a/Source/LibationWinForms/Dialogs/SettingsDialog.AudioSettings.cs +++ b/Source/LibationWinForms/Dialogs/SettingsDialog.AudioSettings.cs @@ -20,6 +20,7 @@ private void Load_AudioSettings(Configuration config) this.combineNestedChapterTitlesCbox.Text = desc(nameof(config.CombineNestedChapterTitles)); this.splitFilesByChapterCbox.Text = desc(nameof(config.SplitFilesByChapter)); this.minFileDurationLbl.Text = desc(nameof(config.MinimumFileDuration)); + this.maxFileDurationLbl.Text = desc(nameof(config.MaximumFileDuration)); this.mergeOpeningEndCreditsCbox.Text = desc(nameof(config.MergeOpeningAndEndCredits)); this.stripAudibleBrandingCbox.Text = desc(nameof(config.StripAudibleBrandAudio)); this.stripUnabridgedCbox.Text = desc(nameof(config.StripUnabridged)); @@ -80,6 +81,7 @@ private void Load_AudioSettings(Configuration config) combineNestedChapterTitlesCbox.Checked = config.CombineNestedChapterTitles; splitFilesByChapterCbox.Checked = config.SplitFilesByChapter; minFileDurationNud.Value = config.MinimumFileDuration; + maxFileDurationNud.Value = config.MaximumFileDuration; mergeOpeningEndCreditsCbox.Checked = config.MergeOpeningAndEndCredits; stripUnabridgedCbox.Checked = config.StripUnabridged; stripAudibleBrandingCbox.Checked = config.StripAudibleBrandAudio; @@ -124,6 +126,7 @@ private void Save_AudioSettings(Configuration config) config.CombineNestedChapterTitles = combineNestedChapterTitlesCbox.Checked; config.SplitFilesByChapter = splitFilesByChapterCbox.Checked; config.MinimumFileDuration = (int)minFileDurationNud.Value; + config.MaximumFileDuration = (int)maxFileDurationNud.Value; config.MergeOpeningAndEndCredits = mergeOpeningEndCreditsCbox.Checked; config.StripUnabridged = stripUnabridgedCbox.Checked; config.StripAudibleBrandAudio = stripAudibleBrandingCbox.Checked; @@ -159,7 +162,7 @@ private void LameMatchSourceBRCbox_CheckedChanged(object sender, EventArgs e) private void splitFilesByChapterCbox_CheckedChanged(object sender, EventArgs e) { - chapterTitleTemplateGb.Enabled = minFileDurationNud.Enabled = minFileDurationLbl.Enabled = splitFilesByChapterCbox.Checked; + chapterTitleTemplateGb.Enabled = minFileDurationNud.Enabled = minFileDurationLbl.Enabled = maxFileDurationNud.Enabled = maxFileDurationLbl.Enabled = splitFilesByChapterCbox.Checked; } private void chapterTitleTemplateBtn_Click(object sender, EventArgs e) diff --git a/Source/LibationWinForms/Dialogs/SettingsDialog.Designer.cs b/Source/LibationWinForms/Dialogs/SettingsDialog.Designer.cs index f2a6ed4d..0d6b34e7 100644 --- a/Source/LibationWinForms/Dialogs/SettingsDialog.Designer.cs +++ b/Source/LibationWinForms/Dialogs/SettingsDialog.Designer.cs @@ -100,6 +100,8 @@ private void InitializeComponent() audiobookFixupsGb = new System.Windows.Forms.GroupBox(); minFileDurationLbl = new System.Windows.Forms.Label(); minFileDurationNud = new System.Windows.Forms.NumericUpDown(); + maxFileDurationLbl = new System.Windows.Forms.Label(); + maxFileDurationNud = new System.Windows.Forms.NumericUpDown(); stripUnabridgedCbox = new System.Windows.Forms.CheckBox(); chapterTitleTemplateGb = new System.Windows.Forms.GroupBox(); chapterTitleTemplateBtn = new System.Windows.Forms.Button(); @@ -156,6 +158,7 @@ private void InitializeComponent() tab4AudioFileOptions.SuspendLayout(); audiobookFixupsGb.SuspendLayout(); ((System.ComponentModel.ISupportInitialize)minFileDurationNud).BeginInit(); + ((System.ComponentModel.ISupportInitialize)maxFileDurationNud).BeginInit(); chapterTitleTemplateGb.SuspendLayout(); lameOptionsGb.SuspendLayout(); lameBitrateGb.SuspendLayout(); @@ -950,12 +953,14 @@ private void InitializeComponent() // audiobookFixupsGb.Controls.Add(minFileDurationLbl); audiobookFixupsGb.Controls.Add(minFileDurationNud); + audiobookFixupsGb.Controls.Add(maxFileDurationLbl); + audiobookFixupsGb.Controls.Add(maxFileDurationNud); audiobookFixupsGb.Controls.Add(splitFilesByChapterCbox); audiobookFixupsGb.Controls.Add(stripUnabridgedCbox); audiobookFixupsGb.Controls.Add(stripAudibleBrandingCbox); audiobookFixupsGb.Location = new System.Drawing.Point(6, 229); audiobookFixupsGb.Name = "audiobookFixupsGb"; - audiobookFixupsGb.Size = new System.Drawing.Size(416, 153); + audiobookFixupsGb.Size = new System.Drawing.Size(416, 175); audiobookFixupsGb.TabIndex = 14; audiobookFixupsGb.TabStop = false; audiobookFixupsGb.Text = "Audiobook Fix-ups"; @@ -971,14 +976,33 @@ private void InitializeComponent() minFileDurationLbl.Text = "[MinimumFileDuration desc]"; // // minFileDurationNud - // + // minFileDurationNud.Location = new System.Drawing.Point(243, 41); minFileDurationNud.Maximum = new decimal(new int[] { 120, 0, 0, 0 }); minFileDurationNud.Name = "minFileDurationNud"; minFileDurationNud.Size = new System.Drawing.Size(51, 23); minFileDurationNud.TabIndex = 17; minFileDurationNud.TextAlign = System.Windows.Forms.HorizontalAlignment.Center; - // + // + // maxFileDurationLbl + // + maxFileDurationLbl.AutoSize = true; + maxFileDurationLbl.Location = new System.Drawing.Point(34, 66); + maxFileDurationLbl.Margin = new System.Windows.Forms.Padding(0, 0, 2, 0); + maxFileDurationLbl.Name = "maxFileDurationLbl"; + maxFileDurationLbl.Size = new System.Drawing.Size(159, 15); + maxFileDurationLbl.TabIndex = 30; + maxFileDurationLbl.Text = "[MaximumFileDuration desc]"; + // + // maxFileDurationNud + // + maxFileDurationNud.Location = new System.Drawing.Point(243, 63); + maxFileDurationNud.Maximum = new decimal(new int[] { 1440, 0, 0, 0 }); + maxFileDurationNud.Name = "maxFileDurationNud"; + maxFileDurationNud.Size = new System.Drawing.Size(51, 23); + maxFileDurationNud.TabIndex = 31; + maxFileDurationNud.TextAlign = System.Windows.Forms.HorizontalAlignment.Center; + // // stripUnabridgedCbox // stripUnabridgedCbox.AutoSize = true; @@ -1508,6 +1532,7 @@ private void InitializeComponent() audiobookFixupsGb.ResumeLayout(false); audiobookFixupsGb.PerformLayout(); ((System.ComponentModel.ISupportInitialize)minFileDurationNud).EndInit(); + ((System.ComponentModel.ISupportInitialize)maxFileDurationNud).EndInit(); chapterTitleTemplateGb.ResumeLayout(false); chapterTitleTemplateGb.PerformLayout(); lameOptionsGb.ResumeLayout(false); @@ -1638,5 +1663,7 @@ private void InitializeComponent() private System.Windows.Forms.CheckBox importPlusTitlesCb; private System.Windows.Forms.Label minFileDurationLbl; private System.Windows.Forms.NumericUpDown minFileDurationNud; + private System.Windows.Forms.Label maxFileDurationLbl; + private System.Windows.Forms.NumericUpDown maxFileDurationNud; } } \ No newline at end of file diff --git a/Source/_Tests/FileLiberator.Tests/DownloadDecryptBookTests.cs b/Source/_Tests/FileLiberator.Tests/DownloadDecryptBookTests.cs index a1694719..9a90071e 100644 --- a/Source/_Tests/FileLiberator.Tests/DownloadDecryptBookTests.cs +++ b/Source/_Tests/FileLiberator.Tests/DownloadDecryptBookTests.cs @@ -538,4 +538,116 @@ private static void checkChapters(IList value, IList expected) value[i].LengthMs.Should().Be(expected[i].LengthMs); } } + + [TestMethod] + public void SplitLongChapters_ShortChapter_NotSplit() + { + var chapters = new List + { + new() { Title = "Chapter 1", StartOffsetMs = 0, StartOffsetSec = 0, LengthMs = 60_000 }, + }; + + DownloadOptions.splitLongChapters(chapters, 120_000); + + chapters.Count.Should().Be(1); + chapters[0].Title.Should().Be("Chapter 1"); + chapters[0].LengthMs.Should().Be(60_000); + } + + [TestMethod] + public void SplitLongChapters_ExactlyAtLimit_NotSplit() + { + var chapters = new List + { + new() { Title = "Chapter 1", StartOffsetMs = 0, StartOffsetSec = 0, LengthMs = 120_000 }, + }; + + DownloadOptions.splitLongChapters(chapters, 120_000); + + chapters.Count.Should().Be(1); + chapters[0].Title.Should().Be("Chapter 1"); + } + + [TestMethod] + public void SplitLongChapters_TwoParts_SplitsEvenly() + { + var chapters = new List + { + new() { Title = "Chapter 1", StartOffsetMs = 0, StartOffsetSec = 0, LengthMs = 200_000 }, + }; + + DownloadOptions.splitLongChapters(chapters, 100_000); + + chapters.Count.Should().Be(2); + chapters[0].Title.Should().Be("Chapter 1 (Part 1 of 2)"); + chapters[0].StartOffsetMs.Should().Be(0); + chapters[0].StartOffsetSec.Should().Be(0); + chapters[0].LengthMs.Should().Be(100_000); + chapters[1].Title.Should().Be("Chapter 1 (Part 2 of 2)"); + chapters[1].StartOffsetMs.Should().Be(100_000); + chapters[1].StartOffsetSec.Should().Be(100); + chapters[1].LengthMs.Should().Be(100_000); + } + + [TestMethod] + public void SplitLongChapters_ThreeParts_LastPartShorter() + { + var chapters = new List + { + new() { Title = "Long Chapter", StartOffsetMs = 0, StartOffsetSec = 0, LengthMs = 250_000 }, + }; + + DownloadOptions.splitLongChapters(chapters, 100_000); + + chapters.Count.Should().Be(3); + chapters[0].LengthMs.Should().Be(100_000); + chapters[1].LengthMs.Should().Be(100_000); + chapters[2].LengthMs.Should().Be(50_000); + chapters[2].StartOffsetMs.Should().Be(200_000); + chapters[2].StartOffsetSec.Should().Be(200); + chapters[2].Title.Should().Be("Long Chapter (Part 3 of 3)"); + } + + [TestMethod] + public void SplitLongChapters_OffsetPreserved() + { + var chapters = new List + { + new() { Title = "Chapter 1", StartOffsetMs = 0, StartOffsetSec = 0, LengthMs = 60_000 }, + new() { Title = "Chapter 2", StartOffsetMs = 60_000, StartOffsetSec = 60, LengthMs = 200_000 }, + }; + + DownloadOptions.splitLongChapters(chapters, 100_000); + + chapters.Count.Should().Be(3); + chapters[0].Title.Should().Be("Chapter 1"); + chapters[1].Title.Should().Be("Chapter 2 (Part 1 of 2)"); + chapters[1].StartOffsetMs.Should().Be(60_000); + chapters[1].StartOffsetSec.Should().Be(60); + chapters[1].LengthMs.Should().Be(100_000); + chapters[2].Title.Should().Be("Chapter 2 (Part 2 of 2)"); + chapters[2].StartOffsetMs.Should().Be(160_000); + chapters[2].StartOffsetSec.Should().Be(160); + chapters[2].LengthMs.Should().Be(100_000); + } + + [TestMethod] + public void SplitLongChapters_MultipleChaptersOnlyLongOnesSplit() + { + var chapters = new List + { + new() { Title = "Short", StartOffsetMs = 0, StartOffsetSec = 0, LengthMs = 30_000 }, + new() { Title = "Long", StartOffsetMs = 30_000, StartOffsetSec = 30, LengthMs = 300_000 }, + new() { Title = "Short2", StartOffsetMs = 330_000, StartOffsetSec = 330, LengthMs = 30_000 }, + }; + + DownloadOptions.splitLongChapters(chapters, 100_000); + + chapters.Count.Should().Be(5); + chapters[0].Title.Should().Be("Short"); + chapters[1].Title.Should().Be("Long (Part 1 of 3)"); + chapters[2].Title.Should().Be("Long (Part 2 of 3)"); + chapters[3].Title.Should().Be("Long (Part 3 of 3)"); + chapters[4].Title.Should().Be("Short2"); + } }