From 6ab82dba7bafe64da76e923ef4458409e5ffbd6d Mon Sep 17 00:00:00 2001 From: Michael Bucari-Tovo Date: Thu, 14 Aug 2025 12:46:10 -0600 Subject: [PATCH 1/7] Add audio format info to wiki --- Documentation/Advanced.md | 2 +- Documentation/AudioFileFormats.md | 99 +++++++++++++++++++ Documentation/DolbyAtmos.md | 30 ------ Documentation/FrequentlyAskedQuestions.md | 10 +- Documentation/images/AudioFormatSettings.png | Bin 0 -> 20383 bytes README.md | 1 + 6 files changed, 109 insertions(+), 33 deletions(-) create mode 100644 Documentation/AudioFileFormats.md delete mode 100644 Documentation/DolbyAtmos.md create mode 100644 Documentation/images/AudioFormatSettings.png diff --git a/Documentation/Advanced.md b/Documentation/Advanced.md index 9ad2ca08..dfee661f 100644 --- a/Documentation/Advanced.md +++ b/Documentation/Advanced.md @@ -12,7 +12,7 @@ - [Custom File Naming](NamingTemplates.md) - [Command Line Interface](#command-line-interface) - [Custom Theme Colors](#custom-theme-colors) (Chardonnay Only) -- [Dolby Atmos, Widevine, Spacial Audio, 4D](DolbyAtmos.md) +- [Audio Formats (Dolby Atmos, Widevine, Spacial Audio)](AudioFileFormats.md) diff --git a/Documentation/AudioFileFormats.md b/Documentation/AudioFileFormats.md new file mode 100644 index 00000000..f5b1ae67 --- /dev/null +++ b/Documentation/AudioFileFormats.md @@ -0,0 +1,99 @@ +# Audio Formats Produced by Libation + +Libation will download audio in a number of different audio formats, depending on the settings you choose within Libation and the per-title availability of audio formats from Audible. The Libation settings which affect the format downloaded by Libation are shown in the Settings menu screenshot below. + +Notes: +- Audiobook file extensions are either `.m4b` or `.mp3`. Libation uses the `.m4b` file extension for all non-MP3 files, regardless of the audio codec contained therein. Some media players don't recognize the `.m4b` file extension and may require the extension be changed to `.m4a` or `.mp4`. +- Most (but not all) podcasts are delivered by Audible as native MP3 files. None of the following audio formats and settings discussions pertain to those podcasts because MP3s have no DRM, and those episodes are copied directly to their output folders. + +![Audio format settings menu](images/AudioFormatSettings.png) + +## Settings Summary +### Audio quality to request from Audible +Audiobooks can be requested from Audible as "Normal" quality or "High" quality, matching the settings in the Audible mobile apps. This setting affects the audio bitrate and, sometimes, the number of audio channels. This setting has no effect on the _audio codec_. + +### Use Widevine DRM +When this setting is disabled, all audiobooks will be downloaded using Audible's in-house DRM (AAX(C)) in the [AAC-LC](#aac-lc) format. +When this setting is enabled, Libation will request audio files protected by Google's Widevine Digital Rights Managements scheme, and two additional settings will be unlocked: [Request xHE-AAC Codec](#request-xhe-aac-codec) and [Request Spatial Audio](#request-spatial-audio) (explained further below). + +If you don't enable either of those additional options, then enabling 'Use Widevine DRM' will have no pratcical effect in nearly all circumstances. Audiobooks will be downloaded in the same [AAC-LC](#aac-lc) format with the same bitrate and the same number of audio channels. On rare occasions, enabling 'Use Widevine DRM' without the other two options will result in audio files with a different bitrate. + +### Request xHE-AAC Codec +Enable this setting to request audiobooks in the [xHE-AAC](#xhe-aac) format. This codec is generally better quality than the [AAC-LC](#aac-lc) codec at the same bitrate, but it isn't as commonly supported by media players, so you may have some difficulty playing these audiobooks. The highest bitrate version of some audiobooks is only available as [xHE-AAC](#xhe-aac). + +### Request Spatial Audio +Enable this setting to request audiobooks in a "spatial" ([Dolby Atmos](#dolby-atmos)) audio format. If an audiobook is not available in a spatial format, it will instead be downloaded in the [xHE-AAC codec](#xhe-aac). + +### Spatial audio codec +Choose whether spatial audiobooks are downloaded in the [E-AC-3](#e-ac-3) or [AC-4](#ac-4) format. + +### Download my books in the original audio format (Lossless) +If selected, Audiobooks will be downloaded and saved in the format delivered by audible (which depends on the settings explained above). Libation will not change the audio. + +### Download my books as .MP3 files (transcode if necessary). +If selected, Libation will decode [AAC-LC](#aac-lc), [xHE-AAC](#xhe-aac), and [E-AC-3](#e-ac-3) audiobooks and re-encode them as MP3s using the MP3 encoder settings ([read about LAME MP3 encoder settings](https://lame.sourceforge.io/lame_ui_example.php)). Note that Libation cannot convert [AC-4](#ac-4) audio to MP3. + +# Audio Formats + +## Traditional Mono and Stereo Formats + +### AAC-LC +#### _Full Name_ +Advanced Audio Coding - Low Complexity +#### _Description_ +This is the base profile for AAC audio and has existed since AAC's initial release in 1997. It enjoys wide support on nearly every conceivable platform capable of playing digital audio, as ubiquitous as MP3. +If Widevine support is not enabled, or if the book is not available in the more high-definition formats, Libation will download audiobooks in this format. + +### MP3 +#### _Full Name_ +MPEG-1 Audio Layer III or MPEG-2 Audio Layer III +#### _Description_ +An older (released in 1991) but still nearly universally supported audio codec. Its audio quality is generally worse than AAC-LC at similar bitrates. Audible delivers some podcasts in MP3 format, but no audiobooks are natively availble as MP3. Libation supports converting Audiobooks delivered in other audio formats to MP3. Note that the MP3 format supports a maximum of two audio channels, so multichannel E-AC-3 audio will be downsampled to stereo or mono (depending on the Libation's settings). [AC-4](#ac-4) cannot be converted to MP3. + +### xHE-AAC +#### _Full Name_ +Extended High-Efficiency Advanced Audio Coding +#### _Description_ +This is a proprietary codec created by the [Fraunhofer Institute for Integrated Circuits IIS](https://www.iis.fraunhofer.de/en/ff/amm/broadcast-streaming/xheaac.html). It combines features of the HE-AAC v2 and the baseline USAC (Unified Speech and Audio Coding) profiles with the parts of the MPEG-D DRC Loudness Control Profile or Dynamic Range Control Profile. Therefore, USAC and xHE-AAC are not synonymous and should not be used interchangeably. A player capable of decoding USAC will not necessarily be able to decode xHE-AAC. + +xHE-AAC boasts significantly higher quality audio at low bitrates. Though it has existed since at least 2016, playback support is still quite limited. FFmpeg has recently added partial decoder support for the USAC profiles, but it is insufficient to decode the xHE-AAC audio files acquired from Audible (due to FFmpeg's lack of support for MPEG Surround for Mono to Stereo Upmixing; ISO 23003-3:2012 §7.11) + +## Dolby Atmos +Atmos is a surround sound technology that expands on existing surround sound systems by adding height channels as well as free-moving sound objects. Audible delivers Dolby Atmos in two formats: E-AC-3 and AC-4. + +Your device's ability to play audio from these formats does not necessarily mean that the audio you are hearing is Atmos (spatial). For instance, downloading the AC-4 codec for Windows ([links in the [Supported media Players](#supported-media-players) section) will enable you to play AC-4 audiobooks, but you'll still need to download [Dolby Access](https://apps.microsoft.com/detail/9n0866fs04w8?hl=en-US&gl=US) and pay $15 to enable _Dolby Atmos For Headphones_. Please refer to [this comment](https://github.com/rmcrackan/Libation/pull/1331#discussion_r2268660524) for additional context. + +### E-AC-3 +#### _Full Name_ +Dolby Digital Plus (a.k.a Enhanced AC-3, DDP, DD+, and EC-3) +#### _Description_ +A proprietary digital audio compression scheme developed by Dolby Digital for the transport and storage of multichannel audio. This format can be extended to add support for Atmos, making the codec _Dolby Digital Plus Atmos_. _Dolby Digital Plus Atmos_ is backwards compatible with Dolby Digital Plus, so any media player capable of playing Dolby Digital Plus can play _Dolby Digital Plus Atmos_. Audible spatial audiobooks downloaded in the E-AC-3 format are _Dolby Digital Plus Atmos_. If they are played by a media player that supports Atmos, they will play as Atmos audio. If they are played by a media player that does not support Atmos, they will be played as traditional 5.1 surround audio. + +### AC-4 +#### _Full Name_ +Dolby AC-4 +#### _Description_ +A proprietary audio compression technology developed by Dolby Digital for the transport and storage of audio channels and/or audio objects. Audible spatial audiobooks downloaded in the AC-4 format are 2-channel AC-4 Immersive Stereo (AC4-IMS) audio, intended for playback in headphones or earbuds (though apparently [not supported on Apple devices](https://github.com/rmcrackan/Libation/issues/996#issuecomment-3169574514)). + +# Supported Media Players +Below is an incomplete matrix of codec support across various media players and platforms. +| Player | [AAC-LC](#aac-lc) | [xHE-AAC](#xhe-aac) | [E-AC-3](#e-ac-3) | [AC-4](#ac-4) | +| :--- | :---: | :---: | :---: | :---: | +|Windows Native Support|Yes|Yes1|Yes2,3|Yes4| +|macOS Native Support|Yes|Yes|Yes3| | +|Android Native Support5|Yes|Yes| | | +|FFmpeg (all platforms)|Yes|Yes6|Yes3|| +|[VLC](https://www.videolan.org/vlc/) (Windows)|Yes| |Yes3 | | +|[foobar2000](https://www.foobar2000.org/components) (Windows and Mac)|Yes|Yes7 | | | +|[PotPlayer](https://potplayer.daum.net/) (Windows)|Yes|Yes|Yes3| | +|[Samsung Media Player](https://play.google.com/store/apps/details?id=com.sec.android.app.music)8 (Samsung devices) |Yes|Yes|Yes|Yes| + +1. Windows 11 22H2 and later +2. On Windows [prior to Windows 11, version 24H2](https://support.microsoft.com/en-us/windows/codecs-in-media-player-d5c2cdcd-83a2-4805-abb0-c6888138e456). You can still get the codec by running the following command from a Windows PowerShell console: `winget install --id 9nvjqjbdkn97` +3. As mentioned in the [Dolby Atmos](#dolby-atmos) section, just because a media player can play a file does not mean it's rendering Atmos. _Dolby Digital Plus Atmos_ is backwards compatible with _Dolby Digital Plus_, so media players which only support _Dolby Digital Plus_ will play E-AC-3 audio files as regular 5.1 surround without rendering the Atmos spatial qualities. Additional software or hardware support may be required for Dolby Atmos playback. +4. You can download the AC-4 codec for Windows from 3rd party sites like [Major Geeks](https://www.majorgeeks.com/files/details/dolby_ac_3ac_4_installer.html) and [Free-Codecs](https://www.free-codecs.com/dolby-ac-4-decoder_download.htm). Once you install the codec bundle from one of those sources, the Windows store app will keep it updated. Read more about the process [in this comment](https://github.com/rmcrackan/Libation/pull/1331#discussion_r2268660524). +5. All Android devices will support AAC-LC and xHE-AAC. Some manufactures (such as Samsung) will include Dolby codecs for playing E-AC-3 and AC-4 audio. +6. requires FFmpeg to be [built with fdk-aac](https://trac.ffmpeg.org/wiki/Encode/AAC#fdk_aac). You will almost certainly not find pre-build binaries in the wild due to licensing restrictions. +7. Requires the [fdk-aac plugin](https://www.foobar2000.org/components/view/foo_pd_aac) (Windows only) +8. Requires audio file extensions to be `.m4a` or `.mp4`. Libation sets the file extensions to `.m4b`, so you must manually change it to `.m4a` by renaming the audio file. + diff --git a/Documentation/DolbyAtmos.md b/Documentation/DolbyAtmos.md deleted file mode 100644 index c1bab378..00000000 --- a/Documentation/DolbyAtmos.md +++ /dev/null @@ -1,30 +0,0 @@ -## [Download Libation](https://github.com/rmcrackan/Libation/releases/latest) - -### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PayPal.me](https://paypal.me/mcrackan?locale.x=en_us) -...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**. - - - -## \*\*\* THIS PAGE IS A WORK IN PROGRESS. PLEASE HELP OUT BY CONTRIBUTING TO IT. \*\*\* - - - -# Dolby Atmos - -Dolby Atmos (Dolby Digital Plus (E-AC-3)) is a surround sound technology that creates an immersive audio experience by adding height channels to traditional surround sound setups. It's also used in 4D and spatial audio. - -## Downloading Dolby Atmos files - -Audible uses a different DRM technology on these files called "Widevine." To remove it, enable "Use widevine DRM" in Settings. - -## Listening to Dolby Atmos files - -### VLC Media Player - -If you save your file with Libation and play it in VLC and only get an image and no audio, the problem is most likely with the audio codecs. Specifically, with the xHE-AAC codec, as it is a modern codec and platforms such as Audible are implementing it in their most recent audiobooks, and VLC cannot process it. In these cases, one user recommended the Polport pre-decoder, which does run this more modern codec and will allow you to listen to your audiobook without any problems. There is an installable and portable version of Polport. - -### Samsung Music app - -To listen with the Samsung Music app, even if your phone does not have Dolby Atmos enabled, the Samsung Music app will read the Dolby Atmos data in the file. The only condition is that the audiobook must be in M4A format, as M4B is not supported. - -If you download it in M4B on your PC, simply change the extension to M4A. You will not lose any quality with that change, and you will be able to play it on the Samsung Music app on your cell phone. \ No newline at end of file diff --git a/Documentation/FrequentlyAskedQuestions.md b/Documentation/FrequentlyAskedQuestions.md index d4268283..cf42b445 100644 --- a/Documentation/FrequentlyAskedQuestions.md +++ b/Documentation/FrequentlyAskedQuestions.md @@ -35,9 +35,15 @@ Self-hosting online: * [audiobookshelf](https://www.audiobookshelf.org). On [reddit](https://www.reddit.com/r/audiobookshelf/) * [plex](https://www.plex.tv/). Listen with [Prologue](https://prologue.audio/) (iOS) -## I'm having trouble playing my book with 4D, spatial audio, or Dolby Atmos, how can I fix this? +## Q: I'm having trouble playing my non-spatial audiobook, how can I fix this? -**A:** Disable the 'Use widevine DRM' option in settings and download it again. [See this page for more information about this file format.](DolbyAtmos.md) +**A:** If you enabled the [Use Widevine DRM](AudioFileFormats.md#use-widevine-drm) option in settings, the audiobook is most likely being downloaded in the [xHE-AAC codec](AudioFileFormats.md#xhe-aac) which isn't widely supported. You have two options: +1. Use a media player which supports the xHE-AAC codec. [See an incomplete list of media players which support xHE-AAC](AudioFileFormats.md#supported-media-players). +2. Disable [Use Widevine DRM](AudioFileFormats.md#use-widevine-drm) option in settings and re-download the audiobook. This will cause Libation to download audiobooks in the [AAC-LC codec](AudioFileFormats.md#aac-lc), which enjoys near-universal media player support. + +## Q: I'm having trouble playing my book with 4D, spatial audio, or Dolby Atmos, how can I fix this? + +**A:** Spatial audiobooks are delivered in two formats: [E-AC-3](AudioFileFormats.md#e-ac-3) and [AC-4](AudioFileFormats.md#ac-4). [See an incomplete list of media players which support those codecs](AudioFileFormats.md#supported-media-players). ## Q: I'm having trouble loggin into my Brazil account. diff --git a/Documentation/images/AudioFormatSettings.png b/Documentation/images/AudioFormatSettings.png new file mode 100644 index 0000000000000000000000000000000000000000..786c6272dd2257270dd59082c81d67ed99fbfabe GIT binary patch literal 20383 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 Date: Thu, 14 Aug 2025 13:20:01 -0600 Subject: [PATCH 2/7] Add xHE-AAC option --- .../FileLiberator/DownloadOptions.Factory.cs | 12 +-- Source/FileLiberator/DownloadOptions.cs | 2 +- .../Controls/Settings/Audio.axaml | 71 ++++++++------- .../Controls/Settings/Audio.axaml.cs | 14 +++ .../ViewModels/Settings/AudioSettingsVM.cs | 25 ++++-- .../Configuration.HelpText.cs | 23 ++--- .../Configuration.PersistentSettings.cs | 5 +- .../Dialogs/SettingsDialog.AudioSettings.cs | 36 ++++++-- .../Dialogs/SettingsDialog.Designer.cs | 87 ++++++++++--------- 9 files changed, 171 insertions(+), 104 deletions(-) diff --git a/Source/FileLiberator/DownloadOptions.Factory.cs b/Source/FileLiberator/DownloadOptions.Factory.cs index af58d360..d560886c 100644 --- a/Source/FileLiberator/DownloadOptions.Factory.cs +++ b/Source/FileLiberator/DownloadOptions.Factory.cs @@ -18,9 +18,6 @@ namespace FileLiberator; public partial class DownloadOptions { - private const string Ec3Codec = "ec+3"; - private const string Ac4Codec = "ac-4"; - ///

/// Initiate an audiobook download from the audible api. /// @@ -71,8 +68,10 @@ public partial class DownloadOptions token.ThrowIfCancellationRequested(); try { - //try to request a widevine content license using the user's spatial audio settings - var codecChoice = config.SpatialAudioCodec is Configuration.SpatialCodec.AC_4 ? Ac4Codec : Ec3Codec; + //try to request a widevine content license using the user's audio settings + var aacCodecChoice = config.Request_xHE_AAC ? Codecs.xHE_AAC : Codecs.AAC_LC; + //Always use the ec+3 codec if converting to mp3 + var spatialCodecChoice = config.SpatialAudioCodec is Configuration.SpatialCodec.AC_4 && !config.DecryptToLossy ? Codecs.AC_4 : Codecs.EC_3; var contentLic = await api.GetDownloadLicenseAsync( @@ -81,7 +80,8 @@ public partial class DownloadOptions ChapterTitlesType.Tree, DrmType.Widevine, config.RequestSpatial, - codecChoice); + aacCodecChoice, + spatialCodecChoice); if (contentLic.DrmType is not DrmType.Widevine) return new LicenseInfo(contentLic); diff --git a/Source/FileLiberator/DownloadOptions.cs b/Source/FileLiberator/DownloadOptions.cs index a276e6ad..0b761f6a 100644 --- a/Source/FileLiberator/DownloadOptions.cs +++ b/Source/FileLiberator/DownloadOptions.cs @@ -74,7 +74,7 @@ namespace FileLiberator //If DrmType is not Adrm or Widevine, the delivered file is an unencrypted mp3. OutputFormat = licInfo.DrmType is not AudibleApi.Common.DrmType.Adrm and not AudibleApi.Common.DrmType.Widevine || - (config.AllowLibationFixup && config.DecryptToLossy && licInfo.ContentMetadata.ContentReference.Codec != Ac4Codec) + (config.AllowLibationFixup && config.DecryptToLossy && licInfo.ContentMetadata.ContentReference.Codec != AudibleApi.Codecs.AC_4) ? OutputFormat.Mp3 : OutputFormat.M4b; diff --git a/Source/LibationAvalonia/Controls/Settings/Audio.axaml b/Source/LibationAvalonia/Controls/Settings/Audio.axaml index e99477ae..54d77e86 100644 --- a/Source/LibationAvalonia/Controls/Settings/Audio.axaml +++ b/Source/LibationAvalonia/Controls/Settings/Audio.axaml @@ -47,52 +47,57 @@ SelectedItem="{CompiledBinding FileDownloadQuality}"/> - - - + + IsChecked="{CompiledBinding UseWidevine, Mode=TwoWay}"> - + + + + + + - - - - - - - - - - - - - - - + ColumnDefinitions="Auto,Auto" + VerticalAlignment="Top" + ToolTip.Tip="{CompiledBinding SpatialAudioCodecTip}"> + + + + + + + + + + + + + + - - - - - diff --git a/Source/LibationAvalonia/Controls/Settings/Audio.axaml.cs b/Source/LibationAvalonia/Controls/Settings/Audio.axaml.cs index a7f1d601..63758b69 100644 --- a/Source/LibationAvalonia/Controls/Settings/Audio.axaml.cs +++ b/Source/LibationAvalonia/Controls/Settings/Audio.axaml.cs @@ -5,6 +5,7 @@ using LibationAvalonia.ViewModels.Settings; using LibationFileManager; using LibationFileManager.Templates; using LibationUiBase.Forms; +using ReactiveUI; using System.Linq; using System.Threading.Tasks; @@ -23,6 +24,15 @@ namespace LibationAvalonia.Controls.Settings } } + private void SpatialCodec_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (_viewModel.SpatialAudioCodec.Value is Configuration.SpatialCodec.AC_4 && _viewModel.DecryptToLossy) + { + _viewModel.SpatialAudioCodec = _viewModel.SpatialAudioCodecs[0]; + _viewModel.RaisePropertyChanged(nameof(AudioSettingsVM.SpatialAudioCodec)); + } + } + private async void UseWidevine_IsCheckedChanged(object sender, Avalonia.Interactivity.RoutedEventArgs e) { if (sender is CheckBox cbox && cbox.IsChecked is true) @@ -59,6 +69,10 @@ namespace LibationAvalonia.Controls.Settings _viewModel.UseWidevine = false; } } + else + { + _viewModel.Request_xHE_AAC = _viewModel.RequestSpatial = false; + } } public async void EditChapterTitleTemplateButton_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) diff --git a/Source/LibationAvalonia/ViewModels/Settings/AudioSettingsVM.cs b/Source/LibationAvalonia/ViewModels/Settings/AudioSettingsVM.cs index 17fb37e9..ee1d5a90 100644 --- a/Source/LibationAvalonia/ViewModels/Settings/AudioSettingsVM.cs +++ b/Source/LibationAvalonia/ViewModels/Settings/AudioSettingsVM.cs @@ -54,7 +54,6 @@ namespace LibationAvalonia.ViewModels.Settings StripAudibleBrandAudio = config.StripAudibleBrandAudio; StripUnabridged = config.StripUnabridged; _chapterTitleTemplate = config.ChapterTitleTemplate; - DecryptToLossy = config.DecryptToLossy; MoveMoovToBeginning = config.MoveMoovToBeginning; LameTargetBitrate = config.LameTargetBitrate; LameDownsampleMono = config.LameDownsampleMono; @@ -69,6 +68,8 @@ namespace LibationAvalonia.ViewModels.Settings SelectedEncoderQuality = config.LameEncoderQuality; UseWidevine = config.UseWidevine; RequestSpatial = config.RequestSpatial; + Request_xHE_AAC = config.Request_xHE_AAC; + DecryptToLossy = config.DecryptToLossy; } public void SaveSettings(Configuration config) @@ -100,6 +101,7 @@ namespace LibationAvalonia.ViewModels.Settings config.SpatialAudioCodec = SpatialAudioCodec?.Value ?? config.SpatialAudioCodec; config.UseWidevine = UseWidevine; config.RequestSpatial = RequestSpatial; + config.Request_xHE_AAC = Request_xHE_AAC; } public AvaloniaList> DownloadQualities { get; } = new([ @@ -114,9 +116,10 @@ namespace LibationAvalonia.ViewModels.Settings public string FileDownloadQualityText { get; } = Configuration.GetDescription(nameof(Configuration.FileDownloadQuality)); public string UseWidevineText { get; } = Configuration.GetDescription(nameof(Configuration.UseWidevine)); public string UseWidevineTip { get; } = Configuration.GetHelpText(nameof(Configuration.UseWidevine)); + public string Request_xHE_AACText { get; } = Configuration.GetDescription(nameof(Configuration.Request_xHE_AAC)); + public string Request_xHE_AACTip { get; } = Configuration.GetHelpText(nameof(Configuration.Request_xHE_AAC)); public string RequestSpatialText { get; } = Configuration.GetDescription(nameof(Configuration.RequestSpatial)); public string RequestSpatialTip { get; } = Configuration.GetHelpText(nameof(Configuration.RequestSpatial)); - public string SpatialAudioCodecText { get; } = Configuration.GetDescription(nameof(Configuration.SpatialAudioCodec)); public string SpatialAudioCodecTip { get; } = Configuration.GetHelpText(nameof(Configuration.SpatialAudioCodec)); public string CreateCueSheetText { get; } = Configuration.GetDescription(nameof(Configuration.CreateCueSheet)); public string CombineNestedChapterTitlesText { get; } = Configuration.GetDescription(nameof(Configuration.CombineNestedChapterTitles)); @@ -140,10 +143,9 @@ namespace LibationAvalonia.ViewModels.Settings public string RetainAaxFileTip => Configuration.GetHelpText(nameof(RetainAaxFile)); public bool DownloadClipsBookmarks { get => _downloadClipsBookmarks; set => this.RaiseAndSetIfChanged(ref _downloadClipsBookmarks, value); } - - private bool _useWidevine; - private bool _requestSpatial; + private bool _useWidevine, _requestSpatial, _request_xHE_AAC; public bool UseWidevine { get => _useWidevine; set => this.RaiseAndSetIfChanged(ref _useWidevine, value); } + public bool Request_xHE_AAC { get => _request_xHE_AAC; set => this.RaiseAndSetIfChanged(ref _request_xHE_AAC, value); } public bool RequestSpatial { get => _requestSpatial; set => this.RaiseAndSetIfChanged(ref _requestSpatial, value); } public EnumDisplay FileDownloadQuality { get; set; } @@ -155,7 +157,18 @@ namespace LibationAvalonia.ViewModels.Settings public string StripAudibleBrandAudioTip => Configuration.GetHelpText(nameof(StripAudibleBrandAudio)); public bool StripUnabridged { get; set; } public string StripUnabridgedTip => Configuration.GetHelpText(nameof(StripUnabridged)); - public bool DecryptToLossy { get => _decryptToLossy; set => this.RaiseAndSetIfChanged(ref _decryptToLossy, value); } + public bool DecryptToLossy { + get => _decryptToLossy; + set + { + this.RaiseAndSetIfChanged(ref _decryptToLossy, value); + if (DecryptToLossy && SpatialAudioCodec.Value is Configuration.SpatialCodec.AC_4) + { + SpatialAudioCodec = SpatialAudioCodecs[0]; + this.RaisePropertyChanged(nameof(SpatialAudioCodec)); + } + } + } public string DecryptToLossyTip => Configuration.GetHelpText(nameof(DecryptToLossy)); public bool MoveMoovToBeginning { get; set; } diff --git a/Source/LibationFileManager/Configuration.HelpText.cs b/Source/LibationFileManager/Configuration.HelpText.cs index 52a59810..5f41c7dc 100644 --- a/Source/LibationFileManager/Configuration.HelpText.cs +++ b/Source/LibationFileManager/Configuration.HelpText.cs @@ -89,23 +89,26 @@ namespace LibationFileManager AC-4 cannot be converted to MP3. """ }, - {nameof(UseWidevine), """ + {nameof(UseWidevine), """ Some audiobooks are only delivered in the highest available quality with special, third-party content - protection. Enabling this option will make Libation - request audiobooks with Widevine DRM, which may - yield higher quality audiobook files. If they are - higher quality, however, they will also be encoded - with a somewhat uncommon codec (xHE-AAC USAC) - which you may have difficulty playing. - - This must be enable to download spatial audiobooks. + protection. Enabling this option will allows you to + request audiobooks in the xHE-AAC codec and in + spatial (Dolby Atmos) audio formats. + """ }, + {nameof(Request_xHE_AAC), """ + If selected, Libation will request audiobooks in the + xHE-AAC codec. This codec is generally better quality + than AAC-LC codec (which is what you'll get if this + option isn't enabled), but it isn't as commonly + supported by media players, so you may have some + difficulty playing these audiobooks. """ }, {nameof(RequestSpatial), """ If selected, Libation will request audiobooks in the Dolby Atmos 'Spatial Audio' format. Audiobooks which don't have a spatial audio version will be download - as usual based on your other file quality settings. + as usual based on your other audio format settings. """ }, {"LocateAudiobooks",""" Scan the contents a folder to find audio files that diff --git a/Source/LibationFileManager/Configuration.PersistentSettings.cs b/Source/LibationFileManager/Configuration.PersistentSettings.cs index b0e99649..392c5ecf 100644 --- a/Source/LibationFileManager/Configuration.PersistentSettings.cs +++ b/Source/LibationFileManager/Configuration.PersistentSettings.cs @@ -285,9 +285,12 @@ namespace LibationFileManager AC_4 } - [Description("Use widevine DRM")] + [Description("Use Widevine DRM")] public bool UseWidevine { get => GetNonString(defaultValue: false); set => SetNonString(value); } + [Description("Request xHE-AAC codec")] + public bool Request_xHE_AAC { get => GetNonString(defaultValue: false); set => SetNonString(value); } + [Description("Request Spatial Audio")] public bool RequestSpatial { get => GetNonString(defaultValue: true); set => SetNonString(value); } diff --git a/Source/LibationWinForms/Dialogs/SettingsDialog.AudioSettings.cs b/Source/LibationWinForms/Dialogs/SettingsDialog.AudioSettings.cs index bb954ce1..da172bf6 100644 --- a/Source/LibationWinForms/Dialogs/SettingsDialog.AudioSettings.cs +++ b/Source/LibationWinForms/Dialogs/SettingsDialog.AudioSettings.cs @@ -25,7 +25,7 @@ namespace LibationWinForms.Dialogs this.moveMoovAtomCbox.Text = desc(nameof(config.MoveMoovToBeginning)); this.useWidevineCbox.Text = desc(nameof(config.UseWidevine)); this.requestSpatialCbox.Text = desc(nameof(config.RequestSpatial)); - this.spatialCodecLbl.Text = desc(nameof(config.SpatialAudioCodec)); + this.request_xHE_AAC_Cbox.Text = desc(nameof(config.Request_xHE_AAC)); toolTip.SetToolTip(combineNestedChapterTitlesCbox, Configuration.GetHelpText(nameof(config.CombineNestedChapterTitles))); toolTip.SetToolTip(allowLibationFixupCbox, Configuration.GetHelpText(nameof(config.AllowLibationFixup))); @@ -38,7 +38,7 @@ namespace LibationWinForms.Dialogs toolTip.SetToolTip(stripAudibleBrandingCbox, Configuration.GetHelpText(nameof(config.StripAudibleBrandAudio))); toolTip.SetToolTip(useWidevineCbox, Configuration.GetHelpText(nameof(config.UseWidevine))); toolTip.SetToolTip(requestSpatialCbox, Configuration.GetHelpText(nameof(config.RequestSpatial))); - toolTip.SetToolTip(spatialCodecLbl, Configuration.GetHelpText(nameof(config.SpatialAudioCodec))); + toolTip.SetToolTip(request_xHE_AAC_Cbox, Configuration.GetHelpText(nameof(config.Request_xHE_AAC))); toolTip.SetToolTip(spatialAudioCodecCb, Configuration.GetHelpText(nameof(config.SpatialAudioCodec))); fileDownloadQualityCb.Items.AddRange( @@ -80,6 +80,7 @@ namespace LibationWinForms.Dialogs fileDownloadQualityCb.SelectedItem = config.FileDownloadQuality; spatialAudioCodecCb.SelectedItem = config.SpatialAudioCodec; useWidevineCbox.Checked = config.UseWidevine; + request_xHE_AAC_Cbox.Checked = config.Request_xHE_AAC; requestSpatialCbox.Checked = config.RequestSpatial; clipsBookmarksFormatCb.SelectedItem = config.ClipsBookmarksFileFormat; @@ -124,6 +125,7 @@ namespace LibationWinForms.Dialogs config.DownloadClipsBookmarks = downloadClipsBookmarksCbox.Checked; config.FileDownloadQuality = ((EnumDisplay)fileDownloadQualityCb.SelectedItem).Value; config.UseWidevine = useWidevineCbox.Checked; + config.Request_xHE_AAC = request_xHE_AAC_Cbox.Checked; config.RequestSpatial = requestSpatialCbox.Checked; config.SpatialAudioCodec = ((EnumDisplay)spatialAudioCodecCb.SelectedItem).Value; config.ClipsBookmarksFileFormat = (Configuration.ClipBookmarkFormat)clipsBookmarksFormatCb.SelectedItem; @@ -175,6 +177,13 @@ namespace LibationWinForms.Dialogs { moveMoovAtomCbox.Enabled = convertLosslessRb.Checked; lameOptionsGb.Enabled = !convertLosslessRb.Checked; + + if (convertLossyRb.Checked && requestSpatialCbox.Checked) + { + // Only E-AC-3 can be converted to mp3 + spatialAudioCodecCb.SelectedIndex = 0; + } + lameTargetRb_CheckedChanged(sender, e); LameMatchSourceBRCbox_CheckedChanged(sender, e); } @@ -196,7 +205,18 @@ namespace LibationWinForms.Dialogs } } - + private void spatialAudioCodecCb_SelectedIndexChanged(object sender, EventArgs e) + { + if (spatialAudioCodecCb.SelectedIndex == 1 && convertLossyRb.Checked) + { + // Only E-AC-3 can be converted to mp3 + spatialAudioCodecCb.SelectedIndex = 0; + } + } + private void requestSpatialCbox_CheckedChanged(object sender, EventArgs e) + { + spatialAudioCodecCb.Enabled = requestSpatialCbox.Checked && useWidevineCbox.Checked; + } private void useWidevineCbox_CheckedChanged(object sender, EventArgs e) { @@ -233,9 +253,13 @@ namespace LibationWinForms.Dialogs return; } } - requestSpatialCbox.Enabled = useWidevineCbox.Checked; - spatialCodecLbl.Enabled = spatialAudioCodecCb.Enabled = useWidevineCbox.Checked && requestSpatialCbox.Checked; - } + else + { + requestSpatialCbox.Checked = request_xHE_AAC_Cbox.Checked = false; + } + requestSpatialCbox.Enabled = request_xHE_AAC_Cbox.Enabled = useWidevineCbox.Checked; + requestSpatialCbox_CheckedChanged(sender, e); + } } } diff --git a/Source/LibationWinForms/Dialogs/SettingsDialog.Designer.cs b/Source/LibationWinForms/Dialogs/SettingsDialog.Designer.cs index 1f8ef5ab..53dafff0 100644 --- a/Source/LibationWinForms/Dialogs/SettingsDialog.Designer.cs +++ b/Source/LibationWinForms/Dialogs/SettingsDialog.Designer.cs @@ -84,10 +84,10 @@ folderTemplateTb = new System.Windows.Forms.TextBox(); folderTemplateLbl = new System.Windows.Forms.Label(); tab4AudioFileOptions = new System.Windows.Forms.TabPage(); + request_xHE_AAC_Cbox = new System.Windows.Forms.CheckBox(); requestSpatialCbox = new System.Windows.Forms.CheckBox(); useWidevineCbox = new System.Windows.Forms.CheckBox(); spatialAudioCodecCb = new System.Windows.Forms.ComboBox(); - spatialCodecLbl = new System.Windows.Forms.Label(); moveMoovAtomCbox = new System.Windows.Forms.CheckBox(); fileDownloadQualityCb = new System.Windows.Forms.ComboBox(); fileDownloadQualityLbl = new System.Windows.Forms.Label(); @@ -288,7 +288,7 @@ stripAudibleBrandingCbox.Location = new System.Drawing.Point(13, 70); stripAudibleBrandingCbox.Name = "stripAudibleBrandingCbox"; stripAudibleBrandingCbox.Size = new System.Drawing.Size(143, 34); - stripAudibleBrandingCbox.TabIndex = 14; + stripAudibleBrandingCbox.TabIndex = 16; stripAudibleBrandingCbox.Text = "[StripAudibleBranding\r\ndesc]"; stripAudibleBrandingCbox.UseVisualStyleBackColor = true; // @@ -298,7 +298,7 @@ splitFilesByChapterCbox.Location = new System.Drawing.Point(13, 22); splitFilesByChapterCbox.Name = "splitFilesByChapterCbox"; splitFilesByChapterCbox.Size = new System.Drawing.Size(162, 19); - splitFilesByChapterCbox.TabIndex = 12; + splitFilesByChapterCbox.TabIndex = 14; splitFilesByChapterCbox.Text = "[SplitFilesByChapter desc]"; splitFilesByChapterCbox.UseVisualStyleBackColor = true; splitFilesByChapterCbox.CheckedChanged += splitFilesByChapterCbox_CheckedChanged; @@ -311,7 +311,7 @@ allowLibationFixupCbox.Location = new System.Drawing.Point(19, 230); allowLibationFixupCbox.Name = "allowLibationFixupCbox"; allowLibationFixupCbox.Size = new System.Drawing.Size(162, 19); - allowLibationFixupCbox.TabIndex = 11; + allowLibationFixupCbox.TabIndex = 13; allowLibationFixupCbox.Text = "[AllowLibationFixup desc]"; allowLibationFixupCbox.UseVisualStyleBackColor = true; allowLibationFixupCbox.CheckedChanged += allowLibationFixupCbox_CheckedChanged; @@ -323,6 +323,7 @@ convertLossyRb.Name = "convertLossyRb"; convertLossyRb.Size = new System.Drawing.Size(329, 19); convertLossyRb.TabIndex = 27; + convertLossyRb.TabStop = true; convertLossyRb.Text = "Download my books as .MP3 files (transcode if necessary)"; convertLossyRb.UseVisualStyleBackColor = true; convertLossyRb.CheckedChanged += convertFormatRb_CheckedChanged; @@ -774,10 +775,10 @@ // tab4AudioFileOptions // tab4AudioFileOptions.AutoScroll = true; + tab4AudioFileOptions.Controls.Add(request_xHE_AAC_Cbox); tab4AudioFileOptions.Controls.Add(requestSpatialCbox); tab4AudioFileOptions.Controls.Add(useWidevineCbox); tab4AudioFileOptions.Controls.Add(spatialAudioCodecCb); - tab4AudioFileOptions.Controls.Add(spatialCodecLbl); tab4AudioFileOptions.Controls.Add(moveMoovAtomCbox); tab4AudioFileOptions.Controls.Add(fileDownloadQualityCb); tab4AudioFileOptions.Controls.Add(fileDownloadQualityLbl); @@ -802,19 +803,31 @@ tab4AudioFileOptions.Text = "Audio File Options"; tab4AudioFileOptions.UseVisualStyleBackColor = true; // + // request_xHE_AAC_Cbox + // + request_xHE_AAC_Cbox.CheckAlign = System.Drawing.ContentAlignment.MiddleRight; + request_xHE_AAC_Cbox.Checked = true; + request_xHE_AAC_Cbox.CheckState = System.Windows.Forms.CheckState.Checked; + request_xHE_AAC_Cbox.Location = new System.Drawing.Point(239, 35); + request_xHE_AAC_Cbox.Name = "request_xHE_AAC_Cbox"; + request_xHE_AAC_Cbox.Size = new System.Drawing.Size(183, 19); + request_xHE_AAC_Cbox.TabIndex = 3; + request_xHE_AAC_Cbox.Text = "[Request_xHE_AAC desc]"; + request_xHE_AAC_Cbox.TextAlign = System.Drawing.ContentAlignment.MiddleRight; + request_xHE_AAC_Cbox.UseVisualStyleBackColor = true; + // // requestSpatialCbox // requestSpatialCbox.AutoSize = true; - requestSpatialCbox.CheckAlign = System.Drawing.ContentAlignment.MiddleRight; requestSpatialCbox.Checked = true; requestSpatialCbox.CheckState = System.Windows.Forms.CheckState.Checked; - requestSpatialCbox.Location = new System.Drawing.Point(284, 35); + requestSpatialCbox.Location = new System.Drawing.Point(19, 60); requestSpatialCbox.Name = "requestSpatialCbox"; requestSpatialCbox.Size = new System.Drawing.Size(138, 19); - requestSpatialCbox.TabIndex = 29; + requestSpatialCbox.TabIndex = 4; requestSpatialCbox.Text = "[RequestSpatial desc]"; requestSpatialCbox.UseVisualStyleBackColor = true; - requestSpatialCbox.CheckedChanged += useWidevineCbox_CheckedChanged; + requestSpatialCbox.CheckedChanged += requestSpatialCbox_CheckedChanged; // // useWidevineCbox // @@ -824,7 +837,7 @@ useWidevineCbox.Location = new System.Drawing.Point(19, 35); useWidevineCbox.Name = "useWidevineCbox"; useWidevineCbox.Size = new System.Drawing.Size(129, 19); - useWidevineCbox.TabIndex = 28; + useWidevineCbox.TabIndex = 2; useWidevineCbox.Text = "[UseWidevine desc]"; useWidevineCbox.UseVisualStyleBackColor = true; useWidevineCbox.CheckedChanged += useWidevineCbox_CheckedChanged; @@ -837,16 +850,8 @@ spatialAudioCodecCb.Margin = new System.Windows.Forms.Padding(3, 3, 5, 3); spatialAudioCodecCb.Name = "spatialAudioCodecCb"; spatialAudioCodecCb.Size = new System.Drawing.Size(173, 23); - spatialAudioCodecCb.TabIndex = 2; - // - // spatialCodecLbl - // - spatialCodecLbl.AutoSize = true; - spatialCodecLbl.Location = new System.Drawing.Point(19, 62); - spatialCodecLbl.Name = "spatialCodecLbl"; - spatialCodecLbl.Size = new System.Drawing.Size(143, 15); - spatialCodecLbl.TabIndex = 24; - spatialCodecLbl.Text = "[SpatialAudioCodec desc]"; + spatialAudioCodecCb.TabIndex = 5; + spatialAudioCodecCb.SelectedIndexChanged += spatialAudioCodecCb_SelectedIndexChanged; // // moveMoovAtomCbox // @@ -875,7 +880,7 @@ fileDownloadQualityLbl.Margin = new System.Windows.Forms.Padding(0, 0, 2, 0); fileDownloadQualityLbl.Name = "fileDownloadQualityLbl"; fileDownloadQualityLbl.Size = new System.Drawing.Size(152, 15); - fileDownloadQualityLbl.TabIndex = 22; + fileDownloadQualityLbl.TabIndex = 1; fileDownloadQualityLbl.Text = "[FileDownloadQuality desc]"; // // combineNestedChapterTitlesCbox @@ -884,7 +889,7 @@ combineNestedChapterTitlesCbox.Location = new System.Drawing.Point(19, 206); combineNestedChapterTitlesCbox.Name = "combineNestedChapterTitlesCbox"; combineNestedChapterTitlesCbox.Size = new System.Drawing.Size(217, 19); - combineNestedChapterTitlesCbox.TabIndex = 10; + combineNestedChapterTitlesCbox.TabIndex = 12; combineNestedChapterTitlesCbox.Text = "[CombineNestedChapterTitles desc]"; combineNestedChapterTitlesCbox.UseVisualStyleBackColor = true; // @@ -895,7 +900,7 @@ clipsBookmarksFormatCb.Location = new System.Drawing.Point(285, 132); clipsBookmarksFormatCb.Name = "clipsBookmarksFormatCb"; clipsBookmarksFormatCb.Size = new System.Drawing.Size(67, 23); - clipsBookmarksFormatCb.TabIndex = 6; + clipsBookmarksFormatCb.TabIndex = 9; // // downloadClipsBookmarksCbox // @@ -903,7 +908,7 @@ downloadClipsBookmarksCbox.Location = new System.Drawing.Point(19, 134); downloadClipsBookmarksCbox.Name = "downloadClipsBookmarksCbox"; downloadClipsBookmarksCbox.Size = new System.Drawing.Size(248, 19); - downloadClipsBookmarksCbox.TabIndex = 5; + downloadClipsBookmarksCbox.TabIndex = 8; downloadClipsBookmarksCbox.Text = "Download Clips, Notes, and Bookmarks as"; downloadClipsBookmarksCbox.UseVisualStyleBackColor = true; downloadClipsBookmarksCbox.CheckedChanged += downloadClipsBookmarksCbox_CheckedChanged; @@ -916,7 +921,7 @@ audiobookFixupsGb.Location = new System.Drawing.Point(6, 254); audiobookFixupsGb.Name = "audiobookFixupsGb"; audiobookFixupsGb.Size = new System.Drawing.Size(416, 114); - audiobookFixupsGb.TabIndex = 19; + audiobookFixupsGb.TabIndex = 14; audiobookFixupsGb.TabStop = false; audiobookFixupsGb.Text = "Audiobook Fix-ups"; // @@ -926,7 +931,7 @@ stripUnabridgedCbox.Location = new System.Drawing.Point(13, 46); stripUnabridgedCbox.Name = "stripUnabridgedCbox"; stripUnabridgedCbox.Size = new System.Drawing.Size(147, 19); - stripUnabridgedCbox.TabIndex = 13; + stripUnabridgedCbox.TabIndex = 15; stripUnabridgedCbox.Text = "[StripUnabridged desc]"; stripUnabridgedCbox.UseVisualStyleBackColor = true; // @@ -948,7 +953,7 @@ chapterTitleTemplateBtn.Location = new System.Drawing.Point(769, 22); chapterTitleTemplateBtn.Name = "chapterTitleTemplateBtn"; chapterTitleTemplateBtn.Size = new System.Drawing.Size(75, 23); - chapterTitleTemplateBtn.TabIndex = 15; + chapterTitleTemplateBtn.TabIndex = 17; chapterTitleTemplateBtn.Text = "Edit..."; chapterTitleTemplateBtn.UseVisualStyleBackColor = true; chapterTitleTemplateBtn.Click += chapterTitleTemplateBtn_Click; @@ -960,7 +965,7 @@ chapterTitleTemplateTb.Name = "chapterTitleTemplateTb"; chapterTitleTemplateTb.ReadOnly = true; chapterTitleTemplateTb.Size = new System.Drawing.Size(757, 23); - chapterTitleTemplateTb.TabIndex = 16; + chapterTitleTemplateTb.TabIndex = 18; // // lameOptionsGb // @@ -977,7 +982,7 @@ lameOptionsGb.Location = new System.Drawing.Point(438, 78); lameOptionsGb.Name = "lameOptionsGb"; lameOptionsGb.Size = new System.Drawing.Size(412, 304); - lameOptionsGb.TabIndex = 14; + lameOptionsGb.TabIndex = 28; lameOptionsGb.TabStop = false; lameOptionsGb.Text = "Mp3 Encoding Options"; // @@ -997,7 +1002,7 @@ label21.Location = new System.Drawing.Point(227, 75); label21.Name = "label21"; label21.Size = new System.Drawing.Size(94, 15); - label21.TabIndex = 3; + label21.TabIndex = 0; label21.Text = "Encoder Quality:"; // // encoderQualityCb @@ -1045,7 +1050,7 @@ lameBitrateGb.Location = new System.Drawing.Point(6, 100); lameBitrateGb.Name = "lameBitrateGb"; lameBitrateGb.Size = new System.Drawing.Size(400, 92); - lameBitrateGb.TabIndex = 0; + lameBitrateGb.TabIndex = 33; lameBitrateGb.TabStop = false; lameBitrateGb.Text = "Bitrate"; // @@ -1170,7 +1175,7 @@ lameQualityGb.Location = new System.Drawing.Point(6, 196); lameQualityGb.Name = "lameQualityGb"; lameQualityGb.Size = new System.Drawing.Size(400, 85); - lameQualityGb.TabIndex = 0; + lameQualityGb.TabIndex = 36; lameQualityGb.TabStop = false; lameQualityGb.Text = "Quality"; // @@ -1260,7 +1265,7 @@ label13.Location = new System.Drawing.Point(355, 66); label13.Name = "label13"; label13.Size = new System.Drawing.Size(39, 15); - label13.TabIndex = 1; + label13.TabIndex = 0; label13.Text = "Lower"; // // label10 @@ -1269,7 +1274,7 @@ label10.Location = new System.Drawing.Point(6, 66); label10.Name = "label10"; label10.Size = new System.Drawing.Size(43, 15); - label10.TabIndex = 1; + label10.TabIndex = 0; label10.Text = "Higher"; // // label14 @@ -1311,7 +1316,7 @@ groupBox2.Location = new System.Drawing.Point(6, 22); groupBox2.Name = "groupBox2"; groupBox2.Size = new System.Drawing.Size(182, 45); - groupBox2.TabIndex = 0; + groupBox2.TabIndex = 28; groupBox2.TabStop = false; groupBox2.Text = "Target"; // @@ -1348,7 +1353,7 @@ label1.Location = new System.Drawing.Point(6, 286); label1.Name = "label1"; label1.Size = new System.Drawing.Size(172, 15); - label1.TabIndex = 1; + label1.TabIndex = 0; label1.Text = "Using L.A.M.E. encoding engine"; // // mergeOpeningEndCreditsCbox @@ -1357,7 +1362,7 @@ mergeOpeningEndCreditsCbox.Location = new System.Drawing.Point(19, 182); mergeOpeningEndCreditsCbox.Name = "mergeOpeningEndCreditsCbox"; mergeOpeningEndCreditsCbox.Size = new System.Drawing.Size(198, 19); - mergeOpeningEndCreditsCbox.TabIndex = 9; + mergeOpeningEndCreditsCbox.TabIndex = 11; mergeOpeningEndCreditsCbox.Text = "[MergeOpeningEndCredits desc]"; mergeOpeningEndCreditsCbox.UseVisualStyleBackColor = true; // @@ -1367,7 +1372,7 @@ retainAaxFileCbox.Location = new System.Drawing.Point(19, 158); retainAaxFileCbox.Name = "retainAaxFileCbox"; retainAaxFileCbox.Size = new System.Drawing.Size(131, 19); - retainAaxFileCbox.TabIndex = 8; + retainAaxFileCbox.TabIndex = 10; retainAaxFileCbox.Text = "[RetainAaxFile desc]"; retainAaxFileCbox.UseVisualStyleBackColor = true; retainAaxFileCbox.CheckedChanged += allowLibationFixupCbox_CheckedChanged; @@ -1380,7 +1385,7 @@ downloadCoverArtCbox.Location = new System.Drawing.Point(19, 110); downloadCoverArtCbox.Name = "downloadCoverArtCbox"; downloadCoverArtCbox.Size = new System.Drawing.Size(162, 19); - downloadCoverArtCbox.TabIndex = 4; + downloadCoverArtCbox.TabIndex = 7; downloadCoverArtCbox.Text = "[DownloadCoverArt desc]"; downloadCoverArtCbox.UseVisualStyleBackColor = true; downloadCoverArtCbox.CheckedChanged += allowLibationFixupCbox_CheckedChanged; @@ -1393,7 +1398,7 @@ createCueSheetCbox.Location = new System.Drawing.Point(19, 86); createCueSheetCbox.Name = "createCueSheetCbox"; createCueSheetCbox.Size = new System.Drawing.Size(145, 19); - createCueSheetCbox.TabIndex = 3; + createCueSheetCbox.TabIndex = 6; createCueSheetCbox.Text = "[CreateCueSheet desc]"; createCueSheetCbox.UseVisualStyleBackColor = true; createCueSheetCbox.CheckedChanged += allowLibationFixupCbox_CheckedChanged; @@ -1560,8 +1565,8 @@ private System.Windows.Forms.GroupBox groupBox1; private System.Windows.Forms.Button applyDisplaySettingsBtn; private System.Windows.Forms.ComboBox spatialAudioCodecCb; - private System.Windows.Forms.Label spatialCodecLbl; private System.Windows.Forms.CheckBox useWidevineCbox; private System.Windows.Forms.CheckBox requestSpatialCbox; + private System.Windows.Forms.CheckBox request_xHE_AAC_Cbox; } } \ No newline at end of file From e1d789ccdc333b921d31b54ff44fc949b8db4f6a Mon Sep 17 00:00:00 2001 From: Michael Bucari-Tovo Date: Thu, 14 Aug 2025 15:10:53 -0600 Subject: [PATCH 3/7] Improve series order parsing and formatting --- Documentation/NamingTemplates.md | 4 +- Source/FileLiberator/DownloadOptions.cs | 2 +- .../Templates/SeriesDto.cs | 23 +++-- .../Templates/SeriesListFormat.cs | 2 +- .../Templates/SeriesOrder.cs | 88 +++++++++++++++++++ .../Templates/Templates.cs | 2 +- .../TemplatesTests.cs | 27 +++++- 7 files changed, 134 insertions(+), 14 deletions(-) create mode 100644 Source/LibationFileManager/Templates/SeriesOrder.cs diff --git a/Documentation/NamingTemplates.md b/Documentation/NamingTemplates.md index 184570ea..b364fa04 100644 --- a/Documentation/NamingTemplates.md +++ b/Documentation/NamingTemplates.md @@ -105,13 +105,13 @@ As an example, this folder template will place all Liberated podcasts into a "Po ## Series Formatters |Formatter|Description|Example Usage|Example Result| |-|-|-|-| -|\{N \| # \| ID\}|Formats the series using
the series part tags.
\{N\} = Series Name
\{#\} = Number order in series
\{ID\} = Audible Series ID

Default is \{N\}|``
``
``|Sherlock Holmes
Sherlock Holmes
Sherlock Holmes, 1, B08376S3R2| +|\{N \| # \| ID\}|Formats the series using
the series part tags.
\{N\} = Series Name
\{#\} = Number order in series
\{#:[Number_Formatter](#number-formatters)\} = Number order in series, formatted
\{ID\} = Audible Series ID

Default is \{N\}|``
``
``
``|Sherlock Holmes
Sherlock Holmes
Sherlock Holmes, 1-6, B08376S3R2
Sherlock Holmes, B08376S3R2, 01.0-06.0| ## Series List Formatters |Formatter|Description|Example Usage|Example Result| |-|-|-|-| |separator()|Speficy the text used to join
multiple series names.

Default is ", "|``|Sherlock Holmes; Some Other Series| -|format(\{N \| # \| ID\})|Formats the series properties
using the name series tags.
See [Series Formatter Usage](#series-formatters) above.|``separator(; )]>`
``|Sherlock Holmes, 1; Some Other Series, 1
herlock Holmes, B08376S3R2; Some Other Series, B000000000| +|format(\{N \| # \| ID\})|Formats the series properties
using the name series tags.
See [Series Formatter Usage](#series-formatters) above.|``separator(; )]>`
``|Sherlock Holmes, 1-6; Book Collection, 1
B08376S3R2-Sherlock Holmes, 01.0-06.0, B000000000-Book Collection, 01.0| |max(#)|Only use the first # of series

Default is all series|``|Sherlock Holmes| ## Name Formatters diff --git a/Source/FileLiberator/DownloadOptions.cs b/Source/FileLiberator/DownloadOptions.cs index 0b761f6a..578c8ff5 100644 --- a/Source/FileLiberator/DownloadOptions.cs +++ b/Source/FileLiberator/DownloadOptions.cs @@ -26,7 +26,7 @@ namespace FileLiberator public string Language => LibraryBook.Book.Language; public string? AudibleProductId => LibraryBookDto.AudibleProductId; public string? SeriesName => LibraryBookDto.FirstSeries?.Name; - public string? SeriesNumber => LibraryBookDto.FirstSeries?.Number; + public string? SeriesNumber => LibraryBookDto.FirstSeries?.Order?.ToString(); public NAudio.Lame.LameConfig? LameConfig { get; } public string UserAgent => AudibleApi.Resources.Download_User_Agent; public bool StripUnabridged => Config.AllowLibationFixup && Config.StripUnabridged; diff --git a/Source/LibationFileManager/Templates/SeriesDto.cs b/Source/LibationFileManager/Templates/SeriesDto.cs index cfd3d3fd..a2d7a867 100644 --- a/Source/LibationFileManager/Templates/SeriesDto.cs +++ b/Source/LibationFileManager/Templates/SeriesDto.cs @@ -1,27 +1,34 @@ using System; +using System.Text.RegularExpressions; #nullable enable namespace LibationFileManager.Templates; -public record SeriesDto : IFormattable +public partial record SeriesDto : IFormattable { public string Name { get; } - public string? Number { get; } + public SeriesOrder Order { get; } public string AudibleSeriesId { get; } public SeriesDto(string name, string? number, string audibleSeriesId) { Name = name; - Number = number; + Order = SeriesOrder.Parse(number); AudibleSeriesId = audibleSeriesId; } public override string ToString() => Name.Trim(); public string ToString(string? format, IFormatProvider? _) => string.IsNullOrWhiteSpace(format) ? ToString() - : format - .Replace("{N}", Name) - .Replace("{#}", Number?.ToString()) - .Replace("{ID}", AudibleSeriesId) - .Trim(); + : FormatRegex().Replace(format, MatchEvaluator) + .Replace("{N}", Name) + .Replace("{ID}", AudibleSeriesId) + .Trim(); + + private string MatchEvaluator(Match match) + => Order?.ToString(match.Groups[1].Value, null) ?? ""; + + /// Format must have at least one of the string {N}, {#}, {ID} + [GeneratedRegex(@"{#(?:\:(.*?))?}")] + public static partial Regex FormatRegex(); } diff --git a/Source/LibationFileManager/Templates/SeriesListFormat.cs b/Source/LibationFileManager/Templates/SeriesListFormat.cs index 1127eaa5..9db3e441 100644 --- a/Source/LibationFileManager/Templates/SeriesListFormat.cs +++ b/Source/LibationFileManager/Templates/SeriesListFormat.cs @@ -12,6 +12,6 @@ internal partial class SeriesListFormat : IListFormat : IListFormat.Join(formatString, series); /// Format must have at least one of the string {N}, {#}, {ID} - [GeneratedRegex(@"[Ff]ormat\((.*?(?:{[N#]}|{ID})+.*?)\)")] + [GeneratedRegex(@"[Ff]ormat\((.*?(?:{#(?:\:.*?)?}|{N}|{ID})+.*?)\)")] public static partial Regex FormatRegex(); } diff --git a/Source/LibationFileManager/Templates/SeriesOrder.cs b/Source/LibationFileManager/Templates/SeriesOrder.cs new file mode 100644 index 00000000..ddf31a63 --- /dev/null +++ b/Source/LibationFileManager/Templates/SeriesOrder.cs @@ -0,0 +1,88 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; + +#nullable enable +namespace LibationFileManager.Templates; + +public class SeriesOrder : IFormattable +{ + public object[] OrderParts { get; } + private SeriesOrder(object[] orderParts) + { + OrderParts = orderParts; + } + + public override string ToString() => ToString(null, null); + + /// + /// Use float formatters to format the number parts of the order. + /// + public string ToString(string? format, IFormatProvider? formatProvider) + => string.Concat(OrderParts.Select(p => p is float f ? f.ToString(format) : p.ToString())).Trim(); + + public static SeriesOrder Parse(string? order) + { + List parts = new(); + while (TryParseNumber(order, out var value, out var range)) + { + var prefix = order[..range.Start.Value]; + if(!string.IsNullOrWhiteSpace(prefix)) + parts.Add(prefix); + + parts.Add(value); + + order = order[range.End.Value..]; + } + + if (!string.IsNullOrWhiteSpace(order)) + parts.Add(order); + + return new(parts.ToArray()); + } + + /// + /// Try to parse any positive number from within the string (greedy). + /// + /// the string to search for a numeric value + /// If this function succeeds, the number that was found; otherwise zero. + /// If this function succeeds, the range of characters representing in ; otherwise default + /// True if a number was found; otherwise false. + private static bool TryParseNumber([NotNullWhen(true)] string? numString, out float value, out Range range) + { + value = 0; + if (string.IsNullOrWhiteSpace(numString)) + { + range = default; + return false; + } + + for (int s = 0; s < numString.Length; s++) + { + //Assume any valid number will begin with a digit. + //This way, leading dots and dashes will never be considered part of a number, so + //no negative series numbers and no fractional series numbers < 1 (unless preceded with a '0'). + if (!char.IsDigit(numString[s])) + continue; + + for (int e = numString.Length; e > s; e--) + { + //The float parser will succeed with trailing whitespace, + //but we want to preserve it in the final display string. + if (char.IsWhiteSpace(numString[e - 1])) + continue; + + var substring = numString[s..e]; + if (float.TryParse(substring, out value)) + { + range = new Range(s, e); + return true; + } + } + } + + range = default; + return false; + } +} diff --git a/Source/LibationFileManager/Templates/Templates.cs b/Source/LibationFileManager/Templates/Templates.cs index 5b36ae1a..e67acd6f 100644 --- a/Source/LibationFileManager/Templates/Templates.cs +++ b/Source/LibationFileManager/Templates/Templates.cs @@ -271,7 +271,7 @@ namespace LibationFileManager.Templates { TemplateTags.FirstNarrator, lb => lb.FirstNarrator, FormattableFormatter }, { TemplateTags.Series, lb => lb.Series, SeriesListFormat.Formatter }, { TemplateTags.FirstSeries, lb => lb.FirstSeries, FormattableFormatter }, - { TemplateTags.SeriesNumber, lb => lb.FirstSeries?.Number }, + { TemplateTags.SeriesNumber, lb => lb.FirstSeries?.Order, FormattableFormatter }, { TemplateTags.Language, lb => lb.Language }, //Don't allow formatting of LanguageShort { TemplateTags.LanguageShort, lb =>lb.Language, getLanguageShort }, diff --git a/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs b/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs index cb853d15..15da05ea 100644 --- a/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs +++ b/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs @@ -388,6 +388,7 @@ namespace TemplatesTests [DataRow("", "Series A")] [DataRow("", "Series A")] [DataRow("", "Series A, 1, B1")] + [DataRow("", "Series A, 01.0")] public void SeriesFormat_formatters(string template, string expected) { var bookDto = GetLibraryBook(); @@ -406,6 +407,30 @@ namespace TemplatesTests .Should().Be(expected); } + [TestMethod] + [DataRow("", "1-6", "1-6")] + [DataRow("", "1-6", "1.00-6.00")] + [DataRow("", "1-6", "1.00-6.00")] + [DataRow("", "1-6", "1.00-6.00")] + [DataRow("", "front 1-6 back", "front 1.00-6.00 back")] + [DataRow("", "front 1 - 6 back", "front 1.00 - 6.00 back")] + [DataRow("", "f.1", "f.1.00")] + [DataRow("", "f1g", "f1.00g")] + [DataRow("", " f1g ", "f1.00g")] + [DataRow("", "1", "1")] + [DataRow("", "1", "1")] + public void SeriesOrder_formatters(string template, string seriesOrder, string expected) + { + var bookDto = GetLibraryBook(); + bookDto.Series = [new("Series A", seriesOrder, "B1")]; + + Templates.TryGetTemplate(template, out var fileTemplate).Should().BeTrue(); + fileTemplate + .GetFilename(bookDto, "", "", Replacements) + .PathWithoutPrefix + .Should().Be(expected); + } + [TestMethod] [DataRow(@"C:\a\b", @"C:\a\b\foobar.ext", PlatformID.Win32NT)] [DataRow(@"/a/b", @"/a/b/foobar.ext", PlatformID.Unix)] @@ -496,7 +521,7 @@ namespace Templates_Other var sb = new System.Text.StringBuilder(); sb.Append('0', 300); var longText = sb.ToString(); - Assert.ThrowsException(() => NEW_GetValidFilename_FileNamingTemplate(baseDir, template, "my: book " + longText, "txt")); + Assert.ThrowsExactly(() => NEW_GetValidFilename_FileNamingTemplate(baseDir, template, "my: book " + longText, "txt")); } private class TemplateTag : ITemplateTag From 05fad016249b390a922ee459889995de4e4b661b Mon Sep 17 00:00:00 2001 From: Michael Bucari-Tovo Date: Thu, 14 Aug 2025 15:57:35 -0600 Subject: [PATCH 4/7] Update dependencies --- Source/AudibleUtilities/AudibleUtilities.csproj | 2 +- Source/DataLayer/DataLayer.csproj | 6 +++--- Source/HangoverAvalonia/HangoverAvalonia.csproj | 10 +++++----- Source/LibationAvalonia/LibationAvalonia.csproj | 14 +++++++------- .../AudibleUtilities.Tests.csproj | 2 +- .../FileLiberator.Tests/FileLiberator.Tests.csproj | 2 +- .../FileManager.Tests/FileManager.Tests.csproj | 2 +- .../LibationFileManager.Tests.csproj | 2 +- .../LibationSearchEngine.Tests.csproj | 2 +- 9 files changed, 21 insertions(+), 21 deletions(-) diff --git a/Source/AudibleUtilities/AudibleUtilities.csproj b/Source/AudibleUtilities/AudibleUtilities.csproj index 46a4a94f..48700e33 100644 --- a/Source/AudibleUtilities/AudibleUtilities.csproj +++ b/Source/AudibleUtilities/AudibleUtilities.csproj @@ -6,7 +6,7 @@ - + diff --git a/Source/DataLayer/DataLayer.csproj b/Source/DataLayer/DataLayer.csproj index 1a990172..bef26edf 100644 --- a/Source/DataLayer/DataLayer.csproj +++ b/Source/DataLayer/DataLayer.csproj @@ -12,12 +12,12 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Source/HangoverAvalonia/HangoverAvalonia.csproj b/Source/HangoverAvalonia/HangoverAvalonia.csproj index 9b140e6a..da86862c 100644 --- a/Source/HangoverAvalonia/HangoverAvalonia.csproj +++ b/Source/HangoverAvalonia/HangoverAvalonia.csproj @@ -71,12 +71,12 @@ - - + + - - - + + + diff --git a/Source/LibationAvalonia/LibationAvalonia.csproj b/Source/LibationAvalonia/LibationAvalonia.csproj index 10d24d0c..1e5e0f7d 100644 --- a/Source/LibationAvalonia/LibationAvalonia.csproj +++ b/Source/LibationAvalonia/LibationAvalonia.csproj @@ -73,13 +73,13 @@ - - - - - - - + + + + + + + diff --git a/Source/_Tests/AudibleUtilities.Tests/AudibleUtilities.Tests.csproj b/Source/_Tests/AudibleUtilities.Tests/AudibleUtilities.Tests.csproj index 3cd616a5..31bc85af 100644 --- a/Source/_Tests/AudibleUtilities.Tests/AudibleUtilities.Tests.csproj +++ b/Source/_Tests/AudibleUtilities.Tests/AudibleUtilities.Tests.csproj @@ -6,7 +6,7 @@ - + diff --git a/Source/_Tests/FileLiberator.Tests/FileLiberator.Tests.csproj b/Source/_Tests/FileLiberator.Tests/FileLiberator.Tests.csproj index daa5eef8..056bb344 100644 --- a/Source/_Tests/FileLiberator.Tests/FileLiberator.Tests.csproj +++ b/Source/_Tests/FileLiberator.Tests/FileLiberator.Tests.csproj @@ -6,7 +6,7 @@ - + diff --git a/Source/_Tests/FileManager.Tests/FileManager.Tests.csproj b/Source/_Tests/FileManager.Tests/FileManager.Tests.csproj index a63570b9..2791941c 100644 --- a/Source/_Tests/FileManager.Tests/FileManager.Tests.csproj +++ b/Source/_Tests/FileManager.Tests/FileManager.Tests.csproj @@ -7,7 +7,7 @@ - + diff --git a/Source/_Tests/LibationFileManager.Tests/LibationFileManager.Tests.csproj b/Source/_Tests/LibationFileManager.Tests/LibationFileManager.Tests.csproj index 13b07e38..d89e59b0 100644 --- a/Source/_Tests/LibationFileManager.Tests/LibationFileManager.Tests.csproj +++ b/Source/_Tests/LibationFileManager.Tests/LibationFileManager.Tests.csproj @@ -7,7 +7,7 @@ - + diff --git a/Source/_Tests/LibationSearchEngine.Tests/LibationSearchEngine.Tests.csproj b/Source/_Tests/LibationSearchEngine.Tests/LibationSearchEngine.Tests.csproj index 25e09cef..ec4e83fe 100644 --- a/Source/_Tests/LibationSearchEngine.Tests/LibationSearchEngine.Tests.csproj +++ b/Source/_Tests/LibationSearchEngine.Tests/LibationSearchEngine.Tests.csproj @@ -7,7 +7,7 @@ - + From ceb007500db16853dd9fac61a1ef8e76f428a7ea Mon Sep 17 00:00:00 2001 From: Michael Bucari-Tovo Date: Thu, 14 Aug 2025 15:58:01 -0600 Subject: [PATCH 5/7] Update assertions to use ThrowsExactly --- Source/_Tests/AudibleUtilities.Tests/AccountTests.cs | 4 ++-- Source/_Tests/FileManager.Tests/FileUtilityTests.cs | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Source/_Tests/AudibleUtilities.Tests/AccountTests.cs b/Source/_Tests/AudibleUtilities.Tests/AccountTests.cs index 03309832..f45a850f 100644 --- a/Source/_Tests/AudibleUtilities.Tests/AccountTests.cs +++ b/Source/_Tests/AudibleUtilities.Tests/AccountTests.cs @@ -528,7 +528,7 @@ namespace AccountsTests var a2 = new Account("a") { AccountName = "two", IdentityTokens = idIn }; // violation: validate() - Assert.ThrowsException(() => accountsSettings.Add(a2)); + Assert.ThrowsExactly(() => accountsSettings.Add(a2)); } [TestMethod] @@ -545,7 +545,7 @@ namespace AccountsTests accountsSettings.Add(a2); // violation: GetAccount.SingleOrDefault - Assert.ThrowsException(() => a2.IdentityTokens = idIn); + Assert.ThrowsExactly(() => a2.IdentityTokens = idIn); } } diff --git a/Source/_Tests/FileManager.Tests/FileUtilityTests.cs b/Source/_Tests/FileManager.Tests/FileUtilityTests.cs index ff0e9f58..0b3dcafc 100644 --- a/Source/_Tests/FileManager.Tests/FileUtilityTests.cs +++ b/Source/_Tests/FileManager.Tests/FileUtilityTests.cs @@ -17,7 +17,7 @@ namespace FileUtilityTests static readonly ReplacementCharacters Barebones = ReplacementCharacters.Barebones(Environment.OSVersion.Platform == PlatformID.Win32NT); [TestMethod] - public void null_path_throws() => Assert.ThrowsException(() => FileUtility.GetSafePath(null, Default)); + public void null_path_throws() => Assert.ThrowsExactly(() => FileUtility.GetSafePath(null, Default)); [TestMethod] // non-empty replacement @@ -137,25 +137,25 @@ namespace FileUtilityTests public class GetSequenceFormatted { [TestMethod] - public void negative_partsPosition() => Assert.ThrowsException(() + public void negative_partsPosition() => Assert.ThrowsExactly(() => FileUtility.GetSequenceFormatted(-1, 2) ); [TestMethod] - public void zero_partsPosition() => Assert.ThrowsException(() + public void zero_partsPosition() => Assert.ThrowsExactly(() => FileUtility.GetSequenceFormatted(0, 2) ); [TestMethod] - public void negative_partsTotal() => Assert.ThrowsException(() + public void negative_partsTotal() => Assert.ThrowsExactly(() => FileUtility.GetSequenceFormatted(2, -1) ); [TestMethod] - public void zero_partsTotal() => Assert.ThrowsException(() + public void zero_partsTotal() => Assert.ThrowsExactly(() => FileUtility.GetSequenceFormatted(2, 0) ); [TestMethod] - public void partsPosition_greater_than_partsTotal() => Assert.ThrowsException(() + public void partsPosition_greater_than_partsTotal() => Assert.ThrowsExactly(() => FileUtility.GetSequenceFormatted(2, 1) ); From eda100b7ac40d5e441b758a80b34ed3506906734 Mon Sep 17 00:00:00 2001 From: Michael Bucari-Tovo Date: Fri, 15 Aug 2025 09:54:26 -0600 Subject: [PATCH 6/7] Remove FluentAssertions --- Source/Libation.sln | 7 ++++ .../AssertionHelper/AssertionExtensions.cs | 37 +++++++++++++++++++ .../AssertionHelper/AssertionHelper.csproj | 14 +++++++ .../AudibleUtilities.Tests/AccountTests.cs | 12 +----- .../AudibleUtilities.Tests.csproj | 6 +-- .../DownloadDecryptBookTests.cs | 10 +---- .../FileLiberator.Tests.csproj | 4 +- .../FileManager.Tests.csproj | 6 +-- .../FileNamingTemplateTests.cs | 2 +- .../FileManager.Tests/FileUtilityTests.cs | 6 +-- .../LibationFileManager.Tests.csproj | 4 +- .../TemplatesTests.cs | 3 +- .../LibationSearchEngine.Tests.csproj | 4 +- .../SearchEngineTests.cs | 15 +------- 14 files changed, 72 insertions(+), 58 deletions(-) create mode 100644 Source/_Tests/AssertionHelper/AssertionExtensions.cs create mode 100644 Source/_Tests/AssertionHelper/AssertionHelper.csproj diff --git a/Source/Libation.sln b/Source/Libation.sln index 20a54d45..024fb96a 100644 --- a/Source/Libation.sln +++ b/Source/Libation.sln @@ -102,6 +102,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Libation UI", "Libation UI" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Libation CLI", "Libation CLI", "{47E27674-595D-4F7A-8CFB-127E768E1D1E}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AssertionHelper", "_Tests\AssertionHelper\AssertionHelper.csproj", "{CFE7A0E5-37FE-40BE-A70B-41B5104181C4}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -220,6 +222,10 @@ Global {E90C4651-AF11-41B4-A839-10082D0391F9}.Debug|Any CPU.Build.0 = Debug|Any CPU {E90C4651-AF11-41B4-A839-10082D0391F9}.Release|Any CPU.ActiveCfg = Release|Any CPU {E90C4651-AF11-41B4-A839-10082D0391F9}.Release|Any CPU.Build.0 = Release|Any CPU + {CFE7A0E5-37FE-40BE-A70B-41B5104181C4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CFE7A0E5-37FE-40BE-A70B-41B5104181C4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CFE7A0E5-37FE-40BE-A70B-41B5104181C4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CFE7A0E5-37FE-40BE-A70B-41B5104181C4}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -258,6 +264,7 @@ Global {FDDABAFE-35AD-42FC-AC95-0B1FE0DF0DDE} = {8679CAC8-9164-4007-BDD2-F004810EDA14} {53758A35-1C7E-4702-9B96-433ABA457B37} = {8679CAC8-9164-4007-BDD2-F004810EDA14} {47E27674-595D-4F7A-8CFB-127E768E1D1E} = {8679CAC8-9164-4007-BDD2-F004810EDA14} + {CFE7A0E5-37FE-40BE-A70B-41B5104181C4} = {67E66E82-5532-4440-AFB3-9FB1DF9DEF53} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {615E00ED-BAEF-4E8E-A92A-9B82D87942A9} diff --git a/Source/_Tests/AssertionHelper/AssertionExtensions.cs b/Source/_Tests/AssertionHelper/AssertionExtensions.cs new file mode 100644 index 00000000..c314c614 --- /dev/null +++ b/Source/_Tests/AssertionHelper/AssertionExtensions.cs @@ -0,0 +1,37 @@ +using System.Diagnostics; + +namespace AssertionHelper; + +public static class AssertionExtensions +{ + [StackTraceHidden] + public static T? Should(this T? value) => value; + + [StackTraceHidden] + public static void Be(this T? value, T? expectedValue) where T : IEquatable + => Assert.AreEqual(value, expectedValue); + + [StackTraceHidden] + public static void BeNull(this T? value) where T : class + => Assert.IsNull(value); + + [StackTraceHidden] + public static void BeSameAs(this T? value, T? otherValue) + => Assert.AreSame(value, otherValue); + + [StackTraceHidden] + public static void BeFalse(this bool value) + => Assert.IsFalse(value); + + [StackTraceHidden] + public static void BeTrue(this bool value) + => Assert.IsTrue(value); + + [StackTraceHidden] + public static void HaveCount(this IEnumerable value, int expected) + => Assert.HasCount(expected, value); + + [StackTraceHidden] + public static void BeEquivalentTo(this IEnumerable? value, IEnumerable? expectedValue) + => CollectionAssert.AreEquivalent(value, expectedValue, EqualityComparer.Default); +} diff --git a/Source/_Tests/AssertionHelper/AssertionHelper.csproj b/Source/_Tests/AssertionHelper/AssertionHelper.csproj new file mode 100644 index 00000000..7f3ad5da --- /dev/null +++ b/Source/_Tests/AssertionHelper/AssertionHelper.csproj @@ -0,0 +1,14 @@ + + + + net9.0 + enable + enable + + + + + + + + diff --git a/Source/_Tests/AudibleUtilities.Tests/AccountTests.cs b/Source/_Tests/AudibleUtilities.Tests/AccountTests.cs index f45a850f..8a40991f 100644 --- a/Source/_Tests/AudibleUtilities.Tests/AccountTests.cs +++ b/Source/_Tests/AudibleUtilities.Tests/AccountTests.cs @@ -1,21 +1,11 @@ using System; -using System.Collections.Generic; using System.IO; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; +using AssertionHelper; using AudibleApi; using AudibleApi.Authorization; using AudibleUtilities; -using Dinah.Core; -using FluentAssertions; -using FluentAssertions.Common; -using Microsoft.VisualStudio.TestPlatform.Common.Filtering; using Microsoft.VisualStudio.TestTools.UnitTesting; using Newtonsoft.Json; -using Newtonsoft.Json.Linq; namespace AccountsTests { diff --git a/Source/_Tests/AudibleUtilities.Tests/AudibleUtilities.Tests.csproj b/Source/_Tests/AudibleUtilities.Tests/AudibleUtilities.Tests.csproj index 31bc85af..76ac21e6 100644 --- a/Source/_Tests/AudibleUtilities.Tests/AudibleUtilities.Tests.csproj +++ b/Source/_Tests/AudibleUtilities.Tests/AudibleUtilities.Tests.csproj @@ -1,4 +1,4 @@ - + net9.0 @@ -6,10 +6,7 @@ - - - all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -18,6 +15,7 @@ + diff --git a/Source/_Tests/FileLiberator.Tests/DownloadDecryptBookTests.cs b/Source/_Tests/FileLiberator.Tests/DownloadDecryptBookTests.cs index fac89148..d6f64d9c 100644 --- a/Source/_Tests/FileLiberator.Tests/DownloadDecryptBookTests.cs +++ b/Source/_Tests/FileLiberator.Tests/DownloadDecryptBookTests.cs @@ -1,14 +1,8 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; +using System.Collections.Generic; +using AssertionHelper; using AudibleApi.Common; -using Dinah.Core; -using FileManager; -using FluentAssertions; using Microsoft.VisualStudio.TestTools.UnitTesting; - namespace FileLiberator.Tests { [TestClass] diff --git a/Source/_Tests/FileLiberator.Tests/FileLiberator.Tests.csproj b/Source/_Tests/FileLiberator.Tests/FileLiberator.Tests.csproj index 056bb344..8f0982e2 100644 --- a/Source/_Tests/FileLiberator.Tests/FileLiberator.Tests.csproj +++ b/Source/_Tests/FileLiberator.Tests/FileLiberator.Tests.csproj @@ -6,10 +6,7 @@ - - - all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -18,6 +15,7 @@ + diff --git a/Source/_Tests/FileManager.Tests/FileManager.Tests.csproj b/Source/_Tests/FileManager.Tests/FileManager.Tests.csproj index 2791941c..b2973d87 100644 --- a/Source/_Tests/FileManager.Tests/FileManager.Tests.csproj +++ b/Source/_Tests/FileManager.Tests/FileManager.Tests.csproj @@ -1,4 +1,4 @@ - + net9.0 @@ -7,10 +7,7 @@ - - - all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -19,6 +16,7 @@ + diff --git a/Source/_Tests/FileManager.Tests/FileNamingTemplateTests.cs b/Source/_Tests/FileManager.Tests/FileNamingTemplateTests.cs index fccffc91..65dc1218 100644 --- a/Source/_Tests/FileManager.Tests/FileNamingTemplateTests.cs +++ b/Source/_Tests/FileManager.Tests/FileNamingTemplateTests.cs @@ -1,6 +1,6 @@ using System.Linq; +using AssertionHelper; using FileManager.NamingTemplate; -using FluentAssertions; using Microsoft.VisualStudio.TestTools.UnitTesting; namespace NamingTemplateTests diff --git a/Source/_Tests/FileManager.Tests/FileUtilityTests.cs b/Source/_Tests/FileManager.Tests/FileUtilityTests.cs index 0b3dcafc..4cc67b30 100644 --- a/Source/_Tests/FileManager.Tests/FileUtilityTests.cs +++ b/Source/_Tests/FileManager.Tests/FileUtilityTests.cs @@ -1,10 +1,6 @@ using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using Dinah.Core; +using AssertionHelper; using FileManager; -using FluentAssertions; using Microsoft.VisualStudio.TestTools.UnitTesting; namespace FileUtilityTests diff --git a/Source/_Tests/LibationFileManager.Tests/LibationFileManager.Tests.csproj b/Source/_Tests/LibationFileManager.Tests/LibationFileManager.Tests.csproj index d89e59b0..2e9c8b73 100644 --- a/Source/_Tests/LibationFileManager.Tests/LibationFileManager.Tests.csproj +++ b/Source/_Tests/LibationFileManager.Tests/LibationFileManager.Tests.csproj @@ -7,10 +7,7 @@ - - - all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -19,6 +16,7 @@ + diff --git a/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs b/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs index 15da05ea..bbee3c80 100644 --- a/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs +++ b/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs @@ -2,10 +2,9 @@ using System.Collections.Generic; using System.IO; using System.Linq; -using Dinah.Core; +using AssertionHelper; using FileManager; using FileManager.NamingTemplate; -using FluentAssertions; using LibationFileManager.Templates; using Microsoft.VisualStudio.TestTools.UnitTesting; diff --git a/Source/_Tests/LibationSearchEngine.Tests/LibationSearchEngine.Tests.csproj b/Source/_Tests/LibationSearchEngine.Tests/LibationSearchEngine.Tests.csproj index ec4e83fe..28b5897c 100644 --- a/Source/_Tests/LibationSearchEngine.Tests/LibationSearchEngine.Tests.csproj +++ b/Source/_Tests/LibationSearchEngine.Tests/LibationSearchEngine.Tests.csproj @@ -7,10 +7,7 @@ - - - all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -19,6 +16,7 @@ + diff --git a/Source/_Tests/LibationSearchEngine.Tests/SearchEngineTests.cs b/Source/_Tests/LibationSearchEngine.Tests/SearchEngineTests.cs index 492627f6..eb62fd52 100644 --- a/Source/_Tests/LibationSearchEngine.Tests/SearchEngineTests.cs +++ b/Source/_Tests/LibationSearchEngine.Tests/SearchEngineTests.cs @@ -1,20 +1,7 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; -using Dinah.Core; -using FluentAssertions; -using FluentAssertions.Common; +using AssertionHelper; using LibationSearchEngine; using Lucene.Net.Analysis.Standard; -using Microsoft.VisualStudio.TestPlatform.Common.Filtering; using Microsoft.VisualStudio.TestTools.UnitTesting; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; namespace SearchEngineTests { From 736fbbf82f0aa7819b64c118d59e80cd354ebaa8 Mon Sep 17 00:00:00 2001 From: Michael Bucari-Tovo Date: Fri, 15 Aug 2025 10:50:37 -0600 Subject: [PATCH 7/7] Improve FilePathCache performance --- Source/LibationFileManager/FilePathCache.cs | 73 +++++++++++++++++---- 1 file changed, 59 insertions(+), 14 deletions(-) diff --git a/Source/LibationFileManager/FilePathCache.cs b/Source/LibationFileManager/FilePathCache.cs index a6c99ef6..3cf6c528 100644 --- a/Source/LibationFileManager/FilePathCache.cs +++ b/Source/LibationFileManager/FilePathCache.cs @@ -1,10 +1,11 @@ -using System; +using FileManager; +using Newtonsoft.Json; +using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Linq; -using FileManager; -using Newtonsoft.Json; +using System.Threading.Tasks; #nullable enable namespace LibationFileManager @@ -32,6 +33,10 @@ namespace LibationFileManager { Cache = JsonConvert.DeserializeObject>(File.ReadAllText(jsonFileV2)) ?? throw new NullReferenceException("File exists but deserialize is null. This will never happen when file is healthy."); + + //Once per startup, launch a task to validate existence of files in the cache. + //This is fire-and-forget. Since it is never awaited, it will no exceptions will be thrown to the caller. + Task.Run(ValidateAllFiles); } catch (Exception ex) { @@ -42,6 +47,23 @@ namespace LibationFileManager } } + private static void ValidateAllFiles() + { + bool cacheChanged = false; + foreach (var id in Cache.GetIDs()) + { + foreach (var entry in Cache.GetIdEntries(id)) + { + if (!File.Exists(entry.Path)) + { + cacheChanged |= Remove(entry); + } + } + } + if (cacheChanged) + save(); + } + public static bool Exists(string id, FileType type) => GetFirstPath(id, type) is not null; public static List<(FileType fileType, LongPath path)> GetFiles(string id) @@ -111,10 +133,20 @@ namespace LibationFileManager return false; } - public static void Insert(string id, string path) + public static void Insert(string id, params string[] paths) { - var type = FileTypes.GetFileTypeFromPath(path); - Insert(new CacheEntry(id, type, path)); + var newEntries + = paths + .Select(path => new CacheEntry(id, FileTypes.GetFileTypeFromPath(path), path)) + .ToList(); + + lock (locker) + Cache.AddRange(id, newEntries); + + if (Inserted is not null) + newEntries.ForEach(e => Inserted?.Invoke(null, e)); + + save(); } public static void Insert(CacheEntry entry) @@ -150,9 +182,11 @@ namespace LibationFileManager private class FileCacheV2 { [JsonProperty] - private readonly ConcurrentDictionary> Dictionary = new(); + private readonly ConcurrentDictionary> Dictionary = new(); private static object lockObject = new(); + public List GetIDs() => Dictionary.Keys.ToList(); + public List GetIdEntries(string id) { static List empty() => new(); @@ -162,23 +196,34 @@ namespace LibationFileManager public void Add(string id, TEntry entry) { - Dictionary.AddOrUpdate(id, [entry], (id, entries) => { entries.Add(entry); return entries; }); + Dictionary.AddOrUpdate(id, + (_, e) => [e], //Add new Dictionary Value + (id, existingEntries, newEntry) => //Update existing Dictionary Value + { + existingEntries.Add(entry); + return existingEntries; + }, + entry); } public void AddRange(string id, IEnumerable entries) { - Dictionary.AddOrUpdate(id, entries.ToList(), (id, entries) => - { - entries.AddRange(entries); - return entries; - }); + Dictionary.AddOrUpdate>(id, + (_, e) => e.ToHashSet(), //Add new Dictionary Value + (id, existingEntries, newEntries) => //Update existing Dictionary Value + { + foreach (var entry in newEntries) + existingEntries.Add(entry); + return existingEntries; + }, + entries); } public bool Remove(string id, TEntry entry) { lock (lockObject) { - if (Dictionary.TryGetValue(id, out List? entries)) + if (Dictionary.TryGetValue(id, out HashSet? entries)) { var removed = entries?.Remove(entry) ?? false; if (removed && entries?.Count == 0)