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:
@@ -112,3 +112,12 @@ public class DownloadInfo
|
||||
public DateTime StartedAt { 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.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
|
||||
|
||||
@@ -18,65 +18,89 @@ public class DeezerMetadataService : IMusicMetadataService
|
||||
|
||||
public async Task<List<Song>> 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<Song>();
|
||||
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<Song>();
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
var result = JsonDocument.Parse(json);
|
||||
|
||||
var songs = new List<Song>();
|
||||
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<Song>();
|
||||
}
|
||||
|
||||
return songs;
|
||||
}
|
||||
|
||||
public async Task<List<Album>> 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<Album>();
|
||||
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<Album>();
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
var result = JsonDocument.Parse(json);
|
||||
|
||||
var albums = new List<Album>();
|
||||
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<Album>();
|
||||
}
|
||||
|
||||
return albums;
|
||||
}
|
||||
|
||||
public async Task<List<Artist>> 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<Artist>();
|
||||
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<Artist>();
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
var result = JsonDocument.Parse(json);
|
||||
|
||||
var artists = new List<Artist>();
|
||||
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<Artist>();
|
||||
}
|
||||
|
||||
return artists;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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
|
||||
/// </summary>
|
||||
(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>
|
||||
@@ -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<LocalLibraryService> _logger;
|
||||
private Dictionary<string, LocalSongMapping>? _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> subsonicSettings,
|
||||
ILogger<LocalLibraryService> 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<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>
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.4" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.4" />
|
||||
</ItemGroup>
|
||||
|
||||
Reference in New Issue
Block a user