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