diff --git a/allstarr/Program.cs b/allstarr/Program.cs index 928ff70a..25f9b287 100644 --- a/allstarr/Program.cs +++ b/allstarr/Program.cs @@ -163,6 +163,7 @@ builder.Services.AddControllers() }); builder.Services.AddHttpClient(); +builder.Services.AddHttpClient("SquidWTF"); builder.Services.ConfigureAll(options => { options.HttpMessageHandlerBuilderActions.Add(builder => @@ -643,7 +644,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => new SquidWTFStartupValidator( sp.GetRequiredService>(), - sp.GetRequiredService().CreateClient(), + sp.GetRequiredService().CreateClient("SquidWTF"), squidWtfApiUrls, squidWtfStreamingUrls, sp.GetRequiredService(), diff --git a/allstarr/Services/Common/RoundRobinFallbackHelper.cs b/allstarr/Services/Common/RoundRobinFallbackHelper.cs index 0af8ade9..56c749e6 100644 --- a/allstarr/Services/Common/RoundRobinFallbackHelper.cs +++ b/allstarr/Services/Common/RoundRobinFallbackHelper.cs @@ -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); + } } /// @@ -124,6 +124,11 @@ public class RoundRobinFallbackHelper /// private async Task> GetHealthyEndpointsAsync() { + if (_apiUrls.Count == 0) + { + return new List(); + } + var healthCheckTasks = _apiUrls.Select(async url => new { Url = url, @@ -212,6 +217,11 @@ public class RoundRobinFallbackHelper /// public async Task TryWithFallbackAsync(Func> 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 } /// - /// 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. /// - /// - /// 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. - /// - public async Task RaceTopEndpointsAsync(int topN, Func> action, CancellationToken cancellationToken = default) + public async Task RaceTopEndpointsAsync(int topN, Func> 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>(); - - // 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>(); + + // 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"); + } + /// /// 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 /// public async Task TryWithFallbackAsync(Func> 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(); } + if (_apiUrls.Count == 0) + { + _logger.LogWarning("No {Service} endpoints are configured, skipping parallel processing", _serviceName); + return new List(); + } + var results = new List(); var resultsLock = new object(); var itemQueue = new Queue(items); diff --git a/allstarr/Services/SquidWTF/SquidWTFDownloadService.cs b/allstarr/Services/SquidWTF/SquidWTFDownloadService.cs index 37ee0a32..2f9e6b36 100644 --- a/allstarr/Services/SquidWTF/SquidWTFDownloadService.cs +++ b/allstarr/Services/SquidWTF/SquidWTFDownloadService.cs @@ -73,7 +73,7 @@ public class SquidWTFDownloadService : BaseDownloadService List 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 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 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, diff --git a/allstarr/Services/SquidWTF/SquidWTFMetadataService.cs b/allstarr/Services/SquidWTF/SquidWTFMetadataService.cs index a98f67cc..46644592 100644 --- a/allstarr/Services/SquidWTF/SquidWTFMetadataService.cs +++ b/allstarr/Services/SquidWTF/SquidWTFMetadataService.cs @@ -76,7 +76,7 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService List apiUrls, GenreEnrichmentService? genreEnrichment = null) { - _httpClient = httpClientFactory.CreateClient(); + _httpClient = httpClientFactory.CreateClient("SquidWTF"); _settings = settings.Value; _logger = logger; _cache = cache; diff --git a/allstarr/Services/SquidWTF/SquidWTFStartupValidator.cs b/allstarr/Services/SquidWTF/SquidWTFStartupValidator.cs index 75e1ae73..9fd0dfdb 100644 --- a/allstarr/Services/SquidWTF/SquidWTFStartupValidator.cs +++ b/allstarr/Services/SquidWTF/SquidWTFStartupValidator.cs @@ -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; } diff --git a/allstarr/Services/SquidWTF/SquidWtfEndpointCatalog.cs b/allstarr/Services/SquidWTF/SquidWtfEndpointCatalog.cs index 4e98a666..cc3abf17 100644 --- a/allstarr/Services/SquidWTF/SquidWtfEndpointCatalog.cs +++ b/allstarr/Services/SquidWTF/SquidWtfEndpointCatalog.cs @@ -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; diff --git a/allstarr/Services/SquidWTF/SquidWtfEndpointDiscovery.cs b/allstarr/Services/SquidWTF/SquidWtfEndpointDiscovery.cs index 14673d36..586f45e2 100644 --- a/allstarr/Services/SquidWTF/SquidWtfEndpointDiscovery.cs +++ b/allstarr/Services/SquidWTF/SquidWtfEndpointDiscovery.cs @@ -6,9 +6,9 @@ public static class SquidWtfEndpointDiscovery { public static readonly IReadOnlyList 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 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(), new List()); } 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}");