mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-09 23:55:10 -05:00
Compare commits
2 Commits
0011538966
...
8e6eb5cc4a
| Author | SHA1 | Date | |
|---|---|---|---|
|
8e6eb5cc4a
|
|||
|
1326f1b3ab
|
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user