feat: add LyricsPlus API integration with modular orchestrator

- Add LyricsPlusService for multi-source lyrics (Apple, Spotify, Musixmatch)
- Create LyricsOrchestrator for modular, priority-based lyrics fetching
- Refactor lyrics logic into independent service modules
- Priority: Spotify → LyricsPlus → LRCLib
- Each service logs independently for easier debugging
- Supports word-level and line-level timing conversion to LRC
This commit is contained in:
2026-02-10 23:01:47 -05:00
parent 206cb0b84f
commit 7ae5b75d68
4 changed files with 567 additions and 39 deletions

View File

@@ -39,7 +39,9 @@ public class JellyfinController : ControllerBase
private readonly PlaylistSyncService? _playlistSyncService;
private readonly SpotifyPlaylistFetcher? _spotifyPlaylistFetcher;
private readonly SpotifyLyricsService? _spotifyLyricsService;
private readonly LyricsPlusService? _lyricsPlusService;
private readonly LrclibService? _lrclibService;
private readonly LyricsOrchestrator? _lyricsOrchestrator;
private readonly OdesliService _odesliService;
private readonly RedisCacheService _cache;
private readonly IConfiguration _configuration;
@@ -64,7 +66,9 @@ public class JellyfinController : ControllerBase
PlaylistSyncService? playlistSyncService = null,
SpotifyPlaylistFetcher? spotifyPlaylistFetcher = null,
SpotifyLyricsService? spotifyLyricsService = null,
LrclibService? lrclibService = null)
LyricsPlusService? lyricsPlusService = null,
LrclibService? lrclibService = null,
LyricsOrchestrator? lyricsOrchestrator = null)
{
_settings = settings.Value;
_spotifySettings = spotifySettings.Value;
@@ -80,7 +84,9 @@ public class JellyfinController : ControllerBase
_playlistSyncService = playlistSyncService;
_spotifyPlaylistFetcher = spotifyPlaylistFetcher;
_spotifyLyricsService = spotifyLyricsService;
_lyricsPlusService = lyricsPlusService;
_lrclibService = lrclibService;
_lyricsOrchestrator = lyricsOrchestrator;
_odesliService = odesliService;
_cache = cache;
_configuration = configuration;
@@ -1271,50 +1277,53 @@ public class JellyfinController : ControllerBase
searchArtists.Add(searchArtist);
}
// Use orchestrator for clean, modular lyrics fetching
LyricsInfo? lyrics = null;
if (_lyricsOrchestrator != null)
{
lyrics = await _lyricsOrchestrator.GetLyricsAsync(
trackName: searchTitle,
artistNames: searchArtists.ToArray(),
albumName: searchAlbum,
durationSeconds: song.Duration ?? 0,
spotifyTrackId: spotifyTrackId);
}
else
{
// Fallback to manual fetching if orchestrator not available
_logger.LogWarning("LyricsOrchestrator not available, using fallback method");
// Try Spotify lyrics ONLY if we have a valid Spotify track ID
// Spotify lyrics only work for tracks from injected playlists that have been matched
if (_spotifyLyricsService != null && _spotifyApiSettings.Enabled && !string.IsNullOrEmpty(spotifyTrackId))
{
// Validate that this is a real Spotify ID (not spotify:local or other invalid formats)
var cleanSpotifyId = spotifyTrackId.Replace("spotify:track:", "").Trim();
// Spotify track IDs are 22 characters, base62 encoded
if (cleanSpotifyId.Length == 22 && !cleanSpotifyId.Contains(":") && !cleanSpotifyId.Contains("local"))
{
_logger.LogInformation("Trying Spotify lyrics for track ID: {SpotifyId} ({Artist} - {Title})",
cleanSpotifyId, searchArtist, searchTitle);
var spotifyLyrics = await _spotifyLyricsService.GetLyricsByTrackIdAsync(cleanSpotifyId);
if (spotifyLyrics != null && spotifyLyrics.Lines.Count > 0)
{
_logger.LogInformation("Found Spotify lyrics for {Artist} - {Title} ({LineCount} lines, type: {SyncType})",
searchArtist, searchTitle, spotifyLyrics.Lines.Count, spotifyLyrics.SyncType);
lyrics = _spotifyLyricsService.ToLyricsInfo(spotifyLyrics);
}
else
{
_logger.LogDebug("No Spotify lyrics found for track ID {SpotifyId}", cleanSpotifyId);
}
}
else
{
_logger.LogDebug("Invalid Spotify ID format: {SpotifyId}, skipping Spotify lyrics", spotifyTrackId);
}
}
// Fall back to LRCLIB if no Spotify lyrics
if (lyrics == null)
// Fall back to LyricsPlus
if (lyrics == null && _lyricsPlusService != null)
{
_logger.LogInformation("Searching LRCLIB for lyrics: {Artists} - {Title}",
string.Join(", ", searchArtists),
searchTitle);
var lrclibService = HttpContext.RequestServices.GetService<LrclibService>();
if (lrclibService != null)
lyrics = await _lyricsPlusService.GetLyricsAsync(
searchTitle,
searchArtists.ToArray(),
searchAlbum,
song.Duration ?? 0);
}
// Fall back to LRCLIB
if (lyrics == null && _lrclibService != null)
{
lyrics = await lrclibService.GetLyricsAsync(
lyrics = await _lrclibService.GetLyricsAsync(
searchTitle,
searchArtists.ToArray(),
searchAlbum,
@@ -1495,6 +1504,21 @@ public class JellyfinController : ControllerBase
_logger.LogDebug("🎵 Prefetching lyrics for: {Artist} - {Title}", searchArtist, searchTitle);
// Use orchestrator for prefetching
if (_lyricsOrchestrator != null)
{
await _lyricsOrchestrator.PrefetchLyricsAsync(
trackName: searchTitle,
artistNames: searchArtists.ToArray(),
albumName: searchAlbum,
durationSeconds: song.Duration ?? 0,
spotifyTrackId: spotifyTrackId);
return;
}
// Fallback to manual prefetching if orchestrator not available
_logger.LogWarning("LyricsOrchestrator not available for prefetch, using fallback method");
// Try Spotify lyrics if we have a valid Spotify track ID
if (_spotifyLyricsService != null && _spotifyApiSettings.Enabled && !string.IsNullOrEmpty(spotifyTrackId))
{
@@ -1513,6 +1537,22 @@ public class JellyfinController : ControllerBase
}
}
// Fall back to LyricsPlus
if (_lyricsPlusService != null)
{
var lyrics = await _lyricsPlusService.GetLyricsAsync(
searchTitle,
searchArtists.ToArray(),
searchAlbum,
song.Duration ?? 0);
if (lyrics != null)
{
_logger.LogDebug("✓ Prefetched LyricsPlus lyrics for {Artist} - {Title}", searchArtist, searchTitle);
return; // Success, lyrics are now cached
}
}
// Fall back to LRCLIB
if (_lrclibService != null)
{

View File

@@ -575,6 +575,12 @@ builder.Services.AddSingleton<allstarr.Services.Spotify.SpotifyApiClient>();
// Register Spotify lyrics service (uses Spotify's color-lyrics API)
builder.Services.AddSingleton<allstarr.Services.Lyrics.SpotifyLyricsService>();
// Register LyricsPlus service (multi-source lyrics API)
builder.Services.AddSingleton<allstarr.Services.Lyrics.LyricsPlusService>();
// Register Lyrics Orchestrator (manages priority-based lyrics fetching)
builder.Services.AddSingleton<allstarr.Services.Lyrics.LyricsOrchestrator>();
// Register Spotify playlist fetcher (uses direct Spotify API when SpotifyApi is enabled)
builder.Services.AddSingleton<allstarr.Services.Spotify.SpotifyPlaylistFetcher>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<allstarr.Services.Spotify.SpotifyPlaylistFetcher>());

View File

@@ -0,0 +1,228 @@
using allstarr.Models.Lyrics;
using allstarr.Models.Settings;
using Microsoft.Extensions.Options;
namespace allstarr.Services.Lyrics;
/// <summary>
/// Orchestrates lyrics fetching from multiple sources with priority-based fallback.
/// Priority order: Spotify → LyricsPlus → LRCLib
/// Note: Jellyfin local lyrics are handled by the controller before calling this orchestrator.
/// </summary>
public class LyricsOrchestrator
{
private readonly SpotifyLyricsService _spotifyLyrics;
private readonly LyricsPlusService _lyricsPlus;
private readonly LrclibService _lrclib;
private readonly SpotifyApiSettings _spotifySettings;
private readonly ILogger<LyricsOrchestrator> _logger;
public LyricsOrchestrator(
SpotifyLyricsService spotifyLyrics,
LyricsPlusService lyricsPlus,
LrclibService lrclib,
IOptions<SpotifyApiSettings> spotifySettings,
ILogger<LyricsOrchestrator> logger)
{
_spotifyLyrics = spotifyLyrics;
_lyricsPlus = lyricsPlus;
_lrclib = lrclib;
_spotifySettings = spotifySettings.Value;
_logger = logger;
}
/// <summary>
/// Fetches lyrics with automatic fallback through all available sources.
/// Note: Jellyfin local lyrics are handled by the controller before calling this.
/// </summary>
/// <param name="trackName">Track title</param>
/// <param name="artistNames">Artist names (can be multiple)</param>
/// <param name="albumName">Album name</param>
/// <param name="durationSeconds">Track duration in seconds</param>
/// <param name="spotifyTrackId">Spotify track ID (if available)</param>
/// <returns>Lyrics info or null if not found</returns>
public async Task<LyricsInfo?> GetLyricsAsync(
string trackName,
string[] artistNames,
string? albumName,
int durationSeconds,
string? spotifyTrackId = null)
{
var artistName = string.Join(", ", artistNames);
_logger.LogInformation("🎵 Fetching lyrics for: {Artist} - {Track}", artistName, trackName);
// 1. Try Spotify lyrics (if Spotify ID provided)
if (!string.IsNullOrEmpty(spotifyTrackId))
{
var spotifyLyrics = await TrySpotifyLyrics(spotifyTrackId, artistName, trackName);
if (spotifyLyrics != null)
{
return spotifyLyrics;
}
}
// 2. Try LyricsPlus
var lyricsPlusLyrics = await TryLyricsPlusLyrics(trackName, artistNames, albumName, durationSeconds, artistName);
if (lyricsPlusLyrics != null)
{
return lyricsPlusLyrics;
}
// 3. Try LRCLib
var lrclibLyrics = await TryLrclibLyrics(trackName, artistNames, albumName, durationSeconds, artistName);
if (lrclibLyrics != null)
{
return lrclibLyrics;
}
_logger.LogInformation("❌ No lyrics found for: {Artist} - {Track}", artistName, trackName);
return null;
}
/// <summary>
/// Prefetches lyrics in the background (for cache warming).
/// Skips Jellyfin local since we don't have an itemId.
/// </summary>
public async Task<bool> PrefetchLyricsAsync(
string trackName,
string[] artistNames,
string? albumName,
int durationSeconds,
string? spotifyTrackId = null)
{
var artistName = string.Join(", ", artistNames);
_logger.LogDebug("🎵 Prefetching lyrics for: {Artist} - {Track}", artistName, trackName);
// 1. Try Spotify lyrics (if Spotify ID provided)
if (!string.IsNullOrEmpty(spotifyTrackId))
{
var spotifyLyrics = await TrySpotifyLyrics(spotifyTrackId, artistName, trackName);
if (spotifyLyrics != null)
{
return true;
}
}
// 2. Try LyricsPlus
var lyricsPlusLyrics = await TryLyricsPlusLyrics(trackName, artistNames, albumName, durationSeconds, artistName);
if (lyricsPlusLyrics != null)
{
return true;
}
// 3. Try LRCLib
var lrclibLyrics = await TryLrclibLyrics(trackName, artistNames, albumName, durationSeconds, artistName);
if (lrclibLyrics != null)
{
return true;
}
_logger.LogDebug("No lyrics found for prefetch: {Artist} - {Track}", artistName, trackName);
return false;
}
#region Private Helper Methods
private async Task<LyricsInfo?> TrySpotifyLyrics(string spotifyTrackId, string artistName, string trackName)
{
if (!_spotifySettings.Enabled)
{
_logger.LogDebug("Spotify API not enabled, skipping Spotify lyrics");
return null;
}
try
{
// Validate Spotify ID format
var cleanSpotifyId = spotifyTrackId.Replace("spotify:track:", "").Trim();
if (cleanSpotifyId.Length != 22 || cleanSpotifyId.Contains(":") || cleanSpotifyId.Contains("local"))
{
_logger.LogDebug("Invalid Spotify ID format: {SpotifyId}, skipping", spotifyTrackId);
return null;
}
_logger.LogDebug("→ Trying Spotify lyrics for track ID: {SpotifyId}", cleanSpotifyId);
var spotifyLyrics = await _spotifyLyrics.GetLyricsByTrackIdAsync(cleanSpotifyId);
if (spotifyLyrics != null && spotifyLyrics.Lines.Count > 0)
{
_logger.LogInformation("✓ Found Spotify lyrics for {Artist} - {Track} ({LineCount} lines, type: {SyncType})",
artistName, trackName, spotifyLyrics.Lines.Count, spotifyLyrics.SyncType);
return _spotifyLyrics.ToLyricsInfo(spotifyLyrics);
}
_logger.LogDebug("No Spotify lyrics found for track ID {SpotifyId}", cleanSpotifyId);
return null;
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Error fetching Spotify lyrics for track ID {SpotifyId}", spotifyTrackId);
return null;
}
}
private async Task<LyricsInfo?> TryLyricsPlusLyrics(
string trackName,
string[] artistNames,
string? albumName,
int durationSeconds,
string artistName)
{
try
{
_logger.LogDebug("→ Trying LyricsPlus for: {Artist} - {Track}", artistName, trackName);
var lyrics = await _lyricsPlus.GetLyricsAsync(trackName, artistNames, albumName, durationSeconds);
if (lyrics != null)
{
_logger.LogInformation("✓ Found LyricsPlus lyrics for {Artist} - {Track}", artistName, trackName);
return lyrics;
}
_logger.LogDebug("No LyricsPlus lyrics found for {Artist} - {Track}", artistName, trackName);
return null;
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Error fetching LyricsPlus lyrics for {Artist} - {Track}", artistName, trackName);
return null;
}
}
private async Task<LyricsInfo?> TryLrclibLyrics(
string trackName,
string[] artistNames,
string? albumName,
int durationSeconds,
string artistName)
{
try
{
_logger.LogDebug("→ Trying LRCLib for: {Artist} - {Track}", artistName, trackName);
var lyrics = await _lrclib.GetLyricsAsync(trackName, artistNames, albumName ?? string.Empty, durationSeconds);
if (lyrics != null)
{
_logger.LogInformation("✓ Found LRCLib lyrics for {Artist} - {Track}", artistName, trackName);
return lyrics;
}
_logger.LogDebug("No LRCLib lyrics found for {Artist} - {Track}", artistName, trackName);
return null;
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Error fetching LRCLib lyrics for {Artist} - {Track}", artistName, trackName);
return null;
}
}
#endregion
}

View File

@@ -0,0 +1,254 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using allstarr.Models.Lyrics;
using allstarr.Services.Common;
namespace allstarr.Services.Lyrics;
/// <summary>
/// Service for fetching lyrics from LyricsPlus API (https://lyricsplus.prjktla.workers.dev)
/// Supports multiple sources: Apple Music, Spotify, Musixmatch, and more
/// </summary>
public class LyricsPlusService
{
private readonly HttpClient _httpClient;
private readonly RedisCacheService _cache;
private readonly ILogger<LyricsPlusService> _logger;
private const string BaseUrl = "https://lyricsplus.prjktla.workers.dev/v2/lyrics/get";
public LyricsPlusService(
IHttpClientFactory httpClientFactory,
RedisCacheService cache,
ILogger<LyricsPlusService> logger)
{
_httpClient = httpClientFactory.CreateClient();
_httpClient.DefaultRequestHeaders.Add("User-Agent", "Allstarr/1.0.0 (https://github.com/SoPat712/allstarr)");
_cache = cache;
_logger = logger;
}
public async Task<LyricsInfo?> GetLyricsAsync(string trackName, string artistName, string? albumName, int durationSeconds)
{
return await GetLyricsAsync(trackName, new[] { artistName }, albumName, durationSeconds);
}
public async Task<LyricsInfo?> GetLyricsAsync(string trackName, string[] artistNames, string? albumName, int durationSeconds)
{
// Validate input parameters
if (string.IsNullOrWhiteSpace(trackName) || artistNames == null || artistNames.Length == 0)
{
_logger.LogDebug("Invalid parameters for LyricsPlus search: trackName={TrackName}, artistCount={ArtistCount}",
trackName, artistNames?.Length ?? 0);
return null;
}
var artistName = string.Join(", ", artistNames);
var cacheKey = $"lyricsplus:{artistName}:{trackName}:{albumName}:{durationSeconds}";
// Check cache
var cached = await _cache.GetStringAsync(cacheKey);
if (!string.IsNullOrEmpty(cached))
{
try
{
return JsonSerializer.Deserialize<LyricsInfo>(cached, JsonOptions);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to deserialize cached LyricsPlus lyrics");
}
}
try
{
// Build URL with query parameters
var url = $"{BaseUrl}?title={Uri.EscapeDataString(trackName)}&artist={Uri.EscapeDataString(artistName)}";
if (!string.IsNullOrEmpty(albumName))
{
url += $"&album={Uri.EscapeDataString(albumName)}";
}
if (durationSeconds > 0)
{
url += $"&duration={durationSeconds}";
}
// Add sources: apple, lyricsplus, musixmatch, spotify, musixmatch-word
url += "&source=apple,lyricsplus,musixmatch,spotify,musixmatch-word";
_logger.LogDebug("Fetching lyrics from LyricsPlus: {Url}", url);
var response = await _httpClient.GetAsync(url);
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
_logger.LogDebug("Lyrics not found on LyricsPlus for {Artist} - {Track}", artistName, trackName);
return null;
}
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync();
var lyricsResponse = JsonSerializer.Deserialize<LyricsPlusResponse>(json, JsonOptions);
if (lyricsResponse == null || lyricsResponse.Lyrics == null || lyricsResponse.Lyrics.Count == 0)
{
_logger.LogDebug("Empty lyrics response from LyricsPlus for {Artist} - {Track}", artistName, trackName);
return null;
}
// Convert to LyricsInfo format
var result = ConvertToLyricsInfo(lyricsResponse, trackName, artistName, albumName, durationSeconds);
if (result != null)
{
await _cache.SetStringAsync(cacheKey, JsonSerializer.Serialize(result, JsonOptions), TimeSpan.FromDays(30));
_logger.LogInformation("✓ Retrieved lyrics from LyricsPlus for {Artist} - {Track} (type: {Type}, source: {Source})",
artistName, trackName, lyricsResponse.Type, lyricsResponse.Metadata?.Source);
}
return result;
}
catch (HttpRequestException ex)
{
_logger.LogWarning(ex, "Failed to fetch lyrics from LyricsPlus for {Artist} - {Track}", artistName, trackName);
return null;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching lyrics from LyricsPlus for {Artist} - {Track}", artistName, trackName);
return null;
}
}
private LyricsInfo? ConvertToLyricsInfo(LyricsPlusResponse response, string trackName, string artistName, string? albumName, int durationSeconds)
{
if (response.Lyrics == null || response.Lyrics.Count == 0)
{
return null;
}
string? syncedLyrics = null;
string? plainLyrics = null;
// Convert based on type
if (response.Type == "Word")
{
// Word-level timing - convert to line-level LRC
syncedLyrics = ConvertWordTimingToLrc(response.Lyrics);
plainLyrics = string.Join("\n", response.Lyrics.Select(l => l.Text));
}
else if (response.Type == "Line")
{
// Line-level timing - convert to LRC
syncedLyrics = ConvertLineTimingToLrc(response.Lyrics);
plainLyrics = string.Join("\n", response.Lyrics.Select(l => l.Text));
}
else
{
// Static or unknown type - just plain text
plainLyrics = string.Join("\n", response.Lyrics.Select(l => l.Text));
}
return new LyricsInfo
{
TrackName = trackName,
ArtistName = artistName,
AlbumName = albumName ?? string.Empty,
Duration = durationSeconds,
Instrumental = false,
PlainLyrics = plainLyrics,
SyncedLyrics = syncedLyrics
};
}
private string ConvertLineTimingToLrc(List<LyricsPlusLine> lines)
{
var lrcLines = new List<string>();
foreach (var line in lines)
{
if (line.Time.HasValue)
{
var timestamp = TimeSpan.FromMilliseconds(line.Time.Value);
var mm = (int)timestamp.TotalMinutes;
var ss = timestamp.Seconds;
var cs = timestamp.Milliseconds / 10; // Convert to centiseconds
lrcLines.Add($"[{mm:D2}:{ss:D2}.{cs:D2}]{line.Text}");
}
else
{
// No timing, just add the text
lrcLines.Add(line.Text);
}
}
return string.Join("\n", lrcLines);
}
private string ConvertWordTimingToLrc(List<LyricsPlusLine> lines)
{
// For word-level timing, we use the line start time
// (word-level detail is in syllabus array but we simplify to line-level for LRC)
return ConvertLineTimingToLrc(lines);
}
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
private class LyricsPlusResponse
{
[JsonPropertyName("type")]
public string Type { get; set; } = string.Empty; // "Word", "Line", or "Static"
[JsonPropertyName("metadata")]
public LyricsPlusMetadata? Metadata { get; set; }
[JsonPropertyName("lyrics")]
public List<LyricsPlusLine> Lyrics { get; set; } = new();
}
private class LyricsPlusMetadata
{
[JsonPropertyName("source")]
public string? Source { get; set; }
[JsonPropertyName("title")]
public string? Title { get; set; }
[JsonPropertyName("language")]
public string? Language { get; set; }
}
private class LyricsPlusLine
{
[JsonPropertyName("time")]
public long? Time { get; set; } // Milliseconds
[JsonPropertyName("duration")]
public long? Duration { get; set; }
[JsonPropertyName("text")]
public string Text { get; set; } = string.Empty;
[JsonPropertyName("syllabus")]
public List<LyricsPlusSyllable>? Syllabus { get; set; }
}
private class LyricsPlusSyllable
{
[JsonPropertyName("time")]
public long Time { get; set; }
[JsonPropertyName("duration")]
public long Duration { get; set; }
[JsonPropertyName("text")]
public string Text { get; set; } = string.Empty;
}
}