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:
Shadowghost
2026-08-22 07:01:58 +02:00
52 changed files with 3027 additions and 259 deletions
@@ -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));
}
}
@@ -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;
}
}