bugfix #1925: show orphaned episodes as standalone rows

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
This commit is contained in:
Cursor Agentandrmcrackan committed 2026-08-10 03:20:07 +00:00
1 parent 8f56046a11
commit 4e6bba54b5
6 files changed
+135 -9

No files matched your search

+5 -3
View File
@@ -143,8 +143,10 @@ public class ApiExtended
var importEpisodes = Configuration.Instance.ImportEpisodes;
var importPlusTitles = Configuration.Instance.ImportPlusTitles;
List<Item> items = new();
int libraryItemCount = 0, episodeItemsExcluded = 0, plusTitlesExcluded = 0;
var items = new List<Item>();
var libraryItemCount = 0;
var episodeItemsExcluded = 0;
var plusTitlesExcluded = 0;
var sw = Stopwatch.StartNew();
var totalTime = TimeSpan.Zero;
using var semaphore = new SemaphoreSlim(MaxConcurrency);
@@ -378,7 +380,7 @@ public class ApiExtended
var items = await fetch(asins);
var missing = GetMissingAsins(asins, items);
for (int attempt = 1; attempt <= maxRetries && missing.Count > 0; attempt++)
for (var attempt = 1; attempt <= maxRetries && missing.Count > 0; attempt++)
{
onRetry?.Invoke(attempt);
@@ -108,6 +108,16 @@ public static class LibraryBookQueries
.ParentedEpisodes()
.Select(ge => ge.Book.AudibleProductId), ge => ge.Book.AudibleProductId);
/// <summary>
/// Books displayed as top-level rows: ordinary products plus episodes whose series parent
/// is not in the database. Showing orphaned episodes here keeps a successfully imported
/// title discoverable instead of silently hiding it from both grids.
/// </summary>
public IEnumerable<LibraryBook> StandaloneBooks()
=> libraryBooks
.Where(lb => lb.Book.IsProduct())
.Concat(libraryBooks.FindOrphanedEpisodes());
public IEnumerable<LibraryBook> FindChildren(LibraryBook parent)
=> libraryBooks.Where(lb => lb.Book.IsEpisodeChild() && lb.HasSeriesId(parent.Book.AudibleProductId));
@@ -162,6 +162,7 @@ public class ProductsDisplayViewModel : ViewModelBase
var allEntries = SOURCE.BookEntries().ToDictionarySafe(b => b.AudibleProductId);
var seriesEntries = SOURCE.SeriesEntries().ToList();
var parentedEpisodes = dbBooks.ParentedEpisodes().ToHashSet();
var orphanedEpisodes = dbBooks.FindOrphanedEpisodes().ToHashSet();
await Dispatcher.UIThread.InvokeAsync(() =>
{
@@ -169,11 +170,31 @@ public class ProductsDisplayViewModel : ViewModelBase
{
var existingEntry = allEntries.TryGetValue(libraryBook.Book.AudibleProductId, out var e) ? e : null;
if (libraryBook.Book.IsProduct())
if (libraryBook.Book.IsProduct() || orphanedEpisodes.Contains(libraryBook))
{
// An episode can become orphaned when its parent is removed. Move it out from under
// the old parent and keep it visible as a standalone row.
if (existingEntry?.Parent is not null)
{
existingEntry.Parent.RemoveChild(existingEntry);
RemoveBooks([existingEntry], []);
existingEntry = null;
}
UpsertBook(libraryBook, existingEntry);
}
else if (parentedEpisodes.Contains(libraryBook))
//Only try to add or update is this LibraryBook is a know child of a parent
{
// If a formerly orphaned episode now has a parent, remove its standalone row
// before recreating it beneath that parent.
if (existingEntry is { Parent: null })
{
RemoveBooks([existingEntry], []);
existingEntry = null;
}
UpsertEpisode(libraryBook, existingEntry, seriesEntries, dbBooks);
}
}
});
@@ -32,11 +32,14 @@ public class LibraryBookEntry : GridEntry
}
/// <summary>
/// Creates <see cref="LibraryBookEntry{TStatus}"/> for all non-episode books in an enumeration of <see cref="LibraryBook"/>.
/// Creates <see cref="LibraryBookEntry{TStatus}"/> for ordinary books and episodes whose series parent is missing.
/// </summary>
/// <remarks>Can be called from any thread, but requires the calling thread's <see cref="System.Threading.SynchronizationContext.Current"/> to be valid.</remarks>
public static async Task<List<GridEntry>> GetAllProductsAsync(IEnumerable<LibraryBook> libraryBooks)
=> await GetAllProductsAsync(libraryBooks, lb => lb.Book.IsProduct(), lb => new LibraryBookEntry(lb) as GridEntry);
=> await GetAllProductsAsync(
libraryBooks.StandaloneBooks(),
_ => true,
lb => new LibraryBookEntry(lb) as GridEntry);
protected override string? GetBookTags()
=> Book is null ? null : string.Join("\r\n", Book.UserDefinedItem.TagsEnumerated);
@@ -350,6 +350,7 @@ public partial class ProductsGrid : UserControl
var allEntries = bindingList.AllItems().BookEntries().ToDictionarySafe(b => b.AudibleProductId);
var seriesEntries = bindingList.AllItems().SeriesEntries().ToList();
var parentedEpisodes = dbBooks.ParentedEpisodes().ToHashSet();
var orphanedEpisodes = dbBooks.FindOrphanedEpisodes().ToHashSet();
//Get the UI thread's synchronization context and set it on the current thread to ensure
//it's available for creation of new IGridEntry items during upsert
@@ -361,14 +362,30 @@ public partial class ProductsGrid : UserControl
{
var existingEntry = allEntries.TryGetValue(libraryBook.Book.AudibleProductId, out var e) ? e : null;
if (libraryBook.Book.IsProduct())
if (libraryBook.Book.IsProduct() || orphanedEpisodes.Contains(libraryBook))
{
// An episode can become orphaned when its parent is removed. Move it out from under
// the old parent and keep it visible as a standalone row.
if (existingEntry?.Parent is not null)
{
existingEntry.Parent.RemoveChild(existingEntry);
bindingList.Remove(existingEntry);
existingEntry = null;
}
AddOrUpdateBook(libraryBook, existingEntry);
continue;
}
if (parentedEpisodes.Contains(libraryBook))
{
//Only try to add or update is this LibraryBook is a know child of a parent
// If a formerly orphaned episode now has a parent, remove its standalone row
// before recreating it beneath that parent.
if (existingEntry is { Parent: null })
{
bindingList.Remove(existingEntry);
existingEntry = null;
}
AddOrUpdateEpisode(libraryBook, existingEntry, seriesEntries, dbBooks);
}
}
@@ -0,0 +1,73 @@
using DataLayer;
namespace LibationUiBase.Tests;
[TestClass]
public class StandaloneBooksTests
{
private static LibraryBook libraryBook(string asin, ContentType contentType, string? seriesAsin = null)
{
var contributor = Contributor.GetEmpty();
var book = new Book(
new AudibleProductId(asin),
asin,
null,
null,
1,
contentType,
[contributor],
[contributor],
"us");
if (seriesAsin is not null)
book.UpsertSeries(new Series(new AudibleSeriesId(seriesAsin), $"Series {seriesAsin}"), "1");
return new LibraryBook(book, new DateTime(2026, 8, 10), "account");
}
[TestMethod]
public void ordinary_books_are_standalone()
{
var product = libraryBook("BOOK", ContentType.Product);
var standalone = new[] { product }.StandaloneBooks().ToList();
CollectionAssert.AreEqual(new[] { "BOOK" }, standalone.Select(lb => lb.Book.AudibleProductId).ToList());
}
[TestMethod]
public void episode_without_its_parent_is_standalone()
{
var orphan = libraryBook("EPISODE", ContentType.Episode, "SHOW");
var standalone = new[] { orphan }.StandaloneBooks().ToList();
CollectionAssert.AreEqual(new[] { "EPISODE" }, standalone.Select(lb => lb.Book.AudibleProductId).ToList());
}
[TestMethod]
public void episode_with_its_parent_is_not_standalone()
{
var parent = libraryBook("SHOW", ContentType.Parent, "SHOW");
var episode = libraryBook("EPISODE", ContentType.Episode, "SHOW");
var standalone = new[] { parent, episode }.StandaloneBooks().ToList();
Assert.AreEqual(0, standalone.Count);
}
[TestMethod]
public void products_and_orphans_are_returned_but_parents_and_parented_episodes_are_not()
{
var product = libraryBook("BOOK", ContentType.Product);
var parent = libraryBook("SHOW", ContentType.Parent, "SHOW");
var child = libraryBook("CHILD", ContentType.Episode, "SHOW");
var orphan = libraryBook("ORPHAN", ContentType.Episode, "MISSING_SHOW");
var standalone = new[] { product, parent, child, orphan }.StandaloneBooks().ToList();
CollectionAssert.AreEquivalent(
new[] { "BOOK", "ORPHAN" },
standalone.Select(lb => lb.Book.AudibleProductId).ToList());
}
}