mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-09 23:55:10 -05:00
Compare commits
9 Commits
8e6eb5cc4a
...
936fa27aa7
| Author | SHA1 | Date | |
|---|---|---|---|
|
936fa27aa7
|
|||
|
b90ce423d7
|
|||
|
5f038965a2
|
|||
|
229fa0bf65
|
|||
|
1aec76c3dd
|
|||
|
96b06d5d4f
|
|||
|
747d310375
|
|||
|
fc78a095a9
|
|||
|
65ca80f9a0
|
@@ -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);
|
||||||
|
|||||||
220
allstarr/Middleware/WebSocketProxyMiddleware.cs
Normal file
220
allstarr/Middleware/WebSocketProxyMiddleware.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user