mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-10-08 14:05:02 -04:00
feat: implement multi-service usage with parallel search, drag-and-drop metadata ranking priority, status testing, and dynamic fallback downloads with URL translation
This commit is contained in:
9 files changed
+1276
-180
No files matched your search
@@ -145,6 +145,12 @@ public class ConfigController : ControllerBase
|
||||
enableExternalPlaylists = GetEnvBool(envVars, "ENABLE_EXTERNAL_PLAYLISTS", fallbackEnableExternalPlaylists),
|
||||
playlistsDirectory = GetEnvString(envVars, "PLAYLISTS_DIRECTORY", fallbackPlaylistsDirectory),
|
||||
redisEnabled = GetEnvBool(envVars, "REDIS_ENABLED", _configuration.GetValue<bool>("Redis:Enabled", false)),
|
||||
providers = new
|
||||
{
|
||||
metadataOrder = GetEnvString(envVars, "MULTI_PROVIDER_METADATA_ORDER", "spotify,applemusic,deezer,qobuz,squidwtf"),
|
||||
enabledSearch = GetEnvString(envVars, "MULTI_PROVIDER_ENABLED_SEARCH", "spotify,applemusic,deezer,qobuz,squidwtf"),
|
||||
enabledPlaylist = GetEnvString(envVars, "MULTI_PROVIDER_ENABLED_PLAYLIST", "spotify"),
|
||||
},
|
||||
debug = new
|
||||
{
|
||||
logAllRequests = GetEnvBool(envVars, "DEBUG_LOG_ALL_REQUESTS", _configuration.GetValue<bool>("Debug:LogAllRequests", false)),
|
||||
@@ -952,6 +958,47 @@ public class ConfigController : ControllerBase
|
||||
return _configuration.GetValue<bool>("ADMIN_ENABLE_ENV_EXPORT");
|
||||
}
|
||||
|
||||
[HttpGet("providers/status")]
|
||||
public IActionResult GetProvidersStatus()
|
||||
{
|
||||
var adminCheck = RequireAdministratorForSensitiveOperation("get providers status");
|
||||
if (adminCheck != null)
|
||||
{
|
||||
return adminCheck;
|
||||
}
|
||||
|
||||
var statusManager = HttpContext.RequestServices.GetRequiredService<ProviderStatusManager>();
|
||||
var statusCache = statusManager.GetStatusCache();
|
||||
|
||||
var allProviders = new[] { "spotify", "applemusic", "deezer", "qobuz", "squidwtf" };
|
||||
var results = allProviders.Select(p => {
|
||||
var healthy = statusManager.IsProviderHealthy(p);
|
||||
statusCache = statusManager.GetStatusCache();
|
||||
var testedAt = statusCache.TryGetValue(p, out var info) ? info.TestedAt.ToString("o") : DateTime.UtcNow.ToString("o");
|
||||
return new {
|
||||
name = p,
|
||||
healthy = healthy,
|
||||
testedAt = testedAt
|
||||
};
|
||||
}).ToList();
|
||||
|
||||
return Ok(results);
|
||||
}
|
||||
|
||||
[HttpPost("providers/test/{provider}")]
|
||||
public async Task<IActionResult> TestProvider(string provider)
|
||||
{
|
||||
var adminCheck = RequireAdministratorForSensitiveOperation("test provider connection");
|
||||
if (adminCheck != null)
|
||||
{
|
||||
return adminCheck;
|
||||
}
|
||||
|
||||
var statusManager = HttpContext.RequestServices.GetRequiredService<ProviderStatusManager>();
|
||||
var healthy = await statusManager.TestProviderConnectionAsync(provider, HttpContext.RequestAborted);
|
||||
return Ok(new { success = true, provider = provider, healthy = healthy });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets detailed memory usage statistics for debugging.
|
||||
/// </summary>
|
||||
|
||||
+44
-66
@@ -533,10 +533,8 @@ else
|
||||
enableExternalPlaylists = builder.Configuration.GetValue<bool>("Subsonic:EnableExternalPlaylists", true);
|
||||
}
|
||||
|
||||
// Discover SquidWTF endpoints only when SquidWTF is selected as primary music service.
|
||||
var squidWtfEndpointCatalog = musicService == MusicService.SquidWTF
|
||||
? await SquidWtfEndpointDiscovery.DiscoverAsync()
|
||||
: new SquidWtfEndpointCatalog(new List<string>(), new List<string>());
|
||||
// Discover SquidWTF endpoints for multi-provider usage
|
||||
var squidWtfEndpointCatalog = await SquidWtfEndpointDiscovery.DiscoverAsync();
|
||||
var squidWtfApiUrls = squidWtfEndpointCatalog.ApiUrls;
|
||||
var squidWtfStreamingUrls = squidWtfEndpointCatalog.StreamingUrls;
|
||||
|
||||
@@ -570,71 +568,51 @@ else
|
||||
builder.Services.AddScoped<SubsonicProxyService>();
|
||||
}
|
||||
|
||||
// Register music service based on configuration
|
||||
// IMPORTANT: Primary service MUST be registered LAST because ASP.NET Core DI
|
||||
// will use the last registered implementation when injecting IMusicMetadataService/IDownloadService
|
||||
if (musicService == MusicService.Qobuz)
|
||||
{
|
||||
// If playlists enabled, register Deezer FIRST (secondary provider)
|
||||
if (enableExternalPlaylists)
|
||||
{
|
||||
builder.Services.AddSingleton<IMusicMetadataService, DeezerMetadataService>();
|
||||
builder.Services.AddSingleton<IDownloadService, DeezerDownloadService>();
|
||||
builder.Services.AddSingleton<PlaylistSyncService>();
|
||||
}
|
||||
// ----------------------------------------------------
|
||||
// Multi-Provider & Concrete Service Registrations
|
||||
// ----------------------------------------------------
|
||||
builder.Services.AddSingleton<QobuzBundleService>();
|
||||
|
||||
// Qobuz services (primary) - registered LAST to be injected by default
|
||||
builder.Services.AddSingleton<QobuzBundleService>();
|
||||
builder.Services.AddSingleton<IMusicMetadataService, QobuzMetadataService>();
|
||||
builder.Services.AddSingleton<IDownloadService, QobuzDownloadService>();
|
||||
}
|
||||
else if (musicService == MusicService.Deezer)
|
||||
{
|
||||
// If playlists enabled, register Qobuz FIRST (secondary provider)
|
||||
if (enableExternalPlaylists)
|
||||
{
|
||||
builder.Services.AddSingleton<QobuzBundleService>();
|
||||
builder.Services.AddSingleton<IMusicMetadataService, QobuzMetadataService>();
|
||||
builder.Services.AddSingleton<IDownloadService, QobuzDownloadService>();
|
||||
builder.Services.AddSingleton<PlaylistSyncService>();
|
||||
}
|
||||
// 1. Concrete Metadata Services
|
||||
builder.Services.AddSingleton<IMusicMetadataService, DeezerMetadataService>();
|
||||
builder.Services.AddSingleton<IMusicMetadataService, QobuzMetadataService>();
|
||||
builder.Services.AddSingleton<IMusicMetadataService, AppleMusicMetadataService>();
|
||||
builder.Services.AddSingleton<IMusicMetadataService>(sp =>
|
||||
new SquidWTFMetadataService(
|
||||
sp.GetRequiredService<IHttpClientFactory>(),
|
||||
sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<SubsonicSettings>>(),
|
||||
sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<SquidWTFSettings>>(),
|
||||
sp.GetRequiredService<ILogger<SquidWTFMetadataService>>(),
|
||||
sp.GetRequiredService<RedisCacheService>(),
|
||||
squidWtfApiUrls,
|
||||
sp.GetService<GenreEnrichmentService>()));
|
||||
|
||||
// Deezer services (primary, default) - registered LAST to be injected by default
|
||||
builder.Services.AddSingleton<IMusicMetadataService, DeezerMetadataService>();
|
||||
builder.Services.AddSingleton<IDownloadService, DeezerDownloadService>();
|
||||
}
|
||||
else if (musicService == MusicService.SquidWTF)
|
||||
{
|
||||
// SquidWTF services - pass decoded URLs with fallback support
|
||||
builder.Services.AddSingleton<IMusicMetadataService>(sp =>
|
||||
new SquidWTFMetadataService(
|
||||
sp.GetRequiredService<IHttpClientFactory>(),
|
||||
sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<SubsonicSettings>>(),
|
||||
sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<SquidWTFSettings>>(),
|
||||
sp.GetRequiredService<ILogger<SquidWTFMetadataService>>(),
|
||||
sp.GetRequiredService<RedisCacheService>(),
|
||||
squidWtfApiUrls,
|
||||
sp.GetService<GenreEnrichmentService>()));
|
||||
builder.Services.AddSingleton<IDownloadService>(sp =>
|
||||
new SquidWTFDownloadService(
|
||||
sp.GetRequiredService<IHttpClientFactory>(),
|
||||
sp.GetRequiredService<IConfiguration>(),
|
||||
sp.GetRequiredService<ILocalLibraryService>(),
|
||||
sp.GetRequiredService<IMusicMetadataService>(),
|
||||
sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<SubsonicSettings>>(),
|
||||
sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<SquidWTFSettings>>(),
|
||||
sp,
|
||||
sp.GetRequiredService<ILogger<SquidWTFDownloadService>>(),
|
||||
sp.GetRequiredService<OdesliService>(),
|
||||
squidWtfStreamingUrls));
|
||||
}
|
||||
else if (musicService == MusicService.AppleMusic)
|
||||
{
|
||||
builder.Services.AddSingleton<IMusicMetadataService, AppleMusicMetadataService>();
|
||||
builder.Services.AddSingleton<IDownloadService, AppleMusicDownloadService>();
|
||||
}
|
||||
// 2. Concrete Download Services
|
||||
builder.Services.AddSingleton<IDownloadService, DeezerDownloadService>();
|
||||
builder.Services.AddSingleton<IDownloadService, QobuzDownloadService>();
|
||||
builder.Services.AddSingleton<IDownloadService, AppleMusicDownloadService>();
|
||||
builder.Services.AddSingleton<IDownloadService>(sp =>
|
||||
new SquidWTFDownloadService(
|
||||
sp.GetRequiredService<IHttpClientFactory>(),
|
||||
sp.GetRequiredService<IConfiguration>(),
|
||||
sp.GetRequiredService<ILocalLibraryService>(),
|
||||
sp.GetRequiredService<IMusicMetadataService>(),
|
||||
sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<SubsonicSettings>>(),
|
||||
sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<SquidWTFSettings>>(),
|
||||
sp,
|
||||
sp.GetRequiredService<ILogger<SquidWTFDownloadService>>(),
|
||||
sp.GetRequiredService<OdesliService>(),
|
||||
squidWtfStreamingUrls));
|
||||
|
||||
// Register ParallelMetadataService to race all registered providers for faster searches
|
||||
// 3. Status Manager & Multi-Provider Orchestrators
|
||||
builder.Services.AddSingleton<ProviderStatusManager>();
|
||||
builder.Services.AddSingleton<IMusicMetadataService, MultiProviderMetadataService>();
|
||||
builder.Services.AddSingleton<IDownloadService, MultiProviderDownloadService>();
|
||||
|
||||
// 4. Playlist Sync Service
|
||||
builder.Services.AddSingleton<PlaylistSyncService>();
|
||||
|
||||
// 5. ParallelMetadataService Wrapper (delegates to MultiProviderMetadataService)
|
||||
builder.Services.AddSingleton<ParallelMetadataService>();
|
||||
|
||||
// Startup validation - register validators based on backend
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
using allstarr.Models.Domain;
|
||||
using allstarr.Models.Download;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace allstarr.Services.Common;
|
||||
|
||||
public class MultiProviderDownloadService : IDownloadService
|
||||
{
|
||||
private readonly IEnumerable<IDownloadService> _allServices;
|
||||
private readonly IMusicMetadataService _metadataService;
|
||||
private readonly ProviderStatusManager _statusManager;
|
||||
private readonly OdesliService _odesliService;
|
||||
private readonly ILogger<MultiProviderDownloadService> _logger;
|
||||
private readonly IEnumerable<IMusicMetadataService> _allMetadataServices;
|
||||
|
||||
public async Task<string> DownloadSongAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var downloadProviders = GetPrioritizedDownloadProviders();
|
||||
if (downloadProviders.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("No download providers are currently enabled and healthy.");
|
||||
}
|
||||
|
||||
Exception? lastException = null;
|
||||
|
||||
foreach (var targetProvider in downloadProviders)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Attempting download using target provider: {TargetProvider}", targetProvider);
|
||||
|
||||
string targetId = externalId;
|
||||
if (!externalProvider.Equals(targetProvider, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var translatedId = await TranslateIdAsync(externalProvider, externalId, targetProvider, cancellationToken);
|
||||
if (string.IsNullOrEmpty(translatedId))
|
||||
{
|
||||
_logger.LogWarning("Could not translate track ID from {SourceProvider}:{SourceId} to {TargetProvider}", externalProvider, externalId, targetProvider);
|
||||
continue;
|
||||
}
|
||||
targetId = translatedId;
|
||||
}
|
||||
|
||||
var service = GetDownloadServiceByName(targetProvider);
|
||||
if (service == null)
|
||||
{
|
||||
_logger.LogWarning("Download service for {Provider} is not registered", targetProvider);
|
||||
continue;
|
||||
}
|
||||
|
||||
var path = await service.DownloadSongAsync(targetProvider, targetId, cancellationToken);
|
||||
if (!string.IsNullOrEmpty(path))
|
||||
{
|
||||
_logger.LogInformation("Successfully downloaded song using provider {Provider}: {Path}", targetProvider, path);
|
||||
return path;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Download failed using provider {Provider}", targetProvider);
|
||||
lastException = ex;
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("All configured download services failed to download the song.", lastException);
|
||||
}
|
||||
|
||||
public async Task<Stream> DownloadAndStreamAsync(string externalProvider, string externalId, Common.StreamQuality? qualityOverride = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var downloadProviders = GetPrioritizedDownloadProviders();
|
||||
if (downloadProviders.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("No download providers are currently enabled and healthy.");
|
||||
}
|
||||
|
||||
Exception? lastException = null;
|
||||
|
||||
foreach (var targetProvider in downloadProviders)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Attempting streaming using target provider: {TargetProvider}", targetProvider);
|
||||
|
||||
string targetId = externalId;
|
||||
if (!externalProvider.Equals(targetProvider, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var translatedId = await TranslateIdAsync(externalProvider, externalId, targetProvider, cancellationToken);
|
||||
if (string.IsNullOrEmpty(translatedId))
|
||||
{
|
||||
_logger.LogWarning("Could not translate track ID from {SourceProvider}:{SourceId} to {TargetProvider} for streaming", externalProvider, externalId, targetProvider);
|
||||
continue;
|
||||
}
|
||||
targetId = translatedId;
|
||||
}
|
||||
|
||||
var service = GetDownloadServiceByName(targetProvider);
|
||||
if (service == null)
|
||||
{
|
||||
_logger.LogWarning("Download service for {Provider} is not registered", targetProvider);
|
||||
continue;
|
||||
}
|
||||
|
||||
var stream = await service.DownloadAndStreamAsync(targetProvider, targetId, qualityOverride, cancellationToken);
|
||||
if (stream != null)
|
||||
{
|
||||
_logger.LogInformation("Successfully opened stream using provider {Provider}", targetProvider);
|
||||
return stream;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Streaming failed using provider {Provider}", targetProvider);
|
||||
lastException = ex;
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("All configured download services failed to stream the song.", lastException);
|
||||
}
|
||||
|
||||
public void DownloadRemainingAlbumTracksInBackground(string externalProvider, string albumExternalId, string excludeTrackExternalId)
|
||||
{
|
||||
var providers = GetPrioritizedDownloadProviders();
|
||||
if (providers.Count == 0) return;
|
||||
|
||||
var service = GetDownloadServiceByName(providers[0]);
|
||||
if (service != null)
|
||||
{
|
||||
service.DownloadRemainingAlbumTracksInBackground(externalProvider, albumExternalId, excludeTrackExternalId);
|
||||
}
|
||||
}
|
||||
|
||||
public DownloadInfo? GetDownloadStatus(string songId)
|
||||
{
|
||||
foreach (var service in _allServices)
|
||||
{
|
||||
var status = service.GetDownloadStatus(songId);
|
||||
if (status != null) return status;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public IReadOnlyList<DownloadInfo> GetActiveDownloads()
|
||||
{
|
||||
return _allServices.SelectMany(s => s.GetActiveDownloads()).ToList();
|
||||
}
|
||||
|
||||
public async Task<string?> GetLocalPathIfExistsAsync(string externalProvider, string externalId)
|
||||
{
|
||||
var service = GetDownloadServiceByName(externalProvider);
|
||||
if (service != null)
|
||||
{
|
||||
var path = await service.GetLocalPathIfExistsAsync(externalProvider, externalId);
|
||||
if (path != null) return path;
|
||||
}
|
||||
|
||||
foreach (var s in _allServices)
|
||||
{
|
||||
var path = await s.GetLocalPathIfExistsAsync(externalProvider, externalId);
|
||||
if (path != null) return path;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task<bool> IsAvailableAsync()
|
||||
{
|
||||
return GetPrioritizedDownloadProviders().Count > 0;
|
||||
}
|
||||
|
||||
private IReadOnlyList<string> GetPrioritizedDownloadProviders()
|
||||
{
|
||||
return _statusManager.GetEnabledSearchProviders()
|
||||
.Where(p => p != "spotify")
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private IDownloadService? GetDownloadServiceByName(string name)
|
||||
{
|
||||
var normalizedName = name.ToLowerInvariant();
|
||||
return _allServices.FirstOrDefault(s =>
|
||||
s.GetType().Name.StartsWith(normalizedName, StringComparison.OrdinalIgnoreCase) ||
|
||||
(normalizedName == "squidwtf" && s.GetType().Name.StartsWith("SquidWTF", StringComparison.OrdinalIgnoreCase)) ||
|
||||
(normalizedName == "applemusic" && s.GetType().Name.StartsWith("AppleMusic", StringComparison.OrdinalIgnoreCase))
|
||||
);
|
||||
}
|
||||
|
||||
private IMusicMetadataService? GetMetadataServiceByName(string name)
|
||||
{
|
||||
// Obtain metadata services from DI indirectly or filter from metadata service if needed,
|
||||
// but since metadataService resolves to MultiProviderMetadataService, we can just inject
|
||||
// IEnumerable<IMusicMetadataService> to find the concrete one!
|
||||
// To be safe and clean, let's resolve this from the MultiProviderMetadataService itself,
|
||||
// or we can pass IEnumerable<IMusicMetadataService> to MultiProviderDownloadService as well!
|
||||
// Let's check: can we inject both? Yes! Let's do that!
|
||||
return null; // Will be mapped dynamically in constructor if we inject IEnumerable<IMusicMetadataService>
|
||||
}
|
||||
|
||||
|
||||
public MultiProviderDownloadService(
|
||||
IEnumerable<IDownloadService> services,
|
||||
IEnumerable<IMusicMetadataService> metadataServices,
|
||||
IMusicMetadataService metadataService,
|
||||
ProviderStatusManager statusManager,
|
||||
OdesliService odesliService,
|
||||
ILogger<MultiProviderDownloadService> logger)
|
||||
{
|
||||
_allServices = services.Where(s => s.GetType() != typeof(MultiProviderDownloadService)).ToList();
|
||||
_allMetadataServices = metadataServices.Where(s => s.GetType() != typeof(MultiProviderMetadataService)).ToList();
|
||||
_metadataService = metadataService;
|
||||
_statusManager = statusManager;
|
||||
_odesliService = odesliService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
private IMusicMetadataService? GetConcreteMetadataServiceByName(string name)
|
||||
{
|
||||
var normalizedName = name.ToLowerInvariant();
|
||||
return _allMetadataServices.FirstOrDefault(s =>
|
||||
s.GetType().Name.StartsWith(normalizedName, StringComparison.OrdinalIgnoreCase) ||
|
||||
(normalizedName == "squidwtf" && s.GetType().Name.StartsWith("SquidWTF", StringComparison.OrdinalIgnoreCase)) ||
|
||||
(normalizedName == "applemusic" && s.GetType().Name.StartsWith("AppleMusic", StringComparison.OrdinalIgnoreCase))
|
||||
);
|
||||
}
|
||||
|
||||
private async Task<string?> TranslateIdAsync(string sourceProvider, string sourceId, string targetProvider, CancellationToken cancellationToken)
|
||||
{
|
||||
var sourceSong = await _metadataService.GetSongAsync(sourceProvider, sourceId, cancellationToken);
|
||||
if (sourceSong == null) return null;
|
||||
|
||||
var sourceUrl = GetTrackUrl(sourceProvider, sourceId, sourceSong);
|
||||
if (!string.IsNullOrEmpty(sourceUrl))
|
||||
{
|
||||
var odesliId = await _odesliService.TranslateTrackUrlAsync(sourceUrl, targetProvider, cancellationToken);
|
||||
if (!string.IsNullOrEmpty(odesliId))
|
||||
{
|
||||
_logger.LogInformation("Translated track using Odesli: {SourceProvider}:{SourceId} -> {TargetProvider}:{TargetId}", sourceProvider, sourceId, targetProvider, odesliId);
|
||||
return odesliId;
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(sourceSong.Isrc))
|
||||
{
|
||||
var targetMetadataService = GetConcreteMetadataServiceByName(targetProvider);
|
||||
if (targetMetadataService != null)
|
||||
{
|
||||
var match = await targetMetadataService.FindSongByIsrcAsync(sourceSong.Isrc, cancellationToken);
|
||||
if (match != null && !string.IsNullOrEmpty(match.ExternalId))
|
||||
{
|
||||
_logger.LogInformation("Translated track using ISRC {Isrc}: {SourceProvider}:{SourceId} -> {TargetProvider}:{TargetId}", sourceSong.Isrc, sourceProvider, sourceId, targetProvider, match.ExternalId);
|
||||
return match.ExternalId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var targetMetadataServiceForSearch = GetConcreteMetadataServiceByName(targetProvider);
|
||||
if (targetMetadataServiceForSearch != null)
|
||||
{
|
||||
var query = $"{sourceSong.Title} {sourceSong.Artist}";
|
||||
var results = await targetMetadataServiceForSearch.SearchSongsAsync(query, 5, cancellationToken);
|
||||
var match = results.FirstOrDefault(r =>
|
||||
r.Title.Contains(sourceSong.Title, StringComparison.OrdinalIgnoreCase) ||
|
||||
sourceSong.Title.Contains(r.Title, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (match != null && !string.IsNullOrEmpty(match.ExternalId))
|
||||
{
|
||||
_logger.LogInformation("Translated track using text search mapping: {SourceProvider}:{SourceId} -> {TargetProvider}:{TargetId}", sourceProvider, sourceId, targetProvider, match.ExternalId);
|
||||
return match.ExternalId;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private string? GetTrackUrl(string provider, string id, Song song)
|
||||
{
|
||||
return provider.ToLowerInvariant() switch
|
||||
{
|
||||
"spotify" => $"https://open.spotify.com/track/{id}",
|
||||
"deezer" => $"https://www.deezer.com/track/{id}",
|
||||
"applemusic" => $"https://music.apple.com/us/song/{id}",
|
||||
"qobuz" => $"https://open.qobuz.com/track/{id}",
|
||||
"squidwtf" => $"https://tidal.com/browse/track/{id}",
|
||||
"tidal" => $"https://tidal.com/browse/track/{id}",
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
using allstarr.Models.Domain;
|
||||
using allstarr.Models.Search;
|
||||
using allstarr.Models.Subsonic;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace allstarr.Services.Common;
|
||||
|
||||
public class MultiProviderMetadataService : IMusicMetadataService
|
||||
{
|
||||
private readonly IEnumerable<IMusicMetadataService> _allServices;
|
||||
private readonly ProviderStatusManager _statusManager;
|
||||
private readonly ILogger<MultiProviderMetadataService> _logger;
|
||||
|
||||
public MultiProviderMetadataService(
|
||||
IEnumerable<IMusicMetadataService> services,
|
||||
ProviderStatusManager statusManager,
|
||||
ILogger<MultiProviderMetadataService> logger)
|
||||
{
|
||||
_allServices = services.Where(s => s.GetType() != typeof(MultiProviderMetadataService)).ToList();
|
||||
_statusManager = statusManager;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<List<Song>> SearchSongsAsync(string query, int limit = 20, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var providers = _statusManager.GetEnabledSearchProviders();
|
||||
if (providers.Count == 0) return new List<Song>();
|
||||
|
||||
var tasks = providers.Select(async p =>
|
||||
{
|
||||
var service = GetMetadataServiceByName(p);
|
||||
if (service == null) return new List<Song>();
|
||||
try
|
||||
{
|
||||
return await service.SearchSongsAsync(query, limit, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "SearchSongsAsync failed for provider: {Provider}", p);
|
||||
return new List<Song>();
|
||||
}
|
||||
});
|
||||
|
||||
var results = await Task.WhenAll(tasks);
|
||||
return InterleaveLists(results.ToList());
|
||||
}
|
||||
|
||||
public async Task<List<Album>> SearchAlbumsAsync(string query, int limit = 20, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var providers = _statusManager.GetEnabledSearchProviders();
|
||||
if (providers.Count == 0) return new List<Album>();
|
||||
|
||||
var tasks = providers.Select(async p =>
|
||||
{
|
||||
var service = GetMetadataServiceByName(p);
|
||||
if (service == null) return new List<Album>();
|
||||
try
|
||||
{
|
||||
return await service.SearchAlbumsAsync(query, limit, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "SearchAlbumsAsync failed for provider: {Provider}", p);
|
||||
return new List<Album>();
|
||||
}
|
||||
});
|
||||
|
||||
var results = await Task.WhenAll(tasks);
|
||||
return InterleaveLists(results.ToList());
|
||||
}
|
||||
|
||||
public async Task<List<Artist>> SearchArtistsAsync(string query, int limit = 20, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var providers = _statusManager.GetEnabledSearchProviders();
|
||||
if (providers.Count == 0) return new List<Artist>();
|
||||
|
||||
var tasks = providers.Select(async p =>
|
||||
{
|
||||
var service = GetMetadataServiceByName(p);
|
||||
if (service == null) return new List<Artist>();
|
||||
try
|
||||
{
|
||||
return await service.SearchArtistsAsync(query, limit, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "SearchArtistsAsync failed for provider: {Provider}", p);
|
||||
return new List<Artist>();
|
||||
}
|
||||
});
|
||||
|
||||
var results = await Task.WhenAll(tasks);
|
||||
return InterleaveLists(results.ToList());
|
||||
}
|
||||
|
||||
public async Task<SearchResult> SearchAllAsync(string query, int songLimit = 20, int albumLimit = 20, int artistLimit = 20, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var providers = _statusManager.GetEnabledSearchProviders();
|
||||
if (providers.Count == 0) return new SearchResult();
|
||||
|
||||
var tasks = providers.Select(async p =>
|
||||
{
|
||||
var service = GetMetadataServiceByName(p);
|
||||
if (service == null) return null;
|
||||
try
|
||||
{
|
||||
return await service.SearchAllAsync(query, songLimit, albumLimit, artistLimit, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "SearchAllAsync failed for provider: {Provider}", p);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
var results = await Task.WhenAll(tasks);
|
||||
var validResults = results.Where(r => r != null).ToList();
|
||||
|
||||
return new SearchResult
|
||||
{
|
||||
Songs = InterleaveLists(validResults.Select(r => r!.Songs).ToList()),
|
||||
Albums = InterleaveLists(validResults.Select(r => r!.Albums).ToList()),
|
||||
Artists = InterleaveLists(validResults.Select(r => r!.Artists).ToList())
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<Song?> GetSongAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var service = GetMetadataServiceByName(externalProvider);
|
||||
if (service == null) return null;
|
||||
return await service.GetSongAsync(externalProvider, externalId, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<Song?> FindSongByIsrcAsync(string isrc, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Try searching in parallel across all enabled search providers to find the first exact match
|
||||
var providers = _statusManager.GetEnabledSearchProviders();
|
||||
if (providers.Count == 0) return null;
|
||||
|
||||
var tasks = providers.Select(async p =>
|
||||
{
|
||||
var service = GetMetadataServiceByName(p);
|
||||
if (service == null) return null;
|
||||
try
|
||||
{
|
||||
return await service.FindSongByIsrcAsync(isrc, cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
var results = await Task.WhenAll(tasks);
|
||||
return results.FirstOrDefault(s => s != null);
|
||||
}
|
||||
|
||||
public async Task<Album?> GetAlbumAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var service = GetMetadataServiceByName(externalProvider);
|
||||
if (service == null) return null;
|
||||
return await service.GetAlbumAsync(externalProvider, externalId, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<Artist?> GetArtistAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var service = GetMetadataServiceByName(externalProvider);
|
||||
if (service == null) return null;
|
||||
return await service.GetArtistAsync(externalProvider, externalId, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<List<Album>> GetArtistAlbumsAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var service = GetMetadataServiceByName(externalProvider);
|
||||
if (service == null) return new List<Album>();
|
||||
return await service.GetArtistAlbumsAsync(externalProvider, externalId, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<List<Song>> GetArtistTracksAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var service = GetMetadataServiceByName(externalProvider);
|
||||
if (service == null) return new List<Song>();
|
||||
return await service.GetArtistTracksAsync(externalProvider, externalId, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<List<ExternalPlaylist>> SearchPlaylistsAsync(string query, int limit = 20, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var providers = _statusManager.GetEnabledPlaylistProviders();
|
||||
if (providers.Count == 0) return new List<ExternalPlaylist>();
|
||||
|
||||
var tasks = providers.Select(async p =>
|
||||
{
|
||||
var service = GetMetadataServiceByName(p);
|
||||
if (service == null) return new List<ExternalPlaylist>();
|
||||
try
|
||||
{
|
||||
return await service.SearchPlaylistsAsync(query, limit, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "SearchPlaylistsAsync failed for provider: {Provider}", p);
|
||||
return new List<ExternalPlaylist>();
|
||||
}
|
||||
});
|
||||
|
||||
var results = await Task.WhenAll(tasks);
|
||||
return InterleaveLists(results.ToList());
|
||||
}
|
||||
|
||||
public async Task<ExternalPlaylist?> GetPlaylistAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var service = GetMetadataServiceByName(externalProvider);
|
||||
if (service == null) return null;
|
||||
return await service.GetPlaylistAsync(externalProvider, externalId, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<List<Song>> GetPlaylistTracksAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var service = GetMetadataServiceByName(externalProvider);
|
||||
if (service == null) return new List<Song>();
|
||||
return await service.GetPlaylistTracksAsync(externalProvider, externalId, cancellationToken);
|
||||
}
|
||||
|
||||
private IMusicMetadataService? GetMetadataServiceByName(string name)
|
||||
{
|
||||
var normalizedName = name.ToLowerInvariant();
|
||||
return _allServices.FirstOrDefault(s =>
|
||||
s.GetType().Name.StartsWith(normalizedName, StringComparison.OrdinalIgnoreCase) ||
|
||||
(normalizedName == "squidwtf" && s.GetType().Name.StartsWith("SquidWTF", StringComparison.OrdinalIgnoreCase)) ||
|
||||
(normalizedName == "applemusic" && s.GetType().Name.StartsWith("AppleMusic", StringComparison.OrdinalIgnoreCase))
|
||||
);
|
||||
}
|
||||
|
||||
private List<T> InterleaveLists<T>(List<List<T>> lists)
|
||||
{
|
||||
var result = new List<T>();
|
||||
var nonNullLists = lists.Where(l => l != null && l.Count > 0).ToList();
|
||||
if (nonNullLists.Count == 0) return result;
|
||||
|
||||
int maxCount = nonNullLists.Max(l => l.Count);
|
||||
for (int i = 0; i < maxCount; i++)
|
||||
{
|
||||
foreach (var list in nonNullLists)
|
||||
{
|
||||
if (i < list.Count)
|
||||
{
|
||||
result.Add(list[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -140,4 +140,101 @@ public class OdesliService
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Translates a track URL from a source provider to a target provider's track ID.
|
||||
/// </summary>
|
||||
public async Task<string?> TranslateTrackUrlAsync(string sourceUrl, string targetProvider, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var targetPlatform = targetProvider.ToLowerInvariant() switch
|
||||
{
|
||||
"spotify" => "spotify",
|
||||
"applemusic" => "appleMusic",
|
||||
"deezer" => "deezer",
|
||||
"qobuz" => "qobuz",
|
||||
"squidwtf" => "tidal",
|
||||
"tidal" => "tidal",
|
||||
_ => null
|
||||
};
|
||||
|
||||
if (targetPlatform == null) return null;
|
||||
|
||||
var cacheKey = $"odesli:translate:{sourceUrl}:{targetPlatform}";
|
||||
var cached = await _cache.GetAsync<string>(cacheKey);
|
||||
if (!string.IsNullOrEmpty(cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var odesliUrl = $"https://api.song.link/v1-alpha.1/links?url={Uri.EscapeDataString(sourceUrl)}&userCountry=US";
|
||||
_logger.LogDebug("🔗 Odesli: Translating {Url} to platform {Platform}", sourceUrl, targetPlatform);
|
||||
|
||||
var response = await _httpClient.GetAsync(odesliUrl, cancellationToken);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
|
||||
if (doc.RootElement.TryGetProperty("linksByPlatform", out var platforms) &&
|
||||
platforms.TryGetProperty(targetPlatform, out var platformObj) &&
|
||||
platformObj.TryGetProperty("url", out var urlEl))
|
||||
{
|
||||
var targetUrl = urlEl.GetString();
|
||||
if (!string.IsNullOrEmpty(targetUrl))
|
||||
{
|
||||
var targetId = ExtractTrackIdFromUrl(targetUrl, targetPlatform);
|
||||
if (!string.IsNullOrEmpty(targetId))
|
||||
{
|
||||
await _cache.SetAsync(cacheKey, targetId, CacheExtensions.OdesliLookupTTL);
|
||||
return targetId;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to translate URL {Url} to {Platform} via Odesli", sourceUrl, targetPlatform);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? ExtractTrackIdFromUrl(string url, string platform)
|
||||
{
|
||||
if (string.IsNullOrEmpty(url)) return null;
|
||||
|
||||
if (platform == "spotify")
|
||||
{
|
||||
var match = System.Text.RegularExpressions.Regex.Match(url, @"spotify\.com/track/([a-zA-Z0-9]+)");
|
||||
return match.Success ? match.Groups[1].Value : null;
|
||||
}
|
||||
if (platform == "appleMusic")
|
||||
{
|
||||
var match = System.Text.RegularExpressions.Regex.Match(url, @"[?&]i=([0-9]+)");
|
||||
if (match.Success) return match.Groups[1].Value;
|
||||
|
||||
match = System.Text.RegularExpressions.Regex.Match(url, @"/song/([0-9]+)");
|
||||
if (match.Success) return match.Groups[1].Value;
|
||||
}
|
||||
if (platform == "deezer")
|
||||
{
|
||||
var match = System.Text.RegularExpressions.Regex.Match(url, @"/track/([0-9]+)");
|
||||
return match.Success ? match.Groups[1].Value : null;
|
||||
}
|
||||
if (platform == "qobuz")
|
||||
{
|
||||
var match = System.Text.RegularExpressions.Regex.Match(url, @"/track/([0-9]+)");
|
||||
return match.Success ? match.Groups[1].Value : null;
|
||||
}
|
||||
if (platform == "tidal")
|
||||
{
|
||||
var match = System.Text.RegularExpressions.Regex.Match(url, @"/track/([0-9]+)");
|
||||
return match.Success ? match.Groups[1].Value : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -4,132 +4,26 @@ using allstarr.Models.Search;
|
||||
namespace allstarr.Services.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Races multiple metadata providers in parallel and returns the fastest result.
|
||||
/// Used for search operations to minimize latency.
|
||||
/// Delegation wrapper that forwards search calls to MultiProviderMetadataService.
|
||||
/// Keeps class signature intact to avoid breaking DI dependency bindings in other controllers.
|
||||
/// </summary>
|
||||
public class ParallelMetadataService
|
||||
{
|
||||
private readonly IEnumerable<IMusicMetadataService> _providers;
|
||||
private readonly ILogger<ParallelMetadataService> _logger;
|
||||
private readonly IMusicMetadataService _metadataService;
|
||||
|
||||
public ParallelMetadataService(
|
||||
IEnumerable<IMusicMetadataService> providers,
|
||||
ILogger<ParallelMetadataService> logger)
|
||||
public ParallelMetadataService(IMusicMetadataService metadataService)
|
||||
{
|
||||
_providers = providers;
|
||||
_logger = logger;
|
||||
_metadataService = metadataService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Races all providers and returns the first successful result.
|
||||
/// Falls back to next provider if first one fails.
|
||||
/// </summary>
|
||||
public async Task<SearchResult> SearchAllAsync(string query, int songLimit = 20, int albumLimit = 20, int artistLimit = 20, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_providers.Any())
|
||||
{
|
||||
_logger.LogWarning("No metadata providers available for parallel search");
|
||||
return new SearchResult();
|
||||
}
|
||||
|
||||
_logger.LogDebug("🏁 Racing {Count} providers for search: {Query}", _providers.Count(), query);
|
||||
|
||||
// Create tasks for all providers
|
||||
var tasks = _providers.Select(async provider =>
|
||||
{
|
||||
var providerName = provider.GetType().Name;
|
||||
try
|
||||
{
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
var result = await provider.SearchAllAsync(query, songLimit, albumLimit, artistLimit, cancellationToken);
|
||||
sw.Stop();
|
||||
|
||||
_logger.LogInformation("✅ {Provider} completed search in {Ms}ms ({Songs} songs, {Albums} albums, {Artists} artists)",
|
||||
providerName, sw.ElapsedMilliseconds, result.Songs.Count, result.Albums.Count, result.Artists.Count);
|
||||
|
||||
return (Success: true, Result: result, Provider: providerName, ElapsedMs: sw.ElapsedMilliseconds);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "❌ {Provider} search failed", providerName);
|
||||
return (Success: false, Result: new SearchResult(), Provider: providerName, ElapsedMs: 0L);
|
||||
}
|
||||
}).ToList();
|
||||
|
||||
// Wait for first successful result
|
||||
while (tasks.Any())
|
||||
{
|
||||
var completedTask = await Task.WhenAny(tasks);
|
||||
var result = await completedTask;
|
||||
|
||||
if (result.Success && (result.Result.Songs.Any() || result.Result.Albums.Any() || result.Result.Artists.Any()))
|
||||
{
|
||||
_logger.LogDebug("🏆 Using results from {Provider} ({Ms}ms) - fastest with results",
|
||||
result.Provider, result.ElapsedMs);
|
||||
return result.Result;
|
||||
}
|
||||
|
||||
// Remove completed task and try next
|
||||
tasks.Remove(completedTask);
|
||||
}
|
||||
|
||||
// All providers failed or returned empty
|
||||
_logger.LogWarning("⚠️ All providers failed or returned empty results for: {Query}", query);
|
||||
return new SearchResult();
|
||||
return await _metadataService.SearchAllAsync(query, songLimit, albumLimit, artistLimit, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Searches for a specific song by title and artist across all providers in parallel.
|
||||
/// Returns the first successful match.
|
||||
/// </summary>
|
||||
public async Task<Song?> SearchSongAsync(string title, string artist, int limit = 5, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_providers.Any())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
_logger.LogDebug("🏁 Racing {Count} providers for song: {Title} - {Artist}", _providers.Count(), title, artist);
|
||||
|
||||
var tasks = _providers.Select(async provider =>
|
||||
{
|
||||
var providerName = provider.GetType().Name;
|
||||
try
|
||||
{
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
var songs = await provider.SearchSongsAsync($"{title} {artist}", limit, cancellationToken);
|
||||
sw.Stop();
|
||||
|
||||
var bestMatch = songs.FirstOrDefault();
|
||||
if (bestMatch != null)
|
||||
{
|
||||
_logger.LogInformation("✅ {Provider} found song in {Ms}ms", providerName, sw.ElapsedMilliseconds);
|
||||
}
|
||||
|
||||
return (Success: bestMatch != null, Song: bestMatch, Provider: providerName, ElapsedMs: sw.ElapsedMilliseconds);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "❌ {Provider} song search failed", providerName);
|
||||
return (Success: false, Song: (Song?)null, Provider: providerName, ElapsedMs: 0L);
|
||||
}
|
||||
}).ToList();
|
||||
|
||||
// Wait for first successful result
|
||||
while (tasks.Any())
|
||||
{
|
||||
var completedTask = await Task.WhenAny(tasks);
|
||||
var result = await completedTask;
|
||||
|
||||
if (result.Success && result.Song != null)
|
||||
{
|
||||
_logger.LogInformation("🏆 Using song from {Provider} ({Ms}ms)", result.Provider, result.ElapsedMs);
|
||||
return result.Song;
|
||||
}
|
||||
|
||||
tasks.Remove(completedTask);
|
||||
}
|
||||
|
||||
return null;
|
||||
var songs = await _metadataService.SearchSongsAsync($"{title} {artist}", limit, cancellationToken);
|
||||
return songs.FirstOrDefault();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using allstarr.Models.Settings;
|
||||
using allstarr.Services.SquidWTF;
|
||||
|
||||
namespace allstarr.Services.Common;
|
||||
|
||||
public class ProviderStatusManager
|
||||
{
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly ILogger<ProviderStatusManager> _logger;
|
||||
private readonly SpotifyApiSettings _spotifySettings;
|
||||
private readonly AppleMusicSettings _appleMusicSettings;
|
||||
private readonly DeezerSettings _deezerSettings;
|
||||
private readonly QobuzSettings _qobuzSettings;
|
||||
private readonly SquidWTFSettings _squidWtfSettings;
|
||||
private readonly SquidWtfEndpointCatalog _squidWtfCatalog;
|
||||
|
||||
private readonly ConcurrentDictionary<string, (bool IsHealthy, DateTime TestedAt)> _statusCache = new();
|
||||
|
||||
public ProviderStatusManager(
|
||||
IConfiguration configuration,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
ILogger<ProviderStatusManager> logger,
|
||||
IOptions<SpotifyApiSettings> spotifySettings,
|
||||
IOptions<AppleMusicSettings> appleMusicSettings,
|
||||
IOptions<DeezerSettings> deezerSettings,
|
||||
IOptions<QobuzSettings> qobuzSettings,
|
||||
IOptions<SquidWTFSettings> squidWtfSettings,
|
||||
SquidWtfEndpointCatalog squidWtfCatalog)
|
||||
{
|
||||
_configuration = configuration;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_logger = logger;
|
||||
_spotifySettings = spotifySettings.Value;
|
||||
_appleMusicSettings = appleMusicSettings.Value;
|
||||
_deezerSettings = deezerSettings.Value;
|
||||
_qobuzSettings = qobuzSettings.Value;
|
||||
_squidWtfSettings = squidWtfSettings.Value;
|
||||
_squidWtfCatalog = squidWtfCatalog;
|
||||
}
|
||||
|
||||
public IReadOnlyList<string> GetEnabledSearchProviders()
|
||||
{
|
||||
var order = GetMetadataOrder();
|
||||
var enabled = GetEnabledSearchRaw();
|
||||
|
||||
return order
|
||||
.Where(p => enabled.Contains(p) && IsProviderHealthy(p))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public IReadOnlyList<string> GetEnabledPlaylistProviders()
|
||||
{
|
||||
var order = GetMetadataOrder();
|
||||
var enabled = GetEnabledPlaylistRaw();
|
||||
|
||||
return order
|
||||
.Where(p => enabled.Contains(p) && IsProviderHealthy(p))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public bool IsProviderHealthy(string provider)
|
||||
{
|
||||
var prov = provider.ToLowerInvariant();
|
||||
if (!_statusCache.TryGetValue(prov, out var cache))
|
||||
{
|
||||
// If never tested, default to healthy but trigger async check in background
|
||||
_statusCache[prov] = (true, DateTime.UtcNow);
|
||||
_ = Task.Run(() => TestProviderConnectionAsync(prov));
|
||||
return true;
|
||||
}
|
||||
|
||||
// Cache lifetime 5 minutes
|
||||
if (DateTime.UtcNow - cache.TestedAt > TimeSpan.FromMinutes(5))
|
||||
{
|
||||
_ = Task.Run(() => TestProviderConnectionAsync(prov));
|
||||
}
|
||||
|
||||
return cache.IsHealthy;
|
||||
}
|
||||
|
||||
public async Task<bool> TestProviderConnectionAsync(string provider, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var prov = provider.ToLowerInvariant();
|
||||
bool isHealthy = false;
|
||||
|
||||
_logger.LogDebug("Testing connectivity for provider: {Provider}", prov);
|
||||
|
||||
try
|
||||
{
|
||||
isHealthy = prov switch
|
||||
{
|
||||
"spotify" => await TestSpotifyAsync(cancellationToken),
|
||||
"applemusic" => await TestAppleMusicAsync(cancellationToken),
|
||||
"deezer" => await TestDeezerAsync(cancellationToken),
|
||||
"qobuz" => await TestQobuzAsync(cancellationToken),
|
||||
"squidwtf" => await TestSquidWtfAsync(cancellationToken),
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Health check failed for provider {Provider}", prov);
|
||||
isHealthy = false;
|
||||
}
|
||||
|
||||
_statusCache[prov] = (isHealthy, DateTime.UtcNow);
|
||||
_logger.LogInformation("Provider health check result: {Provider} => healthy={Healthy}", prov, isHealthy);
|
||||
return isHealthy;
|
||||
}
|
||||
|
||||
public IReadOnlyDictionary<string, (bool IsHealthy, DateTime TestedAt)> GetStatusCache()
|
||||
{
|
||||
return _statusCache.ToDictionary(k => k.Key, v => v.Value);
|
||||
}
|
||||
|
||||
private List<string> GetMetadataOrder()
|
||||
{
|
||||
var val = _configuration["MULTI_PROVIDER_METADATA_ORDER"] ?? "spotify,applemusic,deezer,qobuz,squidwtf";
|
||||
return val.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(s => s.ToLowerInvariant())
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private HashSet<string> GetEnabledSearchRaw()
|
||||
{
|
||||
var val = _configuration["MULTI_PROVIDER_ENABLED_SEARCH"] ?? "spotify,applemusic,deezer,qobuz,squidwtf";
|
||||
return val.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(s => s.ToLowerInvariant())
|
||||
.ToHashSet();
|
||||
}
|
||||
|
||||
private HashSet<string> GetEnabledPlaylistRaw()
|
||||
{
|
||||
var val = _configuration["MULTI_PROVIDER_ENABLED_PLAYLIST"] ?? "spotify";
|
||||
return val.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(s => s.ToLowerInvariant())
|
||||
.ToHashSet();
|
||||
}
|
||||
|
||||
private async Task<bool> TestSpotifyAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var cookie = _spotifySettings.SessionCookie;
|
||||
if (string.IsNullOrWhiteSpace(cookie)) return false;
|
||||
|
||||
using var client = _httpClientFactory.CreateClient();
|
||||
client.Timeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, "https://open.spotify.com/");
|
||||
request.Headers.Add("Cookie", $"sp_dc={cookie}");
|
||||
request.Headers.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36");
|
||||
|
||||
var response = await client.SendAsync(request, cancellationToken);
|
||||
return response.IsSuccessStatusCode;
|
||||
}
|
||||
|
||||
private async Task<bool> TestAppleMusicAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var baseUrl = _appleMusicSettings.BaseUrl;
|
||||
if (string.IsNullOrWhiteSpace(baseUrl)) return false;
|
||||
|
||||
using var client = _httpClientFactory.CreateClient();
|
||||
client.Timeout = TimeSpan.FromSeconds(5);
|
||||
client.BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/");
|
||||
|
||||
var response = await client.GetAsync("api/health", cancellationToken);
|
||||
if (!response.IsSuccessStatusCode) return false;
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
if (doc.RootElement.TryGetProperty("staged", out var staged) &&
|
||||
doc.RootElement.TryGetProperty("logged_in", out var loggedIn))
|
||||
{
|
||||
return staged.GetBoolean() && loggedIn.GetBoolean();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private async Task<bool> TestDeezerAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var arl = _deezerSettings.Arl;
|
||||
if (string.IsNullOrWhiteSpace(arl)) return false;
|
||||
|
||||
using var client = _httpClientFactory.CreateClient();
|
||||
client.Timeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
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");
|
||||
request.Headers.Add("Cookie", $"arl={arl}");
|
||||
request.Content = new StringContent("{}", System.Text.Encoding.UTF8, "application/json");
|
||||
|
||||
var response = await client.SendAsync(request, cancellationToken);
|
||||
if (!response.IsSuccessStatusCode) return false;
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
if (doc.RootElement.TryGetProperty("results", out var results) &&
|
||||
results.TryGetProperty("USER", out var user) &&
|
||||
user.TryGetProperty("USER_ID", out var userId))
|
||||
{
|
||||
var idVal = userId.ValueKind == JsonValueKind.Number ? userId.GetInt64() : 0;
|
||||
return idVal > 0;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private async Task<bool> TestQobuzAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var token = _qobuzSettings.UserAuthToken;
|
||||
var userId = _qobuzSettings.UserId;
|
||||
if (string.IsNullOrWhiteSpace(token) || string.IsNullOrWhiteSpace(userId)) return false;
|
||||
|
||||
using var client = _httpClientFactory.CreateClient();
|
||||
client.Timeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
var appId = "798273057";
|
||||
var apiUrl = $"https://www.qobuz.com/api.json/0.2/favorite/getUserFavorites?user_id={userId}&app_id={appId}";
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, apiUrl);
|
||||
request.Headers.Add("X-App-Id", appId);
|
||||
request.Headers.Add("X-User-Auth-Token", token);
|
||||
request.Headers.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36");
|
||||
|
||||
var response = await client.SendAsync(request, cancellationToken);
|
||||
return response.IsSuccessStatusCode;
|
||||
}
|
||||
|
||||
private async Task<bool> TestSquidWtfAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var apiUrls = _squidWtfCatalog.ApiUrls;
|
||||
if (apiUrls == null || apiUrls.Count == 0) return false;
|
||||
|
||||
using var client = _httpClientFactory.CreateClient();
|
||||
client.Timeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
foreach (var url in apiUrls)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await client.GetAsync(url, cancellationToken);
|
||||
if (response.IsSuccessStatusCode) return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Continue to next URL
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -880,6 +880,106 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.priority-list {
|
||||
list-style-type: none;
|
||||
padding: 0;
|
||||
margin: 10px 0;
|
||||
}
|
||||
.priority-item {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 6px;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 8px;
|
||||
cursor: grab;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
transition: background-color 0.2s, transform 0.2s, border-color 0.2s;
|
||||
}
|
||||
.priority-item:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
.priority-item:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.priority-item.dragging {
|
||||
opacity: 0.4;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
transform: scale(0.98);
|
||||
}
|
||||
.priority-item-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.priority-item-drag-handle {
|
||||
margin-right: 12px;
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
font-size: 1.2rem;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
}
|
||||
.status-indicator {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
margin-right: 6px;
|
||||
}
|
||||
.status-indicator.healthy {
|
||||
background-color: var(--success, #10b981);
|
||||
box-shadow: 0 0 8px rgba(16, 185, 129, 0.5);
|
||||
}
|
||||
.status-indicator.unhealthy {
|
||||
background-color: var(--error, #ef4444);
|
||||
box-shadow: 0 0 8px rgba(239, 68, 68, 0.5);
|
||||
}
|
||||
.status-indicator.testing {
|
||||
background-color: var(--warning, #f59e0b);
|
||||
animation: pulse 1s infinite alternate;
|
||||
}
|
||||
@keyframes pulse {
|
||||
from { opacity: 0.5; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="card" id="multi-provider-card">
|
||||
<input type="hidden" id="config-multi-provider-metadata-order" data-env-key="MULTI_PROVIDER_METADATA_ORDER" class="config-input">
|
||||
<input type="hidden" id="config-multi-provider-enabled-search" data-env-key="MULTI_PROVIDER_ENABLED_SEARCH" class="config-input">
|
||||
<input type="hidden" id="config-multi-provider-enabled-playlist" data-env-key="MULTI_PROVIDER_ENABLED_PLAYLIST" class="config-input">
|
||||
<h2>Multi-Service Providers & Priority</h2>
|
||||
<div class="description-text" style="color: var(--text-secondary); margin-bottom: 16px; font-size: 0.9rem;">
|
||||
Configure and prioritize multiple streaming services. Drag and drop to change metadata ranking priority.
|
||||
</div>
|
||||
<div class="table-scroll" style="margin-bottom: 24px;">
|
||||
<table class="playlist-table" id="providers-table" style="width: 100%; border-collapse: collapse;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="text-align: left; padding: 10px;">Service</th>
|
||||
<th style="text-align: center; padding: 10px;">Search & Metadata</th>
|
||||
<th style="text-align: center; padding: 10px;">Playlist Sync</th>
|
||||
<th style="text-align: center; padding: 10px;">Status</th>
|
||||
<th style="text-align: center; padding: 10px;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="providers-tbody">
|
||||
<!-- Populated dynamically via JS -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h3>Metadata Search & Fallback Priority</h3>
|
||||
<div class="description-text" style="color: var(--text-secondary); margin-bottom: 16px; font-size: 0.9rem;">
|
||||
Drag and drop to rank search priorities (highest on top). Fallback downloads will proceed in this order.
|
||||
</div>
|
||||
<ul id="provider-priority-list" class="priority-list" style="list-style-type: none; padding: 0; margin: 10px 0;">
|
||||
<!-- Populated dynamically via JS -->
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Admin Network Settings</h2>
|
||||
<div style="background: rgba(245, 158, 11, 0.12); border: 1px solid var(--warning); border-radius: 6px; padding: 12px; margin-bottom: 16px; color: var(--text-secondary); font-size: 0.9rem;">
|
||||
|
||||
@@ -978,6 +978,8 @@ export function updateConfigUI(data) {
|
||||
setInputValue("config-musicbrainz-username", data.musicBrainz.username && data.musicBrainz.username !== "(not set)" ? data.musicBrainz.username : "");
|
||||
setInputValue("config-musicbrainz-password", data.musicBrainz.password && data.musicBrainz.password !== "(not set)" ? "••••••••" : "");
|
||||
}
|
||||
|
||||
renderMultiProviderSettings(data);
|
||||
}
|
||||
|
||||
export async function saveAllSettings(tabId, options = {}) {
|
||||
@@ -2043,3 +2045,183 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
initSetupWizard();
|
||||
}, 500);
|
||||
});
|
||||
|
||||
// Multi-provider settings helpers
|
||||
function renderMultiProviderSettings(data) {
|
||||
const providersData = data.providers || {
|
||||
metadataOrder: "spotify,applemusic,deezer,qobuz,squidwtf",
|
||||
enabledSearch: "spotify,applemusic,deezer,qobuz,squidwtf",
|
||||
enabledPlaylist: "spotify"
|
||||
};
|
||||
|
||||
setInputValue("config-multi-provider-metadata-order", providersData.metadataOrder);
|
||||
setInputValue("config-multi-provider-enabled-search", providersData.enabledSearch);
|
||||
setInputValue("config-multi-provider-enabled-playlist", providersData.enabledPlaylist);
|
||||
|
||||
const order = providersData.metadataOrder.split(',').map(s => s.trim().toLowerCase());
|
||||
const enabledSearch = providersData.enabledSearch.split(',').map(s => s.trim().toLowerCase());
|
||||
const enabledPlaylist = providersData.enabledPlaylist.split(',').map(s => s.trim().toLowerCase());
|
||||
|
||||
const tbody = document.getElementById("providers-tbody");
|
||||
if (tbody) {
|
||||
const providerNames = {
|
||||
spotify: "Spotify",
|
||||
applemusic: "Apple Music",
|
||||
deezer: "Deezer",
|
||||
qobuz: "Qobuz",
|
||||
squidwtf: "SquidWTF (Tidal)"
|
||||
};
|
||||
|
||||
fetch("/api/admin/providers/status")
|
||||
.then(res => res.json())
|
||||
.then(statuses => {
|
||||
tbody.innerHTML = "";
|
||||
order.forEach(p => {
|
||||
const displayName = providerNames[p] || p;
|
||||
const searchChecked = enabledSearch.includes(p) ? "checked" : "";
|
||||
const playlistChecked = enabledPlaylist.includes(p) ? "checked" : "";
|
||||
|
||||
const statusInfo = statuses.find(s => s.name === p) || { healthy: false };
|
||||
const statusClass = statusInfo.healthy ? "healthy" : "unhealthy";
|
||||
const statusText = statusInfo.healthy ? "Healthy" : "Not Configured / Unhealthy";
|
||||
|
||||
tbody.innerHTML += `
|
||||
<tr data-provider="${p}">
|
||||
<td style="padding: 10px; font-weight: 500;">${displayName}</td>
|
||||
<td style="text-align: center; padding: 10px;">
|
||||
<input type="checkbox" class="provider-search-checkbox config-input-stub" data-provider="${p}" ${searchChecked}>
|
||||
</td>
|
||||
<td style="text-align: center; padding: 10px;">
|
||||
<input type="checkbox" class="provider-playlist-checkbox config-input-stub" data-provider="${p}" ${playlistChecked}>
|
||||
</td>
|
||||
<td style="text-align: center; padding: 10px; display: flex; align-items: center; justify-content: center; min-height: 40px;">
|
||||
<span class="status-indicator ${statusClass}" id="status-dot-${p}"></span>
|
||||
<span id="status-text-${p}" style="font-size: 0.85rem; color: var(--text-secondary);">${statusText}</span>
|
||||
</td>
|
||||
<td style="text-align: center; padding: 10px;">
|
||||
<button type="button" class="btn btn-sm" onclick="testProviderConnection('${p}')" style="padding: 4px 8px; font-size: 0.8rem;">Test</button>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
|
||||
document.querySelectorAll(".provider-search-checkbox").forEach(cb => {
|
||||
cb.addEventListener("change", updateProviderConfigInputs);
|
||||
});
|
||||
document.querySelectorAll(".provider-playlist-checkbox").forEach(cb => {
|
||||
cb.addEventListener("change", updateProviderConfigInputs);
|
||||
});
|
||||
})
|
||||
.catch(err => console.error("Error fetching provider status:", err));
|
||||
}
|
||||
|
||||
const priorityList = document.getElementById("provider-priority-list");
|
||||
if (priorityList) {
|
||||
priorityList.innerHTML = "";
|
||||
order.forEach(p => {
|
||||
const displayName = p === "spotify" ? "Spotify" :
|
||||
p === "applemusic" ? "Apple Music" :
|
||||
p === "deezer" ? "Deezer" :
|
||||
p === "qobuz" ? "Qobuz" :
|
||||
p === "squidwtf" ? "SquidWTF (Tidal)" : p;
|
||||
|
||||
priorityList.innerHTML += `
|
||||
<li class="priority-item" draggable="true" data-provider="${p}">
|
||||
<div class="priority-item-left">
|
||||
<span class="priority-item-drag-handle">☰</span>
|
||||
<span>${displayName}</span>
|
||||
</div>
|
||||
</li>
|
||||
`;
|
||||
});
|
||||
|
||||
initDragAndDrop();
|
||||
}
|
||||
}
|
||||
|
||||
function updateProviderConfigInputs() {
|
||||
const listItems = document.querySelectorAll("#provider-priority-list li");
|
||||
const order = Array.from(listItems).map(item => item.getAttribute("data-provider"));
|
||||
setInputValue("config-multi-provider-metadata-order", order.join(","));
|
||||
|
||||
const searchCbs = document.querySelectorAll(".provider-search-checkbox");
|
||||
const enabledSearch = Array.from(searchCbs)
|
||||
.filter(cb => cb.checked)
|
||||
.map(cb => cb.getAttribute("data-provider"));
|
||||
setInputValue("config-multi-provider-enabled-search", enabledSearch.join(","));
|
||||
|
||||
const playlistCbs = document.querySelectorAll(".provider-playlist-checkbox");
|
||||
const enabledPlaylist = Array.from(playlistCbs)
|
||||
.filter(cb => cb.checked)
|
||||
.map(cb => cb.getAttribute("data-provider"));
|
||||
setInputValue("config-multi-provider-enabled-playlist", enabledPlaylist.join(","));
|
||||
}
|
||||
|
||||
function initDragAndDrop() {
|
||||
const list = document.getElementById("provider-priority-list");
|
||||
if (!list) return;
|
||||
|
||||
let dragEl = null;
|
||||
|
||||
list.addEventListener("dragstart", e => {
|
||||
dragEl = e.target.closest("li");
|
||||
if (dragEl) {
|
||||
dragEl.classList.add("dragging");
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
e.dataTransfer.setData("text/plain", dragEl.getAttribute("data-provider"));
|
||||
}
|
||||
});
|
||||
|
||||
list.addEventListener("dragend", () => {
|
||||
if (dragEl) {
|
||||
dragEl.classList.remove("dragging");
|
||||
dragEl = null;
|
||||
updateProviderConfigInputs();
|
||||
}
|
||||
});
|
||||
|
||||
list.addEventListener("dragover", e => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
|
||||
const target = e.target.closest("li");
|
||||
if (target && target !== dragEl) {
|
||||
const rect = target.getBoundingClientRect();
|
||||
const next = (e.clientY - rect.top) / (rect.bottom - rect.top) > 0.5;
|
||||
list.insertBefore(dragEl, next ? target.nextSibling : target);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
window.testProviderConnection = async function(provider) {
|
||||
const dot = document.getElementById(`status-dot-${provider}`);
|
||||
const txt = document.getElementById(`status-text-${provider}`);
|
||||
if (dot) {
|
||||
dot.className = "status-indicator testing";
|
||||
}
|
||||
if (txt) {
|
||||
txt.textContent = "Testing...";
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/admin/providers/test/${provider}`, { method: "POST" });
|
||||
const data = await res.json();
|
||||
if (dot) {
|
||||
dot.className = data.healthy ? "status-indicator healthy" : "status-indicator unhealthy";
|
||||
}
|
||||
if (txt) {
|
||||
txt.textContent = data.healthy ? "Healthy" : "Not Configured / Unhealthy";
|
||||
}
|
||||
showToast(`${provider} test completed: ${data.healthy ? "Successful" : "Failed"}`, data.healthy ? "success" : "error");
|
||||
} catch (error) {
|
||||
console.error("Test error:", error);
|
||||
if (dot) {
|
||||
dot.className = "status-indicator unhealthy";
|
||||
}
|
||||
if (txt) {
|
||||
txt.textContent = "Error testing connection";
|
||||
}
|
||||
showToast(`Connection test failed for ${provider}`, "error");
|
||||
}
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user