From 3fa805d51f80dc364cfde17463de344b59e72d23 Mon Sep 17 00:00:00 2001 From: Michael Bucari-Tovo Date: Wed, 7 Jan 2026 15:10:54 -0700 Subject: [PATCH 01/10] Verify correct Serilog parameters used (#1536 ) --- Source/AppScaffolding/LibationScaffolding.cs | 4 ++-- Source/DtoImporterService/CategoryImporter.cs | 2 +- Source/FileLiberator/AudioFormatDecoder.cs | 4 ++-- Source/FileLiberator/DownloadDecryptBook.cs | 12 ++++++------ Source/FileLiberator/DownloadOptions.Factory.cs | 4 ++-- Source/FileManager/FileSystemTest.cs | 10 +++++----- Source/FileManager/PersistentDictionary.cs | 2 +- Source/LibationCli/Options/LiberateOptions.cs | 2 +- Source/LibationFileManager/AudibleFileStorage.cs | 6 +++--- Source/LibationFileManager/InteropFactory.cs | 2 +- Source/LibationFileManager/LibationFiles.cs | 10 +++++----- .../FindBetterQualityBooksViewModel.cs | 2 +- .../ProcessQueue/ProcessQueueViewModel.cs | 8 ++++---- 13 files changed, 34 insertions(+), 34 deletions(-) diff --git a/Source/AppScaffolding/LibationScaffolding.cs b/Source/AppScaffolding/LibationScaffolding.cs index 72e262a7..72f3de07 100644 --- a/Source/AppScaffolding/LibationScaffolding.cs +++ b/Source/AppScaffolding/LibationScaffolding.cs @@ -121,7 +121,7 @@ namespace AppScaffolding } catch(Exception ex) { - Log.Logger.Warning(ex, "Could not delete SQLite WAL file: {@WalFile}", walFile); + Log.Logger.Warning(ex, "Could not delete SQLite WAL file: {WalFile}", walFile); } } if (File.Exists(shmFile)) @@ -132,7 +132,7 @@ namespace AppScaffolding } catch (Exception ex) { - Log.Logger.Warning(ex, "Could not delete SQLite SHM file: {@ShmFile}", shmFile); + Log.Logger.Warning(ex, "Could not delete SQLite SHM file: {ShmFile}", shmFile); } } } diff --git a/Source/DtoImporterService/CategoryImporter.cs b/Source/DtoImporterService/CategoryImporter.cs index d4c81ed2..f33aa5d8 100644 --- a/Source/DtoImporterService/CategoryImporter.cs +++ b/Source/DtoImporterService/CategoryImporter.cs @@ -87,7 +87,7 @@ namespace DtoImporterService } catch (Exception ex) { - Serilog.Log.Logger.Error(ex, "Error adding category ladder. {@DebugInfo}", categoryList); + Serilog.Log.Logger.Error(ex, "Error adding category ladder."); throw; } } diff --git a/Source/FileLiberator/AudioFormatDecoder.cs b/Source/FileLiberator/AudioFormatDecoder.cs index 33d0761e..9d96d952 100644 --- a/Source/FileLiberator/AudioFormatDecoder.cs +++ b/Source/FileLiberator/AudioFormatDecoder.cs @@ -67,7 +67,7 @@ public static class AudioFormatDecoder 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); + Serilog.Log.Logger.Warning("Remaining file length is too short to contain any mp3 frames. {File}", mp3Filename); return AudioFormat.Default; } @@ -80,7 +80,7 @@ public static class AudioFormatDecoder if (layerDesc is not Layer.Layer_3) { - Serilog.Log.Logger.Warning("Could not read mp3 data from {@layerVersion} file.", layerDesc.ToString()); + Serilog.Log.Logger.Warning("Could not read mp3 data from {layerVersion} file.", layerDesc); return AudioFormat.Default; } diff --git a/Source/FileLiberator/DownloadDecryptBook.cs b/Source/FileLiberator/DownloadDecryptBook.cs index 4897ec79..59867b6d 100644 --- a/Source/FileLiberator/DownloadDecryptBook.cs +++ b/Source/FileLiberator/DownloadDecryptBook.cs @@ -427,7 +427,7 @@ namespace FileLiberator { //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.LogFriendly(), coverPath); + Serilog.Log.Logger.Error(ex, "Error downloading cover art for {@Book} to {coverPath}.", options.LibraryBook.LogFriendly(), coverPath); throw; } } @@ -476,7 +476,7 @@ namespace FileLiberator { //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.LogFriendly(), recordsPath); + Serilog.Log.Logger.Error(ex, "Error downloading clips and bookmarks for {@Book} to {recordsPath}.", options.LibraryBook.LogFriendly(), recordsPath); throw; } } @@ -512,7 +512,7 @@ namespace FileLiberator { //Failure to download metadata should not be considered a failure to download the book if (!cancellationToken.IsCancellationRequested) - Serilog.Log.Logger.Error(ex, "Error downloading metadata of {@Book} to {@metadataFile}.", options.LibraryBook.LogFriendly(), metadataPath); + Serilog.Log.Logger.Error(ex, "Error downloading metadata of {@Book} to {metadataFile}.", options.LibraryBook.LogFriendly(), metadataPath); throw; } } @@ -523,12 +523,12 @@ namespace FileLiberator { Serilog.Log.Verbose("Getting destination directory for {@Book}", libraryBook.LogFriendly()); var destinationDir = AudibleFileStorage.Audio.GetDestinationDirectory(libraryBook, Configuration); - Serilog.Log.Verbose("Got destination directory for {@Book}. {@Directory}", libraryBook.LogFriendly(), destinationDir); + Serilog.Log.Verbose("Got destination directory for {@Book}. {Directory}", libraryBook.LogFriendly(), destinationDir); if (!Directory.Exists(destinationDir)) { - Serilog.Log.Verbose("Creating destination {@Directory}", destinationDir); + Serilog.Log.Verbose("Creating destination {Directory}", destinationDir); Directory.CreateDirectory(destinationDir); - Serilog.Log.Verbose("Created destination {@Directory}", destinationDir); + Serilog.Log.Verbose("Created destination {Directory}", destinationDir); } return destinationDir; } diff --git a/Source/FileLiberator/DownloadOptions.Factory.cs b/Source/FileLiberator/DownloadOptions.Factory.cs index 4a78f64c..7f3fd0a8 100644 --- a/Source/FileLiberator/DownloadOptions.Factory.cs +++ b/Source/FileLiberator/DownloadOptions.Factory.cs @@ -56,7 +56,7 @@ public partial class DownloadOptions } else if (metadata.ContentReference != license.ContentMetadata.ContentReference) { - Serilog.Log.Logger.Warning("Metadata ContentReference does not match License ContentReference with drm_type = {@DrmType}. {@Metadata}. {@License} ", + Serilog.Log.Logger.Warning("Metadata ContentReference does not match License ContentReference with drm_type = {DrmType}. {@Metadata}. {@License} ", license.DrmType, metadata.ContentReference, license.ContentMetadata.ContentReference); @@ -111,7 +111,7 @@ public partial class DownloadOptions if (canUseWidevine) Serilog.Log.Logger.Warning("Unable to get a Widevine CDM. Falling back to ADRM."); else - Serilog.Log.Logger.Warning("Account {@account} is not registered as an android device, so content will not be downloaded with Widevine DRM. Remove and re-add the account in Libation to fix.", libraryBook.Account.ToMask()); + Serilog.Log.Logger.Warning("Account {account} is not registered as an android device, so content will not be downloaded with Widevine DRM. Remove and re-add the account in Libation to fix.", libraryBook.Account.ToMask()); } token.ThrowIfCancellationRequested(); diff --git a/Source/FileManager/FileSystemTest.cs b/Source/FileManager/FileSystemTest.cs index c8be82bf..27efd98f 100644 --- a/Source/FileManager/FileSystemTest.cs +++ b/Source/FileManager/FileSystemTest.cs @@ -46,7 +46,7 @@ namespace FileManager if (!Directory.Exists(directoryName)) return false; - Serilog.Log.Logger.Debug("Testing write permissions for directory: {@DirectoryName}", directoryName); + Serilog.Log.Logger.Debug("Testing write permissions for directory: {DirectoryName}", directoryName); var testFilePath = Path.Combine(directoryName, Guid.NewGuid().ToString()); return CanWriteFile(testFilePath); } @@ -55,9 +55,9 @@ namespace FileManager { try { - Serilog.Log.Logger.Debug("Testing ability to write filename: {@filename}", filename); + Serilog.Log.Logger.Debug("Testing ability to write filename: {filename}", filename); File.WriteAllBytes(filename, []); - Serilog.Log.Logger.Debug("Deleting test file after successful write: {@filename}", filename); + Serilog.Log.Logger.Debug("Deleting test file after successful write: {filename}", filename); try { FileUtility.SaferDelete(filename); @@ -65,13 +65,13 @@ namespace FileManager catch (Exception ex) { //An error deleting the file doesn't constitute a write failure. - Serilog.Log.Logger.Debug(ex, "Error deleting test file: {@filename}", filename); + Serilog.Log.Logger.Debug(ex, "Error deleting test file: {filename}", filename); } return true; } catch (Exception ex) { - Serilog.Log.Logger.Debug(ex, "Error writing test file: {@filename}", filename); + Serilog.Log.Logger.Debug(ex, "Error writing test file: {filename}", filename); return false; } } diff --git a/Source/FileManager/PersistentDictionary.cs b/Source/FileManager/PersistentDictionary.cs index 62aaee61..6594ba5b 100644 --- a/Source/FileManager/PersistentDictionary.cs +++ b/Source/FileManager/PersistentDictionary.cs @@ -142,7 +142,7 @@ namespace FileManager File.WriteAllText(Filepath, endContents); success = true; } - Serilog.Log.Logger.Information("Removed property. {@DebugInfo}", propertyName); + Serilog.Log.Logger.Information("Removed property. {propertyName}", propertyName); } catch { } diff --git a/Source/LibationCli/Options/LiberateOptions.cs b/Source/LibationCli/Options/LiberateOptions.cs index 3a1ef9db..adcd9b5a 100644 --- a/Source/LibationCli/Options/LiberateOptions.cs +++ b/Source/LibationCli/Options/LiberateOptions.cs @@ -87,7 +87,7 @@ namespace LibationCli } catch (Exception ex) { - Serilog.Log.Error(ex, "Failed to read license file: {@LicenseFile}", licFile); + Serilog.Log.Error(ex, "Failed to read license file: {LicenseFile}", licFile); Console.Error.WriteLine("Error: Failed to read license file. Please ensure the file is a valid license file in JSON format."); } return null; diff --git a/Source/LibationFileManager/AudibleFileStorage.cs b/Source/LibationFileManager/AudibleFileStorage.cs index c97e0ecb..afacce15 100644 --- a/Source/LibationFileManager/AudibleFileStorage.cs +++ b/Source/LibationFileManager/AudibleFileStorage.cs @@ -37,7 +37,7 @@ namespace LibationFileManager } catch (Exception ex) { - Serilog.Log.Error(ex, "Error creating subdirectory in {@InProgress}", Configuration.Instance.InProgress); + Serilog.Log.Error(ex, "Error creating subdirectory in {InProgress}", Configuration.Instance.InProgress); lastInProgressFail = DateTime.UtcNow; return null; } @@ -86,7 +86,7 @@ namespace LibationFileManager } catch (Exception ex) { - Serilog.Log.Error(ex, "Error creating Books directory: {@BooksDirectory}", Configuration.Instance.Books); + Serilog.Log.Error(ex, "Error creating Books directory: {BooksDirectory}", Configuration.Instance.Books); return null; } } @@ -272,7 +272,7 @@ namespace LibationFileManager } catch (Exception ex) { - Serilog.Log.Error(ex, "Error checking for asin in {@file}", path); + Serilog.Log.Error(ex, "Error checking for asin in {file}", path); } finally { diff --git a/Source/LibationFileManager/InteropFactory.cs b/Source/LibationFileManager/InteropFactory.cs index 043535d4..cfb9598a 100644 --- a/Source/LibationFileManager/InteropFactory.cs +++ b/Source/LibationFileManager/InteropFactory.cs @@ -76,7 +76,7 @@ namespace LibationFileManager catch (Exception e) { //None of the interop functions are strictly necessary for Libation to run. - Serilog.Log.Logger.Error(e, "Unable to load types from assembly {@configApp}", configApp); + Serilog.Log.Logger.Error(e, "Unable to load types from assembly {configApp}", configApp); } } private static string? getOSConfigApp() diff --git a/Source/LibationFileManager/LibationFiles.cs b/Source/LibationFileManager/LibationFiles.cs index 7e1a249e..dc9bcde0 100644 --- a/Source/LibationFileManager/LibationFiles.cs +++ b/Source/LibationFileManager/LibationFiles.cs @@ -114,24 +114,24 @@ public class LibationFiles } catch (Exception ex) { - Log.Logger.Error(ex, "Failed to load settings file: {@SettingsFile}", settingsFile); + Log.Logger.Error(ex, "Failed to load settings file: {SettingsFile}", settingsFile); try { - Log.Logger.Information("Deleting invalid settings file: {@SettingsFile}", settingsFile); + Log.Logger.Information("Deleting invalid settings file: {SettingsFile}", settingsFile); FileUtility.SaferDelete(settingsFile); - Log.Logger.Information("Creating a new, empty setting file: {@SettingsFile}", settingsFile); + Log.Logger.Information("Creating a new, empty setting file: {SettingsFile}", settingsFile); try { File.WriteAllText(settingsFile, "{}"); } catch (Exception createEx) { - Log.Logger.Error(createEx, "Failed to create new settings file: {@SettingsFile}", settingsFile); + Log.Logger.Error(createEx, "Failed to create new settings file: {SettingsFile}", settingsFile); } } catch (Exception deleteEx) { - Log.Logger.Error(deleteEx, "Failed to delete the invalid settings file: {@SettingsFile}", settingsFile); + Log.Logger.Error(deleteEx, "Failed to delete the invalid settings file: {SettingsFile}", settingsFile); } return false; diff --git a/Source/LibationUiBase/FindBetterQualityBooksViewModel.cs b/Source/LibationUiBase/FindBetterQualityBooksViewModel.cs index 2c182ec1..c917c935 100644 --- a/Source/LibationUiBase/FindBetterQualityBooksViewModel.cs +++ b/Source/LibationUiBase/FindBetterQualityBooksViewModel.cs @@ -150,7 +150,7 @@ public class FindBetterQualityBooksViewModel : ReactiveObject } catch (Exception ex) { - Serilog.Log.Logger.Error(ex, "Error checking for better quality for {@Asin}", b.Asin); + Serilog.Log.Logger.Error(ex, "Error checking for better quality for {@Asin}", new { b.Asin }); b.FoundFile = $"Error: {ex.Message}"; b.ScanStatus = ProcessBookStatus.Failed; } diff --git a/Source/LibationUiBase/ProcessQueue/ProcessQueueViewModel.cs b/Source/LibationUiBase/ProcessQueue/ProcessQueueViewModel.cs index 252271e6..92115bad 100644 --- a/Source/LibationUiBase/ProcessQueue/ProcessQueueViewModel.cs +++ b/Source/LibationUiBase/ProcessQueue/ProcessQueueViewModel.cs @@ -168,7 +168,7 @@ public class ProcessQueueViewModel : ReactiveObject private bool IsBooksDirectoryValid(Configuration config) { - if (string.IsNullOrWhiteSpace(config.Books)) + if (string.IsNullOrWhiteSpace(config.Books?.Path)) { Serilog.Log.Logger.Error("Books location is not set in configuration."); MessageBoxBase.Show( @@ -180,7 +180,7 @@ public class ProcessQueueViewModel : ReactiveObject } else if (AudibleFileStorage.BooksDirectory is null) { - Serilog.Log.Logger.Error("Failed to create books directory: {@booksDir}", config.Books); + Serilog.Log.Logger.Error("Failed to create books directory: {booksDir}", config.Books?.Path); MessageBoxBase.Show( $"Libation was unable to create the \"Books location\" folder at:\n{config.Books}\n\nPlease change the Books location in the settings menu.", "Failed to Create Books Directory", @@ -190,7 +190,7 @@ public class ProcessQueueViewModel : ReactiveObject } else if (AudibleFileStorage.DownloadsInProgressDirectory is null) { - Serilog.Log.Logger.Error("Failed to create DownloadsInProgressDirectory in {@InProgress}", config.InProgress); + Serilog.Log.Logger.Error("Failed to create DownloadsInProgressDirectory in {InProgress}", config.InProgress); MessageBoxBase.Show( $"Libation was unable to create the \"Downloads In Progress\" folder in:\n{config.InProgress}\n\nPlease change the In Progress location in the settings menu.", "Failed to Create Downloads In Progress Directory", @@ -200,7 +200,7 @@ public class ProcessQueueViewModel : ReactiveObject } else if (AudibleFileStorage.DecryptInProgressDirectory is null) { - Serilog.Log.Logger.Error("Failed to create DecryptInProgressDirectory in {@InProgress}", config.InProgress); + Serilog.Log.Logger.Error("Failed to create DecryptInProgressDirectory in {InProgress}", config.InProgress); MessageBoxBase.Show( $"Libation was unable to create the \"Decrypt In Progress\" folder in:\n{config.InProgress}\n\nPlease change the In Progress location in the settings menu.", "Failed to Create Decrypt In Progress Directory", From 804bac5c4cfa88a6dca578404f8c2a206b8c7110 Mon Sep 17 00:00:00 2001 From: Michael Bucari-Tovo Date: Wed, 7 Jan 2026 15:50:23 -0700 Subject: [PATCH 02/10] Add LibraryBook.IsAudiblePlus property --- Source/ApplicationServices/LibraryCommands.cs | 2 + Source/AudibleUtilities/Extensions.cs | 24 + ...0260107224301_AddIsAudiblePlus.Designer.cs | 499 ++++++++++++++++++ .../20260107224301_AddIsAudiblePlus.cs | 101 ++++ .../LibationContextModelSnapshot.cs | 9 +- ...0260107224303_AddIsAudiblePlus.Designer.cs | 482 +++++++++++++++++ .../20260107224303_AddIsAudiblePlus.cs | 101 ++++ .../LibationContextModelSnapshot.cs | 9 +- Source/DataLayer/EfClasses/LibraryBook.cs | 2 + Source/DataLayer/MockLibraryBook.cs | 7 +- .../DtoImporterService/LibraryBookImporter.cs | 16 +- Source/LibationUiBase/GridView/GridEntry.cs | 5 +- dotnet-tools.json | 2 +- 13 files changed, 1237 insertions(+), 22 deletions(-) create mode 100644 Source/AudibleUtilities/Extensions.cs create mode 100644 Source/DataLayer.Postgres/Migrations/20260107224301_AddIsAudiblePlus.Designer.cs create mode 100644 Source/DataLayer.Postgres/Migrations/20260107224301_AddIsAudiblePlus.cs create mode 100644 Source/DataLayer.Sqlite/Migrations/20260107224303_AddIsAudiblePlus.Designer.cs create mode 100644 Source/DataLayer.Sqlite/Migrations/20260107224303_AddIsAudiblePlus.cs diff --git a/Source/ApplicationServices/LibraryCommands.cs b/Source/ApplicationServices/LibraryCommands.cs index adb0517c..ae165e7e 100644 --- a/Source/ApplicationServices/LibraryCommands.cs +++ b/Source/ApplicationServices/LibraryCommands.cs @@ -220,6 +220,8 @@ namespace ApplicationServices { book.AbsentFromLastScan = false; } + book.SetIncludedUntil(importItem.DtoItem.GetExpirationDate()); + book.SetIsAudiblePlus(importItem.DtoItem.IsAyce is true); }); } diff --git a/Source/AudibleUtilities/Extensions.cs b/Source/AudibleUtilities/Extensions.cs new file mode 100644 index 00000000..9e843acc --- /dev/null +++ b/Source/AudibleUtilities/Extensions.cs @@ -0,0 +1,24 @@ +using AudibleApi.Common; +using System; +using System.Linq; + +namespace AudibleUtilities; + +public static class Extensions +{ + extension(Item item) + { + /// + /// Determines when your audible plus or free book will expire from your library + /// plan.IsAyce from underlying AudibleApi project determines the plans to look at, first plan found is used. + /// In some cases current date is later than end date so exclude. + /// + /// The DateTime that this title will become unavailable, otherwise null + public DateTime? GetExpirationDate() + => item.Plans + ?.Where(p => p.IsAyce) + .Select(p => p.EndDate) + .FirstOrDefault(end => end.HasValue && end.Value.Year is not (2099 or 9999) && end.Value.LocalDateTime >= DateTime.Now) + ?.DateTime; + } +} diff --git a/Source/DataLayer.Postgres/Migrations/20260107224301_AddIsAudiblePlus.Designer.cs b/Source/DataLayer.Postgres/Migrations/20260107224301_AddIsAudiblePlus.Designer.cs new file mode 100644 index 00000000..92817b85 --- /dev/null +++ b/Source/DataLayer.Postgres/Migrations/20260107224301_AddIsAudiblePlus.Designer.cs @@ -0,0 +1,499 @@ +// +using System; +using DataLayer; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace DataLayer.Postgres.Migrations +{ + [DbContext(typeof(LibationContext))] + [Migration("20260107224301_AddIsAudiblePlus")] + partial class AddIsAudiblePlus + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + 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"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BookId")); + + b.Property("AudibleProductId") + .HasColumnType("text"); + + b.Property("ContentType") + .HasColumnType("integer"); + + b.Property("DatePublished") + .HasColumnType("timestamp without time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("IsAbridged") + .HasColumnType("boolean"); + + b.Property("IsSpatial") + .HasColumnType("boolean"); + + 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("smallint"); + + 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"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("CategoryId")); + + 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"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("CategoryLadderId")); + + b.HasKey("CategoryLadderId"); + + b.ToTable("CategoryLadders"); + }); + + modelBuilder.Entity("DataLayer.Contributor", b => + { + b.Property("ContributorId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ContributorId")); + + 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("boolean"); + + b.Property("Account") + .HasColumnType("text"); + + b.Property("DateAdded") + .HasColumnType("timestamp without time zone"); + + b.Property("IncludedUntil") + .HasColumnType("timestamp without time zone"); + + b.Property("IsAudiblePlus") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.HasKey("BookId"); + + b.ToTable("LibraryBooks"); + }); + + modelBuilder.Entity("DataLayer.Series", b => + { + b.Property("SeriesId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SeriesId")); + + 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"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("SupplementId")); + + 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("boolean"); + + b1.Property("LastDownloaded") + .HasColumnType("timestamp without time zone"); + + b1.Property("LastDownloadedFileVersion") + .HasColumnType("text"); + + b1.Property("LastDownloadedFormat") + .HasColumnType("bigint"); + + b1.Property("LastDownloadedVersion") + .HasColumnType("text"); + + b1.Property("PdfStatus") + .HasColumnType("integer"); + + b1.Property("Tags") + .IsRequired() + .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") + .IsRequired(); + }); + + 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.Postgres/Migrations/20260107224301_AddIsAudiblePlus.cs b/Source/DataLayer.Postgres/Migrations/20260107224301_AddIsAudiblePlus.cs new file mode 100644 index 00000000..fa0afea7 --- /dev/null +++ b/Source/DataLayer.Postgres/Migrations/20260107224301_AddIsAudiblePlus.cs @@ -0,0 +1,101 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DataLayer.Postgres.Migrations +{ + /// + public partial class AddIsAudiblePlus : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "Tags", + table: "UserDefinedItem", + type: "text", + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "text", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "Rating_StoryRating", + table: "UserDefinedItem", + type: "real", + nullable: false, + defaultValue: 0f, + oldClrType: typeof(float), + oldType: "real", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "Rating_PerformanceRating", + table: "UserDefinedItem", + type: "real", + nullable: false, + defaultValue: 0f, + oldClrType: typeof(float), + oldType: "real", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "Rating_OverallRating", + table: "UserDefinedItem", + type: "real", + nullable: false, + defaultValue: 0f, + oldClrType: typeof(float), + oldType: "real", + oldNullable: true); + + migrationBuilder.AddColumn( + name: "IsAudiblePlus", + table: "LibraryBooks", + type: "boolean", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "IsAudiblePlus", + table: "LibraryBooks"); + + migrationBuilder.AlterColumn( + name: "Tags", + table: "UserDefinedItem", + type: "text", + nullable: true, + oldClrType: typeof(string), + oldType: "text"); + + migrationBuilder.AlterColumn( + name: "Rating_StoryRating", + table: "UserDefinedItem", + type: "real", + nullable: true, + oldClrType: typeof(float), + oldType: "real"); + + migrationBuilder.AlterColumn( + name: "Rating_PerformanceRating", + table: "UserDefinedItem", + type: "real", + nullable: true, + oldClrType: typeof(float), + oldType: "real"); + + migrationBuilder.AlterColumn( + name: "Rating_OverallRating", + table: "UserDefinedItem", + type: "real", + nullable: true, + oldClrType: typeof(float), + oldType: "real"); + } + } +} diff --git a/Source/DataLayer.Postgres/Migrations/LibationContextModelSnapshot.cs b/Source/DataLayer.Postgres/Migrations/LibationContextModelSnapshot.cs index d6a6957f..23733afe 100644 --- a/Source/DataLayer.Postgres/Migrations/LibationContextModelSnapshot.cs +++ b/Source/DataLayer.Postgres/Migrations/LibationContextModelSnapshot.cs @@ -17,7 +17,7 @@ namespace DataLayer.Postgres.Migrations { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "9.0.8") + .HasAnnotation("ProductVersion", "10.0.0") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); @@ -210,6 +210,9 @@ namespace DataLayer.Postgres.Migrations b.Property("IncludedUntil") .HasColumnType("timestamp without time zone"); + b.Property("IsAudiblePlus") + .HasColumnType("boolean"); + b.Property("IsDeleted") .HasColumnType("boolean"); @@ -351,6 +354,7 @@ namespace DataLayer.Postgres.Migrations .HasColumnType("integer"); b1.Property("Tags") + .IsRequired() .HasColumnType("text"); b1.HasKey("BookId"); @@ -384,7 +388,8 @@ namespace DataLayer.Postgres.Migrations b1.Navigation("Book"); - b1.Navigation("Rating"); + b1.Navigation("Rating") + .IsRequired(); }); b.Navigation("Rating"); diff --git a/Source/DataLayer.Sqlite/Migrations/20260107224303_AddIsAudiblePlus.Designer.cs b/Source/DataLayer.Sqlite/Migrations/20260107224303_AddIsAudiblePlus.Designer.cs new file mode 100644 index 00000000..65bd1b85 --- /dev/null +++ b/Source/DataLayer.Sqlite/Migrations/20260107224303_AddIsAudiblePlus.Designer.cs @@ -0,0 +1,482 @@ +// +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("20260107224303_AddIsAudiblePlus")] + partial class AddIsAudiblePlus + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.0"); + + 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("IncludedUntil") + .HasColumnType("TEXT"); + + b.Property("IsAudiblePlus") + .HasColumnType("INTEGER"); + + 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") + .IsRequired() + .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") + .IsRequired(); + }); + + 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.Sqlite/Migrations/20260107224303_AddIsAudiblePlus.cs b/Source/DataLayer.Sqlite/Migrations/20260107224303_AddIsAudiblePlus.cs new file mode 100644 index 00000000..c97ba61b --- /dev/null +++ b/Source/DataLayer.Sqlite/Migrations/20260107224303_AddIsAudiblePlus.cs @@ -0,0 +1,101 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DataLayer.Migrations +{ + /// + public partial class AddIsAudiblePlus : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "Tags", + table: "UserDefinedItem", + type: "TEXT", + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "TEXT", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "Rating_StoryRating", + table: "UserDefinedItem", + type: "REAL", + nullable: false, + defaultValue: 0f, + oldClrType: typeof(float), + oldType: "REAL", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "Rating_PerformanceRating", + table: "UserDefinedItem", + type: "REAL", + nullable: false, + defaultValue: 0f, + oldClrType: typeof(float), + oldType: "REAL", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "Rating_OverallRating", + table: "UserDefinedItem", + type: "REAL", + nullable: false, + defaultValue: 0f, + oldClrType: typeof(float), + oldType: "REAL", + oldNullable: true); + + migrationBuilder.AddColumn( + name: "IsAudiblePlus", + table: "LibraryBooks", + type: "INTEGER", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "IsAudiblePlus", + table: "LibraryBooks"); + + migrationBuilder.AlterColumn( + name: "Tags", + table: "UserDefinedItem", + type: "TEXT", + nullable: true, + oldClrType: typeof(string), + oldType: "TEXT"); + + migrationBuilder.AlterColumn( + name: "Rating_StoryRating", + table: "UserDefinedItem", + type: "REAL", + nullable: true, + oldClrType: typeof(float), + oldType: "REAL"); + + migrationBuilder.AlterColumn( + name: "Rating_PerformanceRating", + table: "UserDefinedItem", + type: "REAL", + nullable: true, + oldClrType: typeof(float), + oldType: "REAL"); + + migrationBuilder.AlterColumn( + name: "Rating_OverallRating", + table: "UserDefinedItem", + type: "REAL", + nullable: true, + oldClrType: typeof(float), + oldType: "REAL"); + } + } +} diff --git a/Source/DataLayer.Sqlite/Migrations/LibationContextModelSnapshot.cs b/Source/DataLayer.Sqlite/Migrations/LibationContextModelSnapshot.cs index 65f867f9..e87511a2 100644 --- a/Source/DataLayer.Sqlite/Migrations/LibationContextModelSnapshot.cs +++ b/Source/DataLayer.Sqlite/Migrations/LibationContextModelSnapshot.cs @@ -15,7 +15,7 @@ namespace DataLayer.Migrations protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "9.0.8"); + modelBuilder.HasAnnotation("ProductVersion", "10.0.0"); modelBuilder.Entity("CategoryCategoryLadder", b => { @@ -197,6 +197,9 @@ namespace DataLayer.Migrations b.Property("IncludedUntil") .HasColumnType("TEXT"); + b.Property("IsAudiblePlus") + .HasColumnType("INTEGER"); + b.Property("IsDeleted") .HasColumnType("INTEGER"); @@ -334,6 +337,7 @@ namespace DataLayer.Migrations .HasColumnType("INTEGER"); b1.Property("Tags") + .IsRequired() .HasColumnType("TEXT"); b1.HasKey("BookId"); @@ -367,7 +371,8 @@ namespace DataLayer.Migrations b1.Navigation("Book"); - b1.Navigation("Rating"); + b1.Navigation("Rating") + .IsRequired(); }); b.Navigation("Rating"); diff --git a/Source/DataLayer/EfClasses/LibraryBook.cs b/Source/DataLayer/EfClasses/LibraryBook.cs index 9c57fe0a..2a4e9c56 100644 --- a/Source/DataLayer/EfClasses/LibraryBook.cs +++ b/Source/DataLayer/EfClasses/LibraryBook.cs @@ -15,6 +15,7 @@ namespace DataLayer public bool AbsentFromLastScan { get; set; } public DateTime? IncludedUntil { get; private set; } + public bool IsAudiblePlus { get; set; } private LibraryBook() { } public LibraryBook(Book book, DateTime dateAdded, string account) { @@ -28,6 +29,7 @@ namespace DataLayer public void SetAccount(string account) => Account = account; public void SetIncludedUntil(DateTime? includedUntil) => IncludedUntil = includedUntil; + public void SetIsAudiblePlus(bool isAudiblePlus) => IsAudiblePlus = isAudiblePlus; public override string ToString() => $"{DateAdded:d} {Book}"; } } \ No newline at end of file diff --git a/Source/DataLayer/MockLibraryBook.cs b/Source/DataLayer/MockLibraryBook.cs index 85fb3297..214017f0 100644 --- a/Source/DataLayer/MockLibraryBook.cs +++ b/Source/DataLayer/MockLibraryBook.cs @@ -7,10 +7,11 @@ using System.Text; namespace DataLayer; public class MockLibraryBook : LibraryBook { - protected MockLibraryBook(Book book, DateTime dateAdded, string account, DateTime? includedUntil) + protected MockLibraryBook(Book book, DateTime dateAdded, string account, DateTime? includedUntil, bool isAudiblePlus) : base(book, dateAdded, account) { SetIncludedUntil(includedUntil); + SetIsAudiblePlus(isAudiblePlus); } public MockLibraryBook AddSeries(string seriesName, int order) @@ -76,6 +77,7 @@ public class MockLibraryBook : LibraryBook DateTime? dateAdded = null, DateTime? datePublished = null, DateTime? includedUntil = null, + bool isAudiblePlus = false, string title = "Mock Book Title", string subtitle = "Mock Book Subtitle", string description = "This is a mock book description.", @@ -115,7 +117,8 @@ public class MockLibraryBook : LibraryBook book, dateAdded ?? DateTime.Now, account, - includedUntil) + includedUntil, + isAudiblePlus) { AbsentFromLastScan = absetFromLastScan }; diff --git a/Source/DtoImporterService/LibraryBookImporter.cs b/Source/DtoImporterService/LibraryBookImporter.cs index 689a1cc7..34ef85f9 100644 --- a/Source/DtoImporterService/LibraryBookImporter.cs +++ b/Source/DtoImporterService/LibraryBookImporter.cs @@ -85,7 +85,8 @@ namespace DtoImporterService } } - existing.SetIncludedUntil(GetExpirationDate(item)); + existing.SetIncludedUntil(item.DtoItem.GetExpirationDate()); + existing.SetIsAudiblePlus(item.DtoItem.IsAyce is true); } var scannedAccounts = importItems.Select(i => i.AccountId).Distinct().ToList(); @@ -126,18 +127,5 @@ namespace DtoImporterService private static bool isPlusTitleUnavailable(ImportItem item) => item.DtoItem.ContentType is null || (item.DtoItem.IsAyce is true && item.DtoItem.Plans?.Any(p => p.IsAyce) is not true); - - /// - /// Determines when your audible plus or free book will expire from your library - /// plan.IsAyce from underlying AudibleApi project determines the plans to look at, first plan found is used. - /// In some cases current date is later than end date so exclude. - /// - /// The DateTime that this title will become unavailable, otherwise null - private static DateTime? GetExpirationDate(ImportItem item) - => item.DtoItem.Plans - ?.Where(p => p.IsAyce) - .Select(p => p.EndDate) - .FirstOrDefault(end => end.HasValue && end.Value.Year is not (2099 or 9999) && end.Value.LocalDateTime >= DateTime.Now) - ?.DateTime; } } \ No newline at end of file diff --git a/Source/LibationUiBase/GridView/GridEntry.cs b/Source/LibationUiBase/GridView/GridEntry.cs index 84c0f9d5..f85b91e8 100644 --- a/Source/LibationUiBase/GridView/GridEntry.cs +++ b/Source/LibationUiBase/GridView/GridEntry.cs @@ -146,7 +146,10 @@ namespace LibationUiBase.GridView { //If UserDefinedItem was changed on a different Book instance (such as when batch liberating via menus), //Liberate.Book and LibraryBook.Book instances will not have the current DB state. - Invoke(() => UpdateLibraryBook(new LibraryBook(udi.Book, LibraryBook.DateAdded, LibraryBook.Account))); + var newLB = new LibraryBook(udi.Book, LibraryBook.DateAdded, LibraryBook.Account); + newLB.SetIncludedUntil(LibraryBook.IncludedUntil); + newLB.SetIsAudiblePlus(LibraryBook.IsAudiblePlus); + Invoke(() => UpdateLibraryBook(newLB)); return; } diff --git a/dotnet-tools.json b/dotnet-tools.json index d4937e07..fbb7b764 100644 --- a/dotnet-tools.json +++ b/dotnet-tools.json @@ -3,7 +3,7 @@ "isRoot": true, "tools": { "dotnet-ef": { - "version": "9.0.10", + "version": "10.0.1", "commands": [ "dotnet-ef" ], From 1514de54da9688a3794296c201ba70f03cc4a540 Mon Sep 17 00:00:00 2001 From: Michael Bucari-Tovo Date: Thu, 8 Jan 2026 13:00:47 -0700 Subject: [PATCH 03/10] Add menu option to remove Plus books from Audible --- .../Views/ProductsDisplay.axaml.cs | 14 ++++ .../GridView/GridContextMenu.cs | 66 +++++++++++++++++++ .../GridView/ProductsDisplay.cs | 10 +++ 3 files changed, 90 insertions(+) diff --git a/Source/LibationAvalonia/Views/ProductsDisplay.axaml.cs b/Source/LibationAvalonia/Views/ProductsDisplay.axaml.cs index b4c7d25a..4684ab47 100644 --- a/Source/LibationAvalonia/Views/ProductsDisplay.axaml.cs +++ b/Source/LibationAvalonia/Views/ProductsDisplay.axaml.cs @@ -351,6 +351,20 @@ namespace LibationAvalonia.Views }) }); } + #endregion + #region Remove Audible Plus Books from Audible Library + + if (entries.Length != 1 || ctx.RemoveFromAudibleEnabled) + { + args.ContextMenuItems.Add(new Separator()); + args.ContextMenuItems.Add(new MenuItem + { + Header = ctx.RemoveFromAudibleText, + IsEnabled = ctx.RemoveFromAudibleEnabled, + Command = ReactiveCommand.CreateFromTask(ctx.RemoveFromAudibleAsync) + }); + } + #endregion if (entries.Length > 1) diff --git a/Source/LibationUiBase/GridView/GridContextMenu.cs b/Source/LibationUiBase/GridView/GridContextMenu.cs index a5d05d96..210b28f9 100644 --- a/Source/LibationUiBase/GridView/GridContextMenu.cs +++ b/Source/LibationUiBase/GridView/GridContextMenu.cs @@ -1,9 +1,15 @@ using ApplicationServices; using DataLayer; +using Dinah.Core; +using DocumentFormat.OpenXml.Office2010.ExcelAc; +using DocumentFormat.OpenXml.Wordprocessing; using FileLiberator; using LibationFileManager; using LibationFileManager.Templates; +using LibationUiBase.Forms; +using Lucene.Net.Messages; using System; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; @@ -17,6 +23,7 @@ public class GridContextMenu public string SetDownloadedText => $"Set Download status to '{Accelerator}Downloaded'"; public string SetNotDownloadedText => $"Set Download status to '{Accelerator}Not Downloaded'"; public string RemoveText => $"{Accelerator}Remove from library"; + public string RemoveFromAudibleText => $"Remove Plus {(GridEntries.Count(e => e.LibraryBook.IsAudiblePlus) == 1 ? "Book" : "Books")} from Audible Library"; public string LocateFileText => $"{Accelerator}Locate file..."; public string LocateFileDialogTitle => $"Locate the audio file for '{GridEntries[0].Book?.TitleWithSubtitle ?? "[null]"}'"; public string LocateFileErrorMessage => "Error saving book's location"; @@ -37,6 +44,7 @@ public class GridContextMenu public bool ConvertToMp3Enabled => LibraryBookEntries.Any(ge => ge.Book?.UserDefinedItem.BookStatus is LiberatedStatus.Liberated); public bool DownloadAsChaptersEnabled => LibraryBookEntries.Any(ge => ge.Book?.UserDefinedItem.BookStatus is not LiberatedStatus.Error); public bool ReDownloadEnabled => LibraryBookEntries.Any(ge => ge.Book?.UserDefinedItem.BookStatus is LiberatedStatus.Liberated); + public bool RemoveFromAudibleEnabled => LibraryBookEntries.Any(ge => ge.LibraryBook.IsAudiblePlus); private GridEntry[] GridEntries { get; } public LibraryBookEntry[] LibraryBookEntries { get; } @@ -84,6 +92,64 @@ public class GridContextMenu await LibraryBookEntries.Select(e => e.LibraryBook).RemoveBooksAsync(); } + public async Task RemoveFromAudibleAsync() + { + List removedFromAudible = []; + List failedToRemove = []; + + foreach (var entry in LibraryBookEntries.Select(l => l.LibraryBook).Where(lb => lb.IsAudiblePlus)) + { + try + { + var api = await entry.GetApiAsync(); + var success = await api.RemoveItemFromLibraryAsync(entry.Book.AudibleProductId); + if (success) + { + removedFromAudible.Add(entry); + } + else + { + failedToRemove.Add(entry); + } + } + catch (Exception ex) + { + Serilog.Log.Logger.Error(ex, "Failed to remove book from audible account. {@Book}", entry.LogFriendly()); + failedToRemove.Add(entry); + } + } + if (failedToRemove.Count > 0) + { + var count = failedToRemove.Count; + string bookBooks = count == 1 ? "book" : "books"; + + var message = $""" + Failed to remove {count} {bookBooks} from Audible. + + {failedToRemove.AggregateTitles()} + """; + await MessageBoxBase.Show(message, $"Failed to Remove {bookBooks.FirstCharToUpper()} from Audible"); + } + try + { + await removedFromAudible.PermanentlyDeleteBooksAsync(); + } + catch (Exception ex) + { + Serilog.Log.Logger.Error(ex, "Failed to delete locally removed from Audible books."); + + var count = removedFromAudible.Count; + string bookBooks = count == 1 ? "book" : "books"; + + var message = $""" + Failed to delete {count} {bookBooks} from Libation. + + {removedFromAudible.AggregateTitles()} + """; + await MessageBoxBase.Show(message, $"Failed to Delete {bookBooks.FirstCharToUpper()} from Libation"); + } + } + public ITemplateEditor CreateTemplateEditor(LibraryBook libraryBook, string existingTemplate) where T : Templates, ITemplate, new() { diff --git a/Source/LibationWinForms/GridView/ProductsDisplay.cs b/Source/LibationWinForms/GridView/ProductsDisplay.cs index c51655ef..1f14761b 100644 --- a/Source/LibationWinForms/GridView/ProductsDisplay.cs +++ b/Source/LibationWinForms/GridView/ProductsDisplay.cs @@ -262,7 +262,17 @@ namespace LibationWinForms.GridView } #endregion + #region Remove Audible Plus Books from Audible Library + if (entries.Length != 1 || ctx.RemoveFromAudibleEnabled) + { + ctxMenu.Items.Add(new ToolStripSeparator()); + var removeFromAudibleMenuItem = new ToolStripMenuItem() { Text = ctx.RemoveFromAudibleText, Enabled = ctx.RemoveFromAudibleEnabled }; + removeFromAudibleMenuItem.Click += async (_, _) => await ctx.RemoveFromAudibleAsync(); + ctxMenu.Items.Add(removeFromAudibleMenuItem); + } + + #endregion if (entries.Length > 1) return; From 7b68415b02d7c4d2386db40a08cf1e26dd7bfa34 Mon Sep 17 00:00:00 2001 From: Michael Bucari-Tovo Date: Thu, 8 Jan 2026 14:15:41 -0700 Subject: [PATCH 04/10] Add more properties to search engine and library export - Add `IsAudiblePlus` to search engine - Add `IsAudiblePlus` and `AbsentFromLastScan` properties to library export - Refactor library export ToXlsx method - Make nullable - Improve readability and extensability - Use same column header names as CSV - Extend export methods to accept optional list of books (future use) --- Source/ApplicationServices/ExportDto.cs | 142 +++++++ Source/ApplicationServices/LibraryExporter.cs | 355 +++--------------- Source/LibationSearchEngine/SearchEngine.cs | 1 + 3 files changed, 196 insertions(+), 302 deletions(-) create mode 100644 Source/ApplicationServices/ExportDto.cs diff --git a/Source/ApplicationServices/ExportDto.cs b/Source/ApplicationServices/ExportDto.cs new file mode 100644 index 00000000..7221deaf --- /dev/null +++ b/Source/ApplicationServices/ExportDto.cs @@ -0,0 +1,142 @@ +using CsvHelper.Configuration.Attributes; +using DataLayer; +using Newtonsoft.Json; +using System; +using System.Linq; + +#nullable enable +namespace ApplicationServices; + +internal class ExportDto(LibraryBook libBook) +{ + [Name("Account")] + public string Account { get; } = libBook.Account; + + [Name("Date Added to library")] + public DateTime DateAdded { get; } = libBook.DateAdded; + + [Name("Is Audible Plus?")] + public bool IsAudiblePlus { get; } = libBook.IsAudiblePlus; + + [Name("Absent from last scan?")] + public bool AbsentFromLastScan { get; } = libBook.AbsentFromLastScan; + + [Name("Audible Product Id")] + public string AudibleProductId { get; } = libBook.Book.AudibleProductId; + + [Name("Locale")] + public string Locale { get; } = libBook.Book.Locale; + + [Name("Title")] + public string Title { get; } = libBook.Book.Title; + + [Name("Subtitle")] + public string Subtitle { get; } = libBook.Book.Subtitle; + + [Name("Authors")] + public string AuthorNames { get; } = libBook.Book.AuthorNames; + + [Name("Narrators")] + public string NarratorNames { get; } = libBook.Book.NarratorNames; + + [Name("Length In Minutes")] + public int LengthInMinutes { get; } = libBook.Book.LengthInMinutes; + + [Name("Description")] + public string Description { get; } = libBook.Book.Description; + + [Name("Publisher")] + public string Publisher { get; } = libBook.Book.Publisher; + + [Name("Has PDF")] + public bool HasPdf { get; } = libBook.Book.HasPdf; + + [Name("Series Names")] + public string SeriesNames { get; } = libBook.Book.SeriesNames(); + + [Name("Series Order")] + public string SeriesOrder { get; } = libBook.Book.SeriesLink?.Any() is true ? string.Join(", ", libBook.Book.SeriesLink.Select(sl => $"{sl.Order} : {sl.Series.Name}")) : ""; + + [Name("Community Rating: Overall")] + public float? CommunityRatingOverall { get; } = ZeroIsNull(libBook.Book.Rating?.OverallRating); + + [Name("Community Rating: Performance")] + public float? CommunityRatingPerformance { get; } = ZeroIsNull(libBook.Book.Rating?.PerformanceRating); + + [Name("Community Rating: Story")] + public float? CommunityRatingStory { get; } = ZeroIsNull(libBook.Book.Rating?.StoryRating); + + [Name("Cover Id")] + public string PictureId { get; } = libBook.Book.PictureId; + + [Name("Cover Id Large")] + public string PictureLarge { get; } = libBook.Book.PictureLarge; + + [Name("Is Abridged?")] + public bool IsAbridged { get; } = libBook.Book.IsAbridged; + + [Name("Date Published")] + public DateTime? DatePublished { get; } = libBook.Book.DatePublished; + + [Name("Categories")] + public string CategoriesNames { get; } = string.Join("; ", libBook.Book.LowestCategoryNames()); + + [Name("My Rating: Overall")] + public float? MyRatingOverall { get; } = ZeroIsNull(libBook.Book.UserDefinedItem.Rating.OverallRating); + + [Name("My Rating: Performance")] + public float? MyRatingPerformance { get; } = ZeroIsNull(libBook.Book.UserDefinedItem.Rating.PerformanceRating); + + [Name("My Rating: Story")] + public float? MyRatingStory { get; } = ZeroIsNull(libBook.Book.UserDefinedItem.Rating.StoryRating); + + [Name("My Libation Tags")] + public string MyLibationTags { get; } = libBook.Book.UserDefinedItem.Tags; + + [Name("Book Liberated Status")] + public string BookStatus { get; } = libBook.Book.UserDefinedItem.BookStatus.ToString(); + + [Name("PDF Liberated Status")] + public string? PdfStatus { get; } = libBook.Book.UserDefinedItem.PdfStatus.ToString(); + + [Name("Content Type")] + public string ContentType { get; } = libBook.Book.ContentType.ToString(); + + [Name("Language")] + public string Language { get; } = libBook.Book.Language; + + [Name("Last Downloaded")] + public DateTime? LastDownloaded { get; } = libBook.Book.UserDefinedItem.LastDownloaded; + + [Name("Last Downloaded Version")] + public string? LastDownloadedVersion { get; } = libBook.Book.UserDefinedItem.LastDownloadedVersion?.ToString(); + + [Name("Is Finished?")] + public bool IsFinished { get; } = libBook.Book.UserDefinedItem.IsFinished; + + [Name("Is Spatial?")] + public bool IsSpatial { get; } = libBook.Book.IsSpatial; + + [Name("Included Until")] + public DateTime? IncludedUntil { get; } = libBook.IncludedUntil; + + [Name("Last Downloaded File Version")] + public string? LastDownloadedFileVersion { get; } = libBook.Book.UserDefinedItem.LastDownloadedFileVersion; + + [Ignore /* csv ignore */] + public AudioFormat? LastDownloadedFormat { get; } = libBook.Book.UserDefinedItem.LastDownloadedFormat; + + [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; + + private static float? ZeroIsNull(float? value) => value is 0 ? null : value; +} diff --git a/Source/ApplicationServices/LibraryExporter.cs b/Source/ApplicationServices/LibraryExporter.cs index 20cbd2bf..afa73620 100644 --- a/Source/ApplicationServices/LibraryExporter.cs +++ b/Source/ApplicationServices/LibraryExporter.cs @@ -3,328 +3,79 @@ using CsvHelper; using CsvHelper.Configuration.Attributes; using DataLayer; using Newtonsoft.Json; -using System; using System.Collections.Generic; using System.Globalization; using System.Linq; +using System.Reflection; -namespace ApplicationServices +#nullable enable +namespace ApplicationServices; + +public static class LibraryExporter { - public class ExportDto + public static void ToCsv(string saveFilePath, IEnumerable? libraryBooks = null) { - public static string GetName(string fieldName) - { - var property = typeof(ExportDto).GetProperty(fieldName); - var attribute = property.GetCustomAttributes(typeof(NameAttribute), true)[0]; - var description = (NameAttribute)attribute; - var text = description.Names; - return text[0]; - } + libraryBooks ??= DbContexts.GetLibrary_Flat_NoTracking(); + var dtos = libraryBooks.ToDtos(); + if (dtos.Count == 0) + return; - [Name("Account")] - public string Account { get; set; } + using var csv = new CsvWriter(new System.IO.StreamWriter(saveFilePath), CultureInfo.CurrentCulture); + csv.WriteHeader(typeof(ExportDto)); + csv.NextRecord(); + csv.WriteRecords(dtos); } - [Name("Date Added to library")] - public DateTime DateAdded { get; set; } - - [Name("Audible Product Id")] - public string AudibleProductId { get; set; } - - [Name("Locale")] - public string Locale { get; set; } - - [Name("Title")] - public string Title { get; set; } - - [Name("Subtitle")] - public string Subtitle { get; set; } - - [Name("Authors")] - public string AuthorNames { get; set; } - - [Name("Narrators")] - public string NarratorNames { get; set; } - - [Name("Length In Minutes")] - public int LengthInMinutes { get; set; } - - [Name("Description")] - public string Description { get; set; } - - [Name("Publisher")] - public string Publisher { get; set; } - - [Name("Has PDF")] - public bool HasPdf { get; set; } - - [Name("Series Names")] - public string SeriesNames { get; set; } - - [Name("Series Order")] - public string SeriesOrder { get; set; } - - [Name("Community Rating: Overall")] - public float? CommunityRatingOverall { get; set; } - - [Name("Community Rating: Performance")] - public float? CommunityRatingPerformance { get; set; } - - [Name("Community Rating: Story")] - public float? CommunityRatingStory { get; set; } - - [Name("Cover Id")] - public string PictureId { get; set; } - - [Name("Is Abridged?")] - public bool IsAbridged { get; set; } - - [Name("Date Published")] - public DateTime? DatePublished { get; set; } - - [Name("Categories")] - public string CategoriesNames { get; set; } - - [Name("My Rating: Overall")] - public float? MyRatingOverall { get; set; } - - [Name("My Rating: Performance")] - public float? MyRatingPerformance { get; set; } - - [Name("My Rating: Story")] - public float? MyRatingStory { get; set; } - - [Name("My Libation Tags")] - public string MyLibationTags { get; set; } - - [Name("Book Liberated Status")] - public string BookStatus { get; set; } - - [Name("PDF Liberated Status")] - public string PdfStatus { get; set; } - - [Name("Content Type")] - public string ContentType { get; set; } - - [Name("Language")] - public string Language { get; set; } - - [Name("Last Downloaded")] - public DateTime? LastDownloaded { get; set; } - - [Name("Last Downloaded Version")] - public string LastDownloadedVersion { get; set; } - - [Name("Is Finished?")] - public bool IsFinished { get; set; } - - [Name("Is Spatial?")] - public bool IsSpatial { get; set; } - - [Name("Included Until")] - public DateTime? IncludedUntil { 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 void ToJson(string saveFilePath, IEnumerable? libraryBooks = null) + { + libraryBooks ??= DbContexts.GetLibrary_Flat_NoTracking(); + var dtos = libraryBooks.ToDtos(); + var serializer = new JsonSerializer(); + using var writer = new JsonTextWriter(new System.IO.StreamWriter(saveFilePath)) { Formatting = Formatting.Indented }; + serializer.Serialize(writer, dtos); } - public static class LibToDtos + public static void ToXlsx(string saveFilePath, IEnumerable? libraryBooks = null) { - public static List ToDtos(this IEnumerable library) - => library.Select(a => new ExportDto - { - Account = a.Account, - DateAdded = a.DateAdded, - AudibleProductId = a.Book.AudibleProductId, - Locale = a.Book.Locale, - Title = a.Book.Title, - Subtitle = a.Book.Subtitle, - AuthorNames = a.Book.AuthorNames, - NarratorNames = a.Book.NarratorNames, - LengthInMinutes = a.Book.LengthInMinutes, - Description = a.Book.Description, - Publisher = a.Book.Publisher, - 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.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.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(), - ContentType = a.Book.ContentType.ToString(), - Language = a.Book.Language, - LastDownloaded = a.Book.UserDefinedItem.LastDownloaded, - LastDownloadedVersion = a.Book.UserDefinedItem.LastDownloadedVersion?.ToString() ?? "", - IsFinished = a.Book.UserDefinedItem.IsFinished, - IsSpatial = a.Book.IsSpatial, - IncludedUntil = a.IncludedUntil, - LastDownloadedFileVersion = a.Book.UserDefinedItem.LastDownloadedFileVersion ?? "", - LastDownloadedFormat = a.Book.UserDefinedItem.LastDownloadedFormat - }).ToList(); + libraryBooks ??= DbContexts.GetLibrary_Flat_NoTracking(); + var dtos = libraryBooks.ToDtos(); - private static float? ZeroIsNull(this float value) => value is 0 ? null : value; - } - public static class LibraryExporter - { - public static void ToCsv(string saveFilePath) + using var workbook = new XLWorkbook(); + var sheet = workbook.AddWorksheet("Library"); + var columns = typeof(ExportDto).GetProperties().Where(p => p.GetCustomAttribute() is not null).ToArray(); + + // headers + var currentRow = sheet.FirstRow(); + var currentCell = currentRow.FirstCell(); + foreach (var column in columns) { - 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); - - csv.WriteHeader(typeof(ExportDto)); - csv.NextRecord(); - csv.WriteRecords(dtos); + currentCell.Value = GetColumnName(column); + currentCell.Style.Font.Bold = true; + currentCell = currentCell.CellRight(); } - public static void ToJson(string saveFilePath) + var dateFormat = CultureInfo.CurrentCulture.DateTimeFormat.ShortDatePattern + " HH:mm:ss"; + + // Add data rows + foreach (var dto in dtos) { - var dtos = DbContexts.GetLibrary_Flat_NoTracking().ToDtos(); - var json = JsonConvert.SerializeObject(dtos, Formatting.Indented); - System.IO.File.WriteAllText(saveFilePath, json); - } + currentRow = currentRow.RowBelow(); + currentCell = currentRow.FirstCell(); - public static void ToXlsx(string saveFilePath) - { - var dtos = DbContexts.GetLibrary_Flat_NoTracking().ToDtos(); - - using var workbook = new XLWorkbook(); - var sheet = workbook.AddWorksheet("Library"); - - - // headers - var columns = new[] { - nameof(ExportDto.Account), - nameof(ExportDto.DateAdded), - nameof(ExportDto.AudibleProductId), - nameof(ExportDto.Locale), - nameof(ExportDto.Title), - nameof(ExportDto.Subtitle), - nameof(ExportDto.AuthorNames), - nameof(ExportDto.NarratorNames), - nameof(ExportDto.LengthInMinutes), - nameof(ExportDto.Description), - nameof(ExportDto.Publisher), - nameof(ExportDto.HasPdf), - nameof(ExportDto.SeriesNames), - nameof(ExportDto.SeriesOrder), - nameof(ExportDto.CommunityRatingOverall), - nameof(ExportDto.CommunityRatingPerformance), - nameof(ExportDto.CommunityRatingStory), - nameof(ExportDto.PictureId), - nameof(ExportDto.IsAbridged), - nameof(ExportDto.DatePublished), - nameof(ExportDto.CategoriesNames), - nameof(ExportDto.MyRatingOverall), - nameof(ExportDto.MyRatingPerformance), - nameof(ExportDto.MyRatingStory), - nameof(ExportDto.MyLibationTags), - nameof(ExportDto.BookStatus), - nameof(ExportDto.PdfStatus), - nameof(ExportDto.ContentType), - nameof(ExportDto.Language), - nameof(ExportDto.LastDownloaded), - nameof(ExportDto.LastDownloadedVersion), - nameof(ExportDto.IsFinished), - nameof(ExportDto.IsSpatial), - nameof(ExportDto.IncludedUntil), - nameof(ExportDto.LastDownloadedFileVersion), - nameof(ExportDto.CodecString), - nameof(ExportDto.SampleRate), - nameof(ExportDto.ChannelCount), - nameof(ExportDto.BitRate) - }; - - int rowIndex = 1, col = 1; - var headerRow = sheet.Row(rowIndex++); - foreach (var c in columns) + foreach (var column in columns) { - var headerCell = headerRow.Cell(col++); - headerCell.Value = ExportDto.GetName(c); - headerCell.Style.Font.Bold = true; + var value = column.GetValue(dto); + currentCell.Value = XLCellValue.FromObject(value); + currentCell.Style.DateFormat.Format = currentCell.DataType is XLDataType.DateTime ? dateFormat : string.Empty; + currentCell = currentCell.CellRight(); } - - var dateFormat = CultureInfo.CurrentCulture.DateTimeFormat.ShortDatePattern + " HH:mm:ss"; - - // Add data rows - foreach (var dto in dtos) - { - col = 1; - var row = sheet.Row(rowIndex++); - - row.Cell(col++).Value = dto.Account; - row.Cell(col++).SetDate(dto.DateAdded, dateFormat); - row.Cell(col++).Value = dto.AudibleProductId; - row.Cell(col++).Value = dto.Locale; - row.Cell(col++).Value = dto.Title; - row.Cell(col++).Value = dto.Subtitle; - row.Cell(col++).Value = dto.AuthorNames; - row.Cell(col++).Value = dto.NarratorNames; - row.Cell(col++).Value = dto.LengthInMinutes; - row.Cell(col++).Value = dto.Description; - row.Cell(col++).Value = dto.Publisher; - row.Cell(col++).Value = dto.HasPdf; - row.Cell(col++).Value = dto.SeriesNames; - row.Cell(col++).Value = dto.SeriesOrder; - row.Cell(col++).Value = dto.CommunityRatingOverall; - row.Cell(col++).Value = dto.CommunityRatingPerformance; - row.Cell(col++).Value = dto.CommunityRatingStory; - row.Cell(col++).Value = dto.PictureId; - row.Cell(col++).Value = dto.IsAbridged; - row.Cell(col++).SetDate(dto.DatePublished, dateFormat); - row.Cell(col++).Value = dto.CategoriesNames; - row.Cell(col++).Value = dto.MyRatingOverall; - row.Cell(col++).Value = dto.MyRatingPerformance; - row.Cell(col++).Value = dto.MyRatingStory; - row.Cell(col++).Value = dto.MyLibationTags; - row.Cell(col++).Value = dto.BookStatus; - row.Cell(col++).Value = dto.PdfStatus; - row.Cell(col++).Value = dto.ContentType; - row.Cell(col++).Value = dto.Language; - row.Cell(col++).SetDate(dto.LastDownloaded, dateFormat); - row.Cell(col++).Value = dto.LastDownloadedVersion; - row.Cell(col++).Value = dto.IsFinished; - row.Cell(col++).Value = dto.IsSpatial; - row.Cell(col++).Value = dto.IncludedUntil; - row.Cell(col++).Value = dto.LastDownloadedFileVersion; - row.Cell(col++).Value = dto.CodecString; - row.Cell(col++).Value = dto.SampleRate; - row.Cell(col++).Value = dto.ChannelCount; - row.Cell(col++).Value = dto.BitRate; - } - - workbook.SaveAs(saveFilePath); } - private static void SetDate(this IXLCell cell, DateTime? value, string dateFormat) - { - cell.Value = value; - cell.Style.DateFormat.Format = dateFormat; - } + workbook.SaveAs(saveFilePath); } + + private static List ToDtos(this IEnumerable library) + => library.Select(a => new ExportDto(a)).ToList(); + + private static string GetColumnName(PropertyInfo property) + => property.GetCustomAttribute()?.Names?.FirstOrDefault() ?? property.Name; } diff --git a/Source/LibationSearchEngine/SearchEngine.cs b/Source/LibationSearchEngine/SearchEngine.cs index 5f2bede1..d7ad3056 100644 --- a/Source/LibationSearchEngine/SearchEngine.cs +++ b/Source/LibationSearchEngine/SearchEngine.cs @@ -59,6 +59,7 @@ namespace LibationSearchEngine { FieldType.Bool, lb => lb.AbsentFromLastScan.ToString(), "AbsentFromLastScan", "Absent" }, { FieldType.Bool, lb => (!string.IsNullOrWhiteSpace(lb.Book.SeriesNames())).ToString(), "IsInSeries", "InSeries" }, { FieldType.Bool, lb => lb.Book.UserDefinedItem.IsFinished.ToString(), nameof(UserDefinedItem.IsFinished), "Finished", "IsFinished" }, + { FieldType.Bool, lb => lb.IsAudiblePlus.ToString(), nameof(LibraryBook.IsAudiblePlus), "AudiblePlus", "Plus" }, // all numbers are padded to 8 char.s // This will allow a single method to auto-pad numbers. The method will match these as well as date: yyyymmdd { FieldType.Number, lb => lb.Book.LengthInMinutes.ToLuceneString(), nameof(Book.LengthInMinutes), "Length", "Minutes" }, From dc58a101af5511c686895d1498b8e9e7c0a673c0 Mon Sep 17 00:00:00 2001 From: Michael Bucari-Tovo Date: Thu, 8 Jan 2026 16:54:43 -0700 Subject: [PATCH 05/10] Add cli export option to specify Asins --- Source/LibationCli/Options/ExportOptions.cs | 22 ++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/Source/LibationCli/Options/ExportOptions.cs b/Source/LibationCli/Options/ExportOptions.cs index e3cb3602..c7ed5a0c 100644 --- a/Source/LibationCli/Options/ExportOptions.cs +++ b/Source/LibationCli/Options/ExportOptions.cs @@ -1,16 +1,20 @@ using ApplicationServices; using CommandLine; +using DataLayer; using System; +using System.Collections.Generic; using System.IO; +using System.Linq; using System.Threading.Tasks; +#nullable enable namespace LibationCli { [Verb("export", HelpText = "Must include path and flag for export file type: --xlsx , --csv , --json")] public class ExportOptions : OptionsBase { [Option(shortName: 'p', longName: "path", Required = true, HelpText = "Path to save file to.")] - public string FilePath { get; set; } + public string? FilePath { get; set; } #region explanation of mutually exclusive options /* @@ -36,9 +40,12 @@ namespace LibationCli [Option(shortName: 'j', longName: "json", HelpText = "JavaScript Object Notation", SetName = "json")] public bool json { get; set; } + [Value(0, MetaName = "[asins]", HelpText = "Optional product IDs of books to process.")] + public IEnumerable? Asins { get; set; } + protected override Task ProcessAsync() { - Action exporter + Action?>? exporter = csv ? LibraryExporter.ToCsv : json ? LibraryExporter.ToJson : xlsx ? LibraryExporter.ToXlsx @@ -54,9 +61,18 @@ namespace LibationCli { PrintVerbUsage($"Undefined export format for file type \"{Path.GetExtension(FilePath)}\""); } + else if (FilePath is null) + { + PrintVerbUsage($"Undefined export file name"); + } else { - exporter(FilePath); + IEnumerable? booksToScan = null; + if (Asins?.Any() is true) + { + booksToScan = DbContexts.GetLibrary_Flat_NoTracking().IntersectBy(Asins, l => l.Book.AudibleProductId); + } + exporter(FilePath, booksToScan); Console.WriteLine($"Library exported to: {FilePath}"); } return Task.CompletedTask; From 068f37319f1f33e45347e947ee894080ab31c433 Mon Sep 17 00:00:00 2001 From: Michael Bucari-Tovo Date: Thu, 8 Jan 2026 18:36:57 -0700 Subject: [PATCH 06/10] Add option to adjust minimum file duration when splitting audiobooks into multiple files by chapter --- .../AaxcDownloadMultiConverter.cs | 48 +---- .../FileLiberator/DownloadOptions.Factory.cs | 79 ++++++-- Source/LibationAvalonia/App.axaml | 23 +++ .../Controls/Settings/Audio.axaml | 183 ++++++++++-------- .../ViewModels/Settings/AudioSettingsVM.cs | 5 + .../Views/ProcessQueueControl.axaml | 3 + .../Configuration.HelpText.cs | 7 + .../Configuration.PersistentSettings.cs | 3 + .../Dialogs/SettingsDialog.AudioSettings.cs | 7 +- .../Dialogs/SettingsDialog.Designer.cs | 33 +++- 10 files changed, 240 insertions(+), 151 deletions(-) diff --git a/Source/AaxDecrypter/AaxcDownloadMultiConverter.cs b/Source/AaxDecrypter/AaxcDownloadMultiConverter.cs index 5589ebe1..51832c65 100644 --- a/Source/AaxDecrypter/AaxcDownloadMultiConverter.cs +++ b/Source/AaxDecrypter/AaxcDownloadMultiConverter.cs @@ -40,58 +40,14 @@ namespace AaxDecrypter } } - /* -https://github.com/rmcrackan/Libation/pull/127#issuecomment-939088489 -If the chapter truly is empty, that is, 0 audio frames in length, then yes it is ignored. -If the chapter is shorter than 3 seconds long but still has some audio frames, those frames are combined with the following chapter and not split into a new file. - -I also implemented file naming by chapter title. When 2 or more consecutive chapters are combined, the first of the combined chapter's title is used in the file name. For example, given an audiobook with the following chapters: - -00:00:00 - 00:00:02 | Part 1 -00:00:02 - 00:35:00 | Chapter 1 -00:35:02 - 01:02:00 | Chapter 2 -01:02:00 - 01:02:02 | Part 2 -01:02:02 - 01:41:00 | Chapter 3 -01:41:00 - 02:05:00 | Chapter 4 - -The book will be split into the following files: - -00:00:00 - 00:35:00 | Book - 01 - Part 1.m4b -00:35:00 - 01:02:00 | Book - 02 - Chapter 2.m4b -01:02:00 - 01:41:00 | Book - 03 - Part 2.m4b -01:41:00 - 02:05:00 | Book - 04 - Chapter 4.m4b - -That naming may not be desirable for everyone, but it's an easy change to instead use the last of the combined chapter's title in the file name. - */ 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. - var splitChapters = new ChapterInfo(DownloadOptions.ChapterInfo.StartOffset); - - var runningTotal = TimeSpan.Zero; - string title = ""; - - for (int i = 0; i < chapters.Count; i++) - { - if (runningTotal == TimeSpan.Zero) - title = chapters[i].Title; - - runningTotal += chapters[i].Duration; - - if (runningTotal >= minChapterLength) - { - splitChapters.AddChapter(title, runningTotal); - runningTotal = TimeSpan.Zero; - } - } - + try { - await (AaxConversion = decryptMultiAsync(AaxFile, splitChapters)); + await (AaxConversion = decryptMultiAsync(AaxFile, DownloadOptions.ChapterInfo)); if (AaxConversion.IsCompletedSuccessfully) await moveMoovToBeginning(AaxFile, workingFileStream?.Name); diff --git a/Source/FileLiberator/DownloadOptions.Factory.cs b/Source/FileLiberator/DownloadOptions.Factory.cs index 7f3fd0a8..9aaf8e8b 100644 --- a/Source/FileLiberator/DownloadOptions.Factory.cs +++ b/Source/FileLiberator/DownloadOptions.Factory.cs @@ -170,17 +170,6 @@ public partial class DownloadOptions /// public static DownloadOptions BuildDownloadOptions(LibraryBook libraryBook, Configuration config, LicenseInfo licInfo) { - long chapterStartMs - = config.StripAudibleBrandAudio - ? licInfo.ContentMetadata.ChapterInfo.BrandIntroDurationMs - : 0; - - var dlOptions = new DownloadOptions(config, libraryBook, licInfo) - { - ChapterInfo = new AAXClean.ChapterInfo(TimeSpan.FromMilliseconds(chapterStartMs)), - RuntimeLength = TimeSpan.FromMilliseconds(licInfo.ContentMetadata.ChapterInfo.RuntimeLengthMs), - }; - var titleConcat = config.CombineNestedChapterTitles ? ": " : null; var chapters = flattenChapters(licInfo.ContentMetadata.ChapterInfo.Chapters, titleConcat) @@ -190,18 +179,22 @@ public partial class DownloadOptions if (config.MergeOpeningAndEndCredits) combineCredits(chapters); + if (config.StripAudibleBrandAudio) + stripBranding(chapters, licInfo.ContentMetadata.ChapterInfo.BrandIntroDurationMs, licInfo.ContentMetadata.ChapterInfo.BrandOutroDurationMs); + + if (config.SplitFilesByChapter) + combineShortChapters(chapters, config.MinimumFileDuration * 1000); + + var dlOptions = new DownloadOptions(config, libraryBook, licInfo) + { + ChapterInfo = new AAXClean.ChapterInfo(TimeSpan.FromMilliseconds(chapters[0].StartOffsetMs)), + RuntimeLength = TimeSpan.FromMilliseconds(licInfo.ContentMetadata.ChapterInfo.RuntimeLengthMs), + }; + + //Build AAXClean.ChapterInfo for (int i = 0; i < chapters.Count; i++) { - var chapter = chapters[i]; - long chapLenMs = chapter.LengthMs; - - if (i == 0) - chapLenMs -= chapterStartMs; - - if (config.StripAudibleBrandAudio && i == chapters.Count - 1) - chapLenMs -= licInfo.ContentMetadata.ChapterInfo.BrandOutroDurationMs; - - dlOptions.ChapterInfo.AddChapter(chapter.Title, TimeSpan.FromMilliseconds(chapLenMs)); + dlOptions.ChapterInfo.AddChapter(chapters[i].Title, TimeSpan.FromMilliseconds(chapters[i].LengthMs)); } return dlOptions; @@ -349,6 +342,50 @@ public partial class DownloadOptions return chaps; } + /* + https://github.com/rmcrackan/Libation/pull/127#issuecomment-939088489 + + If the chapter truly is empty, that is, 0 audio frames in length, then yes it is ignored. + If the chapter is shorter than minChapterLength but still has some audio frames, those + frames are combined with the following chapter and not split into a new file. + + When 2 or more consecutive chapters are combined, chapter titles are concatenated + with a apace in between. For example, given an audiobook with the following chapters: + + 00:00:00 - 00:00:02 | Part 1 + 00:00:02 - 00:35:00 | Chapter 1 + 00:35:02 - 01:02:00 | Chapter 2 + 01:02:00 - 01:02:02 | Part 2 + 01:02:02 - 01:41:00 | Chapter 3 + 01:41:00 - 02:05:00 | Chapter 4 + + The book will be split into the following files: + + 00:00:00 - 00:35:00 | Book - 01 - Part 1 Chapter 1.m4b + 00:35:00 - 01:02:00 | Book - 02 - Chapter 2.m4b + 01:02:00 - 01:41:00 | Book - 03 - Part 2.m4b + 01:41:00 - 02:05:00 | Book - 04 - Chapter 4.m4b + */ + public static void combineShortChapters(List chapters, long minChapterLengthMs) + { + for (int i = 0; i < chapters.Count; i++) + { + while (chapters[i].LengthMs < minChapterLengthMs && chapters.Count > i + 1) + { + chapters[i].Title += " " + chapters[i + 1].Title; + chapters[i].LengthMs += chapters[i + 1].LengthMs; + chapters.RemoveAt(i + 1); + } + } + } + + public static void stripBranding(List chapters, long introMs, long outroMs) + { + chapters[0].LengthMs -= introMs; + chapters[0].StartOffsetMs += introMs; + chapters[^1].LengthMs -= outroMs; + } + public static void combineCredits(IList chapters) { if (chapters.Count > 1 && chapters[0].Title == "Opening Credits") diff --git a/Source/LibationAvalonia/App.axaml b/Source/LibationAvalonia/App.axaml index ebd9b60c..f8dcd23d 100644 --- a/Source/LibationAvalonia/App.axaml +++ b/Source/LibationAvalonia/App.axaml @@ -101,6 +101,29 @@ + + + + + + + diff --git a/Source/LibationAvalonia/Controls/Settings/Audio.axaml b/Source/LibationAvalonia/Controls/Settings/Audio.axaml index 6b0a1544..55b8fa73 100644 --- a/Source/LibationAvalonia/Controls/Settings/Audio.axaml +++ b/Source/LibationAvalonia/Controls/Settings/Audio.axaml @@ -6,6 +6,7 @@ xmlns:controls="clr-namespace:LibationAvalonia.Controls" xmlns:vm="clr-namespace:LibationAvalonia.ViewModels.Settings" x:DataType="vm:AudioSettingsVM" + x:CompileBindings="True" x:Class="LibationAvalonia.Controls.Settings.Audio"> + Text="{Binding FileDownloadQualityText}" /> + ItemsSource="{Binding DownloadQualities}" + SelectedItem="{Binding FileDownloadQuality}"/> - + IsChecked="{Binding UseWidevine, Mode=TwoWay}"> + - + ToolTip.Tip="{Binding Request_xHE_AACTip}" + IsEnabled="{Binding UseWidevine}" + IsChecked="{Binding Request_xHE_AAC, Mode=TwoWay}"> + - + ToolTip.Tip="{Binding RequestSpatialTip}" + IsEnabled="{Binding UseWidevine}" + IsChecked="{Binding RequestSpatial, Mode=TwoWay}"> + + ToolTip.Tip="{Binding SpatialAudioCodecTip}"> @@ -92,78 +93,100 @@ Margin="5,0,0,0" Grid.Column="1" VerticalAlignment="Center" - ItemsSource="{CompiledBinding SpatialAudioCodecs}" - SelectedItem="{CompiledBinding SpatialAudioCodec}"/> + ItemsSource="{Binding SpatialAudioCodecs}" + SelectedItem="{Binding SpatialAudioCodec}"/> - - + + - - + + - + + IsEnabled="{Binding DownloadClipsBookmarks}" + ItemsSource="{Binding ClipBookmarkFormats}" + SelectedItem="{Binding ClipBookmarkFormat}"/> - + IsChecked="{Binding RetainAaxFile, Mode=TwoWay}" + ToolTip.Tip="{Binding RetainAaxFileTip}"> + - + IsChecked="{Binding MergeOpeningAndEndCredits, Mode=TwoWay}" + ToolTip.Tip="{Binding MergeOpeningAndEndCreditsTip}"> + - + ToolTip.Tip="{Binding CombineNestedChapterTitlesTip}" + IsChecked="{Binding CombineNestedChapterTitles, Mode=TwoWay}"> + - + ToolTip.Tip="{Binding AllowLibationFixupTip}" + IsChecked="{Binding AllowLibationFixup, Mode=TwoWay}"> + + IsEnabled="{Binding AllowLibationFixup}"> - - + + + + + + + + + + + + + - - - - - + IsChecked="{Binding StripUnabridged, Mode=TwoWay}" + ToolTip.Tip="{Binding StripUnabridgedTip}"> + @@ -178,24 +201,24 @@ Margin="10,0,0,0"> + IsChecked="{Binding !DecryptToLossy, Mode=TwoWay}" + ToolTip.Tip="{Binding DecryptToLossyTip}"> - + IsEnabled="{Binding !DecryptToLossy}" + IsChecked="{Binding MoveMoovToBeginning, Mode=TwoWay}" + ToolTip.Tip="{Binding MoveMoovToBeginningTip}"> + + IsChecked="{Binding DecryptToLossy, Mode=TwoWay}" + ToolTip.Tip="{Binding DecryptToLossyTip}"> @@ -203,7 +226,7 @@ @@ -220,21 +243,21 @@ + IsChecked="{Binding LameTargetBitrate, Mode=TwoWay}"/> + IsChecked="{Binding !LameTargetBitrate, Mode=TwoWay}"/> + IsChecked="{Binding LameDownsampleMono, Mode=TwoWay}" + ToolTip.Tip="{Binding LameDownsampleMonoTip}"> + ItemsSource="{Binding SampleRates}" + SelectedItem="{Binding SelectedSampleRate, Mode=TwoWay}"/> @@ -258,23 +281,23 @@ Grid.Column="2" Grid.Row="1" HorizontalAlignment="Stretch" - ItemsSource="{CompiledBinding EncoderQualities}" - SelectedItem="{CompiledBinding SelectedEncoderQuality, Mode=TwoWay}"/> + ItemsSource="{Binding EncoderQualities}" + SelectedItem="{Binding SelectedEncoderQuality, Mode=TwoWay}"/> + IsEnabled="{Binding LameTargetBitrate}" > + diff --git a/Source/LibationAvalonia/Views/ProductsDisplay.axaml.cs b/Source/LibationAvalonia/Views/ProductsDisplay.axaml.cs index 4684ab47..ebf6d77d 100644 --- a/Source/LibationAvalonia/Views/ProductsDisplay.axaml.cs +++ b/Source/LibationAvalonia/Views/ProductsDisplay.axaml.cs @@ -1,8 +1,6 @@ -using ApplicationServices; using Avalonia; using Avalonia.Controls; using Avalonia.Input.Platform; -using Avalonia.Media; using Avalonia.Platform.Storage; using Avalonia.Styling; using DataLayer; @@ -30,13 +28,31 @@ namespace LibationAvalonia.Views public event EventHandler? ConvertToMp3Clicked; public event EventHandler? TagsButtonClicked; + + public static readonly StyledProperty DisableContextMenuProperty = + AvaloniaProperty.Register(nameof(DisableContextMenu)); + + public static readonly StyledProperty DisableColumnCustomizationProperty = + AvaloniaProperty.Register(nameof(DisableColumnCustomization)); + + public bool DisableContextMenu + { + get { return GetValue(DisableContextMenuProperty); } + set { SetValue(DisableContextMenuProperty, value); } + } + + public bool DisableColumnCustomization + { + get { return GetValue(DisableColumnCustomizationProperty); } + set { SetValue(DisableColumnCustomizationProperty, value); } + } + private ProductsDisplayViewModel? _viewModel => DataContext as ProductsDisplayViewModel; ImageDisplayDialog? imageDisplayDialog; public ProductsDisplay() { InitializeComponent(); - DataGridContextMenus.CellContextMenuStripNeeded += ProductsGrid_CellContextMenuStripNeeded; var cellSelector = Selectors.Is(null); rowHeightStyle = new Style(_ => cellSelector); @@ -91,6 +107,18 @@ namespace LibationAvalonia.Views } } + protected override void OnApplyTemplate(Avalonia.Controls.Primitives.TemplateAppliedEventArgs e) + { + ApplyDisableColumnCustimaziton(); + base.OnApplyTemplate(e); + } + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + if (change.Property == DisableColumnCustomizationProperty) + ApplyDisableColumnCustimaziton(); + base.OnPropertyChanged(change); + } + private void ProductsDisplay_LoadingRow(object sender, DataGridRowEventArgs e) { if (e.Row.DataContext is LibraryBookEntry entry && entry.Liberate?.IsEpisode is true) @@ -180,10 +208,19 @@ namespace LibationAvalonia.Views #endregion #region Cell Context Menu - - public void ProductsGrid_CellContextMenuStripNeeded(object? sender, DataGridCellContextMenuStripNeededEventArgs args) + public void GridCellContextMenu_Opening(object? sender, System.ComponentModel.CancelEventArgs e) { - var entries = args.GridEntries; + e.Cancel = DisableContextMenu; + } + + //Use Opened instead of opening because the parent is not set yet in Opening + public void GridCellContextMenu_Opened(object? sender, Avalonia.Interactivity.RoutedEventArgs e) + { + if (sender is not ContextMenu contextMenu || + DataGridCellContextMenu.Create(contextMenu) is not { } args) + return; + + var entries = args.RowItems; var ctx = new GridContextMenu(entries, '_'); if (App.MainWindow?.Clipboard is IClipboard clipboard) @@ -206,8 +243,8 @@ namespace LibationAvalonia.Views }); args.ContextMenuItems.Add(new Separator()); - } - + } + #region Liberate all Episodes (Single series only) @@ -454,13 +491,13 @@ namespace LibationAvalonia.Views var itemName = column.SortMemberPath; if (itemName == nameof(GridEntry.Remove)) continue; - + GridHeaderContextMenu.Items.Add(new MenuItem { Header = new CheckBox { Content = new TextBlock { Text = ((string)column.Header).Replace('\n', ' ') } }, Tag = column, }); - + column.IsVisible = Configuration.Instance.GetColumnVisibility(itemName); } @@ -478,10 +515,19 @@ namespace LibationAvalonia.Views } } - public void ContextMenu_ContextMenuOpening(object? sender, System.ComponentModel.CancelEventArgs e) + private void ApplyDisableColumnCustimaziton() { - if (sender is not ContextMenu contextMenu) + _viewModel?.DisablePersistColumnWidths = DisableColumnCustomization; + productsGrid.CanUserReorderColumns = !DisableColumnCustomization; + } + + public void GridHeaderContextMenu_Opening(object? sender, System.ComponentModel.CancelEventArgs e) + { + if (DisableContextMenu || sender is not ContextMenu contextMenu) + { + e.Cancel = true; return; + } foreach (var mi in contextMenu.Items.OfType()) { if (mi.Tag is DataGridColumn column && mi.Header is CheckBox cbox) @@ -491,7 +537,7 @@ namespace LibationAvalonia.Views } } - public void ContextMenu_MenuClosed(object? sender, Avalonia.Interactivity.RoutedEventArgs e) + public void GridHeaderContextMenu_Closed(object? sender, Avalonia.Interactivity.RoutedEventArgs e) { if (sender is not ContextMenu contextMenu) return; @@ -518,6 +564,7 @@ namespace LibationAvalonia.Views private void ProductsGrid_ColumnDisplayIndexChanged(object? sender, DataGridColumnEventArgs e) { + if (DisableColumnCustomization) return; var config = Configuration.Instance; var dictionary = config.GridColumnsDisplayIndices; diff --git a/Source/LibationSearchEngine/SearchEngine.cs b/Source/LibationSearchEngine/SearchEngine.cs index d7ad3056..03ff8192 100644 --- a/Source/LibationSearchEngine/SearchEngine.cs +++ b/Source/LibationSearchEngine/SearchEngine.cs @@ -93,6 +93,12 @@ namespace LibationSearchEngine } } + public SearchEngine(string directory = null) + { + SearchEngineDirectory = directory + ?? new System.IO.DirectoryInfo(Configuration.Instance.LibationFiles.Location).CreateSubdirectoryEx("SearchEngine").FullName; + } + /// Long running. Use await Task.Run(() => UpdateBook(productId)) public void UpdateBook(LibationContext context, string productId) { @@ -131,7 +137,7 @@ namespace LibationSearchEngine public void UpdateTags(string productId, string tags) => updateAnalyzedField(productId, TAGS, tags); // all fields are case-specific - private static void updateAnalyzedField(string productId, string fieldName, string newValue) + private void updateAnalyzedField(string productId, string fieldName, string newValue) => updateDocument( productId, d => @@ -170,7 +176,7 @@ namespace LibationSearchEngine d.AddIndexRule(rating, book); }); - private static void updateDocument(string productId, Action action) + private void updateDocument(string productId, Action action) { var productTerm = new Term(_ID_, productId); @@ -277,10 +283,10 @@ namespace LibationSearchEngine } #endregion - private static Directory getIndex() => FSDirectory.Open(SearchEngineDirectory); + private Directory getIndex() => FSDirectory.Open(SearchEngineDirectory); - // not customizable. don't move to config - private static string SearchEngineDirectory { get; } - = new System.IO.DirectoryInfo(Configuration.Instance.LibationFiles.Location).CreateSubdirectoryEx("SearchEngine").FullName; + //Defaults to "LibationFiles/SearchEngine, but can be overridden + //in constructor for use in TrashBinDialog search + private string SearchEngineDirectory { get; } } } diff --git a/Source/LibationUiBase/GridView/QueryExtensions.cs b/Source/LibationUiBase/GridView/QueryExtensions.cs index d31d315f..890223e9 100644 --- a/Source/LibationUiBase/GridView/QueryExtensions.cs +++ b/Source/LibationUiBase/GridView/QueryExtensions.cs @@ -45,16 +45,13 @@ namespace LibationUiBase.GridView => searchSet is null != otherSet is null || (searchSet is not null && otherSet is not null && - searchSet.Intersect(otherSet).Count() != searchSet.Count); + searchSet.Intersect(otherSet).Count() != searchSet.Count); - [return: NotNullIfNotNull(nameof(searchString))] - public static HashSet? FilterEntries(this IEnumerable entries, string? searchString) + [return: NotNullIfNotNull(nameof(searchResultSet))] + public static HashSet? FilterEntries(this IEnumerable entries, LibationSearchEngine.SearchResultSet? searchResultSet) { - if (string.IsNullOrEmpty(searchString)) + if (searchResultSet is null) return null; - - var searchResultSet = SearchEngineCommands.Search(searchString); - var booksFilteredIn = entries.IntersectBy(searchResultSet.Docs.Select(d => d.ProductId), l => l.AudibleProductId); //Find all series containing children that match the search criteria diff --git a/Source/LibationWinForms/Dialogs/TrashBinDialog.Designer.cs b/Source/LibationWinForms/Dialogs/TrashBinDialog.Designer.cs index 7e3edcaf..0c51cd32 100644 --- a/Source/LibationWinForms/Dialogs/TrashBinDialog.Designer.cs +++ b/Source/LibationWinForms/Dialogs/TrashBinDialog.Designer.cs @@ -28,40 +28,35 @@ /// private void InitializeComponent() { - deletedCbl = new System.Windows.Forms.CheckedListBox(); label1 = new System.Windows.Forms.Label(); restoreBtn = new System.Windows.Forms.Button(); permanentlyDeleteBtn = new System.Windows.Forms.Button(); everythingCb = new System.Windows.Forms.CheckBox(); deletedCheckedLbl = new System.Windows.Forms.Label(); + productsGrid1 = new LibationWinForms.GridView.ProductsGrid(); + label2 = new System.Windows.Forms.Label(); + textBox1 = new System.Windows.Forms.TextBox(); + button1 = new System.Windows.Forms.Button(); + audiblePlusCb = new System.Windows.Forms.CheckBox(); + plusBookcSheckedLbl = new System.Windows.Forms.Label(); SuspendLayout(); // - // deletedCbl - // - deletedCbl.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right; - deletedCbl.FormattingEnabled = true; - deletedCbl.Location = new System.Drawing.Point(12, 27); - deletedCbl.Name = "deletedCbl"; - deletedCbl.Size = new System.Drawing.Size(776, 364); - deletedCbl.TabIndex = 3; - deletedCbl.ItemCheck += deletedCbl_ItemCheck; - // // label1 // label1.AutoSize = true; label1.Location = new System.Drawing.Point(12, 9); label1.Name = "label1"; label1.Size = new System.Drawing.Size(388, 15); - label1.TabIndex = 4; + label1.TabIndex = 0; label1.Text = "Check books you want to permanently delete from or restore to Libation"; // // restoreBtn // restoreBtn.Anchor = System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right; - restoreBtn.Location = new System.Drawing.Point(572, 398); + restoreBtn.Location = new System.Drawing.Point(572, 450); restoreBtn.Name = "restoreBtn"; restoreBtn.Size = new System.Drawing.Size(75, 40); - restoreBtn.TabIndex = 5; + restoreBtn.TabIndex = 6; restoreBtn.Text = "Restore"; restoreBtn.UseVisualStyleBackColor = true; restoreBtn.Click += restoreBtn_Click; @@ -69,10 +64,10 @@ // permanentlyDeleteBtn // permanentlyDeleteBtn.Anchor = System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right; - permanentlyDeleteBtn.Location = new System.Drawing.Point(653, 398); + permanentlyDeleteBtn.Location = new System.Drawing.Point(653, 450); permanentlyDeleteBtn.Name = "permanentlyDeleteBtn"; permanentlyDeleteBtn.Size = new System.Drawing.Size(135, 40); - permanentlyDeleteBtn.TabIndex = 5; + permanentlyDeleteBtn.TabIndex = 7; permanentlyDeleteBtn.Text = "Permanently Remove\r\nfrom Libation"; permanentlyDeleteBtn.UseVisualStyleBackColor = true; permanentlyDeleteBtn.Click += permanentlyDeleteBtn_Click; @@ -81,10 +76,11 @@ // everythingCb.Anchor = System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left; everythingCb.AutoSize = true; - everythingCb.Location = new System.Drawing.Point(12, 410); + everythingCb.Location = new System.Drawing.Point(12, 462); + everythingCb.Margin = new System.Windows.Forms.Padding(10, 3, 3, 3); everythingCb.Name = "everythingCb"; everythingCb.Size = new System.Drawing.Size(82, 19); - everythingCb.TabIndex = 6; + everythingCb.TabIndex = 4; everythingCb.Text = "Everything"; everythingCb.ThreeState = true; everythingCb.UseVisualStyleBackColor = true; @@ -94,23 +90,93 @@ // deletedCheckedLbl.Anchor = System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left; deletedCheckedLbl.AutoSize = true; - deletedCheckedLbl.Location = new System.Drawing.Point(126, 411); + deletedCheckedLbl.Location = new System.Drawing.Point(100, 463); deletedCheckedLbl.Name = "deletedCheckedLbl"; deletedCheckedLbl.Size = new System.Drawing.Size(104, 15); - deletedCheckedLbl.TabIndex = 7; + deletedCheckedLbl.TabIndex = 0; deletedCheckedLbl.Text = "Checked: {0} of {1}"; // + // productsGrid1 + // + productsGrid1.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right; + productsGrid1.AutoScroll = true; + productsGrid1.DisableColumnCustomization = true; + productsGrid1.DisableContextMenu = true; + productsGrid1.Location = new System.Drawing.Point(12, 62); + productsGrid1.Name = "productsGrid1"; + productsGrid1.SearchEngine = null; + productsGrid1.Size = new System.Drawing.Size(776, 382); + productsGrid1.TabIndex = 3; + // + // label2 + // + label2.AutoSize = true; + label2.Location = new System.Drawing.Point(12, 36); + label2.Name = "label2"; + label2.Size = new System.Drawing.Size(123, 15); + label2.TabIndex = 0; + label2.Text = "Search Deleted Books:"; + // + // textBox1 + // + textBox1.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right; + textBox1.Location = new System.Drawing.Point(141, 33); + textBox1.Name = "textBox1"; + textBox1.Size = new System.Drawing.Size(574, 23); + textBox1.TabIndex = 1; + textBox1.KeyDown += textBox1_KeyDown; + // + // button1 + // + button1.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Right; + button1.Location = new System.Drawing.Point(721, 33); + button1.Name = "button1"; + button1.Size = new System.Drawing.Size(67, 23); + button1.TabIndex = 2; + button1.Text = "Filter"; + button1.UseVisualStyleBackColor = true; + button1.Click += searchBtn_Click; + // + // audiblePlusCb + // + audiblePlusCb.Anchor = System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left; + audiblePlusCb.AutoSize = true; + audiblePlusCb.Location = new System.Drawing.Point(247, 462); + audiblePlusCb.Margin = new System.Windows.Forms.Padding(10, 3, 3, 3); + audiblePlusCb.Name = "audiblePlusCb"; + audiblePlusCb.Size = new System.Drawing.Size(127, 19); + audiblePlusCb.TabIndex = 5; + audiblePlusCb.Text = "Audible Plus Books"; + audiblePlusCb.ThreeState = true; + audiblePlusCb.UseVisualStyleBackColor = true; + audiblePlusCb.CheckStateChanged += audiblePlusCb_CheckStateChanged; + // + // plusBookcSheckedLbl + // + plusBookcSheckedLbl.Anchor = System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left; + plusBookcSheckedLbl.AutoSize = true; + plusBookcSheckedLbl.Location = new System.Drawing.Point(380, 463); + plusBookcSheckedLbl.Name = "plusBookcSheckedLbl"; + plusBookcSheckedLbl.Size = new System.Drawing.Size(104, 15); + plusBookcSheckedLbl.TabIndex = 0; + plusBookcSheckedLbl.Text = "Checked: {0} of {1}"; + // // TrashBinDialog // AutoScaleDimensions = new System.Drawing.SizeF(96F, 96F); AutoScaleMode = System.Windows.Forms.AutoScaleMode.Dpi; - ClientSize = new System.Drawing.Size(800, 450); + ClientSize = new System.Drawing.Size(800, 502); + Controls.Add(plusBookcSheckedLbl); + Controls.Add(button1); + Controls.Add(textBox1); + Controls.Add(label2); + Controls.Add(productsGrid1); Controls.Add(deletedCheckedLbl); + Controls.Add(audiblePlusCb); Controls.Add(everythingCb); Controls.Add(permanentlyDeleteBtn); Controls.Add(restoreBtn); Controls.Add(label1); - Controls.Add(deletedCbl); Name = "TrashBinDialog"; Text = "Trash Bin"; ResumeLayout(false); @@ -118,12 +184,16 @@ } #endregion - - private System.Windows.Forms.CheckedListBox deletedCbl; private System.Windows.Forms.Label label1; private System.Windows.Forms.Button restoreBtn; private System.Windows.Forms.Button permanentlyDeleteBtn; private System.Windows.Forms.CheckBox everythingCb; private System.Windows.Forms.Label deletedCheckedLbl; + private GridView.ProductsGrid productsGrid1; + private System.Windows.Forms.Label label2; + private System.Windows.Forms.TextBox textBox1; + private System.Windows.Forms.Button button1; + private System.Windows.Forms.CheckBox audiblePlusCb; + private System.Windows.Forms.Label plusBookcSheckedLbl; } } \ No newline at end of file diff --git a/Source/LibationWinForms/Dialogs/TrashBinDialog.cs b/Source/LibationWinForms/Dialogs/TrashBinDialog.cs index fbb6c452..28826149 100644 --- a/Source/LibationWinForms/Dialogs/TrashBinDialog.cs +++ b/Source/LibationWinForms/Dialogs/TrashBinDialog.cs @@ -1,18 +1,21 @@ using ApplicationServices; +using DataLayer; +using Dinah.Core.Collections.Generic; +using LibationFileManager; using System; +using System.Collections.Generic; using System.Data; using System.Linq; using System.Threading.Tasks; using System.Windows.Forms; -using DataLayer; -using LibationFileManager; -using System.Collections; +#nullable enable namespace LibationWinForms.Dialogs { public partial class TrashBinDialog : Form { - private readonly string deletedCheckedTemplate; + private string lastGoodFilter = ""; + private TempSearchEngine SearchEngine { get; } = new TempSearchEngine(); public TrashBinDialog() { InitializeComponent(); @@ -21,29 +24,67 @@ namespace LibationWinForms.Dialogs this.RestoreSizeAndLocation(Configuration.Instance); this.FormClosing += (_, _) => this.SaveSizeAndLocation(Configuration.Instance); - deletedCheckedTemplate = deletedCheckedLbl.Text; - - var deletedBooks = DbContexts.GetDeletedLibraryBooks(); - foreach (var lb in deletedBooks) - deletedCbl.Items.Add(lb); - - setLabel(); + deletedCheckedLbl.Text = ""; + plusBookcSheckedLbl.Text = ""; + productsGrid1.SearchEngine = SearchEngine; + productsGrid1.RemovableCountChanged += (_, _) => UpdateCounts(); + productsGrid1.VisibleCountChanged += (_, _) => UpdateCounts(); + Load += TrashBinDialog_Load; } - private void deletedCbl_ItemCheck(object sender, ItemCheckEventArgs e) + private IEnumerable GetCheckedBooks() => productsGrid1.GetVisibleGridEntries().Where(i => i.Remove is true).Select(i => i.LibraryBook); + + private async void TrashBinDialog_Load(object? sender, EventArgs e) { - // CheckedItems.Count is not updated until after the event fires - setLabel(e.NewValue); + productsGrid1.RemoveColumnVisible = true; + await InitAsync(); + } + + private void UpdateCounts() + { + var visible = productsGrid1.GetVisibleGridEntries().ToArray(); + var plusVisibleCount = visible.Count(e => e.LibraryBook.IsAudiblePlus); + + var checkedCount = visible.Count(e => e.Remove is true); + var plusCheckedCount = visible.Count(e => e.LibraryBook.IsAudiblePlus && e.Remove is true); + + deletedCheckedLbl.Text = $"Checked: {checkedCount} of {visible.Length}"; + plusBookcSheckedLbl.Text = $"Checked: {plusCheckedCount} of {plusVisibleCount}"; + + everythingCb.CheckStateChanged -= everythingCb_CheckStateChanged; + everythingCb.CheckState = checkedCount == 0 || visible.Length == 0 ? CheckState.Unchecked + : checkedCount == visible.Length ? CheckState.Checked + : CheckState.Indeterminate; + everythingCb.CheckStateChanged += everythingCb_CheckStateChanged; + + audiblePlusCb.CheckStateChanged -= audiblePlusCb_CheckStateChanged; + audiblePlusCb.CheckState = plusCheckedCount == 0 || plusVisibleCount == 0 ? CheckState.Unchecked + : plusCheckedCount == plusVisibleCount ? CheckState.Checked + : CheckState.Indeterminate; + audiblePlusCb.CheckStateChanged += audiblePlusCb_CheckStateChanged; + } + + private async Task InitAsync() + { + var deletedBooks = DbContexts.GetDeletedLibraryBooks(); + SearchEngine.ReindexSearchEngine(deletedBooks); + await productsGrid1.BindToGridAsync(deletedBooks); + } + + private void Reload() + { + var deletedBooks = DbContexts.GetDeletedLibraryBooks(); + SearchEngine.ReindexSearchEngine(deletedBooks); + productsGrid1.UpdateGrid(deletedBooks); } private async void permanentlyDeleteBtn_Click(object sender, EventArgs e) { setControlsEnabled(false); - var removed = deletedCbl.CheckedItems.Cast().ToList(); - - removeFromCheckList(removed); - await removed.PermanentlyDeleteBooksAsync(); + var qtyChanges = await GetCheckedBooks().PermanentlyDeleteBooksAsync(); + if (qtyChanges > 0) + Reload(); setControlsEnabled(true); } @@ -52,65 +93,70 @@ namespace LibationWinForms.Dialogs { setControlsEnabled(false); - var removed = deletedCbl.CheckedItems.Cast().ToList(); - - removeFromCheckList(removed); - await removed.RestoreBooksAsync(); + var qtyChanges = await GetCheckedBooks().RestoreBooksAsync(); + if (qtyChanges > 0) + Reload(); setControlsEnabled(true); } - private void removeFromCheckList(IEnumerable objects) - { - foreach (var o in objects) - deletedCbl.Items.Remove(o); - - deletedCbl.Refresh(); - setLabel(); - } - private void setControlsEnabled(bool enabled) - => restoreBtn.Enabled = permanentlyDeleteBtn.Enabled = deletedCbl.Enabled = everythingCb.Enabled = enabled; + => Invoke(() => productsGrid1.Enabled = restoreBtn.Enabled = permanentlyDeleteBtn.Enabled = everythingCb.Enabled = enabled); - private void everythingCb_CheckStateChanged(object sender, EventArgs e) + private void textBox1_KeyDown(object sender, KeyEventArgs e) { - if (everythingCb.CheckState is CheckState.Indeterminate) - { - everythingCb.CheckState = CheckState.Unchecked; - return; - } - - deletedCbl.ItemCheck -= deletedCbl_ItemCheck; - - for (var i = 0; i < deletedCbl.Items.Count; i++) - deletedCbl.SetItemChecked(i, everythingCb.CheckState is CheckState.Checked); - - setLabel(); - - deletedCbl.ItemCheck += deletedCbl_ItemCheck; + if (e.KeyCode == Keys.Enter) + searchBtn_Click(sender, e); } - - private void setLabel(CheckState? checkedState = null) + private void searchBtn_Click(object sender, EventArgs e) { - var pre = deletedCbl.CheckedItems.Count; - int count = checkedState switch + try { - CheckState.Checked => pre + 1, - CheckState.Unchecked => pre - 1, - _ => pre, - }; + productsGrid1.Filter(textBox1.Text); + lastGoodFilter = textBox1.Text; + } + catch + { + productsGrid1.Filter(lastGoodFilter); + } + } - everythingCb.CheckStateChanged -= everythingCb_CheckStateChanged; + private void audiblePlusCb_CheckStateChanged(object? sender, EventArgs e) + { + switch (audiblePlusCb.CheckState) + { + case CheckState.Checked: + SetVisibleChecked(e => e.IsAudiblePlus, isChecked: true); + break; + case CheckState.Unchecked: + SetVisibleChecked(e => e.IsAudiblePlus, isChecked: false); + break; + default: + audiblePlusCb.CheckState = CheckState.Unchecked; + break; + } + } + private void everythingCb_CheckStateChanged(object? sender, EventArgs e) + { + switch (everythingCb.CheckState) + { + case CheckState.Checked: + SetVisibleChecked(_ => true, isChecked: true); + break; + case CheckState.Unchecked: + SetVisibleChecked(_ => true, isChecked: false); + break; + default: + everythingCb.CheckState = CheckState.Unchecked; + break; + } + } - everythingCb.CheckState - = count > 0 && count == deletedCbl.Items.Count ? CheckState.Checked - : count == 0 ? CheckState.Unchecked - : CheckState.Indeterminate; - - everythingCb.CheckStateChanged += everythingCb_CheckStateChanged; - - deletedCheckedLbl.Text = string.Format(deletedCheckedTemplate, count, deletedCbl.Items.Count); + public void SetVisibleChecked(Func predicate, bool isChecked) + { + productsGrid1.GetVisibleGridEntries().Where(e => predicate(e.LibraryBook)).ForEach(i => i.Remove = isChecked); + UpdateCounts(); } } } diff --git a/Source/LibationWinForms/Dialogs/TrashBinDialog.resx b/Source/LibationWinForms/Dialogs/TrashBinDialog.resx index f298a7be..8b2ff64a 100644 --- a/Source/LibationWinForms/Dialogs/TrashBinDialog.resx +++ b/Source/LibationWinForms/Dialogs/TrashBinDialog.resx @@ -1,4 +1,64 @@ - + + + diff --git a/Source/LibationWinForms/GridView/GridEntryBindingList.cs b/Source/LibationWinForms/GridView/GridEntryBindingList.cs index 583e6f33..3f0bcd74 100644 --- a/Source/LibationWinForms/GridView/GridEntryBindingList.cs +++ b/Source/LibationWinForms/GridView/GridEntryBindingList.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.ComponentModel; using System.Linq; +#nullable enable namespace LibationWinForms.GridView { /* @@ -43,8 +44,10 @@ namespace LibationWinForms.GridView .OfType() .Union(Items.OfType()); + public ISearchEngine? SearchEngine { get; set; } + public bool SupportsFiltering => true; - public string Filter + public string? Filter { get => FilterString; set @@ -54,7 +57,8 @@ namespace LibationWinForms.GridView if (Items.Count + FilterRemoved.Count == 0) return; - FilteredInGridEntries = AllItems().FilterEntries(FilterString); + var searchResultSet = SearchEngine?.GetSearchResultSet(FilterString); + FilteredInGridEntries = AllItems().FilterEntries(searchResultSet); refreshEntries(); } } @@ -63,16 +67,16 @@ namespace LibationWinForms.GridView protected override bool SupportsSortingCore => true; protected override bool SupportsSearchingCore => true; protected override bool IsSortedCore => isSorted; - protected override PropertyDescriptor SortPropertyCore => propertyDescriptor; + protected override PropertyDescriptor? SortPropertyCore => propertyDescriptor; protected override ListSortDirection SortDirectionCore => Comparer.SortOrder; /// Items that were removed from the base list due to filtering private readonly List FilterRemoved = new(); - private string FilterString; + private string? FilterString; private bool isSorted; - private PropertyDescriptor propertyDescriptor; + private PropertyDescriptor? propertyDescriptor; /// All GridEntries present in the current filter set. If null, no filter is applied and all entries are filtered in.(This was a performance choice) - private HashSet FilteredInGridEntries; + private HashSet? FilteredInGridEntries; #region Unused - Advanced Filtering public bool SupportsAdvancedSorting => false; @@ -128,7 +132,7 @@ namespace LibationWinForms.GridView //(except for episodes that are collapsed) foreach (var addBack in addBackEntries) { - if (addBack is LibraryBookEntry lbe && lbe.Parent is SeriesEntry se && !se.Liberate.Expanded) + if (addBack is LibraryBookEntry lbe && lbe.Parent is SeriesEntry se && se.Liberate?.Expanded is not true) continue; FilterRemoved.Remove(addBack); @@ -137,9 +141,10 @@ namespace LibationWinForms.GridView } } - private void SearchEngineCommands_SearchEngineUpdated(object sender, EventArgs e) + private void SearchEngineCommands_SearchEngineUpdated(object? sender, EventArgs e) { - var filterResults = AllItems().FilterEntries(FilterString); + var searchResultSet = SearchEngine?.GetSearchResultSet(FilterString); + var filterResults = AllItems().FilterEntries(searchResultSet); if (FilteredInGridEntries.SearchSetsDiffer(filterResults)) { @@ -168,7 +173,7 @@ namespace LibationWinForms.GridView base.Remove(episode); } - sEntry.Liberate.Expanded = false; + sEntry.Liberate?.Expanded = false; } public void ExpandItem(SeriesEntry sEntry) @@ -183,7 +188,7 @@ namespace LibationWinForms.GridView InsertItem(++sindex, episode); } } - sEntry.Liberate.Expanded = true; + sEntry.Liberate?.Expanded = true; } public void RemoveFilter() @@ -216,7 +221,7 @@ namespace LibationWinForms.GridView itemsList.AddRange(sortedItems); } - private void GridEntryBindingList_ListChanged(object sender, ListChangedEventArgs e) + private void GridEntryBindingList_ListChanged(object? sender, ListChangedEventArgs e) { if (e.ListChangedType == ListChangedType.ItemChanged && IsSortedCore && e.PropertyDescriptor == SortPropertyCore) refreshEntries(); diff --git a/Source/LibationWinForms/GridView/ProductsDisplay.cs b/Source/LibationWinForms/GridView/ProductsDisplay.cs index 1f14761b..05e31ef7 100644 --- a/Source/LibationWinForms/GridView/ProductsDisplay.cs +++ b/Source/LibationWinForms/GridView/ProductsDisplay.cs @@ -32,6 +32,7 @@ namespace LibationWinForms.GridView public ProductsDisplay() { InitializeComponent(); + productsGrid.SearchEngine = MainSearchEngine.Instance; } #region Button controls @@ -432,7 +433,7 @@ namespace LibationWinForms.GridView #endregion - internal List GetVisible() => productsGrid.GetVisibleBooks().ToList(); + internal List GetVisible() => productsGrid.GetVisibleBookEntries().ToList(); private void productsGrid_VisibleCountChanged(object sender, int count) { diff --git a/Source/LibationWinForms/GridView/ProductsGrid.cs b/Source/LibationWinForms/GridView/ProductsGrid.cs index a3ef2d78..04d728f4 100644 --- a/Source/LibationWinForms/GridView/ProductsGrid.cs +++ b/Source/LibationWinForms/GridView/ProductsGrid.cs @@ -1,4 +1,5 @@ -using DataLayer; +using ApplicationServices; +using DataLayer; using Dinah.Core; using Dinah.Core.Collections.Generic; using Dinah.Core.WindowsDesktop.Forms; @@ -6,6 +7,7 @@ using LibationFileManager; using LibationUiBase.GridView; using System; using System.Collections.Generic; +using System.ComponentModel; using System.Data; using System.Drawing; using System.Linq; @@ -22,6 +24,23 @@ namespace LibationWinForms.GridView public partial class ProductsGrid : UserControl { + [DefaultValue(false)] + [Category("Behavior")] + [Description("Disable the grid context menu")] + public bool DisableContextMenu { get; set; } + [DefaultValue(false)] + [Category("Behavior")] + [Description("Disable grid column reordering and don't persist width changes")] + public bool DisableColumnCustomization + { + get => field; + set + { + field = value; + gridEntryDataGridView.AllowUserToOrderColumns = !value; + } + } + /// Number of visible rows has changed public event EventHandler? VisibleCountChanged; public event LibraryBookEntryClickedEventHandler? LiberateClicked; @@ -33,13 +52,17 @@ namespace LibationWinForms.GridView public event ProductsGridCellContextMenuStripNeededEventHandler? LiberateContextMenuStripNeeded; private GridEntryBindingList? bindingList; - internal IEnumerable GetVisibleBooks() - => bindingList - ?.GetFilteredInItems() - .Select(lbe => lbe.LibraryBook) ?? Enumerable.Empty(); + internal IEnumerable GetVisibleBookEntries() + => GetVisibleGridEntries().Select(lbe => lbe.LibraryBook); + + public IEnumerable GetVisibleGridEntries() + => bindingList?.GetFilteredInItems().OfType() ?? []; + internal IEnumerable GetAllBookEntries() => bindingList?.AllItems().BookEntries() ?? Enumerable.Empty(); + public ISearchEngine? SearchEngine { get => field; set { field = value; bindingList?.SearchEngine = value; } } + public ProductsGrid() { InitializeComponent(); @@ -47,19 +70,7 @@ namespace LibationWinForms.GridView gridEntryDataGridView.Scroll += (_, s) => Scroll?.Invoke(this, s); gridEntryDataGridView.CellContextMenuStripNeeded += GridEntryDataGridView_CellContextMenuStripNeeded; removeGVColumn.Frozen = false; - - defaultFont = gridEntryDataGridView.DefaultCellStyle.Font ?? gridEntryDataGridView.Font; - setGridFontScale(Configuration.Instance.GridFontScaleFactor); - setGridScale(Configuration.Instance.GridScaleFactor); - Configuration.Instance.PropertyChanged += Configuration_ScaleChanged; - Configuration.Instance.PropertyChanged += Configuration_FontScaleChanged; - gridEntryDataGridView.EnableHeadersVisualStyles = !Application.IsDarkModeEnabled; - - gridEntryDataGridView.Disposed += (_, _) => - { - Configuration.Instance.PropertyChanged -= Configuration_ScaleChanged; - Configuration.Instance.PropertyChanged -= Configuration_FontScaleChanged; - }; + defaultFont = gridEntryDataGridView.DefaultCellStyle.Font ?? gridEntryDataGridView.Font; } #region Scaling @@ -120,7 +131,7 @@ namespace LibationWinForms.GridView private void GridEntryDataGridView_CellContextMenuStripNeeded(object? sender, DataGridViewCellContextMenuStripNeededEventArgs e) { // header - if (e.RowIndex < 0 || sender is not DataGridView dgv) + if (DisableContextMenu || e.RowIndex < 0 || sender is not DataGridView dgv) return; e.ContextMenuStrip = new ContextMenuStrip(); @@ -313,7 +324,7 @@ namespace LibationWinForms.GridView } System.Threading.SynchronizationContext.SetSynchronizationContext(null); - bindingList = new GridEntryBindingList(geList); + bindingList = new GridEntryBindingList(geList) { SearchEngine = SearchEngine }; bindingList.CollapseAll(); //The syncBindingSource ensures that the IGridEntry list is added on the UI thread @@ -381,7 +392,8 @@ namespace LibationWinForms.GridView RemoveBooks(removedBooks); - gridEntryDataGridView.FirstDisplayedScrollingRowIndex = topRow; + if (topRow >= 0 && topRow < gridEntryDataGridView.RowCount) + gridEntryDataGridView.FirstDisplayedScrollingRowIndex = topRow; } public void RemoveBooks(IEnumerable removedBooks) @@ -505,8 +517,21 @@ namespace LibationWinForms.GridView private void ProductsGrid_Load(object sender, EventArgs e) { - //https://stackoverflow.com/a/4498512/3335599 - if (System.ComponentModel.LicenseManager.UsageMode == System.ComponentModel.LicenseUsageMode.Designtime) return; + //DesignMode is not set in constructor + if (DesignMode) + return; + + setGridFontScale(Configuration.Instance.GridFontScaleFactor); + setGridScale(Configuration.Instance.GridScaleFactor); + Configuration.Instance.PropertyChanged += Configuration_ScaleChanged; + Configuration.Instance.PropertyChanged += Configuration_FontScaleChanged; + gridEntryDataGridView.EnableHeadersVisualStyles = !Application.IsDarkModeEnabled; + + gridEntryDataGridView.Disposed += (_, _) => + { + Configuration.Instance.PropertyChanged -= Configuration_ScaleChanged; + Configuration.Instance.PropertyChanged -= Configuration_FontScaleChanged; + }; gridEntryDataGridView.ColumnWidthChanged += gridEntryDataGridView_ColumnWidthChanged; gridEntryDataGridView.ColumnDisplayIndexChanged += gridEntryDataGridView_ColumnDisplayIndexChanged; @@ -523,6 +548,8 @@ namespace LibationWinForms.GridView foreach (DataGridViewColumn column in gridEntryDataGridView.Columns) { + if (column == removeGVColumn) + continue; var itemName = column.DataPropertyName; var visible = config.GetColumnVisibility(itemName); @@ -596,6 +623,7 @@ namespace LibationWinForms.GridView private void gridEntryDataGridView_ColumnDisplayIndexChanged(object? sender, DataGridViewColumnEventArgs e) { + if (DisableColumnCustomization) return; var config = Configuration.Instance; var dictionary = config.GridColumnsDisplayIndices; @@ -613,6 +641,7 @@ namespace LibationWinForms.GridView private void gridEntryDataGridView_ColumnWidthChanged(object? sender, DataGridViewColumnEventArgs e) { + if (DisableColumnCustomization) return; var config = Configuration.Instance; var dictionary = config.GridColumnsWidths; From 94cf665be791ac8796fc6ffafe9ddf0a2b2deb2f Mon Sep 17 00:00:00 2001 From: Mbucari <37587114+Mbucari@users.noreply.github.com> Date: Sun, 11 Jan 2026 15:40:11 -0700 Subject: [PATCH 09/10] Fix books not being marked absent on large imports --- Source/DtoImporterService/BookImporter.cs | 9 + .../DtoImporterService/LibraryBookImporter.cs | 247 +++++++++--------- Source/LibationUiBase/GridView/EntryStatus.cs | 4 +- 3 files changed, 139 insertions(+), 121 deletions(-) diff --git a/Source/DtoImporterService/BookImporter.cs b/Source/DtoImporterService/BookImporter.cs index 1d85fbfb..c39622f3 100644 --- a/Source/DtoImporterService/BookImporter.cs +++ b/Source/DtoImporterService/BookImporter.cs @@ -18,6 +18,14 @@ namespace DtoImporterService private SeriesImporter seriesImporter { get; } private CategoryImporter categoryImporter { get; } + /// + /// Indicates whether loaded every Book from the during import. + /// If true, the DbContext was queried for all Books, rather than just those being imported. + /// If means that all objects in the DbContext will have their property populated. + /// If false, only those Books being imported were loaded, and some objects will have a null property for books not included in the import set. + /// + internal bool LoadedEntireLibrary {get; private set; } + public BookImporter(LibationContext context) : base(context) { contributorImporter = new ContributorImporter(DbContext); @@ -56,6 +64,7 @@ namespace DtoImporterService .ToArray() .Where(b => productIds.Contains(b.AudibleProductId)) .ToDictionarySafe(b => b.AudibleProductId); + LoadedEntireLibrary = true; } else { diff --git a/Source/DtoImporterService/LibraryBookImporter.cs b/Source/DtoImporterService/LibraryBookImporter.cs index 34ef85f9..b3426e46 100644 --- a/Source/DtoImporterService/LibraryBookImporter.cs +++ b/Source/DtoImporterService/LibraryBookImporter.cs @@ -6,126 +6,135 @@ using DataLayer; using Dinah.Core; using Dinah.Core.Collections.Generic; -namespace DtoImporterService +namespace DtoImporterService; + +public class LibraryBookImporter : ItemsImporterBase { - public class LibraryBookImporter : ItemsImporterBase + protected override IValidator Validator => new LibraryValidator(); + + private BookImporter bookImporter { get; } + + public LibraryBookImporter(LibationContext context) : base(context) { - protected override IValidator Validator => new LibraryValidator(); - - private BookImporter bookImporter { get; } - - public LibraryBookImporter(LibationContext context) : base(context) - { - bookImporter = new BookImporter(DbContext); - } - - protected override int DoImport(IEnumerable importItems) - { - bookImporter.Import(importItems); - - var qtyNew = upsertLibraryBooks(importItems); - return qtyNew; - } - - private int upsertLibraryBooks(IEnumerable importItems) - { - // technically, we should be able to have duplicate books from separate accounts. - // this would violate the current pk and would be difficult to deal with elsewhere: - // - what to show in the grid - // - which to consider liberated - // - // sqlite cannot alter pk. the work around is an extensive headache - // - update: now possible in .net5/efcore5 - // - // currently, inserting LibraryBook will throw error if the same book is in multiple accounts for the same region. - // - // CURRENT SOLUTION: don't re-insert - - - //When Books are upserted during the BookImporter run, they are linked to their LibraryBook in the DbContext - //instance. If a LibraryBook has a null book here, that means it's Book was not imported during by BookImporter. - //There should never be duplicates, but this is defensive. - var existingEntries = DbContext.LibraryBooks.AsEnumerable().Where(l => l.Book is not null).ToDictionarySafe(l => l.Book.AudibleProductId); - - //If importItems are contains duplicates by asin, keep the Item that's "available" - var uniqueImportItems = ToDictionarySafe(importItems, dto => dto.DtoItem.ProductId, tieBreak); - - int qtyNew = 0; - - foreach (var item in uniqueImportItems.Values) - { - if (existingEntries.TryGetValue(item.DtoItem.ProductId, out LibraryBook existing)) - { - if (existing.Account != item.AccountId) - { - //Book is absent from the existing LibraryBook's account. Use the alternate account. - existing.SetAccount(item.AccountId); - } - - existing.AbsentFromLastScan = isUnavailable(item); - } - else - { - existing = new LibraryBook( - bookImporter.Cache[item.DtoItem.ProductId], - item.DtoItem.DateAdded, - item.AccountId) - { - AbsentFromLastScan = isUnavailable(item) - }; - - try - { - DbContext.LibraryBooks.Add(existing); - qtyNew++; - } - catch (Exception ex) - { - Serilog.Log.Logger.Error(ex, "Error adding library book. {@DebugInfo}", new { existing.Book, existing.Account }); - } - } - - existing.SetIncludedUntil(item.DtoItem.GetExpirationDate()); - existing.SetIsAudiblePlus(item.DtoItem.IsAyce is true); - } - - var scannedAccounts = importItems.Select(i => i.AccountId).Distinct().ToList(); - - //If an existing Book wasn't found in the import, the owning LibraryBook's Book will be null. - //Only change AbsentFromLastScan for LibraryBooks of accounts that were scanned. - foreach (var nullBook in DbContext.LibraryBooks.AsEnumerable().Where(lb => lb.Book is null && lb.Account.In(scannedAccounts))) - nullBook.AbsentFromLastScan = true; - - return qtyNew; - } - - private static Dictionary ToDictionarySafe(IEnumerable source, Func keySelector, Func tieBreaker) - { - var dictionary = new Dictionary(); - - foreach (TSource newItem in source) - { - TKey key = keySelector(newItem); - - dictionary[key] - = dictionary.TryGetValue(key, out TSource existingItem) - ? tieBreaker(existingItem, newItem) - : newItem; - } - return dictionary; - } - - private static ImportItem tieBreak(ImportItem item1, ImportItem item2) - => isUnavailable(item1) && !isUnavailable(item2) ? item2 : item1; - - private static bool isUnavailable(ImportItem item) - => isFutureRelease(item) || isPlusTitleUnavailable(item); - - private static bool isFutureRelease(ImportItem item) - => item.DtoItem.IssueDate is DateTimeOffset dt && dt > DateTimeOffset.UtcNow; - - private static bool isPlusTitleUnavailable(ImportItem item) - => item.DtoItem.ContentType is null - || (item.DtoItem.IsAyce is true && item.DtoItem.Plans?.Any(p => p.IsAyce) is not true); + bookImporter = new BookImporter(DbContext); } + + protected override int DoImport(IEnumerable importItems) + { + bookImporter.Import(importItems); + + var qtyNew = upsertLibraryBooks(importItems); + return qtyNew; + } + + private int upsertLibraryBooks(IEnumerable importItems) + { + // technically, we should be able to have duplicate books from separate accounts. + // this would violate the current pk and would be difficult to deal with elsewhere: + // - what to show in the grid + // - which to consider liberated + // + // sqlite cannot alter pk. the work around is an extensive headache + // - update: now possible in .net5/efcore5 + // + // currently, inserting LibraryBook will throw error if the same book is in multiple accounts for the same region. + // + // CURRENT SOLUTION: don't re-insert + + + //When Books are upserted during the BookImporter run, they are linked to their LibraryBook in the DbContext + //instance. If a LibraryBook has a null book here, that means it's Book was not imported during by BookImporter. + //There should never be duplicates, but this is defensive. + var existingEntries = DbContext.LibraryBooks.AsEnumerable().Where(l => l.Book is not null).ToDictionarySafe(l => l.Book.AudibleProductId); + + //If importItems are contains duplicates by asin, keep the Item that's "available" + var uniqueImportItems = ToDictionarySafe(importItems, dto => dto.DtoItem.ProductId, tieBreak); + + int qtyNew = 0; + foreach (var item in uniqueImportItems.Values) + { + if (existingEntries.TryGetValue(item.DtoItem.ProductId, out LibraryBook existing)) + { + if (existing.Account != item.AccountId) + { + //Book is absent from the existing LibraryBook's account. Use the alternate account. + existing.SetAccount(item.AccountId); + } + + existing.AbsentFromLastScan = isUnavailable(item); + } + else + { + existing = new LibraryBook( + bookImporter.Cache[item.DtoItem.ProductId], + item.DtoItem.DateAdded, + item.AccountId) + { + AbsentFromLastScan = isUnavailable(item) + }; + + try + { + DbContext.LibraryBooks.Add(existing); + qtyNew++; + } + catch (Exception ex) + { + Serilog.Log.Logger.Error(ex, "Error adding library book. {@DebugInfo}", new { existing.Book, existing.Account }); + } + } + + existing.SetIncludedUntil(item.DtoItem.GetExpirationDate()); + existing.SetIsAudiblePlus(item.DtoItem.IsAyce is true); + } + + var scannedAccounts = importItems.Select(i => i.AccountId).Distinct().ToHashSet(); + var allInScannedAccounts = DbContext.LibraryBooks.Where(lb => scannedAccounts.Contains(lb.Account)).ToArray(); + + if (bookImporter.LoadedEntireLibrary) + { + //If the entire library was loaded, we can be sure that all existing LibraryBooks have their Book property populated. + //Find LibraryBooks which have a Book but weren't found in the import, and mark them as absent. + foreach (var absentBook in allInScannedAccounts.Where(lb => !uniqueImportItems.ContainsKey(lb.Book.AudibleProductId))) + absentBook.AbsentFromLastScan = true; + } + else + { + //If an existing Book wasn't found in the import, the owning LibraryBook's Book will be null. + //Only change AbsentFromLastScan for LibraryBooks of accounts that were scanned. + foreach (var nullBook in allInScannedAccounts.Where(lb => lb.Book is null && lb.Account.In(scannedAccounts))) + nullBook.AbsentFromLastScan = true; + } + + return qtyNew; + } + + private static Dictionary ToDictionarySafe(IEnumerable source, Func keySelector, Func tieBreaker) + { + var dictionary = new Dictionary(); + + foreach (TSource newItem in source) + { + TKey key = keySelector(newItem); + + dictionary[key] + = dictionary.TryGetValue(key, out TSource existingItem) + ? tieBreaker(existingItem, newItem) + : newItem; + } + return dictionary; + } + + private static ImportItem tieBreak(ImportItem item1, ImportItem item2) + => isUnavailable(item1) && !isUnavailable(item2) ? item2 : item1; + + private static bool isUnavailable(ImportItem item) + => isFutureRelease(item) || isPlusTitleUnavailable(item); + + private static bool isFutureRelease(ImportItem item) + => item.DtoItem.IssueDate is DateTimeOffset dt && dt > DateTimeOffset.UtcNow; + + private static bool isPlusTitleUnavailable(ImportItem item) + => item.DtoItem.ContentType is null + || (item.DtoItem.IsAyce is true && item.DtoItem.Plans?.Any(p => p.IsAyce) is not true); } \ No newline at end of file diff --git a/Source/LibationUiBase/GridView/EntryStatus.cs b/Source/LibationUiBase/GridView/EntryStatus.cs index e10687ad..72d0898e 100644 --- a/Source/LibationUiBase/GridView/EntryStatus.cs +++ b/Source/LibationUiBase/GridView/EntryStatus.cs @@ -47,8 +47,8 @@ namespace LibationUiBase.GridView public bool IsBook => !IsSeries && !IsEpisode; public bool IsUnavailable => !IsSeries - & isAbsent - & ( + && isAbsent + && ( BookStatus is not LiberatedStatus.Liberated || PdfStatus is not null and not LiberatedStatus.Liberated ); From c95dccd246b818a4f99d5016160a098c72bbf32e Mon Sep 17 00:00:00 2001 From: Mbucari <37587114+Mbucari@users.noreply.github.com> Date: Sun, 11 Jan 2026 15:43:57 -0700 Subject: [PATCH 10/10] Add confirmation dialog when removing books from Audible --- .../GridView/GridContextMenu.cs | 54 ++++++++++++------- 1 file changed, 36 insertions(+), 18 deletions(-) diff --git a/Source/LibationUiBase/GridView/GridContextMenu.cs b/Source/LibationUiBase/GridView/GridContextMenu.cs index 210b28f9..2be8ce03 100644 --- a/Source/LibationUiBase/GridView/GridContextMenu.cs +++ b/Source/LibationUiBase/GridView/GridContextMenu.cs @@ -1,13 +1,10 @@ using ApplicationServices; using DataLayer; using Dinah.Core; -using DocumentFormat.OpenXml.Office2010.ExcelAc; -using DocumentFormat.OpenXml.Wordprocessing; using FileLiberator; using LibationFileManager; using LibationFileManager.Templates; using LibationUiBase.Forms; -using Lucene.Net.Messages; using System; using System.Collections.Generic; using System.Linq; @@ -94,15 +91,40 @@ public class GridContextMenu public async Task RemoveFromAudibleAsync() { + LibraryBook[] toRemove = LibraryBookEntries.Select(l => l.LibraryBook).Where(lb => lb.IsAudiblePlus).ToArray(); + if (toRemove.Length == 0) + return; + + string bookStr = "book".PluralizeWithCount(toRemove.Length), itsThem = toRemove.Length == 1 ? "it" : "them"; + string confirmMessage = $""" + Libation is about to remove {bookStr} from your Audible account. The only way to get {itsThem} back + is to re-add {itsThem} to your Audible Library through the Audible website or app. + + Are you sure you want to remove the following {bookStr}? + + {toRemove.AggregateTitles()} + """; + DialogResult result = await MessageBoxBase.Show(confirmMessage, "Confirm Remove from Audible Library", MessageBoxButtons.YesNo, MessageBoxIcon.Question, MessageBoxDefaultButton.Button1); + if (result != DialogResult.Yes) + return; + List removedFromAudible = []; List failedToRemove = []; - foreach (var entry in LibraryBookEntries.Select(l => l.LibraryBook).Where(lb => lb.IsAudiblePlus)) + //Getting the API loads AccountsSettings every time and es expensive + //cache Api to improve perfomanc on large batches of deletions + Dictionary apis = []; + + foreach (var entry in toRemove) { try { - var api = await entry.GetApiAsync(); - var success = await api.RemoveItemFromLibraryAsync(entry.Book.AudibleProductId); + if (!apis.TryGetValue(entry.Account, out var api)) + { + apis[entry.Account] = api = await entry.GetApiAsync(); + } + + bool success = await api.RemoveItemFromLibraryAsync(entry.Book.AudibleProductId); if (success) { removedFromAudible.Add(entry); @@ -120,15 +142,13 @@ public class GridContextMenu } if (failedToRemove.Count > 0) { - var count = failedToRemove.Count; - string bookBooks = count == 1 ? "book" : "books"; - - var message = $""" - Failed to remove {count} {bookBooks} from Audible. + string booksStr = "book".PluralizeWithCount(failedToRemove.Count); + string message = $""" + Failed to remove {booksStr} from Audible. {failedToRemove.AggregateTitles()} """; - await MessageBoxBase.Show(message, $"Failed to Remove {bookBooks.FirstCharToUpper()} from Audible"); + await MessageBoxBase.Show(message, $"Failed to Remove {booksStr} from Audible"); } try { @@ -138,15 +158,13 @@ public class GridContextMenu { Serilog.Log.Logger.Error(ex, "Failed to delete locally removed from Audible books."); - var count = removedFromAudible.Count; - string bookBooks = count == 1 ? "book" : "books"; - - var message = $""" - Failed to delete {count} {bookBooks} from Libation. + string booksStr = "book".PluralizeWithCount(removedFromAudible.Count); + string message = $""" + Failed to delete {booksStr} from Libation. {removedFromAudible.AggregateTitles()} """; - await MessageBoxBase.Show(message, $"Failed to Delete {bookBooks.FirstCharToUpper()} from Libation"); + await MessageBoxBase.Show(message, $"Failed to Delete {booksStr} from Libation"); } }