diff --git a/allstarr/Controllers/JellyfinController.cs b/allstarr/Controllers/JellyfinController.cs index 5fafbd7..95f74fc 100644 --- a/allstarr/Controllers/JellyfinController.cs +++ b/allstarr/Controllers/JellyfinController.cs @@ -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(); + var lyricLines = new List>(); 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 { - 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 { - 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 { - 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); } @@ -1111,6 +1134,8 @@ public class JellyfinController : ControllerBase [HttpPost("Users/{userId}/FavoriteItems/{itemId}")] public async Task MarkFavorite(string userId, string itemId) { + _logger.LogInformation("MarkFavorite called: userId={UserId}, itemId={ItemId}", userId, itemId); + // Check if this is an external playlist - trigger download 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 @@ -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 var endpoint = $"Users/{userId}/FavoriteItems/{itemId}"; + _logger.LogInformation("Proxying favorite request to Jellyfin: {Endpoint}", endpoint); - try + 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(result.RootElement.GetRawText())); } /// @@ -1199,44 +1215,38 @@ public class JellyfinController : ControllerBase [HttpDelete("Users/{userId}/FavoriteItems/{itemId}")] public async Task 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); 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}"; + var endpoint = $"Users/{userId}/FavoriteItems/{itemId}"; + _logger.LogInformation("Proxying unfavorite request to Jellyfin: {Endpoint}", endpoint); - try + 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(result.RootElement.GetRawText())); } #endregion diff --git a/allstarr/Services/Jellyfin/JellyfinProxyService.cs b/allstarr/Services/Jellyfin/JellyfinProxyService.cs index b21554c..4c9b6dc 100644 --- a/allstarr/Services/Jellyfin/JellyfinProxyService.cs +++ b/allstarr/Services/Jellyfin/JellyfinProxyService.cs @@ -398,6 +398,95 @@ public class JellyfinProxyService return (body, contentType); } + /// + /// Sends a DELETE request to the Jellyfin server. + /// Forwards client headers for authentication passthrough. + /// + public async Task 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); + } + /// /// Safely sends a GET request to the Jellyfin server, returning null on failure. /// diff --git a/allstarr/Services/Lyrics/LrclibService.cs b/allstarr/Services/Lyrics/LrclibService.cs index 755ab4f..be0687f 100644 --- a/allstarr/Services/Lyrics/LrclibService.cs +++ b/allstarr/Services/Lyrics/LrclibService.cs @@ -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); } } }