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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **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.
- **Authentication settings: corrected misleading description on the "Enable login screen" toggle.** Previously said *"Changes here are local and will not modify server files — edit config/config.json on the host to persist"*, which was demonstrably wrong: `SettingsView` actually writes `authenticationRequired` back to the server's startup config on save. The description now accurately states the toggle persists, and prompts the user to set admin credentials in the same save.
- **Authentication settings: admin credential fields are always visible.** Previously the *Admin Account Management* row was gated by `v-if="authEnabledComputed"` in `AuthenticationSection.vue`, which meant the only way to surface the username/password inputs was to first toggle on the login screen. If a user enabled `AuthenticationRequired` via `config.json` on the host (e.g., for the very first time) and then opened settings, the toggle reflected the server state (on), but if they instead opened settings *with auth still off*, the fields were hidden — and once they ticked the toggle and saved, the login screen activated immediately on the next page load, locking them out before they could create a user. The fields now render unconditionally so credentials can be configured before or after enabling auth. Help text and the password placeholder were updated to reflect the create-or-update semantics (blank password = keep existing).
- **Automatic search no longer matches results whose title is irrelevant to the audiobook:** A new context-aware `RelevanceFilter` participates in the search-result filter pipeline. When automatic search runs, the pipeline is invoked with the target audiobook so the filter can compare the result title against the audiobook's title + authors via stop-word-filtered token overlap. Below 30% overlap → rejected. Defends against indexers returning matter that shares one or two tokens with the query — e.g., a Patterson Hood concert recording being matched to a James Patterson audiobook on the single shared surname. Manual search keeps the old behavior (no context passed → filter fails open) so users browsing results still see everything.

## [0.2.71] - 2026-04-17

Expand Down
24 changes: 24 additions & 0 deletions listenarr.api/Controllers/LibraryController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3059,6 +3059,30 @@ private async Task<int> ProcessAudiobookForSearchAsync(
return 0;
}

// Context-aware filter pass — reject results whose title isn't relevant to
// this specific audiobook before scoring picks one to download. This is the
// bulk-search-all endpoint, an auto-pick flow like AutomaticSearchService.
try
{
using var filterScope = _scopeFactory.CreateScope();
var filterPipeline = filterScope.ServiceProvider.GetRequiredService<Listenarr.Application.Search.Filters.SearchResultFilterPipeline>();
var preFilterCount = searchResults.Count;
searchResults = filterPipeline.ApplyFilters(searchResults, logFilteredResults: true, audiobook: audiobook);
if (searchResults.Count < preFilterCount)
{
_logger.LogInformation("Filtered {Removed} of {Total} raw results for audiobook '{Title}' via context-aware pipeline", preFilterCount - searchResults.Count, preFilterCount, LogRedaction.SanitizeText(audiobook.Title));
}
if (!searchResults.Any())
{
_logger.LogInformation("All search results filtered for audiobook '{Title}'", LogRedaction.SanitizeText(audiobook.Title));
return 0;
}
}
catch (Exception ex) when (ex is not (OperationCanceledException or OutOfMemoryException or StackOverflowException))
{
_logger.LogDebug(ex, "SearchResultFilterPipeline unavailable; skipping context-aware filtering for audiobook {Id}", audiobook.Id);
}

// Score results against quality profile
var scoredResults = await qualityProfileService.ScoreSearchResults(searchResults, audiobook.QualityProfile!);

Expand Down
1 change: 1 addition & 0 deletions listenarr.api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,7 @@ ex is IOException
builder.Services.AddScoped<ISearchResultFilter, PromotionalTitleFilter>();
builder.Services.AddScoped<ISearchResultFilter, ProductLikeTitleFilter>();
builder.Services.AddScoped<ISearchResultFilter, MissingInformationFilter>();
builder.Services.AddScoped<ISearchResultFilter, RelevanceFilter>();
builder.Services.AddScoped<SearchResultFilterPipeline>();

// Add metadata fetching strategies
Expand Down
21 changes: 20 additions & 1 deletion listenarr.application/Downloads/DownloadService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
using Listenarr.Application.Interfaces;
using Listenarr.Domain.Models;
using Listenarr.Application.Interfaces.Repositories;
using Listenarr.Application.Search.Filters;
using Microsoft.Extensions.Logging;
using Listenarr.Application.Security;

Expand All @@ -42,7 +43,8 @@ public class DownloadService(
IDownloadQueueService downloadQueueService,
INotificationService notificationService,
IHubBroadcaster hubBroadcaster,
IDownloadHistoryService downloadHistoryService) : IDownloadService
IDownloadHistoryService downloadHistoryService,
SearchResultFilterPipeline filterPipeline) : IDownloadService
{
// Cache expiration constants
private const int QueueCacheExpirationSeconds = 10;
Expand Down Expand Up @@ -204,6 +206,23 @@ public async Task<SearchAndDownloadResult> SearchAndDownloadAsync(int audiobookI
};
}

// Context-aware filter pass — reject results whose title isn't relevant to
// this specific audiobook before scoring picks one to download.
var preFilterCount = searchResults.Count;
searchResults = filterPipeline.ApplyFilters(searchResults, logFilteredResults: true, audiobook: audiobook);
if (searchResults.Count < preFilterCount)
{
logger.LogInformation("Filtered {Removed} of {Total} raw results for audiobook '{Title}' via context-aware pipeline", preFilterCount - searchResults.Count, preFilterCount, LogRedaction.SanitizeText(audiobook.Title));
}
if (!searchResults.Any())
{
return new SearchAndDownloadResult
{
Success = false,
Message = "All results filtered as irrelevant"
};
}

// Score results against quality profile
var scoredResults = await qualityProfileService.ScoreSearchResults(searchResults, audiobook.QualityProfile);

Expand Down
14 changes: 10 additions & 4 deletions listenarr.application/Interfaces/ISearchResultFilter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,17 @@ namespace Listenarr.Application.Interfaces;
public interface ISearchResultFilter
{
/// <summary>
/// Determines if the result should be filtered out (excluded).
/// Determines whether the result should be filtered out (excluded).
/// </summary>
/// <param name="result">The search result to evaluate</param>
/// <returns>True if the result should be filtered out, false to keep it</returns>
bool ShouldFilter(SearchResult result);
/// <param name="result">The search result to evaluate.</param>
/// <param name="audiobook">
/// The audiobook the search is running for, when the caller knows it (e.g. automatic
/// search). Null for context-free callers such as manual search or per-result
/// enrichment; context-aware filters (e.g. title relevance) treat a null audiobook as
/// fail-open and keep the result.
/// </param>
/// <returns>True if the result should be filtered out, false to keep it.</returns>
bool ShouldFilter(SearchResult result, Audiobook? audiobook = null);

/// <summary>
/// Reason why the result was filtered (for logging/debugging).
Expand Down
21 changes: 20 additions & 1 deletion listenarr.application/Search/AutomaticSearchService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
using Listenarr.Application.Interfaces;
using Listenarr.Application.Interfaces.Repositories;
using Listenarr.Application.Notification;
using Listenarr.Application.Search.Filters;
using Listenarr.Domain.Models;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.DependencyInjection;
Expand Down Expand Up @@ -101,6 +102,7 @@ private async Task PerformAutomaticSearchesAsync(CancellationToken stoppingToken
var searchService = scope.ServiceProvider.GetRequiredService<ISearchService>();
var qualityProfileService = scope.ServiceProvider.GetRequiredService<IQualityProfileService>();
var downloadService = scope.ServiceProvider.GetRequiredService<IDownloadService>();
var filterPipeline = scope.ServiceProvider.GetRequiredService<SearchResultFilterPipeline>();

// Get all monitored audiobooks that haven't been searched in the last 6 hours
var cutoffTime = DateTime.UtcNow.AddHours(-6);
Expand All @@ -125,7 +127,7 @@ private async Task PerformAutomaticSearchesAsync(CancellationToken stoppingToken
try
{
var downloadsQueuedForBook = await ProcessAudiobookAsync(
audiobook, searchService, qualityProfileService, downloadService, audiobookRepository, downloadRepository, fileRepository, stoppingToken);
audiobook, searchService, qualityProfileService, downloadService, audiobookRepository, downloadRepository, fileRepository, filterPipeline, stoppingToken);

downloadsQueued += downloadsQueuedForBook;
processedCount++;
Expand Down Expand Up @@ -163,6 +165,7 @@ private async Task<int> ProcessAudiobookAsync(
IAudiobookRepository audiobookRepository,
IDownloadRepository downloadRepository,
IAudiobookFileRepository fileRepository,
SearchResultFilterPipeline filterPipeline,
CancellationToken stoppingToken)
{
if (audiobook.QualityProfile == null)
Expand Down Expand Up @@ -239,6 +242,22 @@ private async Task<int> ProcessAudiobookAsync(
return 0;
}

// Context-aware filter pass — reject results whose title isn't relevant to
// this specific audiobook (e.g., a Patterson Hood concert recording vs. a
// James Patterson audiobook sharing only the "Patterson" surname).
var preFilterCount = searchResults.Count;
searchResults = filterPipeline.ApplyFilters(searchResults, logFilteredResults: true, audiobook: audiobook);
if (searchResults.Count < preFilterCount)
{
_logger.LogInformation("Filtered {Removed} of {Total} raw results for audiobook '{Title}' via context-aware pipeline", preFilterCount - searchResults.Count, preFilterCount, audiobook.Title);
}

if (!searchResults.Any())
{
_logger.LogInformation("All search results filtered for audiobook '{Title}'", audiobook.Title);
return 0;
}

// Score results against quality profile
var scoredResults = await qualityProfileService.ScoreSearchResults(searchResults, audiobook.QualityProfile);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ public class AudiobookOnlyFilter : ISearchResultFilter
{
public string FilterReason => "non_audiobook_filtered";

public bool ShouldFilter(SearchResult result)
public bool ShouldFilter(SearchResult result, Audiobook? audiobook = null)
{
// If enriched with a metadata source, prefer that metadata only when the
// metadata source is a trusted audio provider or the enriched metadata
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ public class KindleEditionFilter : ISearchResultFilter
{
public string FilterReason => "kindle_edition_filtered";

public bool ShouldFilter(SearchResult result)
public bool ShouldFilter(SearchResult result, Audiobook? audiobook = null)
{
return SearchValidation.IsKindleEdition(result.Title);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ public class MissingInformationFilter : ISearchResultFilter
{
public string FilterReason => "missing_author_or_title";

public bool ShouldFilter(SearchResult result)
public bool ShouldFilter(SearchResult result, Audiobook? audiobook = null)
{
return string.IsNullOrWhiteSpace(result.Artist) || string.IsNullOrWhiteSpace(result.Title);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ public class ProductLikeTitleFilter : ISearchResultFilter
{
public string FilterReason => "product_like_filtered";

public bool ShouldFilter(SearchResult result)
public bool ShouldFilter(SearchResult result, Audiobook? audiobook = null)
{
// If this result was enriched by a metadata source (Amazon/Audible/Audible/Audnexus/OpenLibrary),
// prefer the enriched metadata and do not treat it as a product-like false positive.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ public class PromotionalTitleFilter : ISearchResultFilter
{
public string FilterReason => "promotional_title_filtered";

public bool ShouldFilter(SearchResult result)
public bool ShouldFilter(SearchResult result, Audiobook? audiobook = null)
{
return SearchValidation.IsPromotionalTitle(result.Title) || SearchValidation.IsTitleNoise(result.Title);
}
Expand Down
83 changes: 83 additions & 0 deletions listenarr.application/Search/Filters/RelevanceFilter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/*
* Listenarr - Audiobook Management System
* Copyright (C) 2024-2026 Listenarr Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
using Listenarr.Application.Interfaces;
using Listenarr.Domain.Models;

namespace Listenarr.Application.Search.Filters
{
/// <summary>
/// Context-aware filter that rejects search results whose title shares too few
/// significant tokens with the audiobook being searched for. Defends against
/// indexers returning matter that shares one or two tokens with the query —
/// e.g., a Patterson Hood concert recording being suggested for a James
/// Patterson audiobook on the single shared "Patterson" surname.
///
/// Without an audiobook context (manual search, AsinEnricher per-result calls)
/// the filter fails open — manual users keep seeing every result.
/// </summary>
public class RelevanceFilter : ISearchResultFilter
{
/// <summary>
/// Default fraction of significant audiobook tokens that must appear in the
/// result title for the result to be considered relevant. Empirically chosen
/// so that single-shared-token false matches (one author surname only) are
/// rejected while multi-token legitimate matches pass.
/// </summary>
public const double DefaultMinRelevance = 0.30;

public string FilterReason => "title_not_relevant";

public bool ShouldFilter(SearchResult result, Audiobook? audiobook = null)
{
// No audiobook context (manual search, per-result AsinEnricher calls) — fail
// open so those callers keep every result.
if (audiobook == null) return false;
var relevance = ComputeRelevance(result.Title, audiobook.Title, audiobook.Authors);
return relevance < DefaultMinRelevance;
}

/// <summary>
/// Computes a relevance ratio in [0, 1]: the fraction of distinct
/// significant tokens from the audiobook's title + authors that appear
/// in the result title.
///
/// Returns 1.0 when the audiobook side has no significant tokens (cannot
/// be judged — fail open rather than reject every result).
/// </summary>
public static double ComputeRelevance(
string? resultTitle,
string? audiobookTitle,
IEnumerable<string>? authors)
{
var expected = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var t in SignificantTokens.From(audiobookTitle)) expected.Add(t);
if (authors != null)
{
foreach (var author in authors)
{
foreach (var t in SignificantTokens.From(author)) expected.Add(t);
}
}
if (expected.Count == 0) return 1.0;

var resultTokens = new HashSet<string>(SignificantTokens.From(resultTitle), StringComparer.OrdinalIgnoreCase);
var hits = expected.Count(t => resultTokens.Contains(t));
return (double)hits / expected.Count;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,14 +40,16 @@ public SearchResultFilterPipeline(IEnumerable<ISearchResultFilter> filters, ILog
/// </summary>
/// <param name="results">Results to filter</param>
/// <param name="logFilteredResults">Whether to log filtered results</param>
/// <param name="audiobook">Optional audiobook context. When supplied, context-aware filters
/// (e.g., title-relevance) can use it; otherwise they fail open.</param>
/// <returns>Filtered list with unwanted results removed</returns>
public List<SearchResult> ApplyFilters(List<SearchResult> results, bool logFilteredResults = true)
public List<SearchResult> ApplyFilters(List<SearchResult> results, bool logFilteredResults = true, Audiobook? audiobook = null)
{
var filtered = new List<SearchResult>();

foreach (var result in results)
{
var matchingFilter = _filters.FirstOrDefault(f => f.ShouldFilter(result));
var matchingFilter = _filters.FirstOrDefault(f => f.ShouldFilter(result, audiobook));
bool shouldFilter = matchingFilter != null;
string? filterReason = matchingFilter?.FilterReason;

Expand All @@ -71,9 +73,9 @@ public List<SearchResult> ApplyFilters(List<SearchResult> results, bool logFilte
/// <summary>
/// Checks if a single result would be filtered.
/// </summary>
public bool WouldFilter(SearchResult result, out string? filterReason)
public bool WouldFilter(SearchResult result, out string? filterReason, Audiobook? audiobook = null)
{
foreach (var filter in _filters.Where(f => f.ShouldFilter(result)))
foreach (var filter in _filters.Where(f => f.ShouldFilter(result, audiobook)))
{
filterReason = filter.FilterReason;
return true;
Expand Down
Loading
Loading