mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-09 23:55:10 -05:00
Refactor: Extract duplicate code into reusable helpers
- Created RoundRobinFallbackHelper for SquidWTF services (eliminates 3 duplicates) - Moved QueueRequestAsync to BaseDownloadService (eliminates 2 duplicates) - Moved CalculateArtistMatchScore to FuzzyMatcher (eliminates 2 duplicates) - Updated all SquidWTF services to use RoundRobinFallbackHelper - Updated DeezerDownloadService and SquidWTFDownloadService to use base class rate limiting - Updated SpotifyTrackMatchingService and JellyfinController to use FuzzyMatcher helper - All 225 tests passing
This commit is contained in:
@@ -972,13 +972,23 @@ 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,
|
||||
HttpContext.RequestAborted);
|
||||
downloadCts.Token);
|
||||
|
||||
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);
|
||||
@@ -3753,8 +3763,7 @@ public class JellyfinController : ControllerBase
|
||||
{
|
||||
Song = song,
|
||||
TitleScore = FuzzyMatcher.CalculateSimilarity(track.Title, song.Title),
|
||||
// Calculate artist score by checking ALL artists match
|
||||
ArtistScore = CalculateArtistMatchScore(track.Artists, song.Artist, song.Contributors)
|
||||
ArtistScore = FuzzyMatcher.CalculateArtistMatchScore(track.Artists, song.Artist, song.Contributors)
|
||||
})
|
||||
.Select(x => new
|
||||
{
|
||||
|
||||
@@ -483,7 +483,8 @@ builder.Services.AddSingleton<IStartupValidator>(sp =>
|
||||
new SquidWTFStartupValidator(
|
||||
sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<SquidWTFSettings>>(),
|
||||
sp.GetRequiredService<IHttpClientFactory>().CreateClient(),
|
||||
squidWtfApiUrls));
|
||||
squidWtfApiUrls,
|
||||
sp.GetRequiredService<ILogger<SquidWTFStartupValidator>>()));
|
||||
builder.Services.AddSingleton<IStartupValidator, LyricsStartupValidator>();
|
||||
|
||||
// Register orchestrator as hosted service
|
||||
|
||||
@@ -31,6 +31,11 @@ public abstract class BaseDownloadService : IDownloadService
|
||||
protected readonly ConcurrentDictionary<string, DownloadInfo> ActiveDownloads = new();
|
||||
protected readonly SemaphoreSlim DownloadLock = new(1, 1);
|
||||
|
||||
// Rate limiting fields
|
||||
private readonly SemaphoreSlim _requestLock = new(1, 1);
|
||||
private DateTime _lastRequestTime = DateTime.MinValue;
|
||||
private readonly int _minRequestIntervalMs = 200;
|
||||
|
||||
/// <summary>
|
||||
/// Lazy-loaded PlaylistSyncService to avoid circular dependency
|
||||
/// </summary>
|
||||
@@ -604,4 +609,34 @@ public abstract class BaseDownloadService : IDownloadService
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Rate Limiting
|
||||
|
||||
/// <summary>
|
||||
/// Queues a request with rate limiting to prevent overwhelming the API.
|
||||
/// Ensures minimum interval between requests.
|
||||
/// </summary>
|
||||
protected async Task<T> QueueRequestAsync<T>(Func<Task<T>> action)
|
||||
{
|
||||
await _requestLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var timeSinceLastRequest = (now - _lastRequestTime).TotalMilliseconds;
|
||||
|
||||
if (timeSinceLastRequest < _minRequestIntervalMs)
|
||||
{
|
||||
await Task.Delay((int)(_minRequestIntervalMs - timeSinceLastRequest));
|
||||
}
|
||||
|
||||
_lastRequestTime = DateTime.UtcNow;
|
||||
return await action();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_requestLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -221,4 +221,54 @@ public static class FuzzyMatcher
|
||||
|
||||
return distance[sourceLength, targetLength];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates artist match score between Spotify artists and local song artists.
|
||||
/// Checks bidirectional matching and penalizes mismatches.
|
||||
/// Penalizes if artist counts don't match or if any artist is missing.
|
||||
/// Returns score 0-100.
|
||||
/// </summary>
|
||||
public static double CalculateArtistMatchScore(List<string> spotifyArtists, string songMainArtist, List<string> songContributors)
|
||||
{
|
||||
if (spotifyArtists.Count == 0 || string.IsNullOrEmpty(songMainArtist))
|
||||
return 0;
|
||||
|
||||
// Build list of all song artists (main + contributors)
|
||||
var allSongArtists = new List<string> { songMainArtist };
|
||||
allSongArtists.AddRange(songContributors);
|
||||
|
||||
// If artist counts differ significantly, penalize
|
||||
var countDiff = Math.Abs(spotifyArtists.Count - allSongArtists.Count);
|
||||
if (countDiff > 1) // Allow 1 artist difference (sometimes features are listed differently)
|
||||
return 0;
|
||||
|
||||
// Check that each Spotify artist has a good match in song artists
|
||||
var spotifyScores = new List<double>();
|
||||
foreach (var spotifyArtist in spotifyArtists)
|
||||
{
|
||||
var bestMatch = allSongArtists.Max(songArtist =>
|
||||
CalculateSimilarity(spotifyArtist, songArtist));
|
||||
spotifyScores.Add(bestMatch);
|
||||
}
|
||||
|
||||
// Check that each song artist has a good match in Spotify artists
|
||||
var songScores = new List<double>();
|
||||
foreach (var songArtist in allSongArtists)
|
||||
{
|
||||
var bestMatch = spotifyArtists.Max(spotifyArtist =>
|
||||
CalculateSimilarity(songArtist, spotifyArtist));
|
||||
songScores.Add(bestMatch);
|
||||
}
|
||||
|
||||
// Average all scores - this ensures ALL artists must match well
|
||||
var allScores = spotifyScores.Concat(songScores);
|
||||
var avgScore = allScores.Average();
|
||||
|
||||
// Penalize if any individual artist match is poor (< 70)
|
||||
var minScore = allScores.Min();
|
||||
if (minScore < 70)
|
||||
avgScore *= 0.7; // 30% penalty for poor individual match
|
||||
|
||||
return avgScore;
|
||||
}
|
||||
}
|
||||
|
||||
110
allstarr/Services/Common/RoundRobinFallbackHelper.cs
Normal file
110
allstarr/Services/Common/RoundRobinFallbackHelper.cs
Normal file
@@ -0,0 +1,110 @@
|
||||
namespace allstarr.Services.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Helper for round-robin load balancing with fallback across multiple API endpoints.
|
||||
/// Distributes load evenly while maintaining reliability through automatic failover.
|
||||
/// </summary>
|
||||
public class RoundRobinFallbackHelper
|
||||
{
|
||||
private readonly List<string> _apiUrls;
|
||||
private int _currentUrlIndex = 0;
|
||||
private readonly object _urlIndexLock = new object();
|
||||
private readonly ILogger _logger;
|
||||
private readonly string _serviceName;
|
||||
|
||||
public RoundRobinFallbackHelper(List<string> apiUrls, ILogger logger, string serviceName)
|
||||
{
|
||||
_apiUrls = apiUrls ?? throw new ArgumentNullException(nameof(apiUrls));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_serviceName = serviceName ?? "Service";
|
||||
|
||||
if (_apiUrls.Count == 0)
|
||||
{
|
||||
throw new ArgumentException("API URLs list cannot be empty", nameof(apiUrls));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries the request with the next provider in round-robin, then falls back to others on failure.
|
||||
/// This distributes load evenly across all providers while maintaining reliability.
|
||||
/// Throws exception if all endpoints fail.
|
||||
/// </summary>
|
||||
public async Task<T> TryWithFallbackAsync<T>(Func<string, Task<T>> action)
|
||||
{
|
||||
// Start with the next URL in round-robin to distribute load
|
||||
var startIndex = 0;
|
||||
lock (_urlIndexLock)
|
||||
{
|
||||
startIndex = _currentUrlIndex;
|
||||
_currentUrlIndex = (_currentUrlIndex + 1) % _apiUrls.Count;
|
||||
}
|
||||
|
||||
// Try all URLs starting from the round-robin selected one
|
||||
for (int attempt = 0; attempt < _apiUrls.Count; attempt++)
|
||||
{
|
||||
var urlIndex = (startIndex + attempt) % _apiUrls.Count;
|
||||
var baseUrl = _apiUrls[urlIndex];
|
||||
|
||||
try
|
||||
{
|
||||
_logger.LogDebug("Trying {Service} endpoint {Endpoint} (attempt {Attempt}/{Total})",
|
||||
_serviceName, baseUrl, attempt + 1, _apiUrls.Count);
|
||||
return await action(baseUrl);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "{Service} request failed with endpoint {Endpoint}, trying next...",
|
||||
_serviceName, baseUrl);
|
||||
|
||||
if (attempt == _apiUrls.Count - 1)
|
||||
{
|
||||
_logger.LogError("All {Count} {Service} endpoints failed", _apiUrls.Count, _serviceName);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Exception($"All {_serviceName} endpoints failed");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
public async Task<T> TryWithFallbackAsync<T>(Func<string, Task<T>> action, T defaultValue)
|
||||
{
|
||||
// Start with the next URL in round-robin to distribute load
|
||||
var startIndex = 0;
|
||||
lock (_urlIndexLock)
|
||||
{
|
||||
startIndex = _currentUrlIndex;
|
||||
_currentUrlIndex = (_currentUrlIndex + 1) % _apiUrls.Count;
|
||||
}
|
||||
|
||||
// Try all URLs starting from the round-robin selected one
|
||||
for (int attempt = 0; attempt < _apiUrls.Count; attempt++)
|
||||
{
|
||||
var urlIndex = (startIndex + attempt) % _apiUrls.Count;
|
||||
var baseUrl = _apiUrls[urlIndex];
|
||||
|
||||
try
|
||||
{
|
||||
_logger.LogDebug("Trying {Service} endpoint {Endpoint} (attempt {Attempt}/{Total})",
|
||||
_serviceName, baseUrl, attempt + 1, _apiUrls.Count);
|
||||
return await action(baseUrl);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "{Service} request failed with endpoint {Endpoint}, trying next...",
|
||||
_serviceName, baseUrl);
|
||||
|
||||
if (attempt == _apiUrls.Count - 1)
|
||||
{
|
||||
_logger.LogError("All {Count} {Service} endpoints failed, returning default value",
|
||||
_apiUrls.Count, _serviceName);
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,6 @@ namespace allstarr.Services.Deezer;
|
||||
public class DeezerDownloadService : BaseDownloadService
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly SemaphoreSlim _requestLock = new(1, 1);
|
||||
|
||||
private readonly string? _arl;
|
||||
private readonly string? _arlFallback;
|
||||
@@ -33,9 +32,6 @@ public class DeezerDownloadService : BaseDownloadService
|
||||
private string? _apiToken;
|
||||
private string? _licenseToken;
|
||||
|
||||
private DateTime _lastRequestTime = DateTime.MinValue;
|
||||
private readonly int _minRequestIntervalMs = 200;
|
||||
|
||||
private const string DeezerApiBase = "https://api.deezer.com";
|
||||
|
||||
// Deezer's standard Blowfish CBC encryption key for track decryption
|
||||
@@ -497,27 +493,6 @@ public class DeezerDownloadService : BaseDownloadService
|
||||
await RetryWithBackoffAsync<bool>(action, maxRetries, initialDelayMs);
|
||||
}
|
||||
|
||||
private async Task<T> QueueRequestAsync<T>(Func<Task<T>> action)
|
||||
{
|
||||
await _requestLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var timeSinceLastRequest = (now - _lastRequestTime).TotalMilliseconds;
|
||||
|
||||
if (timeSinceLastRequest < _minRequestIntervalMs)
|
||||
{
|
||||
await Task.Delay((int)(_minRequestIntervalMs - timeSinceLastRequest));
|
||||
}
|
||||
|
||||
_lastRequestTime = DateTime.UtcNow;
|
||||
return await action();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_requestLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
|
||||
@@ -554,7 +554,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
Song = song,
|
||||
// Use aggressive matching which follows optimal order internally
|
||||
TitleScore = FuzzyMatcher.CalculateSimilarityAggressive(title, song.Title),
|
||||
ArtistScore = CalculateArtistMatchScore(artists, song.Artist, song.Contributors)
|
||||
ArtistScore = FuzzyMatcher.CalculateArtistMatchScore(artists, song.Artist, song.Contributors)
|
||||
})
|
||||
.Select(x => new
|
||||
{
|
||||
@@ -640,7 +640,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
Song = song,
|
||||
// Use aggressive matching which follows optimal order internally
|
||||
TitleScore = FuzzyMatcher.CalculateSimilarityAggressive(title, song.Title),
|
||||
ArtistScore = CalculateArtistMatchScore(artists, song.Artist, song.Contributors)
|
||||
ArtistScore = FuzzyMatcher.CalculateArtistMatchScore(artists, song.Artist, song.Contributors)
|
||||
})
|
||||
.Select(x => new
|
||||
{
|
||||
@@ -744,7 +744,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
Song = song,
|
||||
TitleScore = FuzzyMatcher.CalculateSimilarity(track.Title, song.Title),
|
||||
// Calculate artist score by checking ALL artists match
|
||||
ArtistScore = CalculateArtistMatchScore(track.Artists, song.Artist, song.Contributors)
|
||||
ArtistScore = FuzzyMatcher.CalculateArtistMatchScore(track.Artists, song.Artist, song.Contributors)
|
||||
})
|
||||
.Select(x => new
|
||||
{
|
||||
|
||||
@@ -20,16 +20,9 @@ namespace allstarr.Services.SquidWTF;
|
||||
public class SquidWTFDownloadService : BaseDownloadService
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly SemaphoreSlim _requestLock = new(1, 1);
|
||||
private readonly SquidWTFSettings _squidwtfSettings;
|
||||
private readonly OdesliService _odesliService;
|
||||
|
||||
private DateTime _lastRequestTime = DateTime.MinValue;
|
||||
private readonly int _minRequestIntervalMs = 200;
|
||||
|
||||
private readonly List<string> _apiUrls;
|
||||
private int _currentUrlIndex = 0;
|
||||
private readonly object _urlIndexLock = new object();
|
||||
private readonly RoundRobinFallbackHelper _fallbackHelper;
|
||||
|
||||
protected override string ProviderName => "squidwtf";
|
||||
|
||||
@@ -49,54 +42,15 @@ public class SquidWTFDownloadService : BaseDownloadService
|
||||
_httpClient = httpClientFactory.CreateClient();
|
||||
_squidwtfSettings = SquidWTFSettings.Value;
|
||||
_odesliService = odesliService;
|
||||
_apiUrls = apiUrls;
|
||||
_fallbackHelper = new RoundRobinFallbackHelper(apiUrls, logger, "SquidWTF");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries the request with the next provider in round-robin, then falls back to others on failure.
|
||||
/// This distributes load evenly across all providers while maintaining reliability.
|
||||
/// </summary>
|
||||
private async Task<T> TryWithFallbackAsync<T>(Func<string, Task<T>> action)
|
||||
{
|
||||
// Start with the next URL in round-robin to distribute load
|
||||
var startIndex = 0;
|
||||
lock (_urlIndexLock)
|
||||
{
|
||||
startIndex = _currentUrlIndex;
|
||||
_currentUrlIndex = (_currentUrlIndex + 1) % _apiUrls.Count;
|
||||
}
|
||||
|
||||
// Try all URLs starting from the round-robin selected one
|
||||
for (int attempt = 0; attempt < _apiUrls.Count; attempt++)
|
||||
{
|
||||
var urlIndex = (startIndex + attempt) % _apiUrls.Count;
|
||||
var baseUrl = _apiUrls[urlIndex];
|
||||
|
||||
try
|
||||
{
|
||||
Logger.LogDebug("Trying endpoint {Endpoint} (attempt {Attempt}/{Total})",
|
||||
baseUrl, attempt + 1, _apiUrls.Count);
|
||||
return await action(baseUrl);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogWarning(ex, "Request failed with endpoint {Endpoint}, trying next...", baseUrl);
|
||||
|
||||
if (attempt == _apiUrls.Count - 1)
|
||||
{
|
||||
Logger.LogError("All {Count} SquidWTF endpoints failed", _apiUrls.Count);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Exception("All SquidWTF endpoints failed");
|
||||
}
|
||||
|
||||
#region BaseDownloadService Implementation
|
||||
|
||||
public override async Task<bool> IsAvailableAsync()
|
||||
{
|
||||
return await TryWithFallbackAsync(async (baseUrl) =>
|
||||
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
||||
{
|
||||
var response = await _httpClient.GetAsync(baseUrl);
|
||||
Console.WriteLine($"Response code from is available async: {response.IsSuccessStatusCode}");
|
||||
@@ -191,7 +145,7 @@ public class SquidWTFDownloadService : BaseDownloadService
|
||||
{
|
||||
return await QueueRequestAsync(async () =>
|
||||
{
|
||||
return await TryWithFallbackAsync(async (baseUrl) =>
|
||||
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
||||
{
|
||||
// Map quality settings to Tidal's quality levels
|
||||
var quality = _squidwtfSettings.Quality?.ToUpperInvariant() switch
|
||||
@@ -262,27 +216,6 @@ public class SquidWTFDownloadService : BaseDownloadService
|
||||
|
||||
#region Utility Methods
|
||||
|
||||
private async Task<T> QueueRequestAsync<T>(Func<Task<T>> action)
|
||||
{
|
||||
await _requestLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var timeSinceLastRequest = (now - _lastRequestTime).TotalMilliseconds;
|
||||
|
||||
if (timeSinceLastRequest < _minRequestIntervalMs)
|
||||
{
|
||||
await Task.Delay((int)(_minRequestIntervalMs - timeSinceLastRequest));
|
||||
}
|
||||
|
||||
_lastRequestTime = DateTime.UtcNow;
|
||||
return await action();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_requestLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
|
||||
@@ -21,9 +21,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
private readonly SubsonicSettings _settings;
|
||||
private readonly ILogger<SquidWTFMetadataService> _logger;
|
||||
private readonly RedisCacheService _cache;
|
||||
private readonly List<string> _apiUrls;
|
||||
private int _currentUrlIndex = 0;
|
||||
private readonly object _urlIndexLock = new object();
|
||||
private readonly RoundRobinFallbackHelper _fallbackHelper;
|
||||
|
||||
public SquidWTFMetadataService(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
@@ -37,69 +35,18 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
_settings = settings.Value;
|
||||
_logger = logger;
|
||||
_cache = cache;
|
||||
_apiUrls = apiUrls;
|
||||
_fallbackHelper = new RoundRobinFallbackHelper(apiUrls, logger, "SquidWTF");
|
||||
|
||||
// Set up default headers
|
||||
_httpClient.DefaultRequestHeaders.Add("User-Agent",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:83.0) Gecko/20100101 Firefox/83.0");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the next URL in round-robin fashion to distribute load across providers
|
||||
/// </summary>
|
||||
private string GetNextBaseUrl()
|
||||
{
|
||||
lock (_urlIndexLock)
|
||||
{
|
||||
var url = _apiUrls[_currentUrlIndex];
|
||||
_currentUrlIndex = (_currentUrlIndex + 1) % _apiUrls.Count;
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries the request with the next provider in round-robin, then falls back to others on failure.
|
||||
/// This distributes load evenly across all providers while maintaining reliability.
|
||||
/// </summary>
|
||||
private async Task<T> TryWithFallbackAsync<T>(Func<string, Task<T>> action, T defaultValue)
|
||||
{
|
||||
// Start with the next URL in round-robin to distribute load
|
||||
var startIndex = 0;
|
||||
lock (_urlIndexLock)
|
||||
{
|
||||
startIndex = _currentUrlIndex;
|
||||
_currentUrlIndex = (_currentUrlIndex + 1) % _apiUrls.Count;
|
||||
}
|
||||
|
||||
// Try all URLs starting from the round-robin selected one
|
||||
for (int attempt = 0; attempt < _apiUrls.Count; attempt++)
|
||||
{
|
||||
var urlIndex = (startIndex + attempt) % _apiUrls.Count;
|
||||
var baseUrl = _apiUrls[urlIndex];
|
||||
|
||||
try
|
||||
{
|
||||
_logger.LogDebug("Trying endpoint {Endpoint} (attempt {Attempt}/{Total})",
|
||||
baseUrl, attempt + 1, _apiUrls.Count);
|
||||
return await action(baseUrl);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Request failed with endpoint {Endpoint}, trying next...", baseUrl);
|
||||
|
||||
if (attempt == _apiUrls.Count - 1)
|
||||
{
|
||||
_logger.LogError("All {Count} SquidWTF endpoints failed", _apiUrls.Count);
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
public async Task<List<Song>> SearchSongsAsync(string query, int limit = 20)
|
||||
{
|
||||
return await TryWithFallbackAsync(async (baseUrl) =>
|
||||
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
||||
{
|
||||
var url = $"{baseUrl}/search/?s={Uri.EscapeDataString(query)}";
|
||||
var response = await _httpClient.GetAsync(url);
|
||||
@@ -139,7 +86,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
|
||||
public async Task<List<Album>> SearchAlbumsAsync(string query, int limit = 20)
|
||||
{
|
||||
return await TryWithFallbackAsync(async (baseUrl) =>
|
||||
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
||||
{
|
||||
var url = $"{baseUrl}/search/?al={Uri.EscapeDataString(query)}";
|
||||
var response = await _httpClient.GetAsync(url);
|
||||
@@ -173,7 +120,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
|
||||
public async Task<List<Artist>> SearchArtistsAsync(string query, int limit = 20)
|
||||
{
|
||||
return await TryWithFallbackAsync(async (baseUrl) =>
|
||||
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
||||
{
|
||||
var url = $"{baseUrl}/search/?a={Uri.EscapeDataString(query)}";
|
||||
_logger.LogInformation("🔍 SQUIDWTF: Searching artists with URL: {Url}", url);
|
||||
@@ -213,7 +160,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
|
||||
public async Task<List<ExternalPlaylist>> SearchPlaylistsAsync(string query, int limit = 20)
|
||||
{
|
||||
return await TryWithFallbackAsync(async (baseUrl) =>
|
||||
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
||||
{
|
||||
var url = $"{baseUrl}/search/?p={Uri.EscapeDataString(query)}";
|
||||
var response = await _httpClient.GetAsync(url);
|
||||
@@ -267,7 +214,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
{
|
||||
if (externalProvider != "squidwtf") return null;
|
||||
|
||||
return await TryWithFallbackAsync(async (baseUrl) =>
|
||||
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
||||
{
|
||||
var url = $"{baseUrl}/info/?id={externalId}";
|
||||
|
||||
@@ -298,7 +245,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
var cached = await _cache.GetAsync<Album>(cacheKey);
|
||||
if (cached != null) return cached;
|
||||
|
||||
return await TryWithFallbackAsync(async (baseUrl) =>
|
||||
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
||||
{
|
||||
var url = $"{baseUrl}/album/?id={externalId}";
|
||||
|
||||
@@ -352,7 +299,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
return cached;
|
||||
}
|
||||
|
||||
return await TryWithFallbackAsync(async (baseUrl) =>
|
||||
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
||||
{
|
||||
var url = $"{baseUrl}/artist/?f={externalId}";
|
||||
_logger.LogInformation("Fetching artist from {Url}", url);
|
||||
@@ -427,7 +374,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
{
|
||||
if (externalProvider != "squidwtf") return new List<Album>();
|
||||
|
||||
return await TryWithFallbackAsync(async (baseUrl) =>
|
||||
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
||||
{
|
||||
_logger.LogInformation("GetArtistAlbumsAsync called for SquidWTF artist {ExternalId}", externalId);
|
||||
|
||||
@@ -472,7 +419,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
{
|
||||
if (externalProvider != "squidwtf") return null;
|
||||
|
||||
return await TryWithFallbackAsync(async (baseUrl) =>
|
||||
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
||||
{
|
||||
var url = $"{baseUrl}/playlist/?id={externalId}";
|
||||
var response = await _httpClient.GetAsync(url);
|
||||
@@ -491,7 +438,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
{
|
||||
if (externalProvider != "squidwtf") return new List<Song>();
|
||||
|
||||
return await TryWithFallbackAsync(async (baseUrl) =>
|
||||
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
||||
{
|
||||
var url = $"{baseUrl}/playlist/?id={externalId}";
|
||||
var response = await _httpClient.GetAsync(url);
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Text.Json;
|
||||
using Microsoft.Extensions.Options;
|
||||
using allstarr.Models.Settings;
|
||||
using allstarr.Services.Validation;
|
||||
using allstarr.Services.Common;
|
||||
|
||||
namespace allstarr.Services.SquidWTF;
|
||||
|
||||
@@ -12,56 +13,17 @@ namespace allstarr.Services.SquidWTF;
|
||||
public class SquidWTFStartupValidator : BaseStartupValidator
|
||||
{
|
||||
private readonly SquidWTFSettings _settings;
|
||||
private readonly List<string> _apiUrls;
|
||||
private int _currentUrlIndex = 0;
|
||||
private readonly object _urlIndexLock = new object();
|
||||
private readonly RoundRobinFallbackHelper _fallbackHelper;
|
||||
|
||||
public override string ServiceName => "SquidWTF";
|
||||
|
||||
public SquidWTFStartupValidator(IOptions<SquidWTFSettings> settings, HttpClient httpClient, List<string> apiUrls)
|
||||
public SquidWTFStartupValidator(IOptions<SquidWTFSettings> settings, HttpClient httpClient, List<string> apiUrls, ILogger<SquidWTFStartupValidator> logger)
|
||||
: base(httpClient)
|
||||
{
|
||||
_settings = settings.Value;
|
||||
_apiUrls = apiUrls;
|
||||
_fallbackHelper = new RoundRobinFallbackHelper(apiUrls, logger, "SquidWTF");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries the request with the next provider in round-robin, then falls back to others on failure.
|
||||
/// This distributes load evenly across all providers while maintaining reliability.
|
||||
/// </summary>
|
||||
private async Task<T> TryWithFallbackAsync<T>(Func<string, Task<T>> action, T defaultValue)
|
||||
{
|
||||
// Start with the next URL in round-robin to distribute load
|
||||
var startIndex = 0;
|
||||
lock (_urlIndexLock)
|
||||
{
|
||||
startIndex = _currentUrlIndex;
|
||||
_currentUrlIndex = (_currentUrlIndex + 1) % _apiUrls.Count;
|
||||
}
|
||||
|
||||
// Try all URLs starting from the round-robin selected one
|
||||
for (int attempt = 0; attempt < _apiUrls.Count; attempt++)
|
||||
{
|
||||
var urlIndex = (startIndex + attempt) % _apiUrls.Count;
|
||||
var baseUrl = _apiUrls[urlIndex];
|
||||
|
||||
try
|
||||
{
|
||||
return await action(baseUrl);
|
||||
}
|
||||
catch
|
||||
{
|
||||
WriteDetail($"Endpoint {baseUrl} failed, trying next...");
|
||||
|
||||
if (attempt == _apiUrls.Count - 1)
|
||||
{
|
||||
WriteDetail($"All {_apiUrls.Count} endpoints failed");
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
public override async Task<ValidationResult> ValidateAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -80,7 +42,7 @@ public class SquidWTFStartupValidator : BaseStartupValidator
|
||||
WriteStatus("SquidWTF Quality", quality, ConsoleColor.Cyan);
|
||||
|
||||
// Test connectivity with fallback
|
||||
var result = await TryWithFallbackAsync(async (baseUrl) =>
|
||||
var result = await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
||||
{
|
||||
var response = await _httpClient.GetAsync(baseUrl, cancellationToken);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user