Compare commits

..

9 Commits

Author SHA1 Message Date
bc4e5d9442 fix: enable deduplication for cache mode
Some checks failed
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
- Cache mode now registers downloaded songs in mappings
- Prevents duplicate downloads in cache mode
- Fixes wasted API calls and duplicate cache files
- Both cache and download modes benefit from deduplication
2026-01-30 14:11:47 -05:00
2297455923 fix: prevent duplicate downloads by registering before releasing lock
- Race condition fixed where multiple threads could download same song
- RegisterDownloadedSongAsync now called before lock release
- Second thread finds registered mapping and skips download
- Eliminates duplicate files with (1), (2) suffixes
2026-01-30 13:38:36 -05:00
c2f843eabd fix: add base64 encoded SquidWTF endpoints with automatic fallback
- Decode 6 endpoints at startup (triton, wolf, hund, maus, vogel, katze)
- Automatic fallback when endpoint fails
- All services cycle through endpoints on failure
- URLs stored as base64, decoded once in Program.cs
- Fixes search/download issues
2026-01-30 13:24:34 -05:00
4efbd6890b Add automatic endpoint fallback to all SquidWTF metadata search methods 2026-01-30 12:40:38 -05:00
fc167e5151 Standardize all SquidWTF API URL formats to prevent double slashes 2026-01-30 12:38:41 -05:00
ef1606480c Fix double slash bug in SquidWTF API URLs 2026-01-30 12:36:54 -05:00
1f10bf5bc4 Update startup validator to use base64 encoded endpoint 2026-01-30 12:26:35 -05:00
d135bccaba Fix remaining BaseUrl references 2026-01-30 12:25:07 -05:00
8b21e9880f Add backup API endpoints with automatic fallback 2026-01-30 12:12:55 -05:00
6 changed files with 257 additions and 199 deletions

View File

@@ -140,7 +140,7 @@ Choose your preferred provider via the `MUSIC_SERVICE` environment variable. Add
- **Subsonic**: Navidrome or other Subsonic-compatible server - **Subsonic**: Navidrome or other Subsonic-compatible server
- Credentials for at least one music provider (IF NOT USING SQUIDWTF): - Credentials for at least one music provider (IF NOT USING SQUIDWTF):
- **Deezer**: ARL token from browser cookies - **Deezer**: ARL token from browser cookies
- **Qobuz**: User ID + User Auth Token from browser localStorage ([see Wiki guide](https://github.com/V1ck3s/allstarr/wiki/Getting-Qobuz-Credentials-(User-ID-&-Token))) - **Qobuz**: User ID + User Auth Token from browser localStorage ([see Wiki guide](https://github.com/V1ck3s/octo-fiesta/wiki/Getting-Qobuz-Credentials-(User-ID-&-Token)))
- Docker and Docker Compose (recommended) **or** [.NET 10.0 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) for manual installation - Docker and Docker Compose (recommended) **or** [.NET 10.0 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) for manual installation
## Configuration ## Configuration

View File

@@ -11,9 +11,30 @@ using allstarr.Services.Common;
using allstarr.Services.Lyrics; using allstarr.Services.Lyrics;
using allstarr.Middleware; using allstarr.Middleware;
using allstarr.Filters; using allstarr.Filters;
using Microsoft.Extensions.Http;
using System.Text;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
// Decode SquidWTF API base URLs once at startup
var squidWtfApiUrls = DecodeSquidWtfUrls();
static List<string> DecodeSquidWtfUrls()
{
var encodedUrls = new[]
{
"aHR0cHM6Ly90cml0b24uc3F1aWQud3Rm", // triton
"aHR0cHM6Ly93b2xmLnFxZGwuc2l0ZQ==", // wolf
"aHR0cDovL2h1bmQucXFkbC5zaXRl", // hund
"aHR0cHM6Ly9tYXVzLnFxZGwuc2l0ZQ==", // maus
"aHR0cHM6Ly92b2dlbC5xcWRsLnNpdGU=", // vogel
"aHR0cHM6Ly9rYXR6ZS5xcWRsLnNpdGU=" // katze
};
return encodedUrls
.Select(encoded => Encoding.UTF8.GetString(Convert.FromBase64String(encoded)))
.ToList();
}
// Determine backend type FIRST // Determine backend type FIRST
var backendType = builder.Configuration.GetValue<BackendType>("Backend:Type"); var backendType = builder.Configuration.GetValue<BackendType>("Backend:Type");
@@ -55,6 +76,17 @@ builder.Services.AddControllers()
}); });
builder.Services.AddHttpClient(); builder.Services.AddHttpClient();
builder.Services.ConfigureAll<HttpClientFactoryOptions>(options =>
{
options.HttpMessageHandlerBuilderActions.Add(builder =>
{
builder.PrimaryHandler = new HttpClientHandler
{
AllowAutoRedirect = true,
MaxAutomaticRedirections = 5
};
});
});
builder.Services.AddEndpointsApiExplorer(); builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(); builder.Services.AddSwaggerGen();
builder.Services.AddHttpContextAccessor(); builder.Services.AddHttpContextAccessor();
@@ -151,9 +183,26 @@ else if (musicService == MusicService.Deezer)
} }
else if (musicService == MusicService.SquidWTF) else if (musicService == MusicService.SquidWTF)
{ {
// SquidWTF services // SquidWTF services - pass decoded URLs with fallback support
builder.Services.AddSingleton<IMusicMetadataService, SquidWTFMetadataService>(); builder.Services.AddSingleton<IMusicMetadataService>(sp =>
builder.Services.AddSingleton<IDownloadService, SquidWTFDownloadService>(); 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));
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>>(),
squidWtfApiUrls));
} }
// Startup validation - register validators based on backend // Startup validation - register validators based on backend
@@ -168,7 +217,11 @@ else
builder.Services.AddSingleton<IStartupValidator, DeezerStartupValidator>(); builder.Services.AddSingleton<IStartupValidator, DeezerStartupValidator>();
builder.Services.AddSingleton<IStartupValidator, QobuzStartupValidator>(); builder.Services.AddSingleton<IStartupValidator, QobuzStartupValidator>();
builder.Services.AddSingleton<IStartupValidator, SquidWTFStartupValidator>(); builder.Services.AddSingleton<IStartupValidator>(sp =>
new SquidWTFStartupValidator(
sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<SquidWTFSettings>>(),
sp.GetRequiredService<IHttpClientFactory>().CreateClient(),
squidWtfApiUrls));
// Register orchestrator as hosted service // Register orchestrator as hosted service
builder.Services.AddHostedService<StartupValidationOrchestrator>(); builder.Services.AddHostedService<StartupValidationOrchestrator>();

View File

@@ -298,6 +298,9 @@ public abstract class BaseDownloadService : IDownloadService
song.LocalPath = localPath; song.LocalPath = localPath;
// Register BEFORE releasing lock to prevent race conditions (both cache and download modes)
await LocalLibraryService.RegisterDownloadedSongAsync(song, localPath);
// Check if this track belongs to a playlist and update M3U // Check if this track belongs to a playlist and update M3U
if (PlaylistSyncService != null) if (PlaylistSyncService != null)
{ {
@@ -316,11 +319,9 @@ public abstract class BaseDownloadService : IDownloadService
} }
} }
// Only register and scan if NOT in cache mode // Trigger library scan and album download AFTER releasing lock (download mode only)
if (!isCache) if (!isCache)
{ {
await LocalLibraryService.RegisterDownloadedSongAsync(song, localPath);
// Trigger a Subsonic library rescan (with debounce) // Trigger a Subsonic library rescan (with debounce)
_ = Task.Run(async () => _ = Task.Run(async () =>
{ {

View File

@@ -26,7 +26,8 @@ public class SquidWTFDownloadService : BaseDownloadService
private DateTime _lastRequestTime = DateTime.MinValue; private DateTime _lastRequestTime = DateTime.MinValue;
private readonly int _minRequestIntervalMs = 200; private readonly int _minRequestIntervalMs = 200;
private const string SquidWTFApiBase = "https://triton.squid.wtf"; private readonly List<string> _apiUrls;
private int _currentUrlIndex = 0;
protected override string ProviderName => "squidwtf"; protected override string ProviderName => "squidwtf";
@@ -38,29 +39,49 @@ public class SquidWTFDownloadService : BaseDownloadService
IOptions<SubsonicSettings> subsonicSettings, IOptions<SubsonicSettings> subsonicSettings,
IOptions<SquidWTFSettings> SquidWTFSettings, IOptions<SquidWTFSettings> SquidWTFSettings,
IServiceProvider serviceProvider, IServiceProvider serviceProvider,
ILogger<SquidWTFDownloadService> logger) ILogger<SquidWTFDownloadService> logger,
List<string> apiUrls)
: base(configuration, localLibraryService, metadataService, subsonicSettings.Value, serviceProvider, logger) : base(configuration, localLibraryService, metadataService, subsonicSettings.Value, serviceProvider, logger)
{ {
_httpClient = httpClientFactory.CreateClient(); _httpClient = httpClientFactory.CreateClient();
_squidwtfSettings = SquidWTFSettings.Value; _squidwtfSettings = SquidWTFSettings.Value;
_apiUrls = apiUrls;
} }
private async Task<T> TryWithFallbackAsync<T>(Func<string, Task<T>> action)
{
for (int attempt = 0; attempt < _apiUrls.Count; attempt++)
{
try
{
var baseUrl = _apiUrls[_currentUrlIndex];
return await action(baseUrl);
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Request failed with endpoint {Endpoint}, trying next...", _apiUrls[_currentUrlIndex]);
_currentUrlIndex = (_currentUrlIndex + 1) % _apiUrls.Count;
if (attempt == _apiUrls.Count - 1)
{
Logger.LogError("All SquidWTF endpoints failed");
throw;
}
}
}
throw new Exception("All SquidWTF endpoints failed");
}
#region BaseDownloadService Implementation #region BaseDownloadService Implementation
public override async Task<bool> IsAvailableAsync() public override async Task<bool> IsAvailableAsync()
{ {
try return await TryWithFallbackAsync(async (baseUrl) =>
{ {
// Test connectivity to triton.squid.wtf var response = await _httpClient.GetAsync(baseUrl);
var response = await _httpClient.GetAsync("https://triton.squid.wtf/");
Console.WriteLine($"Response code from is available async: {response.IsSuccessStatusCode}"); Console.WriteLine($"Response code from is available async: {response.IsSuccessStatusCode}");
return response.IsSuccessStatusCode; return response.IsSuccessStatusCode;
} });
catch (Exception ex)
{
Logger.LogWarning(ex, "SquidWTF service not available");
return false;
}
} }
protected override string? ExtractExternalIdFromAlbumId(string albumId) protected override string? ExtractExternalIdFromAlbumId(string albumId)
@@ -136,67 +157,69 @@ public class SquidWTFDownloadService : BaseDownloadService
{ {
return await QueueRequestAsync(async () => return await QueueRequestAsync(async () =>
{ {
// Map quality settings to Tidal's quality levels return await TryWithFallbackAsync(async (baseUrl) =>
var quality = _squidwtfSettings.Quality?.ToUpperInvariant() switch
{ {
"FLAC" => "LOSSLESS", // Map quality settings to Tidal's quality levels
"HI_RES" => "HI_RES_LOSSLESS", var quality = _squidwtfSettings.Quality?.ToUpperInvariant() switch
"LOSSLESS" => "LOSSLESS", {
"HIGH" => "HIGH", "FLAC" => "LOSSLESS",
"LOW" => "LOW", "HI_RES" => "HI_RES_LOSSLESS",
_ => "LOSSLESS" // Default to lossless "LOSSLESS" => "LOSSLESS",
}; "HIGH" => "HIGH",
"LOW" => "LOW",
// Use the triton.squid.wtf endpoint to get track download info _ => "LOSSLESS" // Default to lossless
var url = $"https://triton.squid.wtf/track/?id={trackId}&quality={quality}"; };
var url = $"{baseUrl}/track/?id={trackId}&quality={quality}";
Console.WriteLine($"%%%%%%%%%%%%%%%%%%% URL For downloads??: {url}"); Console.WriteLine($"%%%%%%%%%%%%%%%%%%% URL For downloads??: {url}");
var response = await _httpClient.GetAsync(url, cancellationToken); var response = await _httpClient.GetAsync(url, cancellationToken);
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync(cancellationToken); var json = await response.Content.ReadAsStringAsync(cancellationToken);
var doc = JsonDocument.Parse(json); var doc = JsonDocument.Parse(json);
if (!doc.RootElement.TryGetProperty("data", out var data)) if (!doc.RootElement.TryGetProperty("data", out var data))
{ {
throw new Exception("Invalid response from triton.squid.wtf"); throw new Exception("Invalid response from API");
} }
// Get the manifest (base64 encoded JSON containing the actual CDN URL) // Get the manifest (base64 encoded JSON containing the actual CDN URL)
var manifestBase64 = data.GetProperty("manifest").GetString() var manifestBase64 = data.GetProperty("manifest").GetString()
?? throw new Exception("No manifest in response"); ?? throw new Exception("No manifest in response");
// Decode the manifest // Decode the manifest
var manifestJson = Encoding.UTF8.GetString(Convert.FromBase64String(manifestBase64)); var manifestJson = Encoding.UTF8.GetString(Convert.FromBase64String(manifestBase64));
var manifest = JsonDocument.Parse(manifestJson); var manifest = JsonDocument.Parse(manifestJson);
// Extract the download URL from the manifest // Extract the download URL from the manifest
if (!manifest.RootElement.TryGetProperty("urls", out var urls) || urls.GetArrayLength() == 0) if (!manifest.RootElement.TryGetProperty("urls", out var urls) || urls.GetArrayLength() == 0)
{ {
throw new Exception("No download URLs in manifest"); throw new Exception("No download URLs in manifest");
} }
var downloadUrl = urls[0].GetString() var downloadUrl = urls[0].GetString()
?? throw new Exception("Download URL is null"); ?? throw new Exception("Download URL is null");
var mimeType = manifest.RootElement.TryGetProperty("mimeType", out var mimeTypeEl) var mimeType = manifest.RootElement.TryGetProperty("mimeType", out var mimeTypeEl)
? mimeTypeEl.GetString() ? mimeTypeEl.GetString()
: "audio/flac"; : "audio/flac";
var audioQuality = data.TryGetProperty("audioQuality", out var audioQualityEl) var audioQuality = data.TryGetProperty("audioQuality", out var audioQualityEl)
? audioQualityEl.GetString() ? audioQualityEl.GetString()
: "LOSSLESS"; : "LOSSLESS";
Logger.LogDebug("Decoded manifest - URL: {Url}, MIME: {MimeType}, Quality: {Quality}", Logger.LogDebug("Decoded manifest - URL: {Url}, MIME: {MimeType}, Quality: {Quality}",
downloadUrl, mimeType, audioQuality); downloadUrl, mimeType, audioQuality);
return new DownloadResult return new DownloadResult
{ {
DownloadUrl = downloadUrl, DownloadUrl = downloadUrl,
MimeType = mimeType ?? "audio/flac", MimeType = mimeType ?? "audio/flac",
AudioQuality = audioQuality ?? "LOSSLESS" AudioQuality = audioQuality ?? "LOSSLESS"
}; };
});
}); });
} }

View File

@@ -21,30 +21,59 @@ public class SquidWTFMetadataService : IMusicMetadataService
private readonly SubsonicSettings _settings; private readonly SubsonicSettings _settings;
private readonly ILogger<SquidWTFMetadataService> _logger; private readonly ILogger<SquidWTFMetadataService> _logger;
private readonly RedisCacheService _cache; private readonly RedisCacheService _cache;
private const string BaseUrl = "https://triton.squid.wtf"; private readonly List<string> _apiUrls;
private int _currentUrlIndex = 0;
public SquidWTFMetadataService( public SquidWTFMetadataService(
IHttpClientFactory httpClientFactory, IHttpClientFactory httpClientFactory,
IOptions<SubsonicSettings> settings, IOptions<SubsonicSettings> settings,
IOptions<SquidWTFSettings> squidwtfSettings, IOptions<SquidWTFSettings> squidwtfSettings,
ILogger<SquidWTFMetadataService> logger, ILogger<SquidWTFMetadataService> logger,
RedisCacheService cache) RedisCacheService cache,
List<string> apiUrls)
{ {
_httpClient = httpClientFactory.CreateClient(); _httpClient = httpClientFactory.CreateClient();
_settings = settings.Value; _settings = settings.Value;
_logger = logger; _logger = logger;
_cache = cache; _cache = cache;
_apiUrls = apiUrls;
// Set up default headers // Set up default headers
_httpClient.DefaultRequestHeaders.Add("User-Agent", _httpClient.DefaultRequestHeaders.Add("User-Agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:83.0) Gecko/20100101 Firefox/83.0"); "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:83.0) Gecko/20100101 Firefox/83.0");
} }
private string GetCurrentBaseUrl() => _apiUrls[_currentUrlIndex];
private async Task<T> TryWithFallbackAsync<T>(Func<string, Task<T>> action, T defaultValue)
{
for (int attempt = 0; attempt < _apiUrls.Count; attempt++)
{
try
{
var baseUrl = _apiUrls[_currentUrlIndex];
return await action(baseUrl);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Request failed with endpoint {Endpoint}, trying next...", _apiUrls[_currentUrlIndex]);
_currentUrlIndex = (_currentUrlIndex + 1) % _apiUrls.Count;
if (attempt == _apiUrls.Count - 1)
{
_logger.LogError("All SquidWTF endpoints failed");
return defaultValue;
}
}
}
return defaultValue;
}
public async Task<List<Song>> SearchSongsAsync(string query, int limit = 20) public async Task<List<Song>> SearchSongsAsync(string query, int limit = 20)
{ {
try return await TryWithFallbackAsync(async (baseUrl) =>
{ {
var url = $"{BaseUrl}/search/?s={Uri.EscapeDataString(query)}"; var url = $"{baseUrl}/search/?s={Uri.EscapeDataString(query)}";
var response = await _httpClient.GetAsync(url); var response = await _httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
@@ -70,19 +99,14 @@ public class SquidWTFMetadataService : IMusicMetadataService
} }
} }
return songs; return songs;
} }, new List<Song>());
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to search songs for query: {Query}", query);
return new List<Song>();
}
} }
public async Task<List<Album>> SearchAlbumsAsync(string query, int limit = 20) public async Task<List<Album>> SearchAlbumsAsync(string query, int limit = 20)
{ {
try return await TryWithFallbackAsync(async (baseUrl) =>
{ {
var url = $"{BaseUrl}/search/?al={Uri.EscapeDataString(query)}"; var url = $"{baseUrl}/search/?al={Uri.EscapeDataString(query)}";
var response = await _httpClient.GetAsync(url); var response = await _httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
@@ -109,19 +133,14 @@ public class SquidWTFMetadataService : IMusicMetadataService
} }
return albums; return albums;
} }, new List<Album>());
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to search albums for query: {Query}", query);
return new List<Album>();
}
} }
public async Task<List<Artist>> SearchArtistsAsync(string query, int limit = 20) public async Task<List<Artist>> SearchArtistsAsync(string query, int limit = 20)
{ {
try return await TryWithFallbackAsync(async (baseUrl) =>
{ {
var url = $"{BaseUrl}/search/?a={Uri.EscapeDataString(query)}"; var url = $"{baseUrl}/search/?a={Uri.EscapeDataString(query)}";
var response = await _httpClient.GetAsync(url); var response = await _httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
@@ -148,19 +167,14 @@ public class SquidWTFMetadataService : IMusicMetadataService
} }
return artists; return artists;
} }, new List<Artist>());
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to search artists for query: {Query}", query);
return new List<Artist>();
}
} }
public async Task<List<ExternalPlaylist>> SearchPlaylistsAsync(string query, int limit = 20) public async Task<List<ExternalPlaylist>> SearchPlaylistsAsync(string query, int limit = 20)
{ {
try return await TryWithFallbackAsync(async (baseUrl) =>
{ {
var url = $"{BaseUrl}/search/?p={Uri.EscapeDataString(query)}"; var url = $"{baseUrl}/search/?p={Uri.EscapeDataString(query)}";
var response = await _httpClient.GetAsync(url); var response = await _httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode) return new List<ExternalPlaylist>(); if (!response.IsSuccessStatusCode) return new List<ExternalPlaylist>();
@@ -178,13 +192,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
} }
} }
return playlists; return playlists;
} }, new List<ExternalPlaylist>());
catch
{
return new List<ExternalPlaylist>();
}
} }
public async Task<SearchResult> SearchAllAsync(string query, int songLimit = 20, int albumLimit = 20, int artistLimit = 20) public async Task<SearchResult> SearchAllAsync(string query, int songLimit = 20, int albumLimit = 20, int artistLimit = 20)
@@ -210,10 +218,9 @@ public class SquidWTFMetadataService : IMusicMetadataService
{ {
if (externalProvider != "squidwtf") return null; if (externalProvider != "squidwtf") return null;
try return await TryWithFallbackAsync(async (baseUrl) =>
{ {
// Use the /info endpoint for full track metadata var url = $"{baseUrl}/info/?id={externalId}";
var url = $"{BaseUrl}/info/?id={externalId}";
var response = await _httpClient.GetAsync(url); var response = await _httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode) return null; if (!response.IsSuccessStatusCode) return null;
@@ -225,12 +232,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
return null; return null;
return ParseTidalTrackFull(track); return ParseTidalTrackFull(track);
} }, (Song?)null);
catch (Exception ex)
{
_logger.LogWarning(ex, "GetSongAsync Exception");
return null;
}
} }
public async Task<Album?> GetAlbumAsync(string externalProvider, string externalId) public async Task<Album?> GetAlbumAsync(string externalProvider, string externalId)
@@ -242,10 +244,9 @@ public class SquidWTFMetadataService : IMusicMetadataService
var cached = await _cache.GetAsync<Album>(cacheKey); var cached = await _cache.GetAsync<Album>(cacheKey);
if (cached != null) return cached; if (cached != null) return cached;
try return await TryWithFallbackAsync(async (baseUrl) =>
{ {
// Use the /info endpoint for full track metadata var url = $"{baseUrl}/album/?id={externalId}";
var url = $"{BaseUrl}/album/?id={externalId}";
var response = await _httpClient.GetAsync(url); var response = await _httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode) return null; if (!response.IsSuccessStatusCode) return null;
@@ -279,12 +280,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
await _cache.SetAsync(cacheKey, album, TimeSpan.FromHours(24)); await _cache.SetAsync(cacheKey, album, TimeSpan.FromHours(24));
return album; return album;
} }, (Album?)null);
catch (Exception ex)
{
_logger.LogWarning(ex, "GetAlbumAsync Exception");
return null;
}
} }
public async Task<Artist?> GetArtistAsync(string externalProvider, string externalId) public async Task<Artist?> GetArtistAsync(string externalProvider, string externalId)
@@ -302,10 +298,9 @@ public class SquidWTFMetadataService : IMusicMetadataService
return cached; return cached;
} }
try return await TryWithFallbackAsync(async (baseUrl) =>
{ {
// Use the /info endpoint for full track metadata var url = $"{baseUrl}/artist/?f={externalId}";
var url = $"{BaseUrl}/artist/?f={externalId}";
_logger.LogInformation("Fetching artist from {Url}", url); _logger.LogInformation("Fetching artist from {Url}", url);
var response = await _httpClient.GetAsync(url); var response = await _httpClient.GetAsync(url);
@@ -366,25 +361,18 @@ public class SquidWTFMetadataService : IMusicMetadataService
await _cache.SetAsync(cacheKey, artist, TimeSpan.FromHours(24)); await _cache.SetAsync(cacheKey, artist, TimeSpan.FromHours(24));
return artist; return artist;
}, (Artist?)null);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "GetArtistAsync Exception.");
return null;
}
} }
public async Task<List<Album>> GetArtistAlbumsAsync(string externalProvider, string externalId) public async Task<List<Album>> GetArtistAlbumsAsync(string externalProvider, string externalId)
{ {
if (externalProvider != "squidwtf") return new List<Album>();
try
return await TryWithFallbackAsync(async (baseUrl) =>
{ {
if (externalProvider != "squidwtf") return new List<Album>();
_logger.LogInformation("GetArtistAlbumsAsync called for SquidWTF artist {ExternalId}", externalId); _logger.LogInformation("GetArtistAlbumsAsync called for SquidWTF artist {ExternalId}", externalId);
var url = $"{BaseUrl}/artist/?f={externalId}"; var url = $"{baseUrl}/artist/?f={externalId}";
_logger.LogInformation("Fetching artist albums from URL: {Url}", url); _logger.LogInformation("Fetching artist albums from URL: {Url}", url);
var response = await _httpClient.GetAsync(url); var response = await _httpClient.GetAsync(url);
@@ -418,21 +406,16 @@ public class SquidWTFMetadataService : IMusicMetadataService
} }
return albums; return albums;
} }, new List<Album>());
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get SquidWTF artist albums for {ExternalId}", externalId);
return new List<Album>();
}
} }
public async Task<ExternalPlaylist?> GetPlaylistAsync(string externalProvider, string externalId) public async Task<ExternalPlaylist?> GetPlaylistAsync(string externalProvider, string externalId)
{ {
if (externalProvider != "squidwtf") return null; if (externalProvider != "squidwtf") return null;
try return await TryWithFallbackAsync(async (baseUrl) =>
{ {
var url = $"{BaseUrl}/playlist/?id={externalId}"; var url = $"{baseUrl}/playlist/?id={externalId}";
var response = await _httpClient.GetAsync(url); var response = await _httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode) return null; if (!response.IsSuccessStatusCode) return null;
@@ -442,21 +425,16 @@ public class SquidWTFMetadataService : IMusicMetadataService
if (playlistElement.TryGetProperty("error", out _)) return null; if (playlistElement.TryGetProperty("error", out _)) return null;
return ParseTidalPlaylist(playlistElement); return ParseTidalPlaylist(playlistElement);
} }, (ExternalPlaylist?)null);
catch
{
return null;
}
} }
public async Task<List<Song>> GetPlaylistTracksAsync(string externalProvider, string externalId) public async Task<List<Song>> GetPlaylistTracksAsync(string externalProvider, string externalId)
{ {
if (externalProvider != "squidwtf") return new List<Song>(); if (externalProvider != "squidwtf") return new List<Song>();
try return await TryWithFallbackAsync(async (baseUrl) =>
{ {
var url = $"{BaseUrl}/playlist/?id={externalId}"; var url = $"{baseUrl}/playlist/?id={externalId}";
var response = await _httpClient.GetAsync(url); var response = await _httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode) return new List<Song>(); if (!response.IsSuccessStatusCode) return new List<Song>();
@@ -507,12 +485,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
} }
} }
return songs; return songs;
} }, new List<Song>());
catch
{
return new List<Song>();
}
} }
// --- Parser functions start here --- // --- Parser functions start here ---

View File

@@ -12,13 +12,39 @@ namespace allstarr.Services.SquidWTF;
public class SquidWTFStartupValidator : BaseStartupValidator public class SquidWTFStartupValidator : BaseStartupValidator
{ {
private readonly SquidWTFSettings _settings; private readonly SquidWTFSettings _settings;
private readonly List<string> _apiUrls;
private int _currentUrlIndex = 0;
public override string ServiceName => "SquidWTF"; public override string ServiceName => "SquidWTF";
public SquidWTFStartupValidator(IOptions<SquidWTFSettings> settings, HttpClient httpClient) public SquidWTFStartupValidator(IOptions<SquidWTFSettings> settings, HttpClient httpClient, List<string> apiUrls)
: base(httpClient) : base(httpClient)
{ {
_settings = settings.Value; _settings = settings.Value;
_apiUrls = apiUrls;
}
private async Task<T> TryWithFallbackAsync<T>(Func<string, Task<T>> action, T defaultValue)
{
for (int attempt = 0; attempt < _apiUrls.Count; attempt++)
{
try
{
var baseUrl = _apiUrls[_currentUrlIndex];
return await action(baseUrl);
}
catch
{
WriteDetail($"Endpoint {_apiUrls[_currentUrlIndex]} failed, trying next...");
_currentUrlIndex = (_currentUrlIndex + 1) % _apiUrls.Count;
if (attempt == _apiUrls.Count - 1)
{
return defaultValue;
}
}
}
return defaultValue;
} }
public override async Task<ValidationResult> ValidateAsync(CancellationToken cancellationToken) public override async Task<ValidationResult> ValidateAsync(CancellationToken cancellationToken)
@@ -37,54 +63,36 @@ public class SquidWTFStartupValidator : BaseStartupValidator
WriteStatus("SquidWTF Quality", quality, ConsoleColor.Cyan); WriteStatus("SquidWTF Quality", quality, ConsoleColor.Cyan);
// Test connectivity to triton.squid.wtf // Test connectivity with fallback
try var result = await TryWithFallbackAsync(async (baseUrl) =>
{ {
var response = await _httpClient.GetAsync("https://triton.squid.wtf/", cancellationToken); var response = await _httpClient.GetAsync(baseUrl, cancellationToken);
if (response.IsSuccessStatusCode) if (response.IsSuccessStatusCode)
{ {
WriteStatus("SquidWTF API", "REACHABLE", ConsoleColor.Green); WriteStatus("SquidWTF API", $"REACHABLE ({baseUrl})", ConsoleColor.Green);
WriteDetail("No authentication required - powered by Tidal"); WriteDetail("No authentication required - powered by Tidal");
// Try a test search to verify functionality // Try a test search to verify functionality
await ValidateSearchFunctionality(cancellationToken); await ValidateSearchFunctionality(baseUrl, cancellationToken);
return ValidationResult.Success("SquidWTF validation completed"); return ValidationResult.Success("SquidWTF validation completed");
} }
else else
{ {
WriteStatus("SquidWTF API", $"HTTP {(int)response.StatusCode}", ConsoleColor.Yellow); throw new HttpRequestException($"HTTP {(int)response.StatusCode}");
WriteDetail("Service may be temporarily unavailable");
return ValidationResult.Failure($"{response.StatusCode}", "SquidWTF returned code");
} }
} }, ValidationResult.Failure("-1", "All SquidWTF endpoints failed"));
catch (TaskCanceledException)
{ return result;
WriteStatus("SquidWTF API", "TIMEOUT", ConsoleColor.Yellow);
WriteDetail("Could not reach service within timeout period");
return ValidationResult.Failure("-1", "SquidWTF connection timeout");
}
catch (HttpRequestException ex)
{
WriteStatus("SquidWTF API", "UNREACHABLE", ConsoleColor.Red);
WriteDetail(ex.Message);
return ValidationResult.Failure("-1", $"Cannot connect to SquidWTF: {ex.Message}");
}
catch (Exception ex)
{
WriteStatus("SquidWTF API", "ERROR", ConsoleColor.Red);
WriteDetail(ex.Message);
return ValidationResult.Failure("-1", $"Validation error: {ex.Message}");
}
} }
private async Task ValidateSearchFunctionality(CancellationToken cancellationToken) private async Task ValidateSearchFunctionality(string baseUrl, CancellationToken cancellationToken)
{ {
try try
{ {
// Test search with a simple query // Test search with a simple query
var searchUrl = "https://triton.squid.wtf/search/?s=Taylor%20Swift"; var searchUrl = $"{baseUrl}/search/?s=Taylor%20Swift";
var searchResponse = await _httpClient.GetAsync(searchUrl, cancellationToken); var searchResponse = await _httpClient.GetAsync(searchUrl, cancellationToken);
if (searchResponse.IsSuccessStatusCode) if (searchResponse.IsSuccessStatusCode)