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
This commit is contained in:
2026-01-30 02:09:27 -05:00
parent ceaa17f018
commit f8969bea8d
4 changed files with 328 additions and 0 deletions

View File

@@ -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
/// <summary>
/// Gets lyrics for an item.
/// </summary>
[HttpGet("Audio/{itemId}/Lyrics")]
[HttpGet("Items/{itemId}/Lyrics")]
public async Task<IActionResult> 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<LrclibService>();
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
/// <summary>

View File

@@ -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; }
}

View File

@@ -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<RedisCacheService>();
builder.Services.AddSingleton<ILocalLibraryService, LocalLibraryService>();
builder.Services.AddSingleton<LrclibService>();
// Register backend-specific services
if (backendType == BackendType.Jellyfin)

View File

@@ -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<LrclibService> _logger;
private const string BaseUrl = "https://lrclib.net/api";
public LrclibService(
IHttpClientFactory httpClientFactory,
RedisCacheService cache,
ILogger<LrclibService> 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)
{
var cacheKey = $"lyrics:{artistName}:{trackName}:{albumName}:{durationSeconds}";
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 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<LrclibResponse>(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<LyricsInfo?> 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<LrclibResponse>(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<LyricsInfo?> GetLyricsByIdAsync(int id)
{
var cacheKey = $"lyrics:id:{id}";
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 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<LrclibResponse>(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; }
}
}