Files
allstarr/allstarr/Services/Jellyfin/JellyfinProxyService.cs
Josh Patra dfd7d678e7 Add internal API method and fix playlist count authentication
- Added GetJsonAsyncInternal method to JellyfinProxyService for server-side requests
- Uses server API key instead of client tokens for internal operations
- Updated UpdateSpotifyPlaylistCounts to use internal method with proper authentication
- This should resolve 401 Unauthorized errors when updating playlist counts

Now Spotify playlists should show correct track counts without authentication issues.
2026-02-04 23:42:16 -05:00

1010 lines
38 KiB
C#

using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using allstarr.Models.Settings;
using allstarr.Services.Common;
using System.Net.Http.Headers;
using System.Text.Json;
namespace allstarr.Services.Jellyfin;
/// <summary>
/// Handles proxying requests to the Jellyfin server and authentication.
/// </summary>
public class JellyfinProxyService
{
private readonly HttpClient _httpClient;
private readonly JellyfinSettings _settings;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly ILogger<JellyfinProxyService> _logger;
private readonly RedisCacheService _cache;
private string? _cachedMusicLibraryId;
private bool _libraryIdDetected = false;
// Expose HttpClient for direct streaming scenarios
public HttpClient HttpClient => _httpClient;
public JellyfinProxyService(
IHttpClientFactory httpClientFactory,
IOptions<JellyfinSettings> settings,
IHttpContextAccessor httpContextAccessor,
ILogger<JellyfinProxyService> logger,
RedisCacheService cache)
{
_httpClient = httpClientFactory.CreateClient();
_settings = settings.Value;
_httpContextAccessor = httpContextAccessor;
_logger = logger;
_cache = cache;
}
/// <summary>
/// Gets the music library ID, auto-detecting it if not configured.
/// </summary>
private async Task<string?> GetMusicLibraryIdAsync()
{
// Return configured library ID if set
if (!string.IsNullOrEmpty(_settings.LibraryId))
{
return _settings.LibraryId;
}
// Return cached value if already detected
if (_libraryIdDetected)
{
return _cachedMusicLibraryId;
}
// Auto-detect music library ID
try
{
_logger.LogInformation("Auto-detecting music library ID...");
_cachedMusicLibraryId = await GetMusicLibraryIdInternalAsync();
_libraryIdDetected = true;
if (!string.IsNullOrEmpty(_cachedMusicLibraryId))
{
_logger.LogInformation("Music library auto-detected: {LibraryId}", _cachedMusicLibraryId);
}
else
{
_logger.LogWarning("Could not auto-detect music library. All content types will be visible. Set JELLYFIN_LIBRARY_ID to filter to music only.");
}
return _cachedMusicLibraryId;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to auto-detect music library ID");
_libraryIdDetected = true; // Don't keep trying
return null;
}
}
/// <summary>
/// Public method for controllers to get the music library ID for filtering.
/// </summary>
public async Task<string?> GetMusicLibraryIdForFilteringAsync()
{
return await GetMusicLibraryIdAsync();
}
/// <summary>
/// Gets the authorization header value for Jellyfin API requests.
/// </summary>
private string GetAuthorizationHeader()
{
return $"MediaBrowser Client=\"{_settings.ClientName}\", " +
$"Device=\"{_settings.DeviceName}\", " +
$"DeviceId=\"{_settings.DeviceId}\", " +
$"Version=\"{_settings.ClientVersion}\", " +
$"Token=\"{_settings.ApiKey}\"";
}
/// <summary>
/// Sends a GET request to the Jellyfin server.
/// If endpoint already contains query parameters, they will be preserved and merged with queryParams.
/// Returns the response body and HTTP status code.
/// </summary>
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('?'))
{
var parts = endpoint.Split('?', 2);
var baseEndpoint = parts[0];
var existingQuery = parts[1];
// Parse existing query string
var mergedParams = new Dictionary<string, string>();
foreach (var param in existingQuery.Split('&'))
{
var kv = param.Split('=', 2);
if (kv.Length == 2)
{
mergedParams[Uri.UnescapeDataString(kv[0])] = Uri.UnescapeDataString(kv[1]);
}
}
// Merge with provided queryParams (provided params take precedence)
if (queryParams != null)
{
foreach (var kv in queryParams)
{
mergedParams[kv.Key] = kv.Value;
}
}
var url = BuildUrl(baseEndpoint, mergedParams);
return await GetJsonAsyncInternal(url, clientHeaders);
}
var finalUrl = BuildUrl(endpoint, queryParams);
return await GetJsonAsyncInternal(finalUrl, clientHeaders);
}
private async Task<(JsonDocument? Body, int StatusCode)> GetJsonAsyncInternal(string url, IHeaderDictionary? clientHeaders)
{
using var request = new HttpRequestMessage(HttpMethod.Get, url);
// Forward client IP address to Jellyfin so it can identify the real client
if (_httpContextAccessor.HttpContext != null)
{
var clientIp = _httpContextAccessor.HttpContext.Connection.RemoteIpAddress?.ToString();
if (!string.IsNullOrEmpty(clientIp))
{
request.Headers.TryAddWithoutValidation("X-Forwarded-For", clientIp);
request.Headers.TryAddWithoutValidation("X-Real-IP", clientIp);
}
}
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
if (clientHeaders != null && clientHeaders.Count > 0)
{
// Try X-Emby-Authorization first (case-insensitive)
foreach (var header in clientHeaders)
{
if (header.Key.Equals("X-Emby-Authorization", StringComparison.OrdinalIgnoreCase))
{
var headerValue = header.Value.ToString();
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue);
authHeaderAdded = true;
_logger.LogInformation("✓ Forwarded X-Emby-Authorization: {Value}", headerValue);
break;
}
}
// If no X-Emby-Authorization, check if Authorization header contains MediaBrowser format
// Some clients send it as "Authorization" instead of "X-Emby-Authorization"
if (!authHeaderAdded)
{
foreach (var header in clientHeaders)
{
if (header.Key.Equals("Authorization", StringComparison.OrdinalIgnoreCase))
{
var headerValue = header.Value.ToString();
// Check if it's MediaBrowser/Jellyfin format (contains "MediaBrowser" or "Token=")
if (headerValue.Contains("MediaBrowser", StringComparison.OrdinalIgnoreCase) ||
headerValue.Contains("Token=", StringComparison.OrdinalIgnoreCase))
{
// Forward as X-Emby-Authorization (Jellyfin's expected header)
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue);
authHeaderAdded = true;
_logger.LogInformation("✓ Converted Authorization to X-Emby-Authorization: {Value}", headerValue);
}
else
{
// Standard Bearer token - forward as-is
request.Headers.TryAddWithoutValidation("Authorization", headerValue);
authHeaderAdded = true;
_logger.LogInformation("✓ Forwarded Authorization (Bearer): {Value}", headerValue);
}
break;
}
}
}
// Only log warnings for non-browser static requests
if (!authHeaderAdded && !isBrowserStaticRequest)
{
_logger.LogWarning("✗ No auth header found. Available headers: {Headers}",
string.Join(", ", clientHeaders.Select(h => $"{h.Key}={h.Value}")));
}
}
else if (!isBrowserStaticRequest)
{
_logger.LogWarning("✗ No client headers provided for {Url}", url);
}
// 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 (!authHeaderAdded && !isBrowserStaticRequest)
{
_logger.LogInformation("No client auth provided for {Url} - forwarding without auth", url);
}
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var response = await _httpClient.SendAsync(request);
var statusCode = (int)response.StatusCode;
// Always parse the response, even for errors
// The caller needs to see 401s so the client can re-authenticate
var content = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{
_logger.LogWarning("Jellyfin returned 401 Unauthorized for {Url} - passing through to client", url);
}
else if (!isBrowserStaticRequest) // Don't log 404s for browser static requests
{
_logger.LogWarning("Jellyfin request failed: {StatusCode} for {Url}", response.StatusCode, url);
}
// Return null body with the actual status code
return (null, statusCode);
}
return (JsonDocument.Parse(content), statusCode);
}
/// <summary>
/// Sends a POST request to the Jellyfin server with JSON body.
/// Forwards client headers for authentication passthrough.
/// Returns the response body and HTTP status code.
/// </summary>
public async Task<(JsonDocument? Body, int StatusCode)> PostJsonAsync(string endpoint, string body, IHeaderDictionary clientHeaders)
{
var url = BuildUrl(endpoint, null);
using var request = new HttpRequestMessage(HttpMethod.Post, url);
// Forward client IP address to Jellyfin so it can identify the real client
if (_httpContextAccessor.HttpContext != null)
{
var clientIp = _httpContextAccessor.HttpContext.Connection.RemoteIpAddress?.ToString();
if (!string.IsNullOrEmpty(clientIp))
{
request.Headers.TryAddWithoutValidation("X-Forwarded-For", clientIp);
request.Headers.TryAddWithoutValidation("X-Real-IP", clientIp);
}
}
// Handle special case for playback endpoints
// NOTE: Jellyfin API expects PlaybackStartInfo/PlaybackProgressInfo/PlaybackStopInfo
// DIRECTLY as the body, NOT wrapped in a field. Do NOT wrap the body.
var bodyToSend = body;
if (string.IsNullOrWhiteSpace(body))
{
bodyToSend = "{}";
_logger.LogWarning("POST body was empty for {Url}, sending empty JSON object", url);
}
request.Content = new StringContent(bodyToSend, System.Text.Encoding.UTF8, "application/json");
bool authHeaderAdded = false;
// Forward authentication headers from client (case-insensitive)
foreach (var header in clientHeaders)
{
if (header.Key.Equals("X-Emby-Authorization", StringComparison.OrdinalIgnoreCase))
{
var headerValue = header.Value.ToString();
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue);
authHeaderAdded = true;
_logger.LogDebug("Forwarded X-Emby-Authorization from client");
break;
}
}
if (!authHeaderAdded)
{
foreach (var header in clientHeaders)
{
if (header.Key.Equals("Authorization", StringComparison.OrdinalIgnoreCase))
{
var headerValue = header.Value.ToString();
// Check if it's MediaBrowser/Jellyfin format
if (headerValue.Contains("MediaBrowser", StringComparison.OrdinalIgnoreCase) ||
headerValue.Contains("Client=", StringComparison.OrdinalIgnoreCase))
{
// Forward as X-Emby-Authorization
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue);
_logger.LogDebug("Converted Authorization to X-Emby-Authorization");
}
else
{
// Standard Bearer token
request.Headers.TryAddWithoutValidation("Authorization", headerValue);
_logger.LogDebug("Forwarded Authorization header");
}
authHeaderAdded = true;
break;
}
}
}
// DO NOT use server credentials as fallback
// Exception: For auth endpoints, client provides their own credentials in the body
// For all other endpoints, if client doesn't provide auth, let Jellyfin reject it
if (!authHeaderAdded)
{
_logger.LogInformation("No client auth provided for POST {Url} - forwarding without auth", url);
}
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
// DO NOT log the body for auth endpoints - it contains passwords!
if (endpoint.Contains("Authenticate", StringComparison.OrdinalIgnoreCase))
{
_logger.LogDebug("POST to Jellyfin: {Url} (auth request - body not logged)", url);
}
else
{
_logger.LogInformation("POST to Jellyfin: {Url}, body length: {Length} bytes", url, bodyToSend.Length);
// Log body content for playback endpoints to debug
if (endpoint.Contains("Playing", StringComparison.OrdinalIgnoreCase))
{
_logger.LogInformation("Sending body to Jellyfin: {Body}", bodyToSend);
}
}
var response = await _httpClient.SendAsync(request);
var statusCode = (int)response.StatusCode;
if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync();
_logger.LogWarning("❌ SESSION: Jellyfin POST request failed: {StatusCode} for {Url}. Response: {Response}",
response.StatusCode, url, errorContent);
return (null, statusCode);
}
// Log successful session-related responses
if (endpoint.Contains("Sessions", StringComparison.OrdinalIgnoreCase))
{
_logger.LogWarning("✓ SESSION: Jellyfin responded {StatusCode} for {Endpoint}", statusCode, endpoint);
}
// Handle 204 No Content responses (e.g., /sessions/playing, /sessions/playing/progress)
if (response.StatusCode == System.Net.HttpStatusCode.NoContent)
{
return (null, statusCode);
}
var responseContent = await response.Content.ReadAsStringAsync();
// Handle empty responses
if (string.IsNullOrWhiteSpace(responseContent))
{
return (null, statusCode);
}
// Log response content for session endpoints
if (endpoint.Contains("Sessions", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(responseContent))
{
var preview = responseContent.Length > 200 ? responseContent[..200] + "..." : responseContent;
_logger.LogWarning("📥 SESSION: Jellyfin response body: {Body}", preview);
}
return (JsonDocument.Parse(responseContent), statusCode);
}
/// <summary>
/// Sends a GET request and returns raw bytes (for images, audio streams).
/// WARNING: This loads entire response into memory - use StreamAsync for large files!
/// </summary>
public async Task<(byte[] Body, string? ContentType)> GetBytesAsync(string endpoint, Dictionary<string, string>? queryParams = null)
{
var url = BuildUrl(endpoint, queryParams);
using var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Add("Authorization", GetAuthorizationHeader());
var response = await _httpClient.SendAsync(request);
response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsByteArrayAsync();
var contentType = response.Content.Headers.ContentType?.ToString();
// Trigger GC for large files to prevent memory leaks
if (body.Length > 1024 * 1024) // 1MB threshold
{
GC.Collect(2, GCCollectionMode.Optimized, blocking: false);
}
return (body, contentType);
}
/// <summary>
/// Streams content directly without loading into memory (for large files like audio).
/// </summary>
public async Task<(Stream Stream, string? ContentType, long? ContentLength)> GetStreamAsync(string endpoint, Dictionary<string, string>? queryParams = null)
{
var url = BuildUrl(endpoint, queryParams);
using var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Add("Authorization", GetAuthorizationHeader());
var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
response.EnsureSuccessStatusCode();
var stream = await response.Content.ReadAsStreamAsync();
var contentType = response.Content.Headers.ContentType?.ToString();
var contentLength = response.Content.Headers.ContentLength;
return (stream, contentType, contentLength);
}
/// <summary>
/// Sends a DELETE request to the Jellyfin server.
/// Forwards client headers for authentication passthrough.
/// Returns the response body and HTTP status code.
/// </summary>
public async Task<(JsonDocument? Body, int StatusCode)> DeleteAsync(string endpoint, IHeaderDictionary clientHeaders)
{
var url = BuildUrl(endpoint, null);
using var request = new HttpRequestMessage(HttpMethod.Delete, url);
// Forward client IP address to Jellyfin so it can identify the real client
if (_httpContextAccessor.HttpContext != null)
{
var clientIp = _httpContextAccessor.HttpContext.Connection.RemoteIpAddress?.ToString();
if (!string.IsNullOrEmpty(clientIp))
{
request.Headers.TryAddWithoutValidation("X-Forwarded-For", clientIp);
request.Headers.TryAddWithoutValidation("X-Real-IP", clientIp);
}
}
bool authHeaderAdded = false;
// Forward authentication headers from client (case-insensitive)
foreach (var header in clientHeaders)
{
if (header.Key.Equals("X-Emby-Authorization", StringComparison.OrdinalIgnoreCase))
{
var headerValue = header.Value.ToString();
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue);
authHeaderAdded = true;
_logger.LogDebug("Forwarded X-Emby-Authorization from client");
break;
}
}
if (!authHeaderAdded)
{
foreach (var header in clientHeaders)
{
if (header.Key.Equals("Authorization", StringComparison.OrdinalIgnoreCase))
{
var headerValue = header.Value.ToString();
// Check if it's MediaBrowser/Jellyfin format
if (headerValue.Contains("MediaBrowser", StringComparison.OrdinalIgnoreCase) ||
headerValue.Contains("Client=", StringComparison.OrdinalIgnoreCase))
{
// Forward as X-Emby-Authorization
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue);
_logger.LogDebug("Converted Authorization to X-Emby-Authorization");
}
else
{
// Standard Bearer token
request.Headers.TryAddWithoutValidation("Authorization", headerValue);
_logger.LogDebug("Forwarded Authorization header");
}
authHeaderAdded = true;
break;
}
}
}
if (!authHeaderAdded)
{
_logger.LogInformation("No client auth provided for DELETE {Url} - forwarding without auth", url);
}
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
_logger.LogInformation("DELETE to Jellyfin: {Url}", url);
var response = await _httpClient.SendAsync(request);
var statusCode = (int)response.StatusCode;
if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync();
_logger.LogWarning("Jellyfin DELETE request failed: {StatusCode} for {Url}. Response: {Response}",
response.StatusCode, url, errorContent);
return (null, statusCode);
}
// Handle 204 No Content responses
if (response.StatusCode == System.Net.HttpStatusCode.NoContent)
{
return (null, statusCode);
}
var responseContent = await response.Content.ReadAsStringAsync();
// Handle empty responses
if (string.IsNullOrWhiteSpace(responseContent))
{
return (null, statusCode);
}
return (JsonDocument.Parse(responseContent), statusCode);
}
/// <summary>
/// Safely sends a GET request to the Jellyfin server, returning null on failure.
/// </summary>
public async Task<(byte[]? Body, string? ContentType, bool Success)> GetBytesSafeAsync(
string endpoint,
Dictionary<string, string>? queryParams = null)
{
try
{
var result = await GetBytesAsync(endpoint, queryParams);
return (result.Body, result.ContentType, true);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to get bytes from {Endpoint}", endpoint);
return (null, null, false);
}
}
/// <summary>
/// Searches for items in Jellyfin.
/// Uses configured or auto-detected LibraryId to filter search to music library only.
/// </summary>
public async Task<(JsonDocument? Body, int StatusCode)> SearchAsync(
string searchTerm,
string[]? includeItemTypes = null,
int limit = 20,
bool recursive = true,
IHeaderDictionary? clientHeaders = null)
{
var queryParams = new Dictionary<string, string>
{
["searchTerm"] = searchTerm,
["limit"] = limit.ToString(),
["recursive"] = recursive.ToString().ToLower(),
["fields"] = "PrimaryImageAspectRatio,MediaSources,Path,Genres,Studios,DateCreated,Overview,ProviderIds"
};
if (!string.IsNullOrEmpty(_settings.UserId))
{
queryParams["userId"] = _settings.UserId;
}
// Only filter search to music library if explicitly configured
if (!string.IsNullOrEmpty(_settings.LibraryId))
{
queryParams["parentId"] = _settings.LibraryId;
_logger.LogDebug("Searching within configured LibraryId {LibraryId}", _settings.LibraryId);
}
if (includeItemTypes != null && includeItemTypes.Length > 0)
{
queryParams["includeItemTypes"] = string.Join(",", includeItemTypes);
}
return await GetJsonAsync("Items", queryParams, clientHeaders);
}
/// <summary>
/// Gets items from a specific parent (album, artist, playlist).
/// </summary>
public async Task<(JsonDocument? Body, int StatusCode)> GetItemsAsync(
string? parentId = null,
string[]? includeItemTypes = null,
string? sortBy = null,
int? limit = null,
int? startIndex = null,
string? artistIds = null,
IHeaderDictionary? clientHeaders = null)
{
var queryParams = new Dictionary<string, string>
{
["recursive"] = "true",
["fields"] = "PrimaryImageAspectRatio,MediaSources,Path,Genres,Studios,DateCreated,Overview,ProviderIds,ParentId"
};
if (!string.IsNullOrEmpty(_settings.UserId))
{
queryParams["userId"] = _settings.UserId;
}
if (!string.IsNullOrEmpty(parentId))
{
queryParams["parentId"] = parentId;
}
if (includeItemTypes != null && includeItemTypes.Length > 0)
{
queryParams["includeItemTypes"] = string.Join(",", includeItemTypes);
}
if (!string.IsNullOrEmpty(sortBy))
{
queryParams["sortBy"] = sortBy;
}
if (limit.HasValue)
{
queryParams["limit"] = limit.Value.ToString();
}
if (startIndex.HasValue)
{
queryParams["startIndex"] = startIndex.Value.ToString();
}
if (!string.IsNullOrEmpty(artistIds))
{
queryParams["artistIds"] = artistIds;
}
return await GetJsonAsync("Items", queryParams, clientHeaders);
}
/// <summary>
/// Gets a single item by ID.
/// </summary>
public async Task<(JsonDocument? Body, int StatusCode)> GetItemAsync(string itemId, IHeaderDictionary? clientHeaders = null)
{
var queryParams = new Dictionary<string, string>();
if (!string.IsNullOrEmpty(_settings.UserId))
{
queryParams["userId"] = _settings.UserId;
}
return await GetJsonAsync($"Items/{itemId}", queryParams, clientHeaders);
}
/// <summary>
/// Gets artists from the library.
/// </summary>
public async Task<(JsonDocument? Body, int StatusCode)> GetArtistsAsync(
string? searchTerm = null,
int? limit = null,
int? startIndex = null,
IHeaderDictionary? clientHeaders = null)
{
var queryParams = new Dictionary<string, string>
{
["fields"] = "PrimaryImageAspectRatio,Genres,Overview"
};
if (!string.IsNullOrEmpty(_settings.UserId))
{
queryParams["userId"] = _settings.UserId;
}
if (!string.IsNullOrEmpty(searchTerm))
{
queryParams["searchTerm"] = searchTerm;
}
if (limit.HasValue)
{
queryParams["limit"] = limit.Value.ToString();
}
if (startIndex.HasValue)
{
queryParams["startIndex"] = startIndex.Value.ToString();
}
return await GetJsonAsync("Artists", queryParams, clientHeaders);
}
/// <summary>
/// Gets an artist by name or ID.
/// </summary>
public async Task<(JsonDocument? Body, int StatusCode)> GetArtistAsync(string artistIdOrName, IHeaderDictionary? clientHeaders = null)
{
var queryParams = new Dictionary<string, string>();
if (!string.IsNullOrEmpty(_settings.UserId))
{
queryParams["userId"] = _settings.UserId;
}
// Try to get by ID first
if (Guid.TryParse(artistIdOrName, out _))
{
return await GetJsonAsync($"Items/{artistIdOrName}", queryParams, clientHeaders);
}
// Otherwise search by name
return await GetJsonAsync($"Artists/{Uri.EscapeDataString(artistIdOrName)}", queryParams, clientHeaders);
}
/// <summary>
/// Streams audio from Jellyfin with range support.
/// </summary>
public async Task<IActionResult> StreamAudioAsync(
string itemId,
CancellationToken cancellationToken)
{
try
{
var httpContext = _httpContextAccessor.HttpContext;
if (httpContext == null)
{
return new ObjectResult(new { error = "HTTP context not available" })
{
StatusCode = 500
};
}
var incomingRequest = httpContext.Request;
var outgoingResponse = httpContext.Response;
// Build the stream URL - use static streaming for simplicity
var queryParams = new Dictionary<string, string>
{
["static"] = "true",
["mediaSourceId"] = itemId
};
var url = BuildUrl($"Audio/{itemId}/stream", queryParams);
using var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Add("Authorization", GetAuthorizationHeader());
// Forward Range headers for progressive streaming
if (incomingRequest.Headers.TryGetValue("Range", out var range))
{
request.Headers.TryAddWithoutValidation("Range", range.ToArray());
}
if (incomingRequest.Headers.TryGetValue("If-Range", out var ifRange))
{
request.Headers.TryAddWithoutValidation("If-Range", ifRange.ToArray());
}
var response = await _httpClient.SendAsync(
request,
HttpCompletionOption.ResponseHeadersRead,
cancellationToken);
if (!response.IsSuccessStatusCode)
{
return new StatusCodeResult((int)response.StatusCode);
}
// Forward HTTP status code
outgoingResponse.StatusCode = (int)response.StatusCode;
// Forward streaming headers
var streamingHeaders = new[] { "Accept-Ranges", "Content-Range", "Content-Length", "ETag", "Last-Modified" };
foreach (var header in streamingHeaders)
{
if (response.Headers.TryGetValues(header, out var values) ||
response.Content.Headers.TryGetValues(header, out values))
{
outgoingResponse.Headers[header] = values.ToArray();
}
}
var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
var contentType = response.Content.Headers.ContentType?.ToString() ?? "audio/mpeg";
return new FileStreamResult(stream, contentType)
{
EnableRangeProcessing = true
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Error streaming from Jellyfin item {ItemId}", itemId);
return new ObjectResult(new { error = $"Error streaming: {ex.Message}" })
{
StatusCode = 500
};
}
}
/// <summary>
/// Gets the image for an item.
/// </summary>
public async Task<(byte[]? Body, string? ContentType)> GetImageAsync(
string itemId,
string imageType = "Primary",
int? maxWidth = null,
int? maxHeight = null)
{
// Build cache key
var cacheKey = $"image:{itemId}:{imageType}:{maxWidth}:{maxHeight}";
// Try cache first
var cached = await _cache.GetStringAsync(cacheKey);
if (!string.IsNullOrEmpty(cached))
{
var parts = cached.Split('|', 2);
if (parts.Length == 2)
{
var body = Convert.FromBase64String(parts[0]);
var contentType = parts[1];
return (body, contentType);
}
}
var queryParams = new Dictionary<string, string>();
if (maxWidth.HasValue)
{
queryParams["maxWidth"] = maxWidth.Value.ToString();
}
if (maxHeight.HasValue)
{
queryParams["maxHeight"] = maxHeight.Value.ToString();
}
var result = await GetBytesSafeAsync($"Items/{itemId}/Images/{imageType}", queryParams);
// Cache for 7 days if successful
if (result.Success && result.Body != null)
{
var cacheValue = $"{Convert.ToBase64String(result.Body)}|{result.ContentType}";
await _cache.SetStringAsync(cacheKey, cacheValue, TimeSpan.FromDays(7));
}
return (result.Body, result.ContentType);
}
/// <summary>
/// Tests connection to the Jellyfin server.
/// </summary>
public async Task<(bool Success, string? ServerName, string? Version)> TestConnectionAsync()
{
try
{
var (result, statusCode) = await GetJsonAsync("System/Info/Public");
if (result == null || statusCode != 200)
{
return (false, null, null);
}
var serverName = result.RootElement.TryGetProperty("ServerName", out var name)
? name.GetString()
: null;
var version = result.RootElement.TryGetProperty("Version", out var ver)
? ver.GetString()
: null;
return (true, serverName, version);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to test Jellyfin connection");
return (false, null, null);
}
}
/// <summary>
/// Gets the music library ID from Jellyfin by querying media folders.
/// </summary>
private async Task<string?> GetMusicLibraryIdInternalAsync()
{
try
{
var queryParams = new Dictionary<string, string>();
if (!string.IsNullOrEmpty(_settings.UserId))
{
queryParams["userId"] = _settings.UserId;
}
var (result, statusCode) = await GetJsonAsync("Library/MediaFolders", queryParams);
if (result == null)
{
return null;
}
if (result.RootElement.TryGetProperty("Items", out var items))
{
foreach (var item in items.EnumerateArray())
{
var collectionType = item.TryGetProperty("CollectionType", out var ct)
? ct.GetString()
: null;
if (collectionType == "music")
{
return item.TryGetProperty("Id", out var id)
? id.GetString()
: null;
}
}
}
return null;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get music library ID");
return null;
}
}
private string BuildUrl(string endpoint, Dictionary<string, string>? queryParams = null)
{
var baseUrl = _settings.Url?.TrimEnd('/') ?? "";
var url = $"{baseUrl}/{endpoint}";
if (queryParams != null && queryParams.Count > 0)
{
var query = string.Join("&", queryParams.Select(kv =>
$"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value)}"));
url = $"{url}?{query}";
}
return url;
}
/// <summary>
/// Sends a GET request to the Jellyfin server using the server's API key for internal operations.
/// This should only be used for server-side operations, not for proxying client requests.
/// </summary>
public async Task<(JsonDocument? Body, int StatusCode)> GetJsonAsyncInternal(string endpoint, Dictionary<string, string>? queryParams = null)
{
var url = BuildUrl(endpoint, queryParams);
using var request = new HttpRequestMessage(HttpMethod.Get, url);
// Use server's API key for authentication
var authHeader = GetAuthorizationHeader();
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", authHeader);
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var response = await _httpClient.SendAsync(request);
var statusCode = (int)response.StatusCode;
var content = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("Jellyfin internal request returned {StatusCode} for {Url}: {Content}",
statusCode, url, content);
return (null, statusCode);
}
try
{
var jsonDocument = JsonDocument.Parse(content);
return (jsonDocument, statusCode);
}
catch (JsonException ex)
{
_logger.LogError(ex, "Failed to parse JSON response from {Url}: {Content}", url, content);
return (null, statusCode);
}
}
}