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:
joshpatra committed 2026-07-07 19:15:57 -04:00
1 parent c8a9b4d7cb
commit d7cfe4e163
9 files changed
+1276 -180

No files matched your search

+47
View File
@@ -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
View File
@@ -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;
}
}
+97
View File
@@ -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;
}
}
+100
View File
@@ -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;">
+182
View File
@@ -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");
}
};