Skip to content

chore: sync with upstream canary and merge 25 open upstream fixes - #9

Merged
krejko merged 63 commits into
canaryfrom
chore/sync-upstream-fixes
Aug 26, 2026
Merged

chore: sync with upstream canary and merge 25 open upstream fixes#9
krejko merged 63 commits into
canaryfrom
chore/sync-upstream-fixes

Conversation

@krejko

@krejko krejko commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

Syncs the fork with upstream/canary and pulls in ten open upstream PRs that fix
bugs this deployment will actually hit. Nothing fork-only changes here.

Upstream catch-up

Merges upstream/canary (3 commits, including Listenarrs#864 weak-storage moves). The only
conflicts were the version numbers — the fork is on 1.4.0, upstream on 1.3.3 — and
1.4.0 is kept everywhere.

Note this brings a new EF migration with it: 20260821141235_AddCompatibilityFilePublication.

Upstream PRs merged

Linux/container correctness — each verified present in this tree before merging:

PR Fixes
Listenarrs#849 On Linux the lease's metadata path is /proc/<pid>/fd/<n>. The scan collapsed both halves onto it, so the extension guard rejected the file and the embedded-metadata pass never ran; FileInfo on the magic link also reports a constant 64 bytes
Listenarrs#902 ClonePhysicalGeneration re-applied a DB-loaded timestamp that SQLite materializes as Unspecified, tripping the UTC guard — any rescan of a book with an existing physical identity threw ArgumentException and failed the scan job
Listenarrs#847 Library Import collapsed a multi-series match to series[0], so the backend took the legacy scalar path and stored one membership

Scan and import quality, ahead of the first large library scan:

PR Fixes
Listenarrs#784 Tolerant filename matching — dropped leading "The", author credentials, omitted subtitles. Those files previously matched nothing and Scan Folder appeared to do nothing
Listenarrs#781 Reads the ----:com.apple.iTunes:ASIN atom during scan, so Reload Metadata works on already-tagged files
Listenarrs#845 Groups chapter-per-file books numbered 001 of 006 into one scan item instead of six
Listenarrs#843 ASIN embedding after import was a silent no-op (write-only handle plus a CombinedTag type test that never matched)
Listenarrs#866 Startup reconciliation named only the first blocking legacy journal
Listenarrs#768 Populates SeriesAsin from the Audnexus series record
Listenarrs#763 Non-numeric series positions ("1-4", "1.5") were lost through a decimal and reached filenames

Deliberately not taken

Listenarrs#901 fixes the same 64-byte size bug as Listenarrs#849 by a different route and conflicts
with it. Listenarrs#849 is the broader fix and covers both size sites, so Listenarrs#901 adds nothing here.

Listenarrs#892 / Listenarrs#893 (invariant-culture parses) only bite when LANG/LC_ALL is set; the
container runs invariant.

Download-client and indexer fixes (Listenarrs#840, Listenarrs#837, Listenarrs#830, Listenarrs#868, Listenarrs#871, Listenarrs#876, Listenarrs#863, Listenarrs#832)
are real but we have not configured a client yet. Worth revisiting then — Listenarrs#840 in
particular strands SABnzbd downloads permanently.

Listenarrs#878 (weak-storage safety, +1833/-461) is held: large, layered on Listenarrs#864, and worth
understanding on its own before merging, since unraid's /mnt/user is a FUSE overlay
and the weak-storage identity path plausibly applies to us.

Conflict resolutions

Three merges needed hand-resolution:

Verification

  • dotnet format --verify-no-changes clean
  • Backend suite in the Linux dev container (the host is useless here — macOS arm64
    fails 687 filesystem tests on the platform gate regardless of this branch)
  • vue-tsc --build tsconfig.app.json clean
  • Frontend suite: 89 files, 600 tests passing

Also confirmed feat/collection-authors-and-series-grouping still merges cleanly on
top of this branch.


Second batch: indexer and download-client fixes

Merged after the first batch, on request: Listenarrs#756, Listenarrs#757, Listenarrs#755, Listenarrs#759, Listenarrs#840, Listenarrs#837, Listenarrs#830,
Listenarrs#772, Listenarrs#806, Listenarrs#863, Listenarrs#868, Listenarrs#871, Listenarrs#876, Listenarrs#832.

Two matter regardless of which client we end up configuring:

One conflict, in tests/Mocks/Api/SabnzbdApiMock.cs: Listenarrs#837 and Listenarrs#759 each added a
branch to the same mode dispatch chain (addfile vs get_cats). Both kept.

Listenarrs#760 was left out. It is the most relevant of the batch — profile-less adds are
silently never searched — but it is CONFLICTING/DIRTY against upstream itself and
conflicts here across LibraryAddWorkflow, LibraryAddService, ILibraryAddService
and StartupDbNormalizer. Rebasing someone else's stale PR through the add path does
not belong in a sync PR.

Listenarrs#803 (MyAnonamouse freeleech wedge) was left out as a feature that spends account
currency, not a fix.

Verification status

Frontend is verified: vue-tsc clean, 601 tests passing.

The backend suite could not be run locally. macOS fails 687 filesystem tests on the
platform gate, and running in the Linux dev container against a bind-mounted macOS
worktree fails differently (Interop.ThrowExceptionForIoErrno across the FileMover
and relocation suites) before hanging outright — Docker Desktop's FUSE mount does not
provide the openat/hardlink/rename semantics those tests exercise.

run-tests.yml on ubuntu-24.04 is the real verification for this branch.

t3chnaztea and others added 30 commits July 12, 2026 21:48
…randing deleted downloads and blocking re-grabs with 409

Deleting a torrent directly in the client when it was the only active
download left its Download record stranded forever, and every re-grab
returned HTTP 409 "already active". RemoveOrphansAsync bailed whenever a
snapshot returned 0 items while downloads were tracked, on the theory the
client might be unreachable, but that condition is already fully excluded
upstream by the UsedCachedSnapshot and IsUnavailable guards: the poller
surfaces every timeout/cancel/error as a cached or unavailable snapshot,
never as a live empty one, so a live empty queue genuinely means the items
are gone. Removing the redundant guard lets cleanup proceed on live empty
snapshots while preserving the Listenarrs#640 protections unchanged: unreachable
clients are still skipped, ImportPending/Ready/ImportBlocked are never
terminalized (only Queued/Downloading/Paused are considered), and the
per-item 5-minute grace period still shields fresh adds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…er's results

An HttpClient timeout surfaces as TaskCanceledException, a subclass of
OperationCanceledException. The per-indexer catch in the parallel search loop
excluded OperationCanceledException, so a single slow/timing-out indexer's
exception escaped the catch, propagated out of Task.WhenAll, and discarded every
healthy indexer's results for the whole search (zeroing the automatic cycle for
the book). No workflow-level cancellation token flows into SearchIndexersAsync,
so any OCE there is a per-request timeout: catch it per-indexer, log a warning
naming the indexer, and return an empty result for that indexer only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…s into the real grab pipeline

When no indexers were configured/enabled, SearchIndexersAsync returned
GenerateMockIndexerResults: five synthetic releases (fabricated titles,
sizes, and magnet/NZB URLs) that flowed into real scoring and the
automatic-grab decision and could be handed to a download client. Return
an empty result set with a clear warning instead, and remove the now-dead
GenerateMockIndexerResults helpers. Nothing in the frontend or tests
depended on them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…o Default, stranding them unimported; warn when category missing

SABnzbd silently reassigns jobs with an unknown category to Default (*), but
every SABnzbd read mapper filtered slots by the configured category before
matching nzo_id, so a job Listenarr itself grabbed became invisible ("tracked
but 0 in client") and never imported. The queue/history mappers now bypass the
category filter for any slot whose nzo_id is in the monitored set, reconciling
grabbed items by download ID while category filtering still scopes untracked
discovery. The connection tester also probes mode=get_cats and returns an
advisory (not a hard failure) when the configured category is absent from
SABnzbd, so misconfiguration surfaces before jobs strand.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
qBittorrent's download client only had Host/Port, so it always connected at the
root path and couldn't reach instances served behind a path-prefixed reverse
proxy (Transmission already had this field). Unlike Transmission's urlBase,
which replaces the whole RPC path to match Transmission's own configurable
--rpc-url-base daemon setting, qBittorrent has no equivalent server-side base
path setting (upstream qbittorrent/qBittorrent#21471 and #23467 are both
unmerged), so this prepends urlBase as a plain prefix before the fixed
/api/v2/... routes instead of replacing them.

Closes Listenarrs#690.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
All filter options (Active, Freeleech, FreeleechOrVip, Vip, NotVip) were
sending unrecognised boolean parameters that MaM silently ignores, causing
filters to have no effect on search results.

MaM's API uses a single tor[searchType] parameter with specific string
values. Replace the five separate boolean parameters with the correct
tor[searchType] values matching Prowlarr's working implementation:

  Active         -> tor[searchType]=active
  Freeleech      -> tor[searchType]=fl
  FreeleechOrVip -> tor[searchType]=fl-VIP
  Vip            -> tor[searchType]=VIP
  NotVip         -> tor[searchType]=nVIP

Empirically confirmed: Not VIP filter now returns 1 result instead of 5
(including 3 VIP torrents) for the same query.

Fixes Listenarrs#805
…e poll

A throw while mapping torrent N escaped the loop walking the response, so
torrents N..end were dropped while the poll still reported itself as a healthy
live snapshot. The queue simply looked shorter, with nothing to say a row had
been lost, and the only warning claimed the client might be unreachable when it
had answered fine and answered completely.

Guard each torrent individually in both loops, logging the hash and continuing.
SabnzbdQueueFetchWorkflow and TransmissionQueueFetchWorkflow already do exactly
this, so qBittorrent converges on what its two closest neighbours share rather
than introducing a third approach.

The hash read now checks ValueKind before GetString(), so a non-string hash
cannot throw before the guard is entered.

Refs Listenarrs#829
Release titles containing " (very common in usenet subject lines,
e.g. embedded sub-titles) crashed the SABnzbd add-file submission with
System.ArgumentException from ContentDispositionHeaderValue, because
GenericUsenetSourceResolver.SanitizeFileName() only stripped
filesystem-invalid characters and left '"'/'\\' untouched. Those are
valid on Linux/macOS filesystems but break the multipart
Content-Disposition "filename" quoting used when submitting to
SABnzbd, so the download silently never reached the client.

Fixes Listenarrs#808.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
SABnzbd briefly reports an item as status "Completed" in its active
queue before archiving it to history with the real storage path.
MapQueueSlotToQueueItem mapped that slot into a completed QueueItem
regardless, which let QueueItemConverter mark the download Completed
with an empty DownloadPath - permanently blocking import with
"Inconsistency: Download {id} has no path set", since nothing ever
backfills the path afterward.

When status is "completed" but no storage path is present yet, return
null instead of a pathless completed item. Excluding it from the
active-queue result set makes the poller treat the download as
missing, which triggers a same-cycle history lookup - and history
reliably has the storage path by then. This resolves the download via
the code path that already works correctly, rather than completing
early on unreliable telemetry.

Fixes Listenarrs#839.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
ExtractTitleStem strips a leading track number and a trailing
Part/CD/Disc/Chapter/pt number, but nothing recognises an "N of M" index.
A book split as "Title 001 of 498.mp3" therefore produces a distinct stem
per file, and BuildStemGroups returns one group per file, so Library
Import asks the operator to identify the same book once per chapter.

Unlike a bare "(N)", this form is not ambiguous with the series marker
the existing comment describes: it carries its own total, so it names a
part of a set rather than one work among several.

The strip runs before the leading-track strip. A file named
"001 of 498.mp3" would otherwise lose its leading "001 " first and be
left as "of 498", which is both different in every file and no longer
numeric, so it would never reach the folder-name fallback that exists to
group exactly this case.

Titles are unaffected where the pattern does not fit: "of" needs digits
on both sides, so "Jack of Shadows.mp3" keeps its stem, and a trailing
plain number still separates two works in one folder.
matchToMetadata collapsed a search result's series array to its first entry and
sent only the legacy scalar series/seriesNumber. AudibleBookMetadata already
carries SeriesMemberships, and /library/add applies it via
AudiobookSeriesMembershipHelper.ApplyToAudiobook, falling back to the single
scalar only when no memberships are supplied. So a book in more than one series
lost every membership after the first on this path, while the Add New path
(AddLibraryModal) preserved them.

Build one membership per series entry, ordered, with the first marked primary.
The scalar fields stay populated from the primary so nothing downstream changes.

The search endpoint's fallback branch fills a series entry's asin with the
series name when it cannot re-fetch the book by ASIN. That value used to be
discarded because AudibleBookMetadata has no top-level SeriesAsin; a membership
would persist it, so only a value shaped like an ASIN is kept.
Two independent faults stop the post-import ASIN write from reaching the file. The
import reports success either way, because the enrichment step is deliberately
non-fatal, so neither is visible without reading the destination file's tags.

The stream is write-only. TagLibAudioTagWriter's file abstraction hands TagLib the
lease's metadata write stream, which reaches OpenIndependentWriteStream and opens
O_WRONLY on Unix, or NtCreateFile without GenericRead on Windows. Mpeg4.File.Save()
parses the existing box headers through that same stream before it writes, so it
throws NotSupportedException mid-parse. Open read+write instead, via a new
UnixOpenFlags.OpenReadWriteNoFollow() alongside the existing OpenWriteNoFollow().

The tag lookup never matches on MPEG-4. ApplyAsinTag tests `file.Tag is AppleTag`,
but an MPEG-4 file's Tag is a CombinedTag wrapping the Apple tag, so the branch is
never taken. The Id3v2 and Xiph branches do not match an m4b either, so Save()
rewrites an unchanged file and the writer logs success. Ask for the tag by type
instead. Only MPEG-4 answers to Apple, so mp3 and flac fall through as before.

The second fault matters for how the first is judged: fixing only the stream turns
a logged failure into a silent one. That was observed on a build carrying just the
stream change, not predicted.

Pinning is unaffected. The handle is still opened relative to the pinned parent,
still refuses to follow a link, and is still checked against the validated file
object. Widening the access mode does not widen what the lease will open.

Rebuilt on top of Listenarrs#828, which replaced the hardcoded open-flag constants with
UnixOpenFlags. The new method inherits that commit's per-architecture noFollow
detection rather than reintroducing a literal.
… parallel

ScoreSearchResults fans out with Task.WhenAll, and each task built a
SearchResultScorer over the same scoped IIndexerRepository and queried it for the
result's indexer. IIndexerRepository is scoped and the ListenArrDbContext behind
it is scoped, so a batch of N results issued N concurrent queries against one
context. EF rejects a second operation started on a context while another is in
flight.

The symptom is not a failed request. The scorer catches the exception and logs at
Debug, leaving indexerRetention at 0 and skipping the Usenet detection that sets
isNzb. So age and retention checks silently score against the wrong assumptions
for whichever results lost the race, and the batch comes back plausibly ordered
and quietly wrong.

Resolve each distinct indexer once, sequentially, before fanning out, and pass the
results into the scorer. Three callers reach this: the quality profile controller,
download submission, and the six-hour automatic search sweep.

This is also fewer queries rather than merely safer ones. A batch commonly carries
many results across a handful of indexers, so it goes from one query per result to
one per distinct indexer. IndexerSearchWorkflow already fetches its indexers before
its own fan-out, so this makes the two agree.

The single-result ScoreSearchResult keeps its old behaviour and still queries,
since there is nothing to batch there.

Asserting on the EF exception would mean racing it, so the test counts the overlap
directly: a stub repository records the highest number of calls in flight at once.
It reports 12 without the change and 1 with it, and also pins the query count at
one per distinct indexer rather than one per result.
…he first

A file-mutation journal left on an older protocol version disables filesystem
mutations for the whole application. Scan, import and move all return 503 and the
startup error is logged once per restart. There is no in-app route to clear the
state: git grep FileMutationJournal across listenarr.api returns nothing, so this
exception message is the entire brief an operator gets.

It named unsupported[0] only, while the query that produced it collects every
affected journal and the update marks every one of them NeedsAttention. With three
stuck rows an operator learns about one, resolves it, restarts, and meets the next,
with no way to know how many remain.

Report the count and the identifiers, capped at ten with a remainder, and fold the
stored Error text into the message so the reason travels with the list rather than
living only in a column nothing surfaces.

This does not add a repair mechanism and does not change the refusal. Refusing to
proceed on a state that cannot be safely resumed is the right instinct and is
deliberate. What is missing is a way out, and designing that is a maintainer's call
rather than something to infer. This makes the position knowable in one restart
instead of N while that is decided.
…l of them

AddDownloadClientHttpClients built a single circuit breaker and passed the same
instance to the generic DownloadClient registration and to all four adapter clients.
Polly's circuit breaker is stateful: the open and closed state and the failure count
live in the policy object. Five registrations sharing one instance is one global
breaker, not five, so three transient failures against any one client opened the
circuit for every other one, including client types the deployment may not use.

It compounds. Polly's simple breaker returns straight to Open on a single failed
half-open trial, and with the breaker shared, that trial can be consumed by any of
the five clients' next poll. Intermittent flakiness on one can keep re-tripping the
breaker and starve the rest long after the original trigger cleared.

Each client now builds its own via CreateCircuitBreakerPolicy(). The retry policy is
deliberately still shared, because a retry policy is stateless and sharing one is
both correct and how Polly is intended to be used. Only the stateful policy needed
splitting, and the comment says which is which so the difference is not read as an
oversight later.

The test drives the policies directly rather than through HttpClient. Going through
the named clients would also pass through the retry policy, whose backoff is 2, 4
and 8 seconds, so opening a breaker that way costs about 45 seconds for a property
observable in milliseconds. It asserts the instances differ, opens the first, and
then asserts the second still executes.
…per item

DownloadClientGateway.GetQueueAsync fans out over every queue item, and each item
translated its paths by calling IRemotePathMappingService.TranslatePathAsync, which
queries the repository for that client's mappings on every call. The service, the
repository and the ListenArrDbContext behind them are all scoped, so a queue of N
items issued up to 2N concurrent queries against a context that permits one at a
time. DownloadClientQueuePoller then runs that whole thing inside its own
Task.WhenAll across every enabled client, so the fan-out is nested.

This is the origin of the trace in upstream Listenarrs#783. The exception surfaces at
DownloadClientQueuePoller.FetchAsync because that is where the await unwinds; the
class itself holds no repository and no context and never did.

Split the lookup from the translation. IRemotePathMappingService gains a
TranslatePath overload that takes mappings the caller has already resolved and does
no I/O, and TranslatePathAsync keeps its behaviour by fetching and delegating to it.
The gateway resolves once per client before each fan-out and passes the result down.
The single-item import path also resolves once, where it is one query either way.

Asserting on the EF exception would mean racing it, so the test counts overlap
directly: a stub records the highest number of lookups in flight. Ten items carrying
two translatable paths each report 10 concurrent lookups without the change and 1
with it, and the lookup count is pinned at 1 so a regression fails even if it
somehow avoids overlapping.
…d on

Importing indexers from a Prowlarr instance that runs under a URL base stored
every proxy URL without that base. The discovery request to /api/v1/indexer is
redirected onto the base and Listenarr follows it, so the import reports success,
but each indexer's URL was composed from the address that was typed in rather
than the one that answered.

SendWithValidatedRedirectsAsync already returns the final URI and the workflow
discarded it. Keep it, strip the discovery path off the end, and use what is
left as the base for BuildProxyUrl and for the tag lookup.
Fold the leading-article tolerance from the previous version of this PR into
Listenarrs#717's new book-boundary matcher: SegmentMatchesExpectedTitle now treats a
segment and an expected title as equal when they differ only by a leading
article ("The"/"A"/"An") on either side, so a folder "Language of Emotions"
still attributes to the audiobook "The Language of Emotions" (and vice versa).

This stays a full-title equality modulo the article -- it does not reintroduce
substring or author-based matching -- so the same-author / different-book
boundary guards are preserved. Covered by three new ScanFileDiscoveryTests
(dropped article, added article, and a same-author sibling-book guard).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(imports): support weak storage moves

- copy and retain sources when durable identity is unavailable
- journal compatibility publications and recover registration state
- disclose effective move policy across API and UI

* test(storage): isolate mount probe tests

- Keep identity-state tests independent of Linux mount probing
- Preserve dedicated coverage for storage access capability

* fix(storage): capture recovery owner id

- Avoid re-reading a nullable journal property inside the EF query
- Make committed-publication ownership checks statically non-null
[skip ci] Merge: Bump version to 1.3.3
… the book folder

ManualImportCompanionImporter passed destinationRoot to EnsureCreatedHierarchyAsync as the
managed boundary. destinationRoot is DetermineScanPath over the batch's destination paths,
so for a single-book import it is the book folder. LibraryDirectoryOwnershipBoundaryAuthorizer
matches the boundary against configured root folders by equivalence rather than containment,
so it refused every companion with 'The requested directory boundary is not a configured root
folder'. The exception was caught and logged as a warning, so the import reported success and
silently dropped the companions.

Select the boundary the way the primary audio file's import already does, via
LibraryDirectoryOwnershipPlanning.SelectMostSpecificBoundary over the configured roots, falling
back to the destination resolution's boundary path. The authorizer is unchanged and keeps its
existing strictness; the companion is now authorized against the same root as the audio file it
sits beside.
…p store

The four existing companion tests all mock ILibraryDirectoryOwnershipStore with
It.IsAny<string>() for the boundary, so the real authorizer never runs and nothing in the
suite could tell the book folder from the root folder. All five pass unchanged either way,
which is why this shipped.

ImportAsync_ManagedBoundaryIsTheConfiguredRootFolderRatherThanTheBookFolder captures the
boundary argument instead of the outcome and asserts it is the configured root. It fails
with destinationRoot restored ("library/Author/Book" against the expected "library") and
passes with the fix. The four existing call sites gain the new rootFolders argument.
…introduced

Listenarrs#864 reshaped the companion pass. It now chooses between EnsureCreatedHierarchyAsync
and EnsureAdditiveHierarchyAsync on the publication plan, and with no capability
resolver injected a non-durable source takes the additive branch, which this test
never stubbed. Capture the boundary from both so the assertion holds whichever runs.

It also routes publication through IFileMover.PrepareActionForRegistrationDetailedAsync
and a registration lease. Standing that up would mean asserting the mock graph rather
than the boundary, which is the one thing this test exists to pin and which is captured
before publication is attempted. The count assertion goes, with the reason recorded in
place; ManualImportCompanionOwnershipTests still covers the end-to-end path.

Verified by reintroducing only the buggy argument: the store then receives the book
folder rather than the root folder, and this test fails on exactly that difference.
…ed mappings too

The batch lookup added alongside this only covered RemotePath and ContentPath. The
SourceFiles loop still called TranslatePathAsync, which queries the repository for the
client's mappings on every file. That loop is sequential within an item, but it runs
inside GetQueueAsync's Task.WhenAll over items, so the overlap the batch lookup was
meant to remove is still there whenever items carry source files.

qBittorrent's queue mapper populates SourceFiles from the torrent's file list
(QbittorrentResponseMapper.MapQueueItem), and Transmission's does the same, so this is
the normal case on a torrent client rather than an edge case.

The new test covers items carrying source files, which the existing one did not: it
reports 10 concurrent lookups without this change and 1 with it.

DownloadClientGatewayTests' mapping mock only stubbed TranslatePathAsync, so it went
stale when the source-file loop moved onto TranslatePath and returned null for every
path. Stub both.
The registration lease deliberately separates stable byte access (ReadPath)
from public media identity (PublicPath). On Linux the lease's metadata path
is a /proc/{pid}/fd/{fd} descriptor link, and two consumers treat it as if
it were the file.

The scan's embedded-metadata pass called the single-path overload of
ExtractFileMetadataAsync, which builds MetadataFileSource(path, path). The
probe guard tests the public half for an audio extension, a descriptor link
has none, and so the candidate was rejected before ffprobe ran. That pass is
the fallback for candidates path attribution could not claim, so on Linux a
correctly tagged file in an unrecognised folder shape could never be claimed
by any route.

The registered length was stat'ed from the same descriptor path. Stat on the
link reports the length of the link rather than of its target, a constant 64
bytes, so every registered file on Linux recorded Size = 64. Reading the
length from the pinned handle keeps the lease's generation guarantee, since
it never consults the visible path.

Refs Listenarrs#818
Both of these mocked only the single-path overload of ExtractFileMetadataAsync, so
once the scan routes through MetadataFileSource the strict mock saw no matching
setup, the extractor returned nothing, and every file read as unreadable. That
turned a passing suite into two failures that looked like behaviour regressions
and were not.

ScanAsync_CaseDistinctMetadataFolders_RemainConflicting just needed the overload.

ScanAsync_MetadataReplacementAndRestore_ReadsPinnedFileGeneration needed the
overload plus a decision about which half of the source its callback reads. It
reads ReadPath, because the point of the test is that the scan sees the original
generation even while the visible file is swapped underneath it.

It now also asserts the other half. PublicPath must still be the candidate as a
person sees it, extension included, because on Linux ReadPath is a /proc descriptor
link with no extension and anything deriving media identity from it loses the
extension entirely. Confirmed load-bearing by collapsing both halves onto the
descriptor path: the assertion fails with the real path expected and
/proc/<pid>/fd/<fd> observed, which is the defect this branch exists to fix,
previously only demonstrable against a running container.
Ported onto Listenarrs#717, where BuildNamingMetadata has moved into
DownloadImportService.Naming.cs and the culture-dependent parse is
reintroduced there.

A position arrives from Audnexus as a string and is not always a decimal:
an omnibus sits at "1-4", a prequel at "0", a novella at "1.5". Squeezing
it through decimal.TryParse loses the ones that do not parse, and naming
then falls through to the track number and writes that into the filename
as if it were the series number. The parse also used the server's culture,
so "1.5" read as 15 wherever '.' is the group separator.

SeriesPositionRaw keeps the original string, naming prefers it, and the
remaining decimal parses are pinned to InvariantCulture.

On the import side the {SeriesNumber} token is built by a small helper in
DownloadImportService.Naming.cs rather than written inline.
DownloadImportService.cs is already at the 500 line architecture cap, and
the naming partial is where the rest of this logic lives.
AudnexusSeries carries Asin, Name and Position. ConvertAudnexusToMetadata
mapped the name and the position but dropped the ASIN, so
AudiobookSeriesMembership.SeriesAsin was null for every audiobook even though
the column exists for it and the repository preserves it when set.

The series ASIN is the stable identifier for a series. A book ASIN is not: it
is per-marketplace and per-narrator, and the same work carries different book
ASINs in different catalogues. A series name is free text that varies between
editions and translations. The series ASIN is stable across both, so
(SeriesAsin, SeriesNumber) is the durable way to say "book N of that series".

Both the primary and the secondary membership now carry it.

The test class inherits BaseTests and carries the Name and Category traits
that BackendArchitectureTests.TestClasses_FollowRepositoryConventions now
requires. That test landed after this branch was cut.

Fixes Listenarrs#767
Rework of this PR onto Listenarrs#717's rewritten file-registration flow.

- FfprobeTagMetadataMapper now reads ASIN / AUDIBLE_ASIN / ISBN tags into
  AudioMetadata, so a scanned file's embedded identifier is available during
  registration.
- New AudiobookFileService.IdentifierAdoption partial adopts that identifier
  onto a bare audiobook from INSIDE the per-audiobook operation lock
  (EnsureAudiobookFileCoreAsync), so the write cannot race file ownership or a
  concurrent audiobook update. A unanimity guard refuses to adopt when the book's
  linked files carry disagreeing ASINs (a sign of mis-attribution).
- The upstream metadata refresh (Audible lookup) runs AFTER the lock is released
  -- signalled out via a StrongBox -- so the network call never holds the global
  filesystem lock. It fills only empty fields and never fails the scan. Restores
  the IAudiobookMetadataRefreshService dropped in the rebase.

Tests: FfprobeTagMetadataMapper (incl. AUDIBLE_ASIN dialect + no-overwrite),
AudiobookMetadataRefreshService.FillMissingFields, and the coordination test
updated for the new constructor dependency. Build + suite green, no regressions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
krejko added 21 commits August 26, 2026 12:38
…xed as "N of M" into one unmatched-scan item
…urnal blocking startup, not just the first
… scan

# Conflicts:
#	listenarr.infrastructure/DependencyInjection/Metadata/MetadataRegistrationExtensions.cs
…ies membership, not just the first

# Conflicts:
#	fe/src/__tests__/libraryImport.store.spec.ts
…rs returned five fake mock releases into the real grab pipeline
… longer discards every other indexer's results
…ped live empty-queue snapshots, stranding deleted downloads and blocking re-grabs with 409
…lter hid tracked jobs reassigned to Default, stranding them unimported
…th no path set (race between active queue and history)
… containing quote characters

# Conflicts:
#	tests/Mocks/Api/SabnzbdApiMock.cs
… per batch, not once per result in parallel
…breaker per client, not one for all of them
…path mappings once per batch, not per item
…nion files against the root folder, not the book folder
@krejko krejko added the minor Version bump: minor (x.Y.0) label Aug 26, 2026
krejko and others added 7 commits August 26, 2026 14:27
…atter

The aligned `=>` arms in MyAnonamouseRequestFactory fail
`format:backend:check`, which the fork's run-tests workflow gates on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…pe] parameter for filter options"

This reverts commit 5d4a124, reversing
changes made to 3f43278.
… rules

CI on ubuntu-24.04 ran the backend suite for the first time: 3 failures out of
3264, all from merged PRs that had not been through upstream review.

- Conform AudiobookMetadataRefreshServiceTests (Listenarrs#781), FfprobeTagMetadataMapperTests
  (Listenarrs#781) and SabnzbdResponseMapperTests (Listenarrs#840) to TestClasses_FollowRepositoryConventions.
- Split the claim diagnostics helpers out of AudiobookFileService.cs, which Listenarrs#849 and
  Listenarrs#781 together pushed to 503 lines against the 500-line cap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…lient's path mappings once per batch, not per item"

This reverts commit 55c18f8, reversing
changes made to d64de28.
… the ASIN after an import"

This reverts commit 45a2d70, reversing
changes made to b26bc2a.
…d the ASIN after an import"

This reverts commit ae63b0a.
…client's path mappings once per batch, not per item"

This reverts commit f3ca823.
@krejko
krejko merged commit 47dbdeb into canary Aug 26, 2026
2 of 3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

minor Version bump: minor (x.Y.0)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

9 participants