diff --git a/allstarr/Controllers/JellyfinController.cs b/allstarr/Controllers/JellyfinController.cs
index 3c3b4bd..b669110 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;
@@ -96,15 +97,16 @@ public class JellyfinController : ControllerBase
// If Jellyfin returns empty results, we'll just return empty (not mixing browse with external)
if (string.IsNullOrWhiteSpace(searchTerm) && string.IsNullOrWhiteSpace(parentId))
{
- _logger.LogDebug("No search term or parentId, proxying to Jellyfin with artistIds={ArtistIds}", artistIds);
- var browseResult = await _proxyService.GetItemsAsync(
- parentId: null,
- includeItemTypes: ParseItemTypes(includeItemTypes),
- sortBy: sortBy,
- limit: limit,
- startIndex: startIndex,
- artistIds: artistIds,
- clientHeaders: Request.Headers);
+ _logger.LogDebug("No search term or parentId, proxying to Jellyfin with full query string");
+
+ // Build the full endpoint path with query string
+ var endpoint = userId != null ? $"Users/{userId}/Items" : "Items";
+ if (Request.QueryString.HasValue)
+ {
+ endpoint = $"{endpoint}{Request.QueryString.Value}";
+ }
+
+ var browseResult = await _proxyService.GetJsonAsync(endpoint, null, Request.Headers);
if (browseResult == null)
{
@@ -938,6 +940,100 @@ 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 ?? 0);
+
+ if (lyrics == null)
+ {
+ return NotFound(new { error = "Lyrics not found" });
+ }
+
+ // Prefer synced lyrics, fall back to plain
+ var lyricsText = lyrics.SyncedLyrics ?? lyrics.PlainLyrics ?? "";
+ var isSynced = !string.IsNullOrEmpty(lyrics.SyncedLyrics);
+
+ // Return in Jellyfin lyrics format
+ // For synced lyrics, return the LRC format directly
+ // For plain lyrics, return as a single block
+ var response = new
+ {
+ Metadata = new
+ {
+ Artist = lyrics.ArtistName,
+ Album = lyrics.AlbumName,
+ Title = lyrics.TrackName,
+ Length = lyrics.Duration,
+ IsSynced = isSynced
+ },
+ Lyrics = new[]
+ {
+ new
+ {
+ Start = (long?)null,
+ Text = lyricsText
+ }
+ }
+ };
+
+ 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/Common/RedisCacheService.cs b/allstarr/Services/Common/RedisCacheService.cs
index 922a2d5..84cc9fd 100644
--- a/allstarr/Services/Common/RedisCacheService.cs
+++ b/allstarr/Services/Common/RedisCacheService.cs
@@ -56,7 +56,16 @@ public class RedisCacheService
try
{
- return await _db!.StringGetAsync(key);
+ var value = await _db!.StringGetAsync(key);
+ if (value.HasValue)
+ {
+ _logger.LogInformation("Redis cache HIT: {Key}", key);
+ }
+ else
+ {
+ _logger.LogInformation("Redis cache MISS: {Key}", key);
+ }
+ return value;
}
catch (Exception ex)
{
@@ -93,7 +102,12 @@ public class RedisCacheService
try
{
- return await _db!.StringSetAsync(key, value, expiry);
+ var result = await _db!.StringSetAsync(key, value, expiry);
+ if (result)
+ {
+ _logger.LogInformation("Redis cache SET: {Key} (TTL: {Expiry})", key, expiry?.ToString() ?? "none");
+ }
+ return result;
}
catch (Exception ex)
{
diff --git a/allstarr/Services/Lyrics/LrclibService.cs b/allstarr/Services/Lyrics/LrclibService.cs
new file mode 100644
index 0000000..cc5e758
--- /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 = (int)Math.Round(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 = (int)Math.Round(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 = (int)Math.Round(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 double Duration { get; set; }
+ public bool Instrumental { get; set; }
+ public string? PlainLyrics { get; set; }
+ public string? SyncedLyrics { get; set; }
+ }
+}