From f8969bea8d84d68064588b23fa3c09462a2466bf Mon Sep 17 00:00:00 2001 From: Josh Patra Date: Fri, 30 Jan 2026 02:09:27 -0500 Subject: [PATCH] Add LRCLIB lyrics integration for Jellyfin - Create LrclibService to fetch lyrics from lrclib.net API - Add LyricsInfo model for lyrics data - Add /Audio/{itemId}/Lyrics and /Items/{itemId}/Lyrics endpoints - Support both local and external songs - Cache lyrics for 30 days in Redis - Return lyrics in Jellyfin format with synced/plain lyrics --- allstarr/Controllers/JellyfinController.cs | 89 ++++++++ allstarr/Models/Lyrics/LyricsInfo.cs | 13 ++ allstarr/Program.cs | 2 + allstarr/Services/Lyrics/LrclibService.cs | 224 +++++++++++++++++++++ 4 files changed, 328 insertions(+) create mode 100644 allstarr/Models/Lyrics/LyricsInfo.cs create mode 100644 allstarr/Services/Lyrics/LrclibService.cs diff --git a/allstarr/Controllers/JellyfinController.cs b/allstarr/Controllers/JellyfinController.cs index 3c3b4bd..22300c9 100644 --- a/allstarr/Controllers/JellyfinController.cs +++ b/allstarr/Controllers/JellyfinController.cs @@ -9,6 +9,7 @@ using allstarr.Services.Common; using allstarr.Services.Local; using allstarr.Services.Jellyfin; using allstarr.Services.Subsonic; +using allstarr.Services.Lyrics; namespace allstarr.Controllers; @@ -938,6 +939,94 @@ public class JellyfinController : ControllerBase #endregion + #region Lyrics + + /// + /// Gets lyrics for an item. + /// + [HttpGet("Audio/{itemId}/Lyrics")] + [HttpGet("Items/{itemId}/Lyrics")] + public async Task GetLyrics(string itemId) + { + if (string.IsNullOrWhiteSpace(itemId)) + { + return NotFound(); + } + + var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId); + + Song? song = null; + + if (isExternal) + { + song = await _metadataService.GetSongAsync(provider!, externalId!); + } + else + { + // For local songs, get metadata from Jellyfin + var item = await _proxyService.GetItemAsync(itemId, Request.Headers); + if (item != null && item.RootElement.TryGetProperty("Type", out var typeEl) && + typeEl.GetString() == "Audio") + { + song = new Song + { + Title = item.RootElement.TryGetProperty("Name", out var name) ? name.GetString() ?? "" : "", + Artist = item.RootElement.TryGetProperty("AlbumArtist", out var artist) ? artist.GetString() ?? "" : "", + Album = item.RootElement.TryGetProperty("Album", out var album) ? album.GetString() ?? "" : "", + Duration = item.RootElement.TryGetProperty("RunTimeTicks", out var ticks) ? (int)(ticks.GetInt64() / 10000000) : 0 + }; + } + } + + if (song == null) + { + return NotFound(new { error = "Song not found" }); + } + + // Try to get lyrics from LRCLIB + var lyricsService = HttpContext.RequestServices.GetService(); + if (lyricsService == null) + { + return NotFound(new { error = "Lyrics service not available" }); + } + + var lyrics = await lyricsService.GetLyricsAsync( + song.Title, + song.Artist ?? "", + song.Album ?? "", + song.Duration); + + if (lyrics == null) + { + return NotFound(new { error = "Lyrics not found" }); + } + + // Return in Jellyfin lyrics format + var response = new + { + Metadata = new + { + Artist = lyrics.ArtistName, + Album = lyrics.AlbumName, + Title = lyrics.TrackName, + Length = lyrics.Duration, + IsSynced = !string.IsNullOrEmpty(lyrics.SyncedLyrics) + }, + Lyrics = new[] + { + new + { + Start = (long?)null, + Text = lyrics.SyncedLyrics ?? lyrics.PlainLyrics ?? "" + } + } + }; + + return Ok(response); + } + + #endregion + #region Favorites /// diff --git a/allstarr/Models/Lyrics/LyricsInfo.cs b/allstarr/Models/Lyrics/LyricsInfo.cs new file mode 100644 index 0000000..d63bcd6 --- /dev/null +++ b/allstarr/Models/Lyrics/LyricsInfo.cs @@ -0,0 +1,13 @@ +namespace allstarr.Models.Lyrics; + +public class LyricsInfo +{ + public int Id { get; set; } + public string TrackName { get; set; } = string.Empty; + public string ArtistName { get; set; } = string.Empty; + public string AlbumName { get; set; } = string.Empty; + public int Duration { get; set; } + public bool Instrumental { get; set; } + public string? PlainLyrics { get; set; } + public string? SyncedLyrics { get; set; } +} diff --git a/allstarr/Program.cs b/allstarr/Program.cs index 382a199..e703f2e 100644 --- a/allstarr/Program.cs +++ b/allstarr/Program.cs @@ -8,6 +8,7 @@ using allstarr.Services.Validation; using allstarr.Services.Subsonic; using allstarr.Services.Jellyfin; using allstarr.Services.Common; +using allstarr.Services.Lyrics; using allstarr.Middleware; using allstarr.Filters; @@ -95,6 +96,7 @@ else // Business services - shared across backends builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); // Register backend-specific services if (backendType == BackendType.Jellyfin) diff --git a/allstarr/Services/Lyrics/LrclibService.cs b/allstarr/Services/Lyrics/LrclibService.cs new file mode 100644 index 0000000..eac44b3 --- /dev/null +++ b/allstarr/Services/Lyrics/LrclibService.cs @@ -0,0 +1,224 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using allstarr.Models.Lyrics; +using allstarr.Services.Common; + +namespace allstarr.Services.Lyrics; + +public class LrclibService +{ + private readonly HttpClient _httpClient; + private readonly RedisCacheService _cache; + private readonly ILogger _logger; + private const string BaseUrl = "https://lrclib.net/api"; + + public LrclibService( + 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) + { + var cacheKey = $"lyrics:{artistName}:{trackName}:{albumName}:{durationSeconds}"; + + 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 lyrics"); + } + } + + try + { + var url = $"{BaseUrl}/get?" + + $"track_name={Uri.EscapeDataString(trackName)}&" + + $"artist_name={Uri.EscapeDataString(artistName)}&" + + $"album_name={Uri.EscapeDataString(albumName)}&" + + $"duration={durationSeconds}"; + + _logger.LogDebug("Fetching lyrics from LRCLIB: {Url}", url); + + var response = await _httpClient.GetAsync(url); + + if (response.StatusCode == System.Net.HttpStatusCode.NotFound) + { + _logger.LogDebug("Lyrics not found for {Artist} - {Track}", artistName, trackName); + return null; + } + + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync(); + var lyrics = JsonSerializer.Deserialize(json, JsonOptions); + + if (lyrics == null) + { + return null; + } + + var result = new LyricsInfo + { + Id = lyrics.Id, + TrackName = lyrics.TrackName ?? trackName, + ArtistName = lyrics.ArtistName ?? artistName, + AlbumName = lyrics.AlbumName ?? albumName, + Duration = lyrics.Duration, + Instrumental = lyrics.Instrumental, + PlainLyrics = lyrics.PlainLyrics, + SyncedLyrics = lyrics.SyncedLyrics + }; + + await _cache.SetStringAsync(cacheKey, JsonSerializer.Serialize(result, JsonOptions), TimeSpan.FromDays(30)); + + _logger.LogInformation("Retrieved lyrics for {Artist} - {Track} (ID: {Id})", artistName, trackName, lyrics.Id); + + return result; + } + catch (HttpRequestException ex) + { + _logger.LogWarning(ex, "Failed to fetch lyrics from LRCLIB for {Artist} - {Track}", artistName, trackName); + return null; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error fetching lyrics for {Artist} - {Track}", artistName, trackName); + return null; + } + } + + public async Task GetLyricsCachedAsync(string trackName, string artistName, string albumName, int durationSeconds) + { + try + { + var url = $"{BaseUrl}/get-cached?" + + $"track_name={Uri.EscapeDataString(trackName)}&" + + $"artist_name={Uri.EscapeDataString(artistName)}&" + + $"album_name={Uri.EscapeDataString(albumName)}&" + + $"duration={durationSeconds}"; + + var response = await _httpClient.GetAsync(url); + + if (response.StatusCode == System.Net.HttpStatusCode.NotFound) + { + return null; + } + + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync(); + var lyrics = JsonSerializer.Deserialize(json, JsonOptions); + + if (lyrics == null) + { + return null; + } + + return new LyricsInfo + { + Id = lyrics.Id, + TrackName = lyrics.TrackName ?? trackName, + ArtistName = lyrics.ArtistName ?? artistName, + AlbumName = lyrics.AlbumName ?? albumName, + Duration = lyrics.Duration, + Instrumental = lyrics.Instrumental, + PlainLyrics = lyrics.PlainLyrics, + SyncedLyrics = lyrics.SyncedLyrics + }; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to fetch cached lyrics for {Artist} - {Track}", artistName, trackName); + return null; + } + } + + public async Task GetLyricsByIdAsync(int id) + { + var cacheKey = $"lyrics:id:{id}"; + + 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 lyrics"); + } + } + + try + { + var url = $"{BaseUrl}/get/{id}"; + var response = await _httpClient.GetAsync(url); + + if (response.StatusCode == System.Net.HttpStatusCode.NotFound) + { + return null; + } + + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync(); + var lyrics = JsonSerializer.Deserialize(json, JsonOptions); + + if (lyrics == null) + { + return null; + } + + var result = new LyricsInfo + { + Id = lyrics.Id, + TrackName = lyrics.TrackName ?? string.Empty, + ArtistName = lyrics.ArtistName ?? string.Empty, + AlbumName = lyrics.AlbumName ?? string.Empty, + Duration = lyrics.Duration, + Instrumental = lyrics.Instrumental, + PlainLyrics = lyrics.PlainLyrics, + SyncedLyrics = lyrics.SyncedLyrics + }; + + await _cache.SetStringAsync(cacheKey, JsonSerializer.Serialize(result, JsonOptions), TimeSpan.FromDays(30)); + + return result; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error fetching lyrics by ID {Id}", id); + return null; + } + } + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + private class LrclibResponse + { + public int Id { get; set; } + public string? TrackName { get; set; } + public string? ArtistName { get; set; } + public string? AlbumName { get; set; } + public int Duration { get; set; } + public bool Instrumental { get; set; } + public string? PlainLyrics { get; set; } + public string? SyncedLyrics { get; set; } + } +}