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