diff --git a/.env.example b/.env.example
index b5411e4..cde2e68 100644
--- a/.env.example
+++ b/.env.example
@@ -4,7 +4,11 @@ SUBSONIC_URL=http://localhost:4533
# Path where downloaded songs will be stored on the host
DOWNLOAD_PATH=./downloads
-# Deezer ARL token (required)
+# Music service to use: Deezer or Qobuz (default: Deezer)
+MUSIC_SERVICE=Deezer
+
+# ===== DEEZER CONFIGURATION =====
+# Deezer ARL token (required if using Deezer)
# See README.md for instructions on how to get this token
DEEZER_ARL=your-deezer-arl-token
@@ -15,10 +19,26 @@ DEEZER_ARL_FALLBACK=
# If not specified, the highest available quality for your account will be used
DEEZER_QUALITY=
+# ===== QOBUZ CONFIGURATION =====
+# Qobuz user authentication token (required if using Qobuz)
+# Get this from your browser after logging into play.qobuz.com
+# See README.md for detailed instructions
+QOBUZ_USER_AUTH_TOKEN=
+
+# Qobuz user ID (required if using Qobuz)
+# Get this from your browser after logging into play.qobuz.com
+QOBUZ_USER_ID=
+
+# Preferred audio quality: FLAC, FLAC_24_HIGH, FLAC_24_LOW, FLAC_16, MP3_320 (optional)
+# If not specified, the highest available quality will be used
+QOBUZ_QUALITY=
+
+# ===== GENERAL SETTINGS =====
# Explicit content filter (optional, default: All)
# - All: Show all tracks (no filtering)
# - ExplicitOnly: Exclude clean/edited versions, keep original explicit content
# - CleanOnly: Only show clean content (naturally clean or edited versions)
+# Note: This only works with Deezer, Qobuz doesn't expose explicit content flags
EXPLICIT_FILTER=All
# Download mode (optional, default: Track)
diff --git a/octo-fiesta/Models/QobuzSettings.cs b/octo-fiesta/Models/QobuzSettings.cs
new file mode 100644
index 0000000..977ac5e
--- /dev/null
+++ b/octo-fiesta/Models/QobuzSettings.cs
@@ -0,0 +1,25 @@
+namespace octo_fiesta.Models;
+
+///
+/// Configuration for the Qobuz downloader and metadata service
+///
+public class QobuzSettings
+{
+ ///
+ /// Qobuz user authentication token
+ /// Obtained from browser's localStorage after logging into play.qobuz.com
+ ///
+ public string? UserAuthToken { get; set; }
+
+ ///
+ /// Qobuz user ID
+ /// Obtained from browser's localStorage after logging into play.qobuz.com
+ ///
+ public string? UserId { get; set; }
+
+ ///
+ /// Preferred audio quality: FLAC, MP3_320, MP3_128
+ /// If not specified or unavailable, the highest available quality will be used.
+ ///
+ public string? Quality { get; set; }
+}
diff --git a/octo-fiesta/Models/SubsonicSettings.cs b/octo-fiesta/Models/SubsonicSettings.cs
index 66eea3f..c04dca4 100644
--- a/octo-fiesta/Models/SubsonicSettings.cs
+++ b/octo-fiesta/Models/SubsonicSettings.cs
@@ -40,6 +40,22 @@ public enum ExplicitFilter
CleanOnly
}
+///
+/// Music service provider
+///
+public enum MusicService
+{
+ ///
+ /// Deezer music service
+ ///
+ Deezer,
+
+ ///
+ /// Qobuz music service
+ ///
+ 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
///
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)
///
public DownloadMode DownloadMode { get; set; } = DownloadMode.Track;
+
+ ///
+ /// Music service to use (default: Deezer)
+ /// Environment variable: MUSIC_SERVICE
+ /// Values: "Deezer", "Qobuz"
+ ///
+ public MusicService MusicService { get; set; } = MusicService.Deezer;
}
\ No newline at end of file
diff --git a/octo-fiesta/Program.cs b/octo-fiesta/Program.cs
index c8e0722..1d9bc8c 100644
--- a/octo-fiesta/Program.cs
+++ b/octo-fiesta/Program.cs
@@ -13,12 +13,30 @@ builder.Services.AddSwaggerGen();
// Configuration
builder.Services.Configure(
builder.Configuration.GetSection("Subsonic"));
+builder.Services.Configure(
+ builder.Configuration.GetSection("Qobuz"));
+
+// Get the configured music service
+var musicService = builder.Configuration.GetValue("Subsonic:MusicService");
// Business services
// Registered as Singleton to share state (mappings cache, scan debounce, download tracking, rate limiting)
builder.Services.AddSingleton();
-builder.Services.AddSingleton();
-builder.Services.AddSingleton();
+
+// Register music service based on configuration
+if (musicService == MusicService.Qobuz)
+{
+ // Qobuz services
+ builder.Services.AddSingleton();
+ builder.Services.AddSingleton();
+ builder.Services.AddSingleton();
+}
+else
+{
+ // Deezer services (default)
+ builder.Services.AddSingleton();
+ builder.Services.AddSingleton();
+}
// Startup validation - runs at application startup to validate configuration
builder.Services.AddHostedService();
@@ -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();
\ No newline at end of file
diff --git a/octo-fiesta/Services/QobuzBundleService.cs b/octo-fiesta/Services/QobuzBundleService.cs
new file mode 100644
index 0000000..ca7a97d
--- /dev/null
+++ b/octo-fiesta/Services/QobuzBundleService.cs
@@ -0,0 +1,286 @@
+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;
+ }
+ }
+}
diff --git a/octo-fiesta/Services/QobuzDownloadService.cs b/octo-fiesta/Services/QobuzDownloadService.cs
new file mode 100644
index 0000000..c59399b
--- /dev/null
+++ b/octo-fiesta/Services/QobuzDownloadService.cs
@@ -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;
+
+///
+/// Download service implementation for Qobuz
+/// Handles track downloading with MD5 signature for authentication
+///
+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 _logger;
+
+ private readonly string _downloadPath;
+ private readonly string? _userAuthToken;
+ private readonly string? _userId;
+ private readonly string? _preferredQuality;
+
+ private readonly Dictionary _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,
+ IOptions qobuzSettings,
+ ILogger 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 DownloadSongAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default)
+ {
+ return await DownloadSongInternalAsync(externalProvider, externalId, triggerAlbumDownload: true, cancellationToken);
+ }
+
+ ///
+ /// Internal method for downloading a song with control over album download triggering
+ ///
+ private async Task 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 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 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 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;
+ }
+
+ ///
+ /// Gets the download URL for a track with proper MD5 signature
+ ///
+ private async Task 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 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
+ };
+ }
+
+ ///
+ /// Computes MD5 signature for track download request
+ /// Format based on qobuz-dl: trackgetFileUrlformat_id{X}intentstreamtrack_id{Y}{TIMESTAMP}{SECRET}
+ ///
+ 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;
+ }
+
+ ///
+ /// Gets the format ID based on quality preference
+ ///
+ 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
+ };
+ }
+
+ ///
+ /// Gets the list of format IDs to try in priority order (highest to lowest)
+ ///
+ private List GetFormatPriority(int preferredFormat)
+ {
+ var allFormats = new List { FormatFlac24High, FormatFlac24Low, FormatFlac16, FormatMp3320 };
+
+ // Start with preferred format, then try others in descending quality order
+ var priority = new List { preferredFormat };
+ priority.AddRange(allFormats.Where(f => f != preferredFormat));
+
+ return priority;
+ }
+
+ ///
+ /// Writes ID3/Vorbis metadata and cover art to the audio file
+ ///
+ 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();
+ 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 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; }
+ }
+}
diff --git a/octo-fiesta/Services/QobuzMetadataService.cs b/octo-fiesta/Services/QobuzMetadataService.cs
new file mode 100644
index 0000000..3e563dc
--- /dev/null
+++ b/octo-fiesta/Services/QobuzMetadataService.cs
@@ -0,0 +1,641 @@
+using octo_fiesta.Models;
+using System.Text.Json;
+using Microsoft.Extensions.Options;
+
+namespace octo_fiesta.Services;
+
+///
+/// Metadata service implementation using the Qobuz API
+/// Uses user authentication token instead of email/password
+///
+public class QobuzMetadataService : IMusicMetadataService
+{
+ private readonly HttpClient _httpClient;
+ private readonly SubsonicSettings _settings;
+ private readonly QobuzBundleService _bundleService;
+ private readonly ILogger _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 settings,
+ IOptions qobuzSettings,
+ QobuzBundleService bundleService,
+ ILogger 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> 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();
+
+ var json = await response.Content.ReadAsStringAsync();
+ var result = JsonDocument.Parse(json);
+
+ var songs = new List();
+ 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();
+ }
+ }
+
+ public async Task> 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();
+
+ var json = await response.Content.ReadAsStringAsync();
+ var result = JsonDocument.Parse(json);
+
+ var albums = new List();
+ 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();
+ }
+ }
+
+ public async Task> 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();
+
+ var json = await response.Content.ReadAsStringAsync();
+ var result = JsonDocument.Parse(json);
+
+ var artists = new List();
+ 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();
+ }
+ }
+
+ public async Task 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 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 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 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> GetArtistAlbumsAsync(string externalProvider, string externalId)
+ {
+ if (externalProvider != "qobuz") return new List();
+
+ try
+ {
+ var albums = new List();
+ 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();
+ }
+ }
+
+ ///
+ /// Safely gets an ID value as a string, handling both number and string types from JSON
+ ///
+ private string GetIdAsString(JsonElement element)
+ {
+ return element.ValueKind switch
+ {
+ JsonValueKind.Number => element.GetInt64().ToString(),
+ JsonValueKind.String => element.GetString() ?? "",
+ _ => ""
+ };
+ }
+
+ ///
+ /// Makes an HTTP GET request with Qobuz authentication headers
+ ///
+ private async Task 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 { 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
+ };
+ }
+
+ ///
+ /// Extracts cover art URL from track or album element
+ ///
+ 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;
+ }
+
+ ///
+ /// Gets large cover art URL (600x600 or original)
+ ///
+ 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;
+ }
+
+ ///
+ /// Gets artist image URL
+ ///
+ private string? GetArtistImageUrl(JsonElement artist)
+ {
+ if (artist.TryGetProperty("image", out var image) &&
+ image.TryGetProperty("large", out var large))
+ {
+ return large.GetString();
+ }
+
+ return null;
+ }
+
+ ///
+ /// Formats Qobuz genre list into a readable string
+ /// Example: ["Pop/Rock", "Pop/Rock→Rock"] becomes "Pop, Rock"
+ ///
+ private string FormatGenres(JsonElement genresList)
+ {
+ var genres = new List();
+
+ 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);
+ }
+
+ ///
+ /// Formats copyright string
+ /// Replaces (P) with ℗ and (C) with ©
+ ///
+ private string FormatCopyright(string copyright)
+ {
+ return copyright
+ .Replace("(P)", "℗")
+ .Replace("(C)", "©");
+ }
+
+ ///
+ /// 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
+ ///
+ 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;
+ }
+}
diff --git a/octo-fiesta/Services/StartupValidationService.cs b/octo-fiesta/Services/StartupValidationService.cs
index 07580cc..81d4179 100644
--- a/octo-fiesta/Services/StartupValidationService.cs
+++ b/octo-fiesta/Services/StartupValidationService.cs
@@ -7,21 +7,24 @@ namespace octo_fiesta.Services;
///
/// 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.
///
public class StartupValidationService : IHostedService
{
private readonly IConfiguration _configuration;
private readonly IOptions _subsonicSettings;
+ private readonly IOptions _qobuzSettings;
private readonly HttpClient _httpClient;
public StartupValidationService(
IConfiguration configuration,
- IOptions subsonicSettings)
+ IOptions subsonicSettings,
+ IOptions 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})";