diff --git a/allstarr/Controllers/JellyfinController.cs b/allstarr/Controllers/JellyfinController.cs index 9d00358..4dcb775 100644 --- a/allstarr/Controllers/JellyfinController.cs +++ b/allstarr/Controllers/JellyfinController.cs @@ -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 _logger; @@ -54,14 +56,17 @@ public class JellyfinController : ControllerBase JellyfinSessionManager sessionManager, RedisCacheService cache, ILogger 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>(); diff --git a/allstarr/Program.cs b/allstarr/Program.cs index cd39fa0..d2c0bae 100644 --- a/allstarr/Program.cs +++ b/allstarr/Program.cs @@ -455,6 +455,9 @@ else if (musicService == MusicService.SquidWTF) squidWtfApiUrls)); } +// Register ParallelMetadataService to race all registered providers for faster searches +builder.Services.AddSingleton(); + // Startup validation - register validators based on backend if (backendType == BackendType.Jellyfin) { diff --git a/allstarr/Services/Common/ParallelMetadataService.cs b/allstarr/Services/Common/ParallelMetadataService.cs new file mode 100644 index 0000000..791c88d --- /dev/null +++ b/allstarr/Services/Common/ParallelMetadataService.cs @@ -0,0 +1,135 @@ +using allstarr.Models.Domain; +using allstarr.Models.Search; + +namespace allstarr.Services.Common; + +/// +/// Races multiple metadata providers in parallel and returns the fastest result. +/// Used for search operations to minimize latency. +/// +public class ParallelMetadataService +{ + private readonly IEnumerable _providers; + private readonly ILogger _logger; + + public ParallelMetadataService( + IEnumerable providers, + ILogger logger) + { + _providers = providers; + _logger = logger; + } + + /// + /// Races all providers and returns the first successful result. + /// Falls back to next provider if first one fails. + /// + public async Task 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(); + } + + /// + /// Searches for a specific song by title and artist across all providers in parallel. + /// Returns the first successful match. + /// + public async Task 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; + } +}