mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-10 07:58:39 -05:00
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
This commit is contained in:
@@ -113,12 +113,18 @@ 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)
|
||||||
return Unauthorized(new { error = "Authentication required" });
|
{
|
||||||
|
_logger.LogInformation("Jellyfin returned 401 Unauthorized, returning 401 to client");
|
||||||
|
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
|
||||||
@@ -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,19 +581,14 @@ 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>
|
||||||
@@ -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",
|
||||||
@@ -992,7 +984,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")
|
||||||
{
|
{
|
||||||
@@ -1213,15 +1205,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,20 +1249,13 @@ 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
|
IsFavorite = false,
|
||||||
_logger.LogInformation("Unfavorite succeeded (no content returned)");
|
ItemId = itemId
|
||||||
return Ok(new
|
});
|
||||||
{
|
|
||||||
IsFavorite = false,
|
|
||||||
ItemId = itemId
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return new JsonResult(JsonSerializer.Deserialize<object>(result.RootElement.GetRawText()));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
@@ -1349,7 +1328,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 +1351,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,12 +1421,16 @@ 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");
|
||||||
@@ -1558,18 +1537,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,18 +1642,228 @@ 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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Playback Session Reporting
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reports playback start. Handles both local and external tracks.
|
||||||
|
/// For local tracks, forwards to Jellyfin. For external tracks, logs locally.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("Sessions/Playing")]
|
||||||
|
public async Task<IActionResult> ReportPlaybackStart()
|
||||||
|
{
|
||||||
|
try
|
||||||
{
|
{
|
||||||
return _responseBuilder.CreateJsonResponse(new
|
Request.EnableBuffering();
|
||||||
|
string body;
|
||||||
|
using (var reader = new StreamReader(Request.Body, System.Text.Encoding.UTF8, detectEncodingFromByteOrderMarks: false, bufferSize: 1024, leaveOpen: true))
|
||||||
{
|
{
|
||||||
Items = Array.Empty<object>(),
|
body = await reader.ReadToEndAsync();
|
||||||
TotalRecordCount = 0
|
}
|
||||||
});
|
Request.Body.Position = 0;
|
||||||
}
|
|
||||||
|
|
||||||
return new JsonResult(JsonSerializer.Deserialize<object>(result.RootElement.GetRawText()));
|
_logger.LogInformation("📻 Playback START reported");
|
||||||
|
|
||||||
|
// 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 failed with 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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
@@ -1865,6 +2045,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 +2087,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();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1941,6 +2148,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 +2428,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);
|
||||||
|
|||||||
@@ -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,7 +142,7 @@ 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);
|
||||||
|
|
||||||
@@ -216,6 +217,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();
|
||||||
@@ -231,19 +234,19 @@ public class JellyfinProxyService
|
|||||||
_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 +357,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 +378,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 +406,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 +468,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 +489,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 +518,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 +556,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 +612,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 +627,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 +664,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 +825,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 +860,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;
|
||||||
|
|||||||
Reference in New Issue
Block a user