diff --git a/allstarr/Controllers/JellyfinController.cs b/allstarr/Controllers/JellyfinController.cs index ec2e974..e7dfb1d 100644 --- a/allstarr/Controllers/JellyfinController.cs +++ b/allstarr/Controllers/JellyfinController.cs @@ -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; - // 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)) + if (_lyricsOrchestrator != null) { - // 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); - } + lyrics = await _lyricsOrchestrator.GetLyricsAsync( + trackName: searchTitle, + artistNames: searchArtists.ToArray(), + albumName: searchAlbum, + durationSeconds: song.Duration ?? 0, + spotifyTrackId: spotifyTrackId); } - - // Fall back to LRCLIB if no Spotify lyrics - if (lyrics == null) + else { - _logger.LogInformation("Searching LRCLIB for lyrics: {Artists} - {Title}", - string.Join(", ", searchArtists), - searchTitle); - var lrclibService = HttpContext.RequestServices.GetService(); - if (lrclibService != null) + // 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 + if (_spotifyLyricsService != null && _spotifyApiSettings.Enabled && !string.IsNullOrEmpty(spotifyTrackId)) { - lyrics = await lrclibService.GetLyricsAsync( + var cleanSpotifyId = spotifyTrackId.Replace("spotify:track:", "").Trim(); + + if (cleanSpotifyId.Length == 22 && !cleanSpotifyId.Contains(":") && !cleanSpotifyId.Contains("local")) + { + var spotifyLyrics = await _spotifyLyricsService.GetLyricsByTrackIdAsync(cleanSpotifyId); + + if (spotifyLyrics != null && spotifyLyrics.Lines.Count > 0) + { + lyrics = _spotifyLyricsService.ToLyricsInfo(spotifyLyrics); + } + } + } + + // Fall back to LyricsPlus + if (lyrics == null && _lyricsPlusService != 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( 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) { diff --git a/allstarr/Program.cs b/allstarr/Program.cs index 15acbc3..40faf39 100644 --- a/allstarr/Program.cs +++ b/allstarr/Program.cs @@ -575,6 +575,12 @@ builder.Services.AddSingleton(); // Register Spotify lyrics service (uses Spotify's color-lyrics API) builder.Services.AddSingleton(); +// Register LyricsPlus service (multi-source lyrics API) +builder.Services.AddSingleton(); + +// Register Lyrics Orchestrator (manages priority-based lyrics fetching) +builder.Services.AddSingleton(); + // Register Spotify playlist fetcher (uses direct Spotify API when SpotifyApi is enabled) builder.Services.AddSingleton(); builder.Services.AddHostedService(sp => sp.GetRequiredService()); diff --git a/allstarr/Services/Lyrics/LyricsOrchestrator.cs b/allstarr/Services/Lyrics/LyricsOrchestrator.cs new file mode 100644 index 0000000..2a13045 --- /dev/null +++ b/allstarr/Services/Lyrics/LyricsOrchestrator.cs @@ -0,0 +1,228 @@ +using allstarr.Models.Lyrics; +using allstarr.Models.Settings; +using Microsoft.Extensions.Options; + +namespace allstarr.Services.Lyrics; + +/// +/// 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. +/// +public class LyricsOrchestrator +{ + private readonly SpotifyLyricsService _spotifyLyrics; + private readonly LyricsPlusService _lyricsPlus; + private readonly LrclibService _lrclib; + private readonly SpotifyApiSettings _spotifySettings; + private readonly ILogger _logger; + + public LyricsOrchestrator( + SpotifyLyricsService spotifyLyrics, + LyricsPlusService lyricsPlus, + LrclibService lrclib, + IOptions spotifySettings, + ILogger logger) + { + _spotifyLyrics = spotifyLyrics; + _lyricsPlus = lyricsPlus; + _lrclib = lrclib; + _spotifySettings = spotifySettings.Value; + _logger = logger; + } + + /// + /// Fetches lyrics with automatic fallback through all available sources. + /// Note: Jellyfin local lyrics are handled by the controller before calling this. + /// + /// Track title + /// Artist names (can be multiple) + /// Album name + /// Track duration in seconds + /// Spotify track ID (if available) + /// Lyrics info or null if not found + public async Task 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; + } + + /// + /// Prefetches lyrics in the background (for cache warming). + /// Skips Jellyfin local since we don't have an itemId. + /// + public async Task 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 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 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 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 +} diff --git a/allstarr/Services/Lyrics/LyricsPlusService.cs b/allstarr/Services/Lyrics/LyricsPlusService.cs new file mode 100644 index 0000000..4460b25 --- /dev/null +++ b/allstarr/Services/Lyrics/LyricsPlusService.cs @@ -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; + +/// +/// Service for fetching lyrics from LyricsPlus API (https://lyricsplus.prjktla.workers.dev) +/// Supports multiple sources: Apple Music, Spotify, Musixmatch, and more +/// +public class LyricsPlusService +{ + private readonly HttpClient _httpClient; + private readonly RedisCacheService _cache; + private readonly ILogger _logger; + private const string BaseUrl = "https://lyricsplus.prjktla.workers.dev/v2/lyrics/get"; + + public LyricsPlusService( + IHttpClientFactory httpClientFactory, + RedisCacheService cache, + ILogger 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 GetLyricsAsync(string trackName, string artistName, string? albumName, int durationSeconds) + { + return await GetLyricsAsync(trackName, new[] { artistName }, albumName, durationSeconds); + } + + public async Task 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(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(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 lines) + { + var lrcLines = new List(); + + 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 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 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? 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; + } +}