using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using octo_fiesta.Models;
using octo_fiesta.Services;
using octo_fiesta.Services.Deezer;
using octo_fiesta.Services.Local;
using Microsoft.Extensions.Options;
using IOFile = System.IO.File;
namespace octo_fiesta.Services.Qobuz;
///
/// 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; }
}
}