From b366a4b7718f0453fa3dec8b64e6537ccfd08c48 Mon Sep 17 00:00:00 2001 From: Josh Patra Date: Fri, 6 Feb 2026 19:55:16 -0500 Subject: [PATCH] fix: add rate limiting for Odesli/song.link API - Implemented semaphore-based rate limiter (10 requests per minute) - Odesli API allows 10 requests per 60 seconds - Rate limiter ensures 1 request per 6 seconds maximum - Prevents API rate limit violations - Cache still used first (30 day TTL) to minimize API calls --- allstarr/Controllers/JellyfinController.cs | 101 ++++++++++++--------- 1 file changed, 60 insertions(+), 41 deletions(-) diff --git a/allstarr/Controllers/JellyfinController.cs b/allstarr/Controllers/JellyfinController.cs index 931a890..83d2590 100644 --- a/allstarr/Controllers/JellyfinController.cs +++ b/allstarr/Controllers/JellyfinController.cs @@ -4471,54 +4471,70 @@ public class JellyfinController : ControllerBase return cachedSpotifyId; } - // Call Odesli API - var odesliUrl = $"https://api.song.link/v1-alpha.1/links?url={Uri.EscapeDataString(sourceUrl)}&userCountry=US"; + // RATE LIMITING: Odesli allows 10 requests per minute + // Use a simple semaphore-based rate limiter + await OdesliRateLimiter.WaitAsync(); - _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) + try { - _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)) + // 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) { - // 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) + _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)) { - 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; + // 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(); + }); } - - _logger.LogDebug("No Spotify link found in Odesli response for {Provider}/{ExternalId}", provider, externalId); - return null; } catch (Exception ex) { @@ -4526,6 +4542,9 @@ public class JellyfinController : ControllerBase 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