Files
Libation/Source/DtoImporterService/BookImporter.cs
T
Cursor Agentandrmcrackan bc72aad30c refactor(metadata): flatten Audible's HTML at the API boundary, not on read
Parsing HTML belongs before the data reaches the data layer, not in it. Move the
flattening to AudibleUtilities, where the rest of the Item translation lives, so
Book.Description holds plain text and DataLayer no longer references
HtmlAgilityPack.

Import now updates a description as well as creating one, which it never did
before: books imported while descriptions were stored as raw HTML are still
holding markup, and a re-scan is what clears it. A scan that reports no summary
leaves the stored one alone.

This also cleans up the CSV/Excel export, which was emitting raw HTML too.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-26 13:15:02 +00:00

300 lines
10 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.PlainTextDescription(),
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);
}
syncSupplement(item, book);
return book;
}
/// <summary>
/// Brings a book's stored description into line with what this scan says about it.
/// <para>
/// The description used to be stored exactly as Audible sends it, which is HTML. It is flattened on the
/// way in now, so every book imported before that change is still holding markup and needs rewriting -
/// hence updating a description at all, which import never used to do.
/// </para>
/// <para>
/// A scan that reports no description leaves the stored one alone. Episodes are imported from the
/// catalog, and "no summary" there can just as easily mean the response group was not asked for.
/// </para>
/// </summary>
internal static void syncDescription(Item item, Book book)
=> book.UpdateDescription(item.PlainTextDescription());
/// <summary>
/// Brings a book's supplement into line with what this scan says about it.
/// <para>
/// Only new books used to get this far, so a title that gained a PDF after it was first imported never got
/// one, and a title that lost its PDF went on claiming one - and, until issue #1973, went on being asked
/// for on every run.
/// </para>
/// <para>
/// A supplement is dropped only when Audible says outright that no supplement url is available. A missing
/// url says nothing on its own: episodes are imported from the catalog, which is never asked for
/// <c>pdf_url</c>, so there "no url" means "not asked" rather than "none exists". A PDF already downloaded
/// is left alone either way, because the file is on disk and the library should go on saying so.
/// </para>
/// </summary>
internal static void syncSupplement(Item item, Book book)
{
if (item.PdfUrl is not null)
book.SetSupplementDownloadUrl(item.PdfUrl.ToString());
else if (item.IsPdfUrlAvailable is false
&& book.UserDefinedItem.PdfStatus is not LiberatedStatus.Liberated
&& book.RemoveSupplements())
{
Serilog.Log.Logger.Information(
"Audible no longer offers a supplement for {audibleProductId}, so Libation has stopped expecting one.",
book.AudibleProductId);
}
}
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);
syncDescription(item, book);
// 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.
// 2026-08-26
// updateBook must update copyright on books which were imported before the migration which added copyright.
book.UpdateBookDetails(item.IsAbridged, item.AssetDetails?.Any(a => a.IsSpatial), item.DatePublished, item.Language, item.CopyrightString());
syncSupplement(item, book);
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();
}