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);
}
}
}