Skip to content

feat: show audiobooks under all their series and fix the Primary-series toggle (#658)#659

Merged
therobbiedavis merged 4 commits into
Listenarrs:canaryfrom
s3ntin3l8:658-multi-series-display-and-primary-toggle
Jun 9, 2026
Merged

feat: show audiobooks under all their series and fix the Primary-series toggle (#658)#659
therobbiedavis merged 4 commits into
Listenarrs:canaryfrom
s3ntin3l8:658-multi-series-display-and-primary-toggle

Conversation

@s3ntin3l8

@s3ntin3l8 s3ntin3l8 commented Jun 7, 2026

Copy link
Copy Markdown
Contributor

Summary

The backend already models books that belong to multiple series (AudiobookSeriesMembership join with IsPrimary/SortOrder, populated by Audible/Audnexus), but the UI and one list query still treated each book as single-series. As a result a book that's in two or more series only showed under its provider-chosen primary, and the per-book "Primary" series toggle silently reverted on save.

This PR makes a book appear under every series it belongs to and makes the Primary selection actually stick — which also makes {Series} folder naming follow the chosen series.

Motivating example: a series catalogued in both publication order and chronological order (e.g. Jack Ryan) previously split across two half-empty series with no way to pick the canonical one.

Closes #658.

Changes

Added

  • Books appear under all their series: the library "Series" grouping and the series collection view list a book under each of its series memberships (not just the primary), showing the correct position number for that series. Book cards and detail modals list every series the book belongs to.
  • Series memberships in the library list payload: LibraryAudiobookListItem now carries SeriesMemberships, batch-loaded in LibraryListService via a new GetAllSeriesMembershipsGroupedByAudiobookIdAsync repository query (mirrors the existing file-summary batching — no per-row Include, no change to the shared GetAllAsync).

Fixed

  • "Primary" series toggle now persists: the edit dialog's save payload built isPrimary: Boolean(membership.isPrimary || index === 0), which re-flagged the first series as primary alongside the user's pick; the backend keeps the first primary it finds, so the choice reverted to the provider default. The payload now sends only the user's selection.
  • Metadata rescan keeps the chosen primary: a manual "Rescan Metadata" replaced memberships with provider data and re-derived primary as the provider's first. A new AudiobookSeriesMembershipHelper.ApplyToAudiobookPreservingPrimary re-applies the user's chosen primary when the provider still returns that series (falling back to the provider default otherwise).
  • Latent DbContext concurrency hardening (independent of the feature): LibraryListService started the file-summary, file-count, and series-membership reads concurrently on the shared scoped ListenArrDbContext; under real database latency that risks EF Core's "a second operation was started on this context instance" error (SQLite masks it today by running the async path synchronously). Those same-context reads now run sequentially, while the download read — which uses its own IDbContextFactory context — stays parallel.

Testing

  • Backend dotnet test: full suite green, including new unit tests for ApplyToAudiobookPreservingPrimary and the Normalize non-first-primary case, a library-list payload test asserting seriesMemberships, and a RenameService regression asserting {Series} folders under the chosen primary.
  • Frontend vitest: full suite green, including a new EditAudiobookModal test asserting the save payload keeps a non-first primary (index 0 = isPrimary:false) and seriesUtils unit tests.
  • vue-tsc type-check, ESLint, Vue template-handler check, Prettier, and dotnet format all clean.

Notes

…le (Listenarrs#658)

- Library grouping and series collections list a book under every series
  membership, not just the metadata provider's primary
- Library list payload now carries seriesMemberships (batched query, no
  blanket Include on the shared GetAllAsync)
- Fix EditAudiobookModal save payload that re-flagged the first series as
  primary, reverting a non-default Primary selection
- Preserve the user's chosen primary across a manual metadata rescan
- Cards/modals list all series; {Series} folder naming follows the chosen
  primary

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@s3ntin3l8 s3ntin3l8 requested a review from a team June 7, 2026 22:49
@therobbiedavis therobbiedavis self-assigned this Jun 8, 2026

@therobbiedavis therobbiedavis left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, this is a nicely focused fix and the regression coverage is good. I left two comments: one backend concurrency risk around starting multiple EF queries on the same scoped DbContext, and one UI gap where the series collection view resolves the per-series number but never renders it. Also the changelog is no longer needed.

Comment thread listenarr.application/Audiobooks/LibraryListService.cs Outdated
Comment thread fe/src/views/library/CollectionView.vue
@therobbiedavis therobbiedavis added the patch patch version bump - backward compatible bug fixes label Jun 8, 2026
s3ntin3l8 and others added 3 commits June 8, 2026 12:14
- LibraryListService: await the three shared-scoped-context reads (file
  summaries, file counts, series memberships) sequentially. They all run on
  the scoped ListenArrDbContext, so firing them concurrently risked EF's
  "a second operation was started on this context instance" under real DB
  latency (only survived on SQLite because its async path runs sync). The
  download read uses IDbContextFactory (own context) and stays parallel.
- CollectionView: render the resolved per-collection series position (#N) in
  the series collection list and grid item details — previously
  resolveSeriesForCollection computed it but nothing displayed it.
- Drop the CHANGELOG entries (maintainer feedback: changelog no longer needed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A book in multiple series lists every series it belongs to, but the series
catalog mapped each book via book.Series.FirstOrDefault() — its primary series.
So opening "Series A" showed not-added (catalog) suggestions with their primary
series number (e.g. "Series B Listenarrs#5") instead of their position in the series being
viewed ("Series A Listenarrs#3"). Library items were already correct; only the remote
catalog "Not Added" items were wrong.

SeriesCatalogService now reorders each fetched book's series list so the
catalogued series (matched by ASIN, else name) comes first, before the books are
cached and returned. Both the cache write and the controller response mapping
(both FirstOrDefault-based) then reflect the requested series.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
GetSeriesMembershipsByAudiobookIdsAsync implied filtering by audiobook IDs, but
it takes no IDs and returns the entire AudiobookSeriesMemberships table grouped
by AudiobookId. The sole caller (LibraryListService, the full library list)
wants all rows, so the behavior is correct — only the name was misleading (a
footgun for future callers who might pass IDs and silently get every row).
Renamed to GetAllSeriesMembershipsGroupedByAudiobookIdAsync across the
interface, implementation, and caller. No behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@s3ntin3l8

Copy link
Copy Markdown
Contributor Author

Fixed the points from the review, also pushed two follow-ups that I noticed while testing:

  • "Not Added" items now show the viewed series' position, not the book's primary-series number. The series catalog was mapping each book via book.Series.FirstOrDefault() (its primary); it now selects the entry for the series being viewed, so a book that's Update workflows: add Docker login and fix indent #3 in this series no longer shows its primary's Bump version to 1.0.1 #5. Library items were already correct. (Covered by a new test.)

  • Renamed GetSeriesMembershipsByAudiobookIdsAsync → GetAllSeriesMembershipsGroupedByAudiobookIdAsync — it takes no IDs and returns the whole AudiobookSeriesMemberships table grouped by audiobook id, so the name now matches the behavior (no more "expects IDs" footgun). Pure rename, no behavior change.

I also called out the LibraryListService sequential-await change in the description as the latent DbContext "second operation" fix, per the review note.

These are separate commits for easier review — can be squashed before merge.

@s3ntin3l8 s3ntin3l8 requested a review from therobbiedavis June 8, 2026 15:10

@therobbiedavis therobbiedavis left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one LGTM as well!

@therobbiedavis therobbiedavis merged commit a240c95 into Listenarrs:canary Jun 9, 2026
6 checks passed
s3ntin3l8 added a commit to s3ntin3l8/Listenarr that referenced this pull request Jun 9, 2026
… from PATCH_BRANCHES

658-multi-series-display-and-primary-toggle (PR Listenarrs#659) and
626-sort-series-by-position (PR Listenarrs#660) merged into upstream/canary, so
they now arrive via the base when my-canary is recreated.
kevinheneveld added a commit to kevinheneveld/Listenarr that referenced this pull request Jun 9, 2026
Catch-up merge bringing upstream canary up to v1.0.11. Net-new from canary:
System Storage available-space (Listenarrs#656), Title-folder persistence (Listenarrs#646), sort
series by reading order (Listenarrs#626/Listenarrs#660), audiobooks-under-all-series + Primary-series
toggle (Listenarrs#658/Listenarrs#659), dependency/vulnerability + lint-config updates (Listenarrs#636).

Conflict resolutions of note (series work overlapped kevin/live's series suite):
- LibraryListService: kept kevin's importedAt (Recently-Imported) AND adopted
  canary's series memberships, using canary's sequential shared-DbContext awaits.
- SeriesCatalogService: kept kevin's richer GetSeriesCandidates/owned-book
  ResolveSeriesAsync; added canary's PrioritizeCatalogSeries/FindCatalogSeries.
- AudiobooksView: merged kevin's narrator grouping with canary's multi-series
  grouping (getBookSeriesNames) + formatSeriesMemberships display.
- CollectionView: adopted canary's proper series-position sort + multi-series
  membership matching, superseding kevin's interim "Series Order" relabel, but
  kept kevin's article/apostrophe-aware normalizeSeriesName for slug matching.
- SeriesMonitoringServiceTests: kept both kevin's (old path, +enhancements) and
  canary's relocated copy (different namespaces, no collision).
- Test files: unioned both sides' added cases; dropped kevin's now-superseded
  "Series Order" option test, adapted the default-sort test to series-position.
- Fixed pre-existing unused-var lint now surfaced by canary's stricter config.

Validated on the throwaway branch: 1093 backend + 501 frontend tests pass;
backend + frontend build, FE lint, and type-check all clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@s3ntin3l8 s3ntin3l8 deleted the 658-multi-series-display-and-primary-toggle branch June 9, 2026 20:50
s3ntin3l8 added a commit to s3ntin3l8/Listenarr that referenced this pull request Jun 9, 2026
)

- Remove the two Listenarrs#571 CHANGELOG entries — changelog entries are no longer
  wanted on upstream PRs (maintainer confirmed on Listenarrs#659/Listenarrs#660); they only
  cause [Unreleased] conflicts. Keeps the existing Authentication entries.
- Unify LibraryController.ComputeAudiobookBaseDirectoryFromPattern's
  no-pattern fallback from {Author}/{Title} to {Author}/{Series}/{Title},
  matching the orchestrator's legacy default (review follow-up). Reachable
  only when no folder pattern is configured at all; empty {Series} collapses
  away, so non-series books are unaffected.

719 backend tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
s3ntin3l8 added a commit to s3ntin3l8/Listenarr that referenced this pull request Jun 10, 2026
… from PATCH_BRANCHES

658-multi-series-display-and-primary-toggle (PR Listenarrs#659) and
626-sort-series-by-position (PR Listenarrs#660) merged into upstream/canary, so
they now arrive via the base when my-canary is recreated.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

patch patch version bump - backward compatible bug fixes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Books in multiple series: only shown under their primary series, and the "Primary" toggle doesn't persist (also affects {Series} folder naming)

2 participants