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; } }