feat: add automatic fallback support for SquidWTF endpoints

- Decode 6 base64 URLs at startup (1 primary + 5 backups)
- Automatic fallback when endpoint fails
- All services try next endpoint on failure
- Metadata, Download, and Validator all support fallback
- Endpoints: triton.squid.wtf, wolf/hund/maus/vogel/katze.qqdl.site
- Logs which endpoint is being used
- Cycles through all endpoints before giving up
This commit is contained in:
2026-01-30 13:21:23 -05:00
parent 3487f79b5e
commit 649351f68b
4 changed files with 211 additions and 207 deletions

View File

@@ -16,13 +16,23 @@ using System.Text;
var builder = WebApplication.CreateBuilder(args);
// Decode SquidWTF API base URL once at startup
var squidWtfApiBase = DecodeSquidWtfUrl();
static string DecodeSquidWtfUrl()
// Decode SquidWTF API base URLs once at startup (primary + backups)
var squidWtfApiUrls = DecodeSquidWtfUrls();
static List<string> DecodeSquidWtfUrls()
{
var encoded = "aHR0cHM6Ly90cml0b24uc3F1aWQud3Rm";
var bytes = Convert.FromBase64String(encoded);
return Encoding.UTF8.GetString(bytes);
var encodedUrls = new[]
{
"aHR0cHM6Ly90cml0b24uc3F1aWQud3Rm", // Primary: triton.squid.wtf
"aHR0cHM6Ly93b2xmLnFxZGwuc2l0ZQ==", // Backup 1: wolf.qqdl.site
"aHR0cDovL2h1bmQucXFkbC5zaXRl", // Backup 2: hund.qqdl.site
"aHR0cHM6Ly9tYXVzLnFxZGwuc2l0ZQ==", // Backup 3: maus.qqdl.site
"aHR0cHM6Ly92b2dlbC5xcWRsLnNpdGU=", // Backup 4: vogel.qqdl.site
"aHR0cHM6Ly9rYXR6ZS5xcWRsLnNpdGU=" // Backup 5: katze.qqdl.site
};
return encodedUrls
.Select(encoded => Encoding.UTF8.GetString(Convert.FromBase64String(encoded)))
.ToList();
}
// Determine backend type FIRST
@@ -173,7 +183,7 @@ else if (musicService == MusicService.Deezer)
}
else if (musicService == MusicService.SquidWTF)
{
// SquidWTF services - pass decoded URL
// SquidWTF services - pass decoded URLs with fallback support
builder.Services.AddSingleton<IMusicMetadataService>(sp =>
new SquidWTFMetadataService(
sp.GetRequiredService<IHttpClientFactory>(),
@@ -181,7 +191,7 @@ else if (musicService == MusicService.SquidWTF)
sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<SquidWTFSettings>>(),
sp.GetRequiredService<ILogger<SquidWTFMetadataService>>(),
sp.GetRequiredService<RedisCacheService>(),
squidWtfApiBase));
squidWtfApiUrls));
builder.Services.AddSingleton<IDownloadService>(sp =>
new SquidWTFDownloadService(
sp.GetRequiredService<IHttpClientFactory>(),
@@ -192,7 +202,7 @@ else if (musicService == MusicService.SquidWTF)
sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<SquidWTFSettings>>(),
sp,
sp.GetRequiredService<ILogger<SquidWTFDownloadService>>(),
squidWtfApiBase));
squidWtfApiUrls));
}
// Startup validation - register validators based on backend
@@ -211,7 +221,7 @@ builder.Services.AddSingleton<IStartupValidator>(sp =>
new SquidWTFStartupValidator(
sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<SquidWTFSettings>>(),
sp.GetRequiredService<IHttpClientFactory>().CreateClient(),
squidWtfApiBase));
squidWtfApiUrls));
// Register orchestrator as hosted service
builder.Services.AddHostedService<StartupValidationOrchestrator>();

View File

@@ -26,7 +26,8 @@ public class SquidWTFDownloadService : BaseDownloadService
private DateTime _lastRequestTime = DateTime.MinValue;
private readonly int _minRequestIntervalMs = 200;
private readonly string SquidWTFApiBase;
private readonly List<string> _apiUrls;
private int _currentUrlIndex = 0;
protected override string ProviderName => "squidwtf";
@@ -39,29 +40,48 @@ public class SquidWTFDownloadService : BaseDownloadService
IOptions<SquidWTFSettings> SquidWTFSettings,
IServiceProvider serviceProvider,
ILogger<SquidWTFDownloadService> logger,
string apiBase)
List<string> apiUrls)
: base(configuration, localLibraryService, metadataService, subsonicSettings.Value, serviceProvider, logger)
{
_httpClient = httpClientFactory.CreateClient();
_squidwtfSettings = SquidWTFSettings.Value;
SquidWTFApiBase = apiBase;
_apiUrls = apiUrls;
}
private async Task<T> TryWithFallbackAsync<T>(Func<string, Task<T>> action)
{
for (int attempt = 0; attempt < _apiUrls.Count; attempt++)
{
try
{
var baseUrl = _apiUrls[_currentUrlIndex];
return await action(baseUrl);
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Request failed with endpoint {Endpoint}, trying next...", _apiUrls[_currentUrlIndex]);
_currentUrlIndex = (_currentUrlIndex + 1) % _apiUrls.Count;
if (attempt == _apiUrls.Count - 1)
{
Logger.LogError("All SquidWTF endpoints failed");
throw;
}
}
}
throw new Exception("All SquidWTF endpoints failed");
}
#region BaseDownloadService Implementation
public override async Task<bool> IsAvailableAsync()
{
try
return await TryWithFallbackAsync(async (baseUrl) =>
{
var response = await _httpClient.GetAsync(SquidWTFApiBase);
var response = await _httpClient.GetAsync(baseUrl);
Console.WriteLine($"Response code from is available async: {response.IsSuccessStatusCode}");
return response.IsSuccessStatusCode;
}
catch (Exception ex)
{
Logger.LogWarning(ex, "SquidWTF service not available");
return false;
}
});
}
protected override string? ExtractExternalIdFromAlbumId(string albumId)
@@ -136,6 +156,8 @@ public class SquidWTFDownloadService : BaseDownloadService
private async Task<DownloadResult> GetTrackDownloadInfoAsync(string trackId, CancellationToken cancellationToken)
{
return await QueueRequestAsync(async () =>
{
return await TryWithFallbackAsync(async (baseUrl) =>
{
// Map quality settings to Tidal's quality levels
var quality = _squidwtfSettings.Quality?.ToUpperInvariant() switch
@@ -148,12 +170,10 @@ public class SquidWTFDownloadService : BaseDownloadService
_ => "LOSSLESS" // Default to lossless
};
var url = $"{SquidWTFApiBase}/track/?id={trackId}&quality={quality}";
var url = $"{baseUrl}/track/?id={trackId}&quality={quality}";
Console.WriteLine($"%%%%%%%%%%%%%%%%%%% URL For downloads??: {url}");
try
{
var response = await _httpClient.GetAsync(url, cancellationToken);
response.EnsureSuccessStatusCode();
@@ -199,12 +219,7 @@ public class SquidWTFDownloadService : BaseDownloadService
MimeType = mimeType ?? "audio/flac",
AudioQuality = audioQuality ?? "LOSSLESS"
};
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to get track info");
throw;
}
});
});
}

View File

@@ -21,7 +21,8 @@ public class SquidWTFMetadataService : IMusicMetadataService
private readonly SubsonicSettings _settings;
private readonly ILogger<SquidWTFMetadataService> _logger;
private readonly RedisCacheService _cache;
private readonly string BaseUrl;
private readonly List<string> _apiUrls;
private int _currentUrlIndex = 0;
public SquidWTFMetadataService(
IHttpClientFactory httpClientFactory,
@@ -29,24 +30,50 @@ public class SquidWTFMetadataService : IMusicMetadataService
IOptions<SquidWTFSettings> squidwtfSettings,
ILogger<SquidWTFMetadataService> logger,
RedisCacheService cache,
string baseUrl)
List<string> apiUrls)
{
_httpClient = httpClientFactory.CreateClient();
_settings = settings.Value;
_logger = logger;
_cache = cache;
BaseUrl = baseUrl;
_apiUrls = apiUrls;
// 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");
}
public async Task<List<Song>> SearchSongsAsync(string query, int limit = 20)
private string GetCurrentBaseUrl() => _apiUrls[_currentUrlIndex];
private async Task<T> TryWithFallbackAsync<T>(Func<string, Task<T>> action, T defaultValue)
{
for (int attempt = 0; attempt < _apiUrls.Count; attempt++)
{
try
{
var url = $"{BaseUrl}/search/?s={Uri.EscapeDataString(query)}";
var baseUrl = _apiUrls[_currentUrlIndex];
return await action(baseUrl);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Request failed with endpoint {Endpoint}, trying next...", _apiUrls[_currentUrlIndex]);
_currentUrlIndex = (_currentUrlIndex + 1) % _apiUrls.Count;
if (attempt == _apiUrls.Count - 1)
{
_logger.LogError("All SquidWTF endpoints failed");
return defaultValue;
}
}
}
return defaultValue;
}
public async Task<List<Song>> SearchSongsAsync(string query, int limit = 20)
{
return await TryWithFallbackAsync(async (baseUrl) =>
{
var url = $"{baseUrl}/search/?s={Uri.EscapeDataString(query)}";
var response = await _httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode)
@@ -72,19 +99,14 @@ public class SquidWTFMetadataService : IMusicMetadataService
}
}
return songs;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to search songs for query: {Query}", query);
return new List<Song>();
}
}, new List<Song>());
}
public async Task<List<Album>> SearchAlbumsAsync(string query, int limit = 20)
{
try
return await 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);
if (!response.IsSuccessStatusCode)
@@ -111,19 +133,14 @@ public class SquidWTFMetadataService : IMusicMetadataService
}
return albums;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to search albums for query: {Query}", query);
return new List<Album>();
}
}, new List<Album>());
}
public async Task<List<Artist>> SearchArtistsAsync(string query, int limit = 20)
{
try
return await TryWithFallbackAsync(async (baseUrl) =>
{
var url = $"{BaseUrl}/search/?a={Uri.EscapeDataString(query)}";
var url = $"{baseUrl}/search/?a={Uri.EscapeDataString(query)}";
var response = await _httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode)
@@ -150,19 +167,14 @@ public class SquidWTFMetadataService : IMusicMetadataService
}
return artists;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to search artists for query: {Query}", query);
return new List<Artist>();
}
}, new List<Artist>());
}
public async Task<List<ExternalPlaylist>> SearchPlaylistsAsync(string query, int limit = 20)
{
try
return await 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);
if (!response.IsSuccessStatusCode) return new List<ExternalPlaylist>();
@@ -180,11 +192,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
}
}
return playlists;
}
catch
{
return new List<ExternalPlaylist>();
}
}, new List<ExternalPlaylist>());
}
public async Task<SearchResult> SearchAllAsync(string query, int songLimit = 20, int albumLimit = 20, int artistLimit = 20)
@@ -210,10 +218,9 @@ public class SquidWTFMetadataService : IMusicMetadataService
{
if (externalProvider != "squidwtf") return null;
try
return await TryWithFallbackAsync(async (baseUrl) =>
{
// Use the /info endpoint for full track metadata
var url = $"{BaseUrl}/info/?id={externalId}";
var url = $"{baseUrl}/info/?id={externalId}";
var response = await _httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode) return null;
@@ -225,12 +232,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
return null;
return ParseTidalTrackFull(track);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "GetSongAsync Exception");
return null;
}
}, (Song?)null);
}
public async Task<Album?> GetAlbumAsync(string externalProvider, string externalId)
@@ -242,10 +244,9 @@ public class SquidWTFMetadataService : IMusicMetadataService
var cached = await _cache.GetAsync<Album>(cacheKey);
if (cached != null) return cached;
try
return await TryWithFallbackAsync(async (baseUrl) =>
{
// Use the /info endpoint for full track metadata
var url = $"{BaseUrl}/album/?id={externalId}";
var url = $"{baseUrl}/album/?id={externalId}";
var response = await _httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode) return null;
@@ -279,12 +280,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
await _cache.SetAsync(cacheKey, album, TimeSpan.FromHours(24));
return album;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "GetAlbumAsync Exception");
return null;
}
}, (Album?)null);
}
public async Task<Artist?> GetArtistAsync(string externalProvider, string externalId)
@@ -302,10 +298,9 @@ public class SquidWTFMetadataService : IMusicMetadataService
return cached;
}
try
return await TryWithFallbackAsync(async (baseUrl) =>
{
// Use the /info endpoint for full track metadata
var url = $"{BaseUrl}/artist/?f={externalId}";
var url = $"{baseUrl}/artist/?f={externalId}";
_logger.LogInformation("Fetching artist from {Url}", url);
var response = await _httpClient.GetAsync(url);
@@ -366,25 +361,18 @@ public class SquidWTFMetadataService : IMusicMetadataService
await _cache.SetAsync(cacheKey, artist, TimeSpan.FromHours(24));
return artist;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "GetArtistAsync Exception.");
return null;
}
}, (Artist?)null);
}
public async Task<List<Album>> GetArtistAlbumsAsync(string externalProvider, string externalId)
{
try
{
if (externalProvider != "squidwtf") return new List<Album>();
return await TryWithFallbackAsync(async (baseUrl) =>
{
_logger.LogInformation("GetArtistAlbumsAsync called for SquidWTF artist {ExternalId}", externalId);
var url = $"{BaseUrl}/artist/?f={externalId}";
var url = $"{baseUrl}/artist/?f={externalId}";
_logger.LogInformation("Fetching artist albums from URL: {Url}", url);
var response = await _httpClient.GetAsync(url);
@@ -418,21 +406,16 @@ public class SquidWTFMetadataService : IMusicMetadataService
}
return albums;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get SquidWTF artist albums for {ExternalId}", externalId);
return new List<Album>();
}
}, new List<Album>());
}
public async Task<ExternalPlaylist?> GetPlaylistAsync(string externalProvider, string externalId)
{
if (externalProvider != "squidwtf") return null;
try
return await TryWithFallbackAsync(async (baseUrl) =>
{
var url = $"{BaseUrl}/playlist/?id={externalId}";
var url = $"{baseUrl}/playlist/?id={externalId}";
var response = await _httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode) return null;
@@ -442,21 +425,16 @@ public class SquidWTFMetadataService : IMusicMetadataService
if (playlistElement.TryGetProperty("error", out _)) return null;
return ParseTidalPlaylist(playlistElement);
}
catch
{
return null;
}
}, (ExternalPlaylist?)null);
}
public async Task<List<Song>> GetPlaylistTracksAsync(string externalProvider, string externalId)
{
if (externalProvider != "squidwtf") return new List<Song>();
try
return await TryWithFallbackAsync(async (baseUrl) =>
{
var url = $"{BaseUrl}/playlist/?id={externalId}";
var url = $"{baseUrl}/playlist/?id={externalId}";
var response = await _httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode) return new List<Song>();
@@ -507,12 +485,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
}
}
return songs;
}
catch
{
return new List<Song>();
}
}, new List<Song>());
}
// --- Parser functions start here ---

View File

@@ -12,15 +12,39 @@ namespace allstarr.Services.SquidWTF;
public class SquidWTFStartupValidator : BaseStartupValidator
{
private readonly SquidWTFSettings _settings;
private readonly string _apiBase;
private readonly List<string> _apiUrls;
private int _currentUrlIndex = 0;
public override string ServiceName => "SquidWTF";
public SquidWTFStartupValidator(IOptions<SquidWTFSettings> settings, HttpClient httpClient, string apiBase)
public SquidWTFStartupValidator(IOptions<SquidWTFSettings> settings, HttpClient httpClient, List<string> apiUrls)
: base(httpClient)
{
_settings = settings.Value;
_apiBase = apiBase;
_apiUrls = apiUrls;
}
private async Task<T> TryWithFallbackAsync<T>(Func<string, Task<T>> action, T defaultValue)
{
for (int attempt = 0; attempt < _apiUrls.Count; attempt++)
{
try
{
var baseUrl = _apiUrls[_currentUrlIndex];
return await action(baseUrl);
}
catch
{
WriteDetail($"Endpoint {_apiUrls[_currentUrlIndex]} failed, trying next...");
_currentUrlIndex = (_currentUrlIndex + 1) % _apiUrls.Count;
if (attempt == _apiUrls.Count - 1)
{
return defaultValue;
}
}
}
return defaultValue;
}
public override async Task<ValidationResult> ValidateAsync(CancellationToken cancellationToken)
@@ -39,54 +63,36 @@ public class SquidWTFStartupValidator : BaseStartupValidator
WriteStatus("SquidWTF Quality", quality, ConsoleColor.Cyan);
// Test connectivity
try
// Test connectivity with fallback
var result = await TryWithFallbackAsync(async (baseUrl) =>
{
var response = await _httpClient.GetAsync(_apiBase, cancellationToken);
var response = await _httpClient.GetAsync(baseUrl, cancellationToken);
if (response.IsSuccessStatusCode)
{
WriteStatus("SquidWTF API", "REACHABLE", ConsoleColor.Green);
WriteStatus("SquidWTF API", $"REACHABLE ({baseUrl})", ConsoleColor.Green);
WriteDetail("No authentication required - powered by Tidal");
// Try a test search to verify functionality
await ValidateSearchFunctionality(cancellationToken);
await ValidateSearchFunctionality(baseUrl, cancellationToken);
return ValidationResult.Success("SquidWTF validation completed");
}
else
{
WriteStatus("SquidWTF API", $"HTTP {(int)response.StatusCode}", ConsoleColor.Yellow);
WriteDetail("Service may be temporarily unavailable");
return ValidationResult.Failure($"{response.StatusCode}", "SquidWTF returned code");
}
}
catch (TaskCanceledException)
{
WriteStatus("SquidWTF API", "TIMEOUT", ConsoleColor.Yellow);
WriteDetail("Could not reach service within timeout period");
return ValidationResult.Failure("-1", "SquidWTF connection timeout");
}
catch (HttpRequestException ex)
{
WriteStatus("SquidWTF API", "UNREACHABLE", ConsoleColor.Red);
WriteDetail(ex.Message);
return ValidationResult.Failure("-1", $"Cannot connect to SquidWTF: {ex.Message}");
}
catch (Exception ex)
{
WriteStatus("SquidWTF API", "ERROR", ConsoleColor.Red);
WriteDetail(ex.Message);
return ValidationResult.Failure("-1", $"Validation error: {ex.Message}");
throw new HttpRequestException($"HTTP {(int)response.StatusCode}");
}
}, ValidationResult.Failure("-1", "All SquidWTF endpoints failed"));
return result;
}
private async Task ValidateSearchFunctionality(CancellationToken cancellationToken)
private async Task ValidateSearchFunctionality(string baseUrl, CancellationToken cancellationToken)
{
try
{
// Test search with a simple query
var searchUrl = $"{_apiBase}/search/?s=Taylor%20Swift";
var searchUrl = $"{baseUrl}/search/?s=Taylor%20Swift";
var searchResponse = await _httpClient.GetAsync(searchUrl, cancellationToken);
if (searchResponse.IsSuccessStatusCode)