diff --git a/.gitignore b/.gitignore index b3a9b1c..81b563b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -## A streamlined .gitignore for modern .NET projects +## A streamlined .gitignore for modern .NET projects ## including temporary files, build results, and ## files generated by popular .NET tools. If you are ## developing with Visual Studio, the VS .gitignore @@ -68,4 +68,7 @@ obj/ # Autres fichiers temporaires *.log -/.env \ No newline at end of file +/.env + +# Downloaded music files +octo-fiesta/downloads/ \ No newline at end of file diff --git a/octo-fiesta/Controllers/SubSonicController.cs b/octo-fiesta/Controllers/SubSonicController.cs index 7134b03..17d7ba6 100644 --- a/octo-fiesta/Controllers/SubSonicController.cs +++ b/octo-fiesta/Controllers/SubSonicController.cs @@ -17,19 +17,22 @@ public class SubsonicController : ControllerBase private readonly IMusicMetadataService _metadataService; private readonly ILocalLibraryService _localLibraryService; private readonly IDownloadService _downloadService; + private readonly ILogger _logger; public SubsonicController( IHttpClientFactory httpClientFactory, IOptions subsonicSettings, IMusicMetadataService metadataService, ILocalLibraryService localLibraryService, - IDownloadService downloadService) + IDownloadService downloadService, + ILogger logger) { _httpClient = httpClientFactory.CreateClient(); _subsonicSettings = subsonicSettings.Value; _metadataService = metadataService; _localLibraryService = localLibraryService; _downloadService = downloadService; + _logger = logger; 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 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); if (!response.IsSuccessStatusCode) @@ -671,7 +674,7 @@ public class SubsonicController : ControllerBase } 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 }; - if (song.IsLocal) - { - result["bitRate"] = 128; // Default for local files - } - else - { - result["bitRate"] = 0; - } + result["bitRate"] = song.IsLocal ? 128 : 0; // Default bitrate for local files return result; } diff --git a/octo-fiesta/Program.cs b/octo-fiesta/Program.cs index dbbe5cc..dea7e9a 100644 --- a/octo-fiesta/Program.cs +++ b/octo-fiesta/Program.cs @@ -15,9 +15,10 @@ builder.Services.Configure( builder.Configuration.GetSection("Subsonic")); // Business services +// Registered as Singleton to share state (mappings cache, scan debounce, download tracking, rate limiting) builder.Services.AddSingleton(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddCors(options => { diff --git a/octo-fiesta/Services/DeezerDownloadService.cs b/octo-fiesta/Services/DeezerDownloadService.cs index 85247a8..a38110f 100644 --- a/octo-fiesta/Services/DeezerDownloadService.cs +++ b/octo-fiesta/Services/DeezerDownloadService.cs @@ -48,6 +48,9 @@ public class DeezerDownloadService : IDownloadService private readonly int _minRequestIntervalMs = 200; 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"; public DeezerDownloadService( @@ -96,17 +99,17 @@ public class DeezerDownloadService : IDownloadService if (_activeDownloads.TryGetValue(songId, out var activeDownload) && activeDownload.Status == DownloadStatus.InProgress) { _logger.LogInformation("Download already in progress for {SongId}", songId); - while (activeDownload.Status == DownloadStatus.InProgress) + while (_activeDownloads.TryGetValue(songId, out activeDownload) && activeDownload.Status == DownloadStatus.InProgress) { await Task.Delay(500, cancellationToken); } - if (activeDownload.Status == DownloadStatus.Completed && activeDownload.LocalPath != null) + if (activeDownload?.Status == DownloadStatus.Completed && activeDownload.LocalPath != null) { return activeDownload.LocalPath; } - throw new Exception(activeDownload.ErrorMessage ?? "Download failed"); + throw new Exception(activeDownload?.ErrorMessage ?? "Download failed"); } await _downloadLock.WaitAsync(cancellationToken); @@ -141,7 +144,18 @@ public class DeezerDownloadService : IDownloadService await _localLibraryService.RegisterDownloadedSongAsync(song, localPath); // 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); return localPath; @@ -206,7 +220,7 @@ public class DeezerDownloadService : IDownloadService 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"); request.Headers.Add("Cookie", $"arl={arl}"); @@ -230,7 +244,8 @@ public class DeezerDownloadService : IDownloadService _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; } @@ -291,51 +306,54 @@ public class DeezerDownloadService : IDownloadService Encoding.UTF8, "application/json"); - var mediaResponse = await _httpClient.SendAsync(mediaHttpRequest, cancellationToken); - mediaResponse.EnsureSuccessStatusCode(); - - var mediaJson = await mediaResponse.Content.ReadAsStringAsync(cancellationToken); - var mediaDoc = JsonDocument.Parse(mediaJson); - - if (!mediaDoc.RootElement.TryGetProperty("data", out var data) || - data.GetArrayLength() == 0) + using (mediaHttpRequest) { - throw new Exception("No download URL available"); - } + var mediaResponse = await _httpClient.SendAsync(mediaHttpRequest, cancellationToken); + mediaResponse.EnsureSuccessStatusCode(); - var firstData = data[0]; - if (!firstData.TryGetProperty("media", out var media) || - media.GetArrayLength() == 0) - { - throw new Exception("No media sources available - track may be unavailable in your region"); - } + var mediaJson = await mediaResponse.Content.ReadAsStringAsync(cancellationToken); + var mediaDoc = JsonDocument.Parse(mediaJson); - string? downloadUrl = null; - string? format = null; - - foreach (var mediaItem in media.EnumerateArray()) - { - if (mediaItem.TryGetProperty("sources", out var sources) && - sources.GetArrayLength() > 0) + if (!mediaDoc.RootElement.TryGetProperty("data", out var data) || + data.GetArrayLength() == 0) { - downloadUrl = sources[0].GetProperty("url").GetString(); - format = mediaItem.GetProperty("format").GetString(); - break; + throw new Exception("No download URL available"); } - } - if (string.IsNullOrEmpty(downloadUrl)) - { - throw new Exception("No download URL found in media sources - track may be region locked"); - } + var firstData = data[0]; + if (!firstData.TryGetProperty("media", out var media) || + media.GetArrayLength() == 0) + { + throw new Exception("No media sources available - track may be unavailable in your region"); + } - return new DownloadResult - { - DownloadUrl = downloadUrl, - Format = format ?? "MP3_128", - Title = title, - Artist = artist - }; + string? downloadUrl = null; + string? format = null; + + foreach (var mediaItem in media.EnumerateArray()) + { + if (mediaItem.TryGetProperty("sources", out var sources) && + sources.GetArrayLength() > 0) + { + downloadUrl = sources[0].GetProperty("url").GetString(); + format = mediaItem.GetProperty("format").GetString(); + break; + } + } + + if (string.IsNullOrEmpty(downloadUrl)) + { + throw new Exception("No download URL found in media sources - track may be region locked"); + } + + return new DownloadResult + { + DownloadUrl = downloadUrl, + Format = format ?? "MP3_128", + Title = title, + Artist = artist + }; + } }); }; @@ -382,7 +400,7 @@ public class DeezerDownloadService : IDownloadService // Download the encrypted file 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("Accept", "*/*"); @@ -423,14 +441,7 @@ public class DeezerDownloadService : IDownloadService tagFile.Tag.Album = song.Album; // Album artist (may differ from track artist for compilations) - if (!string.IsNullOrEmpty(song.AlbumArtist)) - { - tagFile.Tag.AlbumArtists = new[] { song.AlbumArtist }; - } - else - { - tagFile.Tag.AlbumArtists = new[] { song.Artist }; - } + tagFile.Tag.AlbumArtists = new[] { !string.IsNullOrEmpty(song.AlbumArtist) ? song.AlbumArtist : song.Artist }; // Track number if (song.Track.HasValue) @@ -763,7 +774,7 @@ public static class PathHelper if (sanitized.Length > 100) { - sanitized = sanitized.Substring(0, 100); + sanitized = sanitized[..100]; } return sanitized.Trim(); @@ -794,7 +805,7 @@ public static class PathHelper if (sanitized.Length > 100) { - sanitized = sanitized.Substring(0, 100).TrimEnd('.'); + sanitized = sanitized[..100].TrimEnd('.'); } // Ensure we have a valid name diff --git a/octo-fiesta/Services/LocalLibraryService.cs b/octo-fiesta/Services/LocalLibraryService.cs index fb93134..dec4e20 100644 --- a/octo-fiesta/Services/LocalLibraryService.cs +++ b/octo-fiesta/Services/LocalLibraryService.cs @@ -1,295 +1,314 @@ -using System.Text.Json; -using System.Xml.Linq; -using Microsoft.Extensions.Options; -using octo_fiesta.Models; - -namespace octo_fiesta.Services; - -/// -/// Interface for local music library management -/// -public interface ILocalLibraryService -{ - /// - /// Checks if an external song already exists locally - /// - Task GetLocalPathForExternalSongAsync(string externalProvider, string externalId); - - /// - /// Registers a downloaded song in the local library - /// - Task RegisterDownloadedSongAsync(Song song, string localPath); - - /// - /// Gets the mapping between external ID and local ID - /// - Task GetLocalIdForExternalSongAsync(string externalProvider, string externalId); - - /// - /// Parses a song ID to determine if it is external or local - /// - (bool isExternal, string? provider, string? externalId) ParseSongId(string songId); - - /// - /// 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) - /// Also supports legacy format: ext-{provider}-{id} (assumes song type) - /// - (bool isExternal, string? provider, string? type, string? externalId) ParseExternalId(string id); - - /// - /// Triggers a Subsonic library scan - /// - Task TriggerLibraryScanAsync(); - - /// - /// Gets the current scan status - /// - Task GetScanStatusAsync(); -} - -/// -/// Local library service implementation -/// Uses a simple JSON file to store mappings (can be replaced with a database) -/// -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 to avoid triggering too many scans - private DateTime _lastScanTrigger = DateTime.MinValue; - private readonly TimeSpan _scanDebounceInterval = TimeSpan.FromSeconds(30); - - 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)) - { - Directory.CreateDirectory(_downloadDirectory); - } - } - - public async Task GetLocalPathForExternalSongAsync(string externalProvider, string externalId) - { - var mappings = await LoadMappingsAsync(); - var key = $"{externalProvider}:{externalId}"; - - if (mappings.TryGetValue(key, out var mapping) && File.Exists(mapping.LocalPath)) - { - return mapping.LocalPath; - } - - return null; - } - - public async Task RegisterDownloadedSongAsync(Song song, string localPath) - { - if (song.ExternalProvider == null || song.ExternalId == null) return; - - await _lock.WaitAsync(); - try - { - var mappings = await LoadMappingsAsync(); - var key = $"{song.ExternalProvider}:{song.ExternalId}"; - - mappings[key] = new LocalSongMapping - { - ExternalProvider = song.ExternalProvider, - ExternalId = song.ExternalId, - LocalPath = localPath, - Title = song.Title, - Artist = song.Artist, - Album = song.Album, - DownloadedAt = DateTime.UtcNow - }; - - await SaveMappingsAsync(mappings); - } - finally - { - _lock.Release(); - } - } - - public async Task GetLocalIdForExternalSongAsync(string externalProvider, string externalId) - { - // For now, return null as we don't yet have integration - // 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); - return (isExternal, provider, externalId); - } - - public (bool isExternal, string? provider, string? type, string? externalId) ParseExternalId(string id) - { - if (!id.StartsWith("ext-")) - { - return (false, null, null, null); - } - - var parts = id.Split('-'); - - // Known types for the new format - var knownTypes = new HashSet { "song", "album", "artist" }; - - // New format: ext-{provider}-{type}-{id} (e.g., ext-deezer-artist-259) - // Only use new format if parts[2] is a known type - if (parts.Length >= 4 && knownTypes.Contains(parts[2])) - { - var provider = parts[1]; - 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) - { - 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); - } - - private async Task> LoadMappingsAsync() - { - if (_mappings != null) return _mappings; - - if (File.Exists(_mappingFilePath)) - { - var json = await File.ReadAllTextAsync(_mappingFilePath); - _mappings = System.Text.Json.JsonSerializer.Deserialize>(json) - ?? new Dictionary(); - } - else - { - _mappings = new Dictionary(); - } - - return _mappings; - } - - private async Task SaveMappingsAsync(Dictionary mappings) - { - _mappings = mappings; - var json = System.Text.Json.JsonSerializer.Serialize(mappings, new System.Text.Json.JsonSerializerOptions - { - WriteIndented = true - }); - await File.WriteAllTextAsync(_mappingFilePath, json); - } - - public string GetDownloadDirectory() => _downloadDirectory; - - public async Task TriggerLibraryScanAsync() - { - // Debounce: avoid triggering too many successive scans - 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 - { - // Call Subsonic API to trigger a scan - // Note: Credentials must be passed as parameters (u, p or 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; - } -} - -/// -/// Represents the mapping between an external song and its local file -/// -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; } -} +using System.Text.Json; +using System.Xml.Linq; +using Microsoft.Extensions.Options; +using octo_fiesta.Models; + +namespace octo_fiesta.Services; + +/// +/// Interface for local music library management +/// +public interface ILocalLibraryService +{ + /// + /// Checks if an external song already exists locally + /// + Task GetLocalPathForExternalSongAsync(string externalProvider, string externalId); + + /// + /// Registers a downloaded song in the local library + /// + Task RegisterDownloadedSongAsync(Song song, string localPath); + + /// + /// Gets the mapping between external ID and local ID + /// + Task GetLocalIdForExternalSongAsync(string externalProvider, string externalId); + + /// + /// Parses a song ID to determine if it is external or local + /// + (bool isExternal, string? provider, string? externalId) ParseSongId(string songId); + + /// + /// 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) + /// Also supports legacy format: ext-{provider}-{id} (assumes song type) + /// + (bool isExternal, string? provider, string? type, string? externalId) ParseExternalId(string id); + + /// + /// Triggers a Subsonic library scan + /// + Task TriggerLibraryScanAsync(); + + /// + /// Gets the current scan status + /// + Task GetScanStatusAsync(); +} + +/// +/// Local library service implementation +/// Uses a simple JSON file to store mappings (can be replaced with a database) +/// +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 to avoid triggering too many scans + private DateTime _lastScanTrigger = DateTime.MinValue; + private readonly TimeSpan _scanDebounceInterval = TimeSpan.FromSeconds(30); + + 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)) + { + Directory.CreateDirectory(_downloadDirectory); + } + } + + public async Task GetLocalPathForExternalSongAsync(string externalProvider, string externalId) + { + var mappings = await LoadMappingsAsync(); + var key = $"{externalProvider}:{externalId}"; + + if (mappings.TryGetValue(key, out var mapping) && File.Exists(mapping.LocalPath)) + { + return mapping.LocalPath; + } + + return null; + } + + public async Task RegisterDownloadedSongAsync(Song song, string localPath) + { + if (song.ExternalProvider == null || song.ExternalId == null) return; + + // Load mappings first (this acquires the lock internally if needed) + var mappings = await LoadMappingsAsync(); + + await _lock.WaitAsync(); + try + { + var key = $"{song.ExternalProvider}:{song.ExternalId}"; + + mappings[key] = new LocalSongMapping + { + ExternalProvider = song.ExternalProvider, + ExternalId = song.ExternalId, + LocalPath = localPath, + Title = song.Title, + Artist = song.Artist, + Album = song.Album, + DownloadedAt = DateTime.UtcNow + }; + + await SaveMappingsAsync(mappings); + } + finally + { + _lock.Release(); + } + } + + public async Task GetLocalIdForExternalSongAsync(string externalProvider, string externalId) + { + // For now, return null as we don't yet have integration + // 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, _, externalId) = ParseExternalId(songId); + return (isExternal, provider, externalId); + } + + public (bool isExternal, string? provider, string? type, string? externalId) ParseExternalId(string id) + { + if (!id.StartsWith("ext-")) + { + return (false, null, null, null); + } + + var parts = id.Split('-'); + + // Known types for the new format + var knownTypes = new HashSet { "song", "album", "artist" }; + + // New format: ext-{provider}-{type}-{id} (e.g., ext-deezer-artist-259) + // Only use new format if parts[2] is a known type + if (parts.Length >= 4 && knownTypes.Contains(parts[2])) + { + var provider = parts[1]; + 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) + { + 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); + } + + private async Task> LoadMappingsAsync() + { + // Fast path: return cached mappings if available + if (_mappings != null) return _mappings; + + // Slow path: acquire lock to load from file (prevents race condition) + await _lock.WaitAsync(); + try + { + // Double-check after acquiring lock + if (_mappings != null) return _mappings; + + if (File.Exists(_mappingFilePath)) + { + var json = await File.ReadAllTextAsync(_mappingFilePath); + _mappings = System.Text.Json.JsonSerializer.Deserialize>(json) + ?? new Dictionary(); + } + else + { + _mappings = new Dictionary(); + } + + return _mappings; + } + finally + { + _lock.Release(); + } + } + + private async Task SaveMappingsAsync(Dictionary mappings) + { + _mappings = mappings; + var json = System.Text.Json.JsonSerializer.Serialize(mappings, new System.Text.Json.JsonSerializerOptions + { + WriteIndented = true + }); + await File.WriteAllTextAsync(_mappingFilePath, json); + } + + public string GetDownloadDirectory() => _downloadDirectory; + + public async Task TriggerLibraryScanAsync() + { + // Debounce: avoid triggering too many successive scans + 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 + { + // Call Subsonic API to trigger a scan + // Note: This endpoint works without authentication on most Subsonic/Navidrome servers + // 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"; + + _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} - Server may require authentication", response.StatusCode); + return false; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error triggering Subsonic library scan"); + return false; + } + } + + public async Task GetScanStatusAsync() + { + try + { + // Note: This endpoint works without authentication on most Subsonic/Navidrome servers + // when called from localhost. + 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; + } +} + +/// +/// Represents the mapping between an external song and its local file +/// +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; } +} diff --git a/octo-fiesta/appsettings.json b/octo-fiesta/appsettings.json index 63fcbd6..f2ff391 100644 --- a/octo-fiesta/appsettings.json +++ b/octo-fiesta/appsettings.json @@ -7,7 +7,7 @@ }, "AllowedHosts": "*", "Subsonic": { - "Url": "http://192.168.1.12:4533" + "Url": "http://localhost:4533" }, "Library": { "DownloadPath": "./downloads"