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