Skip to content

Series: keep every membership and a real identifier on every path that adds one - #1020

Open
m4bard wants to merge 9 commits into
Listenarrs:canaryfrom
m4bard:bundle/series-and-metadata
Open

m4bard wants to merge 9 commits into
Listenarrs:canaryfrom
m4bard:bundle/series-and-metadata

Conversation

@m4bard

@m4bard m4bard commented Oct 6, 2026

Copy link
Copy Markdown
Contributor

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.

Commit Original PR Issue Effect
aeca3a88f #768 Fixes #767 Audnexus's own series ASIN is copied onto SeriesAsin for both the primary and secondary membership
fe20df2fb #986 Fixes #979 A synthesized search-fallback series entry reports no identifier instead of the series name
7f8028879 #961 Fixes #962 Monitoring adds keep every series membership and ASIN, and a legacy-only payload can carry seriesAsin again
90d892e9c #847 Fixes #846 Library import keeps every series membership, not just the first, and only keeps a seriesAsin that looks like one
b4b31fc2b #910 Refs #877 A failed Audible catalog lookup now answers 503 instead of an empty result indistinguishable from a real zero-match
43b6b8af2 #1013 none A translator or editor's role is stripped from the stored author credit, keeping the person
9e2bb4ad5 integration none Fixes a regression commit 4 caused in the manual search box's own ASIN detection
f0e1e1e5c integration none Fixes a gap in commit 6: a metadata rescan could put a role suffix back after the add path removed it
c133412a1 comment only none Corrects a wrong ASIN citation in comments, including one commit 6 added

All file:line references below are at a630572e9 unless I say otherwise.

1. Populate SeriesAsin from the Audnexus series record (aeca3a88f, from #768)

AudnexusSeries carries Asin, Name and Position, but ConvertAudnexusToMetadata mapped only Name and Position (MetadataConverters.cs:141-163 at the base commit), so AudiobookSeriesMembership.SeriesAsin was 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, so SeriesAsin is the only thing that makes SeriesNumber mean anything durable.

The conversion now goes through AudnexusSeriesMapper.ToAudibleSeries and the same BuildSeriesMemberships the 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 an AudibleBookResponse never assigned Series at 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). AudiobookMetadataServiceTests gains 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's asin field instead of leaving it null (SearchResponseMapper.cs:256), while the author fallback three lines above already emits asin = null for the same reason (:253). Three consumers now read a series ASIN off search results into the database: AudiobookSeriesMembership.SeriesAsin, the library-import wizard, and AudibleBookMetadata.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 setting SeriesMemberships (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 through MetadataConverters.BuildSeriesMemberships, the same helper the search path uses, with the legacy fields read off the primary membership afterward.

Separately, AudibleBookMetadata had no SeriesAsin property at all (AudibleBookMetadata.cs:32-33), even though the frontend has been sending seriesAsin on 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 pass Series and SeriesNumber: 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: AuthorMonitoringServiceTests and SeriesMonitoringServiceTests each gain 3 added [Fact] cases (2 pre-existing, 5 total each), and AudibleBookMetadataTests is 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.

matchToMetadata took only the first entry of a search result's series array and never populated seriesMemberships (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 shared buildSeriesFields and 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 seriesAsin is kept only when it matches the real Audible ASIN shape, B0 followed by eight alphanumerics (seriesUtils.ts). There was no guard on this field at all before this commit: matchToMetadata's seriesAsin (libraryImport.ts:110) took whatever the search response put in seriesItem.asin and stored it verbatim, which is exactly how a series name could reach the database once this commit started sending seriesAsin along 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, at libraryImport.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.ts gains 4 added cases (7 pre-existing, 11 total in this commit). seriesUtils.spec.ts gains 4 added cases (4 pre-existing, 8 total). unmatchedFilesMapping.spec.ts is 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 CancellationTokenSource fires, AudibleApiClient returns null, and SearchProductsCoreAsync turned that null into new SearchProductsDirectResponse() with nothing to tell the two apart (AudibleProductSearchWorkflow.cs:223).

SearchProductsDirectResponse now carries ProviderUnavailable, set where the null used to be discarded. GET /search/audible answers 503 instead of a 200 that claims more than it knows. Read at AudibleApiClient.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. SearchProductsDirectAsync also skips its diacritics-stripped retry once ProviderUnavailable is 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's searchAudible has no reference anywhere else in fe/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 AudibleApiClient refactor in #635 is a better place to pick new per-attempt durations. POST /api/v1/search still 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), and SearchControllerAudibleUnavailableTests, 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() passed Authors straight through (AudibleBookMetadata.cs:60), and FileNamingService took Authors.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 pick Authors[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:415 at the bundle head before this fix). Real Audible product ASINs are not all B0-prefixed: some are ISBN-shaped ten-character values, for example 1781105022. 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 other seriesAsin fallback, 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-level seriesAsin on a search result (I grepped for every assignment to a property named SeriesAsin across listenarr.api and listenarr.application, and none of them write to anything serialized as a SearchResult), and separately AudiobookSeriesMembershipHelper.Normalize only reads the legacy scalar when no named membership was built at all, so even a populated value would be ignored whenever seriesMemberships carries at least one named entry.

A regression test pins the main fix: searchItem with a non-B0 ten-character query now reaches advancedSearch as an ASIN lookup, not a title search. Measured failing against the code before this fix: it called advancedSearch with { 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) wrote metadata.Authors straight through. A rescan on a book whose credits the add path had already cleaned could put a role suffix 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.

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 on AuthorCredits.StripRole in AuthorCredits.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, and AuthorCreditsTests.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 make SeriesAsin land 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 a630572e9 and ran dotnet 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 between a630572e9 and its real parent; it calls AuthorCredits.WithoutRoleSuffixes, which commit 6 adds, so it does not compile on its own: cherry-picked alone onto a630572e9 it fails with CS0103: 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 from private to internal on BuildSeriesMemberships in commit 3, so it can be called from a different class, not a different assembly (both files already live in listenarr.application). They merged without conflicts here. I built and ran the full suite with all nine together, not each alone.

Default and behaviour changes

  • The series-ASIN guard is new, not tightened. At a630572e9, matchToMetadata's seriesAsin (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.
  • Books added by monitoring, library import or an Audnexus lookup now appear under every series tile they belong to, not just one. The frontend's own grouping, getBookSeriesNames (AudiobooksView.vue:1381), already groups a book under every entry in seriesMemberships and already existed at a630572e9; 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:830 at 0b79d3000) adds a SeriesBookLink for every series resource that references a book, not only the first.
  • The unmatched-files bulk add now stores the series name the catalogue gave it, rather than one re-parsed from file tags. It was reading field names that do not exist on the response it had (series[0].title and .part on a payload shaped asin/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.
  • With the Audnexus source, a book can now gain series data on the interactive add and on a rescan where it previously gained none at all. Covered in section 1 above (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.
  • Stored author credits lose their role suffix, going forward. A row written before this bundle lands keeps its role-suffixed credit until it is re-added or rescanned, so for a while the same real person can show as two different author-group entries, "Constance Garnett - translator" on an old row and "Constance Garnett" on a new one, until a rescan catches up the old one. Readarr does not strip a role from a credit at all, and does not need to: its ContributorResource carries a separate Role field alongside the name (ContributorResource.cs:5-6 at 0b79d3000), 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:527 and :990 at 0b79d3000), 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/audible can 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 carrying HttpStatusCode.ServiceUnavailable when their own metadata provider fails: BookInfoException (BookInfoException.cs:10 at 0b79d3000) and SkyHookException (SkyHookException.cs:10 at 76c684e09), both constructing NzbDroneClientException with ServiceUnavailable. 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-tree against every open PR whose head still merges into a630572e9 itself (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 into a630572e9 themselves, so none of the three is in scope for this bundle; #993 is covered below.

Tests

At the bundle head, against stock canary at a630572e9 on the same machine:

canary this bundle
Backend 3118 passed, 0 failed, 130 skipped, 3248 total 3236 passed, 0 failed, 130 skipped, 3366 total
vue-tsc clean clean
vitest 607 passed in 89 files 624 passed in 90 files

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.

m4bard and others added 9 commits October 2, 2026 19:20
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment