From 4e6bba54b583cb6801975bc1241b94412daf87cb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 10 Aug 2026 03:20:07 +0000 Subject: [PATCH] bugfix #1925: show orphaned episodes as standalone rows Co-authored-by: rmcrackan --- Source/AudibleUtilities/ApiExtended.cs | 8 +- .../QueryObjects/LibraryBookQueries.cs | 10 +++ .../ViewModels/ProductsDisplayViewModel.cs | 25 ++++++- .../GridView/LibraryBookEntry.cs | 7 +- .../LibationWinForms/GridView/ProductsGrid.cs | 21 +++++- .../StandaloneBooksTests.cs | 73 +++++++++++++++++++ 6 files changed, 135 insertions(+), 9 deletions(-) create mode 100644 Source/_Tests/LibationUiBase.Tests/StandaloneBooksTests.cs diff --git a/Source/AudibleUtilities/ApiExtended.cs b/Source/AudibleUtilities/ApiExtended.cs index d5568fca..8155b445 100644 --- a/Source/AudibleUtilities/ApiExtended.cs +++ b/Source/AudibleUtilities/ApiExtended.cs @@ -143,8 +143,10 @@ public class ApiExtended var importEpisodes = Configuration.Instance.ImportEpisodes; var importPlusTitles = Configuration.Instance.ImportPlusTitles; - List items = new(); - int libraryItemCount = 0, episodeItemsExcluded = 0, plusTitlesExcluded = 0; + var items = new List(); + 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); diff --git a/Source/DataLayer/QueryObjects/LibraryBookQueries.cs b/Source/DataLayer/QueryObjects/LibraryBookQueries.cs index 88f310f3..185d4077 100644 --- a/Source/DataLayer/QueryObjects/LibraryBookQueries.cs +++ b/Source/DataLayer/QueryObjects/LibraryBookQueries.cs @@ -108,6 +108,16 @@ public static class LibraryBookQueries .ParentedEpisodes() .Select(ge => ge.Book.AudibleProductId), ge => ge.Book.AudibleProductId); + /// + /// 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. + /// + public IEnumerable StandaloneBooks() + => libraryBooks + .Where(lb => lb.Book.IsProduct()) + .Concat(libraryBooks.FindOrphanedEpisodes()); + public IEnumerable FindChildren(LibraryBook parent) => libraryBooks.Where(lb => lb.Book.IsEpisodeChild() && lb.HasSeriesId(parent.Book.AudibleProductId)); diff --git a/Source/LibationAvalonia/ViewModels/ProductsDisplayViewModel.cs b/Source/LibationAvalonia/ViewModels/ProductsDisplayViewModel.cs index 280e8107..5b6d6edb 100644 --- a/Source/LibationAvalonia/ViewModels/ProductsDisplayViewModel.cs +++ b/Source/LibationAvalonia/ViewModels/ProductsDisplayViewModel.cs @@ -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); + } } }); diff --git a/Source/LibationUiBase/GridView/LibraryBookEntry.cs b/Source/LibationUiBase/GridView/LibraryBookEntry.cs index d083d6d9..e3eaf756 100644 --- a/Source/LibationUiBase/GridView/LibraryBookEntry.cs +++ b/Source/LibationUiBase/GridView/LibraryBookEntry.cs @@ -32,11 +32,14 @@ public class LibraryBookEntry : GridEntry } /// - /// Creates for all non-episode books in an enumeration of . + /// Creates for ordinary books and episodes whose series parent is missing. /// /// Can be called from any thread, but requires the calling thread's to be valid. public static async Task> GetAllProductsAsync(IEnumerable 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); diff --git a/Source/LibationWinForms/GridView/ProductsGrid.cs b/Source/LibationWinForms/GridView/ProductsGrid.cs index 0a00d982..353262c8 100644 --- a/Source/LibationWinForms/GridView/ProductsGrid.cs +++ b/Source/LibationWinForms/GridView/ProductsGrid.cs @@ -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); } } diff --git a/Source/_Tests/LibationUiBase.Tests/StandaloneBooksTests.cs b/Source/_Tests/LibationUiBase.Tests/StandaloneBooksTests.cs new file mode 100644 index 00000000..6ef824b8 --- /dev/null +++ b/Source/_Tests/LibationUiBase.Tests/StandaloneBooksTests.cs @@ -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()); + } +}