Fix multiple Jellyfin proxy issues and improvements

- Fix playback progress reporting by wrapping POST bodies in required field names
- Fix cache cleanup by updating last access time when streaming files
- Fix Artists/{id}/Similar endpoint proxying to correct Jellyfin endpoint
- Add ' - SW' suffix to external albums and artists for better identification
- Register SquidWTFSettings configuration to enable quality settings
- Remove unused code and improve debugging logs
This commit is contained in:
2026-01-30 01:58:10 -05:00
parent 457a5b7582
commit aebb1c14dd
6 changed files with 89 additions and 23 deletions

View File

@@ -811,6 +811,16 @@ public class JellyfinController : ControllerBase
if (localPath != null && System.IO.File.Exists(localPath)) if (localPath != null && System.IO.File.Exists(localPath))
{ {
// Update last access time for cache cleanup
try
{
System.IO.File.SetLastAccessTimeUtc(localPath, DateTime.UtcNow);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to update last access time for {Path}", localPath);
}
var stream = System.IO.File.OpenRead(localPath); var stream = System.IO.File.OpenRead(localPath);
return File(stream, GetContentType(localPath), enableRangeProcessing: true); return File(stream, GetContentType(localPath), enableRangeProcessing: true);
} }
@@ -1202,7 +1212,7 @@ public class JellyfinController : ControllerBase
/// </summary> /// </summary>
[HttpGet("Items/{itemId}/Similar")] [HttpGet("Items/{itemId}/Similar")]
[HttpGet("Songs/{itemId}/Similar")] [HttpGet("Songs/{itemId}/Similar")]
[HttpGet("Artists/{artistId}/Similar")] [HttpGet("Artists/{itemId}/Similar")]
public async Task<IActionResult> GetSimilarItems( public async Task<IActionResult> GetSimilarItems(
string itemId, string itemId,
[FromQuery] int limit = 50, [FromQuery] int limit = 50,
@@ -1266,7 +1276,11 @@ public class JellyfinController : ControllerBase
} }
} }
// For local items, proxy to Jellyfin // For local items, determine the correct endpoint based on the request path
var endpoint = Request.Path.Value?.Contains("/Artists/", StringComparison.OrdinalIgnoreCase) == true
? $"Artists/{itemId}/Similar"
: $"Items/{itemId}/Similar";
var queryParams = new Dictionary<string, string> var queryParams = new Dictionary<string, string>
{ {
["limit"] = limit.ToString() ["limit"] = limit.ToString()
@@ -1282,7 +1296,7 @@ public class JellyfinController : ControllerBase
queryParams["userId"] = userId; queryParams["userId"] = userId;
} }
var result = await _proxyService.GetJsonAsync($"Items/{itemId}/Similar", queryParams, Request.Headers); var result = await _proxyService.GetJsonAsync(endpoint, queryParams, Request.Headers);
if (result == null) if (result == null)
{ {
@@ -1532,28 +1546,32 @@ public class JellyfinController : ControllerBase
// Read body using StreamReader with proper encoding // Read body using StreamReader with proper encoding
string body; string body;
using (var reader = new StreamReader(Request.Body, System.Text.Encoding.UTF8, leaveOpen: true)) using (var reader = new StreamReader(Request.Body, System.Text.Encoding.UTF8, detectEncodingFromByteOrderMarks: false, bufferSize: 1024, leaveOpen: true))
{ {
body = await reader.ReadToEndAsync(); body = await reader.ReadToEndAsync();
} }
// Reset stream position after reading // Reset stream position after reading so it can be read again if needed
Request.Body.Position = 0; Request.Body.Position = 0;
if (string.IsNullOrWhiteSpace(body)) if (string.IsNullOrWhiteSpace(body))
{ {
_logger.LogWarning("Empty POST body for {Path}, ContentLength={ContentLength}, ContentType={ContentType}", _logger.LogWarning("Empty POST body received from client for {Path}, ContentLength={ContentLength}, ContentType={ContentType}",
fullPath, Request.ContentLength, Request.ContentType); fullPath, Request.ContentLength, Request.ContentType);
// Log all headers to debug
_logger.LogWarning("Request headers: {Headers}",
string.Join(", ", Request.Headers.Select(h => $"{h.Key}={h.Value}")));
} }
else else
{ {
_logger.LogInformation("POST body for {Path}: {BodyLength} bytes, ContentType={ContentType}", _logger.LogInformation("POST body received from client for {Path}: {BodyLength} bytes, ContentType={ContentType}",
fullPath, body.Length, Request.ContentType); fullPath, body.Length, Request.ContentType);
// Always log body content for playback endpoints to debug the issue // Always log body content for playback endpoints to debug the issue
if (fullPath.Contains("Playing", StringComparison.OrdinalIgnoreCase)) if (fullPath.Contains("Playing", StringComparison.OrdinalIgnoreCase))
{ {
_logger.LogInformation("POST body content: {Body}", body); _logger.LogInformation("POST body content from client: {Body}", body);
} }
} }

View File

@@ -64,7 +64,7 @@ public class SubsonicController : ControllerBase
{ {
return await _requestParser.ExtractAllParametersAsync(Request); return await _requestParser.ExtractAllParametersAsync(Request);
} }
/// <summary> /// <summary>
/// Merges local and external search results. /// Merges local and external search results.
/// </summary> /// </summary>
@@ -142,6 +142,16 @@ public class SubsonicController : ControllerBase
if (localPath != null && System.IO.File.Exists(localPath)) if (localPath != null && System.IO.File.Exists(localPath))
{ {
// Update last access time for cache cleanup
try
{
System.IO.File.SetLastAccessTimeUtc(localPath, DateTime.UtcNow);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to update last access time for {Path}", localPath);
}
var stream = System.IO.File.OpenRead(localPath); var stream = System.IO.File.OpenRead(localPath);
return File(stream, GetContentType(localPath), enableRangeProcessing: true); return File(stream, GetContentType(localPath), enableRangeProcessing: true);
} }

View File

@@ -71,6 +71,8 @@ builder.Services.Configure<DeezerSettings>(
builder.Configuration.GetSection("Deezer")); builder.Configuration.GetSection("Deezer"));
builder.Services.Configure<QobuzSettings>( builder.Services.Configure<QobuzSettings>(
builder.Configuration.GetSection("Qobuz")); builder.Configuration.GetSection("Qobuz"));
builder.Services.Configure<SquidWTFSettings>(
builder.Configuration.GetSection("SquidWTF"));
builder.Services.Configure<RedisSettings>( builder.Services.Configure<RedisSettings>(
builder.Configuration.GetSection("Redis")); builder.Configuration.GetSection("Redis"));

View File

@@ -256,17 +256,40 @@ public class JellyfinProxyService
using var request = new HttpRequestMessage(HttpMethod.Post, url); using var request = new HttpRequestMessage(HttpMethod.Post, url);
// Create content from body string // Handle special case for playback endpoints - Jellyfin expects wrapped body
if (!string.IsNullOrEmpty(body)) var bodyToSend = body;
if (!string.IsNullOrWhiteSpace(body))
{ {
request.Content = new StringContent(body, System.Text.Encoding.UTF8, "application/json"); // Check if this is a playback progress endpoint
_logger.LogDebug("POST body length: {Length} bytes", body.Length); if (endpoint.Contains("Sessions/Playing/Progress", StringComparison.OrdinalIgnoreCase))
{
// Wrap the body in playbackProgressInfo field
bodyToSend = $"{{\"playbackProgressInfo\":{body}}}";
_logger.LogDebug("Wrapped body for playback progress endpoint");
}
else if (endpoint.Contains("Sessions/Playing/Stopped", StringComparison.OrdinalIgnoreCase))
{
// Wrap the body in playbackStopInfo field
bodyToSend = $"{{\"playbackStopInfo\":{body}}}";
_logger.LogDebug("Wrapped body for playback stopped endpoint");
}
else if (endpoint.Contains("Sessions/Playing", StringComparison.OrdinalIgnoreCase) &&
!endpoint.Contains("Progress", StringComparison.OrdinalIgnoreCase) &&
!endpoint.Contains("Stopped", StringComparison.OrdinalIgnoreCase))
{
// Wrap the body in playbackStartInfo field for /Sessions/Playing
bodyToSend = $"{{\"playbackStartInfo\":{body}}}";
_logger.LogDebug("Wrapped body for playback start endpoint");
}
} }
else else
{ {
_logger.LogWarning("POST body is empty for {Url}", url); 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; bool authHeaderAdded = false;
// Forward authentication headers from client (case-insensitive) // Forward authentication headers from client (case-insensitive)
@@ -312,12 +335,12 @@ public class JellyfinProxyService
} }
else else
{ {
_logger.LogInformation("POST to Jellyfin: {Url}, body length: {Length} bytes", url, body.Length); _logger.LogInformation("POST to Jellyfin: {Url}, body length: {Length} bytes", url, bodyToSend.Length);
// Log body content for playback endpoints to debug // Log body content for playback endpoints to debug
if (endpoint.Contains("Playing", StringComparison.OrdinalIgnoreCase)) if (endpoint.Contains("Playing", StringComparison.OrdinalIgnoreCase))
{ {
_logger.LogInformation("Sending body to Jellyfin: {Body}", body); _logger.LogInformation("Sending body to Jellyfin: {Body}", bodyToSend);
} }
} }

View File

@@ -304,10 +304,17 @@ public class JellyfinResponseBuilder
/// </summary> /// </summary>
public Dictionary<string, object?> ConvertAlbumToJellyfinItem(Album album) public Dictionary<string, object?> ConvertAlbumToJellyfinItem(Album album)
{ {
// Add " - SW" suffix to external album names
var albumName = album.Title;
if (!album.IsLocal)
{
albumName = $"{album.Title} - SW";
}
var item = new Dictionary<string, object?> var item = new Dictionary<string, object?>
{ {
["Id"] = album.Id, ["Id"] = album.Id,
["Name"] = album.Title, ["Name"] = albumName,
["ServerId"] = "allstarr", ["ServerId"] = "allstarr",
["Type"] = "MusicAlbum", ["Type"] = "MusicAlbum",
["IsFolder"] = true, ["IsFolder"] = true,
@@ -328,10 +335,10 @@ public class JellyfinResponseBuilder
}, },
["BackdropImageTags"] = new string[0], ["BackdropImageTags"] = new string[0],
["ImageBlurHashes"] = new Dictionary<string, object>(), ["ImageBlurHashes"] = new Dictionary<string, object>(),
["LocationType"] = "FileSystem", // External content appears as local files to clients ["LocationType"] = "FileSystem",
["MediaType"] = (object?)null, // Match Jellyfin structure ["MediaType"] = (object?)null,
["ChannelId"] = (object?)null, // Match Jellyfin structure ["ChannelId"] = (object?)null,
["CollectionType"] = (object?)null, // Match Jellyfin structure ["CollectionType"] = (object?)null,
["UserData"] = new Dictionary<string, object> ["UserData"] = new Dictionary<string, object>
{ {
["PlaybackPositionTicks"] = 0, ["PlaybackPositionTicks"] = 0,
@@ -364,10 +371,17 @@ public class JellyfinResponseBuilder
/// </summary> /// </summary>
public Dictionary<string, object?> ConvertArtistToJellyfinItem(Artist artist) public Dictionary<string, object?> ConvertArtistToJellyfinItem(Artist artist)
{ {
// Add " - SW" suffix to external artist names
var artistName = artist.Name;
if (!artist.IsLocal)
{
artistName = $"{artist.Name} - SW";
}
var item = new Dictionary<string, object?> var item = new Dictionary<string, object?>
{ {
["Id"] = artist.Id, ["Id"] = artist.Id,
["Name"] = artist.Name, ["Name"] = artistName,
["ServerId"] = "allstarr", ["ServerId"] = "allstarr",
["Type"] = "MusicArtist", ["Type"] = "MusicArtist",
["IsFolder"] = true, ["IsFolder"] = true,

View File

@@ -21,7 +21,6 @@ public class SquidWTFDownloadService : BaseDownloadService
{ {
private readonly HttpClient _httpClient; private readonly HttpClient _httpClient;
private readonly SemaphoreSlim _requestLock = new(1, 1); private readonly SemaphoreSlim _requestLock = new(1, 1);
private readonly string? _preferredQuality;
private readonly SquidWTFSettings _squidwtfSettings; private readonly SquidWTFSettings _squidwtfSettings;
private DateTime _lastRequestTime = DateTime.MinValue; private DateTime _lastRequestTime = DateTime.MinValue;