using System.Text.RegularExpressions;
namespace octo_fiesta.Services;
///
/// 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
///
public class QobuzBundleService
{
private readonly HttpClient _httpClient;
private readonly ILogger _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(
@"",
RegexOptions.Compiled);
private static readonly Regex AppIdRegex = new(
@"production:\{api:\{appId:""(?\d{9})"",appSecret:""\w{32}""",
RegexOptions.Compiled);
// Cached values (valid for the lifetime of the application)
private string? _cachedAppId;
private List? _cachedSecrets;
private readonly SemaphoreSlim _initLock = new(1, 1);
public QobuzBundleService(IHttpClientFactory httpClientFactory, ILogger 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;
}
///
/// Gets the Qobuz App ID, extracting it from the bundle if not cached
///
public async Task GetAppIdAsync()
{
await EnsureInitializedAsync();
return _cachedAppId!;
}
///
/// Gets the Qobuz secrets list, extracting them from the bundle if not cached
///
public async Task> GetSecretsAsync()
{
await EnsureInitializedAsync();
return _cachedSecrets!;
}
///
/// Gets a specific secret by index (used for signing requests)
///
public async Task 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];
}
///
/// Ensures App ID and secrets are extracted and cached
///
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();
}
}
///
/// Gets the bundle JavaScript URL from the login page
///
private async Task 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;
}
///
/// Downloads the bundle JavaScript file
///
private async Task DownloadBundleAsync(string bundleUrl)
{
var response = await _httpClient.GetAsync(bundleUrl);
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync();
}
///
/// Extracts the App ID from the bundle JavaScript
///
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;
}
///
/// 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
///
private List ExtractSecrets(string bundleJs)
{
var secrets = new Dictionary>();
// Step 1: Extract seed and timezone pairs
// Pattern: [a-z].initialSeed("base64string",window.utimezone.timezone)
var seedTimezonePattern = new Regex(
@"[a-z]\.initialSeed\(""(?[\w=]+)"",window\.utimezone\.(?[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();
}
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> { { 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+/(?{timezones})"",info:""(?[\w=]+)"",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();
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;
}
///
/// Tries to decode a base64 string
///
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;
}
}
}