Merge remote-tracking branch 'upstream/master' into optimize-db-helper-memory
# Conflicts: # src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/JellyfinDbModelSnapshot.cs
This commit is contained in:
@@ -23,6 +23,7 @@ namespace Jellyfin.Naming.Tests.Video
|
||||
[InlineData("Crouching.Tiger.Hidden.Dragon.BDrip.mkv", "Crouching.Tiger.Hidden.Dragon")]
|
||||
[InlineData("Crouching.Tiger.Hidden.Dragon.BDrip-HDC.mkv", "Crouching.Tiger.Hidden.Dragon")]
|
||||
[InlineData("Crouching.Tiger.Hidden.Dragon.4K.UltraHD.HDR.BDrip-HDC.mkv", "Crouching.Tiger.Hidden.Dragon")]
|
||||
[InlineData("Last.Call.for.Nowhere.WEB-DL.1080p", "Last.Call.for.Nowhere")]
|
||||
[InlineData("[HorribleSubs] Made in Abyss - 13 [720p].mkv", "Made in Abyss")]
|
||||
[InlineData("[Tsundere] Kore wa Zombie Desu ka of the Dead [BDRip h264 1920x1080 FLAC]", "Kore wa Zombie Desu ka of the Dead")]
|
||||
[InlineData("[Erai-raws] Jujutsu Kaisen - 03 [720p][Multiple Subtitle].mkv", "Jujutsu Kaisen")]
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Jellyfin.Database.Implementations;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Database.Implementations.Locking;
|
||||
using Jellyfin.Database.Providers.Sqlite;
|
||||
using Jellyfin.Server.Implementations.Item;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Persistence;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Jellyfin.Server.Implementations.Tests.Item;
|
||||
|
||||
public sealed class ItemCountServiceTests : IDisposable
|
||||
{
|
||||
private readonly SqliteConnection _connection;
|
||||
private readonly DbContextOptions<JellyfinDbContext> _dbOptions;
|
||||
private readonly IApplicationPaths _applicationPaths;
|
||||
private readonly ItemCountService _service;
|
||||
|
||||
public ItemCountServiceTests()
|
||||
{
|
||||
_applicationPaths = new Mock<IApplicationPaths>().Object;
|
||||
|
||||
_connection = new SqliteConnection("Data Source=:memory:");
|
||||
_connection.Open();
|
||||
|
||||
_dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>()
|
||||
.UseSqlite(_connection)
|
||||
.Options;
|
||||
|
||||
using (var context = CreateDbContext())
|
||||
{
|
||||
context.Database.EnsureCreated();
|
||||
}
|
||||
|
||||
var factory = new Mock<IDbContextFactory<JellyfinDbContext>>();
|
||||
factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext);
|
||||
|
||||
_service = new ItemCountService(
|
||||
factory.Object,
|
||||
new Mock<IItemTypeLookup>().Object,
|
||||
new Mock<IItemQueryHelpers>().Object);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_connection.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetChildCountBatch_LargeParentIdSet_DoesNotExceedSqliteVariableLimit()
|
||||
{
|
||||
var hierarchicalParentId = Guid.NewGuid();
|
||||
var linkedParentId = Guid.NewGuid();
|
||||
|
||||
var hierarchicalChildId = Guid.NewGuid();
|
||||
var linkedChildId1 = Guid.NewGuid();
|
||||
var linkedChildId2 = Guid.NewGuid();
|
||||
|
||||
using (var context = CreateDbContext())
|
||||
{
|
||||
context.BaseItems.AddRange(
|
||||
CreateItem(hierarchicalParentId),
|
||||
CreateItem(linkedParentId),
|
||||
CreateItem(hierarchicalChildId, hierarchicalParentId),
|
||||
CreateItem(linkedChildId1),
|
||||
CreateItem(linkedChildId2));
|
||||
|
||||
context.LinkedChildren.AddRange(
|
||||
new LinkedChildEntity
|
||||
{
|
||||
ParentId = linkedParentId,
|
||||
ChildId = linkedChildId1,
|
||||
ChildType = LinkedChildType.Manual,
|
||||
SortOrder = 0
|
||||
},
|
||||
new LinkedChildEntity
|
||||
{
|
||||
ParentId = linkedParentId,
|
||||
ChildId = linkedChildId2,
|
||||
ChildType = LinkedChildType.Manual,
|
||||
SortOrder = 1
|
||||
});
|
||||
|
||||
context.SaveChanges();
|
||||
}
|
||||
|
||||
var parentIds = Enumerable.Range(0, 40_000)
|
||||
.Select(_ => Guid.NewGuid())
|
||||
.ToList();
|
||||
|
||||
parentIds.Add(hierarchicalParentId);
|
||||
parentIds.Add(linkedParentId);
|
||||
|
||||
var result = _service.GetChildCountBatch(parentIds, null);
|
||||
|
||||
Assert.Equal(1, result[hierarchicalParentId]);
|
||||
Assert.Equal(2, result[linkedParentId]);
|
||||
Assert.Equal(parentIds.Count, result.Count);
|
||||
}
|
||||
|
||||
private static BaseItemEntity CreateItem(Guid id, Guid? parentId = null)
|
||||
{
|
||||
return new BaseItemEntity
|
||||
{
|
||||
Id = id,
|
||||
Type = "Folder",
|
||||
ParentId = parentId,
|
||||
IsFolder = true
|
||||
};
|
||||
}
|
||||
|
||||
private JellyfinDbContext CreateDbContext()
|
||||
{
|
||||
return new JellyfinDbContext(
|
||||
_dbOptions,
|
||||
NullLogger<JellyfinDbContext>.Instance,
|
||||
new SqliteDatabaseProvider(
|
||||
_applicationPaths,
|
||||
NullLogger<SqliteDatabaseProvider>.Instance),
|
||||
new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -349,7 +349,7 @@ namespace Jellyfin.Server.Implementations.Tests.Localization
|
||||
});
|
||||
|
||||
var translated = localizationManager.GetLocalizedString("Artists", "de");
|
||||
Assert.Equal("Interpreten", translated);
|
||||
Assert.Equal("Künstler", translated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -406,7 +406,7 @@ namespace Jellyfin.Server.Implementations.Tests.Localization
|
||||
{
|
||||
CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo("fr");
|
||||
var translated = localizationManager.GetServerLocalizedString("Artists");
|
||||
Assert.Equal("Interpreten", translated);
|
||||
Assert.Equal("Künstler", translated);
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using System.Threading.Tasks;
|
||||
using MediaBrowser.Controller;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Controller.Devices;
|
||||
using MediaBrowser.Controller.Drawing;
|
||||
using MediaBrowser.Controller.Dto;
|
||||
using MediaBrowser.Controller.Events;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Session;
|
||||
using MediaBrowser.Model.Dto;
|
||||
using MediaBrowser.Model.Session;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Jellyfin.Server.Implementations.Tests.SessionManager;
|
||||
|
||||
public class IdlePlaybackTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(null, null)]
|
||||
[InlineData(123456789L, 123456789L)]
|
||||
public async Task CheckForIdlePlayback_StopsAtLastClientReportedPosition(long? clientPositionTicks, long? expectedPositionTicks)
|
||||
{
|
||||
var playbackStopped = new TaskCompletionSource<long?>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var eventManager = new Mock<IEventManager>();
|
||||
eventManager
|
||||
.Setup(manager => manager.PublishAsync(It.IsAny<PlaybackStopEventArgs>()))
|
||||
.Callback<PlaybackStopEventArgs>(eventArgs => playbackStopped.TrySetResult(eventArgs.PlaybackPositionTicks))
|
||||
.Returns(Task.CompletedTask);
|
||||
await using var sessionManager = new Emby.Server.Implementations.Session.SessionManager(
|
||||
NullLogger<Emby.Server.Implementations.Session.SessionManager>.Instance,
|
||||
eventManager.Object,
|
||||
Mock.Of<IUserDataManager>(),
|
||||
Mock.Of<IServerConfigurationManager>(),
|
||||
Mock.Of<ILibraryManager>(),
|
||||
Mock.Of<IUserManager>(),
|
||||
Mock.Of<IMusicManager>(),
|
||||
Mock.Of<IDtoService>(),
|
||||
Mock.Of<IImageProcessor>(),
|
||||
Mock.Of<IServerApplicationHost>(),
|
||||
Mock.Of<IDeviceManager>(),
|
||||
Mock.Of<IMediaSourceManager>(),
|
||||
Mock.Of<IHostApplicationLifetime>());
|
||||
var session = await sessionManager.LogSessionActivity(
|
||||
"Test Client",
|
||||
"1.0.0",
|
||||
"test-device",
|
||||
"Test Device",
|
||||
"127.0.0.1",
|
||||
null);
|
||||
session.NowPlayingItem = new BaseItemDto
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Test Item"
|
||||
};
|
||||
session.PlayState.PositionTicks = 987654321;
|
||||
|
||||
if (clientPositionTicks.HasValue)
|
||||
{
|
||||
session.StartAutomaticProgress(new PlaybackProgressInfo
|
||||
{
|
||||
IsPaused = true,
|
||||
PositionTicks = clientPositionTicks
|
||||
});
|
||||
session.StopAutomaticProgress();
|
||||
}
|
||||
|
||||
var idlePlaybackCallback = typeof(Emby.Server.Implementations.Session.SessionManager)
|
||||
.GetMethod("CheckForIdlePlayback", BindingFlags.Instance | BindingFlags.NonPublic)!;
|
||||
idlePlaybackCallback.Invoke(sessionManager, new object?[] { null });
|
||||
|
||||
var stoppedPositionTicks = await playbackStopped.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(expectedPositionTicks, stoppedPositionTicks);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using MediaBrowser.Controller.Session;
|
||||
using MediaBrowser.Model.Session;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Jellyfin.Server.Implementations.Tests.SessionManager;
|
||||
|
||||
public class SessionInfoTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task StartAutomaticProgress_SnapshotsClientReportedPosition()
|
||||
{
|
||||
await using var session = new SessionInfo(Mock.Of<ISessionManager>(), NullLogger.Instance);
|
||||
var progressInfo = new PlaybackProgressInfo
|
||||
{
|
||||
IsPaused = true,
|
||||
PositionTicks = 123456789
|
||||
};
|
||||
|
||||
session.StartAutomaticProgress(progressInfo);
|
||||
|
||||
Assert.Equal(progressInfo.PositionTicks, session.LastPlaybackCheckInPositionTicks);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AutomaticProgress_AdvancesEstimatedPositionWithoutAdvancingSnapshot()
|
||||
{
|
||||
var sessionManager = new Mock<ISessionManager>();
|
||||
await using var session = new SessionInfo(sessionManager.Object, NullLogger.Instance);
|
||||
var automaticProgress = new TaskCompletionSource<long?>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
const long reportedPositionTicks = 123456789;
|
||||
|
||||
sessionManager
|
||||
.Setup(manager => manager.OnPlaybackProgress(It.IsAny<PlaybackProgressInfo>(), true))
|
||||
.Callback<PlaybackProgressInfo, bool>((info, _) =>
|
||||
{
|
||||
session.PlayState.PositionTicks = info.PositionTicks;
|
||||
automaticProgress.TrySetResult(info.PositionTicks);
|
||||
})
|
||||
.Returns(Task.CompletedTask);
|
||||
session.PlayState.PositionTicks = reportedPositionTicks;
|
||||
|
||||
session.StartAutomaticProgress(new PlaybackProgressInfo
|
||||
{
|
||||
PositionTicks = reportedPositionTicks
|
||||
});
|
||||
|
||||
var estimatedPositionTicks = await automaticProgress.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
|
||||
session.StopAutomaticProgress();
|
||||
|
||||
Assert.Equal(reportedPositionTicks + TimeSpan.TicksPerSecond, estimatedPositionTicks);
|
||||
Assert.Equal(estimatedPositionTicks, session.PlayState.PositionTicks);
|
||||
Assert.Equal(reportedPositionTicks, session.LastPlaybackCheckInPositionTicks);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAutomaticProgress_ReplacesSnapshotOnLaterClientReport()
|
||||
{
|
||||
await using var session = new SessionInfo(Mock.Of<ISessionManager>(), NullLogger.Instance);
|
||||
session.StartAutomaticProgress(new PlaybackProgressInfo
|
||||
{
|
||||
IsPaused = true,
|
||||
PositionTicks = 123456789
|
||||
});
|
||||
|
||||
session.StartAutomaticProgress(new PlaybackProgressInfo
|
||||
{
|
||||
IsPaused = true,
|
||||
PositionTicks = 987654321
|
||||
});
|
||||
|
||||
Assert.Equal(987654321, session.LastPlaybackCheckInPositionTicks);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAutomaticProgress_PreservesExactPausedPosition()
|
||||
{
|
||||
await using var session = new SessionInfo(Mock.Of<ISessionManager>(), NullLogger.Instance);
|
||||
var pausedProgress = new PlaybackProgressInfo
|
||||
{
|
||||
IsPaused = true,
|
||||
PositionTicks = 314159265
|
||||
};
|
||||
|
||||
session.StartAutomaticProgress(pausedProgress);
|
||||
|
||||
Assert.Equal(pausedProgress.PositionTicks, session.LastPlaybackCheckInPositionTicks);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Data;
|
||||
using Jellyfin.Database.Implementations;
|
||||
using Jellyfin.Database.Implementations.Enums;
|
||||
using Jellyfin.Database.Implementations.Locking;
|
||||
using Jellyfin.Database.Providers.Sqlite;
|
||||
using Jellyfin.Server.Implementations.Users;
|
||||
using MediaBrowser.Common;
|
||||
using MediaBrowser.Common.Net;
|
||||
using MediaBrowser.Controller;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Controller.Drawing;
|
||||
using MediaBrowser.Controller.Events;
|
||||
using MediaBrowser.Model.Cryptography;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Jellyfin.Server.Implementations.Tests.Users;
|
||||
|
||||
public sealed class UserManagerUpdateUserTests : IDisposable
|
||||
{
|
||||
private readonly SqliteConnection _connection;
|
||||
private readonly DbContextOptions<JellyfinDbContext> _dbOptions;
|
||||
private readonly UserManager _userManager;
|
||||
|
||||
public UserManagerUpdateUserTests()
|
||||
{
|
||||
_connection = new SqliteConnection("Data Source=:memory:");
|
||||
_connection.Open();
|
||||
|
||||
_dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>()
|
||||
.UseSqlite(_connection)
|
||||
.Options;
|
||||
|
||||
// Create the schema
|
||||
using var ctx = CreateDbContext();
|
||||
ctx.Database.EnsureCreated();
|
||||
|
||||
var factory = new Mock<IDbContextFactory<JellyfinDbContext>>();
|
||||
factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext);
|
||||
factory.Setup(f => f.CreateDbContextAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(CreateDbContext);
|
||||
|
||||
var cryptoProvider = new Mock<ICryptoProvider>();
|
||||
var configManager = new Mock<IServerConfigurationManager>();
|
||||
var appPaths = new Mock<IServerApplicationPaths>();
|
||||
appPaths.Setup(x => x.ProgramDataPath).Returns(Path.GetTempPath());
|
||||
configManager.Setup(x => x.ApplicationPaths).Returns(appPaths.Object);
|
||||
|
||||
var appHost = new Mock<IApplicationHost>();
|
||||
|
||||
var defaultAuthProvider = new DefaultAuthenticationProvider(
|
||||
NullLogger<DefaultAuthenticationProvider>.Instance,
|
||||
cryptoProvider.Object);
|
||||
var invalidAuthProvider = new InvalidAuthProvider();
|
||||
var defaultPasswordResetProvider = new DefaultPasswordResetProvider(
|
||||
configManager.Object,
|
||||
appHost.Object);
|
||||
|
||||
_userManager = new UserManager(
|
||||
factory.Object,
|
||||
new NoopEventManager(),
|
||||
new Mock<INetworkManager>().Object,
|
||||
appHost.Object,
|
||||
new Mock<IImageProcessor>().Object,
|
||||
NullLogger<UserManager>.Instance,
|
||||
configManager.Object,
|
||||
[defaultPasswordResetProvider],
|
||||
[defaultAuthProvider, invalidAuthProvider]);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_userManager.Dispose();
|
||||
_connection.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateUserAsync_DoesNotDetachPermissionsOrPreferences()
|
||||
{
|
||||
var user = await _userManager.CreateUserAsync("orphanuser");
|
||||
var permissionCount = user.Permissions.Count;
|
||||
var preferenceCount = user.Preferences.Count;
|
||||
|
||||
user.LastActivityDate = DateTime.UtcNow;
|
||||
await _userManager.UpdateUserAsync(user);
|
||||
await _userManager.UpdateUserAsync(user);
|
||||
|
||||
await using var context = CreateDbContext();
|
||||
Assert.Equal(permissionCount, await context.Permissions.CountAsync(TestContext.Current.CancellationToken));
|
||||
Assert.Equal(preferenceCount, await context.Preferences.CountAsync(TestContext.Current.CancellationToken));
|
||||
Assert.All(
|
||||
await context.Permissions.ToListAsync(TestContext.Current.CancellationToken),
|
||||
permission => Assert.Equal(user.Id, permission.UserId));
|
||||
Assert.All(
|
||||
await context.Preferences.ToListAsync(TestContext.Current.CancellationToken),
|
||||
preference => Assert.Equal(user.Id, preference.UserId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateUserAsync_WhenOnlyTheUserRowChanged_LeavesChildRowsUntouched()
|
||||
{
|
||||
var user = await _userManager.CreateUserAsync("churnuser");
|
||||
var before = await ReadChildRowsAsync();
|
||||
|
||||
// A session activity stamp goes through the same path. It must not rewrite all 37 child
|
||||
// rows, which is what tearing the collections down and rebuilding them used to do.
|
||||
user.LastActivityDate = DateTime.UtcNow;
|
||||
await _userManager.UpdateUserAsync(user);
|
||||
|
||||
Assert.Equal(before, await ReadChildRowsAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateUserAsync_AppliesPermissionAndPreferenceChanges()
|
||||
{
|
||||
var user = await _userManager.CreateUserAsync("policyuser");
|
||||
Assert.False(user.HasPermission(PermissionKind.IsAdministrator));
|
||||
|
||||
user.SetPermission(PermissionKind.IsAdministrator, true);
|
||||
user.SetPreference(PreferenceKind.BlockedTags, ["spoilers"]);
|
||||
user.Permissions.Remove(user.Permissions.First(permission => permission.Kind == PermissionKind.EnableAllChannels));
|
||||
|
||||
await _userManager.UpdateUserAsync(user);
|
||||
|
||||
var reloaded = _userManager.GetUserById(user.Id)!;
|
||||
Assert.True(reloaded.HasPermission(PermissionKind.IsAdministrator));
|
||||
Assert.Equal(new[] { "spoilers" }, reloaded.GetPreference(PreferenceKind.BlockedTags));
|
||||
Assert.DoesNotContain(reloaded.Permissions, permission => permission.Kind == PermissionKind.EnableAllChannels);
|
||||
|
||||
await using var context = CreateDbContext();
|
||||
Assert.Equal(reloaded.Permissions.Count, await context.Permissions.CountAsync(TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
private JellyfinDbContext CreateDbContext()
|
||||
{
|
||||
return new JellyfinDbContext(
|
||||
_dbOptions,
|
||||
NullLogger<JellyfinDbContext>.Instance,
|
||||
new SqliteDatabaseProvider(null!, NullLogger<SqliteDatabaseProvider>.Instance),
|
||||
new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the identity and concurrency token of every permission and preference row.
|
||||
/// </summary>
|
||||
private async Task<List<(string Table, int Id, int Kind, uint RowVersion)>> ReadChildRowsAsync()
|
||||
{
|
||||
await using var context = CreateDbContext();
|
||||
var permissions = await context.Permissions
|
||||
.OrderBy(permission => permission.Id)
|
||||
.Select(permission => new ValueTuple<string, int, int, uint>("Permission", permission.Id, (int)permission.Kind, permission.RowVersion))
|
||||
.ToListAsync(TestContext.Current.CancellationToken);
|
||||
var preferences = await context.Preferences
|
||||
.OrderBy(preference => preference.Id)
|
||||
.Select(preference => new ValueTuple<string, int, int, uint>("Preference", preference.Id, (int)preference.Kind, preference.RowVersion))
|
||||
.ToListAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
return permissions.Concat(preferences).ToList();
|
||||
}
|
||||
|
||||
private sealed class NoopEventManager : IEventManager
|
||||
{
|
||||
public void Publish<T>(T eventArgs)
|
||||
where T : EventArgs
|
||||
{
|
||||
}
|
||||
|
||||
public Task PublishAsync<T>(T eventArgs)
|
||||
where T : EventArgs
|
||||
=> Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user