From cb37c7f69a44149ccc12b21e8b99b67e4ea4ac59 Mon Sep 17 00:00:00 2001 From: V1ck3s Date: Thu, 8 Jan 2026 19:19:45 +0100 Subject: [PATCH] refactor: implement unified startup validator architecture with IStartupValidator interface --- octo-fiesta/Program.cs | 10 +- .../Services/Deezer/DeezerStartupValidator.cs | 47 ++------- .../Services/Qobuz/QobuzStartupValidator.cs | 49 ++-------- .../Validation/BaseStartupValidator.cs | 95 +++++++++++++++++++ .../Services/Validation/IStartupValidator.cs | 19 ++++ .../StartupValidationOrchestrator.cs | 55 +++++++++++ .../Validation/SubsonicStartupValidator.cs | 87 +++++++++++++++++ .../Services/Validation/ValidationResult.cs | 69 ++++++++++++++ 8 files changed, 352 insertions(+), 79 deletions(-) create mode 100644 octo-fiesta/Services/Validation/BaseStartupValidator.cs create mode 100644 octo-fiesta/Services/Validation/IStartupValidator.cs create mode 100644 octo-fiesta/Services/Validation/StartupValidationOrchestrator.cs create mode 100644 octo-fiesta/Services/Validation/SubsonicStartupValidator.cs create mode 100644 octo-fiesta/Services/Validation/ValidationResult.cs diff --git a/octo-fiesta/Program.cs b/octo-fiesta/Program.cs index 5eeb057..a6fc2d9 100644 --- a/octo-fiesta/Program.cs +++ b/octo-fiesta/Program.cs @@ -3,6 +3,7 @@ using octo_fiesta.Services; using octo_fiesta.Services.Deezer; using octo_fiesta.Services.Qobuz; using octo_fiesta.Services.Local; +using octo_fiesta.Services.Validation; var builder = WebApplication.CreateBuilder(args); @@ -43,8 +44,13 @@ else builder.Services.AddSingleton(); } -// Startup validation - runs at application startup to validate configuration -builder.Services.AddHostedService(); +// Startup validation - register validators +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +// Register orchestrator as hosted service +builder.Services.AddHostedService(); builder.Services.AddCors(options => { diff --git a/octo-fiesta/Services/Deezer/DeezerStartupValidator.cs b/octo-fiesta/Services/Deezer/DeezerStartupValidator.cs index c99159b..38233f6 100644 --- a/octo-fiesta/Services/Deezer/DeezerStartupValidator.cs +++ b/octo-fiesta/Services/Deezer/DeezerStartupValidator.cs @@ -2,24 +2,26 @@ using System.Text; using System.Text.Json; using Microsoft.Extensions.Options; using octo_fiesta.Models; +using octo_fiesta.Services.Validation; namespace octo_fiesta.Services.Deezer; /// /// Validates Deezer ARL credentials at startup /// -public class DeezerStartupValidator +public class DeezerStartupValidator : BaseStartupValidator { private readonly DeezerSettings _settings; - private readonly HttpClient _httpClient; + + public override string ServiceName => "Deezer"; public DeezerStartupValidator(IOptions settings, HttpClient httpClient) + : base(httpClient) { _settings = settings.Value; - _httpClient = httpClient; } - public async Task ValidateAsync(CancellationToken cancellationToken) + public override async Task ValidateAsync(CancellationToken cancellationToken) { var arl = _settings.Arl; var arlFallback = _settings.ArlFallback; @@ -31,7 +33,7 @@ public class DeezerStartupValidator { WriteStatus("Deezer ARL", "NOT CONFIGURED", ConsoleColor.Red); WriteDetail("Set the Deezer__Arl environment variable"); - return; + return ValidationResult.NotConfigured("Deezer ARL not configured"); } WriteStatus("Deezer ARL", MaskSecret(arl), ConsoleColor.Cyan); @@ -50,6 +52,8 @@ public class DeezerStartupValidator { await ValidateArlTokenAsync(arlFallback, "fallback", cancellationToken); } + + return ValidationResult.Success("Deezer validation completed"); } private async Task ValidateArlTokenAsync(string arl, string label, CancellationToken cancellationToken) @@ -150,37 +154,4 @@ public class DeezerStartupValidator return "Free"; } - - private static void WriteStatus(string label, string value, ConsoleColor valueColor) - { - Console.Write($" {label}: "); - var originalColor = Console.ForegroundColor; - Console.ForegroundColor = valueColor; - Console.WriteLine(value); - Console.ForegroundColor = originalColor; - } - - private static void WriteDetail(string message) - { - var originalColor = Console.ForegroundColor; - Console.ForegroundColor = ConsoleColor.DarkGray; - Console.WriteLine($" -> {message}"); - Console.ForegroundColor = originalColor; - } - - private static string MaskSecret(string secret) - { - if (string.IsNullOrEmpty(secret)) - { - return "(empty)"; - } - - const int visibleChars = 4; - if (secret.Length <= visibleChars) - { - return new string('*', secret.Length); - } - - return secret[..visibleChars] + new string('*', Math.Min(secret.Length - visibleChars, 8)); - } } diff --git a/octo-fiesta/Services/Qobuz/QobuzStartupValidator.cs b/octo-fiesta/Services/Qobuz/QobuzStartupValidator.cs index ba1eb19..dbaab08 100644 --- a/octo-fiesta/Services/Qobuz/QobuzStartupValidator.cs +++ b/octo-fiesta/Services/Qobuz/QobuzStartupValidator.cs @@ -1,23 +1,25 @@ using Microsoft.Extensions.Options; using octo_fiesta.Models; +using octo_fiesta.Services.Validation; namespace octo_fiesta.Services.Qobuz; /// /// Validates Qobuz credentials at startup /// -public class QobuzStartupValidator +public class QobuzStartupValidator : BaseStartupValidator { private readonly IOptions _qobuzSettings; - private readonly HttpClient _httpClient; + + public override string ServiceName => "Qobuz"; public QobuzStartupValidator(IOptions qobuzSettings, HttpClient httpClient) + : base(httpClient) { _qobuzSettings = qobuzSettings; - _httpClient = httpClient; } - public async Task ValidateAsync(CancellationToken cancellationToken) + public override async Task ValidateAsync(CancellationToken cancellationToken) { var userAuthToken = _qobuzSettings.Value.UserAuthToken; var userId = _qobuzSettings.Value.UserId; @@ -29,14 +31,14 @@ public class QobuzStartupValidator { WriteStatus("Qobuz UserAuthToken", "NOT CONFIGURED", ConsoleColor.Red); WriteDetail("Set the Qobuz__UserAuthToken environment variable"); - return; + 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; + return ValidationResult.NotConfigured("Qobuz UserId not configured"); } WriteStatus("Qobuz UserAuthToken", MaskSecret(userAuthToken), ConsoleColor.Cyan); @@ -45,6 +47,8 @@ public class QobuzStartupValidator // 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) @@ -122,37 +126,4 @@ public class QobuzStartupValidator WriteDetail(ex.Message); } } - - private static void WriteStatus(string label, string value, ConsoleColor valueColor) - { - Console.Write($" {label}: "); - var originalColor = Console.ForegroundColor; - Console.ForegroundColor = valueColor; - Console.WriteLine(value); - Console.ForegroundColor = originalColor; - } - - private static void WriteDetail(string message) - { - var originalColor = Console.ForegroundColor; - Console.ForegroundColor = ConsoleColor.DarkGray; - Console.WriteLine($" -> {message}"); - Console.ForegroundColor = originalColor; - } - - private static string MaskSecret(string secret) - { - if (string.IsNullOrEmpty(secret)) - { - return "(empty)"; - } - - const int visibleChars = 4; - if (secret.Length <= visibleChars) - { - return new string('*', secret.Length); - } - - return secret[..visibleChars] + new string('*', Math.Min(secret.Length - visibleChars, 8)); - } } diff --git a/octo-fiesta/Services/Validation/BaseStartupValidator.cs b/octo-fiesta/Services/Validation/BaseStartupValidator.cs new file mode 100644 index 0000000..6f6d0bf --- /dev/null +++ b/octo-fiesta/Services/Validation/BaseStartupValidator.cs @@ -0,0 +1,95 @@ +namespace octo_fiesta.Services.Validation; + +/// +/// Base class for startup validators providing common functionality +/// +public abstract class BaseStartupValidator : IStartupValidator +{ + protected readonly HttpClient _httpClient; + + protected BaseStartupValidator(HttpClient httpClient) + { + _httpClient = httpClient; + } + + /// + /// Gets the name of the service being validated + /// + public abstract string ServiceName { get; } + + /// + /// Validates the service configuration and connectivity + /// + public abstract Task ValidateAsync(CancellationToken cancellationToken); + + /// + /// Writes a status line to the console with colored output + /// + protected static void WriteStatus(string label, string value, ConsoleColor valueColor) + { + Console.Write($" {label}: "); + var originalColor = Console.ForegroundColor; + Console.ForegroundColor = valueColor; + Console.WriteLine(value); + Console.ForegroundColor = originalColor; + } + + /// + /// Writes a detail line to the console in dark gray + /// + protected static void WriteDetail(string message) + { + var originalColor = Console.ForegroundColor; + Console.ForegroundColor = ConsoleColor.DarkGray; + Console.WriteLine($" -> {message}"); + Console.ForegroundColor = originalColor; + } + + /// + /// Masks a secret string for display, showing only the first few characters + /// + protected static string MaskSecret(string secret) + { + if (string.IsNullOrEmpty(secret)) + { + return "(empty)"; + } + + const int visibleChars = 4; + if (secret.Length <= visibleChars) + { + return new string('*', secret.Length); + } + + return secret[..visibleChars] + new string('*', Math.Min(secret.Length - visibleChars, 8)); + } + + /// + /// Handles common HTTP exceptions and returns appropriate validation result + /// + 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 httpEx => ValidationResult.Failure("UNREACHABLE", + httpEx.Message, ConsoleColor.Yellow), + + _ => ValidationResult.Failure("ERROR", ex.Message, ConsoleColor.Red) + }; + } + + /// + /// Writes validation result to console + /// + protected void WriteValidationResult(string fieldName, ValidationResult result) + { + WriteStatus(fieldName, result.Status, result.StatusColor); + if (!string.IsNullOrEmpty(result.Details)) + { + WriteDetail(result.Details); + } + } +} diff --git a/octo-fiesta/Services/Validation/IStartupValidator.cs b/octo-fiesta/Services/Validation/IStartupValidator.cs new file mode 100644 index 0000000..59e2fa6 --- /dev/null +++ b/octo-fiesta/Services/Validation/IStartupValidator.cs @@ -0,0 +1,19 @@ +namespace octo_fiesta.Services.Validation; + +/// +/// Interface for service startup validators +/// +public interface IStartupValidator +{ + /// + /// Gets the name of the service being validated + /// + string ServiceName { get; } + + /// + /// Validates the service configuration and connectivity + /// + /// Cancellation token + /// Validation result containing status and details + Task ValidateAsync(CancellationToken cancellationToken); +} diff --git a/octo-fiesta/Services/Validation/StartupValidationOrchestrator.cs b/octo-fiesta/Services/Validation/StartupValidationOrchestrator.cs new file mode 100644 index 0000000..0fb63a8 --- /dev/null +++ b/octo-fiesta/Services/Validation/StartupValidationOrchestrator.cs @@ -0,0 +1,55 @@ +using Microsoft.Extensions.Options; +using octo_fiesta.Models; + +namespace octo_fiesta.Services.Validation; + +/// +/// Orchestrates startup validation for all configured services. +/// This replaces the old StartupValidationService with a more extensible architecture. +/// +public class StartupValidationOrchestrator : IHostedService +{ + private readonly IEnumerable _validators; + private readonly IOptions _subsonicSettings; + + public StartupValidationOrchestrator( + IEnumerable validators, + IOptions subsonicSettings) + { + _validators = validators; + _subsonicSettings = subsonicSettings; + } + + public async Task StartAsync(CancellationToken cancellationToken) + { + Console.WriteLine(); + Console.WriteLine("========================================"); + Console.WriteLine(" octo-fiesta starting up... "); + Console.WriteLine("========================================"); + Console.WriteLine(); + + // Run all validators + foreach (var validator in _validators) + { + try + { + await validator.ValidateAsync(cancellationToken); + } + catch (Exception ex) + { + Console.WriteLine($"Error validating {validator.ServiceName}: {ex.Message}"); + } + } + + Console.WriteLine(); + Console.WriteLine("========================================"); + Console.WriteLine(" Startup validation complete "); + Console.WriteLine("========================================"); + Console.WriteLine(); + } + + public Task StopAsync(CancellationToken cancellationToken) + { + return Task.CompletedTask; + } +} diff --git a/octo-fiesta/Services/Validation/SubsonicStartupValidator.cs b/octo-fiesta/Services/Validation/SubsonicStartupValidator.cs new file mode 100644 index 0000000..ac16922 --- /dev/null +++ b/octo-fiesta/Services/Validation/SubsonicStartupValidator.cs @@ -0,0 +1,87 @@ +using Microsoft.Extensions.Options; +using octo_fiesta.Models; + +namespace octo_fiesta.Services.Validation; + +/// +/// Validates Subsonic server connectivity at startup +/// +public class SubsonicStartupValidator : BaseStartupValidator +{ + private readonly IOptions _subsonicSettings; + + public override string ServiceName => "Subsonic"; + + public SubsonicStartupValidator(IOptions subsonicSettings, HttpClient httpClient) + : base(httpClient) + { + _subsonicSettings = subsonicSettings; + } + + public override async Task ValidateAsync(CancellationToken cancellationToken) + { + var subsonicUrl = _subsonicSettings.Value.Url; + + 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=octo-fiesta&f=json"; + var response = await _httpClient.GetAsync(pingUrl, cancellationToken); + + if (response.IsSuccessStatusCode) + { + 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"); + } + } + 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 ex) + { + WriteStatus("Subsonic server", "UNREACHABLE", ConsoleColor.Red); + WriteDetail(ex.Message); + return ValidationResult.Failure("UNREACHABLE", ex.Message, ConsoleColor.Red); + } + catch (Exception ex) + { + WriteStatus("Subsonic server", "ERROR", ConsoleColor.Red); + WriteDetail(ex.Message); + return ValidationResult.Failure("ERROR", ex.Message, ConsoleColor.Red); + } + } +} diff --git a/octo-fiesta/Services/Validation/ValidationResult.cs b/octo-fiesta/Services/Validation/ValidationResult.cs new file mode 100644 index 0000000..2a3fc16 --- /dev/null +++ b/octo-fiesta/Services/Validation/ValidationResult.cs @@ -0,0 +1,69 @@ +namespace octo_fiesta.Services.Validation; + +/// +/// Result of a startup validation operation +/// +public class ValidationResult +{ + /// + /// Indicates whether the validation was successful + /// + public bool IsValid { get; set; } + + /// + /// Short status message (e.g., "VALID", "INVALID", "TIMEOUT", "NOT CONFIGURED") + /// + public string Status { get; set; } = string.Empty; + + /// + /// Detailed information about the validation result + /// + public string? Details { get; set; } + + /// + /// Color to use when displaying the status in console + /// + public ConsoleColor StatusColor { get; set; } = ConsoleColor.White; + + /// + /// Additional metadata about the validation + /// + public Dictionary Metadata { get; set; } = new(); + + /// + /// Creates a successful validation result + /// + public static ValidationResult Success(string details, Dictionary? metadata = null) + { + return new ValidationResult + { + IsValid = true, + Status = "VALID", + StatusColor = ConsoleColor.Green, + Details = details, + Metadata = metadata ?? new() + }; + } + + /// + /// Creates a failed validation result + /// + public static ValidationResult Failure(string status, string details, ConsoleColor color = ConsoleColor.Red) + { + return new ValidationResult + { + IsValid = false, + Status = status, + StatusColor = color, + Details = details + }; + } + + /// + /// Creates a not configured validation result + /// + public static ValidationResult NotConfigured(string details) + { + return Failure("NOT CONFIGURED", details, ConsoleColor.Red); + } +}