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));
- }
}