Compare commits

...

28 Commits

Author SHA1 Message Date
unkin-agent 9f4c857b23 test(session): cover the maintenance sweeps and capabilities routing
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
2026-09-26 22:11:03 +10:00
unkin-agent ce7e9f0c3d fix(session): bound a routed request and stop hiding a failed subscribe 2026-09-26 22:11:03 +10:00
unkin-agent bb26760011 fix(session): keep the maintenance sweeps and capabilities with the session owner 2026-09-26 22:11:03 +10:00
unkin-agent af4e44173f Merge remote-tracking branch 'origin/main' into benvin/session-directory
# Conflicts:
#	.woodpecker/ci.yaml
#	Jellyfin.Server/CoreAppHost.cs
#	tests/Jellyfin.Server.Tests/HighAvailability/RedisTestServer.cs
2026-09-26 20:43:52 +10:00
benvin 2a09108d4f Merge pull request 'fix(ha): share quick connect state between instances' (#34) from benvin/quickconnect-shared-state into main
ci/woodpecker/push/ci Pipeline was successful
Reviewed-on: #34
2026-09-26 16:39:55 +10:00
unkin-agent 577d24d190 test(session): prove the directory and bus fall back together
ci/woodpecker/pr/ci Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-09-26 15:12:04 +10:00
unkin-agent 611bcf2c4a fix(session): confirm routed delivery and keep playback state with the owner 2026-09-26 14:45:40 +10:00
unkin-agent e1ca272d6c fail quick connect closed on an unreachable valkey and restore the idempotent exchange
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
2026-09-26 14:28:45 +10:00
unkin-agent 086fdb8257 fix(session): parse the owner key without assuming the pod id
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
2026-09-25 00:04:54 +10:00
unkin-agent 51261b0128 test(session): sample ownership after each non-owner request
ci/woodpecker/push/ci Pipeline was canceled
ci/woodpecker/pr/ci Pipeline was canceled
2026-09-25 00:01:42 +10:00
unkin-agent 9e66708d87 fix(session): tie directory ownership to the live connection
Ownership is claimed with a Lua check-and-set keyed on the instance holding
the websocket, routing prefers a live controller over a local copy, the
session list deduplicates by owner, removal is ownership-checked, undelivered
routed messages surface, single-session lookups stop scanning the keyspace and
directory writes leave the request path bounded by a timeout.
2026-09-24 23:50:01 +10:00
unkin-agent 6c54a02240 make quick connect authorize atomic and stop the fallback shadowing redis
ci/woodpecker/pr/ci Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-09-24 23:38:35 +10:00
unkin-agent 6f362c33c9 fix(session): satisfy StyleCop member ordering and indentation
ci/woodpecker/pr/ci Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-09-24 23:08:52 +10:00
unkin-agent ba4d487c65 share quick connect state between instances
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
Move the pending requests and authorized secrets out of process so the
initiate, authorize and exchange legs can land on different replicas.
2026-09-24 23:02:56 +10:00
unkin-agent 00d0765152 feat(session): share the session directory between instances
ci/woodpecker/pr/ci Pipeline failed
ci/woodpecker/push/ci Pipeline failed
Publish every session to valkey with the instance holding it, and route a
command for a non-local session to its owner over a per-instance pub/sub
channel. Entries expire, so a dead instance leaves the directory.
2026-09-24 22:48:48 +10:00
benvin 6691b785c3 Merge pull request #32 from benvin/userdata-cache
ci/woodpecker/push/ci Pipeline was successful
fix(userdata): read user data through to the database
2026-09-21 23:13:37 +10:00
benvin 2adb13f50f Merge pull request #29 from benvin/config-propagation
ci/woodpecker/push/ci Pipeline was successful
fix(ha): propagate shared-config and library-visibility changes between replicas
2026-09-21 21:44:34 +10:00
benvin 1c98f4a074 Merge pull request #31 from benvin/pg-tests-in-ci
ci/woodpecker/push/ci Pipeline was successful
test(db): run the PostgreSQL provider tests in CI
2026-09-21 07:45:29 +10:00
benvin 393994a454 Merge pull request #30 from benvin/nextup-datetime-kind
ci/woodpecker/push/ci Pipeline was canceled
fix(api): normalise the Next Up cutoff to UTC
2026-09-21 07:42:33 +10:00
benvin ad50c4e433 Merge pull request #28 from benvin/gated-task-keys
ci/woodpecker/push/ci Pipeline was canceled
fix(ha): gate the remaining timer-driven scheduled tasks
2026-09-21 07:40:32 +10:00
unkin-agent 483c739fb1 report a peer's port change locally without rewriting it
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
Keep IConfigurationManager source-compatible for plugin implementers.
2026-09-21 00:46:16 +10:00
unkin-agent d39ec60e2c test(db): link PostgreSqlTestServer instead of referencing Jellyfin.Server.Tests
ci/woodpecker/pr/ci Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-09-21 00:29:11 +10:00
unkin-agent a9d6c749fb keep an applied invalidation from inducing a write that publishes back
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
2026-09-21 00:24:01 +10:00
unkin-agent 44b62dcc64 test(ha): pin the default gated task key set
ci/woodpecker/pr/ci Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-09-20 23:55:17 +10:00
unkin-agent a025655b4d test(db): run the PostgreSQL provider tests in CI
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
Point Jellyfin.Database.Tests.PostgreSQL at the server postgres-migration-chain
already runs, give every test its own database, and fix the two tests that only
ever failed against real PostgreSQL.
2026-09-20 23:48:43 +10:00
unkin-agent face8ac653 fix(api): normalise the Next Up cutoff to UTC
ci/woodpecker/pr/ci Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
The query-string cutoff binds as DateTimeKind.Unspecified, which Npgsql
refuses to write to timestamp with time zone, so /Shows/NextUp 500s.
2026-09-20 23:47:23 +10:00
unkin-agent b662ffa48f propagate shared-config and library-option changes between instances
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
Add a Redis pub/sub invalidation bus, publish from the configuration and library-option write paths, and drop the matching local cache entry on the instances that did not write. No-op without a Redis connection string, and fails open when it is unreachable.
2026-09-20 23:47:21 +10:00
unkin-agent 1965c68a76 fix(ha): gate the remaining timer-driven scheduled tasks
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
Add the eight provider, live TV and plugin-update task keys to
ScanLeaderOptions.GatedTaskKeys and widen the test's task-key discovery to
every assembly that declares an IScheduledTask.
2026-09-20 23:36:34 +10:00
79 changed files with 6685 additions and 277 deletions
+9 -4
View File
@@ -45,9 +45,9 @@ steps:
# testcontainers, and a postgres service container deadlocks the step because the backend mounts
# the ReadWriteOnce workspace volume into service pods and schedules them on another node.
# Its data directory lives on the step's ephemeral storage, not on the workspace volume.
# Scoped to Jellyfin.Server.Tests: the three classes in Jellyfin.Database.Tests.PostgreSQL still
# start their own container, and PostgreSqlProviderTests already fails on main - on an EF 10
# scalar query and on its own data - which a third test in the class then inherits.
# Both projects attach to it through JELLYFIN_TEST_POSTGRES and give every test a database of
# its own, so nothing here depends on a docker daemon.
# Valkey runs in the step for the same reason, attached through JELLYFIN_TEST_REDIS.
- name: postgres-migration-chain
image: mcr.microsoft.com/dotnet/sdk:10.0
depends_on:
@@ -56,15 +56,20 @@ steps:
DOTNET_CLI_TELEMETRY_OPTOUT: "1"
DOTNET_NOLOGO: "1"
JELLYFIN_TEST_POSTGRES: "Host=127.0.0.1;Port=5432;Database=postgres;Username=postgres"
JELLYFIN_TEST_REDIS: "127.0.0.1:6379"
commands:
- apt-get -o Acquire::Retries=3 update || apt-get -o Acquire::Retries=3 update
- DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends postgresql
- DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends postgresql valkey-server
- install -d -o postgres -g postgres /tmp/pgdata /tmp/pgrun
- PGBIN=$(ls -d /usr/lib/postgresql/*/bin | tail -1)
- su postgres -c "$PGBIN/initdb -D /tmp/pgdata -A trust -U postgres"
- su postgres -c "$PGBIN/pg_ctl -D /tmp/pgdata -o \"-c listen_addresses=127.0.0.1 -k /tmp/pgrun\" -l /tmp/pg.log -w start"
- valkey-server --daemonize yes --bind 127.0.0.1 --port 6379 --save ''
- for i in $(seq 30); do valkey-cli -h 127.0.0.1 ping && break; sleep 1; done
- dotnet build tests/Jellyfin.Server.Tests/Jellyfin.Server.Tests.csproj -c Release
- dotnet build tests/Jellyfin.Database.Tests.PostgreSQL/Jellyfin.Database.Tests.PostgreSQL.csproj -c Release
- dotnet test tests/Jellyfin.Server.Tests/Jellyfin.Server.Tests.csproj -c Release --no-build --verbosity minimal --filter "Category=RequiresDocker"
- dotnet test tests/Jellyfin.Database.Tests.PostgreSQL/Jellyfin.Database.Tests.PostgreSQL.csproj -c Release --no-build --verbosity minimal --filter "Category=RequiresDocker"
backend_options:
kubernetes:
serviceAccountName: jellyfin-ha-src
@@ -87,6 +87,12 @@ namespace Emby.Server.Implementations.AppBase
/// <value>The application paths.</value>
public IApplicationPaths CommonApplicationPaths { get; private set; }
/// <summary>
/// Gets or sets the bus announcing configuration writes to the other instances sharing this
/// configuration directory. Defaults to a no-op, which is the single-instance behaviour.
/// </summary>
public IConfigurationInvalidationBus InvalidationBus { get; set; } = NullConfigurationInvalidationBus.Instance;
/// <summary>
/// Gets or sets the system configuration.
/// </summary>
@@ -169,6 +175,8 @@ namespace Emby.Server.Implementations.AppBase
}
OnConfigurationUpdated();
InvalidationBus.PublishLocalWrite(ConfigurationInvalidationScope.SystemConfiguration, null);
}
/// <summary>
@@ -350,6 +358,29 @@ namespace Emby.Server.Implementations.AppBase
}
OnNamedConfigurationUpdated(key, configuration);
InvalidationBus.PublishLocalWrite(ConfigurationInvalidationScope.NamedConfiguration, key);
}
/// <inheritdoc />
public void InvalidateCachedConfiguration(string? key)
{
if (string.IsNullOrEmpty(key))
{
lock (_configurationSyncLock)
{
_configuration = null;
}
// Reloads the system configuration off the shared file as a side effect of re-deriving
// the cache path from it, then tells the in-process consumers to re-read it.
OnConfigurationUpdated();
return;
}
_configurations.TryRemove(key, out _);
OnNamedConfigurationUpdated(key, GetConfiguration(key));
}
/// <summary>
+52 -15
View File
@@ -706,6 +706,8 @@ namespace Emby.Server.Implementations
BaseItem.UserDataManager = Resolve<IUserDataManager>();
CollectionFolder.XmlSerializer = _xmlSerializer;
CollectionFolder.ApplicationHost = this;
CollectionFolder.InvalidationBus = Resolve<IConfigurationInvalidationBus>();
ConfigurationManager.InvalidationBus = CollectionFolder.InvalidationBus;
Folder.UserViewManager = Resolve<IUserViewManager>();
Folder.CollectionManager = Resolve<ICollectionManager>();
Folder.LimitedConcurrencyLibraryScheduler = Resolve<ILimitedConcurrencyLibraryScheduler>();
@@ -790,6 +792,43 @@ namespace Emby.Server.Implementations
}
}
/// <summary>
/// Works out what a configuration update means for the ports this process bound at startup.
/// </summary>
/// <param name="boundHttpPort">The HTTP port this process is bound to.</param>
/// <param name="boundHttpsPort">The HTTPS port this process is bound to.</param>
/// <param name="configuredHttpPort">The HTTP port the shared configuration now carries.</param>
/// <param name="configuredHttpsPort">The HTTPS port the shared configuration now carries.</param>
/// <param name="isPortAuthorized">Whether the shared configuration still marks the port as authorized.</param>
/// <param name="isApplyingRemoteInvalidation">Whether this update is another instance's write being applied.</param>
/// <returns>What the update requires of this instance.</returns>
internal static PortChangeOutcome EvaluatePortChange(
int boundHttpPort,
int boundHttpsPort,
int configuredHttpPort,
int configuredHttpsPort,
bool isPortAuthorized,
bool isApplyingRemoteInvalidation)
{
// Nothing is bound yet, so nothing has gone stale.
if (boundHttpPort == 0 || boundHttpsPort == 0)
{
return default;
}
if (configuredHttpPort == boundHttpPort && configuredHttpsPort == boundHttpsPort)
{
return default;
}
// Whoever wrote the change, this process is still listening on a port the configuration no
// longer names, so the pending restart is reported either way. The authorization flag belongs
// to the instance that made the change: it cleared the flag along with the port, and clearing
// it again here would write shared configuration on that instance's behalf and announce it a
// second time.
return new PortChangeOutcome(true, isPortAuthorized && !isApplyingRemoteInvalidation);
}
/// <summary>
/// Called when [configuration updated].
/// </summary>
@@ -797,26 +836,24 @@ namespace Emby.Server.Implementations
/// <param name="e">The <see cref="EventArgs"/> instance containing the event data.</param>
private void OnConfigurationUpdated(object sender, EventArgs e)
{
var requiresRestart = false;
var networkConfiguration = ConfigurationManager.GetNetworkConfiguration();
// Don't do anything if these haven't been set yet
if (HttpPort != 0 && HttpsPort != 0)
{
// Need to restart if ports have changed
if (networkConfiguration.InternalHttpPort != HttpPort
|| networkConfiguration.InternalHttpsPort != HttpsPort)
{
if (ConfigurationManager.Configuration.IsPortAuthorized)
{
ConfigurationManager.Configuration.IsPortAuthorized = false;
ConfigurationManager.SaveConfiguration();
var portChange = EvaluatePortChange(
HttpPort,
HttpsPort,
networkConfiguration.InternalHttpPort,
networkConfiguration.InternalHttpsPort,
ConfigurationManager.Configuration.IsPortAuthorized,
ConfigurationInvalidationContext.IsApplyingRemoteInvalidation);
requiresRestart = true;
}
}
if (portChange.ClearsPortAuthorization)
{
ConfigurationManager.Configuration.IsPortAuthorized = false;
ConfigurationManager.SaveConfiguration();
}
var requiresRestart = portChange.RequiresRestart;
if (ValidateSslCertificate(networkConfiguration))
{
requiresRestart = true;
@@ -0,0 +1,82 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.Entities;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Emby.Server.Implementations.Configuration
{
/// <summary>
/// Applies the configuration invalidations published by the other instances sharing this
/// configuration directory, dropping the local cache entry so the next read comes off the shared file.
/// </summary>
public sealed class ConfigurationInvalidationSubscriber : IHostedService
{
private readonly IConfigurationInvalidationBus _bus;
private readonly IConfigurationManager _configurationManager;
private readonly ILogger<ConfigurationInvalidationSubscriber> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="ConfigurationInvalidationSubscriber"/> class.
/// </summary>
/// <param name="bus">The invalidation bus.</param>
/// <param name="configurationManager">The configuration manager holding the cached configuration.</param>
/// <param name="logger">The logger.</param>
public ConfigurationInvalidationSubscriber(
IConfigurationInvalidationBus bus,
IConfigurationManager configurationManager,
ILogger<ConfigurationInvalidationSubscriber> logger)
{
_bus = bus;
_configurationManager = configurationManager;
_logger = logger;
}
/// <inheritdoc />
public Task StartAsync(CancellationToken cancellationToken)
{
_bus.Subscribe(Apply);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
private void Apply(ConfigurationInvalidation invalidation)
{
try
{
// Applying re-raises the same update events a local save raises, so the in-process
// consumers re-read. The scope tells those consumers that the write was somebody else's,
// so the ones that answer an update by writing neither repeat it nor publish it back.
using var scope = ConfigurationInvalidationContext.BeginApply();
switch (invalidation.Scope)
{
case ConfigurationInvalidationScope.SystemConfiguration:
_configurationManager.InvalidateCachedConfiguration(null);
break;
case ConfigurationInvalidationScope.NamedConfiguration when !string.IsNullOrEmpty(invalidation.Target):
_configurationManager.InvalidateCachedConfiguration(invalidation.Target);
break;
case ConfigurationInvalidationScope.LibraryOptions when !string.IsNullOrEmpty(invalidation.Target):
CollectionFolder.InvalidateLibraryOptions(invalidation.Target);
break;
case ConfigurationInvalidationScope.AllLibraryOptions:
CollectionFolder.InvalidateAllLibraryOptions();
break;
default:
return;
}
_logger.LogDebug("Applied {Scope} invalidation for {Target} from {OriginId}.", invalidation.Scope, invalidation.Target, invalidation.OriginId);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to apply a {Scope} invalidation for {Target}.", invalidation.Scope, invalidation.Target);
}
}
}
}
@@ -0,0 +1,96 @@
using System;
using System.Text.Json;
using Jellyfin.Extensions.Json;
using MediaBrowser.Common.Configuration;
using Microsoft.Extensions.Logging;
using StackExchange.Redis;
namespace Emby.Server.Implementations.Configuration
{
/// <summary>
/// A Redis pub/sub <see cref="IConfigurationInvalidationBus"/>. Notices are broadcast on one channel
/// and every instance but the publisher applies them.
/// </summary>
public sealed class RedisConfigurationInvalidationBus : IConfigurationInvalidationBus
{
private const string ChannelName = "jellyfin:configinvalidation";
private static readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options;
private readonly ISubscriber _subscriber;
private readonly ILogger<RedisConfigurationInvalidationBus> _logger;
private readonly string _originId;
/// <summary>
/// Initializes a new instance of the <see cref="RedisConfigurationInvalidationBus"/> class.
/// </summary>
/// <param name="redis">The Redis connection multiplexer.</param>
/// <param name="logger">The logger.</param>
public RedisConfigurationInvalidationBus(IConnectionMultiplexer redis, ILogger<RedisConfigurationInvalidationBus> logger)
{
ArgumentNullException.ThrowIfNull(redis);
_subscriber = redis.GetSubscriber();
_logger = logger;
_originId = Environment.GetEnvironmentVariable("JELLYFIN_INSTANCE_ID") ?? Environment.MachineName;
}
/// <inheritdoc />
public void Publish(ConfigurationInvalidationScope scope, string? target)
{
var invalidation = new ConfigurationInvalidation
{
Scope = scope,
Target = target,
OriginId = _originId
};
try
{
// Fire and forget: an admin saving configuration must never wait on, or fail because of,
// the bus. The write has already reached the shared directory by this point.
_subscriber.Publish(
RedisChannel.Literal(ChannelName),
JsonSerializer.Serialize(invalidation, _jsonOptions),
CommandFlags.FireAndForget);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to publish {Scope} invalidation for {Target}; other instances keep their cached copy until they restart.", scope, target);
}
}
/// <inheritdoc />
public void Subscribe(Action<ConfigurationInvalidation> handler)
{
ArgumentNullException.ThrowIfNull(handler);
try
{
_subscriber.Subscribe(RedisChannel.Literal(ChannelName), (_, value) => Dispatch(handler, value));
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to subscribe to configuration invalidations; this instance keeps its cached configuration until it restarts.");
}
}
private void Dispatch(Action<ConfigurationInvalidation> handler, RedisValue value)
{
try
{
var invalidation = JsonSerializer.Deserialize<ConfigurationInvalidation>(value.ToString(), _jsonOptions);
if (invalidation is null || string.Equals(invalidation.OriginId, _originId, StringComparison.Ordinal))
{
return;
}
handler(invalidation);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to apply a configuration invalidation.");
}
}
}
}
@@ -0,0 +1,14 @@
namespace Emby.Server.Implementations
{
/// <summary>
/// What a configuration update carrying different ports requires of the instance reading it.
/// </summary>
/// <param name="RequiresRestart">
/// Whether this process is still bound to a port the shared configuration no longer names, and so has
/// to report a pending restart.
/// </param>
/// <param name="ClearsPortAuthorization">
/// Whether this instance is the one that has to clear the port authorization flag and save it.
/// </param>
internal readonly record struct PortChangeOutcome(bool RequiresRestart, bool ClearsPortAuthorization);
}
@@ -1,7 +1,5 @@
using System;
using System.Collections.Concurrent;
using System.Globalization;
using System.Linq;
using System.Security.Cryptography;
using System.Threading.Tasks;
using MediaBrowser.Common.Extensions;
@@ -30,12 +28,10 @@ namespace Emby.Server.Implementations.QuickConnect
/// </summary>
private const int Timeout = 10;
private readonly ConcurrentDictionary<string, QuickConnectResult> _currentRequests = new();
private readonly ConcurrentDictionary<string, (DateTime Timestamp, AuthenticationResult AuthenticationResult)> _authorizedSecrets = new();
private readonly IServerConfigurationManager _config;
private readonly ILogger<QuickConnectManager> _logger;
private readonly ISessionManager _sessionManager;
private readonly IQuickConnectStore _store;
/// <summary>
/// Initializes a new instance of the <see cref="QuickConnectManager"/> class.
@@ -44,14 +40,17 @@ namespace Emby.Server.Implementations.QuickConnect
/// <param name="config">Configuration.</param>
/// <param name="logger">Logger.</param>
/// <param name="sessionManager">Session Manager.</param>
/// <param name="store">Quick connect store.</param>
public QuickConnectManager(
IServerConfigurationManager config,
ILogger<QuickConnectManager> logger,
ISessionManager sessionManager)
ISessionManager sessionManager,
IQuickConnectStore store)
{
_config = config;
_logger = logger;
_sessionManager = sessionManager;
_store = store;
}
/// <inheritdoc />
@@ -69,7 +68,7 @@ namespace Emby.Server.Implementations.QuickConnect
}
/// <inheritdoc/>
public QuickConnectResult TryConnect(AuthorizationInfo authorizationInfo)
public async Task<QuickConnectResult> TryConnect(AuthorizationInfo authorizationInfo)
{
ArgumentException.ThrowIfNullOrEmpty(authorizationInfo.DeviceId);
ArgumentException.ThrowIfNullOrEmpty(authorizationInfo.Device);
@@ -77,7 +76,6 @@ namespace Emby.Server.Implementations.QuickConnect
ArgumentException.ThrowIfNullOrEmpty(authorizationInfo.Version);
AssertActive();
ExpireRequests();
var secret = GenerateSecureRandom();
var code = GenerateCode();
@@ -90,19 +88,17 @@ namespace Emby.Server.Implementations.QuickConnect
authorizationInfo.Client,
authorizationInfo.Version);
_currentRequests[code] = result;
await _store.SetRequestAsync(result, ExpiryOf(result)).ConfigureAwait(false);
return result;
}
/// <inheritdoc/>
public QuickConnectResult CheckRequestStatus(string secret)
public async Task<QuickConnectResult> CheckRequestStatus(string secret)
{
AssertActive();
ExpireRequests();
string code = _currentRequests.Where(x => x.Value.Secret == secret).Select(x => x.Value.Code).DefaultIfEmpty(string.Empty).First();
if (!_currentRequests.TryGetValue(code, out QuickConnectResult? result))
var result = await _store.GetRequestBySecretAsync(secret).ConfigureAwait(false);
if (result is null)
{
throw new ResourceNotFoundException("Unable to find request with provided secret");
}
@@ -136,21 +132,27 @@ namespace Emby.Server.Implementations.QuickConnect
public async Task<bool> AuthorizeRequest(Guid userId, string code)
{
AssertActive();
ExpireRequests();
if (!_currentRequests.TryGetValue(code, out QuickConnectResult? result))
var result = await _store.GetRequestByCodeAsync(code).ConfigureAwait(false);
if (result is null)
{
throw new ResourceNotFoundException("Unable to find request");
}
if (result.Authenticated)
{
throw new InvalidOperationException("Request is already authorized");
throw new ConflictException("Request is already authorized");
}
// Change the time on the request so it expires one minute into the future. It can't expire immediately as otherwise some clients wouldn't ever see that they have been authenticated.
result.DateAdded = DateTime.UtcNow.Add(TimeSpan.FromMinutes(1));
// The guard above is a read on shared state, so it cannot settle a race between instances; the claim can.
if (!await _store.TryClaimAuthorizationAsync(result.Secret, ExpiryOf(result)).ConfigureAwait(false))
{
throw await RefusedClaimAsync(result.Secret).ConfigureAwait(false);
}
var authenticationResult = await _sessionManager.AuthenticateDirect(new AuthenticationRequest
{
UserId = userId,
@@ -160,9 +162,10 @@ namespace Emby.Server.Implementations.QuickConnect
AppVersion = result.AppVersion
}).ConfigureAwait(false);
_authorizedSecrets[result.Secret] = (DateTime.UtcNow, authenticationResult);
result.Authenticated = true;
_currentRequests[code] = result;
await _store.SetAuthorizationAsync(result.Secret, authenticationResult, DateTime.UtcNow.AddMinutes(Timeout)).ConfigureAwait(false);
await _store.SetRequestAsync(result, ExpiryOf(result)).ConfigureAwait(false);
_logger.LogDebug("Authorizing device with code {Code} to login as user {UserId}", code, userId);
@@ -170,17 +173,33 @@ namespace Emby.Server.Implementations.QuickConnect
}
/// <inheritdoc/>
public AuthenticationResult GetAuthorizedRequest(string secret)
public async Task<AuthenticationResult> GetAuthorizedRequest(string secret)
{
AssertActive();
ExpireRequests();
if (!_authorizedSecrets.TryGetValue(secret, out var result))
var result = await _store.GetAuthorizationAsync(secret).ConfigureAwait(false);
if (result is null)
{
throw new ResourceNotFoundException("Unable to find request");
}
return result.AuthenticationResult;
return result;
}
private static DateTime ExpiryOf(QuickConnectResult request) => request.DateAdded.AddMinutes(Timeout);
/// <summary>
/// Explains a refused claim. The claim outlives a failed mint on purpose, so it can mean either
/// that the request is authorized or that authorizing it did not finish; the two are told apart
/// by re-reading the request rather than reported as the same thing.
/// </summary>
private async Task<ConflictException> RefusedClaimAsync(string secret)
{
var current = await _store.GetRequestBySecretAsync(secret).ConfigureAwait(false);
return current?.Authenticated == true
? new ConflictException("Request is already authorized")
: new ConflictException("Request is being authorized elsewhere, or an earlier attempt to authorize it did not complete. Start quick connect again for a new code.");
}
private string GenerateSecureRandom(int length = 32)
@@ -190,42 +209,5 @@ namespace Emby.Server.Implementations.QuickConnect
return Convert.ToHexString(bytes);
}
/// <summary>
/// Expire quick connect requests that are over the time limit. If <paramref name="expireAll"/> is true, all requests are unconditionally expired.
/// </summary>
/// <param name="expireAll">If true, all requests will be expired.</param>
private void ExpireRequests(bool expireAll = false)
{
// All requests before this timestamp have expired
var minTime = DateTime.UtcNow.AddMinutes(-Timeout);
// Expire stale connection requests
foreach (var (_, currentRequest) in _currentRequests)
{
if (expireAll || currentRequest.DateAdded < minTime)
{
var code = currentRequest.Code;
_logger.LogDebug("Removing expired request {Code}", code);
if (!_currentRequests.TryRemove(code, out _))
{
_logger.LogWarning("Request {Code} already expired", code);
}
}
}
foreach (var (secret, (timestamp, _)) in _authorizedSecrets)
{
if (expireAll || timestamp < minTime)
{
_logger.LogDebug("Removing expired secret {Secret}", secret);
if (!_authorizedSecrets.TryRemove(secret, out _))
{
_logger.LogWarning("Secret {Secret} already expired", secret);
}
}
}
}
}
}
@@ -0,0 +1,164 @@
using System;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Extensions.Json;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller.Authentication;
using MediaBrowser.Controller.QuickConnect;
using MediaBrowser.Model.QuickConnect;
using Microsoft.Extensions.Logging;
using StackExchange.Redis;
namespace Emby.Server.Implementations.QuickConnect;
/// <summary>
/// A Redis-backed <see cref="IQuickConnectStore"/> that lets the initiate, authorize and exchange legs
/// of a quick connect flow land on different instances. Expiry is the key TTL and an authorization is
/// claimed with a Lua check-and-set, so only one instance can ever mint a given secret's access token.
/// </summary>
/// <remarks>
/// There is no local fallback: a call Redis did not answer is inconclusive, and reporting it as a miss
/// would tell a polling client its secret is invalid. Quick connect is unavailable for as long as Redis
/// is, which password login is not.
/// </remarks>
public sealed class RedisQuickConnectStore : IQuickConnectStore
{
private const string KeyPrefix = "jellyfin:quickconnect:";
/// <summary>
/// Lua script writing the two keys a request is resolvable by in one step, so it can never be
/// reachable by its secret while the code the user is reading off the screen resolves to nothing.
/// </summary>
private const string SetRequestScript = @"
redis.call('SET', KEYS[1], ARGV[1], 'PX', ARGV[3])
redis.call('SET', KEYS[2], ARGV[2], 'PX', ARGV[3])
return 1";
/// <summary>
/// Lua script for the atomic claim of the sole right to authorize a request: the request has to
/// exist and not already be authorized, and the claim marker is taken with <c>SET NX</c>, so of two
/// instances racing on one code exactly one goes on to mint an access token.
/// </summary>
private const string ClaimAuthorizationScript = @"
local raw = redis.call('GET', KEYS[1])
if not raw then return 0 end
if cjson.decode(raw)['Authenticated'] then return 0 end
if redis.call('SET', KEYS[2], '1', 'NX', 'PX', ARGV[1]) then return 1 end
return 0";
private readonly IDatabase _db;
private readonly ILogger<RedisQuickConnectStore> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="RedisQuickConnectStore"/> class.
/// </summary>
/// <param name="redis">The Redis connection multiplexer.</param>
/// <param name="logger">The logger.</param>
public RedisQuickConnectStore(IConnectionMultiplexer redis, ILogger<RedisQuickConnectStore> logger)
{
ArgumentNullException.ThrowIfNull(redis);
_db = redis.GetDatabase();
_logger = logger;
}
/// <inheritdoc />
public async Task<QuickConnectResult?> GetRequestBySecretAsync(string secret, CancellationToken cancellationToken = default)
{
var raw = await CallAsync(() => _db.StringGetAsync(RequestKey(secret))).ConfigureAwait(false);
// Deserialization is outside the guard: a malformed stored value is a fault of its own, not Redis
// being unavailable.
return raw.HasValue ? JsonSerializer.Deserialize<QuickConnectResult>(raw.ToString(), JsonDefaults.Options) : null;
}
/// <inheritdoc />
public async Task<QuickConnectResult?> GetRequestByCodeAsync(string code, CancellationToken cancellationToken = default)
{
var secret = await CallAsync(() => _db.StringGetAsync(CodeKey(code))).ConfigureAwait(false);
return secret.HasValue
? await GetRequestBySecretAsync(secret.ToString(), cancellationToken).ConfigureAwait(false)
: null;
}
/// <inheritdoc />
public async Task SetRequestAsync(QuickConnectResult request, DateTime expiresUtc, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var ttl = expiresUtc - DateTime.UtcNow;
if (ttl <= TimeSpan.Zero)
{
return;
}
var json = JsonSerializer.Serialize(request, JsonDefaults.Options);
await CallAsync(() => _db.ScriptEvaluateAsync(
SetRequestScript,
keys: new RedisKey[] { RequestKey(request.Secret), CodeKey(request.Code) },
values: new RedisValue[] { json, request.Secret, (long)ttl.TotalMilliseconds })).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<bool> TryClaimAuthorizationAsync(string secret, DateTime expiresUtc, CancellationToken cancellationToken = default)
{
var ttl = expiresUtc - DateTime.UtcNow;
if (ttl <= TimeSpan.Zero)
{
return false;
}
var claimed = (long?)await CallAsync(() => _db.ScriptEvaluateAsync(
ClaimAuthorizationScript,
keys: new RedisKey[] { RequestKey(secret), ClaimKey(secret) },
values: new RedisValue[] { (long)ttl.TotalMilliseconds })).ConfigureAwait(false);
return claimed == 1;
}
/// <inheritdoc />
public async Task SetAuthorizationAsync(string secret, AuthenticationResult authenticationResult, DateTime expiresUtc, CancellationToken cancellationToken = default)
{
var ttl = expiresUtc - DateTime.UtcNow;
if (ttl <= TimeSpan.Zero)
{
return;
}
var json = JsonSerializer.Serialize(authenticationResult, JsonDefaults.Options);
await CallAsync(() => _db.StringSetAsync(AuthorizationKey(secret), json, ttl)).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<AuthenticationResult?> GetAuthorizationAsync(string secret, CancellationToken cancellationToken = default)
{
var raw = await CallAsync(() => _db.StringGetAsync(AuthorizationKey(secret))).ConfigureAwait(false);
return raw.HasValue
? JsonSerializer.Deserialize<AuthenticationResult>(raw.ToString(), JsonDefaults.Options)
: null;
}
private static string RequestKey(string secret) => KeyPrefix + "request:" + secret;
private static string CodeKey(string code) => KeyPrefix + "code:" + code;
private static string ClaimKey(string secret) => KeyPrefix + "claim:" + secret;
private static string AuthorizationKey(string secret) => KeyPrefix + "auth:" + secret;
private async Task<T> CallAsync<T>(Func<Task<T>> call)
{
try
{
return await call().ConfigureAwait(false);
}
catch (Exception exception) when (exception is RedisException or RedisCommandException or TimeoutException)
{
_logger.LogError(exception, "Quick connect state could not be reached in Redis.");
throw new ServiceUnavailableException("Quick connect is temporarily unavailable.", exception);
}
}
}
@@ -0,0 +1,181 @@
using System;
using System.Collections.Concurrent;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Extensions.Json;
using MediaBrowser.Controller.Session;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StackExchange.Redis;
namespace Emby.Server.Implementations.Session;
/// <summary>
/// A Redis pub/sub <see cref="IPodMessageBus"/>. Every instance subscribes to a channel named after
/// itself, which keeps addressed delivery working without the instances being routable to each other.
/// A request is answered on the sender's own channel, so the sender learns what the receiver did with
/// it rather than only that something was subscribed.
/// </summary>
public sealed class RedisPodMessageBus : IPodMessageBus
{
private const string ChannelPrefix = "jellyfin:pod:";
private static readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options;
private readonly ConcurrentDictionary<string, TaskCompletionSource<bool>> _pending = new(StringComparer.Ordinal);
private readonly ISubscriber _subscriber;
private readonly ILogger<RedisPodMessageBus> _logger;
private readonly TimeSpan _timeout;
private Func<PodMessage, Task<bool>>? _handler;
/// <summary>
/// Initializes a new instance of the <see cref="RedisPodMessageBus"/> class.
/// </summary>
/// <param name="redis">The Redis connection multiplexer.</param>
/// <param name="options">The session directory configuration options.</param>
/// <param name="podId">The identity of this instance.</param>
/// <param name="logger">The logger.</param>
public RedisPodMessageBus(
IConnectionMultiplexer redis,
IOptions<SessionDirectoryOptions> options,
string podId,
ILogger<RedisPodMessageBus> logger)
{
ArgumentNullException.ThrowIfNull(redis);
ArgumentNullException.ThrowIfNull(options);
ArgumentException.ThrowIfNullOrEmpty(podId);
_subscriber = redis.GetSubscriber();
_logger = logger;
_timeout = TimeSpan.FromSeconds(Math.Max(1, options.Value.OperationTimeoutSeconds));
PodId = podId;
// A bus that cannot subscribe can only send, so every request it makes waits out the timeout and
// nothing routed here is ever answered. The caller degrades the pair to single-instance instead.
_subscriber.Subscribe(RedisChannel.Literal(ChannelPrefix + PodId), (_, value) => Dispatch(value));
}
/// <inheritdoc />
public string PodId { get; }
/// <inheritdoc />
public async Task<bool> RequestAsync(string targetPod, PodMessage message, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrEmpty(targetPod);
ArgumentNullException.ThrowIfNull(message);
message.OriginPod = PodId;
message.CorrelationId = Guid.NewGuid().ToString("N");
var acknowledged = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
_pending[message.CorrelationId] = acknowledged;
// Publish and acknowledgement share one deadline, so a request is bounded by the timeout rather
// than by twice it.
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
deadline.CancelAfter(_timeout);
try
{
var subscribers = await _subscriber.PublishAsync(
RedisChannel.Literal(ChannelPrefix + targetPod),
JsonSerializer.Serialize(message, _jsonOptions)).WaitAsync(deadline.Token).ConfigureAwait(false);
if (subscribers == 0)
{
return false;
}
return await acknowledged.Task.WaitAsync(deadline.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
_logger.LogWarning("Instance {TargetPod} did not acknowledge a {Kind} message within {Timeout}.", targetPod, message.Kind, _timeout);
return false;
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogWarning(ex, "Failed to send a {Kind} message to {TargetPod}.", message.Kind, targetPod);
return false;
}
finally
{
_pending.TryRemove(message.CorrelationId, out _);
}
}
/// <inheritdoc />
public void Subscribe(Func<PodMessage, Task<bool>> handler)
{
ArgumentNullException.ThrowIfNull(handler);
_handler = handler;
}
private async void Dispatch(RedisValue value)
{
PodMessage? message = null;
try
{
message = JsonSerializer.Deserialize<PodMessage>(value.ToString(), _jsonOptions);
if (message is null)
{
return;
}
if (string.Equals(message.Kind, PodMessage.AckKind, StringComparison.Ordinal))
{
if (_pending.TryRemove(message.CorrelationId, out var acknowledged))
{
acknowledged.TrySetResult(message.Handled);
}
return;
}
var handler = _handler;
var handled = handler is not null && await handler(message).ConfigureAwait(false);
await AcknowledgeAsync(message, handled).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to handle a message routed to this instance.");
if (message is not null && !string.Equals(message.Kind, PodMessage.AckKind, StringComparison.Ordinal))
{
await AcknowledgeAsync(message, false).ConfigureAwait(false);
}
}
}
private async Task AcknowledgeAsync(PodMessage message, bool handled)
{
if (string.IsNullOrEmpty(message.CorrelationId) || string.IsNullOrEmpty(message.OriginPod))
{
return;
}
var ack = new PodMessage
{
Kind = PodMessage.AckKind,
OriginPod = PodId,
CorrelationId = message.CorrelationId,
Handled = handled
};
try
{
await _subscriber.PublishAsync(
RedisChannel.Literal(ChannelPrefix + message.OriginPod),
JsonSerializer.Serialize(ack, _jsonOptions)).WaitAsync(_timeout).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to acknowledge a {Kind} message to {OriginPod}.", message.Kind, message.OriginPod);
}
}
}
@@ -0,0 +1,251 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Extensions.Json;
using MediaBrowser.Controller.Session;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StackExchange.Redis;
namespace Emby.Server.Implementations.Session;
/// <summary>
/// A Redis-backed <see cref="ISessionDirectory"/>. A session is owned by the instance holding its
/// connection: ownership is claimed through a check-and-set against a connection epoch handed out by
/// Redis, so an instance that only served a request for the session cannot take it from the instance the
/// device is actually connected to, and no instance's clock is compared against another's. Each entry is
/// a key with an expiry, so the sessions of an instance that stops refreshing them disappear on their own.
/// </summary>
public sealed class RedisSessionDirectory : ISessionDirectory
{
private const string KeyPrefix = "jellyfin:session:";
private const string OwnerKeyPrefix = "jellyfin:sessionowner:";
private const string EpochKeyPrefix = "jellyfin:sessionepoch:";
/// <summary>
/// Lua script for an atomic ownership claim. The owner key holds <c>epoch|pod</c>, where the epoch is
/// zero for an instance that holds no connection. Another instance takes ownership only by presenting
/// a connection epoch newer than the recorded one, so neither the instances serving the session's
/// requests nor two instances without a connection can take it from the one that has it.
/// </summary>
private const string ClaimScript = @"
redis.call('PEXPIRE', KEYS[3], ARGV[5])
local current = redis.call('GET', KEYS[1])
if current then
local separator = string.find(current, '|', 1, true)
local connected = tonumber(string.sub(current, 1, separator - 1))
local owner = string.sub(current, separator + 1)
local claiming = tonumber(ARGV[2])
if owner ~= ARGV[1] and (claiming == 0 or claiming <= connected) then
return 0
end
end
redis.call('SET', KEYS[1], ARGV[2] .. '|' .. ARGV[1], 'PX', ARGV[4])
redis.call('SET', KEYS[2], ARGV[3], 'PX', ARGV[4])
return 1";
/// <summary>
/// Lua script for an atomic, ownership-checked removal, so that an instance ending its own copy of a
/// session cannot erase the entry of the instance still holding the connection.
/// </summary>
private const string ReleaseScript = @"
local current = redis.call('GET', KEYS[1])
if not current then return 0 end
local separator = string.find(current, '|', 1, true)
if string.sub(current, separator + 1) ~= ARGV[1] then return 0 end
redis.call('DEL', KEYS[1], KEYS[2])
return 1";
/// <summary>
/// Lua script allocating the next connection epoch. The counter outlives the entries that reference
/// it, so it never restarts underneath a recorded epoch.
/// </summary>
private const string EpochScript = @"
local epoch = redis.call('INCR', KEYS[1])
redis.call('PEXPIRE', KEYS[1], ARGV[1])
return epoch";
private static readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options;
private readonly IConnectionMultiplexer _redis;
private readonly IDatabase _db;
private readonly SessionDirectoryOptions _options;
private readonly ILogger<RedisSessionDirectory> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="RedisSessionDirectory"/> class.
/// </summary>
/// <param name="redis">The Redis connection multiplexer.</param>
/// <param name="options">The session directory configuration options.</param>
/// <param name="logger">The logger.</param>
public RedisSessionDirectory(
IConnectionMultiplexer redis,
IOptions<SessionDirectoryOptions> options,
ILogger<RedisSessionDirectory> logger)
{
ArgumentNullException.ThrowIfNull(redis);
ArgumentNullException.ThrowIfNull(options);
_redis = redis;
_db = redis.GetDatabase();
_options = options.Value;
_logger = logger;
}
private long EntryTtlMs => Math.Max(1, _options.EntryTtlSeconds) * 1000L;
// Outlives the entries that name an epoch, so a live entry never outlives the counter it came from.
private long EpochTtlMs => EntryTtlMs * 4;
private TimeSpan OperationTimeout => TimeSpan.FromSeconds(Math.Max(1, _options.OperationTimeoutSeconds));
/// <inheritdoc />
public async Task<long> AllocateConnectionEpochAsync(string sessionId, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrEmpty(sessionId);
var epoch = (long?)await _db.ScriptEvaluateAsync(
EpochScript,
keys: new RedisKey[] { EpochKeyPrefix + sessionId },
values: new RedisValue[] { EpochTtlMs }).WaitAsync(OperationTimeout, cancellationToken).ConfigureAwait(false);
return epoch ?? 0;
}
/// <inheritdoc />
public async Task<bool> PublishAsync(SessionDirectoryEntry entry, long connectionEpoch, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(entry);
var sessionId = entry.Session?.Id;
if (string.IsNullOrEmpty(sessionId))
{
return false;
}
try
{
var claimed = (long?)await _db.ScriptEvaluateAsync(
ClaimScript,
keys: new RedisKey[] { OwnerKeyPrefix + sessionId, KeyPrefix + sessionId, EpochKeyPrefix + sessionId },
values: new RedisValue[]
{
entry.OwnerPod,
connectionEpoch.ToString(CultureInfo.InvariantCulture),
JsonSerializer.Serialize(entry, _jsonOptions),
EntryTtlMs,
EpochTtlMs
}).WaitAsync(OperationTimeout, cancellationToken).ConfigureAwait(false);
return claimed == 1;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to publish session {SessionId}; it stays invisible to the other instances.", sessionId);
return false;
}
}
/// <inheritdoc />
public async Task RemoveAsync(string sessionId, string ownerPod, CancellationToken cancellationToken = default)
{
try
{
await _db.ScriptEvaluateAsync(
ReleaseScript,
keys: new RedisKey[] { OwnerKeyPrefix + sessionId, KeyPrefix + sessionId },
values: new RedisValue[] { ownerPod }).WaitAsync(OperationTimeout, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to remove session {SessionId}; it expires on its own.", sessionId);
}
}
/// <inheritdoc />
public async Task<SessionDirectoryEntry?> GetAsync(string sessionId, CancellationToken cancellationToken = default)
{
// A store that cannot be read says nothing about where the session is, so the failure is raised
// rather than reported as "no such session", which would be acted on as a local-only session.
var raw = await _db.StringGetAsync(KeyPrefix + sessionId).WaitAsync(OperationTimeout, cancellationToken).ConfigureAwait(false);
return raw.HasValue ? Deserialize(raw) : null;
}
/// <inheritdoc />
public async Task<IReadOnlyList<SessionDirectoryEntry>> GetAllAsync(CancellationToken cancellationToken = default)
{
var entries = new List<SessionDirectoryEntry>();
foreach (var server in _redis.GetServers())
{
if (!server.IsConnected)
{
continue;
}
var keys = new List<RedisKey>();
try
{
await foreach (var key in server.KeysAsync(database: _db.Database, pattern: KeyPrefix + "*", pageSize: 1000).WithCancellation(cancellationToken).ConfigureAwait(false))
{
keys.Add(key);
}
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
// Degrade to the sessions that could be read rather than failing the request outright.
_logger.LogWarning(ex, "Failed to list the session directory on {Server}; its sessions are not reported.", server.EndPoint);
continue;
}
foreach (var raw in await Task.WhenAll(keys.Select(key => ReadAsync(key, cancellationToken))).ConfigureAwait(false))
{
if (!raw.HasValue)
{
continue;
}
var entry = Deserialize(raw);
if (entry?.Session is not null)
{
entries.Add(entry);
}
}
}
return entries;
}
// One unreadable key must not discard the entries that did load.
private async Task<RedisValue> ReadAsync(RedisKey key, CancellationToken cancellationToken)
{
try
{
return await _db.StringGetAsync(key).WaitAsync(OperationTimeout, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogWarning(ex, "Failed to read a session directory entry.");
return RedisValue.Null;
}
}
private SessionDirectoryEntry? Deserialize(RedisValue raw)
{
try
{
return JsonSerializer.Deserialize<SessionDirectoryEntry>(raw.ToString(), _jsonOptions);
}
catch (JsonException ex)
{
_logger.LogWarning(ex, "Failed to deserialize a session directory entry.");
return null;
}
}
}
@@ -0,0 +1,78 @@
using System;
using System.Globalization;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Extensions.Json;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller.Session;
using MediaBrowser.Model.Session;
using Microsoft.Extensions.Logging;
namespace Emby.Server.Implementations.Session;
/// <summary>
/// Stands in for the websocket of a session another instance holds: messages are forwarded to that
/// instance, which writes them to the connection it owns and reports back whether it did.
/// </summary>
public sealed class RemoteSessionController : ISessionController
{
private readonly IPodMessageBus _bus;
private readonly ILogger _logger;
private readonly string _ownerPod;
private readonly string _sessionId;
/// <summary>
/// Initializes a new instance of the <see cref="RemoteSessionController"/> class.
/// </summary>
/// <param name="bus">The cross-instance bus.</param>
/// <param name="logger">The logger.</param>
/// <param name="ownerPod">The instance holding the connection.</param>
/// <param name="sessionId">The session identifier.</param>
/// <param name="supportsMediaControl">Whether the owner reported the session as controllable.</param>
/// <param name="holdsConnection">Whether the owner reported that it holds the session's connection.</param>
public RemoteSessionController(IPodMessageBus bus, ILogger logger, string ownerPod, string sessionId, bool supportsMediaControl, bool holdsConnection)
{
_bus = bus;
_logger = logger;
_ownerPod = ownerPod;
_sessionId = sessionId;
SupportsMediaControl = supportsMediaControl;
IsSessionActive = holdsConnection;
}
/// <inheritdoc />
public bool IsSessionActive { get; }
/// <inheritdoc />
public bool SupportsMediaControl { get; }
/// <inheritdoc />
public async Task SendMessage<T>(SessionMessageType name, Guid messageId, T data, CancellationToken cancellationToken)
{
var routed = new RoutedSessionMessage
{
SessionId = _sessionId,
MessageType = name,
MessageId = messageId,
Data = JsonSerializer.Serialize(data, JsonDefaults.Options)
};
var delivered = await _bus.RequestAsync(
_ownerPod,
new PodMessage
{
Kind = RoutedSessionMessage.Kind,
Payload = JsonSerializer.Serialize(routed, JsonDefaults.Options)
},
cancellationToken).ConfigureAwait(false);
if (!delivered)
{
_logger.LogWarning("Instance {OwnerPod} did not write the {MessageType} message for session {SessionId} to a connection.", _ownerPod, name, _sessionId);
throw new ResourceNotFoundException(
string.Format(CultureInfo.InvariantCulture, "No instance holds a connection to session {0}.", _sessionId));
}
}
}
File diff suppressed because it is too large Load Diff
@@ -114,11 +114,11 @@ namespace Emby.Server.Implementations.Session
public async Task ProcessWebSocketConnectedAsync(IWebSocketConnection connection, HttpContext httpContext)
{
var session = await RequestHelpers.GetSession(_sessionManager, _userManager, httpContext).ConfigureAwait(false);
EnsureController(session, connection);
await EnsureController(session, connection).ConfigureAwait(false);
await KeepAliveWebSocket(connection).ConfigureAwait(false);
}
private void EnsureController(SessionInfo session, IWebSocketConnection connection)
private async Task EnsureController(SessionInfo session, IWebSocketConnection connection)
{
var controllerInfo = session.EnsureController<WebSocketController>(
s => new WebSocketController(_loggerFactory.CreateLogger<WebSocketController>(), s, _sessionManager));
@@ -126,7 +126,7 @@ namespace Emby.Server.Implementations.Session
var controller = (WebSocketController)controllerInfo.Item1;
controller.AddWebSocket(connection);
_sessionManager.OnSessionControllerConnected(session);
await _sessionManager.OnSessionControllerConnected(session).ConfigureAwait(false);
}
/// <summary>
@@ -50,16 +50,18 @@ public class QuickConnectController : BaseJellyfinApiController
/// </summary>
/// <response code="200">Quick connect request successfully created.</response>
/// <response code="401">Quick connect is not active on this server.</response>
/// <response code="503">Quick connect state is unavailable.</response>
/// <returns>A <see cref="QuickConnectResult"/> with a secret and code for future use or an error message.</returns>
[HttpPost("Initiate")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
public async Task<ActionResult<QuickConnectResult>> InitiateQuickConnect()
{
try
{
var auth = await _authContext.GetAuthorizationInfo(Request).ConfigureAwait(false);
return _quickConnect.TryConnect(auth);
return await _quickConnect.TryConnect(auth).ConfigureAwait(false);
}
catch (AuthenticationException)
{
@@ -73,15 +75,17 @@ public class QuickConnectController : BaseJellyfinApiController
/// <param name="secret">Secret previously returned from the Initiate endpoint.</param>
/// <response code="200">Quick connect result returned.</response>
/// <response code="404">Unknown quick connect secret.</response>
/// <response code="503">Quick connect state is unavailable.</response>
/// <returns>An updated <see cref="QuickConnectResult"/>.</returns>
[HttpGet("Connect")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult<QuickConnectResult> GetQuickConnectState([FromQuery, Required] string secret)
[ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
public async Task<ActionResult<QuickConnectResult>> GetQuickConnectState([FromQuery, Required] string secret)
{
try
{
return _quickConnect.CheckRequestStatus(secret);
return await _quickConnect.CheckRequestStatus(secret).ConfigureAwait(false);
}
catch (ResourceNotFoundException)
{
@@ -100,11 +104,15 @@ public class QuickConnectController : BaseJellyfinApiController
/// <param name="userId">The user the authorize. Access to the requested user is required.</param>
/// <response code="200">Quick connect result authorized successfully.</response>
/// <response code="403">Unknown user id.</response>
/// <response code="409">Request is already authorized, or authorizing it did not complete.</response>
/// <response code="503">Quick connect state is unavailable.</response>
/// <returns>Boolean indicating if the authorization was successful.</returns>
[HttpPost("Authorize")]
[Authorize]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
public async Task<ActionResult<bool>> AuthorizeQuickConnect([FromQuery, Required] string code, [FromQuery] Guid? userId = null)
{
userId = RequestHelpers.GetUserId(User, userId);
+12 -11
View File
@@ -52,18 +52,19 @@ public class SessionController : BaseJellyfinApiController
[HttpGet("Sessions")]
[Authorize]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<IReadOnlyList<SessionInfoDto>> GetSessions(
public async Task<ActionResult<IReadOnlyList<SessionInfoDto>>> GetSessions(
[FromQuery] Guid? controllableByUserId,
[FromQuery] string? deviceId,
[FromQuery] int? activeWithinSeconds)
{
Guid? controllableUserToCheck = controllableByUserId is null ? null : RequestHelpers.GetUserId(User, controllableByUserId);
var result = _sessionManager.GetSessions(
var result = await _sessionManager.GetSessions(
User.GetUserId(),
deviceId,
activeWithinSeconds,
controllableUserToCheck,
User.GetIsApiKey());
User.GetIsApiKey(),
HttpContext.RequestAborted).ConfigureAwait(false);
return Ok(result);
}
@@ -310,10 +311,10 @@ public class SessionController : BaseJellyfinApiController
[FromRoute, Required] string sessionId,
[FromRoute, Required] Guid userId)
{
_sessionManager.AddAdditionalUser(
await _sessionManager.AddAdditionalUser(
await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false),
sessionId,
userId);
userId).ConfigureAwait(false);
return NoContent();
}
@@ -331,10 +332,10 @@ public class SessionController : BaseJellyfinApiController
[FromRoute, Required] string sessionId,
[FromRoute, Required] Guid userId)
{
_sessionManager.RemoveAdditionalUser(
await _sessionManager.RemoveAdditionalUser(
await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false),
sessionId,
userId);
userId).ConfigureAwait(false);
return NoContent();
}
@@ -364,13 +365,13 @@ public class SessionController : BaseJellyfinApiController
id = currentSessionId;
}
_sessionManager.ReportCapabilities(currentSessionId, id, new ClientCapabilities
await _sessionManager.ReportCapabilities(currentSessionId, id, new ClientCapabilities
{
PlayableMediaTypes = playableMediaTypes,
SupportedCommands = supportedCommands,
SupportsMediaControl = supportsMediaControl,
SupportsPersistentIdentifier = supportsPersistentIdentifier
});
}).ConfigureAwait(false);
return NoContent();
}
@@ -394,7 +395,7 @@ public class SessionController : BaseJellyfinApiController
id = currentSessionId;
}
_sessionManager.ReportCapabilities(currentSessionId, id, capabilities.ToClientCapabilities());
await _sessionManager.ReportCapabilities(currentSessionId, id, capabilities.ToClientCapabilities()).ConfigureAwait(false);
return NoContent();
}
@@ -415,7 +416,7 @@ public class SessionController : BaseJellyfinApiController
{
var currentSessionId = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
_sessionManager.ReportNowViewingItem(currentSessionId, sessionId ?? currentSessionId, itemId);
await _sessionManager.ReportNowViewingItem(currentSessionId, sessionId ?? currentSessionId, itemId).ConfigureAwait(false);
return NoContent();
}
@@ -108,7 +108,7 @@ public class TvShowsController : BaseJellyfinApiController
StartIndex = startIndex,
User = user,
EnableTotalRecordCount = enableTotalRecordCount,
NextUpDateCutoff = nextUpDateCutoff ?? DateTime.MinValue,
NextUpDateCutoff = nextUpDateCutoff?.ToUniversalTime() ?? DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc),
EnableResumable = enableResumable,
EnableRewatching = enableRewatching
},
+6 -2
View File
@@ -241,15 +241,19 @@ public class UserController : BaseJellyfinApiController
/// <param name="request">The <see cref="QuickConnectDto"/> request.</param>
/// <response code="200">User authenticated.</response>
/// <response code="400">Missing token.</response>
/// <response code="404">Unknown or unauthorized quick connect secret.</response>
/// <response code="503">Quick connect state is unavailable.</response>
/// <returns>A <see cref="Task"/> containing an <see cref="AuthenticationRequest"/> with information about the new session.</returns>
[HttpPost("AuthenticateWithQuickConnect")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
[Tags("Authentication")]
public ActionResult<AuthenticationResult> AuthenticateWithQuickConnect([FromBody, Required] QuickConnectDto request)
public async Task<ActionResult<AuthenticationResult>> AuthenticateWithQuickConnect([FromBody, Required] QuickConnectDto request)
{
try
{
return _quickConnectManager.GetAuthorizedRequest(request.Secret);
return await _quickConnectManager.GetAuthorizedRequest(request.Secret).ConfigureAwait(false);
}
catch (SecurityException e)
{
@@ -131,6 +131,8 @@ public class ExceptionMiddleware
FileNotFoundException => StatusCodes.Status404NotFound,
ResourceNotFoundException => StatusCodes.Status404NotFound,
MethodNotAllowedException => StatusCodes.Status405MethodNotAllowed,
ConflictException => StatusCodes.Status409Conflict,
ServiceUnavailableException => StatusCodes.Status503ServiceUnavailable,
_ => StatusCodes.Status500InternalServerError
};
}
+12
View File
@@ -112,6 +112,18 @@ namespace Jellyfin.Server
// instance. Active by default once a Redis connection is configured, no-op otherwise.
serviceCollection.AddScanLeaderLease(_startupConfig, Logger);
// Configuration invalidation bus: propagates shared-configuration and library-option writes
// to the other instances. Redis-backed when configured, no-op otherwise.
serviceCollection.AddConfigurationInvalidationBus(_startupConfig, Logger);
// Session directory: publishes which instance holds which session and routes remote-control
// messages to it. Redis-backed when configured, no-op otherwise.
serviceCollection.AddSessionDirectory(_startupConfig, Logger);
// Quick connect store: shares in-flight quick connect requests so the initiate, authorize and
// exchange legs can land on different instances. Redis-backed when configured, local otherwise.
serviceCollection.AddQuickConnectStore(_startupConfig, Logger);
foreach (var type in GetExportTypes<ILyricProvider>())
{
serviceCollection.AddSingleton(typeof(ILyricProvider), type);
@@ -0,0 +1,74 @@
using System;
using Emby.Server.Implementations.Configuration;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.MediaEncoding;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using StackExchange.Redis;
namespace Jellyfin.Server.Extensions;
/// <summary>
/// Extensions for registering the shared-configuration invalidation bus.
/// </summary>
public static class ConfigurationInvalidationServiceCollectionExtensions
{
/// <summary>
/// Registers the invalidation bus, Redis-backed when a connection string is configured and no-op
/// otherwise, and the subscriber applying the notices other instances publish.
/// </summary>
/// <remarks>
/// The connection string is only set for a multi-instance deployment, which is the only shape where
/// one instance can write the shared configuration directory behind another's back.
/// </remarks>
/// <param name="serviceCollection">The service collection.</param>
/// <param name="configuration">The configuration to read the Redis connection string from.</param>
/// <param name="logger">The logger to report the selected bus on.</param>
/// <returns>The updated service collection.</returns>
public static IServiceCollection AddConfigurationInvalidationBus(
this IServiceCollection serviceCollection,
IConfiguration configuration,
ILogger logger)
{
ArgumentNullException.ThrowIfNull(configuration);
ArgumentNullException.ThrowIfNull(logger);
if (string.IsNullOrEmpty(configuration[TranscodeStoreOptions.RedisConnectionStringKey]))
{
logger.LogInformation(
"Configuration invalidation bus: {Bus}. Shared-configuration and library-visibility changes stay local to the instance that made them; set {Key} to propagate them.",
nameof(NullConfigurationInvalidationBus),
TranscodeStoreOptions.RedisConnectionStringKey);
return serviceCollection.AddSingleton<IConfigurationInvalidationBus>(NullConfigurationInvalidationBus.Instance);
}
logger.LogInformation(
"Configuration invalidation bus: {Bus}. Shared-configuration and library-visibility changes propagate to every instance.",
nameof(RedisConfigurationInvalidationBus));
serviceCollection.AddSingleton<IConfigurationInvalidationBus>(sp =>
{
try
{
return new RedisConfigurationInvalidationBus(
sp.GetRequiredService<IConnectionMultiplexer>(),
sp.GetRequiredService<ILogger<RedisConfigurationInvalidationBus>>());
}
catch (Exception ex)
{
// Fail open: an unreachable Redis degrades to the single-instance behaviour of every
// instance keeping its own cached configuration, rather than aborting startup.
sp.GetRequiredService<ILogger<CoreAppHost>>().LogError(
ex,
"Redis is configured but unavailable, so shared-configuration changes will not propagate between instances. Check {Key}.",
TranscodeStoreOptions.RedisConnectionStringKey);
return NullConfigurationInvalidationBus.Instance;
}
});
return serviceCollection.AddHostedService<ConfigurationInvalidationSubscriber>();
}
}
@@ -0,0 +1,57 @@
using System;
using Emby.Server.Implementations.QuickConnect;
using MediaBrowser.Controller.MediaEncoding;
using MediaBrowser.Controller.QuickConnect;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using StackExchange.Redis;
namespace Jellyfin.Server.Extensions;
/// <summary>
/// Extensions for registering the quick connect store.
/// </summary>
public static class QuickConnectStoreServiceCollectionExtensions
{
/// <summary>
/// Registers the quick connect store, Redis-backed when a connection string is configured and
/// process-local otherwise, and reports the selected store at <see cref="LogLevel.Information"/>.
/// </summary>
/// <remarks>
/// The connection string is only set for a multi-instance deployment, which is the only shape where
/// the initiate, authorize and exchange legs of one flow can land on different instances. Set but
/// unreachable is a misconfigured deployment rather than a single-instance one, so it fails rather
/// than quietly handing out a store the other instances cannot see.
/// </remarks>
/// <param name="serviceCollection">The service collection.</param>
/// <param name="configuration">The configuration to read the Redis connection string from.</param>
/// <param name="logger">The logger to report the selected store on.</param>
/// <returns>The updated service collection.</returns>
public static IServiceCollection AddQuickConnectStore(
this IServiceCollection serviceCollection,
IConfiguration configuration,
ILogger logger)
{
ArgumentNullException.ThrowIfNull(configuration);
ArgumentNullException.ThrowIfNull(logger);
if (string.IsNullOrEmpty(configuration[TranscodeStoreOptions.RedisConnectionStringKey]))
{
logger.LogInformation(
"Quick connect store: {Store}. A quick connect flow has to complete against one instance; set {Key} to share it.",
nameof(InMemoryQuickConnectStore),
TranscodeStoreOptions.RedisConnectionStringKey);
return serviceCollection.AddSingleton<IQuickConnectStore, InMemoryQuickConnectStore>();
}
logger.LogInformation(
"Quick connect store: {Store}. Quick connect flows complete across any instance.",
nameof(RedisQuickConnectStore));
return serviceCollection.AddSingleton<IQuickConnectStore>(sp => new RedisQuickConnectStore(
sp.GetRequiredService<IConnectionMultiplexer>(),
sp.GetRequiredService<ILogger<RedisQuickConnectStore>>()));
}
}
@@ -0,0 +1,94 @@
using System;
using Emby.Server.Implementations.Session;
using MediaBrowser.Controller.MediaEncoding;
using MediaBrowser.Controller.Session;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StackExchange.Redis;
namespace Jellyfin.Server.Extensions;
/// <summary>
/// Extensions for registering the session directory and the instance-addressed message bus.
/// </summary>
public static class SessionDirectoryServiceCollectionExtensions
{
/// <summary>
/// Registers the session directory and message bus, Redis-backed when a connection string is
/// configured and no-op otherwise, and reports the selection at <see cref="LogLevel.Information"/>.
/// </summary>
/// <param name="serviceCollection">The service collection.</param>
/// <param name="configuration">The configuration to read <c>Jellyfin:SessionDirectory</c> from.</param>
/// <param name="logger">The logger to report the selection on.</param>
/// <returns>The updated service collection.</returns>
public static IServiceCollection AddSessionDirectory(
this IServiceCollection serviceCollection,
IConfiguration configuration,
ILogger logger)
{
ArgumentNullException.ThrowIfNull(configuration);
ArgumentNullException.ThrowIfNull(logger);
serviceCollection.Configure<SessionDirectoryOptions>(configuration.GetSection(SessionDirectoryOptions.ConfigurationSection));
if (string.IsNullOrEmpty(configuration[TranscodeStoreOptions.RedisConnectionStringKey]))
{
logger.LogInformation(
"Session directory: {Directory}. The session list and remote control only reach the sessions this instance holds; set {Key} to share them.",
nameof(NullSessionDirectory),
TranscodeStoreOptions.RedisConnectionStringKey);
}
else
{
logger.LogInformation(
"Session directory: {Directory}. Sessions are visible to, and controllable from, every instance.",
nameof(RedisSessionDirectory));
serviceCollection.AddSingleton(SharedSessionServices.Create);
}
serviceCollection.AddSingleton<IPodMessageBus>(sp => sp.GetService<SharedSessionServices>()?.Bus ?? NullPodMessageBus.Instance);
return serviceCollection.AddSingleton<ISessionDirectory>(sp => sp.GetService<SharedSessionServices>()?.Directory ?? NullSessionDirectory.Instance);
}
// A Redis directory paired with a no-op bus would leave every routed command permanently
// undeliverable, so the pair is built together and falls back together.
private sealed class SharedSessionServices
{
private SharedSessionServices(ISessionDirectory directory, IPodMessageBus bus)
{
Directory = directory;
Bus = bus;
}
public ISessionDirectory Directory { get; }
public IPodMessageBus Bus { get; }
// Fail open: an unreachable Redis degrades to the single-instance behaviour rather than aborting startup.
public static SharedSessionServices Create(IServiceProvider serviceProvider)
{
try
{
var redis = serviceProvider.GetRequiredService<IConnectionMultiplexer>();
var options = serviceProvider.GetRequiredService<IOptions<SessionDirectoryOptions>>();
return new SharedSessionServices(
new RedisSessionDirectory(redis, options, serviceProvider.GetRequiredService<ILogger<RedisSessionDirectory>>()),
new RedisPodMessageBus(redis, options, PodIdentity.Current, serviceProvider.GetRequiredService<ILogger<RedisPodMessageBus>>()));
}
catch (Exception ex)
{
serviceProvider.GetRequiredService<ILogger<CoreAppHost>>().LogError(
ex,
"Redis is configured but unavailable, so sessions will not be shared between instances. Check {Key}.",
TranscodeStoreOptions.RedisConnectionStringKey);
return new SharedSessionServices(NullSessionDirectory.Instance, NullPodMessageBus.Instance);
}
}
}
}
@@ -0,0 +1,26 @@
namespace MediaBrowser.Common.Configuration
{
/// <summary>
/// A notice that one instance has written shared configuration, so every other instance has to drop
/// its locally cached copy and read the shared file again.
/// </summary>
public sealed class ConfigurationInvalidation
{
/// <summary>
/// Gets or sets the cache this notice refers to.
/// </summary>
public ConfigurationInvalidationScope Scope { get; set; }
/// <summary>
/// Gets or sets what was invalidated within the scope: the configuration key for
/// <see cref="ConfigurationInvalidationScope.NamedConfiguration"/>, the library path for
/// <see cref="ConfigurationInvalidationScope.LibraryOptions"/>, and <c>null</c> otherwise.
/// </summary>
public string? Target { get; set; }
/// <summary>
/// Gets or sets the identity of the instance that published the notice, so it can ignore its own.
/// </summary>
public string? OriginId { get; set; }
}
}
@@ -0,0 +1,30 @@
namespace MediaBrowser.Common.Configuration
{
/// <summary>
/// Extensions for <see cref="IConfigurationInvalidationBus"/>.
/// </summary>
public static class ConfigurationInvalidationBusExtensions
{
/// <summary>
/// Announces a write this instance originated, and stays silent for a write induced by an
/// invalidation another instance published.
/// </summary>
/// <remarks>
/// This is the backstop for the write path: a consumer reacting to an applied invalidation by
/// writing - including a plugin that knows nothing about the bus - cannot turn that write into a
/// notice of its own.
/// </remarks>
/// <param name="bus">The bus.</param>
/// <param name="scope">The cache that was written.</param>
/// <param name="target">The configuration key or library path that was written, if any.</param>
public static void PublishLocalWrite(this IConfigurationInvalidationBus bus, ConfigurationInvalidationScope scope, string? target)
{
if (ConfigurationInvalidationContext.IsApplyingRemoteInvalidation)
{
return;
}
bus.Publish(scope, target);
}
}
}
@@ -0,0 +1,57 @@
using System;
using System.Threading;
namespace MediaBrowser.Common.Configuration
{
/// <summary>
/// Marks the flow of control that is applying an invalidation published by another instance, so the
/// reactions to it can tell a remote write apart from a local one.
/// </summary>
/// <remarks>
/// Applying an invalidation raises the same update events a local save raises, because the in-process
/// consumers have to re-read the configuration either way. Some of those consumers answer an update by
/// writing, and that write must neither repeat what the publishing instance already did nor fan back
/// out over the bus. The flag rides the execution context, so it reaches the queued and asynchronous
/// event handlers as well as the synchronous ones.
/// </remarks>
public static class ConfigurationInvalidationContext
{
private static readonly AsyncLocal<bool> _applyingRemoteInvalidation = new();
/// <summary>
/// Gets a value indicating whether the current flow of control is applying an invalidation
/// published by another instance rather than handling a local save.
/// </summary>
public static bool IsApplyingRemoteInvalidation => _applyingRemoteInvalidation.Value;
/// <summary>
/// Marks the current flow of control as applying a remote invalidation until the returned scope is
/// disposed.
/// </summary>
/// <returns>The scope to dispose once the invalidation has been applied.</returns>
public static IDisposable BeginApply() => new ApplyScope();
private sealed class ApplyScope : IDisposable
{
private readonly bool _previous;
private bool _disposed;
public ApplyScope()
{
_previous = _applyingRemoteInvalidation.Value;
_applyingRemoteInvalidation.Value = true;
}
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
_applyingRemoteInvalidation.Value = _previous;
}
}
}
}
@@ -0,0 +1,29 @@
namespace MediaBrowser.Common.Configuration
{
/// <summary>
/// Identifies which locally cached copy of the shared configuration a
/// <see cref="ConfigurationInvalidation"/> refers to.
/// </summary>
public enum ConfigurationInvalidationScope
{
/// <summary>
/// The system configuration cached by the configuration manager.
/// </summary>
SystemConfiguration = 0,
/// <summary>
/// A single named configuration, identified by its key.
/// </summary>
NamedConfiguration = 1,
/// <summary>
/// The library options of a single collection folder, identified by its path.
/// </summary>
LibraryOptions = 2,
/// <summary>
/// The library options of every collection folder, for changes to the library structure itself.
/// </summary>
AllLibraryOptions = 3
}
}
@@ -0,0 +1,28 @@
using System;
namespace MediaBrowser.Common.Configuration
{
/// <summary>
/// Carries cache-invalidation notices between the instances that share one configuration directory.
/// </summary>
/// <remarks>
/// The shared directory carries the content; this bus only carries the fact that it changed. Every
/// implementation is expected to fail open: a bus that cannot deliver must not throw into the write
/// path, leaving each instance on its own locally cached copy until it restarts.
/// </remarks>
public interface IConfigurationInvalidationBus
{
/// <summary>
/// Announces that this instance has written shared configuration.
/// </summary>
/// <param name="scope">The cache that was written.</param>
/// <param name="target">The configuration key or library path that was written, if any.</param>
void Publish(ConfigurationInvalidationScope scope, string? target);
/// <summary>
/// Registers the handler invoked for notices published by other instances.
/// </summary>
/// <param name="handler">The handler applying the invalidation locally.</param>
void Subscribe(Action<ConfigurationInvalidation> handler);
}
}
@@ -85,6 +85,20 @@ namespace MediaBrowser.Common.Configuration
/// </summary>
/// <param name="factories">The factories.</param>
void AddParts(IEnumerable<IConfigurationFactory> factories);
/// <summary>
/// Drops the locally cached copy of configuration another instance has written to the shared
/// configuration directory, so the next read reloads it, and raises the local update event.
/// </summary>
/// <remarks>
/// An implementation predating the invalidation bus keeps the default, which reports that it
/// cannot drop its cache rather than quietly leaving it stale. The caller applying a remote
/// notice treats that as a failed apply and logs it.
/// </remarks>
/// <param name="key">The named configuration key, or <c>null</c> for the system configuration.</param>
/// <exception cref="NotSupportedException">The implementation cannot drop its cached configuration.</exception>
void InvalidateCachedConfiguration(string? key)
=> throw new NotSupportedException(GetType().Name + " cannot drop configuration cached from the shared configuration directory, so writes by other instances will not be picked up.");
}
public static class ConfigurationManagerExtensions
@@ -0,0 +1,27 @@
using System;
namespace MediaBrowser.Common.Configuration
{
/// <summary>
/// A no-op <see cref="IConfigurationInvalidationBus"/> used by single-instance installs and whenever
/// the shared bus is unavailable. Every instance keeps its own cached configuration, which is the
/// behaviour of an install that has only one.
/// </summary>
public sealed class NullConfigurationInvalidationBus : IConfigurationInvalidationBus
{
/// <summary>
/// Gets the shared instance.
/// </summary>
public static NullConfigurationInvalidationBus Instance { get; } = new NullConfigurationInvalidationBus();
/// <inheritdoc />
public void Publish(ConfigurationInvalidationScope scope, string? target)
{
}
/// <inheritdoc />
public void Subscribe(Action<ConfigurationInvalidation> handler)
{
}
}
}
@@ -0,0 +1,36 @@
using System;
namespace MediaBrowser.Common.Extensions
{
/// <summary>
/// Thrown when the current state of a resource does not allow the requested operation.
/// </summary>
public class ConflictException : Exception
{
/// <summary>
/// Initializes a new instance of the <see cref="ConflictException" /> class.
/// </summary>
public ConflictException()
{
}
/// <summary>
/// Initializes a new instance of the <see cref="ConflictException" /> class.
/// </summary>
/// <param name="message">The message.</param>
public ConflictException(string message)
: base(message)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="ConflictException" /> class.
/// </summary>
/// <param name="message">The message.</param>
/// <param name="innerException">The exception that caused this one.</param>
public ConflictException(string message, Exception innerException)
: base(message, innerException)
{
}
}
}
@@ -0,0 +1,37 @@
using System;
namespace MediaBrowser.Common.Extensions
{
/// <summary>
/// Thrown when an operation cannot be answered because a backing service is unreachable, rather than
/// because the thing it was asked about does not exist.
/// </summary>
public class ServiceUnavailableException : Exception
{
/// <summary>
/// Initializes a new instance of the <see cref="ServiceUnavailableException" /> class.
/// </summary>
public ServiceUnavailableException()
{
}
/// <summary>
/// Initializes a new instance of the <see cref="ServiceUnavailableException" /> class.
/// </summary>
/// <param name="message">The message.</param>
public ServiceUnavailableException(string message)
: base(message)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="ServiceUnavailableException" /> class.
/// </summary>
/// <param name="message">The message.</param>
/// <param name="innerException">The exception that caused this one.</param>
public ServiceUnavailableException(string message, Exception innerException)
: base(message, innerException)
{
}
}
}
@@ -14,6 +14,7 @@ using System.Threading.Tasks;
using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Extensions.Json;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Providers;
@@ -70,6 +71,12 @@ namespace MediaBrowser.Controller.Entities
public static IServerApplicationHost ApplicationHost { get; set; }
/// <summary>
/// Gets or sets the bus announcing library option writes to the other instances sharing this
/// configuration directory. Defaults to a no-op, which is the single-instance behaviour.
/// </summary>
public static IConfigurationInvalidationBus InvalidationBus { get; set; } = NullConfigurationInvalidationBus.Instance;
[JsonIgnore]
public override bool SupportsPlayedStatus => false;
@@ -188,11 +195,36 @@ namespace MediaBrowser.Controller.Entities
XmlSerializer.SerializeToFile(clone, GetLibraryOptionsPath(path));
LibraryOptionsUpdated?.Invoke(null, new LibraryOptionsUpdatedEventArgs(path, options));
InvalidationBus.PublishLocalWrite(ConfigurationInvalidationScope.LibraryOptions, path);
}
public static void OnCollectionFolderChange()
/// <summary>
/// Drops the cached options of one library so the next read comes off <c>options.xml</c> again.
/// Applied on the instances that did not write, and so does not publish.
/// </summary>
/// <param name="path">The library path.</param>
public static void InvalidateLibraryOptions(string path)
{
_libraryOptions.TryRemove(path, out _);
LibraryOptionsUpdated?.Invoke(null, new LibraryOptionsUpdatedEventArgs(path, GetLibraryOptions(path)));
}
/// <summary>
/// Drops every cached library option set. Applied on the instances that did not write, and so does
/// not publish.
/// </summary>
public static void InvalidateAllLibraryOptions()
=> _libraryOptions.Clear();
public static void OnCollectionFolderChange()
{
InvalidateAllLibraryOptions();
InvalidationBus.PublishLocalWrite(ConfigurationInvalidationScope.AllLibraryOptions, null);
}
public override bool IsSaveLocalMetadataEnabled()
{
return true;
@@ -1,5 +1,6 @@
using System;
using System.Threading.Tasks;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller.Authentication;
using MediaBrowser.Controller.Net;
using MediaBrowser.Model.QuickConnect;
@@ -21,17 +22,19 @@ namespace MediaBrowser.Controller.QuickConnect
/// </summary>
/// <param name="authorizationInfo">The initiator authorization info.</param>
/// <returns>A quick connect result with tokens to proceed or throws an exception if not active.</returns>
QuickConnectResult TryConnect(AuthorizationInfo authorizationInfo);
Task<QuickConnectResult> TryConnect(AuthorizationInfo authorizationInfo);
/// <summary>
/// Checks the status of an individual request.
/// </summary>
/// <param name="secret">Unique secret identifier of the request.</param>
/// <returns>Quick connect result.</returns>
QuickConnectResult CheckRequestStatus(string secret);
Task<QuickConnectResult> CheckRequestStatus(string secret);
/// <summary>
/// Authorizes a quick connect request to connect as the calling user.
/// Authorizes a quick connect request to connect as the calling user. A request can be authorized
/// once: a second attempt, including one following an attempt that failed part way, throws
/// <see cref="ConflictException"/> and the user has to start quick connect again for a new code.
/// </summary>
/// <param name="userId">User id.</param>
/// <param name="code">Identifying code for the request.</param>
@@ -39,10 +42,11 @@ namespace MediaBrowser.Controller.QuickConnect
Task<bool> AuthorizeRequest(Guid userId, string code);
/// <summary>
/// Gets the authorized request for the secret.
/// Gets the authorized request for the secret. The read does not consume the authorization, so a
/// client that retries the exchange gets the same access token until the authorization expires.
/// </summary>
/// <param name="secret">The secret.</param>
/// <returns>The authentication result.</returns>
AuthenticationResult GetAuthorizedRequest(string secret);
Task<AuthenticationResult> GetAuthorizedRequest(string secret);
}
}
@@ -0,0 +1,77 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller.Authentication;
using MediaBrowser.Model.QuickConnect;
namespace MediaBrowser.Controller.QuickConnect;
/// <summary>
/// Holds the state of in-flight quick connect requests. The three legs of a quick connect flow -
/// initiate, authorize and exchange - can each land on a different instance, so the state has to be
/// reachable from all of them.
/// </summary>
/// <remarks>
/// A shared implementation that cannot reach its backend throws <see cref="ServiceUnavailableException"/>
/// rather than reporting a miss, because a miss tells a polling client its secret is invalid.
/// </remarks>
public interface IQuickConnectStore
{
/// <summary>
/// Looks up a pending request by the secret handed to the initiating client.
/// </summary>
/// <param name="secret">The request secret.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>The request, or <c>null</c> when it is unknown or has expired.</returns>
Task<QuickConnectResult?> GetRequestBySecretAsync(string secret, CancellationToken cancellationToken = default);
/// <summary>
/// Looks up a pending request by the code shown to the user.
/// </summary>
/// <param name="code">The user facing code.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>The request, or <c>null</c> when it is unknown or has expired.</returns>
Task<QuickConnectResult?> GetRequestByCodeAsync(string code, CancellationToken cancellationToken = default);
/// <summary>
/// Stores a new or updated request until <paramref name="expiresUtc"/>, resolvable by both its secret
/// and its code or by neither. A request already past <paramref name="expiresUtc"/> is not stored.
/// </summary>
/// <param name="request">The request to store.</param>
/// <param name="expiresUtc">The instant the request stops being resolvable.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
Task SetRequestAsync(QuickConnectResult request, DateTime expiresUtc, CancellationToken cancellationToken = default);
/// <summary>
/// Atomically claims the sole right to authorize the request behind <paramref name="secret"/>, so
/// that two instances racing on one code cannot both mint an access token. The claim is never
/// released: a mint that failed after writing its token would otherwise be retried into a second one,
/// so a request whose authorization failed has to be started again.
/// </summary>
/// <param name="secret">The request secret.</param>
/// <param name="expiresUtc">The instant the claim lapses, after which the request can be authorized again.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns><c>true</c> when this caller may go on to authorize the request; <c>false</c> when it is unknown, expired, already authorized or claimed elsewhere.</returns>
Task<bool> TryClaimAuthorizationAsync(string secret, DateTime expiresUtc, CancellationToken cancellationToken = default);
/// <summary>
/// Stores the authentication minted for an authorized request until <paramref name="expiresUtc"/>.
/// </summary>
/// <param name="secret">The request secret the client exchanges.</param>
/// <param name="authenticationResult">The authentication to hand out.</param>
/// <param name="expiresUtc">The instant the authentication stops being exchangeable.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
Task SetAuthorizationAsync(string secret, AuthenticationResult authenticationResult, DateTime expiresUtc, CancellationToken cancellationToken = default);
/// <summary>
/// Reads the authentication for <paramref name="secret"/>. The read does not consume it, so a client
/// that retries an exchange gets the same access token for as long as the authentication lives.
/// </summary>
/// <param name="secret">The request secret.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>The authentication, or <c>null</c> when the secret is unknown or has expired.</returns>
Task<AuthenticationResult?> GetAuthorizationAsync(string secret, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,113 @@
using System;
using System.Collections.Concurrent;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.Authentication;
using MediaBrowser.Model.QuickConnect;
namespace MediaBrowser.Controller.QuickConnect;
/// <summary>
/// A process-local <see cref="IQuickConnectStore"/>, and the single-instance default. Nothing it holds
/// is visible to another instance, so a deployment running more than one has to configure a shared
/// store instead.
/// </summary>
public sealed class InMemoryQuickConnectStore : IQuickConnectStore
{
private readonly ConcurrentDictionary<string, Entry<QuickConnectResult>> _requests = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, Entry<AuthenticationResult>> _authorizations = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, DateTime> _authorizationClaims = new(StringComparer.Ordinal);
/// <inheritdoc />
public Task<QuickConnectResult?> GetRequestBySecretAsync(string secret, CancellationToken cancellationToken = default)
{
Expire();
return Task.FromResult(_requests.TryGetValue(secret, out var entry) ? entry.Value : null);
}
/// <inheritdoc />
public Task<QuickConnectResult?> GetRequestByCodeAsync(string code, CancellationToken cancellationToken = default)
{
Expire();
return Task.FromResult(_requests.Values
.Select(entry => entry.Value)
.FirstOrDefault(request => string.Equals(request.Code, code, StringComparison.Ordinal)));
}
/// <inheritdoc />
public Task SetRequestAsync(QuickConnectResult request, DateTime expiresUtc, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
Expire();
if (expiresUtc > DateTime.UtcNow)
{
_requests[request.Secret] = new Entry<QuickConnectResult>(expiresUtc, request);
}
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<bool> TryClaimAuthorizationAsync(string secret, DateTime expiresUtc, CancellationToken cancellationToken = default)
{
Expire();
if (!_requests.TryGetValue(secret, out var entry) || entry.Value.Authenticated)
{
return Task.FromResult(false);
}
return Task.FromResult(_authorizationClaims.TryAdd(secret, expiresUtc));
}
/// <inheritdoc />
public Task SetAuthorizationAsync(string secret, AuthenticationResult authenticationResult, DateTime expiresUtc, CancellationToken cancellationToken = default)
{
Expire();
if (expiresUtc > DateTime.UtcNow)
{
_authorizations[secret] = new Entry<AuthenticationResult>(expiresUtc, authenticationResult);
}
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<AuthenticationResult?> GetAuthorizationAsync(string secret, CancellationToken cancellationToken = default)
{
Expire();
return Task.FromResult(_authorizations.TryGetValue(secret, out var entry) ? entry.Value : null);
}
private void Expire()
{
var now = DateTime.UtcNow;
foreach (var (secret, entry) in _requests)
{
if (entry.ExpiresUtc <= now)
{
_requests.TryRemove(secret, out _);
}
}
foreach (var (secret, entry) in _authorizations)
{
if (entry.ExpiresUtc <= now)
{
_authorizations.TryRemove(secret, out _);
}
}
foreach (var (secret, expiresUtc) in _authorizationClaims)
{
if (expiresUtc <= now)
{
_authorizationClaims.TryRemove(secret, out _);
}
}
}
private sealed record Entry<T>(DateTime ExpiresUtc, T Value);
}
@@ -43,6 +43,14 @@ public sealed class ScanLeaderOptions
"TaskExtractMediaSegments",
"KeyframeExtraction",
"CleanupUserDataTask",
"OptimizeDatabaseTask"
"OptimizeDatabaseTask",
"DownloadLyrics",
"DownloadSubtitles",
"TmdbRefreshUpcomingEpisodes",
"RefreshTrickplayImages",
"MoveTrickplayImages",
"RefreshInternetChannels",
"RefreshGuide",
"PluginUpdates"
};
}
@@ -0,0 +1,35 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace MediaBrowser.Controller.Session;
/// <summary>
/// Point-to-point delivery between instances: every instance listens on a channel of its own, so a
/// message can be addressed to the one instance holding a given connection.
/// </summary>
public interface IPodMessageBus
{
/// <summary>
/// Gets the identity of this instance.
/// </summary>
string PodId { get; }
/// <summary>
/// Sends a message to one instance and waits for that instance to report what it did with it. The
/// number of subscribers only proves the target's connection to the broker is up, so delivery is
/// taken from the acknowledgement of the instance that has to act on the message.
/// </summary>
/// <param name="targetPod">The instance to deliver to.</param>
/// <param name="message">The message.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns><c>true</c> if the target acknowledged having carried the message out.</returns>
Task<bool> RequestAsync(string targetPod, PodMessage message, CancellationToken cancellationToken = default);
/// <summary>
/// Registers a handler for the messages addressed to this instance. Whatever the handler returns is
/// sent back to the origin as the acknowledgement.
/// </summary>
/// <param name="handler">The handler.</param>
void Subscribe(Func<PodMessage, Task<bool>> handler);
}
@@ -0,0 +1,57 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace MediaBrowser.Controller.Session;
/// <summary>
/// The shared record of which instance holds which session. Entries expire, so an instance that stops
/// refreshing them drops out of every other instance's view instead of lingering.
/// </summary>
public interface ISessionDirectory
{
/// <summary>
/// Allocates the next connection epoch for a session. The counter lives in the shared store, so the
/// epochs of every instance are ordered by one clock instead of being compared across machines.
/// </summary>
/// <param name="sessionId">The session identifier.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The allocated epoch, which is greater than every epoch allocated for the session before it.</returns>
Task<long> AllocateConnectionEpochAsync(string sessionId, CancellationToken cancellationToken = default);
/// <summary>
/// Claims a session for the publishing instance and restarts its expiry. The claim is refused when
/// another instance holds the connection, so an instance that merely served a request for the session
/// cannot take ownership of it.
/// </summary>
/// <param name="entry">The entry.</param>
/// <param name="connectionEpoch">The epoch of the publishing instance's connection to the session, or zero when it holds none.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns><c>true</c> if the entry was written.</returns>
Task<bool> PublishAsync(SessionDirectoryEntry entry, long connectionEpoch, CancellationToken cancellationToken = default);
/// <summary>
/// Removes an entry, but only while the calling instance still owns it.
/// </summary>
/// <param name="sessionId">The session identifier.</param>
/// <param name="ownerPod">The instance requesting the removal.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A task representing the operation.</returns>
Task RemoveAsync(string sessionId, string ownerPod, CancellationToken cancellationToken = default);
/// <summary>
/// Gets one entry by session identifier.
/// </summary>
/// <param name="sessionId">The session identifier.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The entry, or <c>null</c> when the session is in no instance's directory.</returns>
/// <exception cref="System.Exception">The store could not be read. An unreadable store is not an absent session.</exception>
Task<SessionDirectoryEntry?> GetAsync(string sessionId, CancellationToken cancellationToken = default);
/// <summary>
/// Gets every entry that has not expired.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The entries.</returns>
Task<IReadOnlyList<SessionDirectoryEntry>> GetAllAsync(CancellationToken cancellationToken = default);
}
@@ -80,7 +80,8 @@ namespace MediaBrowser.Controller.Session
/// Used to report that a session controller has connected.
/// </summary>
/// <param name="session">The session.</param>
void OnSessionControllerConnected(SessionInfo session);
/// <returns>A task representing the operation.</returns>
Task OnSessionControllerConnected(SessionInfo session);
void UpdateDeviceName(string sessionId, string reportedDeviceName);
@@ -241,7 +242,8 @@ namespace MediaBrowser.Controller.Session
/// <param name="controllingSessionId">The controlling session identifier.</param>
/// <param name="sessionId">The session identifier.</param>
/// <param name="userId">The user identifier.</param>
void AddAdditionalUser(string controllingSessionId, string sessionId, Guid userId);
/// <returns>A task representing the operation.</returns>
Task AddAdditionalUser(string controllingSessionId, string sessionId, Guid userId);
/// <summary>
/// Removes the additional user.
@@ -249,7 +251,8 @@ namespace MediaBrowser.Controller.Session
/// <param name="controllingSessionId">The controlling session identifier.</param>
/// <param name="sessionId">The session identifier.</param>
/// <param name="userId">The user identifier.</param>
void RemoveAdditionalUser(string controllingSessionId, string sessionId, Guid userId);
/// <returns>A task representing the operation.</returns>
Task RemoveAdditionalUser(string controllingSessionId, string sessionId, Guid userId);
/// <summary>
/// Reports the now viewing item.
@@ -257,7 +260,8 @@ namespace MediaBrowser.Controller.Session
/// <param name="controllingSessionId">The controlling session identifier.</param>
/// <param name="sessionId">The session identifier.</param>
/// <param name="itemId">The item identifier.</param>
void ReportNowViewingItem(string controllingSessionId, string sessionId, string itemId);
/// <returns>A task representing the operation.</returns>
Task ReportNowViewingItem(string controllingSessionId, string sessionId, string itemId);
/// <summary>
/// Authenticates the new session.
@@ -274,7 +278,8 @@ namespace MediaBrowser.Controller.Session
/// <param name="controllingSessionId">The controlling session identifier.</param>
/// <param name="sessionId">The session identifier.</param>
/// <param name="capabilities">The capabilities.</param>
void ReportCapabilities(string controllingSessionId, string sessionId, ClientCapabilities capabilities);
/// <returns>A task representing the operation.</returns>
Task ReportCapabilities(string controllingSessionId, string sessionId, ClientCapabilities capabilities);
/// <summary>
/// Reports the transcoding information.
@@ -306,8 +311,9 @@ namespace MediaBrowser.Controller.Session
/// <param name="activeWithinSeconds">Active within session limit.</param>
/// <param name="controllableUserToCheck">Filter for sessions remote controllable for this user.</param>
/// <param name="isApiKey">Is the request authenticated with API key.</param>
/// <returns>IReadOnlyList{SessionInfoDto}.</returns>
IReadOnlyList<SessionInfoDto> GetSessions(Guid userId, string deviceId, int? activeWithinSeconds, Guid? controllableUserToCheck, bool isApiKey);
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>IReadOnlyList{SessionInfoDto}, including the sessions held by the other instances.</returns>
Task<IReadOnlyList<SessionInfoDto>> GetSessions(Guid userId, string deviceId, int? activeWithinSeconds, Guid? controllableUserToCheck, bool isApiKey, CancellationToken cancellationToken);
/// <summary>
/// Gets the session by authentication token.
@@ -0,0 +1,28 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace MediaBrowser.Controller.Session;
/// <summary>
/// The single-instance <see cref="IPodMessageBus"/>: there is no other instance to reach.
/// </summary>
public sealed class NullPodMessageBus : IPodMessageBus
{
/// <summary>
/// Gets the shared instance.
/// </summary>
public static NullPodMessageBus Instance { get; } = new NullPodMessageBus();
/// <inheritdoc />
public string PodId => PodIdentity.Current;
/// <inheritdoc />
public Task<bool> RequestAsync(string targetPod, PodMessage message, CancellationToken cancellationToken = default)
=> Task.FromResult(false);
/// <inheritdoc />
public void Subscribe(Func<PodMessage, Task<bool>> handler)
{
}
}
@@ -0,0 +1,38 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace MediaBrowser.Controller.Session;
/// <summary>
/// The single-instance <see cref="ISessionDirectory"/>: nothing is published and no session is held
/// anywhere but here, which is exactly the behaviour of a deployment without a shared store.
/// </summary>
public sealed class NullSessionDirectory : ISessionDirectory
{
/// <summary>
/// Gets the shared instance.
/// </summary>
public static NullSessionDirectory Instance { get; } = new NullSessionDirectory();
/// <inheritdoc />
public Task<long> AllocateConnectionEpochAsync(string sessionId, CancellationToken cancellationToken = default)
=> Task.FromResult(0L);
/// <inheritdoc />
public Task<bool> PublishAsync(SessionDirectoryEntry entry, long connectionEpoch, CancellationToken cancellationToken = default)
=> Task.FromResult(false);
/// <inheritdoc />
public Task RemoveAsync(string sessionId, string ownerPod, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
/// <inheritdoc />
public Task<SessionDirectoryEntry?> GetAsync(string sessionId, CancellationToken cancellationToken = default)
=> Task.FromResult<SessionDirectoryEntry?>(null);
/// <inheritdoc />
public Task<IReadOnlyList<SessionDirectoryEntry>> GetAllAsync(CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<SessionDirectoryEntry>>(Array.Empty<SessionDirectoryEntry>());
}
@@ -0,0 +1,14 @@
using System;
namespace MediaBrowser.Controller.Session;
/// <summary>
/// The identity of this instance among the replicas sharing a deployment.
/// </summary>
public static class PodIdentity
{
/// <summary>
/// Gets the identity of this instance.
/// </summary>
public static string Current => Environment.GetEnvironmentVariable("JELLYFIN_INSTANCE_ID") ?? Environment.MachineName;
}
@@ -0,0 +1,39 @@
namespace MediaBrowser.Controller.Session;
/// <summary>
/// An envelope addressed to one instance. <see cref="Kind"/> names the payload so that features other
/// than session routing can share the same channel.
/// </summary>
public sealed class PodMessage
{
/// <summary>
/// The <see cref="Kind"/> of the acknowledgement the receiving instance sends back.
/// </summary>
public const string AckKind = "Ack";
/// <summary>
/// Gets or sets the payload discriminator.
/// </summary>
public string Kind { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the identity of the sending instance.
/// </summary>
public string OriginPod { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the identifier tying an acknowledgement to the message it answers.
/// </summary>
public string CorrelationId { get; set; } = string.Empty;
/// <summary>
/// Gets or sets a value indicating whether the receiving instance carried the message out. Only
/// meaningful on an <see cref="AckKind"/> message.
/// </summary>
public bool Handled { get; set; }
/// <summary>
/// Gets or sets the serialized payload.
/// </summary>
public string Payload { get; set; } = string.Empty;
}
@@ -0,0 +1,30 @@
using System;
namespace MediaBrowser.Controller.Session;
/// <summary>
/// An additional-user change for a session held by another instance, carried as a
/// <see cref="PodMessage"/>. The calling instance has already authorized it.
/// </summary>
public sealed class RoutedAdditionalUserChange
{
/// <summary>
/// The <see cref="PodMessage.Kind"/> this payload travels under.
/// </summary>
public const string Kind = "AdditionalUserChange";
/// <summary>
/// Gets or sets the session the change applies to.
/// </summary>
public string SessionId { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the user to attach or detach.
/// </summary>
public Guid UserId { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the user is being attached rather than detached.
/// </summary>
public bool Add { get; set; }
}
@@ -0,0 +1,25 @@
using MediaBrowser.Model.Session;
namespace MediaBrowser.Controller.Session;
/// <summary>
/// A capabilities report for a session held by another instance, carried as a <see cref="PodMessage"/>.
/// The calling instance has already authorized it.
/// </summary>
public sealed class RoutedCapabilities
{
/// <summary>
/// The <see cref="PodMessage.Kind"/> this payload travels under.
/// </summary>
public const string Kind = "Capabilities";
/// <summary>
/// Gets or sets the session the report applies to.
/// </summary>
public string SessionId { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the reported capabilities.
/// </summary>
public ClientCapabilities? Capabilities { get; set; }
}
@@ -0,0 +1,23 @@
namespace MediaBrowser.Controller.Session;
/// <summary>
/// A now-viewing report for a session held by another instance, carried as a <see cref="PodMessage"/>.
/// The calling instance has already authorized it.
/// </summary>
public sealed class RoutedNowViewingItem
{
/// <summary>
/// The <see cref="PodMessage.Kind"/> this payload travels under.
/// </summary>
public const string Kind = "NowViewingItem";
/// <summary>
/// Gets or sets the session the report applies to.
/// </summary>
public string SessionId { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the item being viewed.
/// </summary>
public string ItemId { get; set; } = string.Empty;
}
@@ -0,0 +1,23 @@
namespace MediaBrowser.Controller.Session;
/// <summary>
/// The <see cref="PodMessage.Kind"/> values a playback report travels under when the session it is
/// addressed to is held by another instance. The payload is the report itself.
/// </summary>
public static class RoutedPlaybackReport
{
/// <summary>
/// A <c>PlaybackStartInfo</c>.
/// </summary>
public const string StartKind = "PlaybackStart";
/// <summary>
/// A <c>PlaybackProgressInfo</c>.
/// </summary>
public const string ProgressKind = "PlaybackProgress";
/// <summary>
/// A <c>PlaybackStopInfo</c>.
/// </summary>
public const string StoppedKind = "PlaybackStopped";
}
@@ -0,0 +1,35 @@
using System;
using MediaBrowser.Model.Session;
namespace MediaBrowser.Controller.Session;
/// <summary>
/// A websocket message for a session held by another instance, carried as a <see cref="PodMessage"/>.
/// </summary>
public sealed class RoutedSessionMessage
{
/// <summary>
/// The <see cref="PodMessage.Kind"/> this payload travels under.
/// </summary>
public const string Kind = "SessionMessage";
/// <summary>
/// Gets or sets the session the message is addressed to.
/// </summary>
public string SessionId { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the message type.
/// </summary>
public SessionMessageType MessageType { get; set; }
/// <summary>
/// Gets or sets the message identifier.
/// </summary>
public Guid MessageId { get; set; }
/// <summary>
/// Gets or sets the message data, serialized as JSON.
/// </summary>
public string Data { get; set; } = "null";
}
@@ -0,0 +1,25 @@
using MediaBrowser.Model.Dto;
namespace MediaBrowser.Controller.Session;
/// <summary>
/// A session held by one instance, as the other instances see it.
/// </summary>
public sealed class SessionDirectoryEntry
{
/// <summary>
/// Gets or sets the identity of the instance holding the connection.
/// </summary>
public string OwnerPod { get; set; } = string.Empty;
/// <summary>
/// Gets or sets a value indicating whether the owner holds a live connection to the session. Only an
/// owner that does can be routed a remote-control message.
/// </summary>
public bool HoldsConnection { get; set; }
/// <summary>
/// Gets or sets the session as its owner last rendered it.
/// </summary>
public SessionInfoDto? Session { get; set; }
}
@@ -0,0 +1,28 @@
namespace MediaBrowser.Controller.Session;
/// <summary>
/// Configuration options for the session directory and the cross-instance bus that goes with it.
/// </summary>
public sealed class SessionDirectoryOptions
{
/// <summary>
/// The configuration section these options bind from.
/// </summary>
public const string ConfigurationSection = "Jellyfin:SessionDirectory";
/// <summary>
/// Gets or sets how long in seconds a published entry survives without being refreshed. An instance
/// that dies stops refreshing, so its sessions leave the directory after this long.
/// </summary>
public int EntryTtlSeconds { get; set; } = 60;
/// <summary>
/// Gets or sets how often in seconds an instance republishes the sessions it holds.
/// </summary>
public int RefreshIntervalSeconds { get; set; } = 20;
/// <summary>
/// Gets or sets how long in seconds a single directory operation may take before it is abandoned.
/// </summary>
public int OperationTimeoutSeconds { get; set; } = 5;
}
+2 -2
View File
@@ -12,7 +12,7 @@ public class NextUpQuery
{
EnableImageTypes = Array.Empty<ImageType>();
EnableTotalRecordCount = true;
NextUpDateCutoff = DateTime.MinValue;
NextUpDateCutoff = DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc);
EnableResumable = false;
EnableRewatching = false;
}
@@ -56,7 +56,7 @@ public class NextUpQuery
public bool EnableTotalRecordCount { get; set; }
/// <summary>
/// Gets or sets a value indicating the oldest date for a show to appear in Next Up.
/// Gets or sets a value indicating the oldest date, in UTC, for a show to appear in Next Up.
/// </summary>
public DateTime NextUpDateCutoff { get; set; }
+3
View File
@@ -116,6 +116,9 @@ Without a connection string the line reads `Transcode session store: NullTransco
| `Jellyfin:TranscodeStore:RedisConnectionString` | _(empty)_ | StackExchange.Redis connection string. Empty = single-instance mode. |
| `Jellyfin:TranscodeStore:LeaseDurationSeconds` | `30` | How long a pod's transcode lease is valid before another pod may take over. |
| `Jellyfin:TranscodeStore:SessionRetentionSeconds` | `300` | How long an unrenewed session record is kept so another pod can still take it over. |
| `Jellyfin:SessionDirectory:EntryTtlSeconds` | `60` | How long a published session stays visible to the other pods without being refreshed. |
| `Jellyfin:SessionDirectory:RefreshIntervalSeconds` | `20` | How often a pod republishes the sessions it holds. |
| `Jellyfin:SessionDirectory:OperationTimeoutSeconds` | `5` | How long a single session directory read or write may take before it is abandoned. |
### Redis connection string examples
@@ -444,6 +444,13 @@ public sealed class RecordingsManager : IRecordingsManager, IDisposable
private async void OnNamedConfigurationUpdated(object? sender, ConfigurationUpdateEventArgs e)
{
// The instance that wrote the change creates the folders; racing it from here would write the
// same virtual folder a second time.
if (ConfigurationInvalidationContext.IsApplyingRemoteInvalidation)
{
return;
}
if (string.Equals(e.Key, "livetv", StringComparison.OrdinalIgnoreCase))
{
await CreateRecordingFolders().ConfigureAwait(false);
@@ -18,6 +18,11 @@
<PackageReference Include="coverlet.collector" />
</ItemGroup>
<ItemGroup>
<!-- Linked, not project-referenced: Jellyfin.Server.Tests drags the whole server into this output. -->
<Compile Include="..\Jellyfin.Server.Tests\Migrations\PostgreSqlTestServer.cs" Link="Migrations\PostgreSqlTestServer.cs" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Jellyfin.Database\Jellyfin.Database.Providers.PostgreSQL\Jellyfin.Database.Providers.PostgreSQL.csproj" />
<ProjectReference Include="..\..\src\Jellyfin.Database\Jellyfin.Database.Implementations\Jellyfin.Database.Implementations.csproj" />
@@ -1,71 +1,68 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using DotNet.Testcontainers.Builders;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.DbConfiguration;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.Locking;
using Jellyfin.Database.Providers.PostgreSQL;
using Jellyfin.Server.Tests.Migrations;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Npgsql;
using Testcontainers.PostgreSql;
using Xunit;
namespace Jellyfin.Database.Tests.PostgreSQL;
/// <summary>
/// Integration tests that verify concurrent access patterns against a real PostgreSQL 16 container.
/// Integration tests that verify concurrent access patterns against a real PostgreSQL server.
/// </summary>
[Xunit.Trait("Category", "RequiresDocker")]
public sealed class PostgreSqlConcurrencyTests : IAsyncLifetime
{
private readonly PostgreSqlContainer _container;
private static int _databaseSequence;
private PostgreSqlTestServer? _server;
private NpgsqlDataSource? _dataSource;
private PostgreSqlDatabaseProvider? _provider;
/// <summary>
/// Initializes a new instance of the <see cref="PostgreSqlConcurrencyTests"/> class.
/// </summary>
public PostgreSqlConcurrencyTests()
{
_container = new PostgreSqlBuilder("postgres:16-alpine")
.WithWaitStrategy(Wait.ForUnixContainer().UntilCommandIsCompleted("pg_isready"))
.Build();
}
/// <summary>
/// Starts the PostgreSQL container and applies migrations before any tests in the class run.
/// Attaches to the test server, hands this test a database of its own and applies migrations to it.
/// </summary>
/// <returns>A <see cref="ValueTask"/> representing the asynchronous operation.</returns>
public async ValueTask InitializeAsync()
{
await _container.StartAsync().ConfigureAwait(false);
_server = await PostgreSqlTestServer.StartAsync().ConfigureAwait(false);
_dataSource = new NpgsqlDataSourceBuilder(_container.GetConnectionString()).Build();
var databaseName = FormattableString.Invariant($"pg_concurrency_{Interlocked.Increment(ref _databaseSequence)}");
var connectionString = await _server.CreateDatabaseAsync(databaseName, TestContext.Current.CancellationToken).ConfigureAwait(false);
_dataSource = new NpgsqlDataSourceBuilder(connectionString).Build();
_provider = new PostgreSqlDatabaseProvider(_dataSource);
// Apply migrations once for the whole test class.
var context = CreateContext();
await using (context.ConfigureAwait(false))
{
await context.Database.MigrateAsync().ConfigureAwait(false);
await context.Database.MigrateAsync(TestContext.Current.CancellationToken).ConfigureAwait(false);
}
}
/// <summary>
/// Stops and removes the PostgreSQL container after all tests in the class have run.
/// Releases the data source and the test server.
/// </summary>
/// <returns>A <see cref="ValueTask"/> representing the asynchronous operation.</returns>
public async ValueTask DisposeAsync()
{
// InitializeAsync can fail before the data source exists; its error must not be masked by an NRE here.
if (_dataSource is not null)
{
await _dataSource.DisposeAsync().ConfigureAwait(false);
}
await _container.DisposeAsync().ConfigureAwait(false);
if (_server is not null)
{
await _server.DisposeAsync().ConfigureAwait(false);
}
}
/// <summary>
@@ -1,62 +1,68 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using DotNet.Testcontainers.Builders;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.DbConfiguration;
using Jellyfin.Database.Implementations.Locking;
using Jellyfin.Database.Providers.PostgreSQL;
using Jellyfin.Server.Tests.Migrations;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Npgsql;
using Testcontainers.PostgreSql;
using Xunit;
namespace Jellyfin.Database.Tests.PostgreSQL;
/// <summary>
/// Integration tests that validate PostgreSQL migrations against a real container.
/// Integration tests that validate PostgreSQL migrations against a real server.
/// </summary>
[Xunit.Trait("Category", "RequiresDocker")]
public sealed class PostgreSqlMigrationTests : IAsyncLifetime
{
private readonly PostgreSqlContainer _container;
private static int _databaseSequence;
private PostgreSqlTestServer? _server;
private NpgsqlDataSource? _dataSource;
/// <summary>
/// Initializes a new instance of the <see cref="PostgreSqlMigrationTests"/> class.
/// </summary>
public PostgreSqlMigrationTests()
{
_container = new PostgreSqlBuilder("postgres:16-alpine")
.WithWaitStrategy(Wait.ForUnixContainer().UntilCommandIsCompleted("pg_isready"))
.Build();
}
/// <summary>
/// Starts the PostgreSQL container before any tests in the class run.
/// Attaches to the test server and hands this test an empty database of its own.
/// </summary>
/// <returns>A <see cref="ValueTask"/> representing the asynchronous operation.</returns>
public async ValueTask InitializeAsync()
{
await _container.StartAsync().ConfigureAwait(false);
_server = await PostgreSqlTestServer.StartAsync().ConfigureAwait(false);
var databaseName = FormattableString.Invariant($"pg_migration_{Interlocked.Increment(ref _databaseSequence)}");
var connectionString = await _server.CreateDatabaseAsync(databaseName, TestContext.Current.CancellationToken).ConfigureAwait(false);
_dataSource = new NpgsqlDataSourceBuilder(connectionString).Build();
}
/// <summary>
/// Stops and removes the PostgreSQL container after all tests in the class have run.
/// Releases the data source and the test server.
/// </summary>
/// <returns>A <see cref="ValueTask"/> representing the asynchronous operation.</returns>
public async ValueTask DisposeAsync()
{
await _container.DisposeAsync().ConfigureAwait(false);
// InitializeAsync can fail before the data source exists; its error must not be masked by an NRE here.
if (_dataSource is not null)
{
await _dataSource.DisposeAsync().ConfigureAwait(false);
}
if (_server is not null)
{
await _server.DisposeAsync().ConfigureAwait(false);
}
}
/// <summary>
/// Verifies that the <c>InitialPostgreSql</c> migration applies cleanly to a fresh PostgreSQL 16 container.
/// Verifies that the <c>InitialPostgreSql</c> migration applies cleanly to a fresh database.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[Fact]
public async Task MigrateAsync_AppliesInitialMigrationCleanly()
{
await using var dataSource = new NpgsqlDataSourceBuilder(_container.GetConnectionString()).Build();
var context = CreateContext(dataSource);
var context = CreateContext(_dataSource!);
await using (context)
{
await context.Database.MigrateAsync(TestContext.Current.CancellationToken);
@@ -73,11 +79,7 @@ public sealed class PostgreSqlMigrationTests : IAsyncLifetime
[Fact]
public void CheckForUnappliedMigrations_PostgreSql()
{
// Use a dummy connection string; HasPendingModelChanges() is a purely in-memory check
// that compares the current compiled model with the migration snapshots — no real DB needed.
const string dummyConnectionString = "Host=localhost;Database=jellyfin;Username=postgres;Password=postgres";
using var dataSource = new NpgsqlDataSourceBuilder(dummyConnectionString).Build();
using var context = CreateContext(dataSource);
using var context = CreateContext(_dataSource!);
Assert.False(
context.Database.HasPendingModelChanges(),
@@ -2,71 +2,67 @@ using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using DotNet.Testcontainers.Builders;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.DbConfiguration;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.Locking;
using Jellyfin.Database.Providers.PostgreSQL;
using Jellyfin.Server.Tests.Migrations;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Npgsql;
using Testcontainers.PostgreSql;
using Xunit;
namespace Jellyfin.Database.Tests.PostgreSQL;
/// <summary>
/// Integration tests for CRUD operations, optimisation, and purge against a real PostgreSQL 16 container.
/// Integration tests for CRUD operations, optimisation, and purge against a real PostgreSQL server.
/// </summary>
[Xunit.Trait("Category", "RequiresDocker")]
public sealed class PostgreSqlProviderTests : IAsyncLifetime
{
private readonly PostgreSqlContainer _container;
private static int _databaseSequence;
private PostgreSqlTestServer? _server;
private NpgsqlDataSource? _dataSource;
private PostgreSqlDatabaseProvider? _provider;
/// <summary>
/// Initializes a new instance of the <see cref="PostgreSqlProviderTests"/> class.
/// </summary>
public PostgreSqlProviderTests()
{
_container = new PostgreSqlBuilder("postgres:16-alpine")
.WithWaitStrategy(Wait.ForUnixContainer().UntilCommandIsCompleted("pg_isready"))
.Build();
}
/// <summary>
/// Starts the PostgreSQL container and applies migrations before any tests in the class run.
/// Attaches to the test server, hands this test a database of its own and applies migrations to it.
/// </summary>
/// <returns>A <see cref="ValueTask"/> representing the asynchronous operation.</returns>
public async ValueTask InitializeAsync()
{
await _container.StartAsync().ConfigureAwait(false);
_server = await PostgreSqlTestServer.StartAsync().ConfigureAwait(false);
_dataSource = new NpgsqlDataSourceBuilder(_container.GetConnectionString()).Build();
var databaseName = FormattableString.Invariant($"pg_provider_{Interlocked.Increment(ref _databaseSequence)}");
var connectionString = await _server.CreateDatabaseAsync(databaseName, TestContext.Current.CancellationToken).ConfigureAwait(false);
_dataSource = new NpgsqlDataSourceBuilder(connectionString).Build();
_provider = new PostgreSqlDatabaseProvider(_dataSource);
// Apply migrations once for the whole test class.
var context = CreateContext();
await using (context.ConfigureAwait(false))
{
await context.Database.MigrateAsync().ConfigureAwait(false);
await context.Database.MigrateAsync(TestContext.Current.CancellationToken).ConfigureAwait(false);
}
}
/// <summary>
/// Stops and removes the PostgreSQL container after all tests in the class have run.
/// Releases the data source and the test server.
/// </summary>
/// <returns>A <see cref="ValueTask"/> representing the asynchronous operation.</returns>
public async ValueTask DisposeAsync()
{
// InitializeAsync can fail before the data source exists; its error must not be masked by an NRE here.
if (_dataSource is not null)
{
await _dataSource.DisposeAsync().ConfigureAwait(false);
}
await _container.DisposeAsync().ConfigureAwait(false);
if (_server is not null)
{
await _server.DisposeAsync().ConfigureAwait(false);
}
}
/// <summary>
@@ -155,11 +151,15 @@ public sealed class PostgreSqlProviderTests : IAsyncLifetime
var ctx = CreateContext();
await using (ctx)
{
var userId = Guid.NewGuid();
// DisplayPreferences.UserId is a foreign key onto Users, which PostgreSQL enforces and SQLite does not.
var user = new User("prefsuser", "Jellyfin.Server.Implementations.Users.DefaultAuthenticationProvider", "Jellyfin.Server.Implementations.Users.DefaultPasswordResetProvider");
ctx.Users.Add(user);
await ctx.SaveChangesAsync(TestContext.Current.CancellationToken);
var itemId = Guid.NewGuid();
// Create
var prefs = new DisplayPreferences(userId, itemId, "TestClient");
var prefs = new DisplayPreferences(user.Id, itemId, "TestClient");
ctx.DisplayPreferences.Add(prefs);
await ctx.SaveChangesAsync(TestContext.Current.CancellationToken);
@@ -278,7 +278,7 @@ public sealed class PostgreSqlProviderTests : IAsyncLifetime
// session_replication_role should be reset to 'origin' (default)
var role = await ctx.Database
.SqlQueryRaw<string>("SELECT current_setting('session_replication_role')")
.SqlQueryRaw<string>("SELECT current_setting('session_replication_role') AS \"Value\"")
.FirstAsync(TestContext.Current.CancellationToken);
Assert.Equal("origin", role);
}
@@ -0,0 +1,81 @@
using Emby.Server.Implementations;
using Xunit;
namespace Jellyfin.Server.Implementations.Tests.Configuration;
/// <summary>
/// The decision <c>ApplicationHost.OnConfigurationUpdated</c> makes about a port change. The ports this
/// process bound are fixed for its lifetime and the pending-restart flag is per-process, so an instance
/// applying another instance's port change still has to notice its own binding went stale - while leaving
/// the authorization write, and the notice that follows it, to the instance that made the change.
/// </summary>
public static class ApplicationHostPortChangeTests
{
/// <summary>
/// The local case, unchanged: clear the authorization flag and report the pending restart.
/// </summary>
[Fact]
public static void LocalPortChange_ClearsAuthorizationAndRequiresRestart()
{
var outcome = ApplicationHost.EvaluatePortChange(8096, 8920, 9096, 8920, true, false);
Assert.True(outcome.RequiresRestart);
Assert.True(outcome.ClearsPortAuthorization);
}
/// <summary>
/// The cross-instance case: the peer wrote the new port and cleared the flag with it, so this
/// instance must not write, but it is still listening on the old port and has to say so.
/// </summary>
[Theory]
[InlineData(true)]
[InlineData(false)]
public static void RemotePortChange_RequiresRestartWithoutWriting(bool isPortAuthorized)
{
var outcome = ApplicationHost.EvaluatePortChange(8096, 8920, 9096, 8920, isPortAuthorized, true);
Assert.True(outcome.RequiresRestart);
Assert.False(outcome.ClearsPortAuthorization);
}
/// <summary>
/// A second update while a port change is already pending must not write the flag again, and the
/// binding is still stale.
/// </summary>
[Fact]
public static void LocalPortChange_WithAuthorizationAlreadyCleared_RequiresRestartWithoutWriting()
{
var outcome = ApplicationHost.EvaluatePortChange(8096, 8920, 9096, 8920, false, false);
Assert.True(outcome.RequiresRestart);
Assert.False(outcome.ClearsPortAuthorization);
}
/// <summary>
/// An update that leaves the ports alone is not a port change, whoever wrote it.
/// </summary>
[Theory]
[InlineData(false)]
[InlineData(true)]
public static void UnchangedPorts_DoNothing(bool isApplyingRemoteInvalidation)
{
var outcome = ApplicationHost.EvaluatePortChange(8096, 8920, 8096, 8920, true, isApplyingRemoteInvalidation);
Assert.False(outcome.RequiresRestart);
Assert.False(outcome.ClearsPortAuthorization);
}
/// <summary>
/// Nothing is decided before the ports have been bound.
/// </summary>
[Theory]
[InlineData(0, 8920)]
[InlineData(8096, 0)]
public static void UnboundPorts_DoNothing(int boundHttpPort, int boundHttpsPort)
{
var outcome = ApplicationHost.EvaluatePortChange(boundHttpPort, boundHttpsPort, 9096, 9920, true, false);
Assert.False(outcome.RequiresRestart);
Assert.False(outcome.ClearsPortAuthorization);
}
}
@@ -0,0 +1,73 @@
using System;
using System.Collections.Generic;
using System.Linq;
using MediaBrowser.Common.Configuration;
namespace Jellyfin.Server.Implementations.Tests.Configuration;
/// <summary>
/// An in-process stand-in for the Redis pub/sub bus: every endpoint connected to one fabric receives
/// what the others publish, and never its own notices.
/// </summary>
internal sealed class FakeInvalidationBusFabric
{
private readonly List<Endpoint> _endpoints = new();
/// <summary>
/// Connects a new instance to the fabric.
/// </summary>
/// <param name="originId">The identity of the connecting instance.</param>
/// <returns>The bus of that instance.</returns>
public IConfigurationInvalidationBus Connect(string originId)
{
var endpoint = new Endpoint(this, originId);
lock (_endpoints)
{
_endpoints.Add(endpoint);
}
return endpoint;
}
private void Broadcast(ConfigurationInvalidation invalidation)
{
Endpoint[] endpoints;
lock (_endpoints)
{
endpoints = _endpoints.ToArray();
}
foreach (var endpoint in endpoints.Where(e => !string.Equals(e.OriginId, invalidation.OriginId, StringComparison.Ordinal)))
{
endpoint.Deliver(invalidation);
}
}
private sealed class Endpoint : IConfigurationInvalidationBus
{
private readonly FakeInvalidationBusFabric _fabric;
private readonly List<Action<ConfigurationInvalidation>> _handlers = new();
public Endpoint(FakeInvalidationBusFabric fabric, string originId)
{
_fabric = fabric;
OriginId = originId;
}
public string OriginId { get; }
public void Publish(ConfigurationInvalidationScope scope, string? target)
=> _fabric.Broadcast(new ConfigurationInvalidation { Scope = scope, Target = target, OriginId = OriginId });
public void Subscribe(Action<ConfigurationInvalidation> handler)
=> _handlers.Add(handler);
public void Deliver(ConfigurationInvalidation invalidation)
{
foreach (var handler in _handlers)
{
handler(invalidation);
}
}
}
}
@@ -0,0 +1,161 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Emby.Server.Implementations.Configuration;
using Emby.Server.Implementations.Serialization;
using Jellyfin.Data;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.Enums;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Model.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace Jellyfin.Server.Implementations.Tests.Configuration;
/// <summary>
/// Library options are cached in a process-wide dictionary, so the replica that did not serve the admin's
/// request is the one under test here: the other replica's write reaches the shared library directory, and
/// this one has to stop answering out of its own stale copy.
/// </summary>
/// <remarks>
/// Disabling a library is an access revocation that overrides every per-user check, so a stale replica
/// keeps serving content that is supposed to be hidden from everyone.
/// </remarks>
public sealed class LibraryVisibilityPropagationTests : IDisposable
{
private readonly string _libraryPath;
private readonly MyXmlSerializer _serializer = new MyXmlSerializer();
/// <summary>
/// Initializes a new instance of the <see cref="LibraryVisibilityPropagationTests"/> class.
/// </summary>
public LibraryVisibilityPropagationTests()
{
_libraryPath = Path.Combine(Path.GetTempPath(), "jf-library-prop-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_libraryPath);
var applicationHost = new Mock<IServerApplicationHost>();
applicationHost.Setup(host => host.ExpandVirtualPath(It.IsAny<string>())).Returns<string>(path => path);
applicationHost.Setup(host => host.ReverseVirtualPath(It.IsAny<string>())).Returns<string>(path => path);
CollectionFolder.XmlSerializer = _serializer;
CollectionFolder.ApplicationHost = applicationHost.Object;
}
/// <inheritdoc />
public void Dispose()
{
CollectionFolder.InvalidationBus = NullConfigurationInvalidationBus.Instance;
CollectionFolder.InvalidateAllLibraryOptions();
try
{
Directory.Delete(_libraryPath, true);
}
catch (IOException)
{
}
}
/// <summary>
/// Disabling a library on one replica has to hide it on every replica. Until it does, the ones that did
/// not serve the request keep the library visible to every user.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[Fact]
public async Task LibraryDisabledOnAnotherInstance_IsNotVisibleHere()
{
var fabric = new FakeInvalidationBusFabric();
var otherInstance = fabric.Connect("pod-a");
await SubscribeThisInstanceAsync(fabric);
WriteSharedOptions(enabled: true);
var user = CreateUser();
var library = new CollectionFolder { Path = _libraryPath, Name = "Movies" };
// This replica answers out of its cache from here on.
Assert.True(library.IsVisible(user));
// The admin disables the library on the other replica: it writes the shared directory and says so.
WriteSharedOptions(enabled: false);
otherInstance.Publish(ConfigurationInvalidationScope.LibraryOptions, _libraryPath);
Assert.False(library.IsVisible(user));
Assert.False(CollectionFolder.GetLibraryOptions(_libraryPath).Enabled);
}
/// <summary>
/// A path remap made on another replica has to reach this one, or it keeps resolving media against a
/// path that is no longer the library's.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[Fact]
public async Task LibraryPathRemappedOnAnotherInstance_IsSeenHere()
{
var fabric = new FakeInvalidationBusFabric();
var otherInstance = fabric.Connect("pod-a");
await SubscribeThisInstanceAsync(fabric);
WriteSharedOptions(enabled: true, mediaPath: "/media/old");
Assert.Equal("/media/old", CollectionFolder.GetLibraryOptions(_libraryPath).PathInfos[0].Path);
WriteSharedOptions(enabled: true, mediaPath: "/media/new");
otherInstance.Publish(ConfigurationInvalidationScope.LibraryOptions, _libraryPath);
Assert.Equal("/media/new", CollectionFolder.GetLibraryOptions(_libraryPath).PathInfos[0].Path);
}
/// <summary>
/// Saving library options here has to tell the other replicas, which is the half of the exchange the
/// tests above take as given.
/// </summary>
[Fact]
public void SaveLibraryOptions_AnnouncesTheLibraryToTheOtherInstances()
{
var fabric = new FakeInvalidationBusFabric();
ConfigurationInvalidation? received = null;
var otherInstance = fabric.Connect("pod-b");
otherInstance.Subscribe(invalidation => received = invalidation);
CollectionFolder.InvalidationBus = fabric.Connect("pod-a");
CollectionFolder.SaveLibraryOptions(_libraryPath, new LibraryOptions { Enabled = false });
Assert.NotNull(received);
Assert.Equal(ConfigurationInvalidationScope.LibraryOptions, received.Scope);
Assert.Equal(_libraryPath, received.Target);
}
private async Task SubscribeThisInstanceAsync(FakeInvalidationBusFabric fabric)
{
var bus = fabric.Connect("pod-b");
CollectionFolder.InvalidationBus = bus;
var subscriber = new ConfigurationInvalidationSubscriber(
bus,
Mock.Of<IConfigurationManager>(),
NullLogger<ConfigurationInvalidationSubscriber>.Instance);
await subscriber.StartAsync(CancellationToken.None);
}
private void WriteSharedOptions(bool enabled, string mediaPath = "/media")
{
// Written the way the other replica writes it, straight onto the shared directory.
var options = new LibraryOptions { Enabled = enabled, PathInfos = [new MediaPathInfo(mediaPath)] };
_serializer.SerializeToFile(options, Path.Combine(_libraryPath, "options.xml"));
}
private static User CreateUser()
{
var user = new User("propagation", "auth", "reset");
user.SetPermission(PermissionKind.EnableAllFolders, true);
return user;
}
}
@@ -0,0 +1,104 @@
using System;
using System.Threading.Tasks;
using Emby.Server.Implementations.Configuration;
using MediaBrowser.Common.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using StackExchange.Redis;
using Testcontainers.Redis;
using Xunit;
namespace Jellyfin.Server.Implementations.Tests.Configuration;
/// <summary>
/// Round-trips <see cref="RedisConfigurationInvalidationBus"/> through a real Redis, the transport two
/// replicas actually use to tell each other that the shared configuration directory has changed.
/// </summary>
[Trait("Category", "RequiresDocker")]
public sealed class RedisConfigurationInvalidationBusTests : IAsyncLifetime
{
private readonly RedisContainer _container;
private IConnectionMultiplexer? _redis;
/// <summary>
/// Initializes a new instance of the <see cref="RedisConfigurationInvalidationBusTests"/> class.
/// </summary>
public RedisConfigurationInvalidationBusTests()
{
_container = new RedisBuilder("redis:7-alpine").Build();
}
/// <inheritdoc />
public async ValueTask InitializeAsync()
{
await _container.StartAsync();
_redis = await ConnectionMultiplexer.ConnectAsync(_container.GetConnectionString());
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
if (_redis is not null)
{
await _redis.DisposeAsync();
}
await _container.DisposeAsync();
}
/// <summary>
/// A notice published by one replica reaches the other, carrying enough to invalidate one entry.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[Fact]
public async Task Publish_ReachesTheOtherInstance()
{
var received = new TaskCompletionSource<ConfigurationInvalidation>();
var instanceA = CreateBus("pod-a");
var instanceB = CreateBus("pod-b");
instanceB.Subscribe(invalidation => received.TrySetResult(invalidation));
instanceA.Publish(ConfigurationInvalidationScope.LibraryOptions, "/media/movies");
var invalidation = await received.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
Assert.Equal(ConfigurationInvalidationScope.LibraryOptions, invalidation.Scope);
Assert.Equal("/media/movies", invalidation.Target);
Assert.Equal("pod-a", invalidation.OriginId);
}
/// <summary>
/// The publishing replica has already applied the change to its own cache, so it must not act on its
/// own notice and reload what it just wrote.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[Fact]
public async Task Publish_IsNotDeliveredToThePublisher()
{
var ownNotice = new TaskCompletionSource<ConfigurationInvalidation>();
var otherNotice = new TaskCompletionSource<ConfigurationInvalidation>();
var instanceA = CreateBus("pod-a");
var instanceB = CreateBus("pod-b");
instanceA.Subscribe(invalidation => ownNotice.TrySetResult(invalidation));
instanceB.Subscribe(invalidation => otherNotice.TrySetResult(invalidation));
instanceA.Publish(ConfigurationInvalidationScope.SystemConfiguration, null);
// Ordering is per channel, so B having the notice means A would have had it too.
await otherNotice.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
Assert.False(ownNotice.Task.IsCompleted);
}
private RedisConfigurationInvalidationBus CreateBus(string originId)
{
Environment.SetEnvironmentVariable("JELLYFIN_INSTANCE_ID", originId);
try
{
return new RedisConfigurationInvalidationBus(_redis!, NullLogger<RedisConfigurationInvalidationBus>.Instance);
}
finally
{
Environment.SetEnvironmentVariable("JELLYFIN_INSTANCE_ID", null);
}
}
}
@@ -0,0 +1,205 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Emby.Server.Implementations;
using Emby.Server.Implementations.Configuration;
using Emby.Server.Implementations.Serialization;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
namespace Jellyfin.Server.Implementations.Tests.Configuration;
/// <summary>
/// Applying an invalidation re-raises the same update events a local save raises, and some consumers of
/// those events answer an update by writing - <c>RecordingsManager</c> creating the recording folders for
/// the <c>livetv</c> key, <c>ApplicationHost</c> clearing <c>IsPortAuthorized</c> on a port change. The
/// instance that did not write must not repeat those writes, and no write it is induced into must reach
/// the bus.
/// </summary>
public sealed class RemoteInvalidationApplyTests : IDisposable
{
private readonly string _root;
/// <summary>
/// Initializes a new instance of the <see cref="RemoteInvalidationApplyTests"/> class.
/// </summary>
public RemoteInvalidationApplyTests()
{
_root = Path.Combine(Path.GetTempPath(), "jf-config-apply-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_root);
}
/// <inheritdoc />
public void Dispose()
{
try
{
Directory.Delete(_root, true);
}
catch (IOException)
{
}
}
/// <summary>
/// A consumer that knows nothing about the bus - a plugin, or anything reached transitively from one -
/// can answer an applied invalidation by writing. That write must not become a notice of its own.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[Fact]
public async Task NamedInvalidation_InducingAWriteOnTheReceiver_DoesNotPublishBack()
{
var fabric = new FakeInvalidationBusFabric();
var instanceA = CreateInstance(fabric, "pod-a");
var instanceB = await CreateSubscribedInstanceAsync(fabric, "pod-b");
var receivedByA = 0;
instanceA.InvalidationBus.Subscribe(_ => Interlocked.Increment(ref receivedByA));
var writesByB = 0;
instanceB.NamedConfigurationUpdated += (_, e) =>
{
// One shot: the induced write raises the event again on this instance.
if (Interlocked.Increment(ref writesByB) > 1)
{
return;
}
var configuration = (NetworkConfiguration)instanceB.GetConfiguration(e.Key);
configuration.PublishedServerUriBySubnet = ["10.0.0.0/8=example"];
instanceB.SaveConfiguration(e.Key, configuration);
};
var updated = instanceA.GetNetworkConfiguration();
updated.EnableRemoteAccess = false;
instanceA.SaveConfiguration(NetworkConfigurationStore.StoreKey, updated);
Assert.Equal(0, receivedByA);
// The point of the bus still holds: B is not left on its stale copy.
Assert.False(instanceB.GetNetworkConfiguration().EnableRemoteAccess);
}
/// <summary>
/// The shape of <c>RecordingsManager</c>: a consumer that answers a named configuration update by
/// writing has to be able to tell that the write was another instance's, and skip it.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[Fact]
public async Task NamedInvalidation_WithAWriteTriggeringConsumer_DoesNotDuplicateTheWrite()
{
var fabric = new FakeInvalidationBusFabric();
var instanceA = CreateInstance(fabric, "pod-a");
var instanceB = await CreateSubscribedInstanceAsync(fabric, "pod-b");
var receivedByA = 0;
instanceA.InvalidationBus.Subscribe(_ => Interlocked.Increment(ref receivedByA));
var writesByB = 0;
instanceB.NamedConfigurationUpdated += (_, e) =>
{
if (ConfigurationInvalidationContext.IsApplyingRemoteInvalidation)
{
return;
}
Interlocked.Increment(ref writesByB);
};
var updated = instanceA.GetNetworkConfiguration();
updated.EnableRemoteAccess = false;
instanceA.SaveConfiguration(NetworkConfigurationStore.StoreKey, updated);
Assert.Equal(0, writesByB);
Assert.Equal(0, receivedByA);
Assert.False(instanceB.GetNetworkConfiguration().EnableRemoteAccess);
// A read-only consumer is still told, which is what the invalidation exists for.
var refreshes = 0;
instanceB.NamedConfigurationUpdated += (_, _) => Interlocked.Increment(ref refreshes);
updated.EnableRemoteAccess = true;
instanceA.SaveConfiguration(NetworkConfigurationStore.StoreKey, updated);
Assert.Equal(1, refreshes);
}
/// <summary>
/// The <c>ApplicationHost.IsPortAuthorized</c> class of consumer: the system configuration event is
/// queued rather than raised inline, so the fix has to survive the hop onto the thread pool.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[Fact]
public async Task SystemInvalidation_InducingAQueuedWriteOnTheReceiver_DoesNotPublishBack()
{
var fabric = new FakeInvalidationBusFabric();
var instanceA = CreateInstance(fabric, "pod-a");
var instanceB = await CreateSubscribedInstanceAsync(fabric, "pod-b");
var receivedByA = 0;
instanceA.InvalidationBus.Subscribe(_ => Interlocked.Increment(ref receivedByA));
var applied = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var handled = 0;
instanceB.ConfigurationUpdated += (_, _) =>
{
if (Interlocked.Increment(ref handled) > 1)
{
return;
}
instanceB.Configuration.IsPortAuthorized = false;
instanceB.SaveConfiguration();
applied.TrySetResult();
};
instanceA.Configuration.QuickConnectAvailable = false;
instanceA.SaveConfiguration();
await applied.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
Assert.Equal(0, receivedByA);
Assert.False(instanceB.Configuration.QuickConnectAvailable);
}
private async Task<ServerConfigurationManager> CreateSubscribedInstanceAsync(FakeInvalidationBusFabric fabric, string originId)
{
var instance = CreateInstance(fabric, originId);
var subscriber = new ConfigurationInvalidationSubscriber(
instance.InvalidationBus,
instance,
NullLogger<ConfigurationInvalidationSubscriber>.Instance);
await subscriber.StartAsync(CancellationToken.None);
return instance;
}
private ServerConfigurationManager CreateInstance(FakeInvalidationBusFabric fabric, string originId)
{
var paths = new ServerApplicationPaths(
Ensure("data"),
Ensure("log"),
Ensure("config"),
Ensure("cache"),
Ensure("web"));
var manager = new ServerConfigurationManager(paths, NullLoggerFactory.Instance, new MyXmlSerializer())
{
InvalidationBus = fabric.Connect(originId)
};
manager.AddParts([new NetworkConfigurationFactory()]);
return manager;
}
private string Ensure(string name)
{
var path = Path.Combine(_root, name);
Directory.CreateDirectory(path);
return path;
}
}
@@ -0,0 +1,147 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Emby.Server.Implementations;
using Emby.Server.Implementations.Configuration;
using Emby.Server.Implementations.Serialization;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using StackExchange.Redis;
using Xunit;
namespace Jellyfin.Server.Implementations.Tests.Configuration;
/// <summary>
/// Two independently constructed <see cref="ServerConfigurationManager"/> instances over one configuration
/// directory are the in-process stand-in for two replicas sharing one <c>/config</c> mount: what either of
/// them writes, the other has to pick up without being restarted.
/// </summary>
public sealed class SharedConfigurationPropagationTests : IDisposable
{
private readonly string _root;
/// <summary>
/// Initializes a new instance of the <see cref="SharedConfigurationPropagationTests"/> class.
/// </summary>
public SharedConfigurationPropagationTests()
{
_root = Path.Combine(Path.GetTempPath(), "jf-config-prop-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_root);
}
/// <inheritdoc />
public void Dispose()
{
try
{
Directory.Delete(_root, true);
}
catch (IOException)
{
}
}
/// <summary>
/// A system configuration setting tightened on one replica has to hold on every other replica, not
/// only on the one that served the admin's request.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[Fact]
public async Task SystemConfigurationSavedOnOneInstance_IsSeenByAnotherWithoutRestart()
{
var fabric = new FakeInvalidationBusFabric();
var instanceA = CreateInstance(fabric, "pod-a");
var instanceB = await CreateSubscribedInstanceAsync(fabric, "pod-b");
// B has the pre-change configuration in hand before A writes, as a running replica would.
Assert.True(instanceB.Configuration.QuickConnectAvailable);
instanceA.Configuration.QuickConnectAvailable = false;
instanceA.SaveConfiguration();
Assert.False(instanceB.Configuration.QuickConnectAvailable);
}
/// <summary>
/// The same has to hold for the named configurations, which are cached per key and never reloaded.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[Fact]
public async Task NamedConfigurationSavedOnOneInstance_IsSeenByAnotherWithoutRestart()
{
var fabric = new FakeInvalidationBusFabric();
var instanceA = CreateInstance(fabric, "pod-a");
var instanceB = await CreateSubscribedInstanceAsync(fabric, "pod-b");
Assert.True(instanceB.GetNetworkConfiguration().EnableRemoteAccess);
var updated = instanceA.GetNetworkConfiguration();
updated.EnableRemoteAccess = false;
instanceA.SaveConfiguration(NetworkConfigurationStore.StoreKey, updated);
Assert.False(instanceB.GetNetworkConfiguration().EnableRemoteAccess);
}
/// <summary>
/// A replica that cannot reach the bus keeps serving: the admin's save still lands on the shared
/// directory, and the only loss is that the other replicas are not told about it.
/// </summary>
[Fact]
public void SaveConfiguration_WithUnreachableBus_DoesNotThrow()
{
using var multiplexer = ConnectionMultiplexer.Connect("127.0.0.1:1,abortConnect=false,connectTimeout=200,connectRetry=1,syncTimeout=200");
var bus = new RedisConfigurationInvalidationBus(multiplexer, NullLogger<RedisConfigurationInvalidationBus>.Instance);
bus.Subscribe(_ => throw new InvalidOperationException("Nothing can be delivered by an unreachable bus."));
var instance = CreateInstance(new FakeInvalidationBusFabric(), "pod-a");
instance.InvalidationBus = bus;
instance.Configuration.QuickConnectAvailable = false;
instance.SaveConfiguration();
instance.SaveConfiguration(NetworkConfigurationStore.StoreKey, instance.GetNetworkConfiguration());
Assert.False(instance.Configuration.QuickConnectAvailable);
}
private async Task<ServerConfigurationManager> CreateSubscribedInstanceAsync(FakeInvalidationBusFabric fabric, string originId)
{
var instance = CreateInstance(fabric, originId);
var subscriber = new ConfigurationInvalidationSubscriber(
instance.InvalidationBus,
instance,
NullLogger<ConfigurationInvalidationSubscriber>.Instance);
await subscriber.StartAsync(CancellationToken.None);
return instance;
}
private ServerConfigurationManager CreateInstance(FakeInvalidationBusFabric fabric, string originId)
{
// Every instance has its own paths object, all of them pointing at the one shared directory.
var paths = new ServerApplicationPaths(
Ensure("data"),
Ensure("log"),
Ensure("config"),
Ensure("cache"),
Ensure("web"));
var manager = new ServerConfigurationManager(paths, NullLoggerFactory.Instance, new MyXmlSerializer())
{
InvalidationBus = fabric.Connect(originId)
};
manager.AddParts([new NetworkConfigurationFactory()]);
return manager;
}
private string Ensure(string name)
{
var path = Path.Combine(_root, name);
Directory.CreateDirectory(path);
return path;
}
}
@@ -31,6 +31,8 @@
<ItemGroup>
<ProjectReference Include="..\..\Emby.Server.Implementations\Emby.Server.Implementations.csproj" />
<ProjectReference Include="..\..\Jellyfin.Server.Implementations\Jellyfin.Server.Implementations.csproj" />
<ProjectReference Include="..\..\MediaBrowser.Providers\MediaBrowser.Providers.csproj" />
<ProjectReference Include="..\..\src\Jellyfin.LiveTv\Jellyfin.LiveTv.csproj" />
<ProjectReference Include="..\Jellyfin.Server.Integration.Tests\Jellyfin.Server.Integration.Tests.csproj" />
<ProjectReference Include="..\..\src\Jellyfin.Database\Jellyfin.Database.Implementations\Jellyfin.Database.Implementations.csproj" />
</ItemGroup>
@@ -8,6 +8,7 @@ using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller.Authentication;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Net;
using MediaBrowser.Controller.QuickConnect;
using MediaBrowser.Model.Configuration;
using Moq;
using Xunit;
@@ -40,6 +41,8 @@ namespace Jellyfin.Server.Implementations.Tests.QuickConnect
ConfigureMembers = true
}).Inject(configManager.Object);
_fixture.Inject<IQuickConnectStore>(new InMemoryQuickConnectStore());
// User object contains circular references.
_fixture.Behaviors.OfType<ThrowingRecursionBehavior>().ToList()
.ForEach(b => _fixture.Behaviors.Remove(b));
@@ -60,8 +63,8 @@ namespace Jellyfin.Server.Implementations.Tests.QuickConnect
[InlineData("Device", "", "Client", "1.0.0")]
[InlineData("Device", "DeviceId", "", "1.0.0")]
[InlineData("Device", "DeviceId", "Client", "")]
public void TryConnect_InvalidAuthorizationInfo_ThrowsArgumentException(string device, string deviceId, string client, string version)
=> Assert.Throws<ArgumentException>(() => _quickConnectManager.TryConnect(
public async Task TryConnect_InvalidAuthorizationInfo_ThrowsArgumentException(string device, string deviceId, string client, string version)
=> await Assert.ThrowsAsync<ArgumentException>(() => _quickConnectManager.TryConnect(
new AuthorizationInfo
{
Device = device,
@@ -71,17 +74,17 @@ namespace Jellyfin.Server.Implementations.Tests.QuickConnect
}));
[Fact]
public void TryConnect_QuickConnectUnavailable_ThrowsAuthenticationException()
public async Task TryConnect_QuickConnectUnavailable_ThrowsAuthenticationException()
{
_config.QuickConnectAvailable = false;
Assert.Throws<AuthenticationException>(() => _quickConnectManager.TryConnect(_quickConnectAuthInfo));
await Assert.ThrowsAsync<AuthenticationException>(() => _quickConnectManager.TryConnect(_quickConnectAuthInfo));
}
[Fact]
public void CheckRequestStatus_QuickConnectUnavailable_ThrowsAuthenticationException()
public async Task CheckRequestStatus_QuickConnectUnavailable_ThrowsAuthenticationException()
{
_config.QuickConnectAvailable = false;
Assert.Throws<AuthenticationException>(() => _quickConnectManager.CheckRequestStatus(string.Empty));
await Assert.ThrowsAsync<AuthenticationException>(() => _quickConnectManager.CheckRequestStatus(string.Empty));
}
[Fact]
@@ -92,10 +95,10 @@ namespace Jellyfin.Server.Implementations.Tests.QuickConnect
}
[Fact]
public void GetAuthorizedRequest_QuickConnectUnavailable_ThrowsAuthenticationException()
public async Task GetAuthorizedRequest_QuickConnectUnavailable_ThrowsAuthenticationException()
{
_config.QuickConnectAvailable = false;
Assert.Throws<AuthenticationException>(() => _quickConnectManager.GetAuthorizedRequest(string.Empty));
await Assert.ThrowsAsync<AuthenticationException>(() => _quickConnectManager.GetAuthorizedRequest(string.Empty));
}
[Fact]
@@ -106,34 +109,83 @@ namespace Jellyfin.Server.Implementations.Tests.QuickConnect
}
[Fact]
public void CheckRequestStatus_QuickConnectAvailable_Success()
public async Task CheckRequestStatus_QuickConnectAvailable_Success()
{
_config.QuickConnectAvailable = true;
var res1 = _quickConnectManager.TryConnect(_quickConnectAuthInfo);
var res2 = _quickConnectManager.CheckRequestStatus(res1.Secret);
Assert.Equal(res1, res2);
var res1 = await _quickConnectManager.TryConnect(_quickConnectAuthInfo);
var res2 = await _quickConnectManager.CheckRequestStatus(res1.Secret);
Assert.Equal(res1.Secret, res2.Secret);
Assert.Equal(res1.Code, res2.Code);
}
[Fact]
public void CheckRequestStatus_UnknownSecret_ThrowsResourceNotFoundException()
public async Task CheckRequestStatus_UnknownSecret_ThrowsResourceNotFoundException()
{
_config.QuickConnectAvailable = true;
Assert.Throws<ResourceNotFoundException>(() => _quickConnectManager.CheckRequestStatus("Unknown secret"));
await Assert.ThrowsAsync<ResourceNotFoundException>(() => _quickConnectManager.CheckRequestStatus("Unknown secret"));
}
[Fact]
public void GetAuthorizedRequest_UnknownSecret_ThrowsResourceNotFoundException()
public async Task GetAuthorizedRequest_UnknownSecret_ThrowsResourceNotFoundException()
{
_config.QuickConnectAvailable = true;
Assert.Throws<ResourceNotFoundException>(() => _quickConnectManager.GetAuthorizedRequest("Unknown secret"));
await Assert.ThrowsAsync<ResourceNotFoundException>(() => _quickConnectManager.GetAuthorizedRequest("Unknown secret"));
}
[Fact]
public async Task AuthorizeRequest_QuickConnectAvailable_Success()
{
_config.QuickConnectAvailable = true;
var res = _quickConnectManager.TryConnect(_quickConnectAuthInfo);
var res = await _quickConnectManager.TryConnect(_quickConnectAuthInfo);
Assert.True(await _quickConnectManager.AuthorizeRequest(Guid.Empty, res.Code));
}
[Fact]
public async Task AuthorizeRequest_RacedOnOneCode_SucceedsOnce()
{
_config.QuickConnectAvailable = true;
var res = await _quickConnectManager.TryConnect(_quickConnectAuthInfo);
var outcomes = await Task.WhenAll(
Task.Run(() => AuthorizeAsync(res.Code)),
Task.Run(() => AuthorizeAsync(res.Code)));
Assert.Single(outcomes, authorized => authorized);
}
[Fact]
public async Task GetAuthorizedRequest_SecondExchange_ReturnsTheSameResult()
{
_config.QuickConnectAvailable = true;
var res = await _quickConnectManager.TryConnect(_quickConnectAuthInfo);
await _quickConnectManager.AuthorizeRequest(Guid.Empty, res.Code);
var first = await _quickConnectManager.GetAuthorizedRequest(res.Secret);
var second = await _quickConnectManager.GetAuthorizedRequest(res.Secret);
Assert.Same(first, second);
}
[Fact]
public async Task AuthorizeRequest_OfAnAuthorizedRequest_ThrowsConflictException()
{
_config.QuickConnectAvailable = true;
var res = await _quickConnectManager.TryConnect(_quickConnectAuthInfo);
await _quickConnectManager.AuthorizeRequest(Guid.Empty, res.Code);
await Assert.ThrowsAsync<ConflictException>(() => _quickConnectManager.AuthorizeRequest(Guid.Empty, res.Code));
}
private async Task<bool> AuthorizeAsync(string code)
{
try
{
return await _quickConnectManager.AuthorizeRequest(Guid.Empty, code).ConfigureAwait(false);
}
catch (ConflictException)
{
return false;
}
}
}
}
@@ -1,6 +1,8 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Runtime.CompilerServices;
using Emby.Server.Implementations.ScheduledTasks.Tasks;
using MediaBrowser.Controller.ScheduledTasks;
@@ -11,6 +13,14 @@ namespace Jellyfin.Server.Implementations.Tests.ScheduledTasks;
public class ScanLeaderOptionsTests
{
private static readonly Assembly[] _taskAssemblies =
{
typeof(DeleteTranscodeFileTask).Assembly,
typeof(MediaBrowser.Providers.Lyric.LyricScheduledTask).Assembly,
typeof(Jellyfin.LiveTv.Guide.RefreshGuideScheduledTask).Assembly,
typeof(Jellyfin.MediaEncoding.Hls.ScheduledTasks.KeyframeExtractionScheduledTask).Assembly
};
/// <summary>
/// A gated key that matches no registered task silently stops gating anything, so the default
/// set is pinned to the task keys that actually exist in the build.
@@ -18,28 +28,92 @@ public class ScanLeaderOptionsTests
[Fact]
public void DefaultGatedTaskKeys_Should_MatchRegisteredScheduledTasks()
{
var registeredKeys = DiscoverScheduledTaskKeys();
var registeredKeys = DiscoverScheduledTaskKeys(_taskAssemblies);
Assert.NotEmpty(registeredKeys);
Assert.Empty(new ScanLeaderOptions().GatedTaskKeys.Except(registeredKeys, StringComparer.Ordinal));
var unmatched = new ScanLeaderOptions().GatedTaskKeys.Except(registeredKeys, StringComparer.Ordinal).ToList();
Assert.True(
unmatched.Count == 0,
$"Gated keys match no scheduled task: {string.Join(", ", unmatched)}. Known keys: {string.Join(", ", registeredKeys.Order(StringComparer.Ordinal))}");
}
private static HashSet<string> DiscoverScheduledTaskKeys()
/// <summary>
/// A key dropped from the default set silently un-gates that task on every replica, so the whole
/// set is pinned against a hand-maintained expectation rather than read back from the options.
/// </summary>
[Fact]
public void DefaultGatedTaskKeys_Should_BeTheExpectedSet()
{
var keys = new HashSet<string>(StringComparer.Ordinal);
var assemblies = new[]
string[] expected =
{
typeof(DeleteTranscodeFileTask).Assembly,
typeof(Jellyfin.MediaEncoding.Hls.ScheduledTasks.KeyframeExtractionScheduledTask).Assembly
"AudioNormalization",
"CleanupUserDataTask",
"DownloadLyrics",
"DownloadSubtitles",
"KeyframeExtraction",
"MoveTrickplayImages",
"OptimizeDatabaseTask",
"PluginUpdates",
"RefreshChapterImages",
"RefreshGuide",
"RefreshInternetChannels",
"RefreshLibrary",
"RefreshPeople",
"RefreshTrickplayImages",
"TaskExtractMediaSegments",
"TmdbRefreshUpcomingEpisodes"
};
foreach (var type in assemblies.SelectMany(a => a.GetTypes()))
var actual = new ScanLeaderOptions().GatedTaskKeys;
var missing = expected.Except(actual, StringComparer.Ordinal).ToList();
var unexpected = actual.Except(expected, StringComparer.Ordinal).ToList();
Assert.True(
missing.Count == 0 && unexpected.Count == 0,
$"Default gated task keys drifted. Missing: {Describe(missing)}. Unexpected: {Describe(unexpected)}.");
}
/// <summary>
/// The key universe is only as complete as the assemblies it is read from, so a task added to an
/// unscanned assembly must fail here rather than narrow what the previous test can catch.
/// </summary>
[Fact]
public void TaskAssemblies_Should_CoverEveryAssemblyDeclaringScheduledTasks()
{
var scanned = _taskAssemblies.Select(a => a.GetName().Name).ToHashSet(StringComparer.Ordinal);
var missing = new List<string>();
foreach (var path in Directory.EnumerateFiles(AppContext.BaseDirectory, "*.dll"))
{
if (type.IsAbstract || type.IsInterface || !typeof(IScheduledTask).IsAssignableFrom(type))
var name = Path.GetFileNameWithoutExtension(path);
if (scanned.Contains(name)
|| name.EndsWith(".Tests", StringComparison.Ordinal)
|| !(name.StartsWith("Jellyfin.", StringComparison.Ordinal)
|| name.StartsWith("Emby.", StringComparison.Ordinal)
|| name.StartsWith("MediaBrowser.", StringComparison.Ordinal)))
{
continue;
}
if (GetScheduledTaskTypes(Assembly.LoadFrom(path)).Any())
{
missing.Add(name);
}
}
Assert.True(missing.Count == 0, $"Assemblies declaring scheduled tasks but not scanned: {string.Join(", ", missing)}");
}
private static string Describe(IReadOnlyCollection<string> keys)
=> keys.Count == 0 ? "none" : string.Join(", ", keys.Order(StringComparer.Ordinal));
private static HashSet<string> DiscoverScheduledTaskKeys(IEnumerable<Assembly> assemblies)
{
var keys = new HashSet<string>(StringComparer.Ordinal);
foreach (var type in assemblies.SelectMany(GetScheduledTaskTypes))
{
// Task keys are constant expressions, so an uninitialised instance is enough to read
// them without standing up each task's dependency graph.
var task = (IScheduledTask)RuntimeHelpers.GetUninitializedObject(type);
@@ -48,4 +122,21 @@ public class ScanLeaderOptionsTests
return keys;
}
private static IEnumerable<Type> GetScheduledTaskTypes(Assembly assembly)
{
Type?[] types;
try
{
types = assembly.GetTypes();
}
catch (ReflectionTypeLoadException ex)
{
types = ex.Types;
}
return types
.Where(t => t is not null && !t.IsAbstract && !t.IsInterface && typeof(IScheduledTask).IsAssignableFrom(t))
.Select(t => t!);
}
}
@@ -13,6 +13,7 @@ using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Session;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
using Xunit;
@@ -44,7 +45,10 @@ public class IdlePlaybackTests
Mock.Of<IServerApplicationHost>(),
Mock.Of<IDeviceManager>(),
Mock.Of<IMediaSourceManager>(),
Mock.Of<IHostApplicationLifetime>());
Mock.Of<IHostApplicationLifetime>(),
NullSessionDirectory.Instance,
NullPodMessageBus.Instance,
Options.Create(new SessionDirectoryOptions()));
var session = await sessionManager.LogSessionActivity(
"Test Client",
"1.0.0",
@@ -16,6 +16,7 @@ using MediaBrowser.Controller.Session;
using MediaBrowser.Model.Session;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
using Xunit;
@@ -41,7 +42,10 @@ public class SessionManagerTests
Mock.Of<IServerApplicationHost>(),
Mock.Of<IDeviceManager>(),
Mock.Of<IMediaSourceManager>(),
Mock.Of<IHostApplicationLifetime>());
Mock.Of<IHostApplicationLifetime>(),
NullSessionDirectory.Instance,
NullPodMessageBus.Instance,
Options.Create(new SessionDirectoryOptions()));
await Assert.ThrowsAsync(exceptionType, () => sessionManager.GetAuthorizationToken(
new User("test", "default", "default"),
@@ -68,7 +72,10 @@ public class SessionManagerTests
Mock.Of<IServerApplicationHost>(),
Mock.Of<IDeviceManager>(),
Mock.Of<IMediaSourceManager>(),
Mock.Of<IHostApplicationLifetime>());
Mock.Of<IHostApplicationLifetime>(),
NullSessionDirectory.Instance,
NullPodMessageBus.Instance,
Options.Create(new SessionDirectoryOptions()));
await Assert.ThrowsAsync(exceptionType, () => sessionManager.AuthenticateNewSessionInternal(authenticationRequest, false));
}
@@ -122,6 +129,7 @@ public class SessionManagerTests
await using var sessionManager = CreateSessionManager(victim, attacker);
var victimSession = await LogSessionActivity(sessionManager, victim);
victimSession.AddController(new StubSessionController());
var attackerSession = await LogSessionActivity(sessionManager, attacker);
await Assert.ThrowsAsync<SecurityException>(() => sessionManager.SendMessageCommand(
@@ -140,6 +148,7 @@ public class SessionManagerTests
await using var sessionManager = CreateSessionManager(victim, attacker);
var victimSession = await LogSessionActivity(sessionManager, victim);
victimSession.AddController(new StubSessionController());
var controllingSession = await LogSessionActivity(sessionManager, attacker);
await sessionManager.SendMessageCommand(
@@ -173,7 +182,7 @@ public class SessionManagerTests
var attackerSession = await LogSessionActivity(sessionManager, attacker);
Assert.Throws<SecurityException>(() => sessionManager.AddAdditionalUser(attackerSession.Id, attackerSession.Id, victim.Id));
await Assert.ThrowsAsync<SecurityException>(() => sessionManager.AddAdditionalUser(attackerSession.Id, attackerSession.Id, victim.Id));
}
[Fact]
@@ -186,7 +195,7 @@ public class SessionManagerTests
var adminSession = await LogSessionActivity(sessionManager, admin);
sessionManager.AddAdditionalUser(adminSession.Id, adminSession.Id, guest.Id);
await sessionManager.AddAdditionalUser(adminSession.Id, adminSession.Id, guest.Id);
Assert.Contains(adminSession.AdditionalUsers, i => i.UserId.Equals(guest.Id));
}
@@ -201,7 +210,7 @@ public class SessionManagerTests
var victimSession = await LogSessionActivity(sessionManager, victim);
var attackerSession = await LogSessionActivity(sessionManager, attacker);
Assert.Throws<SecurityException>(() => sessionManager.RemoveAdditionalUser(attackerSession.Id, victimSession.Id, attacker.Id));
await Assert.ThrowsAsync<SecurityException>(() => sessionManager.RemoveAdditionalUser(attackerSession.Id, victimSession.Id, attacker.Id));
}
[Fact]
@@ -214,7 +223,7 @@ public class SessionManagerTests
var victimSession = await LogSessionActivity(sessionManager, victim);
var attackerSession = await LogSessionActivity(sessionManager, attacker);
Assert.Throws<SecurityException>(() => sessionManager.ReportCapabilities(attackerSession.Id, victimSession.Id, new ClientCapabilities()));
await Assert.ThrowsAsync<SecurityException>(() => sessionManager.ReportCapabilities(attackerSession.Id, victimSession.Id, new ClientCapabilities()));
}
private static Emby.Server.Implementations.Session.SessionManager CreateSessionManager(params User[] users)
@@ -238,11 +247,25 @@ public class SessionManagerTests
Mock.Of<IServerApplicationHost>(),
Mock.Of<IDeviceManager>(),
Mock.Of<IMediaSourceManager>(),
Mock.Of<IHostApplicationLifetime>());
Mock.Of<IHostApplicationLifetime>(),
NullSessionDirectory.Instance,
NullPodMessageBus.Instance,
Options.Create(new SessionDirectoryOptions()));
}
// All sessions are logged with the same client and device id on purpose, those values are taken
// from the request headers and are not bound to the access token of the calling user.
private static Task<SessionInfo> LogSessionActivity(ISessionManager sessionManager, User user)
=> sessionManager.LogSessionActivity("Jellyfin Web", "1.0.0", "victim-tv-01", "device_name", "127.0.0.1", user);
// A session a command can be delivered to is one with a live connection.
private sealed class StubSessionController : ISessionController
{
public bool IsSessionActive => true;
public bool SupportsMediaControl => true;
public Task SendMessage<T>(SessionMessageType name, Guid messageId, T data, CancellationToken cancellationToken)
=> Task.CompletedTask;
}
}
@@ -13,6 +13,7 @@ namespace Jellyfin.Server.Tests.HighAvailability;
/// startup configuration the host reads them from must therefore accept that form; when it does not,
/// a correctly set variable is dropped and the feature it configures stays off without any error.
/// </summary>
[Collection("JellyfinSectionConfiguration")]
public sealed class JellyfinSectionConfigurationTests : IDisposable
{
private const string RedisKey = "Jellyfin:TranscodeStore:RedisConnectionString";
@@ -0,0 +1,223 @@
using System;
using System.Collections.Concurrent;
using System.Globalization;
using System.IO;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using StackExchange.Redis;
namespace Jellyfin.Server.Tests.HighAvailability;
/// <summary>
/// A loopback TCP proxy in front of a Redis server. Cutting it drops every connection through it and
/// refuses new ones, so a test can take Redis away from one instance mid-flow - and give it back - the
/// way a restarted valkey does, and watch what a real StackExchange.Redis client makes of it.
/// </summary>
public sealed class RedisFaultProxy : IAsyncDisposable
{
private readonly ConcurrentDictionary<TcpClient, byte> _live = new();
private readonly CancellationTokenSource _cts = new();
private readonly TcpListener _listener;
private readonly string _targetHost;
private readonly int _targetPort;
private readonly int _port;
private volatile bool _cut;
private volatile byte[]? _cutAfterMarker;
private RedisFaultProxy(TcpListener listener, int port, string targetHost, int targetPort)
{
_listener = listener;
_port = port;
_targetHost = targetHost;
_targetPort = targetPort;
}
/// <summary>
/// Gets a connection string pointing at the proxy. The timeouts are short so a cut surfaces as a
/// failure in seconds rather than in the library's minute-scale defaults.
/// </summary>
public string ConnectionString => string.Create(
CultureInfo.InvariantCulture,
$"127.0.0.1:{_port},abortConnect=false,connectTimeout=500,syncTimeout=2000,connectRetry=1");
/// <summary>
/// Starts a proxy in front of the server named by <paramref name="target"/>.
/// </summary>
/// <param name="target">The connection string of the server to forward to.</param>
/// <returns>The running proxy.</returns>
public static RedisFaultProxy Start(string target)
{
var endpoint = ConfigurationOptions.Parse(target).EndPoints[0];
var (host, port) = endpoint switch
{
DnsEndPoint dns => (dns.Host, dns.Port),
IPEndPoint ip => (ip.Address.ToString(), ip.Port),
_ => throw new NotSupportedException("Unsupported endpoint " + endpoint)
};
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var proxy = new RedisFaultProxy(listener, ((IPEndPoint)listener.LocalEndpoint).Port, host, port);
_ = Task.Run(proxy.AcceptAsync);
return proxy;
}
/// <summary>
/// Takes Redis away from everything connected through the proxy.
/// </summary>
public void Cut()
{
_cut = true;
DropLiveConnections();
}
/// <summary>
/// Arms a cut for the moment after a command containing <paramref name="marker"/> has been forwarded
/// and answered, so a test can take Redis away between two round trips of one operation rather than
/// only before or after all of them.
/// </summary>
/// <param name="marker">Text that identifies the command to cut after.</param>
public void CutAfterForwarding(string marker) => _cutAfterMarker = Encoding.UTF8.GetBytes(marker);
/// <summary>
/// Lets connections through again. Clients reconnect on their own schedule, so callers have to wait
/// for the connection to come back rather than assume it already has.
/// </summary>
public void Restore()
{
_cutAfterMarker = null;
_cut = false;
}
/// <inheritdoc/>
public async ValueTask DisposeAsync()
{
_cut = true;
await _cts.CancelAsync().ConfigureAwait(false);
_listener.Stop();
DropLiveConnections();
_cts.Dispose();
}
private void DropLiveConnections()
{
foreach (var client in _live.Keys)
{
if (_live.TryRemove(client, out _))
{
client.Dispose();
}
}
}
private async Task AcceptAsync()
{
while (!_cts.IsCancellationRequested)
{
TcpClient client;
try
{
client = await _listener.AcceptTcpClientAsync(_cts.Token).ConfigureAwait(false);
}
catch (Exception exception) when (exception is OperationCanceledException or SocketException or ObjectDisposedException)
{
return;
}
if (_cut)
{
client.Dispose();
continue;
}
_ = Task.Run(() => ForwardAsync(client));
}
}
private async Task ForwardAsync(TcpClient client)
{
TcpClient? upstream = null;
try
{
upstream = new TcpClient();
await upstream.ConnectAsync(_targetHost, _targetPort, _cts.Token).ConfigureAwait(false);
_live[client] = 0;
_live[upstream] = 0;
// Registered first, then rechecked: a cut concurrent with this connect would otherwise drop
// the live connections before this pair joined them and leave it running through the outage.
if (_cut)
{
return;
}
var clientStream = client.GetStream();
var upstreamStream = upstream.GetStream();
await Task.WhenAny(
CopyFromClientAsync(clientStream, upstreamStream),
CopyAsync(upstreamStream, clientStream)).ConfigureAwait(false);
}
catch (Exception exception) when (exception is IOException or SocketException or OperationCanceledException or ObjectDisposedException)
{
}
finally
{
_live.TryRemove(client, out _);
client.Dispose();
if (upstream is not null)
{
_live.TryRemove(upstream, out _);
upstream.Dispose();
}
}
}
private async Task CopyFromClientAsync(NetworkStream from, NetworkStream to)
{
var buffer = new byte[16 * 1024];
try
{
while (true)
{
var read = await from.ReadAsync(buffer, _cts.Token).ConfigureAwait(false);
if (read == 0)
{
return;
}
await to.WriteAsync(buffer.AsMemory(0, read), _cts.Token).ConfigureAwait(false);
var marker = _cutAfterMarker;
if (marker is not null && buffer.AsSpan(0, read).IndexOf(marker) >= 0)
{
_cutAfterMarker = null;
// Long enough for the server to have applied the command that was just forwarded.
await Task.Delay(TimeSpan.FromMilliseconds(250), _cts.Token).ConfigureAwait(false);
Cut();
return;
}
}
}
catch (Exception exception) when (exception is IOException or SocketException or OperationCanceledException or ObjectDisposedException)
{
}
}
private async Task CopyAsync(NetworkStream from, NetworkStream to)
{
try
{
await from.CopyToAsync(to, _cts.Token).ConfigureAwait(false);
}
catch (Exception exception) when (exception is IOException or SocketException or OperationCanceledException or ObjectDisposedException)
{
}
}
}
@@ -0,0 +1,91 @@
using System;
using System.Threading.Tasks;
using StackExchange.Redis;
using Testcontainers.Redis;
namespace Jellyfin.Server.Tests.HighAvailability;
/// <summary>
/// Hands out a valkey/Redis server for the tests that need one. A server named by
/// <c>JELLYFIN_TEST_REDIS</c> is used as is, so CI can run one in the step instead of a docker daemon
/// of its own; without it a container is started through testcontainers.
/// </summary>
public sealed class RedisTestServer : IAsyncDisposable
{
/// <summary>
/// The connection string of an already running server.
/// </summary>
public const string ConnectionStringVariable = "JELLYFIN_TEST_REDIS";
private readonly RedisContainer? _container;
private RedisTestServer(RedisContainer? container, string connectionString)
{
_container = container;
ConnectionString = connectionString;
}
/// <summary>
/// Gets the connection string of the running server.
/// </summary>
public string ConnectionString { get; }
/// <summary>
/// Starts or attaches to a server and waits until it accepts connections.
/// </summary>
/// <returns>The running server.</returns>
public static async Task<RedisTestServer> StartAsync()
{
var provided = Environment.GetEnvironmentVariable(ConnectionStringVariable);
if (!string.IsNullOrWhiteSpace(provided))
{
var attached = new RedisTestServer(null, provided);
await attached.WaitUntilReadyAsync().ConfigureAwait(false);
return attached;
}
var container = new RedisBuilder("valkey/valkey:8-alpine").Build();
await container.StartAsync().ConfigureAwait(false);
var started = new RedisTestServer(container, container.GetConnectionString());
await started.WaitUntilReadyAsync().ConfigureAwait(false);
return started;
}
/// <summary>
/// Opens a connection of its own, so each in-process stand-in for a replica talks to the server the
/// way a separate pod would.
/// </summary>
/// <returns>A new multiplexer.</returns>
public async Task<IConnectionMultiplexer> ConnectAsync()
=> await ConnectionMultiplexer.ConnectAsync(ConnectionString).ConfigureAwait(false);
/// <inheritdoc/>
public async ValueTask DisposeAsync()
{
if (_container is not null)
{
await _container.DisposeAsync().ConfigureAwait(false);
}
}
private async Task WaitUntilReadyAsync()
{
for (var attempt = 1; ; attempt++)
{
try
{
var connection = await ConnectionMultiplexer.ConnectAsync(ConnectionString).ConfigureAwait(false);
await using (connection.ConfigureAwait(false))
{
await connection.GetDatabase().PingAsync().ConfigureAwait(false);
return;
}
}
catch (RedisException) when (attempt < 60)
{
await Task.Delay(TimeSpan.FromSeconds(1)).ConfigureAwait(false);
}
}
}
}
@@ -0,0 +1,64 @@
using System.Collections.Generic;
using Jellyfin.Server.Extensions;
using MediaBrowser.Controller.MediaEncoding;
using MediaBrowser.Controller.Session;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using StackExchange.Redis;
using Xunit;
namespace Jellyfin.Server.Tests.HighAvailability;
/// <summary>
/// The directory and the bus are two halves of one mechanism: a shared directory paired with a bus that
/// reaches nobody advertises sessions from every instance and then fails every command sent to one.
/// </summary>
public static class SessionDirectoryRegistrationTests
{
[Fact]
public static void AddSessionDirectory_WithoutARedisConnection_RegistersNeitherHalf()
{
var services = Build(configured: false);
Assert.IsType<NullSessionDirectory>(services.GetRequiredService<ISessionDirectory>());
Assert.IsType<NullPodMessageBus>(services.GetRequiredService<IPodMessageBus>());
}
[Fact]
public static void AddSessionDirectory_WhenOneHalfCannotBeBuilt_FallsBackOnBoth()
{
// A connection that can serve the directory but not the bus: taking the directory on its own
// would advertise every instance's sessions and then fail every command sent to one.
var redis = new Mock<IConnectionMultiplexer>();
redis.Setup(i => i.GetDatabase(It.IsAny<int>(), It.IsAny<object>())).Returns(Mock.Of<IDatabase>());
redis.Setup(i => i.GetSubscriber(It.IsAny<object>())).Throws(new RedisConnectionException(ConnectionFailureType.UnableToConnect, "unreachable"));
var services = Build(configured: true, redis.Object);
Assert.IsType<NullSessionDirectory>(services.GetRequiredService<ISessionDirectory>());
Assert.IsType<NullPodMessageBus>(services.GetRequiredService<IPodMessageBus>());
}
private static ServiceProvider Build(bool configured, IConnectionMultiplexer? redis = null)
{
var settings = new Dictionary<string, string?>();
if (configured)
{
settings[TranscodeStoreOptions.RedisConnectionStringKey] = "127.0.0.1:6379";
}
var configuration = new ConfigurationBuilder().AddInMemoryCollection(settings).Build();
var serviceCollection = new ServiceCollection().AddLogging();
if (redis is not null)
{
serviceCollection.AddSingleton(redis);
}
return serviceCollection
.AddSessionDirectory(configuration, NullLogger.Instance)
.BuildServiceProvider();
}
}
@@ -0,0 +1,997 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Reflection;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data;
using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.DbConfiguration;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.Enums;
using Jellyfin.Database.Implementations.Locking;
using Jellyfin.Database.Providers.PostgreSQL;
using Jellyfin.Server.Implementations.Devices;
using Jellyfin.Server.Tests.Migrations;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Drawing;
using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Events;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Session;
using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Session;
using MediaBrowser.Model.SyncPlay;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
using Npgsql;
using StackExchange.Redis;
using Xunit;
using RedisPodMessageBus = Emby.Server.Implementations.Session.RedisPodMessageBus;
using RedisSessionDirectory = Emby.Server.Implementations.Session.RedisSessionDirectory;
using SessionManager = Emby.Server.Implementations.Session.SessionManager;
namespace Jellyfin.Server.Tests.HighAvailability;
/// <summary>
/// Two independently constructed <see cref="SessionManager"/> instances over one PostgreSQL database and
/// one valkey are the in-process stand-in for two replicas without sticky sessions: a session either of
/// them holds has to be visible to, and controllable from, the other.
/// </summary>
[Trait("Category", "RequiresDocker")]
public sealed class SessionDirectoryReplicaTests : IAsyncLifetime
{
private const string AppName = "Jellyfin Web";
private const string AppVersion = "1.0.0";
private const string DeviceName = "Living Room TV";
private const string RemoteEndPoint = "127.0.0.1";
private PostgreSqlTestServer _postgres = null!;
private RedisTestServer _redis = null!;
private NpgsqlDataSource _dataSource = null!;
private IConnectionMultiplexer _connection = null!;
private ISessionDirectory _directory = null!;
private User _user = null!;
private User _guest = null!;
/// <inheritdoc/>
public async ValueTask InitializeAsync()
{
_postgres = await PostgreSqlTestServer.StartAsync();
_redis = await RedisTestServer.StartAsync();
_connection = await _redis.ConnectAsync();
_directory = new RedisSessionDirectory(
_connection,
Options.Create(new SessionDirectoryOptions()),
NullLogger<RedisSessionDirectory>.Instance);
var connectionString = await _postgres.CreateDatabaseAsync("session_directory", CancellationToken.None);
_dataSource = new NpgsqlDataSourceBuilder(connectionString).Build();
var context = CreateContext(_dataSource);
await using (context.ConfigureAwait(false))
{
await context.Database.EnsureCreatedAsync(CancellationToken.None);
_user = new User("replica-user", "provider", "provider");
_guest = new User("replica-guest", "provider", "provider");
context.Users.Add(_user);
context.Users.Add(_guest);
await context.SaveChangesAsync(CancellationToken.None);
}
}
/// <inheritdoc/>
public async ValueTask DisposeAsync()
{
await _connection.DisposeAsync();
await _dataSource.DisposeAsync();
await _redis.DisposeAsync();
await _postgres.DisposeAsync();
}
/// <summary>
/// Half the active playback is invisible when the session list only reports what the replica serving
/// the request happens to hold, so a session registered on one replica has to appear on the other.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[Fact]
public async Task SessionRegisteredOnOneReplica_IsListedByAnother()
{
var cancellationToken = TestContext.Current.CancellationToken;
await using var replicaA = CreateReplica("pod-a");
await using var replicaB = CreateReplica("pod-b");
var session = await Request(replicaA, "device-listed");
var listedByB = await replicaB.GetSessions(_user.Id, null, null, null, false, cancellationToken);
var listedByA = await replicaA.GetSessions(_user.Id, null, null, null, false, cancellationToken);
Assert.Contains(listedByB, i => string.Equals(i.Id, session.Id, StringComparison.Ordinal));
Assert.Contains(listedByA, i => string.Equals(i.Id, session.Id, StringComparison.Ordinal));
// The session is reported once, not once per replica that can see it.
Assert.Single(listedByB, i => string.Equals(i.Id, session.Id, StringComparison.Ordinal));
}
/// <summary>
/// The deployment has no sticky sessions, so one device's requests land on either replica while its
/// websocket stays on one of them. Ownership has to follow the connection rather than the last
/// request served, or the directory names the wrong replica, the session list doubles up and remote
/// control is delivered to a replica with nothing to deliver it to.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[Fact]
public async Task RequestsAlternatingBetweenReplicas_KeepOwnershipWithTheConnection()
{
var cancellationToken = TestContext.Current.CancellationToken;
var options = new SessionDirectoryOptions { EntryTtlSeconds = 60, RefreshIntervalSeconds = 2 };
await using var replicaA = CreateReplica("pod-a", options);
await using var replicaB = CreateReplica("pod-b", options);
// The device is first seen by the replica that will not hold its websocket.
await Request(replicaB, "device-roaming");
var session = await Request(replicaA, "device-roaming");
var controller = new RecordingSessionController();
session.AddController(controller);
await replicaA.OnSessionControllerConnected(session);
// The load balancer keeps handing the device's requests to whichever replica it likes, and the
// replica without the websocket must never take the session from the one that has it.
for (var i = 0; i < 8; i++)
{
await Request(replicaB, "device-roaming");
await Task.Delay(250, cancellationToken);
var entry = await _directory.GetAsync(session.Id, cancellationToken);
Assert.NotNull(entry);
Assert.Equal("pod-a", entry.OwnerPod);
Assert.True(entry.HoldsConnection);
await Request(replicaA, "device-roaming");
await Task.Delay(50, cancellationToken);
}
var listedByA = await replicaA.GetSessions(_user.Id, null, null, null, false, cancellationToken);
var listedByB = await replicaB.GetSessions(_user.Id, null, null, null, false, cancellationToken);
Assert.Single(listedByA, i => string.Equals(i.Id, session.Id, StringComparison.Ordinal));
Assert.Single(listedByB, i => string.Equals(i.Id, session.Id, StringComparison.Ordinal));
await replicaB.SendMessageCommand(
string.Empty,
session.Id,
new MessageCommand { Header = "Header", Text = "Dinner is ready" },
cancellationToken);
var (messageType, data) = await controller.WaitForMessageAsync(cancellationToken);
Assert.Equal(SessionMessageType.GeneralCommand, messageType);
Assert.Contains("Dinner is ready", data, StringComparison.Ordinal);
}
/// <summary>
/// Remote control and "send message to session" used to succeed and do nothing when the device is
/// connected to another replica; the message has to reach the connection wherever it is held.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[Fact]
public async Task MessageSentOnOneReplica_ReachesTheConnectionHeldByAnother()
{
var cancellationToken = TestContext.Current.CancellationToken;
await using var replicaA = CreateReplica("pod-a");
await using var replicaB = CreateReplica("pod-b");
var session = await Request(replicaA, "device-controlled");
var controller = new RecordingSessionController();
session.AddController(controller);
await replicaA.OnSessionControllerConnected(session);
await replicaB.SendMessageCommand(
string.Empty,
session.Id,
new MessageCommand { Header = "Header", Text = "Dinner is ready" },
cancellationToken);
var (messageType, data) = await controller.WaitForMessageAsync(cancellationToken);
Assert.Equal(SessionMessageType.GeneralCommand, messageType);
Assert.Contains("Dinner is ready", data, StringComparison.Ordinal);
}
/// <summary>
/// An entry outlives the replica that wrote it by up to its expiry, and a command routed into that
/// gap reaches nobody. Reporting it as delivered is the failure this directory exists to remove.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[Fact]
public async Task MessageRoutedToADeadOwner_IsReportedAsUndelivered()
{
var cancellationToken = TestContext.Current.CancellationToken;
await using var replicaA = CreateReplica("pod-a");
await using var replicaB = CreateReplica("pod-b");
var session = await Request(replicaA, "device-dead-owner");
session.AddController(new RecordingSessionController());
await replicaA.OnSessionControllerConnected(session);
var entry = await _directory.GetAsync(session.Id, cancellationToken);
Assert.NotNull(entry);
// A replica that is no longer listening, holding the entry until it expires.
entry.OwnerPod = "pod-gone";
Assert.True(await _directory.PublishAsync(entry, DateTime.UtcNow.Ticks, cancellationToken));
await Assert.ThrowsAsync<ResourceNotFoundException>(
() => replicaB.SendMessageCommand(
string.Empty,
session.Id,
new MessageCommand { Header = "Header", Text = "Dinner is ready" },
cancellationToken));
}
/// <summary>
/// The owner's entry is only as fresh as its last refresh, so a websocket that closes in between
/// leaves an entry claiming a connection that is gone. The command has to be reported undelivered,
/// which only the replica that would have written it to the socket can say.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[Fact]
public async Task MessageRoutedToAnOwnerWhoseSocketDied_IsReportedAsUndelivered()
{
var cancellationToken = TestContext.Current.CancellationToken;
await using var replicaA = CreateReplica("pod-a");
await using var replicaB = CreateReplica("pod-b");
var session = await Request(replicaA, "device-dead-socket");
var controller = new RecordingSessionController();
session.AddController(controller);
await replicaA.OnSessionControllerConnected(session);
// The socket closes. Nothing rewrites the entry: it still names pod-a and still says the
// connection is held, exactly as it does for the rest of the refresh interval.
controller.IsSessionActive = false;
var entry = await _directory.GetAsync(session.Id, cancellationToken);
Assert.NotNull(entry);
Assert.Equal("pod-a", entry.OwnerPod);
Assert.True(entry.HoldsConnection);
await Assert.ThrowsAsync<ResourceNotFoundException>(
() => replicaB.SendMessageCommand(
string.Empty,
session.Id,
new MessageCommand { Header = "Header", Text = "Dinner is ready" },
cancellationToken));
}
/// <summary>
/// A device reconnecting lands on either replica, so both can hold a live connection for the same
/// deterministic session id at once. Exactly one of them owns the entry, and it stays that one.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[Fact]
public async Task BothReplicasHoldingAConnection_AgreeOnOneOwner()
{
var cancellationToken = TestContext.Current.CancellationToken;
var options = new SessionDirectoryOptions { EntryTtlSeconds = 60, RefreshIntervalSeconds = 1 };
await using var replicaA = CreateReplica("pod-a", options);
await using var replicaB = CreateReplica("pod-b", options);
var sessionA = await Request(replicaA, "device-two-sockets");
sessionA.AddController(new RecordingSessionController());
await replicaA.OnSessionControllerConnected(sessionA);
var sessionB = await Request(replicaB, "device-two-sockets");
sessionB.AddController(new RecordingSessionController());
await replicaB.OnSessionControllerConnected(sessionB);
Assert.Equal(sessionA.Id, sessionB.Id);
// The later connection owns the session; both replicas keep republishing theirs.
for (var i = 0; i < 8; i++)
{
await Task.Delay(500, cancellationToken);
var entry = await _directory.GetAsync(sessionA.Id, cancellationToken);
Assert.NotNull(entry);
Assert.Equal("pod-b", entry.OwnerPod);
Assert.True(entry.HoldsConnection);
}
var listedByA = await replicaA.GetSessions(_user.Id, null, null, null, false, cancellationToken);
Assert.Single(listedByA, i => string.Equals(i.Id, sessionA.Id, StringComparison.Ordinal));
}
/// <summary>
/// A session with no websocket is claimed with a zero epoch by every replica that serves a request
/// for it. The first claim has to stand, or the listed session flips between two partial copies.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[Fact]
public async Task TwoReplicasWithoutAConnection_DoNotTakeTheSessionFromEachOther()
{
var cancellationToken = TestContext.Current.CancellationToken;
var options = new SessionDirectoryOptions { EntryTtlSeconds = 60, RefreshIntervalSeconds = 1 };
await using var replicaA = CreateReplica("pod-a", options);
await using var replicaB = CreateReplica("pod-b", options);
var session = await Request(replicaA, "device-no-socket");
await Request(replicaB, "device-no-socket");
var claimed = await _directory.GetAsync(session.Id, cancellationToken);
Assert.NotNull(claimed);
Assert.False(claimed.HoldsConnection);
var owner = claimed.OwnerPod;
Assert.False(await _directory.PublishAsync(
new SessionDirectoryEntry
{
OwnerPod = owner == "pod-a" ? "pod-b" : "pod-a",
HoldsConnection = false,
Session = claimed.Session
},
0,
cancellationToken));
for (var i = 0; i < 6; i++)
{
await Request(replicaB, "device-no-socket");
await Task.Delay(400, cancellationToken);
await Request(replicaA, "device-no-socket");
var entry = await _directory.GetAsync(session.Id, cancellationToken);
Assert.NotNull(entry);
Assert.Equal(owner, entry.OwnerPod);
}
}
/// <summary>
/// Without sticky sessions a playback report lands on either replica while the websocket stays on
/// one. The report belongs to the replica everyone else is shown, so it is applied there and the
/// session reads as playing from every replica rather than idle on all of them.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[Fact]
public async Task PlaybackReportedToTheNonOwner_IsVisibleFromBothReplicas()
{
var cancellationToken = TestContext.Current.CancellationToken;
await using var replicaA = CreateReplica("pod-a");
await using var replicaB = CreateReplica("pod-b");
var session = await Request(replicaA, "device-playing");
session.AddController(new RecordingSessionController());
await replicaA.OnSessionControllerConnected(session);
// The load balancer hands the playback report to the replica without the websocket.
await Request(replicaB, "device-playing");
await replicaB.OnPlaybackStart(new PlaybackStartInfo
{
SessionId = session.Id,
Item = new BaseItemDto { Id = Guid.NewGuid(), Name = "Routed Movie" },
PositionTicks = 0
});
Assert.Equal("Routed Movie", session.NowPlayingItem?.Name);
var listedByA = await replicaA.GetSessions(_user.Id, null, null, null, false, cancellationToken);
var listedByB = await replicaB.GetSessions(_user.Id, null, null, null, false, cancellationToken);
Assert.Equal("Routed Movie", Single(listedByA, session.Id).NowPlayingItem?.Name);
Assert.Equal("Routed Movie", Single(listedByB, session.Id).NowPlayingItem?.Name);
await replicaB.OnPlaybackStopped(new PlaybackStopInfo { SessionId = session.Id, PositionTicks = 1 });
Assert.Null(session.NowPlayingItem);
Assert.Null(Single(await replicaB.GetSessions(_user.Id, null, null, null, false, cancellationToken), session.Id).NowPlayingItem);
}
/// <summary>
/// A report that cannot be handed to the owner is still applied here, so an unreachable owner is
/// never worse than the single-instance behaviour of keeping the state on the replica that served
/// the request.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[Fact]
public async Task PlaybackReportedWithTheOwnerUnreachable_IsAppliedLocally()
{
var cancellationToken = TestContext.Current.CancellationToken;
await using var replicaA = CreateReplica("pod-a");
await using var replicaB = CreateReplica("pod-b");
var session = await Request(replicaA, "device-orphaned");
session.AddController(new RecordingSessionController());
await replicaA.OnSessionControllerConnected(session);
var local = await Request(replicaB, "device-orphaned");
var entry = await _directory.GetAsync(session.Id, cancellationToken);
Assert.NotNull(entry);
entry.OwnerPod = "pod-gone";
Assert.True(await _directory.PublishAsync(entry, long.MaxValue, cancellationToken));
await replicaB.OnPlaybackStart(new PlaybackStartInfo
{
SessionId = session.Id,
Item = new BaseItemDto { Id = Guid.NewGuid(), Name = "Orphaned Movie" },
PositionTicks = 0
});
Assert.Equal("Orphaned Movie", local.NowPlayingItem?.Name);
}
/// <summary>
/// A directory that cannot be read says nothing about where a session is. Treating the failure as
/// "no such entry" hands the command to a local copy with no connection, which reports success and
/// delivers nothing.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[Fact]
public async Task DirectoryReadFailingDuringRemoteControl_DoesNotSilentlyDoNothing()
{
var cancellationToken = TestContext.Current.CancellationToken;
var failing = new FailableSessionDirectory(new RedisSessionDirectory(
_connection,
Options.Create(new SessionDirectoryOptions()),
NullLogger<RedisSessionDirectory>.Instance));
await using var replicaA = CreateReplica("pod-a");
await using var replicaB = CreateReplica("pod-b", failing);
var session = await Request(replicaA, "device-unreadable");
session.AddController(new RecordingSessionController());
await replicaA.OnSessionControllerConnected(session);
// The replica serving the request holds a copy of the session, and only a copy.
await Request(replicaB, "device-unreadable");
failing.FailReads = true;
await Assert.ThrowsAsync<RedisTimeoutException>(
() => replicaB.SendMessageCommand(
string.Empty,
session.Id,
new MessageCommand { Header = "Header", Text = "Dinner is ready" },
cancellationToken));
}
/// <summary>
/// Ownership is decided by comparing the two replicas' connection epochs, so the epochs cannot come
/// from the replicas' own clocks: a lagging clock would keep a genuinely newer connection from ever
/// taking the session. They are handed out per session by the shared store instead.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[Fact]
public async Task ConnectionEpochs_AreHandedOutByTheStore()
{
var cancellationToken = TestContext.Current.CancellationToken;
await using var replicaA = CreateReplica("pod-a");
var session = await Request(replicaA, "device-epoch");
session.AddController(new RecordingSessionController());
await replicaA.OnSessionControllerConnected(session);
var recorded = await ReadOwnerEpoch(session.Id, cancellationToken);
var allocated = await _directory.AllocateConnectionEpochAsync(session.Id, cancellationToken);
var other = await _directory.AllocateConnectionEpochAsync(session.Id + "-other", cancellationToken);
// A per-session counter the store hands out in order, orders of magnitude below any tick count,
// so it cannot be a reading of a replica's clock.
Assert.True(recorded > 0);
Assert.True(allocated > recorded);
Assert.True(other > 0);
Assert.True(other < await _directory.AllocateConnectionEpochAsync(session.Id + "-other", cancellationToken));
Assert.True(allocated < TimeSpan.TicksPerSecond);
}
/// <summary>
/// SyncPlay groups are still instance-local, so the replica serving the request has to notice that
/// its copy of the session has no connection. Holding a copy is not holding the connection, and a
/// command handed to a copy would be dropped without a word.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[Fact]
public async Task SyncPlayCommandOnTheReplicaWithoutTheConnection_IsSkippedAndLogged()
{
var cancellationToken = TestContext.Current.CancellationToken;
var logger = new CapturingLogger<SessionManager>();
await using var replicaA = CreateReplica("pod-a");
await using var replicaB = CreateReplica("pod-b", logger: logger);
var session = await Request(replicaA, "device-syncplay");
var controller = new RecordingSessionController();
session.AddController(controller);
await replicaA.OnSessionControllerConnected(session);
await Request(replicaB, "device-syncplay");
await replicaB.SendSyncPlayCommand(
session.Id,
new SendCommand(Guid.NewGuid(), Guid.NewGuid(), DateTime.UtcNow, SendCommandType.Pause, 0, DateTime.UtcNow),
cancellationToken);
Assert.Contains(logger.Messages, i => i.Contains("SyncPlay command", StringComparison.Ordinal) && i.Contains(session.Id, StringComparison.Ordinal));
Assert.False(controller.HasMessage);
}
/// <summary>
/// Both replicas keep a copy of a session whose requests they have served, so the replica ending its
/// own copy must not erase the entry of the one still holding the connection.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[Fact]
public async Task ReplicaEndingItsOwnCopy_LeavesTheOwnersEntryAlone()
{
var cancellationToken = TestContext.Current.CancellationToken;
await using var replicaA = CreateReplica("pod-a");
await using var replicaB = CreateReplica("pod-b");
var session = await Request(replicaA, "device-shared-end");
session.AddController(new RecordingSessionController());
await replicaA.OnSessionControllerConnected(session);
await Request(replicaB, "device-shared-end");
await replicaB.ReportSessionEnded(session.Id);
var entry = await _directory.GetAsync(session.Id, cancellationToken);
Assert.NotNull(entry);
Assert.Equal("pod-a", entry.OwnerPod);
await replicaA.ReportSessionEnded(session.Id);
Assert.Null(await _directory.GetAsync(session.Id, cancellationToken));
}
/// <summary>
/// The session list now shows sessions from every replica, so an action offered against one of them
/// has to reach it rather than fail as missing on the replica serving the request.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[Fact]
public async Task AdditionalUserAddedOnOneReplica_ReachesTheOwner()
{
var cancellationToken = TestContext.Current.CancellationToken;
await using var replicaA = CreateReplica("pod-a");
await using var replicaB = CreateReplica("pod-b");
var session = await Request(replicaA, "device-additional-user");
session.AddController(new RecordingSessionController());
await replicaA.OnSessionControllerConnected(session);
await replicaB.AddAdditionalUser(string.Empty, session.Id, _guest.Id);
await WaitUntil(() => session.AdditionalUsers.Any(i => i.UserId.Equals(_guest.Id)), cancellationToken);
await replicaB.RemoveAdditionalUser(string.Empty, session.Id, _guest.Id);
await WaitUntil(() => !session.AdditionalUsers.Any(i => i.UserId.Equals(_guest.Id)), cancellationToken);
}
/// <summary>
/// The idle sweep runs on every replica but stops playback and rewrites the resume position. A
/// replica holding a copy it does not own sees a check-in that froze when reports started routing
/// away, so an unguarded sweep would stop a film the other replica is still playing.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[Fact]
public async Task IdleSweepOnANonOwner_LeavesTheOwnersPlaybackAlone()
{
var cancellationToken = TestContext.Current.CancellationToken;
var stopped = new TaskCompletionSource<PlaybackStopEventArgs>(TaskCreationOptions.RunContinuationsAsynchronously);
var events = new Mock<IEventManager>();
events.Setup(i => i.PublishAsync(It.IsAny<PlaybackStopEventArgs>()))
.Callback<PlaybackStopEventArgs>(args => stopped.TrySetResult(args))
.Returns(Task.CompletedTask);
var movie = new Movie { Id = Guid.NewGuid(), Name = "Live Movie" };
var libraryManager = new Mock<ILibraryManager>();
libraryManager.Setup(i => i.GetItemById(movie.Id)).Returns(movie);
var userDataManager = new Mock<IUserDataManager>();
userDataManager.Setup(i => i.GetUserData(It.IsAny<User>(), It.IsAny<BaseItem>())).Returns(new UserItemData { Key = "test" });
await using var replicaA = CreateReplica(
"pod-a",
userDataManager: userDataManager.Object,
libraryManager: libraryManager.Object,
eventManager: events.Object);
await using var replicaB = CreateReplica("pod-b");
var owned = await Request(replicaA, "device-idle-sweep");
owned.AddController(new RecordingSessionController());
await replicaA.OnSessionControllerConnected(owned);
await replicaA.OnPlaybackStart(new PlaybackStartInfo
{
SessionId = owned.Id,
ItemId = movie.Id,
MediaSourceId = movie.Id.ToString("N", CultureInfo.InvariantCulture),
Item = new BaseItemDto { Id = movie.Id, Name = movie.Name },
PositionTicks = 0
});
// The copy a non-owner is left with: it still remembers a now playing item, and its check-in
// froze the moment reports started going to the owner instead.
var copy = await Request(replicaB, "device-idle-sweep");
copy.NowPlayingItem = new BaseItemDto { Id = movie.Id, Name = movie.Name };
copy.StartAutomaticProgress(new PlaybackProgressInfo { IsPaused = true, PositionTicks = 123456789 });
copy.StopAutomaticProgress();
copy.LastPlaybackCheckIn = DateTime.UtcNow.AddHours(-1);
InvokeSweep(replicaB, "CheckForIdlePlayback");
await Assert.ThrowsAsync<TimeoutException>(() => stopped.Task.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken));
Assert.Equal(movie.Name, owned.NowPlayingItem?.Name);
Assert.Equal(movie.Name, Single(await replicaB.GetSessions(_user.Id, null, null, null, false, cancellationToken), owned.Id).NowPlayingItem?.Name);
userDataManager.Verify(
i => i.SaveUserData(It.IsAny<User>(), It.IsAny<BaseItem>(), It.IsAny<UserItemData>(), UserDataSaveReason.PlaybackFinished, It.IsAny<CancellationToken>()),
Times.Never);
// The same sweep on the owner does stop it, so the assertions above are not vacuous.
owned.LastPlaybackCheckIn = DateTime.UtcNow.AddHours(-1);
InvokeSweep(replicaA, "CheckForIdlePlayback");
await stopped.Task.WaitAsync(TimeSpan.FromSeconds(30), cancellationToken);
Assert.Null(owned.NowPlayingItem);
}
/// <summary>
/// The inactive sweep decides from the local copy's paused state and then writes a real Stop to
/// whichever replica holds the connection. A replica that only holds a copy has no business
/// stopping the session the other one is serving.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[Fact]
public async Task InactiveSweepOnANonOwner_DoesNotStopTheOwnersSession()
{
var cancellationToken = TestContext.Current.CancellationToken;
var configuration = new ServerConfiguration { InactiveSessionThreshold = 1 };
await using var replicaA = CreateReplica("pod-a", configuration: configuration);
await using var replicaB = CreateReplica("pod-b", configuration: configuration);
var owned = await Request(replicaA, "device-inactive-sweep");
var controller = new RecordingSessionController();
owned.AddController(controller);
await replicaA.OnSessionControllerConnected(owned);
owned.NowPlayingItem = new BaseItemDto { Id = Guid.NewGuid(), Name = "Paused Movie" };
owned.PlayState.IsPaused = true;
owned.LastPausedDate = DateTime.UtcNow.AddHours(-1);
var copy = await Request(replicaB, "device-inactive-sweep");
copy.NowPlayingItem = owned.NowPlayingItem;
copy.PlayState.IsPaused = true;
copy.LastPausedDate = DateTime.UtcNow.AddHours(-1);
InvokeSweep(replicaB, "CheckForInactiveSteams");
await Task.Delay(TimeSpan.FromSeconds(3), cancellationToken);
Assert.False(controller.HasMessage);
// The same sweep on the owner does write the Stop, so the assertion above is not vacuous.
InvokeSweep(replicaA, "CheckForInactiveSteams");
var (messageType, _) = await controller.WaitForMessageAsync(cancellationToken);
Assert.Equal(SessionMessageType.Playstate, messageType);
}
/// <summary>
/// Without sticky sessions a device posts its capabilities to either replica, and a session is only
/// offered for remote control while the replica that publishes it knows they support media control.
/// A report kept by the replica that served it drops the device from the cast list everywhere.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[Fact]
public async Task CapabilitiesReportedToTheNonOwner_KeepTheDeviceControllable()
{
var cancellationToken = TestContext.Current.CancellationToken;
_user.SetPermission(PermissionKind.EnableAllDevices, true);
await using var replicaA = CreateReplica("pod-a");
await using var replicaB = CreateReplica("pod-b");
var session = await Request(replicaA, "device-capabilities");
session.AddController(new RecordingSessionController());
await replicaA.OnSessionControllerConnected(session);
// The web client posts its capabilities to whichever replica the load balancer picked.
await Request(replicaB, "device-capabilities");
await replicaB.ReportCapabilities(string.Empty, session.Id, new ClientCapabilities
{
PlayableMediaTypes = [MediaType.Video],
SupportedCommands = [GeneralCommandType.DisplayMessage],
SupportsMediaControl = true,
SupportsPersistentIdentifier = true
});
Assert.True(session.SupportsRemoteControl);
await WaitUntilAsync(
async () => (await ControllableIds(replicaB, cancellationToken)).Contains(session.Id),
cancellationToken);
Assert.Contains(session.Id, await ControllableIds(replicaA, cancellationToken));
Assert.Contains(session.Id, await ControllableIds(replicaB, cancellationToken));
}
/// <summary>
/// A replica that dies stops refreshing its entries, and the sessions it held have to leave the
/// directory rather than linger in every other replica's session list forever.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[Fact]
public async Task SessionsOfAReplicaThatStopsRefreshing_LeaveTheDirectory()
{
var cancellationToken = TestContext.Current.CancellationToken;
// A never refreshes within the test, so it stands in for a replica that crashed.
await using var replicaA = CreateReplica("pod-a", new SessionDirectoryOptions { EntryTtlSeconds = 1, RefreshIntervalSeconds = 3600 });
await using var replicaB = CreateReplica("pod-b");
var session = await Request(replicaA, "device-expiring");
var listedWhileAlive = await replicaB.GetSessions(_user.Id, null, null, null, false, cancellationToken);
Assert.Contains(listedWhileAlive, i => string.Equals(i.Id, session.Id, StringComparison.Ordinal));
await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken);
var listedAfterExpiry = await replicaB.GetSessions(_user.Id, null, null, null, false, cancellationToken);
Assert.DoesNotContain(listedAfterExpiry, i => string.Equals(i.Id, session.Id, StringComparison.Ordinal));
}
/// <summary>
/// A deployment without a shared store keeps the single-instance behaviour: nothing is published and
/// the other instance sees nothing.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[Fact]
public async Task WithoutADirectory_ReplicasOnlyReportTheirOwnSessions()
{
var cancellationToken = TestContext.Current.CancellationToken;
await using var replicaA = CreateReplica("pod-a", directory: NullSessionDirectory.Instance, bus: NullPodMessageBus.Instance);
await using var replicaB = CreateReplica("pod-b", directory: NullSessionDirectory.Instance, bus: NullPodMessageBus.Instance);
var session = await Request(replicaA, "device-local");
var listedByB = await replicaB.GetSessions(_user.Id, null, null, null, false, cancellationToken);
Assert.DoesNotContain(listedByB, i => string.Equals(i.Id, session.Id, StringComparison.Ordinal));
}
private async Task<long> ReadOwnerEpoch(string sessionId, CancellationToken cancellationToken)
{
var raw = await _connection.GetDatabase().StringGetAsync("jellyfin:sessionowner:" + sessionId).WaitAsync(cancellationToken);
return long.Parse(raw.ToString().Split('|')[0], CultureInfo.InvariantCulture);
}
private static SessionInfoDto Single(IReadOnlyList<SessionInfoDto> sessions, string sessionId)
=> Assert.Single(sessions, i => string.Equals(i.Id, sessionId, StringComparison.Ordinal));
private static async Task WaitUntil(Func<bool> condition, CancellationToken cancellationToken)
{
var deadline = DateTime.UtcNow.AddSeconds(10);
while (!condition())
{
Assert.True(DateTime.UtcNow < deadline, "The expected change never arrived.");
await Task.Delay(50, cancellationToken);
}
}
private static async Task WaitUntilAsync(Func<Task<bool>> condition, CancellationToken cancellationToken)
{
var deadline = DateTime.UtcNow.AddSeconds(30);
while (!await condition())
{
Assert.True(DateTime.UtcNow < deadline, "The expected change never arrived.");
await Task.Delay(100, cancellationToken);
}
}
private static void InvokeSweep(SessionManager replica, string name)
=> typeof(SessionManager)
.GetMethod(name, BindingFlags.Instance | BindingFlags.NonPublic)!
.Invoke(replica, [null]);
private static JellyfinDbContext CreateContext(NpgsqlDataSource dataSource)
{
var optionsBuilder = new DbContextOptionsBuilder<JellyfinDbContext>();
var provider = new PostgreSqlDatabaseProvider(dataSource);
provider.Initialise(optionsBuilder, new DatabaseConfigurationOptions { DatabaseType = "PostgreSQL" });
return new JellyfinDbContext(
optionsBuilder.Options,
NullLogger<JellyfinDbContext>.Instance,
provider,
new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
}
private Task<SessionInfo> Request(SessionManager replica, string deviceId)
=> replica.LogSessionActivity(AppName, AppVersion, deviceId, DeviceName, RemoteEndPoint, _user);
private async Task<IReadOnlyList<string>> ControllableIds(SessionManager replica, CancellationToken cancellationToken)
=> (await replica.GetSessions(_user.Id, null, null, _user.Id, false, cancellationToken))
.Select(i => i.Id ?? string.Empty)
.ToList();
private SessionManager CreateReplica(
string podId,
SessionDirectoryOptions? options = null,
ILogger<SessionManager>? logger = null,
IUserDataManager? userDataManager = null,
ILibraryManager? libraryManager = null,
IEventManager? eventManager = null,
ServerConfiguration? configuration = null)
{
options ??= new SessionDirectoryOptions { EntryTtlSeconds = 60, RefreshIntervalSeconds = 3600 };
var directory = new RedisSessionDirectory(
_connection,
Options.Create(options),
NullLogger<RedisSessionDirectory>.Instance);
return CreateReplica(podId, options, directory, CreateBus(podId, options), logger, userDataManager, libraryManager, eventManager, configuration);
}
private SessionManager CreateReplica(string podId, ISessionDirectory directory)
=> CreateReplica(podId, new SessionDirectoryOptions(), directory, CreateBus(podId, new SessionDirectoryOptions()), null);
private SessionManager CreateReplica(string podId, ISessionDirectory directory, IPodMessageBus bus)
=> CreateReplica(podId, new SessionDirectoryOptions(), directory, bus, null);
private SessionManager CreateReplica(
string podId,
SessionDirectoryOptions options,
ISessionDirectory directory,
IPodMessageBus bus,
ILogger<SessionManager>? logger,
IUserDataManager? userDataManager = null,
ILibraryManager? libraryManager = null,
IEventManager? eventManager = null,
ServerConfiguration? configuration = null)
{
var userManager = new Mock<IUserManager>();
userManager.Setup(i => i.GetUserById(_user.Id)).Returns(_user);
userManager.Setup(i => i.GetUserById(_guest.Id)).Returns(_guest);
var appHost = new Mock<IServerApplicationHost>();
appHost.SetupGet(i => i.SystemId).Returns("server-" + podId);
var configurationManager = new Mock<IServerConfigurationManager>();
configurationManager.SetupGet(i => i.Configuration).Returns(configuration ?? new ServerConfiguration());
return new SessionManager(
logger ?? NullLogger<SessionManager>.Instance,
eventManager ?? Mock.Of<IEventManager>(),
userDataManager ?? Mock.Of<IUserDataManager>(),
configurationManager.Object,
libraryManager ?? Mock.Of<ILibraryManager>(),
userManager.Object,
Mock.Of<IMusicManager>(),
Mock.Of<IDtoService>(),
Mock.Of<IImageProcessor>(),
appHost.Object,
new DeviceManager(new DataSourceContextFactory(_dataSource), userManager.Object),
Mock.Of<IMediaSourceManager>(),
Mock.Of<IHostApplicationLifetime>(),
directory,
bus,
Options.Create(options));
}
private IPodMessageBus CreateBus(string podId, SessionDirectoryOptions options)
=> new RedisPodMessageBus(
_connection,
Options.Create(options),
podId,
NullLogger<RedisPodMessageBus>.Instance);
/// <summary>
/// Hands every replica its own context over the one shared database, the way the pooled factory does
/// in the server.
/// </summary>
private sealed class DataSourceContextFactory : IDbContextFactory<JellyfinDbContext>
{
private readonly NpgsqlDataSource _dataSource;
public DataSourceContextFactory(NpgsqlDataSource dataSource)
{
_dataSource = dataSource;
}
public JellyfinDbContext CreateDbContext() => CreateContext(_dataSource);
}
/// <summary>
/// Keeps what a replica logged so a skipped route can be told apart from a silent drop.
/// </summary>
/// <typeparam name="T">The category the logger belongs to.</typeparam>
private sealed class CapturingLogger<T> : ILogger<T>
{
private readonly ConcurrentQueue<string> _messages = new();
public IEnumerable<string> Messages => _messages;
public IDisposable BeginScope<TState>(TState state)
where TState : notnull
=> NullLogger.Instance.BeginScope(state);
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
=> _messages.Enqueue(formatter(state, exception));
}
/// <summary>
/// A directory whose reads can be made to fail the way an unreachable valkey does.
/// </summary>
private sealed class FailableSessionDirectory : ISessionDirectory
{
private readonly ISessionDirectory _inner;
public FailableSessionDirectory(ISessionDirectory inner)
{
_inner = inner;
}
public bool FailReads { get; set; }
public Task<long> AllocateConnectionEpochAsync(string sessionId, CancellationToken cancellationToken = default)
=> _inner.AllocateConnectionEpochAsync(sessionId, cancellationToken);
public Task<bool> PublishAsync(SessionDirectoryEntry entry, long connectionEpoch, CancellationToken cancellationToken = default)
=> _inner.PublishAsync(entry, connectionEpoch, cancellationToken);
public Task RemoveAsync(string sessionId, string ownerPod, CancellationToken cancellationToken = default)
=> _inner.RemoveAsync(sessionId, ownerPod, cancellationToken);
public Task<SessionDirectoryEntry?> GetAsync(string sessionId, CancellationToken cancellationToken = default)
=> FailReads
? throw new RedisTimeoutException("The session directory is unreachable.", CommandStatus.Unknown)
: _inner.GetAsync(sessionId, cancellationToken);
public Task<IReadOnlyList<SessionDirectoryEntry>> GetAllAsync(CancellationToken cancellationToken = default)
=> FailReads
? throw new RedisTimeoutException("The session directory is unreachable.", CommandStatus.Unknown)
: _inner.GetAllAsync(cancellationToken);
}
/// <summary>
/// Stands in for the websocket the owning replica holds.
/// </summary>
private sealed class RecordingSessionController : ISessionController
{
private readonly TaskCompletionSource<(SessionMessageType MessageType, string Data)> _received = new();
public bool IsSessionActive { get; set; } = true;
public bool SupportsMediaControl => true;
public bool HasMessage => _received.Task.IsCompleted;
public Task SendMessage<T>(SessionMessageType name, Guid messageId, T data, CancellationToken cancellationToken)
{
_received.TrySetResult((name, JsonSerializer.Serialize(data)));
return Task.CompletedTask;
}
public Task<(SessionMessageType MessageType, string Data)> WaitForMessageAsync(CancellationToken cancellationToken)
=> _received.Task.WaitAsync(TimeSpan.FromSeconds(10), cancellationToken);
}
}
@@ -0,0 +1,234 @@
using System;
using System.Collections.Generic;
using System.Security.Claims;
using System.Threading;
using System.Threading.Tasks;
using Emby.Server.Implementations.Data;
using Jellyfin.Api.Constants;
using Jellyfin.Api.Controllers;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.DbConfiguration;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.Locking;
using Jellyfin.Database.Providers.PostgreSQL;
using Jellyfin.Server.Implementations.Item;
using Jellyfin.Server.Tests.Migrations;
using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.TV;
using MediaBrowser.Model.Querying;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Npgsql;
using Xunit;
using BaseItemKind = Jellyfin.Data.Enums.BaseItemKind;
using User = Jellyfin.Database.Implementations.Entities.User;
namespace Jellyfin.Server.Tests.Item;
/// <summary>
/// Drives the Next Up cutoff from the controller into a real PostgreSQL. The model binder hands a
/// query-string date over as <see cref="DateTimeKind.Unspecified"/>, and Npgsql refuses to write anything
/// but <see cref="DateTimeKind.Utc"/> to <c>timestamp with time zone</c>; SQLite takes every kind, so an
/// unnormalised cutoff only ever fails here.
/// </summary>
[Trait("Category", "RequiresDocker")]
public sealed class PostgreSqlNextUpServiceTests : IAsyncLifetime
{
private static readonly Guid _libraryId = Guid.Parse("aaaaaaaa-0000-0000-0000-000000000001");
private static readonly Guid _otherLibraryId = Guid.Parse("aaaaaaaa-0000-0000-0000-000000000002");
private static readonly Guid _userId = Guid.Parse("bbbbbbbb-0000-0000-0000-000000000001");
private static readonly Guid _recentWatchedId = Guid.Parse("cccccccc-0000-0000-0000-000000000001");
private static readonly Guid _recentOlderId = Guid.Parse("cccccccc-0000-0000-0000-000000000002");
private static readonly Guid _staleWatchedId = Guid.Parse("cccccccc-0000-0000-0000-000000000003");
private static readonly Guid _unwatchedId = Guid.Parse("cccccccc-0000-0000-0000-000000000004");
private static readonly Guid _foreignLibraryId = Guid.Parse("cccccccc-0000-0000-0000-000000000005");
private static readonly DateTime _recentPlayedAt = new DateTime(2026, 3, 1, 12, 0, 0, DateTimeKind.Utc);
private static readonly DateTime _stalePlayedAt = new DateTime(2020, 1, 1, 12, 0, 0, DateTimeKind.Utc);
private readonly ItemTypeLookup _itemTypeLookup = new();
private readonly User _user = new User("next-up", "auth", "reset") { Id = _userId };
private PostgreSqlTestServer _server = null!;
private NpgsqlDataSource _dataSource = null!;
private NextUpService _service = null!;
public async ValueTask InitializeAsync()
{
_server = await PostgreSqlTestServer.StartAsync().ConfigureAwait(false);
var connectionString = await _server.CreateDatabaseAsync("next_up_service", TestContext.Current.CancellationToken).ConfigureAwait(false);
_dataSource = new NpgsqlDataSourceBuilder(connectionString).Build();
var context = CreateDbContext();
await using (context.ConfigureAwait(false))
{
await context.Database.EnsureCreatedAsync(TestContext.Current.CancellationToken).ConfigureAwait(false);
}
var factory = new Mock<IDbContextFactory<JellyfinDbContext>>();
factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext);
factory.Setup(f => f.CreateDbContextAsync(It.IsAny<CancellationToken>())).ReturnsAsync(CreateDbContext);
_service = new NextUpService(factory.Object, _itemTypeLookup, new Mock<IItemQueryHelpers>().Object);
await SeedAsync().ConfigureAwait(false);
}
public async ValueTask DisposeAsync()
{
await _dataSource.DisposeAsync().ConfigureAwait(false);
await _server.DisposeAsync().ConfigureAwait(false);
}
/// <summary>
/// A cutoff on the query string, which the model binder leaves unspecified.
/// </summary>
[Fact]
public void GetNextUpSeriesKeys_WithSuppliedCutoff_DropsSeriesPlayedBeforeIt()
{
var cutoff = RunController(new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Unspecified)).NextUpDateCutoff;
var keys = _service.GetNextUpSeriesKeys(CreateFilter(), cutoff);
Assert.Equal(new[] { "series-recent" }, keys);
}
/// <summary>
/// The home-screen row, where the client sends no cutoff and the query default stands in.
/// </summary>
[Fact]
public void GetNextUpSeriesKeys_WithoutSuppliedCutoff_ReturnsWatchedSeriesNewestFirst()
{
var cutoff = RunController(null).NextUpDateCutoff;
var keys = _service.GetNextUpSeriesKeys(CreateFilter(), cutoff);
Assert.Equal(new[] { "series-recent", "series-stale" }, keys);
}
/// <summary>
/// Calls <c>GET /Shows/NextUp</c> and hands back the query it built for the series lookup.
/// </summary>
private NextUpQuery RunController(DateTime? nextUpDateCutoff)
{
var userManager = new Mock<IUserManager>();
userManager.Setup(m => m.GetUserById(_userId)).Returns(_user);
var dtoService = new Mock<IDtoService>();
dtoService.Setup(s => s.GetBaseItemDtos(
It.IsAny<IReadOnlyList<BaseItem>>(),
It.IsAny<DtoOptions>(),
It.IsAny<User>(),
It.IsAny<BaseItem>(),
It.IsAny<bool>()))
.Returns([]);
NextUpQuery? captured = null;
var tvSeriesManager = new Mock<ITVSeriesManager>();
tvSeriesManager.Setup(m => m.GetNextUp(It.IsAny<NextUpQuery>(), It.IsAny<DtoOptions>()))
.Callback<NextUpQuery, DtoOptions>((query, _) => captured = query)
.Returns(new QueryResult<BaseItem>());
var controller = new TvShowsController(
userManager.Object,
new Mock<ILibraryManager>().Object,
dtoService.Object,
tvSeriesManager.Object)
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext
{
User = new ClaimsPrincipal(new ClaimsIdentity([new Claim(InternalClaimTypes.UserId, _userId.ToString("D"))], "Test"))
}
}
};
controller.GetNextUp(null, null, null, [], null, null, null, null, [], null, nextUpDateCutoff);
return captured!;
}
private InternalItemsQuery CreateFilter()
{
return new InternalItemsQuery(_user) { TopParentIds = [_libraryId] };
}
private JellyfinDbContext CreateDbContext()
{
var optionsBuilder = new DbContextOptionsBuilder<JellyfinDbContext>();
var provider = new PostgreSqlDatabaseProvider(_dataSource);
provider.Initialise(optionsBuilder, new DatabaseConfigurationOptions { DatabaseType = "PostgreSQL" });
return new JellyfinDbContext(
optionsBuilder.Options,
NullLogger<JellyfinDbContext>.Instance,
provider,
new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
}
private async Task SeedAsync()
{
var context = CreateDbContext();
await using (context.ConfigureAwait(false))
{
context.Users.Add(_user);
// The newest play of a series decides its place, so the older episode must not pull it down.
var recentWatched = AddEpisode(context, _recentWatchedId, "series-recent", _libraryId);
AddUserData(context, recentWatched, _recentPlayedAt);
var recentOlder = AddEpisode(context, _recentOlderId, "series-recent", _libraryId);
AddUserData(context, recentOlder, _stalePlayedAt);
var staleWatched = AddEpisode(context, _staleWatchedId, "series-stale", _libraryId);
AddUserData(context, staleWatched, _stalePlayedAt);
// Never played, and played but outside the requested libraries: both stay out.
AddEpisode(context, _unwatchedId, "series-unwatched", _libraryId);
var foreign = AddEpisode(context, _foreignLibraryId, "series-foreign", _otherLibraryId);
AddUserData(context, foreign, _recentPlayedAt);
await context.SaveChangesAsync(TestContext.Current.CancellationToken).ConfigureAwait(false);
}
}
private BaseItemEntity AddEpisode(JellyfinDbContext context, Guid id, string seriesKey, Guid topParentId)
{
var episode = new BaseItemEntity
{
Id = id,
Type = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Episode],
Name = seriesKey + "-" + id.ToString("N"),
SeriesPresentationUniqueKey = seriesKey,
PresentationUniqueKey = id.ToString("N"),
TopParentId = topParentId,
MediaType = "Video",
IsFolder = false,
IsVirtualItem = false
};
context.BaseItems.Add(episode);
return episode;
}
private void AddUserData(JellyfinDbContext context, BaseItemEntity item, DateTime lastPlayedDate)
{
context.UserData.Add(new UserData
{
CustomDataKey = item.Id.ToString("N"),
ItemId = item.Id,
Item = item,
UserId = _userId,
User = _user,
LastPlayedDate = lastPlayedDate,
Played = true,
PlayCount = 1
});
}
}
@@ -11,7 +11,9 @@
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="Npgsql" />
<PackageReference Include="StackExchange.Redis" />
<PackageReference Include="Testcontainers.PostgreSql" />
<PackageReference Include="Testcontainers.Redis" />
<PackageReference Include="xunit.v3" />
<PackageReference Include="xunit.runner.visualstudio">
<PrivateAssets>all</PrivateAssets>
@@ -0,0 +1,374 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Threading;
using System.Threading.Tasks;
using Emby.Server.Implementations.QuickConnect;
using Jellyfin.Data.Queries;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.DbConfiguration;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.Entities.Security;
using Jellyfin.Database.Implementations.Locking;
using Jellyfin.Database.Providers.PostgreSQL;
using Jellyfin.Server.Implementations.Devices;
using Jellyfin.Server.Tests.HighAvailability;
using Jellyfin.Server.Tests.Migrations;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller.Authentication;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Devices;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Net;
using MediaBrowser.Controller.QuickConnect;
using MediaBrowser.Controller.Session;
using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.QuickConnect;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Npgsql;
using StackExchange.Redis;
using Xunit;
namespace Jellyfin.Server.Tests.QuickConnect;
/// <summary>
/// Three independently constructed <see cref="QuickConnectManager"/> instances over one PostgreSQL
/// database and one Redis are the in-process stand-in for three replicas without sticky sessions: the
/// initiate, authorize and exchange legs of one flow each land on a different one.
/// </summary>
[Trait("Category", "RequiresDocker")]
public sealed class QuickConnectReplicaTests : IAsyncLifetime
{
private static readonly AuthorizationInfo _authorizationInfo = new AuthorizationInfo
{
Device = "Living Room TV",
DeviceId = "device-1",
Client = "Jellyfin Web",
Version = "1.0.0"
};
private readonly List<IConnectionMultiplexer> _connections = new();
private PostgreSqlTestServer _postgres = null!;
private RedisTestServer _redis = null!;
/// <inheritdoc/>
public async ValueTask InitializeAsync()
{
_postgres = await PostgreSqlTestServer.StartAsync().ConfigureAwait(false);
_redis = await RedisTestServer.StartAsync().ConfigureAwait(false);
}
/// <inheritdoc/>
public async ValueTask DisposeAsync()
{
foreach (var connection in _connections)
{
await connection.DisposeAsync().ConfigureAwait(false);
}
await _redis.DisposeAsync().ConfigureAwait(false);
await _postgres.DisposeAsync().ConfigureAwait(false);
}
/// <summary>
/// The three legs of a quick connect flow land on three different replicas, and the token the third
/// one hands out is the one the second one minted into the shared database.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[Fact]
public async Task InitiateAuthorizeExchange_AcrossThreeReplicas_Succeeds()
{
var cancellationToken = TestContext.Current.CancellationToken;
var connectionString = await _postgres.CreateDatabaseAsync("quickconnect_replica_flow", cancellationToken);
await using var dataSource = new NpgsqlDataSourceBuilder(connectionString).Build();
var user = await CreateSchemaWithUserAsync(dataSource, cancellationToken);
var replicaA = await CreateReplicaAsync(dataSource, user);
var replicaB = await CreateReplicaAsync(dataSource, user);
var replicaC = await CreateReplicaAsync(dataSource, user);
var initiated = await replicaA.Manager.TryConnect(_authorizationInfo);
// The code is shown to the user on whichever replica serves the dashboard.
Assert.True(await replicaB.Manager.AuthorizeRequest(user.Id, initiated.Code));
var polled = await replicaC.Manager.CheckRequestStatus(initiated.Secret);
Assert.True(polled.Authenticated);
Assert.Equal(initiated.Code, polled.Code);
Assert.Equal(_authorizationInfo.DeviceId, polled.DeviceId);
var exchanged = await replicaC.Manager.GetAuthorizedRequest(initiated.Secret);
Assert.False(string.IsNullOrEmpty(exchanged.AccessToken));
Assert.Equal(user.Id, exchanged.User.Id);
var devices = await replicaA.Devices.GetDevices(new DeviceQuery { AccessToken = exchanged.AccessToken });
Assert.Equal(user.Id, Assert.Single(devices.Items).UserId);
}
/// <summary>
/// Exchanging a secret does not spend it: a client that retries, or whose retry lands on another
/// replica, gets the same access token back rather than a 404, and the device is minted once.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[Fact]
public async Task Exchange_RepeatedOnTwoReplicas_ReturnsTheSameToken()
{
var cancellationToken = TestContext.Current.CancellationToken;
var connectionString = await _postgres.CreateDatabaseAsync("quickconnect_replica_reexchange", cancellationToken);
await using var dataSource = new NpgsqlDataSourceBuilder(connectionString).Build();
var user = await CreateSchemaWithUserAsync(dataSource, cancellationToken);
var replicaA = await CreateReplicaAsync(dataSource, user);
var replicaB = await CreateReplicaAsync(dataSource, user);
var replicaC = await CreateReplicaAsync(dataSource, user);
for (var attempt = 0; attempt < 10; attempt++)
{
var authorizationInfo = AuthorizationInfoFor(attempt);
var initiated = await replicaA.Manager.TryConnect(authorizationInfo);
await replicaB.Manager.AuthorizeRequest(user.Id, initiated.Code);
var exchanged = await Task.WhenAll(
Task.Run(() => ExchangeAsync(replicaA.Manager, initiated.Secret), cancellationToken),
Task.Run(() => ExchangeAsync(replicaC.Manager, initiated.Secret), cancellationToken));
Assert.All(exchanged, outcome => Assert.NotNull(outcome));
Assert.Equal(exchanged[0]!.AccessToken, exchanged[1]!.AccessToken);
// Still there afterwards, on a replica that has not exchanged it yet.
var later = await replicaB.Manager.GetAuthorizedRequest(initiated.Secret);
Assert.Equal(exchanged[0]!.AccessToken, later.AccessToken);
var devices = await replicaA.Devices.GetDevices(new DeviceQuery { DeviceId = authorizationInfo.DeviceId });
Assert.Equal(later.AccessToken, Assert.Single(devices.Items).AccessToken);
}
}
/// <summary>
/// Two replicas authorizing one code at the same time mint one access token between them. A second
/// one would be live, attached to the same device and reachable by nobody.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[Fact]
public async Task Authorize_RacedOnTwoReplicas_MintsOneAccessToken()
{
var cancellationToken = TestContext.Current.CancellationToken;
var connectionString = await _postgres.CreateDatabaseAsync("quickconnect_replica_authorize_race", cancellationToken);
await using var dataSource = new NpgsqlDataSourceBuilder(connectionString).Build();
var user = await CreateSchemaWithUserAsync(dataSource, cancellationToken);
var replicaA = await CreateReplicaAsync(dataSource, user);
var replicaB = await CreateReplicaAsync(dataSource, user);
var replicaC = await CreateReplicaAsync(dataSource, user);
for (var attempt = 0; attempt < 20; attempt++)
{
var authorizationInfo = AuthorizationInfoFor(attempt);
var initiated = await replicaA.Manager.TryConnect(authorizationInfo);
var outcomes = await Task.WhenAll(
Task.Run(() => AuthorizeAsync(replicaB.Manager, user.Id, initiated.Code), cancellationToken),
Task.Run(() => AuthorizeAsync(replicaC.Manager, user.Id, initiated.Code), cancellationToken));
Assert.Single(outcomes, authorized => authorized);
var devices = await replicaA.Devices.GetDevices(new DeviceQuery { DeviceId = authorizationInfo.DeviceId });
var device = Assert.Single(devices.Items);
var exchanged = await replicaA.Manager.GetAuthorizedRequest(initiated.Secret);
Assert.Equal(device.AccessToken, exchanged.AccessToken);
}
}
/// <summary>
/// An expired request is rejected on a replica that never saw it created, rather than resolving to a
/// stale authorization.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[Fact]
public async Task ExpiredRequest_IsRejectedOnEveryReplica()
{
var cancellationToken = TestContext.Current.CancellationToken;
var connectionString = await _postgres.CreateDatabaseAsync("quickconnect_replica_expiry", cancellationToken);
await using var dataSource = new NpgsqlDataSourceBuilder(connectionString).Build();
var user = await CreateSchemaWithUserAsync(dataSource, cancellationToken);
var replicaA = await CreateReplicaAsync(dataSource, user);
var replicaB = await CreateReplicaAsync(dataSource, user);
var initiated = await replicaA.Manager.TryConnect(_authorizationInfo);
Assert.NotNull(await replicaB.Manager.CheckRequestStatus(initiated.Secret));
// Shorten the stored expiry instead of waiting out the ten minute timeout.
await replicaA.Store.SetRequestAsync(initiated, DateTime.UtcNow.AddSeconds(1), cancellationToken);
await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken);
await Assert.ThrowsAsync<ResourceNotFoundException>(() => replicaB.Manager.CheckRequestStatus(initiated.Secret));
await Assert.ThrowsAsync<ResourceNotFoundException>(() => replicaB.Manager.AuthorizeRequest(user.Id, initiated.Code));
}
/// <summary>
/// An authorization that was never exchanged expires too, so a code authorized and then abandoned
/// cannot be redeemed later from another replica.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[Fact]
public async Task ExpiredAuthorization_IsRejectedOnEveryReplica()
{
var cancellationToken = TestContext.Current.CancellationToken;
var connectionString = await _postgres.CreateDatabaseAsync("quickconnect_replica_auth_expiry", cancellationToken);
await using var dataSource = new NpgsqlDataSourceBuilder(connectionString).Build();
var user = await CreateSchemaWithUserAsync(dataSource, cancellationToken);
var replicaA = await CreateReplicaAsync(dataSource, user);
var replicaB = await CreateReplicaAsync(dataSource, user);
var initiated = await replicaA.Manager.TryConnect(_authorizationInfo);
await replicaA.Manager.AuthorizeRequest(user.Id, initiated.Code);
var stored = await replicaA.Store.GetRequestBySecretAsync(initiated.Secret, cancellationToken);
Assert.True(stored?.Authenticated);
await replicaA.Store.SetAuthorizationAsync(
initiated.Secret,
new AuthenticationResult { AccessToken = "stale" },
DateTime.UtcNow.AddSeconds(1),
cancellationToken);
await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken);
await Assert.ThrowsAsync<ResourceNotFoundException>(() => replicaB.Manager.GetAuthorizedRequest(initiated.Secret));
}
private static AuthorizationInfo AuthorizationInfoFor(int attempt) => new AuthorizationInfo
{
Device = _authorizationInfo.Device,
DeviceId = string.Create(CultureInfo.InvariantCulture, $"device-{attempt}"),
Client = _authorizationInfo.Client,
Version = _authorizationInfo.Version
};
private static async Task<bool> AuthorizeAsync(IQuickConnect manager, Guid userId, string code)
{
try
{
return await manager.AuthorizeRequest(userId, code).ConfigureAwait(false);
}
catch (ConflictException)
{
return false;
}
}
private static async Task<AuthenticationResult?> ExchangeAsync(IQuickConnect manager, string secret)
{
try
{
return await manager.GetAuthorizedRequest(secret).ConfigureAwait(false);
}
catch (ResourceNotFoundException)
{
return null;
}
}
private static async Task<User> CreateSchemaWithUserAsync(NpgsqlDataSource dataSource, CancellationToken cancellationToken)
{
var context = CreateContext(dataSource);
await using (context.ConfigureAwait(false))
{
await context.Database.EnsureCreatedAsync(cancellationToken).ConfigureAwait(false);
var user = new User("quickconnect-user", "provider", "provider");
context.Users.Add(user);
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
return user;
}
}
private static JellyfinDbContext CreateContext(NpgsqlDataSource dataSource)
{
var optionsBuilder = new DbContextOptionsBuilder<JellyfinDbContext>();
var provider = new PostgreSqlDatabaseProvider(dataSource);
provider.Initialise(optionsBuilder, new DatabaseConfigurationOptions { DatabaseType = "PostgreSQL" });
return new JellyfinDbContext(
optionsBuilder.Options,
NullLogger<JellyfinDbContext>.Instance,
provider,
new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
}
private async Task<Replica> CreateReplicaAsync(NpgsqlDataSource dataSource, User user)
{
var connection = await _redis.ConnectAsync().ConfigureAwait(false);
_connections.Add(connection);
var userManager = new Mock<IUserManager>();
userManager.Setup(manager => manager.GetUserById(user.Id)).Returns(user);
var deviceManager = new DeviceManager(new DataSourceContextFactory(dataSource), userManager.Object);
var configManager = new Mock<IServerConfigurationManager>();
configManager.Setup(manager => manager.Configuration).Returns(new ServerConfiguration { QuickConnectAvailable = true });
// Stands in for SessionManager.AuthenticateDirect: the token has to be minted into the shared
// database, because the replica that exchanges the secret is not the one that authorized it.
var sessionManager = new Mock<ISessionManager>();
sessionManager
.Setup(manager => manager.AuthenticateDirect(It.IsAny<AuthenticationRequest>()))
.Returns<AuthenticationRequest>(async request =>
{
var device = await deviceManager.CreateDevice(
new Device(request.UserId, request.App, request.AppVersion, request.DeviceName, request.DeviceId)).ConfigureAwait(false);
return new AuthenticationResult
{
AccessToken = device.AccessToken,
ServerId = "server-1",
User = new UserDto { Id = user.Id, Name = user.Username, ServerId = "server-1" },
SessionInfo = new SessionInfoDto
{
Id = device.Id.ToString(CultureInfo.InvariantCulture),
UserId = user.Id,
UserName = user.Username,
Client = request.App,
DeviceId = request.DeviceId,
DeviceName = request.DeviceName,
ApplicationVersion = request.AppVersion
}
};
});
var store = new RedisQuickConnectStore(connection, NullLogger<RedisQuickConnectStore>.Instance);
var manager = new QuickConnectManager(
configManager.Object,
NullLogger<QuickConnectManager>.Instance,
sessionManager.Object,
store);
return new Replica(manager, store, deviceManager);
}
private sealed record Replica(IQuickConnect Manager, IQuickConnectStore Store, IDeviceManager Devices);
private sealed class DataSourceContextFactory : IDbContextFactory<JellyfinDbContext>
{
private readonly NpgsqlDataSource _dataSource;
public DataSourceContextFactory(NpgsqlDataSource dataSource)
{
_dataSource = dataSource;
}
public JellyfinDbContext CreateDbContext() => CreateContext(_dataSource);
}
}
@@ -0,0 +1,261 @@
using System;
using System.IO;
using System.Threading.Tasks;
using Emby.Server.Implementations.QuickConnect;
using Jellyfin.Api.Controllers;
using Jellyfin.Api.Middleware;
using Jellyfin.Server.Tests.HighAvailability;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Authentication;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Net;
using MediaBrowser.Controller.QuickConnect;
using MediaBrowser.Controller.Session;
using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.QuickConnect;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using StackExchange.Redis;
using Xunit;
namespace Jellyfin.Server.Tests.QuickConnect;
/// <summary>
/// The status a client actually sees. A missing key and an unreachable valkey are different answers and
/// must not collapse into one: telling a polling client its secret is unknown ends its flow, while 503
/// tells it to keep trying. The exception the store really throws is run through the real exception
/// middleware, so the mapping is exercised rather than assumed.
/// </summary>
[Trait("Category", "RequiresDocker")]
public sealed class QuickConnectStatusCodeTests : IAsyncLifetime
{
private readonly Mock<ISessionManager> _sessionManager = new();
private RedisTestServer _redis = null!;
private RedisFaultProxy _proxy = null!;
private IConnectionMultiplexer _connection = null!;
private QuickConnectManager _manager = null!;
private QuickConnectController _controller = null!;
/// <inheritdoc/>
public async ValueTask InitializeAsync()
{
_redis = await RedisTestServer.StartAsync().ConfigureAwait(false);
_proxy = RedisFaultProxy.Start(_redis.ConnectionString);
_connection = await ConnectionMultiplexer.ConnectAsync(_proxy.ConnectionString).ConfigureAwait(false);
var configManager = new Mock<IServerConfigurationManager>();
configManager.Setup(manager => manager.Configuration).Returns(new ServerConfiguration { QuickConnectAvailable = true });
_manager = new QuickConnectManager(
configManager.Object,
NullLogger<QuickConnectManager>.Instance,
_sessionManager.Object,
new RedisQuickConnectStore(_connection, NullLogger<RedisQuickConnectStore>.Instance));
_controller = new QuickConnectController(_manager, Mock.Of<IAuthorizationContext>());
}
/// <inheritdoc/>
public async ValueTask DisposeAsync()
{
await _connection.DisposeAsync().ConfigureAwait(false);
await _proxy.DisposeAsync().ConfigureAwait(false);
await _redis.DisposeAsync().ConfigureAwait(false);
}
/// <summary>
/// A secret valkey has never heard of is a 404, which is what ends a flow the user abandoned.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[Fact]
public async Task Poll_UnknownSecret_IsNotFound()
{
Assert.Equal(
StatusCodes.Status404NotFound,
await StatusCodeAsync(async () => StatusOf(await _controller.GetQuickConnectState(NewSecret()))));
}
/// <summary>
/// The same poll while valkey is unreachable is a 503. This is the bug the shared store is here to
/// avoid: a blip must not tell every polling client that its secret is invalid.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[Fact]
public async Task Poll_WhileRedisIsUnreachable_IsServiceUnavailable()
{
var secret = await InitiateAsync();
_proxy.Cut();
Assert.Equal(
StatusCodes.Status503ServiceUnavailable,
await StatusCodeAsync(async () => StatusOf(await _controller.GetQuickConnectState(secret))));
}
/// <summary>
/// The exchange leg tells the two apart the same way.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[Fact]
public async Task Exchange_UnknownSecret_IsNotFound()
{
Assert.Equal(
StatusCodes.Status404NotFound,
await StatusCodeAsync(async () =>
{
await _manager.GetAuthorizedRequest(NewSecret()).ConfigureAwait(false);
return StatusCodes.Status200OK;
}));
}
/// <summary>
/// The exchange leg while valkey is unreachable.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[Fact]
public async Task Exchange_WhileRedisIsUnreachable_IsServiceUnavailable()
{
var secret = await InitiateAsync();
_proxy.Cut();
Assert.Equal(
StatusCodes.Status503ServiceUnavailable,
await StatusCodeAsync(async () =>
{
await _manager.GetAuthorizedRequest(secret).ConfigureAwait(false);
return StatusCodes.Status200OK;
}));
}
/// <summary>
/// The authorize leg while valkey is unreachable.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[Fact]
public async Task Authorize_WhileRedisIsUnreachable_IsServiceUnavailable()
{
var initiated = await _manager.TryConnect(AuthorizationInfo());
_proxy.Cut();
Assert.Equal(
StatusCodes.Status503ServiceUnavailable,
await StatusCodeAsync(async () =>
{
await _manager.AuthorizeRequest(Guid.NewGuid(), initiated.Code).ConfigureAwait(false);
return StatusCodes.Status200OK;
}));
}
/// <summary>
/// A mint that threw leaves its claim taken on purpose, because the write it failed on may have
/// landed. Retrying then has to say so and be a 409 the client can act on, not a 500 and not the
/// untrue claim that the request is already authorized.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[Fact]
public async Task Authorize_AfterAMintThatFailed_IsConflictAndSaysToStartAgain()
{
var initiated = await _manager.TryConnect(AuthorizationInfo());
_sessionManager
.Setup(manager => manager.AuthenticateDirect(It.IsAny<AuthenticationRequest>()))
.ThrowsAsync(new InvalidOperationException("mint failed"));
await Assert.ThrowsAsync<InvalidOperationException>(() => _manager.AuthorizeRequest(Guid.NewGuid(), initiated.Code));
var retry = await Record.ExceptionAsync(() => _manager.AuthorizeRequest(Guid.NewGuid(), initiated.Code));
var conflict = Assert.IsType<ConflictException>(retry);
Assert.DoesNotContain("already authorized", conflict.Message, StringComparison.Ordinal);
Assert.Contains("Start quick connect again", conflict.Message, StringComparison.Ordinal);
Assert.Equal(
StatusCodes.Status409Conflict,
await StatusCodeAsync(async () =>
{
await _manager.AuthorizeRequest(Guid.NewGuid(), initiated.Code).ConfigureAwait(false);
return StatusCodes.Status200OK;
}));
}
/// <summary>
/// A request that really was authorized still says so, so the accurate message above is not just a
/// blanket replacement for the old one.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[Fact]
public async Task Authorize_OfAnAuthorizedRequest_IsConflictAndSaysAlreadyAuthorized()
{
var initiated = await _manager.TryConnect(AuthorizationInfo());
var userId = Guid.NewGuid();
_sessionManager
.Setup(manager => manager.AuthenticateDirect(It.IsAny<AuthenticationRequest>()))
.ReturnsAsync(new AuthenticationResult
{
AccessToken = "token-1",
ServerId = "server-1",
User = new UserDto { Id = userId, Name = "user", ServerId = "server-1" }
});
Assert.True(await _manager.AuthorizeRequest(userId, initiated.Code));
var retry = await Record.ExceptionAsync(() => _manager.AuthorizeRequest(userId, initiated.Code));
Assert.Equal("Request is already authorized", Assert.IsType<ConflictException>(retry).Message);
}
private static AuthorizationInfo AuthorizationInfo() => new AuthorizationInfo
{
Device = "Living Room TV",
DeviceId = Guid.NewGuid().ToString("N"),
Client = "Jellyfin Web",
Version = "1.0.0"
};
private static string NewSecret() => Guid.NewGuid().ToString("N");
private static int StatusOf(ActionResult<QuickConnectResult> result)
=> result.Result is IStatusCodeActionResult status
? status.StatusCode ?? StatusCodes.Status200OK
: StatusCodes.Status200OK;
private static async Task<int> StatusCodeAsync(Func<Task<int>> action)
{
var appPaths = new Mock<IServerApplicationPaths>();
appPaths.Setup(paths => paths.ProgramSystemPath).Returns("/program");
appPaths.Setup(paths => paths.ProgramDataPath).Returns("/data");
var configManager = new Mock<IServerConfigurationManager>();
configManager.Setup(manager => manager.ApplicationPaths).Returns(appPaths.Object);
var hostEnvironment = new Mock<IWebHostEnvironment>();
hostEnvironment.SetupGet(environment => environment.EnvironmentName).Returns(Environments.Production);
var context = new DefaultHttpContext();
context.Response.Body = new MemoryStream();
var middleware = new ExceptionMiddleware(
async _ =>
{
context.Response.StatusCode = await action().ConfigureAwait(false);
},
NullLogger<ExceptionMiddleware>.Instance,
configManager.Object,
hostEnvironment.Object);
await middleware.Invoke(context).ConfigureAwait(false);
return context.Response.StatusCode;
}
private async Task<string> InitiateAsync()
=> (await _manager.TryConnect(AuthorizationInfo()).ConfigureAwait(false)).Secret;
}
@@ -0,0 +1,147 @@
using System;
using System.IO;
using System.Threading.Tasks;
using Emby.Server.Implementations.QuickConnect;
using Jellyfin.Server.Extensions;
using Jellyfin.Server.Tests.HighAvailability;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.Authentication;
using MediaBrowser.Controller.QuickConnect;
using MediaBrowser.Model.QuickConnect;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using StackExchange.Redis;
using Xunit;
namespace Jellyfin.Server.Tests.QuickConnect;
/// <summary>
/// Drives the whole configuration path a deployment uses: a bare
/// <c>Jellyfin__TranscodeStore__RedisConnectionString</c> environment variable, the server's own
/// configuration builder, the store registration, and a quick connect flow against a real valkey.
/// </summary>
[Trait("Category", "RequiresDocker")]
[Collection("JellyfinSectionConfiguration")]
public sealed class QuickConnectStoreWiringTests : IAsyncLifetime
{
private const string RedisConnectionStringVariable = "Jellyfin__TranscodeStore__RedisConnectionString";
private RedisTestServer _redis = null!;
private string _configDirectory = string.Empty;
/// <inheritdoc/>
public async ValueTask InitializeAsync()
{
_redis = await RedisTestServer.StartAsync().ConfigureAwait(false);
_configDirectory = Directory.CreateTempSubdirectory("jellyfin-quickconnect-wiring").FullName;
await File.WriteAllTextAsync(Path.Combine(_configDirectory, "logging.default.json"), "{}").ConfigureAwait(false);
}
/// <inheritdoc/>
public async ValueTask DisposeAsync()
{
Environment.SetEnvironmentVariable(RedisConnectionStringVariable, null);
if (_configDirectory.Length > 0)
{
Directory.Delete(_configDirectory, true);
}
await _redis.DisposeAsync().ConfigureAwait(false);
}
/// <summary>
/// The variable form deployments set selects the shared store, and that store really talks to valkey.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[Fact]
public async Task ManifestStyleEnvironmentVariable_SelectsTheSharedStore()
{
Environment.SetEnvironmentVariable(RedisConnectionStringVariable, _redis.ConnectionString);
await using var provider = BuildProvider();
var store = provider.GetRequiredService<IQuickConnectStore>();
Assert.IsType<RedisQuickConnectStore>(store);
var request = NewRequest();
await store.SetRequestAsync(request, DateTime.UtcNow.AddMinutes(10), TestContext.Current.CancellationToken);
Assert.True(await store.TryClaimAuthorizationAsync(request.Secret, DateTime.UtcNow.AddMinutes(10), TestContext.Current.CancellationToken));
var redis = provider.GetRequiredService<IConnectionMultiplexer>();
Assert.True(await redis.GetDatabase().KeyExistsAsync("jellyfin:quickconnect:request:" + request.Secret));
}
/// <summary>
/// Without the variable the deployment is single-instance and gets the process-local store, which
/// runs a whole flow on its own.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[Fact]
public async Task NoEnvironmentVariable_SelectsTheProcessLocalStore()
{
Environment.SetEnvironmentVariable(RedisConnectionStringVariable, null);
await using var provider = BuildProvider();
var store = provider.GetRequiredService<IQuickConnectStore>();
Assert.IsType<InMemoryQuickConnectStore>(store);
var cancellationToken = TestContext.Current.CancellationToken;
var request = NewRequest();
await store.SetRequestAsync(request, DateTime.UtcNow.AddMinutes(10), cancellationToken);
Assert.Equal(request.Secret, (await store.GetRequestByCodeAsync(request.Code, cancellationToken))?.Secret);
Assert.True(await store.TryClaimAuthorizationAsync(request.Secret, DateTime.UtcNow.AddMinutes(10), cancellationToken));
await store.SetAuthorizationAsync(
request.Secret,
new AuthenticationResult { AccessToken = "token-1" },
DateTime.UtcNow.AddMinutes(10),
cancellationToken);
Assert.Equal("token-1", (await store.GetAuthorizationAsync(request.Secret, cancellationToken))?.AccessToken);
Assert.Equal("token-1", (await store.GetAuthorizationAsync(request.Secret, cancellationToken))?.AccessToken);
}
/// <summary>
/// A connection string that is set but unreachable is a misconfigured multi-instance deployment. It
/// fails rather than handing out a store the other instances cannot see, which would put quick
/// connect back on the cross-instance behaviour this configuration exists to fix.
/// </summary>
[Fact]
public void UnreachableRedisAtStartup_FailsClosed()
{
Environment.SetEnvironmentVariable(RedisConnectionStringVariable, "127.0.0.1:1,connectTimeout=250,connectRetry=0");
using var provider = BuildProvider();
Assert.ThrowsAny<RedisConnectionException>(() => provider.GetRequiredService<IQuickConnectStore>());
}
private static QuickConnectResult NewRequest() => new QuickConnectResult(
Guid.NewGuid().ToString("N"),
Guid.NewGuid().ToString("N").Substring(0, 6),
DateTime.UtcNow,
"device-1",
"Living Room TV",
"Jellyfin Web",
"1.0.0");
private ServiceProvider BuildProvider()
{
var appPaths = new Mock<IApplicationPaths>();
appPaths.Setup(paths => paths.ConfigurationDirectoryPath).Returns(_configDirectory);
IConfiguration configuration = Jellyfin.Server.Program.CreateAppConfiguration(new StartupOptions(), appPaths.Object);
var services = new ServiceCollection();
services.AddLogging();
services.AddTranscodeSessionStore(configuration, NullLogger.Instance);
services.AddQuickConnectStore(configuration, NullLogger.Instance);
return services.BuildServiceProvider();
}
}
@@ -0,0 +1,302 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Emby.Server.Implementations.QuickConnect;
using Jellyfin.Server.Tests.HighAvailability;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller.Authentication;
using MediaBrowser.Controller.QuickConnect;
using MediaBrowser.Model.QuickConnect;
using Microsoft.Extensions.Logging.Abstractions;
using StackExchange.Redis;
using Xunit;
namespace Jellyfin.Server.Tests.QuickConnect;
/// <summary>
/// What a <see cref="RedisQuickConnectStore"/> does while its Redis is unreachable. Each instance talks
/// to the one real server through a proxy of its own, so an outage can be given to one instance and not
/// the others, and then taken back.
/// </summary>
[Trait("Category", "RequiresDocker")]
public sealed class RedisQuickConnectStoreDegradedTests : IAsyncLifetime
{
private readonly List<RedisFaultProxy> _proxies = new();
private readonly List<IConnectionMultiplexer> _connections = new();
private RedisTestServer _redis = null!;
private static CancellationToken CancellationToken => TestContext.Current.CancellationToken;
/// <inheritdoc/>
public async ValueTask InitializeAsync()
{
_redis = await RedisTestServer.StartAsync().ConfigureAwait(false);
}
/// <inheritdoc/>
public async ValueTask DisposeAsync()
{
foreach (var connection in _connections)
{
await connection.DisposeAsync().ConfigureAwait(false);
}
foreach (var proxy in _proxies)
{
await proxy.DisposeAsync().ConfigureAwait(false);
}
await _redis.DisposeAsync().ConfigureAwait(false);
}
/// <summary>
/// Every leg of the flow fails closed while Redis is unreachable. None of them may answer as though
/// Redis had said the request is unknown, because that tells a polling client its secret is invalid.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[Fact]
public async Task EveryLeg_WhileRedisIsUnreachable_ReportsUnavailable()
{
var instance = await CreateInstanceAsync();
var request = NewRequest();
var expiresUtc = DateTime.UtcNow.AddMinutes(10);
instance.Proxy.Cut();
await AssertUnavailableAsync(() => instance.Store.SetRequestAsync(request, expiresUtc, CancellationToken));
await AssertUnavailableAsync(() => instance.Store.GetRequestBySecretAsync(request.Secret, CancellationToken));
await AssertUnavailableAsync(() => instance.Store.GetRequestByCodeAsync(request.Code, CancellationToken));
await AssertUnavailableAsync(() => instance.Store.TryClaimAuthorizationAsync(request.Secret, expiresUtc, CancellationToken));
await AssertUnavailableAsync(() => instance.Store.SetAuthorizationAsync(
request.Secret,
new AuthenticationResult { AccessToken = "token-1" },
expiresUtc,
CancellationToken));
await AssertUnavailableAsync(() => instance.Store.GetAuthorizationAsync(request.Secret, CancellationToken));
}
/// <summary>
/// The same three reads against a Redis that is answering report a genuine miss as a miss, which is
/// what makes an outage and an unknown secret tellable apart by the callers above.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[Fact]
public async Task EveryRead_AgainstAHealthyRedis_ReportsAMissAsAMiss()
{
var instance = await CreateInstanceAsync();
var request = NewRequest();
Assert.Null(await instance.Store.GetRequestBySecretAsync(request.Secret, CancellationToken));
Assert.Null(await instance.Store.GetRequestByCodeAsync(request.Code, CancellationToken));
Assert.Null(await instance.Store.GetAuthorizationAsync(request.Secret, CancellationToken));
}
/// <summary>
/// A request is resolvable by its secret and by its code together or not at all: a failure part way
/// through storing it must not leave a code on the user's screen that resolves to nothing for the
/// whole ten minutes the poll keeps succeeding.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[Fact]
public async Task PendingRequest_WhenRedisGoesAwayMidWrite_IsResolvableByBothKeysOrNeither()
{
var instance = await CreateInstanceAsync();
var request = NewRequest();
// Redis is taken away the instant after it has applied the write of the secret key, which is
// where a two round trip write loses the code key.
instance.Proxy.CutAfterForwarding("request:" + request.Secret);
await Record.ExceptionAsync(() => instance.Store.SetRequestAsync(request, DateTime.UtcNow.AddMinutes(10), CancellationToken));
// Read straight from the server: the instance's own connection is the one that was cut.
var direct = await _redis.ConnectAsync();
_connections.Add(direct);
var bySecret = await direct.GetDatabase().KeyExistsAsync("jellyfin:quickconnect:request:" + request.Secret);
var byCode = await direct.GetDatabase().KeyExistsAsync("jellyfin:quickconnect:code:" + request.Code);
Assert.Equal(bySecret, byCode);
}
/// <summary>
/// A malformed stored value is a fault of its own, not Redis being unavailable, so it is not reported
/// as either a miss or an outage.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[Fact]
public async Task PendingRequest_ThatIsMalformedInRedis_SurfacesAsItsOwnFault()
{
var instance = await CreateInstanceAsync();
var request = NewRequest();
await instance.Connection.GetDatabase().StringSetAsync(
"jellyfin:quickconnect:request:" + request.Secret,
"{ not json",
TimeSpan.FromMinutes(10));
await Assert.ThrowsAsync<JsonException>(() => instance.Store.GetRequestBySecretAsync(request.Secret, CancellationToken));
}
/// <summary>
/// A store whose Redis comes back answers from Redis again, with nothing carried over from the outage.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[Fact]
public async Task Store_AfterAnOutage_WorksAgain()
{
var instance = await CreateInstanceAsync();
var request = NewRequest();
instance.Proxy.Cut();
await AssertUnavailableAsync(() => instance.Store.SetRequestAsync(request, DateTime.UtcNow.AddMinutes(10), CancellationToken));
await RestoreAsync(instance);
Assert.Null(await instance.Store.GetRequestBySecretAsync(request.Secret, CancellationToken));
await instance.Store.SetRequestAsync(request, DateTime.UtcNow.AddMinutes(10), CancellationToken);
Assert.Equal(request.Secret, (await instance.Store.GetRequestByCodeAsync(request.Code, CancellationToken))?.Secret);
}
/// <summary>
/// Two instances racing to authorize one request: exactly one of them may go on to mint a token.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[Fact]
public async Task Claim_RacedOnTwoInstances_SucceedsOnce()
{
var first = await CreateInstanceAsync();
var second = await CreateInstanceAsync();
for (var attempt = 0; attempt < 25; attempt++)
{
var request = NewRequest();
var expiresUtc = DateTime.UtcNow.AddMinutes(10);
await first.Store.SetRequestAsync(request, expiresUtc, CancellationToken);
var claims = await Task.WhenAll(
Task.Run(() => first.Store.TryClaimAuthorizationAsync(request.Secret, expiresUtc, CancellationToken), CancellationToken),
Task.Run(() => second.Store.TryClaimAuthorizationAsync(request.Secret, expiresUtc, CancellationToken), CancellationToken));
Assert.Single(claims, claimed => claimed);
}
}
/// <summary>
/// A request that is unknown, already claimed or already authorized cannot be claimed.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[Fact]
public async Task Claim_IsRefused_ForUnknownClaimedAndAuthorizedRequests()
{
var instance = await CreateInstanceAsync();
var expiresUtc = DateTime.UtcNow.AddMinutes(10);
Assert.False(await instance.Store.TryClaimAuthorizationAsync("unknown-secret", expiresUtc, CancellationToken));
var request = NewRequest();
await instance.Store.SetRequestAsync(request, expiresUtc, CancellationToken);
Assert.True(await instance.Store.TryClaimAuthorizationAsync(request.Secret, expiresUtc, CancellationToken));
Assert.False(await instance.Store.TryClaimAuthorizationAsync(request.Secret, expiresUtc, CancellationToken));
var authorized = NewRequest();
authorized.Authenticated = true;
await instance.Store.SetRequestAsync(authorized, expiresUtc, CancellationToken);
Assert.False(await instance.Store.TryClaimAuthorizationAsync(authorized.Secret, expiresUtc, CancellationToken));
}
/// <summary>
/// An authorization is read, not spent: the same secret exchanged again on the same instance returns
/// the same access token for as long as the authorization lives.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[Fact]
public async Task Authorization_IsReadRepeatedly_WithoutBeingSpent()
{
var instance = await CreateInstanceAsync();
var request = NewRequest();
await instance.Store.SetAuthorizationAsync(
request.Secret,
new AuthenticationResult { AccessToken = "token-1" },
DateTime.UtcNow.AddMinutes(10),
CancellationToken);
Assert.Equal("token-1", (await instance.Store.GetAuthorizationAsync(request.Secret, CancellationToken))?.AccessToken);
Assert.Equal("token-1", (await instance.Store.GetAuthorizationAsync(request.Secret, CancellationToken))?.AccessToken);
}
/// <summary>
/// A write whose expiry has already passed is ignored, by the shared store and the process-local one
/// alike: a deployment must not get a different answer out of the two.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[Fact]
public async Task ElapsedExpiry_IsIgnoredByBothStores()
{
var shared = (await CreateInstanceAsync()).Store;
var local = new InMemoryQuickConnectStore();
foreach (var store in new IQuickConnectStore[] { shared, local })
{
var request = NewRequest();
var elapsed = DateTime.UtcNow.AddSeconds(-1);
await store.SetRequestAsync(request, elapsed, CancellationToken);
await store.SetAuthorizationAsync(request.Secret, new AuthenticationResult { AccessToken = "token-1" }, elapsed, CancellationToken);
Assert.Null(await store.GetRequestBySecretAsync(request.Secret, CancellationToken));
Assert.Null(await store.GetRequestByCodeAsync(request.Code, CancellationToken));
Assert.Null(await store.GetAuthorizationAsync(request.Secret, CancellationToken));
}
}
private static QuickConnectResult NewRequest() => new QuickConnectResult(
Guid.NewGuid().ToString("N"),
Guid.NewGuid().ToString("N").Substring(0, 6),
DateTime.UtcNow,
"device-1",
"Living Room TV",
"Jellyfin Web",
"1.0.0");
private static async Task AssertUnavailableAsync(Func<Task> operation)
{
var exception = await Record.ExceptionAsync(operation);
Assert.NotNull(exception);
Assert.IsType<ServiceUnavailableException>(exception);
}
private static async Task RestoreAsync(Instance instance)
{
instance.Proxy.Restore();
for (var attempt = 1; ; attempt++)
{
try
{
await instance.Connection.GetDatabase().PingAsync();
return;
}
catch (Exception exception) when (exception is RedisException or TimeoutException && attempt < 60)
{
await Task.Delay(TimeSpan.FromMilliseconds(500), CancellationToken);
}
}
}
private async Task<Instance> CreateInstanceAsync()
{
var proxy = RedisFaultProxy.Start(_redis.ConnectionString);
_proxies.Add(proxy);
var connection = await ConnectionMultiplexer.ConnectAsync(proxy.ConnectionString).ConfigureAwait(false);
_connections.Add(connection);
return new Instance(proxy, connection, new RedisQuickConnectStore(connection, NullLogger<RedisQuickConnectStore>.Instance));
}
private sealed record Instance(RedisFaultProxy Proxy, IConnectionMultiplexer Connection, RedisQuickConnectStore Store);
}