mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-09 23:55:10 -05:00
feat: replace OpenSSL with native Blowfish decryption and add library rescan
- Replace OpenSSL subprocess with BouncyCastle native Blowfish CBC decryption - Add automatic Subsonic library scan trigger after downloads (with 30s debounce) - Improve error handling in DeezerMetadataService search methods - Add comprehensive tests for download service, metadata service, and library service - Increase test coverage from 13 to 32 tests
This commit is contained in:
165
octo-fiesta.Tests/DeezerDownloadServiceTests.cs
Normal file
165
octo-fiesta.Tests/DeezerDownloadServiceTests.cs
Normal file
@@ -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<IHttpClientFactory> _httpClientFactoryMock;
|
||||||
|
private readonly Mock<HttpMessageHandler> _httpMessageHandlerMock;
|
||||||
|
private readonly Mock<ILocalLibraryService> _localLibraryServiceMock;
|
||||||
|
private readonly Mock<IMusicMetadataService> _metadataServiceMock;
|
||||||
|
private readonly Mock<ILogger<DeezerDownloadService>> _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<HttpMessageHandler>();
|
||||||
|
var httpClient = new HttpClient(_httpMessageHandlerMock.Object);
|
||||||
|
|
||||||
|
_httpClientFactoryMock = new Mock<IHttpClientFactory>();
|
||||||
|
_httpClientFactoryMock.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(httpClient);
|
||||||
|
|
||||||
|
_localLibraryServiceMock = new Mock<ILocalLibraryService>();
|
||||||
|
_metadataServiceMock = new Mock<IMusicMetadataService>();
|
||||||
|
_loggerMock = new Mock<ILogger<DeezerDownloadService>>();
|
||||||
|
|
||||||
|
_configuration = new ConfigurationBuilder()
|
||||||
|
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["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<string, string?>
|
||||||
|
{
|
||||||
|
["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<NotSupportedException>(() =>
|
||||||
|
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<Exception>(() =>
|
||||||
|
service.DownloadSongAsync("deezer", "999999"));
|
||||||
|
|
||||||
|
Assert.Equal("Song not found", exception.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -181,6 +181,97 @@ public class DeezerMetadataServiceTests
|
|||||||
Assert.Null(result);
|
Assert.Null(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SearchSongsAsync_WithEmptyResponse_ReturnsEmptyList()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
SetupHttpResponse(JsonSerializer.Serialize(new { data = Array.Empty<object>() }));
|
||||||
|
|
||||||
|
// 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)
|
private void SetupHttpResponse(string content, HttpStatusCode statusCode = HttpStatusCode.OK)
|
||||||
{
|
{
|
||||||
_httpMessageHandlerMock
|
_httpMessageHandlerMock
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
using octo_fiesta.Services;
|
using octo_fiesta.Services;
|
||||||
using octo_fiesta.Models;
|
using octo_fiesta.Models;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Moq;
|
||||||
|
using Moq.Protected;
|
||||||
|
using System.Net;
|
||||||
|
|
||||||
namespace octo_fiesta.Tests;
|
namespace octo_fiesta.Tests;
|
||||||
|
|
||||||
@@ -8,6 +13,7 @@ public class LocalLibraryServiceTests : IDisposable
|
|||||||
{
|
{
|
||||||
private readonly LocalLibraryService _service;
|
private readonly LocalLibraryService _service;
|
||||||
private readonly string _testDownloadPath;
|
private readonly string _testDownloadPath;
|
||||||
|
private readonly Mock<IHttpClientFactory> _mockHttpClientFactory;
|
||||||
|
|
||||||
public LocalLibraryServiceTests()
|
public LocalLibraryServiceTests()
|
||||||
{
|
{
|
||||||
@@ -21,7 +27,25 @@ public class LocalLibraryServiceTests : IDisposable
|
|||||||
})
|
})
|
||||||
.Build();
|
.Build();
|
||||||
|
|
||||||
_service = new LocalLibraryService(configuration);
|
// Mock HttpClient
|
||||||
|
var mockHandler = new Mock<HttpMessageHandler>();
|
||||||
|
mockHandler.Protected()
|
||||||
|
.Setup<Task<HttpResponseMessage>>("SendAsync",
|
||||||
|
ItExpr.IsAny<HttpRequestMessage>(),
|
||||||
|
ItExpr.IsAny<CancellationToken>())
|
||||||
|
.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<IHttpClientFactory>();
|
||||||
|
_mockHttpClientFactory.Setup(x => x.CreateClient(It.IsAny<string>())).Returns(httpClient);
|
||||||
|
|
||||||
|
var subsonicSettings = Options.Create(new SubsonicSettings { Url = "http://localhost:4533" });
|
||||||
|
var mockLogger = new Mock<ILogger<LocalLibraryService>>();
|
||||||
|
|
||||||
|
_service = new LocalLibraryService(configuration, _mockHttpClientFactory.Object, subsonicSettings, mockLogger.Object);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
@@ -152,4 +176,45 @@ public class LocalLibraryServiceTests : IDisposable
|
|||||||
// Assert - nothing to assert, just checking it doesn't throw
|
// Assert - nothing to assert, just checking it doesn't throw
|
||||||
Assert.True(true);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,3 +112,12 @@ public class DownloadInfo
|
|||||||
public DateTime StartedAt { get; set; }
|
public DateTime StartedAt { get; set; }
|
||||||
public DateTime? CompletedAt { get; set; }
|
public DateTime? CompletedAt { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Statut du scan de bibliothèque Subsonic
|
||||||
|
/// </summary>
|
||||||
|
public class ScanStatus
|
||||||
|
{
|
||||||
|
public bool Scanning { get; set; }
|
||||||
|
public int? Count { get; set; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using Org.BouncyCastle.Crypto.Engines;
|
||||||
|
using Org.BouncyCastle.Crypto.Modes;
|
||||||
|
using Org.BouncyCastle.Crypto.Parameters;
|
||||||
using octo_fiesta.Models;
|
using octo_fiesta.Models;
|
||||||
|
|
||||||
namespace octo_fiesta.Services;
|
namespace octo_fiesta.Services;
|
||||||
@@ -135,6 +138,9 @@ public class DeezerDownloadService : IDownloadService
|
|||||||
song.LocalPath = localPath;
|
song.LocalPath = localPath;
|
||||||
await _localLibraryService.RegisterDownloadedSongAsync(song, 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);
|
_logger.LogInformation("Download completed: {Path}", localPath);
|
||||||
return localPath;
|
return localPath;
|
||||||
}
|
}
|
||||||
@@ -459,38 +465,20 @@ public class DeezerDownloadService : IDownloadService
|
|||||||
|
|
||||||
private byte[] DecryptBlowfishCbc(byte[] data, byte[] key, byte[] iv)
|
private byte[] DecryptBlowfishCbc(byte[] data, byte[] key, byte[] iv)
|
||||||
{
|
{
|
||||||
// Note: .NET ne supporte pas nativement Blowfish
|
// Use BouncyCastle for native Blowfish CBC decryption
|
||||||
// On utilise BouncyCastle ou une implémentation custom
|
var engine = new BlowfishEngine();
|
||||||
// Pour l'instant, on utilise un appel à OpenSSL via Process (comme le JS)
|
var cipher = new CbcBlockCipher(engine);
|
||||||
|
cipher.Init(false, new ParametersWithIV(new KeyParameter(key), iv));
|
||||||
|
|
||||||
using var process = new System.Diagnostics.Process();
|
var output = new byte[data.Length];
|
||||||
process.StartInfo.FileName = "openssl";
|
var blockSize = cipher.GetBlockSize(); // 8 bytes for Blowfish
|
||||||
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();
|
for (int offset = 0; offset < data.Length; offset += blockSize)
|
||||||
|
|
||||||
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();
|
cipher.ProcessBlock(data, offset, output, offset);
|
||||||
throw new Exception($"OpenSSL decryption failed: {error}");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return ms.ToArray();
|
return output;
|
||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|||||||
@@ -17,10 +17,13 @@ public class DeezerMetadataService : IMusicMetadataService
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<Song>> SearchSongsAsync(string query, int limit = 20)
|
public async Task<List<Song>> SearchSongsAsync(string query, int limit = 20)
|
||||||
|
{
|
||||||
|
try
|
||||||
{
|
{
|
||||||
var url = $"{BaseUrl}/search/track?q={Uri.EscapeDataString(query)}&limit={limit}";
|
var url = $"{BaseUrl}/search/track?q={Uri.EscapeDataString(query)}&limit={limit}";
|
||||||
var response = await _httpClient.GetAsync(url);
|
var response = await _httpClient.GetAsync(url);
|
||||||
response.EnsureSuccessStatusCode();
|
|
||||||
|
if (!response.IsSuccessStatusCode) return new List<Song>();
|
||||||
|
|
||||||
var json = await response.Content.ReadAsStringAsync();
|
var json = await response.Content.ReadAsStringAsync();
|
||||||
var result = JsonDocument.Parse(json);
|
var result = JsonDocument.Parse(json);
|
||||||
@@ -36,12 +39,20 @@ public class DeezerMetadataService : IMusicMetadataService
|
|||||||
|
|
||||||
return songs;
|
return songs;
|
||||||
}
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return new List<Song>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<List<Album>> SearchAlbumsAsync(string query, int limit = 20)
|
public async Task<List<Album>> SearchAlbumsAsync(string query, int limit = 20)
|
||||||
|
{
|
||||||
|
try
|
||||||
{
|
{
|
||||||
var url = $"{BaseUrl}/search/album?q={Uri.EscapeDataString(query)}&limit={limit}";
|
var url = $"{BaseUrl}/search/album?q={Uri.EscapeDataString(query)}&limit={limit}";
|
||||||
var response = await _httpClient.GetAsync(url);
|
var response = await _httpClient.GetAsync(url);
|
||||||
response.EnsureSuccessStatusCode();
|
|
||||||
|
if (!response.IsSuccessStatusCode) return new List<Album>();
|
||||||
|
|
||||||
var json = await response.Content.ReadAsStringAsync();
|
var json = await response.Content.ReadAsStringAsync();
|
||||||
var result = JsonDocument.Parse(json);
|
var result = JsonDocument.Parse(json);
|
||||||
@@ -57,12 +68,20 @@ public class DeezerMetadataService : IMusicMetadataService
|
|||||||
|
|
||||||
return albums;
|
return albums;
|
||||||
}
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return new List<Album>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<List<Artist>> SearchArtistsAsync(string query, int limit = 20)
|
public async Task<List<Artist>> SearchArtistsAsync(string query, int limit = 20)
|
||||||
|
{
|
||||||
|
try
|
||||||
{
|
{
|
||||||
var url = $"{BaseUrl}/search/artist?q={Uri.EscapeDataString(query)}&limit={limit}";
|
var url = $"{BaseUrl}/search/artist?q={Uri.EscapeDataString(query)}&limit={limit}";
|
||||||
var response = await _httpClient.GetAsync(url);
|
var response = await _httpClient.GetAsync(url);
|
||||||
response.EnsureSuccessStatusCode();
|
|
||||||
|
if (!response.IsSuccessStatusCode) return new List<Artist>();
|
||||||
|
|
||||||
var json = await response.Content.ReadAsStringAsync();
|
var json = await response.Content.ReadAsStringAsync();
|
||||||
var result = JsonDocument.Parse(json);
|
var result = JsonDocument.Parse(json);
|
||||||
@@ -78,6 +97,11 @@ public class DeezerMetadataService : IMusicMetadataService
|
|||||||
|
|
||||||
return artists;
|
return artists;
|
||||||
}
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return new List<Artist>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<SearchResult> SearchAllAsync(string query, int songLimit = 20, int albumLimit = 20, int artistLimit = 20)
|
public async Task<SearchResult> SearchAllAsync(string query, int songLimit = 20, int albumLimit = 20, int artistLimit = 20)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Xml.Linq;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
using octo_fiesta.Models;
|
using octo_fiesta.Models;
|
||||||
|
|
||||||
namespace octo_fiesta.Services;
|
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
|
/// Parse un ID de chanson pour déterminer s'il est externe ou local
|
||||||
/// </summary>
|
/// </summary>
|
||||||
(bool isExternal, string? provider, string? externalId) ParseSongId(string songId);
|
(bool isExternal, string? provider, string? externalId) ParseSongId(string songId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Déclenche un scan de la bibliothèque Subsonic
|
||||||
|
/// </summary>
|
||||||
|
Task<bool> TriggerLibraryScanAsync();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Récupère le statut actuel du scan
|
||||||
|
/// </summary>
|
||||||
|
Task<ScanStatus?> GetScanStatusAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -36,13 +49,27 @@ public class LocalLibraryService : ILocalLibraryService
|
|||||||
{
|
{
|
||||||
private readonly string _mappingFilePath;
|
private readonly string _mappingFilePath;
|
||||||
private readonly string _downloadDirectory;
|
private readonly string _downloadDirectory;
|
||||||
|
private readonly HttpClient _httpClient;
|
||||||
|
private readonly SubsonicSettings _subsonicSettings;
|
||||||
|
private readonly ILogger<LocalLibraryService> _logger;
|
||||||
private Dictionary<string, LocalSongMapping>? _mappings;
|
private Dictionary<string, LocalSongMapping>? _mappings;
|
||||||
private readonly SemaphoreSlim _lock = new(1, 1);
|
private readonly SemaphoreSlim _lock = new(1, 1);
|
||||||
|
|
||||||
public LocalLibraryService(IConfiguration configuration)
|
// 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,
|
||||||
|
IHttpClientFactory httpClientFactory,
|
||||||
|
IOptions<SubsonicSettings> subsonicSettings,
|
||||||
|
ILogger<LocalLibraryService> logger)
|
||||||
{
|
{
|
||||||
_downloadDirectory = configuration["Library:DownloadPath"] ?? Path.Combine(Directory.GetCurrentDirectory(), "downloads");
|
_downloadDirectory = configuration["Library:DownloadPath"] ?? Path.Combine(Directory.GetCurrentDirectory(), "downloads");
|
||||||
_mappingFilePath = Path.Combine(_downloadDirectory, ".mappings.json");
|
_mappingFilePath = Path.Combine(_downloadDirectory, ".mappings.json");
|
||||||
|
_httpClient = httpClientFactory.CreateClient();
|
||||||
|
_subsonicSettings = subsonicSettings.Value;
|
||||||
|
_logger = logger;
|
||||||
|
|
||||||
if (!Directory.Exists(_downloadDirectory))
|
if (!Directory.Exists(_downloadDirectory))
|
||||||
{
|
{
|
||||||
@@ -143,6 +170,80 @@ public class LocalLibraryService : ILocalLibraryService
|
|||||||
}
|
}
|
||||||
|
|
||||||
public string GetDownloadDirectory() => _downloadDirectory;
|
public string GetDownloadDirectory() => _downloadDirectory;
|
||||||
|
|
||||||
|
public async Task<bool> 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<ScanStatus?> 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.4" />
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.4" />
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.4" />
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.4" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
Reference in New Issue
Block a user