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
This commit is contained in:
2026-02-07 12:19:41 -05:00
parent 7e6bed51e1
commit e3bcc93597
5 changed files with 186 additions and 159 deletions

View File

@@ -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<JellyfinController> _logger;
@@ -55,6 +56,7 @@ public class JellyfinController : ControllerBase
JellyfinModelMapper modelMapper,
JellyfinProxyService proxyService,
JellyfinSessionManager sessionManager,
OdesliService odesliService,
RedisCacheService cache,
IConfiguration configuration,
ILogger<JellyfinController> 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;
}
}
/// <summary>
/// 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.
/// </summary>
private async Task<string?> 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

View File

@@ -379,6 +379,7 @@ else
// Business services - shared across backends
builder.Services.AddSingleton<RedisCacheService>();
builder.Services.AddSingleton<OdesliService>();
builder.Services.AddSingleton<ILocalLibraryService, LocalLibraryService>();
builder.Services.AddSingleton<LrclibService>();
@@ -459,6 +460,7 @@ else if (musicService == MusicService.SquidWTF)
sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<SquidWTFSettings>>(),
sp,
sp.GetRequiredService<ILogger<SquidWTFDownloadService>>(),
sp.GetRequiredService<OdesliService>(),
squidWtfApiUrls));
}

View File

@@ -0,0 +1,143 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
namespace allstarr.Services.Common;
/// <summary>
/// Service for converting music URLs between platforms using Odesli/song.link API
/// </summary>
public class OdesliService
{
private readonly HttpClient _httpClient;
private readonly ILogger<OdesliService> _logger;
private readonly RedisCacheService _cache;
public OdesliService(
IHttpClientFactory httpClientFactory,
ILogger<OdesliService> logger,
RedisCacheService cache)
{
_httpClient = httpClientFactory.CreateClient();
_logger = logger;
_cache = cache;
}
/// <summary>
/// Converts a Tidal track ID to a Spotify track ID using Odesli
/// Results are cached for 7 days
/// </summary>
public async Task<string?> 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<string>(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;
}
/// <summary>
/// Converts any music URL to a Spotify track ID using Odesli
/// Results are cached for 7 days
/// </summary>
public async Task<string?> ConvertUrlToSpotifyIdAsync(string musicUrl, CancellationToken cancellationToken = default)
{
// Check cache first
var cacheKey = $"odesli:url-to-spotify:{musicUrl}";
var cached = await _cache.GetAsync<string>(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;
}
}

View File

@@ -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> SquidWTFSettings,
IServiceProvider serviceProvider,
ILogger<SquidWTFDownloadService> logger,
OdesliService odesliService,
List<string> 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);
@@ -244,6 +257,7 @@ public class SquidWTFDownloadService : BaseDownloadService
});
}
#endregion
#region Utility Methods

View File

@@ -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);