diff --git a/octo-fiesta.Tests/DeezerDownloadServiceTests.cs b/octo-fiesta.Tests/DeezerDownloadServiceTests.cs new file mode 100644 index 0000000..b93239a --- /dev/null +++ b/octo-fiesta.Tests/DeezerDownloadServiceTests.cs @@ -0,0 +1,165 @@ +using octo_fiesta.Services; +using octo_fiesta.Models; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using Moq.Protected; +using System.Net; +using System.Text.Json; + +namespace octo_fiesta.Tests; + +public class DeezerDownloadServiceTests : IDisposable +{ + private readonly Mock _httpClientFactoryMock; + private readonly Mock _httpMessageHandlerMock; + private readonly Mock _localLibraryServiceMock; + private readonly Mock _metadataServiceMock; + private readonly Mock> _loggerMock; + private readonly IConfiguration _configuration; + private readonly string _testDownloadPath; + + public DeezerDownloadServiceTests() + { + _testDownloadPath = Path.Combine(Path.GetTempPath(), "octo-fiesta-download-tests-" + Guid.NewGuid()); + Directory.CreateDirectory(_testDownloadPath); + + _httpMessageHandlerMock = new Mock(); + var httpClient = new HttpClient(_httpMessageHandlerMock.Object); + + _httpClientFactoryMock = new Mock(); + _httpClientFactoryMock.Setup(f => f.CreateClient(It.IsAny())).Returns(httpClient); + + _localLibraryServiceMock = new Mock(); + _metadataServiceMock = new Mock(); + _loggerMock = new Mock>(); + + _configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Library:DownloadPath"] = _testDownloadPath, + ["Deezer:Arl"] = null, + ["Deezer:ArlFallback"] = null + }) + .Build(); + } + + public void Dispose() + { + if (Directory.Exists(_testDownloadPath)) + { + Directory.Delete(_testDownloadPath, true); + } + } + + private DeezerDownloadService CreateService(string? arl = null) + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Library:DownloadPath"] = _testDownloadPath, + ["Deezer:Arl"] = arl, + ["Deezer:ArlFallback"] = null + }) + .Build(); + + return new DeezerDownloadService( + _httpClientFactoryMock.Object, + config, + _localLibraryServiceMock.Object, + _metadataServiceMock.Object, + _loggerMock.Object); + } + + [Fact] + public async Task IsAvailableAsync_WithoutArl_ReturnsFalse() + { + // Arrange + var service = CreateService(arl: null); + + // Act + var result = await service.IsAvailableAsync(); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task IsAvailableAsync_WithEmptyArl_ReturnsFalse() + { + // Arrange + var service = CreateService(arl: ""); + + // Act + var result = await service.IsAvailableAsync(); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task DownloadSongAsync_WithUnsupportedProvider_ThrowsNotSupportedException() + { + // Arrange + var service = CreateService(arl: "test-arl"); + + // Act & Assert + await Assert.ThrowsAsync(() => + service.DownloadSongAsync("spotify", "123456")); + } + + [Fact] + public async Task DownloadSongAsync_WhenAlreadyDownloaded_ReturnsExistingPath() + { + // Arrange + var existingPath = Path.Combine(_testDownloadPath, "existing-song.mp3"); + await File.WriteAllTextAsync(existingPath, "fake audio content"); + + _localLibraryServiceMock + .Setup(s => s.GetLocalPathForExternalSongAsync("deezer", "123456")) + .ReturnsAsync(existingPath); + + var service = CreateService(arl: "test-arl"); + + // Act + var result = await service.DownloadSongAsync("deezer", "123456"); + + // Assert + Assert.Equal(existingPath, result); + } + + [Fact] + public void GetDownloadStatus_WithUnknownSongId_ReturnsNull() + { + // Arrange + var service = CreateService(arl: "test-arl"); + + // Act + var result = service.GetDownloadStatus("unknown-id"); + + // Assert + Assert.Null(result); + } + + [Fact] + public async Task DownloadSongAsync_WhenSongNotFound_ThrowsException() + { + // Arrange + _localLibraryServiceMock + .Setup(s => s.GetLocalPathForExternalSongAsync("deezer", "999999")) + .ReturnsAsync((string?)null); + + _metadataServiceMock + .Setup(s => s.GetSongAsync("deezer", "999999")) + .ReturnsAsync((Song?)null); + + var service = CreateService(arl: "test-arl"); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + service.DownloadSongAsync("deezer", "999999")); + + Assert.Equal("Song not found", exception.Message); + } +} diff --git a/octo-fiesta.Tests/DeezerMetadataServiceTests.cs b/octo-fiesta.Tests/DeezerMetadataServiceTests.cs index 90a04db..c24c0c6 100644 --- a/octo-fiesta.Tests/DeezerMetadataServiceTests.cs +++ b/octo-fiesta.Tests/DeezerMetadataServiceTests.cs @@ -181,6 +181,97 @@ public class DeezerMetadataServiceTests Assert.Null(result); } + [Fact] + public async Task SearchSongsAsync_WithEmptyResponse_ReturnsEmptyList() + { + // Arrange + SetupHttpResponse(JsonSerializer.Serialize(new { data = Array.Empty() })); + + // Act + var result = await _service.SearchSongsAsync("nonexistent", 20); + + // Assert + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact] + public async Task SearchSongsAsync_WithHttpError_ReturnsEmptyList() + { + // Arrange + SetupHttpResponse("Error", HttpStatusCode.InternalServerError); + + // Act + var result = await _service.SearchSongsAsync("test", 20); + + // Assert + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact] + public async Task GetAlbumAsync_WithDeezerProvider_ReturnsAlbumWithTracks() + { + // Arrange + var deezerResponse = new + { + id = 456789, + title = "Test Album", + nb_tracks = 2, + release_date = "2023-05-20", + cover_medium = "https://example.com/album.jpg", + artist = new { id = 123, name = "Test Artist" }, + tracks = new + { + data = new[] + { + new + { + id = 111, + title = "Track 1", + duration = 180, + track_position = 1, + artist = new { id = 123, name = "Test Artist" }, + album = new { id = 456789, title = "Test Album", cover_medium = "https://example.com/album.jpg" } + }, + new + { + id = 222, + title = "Track 2", + duration = 200, + track_position = 2, + artist = new { id = 123, name = "Test Artist" }, + album = new { id = 456789, title = "Test Album", cover_medium = "https://example.com/album.jpg" } + } + } + } + }; + + SetupHttpResponse(JsonSerializer.Serialize(deezerResponse)); + + // Act + var result = await _service.GetAlbumAsync("deezer", "456789"); + + // Assert + Assert.NotNull(result); + Assert.Equal("ext-deezer-456789", result.Id); + Assert.Equal("Test Album", result.Title); + Assert.Equal("Test Artist", result.Artist); + Assert.Equal(2, result.Songs.Count); + Assert.Equal("Track 1", result.Songs[0].Title); + Assert.Equal("Track 2", result.Songs[1].Title); + } + + [Fact] + public async Task GetAlbumAsync_WithNonDeezerProvider_ReturnsNull() + { + // Act + var result = await _service.GetAlbumAsync("spotify", "123456"); + + // Assert + Assert.Null(result); + } + private void SetupHttpResponse(string content, HttpStatusCode statusCode = HttpStatusCode.OK) { _httpMessageHandlerMock diff --git a/octo-fiesta.Tests/LocalLibraryServiceTests.cs b/octo-fiesta.Tests/LocalLibraryServiceTests.cs index a9f5d22..263f959 100644 --- a/octo-fiesta.Tests/LocalLibraryServiceTests.cs +++ b/octo-fiesta.Tests/LocalLibraryServiceTests.cs @@ -1,6 +1,11 @@ using octo_fiesta.Services; using octo_fiesta.Models; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using Moq.Protected; +using System.Net; namespace octo_fiesta.Tests; @@ -8,6 +13,7 @@ public class LocalLibraryServiceTests : IDisposable { private readonly LocalLibraryService _service; private readonly string _testDownloadPath; + private readonly Mock _mockHttpClientFactory; public LocalLibraryServiceTests() { @@ -21,7 +27,25 @@ public class LocalLibraryServiceTests : IDisposable }) .Build(); - _service = new LocalLibraryService(configuration); + // Mock HttpClient + var mockHandler = new Mock(); + mockHandler.Protected() + .Setup>("SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{\"subsonic-response\":{\"status\":\"ok\",\"scanStatus\":{\"scanning\":false,\"count\":100}}}") + }); + + var httpClient = new HttpClient(mockHandler.Object); + _mockHttpClientFactory = new Mock(); + _mockHttpClientFactory.Setup(x => x.CreateClient(It.IsAny())).Returns(httpClient); + + var subsonicSettings = Options.Create(new SubsonicSettings { Url = "http://localhost:4533" }); + var mockLogger = new Mock>(); + + _service = new LocalLibraryService(configuration, _mockHttpClientFactory.Object, subsonicSettings, mockLogger.Object); } public void Dispose() @@ -152,4 +176,45 @@ public class LocalLibraryServiceTests : IDisposable // Assert - nothing to assert, just checking it doesn't throw Assert.True(true); } + + [Fact] + public async Task TriggerLibraryScanAsync_ReturnsTrue() + { + // Act + var result = await _service.TriggerLibraryScanAsync(); + + // Assert + Assert.True(result); + } + + [Fact] + public async Task GetScanStatusAsync_ReturnsScanStatus() + { + // Act + var result = await _service.GetScanStatusAsync(); + + // Assert + Assert.NotNull(result); + Assert.False(result.Scanning); + Assert.Equal(100, result.Count); + } + + [Theory] + [InlineData("ext-deezer-123", true, "deezer", "123")] + [InlineData("ext-spotify-abc123", true, "spotify", "abc123")] + [InlineData("ext-tidal-999-888", true, "tidal", "999-888")] + [InlineData("123456", false, null, null)] + [InlineData("", false, null, null)] + [InlineData("ext-", false, null, null)] + [InlineData("ext-deezer", false, null, null)] + public void ParseSongId_VariousInputs_ReturnsExpected(string songId, bool expectedIsExternal, string? expectedProvider, string? expectedExternalId) + { + // Act + var (isExternal, provider, externalId) = _service.ParseSongId(songId); + + // Assert + Assert.Equal(expectedIsExternal, isExternal); + Assert.Equal(expectedProvider, provider); + Assert.Equal(expectedExternalId, externalId); + } } diff --git a/octo-fiesta/Models/MusicModels.cs b/octo-fiesta/Models/MusicModels.cs index ee82d67..da0f4cb 100644 --- a/octo-fiesta/Models/MusicModels.cs +++ b/octo-fiesta/Models/MusicModels.cs @@ -112,3 +112,12 @@ public class DownloadInfo public DateTime StartedAt { get; set; } public DateTime? CompletedAt { get; set; } } + +/// +/// Statut du scan de bibliothèque Subsonic +/// +public class ScanStatus +{ + public bool Scanning { get; set; } + public int? Count { get; set; } +} diff --git a/octo-fiesta/Services/DeezerDownloadService.cs b/octo-fiesta/Services/DeezerDownloadService.cs index 0e59613..36f39f8 100644 --- a/octo-fiesta/Services/DeezerDownloadService.cs +++ b/octo-fiesta/Services/DeezerDownloadService.cs @@ -1,6 +1,9 @@ using System.Security.Cryptography; using System.Text; using System.Text.Json; +using Org.BouncyCastle.Crypto.Engines; +using Org.BouncyCastle.Crypto.Modes; +using Org.BouncyCastle.Crypto.Parameters; using octo_fiesta.Models; namespace octo_fiesta.Services; @@ -135,6 +138,9 @@ public class DeezerDownloadService : IDownloadService song.LocalPath = localPath; await _localLibraryService.RegisterDownloadedSongAsync(song, localPath); + // Déclencher un rescan de la bibliothèque Subsonic (avec debounce) + _ = _localLibraryService.TriggerLibraryScanAsync(); + _logger.LogInformation("Download completed: {Path}", localPath); return localPath; } @@ -459,38 +465,20 @@ public class DeezerDownloadService : IDownloadService 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) + // Use BouncyCastle for native Blowfish CBC decryption + var engine = new BlowfishEngine(); + var cipher = new CbcBlockCipher(engine); + cipher.Init(false, new ParametersWithIV(new KeyParameter(key), iv)); - 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(); + var output = new byte[data.Length]; + var blockSize = cipher.GetBlockSize(); // 8 bytes for Blowfish - 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) + for (int offset = 0; offset < data.Length; offset += blockSize) { - var error = process.StandardError.ReadToEnd(); - throw new Exception($"OpenSSL decryption failed: {error}"); + cipher.ProcessBlock(data, offset, output, offset); } - - return ms.ToArray(); + + return output; } #endregion diff --git a/octo-fiesta/Services/DeezerMetadataService.cs b/octo-fiesta/Services/DeezerMetadataService.cs index 35a8714..ece92b4 100644 --- a/octo-fiesta/Services/DeezerMetadataService.cs +++ b/octo-fiesta/Services/DeezerMetadataService.cs @@ -18,65 +18,89 @@ public class DeezerMetadataService : IMusicMetadataService public async Task> SearchSongsAsync(string query, int limit = 20) { - var url = $"{BaseUrl}/search/track?q={Uri.EscapeDataString(query)}&limit={limit}"; - var response = await _httpClient.GetAsync(url); - response.EnsureSuccessStatusCode(); - - var json = await response.Content.ReadAsStringAsync(); - var result = JsonDocument.Parse(json); - - var songs = new List(); - if (result.RootElement.TryGetProperty("data", out var data)) + try { - foreach (var track in data.EnumerateArray()) + var url = $"{BaseUrl}/search/track?q={Uri.EscapeDataString(query)}&limit={limit}"; + var response = await _httpClient.GetAsync(url); + + if (!response.IsSuccessStatusCode) return new List(); + + var json = await response.Content.ReadAsStringAsync(); + var result = JsonDocument.Parse(json); + + var songs = new List(); + if (result.RootElement.TryGetProperty("data", out var data)) { - songs.Add(ParseDeezerTrack(track)); + foreach (var track in data.EnumerateArray()) + { + songs.Add(ParseDeezerTrack(track)); + } } + + return songs; + } + catch + { + return new List(); } - - return songs; } public async Task> SearchAlbumsAsync(string query, int limit = 20) { - var url = $"{BaseUrl}/search/album?q={Uri.EscapeDataString(query)}&limit={limit}"; - var response = await _httpClient.GetAsync(url); - response.EnsureSuccessStatusCode(); - - var json = await response.Content.ReadAsStringAsync(); - var result = JsonDocument.Parse(json); - - var albums = new List(); - if (result.RootElement.TryGetProperty("data", out var data)) + try { - foreach (var album in data.EnumerateArray()) + var url = $"{BaseUrl}/search/album?q={Uri.EscapeDataString(query)}&limit={limit}"; + var response = await _httpClient.GetAsync(url); + + if (!response.IsSuccessStatusCode) return new List(); + + var json = await response.Content.ReadAsStringAsync(); + var result = JsonDocument.Parse(json); + + var albums = new List(); + if (result.RootElement.TryGetProperty("data", out var data)) { - albums.Add(ParseDeezerAlbum(album)); + foreach (var album in data.EnumerateArray()) + { + albums.Add(ParseDeezerAlbum(album)); + } } + + return albums; + } + catch + { + return new List(); } - - return albums; } public async Task> SearchArtistsAsync(string query, int limit = 20) { - var url = $"{BaseUrl}/search/artist?q={Uri.EscapeDataString(query)}&limit={limit}"; - var response = await _httpClient.GetAsync(url); - response.EnsureSuccessStatusCode(); - - var json = await response.Content.ReadAsStringAsync(); - var result = JsonDocument.Parse(json); - - var artists = new List(); - if (result.RootElement.TryGetProperty("data", out var data)) + try { - foreach (var artist in data.EnumerateArray()) + var url = $"{BaseUrl}/search/artist?q={Uri.EscapeDataString(query)}&limit={limit}"; + var response = await _httpClient.GetAsync(url); + + if (!response.IsSuccessStatusCode) return new List(); + + var json = await response.Content.ReadAsStringAsync(); + var result = JsonDocument.Parse(json); + + var artists = new List(); + if (result.RootElement.TryGetProperty("data", out var data)) { - artists.Add(ParseDeezerArtist(artist)); + foreach (var artist in data.EnumerateArray()) + { + artists.Add(ParseDeezerArtist(artist)); + } } + + return artists; + } + catch + { + return new List(); } - - return artists; } public async Task SearchAllAsync(string query, int songLimit = 20, int albumLimit = 20, int artistLimit = 20) diff --git a/octo-fiesta/Services/LocalLibraryService.cs b/octo-fiesta/Services/LocalLibraryService.cs index f6b5546..bec9d90 100644 --- a/octo-fiesta/Services/LocalLibraryService.cs +++ b/octo-fiesta/Services/LocalLibraryService.cs @@ -1,3 +1,6 @@ +using System.Text.Json; +using System.Xml.Linq; +using Microsoft.Extensions.Options; using octo_fiesta.Models; namespace octo_fiesta.Services; @@ -26,6 +29,16 @@ public interface ILocalLibraryService /// Parse un ID de chanson pour déterminer s'il est externe ou local /// (bool isExternal, string? provider, string? externalId) ParseSongId(string songId); + + /// + /// Déclenche un scan de la bibliothèque Subsonic + /// + Task TriggerLibraryScanAsync(); + + /// + /// Récupère le statut actuel du scan + /// + Task GetScanStatusAsync(); } /// @@ -36,13 +49,27 @@ public class LocalLibraryService : ILocalLibraryService { private readonly string _mappingFilePath; private readonly string _downloadDirectory; + private readonly HttpClient _httpClient; + private readonly SubsonicSettings _subsonicSettings; + private readonly ILogger _logger; private Dictionary? _mappings; private readonly SemaphoreSlim _lock = new(1, 1); + + // Debounce pour éviter de déclencher trop de scans + private DateTime _lastScanTrigger = DateTime.MinValue; + private readonly TimeSpan _scanDebounceInterval = TimeSpan.FromSeconds(30); - public LocalLibraryService(IConfiguration configuration) + public LocalLibraryService( + IConfiguration configuration, + IHttpClientFactory httpClientFactory, + IOptions subsonicSettings, + ILogger logger) { _downloadDirectory = configuration["Library:DownloadPath"] ?? Path.Combine(Directory.GetCurrentDirectory(), "downloads"); _mappingFilePath = Path.Combine(_downloadDirectory, ".mappings.json"); + _httpClient = httpClientFactory.CreateClient(); + _subsonicSettings = subsonicSettings.Value; + _logger = logger; if (!Directory.Exists(_downloadDirectory)) { @@ -143,6 +170,80 @@ public class LocalLibraryService : ILocalLibraryService } public string GetDownloadDirectory() => _downloadDirectory; + + public async Task TriggerLibraryScanAsync() + { + // Debounce: éviter de déclencher trop de scans successifs + var now = DateTime.UtcNow; + if (now - _lastScanTrigger < _scanDebounceInterval) + { + _logger.LogDebug("Scan debounced - last scan was {Elapsed}s ago", + (now - _lastScanTrigger).TotalSeconds); + return true; + } + + _lastScanTrigger = now; + + try + { + // Appel à l'API Subsonic pour déclencher un scan + // Note: Les credentials doivent être passés en paramètres (u, p ou t+s) + var url = $"{_subsonicSettings.Url}/rest/startScan?f=json"; + + _logger.LogInformation("Triggering Subsonic library scan..."); + + var response = await _httpClient.GetAsync(url); + + if (response.IsSuccessStatusCode) + { + var content = await response.Content.ReadAsStringAsync(); + _logger.LogInformation("Subsonic scan triggered successfully: {Response}", content); + return true; + } + else + { + _logger.LogWarning("Failed to trigger Subsonic scan: {StatusCode}", response.StatusCode); + return false; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error triggering Subsonic library scan"); + return false; + } + } + + public async Task GetScanStatusAsync() + { + try + { + var url = $"{_subsonicSettings.Url}/rest/getScanStatus?f=json"; + + var response = await _httpClient.GetAsync(url); + + if (response.IsSuccessStatusCode) + { + var content = await response.Content.ReadAsStringAsync(); + var doc = JsonDocument.Parse(content); + + if (doc.RootElement.TryGetProperty("subsonic-response", out var subsonicResponse) && + subsonicResponse.TryGetProperty("scanStatus", out var scanStatus)) + { + return new ScanStatus + { + Scanning = scanStatus.TryGetProperty("scanning", out var scanning) && scanning.GetBoolean(), + Count = scanStatus.TryGetProperty("count", out var count) ? count.GetInt32() : null + }; + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting Subsonic scan status"); + } + + return null; + } } /// diff --git a/octo-fiesta/octo-fiesta.csproj b/octo-fiesta/octo-fiesta.csproj index dabd6a7..07c460a 100644 --- a/octo-fiesta/octo-fiesta.csproj +++ b/octo-fiesta/octo-fiesta.csproj @@ -8,6 +8,7 @@ +