Merge pull request #268 from Mbucari/master

Address issues in 263
This commit is contained in:
rmcrackan 2022-06-08 14:46:17 -04:00 committed by GitHub
commit cdb6c9a1a4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 604 additions and 426 deletions

View File

@ -59,6 +59,7 @@ namespace AppScaffolding
// //
Migrations.migrate_to_v6_6_9(config); Migrations.migrate_to_v6_6_9(config);
Migrations.migrate_from_7_10_1(config);
} }
public static void PopulateMissingConfigValues(Configuration config) public static void PopulateMissingConfigValues(Configuration config)
@ -401,5 +402,28 @@ namespace AppScaffolding
UNSAFE_MigrationHelper.Settings_AddUniqueToArray("Serilog.Enrich", "WithExceptionDetails"); 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<bool>(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);
}
} }
} }

View File

@ -12,10 +12,10 @@ namespace ApplicationServices
=> LibationContext.Create(SqliteStorage.ConnectionString); => LibationContext.Create(SqliteStorage.ConnectionString);
/// <summary>Use for full library querying. No lazy loading</summary> /// <summary>Use for full library querying. No lazy loading</summary>
public static List<LibraryBook> GetLibrary_Flat_NoTracking() public static List<LibraryBook> GetLibrary_Flat_NoTracking(bool includeParents = false)
{ {
using var context = GetContext(); using var context = GetContext();
return context.GetLibrary_Flat_NoTracking(); return context.GetLibrary_Flat_NoTracking(includeParents);
} }
} }
} }

View File

@ -200,7 +200,7 @@ namespace ApplicationServices
var libraryBookImporter = new LibraryBookImporter(context); var libraryBookImporter = new LibraryBookImporter(context);
var newCount = await Task.Run(() => libraryBookImporter.Import(importItems)); var newCount = await Task.Run(() => libraryBookImporter.Import(importItems));
logTime("importIntoDbAsync -- post Import()"); logTime("importIntoDbAsync -- post Import()");
int qtyChanges = saveChanges(context); int qtyChanges = SaveContext(context);
logTime("importIntoDbAsync -- post SaveChanges"); logTime("importIntoDbAsync -- post SaveChanges");
// this is any changes at all to the database, not just new books // this is any changes at all to the database, not just new books
@ -211,7 +211,7 @@ namespace ApplicationServices
return newCount; return newCount;
} }
private static int saveChanges(LibationContext context) public static int SaveContext(LibationContext context)
{ {
try try
{ {

View File

@ -29,7 +29,7 @@ namespace ApplicationServices
} }
#endregion #endregion
public static EventHandler SearchEngineUpdated; public static event EventHandler SearchEngineUpdated;
#region Update #region Update
private static bool isUpdating; private static bool isUpdating;

View File

@ -1,11 +1,14 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using AudibleApi; using AudibleApi;
using AudibleApi.Common; using AudibleApi.Common;
using Dinah.Core; using Dinah.Core;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using Polly; using Polly;
using Polly.Retry; using Polly.Retry;
@ -129,7 +132,7 @@ namespace AudibleUtilities
await foreach (var item in Api.GetLibraryItemAsyncEnumerable(libraryOptions)) 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 //Get child episodes asynchronously and await all at the end
getChildEpisodesTasks.Add(getChildEpisodesAsync(concurrencySemaphore, item)); getChildEpisodesTasks.Add(getChildEpisodesAsync(concurrencySemaphore, item));
@ -173,17 +176,66 @@ namespace AudibleUtilities
{ {
Serilog.Log.Logger.Debug("Beginning episode scan for {parent}", parent); Serilog.Log.Logger.Debug("Beginning episode scan for {parent}", parent);
var children = await getEpisodeChildrenAsync(parent); List<Item> children;
if (!children.Any()) if (parent.IsEpisodes)
{ {
//The parent is the only episode in the podcase series, //The 'parent' is a single episode that was added to the library.
//so the parent is its own child. //Get the episode's parent and add it to the database.
parent.Series = new Series[] { new Series { Asin = parent.Asin, Sequence = RelationshipToProduct.Parent, Title = parent.TitleWithSubtitle } };
children.Add(parent); Serilog.Log.Logger.Debug("Supplied Parent is an episode. Beginning parent scan for {parent}", parent);
return children;
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) foreach (var child in children)
{ {
// use parent's 'DateAdded'. DateAdded is just a convenience prop for: PurchaseDate.UtcDateTime // use parent's 'DateAdded'. DateAdded is just a convenience prop for: PurchaseDate.UtcDateTime
@ -199,17 +251,10 @@ namespace AudibleUtilities
Title = parent.TitleWithSubtitle 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); Serilog.Log.Logger.Debug("Completed episode scan for {parent}", parent);
return children; return children;

View File

@ -5,7 +5,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="AudibleApi" Version="3.0.2.1" /> <PackageReference Include="AudibleApi" Version="3.1.0.1" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@ -16,8 +16,14 @@ namespace DataLayer
} }
} }
// enum will be easier than bool to extend later // enum will be easier than bool to extend later.
public enum ContentType { Unknown = 0, Product = 1, Episode = 2 } public enum ContentType
{
Unknown = 0,
Product = 1,
Episode = 2,
Parent = 4,
}
public class Book public class Book
{ {

View File

@ -35,5 +35,15 @@ namespace DataLayer
.Include(b => b.SeriesLink).ThenInclude(sb => sb.Series) .Include(b => b.SeriesLink).ThenInclude(sb => sb.Series)
.Include(b => b.ContributorsLink).ThenInclude(c => c.Contributor) .Include(b => b.ContributorsLink).ThenInclude(c => c.Contributor)
.Include(b => b.Category).ThenInclude(c => c.ParentCategory); .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;
} }
} }

View File

@ -15,11 +15,13 @@ namespace DataLayer
// .GetLibrary() // .GetLibrary()
// .ToList(); // .ToList();
public static List<LibraryBook> GetLibrary_Flat_NoTracking(this LibationContext context) public static List<LibraryBook> GetLibrary_Flat_NoTracking(this LibationContext context, bool includeParents = false)
=> context => context
.LibraryBooks .LibraryBooks
.AsNoTrackingWithIdentityResolution() .AsNoTrackingWithIdentityResolution()
.GetLibrary() .GetLibrary()
.AsEnumerable()
.Where(lb => !lb.Book.IsEpisodeParent() || includeParents)
.ToList(); .ToList();
public static LibraryBook GetLibraryBook_Flat_NoTracking(this LibationContext context, string productId) 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.SeriesLink).ThenInclude(sb => sb.Series)
.Include(le => le.Book).ThenInclude(b => b.ContributorsLink).ThenInclude(c => c.Contributor) .Include(le => le.Book).ThenInclude(b => b.ContributorsLink).ThenInclude(c => c.Contributor)
.Include(le => le.Book).ThenInclude(b => b.Category).ThenInclude(c => c.ParentCategory); .Include(le => le.Book).ThenInclude(b => b.Category).ThenInclude(c => c.ParentCategory);
#nullable enable
public static LibraryBook? FindSeriesParent(this IEnumerable<LibraryBook> 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<LibraryBook> FindChildren(this IEnumerable<LibraryBook> bookList, LibraryBook parent)
=> bookList
.Where(
lb =>
lb.Book.IsEpisodeChild() &&
lb.Book.SeriesLink?
.Any(
s =>
s.Series.AudibleSeriesId == parent.Book.AudibleProductId
) == true
).ToList();
} }
} }

View File

@ -75,7 +75,7 @@ namespace DtoImporterService
{ {
var item = importItem.DtoItem; 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 // absence of authors is very rare, but possible
if (!item.Authors?.Any() ?? true) 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;
}
} }
} }

View File

@ -29,7 +29,7 @@ namespace FileLiberator
public IEnumerable<LibraryBook> GetValidLibraryBooks(IEnumerable<LibraryBook> library) public IEnumerable<LibraryBook> GetValidLibraryBooks(IEnumerable<LibraryBook> library)
=> library.Where(libraryBook => => library.Where(libraryBook =>
Validate(libraryBook) Validate(libraryBook)
&& (libraryBook.Book.ContentType != ContentType.Episode || LibationFileManager.Configuration.Instance.DownloadEpisodes) && (!libraryBook.Book.IsEpisodeChild() || Configuration.Instance.DownloadEpisodes)
); );
public async Task<StatusHandler> ProcessSingleAsync(LibraryBook libraryBook, bool validate) public async Task<StatusHandler> ProcessSingleAsync(LibraryBook libraryBook, bool validate)

View File

@ -121,12 +121,12 @@ namespace LibationSearchEngine
["Liberated"] = lb => isLiberated(lb.Book), ["Liberated"] = lb => isLiberated(lb.Book),
["LiberatedError"] = lb => liberatedError(lb.Book), ["LiberatedError"] = lb => liberatedError(lb.Book),
["Podcast"] = lb => lb.Book.ContentType == ContentType.Episode, ["Podcast"] = lb => lb.Book.IsEpisodeChild(),
["Podcasts"] = lb => lb.Book.ContentType == ContentType.Episode, ["Podcasts"] = lb => lb.Book.IsEpisodeChild(),
["IsPodcast"] = lb => lb.Book.ContentType == ContentType.Episode, ["IsPodcast"] = lb => lb.Book.IsEpisodeChild(),
["Episode"] = lb => lb.Book.ContentType == ContentType.Episode, ["Episode"] = lb => lb.Book.IsEpisodeChild(),
["Episodes"] = lb => lb.Book.ContentType == ContentType.Episode, ["Episodes"] = lb => lb.Book.IsEpisodeChild(),
["IsEpisode"] = lb => lb.Book.ContentType == ContentType.Episode, ["IsEpisode"] = lb => lb.Book.IsEpisodeChild(),
} }
); );

View File

@ -1,21 +1,30 @@
using DataLayer; using DataLayer;
using Dinah.Core;
using Dinah.Core.DataBinding; using Dinah.Core.DataBinding;
using Dinah.Core.Drawing; using Dinah.Core.Drawing;
using LibationFileManager; using LibationFileManager;
using System; using System;
using System.Collections; using System.Collections;
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel;
using System.Drawing; using System.Drawing;
using System.Linq; using System.Linq;
namespace LibationWinForms.GridView namespace LibationWinForms.GridView
{ {
/// <summary>The View Model base for the DataGridView</summary>
public abstract class GridEntry : AsyncNotifyPropertyChanged, IMemberComparable 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 #region Model properties exposed to the view
public abstract LiberateButtonStatus Liberate { get; }
public Image Cover public Image Cover
{ {
get => _cover; get => _cover;
@ -25,59 +34,31 @@ namespace LibationWinForms.GridView
NotifyPropertyChanged(); NotifyPropertyChanged();
} }
} }
public new bool InvokeRequired => base.InvokeRequired; public string PurchaseDate { get; protected set; }
public abstract DateTime DateAdded { get; } public string Series { get; protected set; }
public abstract float SeriesIndex { get; } public string Title { get; protected set; }
public abstract string ProductRating { get; protected set; } public string Length { get; protected set; }
public abstract string PurchaseDate { get; protected set; } public string Authors { get; protected set; }
public abstract string MyRating { get; protected set; } public string Narrators { get; protected set; }
public abstract string Series { get; protected set; } public string Category { get; protected set; }
public abstract string Title { get; protected set; } public string Misc { get; protected set; }
public abstract string Length { get; protected set; } public string Description { get; protected set; }
public abstract string Authors { get; protected set; } public string ProductRating { get; protected set; }
public abstract string Narrators { get; protected set; } public string MyRating { 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 abstract string DisplayTags { get; } public abstract string DisplayTags { get; }
public abstract LiberateButtonStatus Liberate { get; }
#endregion #endregion
#region Sorting #region Sorting
public GridEntry() => _memberValues = CreateMemberValueDictionary(); public GridEntry() => _memberValues = CreateMemberValueDictionary();
private Dictionary<string, Func<object>> _memberValues { get; set; }
protected abstract Dictionary<string, Func<object>> CreateMemberValueDictionary();
// These methods are implementation of Dinah.Core.DataBinding.IMemberComparable // These methods are implementation of Dinah.Core.DataBinding.IMemberComparable
// Used by GridEntryBindingList for all sorting // Used by GridEntryBindingList for all sorting
public virtual object GetMemberValue(string memberName) => _memberValues[memberName](); public virtual object GetMemberValue(string memberName) => _memberValues[memberName]();
public IComparer GetMemberComparer(Type memberType) => _memberTypeComparers[memberType]; public IComparer GetMemberComparer(Type memberType) => _memberTypeComparers[memberType];
protected abstract Dictionary<string, Func<object>> CreateMemberValueDictionary();
#endregion private Dictionary<string, Func<object>> _memberValues { get; set; }
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;
}
}
// Instantiate comparers for every exposed member object type. // Instantiate comparers for every exposed member object type.
private static readonly Dictionary<Type, IComparer> _memberTypeComparers = new() private static readonly Dictionary<Type, IComparer> _memberTypeComparers = new()
@ -90,25 +71,87 @@ namespace LibationWinForms.GridView
{ typeof(LiberateButtonStatus), new ObjectComparer<LiberateButtonStatus>() }, { typeof(LiberateButtonStatus), new ObjectComparer<LiberateButtonStatus>() },
}; };
#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
/// <summary>This information should not change during <see cref="GridEntry"/> lifetime, so call only once.</summary>
protected static string GetDescriptionDisplay(Book book)
{
var doc = new HtmlAgilityPack.HtmlDocument();
doc.LoadHtml(book?.Description?.Replace("</p> ", "\r\n\r\n</p>") ?? "");
return doc.DocumentNode.InnerText.Trim();
}
protected static string TrimTextToWord(string text, int maxLength)
{
return
text.Length <= maxLength ?
text :
text.Substring(0, maxLength - 3) + "...";
}
/// <summary>
/// This information should not change during <see cref="GridEntry"/> lifetime, so call only once.
/// Maximum of 5 text rows will fit in 80-pixel row height.
/// </summary>
protected static string GetMiscDisplay(LibraryBook libraryBook)
{
var details = new List<string>();
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() ~GridEntry()
{ {
PictureStorage.PictureCached -= PictureStorage_PictureCached; PictureStorage.PictureCached -= PictureStorage_PictureCached;
} }
} }
internal static class GridEntryExtensions
{
#nullable enable
public static IEnumerable<SeriesEntry> Series(this IEnumerable<GridEntry> gridEntries)
=> gridEntries.OfType<SeriesEntry>();
public static IEnumerable<LibraryBookEntry> LibraryBooks(this IEnumerable<GridEntry> gridEntries)
=> gridEntries.OfType<LibraryBookEntry>();
public static LibraryBookEntry? FindBookByAsin(this IEnumerable<LibraryBookEntry> gridEntries, string audibleProductID)
=> gridEntries.FirstOrDefault(i => i.AudibleProductId == audibleProductID);
public static SeriesEntry? FindBookSeriesEntry(this IEnumerable<GridEntry> gridEntries, IEnumerable<SeriesBook> matchSeries)
=> gridEntries.Series().FirstOrDefault(i => matchSeries.Any(s => s.Series.Name == i.Series));
public static IEnumerable<SeriesEntry> EmptySeries(this IEnumerable<GridEntry> gridEntries)
=> gridEntries.Series().Where(i => i.Children.Count == 0);
public static bool IsEpisodeChild(this LibraryBook lb) => lb.Book.ContentType == ContentType.Episode;
}
} }

View File

@ -73,10 +73,10 @@ namespace LibationWinForms.GridView
FilterString = filterString; FilterString = filterString;
SearchResults = SearchEngineCommands.Search(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 //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(); var filteredOut = Items.Except(booksFilteredIn.Concat(seriesFilteredIn)).ToList();
@ -89,19 +89,19 @@ namespace LibationWinForms.GridView
public void CollapseAll() public void CollapseAll()
{ {
foreach (var series in Items.Series().ToList()) foreach (var series in Items.SeriesEntries().ToList())
CollapseItem(series); CollapseItem(series);
} }
public void ExpandAll() public void ExpandAll()
{ {
foreach (var series in Items.Series().ToList()) foreach (var series in Items.SeriesEntries().ToList())
ExpandItem(series); ExpandItem(series);
} }
public void CollapseItem(SeriesEntry sEntry) 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); FilterRemoved.Add(episode);
base.Remove(episode); base.Remove(episode);
@ -114,7 +114,7 @@ namespace LibationWinForms.GridView
{ {
var sindex = Items.IndexOf(sEntry); 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)) if (SearchResults is null || SearchResults.Docs.Any(d => d.ProductId == episode.AudibleProductId))
{ {
@ -174,7 +174,7 @@ namespace LibationWinForms.GridView
{ {
var itemsList = (List<GridEntry>)Items; var itemsList = (List<GridEntry>)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(); var sortedItems = itemsList.Except(children).OrderBy(ge => ge, Comparer).ToList();

View File

@ -8,23 +8,11 @@ using System.Linq;
namespace LibationWinForms.GridView namespace LibationWinForms.GridView
{ {
/// <summary> /// <summary>The View Model for a LibraryBook that is ContentType.Product or ContentType.Episode</summary>
/// The View Model for a LibraryBook
/// </summary>
public class LibraryBookEntry : GridEntry public class LibraryBookEntry : GridEntry
{ {
#region implementation properties NOT exposed to the view [Browsable(false)] public override DateTime DateAdded => LibraryBook.DateAdded;
// hide from public fields from Data Source GUI with [Browsable(false)] [Browsable(false)] public SeriesEntry Parent { get; init; }
[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;
#region Model properties exposed to the view #region Model properties exposed to the view
@ -32,22 +20,6 @@ namespace LibationWinForms.GridView
private LiberatedStatus _bookStatus; private LiberatedStatus _bookStatus;
private LiberatedStatus? _pdfStatus; 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 public override LiberateButtonStatus Liberate
{ {
get get
@ -62,6 +34,8 @@ namespace LibationWinForms.GridView
return new LiberateButtonStatus { BookStatus = _bookStatus, PdfStatus = _pdfStatus, IsSeries = false }; return new LiberateButtonStatus { BookStatus = _bookStatus, PdfStatus = _pdfStatus, IsSeries = false };
} }
} }
public override string DisplayTags => string.Join("\r\n", Book.UserDefinedItem.TagsEnumerated);
#endregion #endregion
public LibraryBookEntry(LibraryBook libraryBook) public LibraryBookEntry(LibraryBook libraryBook)
@ -70,7 +44,6 @@ namespace LibationWinForms.GridView
LoadCover(); LoadCover();
} }
public SeriesEntry Parent { get; init; }
public void UpdateLibraryBook(LibraryBook libraryBook) public void UpdateLibraryBook(LibraryBook libraryBook)
{ {
if (AudibleProductId != libraryBook.Book.AudibleProductId) if (AudibleProductId != libraryBook.Book.AudibleProductId)
@ -86,8 +59,6 @@ namespace LibationWinForms.GridView
{ {
LibraryBook = libraryBook; LibraryBook = libraryBook;
// Immutable properties
{
Title = Book.Title; Title = Book.Title;
Series = Book.SeriesNames(); Series = Book.SeriesNames();
Length = Book.LengthInMinutes == 0 ? "" : $"{Book.LengthInMinutes / 60} hr {Book.LengthInMinutes % 60} min"; Length = Book.LengthInMinutes == 0 ? "" : $"{Book.LengthInMinutes / 60} hr {Book.LengthInMinutes % 60} min";
@ -100,12 +71,11 @@ namespace LibationWinForms.GridView
Misc = GetMiscDisplay(libraryBook); Misc = GetMiscDisplay(libraryBook);
LongDescription = GetDescriptionDisplay(Book); LongDescription = GetDescriptionDisplay(Book);
Description = TrimTextToWord(LongDescription, 62); Description = TrimTextToWord(LongDescription, 62);
} SeriesIndex = Book.SeriesLink.FirstOrDefault()?.Index ?? 0;
UserDefinedItem.ItemChanged += UserDefinedItem_ItemChanged; UserDefinedItem.ItemChanged += UserDefinedItem_ItemChanged;
} }
#region detect changes to the model, update the view, and save to database. #region detect changes to the model, update the view, and save to database.
/// <summary> /// <summary>
@ -169,58 +139,6 @@ namespace LibationWinForms.GridView
{ nameof(DateAdded), () => DateAdded }, { nameof(DateAdded), () => DateAdded },
}; };
#endregion
#region Static library display functions
/// <summary>
/// This information should not change during <see cref="LibraryBookEntry"/> lifetime, so call only once.
/// </summary>
private static string GetDescriptionDisplay(Book book)
{
var doc = new HtmlAgilityPack.HtmlDocument();
doc.LoadHtml(book?.Description?.Replace("</p> ", "\r\n\r\n</p>") ?? "");
return doc.DocumentNode.InnerText.Trim();
}
private static string TrimTextToWord(string text, int maxLength)
{
return
text.Length <= maxLength ?
text :
text.Substring(0, maxLength - 3) + "...";
}
/// <summary>
/// This information should not change during <see cref="LibraryBookEntry"/> lifetime, so call only once.
/// Maximum of 5 text rows will fit in 80-pixel row height.
/// </summary>
private static string GetMiscDisplay(LibraryBook libraryBook)
{
var details = new List<string>();
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 #endregion
~LibraryBookEntry() ~LibraryBookEntry()

View File

@ -39,10 +39,10 @@
this.productsGrid.Name = "productsGrid"; this.productsGrid.Name = "productsGrid";
this.productsGrid.Size = new System.Drawing.Size(1510, 380); this.productsGrid.Size = new System.Drawing.Size(1510, 380);
this.productsGrid.TabIndex = 0; this.productsGrid.TabIndex = 0;
this.productsGrid.LiberateClicked += new LibationWinForms.GridView.ProductsGrid.LibraryBookEntryClickedEventHandler(this.productsGrid_LiberateClicked); this.productsGrid.LiberateClicked += new LibationWinForms.GridView.LibraryBookEntryClickedEventHandler(this.productsGrid_LiberateClicked);
this.productsGrid.CoverClicked += new LibationWinForms.GridView.ProductsGrid.LibraryBookEntryClickedEventHandler(this.productsGrid_CoverClicked); this.productsGrid.CoverClicked += new LibationWinForms.GridView.GridEntryClickedEventHandler(this.productsGrid_CoverClicked);
this.productsGrid.DetailsClicked += new LibationWinForms.GridView.ProductsGrid.LibraryBookEntryClickedEventHandler(this.productsGrid_DetailsClicked); this.productsGrid.DetailsClicked += new LibationWinForms.GridView.LibraryBookEntryClickedEventHandler(this.productsGrid_DetailsClicked);
this.productsGrid.DescriptionClicked += new LibationWinForms.GridView.ProductsGrid.LibraryBookEntryRectangleClickedEventHandler(this.productsGrid_DescriptionClicked); this.productsGrid.DescriptionClicked += new LibationWinForms.GridView.GridEntryRectangleClickedEventHandler(this.productsGrid_DescriptionClicked);
this.productsGrid.VisibleCountChanged += new System.EventHandler<int>(this.productsGrid_VisibleCountChanged); this.productsGrid.VisibleCountChanged += new System.EventHandler<int>(this.productsGrid_VisibleCountChanged);
// //
// ProductsDisplay // ProductsDisplay

View File

@ -29,7 +29,7 @@ namespace LibationWinForms.GridView
#region Button controls #region Button controls
private ImageDisplay imageDisplay; 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 picDefinition = new PictureDefinition(liveGridEntry.LibraryBook.Book.PictureLarge ?? liveGridEntry.LibraryBook.Book.PictureId, PictureSize.Native);
var picDlTask = Task.Run(() => PictureStorage.GetPictureSynchronously(picDefinition)); var picDlTask = Task.Run(() => PictureStorage.GetPictureSynchronously(picDefinition));
@ -52,7 +52,7 @@ namespace LibationWinForms.GridView
imageDisplay.CoverPicture = await picDlTask; imageDisplay.CoverPicture = await picDlTask;
} }
private void productsGrid_DescriptionClicked(LibraryBookEntry liveGridEntry, Rectangle cellRectangle) private void productsGrid_DescriptionClicked(GridEntry liveGridEntry, Rectangle cellRectangle)
{ {
var displayWindow = new DescriptionDisplay var displayWindow = new DescriptionDisplay
{ {
@ -87,7 +87,7 @@ namespace LibationWinForms.GridView
try try
{ {
// don't return early if lib size == 0. this will not update correctly if all books are removed // 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) if (!hasBeenDisplayed)
{ {
@ -103,7 +103,6 @@ namespace LibationWinForms.GridView
{ {
Serilog.Log.Error(ex, "Error displaying library in {0}", nameof(ProductsDisplay)); Serilog.Log.Error(ex, "Error displaying library in {0}", nameof(ProductsDisplay));
} }
} }
#endregion #endregion
@ -115,7 +114,7 @@ namespace LibationWinForms.GridView
#endregion #endregion
internal List<LibraryBook> GetVisible() => productsGrid.GetVisible().Select(v => v.LibraryBook).ToList(); internal List<LibraryBook> GetVisible() => productsGrid.GetVisibleBooks().ToList();
private void productsGrid_VisibleCountChanged(object sender, int count) private void productsGrid_VisibleCountChanged(object sender, int count)
{ {

View File

@ -10,23 +10,25 @@ using System.Windows.Forms;
namespace LibationWinForms.GridView 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 partial class ProductsGrid : UserControl
{ {
public delegate void LibraryBookEntryClickedEventHandler(LibraryBookEntry liveGridEntry);
public delegate void LibraryBookEntryRectangleClickedEventHandler(LibraryBookEntry liveGridEntry, Rectangle cellRectangle);
/// <summary>Number of visible rows has changed</summary> /// <summary>Number of visible rows has changed</summary>
public event EventHandler<int> VisibleCountChanged; public event EventHandler<int> VisibleCountChanged;
public event LibraryBookEntryClickedEventHandler LiberateClicked; public event LibraryBookEntryClickedEventHandler LiberateClicked;
public event LibraryBookEntryClickedEventHandler CoverClicked; public event GridEntryClickedEventHandler CoverClicked;
public event LibraryBookEntryClickedEventHandler DetailsClicked; public event LibraryBookEntryClickedEventHandler DetailsClicked;
public event LibraryBookEntryRectangleClickedEventHandler DescriptionClicked; public event GridEntryRectangleClickedEventHandler DescriptionClicked;
public new event EventHandler<ScrollEventArgs> Scroll; public new event EventHandler<ScrollEventArgs> Scroll;
private GridEntryBindingList bindingList; private GridEntryBindingList bindingList;
internal IEnumerable<LibraryBookEntry> GetVisible() internal IEnumerable<LibraryBook> GetVisibleBooks()
=> bindingList => bindingList
.LibraryBooks(); .BookEntries()
.Select(lbe => lbe.LibraryBook);
public ProductsGrid() public ProductsGrid()
{ {
@ -61,7 +63,9 @@ namespace LibationWinForms.GridView
else if (e.ColumnIndex == coverGVColumn.Index) else if (e.ColumnIndex == coverGVColumn.Index)
CoverClicked?.Invoke(lbEntry); CoverClicked?.Invoke(lbEntry);
} }
else if (entry is SeriesEntry sEntry && e.ColumnIndex == liberateGVColumn.Index) else if (entry is SeriesEntry sEntry)
{
if (e.ColumnIndex == liberateGVColumn.Index)
{ {
if (sEntry.Liberate.Expanded) if (sEntry.Liberate.Expanded)
bindingList.CollapseItem(sEntry); bindingList.CollapseItem(sEntry);
@ -70,7 +74,12 @@ namespace LibationWinForms.GridView
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<LibraryBook> dbBooks) internal void BindToGrid(List<LibraryBook> dbBooks)
{ {
var geList = dbBooks.Where(b => b.Book.ContentType is not ContentType.Episode).Select(b => new LibraryBookEntry(b)).Cast<GridEntry>().ToList(); var geList = dbBooks.Where(lb => lb.Book.IsProduct()).Select(b => new LibraryBookEntry(b)).Cast<GridEntry>().ToList();
var episodes = dbBooks.Where(b => b.IsEpisodeChild()).ToList(); var episodes = dbBooks.Where(lb => lb.Book.IsEpisodeChild());
var allSeries = episodes.SelectMany(lb => lb.Book.SeriesLink.Where(s => !s.Series.AudibleSeriesId.StartsWith("SERIES_"))).DistinctBy(s => s.Series).ToList(); foreach (var parent in dbBooks.Where(lb => lb.Book.IsEpisodeParent()))
foreach (var series in allSeries)
{ {
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.Add(seriesEntry);
geList.AddRange(seriesEntry.Children); geList.AddRange(seriesEntry.Children);
@ -98,79 +110,47 @@ namespace LibationWinForms.GridView
bindingList = new GridEntryBindingList(geList.OrderByDescending(e => e.DateAdded)); bindingList = new GridEntryBindingList(geList.OrderByDescending(e => e.DateAdded));
bindingList.CollapseAll(); bindingList.CollapseAll();
syncBindingSource.DataSource = bindingList; syncBindingSource.DataSource = bindingList;
VisibleCountChanged?.Invoke(this, bindingList.LibraryBooks().Count()); VisibleCountChanged?.Invoke(this, bindingList.BookEntries().Count());
} }
internal void UpdateGrid(List<LibraryBook> dbBooks) internal void UpdateGrid(List<LibraryBook> dbBooks)
{ {
#region Add new or update existing grid entries
//Remove filter prior to adding/updating boooks
string existingFilter = syncBindingSource.Filter; string existingFilter = syncBindingSource.Filter;
Filter(null); Filter(null);
bindingList.SuspendFilteringOnUpdate = true; 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(); var allEntries = bindingList.AllItems().BookEntries();
foreach (var libraryBook in dbBooks) var seriesEntries = bindingList.AllItems().SeriesEntries().ToList();
{
var existingItem = allItmes.FindBookByAsin(libraryBook.Book.AudibleProductId);
// add new to top foreach (var libraryBook in dbBooks.OrderBy(e => e.DateAdded))
if (existingItem is null)
{ {
if (libraryBook.IsEpisodeChild()) var existingEntry = allEntries.FindByAsin(libraryBook.Book.AudibleProductId);
{
LibraryBookEntry lbe;
//Find the series that libraryBook belongs to, if it exists
var series = bindingList.AllItems().FindBookSeriesEntry(libraryBook.Book.SeriesLink);
if (series is null) if (libraryBook.Book.IsProduct())
{ AddOrUpdateBook(libraryBook, existingEntry);
//Series doesn't exist yet, so create and add it else if(libraryBook.Book.IsEpisodeChild())
var newSeries = new SeriesEntry(libraryBook.Book.SeriesLink.First(), libraryBook); AddOrUpdateEpisode(libraryBook, existingEntry, seriesEntries, dbBooks);
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);
}
} }
bindingList.SuspendFilteringOnUpdate = false; 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); Filter(existingFilter);
#endregion
// remove deleted from grid. // remove deleted from grid.
// note: actual deletion from db must still occur via the RemoveBook feature. deleting from audible will not trigger this // note: actual deletion from db must still occur via the RemoveBook feature. deleting from audible will not trigger this
var removedBooks = var removedBooks =
bindingList bindingList
.AllItems() .AllItems()
.LibraryBooks() .BookEntries()
.ExceptBy(dbBooks.Select(lb => lb.Book.AudibleProductId), ge => ge.AudibleProductId); .ExceptBy(dbBooks.Select(lb => lb.Book.AudibleProductId), ge => ge.AudibleProductId);
//Remove books in series from their parents' Children list //Remove books in series from their parents' Children list
@ -190,7 +170,70 @@ namespace LibationWinForms.GridView
//no need to re-filter for removed books //no need to re-filter for removed books
bindingList.Remove(removed); 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<SeriesEntry> seriesEntries, IEnumerable<LibraryBook> 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 #endregion
@ -207,7 +250,7 @@ namespace LibationWinForms.GridView
syncBindingSource.Filter = searchString; syncBindingSource.Filter = searchString;
if (visibleCount != bindingList.Count) if (visibleCount != bindingList.Count)
VisibleCountChanged?.Invoke(this, bindingList.LibraryBooks().Count()); VisibleCountChanged?.Invoke(this, bindingList.BookEntries().Count());
} }

View File

@ -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<LibraryBookEntry> BookEntries(this IEnumerable<GridEntry> gridEntries)
=> gridEntries.OfType<LibraryBookEntry>();
public static IEnumerable<SeriesEntry> SeriesEntries(this IEnumerable<GridEntry> gridEntries)
=> gridEntries.OfType<SeriesEntry>();
public static T? FindByAsin<T>(this IEnumerable<T> gridEntries, string audibleProductID) where T : GridEntry
=> gridEntries.FirstOrDefault(i => i.AudibleProductId == audibleProductID);
public static IEnumerable<SeriesEntry> EmptySeries(this IEnumerable<GridEntry> gridEntries)
=> gridEntries.SeriesEntries().Where(i => i.Children.Count == 0);
public static SeriesEntry? FindSeriesParent(this IEnumerable<GridEntry> 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
}

View File

@ -2,108 +2,90 @@
using Dinah.Core; using Dinah.Core;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel;
using System.Linq; using System.Linq;
namespace LibationWinForms.GridView namespace LibationWinForms.GridView
{ {
/// <summary>The View Model for a LibraryBook that is ContentType.Parent</summary>
public class SeriesEntry : GridEntry public class SeriesEntry : GridEntry
{ {
public List<LibraryBookEntry> Children { get; init; } [Browsable(false)] public List<LibraryBookEntry> Children { get; }
public override DateTime DateAdded => Children.Max(c => c.DateAdded); [Browsable(false)] public override DateTime DateAdded => Children.Max(c => c.DateAdded);
public override float SeriesIndex { get; }
public override string ProductRating #region Model properties exposed to the view
{
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;
public override LiberateButtonStatus Liberate { get; } 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(LibraryBook parent)
private SeriesEntry(SeriesBook seriesBook)
{ {
Liberate = new LiberateButtonStatus { IsSeries = true }; Liberate = new LiberateButtonStatus { IsSeries = true };
SeriesIndex = seriesBook.Index; SeriesIndex = -1;
LibraryBook = parent;
LoadCover();
} }
public SeriesEntry(SeriesBook seriesBook, IEnumerable<LibraryBook> children) : this(seriesBook)
public SeriesEntry(LibraryBook parent, IEnumerable<LibraryBook> children) : this(parent)
{ {
Children = children.Select(c => new LibraryBookEntry(c) { Parent = this }).OrderBy(c => c.SeriesIndex).ToList(); Children = children
SetSeriesBook(seriesBook); .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 } }; Children = new() { new LibraryBookEntry(child) { Parent = this } };
SetSeriesBook(seriesBook); UpdateSeries(parent);
} }
private void SetSeriesBook(SeriesBook seriesBook) public void UpdateSeries(LibraryBook parent)
{ {
SeriesBook = seriesBook; LibraryBook = parent;
LoadCover();
// Immutable properties Title = Book.Title;
{ Series = Book.SeriesNames();
Title = SeriesBook.Series.Name; MyRating = Book.UserDefinedItem.Rating?.ToStarString()?.DefaultIfNullOrWhiteSpace("");
Series = SeriesBook.Series.Name;
PurchaseDate = Children.Min(c => c.LibraryBook.DateAdded).ToString("d"); PurchaseDate = Children.Min(c => c.LibraryBook.DateAdded).ToString("d");
ProductRating = Book.Rating?.ToStarString()?.DefaultIfNullOrWhiteSpace("");
Authors = Book.AuthorNames(); Authors = Book.AuthorNames();
Narrators = Book.NarratorNames(); Narrators = Book.NarratorNames();
Category = string.Join(" > ", Book.CategoriesNames()); 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
/// <summary>Create getters for all member object values by name</summary> /// <summary>Create getters for all member object values by name</summary>
protected override Dictionary<string, Func<object>> CreateMemberValueDictionary() => new() protected override Dictionary<string, Func<object>> CreateMemberValueDictionary() => new()
{ {
{ nameof(Title), () => Book.SeriesSortable() }, { nameof(Title), () => Book.TitleSortable() },
{ nameof(Series), () => Book.SeriesSortable() }, { nameof(Series), () => Book.SeriesSortable() },
{ nameof(Length), () => Children.Sum(c => c.LibraryBook.Book.LengthInMinutes) }, { 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(PurchaseDate), () => Children.Min(c => c.LibraryBook.DateAdded) },
{ nameof(ProductRating), () => Children.Average(c => c.LibraryBook.Book.Rating.FirstScore()) }, { nameof(ProductRating), () => Book.Rating.FirstScore() },
{ nameof(Authors), () => string.Empty }, { nameof(Authors), () => Authors },
{ nameof(Narrators), () => string.Empty }, { nameof(Narrators), () => Narrators },
{ nameof(Description), () => string.Empty }, { nameof(Description), () => Description },
{ nameof(Category), () => string.Empty }, { nameof(Category), () => Category },
{ nameof(Misc), () => string.Empty }, { nameof(Misc), () => Misc },
{ nameof(DisplayTags), () => string.Empty }, { nameof(DisplayTags), () => string.Empty },
{ nameof(Liberate), () => Liberate }, { nameof(Liberate), () => Liberate },
{ nameof(DateAdded), () => DateAdded }, { nameof(DateAdded), () => DateAdded },
}; };
#endregion
} }
} }

View File

@ -77,59 +77,71 @@ namespace LibationWinForms.ProcessQueue
CompletedCount = 0; 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<DataLayer.LibraryBook>() { libraryBook });
public void AddDownloadDecrypt(DataLayer.LibraryBook libraryBook)
=> AddDownloadDecrypt(new List<DataLayer.LibraryBook>() { libraryBook });
public void AddConvertMp3(DataLayer.LibraryBook libraryBook)
=> AddConvertMp3(new List<DataLayer.LibraryBook>() { libraryBook });
public void AddDownloadPdf(IEnumerable<DataLayer.LibraryBook> entries) public void AddDownloadPdf(IEnumerable<DataLayer.LibraryBook> entries)
{ {
List<ProcessBook> procs = new();
foreach (var entry in entries) 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<DataLayer.LibraryBook> entries) public void AddDownloadDecrypt(IEnumerable<DataLayer.LibraryBook> entries)
{ {
List<ProcessBook> procs = new();
foreach (var entry in entries) 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<DataLayer.LibraryBook> entries) public void AddConvertMp3(IEnumerable<DataLayer.LibraryBook> entries)
{ {
List<ProcessBook> procs = new();
foreach (var entry in entries) foreach (var entry in entries)
AddConvertMp3(entry);
}
public void AddDownloadPdf(DataLayer.LibraryBook libraryBook)
{ {
if (Queue.Any(b => b?.LibraryBook?.Book?.AudibleProductId == libraryBook.Book.AudibleProductId)) if (isBookInQueue(entry))
return; continue;
ProcessBook pbook = new(libraryBook, Logger); ProcessBook pbook = new(entry, 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.PropertyChanged += Pbook_DataAvailable;
pbook.AddConvertToMp3(); pbook.AddConvertToMp3();
AddToQueue(pbook); procs.Add(pbook);
} }
private void AddToQueue(ProcessBook pbook) AddToQueue(procs);
}
private void AddToQueue(IEnumerable<ProcessBook> pbook)
{ {
syncContext.Post(_ => syncContext.Post(_ =>
{ {
@ -140,10 +152,11 @@ namespace LibationWinForms.ProcessQueue
null); null);
} }
DateTime StartintTime;
DateTime StartingTime;
private async Task QueueLoop() private async Task QueueLoop()
{ {
StartintTime = DateTime.Now; StartingTime = DateTime.Now;
counterTimer.Start(); counterTimer.Start();
while (Queue.MoveNext()) while (Queue.MoveNext())
@ -225,7 +238,7 @@ namespace LibationWinForms.ProcessQueue
} }
if (Running) if (Running)
runningTimeLbl.Text = timeToStr(DateTime.Now - StartintTime); runningTimeLbl.Text = timeToStr(DateTime.Now - StartingTime);
} }
private void clearLogBtn_Click(object sender, EventArgs e) private void clearLogBtn_Click(object sender, EventArgs e)

View File

@ -85,13 +85,18 @@ namespace LibationWinForms.ProcessQueue
public bool RemoveQueued(T item) public bool RemoveQueued(T item)
{ {
bool itemsRemoved;
int queuedCount;
lock (lockObject) lock (lockObject)
{ {
bool removed = _queued.Remove(item); itemsRemoved = _queued.Remove(item);
if (removed) queuedCount = _queued.Count;
QueuededCountChanged?.Invoke(this, _queued.Count);
return removed;
} }
if (itemsRemoved)
QueuededCountChanged?.Invoke(this, queuedCount);
return itemsRemoved;
} }
public void ClearCurrent() public void ClearCurrent()
@ -102,32 +107,33 @@ namespace LibationWinForms.ProcessQueue
public bool RemoveCompleted(T item) public bool RemoveCompleted(T item)
{ {
bool itemsRemoved;
int completedCount;
lock (lockObject) lock (lockObject)
{ {
bool removed = _completed.Remove(item); itemsRemoved = _completed.Remove(item);
if (removed) completedCount = _completed.Count;
CompletedCountChanged?.Invoke(this, _completed.Count);
return removed;
} }
if (itemsRemoved)
CompletedCountChanged?.Invoke(this, completedCount);
return itemsRemoved;
} }
public void ClearQueue() public void ClearQueue()
{ {
lock (lockObject) lock (lockObject)
{
_queued.Clear(); _queued.Clear();
QueuededCountChanged?.Invoke(this, 0); QueuededCountChanged?.Invoke(this, 0);
} }
}
public void ClearCompleted() public void ClearCompleted()
{ {
lock (lockObject) lock (lockObject)
{
_completed.Clear(); _completed.Clear();
CompletedCountChanged?.Invoke(this, 0); CompletedCountChanged?.Invoke(this, 0);
} }
}
public bool Any(Func<T, bool> predicate) public bool Any(Func<T, bool> predicate)
{ {
@ -174,13 +180,18 @@ namespace LibationWinForms.ProcessQueue
} }
public bool MoveNext() public bool MoveNext()
{
int completedCount = 0, queuedCount = 0;
bool completedChanged = false;
try
{ {
lock (lockObject) lock (lockObject)
{ {
if (Current != null) if (Current != null)
{ {
_completed.Add(Current); _completed.Add(Current);
CompletedCountChanged?.Invoke(this, _completed.Count); completedCount = _completed.Count;
completedChanged = true;
} }
if (_queued.Count == 0) if (_queued.Count == 0)
{ {
@ -190,10 +201,17 @@ namespace LibationWinForms.ProcessQueue
Current = _queued[0]; Current = _queued[0];
_queued.RemoveAt(0); _queued.RemoveAt(0);
QueuededCountChanged?.Invoke(this, _queued.Count); queuedCount = _queued.Count;
return true; return true;
} }
} }
finally
{
if (completedChanged)
CompletedCountChanged?.Invoke(this, completedCount);
QueuededCountChanged?.Invoke(this, queuedCount);
}
}
public bool TryPeek(out T item) public bool TryPeek(out T item)
{ {
@ -218,13 +236,15 @@ namespace LibationWinForms.ProcessQueue
} }
} }
public void Enqueue(T item) public void Enqueue(IEnumerable<T> item)
{ {
int queueCount;
lock (lockObject) lock (lockObject)
{ {
_queued.Add(item); _queued.AddRange(item);
QueuededCountChanged?.Invoke(this, _queued.Count); queueCount = _queued.Count;
} }
QueuededCountChanged?.Invoke(this, queueCount);
} }
} }
} }