mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-09 23:55:10 -05:00
Integrate Spotify lyrics sidecar service
- Add spotify-lyrics-api sidecar container to docker-compose - Replace direct Spotify API lyrics code with sidecar API calls - Update SpotifyLyricsService to use sidecar exclusively - Add LyricsApiUrl setting to SpotifyApiSettings - Update prefetch to try Spotify lyrics first, then LRCLib - Remove unused direct API authentication and parsing code
This commit is contained in:
@@ -24,37 +24,25 @@ public class SpotifyLyricsService
|
||||
{
|
||||
private readonly ILogger<SpotifyLyricsService> _logger;
|
||||
private readonly SpotifyApiSettings _settings;
|
||||
private readonly SpotifyApiClient _spotifyClient;
|
||||
private readonly RedisCacheService _cache;
|
||||
private readonly HttpClient _httpClient;
|
||||
|
||||
private const string LyricsApiBase = "https://spclient.wg.spotify.com/color-lyrics/v2/track";
|
||||
private bool _useSidecarApi = false;
|
||||
|
||||
public SpotifyLyricsService(
|
||||
ILogger<SpotifyLyricsService> logger,
|
||||
IOptions<SpotifyApiSettings> settings,
|
||||
SpotifyApiClient spotifyClient,
|
||||
RedisCacheService cache,
|
||||
IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
_logger = logger;
|
||||
_settings = settings.Value;
|
||||
_spotifyClient = spotifyClient;
|
||||
_cache = cache;
|
||||
|
||||
_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;
|
||||
_httpClient.Timeout = TimeSpan.FromSeconds(10);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets synchronized lyrics for a Spotify track by its ID.
|
||||
/// Gets synchronized lyrics for a Spotify track by its ID using the sidecar API.
|
||||
/// </summary>
|
||||
/// <param name="spotifyTrackId">Spotify track ID (e.g., "3a8mo25v74BMUOJ1IDUEBL")</param>
|
||||
/// <returns>Lyrics info with synced lyrics in LRC format, or null if not available</returns>
|
||||
@@ -66,6 +54,12 @@ public class SpotifyLyricsService
|
||||
return null;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(_settings.LyricsApiUrl))
|
||||
{
|
||||
_logger.LogWarning("Spotify lyrics API URL not configured");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Normalize track ID (remove URI prefix if present)
|
||||
spotifyTrackId = ExtractTrackId(spotifyTrackId);
|
||||
|
||||
@@ -78,26 +72,6 @@ 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);
|
||||
}
|
||||
|
||||
/// <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";
|
||||
@@ -118,6 +92,8 @@ public class SpotifyLyricsService
|
||||
|
||||
if (result != null)
|
||||
{
|
||||
// Cache for 30 days (lyrics don't change)
|
||||
await _cache.SetAsync(cacheKey, result, TimeSpan.FromDays(30));
|
||||
_logger.LogInformation("Got Spotify lyrics from sidecar for track {TrackId} ({LineCount} lines)",
|
||||
spotifyTrackId, result.Lines.Count);
|
||||
}
|
||||
@@ -132,65 +108,9 @@ public class SpotifyLyricsService
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets lyrics directly from Spotify's color-lyrics API.
|
||||
/// </summary>
|
||||
private async Task<SpotifyLyricsResult?> GetLyricsDirectAsync(string spotifyTrackId, string cacheKey)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Get access token
|
||||
var token = await _spotifyClient.GetWebAccessTokenAsync();
|
||||
if (string.IsNullOrEmpty(token))
|
||||
{
|
||||
_logger.LogWarning("Could not get Spotify access token for lyrics");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Request lyrics from Spotify's color-lyrics API
|
||||
var url = $"{LyricsApiBase}/{spotifyTrackId}?format=json&vocalRemoval=false&market=from_token";
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
request.Headers.Add("Accept", "application/json");
|
||||
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
_logger.LogDebug("No lyrics found on Spotify for track {TrackId}", spotifyTrackId);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning("Spotify lyrics API returned {StatusCode} for track {TrackId}",
|
||||
response.StatusCode, spotifyTrackId);
|
||||
return null;
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
var result = ParseLyricsResponse(json, spotifyTrackId);
|
||||
|
||||
if (result != null)
|
||||
{
|
||||
// Cache for 30 days (lyrics don't change)
|
||||
await _cache.SetAsync(cacheKey, result, TimeSpan.FromDays(30));
|
||||
_logger.LogInformation("Cached Spotify lyrics for track {TrackId} ({LineCount} lines)",
|
||||
spotifyTrackId, result.Lines.Count);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error fetching Spotify lyrics for track {TrackId}", spotifyTrackId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Searches for a track on Spotify and returns its lyrics.
|
||||
/// Searches for a track on Spotify and returns its lyrics using the sidecar API.
|
||||
/// Useful when you have track metadata but not a Spotify ID.
|
||||
/// Note: This requires the sidecar to handle search, or we skip it.
|
||||
/// </summary>
|
||||
public async Task<SpotifyLyricsResult?> SearchAndGetLyricsAsync(
|
||||
string trackName,
|
||||
@@ -204,89 +124,11 @@ public class SpotifyLyricsService
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var token = await _spotifyClient.GetWebAccessTokenAsync();
|
||||
if (string.IsNullOrEmpty(token))
|
||||
{
|
||||
_logger.LogWarning("Could not get Spotify access token for lyrics search");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Search for the track
|
||||
var query = $"track:{trackName} artist:{artistName}";
|
||||
if (!string.IsNullOrEmpty(albumName))
|
||||
{
|
||||
query += $" album:{albumName}";
|
||||
}
|
||||
|
||||
_logger.LogDebug("Searching Spotify for lyrics: {Query}", query);
|
||||
|
||||
var searchUrl = $"https://api.spotify.com/v1/search?q={Uri.EscapeDataString(query)}&type=track&limit=5";
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, searchUrl);
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning("Spotify search failed with status {StatusCode}", response.StatusCode);
|
||||
return null;
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
|
||||
if (!root.TryGetProperty("tracks", out var tracks) ||
|
||||
!tracks.TryGetProperty("items", out var items) ||
|
||||
items.GetArrayLength() == 0)
|
||||
{
|
||||
_logger.LogInformation("No Spotify tracks found for: {Track} - {Artist}", trackName, artistName);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find best match considering duration if provided
|
||||
string? bestMatchId = null;
|
||||
var bestScore = 0;
|
||||
|
||||
foreach (var item in items.EnumerateArray())
|
||||
{
|
||||
var id = item.TryGetProperty("id", out var idProp) ? idProp.GetString() : null;
|
||||
if (string.IsNullOrEmpty(id)) continue;
|
||||
|
||||
var score = 100; // Base score
|
||||
|
||||
// Check duration match
|
||||
if (durationMs.HasValue && item.TryGetProperty("duration_ms", out var durProp))
|
||||
{
|
||||
var trackDuration = durProp.GetInt32();
|
||||
var durationDiff = Math.Abs(trackDuration - durationMs.Value);
|
||||
if (durationDiff < 2000) score += 50; // Within 2 seconds
|
||||
else if (durationDiff < 5000) score += 25; // Within 5 seconds
|
||||
}
|
||||
|
||||
if (score > bestScore)
|
||||
{
|
||||
bestScore = score;
|
||||
bestMatchId = id;
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(bestMatchId))
|
||||
{
|
||||
_logger.LogDebug("Found Spotify track match: {TrackId} (score: {Score})", bestMatchId, bestScore);
|
||||
return await GetLyricsByTrackIdAsync(bestMatchId);
|
||||
}
|
||||
|
||||
_logger.LogInformation("No suitable Spotify track match found for: {Track} - {Artist}", trackName, artistName);
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error searching Spotify for lyrics: {Track} - {Artist}", trackName, artistName);
|
||||
return null;
|
||||
}
|
||||
// The sidecar API only supports track ID, not search
|
||||
// So we skip Spotify lyrics for search-based requests
|
||||
// LRCLib will be used as fallback
|
||||
_logger.LogDebug("Spotify lyrics search by metadata not supported with sidecar API, skipping");
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -323,103 +165,6 @@ public class SpotifyLyricsService
|
||||
};
|
||||
}
|
||||
|
||||
private SpotifyLyricsResult? ParseLyricsResponse(string json, string trackId)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
|
||||
var result = new SpotifyLyricsResult
|
||||
{
|
||||
SpotifyTrackId = trackId
|
||||
};
|
||||
|
||||
// Parse lyrics lines
|
||||
if (root.TryGetProperty("lyrics", out var lyrics))
|
||||
{
|
||||
// Check sync type
|
||||
if (lyrics.TryGetProperty("syncType", out var syncType))
|
||||
{
|
||||
result.SyncType = syncType.GetString() ?? "LINE_SYNCED";
|
||||
}
|
||||
|
||||
// Parse lines
|
||||
if (lyrics.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
|
||||
};
|
||||
|
||||
// Parse syllables if available (for word-level sync)
|
||||
if (line.TryGetProperty("syllables", out var syllables))
|
||||
{
|
||||
foreach (var syllable in syllables.EnumerateArray())
|
||||
{
|
||||
lyricsLine.Syllables.Add(new SpotifyLyricsSyllable
|
||||
{
|
||||
StartTimeMs = syllable.TryGetProperty("startTimeMs", out var sStart)
|
||||
? long.Parse(sStart.GetString() ?? "0") : 0,
|
||||
Text = syllable.TryGetProperty("charsIndex", out var text)
|
||||
? text.GetString() ?? "" : ""
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
result.Lines.Add(lyricsLine);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse color information
|
||||
if (lyrics.TryGetProperty("colors", out var colors))
|
||||
{
|
||||
result.Colors = new SpotifyLyricsColors
|
||||
{
|
||||
Background = colors.TryGetProperty("background", out var bg)
|
||||
? ParseColorValue(bg) : null,
|
||||
Text = colors.TryGetProperty("text", out var txt)
|
||||
? ParseColorValue(txt) : null,
|
||||
HighlightText = colors.TryGetProperty("highlightText", out var ht)
|
||||
? ParseColorValue(ht) : null
|
||||
};
|
||||
}
|
||||
|
||||
// Language
|
||||
if (lyrics.TryGetProperty("language", out var lang))
|
||||
{
|
||||
result.Language = lang.GetString();
|
||||
}
|
||||
|
||||
// Provider info
|
||||
if (lyrics.TryGetProperty("provider", out var provider))
|
||||
{
|
||||
result.Provider = provider.GetString();
|
||||
}
|
||||
|
||||
// Display info
|
||||
if (lyrics.TryGetProperty("providerDisplayName", out var providerDisplay))
|
||||
{
|
||||
result.ProviderDisplayName = providerDisplay.GetString();
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error parsing Spotify lyrics response");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the response from the sidecar spotify-lyrics-api service.
|
||||
/// Format: {"error": false, "syncType": "LINE_SYNCED", "lines": [...]}
|
||||
@@ -477,23 +222,6 @@ public class SpotifyLyricsService
|
||||
}
|
||||
}
|
||||
|
||||
private static int? ParseColorValue(JsonElement element)
|
||||
{
|
||||
if (element.ValueKind == JsonValueKind.Number)
|
||||
{
|
||||
return element.GetInt32();
|
||||
}
|
||||
if (element.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var str = element.GetString();
|
||||
if (!string.IsNullOrEmpty(str) && int.TryParse(str, out var val))
|
||||
{
|
||||
return val;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string ExtractTrackId(string input)
|
||||
{
|
||||
if (string.IsNullOrEmpty(input)) return input;
|
||||
|
||||
Reference in New Issue
Block a user