From c38291efa331c259b3af11b04b9c30287cc40356 Mon Sep 17 00:00:00 2001 From: V1ck3s Date: Thu, 8 Jan 2026 19:39:06 +0100 Subject: [PATCH] refactor: extract BaseDownloadService to eliminate code duplication between providers --- .../Services/Common/BaseDownloadService.cs | 402 ++++++++++++++ .../Services/Deezer/DeezerDownloadService.cs | 510 +++--------------- .../Services/Qobuz/QobuzDownloadService.cs | 400 +------------- 3 files changed, 495 insertions(+), 817 deletions(-) create mode 100644 octo-fiesta/Services/Common/BaseDownloadService.cs diff --git a/octo-fiesta/Services/Common/BaseDownloadService.cs b/octo-fiesta/Services/Common/BaseDownloadService.cs new file mode 100644 index 0000000..710bfe4 --- /dev/null +++ b/octo-fiesta/Services/Common/BaseDownloadService.cs @@ -0,0 +1,402 @@ +using octo_fiesta.Models; +using octo_fiesta.Services.Local; +using octo_fiesta.Services.Deezer; +using TagLib; +using IOFile = System.IO.File; + +namespace octo_fiesta.Services.Common; + +/// +/// Abstract base class for download services. +/// Implements common download logic, tracking, and metadata writing. +/// Subclasses implement provider-specific download and authentication logic. +/// +public abstract class BaseDownloadService : IDownloadService +{ + protected readonly IConfiguration Configuration; + protected readonly ILocalLibraryService LocalLibraryService; + protected readonly IMusicMetadataService MetadataService; + protected readonly SubsonicSettings SubsonicSettings; + protected readonly ILogger Logger; + + protected readonly string DownloadPath; + + protected readonly Dictionary ActiveDownloads = new(); + protected readonly SemaphoreSlim DownloadLock = new(1, 1); + + /// + /// Provider name (e.g., "deezer", "qobuz") + /// + protected abstract string ProviderName { get; } + + protected BaseDownloadService( + IConfiguration configuration, + ILocalLibraryService localLibraryService, + IMusicMetadataService metadataService, + SubsonicSettings subsonicSettings, + ILogger logger) + { + Configuration = configuration; + LocalLibraryService = localLibraryService; + MetadataService = metadataService; + SubsonicSettings = subsonicSettings; + Logger = logger; + + DownloadPath = configuration["Library:DownloadPath"] ?? "./downloads"; + + 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); + } + + 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 abstract Task IsAvailableAsync(); + + public void DownloadRemainingAlbumTracksInBackground(string externalProvider, string albumExternalId, string excludeTrackExternalId) + { + if (externalProvider != ProviderName) + { + 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); + } + }); + } + + #endregion + + #region Template Methods (to be implemented by subclasses) + + /// + /// Downloads a track and saves it to disk. + /// Subclasses implement provider-specific logic (encryption, authentication, etc.) + /// + /// External track ID + /// Song metadata + /// Cancellation token + /// Local file path where the track was saved + protected abstract Task DownloadTrackAsync(string trackId, Song song, CancellationToken cancellationToken); + + /// + /// Extracts the external album ID from the internal album ID format. + /// Example: "ext-deezer-album-123456" -> "123456" + /// + protected abstract string? ExtractExternalIdFromAlbumId(string albumId); + + #endregion + + #region Common Download Logic + + /// + /// Internal method for downloading a song with control over album download triggering + /// + protected async Task DownloadSongInternalAsync(string externalProvider, string externalId, bool triggerAlbumDownload, CancellationToken cancellationToken = default) + { + if (externalProvider != ProviderName) + { + 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(); + } + } + + protected 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(ProviderName, 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(ProviderName, 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(ProviderName, 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 Common Metadata Writing + + /// + /// Writes ID3/Vorbis metadata and cover art to the audio file + /// + protected async Task WriteMetadataAsync(string filePath, Song song, CancellationToken cancellationToken) + { + try + { + Logger.LogInformation("Writing metadata to: {Path}", filePath); + + using var tagFile = TagLib.File.Create(filePath); + + // Basic metadata + 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); + } + } + + /// + /// Downloads cover art from a URL + /// + protected async Task DownloadCoverArtAsync(string url, CancellationToken cancellationToken) + { + try + { + using var httpClient = new HttpClient(); + 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 + + /// + /// Ensures a directory exists, creating it and all parent directories if necessary + /// + protected 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 +} diff --git a/octo-fiesta/Services/Deezer/DeezerDownloadService.cs b/octo-fiesta/Services/Deezer/DeezerDownloadService.cs index d023860..e0a2c9c 100644 --- a/octo-fiesta/Services/Deezer/DeezerDownloadService.cs +++ b/octo-fiesta/Services/Deezer/DeezerDownloadService.cs @@ -5,10 +5,9 @@ using Org.BouncyCastle.Crypto.Engines; using Org.BouncyCastle.Crypto.Modes; using Org.BouncyCastle.Crypto.Parameters; using octo_fiesta.Models; -using octo_fiesta.Services; using octo_fiesta.Services.Local; +using octo_fiesta.Services.Common; using Microsoft.Extensions.Options; -using TagLib; using IOFile = System.IO.File; namespace octo_fiesta.Services.Deezer; @@ -17,16 +16,11 @@ namespace octo_fiesta.Services.Deezer; /// C# port of the DeezerDownloader JavaScript /// Handles Deezer authentication, track downloading and decryption /// -public class DeezerDownloadService : IDownloadService +public class DeezerDownloadService : BaseDownloadService { private readonly HttpClient _httpClient; - private readonly IConfiguration _configuration; - private readonly ILocalLibraryService _localLibraryService; - private readonly IMusicMetadataService _metadataService; - private readonly SubsonicSettings _subsonicSettings; - private readonly ILogger _logger; + private readonly SemaphoreSlim _requestLock = new(1, 1); - private readonly string _downloadPath; private readonly string? _arl; private readonly string? _arlFallback; private readonly string? _preferredQuality; @@ -34,10 +28,6 @@ public class DeezerDownloadService : IDownloadService private string? _apiToken; private string? _licenseToken; - private readonly Dictionary _activeDownloads = new(); - private readonly SemaphoreSlim _downloadLock = new(1, 1); - private readonly SemaphoreSlim _requestLock = new(1, 1); - private DateTime _lastRequestTime = DateTime.MinValue; private readonly int _minRequestIntervalMs = 200; @@ -47,6 +37,8 @@ public class DeezerDownloadService : IDownloadService // This is a well-known constant used by the Deezer API, not a user-specific secret private const string BfSecret = "g4el58wc0zvf9na1"; + protected override string ProviderName => "deezer"; + public DeezerDownloadService( IHttpClientFactory httpClientFactory, IConfiguration configuration, @@ -55,162 +47,23 @@ public class DeezerDownloadService : IDownloadService IOptions subsonicSettings, IOptions deezerSettings, ILogger logger) + : base(configuration, localLibraryService, metadataService, subsonicSettings.Value, logger) { _httpClient = httpClientFactory.CreateClient(); - _configuration = configuration; - _localLibraryService = localLibraryService; - _metadataService = metadataService; - _subsonicSettings = subsonicSettings.Value; - _logger = logger; var deezer = deezerSettings.Value; - _downloadPath = configuration["Library:DownloadPath"] ?? "./downloads"; _arl = deezer.Arl; _arlFallback = deezer.ArlFallback; _preferredQuality = deezer.Quality; - - if (!Directory.Exists(_downloadPath)) - { - Directory.CreateDirectory(_downloadPath); - } } - #region IDownloadService Implementation + #region BaseDownloadService 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 - /// - /// If true and DownloadMode is Album, triggers background download of remaining album tracks - private async Task DownloadSongInternalAsync(string externalProvider, string externalId, bool triggerAlbumDownload, CancellationToken cancellationToken = default) - { - if (externalProvider != "deezer") - { - 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) - // Fire-and-forget with error handling to prevent unobserved task exceptions - _ = 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)) - { - // Extract album external ID from AlbumId (format: "ext-deezer-album-{id}") - 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() + public override async Task IsAvailableAsync() { if (string.IsNullOrEmpty(_arl)) { - _logger.LogWarning("Deezer ARL not configured"); + Logger.LogWarning("Deezer ARL not configured"); return false; } @@ -221,76 +74,71 @@ public class DeezerDownloadService : IDownloadService } catch (Exception ex) { - _logger.LogWarning(ex, "Deezer service not available"); + Logger.LogWarning(ex, "Deezer service not available"); return false; } } - public void DownloadRemainingAlbumTracksInBackground(string externalProvider, string albumExternalId, string excludeTrackExternalId) + protected override string? ExtractExternalIdFromAlbumId(string albumId) { - if (externalProvider != "deezer") + const string prefix = "ext-deezer-album-"; + if (albumId.StartsWith(prefix)) { - _logger.LogWarning("Provider '{Provider}' is not supported for album download", externalProvider); - return; + return albumId[prefix.Length..]; } - - // Fire-and-forget with error handling - _ = Task.Run(async () => - { - try - { - await DownloadRemainingAlbumTracksAsync(albumExternalId, excludeTrackExternalId); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to download remaining album tracks for album {AlbumId}", albumExternalId); - } - }); + return null; } - private async Task DownloadRemainingAlbumTracksAsync(string albumExternalId, string excludeTrackExternalId) + protected override async Task DownloadTrackAsync(string trackId, Song song, CancellationToken cancellationToken) { - _logger.LogInformation("Starting background download for album {AlbumId} (excluding track {TrackId})", - albumExternalId, excludeTrackExternalId); + var downloadInfo = await GetTrackDownloadInfoAsync(trackId, cancellationToken); + + Logger.LogInformation("Track token obtained for: {Title} - {Artist}", downloadInfo.Title, downloadInfo.Artist); + Logger.LogInformation("Using format: {Format}", downloadInfo.Format); - // Get album with tracks - var album = await _metadataService.GetAlbumAsync("deezer", albumExternalId); - if (album == null) + // Determine extension based on format + var extension = downloadInfo.Format?.ToUpper() switch { - _logger.LogWarning("Album {AlbumId} not found, cannot download remaining tracks", albumExternalId); - return; - } + "FLAC" => ".flac", + _ => ".mp3" + }; - var tracksToDownload = album.Songs - .Where(s => s.ExternalId != excludeTrackExternalId && !string.IsNullOrEmpty(s.ExternalId)) - .ToList(); + // Build organized folder structure: Artist/Album/Track 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); + + // Create directories if they don't exist + var albumFolder = Path.GetDirectoryName(outputPath)!; + EnsureDirectoryExists(albumFolder); + + // Resolve unique path if file already exists + outputPath = PathHelper.ResolveUniquePath(outputPath); - _logger.LogInformation("Found {Count} additional tracks to download for album '{AlbumTitle}'", - tracksToDownload.Count, album.Title); - - foreach (var track in tracksToDownload) + // Download the encrypted file + var response = await RetryWithBackoffAsync(async () => { - try - { - // Check if already downloaded - var existingPath = await _localLibraryService.GetLocalPathForExternalSongAsync("deezer", track.ExternalId!); - if (existingPath != null && IOFile.Exists(existingPath)) - { - _logger.LogDebug("Track {TrackId} already downloaded, skipping", track.ExternalId); - continue; - } + using var request = new HttpRequestMessage(HttpMethod.Get, downloadInfo.DownloadUrl); + request.Headers.Add("User-Agent", "Mozilla/5.0"); + request.Headers.Add("Accept", "*/*"); + + return await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + }); - _logger.LogInformation("Downloading track '{Title}' from album '{Album}'", track.Title, album.Title); - await DownloadSongInternalAsync("deezer", track.ExternalId!, triggerAlbumDownload: false, CancellationToken.None); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to download track {TrackId} '{Title}'", track.ExternalId, track.Title); - // Continue with other tracks - } - } + response.EnsureSuccessStatusCode(); - _logger.LogInformation("Completed background download for album '{AlbumTitle}'", album.Title); + // Download and decrypt + await using var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken); + await using var outputFile = IOFile.Create(outputPath); + + await DecryptAndWriteStreamAsync(responseStream, outputFile, trackId, cancellationToken); + + // Close file before writing metadata + await outputFile.DisposeAsync(); + + // Write metadata and cover art + await WriteMetadataAsync(outputPath, song, cancellationToken); + + return outputPath; } #endregion @@ -331,7 +179,7 @@ public class DeezerDownloadService : IDownloadService _licenseToken = licenseToken.GetString(); } - _logger.LogInformation("Deezer token refreshed successfully"); + Logger.LogInformation("Deezer token refreshed successfully"); return true; } @@ -434,11 +282,9 @@ public class DeezerDownloadService : IDownloadService } // Log available formats for debugging - _logger.LogInformation("Available formats from Deezer: {Formats}", string.Join(", ", availableFormats.Keys)); + Logger.LogInformation("Available formats from Deezer: {Formats}", string.Join(", ", availableFormats.Keys)); // Quality priority order (highest to lowest) - // Since we already filtered the requested formats based on preference, - // we just need to pick the best one available var qualityPriority = new[] { "FLAC", "MP3_320", "MP3_128" }; string? selectedFormat = null; @@ -460,7 +306,7 @@ public class DeezerDownloadService : IDownloadService throw new Exception("No compatible format found in available media sources"); } - _logger.LogInformation("Selected quality: {Format}", selectedFormat); + Logger.LogInformation("Selected quality: {Format}", selectedFormat); return new DownloadResult { @@ -481,202 +327,13 @@ public class DeezerDownloadService : IDownloadService { if (!string.IsNullOrEmpty(_arlFallback)) { - _logger.LogWarning(ex, "Primary ARL failed, trying fallback ARL..."); + Logger.LogWarning(ex, "Primary ARL failed, trying fallback ARL..."); return await tryDownload(_arlFallback); } throw; } } - private async Task DownloadTrackAsync(string trackId, Song song, CancellationToken cancellationToken) - { - var downloadInfo = await GetTrackDownloadInfoAsync(trackId, cancellationToken); - - _logger.LogInformation("Track token obtained for: {Title} - {Artist}", downloadInfo.Title, downloadInfo.Artist); - _logger.LogInformation("Using format: {Format}", downloadInfo.Format); - - // Determine extension based on format - var extension = downloadInfo.Format?.ToUpper() switch - { - "FLAC" => ".flac", - _ => ".mp3" - }; - - // Build organized folder structure: Artist/Album/Track 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); - - // Create directories if they don't exist - var albumFolder = Path.GetDirectoryName(outputPath)!; - EnsureDirectoryExists(albumFolder); - - // Resolve unique path if file already exists - outputPath = PathHelper.ResolveUniquePath(outputPath); - - // Download the encrypted file - var response = await RetryWithBackoffAsync(async () => - { - using var request = new HttpRequestMessage(HttpMethod.Get, downloadInfo.DownloadUrl); - request.Headers.Add("User-Agent", "Mozilla/5.0"); - request.Headers.Add("Accept", "*/*"); - - return await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); - }); - - response.EnsureSuccessStatusCode(); - - // Download and decrypt - await using var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken); - await using var outputFile = IOFile.Create(outputPath); - - await DecryptAndWriteStreamAsync(responseStream, outputFile, trackId, cancellationToken); - - // Close file before writing metadata - await outputFile.DisposeAsync(); - - // Write metadata and cover art - await WriteMetadataAsync(outputPath, song, cancellationToken); - - return outputPath; - } - - /// - /// 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); - - // Basic metadata - tagFile.Tag.Title = song.Title; - tagFile.Tag.Performers = new[] { song.Artist }; - tagFile.Tag.Album = song.Album; - - // Album artist (may differ from track artist for compilations) - tagFile.Tag.AlbumArtists = new[] { !string.IsNullOrEmpty(song.AlbumArtist) ? song.AlbumArtist : song.Artist }; - - // Track number - if (song.Track.HasValue) - { - tagFile.Tag.Track = (uint)song.Track.Value; - } - - // Total track count - if (song.TotalTracks.HasValue) - { - tagFile.Tag.TrackCount = (uint)song.TotalTracks.Value; - } - - // Disc number - if (song.DiscNumber.HasValue) - { - tagFile.Tag.Disc = (uint)song.DiscNumber.Value; - } - - // Year - if (song.Year.HasValue) - { - tagFile.Tag.Year = (uint)song.Year.Value; - } - - // Genre - if (!string.IsNullOrEmpty(song.Genre)) - { - tagFile.Tag.Genres = new[] { song.Genre }; - } - - // BPM - if (song.Bpm.HasValue) - { - tagFile.Tag.BeatsPerMinute = (uint)song.Bpm.Value; - } - - // ISRC (stored in comment if no dedicated field, or via MusicBrainz ID) - // TagLib doesn't directly support ISRC, but we can add it to comments - var comments = new List(); - if (!string.IsNullOrEmpty(song.Isrc)) - { - comments.Add($"ISRC: {song.Isrc}"); - } - - // Contributors in comments - if (song.Contributors.Count > 0) - { - tagFile.Tag.Composers = song.Contributors.ToArray(); - } - - // Copyright - if (!string.IsNullOrEmpty(song.Copyright)) - { - tagFile.Tag.Copyright = song.Copyright; - } - - // Comment with additional info - 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); - } - } - - // Save changes - tagFile.Save(); - _logger.LogInformation("Metadata written successfully to: {Path}", filePath); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to write metadata to: {Path}", filePath); - // Don't propagate the error - the file is downloaded, just without metadata - } - } - - /// - /// Downloads cover art from a URL - /// - 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 Decryption @@ -759,24 +416,8 @@ public class DeezerDownloadService : IDownloadService #region Utility Methods - /// - /// Extracts the external album ID from the internal album ID format - /// Example: "ext-deezer-album-123456" -> "123456" - /// - private static string? ExtractExternalIdFromAlbumId(string albumId) - { - const string prefix = "ext-deezer-album-"; - if (albumId.StartsWith(prefix)) - { - return albumId[prefix.Length..]; - } - return null; - } - /// /// Builds the list of formats to request from Deezer based on preferred quality. - /// If a specific quality is preferred, only request that quality and lower. - /// This prevents Deezer from returning higher quality formats when user wants a specific one. /// private static object[] BuildFormatsList(string? preferredQuality) { @@ -789,7 +430,6 @@ public class DeezerDownloadService : IDownloadService if (string.IsNullOrEmpty(preferredQuality)) { - // No preference, request all formats (highest quality will be selected) return allFormats; } @@ -797,7 +437,7 @@ public class DeezerDownloadService : IDownloadService return preferred switch { - "FLAC" => allFormats, // Request all, FLAC will be preferred + "FLAC" => allFormats, "MP3_320" => new object[] { new { cipher = "BF_CBC_STRIPE", format = "MP3_320" }, @@ -807,7 +447,7 @@ public class DeezerDownloadService : IDownloadService { new { cipher = "BF_CBC_STRIPE", format = "MP3_128" } }, - _ => allFormats // Unknown preference, request all + _ => allFormats }; } @@ -828,7 +468,7 @@ public class DeezerDownloadService : IDownloadService if (attempt < maxRetries - 1) { var delay = initialDelayMs * (int)Math.Pow(2, attempt); - _logger.LogWarning("Retry attempt {Attempt}/{MaxRetries} after {Delay}ms ({Message})", + Logger.LogWarning("Retry attempt {Attempt}/{MaxRetries} after {Delay}ms ({Message})", attempt + 1, maxRetries, delay, ex.Message); await Task.Delay(delay); } @@ -869,27 +509,6 @@ public class DeezerDownloadService : IDownloadService } } - /// - /// Ensures a directory exists, creating it and all parent directories if necessary. - /// Handles errors gracefully. - /// - 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 DownloadResult @@ -950,7 +569,6 @@ public static class PathHelper /// /// Sanitizes a folder name by removing invalid path characters. - /// Similar to SanitizeFileName but also handles additional folder-specific constraints. /// public static string SanitizeFolderName(string folderName) { diff --git a/octo-fiesta/Services/Qobuz/QobuzDownloadService.cs b/octo-fiesta/Services/Qobuz/QobuzDownloadService.cs index 1c686cc..b5ac195 100644 --- a/octo-fiesta/Services/Qobuz/QobuzDownloadService.cs +++ b/octo-fiesta/Services/Qobuz/QobuzDownloadService.cs @@ -2,9 +2,9 @@ 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 octo_fiesta.Services.Common; +using octo_fiesta.Services.Deezer; using Microsoft.Extensions.Options; using IOFile = System.IO.File; @@ -14,24 +14,14 @@ namespace octo_fiesta.Services.Qobuz; /// Download service implementation for Qobuz /// Handles track downloading with MD5 signature for authentication /// -public class QobuzDownloadService : IDownloadService +public class QobuzDownloadService : BaseDownloadService { 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 @@ -40,6 +30,8 @@ public class QobuzDownloadService : IDownloadService private const int FormatFlac24Low = 7; // 24-bit < 96kHz private const int FormatFlac24High = 27; // 24-bit >= 96kHz + protected override string ProviderName => "qobuz"; + public QobuzDownloadService( IHttpClientFactory httpClientFactory, IConfiguration configuration, @@ -49,252 +41,57 @@ public class QobuzDownloadService : IDownloadService IOptions subsonicSettings, IOptions qobuzSettings, ILogger logger) + : base(configuration, localLibraryService, metadataService, subsonicSettings.Value, 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 + #region BaseDownloadService 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() + public override async Task IsAvailableAsync() { if (string.IsNullOrEmpty(_userAuthToken) || string.IsNullOrEmpty(_userId)) { - _logger.LogWarning("Qobuz user auth token or user ID not configured"); + 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"); + Logger.LogWarning(ex, "Qobuz service not available"); return false; } } - public void DownloadRemainingAlbumTracksInBackground(string externalProvider, string albumExternalId, string excludeTrackExternalId) + protected override string? ExtractExternalIdFromAlbumId(string albumId) { - if (externalProvider != "qobuz") + const string prefix = "ext-qobuz-album-"; + if (albumId.StartsWith(prefix)) { - _logger.LogWarning("Provider '{Provider}' is not supported for album download", externalProvider); - return; + return albumId[prefix.Length..]; } - - _ = Task.Run(async () => - { - try - { - await DownloadRemainingAlbumTracksAsync(albumExternalId, excludeTrackExternalId); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to download remaining album tracks for album {AlbumId}", albumExternalId); - } - }); + return null; } - 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) + protected override 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}", + 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 @@ -308,7 +105,7 @@ public class QobuzDownloadService : IDownloadService // 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 outputPath = PathHelper.BuildTrackPath(DownloadPath, artistForPath, song.Album, song.Title, song.Track, extension); var albumFolder = Path.GetDirectoryName(outputPath)!; EnsureDirectoryExists(albumFolder); @@ -331,6 +128,10 @@ public class QobuzDownloadService : IDownloadService return outputPath; } + #endregion + + #region Qobuz Download Methods + /// /// Gets the download URL for a track with proper MD5 signature /// @@ -365,7 +166,7 @@ public class QobuzDownloadService : IDownloadService // Check if quality was downgraded if (result.WasQualityDowngraded) { - _logger.LogWarning("Requested quality not available, Qobuz downgraded to {BitDepth}bit/{SamplingRate}kHz", + Logger.LogWarning("Requested quality not available, Qobuz downgraded to {BitDepth}bit/{SamplingRate}kHz", result.BitDepth, result.SamplingRate); } @@ -374,7 +175,7 @@ public class QobuzDownloadService : IDownloadService catch (Exception ex) { lastException = ex; - _logger.LogDebug("Failed to get download URL with secret {SecretIndex}, format {Format}: {Error}", + Logger.LogDebug("Failed to get download URL with secret {SecretIndex}, format {Format}: {Error}", secretIndex, format, ex.Message); } } @@ -389,12 +190,10 @@ public class QobuzDownloadService : IDownloadService 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); @@ -404,20 +203,16 @@ public class QobuzDownloadService : IDownloadService } 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}", + 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 doc = JsonDocument.Parse(responseBody); var root = doc.RootElement; if (!root.TryGetProperty("url", out var urlElement) || string.IsNullOrEmpty(urlElement.GetString())) @@ -430,16 +225,12 @@ public class QobuzDownloadService : IDownloadService 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)) { @@ -470,12 +261,9 @@ public class QobuzDownloadService : IDownloadService /// /// 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(); @@ -492,7 +280,7 @@ public class QobuzDownloadService : IDownloadService { if (string.IsNullOrEmpty(quality)) { - return FormatFlac24High; // Default to highest quality + return FormatFlac24High; } return quality.ToUpperInvariant() switch @@ -507,148 +295,18 @@ public class QobuzDownloadService : IDownloadService } /// - /// Gets the list of format IDs to try in priority order (highest to lowest) + /// Gets the list of format IDs to try in priority order /// 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