diff options
| author | Shadowghost <Ghost_of_Stone@web.de> | 2026-01-17 17:10:07 +0100 |
|---|---|---|
| committer | Shadowghost <Ghost_of_Stone@web.de> | 2026-01-18 19:48:46 +0100 |
| commit | 5996c4afce11249804d24f1caa3a99b390543c4d (patch) | |
| tree | d84b98428d95c801492b1354571e2ab3fc0cc99b /MediaBrowser.Controller | |
| parent | dfa78590c2899c7e74b142ebbced4140a354aed0 (diff) | |
Complete LinkedChildren integration and batch DTO optimizations
This commit integrates remaining performance changes:
- Add batch user data fetching in DtoService to reduce N+1 queries
- Add GetNextUpEpisodesBatch in TVSeriesManager for efficient batch retrieval
- Update Video/Movie/BoxSet to use LibraryManager for alternate versions
- Transition LinkedChild to use ItemId instead of Path (obsolete Path/LibraryItemId)
- Update providers and controllers for LinkedChildren-based references
- Add NextUpEpisodeBatchResult for batched episode queries
- Integrate IDescendantQueryProvider in SqliteDatabaseProvider
Diffstat (limited to 'MediaBrowser.Controller')
17 files changed, 397 insertions, 409 deletions
diff --git a/MediaBrowser.Controller/Dto/IDtoService.cs b/MediaBrowser.Controller/Dto/IDtoService.cs index f1d507fcbd..8dc6f0aa68 100644 --- a/MediaBrowser.Controller/Dto/IDtoService.cs +++ b/MediaBrowser.Controller/Dto/IDtoService.cs @@ -36,8 +36,9 @@ namespace MediaBrowser.Controller.Dto /// <param name="options">The options.</param> /// <param name="user">The user.</param> /// <param name="owner">The owner.</param> + /// <param name="skipVisibilityCheck">Skip redundant visibility check if items are already filtered.</param> /// <returns>The <see cref="IReadOnlyList{T}"/> of <see cref="BaseItemDto"/>.</returns> - IReadOnlyList<BaseItemDto> GetBaseItemDtos(IReadOnlyList<BaseItem> items, DtoOptions options, User? user = null, BaseItem? owner = null); + IReadOnlyList<BaseItemDto> GetBaseItemDtos(IReadOnlyList<BaseItem> items, DtoOptions options, User? user = null, BaseItem? owner = null, bool skipVisibilityCheck = false); /// <summary> /// Gets the item by name dto. diff --git a/MediaBrowser.Controller/Entities/BaseItem.cs b/MediaBrowser.Controller/Entities/BaseItem.cs index eb7daeb532..13af7a6178 100644 --- a/MediaBrowser.Controller/Entities/BaseItem.cs +++ b/MediaBrowser.Controller/Entities/BaseItem.cs @@ -1806,10 +1806,23 @@ namespace MediaBrowser.Controller.Entities return item; } +#pragma warning disable CS0618 // Type or member is obsolete - fallback for legacy LinkedChild data private BaseItem FindLinkedChild(LinkedChild info) { - var path = info.Path; + // First try to find by ItemId (new preferred method) + if (info.ItemId.HasValue && !info.ItemId.Value.Equals(Guid.Empty)) + { + var item = LibraryManager.GetItemById(info.ItemId.Value); + if (item is not null) + { + return item; + } + Logger.LogWarning("Unable to find linked item by ItemId {0}", info.ItemId); + } + + // Fall back to Path (legacy method) + var path = info.Path; if (!string.IsNullOrEmpty(path)) { path = FileSystem.MakeAbsolutePath(ContainingFolderPath, path); @@ -1824,13 +1837,14 @@ namespace MediaBrowser.Controller.Entities return itemByPath; } + // Fall back to LibraryItemId (legacy method) if (!string.IsNullOrEmpty(info.LibraryItemId)) { var item = LibraryManager.GetItemById(info.LibraryItemId); if (item is null) { - Logger.LogWarning("Unable to find linked item at path {0}", info.Path); + Logger.LogWarning("Unable to find linked item by LibraryItemId {0}", info.LibraryItemId); } return item; @@ -1838,6 +1852,7 @@ namespace MediaBrowser.Controller.Entities return null; } +#pragma warning restore CS0618 /// <summary> /// Adds a studio to the item. diff --git a/MediaBrowser.Controller/Entities/CollectionFolder.cs b/MediaBrowser.Controller/Entities/CollectionFolder.cs index ca79e62454..cf615788ee 100644 --- a/MediaBrowser.Controller/Entities/CollectionFolder.cs +++ b/MediaBrowser.Controller/Entities/CollectionFolder.cs @@ -45,6 +45,11 @@ namespace MediaBrowser.Controller.Entities } /// <summary> + /// Event raised when library options are updated for any collection folder. + /// </summary> + public static event EventHandler<LibraryOptionsUpdatedEventArgs> LibraryOptionsUpdated; + + /// <summary> /// Gets the display preferences id. /// </summary> /// <remarks> @@ -168,6 +173,8 @@ namespace MediaBrowser.Controller.Entities } XmlSerializer.SerializeToFile(clone, GetLibraryOptionsPath(path)); + + LibraryOptionsUpdated?.Invoke(null, new LibraryOptionsUpdatedEventArgs(path, options)); } public static void OnCollectionFolderChange() diff --git a/MediaBrowser.Controller/Entities/Folder.cs b/MediaBrowser.Controller/Entities/Folder.cs index d2a3290c47..6338e54292 100644 --- a/MediaBrowser.Controller/Entities/Folder.cs +++ b/MediaBrowser.Controller/Entities/Folder.cs @@ -59,6 +59,10 @@ namespace MediaBrowser.Controller.Entities /// <value><c>true</c> if this instance is root; otherwise, <c>false</c>.</value> public bool IsRoot { get; set; } + /// <summary> + /// Gets or sets the linked children. + /// </summary> + [JsonIgnore] public LinkedChild[] LinkedChildren { get; set; } [JsonIgnore] @@ -455,6 +459,14 @@ namespace MediaBrowser.Controller.Entities // If it's an AggregateFolder, don't remove if (shouldRemove && itemsRemoved.Count > 0) { + // Build a set of paths that are alternate versions of valid children + // These items should not be deleted - they're managed by their primary video + var alternateVersionPaths = validChildren + .OfType<Video>() + .SelectMany(v => v.LocalAlternateVersions ?? []) + .Where(p => !string.IsNullOrEmpty(p)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + foreach (var item in itemsRemoved) { if (!item.CanDelete()) @@ -463,6 +475,24 @@ namespace MediaBrowser.Controller.Entities continue; } + // Skip items that are alternate versions of another video + if (item is Video video) + { + // Check via PrimaryVersionId + if (!string.IsNullOrEmpty(video.PrimaryVersionId)) + { + Logger.LogDebug("Item is an alternate version (via PrimaryVersionId), skipping deletion: {Path}", item.Path ?? item.Name); + continue; + } + + // Check if path is in LocalAlternateVersions of any valid child + if (!string.IsNullOrEmpty(item.Path) && alternateVersionPaths.Contains(item.Path)) + { + Logger.LogDebug("Item path matches an alternate version, skipping deletion: {Path}", item.Path); + continue; + } + } + if (item.IsFileProtocol) { Logger.LogDebug("Removed item: {Path}", item.Path); @@ -806,104 +836,12 @@ namespace MediaBrowser.Controller.Entities private bool RequiresPostFiltering(InternalItemsQuery query) { - if (LinkedChildren.Length > 0) - { - if (this is not ICollectionFolder) - { - Logger.LogDebug("{Type}: Query requires post-filtering due to LinkedChildren.", GetType().Name); - return true; - } - } - - // Filter by Video3DFormat - if (query.Is3D.HasValue) - { - Logger.LogDebug("Query requires post-filtering due to Is3D"); - return true; - } - - if (query.HasOfficialRating.HasValue) - { - Logger.LogDebug("Query requires post-filtering due to HasOfficialRating"); - return true; - } - - if (query.IsPlaceHolder.HasValue) - { - Logger.LogDebug("Query requires post-filtering due to IsPlaceHolder"); - return true; - } - - if (query.HasSpecialFeature.HasValue) - { - Logger.LogDebug("Query requires post-filtering due to HasSpecialFeature"); - return true; - } - - if (query.HasSubtitles.HasValue) - { - Logger.LogDebug("Query requires post-filtering due to HasSubtitles"); - return true; - } - - if (query.HasTrailer.HasValue) - { - Logger.LogDebug("Query requires post-filtering due to HasTrailer"); - return true; - } - - if (query.HasThemeSong.HasValue) - { - Logger.LogDebug("Query requires post-filtering due to HasThemeSong"); - return true; - } - - if (query.HasThemeVideo.HasValue) - { - Logger.LogDebug("Query requires post-filtering due to HasThemeVideo"); - return true; - } - - // Filter by VideoType - if (query.VideoTypes.Length > 0) - { - Logger.LogDebug("Query requires post-filtering due to VideoTypes"); - return true; - } - if (CollapseBoxSetItems(query, this, query.User, ConfigurationManager)) { Logger.LogDebug("Query requires post-filtering due to CollapseBoxSetItems"); return true; } - if (!query.AdjacentTo.IsNullOrEmpty()) - { - Logger.LogDebug("Query requires post-filtering due to AdjacentTo"); - return true; - } - - if (query.SeriesStatuses.Length > 0) - { - Logger.LogDebug("Query requires post-filtering due to SeriesStatuses"); - return true; - } - - if (query.AiredDuringSeason.HasValue) - { - Logger.LogDebug("Query requires post-filtering due to AiredDuringSeason"); - return true; - } - - if (query.IsPlayed.HasValue) - { - if (query.IncludeItemTypes.Length == 1 && query.IncludeItemTypes.Contains(BaseItemKind.Series)) - { - Logger.LogDebug("Query requires post-filtering due to IsPlayed"); - return true; - } - } - return false; } @@ -1012,29 +950,6 @@ namespace MediaBrowser.Controller.Entities items = CollapseBoxSetItemsIfNeeded(items, query, this, user, ConfigurationManager, CollectionManager); } -#pragma warning disable CA1309 - if (!string.IsNullOrEmpty(query.NameStartsWithOrGreater)) - { - items = items.Where(i => string.Compare(query.NameStartsWithOrGreater, i.SortName, StringComparison.InvariantCultureIgnoreCase) < 1); - } - - if (!string.IsNullOrEmpty(query.NameStartsWith)) - { - items = items.Where(i => i.SortName.StartsWith(query.NameStartsWith, StringComparison.InvariantCultureIgnoreCase)); - } - - if (!string.IsNullOrEmpty(query.NameLessThan)) - { - items = items.Where(i => string.Compare(query.NameLessThan, i.SortName, StringComparison.InvariantCultureIgnoreCase) == 1); - } -#pragma warning restore CA1309 - - // This must be the last filter - if (!query.AdjacentTo.IsNullOrEmpty()) - { - items = UserViewBuilder.FilterForAdjacency(items.ToList(), query.AdjacentTo.Value); - } - var filteredItems = items as IReadOnlyList<BaseItem> ?? items.ToList(); var result = UserViewBuilder.SortAndPage(filteredItems, null, query, LibraryManager); @@ -1664,11 +1579,13 @@ namespace MediaBrowser.Controller.Entities if (!string.IsNullOrEmpty(resolvedPath)) { +#pragma warning disable CS0618 // Type or member is obsolete - shortcuts require Path for lazy ItemId resolution return new LinkedChild { Path = resolvedPath, Type = LinkedChildType.Shortcut }; +#pragma warning restore CS0618 } Logger.LogError("Error resolving shortcut {0}", i.FullName); @@ -1786,38 +1703,42 @@ namespace MediaBrowser.Controller.Entities return; } - if (itemDto is not null && fields.ContainsField(ItemFields.RecursiveItemCount)) - { - itemDto.RecursiveItemCount = GetRecursiveChildCount(user); - } - - if (SupportsPlayedStatus) + if (SupportsPlayedStatus || (itemDto is not null && fields.ContainsField(ItemFields.RecursiveItemCount))) { - var unplayedQueryResult = GetItems(new InternalItemsQuery(user) - { - Recursive = true, - IsFolder = false, - IsVirtualItem = false, - EnableTotalRecordCount = true, - Limit = 0, - IsPlayed = false, - DtoOptions = new DtoOptions(false) - { - EnableImages = false - } - }).TotalRecordCount; + var query = new InternalItemsQuery(user); + LibraryManager.ConfigureUserAccess(query, user); - dto.UnplayedItemCount = unplayedQueryResult; + int playedCount; + int totalCount; - if (itemDto?.RecursiveItemCount > 0) + if (LinkedChildren.Length > 0) { - var unplayedPercentage = ((double)unplayedQueryResult / itemDto.RecursiveItemCount.Value) * 100; - dto.PlayedPercentage = 100 - unplayedPercentage; - dto.Played = dto.PlayedPercentage.Value >= 100; + (playedCount, totalCount) = ItemRepository.GetPlayedAndTotalCountFromLinkedChildren(query, Id); } else { - dto.Played = (dto.UnplayedItemCount ?? 0) == 0; + (playedCount, totalCount) = ItemRepository.GetPlayedAndTotalCount(query, Id); + } + + if (itemDto is not null && fields.ContainsField(ItemFields.RecursiveItemCount)) + { + itemDto.RecursiveItemCount = totalCount; + } + + if (SupportsPlayedStatus) + { + var unplayedCount = totalCount - playedCount; + dto.UnplayedItemCount = unplayedCount; + + if (totalCount > 0) + { + dto.PlayedPercentage = playedCount / (double)totalCount * 100; + dto.Played = playedCount >= totalCount; + } + else + { + dto.Played = true; + } } } } diff --git a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs index 24fe3bb32d..b36ea627d8 100644 --- a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs +++ b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs @@ -33,6 +33,7 @@ namespace MediaBrowser.Controller.Entities ExcludeItemIds = Array.Empty<Guid>(); ExcludeItemTypes = Array.Empty<BaseItemKind>(); ExcludeTags = Array.Empty<string>(); + ExtraTypes = Array.Empty<ExtraType>(); GenreIds = Array.Empty<Guid>(); Genres = Array.Empty<string>(); GroupByPresentationUniqueKey = true; @@ -44,6 +45,7 @@ namespace MediaBrowser.Controller.Entities MediaTypes = Array.Empty<MediaType>(); OfficialRatings = Array.Empty<string>(); OrderBy = Array.Empty<(ItemSortBy, SortOrder)>(); + OwnerIds = Array.Empty<Guid>(); PersonIds = Array.Empty<Guid>(); PersonTypes = Array.Empty<string>(); PresetViews = Array.Empty<CollectionType?>(); @@ -369,6 +371,8 @@ namespace MediaBrowser.Controller.Entities public bool SkipDeserialization { get; set; } + public bool IncludeExtras { get; set; } + public void SetUser(User user) { var maxRating = user.MaxParentalRatingScore; diff --git a/MediaBrowser.Controller/Entities/LinkedChild.cs b/MediaBrowser.Controller/Entities/LinkedChild.cs index 98e4f525f5..a3aa9dd0c9 100644 --- a/MediaBrowser.Controller/Entities/LinkedChild.cs +++ b/MediaBrowser.Controller/Entities/LinkedChild.cs @@ -3,7 +3,6 @@ #pragma warning disable CS1591 using System; -using System.Globalization; namespace MediaBrowser.Controller.Entities { @@ -13,10 +12,18 @@ namespace MediaBrowser.Controller.Entities { } + /// <summary> + /// Gets or sets the path. + /// </summary> + [Obsolete("Use ItemId instead")] public string Path { get; set; } public LinkedChildType Type { get; set; } + /// <summary> + /// Gets or sets the library item id. + /// </summary> + [Obsolete("Use ItemId instead")] public string LibraryItemId { get; set; } /// <summary> @@ -28,18 +35,11 @@ namespace MediaBrowser.Controller.Entities { ArgumentNullException.ThrowIfNull(item); - var child = new LinkedChild + return new LinkedChild { - Path = item.Path, + ItemId = item.Id, Type = LinkedChildType.Manual }; - - if (string.IsNullOrEmpty(child.Path)) - { - child.LibraryItemId = item.Id.ToString("N", CultureInfo.InvariantCulture); - } - - return child; } } } diff --git a/MediaBrowser.Controller/Entities/LinkedChildComparer.cs b/MediaBrowser.Controller/Entities/LinkedChildComparer.cs index 4f13ac61fe..8b611345f4 100644 --- a/MediaBrowser.Controller/Entities/LinkedChildComparer.cs +++ b/MediaBrowser.Controller/Entities/LinkedChildComparer.cs @@ -19,17 +19,34 @@ namespace MediaBrowser.Controller.Entities public bool Equals(LinkedChild x, LinkedChild y) { - if (x.Type == y.Type) + if (x.Type != y.Type) { - return _fileSystem.AreEqual(x.Path, y.Path); + return false; } - return false; + // Compare by ItemId first (preferred) + if (x.ItemId.HasValue && y.ItemId.HasValue) + { + return x.ItemId.Value.Equals(y.ItemId.Value); + } + +#pragma warning disable CS0618 // Type or member is obsolete - fallback for shortcut/legacy comparison + // Fall back to Path comparison for shortcuts and legacy data + return _fileSystem.AreEqual(x.Path, y.Path); +#pragma warning restore CS0618 } public int GetHashCode(LinkedChild obj) { + // Use ItemId for hash if available, otherwise fall back to legacy fields + if (obj.ItemId.HasValue && !obj.ItemId.Value.Equals(Guid.Empty)) + { + return HashCode.Combine(obj.ItemId.Value, obj.Type); + } + +#pragma warning disable CS0618 // Type or member is obsolete - fallback for shortcut/legacy hashing return ((obj.Path ?? string.Empty) + (obj.LibraryItemId ?? string.Empty) + obj.Type).GetHashCode(StringComparison.Ordinal); +#pragma warning restore CS0618 } } } diff --git a/MediaBrowser.Controller/Entities/LinkedChildType.cs b/MediaBrowser.Controller/Entities/LinkedChildType.cs index 3bd260a102..5ce66a561f 100644 --- a/MediaBrowser.Controller/Entities/LinkedChildType.cs +++ b/MediaBrowser.Controller/Entities/LinkedChildType.cs @@ -13,6 +13,16 @@ namespace MediaBrowser.Controller.Entities /// <summary> /// Shortcut linked child. /// </summary> - Shortcut = 1 + Shortcut = 1, + + /// <summary> + /// Local alternate version (same item, different file path). + /// </summary> + LocalAlternateVersion = 2, + + /// <summary> + /// Linked alternate version (different item ID). + /// </summary> + LinkedAlternateVersion = 3 } } diff --git a/MediaBrowser.Controller/Entities/Movies/BoxSet.cs b/MediaBrowser.Controller/Entities/Movies/BoxSet.cs index 3999c3e076..c55a70a67b 100644 --- a/MediaBrowser.Controller/Entities/Movies/BoxSet.cs +++ b/MediaBrowser.Controller/Entities/Movies/BoxSet.cs @@ -37,9 +37,7 @@ namespace MediaBrowser.Controller.Entities.Movies /// <inheritdoc /> [JsonIgnore] - public IReadOnlyList<BaseItem> LocalTrailers => GetExtras() - .Where(extra => extra.ExtraType == Model.Entities.ExtraType.Trailer) - .ToArray(); + public IReadOnlyList<BaseItem> LocalTrailers => GetExtras([Model.Entities.ExtraType.Trailer]).ToArray(); /// <summary> /// Gets or sets the display order. diff --git a/MediaBrowser.Controller/Entities/Movies/Movie.cs b/MediaBrowser.Controller/Entities/Movies/Movie.cs index 710b05e7f9..797f44e2d5 100644 --- a/MediaBrowser.Controller/Entities/Movies/Movie.cs +++ b/MediaBrowser.Controller/Entities/Movies/Movie.cs @@ -4,13 +4,13 @@ using System; using System.Collections.Generic; -using System.Globalization; using System.Linq; using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; using Jellyfin.Data.Enums; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Providers; namespace MediaBrowser.Controller.Entities.Movies { @@ -28,9 +28,7 @@ namespace MediaBrowser.Controller.Entities.Movies /// <inheritdoc /> [JsonIgnore] - public IReadOnlyList<BaseItem> LocalTrailers => GetExtras() - .Where(extra => extra.ExtraType == Model.Entities.ExtraType.Trailer) - .ToArray(); + public IReadOnlyList<BaseItem> LocalTrailers => GetExtras([Model.Entities.ExtraType.Trailer]).ToArray(); /// <summary> /// Gets or sets the name of the TMDb collection. @@ -85,6 +83,34 @@ namespace MediaBrowser.Controller.Entities.Movies return info; } + protected override async Task RefreshMetadataForVersions(MetadataRefreshOptions options, bool copyTitleMetadata, string path, CancellationToken cancellationToken) + { + var newOptions = new MetadataRefreshOptions(options) + { + SearchResult = null + }; + + var id = LibraryManager.GetNewItemId(path, typeof(Movie)); + if (LibraryManager.GetItemById(id) is not Movie movie) + { + movie = LibraryManager.ResolvePath(FileSystem.GetFileSystemInfo(path)) as Movie; + + newOptions.ForceSave = true; + } + + if (movie is null) + { + return; + } + + if (movie.OwnerId.Equals(Guid.Empty)) + { + movie.OwnerId = Id; + } + + await RefreshMetadataForOwnedItem(movie, copyTitleMetadata, newOptions, cancellationToken).ConfigureAwait(false); + } + /// <inheritdoc /> public override bool BeforeMetadataRefresh(bool replaceAllMetadata) { diff --git a/MediaBrowser.Controller/Entities/TV/Episode.cs b/MediaBrowser.Controller/Entities/TV/Episode.cs index 6bdba36f9c..dbe6f94dfd 100644 --- a/MediaBrowser.Controller/Entities/TV/Episode.cs +++ b/MediaBrowser.Controller/Entities/TV/Episode.cs @@ -28,9 +28,7 @@ namespace MediaBrowser.Controller.Entities.TV /// <inheritdoc /> [JsonIgnore] - public IReadOnlyList<BaseItem> LocalTrailers => GetExtras() - .Where(extra => extra.ExtraType == Model.Entities.ExtraType.Trailer) - .ToArray(); + public IReadOnlyList<BaseItem> LocalTrailers => GetExtras([Model.Entities.ExtraType.Trailer]).ToArray(); /// <summary> /// Gets or sets the season in which it aired. diff --git a/MediaBrowser.Controller/Entities/TV/Series.cs b/MediaBrowser.Controller/Entities/TV/Series.cs index 6396631f99..ca9ac3f047 100644 --- a/MediaBrowser.Controller/Entities/TV/Series.cs +++ b/MediaBrowser.Controller/Entities/TV/Series.cs @@ -52,9 +52,7 @@ namespace MediaBrowser.Controller.Entities.TV /// <inheritdoc /> [JsonIgnore] - public IReadOnlyList<BaseItem> LocalTrailers => GetExtras() - .Where(extra => extra.ExtraType == Model.Entities.ExtraType.Trailer) - .ToArray(); + public IReadOnlyList<BaseItem> LocalTrailers => GetExtras([Model.Entities.ExtraType.Trailer]).ToArray(); /// <summary> /// Gets or sets the display order. diff --git a/MediaBrowser.Controller/Entities/UserViewBuilder.cs b/MediaBrowser.Controller/Entities/UserViewBuilder.cs index bed7554b19..47d732c745 100644 --- a/MediaBrowser.Controller/Entities/UserViewBuilder.cs +++ b/MediaBrowser.Controller/Entities/UserViewBuilder.cs @@ -16,9 +16,7 @@ using MediaBrowser.Controller.TV; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Querying; using Microsoft.Extensions.Logging; -using Episode = MediaBrowser.Controller.Entities.TV.Episode; using MetadataProvider = MediaBrowser.Model.Entities.MetadataProvider; -using Series = MediaBrowser.Controller.Entities.TV.Series; namespace MediaBrowser.Controller.Entities { @@ -140,7 +138,7 @@ namespace MediaBrowser.Controller.Entities if (query.IncludeItemTypes.Length == 0) { - query.IncludeItemTypes = new[] { BaseItemKind.Movie }; + query.IncludeItemTypes = [BaseItemKind.Movie]; } return parent.QueryRecursive(query); @@ -165,7 +163,7 @@ namespace MediaBrowser.Controller.Entities query.Parent = parent; query.SetUser(user); query.IsFavorite = true; - query.IncludeItemTypes = new[] { BaseItemKind.Movie }; + query.IncludeItemTypes = [BaseItemKind.Movie]; return _libraryManager.GetItemsResult(query); } @@ -176,7 +174,7 @@ namespace MediaBrowser.Controller.Entities query.Parent = parent; query.SetUser(user); query.IsFavorite = true; - query.IncludeItemTypes = new[] { BaseItemKind.Series }; + query.IncludeItemTypes = [BaseItemKind.Series]; return _libraryManager.GetItemsResult(query); } @@ -187,7 +185,7 @@ namespace MediaBrowser.Controller.Entities query.Parent = parent; query.SetUser(user); query.IsFavorite = true; - query.IncludeItemTypes = new[] { BaseItemKind.Episode }; + query.IncludeItemTypes = [BaseItemKind.Episode]; return _libraryManager.GetItemsResult(query); } @@ -198,7 +196,7 @@ namespace MediaBrowser.Controller.Entities query.Parent = parent; query.SetUser(user); - query.IncludeItemTypes = new[] { BaseItemKind.Movie }; + query.IncludeItemTypes = [BaseItemKind.Movie]; return _libraryManager.GetItemsResult(query); } @@ -206,7 +204,7 @@ namespace MediaBrowser.Controller.Entities private QueryResult<BaseItem> GetMovieCollections(User user, InternalItemsQuery query) { query.Parent = null; - query.IncludeItemTypes = new[] { BaseItemKind.BoxSet }; + query.IncludeItemTypes = [BaseItemKind.BoxSet]; query.SetUser(user); query.Recursive = true; @@ -215,25 +213,25 @@ namespace MediaBrowser.Controller.Entities private QueryResult<BaseItem> GetMovieLatest(Folder parent, User user, InternalItemsQuery query) { - query.OrderBy = new[] { (ItemSortBy.DateCreated, SortOrder.Descending), (ItemSortBy.SortName, SortOrder.Descending) }; + query.OrderBy = [(ItemSortBy.DateCreated, SortOrder.Descending), (ItemSortBy.SortName, SortOrder.Descending)]; query.Recursive = true; query.Parent = parent; query.SetUser(user); query.Limit = GetSpecialItemsLimit(); - query.IncludeItemTypes = new[] { BaseItemKind.Movie }; + query.IncludeItemTypes = [BaseItemKind.Movie]; return ConvertToResult(_libraryManager.GetItemList(query)); } private QueryResult<BaseItem> GetMovieResume(Folder parent, User user, InternalItemsQuery query) { - query.OrderBy = new[] { (ItemSortBy.DatePlayed, SortOrder.Descending), (ItemSortBy.SortName, SortOrder.Descending) }; + query.OrderBy = [(ItemSortBy.DatePlayed, SortOrder.Descending), (ItemSortBy.SortName, SortOrder.Descending)]; query.IsResumable = true; query.Recursive = true; query.Parent = parent; query.SetUser(user); query.Limit = GetSpecialItemsLimit(); - query.IncludeItemTypes = new[] { BaseItemKind.Movie }; + query.IncludeItemTypes = [BaseItemKind.Movie]; return ConvertToResult(_libraryManager.GetItemList(query)); } @@ -247,7 +245,7 @@ namespace MediaBrowser.Controller.Entities { var genres = parent.QueryRecursive(new InternalItemsQuery(user) { - IncludeItemTypes = new[] { BaseItemKind.Movie }, + IncludeItemTypes = [BaseItemKind.Movie], Recursive = true, EnableTotalRecordCount = false }).Items @@ -275,10 +273,10 @@ namespace MediaBrowser.Controller.Entities { query.Recursive = true; query.Parent = queryParent; - query.GenreIds = new[] { displayParent.Id }; + query.GenreIds = [displayParent.Id]; query.SetUser(user); - query.IncludeItemTypes = new[] { BaseItemKind.Movie }; + query.IncludeItemTypes = [BaseItemKind.Movie]; return _libraryManager.GetItemsResult(query); } @@ -292,12 +290,12 @@ namespace MediaBrowser.Controller.Entities if (query.IncludeItemTypes.Length == 0) { - query.IncludeItemTypes = new[] - { + query.IncludeItemTypes = + [ BaseItemKind.Series, BaseItemKind.Season, BaseItemKind.Episode - }; + ]; } return parent.QueryRecursive(query); @@ -319,12 +317,12 @@ namespace MediaBrowser.Controller.Entities private QueryResult<BaseItem> GetTvLatest(Folder parent, User user, InternalItemsQuery query) { - query.OrderBy = new[] { (ItemSortBy.DateCreated, SortOrder.Descending), (ItemSortBy.SortName, SortOrder.Descending) }; + query.OrderBy = [(ItemSortBy.DateCreated, SortOrder.Descending), (ItemSortBy.SortName, SortOrder.Descending)]; query.Recursive = true; query.Parent = parent; query.SetUser(user); query.Limit = GetSpecialItemsLimit(); - query.IncludeItemTypes = new[] { BaseItemKind.Episode }; + query.IncludeItemTypes = [BaseItemKind.Episode]; query.IsVirtualItem = false; return ConvertToResult(_libraryManager.GetItemList(query)); @@ -332,7 +330,7 @@ namespace MediaBrowser.Controller.Entities private QueryResult<BaseItem> GetTvNextUp(Folder parent, InternalItemsQuery query) { - var parentFolders = GetMediaFolders(parent, query.User, new[] { CollectionType.tvshows }); + var parentFolders = GetMediaFolders(parent, query.User, [CollectionType.tvshows]); var result = _tvSeriesManager.GetNextUp( new NextUpQuery @@ -349,13 +347,13 @@ namespace MediaBrowser.Controller.Entities private QueryResult<BaseItem> GetTvResume(Folder parent, User user, InternalItemsQuery query) { - query.OrderBy = new[] { (ItemSortBy.DatePlayed, SortOrder.Descending), (ItemSortBy.SortName, SortOrder.Descending) }; + query.OrderBy = [(ItemSortBy.DatePlayed, SortOrder.Descending), (ItemSortBy.SortName, SortOrder.Descending)]; query.IsResumable = true; query.Recursive = true; query.Parent = parent; query.SetUser(user); query.Limit = GetSpecialItemsLimit(); - query.IncludeItemTypes = new[] { BaseItemKind.Episode }; + query.IncludeItemTypes = [BaseItemKind.Episode]; return ConvertToResult(_libraryManager.GetItemList(query)); } @@ -366,7 +364,7 @@ namespace MediaBrowser.Controller.Entities query.Parent = parent; query.SetUser(user); - query.IncludeItemTypes = new[] { BaseItemKind.Series }; + query.IncludeItemTypes = [BaseItemKind.Series]; return _libraryManager.GetItemsResult(query); } @@ -375,7 +373,7 @@ namespace MediaBrowser.Controller.Entities { var genres = parent.QueryRecursive(new InternalItemsQuery(user) { - IncludeItemTypes = new[] { BaseItemKind.Series }, + IncludeItemTypes = [BaseItemKind.Series], Recursive = true, EnableTotalRecordCount = false }).Items @@ -403,10 +401,10 @@ namespace MediaBrowser.Controller.Entities { query.Recursive = true; query.Parent = queryParent; - query.GenreIds = new[] { displayParent.Id }; + query.GenreIds = [displayParent.Id]; query.SetUser(user); - query.IncludeItemTypes = new[] { BaseItemKind.Series }; + query.IncludeItemTypes = [BaseItemKind.Series]; return _libraryManager.GetItemsResult(query); } @@ -418,7 +416,7 @@ namespace MediaBrowser.Controller.Entities { items = items.Where(i => Filter(i, query.User, query, _userDataManager, _libraryManager)); - return PostFilterAndSort(items, null, query, _libraryManager); + return SortAndPage(items, null, query, _libraryManager); } public static bool FilterItem(BaseItem item, InternalItemsQuery query) @@ -426,21 +424,6 @@ namespace MediaBrowser.Controller.Entities return Filter(item, query.User, query, BaseItem.UserDataManager, BaseItem.LibraryManager); } - public static QueryResult<BaseItem> PostFilterAndSort( - IEnumerable<BaseItem> items, - int? totalRecordLimit, - InternalItemsQuery query, - ILibraryManager libraryManager) - { - // This must be the last filter - if (!query.AdjacentTo.IsNullOrEmpty()) - { - items = FilterForAdjacency(items.ToList(), query.AdjacentTo.Value); - } - - return SortAndPage(items, totalRecordLimit, query, libraryManager); - } - public static QueryResult<BaseItem> SortAndPage( IEnumerable<BaseItem> items, int? totalRecordLimit, @@ -556,38 +539,6 @@ namespace MediaBrowser.Controller.Entities } } - if (query.IsPlayed.HasValue) - { - userData ??= userDataManager.GetUserData(user, item); - if (item.IsPlayed(user, userData) != query.IsPlayed.Value) - { - return false; - } - } - - // Filter by Video3DFormat - if (query.Is3D.HasValue) - { - var val = query.Is3D.Value; - var video = item as Video; - - if (video is null || val != video.Video3DFormat.HasValue) - { - return false; - } - } - - /* - * fuck - fix this - if (query.IsHD.HasValue) - { - if (item.IsHD != query.IsHD.Value) - { - return false; - } - } - */ - if (query.IsLocked.HasValue) { var val = query.IsLocked.Value; @@ -645,68 +596,6 @@ namespace MediaBrowser.Controller.Entities } } - if (query.HasOfficialRating.HasValue) - { - var filterValue = query.HasOfficialRating.Value; - - var hasValue = !string.IsNullOrEmpty(item.OfficialRating); - - if (hasValue != filterValue) - { - return false; - } - } - - if (query.IsPlaceHolder.HasValue) - { - var filterValue = query.IsPlaceHolder.Value; - - var isPlaceHolder = false; - - if (item is ISupportsPlaceHolders hasPlaceHolder) - { - isPlaceHolder = hasPlaceHolder.IsPlaceHolder; - } - - if (isPlaceHolder != filterValue) - { - return false; - } - } - - if (query.HasSpecialFeature.HasValue) - { - var filterValue = query.HasSpecialFeature.Value; - - if (item is IHasSpecialFeatures movie) - { - var ok = filterValue - ? movie.SpecialFeatureIds.Count > 0 - : movie.SpecialFeatureIds.Count == 0; - - if (!ok) - { - return false; - } - } - else - { - return false; - } - } - - if (query.HasSubtitles.HasValue) - { - var val = query.HasSubtitles.Value; - - var video = item as Video; - - if (video is null || val != video.HasSubtitles) - { - return false; - } - } - if (query.HasParentalRating.HasValue) { var val = query.HasParentalRating.Value; @@ -734,66 +623,12 @@ namespace MediaBrowser.Controller.Entities } } - if (query.HasTrailer.HasValue) - { - var val = query.HasTrailer.Value; - var trailerCount = 0; - - if (item is IHasTrailers hasTrailers) - { - trailerCount = hasTrailers.GetTrailerCount(); - } - - var ok = val ? trailerCount > 0 : trailerCount == 0; - - if (!ok) - { - return false; - } - } - - if (query.HasThemeSong.HasValue) - { - var filterValue = query.HasThemeSong.Value; - - var themeCount = item.GetThemeSongs(user).Count; - var ok = filterValue ? themeCount > 0 : themeCount == 0; - - if (!ok) - { - return false; - } - } - - if (query.HasThemeVideo.HasValue) - { - var filterValue = query.HasThemeVideo.Value; - - var themeCount = item.GetThemeVideos(user).Count; - var ok = filterValue ? themeCount > 0 : themeCount == 0; - - if (!ok) - { - return false; - } - } - // Apply genre filter if (query.Genres.Count > 0 && !query.Genres.Any(v => item.Genres.Contains(v, StringComparison.OrdinalIgnoreCase))) { return false; } - // Filter by VideoType - if (query.VideoTypes.Length > 0) - { - var video = item as Video; - if (video is null || !query.VideoTypes.Contains(video.VideoType)) - { - return false; - } - } - if (query.ImageTypes.Length > 0 && !query.ImageTypes.Any(item.HasImage)) { return false; @@ -912,30 +747,6 @@ namespace MediaBrowser.Controller.Entities } } - if (query.SeriesStatuses.Length > 0) - { - var ok = new[] { item }.OfType<Series>().Any(p => p.Status.HasValue && query.SeriesStatuses.Contains(p.Status.Value)); - if (!ok) - { - return false; - } - } - - if (query.AiredDuringSeason.HasValue) - { - var episode = item as Episode; - - if (episode is null) - { - return false; - } - - if (!Series.FilterEpisodesBySeason(new[] { episode }, query.AiredDuringSeason.Value, true).Any()) - { - return false; - } - } - if (query.ExcludeItemIds.Contains(item.Id)) { return false; @@ -989,7 +800,7 @@ namespace MediaBrowser.Controller.Entities return GetMediaFolders(user, viewTypes); } - return new BaseItem[] { parent }; + return [parent]; } private UserView GetUserViewWithName(CollectionType? type, string sortName, BaseItem parent) diff --git a/MediaBrowser.Controller/Entities/Video.cs b/MediaBrowser.Controller/Entities/Video.cs index 1043029c6e..1ddc193359 100644 --- a/MediaBrowser.Controller/Entities/Video.cs +++ b/MediaBrowser.Controller/Entities/Video.cs @@ -160,7 +160,7 @@ namespace MediaBrowser.Controller.Entities public bool IsStacked => AdditionalParts.Length > 0; [JsonIgnore] - public override bool HasLocalAlternateVersions => LocalAlternateVersions.Length > 0; + public override bool HasLocalAlternateVersions => LibraryManager.GetLocalAlternateVersionIds(this).Any(); public static IRecordingsManager RecordingsManager { get; set; } @@ -260,7 +260,10 @@ namespace MediaBrowser.Controller.Entities { if (callstack.Contains(video.Id)) { - return video.LinkedAlternateVersions.Length + video.LocalAlternateVersions.Length + 1; + // Count alternate versions using LibraryManager + var linkedCount = LibraryManager.GetLinkedAlternateVersions(video).Count(); + var localCount = LibraryManager.GetLocalAlternateVersionIds(video).Count(); + return linkedCount + localCount + 1; } callstack.Add(video.Id); @@ -268,7 +271,10 @@ namespace MediaBrowser.Controller.Entities } } - return LinkedAlternateVersions.Length + LocalAlternateVersions.Length + 1; + // Count alternate versions using LibraryManager + var linkedVersionCount = LibraryManager.GetLinkedAlternateVersions(this).Count(); + var localVersionCount = LibraryManager.GetLocalAlternateVersionIds(this).Count(); + return linkedVersionCount + localVersionCount + 1; } public override List<string> GetUserDataKeys() @@ -364,11 +370,6 @@ namespace MediaBrowser.Controller.Entities return AdditionalParts.Select(i => LibraryManager.GetNewItemId(i, typeof(Video))); } - public IEnumerable<Guid> GetLocalAlternateVersionIds() - { - return LocalAlternateVersions.Select(i => LibraryManager.GetNewItemId(i, typeof(Video))); - } - private string GetUserDataKey(string providerId) { var key = providerId + "-" + ExtraType.ToString().ToLowerInvariant(); @@ -382,15 +383,6 @@ namespace MediaBrowser.Controller.Entities return key; } - public IEnumerable<Video> GetLinkedAlternateVersions() - { - return LinkedAlternateVersions - .Select(GetLinkedChild) - .Where(i => i is not null) - .OfType<Video>() - .OrderBy(i => i.SortName); - } - /// <summary> /// Gets the additional parts. /// </summary> @@ -454,7 +446,7 @@ namespace MediaBrowser.Controller.Entities RefreshLinkedAlternateVersions(); var tasks = LocalAlternateVersions - .Select(i => RefreshMetadataForOwnedVideo(options, false, i, cancellationToken)); + .Select(i => RefreshMetadataForVersions(options, false, i, cancellationToken)); await Task.WhenAll(tasks).ConfigureAwait(false); } @@ -463,6 +455,39 @@ namespace MediaBrowser.Controller.Entities return hasChanges; } + protected virtual async Task RefreshMetadataForVersions(MetadataRefreshOptions options, bool copyTitleMetadata, string path, CancellationToken cancellationToken) + { + await RefreshMetadataForOwnedVideo(options, copyTitleMetadata, path, cancellationToken).ConfigureAwait(false); + } + + private new async Task RefreshMetadataForOwnedVideo(MetadataRefreshOptions options, bool copyTitleMetadata, string path, CancellationToken cancellationToken) + { + var newOptions = new MetadataRefreshOptions(options) + { + SearchResult = null + }; + + var id = LibraryManager.GetNewItemId(path, typeof(Video)); + if (LibraryManager.GetItemById(id) is not Video video) + { + video = LibraryManager.ResolvePath(FileSystem.GetFileSystemInfo(path)) as Video; + + newOptions.ForceSave = true; + } + + if (video is null) + { + return; + } + + if (video.OwnerId.IsEmpty()) + { + video.OwnerId = Id; + } + + await RefreshMetadataForOwnedItem(video, copyTitleMetadata, newOptions, cancellationToken).ConfigureAwait(false); + } + private void RefreshLinkedAlternateVersions() { foreach (var child in LinkedAlternateVersions) @@ -480,7 +505,7 @@ namespace MediaBrowser.Controller.Entities { await base.UpdateToRepositoryAsync(updateReason, cancellationToken).ConfigureAwait(false); - var localAlternates = GetLocalAlternateVersionIds() + var localAlternates = LibraryManager.GetLocalAlternateVersionIds(this) .Select(i => LibraryManager.GetItemById(i)) .Where(i => i is not null); @@ -537,7 +562,7 @@ namespace MediaBrowser.Controller.Entities (this, MediaSourceType.Default) }; - list.AddRange(GetLinkedAlternateVersions().Select(i => ((BaseItem)i, MediaSourceType.Grouping))); + list.AddRange(LibraryManager.GetLinkedAlternateVersions(this).Select(i => ((BaseItem)i, MediaSourceType.Grouping))); if (!string.IsNullOrEmpty(PrimaryVersionId)) { @@ -545,14 +570,14 @@ namespace MediaBrowser.Controller.Entities { var existingIds = list.Select(i => i.Item1.Id).ToList(); list.Add((primary, MediaSourceType.Grouping)); - list.AddRange(primary.GetLinkedAlternateVersions().Where(i => !existingIds.Contains(i.Id)).Select(i => ((BaseItem)i, MediaSourceType.Grouping))); + list.AddRange(LibraryManager.GetLinkedAlternateVersions(primary).Where(i => !existingIds.Contains(i.Id)).Select(i => ((BaseItem)i, MediaSourceType.Grouping))); } } var localAlternates = list .SelectMany(i => { - return i.Item1 is Video video ? video.GetLocalAlternateVersionIds() : Enumerable.Empty<Guid>(); + return i.Item1 is Video video ? LibraryManager.GetLocalAlternateVersionIds(video) : Enumerable.Empty<Guid>(); }) .Select(LibraryManager.GetItemById) .Where(i => i is not null) diff --git a/MediaBrowser.Controller/Library/ILibraryManager.cs b/MediaBrowser.Controller/Library/ILibraryManager.cs index df1c98f3f7..c19d15d85f 100644 --- a/MediaBrowser.Controller/Library/ILibraryManager.cs +++ b/MediaBrowser.Controller/Library/ILibraryManager.cs @@ -214,6 +214,22 @@ namespace MediaBrowser.Controller.Library Task<IEnumerable<Video>> GetIntros(BaseItem item, User user); /// <summary> + /// Gets the IDs of local alternate versions for a video. + /// Local alternate versions are alternate quality versions at different file paths. + /// </summary> + /// <param name="video">The video item.</param> + /// <returns>Enumerable of alternate version item IDs.</returns> + IEnumerable<Guid> GetLocalAlternateVersionIds(Video video); + + /// <summary> + /// Gets the linked alternate versions for a video. + /// Linked alternate versions are different items representing the same content (e.g., Director's Cut). + /// </summary> + /// <param name="video">The video item.</param> + /// <returns>Enumerable of linked Video items.</returns> + IEnumerable<Video> GetLinkedAlternateVersions(Video video); + + /// <summary> /// Adds the parts. /// </summary> /// <param name="rules">The rules.</param> @@ -601,6 +617,20 @@ namespace MediaBrowser.Controller.Library IReadOnlyList<string> GetNextUpSeriesKeys(InternalItemsQuery query, IReadOnlyCollection<BaseItem> parents, DateTime dateCutoff); /// <summary> + /// Gets next up episodes for multiple series in a single batched query. + /// </summary> + /// <param name="query">The query filter.</param> + /// <param name="seriesKeys">The series presentation unique keys to query.</param> + /// <param name="includeSpecials">Whether to include specials for aired episode order sorting.</param> + /// <param name="includeWatchedForRewatching">Whether to include watched episodes for rewatching mode.</param> + /// <returns>A dictionary mapping series key to batch result.</returns> + IReadOnlyDictionary<string, MediaBrowser.Controller.Persistence.NextUpEpisodeBatchResult> GetNextUpEpisodesBatch( + InternalItemsQuery query, + IReadOnlyList<string> seriesKeys, + bool includeSpecials, + bool includeWatchedForRewatching); + + /// <summary> /// Gets the items result. /// </summary> /// <param name="query">The query.</param> @@ -649,6 +679,23 @@ namespace MediaBrowser.Controller.Library ItemCounts GetItemCounts(InternalItemsQuery query); + /// <summary> + /// Batch-fetches child counts for multiple parent folders. + /// Returns the count of immediate children (non-recursive) for each parent. + /// </summary> + /// <param name="parentIds">The list of parent folder IDs.</param> + /// <param name="userId">The user ID for access filtering.</param> + /// <returns>Dictionary mapping parent ID to child count.</returns> + Dictionary<Guid, int> GetChildCountBatch(IReadOnlyList<Guid> parentIds, Guid? userId); + + /// <summary> + /// Configures the query with user access settings including TopParentIds for library access. + /// Call this before passing a query to methods that need user access filtering. + /// </summary> + /// <param name="query">The query to configure.</param> + /// <param name="user">The user to configure access for.</param> + void ConfigureUserAccess(InternalItemsQuery query, User user); + Task RunMetadataSavers(BaseItem item, ItemUpdateType updateReason); BaseItem GetParentItem(Guid? parentId, Guid? userId); diff --git a/MediaBrowser.Controller/Persistence/IItemRepository.cs b/MediaBrowser.Controller/Persistence/IItemRepository.cs index bf80b7d0a8..f7ed39730e 100644 --- a/MediaBrowser.Controller/Persistence/IItemRepository.cs +++ b/MediaBrowser.Controller/Persistence/IItemRepository.cs @@ -88,6 +88,21 @@ public interface IItemRepository IReadOnlyList<string> GetNextUpSeriesKeys(InternalItemsQuery filter, DateTime dateCutoff); /// <summary> + /// Gets next up episodes for multiple series in a single batched query. + /// Returns the last watched episode, next unwatched episode, specials, and next played episode for each series. + /// </summary> + /// <param name="filter">The query filter.</param> + /// <param name="seriesKeys">The series presentation unique keys to query.</param> + /// <param name="includeSpecials">Whether to include specials (ParentIndexNumber = 0) in the results.</param> + /// <param name="includeWatchedForRewatching">Whether to include watched episodes for rewatching mode.</param> + /// <returns>A dictionary mapping series key to batch result containing episodes needed for NextUp calculation.</returns> + IReadOnlyDictionary<string, NextUpEpisodeBatchResult> GetNextUpEpisodesBatch( + InternalItemsQuery filter, + IReadOnlyList<string> seriesKeys, + bool includeSpecials, + bool includeWatchedForRewatching); + + /// <summary> /// Updates the inherited values. /// </summary> void UpdateInheritedValues(); @@ -133,9 +148,66 @@ public interface IItemRepository bool GetIsPlayed(User user, Guid id, bool recursive); /// <summary> + /// Gets the count of played items that are descendants of the specified ancestor. + /// Uses the AncestorIds table for efficient recursive lookup. + /// Applies user access filtering (library access, parental controls, tags). + /// </summary> + /// <param name="filter">The query filter containing user access settings.</param> + /// <param name="ancestorId">The ancestor item id.</param> + /// <returns>The count of played descendant items.</returns> + int GetPlayedCount(InternalItemsQuery filter, Guid ancestorId); + + /// <summary> + /// Gets the total count of items that are descendants of the specified ancestor. + /// Uses the AncestorIds table for efficient recursive lookup. + /// Applies user access filtering (library access, parental controls, tags). + /// </summary> + /// <param name="filter">The query filter containing user access settings.</param> + /// <param name="ancestorId">The ancestor item id.</param> + /// <returns>The total count of descendant items.</returns> + int GetTotalCount(InternalItemsQuery filter, Guid ancestorId); + + /// <summary> + /// Gets both the played count and total count of items that are descendants of the specified ancestor. + /// Uses the AncestorIds table for efficient recursive lookup. + /// Applies user access filtering (library access, parental controls, tags). + /// </summary> + /// <param name="filter">The query filter containing user access settings.</param> + /// <param name="ancestorId">The ancestor item id.</param> + /// <returns>A tuple containing (Played count, Total count).</returns> + (int Played, int Total) GetPlayedAndTotalCount(InternalItemsQuery filter, Guid ancestorId); + + /// <summary> + /// Gets both the played count and total count of items that are linked children of the specified parent. + /// Uses the LinkedChildren table for BoxSets, Playlists, etc. + /// Applies user access filtering (library access, parental controls, tags). + /// </summary> + /// <param name="filter">The query filter containing user access settings.</param> + /// <param name="parentId">The parent item id (BoxSet, Playlist, etc.).</param> + /// <returns>A tuple containing (Played count, Total count).</returns> + (int Played, int Total) GetPlayedAndTotalCountFromLinkedChildren(InternalItemsQuery filter, Guid parentId); + + /// <summary> + /// Gets the IDs of linked children for the specified parent. + /// </summary> + /// <param name="parentId">The parent item ID.</param> + /// <param name="childType">Optional child type filter (e.g., LocalAlternateVersion, LinkedAlternateVersion).</param> + /// <returns>List of child item IDs.</returns> + IReadOnlyList<Guid> GetLinkedChildrenIds(Guid parentId, int? childType = null); + + /// <summary> /// Gets all artist matches from the db. /// </summary> /// <param name="artistNames">The names of the artists.</param> /// <returns>A map of the artist name and the potential matches.</returns> IReadOnlyDictionary<string, MusicArtist[]> FindArtists(IReadOnlyList<string> artistNames); + + /// <summary> + /// Batch-fetches child counts for multiple parent folders. + /// Returns the count of immediate children (non-recursive) for each parent. + /// </summary> + /// <param name="parentIds">The list of parent folder IDs.</param> + /// <param name="userId">The user ID for access filtering.</param> + /// <returns>Dictionary mapping parent ID to child count.</returns> + Dictionary<Guid, int> GetChildCountBatch(IReadOnlyList<Guid> parentIds, Guid? userId); } diff --git a/MediaBrowser.Controller/Persistence/NextUpEpisodeBatchResult.cs b/MediaBrowser.Controller/Persistence/NextUpEpisodeBatchResult.cs new file mode 100644 index 0000000000..f5b09498b9 --- /dev/null +++ b/MediaBrowser.Controller/Persistence/NextUpEpisodeBatchResult.cs @@ -0,0 +1,38 @@ +using System.Collections.Generic; +using MediaBrowser.Controller.Entities; + +namespace MediaBrowser.Controller.Persistence; + +/// <summary> +/// Result of a batched NextUp query for a single series. +/// </summary> +public sealed class NextUpEpisodeBatchResult +{ + /// <summary> + /// Gets or sets the last watched episode (highest season/episode that is played). + /// </summary> + public BaseItem? LastWatched { get; set; } + + /// <summary> + /// Gets or sets the next unwatched episode after the last watched position. + /// </summary> + public BaseItem? NextUp { get; set; } + + /// <summary> + /// Gets or sets specials that may air between episodes. + /// Only populated when includeSpecials is true. + /// </summary> + public IReadOnlyList<BaseItem>? Specials { get; set; } + + /// <summary> + /// Gets or sets the last watched episode for rewatching mode (most recently played). + /// Only populated when includeWatchedForRewatching is true. + /// </summary> + public BaseItem? LastWatchedForRewatching { get; set; } + + /// <summary> + /// Gets or sets the next played episode for rewatching mode. + /// Only populated when includeWatchedForRewatching is true. + /// </summary> + public BaseItem? NextPlayedForRewatching { get; set; } +} |
