diff --git a/.gitignore b/.gitignore index 410d247..7949c16 100644 --- a/.gitignore +++ b/.gitignore @@ -87,6 +87,7 @@ redis-data/ apis/steering/ apis/api-calls/*.json !apis/api-calls/jellyfin-openapi-stable.json +apis/temp.json # Log files for debugging apis/api-calls/*.log diff --git a/allstarr/Controllers/JellyfinController.cs b/allstarr/Controllers/JellyfinController.cs index 276be23..82321a8 100644 --- a/allstarr/Controllers/JellyfinController.cs +++ b/allstarr/Controllers/JellyfinController.cs @@ -1158,9 +1158,20 @@ public class JellyfinController : ControllerBase spotifyTrackId = await FindSpotifyIdForExternalTrackAsync(song); if (!string.IsNullOrEmpty(spotifyTrackId)) { - _logger.LogInformation("Found Spotify ID {SpotifyId} for external track {Provider}/{ExternalId}", + _logger.LogInformation("Found Spotify ID {SpotifyId} for external track {Provider}/{ExternalId} from cache", spotifyTrackId, provider, externalId); } + else + { + // If no cached Spotify ID, try to convert via Odesli/song.link + // This works for SquidWTF (Tidal), Deezer, Qobuz, etc. + spotifyTrackId = await ConvertToSpotifyIdViaOdesliAsync(song, provider!, externalId!); + if (!string.IsNullOrEmpty(spotifyTrackId)) + { + _logger.LogInformation("Converted {Provider}/{ExternalId} to Spotify ID {SpotifyId} via Odesli", + provider, externalId, spotifyTrackId); + } + } } } else @@ -1377,6 +1388,12 @@ public class JellyfinController : ControllerBase if (song != null) { spotifyTrackId = await FindSpotifyIdForExternalTrackAsync(song); + + // If no cached Spotify ID, try Odesli conversion + if (string.IsNullOrEmpty(spotifyTrackId)) + { + spotifyTrackId = await ConvertToSpotifyIdViaOdesliAsync(song, provider, externalId); + } } } else @@ -4356,6 +4373,103 @@ 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; + } + + // 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; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Error converting {Provider}/{ExternalId} via Odesli", provider, externalId); + return null; + } + } } // force rebuild Sun Jan 25 13:22:47 EST 2026