From aebb1c14dd4893e60fd67536baa45ebba503f4be Mon Sep 17 00:00:00 2001 From: Josh Patra Date: Fri, 30 Jan 2026 01:58:10 -0500 Subject: [PATCH 1/7] Fix multiple Jellyfin proxy issues and improvements - Fix playback progress reporting by wrapping POST bodies in required field names - Fix cache cleanup by updating last access time when streaming files - Fix Artists/{id}/Similar endpoint proxying to correct Jellyfin endpoint - Add ' - SW' suffix to external albums and artists for better identification - Register SquidWTFSettings configuration to enable quality settings - Remove unused code and improve debugging logs --- allstarr/Controllers/JellyfinController.cs | 34 +++++++++++++---- allstarr/Controllers/SubSonicController.cs | 12 +++++- allstarr/Program.cs | 2 + .../Services/Jellyfin/JellyfinProxyService.cs | 37 +++++++++++++++---- .../Jellyfin/JellyfinResponseBuilder.cs | 26 ++++++++++--- .../SquidWTF/SquidWTFDownloadService.cs | 1 - 6 files changed, 89 insertions(+), 23 deletions(-) diff --git a/allstarr/Controllers/JellyfinController.cs b/allstarr/Controllers/JellyfinController.cs index b99d5bf..3c3b4bd 100644 --- a/allstarr/Controllers/JellyfinController.cs +++ b/allstarr/Controllers/JellyfinController.cs @@ -811,6 +811,16 @@ public class JellyfinController : ControllerBase if (localPath != null && System.IO.File.Exists(localPath)) { + // Update last access time for cache cleanup + try + { + System.IO.File.SetLastAccessTimeUtc(localPath, DateTime.UtcNow); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to update last access time for {Path}", localPath); + } + var stream = System.IO.File.OpenRead(localPath); return File(stream, GetContentType(localPath), enableRangeProcessing: true); } @@ -1202,7 +1212,7 @@ public class JellyfinController : ControllerBase /// [HttpGet("Items/{itemId}/Similar")] [HttpGet("Songs/{itemId}/Similar")] - [HttpGet("Artists/{artistId}/Similar")] + [HttpGet("Artists/{itemId}/Similar")] public async Task GetSimilarItems( string itemId, [FromQuery] int limit = 50, @@ -1266,7 +1276,11 @@ public class JellyfinController : ControllerBase } } - // For local items, proxy to Jellyfin + // For local items, determine the correct endpoint based on the request path + var endpoint = Request.Path.Value?.Contains("/Artists/", StringComparison.OrdinalIgnoreCase) == true + ? $"Artists/{itemId}/Similar" + : $"Items/{itemId}/Similar"; + var queryParams = new Dictionary { ["limit"] = limit.ToString() @@ -1282,7 +1296,7 @@ public class JellyfinController : ControllerBase queryParams["userId"] = userId; } - var result = await _proxyService.GetJsonAsync($"Items/{itemId}/Similar", queryParams, Request.Headers); + var result = await _proxyService.GetJsonAsync(endpoint, queryParams, Request.Headers); if (result == null) { @@ -1532,28 +1546,32 @@ public class JellyfinController : ControllerBase // Read body using StreamReader with proper encoding string body; - using (var reader = new StreamReader(Request.Body, System.Text.Encoding.UTF8, leaveOpen: true)) + using (var reader = new StreamReader(Request.Body, System.Text.Encoding.UTF8, detectEncodingFromByteOrderMarks: false, bufferSize: 1024, leaveOpen: true)) { body = await reader.ReadToEndAsync(); } - // Reset stream position after reading + // Reset stream position after reading so it can be read again if needed Request.Body.Position = 0; if (string.IsNullOrWhiteSpace(body)) { - _logger.LogWarning("Empty POST body for {Path}, ContentLength={ContentLength}, ContentType={ContentType}", + _logger.LogWarning("Empty POST body received from client for {Path}, ContentLength={ContentLength}, ContentType={ContentType}", fullPath, Request.ContentLength, Request.ContentType); + + // Log all headers to debug + _logger.LogWarning("Request headers: {Headers}", + string.Join(", ", Request.Headers.Select(h => $"{h.Key}={h.Value}"))); } else { - _logger.LogInformation("POST body for {Path}: {BodyLength} bytes, ContentType={ContentType}", + _logger.LogInformation("POST body received from client for {Path}: {BodyLength} bytes, ContentType={ContentType}", fullPath, body.Length, Request.ContentType); // Always log body content for playback endpoints to debug the issue if (fullPath.Contains("Playing", StringComparison.OrdinalIgnoreCase)) { - _logger.LogInformation("POST body content: {Body}", body); + _logger.LogInformation("POST body content from client: {Body}", body); } } diff --git a/allstarr/Controllers/SubSonicController.cs b/allstarr/Controllers/SubSonicController.cs index 97183d4..19cf510 100644 --- a/allstarr/Controllers/SubSonicController.cs +++ b/allstarr/Controllers/SubSonicController.cs @@ -64,7 +64,7 @@ public class SubsonicController : ControllerBase { return await _requestParser.ExtractAllParametersAsync(Request); } - + /// /// Merges local and external search results. /// @@ -142,6 +142,16 @@ public class SubsonicController : ControllerBase if (localPath != null && System.IO.File.Exists(localPath)) { + // Update last access time for cache cleanup + try + { + System.IO.File.SetLastAccessTimeUtc(localPath, DateTime.UtcNow); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to update last access time for {Path}", localPath); + } + var stream = System.IO.File.OpenRead(localPath); return File(stream, GetContentType(localPath), enableRangeProcessing: true); } diff --git a/allstarr/Program.cs b/allstarr/Program.cs index 8013223..382a199 100644 --- a/allstarr/Program.cs +++ b/allstarr/Program.cs @@ -71,6 +71,8 @@ builder.Services.Configure( builder.Configuration.GetSection("Deezer")); builder.Services.Configure( builder.Configuration.GetSection("Qobuz")); +builder.Services.Configure( + builder.Configuration.GetSection("SquidWTF")); builder.Services.Configure( builder.Configuration.GetSection("Redis")); diff --git a/allstarr/Services/Jellyfin/JellyfinProxyService.cs b/allstarr/Services/Jellyfin/JellyfinProxyService.cs index 447ffdb..4cbeba3 100644 --- a/allstarr/Services/Jellyfin/JellyfinProxyService.cs +++ b/allstarr/Services/Jellyfin/JellyfinProxyService.cs @@ -256,17 +256,40 @@ public class JellyfinProxyService using var request = new HttpRequestMessage(HttpMethod.Post, url); - // Create content from body string - if (!string.IsNullOrEmpty(body)) + // Handle special case for playback endpoints - Jellyfin expects wrapped body + var bodyToSend = body; + if (!string.IsNullOrWhiteSpace(body)) { - request.Content = new StringContent(body, System.Text.Encoding.UTF8, "application/json"); - _logger.LogDebug("POST body length: {Length} bytes", body.Length); + // Check if this is a playback progress endpoint + if (endpoint.Contains("Sessions/Playing/Progress", StringComparison.OrdinalIgnoreCase)) + { + // Wrap the body in playbackProgressInfo field + bodyToSend = $"{{\"playbackProgressInfo\":{body}}}"; + _logger.LogDebug("Wrapped body for playback progress endpoint"); + } + else if (endpoint.Contains("Sessions/Playing/Stopped", StringComparison.OrdinalIgnoreCase)) + { + // Wrap the body in playbackStopInfo field + bodyToSend = $"{{\"playbackStopInfo\":{body}}}"; + _logger.LogDebug("Wrapped body for playback stopped endpoint"); + } + else if (endpoint.Contains("Sessions/Playing", StringComparison.OrdinalIgnoreCase) && + !endpoint.Contains("Progress", StringComparison.OrdinalIgnoreCase) && + !endpoint.Contains("Stopped", StringComparison.OrdinalIgnoreCase)) + { + // Wrap the body in playbackStartInfo field for /Sessions/Playing + bodyToSend = $"{{\"playbackStartInfo\":{body}}}"; + _logger.LogDebug("Wrapped body for playback start endpoint"); + } } else { - _logger.LogWarning("POST body is empty for {Url}", url); + bodyToSend = "{}"; + _logger.LogWarning("POST body was empty for {Url}, sending empty JSON object", url); } + request.Content = new StringContent(bodyToSend, System.Text.Encoding.UTF8, "application/json"); + bool authHeaderAdded = false; // Forward authentication headers from client (case-insensitive) @@ -312,12 +335,12 @@ public class JellyfinProxyService } else { - _logger.LogInformation("POST to Jellyfin: {Url}, body length: {Length} bytes", url, body.Length); + _logger.LogInformation("POST to Jellyfin: {Url}, body length: {Length} bytes", url, bodyToSend.Length); // Log body content for playback endpoints to debug if (endpoint.Contains("Playing", StringComparison.OrdinalIgnoreCase)) { - _logger.LogInformation("Sending body to Jellyfin: {Body}", body); + _logger.LogInformation("Sending body to Jellyfin: {Body}", bodyToSend); } } diff --git a/allstarr/Services/Jellyfin/JellyfinResponseBuilder.cs b/allstarr/Services/Jellyfin/JellyfinResponseBuilder.cs index 7d67a06..93acc4e 100644 --- a/allstarr/Services/Jellyfin/JellyfinResponseBuilder.cs +++ b/allstarr/Services/Jellyfin/JellyfinResponseBuilder.cs @@ -304,10 +304,17 @@ public class JellyfinResponseBuilder /// public Dictionary ConvertAlbumToJellyfinItem(Album album) { + // Add " - SW" suffix to external album names + var albumName = album.Title; + if (!album.IsLocal) + { + albumName = $"{album.Title} - SW"; + } + var item = new Dictionary { ["Id"] = album.Id, - ["Name"] = album.Title, + ["Name"] = albumName, ["ServerId"] = "allstarr", ["Type"] = "MusicAlbum", ["IsFolder"] = true, @@ -328,10 +335,10 @@ public class JellyfinResponseBuilder }, ["BackdropImageTags"] = new string[0], ["ImageBlurHashes"] = new Dictionary(), - ["LocationType"] = "FileSystem", // External content appears as local files to clients - ["MediaType"] = (object?)null, // Match Jellyfin structure - ["ChannelId"] = (object?)null, // Match Jellyfin structure - ["CollectionType"] = (object?)null, // Match Jellyfin structure + ["LocationType"] = "FileSystem", + ["MediaType"] = (object?)null, + ["ChannelId"] = (object?)null, + ["CollectionType"] = (object?)null, ["UserData"] = new Dictionary { ["PlaybackPositionTicks"] = 0, @@ -364,10 +371,17 @@ public class JellyfinResponseBuilder /// public Dictionary ConvertArtistToJellyfinItem(Artist artist) { + // Add " - SW" suffix to external artist names + var artistName = artist.Name; + if (!artist.IsLocal) + { + artistName = $"{artist.Name} - SW"; + } + var item = new Dictionary { ["Id"] = artist.Id, - ["Name"] = artist.Name, + ["Name"] = artistName, ["ServerId"] = "allstarr", ["Type"] = "MusicArtist", ["IsFolder"] = true, diff --git a/allstarr/Services/SquidWTF/SquidWTFDownloadService.cs b/allstarr/Services/SquidWTF/SquidWTFDownloadService.cs index ec30a86..1b3d325 100644 --- a/allstarr/Services/SquidWTF/SquidWTFDownloadService.cs +++ b/allstarr/Services/SquidWTF/SquidWTFDownloadService.cs @@ -21,7 +21,6 @@ public class SquidWTFDownloadService : BaseDownloadService { private readonly HttpClient _httpClient; private readonly SemaphoreSlim _requestLock = new(1, 1); - private readonly string? _preferredQuality; private readonly SquidWTFSettings _squidwtfSettings; private DateTime _lastRequestTime = DateTime.MinValue; From 666aa8621a07271bf16550678f10c02c409f1ab4 Mon Sep 17 00:00:00 2001 From: Josh Patra Date: Fri, 30 Jan 2026 02:29:44 -0500 Subject: [PATCH 2/7] 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 --- allstarr/Controllers/JellyfinController.cs | 114 ++++++++- allstarr/Models/Lyrics/LyricsInfo.cs | 13 + allstarr/Program.cs | 2 + allstarr/Services/Common/RedisCacheService.cs | 18 +- allstarr/Services/Lyrics/LrclibService.cs | 224 ++++++++++++++++++ 5 files changed, 360 insertions(+), 11 deletions(-) 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..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; } + } +} From f558e4ba7f2050b2c0b44b8a822b277355754873 Mon Sep 17 00:00:00 2001 From: Josh Patra Date: Fri, 30 Jan 2026 03:00:55 -0500 Subject: [PATCH 3/7] Fix: Parse LRC format into individual lines for Feishin compatibility Feishin expects lyrics as an array of {Start, Text} objects with timestamps in ticks, not as a single LRC-formatted text block --- allstarr/Controllers/JellyfinController.cs | 51 +++++++++++++++++----- 1 file changed, 40 insertions(+), 11 deletions(-) diff --git a/allstarr/Controllers/JellyfinController.cs b/allstarr/Controllers/JellyfinController.cs index b669110..b45b055 100644 --- a/allstarr/Controllers/JellyfinController.cs +++ b/allstarr/Controllers/JellyfinController.cs @@ -1006,9 +1006,45 @@ public class JellyfinController : ControllerBase 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 + // Parse LRC format into individual lines for Jellyfin + var lyricLines = new List(); + + if (isSynced && !string.IsNullOrEmpty(lyrics.SyncedLyrics)) + { + // Parse LRC format: [mm:ss.xx] text + var lines = lyrics.SyncedLyrics.Split('\n', StringSplitOptions.RemoveEmptyEntries); + foreach (var line in lines) + { + var match = System.Text.RegularExpressions.Regex.Match(line, @"^\[(\d+):(\d+)\.(\d+)\]\s*(.*)$"); + if (match.Success) + { + var minutes = int.Parse(match.Groups[1].Value); + var seconds = int.Parse(match.Groups[2].Value); + var centiseconds = int.Parse(match.Groups[3].Value); + var text = match.Groups[4].Value; + + // Convert to ticks (100 nanoseconds) + var totalMilliseconds = (minutes * 60 + seconds) * 1000 + centiseconds * 10; + var ticks = totalMilliseconds * 10000L; + + lyricLines.Add(new + { + Start = ticks, + Text = text + }); + } + } + } + else + { + // Plain lyrics - return as single block + lyricLines.Add(new + { + Start = (long?)null, + Text = lyricsText + }); + } + var response = new { Metadata = new @@ -1019,14 +1055,7 @@ public class JellyfinController : ControllerBase Length = lyrics.Duration, IsSynced = isSynced }, - Lyrics = new[] - { - new - { - Start = (long?)null, - Text = lyricsText - } - } + Lyrics = lyricLines }; return Ok(response); From 01cd2f7711b79d9c03545013f1264dfb150735d4 Mon Sep 17 00:00:00 2001 From: Josh Patra Date: Fri, 30 Jan 2026 03:17:02 -0500 Subject: [PATCH 4/7] Update README to use 'vi' instead of 'nano' --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f85821b..c2a49fa 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Please report all bugs as soon as possible, as the Jellyfin addition is entirely ```bash # 1. Configure environment cp .env.example .env -nano .env # Edit with your settings +vi .env # Edit with your settings # 2. Start services docker-compose up -d --build From 2f43a08c46b7ca270dc5f76c77aad87fb320b6e5 Mon Sep 17 00:00:00 2001 From: Josh Patra Date: Fri, 30 Jan 2026 03:18:34 -0500 Subject: [PATCH 5/7] Revise security section for clarity and caution Updated security note to emphasize zero-trust principles. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c2a49fa..2ad0d30 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ server { } ``` -**Security:** All authentication is forwarded to Jellyfin - this is as secure as Jellyfin itself. Always use HTTPS for public access. +**Security:** Don't trust me or my code, or anyone for that matter (Zero-trust, get it?), use Tailscale or Pangolin or Cloudflare Zero-Trust or anything like it please ## Why "Allstarr"? From 4b19a58ddf0e0bae71786530f76e88751715485c Mon Sep 17 00:00:00 2001 From: Josh Patra Date: Fri, 30 Jan 2026 03:26:22 -0500 Subject: [PATCH 6/7] Add Ko-fi username for funding support --- .github/FUNDING.yml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .github/FUNDING.yml diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..e2ed725 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,15 @@ +# These are supported funding model platforms + +github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] +patreon: # Replace with a single Patreon username +open_collective: # Replace with a single Open Collective username +ko_fi: joshpatra +tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +liberapay: # Replace with a single Liberapay username +issuehunt: # Replace with a single IssueHunt username +lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry +polar: # Replace with a single Polar username +buy_me_a_coffee: # Replace with a single Buy Me a Coffee username +thanks_dev: # Replace with a single thanks.dev username +custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] From 9627831393e90cd446538108b205999db9e0ae73 Mon Sep 17 00:00:00 2001 From: phyzical <5182053+phyzical@users.noreply.github.com> Date: Fri, 30 Jan 2026 19:25:25 +0800 Subject: [PATCH 7/7] adjust to match your fork --- .github/workflows/docker.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 36c852b..e9e38c0 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -3,11 +3,11 @@ name: Docker Build & Push on: workflow_dispatch: push: - tags: ['v*'] - branches: [master, dev] + tags: ["v*"] + branches: [main, dev] pull_request: types: [closed] - branches: [master, dev] + branches: [main, dev] env: DOTNET_VERSION: "9.0.x"