Files
allstarr/octo-fiesta/Services/LocalLibraryService.cs
V1ck3s 88d8cbb376 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
2025-12-13 18:06:37 +01:00

315 lines
12 KiB
C#

using System.Text.Json;
using System.Xml.Linq;
using Microsoft.Extensions.Options;
using octo_fiesta.Models;
namespace octo_fiesta.Services;
/// <summary>
/// Interface for local music library management
/// </summary>
public interface ILocalLibraryService
{
/// <summary>
/// Checks if an external song already exists locally
/// </summary>
Task<string?> GetLocalPathForExternalSongAsync(string externalProvider, string externalId);
/// <summary>
/// Registers a downloaded song in the local library
/// </summary>
Task RegisterDownloadedSongAsync(Song song, string localPath);
/// <summary>
/// Gets the mapping between external ID and local ID
/// </summary>
Task<string?> GetLocalIdForExternalSongAsync(string externalProvider, string externalId);
/// <summary>
/// Parses a song ID to determine if it is external or local
/// </summary>
(bool isExternal, string? provider, string? externalId) ParseSongId(string songId);
/// <summary>
/// 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)
/// </summary>
(bool isExternal, string? provider, string? type, string? externalId) ParseExternalId(string id);
/// <summary>
/// Triggers a Subsonic library scan
/// </summary>
Task<bool> TriggerLibraryScanAsync();
/// <summary>
/// Gets the current scan status
/// </summary>
Task<ScanStatus?> GetScanStatusAsync();
}
/// <summary>
/// Local library service implementation
/// Uses a simple JSON file to store mappings (can be replaced with a database)
/// </summary>
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 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> 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))
{
Directory.CreateDirectory(_downloadDirectory);
}
}
public async Task<string?> 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<string?> 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<string> { "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<Dictionary<string, LocalSongMapping>> 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<Dictionary<string, LocalSongMapping>>(json)
?? new Dictionary<string, LocalSongMapping>();
}
else
{
_mappings = new Dictionary<string, LocalSongMapping>();
}
return _mappings;
}
finally
{
_lock.Release();
}
}
private async Task SaveMappingsAsync(Dictionary<string, LocalSongMapping> 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<bool> 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<ScanStatus?> 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;
}
}
/// <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; }
}