fix external search bucket fanout
CI / build-and-test (push) Has been cancelled

This commit is contained in:
2026-04-06 12:55:43 -04:00
parent 228e1a7f42
commit b8f8fcb1f8
6 changed files with 214 additions and 19 deletions
@@ -0,0 +1,43 @@
using System.Reflection;
using allstarr.Controllers;
namespace allstarr.Tests;
public class JellyfinControllerSearchLimitTests
{
[Theory]
[InlineData(null, 20, true, 20, 20, 20)]
[InlineData("MusicAlbum", 20, true, 0, 20, 0)]
[InlineData("Audio", 20, true, 20, 0, 0)]
[InlineData("MusicArtist", 20, true, 0, 0, 20)]
[InlineData("Playlist", 20, true, 0, 20, 0)]
[InlineData("Playlist", 20, false, 0, 0, 0)]
[InlineData("Audio,MusicArtist", 15, true, 15, 0, 15)]
[InlineData("BoxSet", 10, true, 0, 0, 0)]
public void GetExternalSearchLimits_UsesRequestedItemTypes(
string? includeItemTypes,
int limit,
bool includePlaylistsAsAlbums,
int expectedSongLimit,
int expectedAlbumLimit,
int expectedArtistLimit)
{
var requestedTypes = string.IsNullOrWhiteSpace(includeItemTypes)
? null
: includeItemTypes.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var method = typeof(JellyfinController).GetMethod(
"GetExternalSearchLimits",
BindingFlags.Static | BindingFlags.NonPublic);
Assert.NotNull(method);
var result = ((int SongLimit, int AlbumLimit, int ArtistLimit))method!.Invoke(
null,
new object?[] { requestedTypes, limit, includePlaylistsAsAlbums })!;
Assert.Equal(expectedSongLimit, result.SongLimit);
Assert.Equal(expectedAlbumLimit, result.AlbumLimit);
Assert.Equal(expectedArtistLimit, result.ArtistLimit);
}
}
@@ -299,6 +299,65 @@ public class SquidWTFMetadataServiceTests
Assert.NotNull(result); Assert.NotNull(result);
} }
[Fact]
public async Task SearchAllAsync_WithZeroLimits_SkipsUnusedBuckets()
{
var requestKinds = new List<string>();
var handler = new StubHttpMessageHandler(request =>
{
var trackQuery = GetQueryParameter(request.RequestUri!, "s");
var albumQuery = GetQueryParameter(request.RequestUri!, "al");
var artistQuery = GetQueryParameter(request.RequestUri!, "a");
if (!string.IsNullOrWhiteSpace(trackQuery))
{
requestKinds.Add("song");
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(CreateTrackSearchResponse(CreateTrackPayload(1, "Song", "USRC12345678")))
};
}
if (!string.IsNullOrWhiteSpace(albumQuery))
{
requestKinds.Add("album");
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(CreateAlbumSearchResponse())
};
}
if (!string.IsNullOrWhiteSpace(artistQuery))
{
requestKinds.Add("artist");
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(CreateArtistSearchResponse())
};
}
throw new InvalidOperationException($"Unexpected request URI: {request.RequestUri}");
});
var httpClient = new HttpClient(handler);
_mockHttpClientFactory.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(httpClient);
var service = new SquidWTFMetadataService(
_mockHttpClientFactory.Object,
_subsonicSettings,
_squidwtfSettings,
_mockLogger.Object,
_mockCache.Object,
new List<string> { "https://test1.example.com" });
var result = await service.SearchAllAsync("OK Computer", 0, 5, 0);
Assert.Empty(result.Songs);
Assert.Single(result.Albums);
Assert.Empty(result.Artists);
Assert.Equal(new[] { "album" }, requestKinds);
}
[Fact] [Fact]
public void ExplicitFilter_RespectsSettings() public void ExplicitFilter_RespectsSettings()
{ {
@@ -1,5 +1,6 @@
using System.Text.Json; using System.Text.Json;
using System.Text; using System.Text;
using allstarr.Models.Domain;
using allstarr.Models.Search; using allstarr.Models.Search;
using allstarr.Models.Subsonic; using allstarr.Models.Subsonic;
using allstarr.Services.Common; using allstarr.Services.Common;
@@ -304,6 +305,7 @@ public partial class JellyfinController
// Run local and external searches in parallel // Run local and external searches in parallel
var itemTypes = ParseItemTypes(includeItemTypes); var itemTypes = ParseItemTypes(includeItemTypes);
var externalSearchLimits = GetExternalSearchLimits(itemTypes, limit, includePlaylistsAsAlbums: true);
var jellyfinTask = GetLocalSearchResultForCurrentRequest( var jellyfinTask = GetLocalSearchResultForCurrentRequest(
cleanQuery, cleanQuery,
includeItemTypes, includeItemTypes,
@@ -312,12 +314,29 @@ public partial class JellyfinController
recursive, recursive,
userId); userId);
_logger.LogInformation(
"SEARCH TRACE: external limits for query '{Query}' => songs={SongLimit}, albums={AlbumLimit}, artists={ArtistLimit}",
cleanQuery,
externalSearchLimits.SongLimit,
externalSearchLimits.AlbumLimit,
externalSearchLimits.ArtistLimit);
// Use parallel metadata service if available (races providers), otherwise use primary // Use parallel metadata service if available (races providers), otherwise use primary
var externalTask = favoritesOnlyRequest var externalTask = favoritesOnlyRequest
? Task.FromResult(new SearchResult()) ? Task.FromResult(new SearchResult())
: _parallelMetadataService != null : _parallelMetadataService != null
? _parallelMetadataService.SearchAllAsync(cleanQuery, limit, limit, limit, HttpContext.RequestAborted) ? _parallelMetadataService.SearchAllAsync(
: _metadataService.SearchAllAsync(cleanQuery, limit, limit, limit, HttpContext.RequestAborted); cleanQuery,
externalSearchLimits.SongLimit,
externalSearchLimits.AlbumLimit,
externalSearchLimits.ArtistLimit,
HttpContext.RequestAborted)
: _metadataService.SearchAllAsync(
cleanQuery,
externalSearchLimits.SongLimit,
externalSearchLimits.AlbumLimit,
externalSearchLimits.ArtistLimit,
HttpContext.RequestAborted);
var playlistTask = favoritesOnlyRequest || !_settings.EnableExternalPlaylists var playlistTask = favoritesOnlyRequest || !_settings.EnableExternalPlaylists
? Task.FromResult(new List<ExternalPlaylist>()) ? Task.FromResult(new List<ExternalPlaylist>())
@@ -672,11 +691,36 @@ public partial class JellyfinController
} }
var cleanQuery = searchTerm.Trim().Trim('"'); var cleanQuery = searchTerm.Trim().Trim('"');
var requestedTypes = ParseItemTypes(includeItemTypes);
var externalSearchLimits = GetExternalSearchLimits(requestedTypes, limit, includePlaylistsAsAlbums: false);
var includesSongs = requestedTypes == null || requestedTypes.Length == 0 ||
requestedTypes.Contains("Audio", StringComparer.OrdinalIgnoreCase);
var includesAlbums = requestedTypes == null || requestedTypes.Length == 0 ||
requestedTypes.Contains("MusicAlbum", StringComparer.OrdinalIgnoreCase);
var includesArtists = requestedTypes == null || requestedTypes.Length == 0 ||
requestedTypes.Contains("MusicArtist", StringComparer.OrdinalIgnoreCase);
_logger.LogInformation(
"SEARCH TRACE: hint limits for query '{Query}' => songs={SongLimit}, albums={AlbumLimit}, artists={ArtistLimit}",
cleanQuery,
externalSearchLimits.SongLimit,
externalSearchLimits.AlbumLimit,
externalSearchLimits.ArtistLimit);
// Use parallel metadata service if available (races providers), otherwise use primary // Use parallel metadata service if available (races providers), otherwise use primary
var externalTask = _parallelMetadataService != null var externalTask = _parallelMetadataService != null
? _parallelMetadataService.SearchAllAsync(cleanQuery, limit, limit, limit, HttpContext.RequestAborted) ? _parallelMetadataService.SearchAllAsync(
: _metadataService.SearchAllAsync(cleanQuery, limit, limit, limit, HttpContext.RequestAborted); cleanQuery,
externalSearchLimits.SongLimit,
externalSearchLimits.AlbumLimit,
externalSearchLimits.ArtistLimit,
HttpContext.RequestAborted)
: _metadataService.SearchAllAsync(
cleanQuery,
externalSearchLimits.SongLimit,
externalSearchLimits.AlbumLimit,
externalSearchLimits.ArtistLimit,
HttpContext.RequestAborted);
// Run searches in parallel (local Jellyfin hints + external providers) // Run searches in parallel (local Jellyfin hints + external providers)
var jellyfinTask = GetLocalSearchHintsResultForCurrentRequest(cleanQuery, userId); var jellyfinTask = GetLocalSearchHintsResultForCurrentRequest(cleanQuery, userId);
@@ -689,9 +733,15 @@ public partial class JellyfinController
var (localSongs, localAlbums, localArtists) = _modelMapper.ParseSearchHintsResponse(jellyfinResult); var (localSongs, localAlbums, localArtists) = _modelMapper.ParseSearchHintsResponse(jellyfinResult);
// NO deduplication - merge all results and take top matches // NO deduplication - merge all results and take top matches
var allSongs = localSongs.Concat(externalResult.Songs).Take(limit).ToList(); var allSongs = includesSongs
var allAlbums = localAlbums.Concat(externalResult.Albums).Take(limit).ToList(); ? localSongs.Concat(externalResult.Songs).Take(limit).ToList()
var allArtists = localArtists.Concat(externalResult.Artists).Take(limit).ToList(); : new List<Song>();
var allAlbums = includesAlbums
? localAlbums.Concat(externalResult.Albums).Take(limit).ToList()
: new List<Album>();
var allArtists = includesArtists
? localArtists.Concat(externalResult.Artists).Take(limit).ToList()
: new List<Artist>();
return _responseBuilder.CreateSearchHintsResponse( return _responseBuilder.CreateSearchHintsResponse(
allSongs.Take(limit).ToList(), allSongs.Take(limit).ToList(),
@@ -742,6 +792,33 @@ public partial class JellyfinController
return string.Equals(Request.Query["IsFavorite"].ToString(), "true", StringComparison.OrdinalIgnoreCase); return string.Equals(Request.Query["IsFavorite"].ToString(), "true", StringComparison.OrdinalIgnoreCase);
} }
private static (int SongLimit, int AlbumLimit, int ArtistLimit) GetExternalSearchLimits(
string[]? requestedTypes,
int limit,
bool includePlaylistsAsAlbums)
{
if (limit <= 0)
{
return (0, 0, 0);
}
if (requestedTypes == null || requestedTypes.Length == 0)
{
return (limit, limit, limit);
}
var includeSongs = requestedTypes.Contains("Audio", StringComparer.OrdinalIgnoreCase);
var includeAlbums = requestedTypes.Contains("MusicAlbum", StringComparer.OrdinalIgnoreCase) ||
(includePlaylistsAsAlbums &&
requestedTypes.Contains("Playlist", StringComparer.OrdinalIgnoreCase));
var includeArtists = requestedTypes.Contains("MusicArtist", StringComparer.OrdinalIgnoreCase);
return (
includeSongs ? limit : 0,
includeAlbums ? limit : 0,
includeArtists ? limit : 0);
}
private static IActionResult CreateEmptyItemsResponse(int startIndex) private static IActionResult CreateEmptyItemsResponse(int startIndex)
{ {
return new JsonResult(new return new JsonResult(new
@@ -135,10 +135,15 @@ public class DeezerMetadataService : TrackParserBase, IMusicMetadataService
public async Task<SearchResult> SearchAllAsync(string query, int songLimit = 20, int albumLimit = 20, int artistLimit = 20, CancellationToken cancellationToken = default) public async Task<SearchResult> SearchAllAsync(string query, int songLimit = 20, int albumLimit = 20, int artistLimit = 20, CancellationToken cancellationToken = default)
{ {
// Execute searches in parallel var songsTask = songLimit > 0
var songsTask = SearchSongsAsync(query, songLimit, cancellationToken); ? SearchSongsAsync(query, songLimit, cancellationToken)
var albumsTask = SearchAlbumsAsync(query, albumLimit, cancellationToken); : Task.FromResult(new List<Song>());
var artistsTask = SearchArtistsAsync(query, artistLimit, cancellationToken); var albumsTask = albumLimit > 0
? SearchAlbumsAsync(query, albumLimit, cancellationToken)
: Task.FromResult(new List<Album>());
var artistsTask = artistLimit > 0
? SearchArtistsAsync(query, artistLimit, cancellationToken)
: Task.FromResult(new List<Artist>());
await Task.WhenAll(songsTask, albumsTask, artistsTask); await Task.WhenAll(songsTask, albumsTask, artistsTask);
@@ -160,9 +160,15 @@ public class QobuzMetadataService : TrackParserBase, IMusicMetadataService
public async Task<SearchResult> SearchAllAsync(string query, int songLimit = 20, int albumLimit = 20, int artistLimit = 20, CancellationToken cancellationToken = default) public async Task<SearchResult> SearchAllAsync(string query, int songLimit = 20, int albumLimit = 20, int artistLimit = 20, CancellationToken cancellationToken = default)
{ {
var songsTask = SearchSongsAsync(query, songLimit, cancellationToken); var songsTask = songLimit > 0
var albumsTask = SearchAlbumsAsync(query, albumLimit, cancellationToken); ? SearchSongsAsync(query, songLimit, cancellationToken)
var artistsTask = SearchArtistsAsync(query, artistLimit, cancellationToken); : Task.FromResult(new List<Song>());
var albumsTask = albumLimit > 0
? SearchAlbumsAsync(query, albumLimit, cancellationToken)
: Task.FromResult(new List<Album>());
var artistsTask = artistLimit > 0
? SearchArtistsAsync(query, artistLimit, cancellationToken)
: Task.FromResult(new List<Artist>());
await Task.WhenAll(songsTask, albumsTask, artistsTask); await Task.WhenAll(songsTask, albumsTask, artistsTask);
@@ -498,14 +498,19 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
public async Task<SearchResult> SearchAllAsync(string query, int songLimit = 20, int albumLimit = 20, int artistLimit = 20, CancellationToken cancellationToken = default) public async Task<SearchResult> SearchAllAsync(string query, int songLimit = 20, int albumLimit = 20, int artistLimit = 20, CancellationToken cancellationToken = default)
{ {
// Execute searches in parallel var songsTask = songLimit > 0
var songsTask = SearchSongsAsync(query, songLimit, cancellationToken); ? SearchSongsAsync(query, songLimit, cancellationToken)
var albumsTask = SearchAlbumsAsync(query, albumLimit, cancellationToken); : Task.FromResult(new List<Song>());
var artistsTask = SearchArtistsAsync(query, artistLimit, cancellationToken); var albumsTask = albumLimit > 0
? SearchAlbumsAsync(query, albumLimit, cancellationToken)
: Task.FromResult(new List<Album>());
var artistsTask = artistLimit > 0
? SearchArtistsAsync(query, artistLimit, cancellationToken)
: Task.FromResult(new List<Artist>());
await Task.WhenAll(songsTask, albumsTask, artistsTask); await Task.WhenAll(songsTask, albumsTask, artistsTask);
var temp = new SearchResult var temp = new SearchResult
{ {
Songs = await songsTask, Songs = await songsTask,
Albums = await albumsTask, Albums = await albumsTask,