mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-09 23:55:10 -05:00
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:
@@ -40,6 +40,7 @@ public class JellyfinController : ControllerBase
|
|||||||
private readonly SpotifyPlaylistFetcher? _spotifyPlaylistFetcher;
|
private readonly SpotifyPlaylistFetcher? _spotifyPlaylistFetcher;
|
||||||
private readonly SpotifyLyricsService? _spotifyLyricsService;
|
private readonly SpotifyLyricsService? _spotifyLyricsService;
|
||||||
private readonly LrclibService? _lrclibService;
|
private readonly LrclibService? _lrclibService;
|
||||||
|
private readonly OdesliService _odesliService;
|
||||||
private readonly RedisCacheService _cache;
|
private readonly RedisCacheService _cache;
|
||||||
private readonly IConfiguration _configuration;
|
private readonly IConfiguration _configuration;
|
||||||
private readonly ILogger<JellyfinController> _logger;
|
private readonly ILogger<JellyfinController> _logger;
|
||||||
@@ -55,6 +56,7 @@ public class JellyfinController : ControllerBase
|
|||||||
JellyfinModelMapper modelMapper,
|
JellyfinModelMapper modelMapper,
|
||||||
JellyfinProxyService proxyService,
|
JellyfinProxyService proxyService,
|
||||||
JellyfinSessionManager sessionManager,
|
JellyfinSessionManager sessionManager,
|
||||||
|
OdesliService odesliService,
|
||||||
RedisCacheService cache,
|
RedisCacheService cache,
|
||||||
IConfiguration configuration,
|
IConfiguration configuration,
|
||||||
ILogger<JellyfinController> logger,
|
ILogger<JellyfinController> logger,
|
||||||
@@ -79,6 +81,7 @@ public class JellyfinController : ControllerBase
|
|||||||
_spotifyPlaylistFetcher = spotifyPlaylistFetcher;
|
_spotifyPlaylistFetcher = spotifyPlaylistFetcher;
|
||||||
_spotifyLyricsService = spotifyLyricsService;
|
_spotifyLyricsService = spotifyLyricsService;
|
||||||
_lrclibService = lrclibService;
|
_lrclibService = lrclibService;
|
||||||
|
_odesliService = odesliService;
|
||||||
_cache = cache;
|
_cache = cache;
|
||||||
_configuration = configuration;
|
_configuration = configuration;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
@@ -1184,7 +1187,26 @@ public class JellyfinController : ControllerBase
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Last resort: Try to convert via Odesli/song.link
|
// 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))
|
if (!string.IsNullOrEmpty(spotifyTrackId))
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Converted {Provider}/{ExternalId} to Spotify ID {SpotifyId} via Odesli",
|
_logger.LogInformation("Converted {Provider}/{ExternalId} to Spotify ID {SpotifyId} via Odesli",
|
||||||
@@ -1409,9 +1431,9 @@ public class JellyfinController : ControllerBase
|
|||||||
spotifyTrackId = await FindSpotifyIdForExternalTrackAsync(song);
|
spotifyTrackId = await FindSpotifyIdForExternalTrackAsync(song);
|
||||||
|
|
||||||
// If no cached Spotify ID, try Odesli conversion
|
// 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;
|
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
|
// force rebuild Sun Jan 25 13:22:47 EST 2026
|
||||||
|
|||||||
@@ -379,6 +379,7 @@ else
|
|||||||
|
|
||||||
// Business services - shared across backends
|
// Business services - shared across backends
|
||||||
builder.Services.AddSingleton<RedisCacheService>();
|
builder.Services.AddSingleton<RedisCacheService>();
|
||||||
|
builder.Services.AddSingleton<OdesliService>();
|
||||||
builder.Services.AddSingleton<ILocalLibraryService, LocalLibraryService>();
|
builder.Services.AddSingleton<ILocalLibraryService, LocalLibraryService>();
|
||||||
builder.Services.AddSingleton<LrclibService>();
|
builder.Services.AddSingleton<LrclibService>();
|
||||||
|
|
||||||
@@ -459,6 +460,7 @@ else if (musicService == MusicService.SquidWTF)
|
|||||||
sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<SquidWTFSettings>>(),
|
sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<SquidWTFSettings>>(),
|
||||||
sp,
|
sp,
|
||||||
sp.GetRequiredService<ILogger<SquidWTFDownloadService>>(),
|
sp.GetRequiredService<ILogger<SquidWTFDownloadService>>(),
|
||||||
|
sp.GetRequiredService<OdesliService>(),
|
||||||
squidWtfApiUrls));
|
squidWtfApiUrls));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
143
allstarr/Services/Common/OdesliService.cs
Normal file
143
allstarr/Services/Common/OdesliService.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ public class SquidWTFDownloadService : BaseDownloadService
|
|||||||
private readonly HttpClient _httpClient;
|
private readonly HttpClient _httpClient;
|
||||||
private readonly SemaphoreSlim _requestLock = new(1, 1);
|
private readonly SemaphoreSlim _requestLock = new(1, 1);
|
||||||
private readonly SquidWTFSettings _squidwtfSettings;
|
private readonly SquidWTFSettings _squidwtfSettings;
|
||||||
|
private readonly OdesliService _odesliService;
|
||||||
|
|
||||||
private DateTime _lastRequestTime = DateTime.MinValue;
|
private DateTime _lastRequestTime = DateTime.MinValue;
|
||||||
private readonly int _minRequestIntervalMs = 200;
|
private readonly int _minRequestIntervalMs = 200;
|
||||||
@@ -41,11 +42,13 @@ public class SquidWTFDownloadService : BaseDownloadService
|
|||||||
IOptions<SquidWTFSettings> SquidWTFSettings,
|
IOptions<SquidWTFSettings> SquidWTFSettings,
|
||||||
IServiceProvider serviceProvider,
|
IServiceProvider serviceProvider,
|
||||||
ILogger<SquidWTFDownloadService> logger,
|
ILogger<SquidWTFDownloadService> logger,
|
||||||
|
OdesliService odesliService,
|
||||||
List<string> apiUrls)
|
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;
|
||||||
|
_odesliService = odesliService;
|
||||||
_apiUrls = apiUrls;
|
_apiUrls = apiUrls;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,6 +122,9 @@ public class SquidWTFDownloadService : BaseDownloadService
|
|||||||
Logger.LogInformation("Track token obtained: {Url}", downloadInfo.DownloadUrl);
|
Logger.LogInformation("Track token obtained: {Url}", downloadInfo.DownloadUrl);
|
||||||
Logger.LogInformation("Using format: {Format}", downloadInfo.MimeType);
|
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
|
// Determine extension from MIME type
|
||||||
var extension = downloadInfo.MimeType?.ToLower() switch
|
var extension = downloadInfo.MimeType?.ToLower() switch
|
||||||
{
|
{
|
||||||
@@ -164,6 +170,13 @@ public class SquidWTFDownloadService : BaseDownloadService
|
|||||||
// Close file before writing metadata
|
// Close file before writing metadata
|
||||||
await outputFile.DisposeAsync();
|
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
|
// Write metadata and cover art
|
||||||
await WriteMetadataAsync(outputPath, song, cancellationToken);
|
await WriteMetadataAsync(outputPath, song, cancellationToken);
|
||||||
|
|
||||||
@@ -244,6 +257,7 @@ public class SquidWTFDownloadService : BaseDownloadService
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Utility Methods
|
#region Utility Methods
|
||||||
|
|||||||
@@ -282,46 +282,8 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
|
|
||||||
var song = ParseTidalTrackFull(track);
|
var song = ParseTidalTrackFull(track);
|
||||||
|
|
||||||
// Convert to Spotify ID via Odesli for lyrics support
|
// NOTE: Spotify ID conversion happens during download (in SquidWTFDownloadService)
|
||||||
if (song != null && !string.IsNullOrEmpty(externalId))
|
// This avoids redundant conversions and ensures it's done in parallel with the download
|
||||||
{
|
|
||||||
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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return song;
|
return song;
|
||||||
}, (Song?)null);
|
}, (Song?)null);
|
||||||
|
|||||||
Reference in New Issue
Block a user