mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-09 23:55:10 -05:00
Add lyrics support and fix multiple Jellyfin proxy issues
Features:
- Add LRCLIB lyrics integration with synced/plain lyrics support
- Add Redis cache logging at INFO level for visibility
- Register SquidWTFSettings to enable quality configuration
Fixes:
- Fix playback progress reporting by wrapping POST bodies correctly
- Fix cache cleanup by updating file access times on stream
- Fix Artists/{id}/Similar endpoint proxying
- Fix browse requests to pass full query string (recently added/played/etc)
- Fix nullable duration handling in lyrics endpoint
- Add ' - SW' suffix to external albums/artists
- Remove unused code and improve debugging
This commit is contained in:
@@ -9,6 +9,7 @@ using allstarr.Services.Common;
|
|||||||
using allstarr.Services.Local;
|
using allstarr.Services.Local;
|
||||||
using allstarr.Services.Jellyfin;
|
using allstarr.Services.Jellyfin;
|
||||||
using allstarr.Services.Subsonic;
|
using allstarr.Services.Subsonic;
|
||||||
|
using allstarr.Services.Lyrics;
|
||||||
|
|
||||||
namespace allstarr.Controllers;
|
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 Jellyfin returns empty results, we'll just return empty (not mixing browse with external)
|
||||||
if (string.IsNullOrWhiteSpace(searchTerm) && string.IsNullOrWhiteSpace(parentId))
|
if (string.IsNullOrWhiteSpace(searchTerm) && string.IsNullOrWhiteSpace(parentId))
|
||||||
{
|
{
|
||||||
_logger.LogDebug("No search term or parentId, proxying to Jellyfin with artistIds={ArtistIds}", artistIds);
|
_logger.LogDebug("No search term or parentId, proxying to Jellyfin with full query string");
|
||||||
var browseResult = await _proxyService.GetItemsAsync(
|
|
||||||
parentId: null,
|
// Build the full endpoint path with query string
|
||||||
includeItemTypes: ParseItemTypes(includeItemTypes),
|
var endpoint = userId != null ? $"Users/{userId}/Items" : "Items";
|
||||||
sortBy: sortBy,
|
if (Request.QueryString.HasValue)
|
||||||
limit: limit,
|
{
|
||||||
startIndex: startIndex,
|
endpoint = $"{endpoint}{Request.QueryString.Value}";
|
||||||
artistIds: artistIds,
|
}
|
||||||
clientHeaders: Request.Headers);
|
|
||||||
|
var browseResult = await _proxyService.GetJsonAsync(endpoint, null, Request.Headers);
|
||||||
|
|
||||||
if (browseResult == null)
|
if (browseResult == null)
|
||||||
{
|
{
|
||||||
@@ -938,6 +940,100 @@ public class JellyfinController : ControllerBase
|
|||||||
|
|
||||||
#endregion
|
#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 ?? 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
|
#region Favorites
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
13
allstarr/Models/Lyrics/LyricsInfo.cs
Normal file
13
allstarr/Models/Lyrics/LyricsInfo.cs
Normal 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; }
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ using allstarr.Services.Validation;
|
|||||||
using allstarr.Services.Subsonic;
|
using allstarr.Services.Subsonic;
|
||||||
using allstarr.Services.Jellyfin;
|
using allstarr.Services.Jellyfin;
|
||||||
using allstarr.Services.Common;
|
using allstarr.Services.Common;
|
||||||
|
using allstarr.Services.Lyrics;
|
||||||
using allstarr.Middleware;
|
using allstarr.Middleware;
|
||||||
using allstarr.Filters;
|
using allstarr.Filters;
|
||||||
|
|
||||||
@@ -95,6 +96,7 @@ else
|
|||||||
// Business services - shared across backends
|
// Business services - shared across backends
|
||||||
builder.Services.AddSingleton<RedisCacheService>();
|
builder.Services.AddSingleton<RedisCacheService>();
|
||||||
builder.Services.AddSingleton<ILocalLibraryService, LocalLibraryService>();
|
builder.Services.AddSingleton<ILocalLibraryService, LocalLibraryService>();
|
||||||
|
builder.Services.AddSingleton<LrclibService>();
|
||||||
|
|
||||||
// Register backend-specific services
|
// Register backend-specific services
|
||||||
if (backendType == BackendType.Jellyfin)
|
if (backendType == BackendType.Jellyfin)
|
||||||
|
|||||||
@@ -56,7 +56,16 @@ public class RedisCacheService
|
|||||||
|
|
||||||
try
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -93,7 +102,12 @@ public class RedisCacheService
|
|||||||
|
|
||||||
try
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
224
allstarr/Services/Lyrics/LrclibService.cs
Normal file
224
allstarr/Services/Lyrics/LrclibService.cs
Normal 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 = (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<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 = (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<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 = (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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user