Merge pull request 'Add placeholder item handling and related unit test for deletion' (#2) from pgsql_testing_branch into main

Reviewed-on: #2
This commit was merged in pull request #2.
This commit is contained in:
2026-04-13 19:10:24 +00:00
2 changed files with 153 additions and 0 deletions
@@ -63,6 +63,9 @@ public sealed class BaseItemRepository
/// </summary> /// </summary>
public static readonly Guid PlaceholderId = Guid.Parse("00000000-0000-0000-0000-000000000001"); public static readonly Guid PlaceholderId = Guid.Parse("00000000-0000-0000-0000-000000000001");
private const string PlaceholderType = "PLACEHOLDER";
private const string PlaceholderName = "This is a placeholder item for UserData that has been detacted from its original item";
/// <summary> /// <summary>
/// This holds all the types in the running assemblies /// This holds all the types in the running assemblies
/// so that we can de-serialize properly when we don't have strong types. /// so that we can de-serialize properly when we don't have strong types.
@@ -129,6 +132,8 @@ public sealed class BaseItemRepository
{ {
var date = (DateTime?)DateTime.UtcNow; var date = (DateTime?)DateTime.UtcNow;
await EnsurePlaceholderItemAsync(context, cancellationToken).ConfigureAwait(false);
var relatedItems = ids.SelectMany(f => TraverseHirachyDown(f, context)).ToArray(); var relatedItems = ids.SelectMany(f => TraverseHirachyDown(f, context)).ToArray();
// Remove any UserData entries for the placeholder item that would conflict with the UserData // Remove any UserData entries for the placeholder item that would conflict with the UserData
@@ -196,6 +201,38 @@ public sealed class BaseItemRepository
} }
} }
private async Task EnsurePlaceholderItemAsync(JellyfinDbContext context, CancellationToken cancellationToken)
{
if (await context.BaseItems.AnyAsync(e => e.Id == PlaceholderId, cancellationToken).ConfigureAwait(false))
{
return;
}
var placeholder = new BaseItemEntity
{
Id = PlaceholderId,
Type = PlaceholderType,
Name = PlaceholderName
};
await context.BaseItems.AddAsync(placeholder, cancellationToken).ConfigureAwait(false);
try
{
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
_logger.LogWarning("The detached UserData placeholder item was missing and has been recreated.");
}
catch (DbUpdateException)
{
context.Entry(placeholder).State = EntityState.Detached;
if (!await context.BaseItems.AnyAsync(e => e.Id == PlaceholderId, cancellationToken).ConfigureAwait(false))
{
throw;
}
}
}
/// <inheritdoc /> /// <inheritdoc />
public void UpdateInheritedValues() public void UpdateInheritedValues()
{ {
@@ -5,9 +5,17 @@
namespace Jellyfin.Server.Implementations.Tests.Item; namespace Jellyfin.Server.Implementations.Tests.Item;
using System; using System;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities; using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.Locking;
using Jellyfin.Server.Implementations.Item; using Jellyfin.Server.Implementations.Item;
using MediaBrowser.Controller; using MediaBrowser.Controller;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Persistence;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using Moq; using Moq;
@@ -73,4 +81,112 @@ public class BaseItemRepositoryTests
// Assert // Assert
Assert.NotNull(result); Assert.NotNull(result);
} }
[Fact]
public async Task DeleteItemAsync_RecreatesMissingPlaceholderBeforeDetachingUserData()
{
await using var dbFactory = new TestDbContextFactory();
Guid itemId;
await using (var context = await dbFactory.CreateDbContextAsync(CancellationToken.None))
{
await context.Database.EnsureCreatedAsync();
var user = new User("test-user", "Default", "Default");
var item = new BaseItemEntity
{
Id = Guid.NewGuid(),
Type = "TestItem",
Name = "Delete me"
};
context.Users.Add(user);
context.BaseItems.Add(item);
context.UserData.Add(new UserData
{
CustomDataKey = "userdata-key",
ItemId = item.Id,
Item = item,
UserId = user.Id,
User = user,
Played = true,
PlayCount = 1
});
await context.SaveChangesAsync();
var placeholder = await context.BaseItems.SingleAsync(e => e.Id == BaseItemRepository.PlaceholderId);
context.BaseItems.Remove(placeholder);
await context.SaveChangesAsync();
itemId = item.Id;
}
var repository = new BaseItemRepository(
dbFactory,
Mock.Of<IServerApplicationHost>(),
Mock.Of<IItemTypeLookup>(),
Mock.Of<IServerConfigurationManager>(),
NullLogger<BaseItemRepository>.Instance);
await repository.DeleteItemAsync([itemId], CancellationToken.None);
await using var verificationContext = await dbFactory.CreateDbContextAsync(CancellationToken.None);
var recreatedPlaceholder = await verificationContext.BaseItems.SingleOrDefaultAsync(e => e.Id == BaseItemRepository.PlaceholderId);
var detachedUserData = await verificationContext.UserData.SingleAsync();
Assert.NotNull(recreatedPlaceholder);
Assert.Null(await verificationContext.BaseItems.SingleOrDefaultAsync(e => e.Id == itemId));
Assert.Equal(BaseItemRepository.PlaceholderId, detachedUserData.ItemId);
Assert.NotNull(detachedUserData.RetentionDate);
}
private sealed class TestDbContextFactory : IDbContextFactory<JellyfinDbContext>, IAsyncDisposable
{
private readonly SqliteConnection _connection;
private readonly DbContextOptions<JellyfinDbContext> _options;
private readonly Mock<IJellyfinDatabaseProvider> _databaseProviderMock;
private readonly Mock<IEntityFrameworkCoreLockingBehavior> _lockingBehaviorMock;
public TestDbContextFactory()
{
_connection = new SqliteConnection("Data Source=:memory:");
_connection.Open();
_options = new DbContextOptionsBuilder<JellyfinDbContext>()
.UseSqlite(_connection)
.Options;
_databaseProviderMock = new Mock<IJellyfinDatabaseProvider>();
_databaseProviderMock.Setup(m => m.OnModelCreating(It.IsAny<ModelBuilder>()));
_databaseProviderMock.Setup(m => m.ConfigureConventions(It.IsAny<ModelConfigurationBuilder>()));
_lockingBehaviorMock = new Mock<IEntityFrameworkCoreLockingBehavior>();
_lockingBehaviorMock
.Setup(m => m.OnSaveChanges(It.IsAny<JellyfinDbContext>(), It.IsAny<Action>()))
.Callback<JellyfinDbContext, Action>((_, saveChanges) => saveChanges());
_lockingBehaviorMock
.Setup(m => m.OnSaveChangesAsync(It.IsAny<JellyfinDbContext>(), It.IsAny<Func<Task>>()))
.Returns<JellyfinDbContext, Func<Task>>((_, saveChanges) => saveChanges());
}
public JellyfinDbContext CreateDbContext()
{
return new JellyfinDbContext(
_options,
NullLogger<JellyfinDbContext>.Instance,
_databaseProviderMock.Object,
_lockingBehaviorMock.Object);
}
public ValueTask<JellyfinDbContext> CreateDbContextAsync(CancellationToken cancellationToken = default)
{
return ValueTask.FromResult(CreateDbContext());
}
public async ValueTask DisposeAsync()
{
await _connection.DisposeAsync();
}
}
} }