From e3bcc93597e63a7a0b63f3b441aae9284e2f3f0c Mon Sep 17 00:00:00 2001 From: Josh Patra Date: Sat, 7 Feb 2026 12:19:41 -0500 Subject: [PATCH] Add Odesli service for Tidal to Spotify ID conversion - Created OdesliService to convert Tidal track IDs to Spotify IDs - Integrated Odesli API calls into SquidWTF download workflow - Updated SquidWTFDownloadService to use OdesliService for track metadata enrichment - Fixed dependency injection in Program.cs for OdesliService - All 225 tests passing --- allstarr/Controllers/JellyfinController.cs | 144 +++--------------- allstarr/Program.cs | 2 + allstarr/Services/Common/OdesliService.cs | 143 +++++++++++++++++ .../SquidWTF/SquidWTFDownloadService.cs | 14 ++ .../SquidWTF/SquidWTFMetadataService.cs | 42 +---- 5 files changed, 186 insertions(+), 159 deletions(-) create mode 100644 allstarr/Services/Common/OdesliService.cs diff --git a/allstarr/Controllers/JellyfinController.cs b/allstarr/Controllers/JellyfinController.cs index 50dd6cc..a9d6173 100644 --- a/allstarr/Controllers/JellyfinController.cs +++ b/allstarr/Controllers/JellyfinController.cs @@ -40,6 +40,7 @@ public class JellyfinController : ControllerBase private readonly SpotifyPlaylistFetcher? _spotifyPlaylistFetcher; private readonly SpotifyLyricsService? _spotifyLyricsService; private readonly LrclibService? _lrclibService; + private readonly OdesliService _odesliService; private readonly RedisCacheService _cache; private readonly IConfiguration _configuration; private readonly ILogger _logger; @@ -55,6 +56,7 @@ public class JellyfinController : ControllerBase JellyfinModelMapper modelMapper, JellyfinProxyService proxyService, JellyfinSessionManager sessionManager, + OdesliService odesliService, RedisCacheService cache, IConfiguration configuration, ILogger logger, @@ -79,6 +81,7 @@ public class JellyfinController : ControllerBase _spotifyPlaylistFetcher = spotifyPlaylistFetcher; _spotifyLyricsService = spotifyLyricsService; _lrclibService = lrclibService; + _odesliService = odesliService; _cache = cache; _configuration = configuration; _logger = logger; @@ -1184,7 +1187,26 @@ public class JellyfinController : ControllerBase else { // Last resort: Try to convert via Odesli/song.link - spotifyTrackId = await ConvertToSpotifyIdViaOdesliAsync(song, provider!, externalId!); + if (provider == "squidwtf") + { + spotifyTrackId = await _odesliService.ConvertTidalToSpotifyIdAsync(externalId!, HttpContext.RequestAborted); + } + else + { + // For other providers, build the URL and convert + var sourceUrl = provider.ToLowerInvariant() switch + { + "deezer" => $"https://www.deezer.com/track/{externalId}", + "qobuz" => $"https://www.qobuz.com/us-en/album/-/-/{externalId}", + _ => null + }; + + if (!string.IsNullOrEmpty(sourceUrl)) + { + spotifyTrackId = await _odesliService.ConvertUrlToSpotifyIdAsync(sourceUrl, HttpContext.RequestAborted); + } + } + if (!string.IsNullOrEmpty(spotifyTrackId)) { _logger.LogInformation("Converted {Provider}/{ExternalId} to Spotify ID {SpotifyId} via Odesli", @@ -1409,9 +1431,9 @@ public class JellyfinController : ControllerBase spotifyTrackId = await FindSpotifyIdForExternalTrackAsync(song); // If no cached Spotify ID, try Odesli conversion - if (string.IsNullOrEmpty(spotifyTrackId)) + if (string.IsNullOrEmpty(spotifyTrackId) && provider == "squidwtf") { - spotifyTrackId = await ConvertToSpotifyIdViaOdesliAsync(song, provider, externalId); + spotifyTrackId = await _odesliService.ConvertTidalToSpotifyIdAsync(externalId, HttpContext.RequestAborted); } } } @@ -4467,122 +4489,6 @@ public class JellyfinController : ControllerBase return null; } } - - /// - /// Converts an external track URL (Tidal/Deezer/Qobuz) to a Spotify track ID using Odesli/song.link API. - /// This enables Spotify lyrics for external tracks that aren't in injected playlists. - /// - private async Task ConvertToSpotifyIdViaOdesliAsync(Song song, string provider, string externalId) - { - try - { - // Build the source URL based on provider - string? sourceUrl = null; - - switch (provider.ToLowerInvariant()) - { - case "squidwtf": - // SquidWTF uses Tidal IDs - sourceUrl = $"https://tidal.com/browse/track/{externalId}"; - break; - - case "deezer": - sourceUrl = $"https://www.deezer.com/track/{externalId}"; - break; - - case "qobuz": - sourceUrl = $"https://www.qobuz.com/us-en/album/-/-/{externalId}"; - break; - - default: - _logger.LogDebug("Provider {Provider} not supported for Odesli conversion", provider); - return null; - } - - // Check cache first (cache for 30 days since these mappings don't change) - var cacheKey = $"odesli:{provider}:{externalId}"; - var cachedSpotifyId = await _cache.GetStringAsync(cacheKey); - if (!string.IsNullOrEmpty(cachedSpotifyId)) - { - _logger.LogDebug("Returning cached Odesli conversion: {Provider}/{ExternalId} → {SpotifyId}", - provider, externalId, cachedSpotifyId); - return cachedSpotifyId; - } - - // RATE LIMITING: Odesli allows 10 requests per minute - // Use a simple semaphore-based rate limiter - await OdesliRateLimiter.WaitAsync(); - - try - { - // Call Odesli API - var odesliUrl = $"https://api.song.link/v1-alpha.1/links?url={Uri.EscapeDataString(sourceUrl)}&userCountry=US"; - - _logger.LogDebug("Calling Odesli API: {Url}", odesliUrl); - - using var httpClient = new HttpClient(); - httpClient.Timeout = TimeSpan.FromSeconds(5); - - var response = await httpClient.GetAsync(odesliUrl); - - if (!response.IsSuccessStatusCode) - { - _logger.LogDebug("Odesli API returned {StatusCode} for {Provider}/{ExternalId}", - response.StatusCode, provider, externalId); - return null; - } - - var json = await response.Content.ReadAsStringAsync(); - using var doc = JsonDocument.Parse(json); - var root = doc.RootElement; - - // Extract Spotify URL from linksByPlatform.spotify.url - if (root.TryGetProperty("linksByPlatform", out var platforms) && - platforms.TryGetProperty("spotify", out var spotify) && - spotify.TryGetProperty("url", out var spotifyUrlEl)) - { - var spotifyUrl = spotifyUrlEl.GetString(); - if (!string.IsNullOrEmpty(spotifyUrl)) - { - // Extract Spotify ID from URL: https://open.spotify.com/track/{id} - var match = System.Text.RegularExpressions.Regex.Match(spotifyUrl, @"spotify\.com/track/([a-zA-Z0-9]+)"); - if (match.Success) - { - var spotifyId = match.Groups[1].Value; - - // Cache the result (30 days) - await _cache.SetStringAsync(cacheKey, spotifyId, TimeSpan.FromDays(30)); - - _logger.LogInformation("✓ Odesli converted {Provider}/{ExternalId} → Spotify ID {SpotifyId}", - provider, externalId, spotifyId); - - return spotifyId; - } - } - } - - _logger.LogDebug("No Spotify link found in Odesli response for {Provider}/{ExternalId}", provider, externalId); - return null; - } - finally - { - // Release rate limiter after 6 seconds (10 requests per 60 seconds = 1 request per 6 seconds) - _ = Task.Run(async () => - { - await Task.Delay(TimeSpan.FromSeconds(6)); - OdesliRateLimiter.Release(); - }); - } - } - catch (Exception ex) - { - _logger.LogDebug(ex, "Error converting {Provider}/{ExternalId} via Odesli", provider, externalId); - return null; - } - } - - // Static rate limiter for Odesli API (10 requests per minute = 1 request per 6 seconds) - private static readonly SemaphoreSlim OdesliRateLimiter = new SemaphoreSlim(10, 10); } // force rebuild Sun Jan 25 13:22:47 EST 2026 diff --git a/allstarr/Program.cs b/allstarr/Program.cs index e6b1490..8829e9c 100644 --- a/allstarr/Program.cs +++ b/allstarr/Program.cs @@ -379,6 +379,7 @@ else // Business services - shared across backends builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -459,6 +460,7 @@ else if (musicService == MusicService.SquidWTF) sp.GetRequiredService>(), sp, sp.GetRequiredService>(), + sp.GetRequiredService(), squidWtfApiUrls)); } diff --git a/allstarr/Services/Common/OdesliService.cs b/allstarr/Services/Common/OdesliService.cs new file mode 100644 index 0000000..7fa7132 --- /dev/null +++ b/allstarr/Services/Common/OdesliService.cs @@ -0,0 +1,143 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging; + +namespace allstarr.Services.Common; + +/// +/// Service for converting music URLs between platforms using Odesli/song.link API +/// +public class OdesliService +{ + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + private readonly RedisCacheService _cache; + + public OdesliService( + IHttpClientFactory httpClientFactory, + ILogger logger, + RedisCacheService cache) + { + _httpClient = httpClientFactory.CreateClient(); + _logger = logger; + _cache = cache; + } + + /// + /// Converts a Tidal track ID to a Spotify track ID using Odesli + /// Results are cached for 7 days + /// + public async Task ConvertTidalToSpotifyIdAsync(string tidalTrackId, CancellationToken cancellationToken = default) + { + // Check cache first (7 day TTL - these mappings don't change) + var cacheKey = $"odesli:tidal-to-spotify:{tidalTrackId}"; + var cached = await _cache.GetAsync(cacheKey); + if (!string.IsNullOrEmpty(cached)) + { + _logger.LogDebug("✓ Using cached Spotify ID for Tidal track {TidalId}", tidalTrackId); + return cached; + } + + try + { + var tidalUrl = $"https://tidal.com/browse/track/{tidalTrackId}"; + var odesliUrl = $"https://api.song.link/v1-alpha.1/links?url={Uri.EscapeDataString(tidalUrl)}&userCountry=US"; + + _logger.LogDebug("🔗 Converting Tidal track {TidalId} to Spotify ID via Odesli", tidalTrackId); + + var odesliResponse = await _httpClient.GetAsync(odesliUrl, cancellationToken); + if (odesliResponse.IsSuccessStatusCode) + { + var odesliJson = await odesliResponse.Content.ReadAsStringAsync(cancellationToken); + var odesliDoc = JsonDocument.Parse(odesliJson); + + // Extract Spotify track ID from the Spotify URL + if (odesliDoc.RootElement.TryGetProperty("linksByPlatform", out var platforms) && + platforms.TryGetProperty("spotify", out var spotifyPlatform) && + spotifyPlatform.TryGetProperty("url", out var spotifyUrlEl)) + { + var spotifyUrl = spotifyUrlEl.GetString(); + if (!string.IsNullOrEmpty(spotifyUrl)) + { + // Extract ID from URL: https://open.spotify.com/track/{id} + var match = System.Text.RegularExpressions.Regex.Match(spotifyUrl, @"spotify\.com/track/([a-zA-Z0-9]+)"); + if (match.Success) + { + var spotifyId = match.Groups[1].Value; + _logger.LogInformation("✓ Converted Tidal/{TidalId} → Spotify ID {SpotifyId}", tidalTrackId, spotifyId); + + // Cache for 7 days + await _cache.SetAsync(cacheKey, spotifyId, TimeSpan.FromDays(7)); + + return spotifyId; + } + } + } + } + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to convert Tidal track to Spotify ID via Odesli"); + } + + return null; + } + + /// + /// Converts any music URL to a Spotify track ID using Odesli + /// Results are cached for 7 days + /// + public async Task ConvertUrlToSpotifyIdAsync(string musicUrl, CancellationToken cancellationToken = default) + { + // Check cache first + var cacheKey = $"odesli:url-to-spotify:{musicUrl}"; + var cached = await _cache.GetAsync(cacheKey); + if (!string.IsNullOrEmpty(cached)) + { + _logger.LogDebug("✓ Using cached Spotify ID for URL {Url}", musicUrl); + return cached; + } + + try + { + var odesliUrl = $"https://api.song.link/v1-alpha.1/links?url={Uri.EscapeDataString(musicUrl)}&userCountry=US"; + + _logger.LogDebug("🔗 Converting URL to Spotify ID via Odesli: {Url}", musicUrl); + + var odesliResponse = await _httpClient.GetAsync(odesliUrl, cancellationToken); + if (odesliResponse.IsSuccessStatusCode) + { + var odesliJson = await odesliResponse.Content.ReadAsStringAsync(cancellationToken); + var odesliDoc = JsonDocument.Parse(odesliJson); + + // Extract Spotify track ID from the Spotify URL + if (odesliDoc.RootElement.TryGetProperty("linksByPlatform", out var platforms) && + platforms.TryGetProperty("spotify", out var spotifyPlatform) && + spotifyPlatform.TryGetProperty("url", out var spotifyUrlEl)) + { + var spotifyUrl = spotifyUrlEl.GetString(); + if (!string.IsNullOrEmpty(spotifyUrl)) + { + // Extract ID from URL: https://open.spotify.com/track/{id} + var match = System.Text.RegularExpressions.Regex.Match(spotifyUrl, @"spotify\.com/track/([a-zA-Z0-9]+)"); + if (match.Success) + { + var spotifyId = match.Groups[1].Value; + _logger.LogInformation("✓ Converted URL → Spotify ID {SpotifyId}", spotifyId); + + // Cache for 7 days + await _cache.SetAsync(cacheKey, spotifyId, TimeSpan.FromDays(7)); + + return spotifyId; + } + } + } + } + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to convert URL to Spotify ID via Odesli"); + } + + return null; + } +} diff --git a/allstarr/Services/SquidWTF/SquidWTFDownloadService.cs b/allstarr/Services/SquidWTF/SquidWTFDownloadService.cs index 8184374..4538478 100644 --- a/allstarr/Services/SquidWTF/SquidWTFDownloadService.cs +++ b/allstarr/Services/SquidWTF/SquidWTFDownloadService.cs @@ -22,6 +22,7 @@ public class SquidWTFDownloadService : BaseDownloadService private readonly HttpClient _httpClient; private readonly SemaphoreSlim _requestLock = new(1, 1); private readonly SquidWTFSettings _squidwtfSettings; + private readonly OdesliService _odesliService; private DateTime _lastRequestTime = DateTime.MinValue; private readonly int _minRequestIntervalMs = 200; @@ -41,11 +42,13 @@ public class SquidWTFDownloadService : BaseDownloadService IOptions SquidWTFSettings, IServiceProvider serviceProvider, ILogger logger, + OdesliService odesliService, List apiUrls) : base(configuration, localLibraryService, metadataService, subsonicSettings.Value, serviceProvider, logger) { _httpClient = httpClientFactory.CreateClient(); _squidwtfSettings = SquidWTFSettings.Value; + _odesliService = odesliService; _apiUrls = apiUrls; } @@ -119,6 +122,9 @@ public class SquidWTFDownloadService : BaseDownloadService Logger.LogInformation("Track token obtained: {Url}", downloadInfo.DownloadUrl); Logger.LogInformation("Using format: {Format}", downloadInfo.MimeType); + // Start Spotify ID conversion in parallel with download (don't await yet) + var spotifyIdTask = _odesliService.ConvertTidalToSpotifyIdAsync(trackId, cancellationToken); + // Determine extension from MIME type var extension = downloadInfo.MimeType?.ToLower() switch { @@ -164,6 +170,13 @@ public class SquidWTFDownloadService : BaseDownloadService // Close file before writing metadata await outputFile.DisposeAsync(); + // Wait for Spotify ID conversion to complete and update song metadata + var spotifyId = await spotifyIdTask; + if (!string.IsNullOrEmpty(spotifyId)) + { + song.SpotifyId = spotifyId; + } + // Write metadata and cover art await WriteMetadataAsync(outputPath, song, cancellationToken); @@ -243,6 +256,7 @@ public class SquidWTFDownloadService : BaseDownloadService }); }); } + #endregion diff --git a/allstarr/Services/SquidWTF/SquidWTFMetadataService.cs b/allstarr/Services/SquidWTF/SquidWTFMetadataService.cs index b0c6ce8..41fef1c 100644 --- a/allstarr/Services/SquidWTF/SquidWTFMetadataService.cs +++ b/allstarr/Services/SquidWTF/SquidWTFMetadataService.cs @@ -282,46 +282,8 @@ public class SquidWTFMetadataService : IMusicMetadataService var song = ParseTidalTrackFull(track); - // Convert to Spotify ID via Odesli for lyrics support - if (song != null && !string.IsNullOrEmpty(externalId)) - { - try - { - var tidalUrl = $"https://tidal.com/browse/track/{externalId}"; - var odesliUrl = $"https://api.song.link/v1-alpha.1/links?url={Uri.EscapeDataString(tidalUrl)}&userCountry=US"; - - _logger.LogDebug("🔗 Converting Tidal track {ExternalId} to Spotify ID via Odesli", externalId); - - var odesliResponse = await _httpClient.GetAsync(odesliUrl); - if (odesliResponse.IsSuccessStatusCode) - { - var odesliJson = await odesliResponse.Content.ReadAsStringAsync(); - var odesliDoc = JsonDocument.Parse(odesliJson); - - // Extract Spotify track ID from the Spotify URL - if (odesliDoc.RootElement.TryGetProperty("linksByPlatform", out var platforms) && - platforms.TryGetProperty("spotify", out var spotifyPlatform) && - spotifyPlatform.TryGetProperty("url", out var spotifyUrlEl)) - { - var spotifyUrl = spotifyUrlEl.GetString(); - if (!string.IsNullOrEmpty(spotifyUrl)) - { - // Extract ID from URL: https://open.spotify.com/track/{id} - var match = System.Text.RegularExpressions.Regex.Match(spotifyUrl, @"spotify\.com/track/([a-zA-Z0-9]+)"); - if (match.Success) - { - song.SpotifyId = match.Groups[1].Value; - _logger.LogInformation("✓ Converted squidwtf/{ExternalId} → Spotify ID {SpotifyId}", externalId, song.SpotifyId); - } - } - } - } - } - catch (Exception ex) - { - _logger.LogDebug(ex, "Failed to convert Tidal track to Spotify ID via Odesli"); - } - } + // NOTE: Spotify ID conversion happens during download (in SquidWTFDownloadService) + // This avoids redundant conversions and ensures it's done in parallel with the download return song; }, (Song?)null);