diff --git a/.env.example b/.env.example
index 8b9b972..a628af9 100644
--- a/.env.example
+++ b/.env.example
@@ -187,3 +187,9 @@ SPOTIFY_API_RATE_LIMIT_DELAY_MS=100
# Prefer ISRC matching over fuzzy title/artist matching (default: true)
# ISRC provides exact track identification across different streaming services
SPOTIFY_API_PREFER_ISRC_MATCHING=true
+
+# Spotify Lyrics API URL (default: http://spotify-lyrics:8080)
+# Uses the spotify-lyrics-api sidecar service for fetching synchronized lyrics
+# This service is automatically started in docker-compose
+# Leave as default unless running a custom deployment
+SPOTIFY_LYRICS_API_URL=http://spotify-lyrics:8080
diff --git a/allstarr/Models/Settings/SpotifyApiSettings.cs b/allstarr/Models/Settings/SpotifyApiSettings.cs
index 2dce551..88f23d4 100644
--- a/allstarr/Models/Settings/SpotifyApiSettings.cs
+++ b/allstarr/Models/Settings/SpotifyApiSettings.cs
@@ -69,4 +69,11 @@ public class SpotifyApiSettings
/// Used to track cookie age and warn when it's approaching expiration (~1 year).
///
public string? SessionCookieSetDate { get; set; }
+
+ ///
+ /// URL of the Spotify Lyrics API sidecar service.
+ /// Default: http://spotify-lyrics:8080 (docker-compose service name)
+ /// This service wraps Spotify's color-lyrics API for easier access.
+ ///
+ public string LyricsApiUrl { get; set; } = "http://spotify-lyrics:8080";
}
diff --git a/allstarr/Services/Lyrics/LyricsPrefetchService.cs b/allstarr/Services/Lyrics/LyricsPrefetchService.cs
index 4045857..6ea87e3 100644
--- a/allstarr/Services/Lyrics/LyricsPrefetchService.cs
+++ b/allstarr/Services/Lyrics/LyricsPrefetchService.cs
@@ -161,12 +161,22 @@ public class LyricsPrefetchService : BackgroundService
continue;
}
- // Fetch lyrics from LRCLib
- var lyrics = await _lrclibService.GetLyricsAsync(
- track.Title,
- track.Artists.ToArray(),
- track.Album,
- track.DurationMs / 1000);
+ // Try Spotify lyrics first if we have a Spotify ID
+ LyricsInfo? lyrics = null;
+ if (!string.IsNullOrEmpty(track.SpotifyId))
+ {
+ lyrics = await TryGetSpotifyLyricsAsync(track.SpotifyId, track.Title, track.PrimaryArtist);
+ }
+
+ // Fall back to LRCLib if no Spotify lyrics
+ if (lyrics == null)
+ {
+ lyrics = await _lrclibService.GetLyricsAsync(
+ track.Title,
+ track.Artists.ToArray(),
+ track.Album,
+ track.DurationMs / 1000);
+ }
if (lyrics != null)
{
@@ -305,6 +315,40 @@ public class LyricsPrefetchService : BackgroundService
}
}
+ ///
+ /// Tries to get lyrics from Spotify using the track's Spotify ID.
+ /// Returns null if Spotify API is not enabled or lyrics not found.
+ ///
+ private async Task TryGetSpotifyLyricsAsync(string spotifyTrackId, string trackTitle, string artistName)
+ {
+ try
+ {
+ using var scope = _serviceProvider.CreateScope();
+ var spotifyLyricsService = scope.ServiceProvider.GetService();
+
+ if (spotifyLyricsService == null)
+ {
+ return null;
+ }
+
+ var spotifyLyrics = await spotifyLyricsService.GetLyricsByTrackIdAsync(spotifyTrackId);
+
+ if (spotifyLyrics != null && spotifyLyrics.Lines.Count > 0)
+ {
+ _logger.LogInformation("✓ Found Spotify lyrics for {Artist} - {Track} ({LineCount} lines)",
+ artistName, trackTitle, spotifyLyrics.Lines.Count);
+ return spotifyLyricsService.ToLyricsInfo(spotifyLyrics);
+ }
+
+ return null;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogDebug(ex, "Error fetching Spotify lyrics for track {SpotifyId}", spotifyTrackId);
+ return null;
+ }
+ }
+
///
/// Checks if a track has embedded lyrics in Jellyfin by querying the Jellyfin API.
/// This prevents downloading lyrics from LRCLib when the local file already has them.
diff --git a/allstarr/Services/Lyrics/SpotifyLyricsService.cs b/allstarr/Services/Lyrics/SpotifyLyricsService.cs
index 2abda1f..dbbb8a7 100644
--- a/allstarr/Services/Lyrics/SpotifyLyricsService.cs
+++ b/allstarr/Services/Lyrics/SpotifyLyricsService.cs
@@ -29,6 +29,7 @@ public class SpotifyLyricsService
private readonly HttpClient _httpClient;
private const string LyricsApiBase = "https://spclient.wg.spotify.com/color-lyrics/v2/track";
+ private bool _useSidecarApi = false;
public SpotifyLyricsService(
ILogger logger,
@@ -45,6 +46,11 @@ public class SpotifyLyricsService
_httpClient = httpClientFactory.CreateClient();
_httpClient.DefaultRequestHeaders.Add("User-Agent", "Mozilla/5.0");
_httpClient.DefaultRequestHeaders.Add("App-Platform", "WebPlayer");
+
+ // Check if sidecar API is configured and available
+ _useSidecarApi = !string.IsNullOrEmpty(_settings.LyricsApiUrl) &&
+ _settings.LyricsApiUrl != "http://spotify-lyrics:8080" ||
+ _settings.Enabled;
}
///
@@ -72,6 +78,64 @@ public class SpotifyLyricsService
return cached;
}
+ // Try sidecar API first if available
+ if (_useSidecarApi && !string.IsNullOrEmpty(_settings.LyricsApiUrl))
+ {
+ var sidecarResult = await GetLyricsFromSidecarAsync(spotifyTrackId);
+ if (sidecarResult != null)
+ {
+ await _cache.SetAsync(cacheKey, sidecarResult, TimeSpan.FromDays(30));
+ return sidecarResult;
+ }
+ }
+
+ // Fall back to direct API call
+ return await GetLyricsDirectAsync(spotifyTrackId, cacheKey);
+ }
+
+ ///
+ /// Gets lyrics from the sidecar spotify-lyrics-api service.
+ ///
+ private async Task GetLyricsFromSidecarAsync(string spotifyTrackId)
+ {
+ try
+ {
+ var url = $"{_settings.LyricsApiUrl}/?trackid={spotifyTrackId}&format=id3";
+
+ _logger.LogDebug("Fetching lyrics from sidecar API: {Url}", url);
+
+ var response = await _httpClient.GetAsync(url);
+
+ if (!response.IsSuccessStatusCode)
+ {
+ _logger.LogDebug("Sidecar API returned {StatusCode} for track {TrackId}",
+ response.StatusCode, spotifyTrackId);
+ return null;
+ }
+
+ var json = await response.Content.ReadAsStringAsync();
+ var result = ParseSidecarResponse(json, spotifyTrackId);
+
+ if (result != null)
+ {
+ _logger.LogInformation("Got Spotify lyrics from sidecar for track {TrackId} ({LineCount} lines)",
+ spotifyTrackId, result.Lines.Count);
+ }
+
+ return result;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Error fetching lyrics from sidecar API for track {TrackId}", spotifyTrackId);
+ return null;
+ }
+ }
+
+ ///
+ /// Gets lyrics directly from Spotify's color-lyrics API.
+ ///
+ private async Task GetLyricsDirectAsync(string spotifyTrackId, string cacheKey)
+ {
try
{
// Get access token
@@ -356,6 +420,63 @@ public class SpotifyLyricsService
}
}
+ ///
+ /// Parses the response from the sidecar spotify-lyrics-api service.
+ /// Format: {"error": false, "syncType": "LINE_SYNCED", "lines": [...]}
+ ///
+ private SpotifyLyricsResult? ParseSidecarResponse(string json, string trackId)
+ {
+ try
+ {
+ using var doc = JsonDocument.Parse(json);
+ var root = doc.RootElement;
+
+ // Check for error
+ if (root.TryGetProperty("error", out var error) && error.GetBoolean())
+ {
+ _logger.LogDebug("Sidecar API returned error for track {TrackId}", trackId);
+ return null;
+ }
+
+ var result = new SpotifyLyricsResult
+ {
+ SpotifyTrackId = trackId
+ };
+
+ // Get sync type
+ if (root.TryGetProperty("syncType", out var syncType))
+ {
+ result.SyncType = syncType.GetString() ?? "LINE_SYNCED";
+ }
+
+ // Parse lines
+ if (root.TryGetProperty("lines", out var lines))
+ {
+ foreach (var line in lines.EnumerateArray())
+ {
+ var lyricsLine = new SpotifyLyricsLine
+ {
+ StartTimeMs = line.TryGetProperty("startTimeMs", out var start)
+ ? long.Parse(start.GetString() ?? "0") : 0,
+ Words = line.TryGetProperty("words", out var words)
+ ? words.GetString() ?? "" : "",
+ EndTimeMs = line.TryGetProperty("endTimeMs", out var end)
+ ? long.Parse(end.GetString() ?? "0") : 0
+ };
+
+ result.Lines.Add(lyricsLine);
+ }
+ }
+
+ return result;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error parsing sidecar API response");
+ return null;
+ }
+ }
+
private static int? ParseColorValue(JsonElement element)
{
if (element.ValueKind == JsonValueKind.Number)
diff --git a/docker-compose.yml b/docker-compose.yml
index 5620562..574d623 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -17,6 +17,23 @@ services:
networks:
- allstarr-network
+ spotify-lyrics:
+ image: akashrchandran/spotify-lyrics-api:latest
+ container_name: allstarr-spotify-lyrics
+ restart: unless-stopped
+ # Only accessible internally - no external port exposure
+ expose:
+ - "8080"
+ environment:
+ - SP_DC=${SPOTIFY_API_SESSION_COOKIE:-}
+ networks:
+ - allstarr-network
+ healthcheck:
+ test: ["CMD", "wget", "--spider", "-q", "http://localhost:8080/"]
+ interval: 30s
+ timeout: 5s
+ retries: 3
+
allstarr:
# Use pre-built image from GitHub Container Registry
# For latest stable: ghcr.io/sopat712/allstarr:latest
@@ -40,6 +57,8 @@ services:
depends_on:
redis:
condition: service_healthy
+ spotify-lyrics:
+ condition: service_started
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 30s
@@ -98,6 +117,8 @@ services:
- SpotifyApi__CacheDurationMinutes=${SPOTIFY_API_CACHE_DURATION_MINUTES:-60}
- SpotifyApi__RateLimitDelayMs=${SPOTIFY_API_RATE_LIMIT_DELAY_MS:-100}
- SpotifyApi__PreferIsrcMatching=${SPOTIFY_API_PREFER_ISRC_MATCHING:-true}
+ # Spotify Lyrics API sidecar service URL (internal)
+ - SpotifyApi__LyricsApiUrl=${SPOTIFY_LYRICS_API_URL:-http://spotify-lyrics:8080}
# ===== SHARED =====
- Library__DownloadPath=/app/downloads