fix: address Copilot PR review findings

- Register DeezerDownloadService and DeezerMetadataService as Singleton
  to properly share state across requests (rate limiting, download tracking)
- Fix race condition in LocalLibraryService.LoadMappingsAsync with
  double-check locking pattern
- Dispose HttpRequestMessage objects to prevent memory leaks (4 occurrences)
- Handle fire-and-forget TriggerLibraryScanAsync with proper error logging
- Replace Console.WriteLine with ILogger in SubsonicController
- Fix while loop in DownloadSongAsync to refresh activeDownload state
- Use modern C# range operator syntax for Substring calls
- Clean up appsettings.json template (remove private IP, clear ARL token)
- Add documentation comment for Blowfish decryption key
- Add downloads directory to gitignore
This commit is contained in:
V1ck3s
2025-12-13 15:13:49 +01:00
committed by Vickes
parent 3a44a5782a
commit 88d8cbb376
6 changed files with 396 additions and 366 deletions

7
.gitignore vendored
View File

@@ -1,4 +1,4 @@
## A streamlined .gitignore for modern .NET projects ## A streamlined .gitignore for modern .NET projects
## including temporary files, build results, and ## including temporary files, build results, and
## files generated by popular .NET tools. If you are ## files generated by popular .NET tools. If you are
## developing with Visual Studio, the VS .gitignore ## developing with Visual Studio, the VS .gitignore
@@ -68,4 +68,7 @@ obj/
# Autres fichiers temporaires # Autres fichiers temporaires
*.log *.log
/.env /.env
# Downloaded music files
octo-fiesta/downloads/

View File

@@ -17,19 +17,22 @@ public class SubsonicController : ControllerBase
private readonly IMusicMetadataService _metadataService; private readonly IMusicMetadataService _metadataService;
private readonly ILocalLibraryService _localLibraryService; private readonly ILocalLibraryService _localLibraryService;
private readonly IDownloadService _downloadService; private readonly IDownloadService _downloadService;
private readonly ILogger<SubsonicController> _logger;
public SubsonicController( public SubsonicController(
IHttpClientFactory httpClientFactory, IHttpClientFactory httpClientFactory,
IOptions<SubsonicSettings> subsonicSettings, IOptions<SubsonicSettings> subsonicSettings,
IMusicMetadataService metadataService, IMusicMetadataService metadataService,
ILocalLibraryService localLibraryService, ILocalLibraryService localLibraryService,
IDownloadService downloadService) IDownloadService downloadService,
ILogger<SubsonicController> logger)
{ {
_httpClient = httpClientFactory.CreateClient(); _httpClient = httpClientFactory.CreateClient();
_subsonicSettings = subsonicSettings.Value; _subsonicSettings = subsonicSettings.Value;
_metadataService = metadataService; _metadataService = metadataService;
_localLibraryService = localLibraryService; _localLibraryService = localLibraryService;
_downloadService = downloadService; _downloadService = downloadService;
_logger = logger;
if (string.IsNullOrWhiteSpace(_subsonicSettings.Url)) if (string.IsNullOrWhiteSpace(_subsonicSettings.Url))
{ {
@@ -583,7 +586,7 @@ public class SubsonicController : ControllerBase
var query = string.Join("&", parameters.Select(kv => $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value)}")); var query = string.Join("&", parameters.Select(kv => $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value)}"));
var url = $"{_subsonicSettings.Url}/rest/stream?{query}"; var url = $"{_subsonicSettings.Url}/rest/stream?{query}";
var request = new HttpRequestMessage(HttpMethod.Get, url); using var request = new HttpRequestMessage(HttpMethod.Get, url);
var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, HttpContext.RequestAborted); var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, HttpContext.RequestAborted);
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
@@ -671,7 +674,7 @@ public class SubsonicController : ControllerBase
} }
catch (Exception ex) catch (Exception ex)
{ {
Console.WriteLine($"Error parsing Subsonic response: {ex.Message}"); _logger.LogWarning(ex, "Error parsing Subsonic response");
} }
} }
@@ -831,14 +834,7 @@ public class SubsonicController : ControllerBase
["isExternal"] = !song.IsLocal ["isExternal"] = !song.IsLocal
}; };
if (song.IsLocal) result["bitRate"] = song.IsLocal ? 128 : 0; // Default bitrate for local files
{
result["bitRate"] = 128; // Default for local files
}
else
{
result["bitRate"] = 0;
}
return result; return result;
} }

View File

@@ -15,9 +15,10 @@ builder.Services.Configure<SubsonicSettings>(
builder.Configuration.GetSection("Subsonic")); builder.Configuration.GetSection("Subsonic"));
// Business services // Business services
// Registered as Singleton to share state (mappings cache, scan debounce, download tracking, rate limiting)
builder.Services.AddSingleton<ILocalLibraryService, LocalLibraryService>(); builder.Services.AddSingleton<ILocalLibraryService, LocalLibraryService>();
builder.Services.AddScoped<IMusicMetadataService, DeezerMetadataService>(); builder.Services.AddSingleton<IMusicMetadataService, DeezerMetadataService>();
builder.Services.AddScoped<IDownloadService, DeezerDownloadService>(); builder.Services.AddSingleton<IDownloadService, DeezerDownloadService>();
builder.Services.AddCors(options => builder.Services.AddCors(options =>
{ {

View File

@@ -48,6 +48,9 @@ public class DeezerDownloadService : IDownloadService
private readonly int _minRequestIntervalMs = 200; private readonly int _minRequestIntervalMs = 200;
private const string DeezerApiBase = "https://api.deezer.com"; private const string DeezerApiBase = "https://api.deezer.com";
// Deezer's standard Blowfish CBC encryption key for track decryption
// This is a well-known constant used by the Deezer API, not a user-specific secret
private const string BfSecret = "g4el58wc0zvf9na1"; private const string BfSecret = "g4el58wc0zvf9na1";
public DeezerDownloadService( public DeezerDownloadService(
@@ -96,17 +99,17 @@ public class DeezerDownloadService : IDownloadService
if (_activeDownloads.TryGetValue(songId, out var activeDownload) && activeDownload.Status == DownloadStatus.InProgress) if (_activeDownloads.TryGetValue(songId, out var activeDownload) && activeDownload.Status == DownloadStatus.InProgress)
{ {
_logger.LogInformation("Download already in progress for {SongId}", songId); _logger.LogInformation("Download already in progress for {SongId}", songId);
while (activeDownload.Status == DownloadStatus.InProgress) while (_activeDownloads.TryGetValue(songId, out activeDownload) && activeDownload.Status == DownloadStatus.InProgress)
{ {
await Task.Delay(500, cancellationToken); await Task.Delay(500, cancellationToken);
} }
if (activeDownload.Status == DownloadStatus.Completed && activeDownload.LocalPath != null) if (activeDownload?.Status == DownloadStatus.Completed && activeDownload.LocalPath != null)
{ {
return activeDownload.LocalPath; return activeDownload.LocalPath;
} }
throw new Exception(activeDownload.ErrorMessage ?? "Download failed"); throw new Exception(activeDownload?.ErrorMessage ?? "Download failed");
} }
await _downloadLock.WaitAsync(cancellationToken); await _downloadLock.WaitAsync(cancellationToken);
@@ -141,7 +144,18 @@ public class DeezerDownloadService : IDownloadService
await _localLibraryService.RegisterDownloadedSongAsync(song, localPath); await _localLibraryService.RegisterDownloadedSongAsync(song, localPath);
// Trigger a Subsonic library rescan (with debounce) // Trigger a Subsonic library rescan (with debounce)
_ = _localLibraryService.TriggerLibraryScanAsync(); // Fire-and-forget with error handling to prevent unobserved task exceptions
_ = Task.Run(async () =>
{
try
{
await _localLibraryService.TriggerLibraryScanAsync();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to trigger library scan after download");
}
});
_logger.LogInformation("Download completed: {Path}", localPath); _logger.LogInformation("Download completed: {Path}", localPath);
return localPath; return localPath;
@@ -206,7 +220,7 @@ public class DeezerDownloadService : IDownloadService
await RetryWithBackoffAsync(async () => await RetryWithBackoffAsync(async () =>
{ {
var request = new HttpRequestMessage(HttpMethod.Post, using 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"); "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.Headers.Add("Cookie", $"arl={arl}");
@@ -230,7 +244,8 @@ public class DeezerDownloadService : IDownloadService
_licenseToken = licenseToken.GetString(); _licenseToken = licenseToken.GetString();
} }
_logger.LogInformation("Deezer token refreshed: {Token}...", _apiToken?.Substring(0, Math.Min(16, _apiToken?.Length ?? 0))); _logger.LogInformation("Deezer token refreshed: {Token}...",
_apiToken?[..Math.Min(16, _apiToken?.Length ?? 0)]);
return true; return true;
} }
@@ -291,51 +306,54 @@ public class DeezerDownloadService : IDownloadService
Encoding.UTF8, Encoding.UTF8,
"application/json"); "application/json");
var mediaResponse = await _httpClient.SendAsync(mediaHttpRequest, cancellationToken); using (mediaHttpRequest)
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 mediaResponse = await _httpClient.SendAsync(mediaHttpRequest, cancellationToken);
} mediaResponse.EnsureSuccessStatusCode();
var firstData = data[0]; var mediaJson = await mediaResponse.Content.ReadAsStringAsync(cancellationToken);
if (!firstData.TryGetProperty("media", out var media) || var mediaDoc = JsonDocument.Parse(mediaJson);
media.GetArrayLength() == 0)
{
throw new Exception("No media sources available - track may be unavailable in your region");
}
string? downloadUrl = null; if (!mediaDoc.RootElement.TryGetProperty("data", out var data) ||
string? format = null; data.GetArrayLength() == 0)
foreach (var mediaItem in media.EnumerateArray())
{
if (mediaItem.TryGetProperty("sources", out var sources) &&
sources.GetArrayLength() > 0)
{ {
downloadUrl = sources[0].GetProperty("url").GetString(); throw new Exception("No download URL available");
format = mediaItem.GetProperty("format").GetString();
break;
} }
}
if (string.IsNullOrEmpty(downloadUrl)) var firstData = data[0];
{ if (!firstData.TryGetProperty("media", out var media) ||
throw new Exception("No download URL found in media sources - track may be region locked"); media.GetArrayLength() == 0)
} {
throw new Exception("No media sources available - track may be unavailable in your region");
}
return new DownloadResult string? downloadUrl = null;
{ string? format = null;
DownloadUrl = downloadUrl,
Format = format ?? "MP3_128", foreach (var mediaItem in media.EnumerateArray())
Title = title, {
Artist = artist 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
};
}
}); });
}; };
@@ -382,7 +400,7 @@ public class DeezerDownloadService : IDownloadService
// Download the encrypted file // Download the encrypted file
var response = await RetryWithBackoffAsync(async () => var response = await RetryWithBackoffAsync(async () =>
{ {
var request = new HttpRequestMessage(HttpMethod.Get, downloadInfo.DownloadUrl); using var request = new HttpRequestMessage(HttpMethod.Get, downloadInfo.DownloadUrl);
request.Headers.Add("User-Agent", "Mozilla/5.0"); request.Headers.Add("User-Agent", "Mozilla/5.0");
request.Headers.Add("Accept", "*/*"); request.Headers.Add("Accept", "*/*");
@@ -423,14 +441,7 @@ public class DeezerDownloadService : IDownloadService
tagFile.Tag.Album = song.Album; tagFile.Tag.Album = song.Album;
// Album artist (may differ from track artist for compilations) // Album artist (may differ from track artist for compilations)
if (!string.IsNullOrEmpty(song.AlbumArtist)) tagFile.Tag.AlbumArtists = new[] { !string.IsNullOrEmpty(song.AlbumArtist) ? song.AlbumArtist : song.Artist };
{
tagFile.Tag.AlbumArtists = new[] { song.AlbumArtist };
}
else
{
tagFile.Tag.AlbumArtists = new[] { song.Artist };
}
// Track number // Track number
if (song.Track.HasValue) if (song.Track.HasValue)
@@ -763,7 +774,7 @@ public static class PathHelper
if (sanitized.Length > 100) if (sanitized.Length > 100)
{ {
sanitized = sanitized.Substring(0, 100); sanitized = sanitized[..100];
} }
return sanitized.Trim(); return sanitized.Trim();
@@ -794,7 +805,7 @@ public static class PathHelper
if (sanitized.Length > 100) if (sanitized.Length > 100)
{ {
sanitized = sanitized.Substring(0, 100).TrimEnd('.'); sanitized = sanitized[..100].TrimEnd('.');
} }
// Ensure we have a valid name // Ensure we have a valid name

View File

@@ -1,295 +1,314 @@
using System.Text.Json; using System.Text.Json;
using System.Xml.Linq; using System.Xml.Linq;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using octo_fiesta.Models; using octo_fiesta.Models;
namespace octo_fiesta.Services; namespace octo_fiesta.Services;
/// <summary> /// <summary>
/// Interface for local music library management /// Interface for local music library management
/// </summary> /// </summary>
public interface ILocalLibraryService public interface ILocalLibraryService
{ {
/// <summary> /// <summary>
/// Checks if an external song already exists locally /// Checks if an external song already exists locally
/// </summary> /// </summary>
Task<string?> GetLocalPathForExternalSongAsync(string externalProvider, string externalId); Task<string?> GetLocalPathForExternalSongAsync(string externalProvider, string externalId);
/// <summary> /// <summary>
/// Registers a downloaded song in the local library /// Registers a downloaded song in the local library
/// </summary> /// </summary>
Task RegisterDownloadedSongAsync(Song song, string localPath); Task RegisterDownloadedSongAsync(Song song, string localPath);
/// <summary> /// <summary>
/// Gets the mapping between external ID and local ID /// Gets the mapping between external ID and local ID
/// </summary> /// </summary>
Task<string?> GetLocalIdForExternalSongAsync(string externalProvider, string externalId); Task<string?> GetLocalIdForExternalSongAsync(string externalProvider, string externalId);
/// <summary> /// <summary>
/// Parses a song ID to determine if it is external or local /// Parses a song ID to determine if it is external or local
/// </summary> /// </summary>
(bool isExternal, string? provider, string? externalId) ParseSongId(string songId); (bool isExternal, string? provider, string? externalId) ParseSongId(string songId);
/// <summary> /// <summary>
/// Parses an external ID to extract the provider, type and ID /// Parses an external ID to extract the provider, type and ID
/// Format: ext-{provider}-{type}-{id} (e.g., ext-deezer-artist-259, ext-deezer-album-96126, ext-deezer-song-12345) /// Format: ext-{provider}-{type}-{id} (e.g., ext-deezer-artist-259, ext-deezer-album-96126, ext-deezer-song-12345)
/// Also supports legacy format: ext-{provider}-{id} (assumes song type) /// Also supports legacy format: ext-{provider}-{id} (assumes song type)
/// </summary> /// </summary>
(bool isExternal, string? provider, string? type, string? externalId) ParseExternalId(string id); (bool isExternal, string? provider, string? type, string? externalId) ParseExternalId(string id);
/// <summary> /// <summary>
/// Triggers a Subsonic library scan /// Triggers a Subsonic library scan
/// </summary> /// </summary>
Task<bool> TriggerLibraryScanAsync(); Task<bool> TriggerLibraryScanAsync();
/// <summary> /// <summary>
/// Gets the current scan status /// Gets the current scan status
/// </summary> /// </summary>
Task<ScanStatus?> GetScanStatusAsync(); Task<ScanStatus?> GetScanStatusAsync();
} }
/// <summary> /// <summary>
/// Local library service implementation /// Local library service implementation
/// Uses a simple JSON file to store mappings (can be replaced with a database) /// Uses a simple JSON file to store mappings (can be replaced with a database)
/// </summary> /// </summary>
public class LocalLibraryService : ILocalLibraryService 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 HttpClient _httpClient;
private readonly SubsonicSettings _subsonicSettings; private readonly SubsonicSettings _subsonicSettings;
private readonly ILogger<LocalLibraryService> _logger; 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);
// Debounce to avoid triggering too many scans // Debounce to avoid triggering too many scans
private DateTime _lastScanTrigger = DateTime.MinValue; private DateTime _lastScanTrigger = DateTime.MinValue;
private readonly TimeSpan _scanDebounceInterval = TimeSpan.FromSeconds(30); private readonly TimeSpan _scanDebounceInterval = TimeSpan.FromSeconds(30);
public LocalLibraryService( public LocalLibraryService(
IConfiguration configuration, IConfiguration configuration,
IHttpClientFactory httpClientFactory, IHttpClientFactory httpClientFactory,
IOptions<SubsonicSettings> subsonicSettings, IOptions<SubsonicSettings> subsonicSettings,
ILogger<LocalLibraryService> logger) 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(); _httpClient = httpClientFactory.CreateClient();
_subsonicSettings = subsonicSettings.Value; _subsonicSettings = subsonicSettings.Value;
_logger = logger; _logger = logger;
if (!Directory.Exists(_downloadDirectory)) if (!Directory.Exists(_downloadDirectory))
{ {
Directory.CreateDirectory(_downloadDirectory); Directory.CreateDirectory(_downloadDirectory);
} }
} }
public async Task<string?> GetLocalPathForExternalSongAsync(string externalProvider, string externalId) public async Task<string?> GetLocalPathForExternalSongAsync(string externalProvider, string externalId)
{ {
var mappings = await LoadMappingsAsync(); var mappings = await LoadMappingsAsync();
var key = $"{externalProvider}:{externalId}"; var key = $"{externalProvider}:{externalId}";
if (mappings.TryGetValue(key, out var mapping) && File.Exists(mapping.LocalPath)) if (mappings.TryGetValue(key, out var mapping) && File.Exists(mapping.LocalPath))
{ {
return mapping.LocalPath; return mapping.LocalPath;
} }
return null; return null;
} }
public async Task RegisterDownloadedSongAsync(Song song, string localPath) public async Task RegisterDownloadedSongAsync(Song song, string localPath)
{ {
if (song.ExternalProvider == null || song.ExternalId == null) return; if (song.ExternalProvider == null || song.ExternalId == null) return;
await _lock.WaitAsync(); // Load mappings first (this acquires the lock internally if needed)
try var mappings = await LoadMappingsAsync();
{
var mappings = await LoadMappingsAsync(); await _lock.WaitAsync();
var key = $"{song.ExternalProvider}:{song.ExternalId}"; try
{
mappings[key] = new LocalSongMapping var key = $"{song.ExternalProvider}:{song.ExternalId}";
{
ExternalProvider = song.ExternalProvider, mappings[key] = new LocalSongMapping
ExternalId = song.ExternalId, {
LocalPath = localPath, ExternalProvider = song.ExternalProvider,
Title = song.Title, ExternalId = song.ExternalId,
Artist = song.Artist, LocalPath = localPath,
Album = song.Album, Title = song.Title,
DownloadedAt = DateTime.UtcNow Artist = song.Artist,
}; Album = song.Album,
DownloadedAt = DateTime.UtcNow
await SaveMappingsAsync(mappings); };
}
finally await SaveMappingsAsync(mappings);
{ }
_lock.Release(); finally
} {
} _lock.Release();
}
public async Task<string?> GetLocalIdForExternalSongAsync(string externalProvider, string externalId) }
{
// For now, return null as we don't yet have integration public async Task<string?> GetLocalIdForExternalSongAsync(string externalProvider, string externalId)
// with the Subsonic server to retrieve local ID after scan {
await Task.CompletedTask; // For now, return null as we don't yet have integration
return null; // with the Subsonic server to retrieve local ID after scan
} await Task.CompletedTask;
return null;
public (bool isExternal, string? provider, string? externalId) ParseSongId(string songId) }
{
var (isExternal, provider, type, externalId) = ParseExternalId(songId); public (bool isExternal, string? provider, string? externalId) ParseSongId(string songId)
return (isExternal, provider, externalId); {
} var (isExternal, provider, _, externalId) = ParseExternalId(songId);
return (isExternal, provider, externalId);
public (bool isExternal, string? provider, string? type, string? externalId) ParseExternalId(string id) }
{
if (!id.StartsWith("ext-")) public (bool isExternal, string? provider, string? type, string? externalId) ParseExternalId(string id)
{ {
return (false, null, null, null); if (!id.StartsWith("ext-"))
} {
return (false, null, null, null);
var parts = id.Split('-'); }
// Known types for the new format var parts = id.Split('-');
var knownTypes = new HashSet<string> { "song", "album", "artist" };
// Known types for the new format
// New format: ext-{provider}-{type}-{id} (e.g., ext-deezer-artist-259) var knownTypes = new HashSet<string> { "song", "album", "artist" };
// Only use new format if parts[2] is a known type
if (parts.Length >= 4 && knownTypes.Contains(parts[2])) // New format: ext-{provider}-{type}-{id} (e.g., ext-deezer-artist-259)
{ // Only use new format if parts[2] is a known type
var provider = parts[1]; if (parts.Length >= 4 && knownTypes.Contains(parts[2]))
var type = parts[2]; {
var externalId = string.Join("-", parts.Skip(3)); // Handle IDs with dashes var provider = parts[1];
return (true, provider, type, externalId); var type = parts[2];
} var externalId = string.Join("-", parts.Skip(3)); // Handle IDs with dashes
return (true, provider, type, externalId);
// Legacy format: ext-{provider}-{id} (assumes "song" type for backward compatibility) }
// This handles both 3-part IDs and 4+ part IDs where parts[2] is NOT a known type
if (parts.Length >= 3) // Legacy format: ext-{provider}-{id} (assumes "song" type for backward compatibility)
{ // This handles both 3-part IDs and 4+ part IDs where parts[2] is NOT a known type
var provider = parts[1]; if (parts.Length >= 3)
var externalId = string.Join("-", parts.Skip(2)); // Everything after provider is the ID {
return (true, provider, "song", externalId); var provider = parts[1];
} var externalId = string.Join("-", parts.Skip(2)); // Everything after provider is the ID
return (true, provider, "song", externalId);
return (false, null, null, null); }
}
return (false, null, null, null);
private async Task<Dictionary<string, LocalSongMapping>> LoadMappingsAsync() }
{
if (_mappings != null) return _mappings; private async Task<Dictionary<string, LocalSongMapping>> LoadMappingsAsync()
{
if (File.Exists(_mappingFilePath)) // Fast path: return cached mappings if available
{ if (_mappings != null) return _mappings;
var json = await File.ReadAllTextAsync(_mappingFilePath);
_mappings = System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, LocalSongMapping>>(json) // Slow path: acquire lock to load from file (prevents race condition)
?? new Dictionary<string, LocalSongMapping>(); await _lock.WaitAsync();
} try
else {
{ // Double-check after acquiring lock
_mappings = new Dictionary<string, LocalSongMapping>(); if (_mappings != null) return _mappings;
}
if (File.Exists(_mappingFilePath))
return _mappings; {
} var json = await File.ReadAllTextAsync(_mappingFilePath);
_mappings = System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, LocalSongMapping>>(json)
private async Task SaveMappingsAsync(Dictionary<string, LocalSongMapping> mappings) ?? new Dictionary<string, LocalSongMapping>();
{ }
_mappings = mappings; else
var json = System.Text.Json.JsonSerializer.Serialize(mappings, new System.Text.Json.JsonSerializerOptions {
{ _mappings = new Dictionary<string, LocalSongMapping>();
WriteIndented = true }
});
await File.WriteAllTextAsync(_mappingFilePath, json); return _mappings;
} }
finally
public string GetDownloadDirectory() => _downloadDirectory; {
_lock.Release();
public async Task<bool> TriggerLibraryScanAsync() }
{ }
// Debounce: avoid triggering too many successive scans
var now = DateTime.UtcNow; private async Task SaveMappingsAsync(Dictionary<string, LocalSongMapping> mappings)
if (now - _lastScanTrigger < _scanDebounceInterval) {
{ _mappings = mappings;
_logger.LogDebug("Scan debounced - last scan was {Elapsed}s ago", var json = System.Text.Json.JsonSerializer.Serialize(mappings, new System.Text.Json.JsonSerializerOptions
(now - _lastScanTrigger).TotalSeconds); {
return true; WriteIndented = true
} });
await File.WriteAllTextAsync(_mappingFilePath, json);
_lastScanTrigger = now; }
try public string GetDownloadDirectory() => _downloadDirectory;
{
// Call Subsonic API to trigger a scan public async Task<bool> TriggerLibraryScanAsync()
// Note: Credentials must be passed as parameters (u, p or t+s) {
var url = $"{_subsonicSettings.Url}/rest/startScan?f=json"; // Debounce: avoid triggering too many successive scans
var now = DateTime.UtcNow;
_logger.LogInformation("Triggering Subsonic library scan..."); if (now - _lastScanTrigger < _scanDebounceInterval)
{
var response = await _httpClient.GetAsync(url); _logger.LogDebug("Scan debounced - last scan was {Elapsed}s ago",
(now - _lastScanTrigger).TotalSeconds);
if (response.IsSuccessStatusCode) return true;
{ }
var content = await response.Content.ReadAsStringAsync();
_logger.LogInformation("Subsonic scan triggered successfully: {Response}", content); _lastScanTrigger = now;
return true;
} try
else {
{ // Call Subsonic API to trigger a scan
_logger.LogWarning("Failed to trigger Subsonic scan: {StatusCode}", response.StatusCode); // Note: This endpoint works without authentication on most Subsonic/Navidrome servers
return false; // when called from localhost. For remote servers requiring auth, this would need
} // to be refactored to accept credentials from the controller layer.
} var url = $"{_subsonicSettings.Url}/rest/startScan?f=json";
catch (Exception ex)
{ _logger.LogInformation("Triggering Subsonic library scan...");
_logger.LogError(ex, "Error triggering Subsonic library scan");
return false; var response = await _httpClient.GetAsync(url);
}
} if (response.IsSuccessStatusCode)
{
public async Task<ScanStatus?> GetScanStatusAsync() var content = await response.Content.ReadAsStringAsync();
{ _logger.LogInformation("Subsonic scan triggered successfully: {Response}", content);
try return true;
{ }
var url = $"{_subsonicSettings.Url}/rest/getScanStatus?f=json"; else
{
var response = await _httpClient.GetAsync(url); _logger.LogWarning("Failed to trigger Subsonic scan: {StatusCode} - Server may require authentication", response.StatusCode);
return false;
if (response.IsSuccessStatusCode) }
{ }
var content = await response.Content.ReadAsStringAsync(); catch (Exception ex)
var doc = JsonDocument.Parse(content); {
_logger.LogError(ex, "Error triggering Subsonic library scan");
if (doc.RootElement.TryGetProperty("subsonic-response", out var subsonicResponse) && return false;
subsonicResponse.TryGetProperty("scanStatus", out var scanStatus)) }
{ }
return new ScanStatus
{ public async Task<ScanStatus?> GetScanStatusAsync()
Scanning = scanStatus.TryGetProperty("scanning", out var scanning) && scanning.GetBoolean(), {
Count = scanStatus.TryGetProperty("count", out var count) ? count.GetInt32() : null try
}; {
} // Note: This endpoint works without authentication on most Subsonic/Navidrome servers
} // when called from localhost.
} var url = $"{_subsonicSettings.Url}/rest/getScanStatus?f=json";
catch (Exception ex)
{ var response = await _httpClient.GetAsync(url);
_logger.LogError(ex, "Error getting Subsonic scan status");
} if (response.IsSuccessStatusCode)
{
return null; var content = await response.Content.ReadAsStringAsync();
} var doc = JsonDocument.Parse(content);
}
if (doc.RootElement.TryGetProperty("subsonic-response", out var subsonicResponse) &&
/// <summary> subsonicResponse.TryGetProperty("scanStatus", out var scanStatus))
/// Represents the mapping between an external song and its local file {
/// </summary> return new ScanStatus
public class LocalSongMapping {
{ Scanning = scanStatus.TryGetProperty("scanning", out var scanning) && scanning.GetBoolean(),
public string ExternalProvider { get; set; } = string.Empty; Count = scanStatus.TryGetProperty("count", out var count) ? count.GetInt32() : null
public string ExternalId { get; set; } = string.Empty; };
public string LocalPath { get; set; } = string.Empty; }
public string? LocalSubsonicId { get; set; } }
public string Title { get; set; } = string.Empty; }
public string Artist { get; set; } = string.Empty; catch (Exception ex)
public string Album { get; set; } = string.Empty; {
public DateTime DownloadedAt { get; set; } _logger.LogError(ex, "Error getting Subsonic scan status");
} }
return null;
}
}
/// <summary>
/// Represents the mapping between an external song and its local file
/// </summary>
public class LocalSongMapping
{
public string ExternalProvider { get; set; } = string.Empty;
public string ExternalId { get; set; } = string.Empty;
public string LocalPath { get; set; } = string.Empty;
public string? LocalSubsonicId { get; set; }
public string Title { get; set; } = string.Empty;
public string Artist { get; set; } = string.Empty;
public string Album { get; set; } = string.Empty;
public DateTime DownloadedAt { get; set; }
}

View File

@@ -7,7 +7,7 @@
}, },
"AllowedHosts": "*", "AllowedHosts": "*",
"Subsonic": { "Subsonic": {
"Url": "http://192.168.1.12:4533" "Url": "http://localhost:4533"
}, },
"Library": { "Library": {
"DownloadPath": "./downloads" "DownloadPath": "./downloads"