Compare commits

...

4 Commits

Author SHA1 Message Date
joshpatra 233af5dc8f v1.4.6-beta.1: Hopefully handles #14 and #15, fixes search up to truly interleave, and more transparently proxies /sessions and /socket
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
2026-04-04 17:36:47 -04:00
joshpatra 4c1e6979b3 v1.4.4-beta.1: re-releasing tag
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
2026-03-25 16:30:19 -04:00
joshpatra 0738e2d588 Merge branch 'main' into beta 2026-03-25 16:28:27 -04:00
joshpatra 0a5b383526 v1.4.3: fixed .env restarting from Admin UI, re-release of prev ver 2026-03-25 16:11:27 -04:00
21 changed files with 1109 additions and 307 deletions
+1 -1
View File
@@ -73,7 +73,7 @@ jobs:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=sha,prefix=
type=ref,event=tag
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
@@ -0,0 +1,65 @@
using System.Reflection;
using System.Text.Json;
using allstarr.Controllers;
namespace allstarr.Tests;
public class JellyfinImageTagExtractionTests
{
[Fact]
public void ExtractImageTag_WithMatchingImageTagsObject_ReturnsRequestedTag()
{
using var document = JsonDocument.Parse("""
{
"ImageTags": {
"Primary": "playlist-primary-tag",
"Backdrop": "playlist-backdrop-tag"
}
}
""");
var imageTag = InvokeExtractImageTag(document.RootElement, "Primary");
Assert.Equal("playlist-primary-tag", imageTag);
}
[Fact]
public void ExtractImageTag_WithPrimaryImageTagFallback_ReturnsFallbackTag()
{
using var document = JsonDocument.Parse("""
{
"PrimaryImageTag": "primary-fallback-tag"
}
""");
var imageTag = InvokeExtractImageTag(document.RootElement, "Primary");
Assert.Equal("primary-fallback-tag", imageTag);
}
[Fact]
public void ExtractImageTag_WithoutMatchingTag_ReturnsNull()
{
using var document = JsonDocument.Parse("""
{
"ImageTags": {
"Backdrop": "playlist-backdrop-tag"
}
}
""");
var imageTag = InvokeExtractImageTag(document.RootElement, "Primary");
Assert.Null(imageTag);
}
private static string? InvokeExtractImageTag(JsonElement item, string imageType)
{
var method = typeof(JellyfinController).GetMethod(
"ExtractImageTag",
BindingFlags.Static | BindingFlags.NonPublic);
Assert.NotNull(method);
return (string?)method!.Invoke(null, new object?[] { item, imageType });
}
}
@@ -0,0 +1,41 @@
using System.Reflection;
using allstarr.Controllers;
namespace allstarr.Tests;
public class JellyfinPlaylistRouteMatchingTests
{
[Theory]
[InlineData("playlists/abc123/items", "abc123")]
[InlineData("Playlists/abc123/Items", "abc123")]
[InlineData("/playlists/abc123/items/", "abc123")]
public void GetExactPlaylistItemsRequestId_ExactPlaylistItemsRoute_ReturnsPlaylistId(string path, string expectedPlaylistId)
{
var playlistId = InvokePrivateStatic<string?>("GetExactPlaylistItemsRequestId", path);
Assert.Equal(expectedPlaylistId, playlistId);
}
[Theory]
[InlineData("playlists/abc123/items/extra")]
[InlineData("users/user-1/playlists/abc123/items")]
[InlineData("items/abc123")]
[InlineData("playlists")]
public void GetExactPlaylistItemsRequestId_NonExactRoute_ReturnsNull(string path)
{
var playlistId = InvokePrivateStatic<string?>("GetExactPlaylistItemsRequestId", path);
Assert.Null(playlistId);
}
private static T InvokePrivateStatic<T>(string methodName, params object?[] args)
{
var method = typeof(JellyfinController).GetMethod(
methodName,
BindingFlags.NonPublic | BindingFlags.Static);
Assert.NotNull(method);
var result = method!.Invoke(null, args);
return (T)result!;
}
}
+163
View File
@@ -311,6 +311,169 @@ public class JellyfinProxyServiceTests
Assert.Contains("UserId=user-abc", query);
}
[Fact]
public async Task GetPassthroughResponseAsync_WithRepeatedFields_PreservesAllFieldParameters()
{
// Arrange
HttpRequestMessage? captured = null;
_mockHandler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((req, ct) => captured = req)
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{\"Items\":[]}")
});
// Act
var response = await _service.GetPassthroughResponseAsync(
"Playlists/playlist-123/Items?Fields=Genres&Fields=DateCreated&Fields=MediaSources&UserId=user-abc");
// Assert
Assert.NotNull(captured);
var query = captured!.RequestUri!.Query;
Assert.Contains("Fields=Genres", query);
Assert.Contains("Fields=DateCreated", query);
Assert.Contains("Fields=MediaSources", query);
Assert.Contains("UserId=user-abc", query);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task GetPassthroughResponseAsync_WithClientAuth_ForwardsAuthHeader()
{
// Arrange
HttpRequestMessage? captured = null;
_mockHandler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((req, ct) => captured = req)
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{\"Items\":[]}")
});
var headers = new HeaderDictionary
{
["X-Emby-Authorization"] = "MediaBrowser Token=\"abc\""
};
// Act
var response = await _service.GetPassthroughResponseAsync(
"Playlists/playlist-123/Items?Fields=Genres",
headers);
// Assert
Assert.NotNull(captured);
Assert.True(captured!.Headers.TryGetValues("X-Emby-Authorization", out var values));
Assert.Contains("MediaBrowser Token=\"abc\"", values);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task SendAsync_WithNoBody_PreservesEmptyRequestBody()
{
// Arrange
HttpRequestMessage? captured = null;
_mockHandler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((req, _) => captured = req)
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.NoContent));
var headers = new HeaderDictionary
{
["X-Emby-Authorization"] = "MediaBrowser Token=\"abc\""
};
// Act
var (_, statusCode) = await _service.SendAsync(
HttpMethod.Post,
"Sessions/session-123/Playing/Pause?controllingUserId=user-123",
null,
headers);
// Assert
Assert.Equal(204, statusCode);
Assert.NotNull(captured);
Assert.Equal(HttpMethod.Post, captured!.Method);
Assert.Null(captured.Content);
}
[Fact]
public async Task SendAsync_WithCustomContentType_PreservesOriginalType()
{
// Arrange
HttpRequestMessage? captured = null;
_mockHandler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((req, _) => captured = req)
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.NoContent));
var headers = new HeaderDictionary
{
["X-Emby-Authorization"] = "MediaBrowser Token=\"abc\""
};
// Act
await _service.SendAsync(
HttpMethod.Put,
"Sessions/session-123/Command/DisplayMessage",
"{\"Text\":\"hello\"}",
headers,
"application/json; charset=utf-8");
// Assert
Assert.NotNull(captured);
Assert.Equal(HttpMethod.Put, captured!.Method);
Assert.NotNull(captured.Content);
Assert.Equal("application/json; charset=utf-8", captured.Content!.Headers.ContentType!.ToString());
}
[Fact]
public async Task GetPassthroughResponseAsync_WithAcceptEncoding_ForwardsCompressionHeaders()
{
// Arrange
HttpRequestMessage? captured = null;
_mockHandler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((req, ct) => captured = req)
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{\"Items\":[]}")
});
var headers = new HeaderDictionary
{
["Accept-Encoding"] = "gzip, br",
["User-Agent"] = "Finamp/1.0",
["Accept-Language"] = "en-US"
};
// Act
var response = await _service.GetPassthroughResponseAsync(
"Playlists/playlist-123/Items?Fields=Genres",
headers);
// Assert
Assert.NotNull(captured);
Assert.True(captured!.Headers.TryGetValues("Accept-Encoding", out var encodings));
Assert.Contains("gzip", encodings);
Assert.Contains("br", encodings);
Assert.True(captured.Headers.TryGetValues("User-Agent", out var userAgents));
Assert.Contains("Finamp/1.0", userAgents);
Assert.True(captured.Headers.TryGetValues("Accept-Language", out var languages));
Assert.Contains("en-US", languages);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task GetJsonAsync_WithEndpointAndExplicitQuery_MergesWithExplicitPrecedence()
{
@@ -0,0 +1,94 @@
using System.Reflection;
using System.Runtime.CompilerServices;
using allstarr.Controllers;
namespace allstarr.Tests;
public class JellyfinSearchInterleaveTests
{
[Fact]
public void InterleaveByScore_PrimaryOnly_PreservesOriginalOrder()
{
var controller = CreateController();
var primary = new List<Dictionary<string, object?>>
{
CreateItem("zzz filler"),
CreateItem("BTS Anthem")
};
var result = InvokeInterleaveByScore(controller, primary, [], "bts", 5.0);
Assert.Equal(["zzz filler", "BTS Anthem"], result.Select(GetName));
}
[Fact]
public void InterleaveByScore_SecondaryOnly_PreservesOriginalOrder()
{
var controller = CreateController();
var secondary = new List<Dictionary<string, object?>>
{
CreateItem("zzz filler"),
CreateItem("BTS Anthem")
};
var result = InvokeInterleaveByScore(controller, [], secondary, "bts", 5.0);
Assert.Equal(["zzz filler", "BTS Anthem"], result.Select(GetName));
}
[Fact]
public void InterleaveByScore_LocalBoost_CanWinCloseHeadToHeadWithoutReorderingSource()
{
var controller = CreateController();
var primary = new List<Dictionary<string, object?>>
{
CreateItem("luther remastered"),
CreateItem("zzz filler")
};
var secondary = new List<Dictionary<string, object?>>
{
CreateItem("luther"),
CreateItem("yyy filler")
};
var result = InvokeInterleaveByScore(controller, primary, secondary, "luther", 5.0);
Assert.Equal(["luther remastered", "luther", "zzz filler", "yyy filler"], result.Select(GetName));
}
private static JellyfinController CreateController()
{
return (JellyfinController)RuntimeHelpers.GetUninitializedObject(typeof(JellyfinController));
}
private static List<Dictionary<string, object?>> InvokeInterleaveByScore(
JellyfinController controller,
List<Dictionary<string, object?>> primary,
List<Dictionary<string, object?>> secondary,
string query,
double primaryBoost)
{
var method = typeof(JellyfinController).GetMethod(
"InterleaveByScore",
BindingFlags.Instance | BindingFlags.NonPublic);
Assert.NotNull(method);
return (List<Dictionary<string, object?>>)method!.Invoke(
controller,
[primary, secondary, query, primaryBoost])!;
}
private static Dictionary<string, object?> CreateItem(string name)
{
return new Dictionary<string, object?>
{
["Name"] = name
};
}
private static string GetName(Dictionary<string, object?> item)
{
return item["Name"]?.ToString() ?? string.Empty;
}
}
@@ -0,0 +1,38 @@
using System.Reflection;
using allstarr.Controllers;
namespace allstarr.Tests;
public class JellyfinSearchResponseSerializationTests
{
[Fact]
public void SerializeSearchResponseJson_PreservesPascalCaseShape()
{
var payload = new
{
Items = new[]
{
new Dictionary<string, object?>
{
["Name"] = "BTS",
["Type"] = "MusicAlbum"
}
},
TotalRecordCount = 1,
StartIndex = 0
};
var method = typeof(JellyfinController).GetMethod(
"SerializeSearchResponseJson",
BindingFlags.NonPublic | BindingFlags.Static);
Assert.NotNull(method);
var closedMethod = method!.MakeGenericMethod(payload.GetType());
var json = (string)closedMethod.Invoke(null, new object?[] { payload })!;
Assert.Equal(
"{\"Items\":[{\"Name\":\"BTS\",\"Type\":\"MusicAlbum\"}],\"TotalRecordCount\":1,\"StartIndex\":0}",
json);
}
}
@@ -132,6 +132,46 @@ public class JellyfinSessionManagerTests
Assert.Equal(45 * TimeSpan.TicksPerSecond, state.PositionTicks);
}
[Fact]
public async Task EnsureSessionAsync_WithProxiedWebSocket_DoesNotPostSyntheticCapabilities()
{
var requestedPaths = new ConcurrentBag<string>();
var handler = new DelegateHttpMessageHandler((request, _) =>
{
requestedPaths.Add(request.RequestUri?.AbsolutePath ?? string.Empty);
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NoContent));
});
var settings = new JellyfinSettings
{
Url = "http://127.0.0.1:1",
ApiKey = "server-api-key",
ClientName = "Allstarr",
DeviceName = "Allstarr",
DeviceId = "allstarr",
ClientVersion = "1.0"
};
var proxyService = CreateProxyService(handler, settings);
using var manager = new JellyfinSessionManager(
proxyService,
Options.Create(settings),
NullLogger<JellyfinSessionManager>.Instance);
var headers = new HeaderDictionary
{
["X-Emby-Authorization"] =
"MediaBrowser Client=\"Finamp\", Device=\"Android Auto\", DeviceId=\"dev-123\", Version=\"1.0\", Token=\"abc\""
};
await manager.RegisterProxiedWebSocketAsync("dev-123");
var ensured = await manager.EnsureSessionAsync("dev-123", "Finamp", "Android Auto", "1.0", headers);
Assert.True(ensured);
Assert.DoesNotContain("/Sessions/Capabilities/Full", requestedPaths);
}
private static JellyfinProxyService CreateProxyService(HttpMessageHandler handler, JellyfinSettings settings)
{
var httpClientFactory = new TestHttpClientFactory(handler);
+1 -1
View File
@@ -9,5 +9,5 @@ public static class AppVersion
/// <summary>
/// Current application version.
/// </summary>
public const string Version = "1.4.3";
public const string Version = "1.4.6";
}
+111
View File
@@ -1,9 +1,11 @@
using System.Text.Json;
using System.Text;
using System.Net.Http;
using allstarr.Models.Domain;
using allstarr.Models.Spotify;
using allstarr.Services.Common;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Http.Features;
namespace allstarr.Controllers;
@@ -11,6 +13,20 @@ public partial class JellyfinController
{
#region Helpers
private static readonly HashSet<string> PassthroughResponseHeadersToSkip = new(StringComparer.OrdinalIgnoreCase)
{
"Connection",
"Keep-Alive",
"Proxy-Authenticate",
"Proxy-Authorization",
"TE",
"Trailer",
"Transfer-Encoding",
"Upgrade",
"Content-Type",
"Content-Length"
};
/// <summary>
/// Helper to handle proxy responses with proper status code handling.
/// </summary>
@@ -48,6 +64,60 @@ public partial class JellyfinController
return NoContent();
}
private async Task<IActionResult> ProxyJsonPassthroughAsync(string endpoint)
{
try
{
// Match the previous proxy semantics for client compatibility.
// Some Jellyfin clients/proxies cancel the ASP.NET request token aggressively
// even though the upstream request would still complete successfully.
var upstreamResponse = await _proxyService.GetPassthroughResponseAsync(
endpoint,
Request.Headers);
HttpContext.Response.RegisterForDispose(upstreamResponse);
HttpContext.Features.Get<IHttpResponseBodyFeature>()?.DisableBuffering();
Response.StatusCode = (int)upstreamResponse.StatusCode;
Response.Headers["X-Accel-Buffering"] = "no";
CopyPassthroughResponseHeaders(upstreamResponse);
if (upstreamResponse.Content.Headers.ContentLength.HasValue)
{
Response.ContentLength = upstreamResponse.Content.Headers.ContentLength.Value;
}
var contentType = upstreamResponse.Content.Headers.ContentType?.ToString() ?? "application/json";
var stream = await upstreamResponse.Content.ReadAsStreamAsync();
return new FileStreamResult(stream, contentType);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to transparently proxy Jellyfin request for {Endpoint}", endpoint);
return StatusCode(502, new { error = "Failed to connect to Jellyfin server" });
}
}
private void CopyPassthroughResponseHeaders(HttpResponseMessage upstreamResponse)
{
foreach (var header in upstreamResponse.Headers)
{
if (!PassthroughResponseHeadersToSkip.Contains(header.Key))
{
Response.Headers[header.Key] = header.Value.ToArray();
}
}
foreach (var header in upstreamResponse.Content.Headers)
{
if (!PassthroughResponseHeadersToSkip.Contains(header.Key))
{
Response.Headers[header.Key] = header.Value.ToArray();
}
}
}
/// <summary>
/// Updates ChildCount for Spotify playlists in the response to show total tracks (local + matched).
/// </summary>
@@ -407,6 +477,47 @@ public partial class JellyfinController
return includeItemTypes.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
private static string? GetExactPlaylistItemsRequestId(string path)
{
if (string.IsNullOrWhiteSpace(path))
{
return null;
}
var parts = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length != 3 ||
!parts[0].Equals("playlists", StringComparison.OrdinalIgnoreCase) ||
!parts[2].Equals("items", StringComparison.OrdinalIgnoreCase))
{
return null;
}
return parts[1];
}
private static string? ExtractImageTag(JsonElement item, string imageType)
{
if (item.TryGetProperty("ImageTags", out var imageTags) &&
imageTags.ValueKind == JsonValueKind.Object)
{
foreach (var imageTag in imageTags.EnumerateObject())
{
if (string.Equals(imageTag.Name, imageType, StringComparison.OrdinalIgnoreCase))
{
return imageTag.Value.GetString();
}
}
}
if (string.Equals(imageType, "Primary", StringComparison.OrdinalIgnoreCase) &&
item.TryGetProperty("PrimaryImageTag", out var primaryImageTag))
{
return primaryImageTag.GetString();
}
return null;
}
/// <summary>
/// Determines whether Spotify playlist count enrichment should run for a response.
/// We only run enrichment for playlist-oriented payloads to avoid mutating unrelated item lists
@@ -39,69 +39,9 @@ public partial class JellyfinController
{
var responseJson = result.RootElement.GetRawText();
// On successful auth, extract access token and post session capabilities in background
if (statusCode == 200)
{
_logger.LogInformation("Authentication successful");
// Extract access token from response for session capabilities
string? accessToken = null;
if (result.RootElement.TryGetProperty("AccessToken", out var tokenEl))
{
accessToken = tokenEl.GetString();
}
// Post session capabilities in background if we have a token
if (!string.IsNullOrEmpty(accessToken))
{
var (deviceId, client, device, version) = ExtractDeviceInfo(Request.Headers);
// Capture token in closure - don't use Request.Headers (will be disposed)
var token = accessToken;
var authHeader = AuthHeaderHelper.CreateAuthHeader(token, client, device, deviceId, version);
_ = Task.Run(async () =>
{
try
{
_logger.LogDebug("🔧 Posting session capabilities after authentication");
// Build auth header with the new token
var authHeaders = new HeaderDictionary
{
["X-Emby-Authorization"] = authHeader,
["X-Emby-Token"] = token
};
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,
authHeaders);
if (capStatus == 204 || capStatus == 200)
{
_logger.LogDebug("✓ Session capabilities posted after auth ({StatusCode})",
capStatus);
}
else
{
_logger.LogDebug("⚠ Session capabilities returned {StatusCode} after auth",
capStatus);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to post session capabilities after auth");
}
});
}
}
else
{
@@ -1558,9 +1558,14 @@ public partial class JellyfinController
string.Join(", ", Request.Headers.Keys.Where(h =>
h.Contains("Auth", StringComparison.OrdinalIgnoreCase))));
// Read body if present
string body = "{}";
if ((method == "POST" || method == "PUT") && Request.ContentLength > 0)
// Read body if present. Preserve true empty-body requests because Jellyfin
// uses several POST session-control endpoints with query params only.
string? body = null;
var hasRequestBody = !HttpMethods.IsGet(method) &&
(Request.ContentLength.GetValueOrDefault() > 0 ||
Request.Headers.ContainsKey("Transfer-Encoding"));
if (hasRequestBody)
{
Request.EnableBuffering();
using (var reader = new StreamReader(Request.Body, System.Text.Encoding.UTF8,
@@ -1577,9 +1582,9 @@ public partial class JellyfinController
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
"POST" => await _proxyService.SendAsync(HttpMethod.Post, endpoint, body, Request.Headers, Request.ContentType),
"PUT" => await _proxyService.SendAsync(HttpMethod.Put, endpoint, body, Request.Headers, Request.ContentType),
"DELETE" => await _proxyService.SendAsync(HttpMethod.Delete, endpoint, body, Request.Headers, Request.ContentType),
_ => (null, 405)
};
@@ -32,6 +32,7 @@ public partial class JellyfinController
{
var boundSearchTerm = searchTerm;
searchTerm = GetEffectiveSearchTerm(searchTerm, Request.QueryString.Value);
string? searchCacheKey = null;
// AlbumArtistIds takes precedence over ArtistIds if both are provided
var effectiveArtistIds = albumArtistIds ?? artistIds;
@@ -181,7 +182,7 @@ public partial class JellyfinController
// Check cache for search results (only cache pure searches, not filtered searches)
if (string.IsNullOrWhiteSpace(effectiveArtistIds) && string.IsNullOrWhiteSpace(albumIds))
{
var cacheKey = CacheKeyBuilder.BuildSearchKey(
searchCacheKey = CacheKeyBuilder.BuildSearchKey(
searchTerm,
includeItemTypes,
limit,
@@ -192,12 +193,12 @@ public partial class JellyfinController
recursive,
userId,
Request.Query["IsFavorite"].ToString());
var cachedResult = await _cache.GetAsync<object>(cacheKey);
var cachedResult = await _cache.GetStringAsync(searchCacheKey);
if (cachedResult != null)
if (!string.IsNullOrWhiteSpace(cachedResult))
{
_logger.LogInformation("SEARCH TRACE: cache hit for key '{CacheKey}'", cacheKey);
return new JsonResult(cachedResult);
_logger.LogInformation("SEARCH TRACE: cache hit for key '{CacheKey}'", searchCacheKey);
return Content(cachedResult, "application/json");
}
}
@@ -386,9 +387,9 @@ public partial class JellyfinController
// Score-sort each source, then interleave by highest remaining score.
// Keep only a small source preference for already-relevant primary results.
var allSongs = InterleaveByScore(jellyfinSongItems, externalSongItems, cleanQuery, primaryBoost: 1.5, boostMinScore: 72);
var allAlbums = InterleaveByScore(jellyfinAlbumItems, externalAlbumItems, cleanQuery, primaryBoost: 1.5, boostMinScore: 78);
var allArtists = InterleaveByScore(jellyfinArtistItems, externalArtistItems, cleanQuery, primaryBoost: 1.5, boostMinScore: 75);
var allSongs = InterleaveByScore(jellyfinSongItems, externalSongItems, cleanQuery, primaryBoost: 5.0);
var allAlbums = InterleaveByScore(jellyfinAlbumItems, externalAlbumItems, cleanQuery, primaryBoost: 5.0);
var allArtists = InterleaveByScore(jellyfinArtistItems, externalArtistItems, cleanQuery, primaryBoost: 5.0);
// Log top results for debugging
if (_logger.IsEnabled(LogLevel.Debug))
@@ -438,7 +439,7 @@ public partial class JellyfinController
}
// Merge albums and playlists using score-based interleaving (albums keep a light priority over playlists).
var mergedAlbumsAndPlaylists = InterleaveByScore(allAlbums, mergedPlaylistItems, cleanQuery, primaryBoost: 2.0, boostMinScore: 70);
var mergedAlbumsAndPlaylists = InterleaveByScore(allAlbums, mergedPlaylistItems, cleanQuery, primaryBoost: 2.0);
mergedAlbumsAndPlaylists = ApplyRequestedAlbumOrderingIfApplicable(
mergedAlbumsAndPlaylists,
itemTypes,
@@ -538,24 +539,16 @@ public partial class JellyfinController
TotalRecordCount = items.Count,
StartIndex = startIndex
};
var json = SerializeSearchResponseJson(response);
// Cache search results in Redis using the configured search TTL.
if (!string.IsNullOrWhiteSpace(searchTerm) && string.IsNullOrWhiteSpace(effectiveArtistIds))
if (!string.IsNullOrWhiteSpace(searchTerm) &&
string.IsNullOrWhiteSpace(effectiveArtistIds) &&
!string.IsNullOrWhiteSpace(searchCacheKey))
{
if (externalHasRequestedTypeResults)
{
var cacheKey = CacheKeyBuilder.BuildSearchKey(
searchTerm,
includeItemTypes,
limit,
startIndex,
parentId,
sortBy,
Request.Query["SortOrder"].ToString(),
recursive,
userId,
Request.Query["IsFavorite"].ToString());
await _cache.SetAsync(cacheKey, response, CacheExtensions.SearchResultsTTL);
await _cache.SetStringAsync(searchCacheKey, json, CacheExtensions.SearchResultsTTL);
_logger.LogDebug("💾 Cached search results for '{SearchTerm}' ({Minutes} min TTL)", searchTerm,
CacheExtensions.SearchResultsTTL.TotalMinutes);
}
@@ -570,12 +563,6 @@ public partial class JellyfinController
_logger.LogDebug("About to serialize response...");
var json = System.Text.Json.JsonSerializer.Serialize(response, new System.Text.Json.JsonSerializerOptions
{
PropertyNamingPolicy = null,
DictionaryKeyPolicy = null
});
if (_logger.IsEnabled(LogLevel.Debug))
{
var preview = json.Length > 200 ? json[..200] : json;
@@ -591,6 +578,15 @@ public partial class JellyfinController
}
}
private static string SerializeSearchResponseJson<T>(T response) where T : class
{
return JsonSerializer.Serialize(response, new JsonSerializerOptions
{
PropertyNamingPolicy = null,
DictionaryKeyPolicy = null
});
}
/// <summary>
/// Gets child items of a parent (tracks in album, albums for artist).
/// </summary>
@@ -908,49 +904,37 @@ public partial class JellyfinController
}
/// <summary>
/// Score-sorts each source and then interleaves by highest remaining score.
/// This avoids weak head results in one source blocking stronger results later in that same source.
/// Interleaves two sources while preserving each source's original order.
/// The only decision made at each step is which current head item to take next.
/// </summary>
private List<Dictionary<string, object?>> InterleaveByScore(
List<Dictionary<string, object?>> primaryItems,
List<Dictionary<string, object?>> secondaryItems,
string query,
double primaryBoost,
double boostMinScore = 70)
double primaryBoost)
{
var primaryScored = primaryItems.Select((item, index) =>
var primaryScored = primaryItems.Select(item =>
{
var baseScore = CalculateItemRelevanceScore(query, item);
var finalScore = baseScore >= boostMinScore
? Math.Min(100.0, baseScore + primaryBoost)
: baseScore;
return new
{
Item = item,
BaseScore = baseScore,
Score = finalScore,
SourceIndex = index
Score = Math.Min(100.0, baseScore + primaryBoost)
};
})
.OrderByDescending(x => x.Score)
.ThenByDescending(x => x.BaseScore)
.ThenBy(x => x.SourceIndex)
.ToList();
var secondaryScored = secondaryItems.Select((item, index) =>
var secondaryScored = secondaryItems.Select(item =>
{
var baseScore = CalculateItemRelevanceScore(query, item);
return new
{
Item = item,
BaseScore = baseScore,
Score = baseScore,
SourceIndex = index
Score = baseScore
};
})
.OrderByDescending(x => x.Score)
.ThenByDescending(x => x.BaseScore)
.ThenBy(x => x.SourceIndex)
.ToList();
var result = new List<Dictionary<string, object?>>(primaryScored.Count + secondaryScored.Count);
@@ -981,13 +965,9 @@ public partial class JellyfinController
{
result.Add(secondaryScored[secondaryIdx++].Item);
}
else if (primaryCandidate.BaseScore >= secondaryCandidate.BaseScore)
{
result.Add(primaryScored[primaryIdx++].Item);
}
else
{
result.Add(secondaryScored[secondaryIdx++].Item);
result.Add(primaryScored[primaryIdx++].Item);
}
}
+60 -15
View File
@@ -628,13 +628,20 @@ public partial class JellyfinController : ControllerBase
if (!isExternal)
{
var effectiveImageTag = tag;
if (string.IsNullOrWhiteSpace(effectiveImageTag) &&
_spotifySettings.IsSpotifyPlaylist(itemId))
{
effectiveImageTag = await ResolveCurrentSpotifyPlaylistImageTagAsync(itemId, imageType);
}
// Proxy image from Jellyfin for local content
var (imageBytes, contentType) = await _proxyService.GetImageAsync(
itemId,
imageType,
maxWidth,
maxHeight,
tag);
effectiveImageTag);
if (imageBytes == null || contentType == null)
{
@@ -793,6 +800,40 @@ public partial class JellyfinController : ControllerBase
return File(transparentPng, "image/png");
}
private async Task<string?> ResolveCurrentSpotifyPlaylistImageTagAsync(string itemId, string imageType)
{
try
{
var (itemResult, statusCode) = await _proxyService.GetJsonAsyncInternal($"Items/{itemId}");
if (itemResult == null || statusCode != 200)
{
return null;
}
using var itemDocument = itemResult;
var imageTag = ExtractImageTag(itemDocument.RootElement, imageType);
if (!string.IsNullOrWhiteSpace(imageTag))
{
_logger.LogDebug(
"Resolved current Jellyfin {ImageType} image tag for Spotify playlist {PlaylistId}: {ImageTag}",
imageType,
itemId,
imageTag);
}
return imageTag;
}
catch (Exception ex)
{
_logger.LogDebug(ex,
"Failed to resolve current Jellyfin {ImageType} image tag for Spotify playlist {PlaylistId}",
imageType,
itemId);
return null;
}
}
#endregion
#region Favorites
@@ -1292,33 +1333,37 @@ public partial class JellyfinController : ControllerBase
});
}
// Intercept Spotify playlist requests by ID
if (_spotifySettings.Enabled &&
path.StartsWith("playlists/", StringComparison.OrdinalIgnoreCase) &&
path.Contains("/items", StringComparison.OrdinalIgnoreCase))
var playlistItemsRequestId = GetExactPlaylistItemsRequestId(path);
if (!string.IsNullOrEmpty(playlistItemsRequestId))
{
// Extract playlist ID from path: playlists/{id}/items
var parts = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length >= 2 && parts[0].Equals("playlists", StringComparison.OrdinalIgnoreCase))
if (_spotifySettings.Enabled)
{
var playlistId = parts[1];
_logger.LogDebug("=== PLAYLIST REQUEST ===");
_logger.LogInformation("Playlist ID: {PlaylistId}", playlistId);
_logger.LogInformation("Playlist ID: {PlaylistId}", playlistItemsRequestId);
_logger.LogInformation("Spotify Enabled: {Enabled}", _spotifySettings.Enabled);
_logger.LogInformation("Configured Playlists: {Playlists}", string.Join(", ", _spotifySettings.Playlists.Select(p => $"{p.Name}:{p.Id}")));
_logger.LogInformation("Is configured: {IsConfigured}", _spotifySettings.IsSpotifyPlaylist(playlistId));
_logger.LogInformation("Is configured: {IsConfigured}", _spotifySettings.IsSpotifyPlaylist(playlistItemsRequestId));
// Check if this playlist ID is configured for Spotify injection
if (_spotifySettings.IsSpotifyPlaylist(playlistId))
if (_spotifySettings.IsSpotifyPlaylist(playlistItemsRequestId))
{
_logger.LogInformation("========================================");
_logger.LogInformation("=== INTERCEPTING SPOTIFY PLAYLIST ===");
_logger.LogInformation("Playlist ID: {PlaylistId}", playlistId);
_logger.LogInformation("Playlist ID: {PlaylistId}", playlistItemsRequestId);
_logger.LogInformation("========================================");
return await GetPlaylistTracks(playlistId);
return await GetPlaylistTracks(playlistItemsRequestId);
}
}
var playlistItemsPath = path;
if (Request.QueryString.HasValue)
{
playlistItemsPath = $"{playlistItemsPath}{Request.QueryString.Value}";
}
_logger.LogDebug("Using transparent Jellyfin passthrough for non-injected playlist {PlaylistId}",
playlistItemsRequestId);
return await ProxyJsonPassthroughAsync(playlistItemsPath);
}
// Handle non-JSON responses (images, robots.txt, etc.)
@@ -152,6 +152,11 @@ public class WebSocketProxyMiddleware
clientWebSocket = await context.WebSockets.AcceptWebSocketAsync();
_logger.LogDebug("✓ WEBSOCKET: Client WebSocket accepted");
if (!string.IsNullOrEmpty(deviceId))
{
await _sessionManager.RegisterProxiedWebSocketAsync(deviceId);
}
// Start bidirectional proxying
var clientToServer = ProxyMessagesAsync(clientWebSocket, serverWebSocket, "Client→Server", context.RequestAborted);
var serverToClient = ProxyMessagesAsync(serverWebSocket, clientWebSocket, "Server→Client", context.RequestAborted);
@@ -194,6 +199,11 @@ public class WebSocketProxyMiddleware
}
finally
{
if (!string.IsNullOrEmpty(deviceId))
{
_sessionManager.UnregisterProxiedWebSocket(deviceId);
}
// Clean up connections
if (clientWebSocket?.State == WebSocketState.Open)
{
+178 -151
View File
@@ -153,62 +153,35 @@ public class JellyfinProxyService
return await GetJsonAsyncInternal(finalUrl, clientHeaders);
}
/// <summary>
/// Sends a proxied GET request to Jellyfin and returns the raw upstream response without buffering the body.
/// Intended for transparent passthrough of large JSON payloads that Allstarr does not modify.
/// </summary>
public async Task<HttpResponseMessage> GetPassthroughResponseAsync(
string endpoint,
IHeaderDictionary? clientHeaders = null,
CancellationToken cancellationToken = default)
{
var url = BuildUrl(endpoint);
using var request = CreateClientGetRequest(url, clientHeaders, out var isBrowserStaticRequest, out var isPublicEndpoint);
ForwardPassthroughRequestHeaders(clientHeaders, request);
var response = await _httpClient.SendAsync(
request,
HttpCompletionOption.ResponseHeadersRead,
cancellationToken);
if (!response.IsSuccessStatusCode && !isBrowserStaticRequest && !isPublicEndpoint)
{
LogUpstreamFailure(HttpMethod.Get, response.StatusCode, url);
}
return response;
}
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);
// Check if this is a public endpoint that doesn't require authentication
bool isPublicEndpoint = url.Contains("/System/Info/Public", StringComparison.OrdinalIgnoreCase) ||
url.Contains("/Branding/", StringComparison.OrdinalIgnoreCase) ||
url.Contains("/Startup/", StringComparison.OrdinalIgnoreCase);
// Forward authentication headers from client if provided
if (clientHeaders != null && clientHeaders.Count > 0)
{
authHeaderAdded = AuthHeaderHelper.ForwardAuthHeaders(clientHeaders, request);
if (authHeaderAdded)
{
_logger.LogTrace("Forwarded authentication headers");
}
// Check for api_key query parameter (some clients use this)
if (!authHeaderAdded && url.Contains("api_key=", StringComparison.OrdinalIgnoreCase))
{
authHeaderAdded = true; // It's in the URL, no need to add header
_logger.LogTrace("Using api_key from query string");
}
}
// Only log warnings for non-public, non-browser requests without auth
if (!authHeaderAdded && !isBrowserStaticRequest && !isPublicEndpoint)
{
_logger.LogDebug("No client auth provided for {Url} - Jellyfin will handle authentication", url);
}
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
using var request = CreateClientGetRequest(url, clientHeaders, out var isBrowserStaticRequest, out var isPublicEndpoint);
var response = await _httpClient.SendAsync(request);
@@ -245,16 +218,13 @@ public class JellyfinProxyService
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)
private HttpRequestMessage CreateClientGetRequest(
string url,
IHeaderDictionary? clientHeaders,
out bool isBrowserStaticRequest,
out bool isPublicEndpoint)
{
var url = BuildUrl(endpoint, null);
using var request = new HttpRequestMessage(HttpMethod.Post, url);
var request = new HttpRequestMessage(HttpMethod.Get, url);
// Forward client IP address to Jellyfin so it can identify the real client
if (_httpContextAccessor.HttpContext != null)
@@ -267,58 +237,177 @@ public class JellyfinProxyService
}
}
// 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))
// Check if this is a browser request for static assets (favicon, etc.)
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);
// Check if this is a public endpoint that doesn't require authentication
isPublicEndpoint = url.Contains("/System/Info/Public", StringComparison.OrdinalIgnoreCase) ||
url.Contains("/Branding/", StringComparison.OrdinalIgnoreCase) ||
url.Contains("/Startup/", StringComparison.OrdinalIgnoreCase);
var authHeaderAdded = false;
// Forward authentication headers from client if provided
if (clientHeaders != null && clientHeaders.Count > 0)
{
bodyToSend = "{}";
_logger.LogWarning("POST body was empty for {Url}, sending empty JSON object", url);
authHeaderAdded = AuthHeaderHelper.ForwardAuthHeaders(clientHeaders, request);
if (authHeaderAdded)
{
_logger.LogTrace("Forwarded authentication headers");
}
// Check for api_key query parameter (some clients use this)
if (!authHeaderAdded && url.Contains("api_key=", StringComparison.OrdinalIgnoreCase))
{
authHeaderAdded = true; // It's in the URL, no need to add header
_logger.LogTrace("Using api_key from query string");
}
}
request.Content = new StringContent(bodyToSend, System.Text.Encoding.UTF8, "application/json");
// Only log warnings for non-public, non-browser requests without auth
if (!authHeaderAdded && !isBrowserStaticRequest && !isPublicEndpoint)
{
_logger.LogDebug("No client auth provided for {Url} - Jellyfin will handle authentication", url);
}
bool authHeaderAdded = false;
bool isAuthEndpoint = endpoint.Contains("Authenticate", StringComparison.OrdinalIgnoreCase);
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
return request;
}
// Forward authentication headers from client
authHeaderAdded = AuthHeaderHelper.ForwardAuthHeaders(clientHeaders, request);
private static void ForwardPassthroughRequestHeaders(
IHeaderDictionary? clientHeaders,
HttpRequestMessage request)
{
if (clientHeaders == null || clientHeaders.Count == 0)
{
return;
}
if (clientHeaders.TryGetValue("Accept-Encoding", out var acceptEncoding) &&
acceptEncoding.Count > 0)
{
request.Headers.TryAddWithoutValidation("Accept-Encoding", acceptEncoding.ToArray());
}
if (clientHeaders.TryGetValue("User-Agent", out var userAgent) &&
userAgent.Count > 0)
{
request.Headers.TryAddWithoutValidation("User-Agent", userAgent.ToArray());
}
if (clientHeaders.TryGetValue("Accept-Language", out var acceptLanguage) &&
acceptLanguage.Count > 0)
{
request.Headers.TryAddWithoutValidation("Accept-Language", acceptLanguage.ToArray());
}
}
/// <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 bodyToSend = body;
if (string.IsNullOrWhiteSpace(bodyToSend))
{
bodyToSend = "{}";
_logger.LogWarning("POST body was empty for {Endpoint}, sending empty JSON object", endpoint);
}
return await SendAsync(HttpMethod.Post, endpoint, bodyToSend, clientHeaders, "application/json");
}
/// <summary>
/// Sends an arbitrary HTTP request to Jellyfin while preserving the caller's method and body semantics.
/// Intended for transparent proxy scenarios such as session control routes.
/// </summary>
public async Task<(JsonDocument? Body, int StatusCode)> SendAsync(
HttpMethod method,
string endpoint,
string? body,
IHeaderDictionary clientHeaders,
string? contentType = null)
{
var url = BuildUrl(endpoint, null);
using var request = new HttpRequestMessage(method, 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);
}
}
if (body != null)
{
var requestContent = new StringContent(body, System.Text.Encoding.UTF8);
try
{
requestContent.Headers.ContentType = !string.IsNullOrWhiteSpace(contentType)
? MediaTypeHeaderValue.Parse(contentType)
: new MediaTypeHeaderValue("application/json") { CharSet = "utf-8" };
}
catch (FormatException)
{
_logger.LogWarning("Invalid content type '{ContentType}' for {Method} {Endpoint}; falling back to application/json",
contentType,
method,
endpoint);
requestContent.Headers.ContentType = new MediaTypeHeaderValue("application/json") { CharSet = "utf-8" };
}
request.Content = requestContent;
}
var authHeaderAdded = AuthHeaderHelper.ForwardAuthHeaders(clientHeaders, request);
var isAuthEndpoint = endpoint.Contains("Authenticate", StringComparison.OrdinalIgnoreCase);
if (authHeaderAdded)
{
_logger.LogTrace("Forwarded authentication headers");
}
// For authentication endpoints, credentials are in the body, not headers
// For other endpoints without auth, let Jellyfin reject the request
if (!authHeaderAdded && !isAuthEndpoint)
else if (!isAuthEndpoint)
{
_logger.LogDebug("No client auth provided for POST {Url} - Jellyfin will handle authentication", url);
_logger.LogDebug("No client auth provided for {Method} {Url} - Jellyfin will handle authentication", method, url);
}
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
// DO NOT log the body for auth endpoints - it contains passwords!
if (isAuthEndpoint)
{
_logger.LogDebug("POST to Jellyfin: {Url} (auth request - body not logged)", url);
_logger.LogDebug("{Method} to Jellyfin: {Url} (auth request - body not logged)", method, url);
}
else if (body == null)
{
_logger.LogTrace("{Method} to Jellyfin: {Url} (no request body)", method, url);
}
else
{
_logger.LogTrace("POST to Jellyfin: {Url}, body length: {Length} bytes", url, bodyToSend.Length);
_logger.LogTrace("{Method} to Jellyfin: {Url}, body length: {Length} bytes", method, url, body.Length);
}
var response = await _httpClient.SendAsync(request);
var statusCode = (int)response.StatusCode;
if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync();
LogUpstreamFailure(HttpMethod.Post, response.StatusCode, url, errorContent);
LogUpstreamFailure(method, response.StatusCode, url, errorContent);
// Try to parse error response as JSON to pass through to client
if (!string.IsNullOrWhiteSpace(errorContent))
{
try
@@ -335,21 +424,17 @@ public class JellyfinProxyService
return (null, statusCode);
}
// Log successful session-related responses
if (endpoint.Contains("Sessions", StringComparison.OrdinalIgnoreCase))
{
_logger.LogTrace("Jellyfin responded {StatusCode} for {Endpoint}", statusCode, endpoint);
_logger.LogTrace("Jellyfin responded {StatusCode} for {Method} {Endpoint}", statusCode, method, endpoint);
}
// Handle 204 No Content responses (e.g., /sessions/playing, /sessions/playing/progress)
if (response.StatusCode == System.Net.HttpStatusCode.NoContent)
if (response.StatusCode == HttpStatusCode.NoContent)
{
return (null, statusCode);
}
var responseContent = await response.Content.ReadAsStringAsync();
// Handle empty responses
if (string.IsNullOrWhiteSpace(responseContent))
{
return (null, statusCode);
@@ -411,65 +496,7 @@ public class JellyfinProxyService
/// </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
authHeaderAdded = AuthHeaderHelper.ForwardAuthHeaders(clientHeaders, request);
if (!authHeaderAdded)
{
_logger.LogDebug("No client auth provided for DELETE {Url} - forwarding without auth", url);
}
else
{
_logger.LogTrace("Forwarded authentication headers");
}
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
_logger.LogDebug("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();
LogUpstreamFailure(HttpMethod.Delete, 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);
return await SendAsync(HttpMethod.Delete, endpoint, null, clientHeaders);
}
/// <summary>
@@ -20,6 +20,7 @@ public class JellyfinSessionManager : IDisposable
private readonly ILogger<JellyfinSessionManager> _logger;
private readonly ConcurrentDictionary<string, SessionInfo> _sessions = new();
private readonly ConcurrentDictionary<string, SemaphoreSlim> _sessionInitLocks = new();
private readonly ConcurrentDictionary<string, byte> _proxiedWebSocketConnections = new();
private readonly Timer _keepAliveTimer;
public JellyfinSessionManager(
@@ -53,21 +54,28 @@ public class JellyfinSessionManager : IDisposable
await initLock.WaitAsync();
try
{
var hasProxiedWebSocket = HasProxiedWebSocket(deviceId);
// Check if we already have this session tracked
if (_sessions.TryGetValue(deviceId, out var existingSession))
{
existingSession.LastActivity = DateTime.UtcNow;
existingSession.HasProxiedWebSocket = hasProxiedWebSocket;
_logger.LogInformation("Session already exists for device {DeviceId}", deviceId);
// Refresh capabilities to keep session alive
// If this returns false (401), the token expired and client needs to re-auth
var refreshOk = await PostCapabilitiesAsync(headers);
if (!refreshOk)
if (!hasProxiedWebSocket)
{
// Token expired - remove the stale session
_logger.LogWarning("Token expired for device {DeviceId} - removing session", deviceId);
await RemoveSessionAsync(deviceId);
return false;
// Refresh capabilities to keep session alive only for sessions that Allstarr
// is synthesizing itself. Native proxied websocket sessions should be left
// entirely under Jellyfin's control.
var refreshOk = await PostCapabilitiesAsync(headers);
if (!refreshOk)
{
// Token expired - remove the stale session
_logger.LogWarning("Token expired for device {DeviceId} - removing session", deviceId);
await RemoveSessionAsync(deviceId);
return false;
}
}
return true;
@@ -75,16 +83,26 @@ public class JellyfinSessionManager : IDisposable
_logger.LogDebug("Creating new session for device: {DeviceId} ({Client} on {Device})", deviceId, client, device);
// Post session capabilities to Jellyfin - this creates the session
var createOk = await PostCapabilitiesAsync(headers);
if (!createOk)
if (!hasProxiedWebSocket)
{
// Token expired or invalid - client needs to re-authenticate
_logger.LogError("Failed to create session for {DeviceId} - token may be expired", deviceId);
return false;
}
// Post session capabilities to Jellyfin only when Allstarr is creating a
// synthetic session. If the real client already has a proxied websocket,
// re-posting capabilities can overwrite its remote-control state.
var createOk = await PostCapabilitiesAsync(headers);
if (!createOk)
{
// Token expired or invalid - client needs to re-authenticate
_logger.LogError("Failed to create session for {DeviceId} - token may be expired", deviceId);
return false;
}
_logger.LogInformation("Session created for {DeviceId}", deviceId);
_logger.LogInformation("Session created for {DeviceId}", deviceId);
}
else
{
_logger.LogDebug("Skipping synthetic Jellyfin session bootstrap for proxied websocket device {DeviceId}",
deviceId);
}
// Track this session
var clientIp = headers["X-Forwarded-For"].FirstOrDefault()?.Split(',')[0].Trim()
@@ -99,11 +117,16 @@ public class JellyfinSessionManager : IDisposable
Version = version,
LastActivity = DateTime.UtcNow,
Headers = CloneHeaders(headers),
ClientIp = clientIp
ClientIp = clientIp,
HasProxiedWebSocket = hasProxiedWebSocket
};
// Start a WebSocket connection to Jellyfin on behalf of this client
_ = Task.Run(() => MaintainWebSocketForSessionAsync(deviceId, headers));
// Start a synthetic WebSocket connection only when the client itself does not
// already have a proxied Jellyfin socket through Allstarr.
if (!hasProxiedWebSocket)
{
_ = Task.Run(() => MaintainWebSocketForSessionAsync(deviceId, headers));
}
return true;
}
@@ -118,6 +141,44 @@ public class JellyfinSessionManager : IDisposable
}
}
public async Task RegisterProxiedWebSocketAsync(string deviceId)
{
if (string.IsNullOrWhiteSpace(deviceId))
{
return;
}
_proxiedWebSocketConnections[deviceId] = 0;
if (_sessions.TryGetValue(deviceId, out var session))
{
session.HasProxiedWebSocket = true;
session.LastActivity = DateTime.UtcNow;
await CloseSyntheticWebSocketAsync(deviceId, session);
}
}
public void UnregisterProxiedWebSocket(string deviceId)
{
if (string.IsNullOrWhiteSpace(deviceId))
{
return;
}
_proxiedWebSocketConnections.TryRemove(deviceId, out _);
if (_sessions.TryGetValue(deviceId, out var session))
{
session.HasProxiedWebSocket = false;
session.LastActivity = DateTime.UtcNow;
}
}
private bool HasProxiedWebSocket(string deviceId)
{
return !string.IsNullOrWhiteSpace(deviceId) && _proxiedWebSocketConnections.ContainsKey(deviceId);
}
/// <summary>
/// Posts session capabilities to Jellyfin.
/// Returns true if successful, false if token expired (401).
@@ -345,8 +406,10 @@ public class JellyfinSessionManager : IDisposable
ClientIp = s.ClientIp,
LastActivity = s.LastActivity,
InactiveMinutes = Math.Round((now - s.LastActivity).TotalMinutes, 1),
HasWebSocket = s.WebSocket != null,
WebSocketState = s.WebSocket?.State.ToString() ?? "None"
HasWebSocket = s.HasProxiedWebSocket || s.WebSocket != null,
HasProxiedWebSocket = s.HasProxiedWebSocket,
HasSyntheticWebSocket = s.WebSocket != null,
WebSocketState = s.HasProxiedWebSocket ? "Proxied" : s.WebSocket?.State.ToString() ?? "None"
}).ToList();
return new
@@ -363,6 +426,8 @@ public class JellyfinSessionManager : IDisposable
/// </summary>
public async Task RemoveSessionAsync(string deviceId)
{
_proxiedWebSocketConnections.TryRemove(deviceId, out _);
if (_sessions.TryRemove(deviceId, out var session))
{
_logger.LogDebug("🗑️ SESSION: Removing session for device {DeviceId}", deviceId);
@@ -422,6 +487,12 @@ public class JellyfinSessionManager : IDisposable
return;
}
if (session.HasProxiedWebSocket || HasProxiedWebSocket(deviceId))
{
_logger.LogDebug("Skipping synthetic Jellyfin websocket for proxied device {DeviceId}", deviceId);
return;
}
ClientWebSocket? webSocket = null;
try
@@ -525,6 +596,13 @@ public class JellyfinSessionManager : IDisposable
{
try
{
if (HasProxiedWebSocket(deviceId))
{
_logger.LogDebug("Stopping synthetic Jellyfin websocket because proxied client websocket is active for {DeviceId}",
deviceId);
break;
}
// Use a timeout so we can send keep-alive messages periodically
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cts.Token);
timeoutCts.CancelAfter(TimeSpan.FromSeconds(30));
@@ -635,6 +713,12 @@ public class JellyfinSessionManager : IDisposable
{
try
{
session.HasProxiedWebSocket = HasProxiedWebSocket(session.DeviceId);
if (session.HasProxiedWebSocket)
{
continue;
}
// Post capabilities again to keep session alive
// If this returns false (401), the token has expired
var success = await PostCapabilitiesAsync(session.Headers);
@@ -695,6 +779,7 @@ public class JellyfinSessionManager : IDisposable
public string? LastLocalPlayedSignalItemId { get; set; }
public string? LastExplicitStopItemId { get; set; }
public DateTime? LastExplicitStopAtUtc { get; set; }
public bool HasProxiedWebSocket { get; set; }
}
public sealed record ActivePlaybackState(
@@ -729,4 +814,31 @@ public class JellyfinSessionManager : IDisposable
}
}
}
private async Task CloseSyntheticWebSocketAsync(string deviceId, SessionInfo session)
{
var syntheticSocket = session.WebSocket;
if (syntheticSocket == null)
{
return;
}
session.WebSocket = null;
try
{
if (syntheticSocket.State == WebSocketState.Open || syntheticSocket.State == WebSocketState.CloseReceived)
{
await syntheticSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Native client websocket active", CancellationToken.None);
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to close synthetic Jellyfin websocket for proxied device {DeviceId}", deviceId);
}
finally
{
syntheticSocket.Dispose();
}
}
}
@@ -247,6 +247,7 @@ public class SpotifyPlaylistFetcher : BackgroundService
// Re-fetch
await GetPlaylistTracksAsync(playlistName);
await ClearPlaylistImageCacheAsync(playlistName);
}
/// <summary>
@@ -262,6 +263,20 @@ public class SpotifyPlaylistFetcher : BackgroundService
}
}
private async Task ClearPlaylistImageCacheAsync(string playlistName)
{
var playlistConfig = _spotifyImportSettings.GetPlaylistByName(playlistName);
if (playlistConfig == null || string.IsNullOrWhiteSpace(playlistConfig.JellyfinId))
{
return;
}
var deletedCount = await _cache.DeleteByPatternAsync($"image:{playlistConfig.JellyfinId}:*");
_logger.LogDebug("Cleared {Count} cached local image entries for playlist {Playlist}",
deletedCount,
playlistName);
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("========================================");
@@ -295,6 +295,7 @@ public class SpotifyTrackMatchingService : BackgroundService
throw;
}
await ClearPlaylistImageCacheAsync(playlist);
_logger.LogInformation("✓ Rebuild complete for {Playlist}", playlistName);
}
@@ -337,6 +338,8 @@ public class SpotifyTrackMatchingService : BackgroundService
await MatchPlaylistTracksLegacyAsync(
playlist.Name, metadataService, cancellationToken);
}
await ClearPlaylistImageCacheAsync(playlist);
}
catch (Exception ex)
{
@@ -345,6 +348,19 @@ public class SpotifyTrackMatchingService : BackgroundService
}
}
private async Task ClearPlaylistImageCacheAsync(SpotifyPlaylistConfig playlist)
{
if (string.IsNullOrWhiteSpace(playlist.JellyfinId))
{
return;
}
var deletedCount = await _cache.DeleteByPatternAsync($"image:{playlist.JellyfinId}:*");
_logger.LogDebug("Cleared {Count} cached local image entries for playlist {Playlist}",
deletedCount,
playlist.Name);
}
/// <summary>
/// Public method to trigger full rebuild for all playlists (called from "Rebuild All Remote" button).
/// This clears caches, fetches fresh data, and re-matches everything immediately.
+18
View File
@@ -32,6 +32,14 @@
<div class="auth-error" id="auth-error" role="alert"></div>
</form>
</div>
<div class="support-badge">
If Allstarr has helped you, or maybe even replaced a monthly streaming service subscription, consider
supporting its development via
<a href="https://github.com/sponsors/SoPat712" target="_blank" rel="noopener noreferrer">GitHub Sponsor</a>
or
<a href="https://ko-fi.com/joshpatra" target="_blank" rel="noopener noreferrer">Ko-Fi</a>.
</div>
</div>
<div class="container" id="main-container" style="display:none;">
@@ -954,6 +962,16 @@
</p>
</div>
</div>
<footer class="support-footer">
<p>
If Allstarr has helped you, or maybe even replaced a monthly streaming service subscription, consider
supporting its development via
<a href="https://github.com/sponsors/SoPat712" target="_blank" rel="noopener noreferrer">GitHub Sponsor</a>
or
<a href="https://ko-fi.com/joshpatra" target="_blank" rel="noopener noreferrer">Ko-Fi</a>.
</p>
</footer>
</div>
<!-- Add Playlist Modal -->
+30
View File
@@ -41,6 +41,26 @@
padding: 20px;
}
.support-footer {
max-width: 1400px;
margin: 0 auto 24px;
padding: 20px;
border-top: 1px solid var(--border);
color: var(--text-secondary);
font-size: 0.92rem;
text-align: center;
}
.support-footer a {
color: var(--accent);
text-decoration: none;
}
.support-footer a:hover {
color: var(--accent-hover);
text-decoration: underline;
}
header {
display: flex;
justify-content: space-between;
@@ -646,5 +666,15 @@
</div>
</div>
</div>
<footer class="support-footer">
<p>
If Allstarr has helped you, or maybe even replaced a monthly streaming service subscription, consider
supporting its development via
<a href="https://github.com/sponsors/SoPat712" target="_blank" rel="noopener noreferrer">GitHub Sponsor</a>
or
<a href="https://ko-fi.com/joshpatra" target="_blank" rel="noopener noreferrer">Ko-Fi</a>.
</p>
</footer>
</body>
</html>
+52
View File
@@ -69,12 +69,49 @@ body {
font-size: 0.85rem;
}
.support-badge {
position: fixed;
right: 20px;
bottom: 20px;
width: min(360px, calc(100vw - 32px));
padding: 12px 14px;
border: 1px solid var(--border);
border-radius: 10px;
background: rgba(22, 27, 34, 0.94);
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.28);
color: var(--text-secondary);
font-size: 0.82rem;
line-height: 1.45;
z-index: 10;
}
.support-badge a,
.support-footer a {
color: var(--accent);
text-decoration: none;
}
.support-badge a:hover,
.support-footer a:hover {
color: var(--accent-hover);
text-decoration: underline;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.support-footer {
margin-top: 8px;
padding: 20px 0 8px;
border-top: 1px solid var(--border);
color: var(--text-secondary);
font-size: 0.92rem;
text-align: center;
}
header {
display: flex;
justify-content: space-between;
@@ -859,6 +896,21 @@ input::placeholder {
border-bottom-color: var(--accent);
}
@media (max-width: 768px) {
.support-badge {
right: 12px;
bottom: 12px;
width: min(340px, calc(100vw - 24px));
padding: 10px 12px;
font-size: 0.78rem;
}
.support-footer {
padding-top: 16px;
font-size: 0.88rem;
}
}
.tab-content {
display: none;
}