fix(squidwtf): race manifests and degrade gracefully

This commit is contained in:
joshpatra committed 2026-04-23 18:15:15 -04:00
1 parent 3e28af4f4f
commit cae8eae509
7 files changed
+261 -192

No files matched your search

+2 -1
View File
@@ -163,6 +163,7 @@ builder.Services.AddControllers()
});
builder.Services.AddHttpClient();
builder.Services.AddHttpClient("SquidWTF");
builder.Services.ConfigureAll<HttpClientFactoryOptions>(options =>
{
options.HttpMessageHandlerBuilderActions.Add(builder =>
@@ -643,7 +644,7 @@ builder.Services.AddSingleton<IStartupValidator, QobuzStartupValidator>();
builder.Services.AddSingleton<IStartupValidator>(sp =>
new SquidWTFStartupValidator(
sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<SquidWTFSettings>>(),
sp.GetRequiredService<IHttpClientFactory>().CreateClient(),
sp.GetRequiredService<IHttpClientFactory>().CreateClient("SquidWTF"),
squidWtfApiUrls,
squidWtfStreamingUrls,
sp.GetRequiredService<EndpointBenchmarkService>(),
@@ -26,17 +26,17 @@ public class RoundRobinFallbackHelper
_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));
}
// Create a dedicated HttpClient for health checks with short timeout
_healthCheckClient = new HttpClient
{
Timeout = TimeSpan.FromSeconds(3) // Quick health check timeout
};
if (_apiUrls.Count == 0)
{
_logger.LogWarning("{Service} initialized with zero endpoints; external provider is currently unavailable", _serviceName);
}
}
/// <summary>
@@ -124,6 +124,11 @@ public class RoundRobinFallbackHelper
/// </summary>
private async Task<List<string>> GetHealthyEndpointsAsync()
{
if (_apiUrls.Count == 0)
{
return new List<string>();
}
var healthCheckTasks = _apiUrls.Select(async url => new
{
Url = url,
@@ -212,6 +217,11 @@ public class RoundRobinFallbackHelper
/// </summary>
public async Task<T> TryWithFallbackAsync<T>(Func<string, Task<T>> action)
{
if (_apiUrls.Count == 0)
{
throw new InvalidOperationException($"No {_serviceName} endpoints are configured");
}
// Get healthy endpoints first (with caching to avoid excessive checks)
var healthyEndpoints = await GetHealthyEndpointsAsync();
@@ -254,72 +264,73 @@ public class RoundRobinFallbackHelper
}
/// <summary>
/// Races all endpoints in parallel and returns the first successful result.
/// Cancels remaining requests once one succeeds. Great for latency-sensitive operations.
/// Races the top N fastest endpoints in parallel and returns the first successful result.
/// Cancels remaining requests once one succeeds. Used for latency-sensitive operations like search.
/// </summary>
/// <summary>
/// Races the top N fastest endpoints in parallel and returns the first successful result.
/// Cancels remaining requests once one succeeds. Used for latency-sensitive operations like search.
/// </summary>
public async Task<T> RaceTopEndpointsAsync<T>(int topN, Func<string, CancellationToken, Task<T>> action, CancellationToken cancellationToken = default)
public async Task<T> RaceTopEndpointsAsync<T>(int topN, Func<string, CancellationToken, Task<T>> action, CancellationToken cancellationToken = default)
{
if (_apiUrls.Count == 0)
{
if (_apiUrls.Count == 1 || topN <= 1)
{
// No point racing with one endpoint - use fallback instead
return await TryWithFallbackAsync(baseUrl => action(baseUrl, cancellationToken));
}
// Get top N fastest healthy endpoints
var endpointsToRace = _apiUrls.Take(Math.Min(topN, _apiUrls.Count)).ToList();
if (endpointsToRace.Count == 1)
{
return await action(endpointsToRace[0], cancellationToken);
}
using var raceCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
var tasks = new List<Task<(T result, string endpoint, bool success)>>();
// Start racing the top N endpoints
foreach (var baseUrl in endpointsToRace)
{
var task = Task.Run(async () =>
{
try
{
_logger.LogDebug("🏁 Racing {Service} endpoint {Endpoint}", _serviceName, baseUrl);
var result = await action(baseUrl, raceCts.Token);
return (result, baseUrl, true);
}
catch (Exception ex)
{
_logger.LogDebug("{Service} race failed for endpoint {Endpoint}: {Message}", _serviceName, baseUrl, ex.Message);
return (default(T)!, baseUrl, false);
}
}, raceCts.Token);
tasks.Add(task);
}
// Wait for first successful completion
while (tasks.Count > 0)
{
var completedTask = await Task.WhenAny(tasks);
var (result, endpoint, success) = await completedTask;
if (success)
{
_logger.LogDebug("🏆 {Service} race won by {Endpoint}, canceling others", _serviceName, endpoint);
raceCts.Cancel(); // Cancel all other requests
return result;
}
tasks.Remove(completedTask);
}
throw new Exception($"All {topN} {_serviceName} endpoints failed in race");
throw new InvalidOperationException($"No {_serviceName} endpoints are configured");
}
if (_apiUrls.Count == 1 || topN <= 1)
{
// No point racing with one endpoint - use fallback instead
return await TryWithFallbackAsync(baseUrl => action(baseUrl, cancellationToken));
}
// Get top N fastest healthy endpoints
var endpointsToRace = _apiUrls.Take(Math.Min(topN, _apiUrls.Count)).ToList();
if (endpointsToRace.Count == 1)
{
return await action(endpointsToRace[0], cancellationToken);
}
using var raceCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
var tasks = new List<Task<(T result, string endpoint, bool success)>>();
// Start racing the top N endpoints
foreach (var baseUrl in endpointsToRace)
{
var task = Task.Run(async () =>
{
try
{
_logger.LogDebug("🏁 Racing {Service} endpoint {Endpoint}", _serviceName, baseUrl);
var result = await action(baseUrl, raceCts.Token);
return (result, baseUrl, true);
}
catch (Exception ex)
{
_logger.LogDebug("{Service} race failed for endpoint {Endpoint}: {Message}", _serviceName, baseUrl, ex.Message);
return (default(T)!, baseUrl, false);
}
}, raceCts.Token);
tasks.Add(task);
}
// Wait for first successful completion
while (tasks.Count > 0)
{
var completedTask = await Task.WhenAny(tasks);
var (result, endpoint, success) = await completedTask;
if (success)
{
_logger.LogDebug("🏆 {Service} race won by {Endpoint}, canceling others", _serviceName, endpoint);
raceCts.Cancel(); // Cancel all other requests
return result;
}
tasks.Remove(completedTask);
}
throw new Exception($"All {topN} {_serviceName} endpoints failed in race");
}
/// <summary>
/// Tries the request with the next provider in round-robin, then falls back to others on failure.
/// Performs quick health checks first to avoid wasting time on dead endpoints.
@@ -327,6 +338,12 @@ public class RoundRobinFallbackHelper
/// </summary>
public async Task<T> TryWithFallbackAsync<T>(Func<string, Task<T>> action, T defaultValue)
{
if (_apiUrls.Count == 0)
{
_logger.LogWarning("No {Service} endpoints are configured, returning default value", _serviceName);
return defaultValue;
}
// Get healthy endpoints first (with caching to avoid excessive checks)
var healthyEndpoints = await GetHealthyEndpointsAsync();
@@ -383,6 +400,12 @@ public class RoundRobinFallbackHelper
throw new ArgumentNullException(nameof(isAcceptableResult));
}
if (_apiUrls.Count == 0)
{
_logger.LogWarning("No {Service} endpoints are configured, returning default value", _serviceName);
return defaultValue;
}
// Get healthy endpoints first (with caching to avoid excessive checks)
var healthyEndpoints = await GetHealthyEndpointsAsync();
@@ -483,6 +506,12 @@ public class RoundRobinFallbackHelper
return new List<TResult>();
}
if (_apiUrls.Count == 0)
{
_logger.LogWarning("No {Service} endpoints are configured, skipping parallel processing", _serviceName);
return new List<TResult>();
}
var results = new List<TResult>();
var resultsLock = new object();
var itemQueue = new Queue<TItem>(items);
@@ -73,7 +73,7 @@ public class SquidWTFDownloadService : BaseDownloadService
List<string> apiUrls)
: base(configuration, localLibraryService, metadataService, subsonicSettings.Value, serviceProvider, logger)
{
_httpClient = httpClientFactory.CreateClient();
_httpClient = httpClientFactory.CreateClient("SquidWTF");
_squidwtfSettings = SquidWTFSettings.Value;
_odesliService = odesliService;
_fallbackHelper = new RoundRobinFallbackHelper(apiUrls, logger, "SquidWTF");
@@ -99,87 +99,96 @@ public class SquidWTFDownloadService : BaseDownloadService
private async Task<string> RunDownloadWithFallbackAsync(string trackId, Song song, string quality, string basePath, CancellationToken cancellationToken)
{
return await _fallbackHelper.TryWithFallbackAsync(async baseUrl =>
var songId = BuildTrackedSongId(trackId);
var raceCount = Math.Min(3, _fallbackHelper.EndpointCount);
if (raceCount > 1)
{
var songId = BuildTrackedSongId(trackId);
var downloadInfo = await FetchTrackDownloadInfoAsync(baseUrl, trackId, quality, cancellationToken);
Logger.LogInformation(
"Track download info resolved via {Endpoint} (Format: {Format}, Quality: {Quality})",
downloadInfo.Endpoint, downloadInfo.MimeType, downloadInfo.AudioQuality);
Logger.LogDebug("Resolved SquidWTF CDN download URL: {Url}", downloadInfo.DownloadUrl);
"Racing top {EndpointCount} SquidWTF endpoints for track {TrackId} manifest resolution",
raceCount, trackId);
}
var extension = downloadInfo.MimeType?.ToLower() switch
{
"audio/flac" => ".flac", "audio/mpeg" => ".mp3", "audio/mp4" => ".m4a", _ => ".flac"
};
var downloadInfo = await _fallbackHelper.RaceTopEndpointsAsync(
Math.Max(1, raceCount),
(baseUrl, ct) => FetchTrackDownloadInfoAsync(baseUrl, trackId, quality, ct),
cancellationToken);
var artistForPath = song.AlbumArtist ?? song.Artist;
var outputPath = PathHelper.BuildTrackPath(basePath, artistForPath, song.Album, song.Title, song.Track, extension, "squidwtf", trackId);
var albumFolder = Path.GetDirectoryName(outputPath)!;
EnsureDirectoryExists(albumFolder);
if (basePath.EndsWith("transcoded") && IOFile.Exists(outputPath))
{
IOFile.SetLastWriteTime(outputPath, DateTime.UtcNow);
Logger.LogInformation("Quality override cache hit: {Path}", outputPath);
return outputPath;
}
outputPath = PathHelper.ResolveUniquePath(outputPath);
Logger.LogInformation(
"Track download info resolved via {Endpoint} (Format: {Format}, Quality: {Quality})",
downloadInfo.Endpoint, downloadInfo.MimeType, downloadInfo.AudioQuality);
Logger.LogDebug("Resolved SquidWTF CDN download URL: {Url}", downloadInfo.DownloadUrl);
using var req = new HttpRequestMessage(HttpMethod.Get, downloadInfo.DownloadUrl);
req.Headers.Add("User-Agent", "Mozilla/5.0");
req.Headers.Add("Accept", "*/*");
var res = await _httpClient.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
res.EnsureSuccessStatusCode();
var extension = downloadInfo.MimeType?.ToLower() switch
{
"audio/flac" => ".flac", "audio/mpeg" => ".mp3", "audio/mp4" => ".m4a", _ => ".flac"
};
await using var responseStream = await res.Content.ReadAsStreamAsync(cancellationToken);
await using var outputFile = IOFile.Create(outputPath);
var totalBytes = res.Content.Headers.ContentLength;
var buffer = new byte[81920];
long totalBytesRead = 0;
var artistForPath = song.AlbumArtist ?? song.Artist;
var outputPath = PathHelper.BuildTrackPath(basePath, artistForPath, song.Album, song.Title, song.Track, extension, "squidwtf", trackId);
while (true)
{
var bytesRead = await responseStream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken);
if (bytesRead <= 0)
{
break;
}
var albumFolder = Path.GetDirectoryName(outputPath)!;
EnsureDirectoryExists(albumFolder);
await outputFile.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken);
totalBytesRead += bytesRead;
if (totalBytes.HasValue && totalBytes.Value > 0)
{
SetDownloadProgress(songId, (double)totalBytesRead / totalBytes.Value);
}
}
await outputFile.DisposeAsync();
SetDownloadProgress(songId, 1.0);
_ = Task.Run(async () =>
{
try
{
var spotifyId = await _odesliService.ConvertTidalToSpotifyIdAsync(trackId, CancellationToken.None);
if (!string.IsNullOrEmpty(spotifyId))
{
Logger.LogDebug("Background Spotify ID obtained for Tidal/{TrackId}: {SpotifyId}", trackId, spotifyId);
}
}
catch (Exception ex)
{
Logger.LogDebug(ex, "Background Spotify ID conversion failed for Tidal/{TrackId}", trackId);
}
});
await WriteMetadataAsync(outputPath, song, cancellationToken);
if (basePath.EndsWith("transcoded") && IOFile.Exists(outputPath))
{
IOFile.SetLastWriteTime(outputPath, DateTime.UtcNow);
Logger.LogInformation("Quality override cache hit: {Path}", outputPath);
return outputPath;
}
outputPath = PathHelper.ResolveUniquePath(outputPath);
using var req = new HttpRequestMessage(HttpMethod.Get, downloadInfo.DownloadUrl);
req.Headers.Add("User-Agent", "Mozilla/5.0");
req.Headers.Add("Accept", "*/*");
var res = await _httpClient.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
res.EnsureSuccessStatusCode();
await using var responseStream = await res.Content.ReadAsStreamAsync(cancellationToken);
await using var outputFile = IOFile.Create(outputPath);
var totalBytes = res.Content.Headers.ContentLength;
var buffer = new byte[81920];
long totalBytesRead = 0;
while (true)
{
var bytesRead = await responseStream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken);
if (bytesRead <= 0)
{
break;
}
await outputFile.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken);
totalBytesRead += bytesRead;
if (totalBytes.HasValue && totalBytes.Value > 0)
{
SetDownloadProgress(songId, (double)totalBytesRead / totalBytes.Value);
}
}
await outputFile.DisposeAsync();
SetDownloadProgress(songId, 1.0);
_ = Task.Run(async () =>
{
try
{
var spotifyId = await _odesliService.ConvertTidalToSpotifyIdAsync(trackId, CancellationToken.None);
if (!string.IsNullOrEmpty(spotifyId))
{
Logger.LogDebug("Background Spotify ID obtained for Tidal/{TrackId}: {SpotifyId}", trackId, spotifyId);
}
}
catch (Exception ex)
{
Logger.LogDebug(ex, "Background Spotify ID conversion failed for Tidal/{TrackId}", trackId);
}
});
await WriteMetadataAsync(outputPath, song, cancellationToken);
return outputPath;
}
protected override async Task<string> DownloadTrackAsync(string trackId, Song song, CancellationToken cancellationToken)
@@ -315,7 +324,9 @@ public class SquidWTFDownloadService : BaseDownloadService
{
var url = $"{baseUrl}/track/?id={trackId}&quality={quality}";
Logger.LogDebug("Fetching track download info from: {Url}", url);
Logger.LogInformation("Requesting SquidWTF track manifest for track {TrackId} from {Endpoint} at quality {Quality}",
trackId, baseUrl, quality);
Logger.LogDebug("Fetching SquidWTF track download info from: {Url}", url);
using var response = await _httpClient.GetAsync(url, cancellationToken);
@@ -356,7 +367,10 @@ public class SquidWTFDownloadService : BaseDownloadService
var audioQuality = data.TryGetProperty("audioQuality", out var audioQualityEl)
? audioQualityEl.GetString()
: quality;
Logger.LogInformation("SquidWTF track manifest resolved for track {TrackId} via {Endpoint} (mimeType={MimeType}, audioQuality={AudioQuality})",
trackId, baseUrl, mimeType ?? "audio/flac", audioQuality ?? quality);
return new DownloadResult
{
Endpoint = baseUrl,
@@ -76,7 +76,7 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
List<string> apiUrls,
GenreEnrichmentService? genreEnrichment = null)
{
_httpClient = httpClientFactory.CreateClient();
_httpClient = httpClientFactory.CreateClient("SquidWTF");
_settings = settings.Value;
_logger = logger;
_cache = cache;
@@ -57,50 +57,79 @@ public class SquidWTFStartupValidator : BaseStartupValidator
WriteStatus("SquidWTF API Endpoints", _apiUrls.Count.ToString(), ConsoleColor.Cyan);
WriteStatus("SquidWTF Streaming Endpoints", _streamingUrls.Count.ToString(), ConsoleColor.Cyan);
await BenchmarkEndpointPoolAsync("API", _apiUrls, _apiFallbackHelper, cancellationToken);
await BenchmarkEndpointPoolAsync("streaming", _streamingUrls, _streamingFallbackHelper, cancellationToken);
if (_apiUrls.Count == 0)
{
WriteStatus("SquidWTF API", "UNAVAILABLE", ConsoleColor.Yellow);
WriteDetail("No API endpoints were discovered from the uptime feeds");
}
else
{
await BenchmarkEndpointPoolAsync("API", _apiUrls, _apiFallbackHelper, cancellationToken);
}
if (_streamingUrls.Count == 0)
{
WriteStatus("SquidWTF Streaming", "UNAVAILABLE", ConsoleColor.Yellow);
WriteDetail("No streaming endpoints were discovered from the uptime feeds");
}
else
{
await BenchmarkEndpointPoolAsync("streaming", _streamingUrls, _streamingFallbackHelper, cancellationToken);
}
if (_apiUrls.Count == 0 && _streamingUrls.Count == 0)
{
return ValidationResult.Failure(
"UNAVAILABLE",
"SquidWTF uptime feeds did not return any usable endpoints",
ConsoleColor.Yellow);
}
// Validate API endpoints and search functionality.
var apiResult = await _apiFallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
{
var response = await _httpClient.GetAsync(baseUrl, cancellationToken);
if (response.IsSuccessStatusCode)
var apiResult = _apiUrls.Count == 0
? ValidationResult.Failure("-1", "No SquidWTF API endpoints are currently available", ConsoleColor.Yellow)
: await _apiFallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
{
WriteStatus("SquidWTF API", $"REACHABLE ({baseUrl})", ConsoleColor.Green);
WriteDetail("No authentication required - powered by Tidal");
// Try a test search to verify functionality
await ValidateSearchFunctionality(baseUrl, cancellationToken);
return ValidationResult.Success("SquidWTF validation completed");
}
else
{
throw new HttpRequestException($"HTTP {(int)response.StatusCode}");
}
}, ValidationResult.Failure("-1", "All SquidWTF API endpoints failed"));
var response = await _httpClient.GetAsync(baseUrl, cancellationToken);
if (!apiResult.IsValid)
if (response.IsSuccessStatusCode)
{
WriteStatus("SquidWTF API", $"REACHABLE ({baseUrl})", ConsoleColor.Green);
WriteDetail("No authentication required - powered by Tidal");
// Try a test search to verify functionality
await ValidateSearchFunctionality(baseUrl, cancellationToken);
return ValidationResult.Success("SquidWTF validation completed");
}
else
{
throw new HttpRequestException($"HTTP {(int)response.StatusCode}");
}
}, ValidationResult.Failure("-1", "All SquidWTF API endpoints failed"));
if (_apiUrls.Count > 0 && !apiResult.IsValid)
{
return apiResult;
}
// Validate streaming endpoints independently to avoid API-only endpoints for streaming.
var streamingResult = await _streamingFallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
{
var response = await _httpClient.GetAsync(baseUrl, cancellationToken);
if (response.IsSuccessStatusCode)
var streamingResult = _streamingUrls.Count == 0
? ValidationResult.Failure("-2", "No SquidWTF streaming endpoints are currently available", ConsoleColor.Yellow)
: await _streamingFallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
{
WriteStatus("SquidWTF Streaming", $"REACHABLE ({baseUrl})", ConsoleColor.Green);
return ValidationResult.Success("SquidWTF streaming endpoint validation completed");
}
var response = await _httpClient.GetAsync(baseUrl, cancellationToken);
throw new HttpRequestException($"HTTP {(int)response.StatusCode}");
}, ValidationResult.Failure("-2", "All SquidWTF streaming endpoints failed"));
if (response.IsSuccessStatusCode)
{
WriteStatus("SquidWTF Streaming", $"REACHABLE ({baseUrl})", ConsoleColor.Green);
return ValidationResult.Success("SquidWTF streaming endpoint validation completed");
}
if (!streamingResult.IsValid)
throw new HttpRequestException($"HTTP {(int)response.StatusCode}");
}, ValidationResult.Failure("-2", "All SquidWTF streaming endpoints failed"));
if (_streamingUrls.Count > 0 && !streamingResult.IsValid)
{
return streamingResult;
}
@@ -19,16 +19,6 @@ public sealed class SquidWtfEndpointCatalog
throw new ArgumentNullException(nameof(streamingUrls));
}
if (apiUrls.Count == 0)
{
throw new ArgumentException("API URL list cannot be empty.", nameof(apiUrls));
}
if (streamingUrls.Count == 0)
{
throw new ArgumentException("Streaming URL list cannot be empty.", nameof(streamingUrls));
}
ApiUrls = apiUrls;
StreamingUrls = streamingUrls;
LoadedAtUtc = DateTime.UtcNow;
@@ -6,9 +6,9 @@ public static class SquidWtfEndpointDiscovery
{
public static readonly IReadOnlyList<string> SourceUrls = new[]
{
"https://tidal-uptime.geeked.wtf/",
"https://tidal-uptime.jiffy-puffs-1j.workers.dev/",
"https://tidal-uptime.props-76styles.workers.dev/",
"https://tidal-uptime.geeked.wtf/"
"https://tidal-uptime.props-76styles.workers.dev/"
};
public static async Task<SquidWtfEndpointCatalog> DiscoverAsync(CancellationToken cancellationToken = default)
@@ -24,8 +24,12 @@ public static class SquidWtfEndpointDiscovery
{
try
{
Console.WriteLine($"Loading SquidWTF uptime feed: {sourceUrl}");
var json = await httpClient.GetStringAsync(sourceUrl, cancellationToken);
feeds.Add(ParseFeed(json));
var feed = ParseFeed(json);
feeds.Add(feed);
Console.WriteLine(
$"Loaded SquidWTF uptime feed {sourceUrl}: api={feed.ApiUrls.Count}, streaming={feed.StreamingUrls.Count}, down={feed.DownUrls.Count}, lastUpdated={feed.LastUpdated:O}");
}
catch (Exception ex)
{
@@ -35,7 +39,9 @@ public static class SquidWtfEndpointDiscovery
if (feeds.Count == 0)
{
throw new InvalidOperationException("Could not load SquidWTF endpoint feeds from any source URL.");
Console.WriteLine(
"⚠️ No SquidWTF uptime feeds could be loaded. Starting with SquidWTF external features unavailable; local Jellyfin content will still work.");
return new SquidWtfEndpointCatalog(new List<string>(), new List<string>());
}
var orderedFeeds = feeds
@@ -61,12 +67,12 @@ public static class SquidWtfEndpointDiscovery
if (apiUrls.Count == 0)
{
throw new InvalidOperationException("SquidWTF endpoint feed returned zero API endpoints.");
Console.WriteLine("⚠️ SquidWTF uptime feeds returned zero API endpoints.");
}
if (streamingUrls.Count == 0)
{
throw new InvalidOperationException("SquidWTF endpoint feed returned zero streaming endpoints.");
Console.WriteLine("⚠️ SquidWTF uptime feeds returned zero streaming endpoints.");
}
Console.WriteLine($"Loaded SquidWTF endpoints from uptime feeds: api={apiUrls.Count}, streaming={streamingUrls.Count}");