mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-09 23:55:10 -05:00
feat: add pluggable music service architecture with Qobuz support
This commit is contained in:
25
octo-fiesta/Models/QobuzSettings.cs
Normal file
25
octo-fiesta/Models/QobuzSettings.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
namespace octo_fiesta.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for the Qobuz downloader and metadata service
|
||||
/// </summary>
|
||||
public class QobuzSettings
|
||||
{
|
||||
/// <summary>
|
||||
/// Qobuz user authentication token
|
||||
/// Obtained from browser's localStorage after logging into play.qobuz.com
|
||||
/// </summary>
|
||||
public string? UserAuthToken { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Qobuz user ID
|
||||
/// Obtained from browser's localStorage after logging into play.qobuz.com
|
||||
/// </summary>
|
||||
public string? UserId { 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; }
|
||||
}
|
||||
@@ -40,6 +40,22 @@ public enum ExplicitFilter
|
||||
CleanOnly
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Music service provider
|
||||
/// </summary>
|
||||
public enum MusicService
|
||||
{
|
||||
/// <summary>
|
||||
/// Deezer music service
|
||||
/// </summary>
|
||||
Deezer,
|
||||
|
||||
/// <summary>
|
||||
/// Qobuz music service
|
||||
/// </summary>
|
||||
Qobuz
|
||||
}
|
||||
|
||||
public class SubsonicSettings
|
||||
{
|
||||
public string? Url { get; set; }
|
||||
@@ -48,6 +64,7 @@ public class SubsonicSettings
|
||||
/// Explicit content filter mode (default: All)
|
||||
/// Environment variable: EXPLICIT_FILTER
|
||||
/// Values: "All", "ExplicitOnly", "CleanOnly"
|
||||
/// Note: Only works with Deezer
|
||||
/// </summary>
|
||||
public ExplicitFilter ExplicitFilter { get; set; } = ExplicitFilter.All;
|
||||
|
||||
@@ -57,4 +74,11 @@ public class SubsonicSettings
|
||||
/// Values: "Track" (download only played track), "Album" (download full album when playing a track)
|
||||
/// </summary>
|
||||
public DownloadMode DownloadMode { get; set; } = DownloadMode.Track;
|
||||
|
||||
/// <summary>
|
||||
/// Music service to use (default: Deezer)
|
||||
/// Environment variable: MUSIC_SERVICE
|
||||
/// Values: "Deezer", "Qobuz"
|
||||
/// </summary>
|
||||
public MusicService MusicService { get; set; } = MusicService.Deezer;
|
||||
}
|
||||
@@ -13,12 +13,30 @@ builder.Services.AddSwaggerGen();
|
||||
// Configuration
|
||||
builder.Services.Configure<SubsonicSettings>(
|
||||
builder.Configuration.GetSection("Subsonic"));
|
||||
builder.Services.Configure<QobuzSettings>(
|
||||
builder.Configuration.GetSection("Qobuz"));
|
||||
|
||||
// Get the configured music service
|
||||
var musicService = builder.Configuration.GetValue<MusicService>("Subsonic:MusicService");
|
||||
|
||||
// Business services
|
||||
// Registered as Singleton to share state (mappings cache, scan debounce, download tracking, rate limiting)
|
||||
builder.Services.AddSingleton<ILocalLibraryService, LocalLibraryService>();
|
||||
builder.Services.AddSingleton<IMusicMetadataService, DeezerMetadataService>();
|
||||
builder.Services.AddSingleton<IDownloadService, DeezerDownloadService>();
|
||||
|
||||
// Register music service based on configuration
|
||||
if (musicService == MusicService.Qobuz)
|
||||
{
|
||||
// Qobuz services
|
||||
builder.Services.AddSingleton<QobuzBundleService>();
|
||||
builder.Services.AddSingleton<IMusicMetadataService, QobuzMetadataService>();
|
||||
builder.Services.AddSingleton<IDownloadService, QobuzDownloadService>();
|
||||
}
|
||||
else
|
||||
{
|
||||
// Deezer services (default)
|
||||
builder.Services.AddSingleton<IMusicMetadataService, DeezerMetadataService>();
|
||||
builder.Services.AddSingleton<IDownloadService, DeezerDownloadService>();
|
||||
}
|
||||
|
||||
// Startup validation - runs at application startup to validate configuration
|
||||
builder.Services.AddHostedService<StartupValidationService>();
|
||||
@@ -33,22 +51,22 @@ builder.Services.AddCors(options =>
|
||||
.WithExposedHeaders("X-Content-Duration", "X-Total-Count", "X-Nd-Authorization");
|
||||
});
|
||||
});
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Configure the HTTP request pipeline.
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
}
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
|
||||
app.UseAuthorization();
|
||||
|
||||
app.UseCors();
|
||||
|
||||
app.MapControllers();
|
||||
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Configure the HTTP request pipeline.
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
}
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
|
||||
app.UseAuthorization();
|
||||
|
||||
app.UseCors();
|
||||
|
||||
app.MapControllers();
|
||||
|
||||
app.Run();
|
||||
286
octo-fiesta/Services/QobuzBundleService.cs
Normal file
286
octo-fiesta/Services/QobuzBundleService.cs
Normal file
@@ -0,0 +1,286 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace octo_fiesta.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service to dynamically extract Qobuz App ID and secrets from the Qobuz web player
|
||||
/// This is necessary because these values change periodically
|
||||
/// Based on the Python qobuz-dl implementation
|
||||
/// </summary>
|
||||
public class QobuzBundleService
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILogger<QobuzBundleService> _logger;
|
||||
|
||||
private const string BaseUrl = "https://play.qobuz.com";
|
||||
private const string LoginPageUrl = "https://play.qobuz.com/login";
|
||||
|
||||
// Regex patterns to extract bundle URL and App ID
|
||||
private static readonly Regex BundleUrlRegex = new(
|
||||
@"<script src=""(/resources/\d+\.\d+\.\d+-[a-z]\d{3}/bundle\.js)""></script>",
|
||||
RegexOptions.Compiled);
|
||||
|
||||
private static readonly Regex AppIdRegex = new(
|
||||
@"production:\{api:\{appId:""(?<app_id>\d{9})"",appSecret:""\w{32}""",
|
||||
RegexOptions.Compiled);
|
||||
|
||||
// Cached values (valid for the lifetime of the application)
|
||||
private string? _cachedAppId;
|
||||
private List<string>? _cachedSecrets;
|
||||
private readonly SemaphoreSlim _initLock = new(1, 1);
|
||||
|
||||
public QobuzBundleService(IHttpClientFactory httpClientFactory, ILogger<QobuzBundleService> logger)
|
||||
{
|
||||
_httpClient = httpClientFactory.CreateClient();
|
||||
_httpClient.DefaultRequestHeaders.Add("User-Agent",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:83.0) Gecko/20100101 Firefox/83.0");
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the Qobuz App ID, extracting it from the bundle if not cached
|
||||
/// </summary>
|
||||
public async Task<string> GetAppIdAsync()
|
||||
{
|
||||
await EnsureInitializedAsync();
|
||||
return _cachedAppId!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the Qobuz secrets list, extracting them from the bundle if not cached
|
||||
/// </summary>
|
||||
public async Task<List<string>> GetSecretsAsync()
|
||||
{
|
||||
await EnsureInitializedAsync();
|
||||
return _cachedSecrets!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a specific secret by index (used for signing requests)
|
||||
/// </summary>
|
||||
public async Task<string> GetSecretAsync(int index = 0)
|
||||
{
|
||||
var secrets = await GetSecretsAsync();
|
||||
if (index < 0 || index >= secrets.Count)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(index),
|
||||
$"Secret index {index} out of range (0-{secrets.Count - 1})");
|
||||
}
|
||||
return secrets[index];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ensures App ID and secrets are extracted and cached
|
||||
/// </summary>
|
||||
private async Task EnsureInitializedAsync()
|
||||
{
|
||||
if (_cachedAppId != null && _cachedSecrets != null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await _initLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
// Double-check after acquiring lock
|
||||
if (_cachedAppId != null && _cachedSecrets != null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Extracting Qobuz App ID and secrets from web bundle...");
|
||||
|
||||
// Step 1: Get the bundle URL from login page
|
||||
var bundleUrl = await GetBundleUrlAsync();
|
||||
_logger.LogInformation("Found bundle URL: {BundleUrl}", bundleUrl);
|
||||
|
||||
// Step 2: Download the bundle JavaScript
|
||||
var bundleJs = await DownloadBundleAsync(bundleUrl);
|
||||
|
||||
// Step 3: Extract App ID
|
||||
_cachedAppId = ExtractAppId(bundleJs);
|
||||
_logger.LogInformation("Extracted App ID: {AppId}", _cachedAppId);
|
||||
|
||||
// Step 4: Extract secrets (they are base64 encoded in the bundle)
|
||||
_cachedSecrets = ExtractSecrets(bundleJs);
|
||||
_logger.LogInformation("Extracted {Count} secrets", _cachedSecrets.Count);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_initLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the bundle JavaScript URL from the login page
|
||||
/// </summary>
|
||||
private async Task<string> GetBundleUrlAsync()
|
||||
{
|
||||
var response = await _httpClient.GetAsync(LoginPageUrl);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var html = await response.Content.ReadAsStringAsync();
|
||||
var match = BundleUrlRegex.Match(html);
|
||||
|
||||
if (!match.Success)
|
||||
{
|
||||
throw new Exception("Could not find bundle URL in Qobuz login page");
|
||||
}
|
||||
|
||||
return BaseUrl + match.Groups[1].Value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Downloads the bundle JavaScript file
|
||||
/// </summary>
|
||||
private async Task<string> DownloadBundleAsync(string bundleUrl)
|
||||
{
|
||||
var response = await _httpClient.GetAsync(bundleUrl);
|
||||
response.EnsureSuccessStatusCode();
|
||||
return await response.Content.ReadAsStringAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the App ID from the bundle JavaScript
|
||||
/// </summary>
|
||||
private string ExtractAppId(string bundleJs)
|
||||
{
|
||||
var match = AppIdRegex.Match(bundleJs);
|
||||
|
||||
if (!match.Success)
|
||||
{
|
||||
throw new Exception("Could not extract App ID from bundle");
|
||||
}
|
||||
|
||||
return match.Groups["app_id"].Value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the secrets from the bundle JavaScript
|
||||
/// Based on the Python qobuz-dl implementation (bundle.py)
|
||||
/// The secrets are composed of seed, info, and extras base64-encoded strings
|
||||
/// </summary>
|
||||
private List<string> ExtractSecrets(string bundleJs)
|
||||
{
|
||||
var secrets = new Dictionary<string, List<string>>();
|
||||
|
||||
// Step 1: Extract seed and timezone pairs
|
||||
// Pattern: [a-z].initialSeed("base64string",window.utimezone.timezone)
|
||||
var seedTimezonePattern = new Regex(
|
||||
@"[a-z]\.initialSeed\(""(?<seed>[\w=]+)"",window\.utimezone\.(?<timezone>[a-z]+)\)",
|
||||
RegexOptions.IgnoreCase);
|
||||
|
||||
var seedMatches = seedTimezonePattern.Matches(bundleJs);
|
||||
|
||||
foreach (Match match in seedMatches)
|
||||
{
|
||||
var seed = match.Groups["seed"].Value;
|
||||
var timezone = match.Groups["timezone"].Value.ToLower();
|
||||
|
||||
if (!secrets.ContainsKey(timezone))
|
||||
{
|
||||
secrets[timezone] = new List<string>();
|
||||
}
|
||||
secrets[timezone].Add(seed);
|
||||
}
|
||||
|
||||
if (secrets.Count == 0)
|
||||
{
|
||||
throw new Exception("Could not extract seed/timezone pairs from bundle");
|
||||
}
|
||||
|
||||
// Step 2: Reorder secrets (move second item to first, as per Python implementation)
|
||||
var keypairs = secrets.ToList();
|
||||
if (keypairs.Count > 1)
|
||||
{
|
||||
var secondItem = keypairs[1];
|
||||
secrets.Remove(secondItem.Key);
|
||||
var newDict = new Dictionary<string, List<string>> { { secondItem.Key, secondItem.Value } };
|
||||
foreach (var kv in keypairs)
|
||||
{
|
||||
if (kv.Key != secondItem.Key)
|
||||
{
|
||||
newDict[kv.Key] = kv.Value;
|
||||
}
|
||||
}
|
||||
secrets = newDict;
|
||||
}
|
||||
|
||||
// Step 3: Extract info and extras for each timezone
|
||||
// Pattern: name:"\w+/(Timezone)",info:"base64",extras:"base64"
|
||||
var timezones = string.Join("|", secrets.Keys.Select(tz =>
|
||||
char.ToUpper(tz[0]) + tz.Substring(1)));
|
||||
|
||||
var infoExtrasPattern = new Regex(
|
||||
$@"name:""\w+/(?<timezone>{timezones})"",info:""(?<info>[\w=]+)"",extras:""(?<extras>[\w=]+)""",
|
||||
RegexOptions.IgnoreCase);
|
||||
|
||||
var infoExtrasMatches = infoExtrasPattern.Matches(bundleJs);
|
||||
|
||||
foreach (Match match in infoExtrasMatches)
|
||||
{
|
||||
var timezone = match.Groups["timezone"].Value.ToLower();
|
||||
var info = match.Groups["info"].Value;
|
||||
var extras = match.Groups["extras"].Value;
|
||||
|
||||
if (secrets.ContainsKey(timezone))
|
||||
{
|
||||
secrets[timezone].Add(info);
|
||||
secrets[timezone].Add(extras);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: Decode the secrets
|
||||
// Concatenate all base64 strings for each timezone, remove last 44 chars, then decode
|
||||
var decodedSecrets = new List<string>();
|
||||
|
||||
foreach (var kvp in secrets)
|
||||
{
|
||||
var concatenated = string.Join("", kvp.Value);
|
||||
|
||||
// Remove last 44 characters as per Python implementation
|
||||
if (concatenated.Length > 44)
|
||||
{
|
||||
concatenated = concatenated.Substring(0, concatenated.Length - 44);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var bytes = Convert.FromBase64String(concatenated);
|
||||
var decoded = System.Text.Encoding.UTF8.GetString(bytes);
|
||||
decodedSecrets.Add(decoded);
|
||||
_logger.LogDebug("Decoded secret for timezone {Timezone}: {Length} chars", kvp.Key, decoded.Length);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to decode secret for timezone {Timezone}", kvp.Key);
|
||||
}
|
||||
}
|
||||
|
||||
if (decodedSecrets.Count == 0)
|
||||
{
|
||||
throw new Exception("Could not decode any secrets from bundle");
|
||||
}
|
||||
|
||||
return decodedSecrets;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries to decode a base64 string
|
||||
/// </summary>
|
||||
private bool TryDecodeBase64(string input, out string decoded)
|
||||
{
|
||||
decoded = string.Empty;
|
||||
|
||||
try
|
||||
{
|
||||
var bytes = Convert.FromBase64String(input);
|
||||
decoded = System.Text.Encoding.UTF8.GetString(bytes);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
661
octo-fiesta/Services/QobuzDownloadService.cs
Normal file
661
octo-fiesta/Services/QobuzDownloadService.cs
Normal file
@@ -0,0 +1,661 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using octo_fiesta.Models;
|
||||
using Microsoft.Extensions.Options;
|
||||
using IOFile = System.IO.File;
|
||||
|
||||
namespace octo_fiesta.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Download service implementation for Qobuz
|
||||
/// Handles track downloading with MD5 signature for authentication
|
||||
/// </summary>
|
||||
public class QobuzDownloadService : IDownloadService
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly ILocalLibraryService _localLibraryService;
|
||||
private readonly IMusicMetadataService _metadataService;
|
||||
private readonly QobuzBundleService _bundleService;
|
||||
private readonly SubsonicSettings _subsonicSettings;
|
||||
private readonly ILogger<QobuzDownloadService> _logger;
|
||||
|
||||
private readonly string _downloadPath;
|
||||
private readonly string? _userAuthToken;
|
||||
private readonly string? _userId;
|
||||
private readonly string? _preferredQuality;
|
||||
|
||||
private readonly Dictionary<string, DownloadInfo> _activeDownloads = new();
|
||||
private readonly SemaphoreSlim _downloadLock = new(1, 1);
|
||||
|
||||
private const string BaseUrl = "https://www.qobuz.com/api.json/0.2/";
|
||||
|
||||
// Quality format IDs
|
||||
private const int FormatMp3320 = 5;
|
||||
private const int FormatFlac16 = 6; // CD quality (16-bit 44.1kHz)
|
||||
private const int FormatFlac24Low = 7; // 24-bit < 96kHz
|
||||
private const int FormatFlac24High = 27; // 24-bit >= 96kHz
|
||||
|
||||
public QobuzDownloadService(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IConfiguration configuration,
|
||||
ILocalLibraryService localLibraryService,
|
||||
IMusicMetadataService metadataService,
|
||||
QobuzBundleService bundleService,
|
||||
IOptions<SubsonicSettings> subsonicSettings,
|
||||
IOptions<QobuzSettings> qobuzSettings,
|
||||
ILogger<QobuzDownloadService> logger)
|
||||
{
|
||||
_httpClient = httpClientFactory.CreateClient();
|
||||
_configuration = configuration;
|
||||
_localLibraryService = localLibraryService;
|
||||
_metadataService = metadataService;
|
||||
_bundleService = bundleService;
|
||||
_subsonicSettings = subsonicSettings.Value;
|
||||
_logger = logger;
|
||||
|
||||
_downloadPath = configuration["Library:DownloadPath"] ?? "./downloads";
|
||||
|
||||
var qobuzConfig = qobuzSettings.Value;
|
||||
_userAuthToken = qobuzConfig.UserAuthToken;
|
||||
_userId = qobuzConfig.UserId;
|
||||
_preferredQuality = qobuzConfig.Quality;
|
||||
|
||||
if (!Directory.Exists(_downloadPath))
|
||||
{
|
||||
Directory.CreateDirectory(_downloadPath);
|
||||
}
|
||||
}
|
||||
|
||||
#region IDownloadService Implementation
|
||||
|
||||
public async Task<string> DownloadSongAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await DownloadSongInternalAsync(externalProvider, externalId, triggerAlbumDownload: true, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Internal method for downloading a song with control over album download triggering
|
||||
/// </summary>
|
||||
private async Task<string> DownloadSongInternalAsync(string externalProvider, string externalId, bool triggerAlbumDownload, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (externalProvider != "qobuz")
|
||||
{
|
||||
throw new NotSupportedException($"Provider '{externalProvider}' is not supported");
|
||||
}
|
||||
|
||||
var songId = $"ext-{externalProvider}-{externalId}";
|
||||
|
||||
// Check if already downloaded
|
||||
var existingPath = await _localLibraryService.GetLocalPathForExternalSongAsync(externalProvider, externalId);
|
||||
if (existingPath != null && IOFile.Exists(existingPath))
|
||||
{
|
||||
_logger.LogInformation("Song already downloaded: {Path}", existingPath);
|
||||
return existingPath;
|
||||
}
|
||||
|
||||
// Check if download in progress
|
||||
if (_activeDownloads.TryGetValue(songId, out var activeDownload) && activeDownload.Status == DownloadStatus.InProgress)
|
||||
{
|
||||
_logger.LogInformation("Download already in progress for {SongId}", songId);
|
||||
while (_activeDownloads.TryGetValue(songId, out activeDownload) && activeDownload.Status == DownloadStatus.InProgress)
|
||||
{
|
||||
await Task.Delay(500, cancellationToken);
|
||||
}
|
||||
|
||||
if (activeDownload?.Status == DownloadStatus.Completed && activeDownload.LocalPath != null)
|
||||
{
|
||||
return activeDownload.LocalPath;
|
||||
}
|
||||
|
||||
throw new Exception(activeDownload?.ErrorMessage ?? "Download failed");
|
||||
}
|
||||
|
||||
await _downloadLock.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
// Get metadata
|
||||
var song = await _metadataService.GetSongAsync(externalProvider, externalId);
|
||||
if (song == null)
|
||||
{
|
||||
throw new Exception("Song not found");
|
||||
}
|
||||
|
||||
var downloadInfo = new DownloadInfo
|
||||
{
|
||||
SongId = songId,
|
||||
ExternalId = externalId,
|
||||
ExternalProvider = externalProvider,
|
||||
Status = DownloadStatus.InProgress,
|
||||
StartedAt = DateTime.UtcNow
|
||||
};
|
||||
_activeDownloads[songId] = downloadInfo;
|
||||
|
||||
try
|
||||
{
|
||||
var localPath = await DownloadTrackAsync(externalId, song, cancellationToken);
|
||||
|
||||
downloadInfo.Status = DownloadStatus.Completed;
|
||||
downloadInfo.LocalPath = localPath;
|
||||
downloadInfo.CompletedAt = DateTime.UtcNow;
|
||||
|
||||
song.LocalPath = localPath;
|
||||
await _localLibraryService.RegisterDownloadedSongAsync(song, localPath);
|
||||
|
||||
// Trigger a Subsonic library rescan (with debounce)
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await _localLibraryService.TriggerLibraryScanAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to trigger library scan after download");
|
||||
}
|
||||
});
|
||||
|
||||
// If download mode is Album and triggering is enabled, start background download of remaining tracks
|
||||
if (triggerAlbumDownload && _subsonicSettings.DownloadMode == DownloadMode.Album && !string.IsNullOrEmpty(song.AlbumId))
|
||||
{
|
||||
var albumExternalId = ExtractExternalIdFromAlbumId(song.AlbumId);
|
||||
if (!string.IsNullOrEmpty(albumExternalId))
|
||||
{
|
||||
_logger.LogInformation("Download mode is Album, triggering background download for album {AlbumId}", albumExternalId);
|
||||
DownloadRemainingAlbumTracksInBackground(externalProvider, albumExternalId, externalId);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Download completed: {Path}", localPath);
|
||||
return localPath;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
downloadInfo.Status = DownloadStatus.Failed;
|
||||
downloadInfo.ErrorMessage = ex.Message;
|
||||
_logger.LogError(ex, "Download failed for {SongId}", songId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_downloadLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Stream> DownloadAndStreamAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var localPath = await DownloadSongAsync(externalProvider, externalId, cancellationToken);
|
||||
return IOFile.OpenRead(localPath);
|
||||
}
|
||||
|
||||
public DownloadInfo? GetDownloadStatus(string songId)
|
||||
{
|
||||
_activeDownloads.TryGetValue(songId, out var info);
|
||||
return info;
|
||||
}
|
||||
|
||||
public async Task<bool> IsAvailableAsync()
|
||||
{
|
||||
if (string.IsNullOrEmpty(_userAuthToken) || string.IsNullOrEmpty(_userId))
|
||||
{
|
||||
_logger.LogWarning("Qobuz user auth token or user ID not configured");
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Try to extract app ID and secrets
|
||||
await _bundleService.GetAppIdAsync();
|
||||
await _bundleService.GetSecretsAsync();
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Qobuz service not available");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public void DownloadRemainingAlbumTracksInBackground(string externalProvider, string albumExternalId, string excludeTrackExternalId)
|
||||
{
|
||||
if (externalProvider != "qobuz")
|
||||
{
|
||||
_logger.LogWarning("Provider '{Provider}' is not supported for album download", externalProvider);
|
||||
return;
|
||||
}
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await DownloadRemainingAlbumTracksAsync(albumExternalId, excludeTrackExternalId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to download remaining album tracks for album {AlbumId}", albumExternalId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async Task DownloadRemainingAlbumTracksAsync(string albumExternalId, string excludeTrackExternalId)
|
||||
{
|
||||
_logger.LogInformation("Starting background download for album {AlbumId} (excluding track {TrackId})",
|
||||
albumExternalId, excludeTrackExternalId);
|
||||
|
||||
var album = await _metadataService.GetAlbumAsync("qobuz", albumExternalId);
|
||||
if (album == null)
|
||||
{
|
||||
_logger.LogWarning("Album {AlbumId} not found, cannot download remaining tracks", albumExternalId);
|
||||
return;
|
||||
}
|
||||
|
||||
var tracksToDownload = album.Songs
|
||||
.Where(s => s.ExternalId != excludeTrackExternalId && !string.IsNullOrEmpty(s.ExternalId))
|
||||
.ToList();
|
||||
|
||||
_logger.LogInformation("Found {Count} additional tracks to download for album '{AlbumTitle}'",
|
||||
tracksToDownload.Count, album.Title);
|
||||
|
||||
foreach (var track in tracksToDownload)
|
||||
{
|
||||
try
|
||||
{
|
||||
var existingPath = await _localLibraryService.GetLocalPathForExternalSongAsync("qobuz", track.ExternalId!);
|
||||
if (existingPath != null && IOFile.Exists(existingPath))
|
||||
{
|
||||
_logger.LogDebug("Track {TrackId} already downloaded, skipping", track.ExternalId);
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Downloading track '{Title}' from album '{Album}'", track.Title, album.Title);
|
||||
await DownloadSongInternalAsync("qobuz", track.ExternalId!, triggerAlbumDownload: false, CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to download track {TrackId} '{Title}'", track.ExternalId, track.Title);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Completed background download for album '{AlbumTitle}'", album.Title);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Qobuz Download Methods
|
||||
|
||||
private async Task<string> DownloadTrackAsync(string trackId, Song song, CancellationToken cancellationToken)
|
||||
{
|
||||
// Get the download URL with signature
|
||||
var downloadInfo = await GetTrackDownloadUrlAsync(trackId, cancellationToken);
|
||||
|
||||
_logger.LogInformation("Download URL obtained for: {Title} - {Artist}", song.Title, song.Artist);
|
||||
_logger.LogInformation("Quality: {BitDepth}bit/{SamplingRate}kHz, Format: {MimeType}",
|
||||
downloadInfo.BitDepth, downloadInfo.SamplingRate, downloadInfo.MimeType);
|
||||
|
||||
// Check if it's a demo/sample
|
||||
if (downloadInfo.IsSample)
|
||||
{
|
||||
throw new Exception("Track is only available as a demo/sample");
|
||||
}
|
||||
|
||||
// Determine extension based on MIME type
|
||||
var extension = downloadInfo.MimeType?.Contains("flac") == true ? ".flac" : ".mp3";
|
||||
|
||||
// Build organized folder structure using AlbumArtist (fallback to Artist for singles)
|
||||
var artistForPath = song.AlbumArtist ?? song.Artist;
|
||||
var outputPath = PathHelper.BuildTrackPath(_downloadPath, artistForPath, song.Album, song.Title, song.Track, extension);
|
||||
|
||||
var albumFolder = Path.GetDirectoryName(outputPath)!;
|
||||
EnsureDirectoryExists(albumFolder);
|
||||
|
||||
outputPath = PathHelper.ResolveUniquePath(outputPath);
|
||||
|
||||
// Download the file (Qobuz files are NOT encrypted like Deezer)
|
||||
var response = await _httpClient.GetAsync(downloadInfo.Url, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
await using var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||
await using var outputFile = IOFile.Create(outputPath);
|
||||
|
||||
await responseStream.CopyToAsync(outputFile, cancellationToken);
|
||||
await outputFile.DisposeAsync();
|
||||
|
||||
// Write metadata and cover art
|
||||
await WriteMetadataAsync(outputPath, song, cancellationToken);
|
||||
|
||||
return outputPath;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the download URL for a track with proper MD5 signature
|
||||
/// </summary>
|
||||
private async Task<QobuzDownloadResult> GetTrackDownloadUrlAsync(string trackId, CancellationToken cancellationToken)
|
||||
{
|
||||
var appId = await _bundleService.GetAppIdAsync();
|
||||
var secrets = await _bundleService.GetSecretsAsync();
|
||||
|
||||
if (secrets.Count == 0)
|
||||
{
|
||||
throw new Exception("No secrets available for signing");
|
||||
}
|
||||
|
||||
// Determine format ID based on preferred quality
|
||||
var formatId = GetFormatId(_preferredQuality);
|
||||
|
||||
// Try the preferred quality first, then fallback to lower qualities
|
||||
var formatPriority = GetFormatPriority(formatId);
|
||||
|
||||
Exception? lastException = null;
|
||||
|
||||
// Try each secret with each format
|
||||
foreach (var secret in secrets)
|
||||
{
|
||||
var secretIndex = secrets.IndexOf(secret);
|
||||
foreach (var format in formatPriority)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await TryGetTrackDownloadUrlAsync(trackId, format, secret, cancellationToken);
|
||||
|
||||
// Check if quality was downgraded
|
||||
if (result.WasQualityDowngraded)
|
||||
{
|
||||
_logger.LogWarning("Requested quality not available, Qobuz downgraded to {BitDepth}bit/{SamplingRate}kHz",
|
||||
result.BitDepth, result.SamplingRate);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lastException = ex;
|
||||
_logger.LogDebug("Failed to get download URL with secret {SecretIndex}, format {Format}: {Error}",
|
||||
secretIndex, format, ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Exception($"Failed to get download URL for all secrets and quality formats", lastException);
|
||||
}
|
||||
|
||||
private async Task<QobuzDownloadResult> TryGetTrackDownloadUrlAsync(string trackId, int formatId, string secret, CancellationToken cancellationToken)
|
||||
{
|
||||
var unix = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
var appId = await _bundleService.GetAppIdAsync();
|
||||
var signature = ComputeMD5Signature(trackId, formatId, unix, secret);
|
||||
|
||||
// Build URL with required parameters (app_id goes in header only, not in URL params)
|
||||
var url = $"{BaseUrl}track/getFileUrl?format_id={formatId}&intent=stream&request_ts={unix}&track_id={trackId}&request_sig={signature}";
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
|
||||
// Add required headers (matching qobuz-dl Python implementation)
|
||||
request.Headers.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:83.0) Gecko/20100101 Firefox/83.0");
|
||||
request.Headers.Add("X-App-Id", appId);
|
||||
|
||||
if (!string.IsNullOrEmpty(_userAuthToken))
|
||||
{
|
||||
request.Headers.Add("X-User-Auth-Token", _userAuthToken);
|
||||
}
|
||||
|
||||
var response = await _httpClient.SendAsync(request, cancellationToken);
|
||||
|
||||
// Read response body
|
||||
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
|
||||
// Log error response if not successful
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogDebug("Qobuz getFileUrl failed - Status: {StatusCode}, TrackId: {TrackId}, FormatId: {FormatId}",
|
||||
response.StatusCode, trackId, formatId);
|
||||
throw new HttpRequestException($"Response status code does not indicate success: {response.StatusCode} ({response.ReasonPhrase})");
|
||||
}
|
||||
|
||||
var json = responseBody;
|
||||
var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
|
||||
if (!root.TryGetProperty("url", out var urlElement) || string.IsNullOrEmpty(urlElement.GetString()))
|
||||
{
|
||||
throw new Exception("No download URL in response");
|
||||
}
|
||||
|
||||
var downloadUrl = urlElement.GetString()!;
|
||||
var mimeType = root.TryGetProperty("mime_type", out var mime) ? mime.GetString() : null;
|
||||
var bitDepth = root.TryGetProperty("bit_depth", out var bd) ? bd.GetInt32() : 16;
|
||||
var samplingRate = root.TryGetProperty("sampling_rate", out var sr) ? sr.GetDouble() : 44.1;
|
||||
|
||||
// Check if it's a sample/demo
|
||||
var isSample = root.TryGetProperty("sample", out var sampleEl) && sampleEl.GetBoolean();
|
||||
|
||||
// If sampling_rate is null/0, it's likely a demo
|
||||
if (samplingRate == 0)
|
||||
{
|
||||
isSample = true;
|
||||
}
|
||||
|
||||
// Check for quality restrictions/downgrades
|
||||
var wasDowngraded = false;
|
||||
if (root.TryGetProperty("restrictions", out var restrictions))
|
||||
{
|
||||
foreach (var restriction in restrictions.EnumerateArray())
|
||||
{
|
||||
if (restriction.TryGetProperty("code", out var code))
|
||||
{
|
||||
var codeStr = code.GetString();
|
||||
if (codeStr == "FormatRestrictedByFormatAvailability")
|
||||
{
|
||||
wasDowngraded = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new QobuzDownloadResult
|
||||
{
|
||||
Url = downloadUrl,
|
||||
FormatId = formatId,
|
||||
MimeType = mimeType,
|
||||
BitDepth = bitDepth,
|
||||
SamplingRate = samplingRate,
|
||||
IsSample = isSample,
|
||||
WasQualityDowngraded = wasDowngraded
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes MD5 signature for track download request
|
||||
/// Format based on qobuz-dl: trackgetFileUrlformat_id{X}intentstreamtrack_id{Y}{TIMESTAMP}{SECRET}
|
||||
/// </summary>
|
||||
private string ComputeMD5Signature(string trackId, int formatId, long timestamp, string secret)
|
||||
{
|
||||
// EXACT format from qobuz-dl Python implementation:
|
||||
// "trackgetFileUrlformat_id{}intentstreamtrack_id{}{}{}".format(fmt_id, track_id, unix, secret)
|
||||
var toSign = $"trackgetFileUrlformat_id{formatId}intentstreamtrack_id{trackId}{timestamp}{secret}";
|
||||
|
||||
using var md5 = MD5.Create();
|
||||
var hash = md5.ComputeHash(Encoding.UTF8.GetBytes(toSign));
|
||||
var signature = BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
|
||||
|
||||
return signature;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the format ID based on quality preference
|
||||
/// </summary>
|
||||
private int GetFormatId(string? quality)
|
||||
{
|
||||
if (string.IsNullOrEmpty(quality))
|
||||
{
|
||||
return FormatFlac24High; // Default to highest quality
|
||||
}
|
||||
|
||||
return quality.ToUpperInvariant() switch
|
||||
{
|
||||
"FLAC" => FormatFlac24High,
|
||||
"FLAC_24_HIGH" or "24_192" => FormatFlac24High,
|
||||
"FLAC_24_LOW" or "24_96" => FormatFlac24Low,
|
||||
"FLAC_16" or "CD" => FormatFlac16,
|
||||
"MP3_320" or "MP3" => FormatMp3320,
|
||||
_ => FormatFlac24High
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the list of format IDs to try in priority order (highest to lowest)
|
||||
/// </summary>
|
||||
private List<int> GetFormatPriority(int preferredFormat)
|
||||
{
|
||||
var allFormats = new List<int> { FormatFlac24High, FormatFlac24Low, FormatFlac16, FormatMp3320 };
|
||||
|
||||
// Start with preferred format, then try others in descending quality order
|
||||
var priority = new List<int> { preferredFormat };
|
||||
priority.AddRange(allFormats.Where(f => f != preferredFormat));
|
||||
|
||||
return priority;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes ID3/Vorbis metadata and cover art to the audio file
|
||||
/// </summary>
|
||||
private async Task WriteMetadataAsync(string filePath, Song song, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Writing metadata to: {Path}", filePath);
|
||||
|
||||
using var tagFile = TagLib.File.Create(filePath);
|
||||
|
||||
tagFile.Tag.Title = song.Title;
|
||||
tagFile.Tag.Performers = new[] { song.Artist };
|
||||
tagFile.Tag.Album = song.Album;
|
||||
tagFile.Tag.AlbumArtists = new[] { !string.IsNullOrEmpty(song.AlbumArtist) ? song.AlbumArtist : song.Artist };
|
||||
|
||||
if (song.Track.HasValue)
|
||||
tagFile.Tag.Track = (uint)song.Track.Value;
|
||||
|
||||
if (song.TotalTracks.HasValue)
|
||||
tagFile.Tag.TrackCount = (uint)song.TotalTracks.Value;
|
||||
|
||||
if (song.DiscNumber.HasValue)
|
||||
tagFile.Tag.Disc = (uint)song.DiscNumber.Value;
|
||||
|
||||
if (song.Year.HasValue)
|
||||
tagFile.Tag.Year = (uint)song.Year.Value;
|
||||
|
||||
if (!string.IsNullOrEmpty(song.Genre))
|
||||
tagFile.Tag.Genres = new[] { song.Genre };
|
||||
|
||||
if (song.Bpm.HasValue)
|
||||
tagFile.Tag.BeatsPerMinute = (uint)song.Bpm.Value;
|
||||
|
||||
if (song.Contributors.Count > 0)
|
||||
tagFile.Tag.Composers = song.Contributors.ToArray();
|
||||
|
||||
if (!string.IsNullOrEmpty(song.Copyright))
|
||||
tagFile.Tag.Copyright = song.Copyright;
|
||||
|
||||
var comments = new List<string>();
|
||||
if (!string.IsNullOrEmpty(song.Isrc))
|
||||
comments.Add($"ISRC: {song.Isrc}");
|
||||
|
||||
if (comments.Count > 0)
|
||||
tagFile.Tag.Comment = string.Join(" | ", comments);
|
||||
|
||||
// Download and embed cover art
|
||||
var coverUrl = song.CoverArtUrlLarge ?? song.CoverArtUrl;
|
||||
if (!string.IsNullOrEmpty(coverUrl))
|
||||
{
|
||||
try
|
||||
{
|
||||
var coverData = await DownloadCoverArtAsync(coverUrl, cancellationToken);
|
||||
if (coverData != null && coverData.Length > 0)
|
||||
{
|
||||
var mimeType = coverUrl.Contains(".png") ? "image/png" : "image/jpeg";
|
||||
var picture = new TagLib.Picture
|
||||
{
|
||||
Type = TagLib.PictureType.FrontCover,
|
||||
MimeType = mimeType,
|
||||
Description = "Cover",
|
||||
Data = new TagLib.ByteVector(coverData)
|
||||
};
|
||||
tagFile.Tag.Pictures = new TagLib.IPicture[] { picture };
|
||||
_logger.LogInformation("Cover art embedded: {Size} bytes", coverData.Length);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to download cover art from {Url}", coverUrl);
|
||||
}
|
||||
}
|
||||
|
||||
tagFile.Save();
|
||||
_logger.LogInformation("Metadata written successfully to: {Path}", filePath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to write metadata to: {Path}", filePath);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<byte[]?> DownloadCoverArtAsync(string url, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.GetAsync(url, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
return await response.Content.ReadAsByteArrayAsync(cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to download cover art from {Url}", url);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Utility Methods
|
||||
|
||||
private static string? ExtractExternalIdFromAlbumId(string albumId)
|
||||
{
|
||||
const string prefix = "ext-qobuz-album-";
|
||||
if (albumId.StartsWith(prefix))
|
||||
{
|
||||
return albumId[prefix.Length..];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void EnsureDirectoryExists(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!Directory.Exists(path))
|
||||
{
|
||||
Directory.CreateDirectory(path);
|
||||
_logger.LogDebug("Created directory: {Path}", path);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to create directory: {Path}", path);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private class QobuzDownloadResult
|
||||
{
|
||||
public string Url { get; set; } = string.Empty;
|
||||
public int FormatId { get; set; }
|
||||
public string? MimeType { get; set; }
|
||||
public int BitDepth { get; set; }
|
||||
public double SamplingRate { get; set; }
|
||||
public bool IsSample { get; set; }
|
||||
public bool WasQualityDowngraded { get; set; }
|
||||
}
|
||||
}
|
||||
641
octo-fiesta/Services/QobuzMetadataService.cs
Normal file
641
octo-fiesta/Services/QobuzMetadataService.cs
Normal file
@@ -0,0 +1,641 @@
|
||||
using octo_fiesta.Models;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace octo_fiesta.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Metadata service implementation using the Qobuz API
|
||||
/// Uses user authentication token instead of email/password
|
||||
/// </summary>
|
||||
public class QobuzMetadataService : IMusicMetadataService
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly SubsonicSettings _settings;
|
||||
private readonly QobuzBundleService _bundleService;
|
||||
private readonly ILogger<QobuzMetadataService> _logger;
|
||||
private readonly string? _userAuthToken;
|
||||
private readonly string? _userId;
|
||||
|
||||
private const string BaseUrl = "https://www.qobuz.com/api.json/0.2/";
|
||||
|
||||
public QobuzMetadataService(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IOptions<SubsonicSettings> settings,
|
||||
IOptions<QobuzSettings> qobuzSettings,
|
||||
QobuzBundleService bundleService,
|
||||
ILogger<QobuzMetadataService> logger)
|
||||
{
|
||||
_httpClient = httpClientFactory.CreateClient();
|
||||
_settings = settings.Value;
|
||||
_bundleService = bundleService;
|
||||
_logger = logger;
|
||||
|
||||
var qobuzConfig = qobuzSettings.Value;
|
||||
_userAuthToken = qobuzConfig.UserAuthToken;
|
||||
_userId = qobuzConfig.UserId;
|
||||
|
||||
// Set up default headers
|
||||
_httpClient.DefaultRequestHeaders.Add("User-Agent",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:83.0) Gecko/20100101 Firefox/83.0");
|
||||
}
|
||||
|
||||
public async Task<List<Song>> SearchSongsAsync(string query, int limit = 20)
|
||||
{
|
||||
try
|
||||
{
|
||||
var appId = await _bundleService.GetAppIdAsync();
|
||||
var url = $"{BaseUrl}track/search?query={Uri.EscapeDataString(query)}&limit={limit}&app_id={appId}";
|
||||
|
||||
var response = await GetWithAuthAsync(url);
|
||||
if (!response.IsSuccessStatusCode) return new List<Song>();
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
var result = JsonDocument.Parse(json);
|
||||
|
||||
var songs = new List<Song>();
|
||||
if (result.RootElement.TryGetProperty("tracks", out var tracks) &&
|
||||
tracks.TryGetProperty("items", out var items))
|
||||
{
|
||||
foreach (var track in items.EnumerateArray())
|
||||
{
|
||||
var song = ParseQobuzTrack(track);
|
||||
if (ShouldIncludeSong(song))
|
||||
{
|
||||
songs.Add(song);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return songs;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to search songs for query: {Query}", query);
|
||||
return new List<Song>();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<Album>> SearchAlbumsAsync(string query, int limit = 20)
|
||||
{
|
||||
try
|
||||
{
|
||||
var appId = await _bundleService.GetAppIdAsync();
|
||||
var url = $"{BaseUrl}album/search?query={Uri.EscapeDataString(query)}&limit={limit}&app_id={appId}";
|
||||
|
||||
var response = await GetWithAuthAsync(url);
|
||||
if (!response.IsSuccessStatusCode) return new List<Album>();
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
var result = JsonDocument.Parse(json);
|
||||
|
||||
var albums = new List<Album>();
|
||||
if (result.RootElement.TryGetProperty("albums", out var albumsData) &&
|
||||
albumsData.TryGetProperty("items", out var items))
|
||||
{
|
||||
foreach (var album in items.EnumerateArray())
|
||||
{
|
||||
albums.Add(ParseQobuzAlbum(album));
|
||||
}
|
||||
}
|
||||
|
||||
return albums;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to search albums for query: {Query}", query);
|
||||
return new List<Album>();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<Artist>> SearchArtistsAsync(string query, int limit = 20)
|
||||
{
|
||||
try
|
||||
{
|
||||
var appId = await _bundleService.GetAppIdAsync();
|
||||
var url = $"{BaseUrl}artist/search?query={Uri.EscapeDataString(query)}&limit={limit}&app_id={appId}";
|
||||
|
||||
var response = await GetWithAuthAsync(url);
|
||||
if (!response.IsSuccessStatusCode) return new List<Artist>();
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
var result = JsonDocument.Parse(json);
|
||||
|
||||
var artists = new List<Artist>();
|
||||
if (result.RootElement.TryGetProperty("artists", out var artistsData) &&
|
||||
artistsData.TryGetProperty("items", out var items))
|
||||
{
|
||||
foreach (var artist in items.EnumerateArray())
|
||||
{
|
||||
artists.Add(ParseQobuzArtist(artist));
|
||||
}
|
||||
}
|
||||
|
||||
return artists;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to search artists for query: {Query}", query);
|
||||
return new List<Artist>();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<SearchResult> SearchAllAsync(string query, int songLimit = 20, int albumLimit = 20, int artistLimit = 20)
|
||||
{
|
||||
var songsTask = SearchSongsAsync(query, songLimit);
|
||||
var albumsTask = SearchAlbumsAsync(query, albumLimit);
|
||||
var artistsTask = SearchArtistsAsync(query, artistLimit);
|
||||
|
||||
await Task.WhenAll(songsTask, albumsTask, artistsTask);
|
||||
|
||||
return new SearchResult
|
||||
{
|
||||
Songs = await songsTask,
|
||||
Albums = await albumsTask,
|
||||
Artists = await artistsTask
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<Song?> GetSongAsync(string externalProvider, string externalId)
|
||||
{
|
||||
if (externalProvider != "qobuz") return null;
|
||||
|
||||
try
|
||||
{
|
||||
var appId = await _bundleService.GetAppIdAsync();
|
||||
var url = $"{BaseUrl}track/get?track_id={externalId}&app_id={appId}";
|
||||
|
||||
var response = await GetWithAuthAsync(url);
|
||||
if (!response.IsSuccessStatusCode) return null;
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
var track = JsonDocument.Parse(json).RootElement;
|
||||
|
||||
if (track.TryGetProperty("error", out _)) return null;
|
||||
|
||||
return ParseQobuzTrackFull(track);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to get song {ExternalId}", externalId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Album?> GetAlbumAsync(string externalProvider, string externalId)
|
||||
{
|
||||
if (externalProvider != "qobuz") return null;
|
||||
|
||||
try
|
||||
{
|
||||
var appId = await _bundleService.GetAppIdAsync();
|
||||
var url = $"{BaseUrl}album/get?album_id={externalId}&app_id={appId}";
|
||||
|
||||
var response = await GetWithAuthAsync(url);
|
||||
if (!response.IsSuccessStatusCode) return null;
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
var albumElement = JsonDocument.Parse(json).RootElement;
|
||||
|
||||
if (albumElement.TryGetProperty("error", out _)) return null;
|
||||
|
||||
var album = ParseQobuzAlbum(albumElement);
|
||||
|
||||
// Get album tracks
|
||||
if (albumElement.TryGetProperty("tracks", out var tracks) &&
|
||||
tracks.TryGetProperty("items", out var tracksData))
|
||||
{
|
||||
foreach (var track in tracksData.EnumerateArray())
|
||||
{
|
||||
var song = ParseQobuzTrack(track);
|
||||
if (ShouldIncludeSong(song))
|
||||
{
|
||||
album.Songs.Add(song);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return album;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to get album {ExternalId}", externalId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Artist?> GetArtistAsync(string externalProvider, string externalId)
|
||||
{
|
||||
if (externalProvider != "qobuz") return null;
|
||||
|
||||
try
|
||||
{
|
||||
var appId = await _bundleService.GetAppIdAsync();
|
||||
var url = $"{BaseUrl}artist/get?artist_id={externalId}&app_id={appId}";
|
||||
|
||||
var response = await GetWithAuthAsync(url);
|
||||
if (!response.IsSuccessStatusCode) return null;
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
var artist = JsonDocument.Parse(json).RootElement;
|
||||
|
||||
if (artist.TryGetProperty("error", out _)) return null;
|
||||
|
||||
return ParseQobuzArtist(artist);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to get artist {ExternalId}", externalId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<Album>> GetArtistAlbumsAsync(string externalProvider, string externalId)
|
||||
{
|
||||
if (externalProvider != "qobuz") return new List<Album>();
|
||||
|
||||
try
|
||||
{
|
||||
var albums = new List<Album>();
|
||||
var appId = await _bundleService.GetAppIdAsync();
|
||||
int offset = 0;
|
||||
const int limit = 500;
|
||||
|
||||
// Qobuz requires pagination for artist albums
|
||||
while (true)
|
||||
{
|
||||
var url = $"{BaseUrl}artist/get?artist_id={externalId}&app_id={appId}&limit={limit}&offset={offset}&extra=albums";
|
||||
|
||||
var response = await GetWithAuthAsync(url);
|
||||
if (!response.IsSuccessStatusCode) break;
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
var result = JsonDocument.Parse(json);
|
||||
|
||||
if (!result.RootElement.TryGetProperty("albums", out var albumsData) ||
|
||||
!albumsData.TryGetProperty("items", out var items))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var itemsArray = items.EnumerateArray().ToList();
|
||||
if (itemsArray.Count == 0) break;
|
||||
|
||||
foreach (var album in itemsArray)
|
||||
{
|
||||
albums.Add(ParseQobuzAlbum(album));
|
||||
}
|
||||
|
||||
// If we got less than the limit, we've reached the end
|
||||
if (itemsArray.Count < limit) break;
|
||||
|
||||
offset += limit;
|
||||
}
|
||||
|
||||
return albums;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to get artist albums for {ExternalId}", externalId);
|
||||
return new List<Album>();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Safely gets an ID value as a string, handling both number and string types from JSON
|
||||
/// </summary>
|
||||
private string GetIdAsString(JsonElement element)
|
||||
{
|
||||
return element.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Number => element.GetInt64().ToString(),
|
||||
JsonValueKind.String => element.GetString() ?? "",
|
||||
_ => ""
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Makes an HTTP GET request with Qobuz authentication headers
|
||||
/// </summary>
|
||||
private async Task<HttpResponseMessage> GetWithAuthAsync(string url)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
|
||||
var appId = await _bundleService.GetAppIdAsync();
|
||||
request.Headers.Add("X-App-Id", appId);
|
||||
|
||||
if (!string.IsNullOrEmpty(_userAuthToken))
|
||||
{
|
||||
request.Headers.Add("X-User-Auth-Token", _userAuthToken);
|
||||
}
|
||||
|
||||
return await _httpClient.SendAsync(request);
|
||||
}
|
||||
|
||||
private Song ParseQobuzTrack(JsonElement track)
|
||||
{
|
||||
var externalId = GetIdAsString(track.GetProperty("id"));
|
||||
|
||||
var title = track.GetProperty("title").GetString() ?? "";
|
||||
|
||||
// Add version to title if present (e.g., "Remastered", "Live")
|
||||
if (track.TryGetProperty("version", out var version))
|
||||
{
|
||||
var versionStr = version.GetString();
|
||||
if (!string.IsNullOrEmpty(versionStr))
|
||||
{
|
||||
title = $"{title} ({versionStr})";
|
||||
}
|
||||
}
|
||||
|
||||
// For classical music, prepend work name
|
||||
if (track.TryGetProperty("work", out var work))
|
||||
{
|
||||
var workStr = work.GetString();
|
||||
if (!string.IsNullOrEmpty(workStr))
|
||||
{
|
||||
title = $"{workStr}: {title}";
|
||||
}
|
||||
}
|
||||
|
||||
var performerName = track.TryGetProperty("performer", out var performer)
|
||||
? performer.GetProperty("name").GetString() ?? ""
|
||||
: "";
|
||||
|
||||
var albumTitle = track.TryGetProperty("album", out var album)
|
||||
? album.GetProperty("title").GetString() ?? ""
|
||||
: "";
|
||||
|
||||
var albumId = track.TryGetProperty("album", out var albumForId)
|
||||
? $"ext-qobuz-album-{GetIdAsString(albumForId.GetProperty("id"))}"
|
||||
: null;
|
||||
|
||||
// Get album artist
|
||||
var albumArtist = track.TryGetProperty("album", out var albumForArtist) &&
|
||||
albumForArtist.TryGetProperty("artist", out var albumArtistEl)
|
||||
? albumArtistEl.GetProperty("name").GetString()
|
||||
: performerName;
|
||||
|
||||
return new Song
|
||||
{
|
||||
Id = $"ext-qobuz-song-{externalId}",
|
||||
Title = title,
|
||||
Artist = performerName,
|
||||
ArtistId = track.TryGetProperty("performer", out var performerForId)
|
||||
? $"ext-qobuz-artist-{GetIdAsString(performerForId.GetProperty("id"))}"
|
||||
: null,
|
||||
Album = albumTitle,
|
||||
AlbumId = albumId,
|
||||
AlbumArtist = albumArtist,
|
||||
Duration = track.TryGetProperty("duration", out var duration)
|
||||
? duration.GetInt32()
|
||||
: null,
|
||||
Track = track.TryGetProperty("track_number", out var trackNum)
|
||||
? trackNum.GetInt32()
|
||||
: null,
|
||||
DiscNumber = track.TryGetProperty("media_number", out var mediaNum)
|
||||
? mediaNum.GetInt32()
|
||||
: null,
|
||||
CoverArtUrl = GetCoverArtUrl(track),
|
||||
IsLocal = false,
|
||||
ExternalProvider = "qobuz",
|
||||
ExternalId = externalId
|
||||
};
|
||||
}
|
||||
|
||||
private Song ParseQobuzTrackFull(JsonElement track)
|
||||
{
|
||||
var song = ParseQobuzTrack(track);
|
||||
|
||||
// Add additional metadata for full track
|
||||
if (track.TryGetProperty("composer", out var composer) &&
|
||||
composer.TryGetProperty("name", out var composerName))
|
||||
{
|
||||
song.Contributors = new List<string> { composerName.GetString() ?? "" };
|
||||
}
|
||||
|
||||
if (track.TryGetProperty("isrc", out var isrc))
|
||||
{
|
||||
song.Isrc = isrc.GetString();
|
||||
}
|
||||
|
||||
if (track.TryGetProperty("copyright", out var copyright))
|
||||
{
|
||||
song.Copyright = FormatCopyright(copyright.GetString() ?? "");
|
||||
}
|
||||
|
||||
// Get release date from album
|
||||
if (track.TryGetProperty("album", out var album))
|
||||
{
|
||||
if (album.TryGetProperty("release_date_original", out var releaseDate))
|
||||
{
|
||||
var dateStr = releaseDate.GetString();
|
||||
song.ReleaseDate = dateStr;
|
||||
|
||||
if (!string.IsNullOrEmpty(dateStr) && dateStr.Length >= 4)
|
||||
{
|
||||
if (int.TryParse(dateStr.Substring(0, 4), out var year))
|
||||
{
|
||||
song.Year = year;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (album.TryGetProperty("tracks_count", out var tracksCount))
|
||||
{
|
||||
song.TotalTracks = tracksCount.GetInt32();
|
||||
}
|
||||
|
||||
if (album.TryGetProperty("genres_list", out var genres))
|
||||
{
|
||||
song.Genre = FormatGenres(genres);
|
||||
}
|
||||
|
||||
// Get large cover art
|
||||
song.CoverArtUrlLarge = GetLargeCoverArtUrl(album);
|
||||
}
|
||||
|
||||
return song;
|
||||
}
|
||||
|
||||
private Album ParseQobuzAlbum(JsonElement album)
|
||||
{
|
||||
var externalId = GetIdAsString(album.GetProperty("id"));
|
||||
|
||||
var title = album.GetProperty("title").GetString() ?? "";
|
||||
|
||||
// Add version to title if present
|
||||
if (album.TryGetProperty("version", out var version))
|
||||
{
|
||||
var versionStr = version.GetString();
|
||||
if (!string.IsNullOrEmpty(versionStr))
|
||||
{
|
||||
title = $"{title} ({versionStr})";
|
||||
}
|
||||
}
|
||||
|
||||
var artistName = album.TryGetProperty("artist", out var artist)
|
||||
? artist.GetProperty("name").GetString() ?? ""
|
||||
: "";
|
||||
|
||||
int? year = null;
|
||||
if (album.TryGetProperty("release_date_original", out var releaseDate))
|
||||
{
|
||||
var dateStr = releaseDate.GetString();
|
||||
if (!string.IsNullOrEmpty(dateStr) && dateStr.Length >= 4)
|
||||
{
|
||||
if (int.TryParse(dateStr.Substring(0, 4), out var y))
|
||||
{
|
||||
year = y;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new Album
|
||||
{
|
||||
Id = $"ext-qobuz-album-{externalId}",
|
||||
Title = title,
|
||||
Artist = artistName,
|
||||
ArtistId = album.TryGetProperty("artist", out var artistForId)
|
||||
? $"ext-qobuz-artist-{GetIdAsString(artistForId.GetProperty("id"))}"
|
||||
: null,
|
||||
Year = year,
|
||||
SongCount = album.TryGetProperty("tracks_count", out var tracksCount)
|
||||
? tracksCount.GetInt32()
|
||||
: null,
|
||||
CoverArtUrl = GetCoverArtUrl(album),
|
||||
Genre = album.TryGetProperty("genres_list", out var genres)
|
||||
? FormatGenres(genres)
|
||||
: null,
|
||||
IsLocal = false,
|
||||
ExternalProvider = "qobuz",
|
||||
ExternalId = externalId
|
||||
};
|
||||
}
|
||||
|
||||
private Artist ParseQobuzArtist(JsonElement artist)
|
||||
{
|
||||
var externalId = GetIdAsString(artist.GetProperty("id"));
|
||||
|
||||
return new Artist
|
||||
{
|
||||
Id = $"ext-qobuz-artist-{externalId}",
|
||||
Name = artist.GetProperty("name").GetString() ?? "",
|
||||
ImageUrl = GetArtistImageUrl(artist),
|
||||
AlbumCount = artist.TryGetProperty("albums_count", out var albumsCount)
|
||||
? albumsCount.GetInt32()
|
||||
: null,
|
||||
IsLocal = false,
|
||||
ExternalProvider = "qobuz",
|
||||
ExternalId = externalId
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts cover art URL from track or album element
|
||||
/// </summary>
|
||||
private string? GetCoverArtUrl(JsonElement element)
|
||||
{
|
||||
// For tracks, get album image
|
||||
if (element.TryGetProperty("album", out var album))
|
||||
{
|
||||
element = album;
|
||||
}
|
||||
|
||||
if (element.TryGetProperty("image", out var image))
|
||||
{
|
||||
// Prefer thumbnail (230x230), fallback to small
|
||||
if (image.TryGetProperty("thumbnail", out var thumbnail))
|
||||
{
|
||||
return thumbnail.GetString();
|
||||
}
|
||||
if (image.TryGetProperty("small", out var small))
|
||||
{
|
||||
return small.GetString();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets large cover art URL (600x600 or original)
|
||||
/// </summary>
|
||||
private string? GetLargeCoverArtUrl(JsonElement album)
|
||||
{
|
||||
if (album.TryGetProperty("image", out var image) &&
|
||||
image.TryGetProperty("large", out var large))
|
||||
{
|
||||
var url = large.GetString();
|
||||
// Replace _600.jpg with _org.jpg for original quality
|
||||
return url?.Replace("_600.jpg", "_org.jpg");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets artist image URL
|
||||
/// </summary>
|
||||
private string? GetArtistImageUrl(JsonElement artist)
|
||||
{
|
||||
if (artist.TryGetProperty("image", out var image) &&
|
||||
image.TryGetProperty("large", out var large))
|
||||
{
|
||||
return large.GetString();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats Qobuz genre list into a readable string
|
||||
/// Example: ["Pop/Rock", "Pop/Rock→Rock"] becomes "Pop, Rock"
|
||||
/// </summary>
|
||||
private string FormatGenres(JsonElement genresList)
|
||||
{
|
||||
var genres = new List<string>();
|
||||
|
||||
foreach (var genre in genresList.EnumerateArray())
|
||||
{
|
||||
var genreStr = genre.GetString();
|
||||
if (!string.IsNullOrEmpty(genreStr))
|
||||
{
|
||||
// Extract individual genres from paths like "Pop/Rock→Rock→Alternative"
|
||||
var parts = genreStr.Split(new[] { '/', '→' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
foreach (var part in parts)
|
||||
{
|
||||
var trimmed = part.Trim();
|
||||
if (!genres.Contains(trimmed))
|
||||
{
|
||||
genres.Add(trimmed);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return string.Join(", ", genres);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats copyright string
|
||||
/// Replaces (P) with ℗ and (C) with ©
|
||||
/// </summary>
|
||||
private string FormatCopyright(string copyright)
|
||||
{
|
||||
return copyright
|
||||
.Replace("(P)", "℗")
|
||||
.Replace("(C)", "©");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether a song should be included based on the explicit content filter setting
|
||||
/// Note: Qobuz doesn't have the same explicit content tagging as Deezer, so this is a no-op for now
|
||||
/// </summary>
|
||||
private bool ShouldIncludeSong(Song song)
|
||||
{
|
||||
// Qobuz API doesn't expose explicit content flags in the same way as Deezer
|
||||
// We could implement this in the future if needed
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -7,21 +7,24 @@ namespace octo_fiesta.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Hosted service that validates configuration at startup and logs the results.
|
||||
/// Checks connectivity to Subsonic server and validates Deezer ARL token.
|
||||
/// Checks connectivity to Subsonic server and validates music service credentials (Deezer or Qobuz).
|
||||
/// Uses a dedicated HttpClient without logging to keep console output clean.
|
||||
/// </summary>
|
||||
public class StartupValidationService : IHostedService
|
||||
{
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly IOptions<SubsonicSettings> _subsonicSettings;
|
||||
private readonly IOptions<QobuzSettings> _qobuzSettings;
|
||||
private readonly HttpClient _httpClient;
|
||||
|
||||
public StartupValidationService(
|
||||
IConfiguration configuration,
|
||||
IOptions<SubsonicSettings> subsonicSettings)
|
||||
IOptions<SubsonicSettings> subsonicSettings,
|
||||
IOptions<QobuzSettings> qobuzSettings)
|
||||
{
|
||||
_configuration = configuration;
|
||||
_subsonicSettings = subsonicSettings;
|
||||
_qobuzSettings = qobuzSettings;
|
||||
// Create a dedicated HttpClient without logging to keep startup output clean
|
||||
_httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
|
||||
}
|
||||
@@ -35,7 +38,17 @@ public class StartupValidationService : IHostedService
|
||||
Console.WriteLine();
|
||||
|
||||
await ValidateSubsonicAsync(cancellationToken);
|
||||
await ValidateDeezerArlAsync(cancellationToken);
|
||||
|
||||
// Validate music service credentials based on configured service
|
||||
var musicService = _subsonicSettings.Value.MusicService;
|
||||
if (musicService == MusicService.Qobuz)
|
||||
{
|
||||
await ValidateQobuzAsync(cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
await ValidateDeezerArlAsync(cancellationToken);
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("========================================");
|
||||
@@ -141,6 +154,112 @@ public class StartupValidationService : IHostedService
|
||||
}
|
||||
}
|
||||
|
||||
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})";
|
||||
|
||||
Reference in New Issue
Block a user