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