From 8aa9c2d437ec41ac82aed9a9eecb2f419967b958 Mon Sep 17 00:00:00 2001 From: V1ck3s Date: Mon, 8 Dec 2025 15:12:16 +0100 Subject: [PATCH] feat: implement DeezerDownloadService with Blowfish decryption - Port JavaScript DeezerDownloader to C# - Add Deezer API authentication with ARL token - Implement track download via media.deezer.com API - Add Blowfish CBC decryption for encrypted chunks (uses OpenSSL) - Add rate limiting and retry with exponential backoff - Update config for Deezer ARL tokens - Configure Subsonic URL to 192.168.1.12:4533 --- .gitignore | 140 ++--- octo-fiesta.sln | 32 +- octo-fiesta/Models/SubsonicSettings.cs | 10 +- octo-fiesta/Program.cs | 2 +- octo-fiesta/Properties/launchSettings.json | 50 +- octo-fiesta/Services/DeezerDownloadService.cs | 582 ++++++++++++++++++ .../Services/DeezspotDownloadService.cs | 244 -------- octo-fiesta/appsettings.Development.json | 16 +- octo-fiesta/appsettings.json | 7 +- octo-fiesta/octo-fiesta.csproj | 30 +- octo-fiesta/octo-fiesta.http | 12 +- 11 files changed, 732 insertions(+), 393 deletions(-) create mode 100644 octo-fiesta/Services/DeezerDownloadService.cs delete mode 100644 octo-fiesta/Services/DeezspotDownloadService.cs diff --git a/.gitignore b/.gitignore index 77256f3..b3a9b1c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,71 +1,71 @@ -## A streamlined .gitignore for modern .NET projects -## including temporary files, build results, and -## files generated by popular .NET tools. If you are -## developing with Visual Studio, the VS .gitignore -## https://github.com/github/gitignore/blob/main/VisualStudio.gitignore -## has more thorough IDE-specific entries. -## -## Get latest from https://github.com/github/gitignore/blob/main/Dotnet.gitignore - -# Build results -[Dd]ebug/ -[Dd]ebugPublic/ -[Rr]elease/ -[Rr]eleases/ -x64/ -x86/ -[Ww][Ii][Nn]32/ -[Aa][Rr][Mm]/ -[Aa][Rr][Mm]64/ -bld/ -[Bb]in/ -[Oo]bj/ -[Ll]og/ -[Ll]ogs/ - -# .NET Core -project.lock.json -project.fragment.lock.json -artifacts/ - -# ASP.NET Scaffolding -ScaffoldingReadMe.txt - -# NuGet Packages -*.nupkg -# NuGet Symbol Packages -*.snupkg - -# Others -~$* -*~ -CodeCoverage/ - -# MSBuild Binary and Structured Log -*.binlog - -# MSTest test Results -[Tt]est[Rr]esult*/ -[Bb]uild[Ll]og.* - -# NUnit -*.VisualState.xml -TestResult.xml -nunit-*.xml - -# Fichiers et dossiers à ignorer pour un projet .NET -bin/ -obj/ -*.user -*.suo -*.userosscache -*.sln.docstates -*.vs/ -# Rider -.idea/ -# Visual Studio Code -.vscode/ -# Autres fichiers temporaires -*.log - +## A streamlined .gitignore for modern .NET projects +## including temporary files, build results, and +## files generated by popular .NET tools. If you are +## developing with Visual Studio, the VS .gitignore +## https://github.com/github/gitignore/blob/main/VisualStudio.gitignore +## has more thorough IDE-specific entries. +## +## Get latest from https://github.com/github/gitignore/blob/main/Dotnet.gitignore + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg + +# Others +~$* +*~ +CodeCoverage/ + +# MSBuild Binary and Structured Log +*.binlog + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Fichiers et dossiers à ignorer pour un projet .NET +bin/ +obj/ +*.user +*.suo +*.userosscache +*.sln.docstates +*.vs/ +# Rider +.idea/ +# Visual Studio Code +.vscode/ +# Autres fichiers temporaires +*.log + /.env \ No newline at end of file diff --git a/octo-fiesta.sln b/octo-fiesta.sln index be4809e..e15e293 100644 --- a/octo-fiesta.sln +++ b/octo-fiesta.sln @@ -1,16 +1,16 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "octo-fiesta", "octo-fiesta\octo-fiesta.csproj", "{C56EF44B-3AD5-4D4C-A513-726DA8A0E225}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {C56EF44B-3AD5-4D4C-A513-726DA8A0E225}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {C56EF44B-3AD5-4D4C-A513-726DA8A0E225}.Debug|Any CPU.Build.0 = Debug|Any CPU - {C56EF44B-3AD5-4D4C-A513-726DA8A0E225}.Release|Any CPU.ActiveCfg = Release|Any CPU - {C56EF44B-3AD5-4D4C-A513-726DA8A0E225}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection -EndGlobal + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "octo-fiesta", "octo-fiesta\octo-fiesta.csproj", "{C56EF44B-3AD5-4D4C-A513-726DA8A0E225}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {C56EF44B-3AD5-4D4C-A513-726DA8A0E225}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C56EF44B-3AD5-4D4C-A513-726DA8A0E225}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C56EF44B-3AD5-4D4C-A513-726DA8A0E225}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C56EF44B-3AD5-4D4C-A513-726DA8A0E225}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/octo-fiesta/Models/SubsonicSettings.cs b/octo-fiesta/Models/SubsonicSettings.cs index 1309f4d..a8ec96f 100644 --- a/octo-fiesta/Models/SubsonicSettings.cs +++ b/octo-fiesta/Models/SubsonicSettings.cs @@ -1,6 +1,6 @@ -namespace octo_fiesta.Models; - -public class SubsonicSettings -{ - public string? Url { get; set; } +namespace octo_fiesta.Models; + +public class SubsonicSettings +{ + public string? Url { get; set; } } \ No newline at end of file diff --git a/octo-fiesta/Program.cs b/octo-fiesta/Program.cs index 0ba5e0e..ea50164 100644 --- a/octo-fiesta/Program.cs +++ b/octo-fiesta/Program.cs @@ -17,7 +17,7 @@ builder.Services.Configure( // Services métier builder.Services.AddSingleton(); builder.Services.AddScoped(); -builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddCors(options => { diff --git a/octo-fiesta/Properties/launchSettings.json b/octo-fiesta/Properties/launchSettings.json index ababe98..a4efb4f 100644 --- a/octo-fiesta/Properties/launchSettings.json +++ b/octo-fiesta/Properties/launchSettings.json @@ -1,25 +1,25 @@ -{ - "$schema": "https://json.schemastore.org/launchsettings.json", - "profiles": { - "http": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "launchUrl": "swagger", - "applicationUrl": "http://localhost:5274", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, - "https": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "launchUrl": "swagger", - "applicationUrl": "https://localhost:7248;http://localhost:5274", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - } - } -} +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5274", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7248;http://localhost:5274", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/octo-fiesta/Services/DeezerDownloadService.cs b/octo-fiesta/Services/DeezerDownloadService.cs new file mode 100644 index 0000000..0e59613 --- /dev/null +++ b/octo-fiesta/Services/DeezerDownloadService.cs @@ -0,0 +1,582 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using octo_fiesta.Models; + +namespace octo_fiesta.Services; + +/// +/// Configuration pour le téléchargeur Deezer +/// +public class DeezerDownloaderSettings +{ + public string? Arl { get; set; } + public string? ArlFallback { get; set; } + public string DownloadPath { get; set; } = "./downloads"; +} + +/// +/// Port C# du DeezerDownloader JavaScript +/// Gère l'authentification Deezer, le téléchargement et le déchiffrement des pistes +/// +public class DeezerDownloadService : IDownloadService +{ + private readonly HttpClient _httpClient; + private readonly IConfiguration _configuration; + private readonly ILocalLibraryService _localLibraryService; + private readonly IMusicMetadataService _metadataService; + private readonly ILogger _logger; + + private readonly string _downloadPath; + private readonly string? _arl; + private readonly string? _arlFallback; + + private string? _apiToken; + private string? _licenseToken; + private bool _usingFallback; + + 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; + + private const string DeezerApiBase = "https://api.deezer.com"; + private const string BfSecret = "g4el58wc0zvf9na1"; + + public DeezerDownloadService( + IHttpClientFactory httpClientFactory, + IConfiguration configuration, + ILocalLibraryService localLibraryService, + IMusicMetadataService metadataService, + ILogger logger) + { + _httpClient = httpClientFactory.CreateClient(); + _configuration = configuration; + _localLibraryService = localLibraryService; + _metadataService = metadataService; + _logger = logger; + + _downloadPath = configuration["Library:DownloadPath"] ?? "./downloads"; + _arl = configuration["Deezer:Arl"]; + _arlFallback = configuration["Deezer:ArlFallback"]; + + if (!Directory.Exists(_downloadPath)) + { + Directory.CreateDirectory(_downloadPath); + } + } + + #region IDownloadService Implementation + + public async Task DownloadSongAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default) + { + if (externalProvider != "deezer") + { + throw new NotSupportedException($"Provider '{externalProvider}' is not supported"); + } + + var songId = $"ext-{externalProvider}-{externalId}"; + + // Vérifier si déjà téléchargé + var existingPath = await _localLibraryService.GetLocalPathForExternalSongAsync(externalProvider, externalId); + if (existingPath != null && File.Exists(existingPath)) + { + _logger.LogInformation("Song already downloaded: {Path}", existingPath); + return existingPath; + } + + // Vérifier si téléchargement en cours + if (_activeDownloads.TryGetValue(songId, out var activeDownload) && activeDownload.Status == DownloadStatus.InProgress) + { + _logger.LogInformation("Download already in progress for {SongId}", songId); + while (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 + { + // Récupérer les métadonnées + 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); + + _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 File.OpenRead(localPath); + } + + public DownloadInfo? GetDownloadStatus(string songId) + { + _activeDownloads.TryGetValue(songId, out var info); + return info; + } + + public async Task IsAvailableAsync() + { + if (string.IsNullOrEmpty(_arl)) + { + _logger.LogWarning("Deezer ARL not configured"); + return false; + } + + try + { + await InitializeAsync(); + return true; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Deezer service not available"); + return false; + } + } + + #endregion + + #region Deezer API Methods + + private async Task InitializeAsync(string? arlOverride = null) + { + var arl = arlOverride ?? _arl; + if (string.IsNullOrEmpty(arl)) + { + throw new Exception("ARL token required for Deezer downloads"); + } + + await RetryWithBackoffAsync(async () => + { + var request = new HttpRequestMessage(HttpMethod.Post, + "https://www.deezer.com/ajax/gw-light.php?method=deezer.getUserData&input=3&api_version=1.0&api_token=null"); + + request.Headers.Add("Cookie", $"arl={arl}"); + request.Content = new StringContent("{}", Encoding.UTF8, "application/json"); + + var response = await _httpClient.SendAsync(request); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync(); + var doc = JsonDocument.Parse(json); + + if (doc.RootElement.TryGetProperty("results", out var results) && + results.TryGetProperty("checkForm", out var checkForm)) + { + _apiToken = checkForm.GetString(); + + if (results.TryGetProperty("USER", out var user) && + user.TryGetProperty("OPTIONS", out var options) && + options.TryGetProperty("license_token", out var licenseToken)) + { + _licenseToken = licenseToken.GetString(); + } + + _logger.LogInformation("Deezer token refreshed: {Token}...", _apiToken?.Substring(0, Math.Min(16, _apiToken?.Length ?? 0))); + return true; + } + + throw new Exception("Invalid ARL token"); + }); + } + + private async Task GetTrackDownloadInfoAsync(string trackId, CancellationToken cancellationToken) + { + var tryDownload = async (string arl) => + { + // Refresh token with specific ARL + await InitializeAsync(arl); + + return await QueueRequestAsync(async () => + { + // Get track info + var trackResponse = await _httpClient.GetAsync($"{DeezerApiBase}/track/{trackId}", cancellationToken); + trackResponse.EnsureSuccessStatusCode(); + + var trackJson = await trackResponse.Content.ReadAsStringAsync(cancellationToken); + var trackDoc = JsonDocument.Parse(trackJson); + + if (!trackDoc.RootElement.TryGetProperty("track_token", out var trackTokenElement)) + { + throw new Exception("Track not found or track_token missing"); + } + + var trackToken = trackTokenElement.GetString(); + var title = trackDoc.RootElement.GetProperty("title").GetString() ?? ""; + var artist = trackDoc.RootElement.TryGetProperty("artist", out var artistEl) + ? artistEl.GetProperty("name").GetString() ?? "" + : ""; + + // Get download URL via media API + var mediaRequest = new + { + license_token = _licenseToken, + media = new[] + { + new + { + type = "FULL", + formats = new[] + { + new { cipher = "BF_CBC_STRIPE", format = "MP3_128" }, + new { cipher = "BF_CBC_STRIPE", format = "MP3_320" }, + new { cipher = "BF_CBC_STRIPE", format = "FLAC" } + } + } + }, + track_tokens = new[] { trackToken } + }; + + var mediaHttpRequest = new HttpRequestMessage(HttpMethod.Post, "https://media.deezer.com/v1/get_url"); + mediaHttpRequest.Content = new StringContent( + JsonSerializer.Serialize(mediaRequest), + Encoding.UTF8, + "application/json"); + + var mediaResponse = await _httpClient.SendAsync(mediaHttpRequest, cancellationToken); + mediaResponse.EnsureSuccessStatusCode(); + + var mediaJson = await mediaResponse.Content.ReadAsStringAsync(cancellationToken); + var mediaDoc = JsonDocument.Parse(mediaJson); + + if (!mediaDoc.RootElement.TryGetProperty("data", out var data) || + data.GetArrayLength() == 0) + { + throw new Exception("No download URL available"); + } + + var firstData = data[0]; + if (!firstData.TryGetProperty("media", out var media) || + media.GetArrayLength() == 0) + { + throw new Exception("No media sources available - track may be unavailable in your region"); + } + + string? downloadUrl = null; + string? format = null; + + foreach (var mediaItem in media.EnumerateArray()) + { + if (mediaItem.TryGetProperty("sources", out var sources) && + sources.GetArrayLength() > 0) + { + downloadUrl = sources[0].GetProperty("url").GetString(); + format = mediaItem.GetProperty("format").GetString(); + break; + } + } + + if (string.IsNullOrEmpty(downloadUrl)) + { + throw new Exception("No download URL found in media sources - track may be region locked"); + } + + return new DownloadResult + { + DownloadUrl = downloadUrl, + Format = format ?? "MP3_128", + Title = title, + Artist = artist + }; + }); + }; + + try + { + return await tryDownload(_arl!); + } + catch (Exception ex) + { + if (!string.IsNullOrEmpty(_arlFallback)) + { + _logger.LogWarning(ex, "Primary ARL failed, trying fallback ARL..."); + _usingFallback = true; + 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); + + // Déterminer l'extension basée sur le format + var extension = downloadInfo.Format?.ToUpper() switch + { + "FLAC" => ".flac", + _ => ".mp3" + }; + + // Générer le nom de fichier + var safeTitle = SanitizeFileName(song.Title); + var safeArtist = SanitizeFileName(song.Artist); + var fileName = $"{safeArtist} - {safeTitle}{extension}"; + var outputPath = Path.Combine(_downloadPath, fileName); + + // Éviter les conflits + var counter = 1; + while (File.Exists(outputPath)) + { + fileName = $"{safeArtist} - {safeTitle} ({counter}){extension}"; + outputPath = Path.Combine(_downloadPath, fileName); + counter++; + } + + // Télécharger le fichier chiffré + var response = await RetryWithBackoffAsync(async () => + { + 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(); + + // Télécharger et déchiffrer + await using var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken); + await using var outputFile = File.Create(outputPath); + + await DecryptAndWriteStreamAsync(responseStream, outputFile, trackId, cancellationToken); + + return outputPath; + } + + #endregion + + #region Decryption + + private byte[] GetBlowfishKey(string trackId) + { + var hash = MD5.HashData(Encoding.UTF8.GetBytes(trackId)); + var hashHex = Convert.ToHexString(hash).ToLower(); + + var bfKey = new byte[16]; + for (int i = 0; i < 16; i++) + { + bfKey[i] = (byte)(hashHex[i] ^ hashHex[i + 16] ^ BfSecret[i]); + } + + return bfKey; + } + + private async Task DecryptAndWriteStreamAsync( + Stream input, + Stream output, + string trackId, + CancellationToken cancellationToken) + { + var bfKey = GetBlowfishKey(trackId); + var iv = new byte[] { 0, 1, 2, 3, 4, 5, 6, 7 }; + + var buffer = new byte[2048]; + int chunkIndex = 0; + + while (true) + { + var bytesRead = await ReadExactAsync(input, buffer, cancellationToken); + if (bytesRead == 0) break; + + var chunk = buffer.AsSpan(0, bytesRead).ToArray(); + + // Chaque 3ème chunk (index % 3 == 0) est chiffré + if (chunkIndex % 3 == 0 && bytesRead == 2048) + { + chunk = DecryptBlowfishCbc(chunk, bfKey, iv); + } + + await output.WriteAsync(chunk, cancellationToken); + chunkIndex++; + } + } + + private async Task ReadExactAsync(Stream stream, byte[] buffer, CancellationToken cancellationToken) + { + int totalRead = 0; + while (totalRead < buffer.Length) + { + var bytesRead = await stream.ReadAsync(buffer.AsMemory(totalRead, buffer.Length - totalRead), cancellationToken); + if (bytesRead == 0) break; + totalRead += bytesRead; + } + return totalRead; + } + + private byte[] DecryptBlowfishCbc(byte[] data, byte[] key, byte[] iv) + { + // Note: .NET ne supporte pas nativement Blowfish + // On utilise BouncyCastle ou une implémentation custom + // Pour l'instant, on utilise un appel à OpenSSL via Process (comme le JS) + + using var process = new System.Diagnostics.Process(); + process.StartInfo.FileName = "openssl"; + process.StartInfo.Arguments = $"enc -d -bf-cbc -K {Convert.ToHexString(key).ToLower()} -iv {Convert.ToHexString(iv).ToLower()} -nopad -provider legacy -provider default"; + process.StartInfo.RedirectStandardInput = true; + process.StartInfo.RedirectStandardOutput = true; + process.StartInfo.RedirectStandardError = true; + process.StartInfo.UseShellExecute = false; + process.StartInfo.CreateNoWindow = true; + + process.Start(); + + using var stdin = process.StandardInput.BaseStream; + stdin.Write(data, 0, data.Length); + stdin.Close(); + + using var stdout = process.StandardOutput.BaseStream; + using var ms = new MemoryStream(); + stdout.CopyTo(ms); + + process.WaitForExit(); + + if (process.ExitCode != 0) + { + var error = process.StandardError.ReadToEnd(); + throw new Exception($"OpenSSL decryption failed: {error}"); + } + + return ms.ToArray(); + } + + #endregion + + #region Utility Methods + + private async Task RetryWithBackoffAsync(Func> action, int maxRetries = 3, int initialDelayMs = 1000) + { + Exception? lastException = null; + + for (int attempt = 0; attempt < maxRetries; attempt++) + { + try + { + return await action(); + } + catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.ServiceUnavailable || + ex.StatusCode == System.Net.HttpStatusCode.TooManyRequests) + { + lastException = ex; + if (attempt < maxRetries - 1) + { + var delay = initialDelayMs * (int)Math.Pow(2, attempt); + _logger.LogWarning("Retry attempt {Attempt}/{MaxRetries} after {Delay}ms ({Message})", + attempt + 1, maxRetries, delay, ex.Message); + await Task.Delay(delay); + } + } + catch + { + throw; + } + } + + throw lastException!; + } + + private async Task RetryWithBackoffAsync(Func> action, int maxRetries = 3, int initialDelayMs = 1000) + { + await RetryWithBackoffAsync(action, maxRetries, initialDelayMs); + } + + private async Task QueueRequestAsync(Func> action) + { + await _requestLock.WaitAsync(); + try + { + var now = DateTime.UtcNow; + var timeSinceLastRequest = (now - _lastRequestTime).TotalMilliseconds; + + if (timeSinceLastRequest < _minRequestIntervalMs) + { + await Task.Delay((int)(_minRequestIntervalMs - timeSinceLastRequest)); + } + + _lastRequestTime = DateTime.UtcNow; + return await action(); + } + finally + { + _requestLock.Release(); + } + } + + private string SanitizeFileName(string fileName) + { + var invalidChars = Path.GetInvalidFileNameChars(); + var sanitized = new string(fileName + .Select(c => invalidChars.Contains(c) ? '_' : c) + .ToArray()); + + if (sanitized.Length > 100) + { + sanitized = sanitized.Substring(0, 100); + } + + return sanitized.Trim(); + } + + #endregion + + private class DownloadResult + { + public string DownloadUrl { get; set; } = string.Empty; + public string Format { get; set; } = string.Empty; + public string Title { get; set; } = string.Empty; + public string Artist { get; set; } = string.Empty; + } +} diff --git a/octo-fiesta/Services/DeezspotDownloadService.cs b/octo-fiesta/Services/DeezspotDownloadService.cs deleted file mode 100644 index 7cb3986..0000000 --- a/octo-fiesta/Services/DeezspotDownloadService.cs +++ /dev/null @@ -1,244 +0,0 @@ -using octo_fiesta.Models; -using System.Diagnostics; - -namespace octo_fiesta.Services; - -/// -/// Implémentation du service de téléchargement utilisant Deezspot (ou similaire) -/// Cette implémentation est un placeholder - à adapter selon l'outil de téléchargement choisi -/// -public class DeezspotDownloadService : IDownloadService -{ - private readonly IConfiguration _configuration; - private readonly ILocalLibraryService _localLibraryService; - private readonly IMusicMetadataService _metadataService; - private readonly ILogger _logger; - private readonly Dictionary _activeDownloads = new(); - private readonly SemaphoreSlim _downloadLock = new(1, 1); - private readonly string _downloadPath; - private readonly string? _deezspotPath; - - public DeezspotDownloadService( - IConfiguration configuration, - ILocalLibraryService localLibraryService, - IMusicMetadataService metadataService, - ILogger logger) - { - _configuration = configuration; - _localLibraryService = localLibraryService; - _metadataService = metadataService; - _logger = logger; - _downloadPath = configuration["Library:DownloadPath"] ?? Path.Combine(Directory.GetCurrentDirectory(), "downloads"); - _deezspotPath = configuration["Deezspot:ExecutablePath"]; - - if (!Directory.Exists(_downloadPath)) - { - Directory.CreateDirectory(_downloadPath); - } - } - - public async Task DownloadSongAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default) - { - var songId = $"ext-{externalProvider}-{externalId}"; - - // Vérifier si déjà téléchargé - var existingPath = await _localLibraryService.GetLocalPathForExternalSongAsync(externalProvider, externalId); - if (existingPath != null && File.Exists(existingPath)) - { - return existingPath; - } - - // Vérifier si téléchargement en cours - if (_activeDownloads.TryGetValue(songId, out var activeDownload) && activeDownload.Status == DownloadStatus.InProgress) - { - // Attendre la fin du téléchargement en cours - while (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 - { - // Récupérer les métadonnées pour le nom de fichier - 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 ExecuteDownloadAsync(externalProvider, externalId, song, cancellationToken); - - downloadInfo.Status = DownloadStatus.Completed; - downloadInfo.LocalPath = localPath; - downloadInfo.CompletedAt = DateTime.UtcNow; - - // Enregistrer dans la bibliothèque locale - song.LocalPath = localPath; - await _localLibraryService.RegisterDownloadedSongAsync(song, localPath); - - return localPath; - } - catch (Exception ex) - { - downloadInfo.Status = DownloadStatus.Failed; - downloadInfo.ErrorMessage = ex.Message; - throw; - } - } - finally - { - _downloadLock.Release(); - } - } - - public async Task DownloadAndStreamAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default) - { - // Pour le streaming à la volée, on télécharge d'abord le fichier puis on le stream - // Une implémentation plus avancée pourrait utiliser des pipes pour streamer pendant le téléchargement - var localPath = await DownloadSongAsync(externalProvider, externalId, cancellationToken); - return File.OpenRead(localPath); - } - - public DownloadInfo? GetDownloadStatus(string songId) - { - _activeDownloads.TryGetValue(songId, out var info); - return info; - } - - public async Task IsAvailableAsync() - { - if (string.IsNullOrEmpty(_deezspotPath)) - { - _logger.LogWarning("Deezspot path not configured"); - return false; - } - - if (!File.Exists(_deezspotPath)) - { - _logger.LogWarning("Deezspot executable not found at {Path}", _deezspotPath); - return false; - } - - await Task.CompletedTask; - return true; - } - - private async Task ExecuteDownloadAsync(string provider, string externalId, Song song, CancellationToken cancellationToken) - { - // Générer un nom de fichier sécurisé - var safeTitle = SanitizeFileName(song.Title); - var safeArtist = SanitizeFileName(song.Artist); - var fileName = $"{safeArtist} - {safeTitle}.mp3"; - var outputPath = Path.Combine(_downloadPath, fileName); - - // Éviter les conflits de noms - var counter = 1; - while (File.Exists(outputPath)) - { - fileName = $"{safeArtist} - {safeTitle} ({counter}).mp3"; - outputPath = Path.Combine(_downloadPath, fileName); - counter++; - } - - if (string.IsNullOrEmpty(_deezspotPath)) - { - throw new InvalidOperationException("Deezspot executable path not configured. Set 'Deezspot:ExecutablePath' in configuration."); - } - - // Construire la commande Deezspot - // Note: La syntaxe exacte dépend de la version de Deezspot utilisée - var trackUrl = provider == "deezer" - ? $"https://www.deezer.com/track/{externalId}" - : $"https://open.spotify.com/track/{externalId}"; - - var processInfo = new ProcessStartInfo - { - FileName = _deezspotPath, - Arguments = $"download \"{trackUrl}\" -o \"{_downloadPath}\"", - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - }; - - _logger.LogInformation("Starting download: {Command} {Args}", processInfo.FileName, processInfo.Arguments); - - using var process = new Process { StartInfo = processInfo }; - process.Start(); - - var outputTask = process.StandardOutput.ReadToEndAsync(); - var errorTask = process.StandardError.ReadToEndAsync(); - - await process.WaitForExitAsync(cancellationToken); - - var output = await outputTask; - var error = await errorTask; - - if (process.ExitCode != 0) - { - _logger.LogError("Download failed: {Error}", error); - throw new Exception($"Download failed: {error}"); - } - - // Chercher le fichier téléchargé (Deezspot peut utiliser son propre nommage) - var downloadedFiles = Directory.GetFiles(_downloadPath, "*.mp3") - .OrderByDescending(f => File.GetCreationTime(f)) - .ToList(); - - if (downloadedFiles.Any()) - { - var latestFile = downloadedFiles.First(); - - // Si le fichier a un nom différent, on peut le renommer - if (latestFile != outputPath && File.GetCreationTime(latestFile) > DateTime.UtcNow.AddMinutes(-5)) - { - _logger.LogInformation("Downloaded file: {File}", latestFile); - return latestFile; - } - } - - if (File.Exists(outputPath)) - { - return outputPath; - } - - throw new Exception("Download completed but file not found"); - } - - private string SanitizeFileName(string fileName) - { - var invalidChars = Path.GetInvalidFileNameChars(); - var sanitized = new string(fileName - .Select(c => invalidChars.Contains(c) ? '_' : c) - .ToArray()); - - // Limiter la longueur - if (sanitized.Length > 100) - { - sanitized = sanitized.Substring(0, 100); - } - - return sanitized.Trim(); - } -} diff --git a/octo-fiesta/appsettings.Development.json b/octo-fiesta/appsettings.Development.json index 0c208ae..ff66ba6 100644 --- a/octo-fiesta/appsettings.Development.json +++ b/octo-fiesta/appsettings.Development.json @@ -1,8 +1,8 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - } -} +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/octo-fiesta/appsettings.json b/octo-fiesta/appsettings.json index 03da041..63fcbd6 100644 --- a/octo-fiesta/appsettings.json +++ b/octo-fiesta/appsettings.json @@ -7,12 +7,13 @@ }, "AllowedHosts": "*", "Subsonic": { - "Url": "http://localhost:4533" + "Url": "http://192.168.1.12:4533" }, "Library": { "DownloadPath": "./downloads" }, - "Deezspot": { - "ExecutablePath": "" + "Deezer": { + "Arl": "", + "ArlFallback": "" } } diff --git a/octo-fiesta/octo-fiesta.csproj b/octo-fiesta/octo-fiesta.csproj index 8986156..dabd6a7 100644 --- a/octo-fiesta/octo-fiesta.csproj +++ b/octo-fiesta/octo-fiesta.csproj @@ -1,15 +1,15 @@ - - - - net9.0 - enable - enable - octo_fiesta - - - - - - - - + + + + net9.0 + enable + enable + octo_fiesta + + + + + + + + diff --git a/octo-fiesta/octo-fiesta.http b/octo-fiesta/octo-fiesta.http index 2af47a5..2f7b0c8 100644 --- a/octo-fiesta/octo-fiesta.http +++ b/octo-fiesta/octo-fiesta.http @@ -1,6 +1,6 @@ -@octo_fiesta_HostAddress = http://localhost:5274 - -GET {{octo_fiesta_HostAddress}}/weatherforecast/ -Accept: application/json - -### +@octo_fiesta_HostAddress = http://localhost:5274 + +GET {{octo_fiesta_HostAddress}}/weatherforecast/ +Accept: application/json + +###