mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-10 07:58:39 -05:00
Split monolithic MusicModels.cs (177 lines) into separate files: - Models/Settings/ (DeezerSettings, QobuzSettings, SubsonicSettings) - Models/Domain/ (Song, Album, Artist) - Models/Search/ (SearchResult) - Models/Download/ (DownloadInfo, DownloadStatus) - Models/Subsonic/ (ScanStatus) Updated namespaces and imports across 22 files. Improves navigation and separates models by business context.
276 lines
10 KiB
C#
276 lines
10 KiB
C#
using System.Text.Json;
|
|
using Microsoft.Extensions.Options;
|
|
using octo_fiesta.Models.Domain;
|
|
using octo_fiesta.Models.Settings;
|
|
using octo_fiesta.Models.Download;
|
|
using octo_fiesta.Models.Search;
|
|
using octo_fiesta.Models.Subsonic;
|
|
using octo_fiesta.Services;
|
|
|
|
namespace octo_fiesta.Services.Local;
|
|
|
|
/// <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; }
|
|
}
|