From 0f4197924ed6053f5e244b8cf71fdca783b680f5 Mon Sep 17 00:00:00 2001 From: Michael Bucari-Tovo Date: Tue, 22 Jul 2025 11:59:34 -0600 Subject: [PATCH 01/13] Use LibationUiBase.ReactiveObject where applicable Also tweak the classic process queue control layout --- Source/LibationUiBase/ReactiveObject.cs | 6 ++++++ Source/LibationUiBase/SeriesView/AyceButton.cs | 2 +- .../LibationUiBase/SeriesView/SeriesButton.cs | 13 +++---------- Source/LibationUiBase/SeriesView/SeriesItem.cs | 12 +++--------- .../SeriesView/WishlistButton.cs | 13 +++---------- .../Dialogs/BookRecordsDialog.cs | 4 ++-- .../GridView/AsyncNotifyPropertyChanged.cs | 17 ----------------- .../ProcessQueueControl.Designer.cs | 18 +++--------------- .../ProcessQueue/ProcessQueueControl.cs | 14 ++++---------- 9 files changed, 25 insertions(+), 74 deletions(-) delete mode 100644 Source/LibationWinForms/GridView/AsyncNotifyPropertyChanged.cs diff --git a/Source/LibationUiBase/ReactiveObject.cs b/Source/LibationUiBase/ReactiveObject.cs index e86ed991..53392311 100644 --- a/Source/LibationUiBase/ReactiveObject.cs +++ b/Source/LibationUiBase/ReactiveObject.cs @@ -7,8 +7,14 @@ using System.Runtime.CompilerServices; #nullable enable namespace LibationUiBase; +/// +/// ReactiveObject is the base object for ViewModel classes, and it implements INotifyPropertyChanging +/// and INotifyPropertyChanged. Additionally +/// object changes. +/// public class ReactiveObject : SynchronizeInvoker, INotifyPropertyChanged, INotifyPropertyChanging { + // see also notes in Libation/Source/_ARCHITECTURE NOTES.txt :: MVVM public event PropertyChangedEventHandler? PropertyChanged; public event PropertyChangingEventHandler? PropertyChanging; diff --git a/Source/LibationUiBase/SeriesView/AyceButton.cs b/Source/LibationUiBase/SeriesView/AyceButton.cs index 263e6773..00fb2403 100644 --- a/Source/LibationUiBase/SeriesView/AyceButton.cs +++ b/Source/LibationUiBase/SeriesView/AyceButton.cs @@ -117,7 +117,7 @@ namespace LibationUiBase.SeriesView } private void DownloadButton_ButtonEnabled(object sender, EventArgs e) - => OnPropertyChanged(nameof(Enabled)); + => RaisePropertyChanged(nameof(Enabled)); public override int CompareTo(object ob) { diff --git a/Source/LibationUiBase/SeriesView/SeriesButton.cs b/Source/LibationUiBase/SeriesView/SeriesButton.cs index 3156c036..94d79f2d 100644 --- a/Source/LibationUiBase/SeriesView/SeriesButton.cs +++ b/Source/LibationUiBase/SeriesView/SeriesButton.cs @@ -1,8 +1,6 @@ using AudibleApi.Common; using DataLayer; -using Dinah.Core.Threading; using System; -using System.ComponentModel; using System.Threading.Tasks; namespace LibationUiBase.SeriesView @@ -10,11 +8,9 @@ namespace LibationUiBase.SeriesView /// /// base view model for the Series Viewer 'Availability' button column /// - public abstract class SeriesButton : SynchronizeInvoker, IComparable, INotifyPropertyChanged + public abstract class SeriesButton : ReactiveObject, IComparable { - public event PropertyChangedEventHandler PropertyChanged; private bool inLibrary; - protected Item Item { get; } public abstract string DisplayText { get; } public abstract bool HasButtonAction { get; } @@ -27,8 +23,8 @@ namespace LibationUiBase.SeriesView if (inLibrary != value) { inLibrary = value; - OnPropertyChanged(nameof(InLibrary)); - OnPropertyChanged(nameof(DisplayText)); + RaisePropertyChanged(nameof(InLibrary)); + RaisePropertyChanged(nameof(DisplayText)); } } } @@ -41,9 +37,6 @@ namespace LibationUiBase.SeriesView public abstract Task PerformClickAsync(LibraryBook accountBook); - protected void OnPropertyChanged(string propertyName) - => Invoke(() => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName))); - public override string ToString() => DisplayText; public abstract int CompareTo(object ob); diff --git a/Source/LibationUiBase/SeriesView/SeriesItem.cs b/Source/LibationUiBase/SeriesView/SeriesItem.cs index 44b451e4..cd7d19e1 100644 --- a/Source/LibationUiBase/SeriesView/SeriesItem.cs +++ b/Source/LibationUiBase/SeriesView/SeriesItem.cs @@ -4,7 +4,6 @@ using AudibleApi.Common; using AudibleUtilities; using DataLayer; using Dinah.Core; -using Dinah.Core.Threading; using FileLiberator; using LibationFileManager; using System.Collections.Generic; @@ -15,7 +14,7 @@ using System.Threading.Tasks; namespace LibationUiBase.SeriesView { - public class SeriesItem : SynchronizeInvoker, INotifyPropertyChanged + public class SeriesItem : ReactiveObject { public object Cover { get; private set; } public SeriesOrder Order { get; } @@ -23,8 +22,6 @@ namespace LibationUiBase.SeriesView public SeriesButton Button { get; } public Item Item { get; } - public event PropertyChangedEventHandler PropertyChanged; - private SeriesItem(Item item, string order, bool inLibrary, bool inWishList) { Item = item; @@ -42,10 +39,7 @@ namespace LibationUiBase.SeriesView } private void DownloadButton_PropertyChanged(object sender, PropertyChangedEventArgs e) - => OnPropertyChanged(nameof(Button)); - - private void OnPropertyChanged(string propertyName) - => Invoke(() => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName))); + => RaisePropertyChanged(nameof(Button)); private void LoadCover(string pictureId) { @@ -66,7 +60,7 @@ namespace LibationUiBase.SeriesView { Cover = BaseUtil.LoadImage(e.Picture, PictureSize._80x80); PictureStorage.PictureCached -= PictureStorage_PictureCached; - OnPropertyChanged(nameof(Cover)); + RaisePropertyChanged(nameof(Cover)); } } } diff --git a/Source/LibationUiBase/SeriesView/WishlistButton.cs b/Source/LibationUiBase/SeriesView/WishlistButton.cs index fcf4bcb8..8a236165 100644 --- a/Source/LibationUiBase/SeriesView/WishlistButton.cs +++ b/Source/LibationUiBase/SeriesView/WishlistButton.cs @@ -22,14 +22,7 @@ namespace LibationUiBase.SeriesView public override bool Enabled { get => instanceEnabled; - protected set - { - if (instanceEnabled != value) - { - instanceEnabled = value; - OnPropertyChanged(nameof(Enabled)); - } - } + protected set => RaiseAndSetIfChanged(ref instanceEnabled, value); } private bool InWishList @@ -40,8 +33,8 @@ namespace LibationUiBase.SeriesView if (inWishList != value) { inWishList = value; - OnPropertyChanged(nameof(InWishList)); - OnPropertyChanged(nameof(DisplayText)); + RaisePropertyChanged(nameof(InWishList)); + RaisePropertyChanged(nameof(DisplayText)); } } } diff --git a/Source/LibationWinForms/Dialogs/BookRecordsDialog.cs b/Source/LibationWinForms/Dialogs/BookRecordsDialog.cs index a93e652e..2891f7ac 100644 --- a/Source/LibationWinForms/Dialogs/BookRecordsDialog.cs +++ b/Source/LibationWinForms/Dialogs/BookRecordsDialog.cs @@ -250,12 +250,12 @@ namespace LibationWinForms.Dialogs } } - private class BookRecordEntry : GridView.AsyncNotifyPropertyChanged + private class BookRecordEntry : LibationUiBase.ReactiveObject { private const string DateFormat = "yyyy-MM-dd HH\\:mm"; private bool _ischecked; public IRecord Record { get; } - public bool IsChecked { get => _ischecked; set { _ischecked = value; NotifyPropertyChanged(); } } + public bool IsChecked { get => _ischecked; set => RaiseAndSetIfChanged(ref _ischecked, value); } public string Type => Record.GetType().Name; public string Start => formatTimeSpan(Record.Start); public string Created => Record.Created.ToString(DateFormat); diff --git a/Source/LibationWinForms/GridView/AsyncNotifyPropertyChanged.cs b/Source/LibationWinForms/GridView/AsyncNotifyPropertyChanged.cs deleted file mode 100644 index f02dd26e..00000000 --- a/Source/LibationWinForms/GridView/AsyncNotifyPropertyChanged.cs +++ /dev/null @@ -1,17 +0,0 @@ -using Dinah.Core.Threading; -using System.ComponentModel; -using System.Runtime.CompilerServices; - -namespace LibationWinForms.GridView -{ - public abstract class AsyncNotifyPropertyChanged : SynchronizeInvoker, INotifyPropertyChanged - { - // see also notes in Libation/Source/_ARCHITECTURE NOTES.txt :: MVVM - public event PropertyChangedEventHandler PropertyChanged; - - // per standard INotifyPropertyChanged pattern: - // https://docs.microsoft.com/en-us/dotnet/desktop/wpf/data/how-to-implement-property-change-notification - public void NotifyPropertyChanged([CallerMemberName] string propertyName = "") - => this.UIThreadSync(() => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName))); - } -} diff --git a/Source/LibationWinForms/ProcessQueue/ProcessQueueControl.Designer.cs b/Source/LibationWinForms/ProcessQueue/ProcessQueueControl.Designer.cs index 85fdb856..b5bf91f5 100644 --- a/Source/LibationWinForms/ProcessQueue/ProcessQueueControl.Designer.cs +++ b/Source/LibationWinForms/ProcessQueue/ProcessQueueControl.Designer.cs @@ -40,7 +40,6 @@ this.runningTimeLbl = new System.Windows.Forms.ToolStripStatusLabel(); this.tabControl1 = new System.Windows.Forms.TabControl(); this.tabPage1 = new System.Windows.Forms.TabPage(); - this.panel3 = new System.Windows.Forms.Panel(); this.virtualFlowControl2 = new LibationWinForms.ProcessQueue.VirtualFlowControl(); this.panel1 = new System.Windows.Forms.Panel(); this.label1 = new System.Windows.Forms.Label(); @@ -134,7 +133,6 @@ // // tabPage1 // - this.tabPage1.Controls.Add(this.panel3); this.tabPage1.Controls.Add(this.virtualFlowControl2); this.tabPage1.Controls.Add(this.panel1); this.tabPage1.Location = new System.Drawing.Point(4, 24); @@ -145,14 +143,6 @@ this.tabPage1.Text = "Process Queue"; this.tabPage1.UseVisualStyleBackColor = true; // - // panel3 - // - this.panel3.Dock = System.Windows.Forms.DockStyle.Bottom; - this.panel3.Location = new System.Drawing.Point(3, 422); - this.panel3.Name = "panel3"; - this.panel3.Size = new System.Drawing.Size(390, 5); - this.panel3.TabIndex = 4; - // // virtualFlowControl2 // this.virtualFlowControl2.AccessibleRole = System.Windows.Forms.AccessibleRole.None; @@ -174,14 +164,14 @@ this.panel1.Dock = System.Windows.Forms.DockStyle.Bottom; this.panel1.Location = new System.Drawing.Point(3, 427); this.panel1.Name = "panel1"; - this.panel1.Size = new System.Drawing.Size(390, 25); + this.panel1.Size = new System.Drawing.Size(390, 29); this.panel1.TabIndex = 2; // // label1 // this.label1.Anchor = System.Windows.Forms.AnchorStyles.Right; this.label1.AutoSize = true; - this.label1.Location = new System.Drawing.Point(148, 4); + this.label1.Location = new System.Drawing.Point(148, 6); this.label1.Name = "label1"; this.label1.Size = new System.Drawing.Size(54, 15); this.label1.TabIndex = 5; @@ -196,7 +186,7 @@ 0, 0, 65536}); - this.numericUpDown1.Location = new System.Drawing.Point(208, 0); + this.numericUpDown1.Location = new System.Drawing.Point(208, 2); this.numericUpDown1.Maximum = new decimal(new int[] { 999, 0, @@ -348,7 +338,6 @@ this.panel2.ResumeLayout(false); this.ResumeLayout(false); this.PerformLayout(); - } #endregion @@ -367,7 +356,6 @@ private System.Windows.Forms.ToolStripStatusLabel queueNumberLbl; private System.Windows.Forms.ToolStripStatusLabel completedNumberLbl; private System.Windows.Forms.ToolStripStatusLabel errorNumberLbl; - private System.Windows.Forms.Panel panel3; private System.Windows.Forms.Panel panel4; private System.Windows.Forms.ToolStripStatusLabel runningTimeLbl; private System.Windows.Forms.DataGridView logDGV; diff --git a/Source/LibationWinForms/ProcessQueue/ProcessQueueControl.cs b/Source/LibationWinForms/ProcessQueue/ProcessQueueControl.cs index 76a7d43d..15bcc5d6 100644 --- a/Source/LibationWinForms/ProcessQueue/ProcessQueueControl.cs +++ b/Source/LibationWinForms/ProcessQueue/ProcessQueueControl.cs @@ -1,5 +1,4 @@ -using LibationFileManager; -using LibationUiBase; +using LibationUiBase; using LibationUiBase.ProcessQueue; using System; using System.ComponentModel; @@ -35,23 +34,18 @@ internal partial class ProcessQueueControl : UserControl ViewModel.PropertyChanged += ProcessQueue_PropertyChanged; ViewModel.LogEntries.CollectionChanged += LogEntries_CollectionChanged; + ProcessQueue_PropertyChanged(this, new PropertyChangedEventArgs(null)); } private void LogEntries_CollectionChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e) { if (!IsDisposed && e.Action == System.Collections.Specialized.NotifyCollectionChangedAction.Add) { - foreach(var entry in e.NewItems?.OfType() ?? []) + foreach (var entry in e.NewItems?.OfType() ?? []) logDGV.Rows.Add(entry.LogDate, entry.LogMessage); } } - protected override void OnLoad(EventArgs e) - { - if (DesignMode) return; - ProcessQueue_PropertyChanged(this, new PropertyChangedEventArgs(null)); - } - private async void cancelAllBtn_Click(object? sender, EventArgs e) { ViewModel.Queue.ClearQueue(); @@ -155,7 +149,7 @@ internal partial class ProcessQueueControl : UserControl ViewModel.Queue.MoveQueuePosition(item, position.Value); } } - catch(Exception ex) + catch (Exception ex) { Serilog.Log.Logger.Error(ex, "Error handling button click from queued item"); } From 1f473039e1986d3bbf933dfbf8de10a9a5e729ff Mon Sep 17 00:00:00 2001 From: Michael Bucari-Tovo Date: Tue, 22 Jul 2025 15:39:43 -0600 Subject: [PATCH 02/13] Make search syntax dialog field names scrollable --- .../Dialogs/SearchSyntaxDialog.axaml | 108 +++---- .../Dialogs/SearchSyntaxDialog.axaml.cs | 74 +++-- .../Dialogs/SearchSyntaxDialog.Designer.cs | 297 ++++++++++++++++-- .../Dialogs/SearchSyntaxDialog.cs | 18 +- 4 files changed, 364 insertions(+), 133 deletions(-) diff --git a/Source/LibationAvalonia/Dialogs/SearchSyntaxDialog.axaml b/Source/LibationAvalonia/Dialogs/SearchSyntaxDialog.axaml index 6240e94b..0202d9de 100644 --- a/Source/LibationAvalonia/Dialogs/SearchSyntaxDialog.axaml +++ b/Source/LibationAvalonia/Dialogs/SearchSyntaxDialog.axaml @@ -2,71 +2,73 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="650" - MinWidth="800" MinHeight="650" - MaxWidth="800" MaxHeight="650" + xmlns:dialogs="clr-namespace:LibationAvalonia.Dialogs" + x:DataType="dialogs:SearchSyntaxDialog" + mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="50" + MinWidth="500" MinHeight="650" Width="800" Height="650" x:Class="LibationAvalonia.Dialogs.SearchSyntaxDialog" Title="Filter Options" WindowStartupLocation="CenterOwner"> - + + RowDefinitions="Auto,*" + ColumnDefinitions="*,*,*,*"> + + + + - - - - - - - - - - - - - - - - - - + RowDefinitions="Auto,Auto"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Source/LibationAvalonia/Dialogs/SearchSyntaxDialog.axaml.cs b/Source/LibationAvalonia/Dialogs/SearchSyntaxDialog.axaml.cs index 115ab2cc..3feecbf1 100644 --- a/Source/LibationAvalonia/Dialogs/SearchSyntaxDialog.axaml.cs +++ b/Source/LibationAvalonia/Dialogs/SearchSyntaxDialog.axaml.cs @@ -1,59 +1,55 @@ using LibationSearchEngine; +using System.Linq; namespace LibationAvalonia.Dialogs { public partial class SearchSyntaxDialog : DialogWindow { - public string StringFields { get; init; } - public string NumberFields { get; init; } - public string BoolFields { get; init; } - public string IdFields { get; init; } + public string StringUsage { get; } + public string NumberUsage { get; } + public string BoolUsage { get; } + public string IdUsage { get; } + public string[] StringFields { get; } = SearchEngine.FieldIndexRules.StringFieldNames.ToArray(); + public string[] NumberFields { get; } = SearchEngine.FieldIndexRules.NumberFieldNames.ToArray(); + public string[] BoolFields { get; } = SearchEngine.FieldIndexRules.BoolFieldNames.ToArray(); + public string[] IdFields { get; } = SearchEngine.FieldIndexRules.IdFieldNames.ToArray(); + public SearchSyntaxDialog() { InitializeComponent(); - StringFields = @" -Search for wizard of oz: - title:oz - title:""wizard of oz"" + StringUsage = """ + Search for wizard of oz: + title:oz + title:"wizard of oz" + """; + NumberUsage = """ + Find books between 1-100 minutes long + length:[1 TO 100] + Find books exactly 1 hr long + length:60 + Find books published from 2020-1-1 to + 2023-12-31 + datepublished:[20200101 TO 20231231] + """; -" + string.Join("\r\n", SearchEngine.FieldIndexRules.StringFieldNames); + BoolUsage = """ + Find books that you haven't rated: + -IsRated + """; - NumberFields = @" -Find books between 1-100 minutes long - length:[1 TO 100] -Find books exactly 1 hr long - length:60 -Find books published from 2020-1-1 to -2023-12-31 - datepublished:[20200101 TO 20231231] + IdUsage = """ + Alice's Adventures in + Wonderland (ID: B015D78L0U) + id:B015D78L0U -" + string.Join("\r\n", SearchEngine.FieldIndexRules.NumberFieldNames); - - BoolFields = @" -Find books that you haven't rated: - -IsRated - - -" + string.Join("\r\n", SearchEngine.FieldIndexRules.BoolFieldNames); - - IdFields = @" -Alice's Adventures in - Wonderland (ID: B015D78L0U) - - id:B015D78L0U - -All of these are synonyms -for the ID field - - -" + string.Join("\r\n", SearchEngine.FieldIndexRules.IdFieldNames); - + All of these are synonyms + for the ID field + """; DataContext = this; - } } } diff --git a/Source/LibationWinForms/Dialogs/SearchSyntaxDialog.Designer.cs b/Source/LibationWinForms/Dialogs/SearchSyntaxDialog.Designer.cs index 958edf79..3c62f74d 100644 --- a/Source/LibationWinForms/Dialogs/SearchSyntaxDialog.Designer.cs +++ b/Source/LibationWinForms/Dialogs/SearchSyntaxDialog.Designer.cs @@ -34,7 +34,26 @@ label3 = new System.Windows.Forms.Label(); label4 = new System.Windows.Forms.Label(); label5 = new System.Windows.Forms.Label(); - closeBtn = new System.Windows.Forms.Button(); + tableLayoutPanel1 = new System.Windows.Forms.TableLayoutPanel(); + tableLayoutPanel5 = new System.Windows.Forms.TableLayoutPanel(); + lboxIdFields = new System.Windows.Forms.ListBox(); + label9 = new System.Windows.Forms.Label(); + tableLayoutPanel4 = new System.Windows.Forms.TableLayoutPanel(); + lboxBoolFields = new System.Windows.Forms.ListBox(); + label8 = new System.Windows.Forms.Label(); + tableLayoutPanel3 = new System.Windows.Forms.TableLayoutPanel(); + lboxNumberFields = new System.Windows.Forms.ListBox(); + label7 = new System.Windows.Forms.Label(); + tableLayoutPanel2 = new System.Windows.Forms.TableLayoutPanel(); + lboxStringFields = new System.Windows.Forms.ListBox(); + label6 = new System.Windows.Forms.Label(); + label10 = new System.Windows.Forms.Label(); + label11 = new System.Windows.Forms.Label(); + tableLayoutPanel1.SuspendLayout(); + tableLayoutPanel5.SuspendLayout(); + tableLayoutPanel4.SuspendLayout(); + tableLayoutPanel3.SuspendLayout(); + tableLayoutPanel2.SuspendLayout(); SuspendLayout(); // // label1 @@ -43,75 +62,262 @@ label1.Location = new System.Drawing.Point(14, 10); label1.Margin = new System.Windows.Forms.Padding(4, 0, 4, 0); label1.Name = "label1"; - label1.Size = new System.Drawing.Size(410, 60); + label1.Size = new System.Drawing.Size(410, 30); label1.TabIndex = 0; - label1.Text = "Full Lucene query syntax is supported\r\nFields with similar names are synomyns (eg: Author, Authors, AuthorNames)\r\n\r\nTAG FORMAT: [tagName]"; + label1.Text = "Full Lucene query syntax is supported\r\nFields with similar names are synomyns (eg: Author, Authors, AuthorNames)"; // // label2 // + label2.Anchor = System.Windows.Forms.AnchorStyles.Top; label2.AutoSize = true; - label2.Location = new System.Drawing.Point(14, 82); + label2.Location = new System.Drawing.Point(48, 18); label2.Margin = new System.Windows.Forms.Padding(4, 0, 4, 0); label2.Name = "label2"; - label2.Size = new System.Drawing.Size(129, 75); + label2.Size = new System.Drawing.Size(129, 45); label2.TabIndex = 1; - label2.Text = "STRING FIELDS\r\n\r\nSearch for wizard of oz:\r\n title:oz\r\n title:\"wizard of oz\""; + label2.Text = "Search for wizard of oz:\r\n title:oz\r\n title:\"wizard of oz\""; // // label3 // + label3.Anchor = System.Windows.Forms.AnchorStyles.Top; label3.AutoSize = true; - label3.Location = new System.Drawing.Point(272, 82); + label3.Location = new System.Drawing.Point(4, 18); label3.Margin = new System.Windows.Forms.Padding(4, 0, 4, 0); label3.Name = "label3"; - label3.Size = new System.Drawing.Size(224, 135); + label3.Size = new System.Drawing.Size(218, 120); label3.TabIndex = 2; label3.Text = resources.GetString("label3.Text"); // // label4 // + label4.Anchor = System.Windows.Forms.AnchorStyles.Top; label4.AutoSize = true; - label4.Location = new System.Drawing.Point(530, 82); + label4.Location = new System.Drawing.Point(19, 18); label4.Margin = new System.Windows.Forms.Padding(4, 0, 4, 0); label4.Name = "label4"; - label4.Size = new System.Drawing.Size(187, 60); + label4.Size = new System.Drawing.Size(187, 30); label4.TabIndex = 3; - label4.Text = "BOOLEAN (TRUE/FALSE) FIELDS\r\n\r\nFind books that you haven't rated:\r\n -IsRated"; + label4.Text = "Find books that you haven't rated:\r\n -IsRated"; // // label5 // + label5.Anchor = System.Windows.Forms.AnchorStyles.Top; label5.AutoSize = true; - label5.Location = new System.Drawing.Point(785, 82); + label5.Location = new System.Drawing.Point(8, 18); label5.Margin = new System.Windows.Forms.Padding(4, 0, 4, 0); label5.Name = "label5"; - label5.Size = new System.Drawing.Size(278, 90); + label5.Size = new System.Drawing.Size(209, 90); label5.TabIndex = 4; - label5.Text = "ID FIELDS\r\n\r\nAlice's Adventures in Wonderland (ID: B015D78L0U)\r\n id:B015D78L0U\r\n\r\nAll of these are synonyms for the ID field"; + label5.Text = "Alice's Adventures in Wonderland (ID: B015D78L0U)\r\n id:B015D78L0U\r\n\r\nAll of these are synonyms for the ID field"; // - // closeBtn + // tableLayoutPanel1 // - closeBtn.Anchor = System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right; - closeBtn.DialogResult = System.Windows.Forms.DialogResult.Cancel; - closeBtn.Location = new System.Drawing.Point(1038, 537); - closeBtn.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); - closeBtn.Name = "closeBtn"; - closeBtn.Size = new System.Drawing.Size(88, 27); - closeBtn.TabIndex = 5; - closeBtn.Text = "Close"; - closeBtn.UseVisualStyleBackColor = true; - closeBtn.Click += CloseBtn_Click; + tableLayoutPanel1.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right; + tableLayoutPanel1.ColumnCount = 4; + tableLayoutPanel1.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 25F)); + tableLayoutPanel1.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 25F)); + tableLayoutPanel1.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 25F)); + tableLayoutPanel1.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 25F)); + tableLayoutPanel1.Controls.Add(tableLayoutPanel5, 3, 0); + tableLayoutPanel1.Controls.Add(tableLayoutPanel4, 2, 0); + tableLayoutPanel1.Controls.Add(tableLayoutPanel3, 1, 0); + tableLayoutPanel1.Controls.Add(tableLayoutPanel2, 0, 0); + tableLayoutPanel1.GrowStyle = System.Windows.Forms.TableLayoutPanelGrowStyle.FixedSize; + tableLayoutPanel1.Location = new System.Drawing.Point(12, 51); + tableLayoutPanel1.Name = "tableLayoutPanel1"; + tableLayoutPanel1.RowCount = 1; + tableLayoutPanel1.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 100F)); + tableLayoutPanel1.Size = new System.Drawing.Size(928, 425); + tableLayoutPanel1.TabIndex = 6; + // + // tableLayoutPanel5 + // + tableLayoutPanel5.ColumnCount = 1; + tableLayoutPanel5.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 100F)); + tableLayoutPanel5.Controls.Add(lboxIdFields, 0, 2); + tableLayoutPanel5.Controls.Add(label5, 0, 1); + tableLayoutPanel5.Controls.Add(label9, 0, 0); + tableLayoutPanel5.Dock = System.Windows.Forms.DockStyle.Fill; + tableLayoutPanel5.Location = new System.Drawing.Point(699, 3); + tableLayoutPanel5.Name = "tableLayoutPanel5"; + tableLayoutPanel5.RowCount = 3; + tableLayoutPanel5.RowStyles.Add(new System.Windows.Forms.RowStyle()); + tableLayoutPanel5.RowStyles.Add(new System.Windows.Forms.RowStyle()); + tableLayoutPanel5.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 100F)); + tableLayoutPanel5.Size = new System.Drawing.Size(226, 419); + tableLayoutPanel5.TabIndex = 10; + // + // lboxIdFields + // + lboxIdFields.Dock = System.Windows.Forms.DockStyle.Fill; + lboxIdFields.FormattingEnabled = true; + lboxIdFields.Location = new System.Drawing.Point(3, 111); + lboxIdFields.Name = "lboxIdFields"; + lboxIdFields.Size = new System.Drawing.Size(220, 305); + lboxIdFields.TabIndex = 0; + // + // label9 + // + label9.Anchor = System.Windows.Forms.AnchorStyles.Top; + label9.AutoSize = true; + label9.Font = new System.Drawing.Font("Segoe UI", 9F, System.Drawing.FontStyle.Bold | System.Drawing.FontStyle.Underline); + label9.Location = new System.Drawing.Point(86, 0); + label9.Margin = new System.Windows.Forms.Padding(3, 0, 3, 3); + label9.Name = "label9"; + label9.Size = new System.Drawing.Size(54, 15); + label9.TabIndex = 7; + label9.Text = "ID Fields"; + label9.TextAlign = System.Drawing.ContentAlignment.MiddleCenter; + // + // tableLayoutPanel4 + // + tableLayoutPanel4.ColumnCount = 1; + tableLayoutPanel4.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 100F)); + tableLayoutPanel4.Controls.Add(lboxBoolFields, 0, 2); + tableLayoutPanel4.Controls.Add(label4, 0, 1); + tableLayoutPanel4.Controls.Add(label8, 0, 0); + tableLayoutPanel4.Dock = System.Windows.Forms.DockStyle.Fill; + tableLayoutPanel4.Location = new System.Drawing.Point(467, 3); + tableLayoutPanel4.Name = "tableLayoutPanel4"; + tableLayoutPanel4.RowCount = 3; + tableLayoutPanel4.RowStyles.Add(new System.Windows.Forms.RowStyle()); + tableLayoutPanel4.RowStyles.Add(new System.Windows.Forms.RowStyle()); + tableLayoutPanel4.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 100F)); + tableLayoutPanel4.Size = new System.Drawing.Size(226, 419); + tableLayoutPanel4.TabIndex = 9; + // + // lboxBoolFields + // + lboxBoolFields.Dock = System.Windows.Forms.DockStyle.Fill; + lboxBoolFields.FormattingEnabled = true; + lboxBoolFields.Location = new System.Drawing.Point(3, 51); + lboxBoolFields.Name = "lboxBoolFields"; + lboxBoolFields.Size = new System.Drawing.Size(220, 365); + lboxBoolFields.TabIndex = 0; + // + // label8 + // + label8.Anchor = System.Windows.Forms.AnchorStyles.Top; + label8.AutoSize = true; + label8.Font = new System.Drawing.Font("Segoe UI", 9F, System.Drawing.FontStyle.Bold | System.Drawing.FontStyle.Underline); + label8.Location = new System.Drawing.Point(36, 0); + label8.Margin = new System.Windows.Forms.Padding(3, 0, 3, 3); + label8.Name = "label8"; + label8.Size = new System.Drawing.Size(154, 15); + label8.TabIndex = 7; + label8.Text = "Boolean (True/False) Fields"; + label8.TextAlign = System.Drawing.ContentAlignment.MiddleCenter; + // + // tableLayoutPanel3 + // + tableLayoutPanel3.ColumnCount = 1; + tableLayoutPanel3.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 100F)); + tableLayoutPanel3.Controls.Add(lboxNumberFields, 0, 2); + tableLayoutPanel3.Controls.Add(label3, 0, 1); + tableLayoutPanel3.Controls.Add(label7, 0, 0); + tableLayoutPanel3.Dock = System.Windows.Forms.DockStyle.Fill; + tableLayoutPanel3.Location = new System.Drawing.Point(235, 3); + tableLayoutPanel3.Name = "tableLayoutPanel3"; + tableLayoutPanel3.RowCount = 3; + tableLayoutPanel3.RowStyles.Add(new System.Windows.Forms.RowStyle()); + tableLayoutPanel3.RowStyles.Add(new System.Windows.Forms.RowStyle()); + tableLayoutPanel3.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 100F)); + tableLayoutPanel3.Size = new System.Drawing.Size(226, 419); + tableLayoutPanel3.TabIndex = 8; + // + // lboxNumberFields + // + lboxNumberFields.Dock = System.Windows.Forms.DockStyle.Fill; + lboxNumberFields.FormattingEnabled = true; + lboxNumberFields.Location = new System.Drawing.Point(3, 141); + lboxNumberFields.Name = "lboxNumberFields"; + lboxNumberFields.Size = new System.Drawing.Size(220, 275); + lboxNumberFields.TabIndex = 0; + // + // label7 + // + label7.Anchor = System.Windows.Forms.AnchorStyles.Top; + label7.AutoSize = true; + label7.Font = new System.Drawing.Font("Segoe UI", 9F, System.Drawing.FontStyle.Bold | System.Drawing.FontStyle.Underline); + label7.Location = new System.Drawing.Point(69, 0); + label7.Margin = new System.Windows.Forms.Padding(3, 0, 3, 3); + label7.Name = "label7"; + label7.Size = new System.Drawing.Size(87, 15); + label7.TabIndex = 7; + label7.Text = "Number Fields"; + label7.TextAlign = System.Drawing.ContentAlignment.MiddleCenter; + // + // tableLayoutPanel2 + // + tableLayoutPanel2.ColumnCount = 1; + tableLayoutPanel2.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 100F)); + tableLayoutPanel2.Controls.Add(lboxStringFields, 0, 2); + tableLayoutPanel2.Controls.Add(label2, 0, 1); + tableLayoutPanel2.Controls.Add(label6, 0, 0); + tableLayoutPanel2.Dock = System.Windows.Forms.DockStyle.Fill; + tableLayoutPanel2.Location = new System.Drawing.Point(3, 3); + tableLayoutPanel2.Name = "tableLayoutPanel2"; + tableLayoutPanel2.RowCount = 3; + tableLayoutPanel2.RowStyles.Add(new System.Windows.Forms.RowStyle()); + tableLayoutPanel2.RowStyles.Add(new System.Windows.Forms.RowStyle()); + tableLayoutPanel2.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 100F)); + tableLayoutPanel2.Size = new System.Drawing.Size(226, 419); + tableLayoutPanel2.TabIndex = 7; + // + // lboxStringFields + // + lboxStringFields.Dock = System.Windows.Forms.DockStyle.Fill; + lboxStringFields.FormattingEnabled = true; + lboxStringFields.Location = new System.Drawing.Point(3, 66); + lboxStringFields.Name = "lboxStringFields"; + lboxStringFields.Size = new System.Drawing.Size(220, 350); + lboxStringFields.TabIndex = 0; + // + // label6 + // + label6.Anchor = System.Windows.Forms.AnchorStyles.Top; + label6.AutoSize = true; + label6.Font = new System.Drawing.Font("Segoe UI", 9F, System.Drawing.FontStyle.Bold | System.Drawing.FontStyle.Underline); + label6.Location = new System.Drawing.Point(75, 0); + label6.Margin = new System.Windows.Forms.Padding(3, 0, 3, 3); + label6.Name = "label6"; + label6.Size = new System.Drawing.Size(75, 15); + label6.TabIndex = 0; + label6.Text = "String Fields"; + label6.TextAlign = System.Drawing.ContentAlignment.MiddleCenter; + // + // label10 + // + label10.AutoSize = true; + label10.Font = new System.Drawing.Font("Segoe UI", 9F, System.Drawing.FontStyle.Bold | System.Drawing.FontStyle.Underline); + label10.Location = new System.Drawing.Point(515, 25); + label10.Margin = new System.Windows.Forms.Padding(3, 8, 3, 8); + label10.Name = "label10"; + label10.Size = new System.Drawing.Size(72, 15); + label10.TabIndex = 7; + label10.Text = "Tag Format:"; + label10.TextAlign = System.Drawing.ContentAlignment.MiddleCenter; + // + // label11 + // + label11.AutoSize = true; + label11.Font = new System.Drawing.Font("Segoe UI", 9F); + label11.Location = new System.Drawing.Point(596, 25); + label11.Margin = new System.Windows.Forms.Padding(3, 8, 3, 8); + label11.Name = "label11"; + label11.Size = new System.Drawing.Size(64, 15); + label11.TabIndex = 8; + label11.Text = "[tagName]"; + label11.TextAlign = System.Drawing.ContentAlignment.MiddleCenter; // // SearchSyntaxDialog // - AcceptButton = closeBtn; AutoScaleDimensions = new System.Drawing.SizeF(96F, 96F); AutoScaleMode = System.Windows.Forms.AutoScaleMode.Dpi; - CancelButton = closeBtn; - ClientSize = new System.Drawing.Size(1140, 577); - Controls.Add(closeBtn); - Controls.Add(label5); - Controls.Add(label4); - Controls.Add(label3); - Controls.Add(label2); + ClientSize = new System.Drawing.Size(952, 488); + Controls.Add(label11); + Controls.Add(label10); + Controls.Add(tableLayoutPanel1); Controls.Add(label1); Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); MaximizeBox = false; @@ -119,6 +325,15 @@ Name = "SearchSyntaxDialog"; StartPosition = System.Windows.Forms.FormStartPosition.CenterParent; Text = "Filter options"; + tableLayoutPanel1.ResumeLayout(false); + tableLayoutPanel5.ResumeLayout(false); + tableLayoutPanel5.PerformLayout(); + tableLayoutPanel4.ResumeLayout(false); + tableLayoutPanel4.PerformLayout(); + tableLayoutPanel3.ResumeLayout(false); + tableLayoutPanel3.PerformLayout(); + tableLayoutPanel2.ResumeLayout(false); + tableLayoutPanel2.PerformLayout(); ResumeLayout(false); PerformLayout(); } @@ -130,6 +345,20 @@ private System.Windows.Forms.Label label3; private System.Windows.Forms.Label label4; private System.Windows.Forms.Label label5; - private System.Windows.Forms.Button closeBtn; + private System.Windows.Forms.TableLayoutPanel tableLayoutPanel1; + private System.Windows.Forms.Label label7; + private System.Windows.Forms.Label label6; + private System.Windows.Forms.Label label8; + private System.Windows.Forms.Label label9; + private System.Windows.Forms.TableLayoutPanel tableLayoutPanel5; + private System.Windows.Forms.ListBox lboxIdFields; + private System.Windows.Forms.TableLayoutPanel tableLayoutPanel4; + private System.Windows.Forms.ListBox lboxBoolFields; + private System.Windows.Forms.TableLayoutPanel tableLayoutPanel3; + private System.Windows.Forms.ListBox lboxNumberFields; + private System.Windows.Forms.TableLayoutPanel tableLayoutPanel2; + private System.Windows.Forms.ListBox lboxStringFields; + private System.Windows.Forms.Label label10; + private System.Windows.Forms.Label label11; } } \ No newline at end of file diff --git a/Source/LibationWinForms/Dialogs/SearchSyntaxDialog.cs b/Source/LibationWinForms/Dialogs/SearchSyntaxDialog.cs index f255f520..4eb81155 100644 --- a/Source/LibationWinForms/Dialogs/SearchSyntaxDialog.cs +++ b/Source/LibationWinForms/Dialogs/SearchSyntaxDialog.cs @@ -1,5 +1,5 @@ using LibationSearchEngine; -using System; +using System.ComponentModel; using System.Linq; using System.Windows.Forms; @@ -11,13 +11,17 @@ namespace LibationWinForms.Dialogs { InitializeComponent(); - label2.Text += "\r\n\r\n" + string.Join("\r\n", SearchEngine.FieldIndexRules.StringFieldNames); - label3.Text += "\r\n\r\n" + string.Join("\r\n", SearchEngine.FieldIndexRules.NumberFieldNames); - label4.Text += "\r\n\r\n" + string.Join("\r\n", SearchEngine.FieldIndexRules.BoolFieldNames); - label5.Text += "\r\n\r\n" + string.Join("\r\n", SearchEngine.FieldIndexRules.IdFieldNames); + lboxNumberFields.Items.AddRange(SearchEngine.FieldIndexRules.NumberFieldNames.ToArray()); + lboxStringFields.Items.AddRange(SearchEngine.FieldIndexRules.StringFieldNames.ToArray()); + lboxBoolFields.Items.AddRange(SearchEngine.FieldIndexRules.BoolFieldNames.ToArray()); + lboxIdFields.Items.AddRange(SearchEngine.FieldIndexRules.IdFieldNames.ToArray()); this.SetLibationIcon(); + this.RestoreSizeAndLocation(LibationFileManager.Configuration.Instance); + } + protected override void OnClosing(CancelEventArgs e) + { + base.OnClosing(e); + this.SaveSizeAndLocation(LibationFileManager.Configuration.Instance); } - - private void CloseBtn_Click(object sender, EventArgs e) => this.Close(); } } From 2f082a96568d3764aed40f11ce4de2ef7ed5166e Mon Sep 17 00:00:00 2001 From: Michael Bucari-Tovo Date: Tue, 22 Jul 2025 19:44:56 -0600 Subject: [PATCH 03/13] Refactor and optimize audiobook download and decrypt process - Add more null safety - Fix possible FilePathCache race condition - Add MoveFilesToBooksDir progress reporting - All metadata is now downloaded in parallel with other post-success tasks. - Improve download resuming and file cleanup reliability - The downloader creates temp files with a UUID filename and does not insert them into the FilePathCache. Created files only receive their final file names when they are moved into the Books folder. This is to prepare for a future plan re naming templates --- .../AaxDecrypter/AaxcDownloadConvertBase.cs | 42 +- .../AaxcDownloadMultiConverter.cs | 38 +- .../AaxcDownloadSingleConverter.cs | 31 +- Source/AaxDecrypter/AudiobookDownloadBase.cs | 157 ++--- Source/AaxDecrypter/IDownloadOptions.cs | 6 - Source/AaxDecrypter/NetworkFileStream.cs | 6 + Source/AaxDecrypter/TempFile.cs | 17 + .../UnencryptedAudiobookDownloader.cs | 13 +- Source/FileLiberator/AudioFileStorageExt.cs | 24 +- Source/FileLiberator/DownloadDecryptBook.cs | 652 +++++++++++------- Source/FileLiberator/DownloadOptions.cs | 41 -- Source/LibationFileManager/FilePathCache.cs | 16 +- 12 files changed, 562 insertions(+), 481 deletions(-) create mode 100644 Source/AaxDecrypter/TempFile.cs diff --git a/Source/AaxDecrypter/AaxcDownloadConvertBase.cs b/Source/AaxDecrypter/AaxcDownloadConvertBase.cs index fb33474f..f4ae4134 100644 --- a/Source/AaxDecrypter/AaxcDownloadConvertBase.cs +++ b/Source/AaxDecrypter/AaxcDownloadConvertBase.cs @@ -1,19 +1,21 @@ using AAXClean; using System; +using System.IO; using System.Linq; using System.Threading.Tasks; +#nullable enable namespace AaxDecrypter { public abstract class AaxcDownloadConvertBase : AudiobookDownloadBase { - public event EventHandler RetrievedMetadata; + public event EventHandler? RetrievedMetadata; - public Mp4File AaxFile { get; private set; } - protected Mp4Operation AaxConversion { get; set; } + public Mp4File? AaxFile { get; private set; } + protected Mp4Operation? AaxConversion { get; set; } - protected AaxcDownloadConvertBase(string outFileName, string cacheDirectory, IDownloadOptions dlOptions) - : base(outFileName, cacheDirectory, dlOptions) { } + protected AaxcDownloadConvertBase(string outDirectory, string cacheDirectory, IDownloadOptions dlOptions) + : base(outDirectory, cacheDirectory, dlOptions) { } /// Setting cover art by this method will insert the art into the audiobook metadata public override void SetCoverArt(byte[] coverArt) @@ -31,11 +33,13 @@ namespace AaxDecrypter private Mp4File Open() { - if (DownloadOptions.InputType is FileType.Dash) + if (DownloadOptions.DecryptionKeys is not KeyData[] keys || keys.Length == 0) + throw new InvalidOperationException($"{nameof(DownloadOptions.DecryptionKeys)} cannot be null or empty for a '{DownloadOptions.InputType}' file."); + else if (DownloadOptions.InputType is FileType.Dash) { //We may have multiple keys , so use the key whose key ID matches //the dash files default Key ID. - var keyIds = DownloadOptions.DecryptionKeys.Select(k => new Guid(k.KeyPart1, bigEndian: true)).ToArray(); + var keyIds = keys.Select(k => new Guid(k.KeyPart1, bigEndian: true)).ToArray(); var dash = new DashFile(InputFileStream); var kidIndex = Array.IndexOf(keyIds, dash.Tenc.DefaultKID); @@ -43,26 +47,38 @@ namespace AaxDecrypter if (kidIndex == -1) throw new InvalidOperationException($"None of the {keyIds.Length} key IDs match the dash file's default KeyID of {dash.Tenc.DefaultKID}"); - DownloadOptions.DecryptionKeys[0] = DownloadOptions.DecryptionKeys[kidIndex]; - var keyId = DownloadOptions.DecryptionKeys[kidIndex].KeyPart1; - var key = DownloadOptions.DecryptionKeys[kidIndex].KeyPart2; - + keys[0] = keys[kidIndex]; + var keyId = keys[kidIndex].KeyPart1; + var key = keys[kidIndex].KeyPart2 ?? throw new InvalidOperationException($"{nameof(DownloadOptions.DecryptionKeys)} for '{DownloadOptions.InputType}' must have a non-null decryption key (KeyPart2)."); dash.SetDecryptionKey(keyId, key); + WriteKeyFile($"KeyId={Convert.ToHexString(keyId)}{Environment.NewLine}Key={Convert.ToHexString(key)}"); return dash; } else if (DownloadOptions.InputType is FileType.Aax) { var aax = new AaxFile(InputFileStream); - aax.SetDecryptionKey(DownloadOptions.DecryptionKeys[0].KeyPart1); + var key = keys[0].KeyPart1; + aax.SetDecryptionKey(keys[0].KeyPart1); + WriteKeyFile($"ActivationBytes={Convert.ToHexString(key)}"); return aax; } else if (DownloadOptions.InputType is FileType.Aaxc) { var aax = new AaxFile(InputFileStream); - aax.SetDecryptionKey(DownloadOptions.DecryptionKeys[0].KeyPart1, DownloadOptions.DecryptionKeys[0].KeyPart2); + var key = keys[0].KeyPart1; + var iv = keys[0].KeyPart2 ?? throw new InvalidOperationException($"{nameof(DownloadOptions.DecryptionKeys)} for '{DownloadOptions.InputType}' must have a non-null initialization vector (KeyPart2)."); + aax.SetDecryptionKey(keys[0].KeyPart1, iv); + WriteKeyFile($"Key={Convert.ToHexString(key)}{Environment.NewLine}IV={Convert.ToHexString(iv)}"); return aax; } else throw new InvalidOperationException($"{nameof(DownloadOptions.InputType)} of '{DownloadOptions.InputType}' is unknown."); + + void WriteKeyFile(string contents) + { + var keyFile = Path.Combine(Path.ChangeExtension(InputFileStream.SaveFilePath, ".key")); + File.WriteAllText(keyFile, contents + Environment.NewLine); + OnTempFileCreated(new(keyFile)); + } } protected bool Step_GetMetadata() diff --git a/Source/AaxDecrypter/AaxcDownloadMultiConverter.cs b/Source/AaxDecrypter/AaxcDownloadMultiConverter.cs index 1948593e..bc21b0ea 100644 --- a/Source/AaxDecrypter/AaxcDownloadMultiConverter.cs +++ b/Source/AaxDecrypter/AaxcDownloadMultiConverter.cs @@ -5,20 +5,20 @@ using System; using System.IO; using System.Threading.Tasks; +#nullable enable namespace AaxDecrypter { public class AaxcDownloadMultiConverter : AaxcDownloadConvertBase { private static readonly TimeSpan minChapterLength = TimeSpan.FromSeconds(3); - private FileStream workingFileStream; + private FileStream? workingFileStream; - public AaxcDownloadMultiConverter(string outFileName, string cacheDirectory, IDownloadOptions dlOptions) - : base(outFileName, cacheDirectory, dlOptions) + public AaxcDownloadMultiConverter(string outDirectory, string cacheDirectory, IDownloadOptions dlOptions) + : base(outDirectory, cacheDirectory, dlOptions) { AsyncSteps.Name = $"Download, Convert Aaxc To {DownloadOptions.OutputFormat}, and Split"; AsyncSteps["Step 1: Get Aaxc Metadata"] = () => Task.Run(Step_GetMetadata); AsyncSteps["Step 2: Download Decrypted Audiobook"] = Step_DownloadAndDecryptAudiobookAsync; - AsyncSteps["Step 3: Download Clips and Bookmarks"] = Step_DownloadClipsBookmarksAsync; } protected override void OnInitialized() @@ -59,6 +59,7 @@ That naming may not be desirable for everyone, but it's an easy change to instea */ protected async override Task Step_DownloadAndDecryptAudiobookAsync() { + if (AaxFile is null) return false; var chapters = DownloadOptions.ChapterInfo.Chapters; // Ensure split files are at least minChapterLength in duration. @@ -83,10 +84,10 @@ That naming may not be desirable for everyone, but it's an easy change to instea try { - await (AaxConversion = decryptMultiAsync(splitChapters)); + await (AaxConversion = decryptMultiAsync(AaxFile, splitChapters)); if (AaxConversion.IsCompletedSuccessfully) - await moveMoovToBeginning(workingFileStream?.Name); + await moveMoovToBeginning(AaxFile, workingFileStream?.Name); return AaxConversion.IsCompletedSuccessfully; } @@ -97,17 +98,17 @@ That naming may not be desirable for everyone, but it's an easy change to instea } } - private Mp4Operation decryptMultiAsync(ChapterInfo splitChapters) + private Mp4Operation decryptMultiAsync(Mp4File aaxFile, ChapterInfo splitChapters) { var chapterCount = 0; return DownloadOptions.OutputFormat == OutputFormat.M4b - ? AaxFile.ConvertToMultiMp4aAsync + ? aaxFile.ConvertToMultiMp4aAsync ( splitChapters, newSplitCallback => newSplit(++chapterCount, splitChapters, newSplitCallback) ) - : AaxFile.ConvertToMultiMp3Async + : aaxFile.ConvertToMultiMp3Async ( splitChapters, newSplitCallback => newSplit(++chapterCount, splitChapters, newSplitCallback), @@ -116,33 +117,32 @@ That naming may not be desirable for everyone, but it's an easy change to instea void newSplit(int currentChapter, ChapterInfo splitChapters, INewSplitCallback newSplitCallback) { + moveMoovToBeginning(aaxFile, workingFileStream?.Name).GetAwaiter().GetResult(); + var newTempFile = GetNewTempFilePath(DownloadOptions.OutputFormat.ToString()); MultiConvertFileProperties props = new() { - OutputFileName = OutputFileName, + OutputFileName = newTempFile.FilePath, PartsPosition = currentChapter, PartsTotal = splitChapters.Count, - Title = newSplitCallback?.Chapter?.Title, + Title = newSplitCallback.Chapter?.Title, }; - moveMoovToBeginning(workingFileStream?.Name).GetAwaiter().GetResult(); - newSplitCallback.OutputFile = workingFileStream = createOutputFileStream(props); newSplitCallback.TrackTitle = DownloadOptions.GetMultipartTitle(props); newSplitCallback.TrackNumber = currentChapter; newSplitCallback.TrackCount = splitChapters.Count; - OnFileCreated(workingFileStream.Name); + OnTempFileCreated(newTempFile with { PartProperties = props }); } FileStream createOutputFileStream(MultiConvertFileProperties multiConvertFileProperties) { - var fileName = DownloadOptions.GetMultipartFileName(multiConvertFileProperties); - FileUtility.SaferDelete(fileName); - return File.Open(fileName, FileMode.OpenOrCreate, FileAccess.ReadWrite); + FileUtility.SaferDelete(multiConvertFileProperties.OutputFileName); + return File.Open(multiConvertFileProperties.OutputFileName, FileMode.OpenOrCreate, FileAccess.ReadWrite); } } - private Mp4Operation moveMoovToBeginning(string filename) + private Mp4Operation moveMoovToBeginning(Mp4File aaxFile, string? filename) { if (DownloadOptions.OutputFormat is OutputFormat.M4b && DownloadOptions.MoveMoovToBeginning @@ -151,7 +151,7 @@ That naming may not be desirable for everyone, but it's an easy change to instea { return Mp4File.RelocateMoovAsync(filename); } - else return Mp4Operation.FromCompleted(AaxFile); + else return Mp4Operation.FromCompleted(aaxFile); } } } diff --git a/Source/AaxDecrypter/AaxcDownloadSingleConverter.cs b/Source/AaxDecrypter/AaxcDownloadSingleConverter.cs index 153a6c8d..aa957746 100644 --- a/Source/AaxDecrypter/AaxcDownloadSingleConverter.cs +++ b/Source/AaxDecrypter/AaxcDownloadSingleConverter.cs @@ -6,13 +6,16 @@ using System; using System.IO; using System.Threading.Tasks; +#nullable enable namespace AaxDecrypter { public class AaxcDownloadSingleConverter : AaxcDownloadConvertBase { private readonly AverageSpeed averageSpeed = new(); - public AaxcDownloadSingleConverter(string outFileName, string cacheDirectory, IDownloadOptions dlOptions) - : base(outFileName, cacheDirectory, dlOptions) + private TempFile? outputTempFile; + + public AaxcDownloadSingleConverter(string outDirectory, string cacheDirectory, IDownloadOptions dlOptions) + : base(outDirectory, cacheDirectory, dlOptions) { var step = 1; @@ -21,7 +24,6 @@ namespace AaxDecrypter AsyncSteps[$"Step {step++}: Download Decrypted Audiobook"] = Step_DownloadAndDecryptAudiobookAsync; if (DownloadOptions.MoveMoovToBeginning && DownloadOptions.OutputFormat is OutputFormat.M4b) AsyncSteps[$"Step {step++}: Move moov atom to beginning"] = Step_MoveMoov; - AsyncSteps[$"Step {step++}: Download Clips and Bookmarks"] = Step_DownloadClipsBookmarksAsync; AsyncSteps[$"Step {step++}: Create Cue"] = Step_CreateCueAsync; } @@ -39,14 +41,16 @@ namespace AaxDecrypter protected async override Task Step_DownloadAndDecryptAudiobookAsync() { - FileUtility.SaferDelete(OutputFileName); + if (AaxFile is null) return false; + outputTempFile = GetNewTempFilePath(DownloadOptions.OutputFormat.ToString()); + FileUtility.SaferDelete(outputTempFile.FilePath); - using var outputFile = File.Open(OutputFileName, FileMode.OpenOrCreate, FileAccess.ReadWrite); - OnFileCreated(OutputFileName); + using var outputFile = File.Open(outputTempFile.FilePath, FileMode.OpenOrCreate, FileAccess.ReadWrite); + OnTempFileCreated(outputTempFile); try { - await (AaxConversion = decryptAsync(outputFile)); + await (AaxConversion = decryptAsync(AaxFile, outputFile)); return AaxConversion.IsCompletedSuccessfully; } @@ -58,14 +62,15 @@ namespace AaxDecrypter private async Task Step_MoveMoov() { - AaxConversion = Mp4File.RelocateMoovAsync(OutputFileName); + if (outputTempFile is null) return false; + AaxConversion = Mp4File.RelocateMoovAsync(outputTempFile.FilePath); AaxConversion.ConversionProgressUpdate += AaxConversion_MoovProgressUpdate; await AaxConversion; AaxConversion.ConversionProgressUpdate -= AaxConversion_MoovProgressUpdate; return AaxConversion.IsCompletedSuccessfully; } - private void AaxConversion_MoovProgressUpdate(object sender, ConversionProgressEventArgs e) + private void AaxConversion_MoovProgressUpdate(object? sender, ConversionProgressEventArgs e) { averageSpeed.AddPosition(e.ProcessPosition.TotalSeconds); @@ -84,20 +89,20 @@ namespace AaxDecrypter }); } - private Mp4Operation decryptAsync(Stream outputFile) + private Mp4Operation decryptAsync(Mp4File aaxFile, Stream outputFile) => DownloadOptions.OutputFormat == OutputFormat.Mp3 - ? AaxFile.ConvertToMp3Async + ? aaxFile.ConvertToMp3Async ( outputFile, DownloadOptions.LameConfig, DownloadOptions.ChapterInfo ) : DownloadOptions.FixupFile - ? AaxFile.ConvertToMp4aAsync + ? aaxFile.ConvertToMp4aAsync ( outputFile, DownloadOptions.ChapterInfo ) - : AaxFile.ConvertToMp4aAsync(outputFile); + : aaxFile.ConvertToMp4aAsync(outputFile); } } diff --git a/Source/AaxDecrypter/AudiobookDownloadBase.cs b/Source/AaxDecrypter/AudiobookDownloadBase.cs index c5389d29..f439955e 100644 --- a/Source/AaxDecrypter/AudiobookDownloadBase.cs +++ b/Source/AaxDecrypter/AudiobookDownloadBase.cs @@ -6,55 +6,50 @@ using System; using System.IO; using System.Threading.Tasks; +#nullable enable namespace AaxDecrypter { public enum OutputFormat { M4b, Mp3 } public abstract class AudiobookDownloadBase { - public event EventHandler RetrievedTitle; - public event EventHandler RetrievedAuthors; - public event EventHandler RetrievedNarrators; - public event EventHandler RetrievedCoverArt; - public event EventHandler DecryptProgressUpdate; - public event EventHandler DecryptTimeRemaining; - public event EventHandler FileCreated; + public event EventHandler? RetrievedTitle; + public event EventHandler? RetrievedAuthors; + public event EventHandler? RetrievedNarrators; + public event EventHandler? RetrievedCoverArt; + public event EventHandler? DecryptProgressUpdate; + public event EventHandler? DecryptTimeRemaining; + public event EventHandler? TempFileCreated; public bool IsCanceled { get; protected set; } protected AsyncStepSequence AsyncSteps { get; } = new(); - protected string OutputFileName { get; } + protected string OutputDirectory { get; } public IDownloadOptions DownloadOptions { get; } - protected NetworkFileStream InputFileStream => nfsPersister.NetworkFileStream; + protected NetworkFileStream InputFileStream => NfsPersister.NetworkFileStream; protected virtual long InputFilePosition => InputFileStream.Position; private bool downloadFinished; - private readonly NetworkFileStreamPersister nfsPersister; + private NetworkFileStreamPersister? m_nfsPersister; + private NetworkFileStreamPersister NfsPersister => m_nfsPersister ??= OpenNetworkFileStream(); private readonly DownloadProgress zeroProgress; private readonly string jsonDownloadState; private readonly string tempFilePath; - protected AudiobookDownloadBase(string outFileName, string cacheDirectory, IDownloadOptions dlOptions) - { - OutputFileName = ArgumentValidator.EnsureNotNullOrWhiteSpace(outFileName, nameof(outFileName)); + protected AudiobookDownloadBase(string outDirectory, string cacheDirectory, IDownloadOptions dlOptions) + { + OutputDirectory = ArgumentValidator.EnsureNotNullOrWhiteSpace(outDirectory, nameof(outDirectory)); + DownloadOptions = ArgumentValidator.EnsureNotNull(dlOptions, nameof(dlOptions)); + DownloadOptions.DownloadSpeedChanged += (_, speed) => InputFileStream.SpeedLimit = speed; - var outDir = Path.GetDirectoryName(OutputFileName); - if (!Directory.Exists(outDir)) - Directory.CreateDirectory(outDir); + if (!Directory.Exists(OutputDirectory)) + Directory.CreateDirectory(OutputDirectory); if (!Directory.Exists(cacheDirectory)) Directory.CreateDirectory(cacheDirectory); - jsonDownloadState = Path.Combine(cacheDirectory, Path.GetFileName(Path.ChangeExtension(OutputFileName, ".json"))); + jsonDownloadState = Path.Combine(cacheDirectory, $"{DownloadOptions.AudibleProductId}.json"); tempFilePath = Path.ChangeExtension(jsonDownloadState, ".aaxc"); - DownloadOptions = ArgumentValidator.EnsureNotNull(dlOptions, nameof(dlOptions)); - DownloadOptions.DownloadSpeedChanged += (_, speed) => InputFileStream.SpeedLimit = speed; - - // delete file after validation is complete - FileUtility.SaferDelete(OutputFileName); - - nfsPersister = OpenNetworkFileStream(); - zeroProgress = new DownloadProgress { BytesReceived = 0, @@ -65,24 +60,30 @@ namespace AaxDecrypter OnDecryptProgressUpdate(zeroProgress); } + protected TempFile GetNewTempFilePath(string extension) + { + extension = FileUtility.GetStandardizedExtension(extension); + var path = Path.Combine(OutputDirectory, Guid.NewGuid().ToString("N") + extension); + return new(path, extension); + } + public async Task RunAsync() { await InputFileStream.BeginDownloadingAsync(); var progressTask = Task.Run(reportProgress); - AsyncSteps[$"Cleanup"] = CleanupAsync; (bool success, var elapsed) = await AsyncSteps.RunAsync(); //Stop the downloader so it doesn't keep running in the background. if (!success) - nfsPersister.Dispose(); + NfsPersister.Dispose(); await progressTask; var speedup = DownloadOptions.RuntimeLength / elapsed; Serilog.Log.Information($"Speedup is {speedup:F0}x realtime."); - nfsPersister.Dispose(); + NfsPersister.Dispose(); return success; async Task reportProgress() @@ -129,50 +130,43 @@ namespace AaxDecrypter protected abstract Task Step_DownloadAndDecryptAudiobookAsync(); public virtual void SetCoverArt(byte[] coverArt) { } - - protected void OnRetrievedTitle(string title) + protected void OnRetrievedTitle(string? title) => RetrievedTitle?.Invoke(this, title); - protected void OnRetrievedAuthors(string authors) + protected void OnRetrievedAuthors(string? authors) => RetrievedAuthors?.Invoke(this, authors); - protected void OnRetrievedNarrators(string narrators) + protected void OnRetrievedNarrators(string? narrators) => RetrievedNarrators?.Invoke(this, narrators); - protected void OnRetrievedCoverArt(byte[] coverArt) + protected void OnRetrievedCoverArt(byte[]? coverArt) => RetrievedCoverArt?.Invoke(this, coverArt); protected void OnDecryptProgressUpdate(DownloadProgress downloadProgress) => DecryptProgressUpdate?.Invoke(this, downloadProgress); protected void OnDecryptTimeRemaining(TimeSpan timeRemaining) => DecryptTimeRemaining?.Invoke(this, timeRemaining); - protected void OnFileCreated(string path) - => FileCreated?.Invoke(this, path); + public void OnTempFileCreated(TempFile path) + => TempFileCreated?.Invoke(this, path); protected virtual void FinalizeDownload() { - nfsPersister?.Dispose(); + NfsPersister.Dispose(); downloadFinished = true; } - protected async Task Step_DownloadClipsBookmarksAsync() - { - if (!IsCanceled && DownloadOptions.DownloadClipsBookmarks) - { - var recordsFile = await DownloadOptions.SaveClipsAndBookmarksAsync(OutputFileName); - - if (File.Exists(recordsFile)) - OnFileCreated(recordsFile); - } - return !IsCanceled; - } - protected async Task Step_CreateCueAsync() { if (!DownloadOptions.CreateCueSheet) return !IsCanceled; + if (DownloadOptions.ChapterInfo.Count <= 1) + { + Serilog.Log.Logger.Information($"Skipped creating .cue because book has no chapters."); + return !IsCanceled; + } + // not a critical step. its failure should not prevent future steps from running try { - var path = Path.ChangeExtension(OutputFileName, ".cue"); - await File.WriteAllTextAsync(path, Cue.CreateContents(Path.GetFileName(OutputFileName), DownloadOptions.ChapterInfo)); - OnFileCreated(path); + var tempFile = GetNewTempFilePath(".cue"); + await File.WriteAllTextAsync(tempFile.FilePath, Cue.CreateContents(Path.GetFileName(tempFile.FilePath), DownloadOptions.ChapterInfo)); + OnTempFileCreated(tempFile); } catch (Exception ex) { @@ -181,58 +175,9 @@ namespace AaxDecrypter return !IsCanceled; } - private async Task CleanupAsync() - { - if (IsCanceled) return false; - - FileUtility.SaferDelete(jsonDownloadState); - - if (DownloadOptions.DecryptionKeys != null && - DownloadOptions.RetainEncryptedFile && - DownloadOptions.InputType is AAXClean.FileType fileType) - { - //Write aax decryption key - string keyPath = Path.ChangeExtension(tempFilePath, ".key"); - FileUtility.SaferDelete(keyPath); - string aaxPath; - - if (fileType is AAXClean.FileType.Aax) - { - await File.WriteAllTextAsync(keyPath, $"ActivationBytes={Convert.ToHexString(DownloadOptions.DecryptionKeys[0].KeyPart1)}"); - aaxPath = Path.ChangeExtension(tempFilePath, ".aax"); - } - else if (fileType is AAXClean.FileType.Aaxc) - { - await File.WriteAllTextAsync(keyPath, - $"Key={Convert.ToHexString(DownloadOptions.DecryptionKeys[0].KeyPart1)}{Environment.NewLine}" + - $"IV={Convert.ToHexString(DownloadOptions.DecryptionKeys[0].KeyPart2)}"); - aaxPath = Path.ChangeExtension(tempFilePath, ".aaxc"); - } - else if (fileType is AAXClean.FileType.Dash) - { - await File.WriteAllTextAsync(keyPath, - $"KeyId={Convert.ToHexString(DownloadOptions.DecryptionKeys[0].KeyPart1)}{Environment.NewLine}" + - $"Key={Convert.ToHexString(DownloadOptions.DecryptionKeys[0].KeyPart2)}"); - aaxPath = Path.ChangeExtension(tempFilePath, ".dash"); - } - else - throw new InvalidOperationException($"Unknown file type: {fileType}"); - - if (tempFilePath != aaxPath) - FileUtility.SaferMove(tempFilePath, aaxPath); - - OnFileCreated(aaxPath); - OnFileCreated(keyPath); - } - else - FileUtility.SaferDelete(tempFilePath); - - return !IsCanceled; - } - private NetworkFileStreamPersister OpenNetworkFileStream() { - NetworkFileStreamPersister nfsp = default; + NetworkFileStreamPersister? nfsp = default; try { if (!File.Exists(jsonDownloadState)) @@ -253,8 +198,14 @@ namespace AaxDecrypter } finally { - nfsp.NetworkFileStream.RequestHeaders["User-Agent"] = DownloadOptions.UserAgent; - nfsp.NetworkFileStream.SpeedLimit = DownloadOptions.DownloadSpeedBps; + //nfsp will only be null when an unhandled exception occurs. Let the caller handle it. + if (nfsp is not null) + { + nfsp.NetworkFileStream.RequestHeaders["User-Agent"] = DownloadOptions.UserAgent; + nfsp.NetworkFileStream.SpeedLimit = DownloadOptions.DownloadSpeedBps; + OnTempFileCreated(new(tempFilePath, DownloadOptions.InputType.ToString())); + OnTempFileCreated(new(jsonDownloadState)); + } } NetworkFileStreamPersister newNetworkFilePersister() diff --git a/Source/AaxDecrypter/IDownloadOptions.cs b/Source/AaxDecrypter/IDownloadOptions.cs index fb22350e..d80a5b95 100644 --- a/Source/AaxDecrypter/IDownloadOptions.cs +++ b/Source/AaxDecrypter/IDownloadOptions.cs @@ -1,6 +1,5 @@ using AAXClean; using System; -using System.Threading.Tasks; #nullable enable namespace AaxDecrypter @@ -33,11 +32,8 @@ namespace AaxDecrypter KeyData[]? DecryptionKeys { get; } TimeSpan RuntimeLength { get; } OutputFormat OutputFormat { get; } - bool TrimOutputToChapterLength { get; } - bool RetainEncryptedFile { get; } bool StripUnabridged { get; } bool CreateCueSheet { get; } - bool DownloadClipsBookmarks { get; } long DownloadSpeedBps { get; } ChapterInfo ChapterInfo { get; } bool FixupFile { get; } @@ -52,9 +48,7 @@ namespace AaxDecrypter bool Downsample { get; } bool MatchSourceBitrate { get; } bool MoveMoovToBeginning { get; } - string GetMultipartFileName(MultiConvertFileProperties props); string GetMultipartTitle(MultiConvertFileProperties props); - Task SaveClipsAndBookmarksAsync(string fileName); public FileType? InputType { get; } } } diff --git a/Source/AaxDecrypter/NetworkFileStream.cs b/Source/AaxDecrypter/NetworkFileStream.cs index 1be60545..769a76fc 100644 --- a/Source/AaxDecrypter/NetworkFileStream.cs +++ b/Source/AaxDecrypter/NetworkFileStream.cs @@ -100,6 +100,12 @@ namespace AaxDecrypter Position = WritePosition }; + if (_writeFile.Length < WritePosition) + { + _writeFile.Dispose(); + throw new InvalidDataException($"{SaveFilePath} file length is shorter than {WritePosition}"); + } + _readFile = new FileStream(SaveFilePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); SetUriForSameFile(uri); diff --git a/Source/AaxDecrypter/TempFile.cs b/Source/AaxDecrypter/TempFile.cs new file mode 100644 index 00000000..f6b37ee4 --- /dev/null +++ b/Source/AaxDecrypter/TempFile.cs @@ -0,0 +1,17 @@ +using FileManager; + +#nullable enable +namespace AaxDecrypter; + +public record TempFile +{ + public LongPath FilePath { get; init; } + public string Extension { get; } + public MultiConvertFileProperties? PartProperties { get; init; } + public TempFile(LongPath filePath, string? extension = null) + { + FilePath = filePath; + extension ??= System.IO.Path.GetExtension(filePath); + Extension = FileUtility.GetStandardizedExtension(extension).ToLowerInvariant(); + } +} diff --git a/Source/AaxDecrypter/UnencryptedAudiobookDownloader.cs b/Source/AaxDecrypter/UnencryptedAudiobookDownloader.cs index 18c57054..1c6f09e8 100644 --- a/Source/AaxDecrypter/UnencryptedAudiobookDownloader.cs +++ b/Source/AaxDecrypter/UnencryptedAudiobookDownloader.cs @@ -1,5 +1,4 @@ using FileManager; -using System; using System.Threading.Tasks; namespace AaxDecrypter @@ -8,13 +7,12 @@ namespace AaxDecrypter { protected override long InputFilePosition => InputFileStream.WritePosition; - public UnencryptedAudiobookDownloader(string outFileName, string cacheDirectory, IDownloadOptions dlLic) - : base(outFileName, cacheDirectory, dlLic) + public UnencryptedAudiobookDownloader(string outDirectory, string cacheDirectory, IDownloadOptions dlLic) + : base(outDirectory, cacheDirectory, dlLic) { AsyncSteps.Name = "Download Unencrypted Audiobook"; AsyncSteps["Step 1: Download Audiobook"] = Step_DownloadAndDecryptAudiobookAsync; - AsyncSteps["Step 2: Download Clips and Bookmarks"] = Step_DownloadClipsBookmarksAsync; - AsyncSteps["Step 3: Create Cue"] = Step_CreateCueAsync; + AsyncSteps["Step 2: Create Cue"] = Step_CreateCueAsync; } protected override async Task Step_DownloadAndDecryptAudiobookAsync() @@ -26,8 +24,9 @@ namespace AaxDecrypter else { FinalizeDownload(); - FileUtility.SaferMove(InputFileStream.SaveFilePath, OutputFileName); - OnFileCreated(OutputFileName); + var tempFile = GetNewTempFilePath(DownloadOptions.OutputFormat.ToString()); + FileUtility.SaferMove(InputFileStream.SaveFilePath, tempFile.FilePath); + OnTempFileCreated(tempFile); return true; } } diff --git a/Source/FileLiberator/AudioFileStorageExt.cs b/Source/FileLiberator/AudioFileStorageExt.cs index c0ce9bd0..90ab45b1 100644 --- a/Source/FileLiberator/AudioFileStorageExt.cs +++ b/Source/FileLiberator/AudioFileStorageExt.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using AaxDecrypter; using DataLayer; using LibationFileManager; using LibationFileManager.Templates; @@ -34,30 +35,17 @@ namespace FileLiberator return Templates.Folder.GetFilename(libraryBook.ToDto(), AudibleFileStorage.BooksDirectory, ""); } - /// - /// DownloadDecryptBook: - /// Path: in progress directory. - /// File name: final file name. - /// - public static string GetInProgressFilename(this AudioFileStorage _, LibraryBookDto libraryBook, string extension) - => Templates.File.GetFilename(libraryBook, AudibleFileStorage.DecryptInProgressDirectory, extension, returnFirstExisting: true); - /// /// PDF: audio file does not exist /// - public static string GetBooksDirectoryFilename(this AudioFileStorage _, LibraryBook libraryBook, string extension) - => Templates.File.GetFilename(libraryBook.ToDto(), AudibleFileStorage.BooksDirectory, extension); - - /// - /// PDF: audio file does not exist - /// - public static string GetBooksDirectoryFilename(this AudioFileStorage _, LibraryBookDto dto, string extension) - => Templates.File.GetFilename(dto, AudibleFileStorage.BooksDirectory, extension); + public static string GetBooksDirectoryFilename(this AudioFileStorage _, LibraryBook libraryBook, string extension, bool returnFirstExisting = false) + => Templates.File.GetFilename(libraryBook.ToDto(), AudibleFileStorage.BooksDirectory, extension, returnFirstExisting: returnFirstExisting); /// /// PDF: audio file already exists /// - public static string GetCustomDirFilename(this AudioFileStorage _, LibraryBook libraryBook, string dirFullPath, string extension) - => Templates.File.GetFilename(libraryBook.ToDto(), dirFullPath, extension); + public static string GetCustomDirFilename(this AudioFileStorage _, LibraryBook libraryBook, string dirFullPath, string extension, MultiConvertFileProperties partProperties = null, bool returnFirstExisting = false) + => partProperties is null ? Templates.File.GetFilename(libraryBook.ToDto(), dirFullPath, extension, returnFirstExisting: returnFirstExisting) + : Templates.ChapterFile.GetFilename(libraryBook.ToDto(), partProperties, dirFullPath, extension, returnFirstExisting: returnFirstExisting); } } diff --git a/Source/FileLiberator/DownloadDecryptBook.cs b/Source/FileLiberator/DownloadDecryptBook.cs index 26ffb9b9..8bdae9d0 100644 --- a/Source/FileLiberator/DownloadDecryptBook.cs +++ b/Source/FileLiberator/DownloadDecryptBook.cs @@ -1,116 +1,99 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using AaxDecrypter; +using AaxDecrypter; using ApplicationServices; using AudibleApi.Common; using DataLayer; using Dinah.Core; using Dinah.Core.ErrorHandling; +using Dinah.Core.Net.Http; using FileManager; using LibationFileManager; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +#nullable enable namespace FileLiberator { - public class DownloadDecryptBook : AudioDecodable - { - public override string Name => "Download & Decrypt"; - private AudiobookDownloadBase abDownloader; - private readonly CancellationTokenSource cancellationTokenSource = new(); + public class DownloadDecryptBook : AudioDecodable + { + public override string Name => "Download & Decrypt"; + private CancellationTokenSource? cancellationTokenSource; + private AudiobookDownloadBase? abDownloader; public override bool Validate(LibraryBook libraryBook) => !libraryBook.Book.Audio_Exists(); - public override async Task CancelAsync() - { - cancellationTokenSource.Cancel(); - if (abDownloader is not null) - await abDownloader.CancelAsync(); + public override async Task CancelAsync() + { + if (abDownloader is not null) await abDownloader.CancelAsync(); + if (cancellationTokenSource is not null) await cancellationTokenSource.CancelAsync(); } - public override async Task ProcessAsync(LibraryBook libraryBook) - { - var entries = new List(); - // these only work so minimally b/c CacheEntry is a record. - // in case of parallel decrypts, only capture the ones for this book id. - // if user somehow starts multiple decrypts of the same book in parallel: on their own head be it - void FilePathCache_Inserted(object sender, FilePathCache.CacheEntry e) - { - if (e.Id.EqualsInsensitive(libraryBook.Book.AudibleProductId)) - entries.Add(e); - } - void FilePathCache_Removed(object sender, FilePathCache.CacheEntry e) - { - if (e.Id.EqualsInsensitive(libraryBook.Book.AudibleProductId)) - entries.Remove(e); - } - - OnBegin(libraryBook); - var cancellationToken = cancellationTokenSource.Token; + public override async Task ProcessAsync(LibraryBook libraryBook) + { + OnBegin(libraryBook); + cancellationTokenSource = new CancellationTokenSource(); + var cancellationToken = cancellationTokenSource.Token; try - { - if (libraryBook.Book.Audio_Exists()) - return new StatusHandler { "Cannot find decrypt. Final audio file already exists" }; + { + if (libraryBook.Book.Audio_Exists()) + return new StatusHandler { "Cannot find decrypt. Final audio file already exists" }; + + DownloadValidation(libraryBook); - downloadValidation(libraryBook); var api = await libraryBook.GetApiAsync(); - var config = Configuration.Instance; - using var downloadOptions = await DownloadOptions.InitiateDownloadAsync(api, config, libraryBook, cancellationToken); - - bool success = false; - try - { - FilePathCache.Inserted += FilePathCache_Inserted; - FilePathCache.Removed += FilePathCache_Removed; - - success = await downloadAudiobookAsync(api, config, downloadOptions); - } - finally - { - FilePathCache.Inserted -= FilePathCache_Inserted; - FilePathCache.Removed -= FilePathCache_Removed; - } - - // decrypt failed - if (!success || getFirstAudioFile(entries) == default) - { - await Task.WhenAll( - entries - .Where(f => f.FileType != FileType.AAXC) - .Select(f => Task.Run(() => FileUtility.SaferDelete(f.Path)))); + using var downloadOptions = await DownloadOptions.InitiateDownloadAsync(api, Configuration.Instance, libraryBook, cancellationToken); + var result = await DownloadAudiobookAsync(api, downloadOptions, cancellationToken); + if (!result.Success || getFirstAudioFile(result.ResultFiles) == default) + { + // decrypt failed. Delete all output entries but leave the cache files. + result.ResultFiles.ForEach(f => FileUtility.SaferDelete(f.FilePath)); cancellationToken.ThrowIfCancellationRequested(); return new StatusHandler { "Decrypt failed" }; - } + } - var finalStorageDir = getDestinationDirectory(libraryBook); + if (Configuration.Instance.RetainAaxFile) + { + //Add the cached aaxc and key files to the entries list to be moved to the Books directory. + result.ResultFiles.AddRange(getAaxcFiles(result.CacheFiles)); + } - var moveFilesTask = Task.Run(() => moveFilesToBooksDir(libraryBook, entries, cancellationToken)); - Task[] finalTasks = - [ - Task.Run(() => downloadCoverArt(downloadOptions, cancellationToken)), - moveFilesTask, - Task.Run(() => WindowsDirectory.SetCoverAsFolderIcon(libraryBook.Book.PictureId, finalStorageDir, cancellationToken)) - ]; + var finalStorageDir = getDestinationDirectory(libraryBook); + + //post-download tasks done in parallel. + var moveFilesTask = Task.Run(() => MoveFilesToBooksDir(libraryBook, finalStorageDir, result.ResultFiles, cancellationToken)); + Task[] finalTasks = + [ + moveFilesTask, + Task.Run(() => DownloadCoverArt(finalStorageDir, downloadOptions, cancellationToken)), + Task.Run(() => DownloadRecordsAsync(api, finalStorageDir, downloadOptions, cancellationToken)), + Task.Run(() => DownloadMetadataAsync(api, finalStorageDir, downloadOptions, cancellationToken)), + Task.Run(() => WindowsDirectory.SetCoverAsFolderIcon(libraryBook.Book.PictureId, finalStorageDir, cancellationToken)) + ]; try - { + { await Task.WhenAll(finalTasks); } - catch when (!moveFilesTask.IsFaulted) + catch when (!moveFilesTask.IsFaulted) { - //Swallow downloadCoverArt and SetCoverAsFolderIcon exceptions. + //Swallow DownloadCoverArt, SetCoverAsFolderIcon, and SaveMetadataAsync exceptions. //Only fail if the downloaded audio files failed to move to Books directory } finally - { - if (moveFilesTask.IsCompletedSuccessfully && !cancellationToken.IsCancellationRequested) - { - await Task.Run(() => libraryBook.UpdateBookStatus(LiberatedStatus.Liberated, Configuration.LibationVersion)); - + { + if (moveFilesTask.IsCompletedSuccessfully && !cancellationToken.IsCancellationRequested) + { + libraryBook.UpdateBookStatus(LiberatedStatus.Liberated, Configuration.LibationVersion!); SetDirectoryTime(libraryBook, finalStorageDir); + foreach (var cacheFile in result.CacheFiles.Where(f => File.Exists(f.FilePath))) + { + //Delete cache files only after the download/decrypt operation completes successfully. + FileUtility.SaferDelete(cacheFile.FilePath); + } } } @@ -122,59 +105,86 @@ namespace FileLiberator return new StatusHandler { "Cancelled" }; } finally - { - OnCompleted(libraryBook); - } - } - - private async Task downloadAudiobookAsync(AudibleApi.Api api, Configuration config, DownloadOptions dlOptions) - { - var outFileName = AudibleFileStorage.Audio.GetInProgressFilename(dlOptions.LibraryBookDto, dlOptions.OutputFormat.ToString().ToLower()); - var cacheDir = AudibleFileStorage.DownloadsInProgressDirectory; - - if (dlOptions.DrmType is not DrmType.Adrm and not DrmType.Widevine) - abDownloader = new UnencryptedAudiobookDownloader(outFileName, cacheDir, dlOptions); - else - { - AaxcDownloadConvertBase converter - = config.SplitFilesByChapter ? - new AaxcDownloadMultiConverter(outFileName, cacheDir, dlOptions) : - new AaxcDownloadSingleConverter(outFileName, cacheDir, dlOptions); - - if (config.AllowLibationFixup) - converter.RetrievedMetadata += Converter_RetrievedMetadata; - - abDownloader = converter; - } - - abDownloader.DecryptProgressUpdate += OnStreamingProgressChanged; - abDownloader.DecryptTimeRemaining += OnStreamingTimeRemaining; - abDownloader.RetrievedTitle += OnTitleDiscovered; - abDownloader.RetrievedAuthors += OnAuthorsDiscovered; - abDownloader.RetrievedNarrators += OnNarratorsDiscovered; - abDownloader.RetrievedCoverArt += AaxcDownloader_RetrievedCoverArt; - abDownloader.FileCreated += (_, path) => OnFileCreated(dlOptions.LibraryBook, path); - - // REAL WORK DONE HERE - var success = await abDownloader.RunAsync(); - - if (success && config.SaveMetadataToFile) - { - var metadataFile = LibationFileManager.Templates.Templates.File.GetFilename(dlOptions.LibraryBookDto, Path.GetDirectoryName(outFileName), ".metadata.json"); - - var item = await api.GetCatalogProductAsync(dlOptions.LibraryBook.Book.AudibleProductId, AudibleApi.CatalogOptions.ResponseGroupOptions.ALL_OPTIONS); - item.SourceJson.Add(nameof(ContentMetadata.ChapterInfo), Newtonsoft.Json.Linq.JObject.FromObject(dlOptions.ContentMetadata.ChapterInfo)); - item.SourceJson.Add(nameof(ContentMetadata.ContentReference), Newtonsoft.Json.Linq.JObject.FromObject(dlOptions.ContentMetadata.ContentReference)); - - File.WriteAllText(metadataFile, item.SourceJson.ToString()); - OnFileCreated(dlOptions.LibraryBook, metadataFile); - } - return success; + { + OnCompleted(libraryBook); + cancellationTokenSource.Dispose(); + cancellationTokenSource = null; + } } - private void Converter_RetrievedMetadata(object sender, AAXClean.AppleTags tags) + private record AudiobookDecryptResult(bool Success, List ResultFiles, List CacheFiles); + + private async Task DownloadAudiobookAsync(AudibleApi.Api api, DownloadOptions dlOptions, CancellationToken cancellationToken) { - if (sender is not AaxcDownloadConvertBase converter || converter.DownloadOptions is not DownloadOptions options) + var outpoutDir = AudibleFileStorage.DecryptInProgressDirectory; + var cacheDir = AudibleFileStorage.DownloadsInProgressDirectory; + var result = new AudiobookDecryptResult(false, [], []); + + try + { + if (dlOptions.DrmType is not DrmType.Adrm and not DrmType.Widevine) + abDownloader = new UnencryptedAudiobookDownloader(outpoutDir, cacheDir, dlOptions); + else + { + AaxcDownloadConvertBase converter + = dlOptions.Config.SplitFilesByChapter ? + new AaxcDownloadMultiConverter(outpoutDir, cacheDir, dlOptions) : + new AaxcDownloadSingleConverter(outpoutDir, cacheDir, dlOptions); + + if (dlOptions.Config.AllowLibationFixup) + converter.RetrievedMetadata += Converter_RetrievedMetadata; + + abDownloader = converter; + } + + abDownloader.DecryptProgressUpdate += OnStreamingProgressChanged; + abDownloader.DecryptTimeRemaining += OnStreamingTimeRemaining; + abDownloader.RetrievedTitle += OnTitleDiscovered; + abDownloader.RetrievedAuthors += OnAuthorsDiscovered; + abDownloader.RetrievedNarrators += OnNarratorsDiscovered; + abDownloader.RetrievedCoverArt += AaxcDownloader_RetrievedCoverArt; + abDownloader.TempFileCreated += AbDownloader_TempFileCreated; + + // REAL WORK DONE HERE + bool success = await abDownloader.RunAsync(); + return result with { Success = success }; + } + catch (Exception ex) + { + if (!cancellationToken.IsCancellationRequested) + Serilog.Log.Logger.Error(ex, "Error downloading audiobook {@Book}", dlOptions.LibraryBook.LogFriendly()); + //don't throw any exceptions so the caller can delete any temp files. + return result; + } + finally + { + OnStreamingProgressChanged(new() { ProgressPercentage = 100 }); + } + + void AbDownloader_TempFileCreated(object? sender, TempFile e) + { + if (Path.GetDirectoryName(e.FilePath) == outpoutDir) + { + result.ResultFiles.Add(e); + } + else if (Path.GetDirectoryName(e.FilePath) == cacheDir) + { + result.CacheFiles.Add(e); + // Notify that the aaxc file has been created so that + // the UI can know about partially-downloaded files + if (getFileType(e) is FileType.AAXC) + OnFileCreated(dlOptions.LibraryBook, e.FilePath); + } + } + } + + #region Decryptor event handlers + private void Converter_RetrievedMetadata(object? sender, AAXClean.AppleTags tags) + { + if (sender is not AaxcDownloadConvertBase converter || + converter.AaxFile is not AAXClean.Mp4File aaxFile || + converter.DownloadOptions is not DownloadOptions options || + options.ChapterInfo.Chapters is not List chapters) return; #region Prevent erroneous truncation due to incorrect chapter info @@ -185,159 +195,287 @@ namespace FileLiberator //the chapter. This is never desirable, so pad the last chapter to match //the original audio length. - var fileDuration = converter.AaxFile.Duration; - if (options.Config.StripAudibleBrandAudio) - fileDuration -= TimeSpan.FromMilliseconds(options.ContentMetadata.ChapterInfo.BrandOutroDurationMs); + var fileDuration = aaxFile.Duration; + if (options.Config.StripAudibleBrandAudio) + fileDuration -= TimeSpan.FromMilliseconds(options.ContentMetadata.ChapterInfo.BrandOutroDurationMs); - var durationDelta = fileDuration - options.ChapterInfo.EndOffset; + var durationDelta = fileDuration - options.ChapterInfo.EndOffset; //Remove the last chapter and re-add it with the durationDelta that will - //make the chapter's end coincide with the end of the audio file. - var chapters = options.ChapterInfo.Chapters as List; + //make the chapter's end coincide with the end of the audio file. var lastChapter = chapters[^1]; chapters.Remove(lastChapter); options.ChapterInfo.Add(lastChapter.Title, lastChapter.Duration + durationDelta); - - #endregion + + #endregion tags.Title ??= options.LibraryBookDto.TitleWithSubtitle; - tags.Album ??= tags.Title; - tags.Artist ??= string.Join("; ", options.LibraryBook.Book.Authors.Select(a => a.Name)); - tags.AlbumArtists ??= tags.Artist; + tags.Album ??= tags.Title; + tags.Artist ??= string.Join("; ", options.LibraryBook.Book.Authors.Select(a => a.Name)); + tags.AlbumArtists ??= tags.Artist; tags.Generes = string.Join(", ", options.LibraryBook.Book.LowestCategoryNames()); - tags.ProductID ??= options.ContentMetadata.ContentReference.Sku; - tags.Comment ??= options.LibraryBook.Book.Description; - tags.LongDescription ??= tags.Comment; - tags.Publisher ??= options.LibraryBook.Book.Publisher; + tags.ProductID ??= options.ContentMetadata.ContentReference.Sku; + tags.Comment ??= options.LibraryBook.Book.Description; + tags.LongDescription ??= tags.Comment; + tags.Publisher ??= options.LibraryBook.Book.Publisher; tags.Narrator ??= string.Join("; ", options.LibraryBook.Book.Narrators.Select(n => n.Name)); tags.Asin = options.LibraryBook.Book.AudibleProductId; tags.Acr = options.ContentMetadata.ContentReference.Acr; tags.Version = options.ContentMetadata.ContentReference.Version; if (options.LibraryBook.Book.DatePublished is DateTime pubDate) - { - tags.Year ??= pubDate.Year.ToString(); - tags.ReleaseDate ??= pubDate.ToString("dd-MMM-yyyy"); - } - } - - private static void downloadValidation(LibraryBook libraryBook) - { - string errorString(string field) - => $"{errorTitle()}\r\nCannot download book. {field} is not known. Try re-importing the account which owns this book."; - - string errorTitle() - { - var title - = (libraryBook.Book.TitleWithSubtitle.Length > 53) - ? $"{libraryBook.Book.TitleWithSubtitle.Truncate(50)}..." - : libraryBook.Book.TitleWithSubtitle; - var errorBookTitle = $"{title} [{libraryBook.Book.AudibleProductId}]"; - return errorBookTitle; - }; - - if (string.IsNullOrWhiteSpace(libraryBook.Account)) - throw new Exception(errorString("Account")); - - if (string.IsNullOrWhiteSpace(libraryBook.Book.Locale)) - throw new Exception(errorString("Locale")); - } - - private void AaxcDownloader_RetrievedCoverArt(object _, byte[] e) - { - if (Configuration.Instance.AllowLibationFixup) - { - try - { - e = OnRequestCoverArt(); - abDownloader.SetCoverArt(e); - } - catch (Exception ex) - { - Serilog.Log.Logger.Error(ex, "Failed to retrieve cover art from server."); - } - } - - if (e is not null) - OnCoverImageDiscovered(e); + { + tags.Year ??= pubDate.Year.ToString(); + tags.ReleaseDate ??= pubDate.ToString("dd-MMM-yyyy"); + } } - /// Move new files to 'Books' directory - /// Return directory if audiobook file(s) were successfully created and can be located on disk. Else null. - private static void moveFilesToBooksDir(LibraryBook libraryBook, List entries, CancellationToken cancellationToken) - { - // create final directory. move each file into it - var destinationDir = getDestinationDirectory(libraryBook); - cancellationToken.ThrowIfCancellationRequested(); - - for (var i = 0; i < entries.Count; i++) - { - var entry = entries[i]; - - var realDest - = FileUtility.SaferMoveToValidPath( - entry.Path, - Path.Combine(destinationDir, Path.GetFileName(entry.Path)), - Configuration.Instance.ReplacementCharacters, - overwrite: Configuration.Instance.OverwriteExisting); - - SetFileTime(libraryBook, realDest); - FilePathCache.Insert(libraryBook.Book.AudibleProductId, realDest); - - // propagate corrected path. Must update cache with corrected path. Also want updated path for cue file (after this for-loop) - entries[i] = entry with { Path = realDest }; - cancellationToken.ThrowIfCancellationRequested(); + private void AaxcDownloader_RetrievedCoverArt(object? sender, byte[]? e) + { + if (Configuration.Instance.AllowLibationFixup && sender is AaxcDownloadConvertBase downloader) + { + try + { + e = OnRequestCoverArt(); + downloader.SetCoverArt(e); + } + catch (Exception ex) + { + Serilog.Log.Logger.Error(ex, "Failed to retrieve cover art from server."); + } } - var cue = entries.FirstOrDefault(f => f.FileType == FileType.Cue); - if (cue != default) - { - Cue.UpdateFileName(cue.Path, getFirstAudioFile(entries).Path); - SetFileTime(libraryBook, cue.Path); + if (e is not null) + OnCoverImageDiscovered(e); + } + #endregion + + #region Validation + + private static void DownloadValidation(LibraryBook libraryBook) + { + string errorString(string field) + => $"{errorTitle()}\r\nCannot download book. {field} is not known. Try re-importing the account which owns this book."; + + string errorTitle() + { + var title + = (libraryBook.Book.TitleWithSubtitle.Length > 53) + ? $"{libraryBook.Book.TitleWithSubtitle.Truncate(50)}..." + : libraryBook.Book.TitleWithSubtitle; + var errorBookTitle = $"{title} [{libraryBook.Book.AudibleProductId}]"; + return errorBookTitle; + }; + + if (string.IsNullOrWhiteSpace(libraryBook.Account)) + throw new InvalidOperationException(errorString("Account")); + + if (string.IsNullOrWhiteSpace(libraryBook.Book.Locale)) + throw new InvalidOperationException(errorString("Locale")); + } + #endregion + + #region Post-success routines + /// Move new files to 'Books' directory + /// Return directory if audiobook file(s) were successfully created and can be located on disk. Else null. + private void MoveFilesToBooksDir(LibraryBook libraryBook, LongPath destinationDir, List entries, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + AverageSpeed averageSpeed = new(); + + var totalSizeToMove = entries.Sum(f => new FileInfo(f.FilePath).Length); + long totalBytesMoved = 0; + + for (var i = 0; i < entries.Count; i++) + { + var entry = entries[i]; + + var destFileName + = AudibleFileStorage.Audio.GetCustomDirFilename( + libraryBook, + destinationDir, + entry.Extension, + entry.PartProperties, + Configuration.Instance.OverwriteExisting); + + var realDest + = FileUtility.SaferMoveToValidPath( + entry.FilePath, + destFileName, + Configuration.Instance.ReplacementCharacters, + entry.Extension, + Configuration.Instance.OverwriteExisting); + + #region File Move Progress + totalBytesMoved += new FileInfo(realDest).Length; + averageSpeed.AddPosition(totalBytesMoved); + var estSecsRemaining = (totalSizeToMove - totalBytesMoved) / averageSpeed.Average; + + if (double.IsNormal(estSecsRemaining)) + OnStreamingTimeRemaining(TimeSpan.FromSeconds(estSecsRemaining)); + + OnStreamingProgressChanged(new DownloadProgress + { + ProgressPercentage = 100d * totalBytesMoved / totalSizeToMove, + BytesReceived = totalBytesMoved, + TotalBytesToReceive = totalSizeToMove + }); + #endregion + + // propagate corrected path for cue file (after this for-loop) + entries[i] = entry with { FilePath = realDest }; + + SetFileTime(libraryBook, realDest); + OnFileCreated(libraryBook, realDest); + cancellationToken.ThrowIfCancellationRequested(); + } + + if (entries.FirstOrDefault(f => getFileType(f) is FileType.Cue) is TempFile cue + && getFirstAudioFile(entries)?.FilePath is LongPath audioFilePath) + { + Cue.UpdateFileName(cue.FilePath, audioFilePath); + SetFileTime(libraryBook, cue.FilePath); } cancellationToken.ThrowIfCancellationRequested(); AudibleFileStorage.Audio.Refresh(); - } - - private static string getDestinationDirectory(LibraryBook libraryBook) - { - var destinationDir = AudibleFileStorage.Audio.GetDestinationDirectory(libraryBook); - if (!Directory.Exists(destinationDir)) - Directory.CreateDirectory(destinationDir); - return destinationDir; } - private static FilePathCache.CacheEntry getFirstAudioFile(IEnumerable entries) - => entries.FirstOrDefault(f => f.FileType == FileType.Audio); + private void DownloadCoverArt(LongPath destinationDir, DownloadOptions options, CancellationToken cancellationToken) + { + if (!options.Config.DownloadCoverArt) return; - private static void downloadCoverArt(DownloadOptions options, CancellationToken cancellationToken) - { - if (!Configuration.Instance.DownloadCoverArt) return; + var coverPath = "[null]"; - var coverPath = "[null]"; + try + { + coverPath + = AudibleFileStorage.Audio.GetCustomDirFilename( + options.LibraryBook, + destinationDir, + extension: ".jpg", + returnFirstExisting: Configuration.Instance.OverwriteExisting); - try - { - var destinationDir = getDestinationDirectory(options.LibraryBook); - coverPath = AudibleFileStorage.Audio.GetBooksDirectoryFilename(options.LibraryBookDto, ".jpg"); - coverPath = Path.Combine(destinationDir, Path.GetFileName(coverPath)); + if (File.Exists(coverPath)) + FileUtility.SaferDelete(coverPath); - if (File.Exists(coverPath)) - FileUtility.SaferDelete(coverPath); + var picBytes = PictureStorage.GetPictureSynchronously(new(options.LibraryBook.Book.PictureLarge ?? options.LibraryBook.Book.PictureId, PictureSize.Native), cancellationToken); + if (picBytes.Length > 0) + { + File.WriteAllBytes(coverPath, picBytes); + SetFileTime(options.LibraryBook, coverPath); + OnFileCreated(options.LibraryBook, coverPath); + } + } + catch (Exception ex) + { + //Failure to download cover art should not be considered a failure to download the book + if (!cancellationToken.IsCancellationRequested) + Serilog.Log.Logger.Error(ex, "Error downloading cover art for {@Book} to {@metadataFile}.", options.LibraryBook, coverPath); + throw; + } + } - var picBytes = PictureStorage.GetPictureSynchronously(new(options.LibraryBook.Book.PictureLarge ?? options.LibraryBook.Book.PictureId, PictureSize.Native), cancellationToken); - if (picBytes.Length > 0) - { - File.WriteAllBytes(coverPath, picBytes); - SetFileTime(options.LibraryBook, coverPath); - } - } - catch (Exception ex) - { - //Failure to download cover art should not be considered a failure to download the book - Serilog.Log.Logger.Error(ex, $"Error downloading cover art of {options.LibraryBook.Book.AudibleProductId} to {coverPath} catalog product."); - throw; - } - } - } + public async Task DownloadRecordsAsync(AudibleApi.Api api, LongPath destinationDir, DownloadOptions options, CancellationToken cancellationToken) + { + if (!options.Config.DownloadClipsBookmarks) return; + + var recordsPath = "[null]"; + var format = options.Config.ClipsBookmarksFileFormat; + var formatExtension = FileUtility.GetStandardizedExtension(format.ToString().ToLowerInvariant()); + + try + { + recordsPath + = AudibleFileStorage.Audio.GetCustomDirFilename( + options.LibraryBook, + destinationDir, + extension: formatExtension, + returnFirstExisting: Configuration.Instance.OverwriteExisting); + + if (File.Exists(recordsPath)) + FileUtility.SaferDelete(recordsPath); + + var records = await api.GetRecordsAsync(options.AudibleProductId); + + switch (format) + { + case Configuration.ClipBookmarkFormat.CSV: + RecordExporter.ToCsv(recordsPath, records); + break; + case Configuration.ClipBookmarkFormat.Xlsx: + RecordExporter.ToXlsx(recordsPath, records); + break; + case Configuration.ClipBookmarkFormat.Json: + RecordExporter.ToJson(recordsPath, options.LibraryBook, records); + break; + default: + throw new NotSupportedException($"Unsupported record export format: {format}"); + } + + SetFileTime(options.LibraryBook, recordsPath); + OnFileCreated(options.LibraryBook, recordsPath); + } + catch (Exception ex) + { + //Failure to download records should not be considered a failure to download the book + if (!cancellationToken.IsCancellationRequested) + Serilog.Log.Logger.Error(ex, "Error downloading clips and bookmarks for {@Book} to {@recordsPath}.", options.LibraryBook, recordsPath); + throw; + } + } + + private async Task DownloadMetadataAsync(AudibleApi.Api api, LongPath destinationDir, DownloadOptions options, CancellationToken cancellationToken) + { + if (!options.Config.SaveMetadataToFile) return; + + string metadataPath = "[null]"; + + try + { + metadataPath + = AudibleFileStorage.Audio.GetCustomDirFilename( + options.LibraryBook, + destinationDir, + extension: ".metadata.json", + returnFirstExisting: Configuration.Instance.OverwriteExisting); + + if (File.Exists(metadataPath)) + FileUtility.SaferDelete(metadataPath); + + var item = await api.GetCatalogProductAsync(options.LibraryBook.Book.AudibleProductId, AudibleApi.CatalogOptions.ResponseGroupOptions.ALL_OPTIONS); + item.SourceJson.Add(nameof(ContentMetadata.ChapterInfo), Newtonsoft.Json.Linq.JObject.FromObject(options.ContentMetadata.ChapterInfo)); + item.SourceJson.Add(nameof(ContentMetadata.ContentReference), Newtonsoft.Json.Linq.JObject.FromObject(options.ContentMetadata.ContentReference)); + + cancellationToken.ThrowIfCancellationRequested(); + File.WriteAllText(metadataPath, item.SourceJson.ToString()); + SetFileTime(options.LibraryBook, metadataPath); + OnFileCreated(options.LibraryBook, metadataPath); + } + catch (Exception ex) + { + //Failure to download metadata should not be considered a failure to download the book + if (!cancellationToken.IsCancellationRequested) + Serilog.Log.Logger.Error(ex, "Error downloading metdatat of {@Book} to {@metadataFile}.", options.LibraryBook, metadataPath); + throw; + } + } + #endregion + + #region Macros + private static string getDestinationDirectory(LibraryBook libraryBook) + { + var destinationDir = AudibleFileStorage.Audio.GetDestinationDirectory(libraryBook); + if (!Directory.Exists(destinationDir)) + Directory.CreateDirectory(destinationDir); + return destinationDir; + } + + private static FileType getFileType(TempFile file) + => FileTypes.GetFileTypeFromPath(file.FilePath); + private static TempFile? getFirstAudioFile(IEnumerable entries) + => entries.FirstOrDefault(f => getFileType(f) is FileType.Audio); + private static IEnumerable getAaxcFiles(IEnumerable entries) + => entries.Where(f => getFileType(f) is FileType.AAXC || f.Extension.Equals(".key", StringComparison.OrdinalIgnoreCase)); + #endregion + } } diff --git a/Source/FileLiberator/DownloadOptions.cs b/Source/FileLiberator/DownloadOptions.cs index 86777021..514ee2bd 100644 --- a/Source/FileLiberator/DownloadOptions.cs +++ b/Source/FileLiberator/DownloadOptions.cs @@ -3,10 +3,8 @@ using AAXClean; using Dinah.Core; using DataLayer; using LibationFileManager; -using System.Threading.Tasks; using System; using System.IO; -using ApplicationServices; using LibationFileManager.Templates; #nullable enable @@ -31,12 +29,9 @@ namespace FileLiberator public float? SeriesNumber => LibraryBookDto.FirstSeries?.Number; public NAudio.Lame.LameConfig? LameConfig { get; } public string UserAgent => AudibleApi.Resources.Download_User_Agent; - public bool TrimOutputToChapterLength => Config.AllowLibationFixup && Config.StripAudibleBrandAudio; public bool StripUnabridged => Config.AllowLibationFixup && Config.StripUnabridged; public bool CreateCueSheet => Config.CreateCueSheet; - public bool DownloadClipsBookmarks => Config.DownloadClipsBookmarks; public long DownloadSpeedBps => Config.DownloadSpeedLimit; - public bool RetainEncryptedFile => Config.RetainAaxFile; public bool FixupFile => Config.AllowLibationFixup; public bool Downsample => Config.AllowLibationFixup && Config.LameDownsampleMono; public bool MatchSourceBitrate => Config.AllowLibationFixup && Config.LameMatchSourceBR && Config.LameTargetBitrate; @@ -45,45 +40,9 @@ namespace FileLiberator public AudibleApi.Common.DrmType DrmType { get; } public AudibleApi.Common.ContentMetadata ContentMetadata { get; } - public string GetMultipartFileName(MultiConvertFileProperties props) - { - var baseDir = Path.GetDirectoryName(props.OutputFileName); - var extension = Path.GetExtension(props.OutputFileName); - return Templates.ChapterFile.GetFilename(LibraryBookDto, props, baseDir!, extension); - } - public string GetMultipartTitle(MultiConvertFileProperties props) => Templates.ChapterTitle.GetName(LibraryBookDto, props); - public async Task SaveClipsAndBookmarksAsync(string fileName) - { - if (DownloadClipsBookmarks) - { - var format = Config.ClipsBookmarksFileFormat; - - var formatExtension = format.ToString().ToLowerInvariant(); - var filePath = Path.ChangeExtension(fileName, formatExtension); - - var api = await LibraryBook.GetApiAsync(); - var records = await api.GetRecordsAsync(LibraryBook.Book.AudibleProductId); - - switch(format) - { - case Configuration.ClipBookmarkFormat.CSV: - RecordExporter.ToCsv(filePath, records); - break; - case Configuration.ClipBookmarkFormat.Xlsx: - RecordExporter.ToXlsx(filePath, records); - break; - case Configuration.ClipBookmarkFormat.Json: - RecordExporter.ToJson(filePath, LibraryBook, records); - break; - } - return filePath; - } - return string.Empty; - } - public Configuration Config { get; } private readonly IDisposable cancellation; public void Dispose() diff --git a/Source/LibationFileManager/FilePathCache.cs b/Source/LibationFileManager/FilePathCache.cs index 469de1fd..a6c99ef6 100644 --- a/Source/LibationFileManager/FilePathCache.cs +++ b/Source/LibationFileManager/FilePathCache.cs @@ -46,7 +46,9 @@ namespace LibationFileManager public static List<(FileType fileType, LongPath path)> GetFiles(string id) { - var matchingFiles = Cache.GetIdEntries(id); + List matchingFiles; + lock(locker) + matchingFiles = Cache.GetIdEntries(id); bool cacheChanged = false; @@ -68,7 +70,9 @@ namespace LibationFileManager public static LongPath? GetFirstPath(string id, FileType type) { - var matchingFiles = Cache.GetIdEntries(id).Where(e => e.FileType == type).ToList(); + List matchingFiles; + lock (locker) + matchingFiles = Cache.GetIdEntries(id).Where(e => e.FileType == type).ToList(); bool cacheChanged = false; try @@ -96,7 +100,10 @@ namespace LibationFileManager private static bool Remove(CacheEntry entry) { - if (Cache.Remove(entry.Id, entry)) + bool removed; + lock (locker) + removed = Cache.Remove(entry.Id, entry); + if (removed) { Removed?.Invoke(null, entry); return true; @@ -112,7 +119,8 @@ namespace LibationFileManager public static void Insert(CacheEntry entry) { - Cache.Add(entry.Id, entry); + lock(locker) + Cache.Add(entry.Id, entry); Inserted?.Invoke(null, entry); save(); } From 08aebf8ecf898526b23b9d0ab167d64a54f74ba6 Mon Sep 17 00:00:00 2001 From: Michael Bucari-Tovo Date: Wed, 23 Jul 2025 17:00:36 -0600 Subject: [PATCH 04/13] Add thread safety --- .../ProcessQueue/VirtualFlowControl.cs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/Source/LibationWinForms/ProcessQueue/VirtualFlowControl.cs b/Source/LibationWinForms/ProcessQueue/VirtualFlowControl.cs index 007c732a..fbfa7b18 100644 --- a/Source/LibationWinForms/ProcessQueue/VirtualFlowControl.cs +++ b/Source/LibationWinForms/ProcessQueue/VirtualFlowControl.cs @@ -39,8 +39,19 @@ namespace LibationWinForms.ProcessQueue public void RefreshDisplay() { - AdjustScrollBar(); - DoVirtualScroll(); + if (InvokeRequired) + { + Invoke((MethodInvoker)delegate + { + AdjustScrollBar(); + DoVirtualScroll(); + }); + } + else + { + AdjustScrollBar(); + DoVirtualScroll(); + } } #region Dynamic Properties From a62a9ffc5b6d5c907c7160b19499e4a2bd4cff71 Mon Sep 17 00:00:00 2001 From: Michael Bucari-Tovo Date: Wed, 23 Jul 2025 17:00:54 -0600 Subject: [PATCH 05/13] Use HttpClient in synchronous mode --- Source/LibationFileManager/PictureStorage.cs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/Source/LibationFileManager/PictureStorage.cs b/Source/LibationFileManager/PictureStorage.cs index d07bcf79..582d55d8 100644 --- a/Source/LibationFileManager/PictureStorage.cs +++ b/Source/LibationFileManager/PictureStorage.cs @@ -133,7 +133,16 @@ namespace LibationFileManager try { var sizeStr = def.Size == PictureSize.Native ? "" : $"._SL{(int)def.Size}_"; - var bytes = imageDownloadClient.GetByteArrayAsync("ht" + $"tps://images-na.ssl-images-amazon.com/images/I/{def.PictureId}{sizeStr}.jpg", cancellationToken).Result; + + using var requestMessage = new HttpRequestMessage(HttpMethod.Get, "ht" + $"tps://images-na.ssl-images-amazon.com/images/I/{def.PictureId}{sizeStr}.jpg"); + using var response = imageDownloadClient.Send(requestMessage, HttpCompletionOption.ResponseHeadersRead, cancellationToken).EnsureSuccessStatusCode(); + + if (response.Content.Headers.ContentLength is not long size) + return GetDefaultImage(def.Size); + + var bytes = new byte[size]; + using var respStream = response.Content.ReadAsStream(cancellationToken); + respStream.ReadExactly(bytes); // save image file. make sure to not save default image var path = getPath(def); From 9b217a4e183df0d9a55b10f40fe8be8c4ac258a2 Mon Sep 17 00:00:00 2001 From: MBucari Date: Fri, 25 Jul 2025 03:38:37 -0600 Subject: [PATCH 06/13] Add audio format data - Add Book.IsSpatial property and add it to search index - Read audio format of actual output files and store it in UserDefinedItem. Now works with MP3s. - Store last downloaded audio file version - Add IsSpatial, file version, and Audio Format to library exports and to template tags. Updated docs. - Add last downloaded audio file version and format info to the Last Downloaded tab - Migrated the DB - Update AAXClean with some bug fixes - Fixed error converting xHE-AAC audio files to mp3 when splitting by chapter (or trimming the audible branding from the beginning of the file) - Improve mp3 ID# tags support. Chapter titles are now preserved. - Add support for reading EC-3 and AC-4 audio format metadata --- Documentation/NamingTemplates.md | 9 +- Source/AaxDecrypter/AaxDecrypter.csproj | 2 +- Source/ApplicationServices/LibraryCommands.cs | 4 +- Source/ApplicationServices/LibraryExporter.cs | 135 ++--- .../AudibleUtilities/AudibleUtilities.csproj | 2 +- Source/DataLayer/AudioFormat.cs | 70 +++ Source/DataLayer/Configurations/BookConfig.cs | 6 +- Source/DataLayer/EfClasses/Book.cs | 10 +- Source/DataLayer/EfClasses/UserDefinedItem.cs | 44 +- Source/DataLayer/LibationContextFactory.cs | 4 +- ...50725074123_AddAudioFormatData.Designer.cs | 474 ++++++++++++++++++ .../20250725074123_AddAudioFormatData.cs | 48 ++ .../LibationContextModelSnapshot.cs | 14 +- Source/DtoImporterService/BookImporter.cs | 7 +- Source/FileLiberator/AudioFormatDecoder.cs | 242 +++++++++ Source/FileLiberator/DownloadDecryptBook.cs | 37 +- .../FileLiberator/DownloadOptions.Factory.cs | 54 -- Source/FileLiberator/DownloadOptions.cs | 1 - Source/FileLiberator/UtilityExtensions.cs | 8 +- .../Templates/LibraryBookDto.cs | 2 + .../Templates/TemplateEditor[T].cs | 3 + .../Templates/TemplateTags.cs | 10 +- .../Templates/Templates.cs | 4 +- Source/LibationSearchEngine/SearchEngine.cs | 1 + .../GridView/LastDownloadStatus.cs | 11 +- 25 files changed, 1046 insertions(+), 156 deletions(-) create mode 100644 Source/DataLayer/AudioFormat.cs create mode 100644 Source/DataLayer/Migrations/20250725074123_AddAudioFormatData.Designer.cs create mode 100644 Source/DataLayer/Migrations/20250725074123_AddAudioFormatData.cs create mode 100644 Source/FileLiberator/AudioFormatDecoder.cs diff --git a/Documentation/NamingTemplates.md b/Documentation/NamingTemplates.md index 8f5d3e2e..184570ea 100644 --- a/Documentation/NamingTemplates.md +++ b/Documentation/NamingTemplates.md @@ -46,9 +46,12 @@ These tags will be replaced in the template with the audiobook's values. |\|All series to which the book belongs (if any)|[Series List](#series-list-formatters)| |\|First series|[Series](#series-formatters)| |\|Number order in series (alias for \|[Number](#number-formatters)| -|\|File's original bitrate (Kbps)|[Number](#number-formatters)| -|\|File's original audio sample rate|[Number](#number-formatters)| -|\|Number of audio channels|[Number](#number-formatters)| +|\|Bitrate (kbps) of the last downloaded audiobook|[Number](#number-formatters)| +|\|Sample rate (Hz) of the last downloaded audiobook|[Number](#number-formatters)| +|\|Number of audio channels in the last downloaded audiobook|[Number](#number-formatters)| +|\|Audio codec of the last downloaded audiobook|[Text](#text-formatters)| +|\|Audible's file version number of the last downloaded audiobook|[Text](#text-formatters)| +|\|Libation version used during last download of the audiobook|[Text](#text-formatters)| |\|Audible account of this book|[Text](#text-formatters)| |\|Audible account nickname of this book|[Text](#text-formatters)| |\|Region/country|[Text](#text-formatters)| diff --git a/Source/AaxDecrypter/AaxDecrypter.csproj b/Source/AaxDecrypter/AaxDecrypter.csproj index d585820a..4ad8889d 100644 --- a/Source/AaxDecrypter/AaxDecrypter.csproj +++ b/Source/AaxDecrypter/AaxDecrypter.csproj @@ -13,7 +13,7 @@ - + diff --git a/Source/ApplicationServices/LibraryCommands.cs b/Source/ApplicationServices/LibraryCommands.cs index 7c373d0b..f2e2a7cc 100644 --- a/Source/ApplicationServices/LibraryCommands.cs +++ b/Source/ApplicationServices/LibraryCommands.cs @@ -521,8 +521,8 @@ namespace ApplicationServices udi.UpdateRating(rating.OverallRating, rating.PerformanceRating, rating.StoryRating); }); - public static int UpdateBookStatus(this LibraryBook lb, LiberatedStatus bookStatus, Version libationVersion) - => lb.UpdateUserDefinedItem(udi => { udi.BookStatus = bookStatus; udi.SetLastDownloaded(libationVersion); }); + public static int UpdateBookStatus(this LibraryBook lb, LiberatedStatus bookStatus, Version? libationVersion, AudioFormat audioFormat, string audioVersion) + => lb.UpdateUserDefinedItem(udi => { udi.BookStatus = bookStatus; udi.SetLastDownloaded(libationVersion, audioFormat, audioVersion); }); public static int UpdateBookStatus(this LibraryBook libraryBook, LiberatedStatus bookStatus) => libraryBook.UpdateUserDefinedItem(udi => udi.BookStatus = bookStatus); diff --git a/Source/ApplicationServices/LibraryExporter.cs b/Source/ApplicationServices/LibraryExporter.cs index f0a62193..1f16052a 100644 --- a/Source/ApplicationServices/LibraryExporter.cs +++ b/Source/ApplicationServices/LibraryExporter.cs @@ -4,8 +4,8 @@ using System.Linq; using CsvHelper; using CsvHelper.Configuration.Attributes; using DataLayer; +using Newtonsoft.Json; using NPOI.XSSF.UserModel; -using Serilog; namespace ApplicationServices { @@ -115,7 +115,29 @@ namespace ApplicationServices [Name("IsFinished")] public bool IsFinished { get; set; } - } + + [Name("IsSpatial")] + public bool IsSpatial { get; set; } + + [Name("Last Downloaded File Version")] + public string LastDownloadedFileVersion { get; set; } + + [Ignore /* csv ignore */] + public AudioFormat LastDownloadedFormat { get; set; } + + [Name("Last Downloaded Codec"), JsonIgnore] + public string CodecString => LastDownloadedFormat?.CodecString ?? ""; + + [Name("Last Downloaded Sample rate"), JsonIgnore] + public int? SampleRate => LastDownloadedFormat?.SampleRate; + + [Name("Last Downloaded Audio Channels"), JsonIgnore] + public int? ChannelCount => LastDownloadedFormat?.ChannelCount; + + [Name("Last Downloaded Bitrate"), JsonIgnore] + public int? BitRate => LastDownloadedFormat?.BitRate; + } + public static class LibToDtos { public static List ToDtos(this IEnumerable library) @@ -135,16 +157,16 @@ namespace ApplicationServices HasPdf = a.Book.HasPdf(), SeriesNames = a.Book.SeriesNames(), SeriesOrder = a.Book.SeriesLink.Any() ? a.Book.SeriesLink?.Select(sl => $"{sl.Order} : {sl.Series.Name}").Aggregate((a, b) => $"{a}, {b}") : "", - CommunityRatingOverall = a.Book.Rating?.OverallRating, - CommunityRatingPerformance = a.Book.Rating?.PerformanceRating, - CommunityRatingStory = a.Book.Rating?.StoryRating, + CommunityRatingOverall = a.Book.Rating?.OverallRating.ZeroIsNull(), + CommunityRatingPerformance = a.Book.Rating?.PerformanceRating.ZeroIsNull(), + CommunityRatingStory = a.Book.Rating?.StoryRating.ZeroIsNull(), PictureId = a.Book.PictureId, IsAbridged = a.Book.IsAbridged, DatePublished = a.Book.DatePublished, CategoriesNames = string.Join("; ", a.Book.LowestCategoryNames()), - MyRatingOverall = a.Book.UserDefinedItem.Rating.OverallRating, - MyRatingPerformance = a.Book.UserDefinedItem.Rating.PerformanceRating, - MyRatingStory = a.Book.UserDefinedItem.Rating.StoryRating, + MyRatingOverall = a.Book.UserDefinedItem.Rating.OverallRating.ZeroIsNull(), + MyRatingPerformance = a.Book.UserDefinedItem.Rating.PerformanceRating.ZeroIsNull(), + MyRatingStory = a.Book.UserDefinedItem.Rating.StoryRating.ZeroIsNull(), MyLibationTags = a.Book.UserDefinedItem.Tags, BookStatus = a.Book.UserDefinedItem.BookStatus.ToString(), PdfStatus = a.Book.UserDefinedItem.PdfStatus.ToString(), @@ -152,8 +174,13 @@ namespace ApplicationServices Language = a.Book.Language, LastDownloaded = a.Book.UserDefinedItem.LastDownloaded, LastDownloadedVersion = a.Book.UserDefinedItem.LastDownloadedVersion?.ToString() ?? "", - IsFinished = a.Book.UserDefinedItem.IsFinished - }).ToList(); + IsFinished = a.Book.UserDefinedItem.IsFinished, + IsSpatial = a.Book.IsSpatial, + LastDownloadedFileVersion = a.Book.UserDefinedItem.LastDownloadedFileVersion ?? "", + LastDownloadedFormat = a.Book.UserDefinedItem.LastDownloadedFormat + }).ToList(); + + private static float? ZeroIsNull(this float value) => value is 0 ? null : value; } public static class LibraryExporter { @@ -162,7 +189,6 @@ namespace ApplicationServices var dtos = DbContexts.GetLibrary_Flat_NoTracking().ToDtos(); if (!dtos.Any()) return; - using var writer = new System.IO.StreamWriter(saveFilePath); using var csv = new CsvWriter(writer, System.Globalization.CultureInfo.CurrentCulture); @@ -174,7 +200,7 @@ namespace ApplicationServices public static void ToJson(string saveFilePath) { var dtos = DbContexts.GetLibrary_Flat_NoTracking().ToDtos(); - var json = Newtonsoft.Json.JsonConvert.SerializeObject(dtos, Newtonsoft.Json.Formatting.Indented); + var json = JsonConvert.SerializeObject(dtos, Formatting.Indented); System.IO.File.WriteAllText(saveFilePath, json); } @@ -227,7 +253,13 @@ namespace ApplicationServices nameof(ExportDto.Language), nameof(ExportDto.LastDownloaded), nameof(ExportDto.LastDownloadedVersion), - nameof(ExportDto.IsFinished) + nameof(ExportDto.IsFinished), + nameof(ExportDto.IsSpatial), + nameof(ExportDto.LastDownloadedFileVersion), + nameof(ExportDto.CodecString), + nameof(ExportDto.SampleRate), + nameof(ExportDto.ChannelCount), + nameof(ExportDto.BitRate) }; var col = 0; foreach (var c in columns) @@ -248,15 +280,10 @@ namespace ApplicationServices foreach (var dto in dtos) { col = 0; - - row = sheet.CreateRow(rowIndex); + row = sheet.CreateRow(rowIndex++); row.CreateCell(col++).SetCellValue(dto.Account); - - var dateCell = row.CreateCell(col++); - dateCell.CellStyle = dateStyle; - dateCell.SetCellValue(dto.DateAdded); - + row.CreateCell(col++).SetCellValue(dto.DateAdded).CellStyle = dateStyle; row.CreateCell(col++).SetCellValue(dto.AudibleProductId); row.CreateCell(col++).SetCellValue(dto.Locale); row.CreateCell(col++).SetCellValue(dto.Title); @@ -269,56 +296,46 @@ namespace ApplicationServices row.CreateCell(col++).SetCellValue(dto.HasPdf); row.CreateCell(col++).SetCellValue(dto.SeriesNames); row.CreateCell(col++).SetCellValue(dto.SeriesOrder); - - col = createCell(row, col, dto.CommunityRatingOverall); - col = createCell(row, col, dto.CommunityRatingPerformance); - col = createCell(row, col, dto.CommunityRatingStory); - + row.CreateCell(col++).SetCellValue(dto.CommunityRatingOverall); + row.CreateCell(col++).SetCellValue(dto.CommunityRatingPerformance); + row.CreateCell(col++).SetCellValue(dto.CommunityRatingStory); row.CreateCell(col++).SetCellValue(dto.PictureId); row.CreateCell(col++).SetCellValue(dto.IsAbridged); - - var datePubCell = row.CreateCell(col++); - datePubCell.CellStyle = dateStyle; - if (dto.DatePublished.HasValue) - datePubCell.SetCellValue(dto.DatePublished.Value); - else - datePubCell.SetCellValue(""); - + row.CreateCell(col++).SetCellValue(dto.DatePublished).CellStyle = dateStyle; row.CreateCell(col++).SetCellValue(dto.CategoriesNames); - - col = createCell(row, col, dto.MyRatingOverall); - col = createCell(row, col, dto.MyRatingPerformance); - col = createCell(row, col, dto.MyRatingStory); - + row.CreateCell(col++).SetCellValue(dto.MyRatingOverall); + row.CreateCell(col++).SetCellValue(dto.MyRatingPerformance); + row.CreateCell(col++).SetCellValue(dto.MyRatingStory); row.CreateCell(col++).SetCellValue(dto.MyLibationTags); row.CreateCell(col++).SetCellValue(dto.BookStatus); row.CreateCell(col++).SetCellValue(dto.PdfStatus); row.CreateCell(col++).SetCellValue(dto.ContentType); - row.CreateCell(col++).SetCellValue(dto.Language); - - if (dto.LastDownloaded.HasValue) - { - dateCell = row.CreateCell(col); - dateCell.CellStyle = dateStyle; - dateCell.SetCellValue(dto.LastDownloaded.Value); - } - - row.CreateCell(++col).SetCellValue(dto.LastDownloadedVersion); - row.CreateCell(++col).SetCellValue(dto.IsFinished); - - rowIndex++; + row.CreateCell(col++).SetCellValue(dto.Language); + row.CreateCell(col++).SetCellValue(dto.LastDownloaded).CellStyle = dateStyle; + row.CreateCell(col++).SetCellValue(dto.LastDownloadedVersion); + row.CreateCell(col++).SetCellValue(dto.IsFinished); + row.CreateCell(col++).SetCellValue(dto.IsSpatial); + row.CreateCell(col++).SetCellValue(dto.LastDownloadedFileVersion); + row.CreateCell(col++).SetCellValue(dto.CodecString); + row.CreateCell(col++).SetCellValue(dto.SampleRate); + row.CreateCell(col++).SetCellValue(dto.ChannelCount); + row.CreateCell(col++).SetCellValue(dto.BitRate); } using var fileData = new System.IO.FileStream(saveFilePath, System.IO.FileMode.Create); workbook.Write(fileData); } - private static int createCell(NPOI.SS.UserModel.IRow row, int col, float? nullableFloat) - { - if (nullableFloat.HasValue) - row.CreateCell(col++).SetCellValue(nullableFloat.Value); - else - row.CreateCell(col++).SetCellValue(""); - return col; - } + + private static NPOI.SS.UserModel.ICell SetCellValue(this NPOI.SS.UserModel.ICell cell, DateTime? nullableDate) + => nullableDate.HasValue ? cell.SetCellValue(nullableDate.Value) + : cell.SetCellType(NPOI.SS.UserModel.CellType.Numeric); + + private static NPOI.SS.UserModel.ICell SetCellValue(this NPOI.SS.UserModel.ICell cell, int? nullableInt) + => nullableInt.HasValue ? cell.SetCellValue(nullableInt.Value) + : cell.SetCellType(NPOI.SS.UserModel.CellType.Numeric); + + private static NPOI.SS.UserModel.ICell SetCellValue(this NPOI.SS.UserModel.ICell cell, float? nullableFloat) + => nullableFloat.HasValue ? cell.SetCellValue(nullableFloat.Value) + : cell.SetCellType(NPOI.SS.UserModel.CellType.Numeric); } } diff --git a/Source/AudibleUtilities/AudibleUtilities.csproj b/Source/AudibleUtilities/AudibleUtilities.csproj index 8007d316..d9916eaa 100644 --- a/Source/AudibleUtilities/AudibleUtilities.csproj +++ b/Source/AudibleUtilities/AudibleUtilities.csproj @@ -5,7 +5,7 @@ - + diff --git a/Source/DataLayer/AudioFormat.cs b/Source/DataLayer/AudioFormat.cs new file mode 100644 index 00000000..2a517677 --- /dev/null +++ b/Source/DataLayer/AudioFormat.cs @@ -0,0 +1,70 @@ +#nullable enable +using Newtonsoft.Json; + +namespace DataLayer; + +public enum Codec : byte +{ + Unknown, + Mp3, + AAC_LC, + xHE_AAC, + EC_3, + AC_4 +} + +public class AudioFormat +{ + public static AudioFormat Default => new(Codec.Unknown, 0, 0, 0); + [JsonIgnore] + public bool IsDefault => Codec is Codec.Unknown && BitRate == 0 && SampleRate == 0 && ChannelCount == 0; + [JsonIgnore] + public Codec Codec { get; set; } + public int SampleRate { get; set; } + public int ChannelCount { get; set; } + public int BitRate { get; set; } + + public AudioFormat(Codec codec, int bitRate, int sampleRate, int channelCount) + { + Codec = codec; + BitRate = bitRate; + SampleRate = sampleRate; + ChannelCount = channelCount; + } + + public string CodecString => Codec switch + { + Codec.Mp3 => "mp3", + Codec.AAC_LC => "AAC-LC", + Codec.xHE_AAC => "xHE-AAC", + Codec.EC_3 => "EC-3", + Codec.AC_4 => "AC-4", + Codec.Unknown or _ => "[Unknown]", + }; + + //Property | Start | Num | Max | Current Max | + // | Bit | Bits | Value | Value Used | + //----------------------------------------------------- + //Codec | 35 | 4 | 15 | 5 | + //BitRate | 23 | 12 | 4_095 | 768 | + //SampleRate | 5 | 18 | 262_143 | 48_000 | + //ChannelCount | 0 | 5 | 31 | 6 | + public long Serialize() => + ((long)Codec << 35) | + ((long)BitRate << 23) | + ((long)SampleRate << 5) | + (long)ChannelCount; + + public static AudioFormat Deserialize(long value) + { + var codec = (Codec)((value >> 35) & 15); + var bitRate = (int)((value >> 23) & 4_095); + var sampleRate = (int)((value >> 5) & 262_143); + var channelCount = (int)(value & 31); + return new AudioFormat(codec, bitRate, sampleRate, channelCount); + } + + public override string ToString() + => IsDefault ? "[Unknown Audio Format]" + : $"{CodecString} ({ChannelCount}ch | {SampleRate:N0}Hz | {BitRate}kbps)"; +} diff --git a/Source/DataLayer/Configurations/BookConfig.cs b/Source/DataLayer/Configurations/BookConfig.cs index bafa27e6..b0d802ce 100644 --- a/Source/DataLayer/Configurations/BookConfig.cs +++ b/Source/DataLayer/Configurations/BookConfig.cs @@ -13,7 +13,6 @@ namespace DataLayer.Configurations entity.OwnsOne(b => b.Rating); - entity.Property(nameof(Book._audioFormat)); // // CRUCIAL: ignore unmapped collections, even get-only // @@ -50,6 +49,11 @@ namespace DataLayer.Configurations b_udi .Property(udi => udi.LastDownloadedVersion) .HasConversion(ver => ver.ToString(), str => Version.Parse(str)); + b_udi + .Property(udi => udi.LastDownloadedFormat) + .HasConversion(af => af.Serialize(), str => AudioFormat.Deserialize(str)); + + b_udi.Property(udi => udi.LastDownloadedFileVersion); // owns it 1:1, store in same table b_udi.OwnsOne(udi => udi.Rating); diff --git a/Source/DataLayer/EfClasses/Book.cs b/Source/DataLayer/EfClasses/Book.cs index 33cbbaf5..11aedf1d 100644 --- a/Source/DataLayer/EfClasses/Book.cs +++ b/Source/DataLayer/EfClasses/Book.cs @@ -43,18 +43,13 @@ namespace DataLayer public ContentType ContentType { get; private set; } public string Locale { get; private set; } - //This field is now unused, however, there is little sense in adding a - //database migration to remove an unused field. Leave it for compatibility. -#pragma warning disable CS0649 // Field 'Book._audioFormat' is never assigned to, and will always have its default value 0 - internal long _audioFormat; -#pragma warning restore CS0649 - // mutable public string PictureId { get; set; } public string PictureLarge { get; set; } // book details public bool IsAbridged { get; private set; } + public bool IsSpatial { get; private set; } public DateTime? DatePublished { get; private set; } public string Language { get; private set; } @@ -242,10 +237,11 @@ namespace DataLayer public void UpdateProductRating(float overallRating, float performanceRating, float storyRating) => Rating.Update(overallRating, performanceRating, storyRating); - public void UpdateBookDetails(bool isAbridged, DateTime? datePublished, string language) + public void UpdateBookDetails(bool isAbridged, bool? isSpatial, DateTime? datePublished, string language) { // don't overwrite with default values IsAbridged |= isAbridged; + IsSpatial |= isSpatial ?? false; DatePublished = datePublished ?? DatePublished; Language = language?.FirstCharToUpper() ?? Language; } diff --git a/Source/DataLayer/EfClasses/UserDefinedItem.cs b/Source/DataLayer/EfClasses/UserDefinedItem.cs index dce1dfbe..7aa977f0 100644 --- a/Source/DataLayer/EfClasses/UserDefinedItem.cs +++ b/Source/DataLayer/EfClasses/UserDefinedItem.cs @@ -24,24 +24,52 @@ namespace DataLayer { internal int BookId { get; private set; } public Book Book { get; private set; } - public DateTime? LastDownloaded { get; private set; } - public Version LastDownloadedVersion { get; private set; } + /// + /// Date the audio file was last downloaded. + /// + public DateTime? LastDownloaded { get; private set; } + /// + /// Version of Libation used the last time the audio file was downloaded. + /// + public Version LastDownloadedVersion { get; private set; } + /// + /// Audio format of the last downloaded audio file. + /// + public AudioFormat LastDownloadedFormat { get; private set; } + /// + /// Version of the audio file that was last downloaded. + /// + public string LastDownloadedFileVersion { get; private set; } - public void SetLastDownloaded(Version version) + public void SetLastDownloaded(Version libationVersion, AudioFormat audioFormat, string audioVersion) { - if (LastDownloadedVersion != version) + if (LastDownloadedVersion != libationVersion) { - LastDownloadedVersion = version; + LastDownloadedVersion = libationVersion; OnItemChanged(nameof(LastDownloadedVersion)); } + if (LastDownloadedFormat != audioFormat) + { + LastDownloadedFormat = audioFormat; + OnItemChanged(nameof(LastDownloadedFormat)); + } + if (LastDownloadedFileVersion != audioVersion) + { + LastDownloadedFileVersion = audioVersion; + OnItemChanged(nameof(LastDownloadedFileVersion)); + } - if (version is null) + if (libationVersion is null) + { LastDownloaded = null; + LastDownloadedFormat = null; + LastDownloadedFileVersion = null; + } else { - LastDownloaded = DateTime.Now; + LastDownloaded = DateTime.Now; OnItemChanged(nameof(LastDownloaded)); - } + } } private UserDefinedItem() { } diff --git a/Source/DataLayer/LibationContextFactory.cs b/Source/DataLayer/LibationContextFactory.cs index 92f1f7d1..8718f636 100644 --- a/Source/DataLayer/LibationContextFactory.cs +++ b/Source/DataLayer/LibationContextFactory.cs @@ -1,5 +1,6 @@ using Dinah.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; namespace DataLayer { @@ -7,6 +8,7 @@ namespace DataLayer { protected override LibationContext CreateNewInstance(DbContextOptions options) => new LibationContext(options); protected override void UseDatabaseEngine(DbContextOptionsBuilder optionsBuilder, string connectionString) - => optionsBuilder.UseSqlite(connectionString, ob => ob.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery)); + => optionsBuilder.ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning)) + .UseSqlite(connectionString, ob => ob.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery)); } } diff --git a/Source/DataLayer/Migrations/20250725074123_AddAudioFormatData.Designer.cs b/Source/DataLayer/Migrations/20250725074123_AddAudioFormatData.Designer.cs new file mode 100644 index 00000000..a39c89db --- /dev/null +++ b/Source/DataLayer/Migrations/20250725074123_AddAudioFormatData.Designer.cs @@ -0,0 +1,474 @@ +// +using System; +using DataLayer; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace DataLayer.Migrations +{ + [DbContext(typeof(LibationContext))] + [Migration("20250725074123_AddAudioFormatData")] + partial class AddAudioFormatData + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.7"); + + modelBuilder.Entity("CategoryCategoryLadder", b => + { + b.Property("_categoriesCategoryId") + .HasColumnType("INTEGER"); + + b.Property("_categoryLaddersCategoryLadderId") + .HasColumnType("INTEGER"); + + b.HasKey("_categoriesCategoryId", "_categoryLaddersCategoryLadderId"); + + b.HasIndex("_categoryLaddersCategoryLadderId"); + + b.ToTable("CategoryCategoryLadder"); + }); + + modelBuilder.Entity("DataLayer.Book", b => + { + b.Property("BookId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AudibleProductId") + .HasColumnType("TEXT"); + + b.Property("ContentType") + .HasColumnType("INTEGER"); + + b.Property("DatePublished") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("IsAbridged") + .HasColumnType("INTEGER"); + + b.Property("IsSpatial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LengthInMinutes") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .HasColumnType("TEXT"); + + b.Property("PictureId") + .HasColumnType("TEXT"); + + b.Property("PictureLarge") + .HasColumnType("TEXT"); + + b.Property("Subtitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("BookId"); + + b.HasIndex("AudibleProductId"); + + b.ToTable("Books"); + }); + + modelBuilder.Entity("DataLayer.BookCategory", b => + { + b.Property("BookId") + .HasColumnType("INTEGER"); + + b.Property("CategoryLadderId") + .HasColumnType("INTEGER"); + + b.HasKey("BookId", "CategoryLadderId"); + + b.HasIndex("BookId"); + + b.HasIndex("CategoryLadderId"); + + b.ToTable("BookCategory"); + }); + + modelBuilder.Entity("DataLayer.BookContributor", b => + { + b.Property("BookId") + .HasColumnType("INTEGER"); + + b.Property("ContributorId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.HasKey("BookId", "ContributorId", "Role"); + + b.HasIndex("BookId"); + + b.HasIndex("ContributorId"); + + b.ToTable("BookContributor"); + }); + + modelBuilder.Entity("DataLayer.Category", b => + { + b.Property("CategoryId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AudibleCategoryId") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("CategoryId"); + + b.HasIndex("AudibleCategoryId"); + + b.ToTable("Categories"); + }); + + modelBuilder.Entity("DataLayer.CategoryLadder", b => + { + b.Property("CategoryLadderId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.HasKey("CategoryLadderId"); + + b.ToTable("CategoryLadders"); + }); + + modelBuilder.Entity("DataLayer.Contributor", b => + { + b.Property("ContributorId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AudibleContributorId") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("ContributorId"); + + b.HasIndex("Name"); + + b.ToTable("Contributors"); + + b.HasData( + new + { + ContributorId = -1, + Name = "" + }); + }); + + modelBuilder.Entity("DataLayer.LibraryBook", b => + { + b.Property("BookId") + .HasColumnType("INTEGER"); + + b.Property("AbsentFromLastScan") + .HasColumnType("INTEGER"); + + b.Property("Account") + .HasColumnType("TEXT"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.HasKey("BookId"); + + b.ToTable("LibraryBooks"); + }); + + modelBuilder.Entity("DataLayer.Series", b => + { + b.Property("SeriesId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AudibleSeriesId") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("SeriesId"); + + b.HasIndex("AudibleSeriesId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("DataLayer.SeriesBook", b => + { + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("BookId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("TEXT"); + + b.HasKey("SeriesId", "BookId"); + + b.HasIndex("BookId"); + + b.HasIndex("SeriesId"); + + b.ToTable("SeriesBook"); + }); + + modelBuilder.Entity("CategoryCategoryLadder", b => + { + b.HasOne("DataLayer.Category", null) + .WithMany() + .HasForeignKey("_categoriesCategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("DataLayer.CategoryLadder", null) + .WithMany() + .HasForeignKey("_categoryLaddersCategoryLadderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("DataLayer.Book", b => + { + b.OwnsOne("DataLayer.Rating", "Rating", b1 => + { + b1.Property("BookId") + .HasColumnType("INTEGER"); + + b1.Property("OverallRating") + .HasColumnType("REAL"); + + b1.Property("PerformanceRating") + .HasColumnType("REAL"); + + b1.Property("StoryRating") + .HasColumnType("REAL"); + + b1.HasKey("BookId"); + + b1.ToTable("Books"); + + b1.WithOwner() + .HasForeignKey("BookId"); + }); + + b.OwnsMany("DataLayer.Supplement", "Supplements", b1 => + { + b1.Property("SupplementId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b1.Property("BookId") + .HasColumnType("INTEGER"); + + b1.Property("Url") + .HasColumnType("TEXT"); + + b1.HasKey("SupplementId"); + + b1.HasIndex("BookId"); + + b1.ToTable("Supplement"); + + b1.WithOwner("Book") + .HasForeignKey("BookId"); + + b1.Navigation("Book"); + }); + + b.OwnsOne("DataLayer.UserDefinedItem", "UserDefinedItem", b1 => + { + b1.Property("BookId") + .HasColumnType("INTEGER"); + + b1.Property("BookStatus") + .HasColumnType("INTEGER"); + + b1.Property("IsFinished") + .HasColumnType("INTEGER"); + + b1.Property("LastDownloaded") + .HasColumnType("TEXT"); + + b1.Property("LastDownloadedFileVersion") + .HasColumnType("TEXT"); + + b1.Property("LastDownloadedFormat") + .HasColumnType("INTEGER"); + + b1.Property("LastDownloadedVersion") + .HasColumnType("TEXT"); + + b1.Property("PdfStatus") + .HasColumnType("INTEGER"); + + b1.Property("Tags") + .HasColumnType("TEXT"); + + b1.HasKey("BookId"); + + b1.ToTable("UserDefinedItem", (string)null); + + b1.WithOwner("Book") + .HasForeignKey("BookId"); + + b1.OwnsOne("DataLayer.Rating", "Rating", b2 => + { + b2.Property("UserDefinedItemBookId") + .HasColumnType("INTEGER"); + + b2.Property("OverallRating") + .HasColumnType("REAL"); + + b2.Property("PerformanceRating") + .HasColumnType("REAL"); + + b2.Property("StoryRating") + .HasColumnType("REAL"); + + b2.HasKey("UserDefinedItemBookId"); + + b2.ToTable("UserDefinedItem"); + + b2.WithOwner() + .HasForeignKey("UserDefinedItemBookId"); + }); + + b1.Navigation("Book"); + + b1.Navigation("Rating"); + }); + + b.Navigation("Rating"); + + b.Navigation("Supplements"); + + b.Navigation("UserDefinedItem"); + }); + + modelBuilder.Entity("DataLayer.BookCategory", b => + { + b.HasOne("DataLayer.Book", "Book") + .WithMany("CategoriesLink") + .HasForeignKey("BookId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("DataLayer.CategoryLadder", "CategoryLadder") + .WithMany("BooksLink") + .HasForeignKey("CategoryLadderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Book"); + + b.Navigation("CategoryLadder"); + }); + + modelBuilder.Entity("DataLayer.BookContributor", b => + { + b.HasOne("DataLayer.Book", "Book") + .WithMany("ContributorsLink") + .HasForeignKey("BookId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("DataLayer.Contributor", "Contributor") + .WithMany("BooksLink") + .HasForeignKey("ContributorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Book"); + + b.Navigation("Contributor"); + }); + + modelBuilder.Entity("DataLayer.LibraryBook", b => + { + b.HasOne("DataLayer.Book", "Book") + .WithOne() + .HasForeignKey("DataLayer.LibraryBook", "BookId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Book"); + }); + + modelBuilder.Entity("DataLayer.SeriesBook", b => + { + b.HasOne("DataLayer.Book", "Book") + .WithMany("SeriesLink") + .HasForeignKey("BookId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("DataLayer.Series", "Series") + .WithMany("BooksLink") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Book"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("DataLayer.Book", b => + { + b.Navigation("CategoriesLink"); + + b.Navigation("ContributorsLink"); + + b.Navigation("SeriesLink"); + }); + + modelBuilder.Entity("DataLayer.CategoryLadder", b => + { + b.Navigation("BooksLink"); + }); + + modelBuilder.Entity("DataLayer.Contributor", b => + { + b.Navigation("BooksLink"); + }); + + modelBuilder.Entity("DataLayer.Series", b => + { + b.Navigation("BooksLink"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Source/DataLayer/Migrations/20250725074123_AddAudioFormatData.cs b/Source/DataLayer/Migrations/20250725074123_AddAudioFormatData.cs new file mode 100644 index 00000000..f653c00f --- /dev/null +++ b/Source/DataLayer/Migrations/20250725074123_AddAudioFormatData.cs @@ -0,0 +1,48 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DataLayer.Migrations +{ + /// + public partial class AddAudioFormatData : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameColumn( + name: "_audioFormat", + table: "Books", + newName: "IsSpatial"); + + migrationBuilder.AddColumn( + name: "LastDownloadedFileVersion", + table: "UserDefinedItem", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "LastDownloadedFormat", + table: "UserDefinedItem", + type: "INTEGER", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "LastDownloadedFileVersion", + table: "UserDefinedItem"); + + migrationBuilder.DropColumn( + name: "LastDownloadedFormat", + table: "UserDefinedItem"); + + migrationBuilder.RenameColumn( + name: "IsSpatial", + table: "Books", + newName: "_audioFormat"); + } + } +} diff --git a/Source/DataLayer/Migrations/LibationContextModelSnapshot.cs b/Source/DataLayer/Migrations/LibationContextModelSnapshot.cs index 2a17687e..99f70af0 100644 --- a/Source/DataLayer/Migrations/LibationContextModelSnapshot.cs +++ b/Source/DataLayer/Migrations/LibationContextModelSnapshot.cs @@ -15,7 +15,7 @@ namespace DataLayer.Migrations protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "8.0.5"); + modelBuilder.HasAnnotation("ProductVersion", "9.0.7"); modelBuilder.Entity("CategoryCategoryLadder", b => { @@ -53,6 +53,9 @@ namespace DataLayer.Migrations b.Property("IsAbridged") .HasColumnType("INTEGER"); + b.Property("IsSpatial") + .HasColumnType("INTEGER"); + b.Property("Language") .HasColumnType("TEXT"); @@ -74,9 +77,6 @@ namespace DataLayer.Migrations b.Property("Title") .HasColumnType("TEXT"); - b.Property("_audioFormat") - .HasColumnType("INTEGER"); - b.HasKey("BookId"); b.HasIndex("AudibleProductId"); @@ -318,6 +318,12 @@ namespace DataLayer.Migrations b1.Property("LastDownloaded") .HasColumnType("TEXT"); + b1.Property("LastDownloadedFileVersion") + .HasColumnType("TEXT"); + + b1.Property("LastDownloadedFormat") + .HasColumnType("INTEGER"); + b1.Property("LastDownloadedVersion") .HasColumnType("TEXT"); diff --git a/Source/DtoImporterService/BookImporter.cs b/Source/DtoImporterService/BookImporter.cs index 95c86a64..e6596d09 100644 --- a/Source/DtoImporterService/BookImporter.cs +++ b/Source/DtoImporterService/BookImporter.cs @@ -137,8 +137,6 @@ namespace DtoImporterService book.ReplacePublisher(publisher); } - book.UpdateBookDetails(item.IsAbridged, item.DatePublished, item.Language); - if (item.PdfUrl is not null) book.AddSupplementDownloadUrl(item.PdfUrl.ToString()); @@ -166,8 +164,9 @@ namespace DtoImporterService // 2023-02-01 // updateBook must update language on books which were imported before the migration which added language. - // Can eventually delete this - book.UpdateBookDetails(item.IsAbridged, item.DatePublished, item.Language); + // 2025-07-30 + // updateBook must update isSpatial on books which were imported before the migration which added isSpatial. + book.UpdateBookDetails(item.IsAbridged, item.AssetDetails?.Any(a => a.IsSpatial), item.DatePublished, item.Language); book.UpdateProductRating( (float)(item.Rating?.OverallDistribution?.AverageRating ?? 0), diff --git a/Source/FileLiberator/AudioFormatDecoder.cs b/Source/FileLiberator/AudioFormatDecoder.cs new file mode 100644 index 00000000..1894298d --- /dev/null +++ b/Source/FileLiberator/AudioFormatDecoder.cs @@ -0,0 +1,242 @@ +using AAXClean; +using DataLayer; +using FileManager; +using Mpeg4Lib.Boxes; +using Mpeg4Lib.Util; +using NAudio.Lame.ID3; +using System; +using System.Collections.Generic; +using System.IO; + +#nullable enable +namespace AaxDecrypter; + +/// Read audio codec, bitrate, sample rate, and channel count from MP4 and MP3 audio files. +internal static class AudioFormatDecoder +{ + public static AudioFormat FromMpeg4(string filename) + { + using var fileStream = File.Open(filename, FileMode.Open, FileAccess.Read, FileShare.Read); + return FromMpeg4(new Mp4File(fileStream)); + } + + public static AudioFormat FromMpeg4(Mp4File mp4File) + { + Codec codec; + if (mp4File.AudioSampleEntry.Dac4 is not null) + { + codec = Codec.AC_4; + } + else if (mp4File.AudioSampleEntry.Dec3 is not null) + { + codec = Codec.EC_3; + } + else if (mp4File.AudioSampleEntry.Esds is EsdsBox esds) + { + var objectType = esds.ES_Descriptor.DecoderConfig.AudioSpecificConfig.AudioObjectType; + codec + = objectType == 2 ? Codec.AAC_LC + : objectType == 42 ? Codec.xHE_AAC + : Codec.Unknown; + } + else + return AudioFormat.Default; + + var bitrate = (int)Math.Round(mp4File.AverageBitrate / 1024d); + + return new AudioFormat(codec, bitrate, mp4File.TimeScale, mp4File.AudioChannels); + } + + public static AudioFormat FromMpeg3(LongPath mp3Filename) + { + using var mp3File = File.Open(mp3Filename, FileMode.Open, FileAccess.Read, FileShare.Read); + if (Id3Header.Create(mp3File) is Id3Header id3header) + id3header.SeekForwardToPosition(mp3File, mp3File.Position + id3header.Size); + else + { + Serilog.Log.Logger.Debug("File appears not to have ID3 tags."); + mp3File.Position = 0; + } + + if (!SeekToFirstKeyFrame(mp3File)) + { + Serilog.Log.Logger.Warning("Invalid frame sync read from file at end of ID3 tag."); + return AudioFormat.Default; + } + + var mpegSize = mp3File.Length - mp3File.Position; + if (mpegSize < 64) + { + Serilog.Log.Logger.Warning("Remaining file length is too short to contain any mp3 frames. {@File}", mp3Filename); + return AudioFormat.Default; + } + + #region read first mp3 frame header + //https://www.codeproject.com/Articles/8295/MPEG-Audio-Frame-Header#VBRIHeader + var reader = new BitReader(mp3File.ReadBlock(4)); + reader.Position = 11; //Skip frame header magic bits + var versionId = (Version)reader.Read(2); + var layerDesc = (Layer)reader.Read(2); + + if (layerDesc is not Layer.Layer_3) + { + Serilog.Log.Logger.Warning("Could not read mp3 data from {@layerVersion} file.", layerDesc.ToString()); + return AudioFormat.Default; + } + + if (versionId is Version.Reserved) + { + Serilog.Log.Logger.Warning("Mp3 data data cannot be read from a file with version = 'Reserved'"); + return AudioFormat.Default; + } + + var protectionBit = reader.ReadBool(); + var bitrateIndex = reader.Read(4); + var freqIndex = reader.Read(2); + _ = reader.ReadBool(); //Padding bit + _ = reader.ReadBool(); //Private bit + var channelMode = reader.Read(2); + _ = reader.Read(2); //Mode extension + _ = reader.ReadBool(); //Copyright + _ = reader.ReadBool(); //Original + _ = reader.Read(2); //Emphasis + #endregion + + //Read the sample rate,and channels from the first frame's header. + var sampleRate = Mp3SampleRateIndex[versionId][freqIndex]; + var channelCount = channelMode == 3 ? 1 : 2; + + //Try to read variable bitrate info from the first frame. + //Revert to fixed bitrate from frame header if not found. + var bitrate + = TryReadXingBitrate(out var br) ? br + : TryReadVbriBitrate(out br) ? br + : Mp3BitrateIndex[versionId][bitrateIndex]; + + return new AudioFormat(Codec.Mp3, bitrate, sampleRate, channelCount); + + #region Variable bitrate header readers + bool TryReadXingBitrate(out int bitrate) + { + const int XingHeader = 0x58696e67; + const int InfoHeader = 0x496e666f; + + var sideInfoSize = GetSideInfo(channelCount == 2, versionId) + (protectionBit ? 0 : 2); + mp3File.Position += sideInfoSize; + + if (mp3File.ReadUInt32BE() is XingHeader or InfoHeader) + { + //Xing or Info header (common) + var flags = mp3File.ReadUInt32BE(); + bool hasFramesField = (flags & 1) == 1; + bool hasBytesField = (flags & 2) == 2; + + if (hasFramesField) + { + var numFrames = mp3File.ReadUInt32BE(); + if (hasBytesField) + { + mpegSize = mp3File.ReadUInt32BE(); + } + + var samplesPerFrame = GetSamplesPerFrame(sampleRate); + var duration = samplesPerFrame * numFrames / sampleRate; + bitrate = (short)(mpegSize / duration / 1024 * 8); + return true; + } + } + else + mp3File.Position -= sideInfoSize + 4; + + bitrate = 0; + return false; + } + + bool TryReadVbriBitrate(out int bitrate) + { + const int VBRIHeader = 0x56425249; + + mp3File.Position += 32; + + if (mp3File.ReadUInt32BE() is VBRIHeader) + { + //VBRI header (rare) + _ = mp3File.ReadBlock(6); + mpegSize = mp3File.ReadUInt32BE(); + var numFrames = mp3File.ReadUInt32BE(); + + var samplesPerFrame = GetSamplesPerFrame(sampleRate); + var duration = samplesPerFrame * numFrames / sampleRate; + bitrate = (short)(mpegSize / duration / 1024 * 8); + return true; + } + bitrate = 0; + return false; + } + #endregion + } + + #region MP3 frame decoding helpers + private static bool SeekToFirstKeyFrame(Stream file) + { + //Frame headers begin with first 11 bits set. + const int MaxSeekBytes = 4096; + var maxPosition = Math.Min(file.Length, file.Position + MaxSeekBytes) - 2; + + while (file.Position < maxPosition) + { + if (file.ReadByte() == 0xff) + { + if ((file.ReadByte() & 0xe0) == 0xe0) + { + file.Position -= 2; + return true; + } + file.Position--; + } + } + return false; + } + + private enum Version + { + Version_2_5, + Reserved, + Version_2, + Version_1 + } + + private enum Layer + { + Reserved, + Layer_3, + Layer_2, + Layer_1 + } + + private static double GetSamplesPerFrame(int sampleRate) => sampleRate >= 32000 ? 1152 : 576; + + private static byte GetSideInfo(bool stereo, Version version) => (stereo, version) switch + { + (true, Version.Version_1) => 32, + (true, Version.Version_2 or Version.Version_2_5) => 17, + (false, Version.Version_1) => 17, + (false, Version.Version_2 or Version.Version_2_5) => 9, + _ => 0, + }; + + private static readonly Dictionary Mp3SampleRateIndex = new() + { + { Version.Version_2_5, [11025, 12000, 8000] }, + { Version.Version_2, [22050, 24000, 16000] }, + { Version.Version_1, [44100, 48000, 32000] }, + }; + + private static readonly Dictionary Mp3BitrateIndex = new() + { + { Version.Version_2_5, [-1, 8,16,24,32,40,48,56, 64, 80, 96,112,128,144,160,-1]}, + { Version.Version_2, [-1, 8,16,24,32,40,48,56, 64, 80, 96,112,128,144,160,-1]}, + { Version.Version_1, [-1,32,40,48,56,64,80,96,112,128,160,192,224,256,320,-1]} + }; + #endregion +} diff --git a/Source/FileLiberator/DownloadDecryptBook.cs b/Source/FileLiberator/DownloadDecryptBook.cs index 8bdae9d0..d04c904d 100644 --- a/Source/FileLiberator/DownloadDecryptBook.cs +++ b/Source/FileLiberator/DownloadDecryptBook.cs @@ -47,7 +47,7 @@ namespace FileLiberator using var downloadOptions = await DownloadOptions.InitiateDownloadAsync(api, Configuration.Instance, libraryBook, cancellationToken); var result = await DownloadAudiobookAsync(api, downloadOptions, cancellationToken); - if (!result.Success || getFirstAudioFile(result.ResultFiles) == default) + if (!result.Success || getFirstAudioFile(result.ResultFiles) is not TempFile audioFile) { // decrypt failed. Delete all output entries but leave the cache files. result.ResultFiles.ForEach(f => FileUtility.SaferDelete(f.FilePath)); @@ -61,6 +61,12 @@ namespace FileLiberator result.ResultFiles.AddRange(getAaxcFiles(result.CacheFiles)); } + //Set the last downloaded information on the book so that it can be used in the naming templates, + //but don't persist it until everything completes successfully (in the finally block) + var audioFormat = GetFileFormatInfo(downloadOptions, audioFile); + var audioVersion = downloadOptions.ContentMetadata.ContentReference.Version; + libraryBook.Book.UserDefinedItem.SetLastDownloaded(Configuration.LibationVersion, audioFormat, audioVersion); + var finalStorageDir = getDestinationDirectory(libraryBook); //post-download tasks done in parallel. @@ -80,14 +86,14 @@ namespace FileLiberator } catch when (!moveFilesTask.IsFaulted) { - //Swallow DownloadCoverArt, SetCoverAsFolderIcon, and SaveMetadataAsync exceptions. + //Swallow DownloadCoverArt, DownloadRecordsAsync, DownloadMetadataAsync, and SetCoverAsFolderIcon exceptions. //Only fail if the downloaded audio files failed to move to Books directory } finally { if (moveFilesTask.IsCompletedSuccessfully && !cancellationToken.IsCancellationRequested) { - libraryBook.UpdateBookStatus(LiberatedStatus.Liberated, Configuration.LibationVersion!); + libraryBook.UpdateBookStatus(LiberatedStatus.Liberated, Configuration.LibationVersion, audioFormat, audioVersion); SetDirectoryTime(libraryBook, finalStorageDir); foreach (var cacheFile in result.CacheFiles.Where(f => File.Exists(f.FilePath))) { @@ -275,6 +281,31 @@ namespace FileLiberator #endregion #region Post-success routines + /// Read the audio format from the audio file's metadata. + public AudioFormat GetFileFormatInfo(DownloadOptions options, TempFile firstAudioFile) + { + try + { + return firstAudioFile.Extension.ToLowerInvariant() switch + { + ".m4b" or ".m4a" or ".mp4" => GetMp4AudioFormat(), + ".mp3" => AudioFormatDecoder.FromMpeg3(firstAudioFile.FilePath), + _ => AudioFormat.Default + }; + } + catch (Exception ex) + { + //Failure to determine output audio format should not be considered a failure to download the book + Serilog.Log.Logger.Error(ex, "Error determining output audio format for {@Book}. File = '{@audioFile}'", options.LibraryBook, firstAudioFile); + return AudioFormat.Default; + } + + AudioFormat GetMp4AudioFormat() + => abDownloader is AaxcDownloadConvertBase converter && converter.AaxFile is AAXClean.Mp4File mp4File + ? AudioFormatDecoder.FromMpeg4(mp4File) + : AudioFormatDecoder.FromMpeg4(firstAudioFile.FilePath); + } + /// Move new files to 'Books' directory /// Return directory if audiobook file(s) were successfully created and can be located on disk. Else null. private void MoveFilesToBooksDir(LibraryBook libraryBook, LongPath destinationDir, List entries, CancellationToken cancellationToken) diff --git a/Source/FileLiberator/DownloadOptions.Factory.cs b/Source/FileLiberator/DownloadOptions.Factory.cs index 95fdacb7..af58d360 100644 --- a/Source/FileLiberator/DownloadOptions.Factory.cs +++ b/Source/FileLiberator/DownloadOptions.Factory.cs @@ -10,7 +10,6 @@ using System.Collections.Generic; using System.IO; using System.Linq; using System.Net.Http; -using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; @@ -112,7 +111,6 @@ public partial class DownloadOptions } } - private static DownloadOptions BuildDownloadOptions(LibraryBook libraryBook, Configuration config, LicenseInfo licInfo) { long chapterStartMs @@ -126,13 +124,6 @@ public partial class DownloadOptions RuntimeLength = TimeSpan.FromMilliseconds(licInfo.ContentMetadata.ChapterInfo.RuntimeLengthMs), }; - if (TryGetAudioInfo(licInfo.ContentMetadata.ContentUrl, out int? bitrate, out int? sampleRate, out int? channels)) - { - dlOptions.LibraryBookDto.BitRate = bitrate; - dlOptions.LibraryBookDto.SampleRate = sampleRate; - dlOptions.LibraryBookDto.Channels = channels; - } - var titleConcat = config.CombineNestedChapterTitles ? ": " : null; var chapters = flattenChapters(licInfo.ContentMetadata.ChapterInfo.Chapters, titleConcat) @@ -159,43 +150,6 @@ public partial class DownloadOptions return dlOptions; } - /// - /// The most reliable way to get these audio file properties is from the filename itself. - /// Using AAXClean to read the metadata works well for everything except AC-4 bitrate. - /// - private static bool TryGetAudioInfo(ContentUrl? contentUrl, out int? bitrate, out int? sampleRate, out int? channels) - { - bitrate = sampleRate = channels = null; - - if (contentUrl?.OfflineUrl is not string url || !Uri.TryCreate(url, default, out var uri)) - return false; - - var file = Path.GetFileName(uri.LocalPath); - - var match = AdrmAudioProperties().Match(file); - if (match.Success) - { - bitrate = int.Parse(match.Groups[1].Value); - sampleRate = int.Parse(match.Groups[2].Value); - channels = int.Parse(match.Groups[3].Value); - return true; - } - else if ((match = WidevineAudioProperties().Match(file)).Success) - { - bitrate = int.Parse(match.Groups[2].Value); - sampleRate = int.Parse(match.Groups[1].Value) * 1000; - channels = match.Groups[3].Value switch - { - "ec3" => 6, - "ac4" => 3, - _ => null - }; - return true; - } - - return false; - } - public static LameConfig GetLameOptions(Configuration config) { LameConfig lameConfig = new() @@ -350,12 +304,4 @@ public partial class DownloadOptions chapters.Remove(chapters[^1]); } } - - static double RelativePercentDifference(long num1, long num2) - => Math.Abs(num1 - num2) / (double)(num1 + num2); - - [GeneratedRegex(@".+_(\d+)_(\d+)-(\w+).mp4", RegexOptions.Singleline | RegexOptions.IgnoreCase)] - private static partial Regex WidevineAudioProperties(); - [GeneratedRegex(@".+_lc_(\d+)_(\d+)_(\d+).aax", RegexOptions.Singleline | RegexOptions.IgnoreCase)] - private static partial Regex AdrmAudioProperties(); } diff --git a/Source/FileLiberator/DownloadOptions.cs b/Source/FileLiberator/DownloadOptions.cs index 514ee2bd..29e7b286 100644 --- a/Source/FileLiberator/DownloadOptions.cs +++ b/Source/FileLiberator/DownloadOptions.cs @@ -82,7 +82,6 @@ namespace FileLiberator // no null/empty check for key/iv. unencrypted files do not have them LibraryBookDto = LibraryBook.ToDto(); - LibraryBookDto.Codec = licInfo.ContentMetadata.ContentReference.Codec; cancellation = config diff --git a/Source/FileLiberator/UtilityExtensions.cs b/Source/FileLiberator/UtilityExtensions.cs index 4a2f6de5..4459f09a 100644 --- a/Source/FileLiberator/UtilityExtensions.cs +++ b/Source/FileLiberator/UtilityExtensions.cs @@ -61,7 +61,13 @@ namespace FileLiberator IsPodcastParent = libraryBook.Book.IsEpisodeParent(), IsPodcast = libraryBook.Book.IsEpisodeChild() || libraryBook.Book.IsEpisodeParent(), - Language = libraryBook.Book.Language + Language = libraryBook.Book.Language, + Codec = libraryBook.Book.UserDefinedItem.LastDownloadedFormat?.CodecString, + BitRate = libraryBook.Book.UserDefinedItem.LastDownloadedFormat?.BitRate, + SampleRate = libraryBook.Book.UserDefinedItem.LastDownloadedFormat?.SampleRate, + Channels = libraryBook.Book.UserDefinedItem.LastDownloadedFormat?.ChannelCount, + LibationVersion = libraryBook.Book.UserDefinedItem.LastDownloadedVersion?.ToString(3), + FileVersion = libraryBook.Book.UserDefinedItem.LastDownloadedFileVersion }; } diff --git a/Source/LibationFileManager/Templates/LibraryBookDto.cs b/Source/LibationFileManager/Templates/LibraryBookDto.cs index dc32fa9c..1c689845 100644 --- a/Source/LibationFileManager/Templates/LibraryBookDto.cs +++ b/Source/LibationFileManager/Templates/LibraryBookDto.cs @@ -34,6 +34,8 @@ public class BookDto public DateTime FileDate { get; set; } = DateTime.Now; public DateTime? DatePublished { get; set; } public string? Language { get; set; } + public string? LibationVersion { get; set; } + public string? FileVersion { get; set; } } public class LibraryBookDto : BookDto diff --git a/Source/LibationFileManager/Templates/TemplateEditor[T].cs b/Source/LibationFileManager/Templates/TemplateEditor[T].cs index e718b7f4..a92b0fe6 100644 --- a/Source/LibationFileManager/Templates/TemplateEditor[T].cs +++ b/Source/LibationFileManager/Templates/TemplateEditor[T].cs @@ -69,6 +69,9 @@ namespace LibationFileManager.Templates Authors = [new("Arthur Conan Doyle", "B000AQ43GQ"), new("Stephen Fry - introductions", "B000APAGVS")], Narrators = [new("Stephen Fry", null)], Series = [new("Sherlock Holmes", 1, "B08376S3R2"), new("Some Other Series", 1, "B000000000")], + Codec = "AAC-LC", + LibationVersion = Configuration.LibationVersion?.ToString(3), + FileVersion = "36217811", BitRate = 128, SampleRate = 44100, Channels = 2, diff --git a/Source/LibationFileManager/Templates/TemplateTags.cs b/Source/LibationFileManager/Templates/TemplateTags.cs index e8cdb1df..9c370983 100644 --- a/Source/LibationFileManager/Templates/TemplateTags.cs +++ b/Source/LibationFileManager/Templates/TemplateTags.cs @@ -36,10 +36,12 @@ namespace LibationFileManager.Templates public static TemplateTags Series { get; } = new TemplateTags("series", "All series to which the book belongs (if any)"); public static TemplateTags FirstSeries { get; } = new TemplateTags("first series", "First series"); public static TemplateTags SeriesNumber { get; } = new TemplateTags("series#", "Number order in series (alias for "); - public static TemplateTags Bitrate { get; } = new TemplateTags("bitrate", "Audiobook's source bitrate"); - public static TemplateTags SampleRate { get; } = new TemplateTags("samplerate", "Audiobook's source sample rate"); - public static TemplateTags Channels { get; } = new TemplateTags("channels", "Audiobook's source audio channel count"); - public static TemplateTags Codec { get; } = new TemplateTags("codec", "Audiobook's source codec"); + public static TemplateTags Bitrate { get; } = new TemplateTags("bitrate", "Bitrate (kbps) of the last downloaded audiobook"); + public static TemplateTags SampleRate { get; } = new TemplateTags("samplerate", "Sample rate (Hz) of the last downloaded audiobook"); + public static TemplateTags Channels { get; } = new TemplateTags("channels", "Number of audio channels in the last downloaded audiobook"); + public static TemplateTags Codec { get; } = new TemplateTags("codec", "Audio codec of the last downloaded audiobook"); + public static TemplateTags FileVersion { get; } = new TemplateTags("file version", "Audible's file version number of the last downloaded audiobook"); + public static TemplateTags LibationVersion { get; } = new TemplateTags("libation version", "Libation version used during last download of the audiobook"); public static TemplateTags Account { get; } = new TemplateTags("account", "Audible account of this book"); public static TemplateTags AccountNickname { get; } = new TemplateTags("account nickname", "Audible account nickname of this book"); public static TemplateTags Locale { get; } = new("locale", "Region/country"); diff --git a/Source/LibationFileManager/Templates/Templates.cs b/Source/LibationFileManager/Templates/Templates.cs index bfba1468..ac0af0fa 100644 --- a/Source/LibationFileManager/Templates/Templates.cs +++ b/Source/LibationFileManager/Templates/Templates.cs @@ -287,6 +287,8 @@ namespace LibationFileManager.Templates { TemplateTags.SampleRate, lb => lb.SampleRate }, { TemplateTags.Channels, lb => lb.Channels }, { TemplateTags.Codec, lb => lb.Codec }, + { TemplateTags.FileVersion, lb => lb.FileVersion }, + { TemplateTags.LibationVersion, lb => lb.LibationVersion }, }; private static readonly List chapterPropertyTags = new() @@ -382,7 +384,7 @@ namespace LibationFileManager.Templates public static string Name { get; } = "Folder Template"; public static string Description { get; } = Configuration.GetDescription(nameof(Configuration.FolderTemplate)) ?? ""; public static string DefaultTemplate { get; } = " [<id>]"; - public static IEnumerable<TagCollection> TagCollections { get; } = [filePropertyTags, conditionalTags, folderConditionalTags]; + public static IEnumerable<TagCollection> TagCollections { get; } = [filePropertyTags, audioFilePropertyTags, conditionalTags, folderConditionalTags]; public override IEnumerable<string> Errors => TemplateText?.Length >= 2 && Path.IsPathFullyQualified(TemplateText) ? base.Errors.Append(ERROR_FULL_PATH_IS_INVALID) : base.Errors; diff --git a/Source/LibationSearchEngine/SearchEngine.cs b/Source/LibationSearchEngine/SearchEngine.cs index 79284bf2..7d62c849 100644 --- a/Source/LibationSearchEngine/SearchEngine.cs +++ b/Source/LibationSearchEngine/SearchEngine.cs @@ -50,6 +50,7 @@ namespace LibationSearchEngine { FieldType.Bool, lb => (lb.Book.UserDefinedItem.Rating.OverallRating > 0f).ToString(), "IsRated", "Rated" }, { FieldType.Bool, lb => isAuthorNarrated(lb.Book).ToString(), "IsAuthorNarrated", "AuthorNarrated" }, { FieldType.Bool, lb => lb.Book.IsAbridged.ToString(), nameof(Book.IsAbridged), "Abridged" }, + { FieldType.Bool, lb => lb.Book.IsSpatial.ToString(), nameof(Book.IsSpatial), "Spatial" }, { FieldType.Bool, lb => (lb.Book.UserDefinedItem.BookStatus == LiberatedStatus.Liberated).ToString(), "IsLiberated", "Liberated" }, { FieldType.Bool, lb => (lb.Book.UserDefinedItem.BookStatus == LiberatedStatus.Error).ToString(), "LiberatedError" }, { FieldType.Bool, lb => lb.Book.IsEpisodeChild().ToString(), "Podcast", "Podcasts", "IsPodcast", "Episode", "Episodes", "IsEpisode" }, diff --git a/Source/LibationUiBase/GridView/LastDownloadStatus.cs b/Source/LibationUiBase/GridView/LastDownloadStatus.cs index 4eda37bf..87972d42 100644 --- a/Source/LibationUiBase/GridView/LastDownloadStatus.cs +++ b/Source/LibationUiBase/GridView/LastDownloadStatus.cs @@ -6,6 +6,8 @@ namespace LibationUiBase.GridView public class LastDownloadStatus : IComparable { public bool IsValid => LastDownloadedVersion is not null && LastDownloaded.HasValue; + public AudioFormat LastDownloadedFormat { get; } + public string LastDownloadedFileVersion { get; } public Version LastDownloadedVersion { get; } public DateTime? LastDownloaded { get; } public string ToolTipText => IsValid ? $"Double click to open v{LastDownloadedVersion.ToString(3)} release notes" : ""; @@ -14,6 +16,8 @@ namespace LibationUiBase.GridView public LastDownloadStatus(UserDefinedItem udi) { LastDownloadedVersion = udi.LastDownloadedVersion; + LastDownloadedFormat = udi.LastDownloadedFormat; + LastDownloadedFileVersion = udi.LastDownloadedFileVersion; LastDownloaded = udi.LastDownloaded; } @@ -24,7 +28,12 @@ namespace LibationUiBase.GridView } public override string ToString() - => IsValid ? $"{dateString()}\n\nLibation v{LastDownloadedVersion.ToString(3)}" : ""; + => IsValid ? $""" + {dateString()} (File v.{LastDownloadedFileVersion}) + {LastDownloadedFormat} + Libation v{LastDownloadedVersion.ToString(3)} + """ : ""; + //Call ToShortDateString to use current culture's date format. private string dateString() => $"{LastDownloaded.Value.ToShortDateString()} {LastDownloaded.Value:HH:mm}"; From c98c7c095aa26e36e7a2bbf4602aced2f190e400 Mon Sep 17 00:00:00 2001 From: MBucari <mbucari1@gmail.com> Date: Fri, 25 Jul 2025 14:22:29 -0600 Subject: [PATCH 07/13] Fix quickfilter modification bug (#1313) --- Source/LibationAvalonia/ViewModels/MainVM.Filters.cs | 3 +-- Source/LibationAvalonia/Views/MainWindow.axaml | 4 ++-- Source/LibationAvalonia/Views/MainWindow.axaml.cs | 6 +++++- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/Source/LibationAvalonia/ViewModels/MainVM.Filters.cs b/Source/LibationAvalonia/ViewModels/MainVM.Filters.cs index 4f8ac7d2..2809585c 100644 --- a/Source/LibationAvalonia/ViewModels/MainVM.Filters.cs +++ b/Source/LibationAvalonia/ViewModels/MainVM.Filters.cs @@ -27,7 +27,6 @@ namespace LibationAvalonia.ViewModels /// <summary> Indicates if the first quick filter is the default filter </summary> public bool FirstFilterIsDefault { get => _firstFilterIsDefault; set => QuickFilters.UseDefault = this.RaiseAndSetIfChanged(ref _firstFilterIsDefault, value); } - private void Configure_Filters() { FirstFilterIsDefault = QuickFilters.UseDefault; @@ -55,7 +54,7 @@ namespace LibationAvalonia.ViewModels } public void AddQuickFilterBtn() { if (SelectedNamedFilter != null) QuickFilters.Add(SelectedNamedFilter); } - public async Task FilterBtn() => await PerformFilter(SelectedNamedFilter); + public async Task FilterBtn(string filterString) => await PerformFilter(new(filterString, null)); public async Task FilterHelpBtn() => await new LibationAvalonia.Dialogs.SearchSyntaxDialog().ShowDialog(MainWindow); public void ToggleFirstFilterIsDefault() => FirstFilterIsDefault = !FirstFilterIsDefault; public async Task EditQuickFiltersAsync() => await new LibationAvalonia.Dialogs.EditQuickFilters().ShowDialog(MainWindow); diff --git a/Source/LibationAvalonia/Views/MainWindow.axaml b/Source/LibationAvalonia/Views/MainWindow.axaml index 9542b089..d34ec041 100644 --- a/Source/LibationAvalonia/Views/MainWindow.axaml +++ b/Source/LibationAvalonia/Views/MainWindow.axaml @@ -191,10 +191,10 @@ <Button IsVisible="{CompiledBinding RemoveButtonsVisible}" Command="{CompiledBinding DoneRemovingBtn}" Content="Done Removing Books"/> </StackPanel> - <TextBox Grid.Column="1" Margin="10,0,0,0" Name="filterSearchTb" IsVisible="{CompiledBinding !RemoveButtonsVisible}" Text="{CompiledBinding SelectedNamedFilter.Filter, Mode=TwoWay}" KeyDown="filterSearchTb_KeyPress" /> + <TextBox Grid.Column="1" Margin="10,0,0,0" Name="filterSearchTb" IsVisible="{CompiledBinding !RemoveButtonsVisible}" Text="{CompiledBinding SelectedNamedFilter.Filter, Mode=OneWay}" KeyDown="filterSearchTb_KeyPress" /> <StackPanel Grid.Column="2" Height="30" Orientation="Horizontal"> - <Button Name="filterBtn" Command="{CompiledBinding FilterBtn}" VerticalAlignment="Stretch" HorizontalContentAlignment="Center" VerticalContentAlignment="Center" Content="Filter"/> + <Button Name="filterBtn" Command="{CompiledBinding FilterBtn}" CommandParameter="{CompiledBinding #filterSearchTb.Text}" VerticalAlignment="Stretch" HorizontalContentAlignment="Center" VerticalContentAlignment="Center" Content="Filter"/> <Button Padding="2,6,2,6" VerticalAlignment="Stretch" Command="{CompiledBinding ToggleQueueHideBtn}"> <Path Stretch="Uniform" Fill="{DynamicResource IconFill}" Data="{StaticResource LeftArrows}"> <Path.RenderTransform> diff --git a/Source/LibationAvalonia/Views/MainWindow.axaml.cs b/Source/LibationAvalonia/Views/MainWindow.axaml.cs index ee435f08..771b6916 100644 --- a/Source/LibationAvalonia/Views/MainWindow.axaml.cs +++ b/Source/LibationAvalonia/Views/MainWindow.axaml.cs @@ -1,4 +1,5 @@ using AudibleUtilities; +using Avalonia.Controls; using Avalonia.Input; using Avalonia.ReactiveUI; using Avalonia.Threading; @@ -21,6 +22,9 @@ namespace LibationAvalonia.Views { public MainWindow() { + if (Design.IsDesignMode) + _ = Configuration.Instance.LibationFiles; + DataContext = new MainVM(this); ApiExtended.LoginChoiceFactory = account => Dispatcher.UIThread.Invoke(() => new Dialogs.Login.AvaloniaLoginChoiceEager(account)); @@ -156,7 +160,7 @@ namespace LibationAvalonia.Views { if (e.Key == Key.Return) { - await ViewModel.PerformFilter(ViewModel.SelectedNamedFilter); + await ViewModel.FilterBtn(filterSearchTb.Text); // silence the 'ding' e.Handled = true; From accedeb1b1f576febc639ff704f72d8205387e24 Mon Sep 17 00:00:00 2001 From: MBucari <mbucari1@gmail.com> Date: Fri, 25 Jul 2025 14:23:14 -0600 Subject: [PATCH 08/13] Improve EditQuickFilters dialog reordering behavior --- .../Dialogs/EditQuickFilters.axaml | 38 +++++----- .../Dialogs/EditQuickFilters.axaml.cs | 76 +++++++++++++------ 2 files changed, 73 insertions(+), 41 deletions(-) diff --git a/Source/LibationAvalonia/Dialogs/EditQuickFilters.axaml b/Source/LibationAvalonia/Dialogs/EditQuickFilters.axaml index 82ae89cc..68f41b76 100644 --- a/Source/LibationAvalonia/Dialogs/EditQuickFilters.axaml +++ b/Source/LibationAvalonia/Dialogs/EditQuickFilters.axaml @@ -24,9 +24,8 @@ CanUserSortColumns="False" AutoGenerateColumns="False" IsReadOnly="False" - ItemsSource="{Binding Filters}" + ItemsSource="{CompiledBinding Filters}" GridLinesVisibility="All"> - <DataGrid.Columns> <DataGridTemplateColumn Header="Delete"> @@ -38,7 +37,7 @@ VerticalAlignment="Stretch" HorizontalAlignment="Stretch" HorizontalContentAlignment="Center" - IsEnabled="{Binding !IsDefault}" + IsEnabled="{CompiledBinding !IsDefault}" Click="DeleteButton_Clicked" /> </DataTemplate> @@ -48,14 +47,13 @@ <DataGridTextColumn Width="*" IsReadOnly="False" - Binding="{Binding Name, Mode=TwoWay}" + Binding="{CompiledBinding Name, Mode=TwoWay}" Header="Name"/> - <DataGridTextColumn Width="*" IsReadOnly="False" - Binding="{Binding FilterString, Mode=TwoWay}" + Binding="{CompiledBinding FilterString, Mode=TwoWay}" Header="Filter"/> <DataGridTemplateColumn Header="Move Up"> @@ -67,16 +65,19 @@ VerticalAlignment="Stretch" HorizontalAlignment="Stretch" HorizontalContentAlignment="Center" - IsEnabled="{Binding !IsDefault}" - ToolTip.Tip="Export account authorization to audible-cli" - Click="MoveUpButton_Clicked" /> + Click="MoveUpButton_Clicked"> + <Button.IsEnabled> + <MultiBinding Converter="{x:Static BoolConverters.And}"> + <CompiledBinding Path="!IsTop" /> + <CompiledBinding Path="!IsDefault" /> + </MultiBinding> + </Button.IsEnabled> + </Button> </DataTemplate> </DataGridTemplateColumn.CellTemplate> </DataGridTemplateColumn> - - - + <DataGridTemplateColumn Header="Move Down"> <DataGridTemplateColumn.CellTemplate> <DataTemplate> @@ -86,15 +87,18 @@ VerticalAlignment="Stretch" HorizontalAlignment="Stretch" HorizontalContentAlignment="Center" - IsEnabled="{Binding !IsDefault}" - ToolTip.Tip="Export account authorization to audible-cli" - Click="MoveDownButton_Clicked" /> + Click="MoveDownButton_Clicked"> + <Button.IsEnabled> + <MultiBinding Converter="{x:Static BoolConverters.And}"> + <CompiledBinding Path="!IsBottom" /> + <CompiledBinding Path="!IsDefault" /> + </MultiBinding> + </Button.IsEnabled> + </Button> </DataTemplate> </DataGridTemplateColumn.CellTemplate> </DataGridTemplateColumn> - - </DataGrid.Columns> </DataGrid> <Grid diff --git a/Source/LibationAvalonia/Dialogs/EditQuickFilters.axaml.cs b/Source/LibationAvalonia/Dialogs/EditQuickFilters.axaml.cs index 48b4a14d..65c21922 100644 --- a/Source/LibationAvalonia/Dialogs/EditQuickFilters.axaml.cs +++ b/Source/LibationAvalonia/Dialogs/EditQuickFilters.axaml.cs @@ -1,15 +1,15 @@ using AudibleUtilities; +using Avalonia.Collections; using Avalonia.Controls; using LibationFileManager; using ReactiveUI; -using System.Collections.ObjectModel; using System.Linq; namespace LibationAvalonia.Dialogs { public partial class EditQuickFilters : DialogWindow { - public ObservableCollection<Filter> Filters { get; } = new(); + public AvaloniaList<Filter> Filters { get; } = new(); public class Filter : ViewModels.ViewModelBase { @@ -17,11 +17,8 @@ namespace LibationAvalonia.Dialogs public string Name { get => _name; - set - { - this.RaiseAndSetIfChanged(ref _name, value); - } - } + set => this.RaiseAndSetIfChanged(ref _name, value); + } private string _filterString; public string FilterString @@ -35,6 +32,10 @@ namespace LibationAvalonia.Dialogs } } public bool IsDefault { get; private set; } = true; + private bool _isTop; + private bool _isBottom; + public bool IsTop { get => _isTop; set => this.RaiseAndSetIfChanged(ref _isTop, value); } + public bool IsBottom { get => _isBottom; set => this.RaiseAndSetIfChanged(ref _isBottom, value); } public QuickFilters.NamedFilter AsNamedFilter() => new(FilterString, Name); @@ -44,12 +45,12 @@ namespace LibationAvalonia.Dialogs InitializeComponent(); if (Design.IsDesignMode) { - Filters = new ObservableCollection<Filter>([ - new Filter { Name = "Filter 1", FilterString = "[filter1 string]" }, + Filters = [ + new Filter { Name = "Filter 1", FilterString = "[filter1 string]", IsTop = true }, new Filter { Name = "Filter 2", FilterString = "[filter2 string]" }, new Filter { Name = "Filter 3", FilterString = "[filter3 string]" }, - new Filter { Name = "Filter 4", FilterString = "[filter4 string]" } - ]); + new Filter { Name = "Filter 4", FilterString = "[filter4 string]", IsBottom = true }, + new Filter()]; DataContext = this; return; } @@ -65,6 +66,8 @@ namespace LibationAvalonia.Dialogs ControlToFocusOnShow = this.FindControl<Button>(nameof(saveBtn)); var allFilters = QuickFilters.Filters.Select(f => new Filter { FilterString = f.Filter, Name = f.Name }).ToList(); + allFilters[0].IsTop = true; + allFilters[^1].IsBottom = true; allFilters.Add(new Filter()); foreach (var f in allFilters) @@ -81,6 +84,7 @@ namespace LibationAvalonia.Dialogs var newBlank = new Filter(); newBlank.PropertyChanged += Filter_PropertyChanged; Filters.Insert(Filters.Count, newBlank); + ReIndexFilters(); } protected override void SaveAndClose() @@ -98,30 +102,54 @@ namespace LibationAvalonia.Dialogs filter.PropertyChanged -= Filter_PropertyChanged; Filters.Remove(filter); + ReIndexFilters(); } } public void MoveUpButton_Clicked(object sender, Avalonia.Interactivity.RoutedEventArgs e) { - if (e.Source is Button btn && btn.DataContext is Filter filter) - { - var index = Filters.IndexOf(filter); - if (index < 1) return; + if (e.Source is not Button btn || btn.DataContext is not Filter filter || filter.IsDefault) + return; - Filters.Remove(filter); - Filters.Insert(index - 1, filter); - } + var oldIndex = Filters.IndexOf(filter); + if (oldIndex < 1) return; + + var filterCount = Filters.Count(f => !f.IsDefault); + + MoveFilter(oldIndex, oldIndex - 1, filterCount); } public void MoveDownButton_Clicked(object sender, Avalonia.Interactivity.RoutedEventArgs e) { - if (e.Source is Button btn && btn.DataContext is Filter filter) - { - var index = Filters.IndexOf(filter); - if (index >= Filters.Count - 2) return; + if (e.Source is not Button btn || btn.DataContext is not Filter filter || filter.IsDefault) + return; - Filters.Remove(filter); - Filters.Insert(index + 1, filter); + var filterCount = Filters.Count(f => !f.IsDefault); + var oldIndex = Filters.IndexOf(filter); + if (oldIndex >= filterCount - 1) return; + + MoveFilter(oldIndex, oldIndex + 1, filterCount); + } + + private void MoveFilter(int oldIndex, int newIndex, int filterCount) + { + var filter = Filters[oldIndex]; + Filters.RemoveAt(oldIndex); + Filters.Insert(newIndex, filter); + + Filters[oldIndex].IsTop = oldIndex == 0; + Filters[newIndex].IsTop = newIndex == 0; + Filters[newIndex].IsBottom = newIndex == filterCount - 1; + Filters[oldIndex].IsBottom = oldIndex == filterCount - 1; + } + + private void ReIndexFilters() + { + var filterCount = Filters.Count(f => !f.IsDefault); + for (int i = filterCount - 1; i >= 0; i--) + { + Filters[i].IsTop = i == 0; + Filters[i].IsBottom = i == filterCount - 1; } } } From b27325cdcbc2d78657384e8096742a07fde77027 Mon Sep 17 00:00:00 2001 From: MBucari <mbucari1@gmail.com> Date: Fri, 25 Jul 2025 15:35:03 -0600 Subject: [PATCH 09/13] Improve comvert to mp3 task - Improve progress reporting and cancellation performance - Clear current book from queue before queueing single convert to mp3 task --- Source/FileLiberator/ConvertToMp3.cs | 110 +++++++++++------- .../ProcessQueue/ProcessQueueViewModel.cs | 2 + 2 files changed, 72 insertions(+), 40 deletions(-) diff --git a/Source/FileLiberator/ConvertToMp3.cs b/Source/FileLiberator/ConvertToMp3.cs index 2639c23f..6fc97d7c 100644 --- a/Source/FileLiberator/ConvertToMp3.cs +++ b/Source/FileLiberator/ConvertToMp3.cs @@ -1,6 +1,7 @@ using System; using System.IO; using System.Linq; +using System.Threading; using System.Threading.Tasks; using AAXClean; using AAXClean.Codecs; @@ -19,7 +20,13 @@ namespace FileLiberator private readonly AaxDecrypter.AverageSpeed averageSpeed = new(); private static string Mp3FileName(string m4bPath) => Path.ChangeExtension(m4bPath ?? "", ".mp3"); - public override Task CancelAsync() => Mp4Operation?.CancelAsync() ?? Task.CompletedTask; + private CancellationTokenSource CancellationTokenSource { get; set; } + public override async Task CancelAsync() + { + await CancellationTokenSource.CancelAsync(); + if (Mp4Operation is not null) + await Mp4Operation.CancelAsync(); + } public static bool ValidateMp3(LibraryBook libraryBook) { @@ -32,17 +39,29 @@ namespace FileLiberator public override async Task<StatusHandler> ProcessAsync(LibraryBook libraryBook) { OnBegin(libraryBook); + var cancellationToken = (CancellationTokenSource = new()).Token; try { - var m4bPaths = AudibleFileStorage.Audio.GetPaths(libraryBook.Book.AudibleProductId); + var m4bPaths = AudibleFileStorage.Audio.GetPaths(libraryBook.Book.AudibleProductId) + .Where(m4bPath => File.Exists(m4bPath)) + .Select(m4bPath => new { m4bPath, proposedMp3Path = Mp3FileName(m4bPath), m4bSize = new FileInfo(m4bPath).Length }) + .Where(p => !File.Exists(p.proposedMp3Path)) + .ToArray(); - foreach (var m4bPath in m4bPaths) + long totalInputSize = m4bPaths.Sum(p => p.m4bSize); + long sizeOfCompletedFiles = 0L; + foreach (var entry in m4bPaths) { - var proposedMp3Path = Mp3FileName(m4bPath); - if (File.Exists(proposedMp3Path) || !File.Exists(m4bPath)) continue; + cancellationToken.ThrowIfCancellationRequested(); + if (File.Exists(entry.proposedMp3Path) || !File.Exists(entry.m4bPath)) + { + sizeOfCompletedFiles += entry.m4bSize; + continue; + } - var m4bBook = await Task.Run(() => new Mp4File(m4bPath, FileAccess.Read)); + using var m4bFileStream = File.Open(entry.m4bPath, FileMode.Open, FileAccess.Read, FileShare.Read); + var m4bBook = new Mp4File(m4bFileStream); //AAXClean.Codecs only supports decoding AAC and E-AC-3 audio. if (m4bBook.AudioSampleEntry.Esds is null && m4bBook.AudioSampleEntry.Dec3 is null) @@ -69,74 +88,85 @@ namespace FileLiberator lameConfig.ID3.Track = trackCount > 0 ? $"{trackNum}/{trackCount}" : trackNum.ToString(); } - using var mp3File = File.Open(Path.GetTempFileName(), FileMode.OpenOrCreate, FileAccess.ReadWrite); + long currentFileNumBytesProcessed = 0; try { - Mp4Operation = m4bBook.ConvertToMp3Async(mp3File, lameConfig, chapters); - Mp4Operation.ConversionProgressUpdate += M4bBook_ConversionProgressUpdate; - await Mp4Operation; - - if (Mp4Operation.IsCanceled) + var tempPath = Path.GetTempFileName(); + using (var mp3File = File.Open(tempPath, FileMode.OpenOrCreate, FileAccess.ReadWrite)) { - FileUtility.SaferDelete(mp3File.Name); - return new StatusHandler { "Cancelled" }; + Mp4Operation = m4bBook.ConvertToMp3Async(mp3File, lameConfig, chapters); + Mp4Operation.ConversionProgressUpdate += m4bBook_ConversionProgressUpdate; + await Mp4Operation; } - else - { - var realMp3Path + + if (cancellationToken.IsCancellationRequested) + FileUtility.SaferDelete(tempPath); + + cancellationToken.ThrowIfCancellationRequested(); + + var realMp3Path = FileUtility.SaferMoveToValidPath( - mp3File.Name, - proposedMp3Path, + tempPath, + entry.proposedMp3Path, Configuration.Instance.ReplacementCharacters, extension: "mp3", Configuration.Instance.OverwriteExisting); - SetFileTime(libraryBook, realMp3Path); - SetDirectoryTime(libraryBook, Path.GetDirectoryName(realMp3Path)); - - OnFileCreated(libraryBook, realMp3Path); - } - } - catch (Exception ex) - { - Serilog.Log.Error(ex, "AAXClean error"); - return new StatusHandler { "Conversion failed" }; + SetFileTime(libraryBook, realMp3Path); + SetDirectoryTime(libraryBook, Path.GetDirectoryName(realMp3Path)); + OnFileCreated(libraryBook, realMp3Path); } finally { if (Mp4Operation is not null) - Mp4Operation.ConversionProgressUpdate -= M4bBook_ConversionProgressUpdate; + Mp4Operation.ConversionProgressUpdate -= m4bBook_ConversionProgressUpdate; - m4bBook.InputStream.Close(); - mp3File.Close(); + sizeOfCompletedFiles += entry.m4bSize; + } + void m4bBook_ConversionProgressUpdate(object sender, ConversionProgressEventArgs e) + { + currentFileNumBytesProcessed = (long)(e.FractionCompleted * entry.m4bSize); + var bytesCompleted = sizeOfCompletedFiles + currentFileNumBytesProcessed; + ConversionProgressUpdate(totalInputSize, bytesCompleted); } } + return new StatusHandler(); + } + catch (Exception ex) + { + if (!cancellationToken.IsCancellationRequested) + { + Serilog.Log.Error(ex, "AAXClean error"); + return new StatusHandler { "Conversion failed" }; + } + return new StatusHandler { "Cancelled" }; } finally { OnCompleted(libraryBook); + CancellationTokenSource.Dispose(); + CancellationTokenSource = null; } - return new StatusHandler(); } - private void M4bBook_ConversionProgressUpdate(object sender, ConversionProgressEventArgs e) + private void ConversionProgressUpdate(long totalInputSize, long bytesCompleted) { - averageSpeed.AddPosition(e.ProcessPosition.TotalSeconds); + averageSpeed.AddPosition(bytesCompleted); - var remainingTimeToProcess = (e.EndTime - e.ProcessPosition).TotalSeconds; - var estTimeRemaining = remainingTimeToProcess / averageSpeed.Average; + var remainingBytes = (totalInputSize - bytesCompleted); + var estTimeRemaining = remainingBytes / averageSpeed.Average; if (double.IsNormal(estTimeRemaining)) OnStreamingTimeRemaining(TimeSpan.FromSeconds(estTimeRemaining)); - double progressPercent = 100 * e.FractionCompleted; + double progressPercent = 100 * bytesCompleted / totalInputSize; OnStreamingProgressChanged( new DownloadProgress { ProgressPercentage = progressPercent, - BytesReceived = (long)(e.ProcessPosition - e.StartTime).TotalSeconds, - TotalBytesToReceive = (long)(e.EndTime - e.StartTime).TotalSeconds + BytesReceived = bytesCompleted, + TotalBytesToReceive = totalInputSize }); } } diff --git a/Source/LibationUiBase/ProcessQueue/ProcessQueueViewModel.cs b/Source/LibationUiBase/ProcessQueue/ProcessQueueViewModel.cs index d3172b40..b9c1619a 100644 --- a/Source/LibationUiBase/ProcessQueue/ProcessQueueViewModel.cs +++ b/Source/LibationUiBase/ProcessQueue/ProcessQueueViewModel.cs @@ -111,6 +111,8 @@ public class ProcessQueueViewModel : ReactiveObject var preLiberated = libraryBooks.Where(lb => !lb.AbsentFromLastScan && lb.Book.UserDefinedItem.BookStatus is LiberatedStatus.Liberated && lb.Book.ContentType is DataLayer.ContentType.Product).ToArray(); if (preLiberated.Length > 0) { + if (preLiberated.Length == 1) + RemoveCompleted(preLiberated[0]); Serilog.Log.Logger.Information("Begin convert {count} books to mp3", preLiberated.Length); AddConvertMp3(preLiberated); return true; From 7088bd4b8dad089512caa83142cd6e101e983b80 Mon Sep 17 00:00:00 2001 From: MBucari <mbucari1@gmail.com> Date: Fri, 25 Jul 2025 15:49:41 -0600 Subject: [PATCH 10/13] Check for file existance --- Source/FileLiberator/DownloadDecryptBook.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Source/FileLiberator/DownloadDecryptBook.cs b/Source/FileLiberator/DownloadDecryptBook.cs index d04c904d..cd143a7a 100644 --- a/Source/FileLiberator/DownloadDecryptBook.cs +++ b/Source/FileLiberator/DownloadDecryptBook.cs @@ -504,9 +504,9 @@ namespace FileLiberator private static FileType getFileType(TempFile file) => FileTypes.GetFileTypeFromPath(file.FilePath); private static TempFile? getFirstAudioFile(IEnumerable<TempFile> entries) - => entries.FirstOrDefault(f => getFileType(f) is FileType.Audio); + => entries.FirstOrDefault(f => File.Exists(f.FilePath) && getFileType(f) is FileType.Audio); private static IEnumerable<TempFile> getAaxcFiles(IEnumerable<TempFile> entries) - => entries.Where(f => getFileType(f) is FileType.AAXC || f.Extension.Equals(".key", StringComparison.OrdinalIgnoreCase)); + => entries.Where(f => File.Exists(f.FilePath) && (getFileType(f) is FileType.AAXC || f.Extension.Equals(".key", StringComparison.OrdinalIgnoreCase))); #endregion } } From a09ae1316d959f86eb907c1d1c55dbe686bc55b9 Mon Sep 17 00:00:00 2001 From: MBucari <mbucari1@gmail.com> Date: Fri, 25 Jul 2025 16:01:48 -0600 Subject: [PATCH 11/13] Don't display null file versions --- Source/LibationUiBase/GridView/LastDownloadStatus.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Source/LibationUiBase/GridView/LastDownloadStatus.cs b/Source/LibationUiBase/GridView/LastDownloadStatus.cs index 87972d42..be77d007 100644 --- a/Source/LibationUiBase/GridView/LastDownloadStatus.cs +++ b/Source/LibationUiBase/GridView/LastDownloadStatus.cs @@ -29,11 +29,12 @@ namespace LibationUiBase.GridView public override string ToString() => IsValid ? $""" - {dateString()} (File v.{LastDownloadedFileVersion}) + {dateString()} {versionString()} {LastDownloadedFormat} Libation v{LastDownloadedVersion.ToString(3)} """ : ""; - + + private string versionString() => LastDownloadedFileVersion is string ver ? $"(File v.{ver})" : ""; //Call ToShortDateString to use current culture's date format. private string dateString() => $"{LastDownloaded.Value.ToShortDateString()} {LastDownloaded.Value:HH:mm}"; From 53eebcd6bac066a69588e66dae1717ea157a7a6a Mon Sep 17 00:00:00 2001 From: MBucari <mbucari1@gmail.com> Date: Fri, 25 Jul 2025 16:02:28 -0600 Subject: [PATCH 12/13] Use single file downloader/namer if file has only 1 chapter --- Source/FileLiberator/DownloadDecryptBook.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Source/FileLiberator/DownloadDecryptBook.cs b/Source/FileLiberator/DownloadDecryptBook.cs index cd143a7a..de4d35d1 100644 --- a/Source/FileLiberator/DownloadDecryptBook.cs +++ b/Source/FileLiberator/DownloadDecryptBook.cs @@ -133,7 +133,7 @@ namespace FileLiberator else { AaxcDownloadConvertBase converter - = dlOptions.Config.SplitFilesByChapter ? + = dlOptions.Config.SplitFilesByChapter && dlOptions.ChapterInfo.Count > 1 ? new AaxcDownloadMultiConverter(outpoutDir, cacheDir, dlOptions) : new AaxcDownloadSingleConverter(outpoutDir, cacheDir, dlOptions); From a887bf46199a0e92415f7d48b5086499f96d88fa Mon Sep 17 00:00:00 2001 From: MBucari <mbucari1@gmail.com> Date: Sat, 26 Jul 2025 18:13:36 -0600 Subject: [PATCH 13/13] Add "Is Spatial" grid column. --- .../ViewModels/ProductsDisplayViewModel.cs | 1 + .../Views/ProductsDisplay.axaml | 42 ++++++++----- .../Views/ProductsDisplay.axaml.cs | 3 +- .../Configuration.PersistentSettings.cs | 12 ++-- .../{GridEntry[TStatus].cs => GridEntry.cs} | 5 +- .../GridView/ProductsGrid.Designer.cs | 61 ++++++++++--------- .../LibationWinForms/GridView/ProductsGrid.cs | 3 +- .../GridView/ProductsGrid.resx | 9 ++- 8 files changed, 80 insertions(+), 56 deletions(-) rename Source/LibationUiBase/GridView/{GridEntry[TStatus].cs => GridEntry.cs} (98%) diff --git a/Source/LibationAvalonia/ViewModels/ProductsDisplayViewModel.cs b/Source/LibationAvalonia/ViewModels/ProductsDisplayViewModel.cs index 304d8d21..f51a3044 100644 --- a/Source/LibationAvalonia/ViewModels/ProductsDisplayViewModel.cs +++ b/Source/LibationAvalonia/ViewModels/ProductsDisplayViewModel.cs @@ -479,6 +479,7 @@ namespace LibationAvalonia.ViewModels public DataGridLength MiscWidth { get => getColumnWidth("Misc", 140); set => setColumnWidth("Misc", value); } public DataGridLength LastDownloadWidth { get => getColumnWidth("LastDownload", 100); set => setColumnWidth("LastDownload", value); } public DataGridLength BookTagsWidth { get => getColumnWidth("BookTags", 100); set => setColumnWidth("BookTags", value); } + public DataGridLength IsSpatialWidth { get => getColumnWidth("IsSpatial", 100); set => setColumnWidth("IsSpatial", value); } private static DataGridLength getColumnWidth(string columnName, double defaultWidth) => Configuration.Instance.GridColumnsWidths.TryGetValue(columnName, out var val) diff --git a/Source/LibationAvalonia/Views/ProductsDisplay.axaml b/Source/LibationAvalonia/Views/ProductsDisplay.axaml index 739c15d7..8f2ec9c2 100644 --- a/Source/LibationAvalonia/Views/ProductsDisplay.axaml +++ b/Source/LibationAvalonia/Views/ProductsDisplay.axaml @@ -3,9 +3,11 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:views="clr-namespace:LibationAvalonia.Views" + xmlns:vm="clr-namespace:LibationAvalonia.ViewModels" xmlns:uibase="clr-namespace:LibationUiBase.GridView;assembly=LibationUiBase" xmlns:controls="clr-namespace:LibationAvalonia.Controls" mc:Ignorable="d" d:DesignWidth="1560" d:DesignHeight="400" + x:DataType="vm:ProductsDisplayViewModel" x:Class="LibationAvalonia.Views.ProductsDisplay"> <Grid> @@ -15,7 +17,7 @@ ClipboardCopyMode="IncludeHeader" GridLinesVisibility="All" AutoGenerateColumns="False" - ItemsSource="{Binding GridEntries}" + ItemsSource="{CompiledBinding GridEntries}" CanUserSortColumns="True" BorderThickness="3" CanUserResizeColumns="True" LoadingRow="ProductsDisplay_LoadingRow" @@ -51,7 +53,7 @@ <DataGridTemplateColumn CanUserSort="True" CanUserResize="False" - IsVisible="{Binding RemoveColumnVisible}" + IsVisible="{CompiledBinding RemoveColumnVisible}" PropertyChanged="RemoveColumn_PropertyChanged" Header="Remove" IsReadOnly="False" @@ -83,7 +85,7 @@ </DataGridTemplateColumn.CellTemplate> </controls:DataGridTemplateColumnExt> - <controls:DataGridTemplateColumnExt CanUserResize="False" CanUserSort="False" Header="Cover" SortMemberPath="Cover" ClipboardContentBinding="{Binding LibraryBook.Book.PictureLarge}"> + <controls:DataGridTemplateColumnExt Header="Cover" CanUserResize="False" CanUserSort="False" SortMemberPath="Cover" ClipboardContentBinding="{Binding LibraryBook.Book.PictureLarge}"> <DataGridTemplateColumn.CellTemplate> <DataTemplate x:DataType="uibase:GridEntry"> <Image Opacity="{CompiledBinding Liberate.Opacity}" Tapped="Cover_Click" Source="{CompiledBinding Cover}" ToolTip.Tip="Click to see full size" /> @@ -91,7 +93,7 @@ </DataGridTemplateColumn.CellTemplate> </controls:DataGridTemplateColumnExt> - <controls:DataGridTemplateColumnExt Header="Title" MinWidth="10" Width="{Binding TitleWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="Title" ClipboardContentBinding="{Binding Title}"> + <controls:DataGridTemplateColumnExt Header="Title" MinWidth="10" Width="{CompiledBinding TitleWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="Title" ClipboardContentBinding="{Binding Title}"> <DataGridTemplateColumn.CellTemplate> <DataTemplate x:DataType="uibase:GridEntry"> <Panel Opacity="{CompiledBinding Liberate.Opacity}"> @@ -101,7 +103,7 @@ </DataGridTemplateColumn.CellTemplate> </controls:DataGridTemplateColumnExt> - <controls:DataGridTemplateColumnExt Header="Authors" MinWidth="10" Width="{Binding AuthorsWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="Authors" ClipboardContentBinding="{Binding Authors}"> + <controls:DataGridTemplateColumnExt Header="Authors" MinWidth="10" Width="{CompiledBinding AuthorsWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="Authors" ClipboardContentBinding="{Binding Authors}"> <DataGridTemplateColumn.CellTemplate> <DataTemplate x:DataType="uibase:GridEntry"> <Panel Opacity="{CompiledBinding Liberate.Opacity}"> @@ -111,7 +113,7 @@ </DataGridTemplateColumn.CellTemplate> </controls:DataGridTemplateColumnExt> - <controls:DataGridTemplateColumnExt Header="Narrators" MinWidth="10" Width="{Binding NarratorsWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="Narrators" ClipboardContentBinding="{Binding Narrators}"> + <controls:DataGridTemplateColumnExt Header="Narrators" MinWidth="10" Width="{CompiledBinding NarratorsWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="Narrators" ClipboardContentBinding="{Binding Narrators}"> <DataGridTemplateColumn.CellTemplate> <DataTemplate x:DataType="uibase:GridEntry"> <Panel Opacity="{CompiledBinding Liberate.Opacity}"> @@ -121,7 +123,7 @@ </DataGridTemplateColumn.CellTemplate> </controls:DataGridTemplateColumnExt> - <controls:DataGridTemplateColumnExt Header="Length" MinWidth="10" Width="{Binding LengthWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="Length" ClipboardContentBinding="{Binding Length}"> + <controls:DataGridTemplateColumnExt Header="Length" MinWidth="10" Width="{CompiledBinding LengthWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="Length" ClipboardContentBinding="{Binding Length}"> <DataGridTemplateColumn.CellTemplate> <DataTemplate x:DataType="uibase:GridEntry"> <Panel Opacity="{CompiledBinding Liberate.Opacity}"> @@ -131,7 +133,7 @@ </DataGridTemplateColumn.CellTemplate> </controls:DataGridTemplateColumnExt> - <controls:DataGridTemplateColumnExt Header="Series" MinWidth="10" Width="{Binding SeriesWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="Series" ClipboardContentBinding="{Binding Series}"> + <controls:DataGridTemplateColumnExt Header="Series" MinWidth="10" Width="{CompiledBinding SeriesWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="Series" ClipboardContentBinding="{Binding Series}"> <DataGridTemplateColumn.CellTemplate> <DataTemplate x:DataType="uibase:GridEntry"> <Panel Opacity="{CompiledBinding Liberate.Opacity}"> @@ -141,7 +143,7 @@ </DataGridTemplateColumn.CellTemplate> </controls:DataGridTemplateColumnExt> - <controls:DataGridTemplateColumnExt Header="Series Order" MinWidth="10" Width="{Binding SeriesOrderWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="SeriesOrder" ClipboardContentBinding="{Binding Series}"> + <controls:DataGridTemplateColumnExt Header="Series Order" MinWidth="10" Width="{CompiledBinding SeriesOrderWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="SeriesOrder" ClipboardContentBinding="{Binding Series}"> <DataGridTemplateColumn.CellTemplate> <DataTemplate x:DataType="uibase:GridEntry"> <Panel Opacity="{CompiledBinding Liberate.Opacity}"> @@ -151,7 +153,7 @@ </DataGridTemplateColumn.CellTemplate> </controls:DataGridTemplateColumnExt> - <controls:DataGridTemplateColumnExt Header="Description" MinWidth="10" Width="{Binding DescriptionWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="Description" ClipboardContentBinding="{Binding Description}"> + <controls:DataGridTemplateColumnExt Header="Description" MinWidth="10" Width="{CompiledBinding DescriptionWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="Description" ClipboardContentBinding="{Binding Description}"> <DataGridTemplateColumn.CellTemplate> <DataTemplate x:DataType="uibase:GridEntry"> <Panel Opacity="{CompiledBinding Liberate.Opacity}" Tapped="Description_Click" ToolTip.Tip="Click to see full description" > @@ -161,7 +163,7 @@ </DataGridTemplateColumn.CellTemplate> </controls:DataGridTemplateColumnExt> - <controls:DataGridTemplateColumnExt Header="Category" MinWidth="10" Width="{Binding CategoryWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="Category" ClipboardContentBinding="{Binding Category}"> + <controls:DataGridTemplateColumnExt Header="Category" MinWidth="10" Width="{CompiledBinding CategoryWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="Category" ClipboardContentBinding="{Binding Category}"> <DataGridTemplateColumn.CellTemplate> <DataTemplate x:DataType="uibase:GridEntry"> <Panel Opacity="{CompiledBinding Liberate.Opacity}"> @@ -181,7 +183,7 @@ ClipboardContentBinding="{CompiledBinding ProductRating}" Binding="{CompiledBinding ProductRating}" /> - <controls:DataGridTemplateColumnExt Header="Purchase Date" MinWidth="10" Width="{Binding PurchaseDateWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="PurchaseDate" ClipboardContentBinding="{Binding PurchaseDate}"> + <controls:DataGridTemplateColumnExt Header="Purchase Date" MinWidth="10" Width="{CompiledBinding PurchaseDateWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="PurchaseDate" ClipboardContentBinding="{Binding PurchaseDate}"> <DataGridTemplateColumn.CellTemplate> <DataTemplate x:DataType="uibase:GridEntry"> <Panel Opacity="{CompiledBinding Liberate.Opacity}"> @@ -201,7 +203,7 @@ ClipboardContentBinding="{CompiledBinding MyRating}" Binding="{CompiledBinding MyRating, Mode=TwoWay}" /> - <controls:DataGridTemplateColumnExt Header="Misc" MinWidth="10" Width="{Binding MiscWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="Misc" ClipboardContentBinding="{Binding Misc}"> + <controls:DataGridTemplateColumnExt Header="Misc" MinWidth="10" Width="{CompiledBinding MiscWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="Misc" ClipboardContentBinding="{Binding Misc}"> <DataGridTemplateColumn.CellTemplate> <DataTemplate x:DataType="uibase:GridEntry"> <Panel Opacity="{CompiledBinding Liberate.Opacity}"> @@ -211,7 +213,7 @@ </DataGridTemplateColumn.CellTemplate> </controls:DataGridTemplateColumnExt> - <controls:DataGridTemplateColumnExt Header="Last Download" MinWidth="10" Width="{Binding LastDownloadWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="LastDownload" ClipboardContentBinding="{Binding LastDownload}"> + <controls:DataGridTemplateColumnExt Header="Last Download" MinWidth="10" Width="{CompiledBinding LastDownloadWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="LastDownload" ClipboardContentBinding="{Binding LastDownload}"> <DataGridTemplateColumn.CellTemplate> <DataTemplate x:DataType="uibase:GridEntry"> <Panel Opacity="{CompiledBinding Liberate.Opacity}" ToolTip.Tip="{CompiledBinding LastDownload.ToolTipText}" DoubleTapped="Version_DoubleClick"> @@ -220,8 +222,18 @@ </DataTemplate> </DataGridTemplateColumn.CellTemplate> </controls:DataGridTemplateColumnExt> + + <controls:DataGridTemplateColumnExt Header="Is Spatial" MinWidth="10" Width="{CompiledBinding IsSpatialWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="IsSpatial" ClipboardContentBinding="{Binding IsSpatial}"> + <DataGridTemplateColumn.CellTemplate> + <DataTemplate x:DataType="uibase:GridEntry"> + <Panel Opacity="{CompiledBinding Liberate.Opacity}" ToolTip.Tip="{CompiledBinding LastDownload.ToolTipText}"> + <CheckBox IsChecked="{CompiledBinding IsSpatial}" IsEnabled="False" HorizontalAlignment="Center" VerticalAlignment="Center" /> + </Panel> + </DataTemplate> + </DataGridTemplateColumn.CellTemplate> + </controls:DataGridTemplateColumnExt> - <controls:DataGridTemplateColumnExt Header="Tags" MinWidth="10" Width="{Binding BookTagsWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="BookTags" ClipboardContentBinding="{Binding BookTags}"> + <controls:DataGridTemplateColumnExt Header="Tags" MinWidth="10" Width="{CompiledBinding BookTagsWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="BookTags" ClipboardContentBinding="{Binding BookTags}"> <DataGridTemplateColumn.CellTemplate> <DataTemplate x:DataType="uibase:GridEntry"> <Button diff --git a/Source/LibationAvalonia/Views/ProductsDisplay.axaml.cs b/Source/LibationAvalonia/Views/ProductsDisplay.axaml.cs index 167afbe0..e9622024 100644 --- a/Source/LibationAvalonia/Views/ProductsDisplay.axaml.cs +++ b/Source/LibationAvalonia/Views/ProductsDisplay.axaml.cs @@ -426,7 +426,6 @@ namespace LibationAvalonia.Views productsGrid.ColumnDisplayIndexChanged += ProductsGrid_ColumnDisplayIndexChanged; var config = Configuration.Instance; - var gridColumnsVisibilities = config.GridColumnsVisibilities; var displayIndices = config.GridColumnsDisplayIndices; var contextMenu = new ContextMenu(); @@ -464,7 +463,7 @@ namespace LibationAvalonia.Views if (headerCell is not null) headerCell.ContextMenu = contextMenu; - column.IsVisible = gridColumnsVisibilities.GetValueOrDefault(itemName, true); + column.IsVisible = config.GetColumnVisibility(itemName); } //We must set DisplayIndex properties in ascending order diff --git a/Source/LibationFileManager/Configuration.PersistentSettings.cs b/Source/LibationFileManager/Configuration.PersistentSettings.cs index a7636db6..f69e0568 100644 --- a/Source/LibationFileManager/Configuration.PersistentSettings.cs +++ b/Source/LibationFileManager/Configuration.PersistentSettings.cs @@ -179,12 +179,14 @@ namespace LibationFileManager [Description("Lame target VBR quality [10,100]")] public int LameVBRQuality { get => GetNonString(defaultValue: 2); set => SetNonString(value); } - private static readonly EquatableDictionary<string, bool> DefaultColumns = new( - new KeyValuePair<string, bool>[] - { + private static readonly EquatableDictionary<string, bool> DefaultColumns = new([ new ("SeriesOrder", false), - new ("LastDownload", false) - }); + new ("LastDownload", false), + new ("IsSpatial", false) + ]); + public bool GetColumnVisibility(string columnName) + => GridColumnsVisibilities.TryGetValue(columnName, out var isVisible) ? isVisible + :DefaultColumns.GetValueOrDefault(columnName, true); [Description("A Dictionary of GridView data property names and bool indicating its column's visibility in ProductsGrid")] public Dictionary<string, bool> GridColumnsVisibilities { get => GetNonString(defaultValue: DefaultColumns).Clone(); set => SetNonString(value); } diff --git a/Source/LibationUiBase/GridView/GridEntry[TStatus].cs b/Source/LibationUiBase/GridView/GridEntry.cs similarity index 98% rename from Source/LibationUiBase/GridView/GridEntry[TStatus].cs rename to Source/LibationUiBase/GridView/GridEntry.cs index b18a81c9..6f4549a2 100644 --- a/Source/LibationUiBase/GridView/GridEntry[TStatus].cs +++ b/Source/LibationUiBase/GridView/GridEntry.cs @@ -48,7 +48,7 @@ namespace LibationUiBase.GridView private Rating _productrating; private string _bookTags; private Rating _myRating; - + private bool _isSpatial; public abstract bool? Remove { get; set; } public EntryStatus Liberate { get => _liberate; private set => RaiseAndSetIfChanged(ref _liberate, value); } public string PurchaseDate { get => _purchasedate; protected set => RaiseAndSetIfChanged(ref _purchasedate, value); } @@ -65,6 +65,7 @@ namespace LibationUiBase.GridView public string Description { get => _description; private set => RaiseAndSetIfChanged(ref _description, value); } public Rating ProductRating { get => _productrating; private set => RaiseAndSetIfChanged(ref _productrating, value); } public string BookTags { get => _bookTags; private set => RaiseAndSetIfChanged(ref _bookTags, value); } + public bool IsSpatial { get => _isSpatial; protected set => RaiseAndSetIfChanged(ref _isSpatial, value); } public Rating MyRating { @@ -118,6 +119,7 @@ namespace LibationUiBase.GridView Description = GetDescriptionDisplay(Book); SeriesIndex = Book.SeriesLink.FirstOrDefault()?.Index ?? 0; BookTags = GetBookTags(); + IsSpatial = Book.IsSpatial; UserDefinedItem.ItemChanged += UserDefinedItem_ItemChanged; } @@ -205,6 +207,7 @@ namespace LibationUiBase.GridView nameof(BookTags) => BookTags ?? string.Empty, nameof(Liberate) => Liberate, nameof(DateAdded) => DateAdded, + nameof(IsSpatial) => IsSpatial, _ => null }; diff --git a/Source/LibationWinForms/GridView/ProductsGrid.Designer.cs b/Source/LibationWinForms/GridView/ProductsGrid.Designer.cs index 1cbf07f6..3f004673 100644 --- a/Source/LibationWinForms/GridView/ProductsGrid.Designer.cs +++ b/Source/LibationWinForms/GridView/ProductsGrid.Designer.cs @@ -34,6 +34,8 @@ namespace LibationWinForms.GridView System.Windows.Forms.DataGridViewCellStyle dataGridViewCellStyle2 = new System.Windows.Forms.DataGridViewCellStyle(); System.Windows.Forms.DataGridViewCellStyle dataGridViewCellStyle1 = new System.Windows.Forms.DataGridViewCellStyle(); gridEntryDataGridView = new System.Windows.Forms.DataGridView(); + showHideColumnsContextMenuStrip = new System.Windows.Forms.ContextMenuStrip(components); + syncBindingSource = new SyncBindingSource(components); removeGVColumn = new System.Windows.Forms.DataGridViewCheckBoxColumn(); liberateGVColumn = new LiberateDataGridViewImageButtonColumn(); coverGVColumn = new System.Windows.Forms.DataGridViewImageColumn(); @@ -50,9 +52,8 @@ namespace LibationWinForms.GridView myRatingGVColumn = new MyRatingGridViewColumn(); miscGVColumn = new System.Windows.Forms.DataGridViewTextBoxColumn(); lastDownloadedGVColumn = new LastDownloadedGridViewColumn(); + isSpatialGVColumn = new System.Windows.Forms.DataGridViewCheckBoxColumn(); tagAndDetailsGVColumn = new EditTagsDataGridViewImageButtonColumn(); - showHideColumnsContextMenuStrip = new System.Windows.Forms.ContextMenuStrip(components); - syncBindingSource = new SyncBindingSource(components); ((System.ComponentModel.ISupportInitialize)gridEntryDataGridView).BeginInit(); ((System.ComponentModel.ISupportInitialize)syncBindingSource).BeginInit(); SuspendLayout(); @@ -65,12 +66,12 @@ namespace LibationWinForms.GridView gridEntryDataGridView.AllowUserToResizeRows = false; gridEntryDataGridView.AutoGenerateColumns = false; gridEntryDataGridView.ColumnHeadersHeightSizeMode = System.Windows.Forms.DataGridViewColumnHeadersHeightSizeMode.AutoSize; - gridEntryDataGridView.Columns.AddRange(new System.Windows.Forms.DataGridViewColumn[] { removeGVColumn, liberateGVColumn, coverGVColumn, titleGVColumn, authorsGVColumn, narratorsGVColumn, lengthGVColumn, seriesGVColumn, seriesOrderGVColumn, descriptionGVColumn, categoryGVColumn, productRatingGVColumn, purchaseDateGVColumn, myRatingGVColumn, miscGVColumn, lastDownloadedGVColumn, tagAndDetailsGVColumn }); + gridEntryDataGridView.Columns.AddRange(new System.Windows.Forms.DataGridViewColumn[] { removeGVColumn, liberateGVColumn, coverGVColumn, titleGVColumn, authorsGVColumn, narratorsGVColumn, lengthGVColumn, seriesGVColumn, seriesOrderGVColumn, descriptionGVColumn, categoryGVColumn, productRatingGVColumn, purchaseDateGVColumn, myRatingGVColumn, miscGVColumn, lastDownloadedGVColumn, isSpatialGVColumn, tagAndDetailsGVColumn }); gridEntryDataGridView.ContextMenuStrip = showHideColumnsContextMenuStrip; gridEntryDataGridView.DataSource = syncBindingSource; dataGridViewCellStyle2.Alignment = System.Windows.Forms.DataGridViewContentAlignment.MiddleLeft; dataGridViewCellStyle2.BackColor = System.Drawing.SystemColors.Window; - dataGridViewCellStyle2.Font = new System.Drawing.Font("Segoe UI", 9F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point); + dataGridViewCellStyle2.Font = new System.Drawing.Font("Segoe UI", 9F); dataGridViewCellStyle2.ForeColor = System.Drawing.SystemColors.ControlText; dataGridViewCellStyle2.SelectionBackColor = System.Drawing.SystemColors.Highlight; dataGridViewCellStyle2.SelectionForeColor = System.Drawing.SystemColors.HighlightText; @@ -84,11 +85,22 @@ namespace LibationWinForms.GridView gridEntryDataGridView.RowHeadersVisible = false; gridEntryDataGridView.RowHeadersWidth = 82; gridEntryDataGridView.RowTemplate.Height = 82; - gridEntryDataGridView.Size = new System.Drawing.Size(3140, 760); + gridEntryDataGridView.Size = new System.Drawing.Size(1992, 380); gridEntryDataGridView.TabIndex = 0; gridEntryDataGridView.CellContentClick += DataGridView_CellContentClick; gridEntryDataGridView.CellToolTipTextNeeded += gridEntryDataGridView_CellToolTipTextNeeded; // + // showHideColumnsContextMenuStrip + // + showHideColumnsContextMenuStrip.ImageScalingSize = new System.Drawing.Size(32, 32); + showHideColumnsContextMenuStrip.Name = "contextMenuStrip1"; + showHideColumnsContextMenuStrip.ShowCheckMargin = true; + showHideColumnsContextMenuStrip.Size = new System.Drawing.Size(83, 4); + // + // syncBindingSource + // + syncBindingSource.DataSource = typeof(GridEntry); + // // removeGVColumn // removeGVColumn.DataPropertyName = "Remove"; @@ -144,7 +156,6 @@ namespace LibationWinForms.GridView authorsGVColumn.MinimumWidth = 10; authorsGVColumn.Name = "authorsGVColumn"; authorsGVColumn.ReadOnly = true; - authorsGVColumn.Width = 100; // // narratorsGVColumn // @@ -153,7 +164,6 @@ namespace LibationWinForms.GridView narratorsGVColumn.MinimumWidth = 10; narratorsGVColumn.Name = "narratorsGVColumn"; narratorsGVColumn.ReadOnly = true; - narratorsGVColumn.Width = 100; // // lengthGVColumn // @@ -163,7 +173,6 @@ namespace LibationWinForms.GridView lengthGVColumn.Name = "lengthGVColumn"; lengthGVColumn.ReadOnly = true; lengthGVColumn.ToolTipText = "Recording Length"; - lengthGVColumn.Width = 100; // // seriesGVColumn // @@ -172,7 +181,6 @@ namespace LibationWinForms.GridView seriesGVColumn.MinimumWidth = 10; seriesGVColumn.Name = "seriesGVColumn"; seriesGVColumn.ReadOnly = true; - seriesGVColumn.Width = 100; // // seriesOrderGVColumn // @@ -192,7 +200,6 @@ namespace LibationWinForms.GridView descriptionGVColumn.MinimumWidth = 10; descriptionGVColumn.Name = "descriptionGVColumn"; descriptionGVColumn.ReadOnly = true; - descriptionGVColumn.Width = 100; // // categoryGVColumn // @@ -201,7 +208,6 @@ namespace LibationWinForms.GridView categoryGVColumn.MinimumWidth = 10; categoryGVColumn.Name = "categoryGVColumn"; categoryGVColumn.ReadOnly = true; - categoryGVColumn.Width = 100; // // productRatingGVColumn // @@ -220,7 +226,6 @@ namespace LibationWinForms.GridView purchaseDateGVColumn.MinimumWidth = 10; purchaseDateGVColumn.Name = "purchaseDateGVColumn"; purchaseDateGVColumn.ReadOnly = true; - purchaseDateGVColumn.Width = 100; // // myRatingGVColumn // @@ -250,6 +255,17 @@ namespace LibationWinForms.GridView lastDownloadedGVColumn.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.Automatic; lastDownloadedGVColumn.Width = 108; // + // isSpatialGVColumn + // + isSpatialGVColumn.DataPropertyName = "IsSpatial"; + isSpatialGVColumn.HeaderText = "Is Spatial"; + isSpatialGVColumn.MinimumWidth = 20; + isSpatialGVColumn.Name = "isSpatialGVColumn"; + isSpatialGVColumn.ReadOnly = true; + isSpatialGVColumn.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.Automatic; + isSpatialGVColumn.ToolTipText = "Indicates whether this title is available in Dolby Atmos \"spatial\" audio format. Note: Requires enabling \"Request Spatial Audio\" in Settings."; + isSpatialGVColumn.Width = 60; + // // tagAndDetailsGVColumn // tagAndDetailsGVColumn.DataPropertyName = "BookTags"; @@ -259,18 +275,6 @@ namespace LibationWinForms.GridView tagAndDetailsGVColumn.ReadOnly = true; tagAndDetailsGVColumn.ScaleFactor = 0F; tagAndDetailsGVColumn.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.Automatic; - tagAndDetailsGVColumn.Width = 100; - // - // showHideColumnsContextMenuStrip - // - showHideColumnsContextMenuStrip.ImageScalingSize = new System.Drawing.Size(32, 32); - showHideColumnsContextMenuStrip.Name = "contextMenuStrip1"; - showHideColumnsContextMenuStrip.ShowCheckMargin = true; - showHideColumnsContextMenuStrip.Size = new System.Drawing.Size(83, 4); - // - // syncBindingSource - // - syncBindingSource.DataSource = typeof(GridEntry); // // ProductsGrid // @@ -279,10 +283,10 @@ namespace LibationWinForms.GridView AutoScroll = true; Controls.Add(gridEntryDataGridView); Name = "ProductsGrid"; - Size = new System.Drawing.Size(1570, 380); - Load += new System.EventHandler(ProductsGrid_Load); - ((System.ComponentModel.ISupportInitialize)(gridEntryDataGridView)).EndInit(); - ((System.ComponentModel.ISupportInitialize)(syncBindingSource)).EndInit(); + Size = new System.Drawing.Size(1992, 380); + Load += ProductsGrid_Load; + ((System.ComponentModel.ISupportInitialize)gridEntryDataGridView).EndInit(); + ((System.ComponentModel.ISupportInitialize)syncBindingSource).EndInit(); ResumeLayout(false); } @@ -308,6 +312,7 @@ namespace LibationWinForms.GridView private MyRatingGridViewColumn myRatingGVColumn; private System.Windows.Forms.DataGridViewTextBoxColumn miscGVColumn; private LastDownloadedGridViewColumn lastDownloadedGVColumn; + private System.Windows.Forms.DataGridViewCheckBoxColumn isSpatialGVColumn; private EditTagsDataGridViewImageButtonColumn tagAndDetailsGVColumn; } } diff --git a/Source/LibationWinForms/GridView/ProductsGrid.cs b/Source/LibationWinForms/GridView/ProductsGrid.cs index 02c2c5ee..f17a62cb 100644 --- a/Source/LibationWinForms/GridView/ProductsGrid.cs +++ b/Source/LibationWinForms/GridView/ProductsGrid.cs @@ -515,7 +515,6 @@ namespace LibationWinForms.GridView //Restore Grid Display Settings var config = Configuration.Instance; - var gridColumnsVisibilities = config.GridColumnsVisibilities; var gridColumnsWidths = config.GridColumnsWidths; var displayIndices = config.GridColumnsDisplayIndices; @@ -524,7 +523,7 @@ namespace LibationWinForms.GridView foreach (DataGridViewColumn column in gridEntryDataGridView.Columns) { var itemName = column.DataPropertyName; - var visible = gridColumnsVisibilities.GetValueOrDefault(itemName, true); + var visible = config.GetColumnVisibility(itemName); var menuItem = new ToolStripMenuItem(column.HeaderText) { diff --git a/Source/LibationWinForms/GridView/ProductsGrid.resx b/Source/LibationWinForms/GridView/ProductsGrid.resx index 19057fc3..9caaef88 100644 --- a/Source/LibationWinForms/GridView/ProductsGrid.resx +++ b/Source/LibationWinForms/GridView/ProductsGrid.resx @@ -1,7 +1,7 @@ <?xml version="1.0" encoding="utf-8"?> <root> <!-- - Microsoft ResX Schema + Microsoft ResX Schema Version 2.0 @@ -18,7 +18,7 @@ <resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader> <resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader> <data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data> - <data name="Color1" type="System.Drawing.Color, System.Drawing"">Blue</data> + <data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data> <data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64"> <value>[base64 mime encoded serialized .NET Framework object]</value> </data> @@ -48,7 +48,7 @@ value : The object must be serialized with : System.Runtime.Serialization.Formatters.Binary.BinaryFormatter : and then encoded with base64 encoding. - + mimetype: application/x-microsoft.net.object.soap.base64 value : The object must be serialized with : System.Runtime.Serialization.Formatters.Soap.SoapFormatter @@ -120,6 +120,9 @@ <metadata name="removeGVColumn.UserAddedColumn" type="System.Boolean, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089"> <value>True</value> </metadata> + <metadata name="isSpatialGVColumn.UserAddedColumn" type="System.Boolean, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089"> + <value>True</value> + </metadata> <metadata name="showHideColumnsContextMenuStrip.TrayLocation" type="System.Drawing.Point, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a"> <value>171, 17</value> </metadata>