fix(devices): read devices and device options through to the database #8

Merged
benvin merged 1 commits from benvin/fix-device-cache into main 2026-09-13 14:23:18 +10:00
7 changed files with 406 additions and 177 deletions
@@ -256,7 +256,7 @@ namespace Emby.Server.Implementations.Session
ArgumentException.ThrowIfNullOrEmpty(deviceId);
var activityDate = DateTime.UtcNow;
var session = GetSessionInfo(appName, appVersion, deviceId, deviceName, remoteEndPoint, user);
var session = await GetSessionInfo(appName, appVersion, deviceId, deviceName, remoteEndPoint, user).ConfigureAwait(false);
var lastActivityDate = session.LastActivityDate;
session.LastActivityDate = activityDate;
@@ -491,7 +491,7 @@ namespace Emby.Server.Implementations.Session
/// <param name="remoteEndPoint">The remote end point.</param>
/// <param name="user">The user.</param>
/// <returns>SessionInfo.</returns>
private SessionInfo GetSessionInfo(
private async Task<SessionInfo> GetSessionInfo(
string appName,
string appVersion,
string deviceId,
@@ -504,7 +504,7 @@ namespace Emby.Server.Implementations.Session
ArgumentException.ThrowIfNullOrEmpty(deviceId);
var key = GetSessionKey(appName, deviceId, user?.Id ?? Guid.Empty);
SessionInfo newSession = CreateSessionInfo(key, appName, appVersion, deviceId, deviceName, remoteEndPoint, user);
SessionInfo newSession = await CreateSessionInfo(key, appName, appVersion, deviceId, deviceName, remoteEndPoint, user).ConfigureAwait(false);
SessionInfo sessionInfo = _activeConnections.GetOrAdd(key, newSession);
if (ReferenceEquals(newSession, sessionInfo))
{
@@ -532,7 +532,7 @@ namespace Emby.Server.Implementations.Session
return sessionInfo;
}
private SessionInfo CreateSessionInfo(
private async Task<SessionInfo> CreateSessionInfo(
string key,
string appName,
string appVersion,
@@ -562,7 +562,7 @@ namespace Emby.Server.Implementations.Session
deviceName = "Network Device";
}
var deviceOptions = _deviceManager.GetDeviceOptions(deviceId) ?? new()
var deviceOptions = await _deviceManager.GetDeviceOptions(deviceId).ConfigureAwait(false) ?? new()
{
DeviceId = deviceId
};
@@ -1768,12 +1768,12 @@ namespace Emby.Server.Implementations.Session
// This should be validated above, but if it isn't don't delete all tokens.
ArgumentException.ThrowIfNullOrEmpty(deviceId);
var existing = _deviceManager.GetDevices(
var existing = (await _deviceManager.GetDevices(
new DeviceQuery
{
DeviceId = deviceId,
UserId = user.Id
}).Items;
}).ConfigureAwait(false)).Items;
foreach (var auth in existing)
{
@@ -1801,12 +1801,12 @@ namespace Emby.Server.Implementations.Session
ArgumentException.ThrowIfNullOrEmpty(accessToken);
var existing = _deviceManager.GetDevices(
var existing = (await _deviceManager.GetDevices(
new DeviceQuery
{
Limit = 1,
AccessToken = accessToken
}).Items;
}).ConfigureAwait(false)).Items;
if (existing.Count > 0)
{
@@ -1845,10 +1845,10 @@ namespace Emby.Server.Implementations.Session
{
CheckDisposed();
var existing = _deviceManager.GetDevices(new DeviceQuery
var existing = await _deviceManager.GetDevices(new DeviceQuery
{
UserId = userId
});
}).ConfigureAwait(false);
foreach (var info in existing.Items)
{
@@ -2045,11 +2045,11 @@ namespace Emby.Server.Implementations.Session
/// <inheritdoc />
public async Task<SessionInfo> GetSessionByAuthenticationToken(string token, string deviceId, string remoteEndpoint)
{
var items = _deviceManager.GetDevices(new DeviceQuery
var items = (await _deviceManager.GetDevices(new DeviceQuery
{
AccessToken = token,
Limit = 1
}).Items;
}).ConfigureAwait(false)).Items;
if (items.Count == 0)
{
+13 -8
View File
@@ -50,10 +50,10 @@ public class DevicesController : BaseJellyfinApiController
/// <returns>An <see cref="OkResult"/> containing the list of devices.</returns>
[HttpGet]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<QueryResult<DeviceInfoDto>> GetDevices([FromQuery] Guid? userId)
public async Task<ActionResult<QueryResult<DeviceInfoDto>>> GetDevices([FromQuery] Guid? userId)
{
userId = RequestHelpers.GetUserId(User, userId);
return _deviceManager.GetDevicesForUser(userId);
return await _deviceManager.GetDevicesForUser(userId).ConfigureAwait(false);
}
/// <summary>
@@ -66,9 +66,9 @@ public class DevicesController : BaseJellyfinApiController
[HttpGet("Info")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult<DeviceInfoDto> GetDeviceInfo([FromQuery, Required] string id)
public async Task<ActionResult<DeviceInfoDto>> GetDeviceInfo([FromQuery, Required] string id)
{
var deviceInfo = _deviceManager.GetDevice(id);
var deviceInfo = await _deviceManager.GetDevice(id).ConfigureAwait(false);
if (deviceInfo is null)
{
return NotFound();
@@ -87,9 +87,9 @@ public class DevicesController : BaseJellyfinApiController
[HttpGet("Options")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult<DeviceOptionsDto> GetDeviceOptions([FromQuery, Required] string id)
public async Task<ActionResult<DeviceOptionsDto>> GetDeviceOptions([FromQuery, Required] string id)
{
var deviceInfo = _deviceManager.GetDeviceOptions(id);
var deviceInfo = await _deviceManager.GetDeviceOptions(id).ConfigureAwait(false);
if (deviceInfo is null)
{
return NotFound();
@@ -127,7 +127,12 @@ public class DevicesController : BaseJellyfinApiController
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult> DeleteDevice([FromQuery] string[] id)
{
var devices = id.Select(_deviceManager.GetDevice).ToArray();
var devices = new List<DeviceInfoDto?>(id.Length);
foreach (var deviceId in id)
{
devices.Add(await _deviceManager.GetDevice(deviceId).ConfigureAwait(false));
}
if (devices.Any(f => f is null))
{
return BadRequest();
@@ -135,7 +140,7 @@ public class DevicesController : BaseJellyfinApiController
foreach (var device in devices)
{
var sessions = _deviceManager.GetDevices(new DeviceQuery { DeviceId = device!.Id });
var sessions = await _deviceManager.GetDevices(new DeviceQuery { DeviceId = device!.Id }).ConfigureAwait(false);
foreach (var session in sessions.Items)
{
@@ -31,8 +31,6 @@ namespace Jellyfin.Server.Implementations.Devices
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
private readonly IUserManager _userManager;
private readonly ConcurrentDictionary<string, ClientCapabilities> _capabilitiesMap = new();
private readonly ConcurrentDictionary<int, Device> _devices;
private readonly ConcurrentDictionary<string, DeviceOptions> _deviceOptions;
/// <summary>
/// Initializes a new instance of the <see cref="DeviceManager"/> class.
@@ -43,23 +41,6 @@ namespace Jellyfin.Server.Implementations.Devices
{
_dbProvider = dbProvider;
_userManager = userManager;
_devices = new ConcurrentDictionary<int, Device>();
_deviceOptions = new ConcurrentDictionary<string, DeviceOptions>();
using var dbContext = _dbProvider.CreateDbContext();
foreach (var device in dbContext.Devices
.OrderBy(d => d.Id)
.AsEnumerable())
{
_devices.TryAdd(device.Id, device);
}
foreach (var deviceOption in dbContext.DeviceOptions
.OrderBy(d => d.Id)
.AsEnumerable())
{
_deviceOptions.TryAdd(deviceOption.DeviceId, deviceOption);
}
}
/// <inheritdoc />
@@ -89,8 +70,6 @@ namespace Jellyfin.Server.Implementations.Devices
await dbContext.SaveChangesAsync().ConfigureAwait(false);
}
_deviceOptions[deviceId] = deviceOptions;
DeviceOptionsUpdated?.Invoke(this, new GenericEventArgs<Tuple<string, DeviceOptions>>(new Tuple<string, DeviceOptions>(deviceId, deviceOptions)));
}
@@ -102,21 +81,24 @@ namespace Jellyfin.Server.Implementations.Devices
{
dbContext.Devices.Add(device);
await dbContext.SaveChangesAsync().ConfigureAwait(false);
_devices.TryAdd(device.Id, device);
}
return device;
}
/// <inheritdoc />
public DeviceOptionsDto? GetDeviceOptions(string deviceId)
public async Task<DeviceOptionsDto?> GetDeviceOptions(string deviceId)
{
if (_deviceOptions.TryGetValue(deviceId, out var deviceOptions))
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
return ToDeviceOptionsDto(deviceOptions);
}
var deviceOptions = await dbContext.DeviceOptions
.AsNoTracking()
.FirstOrDefaultAsync(dev => dev.DeviceId == deviceId)
.ConfigureAwait(false);
return null;
return deviceOptions is null ? null : ToDeviceOptionsDto(deviceOptions);
}
}
/// <inheritdoc />
@@ -133,43 +115,79 @@ namespace Jellyfin.Server.Implementations.Devices
}
/// <inheritdoc />
public DeviceInfoDto? GetDevice(string id)
public async Task<DeviceInfoDto?> GetDevice(string id)
{
var device = _devices.Values.Where(d => d.DeviceId == id).OrderByDescending(d => d.DateLastActivity).FirstOrDefault();
_deviceOptions.TryGetValue(id, out var deviceOption);
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
var device = await dbContext.Devices
.AsNoTracking()
.Where(d => d.DeviceId == id)
.OrderByDescending(d => d.DateLastActivity)
.FirstOrDefaultAsync()
.ConfigureAwait(false);
var deviceInfo = device is null ? null : ToDeviceInfo(device, deviceOption);
return deviceInfo is null ? null : ToDeviceInfoDto(deviceInfo);
if (device is null)
{
return null;
}
var deviceOption = await dbContext.DeviceOptions
.AsNoTracking()
.FirstOrDefaultAsync(dev => dev.DeviceId == id)
.ConfigureAwait(false);
return ToDeviceInfoDto(ToDeviceInfo(device, deviceOption));
}
}
/// <inheritdoc />
public QueryResult<Device> GetDevices(DeviceQuery query)
public async Task<QueryResult<Device>> GetDevices(DeviceQuery query)
{
IEnumerable<Device> devices = _devices.Values
.Where(device => !query.UserId.HasValue || device.UserId.Equals(query.UserId.Value))
.Where(device => query.DeviceId is null || device.DeviceId == query.DeviceId)
.Where(device => query.AccessToken is null || device.AccessToken == query.AccessToken)
.OrderBy(d => d.Id)
.ToList();
var count = devices.Count();
if (query.Skip.HasValue)
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
devices = devices.Skip(query.Skip.Value);
}
IQueryable<Device> filtered = dbContext.Devices.AsNoTracking();
if (query.Limit.HasValue && query.Limit.Value > 0)
{
devices = devices.Take(query.Limit.Value);
}
if (query.UserId.HasValue)
{
filtered = filtered.Where(device => device.UserId.Equals(query.UserId.Value));
}
return new QueryResult<Device>(query.Skip, count, devices.ToList());
if (query.DeviceId is not null)
{
filtered = filtered.Where(device => device.DeviceId == query.DeviceId);
}
if (query.AccessToken is not null)
{
filtered = filtered.Where(device => device.AccessToken == query.AccessToken);
}
// Every filter is an exact match on an indexed column and the table holds one row per
// user and device, so paging the materialised set costs less than a second round trip.
var matched = await filtered.OrderBy(d => d.Id).ToListAsync().ConfigureAwait(false);
IEnumerable<Device> devices = matched;
if (query.Skip.HasValue)
{
devices = devices.Skip(query.Skip.Value);
}
if (query.Limit.HasValue && query.Limit.Value > 0)
{
devices = devices.Take(query.Limit.Value);
}
return new QueryResult<Device>(query.Skip, matched.Count, devices.ToList());
}
}
/// <inheritdoc />
public QueryResult<DeviceInfo> GetDeviceInfos(DeviceQuery query)
public async Task<QueryResult<DeviceInfo>> GetDeviceInfos(DeviceQuery query)
{
var devices = GetDevices(query);
var devices = await GetDevices(query).ConfigureAwait(false);
return new QueryResult<DeviceInfo>(
devices.StartIndex,
@@ -178,38 +196,49 @@ namespace Jellyfin.Server.Implementations.Devices
}
/// <inheritdoc />
public QueryResult<DeviceInfoDto> GetDevicesForUser(Guid? userId)
public async Task<QueryResult<DeviceInfoDto>> GetDevicesForUser(Guid? userId)
{
IEnumerable<Device> devices = _devices.Values
.OrderByDescending(d => d.DateLastActivity)
.ThenBy(d => d.DeviceId);
if (!userId.IsNullOrEmpty())
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
var user = _userManager.GetUserById(userId.Value);
if (user is null)
IEnumerable<Device> devices = await dbContext.Devices
.AsNoTracking()
.OrderByDescending(d => d.DateLastActivity)
.ThenBy(d => d.DeviceId)
.ToListAsync()
.ConfigureAwait(false);
if (!userId.IsNullOrEmpty())
{
throw new ResourceNotFoundException();
var user = _userManager.GetUserById(userId.Value);
if (user is null)
{
throw new ResourceNotFoundException();
}
devices = devices.Where(i => CanAccessDevice(user, i.DeviceId));
}
devices = devices.Where(i => CanAccessDevice(user, i.DeviceId));
var options = await dbContext.DeviceOptions
.AsNoTracking()
.ToDictionaryAsync(dev => dev.DeviceId)
.ConfigureAwait(false);
var array = devices.Select(device =>
{
options.TryGetValue(device.DeviceId, out var option);
return ToDeviceInfo(device, option);
})
.Select(ToDeviceInfoDto)
.ToArray();
return new QueryResult<DeviceInfoDto>(array);
}
var array = devices.Select(device =>
{
_deviceOptions.TryGetValue(device.DeviceId, out var option);
return ToDeviceInfo(device, option);
})
.Select(ToDeviceInfoDto)
.ToArray();
return new QueryResult<DeviceInfoDto>(array);
}
/// <inheritdoc />
public async Task DeleteDevice(Device device)
{
_devices.TryRemove(device.Id, out _);
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
@@ -229,8 +258,6 @@ namespace Jellyfin.Server.Implementations.Devices
dbContext.Devices.Update(device);
await dbContext.SaveChangesAsync().ConfigureAwait(false);
}
_devices[device.Id] = device;
}
/// <inheritdoc />
@@ -126,95 +126,95 @@ namespace Jellyfin.Server.Implementations.Security
return authInfo;
}
var device = (await _deviceManager.GetDevices(
new DeviceQuery { AccessToken = token }).ConfigureAwait(false)).Items.FirstOrDefault();
if (device is not null)
{
authInfo.IsAuthenticated = true;
var updateToken = false;
// TODO: Remove these checks for IsNullOrWhiteSpace
if (string.IsNullOrWhiteSpace(authInfo.Client))
{
authInfo.Client = device.AppName;
}
if (string.IsNullOrWhiteSpace(authInfo.DeviceId))
{
authInfo.DeviceId = device.DeviceId;
}
// Temporary. TODO - allow clients to specify that the token has been shared with a casting device
var allowTokenInfoUpdate = !authInfo.Client.Contains("chromecast", StringComparison.OrdinalIgnoreCase);
if (string.IsNullOrWhiteSpace(authInfo.Device))
{
authInfo.Device = device.DeviceName;
}
else if (!string.Equals(authInfo.Device, device.DeviceName, StringComparison.OrdinalIgnoreCase))
{
if (allowTokenInfoUpdate)
{
updateToken = true;
device.DeviceName = authInfo.Device;
}
}
if (string.IsNullOrWhiteSpace(authInfo.Version))
{
authInfo.Version = device.AppVersion;
}
else if (!string.Equals(authInfo.Version, device.AppVersion, StringComparison.OrdinalIgnoreCase))
{
if (allowTokenInfoUpdate)
{
updateToken = true;
device.AppVersion = authInfo.Version;
}
}
if ((DateTime.UtcNow - device.DateLastActivity).TotalMinutes > 3)
{
device.DateLastActivity = DateTime.UtcNow;
updateToken = true;
}
authInfo.User = _userManager.GetUserById(device.UserId);
if (updateToken)
{
await _deviceManager.UpdateDevice(device).ConfigureAwait(false);
}
return authInfo;
}
var dbContext = await _jellyfinDbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
var device = _deviceManager.GetDevices(
new DeviceQuery { AccessToken = token }).Items.FirstOrDefault();
if (device is not null)
var key = await dbContext.ApiKeys.FirstOrDefaultAsync(apiKey => apiKey.AccessToken == token).ConfigureAwait(false);
if (key is not null)
{
authInfo.IsAuthenticated = true;
var updateToken = false;
// TODO: Remove these checks for IsNullOrWhiteSpace
if (string.IsNullOrWhiteSpace(authInfo.Client))
{
authInfo.Client = device.AppName;
}
authInfo.Client = key.Name;
authInfo.Token = key.AccessToken;
if (string.IsNullOrWhiteSpace(authInfo.DeviceId))
{
authInfo.DeviceId = device.DeviceId;
authInfo.DeviceId = _serverApplicationHost.SystemId;
}
// Temporary. TODO - allow clients to specify that the token has been shared with a casting device
var allowTokenInfoUpdate = !authInfo.Client.Contains("chromecast", StringComparison.OrdinalIgnoreCase);
if (string.IsNullOrWhiteSpace(authInfo.Device))
{
authInfo.Device = device.DeviceName;
}
else if (!string.Equals(authInfo.Device, device.DeviceName, StringComparison.OrdinalIgnoreCase))
{
if (allowTokenInfoUpdate)
{
updateToken = true;
device.DeviceName = authInfo.Device;
}
authInfo.Device = _serverApplicationHost.Name;
}
if (string.IsNullOrWhiteSpace(authInfo.Version))
{
authInfo.Version = device.AppVersion;
}
else if (!string.Equals(authInfo.Version, device.AppVersion, StringComparison.OrdinalIgnoreCase))
{
if (allowTokenInfoUpdate)
{
updateToken = true;
device.AppVersion = authInfo.Version;
}
authInfo.Version = _serverApplicationHost.ApplicationVersionString;
}
if ((DateTime.UtcNow - device.DateLastActivity).TotalMinutes > 3)
{
device.DateLastActivity = DateTime.UtcNow;
updateToken = true;
}
authInfo.User = _userManager.GetUserById(device.UserId);
if (updateToken)
{
await _deviceManager.UpdateDevice(device).ConfigureAwait(false);
}
}
else
{
var key = await dbContext.ApiKeys.FirstOrDefaultAsync(apiKey => apiKey.AccessToken == token).ConfigureAwait(false);
if (key is not null)
{
authInfo.IsAuthenticated = true;
authInfo.Client = key.Name;
authInfo.Token = key.AccessToken;
if (string.IsNullOrWhiteSpace(authInfo.DeviceId))
{
authInfo.DeviceId = _serverApplicationHost.SystemId;
}
if (string.IsNullOrWhiteSpace(authInfo.Device))
{
authInfo.Device = _serverApplicationHost.Name;
}
if (string.IsNullOrWhiteSpace(authInfo.Version))
{
authInfo.Version = _serverApplicationHost.ApplicationVersionString;
}
authInfo.IsApiKey = true;
}
authInfo.IsApiKey = true;
}
return authInfo;
@@ -61,10 +61,10 @@ public sealed class DeviceAccessHost : IHostedService
private async Task UpdateDeviceAccess(User user)
{
var existing = _deviceManager.GetDevices(new DeviceQuery
var existing = (await _deviceManager.GetDevices(new DeviceQuery
{
UserId = user.Id
}).Items;
}).ConfigureAwait(false)).Items;
foreach (var device in existing)
{
@@ -47,29 +47,29 @@ public interface IDeviceManager
/// Gets the device information.
/// </summary>
/// <param name="id">The identifier.</param>
/// <returns>DeviceInfoDto.</returns>
DeviceInfoDto? GetDevice(string id);
/// <returns>A <see cref="Task"/> representing the retrieval of the device information.</returns>
Task<DeviceInfoDto?> GetDevice(string id);
/// <summary>
/// Gets devices based on the provided query.
/// </summary>
/// <param name="query">The device query.</param>
/// <returns>A <see cref="Task{QueryResult}"/> representing the retrieval of the devices.</returns>
QueryResult<Device> GetDevices(DeviceQuery query);
Task<QueryResult<Device>> GetDevices(DeviceQuery query);
/// <summary>
/// Gets device information based on the provided query.
/// </summary>
/// <param name="query">The device query.</param>
/// <returns>A <see cref="Task{QueryResult}"/> representing the retrieval of the device information.</returns>
QueryResult<DeviceInfo> GetDeviceInfos(DeviceQuery query);
Task<QueryResult<DeviceInfo>> GetDeviceInfos(DeviceQuery query);
/// <summary>
/// Gets the device information.
/// </summary>
/// <param name="userId">The user's id, or <c>null</c>.</param>
/// <returns>IEnumerable&lt;DeviceInfoDto&gt;.</returns>
QueryResult<DeviceInfoDto> GetDevicesForUser(Guid? userId);
/// <returns>A <see cref="Task{QueryResult}"/> representing the retrieval of the device information.</returns>
Task<QueryResult<DeviceInfoDto>> GetDevicesForUser(Guid? userId);
/// <summary>
/// Deletes a device.
@@ -105,8 +105,8 @@ public interface IDeviceManager
/// Gets the options of a device.
/// </summary>
/// <param name="deviceId">The device id.</param>
/// <returns><see cref="DeviceOptions"/> of the device.</returns>
DeviceOptionsDto? GetDeviceOptions(string deviceId);
/// <returns>A <see cref="Task"/> representing the retrieval of the <see cref="DeviceOptions"/> of the device.</returns>
Task<DeviceOptionsDto?> GetDeviceOptions(string deviceId);
/// <summary>
/// Gets the dto for client capabilities.
@@ -0,0 +1,197 @@
using System;
using System.Threading;
using System.Threading.Tasks;
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.Migrations;
using MediaBrowser.Controller.Library;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Npgsql;
using Xunit;
namespace Jellyfin.Server.Tests.Devices;
/// <summary>
/// Two independently constructed <see cref="DeviceManager"/> instances over one PostgreSQL database are the
/// in-process stand-in for two replicas sharing one database: what either of them writes, the other has to
/// see on its very next read.
/// </summary>
[Trait("Category", "RequiresDocker")]
public sealed class DeviceManagerReplicaTests : IAsyncLifetime
{
private PostgreSqlTestServer _server = null!;
/// <inheritdoc/>
public async ValueTask InitializeAsync()
{
_server = await PostgreSqlTestServer.StartAsync().ConfigureAwait(false);
}
/// <inheritdoc/>
public async ValueTask DisposeAsync()
{
await _server.DisposeAsync().ConfigureAwait(false);
}
/// <summary>
/// A client that logs in against one replica has to be authenticated by every other replica that was
/// already running when the token was minted - a rolling update or a scale-up must not 401 it.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[Fact]
public async Task TokenMintedOnOneReplica_IsValidOnAnother()
{
var cancellationToken = TestContext.Current.CancellationToken;
var connectionString = await _server.CreateDatabaseAsync("device_replica_create", cancellationToken);
await using var dataSource = new NpgsqlDataSourceBuilder(connectionString).Build();
var user = await CreateSchemaWithUserAsync(dataSource, cancellationToken);
// Both replicas start before the login, so neither has the device in hand when it is created.
var replicaA = CreateManager(dataSource, user);
var replicaB = CreateManager(dataSource, user);
var device = await replicaA.CreateDevice(new Device(user.Id, "Jellyfin Web", "1.0.0", "Living Room TV", "device-1"));
var seenByB = await replicaB.GetDevices(new DeviceQuery { AccessToken = device.AccessToken });
Assert.Equal(device.Id, Assert.Single(seenByB.Items).Id);
Assert.Equal(user.Id, seenByB.Items[0].UserId);
}
/// <summary>
/// Revocation has to propagate at least as fast as creation: a token logged out on one replica must not
/// still authenticate on another.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[Fact]
public async Task TokenRevokedOnOneReplica_IsInvalidOnAnother()
{
var cancellationToken = TestContext.Current.CancellationToken;
var connectionString = await _server.CreateDatabaseAsync("device_replica_revoke", cancellationToken);
await using var dataSource = new NpgsqlDataSourceBuilder(connectionString).Build();
var user = await CreateSchemaWithUserAsync(dataSource, cancellationToken);
var replicaA = CreateManager(dataSource, user);
var device = await replicaA.CreateDevice(new Device(user.Id, "Jellyfin Web", "1.0.0", "Living Room TV", "device-1"));
// Replica B comes up after the login, so it starts out agreeing that the token is valid.
var replicaB = CreateManager(dataSource, user);
Assert.Single((await replicaB.GetDevices(new DeviceQuery { AccessToken = device.AccessToken })).Items);
await replicaA.DeleteDevice(device);
Assert.Empty((await replicaB.GetDevices(new DeviceQuery { AccessToken = device.AccessToken })).Items);
}
/// <summary>
/// A device renamed on one replica has to be reported under its new name by the others, and the rename has
/// to survive being read back through a replica that never saw the write.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[Fact]
public async Task DeviceOptionsWrittenOnOneReplica_AreReadOnAnother()
{
var cancellationToken = TestContext.Current.CancellationToken;
var connectionString = await _server.CreateDatabaseAsync("device_replica_options", cancellationToken);
await using var dataSource = new NpgsqlDataSourceBuilder(connectionString).Build();
var user = await CreateSchemaWithUserAsync(dataSource, cancellationToken);
var replicaA = CreateManager(dataSource, user);
var replicaB = CreateManager(dataSource, user);
await replicaA.CreateDevice(new Device(user.Id, "Jellyfin Web", "1.0.0", "Living Room TV", "device-1"));
await replicaA.UpdateDeviceOptions("device-1", "Kitchen TV");
var options = await replicaB.GetDeviceOptions("device-1");
Assert.Equal("Kitchen TV", options?.CustomName);
var info = await replicaB.GetDevice("device-1");
Assert.Equal("Kitchen TV", info?.CustomName);
}
/// <summary>
/// Activity written by the replica serving the request has to be visible to the others, because the next
/// request from the same client can land anywhere.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[Fact]
public async Task DeviceUpdatedOnOneReplica_IsReadOnAnother()
{
var cancellationToken = TestContext.Current.CancellationToken;
var connectionString = await _server.CreateDatabaseAsync("device_replica_update", cancellationToken);
await using var dataSource = new NpgsqlDataSourceBuilder(connectionString).Build();
var user = await CreateSchemaWithUserAsync(dataSource, cancellationToken);
var replicaA = CreateManager(dataSource, user);
var replicaB = CreateManager(dataSource, user);
var device = await replicaA.CreateDevice(new Device(user.Id, "Jellyfin Web", "1.0.0", "Living Room TV", "device-1"));
device.AppVersion = "2.0.0";
await replicaA.UpdateDevice(device);
var seenByB = Assert.Single((await replicaB.GetDevices(new DeviceQuery { DeviceId = "device-1" })).Items);
Assert.Equal("2.0.0", seenByB.AppVersion);
}
private static DeviceManager CreateManager(NpgsqlDataSource dataSource, User user)
{
var userManager = new Mock<IUserManager>();
userManager.Setup(manager => manager.GetUserById(user.Id)).Returns(user);
return new DeviceManager(new DataSourceContextFactory(dataSource), userManager.Object);
}
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("replica-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));
}
/// <summary>
/// Hands every <see cref="DeviceManager"/> 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);
}
}