mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-10-08 14:05:02 -04:00
refactor(startup): remove no-op provider probes
This commit is contained in:
8 files changed
+25
-764
No files matched your search
@@ -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})",
|
||||
|
||||
Reference in new issue
Block a user