Repository navigation
Conversation
AudnexusSeries carries Asin, Name and Position, but ConvertAudnexusToMetadata mapped only Name and Position, so AudiobookSeriesMembership.SeriesAsin was null for every audiobook even though the column and the persistence path already existed for it. The series ASIN is the stable identifier for a series: a book ASIN is per-marketplace and per-narrator, and a series name is free text that varies by edition and translation, so SeriesAsin is what lets SeriesNumber mean anything durable. This copies AudnexusSeries.Asin into AudiobookSeriesMembership.SeriesAsin for both the primary and secondary membership. Fixes Listenarrs#767. Originally Listenarrs#768. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
SearchResponseMapper.MapMetadataResultToAudibleAsync synthesizes an Audible-shaped result when the product lookup returns nothing, and in that fallback the synthesized series entry put the series name into its asin field instead of leaving it null. The two non-fallback shapes elsewhere in the same file map s.Asin honestly, so a caller could not tell from the response which branch produced it. This matters because three consumers now read a series ASIN off search results into the database: AudiobookSeriesMembership.SeriesAsin, the library-import wizard's membership construction, and AudibleBookMetadata.seriesAsin as a legacy fallback identifier. A series name landing in the field meant to hold a stable identifier is worse than cosmetic. The fallback series entry now emits asin = null, matching what the author fallback three lines above already does for the same reason. Fixes Listenarrs#979. Originally Listenarrs#986. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Two commits, backend only, squashed into one. The author and series monitoring mappers each took book.Series?.FirstOrDefault() and wrote only its name and position into the legacy Series/SeriesNumber fields, never setting SeriesMemberships. AudibleBookMetadata.ToAudiobook() then fell back to one membership built from those legacy strings with SeriesAsin null, so every series past the first was dropped and the identifier the catalog had already supplied was thrown away. Both mappers now build the full list through the same MetadataConverters.BuildSeriesMemberships the search path uses, with the legacy fields read off the primary membership afterward so the two cannot disagree. Separately, AudibleBookMetadata had no SeriesAsin property even though the frontend has been sending seriesAsin on the add payload and the type it builds from declares the field, so the value was dropped at the model boundary. The property is added and passed at the four sites that already pass Series and SeriesNumber: ToAudiobook(), the add workflow, the metadata rescan patch, and the path preview. The rescan guard is deliberately not widened to a payload carrying an ASIN alone, since its legacy fallback needs a name to build a membership from. This only changes what is written when a book is added; existing rows keep what they have until a metadata refresh re-reads them. Fixes Listenarrs#962. Originally Listenarrs#961. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Three commits squashed into one. Library Import collapsed a multi-series match to its first series and never populated seriesMemberships, so the backend fell back to the legacy scalar path and stored one membership. matchToMetadata in fe/src/stores/libraryImport.ts now maps every entry of the search result's series array into an AudiobookSeriesMembership, with isPrimary and sortOrder following the incoming order; the scalar series, seriesNumber and seriesAsin fields stay populated from the primary entry so nothing reading them changes behaviour. The walk used to be duplicated across three call sites that disagreed about what a series is. It now lives once in buildSeriesFields, which matchToMetadata, the unmatched-files bulk add, and the product-enrich step all use. The unmatched-files path had been reading series[0].title and .part off a response that actually carries asin, name and position, so the read never matched and every add silently fell back to the series string parsed out of file tags; it reads the right fields now. This is the first guard on the seriesAsin field: a membership's seriesAsin is kept only when it looks like an Audible ASIN, the literal prefix B0 followed by eight alphanumerics. A generic ten-character check is not enough, because Foundation, Bartimaeus and Peripheral are real series whose name alone is ten characters, and the search fallback can put a series name into the asin field when its ASIN re-fetch fails. Fixes Listenarrs#846. Originally Listenarrs#847. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Four commits squashed into one. Half of Listenarrs#877, the half that does not wait on anything. An Audible call that times out returns an empty result set identical to Audible answering "no such book". The per-call CancellationTokenSource fires, AudibleApiClient logs a warning and returns null, and SearchProductsCoreAsync turned that null into new SearchProductsDirectResponse(), indistinguishable from a real zero-match from there on. SearchProductsDirectResponse now carries ProviderUnavailable, set where the null used to be discarded silently. It rides through both ToSearchResponse copies onto AudibleSearchResponse, and GET /search/audible answers 503 instead of a 200 that claims more than it knows about the catalogue. The field is additive, so a client that ignores it sees what it saw before. Adding a cancellation branch to the existing Polly policy does not fix this: AudibleApiClient passes its own token into SendAsync, and PolicyHttpMessageHandler hands that same token to policy.ExecuteAsync, so the retry engine is already holding a cancelled token before it can attempt a retry. The retry side of Listenarrs#877 is left alone on purpose, since it interacts with the AudibleApiClient refactor tracked in Listenarrs#635. SearchProductsDirectAsync also retries with diacritics stripped whenever a result comes back empty. A timeout comes back empty too, so it was spending the caller's remaining budget on a second request that would fail the same way; it checks the new flag first now. POST /api/v1/search still collapses the two cases, since carrying the signal through SearchService.IntelligentSearchAsync would mean changing its return type, a larger decision than this change makes. That endpoint is where the original Listenarrs#877 report came from, so this is a partial answer to it. Refs Listenarrs#877. Originally Listenarrs#910. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Audible has no role field. A translator or an editor arrives as an ordinary author name with the role appended, so a book is credited to something like ["Fyodor Dostoevsky", "Constance Garnett - translator"], and the second of those gets displayed, grouped, and looked up as a person named that. AudibleBookMetadata.ToAudiobook() passed Authors straight through with no transformation, and FileNamingService took Authors.First() as the naming author with no role handling either. This strips the role suffix and keeps the person, rather than keeping only the first credit (byline order is editorial choice, not a guarantee) or dropping any role-bearing credit outright (a book credited only to its editors or translator would then lose its only author). A credit that is nothing but a role still comes back as it arrived, and a person credited under two roles collapses to one entry instead of a duplicate. Credits that name a role are stored after the ones that do not, so Authors[0] agrees with the primary-author decision by construction. Four callers pick Authors[0] off the stored row: the library path planner, the rename service, the manual import planner, and the search result classifier. Without that ordering, a book whose provider credits list a translator first could disagree between those four and the add path, and propose moving files that were already filed correctly. The bracket predicate used to decide whether something is a role is split in two: a loose one that strips a tail, and a strict one that decides who wrote the book. A bare participle like "(Illustrated)" satisfies the loose one but not the strict one, so it no longer demotes a real author to a contributor. Originally Listenarrs#1013. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The B0-prefix guard on series-membership identifiers was also reused, through looksLikeAsin, to decide whether a manual search-box query is an ASIN or a title. Real Audible product ASINs are not all B0-prefixed: some are ISBN-shaped ten-character values (for example 1781105022), which the guard rejects. Typing one of those into the manual search box silently did a title search instead of the ASIN lookup the user asked for. The two checks serve different purposes. The series-ASIN guard exists to keep a series name out of a field meant to hold a stable identifier, and B0-only is the right rule there. The manual search box just has to tell an ASIN-shaped query apart from a title, and a bare ten-character pattern does that correctly on its own. This restores that pattern for query detection alone and leaves looksLikeAsin as the series-ASIN guard. Separately checked matchToMetadata's seriesAsin fallback, seriesFields.seriesAsin ?? result.seriesAsin: nothing in the backend currently sets a top-level seriesAsin on a search result, and AudiobookSeriesMembershipHelper.Normalize only reads the legacy scalar when no named membership was built at all, so this fallback has no value to leak today (read, not run). A regression test pins the fix: searchItem with a non-B0 ten-character query now reaches advancedSearch as an asin lookup, not a title search. Measured failing against the unfixed code (title search was called instead) before this change, passing after. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
AudibleBookMetadata.ToAudiobook(), the add path, strips a role suffix from a credit before it reaches storage. The metadata rescan patch wrote metadata.Authors straight through instead, so a rescan on a book that already had its credits cleaned could put a role-suffixed name like "Constance Garnett - translator" back, and flip Authors[0] back to the translator since the ordering guarantee the add path relies on no longer held once the role returned. ApplyMetadataRescanPatch now runs the normalized author list through AuthorCredits.WithoutRoleSuffixes, the same helper the add path uses, so a rescan and an add agree on what gets stored. A regression test pins it: rescanning with a provider response crediting the translator first stores the author first, with no role suffix, matching the add path's own test for the same credit pair. Measured failing against the unfixed code (role suffix and wrong order both came back) before this change, passing after. The full rescan test class still passes with no other regressions. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
No runtime behaviour change, so no test: this is a comment correction only. A comment in FileNamingService.Helpers.cs, matching comments in two test files, and the XML doc remarks on AuthorCredits.StripRole in AuthorCredits.cs all claimed B002V9ZF3K (Crime and Punishment) credits Dostoevsky then Garnett as a two-person byline. On the live catalogue B002V9ZF3K credits only Dostoevsky; there is no translator credit on that edition. The reversed-order example these comments exist to support is real on its own: B00EZAXAF8 (The Brothers Karamazov) credits Garnett before Dostoevsky, which is enough by itself to show that byline order is not reliable. Corrected, each now citing only B00EZAXAF8 with the actual order spelled out: - listenarr.domain/Common/AuthorCredits.cs - listenarr.application/Common/FileNamingService.Helpers.cs - tests/Features/Api/Services/FileNamingService_AuthorCreditTests.cs - tests/Features/Domain/Utils/AuthorCreditsTests.cs Two other test comments used the same hypothetical credit pair with the translator listed second, an order that matches neither real ASIN, so they are left without a citation rather than given one that would not be accurate either. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This bundle is about a book keeping every series it belongs to, and the series' real Audible ASIN rather than its display name, on every path that can add a book: an Audnexus lookup, a search-fallback result, a monitored author or series, the library-import wizard, and a legacy-only payload. It also fixes a case where a failed Audible catalog call reads as a confirmed empty result, and a case where a translator or editor's job title leaked into the stored author name. It collects six fixes, all from my own open PRs (#768, #986, #961, #847, #910, #1013), plus three small integration fixes: a regression one of the six introduced, a gap in another, and a wrong citation in a comment. Each fix is one commit, so any of them can be dropped or cherry-picked on its own.
aeca3a88fSeriesAsinfor both the primary and secondary membershipfe20df2fb7f8028879seriesAsinagain90d892e9cseriesAsinthat looks like oneb4b31fc2b43b6b8af29e2bb4ad5f0e1e1e5cc133412a1All file:line references below are at
a630572e9unless I say otherwise.1. Populate
SeriesAsinfrom the Audnexus series record (aeca3a88f, from #768)AudnexusSeriescarriesAsin,NameandPosition, butConvertAudnexusToMetadatamapped onlyNameandPosition(MetadataConverters.cs:141-163at the base commit), soAudiobookSeriesMembership.SeriesAsinwas null for every audiobook added through this path even though the column and the rest of the persistence already handle it. A book ASIN is per-marketplace and per-narrator, and a series name is free text that varies by edition and translation, soSeriesAsinis the only thing that makesSeriesNumbermean anything durable.The conversion now goes through
AudnexusSeriesMapper.ToAudibleSeriesand the sameBuildSeriesMembershipsthe search path uses, so the ASIN is carried on both memberships of a two-series book.This commit also fixes a second, separate site:
AudiobookMetadataService's own copy of an Audnexus lookup into anAudibleBookResponsenever assignedSeriesat all (AudiobookMetadataService.cs:105), so a book reaching this path had no series information whatsoever, not even the legacy name. Both the interactive add and the metadata rescan go through this lookup, and the rescan only rewrites a book's series when the incoming record carries some series information, so a book that previously hit this gap stayed without a series forever, even across repeated rescans. With the list populated, a rescan now repairs those rows wherever Audnexus answers the lookup.Tests:
MetadataConvertersSeriesAsinTests, a new file with 5 added[Fact]cases (primary ASIN mapped, secondary ASIN mapped, a book in two series keeps both memberships, a blank Audnexus ASIN keeps the membership with no identifier, an empty one stores no identifier).AudiobookMetadataServiceTestsgains 2 added[Fact]cases for the second site (the series list carries identifiers through the lookup; it stays empty when Audnexus reports none). All confirmed present at the bundle head.2. Stop reporting a series name as the series identifier (
fe20df2fb, from #986)SearchResponseMapper's fallback branch, used when the Audible product re-fetch fails, put the series name into the synthesized entry'sasinfield instead of leaving it null (SearchResponseMapper.cs:256), while the author fallback three lines above already emitsasin = nullfor the same reason (:253). Three consumers now read a series ASIN off search results into the database:AudiobookSeriesMembership.SeriesAsin, the library-import wizard, andAudibleBookMetadata.seriesAsin, so a name landing in the identifier field was more than cosmetic.The fix is the one line matching the author fallback's convention.
Tests:
SearchResponseMapperSeriesFallbackTests, a new file with 4 added[Fact]cases, confirmed present at the bundle head, driven through a metadata service that returns no Audible product so the fallback path is what runs.3. Monitoring keeps every series membership and identifier (
7f8028879, from #961)Two commits from the original PR, squashed into one.
Both the author and series monitoring mappers took
book.Series?.FirstOrDefault()and wrote only its name and position into the legacy fields, never settingSeriesMemberships(AuthorMonitoringService.Mapping.cs:14,SeriesMonitoringService.Mapping.cs:14). Every series past the first was dropped, and the identifier the catalog had already supplied was thrown away. Both mappers now build the full list throughMetadataConverters.BuildSeriesMemberships, the same helper the search path uses, with the legacy fields read off the primary membership afterward.Separately,
AudibleBookMetadatahad noSeriesAsinproperty at all (AudibleBookMetadata.cs:32-33), even though the frontend has been sendingseriesAsinon the add payload. The value was dropped at the model boundary with nowhere to land. The property is added and passed at the four sites that already passSeriesandSeriesNumber:ToAudiobook(), the add workflow, the metadata rescan patch, and the path preview.This only changes what gets written when a book is added; existing rows keep what they have until a metadata refresh re-reads them.
Tests:
AuthorMonitoringServiceTestsandSeriesMonitoringServiceTestseach gain 3 added[Fact]cases (2 pre-existing, 5 total each), andAudibleBookMetadataTestsis a new file with 1 added[Fact]. All present at the bundle head.4. Library import keeps every series membership, not just the first (
90d892e9c, from #847)Three commits from the original PR, squashed into one.
matchToMetadatatook only the first entry of a search result's series array and never populatedseriesMemberships(libraryImport.ts:105), so the backend fell back to the legacy scalar path and a book in more than one series lost every membership past the first. The walk now lives once in a sharedbuildSeriesFieldsand is used by the add path, the unmatched-files bulk add (which had been reading Audnexus-shaped field names off an Audible-shaped payload and silently falling back to the series parsed from file tags), and the product-enrich step.A membership's
seriesAsinis kept only when it matches the real Audible ASIN shape,B0followed by eight alphanumerics (seriesUtils.ts). There was no guard on this field at all before this commit:matchToMetadata'sseriesAsin(libraryImport.ts:110) took whatever the search response put inseriesItem.asinand stored it verbatim, which is exactly how a series name could reach the database once this commit started sendingseriesAsinalong as a membership. This commit introduces the first guard for it, and ties it immediately to the real ASIN shape rather than a looser check, because Foundation, Bartimaeus and Peripheral are all real series whose name alone is ten characters, so a bare ten-character check would have let a name straight through the path it exists to block. That bare ten-character check does exist elsewhere in the same file, atlibraryImport.ts:409, but for an unrelated purpose, the manual search box's own ASIN-vs-title detection, not the series-ASIN guard. One of this commit's three squashed changes briefly pointed that detection at the same B0-restricted predicate as the series guard, which section 7 corrects.Tests:
fe/src/__tests__/libraryImport.store.spec.tsgains 4 added cases (7 pre-existing, 11 total in this commit).seriesUtils.spec.tsgains 4 added cases (4 pre-existing, 8 total).unmatchedFilesMapping.spec.tsis a new file with 2 added cases covering the field-name fix.5. Tell a failed catalog lookup apart from a confirmed zero-match (
b4b31fc2b, from #910)Half of #877, the half that does not wait on anything.
An Audible call that times out returns an empty result set identical to Audible answering "no such book": the per-call
CancellationTokenSourcefires,AudibleApiClientreturns null, andSearchProductsCoreAsyncturned that null intonew SearchProductsDirectResponse()with nothing to tell the two apart (AudibleProductSearchWorkflow.cs:223).SearchProductsDirectResponsenow carriesProviderUnavailable, set where the null used to be discarded.GET /search/audibleanswers 503 instead of a 200 that claims more than it knows. Read atAudibleApiClient.GetJsonDocumentAsync(AudibleApiClient.cs:60-98), "unavailable" is not only a timeout: it is set on any non-success HTTP status (so a 500, a 503 or a 429 all become this bundle's 503), on a response body that fails to parse as JSON, and on a request that never leaves at all (a connection failure), since all of those paths return null from the same method and null is what sets the flag.SearchProductsDirectAsyncalso skips its diacritics-stripped retry onceProviderUnavailableis set (AudibleProductSearchWorkflow.cs:179), since a confirmed failure is not worth retrying the same way an empty catalogue answer is. I have not found a frontend caller of this endpoint:api.ts:360'ssearchAudiblehas no reference anywhere else infe/src, so today this is a backend contract fix with no observable frontend effect, ready for whenever something calls it.The retry side of #877 is deliberately untouched, since the existing Polly policy already holds a cancelled token by the time a retry could fire, and the planned
AudibleApiClientrefactor in #635 is a better place to pick new per-attempt durations.POST /api/v1/searchstill collapses the two cases, so this is a partial answer to #877, not a close.Tests:
AudibleProviderUnavailableTests, a new file with 5 added[Fact]cases plus one[Theory]with three rows (500, 503, 429, each confirming the flag is set), andSearchControllerAudibleUnavailableTests, a new file with 2 added[Fact]cases. Both confirmed present at the bundle head.6. Spell a contributor's name without their job title (
43b6b8af2, from #1013)Audible has no role field, so a translator or editor arrives as an ordinary author name with the role appended (for example
"Constance Garnett - translator"), and that string got stored, displayed, and matched against as a person's name.AudibleBookMetadata.ToAudiobook()passedAuthorsstraight through (AudibleBookMetadata.cs:60), andFileNamingServicetookAuthors.First()with no role handling (FileNamingService.Helpers.cs:103).The fix strips the role suffix and keeps the person, rather than keeping only the first credit (byline order is editorial choice, not a guarantee) or dropping any role-bearing credit (a book credited only to its translator would then lose its only author). Credits that name a role are stored after the ones that do not, so
Authors[0]agrees with the primary-author decision by construction: four callers pickAuthors[0]off the stored row (the library path planner, the rename service, the manual import planner, the search result classifier), and without that ordering a book whose provider credits list a translator first could disagree between those four and the add path. The bracket predicate used to decide whether something is a role is split in two: a loose one that strips a tail, and a strict one that decides who wrote the book, so a bare parenthetical like"(Illustrated)"no longer demotes a real author to a contributor.Tests:
AuthorCreditsTests, a new file with 31 added[Fact]cases plus 2[Theory]methods covering 43 inline rows between them.FileNamingService_AuthorCreditTests, a new file with 7 added[Fact]cases.AudibleBookMetadataAuthorCreditTests, a new file with 8 added[Fact]cases. All confirmed present at the bundle head.7. Integration fix: decouple the manual search box from the series-ASIN guard (
9e2bb4ad5)Commit 4's B0-prefix guard was also reused, through
looksLikeAsin, by the manual search box's own ASIN-vs-title detection (libraryImport.ts:415at the bundle head before this fix). Real Audible product ASINs are not all B0-prefixed: some are ISBN-shaped ten-character values, for example1781105022. Typing one into the manual search box silently ran a title search instead of the ASIN lookup asked for.This restores the box's own loose ten-character check for query detection, separate from the B0-restricted guard that stays on the series-ASIN path.
Separately checked
matchToMetadata's otherseriesAsinfallback,seriesFields.seriesAsin ?? result.seriesAsin(libraryImport.ts:113): I don't think it can leak an unguarded value today. Read, not run: nothing in the backend currently sets a top-levelseriesAsinon a search result (I grepped for every assignment to a property namedSeriesAsinacrosslistenarr.apiandlistenarr.application, and none of them write to anything serialized as aSearchResult), and separatelyAudiobookSeriesMembershipHelper.Normalizeonly reads the legacy scalar when no named membership was built at all, so even a populated value would be ignored wheneverseriesMembershipscarries at least one named entry.A regression test pins the main fix:
searchItemwith a non-B0 ten-character query now reachesadvancedSearchas an ASIN lookup, not a title search. Measured failing against the code before this fix: it calledadvancedSearchwith{ title: '1781105022', cap: 5 }instead of{ asin: '1781105022', cap: 5 }. Passing after.8. Integration fix: strip role suffixes on the metadata rescan path too (
f0e1e1e5c)Commit 6's
AudibleBookMetadata.ToAudiobook()strips a role suffix before storage, but the metadata rescan patch (LibraryMetadataRescanWorkflow.Helpers.cs) wrotemetadata.Authorsstraight through. A rescan on a book whose credits the add path had already cleaned could put a role suffix back, and flipAuthors[0]back to the translator, since the ordering guarantee the add path relies on no longer held once the role returned.ApplyMetadataRescanPatchnow runs the normalized author list throughAuthorCredits.WithoutRoleSuffixes, the same helper the add path uses.A regression test pins it: rescanning with a provider response crediting the translator first now stores the author first with no role suffix, matching the add path's own test for the same credit pair. Measured failing against the code before this fix: the stored result came back as
["Constance Garnett - translator", "Fyodor Dostoevsky"]instead of["Fyodor Dostoevsky", "Constance Garnett"]. Passing after. The full rescan test class, 9 tests, still passes.9. Integration fix: correct a wrong ASIN citation in comments (
c133412a1)No runtime behaviour changes here, so no test; this is a comment correction only, which is why it is the one commit in this bundle without one.
A comment in
FileNamingService.Helpers.cs, a matching comment in each of two test files, and the XML doc remarks onAuthorCredits.StripRoleinAuthorCredits.cs(added by commit 6) all said B002V9ZF3K (Crime and Punishment) credits Dostoevsky then Garnett as a two-person byline. On the live catalogue B002V9ZF3K credits only Dostoevsky; there is no translator credit on that edition. The reversed-order example these comments exist to support is real on its own: at least one edition, B00EZAXAF8 (The Brothers Karamazov), credits the translator first, which is enough by itself to show that byline order is not reliable.Four places corrected, each now citing only B00EZAXAF8 with the actual order spelled out:
AuthorCredits.cs,FileNamingService.Helpers.cs,FileNamingService_AuthorCreditTests.cs, andAuthorCreditsTests.cs. Two other test comments used the same hypothetical credit pair with the translator listed second, an order that matches neither real ASIN I have, so I left those without a citation rather than attach one that would not be accurate either.On #953
#953 reports that series are resolved by display name in three places: the library grid's grouping key (
AudiobooksView.vue), the series page's identity lookup (CollectionView.vue,MetadataSeriesLookupWorkflow.cs,AudibleSeriesWorkflow.cs), and the hero label on that page. None of the nine commits here touch any of those four files (read: checked by file path against this bundle's diff). So I don't think this bundle closes #953. What it does is makeSeriesAsinland correctly on more of the paths that write it in the first place, which I think is a precondition for keying that grouping and that lookup on identifier rather than name, not a substitute for doing so.Merge order and dependencies
Commits 1 through 6 are in the order in the table above, and each also builds on its own: I cherry-picked each one alone onto
a630572e9and randotnet build, which succeeded for all six (measured).Commits 7 through 9 are integration fixes and genuinely depend on what came before them. The mechanical version of the same check shows this directly for two of them: commit 7 (exit 1, it corrects commit 4) and commit 9 (exit 1, it corrects commit 6) do not apply standalone onto
a630572e9. Commit 8 passes that same mechanical check (exit 0), but only because its diff happens not to touch any line that differs betweena630572e9and its real parent; it callsAuthorCredits.WithoutRoleSuffixes, which commit 6 adds, so it does not compile on its own: cherry-picked alone ontoa630572e9it fails withCS0103: The name 'AuthorCredits' does not exist in the current context(measured). All three need the bundle's earlier commits in front of them, in the order given.Commits 1 and 3 both edit
MetadataConverters.cs: a visibility change fromprivatetointernalonBuildSeriesMembershipsin commit 3, so it can be called from a different class, not a different assembly (both files already live inlistenarr.application). They merged without conflicts here. I built and ran the full suite with all nine together, not each alone.Default and behaviour changes
a630572e9,matchToMetadata'sseriesAsin(libraryImport.ts:110) had no validation of any kind; whatever the search response put there was stored as the identifier. Commit 4 adds the first guard for it, scoped to the real Audible ASIN shape. There is no Readarr equivalent I know of: Readarr does not carry an Audible-style ASIN on a series at all.getBookSeriesNames(AudiobooksView.vue:1381), already groups a book under every entry inseriesMembershipsand already existed ata630572e9; it was the data arriving from those three paths that only ever carried one series. Readarr keeps the same kind of multi-membership link on its own model:MapSeriesLinks(BookInfoProxy.cs:830at0b79d3000) adds aSeriesBookLinkfor every series resource that references a book, not only the first.series[0].titleand.parton a payload shapedasin/name/position), so the read silently failed and fell back to whatever the add path's file-tag parser guessed. I have no Readarr equivalent for this one; it is specific to how Listenarr's unmatched-files flow reconciles a catalogue response with file tags.AudiobookMetadataService.cs:105). I have no Readarr equivalent for this one either; it is a gap specific to Listenarr carrying two metadata sources (Audible and Audnexus) through one conversion surface.ContributorResourcecarries a separateRolefield alongside the name (ContributorResource.cs:5-6at0b79d3000), so the role never gets baked into the name string the way Audible's lack of a role field forces it to be here. Where Readarr does trust byline position for something else,Contributors.First()(BookInfoProxy.cs:527and:990at0b79d3000), section 6 above explains why I think that would be unsafe here: at least one edition in the catalogue data I looked at credits the translator first.GET /search/audiblecan now answer 503 instead of 200. No frontend caller exists today (section 5), so nothing currently observes this. Readarr and Sonarr both raise a dedicated exception carryingHttpStatusCode.ServiceUnavailablewhen their own metadata provider fails:BookInfoException(BookInfoException.cs:10at0b79d3000) andSkyHookException(SkyHookException.cs:10at76c684e09), both constructingNzbDroneClientExceptionwithServiceUnavailable. The family's answer to a failed metadata provider is the same status code, by a different mechanism (an exception that propagates, rather than a flag the caller reads).Overlap with other open PRs
Checked with
git merge-treeagainst every open PR whose head still merges intoa630572e9itself (93 of the 122 currently open; the rest are stale against that base and out of scope for this check). The maintainer's own #579, #611 and #841 do not merge intoa630572e9themselves, so none of the three is in scope for this bundle; #993 is covered below.SearchController.cs; both merge cleanly.AudiobookMetadataService.cs; merges cleanly.AudibleProductSearchWorkflow.csandAudibleService.cs; merges cleanly.libraryImport.tsand its test spec. The store file merges cleanly; the test spec conflicts, most likely both adding a case near the same point. Whichever lands second needs a small rebase on that one file, happy to do it.libraryImport.ts, its test spec, andUnmatchedFilesModal.vue; all three merge cleanly despite the overlap.Tests
At the bundle head, against stock canary at
a630572e9on the same machine:If you would rather take any of these as separate PRs, I will split them back out.
Disclosure: drafted with Claude Code at my direction; I read the cited code at the stated commit and reviewed this before posting.