Compare commits

...

2 Commits

Author SHA1 Message Date
8e6eb5cc4a Support both favorite endpoint formats
Some checks failed
CI / build-and-test (push) Has been cancelled
- Add /UserFavoriteItems/{itemId} route (official Jellyfin API)
- Keep /Users/{userId}/FavoriteItems/{itemId} for compatibility
- Support userId in query string or path
- Add route logging to debug which endpoint is being called
2026-02-01 18:29:31 -05:00
1326f1b3ab 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
2026-02-01 18:24:09 -05:00
3 changed files with 204 additions and 75 deletions

View File

@@ -1033,15 +1033,21 @@ public class JellyfinController : ControllerBase
var lyricsText = lyrics.SyncedLyrics ?? lyrics.PlainLyrics ?? "";
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
var lyricLines = new List<object>();
var lyricLines = new List<Dictionary<string, object>>();
if (isSynced && !string.IsNullOrEmpty(lyrics.SyncedLyrics))
{
_logger.LogInformation("Parsing synced lyrics (LRC format)");
// Parse LRC format: [mm:ss.xx] text
// Skip ID tags like [ar:Artist], [ti:Title], etc.
var lines = lyrics.SyncedLyrics.Split('\n', StringSplitOptions.RemoveEmptyEntries);
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*(.*)$");
if (match.Success)
{
@@ -1054,34 +1060,40 @@ public class JellyfinController : ControllerBase
var totalMilliseconds = (minutes * 60 + seconds) * 1000 + centiseconds * 10;
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))
{
_logger.LogInformation("Splitting plain lyrics into lines (no timestamps)");
// 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);
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
{
_logger.LogWarning("No lyrics text available");
// 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
};
_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);
}
@@ -1107,10 +1130,21 @@ public class JellyfinController : ControllerBase
/// <summary>
/// Marks an item as favorite. For playlists, triggers a full download.
/// Supports both /Users/{userId}/FavoriteItems/{itemId} and /UserFavoriteItems/{itemId}?userId=xxx
/// </summary>
[HttpPost("Users/{userId}/FavoriteItems/{itemId}")]
public async Task<IActionResult> MarkFavorite(string userId, string itemId)
[HttpPost("UserFavoriteItems/{itemId}")]
public async Task<IActionResult> MarkFavorite(string itemId, string? userId = null)
{
// Get userId from query string if not in path
if (string.IsNullOrEmpty(userId))
{
userId = Request.Query["userId"].ToString();
}
_logger.LogInformation("MarkFavorite called: userId={UserId}, itemId={ItemId}, route={Route}",
userId, itemId, Request.Path);
// Check if this is an external playlist - trigger download
if (PlaylistIdHelper.IsExternalPlaylist(itemId))
{
@@ -1134,7 +1168,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
@@ -1156,87 +1195,88 @@ 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
var endpoint = $"Users/{userId}/FavoriteItems/{itemId}";
// Use the official Jellyfin endpoint format
var endpoint = $"UserFavoriteItems/{itemId}";
if (!string.IsNullOrEmpty(userId))
{
endpoint = $"{endpoint}?userId={userId}";
}
try
_logger.LogInformation("Proxying favorite request to Jellyfin: {Endpoint}", endpoint);
var result = await _proxyService.PostJsonAsync(endpoint, "{}", Request.Headers);
if (result == null)
{
using var request = new HttpRequestMessage(HttpMethod.Post, $"{_settings.Url?.TrimEnd('/')}/{endpoint}");
// Forward client authentication
if (Request.Headers.TryGetValue("X-Emby-Authorization", out var embyAuth))
{
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", embyAuth.ToString());
}
else if (Request.Headers.TryGetValue("Authorization", out var auth))
{
request.Headers.TryAddWithoutValidation("Authorization", auth.ToString());
}
var response = await _proxyService.HttpClient.SendAsync(request);
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");
_logger.LogWarning("Failed to favorite item in Jellyfin - proxy returned null");
return StatusCode(500, new { error = "Failed to mark favorite" });
}
return new JsonResult(JsonSerializer.Deserialize<object>(result.RootElement.GetRawText()));
}
/// <summary>
/// Removes an item from favorites.
/// Supports both /Users/{userId}/FavoriteItems/{itemId} and /UserFavoriteItems/{itemId}?userId=xxx
/// </summary>
[HttpDelete("Users/{userId}/FavoriteItems/{itemId}")]
public async Task<IActionResult> UnmarkFavorite(string userId, string itemId)
[HttpDelete("UserFavoriteItems/{itemId}")]
public async Task<IActionResult> UnmarkFavorite(string itemId, string? userId = null)
{
// External items can't be unfavorited
// Get userId from query string if not in path
if (string.IsNullOrEmpty(userId))
{
userId = Request.Query["userId"].ToString();
}
_logger.LogInformation("UnmarkFavorite called: userId={UserId}, itemId={ItemId}, route={Route}",
userId, itemId, Request.Path);
// External items can't be unfavorited (they're not really favorited in Jellyfin)
var (isExternal, _, _) = _localLibraryService.ParseSongId(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
var url = $"Users/{userId}/FavoriteItems/{itemId}";
// Use the official Jellyfin endpoint format
var endpoint = $"UserFavoriteItems/{itemId}";
if (!string.IsNullOrEmpty(userId))
{
endpoint = $"{endpoint}?userId={userId}";
}
try
_logger.LogInformation("Proxying unfavorite request to Jellyfin: {Endpoint}", endpoint);
var result = await _proxyService.DeleteAsync(endpoint, Request.Headers);
if (result == null)
{
using var request = new HttpRequestMessage(HttpMethod.Delete, $"{_settings.Url?.TrimEnd('/')}/{url}");
// Forward client authentication
if (Request.Headers.TryGetValue("X-Emby-Authorization", out var embyAuth))
{
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", embyAuth.ToString());
}
else if (Request.Headers.TryGetValue("Authorization", out var auth))
{
request.Headers.TryAddWithoutValidation("Authorization", auth.ToString());
}
var response = await _proxyService.HttpClient.SendAsync(request);
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");
// DELETE often returns 204 No Content, which is success
_logger.LogInformation("Unfavorite succeeded (no content returned)");
return Ok(new
{
IsFavorite = false,
ItemId = itemId
});
}
return new JsonResult(JsonSerializer.Deserialize<object>(result.RootElement.GetRawText()));
}
#endregion

View File

@@ -398,6 +398,95 @@ public class JellyfinProxyService
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>
/// Safely sends a GET request to the Jellyfin server, returning null on failure.
/// </summary>

View File

@@ -47,7 +47,7 @@ public class LrclibService
$"track_name={Uri.EscapeDataString(trackName)}&" +
$"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);
@@ -91,8 +91,8 @@ public class LrclibService
// Only use result if score is good enough (>60%)
if (bestMatch != null && bestScore >= 60)
{
_logger.LogInformation("Found lyrics via search for {Artist} - {Track} (ID: {Id}, score: {Score:F1})",
artistName, trackName, bestMatch.Id, bestScore);
_logger.LogInformation("Found lyrics via search for {Artist} - {Track} (ID: {Id}, score: {Score:F1}, synced: {HasSynced})",
artistName, trackName, bestMatch.Id, bestScore, !string.IsNullOrEmpty(bestMatch.SyncedLyrics));
var result = new LyricsInfo
{
@@ -111,7 +111,7 @@ public class LrclibService
}
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);
}
}
}