diff --git a/Source/AppScaffolding/LibationScaffolding.cs b/Source/AppScaffolding/LibationScaffolding.cs index 20af1da9..e16f52f0 100644 --- a/Source/AppScaffolding/LibationScaffolding.cs +++ b/Source/AppScaffolding/LibationScaffolding.cs @@ -59,6 +59,7 @@ namespace AppScaffolding // Migrations.migrate_to_v6_6_9(config); + Migrations.migrate_from_7_10_1(config); } public static void PopulateMissingConfigValues(Configuration config) @@ -401,5 +402,28 @@ namespace AppScaffolding UNSAFE_MigrationHelper.Settings_AddUniqueToArray("Serilog.Enrich", "WithExceptionDetails"); } } + + public static void migrate_from_7_10_1(Configuration config) + { + //This migration removes books and series with SERIES_ prefix that were created + //as a hack workaround in 7.10.1. Said workaround was removed in 7.10.2 + + var migrated = config.GetNonString(nameof(migrate_from_7_10_1)); + + if (migrated) return; + + using var context = DbContexts.GetContext(); + + var booksToRemove = context.Books.Where(b => b.AudibleProductId.StartsWith("SERIES_")).ToArray(); + var seriesToRemove = context.Series.Where(s => s.AudibleSeriesId.StartsWith("SERIES_")).ToArray(); + var lbToRemove = context.LibraryBooks.Where(lb => booksToRemove.Any(b => b == lb.Book)).ToArray(); + + context.LibraryBooks.RemoveRange(lbToRemove); + context.Books.RemoveRange(booksToRemove); + context.Series.RemoveRange(seriesToRemove); + + LibraryCommands.SaveContext(context); + config.SetObject(nameof(migrate_from_7_10_1), true); + } } } diff --git a/Source/ApplicationServices/DbContexts.cs b/Source/ApplicationServices/DbContexts.cs index eb79a34d..d8f4b487 100644 --- a/Source/ApplicationServices/DbContexts.cs +++ b/Source/ApplicationServices/DbContexts.cs @@ -12,10 +12,10 @@ namespace ApplicationServices => LibationContext.Create(SqliteStorage.ConnectionString); /// Use for full library querying. No lazy loading - public static List GetLibrary_Flat_NoTracking() + public static List GetLibrary_Flat_NoTracking(bool includeParents = false) { using var context = GetContext(); - return context.GetLibrary_Flat_NoTracking(); + return context.GetLibrary_Flat_NoTracking(includeParents); } } } diff --git a/Source/ApplicationServices/LibraryCommands.cs b/Source/ApplicationServices/LibraryCommands.cs index 745e8a8f..66ee71e1 100644 --- a/Source/ApplicationServices/LibraryCommands.cs +++ b/Source/ApplicationServices/LibraryCommands.cs @@ -200,7 +200,7 @@ namespace ApplicationServices var libraryBookImporter = new LibraryBookImporter(context); var newCount = await Task.Run(() => libraryBookImporter.Import(importItems)); logTime("importIntoDbAsync -- post Import()"); - int qtyChanges = saveChanges(context); + int qtyChanges = SaveContext(context); logTime("importIntoDbAsync -- post SaveChanges"); // this is any changes at all to the database, not just new books @@ -211,7 +211,7 @@ namespace ApplicationServices return newCount; } - private static int saveChanges(LibationContext context) + public static int SaveContext(LibationContext context) { try { diff --git a/Source/ApplicationServices/SearchEngineCommands.cs b/Source/ApplicationServices/SearchEngineCommands.cs index 7a0e2219..efc21c7f 100644 --- a/Source/ApplicationServices/SearchEngineCommands.cs +++ b/Source/ApplicationServices/SearchEngineCommands.cs @@ -29,7 +29,7 @@ namespace ApplicationServices } #endregion - public static EventHandler SearchEngineUpdated; + public static event EventHandler SearchEngineUpdated; #region Update private static bool isUpdating; diff --git a/Source/AudibleUtilities/ApiExtended.cs b/Source/AudibleUtilities/ApiExtended.cs index 2a14de01..ff96a9ab 100644 --- a/Source/AudibleUtilities/ApiExtended.cs +++ b/Source/AudibleUtilities/ApiExtended.cs @@ -1,11 +1,14 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Threading; using System.Threading.Tasks; using AudibleApi; using AudibleApi.Common; using Dinah.Core; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; using Polly; using Polly.Retry; @@ -129,7 +132,7 @@ namespace AudibleUtilities await foreach (var item in Api.GetLibraryItemAsyncEnumerable(libraryOptions)) { - if (item.IsEpisodes && importEpisodes) + if ((item.IsEpisodes || item.IsSeriesParent) && importEpisodes) { //Get child episodes asynchronously and await all at the end getChildEpisodesTasks.Add(getChildEpisodesAsync(concurrencySemaphore, item)); @@ -173,16 +176,65 @@ namespace AudibleUtilities { Serilog.Log.Logger.Debug("Beginning episode scan for {parent}", parent); - var children = await getEpisodeChildrenAsync(parent); + List children; - if (!children.Any()) + if (parent.IsEpisodes) { - //The parent is the only episode in the podcase series, - //so the parent is its own child. - parent.Series = new Series[] { new Series { Asin = parent.Asin, Sequence = RelationshipToProduct.Parent, Title = parent.TitleWithSubtitle } }; - children.Add(parent); - return children; + //The 'parent' is a single episode that was added to the library. + //Get the episode's parent and add it to the database. + + Serilog.Log.Logger.Debug("Supplied Parent is an episode. Beginning parent scan for {parent}", parent); + + children = new() { parent }; + + var parentAsins = parent.Relationships + .Where(r => r.RelationshipToProduct == RelationshipToProduct.Parent) + .Select(p => p.Asin); + + var seriesParents = await Api.GetCatalogProductsAsync(parentAsins, CatalogOptions.ResponseGroupOptions.ALL_OPTIONS); + + int numSeriesParents = seriesParents.Count(p => p.IsSeriesParent); + if (numSeriesParents != 1) + { + //There should only ever be 1 top-level parent per episode. If not, log + //and throw so we can figure out what to do about those special cases. + JsonSerializerSettings Settings = new() + { + MetadataPropertyHandling = MetadataPropertyHandling.Ignore, + DateParseHandling = DateParseHandling.None, + Converters = + { + new IsoDateTimeConverter { DateTimeStyles = DateTimeStyles.AssumeUniversal } + }, + }; + var ex = new ApplicationException($"Found {numSeriesParents} parents for {parent.Asin}"); + Serilog.Log.Logger.Error(ex, $"Episode Product:\r\n{JsonConvert.SerializeObject(parent, Formatting.None, Settings)}"); + throw ex; + } + + var realParent = seriesParents.Single(p => p.IsSeriesParent); + realParent.PurchaseDate = parent.PurchaseDate; + + Serilog.Log.Logger.Debug("Completed parent scan for {parent}", parent); + parent = realParent; } + else + { + children = await getEpisodeChildrenAsync(parent); + if (!children.Any()) + return new(); + } + + //A series parent will always have exactly 1 Series + parent.Series = new Series[] + { + new Series + { + Asin = parent.Asin, + Sequence = "-1", + Title = parent.TitleWithSubtitle + } + }; foreach (var child in children) { @@ -199,17 +251,10 @@ namespace AudibleUtilities Title = parent.TitleWithSubtitle } }; - // overload (read: abuse) IsEpisodes flag - child.Relationships = new Relationship[] - { - new Relationship - { - RelationshipToProduct = RelationshipToProduct.Child, - RelationshipType = RelationshipType.Episode - } - }; } + children.Add(parent); + Serilog.Log.Logger.Debug("Completed episode scan for {parent}", parent); return children; diff --git a/Source/AudibleUtilities/AudibleUtilities.csproj b/Source/AudibleUtilities/AudibleUtilities.csproj index b6f7774f..69ecdf6c 100644 --- a/Source/AudibleUtilities/AudibleUtilities.csproj +++ b/Source/AudibleUtilities/AudibleUtilities.csproj @@ -5,7 +5,7 @@ - + diff --git a/Source/DataLayer/EfClasses/Book.cs b/Source/DataLayer/EfClasses/Book.cs index 804df40a..ef072484 100644 --- a/Source/DataLayer/EfClasses/Book.cs +++ b/Source/DataLayer/EfClasses/Book.cs @@ -16,8 +16,14 @@ namespace DataLayer } } - // enum will be easier than bool to extend later - public enum ContentType { Unknown = 0, Product = 1, Episode = 2 } + // enum will be easier than bool to extend later. + public enum ContentType + { + Unknown = 0, + Product = 1, + Episode = 2, + Parent = 4, + } public class Book { diff --git a/Source/DataLayer/QueryObjects/BookQueries.cs b/Source/DataLayer/QueryObjects/BookQueries.cs index e54bb42f..efcbdc36 100644 --- a/Source/DataLayer/QueryObjects/BookQueries.cs +++ b/Source/DataLayer/QueryObjects/BookQueries.cs @@ -35,5 +35,15 @@ namespace DataLayer .Include(b => b.SeriesLink).ThenInclude(sb => sb.Series) .Include(b => b.ContributorsLink).ThenInclude(c => c.Contributor) .Include(b => b.Category).ThenInclude(c => c.ParentCategory); + + public static bool IsProduct(this Book book) + => book.ContentType is not ContentType.Episode and not ContentType.Parent; + + public static bool IsEpisodeChild(this Book book) + => book.ContentType is ContentType.Episode; + + public static bool IsEpisodeParent(this Book book) + => book.ContentType is ContentType.Parent; + } } diff --git a/Source/DataLayer/QueryObjects/LibraryBookQueries.cs b/Source/DataLayer/QueryObjects/LibraryBookQueries.cs index 41016e52..9c60f3fb 100644 --- a/Source/DataLayer/QueryObjects/LibraryBookQueries.cs +++ b/Source/DataLayer/QueryObjects/LibraryBookQueries.cs @@ -15,11 +15,13 @@ namespace DataLayer // .GetLibrary() // .ToList(); - public static List GetLibrary_Flat_NoTracking(this LibationContext context) + public static List GetLibrary_Flat_NoTracking(this LibationContext context, bool includeParents = false) => context .LibraryBooks .AsNoTrackingWithIdentityResolution() .GetLibrary() + .AsEnumerable() + .Where(lb => !lb.Book.IsEpisodeParent() || includeParents) .ToList(); public static LibraryBook GetLibraryBook_Flat_NoTracking(this LibationContext context, string productId) @@ -40,5 +42,32 @@ namespace DataLayer .Include(le => le.Book).ThenInclude(b => b.SeriesLink).ThenInclude(sb => sb.Series) .Include(le => le.Book).ThenInclude(b => b.ContributorsLink).ThenInclude(c => c.Contributor) .Include(le => le.Book).ThenInclude(b => b.Category).ThenInclude(c => c.ParentCategory); + +#nullable enable + public static LibraryBook? FindSeriesParent(this IEnumerable libraryBooks, LibraryBook seriesEpisode) + { + if (seriesEpisode.Book.SeriesLink is null) return null; + + //Parent books will always have exactly 1 SeriesBook due to how + //they are imported in ApiExtended.getChildEpisodesAsync() + return libraryBooks.FirstOrDefault( + lb => + lb.Book.IsEpisodeParent() && + seriesEpisode.Book.SeriesLink.Any( + s => s.Series.AudibleSeriesId == lb.Book.SeriesLink.Single().Series.AudibleSeriesId)); + } +#nullable disable + + public static IEnumerable FindChildren(this IEnumerable bookList, LibraryBook parent) + => bookList + .Where( + lb => + lb.Book.IsEpisodeChild() && + lb.Book.SeriesLink? + .Any( + s => + s.Series.AudibleSeriesId == parent.Book.AudibleProductId + ) == true + ).ToList(); } } diff --git a/Source/DtoImporterService/BookImporter.cs b/Source/DtoImporterService/BookImporter.cs index bc69f382..297ae659 100644 --- a/Source/DtoImporterService/BookImporter.cs +++ b/Source/DtoImporterService/BookImporter.cs @@ -75,7 +75,7 @@ namespace DtoImporterService { var item = importItem.DtoItem; - var contentType = item.IsEpisodes ? DataLayer.ContentType.Episode : DataLayer.ContentType.Product; + var contentType = GetContentType(item); // absence of authors is very rare, but possible if (!item.Authors?.Any() ?? true) @@ -184,5 +184,15 @@ namespace DtoImporterService } } } + + private static DataLayer.ContentType GetContentType(Item item) + { + if (item.IsEpisodes) + return DataLayer.ContentType.Episode; + else if (item.IsSeriesParent) + return DataLayer.ContentType.Parent; + else + return DataLayer.ContentType.Product; + } } } diff --git a/Source/FileLiberator/Processable.cs b/Source/FileLiberator/Processable.cs index b8b984ff..f56c67f1 100644 --- a/Source/FileLiberator/Processable.cs +++ b/Source/FileLiberator/Processable.cs @@ -29,7 +29,7 @@ namespace FileLiberator public IEnumerable GetValidLibraryBooks(IEnumerable library) => library.Where(libraryBook => Validate(libraryBook) - && (libraryBook.Book.ContentType != ContentType.Episode || LibationFileManager.Configuration.Instance.DownloadEpisodes) + && (!libraryBook.Book.IsEpisodeChild() || Configuration.Instance.DownloadEpisodes) ); public async Task ProcessSingleAsync(LibraryBook libraryBook, bool validate) diff --git a/Source/LibationSearchEngine/SearchEngine.cs b/Source/LibationSearchEngine/SearchEngine.cs index a3c86867..e3e733d2 100644 --- a/Source/LibationSearchEngine/SearchEngine.cs +++ b/Source/LibationSearchEngine/SearchEngine.cs @@ -121,12 +121,12 @@ namespace LibationSearchEngine ["Liberated"] = lb => isLiberated(lb.Book), ["LiberatedError"] = lb => liberatedError(lb.Book), - ["Podcast"] = lb => lb.Book.ContentType == ContentType.Episode, - ["Podcasts"] = lb => lb.Book.ContentType == ContentType.Episode, - ["IsPodcast"] = lb => lb.Book.ContentType == ContentType.Episode, - ["Episode"] = lb => lb.Book.ContentType == ContentType.Episode, - ["Episodes"] = lb => lb.Book.ContentType == ContentType.Episode, - ["IsEpisode"] = lb => lb.Book.ContentType == ContentType.Episode, + ["Podcast"] = lb => lb.Book.IsEpisodeChild(), + ["Podcasts"] = lb => lb.Book.IsEpisodeChild(), + ["IsPodcast"] = lb => lb.Book.IsEpisodeChild(), + ["Episode"] = lb => lb.Book.IsEpisodeChild(), + ["Episodes"] = lb => lb.Book.IsEpisodeChild(), + ["IsEpisode"] = lb => lb.Book.IsEpisodeChild(), } ); diff --git a/Source/LibationWinForms/GridView/GridEntry.cs b/Source/LibationWinForms/GridView/GridEntry.cs index 4b1a614c..8e5702e1 100644 --- a/Source/LibationWinForms/GridView/GridEntry.cs +++ b/Source/LibationWinForms/GridView/GridEntry.cs @@ -1,21 +1,30 @@ using DataLayer; +using Dinah.Core; using Dinah.Core.DataBinding; using Dinah.Core.Drawing; using LibationFileManager; using System; using System.Collections; using System.Collections.Generic; +using System.ComponentModel; using System.Drawing; using System.Linq; namespace LibationWinForms.GridView { + /// The View Model base for the DataGridView public abstract class GridEntry : AsyncNotifyPropertyChanged, IMemberComparable { - protected abstract Book Book { get; } + [Browsable(false)] public string AudibleProductId => Book.AudibleProductId; + [Browsable(false)] public LibraryBook LibraryBook { get; protected set; } + [Browsable(false)] public float SeriesIndex { get; protected set; } + [Browsable(false)] public string LongDescription { get; protected set; } + [Browsable(false)] public abstract DateTime DateAdded { get; } + [Browsable(false)] protected Book Book => LibraryBook.Book; - private Image _cover; #region Model properties exposed to the view + + public abstract LiberateButtonStatus Liberate { get; } public Image Cover { get => _cover; @@ -25,59 +34,31 @@ namespace LibationWinForms.GridView NotifyPropertyChanged(); } } - public new bool InvokeRequired => base.InvokeRequired; - public abstract DateTime DateAdded { get; } - public abstract float SeriesIndex { get; } - public abstract string ProductRating { get; protected set; } - public abstract string PurchaseDate { get; protected set; } - public abstract string MyRating { get; protected set; } - public abstract string Series { get; protected set; } - public abstract string Title { get; protected set; } - public abstract string Length { get; protected set; } - public abstract string Authors { get; protected set; } - public abstract string Narrators { get; protected set; } - public abstract string Category { get; protected set; } - public abstract string Misc { get; protected set; } - public abstract string Description { get; protected set; } + public string PurchaseDate { get; protected set; } + public string Series { get; protected set; } + public string Title { get; protected set; } + public string Length { get; protected set; } + public string Authors { get; protected set; } + public string Narrators { get; protected set; } + public string Category { get; protected set; } + public string Misc { get; protected set; } + public string Description { get; protected set; } + public string ProductRating { get; protected set; } + public string MyRating { get; protected set; } public abstract string DisplayTags { get; } - public abstract LiberateButtonStatus Liberate { get; } + #endregion #region Sorting public GridEntry() => _memberValues = CreateMemberValueDictionary(); - private Dictionary> _memberValues { get; set; } - protected abstract Dictionary> CreateMemberValueDictionary(); // These methods are implementation of Dinah.Core.DataBinding.IMemberComparable // Used by GridEntryBindingList for all sorting public virtual object GetMemberValue(string memberName) => _memberValues[memberName](); public IComparer GetMemberComparer(Type memberType) => _memberTypeComparers[memberType]; - - #endregion - - protected void LoadCover() - { - // Get cover art. If it's default, subscribe to PictureCached - { - (bool isDefault, byte[] picture) = PictureStorage.GetPicture(new PictureDefinition(Book.PictureId, PictureSize._80x80)); - - if (isDefault) - PictureStorage.PictureCached += PictureStorage_PictureCached; - - // Mutable property. Set the field so PropertyChanged isn't fired. - _cover = ImageReader.ToImage(picture); - } - } - - private void PictureStorage_PictureCached(object sender, PictureCachedEventArgs e) - { - if (e.Definition.PictureId == Book.PictureId) - { - Cover = ImageReader.ToImage(e.Picture); - PictureStorage.PictureCached -= PictureStorage_PictureCached; - } - } + protected abstract Dictionary> CreateMemberValueDictionary(); + private Dictionary> _memberValues { get; set; } // Instantiate comparers for every exposed member object type. private static readonly Dictionary _memberTypeComparers = new() @@ -90,25 +71,87 @@ namespace LibationWinForms.GridView { typeof(LiberateButtonStatus), new ObjectComparer() }, }; + #endregion + + #region Cover Art + + private Image _cover; + protected void LoadCover() + { + // Get cover art. If it's default, subscribe to PictureCached + (bool isDefault, byte[] picture) = PictureStorage.GetPicture(new PictureDefinition(Book.PictureId, PictureSize._80x80)); + + if (isDefault) + PictureStorage.PictureCached += PictureStorage_PictureCached; + + // Mutable property. Set the field so PropertyChanged isn't fired. + _cover = ImageReader.ToImage(picture); + } + + private void PictureStorage_PictureCached(object sender, PictureCachedEventArgs e) + { + if (e.Definition.PictureId == Book.PictureId) + { + Cover = ImageReader.ToImage(e.Picture); + PictureStorage.PictureCached -= PictureStorage_PictureCached; + } + } + + #endregion + + #region Static library display functions + + /// This information should not change during lifetime, so call only once. + protected static string GetDescriptionDisplay(Book book) + { + var doc = new HtmlAgilityPack.HtmlDocument(); + doc.LoadHtml(book?.Description?.Replace("

", "\r\n\r\n

") ?? ""); + return doc.DocumentNode.InnerText.Trim(); + } + + protected static string TrimTextToWord(string text, int maxLength) + { + return + text.Length <= maxLength ? + text : + text.Substring(0, maxLength - 3) + "..."; + } + + + /// + /// This information should not change during lifetime, so call only once. + /// Maximum of 5 text rows will fit in 80-pixel row height. + /// + protected static string GetMiscDisplay(LibraryBook libraryBook) + { + var details = new List(); + + var locale = libraryBook.Book.Locale.DefaultIfNullOrWhiteSpace("[unknown]"); + var acct = libraryBook.Account.DefaultIfNullOrWhiteSpace("[unknown]"); + + details.Add($"Account: {locale} - {acct}"); + + if (libraryBook.Book.HasPdf()) + details.Add("Has PDF"); + if (libraryBook.Book.IsAbridged) + details.Add("Abridged"); + if (libraryBook.Book.DatePublished.HasValue) + details.Add($"Date pub'd: {libraryBook.Book.DatePublished.Value:MM/dd/yyyy}"); + // this goes last since it's most likely to have a line-break + if (!string.IsNullOrWhiteSpace(libraryBook.Book.Publisher)) + details.Add($"Pub: {libraryBook.Book.Publisher.Trim()}"); + + if (!details.Any()) + return "[details not imported]"; + + return string.Join("\r\n", details); + } + + #endregion + ~GridEntry() { PictureStorage.PictureCached -= PictureStorage_PictureCached; } } - - internal static class GridEntryExtensions - { -#nullable enable - public static IEnumerable Series(this IEnumerable gridEntries) - => gridEntries.OfType(); - public static IEnumerable LibraryBooks(this IEnumerable gridEntries) - => gridEntries.OfType(); - public static LibraryBookEntry? FindBookByAsin(this IEnumerable gridEntries, string audibleProductID) - => gridEntries.FirstOrDefault(i => i.AudibleProductId == audibleProductID); - public static SeriesEntry? FindBookSeriesEntry(this IEnumerable gridEntries, IEnumerable matchSeries) - => gridEntries.Series().FirstOrDefault(i => matchSeries.Any(s => s.Series.Name == i.Series)); - public static IEnumerable EmptySeries(this IEnumerable gridEntries) - => gridEntries.Series().Where(i => i.Children.Count == 0); - public static bool IsEpisodeChild(this LibraryBook lb) => lb.Book.ContentType == ContentType.Episode; - } } diff --git a/Source/LibationWinForms/GridView/GridEntryBindingList.cs b/Source/LibationWinForms/GridView/GridEntryBindingList.cs index 58be850c..e2b45c61 100644 --- a/Source/LibationWinForms/GridView/GridEntryBindingList.cs +++ b/Source/LibationWinForms/GridView/GridEntryBindingList.cs @@ -73,10 +73,10 @@ namespace LibationWinForms.GridView FilterString = filterString; SearchResults = SearchEngineCommands.Search(filterString); - var booksFilteredIn = Items.LibraryBooks().Join(SearchResults.Docs, lbe => lbe.AudibleProductId, d => d.ProductId, (lbe, d) => (GridEntry)lbe); + var booksFilteredIn = Items.BookEntries().Join(SearchResults.Docs, lbe => lbe.AudibleProductId, d => d.ProductId, (lbe, d) => (GridEntry)lbe); //Find all series containing children that match the search criteria - var seriesFilteredIn = Items.Series().Where(s => s.Children.Join(SearchResults.Docs, lbe => lbe.AudibleProductId, d => d.ProductId, (lbe, d) => lbe).Any()); + var seriesFilteredIn = Items.SeriesEntries().Where(s => s.Children.Join(SearchResults.Docs, lbe => lbe.AudibleProductId, d => d.ProductId, (lbe, d) => lbe).Any()); var filteredOut = Items.Except(booksFilteredIn.Concat(seriesFilteredIn)).ToList(); @@ -89,19 +89,19 @@ namespace LibationWinForms.GridView public void CollapseAll() { - foreach (var series in Items.Series().ToList()) + foreach (var series in Items.SeriesEntries().ToList()) CollapseItem(series); } public void ExpandAll() { - foreach (var series in Items.Series().ToList()) + foreach (var series in Items.SeriesEntries().ToList()) ExpandItem(series); } public void CollapseItem(SeriesEntry sEntry) { - foreach (var episode in Items.LibraryBooks().Where(b => b.Parent == sEntry).ToList()) + foreach (var episode in Items.BookEntries().Where(b => b.Parent == sEntry).ToList()) { FilterRemoved.Add(episode); base.Remove(episode); @@ -114,7 +114,7 @@ namespace LibationWinForms.GridView { var sindex = Items.IndexOf(sEntry); - foreach (var episode in FilterRemoved.LibraryBooks().Where(b => b.Parent == sEntry).ToList()) + foreach (var episode in FilterRemoved.BookEntries().Where(b => b.Parent == sEntry).ToList()) { if (SearchResults is null || SearchResults.Docs.Any(d => d.ProductId == episode.AudibleProductId)) { @@ -174,7 +174,7 @@ namespace LibationWinForms.GridView { var itemsList = (List)Items; - var children = itemsList.LibraryBooks().Where(i => i.Parent is not null).ToList(); + var children = itemsList.BookEntries().Where(i => i.Parent is not null).ToList(); var sortedItems = itemsList.Except(children).OrderBy(ge => ge, Comparer).ToList(); diff --git a/Source/LibationWinForms/GridView/LibraryBookEntry.cs b/Source/LibationWinForms/GridView/LibraryBookEntry.cs index be6836a8..861b9587 100644 --- a/Source/LibationWinForms/GridView/LibraryBookEntry.cs +++ b/Source/LibationWinForms/GridView/LibraryBookEntry.cs @@ -8,23 +8,11 @@ using System.Linq; namespace LibationWinForms.GridView { - /// - /// The View Model for a LibraryBook - /// + /// The View Model for a LibraryBook that is ContentType.Product or ContentType.Episode public class LibraryBookEntry : GridEntry { - #region implementation properties NOT exposed to the view - // hide from public fields from Data Source GUI with [Browsable(false)] - - [Browsable(false)] - public string AudibleProductId => Book.AudibleProductId; - [Browsable(false)] - public LibraryBook LibraryBook { get; private set; } - [Browsable(false)] - public string LongDescription { get; private set; } - #endregion - - protected override Book Book => LibraryBook.Book; + [Browsable(false)] public override DateTime DateAdded => LibraryBook.DateAdded; + [Browsable(false)] public SeriesEntry Parent { get; init; } #region Model properties exposed to the view @@ -32,22 +20,6 @@ namespace LibationWinForms.GridView private LiberatedStatus _bookStatus; private LiberatedStatus? _pdfStatus; - public override DateTime DateAdded => LibraryBook.DateAdded; - public override float SeriesIndex => Book.SeriesLink.FirstOrDefault()?.Index ?? 0; - public override string ProductRating { get; protected set; } - public override string PurchaseDate { get; protected set; } - public override string MyRating { get; protected set; } - public override string Series { get; protected set; } - public override string Title { get; protected set; } - public override string Length { get; protected set; } - public override string Authors { get; protected set; } - public override string Narrators { get; protected set; } - public override string Category { get; protected set; } - public override string Misc { get; protected set; } - public override string Description { get; protected set; } - public override string DisplayTags => string.Join("\r\n", Book.UserDefinedItem.TagsEnumerated); - - // these 2 values being in 1 field is the trick behind getting the liberated+pdf 'stoplight' icon to draw. See: LiberateDataGridViewImageButtonCell.Paint public override LiberateButtonStatus Liberate { get @@ -62,6 +34,8 @@ namespace LibationWinForms.GridView return new LiberateButtonStatus { BookStatus = _bookStatus, PdfStatus = _pdfStatus, IsSeries = false }; } } + public override string DisplayTags => string.Join("\r\n", Book.UserDefinedItem.TagsEnumerated); + #endregion public LibraryBookEntry(LibraryBook libraryBook) @@ -70,7 +44,6 @@ namespace LibationWinForms.GridView LoadCover(); } - public SeriesEntry Parent { get; init; } public void UpdateLibraryBook(LibraryBook libraryBook) { if (AudibleProductId != libraryBook.Book.AudibleProductId) @@ -86,26 +59,23 @@ namespace LibationWinForms.GridView { LibraryBook = libraryBook; - // Immutable properties - { - Title = Book.Title; - Series = Book.SeriesNames(); - Length = Book.LengthInMinutes == 0 ? "" : $"{Book.LengthInMinutes / 60} hr {Book.LengthInMinutes % 60} min"; - MyRating = Book.UserDefinedItem.Rating?.ToStarString()?.DefaultIfNullOrWhiteSpace(""); - PurchaseDate = libraryBook.DateAdded.ToString("d"); - ProductRating = Book.Rating?.ToStarString()?.DefaultIfNullOrWhiteSpace(""); - Authors = Book.AuthorNames(); - Narrators = Book.NarratorNames(); - Category = string.Join(" > ", Book.CategoriesNames()); - Misc = GetMiscDisplay(libraryBook); - LongDescription = GetDescriptionDisplay(Book); - Description = TrimTextToWord(LongDescription, 62); - } + Title = Book.Title; + Series = Book.SeriesNames(); + Length = Book.LengthInMinutes == 0 ? "" : $"{Book.LengthInMinutes / 60} hr {Book.LengthInMinutes % 60} min"; + MyRating = Book.UserDefinedItem.Rating?.ToStarString()?.DefaultIfNullOrWhiteSpace(""); + PurchaseDate = libraryBook.DateAdded.ToString("d"); + ProductRating = Book.Rating?.ToStarString()?.DefaultIfNullOrWhiteSpace(""); + Authors = Book.AuthorNames(); + Narrators = Book.NarratorNames(); + Category = string.Join(" > ", Book.CategoriesNames()); + Misc = GetMiscDisplay(libraryBook); + LongDescription = GetDescriptionDisplay(Book); + Description = TrimTextToWord(LongDescription, 62); + SeriesIndex = Book.SeriesLink.FirstOrDefault()?.Index ?? 0; UserDefinedItem.ItemChanged += UserDefinedItem_ItemChanged; } - #region detect changes to the model, update the view, and save to database. /// @@ -169,58 +139,6 @@ namespace LibationWinForms.GridView { nameof(DateAdded), () => DateAdded }, }; - - #endregion - - #region Static library display functions - - /// - /// This information should not change during lifetime, so call only once. - /// - private static string GetDescriptionDisplay(Book book) - { - var doc = new HtmlAgilityPack.HtmlDocument(); - doc.LoadHtml(book?.Description?.Replace("

", "\r\n\r\n

") ?? ""); - return doc.DocumentNode.InnerText.Trim(); - } - - private static string TrimTextToWord(string text, int maxLength) - { - return - text.Length <= maxLength ? - text : - text.Substring(0, maxLength - 3) + "..."; - } - - /// - /// This information should not change during lifetime, so call only once. - /// Maximum of 5 text rows will fit in 80-pixel row height. - /// - private static string GetMiscDisplay(LibraryBook libraryBook) - { - var details = new List(); - - var locale = libraryBook.Book.Locale.DefaultIfNullOrWhiteSpace("[unknown]"); - var acct = libraryBook.Account.DefaultIfNullOrWhiteSpace("[unknown]"); - - details.Add($"Account: {locale} - {acct}"); - - if (libraryBook.Book.HasPdf()) - details.Add("Has PDF"); - if (libraryBook.Book.IsAbridged) - details.Add("Abridged"); - if (libraryBook.Book.DatePublished.HasValue) - details.Add($"Date pub'd: {libraryBook.Book.DatePublished.Value:MM/dd/yyyy}"); - // this goes last since it's most likely to have a line-break - if (!string.IsNullOrWhiteSpace(libraryBook.Book.Publisher)) - details.Add($"Pub: {libraryBook.Book.Publisher.Trim()}"); - - if (!details.Any()) - return "[details not imported]"; - - return string.Join("\r\n", details); - } - #endregion ~LibraryBookEntry() diff --git a/Source/LibationWinForms/GridView/ProductsDisplay.Designer.cs b/Source/LibationWinForms/GridView/ProductsDisplay.Designer.cs index c4b1a543..db9ca6a5 100644 --- a/Source/LibationWinForms/GridView/ProductsDisplay.Designer.cs +++ b/Source/LibationWinForms/GridView/ProductsDisplay.Designer.cs @@ -39,10 +39,10 @@ this.productsGrid.Name = "productsGrid"; this.productsGrid.Size = new System.Drawing.Size(1510, 380); this.productsGrid.TabIndex = 0; - this.productsGrid.LiberateClicked += new LibationWinForms.GridView.ProductsGrid.LibraryBookEntryClickedEventHandler(this.productsGrid_LiberateClicked); - this.productsGrid.CoverClicked += new LibationWinForms.GridView.ProductsGrid.LibraryBookEntryClickedEventHandler(this.productsGrid_CoverClicked); - this.productsGrid.DetailsClicked += new LibationWinForms.GridView.ProductsGrid.LibraryBookEntryClickedEventHandler(this.productsGrid_DetailsClicked); - this.productsGrid.DescriptionClicked += new LibationWinForms.GridView.ProductsGrid.LibraryBookEntryRectangleClickedEventHandler(this.productsGrid_DescriptionClicked); + this.productsGrid.LiberateClicked += new LibationWinForms.GridView.LibraryBookEntryClickedEventHandler(this.productsGrid_LiberateClicked); + this.productsGrid.CoverClicked += new LibationWinForms.GridView.GridEntryClickedEventHandler(this.productsGrid_CoverClicked); + this.productsGrid.DetailsClicked += new LibationWinForms.GridView.LibraryBookEntryClickedEventHandler(this.productsGrid_DetailsClicked); + this.productsGrid.DescriptionClicked += new LibationWinForms.GridView.GridEntryRectangleClickedEventHandler(this.productsGrid_DescriptionClicked); this.productsGrid.VisibleCountChanged += new System.EventHandler(this.productsGrid_VisibleCountChanged); // // ProductsDisplay diff --git a/Source/LibationWinForms/GridView/ProductsDisplay.cs b/Source/LibationWinForms/GridView/ProductsDisplay.cs index 7c411f3f..7eb55455 100644 --- a/Source/LibationWinForms/GridView/ProductsDisplay.cs +++ b/Source/LibationWinForms/GridView/ProductsDisplay.cs @@ -29,7 +29,7 @@ namespace LibationWinForms.GridView #region Button controls private ImageDisplay imageDisplay; - private async void productsGrid_CoverClicked(LibraryBookEntry liveGridEntry) + private async void productsGrid_CoverClicked(GridEntry liveGridEntry) { var picDefinition = new PictureDefinition(liveGridEntry.LibraryBook.Book.PictureLarge ?? liveGridEntry.LibraryBook.Book.PictureId, PictureSize.Native); var picDlTask = Task.Run(() => PictureStorage.GetPictureSynchronously(picDefinition)); @@ -52,7 +52,7 @@ namespace LibationWinForms.GridView imageDisplay.CoverPicture = await picDlTask; } - private void productsGrid_DescriptionClicked(LibraryBookEntry liveGridEntry, Rectangle cellRectangle) + private void productsGrid_DescriptionClicked(GridEntry liveGridEntry, Rectangle cellRectangle) { var displayWindow = new DescriptionDisplay { @@ -87,7 +87,7 @@ namespace LibationWinForms.GridView try { // don't return early if lib size == 0. this will not update correctly if all books are removed - var lib = DbContexts.GetLibrary_Flat_NoTracking(); + var lib = DbContexts.GetLibrary_Flat_NoTracking(includeParents: true); if (!hasBeenDisplayed) { @@ -103,7 +103,6 @@ namespace LibationWinForms.GridView { Serilog.Log.Error(ex, "Error displaying library in {0}", nameof(ProductsDisplay)); } - } #endregion @@ -115,7 +114,7 @@ namespace LibationWinForms.GridView #endregion - internal List GetVisible() => productsGrid.GetVisible().Select(v => v.LibraryBook).ToList(); + internal List GetVisible() => productsGrid.GetVisibleBooks().ToList(); private void productsGrid_VisibleCountChanged(object sender, int count) { diff --git a/Source/LibationWinForms/GridView/ProductsGrid.cs b/Source/LibationWinForms/GridView/ProductsGrid.cs index 24dafc73..c13a2d12 100644 --- a/Source/LibationWinForms/GridView/ProductsGrid.cs +++ b/Source/LibationWinForms/GridView/ProductsGrid.cs @@ -10,23 +10,25 @@ using System.Windows.Forms; namespace LibationWinForms.GridView { + public delegate void GridEntryClickedEventHandler(GridEntry liveGridEntry); + public delegate void LibraryBookEntryClickedEventHandler(LibraryBookEntry liveGridEntry); + public delegate void GridEntryRectangleClickedEventHandler(GridEntry liveGridEntry, Rectangle cellRectangle); + public partial class ProductsGrid : UserControl { - public delegate void LibraryBookEntryClickedEventHandler(LibraryBookEntry liveGridEntry); - public delegate void LibraryBookEntryRectangleClickedEventHandler(LibraryBookEntry liveGridEntry, Rectangle cellRectangle); - /// Number of visible rows has changed public event EventHandler VisibleCountChanged; public event LibraryBookEntryClickedEventHandler LiberateClicked; - public event LibraryBookEntryClickedEventHandler CoverClicked; + public event GridEntryClickedEventHandler CoverClicked; public event LibraryBookEntryClickedEventHandler DetailsClicked; - public event LibraryBookEntryRectangleClickedEventHandler DescriptionClicked; + public event GridEntryRectangleClickedEventHandler DescriptionClicked; public new event EventHandler Scroll; private GridEntryBindingList bindingList; - internal IEnumerable GetVisible() + internal IEnumerable GetVisibleBooks() => bindingList - .LibraryBooks(); + .BookEntries() + .Select(lbe => lbe.LibraryBook); public ProductsGrid() { @@ -61,16 +63,23 @@ namespace LibationWinForms.GridView else if (e.ColumnIndex == coverGVColumn.Index) CoverClicked?.Invoke(lbEntry); } - else if (entry is SeriesEntry sEntry && e.ColumnIndex == liberateGVColumn.Index) + else if (entry is SeriesEntry sEntry) { - if (sEntry.Liberate.Expanded) - bindingList.CollapseItem(sEntry); - else - bindingList.ExpandItem(sEntry); + if (e.ColumnIndex == liberateGVColumn.Index) + { + if (sEntry.Liberate.Expanded) + bindingList.CollapseItem(sEntry); + else + bindingList.ExpandItem(sEntry); - sEntry.NotifyPropertyChanged(nameof(sEntry.Liberate)); + sEntry.NotifyPropertyChanged(nameof(sEntry.Liberate)); - VisibleCountChanged?.Invoke(this, bindingList.LibraryBooks().Count()); + VisibleCountChanged?.Invoke(this, bindingList.BookEntries().Count()); + } + else if (e.ColumnIndex == descriptionGVColumn.Index) + DescriptionClicked?.Invoke(sEntry, gridEntryDataGridView.GetCellDisplayRectangle(e.ColumnIndex, e.RowIndex, false)); + else if (e.ColumnIndex == coverGVColumn.Index) + CoverClicked?.Invoke(sEntry); } } @@ -82,14 +91,17 @@ namespace LibationWinForms.GridView internal void BindToGrid(List dbBooks) { - var geList = dbBooks.Where(b => b.Book.ContentType is not ContentType.Episode).Select(b => new LibraryBookEntry(b)).Cast().ToList(); + var geList = dbBooks.Where(lb => lb.Book.IsProduct()).Select(b => new LibraryBookEntry(b)).Cast().ToList(); - var episodes = dbBooks.Where(b => b.IsEpisodeChild()).ToList(); - - var allSeries = episodes.SelectMany(lb => lb.Book.SeriesLink.Where(s => !s.Series.AudibleSeriesId.StartsWith("SERIES_"))).DistinctBy(s => s.Series).ToList(); - foreach (var series in allSeries) + var episodes = dbBooks.Where(lb => lb.Book.IsEpisodeChild()); + + foreach (var parent in dbBooks.Where(lb => lb.Book.IsEpisodeParent())) { - var seriesEntry = new SeriesEntry(series, episodes.Where(lb => lb.Book.SeriesLink.Any(s => s.Series == series.Series))); + var seriesEpisodes = episodes.FindChildren(parent); + + if (!seriesEpisodes.Any()) continue; + + var seriesEntry = new SeriesEntry(parent, seriesEpisodes); geList.Add(seriesEntry); geList.AddRange(seriesEntry.Children); @@ -98,79 +110,47 @@ namespace LibationWinForms.GridView bindingList = new GridEntryBindingList(geList.OrderByDescending(e => e.DateAdded)); bindingList.CollapseAll(); syncBindingSource.DataSource = bindingList; - VisibleCountChanged?.Invoke(this, bindingList.LibraryBooks().Count()); + VisibleCountChanged?.Invoke(this, bindingList.BookEntries().Count()); } internal void UpdateGrid(List dbBooks) { + #region Add new or update existing grid entries + + //Remove filter prior to adding/updating boooks string existingFilter = syncBindingSource.Filter; Filter(null); bindingList.SuspendFilteringOnUpdate = true; - //Add absent books to grid, or update current books + //Add absent entries to grid, or update existing entry - var allItmes = bindingList.AllItems().LibraryBooks(); - foreach (var libraryBook in dbBooks) + var allEntries = bindingList.AllItems().BookEntries(); + var seriesEntries = bindingList.AllItems().SeriesEntries().ToList(); + + foreach (var libraryBook in dbBooks.OrderBy(e => e.DateAdded)) { - var existingItem = allItmes.FindBookByAsin(libraryBook.Book.AudibleProductId); + var existingEntry = allEntries.FindByAsin(libraryBook.Book.AudibleProductId); - // add new to top - if (existingItem is null) - { - if (libraryBook.IsEpisodeChild()) - { - LibraryBookEntry lbe; - //Find the series that libraryBook belongs to, if it exists - var series = bindingList.AllItems().FindBookSeriesEntry(libraryBook.Book.SeriesLink); - - if (series is null) - { - //Series doesn't exist yet, so create and add it - var newSeries = new SeriesEntry(libraryBook.Book.SeriesLink.First(), libraryBook); - lbe = newSeries.Children[0]; - newSeries.Liberate.Expanded = true; - bindingList.Insert(0, newSeries); - series = newSeries; - } - else - { - lbe = new(libraryBook) { Parent = series }; - series.Children.Add(lbe); - } - //Add episode beneath the parent - int seriesIndex = bindingList.IndexOf(series); - bindingList.Insert(seriesIndex + 1, lbe); - - if (series.Liberate.Expanded) - bindingList.ExpandItem(series); - else - bindingList.CollapseItem(series); - - series.NotifyPropertyChanged(); - } - else if (libraryBook.Book.ContentType is not ContentType.Episode) - //Add the new product - bindingList.Insert(0, new LibraryBookEntry(libraryBook)); - } - // update existing - else - { - existingItem.UpdateLibraryBook(libraryBook); - } - } + if (libraryBook.Book.IsProduct()) + AddOrUpdateBook(libraryBook, existingEntry); + else if(libraryBook.Book.IsEpisodeChild()) + AddOrUpdateEpisode(libraryBook, existingEntry, seriesEntries, dbBooks); + } bindingList.SuspendFilteringOnUpdate = false; - //Re-filter after updating existing / adding new books to capture any changes + //Re-apply filter after adding new/updating existing books to capture any changes Filter(existingFilter); + #endregion + // remove deleted from grid. // note: actual deletion from db must still occur via the RemoveBook feature. deleting from audible will not trigger this var removedBooks = bindingList .AllItems() - .LibraryBooks() + .BookEntries() .ExceptBy(dbBooks.Select(lb => lb.Book.AudibleProductId), ge => ge.AudibleProductId); //Remove books in series from their parents' Children list @@ -190,7 +170,70 @@ namespace LibationWinForms.GridView //no need to re-filter for removed books bindingList.Remove(removed); - VisibleCountChanged?.Invoke(this, bindingList.LibraryBooks().Count()); + VisibleCountChanged?.Invoke(this, bindingList.BookEntries().Count()); + } + + private void AddOrUpdateBook(LibraryBook book, LibraryBookEntry existingBookEntry) + { + if (existingBookEntry is null) + // Add the new product to top + bindingList.Insert(0, new LibraryBookEntry(book)); + else + // update existing + existingBookEntry.UpdateLibraryBook(book); + } + + private void AddOrUpdateEpisode(LibraryBook episodeBook, LibraryBookEntry existingEpisodeEntry, List seriesEntries, IEnumerable dbBooks) + { + if (existingEpisodeEntry is null) + { + LibraryBookEntry episodeEntry; + var seriesEntry = seriesEntries.FindSeriesParent(episodeBook); + + if (seriesEntry is null) + { + //Series doesn't exist yet, so create and add it + var seriesBook = dbBooks.FindSeriesParent(episodeBook); + + if (seriesBook is null) + { + //This should be impossible because the importer ensures every episode has a parent. + var ex = new ApplicationException($"Episode's series parent not found in database."); + var seriesLinks = string.Join("\r\n", episodeBook.Book.SeriesLink?.Select(sb => $"{nameof(sb.Series.Name)}={sb.Series.Name}, {nameof(sb.Series.AudibleSeriesId)}={sb.Series.AudibleSeriesId}")); + Serilog.Log.Logger.Error(ex, "Episode={episodeBook}, Series: {seriesLinks}", episodeBook, seriesLinks); + throw ex; + } + + seriesEntry = new SeriesEntry(seriesBook, episodeBook); + seriesEntries.Add(seriesEntry); + + episodeEntry = seriesEntry.Children[0]; + seriesEntry.Liberate.Expanded = true; + bindingList.Insert(0, seriesEntry); + } + else + { + //Series exists. Create and add episode child then update the SeriesEntry + episodeEntry = new(episodeBook) { Parent = seriesEntry }; + seriesEntry.Children.Add(episodeEntry); + var seriesBook = dbBooks.Single(lb => lb.Book.AudibleProductId == seriesEntry.LibraryBook.Book.AudibleProductId); + seriesEntry.UpdateSeries(seriesBook); + } + + //Add episode to the grid beneath the parent + int seriesIndex = bindingList.IndexOf(seriesEntry); + bindingList.Insert(seriesIndex + 1, episodeEntry); + + if (seriesEntry.Liberate.Expanded) + bindingList.ExpandItem(seriesEntry); + else + bindingList.CollapseItem(seriesEntry); + + seriesEntry.NotifyPropertyChanged(); + + } + else + existingEpisodeEntry.UpdateLibraryBook(episodeBook); } #endregion @@ -207,7 +250,7 @@ namespace LibationWinForms.GridView syncBindingSource.Filter = searchString; if (visibleCount != bindingList.Count) - VisibleCountChanged?.Invoke(this, bindingList.LibraryBooks().Count()); + VisibleCountChanged?.Invoke(this, bindingList.BookEntries().Count()); } diff --git a/Source/LibationWinForms/GridView/QueryExtensions.cs b/Source/LibationWinForms/GridView/QueryExtensions.cs new file mode 100644 index 00000000..d084aec7 --- /dev/null +++ b/Source/LibationWinForms/GridView/QueryExtensions.cs @@ -0,0 +1,36 @@ +using DataLayer; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace LibationWinForms.GridView +{ +#nullable enable + internal static class QueryExtensions + { + public static IEnumerable BookEntries(this IEnumerable gridEntries) + => gridEntries.OfType(); + + public static IEnumerable SeriesEntries(this IEnumerable gridEntries) + => gridEntries.OfType(); + + public static T? FindByAsin(this IEnumerable gridEntries, string audibleProductID) where T : GridEntry + => gridEntries.FirstOrDefault(i => i.AudibleProductId == audibleProductID); + + public static IEnumerable EmptySeries(this IEnumerable gridEntries) + => gridEntries.SeriesEntries().Where(i => i.Children.Count == 0); + + public static SeriesEntry? FindSeriesParent(this IEnumerable gridEntries, LibraryBook seriesEpisode) + { + if (seriesEpisode.Book.SeriesLink is null) return null; + + //Parent books will always have exactly 1 SeriesBook due to how + //they are imported in ApiExtended.getChildEpisodesAsync() + return gridEntries.SeriesEntries().FirstOrDefault( + lb => + seriesEpisode.Book.SeriesLink.Any( + s => s.Series.AudibleSeriesId == lb.LibraryBook.Book.SeriesLink.Single().Series.AudibleSeriesId)); + } + } +#nullable disable +} diff --git a/Source/LibationWinForms/GridView/SeriesEntry.cs b/Source/LibationWinForms/GridView/SeriesEntry.cs index 96d40f49..ca422029 100644 --- a/Source/LibationWinForms/GridView/SeriesEntry.cs +++ b/Source/LibationWinForms/GridView/SeriesEntry.cs @@ -2,108 +2,90 @@ using Dinah.Core; using System; using System.Collections.Generic; +using System.ComponentModel; using System.Linq; namespace LibationWinForms.GridView { + /// The View Model for a LibraryBook that is ContentType.Parent public class SeriesEntry : GridEntry { - public List Children { get; init; } - public override DateTime DateAdded => Children.Max(c => c.DateAdded); - public override float SeriesIndex { get; } - public override string ProductRating - { - get - { - var productAverageRating = new Rating(Children.Average(c => c.LibraryBook.Book.Rating.OverallRating), Children.Average(c => c.LibraryBook.Book.Rating.PerformanceRating), Children.Average(c => c.LibraryBook.Book.Rating.StoryRating)); - return productAverageRating.ToStarString()?.DefaultIfNullOrWhiteSpace(""); - } - protected set => throw new NotImplementedException(); - } - public override string PurchaseDate { get; protected set; } - public override string MyRating - { - get - { - var myAverageRating = new Rating(Children.Average(c => c.LibraryBook.Book.UserDefinedItem.Rating.OverallRating), Children.Average(c => c.LibraryBook.Book.UserDefinedItem.Rating.PerformanceRating), Children.Average(c => c.LibraryBook.Book.UserDefinedItem.Rating.StoryRating)); - return myAverageRating.ToStarString()?.DefaultIfNullOrWhiteSpace(""); - } - protected set => throw new NotImplementedException(); - } - public override string Series { get; protected set; } - public override string Title { get; protected set; } - public override string Length - { - get - { - int bookLenMins = Children.Sum(c => c.LibraryBook.Book.LengthInMinutes); - return bookLenMins == 0 ? "" : $"{bookLenMins / 60} hr {bookLenMins % 60} min"; - } - protected set => throw new NotImplementedException(); - } - public override string Authors { get; protected set; } - public override string Narrators { get; protected set; } - public override string Category { get; protected set; } - public override string Misc { get; protected set; } = string.Empty; - public override string Description { get; protected set; } = string.Empty; - public override string DisplayTags { get; } = string.Empty; + [Browsable(false)] public List Children { get; } + [Browsable(false)] public override DateTime DateAdded => Children.Max(c => c.DateAdded); + + #region Model properties exposed to the view public override LiberateButtonStatus Liberate { get; } + public override string DisplayTags { get; } = string.Empty; - protected override Book Book => SeriesBook.Book; + #endregion - private SeriesBook SeriesBook { get; set; } - - private SeriesEntry(SeriesBook seriesBook) + private SeriesEntry(LibraryBook parent) { Liberate = new LiberateButtonStatus { IsSeries = true }; - SeriesIndex = seriesBook.Index; + SeriesIndex = -1; + LibraryBook = parent; + LoadCover(); } - public SeriesEntry(SeriesBook seriesBook, IEnumerable children) : this(seriesBook) + + public SeriesEntry(LibraryBook parent, IEnumerable children) : this(parent) { - Children = children.Select(c => new LibraryBookEntry(c) { Parent = this }).OrderBy(c => c.SeriesIndex).ToList(); - SetSeriesBook(seriesBook); + Children = children + .Select(c => new LibraryBookEntry(c) { Parent = this }) + .OrderBy(c => c.SeriesIndex) + .ToList(); + UpdateSeries(parent); } - public SeriesEntry(SeriesBook seriesBook, LibraryBook child) : this(seriesBook) + + public SeriesEntry(LibraryBook parent, LibraryBook child) : this(parent) { Children = new() { new LibraryBookEntry(child) { Parent = this } }; - SetSeriesBook(seriesBook); + UpdateSeries(parent); } - private void SetSeriesBook(SeriesBook seriesBook) + public void UpdateSeries(LibraryBook parent) { - SeriesBook = seriesBook; - LoadCover(); + LibraryBook = parent; - // Immutable properties - { - Title = SeriesBook.Series.Name; - Series = SeriesBook.Series.Name; - PurchaseDate = Children.Min(c => c.LibraryBook.DateAdded).ToString("d"); - Authors = Book.AuthorNames(); - Narrators = Book.NarratorNames(); - Category = string.Join(" > ", Book.CategoriesNames()); - } + Title = Book.Title; + Series = Book.SeriesNames(); + MyRating = Book.UserDefinedItem.Rating?.ToStarString()?.DefaultIfNullOrWhiteSpace(""); + PurchaseDate = Children.Min(c => c.LibraryBook.DateAdded).ToString("d"); + ProductRating = Book.Rating?.ToStarString()?.DefaultIfNullOrWhiteSpace(""); + Authors = Book.AuthorNames(); + Narrators = Book.NarratorNames(); + Category = string.Join(" > ", Book.CategoriesNames()); + Misc = GetMiscDisplay(LibraryBook); + LongDescription = GetDescriptionDisplay(Book); + Description = TrimTextToWord(LongDescription, 62); + + int bookLenMins = Children.Sum(c => c.LibraryBook.Book.LengthInMinutes); + Length = bookLenMins == 0 ? "" : $"{bookLenMins / 60} hr {bookLenMins % 60} min"; + + NotifyPropertyChanged(); } + #region Data Sorting /// Create getters for all member object values by name protected override Dictionary> CreateMemberValueDictionary() => new() { - { nameof(Title), () => Book.SeriesSortable() }, + { nameof(Title), () => Book.TitleSortable() }, { nameof(Series), () => Book.SeriesSortable() }, { nameof(Length), () => Children.Sum(c => c.LibraryBook.Book.LengthInMinutes) }, - { nameof(MyRating), () => Children.Average(c => c.LibraryBook.Book.UserDefinedItem.Rating.FirstScore()) }, + { nameof(MyRating), () => Book.UserDefinedItem.Rating.FirstScore() }, { nameof(PurchaseDate), () => Children.Min(c => c.LibraryBook.DateAdded) }, - { nameof(ProductRating), () => Children.Average(c => c.LibraryBook.Book.Rating.FirstScore()) }, - { nameof(Authors), () => string.Empty }, - { nameof(Narrators), () => string.Empty }, - { nameof(Description), () => string.Empty }, - { nameof(Category), () => string.Empty }, - { nameof(Misc), () => string.Empty }, + { nameof(ProductRating), () => Book.Rating.FirstScore() }, + { nameof(Authors), () => Authors }, + { nameof(Narrators), () => Narrators }, + { nameof(Description), () => Description }, + { nameof(Category), () => Category }, + { nameof(Misc), () => Misc }, { nameof(DisplayTags), () => string.Empty }, { nameof(Liberate), () => Liberate }, { nameof(DateAdded), () => DateAdded }, }; + + #endregion } } diff --git a/Source/LibationWinForms/ProcessQueue/ProcessQueueControl.cs b/Source/LibationWinForms/ProcessQueue/ProcessQueueControl.cs index d6b9d322..f698e29b 100644 --- a/Source/LibationWinForms/ProcessQueue/ProcessQueueControl.cs +++ b/Source/LibationWinForms/ProcessQueue/ProcessQueueControl.cs @@ -77,73 +77,86 @@ namespace LibationWinForms.ProcessQueue CompletedCount = 0; } + private bool isBookInQueue(DataLayer.LibraryBook libraryBook) + => Queue.Any(b => b?.LibraryBook?.Book?.AudibleProductId == libraryBook.Book.AudibleProductId); + + public void AddDownloadPdf(DataLayer.LibraryBook libraryBook) + => AddDownloadPdf(new List() { libraryBook }); + + public void AddDownloadDecrypt(DataLayer.LibraryBook libraryBook) + => AddDownloadDecrypt(new List() { libraryBook }); + + public void AddConvertMp3(DataLayer.LibraryBook libraryBook) + => AddConvertMp3(new List() { libraryBook }); + public void AddDownloadPdf(IEnumerable entries) { + List procs = new(); foreach (var entry in entries) - AddDownloadPdf(entry); + { + if (isBookInQueue(entry)) + continue; + + ProcessBook pbook = new(entry, Logger); + pbook.PropertyChanged += Pbook_DataAvailable; + pbook.AddDownloadPdf(); + procs.Add(pbook); + } + + AddToQueue(procs); } public void AddDownloadDecrypt(IEnumerable entries) { + List procs = new(); foreach (var entry in entries) - AddDownloadDecrypt(entry); + { + if (isBookInQueue(entry)) + continue; + + ProcessBook pbook = new(entry, Logger); + pbook.PropertyChanged += Pbook_DataAvailable; + pbook.AddDownloadDecryptBook(); + pbook.AddDownloadPdf(); + procs.Add(pbook); + } + + AddToQueue(procs); } public void AddConvertMp3(IEnumerable entries) { + List procs = new(); foreach (var entry in entries) - AddConvertMp3(entry); + { + if (isBookInQueue(entry)) + continue; + + ProcessBook pbook = new(entry, Logger); + pbook.PropertyChanged += Pbook_DataAvailable; + pbook.AddConvertToMp3(); + procs.Add(pbook); + } + + AddToQueue(procs); } - public void AddDownloadPdf(DataLayer.LibraryBook libraryBook) - { - if (Queue.Any(b => b?.LibraryBook?.Book?.AudibleProductId == libraryBook.Book.AudibleProductId)) - return; - - ProcessBook pbook = new(libraryBook, Logger); - pbook.PropertyChanged += Pbook_DataAvailable; - pbook.AddDownloadPdf(); - AddToQueue(pbook); - } - - public void AddDownloadDecrypt(DataLayer.LibraryBook libraryBook) - { - if (Queue.Any(b => b?.LibraryBook?.Book?.AudibleProductId == libraryBook.Book.AudibleProductId)) - return; - - ProcessBook pbook = new(libraryBook, Logger); - pbook.PropertyChanged += Pbook_DataAvailable; - pbook.AddDownloadDecryptBook(); - pbook.AddDownloadPdf(); - AddToQueue(pbook); - } - - public void AddConvertMp3(DataLayer.LibraryBook libraryBook) - { - if (Queue.Any(b => b?.LibraryBook?.Book?.AudibleProductId == libraryBook.Book.AudibleProductId)) - return; - - ProcessBook pbook = new(libraryBook, Logger); - pbook.PropertyChanged += Pbook_DataAvailable; - pbook.AddConvertToMp3(); - AddToQueue(pbook); - } - - private void AddToQueue(ProcessBook pbook) + private void AddToQueue(IEnumerable pbook) { syncContext.Post(_ => { Queue.Enqueue(pbook); if (!Running) QueueRunner = QueueLoop(); - }, - null); + }, + null); } - DateTime StartintTime; + + DateTime StartingTime; private async Task QueueLoop() { - StartintTime = DateTime.Now; + StartingTime = DateTime.Now; counterTimer.Start(); while (Queue.MoveNext()) @@ -225,7 +238,7 @@ namespace LibationWinForms.ProcessQueue } if (Running) - runningTimeLbl.Text = timeToStr(DateTime.Now - StartintTime); + runningTimeLbl.Text = timeToStr(DateTime.Now - StartingTime); } private void clearLogBtn_Click(object sender, EventArgs e) diff --git a/Source/LibationWinForms/ProcessQueue/TrackedQueue[T].cs b/Source/LibationWinForms/ProcessQueue/TrackedQueue[T].cs index 20bbe7e8..80991f34 100644 --- a/Source/LibationWinForms/ProcessQueue/TrackedQueue[T].cs +++ b/Source/LibationWinForms/ProcessQueue/TrackedQueue[T].cs @@ -85,13 +85,18 @@ namespace LibationWinForms.ProcessQueue public bool RemoveQueued(T item) { + bool itemsRemoved; + int queuedCount; + lock (lockObject) { - bool removed = _queued.Remove(item); - if (removed) - QueuededCountChanged?.Invoke(this, _queued.Count); - return removed; + itemsRemoved = _queued.Remove(item); + queuedCount = _queued.Count; } + + if (itemsRemoved) + QueuededCountChanged?.Invoke(this, queuedCount); + return itemsRemoved; } public void ClearCurrent() @@ -102,31 +107,32 @@ namespace LibationWinForms.ProcessQueue public bool RemoveCompleted(T item) { + bool itemsRemoved; + int completedCount; + lock (lockObject) { - bool removed = _completed.Remove(item); - if (removed) - CompletedCountChanged?.Invoke(this, _completed.Count); - return removed; + itemsRemoved = _completed.Remove(item); + completedCount = _completed.Count; } + + if (itemsRemoved) + CompletedCountChanged?.Invoke(this, completedCount); + return itemsRemoved; } public void ClearQueue() { lock (lockObject) - { _queued.Clear(); - QueuededCountChanged?.Invoke(this, 0); - } + QueuededCountChanged?.Invoke(this, 0); } public void ClearCompleted() { lock (lockObject) - { _completed.Clear(); - CompletedCountChanged?.Invoke(this, 0); - } + CompletedCountChanged?.Invoke(this, 0); } public bool Any(Func predicate) @@ -175,23 +181,35 @@ namespace LibationWinForms.ProcessQueue public bool MoveNext() { - lock (lockObject) + int completedCount = 0, queuedCount = 0; + bool completedChanged = false; + try { - if (Current != null) + lock (lockObject) { - _completed.Add(Current); - CompletedCountChanged?.Invoke(this, _completed.Count); - } - if (_queued.Count == 0) - { - Current = null; - return false; - } - Current = _queued[0]; - _queued.RemoveAt(0); + if (Current != null) + { + _completed.Add(Current); + completedCount = _completed.Count; + completedChanged = true; + } + if (_queued.Count == 0) + { + Current = null; + return false; + } + Current = _queued[0]; + _queued.RemoveAt(0); - QueuededCountChanged?.Invoke(this, _queued.Count); - return true; + queuedCount = _queued.Count; + return true; + } + } + finally + { + if (completedChanged) + CompletedCountChanged?.Invoke(this, completedCount); + QueuededCountChanged?.Invoke(this, queuedCount); } } @@ -218,13 +236,15 @@ namespace LibationWinForms.ProcessQueue } } - public void Enqueue(T item) + public void Enqueue(IEnumerable item) { + int queueCount; lock (lockObject) { - _queued.Add(item); - QueuededCountChanged?.Invoke(this, _queued.Count); + _queued.AddRange(item); + queueCount = _queued.Count; } + QueuededCountChanged?.Invoke(this, queueCount); } } }