mirror of
https://github.com/rmcrackan/Libation.git
synced 2026-09-12 21:57:19 -04:00
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>
310 lines
10 KiB
C#
310 lines
10 KiB
C#
using Dinah.Core;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Collections.ObjectModel;
|
|
using System.Linq;
|
|
|
|
namespace DataLayer;
|
|
|
|
public class AudibleProductId
|
|
{
|
|
public string Id { get; }
|
|
public AudibleProductId(string id)
|
|
{
|
|
ArgumentValidator.EnsureNotNullOrWhiteSpace(id, nameof(id));
|
|
Id = id;
|
|
}
|
|
}
|
|
|
|
// enum will be easier than bool to extend later.
|
|
public enum ContentType
|
|
{
|
|
Unknown = 0,
|
|
Product = 1,
|
|
Episode = 2,
|
|
Parent = 4,
|
|
}
|
|
|
|
|
|
public class Book
|
|
{
|
|
// implementation detail. set by db only. only used by data layer
|
|
internal int BookId { get; private set; }
|
|
|
|
// immutable
|
|
public string AudibleProductId { get; private set; }
|
|
public string Title { get; private set; }
|
|
public string Subtitle { get; private set; }
|
|
private string? _titleWithSubtitle;
|
|
public string TitleWithSubtitle => _titleWithSubtitle ??= string.IsNullOrEmpty(Subtitle) ? Title : $"{Title}: {Subtitle}";
|
|
public string Description { get; private set; }
|
|
public int LengthInMinutes { get; private set; }
|
|
public ContentType ContentType { get; private set; }
|
|
public string Locale { get; private set; }
|
|
|
|
// mutable
|
|
public string? PictureId { get; set; }
|
|
public string? PictureLarge { get; set; }
|
|
|
|
// book details
|
|
public bool IsAbridged { get; private set; }
|
|
public bool IsSpatial { get; private set; }
|
|
public DateTime? DatePublished { get; private set; }
|
|
public string? Language { get; private set; }
|
|
/// <summary>The publisher's copyright line. Only returned by the API's product_details response
|
|
/// group, and null for plenty of titles even then.</summary>
|
|
public string? Copyright { get; private set; }
|
|
|
|
// is owned, not optional 1:1
|
|
public UserDefinedItem UserDefinedItem { get; private set; }
|
|
|
|
// is owned, not optional 1:1
|
|
/// <summary>The product's aggregate community rating</summary>
|
|
public Rating Rating { get; private set; } = new Rating(0, 0, 0);
|
|
|
|
// ef-ctor
|
|
#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable.
|
|
private Book() { }
|
|
#pragma warning restore CS8618
|
|
// non-ef ctor
|
|
/// <param name="audibleProductId">special id class b/c it's too easy to get string order mixed up</param>
|
|
public Book(
|
|
AudibleProductId audibleProductId,
|
|
string? title,
|
|
string? subtitle,
|
|
string? description,
|
|
int lengthInMinutes,
|
|
ContentType contentType,
|
|
IEnumerable<Contributor> authors,
|
|
IEnumerable<Contributor> narrators,
|
|
string localeName
|
|
)
|
|
{
|
|
// validate
|
|
ArgumentValidator.EnsureNotNull(audibleProductId, nameof(audibleProductId));
|
|
var productId = audibleProductId.Id;
|
|
ArgumentValidator.EnsureNotNullOrWhiteSpace(productId, nameof(productId));
|
|
|
|
// assign as soon as possible. stuff below relies on this
|
|
AudibleProductId = productId;
|
|
Locale = localeName;
|
|
|
|
ArgumentValidator.EnsureNotNullOrWhiteSpace(title, nameof(title));
|
|
|
|
// non-ef-ctor init.s
|
|
UserDefinedItem = new UserDefinedItem(this);
|
|
ContributorsLink = new HashSet<BookContributor>();
|
|
CategoriesLink = new HashSet<BookCategory>();
|
|
_seriesLink = new HashSet<SeriesBook>();
|
|
_supplements = new HashSet<Supplement>();
|
|
|
|
// simple assigns
|
|
|
|
Title = title?.Trim() ?? "";
|
|
Subtitle = subtitle?.Trim() ?? "";
|
|
Description = description?.Trim() ?? "";
|
|
LengthInMinutes = lengthInMinutes;
|
|
ContentType = contentType;
|
|
|
|
// assigns with biz logic
|
|
ReplaceAuthors(authors);
|
|
ReplaceNarrators(narrators);
|
|
}
|
|
|
|
public void UpdateTitle(string? title, string? subtitle)
|
|
{
|
|
Title = title?.Trim() ?? "";
|
|
Subtitle = subtitle?.Trim() ?? "";
|
|
_titleWithSubtitle = null;
|
|
}
|
|
|
|
public void UpdateLengthInMinutes(int lengthInMinutes)
|
|
=> LengthInMinutes = lengthInMinutes;
|
|
|
|
public void UpdateDescription(string? description)
|
|
{
|
|
// don't overwrite with default values
|
|
if (!string.IsNullOrWhiteSpace(description))
|
|
Description = description.Trim();
|
|
}
|
|
|
|
#region contributors, authors, narrators
|
|
internal HashSet<BookContributor> ContributorsLink { get; private set; }
|
|
|
|
public IEnumerable<Contributor> Authors => ContributorsLink.ByRole(Role.Author).Select(bc => bc.Contributor).ToList();
|
|
public IEnumerable<Contributor> Narrators => ContributorsLink.ByRole(Role.Narrator).Select(bc => bc.Contributor).ToList();
|
|
public string? Publisher => ContributorsLink.ByRole(Role.Publisher).SingleOrDefault()?.Contributor.Name;
|
|
|
|
public void ReplaceAuthors(IEnumerable<Contributor> authors, DbContext? context = null)
|
|
=> replaceContributors(authors, Role.Author, context);
|
|
public void ReplaceNarrators(IEnumerable<Contributor> narrators, DbContext? context = null)
|
|
=> replaceContributors(narrators, Role.Narrator, context);
|
|
public void ReplacePublisher(Contributor publisher, DbContext? context = null)
|
|
=> replaceContributors(new List<Contributor> { publisher }, Role.Publisher, context);
|
|
private void replaceContributors(IEnumerable<Contributor> newContributors, Role role, DbContext? context = null)
|
|
{
|
|
ArgumentValidator.EnsureEnumerableNotNullOrEmpty(newContributors, nameof(newContributors));
|
|
|
|
// the edge cases of doing local-loaded vs remote-only got weird. just load it
|
|
if (ContributorsLink is null)
|
|
{
|
|
if (context is null)
|
|
throw new ArgumentNullException(nameof(context), "A DbContext is required to load the ContributorsLink collection");
|
|
getEntry(context).Collection(s => s.ContributorsLink).Load();
|
|
}
|
|
|
|
var isIdentical
|
|
= ContributorsLink
|
|
!.ByRole(role)
|
|
.Select(c => c.Contributor)
|
|
.SequenceEqual(newContributors);
|
|
|
|
if (isIdentical)
|
|
return;
|
|
|
|
ContributorsLink!.RemoveWhere(bc => bc.Role == role);
|
|
addNewContributors(newContributors, role);
|
|
}
|
|
|
|
private void addNewContributors(IEnumerable<Contributor> newContributors, Role role)
|
|
{
|
|
byte order = 0;
|
|
var newContributionsEnum = newContributors.Select(c => new BookContributor(this, c, role, order++));
|
|
var newContributions = new HashSet<BookContributor>(newContributionsEnum);
|
|
ContributorsLink.UnionWith(newContributions);
|
|
}
|
|
|
|
#endregion
|
|
|
|
private Microsoft.EntityFrameworkCore.ChangeTracking.EntityEntry<Book> getEntry(DbContext context)
|
|
{
|
|
ArgumentValidator.EnsureNotNull(context, nameof(context));
|
|
|
|
var entry = context.Entry(this);
|
|
|
|
if (!entry.IsKeySet)
|
|
throw new InvalidOperationException("Could not load a valid Book from database");
|
|
|
|
return entry;
|
|
}
|
|
|
|
#region categories
|
|
internal HashSet<BookCategory> CategoriesLink { get; private set; }
|
|
|
|
private ReadOnlyCollection<BookCategory>? _categoriesReadOnly;
|
|
public ReadOnlyCollection<BookCategory> Categories
|
|
{
|
|
get
|
|
{
|
|
if (_categoriesReadOnly?.SequenceEqual(CategoriesLink) is not true)
|
|
_categoriesReadOnly = CategoriesLink.ToList().AsReadOnly();
|
|
return _categoriesReadOnly;
|
|
}
|
|
}
|
|
public void SetCategoryLadders(IEnumerable<CategoryLadder> ladders)
|
|
{
|
|
ArgumentValidator.EnsureNotNull(ladders, nameof(ladders));
|
|
|
|
//Replace all existing category ladders.
|
|
//Some books make have duplicate ladders
|
|
CategoriesLink.Clear();
|
|
CategoriesLink.UnionWith(ladders.Distinct().Select(l => new BookCategory(this, l)));
|
|
}
|
|
#endregion
|
|
|
|
#region series
|
|
private readonly HashSet<SeriesBook>? _seriesLink;
|
|
public IEnumerable<SeriesBook> SeriesLink => _seriesLink?.ToList() ?? [];
|
|
|
|
public void UpsertSeries(Series series, string? order, DbContext? context = null)
|
|
{
|
|
ArgumentValidator.EnsureNotNull(series, nameof(series));
|
|
|
|
// our add() is conditional upon what's already included in the collection.
|
|
// therefore if not loaded, a trip is required. might as well just load it
|
|
if (_seriesLink is null)
|
|
{
|
|
if (context is null)
|
|
throw new ArgumentNullException(nameof(context), "A DbContext is required to load the SeriesLink collection");
|
|
getEntry(context).Collection(s => s.SeriesLink).Load();
|
|
}
|
|
|
|
var singleSeriesBook = _seriesLink!.SingleOrDefault(sb => sb.Series == series);
|
|
if (singleSeriesBook is null)
|
|
_seriesLink!.Add(new SeriesBook(series, this, order));
|
|
else
|
|
singleSeriesBook.UpdateOrder(order);
|
|
}
|
|
#endregion
|
|
|
|
#region supplements
|
|
private readonly HashSet<Supplement>? _supplements;
|
|
public IEnumerable<Supplement> Supplements => _supplements?.ToList() ?? [];
|
|
|
|
/// <summary>
|
|
/// Records where Audible says this book's supplement can be found, replacing anything recorded before.
|
|
/// <para>
|
|
/// Audible reports one supplement per title, so this replaces rather than accumulates. It used to add, and
|
|
/// guarded against duplicates by comparing the incoming url to itself - which came to the same thing only
|
|
/// because the always-true comparison meant "this book already has a supplement", so a url that changed was
|
|
/// silently ignored.
|
|
/// </para>
|
|
/// </summary>
|
|
public void SetSupplementDownloadUrl(string url)
|
|
{
|
|
// supplements are owned by Book, so no need to Load():
|
|
// Are automatically loaded, and can only be tracked by a DbContext alongside their owner.
|
|
|
|
ArgumentValidator.EnsureNotNullOrWhiteSpace(url, nameof(url));
|
|
|
|
if (_supplements is null)
|
|
return;
|
|
|
|
if (_supplements.Count == 1 && _supplements.First().Url.EqualsInsensitive(url))
|
|
return;
|
|
|
|
_supplements.Clear();
|
|
_supplements.Add(new Supplement(this, url));
|
|
|
|
// Only when the book had no supplement at all. A url Audible has changed says nothing about whether the
|
|
// file behind it is already downloaded.
|
|
UserDefinedItem.PdfStatus ??= LiberatedStatus.NotLiberated;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Forgets that this book has a supplement, for when Audible says it has none after all.
|
|
/// <para>
|
|
/// The status goes back to null rather than to NotLiberated, because null is what "this title has no PDF"
|
|
/// is recorded as everywhere else: it is the state of a book that never had one, and it is what stops the
|
|
/// grid from showing a PDF marker and the counts from expecting one.
|
|
/// </para>
|
|
/// </summary>
|
|
/// <returns>Whether anything was removed.</returns>
|
|
public bool RemoveSupplements()
|
|
{
|
|
if (_supplements is null || _supplements.Count == 0)
|
|
return false;
|
|
|
|
_supplements.Clear();
|
|
UserDefinedItem.PdfStatus = null;
|
|
return true;
|
|
}
|
|
#endregion
|
|
|
|
public void UpdateProductRating(float overallRating, float performanceRating, float storyRating)
|
|
=> Rating.Update(overallRating, performanceRating, storyRating);
|
|
|
|
public void UpdateBookDetails(bool isAbridged, bool? isSpatial, DateTime? datePublished, string? language, string? copyright = null)
|
|
{
|
|
// don't overwrite with default values
|
|
IsAbridged |= isAbridged;
|
|
IsSpatial = isSpatial ?? IsSpatial;
|
|
DatePublished = datePublished ?? DatePublished;
|
|
Language = language?.Trim().FirstCharToUpper() ?? Language;
|
|
Copyright = copyright?.Trim() ?? Copyright;
|
|
}
|
|
|
|
public override string ToString() => $"[{AudibleProductId}] {TitleWithSubtitle}";
|
|
}
|