diff --git a/octo-fiesta.Tests/DeezerDownloadServiceTests.cs b/octo-fiesta.Tests/DeezerDownloadServiceTests.cs index 9cdc687..e334db1 100644 --- a/octo-fiesta.Tests/DeezerDownloadServiceTests.cs +++ b/octo-fiesta.Tests/DeezerDownloadServiceTests.cs @@ -1,4 +1,6 @@ using octo_fiesta.Services; +using octo_fiesta.Services.Deezer; +using octo_fiesta.Services.Local; using octo_fiesta.Models; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; @@ -68,6 +70,13 @@ public class DeezerDownloadServiceTests : IDisposable { DownloadMode = downloadMode }); + + var deezerSettings = Options.Create(new DeezerSettings + { + Arl = arl, + ArlFallback = null, + Quality = null + }); return new DeezerDownloadService( _httpClientFactoryMock.Object, @@ -75,6 +84,7 @@ public class DeezerDownloadServiceTests : IDisposable _localLibraryServiceMock.Object, _metadataServiceMock.Object, subsonicSettings, + deezerSettings, _loggerMock.Object); } diff --git a/octo-fiesta.Tests/DeezerMetadataServiceTests.cs b/octo-fiesta.Tests/DeezerMetadataServiceTests.cs index 5d3a684..e03a5e4 100644 --- a/octo-fiesta.Tests/DeezerMetadataServiceTests.cs +++ b/octo-fiesta.Tests/DeezerMetadataServiceTests.cs @@ -1,4 +1,4 @@ -using octo_fiesta.Services; +using octo_fiesta.Services.Deezer; using octo_fiesta.Models; using Moq; using Moq.Protected; diff --git a/octo-fiesta.Tests/LocalLibraryServiceTests.cs b/octo-fiesta.Tests/LocalLibraryServiceTests.cs index 0c183a6..934ef27 100644 --- a/octo-fiesta.Tests/LocalLibraryServiceTests.cs +++ b/octo-fiesta.Tests/LocalLibraryServiceTests.cs @@ -1,4 +1,4 @@ -using octo_fiesta.Services; +using octo_fiesta.Services.Local; using octo_fiesta.Models; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; diff --git a/octo-fiesta/Controllers/SubSonicController.cs b/octo-fiesta/Controllers/SubSonicController.cs index c3398d3..dd0570f 100644 --- a/octo-fiesta/Controllers/SubSonicController.cs +++ b/octo-fiesta/Controllers/SubSonicController.cs @@ -5,6 +5,7 @@ using System.Text.Json; using Microsoft.Extensions.Options; using octo_fiesta.Models; using octo_fiesta.Services; +using octo_fiesta.Services.Local; namespace octo_fiesta.Controllers; diff --git a/octo-fiesta/Models/DeezerSettings.cs b/octo-fiesta/Models/DeezerSettings.cs new file mode 100644 index 0000000..b333a5f --- /dev/null +++ b/octo-fiesta/Models/DeezerSettings.cs @@ -0,0 +1,25 @@ +namespace octo_fiesta.Models; + +/// +/// Configuration for the Deezer downloader and metadata service +/// +public class DeezerSettings +{ + /// + /// Deezer ARL token (required for downloading) + /// Obtained from browser cookies after logging into deezer.com + /// + public string? Arl { get; set; } + + /// + /// Fallback ARL token (optional) + /// Used if the primary ARL token fails + /// + public string? ArlFallback { get; set; } + + /// + /// Preferred audio quality: FLAC, MP3_320, MP3_128 + /// If not specified or unavailable, the highest available quality will be used. + /// + public string? Quality { get; set; } +} diff --git a/octo-fiesta/Program.cs b/octo-fiesta/Program.cs index 1d9bc8c..5eeb057 100644 --- a/octo-fiesta/Program.cs +++ b/octo-fiesta/Program.cs @@ -1,5 +1,8 @@ using octo_fiesta.Models; using octo_fiesta.Services; +using octo_fiesta.Services.Deezer; +using octo_fiesta.Services.Qobuz; +using octo_fiesta.Services.Local; var builder = WebApplication.CreateBuilder(args); @@ -13,6 +16,8 @@ builder.Services.AddSwaggerGen(); // Configuration builder.Services.Configure( builder.Configuration.GetSection("Subsonic")); +builder.Services.Configure( + builder.Configuration.GetSection("Deezer")); builder.Services.Configure( builder.Configuration.GetSection("Qobuz")); diff --git a/octo-fiesta/Services/DeezerDownloadService.cs b/octo-fiesta/Services/Deezer/DeezerDownloadService.cs similarity index 98% rename from octo-fiesta/Services/DeezerDownloadService.cs rename to octo-fiesta/Services/Deezer/DeezerDownloadService.cs index 9406d3d..d023860 100644 --- a/octo-fiesta/Services/DeezerDownloadService.cs +++ b/octo-fiesta/Services/Deezer/DeezerDownloadService.cs @@ -5,26 +5,13 @@ using Org.BouncyCastle.Crypto.Engines; using Org.BouncyCastle.Crypto.Modes; using Org.BouncyCastle.Crypto.Parameters; using octo_fiesta.Models; +using octo_fiesta.Services; +using octo_fiesta.Services.Local; using Microsoft.Extensions.Options; using TagLib; using IOFile = System.IO.File; -namespace octo_fiesta.Services; - -/// -/// Configuration for the Deezer downloader -/// -public class DeezerDownloaderSettings -{ - public string? Arl { get; set; } - public string? ArlFallback { get; set; } - public string DownloadPath { get; set; } = "./downloads"; - /// - /// Preferred audio quality: FLAC, MP3_320, MP3_128 - /// If not specified or unavailable, the highest available quality will be used. - /// - public string? Quality { get; set; } -} +namespace octo_fiesta.Services.Deezer; /// /// C# port of the DeezerDownloader JavaScript @@ -66,6 +53,7 @@ public class DeezerDownloadService : IDownloadService ILocalLibraryService localLibraryService, IMusicMetadataService metadataService, IOptions subsonicSettings, + IOptions deezerSettings, ILogger logger) { _httpClient = httpClientFactory.CreateClient(); @@ -75,10 +63,11 @@ public class DeezerDownloadService : IDownloadService _subsonicSettings = subsonicSettings.Value; _logger = logger; + var deezer = deezerSettings.Value; _downloadPath = configuration["Library:DownloadPath"] ?? "./downloads"; - _arl = configuration["Deezer:Arl"]; - _arlFallback = configuration["Deezer:ArlFallback"]; - _preferredQuality = configuration["Deezer:Quality"]; + _arl = deezer.Arl; + _arlFallback = deezer.ArlFallback; + _preferredQuality = deezer.Quality; if (!Directory.Exists(_downloadPath)) { diff --git a/octo-fiesta/Services/DeezerMetadataService.cs b/octo-fiesta/Services/Deezer/DeezerMetadataService.cs similarity index 99% rename from octo-fiesta/Services/DeezerMetadataService.cs rename to octo-fiesta/Services/Deezer/DeezerMetadataService.cs index 93f2954..23cf5e0 100644 --- a/octo-fiesta/Services/DeezerMetadataService.cs +++ b/octo-fiesta/Services/Deezer/DeezerMetadataService.cs @@ -2,7 +2,7 @@ using octo_fiesta.Models; using System.Text.Json; using Microsoft.Extensions.Options; -namespace octo_fiesta.Services; +namespace octo_fiesta.Services.Deezer; /// /// Metadata service implementation using the Deezer API (free, no key required) diff --git a/octo-fiesta/Services/Deezer/DeezerStartupValidator.cs b/octo-fiesta/Services/Deezer/DeezerStartupValidator.cs new file mode 100644 index 0000000..c99159b --- /dev/null +++ b/octo-fiesta/Services/Deezer/DeezerStartupValidator.cs @@ -0,0 +1,186 @@ +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Options; +using octo_fiesta.Models; + +namespace octo_fiesta.Services.Deezer; + +/// +/// Validates Deezer ARL credentials at startup +/// +public class DeezerStartupValidator +{ + private readonly DeezerSettings _settings; + private readonly HttpClient _httpClient; + + public DeezerStartupValidator(IOptions settings, HttpClient httpClient) + { + _settings = settings.Value; + _httpClient = httpClient; + } + + public async Task ValidateAsync(CancellationToken cancellationToken) + { + var arl = _settings.Arl; + var arlFallback = _settings.ArlFallback; + var quality = _settings.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; + } + + 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/Local/ILocalLibraryService.cs b/octo-fiesta/Services/Local/ILocalLibraryService.cs new file mode 100644 index 0000000..b973d40 --- /dev/null +++ b/octo-fiesta/Services/Local/ILocalLibraryService.cs @@ -0,0 +1,46 @@ +using octo_fiesta.Models; + +namespace octo_fiesta.Services.Local; + +/// +/// Interface for local music library management +/// +public interface ILocalLibraryService +{ + /// + /// Checks if an external song already exists locally + /// + Task GetLocalPathForExternalSongAsync(string externalProvider, string externalId); + + /// + /// Registers a downloaded song in the local library + /// + Task RegisterDownloadedSongAsync(Song song, string localPath); + + /// + /// Gets the mapping between external ID and local ID + /// + Task GetLocalIdForExternalSongAsync(string externalProvider, string externalId); + + /// + /// Parses a song ID to determine if it is external or local + /// + (bool isExternal, string? provider, string? externalId) ParseSongId(string songId); + + /// + /// Parses an external ID to extract the provider, type and ID + /// Format: ext-{provider}-{type}-{id} (e.g., ext-deezer-artist-259, ext-deezer-album-96126, ext-deezer-song-12345) + /// Also supports legacy format: ext-{provider}-{id} (assumes song type) + /// + (bool isExternal, string? provider, string? type, string? externalId) ParseExternalId(string id); + + /// + /// Triggers a Subsonic library scan + /// + Task TriggerLibraryScanAsync(); + + /// + /// Gets the current scan status + /// + Task GetScanStatusAsync(); +} diff --git a/octo-fiesta/Services/LocalLibraryService.cs b/octo-fiesta/Services/Local/LocalLibraryService.cs similarity index 83% rename from octo-fiesta/Services/LocalLibraryService.cs rename to octo-fiesta/Services/Local/LocalLibraryService.cs index dec4e20..01baffa 100644 --- a/octo-fiesta/Services/LocalLibraryService.cs +++ b/octo-fiesta/Services/Local/LocalLibraryService.cs @@ -1,58 +1,15 @@ -using System.Text.Json; -using System.Xml.Linq; -using Microsoft.Extensions.Options; -using octo_fiesta.Models; - -namespace octo_fiesta.Services; - -/// -/// Interface for local music library management -/// -public interface ILocalLibraryService -{ - /// - /// Checks if an external song already exists locally - /// - Task GetLocalPathForExternalSongAsync(string externalProvider, string externalId); - - /// - /// Registers a downloaded song in the local library - /// - Task RegisterDownloadedSongAsync(Song song, string localPath); - - /// - /// Gets the mapping between external ID and local ID - /// - Task GetLocalIdForExternalSongAsync(string externalProvider, string externalId); - - /// - /// Parses a song ID to determine if it is external or local - /// - (bool isExternal, string? provider, string? externalId) ParseSongId(string songId); - - /// - /// Parses an external ID to extract the provider, type and ID - /// Format: ext-{provider}-{type}-{id} (e.g., ext-deezer-artist-259, ext-deezer-album-96126, ext-deezer-song-12345) - /// Also supports legacy format: ext-{provider}-{id} (assumes song type) - /// - (bool isExternal, string? provider, string? type, string? externalId) ParseExternalId(string id); - - /// - /// Triggers a Subsonic library scan - /// - Task TriggerLibraryScanAsync(); - - /// - /// Gets the current scan status - /// - Task GetScanStatusAsync(); -} - -/// -/// Local library service implementation -/// Uses a simple JSON file to store mappings (can be replaced with a database) -/// -public class LocalLibraryService : ILocalLibraryService +using System.Text.Json; +using Microsoft.Extensions.Options; +using octo_fiesta.Models; +using octo_fiesta.Services; + +namespace octo_fiesta.Services.Local; + +/// +/// Local library service implementation +/// Uses a simple JSON file to store mappings (can be replaced with a database) +/// +public class LocalLibraryService : ILocalLibraryService { private readonly string _mappingFilePath; private readonly string _downloadDirectory; diff --git a/octo-fiesta/Services/QobuzBundleService.cs b/octo-fiesta/Services/Qobuz/QobuzBundleService.cs similarity index 99% rename from octo-fiesta/Services/QobuzBundleService.cs rename to octo-fiesta/Services/Qobuz/QobuzBundleService.cs index ca7a97d..b42fdd3 100644 --- a/octo-fiesta/Services/QobuzBundleService.cs +++ b/octo-fiesta/Services/Qobuz/QobuzBundleService.cs @@ -1,6 +1,6 @@ using System.Text.RegularExpressions; -namespace octo_fiesta.Services; +namespace octo_fiesta.Services.Qobuz; /// /// Service to dynamically extract Qobuz App ID and secrets from the Qobuz web player diff --git a/octo-fiesta/Services/QobuzDownloadService.cs b/octo-fiesta/Services/Qobuz/QobuzDownloadService.cs similarity index 99% rename from octo-fiesta/Services/QobuzDownloadService.cs rename to octo-fiesta/Services/Qobuz/QobuzDownloadService.cs index c59399b..1c686cc 100644 --- a/octo-fiesta/Services/QobuzDownloadService.cs +++ b/octo-fiesta/Services/Qobuz/QobuzDownloadService.cs @@ -2,10 +2,13 @@ using System.Security.Cryptography; using System.Text; using System.Text.Json; using octo_fiesta.Models; +using octo_fiesta.Services; +using octo_fiesta.Services.Deezer; +using octo_fiesta.Services.Local; using Microsoft.Extensions.Options; using IOFile = System.IO.File; -namespace octo_fiesta.Services; +namespace octo_fiesta.Services.Qobuz; /// /// Download service implementation for Qobuz diff --git a/octo-fiesta/Services/QobuzMetadataService.cs b/octo-fiesta/Services/Qobuz/QobuzMetadataService.cs similarity index 99% rename from octo-fiesta/Services/QobuzMetadataService.cs rename to octo-fiesta/Services/Qobuz/QobuzMetadataService.cs index 3e563dc..2c5fb29 100644 --- a/octo-fiesta/Services/QobuzMetadataService.cs +++ b/octo-fiesta/Services/Qobuz/QobuzMetadataService.cs @@ -2,7 +2,7 @@ using octo_fiesta.Models; using System.Text.Json; using Microsoft.Extensions.Options; -namespace octo_fiesta.Services; +namespace octo_fiesta.Services.Qobuz; /// /// Metadata service implementation using the Qobuz API diff --git a/octo-fiesta/Services/Qobuz/QobuzStartupValidator.cs b/octo-fiesta/Services/Qobuz/QobuzStartupValidator.cs new file mode 100644 index 0000000..ba1eb19 --- /dev/null +++ b/octo-fiesta/Services/Qobuz/QobuzStartupValidator.cs @@ -0,0 +1,158 @@ +using Microsoft.Extensions.Options; +using octo_fiesta.Models; + +namespace octo_fiesta.Services.Qobuz; + +/// +/// Validates Qobuz credentials at startup +/// +public class QobuzStartupValidator +{ + private readonly IOptions _qobuzSettings; + private readonly HttpClient _httpClient; + + public QobuzStartupValidator(IOptions qobuzSettings, HttpClient httpClient) + { + _qobuzSettings = qobuzSettings; + _httpClient = httpClient; + } + + public async Task ValidateAsync(CancellationToken cancellationToken) + { + var userAuthToken = _qobuzSettings.Value.UserAuthToken; + var userId = _qobuzSettings.Value.UserId; + var quality = _qobuzSettings.Value.Quality; + + Console.WriteLine(); + + if (string.IsNullOrWhiteSpace(userAuthToken)) + { + WriteStatus("Qobuz UserAuthToken", "NOT CONFIGURED", ConsoleColor.Red); + WriteDetail("Set the Qobuz__UserAuthToken environment variable"); + return; + } + + if (string.IsNullOrWhiteSpace(userId)) + { + WriteStatus("Qobuz UserId", "NOT CONFIGURED", ConsoleColor.Red); + WriteDetail("Set the Qobuz__UserId environment variable"); + return; + } + + 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); + } + + 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 ex) + { + WriteStatus(fieldName, "UNREACHABLE", ConsoleColor.Yellow); + WriteDetail(ex.Message); + } + catch (Exception ex) + { + WriteStatus(fieldName, "ERROR", ConsoleColor.Red); + 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/StartupValidationService.cs b/octo-fiesta/Services/StartupValidationService.cs index 81d4179..a2ecc5b 100644 --- a/octo-fiesta/Services/StartupValidationService.cs +++ b/octo-fiesta/Services/StartupValidationService.cs @@ -1,7 +1,7 @@ -using System.Text; -using System.Text.Json; using Microsoft.Extensions.Options; using octo_fiesta.Models; +using octo_fiesta.Services.Deezer; +using octo_fiesta.Services.Qobuz; namespace octo_fiesta.Services; @@ -14,16 +14,19 @@ public class StartupValidationService : IHostedService { private readonly IConfiguration _configuration; private readonly IOptions _subsonicSettings; + private readonly IOptions _deezerSettings; private readonly IOptions _qobuzSettings; private readonly HttpClient _httpClient; public StartupValidationService( IConfiguration configuration, IOptions subsonicSettings, + IOptions deezerSettings, IOptions qobuzSettings) { _configuration = configuration; _subsonicSettings = subsonicSettings; + _deezerSettings = deezerSettings; _qobuzSettings = qobuzSettings; // Create a dedicated HttpClient without logging to keep startup output clean _httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(10) }; @@ -43,11 +46,13 @@ public class StartupValidationService : IHostedService var musicService = _subsonicSettings.Value.MusicService; if (musicService == MusicService.Qobuz) { - await ValidateQobuzAsync(cancellationToken); + var qobuzValidator = new QobuzStartupValidator(_qobuzSettings, _httpClient); + await qobuzValidator.ValidateAsync(cancellationToken); } else { - await ValidateDeezerArlAsync(cancellationToken); + var deezerValidator = new DeezerStartupValidator(_deezerSettings, _httpClient); + await deezerValidator.ValidateAsync(cancellationToken); } Console.WriteLine(); @@ -121,244 +126,6 @@ public class StartupValidationService : IHostedService } } - 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 ValidateQobuzAsync(CancellationToken cancellationToken) - { - var userAuthToken = _qobuzSettings.Value.UserAuthToken; - var userId = _qobuzSettings.Value.UserId; - var quality = _qobuzSettings.Value.Quality; - - Console.WriteLine(); - - if (string.IsNullOrWhiteSpace(userAuthToken)) - { - WriteStatus("Qobuz UserAuthToken", "NOT CONFIGURED", ConsoleColor.Red); - WriteDetail("Set the Qobuz__UserAuthToken environment variable"); - return; - } - - if (string.IsNullOrWhiteSpace(userId)) - { - WriteStatus("Qobuz UserId", "NOT CONFIGURED", ConsoleColor.Red); - WriteDetail("Set the Qobuz__UserId environment variable"); - return; - } - - 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); - } - - 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 ex) - { - WriteStatus(fieldName, "UNREACHABLE", ConsoleColor.Yellow); - WriteDetail(ex.Message); - } - catch (Exception ex) - { - WriteStatus(fieldName, "ERROR", ConsoleColor.Red); - WriteDetail(ex.Message); - } - } - - 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}: "); @@ -375,23 +142,4 @@ public class StartupValidationService : IHostedService 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)); - } }