Compare commits

...

7 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
joshpatra 5e8cb13d1a v1.4.3-beta.1: fixed .env restarting from Admin UI, re-release of prev ver 2026-03-25 16:05:59 -04:00
joshpatra efdeef927a Merge branch 'main' into beta
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
2026-03-24 11:12:49 -04:00
joshpatra 5c184d38c8 v1.4.2: added an env migration service, fixed DOWNLOAD_PATH requiring Subsonic settings in the backend
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
2026-03-24 11:11:46 -04:00
29 changed files with 1449 additions and 324 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}}
+2 -2
View File
@@ -65,13 +65,13 @@ Allstarr includes a web UI for easy configuration and playlist management, acces
- `37i9dQZF1DXcBWIGoYBM5M` (just the ID)
- `spotify:playlist:37i9dQZF1DXcBWIGoYBM5M` (Spotify URI)
- `https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M` (full URL)
4. **Restart** to apply changes (should be a banner)
4. **Restart Allstarr** to apply changes (should be a banner)
Then, proceeed to **Active Playlists**, which shows you which Spotify playlists are currently being monitored and filled with tracks, and lets you do a bunch of useful operations on them.
### Configuration Persistence
The web UI updates your `.env` file directly. Changes persist across container restarts, but require a restart to take effect. In development mode, the `.env` file is in your project root. In Docker, it's at `/app/.env`.
The web UI updates your `.env` file directly. Allstarr reloads that file on startup, so a normal container restart is enough for UI changes to take effect. In development mode, the `.env` file is in your project root. In Docker, it's at `/app/.env`.
There's an environment variable to modify this.
@@ -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);
@@ -0,0 +1,88 @@
using allstarr.Services.Common;
using Microsoft.Extensions.Configuration;
namespace allstarr.Tests;
public sealed class RuntimeEnvConfigurationTests : IDisposable
{
private readonly string _envFilePath = Path.Combine(
Path.GetTempPath(),
$"allstarr-runtime-{Guid.NewGuid():N}.env");
[Fact]
public void MapEnvVarToConfiguration_MapsFlatKeyToNestedConfigKey()
{
var mappings = RuntimeEnvConfiguration
.MapEnvVarToConfiguration("SPOTIFY_IMPORT_MATCHING_INTERVAL_HOURS", "7")
.ToList();
var mapping = Assert.Single(mappings);
Assert.Equal("SpotifyImport:MatchingIntervalHours", mapping.Key);
Assert.Equal("7", mapping.Value);
}
[Fact]
public void MapEnvVarToConfiguration_MapsSharedBackendKeysToBothSections()
{
var mappings = RuntimeEnvConfiguration
.MapEnvVarToConfiguration("MUSIC_SERVICE", "Qobuz")
.OrderBy(x => x.Key, StringComparer.Ordinal)
.ToList();
Assert.Equal(2, mappings.Count);
Assert.Equal("Jellyfin:MusicService", mappings[0].Key);
Assert.Equal("Qobuz", mappings[0].Value);
Assert.Equal("Subsonic:MusicService", mappings[1].Key);
Assert.Equal("Qobuz", mappings[1].Value);
}
[Fact]
public void MapEnvVarToConfiguration_IgnoresComposeOnlyMountKeys()
{
var mappings = RuntimeEnvConfiguration
.MapEnvVarToConfiguration("DOWNLOAD_PATH", "./downloads")
.ToList();
Assert.Empty(mappings);
}
[Fact]
public void LoadDotEnvOverrides_StripsQuotesAndSupportsDoubleUnderscoreKeys()
{
File.WriteAllText(
_envFilePath,
"""
SPOTIFY_API_SESSION_COOKIE="secret-cookie"
Admin__EnableEnvExport=true
""");
var overrides = RuntimeEnvConfiguration.LoadDotEnvOverrides(_envFilePath);
Assert.Equal("secret-cookie", overrides["SpotifyApi:SessionCookie"]);
Assert.Equal("true", overrides["Admin:EnableEnvExport"]);
}
[Fact]
public void AddDotEnvOverrides_OverridesEarlierConfigurationValues()
{
File.WriteAllText(_envFilePath, "SPOTIFY_IMPORT_MATCHING_INTERVAL_HOURS=7\n");
var configuration = new ConfigurationManager();
configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["SpotifyImport:MatchingIntervalHours"] = "24"
});
RuntimeEnvConfiguration.AddDotEnvOverrides(configuration, _envFilePath);
Assert.Equal(7, configuration.GetValue<int>("SpotifyImport:MatchingIntervalHours"));
}
public void Dispose()
{
if (File.Exists(_envFilePath))
{
File.Delete(_envFilePath);
}
}
}
+1 -1
View File
@@ -9,5 +9,5 @@ public static class AppVersion
/// <summary>
/// Current application version.
/// </summary>
public const string Version = "1.4.1";
public const string Version = "1.4.6";
}
+5 -5
View File
@@ -580,7 +580,7 @@ public class ConfigController : ControllerBase
return Ok(new
{
message = "Configuration updated. Restart container to apply changes.",
message = "Configuration updated. Restart Allstarr to apply changes.",
updatedKeys = appliedUpdates,
requiresRestart = true,
envFilePath = _helperService.GetEnvFilePath()
@@ -696,7 +696,7 @@ public class ConfigController : ControllerBase
_logger.LogWarning("Docker socket not available at {Path}", socketPath);
return StatusCode(503, new {
error = "Docker socket not available",
message = "Please restart manually: docker-compose restart allstarr"
message = "Please restart manually: docker restart allstarr"
});
}
@@ -749,7 +749,7 @@ public class ConfigController : ControllerBase
_logger.LogError("Failed to restart container: {StatusCode} - {Body}", response.StatusCode, errorBody);
return StatusCode((int)response.StatusCode, new {
error = "Failed to restart container",
message = "Please restart manually: docker-compose restart allstarr"
message = "Please restart manually: docker restart allstarr"
});
}
}
@@ -758,7 +758,7 @@ public class ConfigController : ControllerBase
_logger.LogError(ex, "Error restarting container");
return StatusCode(500, new {
error = "Failed to restart container",
message = "Please restart manually: docker-compose restart allstarr"
message = "Please restart manually: docker restart allstarr"
});
}
}
@@ -890,7 +890,7 @@ public class ConfigController : ControllerBase
return Ok(new
{
success = true,
message = ".env file imported successfully. Restart the application for changes to take effect."
message = ".env file imported successfully. Restart Allstarr for changes to take effect."
});
}
catch (Exception ex)
+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)
{
+1
View File
@@ -16,6 +16,7 @@ using Microsoft.Extensions.Http;
using System.Net;
var builder = WebApplication.CreateBuilder(args);
RuntimeEnvConfiguration.AddDotEnvOverrides(builder.Configuration, builder.Environment, Console.Out);
// Discover SquidWTF API and streaming endpoints from uptime feeds.
var squidWtfEndpointCatalog = await SquidWtfEndpointDiscovery.DiscoverAsync();
@@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using allstarr.Models.Settings;
using allstarr.Models.Spotify;
using allstarr.Services.Common;
namespace allstarr.Services.Admin;
@@ -20,9 +21,7 @@ public class AdminHelperService
{
_logger = logger;
_jellyfinSettings = jellyfinSettings.Value;
_envFilePath = environment.IsDevelopment()
? Path.Combine(environment.ContentRootPath, "..", ".env")
: "/app/.env";
_envFilePath = RuntimeEnvConfiguration.ResolveEnvFilePath(environment);
}
public string GetJellyfinAuthHeader()
@@ -0,0 +1,235 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
namespace allstarr.Services.Common;
/// <summary>
/// Loads supported flat .env keys into ASP.NET configuration so Docker/admin UI
/// updates stored in /app/.env take effect on the next application startup.
/// </summary>
public static class RuntimeEnvConfiguration
{
private static readonly IReadOnlyDictionary<string, string[]> ExactKeyMappings =
new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
{
["BACKEND_TYPE"] = ["Backend:Type"],
["ADMIN_BIND_ANY_IP"] = ["Admin:BindAnyIp"],
["ADMIN_TRUSTED_SUBNETS"] = ["Admin:TrustedSubnets"],
["ADMIN_ENABLE_ENV_EXPORT"] = ["Admin:EnableEnvExport"],
["CORS_ALLOWED_ORIGINS"] = ["Cors:AllowedOrigins"],
["CORS_ALLOWED_METHODS"] = ["Cors:AllowedMethods"],
["CORS_ALLOWED_HEADERS"] = ["Cors:AllowedHeaders"],
["CORS_ALLOW_CREDENTIALS"] = ["Cors:AllowCredentials"],
["SUBSONIC_URL"] = ["Subsonic:Url"],
["JELLYFIN_URL"] = ["Jellyfin:Url"],
["JELLYFIN_API_KEY"] = ["Jellyfin:ApiKey"],
["JELLYFIN_USER_ID"] = ["Jellyfin:UserId"],
["JELLYFIN_CLIENT_USERNAME"] = ["Jellyfin:ClientUsername"],
["JELLYFIN_LIBRARY_ID"] = ["Jellyfin:LibraryId"],
["LIBRARY_DOWNLOAD_PATH"] = ["Library:DownloadPath"],
["LIBRARY_KEPT_PATH"] = ["Library:KeptPath"],
["REDIS_ENABLED"] = ["Redis:Enabled"],
["REDIS_CONNECTION_STRING"] = ["Redis:ConnectionString"],
["SPOTIFY_IMPORT_ENABLED"] = ["SpotifyImport:Enabled"],
["SPOTIFY_IMPORT_SYNC_START_HOUR"] = ["SpotifyImport:SyncStartHour"],
["SPOTIFY_IMPORT_SYNC_START_MINUTE"] = ["SpotifyImport:SyncStartMinute"],
["SPOTIFY_IMPORT_SYNC_WINDOW_HOURS"] = ["SpotifyImport:SyncWindowHours"],
["SPOTIFY_IMPORT_MATCHING_INTERVAL_HOURS"] = ["SpotifyImport:MatchingIntervalHours"],
["SPOTIFY_IMPORT_PLAYLISTS"] = ["SpotifyImport:Playlists"],
["SPOTIFY_IMPORT_PLAYLIST_IDS"] = ["SpotifyImport:PlaylistIds"],
["SPOTIFY_IMPORT_PLAYLIST_NAMES"] = ["SpotifyImport:PlaylistNames"],
["SPOTIFY_IMPORT_PLAYLIST_LOCAL_TRACKS_POSITIONS"] = ["SpotifyImport:PlaylistLocalTracksPositions"],
["SPOTIFY_API_ENABLED"] = ["SpotifyApi:Enabled"],
["SPOTIFY_API_SESSION_COOKIE"] = ["SpotifyApi:SessionCookie"],
["SPOTIFY_API_SESSION_COOKIE_SET_DATE"] = ["SpotifyApi:SessionCookieSetDate"],
["SPOTIFY_API_CACHE_DURATION_MINUTES"] = ["SpotifyApi:CacheDurationMinutes"],
["SPOTIFY_API_RATE_LIMIT_DELAY_MS"] = ["SpotifyApi:RateLimitDelayMs"],
["SPOTIFY_API_PREFER_ISRC_MATCHING"] = ["SpotifyApi:PreferIsrcMatching"],
["SPOTIFY_LYRICS_API_URL"] = ["SpotifyApi:LyricsApiUrl"],
["SCROBBLING_ENABLED"] = ["Scrobbling:Enabled"],
["SCROBBLING_LOCAL_TRACKS_ENABLED"] = ["Scrobbling:LocalTracksEnabled"],
["SCROBBLING_SYNTHETIC_LOCAL_PLAYED_SIGNAL_ENABLED"] = ["Scrobbling:SyntheticLocalPlayedSignalEnabled"],
["SCROBBLING_LASTFM_ENABLED"] = ["Scrobbling:LastFm:Enabled"],
["SCROBBLING_LASTFM_API_KEY"] = ["Scrobbling:LastFm:ApiKey"],
["SCROBBLING_LASTFM_SHARED_SECRET"] = ["Scrobbling:LastFm:SharedSecret"],
["SCROBBLING_LASTFM_SESSION_KEY"] = ["Scrobbling:LastFm:SessionKey"],
["SCROBBLING_LASTFM_USERNAME"] = ["Scrobbling:LastFm:Username"],
["SCROBBLING_LASTFM_PASSWORD"] = ["Scrobbling:LastFm:Password"],
["SCROBBLING_LISTENBRAINZ_ENABLED"] = ["Scrobbling:ListenBrainz:Enabled"],
["SCROBBLING_LISTENBRAINZ_USER_TOKEN"] = ["Scrobbling:ListenBrainz:UserToken"],
["DEBUG_LOG_ALL_REQUESTS"] = ["Debug:LogAllRequests"],
["DEBUG_REDACT_SENSITIVE_REQUEST_VALUES"] = ["Debug:RedactSensitiveRequestValues"],
["DEEZER_ARL"] = ["Deezer:Arl"],
["DEEZER_ARL_FALLBACK"] = ["Deezer:ArlFallback"],
["DEEZER_QUALITY"] = ["Deezer:Quality"],
["DEEZER_MIN_REQUEST_INTERVAL_MS"] = ["Deezer:MinRequestIntervalMs"],
["QOBUZ_USER_AUTH_TOKEN"] = ["Qobuz:UserAuthToken"],
["QOBUZ_USER_ID"] = ["Qobuz:UserId"],
["QOBUZ_QUALITY"] = ["Qobuz:Quality"],
["QOBUZ_MIN_REQUEST_INTERVAL_MS"] = ["Qobuz:MinRequestIntervalMs"],
["SQUIDWTF_QUALITY"] = ["SquidWTF:Quality"],
["SQUIDWTF_MIN_REQUEST_INTERVAL_MS"] = ["SquidWTF:MinRequestIntervalMs"],
["MUSICBRAINZ_ENABLED"] = ["MusicBrainz:Enabled"],
["MUSICBRAINZ_USERNAME"] = ["MusicBrainz:Username"],
["MUSICBRAINZ_PASSWORD"] = ["MusicBrainz:Password"],
["CACHE_SEARCH_RESULTS_MINUTES"] = ["Cache:SearchResultsMinutes"],
["CACHE_PLAYLIST_IMAGES_HOURS"] = ["Cache:PlaylistImagesHours"],
["CACHE_SPOTIFY_PLAYLIST_ITEMS_HOURS"] = ["Cache:SpotifyPlaylistItemsHours"],
["CACHE_SPOTIFY_MATCHED_TRACKS_DAYS"] = ["Cache:SpotifyMatchedTracksDays"],
["CACHE_LYRICS_DAYS"] = ["Cache:LyricsDays"],
["CACHE_GENRE_DAYS"] = ["Cache:GenreDays"],
["CACHE_METADATA_DAYS"] = ["Cache:MetadataDays"],
["CACHE_ODESLI_LOOKUP_DAYS"] = ["Cache:OdesliLookupDays"],
["CACHE_PROXY_IMAGES_DAYS"] = ["Cache:ProxyImagesDays"],
["CACHE_TRANSCODE_MINUTES"] = ["Cache:TranscodeCacheMinutes"]
};
private static readonly IReadOnlyDictionary<string, string[]> SharedBackendKeyMappings =
new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
{
["MUSIC_SERVICE"] = ["Subsonic:MusicService", "Jellyfin:MusicService"],
["EXPLICIT_FILTER"] = ["Subsonic:ExplicitFilter", "Jellyfin:ExplicitFilter"],
["DOWNLOAD_MODE"] = ["Subsonic:DownloadMode", "Jellyfin:DownloadMode"],
["STORAGE_MODE"] = ["Subsonic:StorageMode", "Jellyfin:StorageMode"],
["CACHE_DURATION_HOURS"] = ["Subsonic:CacheDurationHours", "Jellyfin:CacheDurationHours"],
["ENABLE_EXTERNAL_PLAYLISTS"] = ["Subsonic:EnableExternalPlaylists", "Jellyfin:EnableExternalPlaylists"],
["PLAYLISTS_DIRECTORY"] = ["Subsonic:PlaylistsDirectory", "Jellyfin:PlaylistsDirectory"]
};
private static readonly HashSet<string> IgnoredComposeOnlyKeys = new(StringComparer.OrdinalIgnoreCase)
{
"DOWNLOAD_PATH",
"KEPT_PATH",
"CACHE_PATH",
"REDIS_DATA_PATH"
};
public static string ResolveEnvFilePath(IHostEnvironment environment)
{
return environment.IsDevelopment()
? Path.GetFullPath(Path.Combine(environment.ContentRootPath, "..", ".env"))
: "/app/.env";
}
public static void AddDotEnvOverrides(
ConfigurationManager configuration,
IHostEnvironment environment,
TextWriter? logWriter = null)
{
AddDotEnvOverrides(configuration, ResolveEnvFilePath(environment), logWriter);
}
public static void AddDotEnvOverrides(
ConfigurationManager configuration,
string envFilePath,
TextWriter? logWriter = null)
{
var overrides = LoadDotEnvOverrides(envFilePath);
if (overrides.Count == 0)
{
if (File.Exists(envFilePath))
{
logWriter?.WriteLine($"No supported runtime overrides found in {envFilePath}");
}
return;
}
configuration.AddInMemoryCollection(overrides);
logWriter?.WriteLine($"Loaded {overrides.Count} runtime override(s) from {envFilePath}");
}
public static Dictionary<string, string?> LoadDotEnvOverrides(string envFilePath)
{
var overrides = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
if (!File.Exists(envFilePath))
{
return overrides;
}
foreach (var line in File.ReadLines(envFilePath))
{
if (string.IsNullOrWhiteSpace(line) || line.TrimStart().StartsWith('#'))
{
continue;
}
var separatorIndex = line.IndexOf('=');
if (separatorIndex <= 0)
{
continue;
}
var envKey = line[..separatorIndex].Trim();
var envValue = StripQuotes(line[(separatorIndex + 1)..].Trim());
foreach (var mapping in MapEnvVarToConfiguration(envKey, envValue))
{
overrides[mapping.Key] = mapping.Value;
}
}
return overrides;
}
public static IEnumerable<KeyValuePair<string, string?>> MapEnvVarToConfiguration(string envKey, string? envValue)
{
if (string.IsNullOrWhiteSpace(envKey) || IgnoredComposeOnlyKeys.Contains(envKey))
{
yield break;
}
if (envKey.Contains("__", StringComparison.Ordinal))
{
yield return new KeyValuePair<string, string?>(envKey.Replace("__", ":"), envValue);
yield break;
}
if (SharedBackendKeyMappings.TryGetValue(envKey, out var sharedKeys))
{
foreach (var sharedKey in sharedKeys)
{
yield return new KeyValuePair<string, string?>(sharedKey, envValue);
}
yield break;
}
if (ExactKeyMappings.TryGetValue(envKey, out var configKeys))
{
foreach (var configKey in configKeys)
{
yield return new KeyValuePair<string, string?>(configKey, envValue);
}
}
}
private static string StripQuotes(string? value)
{
if (string.IsNullOrEmpty(value))
{
return value ?? string.Empty;
}
if (value.StartsWith('"') && value.EndsWith('"') && value.Length >= 2)
{
return value[1..^1];
}
return value;
}
}
+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.
+20 -2
View File
@@ -12,7 +12,7 @@
<!-- Restart Required Banner -->
<div class="restart-banner" id="restart-banner">
⚠️ Configuration changed. Restart required to apply changes.
<button onclick="restartContainer()">Restart Now</button>
<button onclick="restartContainer()">Restart Allstarr</button>
<button onclick="dismissRestartBanner()"
style="background: transparent; border: 1px solid var(--bg-primary);">Dismiss</button>
</div>
@@ -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;">
@@ -858,7 +866,7 @@
</p>
<div style="display: flex; gap: 12px; flex-wrap: wrap;">
<button class="danger" onclick="clearCache()">Clear All Cache</button>
<button class="danger" onclick="restartContainer()">Restart Container</button>
<button class="danger" onclick="restartContainer()">Restart Allstarr</button>
</div>
</div>
</div>
@@ -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 -->
+1 -1
View File
@@ -274,7 +274,7 @@ export async function restartContainer() {
return requestJson(
"/api/admin/restart",
{ method: "POST" },
"Failed to restart container",
"Failed to restart Allstarr",
);
}
+4 -4
View File
@@ -270,7 +270,7 @@ async function importEnv(event) {
const result = await runAction({
confirmMessage:
"Import this .env file? This will replace your current configuration.\n\nA backup will be created automatically.\n\nYou will need to restart the container for changes to take effect.",
"Import this .env file? This will replace your current configuration.\n\nA backup will be created automatically.\n\nYou will need to restart Allstarr for changes to take effect.",
task: () => API.importEnv(file),
success: (data) => data.message,
error: (err) => err.message || "Failed to import .env file",
@@ -283,7 +283,7 @@ async function importEnv(event) {
async function restartContainer() {
if (
!confirm(
"Restart the container to apply configuration changes?\n\nThe dashboard will be temporarily unavailable.",
"Restart Allstarr to reload /app/.env and apply configuration changes?\n\nThe dashboard will be temporarily unavailable.",
)
) {
return;
@@ -291,7 +291,7 @@ async function restartContainer() {
const result = await runAction({
task: () => API.restartContainer(),
error: "Failed to restart container",
error: "Failed to restart Allstarr",
});
if (!result) {
@@ -301,7 +301,7 @@ async function restartContainer() {
document.getElementById("restart-overlay")?.classList.add("active");
const statusEl = document.getElementById("restart-status");
if (statusEl) {
statusEl.textContent = "Stopping container...";
statusEl.textContent = "Restarting Allstarr...";
}
setTimeout(() => {
+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;
}