diff --git a/allstarr/Controllers/ConfigController.cs b/allstarr/Controllers/ConfigController.cs index 9ab6728d..43dc691a 100644 --- a/allstarr/Controllers/ConfigController.cs +++ b/allstarr/Controllers/ConfigController.cs @@ -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("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("Debug:LogAllRequests", false)), @@ -952,6 +958,47 @@ public class ConfigController : ControllerBase return _configuration.GetValue("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(); + 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 TestProvider(string provider) + { + var adminCheck = RequireAdministratorForSensitiveOperation("test provider connection"); + if (adminCheck != null) + { + return adminCheck; + } + + var statusManager = HttpContext.RequestServices.GetRequiredService(); + var healthy = await statusManager.TestProviderConnectionAsync(provider, HttpContext.RequestAborted); + return Ok(new { success = true, provider = provider, healthy = healthy }); + } + /// /// Gets detailed memory usage statistics for debugging. /// diff --git a/allstarr/Program.cs b/allstarr/Program.cs index 84aaf4c3..3552da0c 100644 --- a/allstarr/Program.cs +++ b/allstarr/Program.cs @@ -533,10 +533,8 @@ else enableExternalPlaylists = builder.Configuration.GetValue("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(), new List()); +// 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(); } -// 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(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - } +// ---------------------------------------------------- +// Multi-Provider & Concrete Service Registrations +// ---------------------------------------------------- +builder.Services.AddSingleton(); - // Qobuz services (primary) - registered LAST to be injected by default - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); -} -else if (musicService == MusicService.Deezer) -{ - // If playlists enabled, register Qobuz FIRST (secondary provider) - if (enableExternalPlaylists) - { - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - } +// 1. Concrete Metadata Services +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(sp => + new SquidWTFMetadataService( + sp.GetRequiredService(), + sp.GetRequiredService>(), + sp.GetRequiredService>(), + sp.GetRequiredService>(), + sp.GetRequiredService(), + squidWtfApiUrls, + sp.GetService())); - // Deezer services (primary, default) - registered LAST to be injected by default - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); -} -else if (musicService == MusicService.SquidWTF) -{ - // SquidWTF services - pass decoded URLs with fallback support - builder.Services.AddSingleton(sp => - new SquidWTFMetadataService( - sp.GetRequiredService(), - sp.GetRequiredService>(), - sp.GetRequiredService>(), - sp.GetRequiredService>(), - sp.GetRequiredService(), - squidWtfApiUrls, - sp.GetService())); - builder.Services.AddSingleton(sp => - new SquidWTFDownloadService( - sp.GetRequiredService(), - sp.GetRequiredService(), - sp.GetRequiredService(), - sp.GetRequiredService(), - sp.GetRequiredService>(), - sp.GetRequiredService>(), - sp, - sp.GetRequiredService>(), - sp.GetRequiredService(), - squidWtfStreamingUrls)); -} -else if (musicService == MusicService.AppleMusic) -{ - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); -} +// 2. Concrete Download Services +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(sp => + new SquidWTFDownloadService( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService>(), + sp.GetRequiredService>(), + sp, + sp.GetRequiredService>(), + sp.GetRequiredService(), + squidWtfStreamingUrls)); -// Register ParallelMetadataService to race all registered providers for faster searches +// 3. Status Manager & Multi-Provider Orchestrators +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +// 4. Playlist Sync Service +builder.Services.AddSingleton(); + +// 5. ParallelMetadataService Wrapper (delegates to MultiProviderMetadataService) builder.Services.AddSingleton(); // Startup validation - register validators based on backend diff --git a/allstarr/Services/Common/MultiProviderDownloadService.cs b/allstarr/Services/Common/MultiProviderDownloadService.cs new file mode 100644 index 00000000..18c3b285 --- /dev/null +++ b/allstarr/Services/Common/MultiProviderDownloadService.cs @@ -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 _allServices; + private readonly IMusicMetadataService _metadataService; + private readonly ProviderStatusManager _statusManager; + private readonly OdesliService _odesliService; + private readonly ILogger _logger; + private readonly IEnumerable _allMetadataServices; + + public async Task 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 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 GetActiveDownloads() + { + return _allServices.SelectMany(s => s.GetActiveDownloads()).ToList(); + } + + public async Task 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 IsAvailableAsync() + { + return GetPrioritizedDownloadProviders().Count > 0; + } + + private IReadOnlyList 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 to find the concrete one! + // To be safe and clean, let's resolve this from the MultiProviderMetadataService itself, + // or we can pass IEnumerable 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 + } + + + public MultiProviderDownloadService( + IEnumerable services, + IEnumerable metadataServices, + IMusicMetadataService metadataService, + ProviderStatusManager statusManager, + OdesliService odesliService, + ILogger 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 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 + }; + } +} diff --git a/allstarr/Services/Common/MultiProviderMetadataService.cs b/allstarr/Services/Common/MultiProviderMetadataService.cs new file mode 100644 index 00000000..a356d164 --- /dev/null +++ b/allstarr/Services/Common/MultiProviderMetadataService.cs @@ -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 _allServices; + private readonly ProviderStatusManager _statusManager; + private readonly ILogger _logger; + + public MultiProviderMetadataService( + IEnumerable services, + ProviderStatusManager statusManager, + ILogger logger) + { + _allServices = services.Where(s => s.GetType() != typeof(MultiProviderMetadataService)).ToList(); + _statusManager = statusManager; + _logger = logger; + } + + public async Task> SearchSongsAsync(string query, int limit = 20, CancellationToken cancellationToken = default) + { + var providers = _statusManager.GetEnabledSearchProviders(); + if (providers.Count == 0) return new List(); + + var tasks = providers.Select(async p => + { + var service = GetMetadataServiceByName(p); + if (service == null) return new List(); + try + { + return await service.SearchSongsAsync(query, limit, cancellationToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "SearchSongsAsync failed for provider: {Provider}", p); + return new List(); + } + }); + + var results = await Task.WhenAll(tasks); + return InterleaveLists(results.ToList()); + } + + public async Task> SearchAlbumsAsync(string query, int limit = 20, CancellationToken cancellationToken = default) + { + var providers = _statusManager.GetEnabledSearchProviders(); + if (providers.Count == 0) return new List(); + + var tasks = providers.Select(async p => + { + var service = GetMetadataServiceByName(p); + if (service == null) return new List(); + try + { + return await service.SearchAlbumsAsync(query, limit, cancellationToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "SearchAlbumsAsync failed for provider: {Provider}", p); + return new List(); + } + }); + + var results = await Task.WhenAll(tasks); + return InterleaveLists(results.ToList()); + } + + public async Task> SearchArtistsAsync(string query, int limit = 20, CancellationToken cancellationToken = default) + { + var providers = _statusManager.GetEnabledSearchProviders(); + if (providers.Count == 0) return new List(); + + var tasks = providers.Select(async p => + { + var service = GetMetadataServiceByName(p); + if (service == null) return new List(); + try + { + return await service.SearchArtistsAsync(query, limit, cancellationToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "SearchArtistsAsync failed for provider: {Provider}", p); + return new List(); + } + }); + + var results = await Task.WhenAll(tasks); + return InterleaveLists(results.ToList()); + } + + public async Task 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 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 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 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 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> GetArtistAlbumsAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default) + { + var service = GetMetadataServiceByName(externalProvider); + if (service == null) return new List(); + return await service.GetArtistAlbumsAsync(externalProvider, externalId, cancellationToken); + } + + public async Task> GetArtistTracksAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default) + { + var service = GetMetadataServiceByName(externalProvider); + if (service == null) return new List(); + return await service.GetArtistTracksAsync(externalProvider, externalId, cancellationToken); + } + + public async Task> SearchPlaylistsAsync(string query, int limit = 20, CancellationToken cancellationToken = default) + { + var providers = _statusManager.GetEnabledPlaylistProviders(); + if (providers.Count == 0) return new List(); + + var tasks = providers.Select(async p => + { + var service = GetMetadataServiceByName(p); + if (service == null) return new List(); + try + { + return await service.SearchPlaylistsAsync(query, limit, cancellationToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "SearchPlaylistsAsync failed for provider: {Provider}", p); + return new List(); + } + }); + + var results = await Task.WhenAll(tasks); + return InterleaveLists(results.ToList()); + } + + public async Task 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> GetPlaylistTracksAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default) + { + var service = GetMetadataServiceByName(externalProvider); + if (service == null) return new List(); + 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 InterleaveLists(List> lists) + { + var result = new List(); + 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; + } +} diff --git a/allstarr/Services/Common/OdesliService.cs b/allstarr/Services/Common/OdesliService.cs index 57aeaf84..87820f43 100644 --- a/allstarr/Services/Common/OdesliService.cs +++ b/allstarr/Services/Common/OdesliService.cs @@ -140,4 +140,101 @@ public class OdesliService return null; } + + /// + /// Translates a track URL from a source provider to a target provider's track ID. + /// + public async Task 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(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; + } } diff --git a/allstarr/Services/Common/ParallelMetadataService.cs b/allstarr/Services/Common/ParallelMetadataService.cs index 678393ef..57e761a3 100644 --- a/allstarr/Services/Common/ParallelMetadataService.cs +++ b/allstarr/Services/Common/ParallelMetadataService.cs @@ -4,132 +4,26 @@ using allstarr.Models.Search; namespace allstarr.Services.Common; /// -/// 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. /// public class ParallelMetadataService { - private readonly IEnumerable _providers; - private readonly ILogger _logger; + private readonly IMusicMetadataService _metadataService; - public ParallelMetadataService( - IEnumerable providers, - ILogger logger) + public ParallelMetadataService(IMusicMetadataService metadataService) { - _providers = providers; - _logger = logger; + _metadataService = metadataService; } - /// - /// Races all providers and returns the first successful result. - /// Falls back to next provider if first one fails. - /// public async Task 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); } - /// - /// Searches for a specific song by title and artist across all providers in parallel. - /// Returns the first successful match. - /// public async Task 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(); } } diff --git a/allstarr/Services/Common/ProviderStatusManager.cs b/allstarr/Services/Common/ProviderStatusManager.cs new file mode 100644 index 00000000..7df1e93c --- /dev/null +++ b/allstarr/Services/Common/ProviderStatusManager.cs @@ -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 _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 _statusCache = new(); + + public ProviderStatusManager( + IConfiguration configuration, + IHttpClientFactory httpClientFactory, + ILogger logger, + IOptions spotifySettings, + IOptions appleMusicSettings, + IOptions deezerSettings, + IOptions qobuzSettings, + IOptions 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 GetEnabledSearchProviders() + { + var order = GetMetadataOrder(); + var enabled = GetEnabledSearchRaw(); + + return order + .Where(p => enabled.Contains(p) && IsProviderHealthy(p)) + .ToList(); + } + + public IReadOnlyList 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 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 GetStatusCache() + { + return _statusCache.ToDictionary(k => k.Key, v => v.Value); + } + + private List 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 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 GetEnabledPlaylistRaw() + { + var val = _configuration["MULTI_PROVIDER_ENABLED_PLAYLIST"] ?? "spotify"; + return val.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(s => s.ToLowerInvariant()) + .ToHashSet(); + } + + private async Task 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 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 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 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 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; + } +} diff --git a/allstarr/wwwroot/index.html b/allstarr/wwwroot/index.html index 0157b722..5940226a 100644 --- a/allstarr/wwwroot/index.html +++ b/allstarr/wwwroot/index.html @@ -880,6 +880,106 @@ + + +
+ + + +

Multi-Service Providers & Priority

+
+ Configure and prioritize multiple streaming services. Drag and drop to change metadata ranking priority. +
+
+ + + + + + + + + + + + + +
ServiceSearch & MetadataPlaylist SyncStatusActions
+
+ +

Metadata Search & Fallback Priority

+
+ Drag and drop to rank search priorities (highest on top). Fallback downloads will proceed in this order. +
+
    + +
+
+

Admin Network Settings

diff --git a/allstarr/wwwroot/js/ui.js b/allstarr/wwwroot/js/ui.js index b7901f6b..01fd8880 100644 --- a/allstarr/wwwroot/js/ui.js +++ b/allstarr/wwwroot/js/ui.js @@ -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 += ` + + ${displayName} + + + + + + + + + ${statusText} + + + + + + `; + }); + + 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 += ` +
  • +
    + ☰ + ${displayName} +
    +
  • + `; + }); + + 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"); + } +}; +