refactor: implement unified startup validator architecture with IStartupValidator interface

This commit is contained in:
V1ck3s
2026-01-08 19:19:45 +01:00
parent fe9cb9b758
commit cb37c7f69a
8 changed files with 352 additions and 79 deletions

View File

@@ -3,6 +3,7 @@ using octo_fiesta.Services;
using octo_fiesta.Services.Deezer; using octo_fiesta.Services.Deezer;
using octo_fiesta.Services.Qobuz; using octo_fiesta.Services.Qobuz;
using octo_fiesta.Services.Local; using octo_fiesta.Services.Local;
using octo_fiesta.Services.Validation;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
@@ -43,8 +44,13 @@ else
builder.Services.AddSingleton<IDownloadService, DeezerDownloadService>(); builder.Services.AddSingleton<IDownloadService, DeezerDownloadService>();
} }
// Startup validation - runs at application startup to validate configuration // Startup validation - register validators
builder.Services.AddHostedService<StartupValidationService>(); builder.Services.AddSingleton<IStartupValidator, SubsonicStartupValidator>();
builder.Services.AddSingleton<IStartupValidator, DeezerStartupValidator>();
builder.Services.AddSingleton<IStartupValidator, QobuzStartupValidator>();
// Register orchestrator as hosted service
builder.Services.AddHostedService<StartupValidationOrchestrator>();
builder.Services.AddCors(options => builder.Services.AddCors(options =>
{ {

View File

@@ -2,24 +2,26 @@ using System.Text;
using System.Text.Json; using System.Text.Json;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using octo_fiesta.Models; using octo_fiesta.Models;
using octo_fiesta.Services.Validation;
namespace octo_fiesta.Services.Deezer; namespace octo_fiesta.Services.Deezer;
/// <summary> /// <summary>
/// Validates Deezer ARL credentials at startup /// Validates Deezer ARL credentials at startup
/// </summary> /// </summary>
public class DeezerStartupValidator public class DeezerStartupValidator : BaseStartupValidator
{ {
private readonly DeezerSettings _settings; private readonly DeezerSettings _settings;
private readonly HttpClient _httpClient;
public override string ServiceName => "Deezer";
public DeezerStartupValidator(IOptions<DeezerSettings> settings, HttpClient httpClient) public DeezerStartupValidator(IOptions<DeezerSettings> settings, HttpClient httpClient)
: base(httpClient)
{ {
_settings = settings.Value; _settings = settings.Value;
_httpClient = httpClient;
} }
public async Task ValidateAsync(CancellationToken cancellationToken) public override async Task<ValidationResult> ValidateAsync(CancellationToken cancellationToken)
{ {
var arl = _settings.Arl; var arl = _settings.Arl;
var arlFallback = _settings.ArlFallback; var arlFallback = _settings.ArlFallback;
@@ -31,7 +33,7 @@ public class DeezerStartupValidator
{ {
WriteStatus("Deezer ARL", "NOT CONFIGURED", ConsoleColor.Red); WriteStatus("Deezer ARL", "NOT CONFIGURED", ConsoleColor.Red);
WriteDetail("Set the Deezer__Arl environment variable"); WriteDetail("Set the Deezer__Arl environment variable");
return; return ValidationResult.NotConfigured("Deezer ARL not configured");
} }
WriteStatus("Deezer ARL", MaskSecret(arl), ConsoleColor.Cyan); WriteStatus("Deezer ARL", MaskSecret(arl), ConsoleColor.Cyan);
@@ -50,6 +52,8 @@ public class DeezerStartupValidator
{ {
await ValidateArlTokenAsync(arlFallback, "fallback", cancellationToken); await ValidateArlTokenAsync(arlFallback, "fallback", cancellationToken);
} }
return ValidationResult.Success("Deezer validation completed");
} }
private async Task ValidateArlTokenAsync(string arl, string label, CancellationToken cancellationToken) private async Task ValidateArlTokenAsync(string arl, string label, CancellationToken cancellationToken)
@@ -150,37 +154,4 @@ public class DeezerStartupValidator
return "Free"; 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));
}
} }

View File

@@ -1,23 +1,25 @@
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using octo_fiesta.Models; using octo_fiesta.Models;
using octo_fiesta.Services.Validation;
namespace octo_fiesta.Services.Qobuz; namespace octo_fiesta.Services.Qobuz;
/// <summary> /// <summary>
/// Validates Qobuz credentials at startup /// Validates Qobuz credentials at startup
/// </summary> /// </summary>
public class QobuzStartupValidator public class QobuzStartupValidator : BaseStartupValidator
{ {
private readonly IOptions<QobuzSettings> _qobuzSettings; private readonly IOptions<QobuzSettings> _qobuzSettings;
private readonly HttpClient _httpClient;
public override string ServiceName => "Qobuz";
public QobuzStartupValidator(IOptions<QobuzSettings> qobuzSettings, HttpClient httpClient) public QobuzStartupValidator(IOptions<QobuzSettings> qobuzSettings, HttpClient httpClient)
: base(httpClient)
{ {
_qobuzSettings = qobuzSettings; _qobuzSettings = qobuzSettings;
_httpClient = httpClient;
} }
public async Task ValidateAsync(CancellationToken cancellationToken) public override async Task<ValidationResult> ValidateAsync(CancellationToken cancellationToken)
{ {
var userAuthToken = _qobuzSettings.Value.UserAuthToken; var userAuthToken = _qobuzSettings.Value.UserAuthToken;
var userId = _qobuzSettings.Value.UserId; var userId = _qobuzSettings.Value.UserId;
@@ -29,14 +31,14 @@ public class QobuzStartupValidator
{ {
WriteStatus("Qobuz UserAuthToken", "NOT CONFIGURED", ConsoleColor.Red); WriteStatus("Qobuz UserAuthToken", "NOT CONFIGURED", ConsoleColor.Red);
WriteDetail("Set the Qobuz__UserAuthToken environment variable"); WriteDetail("Set the Qobuz__UserAuthToken environment variable");
return; return ValidationResult.NotConfigured("Qobuz UserAuthToken not configured");
} }
if (string.IsNullOrWhiteSpace(userId)) if (string.IsNullOrWhiteSpace(userId))
{ {
WriteStatus("Qobuz UserId", "NOT CONFIGURED", ConsoleColor.Red); WriteStatus("Qobuz UserId", "NOT CONFIGURED", ConsoleColor.Red);
WriteDetail("Set the Qobuz__UserId environment variable"); WriteDetail("Set the Qobuz__UserId environment variable");
return; return ValidationResult.NotConfigured("Qobuz UserId not configured");
} }
WriteStatus("Qobuz UserAuthToken", MaskSecret(userAuthToken), ConsoleColor.Cyan); WriteStatus("Qobuz UserAuthToken", MaskSecret(userAuthToken), ConsoleColor.Cyan);
@@ -45,6 +47,8 @@ public class QobuzStartupValidator
// Validate token by calling Qobuz API // Validate token by calling Qobuz API
await ValidateQobuzTokenAsync(userAuthToken, userId, cancellationToken); await ValidateQobuzTokenAsync(userAuthToken, userId, cancellationToken);
return ValidationResult.Success("Qobuz validation completed");
} }
private async Task ValidateQobuzTokenAsync(string userAuthToken, string userId, CancellationToken cancellationToken) private async Task ValidateQobuzTokenAsync(string userAuthToken, string userId, CancellationToken cancellationToken)
@@ -122,37 +126,4 @@ public class QobuzStartupValidator
WriteDetail(ex.Message); 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));
}
} }

View File

@@ -0,0 +1,95 @@
namespace octo_fiesta.Services.Validation;
/// <summary>
/// Base class for startup validators providing common functionality
/// </summary>
public abstract class BaseStartupValidator : IStartupValidator
{
protected readonly 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>
/// Writes a status line to the console with colored output
/// </summary>
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;
}
/// <summary>
/// Writes a detail line to the console in dark gray
/// </summary>
protected static void WriteDetail(string message)
{
var originalColor = Console.ForegroundColor;
Console.ForegroundColor = ConsoleColor.DarkGray;
Console.WriteLine($" -> {message}");
Console.ForegroundColor = originalColor;
}
/// <summary>
/// Masks a secret string for display, showing only the first few characters
/// </summary>
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));
}
/// <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 httpEx => ValidationResult.Failure("UNREACHABLE",
httpEx.Message, ConsoleColor.Yellow),
_ => ValidationResult.Failure("ERROR", ex.Message, 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);
}
}
}

View File

@@ -0,0 +1,19 @@
namespace octo_fiesta.Services.Validation;
/// <summary>
/// Interface for service startup validators
/// </summary>
public interface IStartupValidator
{
/// <summary>
/// Gets the name of the service being validated
/// </summary>
string ServiceName { get; }
/// <summary>
/// Validates the service configuration and connectivity
/// </summary>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Validation result containing status and details</returns>
Task<ValidationResult> ValidateAsync(CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,55 @@
using Microsoft.Extensions.Options;
using octo_fiesta.Models;
namespace octo_fiesta.Services.Validation;
/// <summary>
/// Orchestrates startup validation for all configured services.
/// This replaces the old StartupValidationService with a more extensible architecture.
/// </summary>
public class StartupValidationOrchestrator : IHostedService
{
private readonly IEnumerable<IStartupValidator> _validators;
private readonly IOptions<SubsonicSettings> _subsonicSettings;
public StartupValidationOrchestrator(
IEnumerable<IStartupValidator> validators,
IOptions<SubsonicSettings> 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;
}
}

View File

@@ -0,0 +1,87 @@
using Microsoft.Extensions.Options;
using octo_fiesta.Models;
namespace octo_fiesta.Services.Validation;
/// <summary>
/// Validates Subsonic server connectivity at startup
/// </summary>
public class SubsonicStartupValidator : BaseStartupValidator
{
private readonly IOptions<SubsonicSettings> _subsonicSettings;
public override string ServiceName => "Subsonic";
public SubsonicStartupValidator(IOptions<SubsonicSettings> subsonicSettings, HttpClient httpClient)
: base(httpClient)
{
_subsonicSettings = subsonicSettings;
}
public override async Task<ValidationResult> 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);
}
}
}

View File

@@ -0,0 +1,69 @@
namespace octo_fiesta.Services.Validation;
/// <summary>
/// Result of a startup validation operation
/// </summary>
public class ValidationResult
{
/// <summary>
/// Indicates whether the validation was successful
/// </summary>
public bool IsValid { get; set; }
/// <summary>
/// Short status message (e.g., "VALID", "INVALID", "TIMEOUT", "NOT CONFIGURED")
/// </summary>
public string Status { get; set; } = string.Empty;
/// <summary>
/// Detailed information about the validation result
/// </summary>
public string? Details { get; set; }
/// <summary>
/// Color to use when displaying the status in console
/// </summary>
public ConsoleColor StatusColor { get; set; } = ConsoleColor.White;
/// <summary>
/// Additional metadata about the validation
/// </summary>
public Dictionary<string, object> Metadata { get; set; } = new();
/// <summary>
/// Creates a successful validation result
/// </summary>
public static ValidationResult Success(string details, Dictionary<string, object>? metadata = null)
{
return new ValidationResult
{
IsValid = true,
Status = "VALID",
StatusColor = ConsoleColor.Green,
Details = details,
Metadata = metadata ?? new()
};
}
/// <summary>
/// Creates a failed validation result
/// </summary>
public static ValidationResult Failure(string status, string details, ConsoleColor color = ConsoleColor.Red)
{
return new ValidationResult
{
IsValid = false,
Status = status,
StatusColor = color,
Details = details
};
}
/// <summary>
/// Creates a not configured validation result
/// </summary>
public static ValidationResult NotConfigured(string details)
{
return Failure("NOT CONFIGURED", details, ConsoleColor.Red);
}
}