Files
Libation/Source/DtoImporterService/BookImporter.cs
Michael Bucari-Tovo 3ab1edc076 Code Cleanup
Make fields readonly
Remove unnecessary casts
Format document
Remove unnecessary usings
Sort usings
Use file-level namespaces
Order modifiers
2026-02-05 12:48:44 -07:00

252 lines
7.8 KiB
C#

using AudibleApi.Common;
using AudibleUtilities;
using DataLayer;
using Dinah.Core.Collections.Generic;
using System;
using System.Collections.Generic;
using System.Linq;
namespace DtoImporterService;
public class BookImporter : ItemsImporterBase
{
protected override IValidator Validator => new BookValidator();
public Dictionary<string, Book> Cache { get; private set; } = new();
private ContributorImporter contributorImporter { get; }
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);
seriesImporter = new SeriesImporter(DbContext);
categoryImporter = new CategoryImporter(DbContext);
}
protected override int DoImport(IEnumerable<ImportItem> importItems)
{
// pre-req.s
contributorImporter.Import(importItems);
seriesImporter.Import(importItems);
categoryImporter.Import(importItems);
// load db existing => hash table
loadLocal_books(importItems);
// upsert
var qtyNew = upsertBooks(importItems);
return qtyNew;
}
private void loadLocal_books(IEnumerable<ImportItem> importItems)
{
// get distinct
var productIds = importItems
.Select(i => i.DtoItem.ProductId)
.Distinct()
.ToHashSet();
if (productIds.Count > 100)
{
//For large imports, it is faster to get the whole library and filter in memory.
Cache = DbContext.Books
.GetBooks()
.ToArray()
.Where(b => productIds.Contains(b.AudibleProductId))
.ToDictionarySafe(b => b.AudibleProductId);
LoadedEntireLibrary = true;
}
else
{
Cache = DbContext.Books
.GetBooks(b => productIds.Contains(b.AudibleProductId))
.ToDictionarySafe(b => b.AudibleProductId);
}
}
private int upsertBooks(IEnumerable<ImportItem> importItems)
{
var qtyNew = 0;
foreach (var item in importItems)
{
if (item.DtoItem.ProductId is null)
continue;
if (!Cache.TryGetValue(item.DtoItem.ProductId, out var book))
{
book = createNewBook(item);
qtyNew++;
}
updateBook(item, book);
}
return qtyNew;
}
private Book createNewBook(ImportItem importItem)
{
var item = importItem.DtoItem;
var contentType = GetContentType(item);
// absence of authors is very rare, but possible
if (item.Authors?.Length is null or 0)
item.Authors = [new Person { Name = "", Asin = null }];
// nested logic is required so order of names is retained. else, contributors may appear in the order they were inserted into the db
var authors = ContributorsFromCache(item.Authors);
var narrators
= item.Narrators?.Length is null or 0
// if no narrators listed, author is the narrator
? authors
// nested logic is required so order of names is retained. else, contributors may appear in the order they were inserted into the db
: ContributorsFromCache(item.Narrators);
Book book;
try
{
if (item.ProductId is null)
throw new ArgumentNullException(nameof(item.ProductId), "ProductId is null when trying to create new Book.");
book = DbContext.Books.Add(new Book(
new AudibleProductId(item.ProductId),
item.Title,
item.Subtitle,
item.Description,
item.LengthInMinutes,
contentType,
authors,
narrators,
importItem.LocaleName
)
).Entity;
Cache.Add(book.AudibleProductId, book);
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Error adding book. {@DebugInfo}", new
{
item.ProductId,
item.TitleWithSubtitle,
item.Description,
item.LengthInMinutes,
contentType,
QtyAuthors = authors?.Length,
QtyNarrators = narrators?.Length,
importItem.LocaleName
});
throw;
}
var publisherName = item.Publisher;
if (!string.IsNullOrWhiteSpace(publisherName))
{
var publisher = contributorImporter.Cache[publisherName];
book.ReplacePublisher(publisher);
}
if (item.PdfUrl is not null)
book.AddSupplementDownloadUrl(item.PdfUrl.ToString());
return book;
}
private void updateBook(ImportItem importItem, Book book)
{
var item = importItem.DtoItem;
// Replacing narrators only became necessary to correct a bug introduced in 13.1.0
// which would no import narrators with null ASINs. Thus, affected books had the
// author listed as the narrators. This can probably be removed in the future.
// Bug went live in 13.1.0 on 2026/01/02. Today is 2026/01/08.
if (ContributorsFromCache(item.Narrators) is { } narrators && narrators.Length > 0)
book.ReplaceNarrators(narrators);
book.UpdateLengthInMinutes(item.LengthInMinutes);
// Update the book titles, since formatting can change
book.UpdateTitle(item.Title, item.Subtitle);
// set/update book-specific info which may have changed
if (item.PictureId is not null)
book.PictureId = item.PictureId;
if (item.PictureLarge is not null)
book.PictureLarge = item.PictureLarge;
if (item.IsFinished is not null)
book.UserDefinedItem.IsFinished = item.IsFinished.Value;
// 2023-02-01
// updateBook must update language on books which were imported before the migration which added language.
// 2025-07-30
// updateBook must update isSpatial on books which were imported before the migration which added isSpatial.
book.UpdateBookDetails(item.IsAbridged, item.AssetDetails?.Any(a => a.IsSpatial), item.DatePublished, item.Language);
book.UpdateProductRating(
(float)(item.Rating?.OverallDistribution?.AverageRating ?? 0),
(float)(item.Rating?.PerformanceDistribution?.AverageRating ?? 0),
(float)(item.Rating?.StoryDistribution?.AverageRating ?? 0));
// important to update user-specific info. this will have changed if user has rated/reviewed the book since last library import
book.UserDefinedItem.UpdateRating(item.MyUserRating_Overall, item.MyUserRating_Performance, item.MyUserRating_Story);
// update series even for existing books. these are occasionally updated
// these will upsert over library-scraped series, but will not leave orphans
if (item.Series is not null)
{
foreach (var seriesEntry in item.Series)
{
if (string.IsNullOrEmpty(seriesEntry.SeriesId))
continue;
var series = seriesImporter.Cache[seriesEntry.SeriesId];
book.UpsertSeries(series, seriesEntry.Sequence);
}
}
if (item.CategoryLadders is not null)
{
var ladders = new List<DataLayer.CategoryLadder>();
foreach (var ladder in item.CategoryLadders.Select(cl => cl?.Ladder).Where(l => l?.Length > 0))
{
var categoryIds = ladder?.Select(l => l?.CategoryId).ToList();
ladders.Add(categoryImporter.LadderCache.Single(c => c.Equals(categoryIds)));
}
//Set all ladders at once so ladders that have been
//removed by audible can be removed from the DB
book.SetCategoryLadders(ladders);
}
}
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;
}
[return: System.Diagnostics.CodeAnalysis.NotNullIfNotNull(nameof(toLoad))]
private Contributor[]? ContributorsFromCache(IEnumerable<Person>? toLoad)
=> toLoad
?.Select(a => a.Name)
.OfType<string>()
.Distinct()
.Select(name => contributorImporter.Cache[name])
.ToArray();
}