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();
builder.Services.AddHttpClient("SquidWTF");
builder.Services.ConfigureAll<HttpClientFactoryOptions>(options => builder.Services.ConfigureAll<HttpClientFactoryOptions>(options =>
{ {
options.HttpMessageHandlerBuilderActions.Add(builder => options.HttpMessageHandlerBuilderActions.Add(builder =>
@@ -643,7 +644,7 @@ builder.Services.AddSingleton<IStartupValidator, QobuzStartupValidator>();
builder.Services.AddSingleton<IStartupValidator>(sp => 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("SquidWTF"),
squidWtfApiUrls, squidWtfApiUrls,
squidWtfStreamingUrls, squidWtfStreamingUrls,
sp.GetRequiredService<EndpointBenchmarkService>(), sp.GetRequiredService<EndpointBenchmarkService>(),
@@ -26,17 +26,17 @@ public class RoundRobinFallbackHelper
_apiUrls = apiUrls ?? throw new ArgumentNullException(nameof(apiUrls)); _apiUrls = apiUrls ?? throw new ArgumentNullException(nameof(apiUrls));
_logger = logger ?? throw new ArgumentNullException(nameof(logger)); _logger = logger ?? throw new ArgumentNullException(nameof(logger));
_serviceName = serviceName ?? "Service"; _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 // Create a dedicated HttpClient for health checks with short timeout
_healthCheckClient = new HttpClient _healthCheckClient = new HttpClient
{ {
Timeout = TimeSpan.FromSeconds(3) // Quick health check timeout 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> /// <summary>
@@ -124,6 +124,11 @@ public class RoundRobinFallbackHelper
/// </summary> /// </summary>
private async Task<List<string>> GetHealthyEndpointsAsync() private async Task<List<string>> GetHealthyEndpointsAsync()
{ {
if (_apiUrls.Count == 0)
{
return new List<string>();
}
var healthCheckTasks = _apiUrls.Select(async url => new var healthCheckTasks = _apiUrls.Select(async url => new
{ {
Url = url, Url = url,
@@ -212,6 +217,11 @@ public class RoundRobinFallbackHelper
/// </summary> /// </summary>
public async Task<T> TryWithFallbackAsync<T>(Func<string, Task<T>> action) 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) // Get healthy endpoints first (with caching to avoid excessive checks)
var healthyEndpoints = await GetHealthyEndpointsAsync(); var healthyEndpoints = await GetHealthyEndpointsAsync();
@@ -254,72 +264,73 @@ public class RoundRobinFallbackHelper
} }
/// <summary> /// <summary>
/// Races all endpoints in parallel and returns the first successful result. /// Races the top N fastest endpoints in parallel and returns the first successful result.
/// Cancels remaining requests once one succeeds. Great for latency-sensitive operations. /// Cancels remaining requests once one succeeds. Used for latency-sensitive operations like search.
/// </summary> /// </summary>
/// <summary> public async Task<T> RaceTopEndpointsAsync<T>(int topN, Func<string, CancellationToken, Task<T>> action, CancellationToken cancellationToken = default)
/// 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. if (_apiUrls.Count == 0)
/// </summary>
public async Task<T> RaceTopEndpointsAsync<T>(int topN, Func<string, CancellationToken, Task<T>> action, CancellationToken cancellationToken = default)
{ {
if (_apiUrls.Count == 1 || topN <= 1) throw new InvalidOperationException($"No {_serviceName} endpoints are configured");
{
// 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");
} }
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> /// <summary>
/// Tries the request with the next provider in round-robin, then falls back to others on failure. /// 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. /// Performs quick health checks first to avoid wasting time on dead endpoints.
@@ -327,6 +338,12 @@ public class RoundRobinFallbackHelper
/// </summary> /// </summary>
public async Task<T> TryWithFallbackAsync<T>(Func<string, Task<T>> action, T defaultValue) 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) // Get healthy endpoints first (with caching to avoid excessive checks)
var healthyEndpoints = await GetHealthyEndpointsAsync(); var healthyEndpoints = await GetHealthyEndpointsAsync();
@@ -383,6 +400,12 @@ public class RoundRobinFallbackHelper
throw new ArgumentNullException(nameof(isAcceptableResult)); 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) // Get healthy endpoints first (with caching to avoid excessive checks)
var healthyEndpoints = await GetHealthyEndpointsAsync(); var healthyEndpoints = await GetHealthyEndpointsAsync();
@@ -483,6 +506,12 @@ public class RoundRobinFallbackHelper
return new List<TResult>(); 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 results = new List<TResult>();
var resultsLock = new object(); var resultsLock = new object();
var itemQueue = new Queue<TItem>(items); var itemQueue = new Queue<TItem>(items);
@@ -73,7 +73,7 @@ public class SquidWTFDownloadService : BaseDownloadService
List<string> apiUrls) List<string> apiUrls)
: base(configuration, localLibraryService, metadataService, subsonicSettings.Value, serviceProvider, logger) : base(configuration, localLibraryService, metadataService, subsonicSettings.Value, serviceProvider, logger)
{ {
_httpClient = httpClientFactory.CreateClient(); _httpClient = httpClientFactory.CreateClient("SquidWTF");
_squidwtfSettings = SquidWTFSettings.Value; _squidwtfSettings = SquidWTFSettings.Value;
_odesliService = odesliService; _odesliService = odesliService;
_fallbackHelper = new RoundRobinFallbackHelper(apiUrls, logger, "SquidWTF"); _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) 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( Logger.LogInformation(
"Track download info resolved via {Endpoint} (Format: {Format}, Quality: {Quality})", "Racing top {EndpointCount} SquidWTF endpoints for track {TrackId} manifest resolution",
downloadInfo.Endpoint, downloadInfo.MimeType, downloadInfo.AudioQuality); raceCount, trackId);
Logger.LogDebug("Resolved SquidWTF CDN download URL: {Url}", downloadInfo.DownloadUrl); }
var extension = downloadInfo.MimeType?.ToLower() switch var downloadInfo = await _fallbackHelper.RaceTopEndpointsAsync(
{ Math.Max(1, raceCount),
"audio/flac" => ".flac", "audio/mpeg" => ".mp3", "audio/mp4" => ".m4a", _ => ".flac" (baseUrl, ct) => FetchTrackDownloadInfoAsync(baseUrl, trackId, quality, ct),
}; cancellationToken);
var artistForPath = song.AlbumArtist ?? song.Artist; Logger.LogInformation(
var outputPath = PathHelper.BuildTrackPath(basePath, artistForPath, song.Album, song.Title, song.Track, extension, "squidwtf", trackId); "Track download info resolved via {Endpoint} (Format: {Format}, Quality: {Quality})",
downloadInfo.Endpoint, downloadInfo.MimeType, downloadInfo.AudioQuality);
var albumFolder = Path.GetDirectoryName(outputPath)!; Logger.LogDebug("Resolved SquidWTF CDN download URL: {Url}", downloadInfo.DownloadUrl);
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);
using var req = new HttpRequestMessage(HttpMethod.Get, downloadInfo.DownloadUrl); var extension = downloadInfo.MimeType?.ToLower() switch
req.Headers.Add("User-Agent", "Mozilla/5.0"); {
req.Headers.Add("Accept", "*/*"); "audio/flac" => ".flac", "audio/mpeg" => ".mp3", "audio/mp4" => ".m4a", _ => ".flac"
var res = await _httpClient.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, cancellationToken); };
res.EnsureSuccessStatusCode();
await using var responseStream = await res.Content.ReadAsStreamAsync(cancellationToken); var artistForPath = song.AlbumArtist ?? song.Artist;
await using var outputFile = IOFile.Create(outputPath); var outputPath = PathHelper.BuildTrackPath(basePath, artistForPath, song.Album, song.Title, song.Track, extension, "squidwtf", trackId);
var totalBytes = res.Content.Headers.ContentLength;
var buffer = new byte[81920];
long totalBytesRead = 0;
while (true) var albumFolder = Path.GetDirectoryName(outputPath)!;
{ EnsureDirectoryExists(albumFolder);
var bytesRead = await responseStream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken);
if (bytesRead <= 0)
{
break;
}
await outputFile.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken); if (basePath.EndsWith("transcoded") && IOFile.Exists(outputPath))
totalBytesRead += bytesRead; {
IOFile.SetLastWriteTime(outputPath, DateTime.UtcNow);
if (totalBytes.HasValue && totalBytes.Value > 0) Logger.LogInformation("Quality override cache hit: {Path}", outputPath);
{
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; 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) 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}"; 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); using var response = await _httpClient.GetAsync(url, cancellationToken);
@@ -356,7 +367,10 @@ public class SquidWTFDownloadService : BaseDownloadService
var audioQuality = data.TryGetProperty("audioQuality", out var audioQualityEl) var audioQuality = data.TryGetProperty("audioQuality", out var audioQualityEl)
? audioQualityEl.GetString() ? audioQualityEl.GetString()
: quality; : 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 return new DownloadResult
{ {
Endpoint = baseUrl, Endpoint = baseUrl,
@@ -76,7 +76,7 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
List<string> apiUrls, List<string> apiUrls,
GenreEnrichmentService? genreEnrichment = null) GenreEnrichmentService? genreEnrichment = null)
{ {
_httpClient = httpClientFactory.CreateClient(); _httpClient = httpClientFactory.CreateClient("SquidWTF");
_settings = settings.Value; _settings = settings.Value;
_logger = logger; _logger = logger;
_cache = cache; _cache = cache;
@@ -57,50 +57,79 @@ public class SquidWTFStartupValidator : BaseStartupValidator
WriteStatus("SquidWTF API Endpoints", _apiUrls.Count.ToString(), ConsoleColor.Cyan); WriteStatus("SquidWTF API Endpoints", _apiUrls.Count.ToString(), ConsoleColor.Cyan);
WriteStatus("SquidWTF Streaming Endpoints", _streamingUrls.Count.ToString(), ConsoleColor.Cyan); WriteStatus("SquidWTF Streaming Endpoints", _streamingUrls.Count.ToString(), ConsoleColor.Cyan);
await BenchmarkEndpointPoolAsync("API", _apiUrls, _apiFallbackHelper, cancellationToken); if (_apiUrls.Count == 0)
await BenchmarkEndpointPoolAsync("streaming", _streamingUrls, _streamingFallbackHelper, cancellationToken); {
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. // Validate API endpoints and search functionality.
var apiResult = await _apiFallbackHelper.TryWithFallbackAsync(async (baseUrl) => var apiResult = _apiUrls.Count == 0
{ ? ValidationResult.Failure("-1", "No SquidWTF API endpoints are currently available", ConsoleColor.Yellow)
var response = await _httpClient.GetAsync(baseUrl, cancellationToken); : await _apiFallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
if (response.IsSuccessStatusCode)
{ {
WriteStatus("SquidWTF API", $"REACHABLE ({baseUrl})", ConsoleColor.Green); var response = await _httpClient.GetAsync(baseUrl, cancellationToken);
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 (!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; return apiResult;
} }
// Validate streaming endpoints independently to avoid API-only endpoints for streaming. // Validate streaming endpoints independently to avoid API-only endpoints for streaming.
var streamingResult = await _streamingFallbackHelper.TryWithFallbackAsync(async (baseUrl) => var streamingResult = _streamingUrls.Count == 0
{ ? ValidationResult.Failure("-2", "No SquidWTF streaming endpoints are currently available", ConsoleColor.Yellow)
var response = await _httpClient.GetAsync(baseUrl, cancellationToken); : await _streamingFallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
if (response.IsSuccessStatusCode)
{ {
WriteStatus("SquidWTF Streaming", $"REACHABLE ({baseUrl})", ConsoleColor.Green); var response = await _httpClient.GetAsync(baseUrl, cancellationToken);
return ValidationResult.Success("SquidWTF streaming endpoint validation completed");
}
throw new HttpRequestException($"HTTP {(int)response.StatusCode}"); if (response.IsSuccessStatusCode)
}, ValidationResult.Failure("-2", "All SquidWTF streaming endpoints failed")); {
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; return streamingResult;
} }
@@ -19,16 +19,6 @@ public sealed class SquidWtfEndpointCatalog
throw new ArgumentNullException(nameof(streamingUrls)); 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; ApiUrls = apiUrls;
StreamingUrls = streamingUrls; StreamingUrls = streamingUrls;
LoadedAtUtc = DateTime.UtcNow; LoadedAtUtc = DateTime.UtcNow;
@@ -6,9 +6,9 @@ public static class SquidWtfEndpointDiscovery
{ {
public static readonly IReadOnlyList<string> SourceUrls = new[] public static readonly IReadOnlyList<string> SourceUrls = new[]
{ {
"https://tidal-uptime.geeked.wtf/",
"https://tidal-uptime.jiffy-puffs-1j.workers.dev/", "https://tidal-uptime.jiffy-puffs-1j.workers.dev/",
"https://tidal-uptime.props-76styles.workers.dev/", "https://tidal-uptime.props-76styles.workers.dev/"
"https://tidal-uptime.geeked.wtf/"
}; };
public static async Task<SquidWtfEndpointCatalog> DiscoverAsync(CancellationToken cancellationToken = default) public static async Task<SquidWtfEndpointCatalog> DiscoverAsync(CancellationToken cancellationToken = default)
@@ -24,8 +24,12 @@ public static class SquidWtfEndpointDiscovery
{ {
try try
{ {
Console.WriteLine($"Loading SquidWTF uptime feed: {sourceUrl}");
var json = await httpClient.GetStringAsync(sourceUrl, cancellationToken); 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) catch (Exception ex)
{ {
@@ -35,7 +39,9 @@ public static class SquidWtfEndpointDiscovery
if (feeds.Count == 0) 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 var orderedFeeds = feeds
@@ -61,12 +67,12 @@ public static class SquidWtfEndpointDiscovery
if (apiUrls.Count == 0) 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) 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}"); Console.WriteLine($"Loaded SquidWTF endpoints from uptime feeds: api={apiUrls.Count}, streaming={streamingUrls.Count}");