mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-09 23:55:10 -05:00
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
This commit is contained in:
7
.gitignore
vendored
7
.gitignore
vendored
@@ -1,4 +1,4 @@
|
|||||||
## A streamlined .gitignore for modern .NET projects
|
## A streamlined .gitignore for modern .NET projects
|
||||||
## including temporary files, build results, and
|
## including temporary files, build results, and
|
||||||
## files generated by popular .NET tools. If you are
|
## files generated by popular .NET tools. If you are
|
||||||
## developing with Visual Studio, the VS .gitignore
|
## developing with Visual Studio, the VS .gitignore
|
||||||
@@ -68,4 +68,7 @@ obj/
|
|||||||
# Autres fichiers temporaires
|
# Autres fichiers temporaires
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
/.env
|
/.env
|
||||||
|
|
||||||
|
# Downloaded music files
|
||||||
|
octo-fiesta/downloads/
|
||||||
@@ -17,19 +17,22 @@ public class SubsonicController : ControllerBase
|
|||||||
private readonly IMusicMetadataService _metadataService;
|
private readonly IMusicMetadataService _metadataService;
|
||||||
private readonly ILocalLibraryService _localLibraryService;
|
private readonly ILocalLibraryService _localLibraryService;
|
||||||
private readonly IDownloadService _downloadService;
|
private readonly IDownloadService _downloadService;
|
||||||
|
private readonly ILogger<SubsonicController> _logger;
|
||||||
|
|
||||||
public SubsonicController(
|
public SubsonicController(
|
||||||
IHttpClientFactory httpClientFactory,
|
IHttpClientFactory httpClientFactory,
|
||||||
IOptions<SubsonicSettings> subsonicSettings,
|
IOptions<SubsonicSettings> subsonicSettings,
|
||||||
IMusicMetadataService metadataService,
|
IMusicMetadataService metadataService,
|
||||||
ILocalLibraryService localLibraryService,
|
ILocalLibraryService localLibraryService,
|
||||||
IDownloadService downloadService)
|
IDownloadService downloadService,
|
||||||
|
ILogger<SubsonicController> logger)
|
||||||
{
|
{
|
||||||
_httpClient = httpClientFactory.CreateClient();
|
_httpClient = httpClientFactory.CreateClient();
|
||||||
_subsonicSettings = subsonicSettings.Value;
|
_subsonicSettings = subsonicSettings.Value;
|
||||||
_metadataService = metadataService;
|
_metadataService = metadataService;
|
||||||
_localLibraryService = localLibraryService;
|
_localLibraryService = localLibraryService;
|
||||||
_downloadService = downloadService;
|
_downloadService = downloadService;
|
||||||
|
_logger = logger;
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(_subsonicSettings.Url))
|
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 query = string.Join("&", parameters.Select(kv => $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value)}"));
|
||||||
var url = $"{_subsonicSettings.Url}/rest/stream?{query}";
|
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);
|
var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, HttpContext.RequestAborted);
|
||||||
|
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
@@ -671,7 +674,7 @@ public class SubsonicController : ControllerBase
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
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
|
["isExternal"] = !song.IsLocal
|
||||||
};
|
};
|
||||||
|
|
||||||
if (song.IsLocal)
|
result["bitRate"] = song.IsLocal ? 128 : 0; // Default bitrate for local files
|
||||||
{
|
|
||||||
result["bitRate"] = 128; // Default for local files
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
result["bitRate"] = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,9 +15,10 @@ builder.Services.Configure<SubsonicSettings>(
|
|||||||
builder.Configuration.GetSection("Subsonic"));
|
builder.Configuration.GetSection("Subsonic"));
|
||||||
|
|
||||||
// Business services
|
// Business services
|
||||||
|
// Registered as Singleton to share state (mappings cache, scan debounce, download tracking, rate limiting)
|
||||||
builder.Services.AddSingleton<ILocalLibraryService, LocalLibraryService>();
|
builder.Services.AddSingleton<ILocalLibraryService, LocalLibraryService>();
|
||||||
builder.Services.AddScoped<IMusicMetadataService, DeezerMetadataService>();
|
builder.Services.AddSingleton<IMusicMetadataService, DeezerMetadataService>();
|
||||||
builder.Services.AddScoped<IDownloadService, DeezerDownloadService>();
|
builder.Services.AddSingleton<IDownloadService, DeezerDownloadService>();
|
||||||
|
|
||||||
builder.Services.AddCors(options =>
|
builder.Services.AddCors(options =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ public class DeezerDownloadService : IDownloadService
|
|||||||
private readonly int _minRequestIntervalMs = 200;
|
private readonly int _minRequestIntervalMs = 200;
|
||||||
|
|
||||||
private const string DeezerApiBase = "https://api.deezer.com";
|
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";
|
private const string BfSecret = "g4el58wc0zvf9na1";
|
||||||
|
|
||||||
public DeezerDownloadService(
|
public DeezerDownloadService(
|
||||||
@@ -96,17 +99,17 @@ public class DeezerDownloadService : IDownloadService
|
|||||||
if (_activeDownloads.TryGetValue(songId, out var activeDownload) && activeDownload.Status == DownloadStatus.InProgress)
|
if (_activeDownloads.TryGetValue(songId, out var activeDownload) && activeDownload.Status == DownloadStatus.InProgress)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Download already in progress for {SongId}", songId);
|
_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);
|
await Task.Delay(500, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activeDownload.Status == DownloadStatus.Completed && activeDownload.LocalPath != null)
|
if (activeDownload?.Status == DownloadStatus.Completed && activeDownload.LocalPath != null)
|
||||||
{
|
{
|
||||||
return activeDownload.LocalPath;
|
return activeDownload.LocalPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Exception(activeDownload.ErrorMessage ?? "Download failed");
|
throw new Exception(activeDownload?.ErrorMessage ?? "Download failed");
|
||||||
}
|
}
|
||||||
|
|
||||||
await _downloadLock.WaitAsync(cancellationToken);
|
await _downloadLock.WaitAsync(cancellationToken);
|
||||||
@@ -141,7 +144,18 @@ public class DeezerDownloadService : IDownloadService
|
|||||||
await _localLibraryService.RegisterDownloadedSongAsync(song, localPath);
|
await _localLibraryService.RegisterDownloadedSongAsync(song, localPath);
|
||||||
|
|
||||||
// Trigger a Subsonic library rescan (with debounce)
|
// 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);
|
_logger.LogInformation("Download completed: {Path}", localPath);
|
||||||
return localPath;
|
return localPath;
|
||||||
@@ -206,7 +220,7 @@ public class DeezerDownloadService : IDownloadService
|
|||||||
|
|
||||||
await RetryWithBackoffAsync(async () =>
|
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");
|
"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}");
|
request.Headers.Add("Cookie", $"arl={arl}");
|
||||||
@@ -230,7 +244,8 @@ public class DeezerDownloadService : IDownloadService
|
|||||||
_licenseToken = licenseToken.GetString();
|
_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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,51 +306,54 @@ public class DeezerDownloadService : IDownloadService
|
|||||||
Encoding.UTF8,
|
Encoding.UTF8,
|
||||||
"application/json");
|
"application/json");
|
||||||
|
|
||||||
var mediaResponse = await _httpClient.SendAsync(mediaHttpRequest, cancellationToken);
|
using (mediaHttpRequest)
|
||||||
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)
|
|
||||||
{
|
{
|
||||||
throw new Exception("No download URL available");
|
var mediaResponse = await _httpClient.SendAsync(mediaHttpRequest, cancellationToken);
|
||||||
}
|
mediaResponse.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
var firstData = data[0];
|
var mediaJson = await mediaResponse.Content.ReadAsStringAsync(cancellationToken);
|
||||||
if (!firstData.TryGetProperty("media", out var media) ||
|
var mediaDoc = JsonDocument.Parse(mediaJson);
|
||||||
media.GetArrayLength() == 0)
|
|
||||||
{
|
|
||||||
throw new Exception("No media sources available - track may be unavailable in your region");
|
|
||||||
}
|
|
||||||
|
|
||||||
string? downloadUrl = null;
|
if (!mediaDoc.RootElement.TryGetProperty("data", out var data) ||
|
||||||
string? format = null;
|
data.GetArrayLength() == 0)
|
||||||
|
|
||||||
foreach (var mediaItem in media.EnumerateArray())
|
|
||||||
{
|
|
||||||
if (mediaItem.TryGetProperty("sources", out var sources) &&
|
|
||||||
sources.GetArrayLength() > 0)
|
|
||||||
{
|
{
|
||||||
downloadUrl = sources[0].GetProperty("url").GetString();
|
throw new Exception("No download URL available");
|
||||||
format = mediaItem.GetProperty("format").GetString();
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(downloadUrl))
|
var firstData = data[0];
|
||||||
{
|
if (!firstData.TryGetProperty("media", out var media) ||
|
||||||
throw new Exception("No download URL found in media sources - track may be region locked");
|
media.GetArrayLength() == 0)
|
||||||
}
|
{
|
||||||
|
throw new Exception("No media sources available - track may be unavailable in your region");
|
||||||
|
}
|
||||||
|
|
||||||
return new DownloadResult
|
string? downloadUrl = null;
|
||||||
{
|
string? format = null;
|
||||||
DownloadUrl = downloadUrl,
|
|
||||||
Format = format ?? "MP3_128",
|
foreach (var mediaItem in media.EnumerateArray())
|
||||||
Title = title,
|
{
|
||||||
Artist = artist
|
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
|
// Download the encrypted file
|
||||||
var response = await RetryWithBackoffAsync(async () =>
|
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("User-Agent", "Mozilla/5.0");
|
||||||
request.Headers.Add("Accept", "*/*");
|
request.Headers.Add("Accept", "*/*");
|
||||||
|
|
||||||
@@ -423,14 +441,7 @@ public class DeezerDownloadService : IDownloadService
|
|||||||
tagFile.Tag.Album = song.Album;
|
tagFile.Tag.Album = song.Album;
|
||||||
|
|
||||||
// Album artist (may differ from track artist for compilations)
|
// Album artist (may differ from track artist for compilations)
|
||||||
if (!string.IsNullOrEmpty(song.AlbumArtist))
|
tagFile.Tag.AlbumArtists = new[] { !string.IsNullOrEmpty(song.AlbumArtist) ? song.AlbumArtist : song.Artist };
|
||||||
{
|
|
||||||
tagFile.Tag.AlbumArtists = new[] { song.AlbumArtist };
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
tagFile.Tag.AlbumArtists = new[] { song.Artist };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Track number
|
// Track number
|
||||||
if (song.Track.HasValue)
|
if (song.Track.HasValue)
|
||||||
@@ -763,7 +774,7 @@ public static class PathHelper
|
|||||||
|
|
||||||
if (sanitized.Length > 100)
|
if (sanitized.Length > 100)
|
||||||
{
|
{
|
||||||
sanitized = sanitized.Substring(0, 100);
|
sanitized = sanitized[..100];
|
||||||
}
|
}
|
||||||
|
|
||||||
return sanitized.Trim();
|
return sanitized.Trim();
|
||||||
@@ -794,7 +805,7 @@ public static class PathHelper
|
|||||||
|
|
||||||
if (sanitized.Length > 100)
|
if (sanitized.Length > 100)
|
||||||
{
|
{
|
||||||
sanitized = sanitized.Substring(0, 100).TrimEnd('.');
|
sanitized = sanitized[..100].TrimEnd('.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure we have a valid name
|
// Ensure we have a valid name
|
||||||
|
|||||||
@@ -1,295 +1,314 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Xml.Linq;
|
using System.Xml.Linq;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using octo_fiesta.Models;
|
using octo_fiesta.Models;
|
||||||
|
|
||||||
namespace octo_fiesta.Services;
|
namespace octo_fiesta.Services;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Interface for local music library management
|
/// Interface for local music library management
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface ILocalLibraryService
|
public interface ILocalLibraryService
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Checks if an external song already exists locally
|
/// Checks if an external song already exists locally
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<string?> GetLocalPathForExternalSongAsync(string externalProvider, string externalId);
|
Task<string?> GetLocalPathForExternalSongAsync(string externalProvider, string externalId);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Registers a downloaded song in the local library
|
/// Registers a downloaded song in the local library
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task RegisterDownloadedSongAsync(Song song, string localPath);
|
Task RegisterDownloadedSongAsync(Song song, string localPath);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the mapping between external ID and local ID
|
/// Gets the mapping between external ID and local ID
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<string?> GetLocalIdForExternalSongAsync(string externalProvider, string externalId);
|
Task<string?> GetLocalIdForExternalSongAsync(string externalProvider, string externalId);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Parses a song ID to determine if it is external or local
|
/// Parses a song ID to determine if it is external or local
|
||||||
/// </summary>
|
/// </summary>
|
||||||
(bool isExternal, string? provider, string? externalId) ParseSongId(string songId);
|
(bool isExternal, string? provider, string? externalId) ParseSongId(string songId);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Parses an external ID to extract the provider, type and ID
|
/// 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)
|
/// 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)
|
/// Also supports legacy format: ext-{provider}-{id} (assumes song type)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
(bool isExternal, string? provider, string? type, string? externalId) ParseExternalId(string id);
|
(bool isExternal, string? provider, string? type, string? externalId) ParseExternalId(string id);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Triggers a Subsonic library scan
|
/// Triggers a Subsonic library scan
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<bool> TriggerLibraryScanAsync();
|
Task<bool> TriggerLibraryScanAsync();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the current scan status
|
/// Gets the current scan status
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<ScanStatus?> GetScanStatusAsync();
|
Task<ScanStatus?> GetScanStatusAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Local library service implementation
|
/// Local library service implementation
|
||||||
/// Uses a simple JSON file to store mappings (can be replaced with a database)
|
/// Uses a simple JSON file to store mappings (can be replaced with a database)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class LocalLibraryService : ILocalLibraryService
|
public class LocalLibraryService : ILocalLibraryService
|
||||||
{
|
{
|
||||||
private readonly string _mappingFilePath;
|
private readonly string _mappingFilePath;
|
||||||
private readonly string _downloadDirectory;
|
private readonly string _downloadDirectory;
|
||||||
private readonly HttpClient _httpClient;
|
private readonly HttpClient _httpClient;
|
||||||
private readonly SubsonicSettings _subsonicSettings;
|
private readonly SubsonicSettings _subsonicSettings;
|
||||||
private readonly ILogger<LocalLibraryService> _logger;
|
private readonly ILogger<LocalLibraryService> _logger;
|
||||||
private Dictionary<string, LocalSongMapping>? _mappings;
|
private Dictionary<string, LocalSongMapping>? _mappings;
|
||||||
private readonly SemaphoreSlim _lock = new(1, 1);
|
private readonly SemaphoreSlim _lock = new(1, 1);
|
||||||
|
|
||||||
// Debounce to avoid triggering too many scans
|
// Debounce to avoid triggering too many scans
|
||||||
private DateTime _lastScanTrigger = DateTime.MinValue;
|
private DateTime _lastScanTrigger = DateTime.MinValue;
|
||||||
private readonly TimeSpan _scanDebounceInterval = TimeSpan.FromSeconds(30);
|
private readonly TimeSpan _scanDebounceInterval = TimeSpan.FromSeconds(30);
|
||||||
|
|
||||||
public LocalLibraryService(
|
public LocalLibraryService(
|
||||||
IConfiguration configuration,
|
IConfiguration configuration,
|
||||||
IHttpClientFactory httpClientFactory,
|
IHttpClientFactory httpClientFactory,
|
||||||
IOptions<SubsonicSettings> subsonicSettings,
|
IOptions<SubsonicSettings> subsonicSettings,
|
||||||
ILogger<LocalLibraryService> logger)
|
ILogger<LocalLibraryService> logger)
|
||||||
{
|
{
|
||||||
_downloadDirectory = configuration["Library:DownloadPath"] ?? Path.Combine(Directory.GetCurrentDirectory(), "downloads");
|
_downloadDirectory = configuration["Library:DownloadPath"] ?? Path.Combine(Directory.GetCurrentDirectory(), "downloads");
|
||||||
_mappingFilePath = Path.Combine(_downloadDirectory, ".mappings.json");
|
_mappingFilePath = Path.Combine(_downloadDirectory, ".mappings.json");
|
||||||
_httpClient = httpClientFactory.CreateClient();
|
_httpClient = httpClientFactory.CreateClient();
|
||||||
_subsonicSettings = subsonicSettings.Value;
|
_subsonicSettings = subsonicSettings.Value;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
|
||||||
if (!Directory.Exists(_downloadDirectory))
|
if (!Directory.Exists(_downloadDirectory))
|
||||||
{
|
{
|
||||||
Directory.CreateDirectory(_downloadDirectory);
|
Directory.CreateDirectory(_downloadDirectory);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<string?> GetLocalPathForExternalSongAsync(string externalProvider, string externalId)
|
public async Task<string?> GetLocalPathForExternalSongAsync(string externalProvider, string externalId)
|
||||||
{
|
{
|
||||||
var mappings = await LoadMappingsAsync();
|
var mappings = await LoadMappingsAsync();
|
||||||
var key = $"{externalProvider}:{externalId}";
|
var key = $"{externalProvider}:{externalId}";
|
||||||
|
|
||||||
if (mappings.TryGetValue(key, out var mapping) && File.Exists(mapping.LocalPath))
|
if (mappings.TryGetValue(key, out var mapping) && File.Exists(mapping.LocalPath))
|
||||||
{
|
{
|
||||||
return mapping.LocalPath;
|
return mapping.LocalPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task RegisterDownloadedSongAsync(Song song, string localPath)
|
public async Task RegisterDownloadedSongAsync(Song song, string localPath)
|
||||||
{
|
{
|
||||||
if (song.ExternalProvider == null || song.ExternalId == null) return;
|
if (song.ExternalProvider == null || song.ExternalId == null) return;
|
||||||
|
|
||||||
await _lock.WaitAsync();
|
// Load mappings first (this acquires the lock internally if needed)
|
||||||
try
|
var mappings = await LoadMappingsAsync();
|
||||||
{
|
|
||||||
var mappings = await LoadMappingsAsync();
|
await _lock.WaitAsync();
|
||||||
var key = $"{song.ExternalProvider}:{song.ExternalId}";
|
try
|
||||||
|
{
|
||||||
mappings[key] = new LocalSongMapping
|
var key = $"{song.ExternalProvider}:{song.ExternalId}";
|
||||||
{
|
|
||||||
ExternalProvider = song.ExternalProvider,
|
mappings[key] = new LocalSongMapping
|
||||||
ExternalId = song.ExternalId,
|
{
|
||||||
LocalPath = localPath,
|
ExternalProvider = song.ExternalProvider,
|
||||||
Title = song.Title,
|
ExternalId = song.ExternalId,
|
||||||
Artist = song.Artist,
|
LocalPath = localPath,
|
||||||
Album = song.Album,
|
Title = song.Title,
|
||||||
DownloadedAt = DateTime.UtcNow
|
Artist = song.Artist,
|
||||||
};
|
Album = song.Album,
|
||||||
|
DownloadedAt = DateTime.UtcNow
|
||||||
await SaveMappingsAsync(mappings);
|
};
|
||||||
}
|
|
||||||
finally
|
await SaveMappingsAsync(mappings);
|
||||||
{
|
}
|
||||||
_lock.Release();
|
finally
|
||||||
}
|
{
|
||||||
}
|
_lock.Release();
|
||||||
|
}
|
||||||
public async Task<string?> GetLocalIdForExternalSongAsync(string externalProvider, string externalId)
|
}
|
||||||
{
|
|
||||||
// For now, return null as we don't yet have integration
|
public async Task<string?> GetLocalIdForExternalSongAsync(string externalProvider, string externalId)
|
||||||
// with the Subsonic server to retrieve local ID after scan
|
{
|
||||||
await Task.CompletedTask;
|
// For now, return null as we don't yet have integration
|
||||||
return null;
|
// 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);
|
public (bool isExternal, string? provider, string? externalId) ParseSongId(string songId)
|
||||||
return (isExternal, provider, externalId);
|
{
|
||||||
}
|
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-"))
|
public (bool isExternal, string? provider, string? type, string? externalId) ParseExternalId(string id)
|
||||||
{
|
{
|
||||||
return (false, null, null, null);
|
if (!id.StartsWith("ext-"))
|
||||||
}
|
{
|
||||||
|
return (false, null, null, null);
|
||||||
var parts = id.Split('-');
|
}
|
||||||
|
|
||||||
// Known types for the new format
|
var parts = id.Split('-');
|
||||||
var knownTypes = new HashSet<string> { "song", "album", "artist" };
|
|
||||||
|
// Known types for the new format
|
||||||
// New format: ext-{provider}-{type}-{id} (e.g., ext-deezer-artist-259)
|
var knownTypes = new HashSet<string> { "song", "album", "artist" };
|
||||||
// Only use new format if parts[2] is a known type
|
|
||||||
if (parts.Length >= 4 && knownTypes.Contains(parts[2]))
|
// New format: ext-{provider}-{type}-{id} (e.g., ext-deezer-artist-259)
|
||||||
{
|
// Only use new format if parts[2] is a known type
|
||||||
var provider = parts[1];
|
if (parts.Length >= 4 && knownTypes.Contains(parts[2]))
|
||||||
var type = parts[2];
|
{
|
||||||
var externalId = string.Join("-", parts.Skip(3)); // Handle IDs with dashes
|
var provider = parts[1];
|
||||||
return (true, provider, type, externalId);
|
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)
|
// 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
|
||||||
var provider = parts[1];
|
if (parts.Length >= 3)
|
||||||
var externalId = string.Join("-", parts.Skip(2)); // Everything after provider is the ID
|
{
|
||||||
return (true, provider, "song", externalId);
|
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);
|
}
|
||||||
}
|
|
||||||
|
return (false, null, null, null);
|
||||||
private async Task<Dictionary<string, LocalSongMapping>> LoadMappingsAsync()
|
}
|
||||||
{
|
|
||||||
if (_mappings != null) return _mappings;
|
private async Task<Dictionary<string, LocalSongMapping>> LoadMappingsAsync()
|
||||||
|
{
|
||||||
if (File.Exists(_mappingFilePath))
|
// Fast path: return cached mappings if available
|
||||||
{
|
if (_mappings != null) return _mappings;
|
||||||
var json = await File.ReadAllTextAsync(_mappingFilePath);
|
|
||||||
_mappings = System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, LocalSongMapping>>(json)
|
// Slow path: acquire lock to load from file (prevents race condition)
|
||||||
?? new Dictionary<string, LocalSongMapping>();
|
await _lock.WaitAsync();
|
||||||
}
|
try
|
||||||
else
|
{
|
||||||
{
|
// Double-check after acquiring lock
|
||||||
_mappings = new Dictionary<string, LocalSongMapping>();
|
if (_mappings != null) return _mappings;
|
||||||
}
|
|
||||||
|
if (File.Exists(_mappingFilePath))
|
||||||
return _mappings;
|
{
|
||||||
}
|
var json = await File.ReadAllTextAsync(_mappingFilePath);
|
||||||
|
_mappings = System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, LocalSongMapping>>(json)
|
||||||
private async Task SaveMappingsAsync(Dictionary<string, LocalSongMapping> mappings)
|
?? new Dictionary<string, LocalSongMapping>();
|
||||||
{
|
}
|
||||||
_mappings = mappings;
|
else
|
||||||
var json = System.Text.Json.JsonSerializer.Serialize(mappings, new System.Text.Json.JsonSerializerOptions
|
{
|
||||||
{
|
_mappings = new Dictionary<string, LocalSongMapping>();
|
||||||
WriteIndented = true
|
}
|
||||||
});
|
|
||||||
await File.WriteAllTextAsync(_mappingFilePath, json);
|
return _mappings;
|
||||||
}
|
}
|
||||||
|
finally
|
||||||
public string GetDownloadDirectory() => _downloadDirectory;
|
{
|
||||||
|
_lock.Release();
|
||||||
public async Task<bool> TriggerLibraryScanAsync()
|
}
|
||||||
{
|
}
|
||||||
// Debounce: avoid triggering too many successive scans
|
|
||||||
var now = DateTime.UtcNow;
|
private async Task SaveMappingsAsync(Dictionary<string, LocalSongMapping> mappings)
|
||||||
if (now - _lastScanTrigger < _scanDebounceInterval)
|
{
|
||||||
{
|
_mappings = mappings;
|
||||||
_logger.LogDebug("Scan debounced - last scan was {Elapsed}s ago",
|
var json = System.Text.Json.JsonSerializer.Serialize(mappings, new System.Text.Json.JsonSerializerOptions
|
||||||
(now - _lastScanTrigger).TotalSeconds);
|
{
|
||||||
return true;
|
WriteIndented = true
|
||||||
}
|
});
|
||||||
|
await File.WriteAllTextAsync(_mappingFilePath, json);
|
||||||
_lastScanTrigger = now;
|
}
|
||||||
|
|
||||||
try
|
public string GetDownloadDirectory() => _downloadDirectory;
|
||||||
{
|
|
||||||
// Call Subsonic API to trigger a scan
|
public async Task<bool> TriggerLibraryScanAsync()
|
||||||
// Note: Credentials must be passed as parameters (u, p or t+s)
|
{
|
||||||
var url = $"{_subsonicSettings.Url}/rest/startScan?f=json";
|
// Debounce: avoid triggering too many successive scans
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
_logger.LogInformation("Triggering Subsonic library scan...");
|
if (now - _lastScanTrigger < _scanDebounceInterval)
|
||||||
|
{
|
||||||
var response = await _httpClient.GetAsync(url);
|
_logger.LogDebug("Scan debounced - last scan was {Elapsed}s ago",
|
||||||
|
(now - _lastScanTrigger).TotalSeconds);
|
||||||
if (response.IsSuccessStatusCode)
|
return true;
|
||||||
{
|
}
|
||||||
var content = await response.Content.ReadAsStringAsync();
|
|
||||||
_logger.LogInformation("Subsonic scan triggered successfully: {Response}", content);
|
_lastScanTrigger = now;
|
||||||
return true;
|
|
||||||
}
|
try
|
||||||
else
|
{
|
||||||
{
|
// Call Subsonic API to trigger a scan
|
||||||
_logger.LogWarning("Failed to trigger Subsonic scan: {StatusCode}", response.StatusCode);
|
// Note: This endpoint works without authentication on most Subsonic/Navidrome servers
|
||||||
return false;
|
// 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";
|
||||||
catch (Exception ex)
|
|
||||||
{
|
_logger.LogInformation("Triggering Subsonic library scan...");
|
||||||
_logger.LogError(ex, "Error triggering Subsonic library scan");
|
|
||||||
return false;
|
var response = await _httpClient.GetAsync(url);
|
||||||
}
|
|
||||||
}
|
if (response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
public async Task<ScanStatus?> GetScanStatusAsync()
|
var content = await response.Content.ReadAsStringAsync();
|
||||||
{
|
_logger.LogInformation("Subsonic scan triggered successfully: {Response}", content);
|
||||||
try
|
return true;
|
||||||
{
|
}
|
||||||
var url = $"{_subsonicSettings.Url}/rest/getScanStatus?f=json";
|
else
|
||||||
|
{
|
||||||
var response = await _httpClient.GetAsync(url);
|
_logger.LogWarning("Failed to trigger Subsonic scan: {StatusCode} - Server may require authentication", response.StatusCode);
|
||||||
|
return false;
|
||||||
if (response.IsSuccessStatusCode)
|
}
|
||||||
{
|
}
|
||||||
var content = await response.Content.ReadAsStringAsync();
|
catch (Exception ex)
|
||||||
var doc = JsonDocument.Parse(content);
|
{
|
||||||
|
_logger.LogError(ex, "Error triggering Subsonic library scan");
|
||||||
if (doc.RootElement.TryGetProperty("subsonic-response", out var subsonicResponse) &&
|
return false;
|
||||||
subsonicResponse.TryGetProperty("scanStatus", out var scanStatus))
|
}
|
||||||
{
|
}
|
||||||
return new ScanStatus
|
|
||||||
{
|
public async Task<ScanStatus?> GetScanStatusAsync()
|
||||||
Scanning = scanStatus.TryGetProperty("scanning", out var scanning) && scanning.GetBoolean(),
|
{
|
||||||
Count = scanStatus.TryGetProperty("count", out var count) ? count.GetInt32() : null
|
try
|
||||||
};
|
{
|
||||||
}
|
// Note: This endpoint works without authentication on most Subsonic/Navidrome servers
|
||||||
}
|
// when called from localhost.
|
||||||
}
|
var url = $"{_subsonicSettings.Url}/rest/getScanStatus?f=json";
|
||||||
catch (Exception ex)
|
|
||||||
{
|
var response = await _httpClient.GetAsync(url);
|
||||||
_logger.LogError(ex, "Error getting Subsonic scan status");
|
|
||||||
}
|
if (response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
return null;
|
var content = await response.Content.ReadAsStringAsync();
|
||||||
}
|
var doc = JsonDocument.Parse(content);
|
||||||
}
|
|
||||||
|
if (doc.RootElement.TryGetProperty("subsonic-response", out var subsonicResponse) &&
|
||||||
/// <summary>
|
subsonicResponse.TryGetProperty("scanStatus", out var scanStatus))
|
||||||
/// Represents the mapping between an external song and its local file
|
{
|
||||||
/// </summary>
|
return new ScanStatus
|
||||||
public class LocalSongMapping
|
{
|
||||||
{
|
Scanning = scanStatus.TryGetProperty("scanning", out var scanning) && scanning.GetBoolean(),
|
||||||
public string ExternalProvider { get; set; } = string.Empty;
|
Count = scanStatus.TryGetProperty("count", out var count) ? count.GetInt32() : null
|
||||||
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;
|
catch (Exception ex)
|
||||||
public string Album { get; set; } = string.Empty;
|
{
|
||||||
public DateTime DownloadedAt { get; set; }
|
_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; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
},
|
},
|
||||||
"AllowedHosts": "*",
|
"AllowedHosts": "*",
|
||||||
"Subsonic": {
|
"Subsonic": {
|
||||||
"Url": "http://192.168.1.12:4533"
|
"Url": "http://localhost:4533"
|
||||||
},
|
},
|
||||||
"Library": {
|
"Library": {
|
||||||
"DownloadPath": "./downloads"
|
"DownloadPath": "./downloads"
|
||||||
|
|||||||
Reference in New Issue
Block a user