mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-09 23:55:10 -05:00
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
This commit is contained in:
@@ -187,3 +187,9 @@ SPOTIFY_API_RATE_LIMIT_DELAY_MS=100
|
|||||||
# Prefer ISRC matching over fuzzy title/artist matching (default: true)
|
# Prefer ISRC matching over fuzzy title/artist matching (default: true)
|
||||||
# ISRC provides exact track identification across different streaming services
|
# ISRC provides exact track identification across different streaming services
|
||||||
SPOTIFY_API_PREFER_ISRC_MATCHING=true
|
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
|
||||||
|
|||||||
@@ -69,4 +69,11 @@ public class SpotifyApiSettings
|
|||||||
/// Used to track cookie age and warn when it's approaching expiration (~1 year).
|
/// Used to track cookie age and warn when it's approaching expiration (~1 year).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string? SessionCookieSetDate { get; set; }
|
public string? SessionCookieSetDate { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public string LyricsApiUrl { get; set; } = "http://spotify-lyrics:8080";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -161,12 +161,22 @@ public class LyricsPrefetchService : BackgroundService
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch lyrics from LRCLib
|
// Try Spotify lyrics first if we have a Spotify ID
|
||||||
var lyrics = await _lrclibService.GetLyricsAsync(
|
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.Title,
|
||||||
track.Artists.ToArray(),
|
track.Artists.ToArray(),
|
||||||
track.Album,
|
track.Album,
|
||||||
track.DurationMs / 1000);
|
track.DurationMs / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
if (lyrics != null)
|
if (lyrics != null)
|
||||||
{
|
{
|
||||||
@@ -305,6 +315,40 @@ public class LyricsPrefetchService : BackgroundService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tries to get lyrics from Spotify using the track's Spotify ID.
|
||||||
|
/// Returns null if Spotify API is not enabled or lyrics not found.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<LyricsInfo?> TryGetSpotifyLyricsAsync(string spotifyTrackId, string trackTitle, string artistName)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var scope = _serviceProvider.CreateScope();
|
||||||
|
var spotifyLyricsService = scope.ServiceProvider.GetService<SpotifyLyricsService>();
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Checks if a track has embedded lyrics in Jellyfin by querying the Jellyfin API.
|
/// 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.
|
/// This prevents downloading lyrics from LRCLib when the local file already has them.
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ public class SpotifyLyricsService
|
|||||||
private readonly HttpClient _httpClient;
|
private readonly HttpClient _httpClient;
|
||||||
|
|
||||||
private const string LyricsApiBase = "https://spclient.wg.spotify.com/color-lyrics/v2/track";
|
private const string LyricsApiBase = "https://spclient.wg.spotify.com/color-lyrics/v2/track";
|
||||||
|
private bool _useSidecarApi = false;
|
||||||
|
|
||||||
public SpotifyLyricsService(
|
public SpotifyLyricsService(
|
||||||
ILogger<SpotifyLyricsService> logger,
|
ILogger<SpotifyLyricsService> logger,
|
||||||
@@ -45,6 +46,11 @@ public class SpotifyLyricsService
|
|||||||
_httpClient = httpClientFactory.CreateClient();
|
_httpClient = httpClientFactory.CreateClient();
|
||||||
_httpClient.DefaultRequestHeaders.Add("User-Agent", "Mozilla/5.0");
|
_httpClient.DefaultRequestHeaders.Add("User-Agent", "Mozilla/5.0");
|
||||||
_httpClient.DefaultRequestHeaders.Add("App-Platform", "WebPlayer");
|
_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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -72,6 +78,64 @@ public class SpotifyLyricsService
|
|||||||
return cached;
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets lyrics from the sidecar spotify-lyrics-api service.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<SpotifyLyricsResult?> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets lyrics directly from Spotify's color-lyrics API.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<SpotifyLyricsResult?> GetLyricsDirectAsync(string spotifyTrackId, string cacheKey)
|
||||||
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Get access token
|
// Get access token
|
||||||
@@ -356,6 +420,63 @@ public class SpotifyLyricsService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses the response from the sidecar spotify-lyrics-api service.
|
||||||
|
/// Format: {"error": false, "syncType": "LINE_SYNCED", "lines": [...]}
|
||||||
|
/// </summary>
|
||||||
|
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)
|
private static int? ParseColorValue(JsonElement element)
|
||||||
{
|
{
|
||||||
if (element.ValueKind == JsonValueKind.Number)
|
if (element.ValueKind == JsonValueKind.Number)
|
||||||
|
|||||||
@@ -17,6 +17,23 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- allstarr-network
|
- 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:
|
allstarr:
|
||||||
# Use pre-built image from GitHub Container Registry
|
# Use pre-built image from GitHub Container Registry
|
||||||
# For latest stable: ghcr.io/sopat712/allstarr:latest
|
# For latest stable: ghcr.io/sopat712/allstarr:latest
|
||||||
@@ -40,6 +57,8 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
spotify-lyrics:
|
||||||
|
condition: service_started
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
|
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
@@ -98,6 +117,8 @@ services:
|
|||||||
- SpotifyApi__CacheDurationMinutes=${SPOTIFY_API_CACHE_DURATION_MINUTES:-60}
|
- SpotifyApi__CacheDurationMinutes=${SPOTIFY_API_CACHE_DURATION_MINUTES:-60}
|
||||||
- SpotifyApi__RateLimitDelayMs=${SPOTIFY_API_RATE_LIMIT_DELAY_MS:-100}
|
- SpotifyApi__RateLimitDelayMs=${SPOTIFY_API_RATE_LIMIT_DELAY_MS:-100}
|
||||||
- SpotifyApi__PreferIsrcMatching=${SPOTIFY_API_PREFER_ISRC_MATCHING:-true}
|
- 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 =====
|
# ===== SHARED =====
|
||||||
- Library__DownloadPath=/app/downloads
|
- Library__DownloadPath=/app/downloads
|
||||||
|
|||||||
Reference in New Issue
Block a user