diff --git a/README.md b/README.md index a78507f..c2f4a31 100644 --- a/README.md +++ b/README.md @@ -250,6 +250,10 @@ Choose your preferred provider via the `MUSIC_SERVICE` environment variable. Add |---------|-------------| | `SquidWTF:Quality` | Preferred audio quality: `FLAC`, `MP3_320`, `MP3_128`. If not specified, the highest available quality for your account will be used | +**Load Balancing & Reliability:** + +SquidWTF uses a round-robin load balancing strategy across multiple backup API endpoints to distribute requests evenly and prevent overwhelming any single provider. Each request automatically rotates to the next endpoint in the pool, with automatic fallback to other endpoints if one fails. This ensures high availability and prevents rate limiting by distributing load across multiple providers. + ### Deezer Settings | Setting | Description | diff --git a/allstarr/Controllers/JellyfinController.cs b/allstarr/Controllers/JellyfinController.cs index d4abc15..c6cf237 100644 --- a/allstarr/Controllers/JellyfinController.cs +++ b/allstarr/Controllers/JellyfinController.cs @@ -2821,13 +2821,14 @@ public class JellyfinController : ControllerBase if (results.Count > 0) { // Fuzzy match to find best result + // Check that ALL artists match (not just some) var bestMatch = results .Select(song => new { Song = song, TitleScore = FuzzyMatcher.CalculateSimilarity(track.Title, song.Title), - ArtistScore = FuzzyMatcher.CalculateSimilarity(track.PrimaryArtist, song.Artist), - TotalScore = 0.0 + // Calculate artist score by checking ALL artists match + ArtistScore = CalculateArtistMatchScore(track.Artists, song.Artist, song.Contributors) }) .Select(x => new { @@ -3320,5 +3321,53 @@ public class JellyfinController : ControllerBase } #endregion + + /// + /// Calculates artist match score ensuring ALL artists are present. + /// Penalizes if artist counts don't match or if any artist is missing. + /// + private 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 => + FuzzyMatcher.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 => + FuzzyMatcher.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; + } } // force rebuild Sun Jan 25 13:22:47 EST 2026 diff --git a/allstarr/Program.cs b/allstarr/Program.cs index 1e45c12..a1c1e94 100644 --- a/allstarr/Program.cs +++ b/allstarr/Program.cs @@ -22,12 +22,17 @@ static List DecodeSquidWtfUrls() { var encodedUrls = new[] { - "aHR0cHM6Ly90cml0b24uc3F1aWQud3Rm", // triton - "aHR0cHM6Ly93b2xmLnFxZGwuc2l0ZQ==", // wolf - "aHR0cDovL2h1bmQucXFkbC5zaXRl", // hund - "aHR0cHM6Ly9tYXVzLnFxZGwuc2l0ZQ==", // maus - "aHR0cHM6Ly92b2dlbC5xcWRsLnNpdGU=", // vogel - "aHR0cHM6Ly9rYXR6ZS5xcWRsLnNpdGU=" // katze + "aHR0cHM6Ly90cml0b24uc3F1aWQud3Rm", // triton + "aHR0cHM6Ly9tb25vY2hyb21lLWFwaS5zYW1pZHkuY29t", // samidy + "aHR0cHM6Ly90aWRhbC1hcGkuYmluaW11bS5vcmc=", // binimum + "aHR0cHM6Ly90aWRhbC5raW5vcGx1cy5vbmxpbmU=", // kinoplus + "aHR0cHM6Ly9oaWZpLXR3by5zcG90aXNhdmVyLm5ldA==", // spoti-2 + "aHR0cHM6Ly9oaWZpLW9uZS5zcG90aXNhdmVyLm5ldA==", // spoti-1 + "aHR0cHM6Ly93b2xmLnFxZGwuc2l0ZQ==", // wolf + "aHR0cDovL2h1bmQucXFkbC5zaXRl", // hund + "aHR0cHM6Ly9rYXR6ZS5xcWRsLnNpdGU=", // katze + "aHR0cHM6Ly92b2dlbC5xcWRsLnNpdGU=", // vogel + "aHR0cHM6Ly9tYXVzLnFxZGwuc2l0ZQ==" // maus }; return encodedUrls diff --git a/allstarr/Services/Spotify/SpotifyTrackMatchingService.cs b/allstarr/Services/Spotify/SpotifyTrackMatchingService.cs index edc2ee4..74df99b 100644 --- a/allstarr/Services/Spotify/SpotifyTrackMatchingService.cs +++ b/allstarr/Services/Spotify/SpotifyTrackMatchingService.cs @@ -154,12 +154,14 @@ public class SpotifyTrackMatchingService : BackgroundService if (results.Count > 0) { // Fuzzy match to find best result + // Check that ALL artists match (not just some) var bestMatch = results .Select(song => new { Song = song, TitleScore = FuzzyMatcher.CalculateSimilarity(track.Title, song.Title), - ArtistScore = FuzzyMatcher.CalculateSimilarity(track.PrimaryArtist, song.Artist) + // Calculate artist score by checking ALL artists match + ArtistScore = CalculateArtistMatchScore(track.Artists, song.Artist, song.Contributors) }) .Select(x => new { @@ -206,4 +208,52 @@ public class SpotifyTrackMatchingService : BackgroundService _logger.LogInformation("No tracks matched for {Playlist}", playlistName); } } + + /// + /// Calculates artist match score ensuring ALL artists are present. + /// Penalizes if artist counts don't match or if any artist is missing. + /// + private 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 => + FuzzyMatcher.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 => + FuzzyMatcher.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/SquidWTF/SquidWTFDownloadService.cs b/allstarr/Services/SquidWTF/SquidWTFDownloadService.cs index 1e9e010..a23dd6f 100644 --- a/allstarr/Services/SquidWTF/SquidWTFDownloadService.cs +++ b/allstarr/Services/SquidWTF/SquidWTFDownloadService.cs @@ -28,6 +28,7 @@ public class SquidWTFDownloadService : BaseDownloadService private readonly List _apiUrls; private int _currentUrlIndex = 0; + private readonly object _urlIndexLock = new object(); protected override string ProviderName => "squidwtf"; @@ -48,23 +49,39 @@ public class SquidWTFDownloadService : BaseDownloadService _apiUrls = 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. + /// 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 { - var baseUrl = _apiUrls[_currentUrlIndex]; + 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...", _apiUrls[_currentUrlIndex]); - _currentUrlIndex = (_currentUrlIndex + 1) % _apiUrls.Count; + Logger.LogWarning(ex, "Request failed with endpoint {Endpoint}, trying next...", baseUrl); if (attempt == _apiUrls.Count - 1) { - Logger.LogError("All SquidWTF endpoints failed"); + Logger.LogError("All {Count} SquidWTF endpoints failed", _apiUrls.Count); throw; } } diff --git a/allstarr/Services/SquidWTF/SquidWTFMetadataService.cs b/allstarr/Services/SquidWTF/SquidWTFMetadataService.cs index 56b84da..32aaa7e 100644 --- a/allstarr/Services/SquidWTF/SquidWTFMetadataService.cs +++ b/allstarr/Services/SquidWTF/SquidWTFMetadataService.cs @@ -23,6 +23,7 @@ public class SquidWTFMetadataService : IMusicMetadataService private readonly RedisCacheService _cache; private readonly List _apiUrls; private int _currentUrlIndex = 0; + private readonly object _urlIndexLock = new object(); public SquidWTFMetadataService( IHttpClientFactory httpClientFactory, @@ -43,25 +44,52 @@ public class SquidWTFMetadataService : IMusicMetadataService "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:83.0) Gecko/20100101 Firefox/83.0"); } - private string GetCurrentBaseUrl() => _apiUrls[_currentUrlIndex]; + /// + /// 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 { - var baseUrl = _apiUrls[_currentUrlIndex]; + _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...", _apiUrls[_currentUrlIndex]); - _currentUrlIndex = (_currentUrlIndex + 1) % _apiUrls.Count; + _logger.LogWarning(ex, "Request failed with endpoint {Endpoint}, trying next...", baseUrl); if (attempt == _apiUrls.Count - 1) { - _logger.LogError("All SquidWTF endpoints failed"); + _logger.LogError("All {Count} SquidWTF endpoints failed", _apiUrls.Count); return defaultValue; } } diff --git a/allstarr/Services/SquidWTF/SquidWTFStartupValidator.cs b/allstarr/Services/SquidWTF/SquidWTFStartupValidator.cs index 0207104..8104f02 100644 --- a/allstarr/Services/SquidWTF/SquidWTFStartupValidator.cs +++ b/allstarr/Services/SquidWTF/SquidWTFStartupValidator.cs @@ -14,6 +14,7 @@ public class SquidWTFStartupValidator : BaseStartupValidator private readonly SquidWTFSettings _settings; private readonly List _apiUrls; private int _currentUrlIndex = 0; + private readonly object _urlIndexLock = new object(); public override string ServiceName => "SquidWTF"; @@ -24,22 +25,37 @@ public class SquidWTFStartupValidator : BaseStartupValidator _apiUrls = 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. + /// 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 { - var baseUrl = _apiUrls[_currentUrlIndex]; return await action(baseUrl); } catch { - WriteDetail($"Endpoint {_apiUrls[_currentUrlIndex]} failed, trying next..."); - _currentUrlIndex = (_currentUrlIndex + 1) % _apiUrls.Count; + WriteDetail($"Endpoint {baseUrl} failed, trying next..."); if (attempt == _apiUrls.Count - 1) { + WriteDetail($"All {_apiUrls.Count} endpoints failed"); return defaultValue; } }