Files
Libation/Source/DataLayer/EfClasses/Book.cs
T
Cursor Agentandrmcrackan 178b7ee1d8 feat(metadata): carry the publisher's copyright line from the API to the tag
Libation never had a copyright of its own to fall back on: it only cleaned up
whatever the .aaxc file already embedded. Widevine downloads carry no cprt at
all, so they came out blank.

The copyright line is only returned by the product_details response group, so
request it on the full library scan, store it on Book, and use it as the tag
fallback. Existing libraries pick it up on their next scan, and Audible still
returns null for plenty of titles.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-26 12:40:04 +00:00

303 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;
#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}";
}