From a2aa3d96d2a75b01f94354b541fe890b78e7c8bd Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Tue, 15 Sep 2026 11:15:49 -0400 Subject: Backport pull request #17884 from jellyfin/release-12.z Release a folder's children once its subtree has been scanned Original-merge: 2f2adae7d99537e53417f6d92aa3d40345a17b99 Merged-by: crobibero Backported-by: Cody Robibero --- .../Entities/FolderChildCacheTests.cs | 97 ++++++++++++++++++++++ .../Item/BaseItemMapperUserDataTests.cs | 79 ++++++++++++++++++ 2 files changed, 176 insertions(+) create mode 100644 tests/Jellyfin.Controller.Tests/Entities/FolderChildCacheTests.cs create mode 100644 tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemMapperUserDataTests.cs (limited to 'tests') diff --git a/tests/Jellyfin.Controller.Tests/Entities/FolderChildCacheTests.cs b/tests/Jellyfin.Controller.Tests/Entities/FolderChildCacheTests.cs new file mode 100644 index 0000000000..705238317a --- /dev/null +++ b/tests/Jellyfin.Controller.Tests/Entities/FolderChildCacheTests.cs @@ -0,0 +1,97 @@ +using System.Collections.Generic; +using MediaBrowser.Controller.Entities; +using Xunit; + +namespace Jellyfin.Controller.Tests.Entities; + +/// +/// Covers , which a recursive scan calls as it unwinds so +/// the folders it walked do not keep the whole item graph of the library alive behind it. +/// +public class FolderChildCacheTests +{ + [Fact] + public void ReleaseCachedChildren_MakesTheNextAccessReload() + { + var folder = new TrackingFolder(); + + Assert.Empty(folder.Children); + Assert.Equal(1, folder.LoadCount); + + // Second access is served from the cache on the instance. + Assert.Empty(folder.Children); + Assert.Equal(1, folder.LoadCount); + + folder.ReleaseCachedChildren(); + + Assert.Empty(folder.Children); + Assert.Equal(2, folder.LoadCount); + } + + [Fact] + public void ReleaseCachedChildren_ReachesEveryLevelBelow() + { + var leaf = new TrackingFolder(); + var middle = new TrackingFolder { Source = [leaf] }; + var root = new TrackingFolder { Source = [middle] }; + + // Walk the whole tree, as a recursive scan does, so every level holds its children. + Assert.Single(root.Children); + Assert.Single(middle.Children); + Assert.Empty(leaf.Children); + Assert.Equal(1, root.LoadCount); + Assert.Equal(1, middle.LoadCount); + Assert.Equal(1, leaf.LoadCount); + + root.ReleaseCachedChildren(); + + Assert.Single(root.Children); + Assert.Single(middle.Children); + Assert.Empty(leaf.Children); + Assert.Equal(2, root.LoadCount); + Assert.Equal(2, middle.LoadCount); + Assert.Equal(2, leaf.LoadCount); + } + + [Fact] + public void ReleaseCachedChildren_LoadsNothingThatIsNotAlreadyHeld() + { + var leaf = new TrackingFolder(); + var root = new TrackingFolder { Source = [leaf] }; + + root.ReleaseCachedChildren(); + + Assert.Equal(0, root.LoadCount); + Assert.Equal(0, leaf.LoadCount); + } + + [Fact] + public void ReleaseCachedChildren_TerminatesOnACycle() + { + var first = new TrackingFolder(); + var second = new TrackingFolder { Source = [first] }; + first.Source = [second]; + + Assert.Single(first.Children); + Assert.Single(second.Children); + + // Clearing before descending is what stops this from recursing forever. + first.ReleaseCachedChildren(); + + Assert.Equal(1, first.LoadCount); + Assert.Equal(1, second.LoadCount); + } + + private sealed class TrackingFolder : Folder + { + public int LoadCount { get; private set; } + + public IReadOnlyList Source { get; set; } = []; + + protected override IReadOnlyList LoadChildren() + { + LoadCount++; + return Source; + } + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemMapperUserDataTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemMapperUserDataTests.cs new file mode 100644 index 0000000000..abe1e59496 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemMapperUserDataTests.cs @@ -0,0 +1,79 @@ +using System; +using System.Linq; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Server.Implementations.Item; +using MediaBrowser.Controller.Entities; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Item; + +/// +/// Covers the user data rows hands the domain item. A domain item is +/// held for as long as its folder holds it, so a row that still points back at the entity it was +/// read with would keep that entity - and everything loaded alongside it - alive with it. +/// +public class BaseItemMapperUserDataTests +{ + [Fact] + public void Map_CopiesUserDataWithoutTheEntityGraphBehindIt() + { + var itemId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + var user = new User("someone", "Default", "Default"); + var entity = new BaseItemEntity { Id = itemId, Type = "MediaBrowser.Controller.Entities.TV.Episode" }; + + var row = new UserData + { + ItemId = itemId, + Item = entity, + UserId = userId, + User = user, + CustomDataKey = "key", + PlayCount = 3, + PlaybackPositionTicks = 1234, + IsFavorite = true, + Played = true, + Rating = 7.5, + LastPlayedDate = new DateTime(2026, 9, 8, 0, 0, 0, DateTimeKind.Utc), + AudioStreamIndex = 1, + SubtitleStreamIndex = 2, + Likes = true + }; + + entity.UserData = [row]; + + var dto = BaseItemMapper.Map(entity, new Folder(), null); + + var mapped = Assert.Single(dto.UserData); + Assert.Null(mapped.Item); + Assert.Null(mapped.User); + + // The values callers actually read still come through. + Assert.Equal(itemId, mapped.ItemId); + Assert.Equal(userId, mapped.UserId); + Assert.Equal("key", mapped.CustomDataKey); + Assert.Equal(3, mapped.PlayCount); + Assert.Equal(1234, mapped.PlaybackPositionTicks); + Assert.True(mapped.IsFavorite); + Assert.True(mapped.Played); + Assert.Equal(7.5, mapped.Rating); + Assert.Equal(row.LastPlayedDate, mapped.LastPlayedDate); + Assert.Equal(1, mapped.AudioStreamIndex); + Assert.Equal(2, mapped.SubtitleStreamIndex); + Assert.True(mapped.Likes); + } + + [Fact] + public void Map_WithoutUserData_YieldsAnEmptyCollection() + { + var entity = new BaseItemEntity + { + Id = Guid.NewGuid(), + Type = "MediaBrowser.Controller.Entities.Folder" + }; + + var dto = BaseItemMapper.Map(entity, new Folder(), null); + + Assert.Empty(dto.UserData); + } +} -- cgit v1.2.3