diff --git a/allstarr/Controllers/JellyfinController.cs b/allstarr/Controllers/JellyfinController.cs index 9ee37ae..1812c0d 100644 --- a/allstarr/Controllers/JellyfinController.cs +++ b/allstarr/Controllers/JellyfinController.cs @@ -972,23 +972,13 @@ public class JellyfinController : ControllerBase // Download and stream on-demand try { - // Use a timeout-based cancellation token instead of HttpContext.RequestAborted - // This allows downloads to complete even if the client disconnects - // The download will be cached for future requests - using var downloadCts = new CancellationTokenSource(TimeSpan.FromMinutes(10)); - var downloadStream = await _downloadService.DownloadAndStreamAsync( provider, externalId, - downloadCts.Token); + HttpContext.RequestAborted); return File(downloadStream, "audio/mpeg", enableRangeProcessing: true); } - catch (OperationCanceledException) - { - _logger.LogWarning("Download timeout for {Provider}:{ExternalId}", provider, externalId); - return StatusCode(504, new { error = "Download timeout" }); - } catch (Exception ex) { _logger.LogError(ex, "Failed to stream external song {Provider}:{ExternalId}", provider, externalId); diff --git a/allstarr/Services/Common/RoundRobinFallbackHelper.cs b/allstarr/Services/Common/RoundRobinFallbackHelper.cs index c338a65..601aa72 100644 --- a/allstarr/Services/Common/RoundRobinFallbackHelper.cs +++ b/allstarr/Services/Common/RoundRobinFallbackHelper.cs @@ -12,6 +12,8 @@ public class RoundRobinFallbackHelper private readonly ILogger _logger; private readonly string _serviceName; + public int EndpointCount => _apiUrls.Count; + public RoundRobinFallbackHelper(List apiUrls, ILogger logger, string serviceName) { _apiUrls = apiUrls ?? throw new ArgumentNullException(nameof(apiUrls)); @@ -66,6 +68,61 @@ public class RoundRobinFallbackHelper throw new Exception($"All {_serviceName} endpoints failed"); } + /// + /// Races all endpoints in parallel and returns the first successful result. + /// Cancels remaining requests once one succeeds. Great for latency-sensitive operations. + /// + public async Task RaceAllEndpointsAsync(Func> action, CancellationToken cancellationToken = default) + { + if (_apiUrls.Count == 1) + { + // No point racing with one endpoint + return await action(_apiUrls[0], cancellationToken); + } + + using var raceCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var tasks = new List>(); + + // Start all requests in parallel + foreach (var baseUrl in _apiUrls) + { + var task = Task.Run(async () => + { + try + { + _logger.LogDebug("Racing {Service} endpoint {Endpoint}", _serviceName, baseUrl); + var result = await action(baseUrl, raceCts.Token); + return (result, baseUrl, true); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "{Service} race failed for endpoint {Endpoint}", _serviceName, baseUrl); + return (default(T)!, baseUrl, false); + } + }, raceCts.Token); + + tasks.Add(task); + } + + // Wait for first successful completion + while (tasks.Count > 0) + { + var completedTask = await Task.WhenAny(tasks); + var (result, endpoint, success) = await completedTask; + + if (success) + { + _logger.LogInformation("🏁 {Service} race won by {Endpoint}, canceling others", _serviceName, endpoint); + raceCts.Cancel(); // Cancel all other requests + return result; + } + + tasks.Remove(completedTask); + } + + throw new Exception($"All {_serviceName} endpoints failed in race"); + } + /// /// Tries the request with the next provider in round-robin, then falls back to others on failure. /// Returns default value if all endpoints fail (does not throw). diff --git a/allstarr/Services/SquidWTF/SquidWTFDownloadService.cs b/allstarr/Services/SquidWTF/SquidWTFDownloadService.cs index 1fe44a7..fe2e865 100644 --- a/allstarr/Services/SquidWTF/SquidWTFDownloadService.cs +++ b/allstarr/Services/SquidWTF/SquidWTFDownloadService.cs @@ -103,15 +103,56 @@ public class SquidWTFDownloadService : BaseDownloadService // Resolve unique path if file already exists outputPath = PathHelper.ResolveUniquePath(outputPath); - // Download from Tidal CDN (no authentication needed, token is in URL) - var response = await QueueRequestAsync(async () => + // Race all endpoints to download from the fastest one + Logger.LogInformation("🏁 Racing {Count} endpoints for fastest download", _fallbackHelper.EndpointCount); + + var response = await _fallbackHelper.RaceAllEndpointsAsync(async (baseUrl, ct) => { - using var request = new HttpRequestMessage(HttpMethod.Get, downloadInfo.DownloadUrl); + var quality = _squidwtfSettings.Quality?.ToUpperInvariant() switch + { + "FLAC" => "LOSSLESS", + "HI_RES" => "HI_RES_LOSSLESS", + "LOSSLESS" => "LOSSLESS", + "HIGH" => "HIGH", + "LOW" => "LOW", + _ => "LOSSLESS" + }; + + var url = $"{baseUrl}/track/?id={trackId}&quality={quality}"; + + // Get download info from this endpoint + var infoResponse = await _httpClient.GetAsync(url, ct); + infoResponse.EnsureSuccessStatusCode(); + + var json = await infoResponse.Content.ReadAsStringAsync(ct); + var doc = JsonDocument.Parse(json); + + if (!doc.RootElement.TryGetProperty("data", out var data)) + { + throw new Exception("Invalid response from API"); + } + + var manifestBase64 = data.GetProperty("manifest").GetString() + ?? throw new Exception("No manifest in response"); + + var manifestJson = Encoding.UTF8.GetString(Convert.FromBase64String(manifestBase64)); + var manifest = JsonDocument.Parse(manifestJson); + + if (!manifest.RootElement.TryGetProperty("urls", out var urls) || urls.GetArrayLength() == 0) + { + throw new Exception("No download URLs in manifest"); + } + + var downloadUrl = urls[0].GetString() + ?? throw new Exception("Download URL is null"); + + // Start the actual download from Tidal CDN + using var request = new HttpRequestMessage(HttpMethod.Get, downloadUrl); request.Headers.Add("User-Agent", "Mozilla/5.0"); request.Headers.Add("Accept", "*/*"); - return await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); - }); + return await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct); + }, cancellationToken); response.EnsureSuccessStatusCode(); diff --git a/allstarr/Services/SquidWTF/SquidWTFMetadataService.cs b/allstarr/Services/SquidWTF/SquidWTFMetadataService.cs index 330bbc7..08cbd74 100644 --- a/allstarr/Services/SquidWTF/SquidWTFMetadataService.cs +++ b/allstarr/Services/SquidWTF/SquidWTFMetadataService.cs @@ -46,17 +46,18 @@ public class SquidWTFMetadataService : IMusicMetadataService public async Task> SearchSongsAsync(string query, int limit = 20) { - return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) => + // Race all endpoints for fastest search results + return await _fallbackHelper.RaceAllEndpointsAsync(async (baseUrl, ct) => { var url = $"{baseUrl}/search/?s={Uri.EscapeDataString(query)}"; - var response = await _httpClient.GetAsync(url); + var response = await _httpClient.GetAsync(url, ct); if (!response.IsSuccessStatusCode) { throw new HttpRequestException($"HTTP {response.StatusCode}"); } - var json = await response.Content.ReadAsStringAsync(); + var json = await response.Content.ReadAsStringAsync(ct); // Check for error in response body var result = JsonDocument.Parse(json); @@ -81,22 +82,23 @@ public class SquidWTFMetadataService : IMusicMetadataService } } return songs; - }, new List()); + }); } public async Task> SearchAlbumsAsync(string query, int limit = 20) { - return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) => + // Race all endpoints for fastest search results + return await _fallbackHelper.RaceAllEndpointsAsync(async (baseUrl, ct) => { var url = $"{baseUrl}/search/?al={Uri.EscapeDataString(query)}"; - var response = await _httpClient.GetAsync(url); + var response = await _httpClient.GetAsync(url, ct); if (!response.IsSuccessStatusCode) { - return new List(); + throw new HttpRequestException($"HTTP {response.StatusCode}"); } - var json = await response.Content.ReadAsStringAsync(); + var json = await response.Content.ReadAsStringAsync(ct); var result = JsonDocument.Parse(json); var albums = new List(); @@ -115,25 +117,26 @@ public class SquidWTFMetadataService : IMusicMetadataService } return albums; - }, new List()); + }); } public async Task> SearchArtistsAsync(string query, int limit = 20) { - return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) => + // Race all endpoints for fastest search results + return await _fallbackHelper.RaceAllEndpointsAsync(async (baseUrl, ct) => { var url = $"{baseUrl}/search/?a={Uri.EscapeDataString(query)}"; _logger.LogInformation("🔍 SQUIDWTF: Searching artists with URL: {Url}", url); - var response = await _httpClient.GetAsync(url); + var response = await _httpClient.GetAsync(url, ct); if (!response.IsSuccessStatusCode) { _logger.LogWarning("⚠️ SQUIDWTF: Artist search failed with status {StatusCode}", response.StatusCode); - return new List(); + throw new HttpRequestException($"HTTP {response.StatusCode}"); } - var json = await response.Content.ReadAsStringAsync(); + var json = await response.Content.ReadAsStringAsync(ct); var result = JsonDocument.Parse(json); var artists = new List(); @@ -155,7 +158,7 @@ public class SquidWTFMetadataService : IMusicMetadataService _logger.LogInformation("✓ SQUIDWTF: Artist search returned {Count} results", artists.Count); return artists; - }, new List()); + }); } public async Task> SearchPlaylistsAsync(string query, int limit = 20)