From b906a5fd6d1094d3c27cc06626202ff673f84a5d Mon Sep 17 00:00:00 2001 From: Josh Patra Date: Sat, 7 Feb 2026 12:27:10 -0500 Subject: [PATCH] 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 --- allstarr/Controllers/JellyfinController.cs | 15 ++- allstarr/Program.cs | 3 +- .../Services/Common/BaseDownloadService.cs | 35 ++++++ allstarr/Services/Common/FuzzyMatcher.cs | 50 ++++++++ .../Common/RoundRobinFallbackHelper.cs | 110 ++++++++++++++++++ .../Services/Deezer/DeezerDownloadService.cs | 25 ---- .../Spotify/SpotifyTrackMatchingService.cs | 6 +- .../SquidWTF/SquidWTFDownloadService.cs | 75 +----------- .../SquidWTF/SquidWTFMetadataService.cs | 77 ++---------- .../SquidWTF/SquidWTFStartupValidator.cs | 48 +------- 10 files changed, 233 insertions(+), 211 deletions(-) create mode 100644 allstarr/Services/Common/RoundRobinFallbackHelper.cs diff --git a/allstarr/Controllers/JellyfinController.cs b/allstarr/Controllers/JellyfinController.cs index a9d6173..9ee37ae 100644 --- a/allstarr/Controllers/JellyfinController.cs +++ b/allstarr/Controllers/JellyfinController.cs @@ -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 { diff --git a/allstarr/Program.cs b/allstarr/Program.cs index 8829e9c..714fe01 100644 --- a/allstarr/Program.cs +++ b/allstarr/Program.cs @@ -483,7 +483,8 @@ builder.Services.AddSingleton(sp => new SquidWTFStartupValidator( sp.GetRequiredService>(), sp.GetRequiredService().CreateClient(), - squidWtfApiUrls)); + squidWtfApiUrls, + sp.GetRequiredService>())); builder.Services.AddSingleton(); // Register orchestrator as hosted service diff --git a/allstarr/Services/Common/BaseDownloadService.cs b/allstarr/Services/Common/BaseDownloadService.cs index ef304fd..94683c0 100644 --- a/allstarr/Services/Common/BaseDownloadService.cs +++ b/allstarr/Services/Common/BaseDownloadService.cs @@ -31,6 +31,11 @@ public abstract class BaseDownloadService : IDownloadService protected readonly ConcurrentDictionary 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; + /// /// Lazy-loaded PlaylistSyncService to avoid circular dependency /// @@ -604,4 +609,34 @@ public abstract class BaseDownloadService : IDownloadService } #endregion + + #region Rate Limiting + + /// + /// Queues a request with rate limiting to prevent overwhelming the API. + /// Ensures minimum interval between requests. + /// + protected async Task QueueRequestAsync(Func> 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 } diff --git a/allstarr/Services/Common/FuzzyMatcher.cs b/allstarr/Services/Common/FuzzyMatcher.cs index e62d139..80475e5 100644 --- a/allstarr/Services/Common/FuzzyMatcher.cs +++ b/allstarr/Services/Common/FuzzyMatcher.cs @@ -221,4 +221,54 @@ public static class FuzzyMatcher return distance[sourceLength, targetLength]; } + + /// + /// 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. + /// + public static double CalculateArtistMatchScore(List spotifyArtists, string songMainArtist, List songContributors) + { + if (spotifyArtists.Count == 0 || string.IsNullOrEmpty(songMainArtist)) + return 0; + + // Build list of all song artists (main + contributors) + var allSongArtists = new List { 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(); + 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(); + 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; + } } diff --git a/allstarr/Services/Common/RoundRobinFallbackHelper.cs b/allstarr/Services/Common/RoundRobinFallbackHelper.cs new file mode 100644 index 0000000..c338a65 --- /dev/null +++ b/allstarr/Services/Common/RoundRobinFallbackHelper.cs @@ -0,0 +1,110 @@ +namespace allstarr.Services.Common; + +/// +/// Helper for round-robin load balancing with fallback across multiple API endpoints. +/// Distributes load evenly while maintaining reliability through automatic failover. +/// +public class RoundRobinFallbackHelper +{ + private readonly List _apiUrls; + private int _currentUrlIndex = 0; + private readonly object _urlIndexLock = new object(); + private readonly ILogger _logger; + private readonly string _serviceName; + + public RoundRobinFallbackHelper(List 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)); + } + } + + /// + /// 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. + /// + public async Task TryWithFallbackAsync(Func> 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"); + } + + /// + /// 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). + /// + public async Task TryWithFallbackAsync(Func> 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; + } +} diff --git a/allstarr/Services/Deezer/DeezerDownloadService.cs b/allstarr/Services/Deezer/DeezerDownloadService.cs index 4d21917..2ba3145 100644 --- a/allstarr/Services/Deezer/DeezerDownloadService.cs +++ b/allstarr/Services/Deezer/DeezerDownloadService.cs @@ -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(action, maxRetries, initialDelayMs); } - private async Task QueueRequestAsync(Func> 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 diff --git a/allstarr/Services/Spotify/SpotifyTrackMatchingService.cs b/allstarr/Services/Spotify/SpotifyTrackMatchingService.cs index 6475a4d..793a5f8 100644 --- a/allstarr/Services/Spotify/SpotifyTrackMatchingService.cs +++ b/allstarr/Services/Spotify/SpotifyTrackMatchingService.cs @@ -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 { diff --git a/allstarr/Services/SquidWTF/SquidWTFDownloadService.cs b/allstarr/Services/SquidWTF/SquidWTFDownloadService.cs index 4538478..1fe44a7 100644 --- a/allstarr/Services/SquidWTF/SquidWTFDownloadService.cs +++ b/allstarr/Services/SquidWTF/SquidWTFDownloadService.cs @@ -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 _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"); } - /// - /// 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. - /// - private async Task TryWithFallbackAsync(Func> 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 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 QueueRequestAsync(Func> 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 diff --git a/allstarr/Services/SquidWTF/SquidWTFMetadataService.cs b/allstarr/Services/SquidWTF/SquidWTFMetadataService.cs index 41fef1c..330bbc7 100644 --- a/allstarr/Services/SquidWTF/SquidWTFMetadataService.cs +++ b/allstarr/Services/SquidWTF/SquidWTFMetadataService.cs @@ -21,9 +21,7 @@ public class SquidWTFMetadataService : IMusicMetadataService private readonly SubsonicSettings _settings; private readonly ILogger _logger; private readonly RedisCacheService _cache; - private readonly List _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"); } - /// - /// Gets the next URL in round-robin fashion to distribute load across providers - /// - private string GetNextBaseUrl() - { - lock (_urlIndexLock) - { - var url = _apiUrls[_currentUrlIndex]; - _currentUrlIndex = (_currentUrlIndex + 1) % _apiUrls.Count; - return url; - } - } - /// - /// 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. - /// - private async Task TryWithFallbackAsync(Func> 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> 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> 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> 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> 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(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(); - 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(); - return await TryWithFallbackAsync(async (baseUrl) => + return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) => { var url = $"{baseUrl}/playlist/?id={externalId}"; var response = await _httpClient.GetAsync(url); diff --git a/allstarr/Services/SquidWTF/SquidWTFStartupValidator.cs b/allstarr/Services/SquidWTF/SquidWTFStartupValidator.cs index 24d0d48..5049eb8 100644 --- a/allstarr/Services/SquidWTF/SquidWTFStartupValidator.cs +++ b/allstarr/Services/SquidWTF/SquidWTFStartupValidator.cs @@ -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 _apiUrls; - private int _currentUrlIndex = 0; - private readonly object _urlIndexLock = new object(); + private readonly RoundRobinFallbackHelper _fallbackHelper; public override string ServiceName => "SquidWTF"; - public SquidWTFStartupValidator(IOptions settings, HttpClient httpClient, List apiUrls) + public SquidWTFStartupValidator(IOptions settings, HttpClient httpClient, List apiUrls, ILogger logger) : base(httpClient) { _settings = settings.Value; - _apiUrls = apiUrls; + _fallbackHelper = new RoundRobinFallbackHelper(apiUrls, logger, "SquidWTF"); } - /// - /// 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. - /// - private async Task TryWithFallbackAsync(Func> 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 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);