Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 483c739fb1 | |||
| a9d6c749fb | |||
| b662ffa48f |
@@ -87,6 +87,12 @@ namespace Emby.Server.Implementations.AppBase
|
|||||||
/// <value>The application paths.</value>
|
/// <value>The application paths.</value>
|
||||||
public IApplicationPaths CommonApplicationPaths { get; private set; }
|
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>
|
/// <summary>
|
||||||
/// Gets or sets the system configuration.
|
/// Gets or sets the system configuration.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -169,6 +175,8 @@ namespace Emby.Server.Implementations.AppBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
OnConfigurationUpdated();
|
OnConfigurationUpdated();
|
||||||
|
|
||||||
|
InvalidationBus.PublishLocalWrite(ConfigurationInvalidationScope.SystemConfiguration, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -350,6 +358,29 @@ namespace Emby.Server.Implementations.AppBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
OnNamedConfigurationUpdated(key, configuration);
|
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>
|
/// <summary>
|
||||||
|
|||||||
@@ -706,6 +706,8 @@ namespace Emby.Server.Implementations
|
|||||||
BaseItem.UserDataManager = Resolve<IUserDataManager>();
|
BaseItem.UserDataManager = Resolve<IUserDataManager>();
|
||||||
CollectionFolder.XmlSerializer = _xmlSerializer;
|
CollectionFolder.XmlSerializer = _xmlSerializer;
|
||||||
CollectionFolder.ApplicationHost = this;
|
CollectionFolder.ApplicationHost = this;
|
||||||
|
CollectionFolder.InvalidationBus = Resolve<IConfigurationInvalidationBus>();
|
||||||
|
ConfigurationManager.InvalidationBus = CollectionFolder.InvalidationBus;
|
||||||
Folder.UserViewManager = Resolve<IUserViewManager>();
|
Folder.UserViewManager = Resolve<IUserViewManager>();
|
||||||
Folder.CollectionManager = Resolve<ICollectionManager>();
|
Folder.CollectionManager = Resolve<ICollectionManager>();
|
||||||
Folder.LimitedConcurrencyLibraryScheduler = Resolve<ILimitedConcurrencyLibraryScheduler>();
|
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>
|
/// <summary>
|
||||||
/// Called when [configuration updated].
|
/// Called when [configuration updated].
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -797,26 +836,24 @@ namespace Emby.Server.Implementations
|
|||||||
/// <param name="e">The <see cref="EventArgs"/> instance containing the event data.</param>
|
/// <param name="e">The <see cref="EventArgs"/> instance containing the event data.</param>
|
||||||
private void OnConfigurationUpdated(object sender, EventArgs e)
|
private void OnConfigurationUpdated(object sender, EventArgs e)
|
||||||
{
|
{
|
||||||
var requiresRestart = false;
|
|
||||||
var networkConfiguration = ConfigurationManager.GetNetworkConfiguration();
|
var networkConfiguration = ConfigurationManager.GetNetworkConfiguration();
|
||||||
|
|
||||||
// Don't do anything if these haven't been set yet
|
var portChange = EvaluatePortChange(
|
||||||
if (HttpPort != 0 && HttpsPort != 0)
|
HttpPort,
|
||||||
{
|
HttpsPort,
|
||||||
// Need to restart if ports have changed
|
networkConfiguration.InternalHttpPort,
|
||||||
if (networkConfiguration.InternalHttpPort != HttpPort
|
networkConfiguration.InternalHttpsPort,
|
||||||
|| networkConfiguration.InternalHttpsPort != HttpsPort)
|
ConfigurationManager.Configuration.IsPortAuthorized,
|
||||||
{
|
ConfigurationInvalidationContext.IsApplyingRemoteInvalidation);
|
||||||
if (ConfigurationManager.Configuration.IsPortAuthorized)
|
|
||||||
{
|
|
||||||
ConfigurationManager.Configuration.IsPortAuthorized = false;
|
|
||||||
ConfigurationManager.SaveConfiguration();
|
|
||||||
|
|
||||||
requiresRestart = true;
|
if (portChange.ClearsPortAuthorization)
|
||||||
}
|
{
|
||||||
}
|
ConfigurationManager.Configuration.IsPortAuthorized = false;
|
||||||
|
ConfigurationManager.SaveConfiguration();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var requiresRestart = portChange.RequiresRestart;
|
||||||
|
|
||||||
if (ValidateSslCertificate(networkConfiguration))
|
if (ValidateSslCertificate(networkConfiguration))
|
||||||
{
|
{
|
||||||
requiresRestart = true;
|
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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2332,10 +2332,7 @@ namespace Emby.Server.Implementations.Library
|
|||||||
{
|
{
|
||||||
IOrderedEnumerable<BaseItem>? orderedItems = null;
|
IOrderedEnumerable<BaseItem>? orderedItems = null;
|
||||||
|
|
||||||
var comparers = sortBy.Select(o => GetComparer(o, user)).Where(c => c is not null).ToList();
|
foreach (var orderBy in sortBy.Select(o => GetComparer(o, user)).Where(c => c is not null))
|
||||||
items = PrefetchUserData(items, user, comparers);
|
|
||||||
|
|
||||||
foreach (var orderBy in comparers)
|
|
||||||
{
|
{
|
||||||
if (orderBy is RandomComparer)
|
if (orderBy is RandomComparer)
|
||||||
{
|
{
|
||||||
@@ -2367,14 +2364,14 @@ namespace Emby.Server.Implementations.Library
|
|||||||
{
|
{
|
||||||
IOrderedEnumerable<BaseItem>? orderedItems = null;
|
IOrderedEnumerable<BaseItem>? orderedItems = null;
|
||||||
|
|
||||||
var comparers = orderBy
|
foreach (var (name, sortOrder) in orderBy)
|
||||||
.Select(o => (Comparer: GetComparer(o.OrderBy, user), o.SortOrder))
|
|
||||||
.Where(c => c.Comparer is not null)
|
|
||||||
.ToList();
|
|
||||||
items = PrefetchUserData(items, user, comparers.Select(c => c.Comparer).ToList());
|
|
||||||
|
|
||||||
foreach (var (comparer, sortOrder) in comparers)
|
|
||||||
{
|
{
|
||||||
|
var comparer = GetComparer(name, user);
|
||||||
|
if (comparer is null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (comparer is RandomComparer)
|
if (comparer is RandomComparer)
|
||||||
{
|
{
|
||||||
var randomItems = items.ToArray();
|
var randomItems = items.ToArray();
|
||||||
@@ -2400,31 +2397,6 @@ namespace Emby.Server.Implementations.Library
|
|||||||
return orderedItems ?? items;
|
return orderedItems ?? items;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The user comparers read user data per item, so without one batched read up front an
|
|
||||||
// in-memory sort would issue a database round trip per comparison.
|
|
||||||
private IEnumerable<BaseItem> PrefetchUserData(IEnumerable<BaseItem> items, User? user, IReadOnlyList<IBaseItemComparer?> comparers)
|
|
||||||
{
|
|
||||||
if (user is null)
|
|
||||||
{
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
|
|
||||||
var userComparers = comparers.OfType<IUserBaseItemComparer>().ToList();
|
|
||||||
if (userComparers.Count == 0)
|
|
||||||
{
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
|
|
||||||
var itemList = items as IReadOnlyList<BaseItem> ?? items.ToList();
|
|
||||||
var userData = _userDataManager.GetUserDataBatch(itemList, user);
|
|
||||||
foreach (var comparer in userComparers)
|
|
||||||
{
|
|
||||||
comparer.PrefetchedUserData = userData;
|
|
||||||
}
|
|
||||||
|
|
||||||
return itemList;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the comparer.
|
/// Gets the comparer.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
|
using BitFaster.Caching.Lru;
|
||||||
using Jellyfin.Database.Implementations;
|
using Jellyfin.Database.Implementations;
|
||||||
using Jellyfin.Database.Implementations.Entities;
|
using Jellyfin.Database.Implementations.Entities;
|
||||||
using MediaBrowser.Controller.Configuration;
|
using MediaBrowser.Controller.Configuration;
|
||||||
@@ -25,6 +27,7 @@ namespace Emby.Server.Implementations.Library
|
|||||||
{
|
{
|
||||||
private readonly IServerConfigurationManager _config;
|
private readonly IServerConfigurationManager _config;
|
||||||
private readonly IDbContextFactory<JellyfinDbContext> _repository;
|
private readonly IDbContextFactory<JellyfinDbContext> _repository;
|
||||||
|
private readonly FastConcurrentLru<string, UserItemData> _cache;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="UserDataManager"/> class.
|
/// Initializes a new instance of the <see cref="UserDataManager"/> class.
|
||||||
@@ -37,6 +40,7 @@ namespace Emby.Server.Implementations.Library
|
|||||||
{
|
{
|
||||||
_config = config;
|
_config = config;
|
||||||
_repository = repository;
|
_repository = repository;
|
||||||
|
_cache = new FastConcurrentLru<string, UserItemData>(Environment.ProcessorCount, _config.Configuration.CacheSize, StringComparer.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -73,6 +77,11 @@ namespace Emby.Server.Implementations.Library
|
|||||||
dbContext.SaveChanges();
|
dbContext.SaveChanges();
|
||||||
transaction.Commit();
|
transaction.Commit();
|
||||||
|
|
||||||
|
var userId = user.InternalId;
|
||||||
|
var cacheKey = GetCacheKey(userId, item.Id);
|
||||||
|
_cache.AddOrUpdate(cacheKey, userData);
|
||||||
|
item.UserData = dbContext.UserData.Where(e => e.ItemId == item.Id).AsNoTracking().ToArray(); // rehydrate the cached userdata
|
||||||
|
|
||||||
UserDataSaved?.Invoke(this, new UserDataSaveEventArgs
|
UserDataSaved?.Invoke(this, new UserDataSaveEventArgs
|
||||||
{
|
{
|
||||||
Keys = keys,
|
Keys = keys,
|
||||||
@@ -171,41 +180,64 @@ namespace Emby.Server.Implementations.Library
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Dictionary<Guid, UserItemData> GetUserDataBatch(IReadOnlyList<BaseItem> items, User user)
|
public Dictionary<Guid, UserItemData> GetUserDataBatch(IReadOnlyList<BaseItem> items, User user)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(items);
|
|
||||||
ArgumentNullException.ThrowIfNull(user);
|
|
||||||
|
|
||||||
var result = new Dictionary<Guid, UserItemData>(items.Count);
|
var result = new Dictionary<Guid, UserItemData>(items.Count);
|
||||||
if (items.Count == 0)
|
var itemsNeedingQuery = new List<(BaseItem Item, List<string> Keys)>();
|
||||||
|
|
||||||
|
foreach (var item in items)
|
||||||
|
{
|
||||||
|
var cacheKey = GetCacheKey(user.InternalId, item.Id);
|
||||||
|
if (_cache.TryGet(cacheKey, out var cachedData))
|
||||||
|
{
|
||||||
|
result[item.Id] = cachedData;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var userDataRow = ResolveUserDataRow(item, item.UserData?.Where(e => e.UserId.Equals(user.Id)));
|
||||||
|
var userData = userDataRow is not null ? Map(userDataRow) : null;
|
||||||
|
if (userData is not null)
|
||||||
|
{
|
||||||
|
result[item.Id] = userData;
|
||||||
|
_cache.AddOrUpdate(cacheKey, userData);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var keys = item.GetUserDataKeys();
|
||||||
|
itemsNeedingQuery.Add((item, keys));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (itemsNeedingQuery.Count == 0)
|
||||||
{
|
{
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch rows by item alone so rows kept under keys from older metadata resolve the same
|
// Build a single query for all missing items. Fetch rows by item alone so rows kept
|
||||||
// way as the single item path.
|
// under keys from older metadata resolve the same way as the in-memory path.
|
||||||
var itemIds = items.Select(e => e.Id).Distinct().ToList();
|
var allItemIds = itemsNeedingQuery.Select(x => x.Item.Id).ToList();
|
||||||
using var context = _repository.CreateDbContext();
|
using var context = _repository.CreateDbContext();
|
||||||
var userDataByItem = context.UserData
|
var userDataArray = context.UserData
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(e => e.UserId.Equals(user.Id))
|
.Where(e => e.UserId.Equals(user.Id))
|
||||||
.WhereOneOrMany(itemIds, e => e.ItemId)
|
.WhereOneOrMany(allItemIds, e => e.ItemId)
|
||||||
.ToArray()
|
.ToArray();
|
||||||
.GroupBy(e => e.ItemId)
|
|
||||||
.ToDictionary(g => g.Key, g => g.ToArray());
|
|
||||||
|
|
||||||
foreach (var item in items)
|
var userDataByItem = userDataArray.GroupBy(e => e.ItemId).ToDictionary(g => g.Key, g => g.ToArray());
|
||||||
|
foreach (var (item, keys) in itemsNeedingQuery)
|
||||||
{
|
{
|
||||||
if (result.ContainsKey(item.Id))
|
UserItemData userData;
|
||||||
|
if (userDataByItem.TryGetValue(item.Id, out var itemUserData) && itemUserData.Length > 0)
|
||||||
{
|
{
|
||||||
continue;
|
userData = Map(ResolveUserDataRow(item, itemUserData)!);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
userData = new UserItemData { Key = keys.Count > 0 ? keys[0] : string.Empty };
|
||||||
}
|
}
|
||||||
|
|
||||||
var row = userDataByItem.TryGetValue(item.Id, out var itemUserData)
|
result[item.Id] = userData;
|
||||||
? ResolveUserDataRow(item, itemUserData)
|
var cacheKey = GetCacheKey(user.InternalId, item.Id);
|
||||||
: null;
|
_cache.AddOrUpdate(cacheKey, userData);
|
||||||
|
|
||||||
result[item.Id] = row is not null
|
|
||||||
? Map(row)
|
|
||||||
: new UserItemData { Key = item.GetUserDataKeys().FirstOrDefault() ?? string.Empty };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
@@ -308,19 +340,20 @@ namespace Emby.Server.Implementations.Library
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the internal key.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>System.String.</returns>
|
||||||
|
private static string GetCacheKey(long internalUserId, Guid itemId)
|
||||||
|
{
|
||||||
|
return internalUserId.ToString(CultureInfo.InvariantCulture) + "-" + itemId.ToString("N", CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public UserItemData? GetUserData(User user, BaseItem item)
|
public UserItemData? GetUserData(User user, BaseItem item)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(user);
|
ArgumentNullException.ThrowIfNull(user);
|
||||||
ArgumentNullException.ThrowIfNull(item);
|
var row = ResolveUserDataRow(item, item.UserData?.Where(e => e.UserId.Equals(user.Id)));
|
||||||
|
|
||||||
using var dbContext = _repository.CreateDbContext();
|
|
||||||
var rows = dbContext.UserData
|
|
||||||
.AsNoTracking()
|
|
||||||
.Where(e => e.ItemId == item.Id && e.UserId == user.Id)
|
|
||||||
.ToArray();
|
|
||||||
|
|
||||||
var row = ResolveUserDataRow(item, rows);
|
|
||||||
return row is not null ? Map(row) : new UserItemData()
|
return row is not null ? Map(row) : new UserItemData()
|
||||||
{
|
{
|
||||||
Key = item.GetUserDataKeys()[0],
|
Key = item.GetUserDataKeys()[0],
|
||||||
@@ -503,6 +536,16 @@ namespace Emby.Server.Implementations.Library
|
|||||||
}
|
}
|
||||||
|
|
||||||
dbContext.SaveChanges();
|
dbContext.SaveChanges();
|
||||||
|
|
||||||
|
var cacheKey = GetCacheKey(user.InternalId, item.Id);
|
||||||
|
if (_cache.TryGet(cacheKey, out var cached))
|
||||||
|
{
|
||||||
|
cached.AudioStreamIndex = null;
|
||||||
|
cached.SubtitleStreamIndex = null;
|
||||||
|
_cache.AddOrUpdate(cacheKey, cached);
|
||||||
|
}
|
||||||
|
|
||||||
|
item.UserData = dbContext.UserData.Where(e => e.ItemId == item.Id).AsNoTracking().ToArray();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,6 @@
|
|||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
|
||||||
using Jellyfin.Data.Enums;
|
using Jellyfin.Data.Enums;
|
||||||
using Jellyfin.Database.Implementations.Entities;
|
using Jellyfin.Database.Implementations.Entities;
|
||||||
using MediaBrowser.Controller.Entities;
|
using MediaBrowser.Controller.Entities;
|
||||||
@@ -28,12 +27,6 @@ namespace Emby.Server.Implementations.Sorting
|
|||||||
/// <value>The user manager.</value>
|
/// <value>The user manager.</value>
|
||||||
public IUserManager UserManager { get; set; }
|
public IUserManager UserManager { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the prefetched user data.
|
|
||||||
/// </summary>
|
|
||||||
/// <value>The prefetched user data.</value>
|
|
||||||
public IReadOnlyDictionary<Guid, UserItemData> PrefetchedUserData { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the user data manager.
|
/// Gets or sets the user data manager.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -64,7 +57,7 @@ namespace Emby.Server.Implementations.Sorting
|
|||||||
/// <returns>DateTime.</returns>
|
/// <returns>DateTime.</returns>
|
||||||
private DateTime GetDate(BaseItem x)
|
private DateTime GetDate(BaseItem x)
|
||||||
{
|
{
|
||||||
var userdata = this.GetUserData(x);
|
var userdata = UserDataManager.GetUserData(User, x);
|
||||||
|
|
||||||
if (userdata is not null && userdata.LastPlayedDate.HasValue)
|
if (userdata is not null && userdata.LastPlayedDate.HasValue)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
#nullable disable
|
#nullable disable
|
||||||
#pragma warning disable CS1591
|
#pragma warning disable CS1591
|
||||||
|
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using Jellyfin.Data.Enums;
|
using Jellyfin.Data.Enums;
|
||||||
using Jellyfin.Database.Implementations.Entities;
|
using Jellyfin.Database.Implementations.Entities;
|
||||||
using MediaBrowser.Controller.Entities;
|
using MediaBrowser.Controller.Entities;
|
||||||
@@ -37,12 +35,6 @@ namespace Emby.Server.Implementations.Sorting
|
|||||||
/// <value>The user manager.</value>
|
/// <value>The user manager.</value>
|
||||||
public IUserManager UserManager { get; set; }
|
public IUserManager UserManager { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the prefetched user data.
|
|
||||||
/// </summary>
|
|
||||||
/// <value>The prefetched user data.</value>
|
|
||||||
public IReadOnlyDictionary<Guid, UserItemData> PrefetchedUserData { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Compares the specified x.
|
/// Compares the specified x.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -61,7 +53,7 @@ namespace Emby.Server.Implementations.Sorting
|
|||||||
/// <returns>DateTime.</returns>
|
/// <returns>DateTime.</returns>
|
||||||
private int GetValue(BaseItem x)
|
private int GetValue(BaseItem x)
|
||||||
{
|
{
|
||||||
return x.IsFavoriteOrLiked(User, this.GetUserData(x)) ? 0 : 1;
|
return x.IsFavoriteOrLiked(User, userItemData: null) ? 0 : 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
#pragma warning disable CS1591
|
#pragma warning disable CS1591
|
||||||
|
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using Jellyfin.Data.Enums;
|
using Jellyfin.Data.Enums;
|
||||||
using Jellyfin.Database.Implementations.Entities;
|
using Jellyfin.Database.Implementations.Entities;
|
||||||
using MediaBrowser.Controller.Entities;
|
using MediaBrowser.Controller.Entities;
|
||||||
@@ -38,12 +36,6 @@ namespace Emby.Server.Implementations.Sorting
|
|||||||
/// <value>The user manager.</value>
|
/// <value>The user manager.</value>
|
||||||
public IUserManager UserManager { get; set; }
|
public IUserManager UserManager { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the prefetched user data.
|
|
||||||
/// </summary>
|
|
||||||
/// <value>The prefetched user data.</value>
|
|
||||||
public IReadOnlyDictionary<Guid, UserItemData> PrefetchedUserData { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Compares the specified x.
|
/// Compares the specified x.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -62,7 +54,7 @@ namespace Emby.Server.Implementations.Sorting
|
|||||||
/// <returns>DateTime.</returns>
|
/// <returns>DateTime.</returns>
|
||||||
private int GetValue(BaseItem x)
|
private int GetValue(BaseItem x)
|
||||||
{
|
{
|
||||||
return x.IsPlayed(User, this.GetUserData(x)) ? 0 : 1;
|
return x.IsPlayed(User, userItemData: null) ? 0 : 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
#pragma warning disable CS1591
|
#pragma warning disable CS1591
|
||||||
|
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using Jellyfin.Data.Enums;
|
using Jellyfin.Data.Enums;
|
||||||
using Jellyfin.Database.Implementations.Entities;
|
using Jellyfin.Database.Implementations.Entities;
|
||||||
using MediaBrowser.Controller.Entities;
|
using MediaBrowser.Controller.Entities;
|
||||||
@@ -38,12 +36,6 @@ namespace Emby.Server.Implementations.Sorting
|
|||||||
/// <value>The user manager.</value>
|
/// <value>The user manager.</value>
|
||||||
public IUserManager UserManager { get; set; }
|
public IUserManager UserManager { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the prefetched user data.
|
|
||||||
/// </summary>
|
|
||||||
/// <value>The prefetched user data.</value>
|
|
||||||
public IReadOnlyDictionary<Guid, UserItemData> PrefetchedUserData { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Compares the specified x.
|
/// Compares the specified x.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -62,7 +54,7 @@ namespace Emby.Server.Implementations.Sorting
|
|||||||
/// <returns>DateTime.</returns>
|
/// <returns>DateTime.</returns>
|
||||||
private int GetValue(BaseItem x)
|
private int GetValue(BaseItem x)
|
||||||
{
|
{
|
||||||
return x.IsUnplayed(User, this.GetUserData(x)) ? 0 : 1;
|
return x.IsUnplayed(User, userItemData: null) ? 0 : 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using Jellyfin.Data.Enums;
|
using Jellyfin.Data.Enums;
|
||||||
using Jellyfin.Database.Implementations.Entities;
|
using Jellyfin.Database.Implementations.Entities;
|
||||||
using MediaBrowser.Controller.Entities;
|
using MediaBrowser.Controller.Entities;
|
||||||
@@ -40,12 +38,6 @@ namespace Emby.Server.Implementations.Sorting
|
|||||||
/// <value>The user manager.</value>
|
/// <value>The user manager.</value>
|
||||||
public IUserManager UserManager { get; set; }
|
public IUserManager UserManager { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the prefetched user data.
|
|
||||||
/// </summary>
|
|
||||||
/// <value>The prefetched user data.</value>
|
|
||||||
public IReadOnlyDictionary<Guid, UserItemData> PrefetchedUserData { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Compares the specified x.
|
/// Compares the specified x.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -64,7 +56,7 @@ namespace Emby.Server.Implementations.Sorting
|
|||||||
/// <returns>DateTime.</returns>
|
/// <returns>DateTime.</returns>
|
||||||
private int GetValue(BaseItem x)
|
private int GetValue(BaseItem x)
|
||||||
{
|
{
|
||||||
var userdata = this.GetUserData(x);
|
var userdata = UserDataManager.GetUserData(User, x);
|
||||||
|
|
||||||
return userdata is null ? 0 : userdata.PlayCount;
|
return userdata is null ? 0 : userdata.PlayCount;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ using MediaBrowser.Controller.Configuration;
|
|||||||
using MediaBrowser.Controller.Dto;
|
using MediaBrowser.Controller.Dto;
|
||||||
using MediaBrowser.Controller.Entities;
|
using MediaBrowser.Controller.Entities;
|
||||||
using MediaBrowser.Controller.Library;
|
using MediaBrowser.Controller.Library;
|
||||||
using MediaBrowser.Controller.Persistence;
|
|
||||||
using MediaBrowser.Controller.TV;
|
using MediaBrowser.Controller.TV;
|
||||||
using MediaBrowser.Model.Querying;
|
using MediaBrowser.Model.Querying;
|
||||||
using Episode = MediaBrowser.Controller.Entities.TV.Episode;
|
using Episode = MediaBrowser.Controller.Entities.TV.Episode;
|
||||||
@@ -125,100 +124,53 @@ namespace Emby.Server.Implementations.TV
|
|||||||
|
|
||||||
var batchResult = _libraryManager.GetNextUpEpisodesBatch(query, seriesKeys, includeSpecials, includeRewatching);
|
var batchResult = _libraryManager.GetNextUpEpisodesBatch(query, seriesKeys, includeSpecials, includeRewatching);
|
||||||
|
|
||||||
var results = new List<NextUpEpisodeBatchResult>(seriesKeys.Count);
|
var nextUpList = new List<(DateTime LastWatchedDate, Episode Episode)>();
|
||||||
|
|
||||||
foreach (var seriesKey in seriesKeys)
|
foreach (var seriesKey in seriesKeys)
|
||||||
{
|
{
|
||||||
if (batchResult.TryGetValue(seriesKey, out var result))
|
if (!batchResult.TryGetValue(seriesKey, out var result))
|
||||||
{
|
{
|
||||||
results.Add(result);
|
continue;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// The selection below tests the played state of every episode it considers, so read the whole
|
var nextEpisode = DetermineNextEpisode(result, user, includeSpecials, request.EnableResumable, false);
|
||||||
// series batch in one query rather than one query per series.
|
|
||||||
var selectionCandidates = new List<BaseItem>();
|
|
||||||
foreach (var result in results)
|
|
||||||
{
|
|
||||||
AddCandidate(selectionCandidates, result.NextUp);
|
|
||||||
AddCandidate(selectionCandidates, result.LastWatched);
|
|
||||||
AddCandidate(selectionCandidates, result.NextPlayedForRewatching);
|
|
||||||
AddCandidate(selectionCandidates, result.LastWatchedForRewatching);
|
|
||||||
|
|
||||||
if (result.Specials is not null)
|
|
||||||
{
|
|
||||||
selectionCandidates.AddRange(result.Specials);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var selectionUserData = _userDataManager.GetUserDataBatch(selectionCandidates, user);
|
|
||||||
|
|
||||||
var candidates = new List<NextUpCandidate>();
|
|
||||||
foreach (var result in results)
|
|
||||||
{
|
|
||||||
var nextEpisode = SelectNextEpisode(result, user, includeSpecials, includePlayed: false, selectionUserData);
|
|
||||||
if (nextEpisode is not null)
|
if (nextEpisode is not null)
|
||||||
{
|
{
|
||||||
candidates.Add(new NextUpCandidate(nextEpisode, result.LastWatched, !request.EnableResumable));
|
// The last played date and the version that was actually played live on the version item's user data
|
||||||
|
// The played state propagated to the sibling versions carries no date
|
||||||
|
var (playedVersion, lastPlayedDate) = GetMostRecentlyPlayedVersion(result.LastWatched, user);
|
||||||
|
nextEpisode = GetPreferredVersion(nextEpisode, result.LastWatched, playedVersion);
|
||||||
|
|
||||||
|
DateTime lastWatchedDate = DateTime.MinValue;
|
||||||
|
if (result.LastWatched is not null)
|
||||||
|
{
|
||||||
|
lastWatchedDate = lastPlayedDate ?? DateTime.MinValue.AddDays(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
nextUpList.Add((lastWatchedDate, nextEpisode));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (includeRewatching)
|
if (includeRewatching)
|
||||||
{
|
{
|
||||||
var nextPlayedEpisode = SelectNextEpisode(result, user, includeSpecials, includePlayed: true, selectionUserData);
|
var nextPlayedEpisode = DetermineNextEpisodeForRewatching(result, user, includeSpecials);
|
||||||
|
|
||||||
if (nextPlayedEpisode is not null)
|
if (nextPlayedEpisode is not null)
|
||||||
{
|
{
|
||||||
// A rewatch suggestion is dropped once it has been resumed, whatever the request asked for.
|
var (playedVersion, lastPlayedDate) = GetMostRecentlyPlayedVersion(result.LastWatchedForRewatching, user);
|
||||||
candidates.Add(new NextUpCandidate(nextPlayedEpisode, result.LastWatchedForRewatching, true));
|
nextPlayedEpisode = GetPreferredVersion(nextPlayedEpisode, result.LastWatchedForRewatching, playedVersion);
|
||||||
|
|
||||||
|
DateTime rewatchLastWatchedDate = DateTime.MinValue;
|
||||||
|
if (result.LastWatchedForRewatching is not null)
|
||||||
|
{
|
||||||
|
rewatchLastWatchedDate = lastPlayedDate ?? DateTime.MinValue.AddDays(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
nextUpList.Add((rewatchLastWatchedDate, nextPlayedEpisode));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The resume progress may live on an alternate version, so read every version in one query.
|
|
||||||
var episodeVersions = new List<BaseItem>();
|
|
||||||
foreach (var candidate in candidates)
|
|
||||||
{
|
|
||||||
if (candidate.DropWhenResumed)
|
|
||||||
{
|
|
||||||
candidate.EpisodeVersions = candidate.Episode.GetAllVersions();
|
|
||||||
episodeVersions.AddRange(candidate.EpisodeVersions);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (episodeVersions.Count > 0)
|
|
||||||
{
|
|
||||||
var resumeUserData = _userDataManager.GetUserDataBatch(episodeVersions, user);
|
|
||||||
candidates.RemoveAll(candidate => candidate.EpisodeVersions
|
|
||||||
.Any(version => GetUserData(user, version, resumeUserData)?.PlaybackPositionTicks > 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
// The last played date and the version that was actually played live on the version item's user data
|
|
||||||
// The played state propagated to the sibling versions carries no date
|
|
||||||
var lastWatchedVersions = new List<BaseItem>();
|
|
||||||
foreach (var candidate in candidates)
|
|
||||||
{
|
|
||||||
if (candidate.LastWatched is Video lastWatchedVideo)
|
|
||||||
{
|
|
||||||
candidate.LastWatchedVersions = lastWatchedVideo.GetAllVersions();
|
|
||||||
lastWatchedVersions.AddRange(candidate.LastWatchedVersions);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var lastWatchedUserData = _userDataManager.GetUserDataBatch(lastWatchedVersions, user);
|
|
||||||
|
|
||||||
var nextUpList = new List<(DateTime LastWatchedDate, Episode Episode)>(candidates.Count);
|
|
||||||
foreach (var candidate in candidates)
|
|
||||||
{
|
|
||||||
var (playedVersion, lastPlayedDate) = GetMostRecentlyPlayedVersion(candidate.LastWatchedVersions, user, lastWatchedUserData);
|
|
||||||
var nextEpisode = GetPreferredVersion(candidate.Episode, candidate.LastWatched, playedVersion);
|
|
||||||
|
|
||||||
DateTime lastWatchedDate = DateTime.MinValue;
|
|
||||||
if (candidate.LastWatched is not null)
|
|
||||||
{
|
|
||||||
lastWatchedDate = lastPlayedDate ?? DateTime.MinValue.AddDays(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
nextUpList.Add((lastWatchedDate, nextEpisode));
|
|
||||||
}
|
|
||||||
|
|
||||||
var sortedEpisodes = nextUpList
|
var sortedEpisodes = nextUpList
|
||||||
.OrderByDescending(x => x.LastWatchedDate)
|
.OrderByDescending(x => x.LastWatchedDate)
|
||||||
.Select(x => (BaseItem)x.Episode);
|
.Select(x => (BaseItem)x.Episode);
|
||||||
@@ -226,25 +178,12 @@ namespace Emby.Server.Implementations.TV
|
|||||||
return GetResult(sortedEpisodes, request);
|
return GetResult(sortedEpisodes, request);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void AddCandidate(List<BaseItem> candidates, BaseItem? item)
|
private Episode? DetermineNextEpisode(
|
||||||
{
|
MediaBrowser.Controller.Persistence.NextUpEpisodeBatchResult result,
|
||||||
if (item is not null)
|
|
||||||
{
|
|
||||||
candidates.Add(item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private UserItemData? GetUserData(User user, BaseItem item, IReadOnlyDictionary<Guid, UserItemData> prefetchedUserData)
|
|
||||||
=> prefetchedUserData.TryGetValue(item.Id, out var userData)
|
|
||||||
? userData
|
|
||||||
: _userDataManager.GetUserData(user, item);
|
|
||||||
|
|
||||||
private Episode? SelectNextEpisode(
|
|
||||||
NextUpEpisodeBatchResult result,
|
|
||||||
User user,
|
User user,
|
||||||
bool includeSpecials,
|
bool includeSpecials,
|
||||||
bool includePlayed,
|
bool includeResumable,
|
||||||
IReadOnlyDictionary<Guid, UserItemData> prefetchedUserData)
|
bool includePlayed)
|
||||||
{
|
{
|
||||||
var nextEpisode = (includePlayed ? result.NextPlayedForRewatching : result.NextUp) as Episode;
|
var nextEpisode = (includePlayed ? result.NextPlayedForRewatching : result.NextUp) as Episode;
|
||||||
var lastWatchedEpisode = (includePlayed ? result.LastWatchedForRewatching : result.LastWatched) as Episode;
|
var lastWatchedEpisode = (includePlayed ? result.LastWatchedForRewatching : result.LastWatched) as Episode;
|
||||||
@@ -278,41 +217,60 @@ namespace Emby.Server.Implementations.TV
|
|||||||
|
|
||||||
if (!includePlayed)
|
if (!includePlayed)
|
||||||
{
|
{
|
||||||
sortedEpisodes = sortedEpisodes.Where(episode => GetUserData(user, episode, prefetchedUserData) is not { Played: true });
|
sortedEpisodes = sortedEpisodes.Where(episode => _userDataManager.GetUserData(user, episode) is not { Played: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
nextEpisode = sortedEpisodes.FirstOrDefault();
|
nextEpisode = sortedEpisodes.FirstOrDefault();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (nextEpisode is not null && !includeResumable)
|
||||||
|
{
|
||||||
|
// The resume progress may live on an alternate version
|
||||||
|
foreach (var version in nextEpisode.GetAllVersions())
|
||||||
|
{
|
||||||
|
if (_userDataManager.GetUserData(user, version)?.PlaybackPositionTicks > 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nextEpisode;
|
return nextEpisode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Episode? DetermineNextEpisodeForRewatching(
|
||||||
|
MediaBrowser.Controller.Persistence.NextUpEpisodeBatchResult result,
|
||||||
|
User user,
|
||||||
|
bool includeSpecials)
|
||||||
|
{
|
||||||
|
return DetermineNextEpisode(result, user, includeSpecials, includeResumable: false, includePlayed: true);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the version of the last watched episode that was actually played, together with its last played date.
|
/// Gets the version of the last watched episode that was actually played, together with its last played date.
|
||||||
/// The version that was played carries the most recent LastPlayedDate.
|
/// The version that was played carries the most recent LastPlayedDate.
|
||||||
/// dates.
|
/// dates.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="versions">The versions of the last watched episode.</param>
|
/// <param name="lastWatched">The last watched episode (any version).</param>
|
||||||
/// <param name="user">The user.</param>
|
/// <param name="user">The user.</param>
|
||||||
/// <param name="prefetchedUserData">User data read for every version up front.</param>
|
|
||||||
/// <returns>The played version and its last played date.</returns>
|
/// <returns>The played version and its last played date.</returns>
|
||||||
private (Video? PlayedVersion, DateTime? LastPlayedDate) GetMostRecentlyPlayedVersion(
|
private (Video? PlayedVersion, DateTime? LastPlayedDate) GetMostRecentlyPlayedVersion(BaseItem? lastWatched, User user)
|
||||||
IReadOnlyList<Video> versions,
|
|
||||||
User user,
|
|
||||||
IReadOnlyDictionary<Guid, UserItemData> prefetchedUserData)
|
|
||||||
{
|
{
|
||||||
if (versions.Count == 0)
|
if (lastWatched is not Video lastWatchedVideo)
|
||||||
{
|
{
|
||||||
return (null, null);
|
return (null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var versions = lastWatchedVideo.GetAllVersions();
|
||||||
|
var userDataByVersion = _userDataManager.GetUserDataBatch(versions, user);
|
||||||
|
|
||||||
var playedVersion = VersionPlaybackSelector.SelectMostRecentlyPlayed(
|
var playedVersion = VersionPlaybackSelector.SelectMostRecentlyPlayed(
|
||||||
versions,
|
versions,
|
||||||
version => GetUserData(user, version, prefetchedUserData),
|
version => userDataByVersion.GetValueOrDefault(version.Id),
|
||||||
data => data.LastPlayedDate.HasValue);
|
data => data.LastPlayedDate.HasValue);
|
||||||
|
|
||||||
return (playedVersion, playedVersion is null ? null : GetUserData(user, playedVersion, prefetchedUserData)?.LastPlayedDate);
|
return (playedVersion, playedVersion is null ? null : userDataByVersion[playedVersion.Id].LastPlayedDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -388,28 +346,5 @@ namespace Emby.Server.Implementations.TV
|
|||||||
totalCount,
|
totalCount,
|
||||||
items.ToArray());
|
items.ToArray());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// An episode picked for Next Up, together with the versions its user data is read from.
|
|
||||||
/// </summary>
|
|
||||||
private sealed class NextUpCandidate
|
|
||||||
{
|
|
||||||
public NextUpCandidate(Episode episode, BaseItem? lastWatched, bool dropWhenResumed)
|
|
||||||
{
|
|
||||||
Episode = episode;
|
|
||||||
LastWatched = lastWatched;
|
|
||||||
DropWhenResumed = dropWhenResumed;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Episode Episode { get; }
|
|
||||||
|
|
||||||
public BaseItem? LastWatched { get; }
|
|
||||||
|
|
||||||
public bool DropWhenResumed { get; }
|
|
||||||
|
|
||||||
public IReadOnlyList<Video> EpisodeVersions { get; set; } = [];
|
|
||||||
|
|
||||||
public IReadOnlyList<Video> LastWatchedVersions { get; set; } = [];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,6 +112,10 @@ namespace Jellyfin.Server
|
|||||||
// instance. Active by default once a Redis connection is configured, no-op otherwise.
|
// instance. Active by default once a Redis connection is configured, no-op otherwise.
|
||||||
serviceCollection.AddScanLeaderLease(_startupConfig, Logger);
|
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);
|
||||||
|
|
||||||
foreach (var type in GetExportTypes<ILyricProvider>())
|
foreach (var type in GetExportTypes<ILyricProvider>())
|
||||||
{
|
{
|
||||||
serviceCollection.AddSingleton(typeof(ILyricProvider), type);
|
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,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>
|
/// </summary>
|
||||||
/// <param name="factories">The factories.</param>
|
/// <param name="factories">The factories.</param>
|
||||||
void AddParts(IEnumerable<IConfigurationFactory> factories);
|
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
|
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)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ using System.Threading.Tasks;
|
|||||||
using Jellyfin.Data.Enums;
|
using Jellyfin.Data.Enums;
|
||||||
using Jellyfin.Database.Implementations.Entities;
|
using Jellyfin.Database.Implementations.Entities;
|
||||||
using Jellyfin.Extensions.Json;
|
using Jellyfin.Extensions.Json;
|
||||||
|
using MediaBrowser.Common.Configuration;
|
||||||
using MediaBrowser.Controller.IO;
|
using MediaBrowser.Controller.IO;
|
||||||
using MediaBrowser.Controller.Library;
|
using MediaBrowser.Controller.Library;
|
||||||
using MediaBrowser.Controller.Providers;
|
using MediaBrowser.Controller.Providers;
|
||||||
@@ -70,6 +71,12 @@ namespace MediaBrowser.Controller.Entities
|
|||||||
|
|
||||||
public static IServerApplicationHost ApplicationHost { get; set; }
|
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]
|
[JsonIgnore]
|
||||||
public override bool SupportsPlayedStatus => false;
|
public override bool SupportsPlayedStatus => false;
|
||||||
|
|
||||||
@@ -188,11 +195,36 @@ namespace MediaBrowser.Controller.Entities
|
|||||||
XmlSerializer.SerializeToFile(clone, GetLibraryOptionsPath(path));
|
XmlSerializer.SerializeToFile(clone, GetLibraryOptionsPath(path));
|
||||||
|
|
||||||
LibraryOptionsUpdated?.Invoke(null, new LibraryOptionsUpdatedEventArgs(path, options));
|
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();
|
=> _libraryOptions.Clear();
|
||||||
|
|
||||||
|
public static void OnCollectionFolderChange()
|
||||||
|
{
|
||||||
|
InvalidateAllLibraryOptions();
|
||||||
|
|
||||||
|
InvalidationBus.PublishLocalWrite(ConfigurationInvalidationScope.AllLibraryOptions, null);
|
||||||
|
}
|
||||||
|
|
||||||
public override bool IsSaveLocalMetadataEnabled()
|
public override bool IsSaveLocalMetadataEnabled()
|
||||||
{
|
{
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -449,26 +449,19 @@ namespace MediaBrowser.Controller.Entities
|
|||||||
IUserDataManager userDataManager,
|
IUserDataManager userDataManager,
|
||||||
ILibraryManager libraryManager)
|
ILibraryManager libraryManager)
|
||||||
{
|
{
|
||||||
var itemList = items as IReadOnlyList<BaseItem> ?? items.ToList();
|
var filtered = items.Where(i => Filter(i, user, query, userDataManager, libraryManager));
|
||||||
|
|
||||||
// The user data checks below run per item, so read them all in one query up front.
|
|
||||||
var userDataBatch = user is not null && RequiresUserData(query)
|
|
||||||
? userDataManager.GetUserDataBatch(itemList, user)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
var filtered = itemList.Where(i => Filter(i, user, query, userDataManager, libraryManager, userDataBatch));
|
|
||||||
|
|
||||||
if (query.IsPlayed.HasValue && user is not null)
|
if (query.IsPlayed.HasValue && user is not null)
|
||||||
{
|
{
|
||||||
var filteredList = filtered.ToList();
|
var itemList = filtered.ToList();
|
||||||
var folderIds = filteredList.OfType<Folder>().Select(f => f.Id).ToList();
|
var folderIds = itemList.OfType<Folder>().Select(f => f.Id).ToList();
|
||||||
|
|
||||||
if (folderIds.Count > 0)
|
if (folderIds.Count > 0)
|
||||||
{
|
{
|
||||||
var counts = libraryManager.GetPlayedAndTotalCountBatch(folderIds, user);
|
var counts = libraryManager.GetPlayedAndTotalCountBatch(folderIds, user);
|
||||||
var isPlayedValue = query.IsPlayed.Value;
|
var isPlayedValue = query.IsPlayed.Value;
|
||||||
|
|
||||||
return filteredList.Where(item =>
|
return itemList.Where(item =>
|
||||||
{
|
{
|
||||||
if (item is Folder)
|
if (item is Folder)
|
||||||
{
|
{
|
||||||
@@ -480,7 +473,7 @@ namespace MediaBrowser.Controller.Entities
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return filteredList;
|
return itemList;
|
||||||
}
|
}
|
||||||
|
|
||||||
return filtered;
|
return filtered;
|
||||||
@@ -522,29 +515,12 @@ namespace MediaBrowser.Controller.Entities
|
|||||||
itemsArray);
|
itemsArray);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool RequiresUserData(InternalItemsQuery query)
|
|
||||||
=> query.IsLiked.HasValue
|
|
||||||
|| query.IsFavoriteOrLiked.HasValue
|
|
||||||
|| query.IsFavorite.HasValue
|
|
||||||
|| query.IsResumable.HasValue
|
|
||||||
|| query.IsPlayed.HasValue;
|
|
||||||
|
|
||||||
private static UserItemData GetUserData(
|
|
||||||
IUserDataManager userDataManager,
|
|
||||||
User user,
|
|
||||||
BaseItem item,
|
|
||||||
Dictionary<Guid, UserItemData> userDataBatch)
|
|
||||||
=> userDataBatch is not null && userDataBatch.TryGetValue(item.Id, out var userData)
|
|
||||||
? userData
|
|
||||||
: userDataManager.GetUserData(user, item);
|
|
||||||
|
|
||||||
private static bool Filter(
|
private static bool Filter(
|
||||||
BaseItem item,
|
BaseItem item,
|
||||||
User user,
|
User user,
|
||||||
InternalItemsQuery query,
|
InternalItemsQuery query,
|
||||||
IUserDataManager userDataManager,
|
IUserDataManager userDataManager,
|
||||||
ILibraryManager libraryManager,
|
ILibraryManager libraryManager)
|
||||||
Dictionary<Guid, UserItemData> userDataBatch)
|
|
||||||
{
|
{
|
||||||
if (!string.IsNullOrEmpty(query.NameStartsWith) && !item.SortName.StartsWith(query.NameStartsWith, StringComparison.InvariantCultureIgnoreCase))
|
if (!string.IsNullOrEmpty(query.NameStartsWith) && !item.SortName.StartsWith(query.NameStartsWith, StringComparison.InvariantCultureIgnoreCase))
|
||||||
{
|
{
|
||||||
@@ -592,7 +568,7 @@ namespace MediaBrowser.Controller.Entities
|
|||||||
|
|
||||||
if (query.IsLiked.HasValue)
|
if (query.IsLiked.HasValue)
|
||||||
{
|
{
|
||||||
userData = GetUserData(userDataManager, user, item, userDataBatch);
|
userData = userDataManager.GetUserData(user, item);
|
||||||
if (!userData.Likes.HasValue || userData.Likes != query.IsLiked.Value)
|
if (!userData.Likes.HasValue || userData.Likes != query.IsLiked.Value)
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
@@ -601,7 +577,7 @@ namespace MediaBrowser.Controller.Entities
|
|||||||
|
|
||||||
if (query.IsFavoriteOrLiked.HasValue)
|
if (query.IsFavoriteOrLiked.HasValue)
|
||||||
{
|
{
|
||||||
userData ??= GetUserData(userDataManager, user, item, userDataBatch);
|
userData ??= userDataManager.GetUserData(user, item);
|
||||||
var isFavoriteOrLiked = userData.IsFavorite || (userData.Likes ?? false);
|
var isFavoriteOrLiked = userData.IsFavorite || (userData.Likes ?? false);
|
||||||
|
|
||||||
if (isFavoriteOrLiked != query.IsFavoriteOrLiked.Value)
|
if (isFavoriteOrLiked != query.IsFavoriteOrLiked.Value)
|
||||||
@@ -612,7 +588,7 @@ namespace MediaBrowser.Controller.Entities
|
|||||||
|
|
||||||
if (query.IsFavorite.HasValue)
|
if (query.IsFavorite.HasValue)
|
||||||
{
|
{
|
||||||
userData ??= GetUserData(userDataManager, user, item, userDataBatch);
|
userData ??= userDataManager.GetUserData(user, item);
|
||||||
if (userData.IsFavorite != query.IsFavorite.Value)
|
if (userData.IsFavorite != query.IsFavorite.Value)
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
@@ -621,7 +597,7 @@ namespace MediaBrowser.Controller.Entities
|
|||||||
|
|
||||||
if (query.IsResumable.HasValue)
|
if (query.IsResumable.HasValue)
|
||||||
{
|
{
|
||||||
userData ??= GetUserData(userDataManager, user, item, userDataBatch);
|
userData ??= userDataManager.GetUserData(user, item);
|
||||||
var isResumable = userData.PlaybackPositionTicks > 0;
|
var isResumable = userData.PlaybackPositionTicks > 0;
|
||||||
|
|
||||||
if (isResumable != query.IsResumable.Value)
|
if (isResumable != query.IsResumable.Value)
|
||||||
@@ -636,7 +612,7 @@ namespace MediaBrowser.Controller.Entities
|
|||||||
// Folders are batch-filtered by the collection Filter() overload.
|
// Folders are batch-filtered by the collection Filter() overload.
|
||||||
if (!item.IsFolder)
|
if (!item.IsFolder)
|
||||||
{
|
{
|
||||||
userData ??= GetUserData(userDataManager, user, item, userDataBatch);
|
userData ??= userDataManager.GetUserData(user, item);
|
||||||
if (item.IsPlayed(user, userData) != query.IsPlayed.Value)
|
if (item.IsPlayed(user, userData) != query.IsPlayed.Value)
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using Jellyfin.Database.Implementations.Entities;
|
using Jellyfin.Database.Implementations.Entities;
|
||||||
using MediaBrowser.Controller.Entities;
|
|
||||||
using MediaBrowser.Controller.Library;
|
using MediaBrowser.Controller.Library;
|
||||||
|
|
||||||
namespace MediaBrowser.Controller.Sorting
|
namespace MediaBrowser.Controller.Sorting
|
||||||
@@ -30,16 +27,5 @@ namespace MediaBrowser.Controller.Sorting
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <value>The user data repository.</value>
|
/// <value>The user data repository.</value>
|
||||||
IUserDataManager UserDataManager { get; set; }
|
IUserDataManager UserDataManager { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets user data for the items being sorted, keyed by item id, read once up front.
|
|
||||||
/// A comparer that does not store it reads its user data one item at a time instead.
|
|
||||||
/// </summary>
|
|
||||||
/// <value>The prefetched user data, or <c>null</c> when none was prefetched.</value>
|
|
||||||
IReadOnlyDictionary<Guid, UserItemData> PrefetchedUserData
|
|
||||||
{
|
|
||||||
get => null;
|
|
||||||
set { }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
#nullable disable
|
|
||||||
|
|
||||||
using MediaBrowser.Controller.Entities;
|
|
||||||
|
|
||||||
namespace MediaBrowser.Controller.Sorting
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Helpers shared by the comparers that sort on user data.
|
|
||||||
/// </summary>
|
|
||||||
public static class UserBaseItemComparerExtensions
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the user data for an item, preferring the batch the sort prefetched.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="comparer">The comparer.</param>
|
|
||||||
/// <param name="item">The item.</param>
|
|
||||||
/// <returns>The item's user data.</returns>
|
|
||||||
public static UserItemData GetUserData(this IUserBaseItemComparer comparer, BaseItem item)
|
|
||||||
{
|
|
||||||
if (comparer.PrefetchedUserData is not null && comparer.PrefetchedUserData.TryGetValue(item.Id, out var userData))
|
|
||||||
{
|
|
||||||
return userData;
|
|
||||||
}
|
|
||||||
|
|
||||||
return comparer.UserDataManager.GetUserData(comparer.User, item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -212,8 +212,7 @@ namespace Jellyfin.LiveTv.Channels
|
|||||||
if (query.IsFavorite.HasValue)
|
if (query.IsFavorite.HasValue)
|
||||||
{
|
{
|
||||||
var val = query.IsFavorite.Value;
|
var val = query.IsFavorite.Value;
|
||||||
var userData = _userDataManager.GetUserDataBatch(channels, user);
|
channels = channels.Where(i => _userDataManager.GetUserData(user, i).IsFavorite == val)
|
||||||
channels = channels.Where(i => userData.TryGetValue(i.Id, out var data) && data.IsFavorite == val)
|
|
||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -304,17 +304,8 @@ namespace Jellyfin.LiveTv
|
|||||||
|
|
||||||
if (query.IsAiring ?? false)
|
if (query.IsAiring ?? false)
|
||||||
{
|
{
|
||||||
// Scoring reads the channel's user data per program, so read every channel's in one query.
|
|
||||||
var channels = programList
|
|
||||||
.Cast<LiveTvProgram>()
|
|
||||||
.Select(i => _libraryManager.GetItemById(i.ChannelId))
|
|
||||||
.OfType<BaseItem>()
|
|
||||||
.DistinctBy(i => i.Id)
|
|
||||||
.ToList();
|
|
||||||
var channelUserData = _userDataManager.GetUserDataBatch(channels, user);
|
|
||||||
|
|
||||||
orderedPrograms = orderedPrograms
|
orderedPrograms = orderedPrograms
|
||||||
.ThenByDescending(i => GetRecommendationScore(i, user, true, channelUserData));
|
.ThenByDescending(i => GetRecommendationScore(i, user, true));
|
||||||
}
|
}
|
||||||
|
|
||||||
IEnumerable<BaseItem> programs = orderedPrograms;
|
IEnumerable<BaseItem> programs = orderedPrograms;
|
||||||
@@ -347,11 +338,7 @@ namespace Jellyfin.LiveTv
|
|||||||
_dtoService.GetBaseItemDtos(internalResult.Items, options, query.User)));
|
_dtoService.GetBaseItemDtos(internalResult.Items, options, query.User)));
|
||||||
}
|
}
|
||||||
|
|
||||||
private int GetRecommendationScore(
|
private int GetRecommendationScore(LiveTvProgram program, User user, bool factorChannelWatchCount)
|
||||||
LiveTvProgram program,
|
|
||||||
User user,
|
|
||||||
bool factorChannelWatchCount,
|
|
||||||
IReadOnlyDictionary<Guid, UserItemData> channelUserData)
|
|
||||||
{
|
{
|
||||||
var score = 0;
|
var score = 0;
|
||||||
|
|
||||||
@@ -372,9 +359,7 @@ namespace Jellyfin.LiveTv
|
|||||||
return score;
|
return score;
|
||||||
}
|
}
|
||||||
|
|
||||||
var channelUserdata = channelUserData.TryGetValue(channel.Id, out var cached)
|
var channelUserdata = _userDataManager.GetUserData(user, channel);
|
||||||
? cached
|
|
||||||
: _userDataManager.GetUserData(user, channel);
|
|
||||||
|
|
||||||
if (channelUserdata.Likes.HasValue)
|
if (channelUserdata.Likes.HasValue)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -444,6 +444,13 @@ public sealed class RecordingsManager : IRecordingsManager, IDisposable
|
|||||||
|
|
||||||
private async void OnNamedConfigurationUpdated(object? sender, ConfigurationUpdateEventArgs e)
|
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))
|
if (string.Equals(e.Key, "livetv", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
await CreateRecordingFolders().ConfigureAwait(false);
|
await CreateRecordingFolders().ConfigureAwait(false);
|
||||||
|
|||||||
+81
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+73
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+161
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+104
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+205
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+147
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,6 @@ using Emby.Naming.Common;
|
|||||||
using Emby.Server.Implementations.Library;
|
using Emby.Server.Implementations.Library;
|
||||||
using Emby.Server.Implementations.Sorting;
|
using Emby.Server.Implementations.Sorting;
|
||||||
using Jellyfin.Data.Enums;
|
using Jellyfin.Data.Enums;
|
||||||
using Jellyfin.Database.Implementations.Entities;
|
|
||||||
using Jellyfin.Database.Implementations.Enums;
|
using Jellyfin.Database.Implementations.Enums;
|
||||||
using MediaBrowser.Controller.Configuration;
|
using MediaBrowser.Controller.Configuration;
|
||||||
using MediaBrowser.Controller.Entities;
|
using MediaBrowser.Controller.Entities;
|
||||||
@@ -64,49 +63,13 @@ public class LibraryManagerSortTests
|
|||||||
Assert.Equal(new[] { "Alpha", "Mike", "Zulu" }, sorted.Select(i => i.Name));
|
Assert.Equal(new[] { "Alpha", "Mike", "Zulu" }, sorted.Select(i => i.Name));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Sort_ComparerThatIgnoresPrefetchedUserData_StillSortsFromLiveReads()
|
|
||||||
{
|
|
||||||
var alpha = new Audio { Name = "Alpha", SortName = "Alpha", Id = Guid.NewGuid() };
|
|
||||||
var zulu = new Audio { Name = "Zulu", SortName = "Zulu", Id = Guid.NewGuid() };
|
|
||||||
var playCounts = new Dictionary<Guid, int> { [alpha.Id] = 1, [zulu.Id] = 9 };
|
|
||||||
|
|
||||||
var userDataManager = new Mock<IUserDataManager>();
|
|
||||||
userDataManager
|
|
||||||
.Setup(u => u.GetUserData(It.IsAny<User>(), It.IsAny<BaseItem>()))
|
|
||||||
.Returns<User, BaseItem>((_, item) => new UserItemData { Key = item.Id.ToString("N"), PlayCount = playCounts[item.Id] });
|
|
||||||
userDataManager
|
|
||||||
.Setup(u => u.GetUserDataBatch(It.IsAny<IReadOnlyList<BaseItem>>(), It.IsAny<User>()))
|
|
||||||
.Returns(new Dictionary<Guid, UserItemData>());
|
|
||||||
|
|
||||||
var libraryManager = CreateLibraryManager(
|
|
||||||
new IBaseItemComparer[] { new PluginPlayCountComparer() },
|
|
||||||
userDataManager);
|
|
||||||
|
|
||||||
var sorted = libraryManager.Sort(
|
|
||||||
new BaseItem[] { alpha, zulu },
|
|
||||||
new User("sorter", "provider", "provider"),
|
|
||||||
new[] { (ItemSortBy.PlayCount, SortOrder.Descending) }).ToArray();
|
|
||||||
|
|
||||||
Assert.Equal(new[] { "Zulu", "Alpha" }, sorted.Select(i => i.Name));
|
|
||||||
userDataManager.Verify(u => u.GetUserData(It.IsAny<User>(), It.IsAny<BaseItem>()), Times.AtLeastOnce);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Folder MakeFolder(string name, DateTime dateLastMediaAdded)
|
private static Folder MakeFolder(string name, DateTime dateLastMediaAdded)
|
||||||
=> new() { Name = name, Id = Guid.NewGuid(), DateLastMediaAdded = dateLastMediaAdded };
|
=> new() { Name = name, Id = Guid.NewGuid(), DateLastMediaAdded = dateLastMediaAdded };
|
||||||
|
|
||||||
private static Emby.Server.Implementations.Library.LibraryManager CreateLibraryManager(
|
private static Emby.Server.Implementations.Library.LibraryManager CreateLibraryManager(IReadOnlyCollection<IBaseItemComparer> comparers)
|
||||||
IReadOnlyCollection<IBaseItemComparer> comparers,
|
|
||||||
Mock<IUserDataManager>? userDataManager = null)
|
|
||||||
{
|
{
|
||||||
var fixture = new Fixture().Customize(new AutoMoqCustomization());
|
var fixture = new Fixture().Customize(new AutoMoqCustomization());
|
||||||
fixture.Register(() => new NamingOptions());
|
fixture.Register(() => new NamingOptions());
|
||||||
|
|
||||||
if (userDataManager is not null)
|
|
||||||
{
|
|
||||||
fixture.Inject(userDataManager.Object);
|
|
||||||
}
|
|
||||||
|
|
||||||
var configMock = fixture.Freeze<Mock<IServerConfigurationManager>>();
|
var configMock = fixture.Freeze<Mock<IServerConfigurationManager>>();
|
||||||
configMock.Setup(c => c.ApplicationPaths.ProgramDataPath).Returns("/data");
|
configMock.Setup(c => c.ApplicationPaths.ProgramDataPath).Returns("/data");
|
||||||
BaseItem.ConfigurationManager ??= configMock.Object;
|
BaseItem.ConfigurationManager ??= configMock.Object;
|
||||||
@@ -123,22 +86,4 @@ public class LibraryManagerSortTests
|
|||||||
fixture.Create<IEnumerable<ILibraryPostScanTask>>()))
|
fixture.Create<IEnumerable<ILibraryPostScanTask>>()))
|
||||||
.Create();
|
.Create();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A comparer of the shape a third-party plugin ships: it implements
|
|
||||||
/// <see cref="IUserBaseItemComparer"/> without ever mentioning PrefetchedUserData.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class PluginPlayCountComparer : IUserBaseItemComparer
|
|
||||||
{
|
|
||||||
public User User { get; set; } = null!;
|
|
||||||
|
|
||||||
public IUserManager UserManager { get; set; } = null!;
|
|
||||||
|
|
||||||
public IUserDataManager UserDataManager { get; set; } = null!;
|
|
||||||
|
|
||||||
public ItemSortBy Type => ItemSortBy.PlayCount;
|
|
||||||
|
|
||||||
public int Compare(BaseItem? x, BaseItem? y)
|
|
||||||
=> UserDataManager.GetUserData(User, x!)!.PlayCount.CompareTo(UserDataManager.GetUserData(User, y!)!.PlayCount);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using Emby.Server.Implementations.Library;
|
using Emby.Server.Implementations.Library;
|
||||||
using Jellyfin.Database.Implementations;
|
using Jellyfin.Database.Implementations;
|
||||||
using Jellyfin.Database.Implementations.Entities;
|
using Jellyfin.Database.Implementations.Entities;
|
||||||
@@ -48,12 +49,6 @@ public sealed class UserDataManagerTests : IDisposable
|
|||||||
{
|
{
|
||||||
Id = Guid.NewGuid()
|
Id = Guid.NewGuid()
|
||||||
};
|
};
|
||||||
|
|
||||||
using (var ctx = CreateDbContext())
|
|
||||||
{
|
|
||||||
ctx.Users.Add(_user);
|
|
||||||
ctx.SaveChanges();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
@@ -83,23 +78,6 @@ public sealed class UserDataManagerTests : IDisposable
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private void Seed(AudioBook item, params UserData[] rows)
|
|
||||||
{
|
|
||||||
using var ctx = CreateDbContext();
|
|
||||||
ctx.BaseItems.Add(new BaseItemEntity { Id = item.Id, Type = typeof(AudioBook).FullName! });
|
|
||||||
ctx.UserData.AddRange(rows);
|
|
||||||
ctx.SaveChanges();
|
|
||||||
}
|
|
||||||
|
|
||||||
private User CreateOtherUser()
|
|
||||||
{
|
|
||||||
var user = new User("other", "auth-provider", "reset-provider") { Id = Guid.NewGuid() };
|
|
||||||
using var ctx = CreateDbContext();
|
|
||||||
ctx.Users.Add(user);
|
|
||||||
ctx.SaveChanges();
|
|
||||||
return user;
|
|
||||||
}
|
|
||||||
|
|
||||||
private UserData CreateUserDataRow(AudioBook item, string key, long positionTicks)
|
private UserData CreateUserDataRow(AudioBook item, string key, long positionTicks)
|
||||||
{
|
{
|
||||||
return new UserData
|
return new UserData
|
||||||
@@ -120,10 +98,11 @@ public sealed class UserDataManagerTests : IDisposable
|
|||||||
var currentKey = item.GetUserDataKeys()[0];
|
var currentKey = item.GetUserDataKeys()[0];
|
||||||
|
|
||||||
// the retired-key row comes first to ensure selection is by key, not row order
|
// the retired-key row comes first to ensure selection is by key, not row order
|
||||||
Seed(
|
item.UserData = new List<UserData>
|
||||||
item,
|
{
|
||||||
CreateUserDataRow(item, "Author-Old Album-0001Old File Name", 111),
|
CreateUserDataRow(item, "Author-Old Album-0001Old File Name", 111),
|
||||||
CreateUserDataRow(item, currentKey, 222));
|
CreateUserDataRow(item, currentKey, 222)
|
||||||
|
};
|
||||||
|
|
||||||
var userData = _userDataManager.GetUserData(_user, item);
|
var userData = _userDataManager.GetUserData(_user, item);
|
||||||
|
|
||||||
@@ -138,10 +117,11 @@ public sealed class UserDataManagerTests : IDisposable
|
|||||||
var item = CreateAudioBook();
|
var item = CreateAudioBook();
|
||||||
var idKey = item.GetUserDataKeys()[1];
|
var idKey = item.GetUserDataKeys()[1];
|
||||||
|
|
||||||
Seed(
|
item.UserData = new List<UserData>
|
||||||
item,
|
{
|
||||||
CreateUserDataRow(item, "Author-Old Album-0001Old File Name", 111),
|
CreateUserDataRow(item, "Author-Old Album-0001Old File Name", 111),
|
||||||
CreateUserDataRow(item, idKey, 333));
|
CreateUserDataRow(item, idKey, 333)
|
||||||
|
};
|
||||||
|
|
||||||
var userData = _userDataManager.GetUserData(_user, item);
|
var userData = _userDataManager.GetUserData(_user, item);
|
||||||
|
|
||||||
@@ -155,7 +135,10 @@ public sealed class UserDataManagerTests : IDisposable
|
|||||||
{
|
{
|
||||||
var item = CreateAudioBook();
|
var item = CreateAudioBook();
|
||||||
|
|
||||||
Seed(item, CreateUserDataRow(item, "Author-Old Album-0001Old File Name", 111));
|
item.UserData = new List<UserData>
|
||||||
|
{
|
||||||
|
CreateUserDataRow(item, "Author-Old Album-0001Old File Name", 111)
|
||||||
|
};
|
||||||
|
|
||||||
var userData = _userDataManager.GetUserData(_user, item);
|
var userData = _userDataManager.GetUserData(_user, item);
|
||||||
|
|
||||||
@@ -167,7 +150,7 @@ public sealed class UserDataManagerTests : IDisposable
|
|||||||
public void GetUserData_NoRows_ReturnsDefaultWithPrimaryKey()
|
public void GetUserData_NoRows_ReturnsDefaultWithPrimaryKey()
|
||||||
{
|
{
|
||||||
var item = CreateAudioBook();
|
var item = CreateAudioBook();
|
||||||
Seed(item);
|
item.UserData = new List<UserData>();
|
||||||
|
|
||||||
var userData = _userDataManager.GetUserData(_user, item);
|
var userData = _userDataManager.GetUserData(_user, item);
|
||||||
|
|
||||||
@@ -183,9 +166,13 @@ public sealed class UserDataManagerTests : IDisposable
|
|||||||
var currentKey = item.GetUserDataKeys()[0];
|
var currentKey = item.GetUserDataKeys()[0];
|
||||||
|
|
||||||
var otherUserRow = CreateUserDataRow(item, currentKey, 999);
|
var otherUserRow = CreateUserDataRow(item, currentKey, 999);
|
||||||
otherUserRow.UserId = CreateOtherUser().Id;
|
otherUserRow.UserId = Guid.NewGuid();
|
||||||
|
|
||||||
Seed(item, otherUserRow, CreateUserDataRow(item, currentKey, 222));
|
item.UserData = new List<UserData>
|
||||||
|
{
|
||||||
|
otherUserRow,
|
||||||
|
CreateUserDataRow(item, currentKey, 222)
|
||||||
|
};
|
||||||
|
|
||||||
var userData = _userDataManager.GetUserData(_user, item);
|
var userData = _userDataManager.GetUserData(_user, item);
|
||||||
|
|
||||||
@@ -196,15 +183,23 @@ public sealed class UserDataManagerTests : IDisposable
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void GetUserDataBatch_DatabaseFallback_ResolvesRowsByKeyOrder()
|
public void GetUserDataBatch_DatabaseFallback_ResolvesRowsByKeyOrder()
|
||||||
{
|
{
|
||||||
|
// no preloaded navigation data, so the batch takes the database fallback
|
||||||
var fossilItem = CreateAudioBook();
|
var fossilItem = CreateAudioBook();
|
||||||
var retiredItem = CreateAudioBook();
|
var retiredItem = CreateAudioBook();
|
||||||
|
|
||||||
// the stale id-key row is inserted first so selection by row order would return it
|
using (var ctx = CreateDbContext())
|
||||||
Seed(
|
{
|
||||||
fossilItem,
|
ctx.Users.Add(_user);
|
||||||
CreateUserDataRow(fossilItem, fossilItem.GetUserDataKeys()[1], 111),
|
ctx.BaseItems.Add(new BaseItemEntity { Id = fossilItem.Id, Type = typeof(AudioBook).FullName! });
|
||||||
CreateUserDataRow(fossilItem, fossilItem.GetUserDataKeys()[0], 222));
|
ctx.BaseItems.Add(new BaseItemEntity { Id = retiredItem.Id, Type = typeof(AudioBook).FullName! });
|
||||||
Seed(retiredItem, CreateUserDataRow(retiredItem, "Author-Old Album-0001Old File Name", 333));
|
|
||||||
|
// the stale id-key row is inserted first so selection by row order would return it
|
||||||
|
ctx.UserData.AddRange(
|
||||||
|
CreateUserDataRow(fossilItem, fossilItem.GetUserDataKeys()[1], 111),
|
||||||
|
CreateUserDataRow(fossilItem, fossilItem.GetUserDataKeys()[0], 222),
|
||||||
|
CreateUserDataRow(retiredItem, "Author-Old Album-0001Old File Name", 333));
|
||||||
|
ctx.SaveChanges();
|
||||||
|
}
|
||||||
|
|
||||||
var result = _userDataManager.GetUserDataBatch([fossilItem, retiredItem], _user);
|
var result = _userDataManager.GetUserDataBatch([fossilItem, retiredItem], _user);
|
||||||
|
|
||||||
|
|||||||
@@ -1,94 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Globalization;
|
|
||||||
using System.Linq;
|
|
||||||
using Emby.Server.Implementations.TV;
|
|
||||||
using Jellyfin.Database.Implementations.Entities;
|
|
||||||
using MediaBrowser.Controller.Configuration;
|
|
||||||
using MediaBrowser.Controller.Dto;
|
|
||||||
using MediaBrowser.Controller.Entities;
|
|
||||||
using MediaBrowser.Controller.Entities.TV;
|
|
||||||
using MediaBrowser.Controller.Library;
|
|
||||||
using MediaBrowser.Controller.Persistence;
|
|
||||||
using MediaBrowser.Model.Configuration;
|
|
||||||
using MediaBrowser.Model.Querying;
|
|
||||||
using Moq;
|
|
||||||
using Xunit;
|
|
||||||
|
|
||||||
namespace Jellyfin.Server.Implementations.Tests.TV;
|
|
||||||
|
|
||||||
public class TVSeriesManagerNextUpTests
|
|
||||||
{
|
|
||||||
[Theory]
|
|
||||||
[InlineData(1)]
|
|
||||||
[InlineData(25)]
|
|
||||||
[InlineData(200)]
|
|
||||||
public void GetNextUp_ReadsUserDataInABoundedNumberOfQueries(int seriesCount)
|
|
||||||
{
|
|
||||||
var user = new User("next-up", "provider", "provider");
|
|
||||||
var libraryManager = new Mock<ILibraryManager>();
|
|
||||||
var userDataManager = new Mock<IUserDataManager>();
|
|
||||||
|
|
||||||
var seriesKeys = Enumerable.Range(0, seriesCount)
|
|
||||||
.Select(i => i.ToString(CultureInfo.InvariantCulture))
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
var batch = seriesKeys.ToDictionary(
|
|
||||||
key => key,
|
|
||||||
key => new NextUpEpisodeBatchResult
|
|
||||||
{
|
|
||||||
NextUp = new Episode { Id = Guid.NewGuid(), Name = "Next " + key },
|
|
||||||
LastWatched = new Episode { Id = Guid.NewGuid(), Name = "Watched " + key }
|
|
||||||
});
|
|
||||||
|
|
||||||
libraryManager
|
|
||||||
.Setup(l => l.GetNextUpSeriesKeys(It.IsAny<InternalItemsQuery>(), It.IsAny<IReadOnlyCollection<BaseItem>>(), It.IsAny<DateTime>()))
|
|
||||||
.Returns(seriesKeys);
|
|
||||||
libraryManager
|
|
||||||
.Setup(l => l.GetNextUpEpisodesBatch(It.IsAny<InternalItemsQuery>(), It.IsAny<IReadOnlyList<string>>(), It.IsAny<bool>(), It.IsAny<bool>()))
|
|
||||||
.Returns(batch);
|
|
||||||
libraryManager.Setup(l => l.GetLinkedAlternateVersions(It.IsAny<Video>())).Returns([]);
|
|
||||||
libraryManager.Setup(l => l.GetLocalAlternateVersionIds(It.IsAny<Video>())).Returns([]);
|
|
||||||
|
|
||||||
var batchReads = 0;
|
|
||||||
userDataManager
|
|
||||||
.Setup(u => u.GetUserDataBatch(It.IsAny<IReadOnlyList<BaseItem>>(), It.IsAny<User>()))
|
|
||||||
.Returns<IReadOnlyList<BaseItem>, User>((items, _) =>
|
|
||||||
{
|
|
||||||
batchReads++;
|
|
||||||
return items.DistinctBy(i => i.Id).ToDictionary(
|
|
||||||
i => i.Id,
|
|
||||||
i => new UserItemData { Key = i.Id.ToString("N", CultureInfo.InvariantCulture) });
|
|
||||||
});
|
|
||||||
|
|
||||||
var previousLibraryManager = BaseItem.LibraryManager;
|
|
||||||
BaseItem.LibraryManager = libraryManager.Object;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var manager = new TVSeriesManager(userDataManager.Object, libraryManager.Object, CreateConfigurationManager());
|
|
||||||
|
|
||||||
var result = manager.GetNextUp(
|
|
||||||
new NextUpQuery { User = user, EnableTotalRecordCount = true },
|
|
||||||
[],
|
|
||||||
new DtoOptions(false));
|
|
||||||
|
|
||||||
Assert.Equal(seriesCount, result.TotalRecordCount);
|
|
||||||
|
|
||||||
// Selection, the resume check and the last played date: three reads whatever the library holds.
|
|
||||||
Assert.Equal(3, batchReads);
|
|
||||||
userDataManager.Verify(u => u.GetUserData(It.IsAny<User>(), It.IsAny<BaseItem>()), Times.Never);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
BaseItem.LibraryManager = previousLibraryManager;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static IServerConfigurationManager CreateConfigurationManager()
|
|
||||||
{
|
|
||||||
var configurationManager = new Mock<IServerConfigurationManager>();
|
|
||||||
configurationManager.SetupGet(c => c.Configuration).Returns(new ServerConfiguration());
|
|
||||||
return configurationManager.Object;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,243 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Globalization;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Emby.Server.Implementations.Library;
|
|
||||||
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 MediaBrowser.Controller.Configuration;
|
|
||||||
using MediaBrowser.Model.Configuration;
|
|
||||||
using MediaBrowser.Model.Entities;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
|
||||||
using Moq;
|
|
||||||
using Npgsql;
|
|
||||||
using Xunit;
|
|
||||||
using AudioBook = MediaBrowser.Controller.Entities.AudioBook;
|
|
||||||
|
|
||||||
namespace Jellyfin.Server.Tests.Library;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Two independently constructed <see cref="UserDataManager"/> 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, and a read-modify-write on one must not roll back the other's.
|
|
||||||
/// </summary>
|
|
||||||
[Trait("Category", "RequiresDocker")]
|
|
||||||
public sealed class UserDataManagerReplicaTests : IClassFixture<UserDataManagerReplicaTests.DatabaseFixture>
|
|
||||||
{
|
|
||||||
private static readonly long _quarterIn = TimeSpan.FromMinutes(20).Ticks;
|
|
||||||
|
|
||||||
private readonly NpgsqlDataSource _dataSource;
|
|
||||||
|
|
||||||
public UserDataManagerReplicaTests(DatabaseFixture fixture)
|
|
||||||
{
|
|
||||||
_dataSource = fixture.DataSource;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A resume position written by the replica serving the playback tick has to be the position the next
|
|
||||||
/// request reads, whichever replica it lands on - both through the single item read the write path uses
|
|
||||||
/// and through the batch read the library pages render from.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
|
||||||
[Fact]
|
|
||||||
public async Task ResumePositionWrittenOnOneReplica_IsReadOnAnother()
|
|
||||||
{
|
|
||||||
var cancellationToken = TestContext.Current.CancellationToken;
|
|
||||||
var itemId = Guid.NewGuid();
|
|
||||||
var user = await CreateUserAndItemAsync(_dataSource, itemId, cancellationToken);
|
|
||||||
|
|
||||||
var replicaA = CreateManager(_dataSource);
|
|
||||||
var replicaB = CreateManager(_dataSource);
|
|
||||||
var itemOnA = new AudioBook { Id = itemId, Name = "Replica Book" };
|
|
||||||
var itemOnB = new AudioBook { Id = itemId, Name = "Replica Book" };
|
|
||||||
|
|
||||||
var early = replicaA.GetUserData(user, itemOnA)!;
|
|
||||||
early.PlaybackPositionTicks = TimeSpan.FromMinutes(5).Ticks;
|
|
||||||
replicaA.SaveUserData(user, itemOnA, early, UserDataSaveReason.PlaybackProgress, cancellationToken);
|
|
||||||
|
|
||||||
// Replica B materialised the item before the later tick, so it holds the earlier row in memory.
|
|
||||||
itemOnB.UserData = await LoadUserDataAsync(_dataSource, itemId, cancellationToken);
|
|
||||||
|
|
||||||
var later = replicaA.GetUserData(user, itemOnA)!;
|
|
||||||
later.PlaybackPositionTicks = _quarterIn;
|
|
||||||
replicaA.SaveUserData(user, itemOnA, later, UserDataSaveReason.PlaybackProgress, cancellationToken);
|
|
||||||
|
|
||||||
Assert.Equal(_quarterIn, replicaB.GetUserData(user, itemOnB)!.PlaybackPositionTicks);
|
|
||||||
Assert.Equal(_quarterIn, replicaB.GetUserDataBatch([itemOnB], user)[itemId].PlaybackPositionTicks);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The playback tick is a read-modify-write of the whole row, so a tick served by one replica must build
|
|
||||||
/// on the favourite another replica just recorded instead of writing it back out.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
|
||||||
[Fact]
|
|
||||||
public async Task PlaybackTickOnOneReplica_KeepsFavouriteSetOnAnother()
|
|
||||||
{
|
|
||||||
var cancellationToken = TestContext.Current.CancellationToken;
|
|
||||||
var itemId = Guid.NewGuid();
|
|
||||||
var user = await CreateUserAndItemAsync(_dataSource, itemId, cancellationToken);
|
|
||||||
|
|
||||||
var replicaA = CreateManager(_dataSource);
|
|
||||||
var replicaB = CreateManager(_dataSource);
|
|
||||||
var itemOnA = new AudioBook { Id = itemId, Name = "Replica Book" };
|
|
||||||
var itemOnB = new AudioBook { Id = itemId, Name = "Replica Book" };
|
|
||||||
|
|
||||||
var seed = replicaA.GetUserData(user, itemOnA)!;
|
|
||||||
seed.PlaybackPositionTicks = TimeSpan.FromMinutes(5).Ticks;
|
|
||||||
replicaA.SaveUserData(user, itemOnA, seed, UserDataSaveReason.PlaybackProgress, cancellationToken);
|
|
||||||
|
|
||||||
// Replica B is serving the playback session and read the item before the favourite was recorded.
|
|
||||||
itemOnB.UserData = await LoadUserDataAsync(_dataSource, itemId, cancellationToken);
|
|
||||||
|
|
||||||
var favourited = replicaA.GetUserData(user, itemOnA)!;
|
|
||||||
favourited.IsFavorite = true;
|
|
||||||
replicaA.SaveUserData(user, itemOnA, favourited, UserDataSaveReason.UpdateUserRating, cancellationToken);
|
|
||||||
|
|
||||||
var tick = replicaB.GetUserData(user, itemOnB)!;
|
|
||||||
tick.PlaybackPositionTicks = _quarterIn;
|
|
||||||
replicaB.SaveUserData(user, itemOnB, tick, UserDataSaveReason.PlaybackProgress, cancellationToken);
|
|
||||||
|
|
||||||
var stored = replicaA.GetUserData(user, itemOnA)!;
|
|
||||||
Assert.True(stored.IsFavorite);
|
|
||||||
Assert.Equal(_quarterIn, stored.PlaybackPositionTicks);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A tick that lands on the other replica has to carry the position forward from where the session
|
|
||||||
/// actually is, not from the position that replica happened to have in memory.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
|
||||||
[Fact]
|
|
||||||
public async Task PlaybackTickOnOneReplica_ResumesFromThePositionAnotherWrote()
|
|
||||||
{
|
|
||||||
var cancellationToken = TestContext.Current.CancellationToken;
|
|
||||||
var itemId = Guid.NewGuid();
|
|
||||||
var user = await CreateUserAndItemAsync(_dataSource, itemId, cancellationToken);
|
|
||||||
|
|
||||||
var replicaA = CreateManager(_dataSource);
|
|
||||||
var replicaB = CreateManager(_dataSource);
|
|
||||||
var itemOnA = new AudioBook { Id = itemId, Name = "Replica Book" };
|
|
||||||
var itemOnB = new AudioBook { Id = itemId, Name = "Replica Book" };
|
|
||||||
|
|
||||||
var seed = replicaA.GetUserData(user, itemOnA)!;
|
|
||||||
seed.PlaybackPositionTicks = TimeSpan.FromMinutes(5).Ticks;
|
|
||||||
replicaA.SaveUserData(user, itemOnA, seed, UserDataSaveReason.PlaybackProgress, cancellationToken);
|
|
||||||
|
|
||||||
itemOnB.UserData = await LoadUserDataAsync(_dataSource, itemId, cancellationToken);
|
|
||||||
|
|
||||||
// The viewer seeks forward and the tick reporting it lands on replica A.
|
|
||||||
var seeked = replicaA.GetUserData(user, itemOnA)!;
|
|
||||||
seeked.PlaybackPositionTicks = _quarterIn;
|
|
||||||
replicaA.SaveUserData(user, itemOnA, seeked, UserDataSaveReason.PlaybackProgress, cancellationToken);
|
|
||||||
|
|
||||||
// The next tick lands on replica B, which adds ten seconds to whatever it reads.
|
|
||||||
var tick = replicaB.GetUserData(user, itemOnB)!;
|
|
||||||
tick.PlaybackPositionTicks += TimeSpan.FromSeconds(10).Ticks;
|
|
||||||
replicaB.SaveUserData(user, itemOnB, tick, UserDataSaveReason.PlaybackProgress, cancellationToken);
|
|
||||||
|
|
||||||
var stored = replicaA.GetUserData(user, itemOnA)!;
|
|
||||||
Assert.Equal(_quarterIn + TimeSpan.FromSeconds(10).Ticks, stored.PlaybackPositionTicks);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static UserDataManager CreateManager(NpgsqlDataSource dataSource)
|
|
||||||
{
|
|
||||||
var config = new Mock<IServerConfigurationManager>();
|
|
||||||
config.SetupGet(c => c.Configuration).Returns(new ServerConfiguration());
|
|
||||||
return new UserDataManager(config.Object, new DataSourceContextFactory(dataSource));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<ICollection<UserData>> LoadUserDataAsync(NpgsqlDataSource dataSource, Guid itemId, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var context = CreateContext(dataSource);
|
|
||||||
await using (context.ConfigureAwait(false))
|
|
||||||
{
|
|
||||||
return await context.UserData
|
|
||||||
.AsNoTracking()
|
|
||||||
.Where(e => e.ItemId.Equals(itemId))
|
|
||||||
.ToArrayAsync(cancellationToken)
|
|
||||||
.ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<User> CreateUserAndItemAsync(NpgsqlDataSource dataSource, Guid itemId, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var context = CreateContext(dataSource);
|
|
||||||
await using (context.ConfigureAwait(false))
|
|
||||||
{
|
|
||||||
var user = new User("replica-user-" + itemId.ToString("N", CultureInfo.InvariantCulture), "provider", "provider");
|
|
||||||
context.Users.Add(user);
|
|
||||||
context.BaseItems.Add(new BaseItemEntity { Id = itemId, Type = typeof(AudioBook).FullName! });
|
|
||||||
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="UserDataManager"/> 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>
|
|
||||||
/// Builds the schema once for the whole class. Every test keeps to its own user and item, so one
|
|
||||||
/// database serves all of them and the shared server is spared three schema builds.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class DatabaseFixture : IAsyncLifetime
|
|
||||||
{
|
|
||||||
private PostgreSqlTestServer _server = null!;
|
|
||||||
|
|
||||||
public NpgsqlDataSource DataSource { get; private set; } = null!;
|
|
||||||
|
|
||||||
/// <inheritdoc/>
|
|
||||||
public async ValueTask InitializeAsync()
|
|
||||||
{
|
|
||||||
_server = await PostgreSqlTestServer.StartAsync().ConfigureAwait(false);
|
|
||||||
var connectionString = await _server.CreateDatabaseAsync("userdata_replica", CancellationToken.None).ConfigureAwait(false);
|
|
||||||
DataSource = new NpgsqlDataSourceBuilder(connectionString).Build();
|
|
||||||
|
|
||||||
var context = CreateContext(DataSource);
|
|
||||||
await using (context.ConfigureAwait(false))
|
|
||||||
{
|
|
||||||
await context.Database.EnsureCreatedAsync(CancellationToken.None).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc/>
|
|
||||||
public async ValueTask DisposeAsync()
|
|
||||||
{
|
|
||||||
await DataSource.DisposeAsync().ConfigureAwait(false);
|
|
||||||
await _server.DisposeAsync().ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user