Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ 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."
- **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.
Expand Down
51 changes: 45 additions & 6 deletions listenarr.application/Search/SearchService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -567,7 +567,9 @@ public async Task<List<MetadataSearchResult>> IntelligentSearchAsync(string quer
// Aggregate multiple pages from Audible until we reach candidateLimit
var aggregated = new List<AudibleSearchResult>();
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).
Expand Down Expand Up @@ -652,7 +654,11 @@ public async Task<List<MetadataSearchResult>> IntelligentSearchAsync(string quer
// Aggregate author pages up to candidateLimit to enrich matching
var aggregated = new List<AudibleSearchResult>();
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;
Expand Down Expand Up @@ -707,13 +713,46 @@ public async Task<List<MetadataSearchResult>> 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;
Expand Down