diff options
| author | Shadowghost <Ghost_of_Stone@web.de> | 2026-05-04 21:26:26 +0200 |
|---|---|---|
| committer | Shadowghost <Ghost_of_Stone@web.de> | 2026-05-04 21:26:26 +0200 |
| commit | 57c0fcd674c659c658369f0aebfd5d9d6787a9d4 (patch) | |
| tree | 7aff23d6f54e913a6a34cb5a2568a07298582444 /MediaBrowser.Controller | |
| parent | 68ab58589444091925c15ad20d36f935b7bc2e21 (diff) | |
| parent | ec04313317bed62728b059108cd232e9744f6354 (diff) | |
Merge remote-tracking branch 'upstream/master' into epg-fixes
Diffstat (limited to 'MediaBrowser.Controller')
29 files changed, 1515 insertions, 653 deletions
diff --git a/MediaBrowser.Controller/Chapters/IChapterManager.cs b/MediaBrowser.Controller/Chapters/IChapterManager.cs index 25656fd625..edc20205aa 100644 --- a/MediaBrowser.Controller/Chapters/IChapterManager.cs +++ b/MediaBrowser.Controller/Chapters/IChapterManager.cs @@ -14,11 +14,18 @@ namespace MediaBrowser.Controller.Chapters; public interface IChapterManager { /// <summary> + /// Gets a value indicating whether the specified item type is supported for chapter operations. + /// </summary> + /// <param name="item">The item to check.</param> + /// <returns><c>true</c> if the item type supports chapters; otherwise, <c>false</c>.</returns> + bool Supports(BaseItem item); + + /// <summary> /// Saves the chapters. /// </summary> - /// <param name="video">The video.</param> + /// <param name="item">The item.</param> /// <param name="chapters">The set of chapters.</param> - void SaveChapters(Video video, IReadOnlyList<ChapterInfo> chapters); + void SaveChapters(BaseItem item, IReadOnlyList<ChapterInfo> chapters); /// <summary> /// Gets a single chapter of a BaseItem on a specific index. diff --git a/MediaBrowser.Controller/Dto/IDtoService.cs b/MediaBrowser.Controller/Dto/IDtoService.cs index f1d507fcbd..f735abb09f 100644 --- a/MediaBrowser.Controller/Dto/IDtoService.cs +++ b/MediaBrowser.Controller/Dto/IDtoService.cs @@ -36,8 +36,14 @@ 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 e312e9d80b..822b21c062 100644 --- a/MediaBrowser.Controller/Entities/BaseItem.cs +++ b/MediaBrowser.Controller/Entities/BaseItem.cs @@ -106,7 +106,6 @@ namespace MediaBrowser.Controller.Entities ImageInfos = Array.Empty<ItemImageInfo>(); ProductionLocations = Array.Empty<string>(); RemoteTrailers = Array.Empty<MediaUrl>(); - ExtraIds = Array.Empty<Guid>(); UserData = []; } @@ -397,8 +396,6 @@ namespace MediaBrowser.Controller.Entities public int Height { get; set; } - public Guid[] ExtraIds { get; set; } - /// <summary> /// Gets the primary image path. /// </summary> @@ -491,6 +488,8 @@ namespace MediaBrowser.Controller.Entities public static IItemRepository ItemRepository { get; set; } + public static IItemCountService ItemCountService { get; set; } + public static IChapterManager ChapterManager { get; set; } public static IFileSystem FileSystem { get; set; } @@ -1340,14 +1339,15 @@ namespace MediaBrowser.Controller.Entities return false; } - if (GetParents().Any(i => !i.IsVisible(user, true))) + var parents = GetParents().ToList(); + if (parents.Any(i => !i.IsVisible(user, true))) { return false; } if (checkFolders) { - var topParent = GetParents().LastOrDefault() ?? this; + var topParent = parents.Count > 0 ? parents[^1] : this; if (string.IsNullOrEmpty(topParent.Path)) { @@ -1358,8 +1358,27 @@ namespace MediaBrowser.Controller.Entities if (itemCollectionFolders.Count > 0) { - var userCollectionFolders = LibraryManager.GetUserRootFolder().GetChildren(user, true).Select(i => i.Id).ToList(); - if (!itemCollectionFolders.Any(userCollectionFolders.Contains)) + var blockedMediaFolders = user.GetPreferenceValues<Guid>(PreferenceKind.BlockedMediaFolders); + IEnumerable<Guid> userCollectionFolderIds; + if (blockedMediaFolders.Length > 0) + { + // User has blocked folders - get all library folders and exclude blocked ones + userCollectionFolderIds = LibraryManager.GetUserRootFolder().Children + .Select(i => i.Id) + .Where(id => !blockedMediaFolders.Contains(id)); + } + else if (user.HasPermission(PermissionKind.EnableAllFolders)) + { + // User can access all folders - no need to filter + return true; + } + else + { + // User has specific enabled folders + userCollectionFolderIds = user.GetPreferenceValues<Guid>(PreferenceKind.EnabledFolders); + } + + if (!itemCollectionFolders.Any(userCollectionFolderIds.Contains)) { return false; } @@ -1401,7 +1420,13 @@ namespace MediaBrowser.Controller.Entities { var extras = LibraryManager.FindExtras(item, fileSystemChildren, options.DirectoryService).ToArray(); var newExtraIds = Array.ConvertAll(extras, x => x.Id); - var extrasChanged = !item.ExtraIds.SequenceEqual(newExtraIds); + + var currentExtraIds = LibraryManager.GetItemList(new InternalItemsQuery() + { + OwnerIds = [item.Id] + }).Select(e => e.Id).ToArray(); + + var extrasChanged = !currentExtraIds.OrderBy(x => x).SequenceEqual(newExtraIds.OrderBy(x => x)); if (!extrasChanged && !options.ReplaceAllMetadata && options.MetadataRefreshMode != MetadataRefreshMode.FullRefresh) { @@ -1415,16 +1440,15 @@ namespace MediaBrowser.Controller.Entities var subOptions = new MetadataRefreshOptions(options); if (!i.OwnerId.Equals(ownerId) || !i.ParentId.IsEmpty()) { - i.OwnerId = ownerId; - i.ParentId = Guid.Empty; subOptions.ForceSave = true; } + i.OwnerId = ownerId; + i.ParentId = Guid.Empty; return RefreshMetadataForOwnedItem(i, true, subOptions, cancellationToken); }); - // Cleanup removed extras - var removedExtraIds = item.ExtraIds.Where(e => !newExtraIds.Contains(e)).ToArray(); + var removedExtraIds = currentExtraIds.Where(e => !newExtraIds.Contains(e)).ToArray(); if (removedExtraIds.Length > 0) { var removedExtras = LibraryManager.GetItemList(new InternalItemsQuery() @@ -1433,17 +1457,20 @@ namespace MediaBrowser.Controller.Entities }); foreach (var removedExtra in removedExtras) { - LibraryManager.DeleteItem(removedExtra, new DeleteOptions() + // Only delete items that are actual extras (have ExtraType set) + // Items with OwnerId but no ExtraType might be alternate versions, not extras + if (removedExtra.ExtraType.HasValue) { - DeleteFileLocation = false - }); + LibraryManager.DeleteItem(removedExtra, new DeleteOptions() + { + DeleteFileLocation = false + }); + } } } await Task.WhenAll(tasks).ConfigureAwait(false); - item.ExtraIds = newExtraIds; - return true; } @@ -1673,10 +1700,28 @@ namespace MediaBrowser.Controller.Entities return list.Distinct(StringComparer.OrdinalIgnoreCase).ToList(); } - private bool IsVisibleViaTags(User user, bool skipAllowedTagsCheck) + protected bool IsVisibleViaTags(User user, bool skipAllowedTagsCheck) { - var allTags = GetInheritedTags(); - if (user.GetPreference(PreferenceKind.BlockedTags).Any(i => allTags.Contains(i, StringComparison.OrdinalIgnoreCase))) + var blockedTags = user.GetPreference(PreferenceKind.BlockedTags); + var allowedTags = user.GetPreference(PreferenceKind.AllowedTags); + + if (blockedTags.Length == 0 && allowedTags.Length == 0) + { + return true; + } + + // Normalize tags using the same logic as database queries + var normalizedBlockedTags = blockedTags + .Where(t => !string.IsNullOrWhiteSpace(t)) + .Select(t => t.GetCleanValue()) + .ToHashSet(StringComparer.Ordinal); + + var normalizedItemTags = GetInheritedTags() + .Select(t => t.GetCleanValue()) + .ToHashSet(StringComparer.Ordinal); + + // Check blocked tags - item is hidden if it has any blocked tag + if (normalizedBlockedTags.Overlaps(normalizedItemTags)) { return false; } @@ -1687,10 +1732,18 @@ namespace MediaBrowser.Controller.Entities return true; } - var allowedTagsPreference = user.GetPreference(PreferenceKind.AllowedTags); - if (!skipAllowedTagsCheck && allowedTagsPreference.Length != 0 && !allowedTagsPreference.Any(i => allTags.Contains(i, StringComparison.OrdinalIgnoreCase))) + // Check allowed tags - item must have at least one allowed tag + if (!skipAllowedTagsCheck && allowedTags.Length > 0) { - return false; + var normalizedAllowedTags = allowedTags + .Where(t => !string.IsNullOrWhiteSpace(t)) + .Select(t => t.GetCleanValue()) + .ToHashSet(StringComparer.Ordinal); + + if (!normalizedAllowedTags.Overlaps(normalizedItemTags)) + { + return false; + } } return true; @@ -1803,10 +1856,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); @@ -1821,13 +1887,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; @@ -1835,6 +1902,7 @@ namespace MediaBrowser.Controller.Entities return null; } +#pragma warning restore CS0618 /// <summary> /// Adds a studio to the item. @@ -2415,7 +2483,13 @@ namespace MediaBrowser.Controller.Entities return path; } - public virtual void FillUserDataDtoValues(UserItemDataDto dto, UserItemData userData, BaseItemDto itemDto, User user, DtoOptions fields) + public virtual void FillUserDataDtoValues( + UserItemDataDto dto, + UserItemData userData, + BaseItemDto itemDto, + User user, + DtoOptions fields, + (int Played, int Total)? precomputedCounts = null) { if (RunTimeTicks.HasValue) { @@ -2654,10 +2728,11 @@ namespace MediaBrowser.Controller.Entities /// <returns>An enumerable containing the items.</returns> public IEnumerable<BaseItem> GetExtras() { - return ExtraIds - .Select(LibraryManager.GetItemById) - .Where(i => i is not null) - .OrderBy(i => i.SortName); + return LibraryManager.GetItemList(new InternalItemsQuery() + { + OwnerIds = [Id], + OrderBy = [(ItemSortBy.SortName, SortOrder.Ascending)] + }); } /// <summary> @@ -2667,11 +2742,12 @@ namespace MediaBrowser.Controller.Entities /// <returns>An enumerable containing the extras.</returns> public IEnumerable<BaseItem> GetExtras(IReadOnlyCollection<ExtraType> extraTypes) { - return ExtraIds - .Select(LibraryManager.GetItemById) - .Where(i => i is not null) - .Where(i => i.ExtraType.HasValue && extraTypes.Contains(i.ExtraType.Value)) - .OrderBy(i => i.SortName); + return LibraryManager.GetItemList(new InternalItemsQuery() + { + OwnerIds = [Id], + ExtraTypes = extraTypes.ToArray(), + OrderBy = [(ItemSortBy.SortName, SortOrder.Ascending)] + }); } public virtual long GetRunTimeTicksForPlayState() diff --git a/MediaBrowser.Controller/Entities/CollectionFolder.cs b/MediaBrowser.Controller/Entities/CollectionFolder.cs index ca79e62454..ffdc8421da 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> @@ -74,14 +79,27 @@ namespace MediaBrowser.Controller.Entities public CollectionType? CollectionType { get; set; } /// <summary> - /// Gets the item's children. + /// Gets or sets the item's children. /// </summary> /// <remarks> /// Our children are actually just references to the ones in the physical root... + /// Setting to null propagates invalidation to physical folders since the getter + /// always delegates to <see cref="GetActualChildren"/> and never reads the backing field. /// </remarks> /// <value>The actual children.</value> [JsonIgnore] - public override IEnumerable<BaseItem> Children => GetActualChildren(); + public override IEnumerable<BaseItem> Children + { + get => GetActualChildren(); + set + { + // The getter delegates to physical folders, so invalidate their caches. + foreach (var folder in GetPhysicalFolders(true)) + { + folder.Children = null; + } + } + } [JsonIgnore] public override bool SupportsPeople => false; @@ -168,6 +186,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 2ecb6cbdff..5fa1213db3 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] @@ -416,6 +420,17 @@ namespace MediaBrowser.Controller.Entities // Create a list for our validated children var newItems = new List<BaseItem>(); + var actuallyRemoved = new List<BaseItem>(); + + // Build a reverse path→item lookup for detecting type changes + var currentChildrenByPath = new Dictionary<string, BaseItem>(StringComparer.OrdinalIgnoreCase); + foreach (var kvp in currentChildren) + { + if (!string.IsNullOrEmpty(kvp.Value.Path)) + { + currentChildrenByPath.TryAdd(kvp.Value.Path, kvp.Value); + } + } cancellationToken.ThrowIfCancellationRequested(); @@ -443,6 +458,24 @@ namespace MediaBrowser.Controller.Entities continue; } + // Check if an existing item occupies the same path with different type/ID + if (!string.IsNullOrEmpty(child.Path) + && currentChildrenByPath.TryGetValue(child.Path, out var staleItem) + && !staleItem.Id.Equals(child.Id)) + { + Logger.LogInformation( + "Item type changed at {Path}: {OldType} -> {NewType}, removing stale entry", + child.Path, + staleItem.GetType().Name, + child.GetType().Name); + + currentChildren.Remove(staleItem.Id); + currentChildrenByPath.Remove(child.Path); + staleItem.SetParent(null); + LibraryManager.DeleteItem(staleItem, new DeleteOptions { DeleteFileLocation = false }, this, false); + actuallyRemoved.Add(staleItem); + } + // Brand new item - needs to be added child.SetParent(this); newItems.Add(child); @@ -452,8 +485,18 @@ namespace MediaBrowser.Controller.Entities // That's all the new and changed ones - now see if any have been removed and need cleanup var itemsRemoved = currentChildren.Values.Except(validChildren).ToList(); var shouldRemove = !IsRoot || allowRemoveRoot; - var actuallyRemoved = new List<BaseItem>(); // If it's an AggregateFolder, don't remove + // Collect replaced primaries for deferred deletion (after CreateItems) + var replacedPrimaries = new List<(Video OldPrimary, Video NewPrimary)>(); + + // 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); + if (shouldRemove && itemsRemoved.Count > 0) { foreach (var item in itemsRemoved) @@ -464,6 +507,40 @@ namespace MediaBrowser.Controller.Entities continue; } + // Skip items that are alternate versions of another video + if (item is Video video) + { + // 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; + } + } + + // Defer deletion if this primary video is being replaced by a new primary + // that takes over its alternates. Deleting now would trigger premature + // promotion inside DeleteItem and write stale paths to collection NFOs. + if (item is Video primaryVideo + && !primaryVideo.PrimaryVersionId.HasValue + && primaryVideo.OwnerId.IsEmpty() + && (primaryVideo.LocalAlternateVersions ?? []).Any(p => alternateVersionPaths.Contains(p))) + { + var newPrimary = newItems + .OfType<Video>() + .FirstOrDefault(v => (v.LocalAlternateVersions ?? []) + .Any(p => (primaryVideo.LocalAlternateVersions ?? []) + .Any(op => string.Equals(op, p, StringComparison.OrdinalIgnoreCase)))); + if (newPrimary is not null) + { + Logger.LogDebug("Deferring deletion of replaced primary: {Path}", item.Path); + replacedPrimaries.Add((primaryVideo, newPrimary)); + actuallyRemoved.Add(item); + item.SetParent(null); + continue; + } + } + if (item.IsFileProtocol) { Logger.LogDebug("Removed item: {Path}", item.Path); @@ -480,6 +557,106 @@ namespace MediaBrowser.Controller.Entities LibraryManager.CreateItems(newItems, this, cancellationToken); } + // Process deferred replaced-primary deletions now that new primaries exist in DB/cache. + // This avoids the premature promotion that would occur if DeleteItem ran before CreateItems. + foreach (var (oldPrimary, newPrimary) in replacedPrimaries) + { + Logger.LogInformation( + "Processing deferred deletion of replaced primary {OldName} ({OldId}), new primary {NewName} ({NewId})", + oldPrimary.Name, + oldPrimary.Id, + newPrimary.Name, + newPrimary.Id); + + // Reroute collection/playlist references from old primary to new primary + await LibraryManager.RerouteLinkedChildReferencesAsync(oldPrimary.Id, newPrimary.Id).ConfigureAwait(false); + + // Transfer alternates from old primary to new primary + var localAlternateIds = LibraryManager.GetLocalAlternateVersionIds(oldPrimary).ToHashSet(); + var allAlternateIds = localAlternateIds + .Concat(LibraryManager.GetLinkedAlternateVersions(oldPrimary).Select(v => v.Id)) + .Distinct() + .ToList(); + + foreach (var altId in allAlternateIds) + { + if (LibraryManager.GetItemById(altId) is Video altVideo && !altVideo.Id.Equals(newPrimary.Id)) + { + altVideo.SetPrimaryVersionId(newPrimary.Id); + altVideo.OwnerId = localAlternateIds.Contains(altVideo.Id) ? newPrimary.Id : Guid.Empty; + await altVideo.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken).ConfigureAwait(false); + } + } + + // Clear alternate arrays so DeleteItem won't trigger promotion + oldPrimary.LocalAlternateVersions = []; + oldPrimary.LinkedAlternateVersions = []; + + // Safe to delete now — no promotion will happen + LibraryManager.DeleteItem(oldPrimary, new DeleteOptions { DeleteFileLocation = false }, this, false); + } + + // Demote old primaries that are now alternate versions of newly created primaries. + // This handles the case where a new file is added that becomes the new primary + // (e.g. movie-2 added, movie-3 was primary → movie-3 needs demotion). + // Items in replacedPrimaries are excluded (already in actuallyRemoved). + var oldPrimariesToDemote = new List<(Video OldPrimary, Video NewPrimary)>(); + foreach (var item in itemsRemoved.Except(actuallyRemoved)) + { + if (item is Video video + && video.OwnerId.IsEmpty() + && !string.IsNullOrEmpty(item.Path) + && alternateVersionPaths.Contains(item.Path)) + { + var newPrimary = newItems + .OfType<Video>() + .FirstOrDefault(v => (v.LocalAlternateVersions ?? []) + .Any(p => string.Equals(p, item.Path, StringComparison.OrdinalIgnoreCase))); + if (newPrimary is not null) + { + oldPrimariesToDemote.Add((video, newPrimary)); + } + } + } + + foreach (var (oldPrimary, newPrimary) in oldPrimariesToDemote) + { + Logger.LogInformation( + "Demoting old primary {OldName} ({OldId}) to alternate of new primary {NewName} ({NewId})", + oldPrimary.Name, + oldPrimary.Id, + newPrimary.Name, + newPrimary.Id); + + // First: update old primary's alternate items to point to new primary. + // Order matters — update alternates FIRST so they don't get orphan-deleted + // when old primary's arrays are cleared. + var oldAlternateIds = LibraryManager.GetLocalAlternateVersionIds(oldPrimary) + .Concat(LibraryManager.GetLinkedAlternateVersions(oldPrimary).Select(v => v.Id)) + .Distinct() + .ToList(); + + foreach (var altId in oldAlternateIds) + { + if (LibraryManager.GetItemById(altId) is Video altVideo && !altVideo.Id.Equals(newPrimary.Id)) + { + altVideo.SetPrimaryVersionId(newPrimary.Id); + altVideo.OwnerId = newPrimary.Id; + await altVideo.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken).ConfigureAwait(false); + } + } + + // Then: demote old primary — clear its arrays and set it as alternate of new primary + oldPrimary.LocalAlternateVersions = []; + oldPrimary.LinkedAlternateVersions = []; + oldPrimary.SetPrimaryVersionId(newPrimary.Id); + oldPrimary.OwnerId = newPrimary.Id; + await oldPrimary.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken).ConfigureAwait(false); + + // Re-route playlist/collection references from old primary to new primary + await LibraryManager.RerouteLinkedChildReferencesAsync(oldPrimary.Id, newPrimary.Id).ConfigureAwait(false); + } + // After removing items, reattach any detached user data to remaining children // that share the same user data keys (eg. same episode replaced with a new file). if (actuallyRemoved.Count > 0) @@ -716,36 +893,10 @@ namespace MediaBrowser.Controller.Entities public QueryResult<BaseItem> QueryRecursive(InternalItemsQuery query) { - var user = query.User; - - if (!query.ForceDirect && RequiresPostFiltering(query)) + if (!query.ForceDirect && CollapseBoxSetItems(query, this, query.User, ConfigurationManager)) { - IEnumerable<BaseItem> items; - Func<BaseItem, bool> filter = i => UserViewBuilder.Filter(i, user, query, UserDataManager, LibraryManager); - - var totalCount = 0; - if (query.User is null) - { - items = GetRecursiveChildren(filter); - totalCount = items.Count(); - } - else - { - // Save pagination params before clearing them to prevent pagination from happening - // before sorting. PostFilterAndSort will apply pagination after sorting. - var limit = query.Limit; - var startIndex = query.StartIndex; - query.Limit = null; - query.StartIndex = null; - - items = GetRecursiveChildren(user, query, out totalCount); - - // Restore pagination params so PostFilterAndSort can apply them after sorting - query.Limit = limit; - query.StartIndex = startIndex; - } - - return PostFilterAndSort(items, query); + query.CollapseBoxSetItems = true; + SetCollapseBoxSetItemTypes(query); } if (this is not UserRootFolder @@ -755,15 +906,15 @@ namespace MediaBrowser.Controller.Entities query.Parent = this; } - if (RequiresPostFiltering2(query)) + if (query.IncludeItemTypes.Length == 1 && query.IncludeItemTypes[0] == BaseItemKind.BoxSet) { - return QueryWithPostFiltering2(query); + return QueryWithPostFiltering(query); } return LibraryManager.GetItemsResult(query); } - protected QueryResult<BaseItem> QueryWithPostFiltering2(InternalItemsQuery query) + protected QueryResult<BaseItem> QueryWithPostFiltering(InternalItemsQuery query) { var startIndex = query.StartIndex; var limit = query.Limit; @@ -809,120 +960,6 @@ namespace MediaBrowser.Controller.Entities returnItems.ToArray()); } - private bool RequiresPostFiltering2(InternalItemsQuery query) - { - if (query.IncludeItemTypes.Length == 1 && query.IncludeItemTypes[0] == BaseItemKind.BoxSet) - { - Logger.LogDebug("Query requires post-filtering due to BoxSet query"); - return true; - } - - return false; - } - - 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; - } - private static BaseItem[] SortItemsByRequest(InternalItemsQuery query, IReadOnlyList<BaseItem> items) { return items.OrderBy(i => Array.IndexOf(query.ItemIds, i.Id)).ToArray(); @@ -990,14 +1027,12 @@ namespace MediaBrowser.Controller.Entities var user = query.User; - Func<BaseItem, bool> filter = i => UserViewBuilder.Filter(i, user, query, UserDataManager, LibraryManager); - IEnumerable<BaseItem> items; int totalItemCount = 0; if (query.User is null) { - items = Children.Where(filter); + items = UserViewBuilder.Filter(Children, user, query, UserDataManager, LibraryManager); totalItemCount = items.Count(); } else @@ -1012,7 +1047,12 @@ namespace MediaBrowser.Controller.Entities NameLessThan = query.NameLessThan }; - items = GetChildren(user, true, out totalItemCount, childQuery).Where(filter); + items = UserViewBuilder.Filter( + GetChildren(user, true, out totalItemCount, childQuery), + user, + query, + UserDataManager, + LibraryManager); } return PostFilterAndSort(items, query); @@ -1026,40 +1066,42 @@ namespace MediaBrowser.Controller.Entities if (user is not null) { 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); + // After collapse, BoxSets may have replaced items whose names matched the filter + // but the BoxSet's own name may not match. Re-apply name filtering so BoxSets + // appear under the correct letter (e.g. "Jump Street" under J, not under #). + items = ApplyNameFilter(items, query); } - if (!string.IsNullOrEmpty(query.NameStartsWith)) + var filteredItems = items as IReadOnlyList<BaseItem> ?? items.ToList(); + var result = UserViewBuilder.SortAndPage(filteredItems, null, query, LibraryManager); + + if (query.EnableTotalRecordCount) { - items = items.Where(i => i.SortName.StartsWith(query.NameStartsWith, StringComparison.InvariantCultureIgnoreCase)); + result.TotalRecordCount = filteredItems.Count; } - if (!string.IsNullOrEmpty(query.NameLessThan)) + return result; + } + + private static IEnumerable<BaseItem> ApplyNameFilter(IEnumerable<BaseItem> items, InternalItemsQuery query) + { + if (!string.IsNullOrWhiteSpace(query.NameStartsWith)) { - items = items.Where(i => string.Compare(query.NameLessThan, i.SortName, StringComparison.InvariantCultureIgnoreCase) == 1); + items = items.Where(i => i.SortName.StartsWith(query.NameStartsWith, StringComparison.OrdinalIgnoreCase)); } -#pragma warning restore CA1309 - // This must be the last filter - if (!query.AdjacentTo.IsNullOrEmpty()) + if (!string.IsNullOrWhiteSpace(query.NameStartsWithOrGreater)) { - items = UserViewBuilder.FilterForAdjacency(items.ToList(), query.AdjacentTo.Value); + items = items.Where(i => string.Compare(i.SortName, query.NameStartsWithOrGreater, StringComparison.OrdinalIgnoreCase) >= 0); } - var filteredItems = items as IReadOnlyList<BaseItem> ?? items.ToList(); - var result = UserViewBuilder.SortAndPage(filteredItems, null, query, LibraryManager); - - if (query.EnableTotalRecordCount) + if (!string.IsNullOrWhiteSpace(query.NameLessThan)) { - result.TotalRecordCount = filteredItems.Count; + items = items.Where(i => string.Compare(i.SortName, query.NameLessThan, StringComparison.OrdinalIgnoreCase) < 0); } - return result; + return items; } private static IEnumerable<BaseItem> CollapseBoxSetItemsIfNeeded( @@ -1167,6 +1209,33 @@ namespace MediaBrowser.Controller.Entities return (queryHasMovies || queryHasSeries) && AllowBoxSetCollapsing(query); } + private void SetCollapseBoxSetItemTypes(InternalItemsQuery query) + { + var config = ConfigurationManager.Configuration; + bool collapseMovies = config.EnableGroupingMoviesIntoCollections; + bool collapseSeries = config.EnableGroupingShowsIntoCollections; + + if (collapseMovies && collapseSeries) + { + // Empty means collapse all types + query.CollapseBoxSetItemTypes = []; + return; + } + + var types = new List<BaseItemKind>(); + if (collapseMovies) + { + types.Add(BaseItemKind.Movie); + } + + if (collapseSeries) + { + types.Add(BaseItemKind.Series); + } + + query.CollapseBoxSetItemTypes = types.ToArray(); + } + private static bool AllowBoxSetCollapsing(InternalItemsQuery request) { if (request.IsFavorite.HasValue) @@ -1418,8 +1487,7 @@ namespace MediaBrowser.Controller.Entities .Where(e => e.IsVisible(user)) .ToArray(); - var realChildren = visibleChildren - .Where(e => query is null || UserViewBuilder.FilterItem(e, query)) + var realChildren = UserViewBuilder.Filter(visibleChildren, query.User, query, UserDataManager, LibraryManager) .ToArray(); var childCount = realChildren.Length; @@ -1525,17 +1593,11 @@ namespace MediaBrowser.Controller.Entities /// <returns>IEnumerable{BaseItem}.</returns> public List<BaseItem> GetLinkedChildren() { - var linkedChildren = LinkedChildren; - var list = new List<BaseItem>(linkedChildren.Length); - - foreach (var i in linkedChildren) + var resolved = ResolveLinkedChildren(LinkedChildren); + var list = new List<BaseItem>(resolved.Count); + foreach (var (_, item) in resolved) { - var child = GetLinkedChild(i); - - if (child is not null) - { - list.Add(child); - } + list.Add(item); } return list; @@ -1636,12 +1698,74 @@ namespace MediaBrowser.Controller.Entities /// <returns>IEnumerable{BaseItem}.</returns> public IReadOnlyList<Tuple<LinkedChild, BaseItem>> GetLinkedChildrenInfos() { - return LinkedChildren - .Select(i => new Tuple<LinkedChild, BaseItem>(i, GetLinkedChild(i))) - .Where(i => i.Item2 is not null) + return ResolveLinkedChildren(LinkedChildren) + .Select(t => new Tuple<LinkedChild, BaseItem>(t.Info, t.Item)) .ToArray(); } + /// <summary> + /// Resolves a list of <see cref="LinkedChild"/> entries to their <see cref="BaseItem"/> targets, + /// batching the database lookup across all entries with a known ItemId. + /// Entries without a usable ItemId fall back to the per-entry <see cref="BaseItem.GetLinkedChild"/> + /// path (legacy path-based resolution). + /// </summary> + /// <param name="linkedChildren">Linked children to resolve.</param> + /// <returns>Each input entry paired with its resolved item; entries that fail to resolve are dropped.</returns> + private List<(LinkedChild Info, BaseItem Item)> ResolveLinkedChildren(IReadOnlyList<LinkedChild> linkedChildren) + { + var resolved = new List<(LinkedChild Info, BaseItem Item)>(linkedChildren.Count); + if (linkedChildren.Count == 0) + { + return resolved; + } + + var idsToBatch = new HashSet<Guid>(); + foreach (var info in linkedChildren) + { + if (info.ItemId.HasValue && !info.ItemId.Value.IsEmpty()) + { + idsToBatch.Add(info.ItemId.Value); + } + } + + Dictionary<Guid, BaseItem> byId = null; + if (idsToBatch.Count > 0) + { + var batched = LibraryManager.GetItemList(new InternalItemsQuery + { + ItemIds = [.. idsToBatch] + }); + byId = new Dictionary<Guid, BaseItem>(batched.Count); + foreach (var item in batched) + { + byId[item.Id] = item; + } + } + + foreach (var info in linkedChildren) + { + BaseItem item = null; + if (byId is not null && info.ItemId.HasValue && byId.TryGetValue(info.ItemId.Value, out var batchedItem)) + { + item = batchedItem; + } + else + { + // ItemId is missing/empty or the batched query couldn't return the item + // (e.g. it has been removed). Fall back to per-entry resolution, which also + // handles legacy path-based linked children. + item = GetLinkedChild(info); + } + + if (item is not null) + { + resolved.Add((info, item)); + } + } + + return resolved; + } + protected override async Task<bool> RefreshedOwnedItems(MetadataRefreshOptions options, IReadOnlyList<FileSystemMetadata> fileSystemChildren, CancellationToken cancellationToken) { var changesFound = false; @@ -1680,11 +1804,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); @@ -1712,12 +1838,6 @@ namespace MediaBrowser.Controller.Entities } } - foreach (var child in LinkedChildren) - { - // Reset the cached value - child.ItemId = null; - } - return false; } @@ -1795,45 +1915,63 @@ namespace MediaBrowser.Controller.Entities return !IsPlayed(user, userItemData); } - public override void FillUserDataDtoValues(UserItemDataDto dto, UserItemData userData, BaseItemDto itemDto, User user, DtoOptions fields) + public override void FillUserDataDtoValues( + UserItemDataDto dto, + UserItemData userData, + BaseItemDto itemDto, + User user, + DtoOptions fields, + (int Played, int Total)? precomputedCounts = null) { if (!SupportsUserDataFromChildren) { return; } - if (itemDto is not null && fields.ContainsField(ItemFields.RecursiveItemCount)) + if (SupportsPlayedStatus || (itemDto is not null && fields.ContainsField(ItemFields.RecursiveItemCount))) { - itemDto.RecursiveItemCount = GetRecursiveChildCount(user); - } + int playedCount; + int totalCount; - if (SupportsPlayedStatus) - { - var unplayedQueryResult = GetItems(new InternalItemsQuery(user) + if (precomputedCounts.HasValue) + { + // Use batch-fetched counts (avoids N+1 queries) + (playedCount, totalCount) = precomputedCounts.Value; + } + else { - Recursive = true, - IsFolder = false, - IsVirtualItem = false, - EnableTotalRecordCount = true, - Limit = 0, - IsPlayed = false, - DtoOptions = new DtoOptions(false) + // Fall back to per-item query when no batch data is available + var query = new InternalItemsQuery(user); + + if (LinkedChildren.Length > 0) { - EnableImages = false + (playedCount, totalCount) = ItemCountService.GetPlayedAndTotalCountFromLinkedChildren(query, Id); } - }).TotalRecordCount; - - dto.UnplayedItemCount = unplayedQueryResult; + else + { + (playedCount, totalCount) = ItemCountService.GetPlayedAndTotalCount(query, Id); + } + } - if (itemDto?.RecursiveItemCount > 0) + if (itemDto is not null && fields.ContainsField(ItemFields.RecursiveItemCount)) { - var unplayedPercentage = ((double)unplayedQueryResult / itemDto.RecursiveItemCount.Value) * 100; - dto.PlayedPercentage = 100 - unplayedPercentage; - dto.Played = dto.PlayedPercentage.Value >= 100; + itemDto.RecursiveItemCount = totalCount; } - else + + if (SupportsPlayedStatus) { - dto.Played = (dto.UnplayedItemCount ?? 0) == 0; + 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 ecbeefbb9d..fa82ea8663 100644 --- a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs +++ b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs @@ -18,42 +18,45 @@ namespace MediaBrowser.Controller.Entities { public InternalItemsQuery() { - AlbumArtistIds = Array.Empty<Guid>(); - AlbumIds = Array.Empty<Guid>(); - AncestorIds = Array.Empty<Guid>(); - ArtistIds = Array.Empty<Guid>(); - BlockUnratedItems = Array.Empty<UnratedItem>(); - BoxSetLibraryFolders = Array.Empty<Guid>(); - ChannelIds = Array.Empty<Guid>(); - ContributingArtistIds = Array.Empty<Guid>(); + AlbumArtistIds = []; + AlbumIds = []; + AncestorIds = []; + ArtistIds = []; + BlockUnratedItems = []; + BoxSetLibraryFolders = []; + ChannelIds = []; + ContributingArtistIds = []; DtoOptions = new DtoOptions(); EnableTotalRecordCount = true; - ExcludeArtistIds = Array.Empty<Guid>(); - ExcludeInheritedTags = Array.Empty<string>(); - IncludeInheritedTags = Array.Empty<string>(); - ExcludeItemIds = Array.Empty<Guid>(); - ExcludeItemTypes = Array.Empty<BaseItemKind>(); - ExcludeTags = Array.Empty<string>(); - GenreIds = Array.Empty<Guid>(); - Genres = Array.Empty<string>(); + ExcludeArtistIds = []; + ExcludeInheritedTags = []; + IncludeInheritedTags = []; + ExcludeItemIds = []; + ExcludeItemTypes = []; + ExcludeTags = []; + GenreIds = []; + Genres = []; GroupByPresentationUniqueKey = true; - ImageTypes = Array.Empty<ImageType>(); - IncludeItemTypes = Array.Empty<BaseItemKind>(); - ItemIds = Array.Empty<Guid>(); - MediaTypes = Array.Empty<MediaType>(); - OfficialRatings = Array.Empty<string>(); - OrderBy = Array.Empty<(ItemSortBy, SortOrder)>(); - PersonIds = Array.Empty<Guid>(); - PersonTypes = Array.Empty<string>(); - PresetViews = Array.Empty<CollectionType?>(); - SeriesStatuses = Array.Empty<SeriesStatus>(); - SourceTypes = Array.Empty<SourceType>(); - StudioIds = Array.Empty<Guid>(); - Tags = Array.Empty<string>(); - TopParentIds = Array.Empty<Guid>(); - TrailerTypes = Array.Empty<TrailerType>(); - VideoTypes = Array.Empty<VideoType>(); - Years = Array.Empty<int>(); + ImageTypes = []; + IncludeItemTypes = []; + ItemIds = []; + OwnerIds = []; + ExtraTypes = []; + MediaTypes = []; + OfficialRatings = []; + OrderBy = []; + OwnerIds = []; + PersonIds = []; + PersonTypes = []; + PresetViews = []; + SeriesStatuses = []; + SourceTypes = []; + StudioIds = []; + Tags = []; + TopParentIds = []; + TrailerTypes = []; + VideoTypes = []; + Years = []; SkipDeserialization = false; } @@ -110,6 +113,12 @@ namespace MediaBrowser.Controller.Entities public bool? CollapseBoxSetItems { get; set; } + /// <summary> + /// Gets or sets the item types that should be collapsed into box sets. + /// When empty, all types are collapsed. When set, only items of these types are replaced by their parent box set. + /// </summary> + public BaseItemKind[] CollapseBoxSetItemTypes { get; set; } = []; + public string? NameStartsWithOrGreater { get; set; } public string? NameStartsWith { get; set; } @@ -134,6 +143,10 @@ namespace MediaBrowser.Controller.Entities public Guid[] ItemIds { get; set; } + public Guid[] OwnerIds { get; set; } + + public ExtraType[] ExtraTypes { get; set; } + public Guid[] ExcludeItemIds { get; set; } public Guid? AdjacentTo { get; set; } @@ -348,6 +361,12 @@ namespace MediaBrowser.Controller.Entities public bool? HasOwnerId { get; set; } + /// <summary> + /// Gets or sets a value indicating whether to include items with an OwnerId + /// (additional parts, alternate versions) that are normally excluded from general queries. + /// </summary> + public bool IncludeOwnedItems { get; set; } + public bool? Is4K { get; set; } public int? MaxHeight { get; set; } @@ -364,6 +383,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/LibraryOptionsUpdatedEventArgs.cs b/MediaBrowser.Controller/Entities/LibraryOptionsUpdatedEventArgs.cs new file mode 100644 index 0000000000..7590ad7d36 --- /dev/null +++ b/MediaBrowser.Controller/Entities/LibraryOptionsUpdatedEventArgs.cs @@ -0,0 +1,31 @@ +using System; +using MediaBrowser.Model.Configuration; + +namespace MediaBrowser.Controller.Entities; + +/// <summary> +/// Event arguments for when library options are updated. +/// </summary> +public class LibraryOptionsUpdatedEventArgs : EventArgs +{ + /// <summary> + /// Initializes a new instance of the <see cref="LibraryOptionsUpdatedEventArgs"/> class. + /// </summary> + /// <param name="libraryPath">The path of the library whose options were updated.</param> + /// <param name="libraryOptions">The updated library options.</param> + public LibraryOptionsUpdatedEventArgs(string libraryPath, LibraryOptions libraryOptions) + { + LibraryPath = libraryPath; + LibraryOptions = libraryOptions; + } + + /// <summary> + /// Gets the path of the library whose options were updated. + /// </summary> + public string LibraryPath { get; } + + /// <summary> + /// Gets the updated library options. + /// </summary> + public LibraryOptions LibraryOptions { get; } +} 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..8216937cad 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. @@ -160,25 +158,68 @@ namespace MediaBrowser.Controller.Entities.Movies return base.IsVisible(user, skipAllowedTagsCheck); } - if (base.IsVisible(user, skipAllowedTagsCheck)) + if (!IsParentalAllowed(user, skipAllowedTagsCheck)) { - if (LinkedChildren.Length == 0) - { - return true; - } + return false; + } + + if (LinkedChildren.Length == 0) + { + return true; + } + + var userLibraryFolderIds = GetLibraryFolderIds(user); + var libraryFolderIds = LibraryFolderIds ?? GetLibraryFolderIds(); + + if (libraryFolderIds.Length == 0) + { + return true; + } - var userLibraryFolderIds = GetLibraryFolderIds(user); - var libraryFolderIds = LibraryFolderIds ?? GetLibraryFolderIds(); + if (!userLibraryFolderIds.Any(i => libraryFolderIds.Contains(i))) + { + return false; + } - if (libraryFolderIds.Length == 0) + // If user has parental controls, hide the BoxSet when all children are restricted + if (user.MaxParentalRatingScore.HasValue) + { + var linkedItems = GetLinkedChildren(); + if (linkedItems.Count > 0 && linkedItems.All(child => !child.IsParentalAllowed(user, true))) { - return true; + return false; } + } + + return true; + } + + public override void MarkPlayed(User user, DateTime? datePlayed, bool resetPosition) + { + if (IsLegacyBoxSet) + { + base.MarkPlayed(user, datePlayed, resetPosition); + return; + } + + foreach (var item in GetLinkedChildren(user)) + { + item.MarkPlayed(user, datePlayed, resetPosition); + } + } - return userLibraryFolderIds.Any(i => libraryFolderIds.Contains(i)); + public override void MarkUnplayed(User user) + { + if (IsLegacyBoxSet) + { + base.MarkUnplayed(user); + return; } - return false; + foreach (var item in GetLinkedChildren(user)) + { + item.MarkUnplayed(user); + } } public override bool IsVisibleStandalone(User user) diff --git a/MediaBrowser.Controller/Entities/Movies/Movie.cs b/MediaBrowser.Controller/Entities/Movies/Movie.cs index 710b05e7f9..e8817a29cf 100644 --- a/MediaBrowser.Controller/Entities/Movies/Movie.cs +++ b/MediaBrowser.Controller/Entities/Movies/Movie.cs @@ -4,13 +4,15 @@ 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.Library; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Providers; +using Microsoft.Extensions.Logging; namespace MediaBrowser.Controller.Entities.Movies { @@ -28,9 +30,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. 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/Season.cs b/MediaBrowser.Controller/Entities/TV/Season.cs index 4360253b01..f70f7dfb4c 100644 --- a/MediaBrowser.Controller/Entities/TV/Season.cs +++ b/MediaBrowser.Controller/Entities/TV/Season.cs @@ -175,9 +175,7 @@ namespace MediaBrowser.Controller.Entities.TV var user = query.User; - Func<BaseItem, bool> filter = i => UserViewBuilder.Filter(i, user, query, UserDataManager, LibraryManager); - - var items = GetEpisodes(user, query.DtoOptions, true).Where(filter); + var items = UserViewBuilder.Filter(GetEpisodes(user, query.DtoOptions, true), user, query, UserDataManager, LibraryManager); return PostFilterAndSort(items, query); } diff --git a/MediaBrowser.Controller/Entities/TV/Series.cs b/MediaBrowser.Controller/Entities/TV/Series.cs index 6a26ecaebe..952187c6e1 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..cb05056601 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); } @@ -416,29 +414,54 @@ namespace MediaBrowser.Controller.Entities InternalItemsQuery query) where T : BaseItem { - items = items.Where(i => Filter(i, query.User, query, _userDataManager, _libraryManager)); + var filtered = Filter(items, query.User, query, _userDataManager, _libraryManager); - return PostFilterAndSort(items, null, query, _libraryManager); + return SortAndPage(filtered, null, query, _libraryManager); } - public static bool FilterItem(BaseItem item, InternalItemsQuery query) - { - return Filter(item, query.User, query, BaseItem.UserDataManager, BaseItem.LibraryManager); - } - - public static QueryResult<BaseItem> PostFilterAndSort( + /// <summary> + /// Batch-aware filter that applies per-item checks. + /// </summary> + /// <param name="items">The items to filter.</param> + /// <param name="user">The user for filtering context.</param> + /// <param name="query">The query parameters.</param> + /// <param name="userDataManager">The user data manager.</param> + /// <param name="libraryManager">The library manager.</param> + /// <returns>The filtered items.</returns> + public static IEnumerable<BaseItem> Filter( IEnumerable<BaseItem> items, - int? totalRecordLimit, + User user, InternalItemsQuery query, + IUserDataManager userDataManager, ILibraryManager libraryManager) { - // This must be the last filter - if (!query.AdjacentTo.IsNullOrEmpty()) + var filtered = items.Where(i => Filter(i, user, query, userDataManager, libraryManager)); + + if (query.IsPlayed.HasValue && user is not null) { - items = FilterForAdjacency(items.ToList(), query.AdjacentTo.Value); + var itemList = filtered.ToList(); + var folderIds = itemList.OfType<Folder>().Select(f => f.Id).ToList(); + + if (folderIds.Count > 0) + { + var counts = libraryManager.GetPlayedAndTotalCountBatch(folderIds, user); + var isPlayedValue = query.IsPlayed.Value; + + return itemList.Where(i => + { + if (i.IsFolder && counts.TryGetValue(i.Id, out var c)) + { + return (c.Total > 0 && c.Played == c.Total) == isPlayedValue; + } + + return true; + }); + } + + return itemList; } - return SortAndPage(items, totalRecordLimit, query, libraryManager); + return filtered; } public static QueryResult<BaseItem> SortAndPage( @@ -470,7 +493,12 @@ namespace MediaBrowser.Controller.Entities itemsArray); } - public static bool Filter(BaseItem item, User user, InternalItemsQuery query, IUserDataManager userDataManager, ILibraryManager libraryManager) + private static bool Filter( + BaseItem item, + User user, + InternalItemsQuery query, + IUserDataManager userDataManager, + ILibraryManager libraryManager) { if (!string.IsNullOrEmpty(query.NameStartsWith) && !item.SortName.StartsWith(query.NameStartsWith, StringComparison.InvariantCultureIgnoreCase)) { @@ -558,35 +586,17 @@ 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) + // Folder.IsPlayed() hits the DB per-item (N+1 queries). + // Folders are batch-filtered by the collection Filter() overload. + if (!item.IsFolder) { - return false; + userData ??= userDataManager.GetUserData(user, item); + if (item.IsPlayed(user, userData) != query.IsPlayed.Value) + { + return false; + } } } - */ if (query.IsLocked.HasValue) { @@ -645,68 +655,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 +682,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 +806,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 +859,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..80bcd62dcd 100644 --- a/MediaBrowser.Controller/Entities/Video.cs +++ b/MediaBrowser.Controller/Entities/Video.cs @@ -19,6 +19,7 @@ using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; using MediaBrowser.Model.MediaInfo; +using Microsoft.Extensions.Logging; namespace MediaBrowser.Controller.Entities { @@ -40,7 +41,7 @@ namespace MediaBrowser.Controller.Entities } [JsonIgnore] - public string PrimaryVersionId { get; set; } + public Guid? PrimaryVersionId { get; set; } public string[] AdditionalParts { get; set; } @@ -160,7 +161,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; } @@ -253,14 +254,17 @@ namespace MediaBrowser.Controller.Entities private int GetMediaSourceCount(HashSet<Guid> callstack = null) { callstack ??= new(); - if (!string.IsNullOrEmpty(PrimaryVersionId)) + if (PrimaryVersionId.HasValue) { - var item = LibraryManager.GetItemById(PrimaryVersionId); + var item = LibraryManager.GetItemById(PrimaryVersionId.Value); if (item is Video video) { 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 +272,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() @@ -310,25 +317,17 @@ namespace MediaBrowser.Controller.Entities return list; } - public void SetPrimaryVersionId(string id) + public void SetPrimaryVersionId(Guid? id) { - if (string.IsNullOrEmpty(id)) - { - PrimaryVersionId = null; - } - else - { - PrimaryVersionId = id; - } - + PrimaryVersionId = id; PresentationUniqueKey = CreatePresentationUniqueKey(); } public override string CreatePresentationUniqueKey() { - if (!string.IsNullOrEmpty(PrimaryVersionId)) + if (PrimaryVersionId.HasValue) { - return PrimaryVersionId; + return PrimaryVersionId.Value.ToString("N", CultureInfo.InvariantCulture); } return base.CreatePresentationUniqueKey(); @@ -364,11 +363,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 +376,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> @@ -436,10 +421,21 @@ namespace MediaBrowser.Controller.Entities { var hasChanges = await base.RefreshedOwnedItems(options, fileSystemChildren, cancellationToken).ConfigureAwait(false); + // Clean up LocalAlternateVersions - remove paths that no longer exist + if (LocalAlternateVersions.Length > 0) + { + var validPaths = LocalAlternateVersions.Where(FileSystem.FileExists).ToArray(); + if (validPaths.Length != LocalAlternateVersions.Length) + { + LocalAlternateVersions = validPaths; + hasChanges = true; + } + } + if (IsStacked) { var tasks = AdditionalParts - .Select(i => RefreshMetadataForOwnedVideo(options, true, i, cancellationToken)); + .Select(i => RefreshMetadataForOwnedVideo(options, true, i, typeof(Video), cancellationToken)); await Task.WhenAll(tasks).ConfigureAwait(false); } @@ -449,30 +445,134 @@ namespace MediaBrowser.Controller.Entities // The additional parts won't have additional parts themselves if (IsFileProtocol && SupportsOwnedItems) { - if (!IsStacked) - { - RefreshLinkedAlternateVersions(); + // Check if LinkedChildren are in sync before processing + var existingVersionCount = LibraryManager.GetLocalAlternateVersionIds(this).Count(); + var tasks = LocalAlternateVersions + .Select(i => RefreshMetadataForVersions(options, false, i, cancellationToken)); - var tasks = LocalAlternateVersions - .Select(i => RefreshMetadataForOwnedVideo(options, false, i, cancellationToken)); + await Task.WhenAll(tasks).ConfigureAwait(false); - await Task.WhenAll(tasks).ConfigureAwait(false); + if (existingVersionCount != LocalAlternateVersions.Length) + { + hasChanges = true; } } return hasChanges; } - private void RefreshLinkedAlternateVersions() + private async Task RefreshMetadataForVersions( + MetadataRefreshOptions options, + bool copyTitleMetadata, + string path, + CancellationToken cancellationToken) + { + // Ensure the alternate version exists with the correct type (e.g. Movie, not Video) + // before refreshing. This must happen here rather than in RefreshMetadataForOwnedVideo + // because that method is also used for stacked parts which should keep their resolved type. + var id = LibraryManager.GetNewItemId(path, GetType()); + if (LibraryManager.GetItemById(id) is not Video && FileSystem.FileExists(path)) + { + var parentFolder = GetParent() as Folder; + var collectionType = LibraryManager.GetContentType(this); + var altVideo = LibraryManager.ResolveAlternateVersion(path, GetType(), parentFolder, collectionType); + if (altVideo is not null) + { + altVideo.OwnerId = Id; + altVideo.SetPrimaryVersionId(Id); + LibraryManager.CreateItem(altVideo, GetParent()); + } + } + + await RefreshMetadataForOwnedVideo(options, copyTitleMetadata, path, cancellationToken).ConfigureAwait(false); + + // Create LinkedChild entry for this local alternate version + // This ensures the relationship exists in the database even if the alternate version + // was created after the primary video was first saved + if (LibraryManager.GetItemById(id) is Video video) + { + LibraryManager.UpsertLinkedChild(Id, video.Id, LinkedChildType.LocalAlternateVersion); + + // Ensure PrimaryVersionId is set for existing alternate versions that may not have it + if (!video.PrimaryVersionId.HasValue) + { + video.SetPrimaryVersionId(Id); + await video.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken).ConfigureAwait(false); + } + } + } + + private new Task RefreshMetadataForOwnedVideo( + MetadataRefreshOptions options, + bool copyTitleMetadata, + string path, + CancellationToken cancellationToken) + => RefreshMetadataForOwnedVideo(options, copyTitleMetadata, path, GetType(), cancellationToken); + + private async Task RefreshMetadataForOwnedVideo( + MetadataRefreshOptions options, + bool copyTitleMetadata, + string path, + Type itemType, + CancellationToken cancellationToken) { - foreach (var child in LinkedAlternateVersions) + var newOptions = new MetadataRefreshOptions(options) { - // Reset the cached value - if (child.ItemId.IsNullOrEmpty()) + SearchResult = null + }; + + var id = LibraryManager.GetNewItemId(path, itemType); + + // Check if the file still exists + if (!FileSystem.FileExists(path)) + { + // File was removed - clean up any orphaned database entry + if (LibraryManager.GetItemById(id) is Video orphanedVideo && orphanedVideo.OwnerId.Equals(Id)) { - child.ItemId = null; + Logger.LogInformation("Owned video file no longer exists, removing orphaned item: {Path}", path); + LibraryManager.DeleteItem(orphanedVideo, new DeleteOptions { DeleteFileLocation = false }); } + + return; } + + if (LibraryManager.GetItemById(id) is not Video video) + { + var parentFolder = GetParent() as Folder; + var collectionType = LibraryManager.GetContentType(this); + video = LibraryManager.ResolvePath( + FileSystem.GetFileSystemInfo(path), + parentFolder, + collectionType: collectionType) as Video; + + if (video is null) + { + return; + } + + // Ensure parts use the expected base type (e.g. Video, not Movie) + if (video.GetType() != itemType && Activator.CreateInstance(itemType) is Video correctVideo) + { + correctVideo.Path = video.Path; + correctVideo.Name = video.Name; + correctVideo.VideoType = video.VideoType; + correctVideo.ProductionYear = video.ProductionYear; + correctVideo.ExtraType = video.ExtraType; + video = correctVideo; + } + + video.Id = id; + video.OwnerId = Id; + LibraryManager.CreateItem(video, parentFolder); + newOptions.ForceSave = true; + } + + if (video.OwnerId.IsEmpty()) + { + video.OwnerId = Id; + } + + await RefreshMetadataForOwnedItem(video, copyTitleMetadata, newOptions, cancellationToken).ConfigureAwait(false); } /// <inheritdoc /> @@ -480,7 +580,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,22 +637,24 @@ 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)) + if (PrimaryVersionId.HasValue) { - if (LibraryManager.GetItemById(PrimaryVersionId) is Video primary) + if (LibraryManager.GetItemById(PrimaryVersionId.Value) is Video primary) { 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..f5e3d7034e 100644 --- a/MediaBrowser.Controller/Library/ILibraryManager.cs +++ b/MediaBrowser.Controller/Library/ILibraryManager.cs @@ -20,6 +20,7 @@ using MediaBrowser.Model.IO; using MediaBrowser.Model.Querying; using Episode = MediaBrowser.Controller.Entities.TV.Episode; using Genre = MediaBrowser.Controller.Entities.Genre; +using LinkedChildType = MediaBrowser.Controller.Entities.LinkedChildType; using Person = MediaBrowser.Controller.Entities.Person; namespace MediaBrowser.Controller.Library @@ -58,11 +59,29 @@ namespace MediaBrowser.Controller.Library /// <param name="fileInfo">The file information.</param> /// <param name="parent">The parent.</param> /// <param name="directoryService">An instance of <see cref="IDirectoryService"/>.</param> + /// <param name="collectionType">The collection type of the library containing this item.</param> /// <returns>BaseItem.</returns> BaseItem? ResolvePath( FileSystemMetadata fileInfo, Folder? parent = null, - IDirectoryService? directoryService = null); + IDirectoryService? directoryService = null, + CollectionType? collectionType = null); + + /// <summary> + /// Resolves a video file as an alternate version of a primary video, ensuring the result + /// has the same concrete type as the primary (e.g. Movie instead of generic Video). + /// Also cleans up any existing item with the wrong type from a previous scan. + /// </summary> + /// <param name="path">The file path of the alternate version.</param> + /// <param name="expectedVideoType">The expected concrete type (same as the primary video).</param> + /// <param name="parent">The parent folder.</param> + /// <param name="collectionType">The collection type of the library.</param> + /// <returns>A correctly-typed Video, or null if resolution fails.</returns> + Video? ResolveAlternateVersion( + string path, + Type expectedVideoType, + Folder? parent, + CollectionType? collectionType); /// <summary> /// Resolves a set of files into a list of BaseItem. @@ -158,6 +177,13 @@ namespace MediaBrowser.Controller.Library /// <returns>Task.</returns> Task ValidateTopLibraryFolders(CancellationToken cancellationToken, bool removeRoot = false); + /// <summary> + /// Clears the cached ignore rule directory lookups. + /// Call this before triggering a library scan or item refresh to ensure + /// any changes to .ignore files are picked up. + /// </summary> + void ClearIgnoreRuleCache(); + Task UpdateImagesAsync(BaseItem item, bool forceUpdate = false); /// <summary> @@ -214,6 +240,30 @@ 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> + /// Creates or updates a LinkedChild entry linking a parent to a child item. + /// </summary> + /// <param name="parentId">The parent item ID.</param> + /// <param name="childId">The child item ID.</param> + /// <param name="childType">The type of linked child relationship.</param> + void UpsertLinkedChild(Guid parentId, Guid childId, LinkedChildType childType); + + /// <summary> /// Adds the parts. /// </summary> /// <param name="rules">The rules.</param> @@ -348,8 +398,9 @@ namespace MediaBrowser.Controller.Library /// Deletes items that are not having any children like Actors. /// </summary> /// <param name="items">Items to delete.</param> + /// <param name="deleteSourceFiles">Whether to delete source media files on disk. Defaults to false.</param> /// <remarks>In comparison to <see cref="DeleteItem(BaseItem, DeleteOptions, BaseItem, bool)"/> this method skips a lot of steps assuming there are no children to recusively delete nor does it define the special handling for channels and alike.</remarks> - public void DeleteItemsUnsafeFast(IEnumerable<BaseItem> items); + public void DeleteItemsUnsafeFast(IReadOnlyCollection<BaseItem> items, bool deleteSourceFiles = false); /// <summary> /// Deletes the item. @@ -514,7 +565,7 @@ namespace MediaBrowser.Controller.Library /// </summary> /// <param name="query">The query.</param> /// <returns>List<Person>.</returns> - IReadOnlyList<Person> GetPeopleItems(InternalPeopleQuery query); + QueryResult<BaseItem> GetPeopleItems(InternalPeopleQuery query); /// <summary> /// Updates the people. @@ -601,6 +652,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 +714,42 @@ namespace MediaBrowser.Controller.Library ItemCounts GetItemCounts(InternalItemsQuery query); + /// <summary> + /// Gets item counts for a "by-name" item using an optimized query path. + /// </summary> + /// <param name="kind">The kind of the name item.</param> + /// <param name="id">The ID of the name item.</param> + /// <param name="relatedItemKinds">The item kinds to count.</param> + /// <param name="user">The user for access filtering.</param> + /// <returns>The item counts grouped by type.</returns> + ItemCounts GetItemCountsForNameItem(BaseItemKind kind, Guid id, BaseItemKind[] relatedItemKinds, User? user); + + /// <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> + /// Batch-fetches played and total counts for multiple folder items. + /// Avoids N+1 queries when building DTOs for lists of folder items. + /// </summary> + /// <param name="folderIds">The list of folder item IDs.</param> + /// <param name="user">The user for access filtering and played status.</param> + /// <returns>Dictionary mapping folder ID to (Played count, Total count).</returns> + Dictionary<Guid, (int Played, int Total)> GetPlayedAndTotalCountBatch(IReadOnlyList<Guid> folderIds, User user); + + /// <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); @@ -667,5 +768,21 @@ namespace MediaBrowser.Controller.Library /// <param name="virtualFolderPath">The path to the virtualfolder.</param> /// <param name="pathInfo">The new virtualfolder.</param> public void CreateShortcut(string virtualFolderPath, MediaPathInfo pathInfo); + + /// <summary> + /// Re-routes LinkedChildren references from one child to another. + /// Used when video versions change to maintain playlist/BoxSet integrity. + /// </summary> + /// <param name="fromChildId">The child ID to re-route from.</param> + /// <param name="toChildId">The child ID to re-route to.</param> + /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns> + Task RerouteLinkedChildReferencesAsync(Guid fromChildId, Guid toChildId); + + /// <summary> + /// Gets legacy query filters for filtering UI. + /// </summary> + /// <param name="query">The query filter.</param> + /// <returns>Aggregated filter values.</returns> + QueryFiltersLegacy GetQueryFiltersLegacy(InternalItemsQuery query); } } diff --git a/MediaBrowser.Controller/Library/IUserDataManager.cs b/MediaBrowser.Controller/Library/IUserDataManager.cs index eb46611dd9..798812bf1f 100644 --- a/MediaBrowser.Controller/Library/IUserDataManager.cs +++ b/MediaBrowser.Controller/Library/IUserDataManager.cs @@ -55,6 +55,14 @@ namespace MediaBrowser.Controller.Library UserItemDataDto? GetUserDataDto(BaseItem item, User user); /// <summary> + /// Gets user data for multiple items in a single batch operation. + /// </summary> + /// <param name="items">The items to get user data for.</param> + /// <param name="user">The user.</param> + /// <returns>A dictionary mapping item IDs to their user data.</returns> + Dictionary<Guid, UserItemData> GetUserDataBatch(IReadOnlyList<BaseItem> items, User user); + + /// <summary> /// Gets the user data dto. /// </summary> /// <param name="item">Item to use.</param> diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs index 117f376724..a0e04eae63 100644 --- a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs +++ b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs @@ -1621,13 +1621,25 @@ namespace MediaBrowser.Controller.MediaEncoding mbbrcOpt = " -mbbrc 1"; } + // Some less powerful H.264 HW decoders require strict CPB size + // So bufsize optimizations should not be applied to them + int factor = 2; + var codec = state.ActualOutputVideoCodec; + var level = state.GetRequestedLevel(codec); + if (string.Equals(codec, "h264", StringComparison.OrdinalIgnoreCase) + && double.TryParse(level, CultureInfo.InvariantCulture, out double requestedLevel) + && requestedLevel < 51) + { + factor = 1; + } + // Set (maxrate == bitrate + 1) to trigger VBR for better bitrate allocation // Set (rc_init_occupancy == 2 * bitrate) and (bufsize == 4 * bitrate) to deal with drastic scene changes // Use long arithmetic and clamp to int.MaxValue to prevent int32 overflow // (e.g. bitrate * 4 wraps to a negative value for bitrates above ~537 million) int qsvMaxrate = (int)Math.Min((long)bitrate + 1, int.MaxValue); - int qsvInitOcc = (int)Math.Min((long)bitrate * 2, int.MaxValue); - int qsvBufsize = (int)Math.Min((long)bitrate * 4, int.MaxValue); + int qsvInitOcc = (int)Math.Min((long)bitrate * 1 * factor, int.MaxValue); + int qsvBufsize = (int)Math.Min((long)bitrate * 2 * factor, int.MaxValue); return FormattableString.Invariant($"{mbbrcOpt} -b:v {bitrate} -maxrate {qsvMaxrate} -rc_init_occupancy {qsvInitOcc} -bufsize {qsvBufsize}"); } diff --git a/MediaBrowser.Controller/Persistence/IItemCountService.cs b/MediaBrowser.Controller/Persistence/IItemCountService.cs new file mode 100644 index 0000000000..d57f1fc893 --- /dev/null +++ b/MediaBrowser.Controller/Persistence/IItemCountService.cs @@ -0,0 +1,86 @@ +using System; +using System.Collections.Generic; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations.Entities; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Model.Dto; + +namespace MediaBrowser.Controller.Persistence; + +/// <summary> +/// Provides item counting and played-status query operations. +/// </summary> +public interface IItemCountService +{ + /// <summary> + /// Gets the count of items matching the filter. + /// </summary> + /// <param name="filter">The query filter.</param> + /// <returns>The item count.</returns> + int GetCount(InternalItemsQuery filter); + + /// <summary> + /// Gets item counts grouped by type. + /// </summary> + /// <param name="filter">The query filter.</param> + /// <returns>The item counts by type.</returns> + ItemCounts GetItemCounts(InternalItemsQuery filter); + + /// <summary> + /// Gets item counts for a "by-name" item using an optimized query. + /// </summary> + /// <param name="kind">The kind of the name item.</param> + /// <param name="id">The ID of the name item.</param> + /// <param name="relatedItemKinds">The item kinds to count.</param> + /// <param name="accessFilter">A pre-configured query with user access filtering settings.</param> + /// <returns>The item counts grouped by type.</returns> + ItemCounts GetItemCountsForNameItem(BaseItemKind kind, Guid id, BaseItemKind[] relatedItemKinds, InternalItemsQuery accessFilter); + + /// <summary> + /// Gets the count of played items that are descendants of the specified ancestor. + /// </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. + /// </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 descendant items. + /// </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 from linked children. + /// </summary> + /// <param name="filter">The query filter containing user access settings.</param> + /// <param name="parentId">The parent item id.</param> + /// <returns>A tuple containing (Played count, Total count).</returns> + (int Played, int Total) GetPlayedAndTotalCountFromLinkedChildren(InternalItemsQuery filter, Guid parentId); + + /// <summary> + /// Batch-fetches played and total counts for multiple folder items. + /// </summary> + /// <param name="folderIds">The list of folder item IDs to get counts for.</param> + /// <param name="user">The user for access filtering and played status.</param> + /// <returns>Dictionary mapping folder ID to (Played count, Total count).</returns> + Dictionary<Guid, (int Played, int Total)> GetPlayedAndTotalCountBatch(IReadOnlyList<Guid> folderIds, User user); + + /// <summary> + /// Batch-fetches child counts for multiple parent folders. + /// </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/IItemPersistenceService.cs b/MediaBrowser.Controller/Persistence/IItemPersistenceService.cs new file mode 100644 index 0000000000..37f7194e7a --- /dev/null +++ b/MediaBrowser.Controller/Persistence/IItemPersistenceService.cs @@ -0,0 +1,47 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Entities; + +namespace MediaBrowser.Controller.Persistence; + +/// <summary> +/// Provides item persistence operations (save, delete, update). +/// </summary> +public interface IItemPersistenceService +{ + /// <summary> + /// Deletes items by their IDs. + /// </summary> + /// <param name="ids">The IDs to delete.</param> + void DeleteItem(params IReadOnlyList<Guid> ids); + + /// <summary> + /// Saves items to the database. + /// </summary> + /// <param name="items">The items to save.</param> + /// <param name="cancellationToken">The cancellation token.</param> + void SaveItems(IReadOnlyList<BaseItem> items, CancellationToken cancellationToken); + + /// <summary> + /// Saves image info for an item. + /// </summary> + /// <param name="item">The item.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>A task representing the asynchronous operation.</returns> + Task SaveImagesAsync(BaseItem item, CancellationToken cancellationToken = default); + + /// <summary> + /// Reattaches user data entries to the correct item. + /// </summary> + /// <param name="item">The item.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>A task representing the asynchronous operation.</returns> + Task ReattachUserDataAsync(BaseItem item, CancellationToken cancellationToken); + + /// <summary> + /// Updates inherited values. + /// </summary> + void UpdateInheritedValues(); +} diff --git a/MediaBrowser.Controller/Persistence/IItemQueryHelpers.cs b/MediaBrowser.Controller/Persistence/IItemQueryHelpers.cs new file mode 100644 index 0000000000..2e29cbdbba --- /dev/null +++ b/MediaBrowser.Controller/Persistence/IItemQueryHelpers.cs @@ -0,0 +1,107 @@ +using System; +using System.Linq; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using MediaBrowser.Controller.Entities; + +namespace MediaBrowser.Controller.Persistence; + +/// <summary> +/// Provides shared query-building methods used by extracted item services. +/// Implemented by <c>BaseItemRepository</c>. +/// </summary> +public interface IItemQueryHelpers +{ + /// <summary> + /// Translates an <see cref="InternalItemsQuery"/> into EF Core filter expressions. + /// </summary> + /// <param name="baseQuery">The base queryable to filter.</param> + /// <param name="context">The database context.</param> + /// <param name="filter">The query filter.</param> + /// <returns>The filtered queryable.</returns> + IQueryable<BaseItemEntity> TranslateQuery( + IQueryable<BaseItemEntity> baseQuery, + JellyfinDbContext context, + InternalItemsQuery filter); + + /// <summary> + /// Prepares a base query for items from the context. + /// </summary> + /// <param name="context">The database context.</param> + /// <param name="filter">The query filter.</param> + /// <returns>The prepared queryable.</returns> + IQueryable<BaseItemEntity> PrepareItemQuery(JellyfinDbContext context, InternalItemsQuery filter); + + /// <summary> + /// Applies user access filtering (library access, parental controls, tags) to a query. + /// </summary> + /// <param name="context">The database context.</param> + /// <param name="baseQuery">The base queryable to filter.</param> + /// <param name="filter">The query filter containing access settings.</param> + /// <returns>The access-filtered queryable.</returns> + IQueryable<BaseItemEntity> ApplyAccessFiltering( + JellyfinDbContext context, + IQueryable<BaseItemEntity> baseQuery, + InternalItemsQuery filter); + + /// <summary> + /// Applies navigation property includes to a query based on filter options. + /// </summary> + /// <param name="dbQuery">The queryable to apply navigations to.</param> + /// <param name="filter">The query filter.</param> + /// <returns>The queryable with navigation includes.</returns> + IQueryable<BaseItemEntity> ApplyNavigations( + IQueryable<BaseItemEntity> dbQuery, + InternalItemsQuery filter); + + /// <summary> + /// Applies ordering to a query based on filter options. + /// </summary> + /// <param name="query">The queryable to order.</param> + /// <param name="filter">The query filter.</param> + /// <param name="context">The database context.</param> + /// <returns>The ordered queryable.</returns> + IQueryable<BaseItemEntity> ApplyOrder( + IQueryable<BaseItemEntity> query, + InternalItemsQuery filter, + JellyfinDbContext context); + + /// <summary> + /// Builds a query for descendants of an ancestor with user access filtering applied. + /// </summary> + /// <param name="context">The database context.</param> + /// <param name="filter">The query filter.</param> + /// <param name="ancestorId">The ancestor item ID.</param> + /// <returns>The filtered descendant queryable.</returns> + IQueryable<BaseItemEntity> BuildAccessFilteredDescendantsQuery( + JellyfinDbContext context, + InternalItemsQuery filter, + Guid ancestorId); + + /// <summary> + /// Builds an <see cref="IQueryable{Guid}"/> of folder IDs whose descendants are all played + /// for the given user. Composable into outer queries to avoid an extra DB roundtrip. + /// </summary> + /// <param name="context">The database context the resulting query is bound to.</param> + /// <param name="folderIds">A query yielding candidate folder IDs.</param> + /// <param name="user">The user for access filtering and played status.</param> + /// <returns>An <see cref="IQueryable{Guid}"/> of fully-played folder IDs.</returns> + IQueryable<Guid> GetFullyPlayedFolderIdsQuery( + JellyfinDbContext context, + IQueryable<Guid> folderIds, + User user); + + /// <summary> + /// Deserializes a <see cref="BaseItemEntity"/> into a <see cref="BaseItem"/>. + /// </summary> + /// <param name="entity">The database entity.</param> + /// <param name="skipDeserialization">Whether to skip JSON deserialization.</param> + /// <returns>The deserialized item, or null.</returns> + BaseItem? DeserializeBaseItem(BaseItemEntity entity, bool skipDeserialization = false); + + /// <summary> + /// Prepares a filter query by adjusting limits and virtual item settings. + /// </summary> + /// <param name="query">The query to prepare.</param> + void PrepareFilterQuery(InternalItemsQuery query); +} diff --git a/MediaBrowser.Controller/Persistence/IItemRepository.cs b/MediaBrowser.Controller/Persistence/IItemRepository.cs index bf80b7d0a8..291916ab25 100644 --- a/MediaBrowser.Controller/Persistence/IItemRepository.cs +++ b/MediaBrowser.Controller/Persistence/IItemRepository.cs @@ -1,15 +1,11 @@ #nullable disable -#pragma warning disable CS1591 - using System; using System.Collections.Generic; -using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Querying; @@ -21,29 +17,6 @@ namespace MediaBrowser.Controller.Persistence; public interface IItemRepository { /// <summary> - /// Deletes the item. - /// </summary> - /// <param name="ids">The identifier to delete.</param> - void DeleteItem(params IReadOnlyList<Guid> ids); - - /// <summary> - /// Saves the items. - /// </summary> - /// <param name="items">The items.</param> - /// <param name="cancellationToken">The cancellation token.</param> - void SaveItems(IReadOnlyList<BaseItem> items, CancellationToken cancellationToken); - - Task SaveImagesAsync(BaseItem item, CancellationToken cancellationToken = default); - - /// <summary> - /// Reattaches the user data to the item. - /// </summary> - /// <param name="item">The item.</param> - /// <param name="cancellationToken">The cancellation token.</param> - /// <returns>A task that represents the asynchronous reattachment operation.</returns> - Task ReattachUserDataAsync(BaseItem item, CancellationToken cancellationToken); - - /// <summary> /// Retrieves the item. /// </summary> /// <param name="id">The id.</param> @@ -80,62 +53,91 @@ public interface IItemRepository IReadOnlyList<BaseItem> GetLatestItemList(InternalItemsQuery filter, CollectionType collectionType); /// <summary> - /// Gets the list of series presentation keys for next up. + /// Checks if an item has been persisted to the database. /// </summary> - /// <param name="filter">The query.</param> - /// <param name="dateCutoff">The minimum date for a series to have been most recently watched.</param> - /// <returns>The list of keys.</returns> - IReadOnlyList<string> GetNextUpSeriesKeys(InternalItemsQuery filter, DateTime dateCutoff); + /// <param name="id">The id to check.</param> + /// <returns>True if the item exists, otherwise false.</returns> + Task<bool> ItemExistsAsync(Guid id); /// <summary> - /// Updates the inherited values. + /// Gets genres with item counts. /// </summary> - void UpdateInheritedValues(); - - int GetCount(InternalItemsQuery filter); - - ItemCounts GetItemCounts(InternalItemsQuery filter); - + /// <param name="filter">The query filter.</param> + /// <returns>The genres and their item counts.</returns> QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetGenres(InternalItemsQuery filter); + /// <summary> + /// Gets music genres with item counts. + /// </summary> + /// <param name="filter">The query filter.</param> + /// <returns>The music genres and their item counts.</returns> QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetMusicGenres(InternalItemsQuery filter); + /// <summary> + /// Gets studios with item counts. + /// </summary> + /// <param name="filter">The query filter.</param> + /// <returns>The studios and their item counts.</returns> QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetStudios(InternalItemsQuery filter); + /// <summary> + /// Gets artists with item counts. + /// </summary> + /// <param name="filter">The query filter.</param> + /// <returns>The artists and their item counts.</returns> QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetArtists(InternalItemsQuery filter); + /// <summary> + /// Gets album artists with item counts. + /// </summary> + /// <param name="filter">The query filter.</param> + /// <returns>The album artists and their item counts.</returns> QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetAlbumArtists(InternalItemsQuery filter); + /// <summary> + /// Gets all artists with item counts. + /// </summary> + /// <param name="filter">The query filter.</param> + /// <returns>All artists and their item counts.</returns> QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetAllArtists(InternalItemsQuery filter); + /// <summary> + /// Gets all music genre names. + /// </summary> + /// <returns>The list of music genre names.</returns> IReadOnlyList<string> GetMusicGenreNames(); + /// <summary> + /// Gets all studio names. + /// </summary> + /// <returns>The list of studio names.</returns> IReadOnlyList<string> GetStudioNames(); + /// <summary> + /// Gets all genre names. + /// </summary> + /// <returns>The list of genre names.</returns> IReadOnlyList<string> GetGenreNames(); - IReadOnlyList<string> GetAllArtistNames(); - /// <summary> - /// Checks if an item has been persisted to the database. + /// Gets all artist names. /// </summary> - /// <param name="id">The id to check.</param> - /// <returns>True if the item exists, otherwise false.</returns> - Task<bool> ItemExistsAsync(Guid id); + /// <returns>The list of artist names.</returns> + IReadOnlyList<string> GetAllArtistNames(); /// <summary> - /// Gets a value indicating wherever all children of the requested Id has been played. + /// Gets legacy query filters aggregated from the database. /// </summary> - /// <param name="user">The userdata to check against.</param> - /// <param name="id">The Top id to check.</param> - /// <param name="recursive">Whever the check should be done recursive. Warning expensive operation.</param> - /// <returns>A value indicating whever all children has been played.</returns> - bool GetIsPlayed(User user, Guid id, bool recursive); + /// <param name="filter">The query filter.</param> + /// <returns>Aggregated filter values.</returns> + QueryFiltersLegacy GetQueryFiltersLegacy(InternalItemsQuery filter); /// <summary> - /// Gets all artist matches from the db. + /// Gets whether all children of the requested item have been played. /// </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); + /// <param name="user">The user to check against.</param> + /// <param name="id">The top item id to check.</param> + /// <param name="recursive">Whether the check should be done recursively.</param> + /// <returns>A value indicating whether all children have been played.</returns> + bool GetIsPlayed(User user, Guid id, bool recursive); } diff --git a/MediaBrowser.Controller/Persistence/ILinkedChildrenService.cs b/MediaBrowser.Controller/Persistence/ILinkedChildrenService.cs new file mode 100644 index 0000000000..d0cddf54a6 --- /dev/null +++ b/MediaBrowser.Controller/Persistence/ILinkedChildrenService.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; +using MediaBrowser.Controller.Entities.Audio; +using LinkedChildType = MediaBrowser.Controller.Entities.LinkedChildType; + +namespace MediaBrowser.Controller.Persistence; + +/// <summary> +/// Provides linked children query and manipulation operations. +/// </summary> +public interface ILinkedChildrenService +{ + /// <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.</param> + /// <returns>List of child item IDs.</returns> + IReadOnlyList<Guid> GetLinkedChildrenIds(Guid parentId, int? childType = null); + + /// <summary> + /// Gets all artist matches from the database. + /// </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> + /// Gets parent IDs that reference the specified child with LinkedChildType.Manual. + /// </summary> + /// <param name="childId">The child item ID.</param> + /// <returns>List of parent IDs that reference the child.</returns> + IReadOnlyList<Guid> GetManualLinkedParentIds(Guid childId); + + /// <summary> + /// Updates LinkedChildren references from one child to another. + /// </summary> + /// <param name="fromChildId">The child ID to re-route from.</param> + /// <param name="toChildId">The child ID to re-route to.</param> + /// <returns>List of parent item IDs whose LinkedChildren were modified.</returns> + IReadOnlyList<Guid> RerouteLinkedChildren(Guid fromChildId, Guid toChildId); + + /// <summary> + /// Creates or updates a LinkedChild entry. + /// </summary> + /// <param name="parentId">The parent item ID.</param> + /// <param name="childId">The child item ID.</param> + /// <param name="childType">The type of linked child relationship.</param> + void UpsertLinkedChild(Guid parentId, Guid childId, LinkedChildType childType); +} diff --git a/MediaBrowser.Controller/Persistence/INextUpService.cs b/MediaBrowser.Controller/Persistence/INextUpService.cs new file mode 100644 index 0000000000..ade026d0da --- /dev/null +++ b/MediaBrowser.Controller/Persistence/INextUpService.cs @@ -0,0 +1,33 @@ +using System; +using System.Collections.Generic; +using MediaBrowser.Controller.Entities; + +namespace MediaBrowser.Controller.Persistence; + +/// <summary> +/// Provides next-up episode query operations. +/// </summary> +public interface INextUpService +{ + /// <summary> + /// Gets the list of series presentation keys for next up. + /// </summary> + /// <param name="filter">The query.</param> + /// <param name="dateCutoff">The minimum date for a series to have been most recently watched.</param> + /// <returns>The list of keys.</returns> + IReadOnlyList<string> GetNextUpSeriesKeys(InternalItemsQuery filter, DateTime dateCutoff); + + /// <summary> + /// Gets next up episodes for multiple series in a single batched query. + /// </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.</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, NextUpEpisodeBatchResult> GetNextUpEpisodesBatch( + InternalItemsQuery filter, + IReadOnlyList<string> seriesKeys, + bool includeSpecials, + bool includeWatchedForRewatching); +} diff --git a/MediaBrowser.Controller/Persistence/IPeopleRepository.cs b/MediaBrowser.Controller/Persistence/IPeopleRepository.cs index 418289cb4c..a89f3ef9ee 100644 --- a/MediaBrowser.Controller/Persistence/IPeopleRepository.cs +++ b/MediaBrowser.Controller/Persistence/IPeopleRepository.cs @@ -1,13 +1,15 @@ #nullable disable -#pragma warning disable CS1591 - using System; using System.Collections.Generic; using MediaBrowser.Controller.Entities; +using MediaBrowser.Model.Querying; namespace MediaBrowser.Controller.Persistence; +/// <summary> +/// Provides methods for accessing Peoples. +/// </summary> public interface IPeopleRepository { /// <summary> @@ -15,7 +17,7 @@ public interface IPeopleRepository /// </summary> /// <param name="filter">The query.</param> /// <returns>The list of people matching the filter.</returns> - IReadOnlyList<PersonInfo> GetPeople(InternalPeopleQuery filter); + QueryResult<PersonInfo> GetPeople(InternalPeopleQuery filter); /// <summary> /// Updates the people. 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; } +} diff --git a/MediaBrowser.Controller/Providers/DirectoryService.cs b/MediaBrowser.Controller/Providers/DirectoryService.cs index a1edfa3c96..5b5af75a47 100644 --- a/MediaBrowser.Controller/Providers/DirectoryService.cs +++ b/MediaBrowser.Controller/Providers/DirectoryService.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.IO; using System.Linq; using MediaBrowser.Model.IO; @@ -26,7 +27,20 @@ namespace MediaBrowser.Controller.Providers public FileSystemMetadata[] GetFileSystemEntries(string path) { - return _cache.GetOrAdd(path, static (p, fileSystem) => fileSystem.GetFileSystemEntries(p).ToArray(), _fileSystem); + return _cache.GetOrAdd( + path, + static (p, fileSystem) => + { + try + { + return fileSystem.GetFileSystemEntries(p).ToArray(); + } + catch (DirectoryNotFoundException) + { + return []; + } + }, + _fileSystem); } public List<FileSystemMetadata> GetDirectories(string path) @@ -98,7 +112,20 @@ namespace MediaBrowser.Controller.Providers _filePathCache.TryRemove(path, out _); } - var filePaths = _filePathCache.GetOrAdd(path, static (p, fileSystem) => fileSystem.GetFilePaths(p).ToList(), _fileSystem); + var filePaths = _filePathCache.GetOrAdd( + path, + static (p, fileSystem) => + { + try + { + return fileSystem.GetFilePaths(p).ToList(); + } + catch (DirectoryNotFoundException) + { + return []; + } + }, + _fileSystem); if (sort) { |
