mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-09 23:55:10 -05:00
refactor: extract BaseDownloadService to eliminate code duplication between providers
This commit is contained in:
402
octo-fiesta/Services/Common/BaseDownloadService.cs
Normal file
402
octo-fiesta/Services/Common/BaseDownloadService.cs
Normal file
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Abstract base class for download services.
|
||||||
|
/// Implements common download logic, tracking, and metadata writing.
|
||||||
|
/// Subclasses implement provider-specific download and authentication logic.
|
||||||
|
/// </summary>
|
||||||
|
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<string, DownloadInfo> ActiveDownloads = new();
|
||||||
|
protected readonly SemaphoreSlim DownloadLock = new(1, 1);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Provider name (e.g., "deezer", "qobuz")
|
||||||
|
/// </summary>
|
||||||
|
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<string> DownloadSongAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await DownloadSongInternalAsync(externalProvider, externalId, triggerAlbumDownload: true, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Stream> 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<bool> 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)
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Downloads a track and saves it to disk.
|
||||||
|
/// Subclasses implement provider-specific logic (encryption, authentication, etc.)
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="trackId">External track ID</param>
|
||||||
|
/// <param name="song">Song metadata</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token</param>
|
||||||
|
/// <returns>Local file path where the track was saved</returns>
|
||||||
|
protected abstract Task<string> DownloadTrackAsync(string trackId, Song song, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extracts the external album ID from the internal album ID format.
|
||||||
|
/// Example: "ext-deezer-album-123456" -> "123456"
|
||||||
|
/// </summary>
|
||||||
|
protected abstract string? ExtractExternalIdFromAlbumId(string albumId);
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Common Download Logic
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Internal method for downloading a song with control over album download triggering
|
||||||
|
/// </summary>
|
||||||
|
protected async Task<string> 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
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writes ID3/Vorbis metadata and cover art to the audio file
|
||||||
|
/// </summary>
|
||||||
|
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<string>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Downloads cover art from a URL
|
||||||
|
/// </summary>
|
||||||
|
protected async Task<byte[]?> 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
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ensures a directory exists, creating it and all parent directories if necessary
|
||||||
|
/// </summary>
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -5,10 +5,9 @@ using Org.BouncyCastle.Crypto.Engines;
|
|||||||
using Org.BouncyCastle.Crypto.Modes;
|
using Org.BouncyCastle.Crypto.Modes;
|
||||||
using Org.BouncyCastle.Crypto.Parameters;
|
using Org.BouncyCastle.Crypto.Parameters;
|
||||||
using octo_fiesta.Models;
|
using octo_fiesta.Models;
|
||||||
using octo_fiesta.Services;
|
|
||||||
using octo_fiesta.Services.Local;
|
using octo_fiesta.Services.Local;
|
||||||
|
using octo_fiesta.Services.Common;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using TagLib;
|
|
||||||
using IOFile = System.IO.File;
|
using IOFile = System.IO.File;
|
||||||
|
|
||||||
namespace octo_fiesta.Services.Deezer;
|
namespace octo_fiesta.Services.Deezer;
|
||||||
@@ -17,16 +16,11 @@ namespace octo_fiesta.Services.Deezer;
|
|||||||
/// C# port of the DeezerDownloader JavaScript
|
/// C# port of the DeezerDownloader JavaScript
|
||||||
/// Handles Deezer authentication, track downloading and decryption
|
/// Handles Deezer authentication, track downloading and decryption
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class DeezerDownloadService : IDownloadService
|
public class DeezerDownloadService : BaseDownloadService
|
||||||
{
|
{
|
||||||
private readonly HttpClient _httpClient;
|
private readonly HttpClient _httpClient;
|
||||||
private readonly IConfiguration _configuration;
|
private readonly SemaphoreSlim _requestLock = new(1, 1);
|
||||||
private readonly ILocalLibraryService _localLibraryService;
|
|
||||||
private readonly IMusicMetadataService _metadataService;
|
|
||||||
private readonly SubsonicSettings _subsonicSettings;
|
|
||||||
private readonly ILogger<DeezerDownloadService> _logger;
|
|
||||||
|
|
||||||
private readonly string _downloadPath;
|
|
||||||
private readonly string? _arl;
|
private readonly string? _arl;
|
||||||
private readonly string? _arlFallback;
|
private readonly string? _arlFallback;
|
||||||
private readonly string? _preferredQuality;
|
private readonly string? _preferredQuality;
|
||||||
@@ -34,10 +28,6 @@ public class DeezerDownloadService : IDownloadService
|
|||||||
private string? _apiToken;
|
private string? _apiToken;
|
||||||
private string? _licenseToken;
|
private string? _licenseToken;
|
||||||
|
|
||||||
private readonly Dictionary<string, DownloadInfo> _activeDownloads = new();
|
|
||||||
private readonly SemaphoreSlim _downloadLock = new(1, 1);
|
|
||||||
private readonly SemaphoreSlim _requestLock = new(1, 1);
|
|
||||||
|
|
||||||
private DateTime _lastRequestTime = DateTime.MinValue;
|
private DateTime _lastRequestTime = DateTime.MinValue;
|
||||||
private readonly int _minRequestIntervalMs = 200;
|
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
|
// This is a well-known constant used by the Deezer API, not a user-specific secret
|
||||||
private const string BfSecret = "g4el58wc0zvf9na1";
|
private const string BfSecret = "g4el58wc0zvf9na1";
|
||||||
|
|
||||||
|
protected override string ProviderName => "deezer";
|
||||||
|
|
||||||
public DeezerDownloadService(
|
public DeezerDownloadService(
|
||||||
IHttpClientFactory httpClientFactory,
|
IHttpClientFactory httpClientFactory,
|
||||||
IConfiguration configuration,
|
IConfiguration configuration,
|
||||||
@@ -55,162 +47,23 @@ public class DeezerDownloadService : IDownloadService
|
|||||||
IOptions<SubsonicSettings> subsonicSettings,
|
IOptions<SubsonicSettings> subsonicSettings,
|
||||||
IOptions<DeezerSettings> deezerSettings,
|
IOptions<DeezerSettings> deezerSettings,
|
||||||
ILogger<DeezerDownloadService> logger)
|
ILogger<DeezerDownloadService> logger)
|
||||||
|
: base(configuration, localLibraryService, metadataService, subsonicSettings.Value, logger)
|
||||||
{
|
{
|
||||||
_httpClient = httpClientFactory.CreateClient();
|
_httpClient = httpClientFactory.CreateClient();
|
||||||
_configuration = configuration;
|
|
||||||
_localLibraryService = localLibraryService;
|
|
||||||
_metadataService = metadataService;
|
|
||||||
_subsonicSettings = subsonicSettings.Value;
|
|
||||||
_logger = logger;
|
|
||||||
|
|
||||||
var deezer = deezerSettings.Value;
|
var deezer = deezerSettings.Value;
|
||||||
_downloadPath = configuration["Library:DownloadPath"] ?? "./downloads";
|
|
||||||
_arl = deezer.Arl;
|
_arl = deezer.Arl;
|
||||||
_arlFallback = deezer.ArlFallback;
|
_arlFallback = deezer.ArlFallback;
|
||||||
_preferredQuality = deezer.Quality;
|
_preferredQuality = deezer.Quality;
|
||||||
|
|
||||||
if (!Directory.Exists(_downloadPath))
|
|
||||||
{
|
|
||||||
Directory.CreateDirectory(_downloadPath);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#region IDownloadService Implementation
|
#region BaseDownloadService Implementation
|
||||||
|
|
||||||
public async Task<string> DownloadSongAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default)
|
public override async Task<bool> IsAvailableAsync()
|
||||||
{
|
|
||||||
return await DownloadSongInternalAsync(externalProvider, externalId, triggerAlbumDownload: true, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Internal method for downloading a song with control over album download triggering
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="triggerAlbumDownload">If true and DownloadMode is Album, triggers background download of remaining album tracks</param>
|
|
||||||
private async Task<string> 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<Stream> 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<bool> IsAvailableAsync()
|
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(_arl))
|
if (string.IsNullOrEmpty(_arl))
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Deezer ARL not configured");
|
Logger.LogWarning("Deezer ARL not configured");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,76 +74,71 @@ public class DeezerDownloadService : IDownloadService
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Deezer service not available");
|
Logger.LogWarning(ex, "Deezer service not available");
|
||||||
return false;
|
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 albumId[prefix.Length..];
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task DownloadRemainingAlbumTracksAsync(string albumExternalId, string excludeTrackExternalId)
|
protected override async Task<string> DownloadTrackAsync(string trackId, Song song, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Starting background download for album {AlbumId} (excluding track {TrackId})",
|
var downloadInfo = await GetTrackDownloadInfoAsync(trackId, cancellationToken);
|
||||||
albumExternalId, excludeTrackExternalId);
|
|
||||||
|
Logger.LogInformation("Track token obtained for: {Title} - {Artist}", downloadInfo.Title, downloadInfo.Artist);
|
||||||
|
Logger.LogInformation("Using format: {Format}", downloadInfo.Format);
|
||||||
|
|
||||||
// Get album with tracks
|
// Determine extension based on format
|
||||||
var album = await _metadataService.GetAlbumAsync("deezer", albumExternalId);
|
var extension = downloadInfo.Format?.ToUpper() switch
|
||||||
if (album == null)
|
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Album {AlbumId} not found, cannot download remaining tracks", albumExternalId);
|
"FLAC" => ".flac",
|
||||||
return;
|
_ => ".mp3"
|
||||||
}
|
};
|
||||||
|
|
||||||
var tracksToDownload = album.Songs
|
// Build organized folder structure: Artist/Album/Track using AlbumArtist (fallback to Artist for singles)
|
||||||
.Where(s => s.ExternalId != excludeTrackExternalId && !string.IsNullOrEmpty(s.ExternalId))
|
var artistForPath = song.AlbumArtist ?? song.Artist;
|
||||||
.ToList();
|
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}'",
|
// Download the encrypted file
|
||||||
tracksToDownload.Count, album.Title);
|
var response = await RetryWithBackoffAsync(async () =>
|
||||||
|
|
||||||
foreach (var track in tracksToDownload)
|
|
||||||
{
|
{
|
||||||
try
|
using var request = new HttpRequestMessage(HttpMethod.Get, downloadInfo.DownloadUrl);
|
||||||
{
|
request.Headers.Add("User-Agent", "Mozilla/5.0");
|
||||||
// Check if already downloaded
|
request.Headers.Add("Accept", "*/*");
|
||||||
var existingPath = await _localLibraryService.GetLocalPathForExternalSongAsync("deezer", track.ExternalId!);
|
|
||||||
if (existingPath != null && IOFile.Exists(existingPath))
|
return await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
||||||
{
|
});
|
||||||
_logger.LogDebug("Track {TrackId} already downloaded, skipping", track.ExternalId);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogInformation("Downloading track '{Title}' from album '{Album}'", track.Title, album.Title);
|
response.EnsureSuccessStatusCode();
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_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
|
#endregion
|
||||||
@@ -331,7 +179,7 @@ public class DeezerDownloadService : IDownloadService
|
|||||||
_licenseToken = licenseToken.GetString();
|
_licenseToken = licenseToken.GetString();
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("Deezer token refreshed successfully");
|
Logger.LogInformation("Deezer token refreshed successfully");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -434,11 +282,9 @@ public class DeezerDownloadService : IDownloadService
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Log available formats for debugging
|
// 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)
|
// 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" };
|
var qualityPriority = new[] { "FLAC", "MP3_320", "MP3_128" };
|
||||||
|
|
||||||
string? selectedFormat = null;
|
string? selectedFormat = null;
|
||||||
@@ -460,7 +306,7 @@ public class DeezerDownloadService : IDownloadService
|
|||||||
throw new Exception("No compatible format found in available media sources");
|
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
|
return new DownloadResult
|
||||||
{
|
{
|
||||||
@@ -481,202 +327,13 @@ public class DeezerDownloadService : IDownloadService
|
|||||||
{
|
{
|
||||||
if (!string.IsNullOrEmpty(_arlFallback))
|
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);
|
return await tryDownload(_arlFallback);
|
||||||
}
|
}
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<string> 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Writes ID3/Vorbis metadata and cover art to the audio file
|
|
||||||
/// </summary>
|
|
||||||
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<string>();
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Downloads cover art from a URL
|
|
||||||
/// </summary>
|
|
||||||
private async Task<byte[]?> 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
|
#endregion
|
||||||
|
|
||||||
#region Decryption
|
#region Decryption
|
||||||
@@ -759,24 +416,8 @@ public class DeezerDownloadService : IDownloadService
|
|||||||
|
|
||||||
#region Utility Methods
|
#region Utility Methods
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Extracts the external album ID from the internal album ID format
|
|
||||||
/// Example: "ext-deezer-album-123456" -> "123456"
|
|
||||||
/// </summary>
|
|
||||||
private static string? ExtractExternalIdFromAlbumId(string albumId)
|
|
||||||
{
|
|
||||||
const string prefix = "ext-deezer-album-";
|
|
||||||
if (albumId.StartsWith(prefix))
|
|
||||||
{
|
|
||||||
return albumId[prefix.Length..];
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Builds the list of formats to request from Deezer based on preferred quality.
|
/// 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.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static object[] BuildFormatsList(string? preferredQuality)
|
private static object[] BuildFormatsList(string? preferredQuality)
|
||||||
{
|
{
|
||||||
@@ -789,7 +430,6 @@ public class DeezerDownloadService : IDownloadService
|
|||||||
|
|
||||||
if (string.IsNullOrEmpty(preferredQuality))
|
if (string.IsNullOrEmpty(preferredQuality))
|
||||||
{
|
{
|
||||||
// No preference, request all formats (highest quality will be selected)
|
|
||||||
return allFormats;
|
return allFormats;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -797,7 +437,7 @@ public class DeezerDownloadService : IDownloadService
|
|||||||
|
|
||||||
return preferred switch
|
return preferred switch
|
||||||
{
|
{
|
||||||
"FLAC" => allFormats, // Request all, FLAC will be preferred
|
"FLAC" => allFormats,
|
||||||
"MP3_320" => new object[]
|
"MP3_320" => new object[]
|
||||||
{
|
{
|
||||||
new { cipher = "BF_CBC_STRIPE", format = "MP3_320" },
|
new { cipher = "BF_CBC_STRIPE", format = "MP3_320" },
|
||||||
@@ -807,7 +447,7 @@ public class DeezerDownloadService : IDownloadService
|
|||||||
{
|
{
|
||||||
new { cipher = "BF_CBC_STRIPE", format = "MP3_128" }
|
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)
|
if (attempt < maxRetries - 1)
|
||||||
{
|
{
|
||||||
var delay = initialDelayMs * (int)Math.Pow(2, attempt);
|
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);
|
attempt + 1, maxRetries, delay, ex.Message);
|
||||||
await Task.Delay(delay);
|
await Task.Delay(delay);
|
||||||
}
|
}
|
||||||
@@ -869,27 +509,6 @@ public class DeezerDownloadService : IDownloadService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Ensures a directory exists, creating it and all parent directories if necessary.
|
|
||||||
/// Handles errors gracefully.
|
|
||||||
/// </summary>
|
|
||||||
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
|
#endregion
|
||||||
|
|
||||||
private class DownloadResult
|
private class DownloadResult
|
||||||
@@ -950,7 +569,6 @@ public static class PathHelper
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Sanitizes a folder name by removing invalid path characters.
|
/// Sanitizes a folder name by removing invalid path characters.
|
||||||
/// Similar to SanitizeFileName but also handles additional folder-specific constraints.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static string SanitizeFolderName(string folderName)
|
public static string SanitizeFolderName(string folderName)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ using System.Security.Cryptography;
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using octo_fiesta.Models;
|
using octo_fiesta.Models;
|
||||||
using octo_fiesta.Services;
|
|
||||||
using octo_fiesta.Services.Deezer;
|
|
||||||
using octo_fiesta.Services.Local;
|
using octo_fiesta.Services.Local;
|
||||||
|
using octo_fiesta.Services.Common;
|
||||||
|
using octo_fiesta.Services.Deezer;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using IOFile = System.IO.File;
|
using IOFile = System.IO.File;
|
||||||
|
|
||||||
@@ -14,24 +14,14 @@ namespace octo_fiesta.Services.Qobuz;
|
|||||||
/// Download service implementation for Qobuz
|
/// Download service implementation for Qobuz
|
||||||
/// Handles track downloading with MD5 signature for authentication
|
/// Handles track downloading with MD5 signature for authentication
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class QobuzDownloadService : IDownloadService
|
public class QobuzDownloadService : BaseDownloadService
|
||||||
{
|
{
|
||||||
private readonly HttpClient _httpClient;
|
private readonly HttpClient _httpClient;
|
||||||
private readonly IConfiguration _configuration;
|
|
||||||
private readonly ILocalLibraryService _localLibraryService;
|
|
||||||
private readonly IMusicMetadataService _metadataService;
|
|
||||||
private readonly QobuzBundleService _bundleService;
|
private readonly QobuzBundleService _bundleService;
|
||||||
private readonly SubsonicSettings _subsonicSettings;
|
|
||||||
private readonly ILogger<QobuzDownloadService> _logger;
|
|
||||||
|
|
||||||
private readonly string _downloadPath;
|
|
||||||
private readonly string? _userAuthToken;
|
private readonly string? _userAuthToken;
|
||||||
private readonly string? _userId;
|
private readonly string? _userId;
|
||||||
private readonly string? _preferredQuality;
|
private readonly string? _preferredQuality;
|
||||||
|
|
||||||
private readonly Dictionary<string, DownloadInfo> _activeDownloads = new();
|
|
||||||
private readonly SemaphoreSlim _downloadLock = new(1, 1);
|
|
||||||
|
|
||||||
private const string BaseUrl = "https://www.qobuz.com/api.json/0.2/";
|
private const string BaseUrl = "https://www.qobuz.com/api.json/0.2/";
|
||||||
|
|
||||||
// Quality format IDs
|
// Quality format IDs
|
||||||
@@ -40,6 +30,8 @@ public class QobuzDownloadService : IDownloadService
|
|||||||
private const int FormatFlac24Low = 7; // 24-bit < 96kHz
|
private const int FormatFlac24Low = 7; // 24-bit < 96kHz
|
||||||
private const int FormatFlac24High = 27; // 24-bit >= 96kHz
|
private const int FormatFlac24High = 27; // 24-bit >= 96kHz
|
||||||
|
|
||||||
|
protected override string ProviderName => "qobuz";
|
||||||
|
|
||||||
public QobuzDownloadService(
|
public QobuzDownloadService(
|
||||||
IHttpClientFactory httpClientFactory,
|
IHttpClientFactory httpClientFactory,
|
||||||
IConfiguration configuration,
|
IConfiguration configuration,
|
||||||
@@ -49,252 +41,57 @@ public class QobuzDownloadService : IDownloadService
|
|||||||
IOptions<SubsonicSettings> subsonicSettings,
|
IOptions<SubsonicSettings> subsonicSettings,
|
||||||
IOptions<QobuzSettings> qobuzSettings,
|
IOptions<QobuzSettings> qobuzSettings,
|
||||||
ILogger<QobuzDownloadService> logger)
|
ILogger<QobuzDownloadService> logger)
|
||||||
|
: base(configuration, localLibraryService, metadataService, subsonicSettings.Value, logger)
|
||||||
{
|
{
|
||||||
_httpClient = httpClientFactory.CreateClient();
|
_httpClient = httpClientFactory.CreateClient();
|
||||||
_configuration = configuration;
|
|
||||||
_localLibraryService = localLibraryService;
|
|
||||||
_metadataService = metadataService;
|
|
||||||
_bundleService = bundleService;
|
_bundleService = bundleService;
|
||||||
_subsonicSettings = subsonicSettings.Value;
|
|
||||||
_logger = logger;
|
|
||||||
|
|
||||||
_downloadPath = configuration["Library:DownloadPath"] ?? "./downloads";
|
|
||||||
|
|
||||||
var qobuzConfig = qobuzSettings.Value;
|
var qobuzConfig = qobuzSettings.Value;
|
||||||
_userAuthToken = qobuzConfig.UserAuthToken;
|
_userAuthToken = qobuzConfig.UserAuthToken;
|
||||||
_userId = qobuzConfig.UserId;
|
_userId = qobuzConfig.UserId;
|
||||||
_preferredQuality = qobuzConfig.Quality;
|
_preferredQuality = qobuzConfig.Quality;
|
||||||
|
|
||||||
if (!Directory.Exists(_downloadPath))
|
|
||||||
{
|
|
||||||
Directory.CreateDirectory(_downloadPath);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#region IDownloadService Implementation
|
#region BaseDownloadService Implementation
|
||||||
|
|
||||||
public async Task<string> DownloadSongAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default)
|
public override async Task<bool> IsAvailableAsync()
|
||||||
{
|
|
||||||
return await DownloadSongInternalAsync(externalProvider, externalId, triggerAlbumDownload: true, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Internal method for downloading a song with control over album download triggering
|
|
||||||
/// </summary>
|
|
||||||
private async Task<string> 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<Stream> 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<bool> IsAvailableAsync()
|
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(_userAuthToken) || string.IsNullOrEmpty(_userId))
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Try to extract app ID and secrets
|
|
||||||
await _bundleService.GetAppIdAsync();
|
await _bundleService.GetAppIdAsync();
|
||||||
await _bundleService.GetSecretsAsync();
|
await _bundleService.GetSecretsAsync();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Qobuz service not available");
|
Logger.LogWarning(ex, "Qobuz service not available");
|
||||||
return false;
|
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 albumId[prefix.Length..];
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
_ = Task.Run(async () =>
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await DownloadRemainingAlbumTracksAsync(albumExternalId, excludeTrackExternalId);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Failed to download remaining album tracks for album {AlbumId}", albumExternalId);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task DownloadRemainingAlbumTracksAsync(string albumExternalId, string excludeTrackExternalId)
|
protected override async Task<string> DownloadTrackAsync(string trackId, Song song, CancellationToken cancellationToken)
|
||||||
{
|
|
||||||
_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<string> DownloadTrackAsync(string trackId, Song song, CancellationToken cancellationToken)
|
|
||||||
{
|
{
|
||||||
// Get the download URL with signature
|
// Get the download URL with signature
|
||||||
var downloadInfo = await GetTrackDownloadUrlAsync(trackId, cancellationToken);
|
var downloadInfo = await GetTrackDownloadUrlAsync(trackId, cancellationToken);
|
||||||
|
|
||||||
_logger.LogInformation("Download URL obtained for: {Title} - {Artist}", song.Title, song.Artist);
|
Logger.LogInformation("Download URL obtained for: {Title} - {Artist}", song.Title, song.Artist);
|
||||||
_logger.LogInformation("Quality: {BitDepth}bit/{SamplingRate}kHz, Format: {MimeType}",
|
Logger.LogInformation("Quality: {BitDepth}bit/{SamplingRate}kHz, Format: {MimeType}",
|
||||||
downloadInfo.BitDepth, downloadInfo.SamplingRate, downloadInfo.MimeType);
|
downloadInfo.BitDepth, downloadInfo.SamplingRate, downloadInfo.MimeType);
|
||||||
|
|
||||||
// Check if it's a demo/sample
|
// 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)
|
// Build organized folder structure using AlbumArtist (fallback to Artist for singles)
|
||||||
var artistForPath = song.AlbumArtist ?? song.Artist;
|
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)!;
|
var albumFolder = Path.GetDirectoryName(outputPath)!;
|
||||||
EnsureDirectoryExists(albumFolder);
|
EnsureDirectoryExists(albumFolder);
|
||||||
@@ -331,6 +128,10 @@ public class QobuzDownloadService : IDownloadService
|
|||||||
return outputPath;
|
return outputPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Qobuz Download Methods
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the download URL for a track with proper MD5 signature
|
/// Gets the download URL for a track with proper MD5 signature
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -365,7 +166,7 @@ public class QobuzDownloadService : IDownloadService
|
|||||||
// Check if quality was downgraded
|
// Check if quality was downgraded
|
||||||
if (result.WasQualityDowngraded)
|
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);
|
result.BitDepth, result.SamplingRate);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,7 +175,7 @@ public class QobuzDownloadService : IDownloadService
|
|||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
lastException = 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);
|
secretIndex, format, ex.Message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -389,12 +190,10 @@ public class QobuzDownloadService : IDownloadService
|
|||||||
var appId = await _bundleService.GetAppIdAsync();
|
var appId = await _bundleService.GetAppIdAsync();
|
||||||
var signature = ComputeMD5Signature(trackId, formatId, unix, secret);
|
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}";
|
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);
|
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("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);
|
request.Headers.Add("X-App-Id", appId);
|
||||||
|
|
||||||
@@ -404,20 +203,16 @@ public class QobuzDownloadService : IDownloadService
|
|||||||
}
|
}
|
||||||
|
|
||||||
var response = await _httpClient.SendAsync(request, cancellationToken);
|
var response = await _httpClient.SendAsync(request, cancellationToken);
|
||||||
|
|
||||||
// Read response body
|
|
||||||
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken);
|
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
|
||||||
// Log error response if not successful
|
|
||||||
if (!response.IsSuccessStatusCode)
|
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);
|
response.StatusCode, trackId, formatId);
|
||||||
throw new HttpRequestException($"Response status code does not indicate success: {response.StatusCode} ({response.ReasonPhrase})");
|
throw new HttpRequestException($"Response status code does not indicate success: {response.StatusCode} ({response.ReasonPhrase})");
|
||||||
}
|
}
|
||||||
|
|
||||||
var json = responseBody;
|
var doc = JsonDocument.Parse(responseBody);
|
||||||
var doc = JsonDocument.Parse(json);
|
|
||||||
var root = doc.RootElement;
|
var root = doc.RootElement;
|
||||||
|
|
||||||
if (!root.TryGetProperty("url", out var urlElement) || string.IsNullOrEmpty(urlElement.GetString()))
|
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 bitDepth = root.TryGetProperty("bit_depth", out var bd) ? bd.GetInt32() : 16;
|
||||||
var samplingRate = root.TryGetProperty("sampling_rate", out var sr) ? sr.GetDouble() : 44.1;
|
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();
|
var isSample = root.TryGetProperty("sample", out var sampleEl) && sampleEl.GetBoolean();
|
||||||
|
|
||||||
// If sampling_rate is null/0, it's likely a demo
|
|
||||||
if (samplingRate == 0)
|
if (samplingRate == 0)
|
||||||
{
|
{
|
||||||
isSample = true;
|
isSample = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for quality restrictions/downgrades
|
|
||||||
var wasDowngraded = false;
|
var wasDowngraded = false;
|
||||||
if (root.TryGetProperty("restrictions", out var restrictions))
|
if (root.TryGetProperty("restrictions", out var restrictions))
|
||||||
{
|
{
|
||||||
@@ -470,12 +261,9 @@ public class QobuzDownloadService : IDownloadService
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Computes MD5 signature for track download request
|
/// Computes MD5 signature for track download request
|
||||||
/// Format based on qobuz-dl: trackgetFileUrlformat_id{X}intentstreamtrack_id{Y}{TIMESTAMP}{SECRET}
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private string ComputeMD5Signature(string trackId, int formatId, long timestamp, string 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}";
|
var toSign = $"trackgetFileUrlformat_id{formatId}intentstreamtrack_id{trackId}{timestamp}{secret}";
|
||||||
|
|
||||||
using var md5 = MD5.Create();
|
using var md5 = MD5.Create();
|
||||||
@@ -492,7 +280,7 @@ public class QobuzDownloadService : IDownloadService
|
|||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(quality))
|
if (string.IsNullOrEmpty(quality))
|
||||||
{
|
{
|
||||||
return FormatFlac24High; // Default to highest quality
|
return FormatFlac24High;
|
||||||
}
|
}
|
||||||
|
|
||||||
return quality.ToUpperInvariant() switch
|
return quality.ToUpperInvariant() switch
|
||||||
@@ -507,148 +295,18 @@ public class QobuzDownloadService : IDownloadService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 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
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private List<int> GetFormatPriority(int preferredFormat)
|
private List<int> GetFormatPriority(int preferredFormat)
|
||||||
{
|
{
|
||||||
var allFormats = new List<int> { FormatFlac24High, FormatFlac24Low, FormatFlac16, FormatMp3320 };
|
var allFormats = new List<int> { FormatFlac24High, FormatFlac24Low, FormatFlac16, FormatMp3320 };
|
||||||
|
|
||||||
// Start with preferred format, then try others in descending quality order
|
|
||||||
var priority = new List<int> { preferredFormat };
|
var priority = new List<int> { preferredFormat };
|
||||||
priority.AddRange(allFormats.Where(f => f != preferredFormat));
|
priority.AddRange(allFormats.Where(f => f != preferredFormat));
|
||||||
|
|
||||||
return priority;
|
return priority;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Writes ID3/Vorbis metadata and cover art to the audio file
|
|
||||||
/// </summary>
|
|
||||||
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<string>();
|
|
||||||
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<byte[]?> 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
|
#endregion
|
||||||
|
|
||||||
private class QobuzDownloadResult
|
private class QobuzDownloadResult
|
||||||
|
|||||||
Reference in New Issue
Block a user