From 2a6191e9dbe5c63966bc0ddad5c5e6531338fdbc Mon Sep 17 00:00:00 2001 From: V1ck3s Date: Sun, 4 Jan 2026 17:27:40 +0100 Subject: [PATCH] feat: add startup validation for Subsonic and Deezer connectivity --- octo-fiesta/Program.cs | 3 + octo-fiesta/Services/DeezerDownloadService.cs | 2 - .../Services/StartupValidationService.cs | 278 ++++++++++++++++++ 3 files changed, 281 insertions(+), 2 deletions(-) create mode 100644 octo-fiesta/Services/StartupValidationService.cs diff --git a/octo-fiesta/Program.cs b/octo-fiesta/Program.cs index dea7e9a..c8e0722 100644 --- a/octo-fiesta/Program.cs +++ b/octo-fiesta/Program.cs @@ -20,6 +20,9 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +// Startup validation - runs at application startup to validate configuration +builder.Services.AddHostedService(); + builder.Services.AddCors(options => { options.AddDefaultPolicy(policy => diff --git a/octo-fiesta/Services/DeezerDownloadService.cs b/octo-fiesta/Services/DeezerDownloadService.cs index a6d45bd..efdcce8 100644 --- a/octo-fiesta/Services/DeezerDownloadService.cs +++ b/octo-fiesta/Services/DeezerDownloadService.cs @@ -44,7 +44,6 @@ public class DeezerDownloadService : IDownloadService private string? _apiToken; private string? _licenseToken; - private bool _usingFallback; private readonly Dictionary _activeDownloads = new(); private readonly SemaphoreSlim _downloadLock = new(1, 1); @@ -402,7 +401,6 @@ public class DeezerDownloadService : IDownloadService if (!string.IsNullOrEmpty(_arlFallback)) { _logger.LogWarning(ex, "Primary ARL failed, trying fallback ARL..."); - _usingFallback = true; return await tryDownload(_arlFallback); } throw; diff --git a/octo-fiesta/Services/StartupValidationService.cs b/octo-fiesta/Services/StartupValidationService.cs new file mode 100644 index 0000000..07580cc --- /dev/null +++ b/octo-fiesta/Services/StartupValidationService.cs @@ -0,0 +1,278 @@ +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Options; +using octo_fiesta.Models; + +namespace octo_fiesta.Services; + +/// +/// Hosted service that validates configuration at startup and logs the results. +/// Checks connectivity to Subsonic server and validates Deezer ARL token. +/// Uses a dedicated HttpClient without logging to keep console output clean. +/// +public class StartupValidationService : IHostedService +{ + private readonly IConfiguration _configuration; + private readonly IOptions _subsonicSettings; + private readonly HttpClient _httpClient; + + public StartupValidationService( + IConfiguration configuration, + IOptions subsonicSettings) + { + _configuration = configuration; + _subsonicSettings = subsonicSettings; + // Create a dedicated HttpClient without logging to keep startup output clean + _httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(10) }; + } + + public async Task StartAsync(CancellationToken cancellationToken) + { + Console.WriteLine(); + Console.WriteLine("========================================"); + Console.WriteLine(" octo-fiesta starting up... "); + Console.WriteLine("========================================"); + Console.WriteLine(); + + await ValidateSubsonicAsync(cancellationToken); + await ValidateDeezerArlAsync(cancellationToken); + + Console.WriteLine(); + Console.WriteLine("========================================"); + Console.WriteLine(" Startup validation complete "); + Console.WriteLine("========================================"); + Console.WriteLine(); + } + + public Task StopAsync(CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + private async Task ValidateSubsonicAsync(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; + } + + 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); + } + else if (content.Contains("\"status\":\"failed\"") || content.Contains("status=\"failed\"")) + { + WriteStatus("Subsonic server", "REACHABLE", ConsoleColor.Yellow); + WriteDetail("Authentication may be required for some operations"); + } + else + { + WriteStatus("Subsonic server", "REACHABLE", ConsoleColor.Yellow); + WriteDetail("Unexpected response format"); + } + } + else + { + WriteStatus("Subsonic server", $"HTTP {(int)response.StatusCode}", ConsoleColor.Red); + } + } + catch (TaskCanceledException) + { + WriteStatus("Subsonic server", "TIMEOUT", ConsoleColor.Red); + WriteDetail("Could not reach server within 10 seconds"); + } + catch (HttpRequestException ex) + { + WriteStatus("Subsonic server", "UNREACHABLE", ConsoleColor.Red); + WriteDetail(ex.Message); + } + catch (Exception ex) + { + WriteStatus("Subsonic server", "ERROR", ConsoleColor.Red); + WriteDetail(ex.Message); + } + } + + private async Task ValidateDeezerArlAsync(CancellationToken cancellationToken) + { + var arl = _configuration["Deezer:Arl"]; + var arlFallback = _configuration["Deezer:ArlFallback"]; + var quality = _configuration["Deezer:Quality"]; + + Console.WriteLine(); + + if (string.IsNullOrWhiteSpace(arl)) + { + WriteStatus("Deezer ARL", "NOT CONFIGURED", ConsoleColor.Red); + WriteDetail("Set the Deezer__Arl environment variable"); + return; + } + + 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); + } + } + + 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 ex) + { + WriteStatus(fieldName, "UNREACHABLE", ConsoleColor.Yellow); + WriteDetail(ex.Message); + } + catch (Exception ex) + { + WriteStatus(fieldName, "ERROR", ConsoleColor.Red); + WriteDetail(ex.Message); + } + } + + 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"; + } + + 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; + } + + /// + /// Masks a secret string, showing only the first 4 characters followed by asterisks. + /// + 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)); + } +}