Files
allstarr/allstarr/Services/SquidWTF/SquidWTFStartupValidator.cs
Josh Patra 0dca6b792d Fix Spotify 429 rate limiting and startup performance issues
- Fix: Use correct HttpClient (_webApiClient) for GraphQL library playlists endpoint
  - Was using _httpClient which pointed to wrong base URL causing 429 errors
- Add: Retry logic with Retry-After header support for 429 responses
- Add: Minimum 500ms delay between library playlist pages to prevent rate limiting
- Add: 5-second timeout per endpoint benchmark ping to prevent slow endpoints from blocking startup
- Add: Documentation for timeout requirements in EndpointBenchmarkService
- Fix: ARM64 compatibility for spotify-lyrics service via platform emulation in docker-compose
2026-02-09 16:09:38 -05:00

187 lines
7.5 KiB
C#

using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Options;
using allstarr.Models.Settings;
using allstarr.Services.Validation;
using allstarr.Services.Common;
namespace allstarr.Services.SquidWTF;
/// <summary>
/// Validates SquidWTF service connectivity at startup (no auth needed)
/// </summary>
public class SquidWTFStartupValidator : BaseStartupValidator
{
private readonly SquidWTFSettings _settings;
private readonly RoundRobinFallbackHelper _fallbackHelper;
private readonly EndpointBenchmarkService _benchmarkService;
private readonly ILogger<SquidWTFStartupValidator> _logger;
public override string ServiceName => "SquidWTF";
public SquidWTFStartupValidator(
IOptions<SquidWTFSettings> settings,
HttpClient httpClient,
List<string> apiUrls,
EndpointBenchmarkService benchmarkService,
ILogger<SquidWTFStartupValidator> logger)
: base(httpClient)
{
_settings = settings.Value;
_fallbackHelper = new RoundRobinFallbackHelper(apiUrls, logger, "SquidWTF");
_benchmarkService = benchmarkService;
_logger = logger;
}
public override async Task<ValidationResult> ValidateAsync(CancellationToken cancellationToken)
{
Console.WriteLine();
var quality = _settings.Quality?.ToUpperInvariant() switch
{
"FLAC" => "LOSSLESS",
"HI_RES" => "HI_RES_LOSSLESS",
"LOSSLESS" => "LOSSLESS",
"HIGH" => "HIGH",
"LOW" => "LOW",
_ => "LOSSLESS (default)"
};
WriteStatus("SquidWTF Quality", quality, ConsoleColor.Cyan);
// Benchmark all endpoints to determine fastest
var apiUrls = _fallbackHelper.EndpointCount > 0
? Enumerable.Range(0, _fallbackHelper.EndpointCount).Select(_ => "").ToList() // Placeholder, we'll get actual URLs from fallback helper
: new List<string>();
// Get the actual API URLs by reflection (not ideal, but works for now)
var fallbackHelperType = _fallbackHelper.GetType();
var apiUrlsField = fallbackHelperType.GetField("_apiUrls", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
if (apiUrlsField != null)
{
apiUrls = (List<string>)apiUrlsField.GetValue(_fallbackHelper)!;
}
if (apiUrls.Count > 1)
{
WriteStatus("Benchmarking Endpoints", $"{apiUrls.Count} endpoints", ConsoleColor.Cyan);
var orderedEndpoints = await _benchmarkService.BenchmarkEndpointsAsync(
apiUrls,
async (endpoint, ct) =>
{
try
{
// 5 second timeout per ping - mark slow endpoints as failed
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeoutCts.CancelAfter(TimeSpan.FromSeconds(5));
var response = await _httpClient.GetAsync(endpoint, timeoutCts.Token);
return response.IsSuccessStatusCode;
}
catch
{
return false;
}
},
pingCount: 2,
cancellationToken);
if (orderedEndpoints.Count > 0)
{
_fallbackHelper.SetEndpointOrder(orderedEndpoints);
WriteDetail($"Fastest endpoint: {orderedEndpoints.First()}");
}
}
// Test connectivity with fallback
var result = await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
{
var response = await _httpClient.GetAsync(baseUrl, cancellationToken);
if (response.IsSuccessStatusCode)
{
WriteStatus("SquidWTF API", $"REACHABLE ({baseUrl})", ConsoleColor.Green);
WriteDetail("No authentication required - powered by Tidal");
// Try a test search to verify functionality
await ValidateSearchFunctionality(baseUrl, cancellationToken);
return ValidationResult.Success("SquidWTF validation completed");
}
else
{
throw new HttpRequestException($"HTTP {(int)response.StatusCode}");
}
}, ValidationResult.Failure("-1", "All SquidWTF endpoints failed"));
return result;
}
private async Task ValidateSearchFunctionality(string baseUrl, CancellationToken cancellationToken)
{
try
{
// Test search with "22" by Taylor Swift
var searchUrl = $"{baseUrl}/search/?s=22%20Taylor%20Swift";
var searchResponse = await _httpClient.GetAsync(searchUrl, cancellationToken);
if (searchResponse.IsSuccessStatusCode)
{
var json = await searchResponse.Content.ReadAsStringAsync(cancellationToken);
var doc = JsonDocument.Parse(json);
if (doc.RootElement.TryGetProperty("data", out var data) &&
data.TryGetProperty("items", out var items))
{
var itemCount = items.GetArrayLength();
WriteStatus("Search Functionality", "WORKING", ConsoleColor.Green);
WriteDetail($"Test search for '22' by Taylor Swift returned {itemCount} results");
// Check if we found the actual song
bool foundTaylorSwift22 = false;
foreach (var item in items.EnumerateArray())
{
if (item.TryGetProperty("title", out var title) &&
item.TryGetProperty("artists", out var artists) &&
artists.GetArrayLength() > 0)
{
var titleStr = title.GetString() ?? "";
var artistName = artists[0].TryGetProperty("name", out var name)
? name.GetString() ?? ""
: "";
if (titleStr.Contains("22", StringComparison.OrdinalIgnoreCase) &&
artistName.Contains("Taylor Swift", StringComparison.OrdinalIgnoreCase))
{
foundTaylorSwift22 = true;
var trackId = item.TryGetProperty("id", out var id) ? id.GetInt64() : 0;
WriteDetail($"✓ Found: '{titleStr}' by {artistName} (ID: {trackId})");
break;
}
}
}
if (!foundTaylorSwift22)
{
WriteDetail("⚠ Could not find exact match for '22' by Taylor Swift in results");
}
}
else
{
WriteStatus("Search Functionality", "UNEXPECTED RESPONSE", ConsoleColor.Yellow);
}
}
else
{
WriteStatus("Search Functionality", $"HTTP {(int)searchResponse.StatusCode}", ConsoleColor.Yellow);
}
}
catch (Exception ex)
{
WriteStatus("Search Functionality", "ERROR", ConsoleColor.Yellow);
WriteDetail($"Could not verify search: {ex.Message}");
}
}
}