aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authornintwentydo <128441765+nintwentydo@users.noreply.github.com>2026-10-05 19:18:39 -0400
committerCody Robibero <cody@robibe.ro>2026-10-05 19:18:39 -0400
commit2e5e042f22f30fa7f49339f6a7f51cbec32383c5 (patch)
treeb6cd1afd549318d18fcf2c73b99e767821f13741
parent21034b5768db7f0536e54fc899e0adc0e58ba091 (diff)
Backport pull request #17892 from jellyfin/release-12.z
Fix orphan ItemValues cleanup after deletion Original-merge: b4648c15b185aafab7bea109e79f7bdcccacb641 Merged-by: crobibero <cody@robibe.ro> Backported-by: Cody Robibero <cody@robibe.ro>
-rw-r--r--Emby.Server.Implementations/Data/CleanDatabaseScheduledTask.cs2
-rw-r--r--Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs2
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/ItemValuesCleanupTests.cs194
3 files changed, 196 insertions, 2 deletions
diff --git a/Emby.Server.Implementations/Data/CleanDatabaseScheduledTask.cs b/Emby.Server.Implementations/Data/CleanDatabaseScheduledTask.cs
index 17355960c3..81ae9a536b 100644
--- a/Emby.Server.Implementations/Data/CleanDatabaseScheduledTask.cs
+++ b/Emby.Server.Implementations/Data/CleanDatabaseScheduledTask.cs
@@ -113,7 +113,7 @@ public class CleanDatabaseScheduledTask : ILibraryPostScanTask
var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
await using (transaction.ConfigureAwait(false))
{
- await context.ItemValues.Where(e => e.BaseItemsMap!.Count == 0).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
+ await context.ItemValues.Where(e => !e.BaseItemsMap!.Any()).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
subProgress.Report(50);
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
subProgress.Report(100);
diff --git a/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs b/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs
index a7b0b1c1fc..8b79e44680 100644
--- a/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs
+++ b/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs
@@ -141,12 +141,12 @@ public class ItemPersistenceService : IItemPersistenceService
context.Chapters.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
context.CustomItemDisplayPreferences.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
context.ItemDisplayPreferences.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
- context.ItemValues.Where(e => e.BaseItemsMap!.Count == 0).ExecuteDelete();
context.ItemValuesMap.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
context.LinkedChildren.WhereOneOrMany(relatedItems, e => e.ParentId).ExecuteDelete();
context.LinkedChildren.WhereOneOrMany(relatedItems, e => e.ChildId).ExecuteDelete();
var peopleIds = context.PeopleBaseItemMap.WhereOneOrMany(relatedItems, e => e.ItemId).Select(f => f.PeopleId).Distinct().ToArray();
context.BaseItems.WhereOneOrMany(relatedItems, e => e.Id).ExecuteDelete();
+ context.ItemValues.Where(e => !e.BaseItemsMap!.Any()).ExecuteDelete();
context.KeyframeData.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
context.MediaSegments.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
context.MediaStreamInfos.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/ItemValuesCleanupTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemValuesCleanupTests.cs
new file mode 100644
index 0000000000..cbfab403dc
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemValuesCleanupTests.cs
@@ -0,0 +1,194 @@
+using System;
+using System.Linq;
+using System.Threading.Tasks;
+using Emby.Server.Implementations.Data;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Server.Implementations.Item;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.IO;
+using MediaBrowser.Controller.Library;
+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 ItemValuesCleanupTests : SqliteDbTestFixture
+{
+ private readonly ItemPersistenceService _service;
+
+ public ItemValuesCleanupTests()
+ {
+ _service = new ItemPersistenceService(
+ CreateDbContextFactory(),
+ Mock.Of<IServerApplicationHost>(),
+ NullLogger<ItemPersistenceService>.Instance);
+ }
+
+ [Fact]
+ public void DeleteItem_LastReference_RemovesNewAndExistingOrphans()
+ {
+ var deleted = CreateItem();
+ var survivor = CreateItem();
+ var referenced = CreateValue("Referenced");
+ using (var context = CreateDbContext())
+ {
+ context.ItemValuesMap.AddRange(
+ Map(deleted, CreateValue("Last reference")),
+ Map(survivor, referenced));
+ context.ItemValues.Add(CreateValue("Already orphaned"));
+ context.SaveChanges();
+ }
+
+ _service.DeleteItem([deleted.Id]);
+
+ using var after = CreateDbContext();
+ Assert.False(after.BaseItems.Any(e => e.Id.Equals(deleted.Id)));
+ Assert.Equal(referenced.ItemValueId, Assert.Single(after.ItemValues).ItemValueId);
+ Assert.Equal(survivor.Id, Assert.Single(after.ItemValuesMap).ItemId);
+ }
+
+ [Fact]
+ public void DeleteItem_BatchWithDescendantAndOwnedExtra_CleansAllRemovedReferences()
+ {
+ var parent = CreateItem(isFolder: true);
+ var child = CreateItem();
+ child.ParentId = parent.Id;
+ var extra = CreateItem();
+ extra.OwnerId = child.Id;
+ var otherDeleted = CreateItem();
+ var survivor = CreateItem();
+ var batchShared = CreateValue("Shared inside deletion batch");
+ var survivingShared = CreateValue("Shared with surviving item");
+ using (var context = CreateDbContext())
+ {
+ context.ItemValuesMap.AddRange(
+ Map(parent, CreateValue("Parent value")),
+ Map(child, batchShared),
+ Map(otherDeleted, batchShared),
+ Map(extra, CreateValue("Extra value")),
+ Map(child, survivingShared),
+ Map(survivor, survivingShared));
+ context.AncestorIds.Add(new AncestorId
+ {
+ ItemId = child.Id,
+ Item = child,
+ ParentItemId = parent.Id,
+ ParentItem = parent
+ });
+ context.SaveChanges();
+ }
+
+ _service.DeleteItem([parent.Id, otherDeleted.Id]);
+
+ using var after = CreateDbContext();
+ Assert.Equal(survivingShared.ItemValueId, Assert.Single(after.ItemValues).ItemValueId);
+ Assert.Equal(survivor.Id, Assert.Single(after.ItemValuesMap).ItemId);
+ Assert.Equal(survivor.Id, Assert.Single(after.BaseItems.Where(e => !e.Id.Equals(BaseItemRepository.PlaceholderId))).Id);
+ Assert.Empty(after.AncestorIds);
+ }
+
+ [Fact]
+ public void DeleteItem_ChildWithoutAncestorRows_CleansValueOrphanedByCascade()
+ {
+ var parent = CreateItem(isFolder: true);
+ var child = CreateItem();
+ child.ParentId = parent.Id;
+ using (var context = CreateDbContext())
+ {
+ context.BaseItems.Add(parent);
+ context.ItemValuesMap.Add(Map(child, CreateValue("Cascaded child value")));
+ context.SaveChanges();
+ }
+
+ _service.DeleteItem([parent.Id]);
+
+ using var after = CreateDbContext();
+ Assert.False(after.BaseItems.Any(e => e.Id.Equals(child.Id)));
+ Assert.Empty(after.ItemValuesMap);
+ Assert.Empty(after.ItemValues);
+ }
+
+ [Fact]
+ public void DeleteItem_CleanupFails_RollsBackItemAndMapDeletion()
+ {
+ var item = CreateItem();
+ var value = CreateValue("Last reference");
+ using (var context = CreateDbContext())
+ {
+ context.ItemValuesMap.Add(Map(item, value));
+ context.ItemValues.Add(CreateValue("Already orphaned"));
+ context.SaveChanges();
+ context.Database.ExecuteSqlRaw("""
+ CREATE TRIGGER FailItemValuesCleanup BEFORE DELETE ON ItemValues
+ BEGIN
+ SELECT RAISE(ABORT, 'injected orphan cleanup failure');
+ END;
+ """);
+ }
+
+ var exception = Assert.Throws<SqliteException>(() => _service.DeleteItem([item.Id]));
+
+ Assert.Contains("injected orphan cleanup failure", exception.Message, StringComparison.Ordinal);
+ using var after = CreateDbContext();
+ Assert.True(after.BaseItems.Any(e => e.Id.Equals(item.Id)));
+ Assert.Equal(value.ItemValueId, Assert.Single(after.ItemValuesMap).ItemValueId);
+ Assert.Equal(2, after.ItemValues.Count());
+ }
+
+ [Fact]
+ public async Task PostScanRun_NoDeadItems_RemovesUnrelatedOrphansAndPreservesReferences()
+ {
+ var item = CreateItem();
+ var referenced = CreateValue("Referenced");
+ using (var context = CreateDbContext())
+ {
+ context.ItemValuesMap.Add(Map(item, referenced));
+ context.ItemValues.Add(CreateValue("Unrelated orphan"));
+ await context.SaveChangesAsync(TestContext.Current.CancellationToken);
+ }
+
+ var library = new Mock<ILibraryManager>(MockBehavior.Strict);
+ library.Setup(e => e.GetItemIds(It.Is<InternalItemsQuery>(query => query.HasDeadParentId == true)))
+ .Returns(Array.Empty<Guid>());
+ library.Setup(e => e.GetItemList(It.IsAny<InternalItemsQuery>())).Returns(Array.Empty<BaseItem>());
+ var task = new CleanDatabaseScheduledTask(
+ library.Object,
+ NullLogger<CleanDatabaseScheduledTask>.Instance,
+ CreateDbContextFactory(),
+ Mock.Of<IPathManager>(MockBehavior.Strict));
+
+ await task.Run(Mock.Of<IProgress<double>>(), TestContext.Current.CancellationToken);
+
+ using var after = CreateDbContext();
+ Assert.Equal(referenced.ItemValueId, Assert.Single(after.ItemValues).ItemValueId);
+ Assert.Equal(item.Id, Assert.Single(after.ItemValuesMap).ItemId);
+ Assert.True(after.BaseItems.Any(e => e.Id.Equals(item.Id)));
+ }
+
+ private static BaseItemEntity CreateItem(bool isFolder = false) => new()
+ {
+ Id = Guid.NewGuid(),
+ Type = isFolder ? typeof(Folder).FullName! : typeof(Book).FullName!,
+ IsFolder = isFolder
+ };
+
+ private static ItemValue CreateValue(string value) => new()
+ {
+ ItemValueId = Guid.NewGuid(),
+ Type = ItemValueType.Genre,
+ Value = value,
+ CleanValue = value.ToLowerInvariant()
+ };
+
+ private static ItemValueMap Map(BaseItemEntity item, ItemValue value) => new()
+ {
+ ItemId = item.Id,
+ Item = item,
+ ItemValueId = value.ItemValueId,
+ ItemValue = value
+ };
+}