From 006ecadbe041ea422b34adf6a3fcf6638d189a70 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Tue, 15 Sep 2026 11:15:46 -0400 Subject: Backport pull request #17881 from jellyfin/release-12.z Fix /UserViews exhausting memory and reporting random child counts Original-merge: a838a06aa51eac388a76e9e6421f1a80873c417b Merged-by: crobibero Backported-by: Cody Robibero --- Emby.Server.Implementations/Library/UserViewManager.cs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) (limited to 'Emby.Server.Implementations/Library/UserViewManager.cs') diff --git a/Emby.Server.Implementations/Library/UserViewManager.cs b/Emby.Server.Implementations/Library/UserViewManager.cs index 47b3891901..231dbe6429 100644 --- a/Emby.Server.Implementations/Library/UserViewManager.cs +++ b/Emby.Server.Implementations/Library/UserViewManager.cs @@ -60,7 +60,16 @@ namespace Emby.Server.Implementations.Library var folderViewType = collectionFolder?.CollectionType; // Playlist and BoxSet libraries require special handling because the folder only references linked items - if (folderViewType == CollectionType.playlists || folderViewType == CollectionType.boxsets) + if (folderViewType == CollectionType.boxsets) + { + // Only the existence of one visible box set matters here, so probe the children + // lazily and stop at the first hit. + if (!folder.Children.Any(item => item.IsVisible(user))) + { + continue; + } + } + else if (folderViewType == CollectionType.playlists) { var items = folder.GetItemList(new InternalItemsQuery(user) { -- cgit v1.2.3 From 7c7244d32fd9290989c523e3f344657d87cfbb7c Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Tue, 15 Sep 2026 11:16:54 -0400 Subject: Backport pull request #18003 from jellyfin/release-12.z Check Live TV access for a single user instead of enumerating all users Original-merge: 2bcd3b1efb121ad661495570b43aace247ee0a27 Merged-by: crobibero Backported-by: Cody Robibero --- .../Library/UserViewManager.cs | 2 +- .../Item/BaseItemRepository.Querying.cs | 4 +- .../Item/BaseItemRepository.TranslateQuery.cs | 4 +- .../Item/ItemCountService.cs | 6 +- .../Item/NextUpService.cs | 6 +- MediaBrowser.Controller/LiveTv/ILiveTvManager.cs | 7 ++ .../LeafPlayedState.cs | 10 ++ src/Jellyfin.LiveTv/LiveTvManager.cs | 8 ++ .../Item/BaseItemRepositoryPlayedVersionTests.cs | 64 ++++++++++-- .../Item/NextUpServiceTests.cs | 114 +++++++++++++++++++++ 10 files changed, 205 insertions(+), 20 deletions(-) create mode 100644 src/Jellyfin.Database/Jellyfin.Database.Implementations/LeafPlayedState.cs create mode 100644 tests/Jellyfin.Server.Implementations.Tests/Item/NextUpServiceTests.cs (limited to 'Emby.Server.Implementations/Library/UserViewManager.cs') diff --git a/Emby.Server.Implementations/Library/UserViewManager.cs b/Emby.Server.Implementations/Library/UserViewManager.cs index 231dbe6429..db911cd24a 100644 --- a/Emby.Server.Implementations/Library/UserViewManager.cs +++ b/Emby.Server.Implementations/Library/UserViewManager.cs @@ -136,7 +136,7 @@ namespace Emby.Server.Implementations.Library list.AddRange(channels); - if (_liveTvManager.GetEnabledUsers().Select(i => i.Id).Contains(user.Id)) + if (_liveTvManager.IsEnabledForUser(user)) { list.Add(_liveTvManager.GetInternalLiveTvFolder(CancellationToken.None)); } diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs index 1ed10cce2b..8d573569e9 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs @@ -593,10 +593,10 @@ public sealed partial class BaseItemRepository return dbContext.BaseItems .Where(e => descendantIds.Contains(e.Id) && !e.IsFolder && !e.IsVirtualItem) - .All(f => f.UserData!.Any(e => e.UserId == user.Id && e.Played)); + .All(BuildLeafIsPlayedFilter(dbContext, user.Id)); } - return dbContext.BaseItems.Where(e => e.ParentId == id).All(f => f.UserData!.Any(e => e.UserId == user.Id && e.Played)); + return dbContext.BaseItems.Where(e => e.ParentId == id).All(BuildLeafIsPlayedFilter(dbContext, user.Id)); } /// diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs index 486b3b5de6..d726f0f143 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs @@ -581,8 +581,8 @@ public sealed partial class BaseItemRepository .ToArray(); var folderIsResumableFilter = IsFolderFilter.And(e => resumableFolderTypes.Contains(e.Type)) .And(BuildHasDescendantFilter(context, inProgressLeafItems) - .Or(BuildHasDescendantFilter(context, leafItems.Where(e => e.UserData!.Any(ud => ud.UserId == userId && ud.Played))) - .And(BuildHasDescendantFilter(context, leafItems.Where(e => !e.UserData!.Any(ud => ud.UserId == userId && ud.Played)))))); + .Or(BuildHasDescendantFilter(context, leafItems.Where(BuildLeafIsPlayedFilter(context, userId))) + .And(BuildHasDescendantFilter(context, leafItems.Where(BuildLeafIsPlayedFilter(context, userId).Not()))))); if (isResumable) { diff --git a/Jellyfin.Server.Implementations/Item/ItemCountService.cs b/Jellyfin.Server.Implementations/Item/ItemCountService.cs index f8903127b9..942161a176 100644 --- a/Jellyfin.Server.Implementations/Item/ItemCountService.cs +++ b/Jellyfin.Server.Implementations/Item/ItemCountService.cs @@ -414,7 +414,7 @@ public class ItemCountService : IItemCountService using var dbContext = _dbProvider.CreateDbContext(); var baseQuery = BuildGroupedDescendantsQuery(dbContext, filter, ancestorId); - return baseQuery.Count(b => b.UserData!.Any(u => u.UserId == filter.User.Id && u.Played)); + return baseQuery.Count(DescendantQueryHelper.IsPlayedBy(filter.User.Id)); } /// @@ -617,7 +617,7 @@ public class ItemCountService : IItemCountService leafItems = _queryHelpers.ApplyAccessFiltering(dbContext, leafItems, filter); var playedLeafItems = leafItems - .Select(b => new { b.Id, Played = b.UserData!.Any(ud => ud.UserId == userId && ud.Played) }); + .Select(DescendantQueryHelper.PlayedStateBy(userId)); var ancestorLeaves = dbContext.AncestorIds .WhereOneOrMany(folderIdsArray, a => a.ParentItemId) @@ -735,7 +735,7 @@ public class ItemCountService : IItemCountService private static (int Played, int Total) GetPlayedAndTotalCountFromQuery(IQueryable query, Guid userId) { var result = query - .Select(b => b.UserData!.Any(u => u.UserId == userId && u.Played)) + .Select(DescendantQueryHelper.IsPlayedBy(userId)) .GroupBy(_ => 1) .OrderBy(g => g.Key) .Select(g => new diff --git a/Jellyfin.Server.Implementations/Item/NextUpService.cs b/Jellyfin.Server.Implementations/Item/NextUpService.cs index d3c7ecb0ad..897fb98cbb 100644 --- a/Jellyfin.Server.Implementations/Item/NextUpService.cs +++ b/Jellyfin.Server.Implementations/Item/NextUpService.cs @@ -95,7 +95,7 @@ public class NextUpService : INextUpService .Where(e => e.Type == episodeTypeName) .Where(e => e.SeriesPresentationUniqueKey != null && seriesKeys.Contains(e.SeriesPresentationUniqueKey)) .Where(e => e.ParentIndexNumber != 0) - .Where(e => e.UserData!.Any(ud => ud.UserId == userId && ud.Played)); + .Where(DescendantQueryHelper.IsPlayedBy(userId)); lastWatchedBase = _queryHelpers.ApplyAccessFiltering(context, lastWatchedBase, filter); // Use lightweight projection + client-side dedup to avoid the correlated scalar subquery @@ -207,7 +207,7 @@ public class NextUpService : INextUpService .Where(e => e.SeriesPresentationUniqueKey != null && seriesKeys.Contains(e.SeriesPresentationUniqueKey)) .Where(e => e.ParentIndexNumber != 0) .Where(e => !e.IsVirtualItem) - .Where(e => !e.UserData!.Any(ud => ud.UserId == userId && ud.Played)); + .Where(DescendantQueryHelper.IsUnplayedBy(userId)); allUnplayedBase = _queryHelpers.ApplyAccessFiltering(context, allUnplayedBase, filter); var allUnplayedCandidates = allUnplayedBase .Select(e => new @@ -255,7 +255,7 @@ public class NextUpService : INextUpService .Where(e => e.SeriesPresentationUniqueKey != null && seriesKeys.Contains(e.SeriesPresentationUniqueKey)) .Where(e => e.ParentIndexNumber != 0) .Where(e => !e.IsVirtualItem) - .Where(e => e.UserData!.Any(ud => ud.UserId == userId && ud.Played)); + .Where(DescendantQueryHelper.IsPlayedBy(userId)); allPlayedBase = _queryHelpers.ApplyAccessFiltering(context, allPlayedBase, filter); var allPlayedCandidates = allPlayedBase .Select(e => new diff --git a/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs b/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs index 8d59eef9f1..77e0087048 100644 --- a/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs +++ b/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs @@ -200,6 +200,13 @@ namespace MediaBrowser.Controller.LiveTv /// IEnumerable{User}. IEnumerable GetEnabledUsers(); + /// + /// Gets whether Live TV is enabled for a single user. + /// + /// The user. + /// Whether Live TV is enabled for the user. + bool IsEnabledForUser(User user); + /// /// Gets the internal channels. /// diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/LeafPlayedState.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/LeafPlayedState.cs new file mode 100644 index 0000000000..0846013c45 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/LeafPlayedState.cs @@ -0,0 +1,10 @@ +using System; + +namespace Jellyfin.Database.Implementations; + +/// +/// An item's id paired with whether a given user has played it. +/// +/// The id of the item. +/// Whether the user has played the item. +public readonly record struct LeafPlayedState(Guid Id, bool Played); diff --git a/src/Jellyfin.LiveTv/LiveTvManager.cs b/src/Jellyfin.LiveTv/LiveTvManager.cs index 2edf7681db..93f9083178 100644 --- a/src/Jellyfin.LiveTv/LiveTvManager.cs +++ b/src/Jellyfin.LiveTv/LiveTvManager.cs @@ -1229,6 +1229,14 @@ namespace Jellyfin.LiveTv .Where(IsLiveTvEnabled); } + /// + public bool IsEnabledForUser(User user) + { + ArgumentNullException.ThrowIfNull(user); + + return IsLiveTvEnabled(user); + } + /// /// Resets the tuner. /// diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryPlayedVersionTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryPlayedVersionTests.cs index 0958512b1a..a9548a6d13 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryPlayedVersionTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryPlayedVersionTests.cs @@ -32,6 +32,8 @@ public sealed class BaseItemRepositoryPlayedVersionTests : SqliteDbTestFixture private readonly Guid _seriesPlayedViaAlternate = Guid.NewGuid(); private readonly Guid _unplayedSeries = Guid.NewGuid(); + private readonly Guid _seriesPlayedAcrossVersions = Guid.NewGuid(); + private readonly Guid _partiallyPlayedSeries = Guid.NewGuid(); public BaseItemRepositoryPlayedVersionTests() { @@ -68,8 +70,29 @@ public sealed class BaseItemRepositoryPlayedVersionTests : SqliteDbTestFixture [Fact] public void IsPlayed_CountsASeriesWatchedThroughAnEpisodeAlternateVersion() { - Assert.Equal(new HashSet { _seriesPlayedViaAlternate }, Ids(BaseItemKind.Series, isPlayed: true)); - Assert.Equal(new HashSet { _unplayedSeries }, Ids(BaseItemKind.Series, isPlayed: false)); + Assert.Equal( + new HashSet { _seriesPlayedViaAlternate, _seriesPlayedAcrossVersions }, + Ids(BaseItemKind.Series, isPlayed: true)); + Assert.Equal( + new HashSet { _unplayedSeries, _partiallyPlayedSeries }, + Ids(BaseItemKind.Series, isPlayed: false)); + } + + [Fact] + public void GetIsPlayed_CountsASeriesWatchedThroughAnEpisodeAlternateVersion() + { + Assert.True(_repository.GetIsPlayed(_user, _seriesPlayedViaAlternate, true)); + Assert.False(_repository.GetIsPlayed(_user, _unplayedSeries, true)); + } + + [Fact] + public void IsResumable_DropsASeriesWhoseLastEpisodeWasPlayedThroughAnAlternateVersion() + { + var resumable = _repository.GetItemIdsList(new InternalItemsQuery(_user) { IsResumable = true }); + + // Nothing is left to watch, so the series is not half finished. + Assert.DoesNotContain(_seriesPlayedAcrossVersions, resumable); + Assert.Contains(_partiallyPlayedSeries, resumable); } private HashSet Ids(BaseItemKind kind, bool isPlayed) @@ -95,6 +118,9 @@ public sealed class BaseItemRepositoryPlayedVersionTests : SqliteDbTestFixture AddSeriesWithAlternateEpisode(context, _seriesPlayedViaAlternate, "E", playedAlternate: true); AddSeriesWithAlternateEpisode(context, _unplayedSeries, "F", playedAlternate: false); + AddSeriesWithTwoEpisodes(context, _seriesPlayedAcrossVersions, "G", secondPlayedViaAlternate: true); + AddSeriesWithTwoEpisodes(context, _partiallyPlayedSeries, "H", secondPlayedViaAlternate: false); + context.SaveChanges(); } @@ -113,7 +139,33 @@ public sealed class BaseItemRepositoryPlayedVersionTests : SqliteDbTestFixture { var episodeId = Guid.NewGuid(); - context.BaseItems.Add(new BaseItemEntity + AddSeriesFolder(context, seriesId, name); + + AddItem(context, episodeId, EpisodeType, $"{name} 1"); + context.AncestorIds.Add(new AncestorId { ItemId = episodeId, ParentItemId = seriesId, Item = null!, ParentItem = null! }); + + AddAlternateVersion(context, episodeId, EpisodeType, $"{name} 1 4K", playedAlternate); + } + + // A watched first episode plus a second one that is either watched as its alternate version or not + // watched at all, which is what separates a finished series from a half watched one. + private void AddSeriesWithTwoEpisodes(JellyfinDbContext context, Guid seriesId, string name, bool secondPlayedViaAlternate) + { + AddSeriesFolder(context, seriesId, name); + + var firstId = Guid.NewGuid(); + AddItem(context, firstId, EpisodeType, $"{name} 1"); + context.AncestorIds.Add(new AncestorId { ItemId = firstId, ParentItemId = seriesId, Item = null!, ParentItem = null! }); + AddPlayedUserData(context, firstId); + + var secondId = Guid.NewGuid(); + AddItem(context, secondId, EpisodeType, $"{name} 2"); + context.AncestorIds.Add(new AncestorId { ItemId = secondId, ParentItemId = seriesId, Item = null!, ParentItem = null! }); + AddAlternateVersion(context, secondId, EpisodeType, $"{name} 2 4K", secondPlayedViaAlternate); + } + + private void AddSeriesFolder(JellyfinDbContext context, Guid seriesId, string name) + => context.BaseItems.Add(new BaseItemEntity { Id = seriesId, Type = SeriesType, @@ -123,12 +175,6 @@ public sealed class BaseItemRepositoryPlayedVersionTests : SqliteDbTestFixture IsFolder = true }); - AddItem(context, episodeId, EpisodeType, $"{name} 1"); - context.AncestorIds.Add(new AncestorId { ItemId = episodeId, ParentItemId = seriesId, Item = null!, ParentItem = null! }); - - AddAlternateVersion(context, episodeId, EpisodeType, $"{name} 1 4K", playedAlternate); - } - private void AddItem(JellyfinDbContext context, Guid id, string type, string name) => context.BaseItems.Add(new BaseItemEntity { diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/NextUpServiceTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/NextUpServiceTests.cs new file mode 100644 index 0000000000..8ed3c61a59 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/NextUpServiceTests.cs @@ -0,0 +1,114 @@ +using System; +using System.Linq; +using Emby.Server.Implementations.Data; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Server.Implementations.Item; +using MediaBrowser.Controller.Entities; +using Xunit; +using LinkedChildType = Jellyfin.Database.Implementations.Entities.LinkedChildType; + +namespace Jellyfin.Server.Implementations.Tests.Item; + +/// +/// Covers Next Up over episodes with alternate versions: the episode that was watched is the one +/// whose alternate carries the played row, so an episode already seen must not be offered again. +/// +public sealed class NextUpServiceTests : SqliteDbTestFixture +{ + private const string SeriesKey = "next-up-series"; + private const string EpisodeType = "MediaBrowser.Controller.Entities.TV.Episode"; + + private readonly NextUpService _service; + private readonly User _user = new("test", "auth-provider", "reset-provider"); + + private readonly Guid _playedViaAlternate = Guid.NewGuid(); + private readonly Guid _unplayed = Guid.NewGuid(); + + public NextUpServiceTests() + { + var itemTypeLookup = new ItemTypeLookup(); + + using (var context = CreateDbContext()) + { + Seed(context); + } + + _service = new NextUpService( + CreateDbContextFactory(), + itemTypeLookup, + CreateBaseItemRepository(itemTypeLookup)); + } + + [Fact] + public void GetNextUpEpisodesBatch_EpisodePlayedThroughItsAlternateVersion_OffersTheOneAfterIt() + { + var batch = _service.GetNextUpEpisodesBatch( + new InternalItemsQuery(_user), + [SeriesKey], + includeSpecials: false, + includeWatchedForRewatching: false)[SeriesKey]; + + Assert.Equal(_playedViaAlternate, batch.LastWatched?.Id); + Assert.Equal(_unplayed, batch.NextUp?.Id); + } + + private void Seed(JellyfinDbContext context) + { + context.Users.Add(_user); + + AddEpisode(context, _playedViaAlternate, 1); + AddEpisode(context, _unplayed, 2); + + // The second file of the first episode, and the only row the playback was recorded against. + // It presents under its primary's key, which is what keeps it out of the candidate list. + var alternateId = Guid.NewGuid(); + context.BaseItems.Add(new BaseItemEntity + { + Id = alternateId, + Type = EpisodeType, + Name = "Episode 1 4K", + SeriesPresentationUniqueKey = SeriesKey, + ParentIndexNumber = 1, + IndexNumber = 1, + PresentationUniqueKey = _playedViaAlternate.ToString("N"), + PrimaryVersionId = _playedViaAlternate + }); + + context.SaveChanges(); + + // The link the scanner writes alongside PrimaryVersionId, and the hop the played state + // reaches the alternate through. + context.LinkedChildren.Add(new LinkedChildEntity + { + ParentId = _playedViaAlternate, + ChildId = alternateId, + ChildType = LinkedChildType.LocalAlternateVersion, + SortOrder = 0 + }); + + context.UserData.Add(new UserData + { + ItemId = alternateId, + UserId = _user.Id, + CustomDataKey = alternateId.ToString("N"), + Played = true, + Item = null!, + User = null! + }); + + context.SaveChanges(); + } + + private void AddEpisode(JellyfinDbContext context, Guid id, int indexNumber) + => context.BaseItems.Add(new BaseItemEntity + { + Id = id, + Type = EpisodeType, + Name = $"Episode {indexNumber}", + SeriesPresentationUniqueKey = SeriesKey, + ParentIndexNumber = 1, + IndexNumber = indexNumber, + PresentationUniqueKey = id.ToString("N") + }); +} -- cgit v1.2.3 From 2151b992f96f6f0ba9d404977d536741a281584c Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Tue, 15 Sep 2026 11:17:09 -0400 Subject: Backport pull request #18039 from jellyfin/release-12.z Stop resolving items with every field where only stored columns are read Original-merge: 408e5e01b1447e24689bd3653e2328bf173c8ce3 Merged-by: crobibero Backported-by: Cody Robibero --- .../Collections/CollectionManager.cs | 3 +- .../Library/UserViewManager.cs | 48 +++++++++++++--------- .../Playlists/PlaylistManager.cs | 10 ++--- MediaBrowser.Controller/Dto/DtoOptions.cs | 14 +++++++ MediaBrowser.Controller/Entities/Folder.cs | 18 ++++++-- MediaBrowser.Controller/Entities/Movies/BoxSet.cs | 15 +++---- MediaBrowser.LocalMetadata/Savers/BaseXmlSaver.cs | 4 +- 7 files changed, 76 insertions(+), 36 deletions(-) (limited to 'Emby.Server.Implementations/Library/UserViewManager.cs') diff --git a/Emby.Server.Implementations/Collections/CollectionManager.cs b/Emby.Server.Implementations/Collections/CollectionManager.cs index 9567a29a98..cd93f76d26 100644 --- a/Emby.Server.Implementations/Collections/CollectionManager.cs +++ b/Emby.Server.Implementations/Collections/CollectionManager.cs @@ -9,6 +9,7 @@ using Jellyfin.Database.Implementations.Entities; using Jellyfin.Extensions; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Collections; +using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Library; @@ -235,7 +236,7 @@ namespace Emby.Server.Implementations.Collections List? itemList = null; - var linkedChildrenList = collection.GetLinkedChildren(); + var linkedChildrenList = collection.GetLinkedChildren(DtoOptions.StoredColumnsOnly); var currentLinkedChildrenIds = linkedChildrenList.Select(i => i.Id).ToList(); foreach (var id in ids) diff --git a/Emby.Server.Implementations/Library/UserViewManager.cs b/Emby.Server.Implementations/Library/UserViewManager.cs index db911cd24a..cfb2dd53d3 100644 --- a/Emby.Server.Implementations/Library/UserViewManager.cs +++ b/Emby.Server.Implementations/Library/UserViewManager.cs @@ -60,26 +60,10 @@ namespace Emby.Server.Implementations.Library var folderViewType = collectionFolder?.CollectionType; // Playlist and BoxSet libraries require special handling because the folder only references linked items - if (folderViewType == CollectionType.boxsets) + if ((folderViewType == CollectionType.playlists || folderViewType == CollectionType.boxsets) + && !HasVisibleChild(folder, user)) { - // Only the existence of one visible box set matters here, so probe the children - // lazily and stop at the first hit. - if (!folder.Children.Any(item => item.IsVisible(user))) - { - continue; - } - } - else if (folderViewType == CollectionType.playlists) - { - var items = folder.GetItemList(new InternalItemsQuery(user) - { - ParentId = folder.ParentId - }); - - if (!items.Any(item => item.IsVisible(user))) - { - continue; - } + continue; } if (UserView.IsUserSpecific(folder)) @@ -168,6 +152,32 @@ namespace Emby.Server.Implementations.Library .ToArray(); } + private bool HasVisibleChild(Folder folder, User user) + { + // Folder.Children answers this too, but a collection folder delegates it to its physical + // folders, which resolve and then hold on to every child with every field. + var parentIds = folder is CollectionFolder collectionFolder && collectionFolder.PhysicalFolderIds.Length > 0 + ? collectionFolder.PhysicalFolderIds + : [folder.Id]; + + foreach (var parentId in parentIds) + { + var items = _libraryManager.GetItemList(new InternalItemsQuery(user) + { + ParentId = parentId, + GroupByPresentationUniqueKey = false, + DtoOptions = DtoOptions.StoredColumnsOnly + }); + + if (items.Any(item => item.IsVisible(user))) + { + return true; + } + } + + return false; + } + public UserView GetUserSubViewWithName(string name, Guid parentId, CollectionType? type, string sortName) { var uniqueId = parentId + "subview" + type; diff --git a/Emby.Server.Implementations/Playlists/PlaylistManager.cs b/Emby.Server.Implementations/Playlists/PlaylistManager.cs index 308faed8cc..8208c85222 100644 --- a/Emby.Server.Implementations/Playlists/PlaylistManager.cs +++ b/Emby.Server.Implementations/Playlists/PlaylistManager.cs @@ -371,7 +371,7 @@ namespace Emby.Server.Implementations.Playlists if (extension.Equals(".wpl", StringComparison.OrdinalIgnoreCase)) { var playlist = new WplPlaylist(); - foreach (var child in item.GetLinkedChildren()) + foreach (var child in item.GetLinkedChildren(DtoOptions.StoredColumnsOnly)) { var entry = new WplPlaylistEntry() { @@ -404,7 +404,7 @@ namespace Emby.Server.Implementations.Playlists else if (extension.Equals(".zpl", StringComparison.OrdinalIgnoreCase)) { var playlist = new ZplPlaylist(); - foreach (var child in item.GetLinkedChildren()) + foreach (var child in item.GetLinkedChildren(DtoOptions.StoredColumnsOnly)) { var entry = new ZplPlaylistEntry() { @@ -440,7 +440,7 @@ namespace Emby.Server.Implementations.Playlists { IsExtended = true }; - foreach (var child in item.GetLinkedChildren()) + foreach (var child in item.GetLinkedChildren(DtoOptions.StoredColumnsOnly)) { var entry = new M3uPlaylistEntry() { @@ -472,7 +472,7 @@ namespace Emby.Server.Implementations.Playlists IsExtended = true }; - foreach (var child in item.GetLinkedChildren()) + foreach (var child in item.GetLinkedChildren(DtoOptions.StoredColumnsOnly)) { var entry = new M3uPlaylistEntry() { @@ -500,7 +500,7 @@ namespace Emby.Server.Implementations.Playlists else if (extension.Equals(".pls", StringComparison.OrdinalIgnoreCase)) { var playlist = new PlsPlaylist(); - foreach (var child in item.GetLinkedChildren()) + foreach (var child in item.GetLinkedChildren(DtoOptions.StoredColumnsOnly)) { var entry = new PlsPlaylistEntry() { diff --git a/MediaBrowser.Controller/Dto/DtoOptions.cs b/MediaBrowser.Controller/Dto/DtoOptions.cs index 052626355f..4259b7b65c 100644 --- a/MediaBrowser.Controller/Dto/DtoOptions.cs +++ b/MediaBrowser.Controller/Dto/DtoOptions.cs @@ -46,6 +46,20 @@ namespace MediaBrowser.Controller.Dto ImageTypes = AllImageTypes; } + /// + /// Gets options that populate nothing beyond the item's own stored columns. + /// + /// + /// Each enabled field group is a collection the item query left-joins, so the rows returned are + /// the product of the item's provider, image and user data counts. Never use this for items that + /// get saved back: saving rewrites the owned rows from what the instance holds. + /// + public static DtoOptions StoredColumnsOnly => new(false) + { + EnableImages = false, + EnableUserData = false + }; + /// /// Gets or sets the fields to populate on the DTO. /// diff --git a/MediaBrowser.Controller/Entities/Folder.cs b/MediaBrowser.Controller/Entities/Folder.cs index dae4612aa1..626bc0d5a1 100644 --- a/MediaBrowser.Controller/Entities/Folder.cs +++ b/MediaBrowser.Controller/Entities/Folder.cs @@ -1687,7 +1687,17 @@ namespace MediaBrowser.Controller.Entities /// IEnumerable{BaseItem}. public List GetLinkedChildren() { - var resolved = ResolveLinkedChildren(LinkedChildren); + return GetLinkedChildren(new DtoOptions()); + } + + /// + /// Gets the linked children, populating only what asks for. + /// + /// Fields to populate on the resolved children. + /// The resolved children. + public List GetLinkedChildren(DtoOptions options) + { + var resolved = ResolveLinkedChildren(LinkedChildren, options); var list = new List(resolved.Count); foreach (var (_, item) in resolved) { @@ -1804,8 +1814,9 @@ namespace MediaBrowser.Controller.Entities /// path (legacy path-based resolution). /// /// Linked children to resolve. + /// Fields to populate on the resolved items; all fields when null. /// Each input entry paired with its resolved item; entries that fail to resolve are dropped. - private List<(LinkedChild Info, BaseItem Item)> ResolveLinkedChildren(IReadOnlyList linkedChildren) + private List<(LinkedChild Info, BaseItem Item)> ResolveLinkedChildren(IReadOnlyList linkedChildren, DtoOptions options = null) { var resolved = new List<(LinkedChild Info, BaseItem Item)>(linkedChildren.Count); if (linkedChildren.Count == 0) @@ -1827,7 +1838,8 @@ namespace MediaBrowser.Controller.Entities { var batched = LibraryManager.GetItemList(new InternalItemsQuery { - ItemIds = [.. idsToBatch] + ItemIds = [.. idsToBatch], + DtoOptions = options ?? new DtoOptions() }); byId = new Dictionary(batched.Count); foreach (var item in batched) diff --git a/MediaBrowser.Controller/Entities/Movies/BoxSet.cs b/MediaBrowser.Controller/Entities/Movies/BoxSet.cs index d7f9102be9..16d8bfe391 100644 --- a/MediaBrowser.Controller/Entities/Movies/BoxSet.cs +++ b/MediaBrowser.Controller/Entities/Movies/BoxSet.cs @@ -11,6 +11,7 @@ using Jellyfin.Data; using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations.Entities; using Jellyfin.Database.Implementations.Enums; +using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Querying; @@ -89,7 +90,7 @@ namespace MediaBrowser.Controller.Entities.Movies return base.GetNonCachedChildren(directoryService); } - return Enumerable.Empty(); + return []; } protected override IReadOnlyList LoadChildren() @@ -172,7 +173,7 @@ namespace MediaBrowser.Controller.Entities.Movies var libraryFolderIds = LibraryFolderIds; if (libraryFolderIds is null) { - linkedItems = GetLinkedChildren(); + linkedItems = GetLinkedChildren(DtoOptions.StoredColumnsOnly); libraryFolderIds = GetLibraryFolderIds(linkedItems); } @@ -191,7 +192,7 @@ namespace MediaBrowser.Controller.Entities.Movies // If user has parental controls, hide the BoxSet when all children are restricted if (user.MaxParentalRatingScore.HasValue) { - linkedItems ??= GetLinkedChildren(); + linkedItems ??= GetLinkedChildren(DtoOptions.StoredColumnsOnly); if (linkedItems.Count > 0 && linkedItems.All(child => !child.IsParentalAllowed(user, true))) { return false; @@ -248,7 +249,7 @@ namespace MediaBrowser.Controller.Entities.Movies public Guid[] GetLibraryFolderIds() { - return GetLibraryFolderIds(GetLinkedChildren()); + return GetLibraryFolderIds(GetLinkedChildren(DtoOptions.StoredColumnsOnly)); } private Guid[] GetLibraryFolderIds(IEnumerable linkedChildren) @@ -280,13 +281,13 @@ namespace MediaBrowser.Controller.Entities.Movies { expandedFolders.Add(item.Id); - return FlattenItems(boxset.GetLinkedChildren(), expandedFolders); + return FlattenItems(boxset.GetLinkedChildren(DtoOptions.StoredColumnsOnly), expandedFolders); } - return Array.Empty(); + return []; } - return new[] { item }; + return [item]; } } } diff --git a/MediaBrowser.LocalMetadata/Savers/BaseXmlSaver.cs b/MediaBrowser.LocalMetadata/Savers/BaseXmlSaver.cs index bc184d82fb..d5e233d4f8 100644 --- a/MediaBrowser.LocalMetadata/Savers/BaseXmlSaver.cs +++ b/MediaBrowser.LocalMetadata/Savers/BaseXmlSaver.cs @@ -8,6 +8,7 @@ using System.Threading; using System.Threading.Tasks; using System.Xml; using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Entities.TV; @@ -500,7 +501,8 @@ namespace MediaBrowser.LocalMetadata.Savers { var batched = LibraryManager.GetItemList(new InternalItemsQuery { - ItemIds = [.. idsToResolve] + ItemIds = [.. idsToResolve], + DtoOptions = DtoOptions.StoredColumnsOnly }); pathById = new Dictionary(batched.Count); foreach (var batchedItem in batched) -- cgit v1.2.3 From 7693488122ee612faa15a02891ae85f792d36374 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Sun, 27 Sep 2026 16:30:44 -0400 Subject: Backport pull request #18104 from jellyfin/release-12.z Fix playlist views Original-merge: 074cc5b6d7914064e8cd3447b17c5375715fcfac Merged-by: crobibero Backported-by: Cody Robibero --- .../Library/UserViewManager.cs | 50 ++++++++++++---------- 1 file changed, 28 insertions(+), 22 deletions(-) (limited to 'Emby.Server.Implementations/Library/UserViewManager.cs') diff --git a/Emby.Server.Implementations/Library/UserViewManager.cs b/Emby.Server.Implementations/Library/UserViewManager.cs index cfb2dd53d3..6d52ce7440 100644 --- a/Emby.Server.Implementations/Library/UserViewManager.cs +++ b/Emby.Server.Implementations/Library/UserViewManager.cs @@ -60,10 +60,16 @@ namespace Emby.Server.Implementations.Library var folderViewType = collectionFolder?.CollectionType; // Playlist and BoxSet libraries require special handling because the folder only references linked items - if ((folderViewType == CollectionType.playlists || folderViewType == CollectionType.boxsets) - && !HasVisibleChild(folder, user)) + if (folderViewType == CollectionType.playlists || folderViewType == CollectionType.boxsets) { - continue; + var itemKind = folderViewType == CollectionType.playlists + ? BaseItemKind.Playlist + : BaseItemKind.BoxSet; + + if (!HasVisibleItem(itemKind, folders, user)) + { + continue; + } } if (UserView.IsUserSpecific(folder)) @@ -152,30 +158,30 @@ namespace Emby.Server.Implementations.Library .ToArray(); } - private bool HasVisibleChild(Folder folder, User user) + private bool HasVisibleItem(BaseItemKind itemKind, IReadOnlyList folders, User user) { - // Folder.Children answers this too, but a collection folder delegates it to its physical - // folders, which resolve and then hold on to every child with every field. - var parentIds = folder is CollectionFolder collectionFolder && collectionFolder.PhysicalFolderIds.Length > 0 - ? collectionFolder.PhysicalFolderIds - : [folder.Id]; + var topParentIds = folders.SelectMany(GetTopParentIds).ToArray(); + if (topParentIds.Length == 0) + { + return false; + } - foreach (var parentId in parentIds) + var items = _libraryManager.GetItemList(new InternalItemsQuery(user) { - var items = _libraryManager.GetItemList(new InternalItemsQuery(user) - { - ParentId = parentId, - GroupByPresentationUniqueKey = false, - DtoOptions = DtoOptions.StoredColumnsOnly - }); + IncludeItemTypes = [itemKind], + TopParentIds = topParentIds, + GroupByPresentationUniqueKey = false, + DtoOptions = DtoOptions.StoredColumnsOnly + }); - if (items.Any(item => item.IsVisible(user))) - { - return true; - } - } + return items.Any(item => item.IsVisible(user)); + } - return false; + private static IEnumerable GetTopParentIds(Folder folder) + { + return folder is CollectionFolder collectionFolder && collectionFolder.PhysicalFolderIds.Length > 0 + ? collectionFolder.PhysicalFolderIds + : [folder.Id]; } public UserView GetUserSubViewWithName(string name, Guid parentId, CollectionType? type, string sortName) -- cgit v1.2.3