refactor: organize services by provider and standardize settings pattern

This commit is contained in:
V1ck3s
2026-01-08 19:02:44 +01:00
parent d0e64bac81
commit fe9cb9b758
16 changed files with 469 additions and 341 deletions

View File

@@ -1,4 +1,6 @@
using octo_fiesta.Services; using octo_fiesta.Services;
using octo_fiesta.Services.Deezer;
using octo_fiesta.Services.Local;
using octo_fiesta.Models; using octo_fiesta.Models;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -69,12 +71,20 @@ public class DeezerDownloadServiceTests : IDisposable
DownloadMode = downloadMode DownloadMode = downloadMode
}); });
var deezerSettings = Options.Create(new DeezerSettings
{
Arl = arl,
ArlFallback = null,
Quality = null
});
return new DeezerDownloadService( return new DeezerDownloadService(
_httpClientFactoryMock.Object, _httpClientFactoryMock.Object,
config, config,
_localLibraryServiceMock.Object, _localLibraryServiceMock.Object,
_metadataServiceMock.Object, _metadataServiceMock.Object,
subsonicSettings, subsonicSettings,
deezerSettings,
_loggerMock.Object); _loggerMock.Object);
} }

View File

@@ -1,4 +1,4 @@
using octo_fiesta.Services; using octo_fiesta.Services.Deezer;
using octo_fiesta.Models; using octo_fiesta.Models;
using Moq; using Moq;
using Moq.Protected; using Moq.Protected;

View File

@@ -1,4 +1,4 @@
using octo_fiesta.Services; using octo_fiesta.Services.Local;
using octo_fiesta.Models; using octo_fiesta.Models;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;

View File

@@ -5,6 +5,7 @@ using System.Text.Json;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using octo_fiesta.Models; using octo_fiesta.Models;
using octo_fiesta.Services; using octo_fiesta.Services;
using octo_fiesta.Services.Local;
namespace octo_fiesta.Controllers; namespace octo_fiesta.Controllers;

View File

@@ -0,0 +1,25 @@
namespace octo_fiesta.Models;
/// <summary>
/// Configuration for the Deezer downloader and metadata service
/// </summary>
public class DeezerSettings
{
/// <summary>
/// Deezer ARL token (required for downloading)
/// Obtained from browser cookies after logging into deezer.com
/// </summary>
public string? Arl { get; set; }
/// <summary>
/// Fallback ARL token (optional)
/// Used if the primary ARL token fails
/// </summary>
public string? ArlFallback { get; set; }
/// <summary>
/// Preferred audio quality: FLAC, MP3_320, MP3_128
/// If not specified or unavailable, the highest available quality will be used.
/// </summary>
public string? Quality { get; set; }
}

View File

@@ -1,5 +1,8 @@
using octo_fiesta.Models; using octo_fiesta.Models;
using octo_fiesta.Services; using octo_fiesta.Services;
using octo_fiesta.Services.Deezer;
using octo_fiesta.Services.Qobuz;
using octo_fiesta.Services.Local;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
@@ -13,6 +16,8 @@ builder.Services.AddSwaggerGen();
// Configuration // Configuration
builder.Services.Configure<SubsonicSettings>( builder.Services.Configure<SubsonicSettings>(
builder.Configuration.GetSection("Subsonic")); builder.Configuration.GetSection("Subsonic"));
builder.Services.Configure<DeezerSettings>(
builder.Configuration.GetSection("Deezer"));
builder.Services.Configure<QobuzSettings>( builder.Services.Configure<QobuzSettings>(
builder.Configuration.GetSection("Qobuz")); builder.Configuration.GetSection("Qobuz"));

View File

@@ -5,26 +5,13 @@ using Org.BouncyCastle.Crypto.Engines;
using Org.BouncyCastle.Crypto.Modes; using Org.BouncyCastle.Crypto.Modes;
using Org.BouncyCastle.Crypto.Parameters; using Org.BouncyCastle.Crypto.Parameters;
using octo_fiesta.Models; using octo_fiesta.Models;
using octo_fiesta.Services;
using octo_fiesta.Services.Local;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using TagLib; using TagLib;
using IOFile = System.IO.File; using IOFile = System.IO.File;
namespace octo_fiesta.Services; namespace octo_fiesta.Services.Deezer;
/// <summary>
/// Configuration for the Deezer downloader
/// </summary>
public class DeezerDownloaderSettings
{
public string? Arl { get; set; }
public string? ArlFallback { get; set; }
public string DownloadPath { get; set; } = "./downloads";
/// <summary>
/// Preferred audio quality: FLAC, MP3_320, MP3_128
/// If not specified or unavailable, the highest available quality will be used.
/// </summary>
public string? Quality { get; set; }
}
/// <summary> /// <summary>
/// C# port of the DeezerDownloader JavaScript /// C# port of the DeezerDownloader JavaScript
@@ -66,6 +53,7 @@ public class DeezerDownloadService : IDownloadService
ILocalLibraryService localLibraryService, ILocalLibraryService localLibraryService,
IMusicMetadataService metadataService, IMusicMetadataService metadataService,
IOptions<SubsonicSettings> subsonicSettings, IOptions<SubsonicSettings> subsonicSettings,
IOptions<DeezerSettings> deezerSettings,
ILogger<DeezerDownloadService> logger) ILogger<DeezerDownloadService> logger)
{ {
_httpClient = httpClientFactory.CreateClient(); _httpClient = httpClientFactory.CreateClient();
@@ -75,10 +63,11 @@ public class DeezerDownloadService : IDownloadService
_subsonicSettings = subsonicSettings.Value; _subsonicSettings = subsonicSettings.Value;
_logger = logger; _logger = logger;
var deezer = deezerSettings.Value;
_downloadPath = configuration["Library:DownloadPath"] ?? "./downloads"; _downloadPath = configuration["Library:DownloadPath"] ?? "./downloads";
_arl = configuration["Deezer:Arl"]; _arl = deezer.Arl;
_arlFallback = configuration["Deezer:ArlFallback"]; _arlFallback = deezer.ArlFallback;
_preferredQuality = configuration["Deezer:Quality"]; _preferredQuality = deezer.Quality;
if (!Directory.Exists(_downloadPath)) if (!Directory.Exists(_downloadPath))
{ {

View File

@@ -2,7 +2,7 @@ using octo_fiesta.Models;
using System.Text.Json; using System.Text.Json;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
namespace octo_fiesta.Services; namespace octo_fiesta.Services.Deezer;
/// <summary> /// <summary>
/// Metadata service implementation using the Deezer API (free, no key required) /// Metadata service implementation using the Deezer API (free, no key required)

View File

@@ -0,0 +1,186 @@
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Options;
using octo_fiesta.Models;
namespace octo_fiesta.Services.Deezer;
/// <summary>
/// Validates Deezer ARL credentials at startup
/// </summary>
public class DeezerStartupValidator
{
private readonly DeezerSettings _settings;
private readonly HttpClient _httpClient;
public DeezerStartupValidator(IOptions<DeezerSettings> 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));
}
}

View File

@@ -0,0 +1,46 @@
using octo_fiesta.Models;
namespace octo_fiesta.Services.Local;
/// <summary>
/// Interface for local music library management
/// </summary>
public interface ILocalLibraryService
{
/// <summary>
/// Checks if an external song already exists locally
/// </summary>
Task<string?> GetLocalPathForExternalSongAsync(string externalProvider, string externalId);
/// <summary>
/// Registers a downloaded song in the local library
/// </summary>
Task RegisterDownloadedSongAsync(Song song, string localPath);
/// <summary>
/// Gets the mapping between external ID and local ID
/// </summary>
Task<string?> GetLocalIdForExternalSongAsync(string externalProvider, string externalId);
/// <summary>
/// Parses a song ID to determine if it is external or local
/// </summary>
(bool isExternal, string? provider, string? externalId) ParseSongId(string songId);
/// <summary>
/// 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)
/// </summary>
(bool isExternal, string? provider, string? type, string? externalId) ParseExternalId(string id);
/// <summary>
/// Triggers a Subsonic library scan
/// </summary>
Task<bool> TriggerLibraryScanAsync();
/// <summary>
/// Gets the current scan status
/// </summary>
Task<ScanStatus?> GetScanStatusAsync();
}

View File

@@ -1,52 +1,9 @@
using System.Text.Json; using System.Text.Json;
using System.Xml.Linq;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using octo_fiesta.Models; using octo_fiesta.Models;
using octo_fiesta.Services;
namespace octo_fiesta.Services; namespace octo_fiesta.Services.Local;
/// <summary>
/// Interface for local music library management
/// </summary>
public interface ILocalLibraryService
{
/// <summary>
/// Checks if an external song already exists locally
/// </summary>
Task<string?> GetLocalPathForExternalSongAsync(string externalProvider, string externalId);
/// <summary>
/// Registers a downloaded song in the local library
/// </summary>
Task RegisterDownloadedSongAsync(Song song, string localPath);
/// <summary>
/// Gets the mapping between external ID and local ID
/// </summary>
Task<string?> GetLocalIdForExternalSongAsync(string externalProvider, string externalId);
/// <summary>
/// Parses a song ID to determine if it is external or local
/// </summary>
(bool isExternal, string? provider, string? externalId) ParseSongId(string songId);
/// <summary>
/// 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)
/// </summary>
(bool isExternal, string? provider, string? type, string? externalId) ParseExternalId(string id);
/// <summary>
/// Triggers a Subsonic library scan
/// </summary>
Task<bool> TriggerLibraryScanAsync();
/// <summary>
/// Gets the current scan status
/// </summary>
Task<ScanStatus?> GetScanStatusAsync();
}
/// <summary> /// <summary>
/// Local library service implementation /// Local library service implementation

View File

@@ -1,6 +1,6 @@
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
namespace octo_fiesta.Services; namespace octo_fiesta.Services.Qobuz;
/// <summary> /// <summary>
/// Service to dynamically extract Qobuz App ID and secrets from the Qobuz web player /// Service to dynamically extract Qobuz App ID and secrets from the Qobuz web player

View File

@@ -2,10 +2,13 @@ using System.Security.Cryptography;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using octo_fiesta.Models; using octo_fiesta.Models;
using octo_fiesta.Services;
using octo_fiesta.Services.Deezer;
using octo_fiesta.Services.Local;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using IOFile = System.IO.File; using IOFile = System.IO.File;
namespace octo_fiesta.Services; namespace octo_fiesta.Services.Qobuz;
/// <summary> /// <summary>
/// Download service implementation for Qobuz /// Download service implementation for Qobuz

View File

@@ -2,7 +2,7 @@ using octo_fiesta.Models;
using System.Text.Json; using System.Text.Json;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
namespace octo_fiesta.Services; namespace octo_fiesta.Services.Qobuz;
/// <summary> /// <summary>
/// Metadata service implementation using the Qobuz API /// Metadata service implementation using the Qobuz API

View File

@@ -0,0 +1,158 @@
using Microsoft.Extensions.Options;
using octo_fiesta.Models;
namespace octo_fiesta.Services.Qobuz;
/// <summary>
/// Validates Qobuz credentials at startup
/// </summary>
public class QobuzStartupValidator
{
private readonly IOptions<QobuzSettings> _qobuzSettings;
private readonly HttpClient _httpClient;
public QobuzStartupValidator(IOptions<QobuzSettings> 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));
}
}

View File

@@ -1,7 +1,7 @@
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using octo_fiesta.Models; using octo_fiesta.Models;
using octo_fiesta.Services.Deezer;
using octo_fiesta.Services.Qobuz;
namespace octo_fiesta.Services; namespace octo_fiesta.Services;
@@ -14,16 +14,19 @@ public class StartupValidationService : IHostedService
{ {
private readonly IConfiguration _configuration; private readonly IConfiguration _configuration;
private readonly IOptions<SubsonicSettings> _subsonicSettings; private readonly IOptions<SubsonicSettings> _subsonicSettings;
private readonly IOptions<DeezerSettings> _deezerSettings;
private readonly IOptions<QobuzSettings> _qobuzSettings; private readonly IOptions<QobuzSettings> _qobuzSettings;
private readonly HttpClient _httpClient; private readonly HttpClient _httpClient;
public StartupValidationService( public StartupValidationService(
IConfiguration configuration, IConfiguration configuration,
IOptions<SubsonicSettings> subsonicSettings, IOptions<SubsonicSettings> subsonicSettings,
IOptions<DeezerSettings> deezerSettings,
IOptions<QobuzSettings> qobuzSettings) IOptions<QobuzSettings> qobuzSettings)
{ {
_configuration = configuration; _configuration = configuration;
_subsonicSettings = subsonicSettings; _subsonicSettings = subsonicSettings;
_deezerSettings = deezerSettings;
_qobuzSettings = qobuzSettings; _qobuzSettings = qobuzSettings;
// Create a dedicated HttpClient without logging to keep startup output clean // Create a dedicated HttpClient without logging to keep startup output clean
_httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(10) }; _httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
@@ -43,11 +46,13 @@ public class StartupValidationService : IHostedService
var musicService = _subsonicSettings.Value.MusicService; var musicService = _subsonicSettings.Value.MusicService;
if (musicService == MusicService.Qobuz) if (musicService == MusicService.Qobuz)
{ {
await ValidateQobuzAsync(cancellationToken); var qobuzValidator = new QobuzStartupValidator(_qobuzSettings, _httpClient);
await qobuzValidator.ValidateAsync(cancellationToken);
} }
else else
{ {
await ValidateDeezerArlAsync(cancellationToken); var deezerValidator = new DeezerStartupValidator(_deezerSettings, _httpClient);
await deezerValidator.ValidateAsync(cancellationToken);
} }
Console.WriteLine(); 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) private static void WriteStatus(string label, string value, ConsoleColor valueColor)
{ {
Console.Write($" {label}: "); Console.Write($" {label}: ");
@@ -375,23 +142,4 @@ public class StartupValidationService : IHostedService
Console.WriteLine($" -> {message}"); Console.WriteLine($" -> {message}");
Console.ForegroundColor = originalColor; Console.ForegroundColor = originalColor;
} }
/// <summary>
/// Masks a secret string, showing only the first 4 characters followed by asterisks.
/// </summary>
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));
}
} }