Fix unsynced lyrics and favorite endpoints

- Fix unsynced lyrics showing all at 0:00 by omitting Start field
- Add DeleteAsync to proxy service for proper auth forwarding
- Fix favorite/unfavorite endpoints to use proxy service
- Add comprehensive logging for debugging
This commit is contained in:
2026-02-01 18:24:09 -05:00
parent 0011538966
commit 1326f1b3ab
3 changed files with 171 additions and 72 deletions

View File

@@ -1033,15 +1033,21 @@ public class JellyfinController : ControllerBase
var lyricsText = lyrics.SyncedLyrics ?? lyrics.PlainLyrics ?? ""; var lyricsText = lyrics.SyncedLyrics ?? lyrics.PlainLyrics ?? "";
var isSynced = !string.IsNullOrEmpty(lyrics.SyncedLyrics); var isSynced = !string.IsNullOrEmpty(lyrics.SyncedLyrics);
_logger.LogInformation("Lyrics for {Artist} - {Track}: synced={HasSynced}, plainLength={PlainLen}, syncedLength={SyncLen}",
song.Artist, song.Title, isSynced, lyrics.PlainLyrics?.Length ?? 0, lyrics.SyncedLyrics?.Length ?? 0);
// Parse LRC format into individual lines for Jellyfin // Parse LRC format into individual lines for Jellyfin
var lyricLines = new List<object>(); var lyricLines = new List<Dictionary<string, object>>();
if (isSynced && !string.IsNullOrEmpty(lyrics.SyncedLyrics)) if (isSynced && !string.IsNullOrEmpty(lyrics.SyncedLyrics))
{ {
_logger.LogInformation("Parsing synced lyrics (LRC format)");
// Parse LRC format: [mm:ss.xx] text // Parse LRC format: [mm:ss.xx] text
// Skip ID tags like [ar:Artist], [ti:Title], etc.
var lines = lyrics.SyncedLyrics.Split('\n', StringSplitOptions.RemoveEmptyEntries); var lines = lyrics.SyncedLyrics.Split('\n', StringSplitOptions.RemoveEmptyEntries);
foreach (var line in lines) foreach (var line in lines)
{ {
// Match timestamp format [mm:ss.xx] or [mm:ss.xxx]
var match = System.Text.RegularExpressions.Regex.Match(line, @"^\[(\d+):(\d+)\.(\d+)\]\s*(.*)$"); var match = System.Text.RegularExpressions.Regex.Match(line, @"^\[(\d+):(\d+)\.(\d+)\]\s*(.*)$");
if (match.Success) if (match.Success)
{ {
@@ -1054,34 +1060,40 @@ public class JellyfinController : ControllerBase
var totalMilliseconds = (minutes * 60 + seconds) * 1000 + centiseconds * 10; var totalMilliseconds = (minutes * 60 + seconds) * 1000 + centiseconds * 10;
var ticks = totalMilliseconds * 10000L; var ticks = totalMilliseconds * 10000L;
lyricLines.Add(new // For synced lyrics, include Start timestamp
lyricLines.Add(new Dictionary<string, object>
{ {
Start = ticks, ["Text"] = text,
Text = text ["Start"] = ticks
}); });
} }
// Skip ID tags like [ar:Artist], [ti:Title], [length:2:23], etc.
} }
_logger.LogInformation("Parsed {Count} synced lyric lines (skipped ID tags)", lyricLines.Count);
} }
else if (!string.IsNullOrEmpty(lyricsText)) else if (!string.IsNullOrEmpty(lyricsText))
{ {
_logger.LogInformation("Splitting plain lyrics into lines (no timestamps)");
// Plain lyrics - split by newlines and return each line separately // Plain lyrics - split by newlines and return each line separately
// IMPORTANT: Do NOT include "Start" field at all for unsynced lyrics
// Including it (even as null) causes clients to treat it as synced with timestamp 0:00
var lines = lyricsText.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries); var lines = lyricsText.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries);
foreach (var line in lines) foreach (var line in lines)
{ {
lyricLines.Add(new lyricLines.Add(new Dictionary<string, object>
{ {
Start = (long?)null, ["Text"] = line.Trim()
Text = line.Trim()
}); });
} }
_logger.LogInformation("Split into {Count} plain lyric lines", lyricLines.Count);
} }
else else
{ {
_logger.LogWarning("No lyrics text available");
// No lyrics at all // No lyrics at all
lyricLines.Add(new lyricLines.Add(new Dictionary<string, object>
{ {
Start = (long?)null, ["Text"] = ""
Text = ""
}); });
} }
@@ -1098,6 +1110,17 @@ public class JellyfinController : ControllerBase
Lyrics = lyricLines Lyrics = lyricLines
}; };
_logger.LogInformation("Returning lyrics response: {LineCount} lines, synced={IsSynced}", lyricLines.Count, isSynced);
// Log a sample of the response for debugging
if (lyricLines.Count > 0)
{
var sampleLine = lyricLines[0];
var hasStart = sampleLine.ContainsKey("Start");
_logger.LogInformation("Sample line: Text='{Text}', HasStart={HasStart}",
sampleLine.GetValueOrDefault("Text"), hasStart);
}
return Ok(response); return Ok(response);
} }
@@ -1111,6 +1134,8 @@ public class JellyfinController : ControllerBase
[HttpPost("Users/{userId}/FavoriteItems/{itemId}")] [HttpPost("Users/{userId}/FavoriteItems/{itemId}")]
public async Task<IActionResult> MarkFavorite(string userId, string itemId) public async Task<IActionResult> MarkFavorite(string userId, string itemId)
{ {
_logger.LogInformation("MarkFavorite called: userId={UserId}, itemId={ItemId}", userId, itemId);
// Check if this is an external playlist - trigger download // Check if this is an external playlist - trigger download
if (PlaylistIdHelper.IsExternalPlaylist(itemId)) if (PlaylistIdHelper.IsExternalPlaylist(itemId))
{ {
@@ -1134,7 +1159,12 @@ public class JellyfinController : ControllerBase
} }
}); });
return Ok(new { IsFavorite = true }); // Return a minimal UserItemDataDto response
return Ok(new
{
IsFavorite = true,
ItemId = itemId
});
} }
// Check if this is an external song/album // Check if this is an external song/album
@@ -1156,41 +1186,27 @@ public class JellyfinController : ControllerBase
} }
}); });
return Ok(new { IsFavorite = true }); // Return a minimal UserItemDataDto response
return Ok(new
{
IsFavorite = true,
ItemId = itemId
});
} }
// For local Jellyfin items, proxy the request through // For local Jellyfin items, proxy the request through
var endpoint = $"Users/{userId}/FavoriteItems/{itemId}"; var endpoint = $"Users/{userId}/FavoriteItems/{itemId}";
_logger.LogInformation("Proxying favorite request to Jellyfin: {Endpoint}", endpoint);
try var result = await _proxyService.PostJsonAsync(endpoint, "{}", Request.Headers);
{
using var request = new HttpRequestMessage(HttpMethod.Post, $"{_settings.Url?.TrimEnd('/')}/{endpoint}");
// Forward client authentication if (result == null)
if (Request.Headers.TryGetValue("X-Emby-Authorization", out var embyAuth))
{ {
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", embyAuth.ToString()); _logger.LogWarning("Failed to favorite item in Jellyfin - proxy returned null");
} return StatusCode(500, new { error = "Failed to mark favorite" });
else if (Request.Headers.TryGetValue("Authorization", out var auth))
{
request.Headers.TryAddWithoutValidation("Authorization", auth.ToString());
} }
var response = await _proxyService.HttpClient.SendAsync(request); return new JsonResult(JsonSerializer.Deserialize<object>(result.RootElement.GetRawText()));
if (response.IsSuccessStatusCode)
{
return Ok(new { IsFavorite = true });
}
_logger.LogWarning("Failed to favorite item in Jellyfin: {StatusCode}", response.StatusCode);
return _responseBuilder.CreateError((int)response.StatusCode, "Failed to mark favorite");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error favoriting item {ItemId}", itemId);
return _responseBuilder.CreateError(500, "Failed to mark favorite");
}
} }
/// <summary> /// <summary>
@@ -1199,44 +1215,38 @@ public class JellyfinController : ControllerBase
[HttpDelete("Users/{userId}/FavoriteItems/{itemId}")] [HttpDelete("Users/{userId}/FavoriteItems/{itemId}")]
public async Task<IActionResult> UnmarkFavorite(string userId, string itemId) public async Task<IActionResult> UnmarkFavorite(string userId, string itemId)
{ {
// External items can't be unfavorited _logger.LogInformation("UnmarkFavorite called: userId={UserId}, itemId={ItemId}", userId, itemId);
// External items can't be unfavorited (they're not really favorited in Jellyfin)
var (isExternal, _, _) = _localLibraryService.ParseSongId(itemId); var (isExternal, _, _) = _localLibraryService.ParseSongId(itemId);
if (isExternal || PlaylistIdHelper.IsExternalPlaylist(itemId)) if (isExternal || PlaylistIdHelper.IsExternalPlaylist(itemId))
{ {
return Ok(new { IsFavorite = false }); _logger.LogInformation("Unfavoriting external item {ItemId} - returning success", itemId);
return Ok(new
{
IsFavorite = false,
ItemId = itemId
});
} }
// Proxy to Jellyfin to unfavorite // Proxy to Jellyfin to unfavorite
var url = $"Users/{userId}/FavoriteItems/{itemId}"; var endpoint = $"Users/{userId}/FavoriteItems/{itemId}";
_logger.LogInformation("Proxying unfavorite request to Jellyfin: {Endpoint}", endpoint);
try var result = await _proxyService.DeleteAsync(endpoint, Request.Headers);
{
using var request = new HttpRequestMessage(HttpMethod.Delete, $"{_settings.Url?.TrimEnd('/')}/{url}");
// Forward client authentication if (result == null)
if (Request.Headers.TryGetValue("X-Emby-Authorization", out var embyAuth))
{ {
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", embyAuth.ToString()); // DELETE often returns 204 No Content, which is success
} _logger.LogInformation("Unfavorite succeeded (no content returned)");
else if (Request.Headers.TryGetValue("Authorization", out var auth)) return Ok(new
{ {
request.Headers.TryAddWithoutValidation("Authorization", auth.ToString()); IsFavorite = false,
ItemId = itemId
});
} }
var response = await _proxyService.HttpClient.SendAsync(request); return new JsonResult(JsonSerializer.Deserialize<object>(result.RootElement.GetRawText()));
if (response.IsSuccessStatusCode)
{
return Ok(new { IsFavorite = false });
}
return _responseBuilder.CreateError(500, "Failed to unfavorite item");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error unfavoriting item {ItemId}", itemId);
return _responseBuilder.CreateError(500, "Failed to unfavorite item");
}
} }
#endregion #endregion

View File

@@ -398,6 +398,95 @@ public class JellyfinProxyService
return (body, contentType); return (body, contentType);
} }
/// <summary>
/// Sends a DELETE request to the Jellyfin server.
/// Forwards client headers for authentication passthrough.
/// </summary>
public async Task<JsonDocument?> DeleteAsync(string endpoint, IHeaderDictionary clientHeaders)
{
var url = BuildUrl(endpoint, null);
using var request = new HttpRequestMessage(HttpMethod.Delete, url);
bool authHeaderAdded = false;
// Forward authentication headers from client (case-insensitive)
foreach (var header in clientHeaders)
{
if (header.Key.Equals("X-Emby-Authorization", StringComparison.OrdinalIgnoreCase))
{
var headerValue = header.Value.ToString();
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue);
authHeaderAdded = true;
_logger.LogDebug("Forwarded X-Emby-Authorization from client");
break;
}
}
if (!authHeaderAdded)
{
foreach (var header in clientHeaders)
{
if (header.Key.Equals("Authorization", StringComparison.OrdinalIgnoreCase))
{
var headerValue = header.Value.ToString();
// Check if it's MediaBrowser/Jellyfin format
if (headerValue.Contains("MediaBrowser", StringComparison.OrdinalIgnoreCase) ||
headerValue.Contains("Client=", StringComparison.OrdinalIgnoreCase))
{
// Forward as X-Emby-Authorization
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue);
_logger.LogDebug("Converted Authorization to X-Emby-Authorization");
}
else
{
// Standard Bearer token
request.Headers.TryAddWithoutValidation("Authorization", headerValue);
_logger.LogDebug("Forwarded Authorization header");
}
authHeaderAdded = true;
break;
}
}
}
if (!authHeaderAdded)
{
_logger.LogInformation("No client auth provided for DELETE {Url} - forwarding without auth", url);
}
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
_logger.LogInformation("DELETE to Jellyfin: {Url}", url);
var response = await _httpClient.SendAsync(request);
if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync();
_logger.LogWarning("Jellyfin DELETE request failed: {StatusCode} for {Url}. Response: {Response}",
response.StatusCode, url, errorContent);
return null;
}
// Handle 204 No Content responses
if (response.StatusCode == System.Net.HttpStatusCode.NoContent)
{
return null;
}
var responseContent = await response.Content.ReadAsStringAsync();
// Handle empty responses
if (string.IsNullOrWhiteSpace(responseContent))
{
return null;
}
return JsonDocument.Parse(responseContent);
}
/// <summary> /// <summary>
/// Safely sends a GET request to the Jellyfin server, returning null on failure. /// Safely sends a GET request to the Jellyfin server, returning null on failure.
/// </summary> /// </summary>

View File

@@ -47,7 +47,7 @@ public class LrclibService
$"track_name={Uri.EscapeDataString(trackName)}&" + $"track_name={Uri.EscapeDataString(trackName)}&" +
$"artist_name={Uri.EscapeDataString(artistName)}"; $"artist_name={Uri.EscapeDataString(artistName)}";
_logger.LogDebug("Searching lyrics from LRCLIB: {Url}", searchUrl); _logger.LogInformation("Searching LRCLIB: {Url}", searchUrl);
var searchResponse = await _httpClient.GetAsync(searchUrl); var searchResponse = await _httpClient.GetAsync(searchUrl);
@@ -91,8 +91,8 @@ public class LrclibService
// Only use result if score is good enough (>60%) // Only use result if score is good enough (>60%)
if (bestMatch != null && bestScore >= 60) if (bestMatch != null && bestScore >= 60)
{ {
_logger.LogInformation("Found lyrics via search for {Artist} - {Track} (ID: {Id}, score: {Score:F1})", _logger.LogInformation("Found lyrics via search for {Artist} - {Track} (ID: {Id}, score: {Score:F1}, synced: {HasSynced})",
artistName, trackName, bestMatch.Id, bestScore); artistName, trackName, bestMatch.Id, bestScore, !string.IsNullOrEmpty(bestMatch.SyncedLyrics));
var result = new LyricsInfo var result = new LyricsInfo
{ {
@@ -111,7 +111,7 @@ public class LrclibService
} }
else else
{ {
_logger.LogDebug("Best match score too low ({Score:F1}), trying exact match", bestScore); _logger.LogInformation("Best match score too low ({Score:F1}), trying exact match", bestScore);
} }
} }
} }