mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-09 23:55:10 -05:00
Add parallel provider racing for searches and lyrics pre-fetching
- Created ParallelMetadataService to race all providers and return fastest result - Search now uses parallel service when available for lower latency - Pre-fetch LRCLib lyrics for top 3 search results in background - FuzzyMatcher already handles apostrophe normalization (applied everywhere)
This commit is contained in:
@@ -29,6 +29,7 @@ public class JellyfinController : ControllerBase
|
||||
private readonly SpotifyImportSettings _spotifySettings;
|
||||
private readonly SpotifyApiSettings _spotifyApiSettings;
|
||||
private readonly IMusicMetadataService _metadataService;
|
||||
private readonly ParallelMetadataService? _parallelMetadataService;
|
||||
private readonly ILocalLibraryService _localLibraryService;
|
||||
private readonly IDownloadService _downloadService;
|
||||
private readonly JellyfinResponseBuilder _responseBuilder;
|
||||
@@ -38,6 +39,7 @@ public class JellyfinController : ControllerBase
|
||||
private readonly PlaylistSyncService? _playlistSyncService;
|
||||
private readonly SpotifyPlaylistFetcher? _spotifyPlaylistFetcher;
|
||||
private readonly SpotifyLyricsService? _spotifyLyricsService;
|
||||
private readonly LrclibService? _lrclibService;
|
||||
private readonly RedisCacheService _cache;
|
||||
private readonly ILogger<JellyfinController> _logger;
|
||||
|
||||
@@ -54,14 +56,17 @@ public class JellyfinController : ControllerBase
|
||||
JellyfinSessionManager sessionManager,
|
||||
RedisCacheService cache,
|
||||
ILogger<JellyfinController> logger,
|
||||
ParallelMetadataService? parallelMetadataService = null,
|
||||
PlaylistSyncService? playlistSyncService = null,
|
||||
SpotifyPlaylistFetcher? spotifyPlaylistFetcher = null,
|
||||
SpotifyLyricsService? spotifyLyricsService = null)
|
||||
SpotifyLyricsService? spotifyLyricsService = null,
|
||||
LrclibService? lrclibService = null)
|
||||
{
|
||||
_settings = settings.Value;
|
||||
_spotifySettings = spotifySettings.Value;
|
||||
_spotifyApiSettings = spotifyApiSettings.Value;
|
||||
_metadataService = metadataService;
|
||||
_parallelMetadataService = parallelMetadataService;
|
||||
_localLibraryService = localLibraryService;
|
||||
_downloadService = downloadService;
|
||||
_responseBuilder = responseBuilder;
|
||||
@@ -71,6 +76,7 @@ public class JellyfinController : ControllerBase
|
||||
_playlistSyncService = playlistSyncService;
|
||||
_spotifyPlaylistFetcher = spotifyPlaylistFetcher;
|
||||
_spotifyLyricsService = spotifyLyricsService;
|
||||
_lrclibService = lrclibService;
|
||||
_cache = cache;
|
||||
_logger = logger;
|
||||
|
||||
@@ -241,7 +247,11 @@ public class JellyfinController : ControllerBase
|
||||
// Run local and external searches in parallel
|
||||
var itemTypes = ParseItemTypes(includeItemTypes);
|
||||
var jellyfinTask = _proxyService.SearchAsync(cleanQuery, itemTypes, limit, recursive, Request.Headers);
|
||||
var externalTask = _metadataService.SearchAllAsync(cleanQuery, limit, limit, limit);
|
||||
|
||||
// Use parallel metadata service if available (races providers), otherwise use primary
|
||||
var externalTask = _parallelMetadataService != null
|
||||
? _parallelMetadataService.SearchAllAsync(cleanQuery, limit, limit, limit)
|
||||
: _metadataService.SearchAllAsync(cleanQuery, limit, limit, limit);
|
||||
|
||||
var playlistTask = _settings.EnableExternalPlaylists
|
||||
? _metadataService.SearchPlaylistsAsync(cleanQuery, limit)
|
||||
@@ -312,6 +322,39 @@ public class JellyfinController : ControllerBase
|
||||
_logger.LogInformation("Scored and filtered results: Songs={Songs}, Albums={Albums}, Artists={Artists}",
|
||||
mergedSongs.Count, mergedAlbums.Count, mergedArtists.Count);
|
||||
|
||||
// Pre-fetch lyrics for top 3 songs in background (don't await)
|
||||
if (_lrclibService != null && mergedSongs.Count > 0)
|
||||
{
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var top3 = mergedSongs.Take(3).ToList();
|
||||
_logger.LogDebug("🎵 Pre-fetching lyrics for top {Count} search results", top3.Count);
|
||||
|
||||
foreach (var songItem in top3)
|
||||
{
|
||||
if (songItem.TryGetValue("Name", out var nameObj) && nameObj is JsonElement nameEl &&
|
||||
songItem.TryGetValue("Artists", out var artistsObj) && artistsObj is JsonElement artistsEl &&
|
||||
artistsEl.GetArrayLength() > 0)
|
||||
{
|
||||
var title = nameEl.GetString() ?? "";
|
||||
var artist = artistsEl[0].GetString() ?? "";
|
||||
|
||||
if (!string.IsNullOrEmpty(title) && !string.IsNullOrEmpty(artist))
|
||||
{
|
||||
await _lrclibService.GetLyricsAsync(title, artist, null, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to pre-fetch lyrics for search results");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Filter by item types if specified
|
||||
var items = new List<Dictionary<string, object?>>();
|
||||
|
||||
|
||||
@@ -455,6 +455,9 @@ else if (musicService == MusicService.SquidWTF)
|
||||
squidWtfApiUrls));
|
||||
}
|
||||
|
||||
// Register ParallelMetadataService to race all registered providers for faster searches
|
||||
builder.Services.AddSingleton<ParallelMetadataService>();
|
||||
|
||||
// Startup validation - register validators based on backend
|
||||
if (backendType == BackendType.Jellyfin)
|
||||
{
|
||||
|
||||
135
allstarr/Services/Common/ParallelMetadataService.cs
Normal file
135
allstarr/Services/Common/ParallelMetadataService.cs
Normal file
@@ -0,0 +1,135 @@
|
||||
using allstarr.Models.Domain;
|
||||
using allstarr.Models.Search;
|
||||
|
||||
namespace allstarr.Services.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Races multiple metadata providers in parallel and returns the fastest result.
|
||||
/// Used for search operations to minimize latency.
|
||||
/// </summary>
|
||||
public class ParallelMetadataService
|
||||
{
|
||||
private readonly IEnumerable<IMusicMetadataService> _providers;
|
||||
private readonly ILogger<ParallelMetadataService> _logger;
|
||||
|
||||
public ParallelMetadataService(
|
||||
IEnumerable<IMusicMetadataService> providers,
|
||||
ILogger<ParallelMetadataService> logger)
|
||||
{
|
||||
_providers = providers;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Races all providers and returns the first successful result.
|
||||
/// Falls back to next provider if first one fails.
|
||||
/// </summary>
|
||||
public async Task<SearchResult> SearchAllAsync(string query, int songLimit = 20, int albumLimit = 20, int artistLimit = 20)
|
||||
{
|
||||
if (!_providers.Any())
|
||||
{
|
||||
_logger.LogWarning("No metadata providers available for parallel search");
|
||||
return new SearchResult();
|
||||
}
|
||||
|
||||
_logger.LogDebug("🏁 Racing {Count} providers for search: {Query}", _providers.Count(), query);
|
||||
|
||||
// Create tasks for all providers
|
||||
var tasks = _providers.Select(async provider =>
|
||||
{
|
||||
var providerName = provider.GetType().Name;
|
||||
try
|
||||
{
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
var result = await provider.SearchAllAsync(query, songLimit, albumLimit, artistLimit);
|
||||
sw.Stop();
|
||||
|
||||
_logger.LogInformation("✅ {Provider} completed search in {Ms}ms ({Songs} songs, {Albums} albums, {Artists} artists)",
|
||||
providerName, sw.ElapsedMilliseconds, result.Songs.Count, result.Albums.Count, result.Artists.Count);
|
||||
|
||||
return (Success: true, Result: result, Provider: providerName, ElapsedMs: sw.ElapsedMilliseconds);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "❌ {Provider} search failed", providerName);
|
||||
return (Success: false, Result: new SearchResult(), Provider: providerName, ElapsedMs: 0L);
|
||||
}
|
||||
}).ToList();
|
||||
|
||||
// Wait for first successful result
|
||||
while (tasks.Any())
|
||||
{
|
||||
var completedTask = await Task.WhenAny(tasks);
|
||||
var result = await completedTask;
|
||||
|
||||
if (result.Success && (result.Result.Songs.Any() || result.Result.Albums.Any() || result.Result.Artists.Any()))
|
||||
{
|
||||
_logger.LogInformation("🏆 Using results from {Provider} ({Ms}ms) - fastest with results",
|
||||
result.Provider, result.ElapsedMs);
|
||||
return result.Result;
|
||||
}
|
||||
|
||||
// Remove completed task and try next
|
||||
tasks.Remove(completedTask);
|
||||
}
|
||||
|
||||
// All providers failed or returned empty
|
||||
_logger.LogWarning("⚠️ All providers failed or returned empty results for: {Query}", query);
|
||||
return new SearchResult();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Searches for a specific song by title and artist across all providers in parallel.
|
||||
/// Returns the first successful match.
|
||||
/// </summary>
|
||||
public async Task<Song?> SearchSongAsync(string title, string artist, int limit = 5)
|
||||
{
|
||||
if (!_providers.Any())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
_logger.LogDebug("🏁 Racing {Count} providers for song: {Title} - {Artist}", _providers.Count(), title, artist);
|
||||
|
||||
var tasks = _providers.Select(async provider =>
|
||||
{
|
||||
var providerName = provider.GetType().Name;
|
||||
try
|
||||
{
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
var songs = await provider.SearchSongsAsync($"{title} {artist}", limit);
|
||||
sw.Stop();
|
||||
|
||||
var bestMatch = songs.FirstOrDefault();
|
||||
if (bestMatch != null)
|
||||
{
|
||||
_logger.LogInformation("✅ {Provider} found song in {Ms}ms", providerName, sw.ElapsedMilliseconds);
|
||||
}
|
||||
|
||||
return (Success: bestMatch != null, Song: bestMatch, Provider: providerName, ElapsedMs: sw.ElapsedMilliseconds);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "❌ {Provider} song search failed", providerName);
|
||||
return (Success: false, Song: (Song?)null, Provider: providerName, ElapsedMs: 0L);
|
||||
}
|
||||
}).ToList();
|
||||
|
||||
// Wait for first successful result
|
||||
while (tasks.Any())
|
||||
{
|
||||
var completedTask = await Task.WhenAny(tasks);
|
||||
var result = await completedTask;
|
||||
|
||||
if (result.Success && result.Song != null)
|
||||
{
|
||||
_logger.LogInformation("🏆 Using song from {Provider} ({Ms}ms)", result.Provider, result.ElapsedMs);
|
||||
return result.Song;
|
||||
}
|
||||
|
||||
tasks.Remove(completedTask);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user