Compare commits

...

9 Commits

Author SHA1 Message Date
936fa27aa7 websocket 5
Some checks failed
CI / build-and-test (push) Has been cancelled
2026-02-02 03:01:58 -05:00
b90ce423d7 websocket 4.0 2026-02-02 02:53:14 -05:00
5f038965a2 websocket 3 2026-02-02 02:51:00 -05:00
229fa0bf65 websocket 2.0 2026-02-02 02:47:12 -05:00
1aec76c3dd websocket fix 2026-02-02 02:43:55 -05:00
96b06d5d4f region fix 2026-02-02 02:40:18 -05:00
747d310375 websocket stuff 2026-02-02 02:39:06 -05:00
fc78a095a9 Websocket Proxying 2026-02-02 02:19:43 -05:00
65ca80f9a0 Fix: Properly handle HTTP status codes in Jellyfin proxy
- Refactor proxy service methods to return (Body, StatusCode) tuples
- Add HandleProxyResponse helper for consistent status code handling
- Fix 401 authentication errors being returned as 204
- Add explicit session reporting endpoints for playback tracking
- Ensure local track playback sessions are forwarded to Jellyfin
- All 219 tests passing
2026-02-02 01:05:25 -05:00
4 changed files with 890 additions and 124 deletions

View File

@@ -113,14 +113,20 @@ public class JellyfinController : ControllerBase
endpoint = $"{endpoint}{Request.QueryString.Value}"; endpoint = $"{endpoint}{Request.QueryString.Value}";
} }
var browseResult = await _proxyService.GetJsonAsync(endpoint, null, Request.Headers); var (browseResult, statusCode) = await _proxyService.GetJsonAsync(endpoint, null, Request.Headers);
if (browseResult == null) if (browseResult == null)
{ {
_logger.LogInformation("Jellyfin returned null - likely 401 Unauthorized, returning 401 to client"); if (statusCode == 401)
{
_logger.LogInformation("Jellyfin returned 401 Unauthorized, returning 401 to client");
return Unauthorized(new { error = "Authentication required" }); return Unauthorized(new { error = "Authentication required" });
} }
_logger.LogInformation("Jellyfin returned {StatusCode}, returning empty result", statusCode);
return new JsonResult(new { Items = Array.Empty<object>(), TotalRecordCount = 0, StartIndex = startIndex });
}
// Update Spotify playlist counts if enabled and response contains playlists // Update Spotify playlist counts if enabled and response contains playlists
if (_spotifySettings.Enabled && browseResult.RootElement.TryGetProperty("Items", out var _)) if (_spotifySettings.Enabled && browseResult.RootElement.TryGetProperty("Items", out var _))
{ {
@@ -168,7 +174,7 @@ public class JellyfinController : ControllerBase
await Task.WhenAll(jellyfinTask, externalTask, playlistTask); await Task.WhenAll(jellyfinTask, externalTask, playlistTask);
var jellyfinResult = await jellyfinTask; var (jellyfinResult, _) = await jellyfinTask;
var externalResult = await externalTask; var externalResult = await externalTask;
var playlistResult = await playlistTask; var playlistResult = await playlistTask;
@@ -315,7 +321,7 @@ public class JellyfinController : ControllerBase
} }
// Proxy to Jellyfin for local content // Proxy to Jellyfin for local content
var result = await _proxyService.GetItemsAsync( var (result, statusCode) = await _proxyService.GetItemsAsync(
parentId: parentId, parentId: parentId,
includeItemTypes: ParseItemTypes(includeItemTypes), includeItemTypes: ParseItemTypes(includeItemTypes),
sortBy: sortBy, sortBy: sortBy,
@@ -323,12 +329,7 @@ public class JellyfinController : ControllerBase
startIndex: startIndex, startIndex: startIndex,
clientHeaders: Request.Headers); clientHeaders: Request.Headers);
if (result == null) return HandleProxyResponse(result, statusCode);
{
return _responseBuilder.CreateError(404, "Parent not found");
}
return new JsonResult(JsonSerializer.Deserialize<object>(result.RootElement.GetRawText()));
} }
/// <summary> /// <summary>
@@ -360,7 +361,7 @@ public class JellyfinController : ControllerBase
await Task.WhenAll(jellyfinTask, externalTask); await Task.WhenAll(jellyfinTask, externalTask);
var jellyfinResult = await jellyfinTask; var (jellyfinResult, _) = await jellyfinTask;
var externalResult = await externalTask; var externalResult = await externalTask;
var (localSongs, localAlbums, localArtists) = _modelMapper.ParseItemsResponse(jellyfinResult); var (localSongs, localAlbums, localArtists) = _modelMapper.ParseItemsResponse(jellyfinResult);
@@ -416,13 +417,9 @@ public class JellyfinController : ControllerBase
} }
// Proxy to Jellyfin // Proxy to Jellyfin
var result = await _proxyService.GetItemAsync(itemId, Request.Headers); var (result, statusCode) = await _proxyService.GetItemAsync(itemId, Request.Headers);
if (result == null)
{
return _responseBuilder.CreateError(404, "Item not found");
}
return new JsonResult(JsonSerializer.Deserialize<object>(result.RootElement.GetRawText())); return HandleProxyResponse(result, statusCode);
} }
/// <summary> /// <summary>
@@ -534,7 +531,7 @@ public class JellyfinController : ControllerBase
await Task.WhenAll(jellyfinTask, externalTask); await Task.WhenAll(jellyfinTask, externalTask);
var jellyfinResult = await jellyfinTask; var (jellyfinResult, _) = await jellyfinTask;
var externalArtists = await externalTask; var externalArtists = await externalTask;
_logger.LogInformation("Artist search results: Jellyfin={JellyfinCount}, External={ExternalCount}", _logger.LogInformation("Artist search results: Jellyfin={JellyfinCount}, External={ExternalCount}",
@@ -584,21 +581,16 @@ public class JellyfinController : ControllerBase
} }
// No search term - just proxy to Jellyfin // No search term - just proxy to Jellyfin
var result = await _proxyService.GetArtistsAsync(searchTerm, limit, startIndex, Request.Headers); var (result, statusCode) = await _proxyService.GetArtistsAsync(searchTerm, limit, startIndex, Request.Headers);
if (result == null) return HandleProxyResponse(result, statusCode, new
{ {
return new JsonResult(new Dictionary<string, object> Items = Array.Empty<object>(),
{ TotalRecordCount = 0,
["Items"] = Array.Empty<object>(), StartIndex = startIndex
["TotalRecordCount"] = 0,
["StartIndex"] = startIndex
}); });
} }
return new JsonResult(JsonSerializer.Deserialize<object>(result.RootElement.GetRawText()));
}
/// <summary> /// <summary>
/// Gets a single artist by ID or name. /// Gets a single artist by ID or name.
/// This route has lower priority to avoid conflicting with Artists/AlbumArtists. /// This route has lower priority to avoid conflicting with Artists/AlbumArtists.
@@ -627,10 +619,10 @@ public class JellyfinController : ControllerBase
} }
// Get local artist from Jellyfin // Get local artist from Jellyfin
var jellyfinArtist = await _proxyService.GetArtistAsync(artistIdOrName, Request.Headers); var (jellyfinArtist, statusCode) = await _proxyService.GetArtistAsync(artistIdOrName, Request.Headers);
if (jellyfinArtist == null) if (jellyfinArtist == null)
{ {
return _responseBuilder.CreateError(404, "Artist not found"); return HandleProxyResponse(null, statusCode);
} }
var artistData = _modelMapper.ParseArtist(jellyfinArtist.RootElement); var artistData = _modelMapper.ParseArtist(jellyfinArtist.RootElement);
@@ -638,7 +630,7 @@ public class JellyfinController : ControllerBase
var localArtistId = artistData.Id; var localArtistId = artistData.Id;
// Get local albums // Get local albums
var localAlbumsResult = await _proxyService.GetItemsAsync( var (localAlbumsResult, _) = await _proxyService.GetItemsAsync(
parentId: null, parentId: null,
includeItemTypes: new[] { "MusicAlbum" }, includeItemTypes: new[] { "MusicAlbum" },
sortBy: "SortName", sortBy: "SortName",
@@ -983,6 +975,24 @@ public class JellyfinController : ControllerBase
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId); var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId);
// For local tracks, check if Jellyfin already has embedded lyrics
if (!isExternal)
{
_logger.LogInformation("Checking Jellyfin for embedded lyrics for local track: {ItemId}", itemId);
// Try to get lyrics from Jellyfin first (it reads embedded lyrics from files)
var (jellyfinLyrics, statusCode) = await _proxyService.GetJsonAsync($"Audio/{itemId}/Lyrics", null, Request.Headers);
if (jellyfinLyrics != null && statusCode == 200)
{
_logger.LogInformation("✓ Found embedded lyrics in Jellyfin for track {ItemId}", itemId);
return new JsonResult(JsonSerializer.Deserialize<object>(jellyfinLyrics.RootElement.GetRawText()));
}
_logger.LogInformation("No embedded lyrics found in Jellyfin, falling back to LRCLIB search");
}
// For external tracks or when Jellyfin doesn't have lyrics, search LRCLIB
Song? song = null; Song? song = null;
if (isExternal) if (isExternal)
@@ -992,7 +1002,7 @@ public class JellyfinController : ControllerBase
else else
{ {
// For local songs, get metadata from Jellyfin // For local songs, get metadata from Jellyfin
var item = await _proxyService.GetItemAsync(itemId, Request.Headers); var (item, _) = await _proxyService.GetItemAsync(itemId, Request.Headers);
if (item != null && item.RootElement.TryGetProperty("Type", out var typeEl) && if (item != null && item.RootElement.TryGetProperty("Type", out var typeEl) &&
typeEl.GetString() == "Audio") typeEl.GetString() == "Audio")
{ {
@@ -1012,6 +1022,7 @@ public class JellyfinController : ControllerBase
} }
// Try to get lyrics from LRCLIB // Try to get lyrics from LRCLIB
_logger.LogInformation("Searching LRCLIB for lyrics: {Artist} - {Title}", song.Artist, song.Title);
var lyricsService = HttpContext.RequestServices.GetService<LrclibService>(); var lyricsService = HttpContext.RequestServices.GetService<LrclibService>();
if (lyricsService == null) if (lyricsService == null)
{ {
@@ -1213,15 +1224,9 @@ public class JellyfinController : ControllerBase
_logger.LogInformation("Proxying favorite request to Jellyfin: {Endpoint}", endpoint); _logger.LogInformation("Proxying favorite request to Jellyfin: {Endpoint}", endpoint);
var result = await _proxyService.PostJsonAsync(endpoint, "{}", Request.Headers); var (result, statusCode) = await _proxyService.PostJsonAsync(endpoint, "{}", Request.Headers);
if (result == null) return HandleProxyResponse(result, statusCode);
{
_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> /// <summary>
@@ -1263,22 +1268,15 @@ public class JellyfinController : ControllerBase
_logger.LogInformation("Proxying unfavorite request to Jellyfin: {Endpoint}", endpoint); _logger.LogInformation("Proxying unfavorite request to Jellyfin: {Endpoint}", endpoint);
var result = await _proxyService.DeleteAsync(endpoint, Request.Headers); var (result, statusCode) = await _proxyService.DeleteAsync(endpoint, Request.Headers);
if (result == null) return HandleProxyResponse(result, statusCode, new
{
// DELETE often returns 204 No Content, which is success
_logger.LogInformation("Unfavorite succeeded (no content returned)");
return Ok(new
{ {
IsFavorite = false, IsFavorite = false,
ItemId = itemId ItemId = itemId
}); });
} }
return new JsonResult(JsonSerializer.Deserialize<object>(result.RootElement.GetRawText()));
}
#endregion #endregion
#region Playlists #region Playlists
@@ -1349,7 +1347,7 @@ public class JellyfinController : ControllerBase
{ {
// Get playlist info from Jellyfin to get the name for matching missing tracks // Get playlist info from Jellyfin to get the name for matching missing tracks
_logger.LogInformation("Fetching playlist info from Jellyfin for ID: {PlaylistId}", playlistId); _logger.LogInformation("Fetching playlist info from Jellyfin for ID: {PlaylistId}", playlistId);
var playlistInfo = await _proxyService.GetJsonAsync($"Items/{playlistId}", null, Request.Headers); var (playlistInfo, _) = await _proxyService.GetJsonAsync($"Items/{playlistId}", null, Request.Headers);
if (playlistInfo != null && playlistInfo.RootElement.TryGetProperty("Name", out var nameElement)) if (playlistInfo != null && playlistInfo.RootElement.TryGetProperty("Name", out var nameElement))
{ {
@@ -1372,13 +1370,9 @@ public class JellyfinController : ControllerBase
} }
_logger.LogInformation("Proxying to Jellyfin: {Endpoint}", endpoint); _logger.LogInformation("Proxying to Jellyfin: {Endpoint}", endpoint);
var result = await _proxyService.GetJsonAsync(endpoint, null, Request.Headers); var (result, statusCode) = await _proxyService.GetJsonAsync(endpoint, null, Request.Headers);
if (result == null)
{
return _responseBuilder.CreateError(404, "Playlist not found");
}
return new JsonResult(JsonSerializer.Deserialize<object>(result.RootElement.GetRawText())); return HandleProxyResponse(result, statusCode);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -1446,15 +1440,51 @@ public class JellyfinController : ControllerBase
// DO NOT log request body or detailed headers - contains password // DO NOT log request body or detailed headers - contains password
// Forward to Jellyfin server with client headers // Forward to Jellyfin server with client headers
var result = await _proxyService.PostJsonAsync("Users/AuthenticateByName", body, Request.Headers); var (result, statusCode) = await _proxyService.PostJsonAsync("Users/AuthenticateByName", body, Request.Headers);
if (result == null) if (result == null)
{ {
_logger.LogWarning("Authentication failed - no response from Jellyfin"); _logger.LogWarning("Authentication failed - status {StatusCode}", statusCode);
return Unauthorized(new { error = "Authentication failed" }); if (statusCode == 401)
{
return Unauthorized(new { error = "Invalid username or password" });
}
return StatusCode(statusCode, new { error = "Authentication failed" });
} }
_logger.LogInformation("Authentication successful"); _logger.LogInformation("Authentication successful");
// Post session capabilities immediately after authentication
// This ensures Jellyfin creates a session that will show up in the dashboard
try
{
_logger.LogInformation("🔧 Posting session capabilities after authentication");
var capabilities = new
{
PlayableMediaTypes = new[] { "Audio" },
SupportedCommands = Array.Empty<string>(),
SupportsMediaControl = false,
SupportsPersistentIdentifier = true,
SupportsSync = false
};
var capabilitiesJson = JsonSerializer.Serialize(capabilities);
var (capResult, capStatus) = await _proxyService.PostJsonAsync("Sessions/Capabilities/Full", capabilitiesJson, Request.Headers);
if (capStatus == 204 || capStatus == 200)
{
_logger.LogInformation("✓ Session capabilities posted after auth ({StatusCode})", capStatus);
}
else
{
_logger.LogWarning("⚠ Session capabilities returned {StatusCode} after auth", capStatus);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to post session capabilities after auth, continuing anyway");
}
return Content(result.RootElement.GetRawText(), "application/json"); return Content(result.RootElement.GetRawText(), "application/json");
} }
catch (Exception ex) catch (Exception ex)
@@ -1558,18 +1588,9 @@ public class JellyfinController : ControllerBase
queryParams["userId"] = userId; queryParams["userId"] = userId;
} }
var result = await _proxyService.GetJsonAsync(endpoint, queryParams, Request.Headers); var (result, statusCode) = await _proxyService.GetJsonAsync(endpoint, queryParams, Request.Headers);
if (result == null) return HandleProxyResponse(result, statusCode, new { Items = Array.Empty<object>(), TotalRecordCount = 0 });
{
return _responseBuilder.CreateJsonResponse(new
{
Items = Array.Empty<object>(),
TotalRecordCount = 0
});
}
return new JsonResult(JsonSerializer.Deserialize<object>(result.RootElement.GetRawText()));
} }
/// <summary> /// <summary>
@@ -1672,22 +1693,447 @@ public class JellyfinController : ControllerBase
queryParams["userId"] = userId; queryParams["userId"] = userId;
} }
var result = await _proxyService.GetJsonAsync($"Songs/{itemId}/InstantMix", queryParams, Request.Headers); var (result, statusCode) = await _proxyService.GetJsonAsync($"Songs/{itemId}/InstantMix", queryParams, Request.Headers);
if (result == null) return HandleProxyResponse(result, statusCode, new { Items = Array.Empty<object>(), TotalRecordCount = 0 });
{
return _responseBuilder.CreateJsonResponse(new
{
Items = Array.Empty<object>(),
TotalRecordCount = 0
});
}
return new JsonResult(JsonSerializer.Deserialize<object>(result.RootElement.GetRawText()));
} }
#endregion #endregion
#region Playback Session Reporting
#region Session Management
/// <summary>
/// Reports session capabilities. Required for Jellyfin to track active sessions.
/// Handles both POST (with body) and GET (query params only) methods.
/// </summary>
[HttpPost("Sessions/Capabilities")]
[HttpPost("Sessions/Capabilities/Full")]
[HttpGet("Sessions/Capabilities")]
[HttpGet("Sessions/Capabilities/Full")]
public async Task<IActionResult> ReportCapabilities()
{
try
{
var method = Request.Method;
var queryString = Request.QueryString.HasValue ? Request.QueryString.Value : "";
_logger.LogInformation("📡 Session capabilities reported - Method: {Method}, Query: {Query}", method, queryString);
_logger.LogInformation("Headers: {Headers}",
string.Join(", ", Request.Headers.Where(h => h.Key.Contains("Auth", StringComparison.OrdinalIgnoreCase) || h.Key.Contains("Device", StringComparison.OrdinalIgnoreCase) || h.Key.Contains("Client", StringComparison.OrdinalIgnoreCase))
.Select(h => $"{h.Key}={h.Value}")));
// Forward to Jellyfin with query string and headers
var endpoint = $"Sessions/Capabilities{queryString}";
// Read body if present (POST requests)
string body = "{}";
if (method == "POST" && Request.ContentLength > 0)
{
Request.EnableBuffering();
using (var reader = new StreamReader(Request.Body, System.Text.Encoding.UTF8, detectEncodingFromByteOrderMarks: false, bufferSize: 1024, leaveOpen: true))
{
body = await reader.ReadToEndAsync();
}
Request.Body.Position = 0;
_logger.LogInformation("Capabilities body: {Body}", body);
}
var (result, statusCode) = await _proxyService.PostJsonAsync(endpoint, body, Request.Headers);
if (statusCode == 204 || statusCode == 200)
{
_logger.LogInformation("✓ Session capabilities forwarded to Jellyfin ({StatusCode})", statusCode);
}
else
{
_logger.LogWarning("⚠ Jellyfin returned {StatusCode} for capabilities", statusCode);
}
return NoContent();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to report session capabilities");
return StatusCode(500);
}
}
/// <summary>
/// Reports playback start. Handles both local and external tracks.
/// For local tracks, forwards to Jellyfin. For external tracks, logs locally.
/// Also ensures session is initialized if this is the first report from a device.
/// </summary>
[HttpPost("Sessions/Playing")]
public async Task<IActionResult> ReportPlaybackStart()
{
try
{
Request.EnableBuffering();
string body;
using (var reader = new StreamReader(Request.Body, System.Text.Encoding.UTF8, detectEncodingFromByteOrderMarks: false, bufferSize: 1024, leaveOpen: true))
{
body = await reader.ReadToEndAsync();
}
Request.Body.Position = 0;
_logger.LogInformation("📻 Playback START reported - Body: {Body}", body);
_logger.LogInformation("Auth headers: {Headers}",
string.Join(", ", Request.Headers.Where(h => h.Key.Contains("Auth", StringComparison.OrdinalIgnoreCase)).Select(h => $"{h.Key}={h.Value}")));
// Extract device info from auth headers for session initialization
string? deviceId = null;
string? client = null;
string? device = null;
string? version = null;
if (Request.Headers.TryGetValue("Authorization", out var authHeader))
{
var authStr = authHeader.ToString();
// Parse: MediaBrowser Client="...", Device="...", DeviceId="...", Version="..."
var parts = authStr.Replace("MediaBrowser ", "").Split(',');
foreach (var part in parts)
{
var kv = part.Trim().Split('=');
if (kv.Length == 2)
{
var key = kv[0].Trim();
var value = kv[1].Trim('"');
if (key == "DeviceId") deviceId = value;
else if (key == "Client") client = value;
else if (key == "Device") device = value;
else if (key == "Version") version = value;
}
}
}
// Ensure session capabilities are posted to Jellyfin (if not already done)
// Jellyfin automatically creates a session when the client authenticates, but we need to
// post capabilities so the session shows up in the dashboard with proper device info
if (!string.IsNullOrEmpty(deviceId))
{
_logger.LogInformation("🔧 Ensuring session exists for device: {DeviceId} ({Client} {Version})", deviceId, client, version);
// First, check if a session exists for this device
try
{
var (sessionsResult, sessionsStatus) = await _proxyService.GetJsonAsync($"Sessions?deviceId={deviceId}", null, Request.Headers);
if (sessionsResult != null && sessionsStatus == 200)
{
var sessions = sessionsResult.RootElement;
_logger.LogInformation("📊 Jellyfin sessions for device {DeviceId}: {Sessions}", deviceId, sessions.GetRawText());
}
else
{
_logger.LogWarning("⚠ Could not query sessions ({StatusCode})", sessionsStatus);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to query sessions");
}
// Post capabilities - Jellyfin will match this to the authenticated session by device ID
// The query parameters tell Jellyfin what this device can do
var capabilitiesEndpoint = $"Sessions/Capabilities/Full";
try
{
// Send full capabilities as JSON body (more reliable than query params)
var capabilities = new
{
PlayableMediaTypes = new[] { "Audio" },
SupportedCommands = Array.Empty<string>(),
SupportsMediaControl = false,
SupportsPersistentIdentifier = true,
SupportsSync = false,
DeviceProfile = (object?)null // Let Jellyfin use defaults
};
var capabilitiesJson = JsonSerializer.Serialize(capabilities);
_logger.LogInformation("📤 Posting capabilities: {Json}", capabilitiesJson);
var (capResult, capStatus) = await _proxyService.PostJsonAsync(capabilitiesEndpoint, capabilitiesJson, Request.Headers);
_logger.LogInformation("✓ Session capabilities posted ({StatusCode})", capStatus);
// Check sessions again after posting capabilities
var (sessionsResult2, sessionsStatus2) = await _proxyService.GetJsonAsync($"Sessions?deviceId={deviceId}", null, Request.Headers);
if (sessionsResult2 != null && sessionsStatus2 == 200)
{
var sessions2 = sessionsResult2.RootElement;
_logger.LogInformation("📊 Jellyfin sessions AFTER capabilities: {Sessions}", sessions2.GetRawText());
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to post session capabilities, continuing anyway");
}
}
// Parse the body to check if it's an external track
var doc = JsonDocument.Parse(body);
string? itemId = null;
string? itemName = null;
if (doc.RootElement.TryGetProperty("ItemId", out var itemIdProp))
{
itemId = itemIdProp.GetString();
}
if (doc.RootElement.TryGetProperty("ItemName", out var itemNameProp))
{
itemName = itemNameProp.GetString();
}
if (!string.IsNullOrEmpty(itemId))
{
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId);
if (isExternal)
{
_logger.LogInformation("🎵 External track playback started: {Name} ({Provider}/{ExternalId})",
itemName ?? "Unknown", provider, externalId);
// For external tracks, we can't report to Jellyfin since it doesn't know about them
// Just return success so the client is happy
return NoContent();
}
_logger.LogInformation("🎵 Local track playback started: {Name} (ID: {ItemId})",
itemName ?? "Unknown", itemId);
}
// For local tracks, forward to Jellyfin with client auth
_logger.LogInformation("Forwarding playback start to Jellyfin...");
var (result, statusCode) = await _proxyService.PostJsonAsync("Sessions/Playing", body, Request.Headers);
if (statusCode == 204 || statusCode == 200)
{
_logger.LogInformation("✓ Playback start forwarded to Jellyfin ({StatusCode})", statusCode);
}
else
{
_logger.LogWarning("⚠️ Playback start forward returned status {StatusCode}", statusCode);
}
return NoContent();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to report playback start");
return NoContent(); // Return success anyway to not break playback
}
}
/// <summary>
/// Reports playback progress. Handles both local and external tracks.
/// </summary>
[HttpPost("Sessions/Playing/Progress")]
public async Task<IActionResult> ReportPlaybackProgress()
{
try
{
Request.EnableBuffering();
string body;
using (var reader = new StreamReader(Request.Body, System.Text.Encoding.UTF8, detectEncodingFromByteOrderMarks: false, bufferSize: 1024, leaveOpen: true))
{
body = await reader.ReadToEndAsync();
}
Request.Body.Position = 0;
// Parse the body to check if it's an external track
var doc = JsonDocument.Parse(body);
if (doc.RootElement.TryGetProperty("ItemId", out var itemIdProp))
{
var itemId = itemIdProp.GetString();
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId ?? "");
if (isExternal)
{
// For external tracks, just acknowledge
return NoContent();
}
}
// For local tracks, forward to Jellyfin
var (result, statusCode) = await _proxyService.PostJsonAsync("Sessions/Playing/Progress", body, Request.Headers);
return NoContent();
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to report playback progress");
return NoContent();
}
}
/// <summary>
/// Reports playback stopped. Handles both local and external tracks.
/// </summary>
[HttpPost("Sessions/Playing/Stopped")]
public async Task<IActionResult> ReportPlaybackStopped()
{
try
{
Request.EnableBuffering();
string body;
using (var reader = new StreamReader(Request.Body, System.Text.Encoding.UTF8, detectEncodingFromByteOrderMarks: false, bufferSize: 1024, leaveOpen: true))
{
body = await reader.ReadToEndAsync();
}
Request.Body.Position = 0;
_logger.LogInformation("⏹️ Playback STOPPED reported");
// Parse the body to check if it's an external track
var doc = JsonDocument.Parse(body);
string? itemId = null;
string? itemName = null;
long? positionTicks = null;
if (doc.RootElement.TryGetProperty("ItemId", out var itemIdProp))
{
itemId = itemIdProp.GetString();
}
if (doc.RootElement.TryGetProperty("ItemName", out var itemNameProp))
{
itemName = itemNameProp.GetString();
}
if (doc.RootElement.TryGetProperty("PositionTicks", out var posProp))
{
positionTicks = posProp.GetInt64();
}
if (!string.IsNullOrEmpty(itemId))
{
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId);
if (isExternal)
{
var position = positionTicks.HasValue
? TimeSpan.FromTicks(positionTicks.Value).ToString(@"mm\:ss")
: "unknown";
_logger.LogInformation("🎵 External track playback stopped: {Name} at {Position} ({Provider}/{ExternalId})",
itemName ?? "Unknown", position, provider, externalId);
return NoContent();
}
_logger.LogInformation("🎵 Local track playback stopped: {Name} (ID: {ItemId})",
itemName ?? "Unknown", itemId);
}
// For local tracks, forward to Jellyfin
_logger.LogInformation("Forwarding playback stop to Jellyfin...");
var (result, statusCode) = await _proxyService.PostJsonAsync("Sessions/Playing/Stopped", body, Request.Headers);
if (statusCode == 204 || statusCode == 200)
{
_logger.LogInformation("✓ Playback stop forwarded to Jellyfin ({StatusCode})", statusCode);
}
else
{
_logger.LogWarning("Playback stop forward failed with status {StatusCode}", statusCode);
}
return NoContent();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to report playback stopped");
return NoContent();
}
}
/// <summary>
/// Pings a playback session to keep it alive.
/// </summary>
[HttpPost("Sessions/Playing/Ping")]
public async Task<IActionResult> PingPlaybackSession([FromQuery] string playSessionId)
{
try
{
_logger.LogDebug("Playback session ping: {SessionId}", playSessionId);
// Forward to Jellyfin
var endpoint = $"Sessions/Playing/Ping?playSessionId={Uri.EscapeDataString(playSessionId)}";
var (result, statusCode) = await _proxyService.PostJsonAsync(endpoint, "{}", Request.Headers);
return NoContent();
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to ping playback session");
return NoContent();
}
}
/// <summary>
/// Catch-all for any other session-related requests.
/// <summary>
/// Catch-all proxy for any other session-related endpoints we haven't explicitly implemented.
/// This ensures all session management calls get proxied to Jellyfin.
/// Examples: GET /Sessions, POST /Sessions/Logout, etc.
/// </summary>
[HttpGet("Sessions")]
[HttpPost("Sessions")]
[HttpGet("Sessions/{**path}")]
[HttpPost("Sessions/{**path}")]
[HttpPut("Sessions/{**path}")]
[HttpDelete("Sessions/{**path}")]
public async Task<IActionResult> ProxySessionRequest(string? path = null)
{
try
{
var method = Request.Method;
var queryString = Request.QueryString.HasValue ? Request.QueryString.Value : "";
var endpoint = string.IsNullOrEmpty(path) ? $"Sessions{queryString}" : $"Sessions/{path}{queryString}";
_logger.LogInformation("🔄 Proxying session request: {Method} {Endpoint}", method, endpoint);
_logger.LogDebug("Session proxy headers: {Headers}",
string.Join(", ", Request.Headers.Where(h => h.Key.Contains("Auth", StringComparison.OrdinalIgnoreCase))
.Select(h => $"{h.Key}={h.Value}")));
// Read body if present
string body = "{}";
if ((method == "POST" || method == "PUT") && Request.ContentLength > 0)
{
Request.EnableBuffering();
using (var reader = new StreamReader(Request.Body, System.Text.Encoding.UTF8, detectEncodingFromByteOrderMarks: false, bufferSize: 1024, leaveOpen: true))
{
body = await reader.ReadToEndAsync();
}
Request.Body.Position = 0;
_logger.LogDebug("Session proxy body: {Body}", body);
}
// Forward to Jellyfin
var (result, statusCode) = method switch
{
"GET" => await _proxyService.GetJsonAsync(endpoint, null, Request.Headers),
"POST" => await _proxyService.PostJsonAsync(endpoint, body, Request.Headers),
"PUT" => await _proxyService.PostJsonAsync(endpoint, body, Request.Headers), // Use POST for PUT
"DELETE" => await _proxyService.PostJsonAsync(endpoint, body, Request.Headers), // Use POST for DELETE
_ => (null, 405)
};
if (result != null)
{
_logger.LogInformation("✓ Session request proxied successfully ({StatusCode})", statusCode);
return new JsonResult(result.RootElement.Clone());
}
_logger.LogInformation("✓ Session request proxied ({StatusCode}, no body)", statusCode);
return StatusCode(statusCode);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to proxy session request: {Path}", path);
return StatusCode(500);
}
}
#endregion // Session Management
#endregion // Playback Session Reporting
#region System & Proxy #region System & Proxy
/// <summary> /// <summary>
@@ -1728,8 +2174,16 @@ public class JellyfinController : ControllerBase
[HttpPost("{**path}", Order = 100)] [HttpPost("{**path}", Order = 100)]
public async Task<IActionResult> ProxyRequest(string path) public async Task<IActionResult> ProxyRequest(string path)
{ {
// DEBUG: Log EVERY request to see what's happening // Log session-related requests prominently to debug missing capabilities call
_logger.LogWarning("ProxyRequest called with path: {Path}", path); if (path.Contains("session", StringComparison.OrdinalIgnoreCase) ||
path.Contains("capabilit", StringComparison.OrdinalIgnoreCase))
{
_logger.LogWarning("🔍 SESSION/CAPABILITY REQUEST: {Method} /{Path}{Query}", Request.Method, path, Request.QueryString);
}
else
{
_logger.LogDebug("ProxyRequest: {Method} /{Path}", Request.Method, path);
}
// Log endpoint usage to file for analysis // Log endpoint usage to file for analysis
await LogEndpointUsageAsync(path, Request.Method); await LogEndpointUsageAsync(path, Request.Method);
@@ -1865,6 +2319,7 @@ public class JellyfinController : ControllerBase
} }
JsonDocument? result; JsonDocument? result;
int statusCode;
if (HttpContext.Request.Method == HttpMethod.Post.Method) if (HttpContext.Request.Method == HttpMethod.Post.Method)
{ {
@@ -1906,18 +2361,44 @@ public class JellyfinController : ControllerBase
} }
} }
result = await _proxyService.PostJsonAsync(fullPath, body, Request.Headers); (result, statusCode) = await _proxyService.PostJsonAsync(fullPath, body, Request.Headers);
} }
else else
{ {
// Forward GET requests transparently with authentication headers and query string // Forward GET requests transparently with authentication headers and query string
result = await _proxyService.GetJsonAsync(fullPath, null, Request.Headers); (result, statusCode) = await _proxyService.GetJsonAsync(fullPath, null, Request.Headers);
} }
// Handle different status codes
if (result == null) if (result == null)
{ {
// Return 204 No Content for successful requests with no body // No body - return the status code from Jellyfin
// (e.g., /sessions/playing, /sessions/playing/progress) if (statusCode == 204)
{
return NoContent();
}
else if (statusCode == 401)
{
return Unauthorized();
}
else if (statusCode == 403)
{
return Forbid();
}
else if (statusCode == 404)
{
return NotFound();
}
else if (statusCode >= 400 && statusCode < 500)
{
return StatusCode(statusCode);
}
else if (statusCode >= 500)
{
return StatusCode(statusCode);
}
// Default to 204 for 2xx responses with no body
return NoContent(); return NoContent();
} }
@@ -1928,7 +2409,8 @@ public class JellyfinController : ControllerBase
result = await UpdateSpotifyPlaylistCounts(result); result = await UpdateSpotifyPlaylistCounts(result);
} }
return new JsonResult(JsonSerializer.Deserialize<object>(result.RootElement.GetRawText())); // Return the raw JSON element directly to avoid deserialization issues with simple types
return new JsonResult(result.RootElement.Clone());
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -1941,6 +2423,43 @@ public class JellyfinController : ControllerBase
#region Helpers #region Helpers
/// <summary>
/// Helper to handle proxy responses with proper status code handling.
/// </summary>
private IActionResult HandleProxyResponse(JsonDocument? result, int statusCode, object? fallbackValue = null)
{
if (result != null)
{
return new JsonResult(JsonSerializer.Deserialize<object>(result.RootElement.GetRawText()));
}
// Handle error status codes
if (statusCode == 401)
{
return Unauthorized();
}
else if (statusCode == 403)
{
return Forbid();
}
else if (statusCode == 404)
{
return NotFound();
}
else if (statusCode >= 400)
{
return StatusCode(statusCode);
}
// Success with no body - return fallback or empty
if (fallbackValue != null)
{
return new JsonResult(fallbackValue);
}
return NoContent();
}
/// <summary> /// <summary>
/// Updates ChildCount for Spotify playlists in the response to show total tracks (local + matched). /// Updates ChildCount for Spotify playlists in the response to show total tracks (local + matched).
/// </summary> /// </summary>
@@ -2184,7 +2703,7 @@ public class JellyfinController : ControllerBase
} }
// Get existing Jellyfin playlist items (tracks the plugin already found) // Get existing Jellyfin playlist items (tracks the plugin already found)
var existingTracksResponse = await _proxyService.GetJsonAsync( var (existingTracksResponse, _) = await _proxyService.GetJsonAsync(
$"Playlists/{playlistId}/Items", $"Playlists/{playlistId}/Items",
null, null,
Request.Headers); Request.Headers);

View File

@@ -0,0 +1,220 @@
using System.Net.WebSockets;
using Microsoft.Extensions.Options;
using allstarr.Models.Settings;
namespace allstarr.Middleware;
/// <summary>
/// Middleware that proxies WebSocket connections to Jellyfin server.
/// This enables real-time features like session tracking, remote control, and live updates.
/// </summary>
public class WebSocketProxyMiddleware
{
private readonly RequestDelegate _next;
private readonly JellyfinSettings _settings;
private readonly ILogger<WebSocketProxyMiddleware> _logger;
public WebSocketProxyMiddleware(
RequestDelegate next,
IOptions<JellyfinSettings> settings,
ILogger<WebSocketProxyMiddleware> logger)
{
_next = next;
_settings = settings.Value;
_logger = logger;
_logger.LogInformation("🔧 WebSocketProxyMiddleware initialized - Jellyfin URL: {Url}", _settings.Url);
}
public async Task InvokeAsync(HttpContext context)
{
// Log ALL requests to /socket path for debugging
if (context.Request.Path.StartsWithSegments("/socket", StringComparison.OrdinalIgnoreCase))
{
_logger.LogInformation("📡 Request to /socket path - IsWebSocketRequest: {IsWs}, Method: {Method}, Headers: {Headers}",
context.WebSockets.IsWebSocketRequest,
context.Request.Method,
string.Join(", ", context.Request.Headers.Select(h => $"{h.Key}={h.Value}")));
}
// Check if this is a WebSocket request to /socket
if (context.Request.Path.StartsWithSegments("/socket", StringComparison.OrdinalIgnoreCase) &&
context.WebSockets.IsWebSocketRequest)
{
_logger.LogInformation("🔌 WebSocket connection request received from {RemoteIp}",
context.Connection.RemoteIpAddress);
await HandleWebSocketProxyAsync(context);
return;
}
// Not a WebSocket request, pass to next middleware
await _next(context);
}
private async Task HandleWebSocketProxyAsync(HttpContext context)
{
ClientWebSocket? serverWebSocket = null;
WebSocket? clientWebSocket = null;
try
{
// Accept the WebSocket connection from the client
clientWebSocket = await context.WebSockets.AcceptWebSocketAsync();
_logger.LogInformation("✓ Client WebSocket accepted");
// Build Jellyfin WebSocket URL
var jellyfinUrl = _settings.Url?.TrimEnd('/') ?? "";
var wsScheme = jellyfinUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase) ? "wss://" : "ws://";
var jellyfinHost = jellyfinUrl.Replace("https://", "").Replace("http://", "");
var jellyfinWsUrl = $"{wsScheme}{jellyfinHost}/socket";
// Add query parameters if present (e.g., ?api_key=xxx or ?deviceId=xxx)
if (context.Request.QueryString.HasValue)
{
jellyfinWsUrl += context.Request.QueryString.Value;
}
_logger.LogInformation("Connecting to Jellyfin WebSocket: {Url}", jellyfinWsUrl);
// Connect to Jellyfin WebSocket
serverWebSocket = new ClientWebSocket();
// Forward authentication headers
if (context.Request.Headers.TryGetValue("Authorization", out var authHeader))
{
serverWebSocket.Options.SetRequestHeader("Authorization", authHeader.ToString());
_logger.LogDebug("Forwarded Authorization header");
}
else if (context.Request.Headers.TryGetValue("X-Emby-Authorization", out var embyAuthHeader))
{
serverWebSocket.Options.SetRequestHeader("X-Emby-Authorization", embyAuthHeader.ToString());
_logger.LogDebug("Forwarded X-Emby-Authorization header");
}
// Set user agent
serverWebSocket.Options.SetRequestHeader("User-Agent", "Allstarr/1.0");
await serverWebSocket.ConnectAsync(new Uri(jellyfinWsUrl), context.RequestAborted);
_logger.LogInformation("✓ Connected to Jellyfin WebSocket");
// Start bidirectional proxying
var clientToServer = ProxyMessagesAsync(clientWebSocket, serverWebSocket, "Client→Server", context.RequestAborted);
var serverToClient = ProxyMessagesAsync(serverWebSocket, clientWebSocket, "Server→Client", context.RequestAborted);
// Wait for either direction to complete
await Task.WhenAny(clientToServer, serverToClient);
_logger.LogInformation("WebSocket proxy connection closed");
}
catch (WebSocketException wsEx)
{
_logger.LogWarning(wsEx, "WebSocket error: {Message}", wsEx.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in WebSocket proxy");
}
finally
{
// Clean up connections
if (clientWebSocket?.State == WebSocketState.Open)
{
try
{
await clientWebSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Proxy closing", CancellationToken.None);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Error closing client WebSocket");
}
}
if (serverWebSocket?.State == WebSocketState.Open)
{
try
{
await serverWebSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Proxy closing", CancellationToken.None);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Error closing server WebSocket");
}
}
clientWebSocket?.Dispose();
serverWebSocket?.Dispose();
_logger.LogInformation("WebSocket connections cleaned up");
}
}
private async Task ProxyMessagesAsync(
WebSocket source,
WebSocket destination,
string direction,
CancellationToken cancellationToken)
{
var buffer = new byte[1024 * 4]; // 4KB buffer
var messageBuffer = new List<byte>();
try
{
while (source.State == WebSocketState.Open && destination.State == WebSocketState.Open)
{
var result = await source.ReceiveAsync(new ArraySegment<byte>(buffer), cancellationToken);
if (result.MessageType == WebSocketMessageType.Close)
{
_logger.LogInformation("{Direction}: Close message received", direction);
await destination.CloseAsync(
result.CloseStatus ?? WebSocketCloseStatus.NormalClosure,
result.CloseStatusDescription,
cancellationToken);
break;
}
// Accumulate message fragments
messageBuffer.AddRange(buffer.Take(result.Count));
// If this is the end of the message, forward it
if (result.EndOfMessage)
{
var messageBytes = messageBuffer.ToArray();
// Log message for debugging (only in debug mode to avoid spam)
if (_logger.IsEnabled(LogLevel.Debug))
{
var messageText = System.Text.Encoding.UTF8.GetString(messageBytes);
_logger.LogDebug("{Direction}: {MessageType} message ({Size} bytes): {Preview}",
direction,
result.MessageType,
messageBytes.Length,
messageText.Length > 200 ? messageText[..200] + "..." : messageText);
}
// Forward the complete message
await destination.SendAsync(
new ArraySegment<byte>(messageBytes),
result.MessageType,
true,
cancellationToken);
messageBuffer.Clear();
}
}
}
catch (OperationCanceledException)
{
_logger.LogInformation("{Direction}: Operation cancelled", direction);
}
catch (WebSocketException wsEx) when (wsEx.WebSocketErrorCode == WebSocketError.ConnectionClosedPrematurely)
{
_logger.LogInformation("{Direction}: Connection closed prematurely", direction);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "{Direction}: Error proxying messages", direction);
}
}
}

View File

@@ -288,6 +288,15 @@ app.UseExceptionHandler(_ => { }); // Global exception handler
// Enable response compression EARLY in the pipeline // Enable response compression EARLY in the pipeline
app.UseResponseCompression(); app.UseResponseCompression();
// Enable WebSocket support
app.UseWebSockets(new WebSocketOptions
{
KeepAliveInterval = TimeSpan.FromSeconds(120)
});
// Add WebSocket proxy middleware (BEFORE routing)
app.UseMiddleware<WebSocketProxyMiddleware>();
if (app.Environment.IsDevelopment()) if (app.Environment.IsDevelopment())
{ {
app.UseSwagger(); app.UseSwagger();

View File

@@ -103,8 +103,9 @@ public class JellyfinProxyService
/// <summary> /// <summary>
/// Sends a GET request to the Jellyfin server. /// Sends a GET request to the Jellyfin server.
/// If endpoint already contains query parameters, they will be preserved and merged with queryParams. /// If endpoint already contains query parameters, they will be preserved and merged with queryParams.
/// Returns the response body and HTTP status code.
/// </summary> /// </summary>
public async Task<JsonDocument?> GetJsonAsync(string endpoint, Dictionary<string, string>? queryParams = null, IHeaderDictionary? clientHeaders = null) public async Task<(JsonDocument? Body, int StatusCode)> GetJsonAsync(string endpoint, Dictionary<string, string>? queryParams = null, IHeaderDictionary? clientHeaders = null)
{ {
// If endpoint contains query string, parse and merge with queryParams // If endpoint contains query string, parse and merge with queryParams
if (endpoint.Contains('?')) if (endpoint.Contains('?'))
@@ -141,12 +142,21 @@ public class JellyfinProxyService
return await GetJsonAsyncInternal(finalUrl, clientHeaders); return await GetJsonAsyncInternal(finalUrl, clientHeaders);
} }
private async Task<JsonDocument?> GetJsonAsyncInternal(string url, IHeaderDictionary? clientHeaders) private async Task<(JsonDocument? Body, int StatusCode)> GetJsonAsyncInternal(string url, IHeaderDictionary? clientHeaders)
{ {
using var request = new HttpRequestMessage(HttpMethod.Get, url); using var request = new HttpRequestMessage(HttpMethod.Get, url);
bool authHeaderAdded = false; bool authHeaderAdded = false;
// Check if this is a browser request for static assets (favicon, etc.)
bool isBrowserStaticRequest = url.Contains("/favicon.ico", StringComparison.OrdinalIgnoreCase) ||
url.Contains("/web/", StringComparison.OrdinalIgnoreCase) ||
(clientHeaders?.Any(h => h.Key.Equals("User-Agent", StringComparison.OrdinalIgnoreCase) &&
h.Value.ToString().Contains("Mozilla", StringComparison.OrdinalIgnoreCase)) == true &&
clientHeaders?.Any(h => h.Key.Equals("sec-fetch-dest", StringComparison.OrdinalIgnoreCase) &&
(h.Value.ToString().Contains("image", StringComparison.OrdinalIgnoreCase) ||
h.Value.ToString().Contains("document", StringComparison.OrdinalIgnoreCase))) == true);
// Forward authentication headers from client if provided // Forward authentication headers from client if provided
if (clientHeaders != null && clientHeaders.Count > 0) if (clientHeaders != null && clientHeaders.Count > 0)
{ {
@@ -194,20 +204,21 @@ public class JellyfinProxyService
} }
} }
if (!authHeaderAdded) // Only log warnings for non-browser static requests
if (!authHeaderAdded && !isBrowserStaticRequest)
{ {
_logger.LogWarning("✗ No auth header found. Available headers: {Headers}", _logger.LogWarning("✗ No auth header found. Available headers: {Headers}",
string.Join(", ", clientHeaders.Select(h => $"{h.Key}={h.Value}"))); string.Join(", ", clientHeaders.Select(h => $"{h.Key}={h.Value}")));
} }
} }
else else if (!isBrowserStaticRequest)
{ {
_logger.LogWarning("✗ No client headers provided for {Url}", url); _logger.LogWarning("✗ No client headers provided for {Url}", url);
} }
// DO NOT use server API key as fallback - let Jellyfin handle unauthenticated requests // DO NOT use server API key as fallback - let Jellyfin handle unauthenticated requests
// If client doesn't provide auth, they get what they deserve (401 from Jellyfin) // If client doesn't provide auth, they get what they deserve (401 from Jellyfin)
if (!authHeaderAdded) if (!authHeaderAdded && !isBrowserStaticRequest)
{ {
_logger.LogInformation("No client auth provided for {Url} - forwarding without auth", url); _logger.LogInformation("No client auth provided for {Url} - forwarding without auth", url);
} }
@@ -216,6 +227,8 @@ public class JellyfinProxyService
var response = await _httpClient.SendAsync(request); var response = await _httpClient.SendAsync(request);
var statusCode = (int)response.StatusCode;
// Always parse the response, even for errors // Always parse the response, even for errors
// The caller needs to see 401s so the client can re-authenticate // The caller needs to see 401s so the client can re-authenticate
var content = await response.Content.ReadAsStringAsync(); var content = await response.Content.ReadAsStringAsync();
@@ -226,24 +239,24 @@ public class JellyfinProxyService
{ {
_logger.LogWarning("Jellyfin returned 401 Unauthorized for {Url} - passing through to client", url); _logger.LogWarning("Jellyfin returned 401 Unauthorized for {Url} - passing through to client", url);
} }
else else if (!isBrowserStaticRequest) // Don't log 404s for browser static requests
{ {
_logger.LogWarning("Jellyfin request failed: {StatusCode} for {Url}", response.StatusCode, url); _logger.LogWarning("Jellyfin request failed: {StatusCode} for {Url}", response.StatusCode, url);
} }
// Return null so caller knows request failed // Return null body with the actual status code
// TODO: We should return the status code too so caller can pass it through return (null, statusCode);
return null;
} }
return JsonDocument.Parse(content); return (JsonDocument.Parse(content), statusCode);
} }
/// <summary> /// <summary>
/// Sends a POST request to the Jellyfin server with JSON body. /// Sends a POST request to the Jellyfin server with JSON body.
/// Forwards client headers for authentication passthrough. /// Forwards client headers for authentication passthrough.
/// Returns the response body and HTTP status code.
/// </summary> /// </summary>
public async Task<JsonDocument?> PostJsonAsync(string endpoint, string body, IHeaderDictionary clientHeaders) public async Task<(JsonDocument? Body, int StatusCode)> PostJsonAsync(string endpoint, string body, IHeaderDictionary clientHeaders)
{ {
var url = BuildUrl(endpoint, null); var url = BuildUrl(endpoint, null);
@@ -354,18 +367,20 @@ public class JellyfinProxyService
var response = await _httpClient.SendAsync(request); var response = await _httpClient.SendAsync(request);
var statusCode = (int)response.StatusCode;
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
{ {
var errorContent = await response.Content.ReadAsStringAsync(); var errorContent = await response.Content.ReadAsStringAsync();
_logger.LogWarning("Jellyfin POST request failed: {StatusCode} for {Url}. Response: {Response}", _logger.LogWarning("Jellyfin POST request failed: {StatusCode} for {Url}. Response: {Response}",
response.StatusCode, url, errorContent); response.StatusCode, url, errorContent);
return null; return (null, statusCode);
} }
// Handle 204 No Content responses (e.g., /sessions/playing, /sessions/playing/progress) // Handle 204 No Content responses (e.g., /sessions/playing, /sessions/playing/progress)
if (response.StatusCode == System.Net.HttpStatusCode.NoContent) if (response.StatusCode == System.Net.HttpStatusCode.NoContent)
{ {
return null; return (null, statusCode);
} }
var responseContent = await response.Content.ReadAsStringAsync(); var responseContent = await response.Content.ReadAsStringAsync();
@@ -373,10 +388,10 @@ public class JellyfinProxyService
// Handle empty responses // Handle empty responses
if (string.IsNullOrWhiteSpace(responseContent)) if (string.IsNullOrWhiteSpace(responseContent))
{ {
return null; return (null, statusCode);
} }
return JsonDocument.Parse(responseContent); return (JsonDocument.Parse(responseContent), statusCode);
} }
/// <summary> /// <summary>
@@ -401,8 +416,9 @@ public class JellyfinProxyService
/// <summary> /// <summary>
/// Sends a DELETE request to the Jellyfin server. /// Sends a DELETE request to the Jellyfin server.
/// Forwards client headers for authentication passthrough. /// Forwards client headers for authentication passthrough.
/// Returns the response body and HTTP status code.
/// </summary> /// </summary>
public async Task<JsonDocument?> DeleteAsync(string endpoint, IHeaderDictionary clientHeaders) public async Task<(JsonDocument? Body, int StatusCode)> DeleteAsync(string endpoint, IHeaderDictionary clientHeaders)
{ {
var url = BuildUrl(endpoint, null); var url = BuildUrl(endpoint, null);
@@ -462,18 +478,20 @@ public class JellyfinProxyService
var response = await _httpClient.SendAsync(request); var response = await _httpClient.SendAsync(request);
var statusCode = (int)response.StatusCode;
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
{ {
var errorContent = await response.Content.ReadAsStringAsync(); var errorContent = await response.Content.ReadAsStringAsync();
_logger.LogWarning("Jellyfin DELETE request failed: {StatusCode} for {Url}. Response: {Response}", _logger.LogWarning("Jellyfin DELETE request failed: {StatusCode} for {Url}. Response: {Response}",
response.StatusCode, url, errorContent); response.StatusCode, url, errorContent);
return null; return (null, statusCode);
} }
// Handle 204 No Content responses // Handle 204 No Content responses
if (response.StatusCode == System.Net.HttpStatusCode.NoContent) if (response.StatusCode == System.Net.HttpStatusCode.NoContent)
{ {
return null; return (null, statusCode);
} }
var responseContent = await response.Content.ReadAsStringAsync(); var responseContent = await response.Content.ReadAsStringAsync();
@@ -481,10 +499,10 @@ public class JellyfinProxyService
// Handle empty responses // Handle empty responses
if (string.IsNullOrWhiteSpace(responseContent)) if (string.IsNullOrWhiteSpace(responseContent))
{ {
return null; return (null, statusCode);
} }
return JsonDocument.Parse(responseContent); return (JsonDocument.Parse(responseContent), statusCode);
} }
/// <summary> /// <summary>
@@ -510,7 +528,7 @@ public class JellyfinProxyService
/// Searches for items in Jellyfin. /// Searches for items in Jellyfin.
/// Uses configured or auto-detected LibraryId to filter search to music library only. /// Uses configured or auto-detected LibraryId to filter search to music library only.
/// </summary> /// </summary>
public async Task<JsonDocument?> SearchAsync( public async Task<(JsonDocument? Body, int StatusCode)> SearchAsync(
string searchTerm, string searchTerm,
string[]? includeItemTypes = null, string[]? includeItemTypes = null,
int limit = 20, int limit = 20,
@@ -548,7 +566,7 @@ public class JellyfinProxyService
/// <summary> /// <summary>
/// Gets items from a specific parent (album, artist, playlist). /// Gets items from a specific parent (album, artist, playlist).
/// </summary> /// </summary>
public async Task<JsonDocument?> GetItemsAsync( public async Task<(JsonDocument? Body, int StatusCode)> GetItemsAsync(
string? parentId = null, string? parentId = null,
string[]? includeItemTypes = null, string[]? includeItemTypes = null,
string? sortBy = null, string? sortBy = null,
@@ -604,7 +622,7 @@ public class JellyfinProxyService
/// <summary> /// <summary>
/// Gets a single item by ID. /// Gets a single item by ID.
/// </summary> /// </summary>
public async Task<JsonDocument?> GetItemAsync(string itemId, IHeaderDictionary? clientHeaders = null) public async Task<(JsonDocument? Body, int StatusCode)> GetItemAsync(string itemId, IHeaderDictionary? clientHeaders = null)
{ {
var queryParams = new Dictionary<string, string>(); var queryParams = new Dictionary<string, string>();
@@ -619,7 +637,7 @@ public class JellyfinProxyService
/// <summary> /// <summary>
/// Gets artists from the library. /// Gets artists from the library.
/// </summary> /// </summary>
public async Task<JsonDocument?> GetArtistsAsync( public async Task<(JsonDocument? Body, int StatusCode)> GetArtistsAsync(
string? searchTerm = null, string? searchTerm = null,
int? limit = null, int? limit = null,
int? startIndex = null, int? startIndex = null,
@@ -656,7 +674,7 @@ public class JellyfinProxyService
/// <summary> /// <summary>
/// Gets an artist by name or ID. /// Gets an artist by name or ID.
/// </summary> /// </summary>
public async Task<JsonDocument?> GetArtistAsync(string artistIdOrName, IHeaderDictionary? clientHeaders = null) public async Task<(JsonDocument? Body, int StatusCode)> GetArtistAsync(string artistIdOrName, IHeaderDictionary? clientHeaders = null)
{ {
var queryParams = new Dictionary<string, string>(); var queryParams = new Dictionary<string, string>();
@@ -817,8 +835,8 @@ public class JellyfinProxyService
{ {
try try
{ {
var result = await GetJsonAsync("System/Info/Public"); var (result, statusCode) = await GetJsonAsync("System/Info/Public");
if (result == null) if (result == null || statusCode != 200)
{ {
return (false, null, null); return (false, null, null);
} }
@@ -852,7 +870,7 @@ public class JellyfinProxyService
queryParams["userId"] = _settings.UserId; queryParams["userId"] = _settings.UserId;
} }
var result = await GetJsonAsync("Library/MediaFolders", queryParams); var (result, statusCode) = await GetJsonAsync("Library/MediaFolders", queryParams);
if (result == null) if (result == null)
{ {
return null; return null;