diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs index 2a8c0535..122b3f15 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs @@ -63,6 +63,9 @@ public sealed class BaseItemRepository /// 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"; + /// /// This holds all the types in the running assemblies /// 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; + await EnsurePlaceholderItemAsync(context, cancellationToken).ConfigureAwait(false); + var relatedItems = ids.SelectMany(f => TraverseHirachyDown(f, context)).ToArray(); // 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; + } + } + } + /// public void UpdateInheritedValues() { diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryTests.cs index ed6a9ec3..74c60aec 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryTests.cs @@ -5,9 +5,17 @@ namespace Jellyfin.Server.Implementations.Tests.Item; using System; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Database.Implementations; using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Locking; using Jellyfin.Server.Implementations.Item; 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.Abstractions; using Moq; @@ -73,4 +81,112 @@ public class BaseItemRepositoryTests // Assert 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(), + Mock.Of(), + Mock.Of(), + NullLogger.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, IAsyncDisposable + { + private readonly SqliteConnection _connection; + private readonly DbContextOptions _options; + private readonly Mock _databaseProviderMock; + private readonly Mock _lockingBehaviorMock; + + public TestDbContextFactory() + { + _connection = new SqliteConnection("Data Source=:memory:"); + _connection.Open(); + + _options = new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; + + _databaseProviderMock = new Mock(); + _databaseProviderMock.Setup(m => m.OnModelCreating(It.IsAny())); + _databaseProviderMock.Setup(m => m.ConfigureConventions(It.IsAny())); + + _lockingBehaviorMock = new Mock(); + _lockingBehaviorMock + .Setup(m => m.OnSaveChanges(It.IsAny(), It.IsAny())) + .Callback((_, saveChanges) => saveChanges()); + _lockingBehaviorMock + .Setup(m => m.OnSaveChangesAsync(It.IsAny(), It.IsAny>())) + .Returns>((_, saveChanges) => saveChanges()); + } + + public JellyfinDbContext CreateDbContext() + { + return new JellyfinDbContext( + _options, + NullLogger.Instance, + _databaseProviderMock.Object, + _lockingBehaviorMock.Object); + } + + public ValueTask CreateDbContextAsync(CancellationToken cancellationToken = default) + { + return ValueTask.FromResult(CreateDbContext()); + } + + public async ValueTask DisposeAsync() + { + await _connection.DisposeAsync(); + } + } }