From 6035fe1943ec4ac624074a0ee0f2991ad89ca23d Mon Sep 17 00:00:00 2001 From: Roland Knall Date: Mon, 15 Jun 2026 07:33:13 +0000 Subject: [PATCH 1/2] fix(search): match combined "Title: Subtitle" queries in author+title search The author+title search branch filtered the author's catalogue by requiring the query to be a substring of a result's Title OR Subtitle individually. Audible splits a book name across Title + Subtitle (e.g. Title="A Dance with Dragons", Subtitle="A Song of Ice and Fire, Book 5"), while callers and Library Import pass the combined "Title: Subtitle" string -- which matched neither field, so every author result was filtered out (0 results). Normalize punctuation/whitespace and also match the combined "Title Subtitle" form. Verified: "A Dance with Dragons: A Song of Ice and Fire, Book 5" by George R.R. Martin now returns the book (0 -> 2) in the de region. --- CHANGELOG.md | 1 + listenarr.application/Search/SearchService.cs | 41 +++++++++++++++++-- 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 982b7c6df..d1e055a04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Fixed +- **Author+title search: combined "Title: Subtitle" queries now match.** The author+title search filtered the author's catalogue by requiring the query to be a substring of the result's title *or* subtitle individually. Audible splits a name across those two fields (e.g. title `A Dance with Dragons`, subtitle `A Song of Ice and Fire, Book 5`), while callers and Library Import pass the combined `Title: Subtitle` string — which matched neither field, so every result was filtered out (zero results). Matching now normalizes punctuation/whitespace and also tests the combined `Title Subtitle` form. - **Authentication settings: startup-config save no longer offers a downloadable `config.json` fallback when the backend refuses the save as invalid.** `SettingsView.saveSettings()` previously wrapped `apiService.saveStartupConfig` in a bare `catch {}` and treated every failure as a disk-persistence problem — offering the user a downloadable `config.json` containing the *server-rejected* values so they could save it manually. That bypasses the new backend admin-existence guard entirely: a user who tries to enable the login screen with no admin user gets the backend's 400, the FE catches it, and the FE offers a download of the same `AuthenticationRequired=true` config the server just refused. The catch now inspects the thrown error's `status`: 4xx responses are validation refusals and surface as a hard error toast (no download offered); 5xx and network failures fall through to the existing download fallback, which is the right escape hatch for "server wants to save but can't write to disk." - **Authentication settings: enabling the login screen now refuses to persist when no admin user exists.** `ConfigurationService.SaveStartupConfigAsync` queries `IUserService.GetAdminUsersAsync` whenever the incoming save *transitions* `AuthenticationRequired` from disabled to enabled, and throws if the admin user list is empty. This closes the carveout left by the credential-visibility and admin-provisioning fixes below: the settings DTO clears blank fields before save, so a user who flips "Enable login screen" with empty (or username-only) admin credentials silently skipped provisioning entirely and still reached the startup-config write, locking themselves out of an admin-less instance (recoverable by editing `config/config.json` back to `"AuthenticationRequired": "false"`, but a confusing first-time-setup trap). The check is scoped to the transition: subsequent saves while auth is already on (API key regenerations, port changes, log-level tweaks) don't re-query the admin list, and the common "just updating other startup fields with auth off" path stays unaffected. The admin block in `SaveApplicationSettings` runs before the startup-config write in the same save flow, so the typical "supply credentials and enable login in the same save" sequence has the admin row in place by the time the check runs. - **Authentication settings: admin provisioning failures no longer silently let the auth-required toggle proceed.** `ConfigurationService.SaveApplicationSettingsAsync` previously caught any exception from `CreateUserAsync` / `UpdatePasswordAsync`, logged it, and returned successfully — so when admin credentials were supplied but the user-service rejected them (password policy violation, repo I/O error, concurrent-write race), `SettingsView.saveSettings()` would still go on to persist `AuthenticationRequired=true` on its second request. The result was an instance that required login but had no working admin account — exactly the lockout shape the credential-visibility fix below was meant to prevent. The catch now re-throws the failure so the caller aborts before the auth-toggle write. The settings row itself is still saved before the admin block (non-admin changes like notification triggers and webhooks shouldn't disappear because admin provisioning failed), and the no-credentials path remains an unchanged silent skip. diff --git a/listenarr.application/Search/SearchService.cs b/listenarr.application/Search/SearchService.cs index e4293ca9a..72ba721cf 100644 --- a/listenarr.application/Search/SearchService.cs +++ b/listenarr.application/Search/SearchService.cs @@ -707,13 +707,46 @@ public async Task> IntelligentSearchAsync(string quer var authorFiltered = deduplicated.AsEnumerable(); if (!string.IsNullOrWhiteSpace(language)) authorFiltered = authorFiltered.Where(b => !string.IsNullOrWhiteSpace(b.Language) && string.Equals(b.Language, language, StringComparison.OrdinalIgnoreCase)); - // Title-based filtering can be done directly against the author results + // Title-based filtering can be done directly against the author results. + // Audible splits a book name across Title + Subtitle (e.g. Title="A Dance + // with Dragons", Subtitle="A Song of Ice and Fire, Book 5"), while callers + // (and Library Import) often pass the combined "Title: Subtitle" string. + // Match against Title, Subtitle AND the combined form, after normalizing + // punctuation/whitespace, so a combined query still matches. if (!string.IsNullOrEmpty(titleVal)) { + // Lowercase; collapse any run of non-alphanumeric chars to one space. + static string NormalizeForTitleMatch(string? s) + { + if (string.IsNullOrWhiteSpace(s)) return string.Empty; + var sb = new System.Text.StringBuilder(s.Length); + var lastWasSpace = false; + foreach (var ch in s.ToLowerInvariant()) + { + if (char.IsLetterOrDigit(ch)) { sb.Append(ch); lastWasSpace = false; } + else if (!lastWasSpace) { sb.Append(' '); lastWasSpace = true; } + } + return sb.ToString().Trim(); + } + + var queryNorm = NormalizeForTitleMatch(titleVal); authorFiltered = authorFiltered.Where(b => - (!string.IsNullOrWhiteSpace(b.Title) && b.Title.IndexOf(titleVal, StringComparison.OrdinalIgnoreCase) >= 0) || - (!string.IsNullOrWhiteSpace(b.Subtitle) && b.Subtitle.IndexOf(titleVal, StringComparison.OrdinalIgnoreCase) >= 0) - ); + { + var titleNorm = NormalizeForTitleMatch(b.Title); + var subtitleNorm = NormalizeForTitleMatch(b.Subtitle); + if (titleNorm.Length == 0 && subtitleNorm.Length == 0) return false; + var combinedNorm = subtitleNorm.Length == 0 + ? titleNorm + : titleNorm + " " + subtitleNorm; + + return titleNorm.Contains(queryNorm, StringComparison.Ordinal) + || subtitleNorm.Contains(queryNorm, StringComparison.Ordinal) + || combinedNorm.Contains(queryNorm, StringComparison.Ordinal) + || queryNorm.Contains(combinedNorm, StringComparison.Ordinal) + // Combined "Title: Subtitle" query vs split fields where the + // subtitle differs slightly: still match on the title portion. + || (titleNorm.Length >= 5 && queryNorm.Contains(titleNorm, StringComparison.Ordinal)); + }); } // If an ISBN was provided we must match against detailed metadata; From 62f5a896a8b183ba76fa4f12b5a04037a10007be Mon Sep 17 00:00:00 2001 From: Roland Knall Date: Mon, 15 Jun 2026 08:33:56 +0000 Subject: [PATCH 2/2] perf(search): page Audible author lookups at the maximum page size (50) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The author and author+title aggregation loops derived their page size from the result cap: `pageSize = Math.Min(50, Math.Max(10, candidateLimit))`. That conflates two unrelated concerns: * candidateLimit is a *result-set* bound — how many results we ultimately keep/return to the caller. * page size is a *transport* concern — how many items we ask Audible for per HTTP round-trip. The loop already aggregates *every* page until Audible returns a short page (it deliberately does NOT stop at candidateLimit — see the surrounding comment). So page size has no effect on which or how many items end up in the aggregated set; it only changes how many sequential API calls that takes. The old formula therefore had exactly one effect: when a caller passed a small cap it made pages *smaller*. The Advanced search sends `cap=5`, which drove pageSize down to 10, so a 127-item author catalogue (Elfie Donnelly) was fetched in 13 sequential Audible calls instead of 3 — pure added latency, no benefit. Hardcoding 50 (Audible's maximum page size) is strictly better: * Fewer round-trips: ~ceil(N/50) instead of ~ceil(N/min(...)). Never more. * Identical results — same items aggregated, same final cap applied later. * No larger value is possible (50 is the API max) and any smaller value only adds latency, so there is no reason to make it configurable. Measured: the Elfie Donnelly author+title search drops from 13 to 3 Audible requests with an unchanged result set. --- CHANGELOG.md | 3 +++ listenarr.application/Search/SearchService.cs | 10 ++++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d1e055a04..1c2c8fef9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed +- **Author searches page the Audible catalogue at its maximum page size.** Author and author+title lookups now request 50 results per page (Audible's maximum) instead of a page size derived from the result cap. The aggregation already fetches every page until the catalogue ends, so page size never affected *which* results were considered — only how many sequential API calls it took. With a small cap (e.g. `cap=5`) the old `min(50, max(10, cap))` formula shrank pages to 10, so a 127-item author catalogue took 13 round-trips; it now takes 3. Same results, ~4× fewer Audible requests. + ### Fixed - **Author+title search: combined "Title: Subtitle" queries now match.** The author+title search filtered the author's catalogue by requiring the query to be a substring of the result's title *or* subtitle individually. Audible splits a name across those two fields (e.g. title `A Dance with Dragons`, subtitle `A Song of Ice and Fire, Book 5`), while callers and Library Import pass the combined `Title: Subtitle` string — which matched neither field, so every result was filtered out (zero results). Matching now normalizes punctuation/whitespace and also tests the combined `Title Subtitle` form. - **Authentication settings: startup-config save no longer offers a downloadable `config.json` fallback when the backend refuses the save as invalid.** `SettingsView.saveSettings()` previously wrapped `apiService.saveStartupConfig` in a bare `catch {}` and treated every failure as a disk-persistence problem — offering the user a downloadable `config.json` containing the *server-rejected* values so they could save it manually. That bypasses the new backend admin-existence guard entirely: a user who tries to enable the login screen with no admin user gets the backend's 400, the FE catches it, and the FE offers a download of the same `AuthenticationRequired=true` config the server just refused. The catch now inspects the thrown error's `status`: 4xx responses are validation refusals and surface as a hard error toast (no download offered); 5xx and network failures fall through to the existing download fallback, which is the right escape hatch for "server wants to save but can't write to disk." diff --git a/listenarr.application/Search/SearchService.cs b/listenarr.application/Search/SearchService.cs index 72ba721cf..9325f444e 100644 --- a/listenarr.application/Search/SearchService.cs +++ b/listenarr.application/Search/SearchService.cs @@ -567,7 +567,9 @@ public async Task> IntelligentSearchAsync(string quer // Aggregate multiple pages from Audible until we reach candidateLimit var aggregated = new List(); int page = 1; - int pageSize = Math.Min(50, Math.Max(10, candidateLimit)); + // Page at Audible's maximum page size to minimise round-trips (see the + // author+title branch for the rationale); the result cap is applied later. + int pageSize = 50; // For Audible author listings, do not artificially cap aggregation // by the Amazon candidateLimit. Instead, fetch pages until a // page returns fewer than pageSize results (natural end). @@ -652,7 +654,11 @@ public async Task> IntelligentSearchAsync(string quer // Aggregate author pages up to candidateLimit to enrich matching var aggregated = new List(); int page = 1; - int pageSize = Math.Min(50, Math.Max(10, candidateLimit)); + // Page at Audible's maximum page size to minimise round-trips. The result + // cap (candidateLimit) bounds what we ultimately return, not how many items + // we request per page; coupling the two made a small cap (e.g. cap=5) fetch + // the whole catalogue in tiny 10-item pages (many sequential API calls). + int pageSize = 50; // For Audible author/title combined flows, allow full aggregation // across available pages; we will narrow/return a bounded set later. int maxPages = int.MaxValue;