From a3d1d818101cf7158f68fe2b1c33c21bdd8bd3f0 Mon Sep 17 00:00:00 2001 From: Josh Patra Date: Fri, 6 Feb 2026 01:21:30 -0500 Subject: [PATCH] Add Spotify lyrics sidecar service and integrate with prefetch - Add spotify-lyrics-api container to docker-compose - Update SpotifyLyricsService to use sidecar API - Prefetch now tries Spotify lyrics first (using track ID), then LRCLib - Add SPOTIFY_LYRICS_API_URL setting - Sidecar handles sp_dc cookie authentication automatically --- .env.example | 6 + .../Models/Settings/SpotifyApiSettings.cs | 7 + .../Services/Lyrics/LyricsPrefetchService.cs | 56 +++++++- .../Services/Lyrics/SpotifyLyricsService.cs | 121 ++++++++++++++++++ docker-compose.yml | 21 +++ 5 files changed, 205 insertions(+), 6 deletions(-) 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