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
37 changes: 37 additions & 0 deletions listenarr.application/Downloads/Queue/SearchQueryFallbacks.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/*
* 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.
*/

using System.Text.RegularExpressions;

namespace Listenarr.Application.Downloads.Queue
{
public static partial class SearchQueryFallbacks
{
[GeneratedRegex(@"\s*[\(\[][^\)\]]*[\)\]]", RegexOptions.None, matchTimeoutMilliseconds: 1000)]
private static partial Regex EditionSuffix();

public static IReadOnlyList<string> Expand(string primaryQuery, string? title)
{
var candidates = new List<string> { primaryQuery };

if (!string.IsNullOrWhiteSpace(title))
{
candidates.Add(title);
candidates.Add(EditionSuffix().Replace(title, string.Empty));
}

return candidates
.Select(c => c?.Trim() ?? string.Empty)
.Where(c => !string.IsNullOrEmpty(c))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,15 @@ public async Task<SearchAndDownloadResult> SearchAndDownloadAsync(int audiobookI
// Search using the working search service. This is an automatic search (triggered
// by the background/manual 'search-and-download' endpoint), so set isAutomaticSearch
// to true to ensure only indexers are queried (no Amazon/Audible scraping).
var searchResults = await searchService.SearchAsync(searchQuery, isAutomaticSearch: true);
List<SearchResult>? searchResults = null;
foreach (var fallbackQuery in SearchQueryFallbacks.Expand(searchQuery, audiobook.Title))
{
searchResults = await searchService.SearchAsync(fallbackQuery, isAutomaticSearch: true);
if (searchResults != null && searchResults.Any())
{
break;
}
}

if (searchResults == null || !searchResults.Any())
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,16 @@ private async Task<int> ProcessAudiobookAsync(

// Search for results
var searchResults = await searchService.SearchAsync(searchQuery, isAutomaticSearch: true);
foreach (var candidate in SearchQueryFallbacks.Expand(searchQuery, audiobook.Title))
{
if (searchResults.Count > 0)
{
break;
}

searchQuery = candidate;
searchResults = await searchService.SearchAsync(candidate, isAutomaticSearch: true);
}
_logger.LogInformation("Found {Count} raw search results for audiobook '{Title}'", searchResults.Count, audiobook.Title);

// Broadcast detailed debug info about the raw search results to help diagnose automatic search failures
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/*
* 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/>.
*/
namespace Listenarr.Tests.Features.Application.Downloads.Queue
{
[Trait("Area", "Search")]
public class SearchQueryFallbacksTests
{
[Fact]
public void Expand_KeepsCombinedQueryFirst()
{
var queries = SearchQueryFallbacks.Expand("Red Rising Pierce Brown", "Red Rising");

Assert.Equal("Red Rising Pierce Brown", queries[0]);
}

[Fact]
public void Expand_FallsBackToTitleOnly()
{
var queries = SearchQueryFallbacks.Expand("Project Hail Mary Andy Weir", "Project Hail Mary");

Assert.Contains("Project Hail Mary", queries);
}

[Fact]
public void Expand_FallsBackToTitleWithoutEditionSuffix()
{
var queries = SearchQueryFallbacks.Expand(
"Red Rising (Part 1 of 2) (Dramatized Adaptation) Pierce Brown",
"Red Rising (Part 1 of 2) (Dramatized Adaptation)");

Assert.Equal("Red Rising", queries[^1]);
}

[Fact]
public void Expand_DoesNotRepeatIdenticalQueries()
{
var queries = SearchQueryFallbacks.Expand("Dune", "Dune");

Assert.Single(queries);
}

[Fact]
public void Expand_IgnoresMissingTitle()
{
var queries = SearchQueryFallbacks.Expand("Dune Frank Herbert", null);

Assert.Equal(new[] { "Dune Frank Herbert" }, queries);
}
}
}