refactor(startup): remove no-op provider probes

This commit is contained in:
joshpatra committed 2026-07-27 04:18:19 -04:00
1 parent 4c975826cf
commit cf5ca81b62
8 files changed
+25 -764

No files matched your search

-3
View File
@@ -496,8 +496,6 @@ var probeOptionalProvidersAtStartup =
builder.Configuration.GetValue<bool>("StartupValidation:ProbeOptionalProviders");
if (probeOptionalProvidersAtStartup)
{
builder.Services.AddSingleton<IStartupValidator, DeezerStartupValidator>();
builder.Services.AddSingleton<IStartupValidator, QobuzStartupValidator>();
var disabledProviders = ParseCsv(builder.Configuration["MULTI_PROVIDER_DISABLED_PROVIDERS"])
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var enabledMetadataProviders = ParseCsv(
@@ -507,7 +505,6 @@ if (probeOptionalProvidersAtStartup)
{
builder.Services.AddSingleton<IStartupValidator>(sp =>
new SquidWTFStartupValidator(
sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<SquidWTFSettings>>(),
sp.GetRequiredService<IHttpClientFactory>().CreateClient("SquidWTF"),
squidWtfApiUrls,
squidWtfStreamingUrls,
@@ -1,156 +0,0 @@
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Options;
using allstarr.Models.Settings;
using allstarr.Services.Validation;
namespace allstarr.Services.Deezer;
/// <summary>
/// Validates Deezer ARL credentials at startup
/// </summary>
public class DeezerStartupValidator : BaseStartupValidator
{
private readonly DeezerSettings _settings;
public override string ServiceName => "Deezer";
public DeezerStartupValidator(IOptions<DeezerSettings> settings, HttpClient httpClient)
: base(httpClient)
{
_settings = settings.Value;
}
public override async Task<ValidationResult> ValidateAsync(CancellationToken cancellationToken)
{
var arl = _settings.Arl;
var arlFallback = _settings.ArlFallback;
var quality = _settings.Quality;
if (string.IsNullOrWhiteSpace(arl))
{
WriteStatus("Deezer ARL", "NOT CONFIGURED", ConsoleColor.Red);
WriteDetail("Set the Deezer__Arl environment variable");
return ValidationResult.NotConfigured("Deezer ARL not configured");
}
WriteStatus("Deezer ARL", MaskSecret(arl), ConsoleColor.Cyan);
if (!string.IsNullOrWhiteSpace(arlFallback))
{
WriteStatus("Deezer ARL Fallback", MaskSecret(arlFallback), ConsoleColor.Cyan);
}
WriteStatus("Deezer Quality", string.IsNullOrWhiteSpace(quality) ? "auto (highest available)" : quality, ConsoleColor.Cyan);
// Validate ARL by calling Deezer API
await ValidateArlTokenAsync(arl, "primary", cancellationToken);
if (!string.IsNullOrWhiteSpace(arlFallback))
{
await ValidateArlTokenAsync(arlFallback, "fallback", cancellationToken);
}
return ValidationResult.Success("Deezer validation completed");
}
private async Task ValidateArlTokenAsync(string arl, string label, CancellationToken cancellationToken)
{
var fieldName = $"Deezer ARL ({label})";
try
{
using var request = new HttpRequestMessage(HttpMethod.Post,
"https://www.deezer.com/ajax/gw-light.php?method=deezer.getUserData&input=3&api_version=1.0&api_token=null");
request.Headers.Add("Cookie", $"arl={arl}");
request.Content = new StringContent("{}", Encoding.UTF8, "application/json");
var response = await _httpClient.SendAsync(request, cancellationToken);
if (!response.IsSuccessStatusCode)
{
WriteStatus(fieldName, $"HTTP {(int)response.StatusCode}", ConsoleColor.Red);
return;
}
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var doc = JsonDocument.Parse(json);
if (doc.RootElement.TryGetProperty("results", out var results) &&
results.TryGetProperty("USER", out var user))
{
if (user.TryGetProperty("USER_ID", out var userId))
{
var userIdValue = userId.ValueKind == JsonValueKind.Number
? userId.GetInt64()
: long.TryParse(userId.GetString(), out var parsed) ? parsed : 0;
if (userIdValue > 0)
{
// BLOG_NAME is the username displayed on Deezer
var userName = user.TryGetProperty("BLOG_NAME", out var blogName) && blogName.GetString() is string bn && !string.IsNullOrEmpty(bn)
? bn
: user.TryGetProperty("NAME", out var name) && name.GetString() is string n && !string.IsNullOrEmpty(n)
? n
: "Unknown";
var offerName = GetOfferName(user);
WriteStatus(fieldName, "VALID", ConsoleColor.Green);
WriteDetail($"Logged in as {userName} ({offerName})");
return;
}
}
WriteStatus(fieldName, "INVALID", ConsoleColor.Red);
WriteDetail("Token is expired or invalid");
}
else
{
WriteStatus(fieldName, "INVALID", ConsoleColor.Red);
WriteDetail("Unexpected response from Deezer");
}
}
catch (TaskCanceledException)
{
WriteStatus(fieldName, "TIMEOUT", ConsoleColor.Yellow);
WriteDetail("Could not reach Deezer within 10 seconds");
}
catch (HttpRequestException)
{
WriteStatus(fieldName, "UNREACHABLE", ConsoleColor.Yellow);
WriteDetail("The Deezer service could not be reached");
}
catch (Exception ex)
{
WriteStatus(fieldName, "ERROR", ConsoleColor.Red);
WriteDetail($"Deezer validation failed ({ex.GetType().Name})");
}
}
private static string GetOfferName(JsonElement user)
{
if (!user.TryGetProperty("OPTIONS", out var options))
{
return "Free";
}
// Check actual streaming capabilities, not just license_token presence
var hasLossless = options.TryGetProperty("web_lossless", out var webLossless) && webLossless.GetBoolean();
var hasHq = options.TryGetProperty("web_hq", out var webHq) && webHq.GetBoolean();
if (hasLossless)
{
return "Premium+ (Lossless)";
}
if (hasHq)
{
return "Premium (HQ)";
}
return "Free";
}
}
@@ -1,3 +1,4 @@
using System.Text.Json;
using Microsoft.Extensions.Options;
using allstarr.Models.Settings;
using allstarr.Services.Validation;
@@ -25,99 +26,40 @@ public class JellyfinStartupValidator : BaseStartupValidator
if (string.IsNullOrWhiteSpace(settings.Url))
{
WriteStatus("Jellyfin URL", "NOT CONFIGURED", ConsoleColor.Red);
WriteDetail("Set the Jellyfin__Url environment variable");
return ValidationResult.NotConfigured("Jellyfin URL not configured");
}
WriteStatus("Jellyfin URL", settings.Url, ConsoleColor.Cyan);
// API Key is optional - only needed for server-to-server operations
// Client authentication uses username/password via /Users/AuthenticateByName
if (!string.IsNullOrWhiteSpace(settings.ApiKey))
{
WriteStatus("API Key", MaskSecret(settings.ApiKey), ConsoleColor.DarkGray);
WriteDetail("(Optional - for server operations)");
}
if (!string.IsNullOrWhiteSpace(settings.UserId))
{
WriteStatus("User ID", MaskSecret(settings.UserId), ConsoleColor.DarkGray);
WriteDetail("(Optional - for server operations)");
}
try
{
// Test connection using public system info endpoint (no auth required)
var publicInfoUrl = $"{settings.Url.TrimEnd('/')}/System/Info/Public";
var response = await _httpClient.GetAsync(publicInfoUrl, cancellationToken);
if (response.IsSuccessStatusCode)
{
var content = await response.Content.ReadAsStringAsync(cancellationToken);
// Try to parse server info
string? serverName = null;
string? version = null;
if (content.Contains("ServerName"))
{
var nameStart = content.IndexOf("\"ServerName\":", StringComparison.Ordinal);
if (nameStart >= 0)
{
nameStart = content.IndexOf('"', nameStart + 13) + 1;
var nameEnd = content.IndexOf('"', nameStart);
if (nameEnd > nameStart)
{
serverName = content[nameStart..nameEnd];
}
}
}
if (content.Contains("Version"))
{
var verStart = content.IndexOf("\"Version\":", StringComparison.Ordinal);
if (verStart >= 0)
{
verStart = content.IndexOf('"', verStart + 10) + 1;
var verEnd = content.IndexOf('"', verStart);
if (verEnd > verStart)
{
version = content[verStart..verEnd];
}
}
}
using var document = JsonDocument.Parse(content);
var root = document.RootElement;
var serverName = root.TryGetProperty("ServerName", out var name) ? name.GetString() : null;
var version = root.TryGetProperty("Version", out var value) ? value.GetString() : null;
var serverInfo = !string.IsNullOrEmpty(serverName)
? $"{serverName} (v{version ?? "unknown"})"
: "OK";
WriteStatus("Jellyfin server", serverInfo, ConsoleColor.Green);
// Test authenticated access if API key is configured
if (!string.IsNullOrWhiteSpace(settings.ApiKey))
{
await ValidateAuthenticatedAccessAsync(settings, cancellationToken);
}
return ValidationResult.Success($"Connected to {serverInfo}");
}
else
{
WriteStatus("Jellyfin server", $"HTTP {(int)response.StatusCode}", ConsoleColor.Red);
return ValidationResult.Failure($"HTTP {(int)response.StatusCode}",
"Jellyfin server returned an error", ConsoleColor.Red);
}
}
catch (TaskCanceledException)
{
WriteStatus("Jellyfin server", "TIMEOUT", ConsoleColor.Red);
WriteDetail("Could not reach server within 10 seconds");
return ValidationResult.Failure("TIMEOUT", "Could not reach server within timeout period", ConsoleColor.Red);
}
catch (HttpRequestException)
{
WriteStatus("Jellyfin server", "UNREACHABLE", ConsoleColor.Red);
return ValidationResult.Failure(
"UNREACHABLE",
"The Jellyfin server could not be reached",
@@ -125,7 +67,6 @@ public class JellyfinStartupValidator : BaseStartupValidator
}
catch (Exception ex)
{
WriteStatus("Jellyfin server", "ERROR", ConsoleColor.Red);
return ValidationResult.Failure(
"ERROR",
$"Jellyfin validation failed ({ex.GetType().Name})",
@@ -133,86 +74,4 @@ public class JellyfinStartupValidator : BaseStartupValidator
}
}
private async Task ValidateAuthenticatedAccessAsync(JellyfinSettings settings, CancellationToken cancellationToken)
{
try
{
var authHeader = $"MediaBrowser Client=\"{settings.ClientName}\", " +
$"Device=\"{settings.DeviceName}\", " +
$"DeviceId=\"{settings.DeviceId}\", " +
$"Version=\"{settings.ClientVersion}\", " +
$"Token=\"{settings.ApiKey}\"";
using var request = new HttpRequestMessage(HttpMethod.Get,
$"{settings.Url?.TrimEnd('/')}/System/Info");
request.Headers.Add("Authorization", authHeader);
var response = await _httpClient.SendAsync(request, cancellationToken);
if (response.IsSuccessStatusCode)
{
WriteStatus("Authentication", "OK", ConsoleColor.Green);
}
else if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{
WriteStatus("Authentication", "INVALID API KEY", ConsoleColor.Red);
WriteDetail("Check your Jellyfin API key configuration");
}
else
{
WriteStatus("Authentication", $"HTTP {(int)response.StatusCode}", ConsoleColor.Yellow);
}
// Check if we can access the music library
if (!string.IsNullOrWhiteSpace(settings.LibraryId))
{
WriteStatus("Library ID", settings.LibraryId, ConsoleColor.DarkGray);
}
else
{
await TryDetectMusicLibraryAsync(settings, authHeader, cancellationToken);
}
}
catch (Exception ex)
{
WriteStatus("Authentication", "ERROR", ConsoleColor.Yellow);
WriteDetail($"Authentication validation failed ({ex.GetType().Name})");
}
}
private async Task TryDetectMusicLibraryAsync(JellyfinSettings settings, string authHeader, CancellationToken cancellationToken)
{
try
{
var url = $"{settings.Url?.TrimEnd('/')}/Library/MediaFolders";
if (!string.IsNullOrWhiteSpace(settings.UserId))
{
url += $"?userId={settings.UserId}";
}
using var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Add("Authorization", authHeader);
var response = await _httpClient.SendAsync(request, cancellationToken);
if (response.IsSuccessStatusCode)
{
var content = await response.Content.ReadAsStringAsync(cancellationToken);
if (content.Contains("\"CollectionType\":\"music\""))
{
WriteStatus("Music library", "DETECTED", ConsoleColor.Green);
}
else
{
WriteStatus("Music library", "NOT FOUND", ConsoleColor.Yellow);
WriteDetail("No music library detected. Set Jellyfin__LibraryId to specify one.");
}
}
}
catch
{
// Silently ignore - not critical for startup
}
}
}
@@ -33,28 +33,9 @@ public class LyricsStartupValidator : BaseStartupValidator
public override async Task<ValidationResult> ValidateAsync(CancellationToken cancellationToken)
{
WriteStatus("Lyrics Test Song", $"{TestSongTitle} by {TestArtist}", ConsoleColor.Cyan);
WriteDetail($"Spotify ID: {TestSpotifyId}");
var allSuccess = true;
// Test 1: LRCLib
allSuccess &= await TestLrclibAsync(cancellationToken);
// Test 2: Spotify Lyrics Sidecar
var allSuccess = await TestLrclibAsync(cancellationToken);
allSuccess &= await TestSpotifyLyricsSidecarAsync(cancellationToken);
// Test 3: Spotify API (if enabled)
if (_spotifySettings.Enabled)
{
allSuccess &= await TestSpotifyApiAsync(cancellationToken);
}
else
{
WriteStatus("Spotify API", "DISABLED", ConsoleColor.Yellow);
WriteDetail("Enable SpotifyApi__Enabled to test Spotify API lyrics");
}
return allSuccess
? ValidationResult.Success("Lyrics services validation completed")
: ValidationResult.Failure("PARTIAL", "Some lyrics services had issues", ConsoleColor.Yellow);
@@ -71,33 +52,20 @@ public class LyricsStartupValidator : BaseStartupValidator
if (response.IsSuccessStatusCode)
{
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var doc = JsonDocument.Parse(json);
var hasSyncedLyrics = doc.RootElement.TryGetProperty("syncedLyrics", out var synced) &&
!string.IsNullOrEmpty(synced.GetString());
var hasPlainLyrics = doc.RootElement.TryGetProperty("plainLyrics", out var plain) &&
!string.IsNullOrEmpty(plain.GetString());
WriteStatus("LRCLib", "WORKING", ConsoleColor.Green);
WriteDetail($"✓ Synced: {hasSyncedLyrics}, Plain: {hasPlainLyrics}");
JsonDocument.Parse(json).Dispose();
return true;
}
else if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
WriteStatus("LRCLib", "NO LYRICS FOUND", ConsoleColor.Yellow);
WriteDetail("Service is working but no lyrics available for test song");
return true; // Service is working, just no lyrics
}
else
{
WriteStatus("LRCLib", $"HTTP {(int)response.StatusCode}", ConsoleColor.Red);
return false;
}
}
catch (Exception ex)
catch
{
WriteStatus("LRCLib", "ERROR", ConsoleColor.Red);
WriteDetail($"LRCLib validation failed ({ex.GetType().Name})");
return false;
}
}
@@ -108,8 +76,6 @@ public class LyricsStartupValidator : BaseStartupValidator
{
if (string.IsNullOrEmpty(_spotifySettings.LyricsApiUrl))
{
WriteStatus("Spotify Lyrics Sidecar", "NOT CONFIGURED", ConsoleColor.Yellow);
WriteDetail("Set SpotifyApi__LyricsApiUrl to enable");
return true; // Not an error, just not configured
}
@@ -126,56 +92,18 @@ public class LyricsStartupValidator : BaseStartupValidator
if (hasError)
{
WriteStatus("Spotify Lyrics Sidecar", "API ERROR", ConsoleColor.Yellow);
WriteDetail("Check if sp_dc cookie is valid");
return false;
}
var syncType = doc.RootElement.TryGetProperty("syncType", out var st)
? st.GetString()
: "UNKNOWN";
var lineCount = doc.RootElement.TryGetProperty("lines", out var lines)
? lines.GetArrayLength()
: 0;
WriteStatus("Spotify Lyrics Sidecar", "WORKING", ConsoleColor.Green);
WriteDetail($"✓ Type: {syncType}, Lines: {lineCount}");
return true;
}
else
{
WriteStatus("Spotify Lyrics Sidecar", $"HTTP {(int)response.StatusCode}", ConsoleColor.Red);
WriteDetail("Check if spotify-lyrics container is running");
return false;
}
}
catch (Exception ex)
catch
{
WriteStatus("Spotify Lyrics Sidecar", "ERROR", ConsoleColor.Red);
WriteDetail($"Lyrics sidecar validation failed ({ex.GetType().Name})");
WriteDetail("Ensure spotify-lyrics container is running in docker-compose");
return false;
}
}
private async Task<bool> TestSpotifyApiAsync(CancellationToken cancellationToken)
{
try
{
if (!_spotifySettings.Enabled)
{
WriteStatus("Spotify API", "DISABLED", ConsoleColor.Gray);
return true;
}
WriteStatus("Spotify API", "CONFIGURED", ConsoleColor.Green);
WriteDetail("Note: Spotify API is used for track matching and lyrics");
return true;
}
catch (Exception ex)
{
WriteStatus("Spotify API", "ERROR", ConsoleColor.Red);
WriteDetail($"Spotify validation failed ({ex.GetType().Name})");
return false;
}
}
@@ -1,128 +0,0 @@
using Microsoft.Extensions.Options;
using allstarr.Models.Settings;
using allstarr.Services.Validation;
namespace allstarr.Services.Qobuz;
/// <summary>
/// Validates Qobuz credentials at startup
/// </summary>
public class QobuzStartupValidator : BaseStartupValidator
{
private readonly IOptions<QobuzSettings> _qobuzSettings;
public override string ServiceName => "Qobuz";
public QobuzStartupValidator(IOptions<QobuzSettings> qobuzSettings, HttpClient httpClient)
: base(httpClient)
{
_qobuzSettings = qobuzSettings;
}
public override async Task<ValidationResult> ValidateAsync(CancellationToken cancellationToken)
{
var userAuthToken = _qobuzSettings.Value.UserAuthToken;
var userId = _qobuzSettings.Value.UserId;
var quality = _qobuzSettings.Value.Quality;
if (string.IsNullOrWhiteSpace(userAuthToken))
{
WriteStatus("Qobuz UserAuthToken", "NOT CONFIGURED", ConsoleColor.Red);
WriteDetail("Set the Qobuz__UserAuthToken environment variable");
return ValidationResult.NotConfigured("Qobuz UserAuthToken not configured");
}
if (string.IsNullOrWhiteSpace(userId))
{
WriteStatus("Qobuz UserId", "NOT CONFIGURED", ConsoleColor.Red);
WriteDetail("Set the Qobuz__UserId environment variable");
return ValidationResult.NotConfigured("Qobuz UserId not configured");
}
WriteStatus("Qobuz UserAuthToken", MaskSecret(userAuthToken), ConsoleColor.Cyan);
WriteStatus("Qobuz UserId", userId, ConsoleColor.Cyan);
WriteStatus("Qobuz Quality", quality ?? "auto (highest available)", ConsoleColor.Cyan);
// Validate token by calling Qobuz API
await ValidateQobuzTokenAsync(userAuthToken, userId, cancellationToken);
return ValidationResult.Success("Qobuz validation completed");
}
private async Task ValidateQobuzTokenAsync(string userAuthToken, string userId, CancellationToken cancellationToken)
{
const string fieldName = "Qobuz credentials";
try
{
// First, get the app ID from bundle service (simple check)
var bundleUrl = "https://play.qobuz.com/login";
var bundleResponse = await _httpClient.GetAsync(bundleUrl, cancellationToken);
if (!bundleResponse.IsSuccessStatusCode)
{
WriteStatus(fieldName, "UNABLE TO VERIFY", ConsoleColor.Yellow);
WriteDetail("Could not fetch Qobuz app configuration");
return;
}
// Try to validate with a simple API call
// We'll use the user favorites endpoint which requires authentication
var appId = "798273057"; // Fallback app ID
var apiUrl = $"https://www.qobuz.com/api.json/0.2/favorite/getUserFavorites?user_id={userId}&app_id={appId}";
using var request = new HttpRequestMessage(HttpMethod.Get, apiUrl);
request.Headers.Add("X-App-Id", appId);
request.Headers.Add("X-User-Auth-Token", userAuthToken);
request.Headers.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:83.0) Gecko/20100101 Firefox/83.0");
var response = await _httpClient.SendAsync(request, cancellationToken);
if (!response.IsSuccessStatusCode)
{
// 401 means invalid token, other errors might be network issues
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{
WriteStatus(fieldName, "INVALID", ConsoleColor.Red);
WriteDetail("Token is expired or invalid");
}
else
{
WriteStatus(fieldName, $"HTTP {(int)response.StatusCode}", ConsoleColor.Yellow);
WriteDetail("Unable to verify credentials");
}
return;
}
var json = await response.Content.ReadAsStringAsync(cancellationToken);
// If we got a successful response, credentials are valid
if (!string.IsNullOrEmpty(json) && !json.Contains("\"error\""))
{
WriteStatus(fieldName, "VALID", ConsoleColor.Green);
WriteDetail($"User ID: {userId}");
}
else
{
WriteStatus(fieldName, "INVALID", ConsoleColor.Red);
WriteDetail("Unexpected response from Qobuz");
}
}
catch (TaskCanceledException)
{
WriteStatus(fieldName, "TIMEOUT", ConsoleColor.Yellow);
WriteDetail("Could not reach Qobuz within 10 seconds");
}
catch (HttpRequestException)
{
WriteStatus(fieldName, "UNREACHABLE", ConsoleColor.Yellow);
WriteDetail("The Qobuz service could not be reached");
}
catch (Exception ex)
{
WriteStatus(fieldName, "ERROR", ConsoleColor.Red);
WriteDetail($"Qobuz validation failed ({ex.GetType().Name})");
}
}
}
@@ -1,6 +1,3 @@
using System.Text.Json;
using Microsoft.Extensions.Options;
using allstarr.Models.Settings;
using allstarr.Services.Validation;
using allstarr.Services.Common;
@@ -11,7 +8,6 @@ namespace allstarr.Services.SquidWTF;
/// </summary>
public class SquidWTFStartupValidator : BaseStartupValidator
{
private readonly SquidWTFSettings _settings;
private readonly List<string> _apiUrls;
private readonly List<string> _streamingUrls;
private readonly RoundRobinFallbackHelper _apiFallbackHelper;
@@ -21,7 +17,6 @@ public class SquidWTFStartupValidator : BaseStartupValidator
public override string ServiceName => "SquidWTF";
public SquidWTFStartupValidator(
IOptions<SquidWTFSettings> settings,
HttpClient httpClient,
List<string> apiUrls,
List<string> streamingUrls,
@@ -29,7 +24,6 @@ public class SquidWTFStartupValidator : BaseStartupValidator
ILogger<SquidWTFStartupValidator> logger)
: base(httpClient)
{
_settings = settings.Value;
_apiUrls = apiUrls;
_streamingUrls = streamingUrls;
_apiFallbackHelper = new RoundRobinFallbackHelper(_apiUrls, logger, "SquidWTF API");
@@ -40,41 +34,8 @@ public class SquidWTFStartupValidator : BaseStartupValidator
public override async Task<ValidationResult> ValidateAsync(CancellationToken cancellationToken)
{
var quality = _settings.Quality?.ToUpperInvariant() switch
{
"FLAC" => "LOSSLESS",
"HI_RES" => "HI_RES_LOSSLESS",
"LOSSLESS" => "LOSSLESS",
"HIGH" => "HIGH",
"LOW" => "LOW",
_ => "LOSSLESS (default)"
};
WriteStatus("SquidWTF Quality", quality, ConsoleColor.Cyan);
WriteStatus("SquidWTF API Endpoints", _apiUrls.Count.ToString(), ConsoleColor.Cyan);
WriteStatus("SquidWTF Streaming Endpoints", $"{_streamingUrls.Count} (optional)", ConsoleColor.Cyan);
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", "SKIPPED", ConsoleColor.Yellow);
WriteDetail("No streaming endpoints were discovered; SquidWTF is treated as metadata-only");
}
else
{
await BenchmarkEndpointPoolAsync("streaming", _streamingUrls, _streamingFallbackHelper, cancellationToken);
}
await BenchmarkEndpointPoolAsync(_apiUrls, _apiFallbackHelper, cancellationToken);
await BenchmarkEndpointPoolAsync(_streamingUrls, _streamingFallbackHelper, cancellationToken);
if (_apiUrls.Count == 0)
{
@@ -84,21 +45,12 @@ public class SquidWTFStartupValidator : BaseStartupValidator
ConsoleColor.Yellow);
}
// Validate API endpoints and search functionality.
var apiResult = _apiUrls.Count == 0
? ValidationResult.Failure("-1", "No SquidWTF API endpoints are currently available", ConsoleColor.Yellow)
: await _apiFallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
var apiResult = await _apiFallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
{
var response = await _httpClient.GetAsync(baseUrl, cancellationToken);
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
@@ -107,37 +59,35 @@ public class SquidWTFStartupValidator : BaseStartupValidator
}
}, ValidationResult.Failure("-1", "All SquidWTF API endpoints failed"));
if (_apiUrls.Count > 0 && !apiResult.IsValid)
if (!apiResult.IsValid)
{
return apiResult;
}
// Validate streaming endpoints independently to avoid API-only endpoints for streaming.
var streamingResult = _streamingUrls.Count == 0
? ValidationResult.Failure("-2", "No SquidWTF streaming endpoints are currently available", ConsoleColor.Yellow)
: await _streamingFallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
if (_streamingUrls.Count > 0)
{
var streamingResult = await _streamingFallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
{
var response = await _httpClient.GetAsync(baseUrl, cancellationToken);
if (response.IsSuccessStatusCode)
{
WriteStatus("SquidWTF Streaming", $"REACHABLE ({baseUrl})", ConsoleColor.Green);
return ValidationResult.Success("SquidWTF streaming endpoint validation completed");
}
throw new HttpRequestException($"HTTP {(int)response.StatusCode}");
}, ValidationResult.Failure("-2", "All SquidWTF streaming endpoints failed"));
if (_streamingUrls.Count > 0 && !streamingResult.IsValid)
{
return streamingResult;
if (!streamingResult.IsValid)
{
return streamingResult;
}
}
return ValidationResult.Success("SquidWTF API validation completed");
}
private async Task BenchmarkEndpointPoolAsync(
string poolName,
List<string> endpoints,
RoundRobinFallbackHelper fallbackHelper,
CancellationToken cancellationToken)
@@ -147,7 +97,6 @@ public class SquidWTFStartupValidator : BaseStartupValidator
return;
}
WriteStatus($"Benchmarking {poolName} endpoints", $"{endpoints.Count} endpoints", ConsoleColor.Cyan);
var orderedEndpoints = await _benchmarkService.BenchmarkEndpointsAsync(
endpoints,
@@ -172,97 +121,9 @@ public class SquidWTFStartupValidator : BaseStartupValidator
if (orderedEndpoints.Count == 0)
{
WriteDetail($"No healthy {poolName} endpoints detected during benchmark");
return;
}
fallbackHelper.SetEndpointOrder(orderedEndpoints);
var topEndpoints = orderedEndpoints.Take(5).ToList();
WriteDetail($"Fastest {poolName} endpoint: {topEndpoints.First()}");
if (topEndpoints.Count > 1)
{
WriteDetail($"Top {topEndpoints.Count} {poolName} endpoints by average latency:");
for (int i = 0; i < topEndpoints.Count; i++)
{
var endpoint = topEndpoints[i];
var metrics = _benchmarkService.GetMetrics(endpoint);
if (metrics != null)
{
WriteDetail($" {i + 1}. {endpoint} - {metrics.AverageResponseMs}ms avg ({metrics.SuccessRate:P0} success)");
}
else
{
WriteDetail($" {i + 1}. {endpoint}");
}
}
}
}
private async Task ValidateSearchFunctionality(string baseUrl, CancellationToken cancellationToken)
{
try
{
// Test search with "22" by Taylor Swift
var searchUrl = $"{baseUrl}/search/?s=22%20Taylor%20Swift";
var searchResponse = await _httpClient.GetAsync(searchUrl, cancellationToken);
if (searchResponse.IsSuccessStatusCode)
{
var json = await searchResponse.Content.ReadAsStringAsync(cancellationToken);
var doc = JsonDocument.Parse(json);
if (doc.RootElement.TryGetProperty("data", out var data) &&
data.TryGetProperty("items", out var items))
{
var itemCount = items.GetArrayLength();
WriteStatus("Search Functionality", "WORKING", ConsoleColor.Green);
WriteDetail($"Test search for '22' by Taylor Swift returned {itemCount} results");
// Check if we found the actual song
bool foundTaylorSwift22 = false;
foreach (var item in items.EnumerateArray())
{
if (item.TryGetProperty("title", out var title) &&
item.TryGetProperty("artists", out var artists) &&
artists.GetArrayLength() > 0)
{
var titleStr = title.GetString() ?? "";
var artistName = artists[0].TryGetProperty("name", out var name)
? name.GetString() ?? ""
: "";
if (titleStr.Contains("22", StringComparison.OrdinalIgnoreCase) &&
artistName.Contains("Taylor Swift", StringComparison.OrdinalIgnoreCase))
{
foundTaylorSwift22 = true;
var trackId = item.TryGetProperty("id", out var id) ? id.GetInt64() : 0;
WriteDetail($"✓ Found: '{titleStr}' by {artistName} (ID: {trackId})");
break;
}
}
}
if (!foundTaylorSwift22)
{
WriteDetail("⚠ Could not find exact match for '22' by Taylor Swift in results");
}
}
else
{
WriteStatus("Search Functionality", "UNEXPECTED RESPONSE", ConsoleColor.Yellow);
}
}
else
{
WriteStatus("Search Functionality", $"HTTP {(int)searchResponse.StatusCode}", ConsoleColor.Yellow);
}
}
catch (Exception ex)
{
WriteStatus("Search Functionality", "ERROR", ConsoleColor.Yellow);
WriteDetail($"Search validation failed ({ex.GetType().Name})");
}
}
}
@@ -1,87 +1,10 @@
namespace allstarr.Services.Validation;
/// <summary>
/// Base class for startup validators providing common functionality
/// </summary>
public abstract class BaseStartupValidator : IStartupValidator
public abstract class BaseStartupValidator(HttpClient httpClient) : IStartupValidator
{
protected readonly HttpClient _httpClient;
protected readonly HttpClient _httpClient = httpClient;
protected BaseStartupValidator(HttpClient httpClient)
{
_httpClient = httpClient;
}
/// <summary>
/// Gets the name of the service being validated
/// </summary>
public abstract string ServiceName { get; }
/// <summary>
/// Validates the service configuration and connectivity
/// </summary>
public abstract Task<ValidationResult> ValidateAsync(CancellationToken cancellationToken);
/// <summary>
/// Retained for validator compatibility. The orchestrator owns structured output.
/// </summary>
protected static void WriteStatus(string label, string value, ConsoleColor valueColor)
{
_ = label;
_ = value;
_ = valueColor;
}
/// <summary>
/// Retained for validator compatibility. Details are returned, not printed directly.
/// </summary>
protected static void WriteDetail(string message)
{
_ = message;
}
/// <summary>
/// Reports configuration without revealing any secret characters.
/// </summary>
protected static string MaskSecret(string secret)
{
if (string.IsNullOrEmpty(secret))
{
return "(empty)";
}
return "<configured>";
}
/// <summary>
/// Handles common HTTP exceptions and returns appropriate validation result
/// </summary>
protected static ValidationResult HandleException(Exception ex, string fieldName)
{
return ex switch
{
TaskCanceledException => ValidationResult.Failure("TIMEOUT",
"Could not reach service within timeout period", ConsoleColor.Yellow),
HttpRequestException => ValidationResult.Failure("UNREACHABLE",
"The service could not be reached", ConsoleColor.Yellow),
_ => ValidationResult.Failure(
"ERROR",
$"Validation failed ({ex.GetType().Name})",
ConsoleColor.Red)
};
}
/// <summary>
/// Writes validation result to console
/// </summary>
protected void WriteValidationResult(string fieldName, ValidationResult result)
{
WriteStatus(fieldName, result.Status, result.StatusColor);
if (!string.IsNullOrEmpty(result.Details))
{
WriteDetail(result.Details);
}
}
}
@@ -24,13 +24,9 @@ public class SubsonicStartupValidator : BaseStartupValidator
if (string.IsNullOrWhiteSpace(subsonicUrl))
{
WriteStatus("Subsonic URL", "NOT CONFIGURED", ConsoleColor.Red);
WriteDetail("Set the Subsonic__Url environment variable");
return ValidationResult.NotConfigured("Subsonic URL not configured");
}
WriteStatus("Subsonic URL", subsonicUrl, ConsoleColor.Cyan);
try
{
var pingUrl = $"{subsonicUrl.TrimEnd('/')}/rest/ping.view?v=1.16.1&c=allstarr&f=json";
@@ -40,40 +36,22 @@ public class SubsonicStartupValidator : BaseStartupValidator
{
var content = await response.Content.ReadAsStringAsync(cancellationToken);
if (content.Contains("\"status\":\"ok\"") || content.Contains("status=\"ok\""))
{
WriteStatus("Subsonic server", "OK", ConsoleColor.Green);
return ValidationResult.Success("Subsonic server is accessible");
}
else if (content.Contains("\"status\":\"failed\"") || content.Contains("status=\"failed\""))
{
WriteStatus("Subsonic server", "REACHABLE", ConsoleColor.Yellow);
WriteDetail("Authentication may be required for some operations");
return ValidationResult.Success("Subsonic server is reachable");
}
else
{
WriteStatus("Subsonic server", "REACHABLE", ConsoleColor.Yellow);
WriteDetail("Unexpected response format");
return ValidationResult.Success("Subsonic server is reachable");
}
return content.Contains("\"status\":\"ok\"") || content.Contains("status=\"ok\"")
? ValidationResult.Success("Subsonic server is accessible")
: ValidationResult.Success("Subsonic server is reachable");
}
else
{
WriteStatus("Subsonic server", $"HTTP {(int)response.StatusCode}", ConsoleColor.Red);
return ValidationResult.Failure($"HTTP {(int)response.StatusCode}",
"Subsonic server returned an error", ConsoleColor.Red);
}
}
catch (TaskCanceledException)
{
WriteStatus("Subsonic server", "TIMEOUT", ConsoleColor.Red);
WriteDetail("Could not reach server within 10 seconds");
return ValidationResult.Failure("TIMEOUT", "Could not reach server within timeout period", ConsoleColor.Red);
}
catch (HttpRequestException)
{
WriteStatus("Subsonic server", "UNREACHABLE", ConsoleColor.Red);
return ValidationResult.Failure(
"UNREACHABLE",
"The Subsonic server could not be reached",
@@ -81,7 +59,6 @@ public class SubsonicStartupValidator : BaseStartupValidator
}
catch (Exception ex)
{
WriteStatus("Subsonic server", "ERROR", ConsoleColor.Red);
return ValidationResult.Failure(
"ERROR",
$"Subsonic validation failed ({ex.GetType().Name})",