Fix books not being marked absent on large imports

This commit is contained in:
Mbucari
2026-01-11 15:40:11 -07:00
parent 7d38874257
commit 94cf665be7
3 changed files with 139 additions and 121 deletions

View File

@@ -18,6 +18,14 @@ namespace DtoImporterService
private SeriesImporter seriesImporter { get; }
private CategoryImporter categoryImporter { get; }
/// <summary>
/// Indicates whether <see cref="BookImporter"/> loaded every Book from the <seealso cref="LibationContext"/> during import.
/// If true, the DbContext was queried for all Books, rather than just those being imported.
/// If means that all <see cref="LibraryBook"/> objects in the DbContext will have their <see cref="LibraryBook.Book"/> property populated.
/// If false, only those Books being imported were loaded, and some <see cref="LibraryBook"/> objects will have a null <see cref="LibraryBook.Book"/> property for books not included in the import set.
/// </summary>
internal bool LoadedEntireLibrary {get; private set; }
public BookImporter(LibationContext context) : base(context)
{
contributorImporter = new ContributorImporter(DbContext);
@@ -56,6 +64,7 @@ namespace DtoImporterService
.ToArray()
.Where(b => productIds.Contains(b.AudibleProductId))
.ToDictionarySafe(b => b.AudibleProductId);
LoadedEntireLibrary = true;
}
else
{

View File

@@ -6,126 +6,135 @@ using DataLayer;
using Dinah.Core;
using Dinah.Core.Collections.Generic;
namespace DtoImporterService
namespace DtoImporterService;
public class LibraryBookImporter : ItemsImporterBase
{
public class LibraryBookImporter : ItemsImporterBase
protected override IValidator Validator => new LibraryValidator();
private BookImporter bookImporter { get; }
public LibraryBookImporter(LibationContext context) : base(context)
{
protected override IValidator Validator => new LibraryValidator();
private BookImporter bookImporter { get; }
public LibraryBookImporter(LibationContext context) : base(context)
{
bookImporter = new BookImporter(DbContext);
}
protected override int DoImport(IEnumerable<ImportItem> importItems)
{
bookImporter.Import(importItems);
var qtyNew = upsertLibraryBooks(importItems);
return qtyNew;
}
private int upsertLibraryBooks(IEnumerable<ImportItem> importItems)
{
// technically, we should be able to have duplicate books from separate accounts.
// this would violate the current pk and would be difficult to deal with elsewhere:
// - what to show in the grid
// - which to consider liberated
//
// sqlite cannot alter pk. the work around is an extensive headache
// - update: now possible in .net5/efcore5
//
// currently, inserting LibraryBook will throw error if the same book is in multiple accounts for the same region.
//
// CURRENT SOLUTION: don't re-insert
//When Books are upserted during the BookImporter run, they are linked to their LibraryBook in the DbContext
//instance. If a LibraryBook has a null book here, that means it's Book was not imported during by BookImporter.
//There should never be duplicates, but this is defensive.
var existingEntries = DbContext.LibraryBooks.AsEnumerable().Where(l => l.Book is not null).ToDictionarySafe(l => l.Book.AudibleProductId);
//If importItems are contains duplicates by asin, keep the Item that's "available"
var uniqueImportItems = ToDictionarySafe(importItems, dto => dto.DtoItem.ProductId, tieBreak);
int qtyNew = 0;
foreach (var item in uniqueImportItems.Values)
{
if (existingEntries.TryGetValue(item.DtoItem.ProductId, out LibraryBook existing))
{
if (existing.Account != item.AccountId)
{
//Book is absent from the existing LibraryBook's account. Use the alternate account.
existing.SetAccount(item.AccountId);
}
existing.AbsentFromLastScan = isUnavailable(item);
}
else
{
existing = new LibraryBook(
bookImporter.Cache[item.DtoItem.ProductId],
item.DtoItem.DateAdded,
item.AccountId)
{
AbsentFromLastScan = isUnavailable(item)
};
try
{
DbContext.LibraryBooks.Add(existing);
qtyNew++;
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Error adding library book. {@DebugInfo}", new { existing.Book, existing.Account });
}
}
existing.SetIncludedUntil(item.DtoItem.GetExpirationDate());
existing.SetIsAudiblePlus(item.DtoItem.IsAyce is true);
}
var scannedAccounts = importItems.Select(i => i.AccountId).Distinct().ToList();
//If an existing Book wasn't found in the import, the owning LibraryBook's Book will be null.
//Only change AbsentFromLastScan for LibraryBooks of accounts that were scanned.
foreach (var nullBook in DbContext.LibraryBooks.AsEnumerable().Where(lb => lb.Book is null && lb.Account.In(scannedAccounts)))
nullBook.AbsentFromLastScan = true;
return qtyNew;
}
private static Dictionary<TKey, TSource> ToDictionarySafe<TKey, TSource>(IEnumerable<TSource> source, Func<TSource, TKey> keySelector, Func<TSource, TSource, TSource> tieBreaker)
{
var dictionary = new Dictionary<TKey, TSource>();
foreach (TSource newItem in source)
{
TKey key = keySelector(newItem);
dictionary[key]
= dictionary.TryGetValue(key, out TSource existingItem)
? tieBreaker(existingItem, newItem)
: newItem;
}
return dictionary;
}
private static ImportItem tieBreak(ImportItem item1, ImportItem item2)
=> isUnavailable(item1) && !isUnavailable(item2) ? item2 : item1;
private static bool isUnavailable(ImportItem item)
=> isFutureRelease(item) || isPlusTitleUnavailable(item);
private static bool isFutureRelease(ImportItem item)
=> item.DtoItem.IssueDate is DateTimeOffset dt && dt > DateTimeOffset.UtcNow;
private static bool isPlusTitleUnavailable(ImportItem item)
=> item.DtoItem.ContentType is null
|| (item.DtoItem.IsAyce is true && item.DtoItem.Plans?.Any(p => p.IsAyce) is not true);
bookImporter = new BookImporter(DbContext);
}
protected override int DoImport(IEnumerable<ImportItem> importItems)
{
bookImporter.Import(importItems);
var qtyNew = upsertLibraryBooks(importItems);
return qtyNew;
}
private int upsertLibraryBooks(IEnumerable<ImportItem> importItems)
{
// technically, we should be able to have duplicate books from separate accounts.
// this would violate the current pk and would be difficult to deal with elsewhere:
// - what to show in the grid
// - which to consider liberated
//
// sqlite cannot alter pk. the work around is an extensive headache
// - update: now possible in .net5/efcore5
//
// currently, inserting LibraryBook will throw error if the same book is in multiple accounts for the same region.
//
// CURRENT SOLUTION: don't re-insert
//When Books are upserted during the BookImporter run, they are linked to their LibraryBook in the DbContext
//instance. If a LibraryBook has a null book here, that means it's Book was not imported during by BookImporter.
//There should never be duplicates, but this is defensive.
var existingEntries = DbContext.LibraryBooks.AsEnumerable().Where(l => l.Book is not null).ToDictionarySafe(l => l.Book.AudibleProductId);
//If importItems are contains duplicates by asin, keep the Item that's "available"
var uniqueImportItems = ToDictionarySafe(importItems, dto => dto.DtoItem.ProductId, tieBreak);
int qtyNew = 0;
foreach (var item in uniqueImportItems.Values)
{
if (existingEntries.TryGetValue(item.DtoItem.ProductId, out LibraryBook existing))
{
if (existing.Account != item.AccountId)
{
//Book is absent from the existing LibraryBook's account. Use the alternate account.
existing.SetAccount(item.AccountId);
}
existing.AbsentFromLastScan = isUnavailable(item);
}
else
{
existing = new LibraryBook(
bookImporter.Cache[item.DtoItem.ProductId],
item.DtoItem.DateAdded,
item.AccountId)
{
AbsentFromLastScan = isUnavailable(item)
};
try
{
DbContext.LibraryBooks.Add(existing);
qtyNew++;
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Error adding library book. {@DebugInfo}", new { existing.Book, existing.Account });
}
}
existing.SetIncludedUntil(item.DtoItem.GetExpirationDate());
existing.SetIsAudiblePlus(item.DtoItem.IsAyce is true);
}
var scannedAccounts = importItems.Select(i => i.AccountId).Distinct().ToHashSet();
var allInScannedAccounts = DbContext.LibraryBooks.Where(lb => scannedAccounts.Contains(lb.Account)).ToArray();
if (bookImporter.LoadedEntireLibrary)
{
//If the entire library was loaded, we can be sure that all existing LibraryBooks have their Book property populated.
//Find LibraryBooks which have a Book but weren't found in the import, and mark them as absent.
foreach (var absentBook in allInScannedAccounts.Where(lb => !uniqueImportItems.ContainsKey(lb.Book.AudibleProductId)))
absentBook.AbsentFromLastScan = true;
}
else
{
//If an existing Book wasn't found in the import, the owning LibraryBook's Book will be null.
//Only change AbsentFromLastScan for LibraryBooks of accounts that were scanned.
foreach (var nullBook in allInScannedAccounts.Where(lb => lb.Book is null && lb.Account.In(scannedAccounts)))
nullBook.AbsentFromLastScan = true;
}
return qtyNew;
}
private static Dictionary<TKey, TSource> ToDictionarySafe<TKey, TSource>(IEnumerable<TSource> source, Func<TSource, TKey> keySelector, Func<TSource, TSource, TSource> tieBreaker)
{
var dictionary = new Dictionary<TKey, TSource>();
foreach (TSource newItem in source)
{
TKey key = keySelector(newItem);
dictionary[key]
= dictionary.TryGetValue(key, out TSource existingItem)
? tieBreaker(existingItem, newItem)
: newItem;
}
return dictionary;
}
private static ImportItem tieBreak(ImportItem item1, ImportItem item2)
=> isUnavailable(item1) && !isUnavailable(item2) ? item2 : item1;
private static bool isUnavailable(ImportItem item)
=> isFutureRelease(item) || isPlusTitleUnavailable(item);
private static bool isFutureRelease(ImportItem item)
=> item.DtoItem.IssueDate is DateTimeOffset dt && dt > DateTimeOffset.UtcNow;
private static bool isPlusTitleUnavailable(ImportItem item)
=> item.DtoItem.ContentType is null
|| (item.DtoItem.IsAyce is true && item.DtoItem.Plans?.Any(p => p.IsAyce) is not true);
}

View File

@@ -47,8 +47,8 @@ namespace LibationUiBase.GridView
public bool IsBook => !IsSeries && !IsEpisode;
public bool IsUnavailable
=> !IsSeries
& isAbsent
& (
&& isAbsent
&& (
BookStatus is not LiberatedStatus.Liberated
|| PdfStatus is not null and not LiberatedStatus.Liberated
);