mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-10-08 14:05:02 -04:00
fix(squidwtf): race manifests and degrade gracefully
This commit is contained in:
7 files changed
+261
-192
No files matched your search
+2
-1
@@ -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}");
|
||||
|
||||
Reference in new issue
Block a user