Add Spotify direct API integration for lyrics, ISRC matching, and playlist ordering

Features:
- SpotifyApiClient: Direct Spotify API client using sp_dc session cookie
- SpotifyLyricsService: Fetch synced lyrics from Spotify's color-lyrics API
- SpotifyPlaylistFetcher: Get playlists with correct track ordering and ISRC codes
- SpotifyTrackMatchingService: ISRC-based exact track matching for external providers

Improvements:
- Lyrics endpoint now prioritizes: 1) Jellyfin embedded, 2) Spotify synced, 3) LRCLIB
- Fixed playback progress reporting - removed incorrect body wrapping for Jellyfin API
- Added SpotifyApiSettings configuration model

Security:
- Session cookie and client ID properly masked in startup logs
- All credentials read from environment variables only
This commit is contained in:
2026-02-03 14:06:40 -05:00
parent bbb0d9bb73
commit 375e1894f3
11 changed files with 2410 additions and 203 deletions

View File

@@ -0,0 +1,474 @@
using System.Net;
using System.Net.Http.Headers;
using System.Text.Json;
using allstarr.Models.Lyrics;
using allstarr.Models.Settings;
using allstarr.Services.Common;
using allstarr.Services.Spotify;
using Microsoft.Extensions.Options;
namespace allstarr.Services.Lyrics;
/// <summary>
/// Service for fetching synchronized lyrics from Spotify's internal color-lyrics API.
///
/// Spotify's lyrics API provides:
/// - Line-by-line synchronized lyrics with precise timestamps
/// - Word-level timing for karaoke-style display (syllable sync)
/// - Background color suggestions based on album art
/// - Support for multiple languages and translations
///
/// This requires the sp_dc session cookie for authentication.
/// </summary>
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";
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");
}
/// <summary>
/// Gets synchronized lyrics for a Spotify track by its ID.
/// </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>
public async Task<SpotifyLyricsResult?> GetLyricsByTrackIdAsync(string spotifyTrackId)
{
if (!_settings.Enabled || string.IsNullOrEmpty(_settings.SessionCookie))
{
_logger.LogDebug("Spotify API not enabled or no session cookie configured");
return null;
}
// Normalize track ID (remove URI prefix if present)
spotifyTrackId = ExtractTrackId(spotifyTrackId);
// Check cache
var cacheKey = $"spotify:lyrics:{spotifyTrackId}";
var cached = await _cache.GetAsync<SpotifyLyricsResult>(cacheKey);
if (cached != null)
{
_logger.LogDebug("Returning cached Spotify lyrics for track {TrackId}", spotifyTrackId);
return cached;
}
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.
/// Useful when you have track metadata but not a Spotify ID.
/// </summary>
public async Task<SpotifyLyricsResult?> SearchAndGetLyricsAsync(
string trackName,
string artistName,
string? albumName = null,
int? durationMs = null)
{
if (!_settings.Enabled || string.IsNullOrEmpty(_settings.SessionCookie))
{
return null;
}
try
{
var token = await _spotifyClient.GetWebAccessTokenAsync();
if (string.IsNullOrEmpty(token))
{
return null;
}
// Search for the track
var query = $"track:{trackName} artist:{artistName}";
if (!string.IsNullOrEmpty(albumName))
{
query += $" album:{albumName}";
}
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)
{
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)
{
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))
{
return await GetLyricsByTrackIdAsync(bestMatchId);
}
return null;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error searching Spotify for lyrics: {Track} - {Artist}", trackName, artistName);
return null;
}
}
/// <summary>
/// Converts Spotify lyrics to LRCLIB-compatible LyricsInfo format.
/// </summary>
public LyricsInfo? ToLyricsInfo(SpotifyLyricsResult spotifyLyrics)
{
if (spotifyLyrics.Lines.Count == 0)
{
return null;
}
// Build synced lyrics in LRC format
var lrcLines = new List<string>();
foreach (var line in spotifyLyrics.Lines)
{
var timestamp = TimeSpan.FromMilliseconds(line.StartTimeMs);
var mm = (int)timestamp.TotalMinutes;
var ss = timestamp.Seconds;
var ms = timestamp.Milliseconds / 10; // LRC uses centiseconds
lrcLines.Add($"[{mm:D2}:{ss:D2}.{ms:D2}]{line.Words}");
}
return new LyricsInfo
{
TrackName = spotifyLyrics.TrackName ?? "",
ArtistName = spotifyLyrics.ArtistName ?? "",
AlbumName = spotifyLyrics.AlbumName ?? "",
Duration = (int)(spotifyLyrics.DurationMs / 1000),
Instrumental = spotifyLyrics.Lines.Count == 0,
SyncedLyrics = string.Join("\n", lrcLines),
PlainLyrics = string.Join("\n", spotifyLyrics.Lines.Select(l => l.Words))
};
}
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;
}
}
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;
// Handle spotify:track:xxxxx format
if (input.StartsWith("spotify:track:"))
{
return input.Substring("spotify:track:".Length);
}
// Handle https://open.spotify.com/track/xxxxx format
if (input.Contains("open.spotify.com/track/"))
{
var start = input.IndexOf("/track/") + "/track/".Length;
var end = input.IndexOf('?', start);
return end > 0 ? input.Substring(start, end - start) : input.Substring(start);
}
return input;
}
}
/// <summary>
/// Result from Spotify's color-lyrics API.
/// </summary>
public class SpotifyLyricsResult
{
public string SpotifyTrackId { get; set; } = string.Empty;
public string? TrackName { get; set; }
public string? ArtistName { get; set; }
public string? AlbumName { get; set; }
public long DurationMs { get; set; }
/// <summary>
/// Sync type: "LINE_SYNCED", "SYLLABLE_SYNCED", or "UNSYNCED"
/// </summary>
public string SyncType { get; set; } = "LINE_SYNCED";
/// <summary>
/// Language code (e.g., "en", "es", "ja")
/// </summary>
public string? Language { get; set; }
/// <summary>
/// Lyrics provider (e.g., "MusixMatch", "Spotify")
/// </summary>
public string? Provider { get; set; }
public string? ProviderDisplayName { get; set; }
/// <summary>
/// Lyrics lines in order
/// </summary>
public List<SpotifyLyricsLine> Lines { get; set; } = new();
/// <summary>
/// Color suggestions based on album art
/// </summary>
public SpotifyLyricsColors? Colors { get; set; }
}
public class SpotifyLyricsLine
{
/// <summary>
/// Start time in milliseconds
/// </summary>
public long StartTimeMs { get; set; }
/// <summary>
/// End time in milliseconds
/// </summary>
public long EndTimeMs { get; set; }
/// <summary>
/// The lyrics text for this line
/// </summary>
public string Words { get; set; } = string.Empty;
/// <summary>
/// Syllable-level timing for karaoke display (if available)
/// </summary>
public List<SpotifyLyricsSyllable> Syllables { get; set; } = new();
}
public class SpotifyLyricsSyllable
{
public long StartTimeMs { get; set; }
public string Text { get; set; } = string.Empty;
}
public class SpotifyLyricsColors
{
/// <summary>
/// Suggested background color (ARGB integer)
/// </summary>
public int? Background { get; set; }
/// <summary>
/// Suggested text color (ARGB integer)
/// </summary>
public int? Text { get; set; }
/// <summary>
/// Suggested highlight/active text color (ARGB integer)
/// </summary>
public int? HighlightText { get; set; }
}