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
|
// Download and stream on-demand
|
||||||
try
|
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(
|
var downloadStream = await _downloadService.DownloadAndStreamAsync(
|
||||||
provider,
|
provider,
|
||||||
externalId,
|
externalId,
|
||||||
HttpContext.RequestAborted);
|
downloadCts.Token);
|
||||||
|
|
||||||
return File(downloadStream, "audio/mpeg", enableRangeProcessing: true);
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Failed to stream external song {Provider}:{ExternalId}", provider, externalId);
|
_logger.LogError(ex, "Failed to stream external song {Provider}:{ExternalId}", provider, externalId);
|
||||||
@@ -3753,8 +3763,7 @@ public class JellyfinController : ControllerBase
|
|||||||
{
|
{
|
||||||
Song = song,
|
Song = song,
|
||||||
TitleScore = FuzzyMatcher.CalculateSimilarity(track.Title, song.Title),
|
TitleScore = FuzzyMatcher.CalculateSimilarity(track.Title, song.Title),
|
||||||
// Calculate artist score by checking ALL artists match
|
ArtistScore = FuzzyMatcher.CalculateArtistMatchScore(track.Artists, song.Artist, song.Contributors)
|
||||||
ArtistScore = CalculateArtistMatchScore(track.Artists, song.Artist, song.Contributors)
|
|
||||||
})
|
})
|
||||||
.Select(x => new
|
.Select(x => new
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -483,7 +483,8 @@ builder.Services.AddSingleton<IStartupValidator>(sp =>
|
|||||||
new SquidWTFStartupValidator(
|
new SquidWTFStartupValidator(
|
||||||
sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<SquidWTFSettings>>(),
|
sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<SquidWTFSettings>>(),
|
||||||
sp.GetRequiredService<IHttpClientFactory>().CreateClient(),
|
sp.GetRequiredService<IHttpClientFactory>().CreateClient(),
|
||||||
squidWtfApiUrls));
|
squidWtfApiUrls,
|
||||||
|
sp.GetRequiredService<ILogger<SquidWTFStartupValidator>>()));
|
||||||
builder.Services.AddSingleton<IStartupValidator, LyricsStartupValidator>();
|
builder.Services.AddSingleton<IStartupValidator, LyricsStartupValidator>();
|
||||||
|
|
||||||
// Register orchestrator as hosted service
|
// Register orchestrator as hosted service
|
||||||
|
|||||||
@@ -31,6 +31,11 @@ public abstract class BaseDownloadService : IDownloadService
|
|||||||
protected readonly ConcurrentDictionary<string, DownloadInfo> ActiveDownloads = new();
|
protected readonly ConcurrentDictionary<string, DownloadInfo> ActiveDownloads = new();
|
||||||
protected readonly SemaphoreSlim DownloadLock = new(1, 1);
|
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>
|
/// <summary>
|
||||||
/// Lazy-loaded PlaylistSyncService to avoid circular dependency
|
/// Lazy-loaded PlaylistSyncService to avoid circular dependency
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -604,4 +609,34 @@ public abstract class BaseDownloadService : IDownloadService
|
|||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#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];
|
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
|
public class DeezerDownloadService : BaseDownloadService
|
||||||
{
|
{
|
||||||
private readonly HttpClient _httpClient;
|
private readonly HttpClient _httpClient;
|
||||||
private readonly SemaphoreSlim _requestLock = new(1, 1);
|
|
||||||
|
|
||||||
private readonly string? _arl;
|
private readonly string? _arl;
|
||||||
private readonly string? _arlFallback;
|
private readonly string? _arlFallback;
|
||||||
@@ -33,9 +32,6 @@ public class DeezerDownloadService : BaseDownloadService
|
|||||||
private string? _apiToken;
|
private string? _apiToken;
|
||||||
private string? _licenseToken;
|
private string? _licenseToken;
|
||||||
|
|
||||||
private DateTime _lastRequestTime = DateTime.MinValue;
|
|
||||||
private readonly int _minRequestIntervalMs = 200;
|
|
||||||
|
|
||||||
private const string DeezerApiBase = "https://api.deezer.com";
|
private const string DeezerApiBase = "https://api.deezer.com";
|
||||||
|
|
||||||
// Deezer's standard Blowfish CBC encryption key for track decryption
|
// Deezer's standard Blowfish CBC encryption key for track decryption
|
||||||
@@ -497,27 +493,6 @@ public class DeezerDownloadService : BaseDownloadService
|
|||||||
await RetryWithBackoffAsync<bool>(action, maxRetries, initialDelayMs);
|
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
|
#endregion
|
||||||
|
|
||||||
|
|||||||
@@ -554,7 +554,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
Song = song,
|
Song = song,
|
||||||
// Use aggressive matching which follows optimal order internally
|
// Use aggressive matching which follows optimal order internally
|
||||||
TitleScore = FuzzyMatcher.CalculateSimilarityAggressive(title, song.Title),
|
TitleScore = FuzzyMatcher.CalculateSimilarityAggressive(title, song.Title),
|
||||||
ArtistScore = CalculateArtistMatchScore(artists, song.Artist, song.Contributors)
|
ArtistScore = FuzzyMatcher.CalculateArtistMatchScore(artists, song.Artist, song.Contributors)
|
||||||
})
|
})
|
||||||
.Select(x => new
|
.Select(x => new
|
||||||
{
|
{
|
||||||
@@ -640,7 +640,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
Song = song,
|
Song = song,
|
||||||
// Use aggressive matching which follows optimal order internally
|
// Use aggressive matching which follows optimal order internally
|
||||||
TitleScore = FuzzyMatcher.CalculateSimilarityAggressive(title, song.Title),
|
TitleScore = FuzzyMatcher.CalculateSimilarityAggressive(title, song.Title),
|
||||||
ArtistScore = CalculateArtistMatchScore(artists, song.Artist, song.Contributors)
|
ArtistScore = FuzzyMatcher.CalculateArtistMatchScore(artists, song.Artist, song.Contributors)
|
||||||
})
|
})
|
||||||
.Select(x => new
|
.Select(x => new
|
||||||
{
|
{
|
||||||
@@ -744,7 +744,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
Song = song,
|
Song = song,
|
||||||
TitleScore = FuzzyMatcher.CalculateSimilarity(track.Title, song.Title),
|
TitleScore = FuzzyMatcher.CalculateSimilarity(track.Title, song.Title),
|
||||||
// Calculate artist score by checking ALL artists match
|
// 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
|
.Select(x => new
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -20,16 +20,9 @@ namespace allstarr.Services.SquidWTF;
|
|||||||
public class SquidWTFDownloadService : BaseDownloadService
|
public class SquidWTFDownloadService : BaseDownloadService
|
||||||
{
|
{
|
||||||
private readonly HttpClient _httpClient;
|
private readonly HttpClient _httpClient;
|
||||||
private readonly SemaphoreSlim _requestLock = new(1, 1);
|
|
||||||
private readonly SquidWTFSettings _squidwtfSettings;
|
private readonly SquidWTFSettings _squidwtfSettings;
|
||||||
private readonly OdesliService _odesliService;
|
private readonly OdesliService _odesliService;
|
||||||
|
private readonly RoundRobinFallbackHelper _fallbackHelper;
|
||||||
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();
|
|
||||||
|
|
||||||
protected override string ProviderName => "squidwtf";
|
protected override string ProviderName => "squidwtf";
|
||||||
|
|
||||||
@@ -49,54 +42,15 @@ public class SquidWTFDownloadService : BaseDownloadService
|
|||||||
_httpClient = httpClientFactory.CreateClient();
|
_httpClient = httpClientFactory.CreateClient();
|
||||||
_squidwtfSettings = SquidWTFSettings.Value;
|
_squidwtfSettings = SquidWTFSettings.Value;
|
||||||
_odesliService = odesliService;
|
_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
|
#region BaseDownloadService Implementation
|
||||||
|
|
||||||
public override async Task<bool> IsAvailableAsync()
|
public override async Task<bool> IsAvailableAsync()
|
||||||
{
|
{
|
||||||
return await TryWithFallbackAsync(async (baseUrl) =>
|
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
||||||
{
|
{
|
||||||
var response = await _httpClient.GetAsync(baseUrl);
|
var response = await _httpClient.GetAsync(baseUrl);
|
||||||
Console.WriteLine($"Response code from is available async: {response.IsSuccessStatusCode}");
|
Console.WriteLine($"Response code from is available async: {response.IsSuccessStatusCode}");
|
||||||
@@ -191,7 +145,7 @@ public class SquidWTFDownloadService : BaseDownloadService
|
|||||||
{
|
{
|
||||||
return await QueueRequestAsync(async () =>
|
return await QueueRequestAsync(async () =>
|
||||||
{
|
{
|
||||||
return await TryWithFallbackAsync(async (baseUrl) =>
|
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
||||||
{
|
{
|
||||||
// Map quality settings to Tidal's quality levels
|
// Map quality settings to Tidal's quality levels
|
||||||
var quality = _squidwtfSettings.Quality?.ToUpperInvariant() switch
|
var quality = _squidwtfSettings.Quality?.ToUpperInvariant() switch
|
||||||
@@ -262,27 +216,6 @@ public class SquidWTFDownloadService : BaseDownloadService
|
|||||||
|
|
||||||
#region Utility Methods
|
#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
|
#endregion
|
||||||
|
|
||||||
|
|||||||
@@ -21,9 +21,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
private readonly SubsonicSettings _settings;
|
private readonly SubsonicSettings _settings;
|
||||||
private readonly ILogger<SquidWTFMetadataService> _logger;
|
private readonly ILogger<SquidWTFMetadataService> _logger;
|
||||||
private readonly RedisCacheService _cache;
|
private readonly RedisCacheService _cache;
|
||||||
private readonly List<string> _apiUrls;
|
private readonly RoundRobinFallbackHelper _fallbackHelper;
|
||||||
private int _currentUrlIndex = 0;
|
|
||||||
private readonly object _urlIndexLock = new object();
|
|
||||||
|
|
||||||
public SquidWTFMetadataService(
|
public SquidWTFMetadataService(
|
||||||
IHttpClientFactory httpClientFactory,
|
IHttpClientFactory httpClientFactory,
|
||||||
@@ -37,69 +35,18 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
_settings = settings.Value;
|
_settings = settings.Value;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_cache = cache;
|
_cache = cache;
|
||||||
_apiUrls = apiUrls;
|
_fallbackHelper = new RoundRobinFallbackHelper(apiUrls, logger, "SquidWTF");
|
||||||
|
|
||||||
// Set up default headers
|
// Set up default headers
|
||||||
_httpClient.DefaultRequestHeaders.Add("User-Agent",
|
_httpClient.DefaultRequestHeaders.Add("User-Agent",
|
||||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:83.0) Gecko/20100101 Firefox/83.0");
|
"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)
|
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 url = $"{baseUrl}/search/?s={Uri.EscapeDataString(query)}";
|
||||||
var response = await _httpClient.GetAsync(url);
|
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)
|
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 url = $"{baseUrl}/search/?al={Uri.EscapeDataString(query)}";
|
||||||
var response = await _httpClient.GetAsync(url);
|
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)
|
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)}";
|
var url = $"{baseUrl}/search/?a={Uri.EscapeDataString(query)}";
|
||||||
_logger.LogInformation("🔍 SQUIDWTF: Searching artists with URL: {Url}", url);
|
_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)
|
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 url = $"{baseUrl}/search/?p={Uri.EscapeDataString(query)}";
|
||||||
var response = await _httpClient.GetAsync(url);
|
var response = await _httpClient.GetAsync(url);
|
||||||
@@ -267,7 +214,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
{
|
{
|
||||||
if (externalProvider != "squidwtf") return null;
|
if (externalProvider != "squidwtf") return null;
|
||||||
|
|
||||||
return await TryWithFallbackAsync(async (baseUrl) =>
|
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
||||||
{
|
{
|
||||||
var url = $"{baseUrl}/info/?id={externalId}";
|
var url = $"{baseUrl}/info/?id={externalId}";
|
||||||
|
|
||||||
@@ -298,7 +245,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
var cached = await _cache.GetAsync<Album>(cacheKey);
|
var cached = await _cache.GetAsync<Album>(cacheKey);
|
||||||
if (cached != null) return cached;
|
if (cached != null) return cached;
|
||||||
|
|
||||||
return await TryWithFallbackAsync(async (baseUrl) =>
|
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
||||||
{
|
{
|
||||||
var url = $"{baseUrl}/album/?id={externalId}";
|
var url = $"{baseUrl}/album/?id={externalId}";
|
||||||
|
|
||||||
@@ -352,7 +299,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
return await TryWithFallbackAsync(async (baseUrl) =>
|
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
||||||
{
|
{
|
||||||
var url = $"{baseUrl}/artist/?f={externalId}";
|
var url = $"{baseUrl}/artist/?f={externalId}";
|
||||||
_logger.LogInformation("Fetching artist from {Url}", url);
|
_logger.LogInformation("Fetching artist from {Url}", url);
|
||||||
@@ -427,7 +374,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
{
|
{
|
||||||
if (externalProvider != "squidwtf") return new List<Album>();
|
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);
|
_logger.LogInformation("GetArtistAlbumsAsync called for SquidWTF artist {ExternalId}", externalId);
|
||||||
|
|
||||||
@@ -472,7 +419,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
{
|
{
|
||||||
if (externalProvider != "squidwtf") return null;
|
if (externalProvider != "squidwtf") return null;
|
||||||
|
|
||||||
return await TryWithFallbackAsync(async (baseUrl) =>
|
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
||||||
{
|
{
|
||||||
var url = $"{baseUrl}/playlist/?id={externalId}";
|
var url = $"{baseUrl}/playlist/?id={externalId}";
|
||||||
var response = await _httpClient.GetAsync(url);
|
var response = await _httpClient.GetAsync(url);
|
||||||
@@ -491,7 +438,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
{
|
{
|
||||||
if (externalProvider != "squidwtf") return new List<Song>();
|
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 url = $"{baseUrl}/playlist/?id={externalId}";
|
||||||
var response = await _httpClient.GetAsync(url);
|
var response = await _httpClient.GetAsync(url);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using System.Text.Json;
|
|||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using allstarr.Models.Settings;
|
using allstarr.Models.Settings;
|
||||||
using allstarr.Services.Validation;
|
using allstarr.Services.Validation;
|
||||||
|
using allstarr.Services.Common;
|
||||||
|
|
||||||
namespace allstarr.Services.SquidWTF;
|
namespace allstarr.Services.SquidWTF;
|
||||||
|
|
||||||
@@ -12,56 +13,17 @@ namespace allstarr.Services.SquidWTF;
|
|||||||
public class SquidWTFStartupValidator : BaseStartupValidator
|
public class SquidWTFStartupValidator : BaseStartupValidator
|
||||||
{
|
{
|
||||||
private readonly SquidWTFSettings _settings;
|
private readonly SquidWTFSettings _settings;
|
||||||
private readonly List<string> _apiUrls;
|
private readonly RoundRobinFallbackHelper _fallbackHelper;
|
||||||
private int _currentUrlIndex = 0;
|
|
||||||
private readonly object _urlIndexLock = new object();
|
|
||||||
|
|
||||||
public override string ServiceName => "SquidWTF";
|
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)
|
: base(httpClient)
|
||||||
{
|
{
|
||||||
_settings = settings.Value;
|
_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)
|
public override async Task<ValidationResult> ValidateAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -80,7 +42,7 @@ public class SquidWTFStartupValidator : BaseStartupValidator
|
|||||||
WriteStatus("SquidWTF Quality", quality, ConsoleColor.Cyan);
|
WriteStatus("SquidWTF Quality", quality, ConsoleColor.Cyan);
|
||||||
|
|
||||||
// Test connectivity with fallback
|
// Test connectivity with fallback
|
||||||
var result = await TryWithFallbackAsync(async (baseUrl) =>
|
var result = await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
||||||
{
|
{
|
||||||
var response = await _httpClient.GetAsync(baseUrl, cancellationToken);
|
var response = await _httpClient.GetAsync(baseUrl, cancellationToken);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user