diff --git a/Source/DataLayer/EfClasses/Book.cs b/Source/DataLayer/EfClasses/Book.cs index 05ccd9e6..33cbbaf5 100644 --- a/Source/DataLayer/EfClasses/Book.cs +++ b/Source/DataLayer/EfClasses/Book.cs @@ -43,9 +43,11 @@ 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. - internal long _audioFormat; + //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; } diff --git a/Source/FileLiberator/DownloadOptions.Factory.cs b/Source/FileLiberator/DownloadOptions.Factory.cs index 6e9d71ac..7c3d3c81 100644 --- a/Source/FileLiberator/DownloadOptions.Factory.cs +++ b/Source/FileLiberator/DownloadOptions.Factory.cs @@ -27,6 +27,15 @@ public partial class DownloadOptions public static async Task InitiateDownloadAsync(Api api, Configuration config, LibraryBook libraryBook) { var license = await ChooseContent(api, libraryBook, config); + + //Come audiobooks will have incorrect chapters in the metadata returned from the license request, + //but the metadata returned by the content metadata endpoint will be correct. Call the content + //metadata endpoint and use its chapters. Only replace the license request chapters if the total + //lengths match (defensive against different audio formats having slightly different lengths). + var metadata = await api.GetContentMetadataAsync(libraryBook.Book.AudibleProductId); + if (metadata.ChapterInfo.RuntimeLengthMs == license.ContentMetadata.ChapterInfo.RuntimeLengthMs) + license.ContentMetadata.ChapterInfo = metadata.ChapterInfo; + var options = BuildDownloadOptions(libraryBook, config, license); return options; @@ -356,7 +365,7 @@ public partial class DownloadOptions else if (titleConcat is null) { chaps.Add(c); - chaps.AddRange(flattenChapters(c.Chapters)); + chaps.AddRange(flattenChapters(c.Chapters, titleConcat)); } else { @@ -369,7 +378,7 @@ public partial class DownloadOptions else chaps.Add(c); - var children = flattenChapters(c.Chapters); + var children = flattenChapters(c.Chapters, titleConcat); foreach (var child in children) child.Title = $"{c.Title}{titleConcat}{child.Title}"; diff --git a/Source/HangoverAvalonia/App.axaml b/Source/HangoverAvalonia/App.axaml index 0534c7e8..6908440c 100644 --- a/Source/HangoverAvalonia/App.axaml +++ b/Source/HangoverAvalonia/App.axaml @@ -6,7 +6,30 @@ - - - + + + + + + + + + + + + + + diff --git a/Source/HangoverAvalonia/Controls/CheckedListBox.axaml b/Source/HangoverAvalonia/Controls/CheckedListBox.axaml index b7d8c94d..a1750735 100644 --- a/Source/HangoverAvalonia/Controls/CheckedListBox.axaml +++ b/Source/HangoverAvalonia/Controls/CheckedListBox.axaml @@ -4,27 +4,16 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:Class="HangoverAvalonia.Controls.CheckedListBox"> - - - - - - - - - - - - - + + + + + + + diff --git a/Source/HangoverAvalonia/Controls/CheckedListBox.axaml.cs b/Source/HangoverAvalonia/Controls/CheckedListBox.axaml.cs index bdcfcf61..26ea99b8 100644 --- a/Source/HangoverAvalonia/Controls/CheckedListBox.axaml.cs +++ b/Source/HangoverAvalonia/Controls/CheckedListBox.axaml.cs @@ -2,103 +2,18 @@ using Avalonia; using Avalonia.Collections; using Avalonia.Controls; using HangoverAvalonia.ViewModels; -using ReactiveUI; -using System; -using System.Collections; -using System.Collections.Generic; -using System.ComponentModel; -using System.Linq; -namespace HangoverAvalonia.Controls +namespace HangoverAvalonia.Controls; + +public partial class CheckedListBox : UserControl { - public partial class CheckedListBox : UserControl + public static readonly StyledProperty> ItemsProperty = + AvaloniaProperty.Register>(nameof(Items)); + + public AvaloniaList Items { get => GetValue(ItemsProperty); set => SetValue(ItemsProperty, value); } + + public CheckedListBox() { - public event EventHandler ItemCheck; - - public static readonly StyledProperty ItemsProperty = - AvaloniaProperty.Register(nameof(Items)); - - public IEnumerable Items { get => GetValue(ItemsProperty); set => SetValue(ItemsProperty, value); } - private CheckedListBoxViewModel _viewModel = new(); - - public IEnumerable CheckedItems => - _viewModel - .CheckboxItems - .Where(i => i.IsChecked) - .Select(i => i.Item); - - public void SetItemChecked(int i, bool isChecked) => _viewModel.CheckboxItems[i].IsChecked = isChecked; - public void SetItemChecked(object item, bool isChecked) - { - var obj = _viewModel.CheckboxItems.SingleOrDefault(i => i.Item == item); - if (obj is not null) - obj.IsChecked = isChecked; - } - - public CheckedListBox() - { - InitializeComponent(); - scroller.DataContext = _viewModel; - _viewModel.CheckedChanged += _viewModel_CheckedChanged; - } - - private void _viewModel_CheckedChanged(object sender, CheckBoxViewModel e) - { - var args = new ItemCheckEventArgs { Item = e.Item, ItemIndex = _viewModel.CheckboxItems.IndexOf(e), IsChecked = e.IsChecked }; - ItemCheck?.Invoke(this, args); - } - - protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) - { - if (change.Property.Name == nameof(Items) && Items != null) - _viewModel.SetItems(Items); - base.OnPropertyChanged(change); - } - - public class CheckedListBoxViewModel : ViewModelBase - { - public event EventHandler CheckedChanged; - public AvaloniaList CheckboxItems { get; private set; } - - public void SetItems(IEnumerable items) - { - UnsubscribeFromItems(CheckboxItems); - CheckboxItems = new(items.OfType().Select(o => new CheckBoxViewModel { Item = o })); - SubscribeToItems(CheckboxItems); - this.RaisePropertyChanged(nameof(CheckboxItems)); - } - - private void SubscribeToItems(IEnumerable objects) - { - foreach (var i in objects.OfType()) - i.PropertyChanged += I_PropertyChanged; - } - - private void UnsubscribeFromItems(AvaloniaList objects) - { - if (objects is null) return; - - foreach (var i in objects) - i.PropertyChanged -= I_PropertyChanged; - } - private void I_PropertyChanged(object sender, PropertyChangedEventArgs e) - { - CheckedChanged?.Invoke(this, (CheckBoxViewModel)sender); - } - } - public class CheckBoxViewModel : ViewModelBase - { - private bool _isChecked; - public bool IsChecked { get => _isChecked; set => this.RaiseAndSetIfChanged(ref _isChecked, value); } - private object _bookText; - public object Item { get => _bookText; set => this.RaiseAndSetIfChanged(ref _bookText, value); } - } - } - - public class ItemCheckEventArgs : EventArgs - { - public int ItemIndex { get; init; } - public bool IsChecked { get; init; } - public object Item { get; init; } + InitializeComponent(); } } diff --git a/Source/HangoverAvalonia/HangoverAvalonia.csproj b/Source/HangoverAvalonia/HangoverAvalonia.csproj index b90c1b09..fd506340 100644 --- a/Source/HangoverAvalonia/HangoverAvalonia.csproj +++ b/Source/HangoverAvalonia/HangoverAvalonia.csproj @@ -71,13 +71,12 @@ - - + + - - - - + + + diff --git a/Source/HangoverAvalonia/ViewModels/CheckBoxViewModel.cs b/Source/HangoverAvalonia/ViewModels/CheckBoxViewModel.cs new file mode 100644 index 00000000..f21f62b7 --- /dev/null +++ b/Source/HangoverAvalonia/ViewModels/CheckBoxViewModel.cs @@ -0,0 +1,11 @@ +using ReactiveUI; + +namespace HangoverAvalonia.ViewModels; + +public class CheckBoxViewModel : ViewModelBase +{ + private bool _isChecked; + public bool IsChecked { get => _isChecked; set => this.RaiseAndSetIfChanged(ref _isChecked, value); } + private object _bookText; + public object Item { get => _bookText; set => this.RaiseAndSetIfChanged(ref _bookText, value); } +} diff --git a/Source/HangoverAvalonia/ViewModels/MainVM.Deleted.cs b/Source/HangoverAvalonia/ViewModels/MainVM.Deleted.cs index 2c376fd7..827445d0 100644 --- a/Source/HangoverAvalonia/ViewModels/MainVM.Deleted.cs +++ b/Source/HangoverAvalonia/ViewModels/MainVM.Deleted.cs @@ -1,41 +1,8 @@ -using ApplicationServices; -using DataLayer; -using ReactiveUI; -using System.Collections.Generic; +namespace HangoverAvalonia.ViewModels; -namespace HangoverAvalonia.ViewModels +public partial class MainVM { - public partial class MainVM - { - private List _deletedBooks; - public List DeletedBooks { get => _deletedBooks; set => this.RaiseAndSetIfChanged(ref _deletedBooks, value); } - public string CheckedCountText => $"Checked : {_checkedBooksCount} of {_totalBooksCount}"; + public TrashBinViewModel TrashBinViewModel { get; } = new(); - private int _totalBooksCount = 0; - private int _checkedBooksCount = 0; - public int CheckedBooksCount - { - get => _checkedBooksCount; - set - { - if (_checkedBooksCount != value) - { - _checkedBooksCount = value; - this.RaisePropertyChanged(nameof(CheckedCountText)); - } - } - } - private void Load_deletedVM() - { - reload(); - } - - public void reload() - { - DeletedBooks = DbContexts.GetContext().GetDeletedLibraryBooks(); - _checkedBooksCount = 0; - _totalBooksCount = DeletedBooks.Count; - this.RaisePropertyChanged(nameof(CheckedCountText)); - } - } + private void Load_deletedVM() { } } diff --git a/Source/HangoverAvalonia/ViewModels/TrashBinViewModel.cs b/Source/HangoverAvalonia/ViewModels/TrashBinViewModel.cs new file mode 100644 index 00000000..22b5d15b --- /dev/null +++ b/Source/HangoverAvalonia/ViewModels/TrashBinViewModel.cs @@ -0,0 +1,117 @@ +using ApplicationServices; +using Avalonia.Collections; +using DataLayer; +using ReactiveUI; +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Threading.Tasks; + +namespace HangoverAvalonia.ViewModels; + +public class TrashBinViewModel : ViewModelBase, IDisposable +{ + public AvaloniaList DeletedBooks { get; } + public string CheckedCountText => $"Checked : {_checkedBooksCount} of {_totalBooksCount}"; + + private bool _controlsEnabled = true; + public bool ControlsEnabled { get => _controlsEnabled; set => this.RaiseAndSetIfChanged(ref _controlsEnabled, value); } + + private bool? everythingChecked = false; + public bool? EverythingChecked + { + get => everythingChecked; + set + { + everythingChecked = value ?? false; + + if (everythingChecked is true) + CheckAll(); + else if (everythingChecked is false) + UncheckAll(); + } + } + + private int _totalBooksCount = 0; + private int _checkedBooksCount = -1; + public int CheckedBooksCount + { + get => _checkedBooksCount; + set + { + _checkedBooksCount = value; + this.RaisePropertyChanged(nameof(CheckedCountText)); + + everythingChecked + = _checkedBooksCount == 0 || _totalBooksCount == 0 ? false + : _checkedBooksCount == _totalBooksCount ? true + : null; + + this.RaisePropertyChanged(nameof(EverythingChecked)); + } + } + + public IEnumerable CheckedBooks => DeletedBooks.Where(i => i.IsChecked).Select(i => i.Item).Cast(); + + public TrashBinViewModel() + { + DeletedBooks = new() + { + ResetBehavior = ResetBehavior.Remove + }; + + tracker = DeletedBooks.TrackItemPropertyChanged(CheckboxPropertyChanged); + Reload(); + } + + public void CheckAll() + { + foreach (var item in DeletedBooks) + item.IsChecked = true; + } + + public void UncheckAll() + { + foreach (var item in DeletedBooks) + item.IsChecked = false; + } + + public async Task RestoreCheckedAsync() + { + ControlsEnabled = false; + var qtyChanges = await Task.Run(CheckedBooks.RestoreBooks); + if (qtyChanges > 0) + Reload(); + ControlsEnabled = true; + } + + public async Task PermanentlyDeleteCheckedAsync() + { + ControlsEnabled = false; + var qtyChanges = await Task.Run(CheckedBooks.PermanentlyDeleteBooks); + if (qtyChanges > 0) + Reload(); + ControlsEnabled = true; + } + + public void Reload() + { + var deletedBooks = DbContexts.GetContext().GetDeletedLibraryBooks(); + + DeletedBooks.Clear(); + DeletedBooks.AddRange(deletedBooks.Select(lb => new CheckBoxViewModel { Item = lb })); + + _totalBooksCount = DeletedBooks.Count; + CheckedBooksCount = 0; + } + + private IDisposable tracker; + private void CheckboxPropertyChanged(Tuple e) + { + if (e.Item2.PropertyName == nameof(CheckBoxViewModel.IsChecked)) + CheckedBooksCount = DeletedBooks.Count(b => b.IsChecked); + } + + public void Dispose() => tracker?.Dispose(); +} diff --git a/Source/HangoverAvalonia/Views/MainWindow.Deleted.cs b/Source/HangoverAvalonia/Views/MainWindow.Deleted.cs index 2c2341e4..101d0375 100644 --- a/Source/HangoverAvalonia/Views/MainWindow.Deleted.cs +++ b/Source/HangoverAvalonia/Views/MainWindow.Deleted.cs @@ -1,40 +1,12 @@ -using ApplicationServices; -using DataLayer; -using HangoverAvalonia.Controls; -using System.Linq; +namespace HangoverAvalonia.Views; -namespace HangoverAvalonia.Views +public partial class MainWindow { - public partial class MainWindow + private void deletedTab_VisibleChanged(bool isVisible) { - private void deletedTab_VisibleChanged(bool isVisible) - { - if (!isVisible) - return; + if (!isVisible) + return; - if (_viewModel.DeletedBooks.Count == 0) - _viewModel.reload(); - } - public void Deleted_CheckedListBox_ItemCheck(object sender, ItemCheckEventArgs args) - { - _viewModel.CheckedBooksCount = deletedCbl.CheckedItems.Count(); - } - public void Deleted_CheckAll_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) - { - foreach (var item in deletedCbl.Items) - deletedCbl.SetItemChecked(item, true); - } - public void Deleted_UncheckAll_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) - { - foreach (var item in deletedCbl.Items) - deletedCbl.SetItemChecked(item, false); - } - public void Deleted_Save_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) - { - var libraryBooksToRestore = deletedCbl.CheckedItems.Cast().ToList(); - var qtyChanges = libraryBooksToRestore.RestoreBooks(); - if (qtyChanges > 0) - _viewModel.reload(); - } + _viewModel.TrashBinViewModel.Reload(); } } diff --git a/Source/HangoverAvalonia/Views/MainWindow.axaml b/Source/HangoverAvalonia/Views/MainWindow.axaml index 1b849ddf..f960db00 100644 --- a/Source/HangoverAvalonia/Views/MainWindow.axaml +++ b/Source/HangoverAvalonia/Views/MainWindow.axaml @@ -15,13 +15,11 @@ - - + @@ -42,14 +34,13 @@ HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto" AllowAutoHide="False"> - + + + + + + + diff --git a/Source/LibationWinForms/Dialogs/MessageBoxAlertAdminDialog.cs b/Source/LibationWinForms/Dialogs/MessageBoxAlertAdminDialog.cs index 3f0ab9e0..31c0fab5 100644 --- a/Source/LibationWinForms/Dialogs/MessageBoxAlertAdminDialog.cs +++ b/Source/LibationWinForms/Dialogs/MessageBoxAlertAdminDialog.cs @@ -30,7 +30,8 @@ namespace LibationWinForms.Dialogs return; System.Media.SystemSounds.Hand.Play(); - pictureBox1.Image = SystemIcons.Error.ToBitmap(); + //This is a different (and newer) icon from SystemIcons.Error + pictureBox1.Image = SystemIcons.GetStockIcon(StockIconId.Error).ToBitmap(); } private void githubLink_LinkClicked(object sender, LinkLabelLinkClickedEventArgs e) diff --git a/Source/LibationWinForms/Form1.ProcessQueue.cs b/Source/LibationWinForms/Form1.ProcessQueue.cs index 5c2e86c9..f9c7ccc4 100644 --- a/Source/LibationWinForms/Form1.ProcessQueue.cs +++ b/Source/LibationWinForms/Form1.ProcessQueue.cs @@ -30,6 +30,12 @@ namespace LibationWinForms if (libraryBooks.Length == 1) { var item = libraryBooks[0]; + + //Remove this item from the queue if it's already present and completed. + //Only do this when adding a single book at a time to prevent accidental + //extra downloads when queueing in batches. + processBookQueue1.RemoveCompleted(item); + if (item.Book.UserDefinedItem.BookStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload) { Serilog.Log.Logger.Information("Begin single book backup of {libraryBook}", item); diff --git a/Source/LibationWinForms/ProcessQueue/ProcessQueueControl.cs b/Source/LibationWinForms/ProcessQueue/ProcessQueueControl.cs index c993c8b6..5f10bc04 100644 --- a/Source/LibationWinForms/ProcessQueue/ProcessQueueControl.cs +++ b/Source/LibationWinForms/ProcessQueue/ProcessQueueControl.cs @@ -92,6 +92,11 @@ namespace LibationWinForms.ProcessQueue return true; } + public bool RemoveCompleted(DataLayer.LibraryBook libraryBook) + => Queue.FirstOrDefault(b => b?.LibraryBook?.Book?.AudibleProductId == libraryBook.Book.AudibleProductId) is ProcessBook entry + && entry.Status is ProcessBookStatus.Completed + && Queue.RemoveCompleted(entry); + public void AddDownloadPdf(DataLayer.LibraryBook libraryBook) => AddDownloadPdf(new List() { libraryBook });